perf(team): reduce verified config reads
This commit is contained in:
parent
4385b0c679
commit
a652c44794
10 changed files with 453 additions and 142 deletions
|
|
@ -123,6 +123,12 @@ export class ChangeExtractorService {
|
|||
this.taskChangeComputer = new TaskChangeComputer(logsFinder, boundaryParser);
|
||||
}
|
||||
|
||||
private readConfigForObservation(teamName: string) {
|
||||
return typeof this.configReader.getConfigSnapshot === 'function'
|
||||
? this.configReader.getConfigSnapshot(teamName)
|
||||
: this.configReader.getConfig(teamName);
|
||||
}
|
||||
|
||||
setTaskChangePresenceServices(
|
||||
repository: TaskChangePresenceRepository,
|
||||
tracker: TeamLogSourceTracker
|
||||
|
|
@ -671,7 +677,7 @@ export class ChangeExtractorService {
|
|||
try {
|
||||
const [meta, config] = await Promise.all([
|
||||
this.teamMetaStore.getMeta(teamName).catch(() => null),
|
||||
this.configReader.getConfig(teamName).catch(() => null),
|
||||
this.readConfigForObservation(teamName).catch(() => null),
|
||||
]);
|
||||
const hasOpenCodeMember = (config?.members ?? []).some(
|
||||
(member) => member.providerId === 'opencode'
|
||||
|
|
@ -996,7 +1002,7 @@ export class ChangeExtractorService {
|
|||
/** Получить projectPath из конфига команды */
|
||||
private async resolveProjectPath(teamName: string): Promise<string | undefined> {
|
||||
try {
|
||||
const config = await this.configReader.getConfig(teamName);
|
||||
const config = await this.readConfigForObservation(teamName);
|
||||
return config?.projectPath?.trim() || undefined;
|
||||
} catch {
|
||||
return undefined;
|
||||
|
|
|
|||
|
|
@ -1898,7 +1898,7 @@ export class TeamDataService {
|
|||
|
||||
let projectPath: string | undefined;
|
||||
try {
|
||||
const config = await this.configReader.getConfig(teamName);
|
||||
const config = await readConfigForUiSnapshot(this.configReader, teamName);
|
||||
projectPath = config?.projectPath;
|
||||
} catch {
|
||||
/* best-effort */
|
||||
|
|
@ -2237,7 +2237,7 @@ export class TeamDataService {
|
|||
let enrichedRequest = request;
|
||||
if (!enrichedRequest.leadSessionId) {
|
||||
try {
|
||||
const config = await this.configReader.getConfig(teamName);
|
||||
const config = await readConfigForUiSnapshot(this.configReader, teamName);
|
||||
if (config?.leadSessionId) {
|
||||
enrichedRequest = { ...enrichedRequest, leadSessionId: config.leadSessionId };
|
||||
}
|
||||
|
|
@ -2310,7 +2310,7 @@ export class TeamDataService {
|
|||
|
||||
private async resolveLeadName(teamName: string): Promise<string> {
|
||||
try {
|
||||
const config = await this.configReader.getConfig(teamName);
|
||||
const config = await readConfigForUiSnapshot(this.configReader, teamName);
|
||||
return this.resolveLeadNameFromConfig(config);
|
||||
} catch {
|
||||
return 'team-lead';
|
||||
|
|
@ -2321,7 +2321,7 @@ export class TeamDataService {
|
|||
teamName: string
|
||||
): Promise<{ leadName: string; leadSessionId?: string }> {
|
||||
try {
|
||||
const config = await this.configReader.getConfig(teamName);
|
||||
const config = await readConfigForUiSnapshot(this.configReader, teamName);
|
||||
return {
|
||||
leadName: this.resolveLeadNameFromConfig(config),
|
||||
leadSessionId: config?.leadSessionId,
|
||||
|
|
@ -2638,7 +2638,7 @@ export class TeamDataService {
|
|||
const recoverPending = options?.recoverPending === true;
|
||||
let config: TeamConfig | null = null;
|
||||
try {
|
||||
config = await this.configReader.getConfig(teamName);
|
||||
config = await readConfigForUiSnapshot(this.configReader, teamName);
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
|
|
@ -2793,7 +2793,7 @@ export class TeamDataService {
|
|||
): Promise<SendMessageResult> {
|
||||
let leadSessionId: string | undefined;
|
||||
try {
|
||||
const config = await this.configReader.getConfig(teamName);
|
||||
const config = await readConfigForUiSnapshot(this.configReader, teamName);
|
||||
leadSessionId = config?.leadSessionId;
|
||||
} catch {
|
||||
// non-critical — proceed without sessionId
|
||||
|
|
@ -2826,7 +2826,7 @@ export class TeamDataService {
|
|||
|
||||
async getLeadMemberName(teamName: string): Promise<string | null> {
|
||||
try {
|
||||
const config = await this.configReader.getConfig(teamName);
|
||||
const config = await readConfigForUiSnapshot(this.configReader, teamName);
|
||||
|
||||
// Check config.json members first (Claude Code-created teams)
|
||||
if (config?.members?.length) {
|
||||
|
|
|
|||
|
|
@ -4435,6 +4435,27 @@ interface OpenCodeMemberInboxDelivery {
|
|||
diagnostics?: string[];
|
||||
}
|
||||
|
||||
interface OpenCodeMemberDirectory {
|
||||
config: TeamConfig | null;
|
||||
teamMeta: Awaited<ReturnType<TeamMetaStore['getMeta']>> | null;
|
||||
metaMembers: Awaited<ReturnType<TeamMembersMetaStore['getMembers']>>;
|
||||
}
|
||||
|
||||
type OpenCodeMemberIdentityResolution =
|
||||
| {
|
||||
ok: true;
|
||||
canonicalMemberName: string;
|
||||
laneId: string;
|
||||
laneIdentity: ReturnType<typeof buildPlannedMemberLaneIdentity>;
|
||||
configMember?: TeamMember;
|
||||
metaMember?: TeamMember;
|
||||
memberRuntimeCwd?: string;
|
||||
}
|
||||
| {
|
||||
ok: false;
|
||||
reason: 'recipient_is_not_opencode' | 'recipient_removed' | 'opencode_recipient_unavailable';
|
||||
};
|
||||
|
||||
interface OpenCodeMemberInboxRelayResult {
|
||||
relayed: number;
|
||||
attempted: number;
|
||||
|
|
@ -4613,6 +4634,106 @@ export class TeamProvisioningService {
|
|||
: configReader.getConfig(teamName);
|
||||
}
|
||||
|
||||
private readConfigForObservation(teamName: string): Promise<TeamConfig | null> {
|
||||
return this.readConfigSnapshot(teamName);
|
||||
}
|
||||
|
||||
private readConfigForStrictDecision(teamName: string): Promise<TeamConfig | null> {
|
||||
return this.configReader.getConfig(teamName);
|
||||
}
|
||||
|
||||
private async readOpenCodeMemberDirectory(teamName: string): Promise<OpenCodeMemberDirectory> {
|
||||
const [config, teamMeta, metaMembers] = await Promise.all([
|
||||
this.readConfigForObservation(teamName).catch(() => null),
|
||||
this.teamMetaStore.getMeta(teamName).catch(() => null),
|
||||
this.membersMetaStore.getMembers(teamName).catch(() => []),
|
||||
]);
|
||||
return { config, teamMeta, metaMembers };
|
||||
}
|
||||
|
||||
private resolveOpenCodeMemberIdentityFromDirectory(
|
||||
teamName: string,
|
||||
memberName: string,
|
||||
directory: OpenCodeMemberDirectory
|
||||
): OpenCodeMemberIdentityResolution {
|
||||
const normalizedMemberName = memberName.trim();
|
||||
const configMember = directory.config?.members?.find(
|
||||
(member) => member.name?.trim().toLowerCase() === normalizedMemberName.toLowerCase()
|
||||
);
|
||||
const metaMember = directory.metaMembers.find(
|
||||
(member) => member.name?.trim().toLowerCase() === normalizedMemberName.toLowerCase()
|
||||
);
|
||||
if (!configMember && !metaMember) {
|
||||
return { ok: false, reason: 'opencode_recipient_unavailable' };
|
||||
}
|
||||
|
||||
const configProvider = (configMember as { provider?: unknown } | undefined)?.provider;
|
||||
const metaProvider = (metaMember as { provider?: unknown } | undefined)?.provider;
|
||||
const providerId =
|
||||
normalizeTeamProviderLike(metaMember?.providerId) ??
|
||||
normalizeTeamProviderLike(metaProvider) ??
|
||||
normalizeTeamProviderLike(configMember?.providerId) ??
|
||||
normalizeTeamProviderLike(configProvider) ??
|
||||
inferTeamProviderIdFromModel(metaMember?.model ?? configMember?.model);
|
||||
if (providerId !== 'opencode') {
|
||||
return { ok: false, reason: 'recipient_is_not_opencode' };
|
||||
}
|
||||
|
||||
const removedAt =
|
||||
metaMember != null
|
||||
? metaMember.removedAt
|
||||
: (configMember as { removedAt?: unknown } | undefined)?.removedAt;
|
||||
if (removedAt != null) {
|
||||
return { ok: false, reason: 'recipient_removed' };
|
||||
}
|
||||
|
||||
const canonicalMemberName =
|
||||
metaMember?.name?.trim() || configMember?.name?.trim() || normalizedMemberName;
|
||||
const runtimeRun = this.runtimeAdapterRunByTeam.get(teamName);
|
||||
if (runtimeRun?.providerId === 'opencode') {
|
||||
const laneIdentity = buildPlannedMemberLaneIdentity({
|
||||
leadProviderId: 'opencode',
|
||||
member: {
|
||||
name: canonicalMemberName,
|
||||
providerId: 'opencode',
|
||||
},
|
||||
});
|
||||
const memberRuntimeCwd = metaMember?.cwd?.trim() || configMember?.cwd?.trim();
|
||||
return {
|
||||
ok: true,
|
||||
canonicalMemberName,
|
||||
laneId: laneIdentity.laneId,
|
||||
laneIdentity,
|
||||
...(configMember ? { configMember } : {}),
|
||||
...(metaMember ? { metaMember } : {}),
|
||||
...(memberRuntimeCwd ? { memberRuntimeCwd } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
const leadMember = directory.config?.members?.find((member) => isLeadMember(member));
|
||||
const leadProviderId =
|
||||
normalizeOptionalTeamProviderId(directory.teamMeta?.launchIdentity?.providerId) ??
|
||||
normalizeOptionalTeamProviderId(directory.teamMeta?.providerId) ??
|
||||
normalizeOptionalTeamProviderId(leadMember?.providerId);
|
||||
const laneIdentity = buildPlannedMemberLaneIdentity({
|
||||
leadProviderId,
|
||||
member: {
|
||||
name: canonicalMemberName,
|
||||
providerId,
|
||||
},
|
||||
});
|
||||
const memberRuntimeCwd = metaMember?.cwd?.trim() || configMember?.cwd?.trim();
|
||||
return {
|
||||
ok: true,
|
||||
canonicalMemberName,
|
||||
laneId: laneIdentity.laneId,
|
||||
laneIdentity,
|
||||
...(configMember ? { configMember } : {}),
|
||||
...(metaMember ? { metaMember } : {}),
|
||||
...(memberRuntimeCwd ? { memberRuntimeCwd } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
setRuntimeAdapterRegistry(registry: TeamRuntimeAdapterRegistry | null): void {
|
||||
this.runtimeAdapterRegistry = registry;
|
||||
}
|
||||
|
|
@ -5283,7 +5404,7 @@ export class TeamProvisioningService {
|
|||
const adapter = this.getOpenCodeRuntimeAdapter();
|
||||
const previousLaunchState = await this.launchStateStore.read(teamName).catch(() => null);
|
||||
const [config, metaMembers] = await Promise.all([
|
||||
this.configReader.getConfig(teamName).catch(() => null),
|
||||
this.readConfigForObservation(teamName).catch(() => null),
|
||||
this.membersMetaStore.getMembers(teamName).catch(() => []),
|
||||
]);
|
||||
const evidenceReader = new OpenCodeRuntimeManifestEvidenceReader({
|
||||
|
|
@ -5813,8 +5934,7 @@ export class TeamProvisioningService {
|
|||
}): Promise<string[]> {
|
||||
const explicitRecipient = input.replyRecipient?.trim() || 'user';
|
||||
const candidates = [explicitRecipient];
|
||||
const configuredLeadName = await this.configReader
|
||||
.getConfig(input.teamName)
|
||||
const configuredLeadName = await this.readConfigForObservation(input.teamName)
|
||||
.then(
|
||||
(config) => config?.members?.find((member) => isLeadMember(member))?.name?.trim() || null
|
||||
)
|
||||
|
|
@ -6236,51 +6356,25 @@ export class TeamProvisioningService {
|
|||
if (!adapter) {
|
||||
return { delivered: false, reason: 'opencode_runtime_message_bridge_unavailable' };
|
||||
}
|
||||
const [config, teamMeta, metaMembers] = await Promise.all([
|
||||
this.configReader.getConfig(teamName).catch(() => null),
|
||||
this.teamMetaStore.getMeta(teamName).catch(() => null),
|
||||
this.membersMetaStore.getMembers(teamName).catch(() => []),
|
||||
]);
|
||||
const directory = await this.readOpenCodeMemberDirectory(teamName);
|
||||
const identity = this.resolveOpenCodeMemberIdentityFromDirectory(
|
||||
teamName,
|
||||
input.memberName,
|
||||
directory
|
||||
);
|
||||
if (!identity.ok) {
|
||||
return {
|
||||
delivered: false,
|
||||
reason:
|
||||
identity.reason === 'opencode_recipient_unavailable'
|
||||
? 'recipient_is_not_opencode'
|
||||
: identity.reason,
|
||||
};
|
||||
}
|
||||
const { config } = directory;
|
||||
const { canonicalMemberName, laneIdentity, configMember, metaMember, memberRuntimeCwd } =
|
||||
identity;
|
||||
const normalizedMemberName = input.memberName.trim();
|
||||
const configMember = config?.members?.find(
|
||||
(member) => member.name?.trim().toLowerCase() === normalizedMemberName.toLowerCase()
|
||||
);
|
||||
const metaMember = metaMembers.find(
|
||||
(member) => member.name?.trim().toLowerCase() === normalizedMemberName.toLowerCase()
|
||||
);
|
||||
const configProvider = (configMember as { provider?: unknown } | undefined)?.provider;
|
||||
const metaProvider = (metaMember as { provider?: unknown } | undefined)?.provider;
|
||||
const providerId =
|
||||
normalizeTeamProviderLike(metaMember?.providerId) ??
|
||||
normalizeTeamProviderLike(metaProvider) ??
|
||||
normalizeTeamProviderLike(configMember?.providerId) ??
|
||||
normalizeTeamProviderLike(configProvider) ??
|
||||
inferTeamProviderIdFromModel(metaMember?.model ?? configMember?.model);
|
||||
if (providerId !== 'opencode') {
|
||||
return { delivered: false, reason: 'recipient_is_not_opencode' };
|
||||
}
|
||||
const removedAt =
|
||||
metaMember != null
|
||||
? metaMember.removedAt
|
||||
: (configMember as { removedAt?: unknown } | undefined)?.removedAt;
|
||||
if (removedAt != null) {
|
||||
return { delivered: false, reason: 'recipient_removed' };
|
||||
}
|
||||
const canonicalMemberName =
|
||||
metaMember?.name?.trim() || configMember?.name?.trim() || normalizedMemberName;
|
||||
|
||||
const leadMember = config?.members?.find((member) => isLeadMember(member));
|
||||
const leadProviderId =
|
||||
normalizeOptionalTeamProviderId(teamMeta?.launchIdentity?.providerId) ??
|
||||
normalizeOptionalTeamProviderId(teamMeta?.providerId) ??
|
||||
normalizeOptionalTeamProviderId(leadMember?.providerId);
|
||||
const laneIdentity = buildPlannedMemberLaneIdentity({
|
||||
leadProviderId,
|
||||
member: {
|
||||
name: canonicalMemberName,
|
||||
providerId,
|
||||
},
|
||||
});
|
||||
if (
|
||||
laneIdentity.laneKind === 'secondary' &&
|
||||
laneIdentity.laneOwnerProviderId === 'opencode' &&
|
||||
|
|
@ -6288,7 +6382,6 @@ export class TeamProvisioningService {
|
|||
) {
|
||||
return { delivered: false, reason: 'opencode_runtime_not_active' };
|
||||
}
|
||||
const memberRuntimeCwd = metaMember?.cwd?.trim() || configMember?.cwd?.trim();
|
||||
const cwd =
|
||||
laneIdentity.laneKind === 'secondary' && laneIdentity.laneOwnerProviderId === 'opencode'
|
||||
? memberRuntimeCwd ||
|
||||
|
|
@ -7150,64 +7243,18 @@ export class TeamProvisioningService {
|
|||
| 'opencode_recipient_unavailable';
|
||||
}
|
||||
> {
|
||||
const [config, teamMeta, metaMembers] = await Promise.all([
|
||||
this.configReader.getConfig(teamName).catch(() => null),
|
||||
this.teamMetaStore.getMeta(teamName).catch(() => null),
|
||||
this.membersMetaStore.getMembers(teamName).catch(() => []),
|
||||
]);
|
||||
const normalizedMemberName = memberName.trim();
|
||||
const configMember = config?.members?.find(
|
||||
(member) => member.name?.trim().toLowerCase() === normalizedMemberName.toLowerCase()
|
||||
const directory = await this.readOpenCodeMemberDirectory(teamName);
|
||||
const laneIdentity = this.resolveOpenCodeMemberIdentityFromDirectory(
|
||||
teamName,
|
||||
memberName,
|
||||
directory
|
||||
);
|
||||
const metaMember = metaMembers.find(
|
||||
(member) => member.name?.trim().toLowerCase() === normalizedMemberName.toLowerCase()
|
||||
);
|
||||
if (!configMember && !metaMember) {
|
||||
return { ok: false, reason: 'opencode_recipient_unavailable' };
|
||||
if (!laneIdentity.ok) {
|
||||
return laneIdentity;
|
||||
}
|
||||
const configProvider = (configMember as { provider?: unknown } | undefined)?.provider;
|
||||
const metaProvider = (metaMember as { provider?: unknown } | undefined)?.provider;
|
||||
const providerId =
|
||||
normalizeTeamProviderLike(metaMember?.providerId) ??
|
||||
normalizeTeamProviderLike(metaProvider) ??
|
||||
normalizeTeamProviderLike(configMember?.providerId) ??
|
||||
normalizeTeamProviderLike(configProvider) ??
|
||||
inferTeamProviderIdFromModel(metaMember?.model ?? configMember?.model);
|
||||
if (providerId !== 'opencode') {
|
||||
return { ok: false, reason: 'recipient_is_not_opencode' };
|
||||
}
|
||||
const removedAt =
|
||||
metaMember != null
|
||||
? metaMember.removedAt
|
||||
: (configMember as { removedAt?: unknown } | undefined)?.removedAt;
|
||||
if (removedAt != null) {
|
||||
return { ok: false, reason: 'recipient_removed' };
|
||||
}
|
||||
const canonicalMemberName =
|
||||
metaMember?.name?.trim() || configMember?.name?.trim() || normalizedMemberName;
|
||||
const runtimeRun = this.runtimeAdapterRunByTeam.get(teamName);
|
||||
if (runtimeRun?.providerId === 'opencode') {
|
||||
return {
|
||||
ok: true,
|
||||
canonicalMemberName,
|
||||
laneId: 'primary',
|
||||
};
|
||||
}
|
||||
const leadMember = config?.members?.find((member) => isLeadMember(member));
|
||||
const leadProviderId =
|
||||
normalizeOptionalTeamProviderId(teamMeta?.launchIdentity?.providerId) ??
|
||||
normalizeOptionalTeamProviderId(teamMeta?.providerId) ??
|
||||
normalizeOptionalTeamProviderId(leadMember?.providerId);
|
||||
const laneIdentity = buildPlannedMemberLaneIdentity({
|
||||
leadProviderId,
|
||||
member: {
|
||||
name: canonicalMemberName,
|
||||
providerId,
|
||||
},
|
||||
});
|
||||
return {
|
||||
ok: true,
|
||||
canonicalMemberName,
|
||||
canonicalMemberName: laneIdentity.canonicalMemberName,
|
||||
laneId: laneIdentity.laneId,
|
||||
};
|
||||
}
|
||||
|
|
@ -7216,24 +7263,21 @@ export class TeamProvisioningService {
|
|||
teamName: string,
|
||||
laneId: string
|
||||
): Promise<string[]> {
|
||||
const [config, metaMembers] = await Promise.all([
|
||||
this.configReader.getConfig(teamName).catch(() => null),
|
||||
this.membersMetaStore.getMembers(teamName).catch(() => []),
|
||||
]);
|
||||
const directory = await this.readOpenCodeMemberDirectory(teamName);
|
||||
const names = new Set<string>();
|
||||
for (const member of config?.members ?? []) {
|
||||
for (const member of directory.config?.members ?? []) {
|
||||
if (member.name?.trim()) {
|
||||
names.add(member.name.trim());
|
||||
}
|
||||
}
|
||||
for (const member of metaMembers) {
|
||||
for (const member of directory.metaMembers) {
|
||||
if (member.name?.trim()) {
|
||||
names.add(member.name.trim());
|
||||
}
|
||||
}
|
||||
const resolved: string[] = [];
|
||||
for (const name of names) {
|
||||
const identity = await this.resolveOpenCodeMemberDeliveryIdentity(teamName, name);
|
||||
const identity = this.resolveOpenCodeMemberIdentityFromDirectory(teamName, name, directory);
|
||||
if (identity.ok && identity.laneId === laneId) {
|
||||
resolved.push(identity.canonicalMemberName);
|
||||
}
|
||||
|
|
@ -7306,7 +7350,7 @@ export class TeamProvisioningService {
|
|||
}
|
||||
|
||||
const [config, teamMeta, metaMembers, currentLaneIndex] = await Promise.all([
|
||||
this.configReader.getConfig(teamName).catch(() => null),
|
||||
this.readConfigForObservation(teamName).catch(() => null),
|
||||
this.teamMetaStore.getMeta(teamName).catch(() => null),
|
||||
this.membersMetaStore.getMembers(teamName).catch(() => []),
|
||||
readOpenCodeRuntimeLaneIndex(getTeamsBasePath(), teamName).catch(() => null),
|
||||
|
|
@ -8831,7 +8875,7 @@ export class TeamProvisioningService {
|
|||
memberName: string;
|
||||
previousMember?: PersistedTeamLaunchMemberState;
|
||||
}): Promise<void> {
|
||||
const config = await this.configReader.getConfig(input.teamName).catch(() => null);
|
||||
const config = await this.readConfigForStrictDecision(input.teamName).catch(() => null);
|
||||
const metaMembers = await this.membersMetaStore.getMembers(input.teamName).catch(() => []);
|
||||
const configuredMember = this.resolveEffectiveConfiguredMember(
|
||||
config?.members ?? [],
|
||||
|
|
@ -10886,7 +10930,7 @@ export class TeamProvisioningService {
|
|||
metaMembers: Awaited<ReturnType<TeamMembersMetaStore['getMembers']>>;
|
||||
configuredMember: ReturnType<TeamProvisioningService['resolveEffectiveConfiguredMember']>;
|
||||
}> => {
|
||||
const config = await this.configReader.getConfig(teamName);
|
||||
const config = await this.readConfigForStrictDecision(teamName);
|
||||
const configuredMembers = config?.members ?? [];
|
||||
let metaMembers: Awaited<ReturnType<TeamMembersMetaStore['getMembers']>> = [];
|
||||
try {
|
||||
|
|
@ -11202,7 +11246,7 @@ export class TeamProvisioningService {
|
|||
throw new Error('Member name is required');
|
||||
}
|
||||
|
||||
const config = await this.configReader.getConfig(teamName);
|
||||
const config = await this.readConfigForStrictDecision(teamName);
|
||||
if (!config) {
|
||||
throw new Error(`Team "${teamName}" configuration is no longer available`);
|
||||
}
|
||||
|
|
@ -11362,7 +11406,7 @@ export class TeamProvisioningService {
|
|||
throw new Error('OpenCode runtime adapter is not available for controlled lane reattach.');
|
||||
}
|
||||
|
||||
const config = await this.configReader.getConfig(teamName);
|
||||
const config = await this.readConfigForStrictDecision(teamName);
|
||||
if (!config) {
|
||||
throw new Error(`Team "${teamName}" configuration is no longer available`);
|
||||
}
|
||||
|
|
@ -16255,7 +16299,7 @@ export class TeamProvisioningService {
|
|||
// as read after native delivery, so we must scan ALL messages (including read).
|
||||
let config: Awaited<ReturnType<TeamConfigReader['getConfig']>> | null = null;
|
||||
try {
|
||||
config = await this.configReader.getConfig(teamName);
|
||||
config = await this.readConfigForObservation(teamName);
|
||||
} catch {
|
||||
// config not ready yet during early provisioning — skip scan
|
||||
}
|
||||
|
|
@ -16304,7 +16348,7 @@ export class TeamProvisioningService {
|
|||
// Re-read config if needed (already fetched above but guard provisioningComplete path)
|
||||
if (!config) {
|
||||
try {
|
||||
config = await this.configReader.getConfig(teamName);
|
||||
config = await this.readConfigForObservation(teamName);
|
||||
} catch {
|
||||
return 0;
|
||||
}
|
||||
|
|
@ -16782,7 +16826,7 @@ export class TeamProvisioningService {
|
|||
|
||||
for (const teamName of aliveTeams) {
|
||||
try {
|
||||
const config = await this.configReader.getConfig(teamName);
|
||||
const config = await this.readConfigForStrictDecision(teamName);
|
||||
if (!config) continue;
|
||||
|
||||
const oldCode = config.language || 'system';
|
||||
|
|
@ -21962,7 +22006,7 @@ export class TeamProvisioningService {
|
|||
let currentMembers: TeamCreateRequest['members'] = run.request.members;
|
||||
let leadName = 'team-lead';
|
||||
try {
|
||||
const config = await this.configReader.getConfig(run.teamName);
|
||||
const config = await this.readConfigForObservation(run.teamName);
|
||||
if (config?.members) {
|
||||
const configLead = config.members.find((m) => isLeadMember(m));
|
||||
leadName = configLead?.name?.trim() || 'team-lead';
|
||||
|
|
@ -22122,7 +22166,7 @@ export class TeamProvisioningService {
|
|||
let leadName =
|
||||
run.effectiveMembers.find((m) => m.role?.toLowerCase().includes('lead'))?.name || 'team-lead';
|
||||
try {
|
||||
const config = await this.configReader.getConfig(run.teamName);
|
||||
const config = await this.readConfigForObservation(run.teamName);
|
||||
if (config?.members) {
|
||||
const configLead = config.members.find((m) => isLeadMember(m));
|
||||
leadName = configLead?.name?.trim() || leadName;
|
||||
|
|
@ -22833,7 +22877,7 @@ export class TeamProvisioningService {
|
|||
// Resolve project cwd from team config
|
||||
let projectCwd: string | undefined;
|
||||
try {
|
||||
const config = await this.configReader.getConfig(run.teamName);
|
||||
const config = await this.readConfigForStrictDecision(run.teamName);
|
||||
projectCwd = config?.projectPath ?? config?.members?.[0]?.cwd;
|
||||
} catch {
|
||||
// best-effort
|
||||
|
|
|
|||
|
|
@ -46,6 +46,11 @@ interface SessionProjectMatch extends ProjectDirCandidate {
|
|||
matchedSessionId: string;
|
||||
}
|
||||
|
||||
interface TeamTranscriptProjectConfigReader {
|
||||
getConfig(teamName: string): Promise<TeamConfig | null>;
|
||||
getConfigSnapshot?: (teamName: string) => Promise<TeamConfig | null>;
|
||||
}
|
||||
|
||||
type ScannedSessionProjectMatch = Omit<SessionProjectMatch, 'projectPath'> & {
|
||||
projectPath?: string;
|
||||
};
|
||||
|
|
@ -187,9 +192,15 @@ export class TeamTranscriptProjectResolver {
|
|||
>();
|
||||
|
||||
constructor(
|
||||
private readonly configReader: Pick<TeamConfigReader, 'getConfig'> = new TeamConfigReader()
|
||||
private readonly configReader: TeamTranscriptProjectConfigReader = new TeamConfigReader()
|
||||
) {}
|
||||
|
||||
private readConfigForObservation(teamName: string): Promise<TeamConfig | null> {
|
||||
return typeof this.configReader.getConfigSnapshot === 'function'
|
||||
? this.configReader.getConfigSnapshot(teamName)
|
||||
: this.configReader.getConfig(teamName);
|
||||
}
|
||||
|
||||
async getContext(
|
||||
teamName: string,
|
||||
options?: { forceRefresh?: boolean }
|
||||
|
|
@ -203,7 +214,7 @@ export class TeamTranscriptProjectResolver {
|
|||
return cached.value;
|
||||
}
|
||||
|
||||
const config = await this.configReader.getConfig(teamName);
|
||||
const config = await this.readConfigForObservation(teamName);
|
||||
if (!config) {
|
||||
return null;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1669,6 +1669,12 @@ export class BoardTaskLogStreamService {
|
|||
private readonly historicalBoardMcpRawProbe: HistoricalBoardMcpRawProbe = new HistoricalBoardMcpRawProbe()
|
||||
) {}
|
||||
|
||||
private readConfigForObservation(teamName: string) {
|
||||
return typeof this.configReader.getConfigSnapshot === 'function'
|
||||
? this.configReader.getConfigSnapshot(teamName)
|
||||
: this.configReader.getConfig(teamName);
|
||||
}
|
||||
|
||||
private buildLayoutCacheKey(teamName: string, taskId: string): string {
|
||||
return `${teamName}::${taskId}`;
|
||||
}
|
||||
|
|
@ -2199,7 +2205,7 @@ export class BoardTaskLogStreamService {
|
|||
this.taskReader.getTasks(teamName).catch(() => []),
|
||||
this.taskReader.getDeletedTasks(teamName).catch(() => []),
|
||||
this.membersMetaStore.getMembers(teamName).catch(() => []),
|
||||
this.configReader.getConfig(teamName).catch(() => null),
|
||||
this.readConfigForObservation(teamName).catch(() => null),
|
||||
]);
|
||||
const task = [...activeTasks, ...deletedTasks].find((candidate) => candidate.id === taskId);
|
||||
const ownerName = task?.owner?.trim();
|
||||
|
|
|
|||
|
|
@ -54,6 +54,12 @@ export class CodexNativeTaskLogStreamSource {
|
|||
private readonly chunkBuilder: BoardTaskExactLogChunkBuilder = new BoardTaskExactLogChunkBuilder()
|
||||
) {}
|
||||
|
||||
private readConfigForObservation(teamName: string) {
|
||||
return typeof this.configReader.getConfigSnapshot === 'function'
|
||||
? this.configReader.getConfigSnapshot(teamName)
|
||||
: this.configReader.getConfig(teamName);
|
||||
}
|
||||
|
||||
async getTaskLogStream(
|
||||
teamName: string,
|
||||
taskId: string,
|
||||
|
|
@ -163,7 +169,7 @@ export class CodexNativeTaskLogStreamSource {
|
|||
const normalizedOwner = normalizeMemberName(ownerName);
|
||||
const [metaMembers, config] = await Promise.all([
|
||||
this.membersMetaStore.getMembers(teamName).catch(() => []),
|
||||
this.configReader.getConfig(teamName).catch(() => null),
|
||||
this.readConfigForObservation(teamName).catch(() => null),
|
||||
]);
|
||||
const member = [...metaMembers, ...(config?.members ?? [])].find(
|
||||
(candidate) => normalizeMemberName(candidate.name) === normalizedOwner
|
||||
|
|
|
|||
|
|
@ -339,6 +339,35 @@ describe('TeamDataService task projection cache invalidation', () => {
|
|||
expect(configInvalidateSpy).toHaveBeenCalledWith('gone-team');
|
||||
expect(taskInvalidateSpy).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('keeps team deletion mutations on verified config reads', async () => {
|
||||
const claudeRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'team-data-delete-verified-'));
|
||||
tempPaths.push(claudeRoot);
|
||||
setClaudeBasePathOverride(claudeRoot);
|
||||
await fs.mkdir(path.join(claudeRoot, 'teams', 'my-team'), { recursive: true });
|
||||
|
||||
const getConfig = vi.fn(async () => ({
|
||||
name: 'My team',
|
||||
members: [],
|
||||
}));
|
||||
const getConfigSnapshot = vi.fn(async () => {
|
||||
throw new Error('snapshot config read should not be used for team deletion');
|
||||
});
|
||||
const service = new TeamDataService({
|
||||
listTeams: vi.fn(),
|
||||
getConfig,
|
||||
getConfigSnapshot,
|
||||
} as never);
|
||||
|
||||
await service.deleteTeam('my-team');
|
||||
|
||||
const written = JSON.parse(
|
||||
await fs.readFile(path.join(claudeRoot, 'teams', 'my-team', 'config.json'), 'utf8')
|
||||
) as TeamConfig;
|
||||
expect(written.deletedAt).toBeTruthy();
|
||||
expect(getConfig).toHaveBeenCalledWith('my-team');
|
||||
expect(getConfigSnapshot).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('TeamDataService draft metadata', () => {
|
||||
|
|
@ -1370,15 +1399,20 @@ describe('TeamDataService', () => {
|
|||
|
||||
it('includes projectPath from config when creating a task', async () => {
|
||||
const createTaskMock = vi.fn((task) => task);
|
||||
const getConfig = vi.fn(async () => {
|
||||
throw new Error('verified config read should not be used for task enrichment');
|
||||
});
|
||||
const getConfigSnapshot = vi.fn(async () => ({
|
||||
name: 'My team',
|
||||
members: [],
|
||||
projectPath: '/Users/dev/my-project',
|
||||
}));
|
||||
|
||||
const service = new TeamDataService(
|
||||
{
|
||||
listTeams: vi.fn(),
|
||||
getConfig: vi.fn(async () => ({
|
||||
name: 'My team',
|
||||
members: [],
|
||||
projectPath: '/Users/dev/my-project',
|
||||
})),
|
||||
getConfig,
|
||||
getConfigSnapshot,
|
||||
} as never,
|
||||
{
|
||||
getNextTaskId: vi.fn(async () => '1'),
|
||||
|
|
@ -1417,6 +1451,8 @@ describe('TeamDataService', () => {
|
|||
expect(createTaskMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ projectPath: '/Users/dev/my-project' })
|
||||
);
|
||||
expect(getConfigSnapshot).toHaveBeenCalledWith('my-team');
|
||||
expect(getConfig).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('returns lightweight notification context from config without hydrating team data', async () => {
|
||||
|
|
|
|||
|
|
@ -3439,6 +3439,111 @@ describe('TeamProvisioningService', () => {
|
|||
});
|
||||
});
|
||||
|
||||
it('uses snapshot config reads for OpenCode member delivery routing', async () => {
|
||||
const getConfig = vi.fn(async () => {
|
||||
throw new Error('verified config read should not be used for delivery routing');
|
||||
});
|
||||
const getConfigSnapshot = vi.fn(async () => ({
|
||||
projectPath: '/repo',
|
||||
members: [
|
||||
{ name: 'team-lead', providerId: 'codex', model: 'gpt-5.4' },
|
||||
{ name: 'bob', providerId: 'opencode', model: 'minimax-m2.5-free' },
|
||||
],
|
||||
}));
|
||||
const svc = new TeamProvisioningService({
|
||||
getConfig,
|
||||
getConfigSnapshot,
|
||||
} as any);
|
||||
const sendMessageToMember = vi.fn(async (input: Record<string, unknown>) => ({
|
||||
ok: true,
|
||||
providerId: 'opencode',
|
||||
memberName: String(input.memberName),
|
||||
sessionId: 'oc-session-bob',
|
||||
diagnostics: [],
|
||||
}));
|
||||
svc.setRuntimeAdapterRegistry(
|
||||
new TeamRuntimeAdapterRegistry([
|
||||
{
|
||||
providerId: 'opencode',
|
||||
prepare: vi.fn(),
|
||||
launch: vi.fn(),
|
||||
reconcile: vi.fn(),
|
||||
stop: vi.fn(),
|
||||
sendMessageToMember,
|
||||
} as any,
|
||||
])
|
||||
);
|
||||
(svc as any).teamMetaStore = {
|
||||
getMeta: vi.fn(async () => ({
|
||||
launchIdentity: { providerId: 'codex' },
|
||||
providerId: 'codex',
|
||||
})),
|
||||
};
|
||||
(svc as any).membersMetaStore = {
|
||||
getMembers: vi.fn(async () => [
|
||||
{ name: 'bob', providerId: 'opencode', model: 'opencode/minimax-m2.5-free' },
|
||||
]),
|
||||
};
|
||||
(svc as any).setSecondaryRuntimeRun({
|
||||
teamName: 'team-a',
|
||||
runId: 'opencode-run-bob',
|
||||
providerId: 'opencode',
|
||||
laneId: 'secondary:opencode:bob',
|
||||
memberName: 'bob',
|
||||
cwd: '/repo',
|
||||
});
|
||||
|
||||
await expect(
|
||||
svc.deliverOpenCodeMemberMessage('team-a', {
|
||||
memberName: 'bob',
|
||||
text: 'hello bob',
|
||||
messageId: 'msg-1',
|
||||
})
|
||||
).resolves.toMatchObject({ delivered: true });
|
||||
|
||||
expect(getConfigSnapshot).toHaveBeenCalledWith('team-a');
|
||||
expect(getConfig).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('resolves OpenCode runtime lane members from one snapshot directory read', async () => {
|
||||
const getConfig = vi.fn(async () => {
|
||||
throw new Error('verified config read should not be used for lane member resolution');
|
||||
});
|
||||
const getConfigSnapshot = vi.fn(async () => ({
|
||||
projectPath: '/repo',
|
||||
members: [
|
||||
{ name: 'team-lead', providerId: 'codex', model: 'gpt-5.4' },
|
||||
{ name: 'bob', providerId: 'opencode', model: 'minimax-m2.5-free' },
|
||||
{ name: 'alice', providerId: 'codex', model: 'gpt-5.4' },
|
||||
],
|
||||
}));
|
||||
const svc = new TeamProvisioningService({
|
||||
getConfig,
|
||||
getConfigSnapshot,
|
||||
} as any);
|
||||
(svc as any).teamMetaStore = {
|
||||
getMeta: vi.fn(async () => ({
|
||||
launchIdentity: { providerId: 'codex' },
|
||||
providerId: 'codex',
|
||||
})),
|
||||
};
|
||||
(svc as any).membersMetaStore = {
|
||||
getMembers: vi.fn(async () => [
|
||||
{ name: 'bob', providerId: 'opencode', model: 'opencode/minimax-m2.5-free' },
|
||||
]),
|
||||
};
|
||||
|
||||
await expect(
|
||||
(svc as any).resolveOpenCodeMembersForRuntimeLane(
|
||||
'team-a',
|
||||
'secondary:opencode:bob'
|
||||
)
|
||||
).resolves.toEqual(['bob']);
|
||||
|
||||
expect(getConfigSnapshot).toHaveBeenCalledTimes(1);
|
||||
expect(getConfig).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('delivers OpenCode secondary-lane messages to the member worktree cwd after restart', async () => {
|
||||
const svc = new TeamProvisioningService();
|
||||
const sendMessageToMember = vi.fn(async (input: Record<string, unknown>) => ({
|
||||
|
|
@ -6531,6 +6636,33 @@ describe('TeamProvisioningService', () => {
|
|||
);
|
||||
});
|
||||
|
||||
it('keeps OpenCode bootstrap check-in allowlist on verified config reads', async () => {
|
||||
const getConfig = vi.fn(async () => ({
|
||||
teamName: 'mixed-team',
|
||||
members: [{ name: 'bob', providerId: 'opencode' }],
|
||||
}));
|
||||
const getConfigSnapshot = vi.fn(async () => {
|
||||
throw new Error('snapshot config read should not be used for bootstrap check-in guards');
|
||||
});
|
||||
const svc = new TeamProvisioningService({
|
||||
getConfig,
|
||||
getConfigSnapshot,
|
||||
} as any);
|
||||
(svc as any).membersMetaStore = {
|
||||
getMembers: vi.fn(async () => []),
|
||||
};
|
||||
|
||||
await expect(
|
||||
(svc as any).assertOpenCodeRuntimeMemberCheckinAllowed({
|
||||
teamName: 'mixed-team',
|
||||
memberName: 'bob',
|
||||
})
|
||||
).resolves.toBeUndefined();
|
||||
|
||||
expect(getConfig).toHaveBeenCalledWith('mixed-team');
|
||||
expect(getConfigSnapshot).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('rejects duplicate OpenCode bootstrap check-ins for members removed after the first check-in', async () => {
|
||||
const svc = new TeamProvisioningService();
|
||||
const previousSnapshot = {
|
||||
|
|
@ -9664,11 +9796,16 @@ describe('TeamProvisioningService', () => {
|
|||
|
||||
it('expands teammate permission suggestions to the operational tool set only', async () => {
|
||||
allowConsoleLogs();
|
||||
const getConfig = vi.fn(async () => ({
|
||||
projectPath: tempClaudeRoot,
|
||||
members: [{ cwd: tempClaudeRoot }],
|
||||
}));
|
||||
const getConfigSnapshot = vi.fn(async () => {
|
||||
throw new Error('snapshot config read should not be used for permission writes');
|
||||
});
|
||||
const svc = new TeamProvisioningService({
|
||||
getConfig: vi.fn(async () => ({
|
||||
projectPath: tempClaudeRoot,
|
||||
members: [{ cwd: tempClaudeRoot }],
|
||||
})),
|
||||
getConfig,
|
||||
getConfigSnapshot,
|
||||
} as any);
|
||||
|
||||
await (svc as any).respondToTeammatePermission(
|
||||
|
|
@ -9696,6 +9833,8 @@ describe('TeamProvisioningService', () => {
|
|||
);
|
||||
expect(settings.permissions?.allow).not.toContain('mcp__agent-teams__team_stop');
|
||||
expect(settings.permissions?.allow).not.toContain('mcp__agent-teams__kanban_clear');
|
||||
expect(getConfig).toHaveBeenCalledWith('ops-team');
|
||||
expect(getConfigSnapshot).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not broaden admin/runtime teammate permission suggestions', async () => {
|
||||
|
|
|
|||
|
|
@ -307,6 +307,45 @@ describe('TeamProvisioningService relayLeadInboxMessages', () => {
|
|||
expect(service.getLiveLeadProcessMessages(teamName)).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('uses snapshot config reads for lead inbox relay routing', async () => {
|
||||
const getConfig = vi.fn(async () => {
|
||||
throw new Error('verified config read should not be used for inbox relay routing');
|
||||
});
|
||||
const getConfigSnapshot = vi.fn(async () => ({
|
||||
name: 'My Team',
|
||||
members: [{ name: 'team-lead', agentType: 'team-lead' }],
|
||||
}));
|
||||
const service = new TeamProvisioningService({
|
||||
getConfig,
|
||||
getConfigSnapshot,
|
||||
} as any);
|
||||
const teamName = 'my-team';
|
||||
seedLeadInbox(teamName, [
|
||||
{
|
||||
from: 'bob',
|
||||
text: 'Please assign this to Alice.',
|
||||
timestamp: '2026-02-23T10:00:00.000Z',
|
||||
read: false,
|
||||
summary: 'Need delegation',
|
||||
messageId: 'm-1',
|
||||
},
|
||||
]);
|
||||
|
||||
const { writeSpy } = attachAliveRun(service, teamName);
|
||||
const relayPromise = service.relayLeadInboxMessages(teamName);
|
||||
const run = await waitForCapture(service);
|
||||
(service as any).handleStreamJsonMessage(run, {
|
||||
type: 'assistant',
|
||||
content: [{ type: 'text', text: 'OK, will do.' }],
|
||||
});
|
||||
(service as any).handleStreamJsonMessage(run, { type: 'result', subtype: 'success' });
|
||||
|
||||
await expect(relayPromise).resolves.toBe(1);
|
||||
expect(writeSpy).toHaveBeenCalledTimes(1);
|
||||
expect(getConfigSnapshot).toHaveBeenCalledWith(teamName);
|
||||
expect(getConfig).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('shows assistant text after relay capture has already settled', () => {
|
||||
const service = new TeamProvisioningService();
|
||||
const teamName = 'my-team';
|
||||
|
|
|
|||
|
|
@ -140,6 +140,30 @@ describe('TeamTranscriptProjectResolver', () => {
|
|||
return { projectDir, jsonlPath };
|
||||
}
|
||||
|
||||
it('uses snapshot-capable config readers for resolver observations', async () => {
|
||||
await setupClaudeRoot();
|
||||
const { projectDir } = await createSessionFile('/repo/current', 'lead-session-1');
|
||||
const getConfig = vi.fn(async () => {
|
||||
throw new Error('verified config read should not be used for transcript observations');
|
||||
});
|
||||
const getConfigSnapshot = vi.fn(async () => ({
|
||||
name: 'My Team',
|
||||
projectPath: '/repo/current',
|
||||
leadSessionId: 'lead-session-1',
|
||||
members: [{ name: 'team-lead', agentType: 'team-lead' }],
|
||||
}));
|
||||
const resolver = new TeamTranscriptProjectResolver({
|
||||
getConfig,
|
||||
getConfigSnapshot,
|
||||
});
|
||||
|
||||
const context = await resolver.getContext('my-team');
|
||||
|
||||
expect(context?.projectDir).toBe(projectDir);
|
||||
expect(getConfigSnapshot).toHaveBeenCalledWith('my-team');
|
||||
expect(getConfig).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('repairs stale projectPath when exact leadSessionId exists only in the renamed project', async () => {
|
||||
await setupClaudeRoot();
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue