feat: enhance member management with CLI auto-suffix validation and cleanup
- Implemented validation for member names to prevent the use of auto-suffixed names (e.g., "alice-2") in TeamDataService and TeamProvisioningService. - Added cleanup functionality to remove auto-suffixed members from configuration and metadata, ensuring a cleaner team member list. - Enhanced error handling for member name inputs, including checks for empty names and reserved names like "team-lead". - Updated member management logic in TeamFSWorker to drop auto-suffixed members from the member map. - Improved overall robustness of team member handling across services.
This commit is contained in:
parent
c006fad97d
commit
62cda45a91
4 changed files with 249 additions and 12 deletions
|
|
@ -12,6 +12,7 @@ import { killProcessByPid } from '@main/utils/processKill';
|
||||||
import { AGENT_BLOCK_CLOSE, AGENT_BLOCK_OPEN } from '@shared/constants/agentBlocks';
|
import { AGENT_BLOCK_CLOSE, AGENT_BLOCK_OPEN } from '@shared/constants/agentBlocks';
|
||||||
import { getMemberColor } from '@shared/constants/memberColors';
|
import { getMemberColor } from '@shared/constants/memberColors';
|
||||||
import { createLogger } from '@shared/utils/logger';
|
import { createLogger } from '@shared/utils/logger';
|
||||||
|
import { parseNumericSuffixName } from '@shared/utils/teamMemberName';
|
||||||
import { randomUUID } from 'crypto';
|
import { randomUUID } from 'crypto';
|
||||||
import * as fs from 'fs';
|
import * as fs from 'fs';
|
||||||
import * as path from 'path';
|
import * as path from 'path';
|
||||||
|
|
@ -619,18 +620,29 @@ export class TeamDataService {
|
||||||
}
|
}
|
||||||
|
|
||||||
async addMember(teamName: string, request: AddMemberRequest): Promise<void> {
|
async addMember(teamName: string, request: AddMemberRequest): Promise<void> {
|
||||||
|
const name = request.name.trim();
|
||||||
|
if (!name) {
|
||||||
|
throw new Error('Member name cannot be empty');
|
||||||
|
}
|
||||||
|
const suffixInfo = parseNumericSuffixName(name);
|
||||||
|
if (suffixInfo && suffixInfo.suffix >= 2) {
|
||||||
|
throw new Error(
|
||||||
|
`Member name "${name}" is not allowed (reserved for Claude CLI auto-suffix). Use "${suffixInfo.base}" instead.`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const members = await this.membersMetaStore.getMembers(teamName);
|
const members = await this.membersMetaStore.getMembers(teamName);
|
||||||
const existing = members.find((m) => m.name.toLowerCase() === request.name.toLowerCase());
|
const existing = members.find((m) => m.name.toLowerCase() === name.toLowerCase());
|
||||||
|
|
||||||
if (existing) {
|
if (existing) {
|
||||||
if (existing.removedAt) {
|
if (existing.removedAt) {
|
||||||
throw new Error(`Name "${request.name}" was previously used by a removed member`);
|
throw new Error(`Name "${name}" was previously used by a removed member`);
|
||||||
}
|
}
|
||||||
throw new Error(`Member "${request.name}" already exists`);
|
throw new Error(`Member "${name}" already exists`);
|
||||||
}
|
}
|
||||||
|
|
||||||
const newMember: TeamMember = {
|
const newMember: TeamMember = {
|
||||||
name: request.name,
|
name,
|
||||||
role: request.role?.trim() || undefined,
|
role: request.role?.trim() || undefined,
|
||||||
workflow: request.workflow?.trim() || undefined,
|
workflow: request.workflow?.trim() || undefined,
|
||||||
agentType: 'general-purpose',
|
agentType: 'general-purpose',
|
||||||
|
|
@ -680,6 +692,12 @@ export class TeamDataService {
|
||||||
if (name.toLowerCase() === 'team-lead') {
|
if (name.toLowerCase() === 'team-lead') {
|
||||||
throw new Error('Member name "team-lead" is reserved');
|
throw new Error('Member name "team-lead" is reserved');
|
||||||
}
|
}
|
||||||
|
const suffixInfo = parseNumericSuffixName(name);
|
||||||
|
if (suffixInfo && suffixInfo.suffix >= 2) {
|
||||||
|
throw new Error(
|
||||||
|
`Member name "${name}" is not allowed (reserved for Claude CLI auto-suffix). Use "${suffixInfo.base}" instead.`
|
||||||
|
);
|
||||||
|
}
|
||||||
nextByName.add(name.toLowerCase());
|
nextByName.add(name.toLowerCase());
|
||||||
const prev = existingByName.get(name.toLowerCase());
|
const prev = existingByName.get(name.toLowerCase());
|
||||||
return {
|
return {
|
||||||
|
|
@ -1193,7 +1211,18 @@ export class TeamDataService {
|
||||||
await this.membersMetaStore.writeMembers(
|
await this.membersMetaStore.writeMembers(
|
||||||
request.teamName,
|
request.teamName,
|
||||||
request.members.map((member, index) => ({
|
request.members.map((member, index) => ({
|
||||||
name: member.name,
|
name: (() => {
|
||||||
|
const name = member.name.trim();
|
||||||
|
if (!name) throw new Error('Member name cannot be empty');
|
||||||
|
if (name.toLowerCase() === 'team-lead') throw new Error('Member name "team-lead" is reserved');
|
||||||
|
const suffixInfo = parseNumericSuffixName(name);
|
||||||
|
if (suffixInfo && suffixInfo.suffix >= 2) {
|
||||||
|
throw new Error(
|
||||||
|
`Member name "${name}" is not allowed (reserved for Claude CLI auto-suffix). Use "${suffixInfo.base}" instead.`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return name;
|
||||||
|
})(),
|
||||||
role: member.role?.trim() || undefined,
|
role: member.role?.trim() || undefined,
|
||||||
agentType: 'general-purpose',
|
agentType: 'general-purpose',
|
||||||
color: getMemberColor(index),
|
color: getMemberColor(index),
|
||||||
|
|
|
||||||
|
|
@ -21,6 +21,7 @@ import { getMemberColor } from '@shared/constants/memberColors';
|
||||||
import { resolveLanguageName } from '@shared/utils/agentLanguage';
|
import { resolveLanguageName } from '@shared/utils/agentLanguage';
|
||||||
import { isInboxNoiseMessage } from '@shared/utils/inboxNoise';
|
import { isInboxNoiseMessage } from '@shared/utils/inboxNoise';
|
||||||
import { createLogger } from '@shared/utils/logger';
|
import { createLogger } from '@shared/utils/logger';
|
||||||
|
import { createCliAutoSuffixNameGuard } from '@shared/utils/teamMemberName';
|
||||||
import { spawn } from 'child_process';
|
import { spawn } from 'child_process';
|
||||||
import { randomUUID } from 'crypto';
|
import { randomUUID } from 'crypto';
|
||||||
import * as fs from 'fs';
|
import * as fs from 'fs';
|
||||||
|
|
@ -1924,12 +1925,19 @@ export class TeamProvisioningService {
|
||||||
|
|
||||||
// IMPORTANT: The CLI auto-suffixes teammate names when they already exist in config.json.
|
// IMPORTANT: The CLI auto-suffixes teammate names when they already exist in config.json.
|
||||||
// Normalize config.json to keep only the team-lead before spawning the CLI, so we get stable names.
|
// Normalize config.json to keep only the team-lead before spawning the CLI, so we get stable names.
|
||||||
await this.normalizeTeamConfigForLaunch(request.teamName, configRaw);
|
try {
|
||||||
|
await this.normalizeTeamConfigForLaunch(request.teamName, configRaw);
|
||||||
|
await this.assertConfigLeadOnlyForLaunch(request.teamName);
|
||||||
|
|
||||||
// Update projectPath in config IMMEDIATELY so TeamDetailView shows the correct path
|
// Update projectPath in config IMMEDIATELY so TeamDetailView shows the correct path
|
||||||
// even if provisioning is interrupted or the user stops the team early.
|
// even if provisioning is interrupted or the user stops the team early.
|
||||||
// If launch fails, restorePrelaunchConfig() will revert to the backup (old projectPath).
|
// If launch fails, restorePrelaunchConfig() will revert to the backup (old projectPath).
|
||||||
await this.updateConfigProjectPath(request.teamName, request.cwd);
|
await this.updateConfigProjectPath(request.teamName, request.cwd);
|
||||||
|
} catch (error) {
|
||||||
|
// Restore pre-launch backup so config.json is not left in normalized (lead-only) state.
|
||||||
|
await this.restorePrelaunchConfig(request.teamName);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
let claudePath: string | null;
|
let claudePath: string | null;
|
||||||
try {
|
try {
|
||||||
|
|
@ -3138,6 +3146,10 @@ export class TeamProvisioningService {
|
||||||
await this.updateConfigPostLaunch(run.teamName, run.request.cwd, run.detectedSessionId);
|
await this.updateConfigPostLaunch(run.teamName, run.request.cwd, run.detectedSessionId);
|
||||||
await this.cleanupPrelaunchBackup(run.teamName);
|
await this.cleanupPrelaunchBackup(run.teamName);
|
||||||
|
|
||||||
|
// Defense in depth: if the CLI (or a stale config) produced auto-suffixed members (alice-2),
|
||||||
|
// clean them up so they don't persist and reappear in the UI.
|
||||||
|
await this.cleanupCliAutoSuffixedMembers(run.teamName);
|
||||||
|
|
||||||
// Best-effort: detect CLI-suffixed member names (alice-2, bob-2) that indicate
|
// Best-effort: detect CLI-suffixed member names (alice-2, bob-2) that indicate
|
||||||
// a stale config.json was present during launch (double-launch race).
|
// a stale config.json was present during launch (double-launch race).
|
||||||
try {
|
try {
|
||||||
|
|
@ -3898,6 +3910,106 @@ export class TeamProvisioningService {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async cleanupCliAutoSuffixedMembers(teamName: string): Promise<void> {
|
||||||
|
const configPath = path.join(getTeamsBasePath(), teamName, 'config.json');
|
||||||
|
|
||||||
|
let removedFromConfig: string[] = [];
|
||||||
|
try {
|
||||||
|
const raw = await tryReadRegularFileUtf8(configPath, {
|
||||||
|
timeoutMs: TEAM_JSON_READ_TIMEOUT_MS,
|
||||||
|
maxBytes: TEAM_CONFIG_MAX_BYTES,
|
||||||
|
});
|
||||||
|
if (raw) {
|
||||||
|
const parsed = JSON.parse(raw) as Record<string, unknown>;
|
||||||
|
const membersRaw = Array.isArray(parsed.members)
|
||||||
|
? (parsed.members as Record<string, unknown>[])
|
||||||
|
: [];
|
||||||
|
if (membersRaw.length > 0) {
|
||||||
|
const teammateNames = membersRaw
|
||||||
|
.map((m) => (typeof m.name === 'string' ? m.name.trim() : ''))
|
||||||
|
.filter((n) => n.length > 0 && n.toLowerCase() !== 'team-lead' && n.toLowerCase() !== 'user');
|
||||||
|
|
||||||
|
const keepName = createCliAutoSuffixNameGuard(teammateNames);
|
||||||
|
const nextMembers: Record<string, unknown>[] = [];
|
||||||
|
for (const m of membersRaw) {
|
||||||
|
const name = typeof m.name === 'string' ? m.name.trim() : '';
|
||||||
|
const agentType = typeof m.agentType === 'string' ? m.agentType : '';
|
||||||
|
if (!name) continue;
|
||||||
|
if (agentType === 'team-lead' || name === 'team-lead' || name === 'user') {
|
||||||
|
nextMembers.push(m);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (!keepName(name)) {
|
||||||
|
removedFromConfig.push(name);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
nextMembers.push(m);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (removedFromConfig.length > 0) {
|
||||||
|
parsed.members = nextMembers;
|
||||||
|
await atomicWriteAsync(configPath, JSON.stringify(parsed, null, 2));
|
||||||
|
logger.warn(
|
||||||
|
`[${teamName}] Removed CLI auto-suffixed members from config.json: ${removedFromConfig.join(', ')}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// best-effort
|
||||||
|
}
|
||||||
|
|
||||||
|
let activeNamesForInboxCleanup: Set<string> = new Set();
|
||||||
|
try {
|
||||||
|
const metaMembers = await this.membersMetaStore.getMembers(teamName);
|
||||||
|
if (metaMembers.length > 0) {
|
||||||
|
const activeNames = metaMembers
|
||||||
|
.filter((m) => !m.removedAt)
|
||||||
|
.map((m) => m.name.trim())
|
||||||
|
.filter((n) => n.length > 0 && n.toLowerCase() !== 'team-lead' && n.toLowerCase() !== 'user');
|
||||||
|
|
||||||
|
const keepName = createCliAutoSuffixNameGuard(activeNames);
|
||||||
|
const removedFromMeta: string[] = [];
|
||||||
|
const nextMeta = metaMembers.filter((m) => {
|
||||||
|
const name = m.name?.trim() ?? '';
|
||||||
|
if (!name) return false;
|
||||||
|
const lower = name.toLowerCase();
|
||||||
|
if (lower === 'team-lead' || lower === 'user' || m.agentType === 'team-lead') return true;
|
||||||
|
if (!m.removedAt && !keepName(name)) {
|
||||||
|
removedFromMeta.push(name);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
|
||||||
|
if (removedFromMeta.length > 0) {
|
||||||
|
await this.membersMetaStore.writeMembers(teamName, nextMeta);
|
||||||
|
logger.warn(
|
||||||
|
`[${teamName}] Removed CLI auto-suffixed members from members.meta.json: ${removedFromMeta.join(', ')}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
activeNamesForInboxCleanup = new Set(
|
||||||
|
nextMeta
|
||||||
|
.filter((m) => !m.removedAt)
|
||||||
|
.map((m) => m.name.trim())
|
||||||
|
.filter((n) => n.length > 0 && n.toLowerCase() !== 'team-lead' && n.toLowerCase() !== 'user')
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// best-effort
|
||||||
|
}
|
||||||
|
|
||||||
|
// Also attempt inbox cleanup (merge alice-2.json into alice.json).
|
||||||
|
if (activeNamesForInboxCleanup.size > 0) {
|
||||||
|
try {
|
||||||
|
await this.mergeAndRemoveDuplicateInboxes(teamName, activeNamesForInboxCleanup);
|
||||||
|
} catch {
|
||||||
|
// best-effort
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Fallback: scan the project directory for the newest JSONL file
|
* Fallback: scan the project directory for the newest JSONL file
|
||||||
* that isn't already in sessionHistory. Returns the session ID or null.
|
* that isn't already in sessionHistory. Returns the session ID or null.
|
||||||
|
|
@ -3933,6 +4045,55 @@ export class TeamProvisioningService {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async assertConfigLeadOnlyForLaunch(teamName: string): Promise<void> {
|
||||||
|
const configPath = path.join(getTeamsBasePath(), teamName, 'config.json');
|
||||||
|
const raw = await tryReadRegularFileUtf8(configPath, {
|
||||||
|
timeoutMs: TEAM_JSON_READ_TIMEOUT_MS,
|
||||||
|
maxBytes: TEAM_CONFIG_MAX_BYTES,
|
||||||
|
});
|
||||||
|
if (!raw) {
|
||||||
|
throw new Error('config.json unreadable');
|
||||||
|
}
|
||||||
|
|
||||||
|
let parsed: unknown;
|
||||||
|
try {
|
||||||
|
parsed = JSON.parse(raw) as unknown;
|
||||||
|
} catch {
|
||||||
|
throw new Error('config.json could not be parsed');
|
||||||
|
}
|
||||||
|
if (!parsed || typeof parsed !== 'object') {
|
||||||
|
throw new Error('config.json has invalid shape');
|
||||||
|
}
|
||||||
|
|
||||||
|
const config = parsed as Record<string, unknown>;
|
||||||
|
const members = Array.isArray(config.members)
|
||||||
|
? (config.members as Record<string, unknown>[])
|
||||||
|
: [];
|
||||||
|
if (members.length === 0) return;
|
||||||
|
|
||||||
|
for (const member of members) {
|
||||||
|
const name = typeof member.name === 'string' ? member.name.trim() : '';
|
||||||
|
if (!name) continue;
|
||||||
|
const lower = name.toLowerCase();
|
||||||
|
const agentType = typeof member.agentType === 'string' ? member.agentType : '';
|
||||||
|
|
||||||
|
if (agentType === 'team-lead' || lower === 'team-lead' || lower === 'user') continue;
|
||||||
|
|
||||||
|
const leadAgentId = config.leadAgentId;
|
||||||
|
if (
|
||||||
|
typeof leadAgentId === 'string' &&
|
||||||
|
typeof member.agentId === 'string' &&
|
||||||
|
member.agentId === leadAgentId
|
||||||
|
) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error(
|
||||||
|
`Refusing to launch: config.json still contains teammates (e.g. "${name}"), which can trigger CLI auto-suffixes like "${name}-2".`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private async normalizeTeamConfigForLaunch(teamName: string, configRaw: string): Promise<void> {
|
private async normalizeTeamConfigForLaunch(teamName: string, configRaw: string): Promise<void> {
|
||||||
const configPath = path.join(getTeamsBasePath(), teamName, 'config.json');
|
const configPath = path.join(getTeamsBasePath(), teamName, 'config.json');
|
||||||
const backupPath = `${configPath}.prelaunch.bak`;
|
const backupPath = `${configPath}.prelaunch.bak`;
|
||||||
|
|
@ -4287,6 +4448,14 @@ export class TeamProvisioningService {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// Defense: ignore CLI auto-suffixed duplicates (alice-2) when base name exists.
|
||||||
|
const allNames = Array.from(byName.keys());
|
||||||
|
const keepName = createCliAutoSuffixNameGuard(allNames);
|
||||||
|
for (const name of allNames) {
|
||||||
|
if (!keepName(name)) {
|
||||||
|
byName.delete(name);
|
||||||
|
}
|
||||||
|
}
|
||||||
const members = Array.from(byName.values()).sort((a, b) => a.name.localeCompare(b.name));
|
const members = Array.from(byName.values()).sort((a, b) => a.name.localeCompare(b.name));
|
||||||
if (members.length > 0) {
|
if (members.length > 0) {
|
||||||
return { members, source: 'members-meta' };
|
return { members, source: 'members-meta' };
|
||||||
|
|
@ -4386,6 +4555,14 @@ export class TeamProvisioningService {
|
||||||
if (!name) continue;
|
if (!name) continue;
|
||||||
byName.set(name, { name });
|
byName.set(name, { name });
|
||||||
}
|
}
|
||||||
|
// Defense: ignore CLI auto-suffixed duplicates (alice-2) when base name exists.
|
||||||
|
const allNames = Array.from(byName.keys());
|
||||||
|
const keepName = createCliAutoSuffixNameGuard(allNames);
|
||||||
|
for (const name of allNames) {
|
||||||
|
if (!keepName(name)) {
|
||||||
|
byName.delete(name);
|
||||||
|
}
|
||||||
|
}
|
||||||
return Array.from(byName.values()).sort((a, b) => a.name.localeCompare(b.name));
|
return Array.from(byName.values()).sort((a, b) => a.name.localeCompare(b.name));
|
||||||
} catch {
|
} catch {
|
||||||
logger.warn(`[${teamName}] Failed to parse config.json for launch fallback members`);
|
logger.warn(`[${teamName}] Failed to parse config.json for launch fallback members`);
|
||||||
|
|
|
||||||
|
|
@ -250,6 +250,25 @@ function mergeMember(
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function dropCliAutoSuffixedMembers(
|
||||||
|
memberMap: Map<string, { name: string; role?: string; color?: string }>
|
||||||
|
): void {
|
||||||
|
const keys = Array.from(memberMap.keys());
|
||||||
|
const allLower = new Set(keys); // keys are already lowercased
|
||||||
|
for (const key of keys) {
|
||||||
|
const member = memberMap.get(key);
|
||||||
|
const name = member?.name ?? '';
|
||||||
|
const match = name.trim().match(/^(.+)-(\d+)$/);
|
||||||
|
if (!match?.[1] || !match[2]) continue;
|
||||||
|
const suffix = Number(match[2]);
|
||||||
|
if (!Number.isFinite(suffix) || suffix < 2) continue;
|
||||||
|
const baseLower = match[1].toLowerCase();
|
||||||
|
if (allLower.has(baseLower)) {
|
||||||
|
memberMap.delete(key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function listTeams(
|
async function listTeams(
|
||||||
payload: ListTeamsPayload
|
payload: ListTeamsPayload
|
||||||
): Promise<{ teams: unknown[]; diag: ListTeamsDiag }> {
|
): Promise<{ teams: unknown[]; diag: ListTeamsDiag }> {
|
||||||
|
|
@ -392,6 +411,8 @@ async function listTeams(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
dropCliAutoSuffixedMembers(memberMap);
|
||||||
|
|
||||||
const members = Array.from(memberMap.values());
|
const members = Array.from(memberMap.values());
|
||||||
const summary = {
|
const summary = {
|
||||||
teamName,
|
teamName,
|
||||||
|
|
|
||||||
|
|
@ -60,10 +60,20 @@ export const MemberLogsTab = ({
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const sortedLogs = useMemo(() => {
|
const sortedLogs = useMemo(() => {
|
||||||
|
const nowMs = Date.now();
|
||||||
|
const getLastActivityMs = (log: MemberLogSummary): number => {
|
||||||
|
const startMs = new Date(log.startTime).getTime();
|
||||||
|
if (!Number.isFinite(startMs)) return Number.NaN;
|
||||||
|
const durationMs = Number.isFinite(log.durationMs) ? Math.max(0, log.durationMs) : 0;
|
||||||
|
const endMs = startMs + durationMs;
|
||||||
|
// Keep actively-updating logs at the top even if duration lags slightly.
|
||||||
|
return log.isOngoing ? Math.max(endMs, nowMs) : endMs;
|
||||||
|
};
|
||||||
|
|
||||||
const withIndex = logs.map((log, index) => ({ log, index }));
|
const withIndex = logs.map((log, index) => ({ log, index }));
|
||||||
withIndex.sort((a, b) => {
|
withIndex.sort((a, b) => {
|
||||||
const aTime = new Date(a.log.startTime).getTime();
|
const aTime = getLastActivityMs(a.log);
|
||||||
const bTime = new Date(b.log.startTime).getTime();
|
const bTime = getLastActivityMs(b.log);
|
||||||
if (Number.isFinite(aTime) && Number.isFinite(bTime) && aTime !== bTime) return bTime - aTime;
|
if (Number.isFinite(aTime) && Number.isFinite(bTime) && aTime !== bTime) return bTime - aTime;
|
||||||
if (Number.isFinite(aTime) && !Number.isFinite(bTime)) return -1;
|
if (Number.isFinite(aTime) && !Number.isFinite(bTime)) return -1;
|
||||||
if (!Number.isFinite(aTime) && Number.isFinite(bTime)) return 1;
|
if (!Number.isFinite(aTime) && Number.isFinite(bTime)) return 1;
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue