feat(team): support mixed runtime lanes and improve preflight UX
This commit is contained in:
parent
185789cc0a
commit
5ab14682a2
81 changed files with 14481 additions and 809 deletions
1858
docs/research/mixed-team-per-member-runtime-lanes-plan.md
Normal file
1858
docs/research/mixed-team-per-member-runtime-lanes-plan.md
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -454,6 +454,20 @@
|
|||
"tool_use_system_prompt_tokens": 346,
|
||||
"supports_native_structured_output": true
|
||||
},
|
||||
"anthropic.claude-mythos-preview": {
|
||||
"input_cost_per_token": 0,
|
||||
"output_cost_per_token": 0,
|
||||
"litellm_provider": "bedrock",
|
||||
"max_input_tokens": 1000000,
|
||||
"max_output_tokens": 128000,
|
||||
"max_tokens": 128000,
|
||||
"mode": "chat",
|
||||
"supports_function_calling": true,
|
||||
"supports_vision": true,
|
||||
"supports_prompt_caching": false,
|
||||
"supports_reasoning": true,
|
||||
"supports_tool_choice": true
|
||||
},
|
||||
"global.anthropic.claude-opus-4-7": {
|
||||
"cache_creation_input_token_cost": 0.00000625,
|
||||
"cache_read_input_token_cost": 5e-7,
|
||||
|
|
|
|||
|
|
@ -0,0 +1,228 @@
|
|||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { buildMixedPersistedLaunchSnapshot } from '../buildMixedPersistedLaunchSnapshot';
|
||||
|
||||
describe('buildMixedPersistedLaunchSnapshot', () => {
|
||||
it('records bootstrapExpectedMembers when a secondary lane extends the expected roster', () => {
|
||||
const snapshot = buildMixedPersistedLaunchSnapshot({
|
||||
teamName: 'mixed-team',
|
||||
launchPhase: 'active',
|
||||
updatedAt: '2026-04-22T10:00:00.000Z',
|
||||
leadDefaults: {
|
||||
providerId: 'codex',
|
||||
providerBackendId: 'codex-native',
|
||||
selectedFastMode: 'off',
|
||||
resolvedFastMode: false,
|
||||
launchIdentity: null,
|
||||
},
|
||||
primaryMembers: [{ name: 'alice', providerId: 'codex', model: 'gpt-5.4', effort: 'high' }],
|
||||
primaryStatuses: {
|
||||
alice: {
|
||||
launchState: 'confirmed_alive',
|
||||
status: 'online',
|
||||
agentToolAccepted: true,
|
||||
runtimeAlive: true,
|
||||
bootstrapConfirmed: true,
|
||||
hardFailure: false,
|
||||
livenessSource: 'heartbeat',
|
||||
firstSpawnAcceptedAt: '2026-04-22T09:59:00.000Z',
|
||||
lastHeartbeatAt: '2026-04-22T09:59:30.000Z',
|
||||
updatedAt: '2026-04-22T10:00:00.000Z',
|
||||
} as never,
|
||||
},
|
||||
secondaryMembers: [
|
||||
{
|
||||
laneId: 'secondary:opencode:bob',
|
||||
member: {
|
||||
name: 'bob',
|
||||
providerId: 'opencode',
|
||||
model: 'minimax-m2.5-free',
|
||||
effort: 'medium',
|
||||
},
|
||||
leadDefaults: {
|
||||
providerId: 'codex',
|
||||
providerBackendId: 'codex-native',
|
||||
selectedFastMode: 'off',
|
||||
resolvedFastMode: false,
|
||||
launchIdentity: null,
|
||||
},
|
||||
pendingReason: 'Queued for OpenCode secondary lane launch.',
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(snapshot.expectedMembers).toEqual(['alice', 'bob']);
|
||||
expect(snapshot.bootstrapExpectedMembers).toEqual(['alice']);
|
||||
expect(snapshot.members.alice).toMatchObject({
|
||||
laneId: 'primary',
|
||||
laneKind: 'primary',
|
||||
laneOwnerProviderId: 'codex',
|
||||
launchState: 'confirmed_alive',
|
||||
});
|
||||
expect(snapshot.members.bob).toMatchObject({
|
||||
providerId: 'opencode',
|
||||
laneId: 'secondary:opencode:bob',
|
||||
laneKind: 'secondary',
|
||||
laneOwnerProviderId: 'opencode',
|
||||
launchState: 'starting',
|
||||
});
|
||||
expect(snapshot.summary).toEqual({
|
||||
confirmedCount: 1,
|
||||
pendingCount: 1,
|
||||
failedCount: 0,
|
||||
runtimeAlivePendingCount: 0,
|
||||
});
|
||||
expect(snapshot.teamLaunchState).toBe('partial_pending');
|
||||
});
|
||||
|
||||
it('marks the team clean_success once the secondary lane confirms bootstrap', () => {
|
||||
const snapshot = buildMixedPersistedLaunchSnapshot({
|
||||
teamName: 'mixed-team',
|
||||
launchPhase: 'finished',
|
||||
updatedAt: '2026-04-22T10:05:00.000Z',
|
||||
leadDefaults: {
|
||||
providerId: 'codex',
|
||||
providerBackendId: 'codex-native',
|
||||
selectedFastMode: 'off',
|
||||
resolvedFastMode: false,
|
||||
launchIdentity: null,
|
||||
},
|
||||
primaryMembers: [{ name: 'alice', providerId: 'codex', model: 'gpt-5.4', effort: 'high' }],
|
||||
primaryStatuses: {
|
||||
alice: {
|
||||
launchState: 'confirmed_alive',
|
||||
status: 'online',
|
||||
agentToolAccepted: true,
|
||||
runtimeAlive: true,
|
||||
bootstrapConfirmed: true,
|
||||
hardFailure: false,
|
||||
livenessSource: 'heartbeat',
|
||||
firstSpawnAcceptedAt: '2026-04-22T10:00:00.000Z',
|
||||
lastHeartbeatAt: '2026-04-22T10:01:00.000Z',
|
||||
updatedAt: '2026-04-22T10:05:00.000Z',
|
||||
} as never,
|
||||
},
|
||||
secondaryMembers: [
|
||||
{
|
||||
laneId: 'secondary:opencode:bob',
|
||||
member: {
|
||||
name: 'bob',
|
||||
providerId: 'opencode',
|
||||
model: 'minimax-m2.5-free',
|
||||
effort: 'medium',
|
||||
},
|
||||
leadDefaults: {
|
||||
providerId: 'codex',
|
||||
providerBackendId: 'codex-native',
|
||||
selectedFastMode: 'off',
|
||||
resolvedFastMode: false,
|
||||
launchIdentity: null,
|
||||
},
|
||||
evidence: {
|
||||
launchState: 'confirmed_alive',
|
||||
agentToolAccepted: true,
|
||||
runtimeAlive: true,
|
||||
bootstrapConfirmed: true,
|
||||
hardFailure: false,
|
||||
diagnostics: ['spawn accepted', 'late heartbeat received'],
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(snapshot.bootstrapExpectedMembers).toEqual(['alice']);
|
||||
expect(snapshot.members.bob).toMatchObject({
|
||||
providerId: 'opencode',
|
||||
laneId: 'secondary:opencode:bob',
|
||||
laneKind: 'secondary',
|
||||
launchState: 'confirmed_alive',
|
||||
runtimeAlive: true,
|
||||
bootstrapConfirmed: true,
|
||||
});
|
||||
expect(snapshot.summary).toEqual({
|
||||
confirmedCount: 2,
|
||||
pendingCount: 0,
|
||||
failedCount: 0,
|
||||
runtimeAlivePendingCount: 0,
|
||||
});
|
||||
expect(snapshot.teamLaunchState).toBe('clean_success');
|
||||
});
|
||||
|
||||
it('keeps a side-lane failure member-scoped instead of flattening it onto primary members', () => {
|
||||
const snapshot = buildMixedPersistedLaunchSnapshot({
|
||||
teamName: 'mixed-team',
|
||||
launchPhase: 'finished',
|
||||
updatedAt: '2026-04-22T10:05:00.000Z',
|
||||
leadDefaults: {
|
||||
providerId: 'codex',
|
||||
providerBackendId: 'codex-native',
|
||||
selectedFastMode: 'off',
|
||||
resolvedFastMode: false,
|
||||
launchIdentity: null,
|
||||
},
|
||||
primaryMembers: [{ name: 'alice', providerId: 'codex', model: 'gpt-5.4', effort: 'high' }],
|
||||
primaryStatuses: {
|
||||
alice: {
|
||||
launchState: 'confirmed_alive',
|
||||
status: 'online',
|
||||
agentToolAccepted: true,
|
||||
runtimeAlive: true,
|
||||
bootstrapConfirmed: true,
|
||||
hardFailure: false,
|
||||
livenessSource: 'heartbeat',
|
||||
firstSpawnAcceptedAt: '2026-04-22T10:00:00.000Z',
|
||||
lastHeartbeatAt: '2026-04-22T10:01:00.000Z',
|
||||
updatedAt: '2026-04-22T10:05:00.000Z',
|
||||
} as never,
|
||||
},
|
||||
secondaryMembers: [
|
||||
{
|
||||
laneId: 'secondary:opencode:bob',
|
||||
member: {
|
||||
name: 'bob',
|
||||
providerId: 'opencode',
|
||||
model: 'minimax-m2.5-free',
|
||||
effort: 'medium',
|
||||
},
|
||||
leadDefaults: {
|
||||
providerId: 'codex',
|
||||
providerBackendId: 'codex-native',
|
||||
selectedFastMode: 'off',
|
||||
resolvedFastMode: false,
|
||||
launchIdentity: null,
|
||||
},
|
||||
evidence: {
|
||||
launchState: 'failed_to_start',
|
||||
agentToolAccepted: true,
|
||||
runtimeAlive: false,
|
||||
bootstrapConfirmed: false,
|
||||
hardFailure: true,
|
||||
hardFailureReason: 'OpenCode side lane failed to attach',
|
||||
diagnostics: ['secondary runtime attach failed'],
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(snapshot.members.alice).toMatchObject({
|
||||
laneKind: 'primary',
|
||||
laneOwnerProviderId: 'codex',
|
||||
launchState: 'confirmed_alive',
|
||||
hardFailure: false,
|
||||
});
|
||||
expect(snapshot.members.bob).toMatchObject({
|
||||
laneKind: 'secondary',
|
||||
laneOwnerProviderId: 'opencode',
|
||||
launchState: 'failed_to_start',
|
||||
hardFailure: true,
|
||||
hardFailureReason: 'OpenCode side lane failed to attach',
|
||||
});
|
||||
expect(snapshot.summary).toEqual({
|
||||
confirmedCount: 1,
|
||||
pendingCount: 0,
|
||||
failedCount: 1,
|
||||
runtimeAlivePendingCount: 0,
|
||||
});
|
||||
expect(snapshot.teamLaunchState).toBe('partial_failure');
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,141 @@
|
|||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { planTeamRuntimeLanes } from '../planTeamRuntimeLanes';
|
||||
|
||||
describe('planTeamRuntimeLanes', () => {
|
||||
it('keeps non-OpenCode members on the primary lane', () => {
|
||||
const result = planTeamRuntimeLanes({
|
||||
leadProviderId: 'codex',
|
||||
members: [
|
||||
{ name: 'alice', providerId: 'codex', model: 'gpt-5.4' },
|
||||
{ name: 'bob', providerId: 'gemini', model: 'gemini-2.5-pro' },
|
||||
],
|
||||
});
|
||||
|
||||
expect(result).toMatchObject({
|
||||
ok: true,
|
||||
plan: {
|
||||
mode: 'primary_only',
|
||||
primaryMembers: [
|
||||
expect.objectContaining({ name: 'alice', providerId: 'codex' }),
|
||||
expect.objectContaining({ name: 'bob', providerId: 'gemini' }),
|
||||
],
|
||||
sideLanes: [],
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('creates one secondary OpenCode lane per OpenCode teammate', () => {
|
||||
const result = planTeamRuntimeLanes({
|
||||
leadProviderId: 'codex',
|
||||
members: [
|
||||
{ name: 'alice', providerId: 'codex', model: 'gpt-5.4' },
|
||||
{ name: 'bob', providerId: 'opencode', model: 'minimax-m2.5-free' },
|
||||
{ name: 'tom', providerId: 'opencode', model: 'nemotron-3-super-free' },
|
||||
],
|
||||
});
|
||||
|
||||
expect(result).toMatchObject({
|
||||
ok: true,
|
||||
plan: {
|
||||
mode: 'mixed_opencode_side_lanes',
|
||||
primaryMembers: [expect.objectContaining({ name: 'alice', providerId: 'codex' })],
|
||||
sideLanes: [
|
||||
{
|
||||
laneId: 'secondary:opencode:bob',
|
||||
providerId: 'opencode',
|
||||
member: expect.objectContaining({
|
||||
name: 'bob',
|
||||
providerId: 'opencode',
|
||||
model: 'minimax-m2.5-free',
|
||||
}),
|
||||
},
|
||||
{
|
||||
laneId: 'secondary:opencode:tom',
|
||||
providerId: 'opencode',
|
||||
member: expect.objectContaining({
|
||||
name: 'tom',
|
||||
providerId: 'opencode',
|
||||
model: 'nemotron-3-super-free',
|
||||
}),
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('creates a secondary OpenCode lane for an Anthropic-led mixed team', () => {
|
||||
const result = planTeamRuntimeLanes({
|
||||
leadProviderId: 'anthropic',
|
||||
members: [
|
||||
{ name: 'alice', providerId: 'anthropic', model: 'claude-opus-4-1' },
|
||||
{ name: 'bob', providerId: 'opencode', model: 'minimax-m2.5-free' },
|
||||
],
|
||||
});
|
||||
|
||||
expect(result).toMatchObject({
|
||||
ok: true,
|
||||
plan: {
|
||||
mode: 'mixed_opencode_side_lanes',
|
||||
primaryMembers: [expect.objectContaining({ name: 'alice', providerId: 'anthropic' })],
|
||||
sideLanes: [
|
||||
{
|
||||
laneId: 'secondary:opencode:bob',
|
||||
providerId: 'opencode',
|
||||
member: expect.objectContaining({
|
||||
name: 'bob',
|
||||
providerId: 'opencode',
|
||||
model: 'minimax-m2.5-free',
|
||||
}),
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('creates a secondary OpenCode lane for a Gemini-led mixed team', () => {
|
||||
const result = planTeamRuntimeLanes({
|
||||
leadProviderId: 'gemini',
|
||||
members: [
|
||||
{ name: 'alice', providerId: 'gemini', model: 'gemini-2.5-pro' },
|
||||
{ name: 'bob', providerId: 'opencode', model: 'minimax-m2.5-free' },
|
||||
],
|
||||
});
|
||||
|
||||
expect(result).toMatchObject({
|
||||
ok: true,
|
||||
plan: {
|
||||
mode: 'mixed_opencode_side_lanes',
|
||||
primaryMembers: [expect.objectContaining({ name: 'alice', providerId: 'gemini' })],
|
||||
sideLanes: [
|
||||
{
|
||||
laneId: 'secondary:opencode:bob',
|
||||
providerId: 'opencode',
|
||||
member: expect.objectContaining({
|
||||
name: 'bob',
|
||||
providerId: 'opencode',
|
||||
model: 'minimax-m2.5-free',
|
||||
}),
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('rejects OpenCode-led mixed teams in this phase', () => {
|
||||
const result = planTeamRuntimeLanes({
|
||||
leadProviderId: 'opencode',
|
||||
members: [
|
||||
{ name: 'alice', providerId: 'opencode', model: 'minimax-m2.5-free' },
|
||||
{ name: 'bob', providerId: 'codex', model: 'gpt-5.4' },
|
||||
],
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
ok: false,
|
||||
reason: 'unsupported_opencode_led_mixed_team',
|
||||
message:
|
||||
'Mixed teams with an OpenCode lead are not supported in this phase. Keep the team lead on Anthropic, Codex, or Gemini when you mix OpenCode with other providers.',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,324 @@
|
|||
import { isLeadMember } from '@shared/utils/leadDetection';
|
||||
import { migrateProviderBackendId } from '@shared/utils/providerBackend';
|
||||
import { normalizeOptionalTeamProviderId } from '@shared/utils/teamProvider';
|
||||
|
||||
import type {
|
||||
MemberLaunchState,
|
||||
MemberSpawnLivenessSource,
|
||||
MemberSpawnStatusEntry,
|
||||
PersistedTeamLaunchMemberSources,
|
||||
PersistedTeamLaunchMemberState,
|
||||
PersistedTeamLaunchPhase,
|
||||
PersistedTeamLaunchSnapshot,
|
||||
ProviderModelLaunchIdentity,
|
||||
TeamFastMode,
|
||||
TeamProviderBackendId,
|
||||
TeamProviderId,
|
||||
TeamProvisioningMemberInput,
|
||||
} from '@shared/types';
|
||||
|
||||
export interface MixedLaneLeadRuntimeDefaults {
|
||||
providerId: TeamProviderId;
|
||||
providerBackendId?: TeamProviderBackendId | null;
|
||||
selectedFastMode?: TeamFastMode;
|
||||
resolvedFastMode?: boolean | null;
|
||||
launchIdentity?: ProviderModelLaunchIdentity | null;
|
||||
}
|
||||
|
||||
export interface MixedSecondaryLaneMemberStateInput {
|
||||
laneId: string;
|
||||
member: TeamProvisioningMemberInput;
|
||||
leadDefaults: MixedLaneLeadRuntimeDefaults;
|
||||
evidence?: {
|
||||
launchState?: MemberLaunchState;
|
||||
agentToolAccepted?: boolean;
|
||||
runtimeAlive?: boolean;
|
||||
bootstrapConfirmed?: boolean;
|
||||
hardFailure?: boolean;
|
||||
hardFailureReason?: string;
|
||||
diagnostics?: string[];
|
||||
} | null;
|
||||
pendingReason?: string;
|
||||
}
|
||||
|
||||
function deriveMemberLaunchState(params: {
|
||||
hardFailure?: boolean;
|
||||
bootstrapConfirmed?: boolean;
|
||||
runtimeAlive?: boolean;
|
||||
agentToolAccepted?: boolean;
|
||||
}): MemberLaunchState {
|
||||
if (params.hardFailure) {
|
||||
return 'failed_to_start';
|
||||
}
|
||||
if (params.bootstrapConfirmed) {
|
||||
return 'confirmed_alive';
|
||||
}
|
||||
if (params.runtimeAlive || params.agentToolAccepted) {
|
||||
return 'runtime_pending_bootstrap';
|
||||
}
|
||||
return 'starting';
|
||||
}
|
||||
|
||||
function buildDiagnostics(
|
||||
member: Pick<
|
||||
PersistedTeamLaunchMemberState,
|
||||
'agentToolAccepted' | 'runtimeAlive' | 'bootstrapConfirmed' | 'hardFailureReason' | 'sources'
|
||||
>
|
||||
): string[] {
|
||||
const diagnostics: string[] = [];
|
||||
if (member.agentToolAccepted) diagnostics.push('spawn accepted');
|
||||
if (member.runtimeAlive) diagnostics.push('runtime alive');
|
||||
if (member.bootstrapConfirmed) diagnostics.push('late heartbeat received');
|
||||
if (member.runtimeAlive && !member.bootstrapConfirmed) {
|
||||
diagnostics.push('waiting for teammate check-in');
|
||||
}
|
||||
if (member.hardFailureReason)
|
||||
diagnostics.push(`hard failure reason: ${member.hardFailureReason}`);
|
||||
if (member.sources?.duplicateRespawnBlocked) diagnostics.push('respawn blocked as duplicate');
|
||||
if (member.sources?.configDrift) diagnostics.push('config drift detected');
|
||||
return diagnostics;
|
||||
}
|
||||
|
||||
function createSourcesFromStatus(
|
||||
status: Pick<MemberSpawnStatusEntry, 'livenessSource' | 'runtimeAlive'>
|
||||
): PersistedTeamLaunchMemberSources | undefined {
|
||||
const sources: PersistedTeamLaunchMemberSources = {};
|
||||
if (status.livenessSource === 'heartbeat') {
|
||||
sources.nativeHeartbeat = true;
|
||||
sources.inboxHeartbeat = true;
|
||||
}
|
||||
if (status.livenessSource === 'process' || status.runtimeAlive) {
|
||||
sources.processAlive = true;
|
||||
}
|
||||
return Object.values(sources).some(Boolean) ? sources : undefined;
|
||||
}
|
||||
|
||||
function normalizeFastMode(value: TeamFastMode | undefined): TeamFastMode | undefined {
|
||||
return value === 'inherit' || value === 'on' || value === 'off' ? value : undefined;
|
||||
}
|
||||
|
||||
function createPrimaryLaneMemberState(params: {
|
||||
member: TeamProvisioningMemberInput;
|
||||
status?: MemberSpawnStatusEntry;
|
||||
updatedAt: string;
|
||||
leadDefaults: MixedLaneLeadRuntimeDefaults;
|
||||
}): PersistedTeamLaunchMemberState {
|
||||
const providerId =
|
||||
normalizeOptionalTeamProviderId(params.member.providerId) ?? params.leadDefaults.providerId;
|
||||
const runtime = params.status;
|
||||
const sources = runtime ? createSourcesFromStatus(runtime) : undefined;
|
||||
const base: PersistedTeamLaunchMemberState = {
|
||||
name: params.member.name.trim(),
|
||||
providerId,
|
||||
providerBackendId:
|
||||
migrateProviderBackendId(providerId, params.member.providerBackendId) ??
|
||||
(providerId === params.leadDefaults.providerId
|
||||
? (params.leadDefaults.providerBackendId ?? undefined)
|
||||
: undefined),
|
||||
model: params.member.model?.trim() || undefined,
|
||||
effort: params.member.effort,
|
||||
selectedFastMode:
|
||||
normalizeFastMode(params.member.fastMode) ??
|
||||
(providerId === params.leadDefaults.providerId
|
||||
? normalizeFastMode(params.leadDefaults.selectedFastMode)
|
||||
: undefined),
|
||||
resolvedFastMode:
|
||||
providerId === params.leadDefaults.providerId
|
||||
? (params.leadDefaults.resolvedFastMode ?? undefined)
|
||||
: undefined,
|
||||
laneId: 'primary',
|
||||
laneKind: 'primary',
|
||||
laneOwnerProviderId: params.leadDefaults.providerId,
|
||||
launchIdentity:
|
||||
providerId === params.leadDefaults.providerId
|
||||
? (params.leadDefaults.launchIdentity ?? undefined)
|
||||
: undefined,
|
||||
launchState:
|
||||
runtime?.launchState ??
|
||||
deriveMemberLaunchState({
|
||||
hardFailure: runtime?.hardFailure,
|
||||
bootstrapConfirmed: runtime?.bootstrapConfirmed,
|
||||
runtimeAlive: runtime?.runtimeAlive,
|
||||
agentToolAccepted: runtime?.agentToolAccepted,
|
||||
}),
|
||||
agentToolAccepted: runtime?.agentToolAccepted === true,
|
||||
runtimeAlive: runtime?.runtimeAlive === true,
|
||||
bootstrapConfirmed: runtime?.bootstrapConfirmed === true,
|
||||
hardFailure: runtime?.hardFailure === true || runtime?.launchState === 'failed_to_start',
|
||||
hardFailureReason: runtime?.hardFailureReason ?? runtime?.error,
|
||||
firstSpawnAcceptedAt: runtime?.firstSpawnAcceptedAt,
|
||||
lastHeartbeatAt: runtime?.lastHeartbeatAt,
|
||||
lastRuntimeAliveAt: runtime?.runtimeAlive ? params.updatedAt : undefined,
|
||||
lastEvaluatedAt: runtime?.updatedAt ?? params.updatedAt,
|
||||
sources,
|
||||
diagnostics: undefined,
|
||||
};
|
||||
base.diagnostics = buildDiagnostics(base);
|
||||
return base;
|
||||
}
|
||||
|
||||
function createSecondaryLaneMemberState(
|
||||
params: MixedSecondaryLaneMemberStateInput & { updatedAt: string }
|
||||
): PersistedTeamLaunchMemberState {
|
||||
const providerId =
|
||||
normalizeOptionalTeamProviderId(params.member.providerId) ?? params.leadDefaults.providerId;
|
||||
const evidence = params.evidence;
|
||||
const hardFailureReason =
|
||||
evidence?.hardFailureReason ??
|
||||
(!evidence && params.pendingReason ? params.pendingReason : undefined);
|
||||
const launchState =
|
||||
evidence?.launchState ??
|
||||
deriveMemberLaunchState({
|
||||
hardFailure: evidence?.hardFailure,
|
||||
bootstrapConfirmed: evidence?.bootstrapConfirmed,
|
||||
runtimeAlive: evidence?.runtimeAlive,
|
||||
agentToolAccepted: evidence?.agentToolAccepted,
|
||||
});
|
||||
const base: PersistedTeamLaunchMemberState = {
|
||||
name: params.member.name.trim(),
|
||||
providerId,
|
||||
providerBackendId:
|
||||
migrateProviderBackendId(providerId, params.member.providerBackendId) ??
|
||||
(providerId === params.leadDefaults.providerId
|
||||
? (params.leadDefaults.providerBackendId ?? undefined)
|
||||
: undefined),
|
||||
model: params.member.model?.trim() || undefined,
|
||||
effort: params.member.effort,
|
||||
selectedFastMode:
|
||||
normalizeFastMode(params.member.fastMode) ??
|
||||
(providerId === params.leadDefaults.providerId
|
||||
? normalizeFastMode(params.leadDefaults.selectedFastMode)
|
||||
: undefined),
|
||||
resolvedFastMode:
|
||||
providerId === params.leadDefaults.providerId
|
||||
? (params.leadDefaults.resolvedFastMode ?? undefined)
|
||||
: undefined,
|
||||
laneId: params.laneId,
|
||||
laneKind: 'secondary',
|
||||
laneOwnerProviderId: providerId,
|
||||
launchState,
|
||||
agentToolAccepted: evidence?.agentToolAccepted === true,
|
||||
runtimeAlive: evidence?.runtimeAlive === true,
|
||||
bootstrapConfirmed: evidence?.bootstrapConfirmed === true,
|
||||
hardFailure: evidence?.hardFailure === true || launchState === 'failed_to_start',
|
||||
hardFailureReason,
|
||||
firstSpawnAcceptedAt: evidence?.agentToolAccepted ? params.updatedAt : undefined,
|
||||
lastHeartbeatAt: evidence?.bootstrapConfirmed ? params.updatedAt : undefined,
|
||||
lastRuntimeAliveAt: evidence?.runtimeAlive ? params.updatedAt : undefined,
|
||||
lastEvaluatedAt: params.updatedAt,
|
||||
sources: evidence?.runtimeAlive
|
||||
? {
|
||||
processAlive: true,
|
||||
nativeHeartbeat: evidence.bootstrapConfirmed === true || undefined,
|
||||
inboxHeartbeat: evidence.bootstrapConfirmed === true || undefined,
|
||||
}
|
||||
: undefined,
|
||||
diagnostics: evidence?.diagnostics?.length ? [...evidence.diagnostics] : undefined,
|
||||
};
|
||||
base.diagnostics = base.diagnostics?.length ? base.diagnostics : buildDiagnostics(base);
|
||||
return base;
|
||||
}
|
||||
|
||||
function summarizeMembers(
|
||||
expectedMembers: readonly string[],
|
||||
members: Record<string, PersistedTeamLaunchMemberState>
|
||||
): PersistedTeamLaunchSnapshot['summary'] {
|
||||
let confirmedCount = 0;
|
||||
let pendingCount = 0;
|
||||
let failedCount = 0;
|
||||
let runtimeAlivePendingCount = 0;
|
||||
|
||||
for (const memberName of expectedMembers) {
|
||||
const entry = members[memberName];
|
||||
if (!entry) {
|
||||
pendingCount += 1;
|
||||
continue;
|
||||
}
|
||||
if (entry.launchState === 'confirmed_alive') {
|
||||
confirmedCount += 1;
|
||||
continue;
|
||||
}
|
||||
if (entry.launchState === 'failed_to_start') {
|
||||
failedCount += 1;
|
||||
continue;
|
||||
}
|
||||
pendingCount += 1;
|
||||
if (entry.runtimeAlive) {
|
||||
runtimeAlivePendingCount += 1;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
confirmedCount,
|
||||
pendingCount,
|
||||
failedCount,
|
||||
runtimeAlivePendingCount,
|
||||
};
|
||||
}
|
||||
|
||||
function deriveTeamLaunchState(
|
||||
summary: PersistedTeamLaunchSnapshot['summary']
|
||||
): PersistedTeamLaunchSnapshot['teamLaunchState'] {
|
||||
if (summary.failedCount > 0) {
|
||||
return 'partial_failure';
|
||||
}
|
||||
if (summary.pendingCount > 0) {
|
||||
return 'partial_pending';
|
||||
}
|
||||
return 'clean_success';
|
||||
}
|
||||
|
||||
export function buildMixedPersistedLaunchSnapshot(params: {
|
||||
teamName: string;
|
||||
leadSessionId?: string;
|
||||
launchPhase: PersistedTeamLaunchPhase;
|
||||
leadDefaults: MixedLaneLeadRuntimeDefaults;
|
||||
primaryMembers: readonly TeamProvisioningMemberInput[];
|
||||
primaryStatuses: Record<string, MemberSpawnStatusEntry>;
|
||||
secondaryMembers?: readonly MixedSecondaryLaneMemberStateInput[];
|
||||
updatedAt?: string;
|
||||
}): PersistedTeamLaunchSnapshot {
|
||||
const updatedAt = params.updatedAt ?? new Date().toISOString();
|
||||
const primaryExpectedMembers = params.primaryMembers
|
||||
.map((member) => member.name.trim())
|
||||
.filter((name) => name.length > 0 && name !== 'user' && !isLeadMember({ name }));
|
||||
const members: Record<string, PersistedTeamLaunchMemberState> = {};
|
||||
|
||||
for (const member of params.primaryMembers) {
|
||||
const trimmedName = member.name.trim();
|
||||
if (!trimmedName || trimmedName === 'user' || isLeadMember({ name: trimmedName })) continue;
|
||||
members[trimmedName] = createPrimaryLaneMemberState({
|
||||
member,
|
||||
status: params.primaryStatuses[trimmedName],
|
||||
updatedAt,
|
||||
leadDefaults: params.leadDefaults,
|
||||
});
|
||||
}
|
||||
|
||||
for (const laneMember of params.secondaryMembers ?? []) {
|
||||
const trimmedName = laneMember.member.name.trim();
|
||||
if (!trimmedName || trimmedName === 'user' || isLeadMember({ name: trimmedName })) continue;
|
||||
members[trimmedName] = createSecondaryLaneMemberState({
|
||||
...laneMember,
|
||||
updatedAt,
|
||||
});
|
||||
}
|
||||
|
||||
const expectedMembers = Array.from(new Set([...primaryExpectedMembers, ...Object.keys(members)]));
|
||||
const summary = summarizeMembers(expectedMembers, members);
|
||||
|
||||
return {
|
||||
version: 2,
|
||||
teamName: params.teamName,
|
||||
updatedAt,
|
||||
...(params.leadSessionId ? { leadSessionId: params.leadSessionId } : {}),
|
||||
launchPhase: params.launchPhase,
|
||||
expectedMembers,
|
||||
...(primaryExpectedMembers.join('\u0000') !== expectedMembers.join('\u0000')
|
||||
? { bootstrapExpectedMembers: primaryExpectedMembers }
|
||||
: {}),
|
||||
members,
|
||||
summary,
|
||||
teamLaunchState: deriveTeamLaunchState(summary),
|
||||
};
|
||||
}
|
||||
|
|
@ -0,0 +1,195 @@
|
|||
import { normalizeOptionalTeamProviderId } from '@shared/utils/teamProvider';
|
||||
|
||||
import type {
|
||||
EffortLevel,
|
||||
TeamFastMode,
|
||||
TeamProviderBackendId,
|
||||
TeamProviderId,
|
||||
TeamProvisioningMemberInput,
|
||||
} from '@shared/types';
|
||||
|
||||
export interface RuntimeLanePlannerMemberInput {
|
||||
name: string;
|
||||
providerId?: TeamProviderId;
|
||||
providerBackendId?: TeamProviderBackendId;
|
||||
model?: string;
|
||||
effort?: EffortLevel;
|
||||
fastMode?: TeamFastMode;
|
||||
}
|
||||
|
||||
export interface PlannedRuntimeMember extends RuntimeLanePlannerMemberInput {
|
||||
providerId: TeamProviderId;
|
||||
}
|
||||
|
||||
export interface PlannedTeamMemberLaneIdentity {
|
||||
laneId: string;
|
||||
laneKind: 'primary' | 'secondary';
|
||||
laneOwnerProviderId: TeamProviderId;
|
||||
}
|
||||
|
||||
export type TeamRuntimeLanePlan =
|
||||
| {
|
||||
mode: 'primary_only';
|
||||
primaryMembers: PlannedRuntimeMember[];
|
||||
allMembers: PlannedRuntimeMember[];
|
||||
sideLanes: [];
|
||||
}
|
||||
| {
|
||||
mode: 'pure_opencode';
|
||||
primaryMembers: PlannedRuntimeMember[];
|
||||
allMembers: PlannedRuntimeMember[];
|
||||
sideLanes: [];
|
||||
}
|
||||
| {
|
||||
mode: 'mixed_opencode_side_lanes';
|
||||
primaryMembers: PlannedRuntimeMember[];
|
||||
allMembers: PlannedRuntimeMember[];
|
||||
sideLanes: Array<{
|
||||
laneId: string;
|
||||
providerId: 'opencode';
|
||||
member: PlannedRuntimeMember;
|
||||
}>;
|
||||
};
|
||||
|
||||
export type TeamRuntimeLanePlanErrorReason = 'unsupported_opencode_led_mixed_team';
|
||||
|
||||
export interface TeamRuntimeLanePlanError {
|
||||
ok: false;
|
||||
reason: TeamRuntimeLanePlanErrorReason;
|
||||
message: string;
|
||||
}
|
||||
|
||||
export interface TeamRuntimeLanePlanSuccess {
|
||||
ok: true;
|
||||
plan: TeamRuntimeLanePlan;
|
||||
}
|
||||
|
||||
export type TeamRuntimeLanePlanResult = TeamRuntimeLanePlanSuccess | TeamRuntimeLanePlanError;
|
||||
|
||||
function normalizeLeadProviderId(providerId: TeamProviderId | undefined): TeamProviderId {
|
||||
return normalizeOptionalTeamProviderId(providerId) ?? 'anthropic';
|
||||
}
|
||||
|
||||
function normalizePlannedMembers(
|
||||
members: readonly RuntimeLanePlannerMemberInput[],
|
||||
leadProviderId: TeamProviderId
|
||||
): PlannedRuntimeMember[] {
|
||||
return members
|
||||
.map((member) => ({
|
||||
...member,
|
||||
name: member.name.trim(),
|
||||
providerId: normalizeOptionalTeamProviderId(member.providerId) ?? leadProviderId,
|
||||
}))
|
||||
.filter((member) => member.name.length > 0);
|
||||
}
|
||||
|
||||
export function buildPlannedMemberLaneIdentity(params: {
|
||||
leadProviderId?: TeamProviderId;
|
||||
member: Pick<RuntimeLanePlannerMemberInput, 'name' | 'providerId'>;
|
||||
}): PlannedTeamMemberLaneIdentity {
|
||||
const leadProviderId = normalizeLeadProviderId(params.leadProviderId);
|
||||
const memberProviderId =
|
||||
normalizeOptionalTeamProviderId(params.member.providerId) ?? leadProviderId;
|
||||
const trimmedName = params.member.name.trim();
|
||||
|
||||
if (leadProviderId !== 'opencode' && memberProviderId === 'opencode') {
|
||||
return {
|
||||
laneId: `secondary:opencode:${trimmedName}`,
|
||||
laneKind: 'secondary',
|
||||
laneOwnerProviderId: 'opencode',
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
laneId: 'primary',
|
||||
laneKind: 'primary',
|
||||
laneOwnerProviderId: leadProviderId,
|
||||
};
|
||||
}
|
||||
|
||||
export function planTeamRuntimeLanes(params: {
|
||||
leadProviderId?: TeamProviderId;
|
||||
members: readonly RuntimeLanePlannerMemberInput[];
|
||||
}): TeamRuntimeLanePlanResult {
|
||||
const leadProviderId = normalizeLeadProviderId(params.leadProviderId);
|
||||
const allMembers = normalizePlannedMembers(params.members, leadProviderId);
|
||||
const openCodeMembers = allMembers.filter((member) => member.providerId === 'opencode');
|
||||
|
||||
if (leadProviderId === 'opencode') {
|
||||
const nonOpenCodeMembers = allMembers.filter((member) => member.providerId !== 'opencode');
|
||||
if (nonOpenCodeMembers.length > 0) {
|
||||
return {
|
||||
ok: false,
|
||||
reason: 'unsupported_opencode_led_mixed_team',
|
||||
message:
|
||||
'Mixed teams with an OpenCode lead are not supported in this phase. Keep the team lead on Anthropic, Codex, or Gemini when you mix OpenCode with other providers.',
|
||||
};
|
||||
}
|
||||
return {
|
||||
ok: true,
|
||||
plan: {
|
||||
mode: 'pure_opencode',
|
||||
primaryMembers: allMembers,
|
||||
allMembers,
|
||||
sideLanes: [],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
if (openCodeMembers.length === 0) {
|
||||
return {
|
||||
ok: true,
|
||||
plan: {
|
||||
mode: 'primary_only',
|
||||
primaryMembers: allMembers,
|
||||
allMembers,
|
||||
sideLanes: [],
|
||||
},
|
||||
};
|
||||
}
|
||||
return {
|
||||
ok: true,
|
||||
plan: {
|
||||
mode: 'mixed_opencode_side_lanes',
|
||||
primaryMembers: allMembers.filter((member) => member.providerId !== 'opencode'),
|
||||
allMembers,
|
||||
sideLanes: openCodeMembers.map((member) => ({
|
||||
laneId: buildPlannedMemberLaneIdentity({
|
||||
leadProviderId,
|
||||
member,
|
||||
}).laneId,
|
||||
providerId: 'opencode',
|
||||
member,
|
||||
})),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function isMixedOpenCodeSideLanePlan(
|
||||
plan: TeamRuntimeLanePlan
|
||||
): plan is Extract<TeamRuntimeLanePlan, { mode: 'mixed_opencode_side_lanes' }> {
|
||||
return plan.mode === 'mixed_opencode_side_lanes';
|
||||
}
|
||||
|
||||
export function isPureOpenCodeLanePlan(
|
||||
plan: TeamRuntimeLanePlan
|
||||
): plan is Extract<TeamRuntimeLanePlan, { mode: 'pure_opencode' }> {
|
||||
return plan.mode === 'pure_opencode';
|
||||
}
|
||||
|
||||
export function fromProvisioningMembers(
|
||||
leadProviderId: TeamProviderId | undefined,
|
||||
members: readonly TeamProvisioningMemberInput[]
|
||||
): TeamRuntimeLanePlanResult {
|
||||
return planTeamRuntimeLanes({
|
||||
leadProviderId,
|
||||
members: members.map((member) => ({
|
||||
name: member.name,
|
||||
providerId: normalizeOptionalTeamProviderId(member.providerId),
|
||||
providerBackendId: member.providerBackendId,
|
||||
model: member.model,
|
||||
effort: member.effort,
|
||||
fastMode: member.fastMode,
|
||||
})),
|
||||
});
|
||||
}
|
||||
|
|
@ -0,0 +1,50 @@
|
|||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { createTeamRuntimeLaneCoordinator } from '../createTeamRuntimeLaneCoordinator';
|
||||
|
||||
describe('createTeamRuntimeLaneCoordinator', () => {
|
||||
it('plans a mixed OpenCode side lane when the adapter is available', () => {
|
||||
const coordinator = createTeamRuntimeLaneCoordinator();
|
||||
|
||||
const plan = coordinator.planProvisioningMembers({
|
||||
leadProviderId: 'codex',
|
||||
hasOpenCodeRuntimeAdapter: true,
|
||||
members: [
|
||||
{ name: 'alice', providerId: 'codex', model: 'gpt-5.4' },
|
||||
{ name: 'tom', providerId: 'opencode', model: 'minimax-m2.5-free' },
|
||||
],
|
||||
});
|
||||
|
||||
expect(coordinator.isMixedSideLanePlan(plan)).toBe(true);
|
||||
expect(plan).toMatchObject({
|
||||
mode: 'mixed_opencode_side_lanes',
|
||||
primaryMembers: [{ name: 'alice', providerId: 'codex', model: 'gpt-5.4' }],
|
||||
sideLanes: [
|
||||
{
|
||||
laneId: 'secondary:opencode:tom',
|
||||
providerId: 'opencode',
|
||||
member: {
|
||||
name: 'tom',
|
||||
providerId: 'opencode',
|
||||
model: 'minimax-m2.5-free',
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it('rejects a mixed OpenCode side lane when the runtime adapter is unavailable', () => {
|
||||
const coordinator = createTeamRuntimeLaneCoordinator();
|
||||
|
||||
expect(() =>
|
||||
coordinator.planProvisioningMembers({
|
||||
leadProviderId: 'codex',
|
||||
hasOpenCodeRuntimeAdapter: false,
|
||||
members: [
|
||||
{ name: 'alice', providerId: 'codex', model: 'gpt-5.4' },
|
||||
{ name: 'tom', providerId: 'opencode', model: 'minimax-m2.5-free' },
|
||||
],
|
||||
})
|
||||
).toThrow('Mixed teams with OpenCode side lanes require the OpenCode runtime adapter');
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,43 @@
|
|||
import { buildMixedPersistedLaunchSnapshot } from '@features/team-runtime-lanes/core/domain/buildMixedPersistedLaunchSnapshot';
|
||||
import {
|
||||
fromProvisioningMembers,
|
||||
isMixedOpenCodeSideLanePlan,
|
||||
type TeamRuntimeLanePlan,
|
||||
} from '@features/team-runtime-lanes/core/domain/planTeamRuntimeLanes';
|
||||
|
||||
import type { PersistedTeamLaunchSnapshot, TeamCreateRequest, TeamProviderId } from '@shared/types';
|
||||
|
||||
export interface TeamRuntimeLaneCoordinator {
|
||||
planProvisioningMembers(params: {
|
||||
leadProviderId?: TeamProviderId;
|
||||
members: TeamCreateRequest['members'];
|
||||
hasOpenCodeRuntimeAdapter: boolean;
|
||||
}): TeamRuntimeLanePlan;
|
||||
buildAggregateLaunchSnapshot(
|
||||
params: Parameters<typeof buildMixedPersistedLaunchSnapshot>[0]
|
||||
): PersistedTeamLaunchSnapshot;
|
||||
isMixedSideLanePlan(plan: TeamRuntimeLanePlan): boolean;
|
||||
}
|
||||
|
||||
export function createTeamRuntimeLaneCoordinator(): TeamRuntimeLaneCoordinator {
|
||||
return {
|
||||
planProvisioningMembers(params) {
|
||||
const lanePlan = fromProvisioningMembers(params.leadProviderId, params.members);
|
||||
if (!lanePlan.ok) {
|
||||
throw new Error(lanePlan.message);
|
||||
}
|
||||
if (isMixedOpenCodeSideLanePlan(lanePlan.plan) && !params.hasOpenCodeRuntimeAdapter) {
|
||||
throw new Error(
|
||||
'Mixed teams with OpenCode side lanes require the OpenCode runtime adapter to be registered.'
|
||||
);
|
||||
}
|
||||
return lanePlan.plan;
|
||||
},
|
||||
buildAggregateLaunchSnapshot(params) {
|
||||
return buildMixedPersistedLaunchSnapshot(params);
|
||||
},
|
||||
isMixedSideLanePlan(plan) {
|
||||
return isMixedOpenCodeSideLanePlan(plan);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
@ -95,9 +95,10 @@ import {
|
|||
formatEffortLevelListForProvider,
|
||||
isTeamEffortLevelForProvider,
|
||||
} from '@shared/utils/effortLevels';
|
||||
import { isLeadMember } from '@shared/utils/leadDetection';
|
||||
import { isTeamProviderBackendId, migrateProviderBackendId } from '@shared/utils/providerBackend';
|
||||
import { isRateLimitMessage } from '@shared/utils/rateLimitDetector';
|
||||
import { isTeamProviderId } from '@shared/utils/teamProvider';
|
||||
import { isTeamProviderId, normalizeOptionalTeamProviderId } from '@shared/utils/teamProvider';
|
||||
import {
|
||||
buildStandaloneSlashCommandMeta,
|
||||
parseStandaloneSlashCommand,
|
||||
|
|
@ -195,6 +196,7 @@ import type {
|
|||
TeamFastMode,
|
||||
TeamProviderBackendId,
|
||||
TeamProviderId,
|
||||
TeamProvisioningModelVerificationMode,
|
||||
TeamProvisioningPrepareResult,
|
||||
TeamProvisioningProgress,
|
||||
TeamSummary,
|
||||
|
|
@ -207,6 +209,7 @@ import type {
|
|||
UpdateKanbanPatch,
|
||||
} from '@shared/types';
|
||||
import type { CliArgsValidationResult } from '@shared/utils/cliArgsParser';
|
||||
import type { TeamMembersMetaFile } from '../services/team/TeamMembersMetaStore';
|
||||
|
||||
const logger = createLogger('IPC:teams');
|
||||
|
||||
|
|
@ -1214,6 +1217,234 @@ function parseOptionalTeamFastMode(
|
|||
};
|
||||
}
|
||||
|
||||
type RuntimeRosterMutationMember = {
|
||||
name: string;
|
||||
role?: string;
|
||||
workflow?: string;
|
||||
isolation?: 'worktree';
|
||||
providerId?: TeamProviderId;
|
||||
providerBackendId?: TeamProviderBackendId;
|
||||
model?: string;
|
||||
effort?: EffortLevel;
|
||||
fastMode?: TeamFastMode;
|
||||
removedAt?: number | string | null;
|
||||
};
|
||||
|
||||
const OPENCODE_LEAD_LIVE_ROSTER_MUTATION_BLOCK_MESSAGE =
|
||||
'Live roster mutation for a running OpenCode-led team is not supported in this phase. Stop the team, edit the roster, then relaunch.';
|
||||
const OPENCODE_OWNERSHIP_MIGRATION_BLOCK_MESSAGE =
|
||||
'Live member migration between OpenCode and the primary runtime owner is not supported in this phase. Stop the team, edit the roster, then relaunch.';
|
||||
|
||||
function isOpenCodeRosterMutationMember(member: RuntimeRosterMutationMember | undefined): boolean {
|
||||
return normalizeOptionalTeamProviderId(member?.providerId) === 'opencode';
|
||||
}
|
||||
|
||||
function isLeadRosterMutationMember(member: RuntimeRosterMutationMember | undefined): boolean {
|
||||
if (!member) {
|
||||
return false;
|
||||
}
|
||||
if (isLeadMember(member)) {
|
||||
return true;
|
||||
}
|
||||
const normalizedName = member.name.trim().toLowerCase();
|
||||
if (normalizedName === 'lead') {
|
||||
return true;
|
||||
}
|
||||
return member.role?.toLowerCase().includes('lead') === true;
|
||||
}
|
||||
|
||||
function isOpenCodeLedRoster(members: RuntimeRosterMutationMember[]): boolean {
|
||||
const leadMember = members.find(
|
||||
(member) => !member.removedAt && isLeadRosterMutationMember(member)
|
||||
);
|
||||
return normalizeOptionalTeamProviderId(leadMember?.providerId) === 'opencode';
|
||||
}
|
||||
|
||||
function didOpenCodeRosterMemberChange(
|
||||
previous: RuntimeRosterMutationMember | undefined,
|
||||
next: RuntimeRosterMutationMember | undefined
|
||||
): boolean {
|
||||
if (!previous || !next) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return (
|
||||
(previous.role?.trim() || undefined) !== (next.role?.trim() || undefined) ||
|
||||
(previous.workflow?.trim() || undefined) !== (next.workflow?.trim() || undefined) ||
|
||||
(previous.isolation === 'worktree' ? 'worktree' : undefined) !==
|
||||
(next.isolation === 'worktree' ? 'worktree' : undefined) ||
|
||||
normalizeOptionalTeamProviderId(previous.providerId) !==
|
||||
normalizeOptionalTeamProviderId(next.providerId) ||
|
||||
migrateProviderBackendId(
|
||||
normalizeOptionalTeamProviderId(previous.providerId),
|
||||
previous.providerBackendId
|
||||
) !==
|
||||
migrateProviderBackendId(
|
||||
normalizeOptionalTeamProviderId(next.providerId),
|
||||
next.providerBackendId
|
||||
) ||
|
||||
(previous.model?.trim() || undefined) !== (next.model?.trim() || undefined) ||
|
||||
previous.effort !== next.effort ||
|
||||
previous.fastMode !== next.fastMode
|
||||
);
|
||||
}
|
||||
|
||||
function findOpenCodeOwnershipMigrationNames(options: {
|
||||
previousMembers: RuntimeRosterMutationMember[];
|
||||
nextMembers: RuntimeRosterMutationMember[];
|
||||
}): string[] {
|
||||
const previousByName = new Map(
|
||||
options.previousMembers
|
||||
.filter((member) => !member.removedAt)
|
||||
.map((member) => [member.name.trim().toLowerCase(), member])
|
||||
);
|
||||
const migrationNames: string[] = [];
|
||||
for (const nextMember of options.nextMembers) {
|
||||
const previousMember = previousByName.get(nextMember.name.trim().toLowerCase());
|
||||
if (!previousMember) {
|
||||
continue;
|
||||
}
|
||||
if (
|
||||
isOpenCodeRosterMutationMember(previousMember) !== isOpenCodeRosterMutationMember(nextMember)
|
||||
) {
|
||||
migrationNames.push(nextMember.name.trim());
|
||||
}
|
||||
}
|
||||
return migrationNames;
|
||||
}
|
||||
|
||||
function toRollbackReplaceMembersRequest(members: RuntimeRosterMutationMember[]): {
|
||||
members: {
|
||||
name: string;
|
||||
role?: string;
|
||||
workflow?: string;
|
||||
isolation?: 'worktree';
|
||||
providerId?: TeamProviderId;
|
||||
providerBackendId?: TeamProviderBackendId;
|
||||
model?: string;
|
||||
effort?: EffortLevel;
|
||||
fastMode?: TeamFastMode;
|
||||
}[];
|
||||
} {
|
||||
return {
|
||||
members: members
|
||||
.filter((member) => !member.removedAt && !isLeadRosterMutationMember(member))
|
||||
.map((member) => ({
|
||||
name: member.name.trim(),
|
||||
role: member.role?.trim() || undefined,
|
||||
workflow: member.workflow?.trim() || undefined,
|
||||
isolation: member.isolation === 'worktree' ? ('worktree' as const) : undefined,
|
||||
providerId: normalizeOptionalTeamProviderId(member.providerId),
|
||||
providerBackendId: migrateProviderBackendId(member.providerId, member.providerBackendId),
|
||||
model: member.model?.trim() || undefined,
|
||||
effort: member.effort,
|
||||
fastMode: member.fastMode,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
async function restorePreviousMembersMetaSnapshot(options: {
|
||||
teamName: string;
|
||||
teamDataService: TeamDataService;
|
||||
previousMembers: RuntimeRosterMutationMember[];
|
||||
previousMembersMeta: TeamMembersMetaFile | null;
|
||||
}): Promise<boolean> {
|
||||
const { teamName, teamDataService, previousMembers, previousMembersMeta } = options;
|
||||
|
||||
if (previousMembersMeta) {
|
||||
try {
|
||||
await new TeamMembersMetaStore().writeMembers(teamName, previousMembersMeta.members, {
|
||||
providerBackendId: previousMembersMeta.providerBackendId,
|
||||
});
|
||||
return true;
|
||||
} catch (error) {
|
||||
logger.error(
|
||||
`Failed to restore exact live OpenCode roster metadata for ${teamName}: ${
|
||||
error instanceof Error ? error.message : String(error)
|
||||
}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
await teamDataService.replaceMembers(
|
||||
teamName,
|
||||
toRollbackReplaceMembersRequest(previousMembers)
|
||||
);
|
||||
return true;
|
||||
} catch (error) {
|
||||
logger.error(
|
||||
`Failed to roll back fallback live OpenCode roster metadata for ${teamName}: ${
|
||||
error instanceof Error ? error.message : String(error)
|
||||
}`
|
||||
);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function rollbackOpenCodeLiveRosterMutation(options: {
|
||||
teamName: string;
|
||||
teamDataService: TeamDataService;
|
||||
provisioning: TeamProvisioningService;
|
||||
previousMembers: RuntimeRosterMutationMember[];
|
||||
previousMembersMeta: TeamMembersMetaFile | null;
|
||||
restoreOpenCodeMemberNames?: string[];
|
||||
detachOpenCodeMemberNames?: string[];
|
||||
}): Promise<void> {
|
||||
const {
|
||||
teamName,
|
||||
teamDataService,
|
||||
provisioning,
|
||||
previousMembers,
|
||||
previousMembersMeta,
|
||||
restoreOpenCodeMemberNames = [],
|
||||
detachOpenCodeMemberNames = [],
|
||||
} = options;
|
||||
|
||||
const metadataRestored = await restorePreviousMembersMetaSnapshot({
|
||||
teamName,
|
||||
teamDataService,
|
||||
previousMembers,
|
||||
previousMembersMeta,
|
||||
});
|
||||
|
||||
const detachNames = Array.from(
|
||||
new Set(detachOpenCodeMemberNames.map((memberName) => memberName.trim()).filter(Boolean))
|
||||
);
|
||||
for (const memberName of detachNames) {
|
||||
try {
|
||||
await provisioning.detachOpenCodeOwnedMemberLane(teamName, memberName);
|
||||
} catch (error) {
|
||||
logger.warn(
|
||||
`Failed to clean up OpenCode lane for ${teamName}/${memberName} during rollback: ${
|
||||
error instanceof Error ? error.message : String(error)
|
||||
}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (!metadataRestored) {
|
||||
return;
|
||||
}
|
||||
|
||||
const restoreNames = Array.from(
|
||||
new Set(restoreOpenCodeMemberNames.map((memberName) => memberName.trim()).filter(Boolean))
|
||||
);
|
||||
for (const memberName of restoreNames) {
|
||||
try {
|
||||
await provisioning.reattachOpenCodeOwnedMemberLane(teamName, memberName, {
|
||||
reason: 'member_updated',
|
||||
});
|
||||
} catch (error) {
|
||||
logger.warn(
|
||||
`Failed to restore OpenCode lane for ${teamName}/${memberName} during rollback: ${
|
||||
error instanceof Error ? error.message : String(error)
|
||||
}`
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function validateProvisioningRequest(
|
||||
request: unknown
|
||||
): Promise<{ valid: true; value: TeamCreateRequest } | { valid: false; error: string }> {
|
||||
|
|
@ -1702,13 +1933,15 @@ async function handlePrepareProvisioning(
|
|||
providerId: unknown,
|
||||
providerIds: unknown,
|
||||
selectedModels: unknown,
|
||||
limitContext: unknown
|
||||
limitContext: unknown,
|
||||
modelVerificationMode: unknown
|
||||
): Promise<IpcResult<TeamProvisioningPrepareResult>> {
|
||||
let validatedCwd: string | undefined;
|
||||
let validatedProviderId: TeamLaunchRequest['providerId'];
|
||||
let validatedProviderIds: TeamProviderId[] | undefined;
|
||||
let validatedSelectedModels: string[] | undefined;
|
||||
let validatedLimitContext: boolean | undefined;
|
||||
let validatedModelVerificationMode: TeamProvisioningModelVerificationMode | undefined;
|
||||
if (cwd !== undefined) {
|
||||
if (typeof cwd !== 'string' || cwd.trim().length === 0) {
|
||||
return { success: false, error: 'cwd must be a non-empty string' };
|
||||
|
|
@ -1762,12 +1995,22 @@ async function handlePrepareProvisioning(
|
|||
}
|
||||
validatedLimitContext = limitContext;
|
||||
}
|
||||
if (modelVerificationMode !== undefined) {
|
||||
if (modelVerificationMode !== 'compatibility' && modelVerificationMode !== 'deep') {
|
||||
return {
|
||||
success: false,
|
||||
error: 'modelVerificationMode must be compatibility or deep when provided',
|
||||
};
|
||||
}
|
||||
validatedModelVerificationMode = modelVerificationMode;
|
||||
}
|
||||
return wrapTeamHandler('prepareProvisioning', () =>
|
||||
getTeamProvisioningService().prepareForProvisioning(validatedCwd, {
|
||||
providerId: validatedProviderId,
|
||||
providerIds: validatedProviderIds,
|
||||
modelIds: validatedSelectedModels,
|
||||
limitContext: validatedLimitContext,
|
||||
modelVerificationMode: validatedModelVerificationMode,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
|
@ -3238,7 +3481,17 @@ async function handleAddMember(
|
|||
return wrapTeamHandler('addMember', async () => {
|
||||
const tn = vTeam.value!;
|
||||
const memberName = vName.value!;
|
||||
await getTeamDataService().addMember(tn, {
|
||||
const teamDataService = getTeamDataService();
|
||||
const previousMembersMeta = await new TeamMembersMetaStore().getMeta(tn).catch(() => null);
|
||||
const previousMembers = (await teamDataService.getTeamData(tn))
|
||||
.members as RuntimeRosterMutationMember[];
|
||||
const provisioning = getTeamProvisioningService();
|
||||
const isTeamAlive = provisioning.isTeamAlive(tn);
|
||||
if (isTeamAlive && isOpenCodeLedRoster(previousMembers)) {
|
||||
throw new Error(OPENCODE_LEAD_LIVE_ROSTER_MUTATION_BLOCK_MESSAGE);
|
||||
}
|
||||
|
||||
await teamDataService.addMember(tn, {
|
||||
name: memberName,
|
||||
role: role,
|
||||
workflow: typeof workflow === 'string' ? workflow.trim() || undefined : undefined,
|
||||
|
|
@ -3249,9 +3502,26 @@ async function handleAddMember(
|
|||
});
|
||||
|
||||
// If team is alive, notify the lead to spawn the new teammate
|
||||
const provisioning = getTeamProvisioningService();
|
||||
if (provisioning.isTeamAlive(tn)) {
|
||||
const teamDataService = getTeamDataService();
|
||||
if (isTeamAlive) {
|
||||
if (providerValidation.value === 'opencode') {
|
||||
try {
|
||||
await provisioning.reattachOpenCodeOwnedMemberLane(tn, memberName, {
|
||||
reason: 'member_added',
|
||||
});
|
||||
} catch (error) {
|
||||
await rollbackOpenCodeLiveRosterMutation({
|
||||
teamName: tn,
|
||||
teamDataService,
|
||||
provisioning,
|
||||
previousMembers,
|
||||
previousMembersMeta,
|
||||
detachOpenCodeMemberNames: [memberName],
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
let leadName = 'team-lead';
|
||||
let displayName = tn;
|
||||
try {
|
||||
|
|
@ -3304,8 +3574,10 @@ async function handleReplaceMembers(
|
|||
workflow?: string;
|
||||
isolation?: 'worktree';
|
||||
providerId?: TeamProviderId;
|
||||
providerBackendId?: TeamProviderBackendId;
|
||||
model?: string;
|
||||
effort?: EffortLevel;
|
||||
fastMode?: TeamFastMode;
|
||||
}[] = [];
|
||||
for (const item of payload.members) {
|
||||
if (!item || typeof item !== 'object') {
|
||||
|
|
@ -3317,8 +3589,10 @@ async function handleReplaceMembers(
|
|||
workflow?: unknown;
|
||||
isolation?: unknown;
|
||||
providerId?: unknown;
|
||||
providerBackendId?: unknown;
|
||||
model?: unknown;
|
||||
effort?: unknown;
|
||||
fastMode?: unknown;
|
||||
};
|
||||
const vName = validateTeammateName(m.name);
|
||||
if (!vName.valid) return { success: false, error: vName.error ?? 'Invalid member name' };
|
||||
|
|
@ -3340,6 +3614,13 @@ async function handleReplaceMembers(
|
|||
if (!providerValidation.valid) {
|
||||
return { success: false, error: providerValidation.error };
|
||||
}
|
||||
const providerBackendValidation = parseOptionalProviderBackendId(
|
||||
(m as { providerBackendId?: unknown }).providerBackendId,
|
||||
providerValidation.value
|
||||
);
|
||||
if (!providerBackendValidation.valid) {
|
||||
return { success: false, error: providerBackendValidation.error };
|
||||
}
|
||||
if (m.model !== undefined && typeof m.model !== 'string') {
|
||||
return { success: false, error: 'member model must be string' };
|
||||
}
|
||||
|
|
@ -3350,27 +3631,96 @@ async function handleReplaceMembers(
|
|||
if (!effortValidation.valid) {
|
||||
return { success: false, error: effortValidation.error };
|
||||
}
|
||||
const fastModeValidation = parseOptionalTeamFastMode((m as { fastMode?: unknown }).fastMode);
|
||||
if (!fastModeValidation.valid) {
|
||||
return { success: false, error: fastModeValidation.error };
|
||||
}
|
||||
members.push({
|
||||
name,
|
||||
role: typeof m.role === 'string' ? m.role.trim() : undefined,
|
||||
workflow: typeof m.workflow === 'string' ? m.workflow.trim() : undefined,
|
||||
isolation: m.isolation === 'worktree' ? ('worktree' as const) : undefined,
|
||||
providerId: providerValidation.value,
|
||||
providerBackendId: providerBackendValidation.value,
|
||||
model: typeof m.model === 'string' ? m.model.trim() || undefined : undefined,
|
||||
effort: effortValidation.value,
|
||||
fastMode: fastModeValidation.value,
|
||||
});
|
||||
}
|
||||
|
||||
return wrapTeamHandler('replaceMembers', async () => {
|
||||
const tn = vTeam.value!;
|
||||
const teamDataService = getTeamDataService();
|
||||
const previousMembers = (await teamDataService.getTeamData(tn)).members;
|
||||
const diff = buildReplaceMembersDiff(previousMembers, members);
|
||||
const previousMembersMeta = await new TeamMembersMetaStore().getMeta(tn).catch(() => null);
|
||||
const previousMembers = (await teamDataService.getTeamData(tn))
|
||||
.members as RuntimeRosterMutationMember[];
|
||||
const provisioning = getTeamProvisioningService();
|
||||
const isTeamAlive = provisioning.isTeamAlive(tn);
|
||||
const useSecondaryOpenCodeLaneRouting = isTeamAlive && !isOpenCodeLedRoster(previousMembers);
|
||||
if (isTeamAlive && !useSecondaryOpenCodeLaneRouting) {
|
||||
throw new Error(OPENCODE_LEAD_LIVE_ROSTER_MUTATION_BLOCK_MESSAGE);
|
||||
}
|
||||
if (useSecondaryOpenCodeLaneRouting) {
|
||||
const ownershipMigrationNames = findOpenCodeOwnershipMigrationNames({
|
||||
previousMembers,
|
||||
nextMembers: members,
|
||||
});
|
||||
if (ownershipMigrationNames.length > 0) {
|
||||
throw new Error(
|
||||
`${OPENCODE_OWNERSHIP_MIGRATION_BLOCK_MESSAGE} Affected member(s): ${ownershipMigrationNames.join(', ')}`
|
||||
);
|
||||
}
|
||||
}
|
||||
const primaryDiff = buildReplaceMembersDiff(
|
||||
previousMembers.filter((member) =>
|
||||
useSecondaryOpenCodeLaneRouting ? !isOpenCodeRosterMutationMember(member) : true
|
||||
),
|
||||
members.filter((member) =>
|
||||
useSecondaryOpenCodeLaneRouting ? !isOpenCodeRosterMutationMember(member) : true
|
||||
)
|
||||
);
|
||||
const previousByName = new Map(
|
||||
previousMembers
|
||||
.filter((member) => !member.removedAt)
|
||||
.map((member) => [member.name.trim().toLowerCase(), member as RuntimeRosterMutationMember])
|
||||
);
|
||||
const nextByName = new Map(
|
||||
members.map((member) => [
|
||||
member.name.trim().toLowerCase(),
|
||||
member as RuntimeRosterMutationMember,
|
||||
])
|
||||
);
|
||||
const removedOpenCodeMembers = useSecondaryOpenCodeLaneRouting
|
||||
? previousMembers.filter((member) => {
|
||||
const normalizedName = member.name.trim().toLowerCase();
|
||||
return (
|
||||
!member.removedAt &&
|
||||
isOpenCodeRosterMutationMember(member) &&
|
||||
!nextByName.has(normalizedName)
|
||||
);
|
||||
})
|
||||
: [];
|
||||
const addedOpenCodeMembers = useSecondaryOpenCodeLaneRouting
|
||||
? members.filter((member) => {
|
||||
const normalizedName = member.name.trim().toLowerCase();
|
||||
return isOpenCodeRosterMutationMember(member) && !previousByName.has(normalizedName);
|
||||
})
|
||||
: [];
|
||||
const updatedOpenCodeMembers = useSecondaryOpenCodeLaneRouting
|
||||
? members.filter((member) => {
|
||||
const normalizedName = member.name.trim().toLowerCase();
|
||||
const previousMember = previousByName.get(normalizedName);
|
||||
return (
|
||||
isOpenCodeRosterMutationMember(member) &&
|
||||
isOpenCodeRosterMutationMember(previousMember) &&
|
||||
didOpenCodeRosterMemberChange(previousMember, member)
|
||||
);
|
||||
})
|
||||
: [];
|
||||
|
||||
await teamDataService.replaceMembers(tn, { members });
|
||||
|
||||
const provisioning = getTeamProvisioningService();
|
||||
if (!provisioning.isTeamAlive(tn)) {
|
||||
if (!isTeamAlive) {
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -3387,7 +3737,39 @@ async function handleReplaceMembers(
|
|||
// Best-effort: fall back to default lead and team names
|
||||
}
|
||||
|
||||
for (const addedMember of diff.added) {
|
||||
try {
|
||||
for (const removedMember of removedOpenCodeMembers) {
|
||||
await provisioning.detachOpenCodeOwnedMemberLane(tn, removedMember.name);
|
||||
}
|
||||
|
||||
for (const addedMember of addedOpenCodeMembers) {
|
||||
await provisioning.reattachOpenCodeOwnedMemberLane(tn, addedMember.name, {
|
||||
reason: 'member_added',
|
||||
});
|
||||
}
|
||||
|
||||
for (const updatedMember of updatedOpenCodeMembers) {
|
||||
await provisioning.reattachOpenCodeOwnedMemberLane(tn, updatedMember.name, {
|
||||
reason: 'member_updated',
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
await rollbackOpenCodeLiveRosterMutation({
|
||||
teamName: tn,
|
||||
teamDataService,
|
||||
provisioning,
|
||||
previousMembers,
|
||||
previousMembersMeta,
|
||||
restoreOpenCodeMemberNames: [
|
||||
...removedOpenCodeMembers.map((member) => member.name),
|
||||
...updatedOpenCodeMembers.map((member) => member.name),
|
||||
],
|
||||
detachOpenCodeMemberNames: addedOpenCodeMembers.map((member) => member.name),
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
|
||||
for (const addedMember of primaryDiff.added) {
|
||||
const spawnMessage = buildAddMemberSpawnMessage(tn, displayName, leadName, addedMember);
|
||||
try {
|
||||
await provisioning.sendMessageToTeam(tn, spawnMessage);
|
||||
|
|
@ -3396,7 +3778,7 @@ async function handleReplaceMembers(
|
|||
}
|
||||
}
|
||||
|
||||
const summaryMessage = buildReplaceMembersSummaryMessage(diff);
|
||||
const summaryMessage = buildReplaceMembersSummaryMessage(primaryDiff);
|
||||
if (!summaryMessage) {
|
||||
return;
|
||||
}
|
||||
|
|
@ -3421,11 +3803,39 @@ async function handleRemoveMember(
|
|||
return wrapTeamHandler('removeMember', async () => {
|
||||
const tn = vTeam.value!;
|
||||
const name = vMember.value!;
|
||||
await getTeamDataService().removeMember(tn, name);
|
||||
const teamDataService = getTeamDataService();
|
||||
const previousMembersMeta = await new TeamMembersMetaStore().getMeta(tn).catch(() => null);
|
||||
const previousMembers = (await teamDataService.getTeamData(tn))
|
||||
.members as RuntimeRosterMutationMember[];
|
||||
const provisioning = getTeamProvisioningService();
|
||||
const isTeamAlive = provisioning.isTeamAlive(tn);
|
||||
if (isTeamAlive && isOpenCodeLedRoster(previousMembers)) {
|
||||
throw new Error(OPENCODE_LEAD_LIVE_ROSTER_MUTATION_BLOCK_MESSAGE);
|
||||
}
|
||||
const removedMember = previousMembers.find(
|
||||
(member) => member.name.trim().toLowerCase() === name.trim().toLowerCase()
|
||||
);
|
||||
await teamDataService.removeMember(tn, name);
|
||||
|
||||
// Notify the lead about removed member
|
||||
const provisioning = getTeamProvisioningService();
|
||||
if (provisioning.isTeamAlive(tn)) {
|
||||
if (isTeamAlive) {
|
||||
if (isOpenCodeRosterMutationMember(removedMember)) {
|
||||
try {
|
||||
await provisioning.detachOpenCodeOwnedMemberLane(tn, name);
|
||||
} catch (error) {
|
||||
await rollbackOpenCodeLiveRosterMutation({
|
||||
teamName: tn,
|
||||
teamDataService,
|
||||
provisioning,
|
||||
previousMembers,
|
||||
previousMembersMeta,
|
||||
restoreOpenCodeMemberNames: [name],
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const message =
|
||||
`Teammate "${name}" has been removed from the team. ` +
|
||||
`They will no longer participate in team activities. Please reassign their tasks if needed.`;
|
||||
|
|
|
|||
|
|
@ -59,6 +59,8 @@ const MAX_FILE_SIZE_BYTES = 20 * 1024 * 1024;
|
|||
const TEAM_ROOT_FILES = [
|
||||
'config.json',
|
||||
'team.meta.json',
|
||||
'launch-state.json',
|
||||
'launch-summary.json',
|
||||
'kanban-state.json',
|
||||
'sentMessages.json',
|
||||
'sent-cross-team.json',
|
||||
|
|
@ -68,6 +70,7 @@ const TEAM_ROOT_FILES = [
|
|||
|
||||
// Subdirs under ~/.claude/teams/{teamName}/
|
||||
const TEAM_SUBDIRS = ['inboxes', 'review-decisions'];
|
||||
const TEAM_RECURSIVE_SUBDIRS = ['.opencode-runtime'];
|
||||
// Subdirs under getAppDataPath() (our own storage, not in ~/.claude/)
|
||||
const APP_DATA_SUBDIRS = ['attachments'];
|
||||
const APP_DATA_DEEP_SUBDIRS = ['task-attachments'];
|
||||
|
|
@ -102,6 +105,57 @@ function isValidConfig(content: string): boolean {
|
|||
}
|
||||
}
|
||||
|
||||
async function collectRecursiveFiles(
|
||||
rootDir: string,
|
||||
relPrefix: string
|
||||
): Promise<BackupFileDescriptor[]> {
|
||||
const files: BackupFileDescriptor[] = [];
|
||||
const walk = async (dirPath: string, relDir: string): Promise<void> => {
|
||||
const entries = await fs.promises.readdir(dirPath, { withFileTypes: true });
|
||||
for (const entry of entries) {
|
||||
const sourcePath = path.join(dirPath, entry.name);
|
||||
const relPath = relDir ? `${relDir}/${entry.name}` : entry.name;
|
||||
if (entry.isDirectory()) {
|
||||
await walk(sourcePath, relPath);
|
||||
continue;
|
||||
}
|
||||
if (entry.isFile()) {
|
||||
files.push({
|
||||
sourcePath,
|
||||
relPath: relPrefix ? `${relPrefix}/${relPath}` : relPath,
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
await walk(rootDir, '');
|
||||
return files;
|
||||
}
|
||||
|
||||
function collectRecursiveFilesSync(rootDir: string, relPrefix: string): BackupFileDescriptor[] {
|
||||
const files: BackupFileDescriptor[] = [];
|
||||
const walk = (dirPath: string, relDir: string): void => {
|
||||
const entries = fs.readdirSync(dirPath, { withFileTypes: true });
|
||||
for (const entry of entries) {
|
||||
const sourcePath = path.join(dirPath, entry.name);
|
||||
const relPath = relDir ? `${relDir}/${entry.name}` : entry.name;
|
||||
if (entry.isDirectory()) {
|
||||
walk(sourcePath, relPath);
|
||||
continue;
|
||||
}
|
||||
if (entry.isFile()) {
|
||||
files.push({
|
||||
sourcePath,
|
||||
relPath: relPrefix ? `${relPrefix}/${relPath}` : relPath,
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
walk(rootDir, '');
|
||||
return files;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// TeamBackupService
|
||||
// ---------------------------------------------------------------------------
|
||||
|
|
@ -734,6 +788,15 @@ export class TeamBackupService {
|
|||
}
|
||||
}
|
||||
|
||||
for (const subdir of TEAM_RECURSIVE_SUBDIRS) {
|
||||
const dirPath = path.join(teamDir, subdir);
|
||||
try {
|
||||
files.push(...(await collectRecursiveFiles(dirPath, subdir)));
|
||||
} catch (err: unknown) {
|
||||
if (!isEnoent(err)) hasErrors = true;
|
||||
}
|
||||
}
|
||||
|
||||
// Flat subdirs under app data dir (attachments/)
|
||||
const appDataDir = getAppDataPath();
|
||||
for (const subdir of APP_DATA_SUBDIRS) {
|
||||
|
|
@ -830,6 +893,14 @@ export class TeamBackupService {
|
|||
}
|
||||
}
|
||||
|
||||
for (const subdir of TEAM_RECURSIVE_SUBDIRS) {
|
||||
try {
|
||||
files.push(...collectRecursiveFilesSync(path.join(teamDir, subdir), subdir));
|
||||
} catch {
|
||||
// skip
|
||||
}
|
||||
}
|
||||
|
||||
// Flat subdirs under app data dir (attachments/)
|
||||
const appDataDir = getAppDataPath();
|
||||
for (const subdir of APP_DATA_SUBDIRS) {
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@ const MAX_BOOTSTRAP_STATE_BYTES = 256 * 1024;
|
|||
const MAX_BOOTSTRAP_JOURNAL_BYTES = 256 * 1024;
|
||||
const MAX_BOOTSTRAP_LOCK_METADATA_BYTES = 64 * 1024;
|
||||
const ACTIVE_BOOTSTRAP_STUCK_CLASSIFICATION_MS = 3 * 60 * 1000;
|
||||
const TERMINAL_BOOTSTRAP_ONLY_PENDING_GRACE_MS = 5 * 60 * 1000;
|
||||
|
||||
interface RawBootstrapMemberState {
|
||||
name?: unknown;
|
||||
|
|
@ -810,13 +811,84 @@ export async function clearBootstrapState(teamName: string): Promise<void> {
|
|||
}
|
||||
}
|
||||
|
||||
function isLaunchSnapshotLike(value: unknown): value is PersistedTeamLaunchSnapshot {
|
||||
return (
|
||||
Boolean(value) &&
|
||||
typeof value === 'object' &&
|
||||
Array.isArray((value as PersistedTeamLaunchSnapshot).expectedMembers) &&
|
||||
typeof (value as PersistedTeamLaunchSnapshot).members === 'object' &&
|
||||
(value as PersistedTeamLaunchSnapshot).members !== null
|
||||
);
|
||||
}
|
||||
|
||||
function getLaunchSnapshotRichness(snapshot: PersistedTeamLaunchSnapshot): number {
|
||||
let metadataScore = 0;
|
||||
for (const member of Object.values(snapshot.members)) {
|
||||
if (!member || typeof member !== 'object') continue;
|
||||
if (member.providerId) metadataScore += 3;
|
||||
if (member.providerBackendId) metadataScore += 3;
|
||||
if (member.selectedFastMode) metadataScore += 2;
|
||||
if (typeof member.resolvedFastMode === 'boolean') metadataScore += 2;
|
||||
if (member.laneId) metadataScore += 4;
|
||||
if (member.laneKind) metadataScore += 4;
|
||||
if (member.laneOwnerProviderId) metadataScore += 3;
|
||||
if (member.launchIdentity) metadataScore += 6;
|
||||
}
|
||||
return (
|
||||
snapshot.expectedMembers.length * 10 +
|
||||
Object.keys(snapshot.members).length * 5 +
|
||||
metadataScore +
|
||||
(snapshot.bootstrapExpectedMembers?.length ? 20 : 0)
|
||||
);
|
||||
}
|
||||
|
||||
export function shouldIgnoreTerminalBootstrapOnlyPendingSnapshot(
|
||||
snapshot: Pick<PersistedTeamLaunchSnapshot, 'launchPhase' | 'teamLaunchState' | 'updatedAt'>,
|
||||
nowMs: number = Date.now()
|
||||
): boolean {
|
||||
if (snapshot.launchPhase !== 'finished' || snapshot.teamLaunchState !== 'partial_pending') {
|
||||
return false;
|
||||
}
|
||||
|
||||
const updatedAtMs = Date.parse(snapshot.updatedAt);
|
||||
if (!Number.isFinite(updatedAtMs)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return nowMs - updatedAtMs >= TERMINAL_BOOTSTRAP_ONLY_PENDING_GRACE_MS;
|
||||
}
|
||||
|
||||
export function choosePreferredLaunchSnapshot<T extends { updatedAt?: string }>(
|
||||
bootstrapSnapshot: T | null,
|
||||
launchSnapshot: T | null
|
||||
): T | null {
|
||||
if (!bootstrapSnapshot) return launchSnapshot;
|
||||
if (
|
||||
!launchSnapshot &&
|
||||
isLaunchSnapshotLike(bootstrapSnapshot) &&
|
||||
shouldIgnoreTerminalBootstrapOnlyPendingSnapshot(bootstrapSnapshot)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
if (!launchSnapshot) return bootstrapSnapshot;
|
||||
|
||||
if (isLaunchSnapshotLike(bootstrapSnapshot) && isLaunchSnapshotLike(launchSnapshot)) {
|
||||
const bootstrapRichness = getLaunchSnapshotRichness(bootstrapSnapshot);
|
||||
const launchRichness = getLaunchSnapshotRichness(launchSnapshot);
|
||||
if (
|
||||
launchRichness > bootstrapRichness &&
|
||||
launchSnapshot.expectedMembers.length >= bootstrapSnapshot.expectedMembers.length
|
||||
) {
|
||||
return launchSnapshot as T;
|
||||
}
|
||||
if (
|
||||
bootstrapRichness > launchRichness &&
|
||||
bootstrapSnapshot.expectedMembers.length >= launchSnapshot.expectedMembers.length
|
||||
) {
|
||||
return bootstrapSnapshot as T;
|
||||
}
|
||||
}
|
||||
|
||||
const bootstrapMs = Date.parse(bootstrapSnapshot.updatedAt ?? '');
|
||||
const launchMs = Date.parse(launchSnapshot.updatedAt ?? '');
|
||||
if (Number.isFinite(bootstrapMs) && Number.isFinite(launchMs)) {
|
||||
|
|
|
|||
|
|
@ -9,16 +9,26 @@ import {
|
|||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
|
||||
import {
|
||||
choosePreferredLaunchSnapshot,
|
||||
readBootstrapLaunchSnapshot,
|
||||
} from './TeamBootstrapStateReader';
|
||||
import { readBootstrapLaunchSnapshot } from './TeamBootstrapStateReader';
|
||||
import { getTeamFsWorkerClient } from './TeamFsWorkerClient';
|
||||
import { normalizePersistedLaunchSnapshot } from './TeamLaunchStateEvaluator';
|
||||
import {
|
||||
type LaunchStateSummary,
|
||||
choosePreferredLaunchStateSummary,
|
||||
normalizePersistedLaunchSummaryProjection,
|
||||
shouldSuppressLegacyLaunchArtifactHeuristic,
|
||||
TEAM_LAUNCH_SUMMARY_FILE,
|
||||
} from './TeamLaunchSummaryProjection';
|
||||
import { TeamMembersMetaStore } from './TeamMembersMetaStore';
|
||||
import { TeamMetaStore } from './TeamMetaStore';
|
||||
|
||||
import type { TeamConfig, TeamMember, TeamSummary, TeamSummaryMember } from '@shared/types';
|
||||
import type {
|
||||
TeamConfig,
|
||||
TeamMember,
|
||||
TeamProviderId,
|
||||
TeamSummary,
|
||||
TeamSummaryMember,
|
||||
} from '@shared/types';
|
||||
|
||||
const logger = createLogger('Service:TeamConfigReader');
|
||||
|
||||
|
|
@ -77,67 +87,43 @@ function resolveProjectPathFromConfig(
|
|||
return undefined;
|
||||
}
|
||||
|
||||
interface LaunchStateSummary {
|
||||
partialLaunchFailure?: true;
|
||||
expectedMemberCount?: number;
|
||||
confirmedMemberCount?: number;
|
||||
missingMembers?: string[];
|
||||
teamLaunchState?: TeamSummary['teamLaunchState'];
|
||||
launchUpdatedAt?: string;
|
||||
confirmedCount?: number;
|
||||
pendingCount?: number;
|
||||
failedCount?: number;
|
||||
runtimeAlivePendingCount?: number;
|
||||
}
|
||||
|
||||
async function readLaunchStateSummary(teamDir: string): Promise<LaunchStateSummary | null> {
|
||||
const bootstrapSnapshot = await readBootstrapLaunchSnapshot(path.basename(teamDir));
|
||||
const launchStatePath = path.join(teamDir, TEAM_LAUNCH_STATE_FILE);
|
||||
const launchSnapshot = await (async () => {
|
||||
try {
|
||||
const stat = await fs.promises.stat(launchStatePath);
|
||||
if (!stat.isFile() || stat.size > MAX_LAUNCH_STATE_BYTES) {
|
||||
const launchSummaryPath = path.join(teamDir, TEAM_LAUNCH_SUMMARY_FILE);
|
||||
const [launchSnapshot, launchSummaryProjection] = await Promise.all([
|
||||
(async () => {
|
||||
try {
|
||||
const stat = await fs.promises.stat(launchStatePath);
|
||||
if (!stat.isFile() || stat.size > MAX_LAUNCH_STATE_BYTES) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const raw = await readFileUtf8WithTimeout(launchStatePath, PER_TEAM_READ_TIMEOUT_MS);
|
||||
return normalizePersistedLaunchSnapshot(path.basename(teamDir), JSON.parse(raw));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
})(),
|
||||
(async () => {
|
||||
try {
|
||||
const stat = await fs.promises.stat(launchSummaryPath);
|
||||
if (!stat.isFile() || stat.size > MAX_LAUNCH_STATE_BYTES) {
|
||||
return null;
|
||||
}
|
||||
const raw = await readFileUtf8WithTimeout(launchSummaryPath, PER_TEAM_READ_TIMEOUT_MS);
|
||||
return normalizePersistedLaunchSummaryProjection(path.basename(teamDir), JSON.parse(raw));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
})(),
|
||||
]);
|
||||
|
||||
const raw = await readFileUtf8WithTimeout(launchStatePath, PER_TEAM_READ_TIMEOUT_MS);
|
||||
return normalizePersistedLaunchSnapshot(path.basename(teamDir), JSON.parse(raw));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
})();
|
||||
|
||||
const snapshot = choosePreferredLaunchSnapshot(bootstrapSnapshot, launchSnapshot);
|
||||
if (!snapshot) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const missingMembers = snapshot.expectedMembers.filter((name) => {
|
||||
const member = snapshot.members[name];
|
||||
return member?.launchState === 'failed_to_start';
|
||||
});
|
||||
return {
|
||||
...(snapshot.teamLaunchState === 'partial_failure'
|
||||
? { partialLaunchFailure: true as const }
|
||||
: {}),
|
||||
...(snapshot.expectedMembers.length > 0
|
||||
? { expectedMemberCount: snapshot.expectedMembers.length }
|
||||
: {}),
|
||||
...(snapshot.summary.confirmedCount > 0
|
||||
? { confirmedMemberCount: snapshot.summary.confirmedCount }
|
||||
: {}),
|
||||
...(missingMembers.length > 0 ? { missingMembers } : {}),
|
||||
teamLaunchState: snapshot.teamLaunchState,
|
||||
launchUpdatedAt: snapshot.updatedAt,
|
||||
confirmedCount: snapshot.summary.confirmedCount,
|
||||
pendingCount: snapshot.summary.pendingCount,
|
||||
failedCount: snapshot.summary.failedCount,
|
||||
runtimeAlivePendingCount: snapshot.summary.runtimeAlivePendingCount,
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
return choosePreferredLaunchStateSummary({
|
||||
bootstrapSnapshot,
|
||||
launchSnapshot,
|
||||
launchSummaryProjection,
|
||||
});
|
||||
}
|
||||
|
||||
async function mapLimit<T, R>(
|
||||
|
|
@ -171,7 +157,8 @@ function withReadTimeout<T>(promise: Promise<T>, ms: number): Promise<T> {
|
|||
|
||||
export class TeamConfigReader {
|
||||
constructor(
|
||||
private readonly membersMetaStore: TeamMembersMetaStore = new TeamMembersMetaStore()
|
||||
private readonly membersMetaStore: TeamMembersMetaStore = new TeamMembersMetaStore(),
|
||||
private readonly teamMetaStore: TeamMetaStore = new TeamMetaStore()
|
||||
) {}
|
||||
|
||||
async listTeams(): Promise<TeamSummary[]> {
|
||||
|
|
@ -251,6 +238,7 @@ export class TeamConfigReader {
|
|||
|
||||
try {
|
||||
let config: TeamConfig | null = null;
|
||||
let leadProviderId: TeamProviderId | undefined;
|
||||
let displayName: string | null = null;
|
||||
let description = '';
|
||||
let color: string | undefined;
|
||||
|
|
@ -319,6 +307,7 @@ export class TeamConfigReader {
|
|||
const removedKeys = new Set<string>();
|
||||
const expectedTeammateNames = new Set<string>();
|
||||
const confirmedArtifactNames = new Set<string>();
|
||||
let metaMembers: TeamMember[] = [];
|
||||
|
||||
const mergeMember = (m: TeamMember): void => {
|
||||
const name = m.name?.trim();
|
||||
|
|
@ -339,7 +328,7 @@ export class TeamConfigReader {
|
|||
// Also read members.meta.json — UI-created teams store members there,
|
||||
// and CLI-created teams may have additional members added via the UI.
|
||||
try {
|
||||
const metaMembers = await this.membersMetaStore.getMembers(teamName);
|
||||
metaMembers = await this.membersMetaStore.getMembers(teamName);
|
||||
for (const member of metaMembers) {
|
||||
const name = member.name?.trim();
|
||||
if (!name) continue;
|
||||
|
|
@ -357,6 +346,12 @@ export class TeamConfigReader {
|
|||
// best-effort — don't fail listing if meta file is broken
|
||||
}
|
||||
|
||||
try {
|
||||
leadProviderId = (await this.teamMetaStore.getMeta(teamName))?.providerId;
|
||||
} catch {
|
||||
leadProviderId = undefined;
|
||||
}
|
||||
|
||||
// Merge config members AFTER meta so removedAt can suppress stale config entries.
|
||||
if (config && Array.isArray(config.members)) {
|
||||
for (const member of config.members) {
|
||||
|
|
@ -383,11 +378,15 @@ export class TeamConfigReader {
|
|||
// best-effort
|
||||
}
|
||||
|
||||
// Defense: drop CLI auto-suffixed duplicates (alice-2) when base name exists.
|
||||
const allNames = Array.from(memberMap.values()).map((m) => m.name);
|
||||
const keepName = createCliAutoSuffixNameGuard(allNames);
|
||||
// Defense: drop CLI auto-suffixed duplicates (alice-2) only when the
|
||||
// base name is still active. Removed base members must not hide active
|
||||
// suffixed teammates in summary/list paths.
|
||||
const activeNamesForAutoSuffix = Array.from(memberMap.values())
|
||||
.map((member) => member.name)
|
||||
.filter((name) => !removedKeys.has(name.trim().toLowerCase()));
|
||||
const keepName = createCliAutoSuffixNameGuard(activeNamesForAutoSuffix);
|
||||
// Defense: drop CLI provisioner artifacts (alice-provisioner) when base name exists.
|
||||
const keepProvisioner = createCliProvisionerNameGuard(allNames);
|
||||
const keepProvisioner = createCliProvisionerNameGuard(activeNamesForAutoSuffix);
|
||||
for (const [key, member] of Array.from(memberMap.entries())) {
|
||||
if (!keepName(member.name) || !keepProvisioner(member.name)) {
|
||||
memberMap.delete(key);
|
||||
|
|
@ -395,9 +394,16 @@ export class TeamConfigReader {
|
|||
}
|
||||
|
||||
const members = Array.from(memberMap.values());
|
||||
const suppressLegacyLaunchArtifactHeuristic = shouldSuppressLegacyLaunchArtifactHeuristic({
|
||||
leadProviderId,
|
||||
members: metaMembers,
|
||||
});
|
||||
const launchStateSummary =
|
||||
(await readLaunchStateSummary(teamDir)) ??
|
||||
(() => {
|
||||
if (suppressLegacyLaunchArtifactHeuristic) {
|
||||
return null;
|
||||
}
|
||||
if (
|
||||
!leadSessionId ||
|
||||
expectedTeammateNames.size === 0 ||
|
||||
|
|
@ -470,7 +476,13 @@ export class TeamConfigReader {
|
|||
try {
|
||||
const metaStore = new TeamMembersMetaStore();
|
||||
const members = await metaStore.getMembers(teamName);
|
||||
memberCount = members.length;
|
||||
memberCount = members.filter((member) => {
|
||||
const name = member.name?.trim() ?? '';
|
||||
if (!name || name === 'user' || isLeadMember(member)) {
|
||||
return false;
|
||||
}
|
||||
return !member.removedAt;
|
||||
}).length;
|
||||
} catch {
|
||||
// best-effort
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,10 @@
|
|||
import { yieldToEventLoop } from '@main/utils/asyncYield';
|
||||
import { getClaudeBasePath, getTasksBasePath, getTeamsBasePath } from '@main/utils/pathDecoder';
|
||||
import { killProcessByPid } from '@main/utils/processKill';
|
||||
import {
|
||||
fromProvisioningMembers,
|
||||
isMixedOpenCodeSideLanePlan,
|
||||
} from '@features/team-runtime-lanes/core/domain/planTeamRuntimeLanes';
|
||||
import {
|
||||
AGENT_BLOCK_CLOSE,
|
||||
AGENT_BLOCK_OPEN,
|
||||
|
|
@ -12,6 +16,7 @@ import { isTeamEffortLevel } from '@shared/utils/effortLevels';
|
|||
import { classifyIdleNotificationText } from '@shared/utils/idleNotificationSemantics';
|
||||
import { isLeadMember } from '@shared/utils/leadDetection';
|
||||
import { createLogger } from '@shared/utils/logger';
|
||||
import { migrateProviderBackendId } from '@shared/utils/providerBackend';
|
||||
import { getKanbanColumnFromReviewState, normalizeReviewState } from '@shared/utils/reviewState';
|
||||
import { buildTeamMemberColorMap } from '@shared/utils/teamMemberColors';
|
||||
import { buildStandaloneSlashCommandMeta } from '@shared/utils/slashCommands';
|
||||
|
|
@ -41,9 +46,15 @@ import {
|
|||
} from './mergeLiveLeadProcessMessages';
|
||||
import { buildTaskChangePresenceDescriptor } from './taskChangePresenceUtils';
|
||||
import { TeamConfigReader } from './TeamConfigReader';
|
||||
import {
|
||||
choosePreferredLaunchSnapshot,
|
||||
readBootstrapLaunchSnapshot,
|
||||
} from './TeamBootstrapStateReader';
|
||||
import { TeamInboxReader } from './TeamInboxReader';
|
||||
import { TeamInboxWriter } from './TeamInboxWriter';
|
||||
import { TeamKanbanManager } from './TeamKanbanManager';
|
||||
import { hasMixedPersistedLaunchMetadata } from './TeamLaunchStateEvaluator';
|
||||
import { TeamLaunchStateStore } from './TeamLaunchStateStore';
|
||||
import { TeamMemberResolver } from './TeamMemberResolver';
|
||||
import { TeamMemberRuntimeAdvisoryService } from './TeamMemberRuntimeAdvisoryService';
|
||||
import { TeamMembersMetaStore } from './TeamMembersMetaStore';
|
||||
|
|
@ -67,6 +78,7 @@ import type {
|
|||
KanbanColumnId,
|
||||
KanbanState,
|
||||
MessagesPage,
|
||||
ReplaceMembersRequest,
|
||||
SendMessageRequest,
|
||||
SendMessageResult,
|
||||
TaskAttachmentMeta,
|
||||
|
|
@ -76,6 +88,7 @@ import type {
|
|||
TeamConfig,
|
||||
TeamCreateConfigRequest,
|
||||
TeamMember,
|
||||
TeamMemberSnapshot,
|
||||
TeamMemberActivityMeta,
|
||||
TeamProcess,
|
||||
TeamProviderId,
|
||||
|
|
@ -88,6 +101,7 @@ import type {
|
|||
UpdateKanbanPatch,
|
||||
} from '@shared/types';
|
||||
import type { AgentTeamsController } from 'agent-teams-controller';
|
||||
import type { TeamMetaFile } from './TeamMetaStore';
|
||||
|
||||
const { createController } = agentTeamsControllerModule;
|
||||
|
||||
|
|
@ -100,6 +114,92 @@ const PROCESS_HEALTH_INTERVAL_MS = 2_000;
|
|||
const TASK_MAP_YIELD_EVERY = 250;
|
||||
const TASK_COMMENT_NOTIFICATION_SOURCE = 'system_notification';
|
||||
const PASSIVE_USER_REPLY_LINK_WINDOW_MS = 15_000;
|
||||
const MIXED_TEAM_LIVE_MUTATION_BLOCK_MESSAGE =
|
||||
'Live roster mutation on a running mixed team is not supported in V1. Stop the team, edit the roster, then relaunch.';
|
||||
|
||||
function resolveEffectiveMemberProviderId(
|
||||
leadProviderId: TeamProviderId | undefined,
|
||||
member: ReturnType<typeof toProvisioningMemberShape>[number] | undefined
|
||||
): TeamProviderId {
|
||||
return normalizeOptionalTeamProviderId(member?.providerId) ?? leadProviderId ?? 'anthropic';
|
||||
}
|
||||
|
||||
function isSupportedRunningMixedRosterMutation(params: {
|
||||
leadProviderId: TeamProviderId | undefined;
|
||||
previousMembers: ReturnType<typeof toProvisioningMemberShape>;
|
||||
nextMembers: ReturnType<typeof toProvisioningMemberShape>;
|
||||
}): boolean {
|
||||
if (params.leadProviderId === 'opencode') {
|
||||
return false;
|
||||
}
|
||||
|
||||
const previousByName = new Map(
|
||||
params.previousMembers.map((member) => [member.name.trim().toLowerCase(), member])
|
||||
);
|
||||
const nextByName = new Map(
|
||||
params.nextMembers.map((member) => [member.name.trim().toLowerCase(), member])
|
||||
);
|
||||
const candidateNames = new Set([...previousByName.keys(), ...nextByName.keys()]);
|
||||
|
||||
for (const candidateName of candidateNames) {
|
||||
const previous = previousByName.get(candidateName);
|
||||
const next = nextByName.get(candidateName);
|
||||
const previousProviderId = resolveEffectiveMemberProviderId(params.leadProviderId, previous);
|
||||
const nextProviderId = resolveEffectiveMemberProviderId(params.leadProviderId, next);
|
||||
|
||||
if (!previous && next) {
|
||||
if (nextProviderId !== 'opencode') {
|
||||
return false;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (previous && !next) {
|
||||
if (previousProviderId !== 'opencode') {
|
||||
return false;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!previous || !next) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (previousProviderId !== nextProviderId) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (previousProviderId !== 'opencode') {
|
||||
const stablePrimaryShape = JSON.stringify({
|
||||
name: previous.name,
|
||||
role: previous.role,
|
||||
workflow: previous.workflow,
|
||||
isolation: previous.isolation,
|
||||
providerId: previous.providerId,
|
||||
providerBackendId: previous.providerBackendId,
|
||||
model: previous.model,
|
||||
effort: previous.effort,
|
||||
fastMode: previous.fastMode,
|
||||
});
|
||||
const nextPrimaryShape = JSON.stringify({
|
||||
name: next.name,
|
||||
role: next.role,
|
||||
workflow: next.workflow,
|
||||
isolation: next.isolation,
|
||||
providerId: next.providerId,
|
||||
providerBackendId: next.providerBackendId,
|
||||
model: next.model,
|
||||
effort: next.effort,
|
||||
fastMode: next.fastMode,
|
||||
});
|
||||
if (stablePrimaryShape !== nextPrimaryShape) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
function requireCanonicalMessageId(message: InboxMessage): string {
|
||||
const messageId = typeof message.messageId === 'string' ? message.messageId.trim() : '';
|
||||
|
|
@ -168,6 +268,81 @@ function extractPassiveUserPeerSummaryBody(text: string): string | null {
|
|||
return body.length > 0 ? body : null;
|
||||
}
|
||||
|
||||
function hasVisibleLeadMember(members: readonly TeamMemberSnapshot[]): boolean {
|
||||
return members.some((member) => {
|
||||
if (isLeadMember(member)) {
|
||||
return true;
|
||||
}
|
||||
const normalizedName = member.name.trim().toLowerCase();
|
||||
if (normalizedName === 'lead') {
|
||||
return true;
|
||||
}
|
||||
return member.role?.toLowerCase().includes('lead') === true;
|
||||
});
|
||||
}
|
||||
|
||||
function hasExplicitLeadInConfig(config: TeamConfig): boolean {
|
||||
return (config.members ?? []).some((member) => {
|
||||
if (isLeadMember(member)) {
|
||||
return true;
|
||||
}
|
||||
const normalizedName = member.name?.trim().toLowerCase() ?? '';
|
||||
if (normalizedName === 'lead') {
|
||||
return true;
|
||||
}
|
||||
return member.role?.toLowerCase().includes('lead') === true;
|
||||
});
|
||||
}
|
||||
|
||||
function toProvisioningMemberShape(
|
||||
members: readonly Pick<
|
||||
TeamMember,
|
||||
| 'name'
|
||||
| 'role'
|
||||
| 'workflow'
|
||||
| 'isolation'
|
||||
| 'providerId'
|
||||
| 'providerBackendId'
|
||||
| 'model'
|
||||
| 'effort'
|
||||
| 'fastMode'
|
||||
| 'removedAt'
|
||||
>[]
|
||||
): Array<{
|
||||
name: string;
|
||||
role?: string;
|
||||
workflow?: string;
|
||||
isolation?: 'worktree';
|
||||
providerId?: TeamProviderId;
|
||||
providerBackendId?: TeamMember['providerBackendId'];
|
||||
model?: string;
|
||||
effort?: TeamMember['effort'];
|
||||
fastMode?: TeamMember['fastMode'];
|
||||
}> {
|
||||
return members
|
||||
.filter((member) => !member.removedAt)
|
||||
.filter((member) => {
|
||||
const normalizedName = member.name.trim();
|
||||
return (
|
||||
normalizedName.length > 0 && !isLeadMember({ name: normalizedName, agentType: undefined })
|
||||
);
|
||||
})
|
||||
.map((member) => ({
|
||||
name: member.name.trim(),
|
||||
role: member.role,
|
||||
workflow: member.workflow,
|
||||
isolation: member.isolation === 'worktree' ? ('worktree' as const) : undefined,
|
||||
providerId: normalizeOptionalTeamProviderId(member.providerId),
|
||||
providerBackendId: member.providerBackendId,
|
||||
model: member.model,
|
||||
effort: isTeamEffortLevel(member.effort) ? member.effort : undefined,
|
||||
fastMode:
|
||||
member.fastMode === 'inherit' || member.fastMode === 'on' || member.fastMode === 'off'
|
||||
? member.fastMode
|
||||
: undefined,
|
||||
}));
|
||||
}
|
||||
|
||||
interface FileWatchReconcileTrigger {
|
||||
source: 'inbox' | 'task';
|
||||
detail?: string;
|
||||
|
|
@ -208,7 +383,8 @@ export class TeamDataService {
|
|||
private readonly leadSessionParseCache: LeadSessionParseCache = new LeadSessionParseCache(),
|
||||
private readonly projectResolver: TeamTranscriptProjectResolver = new TeamTranscriptProjectResolver(
|
||||
configReader
|
||||
)
|
||||
),
|
||||
private readonly launchStateStore: TeamLaunchStateStore = new TeamLaunchStateStore()
|
||||
) {
|
||||
this.messageFeedService = new TeamMessageFeedService({
|
||||
getConfig: (teamName) => this.configReader.getConfig(teamName),
|
||||
|
|
@ -223,10 +399,135 @@ export class TeamDataService {
|
|||
return this.controllerFactory(teamName);
|
||||
}
|
||||
|
||||
private async readTeamLaneMutationContext(teamName: string): Promise<{
|
||||
leadProviderId: TeamProviderId | undefined;
|
||||
activeMembers: ReturnType<typeof toProvisioningMemberShape>;
|
||||
currentMixed: boolean;
|
||||
}> {
|
||||
const [teamMeta, activeMembersRaw, bootstrapSnapshot, persistedLaunchSnapshot] =
|
||||
await Promise.all([
|
||||
this.teamMetaStore.getMeta(teamName).catch(() => null),
|
||||
this.membersMetaStore.getMembers(teamName).catch(() => []),
|
||||
readBootstrapLaunchSnapshot(teamName).catch(() => null),
|
||||
this.launchStateStore.read(teamName).catch(() => null),
|
||||
]);
|
||||
|
||||
const preferredLaunchSnapshot = choosePreferredLaunchSnapshot(
|
||||
bootstrapSnapshot,
|
||||
persistedLaunchSnapshot
|
||||
);
|
||||
const leadProviderId =
|
||||
teamMeta?.launchIdentity?.providerId ?? normalizeOptionalTeamProviderId(teamMeta?.providerId);
|
||||
const activeMembers = toProvisioningMemberShape(activeMembersRaw);
|
||||
const currentPlan = fromProvisioningMembers(leadProviderId, activeMembers);
|
||||
const currentMixed =
|
||||
hasMixedPersistedLaunchMetadata(preferredLaunchSnapshot) ||
|
||||
(currentPlan.ok && isMixedOpenCodeSideLanePlan(currentPlan.plan));
|
||||
|
||||
return {
|
||||
leadProviderId,
|
||||
activeMembers,
|
||||
currentMixed,
|
||||
};
|
||||
}
|
||||
|
||||
private async assertRosterMutationAllowed(
|
||||
teamName: string,
|
||||
nextMembers: ReturnType<typeof toProvisioningMemberShape>
|
||||
): Promise<void> {
|
||||
const context = await this.readTeamLaneMutationContext(teamName);
|
||||
const nextPlan = fromProvisioningMembers(context.leadProviderId, nextMembers);
|
||||
if (!nextPlan.ok) {
|
||||
throw new Error(nextPlan.message);
|
||||
}
|
||||
const nextMixed = isMixedOpenCodeSideLanePlan(nextPlan.plan);
|
||||
if (!(context.currentMixed || nextMixed)) {
|
||||
return;
|
||||
}
|
||||
const isRunning = (await this.readProcesses(teamName).catch(() => [] as TeamProcess[])).some(
|
||||
(process) => !process.stoppedAt
|
||||
);
|
||||
if (isRunning) {
|
||||
if (
|
||||
!isSupportedRunningMixedRosterMutation({
|
||||
leadProviderId: context.leadProviderId,
|
||||
previousMembers: context.activeMembers,
|
||||
nextMembers,
|
||||
})
|
||||
) {
|
||||
throw new Error(MIXED_TEAM_LIVE_MUTATION_BLOCK_MESSAGE);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
setMemberRuntimeAdvisoryService(service: TeamMemberRuntimeAdvisoryService): void {
|
||||
this.memberRuntimeAdvisoryService = service;
|
||||
}
|
||||
|
||||
private async synthesizeLeadMemberIfMissing(
|
||||
teamName: string,
|
||||
config: TeamConfig,
|
||||
members: TeamMemberSnapshot[],
|
||||
tasks: TeamTaskWithKanban[],
|
||||
teamMeta?: TeamMetaFile | null
|
||||
): Promise<void> {
|
||||
if (hasVisibleLeadMember(members) || hasExplicitLeadInConfig(config)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (typeof teamMeta === 'undefined') {
|
||||
try {
|
||||
teamMeta = await this.teamMetaStore.getMeta(teamName);
|
||||
} catch {
|
||||
teamMeta = null;
|
||||
}
|
||||
}
|
||||
|
||||
const launchIdentity = teamMeta?.launchIdentity;
|
||||
const leadName = 'team-lead';
|
||||
const ownedTasks = tasks.filter((task) => task.owner === leadName);
|
||||
const currentTask =
|
||||
ownedTasks.find(
|
||||
(task) =>
|
||||
task.status === 'in_progress' &&
|
||||
task.reviewState !== 'approved' &&
|
||||
task.kanbanColumn !== 'approved'
|
||||
) ?? null;
|
||||
|
||||
members.unshift({
|
||||
name: leadName,
|
||||
agentId: undefined,
|
||||
currentTaskId: currentTask?.id ?? null,
|
||||
taskCount: ownedTasks.length,
|
||||
color: getMemberColorByName(leadName),
|
||||
agentType: 'team-lead',
|
||||
role: 'Team Lead',
|
||||
workflow: undefined,
|
||||
isolation: undefined,
|
||||
providerId: launchIdentity?.providerId ?? teamMeta?.providerId,
|
||||
providerBackendId:
|
||||
launchIdentity?.providerBackendId ??
|
||||
migrateProviderBackendId(teamMeta?.providerId, teamMeta?.providerBackendId) ??
|
||||
undefined,
|
||||
model:
|
||||
launchIdentity?.resolvedLaunchModel ?? launchIdentity?.selectedModel ?? teamMeta?.model,
|
||||
effort:
|
||||
launchIdentity?.resolvedEffort ??
|
||||
launchIdentity?.selectedEffort ??
|
||||
(isTeamEffortLevel(teamMeta?.effort) ? teamMeta?.effort : undefined),
|
||||
selectedFastMode: launchIdentity?.selectedFastMode ?? teamMeta?.fastMode ?? undefined,
|
||||
resolvedFastMode:
|
||||
typeof launchIdentity?.resolvedFastMode === 'boolean'
|
||||
? launchIdentity.resolvedFastMode
|
||||
: undefined,
|
||||
laneId: 'primary',
|
||||
laneKind: 'primary',
|
||||
laneOwnerProviderId: launchIdentity?.providerId ?? teamMeta?.providerId ?? 'anthropic',
|
||||
cwd: config.projectPath ?? teamMeta?.cwd,
|
||||
removedAt: undefined,
|
||||
});
|
||||
}
|
||||
|
||||
private getTaskLabel(task: Pick<TeamTask, 'id' | 'displayId'>): string {
|
||||
return formatTaskDisplayLabel(task);
|
||||
}
|
||||
|
|
@ -809,6 +1110,24 @@ export class TeamDataService {
|
|||
warningText: 'Member metadata failed to load',
|
||||
load: () => this.membersMetaStore.getMembers(teamName),
|
||||
});
|
||||
const teamMetaStep = startReadStep({
|
||||
label: 'teamMeta',
|
||||
createFallback: () => null,
|
||||
warningText: 'Team runtime metadata failed to load',
|
||||
load: () => this.teamMetaStore.getMeta(teamName),
|
||||
});
|
||||
const launchStateStep = startReadStep({
|
||||
label: 'launchState',
|
||||
createFallback: () => null,
|
||||
warningText: 'Launch state failed to load',
|
||||
load: async () => {
|
||||
const [bootstrapSnapshot, launchSnapshot] = await Promise.all([
|
||||
readBootstrapLaunchSnapshot(teamName),
|
||||
this.launchStateStore.read(teamName),
|
||||
]);
|
||||
return choosePreferredLaunchSnapshot(bootstrapSnapshot, launchSnapshot);
|
||||
},
|
||||
});
|
||||
const kanbanStateStep = startReadStep({
|
||||
label: 'kanbanState',
|
||||
createFallback: (): KanbanState => ({
|
||||
|
|
@ -827,8 +1146,21 @@ export class TeamDataService {
|
|||
load: () => this.taskReader.getTasks(teamName),
|
||||
})
|
||||
);
|
||||
const [tasksStepResult, inboxNamesStepResult, metaMembersStepResult, kanbanStateStepResult] =
|
||||
await Promise.all([tasksStep, inboxNamesStep, metaMembersStep, kanbanStateStep]);
|
||||
const [
|
||||
tasksStepResult,
|
||||
inboxNamesStepResult,
|
||||
metaMembersStepResult,
|
||||
teamMetaStepResult,
|
||||
launchStateStepResult,
|
||||
kanbanStateStepResult,
|
||||
] = await Promise.all([
|
||||
tasksStep,
|
||||
inboxNamesStep,
|
||||
metaMembersStep,
|
||||
teamMetaStep,
|
||||
launchStateStep,
|
||||
kanbanStateStep,
|
||||
]);
|
||||
|
||||
// After parallelizing the top read phase, these marks no longer represent
|
||||
// serial stage boundaries. They now capture the actual completion time for
|
||||
|
|
@ -837,11 +1169,15 @@ export class TeamDataService {
|
|||
marks.tasks = tasksStepResult.completedAt;
|
||||
marks.inboxNames = inboxNamesStepResult.completedAt;
|
||||
marks.metaMembers = metaMembersStepResult.completedAt;
|
||||
marks.teamMeta = teamMetaStepResult.completedAt;
|
||||
marks.launchState = launchStateStepResult.completedAt;
|
||||
marks.kanbanState = kanbanStateStepResult.completedAt;
|
||||
|
||||
if (tasksStepResult.warning) warnings.push(tasksStepResult.warning);
|
||||
if (inboxNamesStepResult.warning) warnings.push(inboxNamesStepResult.warning);
|
||||
if (metaMembersStepResult.warning) warnings.push(metaMembersStepResult.warning);
|
||||
if (teamMetaStepResult.warning) warnings.push(teamMetaStepResult.warning);
|
||||
if (launchStateStepResult.warning) warnings.push(launchStateStepResult.warning);
|
||||
if (kanbanStateStepResult.warning) warnings.push(kanbanStateStepResult.warning);
|
||||
|
||||
const tasks: TeamTask[] = tasksStepResult.value;
|
||||
|
|
@ -849,6 +1185,8 @@ export class TeamDataService {
|
|||
mark('postStart');
|
||||
|
||||
const metaMembers: TeamConfig['members'] = metaMembersStepResult.value;
|
||||
const teamMeta: TeamMetaFile | null = teamMetaStepResult.value;
|
||||
const launchSnapshot = launchStateStepResult.value;
|
||||
const kanbanState: KanbanState = kanbanStateStepResult.value;
|
||||
|
||||
mark('kanbanGc');
|
||||
|
|
@ -879,8 +1217,22 @@ export class TeamDataService {
|
|||
config,
|
||||
metaMembers,
|
||||
inboxNames,
|
||||
tasksWithKanban
|
||||
tasksWithKanban,
|
||||
{
|
||||
launchSnapshot,
|
||||
leadProviderId: teamMeta?.launchIdentity?.providerId ?? teamMeta?.providerId,
|
||||
leadProviderBackendId:
|
||||
teamMeta?.launchIdentity?.providerBackendId ??
|
||||
migrateProviderBackendId(teamMeta?.providerId, teamMeta?.providerBackendId) ??
|
||||
undefined,
|
||||
leadFastMode: teamMeta?.launchIdentity?.selectedFastMode ?? teamMeta?.fastMode ?? undefined,
|
||||
leadResolvedFastMode:
|
||||
typeof teamMeta?.launchIdentity?.resolvedFastMode === 'boolean'
|
||||
? teamMeta.launchIdentity.resolvedFastMode
|
||||
: undefined,
|
||||
}
|
||||
);
|
||||
await this.synthesizeLeadMemberIfMissing(teamName, config, members, tasksWithKanban, teamMeta);
|
||||
mark('resolveMembers');
|
||||
|
||||
try {
|
||||
|
|
@ -1279,16 +1631,17 @@ export class TeamDataService {
|
|||
role: request.role?.trim() || undefined,
|
||||
workflow: request.workflow?.trim() || undefined,
|
||||
isolation: request.isolation === 'worktree' ? ('worktree' as const) : undefined,
|
||||
providerId:
|
||||
request.providerId === 'codex' || request.providerId === 'gemini'
|
||||
? request.providerId
|
||||
: undefined,
|
||||
providerId: normalizeOptionalTeamProviderId(request.providerId),
|
||||
model: request.model?.trim() || undefined,
|
||||
effort: isTeamEffortLevel(request.effort) ? request.effort : undefined,
|
||||
agentType: 'general-purpose',
|
||||
joinedAt: Date.now(),
|
||||
};
|
||||
|
||||
await this.assertRosterMutationAllowed(
|
||||
teamName,
|
||||
toProvisioningMemberShape([...members, newMember])
|
||||
);
|
||||
const nextMembers = applyDistinctRosterColors([...members, newMember]);
|
||||
await this.membersMetaStore.writeMembers(teamName, nextMembers);
|
||||
}
|
||||
|
|
@ -1311,20 +1664,7 @@ export class TeamDataService {
|
|||
return { oldRole, changed: true };
|
||||
}
|
||||
|
||||
async replaceMembers(
|
||||
teamName: string,
|
||||
request: {
|
||||
members: {
|
||||
name: string;
|
||||
role?: string;
|
||||
workflow?: string;
|
||||
isolation?: 'worktree';
|
||||
providerId?: TeamProviderId;
|
||||
model?: string;
|
||||
effort?: TeamMember['effort'];
|
||||
}[];
|
||||
}
|
||||
): Promise<void> {
|
||||
async replaceMembers(teamName: string, request: ReplaceMembersRequest): Promise<void> {
|
||||
const existing = await this.membersMetaStore.getMembers(teamName);
|
||||
const existingLead = existing.find(isLeadMember) ?? null;
|
||||
const existingByName = new Map(existing.map((m) => [m.name.toLowerCase(), m]));
|
||||
|
|
@ -1363,8 +1703,13 @@ export class TeamDataService {
|
|||
workflow: member.workflow?.trim() || undefined,
|
||||
isolation: member.isolation === 'worktree' ? ('worktree' as const) : undefined,
|
||||
providerId: normalizeOptionalTeamProviderId(member.providerId),
|
||||
providerBackendId: migrateProviderBackendId(member.providerId, member.providerBackendId),
|
||||
model: member.model?.trim() || undefined,
|
||||
effort: isTeamEffortLevel(member.effort) ? member.effort : undefined,
|
||||
fastMode:
|
||||
member.fastMode === 'inherit' || member.fastMode === 'on' || member.fastMode === 'off'
|
||||
? member.fastMode
|
||||
: undefined,
|
||||
agentType: prev?.agentType ?? 'general-purpose',
|
||||
agentId: isSameActiveMember ? prev?.agentId : undefined,
|
||||
color: prev?.color,
|
||||
|
|
@ -1373,6 +1718,7 @@ export class TeamDataService {
|
|||
};
|
||||
})
|
||||
);
|
||||
await this.assertRosterMutationAllowed(teamName, toProvisioningMemberShape(nextActive));
|
||||
|
||||
// Preserve/mark removed members so stale inbox files don't resurrect them in the UI.
|
||||
const nextRemoved: TeamMember[] = [];
|
||||
|
|
@ -1408,6 +1754,14 @@ export class TeamDataService {
|
|||
throw new Error('Cannot remove team lead');
|
||||
}
|
||||
|
||||
await this.assertRosterMutationAllowed(
|
||||
teamName,
|
||||
toProvisioningMemberShape(
|
||||
members.filter(
|
||||
(candidate) => candidate.name.trim().toLowerCase() !== memberName.trim().toLowerCase()
|
||||
)
|
||||
)
|
||||
);
|
||||
member.removedAt = Date.now();
|
||||
await this.membersMetaStore.writeMembers(teamName, members);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,9 +1,12 @@
|
|||
import { isLeadMember } from '@shared/utils/leadDetection';
|
||||
import { migrateProviderBackendId } from '@shared/utils/providerBackend';
|
||||
import { normalizeOptionalTeamProviderId } from '@shared/utils/teamProvider';
|
||||
|
||||
import type {
|
||||
MemberLaunchState,
|
||||
MemberSpawnLivenessSource,
|
||||
MemberSpawnStatusEntry,
|
||||
ProviderModelLaunchIdentity,
|
||||
PersistedTeamLaunchMemberSources,
|
||||
PersistedTeamLaunchMemberState,
|
||||
PersistedTeamLaunchPhase,
|
||||
|
|
@ -106,6 +109,27 @@ export function summarizePersistedLaunchMembers(
|
|||
return { confirmedCount, pendingCount, failedCount, runtimeAlivePendingCount };
|
||||
}
|
||||
|
||||
export function hasMixedPersistedLaunchMetadata(
|
||||
snapshot: PersistedTeamLaunchSnapshot | null | undefined
|
||||
): boolean {
|
||||
if (!snapshot) {
|
||||
return false;
|
||||
}
|
||||
if (
|
||||
Array.isArray(snapshot.bootstrapExpectedMembers) &&
|
||||
snapshot.bootstrapExpectedMembers.join('\u0000') !== snapshot.expectedMembers.join('\u0000')
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
return Object.values(snapshot.members).some(
|
||||
(member) =>
|
||||
Boolean(member?.laneId) ||
|
||||
Boolean(member?.laneKind) ||
|
||||
Boolean(member?.laneOwnerProviderId) ||
|
||||
Boolean(member?.launchIdentity)
|
||||
);
|
||||
}
|
||||
|
||||
function deriveMemberLaunchState(
|
||||
member: Pick<
|
||||
PersistedTeamLaunchMemberState,
|
||||
|
|
@ -128,6 +152,83 @@ function toBoolean(value: unknown): boolean {
|
|||
return value === true;
|
||||
}
|
||||
|
||||
function normalizeFastMode(value: unknown): PersistedTeamLaunchMemberState['selectedFastMode'] {
|
||||
return value === 'inherit' || value === 'on' || value === 'off' ? value : undefined;
|
||||
}
|
||||
|
||||
function normalizeLaunchIdentity(
|
||||
value: unknown,
|
||||
fallbackProviderId?: PersistedTeamLaunchMemberState['providerId']
|
||||
): ProviderModelLaunchIdentity | undefined {
|
||||
if (!value || typeof value !== 'object') {
|
||||
return undefined;
|
||||
}
|
||||
const raw = value as Record<string, unknown>;
|
||||
const providerId =
|
||||
normalizeOptionalTeamProviderId(raw.providerId) ??
|
||||
normalizeOptionalTeamProviderId(fallbackProviderId);
|
||||
if (!providerId) {
|
||||
return undefined;
|
||||
}
|
||||
const selectedModelKind =
|
||||
raw.selectedModelKind === 'explicit' || raw.selectedModelKind === 'default'
|
||||
? raw.selectedModelKind
|
||||
: 'default';
|
||||
const catalogSource =
|
||||
raw.catalogSource === 'anthropic-models-api' ||
|
||||
raw.catalogSource === 'app-server' ||
|
||||
raw.catalogSource === 'static-fallback' ||
|
||||
raw.catalogSource === 'runtime' ||
|
||||
raw.catalogSource === 'unavailable'
|
||||
? raw.catalogSource
|
||||
: 'unavailable';
|
||||
return {
|
||||
providerId,
|
||||
providerBackendId:
|
||||
migrateProviderBackendId(
|
||||
providerId,
|
||||
typeof raw.providerBackendId === 'string' ? raw.providerBackendId : undefined
|
||||
) ?? null,
|
||||
selectedModel: typeof raw.selectedModel === 'string' ? raw.selectedModel.trim() || null : null,
|
||||
selectedModelKind,
|
||||
resolvedLaunchModel:
|
||||
typeof raw.resolvedLaunchModel === 'string' ? raw.resolvedLaunchModel.trim() || null : null,
|
||||
catalogId: typeof raw.catalogId === 'string' ? raw.catalogId.trim() || null : null,
|
||||
catalogSource,
|
||||
catalogFetchedAt:
|
||||
typeof raw.catalogFetchedAt === 'string' ? raw.catalogFetchedAt.trim() || null : null,
|
||||
selectedEffort:
|
||||
raw.selectedEffort === 'none' ||
|
||||
raw.selectedEffort === 'minimal' ||
|
||||
raw.selectedEffort === 'low' ||
|
||||
raw.selectedEffort === 'medium' ||
|
||||
raw.selectedEffort === 'high' ||
|
||||
raw.selectedEffort === 'xhigh' ||
|
||||
raw.selectedEffort === 'max'
|
||||
? raw.selectedEffort
|
||||
: null,
|
||||
resolvedEffort:
|
||||
raw.resolvedEffort === 'none' ||
|
||||
raw.resolvedEffort === 'minimal' ||
|
||||
raw.resolvedEffort === 'low' ||
|
||||
raw.resolvedEffort === 'medium' ||
|
||||
raw.resolvedEffort === 'high' ||
|
||||
raw.resolvedEffort === 'xhigh' ||
|
||||
raw.resolvedEffort === 'max'
|
||||
? raw.resolvedEffort
|
||||
: null,
|
||||
selectedFastMode:
|
||||
raw.selectedFastMode === 'inherit' ||
|
||||
raw.selectedFastMode === 'on' ||
|
||||
raw.selectedFastMode === 'off'
|
||||
? raw.selectedFastMode
|
||||
: null,
|
||||
resolvedFastMode: typeof raw.resolvedFastMode === 'boolean' ? raw.resolvedFastMode : null,
|
||||
fastResolutionReason:
|
||||
typeof raw.fastResolutionReason === 'string' ? raw.fastResolutionReason.trim() || null : null,
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeSources(value: unknown): PersistedTeamLaunchMemberSources | undefined {
|
||||
if (!value || typeof value !== 'object') {
|
||||
return undefined;
|
||||
|
|
@ -158,8 +259,35 @@ function normalizePersistedMemberState(
|
|||
if (!normalizedName || normalizedName === 'user' || isLeadMember({ name: normalizedName })) {
|
||||
return null;
|
||||
}
|
||||
const providerId = normalizeOptionalTeamProviderId(parsed.providerId);
|
||||
const next: PersistedTeamLaunchMemberState = {
|
||||
name: normalizedName,
|
||||
providerId,
|
||||
providerBackendId: migrateProviderBackendId(
|
||||
providerId,
|
||||
typeof parsed.providerBackendId === 'string' ? parsed.providerBackendId : undefined
|
||||
),
|
||||
model: typeof parsed.model === 'string' ? parsed.model.trim() || undefined : undefined,
|
||||
effort:
|
||||
parsed.effort === 'none' ||
|
||||
parsed.effort === 'minimal' ||
|
||||
parsed.effort === 'low' ||
|
||||
parsed.effort === 'medium' ||
|
||||
parsed.effort === 'high' ||
|
||||
parsed.effort === 'xhigh' ||
|
||||
parsed.effort === 'max'
|
||||
? parsed.effort
|
||||
: undefined,
|
||||
selectedFastMode: normalizeFastMode(parsed.selectedFastMode),
|
||||
resolvedFastMode:
|
||||
typeof parsed.resolvedFastMode === 'boolean' ? parsed.resolvedFastMode : undefined,
|
||||
laneId: typeof parsed.laneId === 'string' ? parsed.laneId.trim() || undefined : undefined,
|
||||
laneKind:
|
||||
parsed.laneKind === 'primary' || parsed.laneKind === 'secondary'
|
||||
? parsed.laneKind
|
||||
: undefined,
|
||||
laneOwnerProviderId: normalizeOptionalTeamProviderId(parsed.laneOwnerProviderId),
|
||||
launchIdentity: normalizeLaunchIdentity(parsed.launchIdentity, providerId),
|
||||
launchState: 'starting',
|
||||
agentToolAccepted: toBoolean(parsed.agentToolAccepted),
|
||||
runtimeAlive: toBoolean(parsed.runtimeAlive),
|
||||
|
|
@ -199,6 +327,7 @@ function normalizePersistedMemberState(
|
|||
export function createPersistedLaunchSnapshot(params: {
|
||||
teamName: string;
|
||||
expectedMembers: readonly string[];
|
||||
bootstrapExpectedMembers?: readonly string[];
|
||||
leadSessionId?: string;
|
||||
launchPhase?: PersistedTeamLaunchPhase;
|
||||
members?: Record<string, PersistedTeamLaunchMemberState>;
|
||||
|
|
@ -212,6 +341,13 @@ export function createPersistedLaunchSnapshot(params: {
|
|||
.filter((name) => name.length > 0 && name !== 'user' && !isLeadMember({ name }))
|
||||
)
|
||||
);
|
||||
const bootstrapExpectedMembers = Array.from(
|
||||
new Set(
|
||||
(params.bootstrapExpectedMembers ?? expectedMembers)
|
||||
.map(normalizeMemberName)
|
||||
.filter((name) => name.length > 0 && name !== 'user' && !isLeadMember({ name }))
|
||||
)
|
||||
);
|
||||
const members = params.members ?? {};
|
||||
const launchPhase = params.launchPhase ?? 'active';
|
||||
|
||||
|
|
@ -246,6 +382,10 @@ export function createPersistedLaunchSnapshot(params: {
|
|||
...(params.leadSessionId ? { leadSessionId: params.leadSessionId } : {}),
|
||||
launchPhase,
|
||||
expectedMembers,
|
||||
...(bootstrapExpectedMembers.length > 0 &&
|
||||
bootstrapExpectedMembers.join('\u0000') !== expectedMembers.join('\u0000')
|
||||
? { bootstrapExpectedMembers }
|
||||
: {}),
|
||||
members,
|
||||
summary,
|
||||
teamLaunchState: deriveTeamLaunchAggregateState(summary),
|
||||
|
|
@ -416,6 +556,11 @@ export function normalizePersistedLaunchSnapshot(
|
|||
(name): name is string => typeof name === 'string' && normalizeMemberName(name).length > 0
|
||||
)
|
||||
: [];
|
||||
const bootstrapExpectedMembers = Array.isArray(record.bootstrapExpectedMembers)
|
||||
? record.bootstrapExpectedMembers.filter(
|
||||
(name): name is string => typeof name === 'string' && normalizeMemberName(name).length > 0
|
||||
)
|
||||
: undefined;
|
||||
const updatedAt =
|
||||
typeof record.updatedAt === 'string' && record.updatedAt.trim().length > 0
|
||||
? record.updatedAt
|
||||
|
|
@ -446,6 +591,7 @@ export function normalizePersistedLaunchSnapshot(
|
|||
record.launchPhase === 'reconciled'
|
||||
? record.launchPhase
|
||||
: 'finished',
|
||||
bootstrapExpectedMembers,
|
||||
members: normalizedMembers,
|
||||
updatedAt,
|
||||
});
|
||||
|
|
|
|||
|
|
@ -5,6 +5,10 @@ import * as path from 'path';
|
|||
|
||||
import { atomicWriteAsync } from './atomicWrite';
|
||||
import { normalizePersistedLaunchSnapshot } from './TeamLaunchStateEvaluator';
|
||||
import {
|
||||
createPersistedLaunchSummaryProjection,
|
||||
TEAM_LAUNCH_SUMMARY_FILE,
|
||||
} from './TeamLaunchSummaryProjection';
|
||||
|
||||
import type { PersistedTeamLaunchSnapshot } from '@shared/types';
|
||||
|
||||
|
|
@ -16,6 +20,10 @@ export function getTeamLaunchStatePath(teamName: string): string {
|
|||
return path.join(getTeamsBasePath(), teamName, TEAM_LAUNCH_STATE_FILE);
|
||||
}
|
||||
|
||||
export function getTeamLaunchSummaryPath(teamName: string): string {
|
||||
return path.join(getTeamsBasePath(), teamName, TEAM_LAUNCH_SUMMARY_FILE);
|
||||
}
|
||||
|
||||
export class TeamLaunchStateStore {
|
||||
async read(teamName: string): Promise<PersistedTeamLaunchSnapshot | null> {
|
||||
const targetPath = getTeamLaunchStatePath(teamName);
|
||||
|
|
@ -37,6 +45,10 @@ export class TeamLaunchStateStore {
|
|||
getTeamLaunchStatePath(teamName),
|
||||
`${JSON.stringify(snapshot, null, 2)}\n`
|
||||
);
|
||||
await atomicWriteAsync(
|
||||
getTeamLaunchSummaryPath(teamName),
|
||||
`${JSON.stringify(createPersistedLaunchSummaryProjection(snapshot), null, 2)}\n`
|
||||
);
|
||||
} catch (error) {
|
||||
logger.warn(
|
||||
`[${teamName}] Failed to persist launch-state: ${
|
||||
|
|
@ -49,6 +61,7 @@ export class TeamLaunchStateStore {
|
|||
async clear(teamName: string): Promise<void> {
|
||||
try {
|
||||
await fs.promises.rm(getTeamLaunchStatePath(teamName), { force: true });
|
||||
await fs.promises.rm(getTeamLaunchSummaryPath(teamName), { force: true });
|
||||
} catch {
|
||||
// best-effort
|
||||
}
|
||||
|
|
|
|||
224
src/main/services/team/TeamLaunchSummaryProjection.ts
Normal file
224
src/main/services/team/TeamLaunchSummaryProjection.ts
Normal file
|
|
@ -0,0 +1,224 @@
|
|||
import {
|
||||
isMixedOpenCodeSideLanePlan,
|
||||
planTeamRuntimeLanes,
|
||||
} from '@features/team-runtime-lanes/core/domain/planTeamRuntimeLanes';
|
||||
import { shouldIgnoreTerminalBootstrapOnlyPendingSnapshot } from './TeamBootstrapStateReader';
|
||||
import { hasMixedPersistedLaunchMetadata } from './TeamLaunchStateEvaluator';
|
||||
import { normalizeOptionalTeamProviderId } from '@shared/utils/teamProvider';
|
||||
|
||||
import type { PersistedTeamLaunchSnapshot, TeamProviderId, TeamSummary } from '@shared/types';
|
||||
|
||||
export const TEAM_LAUNCH_SUMMARY_FILE = 'launch-summary.json';
|
||||
|
||||
export interface LaunchStateSummary {
|
||||
partialLaunchFailure?: true;
|
||||
expectedMemberCount?: number;
|
||||
confirmedMemberCount?: number;
|
||||
missingMembers?: string[];
|
||||
teamLaunchState?: TeamSummary['teamLaunchState'];
|
||||
launchUpdatedAt?: string;
|
||||
confirmedCount?: number;
|
||||
pendingCount?: number;
|
||||
failedCount?: number;
|
||||
runtimeAlivePendingCount?: number;
|
||||
}
|
||||
|
||||
export interface PersistedTeamLaunchSummaryProjection extends LaunchStateSummary {
|
||||
version: 1;
|
||||
teamName: string;
|
||||
updatedAt: string;
|
||||
mixedAware?: true;
|
||||
}
|
||||
|
||||
function normalizeIsoDate(value: unknown): string | null {
|
||||
if (typeof value !== 'string') {
|
||||
return null;
|
||||
}
|
||||
const trimmed = value.trim();
|
||||
return trimmed.length > 0 ? trimmed : null;
|
||||
}
|
||||
|
||||
function toMillis(value: string | undefined | null): number {
|
||||
if (!value) {
|
||||
return Number.NaN;
|
||||
}
|
||||
return Date.parse(value);
|
||||
}
|
||||
|
||||
export function createLaunchStateSummary(
|
||||
snapshot: PersistedTeamLaunchSnapshot
|
||||
): LaunchStateSummary {
|
||||
const missingMembers = snapshot.expectedMembers.filter((name) => {
|
||||
const member = snapshot.members[name];
|
||||
return member?.launchState === 'failed_to_start';
|
||||
});
|
||||
|
||||
return {
|
||||
...(snapshot.teamLaunchState === 'partial_failure'
|
||||
? { partialLaunchFailure: true as const }
|
||||
: {}),
|
||||
...(snapshot.expectedMembers.length > 0
|
||||
? { expectedMemberCount: snapshot.expectedMembers.length }
|
||||
: {}),
|
||||
...(snapshot.summary.confirmedCount > 0
|
||||
? { confirmedMemberCount: snapshot.summary.confirmedCount }
|
||||
: {}),
|
||||
...(missingMembers.length > 0 ? { missingMembers } : {}),
|
||||
teamLaunchState: snapshot.teamLaunchState,
|
||||
launchUpdatedAt: snapshot.updatedAt,
|
||||
confirmedCount: snapshot.summary.confirmedCount,
|
||||
pendingCount: snapshot.summary.pendingCount,
|
||||
failedCount: snapshot.summary.failedCount,
|
||||
runtimeAlivePendingCount: snapshot.summary.runtimeAlivePendingCount,
|
||||
};
|
||||
}
|
||||
|
||||
export function createPersistedLaunchSummaryProjection(
|
||||
snapshot: PersistedTeamLaunchSnapshot
|
||||
): PersistedTeamLaunchSummaryProjection {
|
||||
return {
|
||||
version: 1,
|
||||
teamName: snapshot.teamName,
|
||||
updatedAt: snapshot.updatedAt,
|
||||
...(hasMixedPersistedLaunchMetadata(snapshot) ? { mixedAware: true as const } : {}),
|
||||
...createLaunchStateSummary(snapshot),
|
||||
};
|
||||
}
|
||||
|
||||
export function normalizePersistedLaunchSummaryProjection(
|
||||
teamName: string,
|
||||
value: unknown
|
||||
): PersistedTeamLaunchSummaryProjection | null {
|
||||
if (!value || typeof value !== 'object') {
|
||||
return null;
|
||||
}
|
||||
const record = value as Record<string, unknown>;
|
||||
if (record.version !== 1) {
|
||||
return null;
|
||||
}
|
||||
const updatedAt = normalizeIsoDate(record.updatedAt);
|
||||
if (!updatedAt) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const normalized: PersistedTeamLaunchSummaryProjection = {
|
||||
version: 1,
|
||||
teamName,
|
||||
updatedAt,
|
||||
...(record.mixedAware === true ? { mixedAware: true as const } : {}),
|
||||
};
|
||||
|
||||
if (record.partialLaunchFailure === true) {
|
||||
normalized.partialLaunchFailure = true;
|
||||
}
|
||||
if (typeof record.expectedMemberCount === 'number' && record.expectedMemberCount >= 0) {
|
||||
normalized.expectedMemberCount = record.expectedMemberCount;
|
||||
}
|
||||
if (typeof record.confirmedMemberCount === 'number' && record.confirmedMemberCount >= 0) {
|
||||
normalized.confirmedMemberCount = record.confirmedMemberCount;
|
||||
}
|
||||
if (Array.isArray(record.missingMembers)) {
|
||||
const missingMembers = record.missingMembers.filter(
|
||||
(member): member is string => typeof member === 'string' && member.trim().length > 0
|
||||
);
|
||||
if (missingMembers.length > 0) {
|
||||
normalized.missingMembers = missingMembers;
|
||||
}
|
||||
}
|
||||
if (
|
||||
record.teamLaunchState === 'partial_failure' ||
|
||||
record.teamLaunchState === 'partial_pending' ||
|
||||
record.teamLaunchState === 'clean_success'
|
||||
) {
|
||||
normalized.teamLaunchState = record.teamLaunchState;
|
||||
}
|
||||
if (typeof record.confirmedCount === 'number' && record.confirmedCount >= 0) {
|
||||
normalized.confirmedCount = record.confirmedCount;
|
||||
}
|
||||
if (typeof record.pendingCount === 'number' && record.pendingCount >= 0) {
|
||||
normalized.pendingCount = record.pendingCount;
|
||||
}
|
||||
if (typeof record.failedCount === 'number' && record.failedCount >= 0) {
|
||||
normalized.failedCount = record.failedCount;
|
||||
}
|
||||
if (typeof record.runtimeAlivePendingCount === 'number' && record.runtimeAlivePendingCount >= 0) {
|
||||
normalized.runtimeAlivePendingCount = record.runtimeAlivePendingCount;
|
||||
}
|
||||
normalized.launchUpdatedAt = updatedAt;
|
||||
return normalized;
|
||||
}
|
||||
|
||||
export function choosePreferredLaunchStateSummary(params: {
|
||||
bootstrapSnapshot?: PersistedTeamLaunchSnapshot | null;
|
||||
launchSnapshot?: PersistedTeamLaunchSnapshot | null;
|
||||
launchSummaryProjection?: PersistedTeamLaunchSummaryProjection | null;
|
||||
}): LaunchStateSummary | null {
|
||||
if (params.launchSnapshot) {
|
||||
return createLaunchStateSummary(params.launchSnapshot);
|
||||
}
|
||||
|
||||
const bootstrapSnapshot = params.bootstrapSnapshot ?? null;
|
||||
const projection = params.launchSummaryProjection ?? null;
|
||||
if (!bootstrapSnapshot) {
|
||||
return projection;
|
||||
}
|
||||
if (!projection && shouldIgnoreTerminalBootstrapOnlyPendingSnapshot(bootstrapSnapshot)) {
|
||||
return null;
|
||||
}
|
||||
if (!projection) {
|
||||
return createLaunchStateSummary(bootstrapSnapshot);
|
||||
}
|
||||
|
||||
const bootstrapMixedAware = hasMixedPersistedLaunchMetadata(bootstrapSnapshot);
|
||||
const projectionMixedAware = projection.mixedAware === true;
|
||||
if (projectionMixedAware !== bootstrapMixedAware) {
|
||||
return projectionMixedAware ? projection : createLaunchStateSummary(bootstrapSnapshot);
|
||||
}
|
||||
|
||||
const projectionUpdatedAtMs = toMillis(projection.updatedAt);
|
||||
const bootstrapUpdatedAtMs = toMillis(bootstrapSnapshot.updatedAt);
|
||||
if (!Number.isFinite(bootstrapUpdatedAtMs)) {
|
||||
return projection;
|
||||
}
|
||||
if (!Number.isFinite(projectionUpdatedAtMs)) {
|
||||
return createLaunchStateSummary(bootstrapSnapshot);
|
||||
}
|
||||
return projectionUpdatedAtMs >= bootstrapUpdatedAtMs
|
||||
? projection
|
||||
: createLaunchStateSummary(bootstrapSnapshot);
|
||||
}
|
||||
|
||||
export function shouldSuppressLegacyLaunchArtifactHeuristic(params: {
|
||||
leadProviderId?: TeamProviderId;
|
||||
members: readonly { name: string; providerId?: TeamProviderId; removedAt?: unknown }[];
|
||||
}): boolean {
|
||||
const liveMembers = params.members
|
||||
.filter((member) => !member.removedAt)
|
||||
.map((member) => ({
|
||||
name: member.name.trim(),
|
||||
providerId: normalizeOptionalTeamProviderId(member.providerId),
|
||||
}))
|
||||
.filter((member) => member.name.length > 0);
|
||||
|
||||
if (liveMembers.length === 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const normalizedLeadProviderId = normalizeOptionalTeamProviderId(params.leadProviderId);
|
||||
const hasOpenCodeProvider =
|
||||
normalizedLeadProviderId === 'opencode' ||
|
||||
liveMembers.some((member) => member.providerId === 'opencode');
|
||||
const hasNonOpenCodeProvider =
|
||||
(normalizedLeadProviderId != null && normalizedLeadProviderId !== 'opencode') ||
|
||||
liveMembers.some((member) => member.providerId != null && member.providerId !== 'opencode');
|
||||
if (hasOpenCodeProvider && hasNonOpenCodeProvider) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const plan = planTeamRuntimeLanes({
|
||||
leadProviderId: normalizedLeadProviderId,
|
||||
members: liveMembers,
|
||||
});
|
||||
|
||||
return plan.ok && isMixedOpenCodeSideLanePlan(plan.plan);
|
||||
}
|
||||
|
|
@ -1,16 +1,20 @@
|
|||
import { getMemberColorByName } from '@shared/constants/memberColors';
|
||||
import { buildPlannedMemberLaneIdentity } from '@features/team-runtime-lanes/core/domain/planTeamRuntimeLanes';
|
||||
import {
|
||||
createCliAutoSuffixNameGuard,
|
||||
createCliProvisionerNameGuard,
|
||||
} from '@shared/utils/teamMemberName';
|
||||
import { buildTeamMemberColorMap } from '@shared/utils/teamMemberColors';
|
||||
import { getStableTeamOwnerId } from '@shared/utils/teamStableOwnerId';
|
||||
import { migrateProviderBackendId } from '@shared/utils/providerBackend';
|
||||
import { normalizeOptionalTeamProviderId } from '@shared/utils/teamProvider';
|
||||
|
||||
import type {
|
||||
TeamConfig,
|
||||
PersistedTeamLaunchSnapshot,
|
||||
TeamMember,
|
||||
TeamMemberSnapshot,
|
||||
TeamProviderBackendId,
|
||||
TeamProviderId,
|
||||
TeamTaskWithKanban,
|
||||
} from '@shared/types';
|
||||
|
|
@ -65,7 +69,14 @@ export class TeamMemberResolver {
|
|||
config: TeamConfig,
|
||||
metaMembers: TeamConfig['members'],
|
||||
inboxNames: string[],
|
||||
tasks: TeamTaskWithKanban[]
|
||||
tasks: TeamTaskWithKanban[],
|
||||
options?: {
|
||||
launchSnapshot?: PersistedTeamLaunchSnapshot | null;
|
||||
leadProviderId?: TeamProviderId;
|
||||
leadProviderBackendId?: TeamProviderBackendId | null;
|
||||
leadFastMode?: TeamMember['fastMode'];
|
||||
leadResolvedFastMode?: boolean | null;
|
||||
}
|
||||
): TeamMemberSnapshot[] {
|
||||
const names = new Set<string>();
|
||||
const explicitNames = new Set<string>();
|
||||
|
|
@ -99,6 +110,22 @@ export class TeamMemberResolver {
|
|||
}
|
||||
}
|
||||
|
||||
const launchSnapshot = options?.launchSnapshot;
|
||||
if (launchSnapshot) {
|
||||
for (const name of launchSnapshot.expectedMembers) {
|
||||
const trimmed = name.trim();
|
||||
if (!trimmed) continue;
|
||||
addName(trimmed);
|
||||
explicitNames.add(trimmed.toLowerCase());
|
||||
}
|
||||
for (const name of Object.keys(launchSnapshot.members)) {
|
||||
const trimmed = name.trim();
|
||||
if (!trimmed) continue;
|
||||
addName(trimmed);
|
||||
explicitNames.add(trimmed.toLowerCase());
|
||||
}
|
||||
}
|
||||
|
||||
for (const inboxName of inboxNames) {
|
||||
if (typeof inboxName === 'string' && inboxName.trim() !== '') {
|
||||
const trimmed = inboxName.trim();
|
||||
|
|
@ -130,8 +157,10 @@ export class TeamMemberResolver {
|
|||
workflow?: string;
|
||||
isolation?: 'worktree';
|
||||
providerId?: TeamProviderId;
|
||||
providerBackendId?: TeamProviderBackendId;
|
||||
model?: string;
|
||||
effort?: TeamMember['effort'];
|
||||
fastMode?: TeamMember['fastMode'];
|
||||
color?: string;
|
||||
cwd?: string;
|
||||
}
|
||||
|
|
@ -150,8 +179,15 @@ export class TeamMemberResolver {
|
|||
workflow: configMember.workflow,
|
||||
isolation: configMember.isolation === 'worktree' ? ('worktree' as const) : undefined,
|
||||
providerId,
|
||||
providerBackendId: migrateProviderBackendId(providerId, configMember.providerBackendId),
|
||||
model: configMember.model,
|
||||
effort: configMember.effort,
|
||||
fastMode:
|
||||
configMember.fastMode === 'inherit' ||
|
||||
configMember.fastMode === 'on' ||
|
||||
configMember.fastMode === 'off'
|
||||
? configMember.fastMode
|
||||
: undefined,
|
||||
color: configMember.color,
|
||||
cwd: configMember.cwd,
|
||||
});
|
||||
|
|
@ -168,9 +204,12 @@ export class TeamMemberResolver {
|
|||
workflow?: string;
|
||||
isolation?: 'worktree';
|
||||
providerId?: TeamProviderId;
|
||||
providerBackendId?: TeamProviderBackendId;
|
||||
model?: string;
|
||||
effort?: TeamMember['effort'];
|
||||
fastMode?: TeamMember['fastMode'];
|
||||
color?: string;
|
||||
cwd?: string;
|
||||
removedAt?: number;
|
||||
}
|
||||
>();
|
||||
|
|
@ -184,15 +223,36 @@ export class TeamMemberResolver {
|
|||
workflow: member.workflow,
|
||||
isolation: member.isolation === 'worktree' ? ('worktree' as const) : undefined,
|
||||
providerId: member.providerId,
|
||||
providerBackendId: migrateProviderBackendId(
|
||||
member.providerId,
|
||||
member.providerBackendId
|
||||
),
|
||||
model: member.model,
|
||||
effort: member.effort,
|
||||
fastMode:
|
||||
member.fastMode === 'inherit' || member.fastMode === 'on' || member.fastMode === 'off'
|
||||
? member.fastMode
|
||||
: undefined,
|
||||
color: member.color,
|
||||
cwd: member.cwd,
|
||||
removedAt: member.removedAt,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const launchMemberMap = new Map<
|
||||
string,
|
||||
NonNullable<NonNullable<typeof launchSnapshot>['members'][string]>
|
||||
>();
|
||||
if (launchSnapshot) {
|
||||
for (const [memberName, member] of Object.entries(launchSnapshot.members)) {
|
||||
if (typeof memberName === 'string' && memberName.trim().length > 0 && member) {
|
||||
launchMemberMap.set(memberName.trim(), member);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// "user" is a built-in pseudo-member in Claude Code's team framework
|
||||
// (recipient of SendMessage to "user"). It's not a real AI teammate.
|
||||
names.delete('user');
|
||||
|
|
@ -204,8 +264,13 @@ export class TeamMemberResolver {
|
|||
names.delete('lead');
|
||||
}
|
||||
|
||||
// Defense: hide CLI auto-suffixed duplicates (alice-2) when base name (alice) exists.
|
||||
const keepName = createCliAutoSuffixNameGuard(names);
|
||||
// Defense: hide CLI auto-suffixed duplicates (alice-2) only when the base
|
||||
// name still exists as an active member. Removed base members must not hide
|
||||
// active suffixed teammates after live mutation / rollback flows.
|
||||
const activeNamesForAutoSuffix = Array.from(names).filter((name) => {
|
||||
return !metaMemberMap.get(name)?.removedAt;
|
||||
});
|
||||
const keepName = createCliAutoSuffixNameGuard(activeNamesForAutoSuffix);
|
||||
// Defense: hide CLI provisioner artifacts (alice-provisioner) when base name (alice) exists.
|
||||
const keepProvisioner = createCliProvisionerNameGuard(names);
|
||||
for (const name of Array.from(names)) {
|
||||
|
|
@ -226,6 +291,26 @@ export class TeamMemberResolver {
|
|||
) ?? null;
|
||||
const configMember = configMemberMap.get(name);
|
||||
const metaMember = metaMemberMap.get(name);
|
||||
const launchMember = launchMemberMap.get(name);
|
||||
const effectiveProviderId =
|
||||
launchMember?.providerId ??
|
||||
configMember?.providerId ??
|
||||
metaMember?.providerId ??
|
||||
options?.leadProviderId;
|
||||
const plannedLane = buildPlannedMemberLaneIdentity({
|
||||
leadProviderId: options?.leadProviderId,
|
||||
member: {
|
||||
name,
|
||||
providerId: effectiveProviderId,
|
||||
},
|
||||
});
|
||||
const providerBackendId =
|
||||
launchMember?.providerBackendId ??
|
||||
configMember?.providerBackendId ??
|
||||
metaMember?.providerBackendId ??
|
||||
(effectiveProviderId === options?.leadProviderId
|
||||
? (options?.leadProviderBackendId ?? undefined)
|
||||
: undefined);
|
||||
const agentId = configMember?.agentId ?? metaMember?.agentId;
|
||||
members.push({
|
||||
name,
|
||||
|
|
@ -237,10 +322,27 @@ export class TeamMemberResolver {
|
|||
role: configMember?.role ?? metaMember?.role,
|
||||
workflow: configMember?.workflow ?? metaMember?.workflow,
|
||||
isolation: configMember?.isolation ?? metaMember?.isolation,
|
||||
providerId: configMember?.providerId ?? metaMember?.providerId,
|
||||
model: configMember?.model ?? metaMember?.model,
|
||||
effort: configMember?.effort ?? metaMember?.effort,
|
||||
cwd: configMember?.cwd,
|
||||
providerId: effectiveProviderId,
|
||||
providerBackendId,
|
||||
model: launchMember?.model ?? configMember?.model ?? metaMember?.model,
|
||||
effort: launchMember?.effort ?? configMember?.effort ?? metaMember?.effort,
|
||||
selectedFastMode:
|
||||
launchMember?.selectedFastMode ??
|
||||
configMember?.fastMode ??
|
||||
metaMember?.fastMode ??
|
||||
(effectiveProviderId === options?.leadProviderId
|
||||
? (options?.leadFastMode ?? undefined)
|
||||
: undefined),
|
||||
resolvedFastMode:
|
||||
typeof launchMember?.resolvedFastMode === 'boolean'
|
||||
? launchMember.resolvedFastMode
|
||||
: effectiveProviderId === options?.leadProviderId
|
||||
? (options?.leadResolvedFastMode ?? undefined)
|
||||
: undefined,
|
||||
laneId: launchMember?.laneId ?? plannedLane.laneId,
|
||||
laneKind: launchMember?.laneKind ?? plannedLane.laneKind,
|
||||
laneOwnerProviderId: launchMember?.laneOwnerProviderId ?? plannedLane.laneOwnerProviderId,
|
||||
cwd: configMember?.cwd ?? metaMember?.cwd,
|
||||
removedAt: metaMember?.removedAt,
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import { FileReadTimeoutError, readFileUtf8WithTimeout } from '@main/utils/fsRead';
|
||||
import { getTeamsBasePath } from '@main/utils/pathDecoder';
|
||||
import { isTeamEffortLevel } from '@shared/utils/effortLevels';
|
||||
import { migrateProviderBackendId } from '@shared/utils/providerBackend';
|
||||
import { createCliAutoSuffixNameGuard } from '@shared/utils/teamMemberName';
|
||||
import { normalizeOptionalTeamProviderId } from '@shared/utils/teamProvider';
|
||||
import * as fs from 'fs';
|
||||
|
|
@ -26,28 +27,46 @@ function normalizeOptionalBackendId(value: unknown): string | undefined {
|
|||
return trimmed.length > 0 ? trimmed : undefined;
|
||||
}
|
||||
|
||||
function normalizeFastMode(value: unknown): TeamMember['fastMode'] {
|
||||
return value === 'inherit' || value === 'on' || value === 'off' ? value : undefined;
|
||||
}
|
||||
|
||||
function normalizeMember(member: TeamMember): TeamMember | null {
|
||||
const trimmedName = member.name?.trim();
|
||||
if (!trimmedName) {
|
||||
return null;
|
||||
}
|
||||
const providerId = normalizeOptionalTeamProviderId(member.providerId);
|
||||
return {
|
||||
name: trimmedName,
|
||||
role: typeof member.role === 'string' ? member.role.trim() || undefined : undefined,
|
||||
workflow: typeof member.workflow === 'string' ? member.workflow.trim() || undefined : undefined,
|
||||
isolation: member.isolation === 'worktree' ? ('worktree' as const) : undefined,
|
||||
providerId: normalizeOptionalTeamProviderId(member.providerId),
|
||||
providerId,
|
||||
providerBackendId: migrateProviderBackendId(
|
||||
providerId,
|
||||
normalizeOptionalBackendId(member.providerBackendId)
|
||||
),
|
||||
model: typeof member.model === 'string' ? member.model.trim() || undefined : undefined,
|
||||
effort: isTeamEffortLevel(member.effort) ? member.effort : undefined,
|
||||
fastMode: normalizeFastMode(member.fastMode),
|
||||
agentType:
|
||||
typeof member.agentType === 'string' ? member.agentType.trim() || undefined : undefined,
|
||||
color: typeof member.color === 'string' ? member.color.trim() || undefined : undefined,
|
||||
joinedAt: typeof member.joinedAt === 'number' ? member.joinedAt : undefined,
|
||||
agentId: typeof member.agentId === 'string' ? member.agentId : undefined,
|
||||
cwd: typeof member.cwd === 'string' ? member.cwd.trim() || undefined : undefined,
|
||||
removedAt: typeof member.removedAt === 'number' ? member.removedAt : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
function buildActiveNameGuard(membersByName: Map<string, TeamMember>): (name: string) => boolean {
|
||||
const activeNames = Array.from(membersByName.values())
|
||||
.filter((member) => !member.removedAt)
|
||||
.map((member) => member.name);
|
||||
return createCliAutoSuffixNameGuard(activeNames);
|
||||
}
|
||||
|
||||
export class TeamMembersMetaStore {
|
||||
private getMetaPath(teamName: string): string {
|
||||
return path.join(getTeamsBasePath(), teamName, 'members.meta.json');
|
||||
|
|
@ -106,9 +125,11 @@ export class TeamMembersMetaStore {
|
|||
deduped.set(normalized.name, normalized);
|
||||
}
|
||||
|
||||
// Defense: drop CLI auto-suffixed duplicates (alice-2) when base name exists.
|
||||
// Defense: drop CLI auto-suffixed duplicates (alice-2) only when the base
|
||||
// name is still active. Removed base members must not hide active suffixed
|
||||
// teammates after live mutation / rollback flows.
|
||||
const allNames = Array.from(deduped.keys());
|
||||
const keepName = createCliAutoSuffixNameGuard(allNames);
|
||||
const keepName = buildActiveNameGuard(deduped);
|
||||
for (const name of allNames) {
|
||||
if (!keepName(name)) {
|
||||
deduped.delete(name);
|
||||
|
|
@ -140,9 +161,11 @@ export class TeamMembersMetaStore {
|
|||
deduped.set(normalized.name, normalized);
|
||||
}
|
||||
|
||||
// Defense: drop CLI auto-suffixed duplicates (alice-2) when base name exists.
|
||||
// Defense: drop CLI auto-suffixed duplicates (alice-2) only when the base
|
||||
// name is still active. Removed base members must not hide active suffixed
|
||||
// teammates after live mutation / rollback flows.
|
||||
const allNames = Array.from(deduped.keys());
|
||||
const keepName = createCliAutoSuffixNameGuard(allNames);
|
||||
const keepName = buildActiveNameGuard(deduped);
|
||||
for (const name of allNames) {
|
||||
if (!keepName(name)) {
|
||||
deduped.delete(name);
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -49,6 +49,7 @@ export interface OpenCodeTeamLaunchMemberCommandSpec {
|
|||
export interface OpenCodeLaunchTeamCommandBody {
|
||||
mode: OpenCodeTeamLaunchMode;
|
||||
runId: string;
|
||||
laneId: string;
|
||||
teamId: string;
|
||||
teamName: string;
|
||||
projectPath: string;
|
||||
|
|
@ -80,6 +81,7 @@ export interface OpenCodeLaunchTeamCommandData {
|
|||
|
||||
export interface OpenCodeReconcileTeamCommandBody {
|
||||
runId: string;
|
||||
laneId: string;
|
||||
teamId: string;
|
||||
teamName: string;
|
||||
projectPath?: string;
|
||||
|
|
@ -92,6 +94,7 @@ export interface OpenCodeReconcileTeamCommandBody {
|
|||
|
||||
export interface OpenCodeStopTeamCommandBody {
|
||||
runId: string;
|
||||
laneId: string;
|
||||
teamId: string;
|
||||
teamName: string;
|
||||
projectPath?: string;
|
||||
|
|
@ -223,6 +226,7 @@ export interface OpenCodeBridgeHandshake {
|
|||
|
||||
export interface OpenCodeBridgeCommandPreconditions {
|
||||
handshakeIdentityHash: string;
|
||||
laneId: string | null;
|
||||
expectedRunId: string | null;
|
||||
expectedCapabilitySnapshotId: string | null;
|
||||
expectedBehaviorFingerprint: string | null;
|
||||
|
|
@ -486,6 +490,7 @@ export function assertBridgeEvidenceCanCommitToRuntimeStores(input: {
|
|||
export function createOpenCodeBridgeIdempotencyKey(input: {
|
||||
command: OpenCodeBridgeCommandName;
|
||||
teamName: string;
|
||||
laneId?: string | null;
|
||||
runId: string | null;
|
||||
body: unknown;
|
||||
}): string {
|
||||
|
|
@ -493,6 +498,7 @@ export function createOpenCodeBridgeIdempotencyKey(input: {
|
|||
'opencode',
|
||||
sanitizeKeyPart(input.command),
|
||||
sanitizeKeyPart(input.teamName),
|
||||
sanitizeKeyPart(input.laneId ?? 'no-lane'),
|
||||
sanitizeKeyPart(input.runId ?? 'no-run'),
|
||||
].join(':');
|
||||
return `${scope}:${stableHash(input).slice(0, 32)}`;
|
||||
|
|
|
|||
|
|
@ -20,6 +20,7 @@ export interface OpenCodeBridgeCommandLedgerEntry {
|
|||
requestId: string;
|
||||
command: OpenCodeBridgeCommandName;
|
||||
teamName: string;
|
||||
laneId: string | null;
|
||||
runId: string | null;
|
||||
requestHash: string;
|
||||
responseHash: string | null;
|
||||
|
|
@ -33,6 +34,7 @@ export interface OpenCodeBridgeCommandLedgerEntry {
|
|||
export interface OpenCodeBridgeCommandLease {
|
||||
leaseId: string;
|
||||
teamName: string;
|
||||
laneId: string | null;
|
||||
runId: string | null;
|
||||
command: OpenCodeBridgeCommandName;
|
||||
holderPeer: 'claude_team';
|
||||
|
|
@ -68,6 +70,7 @@ export class OpenCodeBridgeCommandLedger {
|
|||
requestId: string;
|
||||
command: OpenCodeBridgeCommandName;
|
||||
teamName: string;
|
||||
laneId?: string | null;
|
||||
runId: string | null;
|
||||
requestHash: string;
|
||||
}): Promise<OpenCodeBridgeLedgerBeginResult> {
|
||||
|
|
@ -110,6 +113,7 @@ export class OpenCodeBridgeCommandLedger {
|
|||
requestId: input.requestId,
|
||||
command: input.command,
|
||||
teamName: input.teamName,
|
||||
laneId: input.laneId ?? null,
|
||||
runId: input.runId,
|
||||
requestHash: input.requestHash,
|
||||
responseHash: null,
|
||||
|
|
@ -216,6 +220,7 @@ export class OpenCodeBridgeCommandLeaseStore {
|
|||
|
||||
async acquire(input: {
|
||||
teamName: string;
|
||||
laneId?: string | null;
|
||||
runId: string | null;
|
||||
command: OpenCodeBridgeCommandName;
|
||||
ttlMs: number;
|
||||
|
|
@ -233,6 +238,7 @@ export class OpenCodeBridgeCommandLeaseStore {
|
|||
const active = normalized.find(
|
||||
(lease) =>
|
||||
lease.teamName === input.teamName &&
|
||||
lease.laneId === (input.laneId ?? null) &&
|
||||
lease.state === 'active' &&
|
||||
Date.parse(lease.expiresAt) > nowMs
|
||||
);
|
||||
|
|
@ -246,6 +252,7 @@ export class OpenCodeBridgeCommandLeaseStore {
|
|||
created = {
|
||||
leaseId: this.idFactory(),
|
||||
teamName: input.teamName,
|
||||
laneId: input.laneId ?? null,
|
||||
runId: input.runId,
|
||||
command: input.command,
|
||||
holderPeer: 'claude_team',
|
||||
|
|
|
|||
|
|
@ -52,7 +52,13 @@ export interface OpenCodeReadinessBridgeOptions {
|
|||
}
|
||||
|
||||
export interface OpenCodeProductionE2EEvidenceReadPort {
|
||||
read(input?: { selectedModel?: string | null; projectPathFingerprint?: string | null }): Promise<{
|
||||
read(input?: {
|
||||
selectedModel?: string | null;
|
||||
projectPathFingerprint?: string | null;
|
||||
opencodeVersion?: string | null;
|
||||
binaryFingerprint?: string | null;
|
||||
capabilitySnapshotId?: string | null;
|
||||
}): Promise<{
|
||||
ok: boolean;
|
||||
evidence: OpenCodeProductionE2EEvidence | null;
|
||||
artifactPath: string;
|
||||
|
|
@ -134,6 +140,9 @@ export class OpenCodeReadinessBridge implements OpenCodeTeamRuntimeBridgePort {
|
|||
? await this.options.productionE2eEvidence.read({
|
||||
selectedModel: expectedModel,
|
||||
projectPathFingerprint,
|
||||
opencodeVersion: input.runtime.version,
|
||||
binaryFingerprint: input.runtime.binaryFingerprint,
|
||||
capabilitySnapshotId: input.runtime.capabilitySnapshotId,
|
||||
})
|
||||
: {
|
||||
ok: false,
|
||||
|
|
@ -204,6 +213,7 @@ export class OpenCodeReadinessBridge implements OpenCodeTeamRuntimeBridgePort {
|
|||
OpenCodeLaunchTeamCommandData
|
||||
>('opencode.launchTeam', input, {
|
||||
teamName: input.teamName,
|
||||
laneId: input.laneId,
|
||||
runId: input.runId,
|
||||
capabilitySnapshotId: input.expectedCapabilitySnapshotId,
|
||||
cwd: input.projectPath,
|
||||
|
|
@ -221,6 +231,7 @@ export class OpenCodeReadinessBridge implements OpenCodeTeamRuntimeBridgePort {
|
|||
OpenCodeLaunchTeamCommandData
|
||||
>('opencode.reconcileTeam', input, {
|
||||
teamName: input.teamName,
|
||||
laneId: input.laneId,
|
||||
runId: input.runId,
|
||||
capabilitySnapshotId: input.expectedCapabilitySnapshotId ?? null,
|
||||
cwd,
|
||||
|
|
@ -236,6 +247,7 @@ export class OpenCodeReadinessBridge implements OpenCodeTeamRuntimeBridgePort {
|
|||
OpenCodeStopTeamCommandData
|
||||
>('opencode.stopTeam', input, {
|
||||
teamName: input.teamName,
|
||||
laneId: input.laneId,
|
||||
runId: input.runId,
|
||||
capabilitySnapshotId: input.expectedCapabilitySnapshotId ?? null,
|
||||
cwd,
|
||||
|
|
@ -269,6 +281,7 @@ export class OpenCodeReadinessBridge implements OpenCodeTeamRuntimeBridgePort {
|
|||
body: TBody,
|
||||
input: {
|
||||
teamName: string;
|
||||
laneId: string;
|
||||
runId: string;
|
||||
capabilitySnapshotId: string | null;
|
||||
cwd: string;
|
||||
|
|
@ -280,6 +293,7 @@ export class OpenCodeReadinessBridge implements OpenCodeTeamRuntimeBridgePort {
|
|||
return await this.options.stateChangingCommands.execute<TBody, TData>({
|
||||
command,
|
||||
teamName: input.teamName,
|
||||
laneId: input.laneId,
|
||||
runId: input.runId,
|
||||
capabilitySnapshotId: input.capabilitySnapshotId,
|
||||
behaviorFingerprint: null,
|
||||
|
|
@ -341,6 +355,7 @@ function blockedReadiness(input: {
|
|||
state: input.state,
|
||||
launchAllowed: false,
|
||||
modelId: input.modelId,
|
||||
availableModels: [],
|
||||
opencodeVersion: null,
|
||||
installMethod: null,
|
||||
binaryPath: null,
|
||||
|
|
|
|||
|
|
@ -44,7 +44,7 @@ export interface OpenCodeBridgeHandshakePort {
|
|||
}
|
||||
|
||||
export interface RuntimeStoreManifestReader {
|
||||
read(teamName: string): Promise<RuntimeStoreManifestEvidence>;
|
||||
read(teamName: string, laneId?: string | null): Promise<RuntimeStoreManifestEvidence>;
|
||||
}
|
||||
|
||||
export interface OpenCodeStateChangingBridgeDiagnosticsSink {
|
||||
|
|
@ -93,6 +93,7 @@ export class OpenCodeStateChangingBridgeCommandService {
|
|||
async execute<TBody, TData>(input: {
|
||||
command: OpenCodeBridgeCommandName;
|
||||
teamName: string;
|
||||
laneId?: string | null;
|
||||
runId: string | null;
|
||||
capabilitySnapshotId: string | null;
|
||||
behaviorFingerprint: string | null;
|
||||
|
|
@ -100,7 +101,8 @@ export class OpenCodeStateChangingBridgeCommandService {
|
|||
cwd: string;
|
||||
timeoutMs: number;
|
||||
}): Promise<OpenCodeBridgeResult<TData>> {
|
||||
const manifest = await this.manifestReader.read(input.teamName);
|
||||
const normalizedLaneId = input.laneId ?? null;
|
||||
const manifest = await this.manifestReader.read(input.teamName, normalizedLaneId);
|
||||
const handshake = await this.handshakePort.handshake({
|
||||
requiredCommand: input.command,
|
||||
expectedRunId: input.runId,
|
||||
|
|
@ -124,12 +126,14 @@ export class OpenCodeStateChangingBridgeCommandService {
|
|||
const idempotencyKey = createOpenCodeBridgeIdempotencyKey({
|
||||
command: input.command,
|
||||
teamName: input.teamName,
|
||||
laneId: normalizedLaneId,
|
||||
runId: input.runId,
|
||||
body: input.body,
|
||||
});
|
||||
const commandRequestId = this.requestIdFactory();
|
||||
const lease = await this.leaseStore.acquire({
|
||||
teamName: input.teamName,
|
||||
laneId: normalizedLaneId,
|
||||
runId: input.runId,
|
||||
command: input.command,
|
||||
ttlMs: input.timeoutMs + 5_000,
|
||||
|
|
@ -138,6 +142,7 @@ export class OpenCodeStateChangingBridgeCommandService {
|
|||
try {
|
||||
const bodyWithPreconditions = attachBridgePreconditions(input.body, {
|
||||
handshakeIdentityHash: handshake.identityHash,
|
||||
laneId: normalizedLaneId,
|
||||
expectedRunId: input.runId,
|
||||
expectedCapabilitySnapshotId: input.capabilitySnapshotId,
|
||||
expectedBehaviorFingerprint: input.behaviorFingerprint,
|
||||
|
|
@ -151,10 +156,12 @@ export class OpenCodeStateChangingBridgeCommandService {
|
|||
requestId: commandRequestId,
|
||||
command: input.command,
|
||||
teamName: input.teamName,
|
||||
laneId: input.laneId,
|
||||
runId: input.runId,
|
||||
requestHash: stableHash({
|
||||
command: input.command,
|
||||
teamName: input.teamName,
|
||||
laneId: normalizedLaneId,
|
||||
runId: input.runId,
|
||||
capabilitySnapshotId: input.capabilitySnapshotId,
|
||||
behaviorFingerprint: input.behaviorFingerprint,
|
||||
|
|
@ -186,6 +193,7 @@ export class OpenCodeStateChangingBridgeCommandService {
|
|||
await this.appendUnknownOutcomeDiagnostic({
|
||||
result,
|
||||
teamName: input.teamName,
|
||||
laneId: normalizedLaneId,
|
||||
runId: input.runId,
|
||||
command: input.command,
|
||||
idempotencyKey,
|
||||
|
|
@ -233,6 +241,7 @@ export class OpenCodeStateChangingBridgeCommandService {
|
|||
private async appendUnknownOutcomeDiagnostic(input: {
|
||||
result: OpenCodeBridgeResult<unknown>;
|
||||
teamName: string;
|
||||
laneId: string | null;
|
||||
runId: string | null;
|
||||
command: OpenCodeBridgeCommandName;
|
||||
idempotencyKey: string;
|
||||
|
|
@ -244,14 +253,25 @@ export class OpenCodeStateChangingBridgeCommandService {
|
|||
type: 'opencode_bridge_unknown_outcome',
|
||||
providerId: 'opencode',
|
||||
teamName: input.teamName,
|
||||
...(input.laneId
|
||||
? {
|
||||
data: {
|
||||
laneId: input.laneId,
|
||||
command: input.command,
|
||||
idempotencyKey: input.idempotencyKey,
|
||||
leaseId: input.leaseId,
|
||||
},
|
||||
}
|
||||
: {
|
||||
data: {
|
||||
command: input.command,
|
||||
idempotencyKey: input.idempotencyKey,
|
||||
leaseId: input.leaseId,
|
||||
},
|
||||
}),
|
||||
runId: input.runId ?? extractRunId(input.result) ?? undefined,
|
||||
severity: 'warning',
|
||||
message: 'OpenCode bridge command timed out; outcome must be reconciled before retry',
|
||||
data: {
|
||||
command: input.command,
|
||||
idempotencyKey: input.idempotencyKey,
|
||||
leaseId: input.leaseId,
|
||||
},
|
||||
createdAt: completedAt,
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -94,6 +94,10 @@ export interface OpenCodeProductionE2EGateExpectation {
|
|||
opencodeVersion: string | null;
|
||||
binaryFingerprint: string | null;
|
||||
capabilitySnapshotId: string | null;
|
||||
/**
|
||||
* The currently selected raw model id. Kept for observability and evidence
|
||||
* lookup preference, but not as a hard production-proof gate.
|
||||
*/
|
||||
selectedModel: string | null;
|
||||
projectPathFingerprint?: string | null;
|
||||
requiredMcpTools?: string[];
|
||||
|
|
@ -376,14 +380,6 @@ function collectExpectedRuntimeDiagnostics(
|
|||
);
|
||||
}
|
||||
|
||||
if (!expected.selectedModel) {
|
||||
diagnostics.push('OpenCode production gate cannot verify selected raw model id');
|
||||
} else if (evidence.selectedModel !== expected.selectedModel) {
|
||||
diagnostics.push(
|
||||
`OpenCode production E2E evidence model ${evidence.selectedModel} does not match selected model ${expected.selectedModel}. Production launch is intentionally scoped to the exact raw model id; regenerate evidence with OPENCODE_E2E_MODEL=${expected.selectedModel}.`
|
||||
);
|
||||
}
|
||||
|
||||
if (
|
||||
expected.projectPathFingerprint &&
|
||||
evidence.projectPathFingerprint !== expected.projectPathFingerprint
|
||||
|
|
|
|||
|
|
@ -25,8 +25,16 @@ export interface OpenCodeProductionE2EEvidenceStoreOptions {
|
|||
}
|
||||
|
||||
export interface OpenCodeProductionE2EEvidenceStoreReadOptions {
|
||||
/**
|
||||
* Preferred exact raw model id when a matching project-scoped proof exists.
|
||||
* Production proof is primarily scoped to the runtime/project integration, not
|
||||
* to a mandatory per-model whitelist.
|
||||
*/
|
||||
selectedModel?: string | null;
|
||||
projectPathFingerprint?: string | null;
|
||||
opencodeVersion?: string | null;
|
||||
binaryFingerprint?: string | null;
|
||||
capabilitySnapshotId?: string | null;
|
||||
}
|
||||
|
||||
export class OpenCodeProductionE2EEvidenceStore {
|
||||
|
|
@ -106,11 +114,48 @@ function selectEvidence(
|
|||
|
||||
const modelId = options.selectedModel?.trim() ?? '';
|
||||
const projectPathFingerprint = options.projectPathFingerprint?.trim() ?? '';
|
||||
if (modelId) {
|
||||
const entries = Object.values(data.entriesByModel).filter(
|
||||
(entry) => entry.selectedModel === modelId
|
||||
const entries = Object.values(data.entriesByModel);
|
||||
const pickBestForRuntime = (
|
||||
candidates: OpenCodeProductionE2EEvidence[]
|
||||
): OpenCodeProductionE2EEvidence | null => {
|
||||
const runtimeMatched = candidates.filter((entry) => runtimeIdentityMatches(entry, options));
|
||||
return pickNewestEvidence(runtimeMatched.length > 0 ? runtimeMatched : candidates);
|
||||
};
|
||||
|
||||
if (projectPathFingerprint) {
|
||||
const pathEntries = entries.filter(
|
||||
(entry) => entry.projectPathFingerprint === projectPathFingerprint
|
||||
);
|
||||
if (entries.length === 0) {
|
||||
if (pathEntries.length === 0) {
|
||||
return {
|
||||
evidence: null,
|
||||
diagnostics: [
|
||||
'OpenCode production E2E evidence artifact has no entry for the current working directory',
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
if (modelId) {
|
||||
const exactModelMatch = pickBestForRuntime(
|
||||
pathEntries.filter((entry) => entry.selectedModel === modelId)
|
||||
);
|
||||
if (exactModelMatch) {
|
||||
return {
|
||||
evidence: exactModelMatch,
|
||||
diagnostics: [],
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
evidence: pickBestForRuntime(pathEntries),
|
||||
diagnostics: [],
|
||||
};
|
||||
}
|
||||
|
||||
if (modelId) {
|
||||
const exactModelEntries = entries.filter((entry) => entry.selectedModel === modelId);
|
||||
if (exactModelEntries.length === 0) {
|
||||
return {
|
||||
evidence: null,
|
||||
diagnostics: [
|
||||
|
|
@ -119,32 +164,12 @@ function selectEvidence(
|
|||
};
|
||||
}
|
||||
|
||||
if (projectPathFingerprint) {
|
||||
const exactMatch = pickNewestEvidence(
|
||||
entries.filter((entry) => entry.projectPathFingerprint === projectPathFingerprint)
|
||||
);
|
||||
if (exactMatch) {
|
||||
return {
|
||||
evidence: exactMatch,
|
||||
diagnostics: [],
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
evidence: null,
|
||||
diagnostics: [
|
||||
`OpenCode production E2E evidence artifact has no entry for selected model ${modelId} and the current working directory`,
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
evidence: pickNewestEvidence(entries),
|
||||
evidence: pickNewestEvidence(exactModelEntries),
|
||||
diagnostics: [],
|
||||
};
|
||||
}
|
||||
|
||||
const entries = Object.values(data.entriesByModel);
|
||||
if (entries.length === 1) {
|
||||
return { evidence: entries[0] ?? null, diagnostics: [] };
|
||||
}
|
||||
|
|
@ -182,6 +207,31 @@ function buildEvidenceKey(evidence: OpenCodeProductionE2EEvidence): string {
|
|||
return [evidence.selectedModel, evidence.projectPathFingerprint ?? 'global'].join('::');
|
||||
}
|
||||
|
||||
function runtimeIdentityMatches(
|
||||
evidence: OpenCodeProductionE2EEvidence,
|
||||
options: OpenCodeProductionE2EEvidenceStoreReadOptions
|
||||
): boolean {
|
||||
const expectedVersion = options.opencodeVersion?.trim() ?? '';
|
||||
if (expectedVersion && evidence.version !== expectedVersion) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const expectedBinaryFingerprint = options.binaryFingerprint?.trim() ?? '';
|
||||
if (expectedBinaryFingerprint && evidence.binaryFingerprint !== expectedBinaryFingerprint) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const expectedCapabilitySnapshotId = options.capabilitySnapshotId?.trim() ?? '';
|
||||
if (
|
||||
expectedCapabilitySnapshotId &&
|
||||
evidence.capabilitySnapshotId !== expectedCapabilitySnapshotId
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
function pickNewestEvidence(
|
||||
entries: OpenCodeProductionE2EEvidence[]
|
||||
): OpenCodeProductionE2EEvidence | null {
|
||||
|
|
|
|||
|
|
@ -45,6 +45,7 @@ export interface OpenCodeTeamLaunchReadiness {
|
|||
state: OpenCodeTeamLaunchReadinessState;
|
||||
launchAllowed: boolean;
|
||||
modelId: string | null;
|
||||
availableModels: string[];
|
||||
opencodeVersion: string | null;
|
||||
installMethod: OpenCodeInstallMethod | null;
|
||||
binaryPath: string | null;
|
||||
|
|
@ -326,6 +327,7 @@ function readiness(input: {
|
|||
state: input.state,
|
||||
launchAllowed: input.launchAllowed === true,
|
||||
modelId: input.modelId,
|
||||
availableModels: input.inventory?.models ?? [],
|
||||
opencodeVersion: input.inventory?.version ?? null,
|
||||
installMethod: input.inventory?.installMethod ?? null,
|
||||
binaryPath: input.inventory?.binaryPath ?? null,
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
import { mkdir, readFile, readdir, rename, rm, stat, writeFile } from 'node:fs/promises';
|
||||
import * as path from 'path';
|
||||
|
||||
import type { RuntimeStoreManifestEvidence } from '../bridge/OpenCodeBridgeCommandContract';
|
||||
|
|
@ -10,7 +11,23 @@ export interface OpenCodeRuntimeManifestEvidenceReaderOptions {
|
|||
}
|
||||
|
||||
const OPENCODE_TEAM_RUNTIME_DIR = '.opencode-runtime';
|
||||
const OPENCODE_TEAM_RUNTIME_LANES_DIR = 'lanes';
|
||||
const OPENCODE_TEAM_RUNTIME_LANES_INDEX_FILE = 'lanes.json';
|
||||
const OPENCODE_RUNTIME_MANIFEST_FILE = 'manifest.json';
|
||||
const OPENCODE_RUNTIME_RUN_TOMBSTONES_FILE = 'opencode-run-tombstones.json';
|
||||
|
||||
export interface OpenCodeRuntimeLaneIndexEntry {
|
||||
laneId: string;
|
||||
state: 'active' | 'stopped' | 'degraded';
|
||||
updatedAt: string;
|
||||
diagnostics?: string[];
|
||||
}
|
||||
|
||||
export interface OpenCodeRuntimeLaneIndex {
|
||||
version: 1;
|
||||
updatedAt: string;
|
||||
lanes: Record<string, OpenCodeRuntimeLaneIndexEntry>;
|
||||
}
|
||||
|
||||
export class OpenCodeRuntimeManifestEvidenceReader implements RuntimeStoreManifestReader {
|
||||
private readonly teamsBasePath: string;
|
||||
|
|
@ -21,9 +38,13 @@ export class OpenCodeRuntimeManifestEvidenceReader implements RuntimeStoreManife
|
|||
this.clock = options.clock ?? (() => new Date());
|
||||
}
|
||||
|
||||
async read(teamName: string): Promise<RuntimeStoreManifestEvidence> {
|
||||
async read(teamName: string, laneId?: string | null): Promise<RuntimeStoreManifestEvidence> {
|
||||
const normalizedLaneId = laneId?.trim() || null;
|
||||
const manifestPath = normalizedLaneId
|
||||
? await resolveOpenCodeRuntimeManifestReadPath(this.teamsBasePath, teamName, normalizedLaneId)
|
||||
: getOpenCodeRuntimeManifestPath(this.teamsBasePath, teamName);
|
||||
const manifest = await createRuntimeStoreManifestStore({
|
||||
filePath: getOpenCodeRuntimeManifestPath(this.teamsBasePath, teamName),
|
||||
filePath: manifestPath,
|
||||
teamName,
|
||||
clock: this.clock,
|
||||
}).read();
|
||||
|
|
@ -36,13 +57,347 @@ export class OpenCodeRuntimeManifestEvidenceReader implements RuntimeStoreManife
|
|||
}
|
||||
}
|
||||
|
||||
async function fileExists(filePath: string): Promise<boolean> {
|
||||
try {
|
||||
await stat(filePath);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function resolveOpenCodeRuntimeManifestReadPath(
|
||||
teamsBasePath: string,
|
||||
teamName: string,
|
||||
laneId: string
|
||||
): Promise<string> {
|
||||
const laneManifestPath = getOpenCodeRuntimeManifestPath(teamsBasePath, teamName, laneId);
|
||||
if (await fileExists(laneManifestPath)) {
|
||||
return laneManifestPath;
|
||||
}
|
||||
|
||||
const legacyManifestPath = getOpenCodeRuntimeManifestPath(teamsBasePath, teamName);
|
||||
if (!(await fileExists(legacyManifestPath))) {
|
||||
return laneManifestPath;
|
||||
}
|
||||
|
||||
if (!(await canFallbackToLegacyManifest(teamsBasePath, teamName, laneId))) {
|
||||
return laneManifestPath;
|
||||
}
|
||||
|
||||
return legacyManifestPath;
|
||||
}
|
||||
|
||||
async function canFallbackToLegacyManifest(
|
||||
teamsBasePath: string,
|
||||
teamName: string,
|
||||
laneId: string
|
||||
): Promise<boolean> {
|
||||
const laneDirsPath = path.join(
|
||||
getOpenCodeTeamRuntimeDirectory(teamsBasePath, teamName),
|
||||
OPENCODE_TEAM_RUNTIME_LANES_DIR
|
||||
);
|
||||
const existingLaneDirs = await readdir(laneDirsPath).catch(() => [] as string[]);
|
||||
if (existingLaneDirs.length > 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const laneIndex = await readOpenCodeRuntimeLaneIndex(teamsBasePath, teamName).catch(() => ({
|
||||
version: 1 as const,
|
||||
updatedAt: new Date().toISOString(),
|
||||
lanes: {},
|
||||
}));
|
||||
const siblingLaneIds = Object.keys(laneIndex.lanes).filter(
|
||||
(candidateLaneId) => candidateLaneId !== laneId
|
||||
);
|
||||
return siblingLaneIds.length === 0;
|
||||
}
|
||||
|
||||
export function getOpenCodeTeamRuntimeDirectory(teamsBasePath: string, teamName: string): string {
|
||||
return path.join(teamsBasePath, teamName, OPENCODE_TEAM_RUNTIME_DIR);
|
||||
}
|
||||
|
||||
export function getOpenCodeRuntimeManifestPath(teamsBasePath: string, teamName: string): string {
|
||||
export function getOpenCodeRuntimeLaneIndexPath(teamsBasePath: string, teamName: string): string {
|
||||
return path.join(
|
||||
getOpenCodeTeamRuntimeDirectory(teamsBasePath, teamName),
|
||||
OPENCODE_TEAM_RUNTIME_LANES_INDEX_FILE
|
||||
);
|
||||
}
|
||||
|
||||
export function getOpenCodeTeamRuntimeLaneDirectory(
|
||||
teamsBasePath: string,
|
||||
teamName: string,
|
||||
laneId: string
|
||||
): string {
|
||||
return path.join(
|
||||
getOpenCodeTeamRuntimeDirectory(teamsBasePath, teamName),
|
||||
OPENCODE_TEAM_RUNTIME_LANES_DIR,
|
||||
encodeURIComponent(laneId)
|
||||
);
|
||||
}
|
||||
|
||||
export function getOpenCodeRuntimeManifestPath(
|
||||
teamsBasePath: string,
|
||||
teamName: string,
|
||||
laneId?: string | null
|
||||
): string {
|
||||
if (laneId && laneId.trim().length > 0) {
|
||||
return path.join(
|
||||
getOpenCodeTeamRuntimeLaneDirectory(teamsBasePath, teamName, laneId.trim()),
|
||||
OPENCODE_RUNTIME_MANIFEST_FILE
|
||||
);
|
||||
}
|
||||
return path.join(
|
||||
getOpenCodeTeamRuntimeDirectory(teamsBasePath, teamName),
|
||||
OPENCODE_RUNTIME_MANIFEST_FILE
|
||||
);
|
||||
}
|
||||
|
||||
export function getOpenCodeLaneScopedRuntimeFilePath(params: {
|
||||
teamsBasePath: string;
|
||||
teamName: string;
|
||||
laneId: string;
|
||||
fileName: string;
|
||||
}): string {
|
||||
return path.join(
|
||||
getOpenCodeTeamRuntimeLaneDirectory(params.teamsBasePath, params.teamName, params.laneId),
|
||||
params.fileName
|
||||
);
|
||||
}
|
||||
|
||||
export async function readOpenCodeRuntimeLaneIndex(
|
||||
teamsBasePath: string,
|
||||
teamName: string
|
||||
): Promise<OpenCodeRuntimeLaneIndex> {
|
||||
const filePath = getOpenCodeRuntimeLaneIndexPath(teamsBasePath, teamName);
|
||||
if (!(await fileExists(filePath))) {
|
||||
return {
|
||||
version: 1,
|
||||
updatedAt: new Date().toISOString(),
|
||||
lanes: {},
|
||||
};
|
||||
}
|
||||
const raw = await readFile(filePath, 'utf8');
|
||||
const parsed = JSON.parse(raw) as Partial<OpenCodeRuntimeLaneIndex>;
|
||||
if (
|
||||
parsed.version !== 1 ||
|
||||
typeof parsed.updatedAt !== 'string' ||
|
||||
!parsed.lanes ||
|
||||
typeof parsed.lanes !== 'object'
|
||||
) {
|
||||
return {
|
||||
version: 1,
|
||||
updatedAt: new Date().toISOString(),
|
||||
lanes: {},
|
||||
};
|
||||
}
|
||||
return {
|
||||
version: 1,
|
||||
updatedAt: parsed.updatedAt,
|
||||
lanes: Object.fromEntries(
|
||||
Object.entries(parsed.lanes).flatMap(([key, value]) => {
|
||||
if (
|
||||
!value ||
|
||||
typeof value !== 'object' ||
|
||||
typeof (value as OpenCodeRuntimeLaneIndexEntry).laneId !== 'string' ||
|
||||
typeof (value as OpenCodeRuntimeLaneIndexEntry).updatedAt !== 'string'
|
||||
) {
|
||||
return [];
|
||||
}
|
||||
const entry = value as OpenCodeRuntimeLaneIndexEntry;
|
||||
return [
|
||||
[
|
||||
key,
|
||||
{
|
||||
laneId: entry.laneId,
|
||||
state:
|
||||
entry.state === 'active' || entry.state === 'stopped' || entry.state === 'degraded'
|
||||
? entry.state
|
||||
: 'degraded',
|
||||
updatedAt: entry.updatedAt,
|
||||
diagnostics: Array.isArray(entry.diagnostics)
|
||||
? entry.diagnostics.filter((item): item is string => typeof item === 'string')
|
||||
: undefined,
|
||||
} satisfies OpenCodeRuntimeLaneIndexEntry,
|
||||
],
|
||||
];
|
||||
})
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
export async function writeOpenCodeRuntimeLaneIndex(
|
||||
teamsBasePath: string,
|
||||
teamName: string,
|
||||
index: OpenCodeRuntimeLaneIndex
|
||||
): Promise<void> {
|
||||
const runtimeDir = getOpenCodeTeamRuntimeDirectory(teamsBasePath, teamName);
|
||||
await mkdir(runtimeDir, { recursive: true });
|
||||
await writeFile(
|
||||
getOpenCodeRuntimeLaneIndexPath(teamsBasePath, teamName),
|
||||
`${JSON.stringify(index, null, 2)}\n`,
|
||||
'utf8'
|
||||
);
|
||||
}
|
||||
|
||||
export async function upsertOpenCodeRuntimeLaneIndexEntry(params: {
|
||||
teamsBasePath: string;
|
||||
teamName: string;
|
||||
laneId: string;
|
||||
state: OpenCodeRuntimeLaneIndexEntry['state'];
|
||||
diagnostics?: string[];
|
||||
}): Promise<void> {
|
||||
const index = await readOpenCodeRuntimeLaneIndex(params.teamsBasePath, params.teamName);
|
||||
index.updatedAt = new Date().toISOString();
|
||||
index.lanes[params.laneId] = {
|
||||
laneId: params.laneId,
|
||||
state: params.state,
|
||||
updatedAt: index.updatedAt,
|
||||
diagnostics: params.diagnostics?.length ? [...params.diagnostics] : undefined,
|
||||
};
|
||||
await writeOpenCodeRuntimeLaneIndex(params.teamsBasePath, params.teamName, index);
|
||||
}
|
||||
|
||||
export async function removeOpenCodeRuntimeLaneIndexEntry(params: {
|
||||
teamsBasePath: string;
|
||||
teamName: string;
|
||||
laneId: string;
|
||||
}): Promise<void> {
|
||||
const index = await readOpenCodeRuntimeLaneIndex(params.teamsBasePath, params.teamName);
|
||||
if (!index.lanes[params.laneId]) {
|
||||
return;
|
||||
}
|
||||
delete index.lanes[params.laneId];
|
||||
index.updatedAt = new Date().toISOString();
|
||||
await writeOpenCodeRuntimeLaneIndex(params.teamsBasePath, params.teamName, index);
|
||||
}
|
||||
|
||||
export async function clearOpenCodeRuntimeLaneStorage(params: {
|
||||
teamsBasePath: string;
|
||||
teamName: string;
|
||||
laneId: string;
|
||||
}): Promise<void> {
|
||||
await rm(
|
||||
getOpenCodeTeamRuntimeLaneDirectory(params.teamsBasePath, params.teamName, params.laneId),
|
||||
{ recursive: true, force: true }
|
||||
);
|
||||
await removeOpenCodeRuntimeLaneIndexEntry(params);
|
||||
}
|
||||
|
||||
export async function migrateLegacyOpenCodeRuntimeState(params: {
|
||||
teamsBasePath: string;
|
||||
teamName: string;
|
||||
laneId: string;
|
||||
clock?: () => Date;
|
||||
}): Promise<{ migrated: boolean; degraded: boolean; diagnostics: string[] }> {
|
||||
const clock = params.clock ?? (() => new Date());
|
||||
const runtimeDir = getOpenCodeTeamRuntimeDirectory(params.teamsBasePath, params.teamName);
|
||||
const laneDir = getOpenCodeTeamRuntimeLaneDirectory(
|
||||
params.teamsBasePath,
|
||||
params.teamName,
|
||||
params.laneId
|
||||
);
|
||||
const diagnostics: string[] = [];
|
||||
|
||||
if (!(await fileExists(runtimeDir))) {
|
||||
await upsertOpenCodeRuntimeLaneIndexEntry({
|
||||
teamsBasePath: params.teamsBasePath,
|
||||
teamName: params.teamName,
|
||||
laneId: params.laneId,
|
||||
state: 'active',
|
||||
});
|
||||
return { migrated: false, degraded: false, diagnostics };
|
||||
}
|
||||
|
||||
const laneDirsPath = path.join(runtimeDir, OPENCODE_TEAM_RUNTIME_LANES_DIR);
|
||||
const existingLaneDirs = await readdir(laneDirsPath).catch(() => [] as string[]);
|
||||
if (existingLaneDirs.length > 0) {
|
||||
await upsertOpenCodeRuntimeLaneIndexEntry({
|
||||
teamsBasePath: params.teamsBasePath,
|
||||
teamName: params.teamName,
|
||||
laneId: params.laneId,
|
||||
state: 'active',
|
||||
});
|
||||
return { migrated: false, degraded: false, diagnostics };
|
||||
}
|
||||
|
||||
const knownLegacyFiles = [
|
||||
OPENCODE_RUNTIME_MANIFEST_FILE,
|
||||
'launch-state.json',
|
||||
'opencode-sessions.json',
|
||||
'opencode-launch-transaction.json',
|
||||
'opencode-delivery-journal.json',
|
||||
'opencode-permissions.json',
|
||||
'opencode-host-leases.json',
|
||||
'opencode-compatibility.json',
|
||||
'opencode-runtime-revision.json',
|
||||
'opencode-diagnostics.json',
|
||||
OPENCODE_RUNTIME_RUN_TOMBSTONES_FILE,
|
||||
];
|
||||
const legacyFiles = (
|
||||
await Promise.all(
|
||||
knownLegacyFiles.map(async (fileName) =>
|
||||
(await fileExists(path.join(runtimeDir, fileName))) ? fileName : null
|
||||
)
|
||||
)
|
||||
).filter((fileName): fileName is string => Boolean(fileName));
|
||||
|
||||
if (legacyFiles.length === 0) {
|
||||
await upsertOpenCodeRuntimeLaneIndexEntry({
|
||||
teamsBasePath: params.teamsBasePath,
|
||||
teamName: params.teamName,
|
||||
laneId: params.laneId,
|
||||
state: 'active',
|
||||
});
|
||||
return { migrated: false, degraded: false, diagnostics };
|
||||
}
|
||||
|
||||
const index = await readOpenCodeRuntimeLaneIndex(params.teamsBasePath, params.teamName);
|
||||
const otherLaneIds = Object.keys(index.lanes).filter((laneId) => laneId !== params.laneId);
|
||||
if (otherLaneIds.length > 0) {
|
||||
diagnostics.push(
|
||||
`Legacy OpenCode runtime state is ambiguous for ${params.teamName}; existing lanes: ${otherLaneIds.join(', ')}`
|
||||
);
|
||||
await upsertOpenCodeRuntimeLaneIndexEntry({
|
||||
teamsBasePath: params.teamsBasePath,
|
||||
teamName: params.teamName,
|
||||
laneId: params.laneId,
|
||||
state: 'degraded',
|
||||
diagnostics,
|
||||
});
|
||||
return { migrated: false, degraded: true, diagnostics };
|
||||
}
|
||||
|
||||
await mkdir(laneDir, { recursive: true });
|
||||
for (const fileName of legacyFiles) {
|
||||
await rename(path.join(runtimeDir, fileName), path.join(laneDir, fileName));
|
||||
}
|
||||
await upsertOpenCodeRuntimeLaneIndexEntry({
|
||||
teamsBasePath: params.teamsBasePath,
|
||||
teamName: params.teamName,
|
||||
laneId: params.laneId,
|
||||
state: 'active',
|
||||
diagnostics: [`migrated legacy team-scoped OpenCode runtime state at ${clock().toISOString()}`],
|
||||
});
|
||||
diagnostics.push(`migrated ${legacyFiles.length} legacy OpenCode runtime files`);
|
||||
return { migrated: true, degraded: false, diagnostics };
|
||||
}
|
||||
|
||||
export function getOpenCodeRuntimeRunTombstonesPath(
|
||||
teamsBasePath: string,
|
||||
teamName: string,
|
||||
laneId?: string | null
|
||||
): string {
|
||||
if (laneId && laneId.trim().length > 0) {
|
||||
return getOpenCodeLaneScopedRuntimeFilePath({
|
||||
teamsBasePath,
|
||||
teamName,
|
||||
laneId: laneId.trim(),
|
||||
fileName: OPENCODE_RUNTIME_RUN_TOMBSTONES_FILE,
|
||||
});
|
||||
}
|
||||
return path.join(
|
||||
getOpenCodeTeamRuntimeDirectory(teamsBasePath, teamName),
|
||||
OPENCODE_RUNTIME_RUN_TOMBSTONES_FILE
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -59,6 +59,7 @@ const REQUIRED_READY_CHECKPOINTS = new Set([
|
|||
export class OpenCodeTeamRuntimeAdapter implements TeamLaunchRuntimeAdapter {
|
||||
readonly providerId = 'opencode' as const;
|
||||
private readonly lastProjectPathByTeamName = new Map<string, string>();
|
||||
private readonly lastReadinessByProjectPath = new Map<string, OpenCodeTeamLaunchReadiness>();
|
||||
|
||||
constructor(
|
||||
private readonly bridge: OpenCodeTeamRuntimeBridgePort,
|
||||
|
|
@ -87,6 +88,7 @@ export class OpenCodeTeamRuntimeAdapter implements TeamLaunchRuntimeAdapter {
|
|||
requireExecutionProbe: !runtimeOnly,
|
||||
launchMode: runtimeOnly ? undefined : configuredLaunchMode,
|
||||
});
|
||||
this.lastReadinessByProjectPath.set(input.cwd, readiness);
|
||||
|
||||
if (!readiness.launchAllowed) {
|
||||
return {
|
||||
|
|
@ -132,6 +134,10 @@ export class OpenCodeTeamRuntimeAdapter implements TeamLaunchRuntimeAdapter {
|
|||
};
|
||||
}
|
||||
|
||||
getLastOpenCodeTeamLaunchReadiness(projectPath: string): OpenCodeTeamLaunchReadiness | null {
|
||||
return this.lastReadinessByProjectPath.get(projectPath) ?? null;
|
||||
}
|
||||
|
||||
async launch(input: TeamRuntimeLaunchInput): Promise<TeamRuntimeLaunchResult> {
|
||||
const configuredLaunchMode = resolveOpenCodeTeamLaunchMode(this.options);
|
||||
const prepared = await this.prepare(input);
|
||||
|
|
@ -157,6 +163,7 @@ export class OpenCodeTeamRuntimeAdapter implements TeamLaunchRuntimeAdapter {
|
|||
const data = await this.bridge.launchOpenCodeTeam({
|
||||
mode: configuredLaunchMode,
|
||||
runId: input.runId,
|
||||
laneId: input.laneId?.trim() || 'primary',
|
||||
teamId: input.teamName,
|
||||
teamName: input.teamName,
|
||||
projectPath: input.cwd,
|
||||
|
|
@ -183,6 +190,7 @@ export class OpenCodeTeamRuntimeAdapter implements TeamLaunchRuntimeAdapter {
|
|||
: null;
|
||||
const data = await this.bridge.reconcileOpenCodeTeam({
|
||||
runId: input.runId,
|
||||
laneId: input.laneId?.trim() || 'primary',
|
||||
teamId: input.teamName,
|
||||
teamName: input.teamName,
|
||||
projectPath,
|
||||
|
|
@ -263,6 +271,7 @@ export class OpenCodeTeamRuntimeAdapter implements TeamLaunchRuntimeAdapter {
|
|||
: null;
|
||||
const data = await this.bridge.stopOpenCodeTeam({
|
||||
runId: input.runId,
|
||||
laneId: input.laneId?.trim() || 'primary',
|
||||
teamId: input.teamName,
|
||||
teamName: input.teamName,
|
||||
projectPath,
|
||||
|
|
|
|||
|
|
@ -25,6 +25,7 @@ export interface TeamRuntimeMemberSpec {
|
|||
export interface TeamRuntimeLaunchInput {
|
||||
runId: string;
|
||||
teamName: string;
|
||||
laneId?: string;
|
||||
cwd: string;
|
||||
prompt?: string;
|
||||
providerId: TeamRuntimeProviderId;
|
||||
|
|
@ -95,6 +96,7 @@ export type TeamRuntimeReconcileReason =
|
|||
export interface TeamRuntimeReconcileInput {
|
||||
runId: string;
|
||||
teamName: string;
|
||||
laneId?: string;
|
||||
providerId: TeamRuntimeProviderId;
|
||||
expectedMembers: TeamRuntimeMemberSpec[];
|
||||
previousLaunchState: PersistedTeamLaunchSnapshot | null;
|
||||
|
|
@ -117,6 +119,7 @@ export type TeamRuntimeStopReason = 'user_requested' | 'relaunch' | 'cleanup' |
|
|||
export interface TeamRuntimeStopInput {
|
||||
runId: string;
|
||||
teamName: string;
|
||||
laneId?: string;
|
||||
cwd?: string;
|
||||
providerId: TeamRuntimeProviderId;
|
||||
reason: TeamRuntimeStopReason;
|
||||
|
|
|
|||
|
|
@ -2,7 +2,14 @@ import * as fs from 'node:fs';
|
|||
import * as path from 'node:path';
|
||||
import { parentPort } from 'node:worker_threads';
|
||||
|
||||
import { readBootstrapLaunchSnapshot } from '@main/services/team/TeamBootstrapStateReader';
|
||||
import { normalizePersistedLaunchSnapshot } from '@main/services/team/TeamLaunchStateEvaluator';
|
||||
import {
|
||||
choosePreferredLaunchStateSummary,
|
||||
normalizePersistedLaunchSummaryProjection,
|
||||
shouldSuppressLegacyLaunchArtifactHeuristic,
|
||||
TEAM_LAUNCH_SUMMARY_FILE,
|
||||
} from '@main/services/team/TeamLaunchSummaryProjection';
|
||||
import { isLeadMember } from '@shared/utils/leadDetection';
|
||||
import { buildTeamMemberColorMap } from '@shared/utils/teamMemberColors';
|
||||
|
||||
|
|
@ -112,6 +119,8 @@ interface RawMember {
|
|||
agentType?: unknown;
|
||||
role?: unknown;
|
||||
color?: unknown;
|
||||
providerId?: unknown;
|
||||
provider?: unknown;
|
||||
removedAt?: unknown;
|
||||
}
|
||||
|
||||
|
|
@ -324,50 +333,42 @@ function dropCliProvisionerMembers(
|
|||
async function readLaunchState(
|
||||
teamsDir: string,
|
||||
teamName: string
|
||||
): Promise<{
|
||||
partialLaunchFailure?: true;
|
||||
expectedMemberCount?: number;
|
||||
confirmedMemberCount?: number;
|
||||
missingMembers?: string[];
|
||||
teamLaunchState?: string;
|
||||
launchUpdatedAt?: string;
|
||||
confirmedCount?: number;
|
||||
pendingCount?: number;
|
||||
failedCount?: number;
|
||||
runtimeAlivePendingCount?: number;
|
||||
} | null> {
|
||||
): Promise<ReturnType<typeof choosePreferredLaunchStateSummary>> {
|
||||
const bootstrapSnapshot = await readBootstrapLaunchSnapshot(teamName);
|
||||
const launchStatePath = path.join(teamsDir, teamName, TEAM_LAUNCH_STATE_FILE);
|
||||
try {
|
||||
const stat = await fs.promises.stat(launchStatePath);
|
||||
if (!stat.isFile() || stat.size > MAX_LAUNCH_STATE_BYTES) return null;
|
||||
const raw = await fs.promises.readFile(launchStatePath, 'utf8');
|
||||
const snapshot = normalizePersistedLaunchSnapshot(teamName, JSON.parse(raw));
|
||||
if (!snapshot) return null;
|
||||
const missingMembers = snapshot.expectedMembers.filter((name) => {
|
||||
const member = snapshot.members[name];
|
||||
return member?.launchState === 'failed_to_start';
|
||||
});
|
||||
return {
|
||||
...(snapshot.teamLaunchState === 'partial_failure'
|
||||
? { partialLaunchFailure: true as const }
|
||||
: {}),
|
||||
...(snapshot.expectedMembers.length > 0
|
||||
? { expectedMemberCount: snapshot.expectedMembers.length }
|
||||
: {}),
|
||||
...(snapshot.summary.confirmedCount > 0
|
||||
? { confirmedMemberCount: snapshot.summary.confirmedCount }
|
||||
: {}),
|
||||
...(missingMembers.length > 0 ? { missingMembers } : {}),
|
||||
teamLaunchState: snapshot.teamLaunchState,
|
||||
launchUpdatedAt: snapshot.updatedAt,
|
||||
confirmedCount: snapshot.summary.confirmedCount,
|
||||
pendingCount: snapshot.summary.pendingCount,
|
||||
failedCount: snapshot.summary.failedCount,
|
||||
runtimeAlivePendingCount: snapshot.summary.runtimeAlivePendingCount,
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
const launchSummaryPath = path.join(teamsDir, teamName, TEAM_LAUNCH_SUMMARY_FILE);
|
||||
const [launchSnapshot, launchSummaryProjection] = await Promise.all([
|
||||
(async () => {
|
||||
try {
|
||||
const stat = await fs.promises.stat(launchStatePath);
|
||||
if (!stat.isFile() || stat.size > MAX_LAUNCH_STATE_BYTES) {
|
||||
return null;
|
||||
}
|
||||
const raw = await fs.promises.readFile(launchStatePath, 'utf8');
|
||||
return normalizePersistedLaunchSnapshot(teamName, JSON.parse(raw));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
})(),
|
||||
(async () => {
|
||||
try {
|
||||
const stat = await fs.promises.stat(launchSummaryPath);
|
||||
if (!stat.isFile() || stat.size > MAX_LAUNCH_STATE_BYTES) {
|
||||
return null;
|
||||
}
|
||||
const raw = await fs.promises.readFile(launchSummaryPath, 'utf8');
|
||||
return normalizePersistedLaunchSummaryProjection(teamName, JSON.parse(raw));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
})(),
|
||||
]);
|
||||
|
||||
return choosePreferredLaunchStateSummary({
|
||||
bootstrapSnapshot,
|
||||
launchSnapshot,
|
||||
launchSummaryProjection,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -398,7 +399,12 @@ async function readDraftTeamMeta(
|
|||
const membersRaw = await fs.promises.readFile(membersPath, 'utf8');
|
||||
const membersData = JSON.parse(membersRaw) as { members?: unknown[] };
|
||||
if (Array.isArray(membersData?.members)) {
|
||||
memberCount = membersData.members.length;
|
||||
memberCount = membersData.members.filter((member) => {
|
||||
if (!isRawMember(member)) return false;
|
||||
const name = typeof member.name === 'string' ? member.name.trim() : '';
|
||||
if (!name || name === 'user' || isLeadMember(member)) return false;
|
||||
return !member.removedAt;
|
||||
}).length;
|
||||
}
|
||||
} catch {
|
||||
// best-effort
|
||||
|
|
@ -538,6 +544,30 @@ async function listTeams(
|
|||
const removedKeys = new Set<string>();
|
||||
const expectedTeammateNames = new Set<string>();
|
||||
const confirmedArtifactNames = new Set<string>();
|
||||
const metaRuntimeMembers: Array<{
|
||||
name: string;
|
||||
providerId?: 'anthropic' | 'codex' | 'gemini' | 'opencode';
|
||||
removedAt?: unknown;
|
||||
}> = [];
|
||||
let leadProviderId: 'anthropic' | 'codex' | 'gemini' | 'opencode' | undefined;
|
||||
|
||||
try {
|
||||
const teamMetaPath = path.join(payload.teamsDir, teamName, 'team.meta.json');
|
||||
const teamMetaStat = await fs.promises.stat(teamMetaPath);
|
||||
if (teamMetaStat.isFile() && teamMetaStat.size <= 256 * 1024) {
|
||||
const raw = await readFileUtf8WithTimeout(teamMetaPath, payload.maxConfigReadMs);
|
||||
const parsed = JSON.parse(raw) as { providerId?: unknown };
|
||||
leadProviderId =
|
||||
parsed?.providerId === 'anthropic' ||
|
||||
parsed?.providerId === 'codex' ||
|
||||
parsed?.providerId === 'gemini' ||
|
||||
parsed?.providerId === 'opencode'
|
||||
? parsed.providerId
|
||||
: undefined;
|
||||
}
|
||||
} catch {
|
||||
leadProviderId = undefined;
|
||||
}
|
||||
|
||||
try {
|
||||
const metaPath = path.join(payload.teamsDir, teamName, 'members.meta.json');
|
||||
|
|
@ -548,15 +578,32 @@ async function listTeams(
|
|||
const members: unknown[] = Array.isArray(parsed?.members) ? parsed.members : [];
|
||||
for (const member of members) {
|
||||
if (!isRawMember(member)) continue;
|
||||
const rawProviderId = member.providerId ?? member.provider;
|
||||
const providerId =
|
||||
rawProviderId === 'anthropic' ||
|
||||
rawProviderId === 'codex' ||
|
||||
rawProviderId === 'gemini' ||
|
||||
rawProviderId === 'opencode'
|
||||
? rawProviderId
|
||||
: undefined;
|
||||
const name = typeof member.name === 'string' ? member.name.trim() : '';
|
||||
if (!name) continue;
|
||||
if (isLeadMember(member)) continue;
|
||||
const key = name.toLowerCase();
|
||||
if (member.removedAt) {
|
||||
removedKeys.add(key);
|
||||
metaRuntimeMembers.push({
|
||||
name,
|
||||
providerId,
|
||||
removedAt: member.removedAt,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
expectedTeammateNames.add(name);
|
||||
metaRuntimeMembers.push({
|
||||
name,
|
||||
providerId,
|
||||
});
|
||||
mergeMember(member, memberMap, removedKeys);
|
||||
}
|
||||
}
|
||||
|
|
@ -599,9 +646,16 @@ async function listTeams(
|
|||
...member,
|
||||
color: memberColors.get(member.name) ?? member.color,
|
||||
}));
|
||||
const suppressLegacyLaunchArtifactHeuristic = shouldSuppressLegacyLaunchArtifactHeuristic({
|
||||
leadProviderId,
|
||||
members: metaRuntimeMembers,
|
||||
});
|
||||
const launchStateSummary =
|
||||
(await readLaunchState(payload.teamsDir, teamName)) ??
|
||||
(() => {
|
||||
if (suppressLegacyLaunchArtifactHeuristic) {
|
||||
return null;
|
||||
}
|
||||
if (
|
||||
!leadSessionId ||
|
||||
expectedTeammateNames.size === 0 ||
|
||||
|
|
|
|||
|
|
@ -305,6 +305,7 @@ import type {
|
|||
TeamLaunchResponse,
|
||||
TeamMemberActivityMeta,
|
||||
TeamMessageNotificationData,
|
||||
TeamProvisioningModelVerificationMode,
|
||||
TeamProvisioningPrepareResult,
|
||||
TeamProvisioningProgress,
|
||||
TeamSummary,
|
||||
|
|
@ -875,7 +876,8 @@ const electronAPI: ElectronAPI = {
|
|||
providerId?: TeamLaunchRequest['providerId'],
|
||||
providerIds?: TeamLaunchRequest['providerId'][],
|
||||
selectedModels?: string[],
|
||||
limitContext?: boolean
|
||||
limitContext?: boolean,
|
||||
modelVerificationMode?: TeamProvisioningModelVerificationMode
|
||||
) => {
|
||||
return invokeIpcWithResult<TeamProvisioningPrepareResult>(
|
||||
TEAM_PREPARE_PROVISIONING,
|
||||
|
|
@ -883,7 +885,8 @@ const electronAPI: ElectronAPI = {
|
|||
providerId,
|
||||
providerIds,
|
||||
selectedModels,
|
||||
limitContext
|
||||
limitContext,
|
||||
modelVerificationMode
|
||||
);
|
||||
},
|
||||
createTeam: async (request: TeamCreateRequest) => {
|
||||
|
|
|
|||
|
|
@ -63,6 +63,7 @@ import type {
|
|||
TeamLaunchRequest,
|
||||
TeamLaunchResponse,
|
||||
TeamMemberActivityMeta,
|
||||
TeamProvisioningModelVerificationMode,
|
||||
TeamProvisioningPrepareResult,
|
||||
TeamProvisioningProgress,
|
||||
TeamsAPI,
|
||||
|
|
@ -748,7 +749,8 @@ export class HttpAPIClient implements ElectronAPI {
|
|||
_providerId?: TeamLaunchRequest['providerId'],
|
||||
_providerIds?: TeamLaunchRequest['providerId'][],
|
||||
_selectedModels?: string[],
|
||||
_limitContext?: boolean
|
||||
_limitContext?: boolean,
|
||||
_modelVerificationMode?: TeamProvisioningModelVerificationMode
|
||||
): Promise<TeamProvisioningPrepareResult> => {
|
||||
throw new Error('Team provisioning is not available in browser mode');
|
||||
},
|
||||
|
|
|
|||
311
src/renderer/components/common/GlobalProviderStatusHeader.tsx
Normal file
311
src/renderer/components/common/GlobalProviderStatusHeader.tsx
Normal file
|
|
@ -0,0 +1,311 @@
|
|||
import { useEffect, useMemo, useState } from 'react';
|
||||
|
||||
import {
|
||||
mergeCodexCliStatusWithSnapshot,
|
||||
useCodexAccountSnapshot,
|
||||
} from '@features/codex-account/renderer';
|
||||
import { isElectronMode } from '@renderer/api';
|
||||
import { formatProviderStatusText } from '@renderer/components/runtime/providerConnectionUi';
|
||||
import { useStore } from '@renderer/store';
|
||||
import { createLoadingMultimodelCliStatus } from '@renderer/store/slices/cliInstallerSlice';
|
||||
import { filterMainScreenCliProviders } from '@renderer/utils/geminiUiFreeze';
|
||||
import { AlertTriangle, CheckCircle2, Loader2 } from 'lucide-react';
|
||||
import { useShallow } from 'zustand/react/shallow';
|
||||
|
||||
import { ProviderBrandLogo } from './ProviderBrandLogo';
|
||||
|
||||
import type { CliProviderId, CliProviderStatus } from '@shared/types';
|
||||
|
||||
interface ProviderActivityState {
|
||||
provider: CliProviderStatus;
|
||||
loading: boolean;
|
||||
error: boolean;
|
||||
}
|
||||
|
||||
function isProviderCardLoading(provider: CliProviderStatus, providerLoading: boolean): boolean {
|
||||
return (
|
||||
providerLoading ||
|
||||
(!provider.authenticated &&
|
||||
provider.statusMessage === 'Checking...' &&
|
||||
provider.models.length === 0 &&
|
||||
provider.backend == null)
|
||||
);
|
||||
}
|
||||
|
||||
function shouldMaskCodexNegativeBootstrapState(
|
||||
sourceProvider: CliProviderStatus | null,
|
||||
mergedProvider: CliProviderStatus
|
||||
): boolean {
|
||||
return (
|
||||
sourceProvider?.providerId === 'codex' &&
|
||||
sourceProvider.statusMessage === 'Checking...' &&
|
||||
mergedProvider.providerId === 'codex' &&
|
||||
mergedProvider.connection?.codex?.launchReadinessState === 'missing_auth' &&
|
||||
mergedProvider.connection.codex.login.status === 'idle'
|
||||
);
|
||||
}
|
||||
|
||||
function getActivityToneStyles(tone: 'loading' | 'checked' | 'error'): {
|
||||
borderColor: string;
|
||||
backgroundColor: string;
|
||||
textColor: string;
|
||||
statusColor: string;
|
||||
} {
|
||||
switch (tone) {
|
||||
case 'checked':
|
||||
return {
|
||||
borderColor: 'rgba(34, 197, 94, 0.22)',
|
||||
backgroundColor: 'rgba(34, 197, 94, 0.08)',
|
||||
textColor: '#dcfce7',
|
||||
statusColor: '#86efac',
|
||||
};
|
||||
case 'error':
|
||||
return {
|
||||
borderColor: 'rgba(239, 68, 68, 0.28)',
|
||||
backgroundColor: 'rgba(239, 68, 68, 0.08)',
|
||||
textColor: '#fee2e2',
|
||||
statusColor: '#fca5a5',
|
||||
};
|
||||
case 'loading':
|
||||
default:
|
||||
return {
|
||||
borderColor: 'var(--color-border-emphasis)',
|
||||
backgroundColor: 'rgba(255, 255, 255, 0.03)',
|
||||
textColor: 'var(--color-text-secondary)',
|
||||
statusColor: 'var(--color-text-muted)',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function areProviderIdListsEqual(nextIds: CliProviderId[], prevIds: CliProviderId[]): boolean {
|
||||
return nextIds.length === prevIds.length && nextIds.every((id, index) => prevIds[index] === id);
|
||||
}
|
||||
|
||||
export const GlobalProviderStatusHeader = (): React.JSX.Element | null => {
|
||||
const isElectron = useMemo(() => isElectronMode(), []);
|
||||
const {
|
||||
cliStatus,
|
||||
cliStatusLoading,
|
||||
cliProviderStatusLoading,
|
||||
multimodelEnabled,
|
||||
isDashboardFocused,
|
||||
} = useStore(
|
||||
useShallow((state) => {
|
||||
const focusedPane = state.paneLayout.panes.find(
|
||||
(pane) => pane.id === state.paneLayout.focusedPaneId
|
||||
);
|
||||
const activeTab = focusedPane?.tabs.find((tab) => tab.id === focusedPane.activeTabId) ?? null;
|
||||
|
||||
return {
|
||||
cliStatus: state.cliStatus,
|
||||
cliStatusLoading: state.cliStatusLoading,
|
||||
cliProviderStatusLoading: state.cliProviderStatusLoading,
|
||||
multimodelEnabled: state.appConfig?.general?.multimodelEnabled ?? true,
|
||||
isDashboardFocused:
|
||||
!focusedPane || focusedPane.tabs.length === 0 || activeTab?.type === 'dashboard',
|
||||
};
|
||||
})
|
||||
);
|
||||
const [cycleProviderIds, setCycleProviderIds] = useState<CliProviderId[]>([]);
|
||||
|
||||
const loadingCliStatus = useMemo(
|
||||
() =>
|
||||
!cliStatus && cliStatusLoading && multimodelEnabled
|
||||
? createLoadingMultimodelCliStatus()
|
||||
: cliStatus,
|
||||
[cliStatus, cliStatusLoading, multimodelEnabled]
|
||||
);
|
||||
|
||||
const codexAccount = useCodexAccountSnapshot({
|
||||
enabled:
|
||||
isElectron &&
|
||||
multimodelEnabled &&
|
||||
loadingCliStatus?.flavor === 'agent_teams_orchestrator' &&
|
||||
Boolean(loadingCliStatus?.providers.some((provider) => provider.providerId === 'codex')),
|
||||
includeRateLimits: false,
|
||||
});
|
||||
|
||||
const effectiveCliStatus = useMemo(
|
||||
() => mergeCodexCliStatusWithSnapshot(loadingCliStatus, codexAccount.snapshot),
|
||||
[codexAccount.snapshot, loadingCliStatus]
|
||||
);
|
||||
const codexSnapshotPending =
|
||||
codexAccount.loading &&
|
||||
Boolean(loadingCliStatus?.providers.some((provider) => provider.providerId === 'codex')) &&
|
||||
!codexAccount.snapshot;
|
||||
|
||||
const sourceProviderMap = useMemo(
|
||||
() =>
|
||||
new Map(
|
||||
(loadingCliStatus?.providers ?? []).map((provider) => [provider.providerId, provider])
|
||||
),
|
||||
[loadingCliStatus?.providers]
|
||||
);
|
||||
|
||||
const providerStates = useMemo<ProviderActivityState[]>(() => {
|
||||
const visibleProviders = filterMainScreenCliProviders(effectiveCliStatus?.providers ?? []);
|
||||
|
||||
return visibleProviders.map((provider) => {
|
||||
const sourceProvider = sourceProviderMap.get(provider.providerId) ?? null;
|
||||
const loading =
|
||||
isProviderCardLoading(provider, cliProviderStatusLoading[provider.providerId] === true) ||
|
||||
(provider.providerId === 'codex' && codexSnapshotPending) ||
|
||||
shouldMaskCodexNegativeBootstrapState(sourceProvider, provider);
|
||||
|
||||
return {
|
||||
provider,
|
||||
loading,
|
||||
error: !loading && provider.verificationState === 'error',
|
||||
};
|
||||
});
|
||||
}, [
|
||||
cliProviderStatusLoading,
|
||||
codexSnapshotPending,
|
||||
effectiveCliStatus?.providers,
|
||||
sourceProviderMap,
|
||||
]);
|
||||
|
||||
const visibleProviderIds = useMemo(
|
||||
() => providerStates.map((state) => state.provider.providerId),
|
||||
[providerStates]
|
||||
);
|
||||
const loadingProviderIds = useMemo(
|
||||
() => providerStates.filter((state) => state.loading).map((state) => state.provider.providerId),
|
||||
[providerStates]
|
||||
);
|
||||
const errorProviderIds = useMemo(
|
||||
() => providerStates.filter((state) => state.error).map((state) => state.provider.providerId),
|
||||
[providerStates]
|
||||
);
|
||||
const providerStateMap = useMemo(
|
||||
() => new Map(providerStates.map((state) => [state.provider.providerId, state])),
|
||||
[providerStates]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
setCycleProviderIds((previousIds) => {
|
||||
const visiblePreviousIds = previousIds.filter((providerId) =>
|
||||
visibleProviderIds.includes(providerId)
|
||||
);
|
||||
|
||||
if (loadingProviderIds.length > 0) {
|
||||
const nextIds = [...visiblePreviousIds];
|
||||
for (const providerId of loadingProviderIds) {
|
||||
if (!nextIds.includes(providerId)) {
|
||||
nextIds.push(providerId);
|
||||
}
|
||||
}
|
||||
|
||||
return areProviderIdListsEqual(nextIds, previousIds) ? previousIds : nextIds;
|
||||
}
|
||||
|
||||
if (errorProviderIds.length > 0) {
|
||||
return areProviderIdListsEqual(errorProviderIds, previousIds)
|
||||
? previousIds
|
||||
: errorProviderIds;
|
||||
}
|
||||
|
||||
return previousIds.length === 0 ? previousIds : [];
|
||||
});
|
||||
}, [errorProviderIds, loadingProviderIds, visibleProviderIds]);
|
||||
|
||||
const displayProviderIds = useMemo(() => {
|
||||
if (loadingProviderIds.length > 0) {
|
||||
const activeCycleIds = (
|
||||
cycleProviderIds.length > 0 ? cycleProviderIds : loadingProviderIds
|
||||
).filter((providerId) => providerStateMap.has(providerId));
|
||||
return Array.from(new Set([...activeCycleIds, ...errorProviderIds]));
|
||||
}
|
||||
|
||||
if (errorProviderIds.length > 0) {
|
||||
return errorProviderIds;
|
||||
}
|
||||
|
||||
return [];
|
||||
}, [cycleProviderIds, errorProviderIds, loadingProviderIds, providerStateMap]);
|
||||
|
||||
if (
|
||||
!isElectron ||
|
||||
isDashboardFocused ||
|
||||
!multimodelEnabled ||
|
||||
!effectiveCliStatus ||
|
||||
effectiveCliStatus.flavor !== 'agent_teams_orchestrator' ||
|
||||
!effectiveCliStatus.installed ||
|
||||
displayProviderIds.length === 0
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="flex shrink-0 flex-wrap items-center gap-2 border-b px-4 py-2"
|
||||
style={{
|
||||
backgroundColor: 'var(--color-surface-sidebar)',
|
||||
borderColor: 'var(--color-border)',
|
||||
}}
|
||||
>
|
||||
<span
|
||||
className="shrink-0 text-[11px] font-medium uppercase tracking-[0.08em]"
|
||||
style={{ color: 'var(--color-text-muted)' }}
|
||||
>
|
||||
Provider Activity
|
||||
</span>
|
||||
<div className="flex min-w-0 flex-1 flex-wrap items-center gap-2">
|
||||
{displayProviderIds.map((providerId) => {
|
||||
const providerState = providerStateMap.get(providerId);
|
||||
if (!providerState) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const tone = providerState.loading
|
||||
? 'loading'
|
||||
: providerState.error
|
||||
? 'error'
|
||||
: 'checked';
|
||||
const styles = getActivityToneStyles(tone);
|
||||
const statusText =
|
||||
tone === 'loading'
|
||||
? 'Checking...'
|
||||
: tone === 'error'
|
||||
? formatProviderStatusText(providerState.provider)
|
||||
: 'Checked';
|
||||
|
||||
return (
|
||||
<div
|
||||
key={providerId}
|
||||
data-testid={`global-provider-status-${providerId}`}
|
||||
className="flex min-w-0 items-center gap-1.5 rounded-md border px-2 py-1 text-[11px]"
|
||||
style={{
|
||||
borderColor: styles.borderColor,
|
||||
backgroundColor: styles.backgroundColor,
|
||||
color: styles.textColor,
|
||||
}}
|
||||
>
|
||||
{tone === 'loading' ? (
|
||||
<Loader2
|
||||
className="size-3 shrink-0 animate-spin"
|
||||
style={{ color: styles.statusColor }}
|
||||
/>
|
||||
) : tone === 'error' ? (
|
||||
<AlertTriangle className="size-3 shrink-0" style={{ color: styles.statusColor }} />
|
||||
) : (
|
||||
<CheckCircle2 className="size-3 shrink-0" style={{ color: styles.statusColor }} />
|
||||
)}
|
||||
<ProviderBrandLogo providerId={providerId} className="size-3.5 shrink-0" />
|
||||
<span className="shrink-0 font-medium" style={{ color: styles.textColor }}>
|
||||
{providerState.provider.displayName}
|
||||
</span>
|
||||
<span
|
||||
className="max-w-[280px] truncate"
|
||||
style={{ color: styles.statusColor }}
|
||||
title={statusText}
|
||||
>
|
||||
{statusText}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
@ -36,6 +36,10 @@ import { SettingsToggle } from '@renderer/components/settings/components';
|
|||
import { TerminalLogPanel } from '@renderer/components/terminal/TerminalLogPanel';
|
||||
import { TerminalModal } from '@renderer/components/terminal/TerminalModal';
|
||||
import { useCliInstaller } from '@renderer/hooks/useCliInstaller';
|
||||
import {
|
||||
loadDashboardCliStatusBannerCollapsed,
|
||||
saveDashboardCliStatusBannerCollapsed,
|
||||
} from '@renderer/services/dashboardCliStatusBannerPreference';
|
||||
import { useStore } from '@renderer/store';
|
||||
import { createLoadingMultimodelCliStatus } from '@renderer/store/slices/cliInstallerSlice';
|
||||
import { formatBytes } from '@renderer/utils/formatters';
|
||||
|
|
@ -47,6 +51,7 @@ import {
|
|||
AlertTriangle,
|
||||
CheckCircle,
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
ChevronUp,
|
||||
Download,
|
||||
HelpCircle,
|
||||
|
|
@ -258,16 +263,20 @@ interface InstalledBannerProps {
|
|||
cliProviderStatusLoading: Partial<Record<CliProviderId, boolean>>;
|
||||
codexSnapshotPending: boolean;
|
||||
cliStatusError: string | null;
|
||||
providersCollapsed: boolean;
|
||||
isBusy: boolean;
|
||||
multimodelEnabled: boolean;
|
||||
multimodelBusy: boolean;
|
||||
onInstall: () => void;
|
||||
onRefresh: () => void;
|
||||
onMultimodelToggle: (enabled: boolean) => void;
|
||||
onToggleProvidersCollapsed: () => void;
|
||||
onProviderLogin: (providerId: CliProviderId) => void;
|
||||
onProviderLogout: (providerId: CliProviderId) => void;
|
||||
onProviderManage: (providerId: CliProviderId) => void;
|
||||
onProviderRefresh: (providerId: CliProviderId) => void;
|
||||
onCodexReconnect: () => void;
|
||||
codexReconnectBusy: boolean;
|
||||
variant: BannerVariant;
|
||||
}
|
||||
|
||||
|
|
@ -547,16 +556,20 @@ const InstalledBanner = ({
|
|||
cliProviderStatusLoading,
|
||||
codexSnapshotPending,
|
||||
cliStatusError,
|
||||
providersCollapsed,
|
||||
isBusy,
|
||||
multimodelEnabled,
|
||||
multimodelBusy,
|
||||
onInstall,
|
||||
onRefresh,
|
||||
onMultimodelToggle,
|
||||
onToggleProvidersCollapsed,
|
||||
onProviderLogin,
|
||||
onProviderLogout,
|
||||
onProviderManage,
|
||||
onProviderRefresh,
|
||||
onCodexReconnect,
|
||||
codexReconnectBusy,
|
||||
variant,
|
||||
}: InstalledBannerProps): React.JSX.Element => {
|
||||
const openExtensionsTab = useStore((s) => s.openExtensionsTab);
|
||||
|
|
@ -568,14 +581,37 @@ const InstalledBanner = ({
|
|||
const canOpenExtensions = cliStatus.installed;
|
||||
const runtimeLabel = formatRuntimeLabel(cliStatus);
|
||||
const runtimeAuthSummary = formatRuntimeAuthSummary(cliStatus, visibleProviders);
|
||||
const showCollapseControl = visibleProviders.length > 0;
|
||||
const showExpandedContent = !providersCollapsed;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`mb-6 rounded-lg border-l-4 px-4 py-3 ${BANNER_MIN_H}`}
|
||||
className={`mb-6 rounded-lg border-l-4 px-4 ${
|
||||
showExpandedContent ? `py-3 ${BANNER_MIN_H}` : 'py-2.5'
|
||||
}`}
|
||||
style={{ borderColor: styles.border, backgroundColor: styles.bg }}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
{showCollapseControl && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onToggleProvidersCollapsed}
|
||||
className="flex items-center justify-center rounded-md p-1 transition-colors hover:bg-white/5"
|
||||
style={{ color: 'var(--color-text-muted)' }}
|
||||
aria-label={
|
||||
providersCollapsed ? 'Expand provider details' : 'Collapse provider details'
|
||||
}
|
||||
aria-expanded={!providersCollapsed}
|
||||
title={providersCollapsed ? 'Expand provider details' : 'Collapse provider details'}
|
||||
>
|
||||
{providersCollapsed ? (
|
||||
<ChevronRight className="size-4 shrink-0" />
|
||||
) : (
|
||||
<ChevronDown className="size-4 shrink-0" />
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
<Terminal className="size-4 shrink-0" style={{ color: 'var(--color-text-muted)' }} />
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
|
|
@ -663,12 +699,12 @@ const InstalledBanner = ({
|
|||
)}
|
||||
</div>
|
||||
</div>
|
||||
{cliStatusError && !cliStatusLoading && (
|
||||
{showExpandedContent && cliStatusError && !cliStatusLoading && (
|
||||
<p className="mt-2 text-xs" style={{ color: '#f87171' }}>
|
||||
Failed to check for updates. Check your network connection and try again.
|
||||
</p>
|
||||
)}
|
||||
{visibleProviders.length > 0 && (
|
||||
{showExpandedContent && visibleProviders.length > 0 && (
|
||||
<div
|
||||
className="mt-3 space-y-2 border-t pt-3"
|
||||
style={{ borderColor: 'var(--color-border-subtle)' }}
|
||||
|
|
@ -682,6 +718,12 @@ const InstalledBanner = ({
|
|||
const credentialSummary = getProviderCredentialSummary(provider);
|
||||
const codexDashboardRateLimits = getCodexDashboardRateLimits(provider);
|
||||
const codexDashboardHint = getCodexDashboardHint(provider);
|
||||
const codexNeedsReconnect =
|
||||
provider.providerId === 'codex' &&
|
||||
Boolean(provider.connection?.codex?.localActiveChatgptAccountPresent) &&
|
||||
provider.connection?.codex?.launchAllowed !== true &&
|
||||
provider.connection?.codex?.login.status !== 'starting' &&
|
||||
provider.connection?.codex?.login.status !== 'pending';
|
||||
const disconnectAction = getProviderDisconnectAction(provider);
|
||||
const providerLoading = cliProviderStatusLoading[provider.providerId] === true;
|
||||
const sourceProvider = sourceProviderMap.get(provider.providerId) ?? null;
|
||||
|
|
@ -842,7 +884,24 @@ const InstalledBanner = ({
|
|||
color: 'var(--color-text-secondary)',
|
||||
}}
|
||||
>
|
||||
{codexDashboardHint}
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="min-w-0 flex-1">{codexDashboardHint}</span>
|
||||
{codexNeedsReconnect ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onCodexReconnect}
|
||||
disabled={codexReconnectBusy || actionDisabled}
|
||||
className="shrink-0 rounded-md border px-2 py-1 text-[10px] font-medium transition-colors hover:bg-white/5 disabled:opacity-50"
|
||||
style={{
|
||||
borderColor: 'rgba(245, 158, 11, 0.28)',
|
||||
backgroundColor: 'rgba(245, 158, 11, 0.08)',
|
||||
color: '#fbbf24',
|
||||
}}
|
||||
>
|
||||
Reconnect ChatGPT
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
|
@ -960,6 +1019,9 @@ export const CliStatusBanner = (): React.JSX.Element | null => {
|
|||
const [isVerifyingAuth, setIsVerifyingAuth] = useState(false);
|
||||
const [isSwitchingFlavor, setIsSwitchingFlavor] = useState(false);
|
||||
const [showTroubleshoot, setShowTroubleshoot] = useState(false);
|
||||
const [providersCollapsed, setProvidersCollapsed] = useState(() =>
|
||||
loadDashboardCliStatusBannerCollapsed()
|
||||
);
|
||||
const multimodelEnabled = appConfig?.general?.multimodelEnabled ?? true;
|
||||
const loadingCliStatus = useMemo(
|
||||
() =>
|
||||
|
|
@ -1048,6 +1110,27 @@ export const CliStatusBanner = (): React.JSX.Element | null => {
|
|||
});
|
||||
}, [bootstrapCliStatus, fetchCliStatus, multimodelEnabled]);
|
||||
|
||||
const handleToggleProvidersCollapsed = useCallback(() => {
|
||||
setProvidersCollapsed((current) => {
|
||||
const next = !current;
|
||||
saveDashboardCliStatusBannerCollapsed(next);
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const handleCodexDashboardLogin = useCallback(() => {
|
||||
void (async () => {
|
||||
const success = await codexAccount.startChatgptLogin();
|
||||
if (success) {
|
||||
await refreshCliStatusForCurrentMode({
|
||||
multimodelEnabled,
|
||||
bootstrapCliStatus,
|
||||
fetchCliStatus,
|
||||
});
|
||||
}
|
||||
})();
|
||||
}, [bootstrapCliStatus, codexAccount, fetchCliStatus, multimodelEnabled]);
|
||||
|
||||
const handleMultimodelToggle = useCallback(
|
||||
async (enabled: boolean) => {
|
||||
setIsSwitchingFlavor(true);
|
||||
|
|
@ -1321,16 +1404,20 @@ export const CliStatusBanner = (): React.JSX.Element | null => {
|
|||
cliProviderStatusLoading={cliProviderStatusLoading}
|
||||
codexSnapshotPending={codexSnapshotPending}
|
||||
cliStatusError={cliStatusError ?? null}
|
||||
providersCollapsed={providersCollapsed}
|
||||
isBusy={isBusy}
|
||||
multimodelEnabled={multimodelEnabled}
|
||||
multimodelBusy={isSwitchingFlavor}
|
||||
onInstall={handleInstall}
|
||||
onRefresh={handleRefresh}
|
||||
onMultimodelToggle={(enabled) => void handleMultimodelToggle(enabled)}
|
||||
onToggleProvidersCollapsed={handleToggleProvidersCollapsed}
|
||||
onProviderLogin={handleProviderLogin}
|
||||
onProviderLogout={handleProviderLogout}
|
||||
onProviderManage={handleProviderManage}
|
||||
onProviderRefresh={handleProviderRefresh}
|
||||
onCodexReconnect={handleCodexDashboardLogin}
|
||||
codexReconnectBusy={codexAccount.loading}
|
||||
variant="info"
|
||||
/>
|
||||
);
|
||||
|
|
@ -1545,16 +1632,20 @@ export const CliStatusBanner = (): React.JSX.Element | null => {
|
|||
cliProviderStatusLoading={cliProviderStatusLoading}
|
||||
codexSnapshotPending={codexSnapshotPending}
|
||||
cliStatusError={cliStatusError ?? null}
|
||||
providersCollapsed={providersCollapsed}
|
||||
isBusy={isBusy}
|
||||
multimodelEnabled={multimodelEnabled}
|
||||
multimodelBusy={isSwitchingFlavor}
|
||||
onInstall={handleInstall}
|
||||
onRefresh={handleRefresh}
|
||||
onMultimodelToggle={(enabled) => void handleMultimodelToggle(enabled)}
|
||||
onToggleProvidersCollapsed={handleToggleProvidersCollapsed}
|
||||
onProviderLogin={handleProviderLogin}
|
||||
onProviderLogout={handleProviderLogout}
|
||||
onProviderManage={handleProviderManage}
|
||||
onProviderRefresh={handleProviderRefresh}
|
||||
onCodexReconnect={handleCodexDashboardLogin}
|
||||
codexReconnectBusy={codexAccount.loading}
|
||||
variant={variant}
|
||||
/>
|
||||
{installedAuxiliaryUi}
|
||||
|
|
@ -1603,16 +1694,20 @@ export const CliStatusBanner = (): React.JSX.Element | null => {
|
|||
cliProviderStatusLoading={cliProviderStatusLoading}
|
||||
codexSnapshotPending={codexSnapshotPending}
|
||||
cliStatusError={cliStatusError ?? null}
|
||||
providersCollapsed={providersCollapsed}
|
||||
isBusy={isBusy}
|
||||
multimodelEnabled={multimodelEnabled}
|
||||
multimodelBusy={isSwitchingFlavor}
|
||||
onInstall={handleInstall}
|
||||
onRefresh={handleRefresh}
|
||||
onMultimodelToggle={(enabled) => void handleMultimodelToggle(enabled)}
|
||||
onToggleProvidersCollapsed={handleToggleProvidersCollapsed}
|
||||
onProviderLogin={handleProviderLogin}
|
||||
onProviderLogout={handleProviderLogout}
|
||||
onProviderManage={handleProviderManage}
|
||||
onProviderRefresh={handleProviderRefresh}
|
||||
onCodexReconnect={handleCodexDashboardLogin}
|
||||
codexReconnectBusy={codexAccount.loading}
|
||||
variant={variant}
|
||||
/>
|
||||
<div
|
||||
|
|
@ -1821,16 +1916,20 @@ export const CliStatusBanner = (): React.JSX.Element | null => {
|
|||
cliProviderStatusLoading={cliProviderStatusLoading}
|
||||
codexSnapshotPending={codexSnapshotPending}
|
||||
cliStatusError={cliStatusError ?? null}
|
||||
providersCollapsed={providersCollapsed}
|
||||
isBusy={isBusy}
|
||||
multimodelEnabled={multimodelEnabled}
|
||||
multimodelBusy={isSwitchingFlavor}
|
||||
onInstall={handleInstall}
|
||||
onRefresh={handleRefresh}
|
||||
onMultimodelToggle={(enabled) => void handleMultimodelToggle(enabled)}
|
||||
onToggleProvidersCollapsed={handleToggleProvidersCollapsed}
|
||||
onProviderLogin={handleProviderLogin}
|
||||
onProviderLogout={handleProviderLogout}
|
||||
onProviderManage={handleProviderManage}
|
||||
onProviderRefresh={handleProviderRefresh}
|
||||
onCodexReconnect={handleCodexDashboardLogin}
|
||||
codexReconnectBusy={codexAccount.loading}
|
||||
variant={variant}
|
||||
/>
|
||||
{installedAuxiliaryUi}
|
||||
|
|
|
|||
|
|
@ -29,6 +29,7 @@ import { useStore } from '@renderer/store';
|
|||
import { useShallow } from 'zustand/react/shallow';
|
||||
|
||||
import { CliInstallWarningBanner } from '../common/CliInstallWarningBanner';
|
||||
import { GlobalProviderStatusHeader } from '../common/GlobalProviderStatusHeader';
|
||||
import { UpdateBanner } from '../common/UpdateBanner';
|
||||
import { UpdateDialog } from '../common/UpdateDialog';
|
||||
import { WorkspaceIndicator } from '../common/WorkspaceIndicator';
|
||||
|
|
@ -163,6 +164,7 @@ export const TabbedLayout = (): React.JSX.Element => {
|
|||
>
|
||||
<TabBarRow />
|
||||
<CliInstallWarningBanner />
|
||||
<GlobalProviderStatusHeader />
|
||||
<div className="flex flex-1 overflow-hidden">
|
||||
{/* Command Palette (Cmd+K) */}
|
||||
<CommandPalette />
|
||||
|
|
|
|||
|
|
@ -276,7 +276,10 @@ function getCodexAccountPanelHint(
|
|||
return null;
|
||||
}
|
||||
|
||||
if (codex.managedAccount?.type === 'chatgpt') {
|
||||
const hasActiveChatgptSession =
|
||||
codex.effectiveAuthMode === 'chatgpt' && codex.launchAllowed === true;
|
||||
|
||||
if (hasActiveChatgptSession) {
|
||||
if (!codex.rateLimits) {
|
||||
return 'Usage limits appear here after Codex reports them for the connected ChatGPT account.';
|
||||
}
|
||||
|
|
@ -689,6 +692,10 @@ export const ProviderRuntimeSettingsDialog = ({
|
|||
: null;
|
||||
const codexConnection =
|
||||
selectedProvider?.providerId === 'codex' ? (selectedProvider.connection?.codex ?? null) : null;
|
||||
const codexHasActiveChatgptSession =
|
||||
codexConnection?.effectiveAuthMode === 'chatgpt' && codexConnection.launchAllowed === true;
|
||||
const codexNeedsReconnect =
|
||||
Boolean(codexConnection?.localActiveChatgptAccountPresent) && !codexHasActiveChatgptSession;
|
||||
const codexLoginPending =
|
||||
codexConnection?.login.status === 'starting' || codexConnection?.login.status === 'pending';
|
||||
const configurableAuthModes = selectedProvider?.connection?.configurableAuthModes ?? [];
|
||||
|
|
@ -1358,7 +1365,7 @@ export const ProviderRuntimeSettingsDialog = ({
|
|||
>
|
||||
Cancel login
|
||||
</Button>
|
||||
) : codexConnection?.managedAccount?.type === 'chatgpt' ? (
|
||||
) : codexHasActiveChatgptSession ? (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
|
|
@ -1375,7 +1382,7 @@ export const ProviderRuntimeSettingsDialog = ({
|
|||
onClick={() => void handleCodexStartLogin()}
|
||||
>
|
||||
<Link2 className="mr-1 size-3.5" />
|
||||
Connect ChatGPT
|
||||
{codexNeedsReconnect ? 'Reconnect ChatGPT' : 'Connect ChatGPT'}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
|
@ -1385,21 +1392,25 @@ export const ProviderRuntimeSettingsDialog = ({
|
|||
<span
|
||||
className="rounded-full px-2 py-0.5"
|
||||
style={{
|
||||
color:
|
||||
codexConnection?.managedAccount?.type === 'chatgpt'
|
||||
? '#86efac'
|
||||
color: codexHasActiveChatgptSession
|
||||
? '#86efac'
|
||||
: codexNeedsReconnect
|
||||
? '#fbbf24'
|
||||
: 'var(--color-text-muted)',
|
||||
backgroundColor:
|
||||
codexConnection?.managedAccount?.type === 'chatgpt'
|
||||
? 'rgba(74, 222, 128, 0.14)'
|
||||
backgroundColor: codexHasActiveChatgptSession
|
||||
? 'rgba(74, 222, 128, 0.14)'
|
||||
: codexNeedsReconnect
|
||||
? 'rgba(245, 158, 11, 0.14)'
|
||||
: 'rgba(255, 255, 255, 0.05)',
|
||||
}}
|
||||
>
|
||||
{codexConnection?.managedAccount?.type === 'chatgpt'
|
||||
{codexHasActiveChatgptSession
|
||||
? 'Connected'
|
||||
: codexLoginPending
|
||||
? 'Login in progress'
|
||||
: 'Not connected'}
|
||||
: codexNeedsReconnect
|
||||
? 'Reconnect required'
|
||||
: codexLoginPending
|
||||
? 'Login in progress'
|
||||
: 'Not connected'}
|
||||
</span>
|
||||
{codexConnection ? (
|
||||
<span
|
||||
|
|
|
|||
|
|
@ -83,8 +83,18 @@ import {
|
|||
type ProviderPrepareDiagnosticsModelResult,
|
||||
runProviderPrepareDiagnostics,
|
||||
} from './providerPrepareDiagnostics';
|
||||
import {
|
||||
getShortLivedProviderPrepareModelResults,
|
||||
storeShortLivedProviderPrepareModelResults,
|
||||
} from './providerPrepareShortLivedCache';
|
||||
import {
|
||||
buildProviderPrepareMembersSignature,
|
||||
buildProviderPrepareRequestSignature,
|
||||
buildProviderPrepareRuntimeStatusSignature,
|
||||
} from './providerPrepareRequestSignature';
|
||||
import { getProvisioningModelIssue } from './provisioningModelIssues';
|
||||
import {
|
||||
deriveEffectiveProvisioningPrepareState,
|
||||
failIncompleteProviderChecks,
|
||||
getPrimaryProvisioningFailureDetail,
|
||||
getProvisioningFailureHint,
|
||||
|
|
@ -558,7 +568,7 @@ export const CreateTeamDialog = ({
|
|||
new Set([
|
||||
selectedProviderId,
|
||||
...members.flatMap((member) =>
|
||||
isTeamProviderId(member.providerId) ? [member.providerId] : []
|
||||
!member.removedAt && isTeamProviderId(member.providerId) ? [member.providerId] : []
|
||||
),
|
||||
])
|
||||
);
|
||||
|
|
@ -590,6 +600,7 @@ export const CreateTeamDialog = ({
|
|||
const prepareModelResultsCacheRef = useRef(
|
||||
new Map<string, Record<string, ProviderPrepareDiagnosticsModelResult>>()
|
||||
);
|
||||
const lastPrepareRequestSignatureRef = useRef<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
runtimeBackendSummaryByProviderRef.current = runtimeBackendSummaryByProvider;
|
||||
|
|
@ -612,10 +623,44 @@ export const CreateTeamDialog = ({
|
|||
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
prepareModelResultsCacheRef.current.clear();
|
||||
lastPrepareRequestSignatureRef.current = null;
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
const prepareRuntimeStatusSignature = useMemo(
|
||||
() =>
|
||||
buildProviderPrepareRuntimeStatusSignature(
|
||||
selectedMemberProviders,
|
||||
runtimeProviderStatusById
|
||||
),
|
||||
[runtimeProviderStatusById, selectedMemberProviders]
|
||||
);
|
||||
const prepareMembersSignature = useMemo(
|
||||
() => buildProviderPrepareMembersSignature(effectiveMemberDrafts),
|
||||
[effectiveMemberDrafts]
|
||||
);
|
||||
const prepareRequestSignature = useMemo(
|
||||
() =>
|
||||
buildProviderPrepareRequestSignature({
|
||||
cwd: effectiveCwd,
|
||||
selectedProviderId,
|
||||
selectedModel,
|
||||
selectedMemberProviders,
|
||||
limitContext,
|
||||
runtimeStatusSignature: prepareRuntimeStatusSignature,
|
||||
membersSignature: prepareMembersSignature,
|
||||
}),
|
||||
[
|
||||
effectiveCwd,
|
||||
limitContext,
|
||||
prepareMembersSignature,
|
||||
prepareRuntimeStatusSignature,
|
||||
selectedMemberProviders,
|
||||
selectedModel,
|
||||
selectedProviderId,
|
||||
]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (multimodelEnabled) {
|
||||
return;
|
||||
|
|
@ -644,10 +689,14 @@ export const CreateTeamDialog = ({
|
|||
|
||||
useEffect(() => {
|
||||
if (!open || !canCreate || !launchTeam) {
|
||||
prepareRequestSeqRef.current += 1;
|
||||
lastPrepareRequestSignatureRef.current = null;
|
||||
return;
|
||||
}
|
||||
|
||||
if (typeof api.teams.prepareProvisioning !== 'function') {
|
||||
prepareRequestSeqRef.current += 1;
|
||||
lastPrepareRequestSignatureRef.current = null;
|
||||
setPrepareState('failed');
|
||||
setPrepareWarnings([]);
|
||||
setPrepareChecks([]);
|
||||
|
|
@ -658,6 +707,8 @@ export const CreateTeamDialog = ({
|
|||
}
|
||||
|
||||
if (!effectiveCwd) {
|
||||
prepareRequestSeqRef.current += 1;
|
||||
lastPrepareRequestSignatureRef.current = null;
|
||||
setPrepareState('idle');
|
||||
setPrepareWarnings([]);
|
||||
setPrepareChecks([]);
|
||||
|
|
@ -665,7 +716,11 @@ export const CreateTeamDialog = ({
|
|||
return;
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
if (lastPrepareRequestSignatureRef.current === prepareRequestSignature) {
|
||||
return;
|
||||
}
|
||||
lastPrepareRequestSignatureRef.current = prepareRequestSignature;
|
||||
|
||||
const requestSeq = ++prepareRequestSeqRef.current;
|
||||
const initialChecks = alignProvisioningChecks(
|
||||
prepareChecksRef.current,
|
||||
|
|
@ -676,170 +731,176 @@ export const CreateTeamDialog = ({
|
|||
setPrepareWarnings([]);
|
||||
setPrepareChecks(initialChecks);
|
||||
|
||||
// Defer so file list fetch (triggered by project select) can run first
|
||||
const timer = setTimeout(() => {
|
||||
void (async () => {
|
||||
let checks = initialChecks;
|
||||
const providerPlans = selectedMemberProviders.map((providerId) => {
|
||||
const selectedModelChecks = (() => {
|
||||
const next = new Set<string>();
|
||||
let hasDefaultSelection = false;
|
||||
const supportsProviderDefaultCheck =
|
||||
providerId === 'codex' ||
|
||||
providerId === 'gemini' ||
|
||||
(providerId === 'anthropic' && selectedProviderId === 'anthropic');
|
||||
const leadModel = computeEffectiveTeamModel(
|
||||
selectedModel,
|
||||
limitContext,
|
||||
selectedProviderId
|
||||
);
|
||||
if (selectedProviderId === providerId && selectedModel.trim()) {
|
||||
if (leadModel?.trim()) {
|
||||
next.add(leadModel.trim());
|
||||
}
|
||||
} else if (selectedProviderId === providerId && supportsProviderDefaultCheck) {
|
||||
void (async () => {
|
||||
await Promise.resolve();
|
||||
let checks = initialChecks;
|
||||
const providerPlans = selectedMemberProviders.map((providerId) => {
|
||||
const selectedModelChecks = (() => {
|
||||
const next = new Set<string>();
|
||||
let hasDefaultSelection = false;
|
||||
const supportsProviderDefaultCheck =
|
||||
providerId === 'codex' ||
|
||||
providerId === 'gemini' ||
|
||||
(providerId === 'anthropic' && selectedProviderId === 'anthropic');
|
||||
const leadModel = computeEffectiveTeamModel(
|
||||
selectedModel,
|
||||
limitContext,
|
||||
selectedProviderId
|
||||
);
|
||||
if (selectedProviderId === providerId && selectedModel.trim()) {
|
||||
if (leadModel?.trim()) {
|
||||
next.add(leadModel.trim());
|
||||
}
|
||||
} else if (selectedProviderId === providerId && supportsProviderDefaultCheck) {
|
||||
hasDefaultSelection = true;
|
||||
}
|
||||
for (const member of effectiveMemberDrafts) {
|
||||
if (member.removedAt) {
|
||||
continue;
|
||||
}
|
||||
const scopedModel = resolveProviderScopedMemberModel({
|
||||
memberProviderId: member.providerId,
|
||||
memberModel: member.model,
|
||||
selectedProviderId,
|
||||
runtimeProviderStatusById,
|
||||
});
|
||||
if (scopedModel.providerId !== providerId) {
|
||||
continue;
|
||||
}
|
||||
if (scopedModel.model) {
|
||||
next.add(scopedModel.model);
|
||||
} else if (supportsProviderDefaultCheck) {
|
||||
hasDefaultSelection = true;
|
||||
}
|
||||
for (const member of effectiveMemberDrafts) {
|
||||
if (member.removedAt) {
|
||||
continue;
|
||||
}
|
||||
const scopedModel = resolveProviderScopedMemberModel({
|
||||
memberProviderId: member.providerId,
|
||||
memberModel: member.model,
|
||||
selectedProviderId,
|
||||
runtimeProviderStatusById,
|
||||
});
|
||||
if (scopedModel.providerId !== providerId) {
|
||||
continue;
|
||||
}
|
||||
if (scopedModel.model) {
|
||||
next.add(scopedModel.model);
|
||||
} else if (supportsProviderDefaultCheck) {
|
||||
hasDefaultSelection = true;
|
||||
}
|
||||
}
|
||||
if (supportsProviderDefaultCheck && hasDefaultSelection) {
|
||||
next.add(DEFAULT_PROVIDER_MODEL_SELECTION);
|
||||
}
|
||||
return Array.from(next);
|
||||
})();
|
||||
const backendSummary = runtimeBackendSummaryByProviderRef.current.get(providerId) ?? null;
|
||||
const cacheKey = buildProviderPrepareModelCacheKey({
|
||||
cwd: effectiveCwd,
|
||||
providerId,
|
||||
backendSummary,
|
||||
limitContext,
|
||||
});
|
||||
const cachedModelResultsById = prepareModelResultsCacheRef.current.get(cacheKey) ?? {};
|
||||
const cachedSnapshot = getProviderPrepareCachedSnapshot({
|
||||
providerId,
|
||||
selectedModelIds: selectedModelChecks,
|
||||
cachedModelResultsById,
|
||||
});
|
||||
return {
|
||||
providerId,
|
||||
selectedModelChecks,
|
||||
backendSummary,
|
||||
cacheKey,
|
||||
cachedModelResultsById,
|
||||
cachedSnapshot,
|
||||
};
|
||||
}
|
||||
if (supportsProviderDefaultCheck && hasDefaultSelection) {
|
||||
next.add(DEFAULT_PROVIDER_MODEL_SELECTION);
|
||||
}
|
||||
return Array.from(next);
|
||||
})();
|
||||
const backendSummary = runtimeBackendSummaryByProviderRef.current.get(providerId) ?? null;
|
||||
const cacheKey = buildProviderPrepareModelCacheKey({
|
||||
cwd: effectiveCwd,
|
||||
providerId,
|
||||
backendSummary,
|
||||
limitContext,
|
||||
runtimeStatusSignature: prepareRuntimeStatusSignature,
|
||||
});
|
||||
const cachedModelResultsById = {
|
||||
...getShortLivedProviderPrepareModelResults({
|
||||
providerId,
|
||||
cacheKey,
|
||||
}),
|
||||
...(prepareModelResultsCacheRef.current.get(cacheKey) ?? {}),
|
||||
};
|
||||
const cachedSnapshot = getProviderPrepareCachedSnapshot({
|
||||
providerId,
|
||||
selectedModelIds: selectedModelChecks,
|
||||
cachedModelResultsById,
|
||||
});
|
||||
return {
|
||||
providerId,
|
||||
selectedModelChecks,
|
||||
backendSummary,
|
||||
cacheKey,
|
||||
cachedModelResultsById,
|
||||
cachedSnapshot,
|
||||
};
|
||||
});
|
||||
|
||||
try {
|
||||
for (const plan of providerPlans) {
|
||||
checks = updateProviderCheck(checks, plan.providerId, {
|
||||
status: plan.selectedModelChecks.length > 0 ? plan.cachedSnapshot.status : 'checking',
|
||||
backendSummary: plan.backendSummary,
|
||||
details: plan.cachedSnapshot.details,
|
||||
});
|
||||
}
|
||||
if (!cancelled && prepareRequestSeqRef.current === requestSeq) {
|
||||
setPrepareChecks(checks);
|
||||
}
|
||||
const providerResults = await Promise.all(
|
||||
providerPlans.map(async (plan) => {
|
||||
const prepResult = await runProviderPrepareDiagnostics({
|
||||
cwd: effectiveCwd,
|
||||
providerId: plan.providerId,
|
||||
selectedModelIds: plan.selectedModelChecks,
|
||||
prepareProvisioning: api.teams.prepareProvisioning,
|
||||
limitContext,
|
||||
cachedModelResultsById: plan.cachedModelResultsById,
|
||||
onModelProgress: ({ details }) => {
|
||||
checks = updateProviderCheck(checks, plan.providerId, {
|
||||
status: 'checking',
|
||||
backendSummary: plan.backendSummary,
|
||||
details,
|
||||
});
|
||||
if (!cancelled && prepareRequestSeqRef.current === requestSeq) {
|
||||
setPrepareChecks(checks);
|
||||
}
|
||||
},
|
||||
});
|
||||
return { ...plan, prepResult };
|
||||
})
|
||||
);
|
||||
let anyFailure = false;
|
||||
let anyNotes = false;
|
||||
const collectedWarnings: string[] = [];
|
||||
for (const plan of providerResults) {
|
||||
if (plan.prepResult.warnings.length > 0) {
|
||||
anyNotes = true;
|
||||
collectedWarnings.push(
|
||||
...plan.prepResult.warnings.map(
|
||||
(warning) => `${getProviderLabel(plan.providerId)}: ${warning}`
|
||||
)
|
||||
);
|
||||
}
|
||||
if (plan.prepResult.status === 'failed') {
|
||||
anyFailure = true;
|
||||
} else if (plan.prepResult.status === 'notes') {
|
||||
anyNotes = true;
|
||||
}
|
||||
prepareModelResultsCacheRef.current.set(
|
||||
plan.cacheKey,
|
||||
buildReusableProviderPrepareModelResults(plan.prepResult.modelResultsById)
|
||||
);
|
||||
checks = updateProviderCheck(checks, plan.providerId, {
|
||||
status: plan.prepResult.status,
|
||||
backendSummary: plan.backendSummary,
|
||||
details: plan.prepResult.details,
|
||||
});
|
||||
}
|
||||
if (!cancelled && prepareRequestSeqRef.current === requestSeq) {
|
||||
setPrepareChecks(checks);
|
||||
}
|
||||
if (cancelled || prepareRequestSeqRef.current !== requestSeq) return;
|
||||
const failureMessage =
|
||||
getPrimaryProvisioningFailureDetail(checks) ??
|
||||
'Some selected providers need attention.';
|
||||
setPrepareState(anyFailure ? 'failed' : 'ready');
|
||||
setPrepareMessage(
|
||||
anyFailure
|
||||
? failureMessage
|
||||
: anyNotes
|
||||
? 'Selected providers are ready with notes.'
|
||||
: 'Selected providers are ready.'
|
||||
);
|
||||
setPrepareWarnings(collectedWarnings);
|
||||
} catch (error) {
|
||||
if (cancelled || prepareRequestSeqRef.current !== requestSeq) return;
|
||||
const failureMessage =
|
||||
error instanceof Error ? error.message : 'Failed to warm up Claude CLI environment';
|
||||
setPrepareState('failed');
|
||||
setPrepareWarnings([]);
|
||||
setPrepareChecks(failIncompleteProviderChecks(checks, failureMessage));
|
||||
setPrepareMessage(failureMessage);
|
||||
try {
|
||||
for (const plan of providerPlans) {
|
||||
checks = updateProviderCheck(checks, plan.providerId, {
|
||||
status: plan.selectedModelChecks.length > 0 ? plan.cachedSnapshot.status : 'checking',
|
||||
backendSummary: plan.backendSummary,
|
||||
details: plan.cachedSnapshot.details,
|
||||
});
|
||||
}
|
||||
})();
|
||||
}, 250);
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
clearTimeout(timer);
|
||||
};
|
||||
if (prepareRequestSeqRef.current === requestSeq) {
|
||||
setPrepareChecks(checks);
|
||||
}
|
||||
const providerResults = await Promise.all(
|
||||
providerPlans.map(async (plan) => {
|
||||
const prepResult = await runProviderPrepareDiagnostics({
|
||||
cwd: effectiveCwd,
|
||||
providerId: plan.providerId,
|
||||
selectedModelIds: plan.selectedModelChecks,
|
||||
prepareProvisioning: api.teams.prepareProvisioning,
|
||||
limitContext,
|
||||
cachedModelResultsById: plan.cachedModelResultsById,
|
||||
onModelProgress: ({ status, details }) => {
|
||||
checks = updateProviderCheck(checks, plan.providerId, {
|
||||
status,
|
||||
backendSummary: plan.backendSummary,
|
||||
details,
|
||||
});
|
||||
if (prepareRequestSeqRef.current === requestSeq) {
|
||||
setPrepareChecks(checks);
|
||||
}
|
||||
},
|
||||
});
|
||||
return { ...plan, prepResult };
|
||||
})
|
||||
);
|
||||
let anyFailure = false;
|
||||
let anyNotes = false;
|
||||
const collectedWarnings: string[] = [];
|
||||
for (const plan of providerResults) {
|
||||
if (plan.prepResult.warnings.length > 0) {
|
||||
anyNotes = true;
|
||||
collectedWarnings.push(
|
||||
...plan.prepResult.warnings.map(
|
||||
(warning) => `${getProviderLabel(plan.providerId)}: ${warning}`
|
||||
)
|
||||
);
|
||||
}
|
||||
if (plan.prepResult.status === 'failed') {
|
||||
anyFailure = true;
|
||||
} else if (plan.prepResult.status === 'notes') {
|
||||
anyNotes = true;
|
||||
}
|
||||
if (prepareRequestSeqRef.current === requestSeq) {
|
||||
const reusableModelResults = buildReusableProviderPrepareModelResults(
|
||||
plan.prepResult.modelResultsById
|
||||
);
|
||||
prepareModelResultsCacheRef.current.set(plan.cacheKey, reusableModelResults);
|
||||
storeShortLivedProviderPrepareModelResults({
|
||||
providerId: plan.providerId,
|
||||
cacheKey: plan.cacheKey,
|
||||
modelResultsById: plan.prepResult.modelResultsById,
|
||||
});
|
||||
}
|
||||
checks = updateProviderCheck(checks, plan.providerId, {
|
||||
status: plan.prepResult.status,
|
||||
backendSummary: plan.backendSummary,
|
||||
details: plan.prepResult.details,
|
||||
});
|
||||
}
|
||||
if (prepareRequestSeqRef.current === requestSeq) {
|
||||
setPrepareChecks(checks);
|
||||
}
|
||||
if (prepareRequestSeqRef.current !== requestSeq) return;
|
||||
const failureMessage =
|
||||
getPrimaryProvisioningFailureDetail(checks) ?? 'Some selected providers need attention.';
|
||||
setPrepareState(anyFailure ? 'failed' : 'ready');
|
||||
setPrepareMessage(
|
||||
anyFailure
|
||||
? failureMessage
|
||||
: anyNotes
|
||||
? 'Selected providers are ready with notes.'
|
||||
: 'Selected providers are ready.'
|
||||
);
|
||||
setPrepareWarnings(collectedWarnings);
|
||||
} catch (error) {
|
||||
if (prepareRequestSeqRef.current !== requestSeq) return;
|
||||
const failureMessage =
|
||||
error instanceof Error ? error.message : 'Failed to warm up Claude CLI environment';
|
||||
setPrepareState('failed');
|
||||
setPrepareWarnings([]);
|
||||
setPrepareChecks(failIncompleteProviderChecks(checks, failureMessage));
|
||||
setPrepareMessage(failureMessage);
|
||||
}
|
||||
})();
|
||||
}, [
|
||||
open,
|
||||
canCreate,
|
||||
|
|
@ -847,6 +908,7 @@ export const CreateTeamDialog = ({
|
|||
effectiveCwd,
|
||||
effectiveMemberDrafts,
|
||||
limitContext,
|
||||
prepareRequestSignature,
|
||||
runtimeProviderStatusById,
|
||||
selectedModel,
|
||||
selectedProviderId,
|
||||
|
|
@ -1383,6 +1445,16 @@ export const CreateTeamDialog = ({
|
|||
|
||||
const activeError =
|
||||
localError ?? modelValidationError ?? provisioningErrorsByTeam[request.teamName] ?? null;
|
||||
const effectivePrepare = useMemo(
|
||||
() =>
|
||||
deriveEffectiveProvisioningPrepareState({
|
||||
state: prepareState,
|
||||
message: prepareMessage,
|
||||
warnings: prepareWarnings,
|
||||
checks: prepareChecks,
|
||||
}),
|
||||
[prepareChecks, prepareMessage, prepareState, prepareWarnings]
|
||||
);
|
||||
const canOpenExistingTeam =
|
||||
activeError?.includes('Team already exists') === true && request.teamName.length > 0;
|
||||
|
||||
|
|
@ -1833,14 +1905,16 @@ export const CreateTeamDialog = ({
|
|||
|
||||
<DialogFooter className="pt-4 sm:justify-between">
|
||||
<div className="min-w-0">
|
||||
{canCreate && launchTeam && (prepareState === 'idle' || prepareState === 'loading') ? (
|
||||
{canCreate &&
|
||||
launchTeam &&
|
||||
(effectivePrepare.state === 'idle' || effectivePrepare.state === 'loading') ? (
|
||||
<>
|
||||
<div className="flex items-center gap-2 text-xs text-[var(--color-text-muted)]">
|
||||
<span className="inline-block size-3.5 animate-spin rounded-full border-2 border-current border-t-transparent" />
|
||||
<div>
|
||||
<span>
|
||||
{prepareMessage ??
|
||||
(prepareState === 'idle'
|
||||
{effectivePrepare.message ??
|
||||
(effectivePrepare.state === 'idle'
|
||||
? 'Warming up CLI environment...'
|
||||
: 'Preparing environment...')}
|
||||
</span>
|
||||
|
|
@ -1853,7 +1927,7 @@ export const CreateTeamDialog = ({
|
|||
</>
|
||||
) : null}
|
||||
|
||||
{canCreate && launchTeam && prepareState === 'ready' ? (
|
||||
{canCreate && launchTeam && effectivePrepare.state === 'ready' ? (
|
||||
<div>
|
||||
<div className="flex items-center gap-1.5 text-xs font-medium text-emerald-400">
|
||||
<CheckCircle2 className="size-3.5 shrink-0" />
|
||||
|
|
@ -1864,9 +1938,9 @@ export const CreateTeamDialog = ({
|
|||
: 'CLI environment ready'}
|
||||
</span>
|
||||
</div>
|
||||
{prepareMessage ? (
|
||||
{effectivePrepare.message ? (
|
||||
<p className="mt-0.5 pl-5 text-[11px] text-[var(--color-text-muted)]">
|
||||
{prepareMessage}
|
||||
{effectivePrepare.message}
|
||||
</p>
|
||||
) : null}
|
||||
<ProvisioningProviderStatusList checks={prepareChecks} className="mt-1" />
|
||||
|
|
@ -1882,7 +1956,7 @@ export const CreateTeamDialog = ({
|
|||
</div>
|
||||
) : null}
|
||||
|
||||
{canCreate && launchTeam && prepareState === 'failed' ? (
|
||||
{canCreate && launchTeam && effectivePrepare.state === 'failed' ? (
|
||||
<div className="text-xs">
|
||||
<div className="flex items-start gap-2 text-red-300">
|
||||
<AlertTriangle className="mt-0.5 size-4 shrink-0" />
|
||||
|
|
@ -1891,7 +1965,7 @@ export const CreateTeamDialog = ({
|
|||
CLI environment is not available - launch is blocked
|
||||
</p>
|
||||
<p className="mt-0.5 text-red-300/80">
|
||||
{prepareMessage ?? 'Failed to prepare environment'}
|
||||
{effectivePrepare.message ?? 'Failed to prepare environment'}
|
||||
</p>
|
||||
<p className="mt-0.5 text-[10px] text-[var(--color-text-muted)] opacity-70">
|
||||
Pre-flight check to catch errors before launch
|
||||
|
|
@ -1919,7 +1993,7 @@ export const CreateTeamDialog = ({
|
|||
</div>
|
||||
) : null}
|
||||
<p className="mt-1 pl-6 text-[11px] text-[var(--color-text-muted)]">
|
||||
{getProvisioningFailureHint(prepareMessage, prepareChecks)}
|
||||
{getProvisioningFailureHint(effectivePrepare.message, prepareChecks)}
|
||||
</p>
|
||||
</div>
|
||||
) : null}
|
||||
|
|
@ -1951,7 +2025,8 @@ export const CreateTeamDialog = ({
|
|||
<Loader2 className="mr-1.5 size-3.5 animate-spin" />
|
||||
Creating...
|
||||
</>
|
||||
) : launchTeam && (prepareState === 'idle' || prepareState === 'loading') ? (
|
||||
) : launchTeam &&
|
||||
(effectivePrepare.state === 'idle' || effectivePrepare.state === 'loading') ? (
|
||||
'Skip preflight and create'
|
||||
) : (
|
||||
'Create'
|
||||
|
|
|
|||
|
|
@ -104,8 +104,18 @@ import {
|
|||
type ProviderPrepareDiagnosticsModelResult,
|
||||
runProviderPrepareDiagnostics,
|
||||
} from './providerPrepareDiagnostics';
|
||||
import {
|
||||
getShortLivedProviderPrepareModelResults,
|
||||
storeShortLivedProviderPrepareModelResults,
|
||||
} from './providerPrepareShortLivedCache';
|
||||
import {
|
||||
buildProviderPrepareModelChecksSignature,
|
||||
buildProviderPrepareRequestSignature,
|
||||
buildProviderPrepareRuntimeStatusSignature,
|
||||
} from './providerPrepareRequestSignature';
|
||||
import { getProvisioningModelIssue } from './provisioningModelIssues';
|
||||
import {
|
||||
deriveEffectiveProvisioningPrepareState,
|
||||
failIncompleteProviderChecks,
|
||||
getPrimaryProvisioningFailureDetail,
|
||||
getProvisioningFailureHint,
|
||||
|
|
@ -447,7 +457,7 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
|
|||
new Set([
|
||||
selectedProviderId,
|
||||
...effectiveMemberDrafts.flatMap((member) =>
|
||||
isTeamProviderId(member.providerId) ? [member.providerId] : []
|
||||
!member.removedAt && isTeamProviderId(member.providerId) ? [member.providerId] : []
|
||||
),
|
||||
])
|
||||
),
|
||||
|
|
@ -471,6 +481,7 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
|
|||
const prepareModelResultsCacheRef = useRef(
|
||||
new Map<string, Record<string, ProviderPrepareDiagnosticsModelResult>>()
|
||||
);
|
||||
const lastPrepareRequestSignatureRef = useRef<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
runtimeBackendSummaryByProviderRef.current = runtimeBackendSummaryByProvider;
|
||||
|
|
@ -480,7 +491,7 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
|
|||
}, [prepareChecks]);
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
prepareModelResultsCacheRef.current.clear();
|
||||
lastPrepareRequestSignatureRef.current = null;
|
||||
}
|
||||
}, [open]);
|
||||
const runtimeProviderStatusById = useMemo(
|
||||
|
|
@ -1211,6 +1222,39 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
|
|||
// ---------------------------------------------------------------------------
|
||||
|
||||
const effectiveCwd = cwdMode === 'project' ? selectedProjectPath.trim() : customCwd.trim();
|
||||
const prepareRuntimeStatusSignature = useMemo(
|
||||
() =>
|
||||
buildProviderPrepareRuntimeStatusSignature(
|
||||
selectedMemberProviders,
|
||||
runtimeProviderStatusById
|
||||
),
|
||||
[runtimeProviderStatusById, selectedMemberProviders]
|
||||
);
|
||||
const selectedModelChecksByProviderSignature = useMemo(
|
||||
() => buildProviderPrepareModelChecksSignature(selectedModelChecksByProvider),
|
||||
[selectedModelChecksByProvider]
|
||||
);
|
||||
const prepareRequestSignature = useMemo(
|
||||
() =>
|
||||
buildProviderPrepareRequestSignature({
|
||||
cwd: effectiveCwd,
|
||||
selectedProviderId,
|
||||
selectedModel,
|
||||
selectedMemberProviders,
|
||||
limitContext,
|
||||
runtimeStatusSignature: prepareRuntimeStatusSignature,
|
||||
modelChecksSignature: selectedModelChecksByProviderSignature,
|
||||
}),
|
||||
[
|
||||
effectiveCwd,
|
||||
limitContext,
|
||||
prepareRuntimeStatusSignature,
|
||||
selectedMemberProviders,
|
||||
selectedModel,
|
||||
selectedModelChecksByProviderSignature,
|
||||
selectedProviderId,
|
||||
]
|
||||
);
|
||||
|
||||
// Clear stale provisioning error when dialog opens
|
||||
useEffect(() => {
|
||||
|
|
@ -1221,9 +1265,15 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
|
|||
|
||||
// Warm up CLI for the currently selected working directory (launch mode only).
|
||||
useEffect(() => {
|
||||
if (!open || !isLaunchMode) return;
|
||||
if (!open || !isLaunchMode) {
|
||||
prepareRequestSeqRef.current += 1;
|
||||
lastPrepareRequestSignatureRef.current = null;
|
||||
return;
|
||||
}
|
||||
|
||||
if (typeof api.teams.prepareProvisioning !== 'function') {
|
||||
prepareRequestSeqRef.current += 1;
|
||||
lastPrepareRequestSignatureRef.current = null;
|
||||
setPrepareState('failed');
|
||||
setPrepareWarnings([]);
|
||||
setPrepareChecks([]);
|
||||
|
|
@ -1234,6 +1284,8 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
|
|||
}
|
||||
|
||||
if (!effectiveCwd) {
|
||||
prepareRequestSeqRef.current += 1;
|
||||
lastPrepareRequestSignatureRef.current = null;
|
||||
setPrepareState('idle');
|
||||
setPrepareWarnings([]);
|
||||
setPrepareChecks([]);
|
||||
|
|
@ -1241,7 +1293,11 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
|
|||
return;
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
if (lastPrepareRequestSignatureRef.current === prepareRequestSignature) {
|
||||
return;
|
||||
}
|
||||
lastPrepareRequestSignatureRef.current = prepareRequestSignature;
|
||||
|
||||
const requestSeq = ++prepareRequestSeqRef.current;
|
||||
const initialChecks = alignProvisioningChecks(
|
||||
prepareChecksRef.current,
|
||||
|
|
@ -1262,8 +1318,15 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
|
|||
providerId,
|
||||
backendSummary,
|
||||
limitContext,
|
||||
runtimeStatusSignature: prepareRuntimeStatusSignature,
|
||||
});
|
||||
const cachedModelResultsById = prepareModelResultsCacheRef.current.get(cacheKey) ?? {};
|
||||
const cachedModelResultsById = {
|
||||
...getShortLivedProviderPrepareModelResults({
|
||||
providerId,
|
||||
cacheKey,
|
||||
}),
|
||||
...(prepareModelResultsCacheRef.current.get(cacheKey) ?? {}),
|
||||
};
|
||||
const cachedSnapshot = getProviderPrepareCachedSnapshot({
|
||||
providerId,
|
||||
selectedModelIds: selectedModelChecks,
|
||||
|
|
@ -1287,7 +1350,7 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
|
|||
details: plan.cachedSnapshot.details,
|
||||
});
|
||||
}
|
||||
if (!cancelled && prepareRequestSeqRef.current === requestSeq) {
|
||||
if (prepareRequestSeqRef.current === requestSeq) {
|
||||
setPrepareChecks(checks);
|
||||
}
|
||||
const providerResults = await Promise.all(
|
||||
|
|
@ -1299,13 +1362,13 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
|
|||
prepareProvisioning: api.teams.prepareProvisioning,
|
||||
limitContext,
|
||||
cachedModelResultsById: plan.cachedModelResultsById,
|
||||
onModelProgress: ({ details }) => {
|
||||
onModelProgress: ({ status, details }) => {
|
||||
checks = updateProviderCheck(checks, plan.providerId, {
|
||||
status: 'checking',
|
||||
status,
|
||||
backendSummary: plan.backendSummary,
|
||||
details,
|
||||
});
|
||||
if (!cancelled && prepareRequestSeqRef.current === requestSeq) {
|
||||
if (prepareRequestSeqRef.current === requestSeq) {
|
||||
setPrepareChecks(checks);
|
||||
}
|
||||
},
|
||||
|
|
@ -1330,20 +1393,27 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
|
|||
} else if (plan.prepResult.status === 'notes') {
|
||||
anyNotes = true;
|
||||
}
|
||||
prepareModelResultsCacheRef.current.set(
|
||||
plan.cacheKey,
|
||||
buildReusableProviderPrepareModelResults(plan.prepResult.modelResultsById)
|
||||
);
|
||||
if (prepareRequestSeqRef.current === requestSeq) {
|
||||
const reusableModelResults = buildReusableProviderPrepareModelResults(
|
||||
plan.prepResult.modelResultsById
|
||||
);
|
||||
prepareModelResultsCacheRef.current.set(plan.cacheKey, reusableModelResults);
|
||||
storeShortLivedProviderPrepareModelResults({
|
||||
providerId: plan.providerId,
|
||||
cacheKey: plan.cacheKey,
|
||||
modelResultsById: plan.prepResult.modelResultsById,
|
||||
});
|
||||
}
|
||||
checks = updateProviderCheck(checks, plan.providerId, {
|
||||
status: plan.prepResult.status,
|
||||
backendSummary: plan.backendSummary,
|
||||
details: plan.prepResult.details,
|
||||
});
|
||||
}
|
||||
if (!cancelled && prepareRequestSeqRef.current === requestSeq) {
|
||||
if (prepareRequestSeqRef.current === requestSeq) {
|
||||
setPrepareChecks(checks);
|
||||
}
|
||||
if (cancelled || prepareRequestSeqRef.current !== requestSeq) return;
|
||||
if (prepareRequestSeqRef.current !== requestSeq) return;
|
||||
const failureMessage =
|
||||
getPrimaryProvisioningFailureDetail(checks) ?? 'Some selected providers need attention.';
|
||||
setPrepareState(anyFailure ? 'failed' : 'ready');
|
||||
|
|
@ -1356,7 +1426,7 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
|
|||
);
|
||||
setPrepareWarnings(collectedWarnings);
|
||||
} catch (error) {
|
||||
if (cancelled || prepareRequestSeqRef.current !== requestSeq) return;
|
||||
if (prepareRequestSeqRef.current !== requestSeq) return;
|
||||
const failureMessage =
|
||||
error instanceof Error ? error.message : 'Failed to warm up Claude CLI environment';
|
||||
setPrepareState('failed');
|
||||
|
|
@ -1365,14 +1435,11 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
|
|||
setPrepareMessage(failureMessage);
|
||||
}
|
||||
})();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [
|
||||
open,
|
||||
isLaunchMode,
|
||||
effectiveCwd,
|
||||
prepareRequestSignature,
|
||||
selectedProviderId,
|
||||
selectedMemberProviders,
|
||||
selectedModelChecksByProvider,
|
||||
|
|
@ -1687,6 +1754,16 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
|
|||
|
||||
const provisioningError = isLaunchMode ? props.provisioningError : null;
|
||||
const activeError = localError ?? modelValidationError ?? provisioningError;
|
||||
const effectivePrepare = useMemo(
|
||||
() =>
|
||||
deriveEffectiveProvisioningPrepareState({
|
||||
state: prepareState,
|
||||
message: prepareMessage,
|
||||
warnings: prepareWarnings,
|
||||
checks: prepareChecks,
|
||||
}),
|
||||
[prepareChecks, prepareMessage, prepareState, prepareWarnings]
|
||||
);
|
||||
const launchInFlight = useStore((s) =>
|
||||
isLaunchMode && effectiveTeamName ? isTeamProvisioningActive(s, effectiveTeamName) : false
|
||||
);
|
||||
|
|
@ -2480,14 +2557,14 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
|
|||
{/* Launch-only: CLI warm-up status */}
|
||||
{isLaunchMode ? (
|
||||
<div className="min-w-0">
|
||||
{prepareState === 'idle' || prepareState === 'loading' ? (
|
||||
{effectivePrepare.state === 'idle' || effectivePrepare.state === 'loading' ? (
|
||||
<>
|
||||
<div className="flex items-center gap-2 text-xs text-[var(--color-text-muted)]">
|
||||
<span className="inline-block size-3.5 animate-spin rounded-full border-2 border-current border-t-transparent" />
|
||||
<div>
|
||||
<span>
|
||||
{prepareMessage ??
|
||||
(prepareState === 'idle'
|
||||
{effectivePrepare.message ??
|
||||
(effectivePrepare.state === 'idle'
|
||||
? 'Warming up CLI environment...'
|
||||
: 'Preparing environment...')}
|
||||
</span>
|
||||
|
|
@ -2503,7 +2580,7 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
|
|||
</>
|
||||
) : null}
|
||||
|
||||
{prepareState === 'ready' ? (
|
||||
{effectivePrepare.state === 'ready' ? (
|
||||
<div>
|
||||
<div className="flex items-center gap-1.5 text-xs font-medium text-emerald-400">
|
||||
<CheckCircle2 className="size-3.5 shrink-0" />
|
||||
|
|
@ -2514,9 +2591,9 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
|
|||
: 'CLI environment ready'}
|
||||
</span>
|
||||
</div>
|
||||
{prepareMessage ? (
|
||||
{effectivePrepare.message ? (
|
||||
<p className="mt-0.5 pl-5 text-[11px] text-[var(--color-text-muted)]">
|
||||
{prepareMessage}
|
||||
{effectivePrepare.message}
|
||||
</p>
|
||||
) : null}
|
||||
<ProvisioningProviderStatusList checks={prepareChecks} className="mt-1" />
|
||||
|
|
@ -2532,7 +2609,7 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
|
|||
</div>
|
||||
) : null}
|
||||
|
||||
{prepareState === 'failed' ? (
|
||||
{effectivePrepare.state === 'failed' ? (
|
||||
<div className="text-xs">
|
||||
<div className="flex items-start gap-2 text-red-300">
|
||||
<AlertTriangle className="mt-0.5 size-4 shrink-0" />
|
||||
|
|
@ -2542,18 +2619,21 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
|
|||
blocked
|
||||
</p>
|
||||
<p className="mt-0.5 text-red-300/80">
|
||||
{prepareMessage ?? 'Failed to prepare environment'}
|
||||
{effectivePrepare.message ?? 'Failed to prepare environment'}
|
||||
</p>
|
||||
<p className="mt-0.5 text-[10px] text-[var(--color-text-muted)] opacity-70">
|
||||
Pre-flight check to catch errors before {isRelaunch ? 'relaunch' : 'launch'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{!shouldHideProvisioningProviderStatusList(prepareChecks, prepareMessage) ? (
|
||||
{!shouldHideProvisioningProviderStatusList(
|
||||
prepareChecks,
|
||||
effectivePrepare.message
|
||||
) ? (
|
||||
<ProvisioningProviderStatusList
|
||||
checks={prepareChecks}
|
||||
className="mt-2"
|
||||
suppressDetailsMatching={prepareMessage}
|
||||
suppressDetailsMatching={effectivePrepare.message}
|
||||
/>
|
||||
) : null}
|
||||
{prepareWarnings.length > 0 && prepareChecks.length === 0 ? (
|
||||
|
|
@ -2571,9 +2651,9 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
|
|||
) : null}
|
||||
<div className="mt-1 flex items-center gap-2 pl-6">
|
||||
<p className="text-[11px] text-[var(--color-text-muted)]">
|
||||
{getProvisioningFailureHint(prepareMessage, prepareChecks)}
|
||||
{getProvisioningFailureHint(effectivePrepare.message, prepareChecks)}
|
||||
</p>
|
||||
{(prepareMessage ?? '').toLowerCase().includes('spawn ') ||
|
||||
{(effectivePrepare.message ?? '').toLowerCase().includes('spawn ') ||
|
||||
prepareChecks.some((check) =>
|
||||
check.details.some((detail) => detail.toLowerCase().includes('spawn '))
|
||||
) ? (
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ import type { TeamProviderId } from '@shared/types';
|
|||
import type { CliProviderStatus } from '@shared/types';
|
||||
|
||||
export type ProvisioningProviderCheckStatus = 'pending' | 'checking' | 'ready' | 'notes' | 'failed';
|
||||
export type ProvisioningPrepareState = 'idle' | 'loading' | 'ready' | 'failed';
|
||||
|
||||
export interface ProvisioningProviderCheck {
|
||||
providerId: TeamProviderId;
|
||||
|
|
@ -139,6 +140,7 @@ type ProvisioningDetailSummary =
|
|||
| 'Authentication required'
|
||||
| 'Runtime provider is not configured'
|
||||
| 'CLI preflight failed'
|
||||
| 'Selected model compatibility pending'
|
||||
| 'Selected model verified'
|
||||
| 'Selected model unavailable'
|
||||
| 'Selected model verification timed out'
|
||||
|
|
@ -197,6 +199,9 @@ function summarizeDetail(
|
|||
if (lower.includes('claude cli preflight check failed')) {
|
||||
return 'CLI preflight failed';
|
||||
}
|
||||
if (lower.includes('compatible, deep verification pending')) {
|
||||
return 'Selected model compatibility pending';
|
||||
}
|
||||
if (lower.includes('selected model') && lower.includes('verified for launch')) {
|
||||
return 'Selected model verified';
|
||||
}
|
||||
|
|
@ -236,6 +241,7 @@ function summarizeDetail(
|
|||
}
|
||||
|
||||
function getModelDetailSummary(details: string[]): string | null {
|
||||
let compatibilityPendingCount = 0;
|
||||
let verifiedCount = 0;
|
||||
let unavailableCount = 0;
|
||||
let timedOutCount = 0;
|
||||
|
|
@ -244,6 +250,10 @@ function getModelDetailSummary(details: string[]): string | null {
|
|||
|
||||
for (const detail of details) {
|
||||
const lower = detail.toLowerCase();
|
||||
if (lower.includes('compatible, deep verification pending')) {
|
||||
compatibilityPendingCount += 1;
|
||||
continue;
|
||||
}
|
||||
if (lower.includes(' - verified')) {
|
||||
verifiedCount += 1;
|
||||
continue;
|
||||
|
|
@ -275,6 +285,9 @@ function getModelDetailSummary(details: string[]): string | null {
|
|||
if (timedOutCount > 0) {
|
||||
parts.push(`${timedOutCount} model${timedOutCount === 1 ? '' : 's'} timed out`);
|
||||
}
|
||||
if (compatibilityPendingCount > 0) {
|
||||
parts.push(`${compatibilityPendingCount} compatible, deep verification pending`);
|
||||
}
|
||||
if (checkingCount > 0) {
|
||||
parts.push(`${checkingCount} checking`);
|
||||
}
|
||||
|
|
@ -285,6 +298,14 @@ function getModelDetailSummary(details: string[]): string | null {
|
|||
return parts.length > 0 ? `Selected model checks - ${parts.join(', ')}` : null;
|
||||
}
|
||||
|
||||
function hasCompatibilityPendingDetails(checks: ProvisioningProviderCheck[]): boolean {
|
||||
return checks.some((check) =>
|
||||
check.details.some((detail) =>
|
||||
detail.toLowerCase().includes('compatible, deep verification pending')
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function getDisplayStatusText(check: ProvisioningProviderCheck): string {
|
||||
const modelSummary = getModelDetailSummary(check.details);
|
||||
if (modelSummary) {
|
||||
|
|
@ -402,6 +423,64 @@ export function getPrimaryProvisioningFailureDetail(
|
|||
return null;
|
||||
}
|
||||
|
||||
export function deriveEffectiveProvisioningPrepareState(params: {
|
||||
state: ProvisioningPrepareState;
|
||||
message: string | null;
|
||||
warnings: string[];
|
||||
checks: ProvisioningProviderCheck[];
|
||||
}): { state: ProvisioningPrepareState; message: string | null } {
|
||||
if (params.state !== 'loading') {
|
||||
return {
|
||||
state: params.state,
|
||||
message: params.message,
|
||||
};
|
||||
}
|
||||
|
||||
if (params.checks.length === 0) {
|
||||
return {
|
||||
state: params.state,
|
||||
message: params.message,
|
||||
};
|
||||
}
|
||||
|
||||
const hasPendingChecks = params.checks.some(
|
||||
(check) => check.status === 'pending' || check.status === 'checking'
|
||||
);
|
||||
if (hasPendingChecks) {
|
||||
if (hasCompatibilityPendingDetails(params.checks)) {
|
||||
return {
|
||||
state: params.state,
|
||||
message:
|
||||
'Deep verification is still running. OpenCode free models may take around 20 seconds.',
|
||||
};
|
||||
}
|
||||
return {
|
||||
state: params.state,
|
||||
message: params.message,
|
||||
};
|
||||
}
|
||||
|
||||
if (params.checks.some((check) => check.status === 'failed')) {
|
||||
return {
|
||||
state: 'failed',
|
||||
message:
|
||||
getPrimaryProvisioningFailureDetail(params.checks) ??
|
||||
params.message ??
|
||||
'Some selected providers need attention.',
|
||||
};
|
||||
}
|
||||
|
||||
const hasNotes =
|
||||
params.warnings.length > 0 || params.checks.some((check) => check.status === 'notes');
|
||||
|
||||
return {
|
||||
state: 'ready',
|
||||
message: hasNotes
|
||||
? 'Selected providers are ready with notes.'
|
||||
: 'Selected providers are ready.',
|
||||
};
|
||||
}
|
||||
|
||||
export function shouldHideProvisioningProviderStatusList(
|
||||
checks: ProvisioningProviderCheck[],
|
||||
message: string | null | undefined
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@ import {
|
|||
} from '@renderer/utils/geminiUiFreeze';
|
||||
import {
|
||||
getAvailableTeamProviderModelOptions,
|
||||
isTeamProviderModelVerificationPending,
|
||||
getTeamModelUiDisabledReason,
|
||||
normalizeTeamModelForUi,
|
||||
TEAM_MODEL_UI_DISABLED_BADGE_LABEL,
|
||||
|
|
@ -259,8 +260,8 @@ export const TeamModelSelector: React.FC<TeamModelSelectorProps> = ({
|
|||
};
|
||||
const shouldAwaitRuntimeModelList =
|
||||
effectiveProviderId !== 'anthropic' &&
|
||||
(effectiveCliStatus == null || effectiveCliStatusLoading) &&
|
||||
runtimeProviderStatus == null;
|
||||
(runtimeProviderStatus == null ||
|
||||
isTeamProviderModelVerificationPending(effectiveProviderId, runtimeProviderStatus));
|
||||
const normalizedValue = normalizeTeamModelForUi(
|
||||
effectiveProviderId,
|
||||
value,
|
||||
|
|
|
|||
|
|
@ -5,16 +5,19 @@ export function buildProviderPrepareModelCacheKey({
|
|||
providerId,
|
||||
backendSummary,
|
||||
limitContext,
|
||||
runtimeStatusSignature,
|
||||
}: {
|
||||
cwd: string;
|
||||
providerId: TeamProviderId;
|
||||
backendSummary: string | null | undefined;
|
||||
limitContext: boolean;
|
||||
runtimeStatusSignature?: string | null | undefined;
|
||||
}): string {
|
||||
return [
|
||||
cwd,
|
||||
providerId,
|
||||
backendSummary ?? '',
|
||||
limitContext ? 'limit-context:on' : 'limit-context:off',
|
||||
runtimeStatusSignature ?? '',
|
||||
].join('::');
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,11 @@
|
|||
import { getProviderScopedTeamModelLabel } from '@renderer/utils/teamModelCatalog';
|
||||
import { isDefaultProviderModelSelection } from '@shared/utils/providerModelSelection';
|
||||
|
||||
import type { TeamProviderId, TeamProvisioningPrepareResult } from '@shared/types';
|
||||
import type {
|
||||
TeamProviderId,
|
||||
TeamProvisioningModelVerificationMode,
|
||||
TeamProvisioningPrepareResult,
|
||||
} from '@shared/types';
|
||||
|
||||
export type ProviderPrepareCheckStatus = 'ready' | 'notes' | 'failed';
|
||||
|
||||
|
|
@ -10,10 +14,12 @@ type PrepareProvisioningFn = (
|
|||
providerId?: TeamProviderId,
|
||||
providerIds?: TeamProviderId[],
|
||||
selectedModels?: string[],
|
||||
limitContext?: boolean
|
||||
limitContext?: boolean,
|
||||
modelVerificationMode?: TeamProvisioningModelVerificationMode
|
||||
) => Promise<TeamProvisioningPrepareResult>;
|
||||
|
||||
interface ProviderPrepareDiagnosticsProgress {
|
||||
status: ProviderPrepareCheckStatus | 'checking';
|
||||
details: string[];
|
||||
completedCount: number;
|
||||
totalCount: number;
|
||||
|
|
@ -69,6 +75,10 @@ function buildModelSuccessLine(providerId: TeamProviderId, modelId: string): str
|
|||
return `${getModelLabel(providerId, modelId)} - verified`;
|
||||
}
|
||||
|
||||
function buildModelCompatibilityPendingLine(providerId: TeamProviderId, modelId: string): string {
|
||||
return `${getModelLabel(providerId, modelId)} - compatible, deep verification pending...`;
|
||||
}
|
||||
|
||||
export function getProviderPrepareCachedSnapshot({
|
||||
providerId,
|
||||
selectedModelIds,
|
||||
|
|
@ -210,6 +220,38 @@ function getModelScopedEntries(modelId: string, result: TeamProvisioningPrepareR
|
|||
.filter((entry) => scopedPattern.test(entry));
|
||||
}
|
||||
|
||||
function isModelScopedEntryForAnyModel(modelIds: readonly string[], entry: string): boolean {
|
||||
const trimmed = entry.trim();
|
||||
if (!trimmed) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return modelIds.some((modelId) =>
|
||||
new RegExp(`^Selected model ${escapeRegExp(modelId)}\\b`, 'i').test(trimmed)
|
||||
);
|
||||
}
|
||||
|
||||
function looksLikeSingleModelBatchFailure(
|
||||
modelId: string,
|
||||
result: TeamProvisioningPrepareResult
|
||||
): boolean {
|
||||
const candidates = [...(result.details ?? []), ...(result.warnings ?? []), result.message]
|
||||
.map((entry) => entry?.trim() ?? '')
|
||||
.filter(Boolean);
|
||||
const modelLower = modelId.toLowerCase();
|
||||
|
||||
return candidates.some((candidate) => {
|
||||
const lower = candidate.toLowerCase();
|
||||
return (
|
||||
lower.includes(modelLower) ||
|
||||
lower.includes('requested model') ||
|
||||
lower.includes('model is not supported') ||
|
||||
lower.includes('model is not available') ||
|
||||
lower.includes('selected model')
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
function getScopedModelReason(modelId: string, entries: string[]): string | null {
|
||||
for (const entry of entries) {
|
||||
const stripped = stripSelectedModelPrefix(modelId, entry);
|
||||
|
|
@ -302,6 +344,27 @@ function suppressSupersededRuntimeWarnings(params: {
|
|||
};
|
||||
}
|
||||
|
||||
function getProgressStatus(params: {
|
||||
completedCount: number;
|
||||
totalCount: number;
|
||||
runtimeWarnings: string[];
|
||||
modelResultsById: Map<string, ProviderPrepareDiagnosticsModelResult>;
|
||||
}): ProviderPrepareCheckStatus | 'checking' {
|
||||
if (params.completedCount < params.totalCount) {
|
||||
return 'checking';
|
||||
}
|
||||
if (Array.from(params.modelResultsById.values()).some((result) => result.status === 'failed')) {
|
||||
return 'failed';
|
||||
}
|
||||
if (
|
||||
params.runtimeWarnings.length > 0 ||
|
||||
Array.from(params.modelResultsById.values()).some((result) => result.status === 'notes')
|
||||
) {
|
||||
return 'notes';
|
||||
}
|
||||
return 'ready';
|
||||
}
|
||||
|
||||
function resolveModelResultFromBatch(
|
||||
providerId: TeamProviderId,
|
||||
modelId: string,
|
||||
|
|
@ -378,6 +441,84 @@ function resolveModelResultFromBatch(
|
|||
};
|
||||
}
|
||||
|
||||
function resolveModelResultFromCompatibilityBatch(
|
||||
providerId: TeamProviderId,
|
||||
modelId: string,
|
||||
result: TeamProvisioningPrepareResult,
|
||||
isOnlyModel: boolean
|
||||
): { kind: 'compatible' } | { kind: 'terminal'; result: ProviderPrepareDiagnosticsModelResult } {
|
||||
const modelScopedEntries = getModelScopedEntries(modelId, result);
|
||||
const scopedReason = getScopedModelReason(modelId, modelScopedEntries);
|
||||
const fallbackBatchReason = isOnlyModel
|
||||
? (getResultReason(modelId, result) ?? normalizeModelReason(result.message))
|
||||
: null;
|
||||
|
||||
const hasCompatibilityLine = modelScopedEntries.some((entry) =>
|
||||
/selected model .* is compatible\. deep verification pending\./i.test(entry)
|
||||
);
|
||||
if (hasCompatibilityLine || (result.ready && modelScopedEntries.length === 0)) {
|
||||
return { kind: 'compatible' };
|
||||
}
|
||||
|
||||
const hasUnavailableLine = modelScopedEntries.some((entry) =>
|
||||
/selected model .* is unavailable\./i.test(entry)
|
||||
);
|
||||
if (hasUnavailableLine || (!result.ready && isOnlyModel)) {
|
||||
return {
|
||||
kind: 'terminal',
|
||||
result: {
|
||||
status: 'failed',
|
||||
line: buildModelFailureLine(
|
||||
providerId,
|
||||
modelId,
|
||||
'unavailable',
|
||||
scopedReason ?? fallbackBatchReason
|
||||
),
|
||||
warningLine: null,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const hasVerificationWarningLine = modelScopedEntries.some((entry) =>
|
||||
/selected model .* could not be verified\./i.test(entry)
|
||||
);
|
||||
if (hasVerificationWarningLine) {
|
||||
const line = buildModelFailureLine(
|
||||
providerId,
|
||||
modelId,
|
||||
'check failed',
|
||||
scopedReason ?? fallbackBatchReason
|
||||
);
|
||||
return {
|
||||
kind: 'terminal',
|
||||
result: {
|
||||
status: 'notes',
|
||||
line,
|
||||
warningLine: line,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
kind: 'terminal',
|
||||
result: {
|
||||
status: 'notes',
|
||||
line: buildModelFailureLine(
|
||||
providerId,
|
||||
modelId,
|
||||
'check failed',
|
||||
scopedReason ?? fallbackBatchReason ?? 'Model verification failed'
|
||||
),
|
||||
warningLine: buildModelFailureLine(
|
||||
providerId,
|
||||
modelId,
|
||||
'check failed',
|
||||
scopedReason ?? fallbackBatchReason ?? 'Model verification failed'
|
||||
),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export async function runProviderPrepareDiagnostics({
|
||||
cwd,
|
||||
providerId,
|
||||
|
|
@ -395,26 +536,26 @@ export async function runProviderPrepareDiagnostics({
|
|||
onModelProgress?: (progress: ProviderPrepareDiagnosticsProgress) => void;
|
||||
cachedModelResultsById?: Record<string, ProviderPrepareDiagnosticsModelResult>;
|
||||
}): Promise<ProviderPrepareDiagnosticsResult> {
|
||||
const runtimeResult = await prepareProvisioning(
|
||||
cwd,
|
||||
providerId,
|
||||
[providerId],
|
||||
undefined,
|
||||
limitContext
|
||||
);
|
||||
const runtimeDetailLines = createRuntimeDetailLines(runtimeResult);
|
||||
const runtimeWarnings = [...(runtimeResult.warnings ?? [])];
|
||||
|
||||
if (!runtimeResult.ready) {
|
||||
return {
|
||||
status: 'failed',
|
||||
details: [...runtimeDetailLines, ...(runtimeResult.message ? [runtimeResult.message] : [])],
|
||||
warnings: runtimeWarnings,
|
||||
modelResultsById: {},
|
||||
};
|
||||
}
|
||||
|
||||
if (selectedModelIds.length === 0) {
|
||||
const runtimeResult = await prepareProvisioning(
|
||||
cwd,
|
||||
providerId,
|
||||
[providerId],
|
||||
undefined,
|
||||
limitContext
|
||||
);
|
||||
const runtimeDetailLines = createRuntimeDetailLines(runtimeResult);
|
||||
const runtimeWarnings = [...(runtimeResult.warnings ?? [])];
|
||||
|
||||
if (!runtimeResult.ready) {
|
||||
return {
|
||||
status: 'failed',
|
||||
details: [...runtimeDetailLines, ...(runtimeResult.message ? [runtimeResult.message] : [])],
|
||||
warnings: runtimeWarnings,
|
||||
modelResultsById: {},
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
status: runtimeWarnings.length > 0 ? 'notes' : 'ready',
|
||||
details: runtimeDetailLines,
|
||||
|
|
@ -429,6 +570,8 @@ export async function runProviderPrepareDiagnostics({
|
|||
const reusableModelResultsById = cachedModelResultsById ?? {};
|
||||
const modelResultsById = new Map<string, ProviderPrepareDiagnosticsModelResult>();
|
||||
const modelLines = new Map<string, string>();
|
||||
let runtimeDetailLines: string[] = [];
|
||||
let runtimeWarnings: string[] = [];
|
||||
let completedCount = 0;
|
||||
let hasFailure = false;
|
||||
let hasNotes = false;
|
||||
|
|
@ -454,9 +597,20 @@ export async function runProviderPrepareDiagnostics({
|
|||
}
|
||||
|
||||
const emitProgress = (): void => {
|
||||
const filteredRuntime = suppressSupersededRuntimeWarnings({
|
||||
runtimeDetailLines,
|
||||
runtimeWarnings,
|
||||
modelResultsById,
|
||||
});
|
||||
onModelProgress?.({
|
||||
status: getProgressStatus({
|
||||
completedCount,
|
||||
totalCount: orderedModelIds.length,
|
||||
runtimeWarnings: filteredRuntime.runtimeWarnings,
|
||||
modelResultsById,
|
||||
}),
|
||||
details: [
|
||||
...runtimeDetailLines,
|
||||
...filteredRuntime.runtimeDetailLines,
|
||||
...orderedModelIds.map((modelId) => modelLines.get(modelId) ?? ''),
|
||||
],
|
||||
completedCount,
|
||||
|
|
@ -467,52 +621,297 @@ export async function runProviderPrepareDiagnostics({
|
|||
emitProgress();
|
||||
|
||||
const uncachedModelIds = orderedModelIds.filter((modelId) => !modelResultsById.has(modelId));
|
||||
if (uncachedModelIds.length > 0) {
|
||||
try {
|
||||
const batchedModelResult = await prepareProvisioning(
|
||||
cwd,
|
||||
providerId,
|
||||
[providerId],
|
||||
uncachedModelIds,
|
||||
limitContext
|
||||
);
|
||||
if (uncachedModelIds.length === 0) {
|
||||
const runtimeResult = await prepareProvisioning(
|
||||
cwd,
|
||||
providerId,
|
||||
[providerId],
|
||||
undefined,
|
||||
limitContext
|
||||
);
|
||||
runtimeDetailLines = createRuntimeDetailLines(runtimeResult);
|
||||
runtimeWarnings = [...(runtimeResult.warnings ?? [])];
|
||||
|
||||
for (const modelId of uncachedModelIds) {
|
||||
const resolvedResult = resolveModelResultFromBatch(
|
||||
if (!runtimeResult.ready) {
|
||||
return {
|
||||
status: 'failed',
|
||||
details: [...runtimeDetailLines, ...(runtimeResult.message ? [runtimeResult.message] : [])],
|
||||
warnings: runtimeWarnings,
|
||||
modelResultsById: {},
|
||||
};
|
||||
}
|
||||
} else {
|
||||
const recordTerminalModelResult = (
|
||||
modelId: string,
|
||||
resolvedResult: ProviderPrepareDiagnosticsModelResult
|
||||
): void => {
|
||||
modelLines.set(modelId, resolvedResult.line);
|
||||
modelResultsById.set(modelId, resolvedResult);
|
||||
completedCount += 1;
|
||||
if (resolvedResult.status === 'failed') {
|
||||
hasFailure = true;
|
||||
} else if (resolvedResult.status === 'notes') {
|
||||
hasNotes = true;
|
||||
}
|
||||
if (resolvedResult.warningLine) {
|
||||
modelWarnings.push(resolvedResult.warningLine);
|
||||
}
|
||||
};
|
||||
|
||||
if (providerId === 'opencode') {
|
||||
const compatibilityPassedModelIds: string[] = [];
|
||||
try {
|
||||
const compatibilityResult = await prepareProvisioning(
|
||||
cwd,
|
||||
providerId,
|
||||
modelId,
|
||||
batchedModelResult,
|
||||
uncachedModelIds.length === 1
|
||||
[providerId],
|
||||
uncachedModelIds,
|
||||
limitContext,
|
||||
'compatibility'
|
||||
);
|
||||
modelLines.set(modelId, resolvedResult.line);
|
||||
modelResultsById.set(modelId, resolvedResult);
|
||||
if (resolvedResult.status === 'failed') {
|
||||
hasFailure = true;
|
||||
} else if (resolvedResult.status === 'notes') {
|
||||
hasNotes = true;
|
||||
runtimeDetailLines = createRuntimeDetailLines(compatibilityResult).filter(
|
||||
(entry) => !isModelScopedEntryForAnyModel(uncachedModelIds, entry)
|
||||
);
|
||||
runtimeWarnings = [...(compatibilityResult.warnings ?? [])].filter(
|
||||
(entry) => !isModelScopedEntryForAnyModel(uncachedModelIds, entry)
|
||||
);
|
||||
|
||||
const hasModelScopedEntries = uncachedModelIds.some(
|
||||
(modelId) => getModelScopedEntries(modelId, compatibilityResult).length > 0
|
||||
);
|
||||
const hasNonModelScopedDiagnostics =
|
||||
runtimeDetailLines.length > 0 || runtimeWarnings.length > 0;
|
||||
const hasSingleModelFallbackReason =
|
||||
uncachedModelIds.length === 1 &&
|
||||
looksLikeSingleModelBatchFailure(uncachedModelIds[0], compatibilityResult);
|
||||
if (
|
||||
!compatibilityResult.ready &&
|
||||
!hasModelScopedEntries &&
|
||||
(uncachedModelIds.length > 1 ||
|
||||
(!hasNonModelScopedDiagnostics && !hasSingleModelFallbackReason))
|
||||
) {
|
||||
return {
|
||||
status: 'failed',
|
||||
details: [
|
||||
...runtimeDetailLines,
|
||||
...(compatibilityResult.message ? [compatibilityResult.message] : []),
|
||||
],
|
||||
warnings: runtimeWarnings,
|
||||
modelResultsById: {},
|
||||
};
|
||||
}
|
||||
if (resolvedResult.warningLine) {
|
||||
modelWarnings.push(resolvedResult.warningLine);
|
||||
if (!hasModelScopedEntries && uncachedModelIds.length === 1) {
|
||||
runtimeDetailLines = [];
|
||||
runtimeWarnings = [];
|
||||
}
|
||||
|
||||
for (const modelId of uncachedModelIds) {
|
||||
const compatibilityResolution = resolveModelResultFromCompatibilityBatch(
|
||||
providerId,
|
||||
modelId,
|
||||
compatibilityResult,
|
||||
uncachedModelIds.length === 1
|
||||
);
|
||||
if (compatibilityResolution.kind === 'compatible') {
|
||||
modelLines.set(modelId, buildModelCompatibilityPendingLine(providerId, modelId));
|
||||
compatibilityPassedModelIds.push(modelId);
|
||||
continue;
|
||||
}
|
||||
recordTerminalModelResult(modelId, compatibilityResolution.result);
|
||||
}
|
||||
} catch (error) {
|
||||
hasNotes = true;
|
||||
const reason = normalizeModelReason(
|
||||
error instanceof Error ? error.message.trim() : String(error).trim()
|
||||
);
|
||||
for (const modelId of uncachedModelIds) {
|
||||
const line = buildModelFailureLine(providerId, modelId, 'check failed', reason || null);
|
||||
recordTerminalModelResult(modelId, {
|
||||
status: 'notes',
|
||||
line,
|
||||
warningLine: line,
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
hasNotes = true;
|
||||
const reason = normalizeModelReason(
|
||||
error instanceof Error ? error.message.trim() : String(error).trim()
|
||||
);
|
||||
for (const modelId of uncachedModelIds) {
|
||||
const line = buildModelFailureLine(providerId, modelId, 'check failed', reason || null);
|
||||
modelLines.set(modelId, line);
|
||||
modelWarnings.push(line);
|
||||
modelResultsById.set(modelId, {
|
||||
status: 'notes',
|
||||
line,
|
||||
warningLine: line,
|
||||
});
|
||||
}
|
||||
} finally {
|
||||
completedCount += uncachedModelIds.length;
|
||||
|
||||
emitProgress();
|
||||
|
||||
if (compatibilityPassedModelIds.length === 0) {
|
||||
const filteredRuntime = suppressSupersededRuntimeWarnings({
|
||||
runtimeDetailLines,
|
||||
runtimeWarnings,
|
||||
modelResultsById,
|
||||
});
|
||||
const dedupedWarnings = Array.from(
|
||||
new Set([...filteredRuntime.runtimeWarnings, ...modelWarnings])
|
||||
);
|
||||
const selectedModelResultsById = Object.fromEntries(
|
||||
orderedModelIds
|
||||
.map((modelId) => [modelId, modelResultsById.get(modelId)] as const)
|
||||
.filter((entry): entry is [string, ProviderPrepareDiagnosticsModelResult] =>
|
||||
Boolean(entry[1])
|
||||
)
|
||||
);
|
||||
|
||||
return {
|
||||
status: hasFailure
|
||||
? 'failed'
|
||||
: hasNotes || dedupedWarnings.length > 0
|
||||
? 'notes'
|
||||
: 'ready',
|
||||
details: [
|
||||
...filteredRuntime.runtimeDetailLines,
|
||||
...orderedModelIds.map((modelId) => modelLines.get(modelId) ?? ''),
|
||||
],
|
||||
warnings: dedupedWarnings,
|
||||
modelResultsById: selectedModelResultsById,
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
const batchedModelResult = await prepareProvisioning(
|
||||
cwd,
|
||||
providerId,
|
||||
[providerId],
|
||||
compatibilityPassedModelIds,
|
||||
limitContext,
|
||||
'deep'
|
||||
);
|
||||
runtimeDetailLines = createRuntimeDetailLines(batchedModelResult).filter(
|
||||
(entry) => !isModelScopedEntryForAnyModel(compatibilityPassedModelIds, entry)
|
||||
);
|
||||
runtimeWarnings = [...(batchedModelResult.warnings ?? [])].filter(
|
||||
(entry) => !isModelScopedEntryForAnyModel(compatibilityPassedModelIds, entry)
|
||||
);
|
||||
|
||||
const hasModelScopedEntries = compatibilityPassedModelIds.some(
|
||||
(modelId) => getModelScopedEntries(modelId, batchedModelResult).length > 0
|
||||
);
|
||||
const hasNonModelScopedDiagnostics =
|
||||
runtimeDetailLines.length > 0 || runtimeWarnings.length > 0;
|
||||
const hasSingleModelFallbackReason =
|
||||
compatibilityPassedModelIds.length === 1 &&
|
||||
looksLikeSingleModelBatchFailure(compatibilityPassedModelIds[0], batchedModelResult);
|
||||
if (
|
||||
!batchedModelResult.ready &&
|
||||
!hasModelScopedEntries &&
|
||||
(compatibilityPassedModelIds.length > 1 ||
|
||||
(!hasNonModelScopedDiagnostics && !hasSingleModelFallbackReason))
|
||||
) {
|
||||
return {
|
||||
status: 'failed',
|
||||
details: [
|
||||
...runtimeDetailLines,
|
||||
...(batchedModelResult.message ? [batchedModelResult.message] : []),
|
||||
],
|
||||
warnings: runtimeWarnings,
|
||||
modelResultsById: {},
|
||||
};
|
||||
}
|
||||
if (!hasModelScopedEntries && compatibilityPassedModelIds.length === 1) {
|
||||
runtimeDetailLines = [];
|
||||
runtimeWarnings = [];
|
||||
}
|
||||
|
||||
for (const modelId of compatibilityPassedModelIds) {
|
||||
recordTerminalModelResult(
|
||||
modelId,
|
||||
resolveModelResultFromBatch(
|
||||
providerId,
|
||||
modelId,
|
||||
batchedModelResult,
|
||||
compatibilityPassedModelIds.length === 1
|
||||
)
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
hasNotes = true;
|
||||
const reason = normalizeModelReason(
|
||||
error instanceof Error ? error.message.trim() : String(error).trim()
|
||||
);
|
||||
for (const modelId of compatibilityPassedModelIds) {
|
||||
const line = buildModelFailureLine(providerId, modelId, 'check failed', reason || null);
|
||||
recordTerminalModelResult(modelId, {
|
||||
status: 'notes',
|
||||
line,
|
||||
warningLine: line,
|
||||
});
|
||||
}
|
||||
} finally {
|
||||
emitProgress();
|
||||
}
|
||||
} else {
|
||||
try {
|
||||
const batchedModelResult = await prepareProvisioning(
|
||||
cwd,
|
||||
providerId,
|
||||
[providerId],
|
||||
uncachedModelIds,
|
||||
limitContext
|
||||
);
|
||||
runtimeDetailLines = createRuntimeDetailLines(batchedModelResult).filter(
|
||||
(entry) => !isModelScopedEntryForAnyModel(uncachedModelIds, entry)
|
||||
);
|
||||
runtimeWarnings = [...(batchedModelResult.warnings ?? [])].filter(
|
||||
(entry) => !isModelScopedEntryForAnyModel(uncachedModelIds, entry)
|
||||
);
|
||||
|
||||
const hasModelScopedEntries = uncachedModelIds.some(
|
||||
(modelId) => getModelScopedEntries(modelId, batchedModelResult).length > 0
|
||||
);
|
||||
const hasNonModelScopedDiagnostics =
|
||||
runtimeDetailLines.length > 0 || runtimeWarnings.length > 0;
|
||||
const hasSingleModelFallbackReason =
|
||||
uncachedModelIds.length === 1 &&
|
||||
looksLikeSingleModelBatchFailure(uncachedModelIds[0], batchedModelResult);
|
||||
if (
|
||||
!batchedModelResult.ready &&
|
||||
!hasModelScopedEntries &&
|
||||
(uncachedModelIds.length > 1 ||
|
||||
(!hasNonModelScopedDiagnostics && !hasSingleModelFallbackReason))
|
||||
) {
|
||||
return {
|
||||
status: 'failed',
|
||||
details: [
|
||||
...runtimeDetailLines,
|
||||
...(batchedModelResult.message ? [batchedModelResult.message] : []),
|
||||
],
|
||||
warnings: runtimeWarnings,
|
||||
modelResultsById: {},
|
||||
};
|
||||
}
|
||||
if (!hasModelScopedEntries && uncachedModelIds.length === 1) {
|
||||
runtimeDetailLines = [];
|
||||
runtimeWarnings = [];
|
||||
}
|
||||
|
||||
for (const modelId of uncachedModelIds) {
|
||||
recordTerminalModelResult(
|
||||
modelId,
|
||||
resolveModelResultFromBatch(
|
||||
providerId,
|
||||
modelId,
|
||||
batchedModelResult,
|
||||
uncachedModelIds.length === 1
|
||||
)
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
hasNotes = true;
|
||||
const reason = normalizeModelReason(
|
||||
error instanceof Error ? error.message.trim() : String(error).trim()
|
||||
);
|
||||
for (const modelId of uncachedModelIds) {
|
||||
const line = buildModelFailureLine(providerId, modelId, 'check failed', reason || null);
|
||||
recordTerminalModelResult(modelId, {
|
||||
status: 'notes',
|
||||
line,
|
||||
warningLine: line,
|
||||
});
|
||||
}
|
||||
} finally {
|
||||
emitProgress();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,122 @@
|
|||
import type { MemberDraft } from '@renderer/components/team/members/membersEditorTypes';
|
||||
import type { CliProviderStatus, TeamProviderId } from '@shared/types';
|
||||
|
||||
type RuntimeProviderStatusById = ReadonlyMap<TeamProviderId, CliProviderStatus | null | undefined>;
|
||||
type SelectedModelChecksByProvider = ReadonlyMap<TeamProviderId, readonly string[]>;
|
||||
|
||||
function normalizeModelIds(modelIds: readonly string[] | null | undefined): string[] {
|
||||
return Array.from(
|
||||
new Set((modelIds ?? []).map((modelId) => modelId.trim()).filter(Boolean))
|
||||
).sort();
|
||||
}
|
||||
|
||||
export function buildProviderPrepareMembersSignature(members: readonly MemberDraft[]): string {
|
||||
return JSON.stringify(
|
||||
members.map((member) => ({
|
||||
id: member.id,
|
||||
providerId: member.providerId ?? null,
|
||||
model: member.model?.trim() || null,
|
||||
effort: member.effort ?? null,
|
||||
removed: Boolean(member.removedAt),
|
||||
}))
|
||||
);
|
||||
}
|
||||
|
||||
export function buildProviderPrepareModelChecksSignature(
|
||||
modelChecksByProvider: SelectedModelChecksByProvider
|
||||
): string {
|
||||
return JSON.stringify(
|
||||
Array.from(modelChecksByProvider.entries())
|
||||
.map(([providerId, modelIds]) => ({
|
||||
providerId,
|
||||
modelIds: normalizeModelIds(modelIds),
|
||||
}))
|
||||
.sort((left, right) => left.providerId.localeCompare(right.providerId))
|
||||
);
|
||||
}
|
||||
|
||||
export function buildProviderPrepareRuntimeStatusSignature(
|
||||
providerIds: readonly TeamProviderId[],
|
||||
runtimeProviderStatusById: RuntimeProviderStatusById
|
||||
): string {
|
||||
return JSON.stringify(
|
||||
Array.from(new Set(providerIds))
|
||||
.sort()
|
||||
.map((providerId) => {
|
||||
const provider = runtimeProviderStatusById.get(providerId) ?? null;
|
||||
return {
|
||||
providerId,
|
||||
supported: provider?.supported ?? null,
|
||||
authenticated: provider?.authenticated ?? null,
|
||||
authMethod: provider?.authMethod ?? null,
|
||||
selectedBackendId: provider?.selectedBackendId ?? null,
|
||||
resolvedBackendId: provider?.resolvedBackendId ?? null,
|
||||
models: normalizeModelIds(provider?.models),
|
||||
modelCatalogSource: provider?.modelCatalog?.source ?? null,
|
||||
modelCatalogStatus: provider?.modelCatalog?.status ?? null,
|
||||
modelCatalogModels: normalizeModelIds(
|
||||
provider?.modelCatalog?.models?.map((model) => model.id)
|
||||
),
|
||||
connection: provider?.connection
|
||||
? {
|
||||
supportsOAuth: provider.connection.supportsOAuth,
|
||||
supportsApiKey: provider.connection.supportsApiKey,
|
||||
configuredAuthMode: provider.connection.configuredAuthMode ?? null,
|
||||
apiKeyConfigured: provider.connection.apiKeyConfigured,
|
||||
apiKeySource: provider.connection.apiKeySource ?? null,
|
||||
codex: provider.connection.codex
|
||||
? {
|
||||
preferredAuthMode: provider.connection.codex.preferredAuthMode,
|
||||
effectiveAuthMode: provider.connection.codex.effectiveAuthMode,
|
||||
appServerState: provider.connection.codex.appServerState,
|
||||
managedAccountType: provider.connection.codex.managedAccount?.type ?? null,
|
||||
managedAccountEmail: provider.connection.codex.managedAccount?.email ?? null,
|
||||
requiresOpenaiAuth: provider.connection.codex.requiresOpenaiAuth ?? null,
|
||||
localAccountArtifactsPresent:
|
||||
provider.connection.codex.localAccountArtifactsPresent ?? null,
|
||||
localActiveChatgptAccountPresent:
|
||||
provider.connection.codex.localActiveChatgptAccountPresent ?? null,
|
||||
loginStatus: provider.connection.codex.login?.status ?? null,
|
||||
launchAllowed: provider.connection.codex.launchAllowed,
|
||||
launchIssueMessage: provider.connection.codex.launchIssueMessage ?? null,
|
||||
launchReadinessState: provider.connection.codex.launchReadinessState,
|
||||
}
|
||||
: null,
|
||||
}
|
||||
: null,
|
||||
availableBackends: (provider?.availableBackends ?? [])
|
||||
.map((backend) => ({
|
||||
id: backend.id,
|
||||
available: backend.available,
|
||||
selectable: backend.selectable,
|
||||
state: backend.state ?? null,
|
||||
recommended: backend.recommended,
|
||||
audience: backend.audience ?? null,
|
||||
}))
|
||||
.sort((left, right) => left.id.localeCompare(right.id)),
|
||||
};
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
export function buildProviderPrepareRequestSignature(input: {
|
||||
cwd: string;
|
||||
selectedProviderId: TeamProviderId;
|
||||
selectedModel: string;
|
||||
selectedMemberProviders: readonly TeamProviderId[];
|
||||
limitContext?: boolean;
|
||||
runtimeStatusSignature: string;
|
||||
membersSignature?: string;
|
||||
modelChecksSignature?: string;
|
||||
}): string {
|
||||
return JSON.stringify({
|
||||
cwd: input.cwd,
|
||||
selectedProviderId: input.selectedProviderId,
|
||||
selectedModel: input.selectedModel.trim(),
|
||||
selectedMemberProviders: Array.from(new Set(input.selectedMemberProviders)).sort(),
|
||||
limitContext: Boolean(input.limitContext),
|
||||
runtimeStatusSignature: input.runtimeStatusSignature,
|
||||
membersSignature: input.membersSignature ?? null,
|
||||
modelChecksSignature: input.modelChecksSignature ?? null,
|
||||
});
|
||||
}
|
||||
|
|
@ -0,0 +1,77 @@
|
|||
import type { TeamProviderId } from '@shared/types';
|
||||
|
||||
import type { ProviderPrepareDiagnosticsModelResult } from './providerPrepareDiagnostics';
|
||||
|
||||
const OPENCODE_DEEP_VERIFY_SUCCESS_CACHE_TTL_MS = 45_000;
|
||||
|
||||
type ShortLivedProviderPrepareCacheEntry = {
|
||||
expiresAt: number;
|
||||
modelResultsById: Record<string, ProviderPrepareDiagnosticsModelResult>;
|
||||
};
|
||||
|
||||
const shortLivedProviderPrepareCache = new Map<string, ShortLivedProviderPrepareCacheEntry>();
|
||||
|
||||
function pruneExpiredEntries(now: number): void {
|
||||
for (const [cacheKey, entry] of shortLivedProviderPrepareCache.entries()) {
|
||||
if (entry.expiresAt <= now) {
|
||||
shortLivedProviderPrepareCache.delete(cacheKey);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function getShortLivedProviderPrepareModelResults({
|
||||
providerId,
|
||||
cacheKey,
|
||||
}: {
|
||||
providerId: TeamProviderId;
|
||||
cacheKey: string;
|
||||
}): Record<string, ProviderPrepareDiagnosticsModelResult> {
|
||||
if (providerId !== 'opencode') {
|
||||
return {};
|
||||
}
|
||||
|
||||
const now = Date.now();
|
||||
pruneExpiredEntries(now);
|
||||
const entry = shortLivedProviderPrepareCache.get(cacheKey);
|
||||
if (!entry) {
|
||||
return {};
|
||||
}
|
||||
|
||||
return { ...entry.modelResultsById };
|
||||
}
|
||||
|
||||
export function storeShortLivedProviderPrepareModelResults({
|
||||
providerId,
|
||||
cacheKey,
|
||||
modelResultsById,
|
||||
}: {
|
||||
providerId: TeamProviderId;
|
||||
cacheKey: string;
|
||||
modelResultsById: Record<string, ProviderPrepareDiagnosticsModelResult>;
|
||||
}): void {
|
||||
if (providerId !== 'opencode') {
|
||||
return;
|
||||
}
|
||||
|
||||
const readyResultsById = Object.fromEntries(
|
||||
Object.entries(modelResultsById).filter(([, result]) => result.status === 'ready')
|
||||
);
|
||||
if (Object.keys(readyResultsById).length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const now = Date.now();
|
||||
pruneExpiredEntries(now);
|
||||
const existingEntry = shortLivedProviderPrepareCache.get(cacheKey);
|
||||
shortLivedProviderPrepareCache.set(cacheKey, {
|
||||
expiresAt: now + OPENCODE_DEEP_VERIFY_SUCCESS_CACHE_TTL_MS,
|
||||
modelResultsById: {
|
||||
...(existingEntry?.modelResultsById ?? {}),
|
||||
...readyResultsById,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function __resetShortLivedProviderPrepareCacheForTests(): void {
|
||||
shortLivedProviderPrepareCache.clear();
|
||||
}
|
||||
|
|
@ -0,0 +1,10 @@
|
|||
import { isTeamProviderId } from '@shared/utils/teamProvider';
|
||||
|
||||
import type { MemberDraft } from '@renderer/components/team/members/membersEditorTypes';
|
||||
import type { TeamProviderId } from '@shared/types';
|
||||
|
||||
export function collectActiveMemberProviderIds(members: readonly MemberDraft[]): TeamProviderId[] {
|
||||
return members.flatMap((member) =>
|
||||
!member.removedAt && isTeamProviderId(member.providerId) ? [member.providerId] : []
|
||||
);
|
||||
}
|
||||
20
src/renderer/services/dashboardCliStatusBannerPreference.ts
Normal file
20
src/renderer/services/dashboardCliStatusBannerPreference.ts
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
const DASHBOARD_CLI_STATUS_BANNER_COLLAPSED_KEY = 'dashboard:cli-status-banner-collapsed';
|
||||
|
||||
export function loadDashboardCliStatusBannerCollapsed(): boolean {
|
||||
try {
|
||||
return window.localStorage.getItem(DASHBOARD_CLI_STATUS_BANNER_COLLAPSED_KEY) === 'true';
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function saveDashboardCliStatusBannerCollapsed(collapsed: boolean): void {
|
||||
try {
|
||||
window.localStorage.setItem(
|
||||
DASHBOARD_CLI_STATUS_BANNER_COLLAPSED_KEY,
|
||||
collapsed ? 'true' : 'false'
|
||||
);
|
||||
} catch {
|
||||
// Ignore storage failures and keep the dashboard responsive.
|
||||
}
|
||||
}
|
||||
|
|
@ -172,6 +172,74 @@ export function mergeCliStatusPreservingHydratedProviders(
|
|||
};
|
||||
}
|
||||
|
||||
function isMultimodelCliStatus(
|
||||
status: CliInstallationStatus | null | undefined
|
||||
): status is CliInstallationStatus & { flavor: 'agent_teams_orchestrator' } {
|
||||
return status?.flavor === 'agent_teams_orchestrator';
|
||||
}
|
||||
|
||||
function hasActiveProviderStatusLoading(
|
||||
providerLoading: Partial<Record<CliProviderId, boolean>>
|
||||
): boolean {
|
||||
return Object.values(providerLoading).some((loading) => loading === true);
|
||||
}
|
||||
|
||||
function getAuthenticatedProvider(providers: CliProviderStatus[]): CliProviderStatus | null {
|
||||
return providers.find((provider) => provider.authenticated) ?? null;
|
||||
}
|
||||
|
||||
function buildMultimodelCliAuthState(params: {
|
||||
status: CliInstallationStatus;
|
||||
providers?: CliProviderStatus[];
|
||||
providerLoading?: Partial<Record<CliProviderId, boolean>>;
|
||||
}): Pick<CliInstallationStatus, 'authLoggedIn' | 'authMethod' | 'authStatusChecking'> {
|
||||
const providers = params.providers ?? params.status.providers;
|
||||
const providerLoading = params.providerLoading ?? {};
|
||||
const authenticatedProvider = getAuthenticatedProvider(providers);
|
||||
|
||||
return {
|
||||
authLoggedIn: providers.some((provider) => provider.authenticated),
|
||||
authMethod: authenticatedProvider?.authMethod ?? null,
|
||||
authStatusChecking: params.status.installed && hasActiveProviderStatusLoading(providerLoading),
|
||||
};
|
||||
}
|
||||
|
||||
function getProviderDisplayName(providerId: CliProviderId): string {
|
||||
switch (providerId) {
|
||||
case 'anthropic':
|
||||
return 'Anthropic';
|
||||
case 'codex':
|
||||
return 'Codex';
|
||||
case 'gemini':
|
||||
return 'Gemini';
|
||||
case 'opencode':
|
||||
return 'OpenCode';
|
||||
}
|
||||
}
|
||||
|
||||
function createProviderStatusErrorSnapshot(params: {
|
||||
providerId: CliProviderId;
|
||||
message: string;
|
||||
currentProvider?: CliProviderStatus;
|
||||
}): CliProviderStatus {
|
||||
const currentProvider =
|
||||
params.currentProvider ??
|
||||
createLoadingMultimodelCliStatus().providers.find(
|
||||
(provider) => provider.providerId === params.providerId
|
||||
)!;
|
||||
|
||||
return {
|
||||
...currentProvider,
|
||||
providerId: params.providerId,
|
||||
displayName: currentProvider.displayName ?? getProviderDisplayName(params.providerId),
|
||||
authenticated: false,
|
||||
authMethod: null,
|
||||
verificationState: 'error',
|
||||
statusMessage: params.message,
|
||||
detailMessage: null,
|
||||
};
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Slice Interface
|
||||
// =============================================================================
|
||||
|
|
@ -297,12 +365,22 @@ export const createCliInstallerSlice: StateCreator<AppState, [], [], CliInstalle
|
|||
return {};
|
||||
}
|
||||
|
||||
const nextCliStatus = mergeCliStatusPreservingHydratedProviders(state.cliStatus, metadata);
|
||||
const nextAuthState = isMultimodelCliStatus(nextCliStatus)
|
||||
? buildMultimodelCliAuthState({
|
||||
status: nextCliStatus,
|
||||
providerLoading: nextProviderLoading,
|
||||
})
|
||||
: null;
|
||||
|
||||
return {
|
||||
cliStatus: {
|
||||
...mergeCliStatusPreservingHydratedProviders(state.cliStatus, metadata),
|
||||
launchError: metadata.launchError ?? null,
|
||||
authStatusChecking: metadata.installed && pendingProviderIds.length > 0,
|
||||
},
|
||||
cliStatus: nextAuthState
|
||||
? {
|
||||
...nextCliStatus,
|
||||
launchError: metadata.launchError ?? null,
|
||||
...nextAuthState,
|
||||
}
|
||||
: nextCliStatus,
|
||||
cliStatusLoading: false,
|
||||
cliProviderStatusLoading: nextProviderLoading,
|
||||
};
|
||||
|
|
@ -362,10 +440,21 @@ export const createCliInstallerSlice: StateCreator<AppState, [], [], CliInstalle
|
|||
if (epoch !== cliStatusEpoch) {
|
||||
return;
|
||||
}
|
||||
set((state) => ({
|
||||
cliStatus: mergeCliStatusPreservingHydratedProviders(state.cliStatus, status),
|
||||
cliProviderStatusLoading: {},
|
||||
}));
|
||||
set((state) => {
|
||||
const nextCliStatus = mergeCliStatusPreservingHydratedProviders(state.cliStatus, status);
|
||||
return {
|
||||
cliStatus: isMultimodelCliStatus(nextCliStatus)
|
||||
? {
|
||||
...nextCliStatus,
|
||||
...buildMultimodelCliAuthState({
|
||||
status: nextCliStatus,
|
||||
providerLoading: {},
|
||||
}),
|
||||
}
|
||||
: nextCliStatus,
|
||||
cliProviderStatusLoading: {},
|
||||
};
|
||||
});
|
||||
if (status.installed) {
|
||||
for (const provider of status.providers) {
|
||||
void get().fetchCliProviderStatus(provider.providerId, {
|
||||
|
|
@ -404,13 +493,27 @@ export const createCliInstallerSlice: StateCreator<AppState, [], [], CliInstalle
|
|||
|
||||
const request = (async () => {
|
||||
if (!silent) {
|
||||
set((state) => ({
|
||||
cliStatusError: null,
|
||||
cliProviderStatusLoading: {
|
||||
set((state) => {
|
||||
const nextLoading = {
|
||||
...state.cliProviderStatusLoading,
|
||||
[providerId]: true,
|
||||
},
|
||||
}));
|
||||
};
|
||||
|
||||
return {
|
||||
cliStatusError: null,
|
||||
cliProviderStatusLoading: nextLoading,
|
||||
cliStatus:
|
||||
state.cliStatus && isMultimodelCliStatus(state.cliStatus)
|
||||
? {
|
||||
...state.cliStatus,
|
||||
...buildMultimodelCliAuthState({
|
||||
status: state.cliStatus,
|
||||
providerLoading: nextLoading,
|
||||
}),
|
||||
}
|
||||
: state.cliStatus,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
|
|
@ -418,6 +521,7 @@ export const createCliInstallerSlice: StateCreator<AppState, [], [], CliInstalle
|
|||
? await api.cliInstaller.verifyProviderModels(providerId)
|
||||
: await api.cliInstaller.getProviderStatus(providerId);
|
||||
set((state) => {
|
||||
const currentCliStatus = state.cliStatus;
|
||||
const nextLoading = silent
|
||||
? state.cliProviderStatusLoading
|
||||
: {
|
||||
|
|
@ -432,28 +536,38 @@ export const createCliInstallerSlice: StateCreator<AppState, [], [], CliInstalle
|
|||
return { cliProviderStatusLoading: nextLoading };
|
||||
}
|
||||
|
||||
if (!providerStatus || !state.cliStatus) {
|
||||
if (!providerStatus || !currentCliStatus) {
|
||||
return { cliProviderStatusLoading: nextLoading };
|
||||
}
|
||||
|
||||
const hasProvider = state.cliStatus.providers.some(
|
||||
const settledCliStatus: CliInstallationStatus = currentCliStatus;
|
||||
const hasProvider = settledCliStatus.providers.some(
|
||||
(provider) => provider.providerId === providerId
|
||||
);
|
||||
const nextProviders = hasProvider
|
||||
? state.cliStatus.providers.map((provider) =>
|
||||
? settledCliStatus.providers.map((provider) =>
|
||||
provider.providerId === providerId ? providerStatus : provider
|
||||
)
|
||||
: [...state.cliStatus.providers, providerStatus];
|
||||
const authenticatedProvider =
|
||||
nextProviders.find((provider) => provider.authenticated) ?? null;
|
||||
: [...settledCliStatus.providers, providerStatus];
|
||||
const nextCliStatus = isMultimodelCliStatus(settledCliStatus)
|
||||
? {
|
||||
...settledCliStatus,
|
||||
providers: nextProviders,
|
||||
...buildMultimodelCliAuthState({
|
||||
status: settledCliStatus,
|
||||
providers: nextProviders,
|
||||
providerLoading: nextLoading,
|
||||
}),
|
||||
}
|
||||
: {
|
||||
...settledCliStatus,
|
||||
providers: nextProviders,
|
||||
authLoggedIn: nextProviders.some((provider) => provider.authenticated),
|
||||
authMethod: getAuthenticatedProvider(nextProviders)?.authMethod ?? null,
|
||||
};
|
||||
|
||||
return {
|
||||
cliStatus: {
|
||||
...state.cliStatus,
|
||||
providers: nextProviders,
|
||||
authLoggedIn: nextProviders.some((provider) => provider.authenticated),
|
||||
authMethod: authenticatedProvider?.authMethod ?? null,
|
||||
},
|
||||
cliStatus: nextCliStatus,
|
||||
cliProviderStatusLoading: nextLoading,
|
||||
};
|
||||
});
|
||||
|
|
@ -462,6 +576,7 @@ export const createCliInstallerSlice: StateCreator<AppState, [], [], CliInstalle
|
|||
error instanceof Error ? error.message : `Failed to refresh ${providerId} status`;
|
||||
logger.error(`Failed to fetch ${providerId} CLI status:`, error);
|
||||
set((state) => {
|
||||
const currentCliStatus = state.cliStatus;
|
||||
const nextLoading = silent
|
||||
? state.cliProviderStatusLoading
|
||||
: {
|
||||
|
|
@ -476,9 +591,57 @@ export const createCliInstallerSlice: StateCreator<AppState, [], [], CliInstalle
|
|||
return { cliProviderStatusLoading: nextLoading };
|
||||
}
|
||||
|
||||
if (!currentCliStatus) {
|
||||
return {
|
||||
cliStatusError: message,
|
||||
cliProviderStatusLoading: nextLoading,
|
||||
};
|
||||
}
|
||||
|
||||
const settledCliStatus: CliInstallationStatus = currentCliStatus;
|
||||
const currentProvider =
|
||||
settledCliStatus.providers.find((provider) => provider.providerId === providerId) ??
|
||||
undefined;
|
||||
const nextProviders = settledCliStatus.providers.some(
|
||||
(provider) => provider.providerId === providerId
|
||||
)
|
||||
? settledCliStatus.providers.map((provider) =>
|
||||
provider.providerId === providerId
|
||||
? createProviderStatusErrorSnapshot({
|
||||
providerId,
|
||||
message,
|
||||
currentProvider,
|
||||
})
|
||||
: provider
|
||||
)
|
||||
: [
|
||||
...currentCliStatus.providers,
|
||||
createProviderStatusErrorSnapshot({
|
||||
providerId,
|
||||
message,
|
||||
currentProvider,
|
||||
}),
|
||||
];
|
||||
|
||||
return {
|
||||
cliStatusError: message,
|
||||
cliProviderStatusLoading: nextLoading,
|
||||
cliStatus: isMultimodelCliStatus(settledCliStatus)
|
||||
? {
|
||||
...settledCliStatus,
|
||||
providers: nextProviders,
|
||||
...buildMultimodelCliAuthState({
|
||||
status: settledCliStatus,
|
||||
providers: nextProviders,
|
||||
providerLoading: nextLoading,
|
||||
}),
|
||||
}
|
||||
: {
|
||||
...settledCliStatus,
|
||||
providers: nextProviders,
|
||||
authLoggedIn: nextProviders.some((provider) => provider.authenticated),
|
||||
authMethod: getAuthenticatedProvider(nextProviders)?.authMethod ?? null,
|
||||
},
|
||||
};
|
||||
});
|
||||
} finally {
|
||||
|
|
|
|||
|
|
@ -90,14 +90,32 @@ export function isTeamProviderModelVerificationPending(
|
|||
return true;
|
||||
}
|
||||
|
||||
if (providerStatus.verificationState !== 'unknown') {
|
||||
return false;
|
||||
}
|
||||
|
||||
const hasRuntimeModelTruth =
|
||||
providerStatus.models.length > 0 ||
|
||||
(providerStatus.modelCatalog?.models.length ?? 0) > 0 ||
|
||||
(providerStatus.modelAvailability?.length ?? 0) > 0;
|
||||
if (!hasRuntimeModelTruth) {
|
||||
if (
|
||||
providerId === 'codex' &&
|
||||
providerStatus.backend?.kind === 'codex-native' &&
|
||||
providerStatus.supported
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (
|
||||
providerId === 'opencode' &&
|
||||
providerStatus.backend?.kind === 'opencode-cli' &&
|
||||
providerStatus.supported
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
if (providerStatus.verificationState !== 'unknown') {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (hasRuntimeModelTruth) {
|
||||
return false;
|
||||
}
|
||||
|
|
@ -454,7 +472,7 @@ export function getTeamModelSelectionError(
|
|||
}
|
||||
|
||||
if (!providerStatus) {
|
||||
return `Model "${trimmed}" is waiting for ${getTeamProviderLabel(providerId) ?? providerId} runtime verification. Wait for the model list to load or use Default.`;
|
||||
return null;
|
||||
}
|
||||
|
||||
if (isTeamProviderModelVerificationPending(providerId, providerStatus)) {
|
||||
|
|
|
|||
|
|
@ -77,6 +77,7 @@ import type {
|
|||
TeamLaunchResponse,
|
||||
TeamMemberActivityMeta,
|
||||
TeamMessageNotificationData,
|
||||
TeamProvisioningModelVerificationMode,
|
||||
TeamProvisioningPrepareResult,
|
||||
TeamProvisioningProgress,
|
||||
TeamSummary,
|
||||
|
|
@ -445,7 +446,8 @@ export interface TeamsAPI {
|
|||
providerId?: TeamLaunchRequest['providerId'],
|
||||
providerIds?: TeamLaunchRequest['providerId'][],
|
||||
selectedModels?: string[],
|
||||
limitContext?: boolean
|
||||
limitContext?: boolean,
|
||||
modelVerificationMode?: TeamProvisioningModelVerificationMode
|
||||
) => Promise<TeamProvisioningPrepareResult>;
|
||||
createTeam: (request: TeamCreateRequest) => Promise<TeamCreateResponse>;
|
||||
getProvisioningStatus: (runId: string) => Promise<TeamProvisioningProgress>;
|
||||
|
|
|
|||
|
|
@ -10,8 +10,10 @@ export interface TeamMember {
|
|||
/** Opt-in runtime isolation for persistent teammates. Omitted means shared workspace. */
|
||||
isolation?: 'worktree';
|
||||
providerId?: TeamProviderId;
|
||||
providerBackendId?: TeamProviderBackendId;
|
||||
model?: string;
|
||||
effort?: EffortLevel;
|
||||
fastMode?: TeamFastMode;
|
||||
color?: string;
|
||||
joinedAt?: number;
|
||||
cwd?: string;
|
||||
|
|
@ -767,8 +769,14 @@ export interface TeamMemberSnapshot {
|
|||
workflow?: string;
|
||||
isolation?: 'worktree';
|
||||
providerId?: TeamProviderId;
|
||||
providerBackendId?: TeamProviderBackendId;
|
||||
model?: string;
|
||||
effort?: EffortLevel;
|
||||
selectedFastMode?: TeamFastMode;
|
||||
resolvedFastMode?: boolean;
|
||||
laneId?: string;
|
||||
laneKind?: 'primary' | 'secondary';
|
||||
laneOwnerProviderId?: TeamProviderId;
|
||||
cwd?: string;
|
||||
/** Set only when member's git branch differs from the lead's branch. */
|
||||
gitBranch?: string;
|
||||
|
|
@ -909,6 +917,16 @@ export interface PersistedTeamLaunchMemberSources {
|
|||
|
||||
export interface PersistedTeamLaunchMemberState {
|
||||
name: string;
|
||||
providerId?: TeamProviderId;
|
||||
providerBackendId?: TeamProviderBackendId;
|
||||
model?: string;
|
||||
effort?: EffortLevel;
|
||||
selectedFastMode?: TeamFastMode;
|
||||
resolvedFastMode?: boolean;
|
||||
laneId?: string;
|
||||
laneKind?: 'primary' | 'secondary';
|
||||
laneOwnerProviderId?: TeamProviderId;
|
||||
launchIdentity?: ProviderModelLaunchIdentity;
|
||||
launchState: MemberLaunchState;
|
||||
agentToolAccepted: boolean;
|
||||
runtimeAlive: boolean;
|
||||
|
|
@ -937,6 +955,7 @@ export interface PersistedTeamLaunchSnapshot {
|
|||
leadSessionId?: string;
|
||||
launchPhase: PersistedTeamLaunchPhase;
|
||||
expectedMembers: string[];
|
||||
bootstrapExpectedMembers?: string[];
|
||||
members: Record<string, PersistedTeamLaunchMemberState>;
|
||||
summary: PersistedTeamLaunchSummary;
|
||||
teamLaunchState: TeamLaunchAggregateState;
|
||||
|
|
@ -962,6 +981,10 @@ export interface TeamAgentRuntimeEntry {
|
|||
alive: boolean;
|
||||
restartable: boolean;
|
||||
backendType?: TeamAgentRuntimeBackendType;
|
||||
providerId?: TeamProviderId;
|
||||
providerBackendId?: TeamProviderBackendId;
|
||||
laneId?: string;
|
||||
laneKind?: 'primary' | 'secondary';
|
||||
pid?: number;
|
||||
runtimeModel?: string;
|
||||
rssBytes?: number;
|
||||
|
|
@ -1072,8 +1095,10 @@ export interface TeamProvisioningMemberInput {
|
|||
/** Opt-in: run this teammate in its own git worktree. */
|
||||
isolation?: 'worktree';
|
||||
providerId?: TeamProviderId;
|
||||
providerBackendId?: TeamProviderBackendId;
|
||||
model?: string;
|
||||
effort?: EffortLevel;
|
||||
fastMode?: TeamFastMode;
|
||||
}
|
||||
|
||||
export interface TeamCreateRequest {
|
||||
|
|
@ -1114,6 +1139,8 @@ export interface TeamCreateResponse {
|
|||
runId: string;
|
||||
}
|
||||
|
||||
export type TeamProvisioningModelVerificationMode = 'compatibility' | 'deep';
|
||||
|
||||
export interface TeamProvisioningPrepareResult {
|
||||
ready: boolean;
|
||||
message: string;
|
||||
|
|
|
|||
|
|
@ -32,6 +32,10 @@ const { mockAddTeamNotification } = vi.hoisted(() => ({
|
|||
const { mockGetMembersMeta } = vi.hoisted(() => ({
|
||||
mockGetMembersMeta: vi.fn(),
|
||||
}));
|
||||
const { mockGetMembersMetaFile, mockWriteMembersMeta } = vi.hoisted(() => ({
|
||||
mockGetMembersMetaFile: vi.fn(),
|
||||
mockWriteMembersMeta: vi.fn(),
|
||||
}));
|
||||
const { mockTeamDataWorkerClient } = vi.hoisted(() => ({
|
||||
mockTeamDataWorkerClient: {
|
||||
isAvailable: vi.fn(),
|
||||
|
|
@ -51,6 +55,8 @@ vi.mock('@main/services/infrastructure/NotificationManager', () => ({
|
|||
vi.mock('@main/services/team/TeamMembersMetaStore', () => ({
|
||||
TeamMembersMetaStore: vi.fn().mockImplementation(() => ({
|
||||
getMembers: mockGetMembersMeta,
|
||||
getMeta: mockGetMembersMetaFile,
|
||||
writeMembers: mockWriteMembersMeta,
|
||||
})),
|
||||
}));
|
||||
vi.mock('@main/services/team/TeamDataWorkerClient', () => ({
|
||||
|
|
@ -229,6 +235,8 @@ describe('ipc teams handlers', () => {
|
|||
getAliveTeams: vi.fn(() => ['my-team']),
|
||||
getLeadActivityState: vi.fn(() => 'idle'),
|
||||
stopTeam: vi.fn(() => undefined),
|
||||
reattachOpenCodeOwnedMemberLane: vi.fn(async () => undefined),
|
||||
detachOpenCodeOwnedMemberLane: vi.fn(async () => undefined),
|
||||
};
|
||||
const boardTaskActivityService = {
|
||||
getTaskActivity: vi.fn<() => Promise<BoardTaskActivityEntry[]>>(async () => []),
|
||||
|
|
@ -259,6 +267,14 @@ describe('ipc teams handlers', () => {
|
|||
vi.clearAllMocks();
|
||||
mockGetMembersMeta.mockReset();
|
||||
mockGetMembersMeta.mockResolvedValue([]);
|
||||
mockGetMembersMetaFile.mockReset();
|
||||
mockGetMembersMetaFile.mockResolvedValue({
|
||||
version: 1,
|
||||
providerBackendId: undefined,
|
||||
members: [],
|
||||
});
|
||||
mockWriteMembersMeta.mockReset();
|
||||
mockWriteMembersMeta.mockResolvedValue(undefined);
|
||||
mockTeamDataWorkerClient.isAvailable.mockReturnValue(false);
|
||||
mockTeamDataWorkerClient.getTeamData.mockReset();
|
||||
mockTeamDataWorkerClient.getMessagesPage.mockReset();
|
||||
|
|
@ -1731,6 +1747,135 @@ describe('ipc teams handlers', () => {
|
|||
const result = (await handler({} as never, 'my-team', null)) as { success: boolean };
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
|
||||
it('blocks live addMember for a running OpenCode-led team before metadata is written', async () => {
|
||||
const handler = handlers.get(TEAM_ADD_MEMBER)!;
|
||||
service.getTeamData.mockResolvedValueOnce({
|
||||
teamName: 'my-team',
|
||||
config: { name: 'My Team' },
|
||||
tasks: [],
|
||||
members: [
|
||||
{
|
||||
name: 'team-lead',
|
||||
providerId: 'opencode',
|
||||
role: 'Team Lead',
|
||||
currentTaskId: null,
|
||||
taskCount: 0,
|
||||
},
|
||||
],
|
||||
kanbanState: { teamName: 'my-team', reviewers: [], tasks: {} },
|
||||
processes: [],
|
||||
});
|
||||
|
||||
const result = (await handler({} as never, 'my-team', {
|
||||
name: 'alice',
|
||||
role: 'developer',
|
||||
providerId: 'opencode',
|
||||
})) as { success: boolean; error?: string };
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toContain('running OpenCode-led team');
|
||||
expect(service.addMember).not.toHaveBeenCalled();
|
||||
expect(provisioningService.reattachOpenCodeOwnedMemberLane).not.toHaveBeenCalled();
|
||||
vi.mocked(console.error).mockClear();
|
||||
});
|
||||
|
||||
it('rolls back live OpenCode addMember metadata when controlled reattach fails', async () => {
|
||||
const handler = handlers.get(TEAM_ADD_MEMBER)!;
|
||||
mockGetMembersMetaFile.mockResolvedValueOnce({
|
||||
version: 1,
|
||||
providerBackendId: 'codex-native',
|
||||
members: [
|
||||
{
|
||||
name: 'team-lead',
|
||||
providerId: 'codex',
|
||||
providerBackendId: 'codex-native',
|
||||
role: 'Team Lead',
|
||||
agentType: 'team-lead',
|
||||
},
|
||||
{
|
||||
name: 'bob',
|
||||
providerId: 'codex',
|
||||
providerBackendId: 'codex-native',
|
||||
role: 'Developer',
|
||||
agentType: 'general-purpose',
|
||||
agentId: 'agent-bob',
|
||||
},
|
||||
],
|
||||
});
|
||||
service.getTeamData.mockResolvedValueOnce({
|
||||
teamName: 'my-team',
|
||||
config: { name: 'My Team' },
|
||||
tasks: [],
|
||||
members: [
|
||||
{
|
||||
name: 'team-lead',
|
||||
providerId: 'codex',
|
||||
role: 'Team Lead',
|
||||
currentTaskId: null,
|
||||
taskCount: 0,
|
||||
},
|
||||
{
|
||||
name: 'bob',
|
||||
providerId: 'codex',
|
||||
role: 'Developer',
|
||||
currentTaskId: null,
|
||||
taskCount: 0,
|
||||
},
|
||||
],
|
||||
kanbanState: { teamName: 'my-team', reviewers: [], tasks: {} },
|
||||
processes: [],
|
||||
});
|
||||
provisioningService.reattachOpenCodeOwnedMemberLane.mockRejectedValueOnce(
|
||||
new Error('reattach failed')
|
||||
);
|
||||
|
||||
const result = (await handler({} as never, 'my-team', {
|
||||
name: 'alice',
|
||||
role: 'developer',
|
||||
providerId: 'opencode',
|
||||
model: 'minimax-m2.5-free',
|
||||
})) as { success: boolean; error?: string };
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toContain('reattach failed');
|
||||
expect(service.addMember).toHaveBeenCalledWith('my-team', {
|
||||
name: 'alice',
|
||||
role: 'developer',
|
||||
workflow: undefined,
|
||||
isolation: undefined,
|
||||
providerId: 'opencode',
|
||||
model: 'minimax-m2.5-free',
|
||||
effort: undefined,
|
||||
});
|
||||
expect(service.replaceMembers).not.toHaveBeenCalled();
|
||||
expect(mockWriteMembersMeta).toHaveBeenCalledWith(
|
||||
'my-team',
|
||||
[
|
||||
{
|
||||
name: 'team-lead',
|
||||
providerId: 'codex',
|
||||
providerBackendId: 'codex-native',
|
||||
role: 'Team Lead',
|
||||
agentType: 'team-lead',
|
||||
},
|
||||
{
|
||||
name: 'bob',
|
||||
providerId: 'codex',
|
||||
providerBackendId: 'codex-native',
|
||||
role: 'Developer',
|
||||
agentType: 'general-purpose',
|
||||
agentId: 'agent-bob',
|
||||
},
|
||||
],
|
||||
{ providerBackendId: 'codex-native' }
|
||||
);
|
||||
expect(provisioningService.detachOpenCodeOwnedMemberLane).toHaveBeenCalledWith(
|
||||
'my-team',
|
||||
'alice'
|
||||
);
|
||||
vi.mocked(console.error).mockClear();
|
||||
});
|
||||
});
|
||||
|
||||
describe('updateConfig', () => {
|
||||
|
|
@ -1793,6 +1938,418 @@ describe('ipc teams handlers', () => {
|
|||
const result = (await handler({} as never, 'my-team', '../bad')) as { success: boolean };
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
|
||||
it('blocks live removeMember for a running OpenCode-led team before metadata is changed', async () => {
|
||||
const handler = handlers.get(TEAM_REMOVE_MEMBER)!;
|
||||
service.getTeamData.mockResolvedValueOnce({
|
||||
teamName: 'my-team',
|
||||
config: { name: 'My Team' },
|
||||
tasks: [],
|
||||
members: [
|
||||
{
|
||||
name: 'team-lead',
|
||||
providerId: 'opencode',
|
||||
role: 'Team Lead',
|
||||
currentTaskId: null,
|
||||
taskCount: 0,
|
||||
},
|
||||
{
|
||||
name: 'alice',
|
||||
providerId: 'opencode',
|
||||
role: 'Developer',
|
||||
currentTaskId: null,
|
||||
taskCount: 0,
|
||||
},
|
||||
],
|
||||
kanbanState: { teamName: 'my-team', reviewers: [], tasks: {} },
|
||||
processes: [],
|
||||
});
|
||||
|
||||
const result = (await handler({} as never, 'my-team', 'alice')) as {
|
||||
success: boolean;
|
||||
error?: string;
|
||||
};
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toContain('running OpenCode-led team');
|
||||
expect(service.removeMember).not.toHaveBeenCalled();
|
||||
expect(provisioningService.detachOpenCodeOwnedMemberLane).not.toHaveBeenCalled();
|
||||
vi.mocked(console.error).mockClear();
|
||||
});
|
||||
|
||||
it('rolls back live OpenCode removeMember metadata when lane detach fails', async () => {
|
||||
const handler = handlers.get(TEAM_REMOVE_MEMBER)!;
|
||||
mockGetMembersMetaFile.mockResolvedValueOnce({
|
||||
version: 1,
|
||||
providerBackendId: undefined,
|
||||
members: [
|
||||
{
|
||||
name: 'team-lead',
|
||||
providerId: 'codex',
|
||||
role: 'Team Lead',
|
||||
agentType: 'team-lead',
|
||||
},
|
||||
{
|
||||
name: 'alice',
|
||||
providerId: 'opencode',
|
||||
model: 'nemotron-3-super-free',
|
||||
role: 'Developer',
|
||||
agentType: 'general-purpose',
|
||||
agentId: 'agent-alice',
|
||||
},
|
||||
],
|
||||
});
|
||||
service.getTeamData.mockResolvedValueOnce({
|
||||
teamName: 'my-team',
|
||||
config: { name: 'My Team' },
|
||||
tasks: [],
|
||||
members: [
|
||||
{
|
||||
name: 'team-lead',
|
||||
providerId: 'codex',
|
||||
role: 'Team Lead',
|
||||
currentTaskId: null,
|
||||
taskCount: 0,
|
||||
},
|
||||
{
|
||||
name: 'alice',
|
||||
providerId: 'opencode',
|
||||
model: 'nemotron-3-super-free',
|
||||
role: 'Developer',
|
||||
currentTaskId: null,
|
||||
taskCount: 0,
|
||||
},
|
||||
],
|
||||
kanbanState: { teamName: 'my-team', reviewers: [], tasks: {} },
|
||||
processes: [],
|
||||
});
|
||||
provisioningService.detachOpenCodeOwnedMemberLane.mockRejectedValueOnce(
|
||||
new Error('detach failed')
|
||||
);
|
||||
|
||||
const result = (await handler({} as never, 'my-team', 'alice')) as {
|
||||
success: boolean;
|
||||
error?: string;
|
||||
};
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toContain('detach failed');
|
||||
expect(service.removeMember).toHaveBeenCalledWith('my-team', 'alice');
|
||||
expect(service.replaceMembers).not.toHaveBeenCalled();
|
||||
expect(mockWriteMembersMeta).toHaveBeenCalledWith(
|
||||
'my-team',
|
||||
[
|
||||
{
|
||||
name: 'team-lead',
|
||||
providerId: 'codex',
|
||||
role: 'Team Lead',
|
||||
agentType: 'team-lead',
|
||||
},
|
||||
{
|
||||
name: 'alice',
|
||||
providerId: 'opencode',
|
||||
model: 'nemotron-3-super-free',
|
||||
role: 'Developer',
|
||||
agentType: 'general-purpose',
|
||||
agentId: 'agent-alice',
|
||||
},
|
||||
],
|
||||
{ providerBackendId: undefined }
|
||||
);
|
||||
expect(provisioningService.reattachOpenCodeOwnedMemberLane).toHaveBeenCalledWith(
|
||||
'my-team',
|
||||
'alice',
|
||||
{ reason: 'member_updated' }
|
||||
);
|
||||
vi.mocked(console.error).mockClear();
|
||||
});
|
||||
});
|
||||
|
||||
describe('replaceMembers', () => {
|
||||
it('blocks live replaceMembers for a running OpenCode-led team before metadata is changed', async () => {
|
||||
const handler = handlers.get(TEAM_REPLACE_MEMBERS)!;
|
||||
service.getTeamData.mockResolvedValueOnce({
|
||||
teamName: 'my-team',
|
||||
config: { name: 'My Team' },
|
||||
tasks: [],
|
||||
members: [
|
||||
{
|
||||
name: 'team-lead',
|
||||
providerId: 'opencode',
|
||||
role: 'Team Lead',
|
||||
currentTaskId: null,
|
||||
taskCount: 0,
|
||||
},
|
||||
{
|
||||
name: 'alice',
|
||||
providerId: 'opencode',
|
||||
role: 'Developer',
|
||||
currentTaskId: null,
|
||||
taskCount: 0,
|
||||
},
|
||||
],
|
||||
kanbanState: { teamName: 'my-team', reviewers: [], tasks: {} },
|
||||
processes: [],
|
||||
});
|
||||
|
||||
const result = (await handler({} as never, 'my-team', {
|
||||
members: [{ name: 'alice', role: 'Developer', providerId: 'opencode' }],
|
||||
})) as { success: boolean; error?: string };
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toContain('running OpenCode-led team');
|
||||
expect(service.replaceMembers).not.toHaveBeenCalled();
|
||||
expect(provisioningService.reattachOpenCodeOwnedMemberLane).not.toHaveBeenCalled();
|
||||
expect(provisioningService.detachOpenCodeOwnedMemberLane).not.toHaveBeenCalled();
|
||||
vi.mocked(console.error).mockClear();
|
||||
});
|
||||
|
||||
it('rolls back live OpenCode replaceMembers metadata when lane reattach fails', async () => {
|
||||
const handler = handlers.get(TEAM_REPLACE_MEMBERS)!;
|
||||
mockGetMembersMetaFile.mockResolvedValueOnce({
|
||||
version: 1,
|
||||
providerBackendId: 'codex-native',
|
||||
members: [
|
||||
{
|
||||
name: 'team-lead',
|
||||
providerId: 'codex',
|
||||
providerBackendId: 'codex-native',
|
||||
role: 'Team Lead',
|
||||
agentType: 'team-lead',
|
||||
},
|
||||
{
|
||||
name: 'alice',
|
||||
providerId: 'opencode',
|
||||
model: 'nemotron-3-super-free',
|
||||
role: 'Developer',
|
||||
agentType: 'general-purpose',
|
||||
agentId: 'agent-alice',
|
||||
},
|
||||
{
|
||||
name: 'bob',
|
||||
providerId: 'codex',
|
||||
providerBackendId: 'codex-native',
|
||||
role: 'Developer',
|
||||
agentType: 'general-purpose',
|
||||
agentId: 'agent-bob',
|
||||
},
|
||||
],
|
||||
});
|
||||
service.getTeamData.mockResolvedValueOnce({
|
||||
teamName: 'my-team',
|
||||
config: { name: 'My Team' },
|
||||
tasks: [],
|
||||
members: [
|
||||
{
|
||||
name: 'team-lead',
|
||||
providerId: 'codex',
|
||||
role: 'Team Lead',
|
||||
currentTaskId: null,
|
||||
taskCount: 0,
|
||||
},
|
||||
{
|
||||
name: 'alice',
|
||||
providerId: 'opencode',
|
||||
model: 'nemotron-3-super-free',
|
||||
role: 'Developer',
|
||||
currentTaskId: null,
|
||||
taskCount: 0,
|
||||
},
|
||||
{
|
||||
name: 'bob',
|
||||
providerId: 'codex',
|
||||
role: 'Developer',
|
||||
currentTaskId: null,
|
||||
taskCount: 0,
|
||||
},
|
||||
],
|
||||
kanbanState: { teamName: 'my-team', reviewers: [], tasks: {} },
|
||||
processes: [],
|
||||
});
|
||||
provisioningService.reattachOpenCodeOwnedMemberLane.mockRejectedValueOnce(
|
||||
new Error('reattach failed')
|
||||
);
|
||||
|
||||
const result = (await handler({} as never, 'my-team', {
|
||||
members: [
|
||||
{
|
||||
name: 'alice',
|
||||
role: 'Developer',
|
||||
providerId: 'opencode',
|
||||
model: 'minimax-m2.5-free',
|
||||
},
|
||||
{
|
||||
name: 'bob',
|
||||
role: 'Developer',
|
||||
providerId: 'codex',
|
||||
},
|
||||
],
|
||||
})) as { success: boolean; error?: string };
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toContain('reattach failed');
|
||||
expect(service.replaceMembers).toHaveBeenNthCalledWith(1, 'my-team', {
|
||||
members: [
|
||||
{
|
||||
name: 'alice',
|
||||
role: 'Developer',
|
||||
workflow: undefined,
|
||||
isolation: undefined,
|
||||
providerId: 'opencode',
|
||||
providerBackendId: undefined,
|
||||
model: 'minimax-m2.5-free',
|
||||
effort: undefined,
|
||||
fastMode: undefined,
|
||||
},
|
||||
{
|
||||
name: 'bob',
|
||||
role: 'Developer',
|
||||
workflow: undefined,
|
||||
isolation: undefined,
|
||||
providerId: 'codex',
|
||||
providerBackendId: undefined,
|
||||
model: undefined,
|
||||
effort: undefined,
|
||||
fastMode: undefined,
|
||||
},
|
||||
],
|
||||
});
|
||||
expect(service.replaceMembers).toHaveBeenCalledTimes(1);
|
||||
expect(mockWriteMembersMeta).toHaveBeenCalledWith(
|
||||
'my-team',
|
||||
[
|
||||
{
|
||||
name: 'team-lead',
|
||||
providerId: 'codex',
|
||||
providerBackendId: 'codex-native',
|
||||
role: 'Team Lead',
|
||||
agentType: 'team-lead',
|
||||
},
|
||||
{
|
||||
name: 'alice',
|
||||
providerId: 'opencode',
|
||||
model: 'nemotron-3-super-free',
|
||||
role: 'Developer',
|
||||
agentType: 'general-purpose',
|
||||
agentId: 'agent-alice',
|
||||
},
|
||||
{
|
||||
name: 'bob',
|
||||
providerId: 'codex',
|
||||
providerBackendId: 'codex-native',
|
||||
role: 'Developer',
|
||||
agentType: 'general-purpose',
|
||||
agentId: 'agent-bob',
|
||||
},
|
||||
],
|
||||
{ providerBackendId: 'codex-native' }
|
||||
);
|
||||
expect(provisioningService.reattachOpenCodeOwnedMemberLane).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
'my-team',
|
||||
'alice',
|
||||
{ reason: 'member_updated' }
|
||||
);
|
||||
expect(provisioningService.reattachOpenCodeOwnedMemberLane).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
'my-team',
|
||||
'alice',
|
||||
{ reason: 'member_updated' }
|
||||
);
|
||||
vi.mocked(console.error).mockClear();
|
||||
});
|
||||
|
||||
it('blocks live replaceMembers when a member migrates from primary runtime ownership to OpenCode', async () => {
|
||||
const handler = handlers.get(TEAM_REPLACE_MEMBERS)!;
|
||||
service.getTeamData.mockResolvedValueOnce({
|
||||
teamName: 'my-team',
|
||||
config: { name: 'My Team' },
|
||||
tasks: [],
|
||||
members: [
|
||||
{
|
||||
name: 'team-lead',
|
||||
providerId: 'codex',
|
||||
role: 'Team Lead',
|
||||
currentTaskId: null,
|
||||
taskCount: 0,
|
||||
},
|
||||
{
|
||||
name: 'alice',
|
||||
providerId: 'codex',
|
||||
role: 'Developer',
|
||||
currentTaskId: null,
|
||||
taskCount: 0,
|
||||
},
|
||||
],
|
||||
kanbanState: { teamName: 'my-team', reviewers: [], tasks: {} },
|
||||
processes: [],
|
||||
});
|
||||
|
||||
const result = (await handler({} as never, 'my-team', {
|
||||
members: [
|
||||
{
|
||||
name: 'alice',
|
||||
role: 'Developer',
|
||||
providerId: 'opencode',
|
||||
model: 'minimax-m2.5-free',
|
||||
},
|
||||
],
|
||||
})) as { success: boolean; error?: string };
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toContain('Live member migration between OpenCode and the primary runtime owner');
|
||||
expect(result.error).toContain('alice');
|
||||
expect(service.replaceMembers).not.toHaveBeenCalled();
|
||||
expect(provisioningService.reattachOpenCodeOwnedMemberLane).not.toHaveBeenCalled();
|
||||
expect(provisioningService.detachOpenCodeOwnedMemberLane).not.toHaveBeenCalled();
|
||||
vi.mocked(console.error).mockClear();
|
||||
});
|
||||
|
||||
it('blocks live replaceMembers when a member migrates from OpenCode to primary runtime ownership', async () => {
|
||||
const handler = handlers.get(TEAM_REPLACE_MEMBERS)!;
|
||||
service.getTeamData.mockResolvedValueOnce({
|
||||
teamName: 'my-team',
|
||||
config: { name: 'My Team' },
|
||||
tasks: [],
|
||||
members: [
|
||||
{
|
||||
name: 'team-lead',
|
||||
providerId: 'codex',
|
||||
role: 'Team Lead',
|
||||
currentTaskId: null,
|
||||
taskCount: 0,
|
||||
},
|
||||
{
|
||||
name: 'alice',
|
||||
providerId: 'opencode',
|
||||
model: 'nemotron-3-super-free',
|
||||
role: 'Developer',
|
||||
currentTaskId: null,
|
||||
taskCount: 0,
|
||||
},
|
||||
],
|
||||
kanbanState: { teamName: 'my-team', reviewers: [], tasks: {} },
|
||||
processes: [],
|
||||
});
|
||||
|
||||
const result = (await handler({} as never, 'my-team', {
|
||||
members: [
|
||||
{
|
||||
name: 'alice',
|
||||
role: 'Developer',
|
||||
providerId: 'codex',
|
||||
},
|
||||
],
|
||||
})) as { success: boolean; error?: string };
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toContain('Live member migration between OpenCode and the primary runtime owner');
|
||||
expect(result.error).toContain('alice');
|
||||
expect(service.replaceMembers).not.toHaveBeenCalled();
|
||||
expect(provisioningService.reattachOpenCodeOwnedMemberLane).not.toHaveBeenCalled();
|
||||
expect(provisioningService.detachOpenCodeOwnedMemberLane).not.toHaveBeenCalled();
|
||||
vi.mocked(console.error).mockClear();
|
||||
});
|
||||
});
|
||||
|
||||
describe('updateMemberRole', () => {
|
||||
|
|
|
|||
|
|
@ -30,7 +30,7 @@ describe('OpenCodeProductionE2EEvidence', () => {
|
|||
await fs.rm(tempDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('accepts strict evidence only when runtime identity, model and required MCP tools match', () => {
|
||||
it('accepts production evidence when runtime identity, project context and required MCP tools match', () => {
|
||||
const evidence = passingEvidence();
|
||||
|
||||
expect(validateOpenCodeProductionE2EEvidence(evidence)).toEqual(evidence);
|
||||
|
|
@ -85,7 +85,6 @@ describe('OpenCodeProductionE2EEvidence', () => {
|
|||
'OpenCode production E2E evidence is expired',
|
||||
'OpenCode production E2E evidence is missing signals: stale_run_rejected',
|
||||
'OpenCode production E2E evidence is missing observed MCP tools: agent-teams_runtime_deliver_message',
|
||||
'OpenCode production E2E evidence model openrouter/anthropic/claude-sonnet-4.5 does not match selected model openai/gpt-5.4-mini. Production launch is intentionally scoped to the exact raw model id; regenerate evidence with OPENCODE_E2E_MODEL=openai/gpt-5.4-mini.',
|
||||
]),
|
||||
});
|
||||
});
|
||||
|
|
@ -139,7 +138,7 @@ describe('OpenCodeProductionE2EEvidence', () => {
|
|||
});
|
||||
});
|
||||
|
||||
it('stores production evidence for multiple raw model ids and reads exact model matches', async () => {
|
||||
it('stores production evidence for multiple raw model ids and reads exact model matches when no project context is provided', async () => {
|
||||
const filePath = path.join(tempDir, 'production-e2e-evidence.json');
|
||||
const store = new OpenCodeProductionE2EEvidenceStore({
|
||||
filePath,
|
||||
|
|
@ -174,6 +173,87 @@ describe('OpenCodeProductionE2EEvidence', () => {
|
|||
});
|
||||
});
|
||||
|
||||
it('reuses the current project production proof even when the requested OpenCode model differs', async () => {
|
||||
const filePath = path.join(tempDir, 'production-e2e-evidence.json');
|
||||
const store = new OpenCodeProductionE2EEvidenceStore({
|
||||
filePath,
|
||||
clock: () => now,
|
||||
});
|
||||
|
||||
await store.write(
|
||||
passingEvidence({
|
||||
evidenceId: 'e2e-project-a',
|
||||
selectedModel: 'opencode/minimax-m2.5-free',
|
||||
projectPathFingerprint: buildOpenCodeProjectPathFingerprint('/repo-a'),
|
||||
})
|
||||
);
|
||||
await store.write(
|
||||
passingEvidence({
|
||||
evidenceId: 'e2e-project-b',
|
||||
selectedModel: 'opencode/minimax-m2.5-free',
|
||||
projectPathFingerprint: buildOpenCodeProjectPathFingerprint('/repo-b'),
|
||||
})
|
||||
);
|
||||
|
||||
await expect(
|
||||
store.read({
|
||||
selectedModel: 'opencode/nemotron-3-super-free',
|
||||
projectPathFingerprint: buildOpenCodeProjectPathFingerprint('/repo-b'),
|
||||
})
|
||||
).resolves.toMatchObject({
|
||||
ok: true,
|
||||
evidence: {
|
||||
evidenceId: 'e2e-project-b',
|
||||
selectedModel: 'opencode/minimax-m2.5-free',
|
||||
},
|
||||
diagnostics: [],
|
||||
});
|
||||
});
|
||||
|
||||
it('prefers a runtime-compatible project proof over a newer stale one from the same cwd', async () => {
|
||||
const filePath = path.join(tempDir, 'production-e2e-evidence.json');
|
||||
const store = new OpenCodeProductionE2EEvidenceStore({
|
||||
filePath,
|
||||
clock: () => now,
|
||||
});
|
||||
|
||||
await store.write(
|
||||
passingEvidence({
|
||||
evidenceId: 'stale-newer',
|
||||
createdAt: '2026-04-21T12:05:00.000Z',
|
||||
selectedModel: 'opencode/big-pickle',
|
||||
projectPathFingerprint: buildOpenCodeProjectPathFingerprint('/repo-a'),
|
||||
capabilitySnapshotId: 'cap-stale',
|
||||
})
|
||||
);
|
||||
await store.write(
|
||||
passingEvidence({
|
||||
evidenceId: 'matching-older',
|
||||
createdAt: '2026-04-21T12:00:00.000Z',
|
||||
selectedModel: 'opencode/minimax-m2.5-free',
|
||||
projectPathFingerprint: buildOpenCodeProjectPathFingerprint('/repo-a'),
|
||||
capabilitySnapshotId: 'cap-current',
|
||||
})
|
||||
);
|
||||
|
||||
await expect(
|
||||
store.read({
|
||||
selectedModel: 'opencode/nemotron-3-super-free',
|
||||
projectPathFingerprint: buildOpenCodeProjectPathFingerprint('/repo-a'),
|
||||
opencodeVersion: '1.14.19',
|
||||
binaryFingerprint: 'version:1.14.19',
|
||||
capabilitySnapshotId: 'cap-current',
|
||||
})
|
||||
).resolves.toMatchObject({
|
||||
ok: true,
|
||||
evidence: {
|
||||
evidenceId: 'matching-older',
|
||||
selectedModel: 'opencode/minimax-m2.5-free',
|
||||
},
|
||||
diagnostics: [],
|
||||
});
|
||||
});
|
||||
|
||||
it('stores production evidence for the same raw model across multiple project contexts', async () => {
|
||||
const filePath = path.join(tempDir, 'production-e2e-evidence.json');
|
||||
const store = new OpenCodeProductionE2EEvidenceStore({
|
||||
|
|
@ -218,9 +298,7 @@ describe('OpenCodeProductionE2EEvidence', () => {
|
|||
).resolves.toMatchObject({
|
||||
ok: true,
|
||||
evidence: null,
|
||||
diagnostics: [
|
||||
'OpenCode production E2E evidence artifact has no entry for selected model opencode/minimax-m2.5-free and the current working directory',
|
||||
],
|
||||
diagnostics: ['OpenCode production E2E evidence artifact has no entry for the current working directory'],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -126,6 +126,7 @@ liveDescribe('OpenCode production gate live e2e', () => {
|
|||
launch = await readinessBridge.launchOpenCodeTeam({
|
||||
mode: 'dogfood',
|
||||
runId,
|
||||
laneId: 'primary',
|
||||
teamId: teamName,
|
||||
teamName,
|
||||
projectPath: PROJECT_PATH,
|
||||
|
|
@ -147,6 +148,7 @@ liveDescribe('OpenCode production gate live e2e', () => {
|
|||
|
||||
reconcile = await readinessBridge.reconcileOpenCodeTeam({
|
||||
runId,
|
||||
laneId: 'primary',
|
||||
teamId: teamName,
|
||||
teamName,
|
||||
projectPath: PROJECT_PATH,
|
||||
|
|
@ -158,11 +160,11 @@ liveDescribe('OpenCode production gate live e2e', () => {
|
|||
expect(reconcile.teamLaunchState).toBe('ready');
|
||||
|
||||
const transcript = await bridgeClient.execute<
|
||||
{ teamId: string; teamName: string; memberName: string },
|
||||
{ teamId: string; teamName: string; laneId: string; memberName: string },
|
||||
{ logProjection?: { messages?: unknown[] }; messages?: unknown[] }
|
||||
>(
|
||||
'opencode.getRuntimeTranscript',
|
||||
{ teamId: teamName, teamName, memberName },
|
||||
{ teamId: teamName, teamName, laneId: 'primary', memberName },
|
||||
{ cwd: PROJECT_PATH, timeoutMs: 60_000 }
|
||||
);
|
||||
expect(transcript.ok).toBe(true);
|
||||
|
|
@ -181,6 +183,7 @@ liveDescribe('OpenCode production gate live e2e', () => {
|
|||
|
||||
stop = await readinessBridge.stopOpenCodeTeam({
|
||||
runId,
|
||||
laneId: 'primary',
|
||||
teamId: teamName,
|
||||
teamName,
|
||||
projectPath: PROJECT_PATH,
|
||||
|
|
@ -247,6 +250,7 @@ liveDescribe('OpenCode production gate live e2e', () => {
|
|||
await readinessBridge
|
||||
.stopOpenCodeTeam({
|
||||
runId,
|
||||
laneId: 'primary',
|
||||
teamId: teamName,
|
||||
teamName,
|
||||
projectPath: PROJECT_PATH,
|
||||
|
|
@ -326,11 +330,13 @@ async function rejectsStaleCapability(input: {
|
|||
await input.stateChangingCommands.execute({
|
||||
command: 'opencode.reconcileTeam',
|
||||
teamName: input.teamName,
|
||||
laneId: 'primary',
|
||||
runId: input.runId,
|
||||
capabilitySnapshotId: 'opencode:stale-capability',
|
||||
behaviorFingerprint: null,
|
||||
body: {
|
||||
runId: input.runId,
|
||||
laneId: 'primary',
|
||||
teamId: input.teamName,
|
||||
teamName: input.teamName,
|
||||
projectPath: PROJECT_PATH,
|
||||
|
|
|
|||
|
|
@ -144,7 +144,7 @@ describe('OpenCodeReadinessBridge', () => {
|
|||
});
|
||||
});
|
||||
|
||||
it('keeps production readiness open when evidence matches runtime identity and raw model', async () => {
|
||||
it('keeps production readiness open when evidence matches runtime identity and project context', async () => {
|
||||
const executor = fakeExecutor(
|
||||
bridgeSuccess(readiness({ state: 'ready', launchAllowed: true }))
|
||||
);
|
||||
|
|
@ -169,6 +169,35 @@ describe('OpenCodeReadinessBridge', () => {
|
|||
expect(evidence.read).toHaveBeenCalledWith({
|
||||
selectedModel: 'openai/gpt-5.4-mini',
|
||||
projectPathFingerprint: buildOpenCodeProjectPathFingerprint('/repo'),
|
||||
opencodeVersion: '1.14.19',
|
||||
binaryFingerprint: 'bin-1',
|
||||
capabilitySnapshotId: 'cap-1',
|
||||
});
|
||||
});
|
||||
|
||||
it('accepts production evidence recorded with a different OpenCode model when runtime identity matches', async () => {
|
||||
const executor = fakeExecutor(
|
||||
bridgeSuccess(readiness({ state: 'ready', launchAllowed: true }))
|
||||
);
|
||||
const evidence = fakeEvidenceStore(
|
||||
productionEvidence({ selectedModel: 'opencode/minimax-m2.5-free' })
|
||||
);
|
||||
const bridge = new OpenCodeReadinessBridge(executor, {
|
||||
productionE2eEvidence: evidence,
|
||||
});
|
||||
|
||||
await expect(
|
||||
bridge.checkOpenCodeTeamLaunchReadiness({
|
||||
projectPath: '/repo',
|
||||
selectedModel: 'opencode/nemotron-3-super-free',
|
||||
requireExecutionProbe: true,
|
||||
launchMode: 'production',
|
||||
})
|
||||
).resolves.toMatchObject({
|
||||
state: 'ready',
|
||||
launchAllowed: true,
|
||||
supportLevel: 'production_supported',
|
||||
diagnostics: [],
|
||||
});
|
||||
});
|
||||
|
||||
|
|
@ -204,6 +233,7 @@ describe('OpenCodeReadinessBridge', () => {
|
|||
bridge.launchOpenCodeTeam({
|
||||
mode: 'dogfood',
|
||||
runId: 'run-1',
|
||||
laneId: 'primary',
|
||||
teamId: 'team-a',
|
||||
teamName: 'team-a',
|
||||
projectPath: '/repo',
|
||||
|
|
@ -223,6 +253,7 @@ describe('OpenCodeReadinessBridge', () => {
|
|||
expect.objectContaining({
|
||||
command: 'opencode.launchTeam',
|
||||
teamName: 'team-a',
|
||||
laneId: 'primary',
|
||||
runId: 'run-1',
|
||||
capabilitySnapshotId: 'cap-1',
|
||||
cwd: '/repo',
|
||||
|
|
@ -327,6 +358,7 @@ function readiness(
|
|||
state: 'adapter_disabled',
|
||||
launchAllowed: false,
|
||||
modelId: 'openai/gpt-5.4-mini',
|
||||
availableModels: ['openai/gpt-5.4-mini'],
|
||||
opencodeVersion: '1.14.19',
|
||||
installMethod: 'brew',
|
||||
binaryPath: '/opt/homebrew/bin/opencode',
|
||||
|
|
|
|||
|
|
@ -0,0 +1,241 @@
|
|||
import { promises as fs } from 'fs';
|
||||
import * as os from 'os';
|
||||
import * as path from 'path';
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
OpenCodeRuntimeManifestEvidenceReader,
|
||||
getOpenCodeLaneScopedRuntimeFilePath,
|
||||
getOpenCodeRuntimeLaneIndexPath,
|
||||
getOpenCodeTeamRuntimeDirectory,
|
||||
migrateLegacyOpenCodeRuntimeState,
|
||||
readOpenCodeRuntimeLaneIndex,
|
||||
upsertOpenCodeRuntimeLaneIndexEntry,
|
||||
} from '../../../../src/main/services/team/opencode/store/OpenCodeRuntimeManifestEvidenceReader';
|
||||
|
||||
describe('OpenCodeRuntimeManifestEvidenceReader migration', () => {
|
||||
let tempDir: string;
|
||||
let now: Date;
|
||||
|
||||
beforeEach(async () => {
|
||||
tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'opencode-runtime-migration-'));
|
||||
now = new Date('2026-04-22T10:00:00.000Z');
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await fs.rm(tempDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('migrates legacy team-scoped OpenCode runtime files into the addressed lane', async () => {
|
||||
const teamName = 'team-alpha';
|
||||
const laneId = 'secondary:opencode:alice';
|
||||
const runtimeDir = getOpenCodeTeamRuntimeDirectory(tempDir, teamName);
|
||||
|
||||
await fs.mkdir(runtimeDir, { recursive: true });
|
||||
await fs.writeFile(path.join(runtimeDir, 'manifest.json'), '{"highWatermark":7}\n', 'utf8');
|
||||
await fs.writeFile(
|
||||
path.join(runtimeDir, 'opencode-launch-transaction.json'),
|
||||
'{"transactionId":"tx-1"}\n',
|
||||
'utf8'
|
||||
);
|
||||
|
||||
const result = await migrateLegacyOpenCodeRuntimeState({
|
||||
teamsBasePath: tempDir,
|
||||
teamName,
|
||||
laneId,
|
||||
clock: () => now,
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
migrated: true,
|
||||
degraded: false,
|
||||
diagnostics: ['migrated 2 legacy OpenCode runtime files'],
|
||||
});
|
||||
|
||||
await expect(fs.readFile(path.join(runtimeDir, 'manifest.json'), 'utf8')).rejects.toThrow();
|
||||
await expect(
|
||||
fs.readFile(path.join(runtimeDir, 'opencode-launch-transaction.json'), 'utf8')
|
||||
).rejects.toThrow();
|
||||
|
||||
await expect(
|
||||
fs.readFile(
|
||||
getOpenCodeLaneScopedRuntimeFilePath({
|
||||
teamsBasePath: tempDir,
|
||||
teamName,
|
||||
laneId,
|
||||
fileName: 'manifest.json',
|
||||
}),
|
||||
'utf8'
|
||||
)
|
||||
).resolves.toBe('{"highWatermark":7}\n');
|
||||
await expect(
|
||||
fs.readFile(
|
||||
getOpenCodeLaneScopedRuntimeFilePath({
|
||||
teamsBasePath: tempDir,
|
||||
teamName,
|
||||
laneId,
|
||||
fileName: 'opencode-launch-transaction.json',
|
||||
}),
|
||||
'utf8'
|
||||
)
|
||||
).resolves.toBe('{"transactionId":"tx-1"}\n');
|
||||
|
||||
await expect(fs.readFile(getOpenCodeRuntimeLaneIndexPath(tempDir, teamName), 'utf8')).resolves.toContain(
|
||||
`"${laneId}"`
|
||||
);
|
||||
await expect(readOpenCodeRuntimeLaneIndex(tempDir, teamName)).resolves.toMatchObject({
|
||||
lanes: {
|
||||
[laneId]: {
|
||||
laneId,
|
||||
state: 'active',
|
||||
diagnostics: [
|
||||
`migrated legacy team-scoped OpenCode runtime state at ${now.toISOString()}`,
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('marks ambiguous legacy runtime state as degraded instead of guessing a lane', async () => {
|
||||
const teamName = 'team-beta';
|
||||
const laneId = 'secondary:opencode:alice';
|
||||
const otherLaneId = 'secondary:opencode:bob';
|
||||
const runtimeDir = getOpenCodeTeamRuntimeDirectory(tempDir, teamName);
|
||||
|
||||
await fs.mkdir(runtimeDir, { recursive: true });
|
||||
await fs.writeFile(path.join(runtimeDir, 'manifest.json'), '{"highWatermark":11}\n', 'utf8');
|
||||
await upsertOpenCodeRuntimeLaneIndexEntry({
|
||||
teamsBasePath: tempDir,
|
||||
teamName,
|
||||
laneId: otherLaneId,
|
||||
state: 'active',
|
||||
});
|
||||
|
||||
const result = await migrateLegacyOpenCodeRuntimeState({
|
||||
teamsBasePath: tempDir,
|
||||
teamName,
|
||||
laneId,
|
||||
clock: () => now,
|
||||
});
|
||||
|
||||
expect(result.migrated).toBe(false);
|
||||
expect(result.degraded).toBe(true);
|
||||
expect(result.diagnostics).toEqual([
|
||||
`Legacy OpenCode runtime state is ambiguous for ${teamName}; existing lanes: ${otherLaneId}`,
|
||||
]);
|
||||
|
||||
await expect(fs.readFile(path.join(runtimeDir, 'manifest.json'), 'utf8')).resolves.toBe(
|
||||
'{"highWatermark":11}\n'
|
||||
);
|
||||
await expect(
|
||||
fs.readFile(
|
||||
getOpenCodeLaneScopedRuntimeFilePath({
|
||||
teamsBasePath: tempDir,
|
||||
teamName,
|
||||
laneId,
|
||||
fileName: 'manifest.json',
|
||||
}),
|
||||
'utf8'
|
||||
)
|
||||
).rejects.toThrow();
|
||||
|
||||
await expect(readOpenCodeRuntimeLaneIndex(tempDir, teamName)).resolves.toMatchObject({
|
||||
lanes: {
|
||||
[otherLaneId]: {
|
||||
laneId: otherLaneId,
|
||||
state: 'active',
|
||||
},
|
||||
[laneId]: {
|
||||
laneId,
|
||||
state: 'degraded',
|
||||
diagnostics: [
|
||||
`Legacy OpenCode runtime state is ambiguous for ${teamName}; existing lanes: ${otherLaneId}`,
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('does not fall back to team-scoped legacy manifest when sibling lane metadata already exists', async () => {
|
||||
const teamName = 'team-gamma';
|
||||
const laneId = 'secondary:opencode:alice';
|
||||
const otherLaneId = 'secondary:opencode:bob';
|
||||
const runtimeDir = getOpenCodeTeamRuntimeDirectory(tempDir, teamName);
|
||||
const reader = new OpenCodeRuntimeManifestEvidenceReader({ teamsBasePath: tempDir });
|
||||
|
||||
await fs.mkdir(runtimeDir, { recursive: true });
|
||||
await fs.writeFile(
|
||||
path.join(
|
||||
runtimeDir,
|
||||
'manifest.json'
|
||||
),
|
||||
JSON.stringify({
|
||||
schemaVersion: 1,
|
||||
updatedAt: '2026-04-22T10:00:00.000Z',
|
||||
data: {
|
||||
schemaVersion: 1,
|
||||
teamName,
|
||||
activeRunId: 'legacy-run',
|
||||
activeCapabilitySnapshotId: 'cap-1',
|
||||
activeBehaviorFingerprint: null,
|
||||
highWatermark: 11,
|
||||
lastCommittedBatchId: null,
|
||||
lastPreparingBatchId: null,
|
||||
entries: [],
|
||||
lastRecoveryPlanId: null,
|
||||
updatedAt: '2026-04-22T10:00:00.000Z',
|
||||
},
|
||||
}),
|
||||
'utf8'
|
||||
);
|
||||
await upsertOpenCodeRuntimeLaneIndexEntry({
|
||||
teamsBasePath: tempDir,
|
||||
teamName,
|
||||
laneId: otherLaneId,
|
||||
state: 'active',
|
||||
});
|
||||
|
||||
await expect(reader.read(teamName, laneId)).resolves.toEqual({
|
||||
highWatermark: 0,
|
||||
activeRunId: null,
|
||||
capabilitySnapshotId: null,
|
||||
});
|
||||
});
|
||||
|
||||
it('still falls back to team-scoped legacy manifest for safe single-lane backward compatibility', async () => {
|
||||
const teamName = 'team-delta';
|
||||
const laneId = 'secondary:opencode:alice';
|
||||
const runtimeDir = getOpenCodeTeamRuntimeDirectory(tempDir, teamName);
|
||||
const reader = new OpenCodeRuntimeManifestEvidenceReader({ teamsBasePath: tempDir });
|
||||
|
||||
await fs.mkdir(runtimeDir, { recursive: true });
|
||||
await fs.writeFile(
|
||||
path.join(runtimeDir, 'manifest.json'),
|
||||
JSON.stringify({
|
||||
schemaVersion: 1,
|
||||
updatedAt: '2026-04-22T10:00:00.000Z',
|
||||
data: {
|
||||
schemaVersion: 1,
|
||||
teamName,
|
||||
activeRunId: 'legacy-run',
|
||||
activeCapabilitySnapshotId: 'cap-1',
|
||||
activeBehaviorFingerprint: null,
|
||||
highWatermark: 11,
|
||||
lastCommittedBatchId: null,
|
||||
lastPreparingBatchId: null,
|
||||
entries: [],
|
||||
lastRecoveryPlanId: null,
|
||||
updatedAt: '2026-04-22T10:00:00.000Z',
|
||||
},
|
||||
}),
|
||||
'utf8'
|
||||
);
|
||||
|
||||
await expect(reader.read(teamName, laneId)).resolves.toEqual({
|
||||
highWatermark: 11,
|
||||
activeRunId: 'legacy-run',
|
||||
capabilitySnapshotId: 'cap-1',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -223,6 +223,7 @@ function readiness(
|
|||
state: 'adapter_disabled',
|
||||
launchAllowed: false,
|
||||
modelId: 'openai/gpt-5.4-mini',
|
||||
availableModels: ['openai/gpt-5.4-mini'],
|
||||
opencodeVersion: '1.14.19',
|
||||
installMethod: 'brew',
|
||||
binaryPath: '/opt/homebrew/bin/opencode',
|
||||
|
|
|
|||
233
test/main/services/team/TeamBackupService.test.ts
Normal file
233
test/main/services/team/TeamBackupService.test.ts
Normal file
|
|
@ -0,0 +1,233 @@
|
|||
import * as fs from 'fs/promises';
|
||||
import * as os from 'os';
|
||||
import * as path from 'path';
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const hoisted = vi.hoisted(() => ({
|
||||
teamsBase: '',
|
||||
backupsBase: '',
|
||||
appDataPath: '',
|
||||
tasksBase: '',
|
||||
}));
|
||||
|
||||
vi.mock('../../../../src/main/utils/pathDecoder', () => ({
|
||||
getTeamsBasePath: () => hoisted.teamsBase,
|
||||
getBackupsBasePath: () => hoisted.backupsBase,
|
||||
getAppDataPath: () => hoisted.appDataPath,
|
||||
getTasksBasePath: () => hoisted.tasksBase,
|
||||
}));
|
||||
|
||||
import { TeamBackupService } from '../../../../src/main/services/team/TeamBackupService';
|
||||
|
||||
describe('TeamBackupService', () => {
|
||||
let tempDir = '';
|
||||
|
||||
beforeEach(async () => {
|
||||
tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'team-backup-service-'));
|
||||
hoisted.teamsBase = path.join(tempDir, 'teams');
|
||||
hoisted.backupsBase = path.join(tempDir, 'backups');
|
||||
hoisted.appDataPath = path.join(tempDir, 'app-data');
|
||||
hoisted.tasksBase = path.join(tempDir, 'tasks');
|
||||
|
||||
await fs.mkdir(hoisted.teamsBase, { recursive: true });
|
||||
await fs.mkdir(hoisted.backupsBase, { recursive: true });
|
||||
await fs.mkdir(hoisted.appDataPath, { recursive: true });
|
||||
await fs.mkdir(hoisted.tasksBase, { recursive: true });
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
hoisted.teamsBase = '';
|
||||
hoisted.backupsBase = '';
|
||||
hoisted.appDataPath = '';
|
||||
hoisted.tasksBase = '';
|
||||
if (tempDir) {
|
||||
await fs.rm(tempDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('backs up and restores additive mixed-lane metadata and launch snapshots', async () => {
|
||||
const service = new TeamBackupService();
|
||||
const teamName = 'mixed-team';
|
||||
const teamDir = path.join(hoisted.teamsBase, teamName);
|
||||
await fs.mkdir(teamDir, { recursive: true });
|
||||
|
||||
const config = {
|
||||
name: 'Mixed Team',
|
||||
projectPath: '/tmp/project',
|
||||
members: [{ name: 'team-lead', agentType: 'team-lead' }],
|
||||
};
|
||||
const teamMeta = {
|
||||
version: 1,
|
||||
cwd: '/tmp/project',
|
||||
providerId: 'codex',
|
||||
providerBackendId: 'codex-native',
|
||||
fastMode: 'off',
|
||||
createdAt: Date.now(),
|
||||
};
|
||||
const membersMeta = {
|
||||
version: 1,
|
||||
providerBackendId: 'codex-native',
|
||||
members: [
|
||||
{ name: 'alice', providerId: 'codex', role: 'reviewer' },
|
||||
{
|
||||
name: 'tom',
|
||||
providerId: 'opencode',
|
||||
providerBackendId: 'opencode-cli',
|
||||
model: 'minimax-m2.5-free',
|
||||
fastMode: 'inherit',
|
||||
role: 'developer',
|
||||
},
|
||||
],
|
||||
};
|
||||
const launchState = {
|
||||
version: 2,
|
||||
teamName,
|
||||
updatedAt: '2026-04-22T12:00:00.000Z',
|
||||
launchPhase: 'active',
|
||||
expectedMembers: ['alice', 'tom'],
|
||||
bootstrapExpectedMembers: ['alice'],
|
||||
members: {
|
||||
alice: {
|
||||
name: 'alice',
|
||||
providerId: 'codex',
|
||||
providerBackendId: 'codex-native',
|
||||
laneId: 'primary',
|
||||
laneKind: 'primary',
|
||||
laneOwnerProviderId: 'codex',
|
||||
launchState: 'confirmed_alive',
|
||||
agentToolAccepted: true,
|
||||
runtimeAlive: true,
|
||||
bootstrapConfirmed: true,
|
||||
hardFailure: false,
|
||||
lastEvaluatedAt: '2026-04-22T12:00:00.000Z',
|
||||
},
|
||||
tom: {
|
||||
name: 'tom',
|
||||
providerId: 'opencode',
|
||||
providerBackendId: 'opencode-cli',
|
||||
laneId: 'secondary:opencode:tom',
|
||||
laneKind: 'secondary',
|
||||
laneOwnerProviderId: 'opencode',
|
||||
launchState: 'starting',
|
||||
agentToolAccepted: true,
|
||||
runtimeAlive: false,
|
||||
bootstrapConfirmed: false,
|
||||
hardFailure: false,
|
||||
lastEvaluatedAt: '2026-04-22T12:00:00.000Z',
|
||||
},
|
||||
},
|
||||
summary: {
|
||||
confirmedCount: 1,
|
||||
pendingCount: 1,
|
||||
failedCount: 0,
|
||||
runtimeAlivePendingCount: 0,
|
||||
},
|
||||
teamLaunchState: 'partial_pending',
|
||||
};
|
||||
const launchSummary = {
|
||||
version: 1,
|
||||
teamName,
|
||||
updatedAt: '2026-04-22T12:00:00.000Z',
|
||||
mixedAware: true,
|
||||
expectedMemberCount: 2,
|
||||
confirmedMemberCount: 1,
|
||||
pendingCount: 1,
|
||||
failedCount: 0,
|
||||
teamLaunchState: 'partial_pending',
|
||||
launchUpdatedAt: '2026-04-22T12:00:00.000Z',
|
||||
};
|
||||
const runtimeLaneDir = path.join(
|
||||
teamDir,
|
||||
'.opencode-runtime',
|
||||
'lanes',
|
||||
encodeURIComponent('secondary:opencode:tom')
|
||||
);
|
||||
const runtimeLaneIndex = {
|
||||
version: 1,
|
||||
updatedAt: '2026-04-22T12:00:00.000Z',
|
||||
lanes: {
|
||||
'secondary:opencode:tom': {
|
||||
laneId: 'secondary:opencode:tom',
|
||||
state: 'active',
|
||||
updatedAt: '2026-04-22T12:00:00.000Z',
|
||||
diagnostics: [],
|
||||
},
|
||||
},
|
||||
};
|
||||
const runtimeManifest = {
|
||||
schemaVersion: 1,
|
||||
highWatermark: 12,
|
||||
activeRunId: 'lane-run-1',
|
||||
capabilitySnapshotId: 'cap-1',
|
||||
};
|
||||
|
||||
await fs.writeFile(path.join(teamDir, 'config.json'), JSON.stringify(config), 'utf8');
|
||||
await fs.writeFile(path.join(teamDir, 'team.meta.json'), JSON.stringify(teamMeta), 'utf8');
|
||||
await fs.writeFile(
|
||||
path.join(teamDir, 'members.meta.json'),
|
||||
JSON.stringify(membersMeta),
|
||||
'utf8'
|
||||
);
|
||||
await fs.writeFile(
|
||||
path.join(teamDir, 'launch-state.json'),
|
||||
JSON.stringify(launchState),
|
||||
'utf8'
|
||||
);
|
||||
await fs.writeFile(
|
||||
path.join(teamDir, 'launch-summary.json'),
|
||||
JSON.stringify(launchSummary),
|
||||
'utf8'
|
||||
);
|
||||
await fs.mkdir(runtimeLaneDir, { recursive: true });
|
||||
await fs.writeFile(
|
||||
path.join(teamDir, '.opencode-runtime', 'lanes.json'),
|
||||
JSON.stringify(runtimeLaneIndex),
|
||||
'utf8'
|
||||
);
|
||||
await fs.writeFile(
|
||||
path.join(runtimeLaneDir, 'runtime-store-manifest.json'),
|
||||
JSON.stringify(runtimeManifest),
|
||||
'utf8'
|
||||
);
|
||||
|
||||
await service.initialize();
|
||||
await service.backupTeam(teamName);
|
||||
|
||||
await fs.rm(teamDir, { recursive: true, force: true });
|
||||
|
||||
const restored = await service.restoreIfNeeded();
|
||||
service.dispose();
|
||||
|
||||
expect(restored).toContain(teamName);
|
||||
|
||||
const restoredMembersMeta = JSON.parse(
|
||||
await fs.readFile(path.join(teamDir, 'members.meta.json'), 'utf8')
|
||||
);
|
||||
const restoredLaunchState = JSON.parse(
|
||||
await fs.readFile(path.join(teamDir, 'launch-state.json'), 'utf8')
|
||||
);
|
||||
const restoredLaunchSummary = JSON.parse(
|
||||
await fs.readFile(path.join(teamDir, 'launch-summary.json'), 'utf8')
|
||||
);
|
||||
const restoredTeamMeta = JSON.parse(
|
||||
await fs.readFile(path.join(teamDir, 'team.meta.json'), 'utf8')
|
||||
);
|
||||
const restoredRuntimeLaneIndex = JSON.parse(
|
||||
await fs.readFile(path.join(teamDir, '.opencode-runtime', 'lanes.json'), 'utf8')
|
||||
);
|
||||
const restoredRuntimeManifest = JSON.parse(
|
||||
await fs.readFile(path.join(runtimeLaneDir, 'runtime-store-manifest.json'), 'utf8')
|
||||
);
|
||||
|
||||
expect(restoredTeamMeta.providerId).toBe('codex');
|
||||
expect(restoredMembersMeta.members).toEqual(membersMeta.members);
|
||||
expect(restoredLaunchState.bootstrapExpectedMembers).toEqual(['alice']);
|
||||
expect(restoredLaunchState.members.tom.laneKind).toBe('secondary');
|
||||
expect(restoredLaunchState.members.tom.laneOwnerProviderId).toBe('opencode');
|
||||
expect(restoredLaunchSummary.mixedAware).toBe(true);
|
||||
expect(restoredLaunchSummary.teamLaunchState).toBe('partial_pending');
|
||||
expect(restoredRuntimeLaneIndex.lanes['secondary:opencode:tom'].state).toBe('active');
|
||||
expect(restoredRuntimeManifest.activeRunId).toBe('lane-run-1');
|
||||
});
|
||||
});
|
||||
|
|
@ -477,4 +477,49 @@ describe('TeamBootstrapStateReader', () => {
|
|||
kind: 'launch',
|
||||
});
|
||||
});
|
||||
|
||||
it('ignores stale terminal bootstrap-only pending snapshots when canonical launch state is missing', () => {
|
||||
const nowSpy = vi.spyOn(Date, 'now').mockReturnValue(Date.parse('2026-04-22T15:00:00.000Z'));
|
||||
|
||||
const preferred = choosePreferredLaunchSnapshot(
|
||||
{
|
||||
version: 2,
|
||||
teamName: 'atlas-hq-2',
|
||||
updatedAt: '2026-04-09T20:35:57.962Z',
|
||||
launchPhase: 'finished',
|
||||
expectedMembers: ['alice', 'jack'],
|
||||
members: {
|
||||
alice: {
|
||||
name: 'alice',
|
||||
launchState: 'runtime_pending_bootstrap',
|
||||
agentToolAccepted: true,
|
||||
runtimeAlive: false,
|
||||
bootstrapConfirmed: false,
|
||||
hardFailure: false,
|
||||
lastEvaluatedAt: '2026-04-09T20:35:57.962Z',
|
||||
},
|
||||
jack: {
|
||||
name: 'jack',
|
||||
launchState: 'runtime_pending_bootstrap',
|
||||
agentToolAccepted: true,
|
||||
runtimeAlive: false,
|
||||
bootstrapConfirmed: false,
|
||||
hardFailure: false,
|
||||
lastEvaluatedAt: '2026-04-09T20:35:57.962Z',
|
||||
},
|
||||
},
|
||||
summary: {
|
||||
confirmedCount: 0,
|
||||
pendingCount: 2,
|
||||
failedCount: 0,
|
||||
runtimeAlivePendingCount: 0,
|
||||
},
|
||||
teamLaunchState: 'partial_pending',
|
||||
},
|
||||
null
|
||||
);
|
||||
|
||||
expect(preferred).toBeNull();
|
||||
nowSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
257
test/main/services/team/TeamConfigReader.test.ts
Normal file
257
test/main/services/team/TeamConfigReader.test.ts
Normal file
|
|
@ -0,0 +1,257 @@
|
|||
import * as fs from 'fs/promises';
|
||||
import * as os from 'os';
|
||||
import * as path from 'path';
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const hoisted = vi.hoisted(() => ({
|
||||
teamsBase: '',
|
||||
}));
|
||||
|
||||
vi.mock('../../../../src/main/utils/pathDecoder', () => ({
|
||||
getTeamsBasePath: () => hoisted.teamsBase,
|
||||
}));
|
||||
|
||||
vi.mock('../../../../src/main/services/team/TeamFsWorkerClient', () => ({
|
||||
getTeamFsWorkerClient: () => ({
|
||||
isAvailable: () => false,
|
||||
}),
|
||||
}));
|
||||
|
||||
import { TeamConfigReader } from '../../../../src/main/services/team/TeamConfigReader';
|
||||
import { createPersistedLaunchSummaryProjection } from '../../../../src/main/services/team/TeamLaunchSummaryProjection';
|
||||
|
||||
describe('TeamConfigReader', () => {
|
||||
let tempDir = '';
|
||||
|
||||
beforeEach(async () => {
|
||||
tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'team-config-reader-'));
|
||||
hoisted.teamsBase = tempDir;
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
if (tempDir) {
|
||||
await fs.rm(tempDir, { recursive: true, force: true });
|
||||
}
|
||||
hoisted.teamsBase = '';
|
||||
});
|
||||
|
||||
it('uses compact launch summary projection when launch-state.json is oversized', async () => {
|
||||
const teamName = 'mixed-team';
|
||||
const teamDir = path.join(tempDir, teamName);
|
||||
await fs.mkdir(teamDir, { recursive: true });
|
||||
await fs.writeFile(
|
||||
path.join(teamDir, 'config.json'),
|
||||
JSON.stringify({
|
||||
name: 'Mixed Team',
|
||||
members: [{ name: 'team-lead', agentType: 'team-lead' }],
|
||||
}),
|
||||
'utf8'
|
||||
);
|
||||
await fs.writeFile(path.join(teamDir, 'launch-state.json'), 'x'.repeat(40 * 1024), 'utf8');
|
||||
await fs.writeFile(
|
||||
path.join(teamDir, 'launch-summary.json'),
|
||||
JSON.stringify(
|
||||
createPersistedLaunchSummaryProjection({
|
||||
version: 2,
|
||||
teamName,
|
||||
updatedAt: '2026-04-22T12:00:00.000Z',
|
||||
launchPhase: 'finished',
|
||||
expectedMembers: ['alice', 'bob'],
|
||||
bootstrapExpectedMembers: ['alice'],
|
||||
members: {
|
||||
alice: {
|
||||
name: 'alice',
|
||||
providerId: 'codex',
|
||||
laneId: 'primary',
|
||||
laneKind: 'primary',
|
||||
laneOwnerProviderId: 'codex',
|
||||
launchState: 'confirmed_alive',
|
||||
agentToolAccepted: true,
|
||||
runtimeAlive: true,
|
||||
bootstrapConfirmed: true,
|
||||
hardFailure: false,
|
||||
lastEvaluatedAt: '2026-04-22T12:00:00.000Z',
|
||||
},
|
||||
bob: {
|
||||
name: 'bob',
|
||||
providerId: 'opencode',
|
||||
laneId: 'secondary:opencode:bob',
|
||||
laneKind: 'secondary',
|
||||
laneOwnerProviderId: 'opencode',
|
||||
launchState: 'failed_to_start',
|
||||
agentToolAccepted: true,
|
||||
runtimeAlive: false,
|
||||
bootstrapConfirmed: false,
|
||||
hardFailure: true,
|
||||
hardFailureReason: 'Side lane failed',
|
||||
lastEvaluatedAt: '2026-04-22T12:00:00.000Z',
|
||||
},
|
||||
},
|
||||
summary: {
|
||||
confirmedCount: 1,
|
||||
pendingCount: 0,
|
||||
failedCount: 1,
|
||||
runtimeAlivePendingCount: 0,
|
||||
},
|
||||
teamLaunchState: 'partial_failure',
|
||||
} as never),
|
||||
null,
|
||||
2
|
||||
),
|
||||
'utf8'
|
||||
);
|
||||
await fs.writeFile(
|
||||
path.join(teamDir, 'bootstrap-state.json'),
|
||||
JSON.stringify({
|
||||
version: 1,
|
||||
teamName,
|
||||
runId: 'bootstrap-run-1',
|
||||
ownerPid: process.pid,
|
||||
startedAt: Date.parse('2026-04-22T12:01:00.000Z'),
|
||||
updatedAt: Date.parse('2026-04-22T12:01:00.000Z'),
|
||||
phase: 'spawning_members',
|
||||
members: [{ name: 'alice', status: 'pending' }],
|
||||
}),
|
||||
'utf8'
|
||||
);
|
||||
|
||||
const reader = new TeamConfigReader();
|
||||
const teams = await reader.listTeams();
|
||||
expect(teams).toHaveLength(1);
|
||||
expect(teams[0]).toMatchObject({
|
||||
teamName,
|
||||
displayName: 'Mixed Team',
|
||||
partialLaunchFailure: true,
|
||||
expectedMemberCount: 2,
|
||||
confirmedMemberCount: 1,
|
||||
missingMembers: ['bob'],
|
||||
teamLaunchState: 'partial_failure',
|
||||
confirmedCount: 1,
|
||||
pendingCount: 0,
|
||||
failedCount: 1,
|
||||
});
|
||||
});
|
||||
|
||||
it('does not invent a partial-failure summary from artifact counts for mixed-aware teams when canonical launch truth is unavailable', async () => {
|
||||
const teamName = 'mixed-aware-team';
|
||||
const teamDir = path.join(tempDir, teamName);
|
||||
await fs.mkdir(path.join(teamDir, 'inboxes'), { recursive: true });
|
||||
await fs.writeFile(
|
||||
path.join(teamDir, 'config.json'),
|
||||
JSON.stringify({
|
||||
name: 'Mixed Aware Team',
|
||||
leadSessionId: 'lead-session-1',
|
||||
members: [{ name: 'team-lead', agentType: 'team-lead' }],
|
||||
}),
|
||||
'utf8'
|
||||
);
|
||||
await fs.writeFile(
|
||||
path.join(teamDir, 'team.meta.json'),
|
||||
JSON.stringify({
|
||||
version: 1,
|
||||
cwd: tempDir,
|
||||
providerId: 'codex',
|
||||
createdAt: Date.now(),
|
||||
}),
|
||||
'utf8'
|
||||
);
|
||||
await fs.writeFile(
|
||||
path.join(teamDir, 'members.meta.json'),
|
||||
JSON.stringify({
|
||||
version: 1,
|
||||
members: [
|
||||
{ name: 'alice', providerId: 'codex', role: 'reviewer' },
|
||||
{ name: 'tom', providerId: 'opencode', role: 'developer' },
|
||||
],
|
||||
}),
|
||||
'utf8'
|
||||
);
|
||||
await fs.writeFile(path.join(teamDir, 'inboxes', 'alice.json'), '{}', 'utf8');
|
||||
|
||||
const reader = new TeamConfigReader();
|
||||
const teams = await reader.listTeams();
|
||||
expect(teams).toHaveLength(1);
|
||||
expect(teams[0]).toMatchObject({
|
||||
teamName,
|
||||
displayName: 'Mixed Aware Team',
|
||||
memberCount: 2,
|
||||
});
|
||||
expect(teams[0]?.partialLaunchFailure).toBeUndefined();
|
||||
expect(teams[0]?.teamLaunchState).toBeUndefined();
|
||||
expect(teams[0]?.missingMembers).toBeUndefined();
|
||||
});
|
||||
|
||||
it('does not let a removed base member hide an active auto-suffixed teammate in team summaries', async () => {
|
||||
const teamName = 'suffix-team';
|
||||
const teamDir = path.join(tempDir, teamName);
|
||||
await fs.mkdir(teamDir, { recursive: true });
|
||||
await fs.writeFile(
|
||||
path.join(teamDir, 'config.json'),
|
||||
JSON.stringify({
|
||||
name: 'Suffix Team',
|
||||
members: [{ name: 'team-lead', agentType: 'team-lead' }],
|
||||
}),
|
||||
'utf8'
|
||||
);
|
||||
await fs.writeFile(
|
||||
path.join(teamDir, 'members.meta.json'),
|
||||
JSON.stringify({
|
||||
version: 1,
|
||||
members: [
|
||||
{ name: 'alice', role: 'developer', removedAt: Date.now() - 60_000 },
|
||||
{ name: 'alice-2', role: 'reviewer' },
|
||||
],
|
||||
}),
|
||||
'utf8'
|
||||
);
|
||||
|
||||
const reader = new TeamConfigReader();
|
||||
const teams = await reader.listTeams();
|
||||
expect(teams).toHaveLength(1);
|
||||
expect(teams[0]).toMatchObject({
|
||||
teamName,
|
||||
displayName: 'Suffix Team',
|
||||
memberCount: 1,
|
||||
members: [{ name: 'alice-2', role: 'reviewer' }],
|
||||
});
|
||||
});
|
||||
|
||||
it('counts only active non-lead teammates for draft team summaries', async () => {
|
||||
const teamName = 'draft-summary-team';
|
||||
const teamDir = path.join(tempDir, teamName);
|
||||
await fs.mkdir(teamDir, { recursive: true });
|
||||
await fs.writeFile(
|
||||
path.join(teamDir, 'team.meta.json'),
|
||||
JSON.stringify({
|
||||
version: 1,
|
||||
cwd: tempDir,
|
||||
displayName: 'Draft Summary Team',
|
||||
createdAt: Date.parse('2026-04-22T12:00:00.000Z'),
|
||||
}),
|
||||
'utf8'
|
||||
);
|
||||
await fs.writeFile(
|
||||
path.join(teamDir, 'members.meta.json'),
|
||||
JSON.stringify({
|
||||
version: 1,
|
||||
members: [
|
||||
{ name: 'team-lead', agentType: 'team-lead' },
|
||||
{ name: 'alice', removedAt: Date.now() - 60_000 },
|
||||
{ name: 'bob', role: 'developer' },
|
||||
],
|
||||
}),
|
||||
'utf8'
|
||||
);
|
||||
|
||||
const reader = new TeamConfigReader();
|
||||
const teams = await reader.listTeams();
|
||||
expect(teams).toHaveLength(1);
|
||||
expect(teams[0]).toMatchObject({
|
||||
teamName,
|
||||
displayName: 'Draft Summary Team',
|
||||
memberCount: 1,
|
||||
pendingCreate: true,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -9,6 +9,7 @@ import { encodePath, setClaudeBasePathOverride } from '../../../../src/main/util
|
|||
import { TeamConfigReader } from '../../../../src/main/services/team/TeamConfigReader';
|
||||
import { buildTaskChangePresenceDescriptor } from '../../../../src/main/services/team/taskChangePresenceUtils';
|
||||
import { TeamDataService } from '../../../../src/main/services/team/TeamDataService';
|
||||
import type { TeamMetaFile } from '../../../../src/main/services/team/TeamMetaStore';
|
||||
|
||||
import type {
|
||||
InboxMessage,
|
||||
|
|
@ -333,6 +334,7 @@ function createGetTeamDataHarness(options: {
|
|||
listInboxNames?: () => Promise<string[]>;
|
||||
getMessages?: () => Promise<InboxMessage[]>;
|
||||
getMembers?: () => Promise<TeamConfig['members']>;
|
||||
getTeamMeta?: () => Promise<TeamMetaFile | null>;
|
||||
getState?: () => Promise<KanbanState>;
|
||||
readMessages?: () => Promise<InboxMessage[]>;
|
||||
resolveMembers?: (
|
||||
|
|
@ -367,6 +369,11 @@ function createGetTeamDataHarness(options: {
|
|||
(async () => {
|
||||
return [] as TeamConfig['members'];
|
||||
});
|
||||
const getTeamMeta =
|
||||
options.getTeamMeta ??
|
||||
(async () => {
|
||||
return null;
|
||||
});
|
||||
const getState =
|
||||
options.getState ??
|
||||
(async () => {
|
||||
|
|
@ -395,6 +402,9 @@ function createGetTeamDataHarness(options: {
|
|||
const membersMetaStore = {
|
||||
getMembers: vi.fn(getMembers),
|
||||
};
|
||||
const teamMetaStore = {
|
||||
getMeta: vi.fn(getTeamMeta),
|
||||
};
|
||||
const sentMessagesStore = {
|
||||
readMessages: vi.fn(readMessages),
|
||||
};
|
||||
|
|
@ -431,7 +441,7 @@ function createGetTeamDataHarness(options: {
|
|||
},
|
||||
}) as never) as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
teamMetaStore as never,
|
||||
advisoryService as never
|
||||
);
|
||||
|
||||
|
|
@ -441,6 +451,7 @@ function createGetTeamDataHarness(options: {
|
|||
taskReader,
|
||||
inboxReader,
|
||||
membersMetaStore,
|
||||
teamMetaStore,
|
||||
sentMessagesStore,
|
||||
resolveMembersSpy,
|
||||
kanbanManager,
|
||||
|
|
@ -630,6 +641,262 @@ describe('TeamDataService', () => {
|
|||
expect(writtenMembers.find((member) => member.name === 'bob')?.isolation).toBeUndefined();
|
||||
});
|
||||
|
||||
it('persists member-level provider backend and fast mode during 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: 'runtime-team', reviewers: [], tasks: {} })),
|
||||
} as never,
|
||||
{} as never,
|
||||
membersMetaStore,
|
||||
{ readMessages: vi.fn(async () => []) } as never,
|
||||
(() => ({ processes: { listProcesses: vi.fn(async () => []) } }) as never) as never,
|
||||
{} as never,
|
||||
{ getMeta: vi.fn(async () => ({ providerId: 'codex' })) } as never
|
||||
);
|
||||
|
||||
await service.replaceMembers('runtime-team', {
|
||||
members: [
|
||||
{
|
||||
name: 'alice',
|
||||
providerId: 'codex',
|
||||
providerBackendId: 'codex-native',
|
||||
model: 'gpt-5.4',
|
||||
effort: 'high',
|
||||
fastMode: 'on',
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(writeMembers).toHaveBeenCalledWith(
|
||||
'runtime-team',
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
name: 'alice',
|
||||
providerId: 'codex',
|
||||
providerBackendId: 'codex-native',
|
||||
model: 'gpt-5.4',
|
||||
effort: 'high',
|
||||
fastMode: 'on',
|
||||
}),
|
||||
])
|
||||
);
|
||||
});
|
||||
|
||||
it('allows multiple OpenCode teammates in replaceMembers drafts before they are persisted', 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: 'runtime-team', reviewers: [], tasks: {} })),
|
||||
} as never,
|
||||
{} as never,
|
||||
membersMetaStore,
|
||||
{ readMessages: vi.fn(async () => []) } as never,
|
||||
(() => ({ processes: { listProcesses: vi.fn(async () => []) } }) as never) as never,
|
||||
{} as never,
|
||||
{ getMeta: vi.fn(async () => ({ providerId: 'codex' })) } as never
|
||||
);
|
||||
|
||||
await expect(
|
||||
service.replaceMembers('runtime-team', {
|
||||
members: [
|
||||
{ name: 'alice', providerId: 'opencode', model: 'minimax-m2.5-free' },
|
||||
{ name: 'bob', providerId: 'opencode', model: 'nemotron-3-super-free' },
|
||||
],
|
||||
})
|
||||
).resolves.toBeUndefined();
|
||||
|
||||
expect(writeMembers).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('blocks live addMember on a running mixed team', async () => {
|
||||
const writeMembers = vi.fn(async () => {});
|
||||
const membersMetaStore = {
|
||||
getMembers: vi.fn(async () => [
|
||||
{
|
||||
name: 'alice',
|
||||
role: 'Reviewer',
|
||||
providerId: 'opencode',
|
||||
model: 'minimax-m2.5-free',
|
||||
agentType: 'general-purpose',
|
||||
},
|
||||
]),
|
||||
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: 'mixed-team', reviewers: [], tasks: {} })),
|
||||
} as never,
|
||||
{} as never,
|
||||
membersMetaStore,
|
||||
{ readMessages: vi.fn(async () => []) } as never,
|
||||
(() =>
|
||||
({
|
||||
processes: {
|
||||
listProcesses: vi.fn(async () => [
|
||||
{
|
||||
id: 'run-1',
|
||||
label: 'mixed-team',
|
||||
pid: 123,
|
||||
registeredAt: new Date().toISOString(),
|
||||
},
|
||||
]),
|
||||
},
|
||||
}) as never) as never,
|
||||
{} as never,
|
||||
{ getMeta: vi.fn(async () => ({ providerId: 'codex' })) } as never
|
||||
);
|
||||
|
||||
await expect(
|
||||
service.addMember('mixed-team', {
|
||||
name: 'bob',
|
||||
role: 'Developer',
|
||||
providerId: 'codex',
|
||||
model: 'gpt-5.4',
|
||||
effort: 'medium',
|
||||
})
|
||||
).rejects.toThrow(
|
||||
'Live roster mutation on a running mixed team is not supported in V1. Stop the team, edit the roster, then relaunch.'
|
||||
);
|
||||
|
||||
expect(writeMembers).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('blocks live replaceMembers on a running mixed team', async () => {
|
||||
const writeMembers = vi.fn(async () => {});
|
||||
const membersMetaStore = {
|
||||
getMembers: vi.fn(async () => [
|
||||
{
|
||||
name: 'alice',
|
||||
role: 'Reviewer',
|
||||
providerId: 'opencode',
|
||||
model: 'minimax-m2.5-free',
|
||||
agentType: 'general-purpose',
|
||||
},
|
||||
]),
|
||||
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: 'mixed-team', reviewers: [], tasks: {} })),
|
||||
} as never,
|
||||
{} as never,
|
||||
membersMetaStore,
|
||||
{ readMessages: vi.fn(async () => []) } as never,
|
||||
(() =>
|
||||
({
|
||||
processes: {
|
||||
listProcesses: vi.fn(async () => [
|
||||
{
|
||||
id: 'run-1',
|
||||
label: 'mixed-team',
|
||||
pid: 123,
|
||||
registeredAt: new Date().toISOString(),
|
||||
},
|
||||
]),
|
||||
},
|
||||
}) as never) as never,
|
||||
{} as never,
|
||||
{ getMeta: vi.fn(async () => ({ providerId: 'codex' })) } as never
|
||||
);
|
||||
|
||||
await expect(
|
||||
service.replaceMembers('mixed-team', {
|
||||
members: [{ name: 'alice', providerId: 'codex', model: 'gpt-5.4', effort: 'high' }],
|
||||
})
|
||||
).rejects.toThrow(
|
||||
'Live roster mutation on a running mixed team is not supported in V1. Stop the team, edit the roster, then relaunch.'
|
||||
);
|
||||
|
||||
expect(writeMembers).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('allows live removeMember for an OpenCode-owned member on a running mixed team', async () => {
|
||||
const writeMembers = vi.fn(async () => {});
|
||||
const membersMetaStore = {
|
||||
getMembers: vi.fn(async () => [
|
||||
{
|
||||
name: 'alice',
|
||||
role: 'Reviewer',
|
||||
providerId: 'opencode',
|
||||
model: 'minimax-m2.5-free',
|
||||
agentType: 'general-purpose',
|
||||
},
|
||||
]),
|
||||
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: 'mixed-team', reviewers: [], tasks: {} })),
|
||||
} as never,
|
||||
{} as never,
|
||||
membersMetaStore,
|
||||
{ readMessages: vi.fn(async () => []) } as never,
|
||||
(() =>
|
||||
({
|
||||
processes: {
|
||||
listProcesses: vi.fn(async () => [
|
||||
{
|
||||
id: 'run-1',
|
||||
label: 'mixed-team',
|
||||
pid: 123,
|
||||
registeredAt: new Date().toISOString(),
|
||||
},
|
||||
]),
|
||||
},
|
||||
}) as never) as never,
|
||||
{} as never,
|
||||
{ getMeta: vi.fn(async () => ({ providerId: 'codex' })) } as never
|
||||
);
|
||||
|
||||
await expect(service.removeMember('mixed-team', 'alice')).resolves.toBeUndefined();
|
||||
|
||||
expect(writeMembers).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('does not carry over agentId from a previously removed member with the same name', async () => {
|
||||
const writeMembers = vi.fn(async () => {});
|
||||
const membersMetaStore = {
|
||||
|
|
@ -3968,6 +4235,104 @@ describe('TeamDataService', () => {
|
|||
]);
|
||||
});
|
||||
|
||||
it('synthesizes a team lead from team meta when config and members meta have no lead entry', async () => {
|
||||
const harness = createGetTeamDataHarness({
|
||||
config: {
|
||||
name: 'My team',
|
||||
projectPath: '/repo',
|
||||
members: [
|
||||
{
|
||||
name: 'alice',
|
||||
role: 'Reviewer',
|
||||
providerId: 'codex',
|
||||
model: 'gpt-5.4',
|
||||
},
|
||||
],
|
||||
},
|
||||
getTeamMeta: async () => ({
|
||||
version: 1,
|
||||
cwd: '/repo',
|
||||
providerId: 'codex',
|
||||
model: 'gpt-5.4',
|
||||
effort: 'medium',
|
||||
createdAt: Date.now(),
|
||||
}),
|
||||
resolveMembers: () => [buildResolvedMember('alice')],
|
||||
});
|
||||
|
||||
const data = await harness.service.getTeamData('my-team');
|
||||
|
||||
expect(data.members[0]).toMatchObject({
|
||||
name: 'team-lead',
|
||||
agentType: 'team-lead',
|
||||
role: 'Team Lead',
|
||||
providerId: 'codex',
|
||||
model: 'gpt-5.4',
|
||||
effort: 'medium',
|
||||
cwd: '/repo',
|
||||
});
|
||||
expect(data.members[1]).toMatchObject({
|
||||
name: 'alice',
|
||||
});
|
||||
expect(harness.teamMetaStore.getMeta).toHaveBeenCalledWith('my-team');
|
||||
});
|
||||
|
||||
it('surfaces lane-aware member runtime truth alongside the synthesized lead snapshot', async () => {
|
||||
const harness = createGetTeamDataHarness({
|
||||
config: {
|
||||
name: 'My team',
|
||||
projectPath: '/repo',
|
||||
members: [{ name: 'alice', role: 'Developer', providerId: 'opencode' }],
|
||||
},
|
||||
getTeamMeta: async () => ({
|
||||
version: 1,
|
||||
cwd: '/repo',
|
||||
providerId: 'codex',
|
||||
providerBackendId: 'codex-native',
|
||||
model: 'gpt-5.4',
|
||||
effort: 'medium',
|
||||
fastMode: 'off',
|
||||
createdAt: Date.now(),
|
||||
}),
|
||||
resolveMembers: () => [
|
||||
{
|
||||
...buildResolvedMember('alice'),
|
||||
providerId: 'opencode',
|
||||
providerBackendId: 'opencode-cli',
|
||||
model: 'minimax-m2.5-free',
|
||||
laneId: 'secondary:opencode:alice',
|
||||
laneKind: 'secondary',
|
||||
laneOwnerProviderId: 'opencode',
|
||||
selectedFastMode: 'inherit',
|
||||
resolvedFastMode: false,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const data = await harness.service.getTeamData('my-team');
|
||||
|
||||
expect(data.members[0]).toMatchObject({
|
||||
name: 'team-lead',
|
||||
agentType: 'team-lead',
|
||||
providerId: 'codex',
|
||||
providerBackendId: 'codex-native',
|
||||
model: 'gpt-5.4',
|
||||
effort: 'medium',
|
||||
cwd: '/repo',
|
||||
});
|
||||
expect(data.members[1]).toMatchObject({
|
||||
name: 'alice',
|
||||
providerId: 'opencode',
|
||||
providerBackendId: 'opencode-cli',
|
||||
model: 'minimax-m2.5-free',
|
||||
laneId: 'secondary:opencode:alice',
|
||||
laneKind: 'secondary',
|
||||
laneOwnerProviderId: 'opencode',
|
||||
selectedFastMode: 'inherit',
|
||||
resolvedFastMode: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('degrades advisory lookup failure to warning and still completes the snapshot', async () => {
|
||||
const harness = createGetTeamDataHarness({
|
||||
resolveMembers: () => [buildResolvedMember('alice')],
|
||||
|
|
@ -4145,12 +4510,19 @@ describe('TeamDataService', () => {
|
|||
buildDefaultTeamConfig(),
|
||||
metaMembers,
|
||||
inboxNames,
|
||||
[
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
id: 'task-1',
|
||||
subject: 'Investigate rollout',
|
||||
}),
|
||||
]
|
||||
]),
|
||||
expect.objectContaining({
|
||||
launchSnapshot: null,
|
||||
leadProviderId: undefined,
|
||||
leadProviderBackendId: undefined,
|
||||
leadFastMode: undefined,
|
||||
leadResolvedFastMode: undefined,
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
|
|
|
|||
215
test/main/services/team/TeamFsWorker.integration.test.ts
Normal file
215
test/main/services/team/TeamFsWorker.integration.test.ts
Normal file
|
|
@ -0,0 +1,215 @@
|
|||
import * as fs from 'fs/promises';
|
||||
import * as os from 'os';
|
||||
import * as path from 'path';
|
||||
import { Worker } from 'worker_threads';
|
||||
|
||||
import { afterEach, describe, expect, it } from 'vitest';
|
||||
|
||||
import { createPersistedLaunchSummaryProjection } from '../../../../src/main/services/team/TeamLaunchSummaryProjection';
|
||||
|
||||
interface WorkerResponse {
|
||||
id: string;
|
||||
ok: boolean;
|
||||
result?: unknown;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
function getWorkerPath(): string {
|
||||
return path.join(process.cwd(), 'dist-electron', 'main', 'team-fs-worker.cjs');
|
||||
}
|
||||
|
||||
function callListTeams(worker: Worker, teamsDir: string): Promise<unknown[]> {
|
||||
const requestId = `req-${Date.now()}`;
|
||||
return new Promise((resolve, reject) => {
|
||||
const timeout = setTimeout(() => {
|
||||
cleanup();
|
||||
reject(new Error('team-fs-worker test timed out'));
|
||||
}, 10_000);
|
||||
|
||||
const cleanup = () => {
|
||||
clearTimeout(timeout);
|
||||
worker.off('message', onMessage);
|
||||
worker.off('error', onError);
|
||||
};
|
||||
|
||||
const onError = (error: Error) => {
|
||||
cleanup();
|
||||
reject(error);
|
||||
};
|
||||
|
||||
const onMessage = (message: WorkerResponse) => {
|
||||
if (!message || message.id !== requestId) {
|
||||
return;
|
||||
}
|
||||
cleanup();
|
||||
if (!message.ok) {
|
||||
reject(new Error(message.error || 'team-fs-worker returned an unknown error'));
|
||||
return;
|
||||
}
|
||||
resolve(Array.isArray(message.result) ? message.result : []);
|
||||
};
|
||||
|
||||
worker.on('message', onMessage);
|
||||
worker.on('error', onError);
|
||||
worker.postMessage({
|
||||
id: requestId,
|
||||
op: 'listTeams',
|
||||
payload: {
|
||||
teamsDir,
|
||||
largeConfigBytes: 8 * 1024,
|
||||
configHeadBytes: 4 * 1024,
|
||||
maxConfigBytes: 256 * 1024,
|
||||
maxConfigReadMs: 5_000,
|
||||
maxMembersMetaBytes: 256 * 1024,
|
||||
maxSessionHistoryInSummary: 10,
|
||||
maxProjectPathHistoryInSummary: 10,
|
||||
concurrency: 2,
|
||||
},
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
describe('team-fs-worker integration', () => {
|
||||
let tempDir = '';
|
||||
|
||||
afterEach(async () => {
|
||||
if (tempDir) {
|
||||
await fs.rm(tempDir, { recursive: true, force: true });
|
||||
tempDir = '';
|
||||
}
|
||||
});
|
||||
|
||||
it('uses launch-summary.json when launch-state.json is too large for mixed-team summaries', async () => {
|
||||
const workerPath = getWorkerPath();
|
||||
tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'team-fs-worker-'));
|
||||
const teamName = 'mixed-worker-team';
|
||||
const teamDir = path.join(tempDir, teamName);
|
||||
await fs.mkdir(teamDir, { recursive: true });
|
||||
|
||||
await fs.writeFile(
|
||||
path.join(teamDir, 'config.json'),
|
||||
JSON.stringify({
|
||||
name: 'Mixed Worker Team',
|
||||
members: [{ name: 'team-lead', agentType: 'team-lead' }],
|
||||
}),
|
||||
'utf8'
|
||||
);
|
||||
await fs.writeFile(path.join(teamDir, 'launch-state.json'), 'x'.repeat(40 * 1024), 'utf8');
|
||||
await fs.writeFile(
|
||||
path.join(teamDir, 'launch-summary.json'),
|
||||
JSON.stringify(
|
||||
createPersistedLaunchSummaryProjection({
|
||||
version: 2,
|
||||
teamName,
|
||||
updatedAt: '2026-04-22T12:00:00.000Z',
|
||||
launchPhase: 'finished',
|
||||
expectedMembers: ['alice', 'bob'],
|
||||
bootstrapExpectedMembers: ['alice'],
|
||||
members: {
|
||||
alice: {
|
||||
name: 'alice',
|
||||
providerId: 'codex',
|
||||
laneId: 'primary',
|
||||
laneKind: 'primary',
|
||||
laneOwnerProviderId: 'codex',
|
||||
launchState: 'confirmed_alive',
|
||||
agentToolAccepted: true,
|
||||
runtimeAlive: true,
|
||||
bootstrapConfirmed: true,
|
||||
hardFailure: false,
|
||||
lastEvaluatedAt: '2026-04-22T12:00:00.000Z',
|
||||
},
|
||||
bob: {
|
||||
name: 'bob',
|
||||
providerId: 'opencode',
|
||||
laneId: 'secondary:opencode:bob',
|
||||
laneKind: 'secondary',
|
||||
laneOwnerProviderId: 'opencode',
|
||||
launchState: 'failed_to_start',
|
||||
agentToolAccepted: true,
|
||||
runtimeAlive: false,
|
||||
bootstrapConfirmed: false,
|
||||
hardFailure: true,
|
||||
hardFailureReason: 'Side lane failed',
|
||||
lastEvaluatedAt: '2026-04-22T12:00:00.000Z',
|
||||
},
|
||||
},
|
||||
summary: {
|
||||
confirmedCount: 1,
|
||||
pendingCount: 0,
|
||||
failedCount: 1,
|
||||
runtimeAlivePendingCount: 0,
|
||||
},
|
||||
teamLaunchState: 'partial_failure',
|
||||
} as never),
|
||||
null,
|
||||
2
|
||||
),
|
||||
'utf8'
|
||||
);
|
||||
|
||||
const worker = new Worker(workerPath);
|
||||
try {
|
||||
const teams = (await callListTeams(worker, tempDir)) as Array<Record<string, unknown>>;
|
||||
expect(teams).toHaveLength(1);
|
||||
expect(teams[0]).toMatchObject({
|
||||
teamName,
|
||||
displayName: 'Mixed Worker Team',
|
||||
partialLaunchFailure: true,
|
||||
expectedMemberCount: 2,
|
||||
confirmedMemberCount: 1,
|
||||
missingMembers: ['bob'],
|
||||
teamLaunchState: 'partial_failure',
|
||||
confirmedCount: 1,
|
||||
pendingCount: 0,
|
||||
failedCount: 1,
|
||||
});
|
||||
} finally {
|
||||
await worker.terminate();
|
||||
}
|
||||
});
|
||||
|
||||
it('ignores removed and lead members when draft-team worker summary counts members', async () => {
|
||||
const workerPath = getWorkerPath();
|
||||
tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'team-fs-worker-'));
|
||||
const teamName = 'draft-worker-team';
|
||||
const teamDir = path.join(tempDir, teamName);
|
||||
await fs.mkdir(teamDir, { recursive: true });
|
||||
|
||||
await fs.writeFile(
|
||||
path.join(teamDir, 'team.meta.json'),
|
||||
JSON.stringify({
|
||||
version: 1,
|
||||
cwd: tempDir,
|
||||
displayName: 'Draft Worker Team',
|
||||
createdAt: Date.parse('2026-04-22T12:00:00.000Z'),
|
||||
}),
|
||||
'utf8'
|
||||
);
|
||||
await fs.writeFile(
|
||||
path.join(teamDir, 'members.meta.json'),
|
||||
JSON.stringify({
|
||||
version: 1,
|
||||
members: [
|
||||
{ name: 'team-lead', agentType: 'team-lead' },
|
||||
{ name: 'alice', removedAt: Date.parse('2026-04-22T12:01:00.000Z') },
|
||||
{ name: 'bob', role: 'developer' },
|
||||
],
|
||||
}),
|
||||
'utf8'
|
||||
);
|
||||
|
||||
const worker = new Worker(workerPath);
|
||||
try {
|
||||
const teams = (await callListTeams(worker, tempDir)) as Array<Record<string, unknown>>;
|
||||
expect(teams).toHaveLength(1);
|
||||
expect(teams[0]).toMatchObject({
|
||||
teamName,
|
||||
displayName: 'Draft Worker Team',
|
||||
memberCount: 1,
|
||||
});
|
||||
} finally {
|
||||
await worker.terminate();
|
||||
}
|
||||
});
|
||||
});
|
||||
177
test/main/services/team/TeamLaunchSummaryProjection.test.ts
Normal file
177
test/main/services/team/TeamLaunchSummaryProjection.test.ts
Normal file
|
|
@ -0,0 +1,177 @@
|
|||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
choosePreferredLaunchStateSummary,
|
||||
createPersistedLaunchSummaryProjection,
|
||||
shouldSuppressLegacyLaunchArtifactHeuristic,
|
||||
} from '../../../../src/main/services/team/TeamLaunchSummaryProjection';
|
||||
|
||||
describe('TeamLaunchSummaryProjection', () => {
|
||||
it('ignores stale terminal bootstrap-only pending summaries when canonical launch truth is missing', () => {
|
||||
const summary = choosePreferredLaunchStateSummary({
|
||||
bootstrapSnapshot: {
|
||||
version: 2,
|
||||
teamName: 'atlas-hq-2',
|
||||
updatedAt: '2026-04-09T20:35:57.962Z',
|
||||
launchPhase: 'finished',
|
||||
expectedMembers: ['alice', 'jack'],
|
||||
members: {
|
||||
alice: {
|
||||
name: 'alice',
|
||||
launchState: 'runtime_pending_bootstrap',
|
||||
agentToolAccepted: true,
|
||||
runtimeAlive: false,
|
||||
bootstrapConfirmed: false,
|
||||
hardFailure: false,
|
||||
lastEvaluatedAt: '2026-04-09T20:35:57.962Z',
|
||||
},
|
||||
jack: {
|
||||
name: 'jack',
|
||||
launchState: 'runtime_pending_bootstrap',
|
||||
agentToolAccepted: true,
|
||||
runtimeAlive: false,
|
||||
bootstrapConfirmed: false,
|
||||
hardFailure: false,
|
||||
lastEvaluatedAt: '2026-04-09T20:35:57.962Z',
|
||||
},
|
||||
},
|
||||
summary: {
|
||||
confirmedCount: 0,
|
||||
pendingCount: 2,
|
||||
failedCount: 0,
|
||||
runtimeAlivePendingCount: 0,
|
||||
},
|
||||
teamLaunchState: 'partial_pending',
|
||||
} as never,
|
||||
launchSummaryProjection: null,
|
||||
});
|
||||
|
||||
expect(summary).toBeNull();
|
||||
});
|
||||
|
||||
it('prefers a mixed-aware persisted summary projection over a newer but poorer bootstrap snapshot', () => {
|
||||
const bootstrapSnapshot = {
|
||||
version: 2,
|
||||
teamName: 'mixed-team',
|
||||
updatedAt: '2026-04-22T12:05:00.000Z',
|
||||
launchPhase: 'active',
|
||||
expectedMembers: ['alice'],
|
||||
members: {
|
||||
alice: {
|
||||
name: 'alice',
|
||||
providerId: 'codex',
|
||||
launchState: 'starting',
|
||||
agentToolAccepted: false,
|
||||
runtimeAlive: false,
|
||||
bootstrapConfirmed: false,
|
||||
hardFailure: false,
|
||||
lastEvaluatedAt: '2026-04-22T12:05:00.000Z',
|
||||
},
|
||||
},
|
||||
summary: {
|
||||
confirmedCount: 0,
|
||||
pendingCount: 1,
|
||||
failedCount: 0,
|
||||
runtimeAlivePendingCount: 0,
|
||||
},
|
||||
teamLaunchState: 'partial_pending',
|
||||
} as const;
|
||||
|
||||
const mixedSnapshot = {
|
||||
version: 2,
|
||||
teamName: 'mixed-team',
|
||||
updatedAt: '2026-04-22T12:00:00.000Z',
|
||||
launchPhase: 'finished',
|
||||
expectedMembers: ['alice', 'bob'],
|
||||
bootstrapExpectedMembers: ['alice'],
|
||||
members: {
|
||||
alice: {
|
||||
name: 'alice',
|
||||
providerId: 'codex',
|
||||
laneId: 'primary',
|
||||
laneKind: 'primary',
|
||||
laneOwnerProviderId: 'codex',
|
||||
launchState: 'confirmed_alive',
|
||||
agentToolAccepted: true,
|
||||
runtimeAlive: true,
|
||||
bootstrapConfirmed: true,
|
||||
hardFailure: false,
|
||||
lastEvaluatedAt: '2026-04-22T12:00:00.000Z',
|
||||
},
|
||||
bob: {
|
||||
name: 'bob',
|
||||
providerId: 'opencode',
|
||||
laneId: 'secondary:opencode:bob',
|
||||
laneKind: 'secondary',
|
||||
laneOwnerProviderId: 'opencode',
|
||||
launchState: 'failed_to_start',
|
||||
agentToolAccepted: true,
|
||||
runtimeAlive: false,
|
||||
bootstrapConfirmed: false,
|
||||
hardFailure: true,
|
||||
hardFailureReason: 'Side lane failed',
|
||||
lastEvaluatedAt: '2026-04-22T12:00:00.000Z',
|
||||
},
|
||||
},
|
||||
summary: {
|
||||
confirmedCount: 1,
|
||||
pendingCount: 0,
|
||||
failedCount: 1,
|
||||
runtimeAlivePendingCount: 0,
|
||||
},
|
||||
teamLaunchState: 'partial_failure',
|
||||
} as const;
|
||||
|
||||
const summary = choosePreferredLaunchStateSummary({
|
||||
bootstrapSnapshot: bootstrapSnapshot as never,
|
||||
launchSummaryProjection: createPersistedLaunchSummaryProjection(mixedSnapshot as never),
|
||||
});
|
||||
|
||||
expect(summary).toMatchObject({
|
||||
partialLaunchFailure: true,
|
||||
expectedMemberCount: 2,
|
||||
confirmedMemberCount: 1,
|
||||
missingMembers: ['bob'],
|
||||
teamLaunchState: 'partial_failure',
|
||||
confirmedCount: 1,
|
||||
pendingCount: 0,
|
||||
failedCount: 1,
|
||||
});
|
||||
});
|
||||
|
||||
it('suppresses legacy artifact-count launch heuristics for mixed-aware desired rosters', () => {
|
||||
expect(
|
||||
shouldSuppressLegacyLaunchArtifactHeuristic({
|
||||
leadProviderId: 'codex',
|
||||
members: [
|
||||
{ name: 'alice', providerId: 'codex' },
|
||||
{ name: 'tom', providerId: 'opencode' },
|
||||
],
|
||||
})
|
||||
).toBe(true);
|
||||
|
||||
expect(
|
||||
shouldSuppressLegacyLaunchArtifactHeuristic({
|
||||
leadProviderId: 'opencode',
|
||||
members: [{ name: 'alice', providerId: 'codex' }],
|
||||
})
|
||||
).toBe(true);
|
||||
|
||||
expect(
|
||||
shouldSuppressLegacyLaunchArtifactHeuristic({
|
||||
leadProviderId: 'codex',
|
||||
members: [
|
||||
{ name: 'alice', providerId: 'opencode' },
|
||||
{ name: 'tom', providerId: 'opencode' },
|
||||
],
|
||||
})
|
||||
).toBe(true);
|
||||
|
||||
expect(
|
||||
shouldSuppressLegacyLaunchArtifactHeuristic({
|
||||
leadProviderId: 'codex',
|
||||
members: [{ name: 'alice', providerId: 'codex' }],
|
||||
})
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
|
@ -202,6 +202,30 @@ describe('TeamMemberResolver', () => {
|
|||
expect(names).not.toContain('ops.bot');
|
||||
});
|
||||
|
||||
it('does not let a removed base member hide an active suffixed teammate', () => {
|
||||
const resolver = new TeamMemberResolver();
|
||||
const config: TeamConfig = {
|
||||
name: 'Team',
|
||||
members: [
|
||||
{ name: 'team-lead', agentType: 'team-lead', role: 'lead' },
|
||||
{ name: 'alice-2', agentType: 'general-purpose' },
|
||||
],
|
||||
};
|
||||
const metaMembers: TeamConfig['members'] = [
|
||||
{
|
||||
name: 'alice',
|
||||
agentType: 'general-purpose',
|
||||
removedAt: 1715000000000,
|
||||
},
|
||||
];
|
||||
|
||||
const members = resolver.resolveMembers(config, metaMembers, [], []);
|
||||
const names = members.map((member) => member.name);
|
||||
|
||||
expect(names).toContain('alice-2');
|
||||
expect(names).toContain('alice');
|
||||
});
|
||||
|
||||
it('sets currentTaskId for in_progress task', () => {
|
||||
const resolver = new TeamMemberResolver();
|
||||
const config: TeamConfig = {
|
||||
|
|
|
|||
87
test/main/services/team/TeamMembersMetaStore.test.ts
Normal file
87
test/main/services/team/TeamMembersMetaStore.test.ts
Normal file
|
|
@ -0,0 +1,87 @@
|
|||
import * as fs from 'fs/promises';
|
||||
import * as os from 'os';
|
||||
import * as path from 'path';
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const hoisted = vi.hoisted(() => ({
|
||||
teamsBase: '',
|
||||
}));
|
||||
|
||||
vi.mock('@main/utils/pathDecoder', () => ({
|
||||
getTeamsBasePath: () => hoisted.teamsBase,
|
||||
}));
|
||||
|
||||
import { TeamMembersMetaStore } from '../../../../src/main/services/team/TeamMembersMetaStore';
|
||||
|
||||
describe('TeamMembersMetaStore', () => {
|
||||
let tempDir = '';
|
||||
|
||||
beforeEach(async () => {
|
||||
tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'team-members-meta-store-'));
|
||||
hoisted.teamsBase = path.join(tempDir, 'teams');
|
||||
await fs.mkdir(hoisted.teamsBase, { recursive: true });
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
hoisted.teamsBase = '';
|
||||
if (tempDir) {
|
||||
await fs.rm(tempDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('keeps an active suffixed member when the base member is removed during writeMembers', async () => {
|
||||
const store = new TeamMembersMetaStore();
|
||||
const teamName = 'mixed-team';
|
||||
await fs.mkdir(path.join(hoisted.teamsBase, teamName), { recursive: true });
|
||||
|
||||
await store.writeMembers(teamName, [
|
||||
{
|
||||
name: 'alice',
|
||||
providerId: 'codex',
|
||||
removedAt: Date.now(),
|
||||
},
|
||||
{
|
||||
name: 'alice-2',
|
||||
providerId: 'opencode',
|
||||
model: 'minimax-m2.5-free',
|
||||
},
|
||||
]);
|
||||
|
||||
const members = await store.getMembers(teamName);
|
||||
expect(members.map((member) => member.name)).toEqual(['alice', 'alice-2']);
|
||||
});
|
||||
|
||||
it('keeps an active suffixed member when reading persisted metadata with a removed base member', async () => {
|
||||
const store = new TeamMembersMetaStore();
|
||||
const teamName = 'mixed-team';
|
||||
const teamDir = path.join(hoisted.teamsBase, teamName);
|
||||
await fs.mkdir(teamDir, { recursive: true });
|
||||
|
||||
await fs.writeFile(
|
||||
path.join(teamDir, 'members.meta.json'),
|
||||
JSON.stringify(
|
||||
{
|
||||
version: 1,
|
||||
members: [
|
||||
{
|
||||
name: 'alice',
|
||||
providerId: 'codex',
|
||||
removedAt: Date.now(),
|
||||
},
|
||||
{
|
||||
name: 'alice-2',
|
||||
providerId: 'opencode',
|
||||
model: 'minimax-m2.5-free',
|
||||
},
|
||||
],
|
||||
},
|
||||
null,
|
||||
2
|
||||
)
|
||||
);
|
||||
|
||||
const members = await store.getMembers(teamName);
|
||||
expect(members.map((member) => member.name)).toEqual(['alice', 'alice-2']);
|
||||
});
|
||||
});
|
||||
File diff suppressed because it is too large
Load diff
|
|
@ -1,6 +1,7 @@
|
|||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
getOpenCodeMixedProviderProvisioningError,
|
||||
shouldWarnOnMissingRegisteredMember,
|
||||
shouldWarnOnUnreadableMemberAuditConfig,
|
||||
} from '@main/services/team/TeamProvisioningService';
|
||||
|
|
@ -69,4 +70,13 @@ describe('TeamProvisioningService audit warning policy', () => {
|
|||
})
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('surfaces a specific error for mixed-provider teams that include OpenCode', () => {
|
||||
expect(getOpenCodeMixedProviderProvisioningError()).toContain(
|
||||
'outside the current support scope'
|
||||
);
|
||||
expect(getOpenCodeMixedProviderProvisioningError()).toContain(
|
||||
'OpenCode-led mixed teams still remain blocked in this phase'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -381,6 +381,308 @@ describe('TeamProvisioningService prepare/auth behavior', () => {
|
|||
);
|
||||
});
|
||||
|
||||
it('checks every selected OpenCode model instead of only the first one', async () => {
|
||||
const prepare = vi.fn(async (input: { model?: string }) => {
|
||||
if (input.model === 'opencode/nemotron-3-super-free') {
|
||||
return {
|
||||
ok: false as const,
|
||||
providerId: 'opencode' as const,
|
||||
reason: 'e2e_missing',
|
||||
retryable: false,
|
||||
diagnostics: [
|
||||
'OpenCode production E2E evidence artifact has no entry for selected model opencode/nemotron-3-super-free',
|
||||
],
|
||||
warnings: [],
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
ok: true as const,
|
||||
providerId: 'opencode' as const,
|
||||
modelId: input.model ?? null,
|
||||
diagnostics: [],
|
||||
warnings: [],
|
||||
};
|
||||
});
|
||||
const registry = new TeamRuntimeAdapterRegistry([
|
||||
{
|
||||
providerId: 'opencode',
|
||||
prepare,
|
||||
launch: vi.fn(),
|
||||
reconcile: vi.fn(),
|
||||
stop: vi.fn(),
|
||||
} as any,
|
||||
]);
|
||||
const svc = new TeamProvisioningService();
|
||||
svc.setRuntimeAdapterRegistry(registry);
|
||||
|
||||
const result = await svc.prepareForProvisioning(tempRoot, {
|
||||
providerId: 'opencode',
|
||||
forceFresh: true,
|
||||
modelIds: ['opencode/minimax-m2.5-free', 'opencode/nemotron-3-super-free'],
|
||||
});
|
||||
|
||||
expect(prepare).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
expect.objectContaining({
|
||||
providerId: 'opencode',
|
||||
model: 'opencode/minimax-m2.5-free',
|
||||
runtimeOnly: false,
|
||||
})
|
||||
);
|
||||
expect(prepare).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
expect.objectContaining({
|
||||
providerId: 'opencode',
|
||||
model: 'opencode/nemotron-3-super-free',
|
||||
runtimeOnly: false,
|
||||
})
|
||||
);
|
||||
expect(result.ready).toBe(false);
|
||||
expect(result.details).toContain(
|
||||
'Selected model opencode/minimax-m2.5-free verified for launch.'
|
||||
);
|
||||
expect(result.message).toBe(
|
||||
'Selected model opencode/nemotron-3-super-free is unavailable. OpenCode production E2E evidence artifact has no entry for selected model opencode/nemotron-3-super-free'
|
||||
);
|
||||
});
|
||||
|
||||
it('runs OpenCode model verification with bounded concurrency and preserves model order', async () => {
|
||||
const started: string[] = [];
|
||||
let activeCount = 0;
|
||||
let maxActiveCount = 0;
|
||||
const releases = new Map<string, () => void>();
|
||||
const prepare = vi.fn((input: { model?: string }) => {
|
||||
const modelId = input.model ?? 'unknown-model';
|
||||
started.push(modelId);
|
||||
activeCount += 1;
|
||||
maxActiveCount = Math.max(maxActiveCount, activeCount);
|
||||
|
||||
return new Promise<any>((resolve) => {
|
||||
releases.set(modelId, () => {
|
||||
activeCount -= 1;
|
||||
if (modelId === 'opencode/big-pickle') {
|
||||
resolve({
|
||||
ok: false as const,
|
||||
providerId: 'opencode' as const,
|
||||
reason: 'provider_busy',
|
||||
retryable: true,
|
||||
diagnostics: ['provider busy'],
|
||||
warnings: [],
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
resolve({
|
||||
ok: true as const,
|
||||
providerId: 'opencode' as const,
|
||||
modelId,
|
||||
diagnostics: [],
|
||||
warnings: [],
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
const registry = new TeamRuntimeAdapterRegistry([
|
||||
{
|
||||
providerId: 'opencode',
|
||||
prepare,
|
||||
launch: vi.fn(),
|
||||
reconcile: vi.fn(),
|
||||
stop: vi.fn(),
|
||||
} as any,
|
||||
]);
|
||||
const svc = new TeamProvisioningService();
|
||||
svc.setRuntimeAdapterRegistry(registry);
|
||||
|
||||
const resultPromise = svc.prepareForProvisioning(tempRoot, {
|
||||
providerId: 'opencode',
|
||||
forceFresh: true,
|
||||
modelIds: [
|
||||
'opencode/minimax-m2.5-free',
|
||||
'opencode/nemotron-3-super-free',
|
||||
'opencode/big-pickle',
|
||||
],
|
||||
});
|
||||
|
||||
await vi.waitFor(() =>
|
||||
expect(started).toEqual([
|
||||
'opencode/minimax-m2.5-free',
|
||||
'opencode/nemotron-3-super-free',
|
||||
])
|
||||
);
|
||||
expect(maxActiveCount).toBe(2);
|
||||
expect(releases.has('opencode/big-pickle')).toBe(false);
|
||||
|
||||
releases.get('opencode/nemotron-3-super-free')?.();
|
||||
await vi.waitFor(() =>
|
||||
expect(started).toEqual([
|
||||
'opencode/minimax-m2.5-free',
|
||||
'opencode/nemotron-3-super-free',
|
||||
'opencode/big-pickle',
|
||||
])
|
||||
);
|
||||
expect(maxActiveCount).toBe(2);
|
||||
|
||||
releases.get('opencode/big-pickle')?.();
|
||||
releases.get('opencode/minimax-m2.5-free')?.();
|
||||
|
||||
const result = await resultPromise;
|
||||
|
||||
expect(result.ready).toBe(true);
|
||||
expect(result.details).toEqual([
|
||||
'Selected model opencode/minimax-m2.5-free verified for launch.',
|
||||
'Selected model opencode/nemotron-3-super-free verified for launch.',
|
||||
]);
|
||||
expect(result.warnings).toEqual([
|
||||
'Selected model opencode/big-pickle could not be verified. provider busy',
|
||||
]);
|
||||
});
|
||||
|
||||
it('runs OpenCode compatibility-only selected model checks without the deep execution probe', async () => {
|
||||
const prepare = vi.fn(async (input: { model?: string; runtimeOnly?: boolean }) => ({
|
||||
ok: true as const,
|
||||
providerId: 'opencode' as const,
|
||||
modelId: input.model ?? null,
|
||||
diagnostics: [],
|
||||
warnings: [],
|
||||
}));
|
||||
const registry = new TeamRuntimeAdapterRegistry([
|
||||
{
|
||||
providerId: 'opencode',
|
||||
prepare,
|
||||
getLastOpenCodeTeamLaunchReadiness: vi.fn(() => ({
|
||||
state: 'ready',
|
||||
launchAllowed: true,
|
||||
modelId: 'openrouter/minimax-m2.5-free',
|
||||
availableModels: [
|
||||
'opencode/minimax-m2.5-free',
|
||||
'opencode/nemotron-3-super-free',
|
||||
],
|
||||
opencodeVersion: '1.0.0',
|
||||
installMethod: 'unknown',
|
||||
binaryPath: 'opencode',
|
||||
hostHealthy: true,
|
||||
appMcpConnected: true,
|
||||
requiredToolsPresent: true,
|
||||
permissionBridgeReady: true,
|
||||
runtimeStoresReady: true,
|
||||
supportLevel: 'production_supported',
|
||||
missing: [],
|
||||
diagnostics: [],
|
||||
evidence: {
|
||||
capabilitiesReady: true,
|
||||
mcpToolProofRoute: 'mcp:tools/list',
|
||||
observedMcpTools: [],
|
||||
runtimeStoreReadinessReason: 'runtime_store_manifest_valid',
|
||||
},
|
||||
})),
|
||||
launch: vi.fn(),
|
||||
reconcile: vi.fn(),
|
||||
stop: vi.fn(),
|
||||
} as any,
|
||||
]);
|
||||
const svc = new TeamProvisioningService();
|
||||
svc.setRuntimeAdapterRegistry(registry);
|
||||
|
||||
const result = await svc.prepareForProvisioning(tempRoot, {
|
||||
providerId: 'opencode',
|
||||
forceFresh: true,
|
||||
modelIds: ['opencode/minimax-m2.5-free', 'opencode/nemotron-3-super-free'],
|
||||
modelVerificationMode: 'compatibility',
|
||||
});
|
||||
|
||||
expect(result.ready).toBe(true);
|
||||
expect(result.details).toEqual([
|
||||
'Selected model opencode/minimax-m2.5-free is compatible. Deep verification pending.',
|
||||
'Selected model opencode/nemotron-3-super-free is compatible. Deep verification pending.',
|
||||
]);
|
||||
expect(prepare).toHaveBeenCalledTimes(1);
|
||||
expect(prepare).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
providerId: 'opencode',
|
||||
model: undefined,
|
||||
runtimeOnly: true,
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('treats retryable OpenCode compatibility failures as blocking selected-model diagnostics', async () => {
|
||||
const prepare = vi.fn(async () => ({
|
||||
ok: false as const,
|
||||
providerId: 'opencode' as const,
|
||||
reason: 'not_authenticated',
|
||||
retryable: true,
|
||||
diagnostics: ['OpenCode provider authentication failed'],
|
||||
warnings: [],
|
||||
}));
|
||||
const registry = new TeamRuntimeAdapterRegistry([
|
||||
{
|
||||
providerId: 'opencode',
|
||||
prepare,
|
||||
launch: vi.fn(),
|
||||
reconcile: vi.fn(),
|
||||
stop: vi.fn(),
|
||||
} as any,
|
||||
]);
|
||||
const svc = new TeamProvisioningService();
|
||||
svc.setRuntimeAdapterRegistry(registry);
|
||||
|
||||
const result = await svc.prepareForProvisioning(tempRoot, {
|
||||
providerId: 'opencode',
|
||||
forceFresh: true,
|
||||
modelIds: ['opencode/minimax-m2.5-free'],
|
||||
modelVerificationMode: 'compatibility',
|
||||
});
|
||||
|
||||
expect(result.ready).toBe(false);
|
||||
expect(result.message).toBe(
|
||||
'Selected model opencode/minimax-m2.5-free could not be verified. OpenCode provider authentication failed'
|
||||
);
|
||||
expect(result.warnings).toEqual([
|
||||
'Selected model opencode/minimax-m2.5-free could not be verified. OpenCode provider authentication failed',
|
||||
]);
|
||||
});
|
||||
|
||||
it('normalizes unexpected OpenCode model prepare exceptions into a blocking diagnostic', async () => {
|
||||
const prepare = vi.fn(async (input: { model?: string }) => {
|
||||
if (input.model === 'opencode/nemotron-3-super-free') {
|
||||
throw new Error('bridge exploded');
|
||||
}
|
||||
|
||||
return {
|
||||
ok: true as const,
|
||||
providerId: 'opencode' as const,
|
||||
modelId: input.model ?? null,
|
||||
diagnostics: [],
|
||||
warnings: [],
|
||||
};
|
||||
});
|
||||
const registry = new TeamRuntimeAdapterRegistry([
|
||||
{
|
||||
providerId: 'opencode',
|
||||
prepare,
|
||||
launch: vi.fn(),
|
||||
reconcile: vi.fn(),
|
||||
stop: vi.fn(),
|
||||
} as any,
|
||||
]);
|
||||
const svc = new TeamProvisioningService();
|
||||
svc.setRuntimeAdapterRegistry(registry);
|
||||
|
||||
const result = await svc.prepareForProvisioning(tempRoot, {
|
||||
providerId: 'opencode',
|
||||
forceFresh: true,
|
||||
modelIds: ['opencode/minimax-m2.5-free', 'opencode/nemotron-3-super-free'],
|
||||
});
|
||||
|
||||
expect(result.ready).toBe(false);
|
||||
expect(result.details).toEqual(['Selected model opencode/minimax-m2.5-free verified for launch.']);
|
||||
expect(result.message).toBe(
|
||||
'Selected model opencode/nemotron-3-super-free is unavailable. bridge exploded'
|
||||
);
|
||||
});
|
||||
|
||||
it('keys the prepare probe cache by cwd', async () => {
|
||||
const svc = new TeamProvisioningService();
|
||||
vi.spyOn(svc as any, 'buildProvisioningEnv').mockResolvedValue({
|
||||
|
|
|
|||
|
|
@ -1,6 +1,9 @@
|
|||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { TeamProvisioningService } from '@main/services/team/TeamProvisioningService';
|
||||
import {
|
||||
getMixedLaunchFallbackRecoveryError,
|
||||
TeamProvisioningService,
|
||||
} from '@main/services/team/TeamProvisioningService';
|
||||
|
||||
describe('TeamProvisioningService (launch roster discovery)', () => {
|
||||
it('inbox fallback keeps -1 names but drops auto-suffixed -2+ when base exists', async () => {
|
||||
|
|
@ -116,4 +119,40 @@ describe('TeamProvisioningService (launch roster discovery)', () => {
|
|||
expect(result.source).toBe('config-fallback');
|
||||
expect(result.members.map((m: { name: string }) => m.name)).toEqual(['bob']);
|
||||
});
|
||||
|
||||
it('rejects inbox fallback when it would reconstruct a mixed OpenCode side lane without members.meta truth', async () => {
|
||||
const svc = new TeamProvisioningService(
|
||||
{} as never,
|
||||
{ listInboxNames: vi.fn(async () => ['tom']) } as never,
|
||||
{ getMembers: vi.fn(async () => []) } as never,
|
||||
{} as never
|
||||
);
|
||||
|
||||
const configRaw = JSON.stringify({
|
||||
name: 't',
|
||||
members: [{ name: 'tom', role: 'developer', provider: 'opencode', model: 'minimax-m2.5-free' }],
|
||||
});
|
||||
|
||||
await expect(
|
||||
(svc as unknown as any).resolveLaunchExpectedMembers('t', configRaw, 'codex')
|
||||
).rejects.toThrow(getMixedLaunchFallbackRecoveryError());
|
||||
});
|
||||
|
||||
it('rejects config fallback when it would reconstruct a mixed OpenCode side lane without members.meta truth', async () => {
|
||||
const svc = new TeamProvisioningService(
|
||||
{} as never,
|
||||
{ listInboxNames: vi.fn(async () => []) } as never,
|
||||
{ getMembers: vi.fn(async () => []) } as never,
|
||||
{} as never
|
||||
);
|
||||
|
||||
const configRaw = JSON.stringify({
|
||||
name: 't',
|
||||
members: [{ name: 'tom', role: 'developer', provider: 'opencode', model: 'minimax-m2.5-free' }],
|
||||
});
|
||||
|
||||
await expect(
|
||||
(svc as unknown as any).resolveLaunchExpectedMembers('t', configRaw, 'codex')
|
||||
).rejects.toThrow(getMixedLaunchFallbackRecoveryError());
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -322,6 +322,7 @@ describe('CLI status visibility during completed install state', () => {
|
|||
};
|
||||
storeState.updateConfig = vi.fn().mockResolvedValue(undefined);
|
||||
storeState.openExtensionsTab = vi.fn();
|
||||
window.localStorage.clear();
|
||||
});
|
||||
|
||||
it('keeps the Multimodel toggle visible and enabled on the dashboard while login is still required', async () => {
|
||||
|
|
@ -631,6 +632,189 @@ describe('CLI status visibility during completed install state', () => {
|
|||
});
|
||||
});
|
||||
|
||||
it('collapses dashboard provider cards down to the header summary', async () => {
|
||||
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
|
||||
storeState.cliInstallerState = 'idle';
|
||||
storeState.cliStatus = createInstalledCliStatus({
|
||||
flavor: 'agent_teams_orchestrator',
|
||||
displayName: 'agent_teams_orchestrator',
|
||||
supportsSelfUpdate: false,
|
||||
showVersionDetails: false,
|
||||
showBinaryPath: false,
|
||||
authLoggedIn: true,
|
||||
providers: [
|
||||
{
|
||||
providerId: 'anthropic',
|
||||
displayName: 'Anthropic',
|
||||
supported: true,
|
||||
authenticated: true,
|
||||
authMethod: 'oauth',
|
||||
verificationState: 'verified',
|
||||
statusMessage: 'Connected via Anthropic subscription',
|
||||
models: ['claude-sonnet-4-5'],
|
||||
canLoginFromUi: true,
|
||||
capabilities: {
|
||||
teamLaunch: true,
|
||||
oneShot: true,
|
||||
},
|
||||
connection: {
|
||||
supportsOAuth: true,
|
||||
supportsApiKey: true,
|
||||
configurableAuthModes: ['auto', 'oauth', 'api_key'],
|
||||
configuredAuthMode: 'oauth',
|
||||
apiKeyConfigured: false,
|
||||
apiKeySource: null,
|
||||
apiKeySourceLabel: null,
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const host = document.createElement('div');
|
||||
document.body.appendChild(host);
|
||||
const root = createRoot(host);
|
||||
|
||||
await act(async () => {
|
||||
root.render(React.createElement(CliStatusBanner));
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(host.textContent).toContain('Providers: 1/1 connected');
|
||||
expect(host.textContent).toContain('Anthropic');
|
||||
|
||||
const collapseButton = host.querySelector(
|
||||
'button[aria-label="Collapse provider details"]'
|
||||
) as HTMLButtonElement | null;
|
||||
expect(collapseButton).not.toBeNull();
|
||||
|
||||
await act(async () => {
|
||||
collapseButton?.dispatchEvent(new MouseEvent('click', { bubbles: true }));
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(host.textContent).toContain('Providers: 1/1 connected');
|
||||
expect(host.textContent).not.toContain('Anthropic');
|
||||
expect(host.textContent).not.toContain('Manage');
|
||||
expect(
|
||||
host.querySelector('button[aria-label="Expand provider details"]')
|
||||
).not.toBeNull();
|
||||
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
await Promise.resolve();
|
||||
});
|
||||
});
|
||||
|
||||
it('restores the collapsed dashboard provider banner after remount', async () => {
|
||||
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
|
||||
storeState.cliInstallerState = 'idle';
|
||||
storeState.cliStatus = createInstalledCliStatus({
|
||||
flavor: 'agent_teams_orchestrator',
|
||||
displayName: 'agent_teams_orchestrator',
|
||||
supportsSelfUpdate: false,
|
||||
showVersionDetails: false,
|
||||
showBinaryPath: false,
|
||||
authLoggedIn: true,
|
||||
providers: [
|
||||
{
|
||||
providerId: 'codex',
|
||||
displayName: 'Codex',
|
||||
supported: true,
|
||||
authenticated: true,
|
||||
authMethod: 'chatgpt',
|
||||
verificationState: 'verified',
|
||||
statusMessage: 'ChatGPT account ready',
|
||||
models: ['gpt-5.4'],
|
||||
canLoginFromUi: false,
|
||||
capabilities: {
|
||||
teamLaunch: true,
|
||||
oneShot: true,
|
||||
},
|
||||
connection: {
|
||||
supportsOAuth: false,
|
||||
supportsApiKey: true,
|
||||
configurableAuthModes: ['auto', 'chatgpt', 'api_key'],
|
||||
configuredAuthMode: 'chatgpt',
|
||||
apiKeyConfigured: true,
|
||||
apiKeySource: 'environment',
|
||||
apiKeySourceLabel: 'Detected from OPENAI_API_KEY',
|
||||
codex: {
|
||||
preferredAuthMode: 'chatgpt',
|
||||
effectiveAuthMode: 'chatgpt',
|
||||
appServerState: 'healthy',
|
||||
appServerStatusMessage: null,
|
||||
managedAccount: {
|
||||
type: 'chatgpt',
|
||||
email: 'user@example.com',
|
||||
planType: 'pro',
|
||||
},
|
||||
requiresOpenaiAuth: false,
|
||||
login: {
|
||||
status: 'idle',
|
||||
error: null,
|
||||
startedAt: null,
|
||||
},
|
||||
rateLimits: null,
|
||||
launchAllowed: true,
|
||||
launchIssueMessage: null,
|
||||
launchReadinessState: 'ready_chatgpt',
|
||||
},
|
||||
},
|
||||
backend: {
|
||||
kind: 'codex-native',
|
||||
label: 'Codex native',
|
||||
endpointLabel: 'codex exec --json',
|
||||
authMethodDetail: 'chatgpt',
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const firstHost = document.createElement('div');
|
||||
document.body.appendChild(firstHost);
|
||||
const firstRoot = createRoot(firstHost);
|
||||
|
||||
await act(async () => {
|
||||
firstRoot.render(React.createElement(CliStatusBanner));
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
const collapseButton = firstHost.querySelector(
|
||||
'button[aria-label="Collapse provider details"]'
|
||||
) as HTMLButtonElement | null;
|
||||
expect(collapseButton).not.toBeNull();
|
||||
|
||||
await act(async () => {
|
||||
collapseButton?.dispatchEvent(new MouseEvent('click', { bubbles: true }));
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
firstRoot.unmount();
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
const secondHost = document.createElement('div');
|
||||
document.body.appendChild(secondHost);
|
||||
const secondRoot = createRoot(secondHost);
|
||||
|
||||
await act(async () => {
|
||||
secondRoot.render(React.createElement(CliStatusBanner));
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(secondHost.textContent).toContain('Providers: 1/1 connected');
|
||||
expect(secondHost.textContent).not.toContain('ChatGPT account ready');
|
||||
expect(
|
||||
secondHost.querySelector('button[aria-label="Expand provider details"]')
|
||||
).not.toBeNull();
|
||||
|
||||
await act(async () => {
|
||||
secondRoot.unmount();
|
||||
await Promise.resolve();
|
||||
});
|
||||
});
|
||||
|
||||
it('shows a degraded runtime warning when a binary is found but the health check fails', async () => {
|
||||
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
|
||||
storeState.cliInstallerState = 'idle';
|
||||
|
|
@ -1533,6 +1717,17 @@ describe('CLI status visibility during completed install state', () => {
|
|||
expect(host.textContent).toContain(
|
||||
'Usage limits appear only after Codex refreshes the currently selected ChatGPT session. Right now the local session needs reconnect. API key fallback is available if you switch auth mode.'
|
||||
);
|
||||
const reconnectButton = Array.from(host.querySelectorAll('button')).find(
|
||||
(button) => button.textContent?.trim() === 'Reconnect ChatGPT'
|
||||
);
|
||||
expect(reconnectButton).toBeTruthy();
|
||||
|
||||
await act(async () => {
|
||||
reconnectButton?.dispatchEvent(new MouseEvent('click', { bubbles: true }));
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(codexAccountHookState.startChatgptLogin).toHaveBeenCalledTimes(1);
|
||||
expect(host.textContent).not.toContain('5h left');
|
||||
|
||||
await act(async () => {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,368 @@
|
|||
import React, { act } from 'react';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import type { CodexAccountSnapshotDto } from '@features/codex-account/contracts';
|
||||
|
||||
interface StoreState {
|
||||
cliStatus: Record<string, unknown> | null;
|
||||
cliStatusLoading: boolean;
|
||||
cliProviderStatusLoading: Record<string, boolean>;
|
||||
appConfig: {
|
||||
general: {
|
||||
multimodelEnabled: boolean;
|
||||
};
|
||||
};
|
||||
paneLayout: {
|
||||
focusedPaneId: string;
|
||||
panes: Array<{
|
||||
id: string;
|
||||
activeTabId: string | null;
|
||||
tabs: Array<{
|
||||
id: string;
|
||||
type: string;
|
||||
}>;
|
||||
}>;
|
||||
};
|
||||
}
|
||||
|
||||
const storeState = {} as StoreState;
|
||||
const codexAccountHookState = {
|
||||
snapshot: null as CodexAccountSnapshotDto | null,
|
||||
loading: false,
|
||||
error: null as string | null,
|
||||
refresh: vi.fn(() => Promise.resolve(undefined)),
|
||||
startChatgptLogin: vi.fn(() => Promise.resolve(true)),
|
||||
cancelChatgptLogin: vi.fn(() => Promise.resolve(true)),
|
||||
logout: vi.fn(() => Promise.resolve(true)),
|
||||
};
|
||||
|
||||
vi.mock('@renderer/api', () => ({
|
||||
isElectronMode: () => true,
|
||||
}));
|
||||
|
||||
vi.mock('@renderer/components/common/ProviderBrandLogo', () => ({
|
||||
ProviderBrandLogo: ({ providerId }: { providerId: string }) =>
|
||||
React.createElement('span', { 'data-testid': `provider-logo-${providerId}` }, providerId),
|
||||
}));
|
||||
|
||||
vi.mock('@features/codex-account/renderer', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('@features/codex-account/renderer')>();
|
||||
return {
|
||||
...actual,
|
||||
useCodexAccountSnapshot: () => codexAccountHookState,
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('@renderer/store', () => ({
|
||||
useStore: (selector: (state: StoreState) => unknown) => selector(storeState),
|
||||
}));
|
||||
|
||||
import { GlobalProviderStatusHeader } from '@renderer/components/common/GlobalProviderStatusHeader';
|
||||
|
||||
function createProvider(
|
||||
overrides: Partial<Record<string, unknown>> & {
|
||||
providerId: string;
|
||||
displayName: string;
|
||||
}
|
||||
): Record<string, unknown> {
|
||||
return {
|
||||
supported: true,
|
||||
authenticated: false,
|
||||
authMethod: null,
|
||||
verificationState: 'verified',
|
||||
statusMessage: null,
|
||||
detailMessage: null,
|
||||
models: [],
|
||||
modelVerificationState: 'idle',
|
||||
modelAvailability: [],
|
||||
canLoginFromUi: true,
|
||||
capabilities: {
|
||||
teamLaunch: true,
|
||||
oneShot: true,
|
||||
extensions: {
|
||||
plugins: { status: 'unsupported' },
|
||||
mcp: { status: 'unsupported' },
|
||||
},
|
||||
},
|
||||
backend: null,
|
||||
availableBackends: [],
|
||||
connection: null,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function createMultimodelStatus(providers: Record<string, unknown>[]): Record<string, unknown> {
|
||||
return {
|
||||
flavor: 'agent_teams_orchestrator',
|
||||
displayName: 'Multimodel runtime',
|
||||
supportsSelfUpdate: false,
|
||||
showVersionDetails: false,
|
||||
showBinaryPath: false,
|
||||
installed: true,
|
||||
installedVersion: '0.0.3',
|
||||
binaryPath: '/tmp/claude-multimodel',
|
||||
latestVersion: null,
|
||||
updateAvailable: false,
|
||||
authLoggedIn: providers.some((provider) => provider.authenticated === true),
|
||||
authStatusChecking: false,
|
||||
authMethod: null,
|
||||
providers,
|
||||
};
|
||||
}
|
||||
|
||||
function setFocusedTab(type: string): void {
|
||||
storeState.paneLayout = {
|
||||
focusedPaneId: 'pane-1',
|
||||
panes: [
|
||||
{
|
||||
id: 'pane-1',
|
||||
activeTabId: type === 'empty' ? null : 'tab-1',
|
||||
tabs: type === 'empty' ? [] : [{ id: 'tab-1', type }],
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
describe('GlobalProviderStatusHeader', () => {
|
||||
beforeEach(() => {
|
||||
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
|
||||
storeState.cliStatus = null;
|
||||
storeState.cliStatusLoading = false;
|
||||
storeState.cliProviderStatusLoading = {};
|
||||
storeState.appConfig = {
|
||||
general: {
|
||||
multimodelEnabled: true,
|
||||
},
|
||||
};
|
||||
setFocusedTab('team');
|
||||
codexAccountHookState.snapshot = null;
|
||||
codexAccountHookState.loading = false;
|
||||
codexAccountHookState.error = null;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
document.body.innerHTML = '';
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it('shows loading providers on non-dashboard screens', async () => {
|
||||
storeState.cliStatus = createMultimodelStatus([
|
||||
createProvider({
|
||||
providerId: 'anthropic',
|
||||
displayName: 'Anthropic',
|
||||
verificationState: 'unknown',
|
||||
statusMessage: 'Checking...',
|
||||
}),
|
||||
]);
|
||||
storeState.cliProviderStatusLoading = { anthropic: true };
|
||||
|
||||
const host = document.createElement('div');
|
||||
document.body.appendChild(host);
|
||||
const root = createRoot(host);
|
||||
|
||||
await act(async () => {
|
||||
root.render(React.createElement(GlobalProviderStatusHeader));
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(host.textContent).toContain('Provider Activity');
|
||||
expect(host.textContent).toContain('Anthropic');
|
||||
expect(host.textContent).toContain('Checking...');
|
||||
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
await Promise.resolve();
|
||||
});
|
||||
});
|
||||
|
||||
it('hides on dashboard tabs', async () => {
|
||||
setFocusedTab('dashboard');
|
||||
storeState.cliStatus = createMultimodelStatus([
|
||||
createProvider({
|
||||
providerId: 'anthropic',
|
||||
displayName: 'Anthropic',
|
||||
verificationState: 'unknown',
|
||||
statusMessage: 'Checking...',
|
||||
}),
|
||||
]);
|
||||
storeState.cliProviderStatusLoading = { anthropic: true };
|
||||
|
||||
const host = document.createElement('div');
|
||||
document.body.appendChild(host);
|
||||
const root = createRoot(host);
|
||||
|
||||
await act(async () => {
|
||||
root.render(React.createElement(GlobalProviderStatusHeader));
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(host.textContent).toBe('');
|
||||
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
await Promise.resolve();
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps completed providers visible as Checked while the same cycle still has loading work, then hides when clean', async () => {
|
||||
storeState.cliStatus = createMultimodelStatus([
|
||||
createProvider({
|
||||
providerId: 'anthropic',
|
||||
displayName: 'Anthropic',
|
||||
verificationState: 'unknown',
|
||||
statusMessage: 'Checking...',
|
||||
}),
|
||||
createProvider({
|
||||
providerId: 'codex',
|
||||
displayName: 'Codex',
|
||||
verificationState: 'unknown',
|
||||
statusMessage: 'Checking...',
|
||||
}),
|
||||
]);
|
||||
storeState.cliProviderStatusLoading = { anthropic: true, codex: true };
|
||||
|
||||
const host = document.createElement('div');
|
||||
document.body.appendChild(host);
|
||||
const root = createRoot(host);
|
||||
|
||||
await act(async () => {
|
||||
root.render(React.createElement(GlobalProviderStatusHeader));
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
storeState.cliStatus = createMultimodelStatus([
|
||||
createProvider({
|
||||
providerId: 'anthropic',
|
||||
displayName: 'Anthropic',
|
||||
verificationState: 'verified',
|
||||
statusMessage: 'Not connected',
|
||||
}),
|
||||
createProvider({
|
||||
providerId: 'codex',
|
||||
displayName: 'Codex',
|
||||
verificationState: 'unknown',
|
||||
statusMessage: 'Checking...',
|
||||
}),
|
||||
]);
|
||||
storeState.cliProviderStatusLoading = { anthropic: false, codex: true };
|
||||
|
||||
await act(async () => {
|
||||
root.render(React.createElement(GlobalProviderStatusHeader));
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(host.textContent).toContain('Anthropic');
|
||||
expect(host.textContent).toContain('Checked');
|
||||
expect(host.textContent).toContain('Codex');
|
||||
expect(host.textContent).toContain('Checking...');
|
||||
|
||||
storeState.cliStatus = createMultimodelStatus([
|
||||
createProvider({
|
||||
providerId: 'anthropic',
|
||||
displayName: 'Anthropic',
|
||||
verificationState: 'verified',
|
||||
statusMessage: 'Not connected',
|
||||
}),
|
||||
createProvider({
|
||||
providerId: 'codex',
|
||||
displayName: 'Codex',
|
||||
verificationState: 'verified',
|
||||
statusMessage: 'ChatGPT account ready',
|
||||
authenticated: true,
|
||||
authMethod: 'chatgpt',
|
||||
}),
|
||||
]);
|
||||
storeState.cliProviderStatusLoading = { anthropic: false, codex: false };
|
||||
|
||||
await act(async () => {
|
||||
root.render(React.createElement(GlobalProviderStatusHeader));
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(host.textContent).toBe('');
|
||||
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
await Promise.resolve();
|
||||
});
|
||||
});
|
||||
|
||||
it('stays visible for provider errors after loading finishes', async () => {
|
||||
storeState.cliStatus = createMultimodelStatus([
|
||||
createProvider({
|
||||
providerId: 'anthropic',
|
||||
displayName: 'Anthropic',
|
||||
verificationState: 'error',
|
||||
statusMessage: 'Failed to refresh anthropic status',
|
||||
}),
|
||||
]);
|
||||
|
||||
const host = document.createElement('div');
|
||||
document.body.appendChild(host);
|
||||
const root = createRoot(host);
|
||||
|
||||
await act(async () => {
|
||||
root.render(React.createElement(GlobalProviderStatusHeader));
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(host.textContent).toContain('Anthropic');
|
||||
expect(host.textContent).toContain('Failed to refresh anthropic status');
|
||||
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
await Promise.resolve();
|
||||
});
|
||||
});
|
||||
|
||||
it('masks the negative Codex bootstrap snapshot while placeholder loading is still active', async () => {
|
||||
storeState.cliStatus = null;
|
||||
storeState.cliStatusLoading = true;
|
||||
codexAccountHookState.snapshot = {
|
||||
preferredAuthMode: 'chatgpt',
|
||||
effectiveAuthMode: null,
|
||||
launchAllowed: false,
|
||||
launchIssueMessage: 'Connect a ChatGPT account to use your Codex subscription.',
|
||||
launchReadinessState: 'missing_auth',
|
||||
appServerState: 'healthy',
|
||||
appServerStatusMessage: null,
|
||||
managedAccount: null,
|
||||
apiKey: {
|
||||
available: false,
|
||||
source: null,
|
||||
sourceLabel: null,
|
||||
},
|
||||
requiresOpenaiAuth: true,
|
||||
localAccountArtifactsPresent: false,
|
||||
localActiveChatgptAccountPresent: false,
|
||||
login: {
|
||||
status: 'idle',
|
||||
error: null,
|
||||
startedAt: null,
|
||||
},
|
||||
rateLimits: null,
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
const host = document.createElement('div');
|
||||
document.body.appendChild(host);
|
||||
const root = createRoot(host);
|
||||
|
||||
await act(async () => {
|
||||
root.render(React.createElement(GlobalProviderStatusHeader));
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(host.textContent).toContain('Codex');
|
||||
expect(host.textContent).toContain('Checking...');
|
||||
expect(host.textContent).not.toContain(
|
||||
'Connect a ChatGPT account to use your Codex subscription.'
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
await Promise.resolve();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -819,6 +819,9 @@ describe('ProviderRuntimeSettingsDialog', () => {
|
|||
expect(host.textContent).toContain(
|
||||
'Codex has a locally selected ChatGPT account, but the current session needs reconnect before usage limits can load here. The detected API key is only used after you switch Codex to API key mode.'
|
||||
);
|
||||
expect(host.textContent).toContain('Reconnect ChatGPT');
|
||||
expect(host.textContent).not.toContain('Disconnect account');
|
||||
expect(host.textContent).toContain('Reconnect required');
|
||||
});
|
||||
|
||||
it('disables Codex account actions while a Codex account request is already in flight', async () => {
|
||||
|
|
|
|||
|
|
@ -119,8 +119,8 @@ describe('formatTeamModelSummary', () => {
|
|||
expect(normalizeTeamModelForUi('codex', 'gpt-5.4', codexProviderStatus)).toBe('gpt-5.4');
|
||||
});
|
||||
|
||||
it('waits for the runtime model list before validating explicit Codex selections', () => {
|
||||
expect(getTeamModelSelectionError('codex', 'gpt-5.4')).toContain('waiting for Codex runtime verification');
|
||||
it('does not raise a hard validation error while explicit Codex models are still loading', () => {
|
||||
expect(getTeamModelSelectionError('codex', 'gpt-5.4')).toBeNull();
|
||||
expect(getTeamModelSelectionError('codex', '')).toBeNull();
|
||||
expect(getTeamModelSelectionError('anthropic', 'opus')).toBeNull();
|
||||
expect(getTeamModelSelectionError('anthropic', 'claude-opus-4-7')).toBeNull();
|
||||
|
|
|
|||
|
|
@ -287,6 +287,16 @@ vi.mock('@renderer/components/team/dialogs/provisioningModelIssues', () => ({
|
|||
|
||||
vi.mock('@renderer/components/team/dialogs/ProvisioningProviderStatusList', () => ({
|
||||
ProvisioningProviderStatusList: () => React.createElement('div', null, 'provider-status-list'),
|
||||
deriveEffectiveProvisioningPrepareState: ({
|
||||
state,
|
||||
message,
|
||||
}: {
|
||||
state: 'idle' | 'loading' | 'ready' | 'failed';
|
||||
message: string | null;
|
||||
}) => ({
|
||||
state,
|
||||
message,
|
||||
}),
|
||||
failIncompleteProviderChecks: (checks: unknown) => checks,
|
||||
getPrimaryProvisioningFailureDetail: () => null,
|
||||
getProvisioningFailureHint: () => 'hint',
|
||||
|
|
@ -914,4 +924,230 @@ describe('LaunchTeamDialog', () => {
|
|||
await flush();
|
||||
});
|
||||
});
|
||||
|
||||
it('does not restart provider preflight when cli status refresh keeps the same semantic inputs', async () => {
|
||||
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
|
||||
storeState.cliStatus = {
|
||||
flavor: 'agent_teams_orchestrator',
|
||||
providers: [
|
||||
{
|
||||
providerId: 'codex',
|
||||
supported: true,
|
||||
authenticated: true,
|
||||
authMethod: 'chatgpt',
|
||||
verificationState: 'verified',
|
||||
modelVerificationState: 'verified',
|
||||
statusMessage: null,
|
||||
detailMessage: null,
|
||||
selectedBackendId: 'codex-native',
|
||||
resolvedBackendId: 'codex-native',
|
||||
models: ['gpt-5.4'],
|
||||
modelCatalog: {
|
||||
source: 'app-server',
|
||||
status: 'ready',
|
||||
models: [{ id: 'gpt-5.4' }],
|
||||
},
|
||||
capabilities: {
|
||||
teamLaunch: true,
|
||||
oneShot: false,
|
||||
},
|
||||
},
|
||||
],
|
||||
} as any;
|
||||
|
||||
const host = document.createElement('div');
|
||||
document.body.appendChild(host);
|
||||
const root = createRoot(host);
|
||||
|
||||
const renderDialog = async (): Promise<void> => {
|
||||
root.render(
|
||||
React.createElement(LaunchTeamDialog, {
|
||||
mode: 'launch',
|
||||
open: true,
|
||||
teamName: 'team-alpha',
|
||||
members: [],
|
||||
defaultProjectPath: '/tmp/project',
|
||||
provisioningError: null,
|
||||
clearProvisioningError: vi.fn(),
|
||||
activeTeams: [],
|
||||
onClose: vi.fn(),
|
||||
onLaunch: vi.fn(async () => {}),
|
||||
})
|
||||
);
|
||||
await flush();
|
||||
await flush();
|
||||
};
|
||||
|
||||
await act(async () => {
|
||||
await renderDialog();
|
||||
});
|
||||
|
||||
expect(vi.mocked(runProviderPrepareDiagnostics)).toHaveBeenCalledTimes(1);
|
||||
|
||||
storeState.cliStatus = {
|
||||
flavor: 'agent_teams_orchestrator',
|
||||
providers: [
|
||||
{
|
||||
providerId: 'codex',
|
||||
supported: true,
|
||||
authenticated: true,
|
||||
authMethod: 'chatgpt',
|
||||
verificationState: 'verified',
|
||||
modelVerificationState: 'verified',
|
||||
statusMessage: null,
|
||||
detailMessage: null,
|
||||
selectedBackendId: 'codex-native',
|
||||
resolvedBackendId: 'codex-native',
|
||||
models: ['gpt-5.4'],
|
||||
modelCatalog: {
|
||||
source: 'app-server',
|
||||
status: 'ready',
|
||||
models: [{ id: 'gpt-5.4' }],
|
||||
},
|
||||
capabilities: {
|
||||
teamLaunch: true,
|
||||
oneShot: false,
|
||||
},
|
||||
},
|
||||
],
|
||||
} as any;
|
||||
|
||||
await act(async () => {
|
||||
await renderDialog();
|
||||
});
|
||||
|
||||
expect(vi.mocked(runProviderPrepareDiagnostics)).toHaveBeenCalledTimes(1);
|
||||
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
await flush();
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps the in-flight preflight result after a same-signature rerender', async () => {
|
||||
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
|
||||
storeState.cliStatus = {
|
||||
flavor: 'agent_teams_orchestrator',
|
||||
providers: [
|
||||
{
|
||||
providerId: 'opencode',
|
||||
supported: true,
|
||||
authenticated: true,
|
||||
authMethod: 'opencode_managed',
|
||||
verificationState: 'verified',
|
||||
modelVerificationState: 'verified',
|
||||
statusMessage: 'warming up',
|
||||
detailMessage: 'first render',
|
||||
models: ['opencode/minimax-m2.5-free'],
|
||||
modelCatalog: {
|
||||
source: 'app-server',
|
||||
status: 'ready',
|
||||
models: [{ id: 'opencode/minimax-m2.5-free' }],
|
||||
},
|
||||
capabilities: {
|
||||
teamLaunch: true,
|
||||
oneShot: false,
|
||||
},
|
||||
},
|
||||
],
|
||||
} as any;
|
||||
|
||||
let resolvePrepare!: (value: {
|
||||
status: 'ready';
|
||||
warnings: [];
|
||||
details: [];
|
||||
modelResultsById: {};
|
||||
}) => void;
|
||||
const preparePromise = new Promise<{
|
||||
status: 'ready';
|
||||
warnings: [];
|
||||
details: [];
|
||||
modelResultsById: {};
|
||||
}>((resolve) => {
|
||||
resolvePrepare = resolve;
|
||||
});
|
||||
vi.mocked(runProviderPrepareDiagnostics).mockReturnValueOnce(preparePromise as any);
|
||||
|
||||
const host = document.createElement('div');
|
||||
document.body.appendChild(host);
|
||||
const root = createRoot(host);
|
||||
|
||||
const renderDialog = async (): Promise<void> => {
|
||||
root.render(
|
||||
React.createElement(LaunchTeamDialog, {
|
||||
mode: 'launch',
|
||||
open: true,
|
||||
teamName: 'team-alpha',
|
||||
members: [
|
||||
{
|
||||
name: 'alice',
|
||||
role: 'Reviewer',
|
||||
providerId: 'opencode',
|
||||
model: 'opencode/minimax-m2.5-free',
|
||||
},
|
||||
] as any,
|
||||
defaultProjectPath: '/tmp/project',
|
||||
provisioningError: null,
|
||||
clearProvisioningError: vi.fn(),
|
||||
activeTeams: [],
|
||||
onClose: vi.fn(),
|
||||
onLaunch: vi.fn(async () => {}),
|
||||
})
|
||||
);
|
||||
await flush();
|
||||
};
|
||||
|
||||
await act(async () => {
|
||||
await renderDialog();
|
||||
});
|
||||
|
||||
storeState.cliStatus = {
|
||||
flavor: 'agent_teams_orchestrator',
|
||||
providers: [
|
||||
{
|
||||
providerId: 'opencode',
|
||||
supported: true,
|
||||
authenticated: true,
|
||||
authMethod: 'opencode_managed',
|
||||
verificationState: 'verified',
|
||||
modelVerificationState: 'verified',
|
||||
statusMessage: 'still warming',
|
||||
detailMessage: 'same semantic status',
|
||||
models: ['opencode/minimax-m2.5-free'],
|
||||
modelCatalog: {
|
||||
source: 'app-server',
|
||||
status: 'ready',
|
||||
models: [{ id: 'opencode/minimax-m2.5-free' }],
|
||||
},
|
||||
capabilities: {
|
||||
teamLaunch: true,
|
||||
oneShot: false,
|
||||
},
|
||||
},
|
||||
],
|
||||
} as any;
|
||||
|
||||
await act(async () => {
|
||||
await renderDialog();
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
resolvePrepare({
|
||||
status: 'ready',
|
||||
warnings: [],
|
||||
details: [],
|
||||
modelResultsById: {},
|
||||
});
|
||||
await flush();
|
||||
await flush();
|
||||
});
|
||||
|
||||
expect(vi.mocked(runProviderPrepareDiagnostics)).toHaveBeenCalledTimes(1);
|
||||
expect(host.textContent).toContain('Selected providers are ready.');
|
||||
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
await flush();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import { createRoot } from 'react-dom/client';
|
|||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import {
|
||||
deriveEffectiveProvisioningPrepareState,
|
||||
getPrimaryProvisioningFailureDetail,
|
||||
getProvisioningProviderBackendSummary,
|
||||
ProvisioningProviderStatusList,
|
||||
|
|
@ -130,6 +131,45 @@ describe('ProvisioningProviderStatusList', () => {
|
|||
});
|
||||
});
|
||||
|
||||
it('summarizes compatibility-pending OpenCode model checks separately from verified ones', 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(ProvisioningProviderStatusList, {
|
||||
checks: [
|
||||
{
|
||||
providerId: 'opencode',
|
||||
status: 'checking',
|
||||
backendSummary: 'OpenCode CLI',
|
||||
details: [
|
||||
'minimax-m2.5-free - compatible, deep verification pending...',
|
||||
'nemotron-3-super-free - verified',
|
||||
],
|
||||
},
|
||||
],
|
||||
})
|
||||
);
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(host.textContent).toContain(
|
||||
'OpenCode (OpenCode CLI): Selected model checks - 1 compatible, deep verification pending, 1 verified'
|
||||
);
|
||||
expect(host.textContent).toContain(
|
||||
'minimax-m2.5-free - compatible, deep verification pending...'
|
||||
);
|
||||
expect(host.textContent).toContain('nemotron-3-super-free - verified');
|
||||
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
await Promise.resolve();
|
||||
});
|
||||
});
|
||||
|
||||
it('normalizes generic preflight timeout notes without depending on a hardcoded CLI name', async () => {
|
||||
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
|
||||
const host = document.createElement('div');
|
||||
|
|
@ -214,4 +254,76 @@ describe('ProvisioningProviderStatusList', () => {
|
|||
})
|
||||
).toBe('Codex native');
|
||||
});
|
||||
|
||||
it('promotes loading to ready once every provider check is already terminal', () => {
|
||||
expect(
|
||||
deriveEffectiveProvisioningPrepareState({
|
||||
state: 'loading',
|
||||
message: 'Checking selected providers in parallel...',
|
||||
warnings: [],
|
||||
checks: [
|
||||
{
|
||||
providerId: 'codex',
|
||||
status: 'ready',
|
||||
details: ['5.4 - verified', 'Default - verified'],
|
||||
},
|
||||
{
|
||||
providerId: 'opencode',
|
||||
status: 'ready',
|
||||
details: ['minimax-m2.5-free - verified', 'nemotron-3-super-free - verified'],
|
||||
},
|
||||
],
|
||||
})
|
||||
).toEqual({
|
||||
state: 'ready',
|
||||
message: 'Selected providers are ready.',
|
||||
});
|
||||
});
|
||||
|
||||
it('promotes loading to failed once a terminal provider failure is already known', () => {
|
||||
expect(
|
||||
deriveEffectiveProvisioningPrepareState({
|
||||
state: 'loading',
|
||||
message: 'Checking selected providers in parallel...',
|
||||
warnings: [],
|
||||
checks: [
|
||||
{
|
||||
providerId: 'opencode',
|
||||
status: 'failed',
|
||||
details: [
|
||||
'nemotron-3-super-free - unavailable - OpenCode production E2E evidence artifact has no entry for selected model opencode/nemotron-3-super-free',
|
||||
],
|
||||
},
|
||||
],
|
||||
})
|
||||
).toEqual({
|
||||
state: 'failed',
|
||||
message:
|
||||
'nemotron-3-super-free - unavailable - OpenCode production E2E evidence artifact has no entry for selected model opencode/nemotron-3-super-free',
|
||||
});
|
||||
});
|
||||
|
||||
it('shows a more honest loading message while OpenCode deep verification is still pending', () => {
|
||||
expect(
|
||||
deriveEffectiveProvisioningPrepareState({
|
||||
state: 'loading',
|
||||
message: 'Checking selected providers in parallel...',
|
||||
warnings: [],
|
||||
checks: [
|
||||
{
|
||||
providerId: 'opencode',
|
||||
status: 'checking',
|
||||
details: [
|
||||
'minimax-m2.5-free - compatible, deep verification pending...',
|
||||
'nemotron-3-super-free - compatible, deep verification pending...',
|
||||
],
|
||||
},
|
||||
],
|
||||
})
|
||||
).toEqual({
|
||||
state: 'loading',
|
||||
message:
|
||||
'Deep verification is still running. OpenCode free models may take around 20 seconds.',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ describe('buildProviderPrepareModelCacheKey', () => {
|
|||
cwd: '/tmp/project',
|
||||
providerId: 'anthropic' as const,
|
||||
backendSummary: 'Claude Code',
|
||||
runtimeStatusSignature: 'status:v1',
|
||||
};
|
||||
|
||||
expect(
|
||||
|
|
@ -29,8 +30,30 @@ describe('buildProviderPrepareModelCacheKey', () => {
|
|||
providerId: 'codex' as const,
|
||||
backendSummary: 'Codex native',
|
||||
limitContext: false,
|
||||
runtimeStatusSignature: 'status:v1',
|
||||
};
|
||||
|
||||
expect(buildProviderPrepareModelCacheKey(input)).toBe(buildProviderPrepareModelCacheKey(input));
|
||||
});
|
||||
|
||||
it('separates runtime-status variants for the same provider runtime', () => {
|
||||
const sharedInput = {
|
||||
cwd: '/tmp/project',
|
||||
providerId: 'codex' as const,
|
||||
backendSummary: 'Codex native',
|
||||
limitContext: false,
|
||||
};
|
||||
|
||||
expect(
|
||||
buildProviderPrepareModelCacheKey({
|
||||
...sharedInput,
|
||||
runtimeStatusSignature: 'status:v1',
|
||||
})
|
||||
).not.toBe(
|
||||
buildProviderPrepareModelCacheKey({
|
||||
...sharedInput,
|
||||
runtimeStatusSignature: 'status:v2',
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -59,7 +59,9 @@ describe('runProviderPrepareDiagnostics', () => {
|
|||
cwd?: string,
|
||||
providerId?: TeamProviderId,
|
||||
providerIds?: TeamProviderId[],
|
||||
selectedModels?: string[]
|
||||
selectedModels?: string[],
|
||||
limitContext?: boolean,
|
||||
modelVerificationMode?: 'compatibility' | 'deep'
|
||||
) => Promise<TeamProvisioningPrepareResult>
|
||||
>().mockResolvedValue({
|
||||
ready: false,
|
||||
|
|
@ -80,8 +82,12 @@ describe('runProviderPrepareDiagnostics', () => {
|
|||
|
||||
it('batches uncached model probes per provider and keeps failures scoped to the affected model', async () => {
|
||||
const deferredBatch = createDeferred<TeamProvisioningPrepareResult>();
|
||||
const progressUpdates: Array<{ details: string[]; completedCount: number; totalCount: number }> =
|
||||
[];
|
||||
const progressUpdates: Array<{
|
||||
status: 'checking' | 'ready' | 'notes' | 'failed';
|
||||
details: string[];
|
||||
completedCount: number;
|
||||
totalCount: number;
|
||||
}> = [];
|
||||
|
||||
const prepareProvisioning = vi.fn<
|
||||
(
|
||||
|
|
@ -91,12 +97,6 @@ describe('runProviderPrepareDiagnostics', () => {
|
|||
selectedModels?: string[]
|
||||
) => Promise<TeamProvisioningPrepareResult>
|
||||
>((_, __, ___, selectedModels) => {
|
||||
if (!selectedModels || selectedModels.length === 0) {
|
||||
return Promise.resolve({
|
||||
ready: true,
|
||||
message: 'CLI is warmed up and ready to launch',
|
||||
});
|
||||
}
|
||||
expect(selectedModels).toEqual(['gpt-5.4', 'gpt-5.2-codex']);
|
||||
return deferredBatch.promise;
|
||||
});
|
||||
|
|
@ -111,6 +111,7 @@ describe('runProviderPrepareDiagnostics', () => {
|
|||
|
||||
await Promise.resolve();
|
||||
expect(progressUpdates[0]).toEqual({
|
||||
status: 'checking',
|
||||
completedCount: 0,
|
||||
totalCount: 2,
|
||||
details: ['5.4 - checking...', '5.2 Codex - checking...'],
|
||||
|
|
@ -132,6 +133,7 @@ describe('runProviderPrepareDiagnostics', () => {
|
|||
'5.2 Codex - unavailable - Not available on this Codex native runtime',
|
||||
]);
|
||||
expect(progressUpdates.at(-1)).toEqual({
|
||||
status: 'failed',
|
||||
completedCount: 2,
|
||||
totalCount: 2,
|
||||
details: [
|
||||
|
|
@ -139,7 +141,117 @@ describe('runProviderPrepareDiagnostics', () => {
|
|||
'5.2 Codex - unavailable - Not available on this Codex native runtime',
|
||||
],
|
||||
});
|
||||
expect(prepareProvisioning).toHaveBeenCalledTimes(2);
|
||||
expect(prepareProvisioning).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('runs OpenCode uncached selected models through compatibility first and deep verification second', async () => {
|
||||
const deferredCompatibility = createDeferred<TeamProvisioningPrepareResult>();
|
||||
const deferredDeep = createDeferred<TeamProvisioningPrepareResult>();
|
||||
const progressUpdates: Array<{
|
||||
status: 'checking' | 'ready' | 'notes' | 'failed';
|
||||
details: string[];
|
||||
completedCount: number;
|
||||
totalCount: number;
|
||||
}> = [];
|
||||
|
||||
const prepareProvisioning = vi.fn(
|
||||
(
|
||||
_cwd?: string,
|
||||
_providerId?: TeamProviderId,
|
||||
_providerIds?: TeamProviderId[],
|
||||
selectedModels?: string[],
|
||||
_limitContext?: boolean,
|
||||
modelVerificationMode?: 'compatibility' | 'deep'
|
||||
) => {
|
||||
if (modelVerificationMode === 'compatibility') {
|
||||
expect(selectedModels).toEqual([
|
||||
'opencode/minimax-m2.5-free',
|
||||
'opencode/nemotron-3-super-free',
|
||||
]);
|
||||
return deferredCompatibility.promise;
|
||||
}
|
||||
expect(modelVerificationMode).toBe('deep');
|
||||
expect(selectedModels).toEqual([
|
||||
'opencode/minimax-m2.5-free',
|
||||
'opencode/nemotron-3-super-free',
|
||||
]);
|
||||
return deferredDeep.promise;
|
||||
}
|
||||
);
|
||||
|
||||
const resultPromise = runProviderPrepareDiagnostics({
|
||||
cwd: '/tmp/project',
|
||||
providerId: 'opencode',
|
||||
selectedModelIds: ['opencode/minimax-m2.5-free', 'opencode/nemotron-3-super-free'],
|
||||
prepareProvisioning,
|
||||
onModelProgress: (progress) => progressUpdates.push(progress),
|
||||
});
|
||||
|
||||
await Promise.resolve();
|
||||
expect(progressUpdates[0]).toEqual({
|
||||
status: 'checking',
|
||||
completedCount: 0,
|
||||
totalCount: 2,
|
||||
details: ['minimax-m2.5-free - checking...', 'nemotron-3-super-free - checking...'],
|
||||
});
|
||||
|
||||
deferredCompatibility.resolve({
|
||||
ready: true,
|
||||
message: 'CLI is ready to launch',
|
||||
details: [
|
||||
'Selected model opencode/minimax-m2.5-free is compatible. Deep verification pending.',
|
||||
'Selected model opencode/nemotron-3-super-free is compatible. Deep verification pending.',
|
||||
],
|
||||
warnings: [],
|
||||
});
|
||||
|
||||
await vi.waitFor(() =>
|
||||
expect(progressUpdates.at(-1)).toEqual({
|
||||
status: 'checking',
|
||||
completedCount: 0,
|
||||
totalCount: 2,
|
||||
details: [
|
||||
'minimax-m2.5-free - compatible, deep verification pending...',
|
||||
'nemotron-3-super-free - compatible, deep verification pending...',
|
||||
],
|
||||
})
|
||||
);
|
||||
|
||||
deferredDeep.resolve({
|
||||
ready: true,
|
||||
message: 'CLI is ready to launch',
|
||||
details: [
|
||||
'Selected model opencode/minimax-m2.5-free verified for launch.',
|
||||
'Selected model opencode/nemotron-3-super-free verified for launch.',
|
||||
],
|
||||
warnings: [],
|
||||
});
|
||||
|
||||
const result = await resultPromise;
|
||||
|
||||
expect(result.status).toBe('ready');
|
||||
expect(result.details).toEqual([
|
||||
'minimax-m2.5-free - verified',
|
||||
'nemotron-3-super-free - verified',
|
||||
]);
|
||||
expect(prepareProvisioning).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
'/tmp/project',
|
||||
'opencode',
|
||||
['opencode'],
|
||||
['opencode/minimax-m2.5-free', 'opencode/nemotron-3-super-free'],
|
||||
undefined,
|
||||
'compatibility'
|
||||
);
|
||||
expect(prepareProvisioning).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
'/tmp/project',
|
||||
'opencode',
|
||||
['opencode'],
|
||||
['opencode/minimax-m2.5-free', 'opencode/nemotron-3-super-free'],
|
||||
undefined,
|
||||
'deep'
|
||||
);
|
||||
});
|
||||
|
||||
it('normalizes raw Codex API error envelopes into a clean model reason', async () => {
|
||||
|
|
@ -151,12 +263,6 @@ describe('runProviderPrepareDiagnostics', () => {
|
|||
selectedModels?: string[]
|
||||
) => Promise<TeamProvisioningPrepareResult>
|
||||
>((_, __, ___, selectedModels) => {
|
||||
if (!selectedModels || selectedModels.length === 0) {
|
||||
return Promise.resolve({
|
||||
ready: true,
|
||||
message: 'CLI is warmed up and ready to launch',
|
||||
});
|
||||
}
|
||||
return Promise.resolve({
|
||||
ready: false,
|
||||
message:
|
||||
|
|
@ -186,12 +292,6 @@ describe('runProviderPrepareDiagnostics', () => {
|
|||
selectedModels?: string[]
|
||||
) => Promise<TeamProvisioningPrepareResult>
|
||||
>((_, __, ___, selectedModels) => {
|
||||
if (!selectedModels || selectedModels.length === 0) {
|
||||
return Promise.resolve({
|
||||
ready: true,
|
||||
message: 'CLI is warmed up and ready to launch',
|
||||
});
|
||||
}
|
||||
return Promise.resolve({
|
||||
ready: true,
|
||||
message: 'CLI is warmed up and ready to launch',
|
||||
|
|
@ -213,8 +313,12 @@ describe('runProviderPrepareDiagnostics', () => {
|
|||
});
|
||||
|
||||
it('renders the provider default model as a dedicated Default check line', async () => {
|
||||
const progressUpdates: Array<{ details: string[]; completedCount: number; totalCount: number }> =
|
||||
[];
|
||||
const progressUpdates: Array<{
|
||||
status: 'checking' | 'ready' | 'notes' | 'failed';
|
||||
details: string[];
|
||||
completedCount: number;
|
||||
totalCount: number;
|
||||
}> = [];
|
||||
const prepareProvisioning = vi.fn<
|
||||
(
|
||||
cwd?: string,
|
||||
|
|
@ -223,12 +327,6 @@ describe('runProviderPrepareDiagnostics', () => {
|
|||
selectedModels?: string[]
|
||||
) => Promise<TeamProvisioningPrepareResult>
|
||||
>((_, __, ___, selectedModels) => {
|
||||
if (!selectedModels || selectedModels.length === 0) {
|
||||
return Promise.resolve({
|
||||
ready: true,
|
||||
message: 'CLI is warmed up and ready to launch',
|
||||
});
|
||||
}
|
||||
return Promise.resolve({
|
||||
ready: true,
|
||||
message: 'CLI is warmed up and ready to launch',
|
||||
|
|
@ -245,6 +343,7 @@ describe('runProviderPrepareDiagnostics', () => {
|
|||
});
|
||||
|
||||
expect(progressUpdates[0]).toEqual({
|
||||
status: 'checking',
|
||||
completedCount: 0,
|
||||
totalCount: 1,
|
||||
details: ['Default - checking...'],
|
||||
|
|
@ -263,12 +362,6 @@ describe('runProviderPrepareDiagnostics', () => {
|
|||
limitContext?: boolean
|
||||
) => Promise<TeamProvisioningPrepareResult>
|
||||
>((_, __, ___, selectedModels) => {
|
||||
if (!selectedModels || selectedModels.length === 0) {
|
||||
return Promise.resolve({
|
||||
ready: true,
|
||||
message: 'CLI is warmed up and ready to launch',
|
||||
});
|
||||
}
|
||||
return Promise.resolve({
|
||||
ready: true,
|
||||
message: 'CLI is warmed up and ready to launch',
|
||||
|
|
@ -285,27 +378,18 @@ describe('runProviderPrepareDiagnostics', () => {
|
|||
});
|
||||
|
||||
expect(result.details).toEqual(['Default - verified']);
|
||||
expect(prepareProvisioning).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
'/tmp/project',
|
||||
'anthropic',
|
||||
['anthropic'],
|
||||
undefined,
|
||||
true
|
||||
);
|
||||
expect(prepareProvisioning).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
'/tmp/project',
|
||||
'anthropic',
|
||||
['anthropic'],
|
||||
[DEFAULT_PROVIDER_MODEL_SELECTION],
|
||||
true
|
||||
);
|
||||
expect(prepareProvisioning).toHaveBeenNthCalledWith(1, '/tmp/project', 'anthropic', ['anthropic'], [
|
||||
DEFAULT_PROVIDER_MODEL_SELECTION,
|
||||
], true);
|
||||
});
|
||||
|
||||
it('reuses cached model results and probes only newly selected models', async () => {
|
||||
const progressUpdates: Array<{ details: string[]; completedCount: number; totalCount: number }> =
|
||||
[];
|
||||
const progressUpdates: Array<{
|
||||
status: 'checking' | 'ready' | 'notes' | 'failed';
|
||||
details: string[];
|
||||
completedCount: number;
|
||||
totalCount: number;
|
||||
}> = [];
|
||||
const prepareProvisioning = vi.fn<
|
||||
(
|
||||
cwd?: string,
|
||||
|
|
@ -314,13 +398,6 @@ describe('runProviderPrepareDiagnostics', () => {
|
|||
selectedModels?: string[]
|
||||
) => Promise<TeamProvisioningPrepareResult>
|
||||
>((_, __, ___, selectedModels) => {
|
||||
if (!selectedModels || selectedModels.length === 0) {
|
||||
return Promise.resolve({
|
||||
ready: true,
|
||||
message: 'CLI is warmed up and ready to launch',
|
||||
});
|
||||
}
|
||||
|
||||
expect(selectedModels).toEqual(['gpt-5.2-codex']);
|
||||
return Promise.resolve({
|
||||
ready: false,
|
||||
|
|
@ -350,6 +427,7 @@ describe('runProviderPrepareDiagnostics', () => {
|
|||
});
|
||||
|
||||
expect(progressUpdates[0]).toEqual({
|
||||
status: 'checking',
|
||||
completedCount: 2,
|
||||
totalCount: 3,
|
||||
details: ['5.2 - verified', '5.4 Mini - verified', '5.2 Codex - checking...'],
|
||||
|
|
@ -359,16 +437,8 @@ describe('runProviderPrepareDiagnostics', () => {
|
|||
'5.4 Mini - verified',
|
||||
'5.2 Codex - unavailable - Not available on this Codex native runtime',
|
||||
]);
|
||||
expect(prepareProvisioning).toHaveBeenCalledTimes(2);
|
||||
expect(prepareProvisioning).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
'/tmp/project',
|
||||
'codex',
|
||||
['codex'],
|
||||
undefined,
|
||||
undefined
|
||||
);
|
||||
expect(prepareProvisioning).toHaveBeenNthCalledWith(2, '/tmp/project', 'codex', ['codex'], [
|
||||
expect(prepareProvisioning).toHaveBeenCalledTimes(1);
|
||||
expect(prepareProvisioning).toHaveBeenNthCalledWith(1, '/tmp/project', 'codex', ['codex'], [
|
||||
'gpt-5.2-codex',
|
||||
], undefined);
|
||||
});
|
||||
|
|
@ -382,23 +452,16 @@ describe('runProviderPrepareDiagnostics', () => {
|
|||
selectedModels?: string[]
|
||||
) => Promise<TeamProvisioningPrepareResult>
|
||||
>((_, __, ___, selectedModels) => {
|
||||
if (!selectedModels || selectedModels.length === 0) {
|
||||
return Promise.resolve({
|
||||
ready: true,
|
||||
message: 'CLI is ready to launch (see notes)',
|
||||
warnings: [
|
||||
'Preflight check for `orchestrator-cli -p` did not complete. Proceeding anyway. Details: Timeout running: orchestrator-cli -p Output only the single word PONG. --output-format text --model gpt-5.4-mini --max-turns 1 --no-session-persistence',
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
return Promise.resolve({
|
||||
ready: true,
|
||||
message: 'CLI is warmed up and ready to launch',
|
||||
message: 'CLI is ready to launch (see notes)',
|
||||
details: [
|
||||
'Selected model gpt-5.4-mini verified for launch.',
|
||||
'Selected model gpt-5.4 verified for launch.',
|
||||
],
|
||||
warnings: [
|
||||
'Preflight check for `orchestrator-cli -p` did not complete. Proceeding anyway. Details: Timeout running: orchestrator-cli -p Output only the single word PONG. --output-format text --model gpt-5.4-mini --max-turns 1 --no-session-persistence',
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
|
|
@ -423,14 +486,6 @@ describe('runProviderPrepareDiagnostics', () => {
|
|||
selectedModels?: string[]
|
||||
) => Promise<TeamProvisioningPrepareResult>
|
||||
>((_, __, ___, selectedModels) => {
|
||||
if (!selectedModels || selectedModels.length === 0) {
|
||||
return Promise.resolve({
|
||||
ready: true,
|
||||
message: 'CLI is ready to launch (see notes)',
|
||||
warnings: ['orchestrator-cli preflight check failed (exit code 1).'],
|
||||
});
|
||||
}
|
||||
|
||||
return Promise.resolve({
|
||||
ready: true,
|
||||
message: 'CLI is ready to launch (see notes)',
|
||||
|
|
@ -457,7 +512,13 @@ describe('runProviderPrepareDiagnostics', () => {
|
|||
});
|
||||
});
|
||||
|
||||
it('prefers detailed OpenCode auth diagnostics over a generic not_authenticated batch message', async () => {
|
||||
it('suppresses a generic runtime preflight note during progress when cached selected models are already verified', async () => {
|
||||
const progressUpdates: Array<{
|
||||
status: 'checking' | 'ready' | 'notes' | 'failed';
|
||||
details: string[];
|
||||
completedCount: number;
|
||||
totalCount: number;
|
||||
}> = [];
|
||||
const prepareProvisioning = vi.fn<
|
||||
(
|
||||
cwd?: string,
|
||||
|
|
@ -469,10 +530,61 @@ describe('runProviderPrepareDiagnostics', () => {
|
|||
if (!selectedModels || selectedModels.length === 0) {
|
||||
return Promise.resolve({
|
||||
ready: true,
|
||||
message: 'CLI is warmed up and ready to launch',
|
||||
message: 'CLI is ready to launch (see notes)',
|
||||
warnings: ['orchestrator-cli preflight check failed (exit code 1).'],
|
||||
});
|
||||
}
|
||||
|
||||
return Promise.resolve({
|
||||
ready: true,
|
||||
message: 'CLI is ready to launch (see notes)',
|
||||
warnings: ['orchestrator-cli preflight check failed (exit code 1).'],
|
||||
});
|
||||
});
|
||||
|
||||
const result = await runProviderPrepareDiagnostics({
|
||||
cwd: '/tmp/project',
|
||||
providerId: 'codex',
|
||||
selectedModelIds: [DEFAULT_PROVIDER_MODEL_SELECTION, 'gpt-5.4'],
|
||||
prepareProvisioning,
|
||||
onModelProgress: (progress) => progressUpdates.push(progress),
|
||||
cachedModelResultsById: {
|
||||
[DEFAULT_PROVIDER_MODEL_SELECTION]: {
|
||||
status: 'ready',
|
||||
line: 'Default - verified',
|
||||
warningLine: null,
|
||||
},
|
||||
'gpt-5.4': {
|
||||
status: 'ready',
|
||||
line: '5.4 - verified',
|
||||
warningLine: null,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(prepareProvisioning).toHaveBeenCalledTimes(1);
|
||||
expect(progressUpdates).toEqual([
|
||||
{
|
||||
status: 'ready',
|
||||
completedCount: 2,
|
||||
totalCount: 2,
|
||||
details: ['Default - verified', '5.4 - verified'],
|
||||
},
|
||||
]);
|
||||
expect(result.status).toBe('ready');
|
||||
expect(result.warnings).toEqual([]);
|
||||
expect(result.details).toEqual(['Default - verified', '5.4 - verified']);
|
||||
});
|
||||
|
||||
it('prefers detailed OpenCode auth diagnostics over a generic not_authenticated batch message', async () => {
|
||||
const prepareProvisioning = vi.fn<
|
||||
(
|
||||
cwd?: string,
|
||||
providerId?: TeamProviderId,
|
||||
providerIds?: TeamProviderId[],
|
||||
selectedModels?: string[]
|
||||
) => Promise<TeamProvisioningPrepareResult>
|
||||
>((_, __, ___, selectedModels) => {
|
||||
return Promise.resolve({
|
||||
ready: false,
|
||||
message: 'OpenCode: not_authenticated',
|
||||
|
|
|
|||
|
|
@ -0,0 +1,464 @@
|
|||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
buildProviderPrepareMembersSignature,
|
||||
buildProviderPrepareModelChecksSignature,
|
||||
buildProviderPrepareRequestSignature,
|
||||
buildProviderPrepareRuntimeStatusSignature,
|
||||
} from '@renderer/components/team/dialogs/providerPrepareRequestSignature';
|
||||
|
||||
describe('providerPrepareRequestSignature', () => {
|
||||
it('stays stable for semantically identical provider runtime snapshots', () => {
|
||||
const providerIds = ['codex'] as const;
|
||||
const first = buildProviderPrepareRuntimeStatusSignature(
|
||||
providerIds,
|
||||
new Map([
|
||||
[
|
||||
'codex',
|
||||
{
|
||||
providerId: 'codex',
|
||||
supported: true,
|
||||
authenticated: true,
|
||||
authMethod: 'chatgpt',
|
||||
verificationState: 'verified',
|
||||
modelVerificationState: 'verified',
|
||||
statusMessage: null,
|
||||
detailMessage: null,
|
||||
selectedBackendId: 'codex-native',
|
||||
resolvedBackendId: 'codex-native',
|
||||
models: ['gpt-5.4', 'gpt-5.4-mini'],
|
||||
modelCatalog: {
|
||||
source: 'app-server',
|
||||
status: 'ready',
|
||||
models: [{ id: 'gpt-5.4-mini' }, { id: 'gpt-5.4' }],
|
||||
},
|
||||
availableBackends: [
|
||||
{
|
||||
id: 'codex-native',
|
||||
available: true,
|
||||
selectable: true,
|
||||
state: 'ready',
|
||||
recommended: true,
|
||||
audience: 'general',
|
||||
},
|
||||
],
|
||||
capabilities: {
|
||||
teamLaunch: true,
|
||||
oneShot: true,
|
||||
extensions: {
|
||||
displayAvailable: true,
|
||||
installAvailable: true,
|
||||
},
|
||||
},
|
||||
canLoginFromUi: true,
|
||||
},
|
||||
],
|
||||
]) as any
|
||||
);
|
||||
const second = buildProviderPrepareRuntimeStatusSignature(
|
||||
providerIds,
|
||||
new Map([
|
||||
[
|
||||
'codex',
|
||||
{
|
||||
providerId: 'codex',
|
||||
supported: true,
|
||||
authenticated: true,
|
||||
authMethod: 'chatgpt',
|
||||
verificationState: 'verified',
|
||||
modelVerificationState: 'verified',
|
||||
statusMessage: null,
|
||||
detailMessage: null,
|
||||
selectedBackendId: 'codex-native',
|
||||
resolvedBackendId: 'codex-native',
|
||||
models: ['gpt-5.4-mini', 'gpt-5.4'],
|
||||
modelCatalog: {
|
||||
source: 'app-server',
|
||||
status: 'ready',
|
||||
models: [{ id: 'gpt-5.4' }, { id: 'gpt-5.4-mini' }],
|
||||
},
|
||||
availableBackends: [
|
||||
{
|
||||
id: 'codex-native',
|
||||
available: true,
|
||||
selectable: true,
|
||||
state: 'ready',
|
||||
recommended: true,
|
||||
audience: 'general',
|
||||
},
|
||||
],
|
||||
capabilities: {
|
||||
teamLaunch: true,
|
||||
oneShot: true,
|
||||
extensions: {
|
||||
displayAvailable: true,
|
||||
installAvailable: true,
|
||||
},
|
||||
},
|
||||
canLoginFromUi: true,
|
||||
},
|
||||
],
|
||||
]) as any
|
||||
);
|
||||
|
||||
expect(first).toBe(second);
|
||||
});
|
||||
|
||||
it('changes when a provider auth/runtime field that affects preflight changes', () => {
|
||||
const providerIds = ['codex'] as const;
|
||||
const authenticated = buildProviderPrepareRuntimeStatusSignature(
|
||||
providerIds,
|
||||
new Map([
|
||||
[
|
||||
'codex',
|
||||
{
|
||||
providerId: 'codex',
|
||||
supported: true,
|
||||
authenticated: true,
|
||||
authMethod: 'chatgpt',
|
||||
verificationState: 'verified',
|
||||
models: ['gpt-5.4'],
|
||||
capabilities: {
|
||||
teamLaunch: true,
|
||||
oneShot: true,
|
||||
extensions: {
|
||||
displayAvailable: true,
|
||||
installAvailable: true,
|
||||
},
|
||||
},
|
||||
canLoginFromUi: true,
|
||||
},
|
||||
],
|
||||
]) as any
|
||||
);
|
||||
const unauthenticated = buildProviderPrepareRuntimeStatusSignature(
|
||||
providerIds,
|
||||
new Map([
|
||||
[
|
||||
'codex',
|
||||
{
|
||||
providerId: 'codex',
|
||||
supported: true,
|
||||
authenticated: false,
|
||||
authMethod: null,
|
||||
verificationState: 'error',
|
||||
detailMessage: 'Reconnect required',
|
||||
models: ['gpt-5.4'],
|
||||
capabilities: {
|
||||
teamLaunch: true,
|
||||
oneShot: true,
|
||||
extensions: {
|
||||
displayAvailable: true,
|
||||
installAvailable: true,
|
||||
},
|
||||
},
|
||||
canLoginFromUi: true,
|
||||
},
|
||||
],
|
||||
]) as any
|
||||
);
|
||||
|
||||
expect(authenticated).not.toBe(unauthenticated);
|
||||
});
|
||||
|
||||
it('changes when provider connection auth truth changes even if model lists stay the same', () => {
|
||||
const providerIds = ['codex'] as const;
|
||||
const first = buildProviderPrepareRuntimeStatusSignature(
|
||||
providerIds,
|
||||
new Map([
|
||||
[
|
||||
'codex',
|
||||
{
|
||||
providerId: 'codex',
|
||||
supported: true,
|
||||
authenticated: true,
|
||||
authMethod: 'chatgpt',
|
||||
verificationState: 'verified',
|
||||
models: ['gpt-5.4'],
|
||||
connection: {
|
||||
supportsOAuth: false,
|
||||
supportsApiKey: true,
|
||||
configurableAuthModes: ['auto', 'chatgpt', 'api_key'],
|
||||
configuredAuthMode: 'chatgpt',
|
||||
apiKeyConfigured: true,
|
||||
apiKeySource: 'environment',
|
||||
codex: {
|
||||
preferredAuthMode: 'chatgpt',
|
||||
effectiveAuthMode: 'chatgpt',
|
||||
appServerState: 'healthy',
|
||||
appServerStatusMessage: null,
|
||||
managedAccount: {
|
||||
type: 'chatgpt',
|
||||
email: 'user@example.com',
|
||||
},
|
||||
requiresOpenaiAuth: false,
|
||||
localAccountArtifactsPresent: true,
|
||||
localActiveChatgptAccountPresent: true,
|
||||
login: {
|
||||
status: 'idle',
|
||||
error: null,
|
||||
},
|
||||
rateLimits: null,
|
||||
launchAllowed: true,
|
||||
launchIssueMessage: null,
|
||||
launchReadinessState: 'ready_chatgpt',
|
||||
},
|
||||
},
|
||||
capabilities: {
|
||||
teamLaunch: true,
|
||||
oneShot: true,
|
||||
extensions: {
|
||||
displayAvailable: true,
|
||||
installAvailable: true,
|
||||
},
|
||||
},
|
||||
canLoginFromUi: true,
|
||||
},
|
||||
],
|
||||
]) as any
|
||||
);
|
||||
const second = buildProviderPrepareRuntimeStatusSignature(
|
||||
providerIds,
|
||||
new Map([
|
||||
[
|
||||
'codex',
|
||||
{
|
||||
providerId: 'codex',
|
||||
supported: true,
|
||||
authenticated: true,
|
||||
authMethod: 'chatgpt',
|
||||
verificationState: 'verified',
|
||||
models: ['gpt-5.4'],
|
||||
connection: {
|
||||
supportsOAuth: false,
|
||||
supportsApiKey: true,
|
||||
configurableAuthModes: ['auto', 'chatgpt', 'api_key'],
|
||||
configuredAuthMode: 'api_key',
|
||||
apiKeyConfigured: true,
|
||||
apiKeySource: 'environment',
|
||||
codex: {
|
||||
preferredAuthMode: 'auto',
|
||||
effectiveAuthMode: 'api_key',
|
||||
appServerState: 'healthy',
|
||||
appServerStatusMessage: null,
|
||||
managedAccount: {
|
||||
type: 'chatgpt',
|
||||
email: 'user@example.com',
|
||||
},
|
||||
requiresOpenaiAuth: false,
|
||||
localAccountArtifactsPresent: true,
|
||||
localActiveChatgptAccountPresent: true,
|
||||
login: {
|
||||
status: 'idle',
|
||||
error: null,
|
||||
},
|
||||
rateLimits: null,
|
||||
launchAllowed: true,
|
||||
launchIssueMessage: null,
|
||||
launchReadinessState: 'ready_api_key',
|
||||
},
|
||||
},
|
||||
capabilities: {
|
||||
teamLaunch: true,
|
||||
oneShot: true,
|
||||
extensions: {
|
||||
displayAvailable: true,
|
||||
installAvailable: true,
|
||||
},
|
||||
},
|
||||
canLoginFromUi: true,
|
||||
},
|
||||
],
|
||||
]) as any
|
||||
);
|
||||
|
||||
expect(first).not.toBe(second);
|
||||
});
|
||||
|
||||
it('ignores volatile provider status copy that should not retrigger preflight', () => {
|
||||
const providerIds = ['opencode'] as const;
|
||||
const first = buildProviderPrepareRuntimeStatusSignature(
|
||||
providerIds,
|
||||
new Map([
|
||||
[
|
||||
'opencode',
|
||||
{
|
||||
providerId: 'opencode',
|
||||
supported: true,
|
||||
authenticated: true,
|
||||
authMethod: 'oauth',
|
||||
verificationState: 'verified',
|
||||
modelVerificationState: 'verified',
|
||||
statusMessage: 'Syncing provider details...',
|
||||
detailMessage: 'Polling host readiness',
|
||||
models: ['opencode/minimax-m2.5-free', 'opencode/nemotron-3-super-free'],
|
||||
modelCatalog: {
|
||||
source: 'live',
|
||||
status: 'ready',
|
||||
models: [
|
||||
{ id: 'opencode/minimax-m2.5-free' },
|
||||
{ id: 'opencode/nemotron-3-super-free' },
|
||||
],
|
||||
},
|
||||
modelAvailability: [
|
||||
{
|
||||
modelId: 'opencode/minimax-m2.5-free',
|
||||
status: 'available',
|
||||
reason: 'Warm host pending',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
]) as any
|
||||
);
|
||||
const second = buildProviderPrepareRuntimeStatusSignature(
|
||||
providerIds,
|
||||
new Map([
|
||||
[
|
||||
'opencode',
|
||||
{
|
||||
providerId: 'opencode',
|
||||
supported: true,
|
||||
authenticated: true,
|
||||
authMethod: 'oauth',
|
||||
verificationState: 'verified',
|
||||
modelVerificationState: 'verified',
|
||||
statusMessage: 'Healthy',
|
||||
detailMessage: 'MCP ready',
|
||||
models: ['opencode/minimax-m2.5-free', 'opencode/nemotron-3-super-free'],
|
||||
modelCatalog: {
|
||||
source: 'live',
|
||||
status: 'ready',
|
||||
models: [
|
||||
{ id: 'opencode/minimax-m2.5-free' },
|
||||
{ id: 'opencode/nemotron-3-super-free' },
|
||||
],
|
||||
},
|
||||
modelAvailability: [
|
||||
{
|
||||
modelId: 'opencode/minimax-m2.5-free',
|
||||
status: 'available',
|
||||
reason: 'Deep probe still running',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
]) as any
|
||||
);
|
||||
|
||||
expect(first).toBe(second);
|
||||
});
|
||||
|
||||
it('ignores live verification fields that can drift while preflight is already running', () => {
|
||||
const providerIds = ['opencode'] as const;
|
||||
const first = buildProviderPrepareRuntimeStatusSignature(
|
||||
providerIds,
|
||||
new Map([
|
||||
[
|
||||
'opencode',
|
||||
{
|
||||
providerId: 'opencode',
|
||||
supported: true,
|
||||
authenticated: true,
|
||||
authMethod: 'oauth',
|
||||
verificationState: 'unknown',
|
||||
modelVerificationState: 'unknown',
|
||||
models: ['opencode/minimax-m2.5-free', 'opencode/nemotron-3-super-free'],
|
||||
modelCatalog: {
|
||||
source: 'live',
|
||||
status: 'ready',
|
||||
models: [
|
||||
{ id: 'opencode/minimax-m2.5-free' },
|
||||
{ id: 'opencode/nemotron-3-super-free' },
|
||||
],
|
||||
},
|
||||
modelAvailability: [
|
||||
{
|
||||
modelId: 'opencode/minimax-m2.5-free',
|
||||
status: 'unknown',
|
||||
reason: null,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
]) as any
|
||||
);
|
||||
const second = buildProviderPrepareRuntimeStatusSignature(
|
||||
providerIds,
|
||||
new Map([
|
||||
[
|
||||
'opencode',
|
||||
{
|
||||
providerId: 'opencode',
|
||||
supported: true,
|
||||
authenticated: true,
|
||||
authMethod: 'oauth',
|
||||
verificationState: 'verified',
|
||||
modelVerificationState: 'verified',
|
||||
models: ['opencode/minimax-m2.5-free', 'opencode/nemotron-3-super-free'],
|
||||
modelCatalog: {
|
||||
source: 'live',
|
||||
status: 'ready',
|
||||
models: [
|
||||
{ id: 'opencode/minimax-m2.5-free' },
|
||||
{ id: 'opencode/nemotron-3-super-free' },
|
||||
],
|
||||
},
|
||||
modelAvailability: [
|
||||
{
|
||||
modelId: 'opencode/minimax-m2.5-free',
|
||||
status: 'available',
|
||||
reason: 'verified',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
]) as any
|
||||
);
|
||||
|
||||
expect(first).toBe(second);
|
||||
});
|
||||
|
||||
it('builds a stable composite request signature for unchanged member/model selections', () => {
|
||||
const membersSignature = buildProviderPrepareMembersSignature([
|
||||
{
|
||||
id: 'member-1',
|
||||
name: 'alice',
|
||||
roleSelection: '',
|
||||
customRole: 'Reviewer',
|
||||
providerId: 'codex',
|
||||
model: 'gpt-5.4',
|
||||
},
|
||||
]);
|
||||
const modelChecksSignature = buildProviderPrepareModelChecksSignature(
|
||||
new Map([
|
||||
['codex', ['gpt-5.4', 'default']],
|
||||
['opencode', ['opencode/nemotron-3-super-free']],
|
||||
])
|
||||
);
|
||||
|
||||
expect(
|
||||
buildProviderPrepareRequestSignature({
|
||||
cwd: '/tmp/project',
|
||||
selectedProviderId: 'codex',
|
||||
selectedModel: 'gpt-5.4',
|
||||
selectedMemberProviders: ['codex', 'opencode'],
|
||||
limitContext: false,
|
||||
runtimeStatusSignature: 'runtime-a',
|
||||
membersSignature,
|
||||
modelChecksSignature,
|
||||
})
|
||||
).toBe(
|
||||
buildProviderPrepareRequestSignature({
|
||||
cwd: '/tmp/project',
|
||||
selectedProviderId: 'codex',
|
||||
selectedModel: 'gpt-5.4',
|
||||
selectedMemberProviders: ['opencode', 'codex'],
|
||||
limitContext: false,
|
||||
runtimeStatusSignature: 'runtime-a',
|
||||
membersSignature,
|
||||
modelChecksSignature,
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,91 @@
|
|||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import {
|
||||
__resetShortLivedProviderPrepareCacheForTests,
|
||||
getShortLivedProviderPrepareModelResults,
|
||||
storeShortLivedProviderPrepareModelResults,
|
||||
} from '@renderer/components/team/dialogs/providerPrepareShortLivedCache';
|
||||
|
||||
describe('providerPrepareShortLivedCache', () => {
|
||||
afterEach(() => {
|
||||
__resetShortLivedProviderPrepareCacheForTests();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it('stores only successful OpenCode deep verification results', () => {
|
||||
storeShortLivedProviderPrepareModelResults({
|
||||
providerId: 'opencode',
|
||||
cacheKey: 'key-1',
|
||||
modelResultsById: {
|
||||
'opencode/minimax-m2.5-free': {
|
||||
status: 'ready',
|
||||
line: 'minimax-m2.5-free - verified',
|
||||
warningLine: null,
|
||||
},
|
||||
'opencode/nemotron-3-super-free': {
|
||||
status: 'notes',
|
||||
line: 'nemotron-3-super-free - check failed - timed out',
|
||||
warningLine: 'nemotron-3-super-free - check failed - timed out',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(
|
||||
getShortLivedProviderPrepareModelResults({
|
||||
providerId: 'opencode',
|
||||
cacheKey: 'key-1',
|
||||
})
|
||||
).toEqual({
|
||||
'opencode/minimax-m2.5-free': {
|
||||
status: 'ready',
|
||||
line: 'minimax-m2.5-free - verified',
|
||||
warningLine: null,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('expires cached OpenCode results after the short-lived TTL', () => {
|
||||
vi.useFakeTimers();
|
||||
storeShortLivedProviderPrepareModelResults({
|
||||
providerId: 'opencode',
|
||||
cacheKey: 'key-2',
|
||||
modelResultsById: {
|
||||
'opencode/minimax-m2.5-free': {
|
||||
status: 'ready',
|
||||
line: 'minimax-m2.5-free - verified',
|
||||
warningLine: null,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
vi.advanceTimersByTime(45_001);
|
||||
|
||||
expect(
|
||||
getShortLivedProviderPrepareModelResults({
|
||||
providerId: 'opencode',
|
||||
cacheKey: 'key-2',
|
||||
})
|
||||
).toEqual({});
|
||||
});
|
||||
|
||||
it('does not store short-lived cache for non-OpenCode providers', () => {
|
||||
storeShortLivedProviderPrepareModelResults({
|
||||
providerId: 'codex',
|
||||
cacheKey: 'key-3',
|
||||
modelResultsById: {
|
||||
'gpt-5.4': {
|
||||
status: 'ready',
|
||||
line: '5.4 - verified',
|
||||
warningLine: null,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(
|
||||
getShortLivedProviderPrepareModelResults({
|
||||
providerId: 'codex',
|
||||
cacheKey: 'key-3',
|
||||
})
|
||||
).toEqual({});
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,37 @@
|
|||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { collectActiveMemberProviderIds } from '@renderer/components/team/dialogs/provisioningMemberScope';
|
||||
|
||||
import type { MemberDraft } from '@renderer/components/team/members/membersEditorTypes';
|
||||
|
||||
function member(overrides: Partial<MemberDraft> = {}): MemberDraft {
|
||||
return {
|
||||
id: overrides.id ?? 'member-1',
|
||||
name: overrides.name ?? 'alice',
|
||||
roleSelection: overrides.roleSelection ?? 'developer',
|
||||
customRole: overrides.customRole ?? '',
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe('collectActiveMemberProviderIds', () => {
|
||||
it('collects only active member provider ids', () => {
|
||||
expect(
|
||||
collectActiveMemberProviderIds([
|
||||
member({ id: '1', providerId: 'codex' }),
|
||||
member({ id: '2', providerId: 'opencode' }),
|
||||
member({ id: '3', providerId: 'codex', removedAt: Date.now() }),
|
||||
member({ id: '4' }),
|
||||
])
|
||||
).toEqual(['codex', 'opencode']);
|
||||
});
|
||||
|
||||
it('ignores removed members even when they still carry provider overrides', () => {
|
||||
expect(
|
||||
collectActiveMemberProviderIds([
|
||||
member({ id: '1', providerId: 'codex', removedAt: Date.now() }),
|
||||
member({ id: '2', providerId: 'gemini', removedAt: '2026-04-22T00:00:00.000Z' }),
|
||||
])
|
||||
).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
|
@ -127,6 +127,9 @@ describe('cliInstallerSlice', () => {
|
|||
// Reset store state
|
||||
useStore.setState({
|
||||
cliStatus: null,
|
||||
cliStatusLoading: false,
|
||||
cliProviderStatusLoading: {},
|
||||
cliStatusError: null,
|
||||
cliInstallerState: 'idle',
|
||||
cliDownloadProgress: 0,
|
||||
cliDownloadTransferred: 0,
|
||||
|
|
@ -705,6 +708,100 @@ describe('cliInstallerSlice', () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe('fetchCliProviderStatus', () => {
|
||||
it('materializes provider fetch failures into provider-scoped error state', async () => {
|
||||
useStore.setState({
|
||||
cliStatus: createMultimodelStatus([
|
||||
createMultimodelProvider({
|
||||
providerId: 'anthropic',
|
||||
displayName: 'Anthropic',
|
||||
verificationState: 'unknown',
|
||||
statusMessage: 'Checking...',
|
||||
}),
|
||||
createMultimodelProvider({
|
||||
providerId: 'codex',
|
||||
displayName: 'Codex',
|
||||
authenticated: true,
|
||||
authMethod: 'chatgpt',
|
||||
statusMessage: 'ChatGPT account ready',
|
||||
}),
|
||||
]),
|
||||
});
|
||||
vi.mocked(api.cliInstaller.getProviderStatus).mockRejectedValue(
|
||||
new Error('Failed to refresh anthropic status')
|
||||
);
|
||||
|
||||
await useStore.getState().fetchCliProviderStatus('anthropic');
|
||||
|
||||
expect(useStore.getState().cliProviderStatusLoading).toEqual({
|
||||
anthropic: false,
|
||||
});
|
||||
expect(useStore.getState().cliStatusError).toBe('Failed to refresh anthropic status');
|
||||
expect(
|
||||
useStore.getState().cliStatus?.providers.find((provider) => provider.providerId === 'anthropic')
|
||||
).toMatchObject({
|
||||
displayName: 'Anthropic',
|
||||
authenticated: false,
|
||||
authMethod: null,
|
||||
verificationState: 'error',
|
||||
statusMessage: 'Failed to refresh anthropic status',
|
||||
});
|
||||
expect(useStore.getState().cliStatus?.authStatusChecking).toBe(false);
|
||||
});
|
||||
|
||||
it('marks authStatusChecking true while a multimodel provider refresh is in flight and clears it on success', async () => {
|
||||
let resolveProviderStatus!: (value: CliInstallationStatus['providers'][number]) => void;
|
||||
const pendingProviderStatus = new Promise<CliInstallationStatus['providers'][number]>((resolve) => {
|
||||
resolveProviderStatus = resolve;
|
||||
});
|
||||
|
||||
useStore.setState({
|
||||
cliStatus: createMultimodelStatus([
|
||||
createMultimodelProvider({
|
||||
providerId: 'anthropic',
|
||||
displayName: 'Anthropic',
|
||||
authenticated: true,
|
||||
authMethod: 'oauth_token',
|
||||
statusMessage: 'Connected',
|
||||
}),
|
||||
]),
|
||||
});
|
||||
vi.mocked(api.cliInstaller.getProviderStatus).mockImplementation(async (providerId) => {
|
||||
if (providerId === 'anthropic') {
|
||||
return pendingProviderStatus;
|
||||
}
|
||||
|
||||
throw new Error(`Unexpected provider status request for ${providerId}`);
|
||||
});
|
||||
|
||||
const refreshPromise = useStore.getState().fetchCliProviderStatus('anthropic');
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(useStore.getState().cliStatus?.authStatusChecking).toBe(true);
|
||||
});
|
||||
|
||||
expect(useStore.getState().cliProviderStatusLoading).toEqual({
|
||||
anthropic: true,
|
||||
});
|
||||
|
||||
resolveProviderStatus(
|
||||
createMultimodelProvider({
|
||||
providerId: 'anthropic',
|
||||
displayName: 'Anthropic',
|
||||
authenticated: true,
|
||||
authMethod: 'oauth_token',
|
||||
statusMessage: 'Connected',
|
||||
})
|
||||
);
|
||||
await refreshPromise;
|
||||
|
||||
expect(useStore.getState().cliProviderStatusLoading).toEqual({
|
||||
anthropic: false,
|
||||
});
|
||||
expect(useStore.getState().cliStatus?.authStatusChecking).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('progress event handling', () => {
|
||||
it('updates download progress from events', () => {
|
||||
useStore.setState({
|
||||
|
|
|
|||
|
|
@ -184,10 +184,8 @@ describe('teamModelAvailability', () => {
|
|||
expect(getTeamModelSelectionError('codex', 'gpt-5.4', providerStatus)).toBeNull();
|
||||
});
|
||||
|
||||
it('waits for the runtime model list before validating explicit Codex selections', () => {
|
||||
expect(getTeamModelSelectionError('codex', 'gpt-5.4')).toContain(
|
||||
'waiting for Codex runtime verification'
|
||||
);
|
||||
it('does not raise a hard validation error while explicit Codex models are still loading', () => {
|
||||
expect(getTeamModelSelectionError('codex', 'gpt-5.4')).toBeNull();
|
||||
expect(getTeamModelSelectionError('codex', '')).toBeNull();
|
||||
});
|
||||
|
||||
|
|
@ -232,6 +230,25 @@ describe('teamModelAvailability', () => {
|
|||
]);
|
||||
});
|
||||
|
||||
it('keeps known Codex selections stable while Codex native account truth is loaded before the runtime model catalog', () => {
|
||||
const providerStatus = createCodexProviderStatus([], {
|
||||
authMethod: 'chatgpt',
|
||||
backend: {
|
||||
kind: 'codex-native',
|
||||
label: 'Codex native',
|
||||
endpointLabel: 'codex exec --json',
|
||||
},
|
||||
authenticated: true,
|
||||
supported: true,
|
||||
verificationState: 'verified',
|
||||
modelVerificationState: 'idle',
|
||||
statusMessage: 'ChatGPT account ready',
|
||||
});
|
||||
|
||||
expect(normalizeTeamModelForUi('codex', 'gpt-5.4', providerStatus)).toBe('gpt-5.4');
|
||||
expect(getTeamModelSelectionError('codex', 'gpt-5.4', providerStatus)).toBeNull();
|
||||
});
|
||||
|
||||
it('keeps runtime models selectable without per-model verification state', () => {
|
||||
const providerStatus = createCodexProviderStatus(['gpt-5.4']);
|
||||
expect(normalizeTeamModelForUi('codex', 'gpt-5.4', providerStatus)).toBe('gpt-5.4');
|
||||
|
|
|
|||
Loading…
Reference in a new issue