chore: checkpoint workspace before relaunch flow
This commit is contained in:
parent
fbf299f276
commit
1e2241aead
33 changed files with 4078 additions and 299 deletions
312
docs/research/codex-native-runtime-integration-decision.md
Normal file
312
docs/research/codex-native-runtime-integration-decision.md
Normal file
|
|
@ -0,0 +1,312 @@
|
||||||
|
# Codex Native Runtime Integration Decision
|
||||||
|
|
||||||
|
**Status**: Decision
|
||||||
|
**Date**: 2026-04-19
|
||||||
|
**Owner repos**:
|
||||||
|
|
||||||
|
- `claude_team`
|
||||||
|
- `agent_teams_orchestrator`
|
||||||
|
- `plugin-kit-ai`
|
||||||
|
|
||||||
|
## Purpose
|
||||||
|
|
||||||
|
Record the chosen direction for improving Codex integration in the multimodel runtime without losing native Codex capabilities such as plugins, skills, and MCP.
|
||||||
|
|
||||||
|
## Chosen Plan Assessment
|
||||||
|
|
||||||
|
- Chosen plan: normalized internal event/log layer plus staged `Codex-native` backend lane
|
||||||
|
- Assessment: `🎯 9 🛡️ 9 🧠 7`
|
||||||
|
- Estimated first serious wave: `2200-4500` lines across `agent_teams_orchestrator`, `claude_team`, and `plugin-kit-ai`
|
||||||
|
|
||||||
|
## Current Reality
|
||||||
|
|
||||||
|
Today, `Codex` inside our multimodel runtime is **not** executed through the real Codex runtime.
|
||||||
|
|
||||||
|
Instead, the current path is:
|
||||||
|
|
||||||
|
- `claude_team`
|
||||||
|
- `agent_teams_orchestrator`
|
||||||
|
- internal Codex backend
|
||||||
|
- OpenAI Responses API
|
||||||
|
|
||||||
|
In practice this means:
|
||||||
|
|
||||||
|
- the orchestrator keeps Anthropic-style streaming semantics
|
||||||
|
- `Codex` is treated as a model backend, not as a native runtime
|
||||||
|
- native Codex plugins are not honestly end-to-end supported
|
||||||
|
- current `Codex` capability support is limited by our adapter, not by the real Codex runtime
|
||||||
|
|
||||||
|
## What We Learned
|
||||||
|
|
||||||
|
After deep code and docs analysis, the most important conclusions are:
|
||||||
|
|
||||||
|
1. `@openai/codex-sdk` and `codex exec --json` are the real official execution seam for embedded Codex runtime usage.
|
||||||
|
2. `codex exec` supports API-key mode, so API-key mode itself is not the blocker.
|
||||||
|
3. `Codex` native plugins, apps, skills, and MCP are part of the real Codex runtime flow.
|
||||||
|
4. Our current `agent_teams_orchestrator` query loop is deeply coupled to Anthropic-style events and tool semantics.
|
||||||
|
5. A full drop-in swap from the current Codex adapter to `@openai/codex-sdk / codex exec` would not be a safe transport-only change. It would change runtime semantics.
|
||||||
|
6. `plugin-kit-ai` is a good fit for plugin management and native plugin placement.
|
||||||
|
7. `codex app-server` is promising for richer control-plane features, but should not be the foundation of the first production rollout for plugin management.
|
||||||
|
|
||||||
|
## Chosen Direction
|
||||||
|
|
||||||
|
We will **not** force Codex into the current Anthropic-shaped runtime contract.
|
||||||
|
|
||||||
|
We will instead:
|
||||||
|
|
||||||
|
- add a new **internal normalized event/log layer**
|
||||||
|
- keep execution semantics provider-native where needed
|
||||||
|
- add a separate **Codex-native runtime lane**
|
||||||
|
- use `plugin-kit-ai` for plugin management and native plugin placement
|
||||||
|
|
||||||
|
In practical terms:
|
||||||
|
|
||||||
|
- current Codex path stays available as the fallback/default path at first
|
||||||
|
- real Codex runtime execution becomes a separate lane instead of a drop-in replacement
|
||||||
|
- unified logs come from normalization, not from pretending every provider has Anthropic-native runtime semantics
|
||||||
|
|
||||||
|
## Decision Summary
|
||||||
|
|
||||||
|
### We are doing this
|
||||||
|
|
||||||
|
- keep the current Codex adapter path as the fallback/default path initially
|
||||||
|
- introduce a new `Codex-native` backend lane using `@openai/codex-sdk / codex exec`
|
||||||
|
- introduce a normalized internal event/log format for all providers
|
||||||
|
- map Anthropic, Gemini, and future Codex-native events into that normalized format
|
||||||
|
- keep unified logging, transcript projection, analytics, and UI-facing event handling on top of the normalized layer
|
||||||
|
- use `plugin-kit-ai` for:
|
||||||
|
- install
|
||||||
|
- update
|
||||||
|
- remove
|
||||||
|
- repair
|
||||||
|
- discover
|
||||||
|
- catalog
|
||||||
|
- native Codex plugin placement through native marketplace/filesystem layout
|
||||||
|
|
||||||
|
### We are not doing this
|
||||||
|
|
||||||
|
- not replacing the whole multimodel runtime in one shot
|
||||||
|
- not forcing real Codex runtime execution into fake Anthropic transport semantics
|
||||||
|
- not pretending a full `@openai/codex-sdk / codex exec` swap is a drop-in backend replacement
|
||||||
|
- not making `app-server plugin/*` the first production seam
|
||||||
|
|
||||||
|
## Why We Chose This
|
||||||
|
|
||||||
|
### Main benefit
|
||||||
|
|
||||||
|
This path gives us both:
|
||||||
|
|
||||||
|
- unified internal logs/events
|
||||||
|
- a real path to native Codex runtime capabilities
|
||||||
|
|
||||||
|
without requiring a full rewrite of the current multimodel runtime.
|
||||||
|
|
||||||
|
### Main reason against a direct full swap
|
||||||
|
|
||||||
|
The current orchestrator is deeply coupled to Anthropic-shaped runtime behavior:
|
||||||
|
|
||||||
|
- `tool_use`
|
||||||
|
- `tool_result`
|
||||||
|
- `content_block_start`
|
||||||
|
- `input_json_delta`
|
||||||
|
- `message_delta`
|
||||||
|
- current permission and sandbox flow
|
||||||
|
- current synthetic tool/result handling
|
||||||
|
- current transcript persistence and resume logic
|
||||||
|
|
||||||
|
`codex exec` emits a different event model:
|
||||||
|
|
||||||
|
- `thread.started`
|
||||||
|
- `turn.started`
|
||||||
|
- `turn.completed`
|
||||||
|
- `turn.failed`
|
||||||
|
- `item.started`
|
||||||
|
- `item.updated`
|
||||||
|
- `item.completed`
|
||||||
|
|
||||||
|
and item types such as:
|
||||||
|
|
||||||
|
- `agent_message`
|
||||||
|
- `reasoning`
|
||||||
|
- `command_execution`
|
||||||
|
- `file_change`
|
||||||
|
- `mcp_tool_call`
|
||||||
|
|
||||||
|
That is not just a different wire format. It is a different runtime shape.
|
||||||
|
|
||||||
|
## What Changes Per Repo
|
||||||
|
|
||||||
|
### `agent_teams_orchestrator`
|
||||||
|
|
||||||
|
This repo takes the biggest change.
|
||||||
|
|
||||||
|
We want to:
|
||||||
|
|
||||||
|
- introduce a provider-neutral normalized event/log model
|
||||||
|
- add adapter mappers from current Anthropic/Gemini style streams into that model
|
||||||
|
- add a separate `Codex-native` backend lane through `@openai/codex-sdk / codex exec`
|
||||||
|
- keep the current Codex adapter path alive as fallback during migration
|
||||||
|
- avoid forcing `codex exec` events into fake `tool_use/tool_result` transport semantics
|
||||||
|
|
||||||
|
We do **not** want to:
|
||||||
|
|
||||||
|
- replace the current Codex backend in one shot
|
||||||
|
- rewrite all providers around Codex-native semantics
|
||||||
|
- make transcript/log normalization depend on Anthropic wire events
|
||||||
|
|
||||||
|
### `claude_team`
|
||||||
|
|
||||||
|
This repo should stay relatively stable compared with the orchestrator.
|
||||||
|
|
||||||
|
We want to:
|
||||||
|
|
||||||
|
- keep one multimodel runtime concept
|
||||||
|
- stay capability-aware per provider/backend lane
|
||||||
|
- consume normalized runtime/log DTOs rather than assuming one provider-shaped event model
|
||||||
|
- integrate plugin management through `plugin-kit-ai`
|
||||||
|
- keep Codex plugin support gated behind the real Codex-native lane
|
||||||
|
|
||||||
|
We do **not** want to:
|
||||||
|
|
||||||
|
- invent a fake Codex plugin support state while execution still goes through the old adapter lane
|
||||||
|
- force UI logic to infer runtime truth from provider labels alone
|
||||||
|
|
||||||
|
### `plugin-kit-ai`
|
||||||
|
|
||||||
|
This repo remains the management engine, not the execution engine.
|
||||||
|
|
||||||
|
We want to:
|
||||||
|
|
||||||
|
- use it for catalog
|
||||||
|
- use it for discover
|
||||||
|
- use it for install/update/remove/repair
|
||||||
|
- use it for native Codex plugin placement through native marketplace/filesystem layout
|
||||||
|
|
||||||
|
We do **not** want to:
|
||||||
|
|
||||||
|
- make it responsible for running Codex plugins inside sessions
|
||||||
|
- blur installation and execution into one concern
|
||||||
|
|
||||||
|
## Target Architecture
|
||||||
|
|
||||||
|
### Runtime execution
|
||||||
|
|
||||||
|
- `Anthropic` can continue on the current path for now
|
||||||
|
- `Gemini` can continue on the current path for now
|
||||||
|
- `Codex-native` gets a dedicated backend lane through `@openai/codex-sdk / codex exec`
|
||||||
|
|
||||||
|
### Internal normalization
|
||||||
|
|
||||||
|
All runtime backends must project into a shared internal event/log model.
|
||||||
|
|
||||||
|
The normalized layer should represent concepts such as:
|
||||||
|
|
||||||
|
- turn started
|
||||||
|
- assistant text
|
||||||
|
- reasoning
|
||||||
|
- command execution
|
||||||
|
- MCP call
|
||||||
|
- file change
|
||||||
|
- approval request
|
||||||
|
- turn completed
|
||||||
|
- turn failed
|
||||||
|
|
||||||
|
The normalized format is the source of truth for:
|
||||||
|
|
||||||
|
- logs
|
||||||
|
- transcript projection
|
||||||
|
- analytics
|
||||||
|
- UI-facing activity/event summaries
|
||||||
|
|
||||||
|
The normalized format is **not** required to preserve provider-native wire semantics.
|
||||||
|
|
||||||
|
## Codex Plugins Strategy
|
||||||
|
|
||||||
|
For Codex plugins we want:
|
||||||
|
|
||||||
|
- native Codex runtime execution
|
||||||
|
- native Codex marketplace/filesystem placement
|
||||||
|
- provider-aware plugin management in `claude_team`
|
||||||
|
|
||||||
|
Therefore:
|
||||||
|
|
||||||
|
- `plugin-kit-ai` is the management engine
|
||||||
|
- real Codex runtime is the execution engine
|
||||||
|
|
||||||
|
This is important because plugin installation and plugin execution are different concerns.
|
||||||
|
|
||||||
|
Installing a native Codex plugin is not enough by itself if the session still runs through our current Responses API adapter path.
|
||||||
|
|
||||||
|
## App Server Position
|
||||||
|
|
||||||
|
`codex app-server` remains relevant, but not as the first critical path for this migration.
|
||||||
|
|
||||||
|
It is better positioned as a later control-plane enhancement for things like:
|
||||||
|
|
||||||
|
- auth state
|
||||||
|
- MCP status and OAuth flows
|
||||||
|
- skills/config inspection
|
||||||
|
- external config import
|
||||||
|
|
||||||
|
For the first production rollout, it should not be the hard dependency for plugin lifecycle management.
|
||||||
|
|
||||||
|
## Implementation Phases
|
||||||
|
|
||||||
|
### Phase 1
|
||||||
|
|
||||||
|
- design and introduce the normalized internal event/log layer
|
||||||
|
- keep current backends working
|
||||||
|
- define the internal mapping contract clearly
|
||||||
|
|
||||||
|
### Phase 2
|
||||||
|
|
||||||
|
- add a `Codex-native` backend lane through `@openai/codex-sdk / codex exec`
|
||||||
|
- keep the current Codex adapter as fallback
|
||||||
|
- validate API-key mode, working directory behavior, sandbox mode, approval policy, thread resume, and streaming
|
||||||
|
|
||||||
|
### Phase 3
|
||||||
|
|
||||||
|
- integrate `plugin-kit-ai` for provider-aware plugin management
|
||||||
|
- add native Codex plugin placement through native marketplace/filesystem model
|
||||||
|
- keep current UI provider-aware and capability-aware
|
||||||
|
|
||||||
|
### Phase 4
|
||||||
|
|
||||||
|
- optionally add selective `codex app-server` control-plane integration where it provides clear value
|
||||||
|
|
||||||
|
## Main Risks And Guardrails
|
||||||
|
|
||||||
|
### Risk 1 - treating `codex-sdk/exec` as a transport-only swap
|
||||||
|
|
||||||
|
This is the most dangerous mistake.
|
||||||
|
|
||||||
|
Guardrail:
|
||||||
|
|
||||||
|
- treat `Codex-native` as a separate runtime lane
|
||||||
|
- normalize logs/events above it
|
||||||
|
- do not assume the current Anthropic-shaped tool loop can be preserved unchanged
|
||||||
|
|
||||||
|
### Risk 2 - claiming Codex plugin support too early
|
||||||
|
|
||||||
|
Installing native Codex plugins is not enough if execution still runs through the current adapter path.
|
||||||
|
|
||||||
|
Guardrail:
|
||||||
|
|
||||||
|
- only advertise Codex plugin support when the session actually runs through the Codex-native lane
|
||||||
|
|
||||||
|
### Risk 3 - overcommitting to `app-server` too early
|
||||||
|
|
||||||
|
`codex app-server` is useful, but it should not become a hard dependency for the first production plugin rollout.
|
||||||
|
|
||||||
|
Guardrail:
|
||||||
|
|
||||||
|
- use it later for selective control-plane features
|
||||||
|
- do not block the first migration on `app-server plugin/*`
|
||||||
|
|
||||||
|
## Practical Rule
|
||||||
|
|
||||||
|
If we need **unified logs**, we normalize events.
|
||||||
|
|
||||||
|
If we need **native Codex capabilities**, we do not fake Codex into Anthropic runtime semantics.
|
||||||
|
|
||||||
|
That is the core architectural rule for this migration.
|
||||||
|
|
@ -1074,6 +1074,9 @@ async function handleUpdateConfig(
|
||||||
}
|
}
|
||||||
return wrapTeamHandler('updateConfig', async () => {
|
return wrapTeamHandler('updateConfig', async () => {
|
||||||
const tn = validated.value!;
|
const tn = validated.value!;
|
||||||
|
const teamDataService = getTeamDataService();
|
||||||
|
const previousDisplayName = await teamDataService.getTeamDisplayName(tn).catch(() => tn);
|
||||||
|
const requestedName = typeof name === 'string' ? name.trim() : '';
|
||||||
const result = await getTeamDataService().updateConfig(tn, {
|
const result = await getTeamDataService().updateConfig(tn, {
|
||||||
name,
|
name,
|
||||||
description,
|
description,
|
||||||
|
|
@ -1084,10 +1087,10 @@ async function handleUpdateConfig(
|
||||||
}
|
}
|
||||||
|
|
||||||
// Notify running lead about the rename so it stays aware of current team name
|
// Notify running lead about the rename so it stays aware of current team name
|
||||||
if (typeof name === 'string' && name.trim()) {
|
if (requestedName && requestedName !== (previousDisplayName?.trim() || tn)) {
|
||||||
const provisioning = getTeamProvisioningService();
|
const provisioning = getTeamProvisioningService();
|
||||||
if (provisioning.isTeamAlive(tn)) {
|
if (provisioning.isTeamAlive(tn)) {
|
||||||
const msg = `The team has been renamed to "${name.trim()}". Please use this name when referring to the team going forward.`;
|
const msg = `The team has been renamed to "${requestedName}". Please use this name when referring to the team going forward.`;
|
||||||
try {
|
try {
|
||||||
await provisioning.sendMessageToTeam(tn, msg);
|
await provisioning.sendMessageToTeam(tn, msg);
|
||||||
} catch {
|
} catch {
|
||||||
|
|
|
||||||
|
|
@ -12,9 +12,10 @@ import { classifyIdleNotificationText } from '@shared/utils/idleNotificationSema
|
||||||
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 { getKanbanColumnFromReviewState, normalizeReviewState } from '@shared/utils/reviewState';
|
import { getKanbanColumnFromReviewState, normalizeReviewState } from '@shared/utils/reviewState';
|
||||||
|
import { buildTeamMemberColorMap } from '@shared/utils/teamMemberColors';
|
||||||
import { buildStandaloneSlashCommandMeta } from '@shared/utils/slashCommands';
|
import { buildStandaloneSlashCommandMeta } from '@shared/utils/slashCommands';
|
||||||
import { formatTaskDisplayLabel } from '@shared/utils/taskIdentity';
|
import { formatTaskDisplayLabel } from '@shared/utils/taskIdentity';
|
||||||
import { parseNumericSuffixName } from '@shared/utils/teamMemberName';
|
import { parseNumericSuffixName, validateTeamMemberNameFormat } from '@shared/utils/teamMemberName';
|
||||||
import { normalizeOptionalTeamProviderId } from '@shared/utils/teamProvider';
|
import { normalizeOptionalTeamProviderId } from '@shared/utils/teamProvider';
|
||||||
import { extractToolPreview, formatToolSummaryFromCalls } from '@shared/utils/toolSummary';
|
import { extractToolPreview, formatToolSummaryFromCalls } from '@shared/utils/toolSummary';
|
||||||
import * as agentTeamsControllerModule from 'agent-teams-controller';
|
import * as agentTeamsControllerModule from 'agent-teams-controller';
|
||||||
|
|
@ -130,6 +131,16 @@ interface FileWatchReconcileDiagnostics {
|
||||||
lastPressureLogAt: number;
|
lastPressureLogAt: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function applyDistinctRosterColors<T extends { name: string; color?: string; removedAt?: number }>(
|
||||||
|
members: readonly T[]
|
||||||
|
): T[] {
|
||||||
|
const colorMap = buildTeamMemberColorMap(members, { preferProvidedColors: false });
|
||||||
|
return members.map((member) => ({
|
||||||
|
...member,
|
||||||
|
color: colorMap.get(member.name) ?? member.color ?? getMemberColorByName(member.name),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
function normalizePassiveUserReplyLinkText(value: string | undefined): string {
|
function normalizePassiveUserReplyLinkText(value: string | undefined): string {
|
||||||
if (typeof value !== 'string') return '';
|
if (typeof value !== 'string') return '';
|
||||||
return value
|
return value
|
||||||
|
|
@ -1161,7 +1172,7 @@ export class TeamDataService {
|
||||||
role: configMember.role,
|
role: configMember.role,
|
||||||
workflow: configMember.workflow,
|
workflow: configMember.workflow,
|
||||||
agentType: configMember.agentType ?? 'general-purpose',
|
agentType: configMember.agentType ?? 'general-purpose',
|
||||||
color: configMember.color ?? getMemberColorByName(configMember.name.trim()),
|
color: configMember.color,
|
||||||
joinedAt: configMember.joinedAt ?? Date.now(),
|
joinedAt: configMember.joinedAt ?? Date.now(),
|
||||||
cwd: configMember.cwd,
|
cwd: configMember.cwd,
|
||||||
};
|
};
|
||||||
|
|
@ -1176,13 +1187,13 @@ export class TeamDataService {
|
||||||
member = {
|
member = {
|
||||||
name: memberName,
|
name: memberName,
|
||||||
agentType: 'general-purpose',
|
agentType: 'general-purpose',
|
||||||
color: getMemberColorByName(memberName),
|
|
||||||
joinedAt: Date.now(),
|
joinedAt: Date.now(),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
members.push(member);
|
const nextMembers = applyDistinctRosterColors([...members, member]);
|
||||||
await this.membersMetaStore.writeMembers(teamName, members);
|
member = nextMembers.find((m) => m.name === memberName) ?? member;
|
||||||
|
await this.membersMetaStore.writeMembers(teamName, nextMembers);
|
||||||
}
|
}
|
||||||
|
|
||||||
return { members, member };
|
return { members, member };
|
||||||
|
|
@ -1193,6 +1204,13 @@ export class TeamDataService {
|
||||||
if (!name) {
|
if (!name) {
|
||||||
throw new Error('Member name cannot be empty');
|
throw new Error('Member name cannot be empty');
|
||||||
}
|
}
|
||||||
|
const formatError = validateTeamMemberNameFormat(name);
|
||||||
|
if (formatError) {
|
||||||
|
throw new Error(`Member name "${name}" is invalid: ${formatError}`);
|
||||||
|
}
|
||||||
|
if (name.toLowerCase() === 'user') {
|
||||||
|
throw new Error('Member name "user" is reserved');
|
||||||
|
}
|
||||||
const suffixInfo = parseNumericSuffixName(name);
|
const suffixInfo = parseNumericSuffixName(name);
|
||||||
if (suffixInfo && suffixInfo.suffix >= 2) {
|
if (suffixInfo && suffixInfo.suffix >= 2) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
|
|
@ -1224,12 +1242,11 @@ export class TeamDataService {
|
||||||
? request.effort
|
? request.effort
|
||||||
: undefined,
|
: undefined,
|
||||||
agentType: 'general-purpose',
|
agentType: 'general-purpose',
|
||||||
color: getMemberColorByName(name),
|
|
||||||
joinedAt: Date.now(),
|
joinedAt: Date.now(),
|
||||||
};
|
};
|
||||||
|
|
||||||
members.push(newMember);
|
const nextMembers = applyDistinctRosterColors([...members, newMember]);
|
||||||
await this.membersMetaStore.writeMembers(teamName, members);
|
await this.membersMetaStore.writeMembers(teamName, nextMembers);
|
||||||
}
|
}
|
||||||
|
|
||||||
async updateMemberRole(
|
async updateMemberRole(
|
||||||
|
|
@ -1269,36 +1286,48 @@ export class TeamDataService {
|
||||||
const joinedAt = Date.now();
|
const joinedAt = Date.now();
|
||||||
const nextByName = new Set<string>();
|
const nextByName = new Set<string>();
|
||||||
|
|
||||||
const nextActive: TeamMember[] = request.members.map((member) => {
|
const nextActive = applyDistinctRosterColors(
|
||||||
const name = member.name.trim();
|
request.members.map((member) => {
|
||||||
if (!name) throw new Error('Member name cannot be empty');
|
const name = member.name.trim();
|
||||||
if (name.toLowerCase() === 'team-lead') {
|
if (!name) throw new Error('Member name cannot be empty');
|
||||||
throw new Error('Member name "team-lead" is reserved');
|
const formatError = validateTeamMemberNameFormat(name);
|
||||||
}
|
if (formatError) {
|
||||||
const suffixInfo = parseNumericSuffixName(name);
|
throw new Error(`Member name "${name}" is invalid: ${formatError}`);
|
||||||
if (suffixInfo && suffixInfo.suffix >= 2) {
|
}
|
||||||
throw new Error(
|
if (name.toLowerCase() === 'user') {
|
||||||
`Member name "${name}" is not allowed (reserved for Claude CLI auto-suffix). Use "${suffixInfo.base}" instead.`
|
throw new Error('Member name "user" is reserved');
|
||||||
);
|
}
|
||||||
}
|
if (name.toLowerCase() === 'team-lead') {
|
||||||
nextByName.add(name.toLowerCase());
|
throw new Error('Member name "team-lead" is reserved');
|
||||||
const prev = existingByName.get(name.toLowerCase());
|
}
|
||||||
return {
|
if (nextByName.has(name.toLowerCase())) {
|
||||||
name,
|
throw new Error(`Member "${name}" already exists`);
|
||||||
role: member.role?.trim() || undefined,
|
}
|
||||||
workflow: member.workflow?.trim() || undefined,
|
const suffixInfo = parseNumericSuffixName(name);
|
||||||
providerId: normalizeOptionalTeamProviderId(member.providerId),
|
if (suffixInfo && suffixInfo.suffix >= 2) {
|
||||||
model: member.model?.trim() || undefined,
|
throw new Error(
|
||||||
effort:
|
`Member name "${name}" is not allowed (reserved for Claude CLI auto-suffix). Use "${suffixInfo.base}" instead.`
|
||||||
member.effort === 'low' || member.effort === 'medium' || member.effort === 'high'
|
);
|
||||||
? member.effort
|
}
|
||||||
: undefined,
|
nextByName.add(name.toLowerCase());
|
||||||
agentType: prev?.agentType ?? 'general-purpose',
|
const prev = existingByName.get(name.toLowerCase());
|
||||||
color: prev?.color ?? getMemberColorByName(name),
|
return {
|
||||||
joinedAt: prev?.joinedAt ?? joinedAt,
|
name,
|
||||||
removedAt: undefined,
|
role: member.role?.trim() || undefined,
|
||||||
};
|
workflow: member.workflow?.trim() || undefined,
|
||||||
});
|
providerId: normalizeOptionalTeamProviderId(member.providerId),
|
||||||
|
model: member.model?.trim() || undefined,
|
||||||
|
effort:
|
||||||
|
member.effort === 'low' || member.effort === 'medium' || member.effort === 'high'
|
||||||
|
? member.effort
|
||||||
|
: undefined,
|
||||||
|
agentType: prev?.agentType ?? 'general-purpose',
|
||||||
|
color: prev?.color,
|
||||||
|
joinedAt: prev?.joinedAt ?? joinedAt,
|
||||||
|
removedAt: undefined,
|
||||||
|
};
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
// Preserve/mark removed members so stale inbox files don't resurrect them in the UI.
|
// Preserve/mark removed members so stale inbox files don't resurrect them in the UI.
|
||||||
const nextRemoved: TeamMember[] = [];
|
const nextRemoved: TeamMember[] = [];
|
||||||
|
|
@ -2322,12 +2351,18 @@ export class TeamDataService {
|
||||||
createdAt: joinedAt,
|
createdAt: joinedAt,
|
||||||
});
|
});
|
||||||
|
|
||||||
await this.membersMetaStore.writeMembers(
|
const membersToWrite = applyDistinctRosterColors(
|
||||||
request.teamName,
|
|
||||||
request.members.map((member) => ({
|
request.members.map((member) => ({
|
||||||
name: (() => {
|
name: (() => {
|
||||||
const name = member.name.trim();
|
const name = member.name.trim();
|
||||||
if (!name) throw new Error('Member name cannot be empty');
|
if (!name) throw new Error('Member name cannot be empty');
|
||||||
|
const formatError = validateTeamMemberNameFormat(name);
|
||||||
|
if (formatError) {
|
||||||
|
throw new Error(`Member name "${name}" is invalid: ${formatError}`);
|
||||||
|
}
|
||||||
|
if (name.toLowerCase() === 'user') {
|
||||||
|
throw new Error('Member name "user" is reserved');
|
||||||
|
}
|
||||||
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);
|
const suffixInfo = parseNumericSuffixName(name);
|
||||||
|
|
@ -2346,11 +2381,11 @@ export class TeamDataService {
|
||||||
member.effort === 'low' || member.effort === 'medium' || member.effort === 'high'
|
member.effort === 'low' || member.effort === 'medium' || member.effort === 'high'
|
||||||
? member.effort
|
? member.effort
|
||||||
: undefined,
|
: undefined,
|
||||||
agentType: 'general-purpose',
|
agentType: 'general-purpose' as const,
|
||||||
color: getMemberColorByName(member.name.trim()),
|
|
||||||
joinedAt,
|
joinedAt,
|
||||||
}))
|
}))
|
||||||
);
|
);
|
||||||
|
await this.membersMetaStore.writeMembers(request.teamName, membersToWrite);
|
||||||
}
|
}
|
||||||
|
|
||||||
async reconcileTeamArtifacts(
|
async reconcileTeamArtifacts(
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ import {
|
||||||
createCliAutoSuffixNameGuard,
|
createCliAutoSuffixNameGuard,
|
||||||
createCliProvisionerNameGuard,
|
createCliProvisionerNameGuard,
|
||||||
} from '@shared/utils/teamMemberName';
|
} from '@shared/utils/teamMemberName';
|
||||||
|
import { buildTeamMemberColorMap } from '@shared/utils/teamMemberColors';
|
||||||
import { getStableTeamOwnerId } from '@shared/utils/teamStableOwnerId';
|
import { getStableTeamOwnerId } from '@shared/utils/teamStableOwnerId';
|
||||||
|
|
||||||
import type { TeamConfig, TeamMember, TeamMemberSnapshot, TeamTaskWithKanban } from '@shared/types';
|
import type { TeamConfig, TeamMember, TeamMemberSnapshot, TeamTaskWithKanban } from '@shared/types';
|
||||||
|
|
@ -262,6 +263,11 @@ export class TeamMemberResolver {
|
||||||
}
|
}
|
||||||
return aStableId.localeCompare(bStableId);
|
return aStableId.localeCompare(bStableId);
|
||||||
});
|
});
|
||||||
return members;
|
|
||||||
|
const colorMap = buildTeamMemberColorMap(members, { preferProvidedColors: false });
|
||||||
|
return members.map((member) => ({
|
||||||
|
...member,
|
||||||
|
color: colorMap.get(member.name) ?? member.color ?? getMemberColorByName(member.name),
|
||||||
|
}));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -38,6 +38,7 @@ import { getMemberColorByName } from '@shared/constants/memberColors';
|
||||||
import { DEFAULT_TOOL_APPROVAL_SETTINGS } from '@shared/types/team';
|
import { DEFAULT_TOOL_APPROVAL_SETTINGS } from '@shared/types/team';
|
||||||
import { resolveLanguageName } from '@shared/utils/agentLanguage';
|
import { resolveLanguageName } from '@shared/utils/agentLanguage';
|
||||||
import { getAnthropicDefaultTeamModel } from '@shared/utils/anthropicModelDefaults';
|
import { getAnthropicDefaultTeamModel } from '@shared/utils/anthropicModelDefaults';
|
||||||
|
import { buildTeamMemberColorMap } from '@shared/utils/teamMemberColors';
|
||||||
import { parseCliArgs } from '@shared/utils/cliArgsParser';
|
import { parseCliArgs } from '@shared/utils/cliArgsParser';
|
||||||
import { deriveContextMetrics, inferContextWindowTokens } from '@shared/utils/contextMetrics';
|
import { deriveContextMetrics, inferContextWindowTokens } from '@shared/utils/contextMetrics';
|
||||||
import {
|
import {
|
||||||
|
|
@ -224,6 +225,16 @@ const PROBE_CACHE_TTL_MS = 36 * 60 * 60 * 1000;
|
||||||
const PREFLIGHT_BINARY_TIMEOUT_MS = 8000;
|
const PREFLIGHT_BINARY_TIMEOUT_MS = 8000;
|
||||||
const PREFLIGHT_AUTH_RETRY_DELAY_MS = 2000;
|
const PREFLIGHT_AUTH_RETRY_DELAY_MS = 2000;
|
||||||
const PREFLIGHT_AUTH_MAX_RETRIES = 2;
|
const PREFLIGHT_AUTH_MAX_RETRIES = 2;
|
||||||
|
|
||||||
|
function applyDistinctProvisioningMemberColors<
|
||||||
|
T extends { name: string; color?: string; removedAt?: number },
|
||||||
|
>(members: readonly T[]): T[] {
|
||||||
|
const colorMap = buildTeamMemberColorMap(members, { preferProvidedColors: false });
|
||||||
|
return members.map((member) => ({
|
||||||
|
...member,
|
||||||
|
color: colorMap.get(member.name) ?? member.color ?? getMemberColorByName(member.name),
|
||||||
|
}));
|
||||||
|
}
|
||||||
const FS_MONITOR_POLL_MS = 2000;
|
const FS_MONITOR_POLL_MS = 2000;
|
||||||
const TASK_WAIT_FALLBACK_MS = 15_000;
|
const TASK_WAIT_FALLBACK_MS = 15_000;
|
||||||
const STALL_CHECK_INTERVAL_MS = 10_000;
|
const STALL_CHECK_INTERVAL_MS = 10_000;
|
||||||
|
|
@ -727,6 +738,8 @@ interface ProvisioningRun {
|
||||||
>;
|
>;
|
||||||
/** Agent tool_use_id -> teammate name for persistent teammate spawns. */
|
/** Agent tool_use_id -> teammate name for persistent teammate spawns. */
|
||||||
memberSpawnToolUseIds: Map<string, string>;
|
memberSpawnToolUseIds: Map<string, string>;
|
||||||
|
/** Explicit restart requests awaiting teammate rejoin or failure. */
|
||||||
|
pendingMemberRestarts: Map<string, PendingMemberRestartContext>;
|
||||||
/** Per-member latest processed lead-inbox bootstrap signal cursor for the current live run. */
|
/** Per-member latest processed lead-inbox bootstrap signal cursor for the current live run. */
|
||||||
memberSpawnLeadInboxCursorByMember: Map<string, MemberSpawnInboxCursor>;
|
memberSpawnLeadInboxCursorByMember: Map<string, MemberSpawnInboxCursor>;
|
||||||
/** Highest accepted deterministic bootstrap event sequence for this run. */
|
/** Highest accepted deterministic bootstrap event sequence for this run. */
|
||||||
|
|
@ -821,6 +834,64 @@ interface LiveTeamAgentRuntimeMetadata {
|
||||||
tmuxPaneId?: string;
|
tmuxPaneId?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function escapeRegexLiteral(value: string): string {
|
||||||
|
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||||
|
}
|
||||||
|
|
||||||
|
function commandContainsCliArgValue(command: string, argName: string, value: string): boolean {
|
||||||
|
const normalizedCommand = command.trim();
|
||||||
|
const normalizedValue = value.trim();
|
||||||
|
if (!normalizedCommand || !normalizedValue) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const pattern = new RegExp(
|
||||||
|
`(?:^|\\s)${escapeRegexLiteral(argName)}(?:=|\\s+)${escapeRegexLiteral(normalizedValue)}(?:\\s|$)`
|
||||||
|
);
|
||||||
|
return pattern.test(normalizedCommand);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isNeverSpawnedDuringLaunchReason(reason?: string): boolean {
|
||||||
|
return reason?.trim() === 'Teammate was never spawned during launch.';
|
||||||
|
}
|
||||||
|
|
||||||
|
function isLaunchGraceWindowFailureReason(reason?: string): boolean {
|
||||||
|
return reason?.trim() === 'Teammate did not join within the launch grace window.';
|
||||||
|
}
|
||||||
|
|
||||||
|
function isConfigRegistrationFailureReason(reason?: string): boolean {
|
||||||
|
return (
|
||||||
|
reason?.trim() ===
|
||||||
|
'Teammate was not registered in config.json during launch. Persistent spawn failed.'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isAutoClearableLaunchFailureReason(reason?: string): boolean {
|
||||||
|
return (
|
||||||
|
isNeverSpawnedDuringLaunchReason(reason) ||
|
||||||
|
isLaunchGraceWindowFailureReason(reason) ||
|
||||||
|
isConfigRegistrationFailureReason(reason)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildRestartStillRunningReason(memberName: string): string {
|
||||||
|
return (
|
||||||
|
`Restart for teammate "${memberName}" was skipped because the previous runtime still appears ` +
|
||||||
|
`to be active. The requested settings may not have been applied.`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildRestartGraceTimeoutReason(memberName: string): string {
|
||||||
|
return `Teammate "${memberName}" did not rejoin within the restart grace window.`;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PendingMemberRestartContext {
|
||||||
|
requestedAt: string;
|
||||||
|
desired: Pick<
|
||||||
|
TeamCreateRequest['members'][number],
|
||||||
|
'name' | 'role' | 'workflow' | 'providerId' | 'model' | 'effort'
|
||||||
|
>;
|
||||||
|
}
|
||||||
|
|
||||||
function normalizeTeamAgentRuntimeBackendType(
|
function normalizeTeamAgentRuntimeBackendType(
|
||||||
value: string | undefined,
|
value: string | undefined,
|
||||||
isLead: boolean
|
isLead: boolean
|
||||||
|
|
@ -1628,7 +1699,9 @@ export function buildRestartMemberSpawnMessage(
|
||||||
return (
|
return (
|
||||||
`Teammate "${member.name}"${roleHint} was restarted from the UI. ` +
|
`Teammate "${member.name}"${roleHint} was restarted from the UI. ` +
|
||||||
`Please respawn them immediately using the **Agent** tool with team_name="${teamName}", name="${member.name}", subagent_type="general-purpose"${providerPart}${modelPart}${effortPart}, and the exact prompt below. ` +
|
`Please respawn them immediately using the **Agent** tool with team_name="${teamName}", name="${member.name}", subagent_type="general-purpose"${providerPart}${modelPart}${effortPart}, and the exact prompt below. ` +
|
||||||
`This is a restart of an existing persistent teammate, not a new teammate.${workflowHint ? workflowHint : ''}\n\n` +
|
`This is a restart of an existing persistent teammate, not a new teammate. ` +
|
||||||
|
`If the Agent tool returns duplicate_skipped with reason bootstrap_pending, treat that as a pending restart and wait for teammate check-in. ` +
|
||||||
|
`If it returns duplicate_skipped with reason already_running, do not report success - it means the previous runtime still appears active and the restart may not have applied.${workflowHint ? workflowHint : ''}\n\n` +
|
||||||
indentMultiline(prompt, ' ')
|
indentMultiline(prompt, ' ')
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -3919,6 +3992,7 @@ export class TeamProvisioningService {
|
||||||
const spawnedMemberName = run.memberSpawnToolUseIds.get(toolUseId);
|
const spawnedMemberName = run.memberSpawnToolUseIds.get(toolUseId);
|
||||||
if (spawnedMemberName) {
|
if (spawnedMemberName) {
|
||||||
run.memberSpawnToolUseIds.delete(toolUseId);
|
run.memberSpawnToolUseIds.delete(toolUseId);
|
||||||
|
const pendingRestart = run.pendingMemberRestarts.get(spawnedMemberName);
|
||||||
if (isError) {
|
if (isError) {
|
||||||
const resultPreview = extractToolResultPreview(resultContent);
|
const resultPreview = extractToolResultPreview(resultContent);
|
||||||
this.handleMemberSpawnFailure(run, spawnedMemberName, resultPreview);
|
this.handleMemberSpawnFailure(run, spawnedMemberName, resultPreview);
|
||||||
|
|
@ -3928,8 +4002,25 @@ export class TeamProvisioningService {
|
||||||
const detail =
|
const detail =
|
||||||
parsedStatus.reason === 'already_running'
|
parsedStatus.reason === 'already_running'
|
||||||
? 'duplicate spawn skipped - already running'
|
? 'duplicate spawn skipped - already running'
|
||||||
: 'duplicate spawn skipped - teammate already online';
|
: 'duplicate spawn skipped - teammate bootstrap still pending';
|
||||||
this.appendMemberBootstrapDiagnostic(run, spawnedMemberName, detail);
|
this.appendMemberBootstrapDiagnostic(run, spawnedMemberName, detail);
|
||||||
|
if (parsedStatus.reason === 'already_running') {
|
||||||
|
if (pendingRestart) {
|
||||||
|
run.pendingMemberRestarts.delete(spawnedMemberName);
|
||||||
|
this.setMemberSpawnStatus(
|
||||||
|
run,
|
||||||
|
spawnedMemberName,
|
||||||
|
'error',
|
||||||
|
buildRestartStillRunningReason(spawnedMemberName)
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.agentRuntimeSnapshotCache.delete(run.teamName);
|
||||||
|
this.liveTeamAgentRuntimeMetadataCache.delete(run.teamName);
|
||||||
|
this.setMemberSpawnStatus(run, spawnedMemberName, 'online', undefined, 'process');
|
||||||
|
} else {
|
||||||
|
this.setMemberSpawnStatus(run, spawnedMemberName, 'waiting');
|
||||||
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -3947,11 +4038,16 @@ export class TeamProvisioningService {
|
||||||
memberName: string,
|
memberName: string,
|
||||||
resultPreview?: string
|
resultPreview?: string
|
||||||
): void {
|
): void {
|
||||||
|
const pendingRestart = run.pendingMemberRestarts.get(memberName);
|
||||||
const reason =
|
const reason =
|
||||||
(typeof resultPreview === 'string' && resultPreview.trim().length > 0
|
(typeof resultPreview === 'string' && resultPreview.trim().length > 0
|
||||||
? resultPreview.trim()
|
? resultPreview.trim()
|
||||||
: 'Teammate spawn failed immediately after launch.') || 'Teammate spawn failed.';
|
: 'Teammate spawn failed immediately after launch.') || 'Teammate spawn failed.';
|
||||||
const message = `Teammate "${memberName}" failed to start: ${reason}`;
|
const message = pendingRestart
|
||||||
|
? `Failed to restart teammate "${memberName}": ${reason}`
|
||||||
|
: `Teammate "${memberName}" failed to start: ${reason}`;
|
||||||
|
|
||||||
|
run.pendingMemberRestarts.delete(memberName);
|
||||||
|
|
||||||
this.setMemberSpawnStatus(run, memberName, 'error', message);
|
this.setMemberSpawnStatus(run, memberName, 'error', message);
|
||||||
|
|
||||||
|
|
@ -4016,6 +4112,21 @@ export class TeamProvisioningService {
|
||||||
heartbeatAt?: string
|
heartbeatAt?: string
|
||||||
): void {
|
): void {
|
||||||
const prev = run.memberSpawnStatuses.get(memberName) ?? createInitialMemberSpawnStatusEntry();
|
const prev = run.memberSpawnStatuses.get(memberName) ?? createInitialMemberSpawnStatusEntry();
|
||||||
|
if (
|
||||||
|
status === 'waiting' &&
|
||||||
|
!prev.hardFailure &&
|
||||||
|
(prev.bootstrapConfirmed || prev.runtimeAlive)
|
||||||
|
) {
|
||||||
|
this.setMemberSpawnStatus(
|
||||||
|
run,
|
||||||
|
memberName,
|
||||||
|
'online',
|
||||||
|
undefined,
|
||||||
|
prev.livenessSource,
|
||||||
|
prev.lastHeartbeatAt
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
const updatedAt = nowIso();
|
const updatedAt = nowIso();
|
||||||
const next: MemberSpawnStatusEntry = {
|
const next: MemberSpawnStatusEntry = {
|
||||||
...prev,
|
...prev,
|
||||||
|
|
@ -4024,13 +4135,26 @@ export class TeamProvisioningService {
|
||||||
};
|
};
|
||||||
|
|
||||||
if (status === 'spawning') {
|
if (status === 'spawning') {
|
||||||
next.launchState = 'starting';
|
next.agentToolAccepted = false;
|
||||||
} else if (status === 'waiting') {
|
next.runtimeAlive = false;
|
||||||
next.agentToolAccepted = true;
|
next.bootstrapConfirmed = false;
|
||||||
next.hardFailure = false;
|
next.hardFailure = false;
|
||||||
next.error = undefined;
|
next.error = undefined;
|
||||||
next.hardFailureReason = undefined;
|
next.hardFailureReason = undefined;
|
||||||
|
next.livenessSource = undefined;
|
||||||
|
next.firstSpawnAcceptedAt = undefined;
|
||||||
|
next.lastHeartbeatAt = undefined;
|
||||||
|
next.launchState = 'starting';
|
||||||
|
} else if (status === 'waiting') {
|
||||||
|
next.agentToolAccepted = true;
|
||||||
|
next.runtimeAlive = false;
|
||||||
|
next.bootstrapConfirmed = false;
|
||||||
|
next.hardFailure = false;
|
||||||
|
next.error = undefined;
|
||||||
|
next.hardFailureReason = undefined;
|
||||||
|
next.livenessSource = undefined;
|
||||||
next.firstSpawnAcceptedAt = prev.firstSpawnAcceptedAt ?? updatedAt;
|
next.firstSpawnAcceptedAt = prev.firstSpawnAcceptedAt ?? updatedAt;
|
||||||
|
next.lastHeartbeatAt = undefined;
|
||||||
next.launchState = 'runtime_pending_bootstrap';
|
next.launchState = 'runtime_pending_bootstrap';
|
||||||
} else if (status === 'online') {
|
} else if (status === 'online') {
|
||||||
next.agentToolAccepted = true;
|
next.agentToolAccepted = true;
|
||||||
|
|
@ -4078,6 +4202,9 @@ export class TeamProvisioningService {
|
||||||
}
|
}
|
||||||
|
|
||||||
run.memberSpawnStatuses.set(memberName, next);
|
run.memberSpawnStatuses.set(memberName, next);
|
||||||
|
if ((status === 'online' && next.bootstrapConfirmed) || status === 'offline') {
|
||||||
|
run.pendingMemberRestarts.delete(memberName);
|
||||||
|
}
|
||||||
this.syncMemberLaunchGraceCheck(run, memberName, next);
|
this.syncMemberLaunchGraceCheck(run, memberName, next);
|
||||||
|
|
||||||
if (status === 'spawning') {
|
if (status === 'spawning') {
|
||||||
|
|
@ -4316,8 +4443,17 @@ export class TeamProvisioningService {
|
||||||
|
|
||||||
const config = await this.configReader.getConfig(teamName);
|
const config = await this.configReader.getConfig(teamName);
|
||||||
const configuredMembers = config?.members ?? [];
|
const configuredMembers = config?.members ?? [];
|
||||||
const configuredMember = configuredMembers.find(
|
let metaMembers: Awaited<ReturnType<TeamMembersMetaStore['getMembers']>> = [];
|
||||||
(member) => member?.name?.trim() === memberName
|
try {
|
||||||
|
metaMembers = await this.membersMetaStore.getMembers(teamName);
|
||||||
|
} catch {
|
||||||
|
metaMembers = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const configuredMember = this.resolveEffectiveConfiguredMember(
|
||||||
|
configuredMembers,
|
||||||
|
metaMembers,
|
||||||
|
memberName
|
||||||
);
|
);
|
||||||
if (!configuredMember) {
|
if (!configuredMember) {
|
||||||
throw new Error(`Member "${memberName}" is not configured in team "${teamName}"`);
|
throw new Error(`Member "${memberName}" is not configured in team "${teamName}"`);
|
||||||
|
|
@ -4328,6 +4464,9 @@ export class TeamProvisioningService {
|
||||||
if (isLeadMember({ name: memberName, agentType: configuredMember.agentType })) {
|
if (isLeadMember({ name: memberName, agentType: configuredMember.agentType })) {
|
||||||
throw new Error('Lead restart is not supported from member controls');
|
throw new Error('Lead restart is not supported from member controls');
|
||||||
}
|
}
|
||||||
|
if (run.pendingMemberRestarts.has(memberName)) {
|
||||||
|
throw new Error(`Restart for teammate "${memberName}" is already in progress`);
|
||||||
|
}
|
||||||
|
|
||||||
const persistedRuntimeMembers = this.readPersistedRuntimeMembers(teamName).filter((member) => {
|
const persistedRuntimeMembers = this.readPersistedRuntimeMembers(teamName).filter((member) => {
|
||||||
const candidateName = typeof member.name === 'string' ? member.name.trim() : '';
|
const candidateName = typeof member.name === 'string' ? member.name.trim() : '';
|
||||||
|
|
@ -4414,15 +4553,25 @@ export class TeamProvisioningService {
|
||||||
this.setMemberSpawnStatus(run, memberName, 'offline');
|
this.setMemberSpawnStatus(run, memberName, 'offline');
|
||||||
this.setMemberSpawnStatus(run, memberName, 'spawning');
|
this.setMemberSpawnStatus(run, memberName, 'spawning');
|
||||||
this.appendMemberBootstrapDiagnostic(run, memberName, 'manual restart requested from UI');
|
this.appendMemberBootstrapDiagnostic(run, memberName, 'manual restart requested from UI');
|
||||||
|
run.pendingMemberRestarts.set(memberName, {
|
||||||
|
requestedAt: nowIso(),
|
||||||
|
desired: {
|
||||||
|
name: configuredMember.name,
|
||||||
|
role: configuredMember.role,
|
||||||
|
workflow: configuredMember.workflow,
|
||||||
|
providerId: configuredMember.providerId,
|
||||||
|
model: configuredMember.model,
|
||||||
|
effort: configuredMember.effort,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
const leadName =
|
const leadName = this.resolveLeadMemberName(configuredMembers, metaMembers);
|
||||||
configuredMembers.find((member) => isLeadMember(member))?.name?.trim() || 'team-lead';
|
|
||||||
const restartMessage = buildRestartMemberSpawnMessage(
|
const restartMessage = buildRestartMemberSpawnMessage(
|
||||||
teamName,
|
teamName,
|
||||||
config?.name?.trim() || teamName,
|
config?.name?.trim() || teamName,
|
||||||
leadName,
|
leadName,
|
||||||
{
|
{
|
||||||
name: memberName,
|
name: configuredMember.name,
|
||||||
role: configuredMember.role,
|
role: configuredMember.role,
|
||||||
workflow: configuredMember.workflow,
|
workflow: configuredMember.workflow,
|
||||||
providerId: configuredMember.providerId,
|
providerId: configuredMember.providerId,
|
||||||
|
|
@ -4434,6 +4583,7 @@ export class TeamProvisioningService {
|
||||||
try {
|
try {
|
||||||
await this.sendMessageToRun(run, restartMessage);
|
await this.sendMessageToRun(run, restartMessage);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
run.pendingMemberRestarts.delete(memberName);
|
||||||
this.setMemberSpawnStatus(
|
this.setMemberSpawnStatus(
|
||||||
run,
|
run,
|
||||||
memberName,
|
memberName,
|
||||||
|
|
@ -4510,11 +4660,17 @@ export class TeamProvisioningService {
|
||||||
) {
|
) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
const restartPending = run.pendingMemberRestarts.has(memberName);
|
||||||
|
if (restartPending) {
|
||||||
|
run.pendingMemberRestarts.delete(memberName);
|
||||||
|
}
|
||||||
this.setMemberSpawnStatus(
|
this.setMemberSpawnStatus(
|
||||||
run,
|
run,
|
||||||
memberName,
|
memberName,
|
||||||
'error',
|
'error',
|
||||||
'Teammate did not join within the launch grace window.'
|
restartPending
|
||||||
|
? buildRestartGraceTimeoutReason(memberName)
|
||||||
|
: 'Teammate did not join within the launch grace window.'
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -5931,6 +6087,7 @@ export class TeamProvisioningService {
|
||||||
request.members.map((m) => [m.name, createInitialMemberSpawnStatusEntry()])
|
request.members.map((m) => [m.name, createInitialMemberSpawnStatusEntry()])
|
||||||
),
|
),
|
||||||
memberSpawnToolUseIds: new Map(),
|
memberSpawnToolUseIds: new Map(),
|
||||||
|
pendingMemberRestarts: new Map(),
|
||||||
memberSpawnLeadInboxCursorByMember: new Map(),
|
memberSpawnLeadInboxCursorByMember: new Map(),
|
||||||
lastDeterministicBootstrapSeq: 0,
|
lastDeterministicBootstrapSeq: 0,
|
||||||
lastMemberSpawnAuditAt: 0,
|
lastMemberSpawnAuditAt: 0,
|
||||||
|
|
@ -6050,8 +6207,7 @@ export class TeamProvisioningService {
|
||||||
limitContext: request.limitContext,
|
limitContext: request.limitContext,
|
||||||
createdAt: Date.now(),
|
createdAt: Date.now(),
|
||||||
});
|
});
|
||||||
await this.membersMetaStore.writeMembers(
|
const membersToWrite = applyDistinctProvisioningMemberColors(
|
||||||
request.teamName,
|
|
||||||
effectiveMemberSpecs.map((m) => ({
|
effectiveMemberSpecs.map((m) => ({
|
||||||
name: m.name.trim(),
|
name: m.name.trim(),
|
||||||
role: m.role?.trim() || undefined,
|
role: m.role?.trim() || undefined,
|
||||||
|
|
@ -6063,10 +6219,10 @@ export class TeamProvisioningService {
|
||||||
? m.effort
|
? m.effort
|
||||||
: undefined,
|
: undefined,
|
||||||
agentType: 'general-purpose' as const,
|
agentType: 'general-purpose' as const,
|
||||||
color: getMemberColorByName(m.name.trim()),
|
|
||||||
joinedAt: Date.now(),
|
joinedAt: Date.now(),
|
||||||
}))
|
}))
|
||||||
);
|
);
|
||||||
|
await this.membersMetaStore.writeMembers(request.teamName, membersToWrite);
|
||||||
if (request.skipPermissions === false) {
|
if (request.skipPermissions === false) {
|
||||||
await this.seedTeammateOperationalPermissionRules(request.teamName, request.cwd);
|
await this.seedTeammateOperationalPermissionRules(request.teamName, request.cwd);
|
||||||
}
|
}
|
||||||
|
|
@ -6512,6 +6668,7 @@ export class TeamProvisioningService {
|
||||||
expectedMembers.map((name) => [name, createInitialMemberSpawnStatusEntry()])
|
expectedMembers.map((name) => [name, createInitialMemberSpawnStatusEntry()])
|
||||||
),
|
),
|
||||||
memberSpawnToolUseIds: new Map(),
|
memberSpawnToolUseIds: new Map(),
|
||||||
|
pendingMemberRestarts: new Map(),
|
||||||
memberSpawnLeadInboxCursorByMember: new Map(),
|
memberSpawnLeadInboxCursorByMember: new Map(),
|
||||||
lastDeterministicBootstrapSeq: 0,
|
lastDeterministicBootstrapSeq: 0,
|
||||||
lastMemberSpawnAuditAt: 0,
|
lastMemberSpawnAuditAt: 0,
|
||||||
|
|
@ -7979,13 +8136,29 @@ export class TeamProvisioningService {
|
||||||
const nextStatuses = { ...statuses };
|
const nextStatuses = { ...statuses };
|
||||||
for (const [memberName, metadata] of runtimeByMember.entries()) {
|
for (const [memberName, metadata] of runtimeByMember.entries()) {
|
||||||
const current = nextStatuses[memberName];
|
const current = nextStatuses[memberName];
|
||||||
if (!current || !metadata.model) {
|
if (!current) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
nextStatuses[memberName] = {
|
const nextEntry: MemberSpawnStatusEntry = {
|
||||||
...current,
|
...current,
|
||||||
runtimeModel: metadata.model,
|
...(metadata.model ? { runtimeModel: metadata.model } : {}),
|
||||||
};
|
};
|
||||||
|
const failureReason = current.hardFailureReason ?? current.error;
|
||||||
|
if (
|
||||||
|
metadata.alive &&
|
||||||
|
current.launchState === 'failed_to_start' &&
|
||||||
|
isAutoClearableLaunchFailureReason(failureReason)
|
||||||
|
) {
|
||||||
|
nextEntry.status = 'online';
|
||||||
|
nextEntry.agentToolAccepted = true;
|
||||||
|
nextEntry.runtimeAlive = true;
|
||||||
|
nextEntry.hardFailure = false;
|
||||||
|
nextEntry.hardFailureReason = undefined;
|
||||||
|
nextEntry.error = undefined;
|
||||||
|
nextEntry.livenessSource = current.bootstrapConfirmed ? current.livenessSource : 'process';
|
||||||
|
nextEntry.launchState = deriveMemberLaunchState(nextEntry);
|
||||||
|
}
|
||||||
|
nextStatuses[memberName] = nextEntry;
|
||||||
}
|
}
|
||||||
return nextStatuses;
|
return nextStatuses;
|
||||||
}
|
}
|
||||||
|
|
@ -8033,6 +8206,87 @@ export class TeamProvisioningService {
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private resolveEffectiveConfiguredMember(
|
||||||
|
configuredMembers: TeamConfig['members'] | undefined,
|
||||||
|
metaMembers: Awaited<ReturnType<TeamMembersMetaStore['getMembers']>>,
|
||||||
|
memberName: string
|
||||||
|
): {
|
||||||
|
name: string;
|
||||||
|
role?: string;
|
||||||
|
workflow?: string;
|
||||||
|
providerId?: TeamProviderId;
|
||||||
|
model?: string;
|
||||||
|
effort?: EffortLevel;
|
||||||
|
agentType?: string;
|
||||||
|
removedAt?: number | string;
|
||||||
|
} | null {
|
||||||
|
const configuredMember = (configuredMembers ?? []).find((member) => {
|
||||||
|
const candidateName = typeof member?.name === 'string' ? member.name.trim() : '';
|
||||||
|
return candidateName.length > 0 && matchesTeamMemberIdentity(candidateName, memberName);
|
||||||
|
});
|
||||||
|
const metaMember = metaMembers.find((member) => {
|
||||||
|
const candidateName = member.name?.trim() ?? '';
|
||||||
|
return candidateName.length > 0 && matchesTeamMemberIdentity(candidateName, memberName);
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!configuredMember && !metaMember) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const name =
|
||||||
|
metaMember?.name?.trim() || configuredMember?.name?.trim() || memberName.trim() || memberName;
|
||||||
|
const role = metaMember?.role?.trim() || configuredMember?.role?.trim() || undefined;
|
||||||
|
const workflow =
|
||||||
|
metaMember?.workflow?.trim() || configuredMember?.workflow?.trim() || undefined;
|
||||||
|
const providerId =
|
||||||
|
normalizeTeamMemberProviderId(metaMember?.providerId) ??
|
||||||
|
normalizeTeamMemberProviderId(configuredMember?.providerId);
|
||||||
|
const model = metaMember?.model?.trim() || configuredMember?.model?.trim() || undefined;
|
||||||
|
const effort =
|
||||||
|
metaMember?.effort === 'low' ||
|
||||||
|
metaMember?.effort === 'medium' ||
|
||||||
|
metaMember?.effort === 'high'
|
||||||
|
? metaMember.effort
|
||||||
|
: configuredMember?.effort === 'low' ||
|
||||||
|
configuredMember?.effort === 'medium' ||
|
||||||
|
configuredMember?.effort === 'high'
|
||||||
|
? configuredMember.effort
|
||||||
|
: undefined;
|
||||||
|
const agentType =
|
||||||
|
metaMember?.agentType?.trim() || configuredMember?.agentType?.trim() || undefined;
|
||||||
|
const removedAt = metaMember?.removedAt ?? configuredMember?.removedAt;
|
||||||
|
|
||||||
|
return {
|
||||||
|
name,
|
||||||
|
...(role ? { role } : {}),
|
||||||
|
...(workflow ? { workflow } : {}),
|
||||||
|
...(providerId ? { providerId } : {}),
|
||||||
|
...(model ? { model } : {}),
|
||||||
|
...(effort ? { effort } : {}),
|
||||||
|
...(agentType ? { agentType } : {}),
|
||||||
|
...(removedAt != null ? { removedAt } : {}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private resolveLeadMemberName(
|
||||||
|
configuredMembers: TeamConfig['members'] | undefined,
|
||||||
|
metaMembers: Awaited<ReturnType<TeamMembersMetaStore['getMembers']>>
|
||||||
|
): string {
|
||||||
|
const configuredLead = (configuredMembers ?? []).find((member) => isLeadMember(member));
|
||||||
|
const configuredLeadName = configuredLead?.name?.trim();
|
||||||
|
if (configuredLeadName) {
|
||||||
|
return configuredLeadName;
|
||||||
|
}
|
||||||
|
|
||||||
|
const metaLead = metaMembers.find((member) => isLeadMember(member));
|
||||||
|
const metaLeadName = metaLead?.name?.trim();
|
||||||
|
if (metaLeadName) {
|
||||||
|
return metaLeadName;
|
||||||
|
}
|
||||||
|
|
||||||
|
return 'team-lead';
|
||||||
|
}
|
||||||
|
|
||||||
private findEffectiveRunMemberModel(
|
private findEffectiveRunMemberModel(
|
||||||
run: ProvisioningRun | null,
|
run: ProvisioningRun | null,
|
||||||
memberName: string
|
memberName: string
|
||||||
|
|
@ -8174,13 +8428,28 @@ export class TeamProvisioningService {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const unresolvedAgentIds = [...metadataByMember.values()]
|
||||||
|
.map((metadata) => metadata.agentId?.trim() ?? '')
|
||||||
|
.filter((agentId) => agentId.length > 0);
|
||||||
|
const processPidByAgentId =
|
||||||
|
unresolvedAgentIds.length > 0
|
||||||
|
? this.findLiveProcessPidByAgentId(teamName, unresolvedAgentIds)
|
||||||
|
: new Map<string, number>();
|
||||||
|
|
||||||
for (const [memberName, metadata] of metadataByMember.entries()) {
|
for (const [memberName, metadata] of metadataByMember.entries()) {
|
||||||
const paneId = metadata.tmuxPaneId?.trim() ?? '';
|
const paneId = metadata.tmuxPaneId?.trim() ?? '';
|
||||||
const backendType = metadata.backendType;
|
const backendType = metadata.backendType;
|
||||||
const panePid = paneId ? panePidById.get(paneId) : undefined;
|
const panePid = paneId ? panePidById.get(paneId) : undefined;
|
||||||
|
const processPid = metadata.agentId ? processPidByAgentId.get(metadata.agentId) : undefined;
|
||||||
|
const resolvedPid =
|
||||||
|
typeof panePid === 'number' && panePid > 0
|
||||||
|
? panePid
|
||||||
|
: typeof processPid === 'number' && processPid > 0
|
||||||
|
? processPid
|
||||||
|
: undefined;
|
||||||
const status = this.findTrackedMemberSpawnStatus(run, memberName);
|
const status = this.findTrackedMemberSpawnStatus(run, memberName);
|
||||||
const alive =
|
const alive =
|
||||||
typeof panePid === 'number' && panePid > 0
|
typeof resolvedPid === 'number' && resolvedPid > 0
|
||||||
? true
|
? true
|
||||||
: backendType === 'tmux'
|
: backendType === 'tmux'
|
||||||
? false
|
? false
|
||||||
|
|
@ -8188,7 +8457,7 @@ export class TeamProvisioningService {
|
||||||
metadataByMember.set(memberName, {
|
metadataByMember.set(memberName, {
|
||||||
...metadata,
|
...metadata,
|
||||||
alive,
|
alive,
|
||||||
...(typeof panePid === 'number' && panePid > 0 ? { pid: panePid } : {}),
|
...(typeof resolvedPid === 'number' && resolvedPid > 0 ? { pid: resolvedPid } : {}),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -8236,6 +8505,46 @@ export class TeamProvisioningService {
|
||||||
return rows;
|
return rows;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private findLiveProcessPidByAgentId(
|
||||||
|
teamName: string,
|
||||||
|
agentIds: readonly string[]
|
||||||
|
): Map<string, number> {
|
||||||
|
const normalizedAgentIds = [
|
||||||
|
...new Set(agentIds.map((agentId) => agentId.trim()).filter(Boolean)),
|
||||||
|
];
|
||||||
|
if (normalizedAgentIds.length === 0) {
|
||||||
|
return new Map();
|
||||||
|
}
|
||||||
|
|
||||||
|
const rows = this.readUnixProcessTableRows();
|
||||||
|
if (rows.length === 0) {
|
||||||
|
return new Map();
|
||||||
|
}
|
||||||
|
|
||||||
|
const pidByAgentId = new Map<string, number>();
|
||||||
|
for (const row of rows) {
|
||||||
|
if (
|
||||||
|
!commandContainsCliArgValue(row.command, '--team-name', teamName) ||
|
||||||
|
!row.command.includes('--agent-id')
|
||||||
|
) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const agentId of normalizedAgentIds) {
|
||||||
|
if (!commandContainsCliArgValue(row.command, '--agent-id', agentId)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const currentPid = pidByAgentId.get(agentId);
|
||||||
|
if (!currentPid || row.pid > currentPid) {
|
||||||
|
pidByAgentId.set(agentId, row.pid);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return pidByAgentId;
|
||||||
|
}
|
||||||
|
|
||||||
private async readProcessRssBytesByPid(pids: readonly number[]): Promise<Map<number, number>> {
|
private async readProcessRssBytesByPid(pids: readonly number[]): Promise<Map<number, number>> {
|
||||||
const uniquePids = [...new Set(pids.filter((pid) => Number.isFinite(pid) && pid > 0))];
|
const uniquePids = [...new Set(pids.filter((pid) => Number.isFinite(pid) && pid > 0))];
|
||||||
if (uniquePids.length === 0) {
|
if (uniquePids.length === 0) {
|
||||||
|
|
@ -8444,6 +8753,7 @@ export class TeamProvisioningService {
|
||||||
const nextMembers = { ...persisted.members };
|
const nextMembers = { ...persisted.members };
|
||||||
const now = nowIso();
|
const now = nowIso();
|
||||||
for (const expected of persisted.expectedMembers) {
|
for (const expected of persisted.expectedMembers) {
|
||||||
|
const bootstrapMember = bootstrapSnapshot?.members[expected];
|
||||||
const current = nextMembers[expected] ?? {
|
const current = nextMembers[expected] ?? {
|
||||||
name: expected,
|
name: expected,
|
||||||
launchState: 'starting',
|
launchState: 'starting',
|
||||||
|
|
@ -8453,6 +8763,20 @@ export class TeamProvisioningService {
|
||||||
hardFailure: false,
|
hardFailure: false,
|
||||||
lastEvaluatedAt: now,
|
lastEvaluatedAt: now,
|
||||||
};
|
};
|
||||||
|
if (bootstrapMember?.agentToolAccepted && !current.agentToolAccepted) {
|
||||||
|
current.agentToolAccepted = true;
|
||||||
|
current.firstSpawnAcceptedAt =
|
||||||
|
current.firstSpawnAcceptedAt ?? bootstrapMember.firstSpawnAcceptedAt;
|
||||||
|
}
|
||||||
|
if (bootstrapMember?.runtimeAlive && !current.runtimeAlive) {
|
||||||
|
current.runtimeAlive = true;
|
||||||
|
current.lastRuntimeAliveAt =
|
||||||
|
current.lastRuntimeAliveAt ?? bootstrapMember.lastRuntimeAliveAt;
|
||||||
|
}
|
||||||
|
if (bootstrapMember?.bootstrapConfirmed && !current.bootstrapConfirmed) {
|
||||||
|
current.bootstrapConfirmed = true;
|
||||||
|
current.lastHeartbeatAt = current.lastHeartbeatAt ?? bootstrapMember.lastHeartbeatAt;
|
||||||
|
}
|
||||||
const matchedRuntimeNames = [...configMembers].filter((name) => {
|
const matchedRuntimeNames = [...configMembers].filter((name) => {
|
||||||
if (name === expected) return true;
|
if (name === expected) return true;
|
||||||
const parsed = parseNumericSuffixName(name);
|
const parsed = parseNumericSuffixName(name);
|
||||||
|
|
@ -8499,6 +8823,21 @@ export class TeamProvisioningService {
|
||||||
: current.sources?.configDrift,
|
: current.sources?.configDrift,
|
||||||
inboxHeartbeat: heartbeatMessage != null ? true : current.sources?.inboxHeartbeat,
|
inboxHeartbeat: heartbeatMessage != null ? true : current.sources?.inboxHeartbeat,
|
||||||
};
|
};
|
||||||
|
const bootstrapProvesSpawnAcceptance =
|
||||||
|
bootstrapMember?.agentToolAccepted === true ||
|
||||||
|
typeof bootstrapMember?.firstSpawnAcceptedAt === 'string';
|
||||||
|
const currentProvesSpawnAcceptance =
|
||||||
|
current.agentToolAccepted === true || typeof current.firstSpawnAcceptedAt === 'string';
|
||||||
|
if (
|
||||||
|
isNeverSpawnedDuringLaunchReason(current.hardFailureReason) &&
|
||||||
|
(bootstrapProvesSpawnAcceptance || currentProvesSpawnAcceptance)
|
||||||
|
) {
|
||||||
|
current.hardFailure = false;
|
||||||
|
current.hardFailureReason = undefined;
|
||||||
|
if (current.sources) {
|
||||||
|
current.sources.hardFailureSignal = undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
if (heartbeatReason) {
|
if (heartbeatReason) {
|
||||||
current.hardFailure = true;
|
current.hardFailure = true;
|
||||||
current.hardFailureReason = heartbeatReason;
|
current.hardFailureReason = heartbeatReason;
|
||||||
|
|
@ -13044,8 +13383,7 @@ export class TeamProvisioningService {
|
||||||
const joinedAt = Date.now();
|
const joinedAt = Date.now();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await this.membersMetaStore.writeMembers(
|
const membersToWrite = applyDistinctProvisioningMemberColors(
|
||||||
teamName,
|
|
||||||
teammateMembers.map((member) => ({
|
teammateMembers.map((member) => ({
|
||||||
name: member.name.trim(),
|
name: member.name.trim(),
|
||||||
role: member.role?.trim() || undefined,
|
role: member.role?.trim() || undefined,
|
||||||
|
|
@ -13056,11 +13394,11 @@ export class TeamProvisioningService {
|
||||||
member.effort === 'low' || member.effort === 'medium' || member.effort === 'high'
|
member.effort === 'low' || member.effort === 'medium' || member.effort === 'high'
|
||||||
? member.effort
|
? member.effort
|
||||||
: undefined,
|
: undefined,
|
||||||
agentType: 'general-purpose',
|
agentType: 'general-purpose' as const,
|
||||||
color: getMemberColorByName(member.name.trim()),
|
|
||||||
joinedAt,
|
joinedAt,
|
||||||
}))
|
}))
|
||||||
);
|
);
|
||||||
|
await this.membersMetaStore.writeMembers(teamName, membersToWrite);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.warn(
|
logger.warn(
|
||||||
`[${teamName}] Failed to persist members.meta.json: ${
|
`[${teamName}] Failed to persist members.meta.json: ${
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ import { parentPort } from 'node:worker_threads';
|
||||||
|
|
||||||
import { normalizePersistedLaunchSnapshot } from '@main/services/team/TeamLaunchStateEvaluator';
|
import { normalizePersistedLaunchSnapshot } from '@main/services/team/TeamLaunchStateEvaluator';
|
||||||
import { isLeadMember } from '@shared/utils/leadDetection';
|
import { isLeadMember } from '@shared/utils/leadDetection';
|
||||||
|
import { buildTeamMemberColorMap } from '@shared/utils/teamMemberColors';
|
||||||
|
|
||||||
interface ListTeamsPayload {
|
interface ListTeamsPayload {
|
||||||
teamsDir: string;
|
teamsDir: string;
|
||||||
|
|
@ -593,6 +594,11 @@ async function listTeams(
|
||||||
dropCliProvisionerMembers(memberMap);
|
dropCliProvisionerMembers(memberMap);
|
||||||
|
|
||||||
const members = Array.from(memberMap.values());
|
const members = Array.from(memberMap.values());
|
||||||
|
const memberColors = buildTeamMemberColorMap(members, { preferProvidedColors: false });
|
||||||
|
const coloredMembers = members.map((member) => ({
|
||||||
|
...member,
|
||||||
|
color: memberColors.get(member.name) ?? member.color,
|
||||||
|
}));
|
||||||
const launchStateSummary =
|
const launchStateSummary =
|
||||||
(await readLaunchState(payload.teamsDir, teamName)) ??
|
(await readLaunchState(payload.teamsDir, teamName)) ??
|
||||||
(() => {
|
(() => {
|
||||||
|
|
@ -623,7 +629,7 @@ async function listTeams(
|
||||||
memberCount: memberMap.size,
|
memberCount: memberMap.size,
|
||||||
taskCount: 0,
|
taskCount: 0,
|
||||||
lastActivity: null,
|
lastActivity: null,
|
||||||
...(members.length > 0 ? { members } : {}),
|
...(coloredMembers.length > 0 ? { members: coloredMembers } : {}),
|
||||||
...(color ? { color } : {}),
|
...(color ? { color } : {}),
|
||||||
...(projectPath ? { projectPath } : {}),
|
...(projectPath ? { projectPath } : {}),
|
||||||
...(leadSessionId ? { leadSessionId } : {}),
|
...(leadSessionId ? { leadSessionId } : {}),
|
||||||
|
|
|
||||||
|
|
@ -40,6 +40,7 @@ import {
|
||||||
import { createChipFromSelection } from '@renderer/utils/chipUtils';
|
import { createChipFromSelection } from '@renderer/utils/chipUtils';
|
||||||
import { sumContextInjectionTokens } from '@renderer/utils/contextMath';
|
import { sumContextInjectionTokens } from '@renderer/utils/contextMath';
|
||||||
import { formatProjectPath } from '@renderer/utils/pathDisplay';
|
import { formatProjectPath } from '@renderer/utils/pathDisplay';
|
||||||
|
import { buildMemberColorMap } from '@renderer/utils/memberHelpers';
|
||||||
import { buildTaskCountsByOwner, normalizePath } from '@renderer/utils/pathNormalize';
|
import { buildTaskCountsByOwner, normalizePath } from '@renderer/utils/pathNormalize';
|
||||||
import { nameColorSet } from '@renderer/utils/projectColor';
|
import { nameColorSet } from '@renderer/utils/projectColor';
|
||||||
import { resolveProjectIdByPath } from '@renderer/utils/projectLookup';
|
import { resolveProjectIdByPath } from '@renderer/utils/projectLookup';
|
||||||
|
|
@ -1537,6 +1538,10 @@ export const TeamDetailView = ({
|
||||||
return nextMember;
|
return nextMember;
|
||||||
});
|
});
|
||||||
}, [leadBranch, members, trackedBranches]);
|
}, [leadBranch, members, trackedBranches]);
|
||||||
|
const resolvedMemberColorMap = useMemo(
|
||||||
|
() => buildMemberColorMap(membersWithLiveBranches),
|
||||||
|
[membersWithLiveBranches]
|
||||||
|
);
|
||||||
|
|
||||||
// Filter sessions to team-only using sessionHistory + leadSessionId
|
// Filter sessions to team-only using sessionHistory + leadSessionId
|
||||||
const teamSessionIds = useMemo(() => {
|
const teamSessionIds = useMemo(() => {
|
||||||
|
|
@ -2168,12 +2173,17 @@ export const TeamDetailView = ({
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
className="h-7 gap-1 px-2 text-xs text-[var(--color-text-muted)] hover:text-[var(--color-text)]"
|
className="h-7 gap-1 px-2 text-xs text-[var(--color-text-muted)] hover:text-[var(--color-text)]"
|
||||||
|
disabled={isTeamProvisioning}
|
||||||
onClick={() => setEditDialogOpen(true)}
|
onClick={() => setEditDialogOpen(true)}
|
||||||
>
|
>
|
||||||
<Pencil size={12} />
|
<Pencil size={12} />
|
||||||
</Button>
|
</Button>
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipContent side="bottom">Edit team</TooltipContent>
|
<TooltipContent side="bottom">
|
||||||
|
{isTeamProvisioning
|
||||||
|
? 'Edit team is unavailable while provisioning is still in progress'
|
||||||
|
: 'Edit team'}
|
||||||
|
</TooltipContent>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
<TooltipTrigger asChild>
|
<TooltipTrigger asChild>
|
||||||
|
|
@ -2708,7 +2718,10 @@ export const TeamDetailView = ({
|
||||||
currentDescription={data.config.description ?? ''}
|
currentDescription={data.config.description ?? ''}
|
||||||
currentColor={data.config.color ?? ''}
|
currentColor={data.config.color ?? ''}
|
||||||
currentMembers={membersWithLiveBranches.filter((m) => !isLeadMember(m))}
|
currentMembers={membersWithLiveBranches.filter((m) => !isLeadMember(m))}
|
||||||
|
leadMember={membersWithLiveBranches.find((m) => isLeadMember(m)) ?? null}
|
||||||
|
resolvedMemberColorMap={resolvedMemberColorMap}
|
||||||
isTeamAlive={data.isAlive && !isTeamProvisioning}
|
isTeamAlive={data.isAlive && !isTeamProvisioning}
|
||||||
|
isTeamProvisioning={isTeamProvisioning}
|
||||||
projectPath={data.config.projectPath}
|
projectPath={data.config.projectPath}
|
||||||
onClose={() => setEditDialogOpen(false)}
|
onClose={() => setEditDialogOpen(false)}
|
||||||
onSaved={() => void selectTeam(teamName)}
|
onSaved={() => void selectTeam(teamName)}
|
||||||
|
|
|
||||||
|
|
@ -47,6 +47,7 @@ import {
|
||||||
} from '@renderer/utils/teamModelAvailability';
|
} from '@renderer/utils/teamModelAvailability';
|
||||||
import { getTeamProviderLabel as getCatalogTeamProviderLabel } from '@renderer/utils/teamModelCatalog';
|
import { getTeamProviderLabel as getCatalogTeamProviderLabel } from '@renderer/utils/teamModelCatalog';
|
||||||
import { DEFAULT_PROVIDER_MODEL_SELECTION } from '@shared/utils/providerModelSelection';
|
import { DEFAULT_PROVIDER_MODEL_SELECTION } from '@shared/utils/providerModelSelection';
|
||||||
|
import { resolveTeamLeadColorName } from '@shared/utils/teamMemberColors';
|
||||||
import { isTeamProviderId, normalizeOptionalTeamProviderId } from '@shared/utils/teamProvider';
|
import { isTeamProviderId, normalizeOptionalTeamProviderId } from '@shared/utils/teamProvider';
|
||||||
import { AlertTriangle, CheckCircle2, Info, Loader2, X } from 'lucide-react';
|
import { AlertTriangle, CheckCircle2, Info, Loader2, X } from 'lucide-react';
|
||||||
|
|
||||||
|
|
@ -940,7 +941,14 @@ export const CreateTeamDialog = ({
|
||||||
const mentionSuggestions = useMemo(
|
const mentionSuggestions = useMemo(
|
||||||
() =>
|
() =>
|
||||||
soloTeam
|
soloTeam
|
||||||
? [{ id: 'team-lead', name: 'team-lead', subtitle: 'Team Lead', color: 'blue' }]
|
? [
|
||||||
|
{
|
||||||
|
id: 'team-lead',
|
||||||
|
name: 'team-lead',
|
||||||
|
subtitle: 'Team Lead',
|
||||||
|
color: resolveTeamLeadColorName(),
|
||||||
|
},
|
||||||
|
]
|
||||||
: buildMemberDraftSuggestions(members, memberColorMap),
|
: buildMemberDraftSuggestions(members, memberColorMap),
|
||||||
[memberColorMap, members, soloTeam]
|
[memberColorMap, members, soloTeam]
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -1,13 +1,15 @@
|
||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||||
|
|
||||||
import { api } from '@renderer/api';
|
import { api } from '@renderer/api';
|
||||||
import {
|
import {
|
||||||
buildMembersFromDrafts,
|
buildMembersFromDrafts,
|
||||||
createMemberDraftsFromInputs,
|
createMemberDraftsFromInputs,
|
||||||
filterEditableMemberInputs,
|
filterEditableMemberInputs,
|
||||||
|
createMemberDraft,
|
||||||
MembersEditorSection,
|
MembersEditorSection,
|
||||||
validateMemberNameInline,
|
validateMemberNameInline,
|
||||||
} from '@renderer/components/team/members/MembersEditorSection';
|
} from '@renderer/components/team/members/MembersEditorSection';
|
||||||
|
import { MemberDraftRow } from '@renderer/components/team/members/MemberDraftRow';
|
||||||
import { Button } from '@renderer/components/ui/button';
|
import { Button } from '@renderer/components/ui/button';
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
|
|
@ -21,8 +23,17 @@ import { getTeamColorSet, getThemedBadge } from '@renderer/constants/teamColors'
|
||||||
import { useFileListCacheWarmer } from '@renderer/hooks/useFileListCacheWarmer';
|
import { useFileListCacheWarmer } from '@renderer/hooks/useFileListCacheWarmer';
|
||||||
import { useTheme } from '@renderer/hooks/useTheme';
|
import { useTheme } from '@renderer/hooks/useTheme';
|
||||||
import { cn } from '@renderer/lib/utils';
|
import { cn } from '@renderer/lib/utils';
|
||||||
|
import { buildMemberColorMap, displayMemberName } from '@renderer/utils/memberHelpers';
|
||||||
|
import { parseNumericSuffixName } from '@shared/utils/teamMemberName';
|
||||||
import { Loader2 } from 'lucide-react';
|
import { Loader2 } from 'lucide-react';
|
||||||
|
|
||||||
|
import {
|
||||||
|
buildEditTeamSourceSnapshot,
|
||||||
|
getMemberRuntimeContractKey,
|
||||||
|
getLiveRosterIdentityChanges,
|
||||||
|
getMembersRequiringRuntimeRestart,
|
||||||
|
} from './editTeamRuntimeChanges';
|
||||||
|
|
||||||
import type { ResolvedTeamMember } from '@shared/types';
|
import type { ResolvedTeamMember } from '@shared/types';
|
||||||
|
|
||||||
const TEAM_COLOR_NAMES = [
|
const TEAM_COLOR_NAMES = [
|
||||||
|
|
@ -43,16 +54,72 @@ interface EditTeamDialogProps {
|
||||||
currentDescription: string;
|
currentDescription: string;
|
||||||
currentColor: string;
|
currentColor: string;
|
||||||
currentMembers: ResolvedTeamMember[];
|
currentMembers: ResolvedTeamMember[];
|
||||||
|
leadMember?: ResolvedTeamMember | null;
|
||||||
|
resolvedMemberColorMap?: ReadonlyMap<string, string>;
|
||||||
isTeamAlive?: boolean;
|
isTeamAlive?: boolean;
|
||||||
|
isTeamProvisioning?: boolean;
|
||||||
projectPath?: string | null;
|
projectPath?: string | null;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
onSaved: () => void;
|
onSaved: () => Promise<void> | void;
|
||||||
}
|
}
|
||||||
|
|
||||||
function membersToDrafts(members: ResolvedTeamMember[]) {
|
function membersToDrafts(members: ResolvedTeamMember[]) {
|
||||||
return createMemberDraftsFromInputs(filterEditableMemberInputs(members));
|
return createMemberDraftsFromInputs(filterEditableMemberInputs(members));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function useEditTeamErrorReset(
|
||||||
|
setError: (value: string | null) => void,
|
||||||
|
setSaveOutcomeError: (value: string | null) => void
|
||||||
|
): () => void {
|
||||||
|
return () => {
|
||||||
|
setError(null);
|
||||||
|
setSaveOutcomeError(null);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function getInvalidMemberNamesError(
|
||||||
|
members: readonly {
|
||||||
|
name: string;
|
||||||
|
removedAt?: number | string | null;
|
||||||
|
}[]
|
||||||
|
): string | null {
|
||||||
|
for (const member of members) {
|
||||||
|
if (member.removedAt) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const name = member.name.trim();
|
||||||
|
if (!name) {
|
||||||
|
return 'Member name cannot be empty';
|
||||||
|
}
|
||||||
|
if (validateMemberNameInline(name) !== null) {
|
||||||
|
return 'Member name must start with alphanumeric, use only [a-zA-Z0-9._-], max 128 chars';
|
||||||
|
}
|
||||||
|
const lower = name.toLowerCase();
|
||||||
|
if (lower === 'user' || lower === 'team-lead') {
|
||||||
|
return `Member name "${name}" is reserved`;
|
||||||
|
}
|
||||||
|
const suffixInfo = parseNumericSuffixName(name);
|
||||||
|
if (suffixInfo && suffixInfo.suffix >= 2) {
|
||||||
|
return `Member name "${name}" is not allowed (reserved for Claude CLI auto-suffix). Use "${suffixInfo.base}" instead.`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyRemovedMembersToSnapshot(
|
||||||
|
members: readonly ResolvedTeamMember[],
|
||||||
|
removedMemberNames: readonly string[]
|
||||||
|
): ResolvedTeamMember[] {
|
||||||
|
if (removedMemberNames.length === 0) {
|
||||||
|
return [...members];
|
||||||
|
}
|
||||||
|
const removedKeys = new Set(removedMemberNames.map((name) => name.trim().toLowerCase()));
|
||||||
|
const removedAt = Date.now();
|
||||||
|
return members.map((member) =>
|
||||||
|
removedKeys.has(member.name.trim().toLowerCase()) ? { ...member, removedAt } : member
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export const EditTeamDialog = ({
|
export const EditTeamDialog = ({
|
||||||
open,
|
open,
|
||||||
teamName,
|
teamName,
|
||||||
|
|
@ -60,7 +127,10 @@ export const EditTeamDialog = ({
|
||||||
currentDescription,
|
currentDescription,
|
||||||
currentColor,
|
currentColor,
|
||||||
currentMembers,
|
currentMembers,
|
||||||
|
leadMember = null,
|
||||||
|
resolvedMemberColorMap,
|
||||||
isTeamAlive = false,
|
isTeamAlive = false,
|
||||||
|
isTeamProvisioning = false,
|
||||||
projectPath,
|
projectPath,
|
||||||
onClose,
|
onClose,
|
||||||
onSaved,
|
onSaved,
|
||||||
|
|
@ -72,39 +142,305 @@ export const EditTeamDialog = ({
|
||||||
const [members, setMembers] = useState(() => membersToDrafts(currentMembers));
|
const [members, setMembers] = useState(() => membersToDrafts(currentMembers));
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [saveOutcomeError, setSaveOutcomeError] = useState<string | null>(null);
|
||||||
|
const [membersPendingRestartRetry, setMembersPendingRestartRetry] = useState<
|
||||||
|
Record<string, string>
|
||||||
|
>({});
|
||||||
|
const wasOpenRef = useRef(false);
|
||||||
|
const initializedTeamNameRef = useRef<string | null>(null);
|
||||||
|
const baselineSourceSnapshotRef = useRef<string | null>(null);
|
||||||
|
const pendingCommittedSourceSnapshotRef = useRef<string | null>(null);
|
||||||
|
|
||||||
useFileListCacheWarmer(projectPath ?? null);
|
useFileListCacheWarmer(projectPath ?? null);
|
||||||
|
const clearTransientErrors = useEditTeamErrorReset(setError, setSaveOutcomeError);
|
||||||
|
const effectiveResolvedMemberColorMap = useMemo(
|
||||||
|
() => resolvedMemberColorMap ?? buildMemberColorMap(currentMembers),
|
||||||
|
[currentMembers, resolvedMemberColorMap]
|
||||||
|
);
|
||||||
|
const leadDraft = useMemo(() => {
|
||||||
|
if (!leadMember) return null;
|
||||||
|
return createMemberDraft({
|
||||||
|
id: `lead:${leadMember.name}`,
|
||||||
|
name: displayMemberName(leadMember.name),
|
||||||
|
originalName: leadMember.name,
|
||||||
|
roleSelection: '',
|
||||||
|
customRole: 'Team Lead',
|
||||||
|
workflow: leadMember.workflow,
|
||||||
|
providerId: leadMember.providerId,
|
||||||
|
model: leadMember.model ?? '',
|
||||||
|
effort: leadMember.effort,
|
||||||
|
});
|
||||||
|
}, [leadMember]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
const wasOpen = wasOpenRef.current;
|
||||||
if (open) {
|
if (open) {
|
||||||
setName(currentName);
|
const shouldInitialize = !wasOpen || initializedTeamNameRef.current !== teamName;
|
||||||
setDescription(currentDescription);
|
if (shouldInitialize) {
|
||||||
setColor(currentColor);
|
setName(currentName);
|
||||||
setMembers(membersToDrafts(currentMembers));
|
setDescription(currentDescription);
|
||||||
setError(null);
|
setColor(currentColor);
|
||||||
|
setMembers(membersToDrafts(currentMembers));
|
||||||
|
setError(null);
|
||||||
|
setSaveOutcomeError(null);
|
||||||
|
setMembersPendingRestartRetry({});
|
||||||
|
initializedTeamNameRef.current = teamName;
|
||||||
|
baselineSourceSnapshotRef.current = buildEditTeamSourceSnapshot({
|
||||||
|
name: currentName,
|
||||||
|
description: currentDescription,
|
||||||
|
color: currentColor,
|
||||||
|
members: currentMembers,
|
||||||
|
});
|
||||||
|
pendingCommittedSourceSnapshotRef.current = null;
|
||||||
|
} else if (pendingCommittedSourceSnapshotRef.current !== null) {
|
||||||
|
const latestSourceSnapshot = buildEditTeamSourceSnapshot({
|
||||||
|
name: currentName,
|
||||||
|
description: currentDescription,
|
||||||
|
color: currentColor,
|
||||||
|
members: currentMembers,
|
||||||
|
});
|
||||||
|
if (latestSourceSnapshot === pendingCommittedSourceSnapshotRef.current) {
|
||||||
|
baselineSourceSnapshotRef.current = latestSourceSnapshot;
|
||||||
|
pendingCommittedSourceSnapshotRef.current = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if (wasOpen) {
|
||||||
|
initializedTeamNameRef.current = null;
|
||||||
|
baselineSourceSnapshotRef.current = null;
|
||||||
|
pendingCommittedSourceSnapshotRef.current = null;
|
||||||
}
|
}
|
||||||
}, [open, currentName, currentDescription, currentColor, currentMembers]);
|
wasOpenRef.current = open;
|
||||||
|
}, [open, teamName, currentName, currentDescription, currentColor, currentMembers]);
|
||||||
|
|
||||||
|
const builtMembers = useMemo(() => buildMembersFromDrafts(members), [members]);
|
||||||
|
const invalidMemberNamesError = useMemo(() => getInvalidMemberNamesError(members), [members]);
|
||||||
|
const hasDuplicateMembers = useMemo(() => {
|
||||||
|
const names = members
|
||||||
|
.filter((member) => !member.removedAt)
|
||||||
|
.map((member) => member.name.trim().toLowerCase())
|
||||||
|
.filter(Boolean);
|
||||||
|
return new Set(names).size !== names.length;
|
||||||
|
}, [members]);
|
||||||
|
const membersToRestart = useMemo(
|
||||||
|
() =>
|
||||||
|
isTeamAlive
|
||||||
|
? getMembersRequiringRuntimeRestart({
|
||||||
|
previousMembers: currentMembers,
|
||||||
|
nextMembers: builtMembers,
|
||||||
|
})
|
||||||
|
: [],
|
||||||
|
[builtMembers, currentMembers, isTeamAlive]
|
||||||
|
);
|
||||||
|
const builtMembersByName = useMemo(
|
||||||
|
() =>
|
||||||
|
new Map(builtMembers.map((member) => [member.name.trim().toLowerCase(), member] as const)),
|
||||||
|
[builtMembers]
|
||||||
|
);
|
||||||
|
const effectiveMembersToRestart = useMemo(() => {
|
||||||
|
const retryMembers = Object.entries(membersPendingRestartRetry).flatMap(
|
||||||
|
([normalizedName, expectedRuntimeContractKey]) => {
|
||||||
|
const nextMember = builtMembersByName.get(normalizedName);
|
||||||
|
if (!nextMember) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
return getMemberRuntimeContractKey(nextMember) === expectedRuntimeContractKey
|
||||||
|
? [nextMember.name.trim()]
|
||||||
|
: [];
|
||||||
|
}
|
||||||
|
);
|
||||||
|
return Array.from(
|
||||||
|
new Set(
|
||||||
|
[...membersToRestart, ...retryMembers]
|
||||||
|
.map((memberName) => memberName.trim())
|
||||||
|
.filter(Boolean)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}, [builtMembersByName, membersPendingRestartRetry, membersToRestart]);
|
||||||
|
const liveIdentityChanges = useMemo(
|
||||||
|
() =>
|
||||||
|
isTeamAlive
|
||||||
|
? getLiveRosterIdentityChanges({
|
||||||
|
previousMembers: currentMembers,
|
||||||
|
nextDrafts: members,
|
||||||
|
})
|
||||||
|
: { renamed: [], removed: [] },
|
||||||
|
[currentMembers, isTeamAlive, members]
|
||||||
|
);
|
||||||
|
const hasBlockedLiveIdentityChanges = liveIdentityChanges.renamed.length > 0;
|
||||||
|
const liveRemovedExistingMembers = useMemo(
|
||||||
|
() => (isTeamAlive ? liveIdentityChanges.removed : []),
|
||||||
|
[isTeamAlive, liveIdentityChanges.removed]
|
||||||
|
);
|
||||||
|
const hasNewLiveTeammates = useMemo(
|
||||||
|
() =>
|
||||||
|
isTeamAlive && members.some((member) => !member.removedAt && !member.originalName?.trim()),
|
||||||
|
[isTeamAlive, members]
|
||||||
|
);
|
||||||
|
const memberWarningById = useMemo(() => {
|
||||||
|
const restartNames = new Set(
|
||||||
|
effectiveMembersToRestart.map((memberName) => memberName.trim().toLowerCase())
|
||||||
|
);
|
||||||
|
if (restartNames.size === 0) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
return Object.fromEntries(
|
||||||
|
members.map((member) => [
|
||||||
|
member.id,
|
||||||
|
restartNames.has(member.name.trim().toLowerCase())
|
||||||
|
? 'Saving will restart this teammate to apply role, workflow, provider, model, or effort changes.'
|
||||||
|
: null,
|
||||||
|
])
|
||||||
|
);
|
||||||
|
}, [effectiveMembersToRestart, members]);
|
||||||
|
|
||||||
const handleSave = (): void => {
|
const handleSave = (): void => {
|
||||||
if (!name.trim()) {
|
if (!name.trim()) {
|
||||||
setError('Team name cannot be empty');
|
setError('Team name cannot be empty');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const builtMembers = buildMembersFromDrafts(members);
|
if (invalidMemberNamesError) {
|
||||||
|
setError(invalidMemberNamesError);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (hasDuplicateMembers) {
|
||||||
|
setError('Member names must be unique before saving');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const latestSourceSnapshot = buildEditTeamSourceSnapshot({
|
||||||
|
name: currentName,
|
||||||
|
description: currentDescription,
|
||||||
|
color: currentColor,
|
||||||
|
members: currentMembers,
|
||||||
|
});
|
||||||
|
const allowedSourceSnapshots = new Set(
|
||||||
|
[baselineSourceSnapshotRef.current, pendingCommittedSourceSnapshotRef.current].filter(
|
||||||
|
(value): value is string => value !== null
|
||||||
|
)
|
||||||
|
);
|
||||||
|
if (allowedSourceSnapshots.size > 0 && !allowedSourceSnapshots.has(latestSourceSnapshot)) {
|
||||||
|
setError(
|
||||||
|
'Team settings changed while this dialog was open. Reopen it and review the latest state before saving.'
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (hasBlockedLiveIdentityChanges) {
|
||||||
|
setError(
|
||||||
|
`Existing teammates cannot be renamed while the team is live. renamed: ${liveIdentityChanges.renamed.join(', ')}`
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (isTeamProvisioning) {
|
||||||
|
setError(
|
||||||
|
'Team settings cannot be edited while provisioning is still in progress. Wait for launch to finish, then try again.'
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (hasNewLiveTeammates) {
|
||||||
|
setError(
|
||||||
|
'Add new teammates from the dedicated Add member dialog while the team is live. Edit Team only supports updating existing teammates.'
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
setSaving(true);
|
setSaving(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
|
setSaveOutcomeError(null);
|
||||||
void (async () => {
|
void (async () => {
|
||||||
|
let configSaved = false;
|
||||||
|
let membersSaved = false;
|
||||||
|
let committedMembersForSnapshot: ResolvedTeamMember[] = currentMembers;
|
||||||
try {
|
try {
|
||||||
await api.teams.updateConfig(teamName, {
|
await api.teams.updateConfig(teamName, {
|
||||||
name: name.trim(),
|
name: name.trim(),
|
||||||
description: description.trim(),
|
description: description.trim(),
|
||||||
color,
|
color,
|
||||||
});
|
});
|
||||||
|
configSaved = true;
|
||||||
|
for (const removedMemberName of liveRemovedExistingMembers) {
|
||||||
|
await api.teams.removeMember(teamName, removedMemberName);
|
||||||
|
committedMembersForSnapshot = applyRemovedMembersToSnapshot(committedMembersForSnapshot, [
|
||||||
|
removedMemberName,
|
||||||
|
]);
|
||||||
|
}
|
||||||
await api.teams.replaceMembers(teamName, { members: builtMembers });
|
await api.teams.replaceMembers(teamName, { members: builtMembers });
|
||||||
onSaved();
|
membersSaved = true;
|
||||||
onClose();
|
pendingCommittedSourceSnapshotRef.current = buildEditTeamSourceSnapshot({
|
||||||
|
name: name.trim(),
|
||||||
|
description: description.trim(),
|
||||||
|
color: color.trim(),
|
||||||
|
members: builtMembers.map((member) => ({
|
||||||
|
name: member.name,
|
||||||
|
role: member.role,
|
||||||
|
workflow: member.workflow,
|
||||||
|
providerId: member.providerId,
|
||||||
|
model: member.model,
|
||||||
|
effort: member.effort,
|
||||||
|
})) as ResolvedTeamMember[],
|
||||||
|
});
|
||||||
|
|
||||||
|
const restartFailures: string[] = [];
|
||||||
|
const failedRestartMembers: string[] = [];
|
||||||
|
for (const memberName of effectiveMembersToRestart) {
|
||||||
|
try {
|
||||||
|
await api.teams.restartMember(teamName, memberName);
|
||||||
|
} catch (restartError) {
|
||||||
|
const detail =
|
||||||
|
restartError instanceof Error ? restartError.message : String(restartError);
|
||||||
|
failedRestartMembers.push(memberName);
|
||||||
|
restartFailures.push(`${memberName} (${detail})`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await Promise.resolve(onSaved());
|
||||||
|
if (restartFailures.length === 0) {
|
||||||
|
setMembersPendingRestartRetry({});
|
||||||
|
onClose();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setMembersPendingRestartRetry(
|
||||||
|
Object.fromEntries(
|
||||||
|
failedRestartMembers.flatMap((memberName) => {
|
||||||
|
const nextMember = builtMembersByName.get(memberName.trim().toLowerCase());
|
||||||
|
if (!nextMember) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
return [
|
||||||
|
[memberName.trim().toLowerCase(), getMemberRuntimeContractKey(nextMember)] as const,
|
||||||
|
];
|
||||||
|
})
|
||||||
|
)
|
||||||
|
);
|
||||||
|
setSaveOutcomeError(
|
||||||
|
`Team saved, but failed to restart ${restartFailures.length === 1 ? 'this teammate' : 'these teammates'}: ${restartFailures.join(', ')}`
|
||||||
|
);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setError(e instanceof Error ? e.message : 'Failed to save');
|
const message = e instanceof Error ? e.message : 'Failed to save';
|
||||||
|
if (membersSaved) {
|
||||||
|
setSaveOutcomeError(
|
||||||
|
`Team changes were saved, but failed to refresh the latest view: ${message}`
|
||||||
|
);
|
||||||
|
} else if (configSaved) {
|
||||||
|
pendingCommittedSourceSnapshotRef.current = buildEditTeamSourceSnapshot({
|
||||||
|
name: name.trim(),
|
||||||
|
description: description.trim(),
|
||||||
|
color: color.trim(),
|
||||||
|
members: committedMembersForSnapshot,
|
||||||
|
});
|
||||||
|
let refreshErrorDetail: string | null = null;
|
||||||
|
try {
|
||||||
|
await Promise.resolve(onSaved());
|
||||||
|
} catch (refreshError) {
|
||||||
|
refreshErrorDetail =
|
||||||
|
refreshError instanceof Error ? refreshError.message : String(refreshError);
|
||||||
|
}
|
||||||
|
setSaveOutcomeError(
|
||||||
|
refreshErrorDetail
|
||||||
|
? `Team settings were saved, but member changes failed: ${message}. Refresh also failed: ${refreshErrorDetail}`
|
||||||
|
: `Team settings were saved, but member changes failed: ${message}`
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
setError(message);
|
||||||
|
}
|
||||||
} finally {
|
} finally {
|
||||||
setSaving(false);
|
setSaving(false);
|
||||||
}
|
}
|
||||||
|
|
@ -131,7 +467,10 @@ export const EditTeamDialog = ({
|
||||||
id="edit-team-name"
|
id="edit-team-name"
|
||||||
type="text"
|
type="text"
|
||||||
value={name}
|
value={name}
|
||||||
onChange={(e) => setName(e.target.value)}
|
onChange={(e) => {
|
||||||
|
clearTransientErrors();
|
||||||
|
setName(e.target.value);
|
||||||
|
}}
|
||||||
onKeyDown={(e) => {
|
onKeyDown={(e) => {
|
||||||
if (e.key === 'Enter' && !saving && name.trim()) handleSave();
|
if (e.key === 'Enter' && !saving && name.trim()) handleSave();
|
||||||
}}
|
}}
|
||||||
|
|
@ -149,7 +488,10 @@ export const EditTeamDialog = ({
|
||||||
<textarea
|
<textarea
|
||||||
id="edit-team-description"
|
id="edit-team-description"
|
||||||
value={description}
|
value={description}
|
||||||
onChange={(e) => setDescription(e.target.value)}
|
onChange={(e) => {
|
||||||
|
clearTransientErrors();
|
||||||
|
setDescription(e.target.value);
|
||||||
|
}}
|
||||||
rows={3}
|
rows={3}
|
||||||
className="w-full resize-none rounded-md border border-[var(--color-border)] bg-[var(--color-surface)] px-3 py-1.5 text-sm text-[var(--color-text)] outline-none focus:border-[var(--color-border-emphasis)]"
|
className="w-full resize-none rounded-md border border-[var(--color-border)] bg-[var(--color-surface)] px-3 py-1.5 text-sm text-[var(--color-text)] outline-none focus:border-[var(--color-border-emphasis)]"
|
||||||
placeholder="Team description (optional)"
|
placeholder="Team description (optional)"
|
||||||
|
|
@ -158,20 +500,82 @@ export const EditTeamDialog = ({
|
||||||
<div>
|
<div>
|
||||||
<MembersEditorSection
|
<MembersEditorSection
|
||||||
members={members}
|
members={members}
|
||||||
onChange={setMembers}
|
onChange={(nextMembers) => {
|
||||||
|
clearTransientErrors();
|
||||||
|
setMembers(nextMembers);
|
||||||
|
}}
|
||||||
|
fieldError={invalidMemberNamesError ?? undefined}
|
||||||
validateMemberName={validateMemberNameInline}
|
validateMemberName={validateMemberNameInline}
|
||||||
showWorkflow
|
showWorkflow
|
||||||
showJsonEditor={!isTeamAlive}
|
showJsonEditor={!isTeamAlive}
|
||||||
draftKeyPrefix={`editTeam:${teamName}`}
|
draftKeyPrefix={`editTeam:${teamName}`}
|
||||||
projectPath={projectPath ?? null}
|
projectPath={projectPath ?? null}
|
||||||
|
headerExtra={
|
||||||
|
leadDraft ? (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<MemberDraftRow
|
||||||
|
member={leadDraft}
|
||||||
|
index={0}
|
||||||
|
resolvedColor={effectiveResolvedMemberColorMap.get(
|
||||||
|
leadDraft.originalName ?? leadDraft.name
|
||||||
|
)}
|
||||||
|
nameError={null}
|
||||||
|
onNameChange={() => undefined}
|
||||||
|
onRoleChange={() => undefined}
|
||||||
|
onCustomRoleChange={() => undefined}
|
||||||
|
onRemove={() => undefined}
|
||||||
|
onProviderChange={() => undefined}
|
||||||
|
onModelChange={() => undefined}
|
||||||
|
onEffortChange={() => undefined}
|
||||||
|
projectPath={projectPath ?? null}
|
||||||
|
lockProviderModel
|
||||||
|
lockRole
|
||||||
|
lockedRoleLabel="Team Lead"
|
||||||
|
lockIdentity
|
||||||
|
hideActionButton
|
||||||
|
modelLockReason="The team lead is shown for context only and cannot be edited from Edit Team."
|
||||||
|
/>
|
||||||
|
<p className="text-[11px] text-[var(--color-text-muted)]">
|
||||||
|
Team lead is shown for context only. Edit Team changes only teammate roster
|
||||||
|
settings.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : null
|
||||||
|
}
|
||||||
existingMembers={currentMembers}
|
existingMembers={currentMembers}
|
||||||
lockProviderModel={isTeamAlive}
|
existingMemberColorMap={effectiveResolvedMemberColorMap}
|
||||||
|
lockProviderModel={false}
|
||||||
|
lockExistingMemberIdentity={isTeamAlive}
|
||||||
|
identityLockReason={undefined}
|
||||||
|
disableAddMember={isTeamAlive}
|
||||||
|
addMemberLockReason="Use the dedicated Add member dialog to add new teammates while the team is live."
|
||||||
|
memberWarningById={memberWarningById}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
{isTeamAlive ? (
|
{isTeamProvisioning ? (
|
||||||
<p className="text-xs text-amber-300">
|
<p className="text-xs text-amber-300">
|
||||||
Provider and model changes are locked while the team is live. Reconnect the team to
|
Team provisioning is still in progress. Editing is temporarily locked until launch
|
||||||
change them safely.
|
finishes.
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
{isTeamAlive && hasNewLiveTeammates ? (
|
||||||
|
<p className="text-xs text-red-300">
|
||||||
|
New teammates cannot be added from Edit Team while the team is live. Use the Add
|
||||||
|
member dialog instead.
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
{isTeamAlive && hasBlockedLiveIdentityChanges ? (
|
||||||
|
<p className="text-xs text-red-300">
|
||||||
|
Live save is blocked because existing teammates were renamed. Revert those identity
|
||||||
|
changes or stop the team first.
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
{isTeamAlive && effectiveMembersToRestart.length > 0 ? (
|
||||||
|
<p className="text-xs text-amber-300">
|
||||||
|
Saving will restart{' '}
|
||||||
|
{effectiveMembersToRestart.length === 1 ? 'this teammate' : 'these teammates'} to
|
||||||
|
apply role, workflow, provider, model, or effort changes:{' '}
|
||||||
|
{effectiveMembersToRestart.join(', ')}.
|
||||||
</p>
|
</p>
|
||||||
) : null}
|
) : null}
|
||||||
<div>
|
<div>
|
||||||
|
|
@ -196,7 +600,10 @@ export const EditTeamDialog = ({
|
||||||
borderColor: isSelected ? colorSet.border : 'transparent',
|
borderColor: isSelected ? colorSet.border : 'transparent',
|
||||||
}}
|
}}
|
||||||
title={colorName}
|
title={colorName}
|
||||||
onClick={() => setColor(isSelected ? '' : colorName)}
|
onClick={() => {
|
||||||
|
clearTransientErrors();
|
||||||
|
setColor(isSelected ? '' : colorName);
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<span
|
<span
|
||||||
className="size-3.5 rounded-full"
|
className="size-3.5 rounded-full"
|
||||||
|
|
@ -207,14 +614,26 @@ export const EditTeamDialog = ({
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{error && <p className="text-xs text-red-400">{error}</p>}
|
{(error || saveOutcomeError) && (
|
||||||
|
<p className="text-xs text-red-400">{error ?? saveOutcomeError}</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<DialogFooter>
|
<DialogFooter>
|
||||||
<Button variant="outline" size="sm" onClick={onClose} disabled={saving}>
|
<Button variant="outline" size="sm" onClick={onClose} disabled={saving}>
|
||||||
Cancel
|
Cancel
|
||||||
</Button>
|
</Button>
|
||||||
<Button size="sm" onClick={handleSave} disabled={saving || !name.trim()}>
|
<Button
|
||||||
|
size="sm"
|
||||||
|
onClick={handleSave}
|
||||||
|
disabled={
|
||||||
|
saving ||
|
||||||
|
isTeamProvisioning ||
|
||||||
|
!name.trim() ||
|
||||||
|
hasDuplicateMembers ||
|
||||||
|
Boolean(invalidMemberNamesError)
|
||||||
|
}
|
||||||
|
>
|
||||||
{saving && <Loader2 size={14} className="mr-1.5 animate-spin" />}
|
{saving && <Loader2 size={14} className="mr-1.5 animate-spin" />}
|
||||||
Save
|
Save
|
||||||
</Button>
|
</Button>
|
||||||
|
|
|
||||||
165
src/renderer/components/team/dialogs/editTeamRuntimeChanges.ts
Normal file
165
src/renderer/components/team/dialogs/editTeamRuntimeChanges.ts
Normal file
|
|
@ -0,0 +1,165 @@
|
||||||
|
import { normalizeOptionalTeamProviderId } from '@shared/utils/teamProvider';
|
||||||
|
|
||||||
|
import type { MemberDraft } from '@renderer/components/team/members/membersEditorTypes';
|
||||||
|
import type { ResolvedTeamMember, TeamProvisioningMemberInput } from '@shared/types';
|
||||||
|
|
||||||
|
function normalizeRestartSensitiveMemberContract(member: {
|
||||||
|
role?: string;
|
||||||
|
workflow?: string;
|
||||||
|
providerId?: string;
|
||||||
|
model?: string;
|
||||||
|
effort?: string;
|
||||||
|
}): {
|
||||||
|
role?: string;
|
||||||
|
workflow?: string;
|
||||||
|
providerId?: 'anthropic' | 'codex' | 'gemini';
|
||||||
|
model?: string;
|
||||||
|
effort?: 'low' | 'medium' | 'high';
|
||||||
|
} {
|
||||||
|
const role = member.role?.trim() || undefined;
|
||||||
|
const workflow = member.workflow?.trim() || undefined;
|
||||||
|
const providerId = normalizeOptionalTeamProviderId(member.providerId);
|
||||||
|
const model = member.model?.trim() || undefined;
|
||||||
|
const effort =
|
||||||
|
member.effort === 'low' || member.effort === 'medium' || member.effort === 'high'
|
||||||
|
? member.effort
|
||||||
|
: undefined;
|
||||||
|
return { role, workflow, providerId, model, effort };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getMemberRuntimeContractKey(member: {
|
||||||
|
role?: string;
|
||||||
|
workflow?: string;
|
||||||
|
providerId?: string;
|
||||||
|
model?: string;
|
||||||
|
effort?: string;
|
||||||
|
}): string {
|
||||||
|
return JSON.stringify(normalizeRestartSensitiveMemberContract(member));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getMembersRequiringRuntimeRestart(params: {
|
||||||
|
previousMembers: readonly ResolvedTeamMember[];
|
||||||
|
nextMembers: readonly TeamProvisioningMemberInput[];
|
||||||
|
}): string[] {
|
||||||
|
const previousByName = new Map(
|
||||||
|
params.previousMembers
|
||||||
|
.filter((member) => !member.removedAt)
|
||||||
|
.map((member) => [member.name.trim().toLowerCase(), member] as const)
|
||||||
|
);
|
||||||
|
|
||||||
|
const membersToRestart: string[] = [];
|
||||||
|
for (const nextMember of params.nextMembers) {
|
||||||
|
const normalizedName = nextMember.name.trim().toLowerCase();
|
||||||
|
if (!normalizedName) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const previousMember = previousByName.get(normalizedName);
|
||||||
|
if (!previousMember) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const previousRuntime = normalizeRestartSensitiveMemberContract(previousMember);
|
||||||
|
const nextRuntime = normalizeRestartSensitiveMemberContract(nextMember);
|
||||||
|
if (
|
||||||
|
previousRuntime.role !== nextRuntime.role ||
|
||||||
|
previousRuntime.workflow !== nextRuntime.workflow ||
|
||||||
|
previousRuntime.providerId !== nextRuntime.providerId ||
|
||||||
|
previousRuntime.model !== nextRuntime.model ||
|
||||||
|
previousRuntime.effort !== nextRuntime.effort
|
||||||
|
) {
|
||||||
|
membersToRestart.push(previousMember.name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return membersToRestart;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getLiveRosterIdentityChanges(params: {
|
||||||
|
previousMembers: readonly ResolvedTeamMember[];
|
||||||
|
nextDrafts: readonly MemberDraft[];
|
||||||
|
}): {
|
||||||
|
renamed: string[];
|
||||||
|
removed: string[];
|
||||||
|
} {
|
||||||
|
const previousMembers = params.previousMembers
|
||||||
|
.filter((member) => !member.removedAt)
|
||||||
|
.filter((member) => member.name.trim().toLowerCase() !== 'team-lead');
|
||||||
|
|
||||||
|
const previousNamesByKey = new Map(
|
||||||
|
previousMembers.map((member) => [member.name.trim().toLowerCase(), member.name.trim()] as const)
|
||||||
|
);
|
||||||
|
|
||||||
|
const nextExistingOriginalKeys = new Set(
|
||||||
|
params.nextDrafts
|
||||||
|
.map((member) => member.originalName?.trim().toLowerCase())
|
||||||
|
.filter((value): value is string => Boolean(value))
|
||||||
|
);
|
||||||
|
|
||||||
|
const renamed = params.nextDrafts
|
||||||
|
.flatMap((member) => {
|
||||||
|
const originalName = member.originalName?.trim();
|
||||||
|
const nextName = member.name.trim();
|
||||||
|
if (!originalName || !nextName) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
return originalName.toLowerCase() === nextName.toLowerCase() ? [] : [originalName];
|
||||||
|
})
|
||||||
|
.sort((a, b) => a.localeCompare(b));
|
||||||
|
|
||||||
|
const removed = Array.from(previousNamesByKey.entries())
|
||||||
|
.filter(([normalizedName]) => !nextExistingOriginalKeys.has(normalizedName))
|
||||||
|
.map(([, displayName]) => displayName)
|
||||||
|
.sort((a, b) => a.localeCompare(b));
|
||||||
|
|
||||||
|
return { renamed, removed };
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeEditableMemberSnapshot(member: {
|
||||||
|
name: string;
|
||||||
|
role?: string;
|
||||||
|
workflow?: string;
|
||||||
|
providerId?: string;
|
||||||
|
model?: string;
|
||||||
|
effort?: string;
|
||||||
|
removedAt?: number | string | null;
|
||||||
|
}): {
|
||||||
|
name: string;
|
||||||
|
role?: string;
|
||||||
|
workflow?: string;
|
||||||
|
providerId?: 'anthropic' | 'codex' | 'gemini';
|
||||||
|
model?: string;
|
||||||
|
effort?: 'low' | 'medium' | 'high';
|
||||||
|
} | null {
|
||||||
|
if (member.removedAt) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const name = member.name.trim();
|
||||||
|
if (!name || name.toLowerCase() === 'team-lead') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
name,
|
||||||
|
role: member.role?.trim() || undefined,
|
||||||
|
workflow: member.workflow?.trim() || undefined,
|
||||||
|
...normalizeRestartSensitiveMemberContract(member),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildEditTeamSourceSnapshot(params: {
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
color: string;
|
||||||
|
members: readonly ResolvedTeamMember[];
|
||||||
|
}): string {
|
||||||
|
const members = params.members
|
||||||
|
.map(normalizeEditableMemberSnapshot)
|
||||||
|
.filter((member): member is NonNullable<typeof member> => member !== null)
|
||||||
|
.sort((a, b) => a.name.localeCompare(b.name));
|
||||||
|
|
||||||
|
return JSON.stringify({
|
||||||
|
name: params.name.trim(),
|
||||||
|
description: params.description.trim(),
|
||||||
|
color: params.color.trim(),
|
||||||
|
members,
|
||||||
|
});
|
||||||
|
}
|
||||||
132
src/renderer/components/team/members/LeadModelRow.test.tsx
Normal file
132
src/renderer/components/team/members/LeadModelRow.test.tsx
Normal file
|
|
@ -0,0 +1,132 @@
|
||||||
|
import React, { act } from 'react';
|
||||||
|
import { createRoot } from 'react-dom/client';
|
||||||
|
|
||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
|
import { getTeamColorSet } from '@renderer/constants/teamColors';
|
||||||
|
import { resolveTeamLeadColorName } from '@shared/utils/teamMemberColors';
|
||||||
|
|
||||||
|
vi.mock('@renderer/components/common/ProviderBrandLogo', () => ({
|
||||||
|
ProviderBrandLogo: () => React.createElement('span', { 'data-testid': 'provider-logo' }),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('@renderer/components/team/dialogs/EffortLevelSelector', () => ({
|
||||||
|
EffortLevelSelector: () => React.createElement('div', null, 'effort-selector'),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('@renderer/components/team/dialogs/LimitContextCheckbox', () => ({
|
||||||
|
LimitContextCheckbox: () => React.createElement('div', null, 'limit-context'),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('@renderer/components/team/dialogs/TeamModelSelector', () => ({
|
||||||
|
getProviderScopedTeamModelLabel: (_providerId: string, model: string) => model || 'Default',
|
||||||
|
getTeamProviderLabel: (providerId: string) => providerId,
|
||||||
|
TeamModelSelector: () => React.createElement('div', null, 'team-model-selector'),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('@renderer/components/ui/checkbox', () => ({
|
||||||
|
Checkbox: ({
|
||||||
|
checked,
|
||||||
|
onCheckedChange,
|
||||||
|
...props
|
||||||
|
}: {
|
||||||
|
checked?: boolean;
|
||||||
|
onCheckedChange?: (value: boolean) => void;
|
||||||
|
}) =>
|
||||||
|
React.createElement('input', {
|
||||||
|
...props,
|
||||||
|
checked,
|
||||||
|
type: 'checkbox',
|
||||||
|
onChange: (event: React.ChangeEvent<HTMLInputElement>) =>
|
||||||
|
onCheckedChange?.(event.target.checked),
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('@renderer/components/ui/label', () => ({
|
||||||
|
Label: ({
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}: React.LabelHTMLAttributes<HTMLLabelElement> & { children: React.ReactNode }) =>
|
||||||
|
React.createElement('label', props, children),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('@renderer/hooks/useTheme', () => ({
|
||||||
|
useTheme: () => ({ isLight: false }),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('@renderer/utils/teamModelCatalog', () => ({
|
||||||
|
isAnthropicHaikuTeamModel: () => false,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('../../ui/button', () => ({
|
||||||
|
Button: ({
|
||||||
|
children,
|
||||||
|
className,
|
||||||
|
onClick,
|
||||||
|
disabled,
|
||||||
|
'aria-label': ariaLabel,
|
||||||
|
}: {
|
||||||
|
children: React.ReactNode;
|
||||||
|
className?: string;
|
||||||
|
onClick?: React.MouseEventHandler<HTMLButtonElement>;
|
||||||
|
disabled?: boolean;
|
||||||
|
'aria-label'?: string;
|
||||||
|
}) =>
|
||||||
|
React.createElement(
|
||||||
|
'button',
|
||||||
|
{ className, disabled, onClick, type: 'button', 'aria-label': ariaLabel },
|
||||||
|
children
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { LeadModelRow } from './LeadModelRow';
|
||||||
|
|
||||||
|
function renderLeadModelRow(): { host: HTMLDivElement; root: ReturnType<typeof createRoot> } {
|
||||||
|
const host = document.createElement('div');
|
||||||
|
document.body.appendChild(host);
|
||||||
|
const root = createRoot(host);
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
root.render(
|
||||||
|
React.createElement(LeadModelRow, {
|
||||||
|
providerId: 'anthropic',
|
||||||
|
model: 'opus',
|
||||||
|
effort: 'medium',
|
||||||
|
limitContext: false,
|
||||||
|
onProviderChange: () => undefined,
|
||||||
|
onModelChange: () => undefined,
|
||||||
|
onEffortChange: () => undefined,
|
||||||
|
onLimitContextChange: () => undefined,
|
||||||
|
syncModelsWithTeammates: true,
|
||||||
|
onSyncModelsWithTeammatesChange: () => undefined,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
return { host, root };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('LeadModelRow', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
document.body.innerHTML = '';
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses the canonical team-lead color for the preview stripe', () => {
|
||||||
|
const { host, root } = renderLeadModelRow();
|
||||||
|
|
||||||
|
const stripe = host.querySelector('[aria-hidden="true"]');
|
||||||
|
const expectedBorder = getTeamColorSet(resolveTeamLeadColorName()).border;
|
||||||
|
|
||||||
|
expect(host.textContent).toContain('lead');
|
||||||
|
expect(host.textContent).toContain('Team Lead');
|
||||||
|
expect(stripe?.getAttribute('style')).toContain(expectedBorder);
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
root.unmount();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -14,7 +14,7 @@ import { getTeamColorSet } from '@renderer/constants/teamColors';
|
||||||
import { useTheme } from '@renderer/hooks/useTheme';
|
import { useTheme } from '@renderer/hooks/useTheme';
|
||||||
import { cn } from '@renderer/lib/utils';
|
import { cn } from '@renderer/lib/utils';
|
||||||
import { isAnthropicHaikuTeamModel } from '@renderer/utils/teamModelCatalog';
|
import { isAnthropicHaikuTeamModel } from '@renderer/utils/teamModelCatalog';
|
||||||
import { getMemberColorByName } from '@shared/constants/memberColors';
|
import { resolveTeamLeadColorName } from '@shared/utils/teamMemberColors';
|
||||||
import { AlertTriangle, ChevronDown, ChevronRight, Info } from 'lucide-react';
|
import { AlertTriangle, ChevronDown, ChevronRight, Info } from 'lucide-react';
|
||||||
|
|
||||||
import { Button } from '../../ui/button';
|
import { Button } from '../../ui/button';
|
||||||
|
|
@ -54,7 +54,7 @@ export const LeadModelRow = ({
|
||||||
}: LeadModelRowProps): React.JSX.Element => {
|
}: LeadModelRowProps): React.JSX.Element => {
|
||||||
const { isLight } = useTheme();
|
const { isLight } = useTheme();
|
||||||
const [modelExpanded, setModelExpanded] = useState(false);
|
const [modelExpanded, setModelExpanded] = useState(false);
|
||||||
const leadColorSet = getTeamColorSet(getMemberColorByName('lead'));
|
const leadColorSet = getTeamColorSet(resolveTeamLeadColorName());
|
||||||
const modelButtonLabel = model.trim()
|
const modelButtonLabel = model.trim()
|
||||||
? getProviderScopedTeamModelLabel(providerId, model.trim())
|
? getProviderScopedTeamModelLabel(providerId, model.trim())
|
||||||
: 'Default';
|
: 'Default';
|
||||||
|
|
|
||||||
|
|
@ -48,6 +48,26 @@ interface MemberCardProps {
|
||||||
onAssignTask?: () => void;
|
onAssignTask?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function splitRuntimeSummaryMemory(runtimeSummary: string | undefined): {
|
||||||
|
summary: string | undefined;
|
||||||
|
memory: string | undefined;
|
||||||
|
} {
|
||||||
|
const trimmed = runtimeSummary?.trim();
|
||||||
|
if (!trimmed) {
|
||||||
|
return { summary: undefined, memory: undefined };
|
||||||
|
}
|
||||||
|
|
||||||
|
const match = /^(.*?)(?:\s·\s(\d+(?:\.\d+)?\s(?:B|KB|MB|GB|TB)))$/.exec(trimmed);
|
||||||
|
if (!match) {
|
||||||
|
return { summary: trimmed, memory: undefined };
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
summary: match[1]?.trim() || undefined,
|
||||||
|
memory: match[2]?.trim() || undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export const MemberCard = ({
|
export const MemberCard = ({
|
||||||
member,
|
member,
|
||||||
memberColor,
|
memberColor,
|
||||||
|
|
@ -102,6 +122,8 @@ export const MemberCard = ({
|
||||||
const totalTasks = pending + inProgress + completed;
|
const totalTasks = pending + inProgress + completed;
|
||||||
const progressPercent = totalTasks > 0 ? Math.round((completed / totalTasks) * 100) : 0;
|
const progressPercent = totalTasks > 0 ? Math.round((completed / totalTasks) * 100) : 0;
|
||||||
const roleLabel = formatAgentRole(member.role) ?? formatAgentRole(member.agentType);
|
const roleLabel = formatAgentRole(member.role) ?? formatAgentRole(member.agentType);
|
||||||
|
const { summary: runtimeSummaryText, memory: memoryLabel } =
|
||||||
|
splitRuntimeSummaryMemory(runtimeSummary);
|
||||||
const activityTask = currentTask ?? reviewTask ?? null;
|
const activityTask = currentTask ?? reviewTask ?? null;
|
||||||
const activityTitle = currentTask
|
const activityTitle = currentTask
|
||||||
? `Current task: #${deriveTaskDisplayId(currentTask.id)}`
|
? `Current task: #${deriveTaskDisplayId(currentTask.id)}`
|
||||||
|
|
@ -215,13 +237,19 @@ export const MemberCard = ({
|
||||||
style={{ backgroundColor: 'var(--skeleton-base)' }}
|
style={{ backgroundColor: 'var(--skeleton-base)' }}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
) : runtimeSummary || roleLabel ? (
|
) : runtimeSummaryText || roleLabel || memoryLabel ? (
|
||||||
<div className="mt-0.5 flex min-w-0 items-center gap-1.5 text-[10px] font-medium text-[var(--color-text-muted)]">
|
<div className="mt-0.5 flex min-w-0 items-center gap-1.5 text-[10px] font-medium text-[var(--color-text-muted)]">
|
||||||
{runtimeSummary ? <span className="min-w-0 truncate">{runtimeSummary}</span> : null}
|
{runtimeSummaryText ? (
|
||||||
{runtimeSummary && roleLabel ? (
|
<span className="min-w-0 truncate">{runtimeSummaryText}</span>
|
||||||
|
) : null}
|
||||||
|
{runtimeSummaryText && roleLabel ? (
|
||||||
<span className="shrink-0 opacity-60">•</span>
|
<span className="shrink-0 opacity-60">•</span>
|
||||||
) : null}
|
) : null}
|
||||||
{roleLabel ? <span className="shrink-0">{roleLabel}</span> : null}
|
{roleLabel ? <span className="shrink-0">{roleLabel}</span> : null}
|
||||||
|
{(runtimeSummaryText || roleLabel) && memoryLabel ? (
|
||||||
|
<span className="shrink-0 opacity-60">•</span>
|
||||||
|
) : null}
|
||||||
|
{memoryLabel ? <span className="shrink-0">{memoryLabel}</span> : null}
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -128,6 +128,9 @@ export const MemberDetailDialog = ({
|
||||||
: undefined,
|
: undefined,
|
||||||
[launchParams, member, runtimeEntry, spawnEntry]
|
[launchParams, member, runtimeEntry, spawnEntry]
|
||||||
);
|
);
|
||||||
|
const restartInFlight =
|
||||||
|
spawnEntry?.launchState === 'starting' ||
|
||||||
|
spawnEntry?.launchState === 'runtime_pending_bootstrap';
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!open || !member) {
|
if (!open || !member) {
|
||||||
|
|
@ -267,7 +270,7 @@ export const MemberDetailDialog = ({
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
className="gap-1.5"
|
className="gap-1.5"
|
||||||
disabled={restarting}
|
disabled={restarting || restartInFlight}
|
||||||
onClick={async () => {
|
onClick={async () => {
|
||||||
setRestartError(null);
|
setRestartError(null);
|
||||||
setRestarting(true);
|
setRestarting(true);
|
||||||
|
|
|
||||||
|
|
@ -50,10 +50,15 @@ interface MemberDraftRowProps {
|
||||||
taskSuggestions?: MentionSuggestion[];
|
taskSuggestions?: MentionSuggestion[];
|
||||||
teamSuggestions?: MentionSuggestion[];
|
teamSuggestions?: MentionSuggestion[];
|
||||||
lockProviderModel?: boolean;
|
lockProviderModel?: boolean;
|
||||||
|
lockRole?: boolean;
|
||||||
|
lockedRoleLabel?: string;
|
||||||
|
lockIdentity?: boolean;
|
||||||
|
identityLockReason?: string;
|
||||||
forceInheritedModelSettings?: boolean;
|
forceInheritedModelSettings?: boolean;
|
||||||
modelLockReason?: string;
|
modelLockReason?: string;
|
||||||
isRemoved?: boolean;
|
isRemoved?: boolean;
|
||||||
onRestore?: (id: string) => void;
|
onRestore?: (id: string) => void;
|
||||||
|
hideActionButton?: boolean;
|
||||||
warningText?: string | null;
|
warningText?: string | null;
|
||||||
disableGeminiOption?: boolean;
|
disableGeminiOption?: boolean;
|
||||||
modelIssueText?: string | null;
|
modelIssueText?: string | null;
|
||||||
|
|
@ -83,10 +88,15 @@ export const MemberDraftRow = ({
|
||||||
taskSuggestions,
|
taskSuggestions,
|
||||||
teamSuggestions,
|
teamSuggestions,
|
||||||
lockProviderModel = false,
|
lockProviderModel = false,
|
||||||
|
lockRole = false,
|
||||||
|
lockedRoleLabel,
|
||||||
|
lockIdentity = false,
|
||||||
|
identityLockReason,
|
||||||
forceInheritedModelSettings = false,
|
forceInheritedModelSettings = false,
|
||||||
modelLockReason,
|
modelLockReason,
|
||||||
isRemoved = false,
|
isRemoved = false,
|
||||||
onRestore,
|
onRestore,
|
||||||
|
hideActionButton = false,
|
||||||
warningText,
|
warningText,
|
||||||
disableGeminiOption = false,
|
disableGeminiOption = false,
|
||||||
modelIssueText,
|
modelIssueText,
|
||||||
|
|
@ -200,29 +210,28 @@ export const MemberDraftRow = ({
|
||||||
className="h-8 text-xs"
|
className="h-8 text-xs"
|
||||||
value={member.name}
|
value={member.name}
|
||||||
aria-label={`Member ${index + 1} name`}
|
aria-label={`Member ${index + 1} name`}
|
||||||
disabled={isRemoved}
|
disabled={isRemoved || lockIdentity}
|
||||||
onChange={(event) => onNameChange(member.id, event.target.value)}
|
onChange={(event) => onNameChange(member.id, event.target.value)}
|
||||||
placeholder="member-name"
|
placeholder="member-name"
|
||||||
style={
|
|
||||||
member.name.trim()
|
|
||||||
? {
|
|
||||||
color: memberColorSet.text,
|
|
||||||
}
|
|
||||||
: undefined
|
|
||||||
}
|
|
||||||
/>
|
/>
|
||||||
{nameError ? <p className="text-[10px] text-red-300">{nameError}</p> : null}
|
{nameError ? <p className="text-[10px] text-red-300">{nameError}</p> : null}
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<RoleSelect
|
{lockRole ? (
|
||||||
value={member.roleSelection || '__none__'}
|
<div className="flex h-8 items-center rounded-md border border-[var(--color-border)] bg-transparent px-3 text-xs text-[var(--color-text)] opacity-80">
|
||||||
disabled={isRemoved}
|
{lockedRoleLabel || member.customRole || member.roleSelection || 'No role'}
|
||||||
onValueChange={(roleSelection) => onRoleChange(member.id, roleSelection)}
|
</div>
|
||||||
customRole={member.customRole}
|
) : (
|
||||||
onCustomRoleChange={(customRole) => onCustomRoleChange(member.id, customRole)}
|
<RoleSelect
|
||||||
triggerClassName="h-8 text-xs"
|
value={member.roleSelection || '__none__'}
|
||||||
inputClassName="h-8 text-xs"
|
disabled={isRemoved}
|
||||||
/>
|
onValueChange={(roleSelection) => onRoleChange(member.id, roleSelection)}
|
||||||
|
customRole={member.customRole}
|
||||||
|
onCustomRoleChange={(customRole) => onCustomRoleChange(member.id, customRole)}
|
||||||
|
triggerClassName="h-8 text-xs"
|
||||||
|
inputClassName="h-8 text-xs"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<div className="flex flex-col gap-2 sm:flex-row sm:items-start">
|
<div className="flex flex-col gap-2 sm:flex-row sm:items-start">
|
||||||
|
|
@ -289,7 +298,7 @@ export const MemberDraftRow = ({
|
||||||
) : null}
|
) : null}
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
</div>
|
</div>
|
||||||
{isRemoved ? (
|
{hideActionButton ? null : isRemoved ? (
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
|
|
|
||||||
|
|
@ -88,10 +88,15 @@ export interface MembersEditorSectionProps {
|
||||||
hideContent?: boolean;
|
hideContent?: boolean;
|
||||||
/** Existing team members — used to reserve their colors so drafts get the next available ones */
|
/** Existing team members — used to reserve their colors so drafts get the next available ones */
|
||||||
existingMembers?: readonly { name: string; color?: string; removedAt?: number | string | null }[];
|
existingMembers?: readonly { name: string; color?: string; removedAt?: number | string | null }[];
|
||||||
|
/** Pre-resolved member colors from the live Team view. */
|
||||||
|
existingMemberColorMap?: ReadonlyMap<string, string>;
|
||||||
/** Default provider to use for newly added member rows. */
|
/** Default provider to use for newly added member rows. */
|
||||||
defaultProviderId?: TeamProviderId;
|
defaultProviderId?: TeamProviderId;
|
||||||
/** When true, provider/model controls stay read-only for existing rows. */
|
/** When true, provider/model controls stay read-only for existing rows. */
|
||||||
lockProviderModel?: boolean;
|
lockProviderModel?: boolean;
|
||||||
|
/** When true, existing teammate names stay read-only while the team is live. */
|
||||||
|
lockExistingMemberIdentity?: boolean;
|
||||||
|
identityLockReason?: string;
|
||||||
inheritedProviderId?: TeamProviderId;
|
inheritedProviderId?: TeamProviderId;
|
||||||
inheritedModel?: string;
|
inheritedModel?: string;
|
||||||
inheritedEffort?: EffortLevel;
|
inheritedEffort?: EffortLevel;
|
||||||
|
|
@ -102,6 +107,8 @@ export interface MembersEditorSectionProps {
|
||||||
memberWarningById?: Record<string, string | null | undefined>;
|
memberWarningById?: Record<string, string | null | undefined>;
|
||||||
disableGeminiOption?: boolean;
|
disableGeminiOption?: boolean;
|
||||||
memberModelIssueById?: Record<string, string | null | undefined>;
|
memberModelIssueById?: Record<string, string | null | undefined>;
|
||||||
|
disableAddMember?: boolean;
|
||||||
|
addMemberLockReason?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const MembersEditorSection = ({
|
export const MembersEditorSection = ({
|
||||||
|
|
@ -118,8 +125,11 @@ export const MembersEditorSection = ({
|
||||||
headerExtra,
|
headerExtra,
|
||||||
hideContent = false,
|
hideContent = false,
|
||||||
existingMembers,
|
existingMembers,
|
||||||
|
existingMemberColorMap,
|
||||||
defaultProviderId = 'anthropic',
|
defaultProviderId = 'anthropic',
|
||||||
lockProviderModel = false,
|
lockProviderModel = false,
|
||||||
|
lockExistingMemberIdentity = false,
|
||||||
|
identityLockReason,
|
||||||
inheritedProviderId,
|
inheritedProviderId,
|
||||||
inheritedModel,
|
inheritedModel,
|
||||||
inheritedEffort,
|
inheritedEffort,
|
||||||
|
|
@ -130,6 +140,8 @@ export const MembersEditorSection = ({
|
||||||
memberWarningById,
|
memberWarningById,
|
||||||
disableGeminiOption = false,
|
disableGeminiOption = false,
|
||||||
memberModelIssueById,
|
memberModelIssueById,
|
||||||
|
disableAddMember = false,
|
||||||
|
addMemberLockReason,
|
||||||
}: MembersEditorSectionProps): React.JSX.Element => {
|
}: MembersEditorSectionProps): React.JSX.Element => {
|
||||||
const [jsonEditorOpen, setJsonEditorOpen] = useState(false);
|
const [jsonEditorOpen, setJsonEditorOpen] = useState(false);
|
||||||
const [jsonText, setJsonText] = useState('');
|
const [jsonText, setJsonText] = useState('');
|
||||||
|
|
@ -257,8 +269,8 @@ export const MembersEditorSection = ({
|
||||||
const names = activeMembers.map((m) => m.name.trim().toLowerCase()).filter(Boolean);
|
const names = activeMembers.map((m) => m.name.trim().toLowerCase()).filter(Boolean);
|
||||||
const hasDuplicates = new Set(names).size !== names.length;
|
const hasDuplicates = new Set(names).size !== names.length;
|
||||||
const memberColorMap = useMemo(
|
const memberColorMap = useMemo(
|
||||||
() => buildMemberDraftColorMap(members, existingMembers),
|
() => buildMemberDraftColorMap(members, existingMembers, existingMemberColorMap),
|
||||||
[members, existingMembers]
|
[members, existingMembers, existingMemberColorMap]
|
||||||
);
|
);
|
||||||
|
|
||||||
const mentionSuggestions = useMemo(
|
const mentionSuggestions = useMemo(
|
||||||
|
|
@ -272,7 +284,14 @@ export const MembersEditorSection = ({
|
||||||
<Label>Members</Label>
|
<Label>Members</Label>
|
||||||
{!hideContent && (
|
{!hideContent && (
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<Button variant="outline" size="sm" className="gap-1.5" onClick={addMember}>
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="gap-1.5"
|
||||||
|
onClick={addMember}
|
||||||
|
disabled={disableAddMember}
|
||||||
|
title={disableAddMember ? addMemberLockReason : undefined}
|
||||||
|
>
|
||||||
<Plus className="size-3.5" />
|
<Plus className="size-3.5" />
|
||||||
Add member
|
Add member
|
||||||
</Button>
|
</Button>
|
||||||
|
|
@ -287,6 +306,9 @@ export const MembersEditorSection = ({
|
||||||
{headerExtra}
|
{headerExtra}
|
||||||
{!hideContent && (
|
{!hideContent && (
|
||||||
<>
|
<>
|
||||||
|
{disableAddMember && addMemberLockReason ? (
|
||||||
|
<p className="text-[11px] text-[var(--color-text-muted)]">{addMemberLockReason}</p>
|
||||||
|
) : null}
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{activeMembers.map((member, index) => (
|
{activeMembers.map((member, index) => (
|
||||||
<MemberDraftRow
|
<MemberDraftRow
|
||||||
|
|
@ -315,6 +337,8 @@ export const MembersEditorSection = ({
|
||||||
taskSuggestions={taskSuggestions}
|
taskSuggestions={taskSuggestions}
|
||||||
teamSuggestions={teamSuggestions}
|
teamSuggestions={teamSuggestions}
|
||||||
lockProviderModel={lockProviderModel}
|
lockProviderModel={lockProviderModel}
|
||||||
|
lockIdentity={lockExistingMemberIdentity && Boolean(member.originalName?.trim())}
|
||||||
|
identityLockReason={identityLockReason}
|
||||||
modelLockReason={modelLockReason}
|
modelLockReason={modelLockReason}
|
||||||
warningText={memberWarningById?.[member.id] ?? null}
|
warningText={memberWarningById?.[member.id] ?? null}
|
||||||
disableGeminiOption={disableGeminiOption}
|
disableGeminiOption={disableGeminiOption}
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ import type { EffortLevel, TeamProviderId } from '@shared/types';
|
||||||
export interface MemberDraft {
|
export interface MemberDraft {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
|
originalName?: string;
|
||||||
roleSelection: string;
|
roleSelection: string;
|
||||||
customRole: string;
|
customRole: string;
|
||||||
workflow?: string;
|
workflow?: string;
|
||||||
|
|
|
||||||
|
|
@ -1,29 +1,20 @@
|
||||||
import { CUSTOM_ROLE, NO_ROLE, PRESET_ROLES } from '@renderer/constants/teamRoles';
|
import { CUSTOM_ROLE, NO_ROLE, PRESET_ROLES } from '@renderer/constants/teamRoles';
|
||||||
import { serializeChipsWithText } from '@renderer/types/inlineChip';
|
import { serializeChipsWithText } from '@renderer/types/inlineChip';
|
||||||
import { normalizeCreateLaunchProviderForUi } from '@renderer/utils/geminiUiFreeze';
|
import { normalizeCreateLaunchProviderForUi } from '@renderer/utils/geminiUiFreeze';
|
||||||
import { buildMemberColorMap } from '@renderer/utils/memberHelpers';
|
|
||||||
import { getMemberColorByName } from '@shared/constants/memberColors';
|
|
||||||
import { normalizeExplicitTeamModelForUi } from '@renderer/utils/teamModelAvailability';
|
import { normalizeExplicitTeamModelForUi } from '@renderer/utils/teamModelAvailability';
|
||||||
import { isLeadMember } from '@shared/utils/leadDetection';
|
import { isLeadMember } from '@shared/utils/leadDetection';
|
||||||
|
import { buildTeamMemberColorMap } from '@shared/utils/teamMemberColors';
|
||||||
|
import { validateTeamMemberNameFormat } from '@shared/utils/teamMemberName';
|
||||||
import { normalizeOptionalTeamProviderId } from '@shared/utils/teamProvider';
|
import { normalizeOptionalTeamProviderId } from '@shared/utils/teamProvider';
|
||||||
|
|
||||||
import type { MemberDraft } from './membersEditorTypes';
|
import type { MemberDraft } from './membersEditorTypes';
|
||||||
import type { MentionSuggestion } from '@renderer/types/mention';
|
import type { MentionSuggestion } from '@renderer/types/mention';
|
||||||
import type { EffortLevel, TeamProviderId, TeamProvisioningMemberInput } from '@shared/types';
|
import type { EffortLevel, TeamProviderId, TeamProvisioningMemberInput } from '@shared/types';
|
||||||
|
|
||||||
function isValidMemberName(name: string): boolean {
|
|
||||||
if (name.length < 1 || name.length > 128) return false;
|
|
||||||
if (!/^[a-zA-Z0-9]/.test(name)) return false;
|
|
||||||
return /^[a-zA-Z0-9._-]+$/.test(name);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function validateMemberNameInline(name: string): string | null {
|
export function validateMemberNameInline(name: string): string | null {
|
||||||
const trimmed = name.trim();
|
const trimmed = name.trim();
|
||||||
if (!trimmed) return null;
|
if (!trimmed) return null;
|
||||||
if (!isValidMemberName(trimmed)) {
|
return validateTeamMemberNameFormat(trimmed);
|
||||||
return 'Start with alphanumeric, use only [a-zA-Z0-9._-], max 128 chars';
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function newDraftId(): string {
|
function newDraftId(): string {
|
||||||
|
|
@ -36,6 +27,7 @@ export function createMemberDraft(initial?: Partial<MemberDraft>): MemberDraft {
|
||||||
return {
|
return {
|
||||||
id: initial?.id ?? newDraftId(),
|
id: initial?.id ?? newDraftId(),
|
||||||
name: initial?.name ?? '',
|
name: initial?.name ?? '',
|
||||||
|
originalName: initial?.originalName,
|
||||||
roleSelection: initial?.roleSelection ?? '',
|
roleSelection: initial?.roleSelection ?? '',
|
||||||
customRole: initial?.customRole ?? '',
|
customRole: initial?.customRole ?? '',
|
||||||
workflow: initial?.workflow,
|
workflow: initial?.workflow,
|
||||||
|
|
@ -66,6 +58,7 @@ export function createMemberDraftsFromInputs(
|
||||||
const isPreset = presetRoles.includes(role);
|
const isPreset = presetRoles.includes(role);
|
||||||
return createMemberDraft({
|
return createMemberDraft({
|
||||||
name: member.name,
|
name: member.name,
|
||||||
|
originalName: member.name,
|
||||||
roleSelection: role ? (isPreset ? role : CUSTOM_ROLE) : '',
|
roleSelection: role ? (isPreset ? role : CUSTOM_ROLE) : '',
|
||||||
customRole: role && !isPreset ? role : '',
|
customRole: role && !isPreset ? role : '',
|
||||||
workflow: member.workflow,
|
workflow: member.workflow,
|
||||||
|
|
@ -141,18 +134,29 @@ interface ExistingMemberColorInput {
|
||||||
|
|
||||||
export function buildMemberDraftColorMap(
|
export function buildMemberDraftColorMap(
|
||||||
members: readonly Pick<MemberDraft, 'name'>[],
|
members: readonly Pick<MemberDraft, 'name'>[],
|
||||||
existingMembers?: readonly ExistingMemberColorInput[]
|
existingMembers?: readonly ExistingMemberColorInput[],
|
||||||
|
existingColorMap?: ReadonlyMap<string, string>
|
||||||
): Map<string, string> {
|
): Map<string, string> {
|
||||||
const draftEntries = members
|
const draftEntries = members
|
||||||
.map((member) => member.name.trim())
|
.map((member) => member.name.trim())
|
||||||
.filter(Boolean)
|
.filter(Boolean)
|
||||||
.map((name) => ({ name }));
|
.map((name) => ({ name }));
|
||||||
|
|
||||||
|
const normalizedExistingColorMap = new Map<string, string>(
|
||||||
|
Array.from(existingColorMap?.entries() ?? []).map(([name, color]) => [
|
||||||
|
name.trim().toLowerCase(),
|
||||||
|
color,
|
||||||
|
])
|
||||||
|
);
|
||||||
|
|
||||||
const existingSeedEntries = (existingMembers ?? [])
|
const existingSeedEntries = (existingMembers ?? [])
|
||||||
.map((member) => ({
|
.map((member) => ({
|
||||||
...member,
|
...member,
|
||||||
name: member.name.trim(),
|
name: member.name.trim(),
|
||||||
color: member.color?.trim() || getMemberColorByName(member.name),
|
color:
|
||||||
|
normalizedExistingColorMap.get(member.name.trim().toLowerCase()) ??
|
||||||
|
member.color?.trim() ??
|
||||||
|
undefined,
|
||||||
}))
|
}))
|
||||||
.filter((member) => member.name);
|
.filter((member) => member.name);
|
||||||
const existingNames = new Set(existingSeedEntries.map((member) => member.name.toLowerCase()));
|
const existingNames = new Set(existingSeedEntries.map((member) => member.name.toLowerCase()));
|
||||||
|
|
@ -166,17 +170,9 @@ export function buildMemberDraftColorMap(
|
||||||
return true;
|
return true;
|
||||||
});
|
});
|
||||||
|
|
||||||
const predictedDraftSeedEntries = uniqueNewDraftEntries.map((entry) => ({
|
const fullMap = buildTeamMemberColorMap([...existingSeedEntries, ...uniqueNewDraftEntries], {
|
||||||
...entry,
|
preferProvidedColors: true,
|
||||||
color: getMemberColorByName(entry.name),
|
});
|
||||||
}));
|
|
||||||
|
|
||||||
// Mirror the team page color inputs:
|
|
||||||
// 1. existing members keep their persisted/resolved color
|
|
||||||
// 2. new draft members get the same name-based default color that the resolver
|
|
||||||
// will assign after create/launch/add
|
|
||||||
// 3. buildMemberColorMap still resolves rare collisions the same way as the UI
|
|
||||||
const fullMap = buildMemberColorMap([...existingSeedEntries, ...predictedDraftSeedEntries]);
|
|
||||||
const fullColorByName = new Map(
|
const fullColorByName = new Map(
|
||||||
Array.from(fullMap.entries()).map(([name, color]) => [name.toLowerCase(), color] as const)
|
Array.from(fullMap.entries()).map(([name, color]) => [name.toLowerCase(), color] as const)
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -30,6 +30,83 @@ const TEAMMATE_COLORS: Record<string, TeamColorSet> = {
|
||||||
text: '#60a5fa',
|
text: '#60a5fa',
|
||||||
textLight: '#2563eb',
|
textLight: '#2563eb',
|
||||||
},
|
},
|
||||||
|
saffron: {
|
||||||
|
border: '#eab308',
|
||||||
|
badge: 'rgba(234, 179, 8, 0.15)',
|
||||||
|
badgeLight: 'rgba(234, 179, 8, 0.12)',
|
||||||
|
text: '#fde047',
|
||||||
|
textLight: '#a16207',
|
||||||
|
},
|
||||||
|
turquoise: {
|
||||||
|
border: '#14b8a6',
|
||||||
|
badge: 'rgba(20, 184, 166, 0.15)',
|
||||||
|
badgeLight: 'rgba(20, 184, 166, 0.12)',
|
||||||
|
text: '#5eead4',
|
||||||
|
textLight: '#0f766e',
|
||||||
|
},
|
||||||
|
brick: {
|
||||||
|
border: '#ef4444',
|
||||||
|
badge: 'rgba(239, 68, 68, 0.15)',
|
||||||
|
badgeLight: 'rgba(239, 68, 68, 0.12)',
|
||||||
|
text: '#f87171',
|
||||||
|
textLight: '#b91c1c',
|
||||||
|
},
|
||||||
|
indigo: {
|
||||||
|
border: '#8b5cf6',
|
||||||
|
badge: 'rgba(139, 92, 246, 0.15)',
|
||||||
|
badgeLight: 'rgba(139, 92, 246, 0.12)',
|
||||||
|
text: '#c4b5fd',
|
||||||
|
textLight: '#6d28d9',
|
||||||
|
},
|
||||||
|
forest: {
|
||||||
|
border: '#22c55e',
|
||||||
|
badge: 'rgba(34, 197, 94, 0.15)',
|
||||||
|
badgeLight: 'rgba(34, 197, 94, 0.12)',
|
||||||
|
text: '#86efac',
|
||||||
|
textLight: '#15803d',
|
||||||
|
},
|
||||||
|
apricot: {
|
||||||
|
border: '#fb923c',
|
||||||
|
badge: 'rgba(251, 146, 60, 0.15)',
|
||||||
|
badgeLight: 'rgba(251, 146, 60, 0.12)',
|
||||||
|
text: '#fdba74',
|
||||||
|
textLight: '#c2410c',
|
||||||
|
},
|
||||||
|
rose: {
|
||||||
|
border: '#f43f5e',
|
||||||
|
badge: 'rgba(244, 63, 94, 0.15)',
|
||||||
|
badgeLight: 'rgba(244, 63, 94, 0.12)',
|
||||||
|
text: '#fda4af',
|
||||||
|
textLight: '#be123c',
|
||||||
|
},
|
||||||
|
cerulean: {
|
||||||
|
border: '#38bdf8',
|
||||||
|
badge: 'rgba(56, 189, 248, 0.15)',
|
||||||
|
badgeLight: 'rgba(56, 189, 248, 0.12)',
|
||||||
|
text: '#7dd3fc',
|
||||||
|
textLight: '#0369a1',
|
||||||
|
},
|
||||||
|
olive: {
|
||||||
|
border: '#84cc16',
|
||||||
|
badge: 'rgba(132, 204, 22, 0.15)',
|
||||||
|
badgeLight: 'rgba(132, 204, 22, 0.12)',
|
||||||
|
text: '#bef264',
|
||||||
|
textLight: '#4d7c0f',
|
||||||
|
},
|
||||||
|
copper: {
|
||||||
|
border: '#b45309',
|
||||||
|
badge: 'rgba(180, 83, 9, 0.15)',
|
||||||
|
badgeLight: 'rgba(180, 83, 9, 0.12)',
|
||||||
|
text: '#fdba74',
|
||||||
|
textLight: '#92400e',
|
||||||
|
},
|
||||||
|
steel: {
|
||||||
|
border: '#64748b',
|
||||||
|
badge: 'rgba(100, 116, 139, 0.15)',
|
||||||
|
badgeLight: 'rgba(100, 116, 139, 0.12)',
|
||||||
|
text: '#cbd5e1',
|
||||||
|
textLight: '#475569',
|
||||||
|
},
|
||||||
green: {
|
green: {
|
||||||
border: '#22c55e',
|
border: '#22c55e',
|
||||||
badge: 'rgba(34, 197, 94, 0.15)',
|
badge: 'rgba(34, 197, 94, 0.15)',
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,4 @@
|
||||||
import {
|
import { buildTeamMemberColorMap } from '@shared/utils/teamMemberColors';
|
||||||
getMemberColorByName,
|
|
||||||
MEMBER_COLOR_PALETTE,
|
|
||||||
normalizeMemberColorName,
|
|
||||||
} from '@shared/constants/memberColors';
|
|
||||||
import { isLeadMember } from '@shared/utils/leadDetection';
|
import { isLeadMember } from '@shared/utils/leadDetection';
|
||||||
|
|
||||||
import type {
|
import type {
|
||||||
|
|
@ -579,42 +575,7 @@ interface MemberColorInput {
|
||||||
* Maps "user" to a reserved color.
|
* Maps "user" to a reserved color.
|
||||||
*/
|
*/
|
||||||
export function buildMemberColorMap(members: MemberColorInput[]): Map<string, string> {
|
export function buildMemberColorMap(members: MemberColorInput[]): Map<string, string> {
|
||||||
const map = new Map<string, string>();
|
return buildTeamMemberColorMap(members, { preferProvidedColors: true });
|
||||||
const active = members.filter((m) => !m.removedAt);
|
|
||||||
const removed = members.filter((m) => m.removedAt);
|
|
||||||
const usedColors = new Set<string>();
|
|
||||||
let nextPaletteIdx = 0;
|
|
||||||
|
|
||||||
for (const member of active) {
|
|
||||||
let color = member.color ? normalizeMemberColorName(member.color) : undefined;
|
|
||||||
if (!color || usedColors.has(color)) {
|
|
||||||
// Assign the next unused color from the pre-ordered palette.
|
|
||||||
while (
|
|
||||||
nextPaletteIdx < MEMBER_COLOR_PALETTE.length &&
|
|
||||||
usedColors.has(MEMBER_COLOR_PALETTE[nextPaletteIdx])
|
|
||||||
) {
|
|
||||||
nextPaletteIdx++;
|
|
||||||
}
|
|
||||||
color =
|
|
||||||
nextPaletteIdx < MEMBER_COLOR_PALETTE.length
|
|
||||||
? MEMBER_COLOR_PALETTE[nextPaletteIdx]
|
|
||||||
: MEMBER_COLOR_PALETTE[active.indexOf(member) % MEMBER_COLOR_PALETTE.length];
|
|
||||||
nextPaletteIdx++;
|
|
||||||
}
|
|
||||||
map.set(member.name, color);
|
|
||||||
usedColors.add(color);
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const member of removed) {
|
|
||||||
const color = member.color
|
|
||||||
? normalizeMemberColorName(member.color)
|
|
||||||
: getMemberColorByName(member.name);
|
|
||||||
map.set(member.name, color);
|
|
||||||
}
|
|
||||||
|
|
||||||
map.set('user', 'user');
|
|
||||||
|
|
||||||
return map;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const KANBAN_COLUMN_DISPLAY: Record<
|
export const KANBAN_COLUMN_DISPLAY: Record<
|
||||||
|
|
|
||||||
|
|
@ -6,36 +6,40 @@
|
||||||
* Intentionally excludes purple-family tones.
|
* Intentionally excludes purple-family tones.
|
||||||
*/
|
*/
|
||||||
export const MEMBER_COLOR_PALETTE = [
|
export const MEMBER_COLOR_PALETTE = [
|
||||||
// ── First 10: maximum contrast (>40° hue gap between any pair) ──
|
// ── First 12: intentionally distinct visual families for roster readability ──
|
||||||
'blue', // 0°
|
'blue',
|
||||||
'saffron', // 177°
|
'saffron',
|
||||||
'turquoise', // 268°
|
'turquoise',
|
||||||
'brick', // 85°
|
'brick',
|
||||||
'apricot', // 131°
|
'indigo',
|
||||||
'indigo', // 314°
|
'forest',
|
||||||
'forest', // 223°
|
'apricot',
|
||||||
'pink', // 39°
|
'rose',
|
||||||
'crimson', // 59°
|
'cerulean',
|
||||||
'tangerine', // 105°
|
'olive',
|
||||||
|
'copper',
|
||||||
|
'steel',
|
||||||
|
|
||||||
// ── Next 14: still good separation ──
|
// ── Next 12: secondary accents after the core distinct set ──
|
||||||
'gold', // 151°
|
'gold',
|
||||||
'emerald', // 203°
|
'emerald',
|
||||||
'cerulean', // 288°
|
'cobalt',
|
||||||
'denim', // 334°
|
'crimson',
|
||||||
'cyan', // 20°
|
'tangerine',
|
||||||
'sage', // 242°
|
'denim',
|
||||||
'tomato', // 72°
|
'cyan',
|
||||||
'rust', // 118°
|
'sage',
|
||||||
'mustard', // 164°
|
'tomato',
|
||||||
'canary', // 190°
|
'rust',
|
||||||
'teal', // 255°
|
'mustard',
|
||||||
'arctic', // 301°
|
'canary',
|
||||||
'royal', // 347°
|
'teal',
|
||||||
'green', // 7°
|
'arctic',
|
||||||
|
'royal',
|
||||||
|
|
||||||
// ── Remaining: fill the hue gaps progressively ──
|
// ── Remaining: fill the hue gaps progressively ──
|
||||||
'rose', // 46°
|
'green',
|
||||||
|
'pink',
|
||||||
'ruby', // 92°
|
'ruby', // 92°
|
||||||
'sienna', // 144°
|
'sienna', // 144°
|
||||||
'mint', // 216°
|
'mint', // 216°
|
||||||
|
|
@ -49,90 +53,45 @@ export const MEMBER_COLOR_PALETTE = [
|
||||||
'salmon', // 79°
|
'salmon', // 79°
|
||||||
'amber', // 98°
|
'amber', // 98°
|
||||||
'peach', // 111°
|
'peach', // 111°
|
||||||
'copper', // 124°
|
|
||||||
'bronze', // 137°
|
'bronze', // 137°
|
||||||
'lemon', // 157°
|
'lemon', // 157°
|
||||||
'honey', // 170°
|
'honey', // 170°
|
||||||
'marigold', // 183°
|
'marigold', // 183°
|
||||||
'sunflower', // 196°
|
'sunflower', // 196°
|
||||||
'lime', // 209°
|
'lime', // 209°
|
||||||
'olive', // 229°
|
|
||||||
'jade', // 236°
|
'jade', // 236°
|
||||||
'chartreuse', // 249°
|
'chartreuse', // 249°
|
||||||
'aqua', // 262°
|
'aqua', // 262°
|
||||||
'azure', // 281°
|
'azure', // 281°
|
||||||
'seafoam', // 295°
|
'seafoam', // 295°
|
||||||
'cobalt', // 308°
|
|
||||||
'periwinkle', // 327°
|
'periwinkle', // 327°
|
||||||
'steel', // 340°
|
|
||||||
'cornflower', // 353°
|
'cornflower', // 353°
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
export type MemberColorName = (typeof MEMBER_COLOR_PALETTE)[number];
|
export type MemberColorName = (typeof MEMBER_COLOR_PALETTE)[number];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Fixed hue angle (0-359) for each palette color name.
|
* Canonical runtime/member id for the team lead.
|
||||||
* This is independent of array order — colors keep their visual identity
|
* UI surfaces should use this exact key when deriving the lead color so
|
||||||
* regardless of how MEMBER_COLOR_PALETTE is sorted.
|
* previews match the resolved team roster.
|
||||||
* Spread evenly across 360° so every name has a unique hue.
|
|
||||||
*/
|
*/
|
||||||
export const MEMBER_COLOR_HUE: Record<string, number> = {
|
export const TEAM_LEAD_MEMBER_COLOR_ID = 'team-lead' as const;
|
||||||
blue: 0,
|
|
||||||
green: 7,
|
/**
|
||||||
yellow: 13,
|
* Fixed hue angle (0-359) for each palette color name.
|
||||||
cyan: 20,
|
* The first roster-assigned colors are intentionally spaced far apart so the
|
||||||
red: 26,
|
* first 10-12 teammates in a team remain visually distinct.
|
||||||
orange: 33,
|
*/
|
||||||
pink: 39,
|
const MEMBER_COLOR_HUES_BY_ORDER = [
|
||||||
rose: 46,
|
240, 60, 180, 0, 120, 300, 30, 210, 330, 90, 150, 270, 15, 195, 105, 285, 45, 225, 135, 315, 75,
|
||||||
coral: 52,
|
255, 165, 345, 7.5, 187.5, 97.5, 277.5, 37.5, 217.5, 127.5, 307.5, 67.5, 247.5, 157.5, 337.5,
|
||||||
crimson: 59,
|
22.5, 202.5, 112.5, 292.5, 52.5, 232.5, 142.5, 322.5, 82.5, 262.5, 172.5, 352.5, 11.25, 191.25,
|
||||||
scarlet: 65,
|
101.25, 281.25, 41.25, 221.25, 131.25,
|
||||||
tomato: 72,
|
] as const;
|
||||||
salmon: 79,
|
|
||||||
brick: 85,
|
export const MEMBER_COLOR_HUE: Record<string, number> = Object.fromEntries(
|
||||||
ruby: 92,
|
MEMBER_COLOR_PALETTE.map((colorName, index) => [colorName, MEMBER_COLOR_HUES_BY_ORDER[index]])
|
||||||
amber: 98,
|
) as Record<string, number>;
|
||||||
tangerine: 105,
|
|
||||||
peach: 111,
|
|
||||||
rust: 118,
|
|
||||||
copper: 124,
|
|
||||||
apricot: 131,
|
|
||||||
bronze: 137,
|
|
||||||
sienna: 144,
|
|
||||||
gold: 151,
|
|
||||||
lemon: 157,
|
|
||||||
mustard: 164,
|
|
||||||
honey: 170,
|
|
||||||
saffron: 177,
|
|
||||||
marigold: 183,
|
|
||||||
canary: 190,
|
|
||||||
sunflower: 196,
|
|
||||||
emerald: 203,
|
|
||||||
lime: 209,
|
|
||||||
mint: 216,
|
|
||||||
forest: 223,
|
|
||||||
olive: 229,
|
|
||||||
jade: 236,
|
|
||||||
sage: 242,
|
|
||||||
chartreuse: 249,
|
|
||||||
teal: 255,
|
|
||||||
aqua: 262,
|
|
||||||
turquoise: 268,
|
|
||||||
sky: 275,
|
|
||||||
azure: 281,
|
|
||||||
cerulean: 288,
|
|
||||||
seafoam: 295,
|
|
||||||
arctic: 301,
|
|
||||||
cobalt: 308,
|
|
||||||
indigo: 314,
|
|
||||||
sapphire: 321,
|
|
||||||
periwinkle: 327,
|
|
||||||
denim: 334,
|
|
||||||
steel: 340,
|
|
||||||
royal: 347,
|
|
||||||
cornflower: 353,
|
|
||||||
};
|
|
||||||
|
|
||||||
const DISALLOWED_MEMBER_COLORS = new Set([
|
const DISALLOWED_MEMBER_COLORS = new Set([
|
||||||
'purple',
|
'purple',
|
||||||
|
|
|
||||||
107
src/shared/utils/teamMemberColors.ts
Normal file
107
src/shared/utils/teamMemberColors.ts
Normal file
|
|
@ -0,0 +1,107 @@
|
||||||
|
import {
|
||||||
|
getMemberColorByName,
|
||||||
|
MEMBER_COLOR_PALETTE,
|
||||||
|
TEAM_LEAD_MEMBER_COLOR_ID,
|
||||||
|
normalizeMemberColorName,
|
||||||
|
} from '@shared/constants/memberColors';
|
||||||
|
import { isLeadMember } from '@shared/utils/leadDetection';
|
||||||
|
|
||||||
|
export interface TeamMemberColorInput {
|
||||||
|
name: string;
|
||||||
|
color?: string;
|
||||||
|
removedAt?: number | string | null;
|
||||||
|
agentType?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface BuildTeamMemberColorMapOptions {
|
||||||
|
preferProvidedColors?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build a deterministic roster color map that optimizes contrast inside a team.
|
||||||
|
* Leads reserve their own color but do not consume the teammate palette order.
|
||||||
|
*/
|
||||||
|
export function buildTeamMemberColorMap(
|
||||||
|
members: readonly TeamMemberColorInput[],
|
||||||
|
options: BuildTeamMemberColorMapOptions = {}
|
||||||
|
): Map<string, string> {
|
||||||
|
const preferProvidedColors = options.preferProvidedColors ?? true;
|
||||||
|
const map = new Map<string, string>();
|
||||||
|
const active = members.filter((member) => !member.removedAt);
|
||||||
|
const removed = members.filter((member) => member.removedAt);
|
||||||
|
const activeLeads = active.filter((member) => isLeadMember(member));
|
||||||
|
const activeTeammates = active.filter((member) => !isLeadMember(member));
|
||||||
|
const usedColors = new Set<string>();
|
||||||
|
let nextPaletteIdx = 0;
|
||||||
|
|
||||||
|
for (const member of activeLeads) {
|
||||||
|
const color =
|
||||||
|
preferProvidedColors && member.color
|
||||||
|
? normalizeMemberColorName(member.color)
|
||||||
|
: getMemberColorByName(member.name);
|
||||||
|
map.set(member.name, color);
|
||||||
|
usedColors.add(color);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const member of activeTeammates) {
|
||||||
|
let color =
|
||||||
|
preferProvidedColors && member.color ? normalizeMemberColorName(member.color) : undefined;
|
||||||
|
if (!color || usedColors.has(color)) {
|
||||||
|
while (
|
||||||
|
nextPaletteIdx < MEMBER_COLOR_PALETTE.length &&
|
||||||
|
usedColors.has(MEMBER_COLOR_PALETTE[nextPaletteIdx])
|
||||||
|
) {
|
||||||
|
nextPaletteIdx += 1;
|
||||||
|
}
|
||||||
|
color =
|
||||||
|
nextPaletteIdx < MEMBER_COLOR_PALETTE.length
|
||||||
|
? MEMBER_COLOR_PALETTE[nextPaletteIdx]
|
||||||
|
: MEMBER_COLOR_PALETTE[activeTeammates.indexOf(member) % MEMBER_COLOR_PALETTE.length];
|
||||||
|
nextPaletteIdx += 1;
|
||||||
|
}
|
||||||
|
map.set(member.name, color);
|
||||||
|
usedColors.add(color);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const member of removed) {
|
||||||
|
const color =
|
||||||
|
preferProvidedColors && member.color
|
||||||
|
? normalizeMemberColorName(member.color)
|
||||||
|
: getMemberColorByName(member.name);
|
||||||
|
map.set(member.name, color);
|
||||||
|
}
|
||||||
|
|
||||||
|
map.set('user', 'user');
|
||||||
|
|
||||||
|
return map;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve the visual color for a standalone member preview by reusing the same
|
||||||
|
* roster color pipeline that powers the team screen.
|
||||||
|
*/
|
||||||
|
export function resolveTeamMemberColorName(
|
||||||
|
member: TeamMemberColorInput,
|
||||||
|
options: BuildTeamMemberColorMapOptions = {}
|
||||||
|
): string {
|
||||||
|
const color = buildTeamMemberColorMap([member], options).get(member.name);
|
||||||
|
if (color) {
|
||||||
|
return color;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (options.preferProvidedColors !== false && member.color) {
|
||||||
|
return normalizeMemberColorName(member.color);
|
||||||
|
}
|
||||||
|
|
||||||
|
return getMemberColorByName(member.name);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveTeamLeadColorName(): string {
|
||||||
|
return resolveTeamMemberColorName(
|
||||||
|
{
|
||||||
|
name: TEAM_LEAD_MEMBER_COLOR_ID,
|
||||||
|
agentType: 'team-lead',
|
||||||
|
},
|
||||||
|
{ preferProvidedColors: false }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -8,6 +8,21 @@ export function parseNumericSuffixName(name: string): { base: string; suffix: nu
|
||||||
return { base: match[1], suffix };
|
return { base: match[1], suffix };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function validateTeamMemberNameFormat(name: string): string | null {
|
||||||
|
const trimmed = name.trim();
|
||||||
|
if (!trimmed) return null;
|
||||||
|
if (trimmed.length < 1 || trimmed.length > 128) {
|
||||||
|
return 'Start with alphanumeric, use only [a-zA-Z0-9._-], max 128 chars';
|
||||||
|
}
|
||||||
|
if (!/^[a-zA-Z0-9]/.test(trimmed)) {
|
||||||
|
return 'Start with alphanumeric, use only [a-zA-Z0-9._-], max 128 chars';
|
||||||
|
}
|
||||||
|
if (!/^[a-zA-Z0-9._-]+$/.test(trimmed)) {
|
||||||
|
return 'Start with alphanumeric, use only [a-zA-Z0-9._-], max 128 chars';
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Claude CLI auto-suffixes teammate names when a name already exists in config.json
|
* Claude CLI auto-suffixes teammate names when a name already exists in config.json
|
||||||
* (e.g. "alice" → "alice-2"). We treat "-2+" as an auto-suffix only when the base
|
* (e.g. "alice" → "alice-2"). We treat "-2+" as an auto-suffix only when the base
|
||||||
|
|
|
||||||
|
|
@ -172,6 +172,7 @@ describe('ipc teams handlers', () => {
|
||||||
deleteTeam: vi.fn(async () => undefined),
|
deleteTeam: vi.fn(async () => undefined),
|
||||||
getLeadMemberName: vi.fn(async () => 'team-lead'),
|
getLeadMemberName: vi.fn(async () => 'team-lead'),
|
||||||
getTeamDisplayName: vi.fn(async () => 'My Team'),
|
getTeamDisplayName: vi.fn(async () => 'My Team'),
|
||||||
|
updateConfig: vi.fn(async () => ({ name: 'My Team' })),
|
||||||
sendMessage: vi.fn(async () => ({ deliveredToInbox: true, messageId: 'm1' })),
|
sendMessage: vi.fn(async () => ({ deliveredToInbox: true, messageId: 'm1' })),
|
||||||
sendDirectToLead: vi.fn(async () => ({ deliveredToInbox: false, messageId: 'direct-1' })),
|
sendDirectToLead: vi.fn(async () => ({ deliveredToInbox: false, messageId: 'direct-1' })),
|
||||||
createTask: vi.fn(async () => ({ id: '1', subject: 'Test', status: 'pending' })),
|
createTask: vi.fn(async () => ({ id: '1', subject: 'Test', status: 'pending' })),
|
||||||
|
|
@ -1732,6 +1733,47 @@ describe('ipc teams handlers', () => {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('updateConfig', () => {
|
||||||
|
it('notifies a live lead only when the team name actually changes', async () => {
|
||||||
|
const handler = handlers.get(TEAM_UPDATE_CONFIG)!;
|
||||||
|
service.getTeamDisplayName.mockResolvedValueOnce('My Team');
|
||||||
|
provisioningService.isTeamAlive = vi.fn(() => true);
|
||||||
|
|
||||||
|
const result = (await handler({} as never, 'my-team', {
|
||||||
|
name: 'Renamed Team',
|
||||||
|
})) as { success: boolean };
|
||||||
|
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
expect(service.updateConfig).toHaveBeenCalledWith('my-team', {
|
||||||
|
name: 'Renamed Team',
|
||||||
|
description: undefined,
|
||||||
|
color: undefined,
|
||||||
|
});
|
||||||
|
expect(provisioningService.sendMessageToTeam).toHaveBeenCalledWith(
|
||||||
|
'my-team',
|
||||||
|
'The team has been renamed to "Renamed Team". Please use this name when referring to the team going forward.'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not notify the lead when the submitted team name is unchanged', async () => {
|
||||||
|
const handler = handlers.get(TEAM_UPDATE_CONFIG)!;
|
||||||
|
service.getTeamDisplayName.mockResolvedValueOnce('My Team');
|
||||||
|
provisioningService.isTeamAlive = vi.fn(() => true);
|
||||||
|
|
||||||
|
const result = (await handler({} as never, 'my-team', {
|
||||||
|
name: 'My Team',
|
||||||
|
})) as { success: boolean };
|
||||||
|
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
expect(service.updateConfig).toHaveBeenCalledWith('my-team', {
|
||||||
|
name: 'My Team',
|
||||||
|
description: undefined,
|
||||||
|
color: undefined,
|
||||||
|
});
|
||||||
|
expect(provisioningService.sendMessageToTeam).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('removeMember', () => {
|
describe('removeMember', () => {
|
||||||
it('calls service on valid input', async () => {
|
it('calls service on valid input', async () => {
|
||||||
const handler = handlers.get(TEAM_REMOVE_MEMBER)!;
|
const handler = handlers.get(TEAM_REMOVE_MEMBER)!;
|
||||||
|
|
|
||||||
|
|
@ -461,6 +461,73 @@ function buildResolvedMember(name: string): ResolvedTeamMember {
|
||||||
}
|
}
|
||||||
|
|
||||||
describe('TeamDataService', () => {
|
describe('TeamDataService', () => {
|
||||||
|
it('rejects duplicate member names in replaceMembers', async () => {
|
||||||
|
const writeMembers = vi.fn(async () => {});
|
||||||
|
const membersMetaStore = {
|
||||||
|
getMembers: vi.fn(async () => []),
|
||||||
|
writeMembers,
|
||||||
|
} as never;
|
||||||
|
|
||||||
|
const service = new TeamDataService(
|
||||||
|
{ getConfig: vi.fn(), listTeams: vi.fn() } as never,
|
||||||
|
{ getTasks: vi.fn(async () => []) } as never,
|
||||||
|
{ listInboxNames: vi.fn(async () => []), getMessages: vi.fn(async () => []) } as never,
|
||||||
|
{} as never,
|
||||||
|
{} as never,
|
||||||
|
{ resolveMembers: vi.fn(() => []) } as never,
|
||||||
|
{ getState: vi.fn(async () => ({ teamName: 'dup-team', reviewers: [], tasks: {} })) } as never,
|
||||||
|
{} as never,
|
||||||
|
membersMetaStore,
|
||||||
|
{ readMessages: vi.fn(async () => []) } as never
|
||||||
|
);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
service.replaceMembers('dup-team', {
|
||||||
|
members: [
|
||||||
|
{ name: 'alice', role: 'Reviewer' },
|
||||||
|
{ name: 'alice', role: 'Developer' },
|
||||||
|
],
|
||||||
|
})
|
||||||
|
).rejects.toThrow('Member "alice" already exists');
|
||||||
|
|
||||||
|
expect(writeMembers).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects invalid or reserved member names in replaceMembers', async () => {
|
||||||
|
const writeMembers = vi.fn(async () => {});
|
||||||
|
const membersMetaStore = {
|
||||||
|
getMembers: vi.fn(async () => []),
|
||||||
|
writeMembers,
|
||||||
|
} as never;
|
||||||
|
|
||||||
|
const service = new TeamDataService(
|
||||||
|
{ getConfig: vi.fn(), listTeams: vi.fn() } as never,
|
||||||
|
{ getTasks: vi.fn(async () => []) } as never,
|
||||||
|
{ listInboxNames: vi.fn(async () => []), getMessages: vi.fn(async () => []) } as never,
|
||||||
|
{} as never,
|
||||||
|
{} as never,
|
||||||
|
{ resolveMembers: vi.fn(() => []) } as never,
|
||||||
|
{ getState: vi.fn(async () => ({ teamName: 'dup-team', reviewers: [], tasks: {} })) } as never,
|
||||||
|
{} as never,
|
||||||
|
membersMetaStore,
|
||||||
|
{ readMessages: vi.fn(async () => []) } as never
|
||||||
|
);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
service.replaceMembers('dup-team', {
|
||||||
|
members: [{ name: 'bad/name', role: 'Reviewer' }],
|
||||||
|
})
|
||||||
|
).rejects.toThrow('Member name "bad/name" is invalid');
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
service.replaceMembers('dup-team', {
|
||||||
|
members: [{ name: 'user', role: 'Reviewer' }],
|
||||||
|
})
|
||||||
|
).rejects.toThrow('Member name "user" is reserved');
|
||||||
|
|
||||||
|
expect(writeMembers).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
it('keeps getTeamData read-only and skips kanban garbage-collect', async () => {
|
it('keeps getTeamData read-only and skips kanban garbage-collect', async () => {
|
||||||
const order: string[] = [];
|
const order: string[] = [];
|
||||||
const tasks: TeamTask[] = [
|
const tasks: TeamTask[] = [
|
||||||
|
|
|
||||||
|
|
@ -69,6 +69,7 @@ import {
|
||||||
getAutoResumeService,
|
getAutoResumeService,
|
||||||
initializeAutoResumeService,
|
initializeAutoResumeService,
|
||||||
} from '@main/services/team/AutoResumeService';
|
} from '@main/services/team/AutoResumeService';
|
||||||
|
import { getTeamBootstrapStatePath } from '@main/services/team/TeamBootstrapStateReader';
|
||||||
import { createPersistedLaunchSnapshot } from '@main/services/team/TeamLaunchStateEvaluator';
|
import { createPersistedLaunchSnapshot } from '@main/services/team/TeamLaunchStateEvaluator';
|
||||||
import { getTeamLaunchStatePath } from '@main/services/team/TeamLaunchStateStore';
|
import { getTeamLaunchStatePath } from '@main/services/team/TeamLaunchStateStore';
|
||||||
import { ClaudeBinaryResolver } from '@main/services/team/ClaudeBinaryResolver';
|
import { ClaudeBinaryResolver } from '@main/services/team/ClaudeBinaryResolver';
|
||||||
|
|
@ -176,6 +177,28 @@ function writeLaunchState(
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function writeBootstrapState(
|
||||||
|
teamName: string,
|
||||||
|
members: { name: string; status: string; lastAttemptAt?: number; lastObservedAt?: number }[],
|
||||||
|
updatedAt = new Date().toISOString()
|
||||||
|
): void {
|
||||||
|
fs.writeFileSync(
|
||||||
|
getTeamBootstrapStatePath(teamName),
|
||||||
|
`${JSON.stringify(
|
||||||
|
{
|
||||||
|
version: 1,
|
||||||
|
teamName,
|
||||||
|
updatedAt,
|
||||||
|
phase: 'completed',
|
||||||
|
members,
|
||||||
|
},
|
||||||
|
null,
|
||||||
|
2
|
||||||
|
)}\n`,
|
||||||
|
'utf8'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function createMemberSpawnStatusEntry(
|
function createMemberSpawnStatusEntry(
|
||||||
overrides: Record<string, unknown> = {}
|
overrides: Record<string, unknown> = {}
|
||||||
): Record<string, unknown> {
|
): Record<string, unknown> {
|
||||||
|
|
@ -226,6 +249,7 @@ function createMemberSpawnRun(params?: {
|
||||||
expectedMembers,
|
expectedMembers,
|
||||||
memberSpawnStatuses,
|
memberSpawnStatuses,
|
||||||
memberSpawnToolUseIds: new Map(),
|
memberSpawnToolUseIds: new Map(),
|
||||||
|
pendingMemberRestarts: new Map(),
|
||||||
memberSpawnLeadInboxCursorByMember:
|
memberSpawnLeadInboxCursorByMember:
|
||||||
params?.memberSpawnLeadInboxCursorByMember ?? new Map(),
|
params?.memberSpawnLeadInboxCursorByMember ?? new Map(),
|
||||||
provisioningOutputParts: [],
|
provisioningOutputParts: [],
|
||||||
|
|
@ -484,6 +508,403 @@ describe('TeamProvisioningService', () => {
|
||||||
expect(snapshot.members['team-lead']?.rssBytes).toBe(123_000_000);
|
expect(snapshot.members['team-lead']?.rssBytes).toBe(123_000_000);
|
||||||
expect(snapshot.members.alice?.rssBytes).toBe(456_000_000);
|
expect(snapshot.members.alice?.rssBytes).toBe(456_000_000);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('falls back to direct agent process lookup when tmux pane pid lookup is unavailable', async () => {
|
||||||
|
const svc = new TeamProvisioningService();
|
||||||
|
(svc as any).configReader = {
|
||||||
|
getConfig: vi.fn(async () => ({
|
||||||
|
members: [
|
||||||
|
{ name: 'team-lead', agentType: 'team-lead' },
|
||||||
|
{ name: 'alice', model: 'gpt-5.2' },
|
||||||
|
],
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
(svc as any).readPersistedRuntimeMembers = vi.fn(() => [
|
||||||
|
{
|
||||||
|
name: 'alice',
|
||||||
|
agentId: 'alice@nice-team',
|
||||||
|
tmuxPaneId: '%0',
|
||||||
|
backendType: 'tmux',
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
(svc as any).aliveRunByTeam.set('nice-team', 'run-1');
|
||||||
|
(svc as any).runs.set('run-1', {
|
||||||
|
runId: 'run-1',
|
||||||
|
child: { pid: 111 },
|
||||||
|
request: { model: 'gpt-5.4' },
|
||||||
|
processKilled: false,
|
||||||
|
cancelRequested: false,
|
||||||
|
spawnContext: null,
|
||||||
|
});
|
||||||
|
(svc as any).readUnixProcessTableRows = vi.fn(() => [
|
||||||
|
{
|
||||||
|
pid: 333,
|
||||||
|
command:
|
||||||
|
'/Users/belief/.bun/bin/bun cli.js --agent-id alice@nice-team --agent-name alice --team-name nice-team --model gpt-5.2',
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
vi.mocked(listTmuxPanePidsForCurrentPlatform).mockResolvedValueOnce(new Map());
|
||||||
|
vi.mocked(pidusage).mockResolvedValueOnce({
|
||||||
|
'111': createPidusageStat(111, 123_000_000),
|
||||||
|
'333': createPidusageStat(333, 456_000_000),
|
||||||
|
} as any);
|
||||||
|
|
||||||
|
const snapshot = await svc.getTeamAgentRuntimeSnapshot('nice-team');
|
||||||
|
|
||||||
|
expect(snapshot.members['team-lead']).toMatchObject({
|
||||||
|
pid: 111,
|
||||||
|
rssBytes: 123_000_000,
|
||||||
|
});
|
||||||
|
expect(snapshot.members.alice).toMatchObject({
|
||||||
|
pid: 333,
|
||||||
|
rssBytes: 456_000_000,
|
||||||
|
runtimeModel: 'gpt-5.2',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('prefers the newest matching agent pid when multiple processes match the same teammate', async () => {
|
||||||
|
const svc = new TeamProvisioningService();
|
||||||
|
(svc as any).configReader = {
|
||||||
|
getConfig: vi.fn(async () => ({
|
||||||
|
members: [
|
||||||
|
{ name: 'team-lead', agentType: 'team-lead' },
|
||||||
|
{ name: 'alice', model: 'gpt-5.2' },
|
||||||
|
],
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
(svc as any).readPersistedRuntimeMembers = vi.fn(() => [
|
||||||
|
{
|
||||||
|
name: 'alice',
|
||||||
|
agentId: 'alice@nice-team',
|
||||||
|
tmuxPaneId: '%0',
|
||||||
|
backendType: 'tmux',
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
(svc as any).aliveRunByTeam.set('nice-team', 'run-1');
|
||||||
|
(svc as any).runs.set('run-1', {
|
||||||
|
runId: 'run-1',
|
||||||
|
child: { pid: 111 },
|
||||||
|
request: { model: 'gpt-5.4' },
|
||||||
|
processKilled: false,
|
||||||
|
cancelRequested: false,
|
||||||
|
spawnContext: null,
|
||||||
|
});
|
||||||
|
(svc as any).readUnixProcessTableRows = vi.fn(() => [
|
||||||
|
{
|
||||||
|
pid: 222,
|
||||||
|
command:
|
||||||
|
'/Users/belief/.bun/bin/bun cli.js --agent-id alice@nice-team --agent-name alice --team-name nice-team --model gpt-5.2',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
pid: 333,
|
||||||
|
command:
|
||||||
|
'/Users/belief/.bun/bin/bun cli.js --team-name nice-team --agent-id alice@nice-team --agent-name alice --model gpt-5.2',
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
vi.mocked(listTmuxPanePidsForCurrentPlatform).mockResolvedValueOnce(new Map());
|
||||||
|
vi.mocked(pidusage).mockResolvedValueOnce({
|
||||||
|
'111': createPidusageStat(111, 123_000_000),
|
||||||
|
'333': createPidusageStat(333, 456_000_000),
|
||||||
|
} as any);
|
||||||
|
|
||||||
|
const snapshot = await svc.getTeamAgentRuntimeSnapshot('nice-team');
|
||||||
|
|
||||||
|
expect(snapshot.members.alice).toMatchObject({
|
||||||
|
pid: 333,
|
||||||
|
rssBytes: 456_000_000,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('restartMember', () => {
|
||||||
|
it('uses members meta runtime settings when config members are stale or absent', async () => {
|
||||||
|
const svc = new TeamProvisioningService();
|
||||||
|
const run = createMemberSpawnRun({
|
||||||
|
teamName: 'edited-team',
|
||||||
|
expectedMembers: ['alice'],
|
||||||
|
memberSpawnStatuses: new Map(),
|
||||||
|
});
|
||||||
|
run.child = { pid: 111 };
|
||||||
|
run.processKilled = false;
|
||||||
|
run.cancelRequested = false;
|
||||||
|
|
||||||
|
const sendMessageToRun = vi.fn(async () => {});
|
||||||
|
(svc as any).sendMessageToRun = sendMessageToRun;
|
||||||
|
(svc as any).configReader = {
|
||||||
|
getConfig: vi.fn(async () => ({
|
||||||
|
name: 'Edited Team',
|
||||||
|
members: [{ name: 'team-lead', agentType: 'team-lead' }],
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
(svc as any).membersMetaStore = {
|
||||||
|
getMembers: vi.fn(async () => [
|
||||||
|
{
|
||||||
|
name: 'alice',
|
||||||
|
role: 'Reviewer',
|
||||||
|
workflow: 'Use checklist',
|
||||||
|
providerId: 'codex',
|
||||||
|
model: 'gpt-5.4-mini',
|
||||||
|
effort: 'high',
|
||||||
|
agentType: 'general-purpose',
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
};
|
||||||
|
(svc as any).readPersistedRuntimeMembers = vi.fn(() => []);
|
||||||
|
(svc as any).getLiveTeamAgentRuntimeMetadata = vi.fn(async () => new Map());
|
||||||
|
(svc as any).aliveRunByTeam.set('edited-team', run.runId);
|
||||||
|
(svc as any).runs.set(run.runId, run);
|
||||||
|
|
||||||
|
await svc.restartMember('edited-team', 'alice');
|
||||||
|
|
||||||
|
expect(sendMessageToRun).toHaveBeenCalledTimes(1);
|
||||||
|
const restartCall = sendMessageToRun.mock.calls[0] as unknown as
|
||||||
|
| [unknown, string]
|
||||||
|
| undefined;
|
||||||
|
const restartMessage = restartCall?.[1] ?? '';
|
||||||
|
expect(restartMessage).toContain('provider="codex"');
|
||||||
|
expect(restartMessage).toContain('model="gpt-5.4-mini"');
|
||||||
|
expect(restartMessage).toContain('effort="high"');
|
||||||
|
expect(restartMessage).toContain('with role "Reviewer"');
|
||||||
|
expect(restartMessage).toContain('Their workflow: Use checklist');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('treats duplicate_skipped already_running as a failed codex restart because the old runtime is still active', async () => {
|
||||||
|
const svc = new TeamProvisioningService();
|
||||||
|
const run = createMemberSpawnRun({
|
||||||
|
teamName: 'codex-team',
|
||||||
|
expectedMembers: ['bob'],
|
||||||
|
memberSpawnStatuses: new Map(),
|
||||||
|
});
|
||||||
|
run.child = { pid: 111 };
|
||||||
|
run.processKilled = false;
|
||||||
|
run.cancelRequested = false;
|
||||||
|
|
||||||
|
const sendMessageToRun = vi.fn(async () => {});
|
||||||
|
(svc as any).sendMessageToRun = sendMessageToRun;
|
||||||
|
(svc as any).configReader = {
|
||||||
|
getConfig: vi.fn(async () => ({
|
||||||
|
name: 'Codex Team',
|
||||||
|
members: [{ name: 'team-lead', agentType: 'team-lead' }],
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
(svc as any).membersMetaStore = {
|
||||||
|
getMembers: vi.fn(async () => [
|
||||||
|
{
|
||||||
|
name: 'bob',
|
||||||
|
role: 'Developer',
|
||||||
|
providerId: 'codex',
|
||||||
|
model: 'gpt-5.2',
|
||||||
|
effort: 'medium',
|
||||||
|
agentType: 'general-purpose',
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
};
|
||||||
|
(svc as any).readPersistedRuntimeMembers = vi.fn(() => []);
|
||||||
|
(svc as any).getLiveTeamAgentRuntimeMetadata = vi.fn(async () => new Map());
|
||||||
|
(svc as any).aliveRunByTeam.set('codex-team', run.runId);
|
||||||
|
(svc as any).runs.set(run.runId, run);
|
||||||
|
|
||||||
|
await svc.restartMember('codex-team', 'bob');
|
||||||
|
|
||||||
|
expect(run.memberSpawnStatuses.get('bob')).toMatchObject({
|
||||||
|
status: 'spawning',
|
||||||
|
launchState: 'starting',
|
||||||
|
});
|
||||||
|
expect(sendMessageToRun).toHaveBeenCalledWith(
|
||||||
|
run,
|
||||||
|
expect.stringContaining('provider="codex", model="gpt-5.2", effort="medium"')
|
||||||
|
);
|
||||||
|
|
||||||
|
run.activeToolCalls.set('tool-agent-1', {
|
||||||
|
memberName: 'bob',
|
||||||
|
toolUseId: 'tool-agent-1',
|
||||||
|
toolName: 'Agent',
|
||||||
|
preview: 'Spawn teammate bob',
|
||||||
|
startedAt: new Date().toISOString(),
|
||||||
|
state: 'running',
|
||||||
|
source: 'runtime',
|
||||||
|
});
|
||||||
|
run.memberSpawnToolUseIds.set('tool-agent-1', 'bob');
|
||||||
|
|
||||||
|
(svc as any).finishRuntimeToolActivity(
|
||||||
|
run,
|
||||||
|
'tool-agent-1',
|
||||||
|
[
|
||||||
|
{
|
||||||
|
type: 'text',
|
||||||
|
text: 'status: duplicate_skipped\nreason: already_running\nname: bob\nteam_name: codex-team',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
false
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(run.memberSpawnStatuses.get('bob')).toMatchObject({
|
||||||
|
status: 'error',
|
||||||
|
launchState: 'failed_to_start',
|
||||||
|
runtimeAlive: false,
|
||||||
|
hardFailure: true,
|
||||||
|
hardFailureReason:
|
||||||
|
'Restart for teammate "bob" was skipped because the previous runtime still appears to be active. The requested settings may not have been applied.',
|
||||||
|
});
|
||||||
|
expect(run.pendingMemberRestarts.has('bob')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps a codex teammate restart pending instead of failed when lead reports duplicate_skipped bootstrap_pending', async () => {
|
||||||
|
const svc = new TeamProvisioningService();
|
||||||
|
const run = createMemberSpawnRun({
|
||||||
|
teamName: 'codex-team',
|
||||||
|
expectedMembers: ['bob'],
|
||||||
|
memberSpawnStatuses: new Map(),
|
||||||
|
});
|
||||||
|
run.child = { pid: 111 };
|
||||||
|
run.processKilled = false;
|
||||||
|
run.cancelRequested = false;
|
||||||
|
|
||||||
|
(svc as any).sendMessageToRun = vi.fn(async () => {});
|
||||||
|
(svc as any).configReader = {
|
||||||
|
getConfig: vi.fn(async () => ({
|
||||||
|
name: 'Codex Team',
|
||||||
|
members: [{ name: 'team-lead', agentType: 'team-lead' }],
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
(svc as any).membersMetaStore = {
|
||||||
|
getMembers: vi.fn(async () => [
|
||||||
|
{
|
||||||
|
name: 'bob',
|
||||||
|
role: 'Developer',
|
||||||
|
providerId: 'codex',
|
||||||
|
model: 'gpt-5.2',
|
||||||
|
effort: 'medium',
|
||||||
|
agentType: 'general-purpose',
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
};
|
||||||
|
(svc as any).readPersistedRuntimeMembers = vi.fn(() => []);
|
||||||
|
(svc as any).getLiveTeamAgentRuntimeMetadata = vi.fn(async () => new Map());
|
||||||
|
(svc as any).aliveRunByTeam.set('codex-team', run.runId);
|
||||||
|
(svc as any).runs.set(run.runId, run);
|
||||||
|
|
||||||
|
await svc.restartMember('codex-team', 'bob');
|
||||||
|
|
||||||
|
run.activeToolCalls.set('tool-agent-1', {
|
||||||
|
memberName: 'bob',
|
||||||
|
toolUseId: 'tool-agent-1',
|
||||||
|
toolName: 'Agent',
|
||||||
|
preview: 'Spawn teammate bob',
|
||||||
|
startedAt: new Date().toISOString(),
|
||||||
|
state: 'running',
|
||||||
|
source: 'runtime',
|
||||||
|
});
|
||||||
|
run.memberSpawnToolUseIds.set('tool-agent-1', 'bob');
|
||||||
|
|
||||||
|
(svc as any).finishRuntimeToolActivity(
|
||||||
|
run,
|
||||||
|
'tool-agent-1',
|
||||||
|
[
|
||||||
|
{
|
||||||
|
type: 'text',
|
||||||
|
text: 'status: duplicate_skipped\nreason: bootstrap_pending\nname: bob\nteam_name: codex-team',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
false
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(run.memberSpawnStatuses.get('bob')).toMatchObject({
|
||||||
|
status: 'waiting',
|
||||||
|
launchState: 'runtime_pending_bootstrap',
|
||||||
|
runtimeAlive: false,
|
||||||
|
agentToolAccepted: true,
|
||||||
|
hardFailure: false,
|
||||||
|
hardFailureReason: undefined,
|
||||||
|
});
|
||||||
|
expect(run.pendingMemberRestarts.has('bob')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects a second restart request while the first restart is still in flight', async () => {
|
||||||
|
const svc = new TeamProvisioningService();
|
||||||
|
const run = createMemberSpawnRun({
|
||||||
|
teamName: 'codex-team',
|
||||||
|
expectedMembers: ['bob'],
|
||||||
|
memberSpawnStatuses: new Map(),
|
||||||
|
});
|
||||||
|
run.child = { pid: 111 };
|
||||||
|
run.processKilled = false;
|
||||||
|
run.cancelRequested = false;
|
||||||
|
run.pendingMemberRestarts.set('bob', {
|
||||||
|
requestedAt: new Date().toISOString(),
|
||||||
|
desired: {
|
||||||
|
name: 'bob',
|
||||||
|
providerId: 'codex',
|
||||||
|
model: 'gpt-5.2',
|
||||||
|
effort: 'medium',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
(svc as any).configReader = {
|
||||||
|
getConfig: vi.fn(async () => ({
|
||||||
|
name: 'Codex Team',
|
||||||
|
members: [{ name: 'team-lead', agentType: 'team-lead' }],
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
(svc as any).membersMetaStore = {
|
||||||
|
getMembers: vi.fn(async () => [
|
||||||
|
{
|
||||||
|
name: 'bob',
|
||||||
|
role: 'Developer',
|
||||||
|
providerId: 'codex',
|
||||||
|
model: 'gpt-5.2',
|
||||||
|
effort: 'medium',
|
||||||
|
agentType: 'general-purpose',
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
};
|
||||||
|
(svc as any).aliveRunByTeam.set('codex-team', run.runId);
|
||||||
|
(svc as any).runs.set(run.runId, run);
|
||||||
|
|
||||||
|
await expect(svc.restartMember('codex-team', 'bob')).rejects.toThrow(
|
||||||
|
'Restart for teammate "bob" is already in progress'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('marks a pending restart as failed when the teammate never rejoins within the restart grace window', async () => {
|
||||||
|
const svc = new TeamProvisioningService();
|
||||||
|
const run = createMemberSpawnRun({
|
||||||
|
teamName: 'codex-team',
|
||||||
|
expectedMembers: ['bob'],
|
||||||
|
memberSpawnStatuses: new Map([
|
||||||
|
[
|
||||||
|
'bob',
|
||||||
|
createMemberSpawnStatusEntry({
|
||||||
|
status: 'waiting',
|
||||||
|
launchState: 'runtime_pending_bootstrap',
|
||||||
|
agentToolAccepted: true,
|
||||||
|
firstSpawnAcceptedAt: new Date(Date.now() - 120_000).toISOString(),
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
]),
|
||||||
|
});
|
||||||
|
run.pendingMemberRestarts.set('bob', {
|
||||||
|
requestedAt: new Date(Date.now() - 120_000).toISOString(),
|
||||||
|
desired: {
|
||||||
|
name: 'bob',
|
||||||
|
providerId: 'codex',
|
||||||
|
model: 'gpt-5.2',
|
||||||
|
effort: 'medium',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
(svc as any).refreshMemberSpawnStatusesFromLeadInbox = vi.fn(async () => {});
|
||||||
|
(svc as any).maybeAuditMemberSpawnStatuses = vi.fn(async () => {});
|
||||||
|
|
||||||
|
await (svc as any).reevaluateMemberLaunchStatus(run, 'bob');
|
||||||
|
|
||||||
|
expect(run.memberSpawnStatuses.get('bob')).toMatchObject({
|
||||||
|
status: 'error',
|
||||||
|
launchState: 'failed_to_start',
|
||||||
|
error: 'Teammate "bob" did not rejoin within the restart grace window.',
|
||||||
|
hardFailureReason: 'Teammate "bob" did not rejoin within the restart grace window.',
|
||||||
|
});
|
||||||
|
expect(run.pendingMemberRestarts.has('bob')).toBe(false);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('removes generated MCP config when createTeam spawn fails synchronously', async () => {
|
it('removes generated MCP config when createTeam spawn fails synchronously', async () => {
|
||||||
|
|
@ -1656,4 +2077,225 @@ describe('TeamProvisioningService', () => {
|
||||||
expect(run.memberSpawnStatuses.get('alice')).toBe(existingEntry);
|
expect(run.memberSpawnStatuses.get('alice')).toBe(existingEntry);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('treats duplicate_skipped already_running as process-confirmed online', () => {
|
||||||
|
const run = createMemberSpawnRun();
|
||||||
|
run.activeToolCalls.set('tool-agent-1', {
|
||||||
|
memberName: 'alice',
|
||||||
|
toolUseId: 'tool-agent-1',
|
||||||
|
toolName: 'Agent',
|
||||||
|
preview: 'Spawn teammate alice',
|
||||||
|
startedAt: new Date().toISOString(),
|
||||||
|
state: 'running',
|
||||||
|
source: 'runtime',
|
||||||
|
});
|
||||||
|
run.memberSpawnToolUseIds.set('tool-agent-1', 'alice');
|
||||||
|
|
||||||
|
const svc = new TeamProvisioningService();
|
||||||
|
|
||||||
|
(svc as any).finishRuntimeToolActivity(
|
||||||
|
run,
|
||||||
|
'tool-agent-1',
|
||||||
|
[
|
||||||
|
{
|
||||||
|
type: 'text',
|
||||||
|
text: 'status: duplicate_skipped\nreason: already_running\nname: alice\nteam_name: nice-team',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
false
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(run.memberSpawnStatuses.get('alice')).toMatchObject({
|
||||||
|
status: 'online',
|
||||||
|
launchState: 'runtime_pending_bootstrap',
|
||||||
|
runtimeAlive: true,
|
||||||
|
livenessSource: 'process',
|
||||||
|
hardFailure: false,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('clears stale failed_to_start state when live runtime metadata proves the teammate is alive', async () => {
|
||||||
|
const svc = new TeamProvisioningService();
|
||||||
|
(svc as any).getLiveTeamAgentRuntimeMetadata = vi.fn(async () =>
|
||||||
|
new Map([
|
||||||
|
[
|
||||||
|
'bob',
|
||||||
|
{
|
||||||
|
alive: true,
|
||||||
|
model: 'gpt-5.2',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
])
|
||||||
|
);
|
||||||
|
|
||||||
|
const result = await (svc as any).attachLiveRuntimeMetadataToStatuses('beacon-desk-4', {
|
||||||
|
bob: createMemberSpawnStatusEntry({
|
||||||
|
status: 'error',
|
||||||
|
launchState: 'failed_to_start',
|
||||||
|
error: 'Teammate did not join within the launch grace window.',
|
||||||
|
hardFailure: true,
|
||||||
|
hardFailureReason: 'Teammate did not join within the launch grace window.',
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.bob).toMatchObject({
|
||||||
|
status: 'online',
|
||||||
|
launchState: 'runtime_pending_bootstrap',
|
||||||
|
runtimeAlive: true,
|
||||||
|
hardFailure: false,
|
||||||
|
hardFailureReason: undefined,
|
||||||
|
error: undefined,
|
||||||
|
runtimeModel: 'gpt-5.2',
|
||||||
|
livenessSource: 'process',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not clear an explicit restart failure just because the old runtime is still alive', async () => {
|
||||||
|
const svc = new TeamProvisioningService();
|
||||||
|
(svc as any).getLiveTeamAgentRuntimeMetadata = vi.fn(async () =>
|
||||||
|
new Map([
|
||||||
|
[
|
||||||
|
'bob',
|
||||||
|
{
|
||||||
|
alive: true,
|
||||||
|
model: 'gpt-5.3-codex',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
])
|
||||||
|
);
|
||||||
|
|
||||||
|
const result = await (svc as any).attachLiveRuntimeMetadataToStatuses('beacon-desk-4', {
|
||||||
|
bob: createMemberSpawnStatusEntry({
|
||||||
|
status: 'error',
|
||||||
|
launchState: 'failed_to_start',
|
||||||
|
error:
|
||||||
|
'Restart for teammate "bob" was skipped because the previous runtime still appears to be active. The requested settings may not have been applied.',
|
||||||
|
hardFailure: true,
|
||||||
|
hardFailureReason:
|
||||||
|
'Restart for teammate "bob" was skipped because the previous runtime still appears to be active. The requested settings may not have been applied.',
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.bob).toMatchObject({
|
||||||
|
status: 'error',
|
||||||
|
launchState: 'failed_to_start',
|
||||||
|
runtimeAlive: false,
|
||||||
|
hardFailure: true,
|
||||||
|
hardFailureReason:
|
||||||
|
'Restart for teammate "bob" was skipped because the previous runtime still appears to be active. The requested settings may not have been applied.',
|
||||||
|
error:
|
||||||
|
'Restart for teammate "bob" was skipped because the previous runtime still appears to be active. The requested settings may not have been applied.',
|
||||||
|
runtimeModel: 'gpt-5.3-codex',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not downgrade an already-online teammate when waiting is reported later', () => {
|
||||||
|
const run = createMemberSpawnRun({
|
||||||
|
memberSpawnStatuses: new Map([
|
||||||
|
[
|
||||||
|
'alice',
|
||||||
|
createMemberSpawnStatusEntry({
|
||||||
|
status: 'online',
|
||||||
|
launchState: 'confirmed_alive',
|
||||||
|
runtimeAlive: true,
|
||||||
|
livenessSource: 'heartbeat',
|
||||||
|
bootstrapConfirmed: true,
|
||||||
|
lastHeartbeatAt: '2026-04-16T10:00:00.000Z',
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
]),
|
||||||
|
});
|
||||||
|
const svc = new TeamProvisioningService();
|
||||||
|
|
||||||
|
(svc as any).setMemberSpawnStatus(run, 'alice', 'waiting');
|
||||||
|
|
||||||
|
expect(run.memberSpawnStatuses.get('alice')).toMatchObject({
|
||||||
|
status: 'online',
|
||||||
|
launchState: 'confirmed_alive',
|
||||||
|
runtimeAlive: true,
|
||||||
|
livenessSource: 'heartbeat',
|
||||||
|
bootstrapConfirmed: true,
|
||||||
|
lastHeartbeatAt: '2026-04-16T10:00:00.000Z',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('clears stale hard failure state when a new spawn attempt starts', () => {
|
||||||
|
const staleAcceptedAt = '2026-04-16T10:00:00.000Z';
|
||||||
|
const run = createMemberSpawnRun({
|
||||||
|
memberSpawnStatuses: new Map([
|
||||||
|
[
|
||||||
|
'alice',
|
||||||
|
createMemberSpawnStatusEntry({
|
||||||
|
status: 'error',
|
||||||
|
launchState: 'failed_to_start',
|
||||||
|
error: 'Teammate was never spawned during launch.',
|
||||||
|
hardFailure: true,
|
||||||
|
hardFailureReason: 'Teammate was never spawned during launch.',
|
||||||
|
runtimeAlive: true,
|
||||||
|
bootstrapConfirmed: true,
|
||||||
|
livenessSource: 'heartbeat',
|
||||||
|
firstSpawnAcceptedAt: staleAcceptedAt,
|
||||||
|
lastHeartbeatAt: staleAcceptedAt,
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
]),
|
||||||
|
});
|
||||||
|
const svc = new TeamProvisioningService();
|
||||||
|
|
||||||
|
(svc as any).setMemberSpawnStatus(run, 'alice', 'spawning');
|
||||||
|
|
||||||
|
expect(run.memberSpawnStatuses.get('alice')).toMatchObject({
|
||||||
|
status: 'spawning',
|
||||||
|
launchState: 'starting',
|
||||||
|
error: undefined,
|
||||||
|
hardFailure: false,
|
||||||
|
hardFailureReason: undefined,
|
||||||
|
agentToolAccepted: false,
|
||||||
|
runtimeAlive: false,
|
||||||
|
bootstrapConfirmed: false,
|
||||||
|
livenessSource: undefined,
|
||||||
|
firstSpawnAcceptedAt: undefined,
|
||||||
|
lastHeartbeatAt: undefined,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reconciles stale never-spawned failures when bootstrap state proves the teammate was registered', async () => {
|
||||||
|
const teamName = 'registered-bootstrap-team';
|
||||||
|
const leadSessionId = 'lead-session';
|
||||||
|
const acceptedAt = new Date(Date.now() - 60_000).toISOString();
|
||||||
|
writeLaunchConfig(teamName, '/Users/test/proj', leadSessionId, ['alice']);
|
||||||
|
writeLaunchState(teamName, leadSessionId, {
|
||||||
|
alice: {
|
||||||
|
launchState: 'failed_to_start',
|
||||||
|
agentToolAccepted: false,
|
||||||
|
runtimeAlive: false,
|
||||||
|
bootstrapConfirmed: false,
|
||||||
|
hardFailure: true,
|
||||||
|
hardFailureReason: 'Teammate was never spawned during launch.',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
writeBootstrapState(
|
||||||
|
teamName,
|
||||||
|
[
|
||||||
|
{
|
||||||
|
name: 'alice',
|
||||||
|
status: 'registered',
|
||||||
|
lastAttemptAt: Date.parse(acceptedAt),
|
||||||
|
lastObservedAt: Date.parse(acceptedAt),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
new Date(Date.now() - 30_000).toISOString()
|
||||||
|
);
|
||||||
|
|
||||||
|
const svc = new TeamProvisioningService();
|
||||||
|
const result = await svc.getMemberSpawnStatuses(teamName);
|
||||||
|
|
||||||
|
expect(result.statuses.alice).toMatchObject({
|
||||||
|
status: 'waiting',
|
||||||
|
launchState: 'runtime_pending_bootstrap',
|
||||||
|
hardFailure: false,
|
||||||
|
hardFailureReason: undefined,
|
||||||
|
agentToolAccepted: true,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -293,6 +293,12 @@ describe('TeamProvisioningService prompt content (solo mode discipline)', () =>
|
||||||
expect(message).toContain('team_name="forge-labs", name="alice"');
|
expect(message).toContain('team_name="forge-labs", name="alice"');
|
||||||
expect(message).toContain('provider="codex", model="gpt-5.4-mini", effort="medium"');
|
expect(message).toContain('provider="codex", model="gpt-5.4-mini", effort="medium"');
|
||||||
expect(message).toContain('This is a restart of an existing persistent teammate, not a new teammate.');
|
expect(message).toContain('This is a restart of an existing persistent teammate, not a new teammate.');
|
||||||
|
expect(message).toContain(
|
||||||
|
'If the Agent tool returns duplicate_skipped with reason bootstrap_pending, treat that as a pending restart and wait for teammate check-in.'
|
||||||
|
);
|
||||||
|
expect(message).toContain(
|
||||||
|
'If it returns duplicate_skipped with reason already_running, do not report success - it means the previous runtime still appears active and the restart may not have applied.'
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('createTeam materializes an explicit Codex default model for teammates before bootstrap spawn', async () => {
|
it('createTeam materializes an explicit Codex default model for teammates before bootstrap spawn', async () => {
|
||||||
|
|
|
||||||
1123
test/renderer/components/team/dialogs/EditTeamDialog.test.ts
Normal file
1123
test/renderer/components/team/dialogs/EditTeamDialog.test.ts
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -0,0 +1,181 @@
|
||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
|
import {
|
||||||
|
buildEditTeamSourceSnapshot,
|
||||||
|
getLiveRosterIdentityChanges,
|
||||||
|
getMembersRequiringRuntimeRestart,
|
||||||
|
} from '@renderer/components/team/dialogs/editTeamRuntimeChanges';
|
||||||
|
|
||||||
|
describe('getMembersRequiringRuntimeRestart', () => {
|
||||||
|
it('returns existing teammates whose role, workflow, provider, model, or effort changed', () => {
|
||||||
|
const result = getMembersRequiringRuntimeRestart({
|
||||||
|
previousMembers: [
|
||||||
|
{
|
||||||
|
name: 'alice',
|
||||||
|
role: 'Reviewer',
|
||||||
|
workflow: 'Review PRs',
|
||||||
|
providerId: 'codex',
|
||||||
|
model: 'gpt-5.2',
|
||||||
|
effort: 'medium',
|
||||||
|
} as any,
|
||||||
|
{
|
||||||
|
name: 'bob',
|
||||||
|
role: 'Developer',
|
||||||
|
workflow: 'Ship features',
|
||||||
|
providerId: 'anthropic',
|
||||||
|
model: '',
|
||||||
|
effort: 'low',
|
||||||
|
} as any,
|
||||||
|
],
|
||||||
|
nextMembers: [
|
||||||
|
{
|
||||||
|
name: 'alice',
|
||||||
|
role: 'Reviewer',
|
||||||
|
workflow: 'Review PRs',
|
||||||
|
providerId: 'codex',
|
||||||
|
model: 'gpt-5.4-mini',
|
||||||
|
effort: 'medium',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'bob',
|
||||||
|
role: 'Developer',
|
||||||
|
workflow: 'Ship safer features',
|
||||||
|
providerId: 'anthropic',
|
||||||
|
model: '',
|
||||||
|
effort: 'high',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result).toEqual(['alice', 'bob']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ignores newly added or renamed teammates for restart targeting', () => {
|
||||||
|
const result = getMembersRequiringRuntimeRestart({
|
||||||
|
previousMembers: [
|
||||||
|
{
|
||||||
|
name: 'alice',
|
||||||
|
role: 'Reviewer',
|
||||||
|
workflow: 'Review PRs',
|
||||||
|
providerId: 'codex',
|
||||||
|
model: 'gpt-5.2',
|
||||||
|
effort: 'medium',
|
||||||
|
} as any,
|
||||||
|
],
|
||||||
|
nextMembers: [
|
||||||
|
{
|
||||||
|
name: 'alice-2',
|
||||||
|
role: 'Reviewer',
|
||||||
|
workflow: 'Review PRs',
|
||||||
|
providerId: 'codex',
|
||||||
|
model: 'gpt-5.4',
|
||||||
|
effort: 'high',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'bob',
|
||||||
|
role: 'Developer',
|
||||||
|
workflow: 'Ship features',
|
||||||
|
providerId: 'anthropic',
|
||||||
|
model: '',
|
||||||
|
effort: undefined,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('treats empty values as unchanged normalized runtime settings', () => {
|
||||||
|
const result = getMembersRequiringRuntimeRestart({
|
||||||
|
previousMembers: [
|
||||||
|
{
|
||||||
|
name: 'alice',
|
||||||
|
role: undefined,
|
||||||
|
workflow: undefined,
|
||||||
|
providerId: undefined,
|
||||||
|
model: undefined,
|
||||||
|
effort: undefined,
|
||||||
|
} as any,
|
||||||
|
],
|
||||||
|
nextMembers: [
|
||||||
|
{
|
||||||
|
name: 'alice',
|
||||||
|
role: '',
|
||||||
|
workflow: '',
|
||||||
|
providerId: undefined,
|
||||||
|
model: '',
|
||||||
|
effort: undefined,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reports live rename and remove of existing teammates separately from runtime restarts', () => {
|
||||||
|
const result = getLiveRosterIdentityChanges({
|
||||||
|
previousMembers: [
|
||||||
|
{
|
||||||
|
name: 'alice',
|
||||||
|
role: 'Reviewer',
|
||||||
|
} as any,
|
||||||
|
{
|
||||||
|
name: 'bob',
|
||||||
|
role: 'Developer',
|
||||||
|
} as any,
|
||||||
|
],
|
||||||
|
nextDrafts: [
|
||||||
|
{
|
||||||
|
id: 'draft-alice',
|
||||||
|
name: 'alice-renamed',
|
||||||
|
originalName: 'alice',
|
||||||
|
roleSelection: '',
|
||||||
|
customRole: '',
|
||||||
|
},
|
||||||
|
] as any,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result).toEqual({
|
||||||
|
renamed: ['alice'],
|
||||||
|
removed: ['bob'],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ignores live status-only member refreshes in the edit source snapshot', () => {
|
||||||
|
const base = buildEditTeamSourceSnapshot({
|
||||||
|
name: 'Team A',
|
||||||
|
description: 'desc',
|
||||||
|
color: 'blue',
|
||||||
|
members: [
|
||||||
|
{
|
||||||
|
name: 'alice',
|
||||||
|
role: 'Reviewer',
|
||||||
|
providerId: 'codex',
|
||||||
|
model: 'gpt-5.4-mini',
|
||||||
|
effort: 'medium',
|
||||||
|
status: 'online',
|
||||||
|
branch: 'main',
|
||||||
|
} as any,
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
const refreshed = buildEditTeamSourceSnapshot({
|
||||||
|
name: 'Team A',
|
||||||
|
description: 'desc',
|
||||||
|
color: 'blue',
|
||||||
|
members: [
|
||||||
|
{
|
||||||
|
name: 'alice',
|
||||||
|
role: 'Reviewer',
|
||||||
|
providerId: 'codex',
|
||||||
|
model: 'gpt-5.4-mini',
|
||||||
|
effort: 'medium',
|
||||||
|
status: 'offline',
|
||||||
|
branch: 'feature/x',
|
||||||
|
} as any,
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(refreshed).toBe(base);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -274,4 +274,35 @@ describe('MemberCard starting-state visuals', () => {
|
||||||
await Promise.resolve();
|
await Promise.resolve();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('renders memory after the role label in the compact runtime summary row', async () => {
|
||||||
|
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
|
||||||
|
const host = document.createElement('div');
|
||||||
|
document.body.appendChild(host);
|
||||||
|
const root = createRoot(host);
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
root.render(
|
||||||
|
React.createElement(MemberCard, {
|
||||||
|
member,
|
||||||
|
memberColor: 'blue',
|
||||||
|
runtimeSummary: '5.2 · Medium · 238.3 MB',
|
||||||
|
isTeamAlive: true,
|
||||||
|
isTeamProvisioning: false,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
await Promise.resolve();
|
||||||
|
});
|
||||||
|
|
||||||
|
const text = host.textContent ?? '';
|
||||||
|
expect(text).toContain('5.2 · Medium');
|
||||||
|
expect(text).toContain('Reviewer');
|
||||||
|
expect(text).toContain('238.3 MB');
|
||||||
|
expect(text.indexOf('Reviewer')).toBeLessThan(text.indexOf('238.3 MB'));
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
root.unmount();
|
||||||
|
await Promise.resolve();
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,7 @@ import {
|
||||||
createMemberDraftsFromInputs,
|
createMemberDraftsFromInputs,
|
||||||
filterEditableMemberInputs,
|
filterEditableMemberInputs,
|
||||||
} from '@renderer/components/team/members/MembersEditorSection';
|
} from '@renderer/components/team/members/MembersEditorSection';
|
||||||
import { buildMemberColorMap } from '@renderer/utils/memberHelpers';
|
import { buildTeamMemberColorMap } from '@shared/utils/teamMemberColors';
|
||||||
import { getMemberColorByName } from '@shared/constants/memberColors';
|
import { getMemberColorByName } from '@shared/constants/memberColors';
|
||||||
import type { ResolvedTeamMember } from '@shared/types';
|
import type { ResolvedTeamMember } from '@shared/types';
|
||||||
|
|
||||||
|
|
@ -60,6 +60,7 @@ describe('members editor editable input filtering', () => {
|
||||||
expect(drafts).toHaveLength(1);
|
expect(drafts).toHaveLength(1);
|
||||||
expect(drafts[0]).toMatchObject({
|
expect(drafts[0]).toMatchObject({
|
||||||
name: 'alice',
|
name: 'alice',
|
||||||
|
originalName: 'alice',
|
||||||
providerId: 'codex',
|
providerId: 'codex',
|
||||||
model: 'gpt-5.4-mini',
|
model: 'gpt-5.4-mini',
|
||||||
effort: 'medium',
|
effort: 'medium',
|
||||||
|
|
@ -95,12 +96,9 @@ describe('members editor editable input filtering', () => {
|
||||||
const existingMembers = [{ name: 'alice' }, { name: 'tom' }, { name: 'bob' }];
|
const existingMembers = [{ name: 'alice' }, { name: 'tom' }, { name: 'bob' }];
|
||||||
const drafts = existingMembers.map((member) => createMemberDraft({ name: member.name }));
|
const drafts = existingMembers.map((member) => createMemberDraft({ name: member.name }));
|
||||||
|
|
||||||
const expectedColors = buildMemberColorMap(
|
const expectedColors = buildTeamMemberColorMap(existingMembers, {
|
||||||
existingMembers.map((member) => ({
|
preferProvidedColors: false,
|
||||||
...member,
|
});
|
||||||
color: getMemberColorByName(member.name),
|
|
||||||
}))
|
|
||||||
);
|
|
||||||
const draftColors = buildMemberDraftColorMap(drafts, existingMembers);
|
const draftColors = buildMemberDraftColorMap(drafts, existingMembers);
|
||||||
|
|
||||||
expect(draftColors.get('alice')).toBe(expectedColors.get('alice'));
|
expect(draftColors.get('alice')).toBe(expectedColors.get('alice'));
|
||||||
|
|
@ -116,12 +114,9 @@ describe('members editor editable input filtering', () => {
|
||||||
createMemberDraft({ name: 'bob' }),
|
createMemberDraft({ name: 'bob' }),
|
||||||
];
|
];
|
||||||
|
|
||||||
const expectedColors = buildMemberColorMap(
|
const expectedColors = buildTeamMemberColorMap([...existingMembers, { name: 'bob' }], {
|
||||||
[...existingMembers, { name: 'bob' }].map((member) => ({
|
preferProvidedColors: false,
|
||||||
...member,
|
});
|
||||||
color: getMemberColorByName(member.name),
|
|
||||||
}))
|
|
||||||
);
|
|
||||||
const draftColors = buildMemberDraftColorMap(drafts, existingMembers);
|
const draftColors = buildMemberDraftColorMap(drafts, existingMembers);
|
||||||
|
|
||||||
expect(draftColors.get('alice')).toBe(expectedColors.get('alice'));
|
expect(draftColors.get('alice')).toBe(expectedColors.get('alice'));
|
||||||
|
|
@ -132,11 +127,11 @@ describe('members editor editable input filtering', () => {
|
||||||
it('predicts the same colors as the team page for brand-new draft members', () => {
|
it('predicts the same colors as the team page for brand-new draft members', () => {
|
||||||
const drafts = ['alice', 'tom', 'bob'].map((name) => createMemberDraft({ name }));
|
const drafts = ['alice', 'tom', 'bob'].map((name) => createMemberDraft({ name }));
|
||||||
|
|
||||||
const expectedColors = buildMemberColorMap(
|
const expectedColors = buildTeamMemberColorMap(
|
||||||
drafts.map((draft) => ({
|
drafts.map((draft) => ({
|
||||||
name: draft.name,
|
name: draft.name,
|
||||||
color: getMemberColorByName(draft.name),
|
})),
|
||||||
}))
|
{ preferProvidedColors: false }
|
||||||
);
|
);
|
||||||
const draftColors = buildMemberDraftColorMap(drafts);
|
const draftColors = buildMemberDraftColorMap(drafts);
|
||||||
|
|
||||||
|
|
@ -145,16 +140,32 @@ describe('members editor editable input filtering', () => {
|
||||||
expect(draftColors.get('bob')).toBe(expectedColors.get('bob'));
|
expect(draftColors.get('bob')).toBe(expectedColors.get('bob'));
|
||||||
});
|
});
|
||||||
|
|
||||||
it('preserves explicit existing colors in edit and launch dialogs', () => {
|
it('preserves the resolved team colors in edit and launch dialogs', () => {
|
||||||
const existingMembers = [
|
const existingMembers = [
|
||||||
{ name: 'alice', color: 'blue' },
|
{ name: 'alice', color: getMemberColorByName('alice') },
|
||||||
{ name: 'bob', color: 'pink' },
|
{ name: 'bob', color: getMemberColorByName('bob') },
|
||||||
|
{ name: 'tom', color: getMemberColorByName('tom') },
|
||||||
];
|
];
|
||||||
const drafts = existingMembers.map((member) => createMemberDraft({ name: member.name }));
|
const drafts = existingMembers.map((member) => createMemberDraft({ name: member.name }));
|
||||||
|
|
||||||
const draftColors = buildMemberDraftColorMap(drafts, existingMembers);
|
const draftColors = buildMemberDraftColorMap(drafts, existingMembers);
|
||||||
|
|
||||||
|
expect(draftColors.get('alice')).toBe(existingMembers[0].color);
|
||||||
|
expect(draftColors.get('bob')).toBe(existingMembers[1].color);
|
||||||
|
expect(draftColors.get('tom')).toBe(existingMembers[2].color);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('prefers an explicit resolved member color map from the team screen', () => {
|
||||||
|
const existingMembers = [{ name: 'alice', color: 'brick' }, { name: 'tom', color: 'forest' }];
|
||||||
|
const drafts = existingMembers.map((member) => createMemberDraft({ name: member.name }));
|
||||||
|
const resolvedColorMap = new Map<string, string>([
|
||||||
|
['alice', 'blue'],
|
||||||
|
['tom', 'saffron'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
const draftColors = buildMemberDraftColorMap(drafts, existingMembers, resolvedColorMap);
|
||||||
|
|
||||||
expect(draftColors.get('alice')).toBe('blue');
|
expect(draftColors.get('alice')).toBe('blue');
|
||||||
expect(draftColors.get('bob')).toBe('pink');
|
expect(draftColors.get('tom')).toBe('saffron');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -30,6 +30,20 @@ describe('getTeamColorSet', () => {
|
||||||
expect(getTeamColorSet('purple').border).toBe('#a855f7');
|
expect(getTeamColorSet('purple').border).toBe('#a855f7');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('resolves curated member palette colors for the first roster slots', () => {
|
||||||
|
expect(getTeamColorSet('saffron').border).toBe('#eab308');
|
||||||
|
expect(getTeamColorSet('turquoise').border).toBe('#14b8a6');
|
||||||
|
expect(getTeamColorSet('brick').border).toBe('#ef4444');
|
||||||
|
expect(getTeamColorSet('indigo').border).toBe('#8b5cf6');
|
||||||
|
expect(getTeamColorSet('forest').border).toBe('#22c55e');
|
||||||
|
expect(getTeamColorSet('apricot').border).toBe('#fb923c');
|
||||||
|
expect(getTeamColorSet('rose').border).toBe('#f43f5e');
|
||||||
|
expect(getTeamColorSet('cerulean').border).toBe('#38bdf8');
|
||||||
|
expect(getTeamColorSet('olive').border).toBe('#84cc16');
|
||||||
|
expect(getTeamColorSet('copper').border).toBe('#b45309');
|
||||||
|
expect(getTeamColorSet('steel').border).toBe('#64748b');
|
||||||
|
});
|
||||||
|
|
||||||
it('is case-insensitive for named colors', () => {
|
it('is case-insensitive for named colors', () => {
|
||||||
expect(getTeamColorSet('Green')).toEqual(getTeamColorSet('green'));
|
expect(getTeamColorSet('Green')).toEqual(getTeamColorSet('green'));
|
||||||
expect(getTeamColorSet('BLUE')).toEqual(getTeamColorSet('blue'));
|
expect(getTeamColorSet('BLUE')).toEqual(getTeamColorSet('blue'));
|
||||||
|
|
|
||||||
45
test/shared/utils/teamMemberColors.test.ts
Normal file
45
test/shared/utils/teamMemberColors.test.ts
Normal file
|
|
@ -0,0 +1,45 @@
|
||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
|
import { getMemberColorByName, TEAM_LEAD_MEMBER_COLOR_ID } from '@shared/constants/memberColors';
|
||||||
|
import {
|
||||||
|
buildTeamMemberColorMap,
|
||||||
|
resolveTeamLeadColorName,
|
||||||
|
resolveTeamMemberColorName,
|
||||||
|
} from '@shared/utils/teamMemberColors';
|
||||||
|
|
||||||
|
describe('buildTeamMemberColorMap', () => {
|
||||||
|
it('assigns the high-contrast palette order to active teammates', () => {
|
||||||
|
const members = [{ name: 'alice' }, { name: 'tom' }, { name: 'bob' }, { name: 'atlas' }];
|
||||||
|
|
||||||
|
const colorMap = buildTeamMemberColorMap(members, { preferProvidedColors: false });
|
||||||
|
|
||||||
|
expect(colorMap.get('alice')).toBe('blue');
|
||||||
|
expect(colorMap.get('tom')).toBe('saffron');
|
||||||
|
expect(colorMap.get('bob')).toBe('turquoise');
|
||||||
|
expect(colorMap.get('atlas')).toBe('brick');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not let the lead consume the teammate palette order', () => {
|
||||||
|
const members = [
|
||||||
|
{ name: 'team-lead', agentType: 'team-lead' as const },
|
||||||
|
{ name: 'alice' },
|
||||||
|
{ name: 'tom' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const colorMap = buildTeamMemberColorMap(members, { preferProvidedColors: false });
|
||||||
|
|
||||||
|
expect(colorMap.get('team-lead')).toBeDefined();
|
||||||
|
expect(colorMap.get('alice')).toBe('blue');
|
||||||
|
expect(colorMap.get('tom')).toBe('saffron');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('resolves standalone lead previews through the same shared roster pipeline', () => {
|
||||||
|
expect(resolveTeamLeadColorName()).toBe(
|
||||||
|
resolveTeamMemberColorName(
|
||||||
|
{ name: TEAM_LEAD_MEMBER_COLOR_ID, agentType: 'team-lead' },
|
||||||
|
{ preferProvidedColors: false }
|
||||||
|
)
|
||||||
|
);
|
||||||
|
expect(resolveTeamLeadColorName()).not.toBe(getMemberColorByName('lead'));
|
||||||
|
});
|
||||||
|
});
|
||||||
Loading…
Reference in a new issue