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);
|
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(
|
setTaskChangePresenceServices(
|
||||||
repository: TaskChangePresenceRepository,
|
repository: TaskChangePresenceRepository,
|
||||||
tracker: TeamLogSourceTracker
|
tracker: TeamLogSourceTracker
|
||||||
|
|
@ -671,7 +677,7 @@ export class ChangeExtractorService {
|
||||||
try {
|
try {
|
||||||
const [meta, config] = await Promise.all([
|
const [meta, config] = await Promise.all([
|
||||||
this.teamMetaStore.getMeta(teamName).catch(() => null),
|
this.teamMetaStore.getMeta(teamName).catch(() => null),
|
||||||
this.configReader.getConfig(teamName).catch(() => null),
|
this.readConfigForObservation(teamName).catch(() => null),
|
||||||
]);
|
]);
|
||||||
const hasOpenCodeMember = (config?.members ?? []).some(
|
const hasOpenCodeMember = (config?.members ?? []).some(
|
||||||
(member) => member.providerId === 'opencode'
|
(member) => member.providerId === 'opencode'
|
||||||
|
|
@ -996,7 +1002,7 @@ export class ChangeExtractorService {
|
||||||
/** Получить projectPath из конфига команды */
|
/** Получить projectPath из конфига команды */
|
||||||
private async resolveProjectPath(teamName: string): Promise<string | undefined> {
|
private async resolveProjectPath(teamName: string): Promise<string | undefined> {
|
||||||
try {
|
try {
|
||||||
const config = await this.configReader.getConfig(teamName);
|
const config = await this.readConfigForObservation(teamName);
|
||||||
return config?.projectPath?.trim() || undefined;
|
return config?.projectPath?.trim() || undefined;
|
||||||
} catch {
|
} catch {
|
||||||
return undefined;
|
return undefined;
|
||||||
|
|
|
||||||
|
|
@ -1898,7 +1898,7 @@ export class TeamDataService {
|
||||||
|
|
||||||
let projectPath: string | undefined;
|
let projectPath: string | undefined;
|
||||||
try {
|
try {
|
||||||
const config = await this.configReader.getConfig(teamName);
|
const config = await readConfigForUiSnapshot(this.configReader, teamName);
|
||||||
projectPath = config?.projectPath;
|
projectPath = config?.projectPath;
|
||||||
} catch {
|
} catch {
|
||||||
/* best-effort */
|
/* best-effort */
|
||||||
|
|
@ -2237,7 +2237,7 @@ export class TeamDataService {
|
||||||
let enrichedRequest = request;
|
let enrichedRequest = request;
|
||||||
if (!enrichedRequest.leadSessionId) {
|
if (!enrichedRequest.leadSessionId) {
|
||||||
try {
|
try {
|
||||||
const config = await this.configReader.getConfig(teamName);
|
const config = await readConfigForUiSnapshot(this.configReader, teamName);
|
||||||
if (config?.leadSessionId) {
|
if (config?.leadSessionId) {
|
||||||
enrichedRequest = { ...enrichedRequest, leadSessionId: config.leadSessionId };
|
enrichedRequest = { ...enrichedRequest, leadSessionId: config.leadSessionId };
|
||||||
}
|
}
|
||||||
|
|
@ -2310,7 +2310,7 @@ export class TeamDataService {
|
||||||
|
|
||||||
private async resolveLeadName(teamName: string): Promise<string> {
|
private async resolveLeadName(teamName: string): Promise<string> {
|
||||||
try {
|
try {
|
||||||
const config = await this.configReader.getConfig(teamName);
|
const config = await readConfigForUiSnapshot(this.configReader, teamName);
|
||||||
return this.resolveLeadNameFromConfig(config);
|
return this.resolveLeadNameFromConfig(config);
|
||||||
} catch {
|
} catch {
|
||||||
return 'team-lead';
|
return 'team-lead';
|
||||||
|
|
@ -2321,7 +2321,7 @@ export class TeamDataService {
|
||||||
teamName: string
|
teamName: string
|
||||||
): Promise<{ leadName: string; leadSessionId?: string }> {
|
): Promise<{ leadName: string; leadSessionId?: string }> {
|
||||||
try {
|
try {
|
||||||
const config = await this.configReader.getConfig(teamName);
|
const config = await readConfigForUiSnapshot(this.configReader, teamName);
|
||||||
return {
|
return {
|
||||||
leadName: this.resolveLeadNameFromConfig(config),
|
leadName: this.resolveLeadNameFromConfig(config),
|
||||||
leadSessionId: config?.leadSessionId,
|
leadSessionId: config?.leadSessionId,
|
||||||
|
|
@ -2638,7 +2638,7 @@ export class TeamDataService {
|
||||||
const recoverPending = options?.recoverPending === true;
|
const recoverPending = options?.recoverPending === true;
|
||||||
let config: TeamConfig | null = null;
|
let config: TeamConfig | null = null;
|
||||||
try {
|
try {
|
||||||
config = await this.configReader.getConfig(teamName);
|
config = await readConfigForUiSnapshot(this.configReader, teamName);
|
||||||
} catch {
|
} catch {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
@ -2793,7 +2793,7 @@ export class TeamDataService {
|
||||||
): Promise<SendMessageResult> {
|
): Promise<SendMessageResult> {
|
||||||
let leadSessionId: string | undefined;
|
let leadSessionId: string | undefined;
|
||||||
try {
|
try {
|
||||||
const config = await this.configReader.getConfig(teamName);
|
const config = await readConfigForUiSnapshot(this.configReader, teamName);
|
||||||
leadSessionId = config?.leadSessionId;
|
leadSessionId = config?.leadSessionId;
|
||||||
} catch {
|
} catch {
|
||||||
// non-critical — proceed without sessionId
|
// non-critical — proceed without sessionId
|
||||||
|
|
@ -2826,7 +2826,7 @@ export class TeamDataService {
|
||||||
|
|
||||||
async getLeadMemberName(teamName: string): Promise<string | null> {
|
async getLeadMemberName(teamName: string): Promise<string | null> {
|
||||||
try {
|
try {
|
||||||
const config = await this.configReader.getConfig(teamName);
|
const config = await readConfigForUiSnapshot(this.configReader, teamName);
|
||||||
|
|
||||||
// Check config.json members first (Claude Code-created teams)
|
// Check config.json members first (Claude Code-created teams)
|
||||||
if (config?.members?.length) {
|
if (config?.members?.length) {
|
||||||
|
|
|
||||||
|
|
@ -4435,6 +4435,27 @@ interface OpenCodeMemberInboxDelivery {
|
||||||
diagnostics?: string[];
|
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 {
|
interface OpenCodeMemberInboxRelayResult {
|
||||||
relayed: number;
|
relayed: number;
|
||||||
attempted: number;
|
attempted: number;
|
||||||
|
|
@ -4613,6 +4634,106 @@ export class TeamProvisioningService {
|
||||||
: configReader.getConfig(teamName);
|
: 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 {
|
setRuntimeAdapterRegistry(registry: TeamRuntimeAdapterRegistry | null): void {
|
||||||
this.runtimeAdapterRegistry = registry;
|
this.runtimeAdapterRegistry = registry;
|
||||||
}
|
}
|
||||||
|
|
@ -5283,7 +5404,7 @@ export class TeamProvisioningService {
|
||||||
const adapter = this.getOpenCodeRuntimeAdapter();
|
const adapter = this.getOpenCodeRuntimeAdapter();
|
||||||
const previousLaunchState = await this.launchStateStore.read(teamName).catch(() => null);
|
const previousLaunchState = await this.launchStateStore.read(teamName).catch(() => null);
|
||||||
const [config, metaMembers] = await Promise.all([
|
const [config, metaMembers] = await Promise.all([
|
||||||
this.configReader.getConfig(teamName).catch(() => null),
|
this.readConfigForObservation(teamName).catch(() => null),
|
||||||
this.membersMetaStore.getMembers(teamName).catch(() => []),
|
this.membersMetaStore.getMembers(teamName).catch(() => []),
|
||||||
]);
|
]);
|
||||||
const evidenceReader = new OpenCodeRuntimeManifestEvidenceReader({
|
const evidenceReader = new OpenCodeRuntimeManifestEvidenceReader({
|
||||||
|
|
@ -5813,8 +5934,7 @@ export class TeamProvisioningService {
|
||||||
}): Promise<string[]> {
|
}): Promise<string[]> {
|
||||||
const explicitRecipient = input.replyRecipient?.trim() || 'user';
|
const explicitRecipient = input.replyRecipient?.trim() || 'user';
|
||||||
const candidates = [explicitRecipient];
|
const candidates = [explicitRecipient];
|
||||||
const configuredLeadName = await this.configReader
|
const configuredLeadName = await this.readConfigForObservation(input.teamName)
|
||||||
.getConfig(input.teamName)
|
|
||||||
.then(
|
.then(
|
||||||
(config) => config?.members?.find((member) => isLeadMember(member))?.name?.trim() || null
|
(config) => config?.members?.find((member) => isLeadMember(member))?.name?.trim() || null
|
||||||
)
|
)
|
||||||
|
|
@ -6236,51 +6356,25 @@ export class TeamProvisioningService {
|
||||||
if (!adapter) {
|
if (!adapter) {
|
||||||
return { delivered: false, reason: 'opencode_runtime_message_bridge_unavailable' };
|
return { delivered: false, reason: 'opencode_runtime_message_bridge_unavailable' };
|
||||||
}
|
}
|
||||||
const [config, teamMeta, metaMembers] = await Promise.all([
|
const directory = await this.readOpenCodeMemberDirectory(teamName);
|
||||||
this.configReader.getConfig(teamName).catch(() => null),
|
const identity = this.resolveOpenCodeMemberIdentityFromDirectory(
|
||||||
this.teamMetaStore.getMeta(teamName).catch(() => null),
|
teamName,
|
||||||
this.membersMetaStore.getMembers(teamName).catch(() => []),
|
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 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 (
|
if (
|
||||||
laneIdentity.laneKind === 'secondary' &&
|
laneIdentity.laneKind === 'secondary' &&
|
||||||
laneIdentity.laneOwnerProviderId === 'opencode' &&
|
laneIdentity.laneOwnerProviderId === 'opencode' &&
|
||||||
|
|
@ -6288,7 +6382,6 @@ export class TeamProvisioningService {
|
||||||
) {
|
) {
|
||||||
return { delivered: false, reason: 'opencode_runtime_not_active' };
|
return { delivered: false, reason: 'opencode_runtime_not_active' };
|
||||||
}
|
}
|
||||||
const memberRuntimeCwd = metaMember?.cwd?.trim() || configMember?.cwd?.trim();
|
|
||||||
const cwd =
|
const cwd =
|
||||||
laneIdentity.laneKind === 'secondary' && laneIdentity.laneOwnerProviderId === 'opencode'
|
laneIdentity.laneKind === 'secondary' && laneIdentity.laneOwnerProviderId === 'opencode'
|
||||||
? memberRuntimeCwd ||
|
? memberRuntimeCwd ||
|
||||||
|
|
@ -7150,64 +7243,18 @@ export class TeamProvisioningService {
|
||||||
| 'opencode_recipient_unavailable';
|
| 'opencode_recipient_unavailable';
|
||||||
}
|
}
|
||||||
> {
|
> {
|
||||||
const [config, teamMeta, metaMembers] = await Promise.all([
|
const directory = await this.readOpenCodeMemberDirectory(teamName);
|
||||||
this.configReader.getConfig(teamName).catch(() => null),
|
const laneIdentity = this.resolveOpenCodeMemberIdentityFromDirectory(
|
||||||
this.teamMetaStore.getMeta(teamName).catch(() => null),
|
teamName,
|
||||||
this.membersMetaStore.getMembers(teamName).catch(() => []),
|
memberName,
|
||||||
]);
|
directory
|
||||||
const normalizedMemberName = memberName.trim();
|
|
||||||
const configMember = config?.members?.find(
|
|
||||||
(member) => member.name?.trim().toLowerCase() === normalizedMemberName.toLowerCase()
|
|
||||||
);
|
);
|
||||||
const metaMember = metaMembers.find(
|
if (!laneIdentity.ok) {
|
||||||
(member) => member.name?.trim().toLowerCase() === normalizedMemberName.toLowerCase()
|
return laneIdentity;
|
||||||
);
|
|
||||||
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') {
|
|
||||||
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 {
|
return {
|
||||||
ok: true,
|
ok: true,
|
||||||
canonicalMemberName,
|
canonicalMemberName: laneIdentity.canonicalMemberName,
|
||||||
laneId: laneIdentity.laneId,
|
laneId: laneIdentity.laneId,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
@ -7216,24 +7263,21 @@ export class TeamProvisioningService {
|
||||||
teamName: string,
|
teamName: string,
|
||||||
laneId: string
|
laneId: string
|
||||||
): Promise<string[]> {
|
): Promise<string[]> {
|
||||||
const [config, metaMembers] = await Promise.all([
|
const directory = await this.readOpenCodeMemberDirectory(teamName);
|
||||||
this.configReader.getConfig(teamName).catch(() => null),
|
|
||||||
this.membersMetaStore.getMembers(teamName).catch(() => []),
|
|
||||||
]);
|
|
||||||
const names = new Set<string>();
|
const names = new Set<string>();
|
||||||
for (const member of config?.members ?? []) {
|
for (const member of directory.config?.members ?? []) {
|
||||||
if (member.name?.trim()) {
|
if (member.name?.trim()) {
|
||||||
names.add(member.name.trim());
|
names.add(member.name.trim());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
for (const member of metaMembers) {
|
for (const member of directory.metaMembers) {
|
||||||
if (member.name?.trim()) {
|
if (member.name?.trim()) {
|
||||||
names.add(member.name.trim());
|
names.add(member.name.trim());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const resolved: string[] = [];
|
const resolved: string[] = [];
|
||||||
for (const name of names) {
|
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) {
|
if (identity.ok && identity.laneId === laneId) {
|
||||||
resolved.push(identity.canonicalMemberName);
|
resolved.push(identity.canonicalMemberName);
|
||||||
}
|
}
|
||||||
|
|
@ -7306,7 +7350,7 @@ export class TeamProvisioningService {
|
||||||
}
|
}
|
||||||
|
|
||||||
const [config, teamMeta, metaMembers, currentLaneIndex] = await Promise.all([
|
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.teamMetaStore.getMeta(teamName).catch(() => null),
|
||||||
this.membersMetaStore.getMembers(teamName).catch(() => []),
|
this.membersMetaStore.getMembers(teamName).catch(() => []),
|
||||||
readOpenCodeRuntimeLaneIndex(getTeamsBasePath(), teamName).catch(() => null),
|
readOpenCodeRuntimeLaneIndex(getTeamsBasePath(), teamName).catch(() => null),
|
||||||
|
|
@ -8831,7 +8875,7 @@ export class TeamProvisioningService {
|
||||||
memberName: string;
|
memberName: string;
|
||||||
previousMember?: PersistedTeamLaunchMemberState;
|
previousMember?: PersistedTeamLaunchMemberState;
|
||||||
}): Promise<void> {
|
}): 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 metaMembers = await this.membersMetaStore.getMembers(input.teamName).catch(() => []);
|
||||||
const configuredMember = this.resolveEffectiveConfiguredMember(
|
const configuredMember = this.resolveEffectiveConfiguredMember(
|
||||||
config?.members ?? [],
|
config?.members ?? [],
|
||||||
|
|
@ -10886,7 +10930,7 @@ export class TeamProvisioningService {
|
||||||
metaMembers: Awaited<ReturnType<TeamMembersMetaStore['getMembers']>>;
|
metaMembers: Awaited<ReturnType<TeamMembersMetaStore['getMembers']>>;
|
||||||
configuredMember: ReturnType<TeamProvisioningService['resolveEffectiveConfiguredMember']>;
|
configuredMember: ReturnType<TeamProvisioningService['resolveEffectiveConfiguredMember']>;
|
||||||
}> => {
|
}> => {
|
||||||
const config = await this.configReader.getConfig(teamName);
|
const config = await this.readConfigForStrictDecision(teamName);
|
||||||
const configuredMembers = config?.members ?? [];
|
const configuredMembers = config?.members ?? [];
|
||||||
let metaMembers: Awaited<ReturnType<TeamMembersMetaStore['getMembers']>> = [];
|
let metaMembers: Awaited<ReturnType<TeamMembersMetaStore['getMembers']>> = [];
|
||||||
try {
|
try {
|
||||||
|
|
@ -11202,7 +11246,7 @@ export class TeamProvisioningService {
|
||||||
throw new Error('Member name is required');
|
throw new Error('Member name is required');
|
||||||
}
|
}
|
||||||
|
|
||||||
const config = await this.configReader.getConfig(teamName);
|
const config = await this.readConfigForStrictDecision(teamName);
|
||||||
if (!config) {
|
if (!config) {
|
||||||
throw new Error(`Team "${teamName}" configuration is no longer available`);
|
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.');
|
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) {
|
if (!config) {
|
||||||
throw new Error(`Team "${teamName}" configuration is no longer available`);
|
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).
|
// as read after native delivery, so we must scan ALL messages (including read).
|
||||||
let config: Awaited<ReturnType<TeamConfigReader['getConfig']>> | null = null;
|
let config: Awaited<ReturnType<TeamConfigReader['getConfig']>> | null = null;
|
||||||
try {
|
try {
|
||||||
config = await this.configReader.getConfig(teamName);
|
config = await this.readConfigForObservation(teamName);
|
||||||
} catch {
|
} catch {
|
||||||
// config not ready yet during early provisioning — skip scan
|
// 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)
|
// Re-read config if needed (already fetched above but guard provisioningComplete path)
|
||||||
if (!config) {
|
if (!config) {
|
||||||
try {
|
try {
|
||||||
config = await this.configReader.getConfig(teamName);
|
config = await this.readConfigForObservation(teamName);
|
||||||
} catch {
|
} catch {
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
|
|
@ -16782,7 +16826,7 @@ export class TeamProvisioningService {
|
||||||
|
|
||||||
for (const teamName of aliveTeams) {
|
for (const teamName of aliveTeams) {
|
||||||
try {
|
try {
|
||||||
const config = await this.configReader.getConfig(teamName);
|
const config = await this.readConfigForStrictDecision(teamName);
|
||||||
if (!config) continue;
|
if (!config) continue;
|
||||||
|
|
||||||
const oldCode = config.language || 'system';
|
const oldCode = config.language || 'system';
|
||||||
|
|
@ -21962,7 +22006,7 @@ export class TeamProvisioningService {
|
||||||
let currentMembers: TeamCreateRequest['members'] = run.request.members;
|
let currentMembers: TeamCreateRequest['members'] = run.request.members;
|
||||||
let leadName = 'team-lead';
|
let leadName = 'team-lead';
|
||||||
try {
|
try {
|
||||||
const config = await this.configReader.getConfig(run.teamName);
|
const config = await this.readConfigForObservation(run.teamName);
|
||||||
if (config?.members) {
|
if (config?.members) {
|
||||||
const configLead = config.members.find((m) => isLeadMember(m));
|
const configLead = config.members.find((m) => isLeadMember(m));
|
||||||
leadName = configLead?.name?.trim() || 'team-lead';
|
leadName = configLead?.name?.trim() || 'team-lead';
|
||||||
|
|
@ -22122,7 +22166,7 @@ export class TeamProvisioningService {
|
||||||
let leadName =
|
let leadName =
|
||||||
run.effectiveMembers.find((m) => m.role?.toLowerCase().includes('lead'))?.name || 'team-lead';
|
run.effectiveMembers.find((m) => m.role?.toLowerCase().includes('lead'))?.name || 'team-lead';
|
||||||
try {
|
try {
|
||||||
const config = await this.configReader.getConfig(run.teamName);
|
const config = await this.readConfigForObservation(run.teamName);
|
||||||
if (config?.members) {
|
if (config?.members) {
|
||||||
const configLead = config.members.find((m) => isLeadMember(m));
|
const configLead = config.members.find((m) => isLeadMember(m));
|
||||||
leadName = configLead?.name?.trim() || leadName;
|
leadName = configLead?.name?.trim() || leadName;
|
||||||
|
|
@ -22833,7 +22877,7 @@ export class TeamProvisioningService {
|
||||||
// Resolve project cwd from team config
|
// Resolve project cwd from team config
|
||||||
let projectCwd: string | undefined;
|
let projectCwd: string | undefined;
|
||||||
try {
|
try {
|
||||||
const config = await this.configReader.getConfig(run.teamName);
|
const config = await this.readConfigForStrictDecision(run.teamName);
|
||||||
projectCwd = config?.projectPath ?? config?.members?.[0]?.cwd;
|
projectCwd = config?.projectPath ?? config?.members?.[0]?.cwd;
|
||||||
} catch {
|
} catch {
|
||||||
// best-effort
|
// best-effort
|
||||||
|
|
|
||||||
|
|
@ -46,6 +46,11 @@ interface SessionProjectMatch extends ProjectDirCandidate {
|
||||||
matchedSessionId: string;
|
matchedSessionId: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface TeamTranscriptProjectConfigReader {
|
||||||
|
getConfig(teamName: string): Promise<TeamConfig | null>;
|
||||||
|
getConfigSnapshot?: (teamName: string) => Promise<TeamConfig | null>;
|
||||||
|
}
|
||||||
|
|
||||||
type ScannedSessionProjectMatch = Omit<SessionProjectMatch, 'projectPath'> & {
|
type ScannedSessionProjectMatch = Omit<SessionProjectMatch, 'projectPath'> & {
|
||||||
projectPath?: string;
|
projectPath?: string;
|
||||||
};
|
};
|
||||||
|
|
@ -187,9 +192,15 @@ export class TeamTranscriptProjectResolver {
|
||||||
>();
|
>();
|
||||||
|
|
||||||
constructor(
|
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(
|
async getContext(
|
||||||
teamName: string,
|
teamName: string,
|
||||||
options?: { forceRefresh?: boolean }
|
options?: { forceRefresh?: boolean }
|
||||||
|
|
@ -203,7 +214,7 @@ export class TeamTranscriptProjectResolver {
|
||||||
return cached.value;
|
return cached.value;
|
||||||
}
|
}
|
||||||
|
|
||||||
const config = await this.configReader.getConfig(teamName);
|
const config = await this.readConfigForObservation(teamName);
|
||||||
if (!config) {
|
if (!config) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1669,6 +1669,12 @@ export class BoardTaskLogStreamService {
|
||||||
private readonly historicalBoardMcpRawProbe: HistoricalBoardMcpRawProbe = new HistoricalBoardMcpRawProbe()
|
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 {
|
private buildLayoutCacheKey(teamName: string, taskId: string): string {
|
||||||
return `${teamName}::${taskId}`;
|
return `${teamName}::${taskId}`;
|
||||||
}
|
}
|
||||||
|
|
@ -2199,7 +2205,7 @@ export class BoardTaskLogStreamService {
|
||||||
this.taskReader.getTasks(teamName).catch(() => []),
|
this.taskReader.getTasks(teamName).catch(() => []),
|
||||||
this.taskReader.getDeletedTasks(teamName).catch(() => []),
|
this.taskReader.getDeletedTasks(teamName).catch(() => []),
|
||||||
this.membersMetaStore.getMembers(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 task = [...activeTasks, ...deletedTasks].find((candidate) => candidate.id === taskId);
|
||||||
const ownerName = task?.owner?.trim();
|
const ownerName = task?.owner?.trim();
|
||||||
|
|
|
||||||
|
|
@ -54,6 +54,12 @@ export class CodexNativeTaskLogStreamSource {
|
||||||
private readonly chunkBuilder: BoardTaskExactLogChunkBuilder = new BoardTaskExactLogChunkBuilder()
|
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(
|
async getTaskLogStream(
|
||||||
teamName: string,
|
teamName: string,
|
||||||
taskId: string,
|
taskId: string,
|
||||||
|
|
@ -163,7 +169,7 @@ export class CodexNativeTaskLogStreamSource {
|
||||||
const normalizedOwner = normalizeMemberName(ownerName);
|
const normalizedOwner = normalizeMemberName(ownerName);
|
||||||
const [metaMembers, config] = await Promise.all([
|
const [metaMembers, config] = await Promise.all([
|
||||||
this.membersMetaStore.getMembers(teamName).catch(() => []),
|
this.membersMetaStore.getMembers(teamName).catch(() => []),
|
||||||
this.configReader.getConfig(teamName).catch(() => null),
|
this.readConfigForObservation(teamName).catch(() => null),
|
||||||
]);
|
]);
|
||||||
const member = [...metaMembers, ...(config?.members ?? [])].find(
|
const member = [...metaMembers, ...(config?.members ?? [])].find(
|
||||||
(candidate) => normalizeMemberName(candidate.name) === normalizedOwner
|
(candidate) => normalizeMemberName(candidate.name) === normalizedOwner
|
||||||
|
|
|
||||||
|
|
@ -339,6 +339,35 @@ describe('TeamDataService task projection cache invalidation', () => {
|
||||||
expect(configInvalidateSpy).toHaveBeenCalledWith('gone-team');
|
expect(configInvalidateSpy).toHaveBeenCalledWith('gone-team');
|
||||||
expect(taskInvalidateSpy).toHaveBeenCalledTimes(1);
|
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', () => {
|
describe('TeamDataService draft metadata', () => {
|
||||||
|
|
@ -1370,15 +1399,20 @@ describe('TeamDataService', () => {
|
||||||
|
|
||||||
it('includes projectPath from config when creating a task', async () => {
|
it('includes projectPath from config when creating a task', async () => {
|
||||||
const createTaskMock = vi.fn((task) => task);
|
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(
|
const service = new TeamDataService(
|
||||||
{
|
{
|
||||||
listTeams: vi.fn(),
|
listTeams: vi.fn(),
|
||||||
getConfig: vi.fn(async () => ({
|
getConfig,
|
||||||
name: 'My team',
|
getConfigSnapshot,
|
||||||
members: [],
|
|
||||||
projectPath: '/Users/dev/my-project',
|
|
||||||
})),
|
|
||||||
} as never,
|
} as never,
|
||||||
{
|
{
|
||||||
getNextTaskId: vi.fn(async () => '1'),
|
getNextTaskId: vi.fn(async () => '1'),
|
||||||
|
|
@ -1417,6 +1451,8 @@ describe('TeamDataService', () => {
|
||||||
expect(createTaskMock).toHaveBeenCalledWith(
|
expect(createTaskMock).toHaveBeenCalledWith(
|
||||||
expect.objectContaining({ projectPath: '/Users/dev/my-project' })
|
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 () => {
|
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 () => {
|
it('delivers OpenCode secondary-lane messages to the member worktree cwd after restart', async () => {
|
||||||
const svc = new TeamProvisioningService();
|
const svc = new TeamProvisioningService();
|
||||||
const sendMessageToMember = vi.fn(async (input: Record<string, unknown>) => ({
|
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 () => {
|
it('rejects duplicate OpenCode bootstrap check-ins for members removed after the first check-in', async () => {
|
||||||
const svc = new TeamProvisioningService();
|
const svc = new TeamProvisioningService();
|
||||||
const previousSnapshot = {
|
const previousSnapshot = {
|
||||||
|
|
@ -9664,11 +9796,16 @@ describe('TeamProvisioningService', () => {
|
||||||
|
|
||||||
it('expands teammate permission suggestions to the operational tool set only', async () => {
|
it('expands teammate permission suggestions to the operational tool set only', async () => {
|
||||||
allowConsoleLogs();
|
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({
|
const svc = new TeamProvisioningService({
|
||||||
getConfig: vi.fn(async () => ({
|
getConfig,
|
||||||
projectPath: tempClaudeRoot,
|
getConfigSnapshot,
|
||||||
members: [{ cwd: tempClaudeRoot }],
|
|
||||||
})),
|
|
||||||
} as any);
|
} as any);
|
||||||
|
|
||||||
await (svc as any).respondToTeammatePermission(
|
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__team_stop');
|
||||||
expect(settings.permissions?.allow).not.toContain('mcp__agent-teams__kanban_clear');
|
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 () => {
|
it('does not broaden admin/runtime teammate permission suggestions', async () => {
|
||||||
|
|
|
||||||
|
|
@ -307,6 +307,45 @@ describe('TeamProvisioningService relayLeadInboxMessages', () => {
|
||||||
expect(service.getLiveLeadProcessMessages(teamName)).toHaveLength(1);
|
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', () => {
|
it('shows assistant text after relay capture has already settled', () => {
|
||||||
const service = new TeamProvisioningService();
|
const service = new TeamProvisioningService();
|
||||||
const teamName = 'my-team';
|
const teamName = 'my-team';
|
||||||
|
|
|
||||||
|
|
@ -140,6 +140,30 @@ describe('TeamTranscriptProjectResolver', () => {
|
||||||
return { projectDir, jsonlPath };
|
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 () => {
|
it('repairs stale projectPath when exact leadSessionId exists only in the renamed project', async () => {
|
||||||
await setupClaudeRoot();
|
await setupClaudeRoot();
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue