5483 lines
195 KiB
TypeScript
5483 lines
195 KiB
TypeScript
import {
|
||
estimateAgentAttachmentSerializedPayloadBytes,
|
||
MAX_AGENT_ATTACHMENT_SERIALIZED_PAYLOAD_BYTES,
|
||
} from '@features/agent-attachments/contracts';
|
||
import { addMainBreadcrumb } from '@main/sentry';
|
||
import { setCurrentMainOp } from '@main/services/infrastructure/EventLoopLagMonitor';
|
||
import { markTeamEngaged } from '@main/services/infrastructure/teamWatchScope';
|
||
import { getTeamDataWorkerClient } from '@main/services/team/TeamDataWorkerClient';
|
||
import { getAppIconPath } from '@main/utils/appIcon';
|
||
import { getAppDataPath, getTeamsBasePath } from '@main/utils/pathDecoder';
|
||
import { safeSendToRenderer } from '@main/utils/safeWebContentsSend';
|
||
import { stripMarkdown } from '@main/utils/textFormatting';
|
||
import {
|
||
TEAM_ADD_MEMBER,
|
||
TEAM_ADD_TASK_COMMENT,
|
||
TEAM_ADD_TASK_RELATIONSHIP,
|
||
TEAM_ALIVE_LIST,
|
||
TEAM_CANCEL_PROVISIONING,
|
||
TEAM_CREATE,
|
||
TEAM_CREATE_CONFIG,
|
||
TEAM_CREATE_INITIAL_GIT_COMMIT,
|
||
TEAM_CREATE_TASK,
|
||
TEAM_DELETE_DRAFT,
|
||
TEAM_DELETE_TASK_ATTACHMENT,
|
||
TEAM_DELETE_TEAM,
|
||
TEAM_GET_AGENT_RUNTIME,
|
||
TEAM_GET_ALL_TASKS,
|
||
TEAM_GET_ATTACHMENTS,
|
||
TEAM_GET_CLAUDE_LOGS,
|
||
TEAM_GET_DATA,
|
||
TEAM_GET_DELETED_TASKS,
|
||
TEAM_GET_LOGS_FOR_TASK,
|
||
TEAM_GET_MEMBER_ACTIVITY_META,
|
||
TEAM_GET_MEMBER_LOGS,
|
||
TEAM_GET_MEMBER_STATS,
|
||
TEAM_GET_MESSAGES_PAGE,
|
||
TEAM_GET_OPENCODE_RUNTIME_DELIVERY_STATUS,
|
||
TEAM_GET_PROJECT_BRANCH,
|
||
TEAM_GET_SAVED_REQUEST,
|
||
TEAM_GET_TASK_ACTIVITY,
|
||
TEAM_GET_TASK_ACTIVITY_DETAIL,
|
||
TEAM_GET_TASK_ATTACHMENT,
|
||
TEAM_GET_TASK_CHANGE_PRESENCE,
|
||
TEAM_GET_TASK_EXACT_LOG_DETAIL,
|
||
TEAM_GET_TASK_EXACT_LOG_SUMMARIES,
|
||
TEAM_GET_TASK_LOG_STREAM,
|
||
TEAM_GET_TASK_LOG_STREAM_SUMMARY,
|
||
TEAM_GET_WORKTREE_GIT_STATUS,
|
||
TEAM_INITIALIZE_GIT_REPOSITORY,
|
||
TEAM_KILL_PROCESS,
|
||
TEAM_LAUNCH,
|
||
TEAM_LAUNCH_FAILURE_DIAGNOSTICS,
|
||
TEAM_LEAD_ACTIVITY,
|
||
TEAM_LEAD_CONTEXT,
|
||
TEAM_LIST,
|
||
TEAM_MEMBER_SPAWN_STATUSES,
|
||
TEAM_PERMANENTLY_DELETE,
|
||
TEAM_PREPARE_PROVISIONING,
|
||
TEAM_PROCESS_ALIVE,
|
||
TEAM_PROCESS_SEND,
|
||
TEAM_PROVISIONING_PROGRESS,
|
||
TEAM_PROVISIONING_STATUS,
|
||
TEAM_REMOVE_MEMBER,
|
||
TEAM_REMOVE_TASK_RELATIONSHIP,
|
||
TEAM_REPLACE_MEMBERS,
|
||
TEAM_REQUEST_REVIEW,
|
||
TEAM_RESTART_MEMBER,
|
||
TEAM_RESTORE,
|
||
TEAM_RESTORE_MEMBER,
|
||
TEAM_RESTORE_TASK,
|
||
TEAM_RETRY_FAILED_OPENCODE_SECONDARY_LANES,
|
||
TEAM_SAVE_TASK_ATTACHMENT,
|
||
TEAM_SEND_MESSAGE,
|
||
TEAM_SET_CHANGE_PRESENCE_TRACKING,
|
||
TEAM_SET_PROJECT_BRANCH_TRACKING,
|
||
TEAM_SET_TASK_CLARIFICATION,
|
||
TEAM_SET_TASK_LOG_STREAM_TRACKING,
|
||
TEAM_SET_TOOL_ACTIVITY_TRACKING,
|
||
TEAM_SHOW_MESSAGE_NOTIFICATION,
|
||
TEAM_SKIP_MEMBER_FOR_LAUNCH,
|
||
TEAM_SOFT_DELETE_TASK,
|
||
TEAM_START_TASK,
|
||
TEAM_START_TASK_BY_USER,
|
||
TEAM_STOP,
|
||
TEAM_TOOL_APPROVAL_READ_FILE,
|
||
TEAM_TOOL_APPROVAL_RESPOND,
|
||
TEAM_TOOL_APPROVAL_SETTINGS,
|
||
TEAM_UPDATE_CONFIG,
|
||
TEAM_UPDATE_KANBAN,
|
||
TEAM_UPDATE_KANBAN_COLUMN_ORDER,
|
||
TEAM_UPDATE_MEMBER_ROLE,
|
||
TEAM_UPDATE_TASK_FIELDS,
|
||
TEAM_UPDATE_TASK_OWNER,
|
||
TEAM_UPDATE_TASK_STATUS,
|
||
TEAM_VALIDATE_CLI_ARGS,
|
||
// eslint-disable-next-line boundaries/element-types -- IPC channel constants are shared between main and preload by design
|
||
} from '@preload/constants/ipcChannels';
|
||
import { wrapAgentBlock } from '@shared/constants/agentBlocks';
|
||
import { KANBAN_COLUMN_IDS } from '@shared/constants/kanban';
|
||
import { MAX_TEXT_LENGTH } from '@shared/constants/teamLimits';
|
||
import {
|
||
extractFlagsFromHelp,
|
||
extractUserFlags,
|
||
PROTECTED_CLI_FLAGS,
|
||
} from '@shared/utils/cliArgsParser';
|
||
import {
|
||
formatEffortLevelListForProvider,
|
||
isTeamEffortLevelForProvider,
|
||
} from '@shared/utils/effortLevels';
|
||
import { getErrorMessage } from '@shared/utils/errorHandling';
|
||
import { isLeadMember } from '@shared/utils/leadDetection';
|
||
import { createLogger } from '@shared/utils/logger';
|
||
import { isTeamProviderBackendId, migrateProviderBackendId } from '@shared/utils/providerBackend';
|
||
import {
|
||
buildStandaloneSlashCommandMeta,
|
||
parseStandaloneSlashCommand,
|
||
} from '@shared/utils/slashCommands';
|
||
import { normalizeTeamMemberMcpPolicy } from '@shared/utils/teamMemberMcpPolicy';
|
||
import { isTeamProviderId, normalizeOptionalTeamProviderId } from '@shared/utils/teamProvider';
|
||
import crypto from 'crypto';
|
||
import { app, BrowserWindow, type IpcMain, type IpcMainInvokeEvent, Notification } from 'electron';
|
||
import * as fs from 'fs';
|
||
import * as path from 'path';
|
||
|
||
import { ConfigManager } from '../services/infrastructure/ConfigManager';
|
||
import { NotificationManager } from '../services/infrastructure/NotificationManager';
|
||
import { gitIdentityResolver } from '../services/parsing/GitIdentityResolver';
|
||
import {
|
||
buildActionModeAgentBlock,
|
||
isAgentActionMode,
|
||
} from '../services/team/actionModeInstructions';
|
||
import {
|
||
getAutoResumeService,
|
||
initializeAutoResumeService,
|
||
} from '../services/team/AutoResumeService';
|
||
import {
|
||
cloneLaunchIoGovernorPayload,
|
||
type LaunchIoGovernor,
|
||
} from '../services/team/LaunchIoGovernor';
|
||
import {
|
||
buildReplaceMembersDiff,
|
||
buildReplaceMembersSummaryMessage,
|
||
} from '../services/team/memberUpdateNotifications';
|
||
import { mergeLiveLeadProcessMessages } from '../services/team/mergeLiveLeadProcessMessages';
|
||
import { TeamAttachmentStore } from '../services/team/TeamAttachmentStore';
|
||
import { TeamConfigReader } from '../services/team/TeamConfigReader';
|
||
import { readTeamLaunchFailureDiagnosticsBundle } from '../services/team/TeamLaunchFailureArtifactPack';
|
||
import { TeamMembersMetaStore } from '../services/team/TeamMembersMetaStore';
|
||
import { TeamMetaStore } from '../services/team/TeamMetaStore';
|
||
import { TeamTaskAttachmentStore } from '../services/team/TeamTaskAttachmentStore';
|
||
import { TeamWorktreeGitService } from '../services/team/TeamWorktreeGitService';
|
||
|
||
import { teamMessageNotificationScanner } from './teams/teamMessageNotificationScanner';
|
||
import {
|
||
validateFromField,
|
||
validateMemberName,
|
||
validateTaskId,
|
||
validateTeammateName,
|
||
validateTeamName,
|
||
} from './guards';
|
||
|
||
import type {
|
||
BoardTaskActivityDetailService,
|
||
BoardTaskActivityService,
|
||
BoardTaskExactLogDetailService,
|
||
BoardTaskExactLogsService,
|
||
BoardTaskLogStreamService,
|
||
BranchStatusService,
|
||
MemberStatsComputer,
|
||
TeamDataService,
|
||
TeamLogSourceTracker,
|
||
TeammateToolTracker,
|
||
TeamMemberLogsFinder,
|
||
TeamProvisioningService,
|
||
} from '../services';
|
||
import type { TeamBackupService } from '../services/team/TeamBackupService';
|
||
import type { TeamMembersMetaFile } from '../services/team/TeamMembersMetaStore';
|
||
import type {
|
||
AddTaskCommentRequest,
|
||
AgentActionMode,
|
||
AttachmentFileData,
|
||
AttachmentMeta,
|
||
AttachmentPayload,
|
||
BoardTaskActivityDetailResult,
|
||
BoardTaskActivityEntry,
|
||
BoardTaskExactLogDetailResult,
|
||
BoardTaskExactLogSummariesResponse,
|
||
BoardTaskLogStreamResponse,
|
||
BoardTaskLogStreamSummary,
|
||
CreateTaskRequest,
|
||
EffortLevel,
|
||
GlobalTask,
|
||
InboxMessage,
|
||
IpcResult,
|
||
KanbanColumnId,
|
||
LeadActivitySnapshot,
|
||
LeadContextUsageSnapshot,
|
||
MemberFullStats,
|
||
MemberLogSummary,
|
||
MemberSpawnStatusesSnapshot,
|
||
MessagesPage,
|
||
OpenCodeRuntimeDeliveryStatus,
|
||
RetryFailedOpenCodeSecondaryLanesResult,
|
||
SendMessageRequest,
|
||
SendMessageResult,
|
||
TaskAttachmentMeta,
|
||
TaskChangePresenceState,
|
||
TaskComment,
|
||
TaskRef,
|
||
TeamAgentRuntimeSnapshot,
|
||
TeamClaudeLogsQuery,
|
||
TeamClaudeLogsResponse,
|
||
TeamConfig,
|
||
TeamCreateConfigRequest,
|
||
TeamCreateRequest,
|
||
TeamCreateResponse,
|
||
TeamFastMode,
|
||
TeamGetDataOptions,
|
||
TeamLaunchFailureDiagnosticsBundle,
|
||
TeamLaunchRequest,
|
||
TeamLaunchResponse,
|
||
TeamMemberActivityMeta,
|
||
TeamMessageNotificationData,
|
||
TeamProviderBackendId,
|
||
TeamProviderId,
|
||
TeamProvisioningModelCheckRequest,
|
||
TeamProvisioningModelVerificationMode,
|
||
TeamProvisioningPrepareResult,
|
||
TeamProvisioningProgress,
|
||
TeamSummary,
|
||
TeamTask,
|
||
TeamTaskStatus,
|
||
TeamUpdateConfigRequest,
|
||
TeamViewSnapshot,
|
||
TeamWorktreeGitStatus,
|
||
ToolApprovalFileContent,
|
||
ToolApprovalSettings,
|
||
UpdateKanbanPatch,
|
||
} from '@shared/types';
|
||
import type { CliArgsValidationResult } from '@shared/utils/cliArgsParser';
|
||
|
||
const logger = createLogger('IPC:teams');
|
||
// Runtime relay continues in the background after this race; keep sendMessage IPC off the
|
||
// 25s OpenCode turn-settled guard while still giving prompt acceptance/reconcile time.
|
||
const OPENCODE_RUNTIME_DELIVERY_UI_TIMEOUT_MS = 6_000;
|
||
const OPENCODE_RUNTIME_DELIVERY_STATUS_AFTER_UI_TIMEOUT_MS = 1_000;
|
||
const OPENCODE_RUNTIME_DELIVERY_UI_TIMEOUT_PENDING_REASON =
|
||
'opencode_runtime_delivery_ui_timeout_pending';
|
||
|
||
type OpenCodeMemberInboxRelayResult = Awaited<
|
||
ReturnType<TeamProvisioningService['relayOpenCodeMemberInboxMessages']>
|
||
>;
|
||
type OpenCodeMemberInboxDelivery = NonNullable<OpenCodeMemberInboxRelayResult['lastDelivery']>;
|
||
|
||
type VisibleDirectReplyProtocol = 'send_message' | 'agent_teams_message_send';
|
||
|
||
function resolveVisibleDirectReplyProtocol(input: {
|
||
providerId?: TeamProviderId;
|
||
isLeadRecipient: boolean;
|
||
replyRecipient: string;
|
||
}): VisibleDirectReplyProtocol {
|
||
if (
|
||
!input.isLeadRecipient &&
|
||
input.replyRecipient.trim().toLowerCase() === 'user' &&
|
||
input.providerId === 'codex'
|
||
) {
|
||
return 'agent_teams_message_send';
|
||
}
|
||
|
||
return 'send_message';
|
||
}
|
||
const TEAM_DATA_DRAFT_CLASSIFICATION_ACCESS_TIMEOUT_MS = 250;
|
||
|
||
function isPlainObject(value: unknown): value is Record<string, unknown> {
|
||
if (value == null || typeof value !== 'object') {
|
||
return false;
|
||
}
|
||
const prototype = Object.getPrototypeOf(value);
|
||
return prototype === Object.prototype || prototype === null;
|
||
}
|
||
|
||
function validateTeamGetDataOptions(
|
||
value: unknown
|
||
): { valid: true; value: TeamGetDataOptions | undefined } | { valid: false; error: string } {
|
||
if (value === undefined) {
|
||
return { valid: true, value: undefined };
|
||
}
|
||
if (!isPlainObject(value)) {
|
||
return { valid: false, error: 'options must be an object' };
|
||
}
|
||
|
||
const allowed = new Set(['includeMemberBranches']);
|
||
for (const key of Object.keys(value)) {
|
||
if (!allowed.has(key)) {
|
||
return { valid: false, error: `Unknown getData option: ${key}` };
|
||
}
|
||
}
|
||
|
||
const includeMemberBranches = value.includeMemberBranches;
|
||
if (includeMemberBranches !== undefined && typeof includeMemberBranches !== 'boolean') {
|
||
return { valid: false, error: 'includeMemberBranches must be a boolean' };
|
||
}
|
||
|
||
return {
|
||
valid: true,
|
||
value: includeMemberBranches === false ? { includeMemberBranches: false } : undefined,
|
||
};
|
||
}
|
||
|
||
async function withTimeoutValue<T>(
|
||
promise: Promise<T>,
|
||
timeoutMs: number,
|
||
timeoutValue: T
|
||
): Promise<T> {
|
||
let timer: ReturnType<typeof setTimeout> | null = null;
|
||
try {
|
||
return await Promise.race([
|
||
promise,
|
||
new Promise<T>((resolve) => {
|
||
timer = setTimeout(() => resolve(timeoutValue), timeoutMs);
|
||
timer.unref?.();
|
||
}),
|
||
]);
|
||
} finally {
|
||
if (timer) {
|
||
clearTimeout(timer);
|
||
}
|
||
}
|
||
}
|
||
|
||
async function waitForOpenCodeRuntimeRelayForUi(input: {
|
||
provisioning: TeamProvisioningService;
|
||
teamName: string;
|
||
memberName: string;
|
||
messageId: string;
|
||
relayPromise: Promise<OpenCodeMemberInboxRelayResult>;
|
||
timeoutMs?: number;
|
||
}): Promise<OpenCodeMemberInboxRelayResult> {
|
||
let timer: ReturnType<typeof setTimeout> | null = null;
|
||
let timedOut = false;
|
||
|
||
void input.relayPromise.then(
|
||
(relay) => {
|
||
if (!timedOut) return;
|
||
const delivery = relay.lastDelivery;
|
||
if (delivery && !delivery.delivered && delivery.reason !== 'recipient_is_not_opencode') {
|
||
logger.warn(
|
||
`OpenCode runtime delivery after sendMessage completed after UI timeout for teammate "${input.memberName}" with failure: ${
|
||
delivery.reason ?? 'unknown error'
|
||
}`
|
||
);
|
||
}
|
||
},
|
||
(error: unknown) => {
|
||
if (!timedOut) return;
|
||
logger.warn(
|
||
`OpenCode runtime delivery after sendMessage rejected after UI timeout for teammate "${input.memberName}": ${getErrorMessage(error)}`
|
||
);
|
||
}
|
||
);
|
||
|
||
try {
|
||
const outcome = await Promise.race<
|
||
{ kind: 'relay'; relay: OpenCodeMemberInboxRelayResult } | { kind: 'timeout' }
|
||
>([
|
||
input.relayPromise.then((relay) => ({ kind: 'relay' as const, relay })),
|
||
new Promise<{ kind: 'timeout' }>((resolve) => {
|
||
timer = setTimeout(() => {
|
||
timedOut = true;
|
||
resolve({ kind: 'timeout' });
|
||
}, input.timeoutMs ?? OPENCODE_RUNTIME_DELIVERY_UI_TIMEOUT_MS);
|
||
timer.unref?.();
|
||
}),
|
||
]);
|
||
|
||
if (outcome.kind === 'relay') {
|
||
return outcome.relay;
|
||
}
|
||
|
||
try {
|
||
const status = await withTimeoutValue(
|
||
input.provisioning.getOpenCodeRuntimeDeliveryStatus(input.teamName, input.messageId),
|
||
OPENCODE_RUNTIME_DELIVERY_STATUS_AFTER_UI_TIMEOUT_MS,
|
||
null
|
||
);
|
||
if (status) {
|
||
return openCodeRuntimeDeliveryStatusToRelayResult(status);
|
||
}
|
||
} catch (error) {
|
||
const reason = getErrorMessage(error);
|
||
logger.warn(
|
||
`OpenCode runtime delivery status after UI timeout failed for teammate "${input.memberName}": ${reason}`
|
||
);
|
||
return buildOpenCodeRuntimeDeliveryUiTimeoutRelayResult([
|
||
`${OPENCODE_RUNTIME_DELIVERY_UI_TIMEOUT_PENDING_REASON}: status lookup failed: ${reason}`,
|
||
]);
|
||
}
|
||
|
||
return buildOpenCodeRuntimeDeliveryUiTimeoutRelayResult();
|
||
} finally {
|
||
if (timer) {
|
||
clearTimeout(timer);
|
||
}
|
||
}
|
||
}
|
||
|
||
function openCodeRuntimeDeliveryStatusToRelayResult(
|
||
status: OpenCodeRuntimeDeliveryStatus
|
||
): OpenCodeMemberInboxRelayResult {
|
||
const lastDelivery: OpenCodeMemberInboxDelivery = {
|
||
delivered: status.delivered,
|
||
...(typeof status.responsePending === 'boolean'
|
||
? { responsePending: status.responsePending }
|
||
: {}),
|
||
...(typeof status.acceptanceUnknown === 'boolean'
|
||
? { acceptanceUnknown: status.acceptanceUnknown }
|
||
: {}),
|
||
...(status.responseState ? { responseState: status.responseState } : {}),
|
||
...(status.ledgerStatus ? { ledgerStatus: status.ledgerStatus } : {}),
|
||
...(status.visibleReplyMessageId
|
||
? { visibleReplyMessageId: status.visibleReplyMessageId }
|
||
: {}),
|
||
...(status.visibleReplyCorrelation
|
||
? { visibleReplyCorrelation: status.visibleReplyCorrelation }
|
||
: {}),
|
||
...(status.queuedBehindMessageId
|
||
? { queuedBehindMessageId: status.queuedBehindMessageId }
|
||
: {}),
|
||
...(status.reason ? { reason: status.reason } : {}),
|
||
...(status.diagnostics ? { diagnostics: status.diagnostics } : {}),
|
||
...(shouldPreserveOpenCodeRuntimeDeliveryStatusImpact(status)
|
||
? { userVisibleImpact: status.userVisibleImpact }
|
||
: {}),
|
||
};
|
||
return {
|
||
relayed: 0,
|
||
attempted: 1,
|
||
delivered: status.delivered && status.responsePending !== true ? 1 : 0,
|
||
failed: status.delivered ? 0 : 1,
|
||
lastDelivery,
|
||
diagnostics: status.diagnostics,
|
||
};
|
||
}
|
||
|
||
function shouldPreserveOpenCodeRuntimeDeliveryStatusImpact(
|
||
status: OpenCodeRuntimeDeliveryStatus
|
||
): boolean {
|
||
if (!status.userVisibleImpact) {
|
||
return false;
|
||
}
|
||
if (
|
||
status.userVisibleImpact.state === 'none' &&
|
||
(status.responsePending === true ||
|
||
status.acceptanceUnknown === true ||
|
||
Boolean(status.queuedBehindMessageId))
|
||
) {
|
||
return false;
|
||
}
|
||
return true;
|
||
}
|
||
|
||
function buildOpenCodeRuntimeDeliveryUiTimeoutRelayResult(
|
||
extraDiagnostics: string[] = []
|
||
): OpenCodeMemberInboxRelayResult {
|
||
const diagnostics = [OPENCODE_RUNTIME_DELIVERY_UI_TIMEOUT_PENDING_REASON, ...extraDiagnostics];
|
||
return {
|
||
relayed: 0,
|
||
attempted: 1,
|
||
delivered: 0,
|
||
failed: 1,
|
||
lastDelivery: {
|
||
delivered: true,
|
||
accepted: false,
|
||
responsePending: true,
|
||
acceptanceUnknown: true,
|
||
responseState: 'not_observed',
|
||
reason: OPENCODE_RUNTIME_DELIVERY_UI_TIMEOUT_PENDING_REASON,
|
||
diagnostics,
|
||
},
|
||
};
|
||
}
|
||
|
||
function noteHeavyTeamDataWorkerFallback(operation: string): void {
|
||
if (!app.isPackaged) {
|
||
return;
|
||
}
|
||
|
||
logger.error(
|
||
`[${operation}] team-data-worker unavailable in packaged runtime; falling back to main-thread execution for heavy message/activity path`
|
||
);
|
||
}
|
||
|
||
async function getNewestMessagesPageWithLiveOverlay(input: {
|
||
teamName: string;
|
||
limit: number;
|
||
liveMessages: InboxMessage[];
|
||
includeUndefinedCursorInFallback?: boolean;
|
||
}): Promise<MessagesPage> {
|
||
const { teamName, limit, liveMessages } = input;
|
||
const worker = getTeamDataWorkerClient();
|
||
const options = input.includeUndefinedCursorInFallback
|
||
? { cursor: undefined, limit, liveMessages }
|
||
: { limit, liveMessages };
|
||
if (worker.isAvailable()) {
|
||
try {
|
||
return await worker.getMessagesPage(teamName, options);
|
||
} catch (workerErr) {
|
||
logger.warn(
|
||
`[teams:getMessagesPage] worker failed for live overlay, falling back: ${
|
||
workerErr instanceof Error ? workerErr.message : workerErr
|
||
}`
|
||
);
|
||
}
|
||
}
|
||
|
||
noteHeavyTeamDataWorkerFallback('teams:getMessagesPage.liveOverlay');
|
||
return getTeamDataService().getMessagesPage(teamName, options);
|
||
}
|
||
|
||
function invalidateTeamRosterSnapshotCaches(teamName: string): void {
|
||
TeamConfigReader.invalidateTeam(teamName);
|
||
const teamDataService = getTeamDataService();
|
||
teamDataService.invalidateMessageFeed(teamName);
|
||
teamDataService.invalidateTeamRuntimeAdvisories(teamName);
|
||
const workerClient = getTeamDataWorkerClient();
|
||
workerClient.invalidateTeamConfig(teamName);
|
||
workerClient.invalidateMemberRuntimeAdvisory(teamName);
|
||
}
|
||
|
||
async function getDurableLeadTeammateRoster(
|
||
teamName: string,
|
||
leadName: string
|
||
): Promise<{ name: string; role?: string }[]> {
|
||
const normalize = (name: string | undefined | null): string => name?.trim().toLowerCase() ?? '';
|
||
const leadLower = normalize(leadName);
|
||
const reserved = new Set(['team-lead', 'user', leadLower].filter((value) => value.length > 0));
|
||
|
||
try {
|
||
const members = await new TeamMembersMetaStore().getMembers(teamName);
|
||
const teammates = members
|
||
.filter((member) => !member.removedAt)
|
||
.filter((member) => {
|
||
const lower = normalize(member.name);
|
||
return lower.length > 0 && !reserved.has(lower);
|
||
})
|
||
.map((member) => ({
|
||
name: member.name.trim(),
|
||
role:
|
||
typeof member.role === 'string' && member.role.trim().length > 0
|
||
? member.role.trim()
|
||
: undefined,
|
||
}));
|
||
if (teammates.length > 0) return teammates;
|
||
} catch (error) {
|
||
logger.debug(
|
||
`[teams:sendMessage] Failed to read members.meta roster for "${teamName}": ${
|
||
error instanceof Error ? error.message : String(error)
|
||
}`
|
||
);
|
||
}
|
||
|
||
try {
|
||
const data = await getTeamDataService().getTeamData(teamName);
|
||
return data.members
|
||
.filter((member) => !member.removedAt)
|
||
.filter((member) => {
|
||
const lower = normalize(member.name);
|
||
return lower.length > 0 && !reserved.has(lower);
|
||
})
|
||
.map((member) => ({
|
||
name: member.name.trim(),
|
||
role:
|
||
typeof member.role === 'string' && member.role.trim().length > 0
|
||
? member.role.trim()
|
||
: undefined,
|
||
}));
|
||
} catch (error) {
|
||
logger.debug(
|
||
`[teams:sendMessage] Failed to read fallback team roster for "${teamName}": ${
|
||
error instanceof Error ? error.message : String(error)
|
||
}`
|
||
);
|
||
return [];
|
||
}
|
||
}
|
||
|
||
function buildLeadRosterContextBlock(
|
||
teamName: string,
|
||
leadName: string,
|
||
teammates: { name: string; role?: string }[]
|
||
): string | null {
|
||
if (teammates.length === 0) return null;
|
||
|
||
const summary = teammates
|
||
.map((member) => (member.role ? `${member.name} (${member.role})` : member.name))
|
||
.join(', ');
|
||
|
||
return [
|
||
`Current durable team context:`,
|
||
`- Team name: ${teamName}`,
|
||
`- You are the live team lead "${leadName}"`,
|
||
`- Persistent teammates currently configured: ${summary}`,
|
||
`- This team is NOT in solo mode`,
|
||
`- If the user asks who is on the team, answer from this durable roster unless newer durable state explicitly says otherwise.`,
|
||
].join('\n');
|
||
}
|
||
|
||
function buildLeadDirectDelegateAckBlock(actionMode?: AgentActionMode): string | null {
|
||
if (actionMode !== 'delegate') return null;
|
||
|
||
return wrapAgentBlock(
|
||
[
|
||
'DELEGATE MODE USER ACK CONTRACT:',
|
||
'Before any task creation, delegation, or other tool use, begin your next assistant response with one short human-readable acknowledgement to the user.',
|
||
'That acknowledgement must be visible plain text, not only an agent-only block.',
|
||
'Make the acknowledgement at least 40 characters so it is preserved in the Messages panel.',
|
||
'After that visible acknowledgement, continue with delegation/orchestration in the same turn.',
|
||
].join('\n')
|
||
);
|
||
}
|
||
|
||
let teamDataService: TeamDataService | null = null;
|
||
let teamProvisioningService: TeamProvisioningService | null = null;
|
||
let teamMemberLogsFinder: TeamMemberLogsFinder | null = null;
|
||
let memberStatsComputer: MemberStatsComputer | null = null;
|
||
let teamBackupService: TeamBackupService | null = null;
|
||
let teammateToolTracker: TeammateToolTracker | null = null;
|
||
let teamLogSourceTracker: TeamLogSourceTracker | null = null;
|
||
let branchStatusService: BranchStatusService | null = null;
|
||
let launchIoGovernor: LaunchIoGovernor | null = null;
|
||
let boardTaskActivityService: BoardTaskActivityService | null = null;
|
||
let boardTaskActivityDetailService: BoardTaskActivityDetailService | null = null;
|
||
let boardTaskLogStreamService: BoardTaskLogStreamService | null = null;
|
||
let boardTaskExactLogsService: BoardTaskExactLogsService | null = null;
|
||
let boardTaskExactLogDetailService: BoardTaskExactLogDetailService | null = null;
|
||
|
||
const attachmentStore = new TeamAttachmentStore();
|
||
const taskAttachmentStore = new TeamTaskAttachmentStore();
|
||
const teamMetaStore = new TeamMetaStore();
|
||
const worktreeGitService = new TeamWorktreeGitService();
|
||
|
||
const ALLOWED_ATTACHMENT_TYPES = new Set([
|
||
'image/png',
|
||
'image/jpeg',
|
||
'image/gif',
|
||
'image/webp',
|
||
'application/pdf',
|
||
'text/plain',
|
||
]);
|
||
const MAX_ATTACHMENT_SIZE = 10 * 1024 * 1024; // 10MB per file
|
||
|
||
function isValidStoredAttachmentMimeType(value: unknown): value is string {
|
||
if (typeof value !== 'string') return false;
|
||
const v = value.trim();
|
||
if (!v) return false;
|
||
if (v.length > 200) return false;
|
||
if (v.includes('\0') || /[\r\n]/.test(v)) return false;
|
||
const slash = v.indexOf('/');
|
||
return slash > 0 && slash < v.length - 1;
|
||
}
|
||
|
||
/**
|
||
* Prevents GC from collecting Notification objects in the deprecated showTeamNativeNotification.
|
||
* @see https://blog.bloomca.me/2025/02/22/electron-mac-notifications.html
|
||
*/
|
||
const activeTeamNotifications = new Set<Notification>();
|
||
const MAX_ATTACHMENTS = 5;
|
||
const MAX_TOTAL_ATTACHMENT_SIZE = 20 * 1024 * 1024; // 20MB total
|
||
|
||
export function initializeTeamHandlers(
|
||
service: TeamDataService,
|
||
provisioningService: TeamProvisioningService,
|
||
logsFinder?: TeamMemberLogsFinder,
|
||
statsComputer?: MemberStatsComputer,
|
||
backupService?: TeamBackupService,
|
||
toolTracker?: TeammateToolTracker,
|
||
logSourceTracker?: TeamLogSourceTracker,
|
||
branchTracker?: BranchStatusService,
|
||
taskActivityService?: BoardTaskActivityService,
|
||
taskActivityDetailService?: BoardTaskActivityDetailService,
|
||
taskLogStreamService?: BoardTaskLogStreamService,
|
||
taskExactLogsService?: BoardTaskExactLogsService,
|
||
taskExactLogDetailService?: BoardTaskExactLogDetailService,
|
||
ioGovernor?: LaunchIoGovernor
|
||
): void {
|
||
teamDataService = service;
|
||
teamProvisioningService = provisioningService;
|
||
initializeAutoResumeService(provisioningService);
|
||
teamMemberLogsFinder = logsFinder ?? null;
|
||
memberStatsComputer = statsComputer ?? null;
|
||
teamBackupService = backupService ?? null;
|
||
teammateToolTracker = toolTracker ?? null;
|
||
teamLogSourceTracker = logSourceTracker ?? null;
|
||
branchStatusService = branchTracker ?? null;
|
||
launchIoGovernor = ioGovernor ?? null;
|
||
boardTaskActivityService = taskActivityService ?? null;
|
||
boardTaskActivityDetailService = taskActivityDetailService ?? null;
|
||
boardTaskLogStreamService = taskLogStreamService ?? null;
|
||
boardTaskExactLogsService = taskExactLogsService ?? null;
|
||
boardTaskExactLogDetailService = taskExactLogDetailService ?? null;
|
||
}
|
||
|
||
export function registerTeamHandlers(ipcMain: IpcMain): void {
|
||
ipcMain.handle(TEAM_LIST, handleListTeams);
|
||
ipcMain.handle(TEAM_GET_DATA, handleGetData);
|
||
ipcMain.handle(TEAM_GET_TASK_CHANGE_PRESENCE, handleGetTaskChangePresence);
|
||
ipcMain.handle(TEAM_SET_CHANGE_PRESENCE_TRACKING, handleSetChangePresenceTracking);
|
||
ipcMain.handle(TEAM_SET_PROJECT_BRANCH_TRACKING, handleSetProjectBranchTracking);
|
||
ipcMain.handle(TEAM_SET_TASK_LOG_STREAM_TRACKING, handleSetTaskLogStreamTracking);
|
||
ipcMain.handle(TEAM_SET_TOOL_ACTIVITY_TRACKING, handleSetToolActivityTracking);
|
||
ipcMain.handle(TEAM_GET_CLAUDE_LOGS, handleGetClaudeLogs);
|
||
ipcMain.handle(TEAM_PREPARE_PROVISIONING, handlePrepareProvisioning);
|
||
ipcMain.handle(TEAM_GET_WORKTREE_GIT_STATUS, handleGetWorktreeGitStatus);
|
||
ipcMain.handle(TEAM_INITIALIZE_GIT_REPOSITORY, handleInitializeGitRepository);
|
||
ipcMain.handle(TEAM_CREATE_INITIAL_GIT_COMMIT, handleCreateInitialGitCommit);
|
||
ipcMain.handle(TEAM_CREATE, handleCreateTeam);
|
||
ipcMain.handle(TEAM_LAUNCH, handleLaunchTeam);
|
||
ipcMain.handle(TEAM_PROVISIONING_STATUS, handleProvisioningStatus);
|
||
ipcMain.handle(TEAM_LAUNCH_FAILURE_DIAGNOSTICS, handleLaunchFailureDiagnostics);
|
||
ipcMain.handle(TEAM_CANCEL_PROVISIONING, handleCancelProvisioning);
|
||
ipcMain.handle(TEAM_SEND_MESSAGE, handleSendMessage);
|
||
ipcMain.handle(TEAM_GET_OPENCODE_RUNTIME_DELIVERY_STATUS, handleGetOpenCodeRuntimeDeliveryStatus);
|
||
ipcMain.handle(TEAM_GET_MESSAGES_PAGE, handleGetMessagesPage);
|
||
ipcMain.handle(TEAM_GET_MEMBER_ACTIVITY_META, handleGetMemberActivityMeta);
|
||
ipcMain.handle(TEAM_CREATE_TASK, handleCreateTask);
|
||
ipcMain.handle(TEAM_REQUEST_REVIEW, handleRequestReview);
|
||
ipcMain.handle(TEAM_UPDATE_KANBAN, handleUpdateKanban);
|
||
ipcMain.handle(TEAM_UPDATE_KANBAN_COLUMN_ORDER, handleUpdateKanbanColumnOrder);
|
||
ipcMain.handle(TEAM_UPDATE_TASK_STATUS, handleUpdateTaskStatus);
|
||
ipcMain.handle(TEAM_UPDATE_TASK_OWNER, handleUpdateTaskOwner);
|
||
ipcMain.handle(TEAM_UPDATE_TASK_FIELDS, handleUpdateTaskFields);
|
||
ipcMain.handle(TEAM_DELETE_TEAM, handleDeleteTeam);
|
||
ipcMain.handle(TEAM_RESTORE, handleRestoreTeam);
|
||
ipcMain.handle(TEAM_PERMANENTLY_DELETE, handlePermanentlyDeleteTeam);
|
||
ipcMain.handle(TEAM_PROCESS_SEND, handleProcessSend);
|
||
ipcMain.handle(TEAM_PROCESS_ALIVE, handleProcessAlive);
|
||
ipcMain.handle(TEAM_ALIVE_LIST, handleAliveList);
|
||
ipcMain.handle(TEAM_STOP, handleStopTeam);
|
||
ipcMain.handle(TEAM_CREATE_CONFIG, handleCreateConfig);
|
||
ipcMain.handle(TEAM_GET_MEMBER_LOGS, handleGetMemberLogs);
|
||
ipcMain.handle(TEAM_GET_LOGS_FOR_TASK, handleGetLogsForTask);
|
||
ipcMain.handle(TEAM_GET_TASK_ACTIVITY, handleGetTaskActivity);
|
||
ipcMain.handle(TEAM_GET_TASK_ACTIVITY_DETAIL, handleGetTaskActivityDetail);
|
||
ipcMain.handle(TEAM_GET_TASK_LOG_STREAM_SUMMARY, handleGetTaskLogStreamSummary);
|
||
ipcMain.handle(TEAM_GET_TASK_LOG_STREAM, handleGetTaskLogStream);
|
||
ipcMain.handle(TEAM_GET_TASK_EXACT_LOG_SUMMARIES, handleGetTaskExactLogSummaries);
|
||
ipcMain.handle(TEAM_GET_TASK_EXACT_LOG_DETAIL, handleGetTaskExactLogDetail);
|
||
ipcMain.handle(TEAM_GET_MEMBER_STATS, handleGetMemberStats);
|
||
ipcMain.handle(TEAM_UPDATE_CONFIG, handleUpdateConfig);
|
||
ipcMain.handle(TEAM_START_TASK, handleStartTask);
|
||
ipcMain.handle(TEAM_START_TASK_BY_USER, handleStartTaskByUser);
|
||
ipcMain.handle(TEAM_GET_ALL_TASKS, handleGetAllTasks);
|
||
ipcMain.handle(TEAM_ADD_TASK_COMMENT, handleAddTaskComment);
|
||
ipcMain.handle(TEAM_ADD_MEMBER, handleAddMember);
|
||
ipcMain.handle(TEAM_REPLACE_MEMBERS, handleReplaceMembers);
|
||
ipcMain.handle(TEAM_REMOVE_MEMBER, handleRemoveMember);
|
||
ipcMain.handle(TEAM_RESTORE_MEMBER, handleRestoreMember);
|
||
ipcMain.handle(TEAM_UPDATE_MEMBER_ROLE, handleUpdateMemberRole);
|
||
ipcMain.handle(TEAM_GET_PROJECT_BRANCH, handleGetProjectBranch);
|
||
ipcMain.handle(TEAM_GET_ATTACHMENTS, handleGetAttachments);
|
||
ipcMain.handle(TEAM_KILL_PROCESS, handleKillProcess);
|
||
ipcMain.handle(TEAM_LEAD_ACTIVITY, handleLeadActivity);
|
||
ipcMain.handle(TEAM_LEAD_CONTEXT, handleLeadContext);
|
||
ipcMain.handle(TEAM_MEMBER_SPAWN_STATUSES, handleMemberSpawnStatuses);
|
||
ipcMain.handle(TEAM_GET_AGENT_RUNTIME, handleGetAgentRuntime);
|
||
ipcMain.handle(
|
||
TEAM_RETRY_FAILED_OPENCODE_SECONDARY_LANES,
|
||
handleRetryFailedOpenCodeSecondaryLanes
|
||
);
|
||
ipcMain.handle(TEAM_RESTART_MEMBER, handleRestartMember);
|
||
ipcMain.handle(TEAM_SKIP_MEMBER_FOR_LAUNCH, handleSkipMemberForLaunch);
|
||
ipcMain.handle(TEAM_SOFT_DELETE_TASK, handleSoftDeleteTask);
|
||
ipcMain.handle(TEAM_RESTORE_TASK, handleRestoreTask);
|
||
ipcMain.handle(TEAM_GET_DELETED_TASKS, handleGetDeletedTasks);
|
||
ipcMain.handle(TEAM_SET_TASK_CLARIFICATION, handleSetTaskClarification);
|
||
ipcMain.handle(TEAM_SHOW_MESSAGE_NOTIFICATION, handleShowMessageNotification);
|
||
ipcMain.handle(TEAM_ADD_TASK_RELATIONSHIP, handleAddTaskRelationship);
|
||
ipcMain.handle(TEAM_REMOVE_TASK_RELATIONSHIP, handleRemoveTaskRelationship);
|
||
ipcMain.handle(TEAM_SAVE_TASK_ATTACHMENT, handleSaveTaskAttachment);
|
||
ipcMain.handle(TEAM_GET_TASK_ATTACHMENT, handleGetTaskAttachment);
|
||
ipcMain.handle(TEAM_DELETE_TASK_ATTACHMENT, handleDeleteTaskAttachment);
|
||
ipcMain.handle(TEAM_TOOL_APPROVAL_RESPOND, handleToolApprovalRespond);
|
||
ipcMain.handle(TEAM_TOOL_APPROVAL_READ_FILE, handleToolApprovalReadFile);
|
||
ipcMain.handle(TEAM_VALIDATE_CLI_ARGS, handleValidateCliArgs);
|
||
ipcMain.handle(TEAM_TOOL_APPROVAL_SETTINGS, handleToolApprovalSettings);
|
||
ipcMain.handle(TEAM_GET_SAVED_REQUEST, handleGetSavedRequest);
|
||
ipcMain.handle(TEAM_DELETE_DRAFT, handleDeleteDraft);
|
||
logger.info('Team handlers registered');
|
||
}
|
||
|
||
export function removeTeamHandlers(ipcMain: IpcMain): void {
|
||
ipcMain.removeHandler(TEAM_LIST);
|
||
ipcMain.removeHandler(TEAM_GET_DATA);
|
||
ipcMain.removeHandler(TEAM_GET_TASK_CHANGE_PRESENCE);
|
||
ipcMain.removeHandler(TEAM_SET_CHANGE_PRESENCE_TRACKING);
|
||
ipcMain.removeHandler(TEAM_SET_PROJECT_BRANCH_TRACKING);
|
||
ipcMain.removeHandler(TEAM_SET_TASK_LOG_STREAM_TRACKING);
|
||
ipcMain.removeHandler(TEAM_SET_TOOL_ACTIVITY_TRACKING);
|
||
ipcMain.removeHandler(TEAM_GET_CLAUDE_LOGS);
|
||
ipcMain.removeHandler(TEAM_PREPARE_PROVISIONING);
|
||
ipcMain.removeHandler(TEAM_GET_WORKTREE_GIT_STATUS);
|
||
ipcMain.removeHandler(TEAM_INITIALIZE_GIT_REPOSITORY);
|
||
ipcMain.removeHandler(TEAM_CREATE_INITIAL_GIT_COMMIT);
|
||
ipcMain.removeHandler(TEAM_CREATE);
|
||
ipcMain.removeHandler(TEAM_LAUNCH);
|
||
ipcMain.removeHandler(TEAM_PROVISIONING_STATUS);
|
||
ipcMain.removeHandler(TEAM_LAUNCH_FAILURE_DIAGNOSTICS);
|
||
ipcMain.removeHandler(TEAM_CANCEL_PROVISIONING);
|
||
ipcMain.removeHandler(TEAM_SEND_MESSAGE);
|
||
ipcMain.removeHandler(TEAM_GET_OPENCODE_RUNTIME_DELIVERY_STATUS);
|
||
ipcMain.removeHandler(TEAM_GET_MESSAGES_PAGE);
|
||
ipcMain.removeHandler(TEAM_GET_MEMBER_ACTIVITY_META);
|
||
ipcMain.removeHandler(TEAM_CREATE_TASK);
|
||
ipcMain.removeHandler(TEAM_REQUEST_REVIEW);
|
||
ipcMain.removeHandler(TEAM_UPDATE_KANBAN);
|
||
ipcMain.removeHandler(TEAM_UPDATE_KANBAN_COLUMN_ORDER);
|
||
ipcMain.removeHandler(TEAM_UPDATE_TASK_STATUS);
|
||
ipcMain.removeHandler(TEAM_UPDATE_TASK_OWNER);
|
||
ipcMain.removeHandler(TEAM_UPDATE_TASK_FIELDS);
|
||
ipcMain.removeHandler(TEAM_DELETE_TEAM);
|
||
ipcMain.removeHandler(TEAM_RESTORE);
|
||
ipcMain.removeHandler(TEAM_PERMANENTLY_DELETE);
|
||
ipcMain.removeHandler(TEAM_PROCESS_SEND);
|
||
ipcMain.removeHandler(TEAM_PROCESS_ALIVE);
|
||
ipcMain.removeHandler(TEAM_ALIVE_LIST);
|
||
ipcMain.removeHandler(TEAM_STOP);
|
||
ipcMain.removeHandler(TEAM_CREATE_CONFIG);
|
||
ipcMain.removeHandler(TEAM_GET_MEMBER_LOGS);
|
||
ipcMain.removeHandler(TEAM_GET_LOGS_FOR_TASK);
|
||
ipcMain.removeHandler(TEAM_GET_TASK_ACTIVITY);
|
||
ipcMain.removeHandler(TEAM_GET_TASK_ACTIVITY_DETAIL);
|
||
ipcMain.removeHandler(TEAM_GET_TASK_LOG_STREAM_SUMMARY);
|
||
ipcMain.removeHandler(TEAM_GET_TASK_LOG_STREAM);
|
||
ipcMain.removeHandler(TEAM_GET_TASK_EXACT_LOG_SUMMARIES);
|
||
ipcMain.removeHandler(TEAM_GET_TASK_EXACT_LOG_DETAIL);
|
||
ipcMain.removeHandler(TEAM_GET_MEMBER_STATS);
|
||
ipcMain.removeHandler(TEAM_UPDATE_CONFIG);
|
||
ipcMain.removeHandler(TEAM_START_TASK);
|
||
ipcMain.removeHandler(TEAM_START_TASK_BY_USER);
|
||
ipcMain.removeHandler(TEAM_GET_ALL_TASKS);
|
||
ipcMain.removeHandler(TEAM_ADD_TASK_COMMENT);
|
||
ipcMain.removeHandler(TEAM_ADD_MEMBER);
|
||
ipcMain.removeHandler(TEAM_REPLACE_MEMBERS);
|
||
ipcMain.removeHandler(TEAM_REMOVE_MEMBER);
|
||
ipcMain.removeHandler(TEAM_RESTORE_MEMBER);
|
||
ipcMain.removeHandler(TEAM_UPDATE_MEMBER_ROLE);
|
||
ipcMain.removeHandler(TEAM_GET_PROJECT_BRANCH);
|
||
ipcMain.removeHandler(TEAM_GET_ATTACHMENTS);
|
||
ipcMain.removeHandler(TEAM_KILL_PROCESS);
|
||
ipcMain.removeHandler(TEAM_LEAD_ACTIVITY);
|
||
ipcMain.removeHandler(TEAM_LEAD_CONTEXT);
|
||
ipcMain.removeHandler(TEAM_MEMBER_SPAWN_STATUSES);
|
||
ipcMain.removeHandler(TEAM_GET_AGENT_RUNTIME);
|
||
ipcMain.removeHandler(TEAM_RETRY_FAILED_OPENCODE_SECONDARY_LANES);
|
||
ipcMain.removeHandler(TEAM_RESTART_MEMBER);
|
||
ipcMain.removeHandler(TEAM_SKIP_MEMBER_FOR_LAUNCH);
|
||
ipcMain.removeHandler(TEAM_SOFT_DELETE_TASK);
|
||
ipcMain.removeHandler(TEAM_RESTORE_TASK);
|
||
ipcMain.removeHandler(TEAM_GET_DELETED_TASKS);
|
||
ipcMain.removeHandler(TEAM_SET_TASK_CLARIFICATION);
|
||
ipcMain.removeHandler(TEAM_SHOW_MESSAGE_NOTIFICATION);
|
||
ipcMain.removeHandler(TEAM_ADD_TASK_RELATIONSHIP);
|
||
ipcMain.removeHandler(TEAM_REMOVE_TASK_RELATIONSHIP);
|
||
ipcMain.removeHandler(TEAM_SAVE_TASK_ATTACHMENT);
|
||
ipcMain.removeHandler(TEAM_GET_TASK_ATTACHMENT);
|
||
ipcMain.removeHandler(TEAM_DELETE_TASK_ATTACHMENT);
|
||
ipcMain.removeHandler(TEAM_TOOL_APPROVAL_RESPOND);
|
||
ipcMain.removeHandler(TEAM_TOOL_APPROVAL_READ_FILE);
|
||
ipcMain.removeHandler(TEAM_VALIDATE_CLI_ARGS);
|
||
ipcMain.removeHandler(TEAM_TOOL_APPROVAL_SETTINGS);
|
||
ipcMain.removeHandler(TEAM_GET_SAVED_REQUEST);
|
||
ipcMain.removeHandler(TEAM_DELETE_DRAFT);
|
||
}
|
||
|
||
function getTeamDataService(): TeamDataService {
|
||
if (!teamDataService) {
|
||
throw new Error('Team handlers are not initialized');
|
||
}
|
||
return teamDataService;
|
||
}
|
||
|
||
function getTeamProvisioningService(): TeamProvisioningService {
|
||
if (!teamProvisioningService) {
|
||
throw new Error('Team provisioning handlers are not initialized');
|
||
}
|
||
return teamProvisioningService;
|
||
}
|
||
|
||
function getTeammateToolTracker(): TeammateToolTracker {
|
||
if (!teammateToolTracker) {
|
||
throw new Error('Teammate tool tracker is not initialized');
|
||
}
|
||
return teammateToolTracker;
|
||
}
|
||
|
||
function getTeamLogSourceTracker(): TeamLogSourceTracker {
|
||
if (!teamLogSourceTracker) {
|
||
throw new Error('Team log source tracker is not initialized');
|
||
}
|
||
return teamLogSourceTracker;
|
||
}
|
||
|
||
function getBranchStatusService(): BranchStatusService {
|
||
if (!branchStatusService) {
|
||
throw new Error('Branch status service is not initialized');
|
||
}
|
||
return branchStatusService;
|
||
}
|
||
|
||
function getBoardTaskActivityService(): BoardTaskActivityService {
|
||
if (!boardTaskActivityService) {
|
||
throw new Error('Board task activity service is not initialized');
|
||
}
|
||
return boardTaskActivityService;
|
||
}
|
||
|
||
function getBoardTaskActivityDetailService(): BoardTaskActivityDetailService {
|
||
if (!boardTaskActivityDetailService) {
|
||
throw new Error('Board task activity detail service is not initialized');
|
||
}
|
||
return boardTaskActivityDetailService;
|
||
}
|
||
|
||
function getBoardTaskLogStreamService(): BoardTaskLogStreamService {
|
||
if (!boardTaskLogStreamService) {
|
||
throw new Error('Board task log stream service is not initialized');
|
||
}
|
||
return boardTaskLogStreamService;
|
||
}
|
||
|
||
function getBoardTaskExactLogsService(): BoardTaskExactLogsService {
|
||
if (!boardTaskExactLogsService) {
|
||
throw new Error('Board task exact logs service is not initialized');
|
||
}
|
||
return boardTaskExactLogsService;
|
||
}
|
||
|
||
function getBoardTaskExactLogDetailService(): BoardTaskExactLogDetailService {
|
||
if (!boardTaskExactLogDetailService) {
|
||
throw new Error('Board task exact log detail service is not initialized');
|
||
}
|
||
return boardTaskExactLogDetailService;
|
||
}
|
||
|
||
async function wrapTeamHandler<T>(
|
||
operation: string,
|
||
handler: () => Promise<T>
|
||
): Promise<IpcResult<T>> {
|
||
try {
|
||
const data = await handler();
|
||
return { success: true, data };
|
||
} catch (error) {
|
||
const message = error instanceof Error ? error.message : String(error);
|
||
logger.error(`[teams:${operation}] ${message}`);
|
||
return { success: false, error: message };
|
||
}
|
||
}
|
||
|
||
async function handleGetProjectBranch(
|
||
_event: IpcMainInvokeEvent,
|
||
projectPath: unknown
|
||
): Promise<IpcResult<string | null>> {
|
||
if (typeof projectPath !== 'string' || projectPath.trim().length === 0) {
|
||
return { success: false, error: 'projectPath must be a non-empty string' };
|
||
}
|
||
try {
|
||
const branch = await gitIdentityResolver.getBranch(path.normalize(projectPath.trim()));
|
||
return { success: true, data: branch };
|
||
} catch (error) {
|
||
const message = error instanceof Error ? error.message : String(error);
|
||
logger.error(`[teams:getProjectBranch] ${message}`);
|
||
return { success: false, error: message };
|
||
}
|
||
}
|
||
|
||
function validateProjectPathInput(
|
||
projectPath: unknown
|
||
): { valid: true; value: string } | { valid: false; error: string } {
|
||
if (typeof projectPath !== 'string' || projectPath.trim().length === 0) {
|
||
return { valid: false, error: 'projectPath must be a non-empty string' };
|
||
}
|
||
return { valid: true, value: path.normalize(projectPath.trim()) };
|
||
}
|
||
|
||
async function handleGetWorktreeGitStatus(
|
||
_event: IpcMainInvokeEvent,
|
||
projectPath: unknown
|
||
): Promise<IpcResult<TeamWorktreeGitStatus>> {
|
||
const validated = validateProjectPathInput(projectPath);
|
||
if (!validated.valid) {
|
||
return { success: false, error: validated.error };
|
||
}
|
||
return wrapTeamHandler('getWorktreeGitStatus', () =>
|
||
worktreeGitService.getStatus(validated.value)
|
||
);
|
||
}
|
||
|
||
async function handleInitializeGitRepository(
|
||
_event: IpcMainInvokeEvent,
|
||
projectPath: unknown
|
||
): Promise<IpcResult<TeamWorktreeGitStatus>> {
|
||
const validated = validateProjectPathInput(projectPath);
|
||
if (!validated.valid) {
|
||
return { success: false, error: validated.error };
|
||
}
|
||
return wrapTeamHandler('initializeGitRepository', () =>
|
||
worktreeGitService.initializeRepository(validated.value)
|
||
);
|
||
}
|
||
|
||
async function handleCreateInitialGitCommit(
|
||
_event: IpcMainInvokeEvent,
|
||
projectPath: unknown
|
||
): Promise<IpcResult<TeamWorktreeGitStatus>> {
|
||
const validated = validateProjectPathInput(projectPath);
|
||
if (!validated.valid) {
|
||
return { success: false, error: validated.error };
|
||
}
|
||
return wrapTeamHandler('createInitialGitCommit', () =>
|
||
worktreeGitService.createInitialCommit(validated.value)
|
||
);
|
||
}
|
||
|
||
async function handleListTeams(_event: IpcMainInvokeEvent): Promise<IpcResult<TeamSummary[]>> {
|
||
setCurrentMainOp('team:list');
|
||
const startedAt = Date.now();
|
||
try {
|
||
return await wrapTeamHandler('list', () => {
|
||
const loadFresh = () => getTeamDataService().listTeams();
|
||
return launchIoGovernor
|
||
? launchIoGovernor.runSummaryOperation('teams:list', loadFresh, {
|
||
clone: cloneLaunchIoGovernorPayload,
|
||
})
|
||
: loadFresh();
|
||
});
|
||
} finally {
|
||
const ms = Date.now() - startedAt;
|
||
if (ms >= 1500) {
|
||
logger.warn(`[teams:list] slow ms=${ms}`);
|
||
}
|
||
setCurrentMainOp(null);
|
||
}
|
||
}
|
||
|
||
async function handleGetData(
|
||
_event: IpcMainInvokeEvent,
|
||
teamName: unknown,
|
||
rawOptions?: unknown
|
||
): Promise<IpcResult<TeamViewSnapshot>> {
|
||
const validated = validateTeamName(teamName);
|
||
if (!validated.valid) {
|
||
return { success: false, error: validated.error ?? 'Invalid teamName' };
|
||
}
|
||
const optionsResult = validateTeamGetDataOptions(rawOptions);
|
||
if (!optionsResult.valid) {
|
||
return { success: false, error: optionsResult.error };
|
||
}
|
||
const tn = validated.value!;
|
||
// The UI is fetching this team, so keep its team-root/task artifacts watched
|
||
// (idle teams the UI never opens are not watched, to scale with team count).
|
||
markTeamEngaged(tn);
|
||
const getDataOptions = optionsResult.value;
|
||
const startedAt = Date.now();
|
||
let data: TeamViewSnapshot;
|
||
let dataSource: 'worker' | 'main-fallback' | 'main-unavailable' = 'main-unavailable';
|
||
let workerAvailable = false;
|
||
const readFromMain = (): Promise<TeamViewSnapshot> =>
|
||
getDataOptions === undefined
|
||
? getTeamDataService().getTeamData(tn)
|
||
: getTeamDataService().getTeamData(tn, getDataOptions);
|
||
setCurrentMainOp('team:getData');
|
||
try {
|
||
// Prefer worker thread to keep main event loop responsive
|
||
const worker = getTeamDataWorkerClient();
|
||
workerAvailable = worker.isAvailable();
|
||
const missingState = await classifyMissingTeamData(tn);
|
||
if (missingState === 'provisioning') {
|
||
return { success: false, error: 'TEAM_PROVISIONING' };
|
||
}
|
||
if (missingState === 'draft') {
|
||
return { success: false, error: 'TEAM_DRAFT' };
|
||
}
|
||
|
||
await getTeamProvisioningService().repairStaleTaskActivityIntervalsBeforeSnapshot?.(tn);
|
||
|
||
if (workerAvailable) {
|
||
try {
|
||
data =
|
||
getDataOptions === undefined
|
||
? await worker.getTeamData(tn)
|
||
: await worker.getTeamData(tn, getDataOptions);
|
||
dataSource = 'worker';
|
||
} catch (workerErr) {
|
||
logger.warn(
|
||
`[teams:getData] worker failed, falling back: ${workerErr instanceof Error ? workerErr.message : workerErr}`
|
||
);
|
||
noteHeavyTeamDataWorkerFallback('teams:getData');
|
||
data = await readFromMain();
|
||
dataSource = 'main-fallback';
|
||
}
|
||
} else {
|
||
noteHeavyTeamDataWorkerFallback('teams:getData');
|
||
data = await readFromMain();
|
||
}
|
||
} catch (error) {
|
||
const message = error instanceof Error ? error.message : String(error);
|
||
if (
|
||
message === `Team not found: ${tn}` &&
|
||
getTeamProvisioningService().hasProvisioningRun?.(tn) === true
|
||
) {
|
||
return { success: false, error: 'TEAM_PROVISIONING' };
|
||
}
|
||
// Draft team: team.meta.json exists but config.json doesn't (provisioning failed before TeamCreate)
|
||
if (message === `Team not found: ${tn}`) {
|
||
const meta = await withTimeoutValue(
|
||
teamMetaStore.getMeta(tn).catch(() => null),
|
||
TEAM_DATA_DRAFT_CLASSIFICATION_ACCESS_TIMEOUT_MS,
|
||
null
|
||
);
|
||
if (meta) {
|
||
return { success: false, error: 'TEAM_DRAFT' };
|
||
}
|
||
}
|
||
logger.error(`[teams:getData] ${message}`);
|
||
return { success: false, error: message };
|
||
} finally {
|
||
setCurrentMainOp(null);
|
||
}
|
||
const getDataMs = Date.now() - startedAt;
|
||
|
||
if (getDataMs >= 1500) {
|
||
const branchMode = getDataOptions?.includeMemberBranches === false ? 'skipped' : 'full';
|
||
logger.warn(
|
||
`[teams:getData] slow team=${tn} ms=${getDataMs} source=${dataSource} workerAvailable=${workerAvailable} branchMode=${branchMode}`
|
||
);
|
||
}
|
||
const teamDataService = getTeamDataService();
|
||
if (data.processes.some((process) => !process.stoppedAt)) {
|
||
teamDataService.trackProcessHealthForTeam?.(tn);
|
||
} else {
|
||
teamDataService.untrackProcessHealthForTeam?.(tn);
|
||
}
|
||
const provisioning = getTeamProvisioningService();
|
||
const isAlive = provisioning.isTeamAlive(tn);
|
||
const currentLeadSessionId = provisioning.getCurrentLeadSessionId(tn);
|
||
|
||
const displayName = data.config.name || tn;
|
||
const projectPath = data.config.projectPath;
|
||
const live = provisioning.getLiveLeadProcessMessages(tn);
|
||
const durableMessages = Array.isArray((data as { messages?: unknown }).messages)
|
||
? ((data as { messages?: typeof live }).messages ?? [])
|
||
: [];
|
||
|
||
if (live.length === 0) {
|
||
if (durableMessages.length > 0) {
|
||
teamMessageNotificationScanner.checkRateLimitMessages(durableMessages, {
|
||
teamName: tn,
|
||
teamDisplayName: displayName,
|
||
projectPath,
|
||
teamIsAlive: isAlive,
|
||
currentLeadSessionId,
|
||
});
|
||
teamMessageNotificationScanner.checkApiErrorMessages(durableMessages, {
|
||
teamName: tn,
|
||
teamDisplayName: displayName,
|
||
projectPath,
|
||
});
|
||
} else {
|
||
teamMessageNotificationScanner.scan(live, {
|
||
teamName: tn,
|
||
teamDisplayName: displayName,
|
||
projectPath,
|
||
});
|
||
}
|
||
return { success: true, data: { ...data, isAlive } };
|
||
}
|
||
|
||
let merged = mergeLiveLeadProcessMessages(durableMessages, live);
|
||
if (durableMessages.length >= 50) {
|
||
try {
|
||
const newestPage = await getNewestMessagesPageWithLiveOverlay({
|
||
teamName: tn,
|
||
limit: 50,
|
||
liveMessages: live,
|
||
});
|
||
merged = newestPage.messages;
|
||
} catch (error) {
|
||
logger.warn(
|
||
`[teams:getData] failed to rebuild newest merged messages for ${tn}: ${
|
||
error instanceof Error ? error.message : String(error)
|
||
}`
|
||
);
|
||
}
|
||
}
|
||
|
||
teamMessageNotificationScanner.checkRateLimitMessages(merged, {
|
||
teamName: tn,
|
||
teamDisplayName: displayName,
|
||
projectPath,
|
||
teamIsAlive: isAlive,
|
||
currentLeadSessionId,
|
||
});
|
||
teamMessageNotificationScanner.checkApiErrorMessages(merged, {
|
||
teamName: tn,
|
||
teamDisplayName: displayName,
|
||
projectPath,
|
||
});
|
||
return { success: true, data: { ...data, isAlive } };
|
||
}
|
||
|
||
async function classifyMissingTeamData(teamName: string): Promise<'provisioning' | 'draft' | null> {
|
||
const configPath = path.join(getTeamsBasePath(), teamName, 'config.json');
|
||
const configExists = await withTimeoutValue(
|
||
fs.promises
|
||
.access(configPath, fs.constants.F_OK)
|
||
.then(() => true)
|
||
.catch((error: unknown) => {
|
||
const code = typeof error === 'object' && error ? (error as { code?: unknown }).code : null;
|
||
return code === 'ENOENT' ? false : null;
|
||
}),
|
||
TEAM_DATA_DRAFT_CLASSIFICATION_ACCESS_TIMEOUT_MS,
|
||
null
|
||
);
|
||
if (configExists !== false) {
|
||
return null;
|
||
}
|
||
if (getTeamProvisioningService().hasProvisioningRun?.(teamName) === true) {
|
||
return 'provisioning';
|
||
}
|
||
const meta = await withTimeoutValue(
|
||
teamMetaStore.getMeta(teamName).catch(() => null),
|
||
TEAM_DATA_DRAFT_CLASSIFICATION_ACCESS_TIMEOUT_MS,
|
||
null
|
||
);
|
||
return meta ? 'draft' : null;
|
||
}
|
||
|
||
async function handleGetTaskChangePresence(
|
||
_event: IpcMainInvokeEvent,
|
||
teamName: unknown
|
||
): Promise<IpcResult<Record<string, TaskChangePresenceState>>> {
|
||
const validated = validateTeamName(teamName);
|
||
if (!validated.valid) {
|
||
return { success: false, error: validated.error ?? 'Invalid teamName' };
|
||
}
|
||
|
||
return wrapTeamHandler('getTaskChangePresence', () =>
|
||
getTeamDataService().getTaskChangePresence(validated.value!)
|
||
);
|
||
}
|
||
|
||
async function handleSetChangePresenceTracking(
|
||
_event: IpcMainInvokeEvent,
|
||
teamName: unknown,
|
||
enabled: unknown
|
||
): Promise<IpcResult<void>> {
|
||
const validated = validateTeamName(teamName);
|
||
if (!validated.valid) {
|
||
return { success: false, error: validated.error ?? 'Invalid teamName' };
|
||
}
|
||
if (typeof enabled !== 'boolean') {
|
||
return { success: false, error: 'enabled must be a boolean' };
|
||
}
|
||
|
||
return wrapTeamHandler('setChangePresenceTracking', async () => {
|
||
getTeamDataService().setTaskChangePresenceTracking(validated.value!, enabled);
|
||
});
|
||
}
|
||
|
||
async function handleSetProjectBranchTracking(
|
||
_event: IpcMainInvokeEvent,
|
||
projectPath: unknown,
|
||
enabled: unknown
|
||
): Promise<IpcResult<void>> {
|
||
if (typeof projectPath !== 'string' || projectPath.trim().length === 0) {
|
||
return { success: false, error: 'projectPath must be a non-empty string' };
|
||
}
|
||
if (typeof enabled !== 'boolean') {
|
||
return { success: false, error: 'enabled must be a boolean' };
|
||
}
|
||
|
||
return wrapTeamHandler('setProjectBranchTracking', async () => {
|
||
await getBranchStatusService().setTracking(projectPath.trim(), enabled);
|
||
});
|
||
}
|
||
|
||
async function handleSetToolActivityTracking(
|
||
_event: IpcMainInvokeEvent,
|
||
teamName: unknown,
|
||
enabled: unknown
|
||
): Promise<IpcResult<void>> {
|
||
const validated = validateTeamName(teamName);
|
||
if (!validated.valid) {
|
||
return { success: false, error: validated.error ?? 'Invalid teamName' };
|
||
}
|
||
if (typeof enabled !== 'boolean') {
|
||
return { success: false, error: 'enabled must be a boolean' };
|
||
}
|
||
|
||
return wrapTeamHandler('setToolActivityTracking', async () => {
|
||
await getTeammateToolTracker().setTracking(validated.value!, enabled);
|
||
});
|
||
}
|
||
|
||
async function handleSetTaskLogStreamTracking(
|
||
_event: IpcMainInvokeEvent,
|
||
teamName: unknown,
|
||
enabled: unknown
|
||
): Promise<IpcResult<void>> {
|
||
const validated = validateTeamName(teamName);
|
||
if (!validated.valid) {
|
||
return { success: false, error: validated.error ?? 'Invalid teamName' };
|
||
}
|
||
if (typeof enabled !== 'boolean') {
|
||
return { success: false, error: 'enabled must be a boolean' };
|
||
}
|
||
|
||
return wrapTeamHandler('setTaskLogStreamTracking', async () => {
|
||
if (enabled) {
|
||
await getTeamLogSourceTracker().enableTracking(validated.value!, 'task_log_stream');
|
||
return;
|
||
}
|
||
await getTeamLogSourceTracker().disableTracking(validated.value!, 'task_log_stream');
|
||
});
|
||
}
|
||
|
||
async function handleDeleteTeam(
|
||
_event: IpcMainInvokeEvent,
|
||
teamName: unknown
|
||
): Promise<IpcResult<void>> {
|
||
const validated = validateTeamName(teamName);
|
||
if (!validated.valid) {
|
||
return { success: false, error: validated.error ?? 'Invalid teamName' };
|
||
}
|
||
return wrapTeamHandler('deleteTeam', async () => {
|
||
getAutoResumeService().cancelPendingAutoResume(validated.value!);
|
||
await getTeamProvisioningService().stopTeam(validated.value!);
|
||
await getTeamDataService().deleteTeam(validated.value!);
|
||
getTeamDataWorkerClient().invalidateTeamConfig(validated.value!);
|
||
});
|
||
}
|
||
|
||
async function handleRestoreTeam(
|
||
_event: IpcMainInvokeEvent,
|
||
teamName: unknown
|
||
): Promise<IpcResult<void>> {
|
||
const validated = validateTeamName(teamName);
|
||
if (!validated.valid) {
|
||
return { success: false, error: validated.error ?? 'Invalid teamName' };
|
||
}
|
||
return wrapTeamHandler('restoreTeam', async () => {
|
||
await getTeamDataService().restoreTeam(validated.value!);
|
||
getTeamDataWorkerClient().invalidateTeamConfig(validated.value!);
|
||
});
|
||
}
|
||
|
||
async function handlePermanentlyDeleteTeam(
|
||
_event: IpcMainInvokeEvent,
|
||
teamName: unknown
|
||
): Promise<IpcResult<void>> {
|
||
const validated = validateTeamName(teamName);
|
||
if (!validated.valid) {
|
||
return { success: false, error: validated.error ?? 'Invalid teamName' };
|
||
}
|
||
return wrapTeamHandler('permanentlyDeleteTeam', async () => {
|
||
getAutoResumeService().cancelPendingAutoResume(validated.value!);
|
||
await getTeamDataService().permanentlyDeleteTeam(validated.value!);
|
||
getTeamDataWorkerClient().invalidateTeamConfig(validated.value!);
|
||
// Clean up app-owned data (attachments, task-attachments) that lives outside ~/.claude/
|
||
const appData = getAppDataPath();
|
||
await fs.promises
|
||
.rm(path.join(appData, 'attachments', validated.value!), { recursive: true, force: true })
|
||
.catch(() => undefined);
|
||
await fs.promises
|
||
.rm(path.join(appData, 'task-attachments', validated.value!), {
|
||
recursive: true,
|
||
force: true,
|
||
})
|
||
.catch(() => undefined);
|
||
// Mark in backup registry AFTER successful deletion
|
||
if (teamBackupService) {
|
||
await teamBackupService.markDeletedByUser(validated.value!);
|
||
}
|
||
});
|
||
}
|
||
|
||
async function handleUpdateConfig(
|
||
_event: IpcMainInvokeEvent,
|
||
teamName: unknown,
|
||
updates: unknown
|
||
): Promise<IpcResult<TeamConfig>> {
|
||
const validated = validateTeamName(teamName);
|
||
if (!validated.valid) {
|
||
return { success: false, error: validated.error ?? 'Invalid teamName' };
|
||
}
|
||
if (!updates || typeof updates !== 'object') {
|
||
return { success: false, error: 'Invalid updates object' };
|
||
}
|
||
const { name, description, color } = updates as TeamUpdateConfigRequest;
|
||
if (name !== undefined && typeof name !== 'string') {
|
||
return { success: false, error: 'name must be a string' };
|
||
}
|
||
if (description !== undefined && typeof description !== 'string') {
|
||
return { success: false, error: 'description must be a string' };
|
||
}
|
||
if (color !== undefined && typeof color !== 'string') {
|
||
return { success: false, error: 'color must be a string' };
|
||
}
|
||
return wrapTeamHandler('updateConfig', async () => {
|
||
const tn = validated.value!;
|
||
const teamDataService = getTeamDataService();
|
||
const previousDisplayName = await teamDataService.getTeamDisplayName(tn).catch(() => tn);
|
||
const requestedName = typeof name === 'string' ? name.trim() : '';
|
||
const result = await getTeamDataService().updateConfig(tn, {
|
||
name,
|
||
description,
|
||
color,
|
||
});
|
||
if (!result) {
|
||
throw new Error('Team config not found');
|
||
}
|
||
|
||
// Notify running lead about the rename so it stays aware of current team name
|
||
if (requestedName && requestedName !== (previousDisplayName?.trim() || tn)) {
|
||
const provisioning = getTeamProvisioningService();
|
||
if (provisioning.isTeamAlive(tn)) {
|
||
const msg = `The team has been renamed to "${requestedName}". Please use this name when referring to the team going forward.`;
|
||
try {
|
||
await provisioning.sendMessageToTeam(tn, msg);
|
||
} catch {
|
||
logger.warn(`Failed to notify lead about team rename for ${tn}`);
|
||
}
|
||
}
|
||
}
|
||
|
||
getTeamDataWorkerClient().invalidateTeamConfig(tn);
|
||
return result;
|
||
});
|
||
}
|
||
|
||
function isProvisioningTeamName(teamName: string): boolean {
|
||
if (teamName.length > 64) return false;
|
||
const parts = teamName.split('-');
|
||
return parts.every((p) => /^[a-z0-9]+$/.test(p));
|
||
}
|
||
|
||
function isValidEffort(value: unknown, providerId?: TeamProviderId | null): value is EffortLevel {
|
||
return isTeamEffortLevelForProvider(value, providerId);
|
||
}
|
||
|
||
function parseOptionalProviderId(
|
||
value: unknown,
|
||
fieldName: string
|
||
): { valid: true; value: TeamProviderId | undefined } | { valid: false; error: string } {
|
||
if (value === undefined || value === null || value === '') {
|
||
return { valid: true, value: undefined };
|
||
}
|
||
if (isTeamProviderId(value)) {
|
||
return { valid: true, value };
|
||
}
|
||
return { valid: false, error: `${fieldName} must be anthropic, codex, gemini, or opencode` };
|
||
}
|
||
|
||
function parseOptionalMemberProviderId(
|
||
value: unknown
|
||
): { valid: true; value: TeamProviderId | undefined } | { valid: false; error: string } {
|
||
return parseOptionalProviderId(value, 'member providerId');
|
||
}
|
||
|
||
function parseOptionalTeamProviderId(
|
||
value: unknown
|
||
): { valid: true; value: TeamProviderId | undefined } | { valid: false; error: string } {
|
||
return parseOptionalProviderId(value, 'providerId');
|
||
}
|
||
|
||
function parseOptionalProviderBackendId(
|
||
value: unknown,
|
||
providerId?: TeamProviderId
|
||
): { valid: true; value: TeamProviderBackendId | undefined } | { valid: false; error: string } {
|
||
if (value === undefined || value === null || value === '') {
|
||
return { valid: true, value: undefined };
|
||
}
|
||
if (typeof value !== 'string') {
|
||
return { valid: false, error: 'providerBackendId must be a string' };
|
||
}
|
||
const trimmed = value.trim();
|
||
if (!trimmed) {
|
||
return { valid: true, value: undefined };
|
||
}
|
||
if (trimmed.length > 64) {
|
||
return { valid: false, error: 'providerBackendId too long (max 64)' };
|
||
}
|
||
if (providerId) {
|
||
const migratedBackendId = migrateProviderBackendId(providerId, trimmed);
|
||
if (migratedBackendId) {
|
||
return { valid: true, value: migratedBackendId };
|
||
}
|
||
} else if (isTeamProviderBackendId(trimmed)) {
|
||
return { valid: true, value: trimmed };
|
||
}
|
||
|
||
return {
|
||
valid: false,
|
||
error:
|
||
'providerBackendId must be valid for the selected provider (auto, adapter, api, cli-sdk, codex-native, or opencode-cli)',
|
||
};
|
||
}
|
||
|
||
function parseOptionalLaunchProviderBackendId(
|
||
value: unknown,
|
||
providerId?: TeamProviderId
|
||
): { valid: true; value: TeamProviderBackendId | undefined } | { valid: false; error: string } {
|
||
if (value === undefined || value === null || value === '') {
|
||
return { valid: true, value: undefined };
|
||
}
|
||
if (typeof value !== 'string') {
|
||
return { valid: false, error: 'providerBackendId must be a string' };
|
||
}
|
||
const trimmed = value.trim();
|
||
if (!trimmed) {
|
||
return { valid: true, value: undefined };
|
||
}
|
||
if (trimmed.length > 64) {
|
||
return { valid: false, error: 'providerBackendId too long (max 64)' };
|
||
}
|
||
|
||
const migratedBackendId = migrateProviderBackendId(providerId, trimmed);
|
||
if (migratedBackendId) {
|
||
return { valid: true, value: migratedBackendId };
|
||
}
|
||
|
||
if (isTeamProviderBackendId(trimmed)) {
|
||
return { valid: true, value: undefined };
|
||
}
|
||
|
||
return {
|
||
valid: false,
|
||
error:
|
||
'providerBackendId must be valid for the selected provider (auto, adapter, api, cli-sdk, codex-native, or opencode-cli)',
|
||
};
|
||
}
|
||
|
||
function parseOptionalMemberEffort(
|
||
value: unknown,
|
||
providerId?: TeamProviderId | null
|
||
): { valid: true; value: EffortLevel | undefined } | { valid: false; error: string } {
|
||
if (value === undefined || value === null || value === '') {
|
||
return { valid: true, value: undefined };
|
||
}
|
||
if (isValidEffort(value, providerId)) {
|
||
return { valid: true, value };
|
||
}
|
||
return {
|
||
valid: false,
|
||
error: `member effort must be one of ${formatEffortLevelListForProvider(providerId)}`,
|
||
};
|
||
}
|
||
|
||
function parseOptionalTeamEffort(
|
||
value: unknown,
|
||
providerId?: TeamProviderId | null
|
||
): { valid: true; value: EffortLevel | undefined } | { valid: false; error: string } {
|
||
if (value === undefined || value === null || value === '') {
|
||
return { valid: true, value: undefined };
|
||
}
|
||
if (isValidEffort(value, providerId)) {
|
||
return { valid: true, value };
|
||
}
|
||
return {
|
||
valid: false,
|
||
error: `effort must be one of ${formatEffortLevelListForProvider(providerId)}`,
|
||
};
|
||
}
|
||
|
||
function parseOptionalTeamFastMode(
|
||
value: unknown
|
||
): { valid: true; value: TeamFastMode | undefined } | { valid: false; error: string } {
|
||
if (value === undefined || value === null || value === '') {
|
||
return { valid: true, value: undefined };
|
||
}
|
||
if (value === 'inherit' || value === 'on' || value === 'off') {
|
||
return { valid: true, value };
|
||
}
|
||
return {
|
||
valid: false,
|
||
error: 'fastMode must be one of inherit, on, or off',
|
||
};
|
||
}
|
||
|
||
interface RuntimeRosterMutationMember {
|
||
name: string;
|
||
role?: string;
|
||
workflow?: string;
|
||
isolation?: 'worktree';
|
||
cwd?: string;
|
||
providerId?: TeamProviderId;
|
||
providerBackendId?: TeamProviderBackendId;
|
||
model?: string;
|
||
effort?: EffortLevel;
|
||
fastMode?: TeamFastMode;
|
||
mcpPolicy?: ReturnType<typeof normalizeTeamMemberMcpPolicy>;
|
||
removedAt?: number | string | null;
|
||
}
|
||
|
||
const OPENCODE_LEAD_LIVE_ROSTER_MUTATION_BLOCK_MESSAGE =
|
||
'Live roster mutation for a running OpenCode-led team is not supported in this phase. Stop the team, edit the roster, then relaunch.';
|
||
const OPENCODE_OWNERSHIP_MIGRATION_BLOCK_MESSAGE =
|
||
'Live member migration between OpenCode and the primary runtime owner is not supported in this phase. Stop the team, edit the roster, then relaunch.';
|
||
|
||
function isOpenCodeRosterMutationMember(member: RuntimeRosterMutationMember | undefined): boolean {
|
||
return normalizeOptionalTeamProviderId(member?.providerId) === 'opencode';
|
||
}
|
||
|
||
function isLeadRosterMutationMember(member: RuntimeRosterMutationMember | undefined): boolean {
|
||
if (!member) {
|
||
return false;
|
||
}
|
||
if (isLeadMember(member)) {
|
||
return true;
|
||
}
|
||
const normalizedName = member.name.trim().toLowerCase();
|
||
if (normalizedName === 'lead') {
|
||
return true;
|
||
}
|
||
return member.role?.toLowerCase().includes('lead') === true;
|
||
}
|
||
|
||
function isOpenCodeLedRoster(members: RuntimeRosterMutationMember[]): boolean {
|
||
const leadMember = members.find(
|
||
(member) => !member.removedAt && isLeadRosterMutationMember(member)
|
||
);
|
||
return normalizeOptionalTeamProviderId(leadMember?.providerId) === 'opencode';
|
||
}
|
||
|
||
function didOpenCodeRosterMemberChange(
|
||
previous: RuntimeRosterMutationMember | undefined,
|
||
next: RuntimeRosterMutationMember | undefined
|
||
): boolean {
|
||
if (!previous || !next) {
|
||
return false;
|
||
}
|
||
|
||
return (
|
||
(previous.role?.trim() || undefined) !== (next.role?.trim() || undefined) ||
|
||
(previous.workflow?.trim() || undefined) !== (next.workflow?.trim() || undefined) ||
|
||
(previous.isolation === 'worktree' ? 'worktree' : undefined) !==
|
||
(next.isolation === 'worktree' ? 'worktree' : undefined) ||
|
||
normalizeOptionalTeamProviderId(previous.providerId) !==
|
||
normalizeOptionalTeamProviderId(next.providerId) ||
|
||
migrateProviderBackendId(
|
||
normalizeOptionalTeamProviderId(previous.providerId),
|
||
previous.providerBackendId
|
||
) !==
|
||
migrateProviderBackendId(
|
||
normalizeOptionalTeamProviderId(next.providerId),
|
||
next.providerBackendId
|
||
) ||
|
||
(previous.model?.trim() || undefined) !== (next.model?.trim() || undefined) ||
|
||
previous.effort !== next.effort ||
|
||
previous.fastMode !== next.fastMode ||
|
||
JSON.stringify(normalizeTeamMemberMcpPolicy(previous.mcpPolicy)) !==
|
||
JSON.stringify(normalizeTeamMemberMcpPolicy(next.mcpPolicy))
|
||
);
|
||
}
|
||
|
||
function findOpenCodeOwnershipMigrationNames(options: {
|
||
previousMembers: RuntimeRosterMutationMember[];
|
||
nextMembers: RuntimeRosterMutationMember[];
|
||
}): string[] {
|
||
const previousByName = new Map(
|
||
options.previousMembers
|
||
.filter((member) => !member.removedAt)
|
||
.map((member) => [member.name.trim().toLowerCase(), member])
|
||
);
|
||
const migrationNames: string[] = [];
|
||
for (const nextMember of options.nextMembers) {
|
||
const previousMember = previousByName.get(nextMember.name.trim().toLowerCase());
|
||
if (!previousMember) {
|
||
continue;
|
||
}
|
||
if (
|
||
isOpenCodeRosterMutationMember(previousMember) !== isOpenCodeRosterMutationMember(nextMember)
|
||
) {
|
||
migrationNames.push(nextMember.name.trim());
|
||
}
|
||
}
|
||
return migrationNames;
|
||
}
|
||
|
||
function toRollbackReplaceMembersRequest(members: RuntimeRosterMutationMember[]): {
|
||
members: {
|
||
name: string;
|
||
role?: string;
|
||
workflow?: string;
|
||
isolation?: 'worktree';
|
||
providerId?: TeamProviderId;
|
||
providerBackendId?: TeamProviderBackendId;
|
||
model?: string;
|
||
effort?: EffortLevel;
|
||
fastMode?: TeamFastMode;
|
||
mcpPolicy?: ReturnType<typeof normalizeTeamMemberMcpPolicy>;
|
||
}[];
|
||
} {
|
||
return {
|
||
members: members
|
||
.filter((member) => !member.removedAt && !isLeadRosterMutationMember(member))
|
||
.map((member) => ({
|
||
name: member.name.trim(),
|
||
role: member.role?.trim() || undefined,
|
||
workflow: member.workflow?.trim() || undefined,
|
||
isolation: member.isolation === 'worktree' ? ('worktree' as const) : undefined,
|
||
providerId: normalizeOptionalTeamProviderId(member.providerId),
|
||
providerBackendId: migrateProviderBackendId(member.providerId, member.providerBackendId),
|
||
model: member.model?.trim() || undefined,
|
||
effort: member.effort,
|
||
fastMode: member.fastMode,
|
||
mcpPolicy: normalizeTeamMemberMcpPolicy(member.mcpPolicy),
|
||
})),
|
||
};
|
||
}
|
||
|
||
async function restorePreviousMembersMetaSnapshot(options: {
|
||
teamName: string;
|
||
teamDataService: TeamDataService;
|
||
previousMembers: RuntimeRosterMutationMember[];
|
||
previousMembersMeta: TeamMembersMetaFile | null;
|
||
}): Promise<boolean> {
|
||
const { teamName, teamDataService, previousMembers, previousMembersMeta } = options;
|
||
|
||
if (previousMembersMeta) {
|
||
try {
|
||
await new TeamMembersMetaStore().writeMembers(teamName, previousMembersMeta.members, {
|
||
providerBackendId: previousMembersMeta.providerBackendId,
|
||
});
|
||
return true;
|
||
} catch (error) {
|
||
logger.error(
|
||
`Failed to restore exact live roster metadata for ${teamName}: ${
|
||
error instanceof Error ? error.message : String(error)
|
||
}`
|
||
);
|
||
}
|
||
}
|
||
|
||
try {
|
||
await teamDataService.replaceMembers(
|
||
teamName,
|
||
toRollbackReplaceMembersRequest(previousMembers)
|
||
);
|
||
return true;
|
||
} catch (error) {
|
||
logger.error(
|
||
`Failed to roll back fallback live roster metadata for ${teamName}: ${
|
||
error instanceof Error ? error.message : String(error)
|
||
}`
|
||
);
|
||
return false;
|
||
}
|
||
}
|
||
|
||
async function rollbackLiveRosterMutation(options: {
|
||
teamName: string;
|
||
teamDataService: TeamDataService;
|
||
provisioning: TeamProvisioningService;
|
||
previousMembers: RuntimeRosterMutationMember[];
|
||
previousMembersMeta: TeamMembersMetaFile | null;
|
||
restoreLiveMemberNames?: string[];
|
||
detachLiveMemberNames?: string[];
|
||
}): Promise<void> {
|
||
const {
|
||
teamName,
|
||
teamDataService,
|
||
provisioning,
|
||
previousMembers,
|
||
previousMembersMeta,
|
||
restoreLiveMemberNames = [],
|
||
detachLiveMemberNames = [],
|
||
} = options;
|
||
|
||
const detachNames = Array.from(
|
||
new Set(detachLiveMemberNames.map((memberName) => memberName.trim()).filter(Boolean))
|
||
);
|
||
for (const memberName of detachNames) {
|
||
try {
|
||
await provisioning.detachLiveRosterMember(teamName, memberName);
|
||
} catch (error) {
|
||
logger.warn(
|
||
`Failed to clean up live roster member for ${teamName}/${memberName} during rollback: ${
|
||
error instanceof Error ? error.message : String(error)
|
||
}`
|
||
);
|
||
}
|
||
}
|
||
|
||
const metadataRestored = await restorePreviousMembersMetaSnapshot({
|
||
teamName,
|
||
teamDataService,
|
||
previousMembers,
|
||
previousMembersMeta,
|
||
});
|
||
if (metadataRestored) {
|
||
invalidateTeamRosterSnapshotCaches(teamName);
|
||
}
|
||
|
||
if (!metadataRestored) {
|
||
return;
|
||
}
|
||
|
||
const restoreNames = Array.from(
|
||
new Set(restoreLiveMemberNames.map((memberName) => memberName.trim()).filter(Boolean))
|
||
);
|
||
for (const memberName of restoreNames) {
|
||
try {
|
||
await provisioning.attachLiveRosterMember(teamName, memberName, {
|
||
reason: 'member_updated',
|
||
});
|
||
} catch (error) {
|
||
logger.warn(
|
||
`Failed to restore live roster member for ${teamName}/${memberName} during rollback: ${
|
||
error instanceof Error ? error.message : String(error)
|
||
}`
|
||
);
|
||
}
|
||
}
|
||
}
|
||
|
||
async function validateProvisioningRequest(
|
||
request: unknown
|
||
): Promise<{ valid: true; value: TeamCreateRequest } | { valid: false; error: string }> {
|
||
if (!request || typeof request !== 'object') {
|
||
return { valid: false, error: 'Invalid team create request' };
|
||
}
|
||
|
||
const payload = request as Partial<TeamCreateRequest>;
|
||
if (typeof payload.teamName !== 'string' || payload.teamName.trim().length === 0) {
|
||
return { valid: false, error: 'teamName is required' };
|
||
}
|
||
const teamName = payload.teamName.trim();
|
||
if (!isProvisioningTeamName(teamName)) {
|
||
return { valid: false, error: 'teamName must be kebab-case [a-z0-9-], max 64 chars' };
|
||
}
|
||
|
||
if (payload.displayName !== undefined && typeof payload.displayName !== 'string') {
|
||
return { valid: false, error: 'displayName must be string' };
|
||
}
|
||
if (payload.description !== undefined && typeof payload.description !== 'string') {
|
||
return { valid: false, error: 'description must be string' };
|
||
}
|
||
|
||
if (!Array.isArray(payload.members)) {
|
||
return { valid: false, error: 'members must be an array' };
|
||
}
|
||
const providerValidation = parseOptionalTeamProviderId(payload.providerId);
|
||
if (!providerValidation.valid) {
|
||
return { valid: false, error: providerValidation.error };
|
||
}
|
||
const providerId = providerValidation.value ?? 'anthropic';
|
||
|
||
const seenNames = new Set<string>();
|
||
const members: TeamCreateRequest['members'] = [];
|
||
for (const member of payload.members) {
|
||
if (!member || typeof member !== 'object') {
|
||
return { valid: false, error: 'member must be object' };
|
||
}
|
||
const nameValidation = validateTeammateName((member as { name?: unknown }).name);
|
||
if (!nameValidation.valid) {
|
||
return { valid: false, error: nameValidation.error ?? 'Invalid member name' };
|
||
}
|
||
const memberName = nameValidation.value!;
|
||
if (seenNames.has(memberName)) {
|
||
return { valid: false, error: 'member names must be unique' };
|
||
}
|
||
seenNames.add(memberName);
|
||
|
||
const role = (member as { role?: unknown }).role;
|
||
if (role !== undefined && typeof role !== 'string') {
|
||
return { valid: false, error: 'member role must be string' };
|
||
}
|
||
const workflow = (member as { workflow?: unknown }).workflow;
|
||
if (workflow !== undefined && typeof workflow !== 'string') {
|
||
return { valid: false, error: 'member workflow must be string' };
|
||
}
|
||
const isolation = (member as { isolation?: unknown }).isolation;
|
||
if (isolation !== undefined && isolation !== 'worktree') {
|
||
return { valid: false, error: 'member isolation must be "worktree" when provided' };
|
||
}
|
||
const providerValidation = parseOptionalMemberProviderId(
|
||
(member as { providerId?: unknown }).providerId
|
||
);
|
||
if (!providerValidation.valid) {
|
||
return { valid: false, error: providerValidation.error };
|
||
}
|
||
const providerBackendValidation = parseOptionalProviderBackendId(
|
||
(member as { providerBackendId?: unknown }).providerBackendId,
|
||
providerValidation.value ?? providerId
|
||
);
|
||
if (!providerBackendValidation.valid) {
|
||
return { valid: false, error: providerBackendValidation.error };
|
||
}
|
||
const model = (member as { model?: unknown }).model;
|
||
if (model !== undefined && typeof model !== 'string') {
|
||
return { valid: false, error: 'member model must be string' };
|
||
}
|
||
const effortValidation = parseOptionalMemberEffort(
|
||
(member as { effort?: unknown }).effort,
|
||
providerValidation.value ?? providerId
|
||
);
|
||
if (!effortValidation.valid) {
|
||
return { valid: false, error: effortValidation.error };
|
||
}
|
||
const fastModeValidation = parseOptionalTeamFastMode(
|
||
(member as { fastMode?: unknown }).fastMode
|
||
);
|
||
if (!fastModeValidation.valid) {
|
||
return { valid: false, error: fastModeValidation.error };
|
||
}
|
||
members.push({
|
||
name: memberName,
|
||
role: typeof role === 'string' ? role.trim() : undefined,
|
||
workflow: typeof workflow === 'string' ? workflow.trim() : undefined,
|
||
isolation: isolation === 'worktree' ? ('worktree' as const) : undefined,
|
||
providerId: providerValidation.value,
|
||
providerBackendId: providerBackendValidation.value,
|
||
model: typeof model === 'string' ? model.trim() || undefined : undefined,
|
||
effort: effortValidation.value,
|
||
fastMode: fastModeValidation.value,
|
||
mcpPolicy: normalizeTeamMemberMcpPolicy((member as { mcpPolicy?: unknown }).mcpPolicy),
|
||
});
|
||
}
|
||
|
||
if (typeof payload.cwd !== 'string' || payload.cwd.trim().length === 0) {
|
||
return { valid: false, error: 'cwd is required' };
|
||
}
|
||
const cwd = payload.cwd.trim();
|
||
if (!path.isAbsolute(cwd)) {
|
||
return { valid: false, error: 'cwd must be an absolute path' };
|
||
}
|
||
|
||
if (payload.prompt !== undefined && typeof payload.prompt !== 'string') {
|
||
return { valid: false, error: 'prompt must be a string' };
|
||
}
|
||
const providerBackendValidation = parseOptionalLaunchProviderBackendId(
|
||
payload.providerBackendId,
|
||
providerId
|
||
);
|
||
if (!providerBackendValidation.valid) {
|
||
return { valid: false, error: providerBackendValidation.error };
|
||
}
|
||
const effortValidation = parseOptionalTeamEffort(payload.effort, providerId);
|
||
if (!effortValidation.valid) {
|
||
return { valid: false, error: effortValidation.error };
|
||
}
|
||
const fastModeValidation = parseOptionalTeamFastMode(payload.fastMode);
|
||
if (!fastModeValidation.valid) {
|
||
return { valid: false, error: fastModeValidation.error };
|
||
}
|
||
|
||
try {
|
||
await fs.promises.mkdir(cwd, { recursive: true });
|
||
} catch {
|
||
return { valid: false, error: 'failed to create cwd directory' };
|
||
}
|
||
|
||
let stat: fs.Stats;
|
||
try {
|
||
stat = await fs.promises.stat(cwd);
|
||
} catch {
|
||
return { valid: false, error: 'cwd does not exist' };
|
||
}
|
||
if (!stat.isDirectory()) {
|
||
return { valid: false, error: 'cwd must be a directory' };
|
||
}
|
||
|
||
if (payload.worktree !== undefined) {
|
||
if (typeof payload.worktree !== 'string') {
|
||
return { valid: false, error: 'worktree must be a string' };
|
||
}
|
||
const wt = payload.worktree.trim();
|
||
if (wt.length > 128) {
|
||
return { valid: false, error: 'worktree name too long (max 128)' };
|
||
}
|
||
if (wt && !/^[a-zA-Z0-9][a-zA-Z0-9._-]*$/.test(wt)) {
|
||
return {
|
||
valid: false,
|
||
error: 'worktree name: start with alphanumeric, use [a-zA-Z0-9._-]',
|
||
};
|
||
}
|
||
}
|
||
if (payload.extraCliArgs !== undefined) {
|
||
if (typeof payload.extraCliArgs !== 'string') {
|
||
return { valid: false, error: 'extraCliArgs must be a string' };
|
||
}
|
||
if (payload.extraCliArgs.length > 1024) {
|
||
return { valid: false, error: 'extraCliArgs too long (max 1024)' };
|
||
}
|
||
}
|
||
|
||
return {
|
||
valid: true,
|
||
value: {
|
||
teamName,
|
||
displayName: payload.displayName?.trim() || undefined,
|
||
description: payload.description?.trim() || undefined,
|
||
color: typeof payload.color === 'string' ? payload.color.trim() || undefined : undefined,
|
||
members,
|
||
cwd,
|
||
prompt: typeof payload.prompt === 'string' ? payload.prompt.trim() || undefined : undefined,
|
||
providerId,
|
||
providerBackendId: providerBackendValidation.value,
|
||
model: typeof payload.model === 'string' ? payload.model.trim() || undefined : undefined,
|
||
effort: effortValidation.value,
|
||
fastMode: fastModeValidation.value,
|
||
skipPermissions:
|
||
typeof payload.skipPermissions === 'boolean' ? payload.skipPermissions : undefined,
|
||
worktree:
|
||
typeof payload.worktree === 'string' && payload.worktree.trim()
|
||
? payload.worktree.trim()
|
||
: undefined,
|
||
extraCliArgs:
|
||
typeof payload.extraCliArgs === 'string' && payload.extraCliArgs.trim()
|
||
? payload.extraCliArgs.trim()
|
||
: undefined,
|
||
},
|
||
};
|
||
}
|
||
|
||
async function handleGetClaudeLogs(
|
||
_event: IpcMainInvokeEvent,
|
||
teamName: unknown,
|
||
query?: unknown
|
||
): Promise<IpcResult<TeamClaudeLogsResponse>> {
|
||
const validated = validateTeamName(teamName);
|
||
if (!validated.valid) {
|
||
return { success: false, error: validated.error ?? 'Invalid teamName' };
|
||
}
|
||
|
||
let parsed: TeamClaudeLogsQuery | undefined;
|
||
if (query !== undefined) {
|
||
if (!query || typeof query !== 'object') {
|
||
return { success: false, error: 'query must be an object' };
|
||
}
|
||
const q = query as Record<string, unknown>;
|
||
parsed = {
|
||
offset: typeof q.offset === 'number' ? q.offset : undefined,
|
||
limit: typeof q.limit === 'number' ? q.limit : undefined,
|
||
};
|
||
}
|
||
|
||
return wrapTeamHandler('getClaudeLogs', async () => {
|
||
const data = await getTeamProvisioningService().getClaudeLogs(validated.value!, parsed);
|
||
return {
|
||
lines: data.lines,
|
||
total: data.total,
|
||
hasMore: data.hasMore,
|
||
updatedAt: data.updatedAt,
|
||
};
|
||
});
|
||
}
|
||
|
||
function sendProvisioningProgress(
|
||
targetWindow: BrowserWindow | null,
|
||
progress: TeamProvisioningProgress
|
||
): void {
|
||
safeSendToRenderer(targetWindow, TEAM_PROVISIONING_PROGRESS, progress);
|
||
}
|
||
|
||
function noteLaunchIntentFailed(teamName: string, source: string): void {
|
||
if (!launchIoGovernor) {
|
||
return;
|
||
}
|
||
const now = new Date().toISOString();
|
||
launchIoGovernor.noteProvisioningProgress({
|
||
runId: `${source}:failed-before-progress`,
|
||
teamName,
|
||
state: 'failed',
|
||
message: 'Launch failed before provisioning progress',
|
||
startedAt: now,
|
||
updatedAt: now,
|
||
} as TeamProvisioningProgress);
|
||
}
|
||
|
||
async function handleCreateTeam(
|
||
event: IpcMainInvokeEvent,
|
||
request: unknown
|
||
): Promise<IpcResult<TeamCreateResponse>> {
|
||
const validation = await validateProvisioningRequest(request);
|
||
if (!validation.valid) {
|
||
return { success: false, error: validation.error };
|
||
}
|
||
const progressTargetWindow = BrowserWindow.fromWebContents(event.sender);
|
||
|
||
return wrapTeamHandler('create', async () => {
|
||
addMainBreadcrumb('team', 'create', { teamName: validation.value.teamName });
|
||
launchIoGovernor?.noteLaunchIntent(validation.value.teamName, 'create');
|
||
// Keep this team's team-root/task artifacts file-watched while createTeam writes
|
||
// its initial config, tasks, inboxes, and launch state.
|
||
markTeamEngaged(validation.value.teamName);
|
||
try {
|
||
const response = await getTeamProvisioningService().createTeam(
|
||
validation.value,
|
||
(progress) => {
|
||
launchIoGovernor?.noteProvisioningProgress(progress);
|
||
sendProvisioningProgress(progressTargetWindow, progress);
|
||
}
|
||
);
|
||
invalidateTeamRosterSnapshotCaches(validation.value.teamName);
|
||
return response;
|
||
} catch (error) {
|
||
noteLaunchIntentFailed(validation.value.teamName, 'create');
|
||
throw error;
|
||
}
|
||
});
|
||
}
|
||
|
||
async function handleLaunchTeam(
|
||
event: IpcMainInvokeEvent,
|
||
request: unknown
|
||
): Promise<IpcResult<TeamLaunchResponse>> {
|
||
if (!request || typeof request !== 'object') {
|
||
return { success: false, error: 'Invalid team launch request' };
|
||
}
|
||
const progressTargetWindow = BrowserWindow.fromWebContents(event.sender);
|
||
|
||
const payload = request as Partial<TeamLaunchRequest>;
|
||
const validatedTeamName = validateTeamName(payload.teamName);
|
||
if (!validatedTeamName.valid) {
|
||
return { success: false, error: validatedTeamName.error ?? 'Invalid teamName' };
|
||
}
|
||
|
||
if (typeof payload.cwd !== 'string' || payload.cwd.trim().length === 0) {
|
||
return { success: false, error: 'cwd is required' };
|
||
}
|
||
const cwd = payload.cwd.trim();
|
||
if (!path.isAbsolute(cwd)) {
|
||
return { success: false, error: 'cwd must be an absolute path' };
|
||
}
|
||
|
||
try {
|
||
const stat = await fs.promises.stat(cwd);
|
||
if (!stat.isDirectory()) {
|
||
return { success: false, error: 'cwd must be a directory' };
|
||
}
|
||
} catch {
|
||
return { success: false, error: 'cwd does not exist' };
|
||
}
|
||
|
||
if (payload.prompt !== undefined && typeof payload.prompt !== 'string') {
|
||
return { success: false, error: 'prompt must be a string' };
|
||
}
|
||
|
||
if (payload.model !== undefined && typeof payload.model !== 'string') {
|
||
return { success: false, error: 'model must be a string' };
|
||
}
|
||
const providerValidation = parseOptionalTeamProviderId(payload.providerId);
|
||
if (!providerValidation.valid) {
|
||
return { success: false, error: providerValidation.error };
|
||
}
|
||
const explicitProviderId = providerValidation.value;
|
||
const providerId = explicitProviderId ?? 'anthropic';
|
||
const providerBackendValidation = parseOptionalLaunchProviderBackendId(
|
||
payload.providerBackendId,
|
||
providerId
|
||
);
|
||
if (!providerBackendValidation.valid) {
|
||
return { success: false, error: providerBackendValidation.error };
|
||
}
|
||
|
||
// Detect draft team: team.meta.json exists but config.json doesn't.
|
||
// This happens when user created team config without launching (launchTeam=false),
|
||
// or when provisioning failed before TeamCreate could run.
|
||
// Redirect to createTeam so TeamCreate runs properly.
|
||
const tn = validatedTeamName.value!;
|
||
const configPath = path.join(getTeamsBasePath(), tn, 'config.json');
|
||
let isDraft = false;
|
||
try {
|
||
await fs.promises.access(configPath, fs.constants.F_OK);
|
||
} catch {
|
||
const meta = await teamMetaStore.getMeta(tn);
|
||
if (meta) isDraft = true;
|
||
}
|
||
|
||
if (isDraft) {
|
||
const savedRequest = await getTeamDataService().getSavedRequest(tn);
|
||
if (!savedRequest) {
|
||
return { success: false, error: `Missing saved request for draft team: ${tn}` };
|
||
}
|
||
|
||
const savedProviderId = savedRequest.providerId ?? 'anthropic';
|
||
const resolvedProviderId = explicitProviderId ?? savedRequest.providerId ?? providerId;
|
||
const providerChangedFromSaved =
|
||
explicitProviderId != null && explicitProviderId !== savedProviderId;
|
||
const effortValidation = parseOptionalTeamEffort(
|
||
Object.hasOwn(payload, 'effort')
|
||
? payload.effort
|
||
: providerChangedFromSaved
|
||
? undefined
|
||
: savedRequest.effort,
|
||
resolvedProviderId
|
||
);
|
||
if (!effortValidation.valid) {
|
||
return { success: false, error: effortValidation.error };
|
||
}
|
||
const fastModeValidation = parseOptionalTeamFastMode(
|
||
Object.hasOwn(payload, 'fastMode')
|
||
? payload.fastMode
|
||
: providerChangedFromSaved
|
||
? undefined
|
||
: savedRequest.fastMode
|
||
);
|
||
if (!fastModeValidation.valid) {
|
||
return { success: false, error: fastModeValidation.error };
|
||
}
|
||
const draftModel = Object.hasOwn(payload, 'model')
|
||
? typeof payload.model === 'string'
|
||
? payload.model.trim() || undefined
|
||
: undefined
|
||
: providerChangedFromSaved
|
||
? undefined
|
||
: savedRequest.model;
|
||
const draftLimitContext =
|
||
typeof payload.limitContext === 'boolean'
|
||
? payload.limitContext
|
||
: providerChangedFromSaved
|
||
? undefined
|
||
: savedRequest.limitContext;
|
||
|
||
const createRequest: TeamCreateRequest = {
|
||
teamName: tn,
|
||
displayName: savedRequest.displayName,
|
||
description: savedRequest.description,
|
||
color: savedRequest.color,
|
||
cwd,
|
||
prompt:
|
||
typeof payload.prompt === 'string'
|
||
? payload.prompt.trim() || undefined
|
||
: savedRequest.prompt,
|
||
providerId: resolvedProviderId,
|
||
providerBackendId: migrateProviderBackendId(
|
||
resolvedProviderId,
|
||
providerBackendValidation.value ?? savedRequest.providerBackendId
|
||
),
|
||
model: draftModel,
|
||
effort: effortValidation.value,
|
||
fastMode: fastModeValidation.value,
|
||
limitContext: draftLimitContext,
|
||
skipPermissions:
|
||
typeof payload.skipPermissions === 'boolean'
|
||
? payload.skipPermissions
|
||
: savedRequest.skipPermissions,
|
||
worktree:
|
||
typeof payload.worktree === 'string'
|
||
? payload.worktree.trim() || undefined
|
||
: savedRequest.worktree,
|
||
extraCliArgs:
|
||
typeof payload.extraCliArgs === 'string'
|
||
? payload.extraCliArgs.trim() || undefined
|
||
: savedRequest.extraCliArgs,
|
||
members: savedRequest.members,
|
||
};
|
||
|
||
return wrapTeamHandler('create', async () => {
|
||
launchIoGovernor?.noteLaunchIntent(tn, 'draft-launch');
|
||
// Draft launch runs through createTeam, so it needs the same immediate watch scope
|
||
// as a normal launch before startup files begin changing.
|
||
markTeamEngaged(tn);
|
||
try {
|
||
const response = await getTeamProvisioningService().createTeam(
|
||
createRequest,
|
||
(progress) => {
|
||
launchIoGovernor?.noteProvisioningProgress(progress);
|
||
sendProvisioningProgress(progressTargetWindow, progress);
|
||
}
|
||
);
|
||
invalidateTeamRosterSnapshotCaches(tn);
|
||
return response;
|
||
} catch (error) {
|
||
noteLaunchIntentFailed(tn, 'draft-launch');
|
||
throw error;
|
||
}
|
||
});
|
||
}
|
||
|
||
const persistedMeta = await teamMetaStore.getMeta(tn).catch(() => null);
|
||
const persistedLaunchProviderId =
|
||
persistedMeta?.launchIdentity?.providerId ?? persistedMeta?.providerId ?? 'anthropic';
|
||
const launchProviderId =
|
||
explicitProviderId ??
|
||
persistedMeta?.launchIdentity?.providerId ??
|
||
persistedMeta?.providerId ??
|
||
providerId;
|
||
const providerChangedFromPersisted =
|
||
explicitProviderId != null && explicitProviderId !== persistedLaunchProviderId;
|
||
const rawLaunchProviderBackendId = Object.hasOwn(payload, 'providerBackendId')
|
||
? payload.providerBackendId
|
||
: providerChangedFromPersisted
|
||
? undefined
|
||
: persistedMeta?.launchIdentity
|
||
? migrateProviderBackendId(
|
||
persistedMeta.launchIdentity.providerId,
|
||
persistedMeta.launchIdentity.providerBackendId ?? persistedMeta.providerBackendId
|
||
)
|
||
: (persistedMeta?.providerBackendId ?? undefined);
|
||
const launchProviderBackendValidation = parseOptionalLaunchProviderBackendId(
|
||
rawLaunchProviderBackendId,
|
||
launchProviderId
|
||
);
|
||
if (!launchProviderBackendValidation.valid) {
|
||
return { success: false, error: launchProviderBackendValidation.error };
|
||
}
|
||
const persistedLaunchEffort = providerChangedFromPersisted
|
||
? undefined
|
||
: (persistedMeta?.launchIdentity?.selectedEffort ?? persistedMeta?.effort ?? undefined);
|
||
const rawLaunchEffort = Object.hasOwn(payload, 'effort') ? payload.effort : persistedLaunchEffort;
|
||
const effortValidation = parseOptionalTeamEffort(rawLaunchEffort, launchProviderId);
|
||
if (!effortValidation.valid) {
|
||
return { success: false, error: effortValidation.error };
|
||
}
|
||
const persistedLaunchFastMode = providerChangedFromPersisted
|
||
? undefined
|
||
: (persistedMeta?.launchIdentity?.selectedFastMode ?? persistedMeta?.fastMode ?? undefined);
|
||
const rawLaunchFastMode = Object.hasOwn(payload, 'fastMode')
|
||
? payload.fastMode
|
||
: persistedLaunchFastMode;
|
||
const fastModeValidation = parseOptionalTeamFastMode(rawLaunchFastMode);
|
||
if (!fastModeValidation.valid) {
|
||
return { success: false, error: fastModeValidation.error };
|
||
}
|
||
const persistedLaunchModel = providerChangedFromPersisted
|
||
? undefined
|
||
: (persistedMeta?.launchIdentity?.selectedModel ?? persistedMeta?.model ?? undefined);
|
||
const rawLaunchModel = Object.hasOwn(payload, 'model')
|
||
? typeof payload.model === 'string' && payload.model.trim().length > 0
|
||
? payload.model.trim()
|
||
: undefined
|
||
: persistedLaunchModel;
|
||
const launchLimitContext =
|
||
typeof payload.limitContext === 'boolean'
|
||
? payload.limitContext
|
||
: providerChangedFromPersisted
|
||
? undefined
|
||
: persistedMeta?.limitContext;
|
||
|
||
return wrapTeamHandler('launch', async () => {
|
||
addMainBreadcrumb('team', 'launch', { teamName: validatedTeamName.value! });
|
||
launchIoGovernor?.noteLaunchIntent(validatedTeamName.value!, 'launch');
|
||
// Keep this team's team-root/task artifacts file-watched for the whole launch (and the
|
||
// engaged TTL after), so the lead's immediate startup writes are not missed during the
|
||
// 0-30s window before the periodic watch-scope reconcile would otherwise pick it up.
|
||
markTeamEngaged(validatedTeamName.value!);
|
||
try {
|
||
const response = await getTeamProvisioningService().launchTeam(
|
||
{
|
||
teamName: validatedTeamName.value!,
|
||
cwd,
|
||
prompt:
|
||
typeof payload.prompt === 'string' ? payload.prompt.trim() || undefined : undefined,
|
||
providerId: launchProviderId,
|
||
providerBackendId: launchProviderBackendValidation.value,
|
||
model: rawLaunchModel,
|
||
effort: effortValidation.value,
|
||
fastMode: fastModeValidation.value,
|
||
limitContext: launchLimitContext,
|
||
clearContext: payload.clearContext === true ? true : undefined,
|
||
skipPermissions:
|
||
typeof payload.skipPermissions === 'boolean' ? payload.skipPermissions : undefined,
|
||
worktree:
|
||
typeof payload.worktree === 'string' ? payload.worktree.trim() || undefined : undefined,
|
||
extraCliArgs:
|
||
typeof payload.extraCliArgs === 'string'
|
||
? payload.extraCliArgs.trim() || undefined
|
||
: undefined,
|
||
},
|
||
(progress) => {
|
||
launchIoGovernor?.noteProvisioningProgress(progress);
|
||
sendProvisioningProgress(progressTargetWindow, progress);
|
||
}
|
||
);
|
||
invalidateTeamRosterSnapshotCaches(validatedTeamName.value!);
|
||
return response;
|
||
} catch (error) {
|
||
noteLaunchIntentFailed(validatedTeamName.value!, 'launch');
|
||
throw error;
|
||
}
|
||
});
|
||
}
|
||
|
||
async function handleValidateCliArgs(
|
||
_event: IpcMainInvokeEvent,
|
||
rawArgs: unknown
|
||
): Promise<IpcResult<CliArgsValidationResult>> {
|
||
if (typeof rawArgs !== 'string') {
|
||
return { success: false, error: 'rawArgs must be a string' };
|
||
}
|
||
if (rawArgs.length > 2048) {
|
||
return { success: false, error: 'rawArgs too long (max 2048)' };
|
||
}
|
||
return wrapTeamHandler('validateCliArgs', async () => {
|
||
const helpOutput = await getTeamProvisioningService().getCliHelpOutput();
|
||
const knownFlags = extractFlagsFromHelp(helpOutput);
|
||
const userFlags = extractUserFlags(rawArgs);
|
||
|
||
const invalidFlags = userFlags.filter((f) => !knownFlags.has(f));
|
||
const protectedFlags = userFlags.filter((f) => PROTECTED_CLI_FLAGS.has(f));
|
||
const allBad = [...new Set([...invalidFlags, ...protectedFlags])];
|
||
|
||
return {
|
||
valid: allBad.length === 0,
|
||
invalidFlags: allBad.length > 0 ? allBad : undefined,
|
||
};
|
||
});
|
||
}
|
||
|
||
async function handlePrepareProvisioning(
|
||
_event: IpcMainInvokeEvent,
|
||
cwd: unknown,
|
||
providerId: unknown,
|
||
providerIds: unknown,
|
||
selectedModels: unknown,
|
||
limitContext: unknown,
|
||
modelVerificationMode: unknown,
|
||
selectedModelChecks: unknown
|
||
): Promise<IpcResult<TeamProvisioningPrepareResult>> {
|
||
let validatedCwd: string | undefined;
|
||
let validatedProviderId: TeamLaunchRequest['providerId'];
|
||
let validatedProviderIds: TeamProviderId[] | undefined;
|
||
let validatedSelectedModels: string[] | undefined;
|
||
let validatedLimitContext: boolean | undefined;
|
||
let validatedModelVerificationMode: TeamProvisioningModelVerificationMode | undefined;
|
||
let validatedSelectedModelChecks: TeamProvisioningModelCheckRequest[] | undefined;
|
||
if (cwd !== undefined) {
|
||
if (typeof cwd !== 'string' || cwd.trim().length === 0) {
|
||
return { success: false, error: 'cwd must be a non-empty string' };
|
||
}
|
||
validatedCwd = cwd.trim();
|
||
if (!path.isAbsolute(validatedCwd)) {
|
||
return { success: false, error: 'cwd must be an absolute path' };
|
||
}
|
||
}
|
||
if (providerId !== undefined) {
|
||
if (!isTeamProviderId(providerId)) {
|
||
return { success: false, error: 'providerId must be anthropic, codex, gemini, or opencode' };
|
||
}
|
||
validatedProviderId = providerId;
|
||
}
|
||
if (providerIds !== undefined) {
|
||
if (!Array.isArray(providerIds)) {
|
||
return { success: false, error: 'providerIds must be an array when provided' };
|
||
}
|
||
const normalized: TeamProviderId[] = [];
|
||
for (const entry of providerIds) {
|
||
if (!isTeamProviderId(entry)) {
|
||
return {
|
||
success: false,
|
||
error: 'providerIds entries must be anthropic, codex, gemini, or opencode',
|
||
};
|
||
}
|
||
if (!normalized.includes(entry)) {
|
||
normalized.push(entry);
|
||
}
|
||
}
|
||
validatedProviderIds = normalized;
|
||
}
|
||
if (selectedModels !== undefined) {
|
||
if (!Array.isArray(selectedModels)) {
|
||
return { success: false, error: 'selectedModels must be an array when provided' };
|
||
}
|
||
const normalized = Array.from(
|
||
new Set(
|
||
selectedModels
|
||
.filter((entry): entry is string => typeof entry === 'string')
|
||
.map((entry) => entry.trim())
|
||
.filter((entry) => entry.length > 0)
|
||
)
|
||
);
|
||
validatedSelectedModels = normalized;
|
||
}
|
||
if (limitContext !== undefined) {
|
||
if (typeof limitContext !== 'boolean') {
|
||
return { success: false, error: 'limitContext must be a boolean when provided' };
|
||
}
|
||
validatedLimitContext = limitContext;
|
||
}
|
||
if (modelVerificationMode !== undefined) {
|
||
if (modelVerificationMode !== 'compatibility' && modelVerificationMode !== 'deep') {
|
||
return {
|
||
success: false,
|
||
error: 'modelVerificationMode must be compatibility or deep when provided',
|
||
};
|
||
}
|
||
validatedModelVerificationMode = modelVerificationMode;
|
||
}
|
||
if (selectedModelChecks !== undefined) {
|
||
if (!Array.isArray(selectedModelChecks)) {
|
||
return { success: false, error: 'selectedModelChecks must be an array when provided' };
|
||
}
|
||
const normalized: TeamProvisioningModelCheckRequest[] = [];
|
||
const seen = new Set<string>();
|
||
for (const entry of selectedModelChecks) {
|
||
if (!entry || typeof entry !== 'object') {
|
||
return { success: false, error: 'selectedModelChecks entries must be objects' };
|
||
}
|
||
const rawEntry = entry as {
|
||
providerId?: unknown;
|
||
model?: unknown;
|
||
effort?: unknown;
|
||
};
|
||
if (!isTeamProviderId(rawEntry.providerId)) {
|
||
return {
|
||
success: false,
|
||
error: 'selectedModelChecks entries must include a valid providerId',
|
||
};
|
||
}
|
||
if (typeof rawEntry.model !== 'string' || rawEntry.model.trim().length === 0) {
|
||
return {
|
||
success: false,
|
||
error: 'selectedModelChecks entries must include a non-empty model',
|
||
};
|
||
}
|
||
const effortValidation = parseOptionalTeamEffort(rawEntry.effort, rawEntry.providerId);
|
||
if (!effortValidation.valid) {
|
||
return { success: false, error: `selectedModelChecks ${effortValidation.error}` };
|
||
}
|
||
const model = rawEntry.model.trim();
|
||
const key = `${rawEntry.providerId}\n${model}\n${effortValidation.value ?? ''}`;
|
||
if (seen.has(key)) {
|
||
continue;
|
||
}
|
||
seen.add(key);
|
||
normalized.push({
|
||
providerId: rawEntry.providerId,
|
||
model,
|
||
...(effortValidation.value ? { effort: effortValidation.value } : {}),
|
||
});
|
||
}
|
||
validatedSelectedModelChecks = normalized;
|
||
}
|
||
return wrapTeamHandler('prepareProvisioning', () =>
|
||
getTeamProvisioningService().prepareForProvisioning(validatedCwd, {
|
||
providerId: validatedProviderId,
|
||
providerIds: validatedProviderIds,
|
||
modelIds: validatedSelectedModels,
|
||
limitContext: validatedLimitContext,
|
||
modelVerificationMode: validatedModelVerificationMode,
|
||
modelChecks: validatedSelectedModelChecks,
|
||
})
|
||
);
|
||
}
|
||
|
||
async function handleProvisioningStatus(
|
||
_event: IpcMainInvokeEvent,
|
||
runId: unknown
|
||
): Promise<IpcResult<TeamProvisioningProgress>> {
|
||
if (typeof runId !== 'string' || runId.trim().length === 0) {
|
||
return { success: false, error: 'runId is required' };
|
||
}
|
||
return wrapTeamHandler('provisioningStatus', () =>
|
||
getTeamProvisioningService().getProvisioningStatus(runId.trim())
|
||
);
|
||
}
|
||
|
||
async function handleLaunchFailureDiagnostics(
|
||
_event: IpcMainInvokeEvent,
|
||
teamName: unknown,
|
||
runId: unknown
|
||
): Promise<IpcResult<TeamLaunchFailureDiagnosticsBundle>> {
|
||
const validatedTeamName = validateTeamName(teamName);
|
||
if (!validatedTeamName.valid) {
|
||
return { success: false, error: validatedTeamName.error ?? 'Invalid teamName' };
|
||
}
|
||
const validatedRunId = typeof runId === 'string' && runId.trim() ? runId.trim() : undefined;
|
||
return wrapTeamHandler('launchFailureDiagnostics', () =>
|
||
readTeamLaunchFailureDiagnosticsBundle(validatedTeamName.value!, validatedRunId)
|
||
);
|
||
}
|
||
|
||
async function handleCancelProvisioning(
|
||
_event: IpcMainInvokeEvent,
|
||
runId: unknown
|
||
): Promise<IpcResult<void>> {
|
||
if (typeof runId !== 'string' || runId.trim().length === 0) {
|
||
return { success: false, error: 'runId is required' };
|
||
}
|
||
return wrapTeamHandler('cancelProvisioning', () =>
|
||
getTeamProvisioningService().cancelProvisioning(runId.trim())
|
||
);
|
||
}
|
||
|
||
function isUpdateKanbanPatch(value: unknown): value is UpdateKanbanPatch {
|
||
if (!value || typeof value !== 'object') {
|
||
return false;
|
||
}
|
||
|
||
const patch = value as Partial<UpdateKanbanPatch> & { op?: unknown; column?: unknown };
|
||
if (patch.op === 'remove') {
|
||
return true;
|
||
}
|
||
|
||
if (patch.op === 'request_changes') {
|
||
return (
|
||
(patch.comment === undefined || typeof patch.comment === 'string') &&
|
||
validateTaskRefs((patch as { taskRefs?: unknown }).taskRefs).valid
|
||
);
|
||
}
|
||
|
||
return patch.op === 'set_column' && (patch.column === 'review' || patch.column === 'approved');
|
||
}
|
||
|
||
function validateTaskRefs(
|
||
value: unknown
|
||
): { valid: true; value: TaskRef[] | undefined } | { valid: false; error: string } {
|
||
if (value === undefined) {
|
||
return { valid: true, value: undefined };
|
||
}
|
||
if (!Array.isArray(value)) {
|
||
return { valid: false, error: 'taskRefs must be an array' };
|
||
}
|
||
|
||
const taskRefs: TaskRef[] = [];
|
||
for (const entry of value) {
|
||
if (!entry || typeof entry !== 'object') {
|
||
return { valid: false, error: 'taskRefs entries must be objects' };
|
||
}
|
||
const row = entry as Partial<TaskRef>;
|
||
const taskId = typeof row.taskId === 'string' ? row.taskId.trim() : '';
|
||
const displayId = typeof row.displayId === 'string' ? row.displayId.trim() : '';
|
||
const teamName = typeof row.teamName === 'string' ? row.teamName.trim() : '';
|
||
if (!taskId || !displayId || !teamName) {
|
||
return { valid: false, error: 'Each taskRef must include taskId, displayId, and teamName' };
|
||
}
|
||
const validatedTaskId = validateTaskId(taskId);
|
||
if (!validatedTaskId.valid) {
|
||
return { valid: false, error: validatedTaskId.error ?? 'Invalid taskRef taskId' };
|
||
}
|
||
const validatedTeamName = validateTeamName(teamName);
|
||
if (!validatedTeamName.valid) {
|
||
return { valid: false, error: validatedTeamName.error ?? 'Invalid taskRef teamName' };
|
||
}
|
||
taskRefs.push({
|
||
taskId: validatedTaskId.value!,
|
||
displayId,
|
||
teamName: validatedTeamName.value!,
|
||
});
|
||
}
|
||
|
||
return { valid: true, value: taskRefs };
|
||
}
|
||
|
||
async function handleGetAttachments(
|
||
_event: IpcMainInvokeEvent,
|
||
teamName: unknown,
|
||
messageId: unknown
|
||
): Promise<IpcResult<AttachmentFileData[]>> {
|
||
const vTeam = validateTeamName(teamName);
|
||
if (!vTeam.valid) return { success: false, error: vTeam.error ?? 'Invalid teamName' };
|
||
if (typeof messageId !== 'string' || messageId.trim().length === 0) {
|
||
return { success: false, error: 'messageId must be a non-empty string' };
|
||
}
|
||
const safeMessageId = messageId.trim();
|
||
if (safeMessageId.includes('/') || safeMessageId.includes('\\') || safeMessageId.includes('..')) {
|
||
return { success: false, error: 'Invalid messageId' };
|
||
}
|
||
return wrapTeamHandler('getAttachments', () =>
|
||
attachmentStore.getAttachments(vTeam.value!, safeMessageId)
|
||
);
|
||
}
|
||
|
||
function validateAttachments(
|
||
attachments: unknown
|
||
): { valid: true; value: AttachmentPayload[] } | { valid: false; error: string } {
|
||
if (!Array.isArray(attachments)) {
|
||
return { valid: false, error: 'attachments must be an array' };
|
||
}
|
||
if (attachments.length > MAX_ATTACHMENTS) {
|
||
return { valid: false, error: `Maximum ${MAX_ATTACHMENTS} attachments allowed` };
|
||
}
|
||
let totalSize = 0;
|
||
const result: AttachmentPayload[] = [];
|
||
for (const att of attachments) {
|
||
if (!att || typeof att !== 'object') {
|
||
return { valid: false, error: 'Invalid attachment entry' };
|
||
}
|
||
const a = att as Partial<AttachmentPayload>;
|
||
if (typeof a.id !== 'string' || typeof a.filename !== 'string') {
|
||
return { valid: false, error: 'Attachment must have id and filename' };
|
||
}
|
||
if (typeof a.data !== 'string' || typeof a.mimeType !== 'string') {
|
||
return { valid: false, error: 'Attachment must have data and mimeType' };
|
||
}
|
||
if (typeof a.size !== 'number' || a.size <= 0) {
|
||
return { valid: false, error: 'Attachment must have a positive size' };
|
||
}
|
||
if (!ALLOWED_ATTACHMENT_TYPES.has(a.mimeType)) {
|
||
return { valid: false, error: `Unsupported attachment type: ${a.mimeType}` };
|
||
}
|
||
if (a.size > MAX_ATTACHMENT_SIZE) {
|
||
return { valid: false, error: `Attachment "${a.filename}" exceeds 10MB limit` };
|
||
}
|
||
// Sanity check: base64 data should be roughly 4/3 of the reported binary size
|
||
const estimatedBinarySize = Math.ceil(a.data.length * 0.75);
|
||
if (estimatedBinarySize > MAX_ATTACHMENT_SIZE * 1.1) {
|
||
return { valid: false, error: `Attachment "${a.filename}" data exceeds size limit` };
|
||
}
|
||
totalSize += Math.max(a.size, estimatedBinarySize);
|
||
result.push({
|
||
id: a.id,
|
||
filename: a.filename,
|
||
data: a.data,
|
||
mimeType: a.mimeType,
|
||
size: a.size,
|
||
});
|
||
}
|
||
if (totalSize > MAX_TOTAL_ATTACHMENT_SIZE) {
|
||
return { valid: false, error: 'Total attachment size exceeds 20MB limit' };
|
||
}
|
||
return { valid: true, value: result };
|
||
}
|
||
|
||
function formatAttachmentBytes(bytes: number): string {
|
||
if (bytes < 1024) return `${bytes} B`;
|
||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
||
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
||
}
|
||
|
||
function validateAttachmentSerializedPayload(input: {
|
||
text: string;
|
||
attachments: AttachmentPayload[];
|
||
}): { valid: true } | { valid: false; error: string } {
|
||
const estimatedBytes = estimateAgentAttachmentSerializedPayloadBytes(input);
|
||
if (estimatedBytes <= MAX_AGENT_ATTACHMENT_SERIALIZED_PAYLOAD_BYTES) {
|
||
return { valid: true };
|
||
}
|
||
return {
|
||
valid: false,
|
||
error: `Attachment payload is too large after optimization: ${formatAttachmentBytes(
|
||
estimatedBytes
|
||
)} serialized. Limit is ${formatAttachmentBytes(
|
||
MAX_AGENT_ATTACHMENT_SERIALIZED_PAYLOAD_BYTES
|
||
)}. Remove an image or use a smaller screenshot.`,
|
||
};
|
||
}
|
||
|
||
function formatAttachmentDeliveryFailure(error: unknown, teamStillAlive: boolean): string {
|
||
if (!teamStillAlive) {
|
||
return 'Failed to deliver message with attachments: team process became unavailable';
|
||
}
|
||
const message = getErrorMessage(error);
|
||
if (message.startsWith('Failed to deliver message with attachments:')) {
|
||
return message;
|
||
}
|
||
return `Failed to deliver message with attachments: ${message}`;
|
||
}
|
||
|
||
function buildMessageDeliveryText(
|
||
baseText: string,
|
||
opts: {
|
||
actionMode?: AgentActionMode;
|
||
isLeadRecipient: boolean;
|
||
memberName?: string;
|
||
messageId?: string;
|
||
protocol?: VisibleDirectReplyProtocol;
|
||
replyRecipient?: string;
|
||
teamName?: string;
|
||
}
|
||
): string {
|
||
const hiddenBlocks: string[] = [];
|
||
const actionModeBlock = buildActionModeAgentBlock(opts.actionMode);
|
||
if (actionModeBlock) {
|
||
hiddenBlocks.push(actionModeBlock);
|
||
}
|
||
if (!opts.isLeadRecipient) {
|
||
const rawReplyRecipient =
|
||
typeof opts.replyRecipient === 'string' && opts.replyRecipient.trim().length > 0
|
||
? opts.replyRecipient.trim()
|
||
: 'user';
|
||
const isUserReplyRecipient = rawReplyRecipient.toLowerCase() === 'user';
|
||
const replyRecipient = isUserReplyRecipient ? 'user' : rawReplyRecipient;
|
||
const senderDescriptor = isUserReplyRecipient ? 'the human user' : `"${replyRecipient}"`;
|
||
const protocol = opts.protocol ?? 'send_message';
|
||
const canUseAgentTeamsMessageSend =
|
||
protocol === 'agent_teams_message_send' &&
|
||
isUserReplyRecipient &&
|
||
typeof opts.teamName === 'string' &&
|
||
opts.teamName.trim().length > 0 &&
|
||
typeof opts.memberName === 'string' &&
|
||
opts.memberName.trim().length > 0 &&
|
||
typeof opts.messageId === 'string' &&
|
||
opts.messageId.trim().length > 0;
|
||
const replyInstructionLines = canUseAgentTeamsMessageSend
|
||
? [
|
||
'CRITICAL: Reply using the Agent Teams MCP message_send tool, not SendMessage.',
|
||
'Use tool agent-teams_message_send or mcp__agent-teams__message_send, whichever exposed name is available.',
|
||
`CRITICAL: The tool input must include teamName="${opts.teamName!.trim()}", to="user", from="${opts.memberName!.trim()}", text, summary, source="runtime_delivery", and relayOfMessageId="${opts.messageId!.trim()}".`,
|
||
'Do NOT answer only with normal assistant text when the Agent Teams message_send tool is available because that will not appear in the UI message thread.',
|
||
]
|
||
: [
|
||
'CRITICAL: Reply using the SendMessage tool, not plain assistant text.',
|
||
`CRITICAL: The destination must be exactly to="${replyRecipient}".`,
|
||
'CRITICAL: The SendMessage tool input must use the exact field names `to`, `summary`, and `message`.',
|
||
'Do NOT answer only with normal assistant text because that will not appear in the UI message thread.',
|
||
];
|
||
hiddenBlocks.push(
|
||
wrapAgentBlock(
|
||
[
|
||
`You received a direct message from ${senderDescriptor} via the UI.`,
|
||
...replyInstructionLines,
|
||
`Please reply back to recipient "${replyRecipient}" with a short, human-readable answer.`,
|
||
'If you cannot respond now, reply with a brief status (e.g. "Busy, will reply later").',
|
||
...(canUseAgentTeamsMessageSend
|
||
? [
|
||
'If neither Agent Teams MCP message_send tool name is available before any visible-message tool attempt, write exactly the concise reply text as normal assistant text so the runtime can relay it.',
|
||
]
|
||
: []),
|
||
...(isUserReplyRecipient
|
||
? [
|
||
'CRITICAL: If the user asks you to check with the lead or another teammate before you can fully answer, FIRST send a short acknowledgement to "user" so the human sees you started (for example: "Принял, сейчас уточню и вернусь с ответом.").',
|
||
'Only after that first acknowledgement may you message the lead or another teammate.',
|
||
'After you get the needed information, send the final answer back to "user".',
|
||
'Do NOT stay silent while you go ask someone else.',
|
||
]
|
||
: []),
|
||
].join('\n')
|
||
)
|
||
);
|
||
}
|
||
|
||
if (hiddenBlocks.length === 0) {
|
||
return baseText;
|
||
}
|
||
|
||
return [...hiddenBlocks, baseText].join('\n\n');
|
||
}
|
||
|
||
async function handleGetMessagesPage(
|
||
_event: IpcMainInvokeEvent,
|
||
teamName: unknown,
|
||
options: unknown
|
||
): Promise<IpcResult<MessagesPage>> {
|
||
const vTeam = validateTeamName(teamName);
|
||
if (!vTeam.valid) {
|
||
return { success: false, error: vTeam.error ?? 'Invalid teamName' };
|
||
}
|
||
const opts = (options && typeof options === 'object' ? options : {}) as {
|
||
cursor?: string | null;
|
||
limit?: number;
|
||
};
|
||
const limit = Math.min(Math.max(1, opts.limit ?? 50), 200);
|
||
const cursor =
|
||
typeof opts.cursor === 'string' ? opts.cursor : opts.cursor === null ? null : undefined;
|
||
|
||
return wrapTeamHandler('getMessagesPage', async () => {
|
||
let page: MessagesPage;
|
||
const teamName = vTeam.value!;
|
||
const scanNotifications = (messagesPage: MessagesPage): void => {
|
||
const notificationContextPromise: Promise<{ displayName: string; projectPath?: string }> =
|
||
getTeamDataService()
|
||
.getTeamNotificationContext(teamName)
|
||
.catch(() => ({ displayName: teamName }));
|
||
void notificationContextPromise
|
||
.then((notificationContext) => {
|
||
teamMessageNotificationScanner.scan(messagesPage.messages, {
|
||
teamName,
|
||
teamDisplayName: notificationContext.displayName,
|
||
projectPath: notificationContext.projectPath,
|
||
});
|
||
})
|
||
.catch((error: unknown) => {
|
||
logger.debug(
|
||
`[teams:getMessagesPage] notification scan skipped team=${teamName}: ${
|
||
error instanceof Error ? error.message : String(error)
|
||
}`
|
||
);
|
||
});
|
||
};
|
||
const liveMessages =
|
||
cursor == null ? getTeamProvisioningService().getLiveLeadProcessMessages(teamName) : [];
|
||
|
||
if (liveMessages.length > 0) {
|
||
page = await getNewestMessagesPageWithLiveOverlay({
|
||
teamName,
|
||
limit,
|
||
liveMessages,
|
||
includeUndefinedCursorInFallback: true,
|
||
});
|
||
scanNotifications(page);
|
||
return page;
|
||
}
|
||
|
||
const worker = getTeamDataWorkerClient();
|
||
if (worker.isAvailable()) {
|
||
try {
|
||
page = await worker.getMessagesPage(teamName, { cursor, limit });
|
||
scanNotifications(page);
|
||
return page;
|
||
} catch (workerErr) {
|
||
logger.warn(
|
||
`[teams:getMessagesPage] worker failed, falling back: ${
|
||
workerErr instanceof Error ? workerErr.message : workerErr
|
||
}`
|
||
);
|
||
}
|
||
}
|
||
noteHeavyTeamDataWorkerFallback('teams:getMessagesPage');
|
||
page = await getTeamDataService().getMessagesPage(teamName, { cursor, limit });
|
||
scanNotifications(page);
|
||
return page;
|
||
});
|
||
}
|
||
|
||
async function handleGetMemberActivityMeta(
|
||
_event: IpcMainInvokeEvent,
|
||
teamName: unknown
|
||
): Promise<IpcResult<TeamMemberActivityMeta>> {
|
||
const vTeam = validateTeamName(teamName);
|
||
if (!vTeam.valid) {
|
||
return { success: false, error: vTeam.error ?? 'Invalid teamName' };
|
||
}
|
||
|
||
return wrapTeamHandler('getMemberActivityMeta', async () => {
|
||
const worker = getTeamDataWorkerClient();
|
||
if (worker.isAvailable()) {
|
||
try {
|
||
return await worker.getMemberActivityMeta(vTeam.value!);
|
||
} catch (workerErr) {
|
||
logger.warn(
|
||
`[teams:getMemberActivityMeta] worker failed, falling back: ${
|
||
workerErr instanceof Error ? workerErr.message : workerErr
|
||
}`
|
||
);
|
||
}
|
||
}
|
||
noteHeavyTeamDataWorkerFallback('teams:getMemberActivityMeta');
|
||
return getTeamDataService().getMemberActivityMeta(vTeam.value!);
|
||
});
|
||
}
|
||
|
||
async function handleSendMessage(
|
||
_event: IpcMainInvokeEvent,
|
||
teamName: unknown,
|
||
request: unknown
|
||
): Promise<IpcResult<SendMessageResult>> {
|
||
const validatedTeamName = validateTeamName(teamName);
|
||
if (!validatedTeamName.valid) {
|
||
return { success: false, error: validatedTeamName.error ?? 'Invalid teamName' };
|
||
}
|
||
|
||
if (!request || typeof request !== 'object') {
|
||
return { success: false, error: 'Invalid send message request' };
|
||
}
|
||
|
||
const payload = request as Partial<SendMessageRequest>;
|
||
const validatedMember = validateMemberName(payload.member);
|
||
if (!validatedMember.valid) {
|
||
return { success: false, error: validatedMember.error ?? 'Invalid member' };
|
||
}
|
||
if (typeof payload.text !== 'string' || payload.text.trim().length === 0) {
|
||
return { success: false, error: 'text must be non-empty string' };
|
||
}
|
||
if (payload.summary !== undefined && typeof payload.summary !== 'string') {
|
||
return { success: false, error: 'summary must be string' };
|
||
}
|
||
if (payload.from !== undefined) {
|
||
const validatedFrom = validateFromField(payload.from);
|
||
if (!validatedFrom.valid) {
|
||
return { success: false, error: validatedFrom.error ?? 'Invalid from' };
|
||
}
|
||
}
|
||
if (payload.actionMode !== undefined && !isAgentActionMode(payload.actionMode)) {
|
||
return { success: false, error: 'actionMode must be one of: do, ask, delegate' };
|
||
}
|
||
const validatedTaskRefs = validateTaskRefs(payload.taskRefs);
|
||
if (!validatedTaskRefs.valid) {
|
||
return { success: false, error: validatedTaskRefs.error };
|
||
}
|
||
|
||
let validatedAttachments: AttachmentPayload[] | undefined;
|
||
if (
|
||
payload.attachments !== undefined &&
|
||
Array.isArray(payload.attachments) &&
|
||
payload.attachments.length > 0
|
||
) {
|
||
const attResult = validateAttachments(payload.attachments);
|
||
if (!attResult.valid) {
|
||
return { success: false, error: attResult.error };
|
||
}
|
||
validatedAttachments = attResult.value;
|
||
const serializedResult = validateAttachmentSerializedPayload({
|
||
text: payload.text,
|
||
attachments: validatedAttachments,
|
||
});
|
||
if (!serializedResult.valid) {
|
||
return { success: false, error: serializedResult.error };
|
||
}
|
||
}
|
||
|
||
const tn = validatedTeamName.value!;
|
||
const memberName = validatedMember.value!;
|
||
let prevalidatedLeadName: string | null | undefined;
|
||
let prevalidatedIsLeadRecipient: boolean | undefined;
|
||
if (payload.actionMode === 'delegate') {
|
||
try {
|
||
prevalidatedLeadName = await getTeamDataService().getLeadMemberName(tn);
|
||
} catch (error) {
|
||
return wrapTeamHandler('sendMessage', async () => {
|
||
throw error;
|
||
});
|
||
}
|
||
prevalidatedIsLeadRecipient =
|
||
prevalidatedLeadName !== null && memberName === prevalidatedLeadName;
|
||
if (!prevalidatedIsLeadRecipient) {
|
||
return {
|
||
success: false,
|
||
error: 'Delegate mode is only supported when messaging the team lead',
|
||
};
|
||
}
|
||
}
|
||
|
||
return wrapTeamHandler('sendMessage', async () => {
|
||
const provisioning = getTeamProvisioningService();
|
||
const isAlive = provisioning.isTeamAlive(tn);
|
||
|
||
const leadName =
|
||
prevalidatedLeadName !== undefined
|
||
? prevalidatedLeadName
|
||
: await getTeamDataService().getLeadMemberName(tn);
|
||
const isLeadRecipient =
|
||
prevalidatedIsLeadRecipient !== undefined
|
||
? prevalidatedIsLeadRecipient
|
||
: leadName !== null && memberName === leadName;
|
||
const actionMode = payload.actionMode;
|
||
|
||
const recipientProviderId = await provisioning.resolveRuntimeRecipientProviderId(
|
||
tn,
|
||
memberName
|
||
);
|
||
const isOpenCodeRecipient = recipientProviderId === 'opencode';
|
||
|
||
// Attachments are routed through explicit provider transports only.
|
||
// Native Claude/Codex teammates still read inbox files directly, so storing
|
||
// attachment metadata there would look successful while silently dropping
|
||
// the image at runtime. Keep those paths fail-closed until they have a
|
||
// real runtime attachment transport.
|
||
if (validatedAttachments?.length) {
|
||
const supportedLiveLead = isLeadRecipient && isAlive;
|
||
const supportedLiveOpenCodeRecipient = !isLeadRecipient && isOpenCodeRecipient && isAlive;
|
||
if (!supportedLiveLead && !supportedLiveOpenCodeRecipient) {
|
||
throw new Error(
|
||
isOpenCodeRecipient
|
||
? 'Attachments for OpenCode teammates require the team to be online'
|
||
: 'Attachments are supported for the online team lead and online OpenCode teammates only'
|
||
);
|
||
}
|
||
}
|
||
|
||
// Smart routing: lead + alive → stdin direct, else → inbox
|
||
if (isLeadRecipient && isAlive && !isOpenCodeRecipient) {
|
||
const resolvedLeadName = leadName ?? memberName;
|
||
const teammateRoster = await getDurableLeadTeammateRoster(tn, resolvedLeadName);
|
||
const rosterContextBlock = buildLeadRosterContextBlock(tn, resolvedLeadName, teammateRoster);
|
||
const delegateAckBlock = buildLeadDirectDelegateAckBlock(actionMode);
|
||
// Pre-generate stable messageId so both stdin and persistence use the same identity.
|
||
// This allows the lead to call task_create_from_message with the exact messageId.
|
||
const preGeneratedMessageId = crypto.randomUUID();
|
||
// Separate try blocks: stdin delivery vs persistence
|
||
// If stdin succeeds but persistence fails, do NOT fallback to inbox (would duplicate)
|
||
const standaloneSlashCommand = !validatedAttachments?.length
|
||
? parseStandaloneSlashCommand(payload.text!)
|
||
: null;
|
||
const slashCommandMeta = standaloneSlashCommand
|
||
? buildStandaloneSlashCommandMeta(standaloneSlashCommand.raw)
|
||
: null;
|
||
const rawSlashCommandText = standaloneSlashCommand?.raw;
|
||
const stdinTextForLead = rawSlashCommandText
|
||
? rawSlashCommandText
|
||
: [
|
||
`You received a direct message from the user.`,
|
||
`IMPORTANT: Your text response here is shown to the user in the Messages panel. Always include a brief human-readable reply. Do NOT respond with only an agent-only block.`,
|
||
...(rosterContextBlock ? [rosterContextBlock] : []),
|
||
...(delegateAckBlock ? [delegateAckBlock] : []),
|
||
wrapAgentBlock(
|
||
[
|
||
`MessageId: ${preGeneratedMessageId}`,
|
||
`When creating a task from this user message, prefer task_create_from_message with messageId="${preGeneratedMessageId}" for reliable provenance. Only use this exact messageId — never guess or fabricate one.`,
|
||
].join('\n')
|
||
),
|
||
``,
|
||
`Message from user:`,
|
||
buildMessageDeliveryText(payload.text!, {
|
||
actionMode,
|
||
isLeadRecipient: true,
|
||
}),
|
||
].join('\n');
|
||
const persistTextForLead = rawSlashCommandText ?? payload.text!;
|
||
|
||
let stdinSent = false;
|
||
try {
|
||
await provisioning.sendMessageToTeam(
|
||
tn,
|
||
stdinTextForLead,
|
||
rawSlashCommandText ? undefined : validatedAttachments
|
||
);
|
||
stdinSent = true;
|
||
} catch (stdinError: unknown) {
|
||
// If attachments were requested, fail rather than silently dropping them.
|
||
// Only report offline when liveness confirms the process is unavailable.
|
||
if (validatedAttachments?.length) {
|
||
throw new Error(
|
||
formatAttachmentDeliveryFailure(stdinError, provisioning.isTeamAlive(tn))
|
||
);
|
||
}
|
||
const errMsg = stdinError instanceof Error ? stdinError.message : 'unknown error';
|
||
logger.warn(`stdin fallback for ${tn}: ${errMsg}`);
|
||
// Fallback to inbox path below
|
||
}
|
||
|
||
if (stdinSent) {
|
||
// Save attachment files to disk FIRST to get file paths for metadata
|
||
let attachmentFilePaths: Map<string, string> | undefined;
|
||
if (validatedAttachments?.length) {
|
||
try {
|
||
attachmentFilePaths = await attachmentStore.saveAttachments(
|
||
tn,
|
||
preGeneratedMessageId,
|
||
validatedAttachments
|
||
);
|
||
} catch (e) {
|
||
logger.warn(`Failed to save attachments: ${e}`);
|
||
}
|
||
}
|
||
|
||
const attachmentMeta: AttachmentMeta[] | undefined = validatedAttachments?.map((a) => {
|
||
const fp = attachmentFilePaths?.get(a.id);
|
||
return {
|
||
id: a.id,
|
||
filename: a.filename,
|
||
mimeType: a.mimeType,
|
||
size: a.size,
|
||
...(fp ? { filePath: fp } : {}),
|
||
};
|
||
});
|
||
|
||
// Persistence is best-effort — stdin already delivered the message
|
||
let result: SendMessageResult;
|
||
try {
|
||
result = await getTeamDataService().sendDirectToLead(
|
||
tn,
|
||
resolvedLeadName,
|
||
persistTextForLead,
|
||
payload.summary,
|
||
attachmentMeta,
|
||
validatedTaskRefs.value,
|
||
preGeneratedMessageId
|
||
);
|
||
} catch (persistError) {
|
||
logger.warn(`Persistence failed after stdin delivery for ${tn}: ${String(persistError)}`);
|
||
result = { deliveredToInbox: false, messageId: preGeneratedMessageId };
|
||
}
|
||
|
||
// Attachment files already saved above (before metadata construction)
|
||
|
||
provisioning.pushLiveLeadProcessMessage(tn, {
|
||
from: 'user',
|
||
to: resolvedLeadName,
|
||
text: persistTextForLead,
|
||
timestamp: new Date().toISOString(),
|
||
read: true,
|
||
summary: payload.summary,
|
||
messageId: result.messageId,
|
||
source: 'user_sent',
|
||
attachments: attachmentMeta,
|
||
taskRefs: validatedTaskRefs.value,
|
||
...(slashCommandMeta
|
||
? {
|
||
messageKind: 'slash_command' as const,
|
||
slashCommand: slashCommandMeta,
|
||
}
|
||
: {}),
|
||
});
|
||
|
||
return result;
|
||
}
|
||
}
|
||
|
||
// Inbox path: offline lead or regular members.
|
||
const baseText = payload.text!.trim();
|
||
const replyRecipient =
|
||
typeof payload.from === 'string' && payload.from.trim().length > 0
|
||
? payload.from.trim()
|
||
: 'user';
|
||
const storedFrom = replyRecipient.toLowerCase() === 'user' ? 'user' : replyRecipient;
|
||
const directReplyProtocol = resolveVisibleDirectReplyProtocol({
|
||
isLeadRecipient,
|
||
replyRecipient,
|
||
...(recipientProviderId ? { providerId: recipientProviderId } : {}),
|
||
});
|
||
const inboxMessageId =
|
||
directReplyProtocol === 'agent_teams_message_send' || validatedAttachments?.length
|
||
? crypto.randomUUID()
|
||
: undefined;
|
||
const memberDeliveryText = buildMessageDeliveryText(baseText, {
|
||
actionMode,
|
||
isLeadRecipient,
|
||
memberName,
|
||
protocol: directReplyProtocol,
|
||
replyRecipient,
|
||
teamName: tn,
|
||
...(inboxMessageId ? { messageId: inboxMessageId } : {}),
|
||
});
|
||
const inboxText = isOpenCodeRecipient ? baseText : memberDeliveryText;
|
||
if (validatedAttachments?.length && inboxMessageId) {
|
||
try {
|
||
await attachmentStore.saveAttachments(tn, inboxMessageId, validatedAttachments);
|
||
} catch (error) {
|
||
throw new Error(`Failed to save message attachments: ${getErrorMessage(error)}`);
|
||
}
|
||
}
|
||
|
||
const result = await getTeamDataService().sendMessage(tn, {
|
||
member: memberName,
|
||
text: inboxText,
|
||
summary: payload.summary,
|
||
from: storedFrom,
|
||
actionMode,
|
||
source: 'user_sent',
|
||
taskRefs: validatedTaskRefs.value,
|
||
...(inboxMessageId ? { messageId: inboxMessageId } : {}),
|
||
...(validatedAttachments?.length ? { attachments: validatedAttachments } : {}),
|
||
});
|
||
|
||
// Teammate inbox relay DISABLED (2026-03-23).
|
||
// Codex/Claude teammates read their own inbox files directly via fs.watch.
|
||
// Relaying through the lead (relayMemberInboxMessages) caused multiple bugs:
|
||
// 1. Lead responded to user instead of forwarding to the teammate
|
||
// 2. Duplicate messages (relay loop: markInboxMessagesRead → FileWatcher → relay again)
|
||
// 3. Fragile LLM-dependent prompt chain for routing
|
||
// The message is already persisted in inboxes/{member}.json above.
|
||
// Teammate responses go to inboxes/user.json and are read by TeamInboxReader.
|
||
// Lead relay (relayLeadInboxMessages) is still needed because lead reads stdin only, not inbox.
|
||
// OpenCode secondary lanes do not watch these inbox files, so they need runtime bridge delivery.
|
||
//
|
||
// if (!isLeadRecipient && isAlive) {
|
||
// try {
|
||
// await provisioning.relayMemberInboxMessages(tn, memberName);
|
||
// } catch (e: unknown) {
|
||
// logger.warn(`Relay after sendMessage failed for teammate "${memberName}": ${String(e)}`);
|
||
// }
|
||
// }
|
||
if (isOpenCodeRecipient) {
|
||
try {
|
||
const relay = await waitForOpenCodeRuntimeRelayForUi({
|
||
provisioning,
|
||
teamName: tn,
|
||
memberName,
|
||
messageId: result.messageId,
|
||
relayPromise: provisioning.relayOpenCodeMemberInboxMessages(tn, memberName, {
|
||
onlyMessageId: result.messageId,
|
||
source: 'ui-send',
|
||
deliveryMetadata: {
|
||
replyRecipient,
|
||
actionMode,
|
||
taskRefs: validatedTaskRefs.value,
|
||
},
|
||
}),
|
||
});
|
||
const delivery = relay.lastDelivery ?? {
|
||
delivered: relay.relayed > 0,
|
||
reason: relay.relayed > 0 ? undefined : 'opencode_message_delivery_not_attempted',
|
||
diagnostics: undefined,
|
||
};
|
||
result.runtimeDelivery = {
|
||
providerId: 'opencode',
|
||
attempted: true,
|
||
delivered: delivery.delivered,
|
||
responsePending: delivery.responsePending,
|
||
acceptanceUnknown: delivery.acceptanceUnknown,
|
||
responseState: delivery.responseState,
|
||
ledgerStatus: delivery.ledgerStatus,
|
||
visibleReplyMessageId: delivery.visibleReplyMessageId,
|
||
visibleReplyCorrelation: delivery.visibleReplyCorrelation,
|
||
queuedBehindMessageId: delivery.queuedBehindMessageId,
|
||
reason: delivery.reason,
|
||
diagnostics: delivery.diagnostics,
|
||
userVisibleImpact:
|
||
delivery.userVisibleImpact ??
|
||
provisioning.buildOpenCodeRuntimeDeliveryUserVisibleImpact(delivery),
|
||
};
|
||
if (
|
||
!delivery.delivered &&
|
||
delivery.reason !== 'recipient_is_not_opencode' &&
|
||
delivery.reason !== 'opencode_runtime_delivery_ui_timeout_pending'
|
||
) {
|
||
logger.warn(
|
||
`OpenCode runtime delivery after sendMessage failed for teammate "${memberName}": ${
|
||
delivery.reason ?? 'unknown error'
|
||
}`
|
||
);
|
||
}
|
||
} catch (e: unknown) {
|
||
const reason = e instanceof Error ? e.message : String(e);
|
||
result.runtimeDelivery = {
|
||
providerId: 'opencode',
|
||
attempted: true,
|
||
delivered: false,
|
||
reason,
|
||
diagnostics: [reason],
|
||
userVisibleImpact: provisioning.buildOpenCodeRuntimeDeliveryUserVisibleImpact({
|
||
delivered: false,
|
||
reason,
|
||
diagnostics: [reason],
|
||
}),
|
||
};
|
||
logger.warn(
|
||
`OpenCode runtime delivery after sendMessage crashed for teammate "${memberName}": ${reason}`
|
||
);
|
||
}
|
||
}
|
||
|
||
// Best-effort relay for lead via inbox
|
||
if (isLeadRecipient && isAlive) {
|
||
void provisioning
|
||
.relayLeadInboxMessages(tn)
|
||
.catch((e: unknown) =>
|
||
logger.warn(`Relay after sendMessage failed for ${tn}: ${String(e)}`)
|
||
);
|
||
}
|
||
|
||
return result;
|
||
});
|
||
}
|
||
|
||
async function handleGetOpenCodeRuntimeDeliveryStatus(
|
||
_event: IpcMainInvokeEvent,
|
||
teamName: unknown,
|
||
messageId: unknown
|
||
): Promise<IpcResult<OpenCodeRuntimeDeliveryStatus | null>> {
|
||
const validatedTeamName = validateTeamName(teamName);
|
||
if (!validatedTeamName.valid) {
|
||
return { success: false, error: validatedTeamName.error ?? 'Invalid teamName' };
|
||
}
|
||
if (typeof messageId !== 'string' || messageId.trim().length === 0) {
|
||
return { success: false, error: 'messageId must be a non-empty string' };
|
||
}
|
||
const safeMessageId = messageId.trim();
|
||
if (safeMessageId.includes('/') || safeMessageId.includes('\\') || safeMessageId.includes('..')) {
|
||
return { success: false, error: 'Invalid messageId' };
|
||
}
|
||
return wrapTeamHandler('getOpenCodeRuntimeDeliveryStatus', async () =>
|
||
getTeamProvisioningService().getOpenCodeRuntimeDeliveryStatus(
|
||
validatedTeamName.value!,
|
||
safeMessageId
|
||
)
|
||
);
|
||
}
|
||
|
||
async function handleCreateTask(
|
||
_event: IpcMainInvokeEvent,
|
||
teamName: unknown,
|
||
request: unknown
|
||
): Promise<IpcResult<TeamTask>> {
|
||
const validatedTeamName = validateTeamName(teamName);
|
||
if (!validatedTeamName.valid) {
|
||
return { success: false, error: validatedTeamName.error ?? 'Invalid teamName' };
|
||
}
|
||
|
||
if (!request || typeof request !== 'object') {
|
||
return { success: false, error: 'Invalid create task request' };
|
||
}
|
||
|
||
const payload = request as Partial<CreateTaskRequest>;
|
||
if (typeof payload.subject !== 'string' || payload.subject.trim().length === 0) {
|
||
return { success: false, error: 'subject must be a non-empty string' };
|
||
}
|
||
if (payload.subject.trim().length > 500) {
|
||
return { success: false, error: 'subject exceeds max length (500)' };
|
||
}
|
||
if (payload.description !== undefined && typeof payload.description !== 'string') {
|
||
return { success: false, error: 'description must be string' };
|
||
}
|
||
const validatedDescriptionTaskRefs = validateTaskRefs(payload.descriptionTaskRefs);
|
||
if (!validatedDescriptionTaskRefs.valid) {
|
||
return { success: false, error: validatedDescriptionTaskRefs.error };
|
||
}
|
||
if (payload.owner !== undefined) {
|
||
const validatedOwner = validateMemberName(payload.owner);
|
||
if (!validatedOwner.valid) {
|
||
return { success: false, error: validatedOwner.error ?? 'Invalid owner' };
|
||
}
|
||
}
|
||
if (payload.blockedBy !== undefined) {
|
||
if (
|
||
!Array.isArray(payload.blockedBy) ||
|
||
payload.blockedBy.some((id) => typeof id !== 'string')
|
||
) {
|
||
return { success: false, error: 'blockedBy must be an array of task ID strings' };
|
||
}
|
||
}
|
||
if (payload.related !== undefined) {
|
||
if (!Array.isArray(payload.related) || payload.related.some((id) => typeof id !== 'string')) {
|
||
return { success: false, error: 'related must be an array of task ID strings' };
|
||
}
|
||
for (const id of payload.related) {
|
||
const validated = validateTaskId(id);
|
||
if (!validated.valid) {
|
||
return { success: false, error: validated.error ?? 'Invalid related task id' };
|
||
}
|
||
}
|
||
}
|
||
if (payload.prompt !== undefined) {
|
||
if (typeof payload.prompt !== 'string') {
|
||
return { success: false, error: 'prompt must be a string' };
|
||
}
|
||
if (payload.prompt.length > 5000) {
|
||
return { success: false, error: 'prompt exceeds max length (5000)' };
|
||
}
|
||
}
|
||
const validatedPromptTaskRefs = validateTaskRefs(payload.promptTaskRefs);
|
||
if (!validatedPromptTaskRefs.valid) {
|
||
return { success: false, error: validatedPromptTaskRefs.error };
|
||
}
|
||
if (payload.startImmediately !== undefined && typeof payload.startImmediately !== 'boolean') {
|
||
return { success: false, error: 'startImmediately must be a boolean' };
|
||
}
|
||
|
||
return wrapTeamHandler('createTask', () =>
|
||
getTeamDataService().createTask(validatedTeamName.value!, {
|
||
subject: payload.subject!.trim(),
|
||
description: payload.description?.trim(),
|
||
owner: payload.owner?.trim() || undefined,
|
||
blockedBy: payload.blockedBy,
|
||
related: payload.related,
|
||
descriptionTaskRefs: validatedDescriptionTaskRefs.value,
|
||
prompt: payload.prompt?.trim() || undefined,
|
||
promptTaskRefs: validatedPromptTaskRefs.value,
|
||
startImmediately: payload.startImmediately,
|
||
})
|
||
);
|
||
}
|
||
|
||
async function handleRequestReview(
|
||
_event: IpcMainInvokeEvent,
|
||
teamName: unknown,
|
||
taskId: unknown
|
||
): Promise<IpcResult<void>> {
|
||
const validatedTeamName = validateTeamName(teamName);
|
||
if (!validatedTeamName.valid) {
|
||
return { success: false, error: validatedTeamName.error ?? 'Invalid teamName' };
|
||
}
|
||
|
||
const validatedTaskId = validateTaskId(taskId);
|
||
if (!validatedTaskId.valid) {
|
||
return { success: false, error: validatedTaskId.error ?? 'Invalid taskId' };
|
||
}
|
||
|
||
return wrapTeamHandler('requestReview', () =>
|
||
getTeamDataService().requestReview(validatedTeamName.value!, validatedTaskId.value!)
|
||
);
|
||
}
|
||
|
||
async function handleUpdateKanban(
|
||
_event: IpcMainInvokeEvent,
|
||
teamName: unknown,
|
||
taskId: unknown,
|
||
patch: unknown
|
||
): Promise<IpcResult<void>> {
|
||
const validatedTeamName = validateTeamName(teamName);
|
||
if (!validatedTeamName.valid) {
|
||
return { success: false, error: validatedTeamName.error ?? 'Invalid teamName' };
|
||
}
|
||
|
||
const validatedTaskId = validateTaskId(taskId);
|
||
if (!validatedTaskId.valid) {
|
||
return { success: false, error: validatedTaskId.error ?? 'Invalid taskId' };
|
||
}
|
||
|
||
if (!isUpdateKanbanPatch(patch)) {
|
||
return { success: false, error: 'Invalid kanban patch' };
|
||
}
|
||
|
||
return wrapTeamHandler('updateKanban', async () => {
|
||
await getTeamDataService().updateKanban(
|
||
validatedTeamName.value!,
|
||
validatedTaskId.value!,
|
||
patch
|
||
);
|
||
});
|
||
}
|
||
|
||
function validateKanbanColumnId(
|
||
value: unknown
|
||
): { valid: true; value: KanbanColumnId } | { valid: false; error: string } {
|
||
if (typeof value !== 'string' || !KANBAN_COLUMN_IDS.includes(value as KanbanColumnId)) {
|
||
return { valid: false, error: `columnId must be one of: ${KANBAN_COLUMN_IDS.join(', ')}` };
|
||
}
|
||
return { valid: true, value: value as KanbanColumnId };
|
||
}
|
||
|
||
async function handleUpdateKanbanColumnOrder(
|
||
_event: IpcMainInvokeEvent,
|
||
teamName: unknown,
|
||
columnId: unknown,
|
||
orderedTaskIds: unknown
|
||
): Promise<IpcResult<void>> {
|
||
const validatedTeamName = validateTeamName(teamName);
|
||
if (!validatedTeamName.valid) {
|
||
return { success: false, error: validatedTeamName.error ?? 'Invalid teamName' };
|
||
}
|
||
const validatedColumnId = validateKanbanColumnId(columnId);
|
||
if (!validatedColumnId.valid) {
|
||
return { success: false, error: validatedColumnId.error ?? 'Invalid columnId' };
|
||
}
|
||
if (!Array.isArray(orderedTaskIds)) {
|
||
return { success: false, error: 'orderedTaskIds must be an array' };
|
||
}
|
||
const ids = orderedTaskIds.filter((id): id is string => typeof id === 'string');
|
||
return wrapTeamHandler('updateKanbanColumnOrder', () =>
|
||
getTeamDataService().updateKanbanColumnOrder(
|
||
validatedTeamName.value!,
|
||
validatedColumnId.value,
|
||
ids
|
||
)
|
||
);
|
||
}
|
||
|
||
const VALID_TASK_STATUSES: TeamTaskStatus[] = ['pending', 'in_progress', 'completed'];
|
||
|
||
async function handleUpdateTaskStatus(
|
||
_event: IpcMainInvokeEvent,
|
||
teamName: unknown,
|
||
taskId: unknown,
|
||
status: unknown
|
||
): Promise<IpcResult<void>> {
|
||
const validatedTeamName = validateTeamName(teamName);
|
||
if (!validatedTeamName.valid) {
|
||
return { success: false, error: validatedTeamName.error ?? 'Invalid teamName' };
|
||
}
|
||
|
||
const validatedTaskId = validateTaskId(taskId);
|
||
if (!validatedTaskId.valid) {
|
||
return { success: false, error: validatedTaskId.error ?? 'Invalid taskId' };
|
||
}
|
||
|
||
if (typeof status !== 'string' || !VALID_TASK_STATUSES.includes(status as TeamTaskStatus)) {
|
||
return { success: false, error: `status must be one of: ${VALID_TASK_STATUSES.join(', ')}` };
|
||
}
|
||
|
||
return wrapTeamHandler('updateTaskStatus', () =>
|
||
getTeamDataService().updateTaskStatus(
|
||
validatedTeamName.value!,
|
||
validatedTaskId.value!,
|
||
status as TeamTaskStatus
|
||
)
|
||
);
|
||
}
|
||
|
||
async function handleSoftDeleteTask(
|
||
_event: IpcMainInvokeEvent,
|
||
teamName: unknown,
|
||
taskId: unknown
|
||
): Promise<IpcResult<void>> {
|
||
const validatedTeamName = validateTeamName(teamName);
|
||
if (!validatedTeamName.valid) {
|
||
return { success: false, error: validatedTeamName.error ?? 'Invalid teamName' };
|
||
}
|
||
|
||
const validatedTaskId = validateTaskId(taskId);
|
||
if (!validatedTaskId.valid) {
|
||
return { success: false, error: validatedTaskId.error ?? 'Invalid taskId' };
|
||
}
|
||
|
||
return wrapTeamHandler('softDeleteTask', () =>
|
||
getTeamDataService().softDeleteTask(validatedTeamName.value!, validatedTaskId.value!)
|
||
);
|
||
}
|
||
|
||
async function handleRestoreTask(
|
||
_event: IpcMainInvokeEvent,
|
||
teamName: unknown,
|
||
taskId: unknown
|
||
): Promise<IpcResult<void>> {
|
||
const validatedTeamName = validateTeamName(teamName);
|
||
if (!validatedTeamName.valid) {
|
||
return { success: false, error: validatedTeamName.error ?? 'Invalid teamName' };
|
||
}
|
||
|
||
const validatedTaskId = validateTaskId(taskId);
|
||
if (!validatedTaskId.valid) {
|
||
return { success: false, error: validatedTaskId.error ?? 'Invalid taskId' };
|
||
}
|
||
|
||
return wrapTeamHandler('restoreTask', () =>
|
||
getTeamDataService().restoreTask(validatedTeamName.value!, validatedTaskId.value!)
|
||
);
|
||
}
|
||
|
||
async function handleGetDeletedTasks(
|
||
_event: IpcMainInvokeEvent,
|
||
teamName: unknown
|
||
): Promise<IpcResult<TeamTask[]>> {
|
||
const validatedTeamName = validateTeamName(teamName);
|
||
if (!validatedTeamName.valid) {
|
||
return { success: false, error: validatedTeamName.error ?? 'Invalid teamName' };
|
||
}
|
||
|
||
return wrapTeamHandler('getDeletedTasks', () =>
|
||
getTeamDataService().getDeletedTasks(validatedTeamName.value!)
|
||
);
|
||
}
|
||
|
||
const VALID_CLARIFICATION_VALUES = ['lead', 'user'] as const;
|
||
|
||
async function handleSetTaskClarification(
|
||
_event: IpcMainInvokeEvent,
|
||
teamName: unknown,
|
||
taskId: unknown,
|
||
value: unknown
|
||
): Promise<IpcResult<void>> {
|
||
const validatedTeamName = validateTeamName(teamName);
|
||
if (!validatedTeamName.valid) {
|
||
return { success: false, error: validatedTeamName.error ?? 'Invalid teamName' };
|
||
}
|
||
|
||
const validatedTaskId = validateTaskId(taskId);
|
||
if (!validatedTaskId.valid) {
|
||
return { success: false, error: validatedTaskId.error ?? 'Invalid taskId' };
|
||
}
|
||
|
||
if (
|
||
value !== null &&
|
||
(typeof value !== 'string' || !VALID_CLARIFICATION_VALUES.includes(value as 'lead' | 'user'))
|
||
) {
|
||
return {
|
||
success: false,
|
||
error: `value must be "lead", "user", or null`,
|
||
};
|
||
}
|
||
|
||
return wrapTeamHandler('setTaskClarification', () =>
|
||
getTeamDataService().setTaskNeedsClarification(
|
||
validatedTeamName.value!,
|
||
validatedTaskId.value!,
|
||
value as 'lead' | 'user' | null
|
||
)
|
||
);
|
||
}
|
||
|
||
async function handleUpdateTaskOwner(
|
||
_event: IpcMainInvokeEvent,
|
||
teamName: unknown,
|
||
taskId: unknown,
|
||
owner: unknown
|
||
): Promise<IpcResult<void>> {
|
||
const validatedTeamName = validateTeamName(teamName);
|
||
if (!validatedTeamName.valid) {
|
||
return { success: false, error: validatedTeamName.error ?? 'Invalid teamName' };
|
||
}
|
||
|
||
const validatedTaskId = validateTaskId(taskId);
|
||
if (!validatedTaskId.valid) {
|
||
return { success: false, error: validatedTaskId.error ?? 'Invalid taskId' };
|
||
}
|
||
|
||
let nextOwner: string | null = null;
|
||
if (owner !== null) {
|
||
const validatedOwner = validateMemberName(owner);
|
||
if (!validatedOwner.valid) {
|
||
return { success: false, error: validatedOwner.error ?? 'Invalid owner' };
|
||
}
|
||
nextOwner = validatedOwner.value!;
|
||
}
|
||
|
||
return wrapTeamHandler('updateTaskOwner', () =>
|
||
getTeamDataService().updateTaskOwner(
|
||
validatedTeamName.value!,
|
||
validatedTaskId.value!,
|
||
nextOwner
|
||
)
|
||
);
|
||
}
|
||
|
||
async function handleProcessSend(
|
||
_event: IpcMainInvokeEvent,
|
||
teamName: unknown,
|
||
message: unknown
|
||
): Promise<IpcResult<void>> {
|
||
const validatedTeamName = validateTeamName(teamName);
|
||
if (!validatedTeamName.valid) {
|
||
return { success: false, error: validatedTeamName.error ?? 'Invalid teamName' };
|
||
}
|
||
if (typeof message !== 'string' || message.trim().length === 0) {
|
||
return { success: false, error: 'message must be a non-empty string' };
|
||
}
|
||
return wrapTeamHandler('processSend', () =>
|
||
getTeamProvisioningService().sendMessageToTeam(validatedTeamName.value!, message)
|
||
);
|
||
}
|
||
|
||
async function handleProcessAlive(
|
||
_event: IpcMainInvokeEvent,
|
||
teamName: unknown
|
||
): Promise<IpcResult<boolean>> {
|
||
const validatedTeamName = validateTeamName(teamName);
|
||
if (!validatedTeamName.valid) {
|
||
return { success: false, error: validatedTeamName.error ?? 'Invalid teamName' };
|
||
}
|
||
return wrapTeamHandler('processAlive', async () =>
|
||
getTeamProvisioningService().isTeamAlive(validatedTeamName.value!)
|
||
);
|
||
}
|
||
|
||
async function handleCreateConfig(
|
||
_event: IpcMainInvokeEvent,
|
||
request: unknown
|
||
): Promise<IpcResult<void>> {
|
||
if (!request || typeof request !== 'object') {
|
||
return { success: false, error: 'Invalid create config request' };
|
||
}
|
||
|
||
const payload = request as Partial<TeamCreateConfigRequest>;
|
||
if (typeof payload.teamName !== 'string' || payload.teamName.trim().length === 0) {
|
||
return { success: false, error: 'teamName is required' };
|
||
}
|
||
const teamName = payload.teamName.trim();
|
||
if (!isProvisioningTeamName(teamName)) {
|
||
return { success: false, error: 'teamName must be kebab-case [a-z0-9-], max 64 chars' };
|
||
}
|
||
|
||
if (!Array.isArray(payload.members)) {
|
||
return { success: false, error: 'members must be an array' };
|
||
}
|
||
|
||
if (payload.displayName !== undefined && typeof payload.displayName !== 'string') {
|
||
return { success: false, error: 'displayName must be a string' };
|
||
}
|
||
if (payload.description !== undefined && typeof payload.description !== 'string') {
|
||
return { success: false, error: 'description must be a string' };
|
||
}
|
||
if (payload.color !== undefined && typeof payload.color !== 'string') {
|
||
return { success: false, error: 'color must be a string' };
|
||
}
|
||
if (payload.cwd !== undefined) {
|
||
if (typeof payload.cwd !== 'string' || payload.cwd.trim().length === 0) {
|
||
return { success: false, error: 'cwd must be a non-empty string if provided' };
|
||
}
|
||
if (!path.isAbsolute(payload.cwd.trim())) {
|
||
return { success: false, error: 'cwd must be an absolute path' };
|
||
}
|
||
}
|
||
if (payload.prompt !== undefined && typeof payload.prompt !== 'string') {
|
||
return { success: false, error: 'prompt must be a string' };
|
||
}
|
||
const teamProviderValidation = parseOptionalTeamProviderId(payload.providerId);
|
||
if (!teamProviderValidation.valid) {
|
||
return { success: false, error: teamProviderValidation.error };
|
||
}
|
||
const effectiveTeamProviderId = teamProviderValidation.value ?? 'anthropic';
|
||
const providerBackendValidation = parseOptionalLaunchProviderBackendId(
|
||
payload.providerBackendId,
|
||
effectiveTeamProviderId
|
||
);
|
||
if (!providerBackendValidation.valid) {
|
||
return { success: false, error: providerBackendValidation.error };
|
||
}
|
||
if (payload.model !== undefined && typeof payload.model !== 'string') {
|
||
return { success: false, error: 'model must be a string' };
|
||
}
|
||
const effortValidation = parseOptionalTeamEffort(payload.effort, effectiveTeamProviderId);
|
||
if (!effortValidation.valid) {
|
||
return { success: false, error: effortValidation.error };
|
||
}
|
||
const fastModeValidation = parseOptionalTeamFastMode(payload.fastMode);
|
||
if (!fastModeValidation.valid) {
|
||
return { success: false, error: fastModeValidation.error };
|
||
}
|
||
if (payload.limitContext !== undefined && typeof payload.limitContext !== 'boolean') {
|
||
return { success: false, error: 'limitContext must be a boolean' };
|
||
}
|
||
if (payload.skipPermissions !== undefined && typeof payload.skipPermissions !== 'boolean') {
|
||
return { success: false, error: 'skipPermissions must be a boolean' };
|
||
}
|
||
if (payload.worktree !== undefined) {
|
||
if (typeof payload.worktree !== 'string') {
|
||
return { success: false, error: 'worktree must be a string' };
|
||
}
|
||
const worktree = payload.worktree.trim();
|
||
if (worktree.length > 128) {
|
||
return { success: false, error: 'worktree name too long (max 128)' };
|
||
}
|
||
if (worktree && !/^[a-zA-Z0-9][a-zA-Z0-9._-]*$/.test(worktree)) {
|
||
return {
|
||
success: false,
|
||
error: 'worktree name: start with alphanumeric, use [a-zA-Z0-9._-]',
|
||
};
|
||
}
|
||
}
|
||
if (payload.extraCliArgs !== undefined) {
|
||
if (typeof payload.extraCliArgs !== 'string') {
|
||
return { success: false, error: 'extraCliArgs must be a string' };
|
||
}
|
||
if (payload.extraCliArgs.length > 1024) {
|
||
return { success: false, error: 'extraCliArgs too long (max 1024)' };
|
||
}
|
||
const protectedFlags = extractUserFlags(payload.extraCliArgs).filter((flag) =>
|
||
PROTECTED_CLI_FLAGS.has(flag)
|
||
);
|
||
if (protectedFlags.length > 0) {
|
||
return {
|
||
success: false,
|
||
error: `extraCliArgs contains app-managed flags: ${[...new Set(protectedFlags)].join(', ')}`,
|
||
};
|
||
}
|
||
}
|
||
|
||
const seenNames = new Set<string>();
|
||
const members: TeamCreateConfigRequest['members'] = [];
|
||
for (const member of payload.members) {
|
||
if (!member || typeof member !== 'object') {
|
||
return { success: false, error: 'member must be object' };
|
||
}
|
||
const nameValidation = validateTeammateName((member as { name?: unknown }).name);
|
||
if (!nameValidation.valid) {
|
||
return { success: false, error: nameValidation.error ?? 'Invalid member name' };
|
||
}
|
||
const memberName = nameValidation.value!;
|
||
if (seenNames.has(memberName)) {
|
||
return { success: false, error: 'member names must be unique' };
|
||
}
|
||
seenNames.add(memberName);
|
||
|
||
const role = (member as { role?: unknown }).role;
|
||
if (role !== undefined && typeof role !== 'string') {
|
||
return { success: false, error: 'member role must be string' };
|
||
}
|
||
const workflow = (member as { workflow?: unknown }).workflow;
|
||
if (workflow !== undefined && typeof workflow !== 'string') {
|
||
return { success: false, error: 'member workflow must be string' };
|
||
}
|
||
const isolation = (member as { isolation?: unknown }).isolation;
|
||
if (isolation !== undefined && isolation !== 'worktree') {
|
||
return { success: false, error: 'member isolation must be "worktree" when provided' };
|
||
}
|
||
const providerValidation = parseOptionalMemberProviderId(
|
||
(member as { providerId?: unknown }).providerId
|
||
);
|
||
if (!providerValidation.valid) {
|
||
return { success: false, error: providerValidation.error };
|
||
}
|
||
const effectiveMemberProviderId = providerValidation.value ?? effectiveTeamProviderId;
|
||
const providerBackendValidation = parseOptionalProviderBackendId(
|
||
(member as { providerBackendId?: unknown }).providerBackendId,
|
||
effectiveMemberProviderId
|
||
);
|
||
if (!providerBackendValidation.valid) {
|
||
return { success: false, error: providerBackendValidation.error };
|
||
}
|
||
const model = (member as { model?: unknown }).model;
|
||
if (model !== undefined && typeof model !== 'string') {
|
||
return { success: false, error: 'member model must be string' };
|
||
}
|
||
const effortValidation = parseOptionalMemberEffort(
|
||
(member as { effort?: unknown }).effort,
|
||
effectiveMemberProviderId
|
||
);
|
||
if (!effortValidation.valid) {
|
||
return { success: false, error: effortValidation.error };
|
||
}
|
||
const fastModeValidation = parseOptionalTeamFastMode(
|
||
(member as { fastMode?: unknown }).fastMode
|
||
);
|
||
if (!fastModeValidation.valid) {
|
||
return { success: false, error: fastModeValidation.error };
|
||
}
|
||
members.push({
|
||
name: memberName,
|
||
role: typeof role === 'string' ? role.trim() : undefined,
|
||
workflow: typeof workflow === 'string' ? workflow.trim() : undefined,
|
||
isolation: isolation === 'worktree' ? ('worktree' as const) : undefined,
|
||
providerId: providerValidation.value,
|
||
providerBackendId: providerBackendValidation.value,
|
||
model: typeof model === 'string' ? model.trim() || undefined : undefined,
|
||
effort: effortValidation.value,
|
||
fastMode: fastModeValidation.value,
|
||
});
|
||
}
|
||
|
||
return wrapTeamHandler('createConfig', async () => {
|
||
await getTeamDataService().createTeamConfig({
|
||
teamName,
|
||
displayName: payload.displayName?.trim() || undefined,
|
||
description: payload.description?.trim() || undefined,
|
||
color: typeof payload.color === 'string' ? payload.color.trim() || undefined : undefined,
|
||
members,
|
||
cwd: typeof payload.cwd === 'string' ? payload.cwd.trim() || undefined : undefined,
|
||
prompt: typeof payload.prompt === 'string' ? payload.prompt.trim() || undefined : undefined,
|
||
providerId: teamProviderValidation.value,
|
||
providerBackendId: providerBackendValidation.value,
|
||
model: typeof payload.model === 'string' ? payload.model.trim() || undefined : undefined,
|
||
effort: effortValidation.value,
|
||
fastMode: fastModeValidation.value,
|
||
limitContext: typeof payload.limitContext === 'boolean' ? payload.limitContext : undefined,
|
||
skipPermissions:
|
||
typeof payload.skipPermissions === 'boolean' ? payload.skipPermissions : undefined,
|
||
worktree:
|
||
typeof payload.worktree === 'string' && payload.worktree.trim()
|
||
? payload.worktree.trim()
|
||
: undefined,
|
||
extraCliArgs:
|
||
typeof payload.extraCliArgs === 'string' && payload.extraCliArgs.trim()
|
||
? payload.extraCliArgs.trim()
|
||
: undefined,
|
||
});
|
||
getTeamDataWorkerClient().invalidateTeamConfig(teamName);
|
||
});
|
||
}
|
||
|
||
function getTeamMemberLogsFinder(): TeamMemberLogsFinder {
|
||
if (!teamMemberLogsFinder) {
|
||
throw new Error('Team member logs finder is not initialized');
|
||
}
|
||
return teamMemberLogsFinder;
|
||
}
|
||
|
||
async function handleGetMemberLogs(
|
||
_event: IpcMainInvokeEvent,
|
||
teamName: unknown,
|
||
memberName: unknown
|
||
): Promise<IpcResult<MemberLogSummary[]>> {
|
||
const vTeam = validateTeamName(teamName);
|
||
if (!vTeam.valid) {
|
||
return { success: false, error: vTeam.error ?? 'Invalid teamName' };
|
||
}
|
||
const vMember = validateMemberName(memberName);
|
||
if (!vMember.valid) {
|
||
return { success: false, error: vMember.error ?? 'Invalid memberName' };
|
||
}
|
||
return wrapTeamHandler('getMemberLogs', () =>
|
||
getTeamMemberLogsFinder().findMemberLogs(vTeam.value!, vMember.value!)
|
||
);
|
||
}
|
||
|
||
async function handleGetLogsForTask(
|
||
_event: IpcMainInvokeEvent,
|
||
teamName: unknown,
|
||
taskId: unknown,
|
||
options?: {
|
||
owner?: string;
|
||
status?: string;
|
||
intervals?: { startedAt: string; completedAt?: string }[];
|
||
since?: string;
|
||
}
|
||
): Promise<IpcResult<MemberLogSummary[]>> {
|
||
const vTeam = validateTeamName(teamName);
|
||
if (!vTeam.valid) {
|
||
return { success: false, error: vTeam.error ?? 'Invalid teamName' };
|
||
}
|
||
const vTask = validateTaskId(taskId);
|
||
if (!vTask.valid) {
|
||
return { success: false, error: vTask.error ?? 'Invalid taskId' };
|
||
}
|
||
const opts =
|
||
options && typeof options === 'object'
|
||
? {
|
||
owner: typeof options.owner === 'string' ? options.owner : undefined,
|
||
status: typeof options.status === 'string' ? options.status : undefined,
|
||
since: typeof options.since === 'string' ? options.since : undefined,
|
||
intervals: Array.isArray(options.intervals)
|
||
? (options.intervals as unknown[]).filter(
|
||
(i): i is { startedAt: string; completedAt?: string } =>
|
||
Boolean(i) &&
|
||
typeof i === 'object' &&
|
||
typeof (i as Record<string, unknown>).startedAt === 'string' &&
|
||
((i as Record<string, unknown>).completedAt === undefined ||
|
||
typeof (i as Record<string, unknown>).completedAt === 'string')
|
||
)
|
||
: undefined,
|
||
}
|
||
: undefined;
|
||
// Prefer worker thread to keep main event loop responsive.
|
||
// Call worker directly (not via wrapTeamHandler) so that failures
|
||
// propagate to the catch block and trigger the main-thread fallback.
|
||
const worker = getTeamDataWorkerClient();
|
||
if (worker.isAvailable()) {
|
||
try {
|
||
const result = await worker.findLogsForTask(vTeam.value!, vTask.value!, opts);
|
||
return { success: true, data: result };
|
||
} catch (workerErr) {
|
||
logger.warn(
|
||
`[teams:getLogsForTask] worker failed, falling back: ${workerErr instanceof Error ? workerErr.message : workerErr}`
|
||
);
|
||
}
|
||
}
|
||
return wrapTeamHandler('getLogsForTask', () =>
|
||
getTeamMemberLogsFinder().findLogsForTask(vTeam.value!, vTask.value!, opts)
|
||
);
|
||
}
|
||
|
||
async function handleGetTaskActivity(
|
||
_event: IpcMainInvokeEvent,
|
||
teamName: unknown,
|
||
taskId: unknown
|
||
): Promise<IpcResult<BoardTaskActivityEntry[]>> {
|
||
const vTeam = validateTeamName(teamName);
|
||
if (!vTeam.valid) {
|
||
return { success: false, error: vTeam.error ?? 'Invalid teamName' };
|
||
}
|
||
const vTask = validateTaskId(taskId);
|
||
if (!vTask.valid) {
|
||
return { success: false, error: vTask.error ?? 'Invalid taskId' };
|
||
}
|
||
return wrapTeamHandler('getTaskActivity', () =>
|
||
getBoardTaskActivityService().getTaskActivity(vTeam.value!, vTask.value!)
|
||
);
|
||
}
|
||
|
||
async function handleGetTaskActivityDetail(
|
||
_event: IpcMainInvokeEvent,
|
||
teamName: unknown,
|
||
taskId: unknown,
|
||
activityId: unknown
|
||
): Promise<IpcResult<BoardTaskActivityDetailResult>> {
|
||
const vTeam = validateTeamName(teamName);
|
||
if (!vTeam.valid) {
|
||
return { success: false, error: vTeam.error ?? 'Invalid teamName' };
|
||
}
|
||
const vTask = validateTaskId(taskId);
|
||
if (!vTask.valid) {
|
||
return { success: false, error: vTask.error ?? 'Invalid taskId' };
|
||
}
|
||
if (typeof activityId !== 'string' || activityId.trim().length === 0) {
|
||
return { success: false, error: 'activityId must be a non-empty string' };
|
||
}
|
||
return wrapTeamHandler('getTaskActivityDetail', () =>
|
||
getBoardTaskActivityDetailService().getTaskActivityDetail(
|
||
vTeam.value!,
|
||
vTask.value!,
|
||
activityId.trim()
|
||
)
|
||
);
|
||
}
|
||
|
||
async function handleGetTaskLogStream(
|
||
_event: IpcMainInvokeEvent,
|
||
teamName: unknown,
|
||
taskId: unknown
|
||
): Promise<IpcResult<BoardTaskLogStreamResponse>> {
|
||
const vTeam = validateTeamName(teamName);
|
||
if (!vTeam.valid) {
|
||
return { success: false, error: vTeam.error ?? 'Invalid teamName' };
|
||
}
|
||
const vTask = validateTaskId(taskId);
|
||
if (!vTask.valid) {
|
||
return { success: false, error: vTask.error ?? 'Invalid taskId' };
|
||
}
|
||
return wrapTeamHandler('getTaskLogStream', () =>
|
||
getBoardTaskLogStreamService().getTaskLogStream(vTeam.value!, vTask.value!)
|
||
);
|
||
}
|
||
|
||
async function handleGetTaskLogStreamSummary(
|
||
_event: IpcMainInvokeEvent,
|
||
teamName: unknown,
|
||
taskId: unknown
|
||
): Promise<IpcResult<BoardTaskLogStreamSummary>> {
|
||
const vTeam = validateTeamName(teamName);
|
||
if (!vTeam.valid) {
|
||
return { success: false, error: vTeam.error ?? 'Invalid teamName' };
|
||
}
|
||
const vTask = validateTaskId(taskId);
|
||
if (!vTask.valid) {
|
||
return { success: false, error: vTask.error ?? 'Invalid taskId' };
|
||
}
|
||
return wrapTeamHandler('getTaskLogStreamSummary', () =>
|
||
getBoardTaskLogStreamService().getTaskLogStreamSummary(vTeam.value!, vTask.value!)
|
||
);
|
||
}
|
||
|
||
async function handleGetTaskExactLogSummaries(
|
||
_event: IpcMainInvokeEvent,
|
||
teamName: unknown,
|
||
taskId: unknown
|
||
): Promise<IpcResult<BoardTaskExactLogSummariesResponse>> {
|
||
const vTeam = validateTeamName(teamName);
|
||
if (!vTeam.valid) {
|
||
return { success: false, error: vTeam.error ?? 'Invalid teamName' };
|
||
}
|
||
const vTask = validateTaskId(taskId);
|
||
if (!vTask.valid) {
|
||
return { success: false, error: vTask.error ?? 'Invalid taskId' };
|
||
}
|
||
return wrapTeamHandler('getTaskExactLogSummaries', () =>
|
||
getBoardTaskExactLogsService().getTaskExactLogSummaries(vTeam.value!, vTask.value!)
|
||
);
|
||
}
|
||
|
||
async function handleGetTaskExactLogDetail(
|
||
_event: IpcMainInvokeEvent,
|
||
teamName: unknown,
|
||
taskId: unknown,
|
||
exactLogId: unknown,
|
||
expectedSourceGeneration: unknown
|
||
): Promise<IpcResult<BoardTaskExactLogDetailResult>> {
|
||
const vTeam = validateTeamName(teamName);
|
||
if (!vTeam.valid) {
|
||
return { success: false, error: vTeam.error ?? 'Invalid teamName' };
|
||
}
|
||
const vTask = validateTaskId(taskId);
|
||
if (!vTask.valid) {
|
||
return { success: false, error: vTask.error ?? 'Invalid taskId' };
|
||
}
|
||
if (typeof exactLogId !== 'string' || exactLogId.trim().length === 0) {
|
||
return { success: false, error: 'exactLogId must be a non-empty string' };
|
||
}
|
||
if (
|
||
typeof expectedSourceGeneration !== 'string' ||
|
||
expectedSourceGeneration.trim().length === 0
|
||
) {
|
||
return { success: false, error: 'expectedSourceGeneration must be a non-empty string' };
|
||
}
|
||
return wrapTeamHandler('getTaskExactLogDetail', () =>
|
||
getBoardTaskExactLogDetailService().getTaskExactLogDetail(
|
||
vTeam.value!,
|
||
vTask.value!,
|
||
exactLogId.trim(),
|
||
expectedSourceGeneration.trim()
|
||
)
|
||
);
|
||
}
|
||
|
||
function getMemberStatsComputer(): MemberStatsComputer {
|
||
if (!memberStatsComputer) {
|
||
throw new Error('Member stats computer is not initialized');
|
||
}
|
||
return memberStatsComputer;
|
||
}
|
||
|
||
async function handleGetMemberStats(
|
||
_event: IpcMainInvokeEvent,
|
||
teamName: unknown,
|
||
memberName: unknown
|
||
): Promise<IpcResult<MemberFullStats>> {
|
||
const vTeam = validateTeamName(teamName);
|
||
if (!vTeam.valid) {
|
||
return { success: false, error: vTeam.error ?? 'Invalid teamName' };
|
||
}
|
||
const vMember = validateMemberName(memberName);
|
||
if (!vMember.valid) {
|
||
return { success: false, error: vMember.error ?? 'Invalid memberName' };
|
||
}
|
||
return wrapTeamHandler('getMemberStats', () =>
|
||
getMemberStatsComputer().getStats(vTeam.value!, vMember.value!)
|
||
);
|
||
}
|
||
|
||
async function handleAliveList(_event: IpcMainInvokeEvent): Promise<IpcResult<string[]>> {
|
||
return wrapTeamHandler('aliveList', async () => getTeamProvisioningService().getAliveTeams());
|
||
}
|
||
|
||
async function handleLeadActivity(
|
||
_event: IpcMainInvokeEvent,
|
||
teamName: unknown
|
||
): Promise<IpcResult<LeadActivitySnapshot>> {
|
||
const validated = validateTeamName(teamName);
|
||
if (!validated.valid) {
|
||
return { success: false, error: validated.error ?? 'Invalid teamName' };
|
||
}
|
||
return wrapTeamHandler('leadActivity', async () =>
|
||
getTeamProvisioningService().getLeadActivityState(validated.value!)
|
||
);
|
||
}
|
||
|
||
async function handleLeadContext(
|
||
_event: IpcMainInvokeEvent,
|
||
teamName: unknown
|
||
): Promise<IpcResult<LeadContextUsageSnapshot>> {
|
||
const validated = validateTeamName(teamName);
|
||
if (!validated.valid) {
|
||
return { success: false, error: validated.error ?? 'Invalid teamName' };
|
||
}
|
||
return wrapTeamHandler('leadContext', async () =>
|
||
getTeamProvisioningService().getLeadContextUsage(validated.value!)
|
||
);
|
||
}
|
||
|
||
async function handleMemberSpawnStatuses(
|
||
_event: IpcMainInvokeEvent,
|
||
teamName: unknown
|
||
): Promise<IpcResult<MemberSpawnStatusesSnapshot>> {
|
||
const validated = validateTeamName(teamName);
|
||
if (!validated.valid) {
|
||
return { success: false, error: validated.error ?? 'Invalid teamName' };
|
||
}
|
||
return wrapTeamHandler('memberSpawnStatuses', async () =>
|
||
getTeamProvisioningService().getMemberSpawnStatuses(validated.value!)
|
||
);
|
||
}
|
||
|
||
async function handleGetAgentRuntime(
|
||
_event: IpcMainInvokeEvent,
|
||
teamName: unknown
|
||
): Promise<IpcResult<TeamAgentRuntimeSnapshot>> {
|
||
const validated = validateTeamName(teamName);
|
||
if (!validated.valid) {
|
||
return { success: false, error: validated.error ?? 'Invalid teamName' };
|
||
}
|
||
return wrapTeamHandler('getAgentRuntime', async () =>
|
||
getTeamProvisioningService().getTeamAgentRuntimeSnapshot(validated.value!)
|
||
);
|
||
}
|
||
|
||
async function handleRestartMember(
|
||
_event: IpcMainInvokeEvent,
|
||
teamName: unknown,
|
||
memberName: unknown
|
||
): Promise<IpcResult<void>> {
|
||
const validatedTeamName = validateTeamName(teamName);
|
||
if (!validatedTeamName.valid) {
|
||
return { success: false, error: validatedTeamName.error ?? 'Invalid teamName' };
|
||
}
|
||
const validatedMemberName = validateMemberName(memberName);
|
||
if (!validatedMemberName.valid) {
|
||
return { success: false, error: validatedMemberName.error ?? 'Invalid memberName' };
|
||
}
|
||
return wrapTeamHandler('restartMember', async () => {
|
||
try {
|
||
await getTeamProvisioningService().restartMember(
|
||
validatedTeamName.value!,
|
||
validatedMemberName.value!
|
||
);
|
||
} finally {
|
||
getTeamDataService().invalidateMessageFeed(validatedTeamName.value!);
|
||
}
|
||
});
|
||
}
|
||
|
||
async function handleRetryFailedOpenCodeSecondaryLanes(
|
||
_event: IpcMainInvokeEvent,
|
||
teamName: unknown
|
||
): Promise<IpcResult<RetryFailedOpenCodeSecondaryLanesResult>> {
|
||
const validatedTeamName = validateTeamName(teamName);
|
||
if (!validatedTeamName.valid) {
|
||
return { success: false, error: validatedTeamName.error ?? 'Invalid teamName' };
|
||
}
|
||
return wrapTeamHandler('retryFailedOpenCodeSecondaryLanes', async () =>
|
||
getTeamProvisioningService().retryFailedOpenCodeSecondaryLanes(validatedTeamName.value!)
|
||
);
|
||
}
|
||
|
||
async function handleSkipMemberForLaunch(
|
||
_event: IpcMainInvokeEvent,
|
||
teamName: unknown,
|
||
memberName: unknown
|
||
): Promise<IpcResult<void>> {
|
||
const validatedTeamName = validateTeamName(teamName);
|
||
if (!validatedTeamName.valid) {
|
||
return { success: false, error: validatedTeamName.error ?? 'Invalid teamName' };
|
||
}
|
||
const validatedMemberName = validateMemberName(memberName);
|
||
if (!validatedMemberName.valid) {
|
||
return { success: false, error: validatedMemberName.error ?? 'Invalid memberName' };
|
||
}
|
||
return wrapTeamHandler('skipMemberForLaunch', async () =>
|
||
getTeamProvisioningService().skipMemberForLaunch(
|
||
validatedTeamName.value!,
|
||
validatedMemberName.value!
|
||
)
|
||
);
|
||
}
|
||
|
||
async function handleStopTeam(
|
||
_event: IpcMainInvokeEvent,
|
||
teamName: unknown
|
||
): Promise<IpcResult<void>> {
|
||
const validated = validateTeamName(teamName);
|
||
if (!validated.valid) {
|
||
return { success: false, error: validated.error ?? 'Invalid teamName' };
|
||
}
|
||
return wrapTeamHandler('stop', async () => {
|
||
addMainBreadcrumb('team', 'stop', { teamName: validated.value! });
|
||
getAutoResumeService().cancelPendingAutoResume(validated.value!);
|
||
await getTeamProvisioningService().stopTeam(validated.value!);
|
||
});
|
||
}
|
||
|
||
async function handleStartTask(
|
||
_event: IpcMainInvokeEvent,
|
||
teamName: unknown,
|
||
taskId: unknown
|
||
): Promise<IpcResult<{ notifiedOwner: boolean }>> {
|
||
const validatedTeamName = validateTeamName(teamName);
|
||
if (!validatedTeamName.valid) {
|
||
return { success: false, error: validatedTeamName.error ?? 'Invalid teamName' };
|
||
}
|
||
const validatedTaskId = validateTaskId(taskId);
|
||
if (!validatedTaskId.valid) {
|
||
return { success: false, error: validatedTaskId.error ?? 'Invalid taskId' };
|
||
}
|
||
return wrapTeamHandler('startTask', () =>
|
||
getTeamDataService().startTask(validatedTeamName.value!, validatedTaskId.value!)
|
||
);
|
||
}
|
||
|
||
async function handleStartTaskByUser(
|
||
_event: IpcMainInvokeEvent,
|
||
teamName: unknown,
|
||
taskId: unknown
|
||
): Promise<IpcResult<{ notifiedOwner: boolean }>> {
|
||
const validatedTeamName = validateTeamName(teamName);
|
||
if (!validatedTeamName.valid) {
|
||
return { success: false, error: validatedTeamName.error ?? 'Invalid teamName' };
|
||
}
|
||
const validatedTaskId = validateTaskId(taskId);
|
||
if (!validatedTaskId.valid) {
|
||
return { success: false, error: validatedTaskId.error ?? 'Invalid taskId' };
|
||
}
|
||
return wrapTeamHandler('startTaskByUser', () =>
|
||
getTeamDataService().startTaskByUser(validatedTeamName.value!, validatedTaskId.value!)
|
||
);
|
||
}
|
||
|
||
async function handleGetAllTasks(_event: IpcMainInvokeEvent): Promise<IpcResult<GlobalTask[]>> {
|
||
setCurrentMainOp('team:getAllTasks');
|
||
const startedAt = Date.now();
|
||
try {
|
||
return await wrapTeamHandler('getAllTasks', () => {
|
||
const loadFresh = () => getTeamDataService().getAllTasks();
|
||
return launchIoGovernor
|
||
? launchIoGovernor.runSummaryOperation('teams:getAllTasks', loadFresh, {
|
||
clone: cloneLaunchIoGovernorPayload,
|
||
})
|
||
: loadFresh();
|
||
});
|
||
} finally {
|
||
const ms = Date.now() - startedAt;
|
||
if (ms >= 1500) {
|
||
logger.warn(`[teams:getAllTasks] slow ms=${ms}`);
|
||
}
|
||
setCurrentMainOp(null);
|
||
}
|
||
}
|
||
|
||
async function handleAddMember(
|
||
_event: IpcMainInvokeEvent,
|
||
teamName: unknown,
|
||
payload: unknown
|
||
): Promise<IpcResult<void>> {
|
||
const vTeam = validateTeamName(teamName);
|
||
if (!vTeam.valid) return { success: false, error: vTeam.error ?? 'Invalid teamName' };
|
||
|
||
if (!payload || typeof payload !== 'object') {
|
||
return { success: false, error: 'Invalid payload' };
|
||
}
|
||
const {
|
||
name,
|
||
role,
|
||
workflow,
|
||
isolation,
|
||
providerId,
|
||
providerBackendId,
|
||
model,
|
||
fastMode,
|
||
mcpPolicy,
|
||
} = payload as {
|
||
name?: unknown;
|
||
role?: unknown;
|
||
workflow?: unknown;
|
||
isolation?: unknown;
|
||
providerId?: unknown;
|
||
providerBackendId?: unknown;
|
||
model?: unknown;
|
||
effort?: unknown;
|
||
fastMode?: unknown;
|
||
mcpPolicy?: 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' };
|
||
}
|
||
if (isolation !== undefined && isolation !== 'worktree') {
|
||
return { success: false, error: 'isolation must be "worktree" when provided' };
|
||
}
|
||
const providerValidation = parseOptionalMemberProviderId(providerId);
|
||
if (!providerValidation.valid) {
|
||
return { success: false, error: providerValidation.error };
|
||
}
|
||
const providerBackendValidation = parseOptionalProviderBackendId(
|
||
providerBackendId,
|
||
providerValidation.value
|
||
);
|
||
if (!providerBackendValidation.valid) {
|
||
return { success: false, error: providerBackendValidation.error };
|
||
}
|
||
if (model !== undefined && typeof model !== 'string') {
|
||
return { success: false, error: 'model must be a string' };
|
||
}
|
||
const effortValidation = parseOptionalMemberEffort(
|
||
(payload as { effort?: unknown }).effort,
|
||
providerValidation.value
|
||
);
|
||
if (!effortValidation.valid) {
|
||
return { success: false, error: effortValidation.error };
|
||
}
|
||
const fastModeValidation = parseOptionalTeamFastMode(fastMode);
|
||
if (!fastModeValidation.valid) {
|
||
return { success: false, error: fastModeValidation.error };
|
||
}
|
||
|
||
return wrapTeamHandler('addMember', async () => {
|
||
const tn = vTeam.value!;
|
||
const memberName = vName.value!;
|
||
const teamDataService = getTeamDataService();
|
||
const previousMembersMeta = await new TeamMembersMetaStore().getMeta(tn).catch(() => null);
|
||
const previousTeamData = await teamDataService.getTeamData(tn);
|
||
const previousMembers = previousTeamData.members as RuntimeRosterMutationMember[];
|
||
const provisioning = getTeamProvisioningService();
|
||
const isTeamAlive = provisioning.isTeamAlive(tn);
|
||
if (isTeamAlive && isOpenCodeLedRoster(previousMembers)) {
|
||
throw new Error(OPENCODE_LEAD_LIVE_ROSTER_MUTATION_BLOCK_MESSAGE);
|
||
}
|
||
|
||
await teamDataService.addMember(tn, {
|
||
name: memberName,
|
||
role: role,
|
||
workflow: typeof workflow === 'string' ? workflow.trim() || undefined : undefined,
|
||
isolation: isolation === 'worktree' ? ('worktree' as const) : undefined,
|
||
providerId: providerValidation.value,
|
||
...(providerBackendValidation.value
|
||
? { providerBackendId: providerBackendValidation.value }
|
||
: {}),
|
||
model: typeof model === 'string' ? model.trim() || undefined : undefined,
|
||
effort: effortValidation.value,
|
||
...(fastModeValidation.value ? { fastMode: fastModeValidation.value } : {}),
|
||
mcpPolicy: normalizeTeamMemberMcpPolicy(mcpPolicy),
|
||
});
|
||
invalidateTeamRosterSnapshotCaches(tn);
|
||
|
||
if (isTeamAlive) {
|
||
try {
|
||
await provisioning.attachLiveRosterMember(tn, memberName, {
|
||
reason: 'member_added',
|
||
});
|
||
} catch (error) {
|
||
await rollbackLiveRosterMutation({
|
||
teamName: tn,
|
||
teamDataService,
|
||
provisioning,
|
||
previousMembers,
|
||
previousMembersMeta,
|
||
detachLiveMemberNames: [memberName],
|
||
});
|
||
throw error;
|
||
}
|
||
}
|
||
});
|
||
}
|
||
|
||
async function handleReplaceMembers(
|
||
_event: IpcMainInvokeEvent,
|
||
teamName: unknown,
|
||
request: unknown
|
||
): Promise<IpcResult<void>> {
|
||
const vTeam = validateTeamName(teamName);
|
||
if (!vTeam.valid) return { success: false, error: vTeam.error ?? 'Invalid teamName' };
|
||
if (!request || typeof request !== 'object') {
|
||
return { success: false, error: 'request must be an object' };
|
||
}
|
||
const payload = request as { members?: unknown };
|
||
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;
|
||
isolation?: 'worktree';
|
||
providerId?: TeamProviderId;
|
||
providerBackendId?: TeamProviderBackendId;
|
||
model?: string;
|
||
effort?: EffortLevel;
|
||
fastMode?: TeamFastMode;
|
||
mcpPolicy?: ReturnType<typeof normalizeTeamMemberMcpPolicy>;
|
||
}[] = [];
|
||
for (const item of payload.members) {
|
||
if (!item || typeof item !== 'object') {
|
||
return { success: false, error: 'member must be object' };
|
||
}
|
||
const m = item as {
|
||
name?: unknown;
|
||
role?: unknown;
|
||
workflow?: unknown;
|
||
isolation?: unknown;
|
||
providerId?: unknown;
|
||
providerBackendId?: unknown;
|
||
model?: unknown;
|
||
effort?: unknown;
|
||
fastMode?: unknown;
|
||
mcpPolicy?: unknown;
|
||
};
|
||
const vName = validateTeammateName(m.name);
|
||
if (!vName.valid) return { success: false, error: vName.error ?? 'Invalid member name' };
|
||
const name = vName.value!;
|
||
if (seenNames.has(name)) return { success: false, error: 'member names must be unique' };
|
||
seenNames.add(name);
|
||
if (m.role !== undefined && typeof m.role !== 'string') {
|
||
return { success: false, error: 'member role must be string' };
|
||
}
|
||
if (m.workflow !== undefined && typeof m.workflow !== 'string') {
|
||
return { success: false, error: 'member workflow must be string' };
|
||
}
|
||
if (m.isolation !== undefined && m.isolation !== 'worktree') {
|
||
return { success: false, error: 'member isolation must be "worktree" when provided' };
|
||
}
|
||
const providerValidation = parseOptionalMemberProviderId(
|
||
(m as { providerId?: unknown }).providerId
|
||
);
|
||
if (!providerValidation.valid) {
|
||
return { success: false, error: providerValidation.error };
|
||
}
|
||
const providerBackendValidation = parseOptionalProviderBackendId(
|
||
(m as { providerBackendId?: unknown }).providerBackendId,
|
||
providerValidation.value
|
||
);
|
||
if (!providerBackendValidation.valid) {
|
||
return { success: false, error: providerBackendValidation.error };
|
||
}
|
||
if (m.model !== undefined && typeof m.model !== 'string') {
|
||
return { success: false, error: 'member model must be string' };
|
||
}
|
||
const effortValidation = parseOptionalMemberEffort(
|
||
(m as { effort?: unknown }).effort,
|
||
providerValidation.value
|
||
);
|
||
if (!effortValidation.valid) {
|
||
return { success: false, error: effortValidation.error };
|
||
}
|
||
const fastModeValidation = parseOptionalTeamFastMode((m as { fastMode?: unknown }).fastMode);
|
||
if (!fastModeValidation.valid) {
|
||
return { success: false, error: fastModeValidation.error };
|
||
}
|
||
members.push({
|
||
name,
|
||
role: typeof m.role === 'string' ? m.role.trim() : undefined,
|
||
workflow: typeof m.workflow === 'string' ? m.workflow.trim() : undefined,
|
||
isolation: m.isolation === 'worktree' ? ('worktree' as const) : undefined,
|
||
providerId: providerValidation.value,
|
||
providerBackendId: providerBackendValidation.value,
|
||
model: typeof m.model === 'string' ? m.model.trim() || undefined : undefined,
|
||
effort: effortValidation.value,
|
||
fastMode: fastModeValidation.value,
|
||
mcpPolicy: normalizeTeamMemberMcpPolicy(m.mcpPolicy),
|
||
});
|
||
}
|
||
|
||
return wrapTeamHandler('replaceMembers', async () => {
|
||
const tn = vTeam.value!;
|
||
const teamDataService = getTeamDataService();
|
||
const previousMembersMeta = await new TeamMembersMetaStore().getMeta(tn).catch(() => null);
|
||
const previousTeamData = await teamDataService.getTeamData(tn);
|
||
const previousMembers = previousTeamData.members as RuntimeRosterMutationMember[];
|
||
const provisioning = getTeamProvisioningService();
|
||
const isTeamAlive = provisioning.isTeamAlive(tn);
|
||
const useSecondaryOpenCodeLaneRouting = isTeamAlive && !isOpenCodeLedRoster(previousMembers);
|
||
if (isTeamAlive && !useSecondaryOpenCodeLaneRouting) {
|
||
throw new Error(OPENCODE_LEAD_LIVE_ROSTER_MUTATION_BLOCK_MESSAGE);
|
||
}
|
||
if (useSecondaryOpenCodeLaneRouting) {
|
||
const ownershipMigrationNames = findOpenCodeOwnershipMigrationNames({
|
||
previousMembers,
|
||
nextMembers: members,
|
||
});
|
||
if (ownershipMigrationNames.length > 0) {
|
||
throw new Error(
|
||
`${OPENCODE_OWNERSHIP_MIGRATION_BLOCK_MESSAGE} Affected member(s): ${ownershipMigrationNames.join(', ')}`
|
||
);
|
||
}
|
||
}
|
||
const primaryDiff = buildReplaceMembersDiff(
|
||
previousMembers.filter((member) =>
|
||
useSecondaryOpenCodeLaneRouting ? !isOpenCodeRosterMutationMember(member) : true
|
||
),
|
||
members.filter((member) =>
|
||
useSecondaryOpenCodeLaneRouting ? !isOpenCodeRosterMutationMember(member) : true
|
||
)
|
||
);
|
||
const previousByName = new Map(
|
||
previousMembers
|
||
.filter((member) => !member.removedAt)
|
||
.map((member) => [member.name.trim().toLowerCase(), member])
|
||
);
|
||
const nextByName = new Map(
|
||
members.map((member) => [
|
||
member.name.trim().toLowerCase(),
|
||
member as RuntimeRosterMutationMember,
|
||
])
|
||
);
|
||
const removedOpenCodeMembers = useSecondaryOpenCodeLaneRouting
|
||
? previousMembers.filter((member) => {
|
||
const normalizedName = member.name.trim().toLowerCase();
|
||
return (
|
||
!member.removedAt &&
|
||
isOpenCodeRosterMutationMember(member) &&
|
||
!nextByName.has(normalizedName)
|
||
);
|
||
})
|
||
: [];
|
||
const addedOpenCodeMembers = useSecondaryOpenCodeLaneRouting
|
||
? members.filter((member) => {
|
||
const normalizedName = member.name.trim().toLowerCase();
|
||
return isOpenCodeRosterMutationMember(member) && !previousByName.has(normalizedName);
|
||
})
|
||
: [];
|
||
const updatedOpenCodeMembers = useSecondaryOpenCodeLaneRouting
|
||
? members.filter((member) => {
|
||
const normalizedName = member.name.trim().toLowerCase();
|
||
const previousMember = previousByName.get(normalizedName);
|
||
return (
|
||
isOpenCodeRosterMutationMember(member) &&
|
||
isOpenCodeRosterMutationMember(previousMember) &&
|
||
didOpenCodeRosterMemberChange(previousMember, member)
|
||
);
|
||
})
|
||
: [];
|
||
|
||
await teamDataService.replaceMembers(tn, { members });
|
||
invalidateTeamRosterSnapshotCaches(tn);
|
||
|
||
if (!isTeamAlive) {
|
||
return;
|
||
}
|
||
|
||
try {
|
||
for (const removedMember of removedOpenCodeMembers) {
|
||
await provisioning.detachLiveRosterMember(tn, removedMember.name);
|
||
}
|
||
|
||
for (const addedMember of addedOpenCodeMembers) {
|
||
await provisioning.attachLiveRosterMember(tn, addedMember.name, {
|
||
reason: 'member_added',
|
||
});
|
||
}
|
||
|
||
for (const updatedMember of updatedOpenCodeMembers) {
|
||
await provisioning.attachLiveRosterMember(tn, updatedMember.name, {
|
||
reason: 'member_updated',
|
||
});
|
||
}
|
||
|
||
for (const removedMemberName of primaryDiff.removed) {
|
||
await provisioning.detachLiveRosterMember(tn, removedMemberName);
|
||
}
|
||
|
||
for (const addedMember of primaryDiff.added) {
|
||
await provisioning.attachLiveRosterMember(tn, addedMember.name, {
|
||
reason: 'member_added',
|
||
});
|
||
}
|
||
|
||
for (const updatedMember of primaryDiff.updated) {
|
||
await provisioning.attachLiveRosterMember(tn, updatedMember.name, {
|
||
reason: 'member_updated',
|
||
});
|
||
}
|
||
} catch (error) {
|
||
await rollbackLiveRosterMutation({
|
||
teamName: tn,
|
||
teamDataService,
|
||
provisioning,
|
||
previousMembers,
|
||
previousMembersMeta,
|
||
restoreLiveMemberNames: [
|
||
...removedOpenCodeMembers.map((member) => member.name),
|
||
...primaryDiff.removed,
|
||
...updatedOpenCodeMembers.map((member) => member.name),
|
||
...primaryDiff.updated.map((member) => member.name),
|
||
],
|
||
detachLiveMemberNames: [
|
||
...addedOpenCodeMembers.map((member) => member.name),
|
||
...primaryDiff.added.map((member) => member.name),
|
||
],
|
||
});
|
||
throw error;
|
||
}
|
||
|
||
const summaryMessage = buildReplaceMembersSummaryMessage({
|
||
...primaryDiff,
|
||
updated: [],
|
||
});
|
||
if (!summaryMessage) {
|
||
return;
|
||
}
|
||
try {
|
||
await provisioning.sendMessageToTeam(tn, summaryMessage);
|
||
} catch {
|
||
logger.warn(`Failed to notify lead about member updates in ${tn}`);
|
||
}
|
||
});
|
||
}
|
||
|
||
async function handleRemoveMember(
|
||
_event: IpcMainInvokeEvent,
|
||
teamName: unknown,
|
||
memberName: unknown
|
||
): Promise<IpcResult<void>> {
|
||
const vTeam = validateTeamName(teamName);
|
||
if (!vTeam.valid) return { success: false, error: vTeam.error ?? 'Invalid teamName' };
|
||
const vMember = validateMemberName(memberName);
|
||
if (!vMember.valid) return { success: false, error: vMember.error ?? 'Invalid memberName' };
|
||
|
||
return wrapTeamHandler('removeMember', async () => {
|
||
const tn = vTeam.value!;
|
||
const name = vMember.value!;
|
||
const teamDataService = getTeamDataService();
|
||
const previousMembersMeta = await new TeamMembersMetaStore().getMeta(tn).catch(() => null);
|
||
const previousTeamData = await teamDataService.getTeamData(tn);
|
||
const previousMembers = previousTeamData.members as RuntimeRosterMutationMember[];
|
||
const provisioning = getTeamProvisioningService();
|
||
const isTeamAlive = provisioning.isTeamAlive(tn);
|
||
if (isTeamAlive && isOpenCodeLedRoster(previousMembers)) {
|
||
throw new Error(OPENCODE_LEAD_LIVE_ROSTER_MUTATION_BLOCK_MESSAGE);
|
||
}
|
||
await teamDataService.removeMember(tn, name);
|
||
invalidateTeamRosterSnapshotCaches(tn);
|
||
|
||
if (isTeamAlive) {
|
||
try {
|
||
await provisioning.detachLiveRosterMember(tn, name);
|
||
} catch (error) {
|
||
await rollbackLiveRosterMutation({
|
||
teamName: tn,
|
||
teamDataService,
|
||
provisioning,
|
||
previousMembers,
|
||
previousMembersMeta,
|
||
restoreLiveMemberNames: [name],
|
||
});
|
||
throw error;
|
||
}
|
||
|
||
const message =
|
||
`Teammate "${name}" has been removed from the team. ` +
|
||
`They will no longer participate in team activities. Please reassign their tasks if needed.`;
|
||
try {
|
||
await provisioning.sendMessageToTeam(tn, message);
|
||
} catch {
|
||
logger.warn(`Failed to notify lead about removal of "${name}" in ${tn}`);
|
||
}
|
||
}
|
||
});
|
||
}
|
||
|
||
async function handleRestoreMember(
|
||
_event: IpcMainInvokeEvent,
|
||
teamName: unknown,
|
||
memberName: unknown
|
||
): Promise<IpcResult<void>> {
|
||
const vTeam = validateTeamName(teamName);
|
||
if (!vTeam.valid) return { success: false, error: vTeam.error ?? 'Invalid teamName' };
|
||
const vMember = validateMemberName(memberName);
|
||
if (!vMember.valid) return { success: false, error: vMember.error ?? 'Invalid memberName' };
|
||
|
||
return wrapTeamHandler('restoreMember', async () => {
|
||
const tn = vTeam.value!;
|
||
const name = vMember.value!;
|
||
const teamDataService = getTeamDataService();
|
||
const previousMembersMeta = await new TeamMembersMetaStore().getMeta(tn).catch(() => null);
|
||
const previousTeamData = await teamDataService.getTeamData(tn);
|
||
const previousMembers = previousTeamData.members as RuntimeRosterMutationMember[];
|
||
const provisioning = getTeamProvisioningService();
|
||
const isTeamAlive = provisioning.isTeamAlive(tn);
|
||
if (isTeamAlive && isOpenCodeLedRoster(previousMembers)) {
|
||
throw new Error(OPENCODE_LEAD_LIVE_ROSTER_MUTATION_BLOCK_MESSAGE);
|
||
}
|
||
|
||
await teamDataService.restoreMember(tn, name);
|
||
invalidateTeamRosterSnapshotCaches(tn);
|
||
|
||
if (!isTeamAlive) {
|
||
return;
|
||
}
|
||
|
||
try {
|
||
await provisioning.attachLiveRosterMember(tn, name, {
|
||
reason: 'member_restored',
|
||
});
|
||
} catch (error) {
|
||
await rollbackLiveRosterMutation({
|
||
teamName: tn,
|
||
teamDataService,
|
||
provisioning,
|
||
previousMembers,
|
||
previousMembersMeta,
|
||
detachLiveMemberNames: [name],
|
||
});
|
||
throw error;
|
||
}
|
||
});
|
||
}
|
||
|
||
async function handleUpdateTaskFields(
|
||
_event: IpcMainInvokeEvent,
|
||
teamName: unknown,
|
||
taskId: unknown,
|
||
fields: unknown
|
||
): Promise<IpcResult<void>> {
|
||
const vTeam = validateTeamName(teamName);
|
||
if (!vTeam.valid) return { success: false, error: vTeam.error ?? 'Invalid teamName' };
|
||
const vTask = validateTaskId(taskId);
|
||
if (!vTask.valid) return { success: false, error: vTask.error ?? 'Invalid taskId' };
|
||
const tid = vTask.value!;
|
||
if (!fields || typeof fields !== 'object') {
|
||
return { success: false, error: 'fields must be an object' };
|
||
}
|
||
const { subject, description } = fields as { subject?: unknown; description?: unknown };
|
||
if (subject !== undefined) {
|
||
if (typeof subject !== 'string') return { success: false, error: 'subject must be a string' };
|
||
if (subject.trim().length === 0) return { success: false, error: 'subject cannot be empty' };
|
||
if (subject.length > 500)
|
||
return { success: false, error: 'subject must be 500 characters or less' };
|
||
}
|
||
if (description !== undefined && typeof description !== 'string') {
|
||
return { success: false, error: 'description must be a string' };
|
||
}
|
||
|
||
const validFields: { subject?: string; description?: string } = {};
|
||
if (typeof subject === 'string') validFields.subject = subject.trim();
|
||
if (typeof description === 'string') validFields.description = description;
|
||
|
||
if (Object.keys(validFields).length === 0) {
|
||
return { success: false, error: 'At least one field must be provided' };
|
||
}
|
||
|
||
return wrapTeamHandler('updateTaskFields', async () => {
|
||
const tn = vTeam.value!;
|
||
await getTeamDataService().updateTaskFields(tn, tid, validFields);
|
||
|
||
// Notify the lead about updated task fields
|
||
const provisioning = getTeamProvisioningService();
|
||
if (provisioning.isTeamAlive(tn)) {
|
||
const changedParts: string[] = [];
|
||
if (validFields.subject) changedParts.push('title');
|
||
if (validFields.description !== undefined) changedParts.push('description');
|
||
const message =
|
||
`Task #${tid} has been updated by the user (changed: ${changedParts.join(', ')}). ` +
|
||
`New title: "${validFields.subject ?? '(unchanged)'}".`;
|
||
try {
|
||
await provisioning.sendMessageToTeam(tn, message);
|
||
} catch {
|
||
logger.warn(`Failed to notify lead about task fields update for #${tid} in ${tn}`);
|
||
}
|
||
}
|
||
});
|
||
}
|
||
|
||
async function handleUpdateMemberRole(
|
||
_event: IpcMainInvokeEvent,
|
||
teamName: unknown,
|
||
memberName: unknown,
|
||
role: unknown
|
||
): Promise<IpcResult<void>> {
|
||
const vTeam = validateTeamName(teamName);
|
||
if (!vTeam.valid) return { success: false, error: vTeam.error ?? 'Invalid teamName' };
|
||
const vMember = validateMemberName(memberName);
|
||
if (!vMember.valid) return { success: false, error: vMember.error ?? 'Invalid memberName' };
|
||
|
||
const normalizedRole =
|
||
role === undefined || role === null
|
||
? undefined
|
||
: typeof role === 'string'
|
||
? role.trim() || undefined
|
||
: undefined;
|
||
|
||
return wrapTeamHandler('updateMemberRole', async () => {
|
||
const tn = vTeam.value!;
|
||
const name = vMember.value!;
|
||
const { oldRole, changed } = await getTeamDataService().updateMemberRole(
|
||
tn,
|
||
name,
|
||
normalizedRole
|
||
);
|
||
|
||
if (changed) {
|
||
invalidateTeamRosterSnapshotCaches(tn);
|
||
const provisioning = getTeamProvisioningService();
|
||
if (provisioning.isTeamAlive(tn)) {
|
||
const oldDesc = oldRole ? `"${oldRole}"` : 'none';
|
||
const newDesc = normalizedRole ? `"${normalizedRole}"` : 'none';
|
||
const message = `Teammate "${name}" role changed from ${oldDesc} to ${newDesc}. This will take effect on next launch.`;
|
||
try {
|
||
await provisioning.sendMessageToTeam(tn, message);
|
||
} catch {
|
||
logger.warn(`Failed to notify lead about role change for "${name}" in ${tn}`);
|
||
}
|
||
}
|
||
}
|
||
});
|
||
}
|
||
|
||
async function handleKillProcess(
|
||
_event: IpcMainInvokeEvent,
|
||
teamName: unknown,
|
||
pid: unknown
|
||
): Promise<IpcResult<void>> {
|
||
const vTeam = validateTeamName(teamName);
|
||
if (!vTeam.valid) return { success: false, error: vTeam.error ?? 'Invalid teamName' };
|
||
if (typeof pid !== 'number' || !Number.isInteger(pid) || pid <= 0) {
|
||
return { success: false, error: 'pid must be a positive integer' };
|
||
}
|
||
return wrapTeamHandler('killProcess', async () => {
|
||
const tn = vTeam.value!;
|
||
const pidNum = pid;
|
||
|
||
// Read process label before killing (for notification message)
|
||
let processLabel = `PID ${pidNum}`;
|
||
try {
|
||
const data = await getTeamDataService().getTeamData(tn);
|
||
const proc = data.processes?.find((p) => p.pid === pidNum);
|
||
if (proc) {
|
||
processLabel = proc.label + (proc.port != null ? ` (:${proc.port})` : '');
|
||
}
|
||
} catch {
|
||
// best-effort label lookup
|
||
}
|
||
|
||
await getTeamDataService().killProcess(tn, pidNum);
|
||
|
||
// Notify the team lead about the killed process
|
||
const provisioning = getTeamProvisioningService();
|
||
if (provisioning.isTeamAlive(tn)) {
|
||
const message =
|
||
`Process "${processLabel}" (PID ${pidNum}) has been stopped by the user from the UI. ` +
|
||
`You may need to restart it if it was still needed.`;
|
||
try {
|
||
await provisioning.sendMessageToTeam(tn, message);
|
||
} catch {
|
||
logger.warn(`Failed to notify lead about killed process ${pidNum} in ${tn}`);
|
||
}
|
||
}
|
||
});
|
||
}
|
||
|
||
async function handleShowMessageNotification(
|
||
_event: IpcMainInvokeEvent,
|
||
data: unknown
|
||
): Promise<IpcResult<void>> {
|
||
if (!data || typeof data !== 'object') {
|
||
return { success: false, error: 'Invalid notification data' };
|
||
}
|
||
const d = data as TeamMessageNotificationData;
|
||
if (!d.teamDisplayName || !d.from || !d.body) {
|
||
return { success: false, error: 'Missing required fields (teamDisplayName, from, body)' };
|
||
}
|
||
if (!d.teamName) {
|
||
return {
|
||
success: false,
|
||
error: 'Missing required field: teamName (needed for deep-link navigation)',
|
||
};
|
||
}
|
||
|
||
// Route through NotificationManager for unified storage + native toast.
|
||
// dedupeKey is required from renderer — built from stable identifiers (taskId, teamName, etc.)
|
||
const dedupeKey =
|
||
d.dedupeKey ?? `msg:${d.teamName}:${d.from}:${d.summary ?? d.body.slice(0, 50)}`;
|
||
|
||
void NotificationManager.getInstance()
|
||
.addTeamNotification({
|
||
teamEventType: d.teamEventType ?? 'task_clarification',
|
||
teamName: d.teamName,
|
||
teamDisplayName: d.teamDisplayName,
|
||
from: d.from,
|
||
to: d.to,
|
||
summary: d.summary ?? `${d.from} → ${d.to ?? 'team'}`,
|
||
body: d.body,
|
||
dedupeKey,
|
||
target: d.target,
|
||
suppressToast: d.suppressToast,
|
||
})
|
||
.catch(() => undefined);
|
||
|
||
return { success: true, data: undefined };
|
||
}
|
||
|
||
/**
|
||
* Show a native OS notification for a team event.
|
||
* @deprecated Use NotificationManager.addTeamNotification() instead for unified storage + toast.
|
||
* Kept for backward compatibility with any remaining callers.
|
||
*/
|
||
export function showTeamNativeNotification(opts: {
|
||
title: string;
|
||
subtitle?: string;
|
||
body: string;
|
||
}): void {
|
||
const config = ConfigManager.getInstance().getConfig();
|
||
if (!config.notifications.enabled) {
|
||
logger.debug('[native-notification] skipped: notifications disabled');
|
||
return;
|
||
}
|
||
if (config.notifications.snoozedUntil && Date.now() < config.notifications.snoozedUntil) {
|
||
logger.debug('[native-notification] skipped: snoozed');
|
||
return;
|
||
}
|
||
|
||
if (
|
||
typeof Notification === 'undefined' ||
|
||
typeof Notification.isSupported !== 'function' ||
|
||
!Notification.isSupported()
|
||
) {
|
||
logger.warn('[native-notification] skipped: Notification not supported on this platform');
|
||
return;
|
||
}
|
||
|
||
const isMac = process.platform === 'darwin';
|
||
const truncatedBody = stripMarkdown(opts.body).slice(0, 300);
|
||
const iconPath = isMac ? undefined : getAppIconPath();
|
||
const notification = new Notification({
|
||
title: opts.title,
|
||
...(isMac && opts.subtitle ? { subtitle: opts.subtitle } : {}),
|
||
body: !isMac && opts.subtitle ? `${opts.subtitle}\n${truncatedBody}` : truncatedBody,
|
||
sound: config.notifications.soundEnabled ? 'default' : undefined,
|
||
...(iconPath ? { icon: iconPath } : {}),
|
||
});
|
||
|
||
// Hold a strong reference to prevent GC from collecting the notification
|
||
activeTeamNotifications.add(notification);
|
||
const cleanup = (): void => {
|
||
activeTeamNotifications.delete(notification);
|
||
};
|
||
|
||
notification.on('click', () => {
|
||
const windows = BrowserWindow.getAllWindows();
|
||
const mainWin = windows[0];
|
||
if (mainWin && !mainWin.isDestroyed()) {
|
||
mainWin.show();
|
||
mainWin.focus();
|
||
}
|
||
cleanup();
|
||
});
|
||
notification.on('close', cleanup);
|
||
|
||
notification.on('show', () => {
|
||
logger.debug(`[native-notification] shown: "${opts.title}" — ${opts.subtitle ?? ''}`);
|
||
});
|
||
|
||
notification.on('failed', (_, error) => {
|
||
logger.warn(`[native-notification] failed: ${error}`);
|
||
cleanup();
|
||
});
|
||
|
||
notification.show();
|
||
}
|
||
|
||
async function handleAddTaskComment(
|
||
_event: IpcMainInvokeEvent,
|
||
teamName: unknown,
|
||
taskId: unknown,
|
||
request: unknown
|
||
): Promise<IpcResult<TaskComment>> {
|
||
const vTeam = validateTeamName(teamName);
|
||
if (!vTeam.valid) return { success: false, error: vTeam.error ?? 'Invalid teamName' };
|
||
const vTask = validateTaskId(taskId);
|
||
if (!vTask.valid) return { success: false, error: vTask.error ?? 'Invalid taskId' };
|
||
if (!request || typeof request !== 'object') {
|
||
return { success: false, error: 'Invalid add task comment request' };
|
||
}
|
||
const payload = request as Partial<AddTaskCommentRequest>;
|
||
const text = payload.text;
|
||
if (typeof text !== 'string' || text.trim().length === 0)
|
||
return { success: false, error: 'Comment text must be non-empty' };
|
||
if (text.trim().length > MAX_TEXT_LENGTH)
|
||
return { success: false, error: `Comment exceeds ${MAX_TEXT_LENGTH} characters` };
|
||
const validatedTaskRefs = validateTaskRefs(payload.taskRefs);
|
||
if (!validatedTaskRefs.valid) {
|
||
return { success: false, error: validatedTaskRefs.error };
|
||
}
|
||
|
||
const rawAttachments = Array.isArray(payload.attachments) ? payload.attachments : [];
|
||
if (rawAttachments.length > MAX_ATTACHMENTS) {
|
||
return { success: false, error: `Maximum ${MAX_ATTACHMENTS} attachments per comment` };
|
||
}
|
||
|
||
return wrapTeamHandler('addTaskComment', async () => {
|
||
// Save comment attachments (images). Done inside wrapTeamHandler so failures return IpcResult.
|
||
let savedAttachments: TaskAttachmentMeta[] | undefined;
|
||
if (rawAttachments.length > 0) {
|
||
savedAttachments = [];
|
||
for (const att of rawAttachments) {
|
||
if (!att || typeof att !== 'object') {
|
||
throw new Error('Invalid attachment data');
|
||
}
|
||
const a = att as unknown as Record<string, unknown>;
|
||
if (
|
||
typeof a.id !== 'string' ||
|
||
typeof a.filename !== 'string' ||
|
||
!isValidStoredAttachmentMimeType(a.mimeType) ||
|
||
typeof a.base64Data !== 'string' ||
|
||
a.base64Data.length === 0
|
||
) {
|
||
throw new Error('Invalid attachment data');
|
||
}
|
||
const safeId = a.id.trim();
|
||
if (safeId.includes('/') || safeId.includes('\\') || safeId.includes('..')) {
|
||
throw new Error('Invalid attachment ID');
|
||
}
|
||
const meta = await taskAttachmentStore.saveAttachment(
|
||
vTeam.value!,
|
||
vTask.value!,
|
||
safeId,
|
||
a.filename,
|
||
a.mimeType.trim(),
|
||
a.base64Data
|
||
);
|
||
savedAttachments.push(meta);
|
||
}
|
||
}
|
||
|
||
return getTeamDataService().addTaskComment(
|
||
vTeam.value!,
|
||
vTask.value!,
|
||
text.trim(),
|
||
savedAttachments,
|
||
validatedTaskRefs.value
|
||
);
|
||
});
|
||
}
|
||
|
||
const VALID_RELATIONSHIP_TYPES = ['blockedBy', 'blocks', 'related'] as const;
|
||
type RelationshipType = (typeof VALID_RELATIONSHIP_TYPES)[number];
|
||
|
||
async function handleAddTaskRelationship(
|
||
_event: IpcMainInvokeEvent,
|
||
teamName: unknown,
|
||
taskId: unknown,
|
||
targetId: unknown,
|
||
type: unknown
|
||
): Promise<IpcResult<void>> {
|
||
const vTeam = validateTeamName(teamName);
|
||
if (!vTeam.valid) return { success: false, error: vTeam.error ?? 'Invalid teamName' };
|
||
const vTask = validateTaskId(taskId);
|
||
if (!vTask.valid) return { success: false, error: vTask.error ?? 'Invalid taskId' };
|
||
const vTarget = validateTaskId(targetId);
|
||
if (!vTarget.valid) return { success: false, error: vTarget.error ?? 'Invalid targetId' };
|
||
if (typeof type !== 'string' || !VALID_RELATIONSHIP_TYPES.includes(type as RelationshipType)) {
|
||
return {
|
||
success: false,
|
||
error: `type must be one of: ${VALID_RELATIONSHIP_TYPES.join(', ')}`,
|
||
};
|
||
}
|
||
|
||
return wrapTeamHandler('addTaskRelationship', () =>
|
||
getTeamDataService().addTaskRelationship(
|
||
vTeam.value!,
|
||
vTask.value!,
|
||
vTarget.value!,
|
||
type as RelationshipType
|
||
)
|
||
);
|
||
}
|
||
|
||
async function handleRemoveTaskRelationship(
|
||
_event: IpcMainInvokeEvent,
|
||
teamName: unknown,
|
||
taskId: unknown,
|
||
targetId: unknown,
|
||
type: unknown
|
||
): Promise<IpcResult<void>> {
|
||
const vTeam = validateTeamName(teamName);
|
||
if (!vTeam.valid) return { success: false, error: vTeam.error ?? 'Invalid teamName' };
|
||
const vTask = validateTaskId(taskId);
|
||
if (!vTask.valid) return { success: false, error: vTask.error ?? 'Invalid taskId' };
|
||
const vTarget = validateTaskId(targetId);
|
||
if (!vTarget.valid) return { success: false, error: vTarget.error ?? 'Invalid targetId' };
|
||
if (typeof type !== 'string' || !VALID_RELATIONSHIP_TYPES.includes(type as RelationshipType)) {
|
||
return {
|
||
success: false,
|
||
error: `type must be one of: ${VALID_RELATIONSHIP_TYPES.join(', ')}`,
|
||
};
|
||
}
|
||
|
||
return wrapTeamHandler('removeTaskRelationship', () =>
|
||
getTeamDataService().removeTaskRelationship(
|
||
vTeam.value!,
|
||
vTask.value!,
|
||
vTarget.value!,
|
||
type as RelationshipType
|
||
)
|
||
);
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Task Attachment Handlers
|
||
// ---------------------------------------------------------------------------
|
||
|
||
async function handleSaveTaskAttachment(
|
||
_event: IpcMainInvokeEvent,
|
||
teamName: unknown,
|
||
taskId: unknown,
|
||
attachmentId: unknown,
|
||
filename: unknown,
|
||
mimeType: unknown,
|
||
base64Data: unknown
|
||
): Promise<IpcResult<TaskAttachmentMeta>> {
|
||
const vTeam = validateTeamName(teamName);
|
||
if (!vTeam.valid) return { success: false, error: vTeam.error ?? 'Invalid teamName' };
|
||
const vTask = validateTaskId(taskId);
|
||
if (!vTask.valid) return { success: false, error: vTask.error ?? 'Invalid taskId' };
|
||
if (typeof attachmentId !== 'string' || attachmentId.trim().length === 0) {
|
||
return { success: false, error: 'attachmentId must be a non-empty string' };
|
||
}
|
||
if (typeof filename !== 'string' || filename.trim().length === 0) {
|
||
return { success: false, error: 'filename must be a non-empty string' };
|
||
}
|
||
if (!isValidStoredAttachmentMimeType(mimeType)) {
|
||
return { success: false, error: 'Invalid mimeType' };
|
||
}
|
||
if (typeof base64Data !== 'string' || base64Data.length === 0) {
|
||
return { success: false, error: 'base64Data must be a non-empty string' };
|
||
}
|
||
// Sanitize IDs against path traversal
|
||
const safeAttId = attachmentId.trim();
|
||
if (safeAttId.includes('/') || safeAttId.includes('\\') || safeAttId.includes('..')) {
|
||
return { success: false, error: 'Invalid attachmentId' };
|
||
}
|
||
|
||
return wrapTeamHandler('saveTaskAttachment', async () => {
|
||
const meta = await taskAttachmentStore.saveAttachment(
|
||
vTeam.value!,
|
||
vTask.value!,
|
||
safeAttId,
|
||
filename,
|
||
mimeType.trim(),
|
||
base64Data
|
||
);
|
||
// Write metadata into the task JSON
|
||
await getTeamDataService().addTaskAttachment(vTeam.value!, vTask.value!, meta);
|
||
return meta;
|
||
});
|
||
}
|
||
|
||
async function handleGetTaskAttachment(
|
||
_event: IpcMainInvokeEvent,
|
||
teamName: unknown,
|
||
taskId: unknown,
|
||
attachmentId: unknown,
|
||
mimeType: unknown
|
||
): Promise<IpcResult<string | null>> {
|
||
const vTeam = validateTeamName(teamName);
|
||
if (!vTeam.valid) return { success: false, error: vTeam.error ?? 'Invalid teamName' };
|
||
const vTask = validateTaskId(taskId);
|
||
if (!vTask.valid) return { success: false, error: vTask.error ?? 'Invalid taskId' };
|
||
if (typeof attachmentId !== 'string' || attachmentId.trim().length === 0) {
|
||
return { success: false, error: 'attachmentId must be a non-empty string' };
|
||
}
|
||
if (!isValidStoredAttachmentMimeType(mimeType)) {
|
||
return { success: false, error: 'Invalid mimeType' };
|
||
}
|
||
const safeAttId = attachmentId.trim();
|
||
if (safeAttId.includes('/') || safeAttId.includes('\\') || safeAttId.includes('..')) {
|
||
return { success: false, error: 'Invalid attachmentId' };
|
||
}
|
||
|
||
return wrapTeamHandler('getTaskAttachment', () =>
|
||
taskAttachmentStore.getAttachment(vTeam.value!, vTask.value!, safeAttId, mimeType.trim())
|
||
);
|
||
}
|
||
|
||
async function handleDeleteTaskAttachment(
|
||
_event: IpcMainInvokeEvent,
|
||
teamName: unknown,
|
||
taskId: unknown,
|
||
attachmentId: unknown,
|
||
mimeType: unknown
|
||
): Promise<IpcResult<void>> {
|
||
const vTeam = validateTeamName(teamName);
|
||
if (!vTeam.valid) return { success: false, error: vTeam.error ?? 'Invalid teamName' };
|
||
const vTask = validateTaskId(taskId);
|
||
if (!vTask.valid) return { success: false, error: vTask.error ?? 'Invalid taskId' };
|
||
if (typeof attachmentId !== 'string' || attachmentId.trim().length === 0) {
|
||
return { success: false, error: 'attachmentId must be a non-empty string' };
|
||
}
|
||
if (!isValidStoredAttachmentMimeType(mimeType)) {
|
||
return { success: false, error: 'Invalid mimeType' };
|
||
}
|
||
const safeAttId = attachmentId.trim();
|
||
if (safeAttId.includes('/') || safeAttId.includes('\\') || safeAttId.includes('..')) {
|
||
return { success: false, error: 'Invalid attachmentId' };
|
||
}
|
||
|
||
return wrapTeamHandler('deleteTaskAttachment', async () => {
|
||
await taskAttachmentStore.deleteAttachment(
|
||
vTeam.value!,
|
||
vTask.value!,
|
||
safeAttId,
|
||
mimeType.trim()
|
||
);
|
||
// Remove metadata from task JSON
|
||
await getTeamDataService().removeTaskAttachment(vTeam.value!, vTask.value!, safeAttId);
|
||
});
|
||
}
|
||
|
||
async function handleToolApprovalRespond(
|
||
_event: IpcMainInvokeEvent,
|
||
teamName: unknown,
|
||
runId: unknown,
|
||
requestId: unknown,
|
||
allow: unknown,
|
||
message?: unknown
|
||
): Promise<IpcResult<void>> {
|
||
const validated = validateTeamName(teamName);
|
||
if (!validated.valid) {
|
||
return { success: false, error: validated.error ?? 'Invalid teamName' };
|
||
}
|
||
if (typeof runId !== 'string' || runId.trim().length === 0) {
|
||
return { success: false, error: 'runId must be a non-empty string' };
|
||
}
|
||
if (typeof requestId !== 'string' || requestId.trim().length === 0) {
|
||
return { success: false, error: 'requestId must be a non-empty string' };
|
||
}
|
||
if (typeof allow !== 'boolean') {
|
||
return { success: false, error: 'allow must be a boolean' };
|
||
}
|
||
return wrapTeamHandler('toolApprovalRespond', () =>
|
||
getTeamProvisioningService().respondToToolApproval(
|
||
validated.value!,
|
||
runId,
|
||
requestId,
|
||
allow,
|
||
typeof message === 'string' ? message : undefined
|
||
)
|
||
);
|
||
}
|
||
|
||
async function handleToolApprovalSettings(
|
||
_event: IpcMainInvokeEvent,
|
||
teamName: unknown,
|
||
settings: unknown
|
||
): Promise<IpcResult<void>> {
|
||
if (typeof teamName !== 'string' || teamName.trim().length === 0) {
|
||
return { success: false, error: 'teamName must be a non-empty string' };
|
||
}
|
||
if (typeof settings !== 'object' || settings === null) {
|
||
return { success: false, error: 'Settings must be an object' };
|
||
}
|
||
const s = settings as Record<string, unknown>;
|
||
if (typeof s.autoAllowAll !== 'boolean') {
|
||
return { success: false, error: 'autoAllowAll must be a boolean' };
|
||
}
|
||
if (typeof s.autoAllowFileEdits !== 'boolean') {
|
||
return { success: false, error: 'autoAllowFileEdits must be a boolean' };
|
||
}
|
||
if (typeof s.autoAllowSafeBash !== 'boolean') {
|
||
return { success: false, error: 'autoAllowSafeBash must be a boolean' };
|
||
}
|
||
if (typeof s.timeoutAction !== 'string' || !['allow', 'deny', 'wait'].includes(s.timeoutAction)) {
|
||
return { success: false, error: 'timeoutAction must be "allow", "deny", or "wait"' };
|
||
}
|
||
if (
|
||
typeof s.timeoutSeconds !== 'number' ||
|
||
!Number.isFinite(s.timeoutSeconds) ||
|
||
s.timeoutSeconds < 5 ||
|
||
s.timeoutSeconds > 300
|
||
) {
|
||
return { success: false, error: 'timeoutSeconds must be a number between 5 and 300' };
|
||
}
|
||
|
||
try {
|
||
getTeamProvisioningService().updateToolApprovalSettings(
|
||
teamName,
|
||
s as unknown as ToolApprovalSettings
|
||
);
|
||
} catch (err) {
|
||
return {
|
||
success: false,
|
||
error: `Failed to update tool approval settings: ${err instanceof Error ? err.message : String(err)}`,
|
||
};
|
||
}
|
||
return { success: true, data: undefined };
|
||
}
|
||
|
||
/** Max file size for tool approval diff preview (2MB). */
|
||
const TOOL_APPROVAL_MAX_FILE_SIZE = 2 * 1024 * 1024;
|
||
|
||
async function handleToolApprovalReadFile(
|
||
_event: IpcMainInvokeEvent,
|
||
filePath: unknown
|
||
): Promise<IpcResult<ToolApprovalFileContent>> {
|
||
if (typeof filePath !== 'string' || filePath.trim().length === 0) {
|
||
return { success: false, error: 'filePath must be a non-empty string' };
|
||
}
|
||
if (!path.isAbsolute(filePath)) {
|
||
return { success: false, error: 'filePath must be an absolute path' };
|
||
}
|
||
|
||
try {
|
||
let stats: fs.Stats;
|
||
try {
|
||
stats = await fs.promises.stat(filePath);
|
||
} catch (err) {
|
||
if ((err as NodeJS.ErrnoException).code === 'ENOENT') {
|
||
return {
|
||
success: true,
|
||
data: { content: '', exists: false, truncated: false, isBinary: false },
|
||
};
|
||
}
|
||
throw err;
|
||
}
|
||
|
||
if (!stats.isFile()) {
|
||
return {
|
||
success: true,
|
||
data: { content: '', exists: true, truncated: false, isBinary: false, error: 'Not a file' },
|
||
};
|
||
}
|
||
|
||
const truncated = stats.size > TOOL_APPROVAL_MAX_FILE_SIZE;
|
||
const readSize = truncated ? TOOL_APPROVAL_MAX_FILE_SIZE : stats.size;
|
||
|
||
// Read file (potentially truncated)
|
||
const fd = await fs.promises.open(filePath, 'r');
|
||
try {
|
||
const buffer = Buffer.alloc(readSize);
|
||
await fd.read(buffer, 0, readSize, 0);
|
||
|
||
// Binary detection: check first 8KB for null bytes
|
||
const checkSize = Math.min(readSize, 8192);
|
||
for (let i = 0; i < checkSize; i++) {
|
||
if (buffer[i] === 0) {
|
||
return {
|
||
success: true,
|
||
data: { content: '', exists: true, truncated: false, isBinary: true },
|
||
};
|
||
}
|
||
}
|
||
|
||
return {
|
||
success: true,
|
||
data: { content: buffer.toString('utf-8'), exists: true, truncated, isBinary: false },
|
||
};
|
||
} finally {
|
||
await fd.close();
|
||
}
|
||
} catch (err) {
|
||
const msg = err instanceof Error ? err.message : String(err);
|
||
return {
|
||
success: true,
|
||
data: { content: '', exists: true, truncated: false, isBinary: false, error: msg },
|
||
};
|
||
}
|
||
}
|
||
|
||
async function handleGetSavedRequest(
|
||
_event: IpcMainInvokeEvent,
|
||
teamName: unknown
|
||
): Promise<IpcResult<TeamCreateRequest | null>> {
|
||
const validated = validateTeamName(teamName);
|
||
if (!validated.valid) {
|
||
return { success: false, error: validated.error ?? 'Invalid teamName' };
|
||
}
|
||
return wrapTeamHandler('getSavedRequest', async () => {
|
||
return getTeamDataService().getSavedRequest(validated.value!);
|
||
});
|
||
}
|
||
|
||
async function handleDeleteDraft(
|
||
_event: IpcMainInvokeEvent,
|
||
teamName: unknown
|
||
): Promise<IpcResult<void>> {
|
||
const validated = validateTeamName(teamName);
|
||
if (!validated.valid) {
|
||
return { success: false, error: validated.error ?? 'Invalid teamName' };
|
||
}
|
||
return wrapTeamHandler('deleteDraft', async () => {
|
||
// Only allow deleting draft teams (no config.json)
|
||
const configPath = path.join(getTeamsBasePath(), validated.value!, 'config.json');
|
||
try {
|
||
await fs.promises.access(configPath, fs.constants.F_OK);
|
||
throw new Error('Cannot delete draft: team has config.json (use deleteTeam instead)');
|
||
} catch (error) {
|
||
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error;
|
||
}
|
||
await getTeamDataService().permanentlyDeleteTeam(validated.value!);
|
||
});
|
||
}
|