chore: merge team ipc clean architecture refactor
This commit is contained in:
commit
f4ff278ac4
46 changed files with 5134 additions and 2066 deletions
|
|
@ -97,7 +97,6 @@ import {
|
||||||
import { wrapAgentBlock } from '@shared/constants/agentBlocks';
|
import { wrapAgentBlock } from '@shared/constants/agentBlocks';
|
||||||
import { KANBAN_COLUMN_IDS } from '@shared/constants/kanban';
|
import { KANBAN_COLUMN_IDS } from '@shared/constants/kanban';
|
||||||
import { MAX_TEXT_LENGTH } from '@shared/constants/teamLimits';
|
import { MAX_TEXT_LENGTH } from '@shared/constants/teamLimits';
|
||||||
import { isApiErrorMessage } from '@shared/utils/apiErrorDetector';
|
|
||||||
import {
|
import {
|
||||||
extractFlagsFromHelp,
|
extractFlagsFromHelp,
|
||||||
extractUserFlags,
|
extractUserFlags,
|
||||||
|
|
@ -111,7 +110,6 @@ import { getErrorMessage } from '@shared/utils/errorHandling';
|
||||||
import { isLeadMember } from '@shared/utils/leadDetection';
|
import { isLeadMember } from '@shared/utils/leadDetection';
|
||||||
import { createLogger } from '@shared/utils/logger';
|
import { createLogger } from '@shared/utils/logger';
|
||||||
import { isTeamProviderBackendId, migrateProviderBackendId } from '@shared/utils/providerBackend';
|
import { isTeamProviderBackendId, migrateProviderBackendId } from '@shared/utils/providerBackend';
|
||||||
import { isRateLimitMessage } from '@shared/utils/rateLimitDetector';
|
|
||||||
import {
|
import {
|
||||||
buildStandaloneSlashCommandMeta,
|
buildStandaloneSlashCommandMeta,
|
||||||
parseStandaloneSlashCommand,
|
parseStandaloneSlashCommand,
|
||||||
|
|
@ -133,7 +131,6 @@ import {
|
||||||
import {
|
import {
|
||||||
getAutoResumeService,
|
getAutoResumeService,
|
||||||
initializeAutoResumeService,
|
initializeAutoResumeService,
|
||||||
planRateLimitAutoResume,
|
|
||||||
} from '../services/team/AutoResumeService';
|
} from '../services/team/AutoResumeService';
|
||||||
import {
|
import {
|
||||||
cloneLaunchIoGovernorPayload,
|
cloneLaunchIoGovernorPayload,
|
||||||
|
|
@ -156,6 +153,7 @@ import {
|
||||||
import { TeamTaskAttachmentStore } from '../services/team/TeamTaskAttachmentStore';
|
import { TeamTaskAttachmentStore } from '../services/team/TeamTaskAttachmentStore';
|
||||||
import { TeamWorktreeGitService } from '../services/team/TeamWorktreeGitService';
|
import { TeamWorktreeGitService } from '../services/team/TeamWorktreeGitService';
|
||||||
|
|
||||||
|
import { teamMessageNotificationScanner } from './teams/teamMessageNotificationScanner';
|
||||||
import {
|
import {
|
||||||
validateFromField,
|
validateFromField,
|
||||||
validateMemberName,
|
validateMemberName,
|
||||||
|
|
@ -301,14 +299,6 @@ function validateTeamGetDataOptions(
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* In-memory set of rate-limit message keys already processed.
|
|
||||||
* Independent of NotificationManager storage — survives notification deletion/pruning.
|
|
||||||
* Without this, deleted rate-limit notifications would re-appear on next getData() scan.
|
|
||||||
*/
|
|
||||||
const seenRateLimitKeys = new Set<string>();
|
|
||||||
const SEEN_RATE_LIMIT_KEYS_MAX = 500;
|
|
||||||
|
|
||||||
async function withTimeoutValue<T>(
|
async function withTimeoutValue<T>(
|
||||||
promise: Promise<T>,
|
promise: Promise<T>,
|
||||||
timeoutMs: number,
|
timeoutMs: number,
|
||||||
|
|
@ -442,178 +432,6 @@ function buildLeadDirectDelegateAckBlock(actionMode?: AgentActionMode): string |
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* In-memory set of API error message keys already processed.
|
|
||||||
* Independent of NotificationManager storage — survives notification deletion/pruning.
|
|
||||||
*/
|
|
||||||
const seenApiErrorKeys = new Set<string>();
|
|
||||||
const SEEN_API_ERROR_KEYS_MAX = 500;
|
|
||||||
|
|
||||||
function formatNotificationClockTime(date: Date): string {
|
|
||||||
return new Intl.DateTimeFormat(undefined, {
|
|
||||||
hour: '2-digit',
|
|
||||||
minute: '2-digit',
|
|
||||||
hour12: false,
|
|
||||||
}).format(date);
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildRateLimitNotificationBody(plan: ReturnType<typeof planRateLimitAutoResume>): string {
|
|
||||||
if (plan.kind === 'scheduled') {
|
|
||||||
return `Auto-resume scheduled at ${formatNotificationClockTime(new Date(plan.fireAtMs))}`;
|
|
||||||
}
|
|
||||||
return 'Manual restart needed';
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Check messages for rate limit indicators and fire notifications for new ones.
|
|
||||||
* Uses both in-memory seenRateLimitKeys (to prevent resurrection after deletion)
|
|
||||||
* and NotificationManager dedupeKey (to prevent storage duplicates).
|
|
||||||
*/
|
|
||||||
function checkRateLimitMessages(
|
|
||||||
messages: readonly {
|
|
||||||
messageId?: string;
|
|
||||||
from: string;
|
|
||||||
text: string;
|
|
||||||
timestamp: string;
|
|
||||||
to?: string;
|
|
||||||
source?: string;
|
|
||||||
leadSessionId?: string;
|
|
||||||
}[],
|
|
||||||
teamName: string,
|
|
||||||
teamDisplayName: string,
|
|
||||||
projectPath?: string,
|
|
||||||
teamIsAlive = true,
|
|
||||||
currentLeadSessionId: string | null = null
|
|
||||||
): void {
|
|
||||||
const observedAt = new Date();
|
|
||||||
const autoResumeEnabled =
|
|
||||||
ConfigManager.getInstance().getConfig().notifications.autoResumeOnRateLimit;
|
|
||||||
|
|
||||||
for (const msg of messages) {
|
|
||||||
if (msg.from === 'user') continue;
|
|
||||||
if (!isRateLimitMessage(msg.text)) continue;
|
|
||||||
|
|
||||||
const rawKey = msg.messageId ?? `${msg.from}:${msg.timestamp}`;
|
|
||||||
const dedupeKey = `rate-limit:${teamName}:${rawKey}`;
|
|
||||||
const isLeadAutoResumeCandidate =
|
|
||||||
!msg.to && (msg.source === 'lead_process' || msg.source === 'lead_session');
|
|
||||||
const autoResumeSessionMatches =
|
|
||||||
msg.source !== 'lead_session' ||
|
|
||||||
(Boolean(currentLeadSessionId) && msg.leadSessionId === currentLeadSessionId);
|
|
||||||
const autoResumePlan = planRateLimitAutoResume({
|
|
||||||
enabled: autoResumeEnabled,
|
|
||||||
canAutoResume: teamIsAlive && isLeadAutoResumeCandidate && autoResumeSessionMatches,
|
|
||||||
messageText: msg.text,
|
|
||||||
observedAt,
|
|
||||||
messageTimestamp: new Date(msg.timestamp),
|
|
||||||
});
|
|
||||||
|
|
||||||
// In-memory guard: prevents resurrection after user deletes the notification.
|
|
||||||
if (!seenRateLimitKeys.has(dedupeKey)) {
|
|
||||||
seenRateLimitKeys.add(dedupeKey);
|
|
||||||
|
|
||||||
// Evict oldest entries to prevent unbounded growth
|
|
||||||
if (seenRateLimitKeys.size > SEEN_RATE_LIMIT_KEYS_MAX) {
|
|
||||||
const first = seenRateLimitKeys.values().next().value;
|
|
||||||
if (first) seenRateLimitKeys.delete(first);
|
|
||||||
}
|
|
||||||
|
|
||||||
void NotificationManager.getInstance()
|
|
||||||
.addTeamNotification({
|
|
||||||
teamEventType: 'rate_limit',
|
|
||||||
teamName,
|
|
||||||
teamDisplayName,
|
|
||||||
from: msg.from,
|
|
||||||
summary: 'Rate limit',
|
|
||||||
body: buildRateLimitNotificationBody(autoResumePlan),
|
|
||||||
dedupeKey,
|
|
||||||
target: { kind: 'member', teamName, memberName: msg.from, focus: 'logs' },
|
|
||||||
projectPath,
|
|
||||||
})
|
|
||||||
.catch(() => undefined);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Only schedule auto-resume while a live team run currently exists.
|
|
||||||
// Persisted history for an offline/stopped team may still contain the old
|
|
||||||
// rate-limit message, but arming a new timer from that stale history would
|
|
||||||
// resurrect the nudge into a later manual restart.
|
|
||||||
if (autoResumePlan.kind === 'scheduled') {
|
|
||||||
// Only let persisted lead_session history rebuild auto-resume when it
|
|
||||||
// clearly belongs to the currently running lead session. Otherwise an old
|
|
||||||
// rate-limit from a previous manual run can resurrect into a newer restart.
|
|
||||||
// Pass the original message timestamp so relative reset windows survive restarts
|
|
||||||
// and old history does not rebuild a fresh auto-resume timer from "now".
|
|
||||||
getAutoResumeService().handleRateLimitMessage(
|
|
||||||
teamName,
|
|
||||||
msg.text,
|
|
||||||
observedAt,
|
|
||||||
new Date(msg.timestamp)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Check messages for API errors (e.g. "API Error: 429 ...") and fire OS notifications.
|
|
||||||
* Mirrors the rate-limit approach: in-memory dedup + NotificationManager dedupeKey.
|
|
||||||
* Skips rate-limit messages (they have their own notification path).
|
|
||||||
*/
|
|
||||||
function checkApiErrorMessages(
|
|
||||||
messages: readonly { messageId?: string; from: string; text: string; timestamp: string }[],
|
|
||||||
teamName: string,
|
|
||||||
teamDisplayName: string,
|
|
||||||
projectPath?: string
|
|
||||||
): void {
|
|
||||||
for (const msg of messages) {
|
|
||||||
if (msg.from === 'user') continue;
|
|
||||||
if (!isApiErrorMessage(msg.text)) continue;
|
|
||||||
// Don't double-notify if it's also a rate limit message
|
|
||||||
if (isRateLimitMessage(msg.text)) continue;
|
|
||||||
|
|
||||||
const rawKey = msg.messageId ?? `${msg.from}:${msg.timestamp}`;
|
|
||||||
const dedupeKey = `api-error:${teamName}:${rawKey}`;
|
|
||||||
|
|
||||||
if (seenApiErrorKeys.has(dedupeKey)) continue;
|
|
||||||
seenApiErrorKeys.add(dedupeKey);
|
|
||||||
|
|
||||||
if (seenApiErrorKeys.size > SEEN_API_ERROR_KEYS_MAX) {
|
|
||||||
const first = seenApiErrorKeys.values().next().value;
|
|
||||||
if (first) seenApiErrorKeys.delete(first);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Extract status code for summary
|
|
||||||
const statusMatch = /^API Error:\s*(\d{3})/.exec(msg.text);
|
|
||||||
const statusCode = statusMatch?.[1] ?? '???';
|
|
||||||
|
|
||||||
void NotificationManager.getInstance()
|
|
||||||
.addTeamNotification({
|
|
||||||
teamEventType: 'api_error',
|
|
||||||
teamName,
|
|
||||||
teamDisplayName,
|
|
||||||
from: msg.from,
|
|
||||||
summary: `API Error ${statusCode}`,
|
|
||||||
body: 'Manual restart needed',
|
|
||||||
dedupeKey,
|
|
||||||
target: { kind: 'member', teamName, memberName: msg.from, focus: 'logs' },
|
|
||||||
projectPath,
|
|
||||||
})
|
|
||||||
.catch(() => undefined);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function scanTeamMessageNotifications(
|
|
||||||
messages: readonly { messageId?: string; from: string; text: string; timestamp: string }[],
|
|
||||||
teamName: string,
|
|
||||||
teamDisplayName: string,
|
|
||||||
projectPath?: string
|
|
||||||
): void {
|
|
||||||
if (messages.length === 0) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
checkRateLimitMessages(messages, teamName, teamDisplayName, projectPath);
|
|
||||||
checkApiErrorMessages(messages, teamName, teamDisplayName, projectPath);
|
|
||||||
}
|
|
||||||
|
|
||||||
let teamDataService: TeamDataService | null = null;
|
let teamDataService: TeamDataService | null = null;
|
||||||
let teamProvisioningService: TeamProvisioningService | null = null;
|
let teamProvisioningService: TeamProvisioningService | null = null;
|
||||||
let teamMemberLogsFinder: TeamMemberLogsFinder | null = null;
|
let teamMemberLogsFinder: TeamMemberLogsFinder | null = null;
|
||||||
|
|
@ -1145,17 +963,24 @@ async function handleGetData(
|
||||||
|
|
||||||
if (live.length === 0) {
|
if (live.length === 0) {
|
||||||
if (durableMessages.length > 0) {
|
if (durableMessages.length > 0) {
|
||||||
checkRateLimitMessages(
|
teamMessageNotificationScanner.checkRateLimitMessages(durableMessages, {
|
||||||
durableMessages,
|
teamName: tn,
|
||||||
tn,
|
teamDisplayName: displayName,
|
||||||
displayName,
|
|
||||||
projectPath,
|
projectPath,
|
||||||
isAlive,
|
teamIsAlive: isAlive,
|
||||||
currentLeadSessionId
|
currentLeadSessionId,
|
||||||
);
|
});
|
||||||
checkApiErrorMessages(durableMessages, tn, displayName, projectPath);
|
teamMessageNotificationScanner.checkApiErrorMessages(durableMessages, {
|
||||||
|
teamName: tn,
|
||||||
|
teamDisplayName: displayName,
|
||||||
|
projectPath,
|
||||||
|
});
|
||||||
} else {
|
} else {
|
||||||
scanTeamMessageNotifications(live, tn, displayName, projectPath);
|
teamMessageNotificationScanner.scan(live, {
|
||||||
|
teamName: tn,
|
||||||
|
teamDisplayName: displayName,
|
||||||
|
projectPath,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
return { success: true, data: { ...data, isAlive } };
|
return { success: true, data: { ...data, isAlive } };
|
||||||
}
|
}
|
||||||
|
|
@ -1177,8 +1002,18 @@ async function handleGetData(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
checkRateLimitMessages(merged, tn, displayName, projectPath, isAlive, currentLeadSessionId);
|
teamMessageNotificationScanner.checkRateLimitMessages(merged, {
|
||||||
checkApiErrorMessages(merged, tn, displayName, projectPath);
|
teamName: tn,
|
||||||
|
teamDisplayName: displayName,
|
||||||
|
projectPath,
|
||||||
|
teamIsAlive: isAlive,
|
||||||
|
currentLeadSessionId,
|
||||||
|
});
|
||||||
|
teamMessageNotificationScanner.checkApiErrorMessages(merged, {
|
||||||
|
teamName: tn,
|
||||||
|
teamDisplayName: displayName,
|
||||||
|
projectPath,
|
||||||
|
});
|
||||||
return { success: true, data: { ...data, isAlive } };
|
return { success: true, data: { ...data, isAlive } };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -2844,12 +2679,11 @@ async function handleGetMessagesPage(
|
||||||
.catch(() => ({ displayName: teamName }));
|
.catch(() => ({ displayName: teamName }));
|
||||||
void notificationContextPromise
|
void notificationContextPromise
|
||||||
.then((notificationContext) => {
|
.then((notificationContext) => {
|
||||||
scanTeamMessageNotifications(
|
teamMessageNotificationScanner.scan(messagesPage.messages, {
|
||||||
messagesPage.messages,
|
|
||||||
teamName,
|
teamName,
|
||||||
notificationContext.displayName,
|
teamDisplayName: notificationContext.displayName,
|
||||||
notificationContext.projectPath
|
projectPath: notificationContext.projectPath,
|
||||||
);
|
});
|
||||||
})
|
})
|
||||||
.catch((error: unknown) => {
|
.catch((error: unknown) => {
|
||||||
logger.debug(
|
logger.debug(
|
||||||
|
|
|
||||||
240
src/main/ipc/teams/teamMessageNotificationScanner.ts
Normal file
240
src/main/ipc/teams/teamMessageNotificationScanner.ts
Normal file
|
|
@ -0,0 +1,240 @@
|
||||||
|
import { ConfigManager } from '@main/services/infrastructure/ConfigManager';
|
||||||
|
import { NotificationManager } from '@main/services/infrastructure/NotificationManager';
|
||||||
|
import {
|
||||||
|
getAutoResumeService,
|
||||||
|
planRateLimitAutoResume,
|
||||||
|
type RateLimitAutoResumePlan,
|
||||||
|
} from '@main/services/team/AutoResumeService';
|
||||||
|
import { isApiErrorMessage } from '@shared/utils/apiErrorDetector';
|
||||||
|
import { isRateLimitMessage } from '@shared/utils/rateLimitDetector';
|
||||||
|
|
||||||
|
import type { TeamNotificationPayload } from '@main/utils/teamNotificationBuilder';
|
||||||
|
|
||||||
|
export interface TeamNotificationMessage {
|
||||||
|
messageId?: string;
|
||||||
|
from: string;
|
||||||
|
text: string;
|
||||||
|
timestamp: string;
|
||||||
|
to?: string;
|
||||||
|
source?: string;
|
||||||
|
leadSessionId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TeamNotificationSink {
|
||||||
|
addTeamNotification(payload: TeamNotificationPayload): Promise<unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AutoResumeSink {
|
||||||
|
handleRateLimitMessage(
|
||||||
|
teamName: string,
|
||||||
|
messageText: string,
|
||||||
|
observedAt: Date,
|
||||||
|
messageTimestamp: Date
|
||||||
|
): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ConfigReader {
|
||||||
|
getConfig(): {
|
||||||
|
notifications: {
|
||||||
|
autoResumeOnRateLimit: boolean;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TeamMessageNotificationScannerDeps {
|
||||||
|
configReader?: ConfigReader;
|
||||||
|
notificationSink?: TeamNotificationSink;
|
||||||
|
autoResumeSink?: AutoResumeSink;
|
||||||
|
planAutoResume?: typeof planRateLimitAutoResume;
|
||||||
|
isRateLimit?: (text: string) => boolean;
|
||||||
|
isApiError?: (text: string) => boolean;
|
||||||
|
now?: () => Date;
|
||||||
|
formatClockTime?: (date: Date) => string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TeamMessageNotificationContext {
|
||||||
|
teamName: string;
|
||||||
|
teamDisplayName: string;
|
||||||
|
projectPath?: string;
|
||||||
|
teamIsAlive?: boolean;
|
||||||
|
currentLeadSessionId?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const SEEN_RATE_LIMIT_KEYS_MAX = 500;
|
||||||
|
const SEEN_API_ERROR_KEYS_MAX = 500;
|
||||||
|
|
||||||
|
function formatNotificationClockTime(date: Date): string {
|
||||||
|
return new Intl.DateTimeFormat(undefined, {
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
hour12: false,
|
||||||
|
}).format(date);
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildRateLimitNotificationBody(
|
||||||
|
plan: RateLimitAutoResumePlan,
|
||||||
|
formatClockTime: (date: Date) => string
|
||||||
|
): string {
|
||||||
|
if (plan.kind === 'scheduled') {
|
||||||
|
return `Auto-resume scheduled at ${formatClockTime(new Date(plan.fireAtMs))}`;
|
||||||
|
}
|
||||||
|
return 'Manual restart needed';
|
||||||
|
}
|
||||||
|
|
||||||
|
function evictOldestIfNeeded(keys: Set<string>, maxSize: number): void {
|
||||||
|
if (keys.size <= maxSize) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const first = keys.values().next().value;
|
||||||
|
if (first) {
|
||||||
|
keys.delete(first);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function createDefaultNotificationSink(): TeamNotificationSink {
|
||||||
|
return {
|
||||||
|
addTeamNotification: (payload) => NotificationManager.getInstance().addTeamNotification(payload),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export class TeamMessageNotificationScanner {
|
||||||
|
readonly #seenRateLimitKeys = new Set<string>();
|
||||||
|
readonly #seenApiErrorKeys = new Set<string>();
|
||||||
|
readonly #configReader: ConfigReader;
|
||||||
|
readonly #notificationSink: TeamNotificationSink;
|
||||||
|
readonly #planAutoResume: typeof planRateLimitAutoResume;
|
||||||
|
readonly #isRateLimit: (text: string) => boolean;
|
||||||
|
readonly #isApiError: (text: string) => boolean;
|
||||||
|
readonly #now: () => Date;
|
||||||
|
readonly #formatClockTime: (date: Date) => string;
|
||||||
|
readonly #autoResumeSink: AutoResumeSink | null;
|
||||||
|
|
||||||
|
constructor(deps: TeamMessageNotificationScannerDeps = {}) {
|
||||||
|
this.#configReader = deps.configReader ?? ConfigManager.getInstance();
|
||||||
|
this.#notificationSink = deps.notificationSink ?? createDefaultNotificationSink();
|
||||||
|
this.#planAutoResume = deps.planAutoResume ?? planRateLimitAutoResume;
|
||||||
|
this.#isRateLimit = deps.isRateLimit ?? isRateLimitMessage;
|
||||||
|
this.#isApiError = deps.isApiError ?? isApiErrorMessage;
|
||||||
|
this.#now = deps.now ?? (() => new Date());
|
||||||
|
this.#formatClockTime = deps.formatClockTime ?? formatNotificationClockTime;
|
||||||
|
this.#autoResumeSink = deps.autoResumeSink ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
checkRateLimitMessages(
|
||||||
|
messages: readonly TeamNotificationMessage[],
|
||||||
|
context: TeamMessageNotificationContext
|
||||||
|
): void {
|
||||||
|
const observedAt = this.#now();
|
||||||
|
const autoResumeEnabled = this.#configReader.getConfig().notifications.autoResumeOnRateLimit;
|
||||||
|
|
||||||
|
for (const msg of messages) {
|
||||||
|
if (msg.from === 'user') continue;
|
||||||
|
if (!this.#isRateLimit(msg.text)) continue;
|
||||||
|
|
||||||
|
const rawKey = msg.messageId ?? `${msg.from}:${msg.timestamp}`;
|
||||||
|
const dedupeKey = `rate-limit:${context.teamName}:${rawKey}`;
|
||||||
|
const isLeadAutoResumeCandidate =
|
||||||
|
!msg.to && (msg.source === 'lead_process' || msg.source === 'lead_session');
|
||||||
|
const currentLeadSessionId = context.currentLeadSessionId ?? null;
|
||||||
|
const autoResumeSessionMatches =
|
||||||
|
msg.source !== 'lead_session' ||
|
||||||
|
(Boolean(currentLeadSessionId) && msg.leadSessionId === currentLeadSessionId);
|
||||||
|
const autoResumePlan = this.#planAutoResume({
|
||||||
|
enabled: autoResumeEnabled,
|
||||||
|
canAutoResume:
|
||||||
|
(context.teamIsAlive ?? true) &&
|
||||||
|
isLeadAutoResumeCandidate &&
|
||||||
|
autoResumeSessionMatches,
|
||||||
|
messageText: msg.text,
|
||||||
|
observedAt,
|
||||||
|
messageTimestamp: new Date(msg.timestamp),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!this.#seenRateLimitKeys.has(dedupeKey)) {
|
||||||
|
this.#seenRateLimitKeys.add(dedupeKey);
|
||||||
|
evictOldestIfNeeded(this.#seenRateLimitKeys, SEEN_RATE_LIMIT_KEYS_MAX);
|
||||||
|
|
||||||
|
void this.#notificationSink
|
||||||
|
.addTeamNotification({
|
||||||
|
teamEventType: 'rate_limit',
|
||||||
|
teamName: context.teamName,
|
||||||
|
teamDisplayName: context.teamDisplayName,
|
||||||
|
from: msg.from,
|
||||||
|
summary: 'Rate limit',
|
||||||
|
body: buildRateLimitNotificationBody(autoResumePlan, this.#formatClockTime),
|
||||||
|
dedupeKey,
|
||||||
|
target: {
|
||||||
|
kind: 'member',
|
||||||
|
teamName: context.teamName,
|
||||||
|
memberName: msg.from,
|
||||||
|
focus: 'logs',
|
||||||
|
},
|
||||||
|
projectPath: context.projectPath,
|
||||||
|
})
|
||||||
|
.catch(() => undefined);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (autoResumePlan.kind === 'scheduled') {
|
||||||
|
const autoResumeSink = this.#autoResumeSink ?? getAutoResumeService();
|
||||||
|
autoResumeSink.handleRateLimitMessage(
|
||||||
|
context.teamName,
|
||||||
|
msg.text,
|
||||||
|
observedAt,
|
||||||
|
new Date(msg.timestamp)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
checkApiErrorMessages(
|
||||||
|
messages: readonly TeamNotificationMessage[],
|
||||||
|
context: TeamMessageNotificationContext
|
||||||
|
): void {
|
||||||
|
for (const msg of messages) {
|
||||||
|
if (msg.from === 'user') continue;
|
||||||
|
if (!this.#isApiError(msg.text)) continue;
|
||||||
|
if (this.#isRateLimit(msg.text)) continue;
|
||||||
|
|
||||||
|
const rawKey = msg.messageId ?? `${msg.from}:${msg.timestamp}`;
|
||||||
|
const dedupeKey = `api-error:${context.teamName}:${rawKey}`;
|
||||||
|
|
||||||
|
if (this.#seenApiErrorKeys.has(dedupeKey)) continue;
|
||||||
|
this.#seenApiErrorKeys.add(dedupeKey);
|
||||||
|
evictOldestIfNeeded(this.#seenApiErrorKeys, SEEN_API_ERROR_KEYS_MAX);
|
||||||
|
|
||||||
|
const statusMatch = /^API Error:\s*(\d{3})/.exec(msg.text);
|
||||||
|
const statusCode = statusMatch?.[1] ?? '???';
|
||||||
|
|
||||||
|
void this.#notificationSink
|
||||||
|
.addTeamNotification({
|
||||||
|
teamEventType: 'api_error',
|
||||||
|
teamName: context.teamName,
|
||||||
|
teamDisplayName: context.teamDisplayName,
|
||||||
|
from: msg.from,
|
||||||
|
summary: `API Error ${statusCode}`,
|
||||||
|
body: 'Manual restart needed',
|
||||||
|
dedupeKey,
|
||||||
|
target: {
|
||||||
|
kind: 'member',
|
||||||
|
teamName: context.teamName,
|
||||||
|
memberName: msg.from,
|
||||||
|
focus: 'logs',
|
||||||
|
},
|
||||||
|
projectPath: context.projectPath,
|
||||||
|
})
|
||||||
|
.catch(() => undefined);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
scan(messages: readonly TeamNotificationMessage[], context: TeamMessageNotificationContext): void {
|
||||||
|
if (messages.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.checkRateLimitMessages(messages, context);
|
||||||
|
this.checkApiErrorMessages(messages, context);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const teamMessageNotificationScanner = new TeamMessageNotificationScanner();
|
||||||
File diff suppressed because it is too large
Load diff
108
src/renderer/store/team/teamAgentRuntimeSnapshotEquality.ts
Normal file
108
src/renderer/store/team/teamAgentRuntimeSnapshotEquality.ts
Normal file
|
|
@ -0,0 +1,108 @@
|
||||||
|
import type {
|
||||||
|
TeamAgentRuntimeEntry,
|
||||||
|
TeamAgentRuntimeResourceSample,
|
||||||
|
TeamAgentRuntimeSnapshot,
|
||||||
|
} from '@shared/types';
|
||||||
|
|
||||||
|
function isTeamAgentRuntimeResourceSampleLike(
|
||||||
|
value: unknown
|
||||||
|
): value is TeamAgentRuntimeResourceSample {
|
||||||
|
return Boolean(value) && typeof value === 'object';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function areTeamAgentRuntimeResourceSamplesEqual(left: unknown, right: unknown): boolean {
|
||||||
|
if (left === right) return true;
|
||||||
|
if (!isTeamAgentRuntimeResourceSampleLike(left) || !isTeamAgentRuntimeResourceSampleLike(right)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
left.timestamp === right.timestamp &&
|
||||||
|
left.cpuPercent === right.cpuPercent &&
|
||||||
|
left.rssBytes === right.rssBytes &&
|
||||||
|
left.primaryCpuPercent === right.primaryCpuPercent &&
|
||||||
|
left.primaryRssBytes === right.primaryRssBytes &&
|
||||||
|
left.childCpuPercent === right.childCpuPercent &&
|
||||||
|
left.childRssBytes === right.childRssBytes &&
|
||||||
|
left.processCount === right.processCount &&
|
||||||
|
left.runtimeLoadScope === right.runtimeLoadScope &&
|
||||||
|
left.runtimeLoadTruncated === right.runtimeLoadTruncated &&
|
||||||
|
left.pidSource === right.pidSource &&
|
||||||
|
left.pid === right.pid &&
|
||||||
|
left.runtimePid === right.runtimePid
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function areTeamAgentRuntimeEntriesEqual(
|
||||||
|
left: TeamAgentRuntimeEntry | undefined,
|
||||||
|
right: TeamAgentRuntimeEntry | undefined
|
||||||
|
): boolean {
|
||||||
|
if (left === right) return true;
|
||||||
|
if (!left || !right) return left === right;
|
||||||
|
const leftDiagnostics = Array.isArray(left.diagnostics) ? left.diagnostics : [];
|
||||||
|
const rightDiagnostics = Array.isArray(right.diagnostics) ? right.diagnostics : [];
|
||||||
|
const leftResourceHistory = Array.isArray(left.resourceHistory) ? left.resourceHistory : [];
|
||||||
|
const rightResourceHistory = Array.isArray(right.resourceHistory) ? right.resourceHistory : [];
|
||||||
|
return (
|
||||||
|
left.memberName === right.memberName &&
|
||||||
|
left.alive === right.alive &&
|
||||||
|
left.restartable === right.restartable &&
|
||||||
|
left.backendType === right.backendType &&
|
||||||
|
left.providerId === right.providerId &&
|
||||||
|
left.providerBackendId === right.providerBackendId &&
|
||||||
|
left.laneId === right.laneId &&
|
||||||
|
left.laneKind === right.laneKind &&
|
||||||
|
left.pid === right.pid &&
|
||||||
|
left.runtimeModel === right.runtimeModel &&
|
||||||
|
left.rssBytes === right.rssBytes &&
|
||||||
|
left.cpuPercent === right.cpuPercent &&
|
||||||
|
left.primaryCpuPercent === right.primaryCpuPercent &&
|
||||||
|
left.primaryRssBytes === right.primaryRssBytes &&
|
||||||
|
left.childCpuPercent === right.childCpuPercent &&
|
||||||
|
left.childRssBytes === right.childRssBytes &&
|
||||||
|
left.processCount === right.processCount &&
|
||||||
|
left.runtimeLoadScope === right.runtimeLoadScope &&
|
||||||
|
left.runtimeLoadTruncated === right.runtimeLoadTruncated &&
|
||||||
|
left.livenessKind === right.livenessKind &&
|
||||||
|
left.pidSource === right.pidSource &&
|
||||||
|
left.processCommand === right.processCommand &&
|
||||||
|
left.paneId === right.paneId &&
|
||||||
|
left.panePid === right.panePid &&
|
||||||
|
left.paneCurrentCommand === right.paneCurrentCommand &&
|
||||||
|
left.runtimePid === right.runtimePid &&
|
||||||
|
left.runtimeSessionId === right.runtimeSessionId &&
|
||||||
|
left.runtimeDiagnostic === right.runtimeDiagnostic &&
|
||||||
|
left.runtimeDiagnosticSeverity === right.runtimeDiagnosticSeverity &&
|
||||||
|
left.runtimeLastSeenAt === right.runtimeLastSeenAt &&
|
||||||
|
left.historicalBootstrapConfirmed === right.historicalBootstrapConfirmed &&
|
||||||
|
leftDiagnostics.length === rightDiagnostics.length &&
|
||||||
|
leftDiagnostics.every((value, index) => value === rightDiagnostics[index]) &&
|
||||||
|
leftResourceHistory.length === rightResourceHistory.length &&
|
||||||
|
leftResourceHistory.every((value, index) =>
|
||||||
|
areTeamAgentRuntimeResourceSamplesEqual(value, rightResourceHistory[index])
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function areTeamAgentRuntimeSnapshotsEqual(
|
||||||
|
left: TeamAgentRuntimeSnapshot | undefined,
|
||||||
|
right: TeamAgentRuntimeSnapshot
|
||||||
|
): boolean {
|
||||||
|
if (!left) return false;
|
||||||
|
if (left.teamName !== right.teamName || left.runId !== right.runId) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const leftKeys = Object.keys(left.members);
|
||||||
|
const rightKeys = Object.keys(right.members);
|
||||||
|
if (leftKeys.length !== rightKeys.length) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
for (const key of leftKeys) {
|
||||||
|
if (!(key in right.members)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (!areTeamAgentRuntimeEntriesEqual(left.members[key], right.members[key])) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
21
src/renderer/store/team/teamDataRefreshTimestamps.ts
Normal file
21
src/renderer/store/team/teamDataRefreshTimestamps.ts
Normal file
|
|
@ -0,0 +1,21 @@
|
||||||
|
const lastResolvedTeamDataRefreshAtByTeam = new Map<string, number>();
|
||||||
|
|
||||||
|
export function getLastResolvedTeamDataRefreshAt(teamName: string): number | undefined {
|
||||||
|
return lastResolvedTeamDataRefreshAtByTeam.get(teamName);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function recordLastResolvedTeamDataRefresh(teamName: string, resolvedAt = Date.now()): void {
|
||||||
|
lastResolvedTeamDataRefreshAtByTeam.set(teamName, resolvedAt);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function hasLastResolvedTeamDataRefreshAt(teamName: string): boolean {
|
||||||
|
return lastResolvedTeamDataRefreshAtByTeam.has(teamName);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function clearLastResolvedTeamDataRefreshAt(teamName: string): void {
|
||||||
|
lastResolvedTeamDataRefreshAtByTeam.delete(teamName);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function clearAllLastResolvedTeamDataRefreshes(): void {
|
||||||
|
lastResolvedTeamDataRefreshAtByTeam.clear();
|
||||||
|
}
|
||||||
39
src/renderer/store/team/teamDataRequestKeys.ts
Normal file
39
src/renderer/store/team/teamDataRequestKeys.ts
Normal file
|
|
@ -0,0 +1,39 @@
|
||||||
|
import type { TeamGetDataOptions } from '@shared/types';
|
||||||
|
|
||||||
|
export type TeamDataSnapshotMode = 'full' | 'thin';
|
||||||
|
|
||||||
|
export function normalizeTeamGetDataOptions(
|
||||||
|
options?: TeamGetDataOptions
|
||||||
|
): TeamGetDataOptions | undefined {
|
||||||
|
return options?.includeMemberBranches === false ? { includeMemberBranches: false } : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function shouldIncludeMemberBranches(options?: TeamGetDataOptions): boolean {
|
||||||
|
return normalizeTeamGetDataOptions(options)?.includeMemberBranches !== false;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getTeamDataSnapshotMode(options?: TeamGetDataOptions): TeamDataSnapshotMode {
|
||||||
|
return shouldIncludeMemberBranches(options) ? 'full' : 'thin';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getTeamDataRequestKey(teamName: string, options?: TeamGetDataOptions): string {
|
||||||
|
const normalizedOptions = normalizeTeamGetDataOptions(options);
|
||||||
|
return `${teamName}\u0000mode:${getTeamDataSnapshotMode(normalizedOptions)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getTeamDataRequestLabel(teamName: string, options?: TeamGetDataOptions): string {
|
||||||
|
const normalizedOptions = normalizeTeamGetDataOptions(options);
|
||||||
|
return `team:getData(${teamName},mode=${getTeamDataSnapshotMode(normalizedOptions)})`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getFullTeamDataRequestKey(teamName: string): string {
|
||||||
|
return getTeamDataRequestKey(teamName);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getThinTeamDataRequestKey(teamName: string): string {
|
||||||
|
return getTeamDataRequestKey(teamName, { includeMemberBranches: false });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isTeamDataRequestKeyForTeam(requestKey: string, teamName: string): boolean {
|
||||||
|
return requestKey.startsWith(`${teamName}\u0000`);
|
||||||
|
}
|
||||||
47
src/renderer/store/team/teamDataSelectors.ts
Normal file
47
src/renderer/store/team/teamDataSelectors.ts
Normal file
|
|
@ -0,0 +1,47 @@
|
||||||
|
import type { TeamViewSnapshot } from '@shared/types';
|
||||||
|
|
||||||
|
export interface TeamDataSelectorState {
|
||||||
|
teamDataCacheByName: Record<string, TeamViewSnapshot>;
|
||||||
|
selectedTeamName: string | null;
|
||||||
|
selectedTeamData: TeamViewSnapshot | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const EMPTY_TEAM_MEMBER_SNAPSHOTS: TeamViewSnapshot['members'] = [];
|
||||||
|
const EMPTY_TEAM_TASKS: TeamViewSnapshot['tasks'] = [];
|
||||||
|
|
||||||
|
export function selectTeamDataForName(
|
||||||
|
state: TeamDataSelectorState,
|
||||||
|
teamName: string | null | undefined
|
||||||
|
): TeamViewSnapshot | null {
|
||||||
|
if (!teamName) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (state.selectedTeamName === teamName && state.selectedTeamData) {
|
||||||
|
return state.selectedTeamData;
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
state.teamDataCacheByName[teamName] ??
|
||||||
|
(state.selectedTeamName === teamName ? state.selectedTeamData : null)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function selectTeamMemberSnapshotsForName(
|
||||||
|
state: TeamDataSelectorState,
|
||||||
|
teamName: string | null | undefined
|
||||||
|
): TeamViewSnapshot['members'] {
|
||||||
|
return selectTeamDataForName(state, teamName)?.members ?? EMPTY_TEAM_MEMBER_SNAPSHOTS;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function selectTeamTasksForName(
|
||||||
|
state: TeamDataSelectorState,
|
||||||
|
teamName: string | null | undefined
|
||||||
|
): TeamViewSnapshot['tasks'] {
|
||||||
|
return selectTeamDataForName(state, teamName)?.tasks ?? EMPTY_TEAM_TASKS;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function selectTeamIsAliveForName(
|
||||||
|
state: TeamDataSelectorState,
|
||||||
|
teamName: string | null | undefined
|
||||||
|
): boolean | undefined {
|
||||||
|
return selectTeamDataForName(state, teamName)?.isAlive;
|
||||||
|
}
|
||||||
33
src/renderer/store/team/teamErrorPolicies.ts
Normal file
33
src/renderer/store/team/teamErrorPolicies.ts
Normal file
|
|
@ -0,0 +1,33 @@
|
||||||
|
import { IpcError } from '@renderer/utils/unwrapIpc';
|
||||||
|
|
||||||
|
function getErrorMessage(error: unknown): string {
|
||||||
|
return error instanceof IpcError ? error.message : error instanceof Error ? error.message : '';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function mapSendMessageError(error: unknown): string {
|
||||||
|
const message = getErrorMessage(error);
|
||||||
|
if (message.includes('Failed to verify inbox write')) {
|
||||||
|
return 'Message was written but not verified (race). Please try again.';
|
||||||
|
}
|
||||||
|
return message || 'Failed to send message';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function mapReviewError(error: unknown): string {
|
||||||
|
const message = getErrorMessage(error);
|
||||||
|
if (message.includes('Task status update verification failed')) {
|
||||||
|
return 'Failed to update task status (possible agent conflict).';
|
||||||
|
}
|
||||||
|
return message || 'Failed to perform review action';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function shouldInvalidateCachedTeamDataForError(
|
||||||
|
teamName: string,
|
||||||
|
message: string
|
||||||
|
): boolean {
|
||||||
|
return (
|
||||||
|
message === 'TEAM_DRAFT' ||
|
||||||
|
message.includes('TEAM_DRAFT') ||
|
||||||
|
message === `Team not found: ${teamName}` ||
|
||||||
|
message === 'Team config not found'
|
||||||
|
);
|
||||||
|
}
|
||||||
219
src/renderer/store/team/teamGraphLayout.ts
Normal file
219
src/renderer/store/team/teamGraphLayout.ts
Normal file
|
|
@ -0,0 +1,219 @@
|
||||||
|
import { buildTeamGraphDefaultLayoutSeed } from '@shared/utils/teamGraphDefaultLayout';
|
||||||
|
import { getStableTeamOwnerId } from '@shared/utils/teamStableOwnerId';
|
||||||
|
|
||||||
|
import type { GraphOwnerSlotAssignment } from '@claude-teams/agent-graph';
|
||||||
|
import type { TeamMemberSnapshot, TeamViewSnapshot } from '@shared/types';
|
||||||
|
|
||||||
|
export const GRAPH_STABLE_SLOT_LAYOUT_VERSION = 'stable-slots-v1' as const;
|
||||||
|
export const DISABLE_PERSISTED_TEAM_GRAPH_SLOT_ASSIGNMENTS = true;
|
||||||
|
|
||||||
|
export type TeamGraphSlotAssignments = Record<string, GraphOwnerSlotAssignment>;
|
||||||
|
export type TeamGraphMemberSeedInput = Pick<TeamMemberSnapshot, 'name' | 'agentId' | 'removedAt'>;
|
||||||
|
export type TeamGraphConfigMemberSeedInput = Pick<
|
||||||
|
NonNullable<TeamViewSnapshot['config']['members']>[number],
|
||||||
|
'name' | 'agentId' | 'removedAt'
|
||||||
|
>;
|
||||||
|
|
||||||
|
export interface TeamGraphLayoutSessionState {
|
||||||
|
mode: 'default' | 'manual';
|
||||||
|
signature: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function migrateStableSlotAssignmentsForMembers(
|
||||||
|
assignments: TeamGraphSlotAssignments | undefined,
|
||||||
|
members: readonly TeamGraphMemberSeedInput[]
|
||||||
|
): { assignments: TeamGraphSlotAssignments; changed: boolean } {
|
||||||
|
const nextAssignments: TeamGraphSlotAssignments = { ...(assignments ?? {}) };
|
||||||
|
let changed = false;
|
||||||
|
|
||||||
|
for (const member of members) {
|
||||||
|
const fallbackKey = member.name.trim();
|
||||||
|
const stableOwnerId = getStableTeamOwnerId(member);
|
||||||
|
const fallbackAssignment = nextAssignments[fallbackKey];
|
||||||
|
const stableAssignment = nextAssignments[stableOwnerId];
|
||||||
|
|
||||||
|
if (stableOwnerId !== fallbackKey && fallbackAssignment && !stableAssignment) {
|
||||||
|
nextAssignments[stableOwnerId] = fallbackAssignment;
|
||||||
|
delete nextAssignments[fallbackKey];
|
||||||
|
changed = true;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (stableOwnerId !== fallbackKey && fallbackAssignment && stableAssignment) {
|
||||||
|
delete nextAssignments[fallbackKey];
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { assignments: nextAssignments, changed };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function seedStableSlotAssignmentsForMembers(
|
||||||
|
assignments: TeamGraphSlotAssignments,
|
||||||
|
members: readonly TeamGraphMemberSeedInput[],
|
||||||
|
configMembers: readonly TeamGraphConfigMemberSeedInput[] = []
|
||||||
|
): { assignments: TeamGraphSlotAssignments; changed: boolean } {
|
||||||
|
const defaultSeed = buildTeamGraphDefaultLayoutSeed(members, configMembers);
|
||||||
|
if (
|
||||||
|
defaultSeed.orderedVisibleOwnerIds.length === 0 ||
|
||||||
|
Object.keys(defaultSeed.assignments).length === 0
|
||||||
|
) {
|
||||||
|
return { assignments, changed: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
const visibleStableOwnerIds = defaultSeed.orderedVisibleOwnerIds;
|
||||||
|
const hasAnyVisibleAssignments = visibleStableOwnerIds.some(
|
||||||
|
(stableOwnerId) => assignments[stableOwnerId] != null
|
||||||
|
);
|
||||||
|
if (hasAnyVisibleAssignments) {
|
||||||
|
return { assignments, changed: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
const nextAssignments: TeamGraphSlotAssignments = { ...assignments };
|
||||||
|
visibleStableOwnerIds.forEach((stableOwnerId) => {
|
||||||
|
nextAssignments[stableOwnerId] = defaultSeed.assignments[stableOwnerId]!;
|
||||||
|
});
|
||||||
|
|
||||||
|
return { assignments: nextAssignments, changed: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function areTeamGraphSlotAssignmentsEqual(
|
||||||
|
left: TeamGraphSlotAssignments | undefined,
|
||||||
|
right: TeamGraphSlotAssignments | undefined
|
||||||
|
): boolean {
|
||||||
|
const leftEntries = Object.entries(left ?? {});
|
||||||
|
const rightEntries = Object.entries(right ?? {});
|
||||||
|
if (leftEntries.length !== rightEntries.length) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const [stableOwnerId, leftAssignment] of leftEntries) {
|
||||||
|
const rightAssignment = right?.[stableOwnerId];
|
||||||
|
if (
|
||||||
|
rightAssignment?.ringIndex !== leftAssignment.ringIndex ||
|
||||||
|
rightAssignment.sectorIndex !== leftAssignment.sectorIndex
|
||||||
|
) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeTeamGraphSlotAssignmentsForVisibleOwners(
|
||||||
|
assignments: TeamGraphSlotAssignments | undefined,
|
||||||
|
visibleOwnerIds: readonly string[]
|
||||||
|
): TeamGraphSlotAssignments {
|
||||||
|
if (visibleOwnerIds.length === 0 || !assignments) {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalizedAssignments: TeamGraphSlotAssignments = {};
|
||||||
|
for (const stableOwnerId of visibleOwnerIds) {
|
||||||
|
const assignment = assignments[stableOwnerId];
|
||||||
|
if (!assignment) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
normalizedAssignments[stableOwnerId] = assignment;
|
||||||
|
}
|
||||||
|
return normalizeLegacySixRowOrbitAssignments(normalizedAssignments, visibleOwnerIds);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeLegacySixRowOrbitAssignments(
|
||||||
|
assignments: TeamGraphSlotAssignments,
|
||||||
|
visibleOwnerIds: readonly string[]
|
||||||
|
): TeamGraphSlotAssignments {
|
||||||
|
if (visibleOwnerIds.length !== 6) {
|
||||||
|
return assignments;
|
||||||
|
}
|
||||||
|
|
||||||
|
const visibleAssignments = visibleOwnerIds.flatMap((stableOwnerId) => {
|
||||||
|
const assignment = assignments[stableOwnerId];
|
||||||
|
return assignment ? [assignment] : [];
|
||||||
|
});
|
||||||
|
const hasLegacyTwoRowBottomMarker = visibleAssignments.some(
|
||||||
|
(assignment) => assignment.ringIndex === 1 && assignment.sectorIndex === 2
|
||||||
|
);
|
||||||
|
let changed = false;
|
||||||
|
const normalizedAssignments: TeamGraphSlotAssignments = { ...assignments };
|
||||||
|
|
||||||
|
for (const stableOwnerId of visibleOwnerIds) {
|
||||||
|
const assignment = normalizedAssignments[stableOwnerId];
|
||||||
|
if (!assignment) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
hasLegacyTwoRowBottomMarker &&
|
||||||
|
assignment.ringIndex === 1 &&
|
||||||
|
assignment.sectorIndex >= 0 &&
|
||||||
|
assignment.sectorIndex < 3
|
||||||
|
) {
|
||||||
|
normalizedAssignments[stableOwnerId] = {
|
||||||
|
ringIndex: 2,
|
||||||
|
sectorIndex: assignment.sectorIndex,
|
||||||
|
};
|
||||||
|
changed = true;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (assignment.ringIndex === 0 && assignment.sectorIndex >= 3 && assignment.sectorIndex < 6) {
|
||||||
|
normalizedAssignments[stableOwnerId] = {
|
||||||
|
ringIndex: 2,
|
||||||
|
sectorIndex: assignment.sectorIndex - 3,
|
||||||
|
};
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return changed ? normalizedAssignments : assignments;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function pruneTeamGraphSlotAssignmentsForVisibleOwners(
|
||||||
|
assignments: TeamGraphSlotAssignments | undefined,
|
||||||
|
visibleOwnerIds: readonly string[]
|
||||||
|
): TeamGraphSlotAssignments | undefined {
|
||||||
|
const normalizedAssignments = normalizeTeamGraphSlotAssignmentsForVisibleOwners(
|
||||||
|
assignments,
|
||||||
|
visibleOwnerIds
|
||||||
|
);
|
||||||
|
return Object.keys(normalizedAssignments).length > 0 ? normalizedAssignments : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeTeamGraphGridOwnerOrder(
|
||||||
|
order: readonly string[] | undefined,
|
||||||
|
visibleOwnerIds: readonly string[]
|
||||||
|
): string[] {
|
||||||
|
const visibleOwnerIdSet = new Set(visibleOwnerIds);
|
||||||
|
const normalizedOrder: string[] = [];
|
||||||
|
const seenOwnerIds = new Set<string>();
|
||||||
|
|
||||||
|
for (const stableOwnerId of order ?? []) {
|
||||||
|
if (!visibleOwnerIdSet.has(stableOwnerId) || seenOwnerIds.has(stableOwnerId)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
normalizedOrder.push(stableOwnerId);
|
||||||
|
seenOwnerIds.add(stableOwnerId);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const stableOwnerId of visibleOwnerIds) {
|
||||||
|
if (seenOwnerIds.has(stableOwnerId)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
normalizedOrder.push(stableOwnerId);
|
||||||
|
seenOwnerIds.add(stableOwnerId);
|
||||||
|
}
|
||||||
|
|
||||||
|
return normalizedOrder;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getDefaultTeamGraphSlotAssignmentsForMembers(
|
||||||
|
members: readonly TeamGraphMemberSeedInput[],
|
||||||
|
configMembers: readonly TeamGraphConfigMemberSeedInput[] = []
|
||||||
|
): TeamGraphSlotAssignments {
|
||||||
|
return buildTeamGraphDefaultLayoutSeed(members, configMembers).assignments;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isTeamGraphSlotPersistenceDisabled(): boolean {
|
||||||
|
return DISABLE_PERSISTED_TEAM_GRAPH_SLOT_ASSIGNMENTS;
|
||||||
|
}
|
||||||
89
src/renderer/store/team/teamLaunchParams.ts
Normal file
89
src/renderer/store/team/teamLaunchParams.ts
Normal file
|
|
@ -0,0 +1,89 @@
|
||||||
|
import { extractProviderScopedBaseModel } from '@renderer/utils/teamModelContext';
|
||||||
|
import { migrateProviderBackendId } from '@shared/utils/providerBackend';
|
||||||
|
|
||||||
|
import type {
|
||||||
|
EffortLevel,
|
||||||
|
TeamCreateRequest,
|
||||||
|
TeamFastMode,
|
||||||
|
TeamProviderId,
|
||||||
|
} from '@shared/types';
|
||||||
|
|
||||||
|
/** Per-team launch parameters shown in the header badge. */
|
||||||
|
export interface TeamLaunchParams {
|
||||||
|
providerId?: TeamProviderId;
|
||||||
|
providerBackendId?: string;
|
||||||
|
model?: string;
|
||||||
|
effort?: EffortLevel;
|
||||||
|
fastMode?: TeamFastMode;
|
||||||
|
limitContext?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function extractBaseModel(
|
||||||
|
raw?: string,
|
||||||
|
providerId?: TeamProviderId
|
||||||
|
): string | undefined {
|
||||||
|
return extractProviderScopedBaseModel(raw, providerId);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildLaunchParamsFromRuntimeRequest(
|
||||||
|
request: Pick<
|
||||||
|
TeamCreateRequest,
|
||||||
|
'providerId' | 'providerBackendId' | 'model' | 'effort' | 'fastMode' | 'limitContext'
|
||||||
|
>,
|
||||||
|
fallback?: TeamLaunchParams
|
||||||
|
): TeamLaunchParams {
|
||||||
|
const providerId = request.providerId ?? fallback?.providerId ?? 'anthropic';
|
||||||
|
const providerChanged =
|
||||||
|
request.providerId != null &&
|
||||||
|
fallback?.providerId != null &&
|
||||||
|
request.providerId !== fallback.providerId;
|
||||||
|
const hasModel = Object.hasOwn(request, 'model');
|
||||||
|
const baseModel =
|
||||||
|
hasModel && typeof request.model === 'string'
|
||||||
|
? extractBaseModel(request.model, providerId)
|
||||||
|
: undefined;
|
||||||
|
const rawProviderBackendId = Object.hasOwn(request, 'providerBackendId')
|
||||||
|
? request.providerBackendId
|
||||||
|
: providerChanged
|
||||||
|
? undefined
|
||||||
|
: fallback?.providerBackendId;
|
||||||
|
return {
|
||||||
|
providerId,
|
||||||
|
providerBackendId: migrateProviderBackendId(providerId, rawProviderBackendId),
|
||||||
|
model: hasModel
|
||||||
|
? baseModel || 'default'
|
||||||
|
: (providerChanged ? undefined : fallback?.model) || 'default',
|
||||||
|
effort: Object.hasOwn(request, 'effort')
|
||||||
|
? request.effort
|
||||||
|
: providerChanged
|
||||||
|
? undefined
|
||||||
|
: fallback?.effort,
|
||||||
|
fastMode: Object.hasOwn(request, 'fastMode')
|
||||||
|
? request.fastMode
|
||||||
|
: providerChanged
|
||||||
|
? undefined
|
||||||
|
: fallback?.fastMode,
|
||||||
|
limitContext:
|
||||||
|
typeof request.limitContext === 'boolean'
|
||||||
|
? request.limitContext
|
||||||
|
: providerChanged
|
||||||
|
? false
|
||||||
|
: (fallback?.limitContext ?? false),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function areTeamLaunchParamsEqual(
|
||||||
|
left: TeamLaunchParams | undefined,
|
||||||
|
right: TeamLaunchParams | undefined
|
||||||
|
): boolean {
|
||||||
|
if (left === right) return true;
|
||||||
|
if (!left || !right) return false;
|
||||||
|
return (
|
||||||
|
left.providerId === right.providerId &&
|
||||||
|
left.providerBackendId === right.providerBackendId &&
|
||||||
|
left.model === right.model &&
|
||||||
|
left.effort === right.effort &&
|
||||||
|
left.fastMode === right.fastMode &&
|
||||||
|
left.limitContext === right.limitContext
|
||||||
|
);
|
||||||
|
}
|
||||||
25
src/renderer/store/team/teamLocalStateEpoch.ts
Normal file
25
src/renderer/store/team/teamLocalStateEpoch.ts
Normal file
|
|
@ -0,0 +1,25 @@
|
||||||
|
const teamLocalStateEpochByTeam = new Map<string, number>();
|
||||||
|
|
||||||
|
export function captureTeamLocalStateEpoch(teamName: string): number {
|
||||||
|
return teamLocalStateEpochByTeam.get(teamName) ?? 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isTeamLocalStateEpochCurrent(teamName: string, epoch: number): boolean {
|
||||||
|
return captureTeamLocalStateEpoch(teamName) === epoch;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function invalidateTeamLocalStateEpoch(teamName: string): void {
|
||||||
|
teamLocalStateEpochByTeam.set(teamName, captureTeamLocalStateEpoch(teamName) + 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function hasTeamLocalStateEpoch(teamName: string): boolean {
|
||||||
|
return teamLocalStateEpochByTeam.has(teamName);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function clearTeamLocalStateEpoch(teamName: string): void {
|
||||||
|
teamLocalStateEpochByTeam.delete(teamName);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function clearAllTeamLocalStateEpochs(): void {
|
||||||
|
teamLocalStateEpochByTeam.clear();
|
||||||
|
}
|
||||||
64
src/renderer/store/team/teamMemberActivityMeta.ts
Normal file
64
src/renderer/store/team/teamMemberActivityMeta.ts
Normal file
|
|
@ -0,0 +1,64 @@
|
||||||
|
import { getTeamMessagesCacheEntry, type TeamMessagesCacheState } from './teamMessagesCache';
|
||||||
|
|
||||||
|
import type { MemberActivityMetaEntry, TeamMemberActivityMeta } from '@shared/types';
|
||||||
|
|
||||||
|
export interface TeamMemberActivityMetaState extends TeamMessagesCacheState {
|
||||||
|
memberActivityMetaByTeam: Record<string, TeamMemberActivityMeta>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function areMemberActivityMetaEntriesEqual(
|
||||||
|
left: MemberActivityMetaEntry | undefined,
|
||||||
|
right: MemberActivityMetaEntry
|
||||||
|
): boolean {
|
||||||
|
if (!left) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
left.memberName === right.memberName &&
|
||||||
|
left.lastAuthoredMessageAt === right.lastAuthoredMessageAt &&
|
||||||
|
left.messageCountExact === right.messageCountExact &&
|
||||||
|
left.latestAuthoredMessageSignalsTermination === right.latestAuthoredMessageSignalsTermination
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function structurallyShareMemberActivityFacts(
|
||||||
|
previous: Record<string, MemberActivityMetaEntry> | undefined,
|
||||||
|
next: Record<string, MemberActivityMetaEntry>
|
||||||
|
): Record<string, MemberActivityMetaEntry> {
|
||||||
|
if (!previous) {
|
||||||
|
return next;
|
||||||
|
}
|
||||||
|
|
||||||
|
const nextKeys = Object.keys(next);
|
||||||
|
const previousKeys = Object.keys(previous);
|
||||||
|
let changed = nextKeys.length !== previousKeys.length;
|
||||||
|
const shared: Record<string, MemberActivityMetaEntry> = {};
|
||||||
|
|
||||||
|
for (const key of nextKeys) {
|
||||||
|
const nextEntry = next[key];
|
||||||
|
const previousEntry = previous[key];
|
||||||
|
if (!areMemberActivityMetaEntriesEqual(previousEntry, nextEntry)) {
|
||||||
|
changed = true;
|
||||||
|
shared[key] = nextEntry;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
shared[key] = previousEntry;
|
||||||
|
}
|
||||||
|
|
||||||
|
return changed ? shared : previous;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isMemberActivityMetaStale(
|
||||||
|
state: TeamMemberActivityMetaState,
|
||||||
|
teamName: string
|
||||||
|
): boolean {
|
||||||
|
const meta = state.memberActivityMetaByTeam[teamName];
|
||||||
|
const feedRevision = getTeamMessagesCacheEntry(state, teamName).feedRevision;
|
||||||
|
if (!meta) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (!feedRevision) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return meta.feedRevision !== feedRevision;
|
||||||
|
}
|
||||||
106
src/renderer/store/team/teamMemberSpawnSnapshotEquality.ts
Normal file
106
src/renderer/store/team/teamMemberSpawnSnapshotEquality.ts
Normal file
|
|
@ -0,0 +1,106 @@
|
||||||
|
import type {
|
||||||
|
MemberSpawnStatusEntry,
|
||||||
|
MemberSpawnStatusesSnapshot,
|
||||||
|
PersistedTeamLaunchSummary,
|
||||||
|
} from '@shared/types';
|
||||||
|
|
||||||
|
export function areLaunchSummaryCountsEqual(
|
||||||
|
left: PersistedTeamLaunchSummary | undefined,
|
||||||
|
right: PersistedTeamLaunchSummary | undefined
|
||||||
|
): boolean {
|
||||||
|
if (left === right) return true;
|
||||||
|
if (!left || !right) return left === right;
|
||||||
|
return (
|
||||||
|
left.confirmedCount === right.confirmedCount &&
|
||||||
|
left.pendingCount === right.pendingCount &&
|
||||||
|
left.failedCount === right.failedCount &&
|
||||||
|
left.skippedCount === right.skippedCount &&
|
||||||
|
left.runtimeAlivePendingCount === right.runtimeAlivePendingCount &&
|
||||||
|
left.shellOnlyPendingCount === right.shellOnlyPendingCount &&
|
||||||
|
left.runtimeProcessPendingCount === right.runtimeProcessPendingCount &&
|
||||||
|
left.runtimeCandidatePendingCount === right.runtimeCandidatePendingCount &&
|
||||||
|
left.noRuntimePendingCount === right.noRuntimePendingCount &&
|
||||||
|
left.permissionPendingCount === right.permissionPendingCount
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function areExpectedMembersEqual(
|
||||||
|
left: readonly string[] | undefined,
|
||||||
|
right: readonly string[] | undefined
|
||||||
|
): boolean {
|
||||||
|
if (left === right) return true;
|
||||||
|
if (!left || !right) return left === right;
|
||||||
|
if (left.length !== right.length) return false;
|
||||||
|
for (let index = 0; index < left.length; index += 1) {
|
||||||
|
if (left[index] !== right[index]) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function areMemberSpawnStatusEntriesEqual(
|
||||||
|
left: MemberSpawnStatusEntry | undefined,
|
||||||
|
right: MemberSpawnStatusEntry | undefined
|
||||||
|
): boolean {
|
||||||
|
if (left === right) return true;
|
||||||
|
if (!left || !right) return left === right;
|
||||||
|
const leftPendingPermissionIds = [...(left.pendingPermissionRequestIds ?? [])].sort();
|
||||||
|
const rightPendingPermissionIds = [...(right.pendingPermissionRequestIds ?? [])].sort();
|
||||||
|
// Renderer equality intentionally ignores raw timing fields that do not change
|
||||||
|
// visible member status. This suppresses heartbeat-only churn in TeamDetailView.
|
||||||
|
return (
|
||||||
|
left.status === right.status &&
|
||||||
|
left.launchState === right.launchState &&
|
||||||
|
left.error === right.error &&
|
||||||
|
left.hardFailureReason === right.hardFailureReason &&
|
||||||
|
left.skippedForLaunch === right.skippedForLaunch &&
|
||||||
|
left.skipReason === right.skipReason &&
|
||||||
|
left.skippedAt === right.skippedAt &&
|
||||||
|
left.livenessSource === right.livenessSource &&
|
||||||
|
left.runtimeAlive === right.runtimeAlive &&
|
||||||
|
left.runtimeModel === right.runtimeModel &&
|
||||||
|
left.livenessKind === right.livenessKind &&
|
||||||
|
left.runtimeDiagnostic === right.runtimeDiagnostic &&
|
||||||
|
left.runtimeDiagnosticSeverity === right.runtimeDiagnosticSeverity &&
|
||||||
|
left.bootstrapConfirmed === right.bootstrapConfirmed &&
|
||||||
|
left.hardFailure === right.hardFailure &&
|
||||||
|
leftPendingPermissionIds.length === rightPendingPermissionIds.length &&
|
||||||
|
leftPendingPermissionIds.every((value, index) => value === rightPendingPermissionIds[index])
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function areMemberSpawnStatusesEqual(
|
||||||
|
left: Record<string, MemberSpawnStatusEntry>,
|
||||||
|
right: Record<string, MemberSpawnStatusEntry>
|
||||||
|
): boolean {
|
||||||
|
if (left === right) return true;
|
||||||
|
const leftKeys = Object.keys(left);
|
||||||
|
const rightKeys = Object.keys(right);
|
||||||
|
if (leftKeys.length !== rightKeys.length) return false;
|
||||||
|
for (const key of leftKeys) {
|
||||||
|
if (!(key in right)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (!areMemberSpawnStatusEntriesEqual(left[key], right[key])) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function areMemberSpawnSnapshotsSemanticallyEqual(
|
||||||
|
left: MemberSpawnStatusesSnapshot | undefined,
|
||||||
|
right: MemberSpawnStatusesSnapshot
|
||||||
|
): boolean {
|
||||||
|
if (!left) return false;
|
||||||
|
return (
|
||||||
|
left.runId === right.runId &&
|
||||||
|
left.teamLaunchState === right.teamLaunchState &&
|
||||||
|
left.launchPhase === right.launchPhase &&
|
||||||
|
left.source === right.source &&
|
||||||
|
areExpectedMembersEqual(left.expectedMembers, right.expectedMembers) &&
|
||||||
|
areLaunchSummaryCountsEqual(left.summary, right.summary) &&
|
||||||
|
areMemberSpawnStatusesEqual(left.statuses, right.statuses)
|
||||||
|
);
|
||||||
|
}
|
||||||
39
src/renderer/store/team/teamMemberSpawnStatusBackoff.ts
Normal file
39
src/renderer/store/team/teamMemberSpawnStatusBackoff.ts
Normal file
|
|
@ -0,0 +1,39 @@
|
||||||
|
const memberSpawnStatusesIpcBackoffUntilByTeam = new Map<string, number>();
|
||||||
|
|
||||||
|
export function getMemberSpawnStatusesIpcBackoffUntil(teamName: string): number {
|
||||||
|
return memberSpawnStatusesIpcBackoffUntilByTeam.get(teamName) ?? 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function hasMemberSpawnStatusesIpcBackoff(teamName: string): boolean {
|
||||||
|
return memberSpawnStatusesIpcBackoffUntilByTeam.has(teamName);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isMemberSpawnStatusesIpcBackoffActive(
|
||||||
|
teamName: string,
|
||||||
|
now = Date.now()
|
||||||
|
): boolean {
|
||||||
|
return getMemberSpawnStatusesIpcBackoffUntil(teamName) > now;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function recordMemberSpawnStatusesIpcBackoffUntil(
|
||||||
|
teamName: string,
|
||||||
|
backoffUntil: number
|
||||||
|
): void {
|
||||||
|
memberSpawnStatusesIpcBackoffUntilByTeam.set(teamName, backoffUntil);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function recordMemberSpawnStatusesIpcRetryBackoff(
|
||||||
|
teamName: string,
|
||||||
|
retryBackoffMs: number,
|
||||||
|
now = Date.now()
|
||||||
|
): void {
|
||||||
|
recordMemberSpawnStatusesIpcBackoffUntil(teamName, now + retryBackoffMs);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function clearMemberSpawnStatusesIpcBackoff(teamName: string): void {
|
||||||
|
memberSpawnStatusesIpcBackoffUntilByTeam.delete(teamName);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function clearAllMemberSpawnStatusesIpcBackoffs(): void {
|
||||||
|
memberSpawnStatusesIpcBackoffUntilByTeam.clear();
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,30 @@
|
||||||
|
const memberSpawnUiEqualLastWarnAtByTeam = new Map<string, number>();
|
||||||
|
|
||||||
|
export function getMemberSpawnUiEqualLastWarnAt(teamName: string): number | undefined {
|
||||||
|
return memberSpawnUiEqualLastWarnAtByTeam.get(teamName);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function hasMemberSpawnUiEqualLastWarn(teamName: string): boolean {
|
||||||
|
return memberSpawnUiEqualLastWarnAtByTeam.has(teamName);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function shouldLogMemberSpawnUiEqualSuppressed(
|
||||||
|
teamName: string,
|
||||||
|
throttleMs: number,
|
||||||
|
now = Date.now()
|
||||||
|
): boolean {
|
||||||
|
const lastWarnAt = memberSpawnUiEqualLastWarnAtByTeam.get(teamName) ?? 0;
|
||||||
|
if (now - lastWarnAt < throttleMs) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
memberSpawnUiEqualLastWarnAtByTeam.set(teamName, now);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function clearMemberSpawnUiEqualLastWarn(teamName: string): void {
|
||||||
|
memberSpawnUiEqualLastWarnAtByTeam.delete(teamName);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function clearAllMemberSpawnUiEqualLastWarns(): void {
|
||||||
|
memberSpawnUiEqualLastWarnAtByTeam.clear();
|
||||||
|
}
|
||||||
291
src/renderer/store/team/teamMessagesCache.ts
Normal file
291
src/renderer/store/team/teamMessagesCache.ts
Normal file
|
|
@ -0,0 +1,291 @@
|
||||||
|
import { mergeTeamMessages } from '@renderer/utils/mergeTeamMessages';
|
||||||
|
import { toMessageKey } from '@renderer/utils/teamMessageKey';
|
||||||
|
|
||||||
|
import type { InboxMessage } from '@shared/types';
|
||||||
|
|
||||||
|
export interface TeamMessagesCacheEntry {
|
||||||
|
canonicalMessages: InboxMessage[];
|
||||||
|
optimisticMessages: InboxMessage[];
|
||||||
|
feedRevision: string | null;
|
||||||
|
nextCursor: string | null;
|
||||||
|
hasMore: boolean;
|
||||||
|
lastFetchedAt: number | null;
|
||||||
|
loadingHead: boolean;
|
||||||
|
loadingOlder: boolean;
|
||||||
|
headHydrated: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RefreshTeamMessagesHeadResult {
|
||||||
|
feedChanged: boolean;
|
||||||
|
headChanged: boolean;
|
||||||
|
feedRevision: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TeamMessagesCacheState {
|
||||||
|
teamMessagesByName: Record<string, TeamMessagesCacheEntry>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TeamMessageSelectorCacheSnapshot {
|
||||||
|
hasMergedMessagesSelector: boolean;
|
||||||
|
memberMessagesSelectorCount: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const EMPTY_TEAM_MESSAGES_CACHE_ENTRY: TeamMessagesCacheEntry = {
|
||||||
|
canonicalMessages: [],
|
||||||
|
optimisticMessages: [],
|
||||||
|
feedRevision: null,
|
||||||
|
nextCursor: null,
|
||||||
|
hasMore: false,
|
||||||
|
lastFetchedAt: null,
|
||||||
|
loadingHead: false,
|
||||||
|
loadingOlder: false,
|
||||||
|
headHydrated: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
const mergedMessagesSelectorCache = new Map<
|
||||||
|
string,
|
||||||
|
{
|
||||||
|
canonicalRef: readonly InboxMessage[];
|
||||||
|
optimisticRef: readonly InboxMessage[];
|
||||||
|
result: InboxMessage[];
|
||||||
|
}
|
||||||
|
>();
|
||||||
|
const memberMessagesSelectorCache = new Map<
|
||||||
|
string,
|
||||||
|
{
|
||||||
|
messagesRef: readonly InboxMessage[];
|
||||||
|
result: InboxMessage[];
|
||||||
|
}
|
||||||
|
>();
|
||||||
|
|
||||||
|
export function clearTeamMessageSelectorCaches(): void {
|
||||||
|
mergedMessagesSelectorCache.clear();
|
||||||
|
memberMessagesSelectorCache.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function clearTeamMessageSelectorCachesForTeam(teamName: string): void {
|
||||||
|
mergedMessagesSelectorCache.delete(teamName);
|
||||||
|
|
||||||
|
const teamScopedPrefix = `${teamName}:`;
|
||||||
|
for (const key of memberMessagesSelectorCache.keys()) {
|
||||||
|
if (key.startsWith(teamScopedPrefix)) {
|
||||||
|
memberMessagesSelectorCache.delete(key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getTeamMessageSelectorCacheSnapshotForTeam(
|
||||||
|
teamName: string
|
||||||
|
): TeamMessageSelectorCacheSnapshot {
|
||||||
|
const teamScopedPrefix = `${teamName}:`;
|
||||||
|
let memberMessagesSelectorCount = 0;
|
||||||
|
for (const key of memberMessagesSelectorCache.keys()) {
|
||||||
|
if (key.startsWith(teamScopedPrefix)) {
|
||||||
|
memberMessagesSelectorCount += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
hasMergedMessagesSelector: mergedMessagesSelectorCache.has(teamName),
|
||||||
|
memberMessagesSelectorCount,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function compareInboxMessagesByTimestamp(a: InboxMessage, b: InboxMessage): number {
|
||||||
|
const aTime = Date.parse(a.timestamp);
|
||||||
|
const bTime = Date.parse(b.timestamp);
|
||||||
|
const aValid = Number.isFinite(aTime);
|
||||||
|
const bValid = Number.isFinite(bTime);
|
||||||
|
if (aValid && bValid && aTime !== bTime) {
|
||||||
|
return aTime - bTime;
|
||||||
|
}
|
||||||
|
if (aValid !== bValid) {
|
||||||
|
return aValid ? -1 : 1;
|
||||||
|
}
|
||||||
|
const aId = typeof a.messageId === 'string' ? a.messageId : '';
|
||||||
|
const bId = typeof b.messageId === 'string' ? b.messageId : '';
|
||||||
|
return aId.localeCompare(bId);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getTeamMessagesCacheEntry(
|
||||||
|
state: TeamMessagesCacheState,
|
||||||
|
teamName: string
|
||||||
|
): TeamMessagesCacheEntry {
|
||||||
|
return state.teamMessagesByName[teamName] ?? EMPTY_TEAM_MESSAGES_CACHE_ENTRY;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function upsertOptimisticTeamMessage(
|
||||||
|
entry: TeamMessagesCacheEntry,
|
||||||
|
message: InboxMessage
|
||||||
|
): TeamMessagesCacheEntry {
|
||||||
|
const nextOptimistic = [...entry.optimisticMessages];
|
||||||
|
const messageId = typeof message.messageId === 'string' ? message.messageId.trim() : '';
|
||||||
|
if (messageId.length > 0) {
|
||||||
|
const existingIndex = nextOptimistic.findIndex(
|
||||||
|
(candidate) =>
|
||||||
|
typeof candidate.messageId === 'string' && candidate.messageId.trim() === messageId
|
||||||
|
);
|
||||||
|
if (existingIndex >= 0) {
|
||||||
|
nextOptimistic[existingIndex] = {
|
||||||
|
...nextOptimistic[existingIndex],
|
||||||
|
...message,
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
nextOptimistic.push(message);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
nextOptimistic.push(message);
|
||||||
|
}
|
||||||
|
nextOptimistic.sort(compareInboxMessagesByTimestamp);
|
||||||
|
return {
|
||||||
|
...entry,
|
||||||
|
optimisticMessages: nextOptimistic,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function areInboxMessageArraysEquivalent(
|
||||||
|
left: readonly InboxMessage[],
|
||||||
|
right: readonly InboxMessage[]
|
||||||
|
): boolean {
|
||||||
|
if (left === right) return true;
|
||||||
|
if (left.length !== right.length) return false;
|
||||||
|
for (let index = 0; index < left.length; index += 1) {
|
||||||
|
const leftItem = left[index];
|
||||||
|
const rightItem = right[index];
|
||||||
|
if (
|
||||||
|
leftItem.messageId !== rightItem.messageId ||
|
||||||
|
leftItem.timestamp !== rightItem.timestamp ||
|
||||||
|
leftItem.from !== rightItem.from ||
|
||||||
|
leftItem.to !== rightItem.to ||
|
||||||
|
leftItem.text !== rightItem.text ||
|
||||||
|
leftItem.summary !== rightItem.summary ||
|
||||||
|
leftItem.read !== rightItem.read ||
|
||||||
|
leftItem.actionMode !== rightItem.actionMode ||
|
||||||
|
leftItem.commentId !== rightItem.commentId ||
|
||||||
|
leftItem.relayOfMessageId !== rightItem.relayOfMessageId ||
|
||||||
|
leftItem.source !== rightItem.source ||
|
||||||
|
leftItem.leadSessionId !== rightItem.leadSessionId ||
|
||||||
|
leftItem.messageKind !== rightItem.messageKind ||
|
||||||
|
JSON.stringify(leftItem.taskRefs ?? null) !== JSON.stringify(rightItem.taskRefs ?? null)
|
||||||
|
) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function pruneOptimisticMessages(
|
||||||
|
optimistic: readonly InboxMessage[],
|
||||||
|
canonical: readonly InboxMessage[]
|
||||||
|
): InboxMessage[] {
|
||||||
|
if (optimistic.length === 0) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const canonicalIds = new Set(
|
||||||
|
canonical
|
||||||
|
.map((message) => (typeof message.messageId === 'string' ? message.messageId.trim() : ''))
|
||||||
|
.filter((messageId) => messageId.length > 0)
|
||||||
|
);
|
||||||
|
|
||||||
|
return optimistic.filter((message) => {
|
||||||
|
const messageId = typeof message.messageId === 'string' ? message.messageId.trim() : '';
|
||||||
|
return !messageId || !canonicalIds.has(messageId);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getCanonicalHeadSlice(
|
||||||
|
canonicalMessages: readonly InboxMessage[],
|
||||||
|
headLength: number
|
||||||
|
): readonly InboxMessage[] {
|
||||||
|
if (headLength <= 0) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
return canonicalMessages.slice(0, headLength);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function extractRetainedCanonicalOlderTail(
|
||||||
|
canonicalMessages: readonly InboxMessage[],
|
||||||
|
freshHeadMessages: readonly InboxMessage[]
|
||||||
|
): InboxMessage[] | null {
|
||||||
|
if (canonicalMessages.length === 0) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
if (freshHeadMessages.length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const freshHeadKeys = new Set(freshHeadMessages.map((message) => toMessageKey(message)));
|
||||||
|
let hasMessagesOutsideFreshHead = false;
|
||||||
|
for (const message of canonicalMessages) {
|
||||||
|
if (!freshHeadKeys.has(toMessageKey(message))) {
|
||||||
|
hasMessagesOutsideFreshHead = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!hasMessagesOutsideFreshHead) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const anchorKey = toMessageKey(freshHeadMessages[freshHeadMessages.length - 1]);
|
||||||
|
const anchorIndex = canonicalMessages.findIndex((message) => toMessageKey(message) === anchorKey);
|
||||||
|
if (anchorIndex < 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return canonicalMessages
|
||||||
|
.slice(anchorIndex + 1)
|
||||||
|
.filter((message) => !freshHeadKeys.has(toMessageKey(message)));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function selectTeamMessages(
|
||||||
|
state: TeamMessagesCacheState,
|
||||||
|
teamName: string | null | undefined
|
||||||
|
): InboxMessage[] {
|
||||||
|
if (!teamName) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const entry = getTeamMessagesCacheEntry(state, teamName);
|
||||||
|
const cached = mergedMessagesSelectorCache.get(teamName);
|
||||||
|
if (
|
||||||
|
cached?.canonicalRef === entry.canonicalMessages &&
|
||||||
|
cached.optimisticRef === entry.optimisticMessages
|
||||||
|
) {
|
||||||
|
return cached.result;
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = mergeTeamMessages(entry.canonicalMessages, entry.optimisticMessages);
|
||||||
|
mergedMessagesSelectorCache.set(teamName, {
|
||||||
|
canonicalRef: entry.canonicalMessages,
|
||||||
|
optimisticRef: entry.optimisticMessages,
|
||||||
|
result,
|
||||||
|
});
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function selectMemberMessagesForTeamMember(
|
||||||
|
state: TeamMessagesCacheState,
|
||||||
|
teamName: string | null | undefined,
|
||||||
|
memberName: string | null | undefined
|
||||||
|
): InboxMessage[] {
|
||||||
|
if (!teamName || !memberName) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const messages = selectTeamMessages(state, teamName);
|
||||||
|
const cacheKey = `${teamName}:${memberName}`;
|
||||||
|
const cached = memberMessagesSelectorCache.get(cacheKey);
|
||||||
|
if (cached?.messagesRef === messages) {
|
||||||
|
return cached.result;
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = messages.filter(
|
||||||
|
(message) => message.from === memberName || message.to === memberName
|
||||||
|
);
|
||||||
|
memberMessagesSelectorCache.set(cacheKey, {
|
||||||
|
messagesRef: messages,
|
||||||
|
result,
|
||||||
|
});
|
||||||
|
return result;
|
||||||
|
}
|
||||||
29
src/renderer/store/team/teamMessagesPanelModePersistence.ts
Normal file
29
src/renderer/store/team/teamMessagesPanelModePersistence.ts
Normal file
|
|
@ -0,0 +1,29 @@
|
||||||
|
import type { TeamMessagesPanelMode } from '@renderer/types/teamMessagesPanelMode';
|
||||||
|
|
||||||
|
const MESSAGES_PANEL_MODE_STORAGE_KEY = 'team:messagesPanelMode';
|
||||||
|
const DEFAULT_MESSAGES_PANEL_MODE: TeamMessagesPanelMode = 'sidebar';
|
||||||
|
const VALID_MESSAGES_PANEL_MODES: ReadonlySet<TeamMessagesPanelMode> = new Set([
|
||||||
|
'sidebar',
|
||||||
|
'inline',
|
||||||
|
'bottom-sheet',
|
||||||
|
'floating-composer',
|
||||||
|
]);
|
||||||
|
|
||||||
|
export function loadPersistedMessagesPanelMode(): TeamMessagesPanelMode {
|
||||||
|
try {
|
||||||
|
const persisted = localStorage.getItem(MESSAGES_PANEL_MODE_STORAGE_KEY);
|
||||||
|
return VALID_MESSAGES_PANEL_MODES.has(persisted as TeamMessagesPanelMode)
|
||||||
|
? (persisted as TeamMessagesPanelMode)
|
||||||
|
: DEFAULT_MESSAGES_PANEL_MODE;
|
||||||
|
} catch {
|
||||||
|
return DEFAULT_MESSAGES_PANEL_MODE;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function savePersistedMessagesPanelMode(mode: TeamMessagesPanelMode): void {
|
||||||
|
try {
|
||||||
|
localStorage.setItem(MESSAGES_PANEL_MODE_STORAGE_KEY, mode);
|
||||||
|
} catch {
|
||||||
|
// ignore - best-effort UI preference persistence
|
||||||
|
}
|
||||||
|
}
|
||||||
45
src/renderer/store/team/teamPendingReplyWaits.ts
Normal file
45
src/renderer/store/team/teamPendingReplyWaits.ts
Normal file
|
|
@ -0,0 +1,45 @@
|
||||||
|
const activeTeamPendingReplyWaitSourceIdsByTeam = new Map<string, Set<string>>();
|
||||||
|
|
||||||
|
export function hasActiveTeamPendingReplyWait(teamName: string): boolean {
|
||||||
|
return (activeTeamPendingReplyWaitSourceIdsByTeam.get(teamName)?.size ?? 0) > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getActiveTeamPendingReplyWaits(): Set<string> {
|
||||||
|
return new Set(
|
||||||
|
Array.from(activeTeamPendingReplyWaitSourceIdsByTeam.entries())
|
||||||
|
.filter(([, sourceIds]) => sourceIds.size > 0)
|
||||||
|
.map(([teamName]) => teamName)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function clearAllPendingReplyRefreshWaits(): void {
|
||||||
|
activeTeamPendingReplyWaitSourceIdsByTeam.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function clearPendingReplyRefreshWaits(teamName: string): void {
|
||||||
|
activeTeamPendingReplyWaitSourceIdsByTeam.delete(teamName);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setPendingReplyRefreshEnabled(
|
||||||
|
teamName: string,
|
||||||
|
sourceId: string,
|
||||||
|
enabled: boolean
|
||||||
|
): boolean {
|
||||||
|
if (enabled) {
|
||||||
|
const existing = activeTeamPendingReplyWaitSourceIdsByTeam.get(teamName) ?? new Set<string>();
|
||||||
|
existing.add(sourceId);
|
||||||
|
activeTeamPendingReplyWaitSourceIdsByTeam.set(teamName, existing);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const existing = activeTeamPendingReplyWaitSourceIdsByTeam.get(teamName);
|
||||||
|
if (!existing) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
existing.delete(sourceId);
|
||||||
|
if (existing.size === 0) {
|
||||||
|
activeTeamPendingReplyWaitSourceIdsByTeam.delete(teamName);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
44
src/renderer/store/team/teamProvisioningStateRules.ts
Normal file
44
src/renderer/store/team/teamProvisioningStateRules.ts
Normal file
|
|
@ -0,0 +1,44 @@
|
||||||
|
import type { TeamProvisioningProgress } from '@shared/types';
|
||||||
|
|
||||||
|
type TeamProvisioningProgressState = TeamProvisioningProgress['state'];
|
||||||
|
|
||||||
|
const ACTIVE_PROVISIONING_STATES: ReadonlySet<TeamProvisioningProgressState> = new Set([
|
||||||
|
'validating',
|
||||||
|
'spawning',
|
||||||
|
'configuring',
|
||||||
|
'assembling',
|
||||||
|
'finalizing',
|
||||||
|
'verifying',
|
||||||
|
]);
|
||||||
|
|
||||||
|
const TERMINAL_PROVISIONING_STATES: ReadonlySet<TeamProvisioningProgressState> = new Set([
|
||||||
|
'ready',
|
||||||
|
'failed',
|
||||||
|
'disconnected',
|
||||||
|
'cancelled',
|
||||||
|
]);
|
||||||
|
|
||||||
|
export function isActiveProvisioningState(state: TeamProvisioningProgressState): boolean {
|
||||||
|
return ACTIVE_PROVISIONING_STATES.has(state);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isTerminalProvisioningState(state: TeamProvisioningProgressState): boolean {
|
||||||
|
return TERMINAL_PROVISIONING_STATES.has(state);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function shouldIgnoreProvisioningProgressRegression(
|
||||||
|
currentState: TeamProvisioningProgressState,
|
||||||
|
nextState: TeamProvisioningProgressState
|
||||||
|
): boolean {
|
||||||
|
if (currentState === 'ready') {
|
||||||
|
return nextState !== 'ready' && nextState !== 'disconnected';
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
currentState === 'failed' ||
|
||||||
|
currentState === 'cancelled' ||
|
||||||
|
currentState === 'disconnected'
|
||||||
|
) {
|
||||||
|
return nextState !== currentState;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
48
src/renderer/store/team/teamRefreshBurstDiagnostics.ts
Normal file
48
src/renderer/store/team/teamRefreshBurstDiagnostics.ts
Normal file
|
|
@ -0,0 +1,48 @@
|
||||||
|
interface TeamRefreshBurstDiagnostic {
|
||||||
|
windowStartedAt: number;
|
||||||
|
count: number;
|
||||||
|
lastWarnAt: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const teamRefreshBurstDiagnostics = new Map<string, TeamRefreshBurstDiagnostic>();
|
||||||
|
|
||||||
|
export function hasTeamRefreshBurstDiagnostics(teamName: string): boolean {
|
||||||
|
return teamRefreshBurstDiagnostics.has(teamName);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getTeamRefreshBurstDiagnosticForTests(
|
||||||
|
teamName: string
|
||||||
|
): TeamRefreshBurstDiagnostic | undefined {
|
||||||
|
const diagnostic = teamRefreshBurstDiagnostics.get(teamName);
|
||||||
|
return diagnostic ? { ...diagnostic } : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function noteTeamRefreshBurst(
|
||||||
|
teamName: string,
|
||||||
|
windowMs: number,
|
||||||
|
now = Date.now()
|
||||||
|
): number {
|
||||||
|
const diagnostic = teamRefreshBurstDiagnostics.get(teamName) ?? {
|
||||||
|
windowStartedAt: now,
|
||||||
|
count: 0,
|
||||||
|
lastWarnAt: 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (now - diagnostic.windowStartedAt > windowMs) {
|
||||||
|
diagnostic.windowStartedAt = now;
|
||||||
|
diagnostic.count = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
diagnostic.count += 1;
|
||||||
|
|
||||||
|
teamRefreshBurstDiagnostics.set(teamName, diagnostic);
|
||||||
|
return diagnostic.count;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function clearTeamRefreshBurstDiagnostics(teamName: string): void {
|
||||||
|
teamRefreshBurstDiagnostics.delete(teamName);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function clearAllTeamRefreshBurstDiagnostics(): void {
|
||||||
|
teamRefreshBurstDiagnostics.clear();
|
||||||
|
}
|
||||||
533
src/renderer/store/team/teamResolvedMembers.ts
Normal file
533
src/renderer/store/team/teamResolvedMembers.ts
Normal file
|
|
@ -0,0 +1,533 @@
|
||||||
|
import { getMemberColorByName } from '@shared/constants/memberColors';
|
||||||
|
import { isLeadMember } from '@shared/utils/leadDetection';
|
||||||
|
import {
|
||||||
|
getTeamTaskWorkflowColumn,
|
||||||
|
isTeamTaskFinalForCompletionNotification,
|
||||||
|
} from '@shared/utils/teamTaskState';
|
||||||
|
|
||||||
|
import { selectTeamDataForName, type TeamDataSelectorState } from './teamDataSelectors';
|
||||||
|
|
||||||
|
import type {
|
||||||
|
MemberActivityMetaEntry,
|
||||||
|
ResolvedTeamMember,
|
||||||
|
TeamMemberActivityMeta,
|
||||||
|
TeamMemberSnapshot,
|
||||||
|
TeamSummary,
|
||||||
|
TeamViewSnapshot,
|
||||||
|
} from '@shared/types';
|
||||||
|
|
||||||
|
export interface ResolvedMemberSelectorState extends TeamDataSelectorState {
|
||||||
|
memberActivityMetaByTeam: Record<string, TeamMemberActivityMeta>;
|
||||||
|
teamByName?: Record<string, TeamSummary>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ResolvedMemberSelectorCacheSnapshot {
|
||||||
|
hasResolvedMembersSelector: boolean;
|
||||||
|
resolvedMemberSelectorCount: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const resolvedMembersSelectorCache = new Map<
|
||||||
|
string,
|
||||||
|
{
|
||||||
|
snapshotRef: TeamViewSnapshot['members'];
|
||||||
|
configMembersRef: TeamViewSnapshot['config']['members'] | undefined;
|
||||||
|
summaryRef: TeamSummary | undefined;
|
||||||
|
tasksRef: TeamViewSnapshot['tasks'] | undefined;
|
||||||
|
metaMembersRef: TeamMemberActivityMeta['members'] | undefined;
|
||||||
|
result: ResolvedTeamMember[];
|
||||||
|
}
|
||||||
|
>();
|
||||||
|
const resolvedMemberSelectorCache = new Map<
|
||||||
|
string,
|
||||||
|
{
|
||||||
|
snapshotMemberRef: TeamMemberSnapshot | undefined;
|
||||||
|
metaEntryRef: MemberActivityMetaEntry | undefined;
|
||||||
|
result: ResolvedTeamMember | null;
|
||||||
|
}
|
||||||
|
>();
|
||||||
|
|
||||||
|
export function clearResolvedMemberSelectorCaches(): void {
|
||||||
|
resolvedMembersSelectorCache.clear();
|
||||||
|
resolvedMemberSelectorCache.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function clearResolvedMemberSelectorCachesForTeam(teamName: string): void {
|
||||||
|
resolvedMembersSelectorCache.delete(teamName);
|
||||||
|
|
||||||
|
const teamScopedPrefix = `${teamName}:`;
|
||||||
|
for (const key of resolvedMemberSelectorCache.keys()) {
|
||||||
|
if (key.startsWith(teamScopedPrefix)) {
|
||||||
|
resolvedMemberSelectorCache.delete(key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getResolvedMemberSelectorCacheSnapshotForTeam(
|
||||||
|
teamName: string
|
||||||
|
): ResolvedMemberSelectorCacheSnapshot {
|
||||||
|
const teamScopedPrefix = `${teamName}:`;
|
||||||
|
let resolvedMemberSelectorCount = 0;
|
||||||
|
|
||||||
|
for (const key of resolvedMemberSelectorCache.keys()) {
|
||||||
|
if (key.startsWith(teamScopedPrefix)) {
|
||||||
|
resolvedMemberSelectorCount += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
hasResolvedMembersSelector: resolvedMembersSelectorCache.has(teamName),
|
||||||
|
resolvedMemberSelectorCount,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveMemberStatus(
|
||||||
|
snapshot: TeamMemberSnapshot,
|
||||||
|
activity: MemberActivityMetaEntry | undefined
|
||||||
|
): ResolvedTeamMember['status'] {
|
||||||
|
if (activity?.latestAuthoredMessageSignalsTermination) {
|
||||||
|
return 'terminated';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!activity?.lastAuthoredMessageAt) {
|
||||||
|
return snapshot.currentTaskId ? 'active' : 'idle';
|
||||||
|
}
|
||||||
|
|
||||||
|
const ageMs = Date.now() - Date.parse(activity.lastAuthoredMessageAt);
|
||||||
|
if (Number.isNaN(ageMs)) {
|
||||||
|
return 'unknown';
|
||||||
|
}
|
||||||
|
if (ageMs < 5 * 60 * 1000) {
|
||||||
|
return 'active';
|
||||||
|
}
|
||||||
|
return 'idle';
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildResolvedMembers(
|
||||||
|
snapshots: readonly TeamMemberSnapshot[],
|
||||||
|
meta: TeamMemberActivityMeta | undefined
|
||||||
|
): ResolvedTeamMember[] {
|
||||||
|
return snapshots.map((member) => buildResolvedMember(member, meta?.members[member.name]));
|
||||||
|
}
|
||||||
|
|
||||||
|
function isDisplayableFallbackCurrentTask(task: TeamViewSnapshot['tasks'][number]): boolean {
|
||||||
|
return (
|
||||||
|
task.status === 'in_progress' &&
|
||||||
|
getTeamTaskWorkflowColumn(task) !== 'review' &&
|
||||||
|
!isTeamTaskFinalForCompletionNotification(task)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildConfigFallbackMemberSnapshots(snapshot: TeamViewSnapshot): TeamMemberSnapshot[] {
|
||||||
|
const configMembers = snapshot.config.members ?? [];
|
||||||
|
const hasConfiguredTeammate = configMembers.some((member) => {
|
||||||
|
const name = member.name?.trim();
|
||||||
|
return Boolean(name) && !member.removedAt && !isLeadMember(member);
|
||||||
|
});
|
||||||
|
if (!hasConfiguredTeammate) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const seenNames = new Set<string>();
|
||||||
|
const fallbackMembers: TeamMemberSnapshot[] = [];
|
||||||
|
for (const member of configMembers) {
|
||||||
|
const name = member.name?.trim();
|
||||||
|
if (!name) continue;
|
||||||
|
const key = name.toLowerCase();
|
||||||
|
if (seenNames.has(key)) continue;
|
||||||
|
seenNames.add(key);
|
||||||
|
|
||||||
|
const ownedTasks = snapshot.tasks.filter((task) => task.owner === name);
|
||||||
|
const currentTask = ownedTasks.find(isDisplayableFallbackCurrentTask);
|
||||||
|
fallbackMembers.push({
|
||||||
|
name,
|
||||||
|
agentId: member.agentId,
|
||||||
|
currentTaskId: currentTask?.id ?? null,
|
||||||
|
taskCount: ownedTasks.length,
|
||||||
|
color: member.color ?? getMemberColorByName(name),
|
||||||
|
agentType: member.agentType,
|
||||||
|
role: member.role,
|
||||||
|
workflow: member.workflow,
|
||||||
|
isolation: member.isolation,
|
||||||
|
providerId: member.providerId,
|
||||||
|
providerBackendId: member.providerBackendId,
|
||||||
|
model: member.model,
|
||||||
|
effort: member.effort,
|
||||||
|
mcpPolicy: member.mcpPolicy,
|
||||||
|
selectedFastMode: member.fastMode,
|
||||||
|
cwd: member.cwd,
|
||||||
|
removedAt: member.removedAt,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return fallbackMembers;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getActiveRawTeammateNameKeys(snapshot: TeamViewSnapshot | null | undefined): string[] {
|
||||||
|
if (!snapshot) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
const names = new Set<string>();
|
||||||
|
for (const member of snapshot.members) {
|
||||||
|
const name = member.name.trim();
|
||||||
|
const key = name.toLowerCase();
|
||||||
|
if (!name || key === 'user' || member.removedAt || isLeadMember(member)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
names.add(key);
|
||||||
|
}
|
||||||
|
return Array.from(names).sort((left, right) => left.localeCompare(right));
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasActiveRawTeammateRoster(snapshot: TeamViewSnapshot | null | undefined): boolean {
|
||||||
|
return getActiveRawTeammateNameKeys(snapshot).length > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasRemovedRawMemberRoster(snapshot: TeamViewSnapshot | null | undefined): boolean {
|
||||||
|
return Boolean(snapshot?.members.some((member) => member.removedAt));
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasConfigTeammateRoster(snapshot: TeamViewSnapshot | null | undefined): boolean {
|
||||||
|
return Boolean(
|
||||||
|
snapshot?.config.members?.some((member) => {
|
||||||
|
const name = member.name?.trim();
|
||||||
|
return Boolean(name) && !member.removedAt && !isLeadMember(member);
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SummaryFallbackMemberSource {
|
||||||
|
name: string;
|
||||||
|
agentId?: string;
|
||||||
|
role?: string;
|
||||||
|
color?: string;
|
||||||
|
mcpPolicy?: TeamMemberSnapshot['mcpPolicy'];
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeSummaryTeammateName(
|
||||||
|
name: string | undefined | null,
|
||||||
|
leadName?: string
|
||||||
|
): string | null {
|
||||||
|
const trimmed = name?.trim();
|
||||||
|
const normalizedName = trimmed?.toLowerCase();
|
||||||
|
const normalizedLeadName = leadName?.trim().toLowerCase();
|
||||||
|
if (
|
||||||
|
!trimmed ||
|
||||||
|
normalizedName === 'user' ||
|
||||||
|
isLeadMember({ name: trimmed }) ||
|
||||||
|
(normalizedLeadName && normalizedName === normalizedLeadName)
|
||||||
|
) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return trimmed;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getSummaryRosterTeammateSources(summary: TeamSummary): SummaryFallbackMemberSource[] {
|
||||||
|
const seenNames = new Set<string>();
|
||||||
|
const sources: SummaryFallbackMemberSource[] = [];
|
||||||
|
for (const member of summary.members ?? []) {
|
||||||
|
const name = normalizeSummaryTeammateName(member.name, summary.leadName);
|
||||||
|
if (!name) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const key = name.toLowerCase();
|
||||||
|
if (seenNames.has(key)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
seenNames.add(key);
|
||||||
|
sources.push({
|
||||||
|
name,
|
||||||
|
agentId: member.agentId,
|
||||||
|
role: member.role,
|
||||||
|
color: member.color,
|
||||||
|
mcpPolicy: member.mcpPolicy,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return sources;
|
||||||
|
}
|
||||||
|
|
||||||
|
function shouldUseSummaryLaunchTeammateSources(summary: TeamSummary): boolean {
|
||||||
|
return (
|
||||||
|
summary.partialLaunchFailure === true ||
|
||||||
|
summary.teamLaunchState === 'partial_failure' ||
|
||||||
|
summary.teamLaunchState === 'partial_pending' ||
|
||||||
|
summary.teamLaunchState === 'partial_skipped'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getSummaryLaunchTeammateSources(summary: TeamSummary): SummaryFallbackMemberSource[] {
|
||||||
|
if (!shouldUseSummaryLaunchTeammateSources(summary)) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const seenNames = new Set<string>();
|
||||||
|
const sources: SummaryFallbackMemberSource[] = [];
|
||||||
|
for (const rawName of [...(summary.missingMembers ?? []), ...(summary.skippedMembers ?? [])]) {
|
||||||
|
const name = normalizeSummaryTeammateName(rawName, summary.leadName);
|
||||||
|
if (!name) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const key = name.toLowerCase();
|
||||||
|
if (seenNames.has(key)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
seenNames.add(key);
|
||||||
|
sources.push({ name });
|
||||||
|
}
|
||||||
|
return sources;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getSummaryLaunchTeammateNameKeys(summary: TeamSummary): string[] {
|
||||||
|
return getSummaryLaunchTeammateSources(summary)
|
||||||
|
.map((member) => member.name.toLowerCase())
|
||||||
|
.sort((left, right) => left.localeCompare(right));
|
||||||
|
}
|
||||||
|
|
||||||
|
function getSummaryTeammateNameKeys(summary: TeamSummary): string[] {
|
||||||
|
const rosterNames = getSummaryRosterTeammateSources(summary)
|
||||||
|
.map((member) => member.name.toLowerCase())
|
||||||
|
.sort((left, right) => left.localeCompare(right));
|
||||||
|
if (rosterNames.length > 0) {
|
||||||
|
return rosterNames;
|
||||||
|
}
|
||||||
|
|
||||||
|
const launchNames = getSummaryLaunchTeammateNameKeys(summary);
|
||||||
|
const expectedCount = summary.expectedMemberCount ?? summary.memberCount;
|
||||||
|
if (expectedCount > 0 && launchNames.length === expectedCount) {
|
||||||
|
return launchNames;
|
||||||
|
}
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
function getSummaryFallbackTeammateSources(summary: TeamSummary): SummaryFallbackMemberSource[] {
|
||||||
|
return getSummaryRosterTeammateSources(summary);
|
||||||
|
}
|
||||||
|
|
||||||
|
function areNameKeyListsEqual(left: readonly string[], right: readonly string[]): boolean {
|
||||||
|
return left.length === right.length && left.every((name, index) => name === right[index]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function summaryConfirmsActiveTeammateRoster(
|
||||||
|
current: TeamViewSnapshot,
|
||||||
|
summary: TeamSummary
|
||||||
|
): boolean {
|
||||||
|
if ((summary.expectedMemberCount ?? summary.memberCount) <= 0) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const currentNames = getActiveRawTeammateNameKeys(current);
|
||||||
|
const summaryNames = getSummaryTeammateNameKeys(summary);
|
||||||
|
if (summaryNames.length === 0 || summaryNames.length !== currentNames.length) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return areNameKeyListsEqual(summaryNames, currentNames);
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildSummaryFallbackMemberSnapshots(
|
||||||
|
snapshot: TeamViewSnapshot,
|
||||||
|
summary: TeamSummary | undefined
|
||||||
|
): TeamMemberSnapshot[] {
|
||||||
|
if (!summary) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
const summaryMembers = getSummaryFallbackTeammateSources(summary);
|
||||||
|
if (summaryMembers.length === 0) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const seenNames = new Set<string>();
|
||||||
|
const buildSnapshot = (
|
||||||
|
name: string,
|
||||||
|
source?: Omit<SummaryFallbackMemberSource, 'name'>,
|
||||||
|
lead = false
|
||||||
|
): TeamMemberSnapshot | null => {
|
||||||
|
const trimmed = name.trim();
|
||||||
|
if (!trimmed) return null;
|
||||||
|
const key = trimmed.toLowerCase();
|
||||||
|
if (seenNames.has(key)) return null;
|
||||||
|
seenNames.add(key);
|
||||||
|
|
||||||
|
const ownedTasks = snapshot.tasks.filter((task) => task.owner === trimmed);
|
||||||
|
const currentTask = ownedTasks.find(isDisplayableFallbackCurrentTask);
|
||||||
|
return {
|
||||||
|
name: trimmed,
|
||||||
|
agentId: source?.agentId,
|
||||||
|
currentTaskId: currentTask?.id ?? null,
|
||||||
|
taskCount: ownedTasks.length,
|
||||||
|
color: source?.color ?? getMemberColorByName(trimmed),
|
||||||
|
agentType: lead ? 'team-lead' : undefined,
|
||||||
|
role: source?.role ?? (lead ? 'Team Lead' : undefined),
|
||||||
|
mcpPolicy: source?.mcpPolicy,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const teammates = summaryMembers.flatMap((member) => {
|
||||||
|
const item = buildSnapshot(member.name, member);
|
||||||
|
return item ? [item] : [];
|
||||||
|
});
|
||||||
|
if (teammates.length === 0) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const existingLead = snapshot.members.find((member) => !member.removedAt && isLeadMember(member));
|
||||||
|
if (existingLead) {
|
||||||
|
return [existingLead, ...teammates];
|
||||||
|
}
|
||||||
|
|
||||||
|
const configuredLead = snapshot.config.members?.find(
|
||||||
|
(member) => !member.removedAt && isLeadMember(member)
|
||||||
|
);
|
||||||
|
const leadName = configuredLead?.name?.trim() || summary.leadName?.trim();
|
||||||
|
const lead = leadName
|
||||||
|
? buildSnapshot(
|
||||||
|
leadName,
|
||||||
|
{
|
||||||
|
agentId: configuredLead?.agentId,
|
||||||
|
role: configuredLead?.role,
|
||||||
|
color: configuredLead?.color ?? summary.leadColor,
|
||||||
|
},
|
||||||
|
true
|
||||||
|
)
|
||||||
|
: null;
|
||||||
|
|
||||||
|
return lead ? [lead, ...teammates] : teammates;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getResolvableMemberSnapshots(
|
||||||
|
snapshot: TeamViewSnapshot,
|
||||||
|
summary?: TeamSummary
|
||||||
|
): readonly TeamMemberSnapshot[] {
|
||||||
|
if (
|
||||||
|
snapshot.members.length > 0 &&
|
||||||
|
(hasActiveRawTeammateRoster(snapshot) || hasRemovedRawMemberRoster(snapshot))
|
||||||
|
) {
|
||||||
|
return snapshot.members;
|
||||||
|
}
|
||||||
|
|
||||||
|
const configFallbackMembers = buildConfigFallbackMemberSnapshots(snapshot);
|
||||||
|
if (configFallbackMembers.length > 0) {
|
||||||
|
return configFallbackMembers;
|
||||||
|
}
|
||||||
|
|
||||||
|
const summaryFallbackMembers = buildSummaryFallbackMemberSnapshots(snapshot, summary);
|
||||||
|
if (summaryFallbackMembers.length > 0) {
|
||||||
|
return summaryFallbackMembers;
|
||||||
|
}
|
||||||
|
|
||||||
|
return snapshot.members;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function shouldPreserveSelectedTeamSnapshot(
|
||||||
|
current: TeamViewSnapshot | null,
|
||||||
|
baseline: TeamViewSnapshot | null | undefined,
|
||||||
|
incoming: TeamViewSnapshot,
|
||||||
|
summary: TeamSummary | undefined
|
||||||
|
): boolean {
|
||||||
|
if (!current || !hasActiveRawTeammateRoster(current)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
hasActiveRawTeammateRoster(incoming) ||
|
||||||
|
hasRemovedRawMemberRoster(incoming) ||
|
||||||
|
hasConfigTeammateRoster(incoming)
|
||||||
|
) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const currentNames = getActiveRawTeammateNameKeys(current);
|
||||||
|
if (
|
||||||
|
current !== baseline &&
|
||||||
|
!areNameKeyListsEqual(currentNames, getActiveRawTeammateNameKeys(baseline))
|
||||||
|
) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (summary) {
|
||||||
|
return summaryConfirmsActiveTeammateRoster(current, summary);
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildResolvedMember(
|
||||||
|
snapshot: TeamMemberSnapshot,
|
||||||
|
activity: MemberActivityMetaEntry | undefined
|
||||||
|
): ResolvedTeamMember {
|
||||||
|
return {
|
||||||
|
...snapshot,
|
||||||
|
status: resolveMemberStatus(snapshot, activity),
|
||||||
|
messageCount: activity?.messageCountExact ?? 0,
|
||||||
|
lastActiveAt: activity?.lastAuthoredMessageAt ?? null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function selectResolvedMembersForTeamName(
|
||||||
|
state: ResolvedMemberSelectorState,
|
||||||
|
teamName: string | null | undefined
|
||||||
|
): ResolvedTeamMember[] {
|
||||||
|
const snapshot = selectTeamDataForName(state, teamName);
|
||||||
|
if (!snapshot || !teamName) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const meta = state.memberActivityMetaByTeam[teamName];
|
||||||
|
const metaMembers = meta?.members;
|
||||||
|
const shouldUseMemberFallback =
|
||||||
|
snapshot.members.length === 0 ||
|
||||||
|
(!hasActiveRawTeammateRoster(snapshot) && !hasRemovedRawMemberRoster(snapshot));
|
||||||
|
const configMembersRef = shouldUseMemberFallback ? snapshot.config.members : undefined;
|
||||||
|
const summaryRef = shouldUseMemberFallback ? state.teamByName?.[teamName] : undefined;
|
||||||
|
const tasksRef = shouldUseMemberFallback ? snapshot.tasks : undefined;
|
||||||
|
const cached = resolvedMembersSelectorCache.get(teamName);
|
||||||
|
if (
|
||||||
|
cached?.snapshotRef === snapshot.members &&
|
||||||
|
cached.configMembersRef === configMembersRef &&
|
||||||
|
cached.summaryRef === summaryRef &&
|
||||||
|
cached.tasksRef === tasksRef &&
|
||||||
|
cached.metaMembersRef === metaMembers
|
||||||
|
) {
|
||||||
|
return cached.result;
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = buildResolvedMembers(getResolvableMemberSnapshots(snapshot, summaryRef), meta);
|
||||||
|
resolvedMembersSelectorCache.set(teamName, {
|
||||||
|
snapshotRef: snapshot.members,
|
||||||
|
configMembersRef,
|
||||||
|
summaryRef,
|
||||||
|
tasksRef,
|
||||||
|
metaMembersRef: metaMembers,
|
||||||
|
result,
|
||||||
|
});
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function selectResolvedMemberForTeamName(
|
||||||
|
state: ResolvedMemberSelectorState,
|
||||||
|
teamName: string | null | undefined,
|
||||||
|
memberName: string | null | undefined
|
||||||
|
): ResolvedTeamMember | null {
|
||||||
|
const snapshot = selectTeamDataForName(state, teamName);
|
||||||
|
if (!snapshot || !teamName || !memberName) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const snapshotMember = getResolvableMemberSnapshots(snapshot, state.teamByName?.[teamName]).find(
|
||||||
|
(member) => member.name === memberName
|
||||||
|
);
|
||||||
|
if (!snapshotMember) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const metaEntry = state.memberActivityMetaByTeam[teamName]?.members[memberName];
|
||||||
|
const cacheKey = `${teamName}:${memberName}`;
|
||||||
|
const cached = resolvedMemberSelectorCache.get(cacheKey);
|
||||||
|
if (cached?.snapshotMemberRef === snapshotMember && cached.metaEntryRef === metaEntry) {
|
||||||
|
return cached.result;
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = buildResolvedMember(snapshotMember, metaEntry);
|
||||||
|
resolvedMemberSelectorCache.set(cacheKey, {
|
||||||
|
snapshotMemberRef: snapshotMember,
|
||||||
|
metaEntryRef: metaEntry,
|
||||||
|
result,
|
||||||
|
});
|
||||||
|
return result;
|
||||||
|
}
|
||||||
190
src/renderer/store/team/teamScopedStateCleanup.ts
Normal file
190
src/renderer/store/team/teamScopedStateCleanup.ts
Normal file
|
|
@ -0,0 +1,190 @@
|
||||||
|
interface TeamMessagesLoadingEntry {
|
||||||
|
loadingHead: boolean;
|
||||||
|
loadingOlder: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TeamScopedVisibleLoadingResetState<
|
||||||
|
TTeamMessagesEntry extends TeamMessagesLoadingEntry,
|
||||||
|
> {
|
||||||
|
teamMessagesByName: Record<string, TTeamMessagesEntry>;
|
||||||
|
selectedTeamName: string | null;
|
||||||
|
selectedTeamLoading: boolean;
|
||||||
|
selectedTeamError: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TeamScopedProvisioningRun {
|
||||||
|
teamName: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
type TeamScopedRecord = Record<string, unknown>;
|
||||||
|
|
||||||
|
interface TeamScopedStateRemovalState<
|
||||||
|
TProvisioningRun extends TeamScopedProvisioningRun = TeamScopedProvisioningRun,
|
||||||
|
> {
|
||||||
|
provisioningRuns: Record<string, TProvisioningRun>;
|
||||||
|
teamDataCacheByName: TeamScopedRecord;
|
||||||
|
teamAgentRuntimeByTeam: TeamScopedRecord;
|
||||||
|
teamMessagesByName: TeamScopedRecord;
|
||||||
|
memberActivityMetaByTeam: TeamScopedRecord;
|
||||||
|
provisioningSnapshotByTeam: TeamScopedRecord;
|
||||||
|
currentProvisioningRunIdByTeam: TeamScopedRecord;
|
||||||
|
currentRuntimeRunIdByTeam: TeamScopedRecord;
|
||||||
|
provisioningStartedAtFloorByTeam: TeamScopedRecord;
|
||||||
|
leadActivityByTeam: TeamScopedRecord;
|
||||||
|
leadContextByTeam: TeamScopedRecord;
|
||||||
|
activeTaskLogActivityByTeam: TeamScopedRecord;
|
||||||
|
activeToolsByTeam: TeamScopedRecord;
|
||||||
|
finishedVisibleByTeam: TeamScopedRecord;
|
||||||
|
toolHistoryByTeam: TeamScopedRecord;
|
||||||
|
memberSpawnStatusesByTeam: TeamScopedRecord;
|
||||||
|
memberSpawnSnapshotsByTeam: TeamScopedRecord;
|
||||||
|
provisioningErrorByTeam: TeamScopedRecord;
|
||||||
|
}
|
||||||
|
|
||||||
|
type TeamScopedStateRemovalKey = keyof TeamScopedStateRemovalState;
|
||||||
|
|
||||||
|
interface TeamScopedProgressTombstoneState {
|
||||||
|
currentProvisioningRunIdByTeam: Record<string, string | null | undefined>;
|
||||||
|
currentRuntimeRunIdByTeam: Record<string, string | null | undefined>;
|
||||||
|
ignoredProvisioningRunIds: Record<string, string>;
|
||||||
|
ignoredRuntimeRunIds: Record<string, string>;
|
||||||
|
provisioningStartedAtFloorByTeam: Record<string, string>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function collectTeamScopedVisibleLoadingResets<
|
||||||
|
TTeamMessagesEntry extends TeamMessagesLoadingEntry,
|
||||||
|
>(
|
||||||
|
state: TeamScopedVisibleLoadingResetState<TTeamMessagesEntry>,
|
||||||
|
teamName: string
|
||||||
|
): Partial<TeamScopedVisibleLoadingResetState<TTeamMessagesEntry>> {
|
||||||
|
const nextTeamMessagesEntry = state.teamMessagesByName[teamName];
|
||||||
|
const nextTeamMessagesByName =
|
||||||
|
nextTeamMessagesEntry &&
|
||||||
|
(nextTeamMessagesEntry.loadingHead || nextTeamMessagesEntry.loadingOlder)
|
||||||
|
? {
|
||||||
|
...state.teamMessagesByName,
|
||||||
|
[teamName]: {
|
||||||
|
...nextTeamMessagesEntry,
|
||||||
|
loadingHead: false,
|
||||||
|
loadingOlder: false,
|
||||||
|
} as TTeamMessagesEntry,
|
||||||
|
}
|
||||||
|
: null;
|
||||||
|
|
||||||
|
const shouldResetSelectedSurface =
|
||||||
|
state.selectedTeamName === teamName &&
|
||||||
|
(state.selectedTeamLoading || state.selectedTeamError != null);
|
||||||
|
|
||||||
|
return {
|
||||||
|
...(nextTeamMessagesByName ? { teamMessagesByName: nextTeamMessagesByName } : {}),
|
||||||
|
...(shouldResetSelectedSurface
|
||||||
|
? {
|
||||||
|
selectedTeamLoading: false,
|
||||||
|
selectedTeamError: null,
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function omitTeamKey<TRecord extends Record<string, unknown>>(
|
||||||
|
record: TRecord,
|
||||||
|
teamName: string
|
||||||
|
): TRecord | null {
|
||||||
|
if (!(teamName in record)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const next = { ...record };
|
||||||
|
delete next[teamName];
|
||||||
|
return next;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function collectTeamScopedStateRemovals<TState extends TeamScopedStateRemovalState>(
|
||||||
|
state: TState,
|
||||||
|
teamName: string
|
||||||
|
): Partial<Pick<TState, TeamScopedStateRemovalKey>> {
|
||||||
|
const nextProvisioningRuns = Object.fromEntries(
|
||||||
|
Object.entries(state.provisioningRuns).filter(([, run]) => run.teamName !== teamName)
|
||||||
|
) as TState['provisioningRuns'];
|
||||||
|
const nextTeamDataCache = omitTeamKey(state.teamDataCacheByName, teamName);
|
||||||
|
const nextTeamAgentRuntime = omitTeamKey(state.teamAgentRuntimeByTeam, teamName);
|
||||||
|
const nextTeamMessages = omitTeamKey(state.teamMessagesByName, teamName);
|
||||||
|
const nextMemberActivityMeta = omitTeamKey(state.memberActivityMetaByTeam, teamName);
|
||||||
|
const nextProvisioningSnapshot = omitTeamKey(state.provisioningSnapshotByTeam, teamName);
|
||||||
|
const nextCurrentProvisioningRunId = omitTeamKey(state.currentProvisioningRunIdByTeam, teamName);
|
||||||
|
const nextCurrentRuntimeRunId = omitTeamKey(state.currentRuntimeRunIdByTeam, teamName);
|
||||||
|
const nextProvisioningStartedAtFloor = omitTeamKey(
|
||||||
|
state.provisioningStartedAtFloorByTeam,
|
||||||
|
teamName
|
||||||
|
);
|
||||||
|
const nextLeadActivity = omitTeamKey(state.leadActivityByTeam, teamName);
|
||||||
|
const nextLeadContext = omitTeamKey(state.leadContextByTeam, teamName);
|
||||||
|
const nextActiveTaskLogActivity = omitTeamKey(state.activeTaskLogActivityByTeam, teamName);
|
||||||
|
const nextActiveTools = omitTeamKey(state.activeToolsByTeam, teamName);
|
||||||
|
const nextFinishedVisible = omitTeamKey(state.finishedVisibleByTeam, teamName);
|
||||||
|
const nextToolHistory = omitTeamKey(state.toolHistoryByTeam, teamName);
|
||||||
|
const nextMemberSpawnStatuses = omitTeamKey(state.memberSpawnStatusesByTeam, teamName);
|
||||||
|
const nextMemberSpawnSnapshots = omitTeamKey(state.memberSpawnSnapshotsByTeam, teamName);
|
||||||
|
const nextProvisioningErrors = omitTeamKey(state.provisioningErrorByTeam, teamName);
|
||||||
|
|
||||||
|
return {
|
||||||
|
...(Object.keys(nextProvisioningRuns).length !== Object.keys(state.provisioningRuns).length
|
||||||
|
? { provisioningRuns: nextProvisioningRuns }
|
||||||
|
: {}),
|
||||||
|
...(nextTeamDataCache ? { teamDataCacheByName: nextTeamDataCache } : {}),
|
||||||
|
...(nextTeamAgentRuntime ? { teamAgentRuntimeByTeam: nextTeamAgentRuntime } : {}),
|
||||||
|
...(nextTeamMessages ? { teamMessagesByName: nextTeamMessages } : {}),
|
||||||
|
...(nextMemberActivityMeta ? { memberActivityMetaByTeam: nextMemberActivityMeta } : {}),
|
||||||
|
...(nextProvisioningSnapshot ? { provisioningSnapshotByTeam: nextProvisioningSnapshot } : {}),
|
||||||
|
...(nextCurrentProvisioningRunId
|
||||||
|
? { currentProvisioningRunIdByTeam: nextCurrentProvisioningRunId }
|
||||||
|
: {}),
|
||||||
|
...(nextCurrentRuntimeRunId ? { currentRuntimeRunIdByTeam: nextCurrentRuntimeRunId } : {}),
|
||||||
|
...(nextProvisioningStartedAtFloor
|
||||||
|
? { provisioningStartedAtFloorByTeam: nextProvisioningStartedAtFloor }
|
||||||
|
: {}),
|
||||||
|
...(nextLeadActivity ? { leadActivityByTeam: nextLeadActivity } : {}),
|
||||||
|
...(nextLeadContext ? { leadContextByTeam: nextLeadContext } : {}),
|
||||||
|
...(nextActiveTaskLogActivity
|
||||||
|
? { activeTaskLogActivityByTeam: nextActiveTaskLogActivity }
|
||||||
|
: {}),
|
||||||
|
...(nextActiveTools ? { activeToolsByTeam: nextActiveTools } : {}),
|
||||||
|
...(nextFinishedVisible ? { finishedVisibleByTeam: nextFinishedVisible } : {}),
|
||||||
|
...(nextToolHistory ? { toolHistoryByTeam: nextToolHistory } : {}),
|
||||||
|
...(nextMemberSpawnStatuses ? { memberSpawnStatusesByTeam: nextMemberSpawnStatuses } : {}),
|
||||||
|
...(nextMemberSpawnSnapshots ? { memberSpawnSnapshotsByTeam: nextMemberSpawnSnapshots } : {}),
|
||||||
|
...(nextProvisioningErrors ? { provisioningErrorByTeam: nextProvisioningErrors } : {}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildTeamScopedProgressTombstones<TState extends TeamScopedProgressTombstoneState>(
|
||||||
|
state: TState,
|
||||||
|
teamName: string,
|
||||||
|
floor: string
|
||||||
|
): Pick<
|
||||||
|
TState,
|
||||||
|
'ignoredProvisioningRunIds' | 'ignoredRuntimeRunIds' | 'provisioningStartedAtFloorByTeam'
|
||||||
|
> {
|
||||||
|
const nextIgnoredProvisioningRunIds = { ...state.ignoredProvisioningRunIds };
|
||||||
|
const nextIgnoredRuntimeRunIds = { ...state.ignoredRuntimeRunIds };
|
||||||
|
|
||||||
|
const currentProvisioningRunId = state.currentProvisioningRunIdByTeam[teamName];
|
||||||
|
const currentRuntimeRunId = state.currentRuntimeRunIdByTeam[teamName];
|
||||||
|
if (currentProvisioningRunId) {
|
||||||
|
nextIgnoredProvisioningRunIds[currentProvisioningRunId] = teamName;
|
||||||
|
}
|
||||||
|
if (currentRuntimeRunId) {
|
||||||
|
nextIgnoredRuntimeRunIds[currentRuntimeRunId] = teamName;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
ignoredProvisioningRunIds: nextIgnoredProvisioningRunIds,
|
||||||
|
ignoredRuntimeRunIds: nextIgnoredRuntimeRunIds,
|
||||||
|
provisioningStartedAtFloorByTeam: {
|
||||||
|
...state.provisioningStartedAtFloorByTeam,
|
||||||
|
[teamName]: floor,
|
||||||
|
},
|
||||||
|
} as Pick<
|
||||||
|
TState,
|
||||||
|
'ignoredProvisioningRunIds' | 'ignoredRuntimeRunIds' | 'provisioningStartedAtFloorByTeam'
|
||||||
|
>;
|
||||||
|
}
|
||||||
61
src/renderer/store/team/teamSnapshotStructuralSharing.ts
Normal file
61
src/renderer/store/team/teamSnapshotStructuralSharing.ts
Normal file
|
|
@ -0,0 +1,61 @@
|
||||||
|
import type { TeamViewSnapshot } from '@shared/types';
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function structurallySharePlainValue<T>(previous: T, next: T): T {
|
||||||
|
if (Object.is(previous, next)) {
|
||||||
|
return previous;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Array.isArray(previous) && Array.isArray(next)) {
|
||||||
|
let changed = previous.length !== next.length;
|
||||||
|
const result = next.map((nextItem, index) => {
|
||||||
|
const sharedItem = structurallySharePlainValue(previous[index], nextItem);
|
||||||
|
if (!Object.is(sharedItem, previous[index])) {
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
return sharedItem;
|
||||||
|
});
|
||||||
|
return changed ? (result as T) : previous;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isPlainObject(previous) && isPlainObject(next)) {
|
||||||
|
const previousRecord = previous as Record<string, unknown>;
|
||||||
|
const nextRecord = next as Record<string, unknown>;
|
||||||
|
const previousKeys = Object.keys(previousRecord);
|
||||||
|
const nextKeys = Object.keys(nextRecord);
|
||||||
|
let changed = previousKeys.length !== nextKeys.length;
|
||||||
|
const result: Record<string, unknown> = {};
|
||||||
|
|
||||||
|
for (const key of nextKeys) {
|
||||||
|
if (!Object.prototype.hasOwnProperty.call(previousRecord, key)) {
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
const sharedValue = structurallySharePlainValue(previousRecord[key], nextRecord[key]);
|
||||||
|
if (!Object.is(sharedValue, previousRecord[key])) {
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
result[key] = sharedValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
return changed ? (result as T) : previous;
|
||||||
|
}
|
||||||
|
|
||||||
|
return next;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function structurallyShareTeamSnapshot(
|
||||||
|
previous: TeamViewSnapshot | null | undefined,
|
||||||
|
next: TeamViewSnapshot
|
||||||
|
): TeamViewSnapshot {
|
||||||
|
if (!previous) {
|
||||||
|
return next;
|
||||||
|
}
|
||||||
|
return structurallySharePlainValue(previous, next);
|
||||||
|
}
|
||||||
42
src/renderer/store/team/teamToolApprovalSettings.ts
Normal file
42
src/renderer/store/team/teamToolApprovalSettings.ts
Normal file
|
|
@ -0,0 +1,42 @@
|
||||||
|
import { DEFAULT_TOOL_APPROVAL_SETTINGS } from '@shared/types/team';
|
||||||
|
|
||||||
|
import type { ToolApprovalSettings } from '@shared/types';
|
||||||
|
|
||||||
|
const VALID_TIMEOUT_ACTIONS: ReadonlySet<ToolApprovalSettings['timeoutAction']> = new Set([
|
||||||
|
'allow',
|
||||||
|
'deny',
|
||||||
|
'wait',
|
||||||
|
]);
|
||||||
|
|
||||||
|
export function parseToolApprovalSettings(raw: string | null): ToolApprovalSettings {
|
||||||
|
if (!raw) return DEFAULT_TOOL_APPROVAL_SETTINGS;
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(raw) as Record<string, unknown>;
|
||||||
|
const d = DEFAULT_TOOL_APPROVAL_SETTINGS;
|
||||||
|
return {
|
||||||
|
autoAllowAll: typeof parsed.autoAllowAll === 'boolean' ? parsed.autoAllowAll : d.autoAllowAll,
|
||||||
|
autoAllowFileEdits:
|
||||||
|
typeof parsed.autoAllowFileEdits === 'boolean'
|
||||||
|
? parsed.autoAllowFileEdits
|
||||||
|
: d.autoAllowFileEdits,
|
||||||
|
autoAllowSafeBash:
|
||||||
|
typeof parsed.autoAllowSafeBash === 'boolean'
|
||||||
|
? parsed.autoAllowSafeBash
|
||||||
|
: d.autoAllowSafeBash,
|
||||||
|
timeoutAction:
|
||||||
|
typeof parsed.timeoutAction === 'string' &&
|
||||||
|
VALID_TIMEOUT_ACTIONS.has(parsed.timeoutAction as ToolApprovalSettings['timeoutAction'])
|
||||||
|
? (parsed.timeoutAction as ToolApprovalSettings['timeoutAction'])
|
||||||
|
: d.timeoutAction,
|
||||||
|
timeoutSeconds:
|
||||||
|
typeof parsed.timeoutSeconds === 'number' &&
|
||||||
|
Number.isFinite(parsed.timeoutSeconds) &&
|
||||||
|
parsed.timeoutSeconds >= 5 &&
|
||||||
|
parsed.timeoutSeconds <= 300
|
||||||
|
? parsed.timeoutSeconds
|
||||||
|
: d.timeoutSeconds,
|
||||||
|
};
|
||||||
|
} catch {
|
||||||
|
return DEFAULT_TOOL_APPROVAL_SETTINGS;
|
||||||
|
}
|
||||||
|
}
|
||||||
171
test/main/ipc/teams/teamMessageNotificationScanner.test.ts
Normal file
171
test/main/ipc/teams/teamMessageNotificationScanner.test.ts
Normal file
|
|
@ -0,0 +1,171 @@
|
||||||
|
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
|
import {
|
||||||
|
TeamMessageNotificationScanner,
|
||||||
|
type TeamNotificationMessage,
|
||||||
|
} from '../../../../src/main/ipc/teams/teamMessageNotificationScanner';
|
||||||
|
|
||||||
|
import type { RateLimitAutoResumePlan } from '../../../../src/main/services/team/AutoResumeService';
|
||||||
|
import type { TeamNotificationPayload } from '../../../../src/main/utils/teamNotificationBuilder';
|
||||||
|
|
||||||
|
function createMessage(overrides: Partial<TeamNotificationMessage> = {}): TeamNotificationMessage {
|
||||||
|
return {
|
||||||
|
from: 'team-lead',
|
||||||
|
text: "You've hit your limit. Resets in 5 minutes.",
|
||||||
|
timestamp: '2026-04-17T12:00:00.000Z',
|
||||||
|
messageId: 'msg-1',
|
||||||
|
source: 'lead_session',
|
||||||
|
leadSessionId: 'sess-live',
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('TeamMessageNotificationScanner', () => {
|
||||||
|
const notificationSink = {
|
||||||
|
addTeamNotification: vi.fn<() => Promise<unknown>>(),
|
||||||
|
};
|
||||||
|
const autoResumeSink = {
|
||||||
|
handleRateLimitMessage: vi.fn(),
|
||||||
|
};
|
||||||
|
let autoResumeEnabled = true;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
notificationSink.addTeamNotification.mockReset();
|
||||||
|
notificationSink.addTeamNotification.mockResolvedValue(null);
|
||||||
|
autoResumeSink.handleRateLimitMessage.mockReset();
|
||||||
|
autoResumeEnabled = true;
|
||||||
|
});
|
||||||
|
|
||||||
|
function createScanner(options?: {
|
||||||
|
isRateLimit?: (text: string) => boolean;
|
||||||
|
isApiError?: (text: string) => boolean;
|
||||||
|
planAutoResume?: (input: {
|
||||||
|
enabled: boolean;
|
||||||
|
canAutoResume: boolean;
|
||||||
|
messageText: string;
|
||||||
|
observedAt: Date;
|
||||||
|
messageTimestamp?: Date;
|
||||||
|
}) => RateLimitAutoResumePlan;
|
||||||
|
}): TeamMessageNotificationScanner {
|
||||||
|
return new TeamMessageNotificationScanner({
|
||||||
|
configReader: {
|
||||||
|
getConfig: () => ({ notifications: { autoResumeOnRateLimit: autoResumeEnabled } }),
|
||||||
|
},
|
||||||
|
notificationSink,
|
||||||
|
autoResumeSink,
|
||||||
|
now: () => new Date('2026-04-17T12:02:00.000Z'),
|
||||||
|
formatClockTime: () => '12:05',
|
||||||
|
isRateLimit: options?.isRateLimit ?? ((text) => text.includes('limit')),
|
||||||
|
isApiError: options?.isApiError ?? ((text) => text.startsWith('API Error:')),
|
||||||
|
planAutoResume:
|
||||||
|
options?.planAutoResume ??
|
||||||
|
((input) =>
|
||||||
|
input.enabled && input.canAutoResume
|
||||||
|
? {
|
||||||
|
kind: 'scheduled',
|
||||||
|
resetTime: new Date('2026-04-17T12:05:00.000Z'),
|
||||||
|
delayMs: 180_000,
|
||||||
|
fireAtMs: Date.parse('2026-04-17T12:05:30.000Z'),
|
||||||
|
rawDelayMs: 180_000,
|
||||||
|
}
|
||||||
|
: { kind: 'manual', reason: 'disabled' }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
it('notifies and schedules auto-resume for a live lead rate-limit message', () => {
|
||||||
|
const scanner = createScanner();
|
||||||
|
|
||||||
|
scanner.checkRateLimitMessages([createMessage()], {
|
||||||
|
teamName: 'my-team',
|
||||||
|
teamDisplayName: 'My Team',
|
||||||
|
projectPath: '/tmp/project',
|
||||||
|
teamIsAlive: true,
|
||||||
|
currentLeadSessionId: 'sess-live',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(notificationSink.addTeamNotification).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining<TeamNotificationPayload>({
|
||||||
|
teamEventType: 'rate_limit',
|
||||||
|
teamName: 'my-team',
|
||||||
|
teamDisplayName: 'My Team',
|
||||||
|
from: 'team-lead',
|
||||||
|
summary: 'Rate limit',
|
||||||
|
body: 'Auto-resume scheduled at 12:05',
|
||||||
|
dedupeKey: 'rate-limit:my-team:msg-1',
|
||||||
|
target: { kind: 'member', teamName: 'my-team', memberName: 'team-lead', focus: 'logs' },
|
||||||
|
projectPath: '/tmp/project',
|
||||||
|
})
|
||||||
|
);
|
||||||
|
expect(autoResumeSink.handleRateLimitMessage).toHaveBeenCalledWith(
|
||||||
|
'my-team',
|
||||||
|
"You've hit your limit. Resets in 5 minutes.",
|
||||||
|
new Date('2026-04-17T12:02:00.000Z'),
|
||||||
|
new Date('2026-04-17T12:00:00.000Z')
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('dedupes notification storage but still re-evaluates auto-resume later', () => {
|
||||||
|
const scanner = createScanner();
|
||||||
|
const context = {
|
||||||
|
teamName: 'my-team',
|
||||||
|
teamDisplayName: 'My Team',
|
||||||
|
teamIsAlive: true,
|
||||||
|
currentLeadSessionId: 'sess-live',
|
||||||
|
};
|
||||||
|
|
||||||
|
autoResumeEnabled = false;
|
||||||
|
scanner.checkRateLimitMessages([createMessage()], context);
|
||||||
|
expect(notificationSink.addTeamNotification).toHaveBeenCalledTimes(1);
|
||||||
|
expect(autoResumeSink.handleRateLimitMessage).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
autoResumeEnabled = true;
|
||||||
|
scanner.checkRateLimitMessages([createMessage()], context);
|
||||||
|
|
||||||
|
expect(notificationSink.addTeamNotification).toHaveBeenCalledTimes(1);
|
||||||
|
expect(autoResumeSink.handleRateLimitMessage).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not schedule auto-resume from an older lead session', () => {
|
||||||
|
const scanner = createScanner();
|
||||||
|
|
||||||
|
scanner.checkRateLimitMessages(
|
||||||
|
[createMessage({ leadSessionId: 'sess-old', messageId: 'old-session' })],
|
||||||
|
{
|
||||||
|
teamName: 'my-team',
|
||||||
|
teamDisplayName: 'My Team',
|
||||||
|
teamIsAlive: true,
|
||||||
|
currentLeadSessionId: 'sess-live',
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(notificationSink.addTeamNotification).toHaveBeenCalledTimes(1);
|
||||||
|
expect(autoResumeSink.handleRateLimitMessage).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('sends API-error notifications while leaving rate limits to the rate-limit path', () => {
|
||||||
|
const scanner = createScanner({
|
||||||
|
isRateLimit: (text) => text.includes('429'),
|
||||||
|
isApiError: (text) => text.startsWith('API Error:'),
|
||||||
|
});
|
||||||
|
|
||||||
|
scanner.checkApiErrorMessages(
|
||||||
|
[
|
||||||
|
createMessage({ text: 'API Error: 429 rate limited', messageId: 'rate-limit-api' }),
|
||||||
|
createMessage({ text: 'API Error: 500 server failed', messageId: 'api-500' }),
|
||||||
|
],
|
||||||
|
{
|
||||||
|
teamName: 'my-team',
|
||||||
|
teamDisplayName: 'My Team',
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(notificationSink.addTeamNotification).toHaveBeenCalledTimes(1);
|
||||||
|
expect(notificationSink.addTeamNotification).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
teamEventType: 'api_error',
|
||||||
|
summary: 'API Error 500',
|
||||||
|
dedupeKey: 'api-error:my-team:api-500',
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
204
test/renderer/store/teamAgentRuntimeSnapshotEquality.test.ts
Normal file
204
test/renderer/store/teamAgentRuntimeSnapshotEquality.test.ts
Normal file
|
|
@ -0,0 +1,204 @@
|
||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
|
import {
|
||||||
|
areTeamAgentRuntimeEntriesEqual,
|
||||||
|
areTeamAgentRuntimeResourceSamplesEqual,
|
||||||
|
areTeamAgentRuntimeSnapshotsEqual,
|
||||||
|
} from '../../../src/renderer/store/team/teamAgentRuntimeSnapshotEquality';
|
||||||
|
|
||||||
|
import type {
|
||||||
|
TeamAgentRuntimeEntry,
|
||||||
|
TeamAgentRuntimeResourceSample,
|
||||||
|
TeamAgentRuntimeSnapshot,
|
||||||
|
} from '../../../src/shared/types';
|
||||||
|
|
||||||
|
function createResourceSample(
|
||||||
|
overrides: Partial<TeamAgentRuntimeResourceSample> = {}
|
||||||
|
): TeamAgentRuntimeResourceSample {
|
||||||
|
return {
|
||||||
|
timestamp: '2026-05-22T10:00:00.000Z',
|
||||||
|
cpuPercent: 4,
|
||||||
|
rssBytes: 1024,
|
||||||
|
primaryCpuPercent: 3,
|
||||||
|
primaryRssBytes: 768,
|
||||||
|
childCpuPercent: 1,
|
||||||
|
childRssBytes: 256,
|
||||||
|
processCount: 2,
|
||||||
|
runtimeLoadScope: 'process-tree',
|
||||||
|
runtimeLoadTruncated: false,
|
||||||
|
pidSource: 'agent_process_table',
|
||||||
|
pid: 111,
|
||||||
|
runtimePid: 222,
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function createRuntimeEntry(overrides: Partial<TeamAgentRuntimeEntry> = {}): TeamAgentRuntimeEntry {
|
||||||
|
return {
|
||||||
|
memberName: 'alice',
|
||||||
|
alive: true,
|
||||||
|
restartable: true,
|
||||||
|
backendType: 'process',
|
||||||
|
providerId: 'codex',
|
||||||
|
providerBackendId: 'codex-native',
|
||||||
|
laneId: 'lane-1',
|
||||||
|
laneKind: 'primary',
|
||||||
|
pid: 111,
|
||||||
|
runtimeModel: 'gpt-5.3-codex',
|
||||||
|
cwd: '/tmp/old',
|
||||||
|
rssBytes: 1024,
|
||||||
|
cpuPercent: 4,
|
||||||
|
primaryCpuPercent: 3,
|
||||||
|
primaryRssBytes: 768,
|
||||||
|
childCpuPercent: 1,
|
||||||
|
childRssBytes: 256,
|
||||||
|
processCount: 2,
|
||||||
|
runtimeLoadScope: 'process-tree',
|
||||||
|
runtimeLoadTruncated: false,
|
||||||
|
resourceHistory: [createResourceSample()],
|
||||||
|
livenessKind: 'confirmed_bootstrap',
|
||||||
|
pidSource: 'agent_process_table',
|
||||||
|
processCommand: 'codex',
|
||||||
|
paneId: '%1',
|
||||||
|
panePid: 333,
|
||||||
|
paneCurrentCommand: 'node',
|
||||||
|
runtimePid: 222,
|
||||||
|
runtimeSessionId: 'runtime-session-1',
|
||||||
|
runtimeLeaseExpiresAt: '2026-05-22T10:10:00.000Z',
|
||||||
|
runtimeLastSeenAt: '2026-05-22T10:00:00.000Z',
|
||||||
|
historicalBootstrapConfirmed: true,
|
||||||
|
runtimeDiagnostic: 'Ready',
|
||||||
|
runtimeDiagnosticSeverity: 'info',
|
||||||
|
diagnostics: ['healthy'],
|
||||||
|
updatedAt: '2026-05-22T10:00:00.000Z',
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function createRuntimeSnapshot(
|
||||||
|
overrides: Partial<TeamAgentRuntimeSnapshot> = {}
|
||||||
|
): TeamAgentRuntimeSnapshot {
|
||||||
|
return {
|
||||||
|
teamName: 'my-team',
|
||||||
|
updatedAt: '2026-05-22T10:00:00.000Z',
|
||||||
|
runId: 'run-1',
|
||||||
|
providerBackendId: 'codex-native',
|
||||||
|
fastMode: 'inherit',
|
||||||
|
members: {
|
||||||
|
alice: createRuntimeEntry(),
|
||||||
|
},
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('teamAgentRuntimeSnapshotEquality', () => {
|
||||||
|
it('compares runtime resource samples by visible process metrics', () => {
|
||||||
|
expect(
|
||||||
|
areTeamAgentRuntimeResourceSamplesEqual(createResourceSample(), createResourceSample())
|
||||||
|
).toBe(true);
|
||||||
|
expect(
|
||||||
|
areTeamAgentRuntimeResourceSamplesEqual(
|
||||||
|
createResourceSample(),
|
||||||
|
createResourceSample({ cpuPercent: 5 })
|
||||||
|
)
|
||||||
|
).toBe(false);
|
||||||
|
expect(areTeamAgentRuntimeResourceSamplesEqual(null, createResourceSample())).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ignores runtime entry fields that do not currently affect equality', () => {
|
||||||
|
const left = createRuntimeEntry({
|
||||||
|
cwd: '/tmp/old',
|
||||||
|
runtimeLeaseExpiresAt: '2026-05-22T10:10:00.000Z',
|
||||||
|
updatedAt: '2026-05-22T10:00:00.000Z',
|
||||||
|
});
|
||||||
|
const right = createRuntimeEntry({
|
||||||
|
cwd: '/tmp/new',
|
||||||
|
runtimeLeaseExpiresAt: '2026-05-22T10:20:00.000Z',
|
||||||
|
updatedAt: '2026-05-22T10:05:00.000Z',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(areTeamAgentRuntimeEntriesEqual(left, right)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('detects visible runtime entry field changes', () => {
|
||||||
|
expect(
|
||||||
|
areTeamAgentRuntimeEntriesEqual(
|
||||||
|
createRuntimeEntry(),
|
||||||
|
createRuntimeEntry({ runtimeDiagnosticSeverity: 'warning' })
|
||||||
|
)
|
||||||
|
).toBe(false);
|
||||||
|
expect(
|
||||||
|
areTeamAgentRuntimeEntriesEqual(
|
||||||
|
createRuntimeEntry(),
|
||||||
|
createRuntimeEntry({ resourceHistory: [createResourceSample({ rssBytes: 2048 })] })
|
||||||
|
)
|
||||||
|
).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('compares diagnostics and resource history arrays in stable order', () => {
|
||||||
|
expect(
|
||||||
|
areTeamAgentRuntimeEntriesEqual(
|
||||||
|
createRuntimeEntry({ diagnostics: ['a', 'b'] }),
|
||||||
|
createRuntimeEntry({ diagnostics: ['b', 'a'] })
|
||||||
|
)
|
||||||
|
).toBe(false);
|
||||||
|
expect(
|
||||||
|
areTeamAgentRuntimeEntriesEqual(
|
||||||
|
createRuntimeEntry({
|
||||||
|
resourceHistory: [
|
||||||
|
createResourceSample({ timestamp: '2026-05-22T10:00:00.000Z' }),
|
||||||
|
createResourceSample({ timestamp: '2026-05-22T10:01:00.000Z' }),
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
createRuntimeEntry({
|
||||||
|
resourceHistory: [
|
||||||
|
createResourceSample({ timestamp: '2026-05-22T10:01:00.000Z' }),
|
||||||
|
createResourceSample({ timestamp: '2026-05-22T10:00:00.000Z' }),
|
||||||
|
],
|
||||||
|
})
|
||||||
|
)
|
||||||
|
).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('compares runtime snapshots by team, run id, and semantic member entries', () => {
|
||||||
|
expect(areTeamAgentRuntimeSnapshotsEqual(createRuntimeSnapshot(), createRuntimeSnapshot())).toBe(
|
||||||
|
true
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
areTeamAgentRuntimeSnapshotsEqual(
|
||||||
|
createRuntimeSnapshot(),
|
||||||
|
createRuntimeSnapshot({ runId: 'run-2' })
|
||||||
|
)
|
||||||
|
).toBe(false);
|
||||||
|
expect(
|
||||||
|
areTeamAgentRuntimeSnapshotsEqual(
|
||||||
|
createRuntimeSnapshot(),
|
||||||
|
createRuntimeSnapshot({
|
||||||
|
members: {
|
||||||
|
alice: createRuntimeEntry(),
|
||||||
|
bob: createRuntimeEntry({ memberName: 'bob' }),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
)
|
||||||
|
).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ignores snapshot metadata fields that do not currently affect equality', () => {
|
||||||
|
const left = createRuntimeSnapshot({
|
||||||
|
updatedAt: '2026-05-22T10:00:00.000Z',
|
||||||
|
providerBackendId: 'codex-native',
|
||||||
|
fastMode: 'inherit',
|
||||||
|
});
|
||||||
|
const right = createRuntimeSnapshot({
|
||||||
|
updatedAt: '2026-05-22T10:05:00.000Z',
|
||||||
|
providerBackendId: 'api',
|
||||||
|
fastMode: 'on',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(areTeamAgentRuntimeSnapshotsEqual(left, right)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns false when there is no previous runtime snapshot', () => {
|
||||||
|
expect(areTeamAgentRuntimeSnapshotsEqual(undefined, createRuntimeSnapshot())).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
60
test/renderer/store/teamDataRefreshTimestamps.test.ts
Normal file
60
test/renderer/store/teamDataRefreshTimestamps.test.ts
Normal file
|
|
@ -0,0 +1,60 @@
|
||||||
|
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
|
import {
|
||||||
|
clearAllLastResolvedTeamDataRefreshes,
|
||||||
|
clearLastResolvedTeamDataRefreshAt,
|
||||||
|
getLastResolvedTeamDataRefreshAt,
|
||||||
|
hasLastResolvedTeamDataRefreshAt,
|
||||||
|
recordLastResolvedTeamDataRefresh,
|
||||||
|
} from '../../../src/renderer/store/team/teamDataRefreshTimestamps';
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.useRealTimers();
|
||||||
|
clearAllLastResolvedTeamDataRefreshes();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('teamDataRefreshTimestamps', () => {
|
||||||
|
it('returns undefined for teams without a recorded refresh', () => {
|
||||||
|
expect(getLastResolvedTeamDataRefreshAt('my-team')).toBeUndefined();
|
||||||
|
expect(hasLastResolvedTeamDataRefreshAt('my-team')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('records explicit refresh timestamps by team', () => {
|
||||||
|
recordLastResolvedTeamDataRefresh('my-team', 100);
|
||||||
|
recordLastResolvedTeamDataRefresh('other-team', 200);
|
||||||
|
|
||||||
|
expect(getLastResolvedTeamDataRefreshAt('my-team')).toBe(100);
|
||||||
|
expect(getLastResolvedTeamDataRefreshAt('other-team')).toBe(200);
|
||||||
|
expect(hasLastResolvedTeamDataRefreshAt('my-team')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses Date.now by default to preserve current call-site behavior', () => {
|
||||||
|
vi.setSystemTime(new Date('2026-05-22T06:30:00.000Z'));
|
||||||
|
|
||||||
|
recordLastResolvedTeamDataRefresh('my-team');
|
||||||
|
|
||||||
|
expect(getLastResolvedTeamDataRefreshAt('my-team')).toBe(
|
||||||
|
new Date('2026-05-22T06:30:00.000Z').getTime()
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('clears one team timestamp without touching other teams', () => {
|
||||||
|
recordLastResolvedTeamDataRefresh('my-team', 100);
|
||||||
|
recordLastResolvedTeamDataRefresh('other-team', 200);
|
||||||
|
|
||||||
|
clearLastResolvedTeamDataRefreshAt('my-team');
|
||||||
|
|
||||||
|
expect(getLastResolvedTeamDataRefreshAt('my-team')).toBeUndefined();
|
||||||
|
expect(getLastResolvedTeamDataRefreshAt('other-team')).toBe(200);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('clears all recorded timestamps', () => {
|
||||||
|
recordLastResolvedTeamDataRefresh('my-team', 100);
|
||||||
|
recordLastResolvedTeamDataRefresh('other-team', 200);
|
||||||
|
|
||||||
|
clearAllLastResolvedTeamDataRefreshes();
|
||||||
|
|
||||||
|
expect(hasLastResolvedTeamDataRefreshAt('my-team')).toBe(false);
|
||||||
|
expect(hasLastResolvedTeamDataRefreshAt('other-team')).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
58
test/renderer/store/teamDataRequestKeys.test.ts
Normal file
58
test/renderer/store/teamDataRequestKeys.test.ts
Normal file
|
|
@ -0,0 +1,58 @@
|
||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
|
import {
|
||||||
|
getFullTeamDataRequestKey,
|
||||||
|
getTeamDataRequestKey,
|
||||||
|
getTeamDataRequestLabel,
|
||||||
|
getTeamDataSnapshotMode,
|
||||||
|
getThinTeamDataRequestKey,
|
||||||
|
isTeamDataRequestKeyForTeam,
|
||||||
|
normalizeTeamGetDataOptions,
|
||||||
|
shouldIncludeMemberBranches,
|
||||||
|
} from '../../../src/renderer/store/team/teamDataRequestKeys';
|
||||||
|
|
||||||
|
describe('teamDataRequestKeys', () => {
|
||||||
|
it('normalizes only the thin snapshot option and treats all other inputs as full snapshots', () => {
|
||||||
|
expect(normalizeTeamGetDataOptions()).toBeUndefined();
|
||||||
|
expect(normalizeTeamGetDataOptions({})).toBeUndefined();
|
||||||
|
expect(normalizeTeamGetDataOptions({ includeMemberBranches: true })).toBeUndefined();
|
||||||
|
expect(normalizeTeamGetDataOptions({ includeMemberBranches: false })).toEqual({
|
||||||
|
includeMemberBranches: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(shouldIncludeMemberBranches()).toBe(true);
|
||||||
|
expect(shouldIncludeMemberBranches({ includeMemberBranches: true })).toBe(true);
|
||||||
|
expect(shouldIncludeMemberBranches({ includeMemberBranches: false })).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('maps normalized request options to stable full and thin snapshot modes', () => {
|
||||||
|
expect(getTeamDataSnapshotMode()).toBe('full');
|
||||||
|
expect(getTeamDataSnapshotMode({ includeMemberBranches: true })).toBe('full');
|
||||||
|
expect(getTeamDataSnapshotMode({ includeMemberBranches: false })).toBe('thin');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('builds request keys that preserve the existing null-separated team/mode contract', () => {
|
||||||
|
expect(getTeamDataRequestKey('my-team')).toBe('my-team\u0000mode:full');
|
||||||
|
expect(getTeamDataRequestKey('my-team', { includeMemberBranches: true })).toBe(
|
||||||
|
'my-team\u0000mode:full'
|
||||||
|
);
|
||||||
|
expect(getTeamDataRequestKey('my-team', { includeMemberBranches: false })).toBe(
|
||||||
|
'my-team\u0000mode:thin'
|
||||||
|
);
|
||||||
|
expect(getFullTeamDataRequestKey('my-team')).toBe('my-team\u0000mode:full');
|
||||||
|
expect(getThinTeamDataRequestKey('my-team')).toBe('my-team\u0000mode:thin');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('builds timeout/debug labels from the same normalized mode policy', () => {
|
||||||
|
expect(getTeamDataRequestLabel('my-team')).toBe('team:getData(my-team,mode=full)');
|
||||||
|
expect(getTeamDataRequestLabel('my-team', { includeMemberBranches: false })).toBe(
|
||||||
|
'team:getData(my-team,mode=thin)'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('matches request keys only for the exact team prefix boundary', () => {
|
||||||
|
expect(isTeamDataRequestKeyForTeam('my-team\u0000mode:full', 'my-team')).toBe(true);
|
||||||
|
expect(isTeamDataRequestKeyForTeam('my-team-extra\u0000mode:full', 'my-team')).toBe(false);
|
||||||
|
expect(isTeamDataRequestKeyForTeam('my-team', 'my-team')).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
95
test/renderer/store/teamDataSelectors.test.ts
Normal file
95
test/renderer/store/teamDataSelectors.test.ts
Normal file
|
|
@ -0,0 +1,95 @@
|
||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
|
import {
|
||||||
|
selectTeamDataForName,
|
||||||
|
selectTeamIsAliveForName,
|
||||||
|
selectTeamMemberSnapshotsForName,
|
||||||
|
selectTeamTasksForName,
|
||||||
|
type TeamDataSelectorState,
|
||||||
|
} from '../../../src/renderer/store/team/teamDataSelectors';
|
||||||
|
|
||||||
|
import type { TeamViewSnapshot } from '../../../src/shared/types';
|
||||||
|
|
||||||
|
function createSnapshot(overrides: Partial<TeamViewSnapshot> = {}): TeamViewSnapshot {
|
||||||
|
return {
|
||||||
|
teamName: 'my-team',
|
||||||
|
config: { name: 'My Team' },
|
||||||
|
members: [],
|
||||||
|
tasks: [],
|
||||||
|
kanbanState: { teamName: 'my-team', reviewers: [], tasks: {} },
|
||||||
|
processes: [],
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function createState(overrides: Partial<TeamDataSelectorState> = {}): TeamDataSelectorState {
|
||||||
|
return {
|
||||||
|
teamDataCacheByName: {},
|
||||||
|
selectedTeamName: null,
|
||||||
|
selectedTeamData: null,
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('teamDataSelectors', () => {
|
||||||
|
it('returns null when no team name is selected or cached', () => {
|
||||||
|
const state = createState();
|
||||||
|
|
||||||
|
expect(selectTeamDataForName(state, null)).toBeNull();
|
||||||
|
expect(selectTeamDataForName(state, undefined)).toBeNull();
|
||||||
|
expect(selectTeamDataForName(state, 'missing-team')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('prefers selected team data over cached data for the active team', () => {
|
||||||
|
const cachedSnapshot = createSnapshot({ teamName: 'my-team', isAlive: false });
|
||||||
|
const selectedSnapshot = createSnapshot({ teamName: 'my-team', isAlive: true });
|
||||||
|
const state = createState({
|
||||||
|
selectedTeamName: 'my-team',
|
||||||
|
selectedTeamData: selectedSnapshot,
|
||||||
|
teamDataCacheByName: {
|
||||||
|
'my-team': cachedSnapshot,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(selectTeamDataForName(state, 'my-team')).toBe(selectedSnapshot);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('falls back to cached team data outside the selected snapshot', () => {
|
||||||
|
const cachedSnapshot = createSnapshot({ teamName: 'cached-team', isAlive: true });
|
||||||
|
const state = createState({
|
||||||
|
selectedTeamName: 'other-team',
|
||||||
|
selectedTeamData: createSnapshot({ teamName: 'other-team' }),
|
||||||
|
teamDataCacheByName: {
|
||||||
|
'cached-team': cachedSnapshot,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(selectTeamDataForName(state, 'cached-team')).toBe(cachedSnapshot);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns stable empty arrays and scalar fields from team snapshots', () => {
|
||||||
|
const task = { id: 'task-1', subject: 'Build', status: 'pending' as const };
|
||||||
|
const member = { name: 'alice', role: 'developer', currentTaskId: null, taskCount: 0 };
|
||||||
|
const state = createState({
|
||||||
|
teamDataCacheByName: {
|
||||||
|
'my-team': createSnapshot({
|
||||||
|
members: [member],
|
||||||
|
tasks: [task],
|
||||||
|
isAlive: true,
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(selectTeamMemberSnapshotsForName(state, 'my-team')).toEqual([member]);
|
||||||
|
expect(selectTeamTasksForName(state, 'my-team')).toEqual([task]);
|
||||||
|
expect(selectTeamIsAliveForName(state, 'my-team')).toBe(true);
|
||||||
|
|
||||||
|
expect(selectTeamMemberSnapshotsForName(state, 'missing-team')).toBe(
|
||||||
|
selectTeamMemberSnapshotsForName(state, 'missing-team')
|
||||||
|
);
|
||||||
|
expect(selectTeamTasksForName(state, 'missing-team')).toBe(
|
||||||
|
selectTeamTasksForName(state, 'missing-team')
|
||||||
|
);
|
||||||
|
expect(selectTeamIsAliveForName(state, 'missing-team')).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
63
test/renderer/store/teamErrorPolicies.test.ts
Normal file
63
test/renderer/store/teamErrorPolicies.test.ts
Normal file
|
|
@ -0,0 +1,63 @@
|
||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
|
import {
|
||||||
|
mapReviewError,
|
||||||
|
mapSendMessageError,
|
||||||
|
shouldInvalidateCachedTeamDataForError,
|
||||||
|
} from '../../../src/renderer/store/team/teamErrorPolicies';
|
||||||
|
import { IpcError } from '../../../src/renderer/utils/unwrapIpc';
|
||||||
|
|
||||||
|
describe('teamErrorPolicies', () => {
|
||||||
|
it('maps send-message verification races to the user-facing retry copy', () => {
|
||||||
|
expect(mapSendMessageError(new Error('Failed to verify inbox write for message-1'))).toBe(
|
||||||
|
'Message was written but not verified (race). Please try again.'
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
mapSendMessageError(
|
||||||
|
new IpcError('team:sendMessage', 'Failed to verify inbox write after timeout')
|
||||||
|
)
|
||||||
|
).toBe('Message was written but not verified (race). Please try again.');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('maps send-message errors to original messages or fallback copy', () => {
|
||||||
|
expect(mapSendMessageError(new Error('Transport failed'))).toBe('Transport failed');
|
||||||
|
expect(mapSendMessageError('plain failure')).toBe('Failed to send message');
|
||||||
|
expect(mapSendMessageError(null)).toBe('Failed to send message');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('maps review verification conflicts to the user-facing conflict copy', () => {
|
||||||
|
expect(mapReviewError(new Error('Task status update verification failed for task-1'))).toBe(
|
||||||
|
'Failed to update task status (possible agent conflict).'
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
mapReviewError(
|
||||||
|
new IpcError('team:updateKanban', 'Task status update verification failed after retry')
|
||||||
|
)
|
||||||
|
).toBe('Failed to update task status (possible agent conflict).');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('maps review errors to original messages or fallback copy', () => {
|
||||||
|
expect(mapReviewError(new Error('Review failed'))).toBe('Review failed');
|
||||||
|
expect(mapReviewError({ message: 'ignored non-error shape' })).toBe(
|
||||||
|
'Failed to perform review action'
|
||||||
|
);
|
||||||
|
expect(mapReviewError(undefined)).toBe('Failed to perform review action');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('invalidates cached team data for draft and missing-team errors', () => {
|
||||||
|
expect(shouldInvalidateCachedTeamDataForError('my-team', 'TEAM_DRAFT')).toBe(true);
|
||||||
|
expect(
|
||||||
|
shouldInvalidateCachedTeamDataForError('my-team', 'Cannot read team: TEAM_DRAFT')
|
||||||
|
).toBe(true);
|
||||||
|
expect(shouldInvalidateCachedTeamDataForError('my-team', 'Team not found: my-team')).toBe(true);
|
||||||
|
expect(shouldInvalidateCachedTeamDataForError('my-team', 'Team config not found')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not invalidate cached team data for unrelated or other-team errors', () => {
|
||||||
|
expect(shouldInvalidateCachedTeamDataForError('my-team', 'Network timeout')).toBe(false);
|
||||||
|
expect(shouldInvalidateCachedTeamDataForError('my-team', 'Team not found: other-team')).toBe(
|
||||||
|
false
|
||||||
|
);
|
||||||
|
expect(shouldInvalidateCachedTeamDataForError('my-team', 'Team config missing')).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
133
test/renderer/store/teamGraphLayout.test.ts
Normal file
133
test/renderer/store/teamGraphLayout.test.ts
Normal file
|
|
@ -0,0 +1,133 @@
|
||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
|
import {
|
||||||
|
areTeamGraphSlotAssignmentsEqual,
|
||||||
|
getDefaultTeamGraphSlotAssignmentsForMembers,
|
||||||
|
isTeamGraphSlotPersistenceDisabled,
|
||||||
|
migrateStableSlotAssignmentsForMembers,
|
||||||
|
normalizeLegacySixRowOrbitAssignments,
|
||||||
|
normalizeTeamGraphGridOwnerOrder,
|
||||||
|
normalizeTeamGraphSlotAssignmentsForVisibleOwners,
|
||||||
|
pruneTeamGraphSlotAssignmentsForVisibleOwners,
|
||||||
|
seedStableSlotAssignmentsForMembers,
|
||||||
|
} from '../../../src/renderer/store/team/teamGraphLayout';
|
||||||
|
|
||||||
|
describe('teamGraphLayout', () => {
|
||||||
|
it('migrates legacy name-keyed assignments to stable owner ids', () => {
|
||||||
|
const migrated = migrateStableSlotAssignmentsForMembers(
|
||||||
|
{
|
||||||
|
alice: { ringIndex: 0, sectorIndex: 1 },
|
||||||
|
},
|
||||||
|
[{ name: 'alice', agentId: 'agent-a' }]
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(migrated.changed).toBe(true);
|
||||||
|
expect(migrated.assignments).toEqual({
|
||||||
|
'agent-a': { ringIndex: 0, sectorIndex: 1 },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('drops stale name-keyed assignments when stable assignments already exist', () => {
|
||||||
|
const migrated = migrateStableSlotAssignmentsForMembers(
|
||||||
|
{
|
||||||
|
alice: { ringIndex: 0, sectorIndex: 1 },
|
||||||
|
'agent-a': { ringIndex: 0, sectorIndex: 2 },
|
||||||
|
},
|
||||||
|
[{ name: 'alice', agentId: 'agent-a' }]
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(migrated.changed).toBe(true);
|
||||||
|
expect(migrated.assignments).toEqual({
|
||||||
|
'agent-a': { ringIndex: 0, sectorIndex: 2 },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('seeds default assignments only when no visible owner has a persisted assignment', () => {
|
||||||
|
const seeded = seedStableSlotAssignmentsForMembers(
|
||||||
|
{ unrelated: { ringIndex: 4, sectorIndex: 0 } },
|
||||||
|
[
|
||||||
|
{ name: 'alice', agentId: 'agent-a' },
|
||||||
|
{ name: 'bob', agentId: 'agent-b' },
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(seeded.changed).toBe(true);
|
||||||
|
expect(Object.keys(seeded.assignments)).toEqual(['unrelated', 'agent-a', 'agent-b']);
|
||||||
|
expect(seeded.assignments['agent-a']).toEqual({ ringIndex: 0, sectorIndex: 0 });
|
||||||
|
expect(seeded.assignments['agent-b']).toEqual({ ringIndex: 0, sectorIndex: 1 });
|
||||||
|
|
||||||
|
const preserved = seedStableSlotAssignmentsForMembers(seeded.assignments, [
|
||||||
|
{ name: 'alice', agentId: 'agent-a' },
|
||||||
|
{ name: 'bob', agentId: 'agent-b' },
|
||||||
|
]);
|
||||||
|
expect(preserved.changed).toBe(false);
|
||||||
|
expect(preserved.assignments).toBe(seeded.assignments);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('normalizes six-owner legacy two-row orbit assignments', () => {
|
||||||
|
const ownerIds = ['a', 'b', 'c', 'd', 'e', 'f'];
|
||||||
|
const normalized = normalizeLegacySixRowOrbitAssignments(
|
||||||
|
{
|
||||||
|
a: { ringIndex: 0, sectorIndex: 0 },
|
||||||
|
b: { ringIndex: 0, sectorIndex: 4 },
|
||||||
|
c: { ringIndex: 1, sectorIndex: 2 },
|
||||||
|
d: { ringIndex: 1, sectorIndex: 0 },
|
||||||
|
},
|
||||||
|
ownerIds
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(normalized).toEqual({
|
||||||
|
a: { ringIndex: 0, sectorIndex: 0 },
|
||||||
|
b: { ringIndex: 2, sectorIndex: 1 },
|
||||||
|
c: { ringIndex: 2, sectorIndex: 2 },
|
||||||
|
d: { ringIndex: 2, sectorIndex: 0 },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('normalizes and prunes assignments to visible owners', () => {
|
||||||
|
const normalized = normalizeTeamGraphSlotAssignmentsForVisibleOwners(
|
||||||
|
{
|
||||||
|
a: { ringIndex: 0, sectorIndex: 0 },
|
||||||
|
hidden: { ringIndex: 4, sectorIndex: 4 },
|
||||||
|
},
|
||||||
|
['a']
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(normalized).toEqual({ a: { ringIndex: 0, sectorIndex: 0 } });
|
||||||
|
expect(pruneTeamGraphSlotAssignmentsForVisibleOwners({ hidden: { ringIndex: 4, sectorIndex: 4 } }, ['a']))
|
||||||
|
.toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('normalizes grid owner order by filtering stale and duplicate ids then appending missing ids', () => {
|
||||||
|
expect(normalizeTeamGraphGridOwnerOrder(['b', 'stale', 'b'], ['a', 'b', 'c'])).toEqual([
|
||||||
|
'b',
|
||||||
|
'a',
|
||||||
|
'c',
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('compares assignments by owner id and slot coordinates', () => {
|
||||||
|
expect(
|
||||||
|
areTeamGraphSlotAssignmentsEqual(
|
||||||
|
{ a: { ringIndex: 0, sectorIndex: 0 } },
|
||||||
|
{ a: { ringIndex: 0, sectorIndex: 0 } }
|
||||||
|
)
|
||||||
|
).toBe(true);
|
||||||
|
expect(
|
||||||
|
areTeamGraphSlotAssignmentsEqual(
|
||||||
|
{ a: { ringIndex: 0, sectorIndex: 0 } },
|
||||||
|
{ a: { ringIndex: 0, sectorIndex: 1 } }
|
||||||
|
)
|
||||||
|
).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('exposes default assignment and persistence guardrail helpers', () => {
|
||||||
|
expect(
|
||||||
|
getDefaultTeamGraphSlotAssignmentsForMembers([
|
||||||
|
{ name: 'team-lead', agentId: 'lead-agent' },
|
||||||
|
{ name: 'alice', agentId: 'agent-a' },
|
||||||
|
])
|
||||||
|
).toEqual({ 'agent-a': { ringIndex: 0, sectorIndex: 0 } });
|
||||||
|
expect(isTeamGraphSlotPersistenceDisabled()).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
132
test/renderer/store/teamLaunchParams.test.ts
Normal file
132
test/renderer/store/teamLaunchParams.test.ts
Normal file
|
|
@ -0,0 +1,132 @@
|
||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
|
import {
|
||||||
|
areTeamLaunchParamsEqual,
|
||||||
|
buildLaunchParamsFromRuntimeRequest,
|
||||||
|
extractBaseModel,
|
||||||
|
} from '../../../src/renderer/store/team/teamLaunchParams';
|
||||||
|
|
||||||
|
import type { TeamLaunchParams } from '../../../src/renderer/store/team/teamLaunchParams';
|
||||||
|
|
||||||
|
const codexFallback: TeamLaunchParams = {
|
||||||
|
providerId: 'codex',
|
||||||
|
providerBackendId: 'codex-native',
|
||||||
|
model: 'gpt-5.5',
|
||||||
|
effort: 'medium',
|
||||||
|
fastMode: 'on',
|
||||||
|
limitContext: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('teamLaunchParams', () => {
|
||||||
|
it('extracts provider-scoped base models', () => {
|
||||||
|
expect(extractBaseModel(' opus[1m] ', 'anthropic')).toBe('opus');
|
||||||
|
expect(extractBaseModel('sonnet', 'anthropic')).toBe('sonnet');
|
||||||
|
expect(extractBaseModel('gpt-5.5[1m]', 'codex')).toBe('gpt-5.5[1m]');
|
||||||
|
expect(extractBaseModel(' ', 'anthropic')).toBeUndefined();
|
||||||
|
expect(extractBaseModel(undefined, 'anthropic')).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('builds default anthropic launch params without fallback', () => {
|
||||||
|
expect(buildLaunchParamsFromRuntimeRequest({})).toEqual({
|
||||||
|
providerId: 'anthropic',
|
||||||
|
providerBackendId: undefined,
|
||||||
|
model: 'default',
|
||||||
|
effort: undefined,
|
||||||
|
fastMode: undefined,
|
||||||
|
limitContext: false,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('preserves fallback values for metadata-only requests on the same provider', () => {
|
||||||
|
expect(buildLaunchParamsFromRuntimeRequest({}, codexFallback)).toEqual(codexFallback);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('resets provider-scoped values when the provider changes without explicit fields', () => {
|
||||||
|
expect(
|
||||||
|
buildLaunchParamsFromRuntimeRequest(
|
||||||
|
{
|
||||||
|
providerId: 'anthropic',
|
||||||
|
},
|
||||||
|
codexFallback
|
||||||
|
)
|
||||||
|
).toEqual({
|
||||||
|
providerId: 'anthropic',
|
||||||
|
providerBackendId: undefined,
|
||||||
|
model: 'default',
|
||||||
|
effort: undefined,
|
||||||
|
fastMode: undefined,
|
||||||
|
limitContext: false,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses explicit model, effort, fast mode, and limitContext when present', () => {
|
||||||
|
expect(
|
||||||
|
buildLaunchParamsFromRuntimeRequest(
|
||||||
|
{
|
||||||
|
providerId: 'anthropic',
|
||||||
|
model: 'haiku[1m]',
|
||||||
|
effort: 'low',
|
||||||
|
fastMode: 'off',
|
||||||
|
limitContext: false,
|
||||||
|
},
|
||||||
|
codexFallback
|
||||||
|
)
|
||||||
|
).toEqual({
|
||||||
|
providerId: 'anthropic',
|
||||||
|
providerBackendId: undefined,
|
||||||
|
model: 'haiku',
|
||||||
|
effort: 'low',
|
||||||
|
fastMode: 'off',
|
||||||
|
limitContext: false,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('treats an explicit undefined model as Default for the active provider', () => {
|
||||||
|
expect(
|
||||||
|
buildLaunchParamsFromRuntimeRequest(
|
||||||
|
{
|
||||||
|
providerId: 'codex',
|
||||||
|
providerBackendId: 'codex-native',
|
||||||
|
model: undefined,
|
||||||
|
effort: 'low',
|
||||||
|
},
|
||||||
|
codexFallback
|
||||||
|
)
|
||||||
|
).toEqual({
|
||||||
|
providerId: 'codex',
|
||||||
|
providerBackendId: 'codex-native',
|
||||||
|
model: 'default',
|
||||||
|
effort: 'low',
|
||||||
|
fastMode: 'on',
|
||||||
|
limitContext: true,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('migrates legacy provider backend ids for codex requests', () => {
|
||||||
|
expect(
|
||||||
|
buildLaunchParamsFromRuntimeRequest({
|
||||||
|
providerId: 'codex',
|
||||||
|
providerBackendId: 'api',
|
||||||
|
})
|
||||||
|
).toEqual({
|
||||||
|
providerId: 'codex',
|
||||||
|
providerBackendId: 'codex-native',
|
||||||
|
model: 'default',
|
||||||
|
effort: undefined,
|
||||||
|
fastMode: undefined,
|
||||||
|
limitContext: false,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('compares launch params by all persisted fields', () => {
|
||||||
|
expect(areTeamLaunchParamsEqual(codexFallback, { ...codexFallback })).toBe(true);
|
||||||
|
expect(
|
||||||
|
areTeamLaunchParamsEqual(codexFallback, {
|
||||||
|
...codexFallback,
|
||||||
|
fastMode: 'off',
|
||||||
|
})
|
||||||
|
).toBe(false);
|
||||||
|
expect(areTeamLaunchParamsEqual(undefined, undefined)).toBe(true);
|
||||||
|
expect(areTeamLaunchParamsEqual(undefined, codexFallback)).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
57
test/renderer/store/teamLocalStateEpoch.test.ts
Normal file
57
test/renderer/store/teamLocalStateEpoch.test.ts
Normal file
|
|
@ -0,0 +1,57 @@
|
||||||
|
import { afterEach, describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
|
import {
|
||||||
|
captureTeamLocalStateEpoch,
|
||||||
|
clearAllTeamLocalStateEpochs,
|
||||||
|
clearTeamLocalStateEpoch,
|
||||||
|
hasTeamLocalStateEpoch,
|
||||||
|
invalidateTeamLocalStateEpoch,
|
||||||
|
isTeamLocalStateEpochCurrent,
|
||||||
|
} from '../../../src/renderer/store/team/teamLocalStateEpoch';
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
clearAllTeamLocalStateEpochs();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('teamLocalStateEpoch', () => {
|
||||||
|
it('starts missing teams at epoch zero without materializing an entry', () => {
|
||||||
|
expect(captureTeamLocalStateEpoch('my-team')).toBe(0);
|
||||||
|
expect(isTeamLocalStateEpochCurrent('my-team', 0)).toBe(true);
|
||||||
|
expect(hasTeamLocalStateEpoch('my-team')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('increments epochs independently per team', () => {
|
||||||
|
invalidateTeamLocalStateEpoch('my-team');
|
||||||
|
invalidateTeamLocalStateEpoch('my-team');
|
||||||
|
invalidateTeamLocalStateEpoch('other-team');
|
||||||
|
|
||||||
|
expect(captureTeamLocalStateEpoch('my-team')).toBe(2);
|
||||||
|
expect(captureTeamLocalStateEpoch('other-team')).toBe(1);
|
||||||
|
expect(isTeamLocalStateEpochCurrent('my-team', 1)).toBe(false);
|
||||||
|
expect(isTeamLocalStateEpochCurrent('my-team', 2)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('clears one team epoch without touching other teams', () => {
|
||||||
|
invalidateTeamLocalStateEpoch('my-team');
|
||||||
|
invalidateTeamLocalStateEpoch('other-team');
|
||||||
|
|
||||||
|
clearTeamLocalStateEpoch('my-team');
|
||||||
|
|
||||||
|
expect(captureTeamLocalStateEpoch('my-team')).toBe(0);
|
||||||
|
expect(hasTeamLocalStateEpoch('my-team')).toBe(false);
|
||||||
|
expect(captureTeamLocalStateEpoch('other-team')).toBe(1);
|
||||||
|
expect(hasTeamLocalStateEpoch('other-team')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('clears all materialized epochs', () => {
|
||||||
|
invalidateTeamLocalStateEpoch('my-team');
|
||||||
|
invalidateTeamLocalStateEpoch('other-team');
|
||||||
|
|
||||||
|
clearAllTeamLocalStateEpochs();
|
||||||
|
|
||||||
|
expect(hasTeamLocalStateEpoch('my-team')).toBe(false);
|
||||||
|
expect(hasTeamLocalStateEpoch('other-team')).toBe(false);
|
||||||
|
expect(captureTeamLocalStateEpoch('my-team')).toBe(0);
|
||||||
|
expect(captureTeamLocalStateEpoch('other-team')).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
197
test/renderer/store/teamMemberActivityMeta.test.ts
Normal file
197
test/renderer/store/teamMemberActivityMeta.test.ts
Normal file
|
|
@ -0,0 +1,197 @@
|
||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
|
import {
|
||||||
|
areMemberActivityMetaEntriesEqual,
|
||||||
|
isMemberActivityMetaStale,
|
||||||
|
structurallyShareMemberActivityFacts,
|
||||||
|
} from '../../../src/renderer/store/team/teamMemberActivityMeta';
|
||||||
|
|
||||||
|
import type { TeamMessagesCacheEntry } from '../../../src/renderer/store/team/teamMessagesCache';
|
||||||
|
import type {
|
||||||
|
MemberActivityMetaEntry,
|
||||||
|
TeamMemberActivityMeta,
|
||||||
|
} from '../../../src/shared/types';
|
||||||
|
|
||||||
|
function createEntry(overrides: Partial<MemberActivityMetaEntry> = {}): MemberActivityMetaEntry {
|
||||||
|
return {
|
||||||
|
memberName: 'alice',
|
||||||
|
lastAuthoredMessageAt: '2026-05-22T10:00:00.000Z',
|
||||||
|
messageCountExact: 3,
|
||||||
|
latestAuthoredMessageSignalsTermination: false,
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function createMeta(overrides: Partial<TeamMemberActivityMeta> = {}): TeamMemberActivityMeta {
|
||||||
|
return {
|
||||||
|
teamName: 'my-team',
|
||||||
|
computedAt: '2026-05-22T10:00:00.000Z',
|
||||||
|
members: {
|
||||||
|
alice: createEntry(),
|
||||||
|
},
|
||||||
|
feedRevision: 'feed-1',
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function createMessagesEntry(
|
||||||
|
overrides: Partial<TeamMessagesCacheEntry> = {}
|
||||||
|
): TeamMessagesCacheEntry {
|
||||||
|
return {
|
||||||
|
canonicalMessages: [],
|
||||||
|
optimisticMessages: [],
|
||||||
|
feedRevision: 'feed-1',
|
||||||
|
nextCursor: null,
|
||||||
|
hasMore: false,
|
||||||
|
lastFetchedAt: null,
|
||||||
|
loadingHead: false,
|
||||||
|
loadingOlder: false,
|
||||||
|
headHydrated: true,
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('teamMemberActivityMeta', () => {
|
||||||
|
it('compares member activity entries by visible facts', () => {
|
||||||
|
expect(areMemberActivityMetaEntriesEqual(createEntry(), createEntry())).toBe(true);
|
||||||
|
expect(
|
||||||
|
areMemberActivityMetaEntriesEqual(createEntry(), createEntry({ messageCountExact: 4 }))
|
||||||
|
).toBe(false);
|
||||||
|
expect(
|
||||||
|
areMemberActivityMetaEntriesEqual(
|
||||||
|
createEntry(),
|
||||||
|
createEntry({ latestAuthoredMessageSignalsTermination: true })
|
||||||
|
)
|
||||||
|
).toBe(false);
|
||||||
|
expect(areMemberActivityMetaEntriesEqual(undefined, createEntry())).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns next activity facts when there is no previous record', () => {
|
||||||
|
const next = {
|
||||||
|
alice: createEntry(),
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(structurallyShareMemberActivityFacts(undefined, next)).toBe(next);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('preserves the previous record when all entries are semantically equal', () => {
|
||||||
|
const previous = {
|
||||||
|
alice: createEntry(),
|
||||||
|
bob: createEntry({ memberName: 'bob', messageCountExact: 1 }),
|
||||||
|
};
|
||||||
|
const next = {
|
||||||
|
alice: createEntry(),
|
||||||
|
bob: createEntry({ memberName: 'bob', messageCountExact: 1 }),
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(structurallyShareMemberActivityFacts(previous, next)).toBe(previous);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shares unchanged entries and replaces changed entries', () => {
|
||||||
|
const previousAlice = createEntry();
|
||||||
|
const previousBob = createEntry({ memberName: 'bob', messageCountExact: 1 });
|
||||||
|
const nextBob = createEntry({ memberName: 'bob', messageCountExact: 2 });
|
||||||
|
const previous = {
|
||||||
|
alice: previousAlice,
|
||||||
|
bob: previousBob,
|
||||||
|
};
|
||||||
|
|
||||||
|
const shared = structurallyShareMemberActivityFacts(
|
||||||
|
previous,
|
||||||
|
{
|
||||||
|
alice: createEntry(),
|
||||||
|
bob: nextBob,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(shared).not.toBe(previous);
|
||||||
|
expect(shared.alice).toBe(previousAlice);
|
||||||
|
expect(shared.bob).toBe(nextBob);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns a new record when activity keys are added or removed', () => {
|
||||||
|
const previous = {
|
||||||
|
alice: createEntry(),
|
||||||
|
bob: createEntry({ memberName: 'bob' }),
|
||||||
|
};
|
||||||
|
|
||||||
|
const removed = structurallyShareMemberActivityFacts(previous, {
|
||||||
|
alice: createEntry(),
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(removed).not.toBe(previous);
|
||||||
|
expect(removed).toEqual({
|
||||||
|
alice: previous.alice,
|
||||||
|
});
|
||||||
|
expect(removed.alice).toBe(previous.alice);
|
||||||
|
|
||||||
|
const singlePrevious = {
|
||||||
|
alice: createEntry(),
|
||||||
|
};
|
||||||
|
const added = structurallyShareMemberActivityFacts(singlePrevious, {
|
||||||
|
alice: createEntry(),
|
||||||
|
bob: createEntry({ memberName: 'bob' }),
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(added).not.toBe(singlePrevious);
|
||||||
|
expect(added.alice).toBe(singlePrevious.alice);
|
||||||
|
expect(added.bob).toEqual(createEntry({ memberName: 'bob' }));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('treats missing member activity meta as stale', () => {
|
||||||
|
expect(
|
||||||
|
isMemberActivityMetaStale(
|
||||||
|
{
|
||||||
|
memberActivityMetaByTeam: {},
|
||||||
|
teamMessagesByName: {},
|
||||||
|
},
|
||||||
|
'my-team'
|
||||||
|
)
|
||||||
|
).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not require refresh when the message feed has no revision yet', () => {
|
||||||
|
expect(
|
||||||
|
isMemberActivityMetaStale(
|
||||||
|
{
|
||||||
|
memberActivityMetaByTeam: {
|
||||||
|
'my-team': createMeta({ feedRevision: 'old-feed' }),
|
||||||
|
},
|
||||||
|
teamMessagesByName: {
|
||||||
|
'my-team': createMessagesEntry({ feedRevision: null }),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
'my-team'
|
||||||
|
)
|
||||||
|
).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('compares member activity meta feedRevision against the messages feed revision', () => {
|
||||||
|
expect(
|
||||||
|
isMemberActivityMetaStale(
|
||||||
|
{
|
||||||
|
memberActivityMetaByTeam: {
|
||||||
|
'my-team': createMeta({ feedRevision: 'feed-1' }),
|
||||||
|
},
|
||||||
|
teamMessagesByName: {
|
||||||
|
'my-team': createMessagesEntry({ feedRevision: 'feed-1' }),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
'my-team'
|
||||||
|
)
|
||||||
|
).toBe(false);
|
||||||
|
expect(
|
||||||
|
isMemberActivityMetaStale(
|
||||||
|
{
|
||||||
|
memberActivityMetaByTeam: {
|
||||||
|
'my-team': createMeta({ feedRevision: 'feed-1' }),
|
||||||
|
},
|
||||||
|
teamMessagesByName: {
|
||||||
|
'my-team': createMessagesEntry({ feedRevision: 'feed-2' }),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
'my-team'
|
||||||
|
)
|
||||||
|
).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
186
test/renderer/store/teamMemberSpawnSnapshotEquality.test.ts
Normal file
186
test/renderer/store/teamMemberSpawnSnapshotEquality.test.ts
Normal file
|
|
@ -0,0 +1,186 @@
|
||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
|
import {
|
||||||
|
areExpectedMembersEqual,
|
||||||
|
areLaunchSummaryCountsEqual,
|
||||||
|
areMemberSpawnSnapshotsSemanticallyEqual,
|
||||||
|
areMemberSpawnStatusEntriesEqual,
|
||||||
|
areMemberSpawnStatusesEqual,
|
||||||
|
} from '../../../src/renderer/store/team/teamMemberSpawnSnapshotEquality';
|
||||||
|
|
||||||
|
import type {
|
||||||
|
MemberSpawnStatusEntry,
|
||||||
|
MemberSpawnStatusesSnapshot,
|
||||||
|
PersistedTeamLaunchSummary,
|
||||||
|
} from '../../../src/shared/types';
|
||||||
|
|
||||||
|
function createSummary(
|
||||||
|
overrides: Partial<PersistedTeamLaunchSummary> = {}
|
||||||
|
): PersistedTeamLaunchSummary {
|
||||||
|
return {
|
||||||
|
confirmedCount: 1,
|
||||||
|
pendingCount: 0,
|
||||||
|
failedCount: 0,
|
||||||
|
skippedCount: 0,
|
||||||
|
runtimeAlivePendingCount: 0,
|
||||||
|
shellOnlyPendingCount: 0,
|
||||||
|
runtimeProcessPendingCount: 0,
|
||||||
|
runtimeCandidatePendingCount: 0,
|
||||||
|
noRuntimePendingCount: 0,
|
||||||
|
permissionPendingCount: 0,
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function createStatusEntry(
|
||||||
|
overrides: Partial<MemberSpawnStatusEntry> = {}
|
||||||
|
): MemberSpawnStatusEntry {
|
||||||
|
return {
|
||||||
|
status: 'online',
|
||||||
|
launchState: 'confirmed_alive',
|
||||||
|
updatedAt: '2026-05-22T10:00:00.000Z',
|
||||||
|
livenessSource: 'heartbeat',
|
||||||
|
runtimeAlive: true,
|
||||||
|
runtimeModel: 'gpt-5.3-codex',
|
||||||
|
livenessKind: 'confirmed_bootstrap',
|
||||||
|
runtimeDiagnostic: 'Ready',
|
||||||
|
runtimeDiagnosticSeverity: 'info',
|
||||||
|
bootstrapConfirmed: true,
|
||||||
|
hardFailure: false,
|
||||||
|
pendingPermissionRequestIds: ['perm-a', 'perm-b'],
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function createSnapshot(
|
||||||
|
overrides: Partial<MemberSpawnStatusesSnapshot> = {}
|
||||||
|
): MemberSpawnStatusesSnapshot {
|
||||||
|
return {
|
||||||
|
statuses: {
|
||||||
|
alice: createStatusEntry(),
|
||||||
|
},
|
||||||
|
runId: 'run-1',
|
||||||
|
teamLaunchState: 'clean_success',
|
||||||
|
launchPhase: 'active',
|
||||||
|
expectedMembers: ['alice'],
|
||||||
|
updatedAt: '2026-05-22T10:00:00.000Z',
|
||||||
|
summary: createSummary(),
|
||||||
|
source: 'live',
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('teamMemberSpawnSnapshotEquality', () => {
|
||||||
|
it('compares launch summaries by visible counts', () => {
|
||||||
|
expect(areLaunchSummaryCountsEqual(createSummary(), createSummary())).toBe(true);
|
||||||
|
expect(
|
||||||
|
areLaunchSummaryCountsEqual(createSummary(), createSummary({ permissionPendingCount: 1 }))
|
||||||
|
).toBe(false);
|
||||||
|
expect(areLaunchSummaryCountsEqual(undefined, undefined)).toBe(true);
|
||||||
|
expect(areLaunchSummaryCountsEqual(undefined, createSummary())).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('compares expected members in stable order', () => {
|
||||||
|
expect(areExpectedMembersEqual(['alice', 'bob'], ['alice', 'bob'])).toBe(true);
|
||||||
|
expect(areExpectedMembersEqual(['alice', 'bob'], ['bob', 'alice'])).toBe(false);
|
||||||
|
expect(areExpectedMembersEqual(undefined, undefined)).toBe(true);
|
||||||
|
expect(areExpectedMembersEqual(undefined, [])).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ignores non-visible status churn and unordered pending permission ids', () => {
|
||||||
|
const left = createStatusEntry({
|
||||||
|
pendingPermissionRequestIds: ['perm-b', 'perm-a'],
|
||||||
|
updatedAt: '2026-05-22T10:00:00.000Z',
|
||||||
|
agentToolAccepted: true,
|
||||||
|
firstSpawnAcceptedAt: '2026-05-22T10:00:01.000Z',
|
||||||
|
lastHeartbeatAt: '2026-05-22T10:00:02.000Z',
|
||||||
|
livenessLastCheckedAt: '2026-05-22T10:00:03.000Z',
|
||||||
|
bootstrapStalled: true,
|
||||||
|
});
|
||||||
|
const right = createStatusEntry({
|
||||||
|
pendingPermissionRequestIds: ['perm-a', 'perm-b'],
|
||||||
|
updatedAt: '2026-05-22T10:05:00.000Z',
|
||||||
|
agentToolAccepted: false,
|
||||||
|
firstSpawnAcceptedAt: '2026-05-22T10:05:01.000Z',
|
||||||
|
lastHeartbeatAt: '2026-05-22T10:05:02.000Z',
|
||||||
|
livenessLastCheckedAt: '2026-05-22T10:05:03.000Z',
|
||||||
|
bootstrapStalled: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(areMemberSpawnStatusEntriesEqual(left, right)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('detects visible status entry changes', () => {
|
||||||
|
expect(
|
||||||
|
areMemberSpawnStatusEntriesEqual(
|
||||||
|
createStatusEntry(),
|
||||||
|
createStatusEntry({ runtimeDiagnosticSeverity: 'warning' })
|
||||||
|
)
|
||||||
|
).toBe(false);
|
||||||
|
expect(
|
||||||
|
areMemberSpawnStatusEntriesEqual(
|
||||||
|
createStatusEntry(),
|
||||||
|
createStatusEntry({ pendingPermissionRequestIds: ['perm-a'] })
|
||||||
|
)
|
||||||
|
).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('compares per-member status maps by keys and semantic entries', () => {
|
||||||
|
expect(
|
||||||
|
areMemberSpawnStatusesEqual(
|
||||||
|
{
|
||||||
|
alice: createStatusEntry(),
|
||||||
|
bob: createStatusEntry({ runtimeModel: 'gpt-5.4' }),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
bob: createStatusEntry({ runtimeModel: 'gpt-5.4' }),
|
||||||
|
alice: createStatusEntry(),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
).toBe(true);
|
||||||
|
expect(
|
||||||
|
areMemberSpawnStatusesEqual(
|
||||||
|
{
|
||||||
|
alice: createStatusEntry(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
alice: createStatusEntry(),
|
||||||
|
bob: createStatusEntry(),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('compares snapshots by semantic launch fields and ignores snapshot updatedAt churn', () => {
|
||||||
|
const left = createSnapshot({
|
||||||
|
updatedAt: '2026-05-22T10:00:00.000Z',
|
||||||
|
});
|
||||||
|
const right = createSnapshot({
|
||||||
|
updatedAt: '2026-05-22T10:05:00.000Z',
|
||||||
|
statuses: {
|
||||||
|
alice: createStatusEntry({
|
||||||
|
pendingPermissionRequestIds: ['perm-b', 'perm-a'],
|
||||||
|
updatedAt: '2026-05-22T10:05:00.000Z',
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(areMemberSpawnSnapshotsSemanticallyEqual(left, right)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('detects semantic snapshot changes', () => {
|
||||||
|
expect(
|
||||||
|
areMemberSpawnSnapshotsSemanticallyEqual(
|
||||||
|
createSnapshot(),
|
||||||
|
createSnapshot({ runId: 'run-2' })
|
||||||
|
)
|
||||||
|
).toBe(false);
|
||||||
|
expect(
|
||||||
|
areMemberSpawnSnapshotsSemanticallyEqual(
|
||||||
|
createSnapshot(),
|
||||||
|
createSnapshot({ expectedMembers: ['alice', 'bob'] })
|
||||||
|
)
|
||||||
|
).toBe(false);
|
||||||
|
expect(areMemberSpawnSnapshotsSemanticallyEqual(undefined, createSnapshot())).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
70
test/renderer/store/teamMemberSpawnStatusBackoff.test.ts
Normal file
70
test/renderer/store/teamMemberSpawnStatusBackoff.test.ts
Normal file
|
|
@ -0,0 +1,70 @@
|
||||||
|
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
|
import {
|
||||||
|
clearAllMemberSpawnStatusesIpcBackoffs,
|
||||||
|
clearMemberSpawnStatusesIpcBackoff,
|
||||||
|
getMemberSpawnStatusesIpcBackoffUntil,
|
||||||
|
hasMemberSpawnStatusesIpcBackoff,
|
||||||
|
isMemberSpawnStatusesIpcBackoffActive,
|
||||||
|
recordMemberSpawnStatusesIpcBackoffUntil,
|
||||||
|
recordMemberSpawnStatusesIpcRetryBackoff,
|
||||||
|
} from '../../../src/renderer/store/team/teamMemberSpawnStatusBackoff';
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.useRealTimers();
|
||||||
|
clearAllMemberSpawnStatusesIpcBackoffs();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('teamMemberSpawnStatusBackoff', () => {
|
||||||
|
it('defaults to no backoff for unknown teams', () => {
|
||||||
|
expect(getMemberSpawnStatusesIpcBackoffUntil('my-team')).toBe(0);
|
||||||
|
expect(hasMemberSpawnStatusesIpcBackoff('my-team')).toBe(false);
|
||||||
|
expect(isMemberSpawnStatusesIpcBackoffActive('my-team', 100)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('tracks active backoff deadlines by team', () => {
|
||||||
|
recordMemberSpawnStatusesIpcBackoffUntil('my-team', 150);
|
||||||
|
recordMemberSpawnStatusesIpcBackoffUntil('other-team', 250);
|
||||||
|
|
||||||
|
expect(getMemberSpawnStatusesIpcBackoffUntil('my-team')).toBe(150);
|
||||||
|
expect(isMemberSpawnStatusesIpcBackoffActive('my-team', 149)).toBe(true);
|
||||||
|
expect(isMemberSpawnStatusesIpcBackoffActive('my-team', 150)).toBe(false);
|
||||||
|
expect(isMemberSpawnStatusesIpcBackoffActive('other-team', 249)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('records retry backoff from Date.now by default', () => {
|
||||||
|
vi.setSystemTime(new Date('2026-05-22T07:00:00.000Z'));
|
||||||
|
|
||||||
|
recordMemberSpawnStatusesIpcRetryBackoff('my-team', 5_000);
|
||||||
|
|
||||||
|
expect(getMemberSpawnStatusesIpcBackoffUntil('my-team')).toBe(
|
||||||
|
new Date('2026-05-22T07:00:05.000Z').getTime()
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('records retry backoff from an explicit clock for deterministic callers', () => {
|
||||||
|
recordMemberSpawnStatusesIpcRetryBackoff('my-team', 5_000, 100);
|
||||||
|
|
||||||
|
expect(getMemberSpawnStatusesIpcBackoffUntil('my-team')).toBe(5_100);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('clears one team backoff without touching others', () => {
|
||||||
|
recordMemberSpawnStatusesIpcBackoffUntil('my-team', 150);
|
||||||
|
recordMemberSpawnStatusesIpcBackoffUntil('other-team', 250);
|
||||||
|
|
||||||
|
clearMemberSpawnStatusesIpcBackoff('my-team');
|
||||||
|
|
||||||
|
expect(hasMemberSpawnStatusesIpcBackoff('my-team')).toBe(false);
|
||||||
|
expect(getMemberSpawnStatusesIpcBackoffUntil('other-team')).toBe(250);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('clears all recorded backoffs', () => {
|
||||||
|
recordMemberSpawnStatusesIpcBackoffUntil('my-team', 150);
|
||||||
|
recordMemberSpawnStatusesIpcBackoffUntil('other-team', 250);
|
||||||
|
|
||||||
|
clearAllMemberSpawnStatusesIpcBackoffs();
|
||||||
|
|
||||||
|
expect(hasMemberSpawnStatusesIpcBackoff('my-team')).toBe(false);
|
||||||
|
expect(hasMemberSpawnStatusesIpcBackoff('other-team')).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -0,0 +1,71 @@
|
||||||
|
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
|
import {
|
||||||
|
clearAllMemberSpawnUiEqualLastWarns,
|
||||||
|
clearMemberSpawnUiEqualLastWarn,
|
||||||
|
getMemberSpawnUiEqualLastWarnAt,
|
||||||
|
hasMemberSpawnUiEqualLastWarn,
|
||||||
|
shouldLogMemberSpawnUiEqualSuppressed,
|
||||||
|
} from '../../../src/renderer/store/team/teamMemberSpawnUiEqualWarningThrottle';
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.useRealTimers();
|
||||||
|
clearAllMemberSpawnUiEqualLastWarns();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('teamMemberSpawnUiEqualWarningThrottle', () => {
|
||||||
|
it('preserves the existing zero fallback boundary for unknown teams', () => {
|
||||||
|
expect(shouldLogMemberSpawnUiEqualSuppressed('my-team', 2_000, 1_999)).toBe(false);
|
||||||
|
expect(hasMemberSpawnUiEqualLastWarn('my-team')).toBe(false);
|
||||||
|
|
||||||
|
expect(shouldLogMemberSpawnUiEqualSuppressed('my-team', 2_000, 2_000)).toBe(true);
|
||||||
|
expect(getMemberSpawnUiEqualLastWarnAt('my-team')).toBe(2_000);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throttles repeated warnings until the boundary is reached', () => {
|
||||||
|
expect(shouldLogMemberSpawnUiEqualSuppressed('my-team', 2_000, 10_000)).toBe(true);
|
||||||
|
expect(shouldLogMemberSpawnUiEqualSuppressed('my-team', 2_000, 11_999)).toBe(false);
|
||||||
|
expect(getMemberSpawnUiEqualLastWarnAt('my-team')).toBe(10_000);
|
||||||
|
|
||||||
|
expect(shouldLogMemberSpawnUiEqualSuppressed('my-team', 2_000, 12_000)).toBe(true);
|
||||||
|
expect(getMemberSpawnUiEqualLastWarnAt('my-team')).toBe(12_000);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('tracks teams independently', () => {
|
||||||
|
expect(shouldLogMemberSpawnUiEqualSuppressed('my-team', 2_000, 10_000)).toBe(true);
|
||||||
|
expect(shouldLogMemberSpawnUiEqualSuppressed('other-team', 2_000, 10_500)).toBe(true);
|
||||||
|
|
||||||
|
expect(getMemberSpawnUiEqualLastWarnAt('my-team')).toBe(10_000);
|
||||||
|
expect(getMemberSpawnUiEqualLastWarnAt('other-team')).toBe(10_500);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses Date.now by default for production callers', () => {
|
||||||
|
vi.setSystemTime(new Date('2026-05-22T07:30:00.000Z'));
|
||||||
|
|
||||||
|
expect(shouldLogMemberSpawnUiEqualSuppressed('my-team', 2_000)).toBe(true);
|
||||||
|
|
||||||
|
expect(getMemberSpawnUiEqualLastWarnAt('my-team')).toBe(
|
||||||
|
new Date('2026-05-22T07:30:00.000Z').getTime()
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('clears one team without touching other teams', () => {
|
||||||
|
shouldLogMemberSpawnUiEqualSuppressed('my-team', 2_000, 10_000);
|
||||||
|
shouldLogMemberSpawnUiEqualSuppressed('other-team', 2_000, 10_500);
|
||||||
|
|
||||||
|
clearMemberSpawnUiEqualLastWarn('my-team');
|
||||||
|
|
||||||
|
expect(hasMemberSpawnUiEqualLastWarn('my-team')).toBe(false);
|
||||||
|
expect(getMemberSpawnUiEqualLastWarnAt('other-team')).toBe(10_500);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('clears all tracked warnings', () => {
|
||||||
|
shouldLogMemberSpawnUiEqualSuppressed('my-team', 2_000, 10_000);
|
||||||
|
shouldLogMemberSpawnUiEqualSuppressed('other-team', 2_000, 10_500);
|
||||||
|
|
||||||
|
clearAllMemberSpawnUiEqualLastWarns();
|
||||||
|
|
||||||
|
expect(hasMemberSpawnUiEqualLastWarn('my-team')).toBe(false);
|
||||||
|
expect(hasMemberSpawnUiEqualLastWarn('other-team')).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
186
test/renderer/store/teamMessagesCache.test.ts
Normal file
186
test/renderer/store/teamMessagesCache.test.ts
Normal file
|
|
@ -0,0 +1,186 @@
|
||||||
|
import { afterEach, describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
|
import {
|
||||||
|
areInboxMessageArraysEquivalent,
|
||||||
|
clearTeamMessageSelectorCaches,
|
||||||
|
clearTeamMessageSelectorCachesForTeam,
|
||||||
|
EMPTY_TEAM_MESSAGES_CACHE_ENTRY,
|
||||||
|
extractRetainedCanonicalOlderTail,
|
||||||
|
getCanonicalHeadSlice,
|
||||||
|
getTeamMessagesCacheEntry,
|
||||||
|
getTeamMessageSelectorCacheSnapshotForTeam,
|
||||||
|
pruneOptimisticMessages,
|
||||||
|
selectMemberMessagesForTeamMember,
|
||||||
|
selectTeamMessages,
|
||||||
|
type TeamMessagesCacheEntry,
|
||||||
|
type TeamMessagesCacheState,
|
||||||
|
upsertOptimisticTeamMessage,
|
||||||
|
} from '../../../src/renderer/store/team/teamMessagesCache';
|
||||||
|
|
||||||
|
import type { InboxMessage } from '../../../src/shared/types';
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
clearTeamMessageSelectorCaches();
|
||||||
|
});
|
||||||
|
|
||||||
|
function createMessage(overrides: Partial<InboxMessage> & { messageId: string }): InboxMessage {
|
||||||
|
return {
|
||||||
|
from: 'lead',
|
||||||
|
to: 'alice',
|
||||||
|
text: overrides.messageId,
|
||||||
|
timestamp: '2026-03-12T10:00:00.000Z',
|
||||||
|
read: false,
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function createEntry(overrides: Partial<TeamMessagesCacheEntry> = {}): TeamMessagesCacheEntry {
|
||||||
|
return {
|
||||||
|
...EMPTY_TEAM_MESSAGES_CACHE_ENTRY,
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('teamMessagesCache', () => {
|
||||||
|
it('returns the immutable empty entry when a team has no cached messages', () => {
|
||||||
|
const state: TeamMessagesCacheState = { teamMessagesByName: {} };
|
||||||
|
|
||||||
|
expect(getTeamMessagesCacheEntry(state, 'missing-team')).toBe(EMPTY_TEAM_MESSAGES_CACHE_ENTRY);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('upserts optimistic messages by durable id and keeps deterministic timestamp order', () => {
|
||||||
|
const first = upsertOptimisticTeamMessage(
|
||||||
|
createEntry(),
|
||||||
|
createMessage({
|
||||||
|
messageId: 'msg-new',
|
||||||
|
timestamp: '2026-03-12T10:00:03.000Z',
|
||||||
|
text: 'draft',
|
||||||
|
})
|
||||||
|
);
|
||||||
|
const second = upsertOptimisticTeamMessage(
|
||||||
|
first,
|
||||||
|
createMessage({
|
||||||
|
messageId: 'msg-old',
|
||||||
|
timestamp: '2026-03-12T10:00:01.000Z',
|
||||||
|
})
|
||||||
|
);
|
||||||
|
const replaced = upsertOptimisticTeamMessage(
|
||||||
|
second,
|
||||||
|
createMessage({
|
||||||
|
messageId: 'msg-new',
|
||||||
|
timestamp: '2026-03-12T10:00:03.000Z',
|
||||||
|
text: 'sent',
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(replaced.optimisticMessages.map((message) => message.messageId)).toEqual([
|
||||||
|
'msg-old',
|
||||||
|
'msg-new',
|
||||||
|
]);
|
||||||
|
expect(replaced.optimisticMessages[1].text).toBe('sent');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('compares semantic message arrays and prunes optimistic rows confirmed by canonical data', () => {
|
||||||
|
const canonical = [
|
||||||
|
createMessage({ messageId: 'msg-1', text: 'confirmed' }),
|
||||||
|
createMessage({ messageId: 'msg-2' }),
|
||||||
|
];
|
||||||
|
const equivalentCanonical = [
|
||||||
|
createMessage({ messageId: 'msg-1', text: 'confirmed' }),
|
||||||
|
createMessage({ messageId: 'msg-2' }),
|
||||||
|
];
|
||||||
|
const optimistic = [
|
||||||
|
createMessage({ messageId: 'msg-1', text: 'draft that arrived' }),
|
||||||
|
createMessage({ messageId: 'msg-local', text: 'still local' }),
|
||||||
|
];
|
||||||
|
|
||||||
|
expect(areInboxMessageArraysEquivalent(canonical, equivalentCanonical)).toBe(true);
|
||||||
|
expect(
|
||||||
|
areInboxMessageArraysEquivalent(canonical, [
|
||||||
|
createMessage({ messageId: 'msg-1', text: 'changed' }),
|
||||||
|
createMessage({ messageId: 'msg-2' }),
|
||||||
|
])
|
||||||
|
).toBe(false);
|
||||||
|
expect(pruneOptimisticMessages(optimistic, canonical).map((message) => message.messageId)).toEqual(
|
||||||
|
['msg-local']
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('retains already-loaded older tail only when the fresh head anchors into canonical data', () => {
|
||||||
|
const canonical = [
|
||||||
|
createMessage({ messageId: 'msg-4', timestamp: '2026-03-12T10:00:04.000Z' }),
|
||||||
|
createMessage({ messageId: 'msg-3', timestamp: '2026-03-12T10:00:03.000Z' }),
|
||||||
|
createMessage({ messageId: 'msg-2', timestamp: '2026-03-12T10:00:02.000Z' }),
|
||||||
|
createMessage({ messageId: 'msg-1', timestamp: '2026-03-12T10:00:01.000Z' }),
|
||||||
|
];
|
||||||
|
const freshHead = [
|
||||||
|
createMessage({ messageId: 'msg-5', timestamp: '2026-03-12T10:00:05.000Z' }),
|
||||||
|
createMessage({ messageId: 'msg-3', timestamp: '2026-03-12T10:00:03.000Z' }),
|
||||||
|
];
|
||||||
|
|
||||||
|
expect(getCanonicalHeadSlice(canonical, 2).map((message) => message.messageId)).toEqual([
|
||||||
|
'msg-4',
|
||||||
|
'msg-3',
|
||||||
|
]);
|
||||||
|
expect(
|
||||||
|
extractRetainedCanonicalOlderTail(canonical, freshHead)?.map((message) => message.messageId)
|
||||||
|
).toEqual(['msg-2', 'msg-1']);
|
||||||
|
expect(
|
||||||
|
extractRetainedCanonicalOlderTail(canonical, [createMessage({ messageId: 'disjoint' })])
|
||||||
|
).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('memoizes merged and member-scoped selectors and clears team-scoped caches', () => {
|
||||||
|
const state: TeamMessagesCacheState = {
|
||||||
|
teamMessagesByName: {
|
||||||
|
'my-team': createEntry({
|
||||||
|
canonicalMessages: [
|
||||||
|
createMessage({
|
||||||
|
messageId: 'msg-1',
|
||||||
|
to: 'alice',
|
||||||
|
timestamp: '2026-03-12T10:00:01.000Z',
|
||||||
|
}),
|
||||||
|
createMessage({
|
||||||
|
messageId: 'msg-2',
|
||||||
|
to: 'bob',
|
||||||
|
timestamp: '2026-03-12T10:00:02.000Z',
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
optimisticMessages: [
|
||||||
|
createMessage({
|
||||||
|
messageId: 'msg-3',
|
||||||
|
from: 'alice',
|
||||||
|
to: 'lead',
|
||||||
|
timestamp: '2026-03-12T10:00:03.000Z',
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const firstTeamMessages = selectTeamMessages(state, 'my-team');
|
||||||
|
const secondTeamMessages = selectTeamMessages(state, 'my-team');
|
||||||
|
const firstAliceMessages = selectMemberMessagesForTeamMember(state, 'my-team', 'alice');
|
||||||
|
const secondAliceMessages = selectMemberMessagesForTeamMember(state, 'my-team', 'alice');
|
||||||
|
|
||||||
|
expect(firstTeamMessages).toBe(secondTeamMessages);
|
||||||
|
expect(firstAliceMessages).toBe(secondAliceMessages);
|
||||||
|
expect(firstTeamMessages.map((message) => message.messageId)).toEqual([
|
||||||
|
'msg-3',
|
||||||
|
'msg-2',
|
||||||
|
'msg-1',
|
||||||
|
]);
|
||||||
|
expect(firstAliceMessages.map((message) => message.messageId)).toEqual(['msg-3', 'msg-1']);
|
||||||
|
expect(getTeamMessageSelectorCacheSnapshotForTeam('my-team')).toEqual({
|
||||||
|
hasMergedMessagesSelector: true,
|
||||||
|
memberMessagesSelectorCount: 1,
|
||||||
|
});
|
||||||
|
|
||||||
|
clearTeamMessageSelectorCachesForTeam('my-team');
|
||||||
|
|
||||||
|
expect(getTeamMessageSelectorCacheSnapshotForTeam('my-team')).toEqual({
|
||||||
|
hasMergedMessagesSelector: false,
|
||||||
|
memberMessagesSelectorCount: 0,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
67
test/renderer/store/teamMessagesPanelModePersistence.test.ts
Normal file
67
test/renderer/store/teamMessagesPanelModePersistence.test.ts
Normal file
|
|
@ -0,0 +1,67 @@
|
||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
|
import {
|
||||||
|
loadPersistedMessagesPanelMode,
|
||||||
|
savePersistedMessagesPanelMode,
|
||||||
|
} from '../../../src/renderer/store/team/teamMessagesPanelModePersistence';
|
||||||
|
|
||||||
|
import type { TeamMessagesPanelMode } from '../../../src/renderer/types/teamMessagesPanelMode';
|
||||||
|
|
||||||
|
const STORAGE_KEY = 'team:messagesPanelMode';
|
||||||
|
|
||||||
|
describe('teamMessagesPanelModePersistence', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
window.localStorage.clear();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('defaults to sidebar when no value was persisted', () => {
|
||||||
|
expect(loadPersistedMessagesPanelMode()).toBe('sidebar');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('loads each supported persisted mode', () => {
|
||||||
|
const modes: TeamMessagesPanelMode[] = [
|
||||||
|
'sidebar',
|
||||||
|
'inline',
|
||||||
|
'bottom-sheet',
|
||||||
|
'floating-composer',
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const mode of modes) {
|
||||||
|
window.localStorage.setItem(STORAGE_KEY, mode);
|
||||||
|
|
||||||
|
expect(loadPersistedMessagesPanelMode()).toBe(mode);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('falls back to sidebar for invalid persisted values', () => {
|
||||||
|
window.localStorage.setItem(STORAGE_KEY, 'bad-mode');
|
||||||
|
|
||||||
|
expect(loadPersistedMessagesPanelMode()).toBe('sidebar');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('persists the selected mode', () => {
|
||||||
|
savePersistedMessagesPanelMode('inline');
|
||||||
|
|
||||||
|
expect(window.localStorage.getItem(STORAGE_KEY)).toBe('inline');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('falls back to sidebar when localStorage read fails', () => {
|
||||||
|
vi.spyOn(Storage.prototype, 'getItem').mockImplementation(() => {
|
||||||
|
throw new Error('blocked');
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(loadPersistedMessagesPanelMode()).toBe('sidebar');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ignores localStorage write failures', () => {
|
||||||
|
vi.spyOn(Storage.prototype, 'setItem').mockImplementation(() => {
|
||||||
|
throw new Error('blocked');
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(() => savePersistedMessagesPanelMode('bottom-sheet')).not.toThrow();
|
||||||
|
});
|
||||||
|
});
|
||||||
65
test/renderer/store/teamPendingReplyWaits.test.ts
Normal file
65
test/renderer/store/teamPendingReplyWaits.test.ts
Normal file
|
|
@ -0,0 +1,65 @@
|
||||||
|
import { afterEach, describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
|
import {
|
||||||
|
clearAllPendingReplyRefreshWaits,
|
||||||
|
clearPendingReplyRefreshWaits,
|
||||||
|
getActiveTeamPendingReplyWaits,
|
||||||
|
hasActiveTeamPendingReplyWait,
|
||||||
|
setPendingReplyRefreshEnabled,
|
||||||
|
} from '../../../src/renderer/store/team/teamPendingReplyWaits';
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
clearAllPendingReplyRefreshWaits();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('teamPendingReplyWaits', () => {
|
||||||
|
it('tracks active teams with at least one enabled source', () => {
|
||||||
|
expect(setPendingReplyRefreshEnabled('my-team', 'tab-a', true)).toBe(true);
|
||||||
|
expect(setPendingReplyRefreshEnabled('other-team', 'tab-b', true)).toBe(true);
|
||||||
|
|
||||||
|
expect(hasActiveTeamPendingReplyWait('my-team')).toBe(true);
|
||||||
|
expect(hasActiveTeamPendingReplyWait('other-team')).toBe(true);
|
||||||
|
expect(getActiveTeamPendingReplyWaits()).toEqual(new Set(['my-team', 'other-team']));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps a team active until the last source is disabled', () => {
|
||||||
|
setPendingReplyRefreshEnabled('my-team', 'tab-a', true);
|
||||||
|
setPendingReplyRefreshEnabled('my-team', 'tab-b', true);
|
||||||
|
|
||||||
|
expect(setPendingReplyRefreshEnabled('my-team', 'tab-b', false)).toBe(true);
|
||||||
|
expect(hasActiveTeamPendingReplyWait('my-team')).toBe(true);
|
||||||
|
expect(getActiveTeamPendingReplyWaits()).toEqual(new Set(['my-team']));
|
||||||
|
|
||||||
|
expect(setPendingReplyRefreshEnabled('my-team', 'tab-a', false)).toBe(false);
|
||||||
|
expect(hasActiveTeamPendingReplyWait('my-team')).toBe(false);
|
||||||
|
expect(getActiveTeamPendingReplyWaits().size).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('is idempotent for repeated enables from the same source', () => {
|
||||||
|
setPendingReplyRefreshEnabled('my-team', 'tab-a', true);
|
||||||
|
setPendingReplyRefreshEnabled('my-team', 'tab-a', true);
|
||||||
|
|
||||||
|
expect(setPendingReplyRefreshEnabled('my-team', 'tab-a', false)).toBe(false);
|
||||||
|
expect(hasActiveTeamPendingReplyWait('my-team')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns false when disabling a source that has no active wait', () => {
|
||||||
|
expect(setPendingReplyRefreshEnabled('missing-team', 'tab-a', false)).toBe(false);
|
||||||
|
expect(getActiveTeamPendingReplyWaits().size).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('clears waits by team or globally', () => {
|
||||||
|
setPendingReplyRefreshEnabled('my-team', 'tab-a', true);
|
||||||
|
setPendingReplyRefreshEnabled('other-team', 'tab-b', true);
|
||||||
|
|
||||||
|
clearPendingReplyRefreshWaits('my-team');
|
||||||
|
|
||||||
|
expect(hasActiveTeamPendingReplyWait('my-team')).toBe(false);
|
||||||
|
expect(getActiveTeamPendingReplyWaits()).toEqual(new Set(['other-team']));
|
||||||
|
|
||||||
|
clearAllPendingReplyRefreshWaits();
|
||||||
|
|
||||||
|
expect(hasActiveTeamPendingReplyWait('other-team')).toBe(false);
|
||||||
|
expect(getActiveTeamPendingReplyWaits().size).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
61
test/renderer/store/teamProvisioningStateRules.test.ts
Normal file
61
test/renderer/store/teamProvisioningStateRules.test.ts
Normal file
|
|
@ -0,0 +1,61 @@
|
||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
|
import {
|
||||||
|
isActiveProvisioningState,
|
||||||
|
isTerminalProvisioningState,
|
||||||
|
shouldIgnoreProvisioningProgressRegression,
|
||||||
|
} from '../../../src/renderer/store/team/teamProvisioningStateRules';
|
||||||
|
|
||||||
|
import type { TeamProvisioningProgress } from '../../../src/shared/types';
|
||||||
|
|
||||||
|
type ProgressState = TeamProvisioningProgress['state'];
|
||||||
|
|
||||||
|
const activeStates: ProgressState[] = [
|
||||||
|
'validating',
|
||||||
|
'spawning',
|
||||||
|
'configuring',
|
||||||
|
'assembling',
|
||||||
|
'finalizing',
|
||||||
|
'verifying',
|
||||||
|
];
|
||||||
|
|
||||||
|
const terminalStates: ProgressState[] = ['ready', 'failed', 'disconnected', 'cancelled'];
|
||||||
|
|
||||||
|
describe('teamProvisioningStateRules', () => {
|
||||||
|
it('classifies active provisioning states', () => {
|
||||||
|
for (const state of activeStates) {
|
||||||
|
expect(isActiveProvisioningState(state), state).toBe(true);
|
||||||
|
expect(isTerminalProvisioningState(state), state).toBe(false);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('classifies terminal provisioning states', () => {
|
||||||
|
for (const state of terminalStates) {
|
||||||
|
expect(isTerminalProvisioningState(state), state).toBe(true);
|
||||||
|
expect(isActiveProvisioningState(state), state).toBe(false);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('allows active state progressions and regressions to be processed', () => {
|
||||||
|
expect(shouldIgnoreProvisioningProgressRegression('spawning', 'validating')).toBe(false);
|
||||||
|
expect(shouldIgnoreProvisioningProgressRegression('validating', 'spawning')).toBe(false);
|
||||||
|
expect(shouldIgnoreProvisioningProgressRegression('verifying', 'ready')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('prevents ready from regressing except to disconnected', () => {
|
||||||
|
expect(shouldIgnoreProvisioningProgressRegression('ready', 'validating')).toBe(true);
|
||||||
|
expect(shouldIgnoreProvisioningProgressRegression('ready', 'failed')).toBe(true);
|
||||||
|
expect(shouldIgnoreProvisioningProgressRegression('ready', 'cancelled')).toBe(true);
|
||||||
|
expect(shouldIgnoreProvisioningProgressRegression('ready', 'ready')).toBe(false);
|
||||||
|
expect(shouldIgnoreProvisioningProgressRegression('ready', 'disconnected')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('locks failed, cancelled, and disconnected to their current terminal state', () => {
|
||||||
|
expect(shouldIgnoreProvisioningProgressRegression('failed', 'failed')).toBe(false);
|
||||||
|
expect(shouldIgnoreProvisioningProgressRegression('failed', 'ready')).toBe(true);
|
||||||
|
expect(shouldIgnoreProvisioningProgressRegression('cancelled', 'cancelled')).toBe(false);
|
||||||
|
expect(shouldIgnoreProvisioningProgressRegression('cancelled', 'spawning')).toBe(true);
|
||||||
|
expect(shouldIgnoreProvisioningProgressRegression('disconnected', 'disconnected')).toBe(false);
|
||||||
|
expect(shouldIgnoreProvisioningProgressRegression('disconnected', 'ready')).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
83
test/renderer/store/teamRefreshBurstDiagnosticsStore.test.ts
Normal file
83
test/renderer/store/teamRefreshBurstDiagnosticsStore.test.ts
Normal file
|
|
@ -0,0 +1,83 @@
|
||||||
|
import { afterEach, describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
|
import {
|
||||||
|
clearAllTeamRefreshBurstDiagnostics,
|
||||||
|
clearTeamRefreshBurstDiagnostics,
|
||||||
|
getTeamRefreshBurstDiagnosticForTests,
|
||||||
|
hasTeamRefreshBurstDiagnostics,
|
||||||
|
noteTeamRefreshBurst,
|
||||||
|
} from '../../../src/renderer/store/team/teamRefreshBurstDiagnostics';
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
clearAllTeamRefreshBurstDiagnostics();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('teamRefreshBurstDiagnostics store', () => {
|
||||||
|
it('creates a window on the first refresh note', () => {
|
||||||
|
expect(noteTeamRefreshBurst('my-team', 4_000, 10_000)).toBe(1);
|
||||||
|
|
||||||
|
expect(getTeamRefreshBurstDiagnosticForTests('my-team')).toEqual({
|
||||||
|
windowStartedAt: 10_000,
|
||||||
|
count: 1,
|
||||||
|
lastWarnAt: 0,
|
||||||
|
});
|
||||||
|
expect(hasTeamRefreshBurstDiagnostics('my-team')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('increments inside the active burst window', () => {
|
||||||
|
expect(noteTeamRefreshBurst('my-team', 4_000, 10_000)).toBe(1);
|
||||||
|
expect(noteTeamRefreshBurst('my-team', 4_000, 13_999)).toBe(2);
|
||||||
|
expect(noteTeamRefreshBurst('my-team', 4_000, 14_000)).toBe(3);
|
||||||
|
|
||||||
|
expect(getTeamRefreshBurstDiagnosticForTests('my-team')).toEqual({
|
||||||
|
windowStartedAt: 10_000,
|
||||||
|
count: 3,
|
||||||
|
lastWarnAt: 0,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('resets only after now is strictly beyond the burst window', () => {
|
||||||
|
expect(noteTeamRefreshBurst('my-team', 4_000, 10_000)).toBe(1);
|
||||||
|
expect(noteTeamRefreshBurst('my-team', 4_000, 14_001)).toBe(1);
|
||||||
|
|
||||||
|
expect(getTeamRefreshBurstDiagnosticForTests('my-team')).toEqual({
|
||||||
|
windowStartedAt: 14_001,
|
||||||
|
count: 1,
|
||||||
|
lastWarnAt: 0,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('tracks each team independently', () => {
|
||||||
|
noteTeamRefreshBurst('my-team', 4_000, 10_000);
|
||||||
|
noteTeamRefreshBurst('my-team', 4_000, 10_500);
|
||||||
|
noteTeamRefreshBurst('other-team', 4_000, 11_000);
|
||||||
|
|
||||||
|
expect(getTeamRefreshBurstDiagnosticForTests('my-team')?.count).toBe(2);
|
||||||
|
expect(getTeamRefreshBurstDiagnosticForTests('other-team')?.count).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('clears one team or all diagnostics', () => {
|
||||||
|
noteTeamRefreshBurst('my-team', 4_000, 10_000);
|
||||||
|
noteTeamRefreshBurst('other-team', 4_000, 11_000);
|
||||||
|
|
||||||
|
clearTeamRefreshBurstDiagnostics('my-team');
|
||||||
|
|
||||||
|
expect(hasTeamRefreshBurstDiagnostics('my-team')).toBe(false);
|
||||||
|
expect(hasTeamRefreshBurstDiagnostics('other-team')).toBe(true);
|
||||||
|
|
||||||
|
clearAllTeamRefreshBurstDiagnostics();
|
||||||
|
|
||||||
|
expect(hasTeamRefreshBurstDiagnostics('other-team')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns defensive diagnostic snapshots for tests', () => {
|
||||||
|
noteTeamRefreshBurst('my-team', 4_000, 10_000);
|
||||||
|
|
||||||
|
const snapshot = getTeamRefreshBurstDiagnosticForTests('my-team');
|
||||||
|
if (snapshot) {
|
||||||
|
snapshot.count = 99;
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(getTeamRefreshBurstDiagnosticForTests('my-team')?.count).toBe(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
210
test/renderer/store/teamResolvedMembers.test.ts
Normal file
210
test/renderer/store/teamResolvedMembers.test.ts
Normal file
|
|
@ -0,0 +1,210 @@
|
||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
|
import {
|
||||||
|
clearResolvedMemberSelectorCaches,
|
||||||
|
getResolvedMemberSelectorCacheSnapshotForTeam,
|
||||||
|
selectResolvedMemberForTeamName,
|
||||||
|
selectResolvedMembersForTeamName,
|
||||||
|
shouldPreserveSelectedTeamSnapshot,
|
||||||
|
} from '../../../src/renderer/store/team/teamResolvedMembers';
|
||||||
|
|
||||||
|
import type {
|
||||||
|
TeamMemberActivityMeta,
|
||||||
|
TeamMemberSnapshot,
|
||||||
|
TeamSummary,
|
||||||
|
TeamTask,
|
||||||
|
TeamViewSnapshot,
|
||||||
|
} from '../../../src/shared/types';
|
||||||
|
|
||||||
|
function createTask(overrides: Partial<TeamTask> = {}): TeamTask {
|
||||||
|
return {
|
||||||
|
id: 'task-1',
|
||||||
|
subject: 'Task',
|
||||||
|
owner: 'alice',
|
||||||
|
status: 'in_progress',
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function createSnapshot(overrides: Partial<TeamViewSnapshot> = {}): TeamViewSnapshot {
|
||||||
|
return {
|
||||||
|
teamName: 'my-team',
|
||||||
|
config: { name: 'My Team' },
|
||||||
|
tasks: [],
|
||||||
|
members: [],
|
||||||
|
kanbanState: { teamName: 'my-team', reviewers: [], tasks: {} },
|
||||||
|
processes: [],
|
||||||
|
...overrides,
|
||||||
|
} as TeamViewSnapshot;
|
||||||
|
}
|
||||||
|
|
||||||
|
function createState(
|
||||||
|
snapshot: TeamViewSnapshot,
|
||||||
|
options: {
|
||||||
|
summary?: TeamSummary;
|
||||||
|
meta?: TeamMemberActivityMeta;
|
||||||
|
} = {}
|
||||||
|
) {
|
||||||
|
return {
|
||||||
|
selectedTeamName: snapshot.teamName,
|
||||||
|
selectedTeamData: snapshot,
|
||||||
|
teamDataCacheByName: { [snapshot.teamName]: snapshot },
|
||||||
|
memberActivityMetaByTeam: options.meta ? { [snapshot.teamName]: options.meta } : {},
|
||||||
|
teamByName: options.summary ? { [snapshot.teamName]: options.summary } : {},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('teamResolvedMembers', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
vi.setSystemTime(new Date('2026-04-17T12:00:00.000Z'));
|
||||||
|
clearResolvedMemberSelectorCaches();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.useRealTimers();
|
||||||
|
clearResolvedMemberSelectorCaches();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('builds config fallback members when runtime snapshots are empty', () => {
|
||||||
|
const snapshot = createSnapshot({
|
||||||
|
config: {
|
||||||
|
name: 'My Team',
|
||||||
|
members: [
|
||||||
|
{ name: 'team-lead', role: 'Lead' },
|
||||||
|
{ name: 'alice', agentId: 'agent-a', role: 'Engineer' },
|
||||||
|
{ name: 'Alice', agentId: 'duplicate' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
tasks: [
|
||||||
|
createTask({ id: 'task-active', owner: 'alice', status: 'in_progress' }),
|
||||||
|
createTask({ id: 'task-done', owner: 'alice', status: 'completed' }),
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
const members = selectResolvedMembersForTeamName(createState(snapshot), 'my-team');
|
||||||
|
|
||||||
|
expect(members.map((member) => member.name)).toEqual(['team-lead', 'alice']);
|
||||||
|
expect(members[1]).toMatchObject({
|
||||||
|
name: 'alice',
|
||||||
|
agentId: 'agent-a',
|
||||||
|
currentTaskId: 'task-active',
|
||||||
|
taskCount: 2,
|
||||||
|
role: 'Engineer',
|
||||||
|
status: 'active',
|
||||||
|
messageCount: 0,
|
||||||
|
lastActiveAt: null,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('builds summary fallback members with a lead when config and runtime snapshots are empty', () => {
|
||||||
|
const snapshot = createSnapshot();
|
||||||
|
const summary = {
|
||||||
|
teamName: 'my-team',
|
||||||
|
displayName: 'My Team',
|
||||||
|
memberCount: 2,
|
||||||
|
taskCount: 0,
|
||||||
|
lastActivity: null,
|
||||||
|
leadName: 'lead-one',
|
||||||
|
leadColor: '#fff',
|
||||||
|
members: [
|
||||||
|
{ name: 'lead-one', role: 'Lead' },
|
||||||
|
{ name: 'bob', agentId: 'agent-b', role: 'Reviewer', color: '#123456' },
|
||||||
|
{ name: 'Bob', agentId: 'duplicate' },
|
||||||
|
],
|
||||||
|
} as TeamSummary;
|
||||||
|
|
||||||
|
const members = selectResolvedMembersForTeamName(createState(snapshot, { summary }), 'my-team');
|
||||||
|
|
||||||
|
expect(members.map((member) => member.name)).toEqual(['lead-one', 'bob']);
|
||||||
|
expect(members[0]).toMatchObject({ agentType: 'team-lead', role: 'Team Lead' });
|
||||||
|
expect(members[1]).toMatchObject({
|
||||||
|
agentId: 'agent-b',
|
||||||
|
role: 'Reviewer',
|
||||||
|
color: '#123456',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('memoizes selector results until resolved-member cache is cleared', () => {
|
||||||
|
const snapshot = createSnapshot({
|
||||||
|
members: [{ name: 'alice', currentTaskId: null, taskCount: 0 } as TeamMemberSnapshot],
|
||||||
|
});
|
||||||
|
const state = createState(snapshot);
|
||||||
|
|
||||||
|
const firstMembers = selectResolvedMembersForTeamName(state, 'my-team');
|
||||||
|
const secondMembers = selectResolvedMembersForTeamName(state, 'my-team');
|
||||||
|
const firstAlice = selectResolvedMemberForTeamName(state, 'my-team', 'alice');
|
||||||
|
const secondAlice = selectResolvedMemberForTeamName(state, 'my-team', 'alice');
|
||||||
|
|
||||||
|
expect(secondMembers).toBe(firstMembers);
|
||||||
|
expect(secondAlice).toBe(firstAlice);
|
||||||
|
expect(getResolvedMemberSelectorCacheSnapshotForTeam('my-team')).toEqual({
|
||||||
|
hasResolvedMembersSelector: true,
|
||||||
|
resolvedMemberSelectorCount: 1,
|
||||||
|
});
|
||||||
|
|
||||||
|
clearResolvedMemberSelectorCaches();
|
||||||
|
|
||||||
|
expect(selectResolvedMembersForTeamName(state, 'my-team')).not.toBe(firstMembers);
|
||||||
|
expect(getResolvedMemberSelectorCacheSnapshotForTeam('my-team')).toEqual({
|
||||||
|
hasResolvedMembersSelector: true,
|
||||||
|
resolvedMemberSelectorCount: 0,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('derives activity status from member activity metadata', () => {
|
||||||
|
const snapshot = createSnapshot({
|
||||||
|
members: [{ name: 'alice', currentTaskId: null, taskCount: 0 } as TeamMemberSnapshot],
|
||||||
|
});
|
||||||
|
const meta = {
|
||||||
|
teamName: 'my-team',
|
||||||
|
feedRevision: 'rev-1',
|
||||||
|
computedAt: '2026-04-17T12:00:00.000Z',
|
||||||
|
members: {
|
||||||
|
alice: {
|
||||||
|
memberName: 'alice',
|
||||||
|
lastAuthoredMessageAt: '2026-04-17T11:57:00.000Z',
|
||||||
|
messageCountExact: 3,
|
||||||
|
latestAuthoredMessageSignalsTermination: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
} as TeamMemberActivityMeta;
|
||||||
|
|
||||||
|
expect(selectResolvedMemberForTeamName(createState(snapshot, { meta }), 'my-team', 'alice'))
|
||||||
|
.toMatchObject({
|
||||||
|
status: 'active',
|
||||||
|
messageCount: 3,
|
||||||
|
lastActiveAt: '2026-04-17T11:57:00.000Z',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('preserves the selected snapshot when an incoming empty snapshot is confirmed by summary', () => {
|
||||||
|
const current = createSnapshot({
|
||||||
|
members: [{ name: 'alice', currentTaskId: null, taskCount: 0 } as TeamMemberSnapshot],
|
||||||
|
});
|
||||||
|
const incoming = createSnapshot({ members: [], config: { name: 'My Team' } });
|
||||||
|
const summary = {
|
||||||
|
teamName: 'my-team',
|
||||||
|
displayName: 'My Team',
|
||||||
|
memberCount: 1,
|
||||||
|
expectedMemberCount: 1,
|
||||||
|
taskCount: 0,
|
||||||
|
lastActivity: null,
|
||||||
|
members: [{ name: 'alice' }],
|
||||||
|
} as TeamSummary;
|
||||||
|
|
||||||
|
expect(shouldPreserveSelectedTeamSnapshot(current, current, incoming, summary)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not preserve the selected snapshot when incoming data has a config roster', () => {
|
||||||
|
const current = createSnapshot({
|
||||||
|
members: [{ name: 'alice', currentTaskId: null, taskCount: 0 } as TeamMemberSnapshot],
|
||||||
|
});
|
||||||
|
const incoming = createSnapshot({
|
||||||
|
members: [],
|
||||||
|
config: { name: 'My Team', members: [{ name: 'bob' }] },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(shouldPreserveSelectedTeamSnapshot(current, current, incoming, undefined)).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
203
test/renderer/store/teamScopedStateCleanup.test.ts
Normal file
203
test/renderer/store/teamScopedStateCleanup.test.ts
Normal file
|
|
@ -0,0 +1,203 @@
|
||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
|
import {
|
||||||
|
buildTeamScopedProgressTombstones,
|
||||||
|
collectTeamScopedStateRemovals,
|
||||||
|
collectTeamScopedVisibleLoadingResets,
|
||||||
|
} from '../../../src/renderer/store/team/teamScopedStateCleanup';
|
||||||
|
|
||||||
|
const teamScopedRecordKeys = [
|
||||||
|
'teamDataCacheByName',
|
||||||
|
'teamAgentRuntimeByTeam',
|
||||||
|
'teamMessagesByName',
|
||||||
|
'memberActivityMetaByTeam',
|
||||||
|
'provisioningSnapshotByTeam',
|
||||||
|
'currentProvisioningRunIdByTeam',
|
||||||
|
'currentRuntimeRunIdByTeam',
|
||||||
|
'provisioningStartedAtFloorByTeam',
|
||||||
|
'leadActivityByTeam',
|
||||||
|
'leadContextByTeam',
|
||||||
|
'activeTaskLogActivityByTeam',
|
||||||
|
'activeToolsByTeam',
|
||||||
|
'finishedVisibleByTeam',
|
||||||
|
'toolHistoryByTeam',
|
||||||
|
'memberSpawnStatusesByTeam',
|
||||||
|
'memberSpawnSnapshotsByTeam',
|
||||||
|
'provisioningErrorByTeam',
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
function buildRecord(label: string): Record<string, unknown> {
|
||||||
|
return {
|
||||||
|
'my-team': `${label}:mine`,
|
||||||
|
'other-team': `${label}:other`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildRemovalState(): Parameters<typeof collectTeamScopedStateRemovals>[0] {
|
||||||
|
return {
|
||||||
|
provisioningRuns: {
|
||||||
|
'run-mine-1': { teamName: 'my-team' },
|
||||||
|
'run-other': { teamName: 'other-team' },
|
||||||
|
'run-mine-2': { teamName: 'my-team' },
|
||||||
|
},
|
||||||
|
teamDataCacheByName: buildRecord('teamDataCacheByName'),
|
||||||
|
teamAgentRuntimeByTeam: buildRecord('teamAgentRuntimeByTeam'),
|
||||||
|
teamMessagesByName: buildRecord('teamMessagesByName'),
|
||||||
|
memberActivityMetaByTeam: buildRecord('memberActivityMetaByTeam'),
|
||||||
|
provisioningSnapshotByTeam: buildRecord('provisioningSnapshotByTeam'),
|
||||||
|
currentProvisioningRunIdByTeam: buildRecord('currentProvisioningRunIdByTeam'),
|
||||||
|
currentRuntimeRunIdByTeam: buildRecord('currentRuntimeRunIdByTeam'),
|
||||||
|
provisioningStartedAtFloorByTeam: buildRecord('provisioningStartedAtFloorByTeam'),
|
||||||
|
leadActivityByTeam: buildRecord('leadActivityByTeam'),
|
||||||
|
leadContextByTeam: buildRecord('leadContextByTeam'),
|
||||||
|
activeTaskLogActivityByTeam: buildRecord('activeTaskLogActivityByTeam'),
|
||||||
|
activeToolsByTeam: buildRecord('activeToolsByTeam'),
|
||||||
|
finishedVisibleByTeam: buildRecord('finishedVisibleByTeam'),
|
||||||
|
toolHistoryByTeam: buildRecord('toolHistoryByTeam'),
|
||||||
|
memberSpawnStatusesByTeam: buildRecord('memberSpawnStatusesByTeam'),
|
||||||
|
memberSpawnSnapshotsByTeam: buildRecord('memberSpawnSnapshotsByTeam'),
|
||||||
|
provisioningErrorByTeam: buildRecord('provisioningErrorByTeam'),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('teamScopedStateCleanup', () => {
|
||||||
|
it('resets visible team loading and message loading flags for the scoped team', () => {
|
||||||
|
const otherEntry = {
|
||||||
|
loadingHead: true,
|
||||||
|
loadingOlder: false,
|
||||||
|
marker: 'other',
|
||||||
|
};
|
||||||
|
const patch = collectTeamScopedVisibleLoadingResets(
|
||||||
|
{
|
||||||
|
teamMessagesByName: {
|
||||||
|
'my-team': {
|
||||||
|
loadingHead: true,
|
||||||
|
loadingOlder: true,
|
||||||
|
marker: 'mine',
|
||||||
|
},
|
||||||
|
'other-team': otherEntry,
|
||||||
|
},
|
||||||
|
selectedTeamName: 'my-team',
|
||||||
|
selectedTeamLoading: true,
|
||||||
|
selectedTeamError: 'Boom',
|
||||||
|
},
|
||||||
|
'my-team'
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(patch).toEqual({
|
||||||
|
teamMessagesByName: {
|
||||||
|
'my-team': {
|
||||||
|
loadingHead: false,
|
||||||
|
loadingOlder: false,
|
||||||
|
marker: 'mine',
|
||||||
|
},
|
||||||
|
'other-team': otherEntry,
|
||||||
|
},
|
||||||
|
selectedTeamLoading: false,
|
||||||
|
selectedTeamError: null,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not emit visible loading changes when the scoped team is already idle', () => {
|
||||||
|
const patch = collectTeamScopedVisibleLoadingResets(
|
||||||
|
{
|
||||||
|
teamMessagesByName: {
|
||||||
|
'my-team': {
|
||||||
|
loadingHead: false,
|
||||||
|
loadingOlder: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
selectedTeamName: 'other-team',
|
||||||
|
selectedTeamLoading: false,
|
||||||
|
selectedTeamError: null,
|
||||||
|
},
|
||||||
|
'my-team'
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(patch).toEqual({});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('removes scoped team records and provisioning runs while preserving other teams', () => {
|
||||||
|
const patch = collectTeamScopedStateRemovals(buildRemovalState(), 'my-team');
|
||||||
|
|
||||||
|
expect(patch.provisioningRuns).toEqual({
|
||||||
|
'run-other': { teamName: 'other-team' },
|
||||||
|
});
|
||||||
|
for (const key of teamScopedRecordKeys) {
|
||||||
|
expect(patch[key]).toEqual({
|
||||||
|
'other-team': `${key}:other`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not emit removal changes when the team is absent', () => {
|
||||||
|
const state = buildRemovalState();
|
||||||
|
const patch = collectTeamScopedStateRemovals(state, 'missing-team');
|
||||||
|
|
||||||
|
expect(patch).toEqual({});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('tombstones current provisioning and runtime run ids for the scoped team', () => {
|
||||||
|
const tombstones = buildTeamScopedProgressTombstones(
|
||||||
|
{
|
||||||
|
currentProvisioningRunIdByTeam: {
|
||||||
|
'my-team': 'provisioning-run-1',
|
||||||
|
'other-team': 'provisioning-run-2',
|
||||||
|
},
|
||||||
|
currentRuntimeRunIdByTeam: {
|
||||||
|
'my-team': 'runtime-run-1',
|
||||||
|
},
|
||||||
|
ignoredProvisioningRunIds: {
|
||||||
|
old: 'old-team',
|
||||||
|
},
|
||||||
|
ignoredRuntimeRunIds: {
|
||||||
|
'old-runtime': 'old-team',
|
||||||
|
},
|
||||||
|
provisioningStartedAtFloorByTeam: {
|
||||||
|
'other-team': '2026-01-01T00:00:00.000Z',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
'my-team',
|
||||||
|
'2026-05-22T10:00:00.000Z'
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(tombstones).toEqual({
|
||||||
|
ignoredProvisioningRunIds: {
|
||||||
|
old: 'old-team',
|
||||||
|
'provisioning-run-1': 'my-team',
|
||||||
|
},
|
||||||
|
ignoredRuntimeRunIds: {
|
||||||
|
'old-runtime': 'old-team',
|
||||||
|
'runtime-run-1': 'my-team',
|
||||||
|
},
|
||||||
|
provisioningStartedAtFloorByTeam: {
|
||||||
|
'other-team': '2026-01-01T00:00:00.000Z',
|
||||||
|
'my-team': '2026-05-22T10:00:00.000Z',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('still records a floor when there are no current run ids to tombstone', () => {
|
||||||
|
const tombstones = buildTeamScopedProgressTombstones(
|
||||||
|
{
|
||||||
|
currentProvisioningRunIdByTeam: {},
|
||||||
|
currentRuntimeRunIdByTeam: {
|
||||||
|
'my-team': null,
|
||||||
|
},
|
||||||
|
ignoredProvisioningRunIds: {},
|
||||||
|
ignoredRuntimeRunIds: {},
|
||||||
|
provisioningStartedAtFloorByTeam: {},
|
||||||
|
},
|
||||||
|
'my-team',
|
||||||
|
'2026-05-22T10:00:00.000Z'
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(tombstones).toEqual({
|
||||||
|
ignoredProvisioningRunIds: {},
|
||||||
|
ignoredRuntimeRunIds: {},
|
||||||
|
provisioningStartedAtFloorByTeam: {
|
||||||
|
'my-team': '2026-05-22T10:00:00.000Z',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
117
test/renderer/store/teamSnapshotStructuralSharing.test.ts
Normal file
117
test/renderer/store/teamSnapshotStructuralSharing.test.ts
Normal file
|
|
@ -0,0 +1,117 @@
|
||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
|
import {
|
||||||
|
structurallySharePlainValue,
|
||||||
|
structurallyShareTeamSnapshot,
|
||||||
|
} from '../../../src/renderer/store/team/teamSnapshotStructuralSharing';
|
||||||
|
|
||||||
|
import type { TeamViewSnapshot } from '../../../src/shared/types';
|
||||||
|
|
||||||
|
function createSnapshot(overrides: Partial<TeamViewSnapshot> = {}): TeamViewSnapshot {
|
||||||
|
return {
|
||||||
|
teamName: 'my-team',
|
||||||
|
config: { name: 'My Team' },
|
||||||
|
members: [],
|
||||||
|
tasks: [],
|
||||||
|
kanbanState: { teamName: 'my-team', reviewers: [], tasks: {} },
|
||||||
|
processes: [],
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('teamSnapshotStructuralSharing', () => {
|
||||||
|
it('returns the next snapshot when there is no previous snapshot', () => {
|
||||||
|
const next = createSnapshot();
|
||||||
|
|
||||||
|
expect(structurallyShareTeamSnapshot(null, next)).toBe(next);
|
||||||
|
expect(structurallyShareTeamSnapshot(undefined, next)).toBe(next);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('preserves the previous snapshot reference when values are deeply equal', () => {
|
||||||
|
const previous = createSnapshot({
|
||||||
|
config: { name: 'My Team', description: 'Same description' },
|
||||||
|
warnings: ['same warning'],
|
||||||
|
isAlive: true,
|
||||||
|
});
|
||||||
|
const next = createSnapshot({
|
||||||
|
config: { name: 'My Team', description: 'Same description' },
|
||||||
|
warnings: ['same warning'],
|
||||||
|
isAlive: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(structurallyShareTeamSnapshot(previous, next)).toBe(previous);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('replaces only changed snapshot branches while sharing unchanged branches', () => {
|
||||||
|
const previousWarnings = ['same warning'];
|
||||||
|
const previous = createSnapshot({
|
||||||
|
config: { name: 'My Team', description: 'Old description' },
|
||||||
|
warnings: previousWarnings,
|
||||||
|
isAlive: true,
|
||||||
|
});
|
||||||
|
const next = createSnapshot({
|
||||||
|
config: { name: 'My Team', description: 'New description' },
|
||||||
|
warnings: ['same warning'],
|
||||||
|
isAlive: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
const shared = structurallyShareTeamSnapshot(previous, next);
|
||||||
|
|
||||||
|
expect(shared).not.toBe(previous);
|
||||||
|
expect(shared).toEqual(next);
|
||||||
|
expect(shared.config).not.toBe(previous.config);
|
||||||
|
expect(shared.warnings).toBe(previousWarnings);
|
||||||
|
expect(shared.members).toBe(previous.members);
|
||||||
|
expect(shared.tasks).toBe(previous.tasks);
|
||||||
|
expect(shared.kanbanState).toBe(previous.kanbanState);
|
||||||
|
expect(shared.processes).toBe(previous.processes);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shares unchanged array entries and replaces changed entries', () => {
|
||||||
|
const previous = [
|
||||||
|
{ id: 'task-1', title: 'Keep' },
|
||||||
|
{ id: 'task-2', title: 'Old' },
|
||||||
|
];
|
||||||
|
const next = [
|
||||||
|
{ id: 'task-1', title: 'Keep' },
|
||||||
|
{ id: 'task-2', title: 'New' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const shared = structurallySharePlainValue(previous, next);
|
||||||
|
|
||||||
|
expect(shared).not.toBe(previous);
|
||||||
|
expect(shared).toEqual(next);
|
||||||
|
expect(shared[0]).toBe(previous[0]);
|
||||||
|
expect(shared[1]).not.toBe(previous[1]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('replaces objects when keys are added or removed', () => {
|
||||||
|
const previous = { id: 'task-1', title: 'Same', extra: true };
|
||||||
|
const next = { id: 'task-1', title: 'Same' };
|
||||||
|
|
||||||
|
const shared = structurallySharePlainValue(previous, next);
|
||||||
|
|
||||||
|
expect(shared).not.toBe(previous);
|
||||||
|
expect(shared).toEqual(next);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('treats null-prototype objects as plain values', () => {
|
||||||
|
const previous = Object.assign(Object.create(null) as Record<string, unknown>, {
|
||||||
|
id: 'task-1',
|
||||||
|
title: 'Same',
|
||||||
|
});
|
||||||
|
const next = Object.assign(Object.create(null) as Record<string, unknown>, {
|
||||||
|
id: 'task-1',
|
||||||
|
title: 'Same',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(structurallySharePlainValue(previous, next)).toBe(previous);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('replaces non-plain objects instead of traversing them', () => {
|
||||||
|
const previous = new Date('2026-05-22T10:00:00.000Z');
|
||||||
|
const next = new Date('2026-05-22T10:00:00.000Z');
|
||||||
|
|
||||||
|
expect(structurallySharePlainValue(previous, next)).toBe(next);
|
||||||
|
});
|
||||||
|
});
|
||||||
81
test/renderer/store/teamToolApprovalSettings.test.ts
Normal file
81
test/renderer/store/teamToolApprovalSettings.test.ts
Normal file
|
|
@ -0,0 +1,81 @@
|
||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
|
import { parseToolApprovalSettings } from '../../../src/renderer/store/team/teamToolApprovalSettings';
|
||||||
|
import { DEFAULT_TOOL_APPROVAL_SETTINGS } from '../../../src/shared/types/team';
|
||||||
|
|
||||||
|
describe('teamToolApprovalSettings', () => {
|
||||||
|
it('returns defaults for missing or invalid JSON', () => {
|
||||||
|
expect(parseToolApprovalSettings(null)).toBe(DEFAULT_TOOL_APPROVAL_SETTINGS);
|
||||||
|
expect(parseToolApprovalSettings('')).toBe(DEFAULT_TOOL_APPROVAL_SETTINGS);
|
||||||
|
expect(parseToolApprovalSettings('{not json')).toBe(DEFAULT_TOOL_APPROVAL_SETTINGS);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('parses valid complete settings', () => {
|
||||||
|
expect(
|
||||||
|
parseToolApprovalSettings(
|
||||||
|
JSON.stringify({
|
||||||
|
autoAllowAll: true,
|
||||||
|
autoAllowFileEdits: true,
|
||||||
|
autoAllowSafeBash: true,
|
||||||
|
timeoutAction: 'allow',
|
||||||
|
timeoutSeconds: 120,
|
||||||
|
})
|
||||||
|
)
|
||||||
|
).toEqual({
|
||||||
|
autoAllowAll: true,
|
||||||
|
autoAllowFileEdits: true,
|
||||||
|
autoAllowSafeBash: true,
|
||||||
|
timeoutAction: 'allow',
|
||||||
|
timeoutSeconds: 120,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('falls back per field when values have invalid types', () => {
|
||||||
|
expect(
|
||||||
|
parseToolApprovalSettings(
|
||||||
|
JSON.stringify({
|
||||||
|
autoAllowAll: 'yes',
|
||||||
|
autoAllowFileEdits: true,
|
||||||
|
autoAllowSafeBash: 1,
|
||||||
|
timeoutAction: 'maybe',
|
||||||
|
timeoutSeconds: '60',
|
||||||
|
})
|
||||||
|
)
|
||||||
|
).toEqual({
|
||||||
|
...DEFAULT_TOOL_APPROVAL_SETTINGS,
|
||||||
|
autoAllowFileEdits: true,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('accepts timeout actions allow, deny, and wait', () => {
|
||||||
|
expect(parseToolApprovalSettings(JSON.stringify({ timeoutAction: 'allow' })).timeoutAction).toBe(
|
||||||
|
'allow'
|
||||||
|
);
|
||||||
|
expect(parseToolApprovalSettings(JSON.stringify({ timeoutAction: 'deny' })).timeoutAction).toBe(
|
||||||
|
'deny'
|
||||||
|
);
|
||||||
|
expect(parseToolApprovalSettings(JSON.stringify({ timeoutAction: 'wait' })).timeoutAction).toBe(
|
||||||
|
'wait'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('accepts timeout seconds at inclusive boundaries', () => {
|
||||||
|
expect(parseToolApprovalSettings(JSON.stringify({ timeoutSeconds: 5 })).timeoutSeconds).toBe(5);
|
||||||
|
expect(parseToolApprovalSettings(JSON.stringify({ timeoutSeconds: 300 })).timeoutSeconds).toBe(
|
||||||
|
300
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects timeout seconds outside allowed boundaries or non-finite values', () => {
|
||||||
|
expect(parseToolApprovalSettings(JSON.stringify({ timeoutSeconds: 4 })).timeoutSeconds).toBe(
|
||||||
|
DEFAULT_TOOL_APPROVAL_SETTINGS.timeoutSeconds
|
||||||
|
);
|
||||||
|
expect(parseToolApprovalSettings(JSON.stringify({ timeoutSeconds: 301 })).timeoutSeconds).toBe(
|
||||||
|
DEFAULT_TOOL_APPROVAL_SETTINGS.timeoutSeconds
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
parseToolApprovalSettings(JSON.stringify({ timeoutSeconds: Number.POSITIVE_INFINITY }))
|
||||||
|
.timeoutSeconds
|
||||||
|
).toBe(DEFAULT_TOOL_APPROVAL_SETTINGS.timeoutSeconds);
|
||||||
|
});
|
||||||
|
});
|
||||||
Loading…
Reference in a new issue