fix(team): harden runtime advisory and pending sends
This commit is contained in:
parent
660875fa0a
commit
51650dca33
13 changed files with 1295 additions and 120 deletions
|
|
@ -13,8 +13,14 @@ import {
|
||||||
} from './opencode/store/OpenCodeRuntimeManifestEvidenceReader';
|
} from './opencode/store/OpenCodeRuntimeManifestEvidenceReader';
|
||||||
import { TeamInboxReader } from './TeamInboxReader';
|
import { TeamInboxReader } from './TeamInboxReader';
|
||||||
import { TeamMemberLogsFinder } from './TeamMemberLogsFinder';
|
import { TeamMemberLogsFinder } from './TeamMemberLogsFinder';
|
||||||
|
import { TeamTaskReader } from './TeamTaskReader';
|
||||||
|
|
||||||
import type { MemberLogSummary, MemberRuntimeAdvisory, ResolvedTeamMember } from '@shared/types';
|
import type {
|
||||||
|
MemberLogSummary,
|
||||||
|
MemberRuntimeAdvisory,
|
||||||
|
ResolvedTeamMember,
|
||||||
|
TeamTask,
|
||||||
|
} from '@shared/types';
|
||||||
|
|
||||||
interface RuntimeAdvisoryLogFileRef {
|
interface RuntimeAdvisoryLogFileRef {
|
||||||
memberName: string;
|
memberName: string;
|
||||||
|
|
@ -110,6 +116,11 @@ interface CachedTeamBatchAdvisories {
|
||||||
expiresAt: number;
|
expiresAt: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface OpenCodeRuntimeDeliverySupersedingProofTimes {
|
||||||
|
visibleReplyTimes: ReadonlyMap<string, number>;
|
||||||
|
taskProgressTimes: ReadonlyMap<string, number>;
|
||||||
|
}
|
||||||
|
|
||||||
function includesAnyToken(value: string, tokens: readonly string[]): boolean {
|
function includesAnyToken(value: string, tokens: readonly string[]): boolean {
|
||||||
return tokens.some((token) => value.includes(token));
|
return tokens.some((token) => value.includes(token));
|
||||||
}
|
}
|
||||||
|
|
@ -204,6 +215,7 @@ async function mapLimit<T, R>(
|
||||||
|
|
||||||
export class TeamMemberRuntimeAdvisoryService {
|
export class TeamMemberRuntimeAdvisoryService {
|
||||||
private readonly inboxReader = new TeamInboxReader();
|
private readonly inboxReader = new TeamInboxReader();
|
||||||
|
private readonly taskReader = new TeamTaskReader();
|
||||||
private readonly memberCache = new Map<string, CachedRuntimeAdvisory>();
|
private readonly memberCache = new Map<string, CachedRuntimeAdvisory>();
|
||||||
private readonly teamBatchCacheByTeam = new Map<string, CachedTeamBatchAdvisories>();
|
private readonly teamBatchCacheByTeam = new Map<string, CachedTeamBatchAdvisories>();
|
||||||
private readonly cacheGenerationByTeam = new Map<string, number>();
|
private readonly cacheGenerationByTeam = new Map<string, number>();
|
||||||
|
|
@ -552,9 +564,10 @@ export class TeamMemberRuntimeAdvisoryService {
|
||||||
return new Map();
|
return new Map();
|
||||||
}
|
}
|
||||||
|
|
||||||
const visibleRuntimeReplyTimes = await this.readVisibleOpenCodeRuntimeDeliveryReplyTimes(
|
const supersedingProofTimes = await this.readOpenCodeRuntimeDeliverySupersedingProofTimes(
|
||||||
teamName,
|
teamName,
|
||||||
memberKeysWithRecentErrors
|
memberKeysWithRecentErrors,
|
||||||
|
recordsByMember
|
||||||
);
|
);
|
||||||
const result = new Map<string, MemberRuntimeAdvisory>();
|
const result = new Map<string, MemberRuntimeAdvisory>();
|
||||||
for (const [memberKey, records] of recordsByMember) {
|
for (const [memberKey, records] of recordsByMember) {
|
||||||
|
|
@ -567,7 +580,7 @@ export class TeamMemberRuntimeAdvisoryService {
|
||||||
originalName,
|
originalName,
|
||||||
records,
|
records,
|
||||||
now,
|
now,
|
||||||
visibleRuntimeReplyTimes
|
supersedingProofTimes
|
||||||
)
|
)
|
||||||
: null;
|
: null;
|
||||||
if (advisory && originalName) {
|
if (advisory && originalName) {
|
||||||
|
|
@ -589,7 +602,7 @@ export class TeamMemberRuntimeAdvisoryService {
|
||||||
memberName: string,
|
memberName: string,
|
||||||
records: readonly OpenCodePromptDeliveryLedgerRecord[],
|
records: readonly OpenCodePromptDeliveryLedgerRecord[],
|
||||||
now: number,
|
now: number,
|
||||||
visibleRuntimeReplyTimes: ReadonlyMap<string, number>
|
supersedingProofTimes: OpenCodeRuntimeDeliverySupersedingProofTimes
|
||||||
): MemberRuntimeAdvisory | null {
|
): MemberRuntimeAdvisory | null {
|
||||||
const ordered = records
|
const ordered = records
|
||||||
.slice()
|
.slice()
|
||||||
|
|
@ -610,10 +623,10 @@ export class TeamMemberRuntimeAdvisoryService {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
if (
|
if (
|
||||||
this.hasVisibleRuntimeReplyForOpenCodeDeliveryRecord(
|
this.hasSupersedingProofForOpenCodeDeliveryRecord(
|
||||||
memberName,
|
memberName,
|
||||||
latestError,
|
latestError,
|
||||||
visibleRuntimeReplyTimes
|
supersedingProofTimes
|
||||||
)
|
)
|
||||||
) {
|
) {
|
||||||
return null;
|
return null;
|
||||||
|
|
@ -632,6 +645,18 @@ export class TeamMemberRuntimeAdvisoryService {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async readOpenCodeRuntimeDeliverySupersedingProofTimes(
|
||||||
|
teamName: string,
|
||||||
|
activeMemberKeys: ReadonlySet<string>,
|
||||||
|
recordsByMember: ReadonlyMap<string, readonly OpenCodePromptDeliveryLedgerRecord[]>
|
||||||
|
): Promise<OpenCodeRuntimeDeliverySupersedingProofTimes> {
|
||||||
|
const [visibleReplyTimes, taskProgressTimes] = await Promise.all([
|
||||||
|
this.readVisibleOpenCodeRuntimeDeliveryReplyTimes(teamName, activeMemberKeys),
|
||||||
|
this.readTaskProgressProofTimes(teamName, activeMemberKeys, recordsByMember),
|
||||||
|
]);
|
||||||
|
return { visibleReplyTimes, taskProgressTimes };
|
||||||
|
}
|
||||||
|
|
||||||
private async readVisibleOpenCodeRuntimeDeliveryReplyTimes(
|
private async readVisibleOpenCodeRuntimeDeliveryReplyTimes(
|
||||||
teamName: string,
|
teamName: string,
|
||||||
activeMemberKeys: ReadonlySet<string>
|
activeMemberKeys: ReadonlySet<string>
|
||||||
|
|
@ -673,10 +698,133 @@ export class TeamMemberRuntimeAdvisoryService {
|
||||||
return typeof replyObservedAt === 'number' && replyObservedAt > getRecordTimeMs(record);
|
return typeof replyObservedAt === 'number' && replyObservedAt > getRecordTimeMs(record);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private hasSupersedingProofForOpenCodeDeliveryRecord(
|
||||||
|
memberName: string,
|
||||||
|
record: OpenCodePromptDeliveryLedgerRecord,
|
||||||
|
proofTimes: OpenCodeRuntimeDeliverySupersedingProofTimes
|
||||||
|
): boolean {
|
||||||
|
if (
|
||||||
|
this.hasVisibleRuntimeReplyForOpenCodeDeliveryRecord(
|
||||||
|
memberName,
|
||||||
|
record,
|
||||||
|
proofTimes.visibleReplyTimes
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.hasTaskProgressProofForOpenCodeDeliveryRecord(
|
||||||
|
memberName,
|
||||||
|
record,
|
||||||
|
proofTimes.taskProgressTimes
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async readTaskProgressProofTimes(
|
||||||
|
teamName: string,
|
||||||
|
activeMemberKeys: ReadonlySet<string>,
|
||||||
|
recordsByMember: ReadonlyMap<string, readonly OpenCodePromptDeliveryLedgerRecord[]>
|
||||||
|
): Promise<Map<string, number>> {
|
||||||
|
const taskIdsByMember = new Map<string, Set<string>>();
|
||||||
|
for (const [memberKey, records] of recordsByMember) {
|
||||||
|
if (!activeMemberKeys.has(memberKey)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
for (const record of records) {
|
||||||
|
for (const taskRef of record.taskRefs) {
|
||||||
|
const taskId = taskRef.taskId?.trim();
|
||||||
|
if (!taskId) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const taskIds = taskIdsByMember.get(memberKey) ?? new Set<string>();
|
||||||
|
taskIds.add(taskId);
|
||||||
|
taskIdsByMember.set(memberKey, taskIds);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (taskIdsByMember.size === 0) {
|
||||||
|
return new Map();
|
||||||
|
}
|
||||||
|
|
||||||
|
const tasks = await this.taskReader.getTasks(teamName).catch(() => []);
|
||||||
|
if (tasks.length === 0) {
|
||||||
|
return new Map();
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = new Map<string, number>();
|
||||||
|
for (const task of tasks) {
|
||||||
|
const taskId = task.id?.trim();
|
||||||
|
if (!taskId) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
for (const [memberKey, taskIds] of taskIdsByMember) {
|
||||||
|
if (!taskIds.has(taskId)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const proofAt = this.getLatestMemberTaskProgressTime(task, memberKey);
|
||||||
|
if (proofAt <= 0) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const key = this.getOpenCodeTaskProgressProofKey(memberKey, taskId);
|
||||||
|
result.set(key, Math.max(result.get(key) ?? 0, proofAt));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
private getLatestMemberTaskProgressTime(task: TeamTask, memberKey: string): number {
|
||||||
|
let latest = 0;
|
||||||
|
for (const comment of task.comments ?? []) {
|
||||||
|
if (this.normalizeToken(comment.author) !== memberKey) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const createdAt = Date.parse(comment.createdAt);
|
||||||
|
if (Number.isFinite(createdAt)) {
|
||||||
|
latest = Math.max(latest, createdAt);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const event of task.historyEvents ?? []) {
|
||||||
|
if (this.normalizeToken(event.actor ?? '') !== memberKey) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const timestamp = Date.parse(event.timestamp);
|
||||||
|
if (Number.isFinite(timestamp)) {
|
||||||
|
latest = Math.max(latest, timestamp);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return latest;
|
||||||
|
}
|
||||||
|
|
||||||
|
private hasTaskProgressProofForOpenCodeDeliveryRecord(
|
||||||
|
memberName: string,
|
||||||
|
record: OpenCodePromptDeliveryLedgerRecord,
|
||||||
|
taskProgressTimes: ReadonlyMap<string, number>
|
||||||
|
): boolean {
|
||||||
|
const recordTime = getRecordTimeMs(record);
|
||||||
|
if (!Number.isFinite(recordTime) || recordTime <= 0 || record.taskRefs.length === 0) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const memberKey = this.normalizeToken(memberName);
|
||||||
|
return record.taskRefs.some((taskRef) => {
|
||||||
|
const taskId = taskRef.taskId?.trim();
|
||||||
|
if (!taskId) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const proofAt = taskProgressTimes.get(
|
||||||
|
this.getOpenCodeTaskProgressProofKey(memberKey, taskId)
|
||||||
|
);
|
||||||
|
return typeof proofAt === 'number' && proofAt > recordTime;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
private getOpenCodeRuntimeReplyKey(memberKey: string, relayOfMessageId: string): string {
|
private getOpenCodeRuntimeReplyKey(memberKey: string, relayOfMessageId: string): string {
|
||||||
return `${memberKey}::${relayOfMessageId.trim()}`;
|
return `${memberKey}::${relayOfMessageId.trim()}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private getOpenCodeTaskProgressProofKey(memberKey: string, taskId: string): string {
|
||||||
|
return `${memberKey}::task::${taskId.trim()}`;
|
||||||
|
}
|
||||||
|
|
||||||
private async findRecentMemberAdvisoriesFromBatchRefs(
|
private async findRecentMemberAdvisoriesFromBatchRefs(
|
||||||
teamName: string,
|
teamName: string,
|
||||||
memberNames: readonly string[]
|
memberNames: readonly string[]
|
||||||
|
|
|
||||||
|
|
@ -1374,11 +1374,20 @@ export function getOpenCodeMixedProviderProvisioningError(): string {
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getMixedLaunchFallbackRecoveryError(): string {
|
export function getMixedLaunchFallbackRecoveryError(): string {
|
||||||
return (
|
return 'This old mixed team is missing stable member metadata. Open Edit Team and save the roster once before launching.';
|
||||||
'Persisted mixed-team launch recovery requires members.meta.json lane-aware roster truth. ' +
|
}
|
||||||
'Inbox/config fallback cannot safely reconstruct an OpenCode secondary lane in V1. ' +
|
|
||||||
'Run a fresh team bootstrap or restore the missing mixed-team metadata first.'
|
type TeamLaunchCompatibilityLevel = 'ready' | 'repairable' | 'unsafe';
|
||||||
);
|
type TeamLaunchCompatibilityRosterSource = 'members-meta' | 'config' | 'inboxes' | 'missing';
|
||||||
|
type TeamLaunchCompatibilityRepairAction = 'materialize-members-meta';
|
||||||
|
|
||||||
|
interface TeamLaunchCompatibilityReport {
|
||||||
|
level: TeamLaunchCompatibilityLevel;
|
||||||
|
rosterSource: TeamLaunchCompatibilityRosterSource;
|
||||||
|
members: TeamCreateRequest['members'];
|
||||||
|
warnings: string[];
|
||||||
|
blockers: string[];
|
||||||
|
repairAction?: TeamLaunchCompatibilityRepairAction;
|
||||||
}
|
}
|
||||||
|
|
||||||
function assertOpenCodeNotLaunchedThroughLegacyProvisioning(request: {
|
function assertOpenCodeNotLaunchedThroughLegacyProvisioning(request: {
|
||||||
|
|
@ -7206,6 +7215,7 @@ export class TeamProvisioningService {
|
||||||
: [],
|
: [],
|
||||||
observedAt: nowIso(),
|
observedAt: nowIso(),
|
||||||
});
|
});
|
||||||
|
this.emitRuntimeDeliveryReplyAdvisoryRefresh(input.teamName, existing.message);
|
||||||
return { ledgerRecord, visibleReply: existing };
|
return { ledgerRecord, visibleReply: existing };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -7251,6 +7261,7 @@ export class TeamProvisioningService {
|
||||||
: ['opencode_plain_text_reply_materialized_to_user_inbox'],
|
: ['opencode_plain_text_reply_materialized_to_user_inbox'],
|
||||||
observedAt: nowIso(),
|
observedAt: nowIso(),
|
||||||
});
|
});
|
||||||
|
this.emitRuntimeDeliveryReplyAdvisoryRefresh(input.teamName, visibleReply.message);
|
||||||
return { ledgerRecord, visibleReply };
|
return { ledgerRecord, visibleReply };
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.warn(
|
logger.warn(
|
||||||
|
|
@ -7750,6 +7761,41 @@ export class TeamProvisioningService {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private emitRuntimeDeliveryReplyAdvisoryRefresh(teamName: string, message: InboxMessage): void {
|
||||||
|
if (
|
||||||
|
message.source !== 'runtime_delivery' ||
|
||||||
|
typeof message.relayOfMessageId !== 'string' ||
|
||||||
|
message.relayOfMessageId.trim().length === 0
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const memberName = message.from?.trim();
|
||||||
|
if (!memberName || memberName === 'user' || memberName === 'system') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
this.memberRuntimeAdvisoryInvalidator?.(teamName, memberName);
|
||||||
|
} catch (error) {
|
||||||
|
logger.warn(
|
||||||
|
`[${teamName}] Failed to invalidate runtime advisory after runtime delivery reply for ${memberName}: ${getErrorMessage(error)}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
this.teamChangeEmitter?.({
|
||||||
|
type: 'member-advisory',
|
||||||
|
teamName,
|
||||||
|
detail: `runtime-delivery-reply:${memberName}:${message.relayOfMessageId.trim()}`,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
logger.warn(
|
||||||
|
`[${teamName}] Failed to emit runtime advisory refresh after runtime delivery reply for ${memberName}: ${getErrorMessage(error)}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private pruneOpenCodeRuntimeDeliveryAdvisoryEventDedupe(now: number): void {
|
private pruneOpenCodeRuntimeDeliveryAdvisoryEventDedupe(now: number): void {
|
||||||
const ttlMs = TeamProvisioningService.OPENCODE_RUNTIME_DELIVERY_ADVISORY_EVENT_TTL_MS;
|
const ttlMs = TeamProvisioningService.OPENCODE_RUNTIME_DELIVERY_ADVISORY_EVENT_TTL_MS;
|
||||||
for (const [key, sentAt] of this.openCodeRuntimeDeliveryAdvisoryEventSentAt) {
|
for (const [key, sentAt] of this.openCodeRuntimeDeliveryAdvisoryEventSentAt) {
|
||||||
|
|
@ -10111,6 +10157,7 @@ export class TeamProvisioningService {
|
||||||
slashCommand: message.slashCommand,
|
slashCommand: message.slashCommand,
|
||||||
commandOutput: message.commandOutput,
|
commandOutput: message.commandOutput,
|
||||||
});
|
});
|
||||||
|
this.emitRuntimeDeliveryReplyAdvisoryRefresh(teamName, message);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.warn(`[${teamName}] inbox-message persist for ${recipient} failed: ${String(error)}`);
|
logger.warn(`[${teamName}] inbox-message persist for ${recipient} failed: ${String(error)}`);
|
||||||
}
|
}
|
||||||
|
|
@ -17936,11 +17983,23 @@ export class TeamProvisioningService {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const launchCompatibility = await this.probeLaunchCompatibility(
|
||||||
|
request.teamName,
|
||||||
|
configRaw,
|
||||||
|
request.providerId
|
||||||
|
);
|
||||||
|
if (launchCompatibility.level === 'unsafe') {
|
||||||
|
this.provisioningRunByTeam.delete(request.teamName);
|
||||||
|
throw new Error(launchCompatibility.blockers[0] ?? getMixedLaunchFallbackRecoveryError());
|
||||||
|
}
|
||||||
|
if (launchCompatibility.repairAction === 'materialize-members-meta') {
|
||||||
|
await this.materializeLaunchCompatibilityRepair(request, launchCompatibility);
|
||||||
|
}
|
||||||
const {
|
const {
|
||||||
members: expectedMemberSpecs,
|
members: expectedMemberSpecs,
|
||||||
source,
|
source,
|
||||||
warning,
|
warning,
|
||||||
} = await this.resolveLaunchExpectedMembers(request.teamName, configRaw, request.providerId);
|
} = this.resolveLaunchExpectedMembersFromCompatibility(launchCompatibility);
|
||||||
assertOpenCodeNotLaunchedThroughLegacyProvisioning({
|
assertOpenCodeNotLaunchedThroughLegacyProvisioning({
|
||||||
providerId: request.providerId,
|
providerId: request.providerId,
|
||||||
members: expectedMemberSpecs,
|
members: expectedMemberSpecs,
|
||||||
|
|
@ -30680,54 +30739,54 @@ export class TeamProvisioningService {
|
||||||
source: 'members-meta' | 'inboxes' | 'config-fallback';
|
source: 'members-meta' | 'inboxes' | 'config-fallback';
|
||||||
warning?: string;
|
warning?: string;
|
||||||
}> {
|
}> {
|
||||||
|
return this.resolveLaunchExpectedMembersFromCompatibility(
|
||||||
|
await this.probeLaunchCompatibility(teamName, configRaw, leadProviderId)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private resolveLaunchExpectedMembersFromCompatibility(report: TeamLaunchCompatibilityReport): {
|
||||||
|
members: TeamCreateRequest['members'];
|
||||||
|
source: 'members-meta' | 'inboxes' | 'config-fallback';
|
||||||
|
warning?: string;
|
||||||
|
} {
|
||||||
|
if (report.level === 'unsafe') {
|
||||||
|
throw new Error(report.blockers[0] ?? getMixedLaunchFallbackRecoveryError());
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
members: report.members,
|
||||||
|
source:
|
||||||
|
report.rosterSource === 'members-meta'
|
||||||
|
? 'members-meta'
|
||||||
|
: report.rosterSource === 'inboxes'
|
||||||
|
? 'inboxes'
|
||||||
|
: 'config-fallback',
|
||||||
|
...(report.warnings.length > 0 ? { warning: report.warnings.join(' ') } : {}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private async probeLaunchCompatibility(
|
||||||
|
teamName: string,
|
||||||
|
configRaw: string,
|
||||||
|
leadProviderId?: TeamProviderId
|
||||||
|
): Promise<TeamLaunchCompatibilityReport> {
|
||||||
|
// Keep this probe read-only: launch-state/bootstrap-state may inform existing resume guards,
|
||||||
|
// but compatibility repair must not mutate or trust stale runtime projections.
|
||||||
|
await Promise.allSettled([
|
||||||
|
this.launchStateStore.read(teamName),
|
||||||
|
readBootstrapLaunchSnapshot(teamName),
|
||||||
|
]);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const metaMembers = await this.membersMetaStore.getMembers(teamName);
|
const metaMembers = await this.membersMetaStore.getMembers(teamName);
|
||||||
const byName = new Map<string, TeamCreateRequest['members'][number]>();
|
const members = this.buildLaunchMembersFromMeta(metaMembers);
|
||||||
for (const member of metaMembers) {
|
|
||||||
const rawName = member.name?.trim() ?? '';
|
|
||||||
const lower = rawName.toLowerCase();
|
|
||||||
if (isLeadMember(member) || lower === 'user') {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
const name = rawName;
|
|
||||||
if (!name) continue;
|
|
||||||
if (member.removedAt) continue;
|
|
||||||
const role = typeof member.role === 'string' ? member.role.trim() || undefined : undefined;
|
|
||||||
const workflow =
|
|
||||||
typeof member.workflow === 'string' ? member.workflow.trim() || undefined : undefined;
|
|
||||||
const isolation = member.isolation === 'worktree' ? 'worktree' : undefined;
|
|
||||||
const providerId = normalizeOptionalTeamProviderId(member.providerId);
|
|
||||||
const model =
|
|
||||||
typeof member.model === 'string' ? member.model.trim() || undefined : undefined;
|
|
||||||
const effort = isTeamEffortLevel(member.effort) ? member.effort : undefined;
|
|
||||||
const cwd = typeof member.cwd === 'string' ? member.cwd.trim() || undefined : undefined;
|
|
||||||
const prev = byName.get(name);
|
|
||||||
if (!prev) {
|
|
||||||
byName.set(name, { name, role, workflow, isolation, cwd, providerId, model, effort });
|
|
||||||
} else {
|
|
||||||
byName.set(name, {
|
|
||||||
...prev,
|
|
||||||
role: prev.role || role,
|
|
||||||
workflow: prev.workflow || workflow,
|
|
||||||
isolation: prev.isolation || isolation,
|
|
||||||
cwd: prev.cwd || cwd,
|
|
||||||
providerId: prev.providerId || providerId,
|
|
||||||
model: prev.model || model,
|
|
||||||
effort: prev.effort || effort,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Defense: ignore CLI auto-suffixed duplicates (alice-2) when base name exists.
|
|
||||||
const allNames = Array.from(byName.keys());
|
|
||||||
const keepName = createCliAutoSuffixNameGuard(allNames);
|
|
||||||
for (const name of allNames) {
|
|
||||||
if (!keepName(name)) {
|
|
||||||
byName.delete(name);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const members = Array.from(byName.values()).sort((a, b) => a.name.localeCompare(b.name));
|
|
||||||
if (members.length > 0) {
|
if (members.length > 0) {
|
||||||
return { members, source: 'members-meta' };
|
return {
|
||||||
|
level: 'ready',
|
||||||
|
rosterSource: 'members-meta',
|
||||||
|
members,
|
||||||
|
warnings: [],
|
||||||
|
blockers: [],
|
||||||
|
};
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.warn(
|
logger.warn(
|
||||||
|
|
@ -30781,28 +30840,37 @@ export class TeamProvisioningService {
|
||||||
const memberOverridesUsed = members.some(
|
const memberOverridesUsed = members.some(
|
||||||
(member) => member.providerId || member.model || member.effort || member.isolation
|
(member) => member.providerId || member.model || member.effort || member.isolation
|
||||||
);
|
);
|
||||||
this.assertMixedLaunchFallbackSafe({
|
if (
|
||||||
teamName,
|
this.hasIncompleteOpenCodeLaunchCompatibilityMember(members) ||
|
||||||
leadProviderId,
|
this.isUnsafeMixedLaunchFallback({
|
||||||
source: 'inboxes',
|
leadProviderId,
|
||||||
members,
|
members,
|
||||||
});
|
})
|
||||||
|
) {
|
||||||
|
return {
|
||||||
|
level: 'unsafe',
|
||||||
|
rosterSource: 'inboxes',
|
||||||
|
members: [],
|
||||||
|
warnings: [],
|
||||||
|
blockers: [
|
||||||
|
`[${teamName}] ${getMixedLaunchFallbackRecoveryError()} Fallback source: inboxes.`,
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
return {
|
return {
|
||||||
|
level: 'ready',
|
||||||
|
rosterSource: 'inboxes',
|
||||||
members,
|
members,
|
||||||
source: 'inboxes',
|
warnings: memberOverridesUsed
|
||||||
...(memberOverridesUsed
|
? [
|
||||||
? {
|
'Launch roster was recovered from inboxes and merged with config.json provider/model/effort overrides. ' +
|
||||||
warning:
|
|
||||||
'Launch roster was recovered from inboxes and merged with config.json provider/model/effort overrides. ' +
|
|
||||||
'Multimodel reconnect is best-effort in this fallback path.',
|
'Multimodel reconnect is best-effort in this fallback path.',
|
||||||
}
|
]
|
||||||
: {}),
|
: [],
|
||||||
|
blockers: [],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof Error && error.message.includes(getMixedLaunchFallbackRecoveryError())) {
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
logger.warn(
|
logger.warn(
|
||||||
`[${teamName}] Failed to read inbox member names: ${
|
`[${teamName}] Failed to read inbox member names: ${
|
||||||
error instanceof Error ? error.message : String(error)
|
error instanceof Error ? error.message : String(error)
|
||||||
|
|
@ -30812,18 +30880,51 @@ export class TeamProvisioningService {
|
||||||
|
|
||||||
const configMembers = this.extractTeammateSpecsFromConfig(teamName, configRaw);
|
const configMembers = this.extractTeammateSpecsFromConfig(teamName, configRaw);
|
||||||
if (configMembers.length > 0) {
|
if (configMembers.length > 0) {
|
||||||
this.assertMixedLaunchFallbackSafe({
|
if (this.hasIncompleteOpenCodeLaunchCompatibilityMember(configMembers)) {
|
||||||
teamName,
|
return {
|
||||||
|
level: 'unsafe',
|
||||||
|
rosterSource: 'config',
|
||||||
|
members: [],
|
||||||
|
warnings: [],
|
||||||
|
blockers: [
|
||||||
|
`[${teamName}] ${getMixedLaunchFallbackRecoveryError()} Fallback source: config.`,
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const lanePlan = this.runtimeLaneCoordinator.planProvisioningMembers({
|
||||||
leadProviderId,
|
leadProviderId,
|
||||||
source: 'config-fallback',
|
|
||||||
members: configMembers,
|
members: configMembers,
|
||||||
|
hasOpenCodeRuntimeAdapter: true,
|
||||||
});
|
});
|
||||||
|
if (this.runtimeLaneCoordinator.isMixedSideLanePlan(lanePlan)) {
|
||||||
|
const sideLanesHaveExplicitProviderModels = lanePlan.sideLanes.every(
|
||||||
|
(lane) =>
|
||||||
|
normalizeOptionalTeamProviderId(lane.member.providerId) === 'opencode' &&
|
||||||
|
typeof lane.member.model === 'string' &&
|
||||||
|
lane.member.model.trim().length > 0
|
||||||
|
);
|
||||||
|
if (!sideLanesHaveExplicitProviderModels) {
|
||||||
|
return {
|
||||||
|
level: 'unsafe',
|
||||||
|
rosterSource: 'config',
|
||||||
|
members: [],
|
||||||
|
warnings: [],
|
||||||
|
blockers: [
|
||||||
|
`[${teamName}] ${getMixedLaunchFallbackRecoveryError()} Fallback source: config.`,
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
return {
|
return {
|
||||||
|
level: 'repairable',
|
||||||
|
rosterSource: 'config',
|
||||||
members: configMembers,
|
members: configMembers,
|
||||||
source: 'config-fallback',
|
warnings: [
|
||||||
warning:
|
|
||||||
'members.meta.json and inboxes are empty; launch fell back to config.json members. ' +
|
'members.meta.json and inboxes are empty; launch fell back to config.json members. ' +
|
||||||
'Run a fresh team bootstrap to persist stable member metadata.',
|
'Run a fresh team bootstrap to persist stable member metadata.',
|
||||||
|
],
|
||||||
|
blockers: [],
|
||||||
|
repairAction: 'materialize-members-meta',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -30835,30 +30936,121 @@ export class TeamProvisioningService {
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
level: 'ready',
|
||||||
|
rosterSource: 'missing',
|
||||||
members: [],
|
members: [],
|
||||||
source: 'config-fallback',
|
warnings: configParseFailed
|
||||||
...(configParseFailed
|
? [
|
||||||
? {
|
'Config could not be parsed during launch roster discovery. ' +
|
||||||
warning:
|
|
||||||
'Config could not be parsed during launch roster discovery. ' +
|
|
||||||
'Launch will continue without explicit teammate names.',
|
'Launch will continue without explicit teammate names.',
|
||||||
}
|
]
|
||||||
: {}),
|
: [],
|
||||||
|
blockers: [],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private buildLaunchMembersFromMeta(metaMembers: TeamMember[]): TeamCreateRequest['members'] {
|
||||||
|
const byName = new Map<string, TeamCreateRequest['members'][number]>();
|
||||||
|
for (const member of metaMembers) {
|
||||||
|
const rawName = member.name?.trim() ?? '';
|
||||||
|
const lower = rawName.toLowerCase();
|
||||||
|
if (isLeadMember(member) || lower === 'user') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const name = rawName;
|
||||||
|
if (!name) continue;
|
||||||
|
if (member.removedAt) continue;
|
||||||
|
const role = typeof member.role === 'string' ? member.role.trim() || undefined : undefined;
|
||||||
|
const workflow =
|
||||||
|
typeof member.workflow === 'string' ? member.workflow.trim() || undefined : undefined;
|
||||||
|
const isolation = member.isolation === 'worktree' ? 'worktree' : undefined;
|
||||||
|
const providerId = normalizeOptionalTeamProviderId(member.providerId);
|
||||||
|
const model = typeof member.model === 'string' ? member.model.trim() || undefined : undefined;
|
||||||
|
const effort = isTeamEffortLevel(member.effort) ? member.effort : undefined;
|
||||||
|
const cwd = typeof member.cwd === 'string' ? member.cwd.trim() || undefined : undefined;
|
||||||
|
const prev = byName.get(name);
|
||||||
|
if (!prev) {
|
||||||
|
byName.set(name, { name, role, workflow, isolation, cwd, providerId, model, effort });
|
||||||
|
} else {
|
||||||
|
byName.set(name, {
|
||||||
|
...prev,
|
||||||
|
role: prev.role || role,
|
||||||
|
workflow: prev.workflow || workflow,
|
||||||
|
isolation: prev.isolation || isolation,
|
||||||
|
cwd: prev.cwd || cwd,
|
||||||
|
providerId: prev.providerId || providerId,
|
||||||
|
model: prev.model || model,
|
||||||
|
effort: prev.effort || effort,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const allNames = Array.from(byName.keys());
|
||||||
|
const keepName = createCliAutoSuffixNameGuard(allNames);
|
||||||
|
for (const name of allNames) {
|
||||||
|
if (!keepName(name)) {
|
||||||
|
byName.delete(name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Array.from(byName.values()).sort((a, b) => a.name.localeCompare(b.name));
|
||||||
|
}
|
||||||
|
|
||||||
|
private async materializeLaunchCompatibilityRepair(
|
||||||
|
request: TeamLaunchRequest,
|
||||||
|
report: TeamLaunchCompatibilityReport
|
||||||
|
): Promise<void> {
|
||||||
|
if (report.repairAction !== 'materialize-members-meta' || report.members.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const joinedAt = Date.now();
|
||||||
|
const membersToWrite = this.buildMembersMetaWritePayload(
|
||||||
|
report.members.map((member) => ({
|
||||||
|
...member,
|
||||||
|
joinedAt,
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
await this.membersMetaStore.writeMembers(request.teamName, membersToWrite, {
|
||||||
|
providerBackendId: request.providerBackendId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private isUnsafeMixedLaunchFallback(params: {
|
||||||
|
leadProviderId?: TeamProviderId;
|
||||||
|
members: TeamCreateRequest['members'];
|
||||||
|
}): boolean {
|
||||||
|
const lanePlan = this.runtimeLaneCoordinator.planProvisioningMembers({
|
||||||
|
leadProviderId: params.leadProviderId,
|
||||||
|
members: params.members,
|
||||||
|
hasOpenCodeRuntimeAdapter: true,
|
||||||
|
});
|
||||||
|
return this.runtimeLaneCoordinator.isMixedSideLanePlan(lanePlan);
|
||||||
|
}
|
||||||
|
|
||||||
|
private hasIncompleteOpenCodeLaunchCompatibilityMember(
|
||||||
|
members: TeamCreateRequest['members']
|
||||||
|
): boolean {
|
||||||
|
return members.some((member) => {
|
||||||
|
const providerId = normalizeOptionalTeamProviderId(member.providerId);
|
||||||
|
const model = typeof member.model === 'string' ? member.model.trim() : '';
|
||||||
|
const inferredProviderId = inferTeamProviderIdFromModel(model);
|
||||||
|
return (
|
||||||
|
(providerId === 'opencode' && model.length === 0) ||
|
||||||
|
(!providerId && inferredProviderId === 'opencode')
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
private assertMixedLaunchFallbackSafe(params: {
|
private assertMixedLaunchFallbackSafe(params: {
|
||||||
teamName: string;
|
teamName: string;
|
||||||
leadProviderId?: TeamProviderId;
|
leadProviderId?: TeamProviderId;
|
||||||
source: 'inboxes' | 'config-fallback';
|
source: 'inboxes' | 'config-fallback';
|
||||||
members: TeamCreateRequest['members'];
|
members: TeamCreateRequest['members'];
|
||||||
}): void {
|
}): void {
|
||||||
const lanePlan = this.runtimeLaneCoordinator.planProvisioningMembers({
|
if (
|
||||||
leadProviderId: params.leadProviderId,
|
this.isUnsafeMixedLaunchFallback({
|
||||||
members: params.members,
|
leadProviderId: params.leadProviderId,
|
||||||
hasOpenCodeRuntimeAdapter: true,
|
members: params.members,
|
||||||
});
|
})
|
||||||
if (this.runtimeLaneCoordinator.isMixedSideLanePlan(lanePlan)) {
|
) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
`[${params.teamName}] ${getMixedLaunchFallbackRecoveryError()} Fallback source: ${params.source}.`
|
`[${params.teamName}] ${getMixedLaunchFallbackRecoveryError()} Fallback source: ${params.source}.`
|
||||||
);
|
);
|
||||||
|
|
@ -30882,6 +31074,7 @@ export class TeamProvisioningService {
|
||||||
model?: string;
|
model?: string;
|
||||||
effort?: string;
|
effort?: string;
|
||||||
cwd?: string;
|
cwd?: string;
|
||||||
|
removedAt?: unknown;
|
||||||
}[];
|
}[];
|
||||||
};
|
};
|
||||||
if (!Array.isArray(parsed.members)) {
|
if (!Array.isArray(parsed.members)) {
|
||||||
|
|
@ -30894,6 +31087,7 @@ export class TeamProvisioningService {
|
||||||
if (!member || isLeadMember(member) || lower === 'user') continue;
|
if (!member || isLeadMember(member) || lower === 'user') continue;
|
||||||
const name = rawName;
|
const name = rawName;
|
||||||
if (!name) continue;
|
if (!name) continue;
|
||||||
|
if (member.removedAt != null) continue;
|
||||||
byName.set(name, {
|
byName.set(name, {
|
||||||
name,
|
name,
|
||||||
role: typeof member.role === 'string' ? member.role.trim() || undefined : undefined,
|
role: typeof member.role === 'string' ? member.role.trim() || undefined : undefined,
|
||||||
|
|
|
||||||
|
|
@ -59,6 +59,11 @@ import {
|
||||||
formatTaskDisplayLabel,
|
formatTaskDisplayLabel,
|
||||||
taskMatchesRef,
|
taskMatchesRef,
|
||||||
} from '@shared/utils/taskIdentity';
|
} from '@shared/utils/taskIdentity';
|
||||||
|
import {
|
||||||
|
calculateTaskImplementationDuration,
|
||||||
|
formatTaskImplementationDuration,
|
||||||
|
shouldShowTaskImplementationDuration,
|
||||||
|
} from '@shared/utils/taskWorkDuration';
|
||||||
import {
|
import {
|
||||||
getTeamTaskWorkflowColumn,
|
getTeamTaskWorkflowColumn,
|
||||||
isTeamTaskFinishedForDependency,
|
isTeamTaskFinishedForDependency,
|
||||||
|
|
@ -330,6 +335,10 @@ export const TaskDetailDialog = ({
|
||||||
currentTask?.sourceMessageId && currentTask?.sourceMessage?.attachments?.length
|
currentTask?.sourceMessageId && currentTask?.sourceMessage?.attachments?.length
|
||||||
? currentTask.sourceMessage.attachments.length
|
? currentTask.sourceMessage.attachments.length
|
||||||
: 0;
|
: 0;
|
||||||
|
const attachmentCount =
|
||||||
|
(currentTask?.attachments?.length ?? 0) +
|
||||||
|
commentImageAttachments.length +
|
||||||
|
sourceAttachmentCount;
|
||||||
|
|
||||||
// Changes is the explicit lazy-load entry point. Keep it visible for all team tasks,
|
// Changes is the explicit lazy-load entry point. Keep it visible for all team tasks,
|
||||||
// including old/pending tasks that may resolve to an empty result.
|
// including old/pending tasks that may resolve to an empty result.
|
||||||
|
|
@ -574,6 +583,29 @@ export const TaskDetailDialog = ({
|
||||||
setChangesSectionOpen(isOpen);
|
setChangesSectionOpen(isOpen);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const [taskDurationNowMs, setTaskDurationNowMs] = useState(() => Date.now());
|
||||||
|
const taskImplementationDuration = useMemo(
|
||||||
|
() => calculateTaskImplementationDuration(currentTask, taskDurationNowMs),
|
||||||
|
[currentTask, taskDurationNowMs]
|
||||||
|
);
|
||||||
|
const showTaskImplementationDuration = shouldShowTaskImplementationDuration(
|
||||||
|
taskImplementationDuration
|
||||||
|
);
|
||||||
|
const taskImplementationDurationLabel = formatTaskImplementationDuration(
|
||||||
|
taskImplementationDuration.elapsedMs
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open || !taskImplementationDuration.hasRunningInterval) return;
|
||||||
|
|
||||||
|
setTaskDurationNowMs(Date.now());
|
||||||
|
const intervalId = window.setInterval(() => {
|
||||||
|
setTaskDurationNowMs(Date.now());
|
||||||
|
}, 1000);
|
||||||
|
|
||||||
|
return () => window.clearInterval(intervalId);
|
||||||
|
}, [open, taskImplementationDuration.hasRunningInterval, currentTask?.id]);
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
<Dialog open={open} onOpenChange={(v) => !v && onClose()}>
|
<Dialog open={open} onOpenChange={(v) => !v && onClose()}>
|
||||||
|
|
@ -1121,24 +1153,11 @@ export const TaskDetailDialog = ({
|
||||||
<CollapsibleTeamSection
|
<CollapsibleTeamSection
|
||||||
title="Attachments"
|
title="Attachments"
|
||||||
icon={<ImageIcon size={14} />}
|
icon={<ImageIcon size={14} />}
|
||||||
badge={
|
badge={attachmentCount}
|
||||||
(currentTask.attachments?.length ?? 0) +
|
|
||||||
commentImageAttachments.length +
|
|
||||||
sourceAttachmentCount >
|
|
||||||
0
|
|
||||||
? (currentTask.attachments?.length ?? 0) +
|
|
||||||
commentImageAttachments.length +
|
|
||||||
sourceAttachmentCount
|
|
||||||
: undefined
|
|
||||||
}
|
|
||||||
contentClassName="pl-2.5"
|
contentClassName="pl-2.5"
|
||||||
headerClassName="-mx-6 w-[calc(100%+3rem)]"
|
headerClassName="-mx-6 w-[calc(100%+3rem)]"
|
||||||
headerContentClassName="pl-6"
|
headerContentClassName="pl-6"
|
||||||
defaultOpen={
|
defaultOpen={attachmentCount > 0}
|
||||||
(currentTask.attachments?.length ?? 0) > 0 ||
|
|
||||||
commentImageAttachments.length > 0 ||
|
|
||||||
sourceAttachmentCount > 0
|
|
||||||
}
|
|
||||||
>
|
>
|
||||||
{currentTask.sourceMessageId && currentTask.sourceMessage ? (
|
{currentTask.sourceMessageId && currentTask.sourceMessage ? (
|
||||||
<SourceMessageAttachments
|
<SourceMessageAttachments
|
||||||
|
|
@ -1352,6 +1371,17 @@ export const TaskDetailDialog = ({
|
||||||
contentClassName="pl-2.5"
|
contentClassName="pl-2.5"
|
||||||
headerClassName="-mx-6 w-[calc(100%+3rem)]"
|
headerClassName="-mx-6 w-[calc(100%+3rem)]"
|
||||||
headerContentClassName="pl-6"
|
headerContentClassName="pl-6"
|
||||||
|
headerExtra={
|
||||||
|
showTaskImplementationDuration ? (
|
||||||
|
<span
|
||||||
|
className="inline-flex items-center gap-1 rounded-md border border-[var(--color-border)] bg-[var(--color-bg-secondary)] px-1.5 py-0.5 text-[10px] font-normal leading-none text-[var(--color-text-muted)]"
|
||||||
|
title="Implementation time from persisted work intervals"
|
||||||
|
>
|
||||||
|
<Clock size={10} />
|
||||||
|
<span>Work time {taskImplementationDurationLabel}</span>
|
||||||
|
</span>
|
||||||
|
) : undefined
|
||||||
|
}
|
||||||
defaultOpen={false}
|
defaultOpen={false}
|
||||||
>
|
>
|
||||||
<WorkflowTimeline events={currentTask.historyEvents} memberColorMap={colorMap} />
|
<WorkflowTimeline events={currentTask.historyEvents} memberColorMap={colorMap} />
|
||||||
|
|
|
||||||
|
|
@ -59,6 +59,38 @@ const draftHarness = vi.hoisted(() => {
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const provisioningHarness = vi.hoisted(() => {
|
||||||
|
const state = {
|
||||||
|
active: false,
|
||||||
|
};
|
||||||
|
return {
|
||||||
|
reset: () => {
|
||||||
|
state.active = false;
|
||||||
|
},
|
||||||
|
state,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const storeHarness = vi.hoisted(() => {
|
||||||
|
const state = {
|
||||||
|
crossTeamTargets: [] as {
|
||||||
|
teamName: string;
|
||||||
|
displayName: string;
|
||||||
|
description?: string;
|
||||||
|
color?: string;
|
||||||
|
leadName?: string;
|
||||||
|
leadColor?: string;
|
||||||
|
isOnline?: boolean;
|
||||||
|
}[],
|
||||||
|
};
|
||||||
|
return {
|
||||||
|
reset: () => {
|
||||||
|
state.crossTeamTargets = [];
|
||||||
|
},
|
||||||
|
state,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
vi.mock('@renderer/api', () => ({
|
vi.mock('@renderer/api', () => ({
|
||||||
api: {
|
api: {
|
||||||
teams: {
|
teams: {
|
||||||
|
|
@ -176,7 +208,7 @@ vi.mock('@renderer/hooks/useTeamSuggestions', () => ({
|
||||||
vi.mock('@renderer/store', () => ({
|
vi.mock('@renderer/store', () => ({
|
||||||
useStore: (selector: (state: Record<string, unknown>) => unknown) =>
|
useStore: (selector: (state: Record<string, unknown>) => unknown) =>
|
||||||
selector({
|
selector({
|
||||||
crossTeamTargets: [],
|
crossTeamTargets: storeHarness.state.crossTeamTargets,
|
||||||
fetchCrossTeamTargets: vi.fn(),
|
fetchCrossTeamTargets: vi.fn(),
|
||||||
fetchSkillsCatalog: vi.fn(),
|
fetchSkillsCatalog: vi.fn(),
|
||||||
selectedTeamData: null,
|
selectedTeamData: null,
|
||||||
|
|
@ -187,7 +219,7 @@ vi.mock('@renderer/store', () => ({
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock('@renderer/store/slices/teamSlice', () => ({
|
vi.mock('@renderer/store/slices/teamSlice', () => ({
|
||||||
isTeamProvisioningActive: () => false,
|
isTeamProvisioningActive: () => provisioningHarness.state.active,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
import { MessageComposer } from './MessageComposer';
|
import { MessageComposer } from './MessageComposer';
|
||||||
|
|
@ -203,6 +235,16 @@ const members: ResolvedTeamMember[] = [
|
||||||
status: 'idle',
|
status: 'idle',
|
||||||
taskCount: 0,
|
taskCount: 0,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
agentType: 'developer',
|
||||||
|
currentTaskId: null,
|
||||||
|
lastActiveAt: null,
|
||||||
|
messageCount: 0,
|
||||||
|
name: 'bob',
|
||||||
|
role: 'Developer',
|
||||||
|
status: 'idle',
|
||||||
|
taskCount: 0,
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
function renderComposer(overrides: Partial<React.ComponentProps<typeof MessageComposer>> = {}): {
|
function renderComposer(overrides: Partial<React.ComponentProps<typeof MessageComposer>> = {}): {
|
||||||
|
|
@ -255,10 +297,22 @@ function getTextarea(host: HTMLElement): HTMLTextAreaElement {
|
||||||
return textarea;
|
return textarea;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getButtonContainingText(host: HTMLElement, text: string): HTMLButtonElement {
|
||||||
|
const button = Array.from(host.querySelectorAll('button')).find((candidate) =>
|
||||||
|
candidate.textContent?.includes(text)
|
||||||
|
);
|
||||||
|
if (!(button instanceof HTMLButtonElement)) {
|
||||||
|
throw new Error(`Button containing "${text}" not found`);
|
||||||
|
}
|
||||||
|
return button;
|
||||||
|
}
|
||||||
|
|
||||||
describe('MessageComposer pending send lifecycle', () => {
|
describe('MessageComposer pending send lifecycle', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
|
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
|
||||||
draftHarness.reset();
|
draftHarness.reset();
|
||||||
|
provisioningHarness.reset();
|
||||||
|
storeHarness.reset();
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
|
|
@ -377,4 +431,101 @@ describe('MessageComposer pending send lifecycle', () => {
|
||||||
root.unmount();
|
root.unmount();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('keeps send enabled when stale provisioning state remains after the team is alive', () => {
|
||||||
|
provisioningHarness.state.active = true;
|
||||||
|
const { host, onSend, root } = renderComposer({ isTeamAlive: true });
|
||||||
|
|
||||||
|
expect(getSendButton(host).disabled).toBe(false);
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
getSendButton(host).click();
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(onSend).toHaveBeenCalledOnce();
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
root.unmount();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps send disabled while provisioning before the team is alive', () => {
|
||||||
|
provisioningHarness.state.active = true;
|
||||||
|
const { host, onSend, root } = renderComposer({ isTeamAlive: false });
|
||||||
|
|
||||||
|
expect(getSendButton(host).disabled).toBe(true);
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
getSendButton(host).click();
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(onSend).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
root.unmount();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns focus to the textarea after sending', () => {
|
||||||
|
const { host, root } = renderComposer();
|
||||||
|
const sendButton = getSendButton(host);
|
||||||
|
const textarea = getTextarea(host);
|
||||||
|
|
||||||
|
sendButton.focus();
|
||||||
|
expect(document.activeElement).toBe(sendButton);
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
sendButton.click();
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(document.activeElement).toBe(textarea);
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
root.unmount();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns focus to the textarea after selecting a recipient member', () => {
|
||||||
|
const { host, root } = renderComposer();
|
||||||
|
const bobButton = getButtonContainingText(host, 'bob');
|
||||||
|
const textarea = getTextarea(host);
|
||||||
|
|
||||||
|
bobButton.focus();
|
||||||
|
expect(document.activeElement).toBe(bobButton);
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
bobButton.click();
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(document.activeElement).toBe(textarea);
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
root.unmount();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns focus to the textarea after selecting a cross-team recipient', () => {
|
||||||
|
storeHarness.state.crossTeamTargets = [
|
||||||
|
{
|
||||||
|
teamName: 'team-beta',
|
||||||
|
displayName: 'Beta Team',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
const { host, root } = renderComposer({ onCrossTeamSend: vi.fn() });
|
||||||
|
const betaTeamButton = getButtonContainingText(host, 'Beta Team');
|
||||||
|
const textarea = getTextarea(host);
|
||||||
|
|
||||||
|
betaTeamButton.focus();
|
||||||
|
expect(document.activeElement).toBe(betaTeamButton);
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
betaTeamButton.click();
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(document.activeElement).toBe(textarea);
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
root.unmount();
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -120,6 +120,14 @@ export const MessageComposer = ({
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}, [externalTextareaRef]);
|
}, [externalTextareaRef]);
|
||||||
|
const focusComposerTextarea = useCallback(() => {
|
||||||
|
const focus = (): void => {
|
||||||
|
internalTextareaRef.current?.focus();
|
||||||
|
};
|
||||||
|
focus();
|
||||||
|
queueMicrotask(focus);
|
||||||
|
window.requestAnimationFrame(focus);
|
||||||
|
}, []);
|
||||||
const [recipient, setRecipient] = useState<string>(() => {
|
const [recipient, setRecipient] = useState<string>(() => {
|
||||||
const lead = members.find((m) => isLeadMember(m));
|
const lead = members.find((m) => isLeadMember(m));
|
||||||
return lead?.name ?? members[0]?.name ?? '';
|
return lead?.name ?? members[0]?.name ?? '';
|
||||||
|
|
@ -251,6 +259,7 @@ export const MessageComposer = ({
|
||||||
);
|
);
|
||||||
const userSkills = useStore(useShallow((s) => s.skillsUserCatalog));
|
const userSkills = useStore(useShallow((s) => s.skillsUserCatalog));
|
||||||
const fetchSkillsCatalog = useStore((s) => s.fetchSkillsCatalog);
|
const fetchSkillsCatalog = useStore((s) => s.fetchSkillsCatalog);
|
||||||
|
const isLaunchBlocking = isProvisioning && !isTeamAlive;
|
||||||
|
|
||||||
// Fetch skills catalog for the team's project on mount / project change
|
// Fetch skills catalog for the team's project on mount / project change
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -285,9 +294,9 @@ export const MessageComposer = ({
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (prevActionModeRef.current !== actionMode) {
|
if (prevActionModeRef.current !== actionMode) {
|
||||||
prevActionModeRef.current = actionMode;
|
prevActionModeRef.current = actionMode;
|
||||||
internalTextareaRef.current?.focus();
|
focusComposerTextarea();
|
||||||
}
|
}
|
||||||
}, [actionMode]);
|
}, [actionMode, focusComposerTextarea]);
|
||||||
|
|
||||||
// Auto-select delegate when lead recipient is chosen by the user.
|
// Auto-select delegate when lead recipient is chosen by the user.
|
||||||
// Wait until draft is restored from IndexedDB (draftLoaded) before running,
|
// Wait until draft is restored from IndexedDB (draftLoaded) before running,
|
||||||
|
|
@ -358,7 +367,7 @@ export const MessageComposer = ({
|
||||||
trimmed.length > 0 &&
|
trimmed.length > 0 &&
|
||||||
trimmed.length <= MAX_TEXT_LENGTH &&
|
trimmed.length <= MAX_TEXT_LENGTH &&
|
||||||
!sending &&
|
!sending &&
|
||||||
!isProvisioning &&
|
!isLaunchBlocking &&
|
||||||
!attachmentsBlocked &&
|
!attachmentsBlocked &&
|
||||||
!slashCommandRestrictionReason &&
|
!slashCommandRestrictionReason &&
|
||||||
(!isCrossTeam || onCrossTeamSend !== undefined);
|
(!isCrossTeam || onCrossTeamSend !== undefined);
|
||||||
|
|
@ -404,6 +413,7 @@ export const MessageComposer = ({
|
||||||
taskRefs
|
taskRefs
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
focusComposerTextarea();
|
||||||
}, [
|
}, [
|
||||||
actionMode,
|
actionMode,
|
||||||
canSend,
|
canSend,
|
||||||
|
|
@ -418,6 +428,7 @@ export const MessageComposer = ({
|
||||||
draft.chips,
|
draft.chips,
|
||||||
draft.text,
|
draft.text,
|
||||||
lastResult,
|
lastResult,
|
||||||
|
focusComposerTextarea,
|
||||||
taskSuggestions,
|
taskSuggestions,
|
||||||
teamName,
|
teamName,
|
||||||
]);
|
]);
|
||||||
|
|
@ -642,7 +653,7 @@ export const MessageComposer = ({
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
<div className="ml-auto flex shrink-0 items-center gap-2">
|
<div className="ml-auto flex shrink-0 items-center gap-2">
|
||||||
{!isTeamAlive && !isProvisioning && (
|
{!isTeamAlive && !isLaunchBlocking && (
|
||||||
<span className="text-[10px]" style={{ color: 'var(--warning-text)' }}>
|
<span className="text-[10px]" style={{ color: 'var(--warning-text)' }}>
|
||||||
Team offline
|
Team offline
|
||||||
</span>
|
</span>
|
||||||
|
|
@ -717,6 +728,7 @@ export const MessageComposer = ({
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setSelectedTeam(null);
|
setSelectedTeam(null);
|
||||||
setTeamSelectorOpen(false);
|
setTeamSelectorOpen(false);
|
||||||
|
focusComposerTextarea();
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{currentTeamColor ? (
|
{currentTeamColor ? (
|
||||||
|
|
@ -752,6 +764,7 @@ export const MessageComposer = ({
|
||||||
setSelectedTeam(target.teamName);
|
setSelectedTeam(target.teamName);
|
||||||
setRecipient('team-lead');
|
setRecipient('team-lead');
|
||||||
setTeamSelectorOpen(false);
|
setTeamSelectorOpen(false);
|
||||||
|
focusComposerTextarea();
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<span
|
<span
|
||||||
|
|
@ -894,6 +907,7 @@ export const MessageComposer = ({
|
||||||
setRecipient(m.name);
|
setRecipient(m.name);
|
||||||
setRecipientOpen(false);
|
setRecipientOpen(false);
|
||||||
setRecipientSearch('');
|
setRecipientSearch('');
|
||||||
|
focusComposerTextarea();
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<MemberBadge
|
<MemberBadge
|
||||||
|
|
@ -944,7 +958,7 @@ export const MessageComposer = ({
|
||||||
ref={textareaRef}
|
ref={textareaRef}
|
||||||
id={`compose-${teamName}`}
|
id={`compose-${teamName}`}
|
||||||
placeholder={
|
placeholder={
|
||||||
isProvisioning
|
isLaunchBlocking
|
||||||
? 'Team is launching... message will be queued for inbox delivery.'
|
? 'Team is launching... message will be queued for inbox delivery.'
|
||||||
: isCrossTeam
|
: isCrossTeam
|
||||||
? `Cross-team message to ${targetDisplayName ?? 'team'}...`
|
? `Cross-team message to ${targetDisplayName ?? 'team'}...`
|
||||||
|
|
@ -1013,7 +1027,7 @@ export const MessageComposer = ({
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
{slashCommandRestrictionReason ? (
|
{slashCommandRestrictionReason ? (
|
||||||
<TooltipContent side="top">{slashCommandRestrictionReason}</TooltipContent>
|
<TooltipContent side="top">{slashCommandRestrictionReason}</TooltipContent>
|
||||||
) : isProvisioning && !sending ? (
|
) : isLaunchBlocking && !sending ? (
|
||||||
<TooltipContent side="top">
|
<TooltipContent side="top">
|
||||||
Sending unavailable while team is launching
|
Sending unavailable while team is launching
|
||||||
</TooltipContent>
|
</TooltipContent>
|
||||||
|
|
|
||||||
|
|
@ -1774,6 +1774,34 @@ export function initializeNotificationListeners(): () => void {
|
||||||
|
|
||||||
if (event.type === 'inbox') {
|
if (event.type === 'inbox') {
|
||||||
scheduleTrackedTeamMessageRefresh(event.teamName, 'event:inbox');
|
scheduleTrackedTeamMessageRefresh(event.teamName, 'event:inbox');
|
||||||
|
if (!event?.teamName || !isTeamVisibleInAnyPane(event.teamName)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const existingDetailTimer = teamRefreshTimers.get(event.teamName);
|
||||||
|
if (existingDetailTimer) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const eventReason = `${buildTeamChangeFanoutReason(event.type)}:structural-safety`;
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
teamRefreshTimers.delete(event.teamName);
|
||||||
|
const current = useStore.getState();
|
||||||
|
if (!isTeamVisibleInAnyPane(event.teamName)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
noteTeamRefreshFanout({
|
||||||
|
teamName: event.teamName,
|
||||||
|
surface: 'team-change-listener',
|
||||||
|
phase: 'executed',
|
||||||
|
reason: eventReason,
|
||||||
|
operation: 'refreshTeamData',
|
||||||
|
eventType: event.type,
|
||||||
|
selected: current.selectedTeamName === event.teamName,
|
||||||
|
visible: true,
|
||||||
|
activeTab: getFocusedVisibleTeamName() === event.teamName,
|
||||||
|
});
|
||||||
|
void current.refreshTeamData(event.teamName, { withDedup: true });
|
||||||
|
}, TEAM_REFRESH_THROTTLE_MS);
|
||||||
|
teamRefreshTimers.set(event.teamName, timer);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
93
src/shared/utils/taskWorkDuration.ts
Normal file
93
src/shared/utils/taskWorkDuration.ts
Normal file
|
|
@ -0,0 +1,93 @@
|
||||||
|
interface TaskWorkDurationIntervalLike {
|
||||||
|
startedAt?: string | null;
|
||||||
|
completedAt?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TaskWorkDurationLike<
|
||||||
|
TInterval extends TaskWorkDurationIntervalLike = TaskWorkDurationIntervalLike,
|
||||||
|
> {
|
||||||
|
status?: string | null;
|
||||||
|
workIntervals?: TInterval[] | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TaskImplementationDuration {
|
||||||
|
elapsedMs: number;
|
||||||
|
hasRunningInterval: boolean;
|
||||||
|
countedIntervalCount: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseIsoMs(value: string | null | undefined): number {
|
||||||
|
if (!value) return 0;
|
||||||
|
const parsed = Date.parse(value);
|
||||||
|
return Number.isFinite(parsed) ? parsed : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function calculateTaskImplementationDuration<TInterval extends TaskWorkDurationIntervalLike>(
|
||||||
|
task: TaskWorkDurationLike<TInterval> | null | undefined,
|
||||||
|
nowMs = Date.now()
|
||||||
|
): TaskImplementationDuration {
|
||||||
|
if (!task || !Array.isArray(task.workIntervals)) {
|
||||||
|
return { elapsedMs: 0, hasRunningInterval: false, countedIntervalCount: 0 };
|
||||||
|
}
|
||||||
|
|
||||||
|
const windows: { startMs: number; endMs: number }[] = [];
|
||||||
|
let hasRunningInterval = false;
|
||||||
|
|
||||||
|
for (const interval of task.workIntervals) {
|
||||||
|
const startMs = parseIsoMs(interval?.startedAt);
|
||||||
|
if (startMs <= 0) continue;
|
||||||
|
|
||||||
|
const completedAtMs = parseIsoMs(interval?.completedAt);
|
||||||
|
if (completedAtMs > startMs) {
|
||||||
|
windows.push({ startMs, endMs: completedAtMs });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!interval?.completedAt && task.status === 'in_progress' && nowMs > startMs) {
|
||||||
|
windows.push({ startMs, endMs: nowMs });
|
||||||
|
hasRunningInterval = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (windows.length === 0) {
|
||||||
|
return { elapsedMs: 0, hasRunningInterval, countedIntervalCount: 0 };
|
||||||
|
}
|
||||||
|
|
||||||
|
windows.sort((left, right) => left.startMs - right.startMs);
|
||||||
|
|
||||||
|
const merged: { startMs: number; endMs: number }[] = [];
|
||||||
|
for (const window of windows) {
|
||||||
|
const previous = merged[merged.length - 1];
|
||||||
|
if (previous && window.startMs <= previous.endMs) {
|
||||||
|
previous.endMs = Math.max(previous.endMs, window.endMs);
|
||||||
|
} else {
|
||||||
|
merged.push({ ...window });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const elapsedMs = merged.reduce((sum, window) => sum + (window.endMs - window.startMs), 0);
|
||||||
|
return { elapsedMs, hasRunningInterval, countedIntervalCount: windows.length };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function shouldShowTaskImplementationDuration(
|
||||||
|
duration: TaskImplementationDuration
|
||||||
|
): boolean {
|
||||||
|
return duration.elapsedMs > 0 || duration.hasRunningInterval;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatTaskImplementationDuration(elapsedMs: number): string {
|
||||||
|
const totalSeconds = Math.max(0, Math.floor(elapsedMs / 1000));
|
||||||
|
if (totalSeconds < 60) {
|
||||||
|
return `${totalSeconds}s`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const totalMinutes = Math.floor(totalSeconds / 60);
|
||||||
|
const seconds = totalSeconds % 60;
|
||||||
|
if (totalMinutes < 60) {
|
||||||
|
return `${totalMinutes}m ${String(seconds).padStart(2, '0')}s`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const hours = Math.floor(totalMinutes / 60);
|
||||||
|
const minutes = totalMinutes % 60;
|
||||||
|
return `${hours}h ${String(minutes).padStart(2, '0')}m`;
|
||||||
|
}
|
||||||
|
|
@ -5,7 +5,10 @@ import * as path from 'path';
|
||||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
import { TeamConfigReader } from '../../../../src/main/services/team/TeamConfigReader';
|
import { TeamConfigReader } from '../../../../src/main/services/team/TeamConfigReader';
|
||||||
import { TeamProvisioningService } from '../../../../src/main/services/team/TeamProvisioningService';
|
import {
|
||||||
|
getMixedLaunchFallbackRecoveryError,
|
||||||
|
TeamProvisioningService,
|
||||||
|
} from '../../../../src/main/services/team/TeamProvisioningService';
|
||||||
import type {
|
import type {
|
||||||
OpenCodeTeamRuntimeMessageInput,
|
OpenCodeTeamRuntimeMessageInput,
|
||||||
OpenCodeTeamRuntimeMessageResult,
|
OpenCodeTeamRuntimeMessageResult,
|
||||||
|
|
@ -233,6 +236,71 @@ describe('Team agent launch matrix safe e2e', () => {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('materializes members metadata before relaunching a legacy pure team config', async () => {
|
||||||
|
const teamName = 'legacy-pure-config-repair-safe-e2e';
|
||||||
|
await writePureAnthropicTeamConfigWithMembers({
|
||||||
|
teamName,
|
||||||
|
projectPath,
|
||||||
|
members: ['alice', 'bob'],
|
||||||
|
});
|
||||||
|
const svc = new TeamProvisioningService();
|
||||||
|
vi.spyOn(svc as any, 'normalizeTeamConfigForLaunch').mockImplementation(async () => {
|
||||||
|
throw new Error('stop after compatibility repair');
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
svc.launchTeam(
|
||||||
|
{
|
||||||
|
teamName,
|
||||||
|
cwd: projectPath,
|
||||||
|
providerId: 'anthropic',
|
||||||
|
model: 'sonnet',
|
||||||
|
skipPermissions: true,
|
||||||
|
},
|
||||||
|
() => undefined
|
||||||
|
)
|
||||||
|
).rejects.toThrow('stop after compatibility repair');
|
||||||
|
|
||||||
|
const membersMeta = JSON.parse(
|
||||||
|
await fs.readFile(path.join(getTeamsBasePath(), teamName, 'members.meta.json'), 'utf8')
|
||||||
|
) as { members: Array<{ name: string; providerId?: string; model?: string }> };
|
||||||
|
expect(membersMeta.members).toEqual(
|
||||||
|
expect.arrayContaining([
|
||||||
|
expect.objectContaining({ name: 'alice', providerId: 'anthropic', model: 'haiku' }),
|
||||||
|
expect.objectContaining({ name: 'bob', providerId: 'anthropic', model: 'sonnet' }),
|
||||||
|
])
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('fails unsafe old mixed OpenCode config without launch-state or members metadata mutation', async () => {
|
||||||
|
const teamName = 'legacy-mixed-config-unsafe-safe-e2e';
|
||||||
|
await writeMixedTeamConfigWithoutOpenCodeProviderMetadata({ teamName, projectPath });
|
||||||
|
const svc = new TeamProvisioningService();
|
||||||
|
const normalizeSpy = vi.spyOn(svc as any, 'normalizeTeamConfigForLaunch');
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
svc.launchTeam(
|
||||||
|
{
|
||||||
|
teamName,
|
||||||
|
cwd: projectPath,
|
||||||
|
providerId: 'codex',
|
||||||
|
providerBackendId: 'codex-native',
|
||||||
|
model: 'gpt-5.4',
|
||||||
|
skipPermissions: true,
|
||||||
|
},
|
||||||
|
() => undefined
|
||||||
|
)
|
||||||
|
).rejects.toThrow(getMixedLaunchFallbackRecoveryError());
|
||||||
|
|
||||||
|
expect(normalizeSpy).not.toHaveBeenCalled();
|
||||||
|
await expect(
|
||||||
|
fs.readFile(path.join(getTeamsBasePath(), teamName, 'members.meta.json'), 'utf8')
|
||||||
|
).rejects.toThrow();
|
||||||
|
await expect(
|
||||||
|
fs.readFile(path.join(getTeamsBasePath(), teamName, 'launch-state.json'), 'utf8')
|
||||||
|
).rejects.toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
it('keeps permission-pending OpenCode members pending instead of reading the team as fully ready', async () => {
|
it('keeps permission-pending OpenCode members pending instead of reading the team as fully ready', async () => {
|
||||||
const adapter = new FakeOpenCodeRuntimeAdapter('partial_pending');
|
const adapter = new FakeOpenCodeRuntimeAdapter('partial_pending');
|
||||||
const svc = new TeamProvisioningService();
|
const svc = new TeamProvisioningService();
|
||||||
|
|
|
||||||
|
|
@ -562,6 +562,127 @@ describe('TeamMemberRuntimeAdvisoryService', () => {
|
||||||
expect(advisory).toBeNull();
|
expect(advisory).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('suppresses stale OpenCode proof advisories after same-task member progress exists', async () => {
|
||||||
|
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'claude-team-advisory-'));
|
||||||
|
setClaudeBasePathOverride(tmpDir);
|
||||||
|
|
||||||
|
const teamName = 'mission-control';
|
||||||
|
const laneId = 'secondary:opencode:bob';
|
||||||
|
const taskId = '10d1c1b5-e8be-4dc9-a500-a7e2bc619c9e';
|
||||||
|
const laneDir = path.join(
|
||||||
|
tmpDir,
|
||||||
|
'teams',
|
||||||
|
teamName,
|
||||||
|
'.opencode-runtime',
|
||||||
|
'lanes',
|
||||||
|
encodeURIComponent(laneId)
|
||||||
|
);
|
||||||
|
await fs.mkdir(laneDir, { recursive: true });
|
||||||
|
await fs.mkdir(path.join(tmpDir, 'tasks', teamName), { recursive: true });
|
||||||
|
await fs.writeFile(
|
||||||
|
path.join(tmpDir, 'teams', teamName, '.opencode-runtime', 'lanes.json'),
|
||||||
|
JSON.stringify({
|
||||||
|
version: 1,
|
||||||
|
updatedAt: '2026-05-08T06:37:47.470Z',
|
||||||
|
lanes: {
|
||||||
|
[laneId]: { laneId, state: 'active', updatedAt: '2026-05-08T06:37:47.470Z' },
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
'utf8'
|
||||||
|
);
|
||||||
|
await fs.writeFile(
|
||||||
|
path.join(laneDir, 'opencode-prompt-delivery-ledger.json'),
|
||||||
|
JSON.stringify({
|
||||||
|
schemaVersion: 1,
|
||||||
|
updatedAt: '2026-05-08T06:37:47.470Z',
|
||||||
|
data: [
|
||||||
|
{
|
||||||
|
id: 'opencode-prompt:task-progress-missing',
|
||||||
|
teamName,
|
||||||
|
memberName: 'bob',
|
||||||
|
laneId,
|
||||||
|
runId: 'run-1',
|
||||||
|
runtimeSessionId: 'ses-1',
|
||||||
|
inboxMessageId: 'task-assignment-1',
|
||||||
|
inboxTimestamp: '2026-05-08T06:36:00.000Z',
|
||||||
|
source: 'watcher',
|
||||||
|
messageKind: null,
|
||||||
|
replyRecipient: 'team-lead',
|
||||||
|
actionMode: null,
|
||||||
|
taskRefs: [{ taskId, displayId: '10d1c1b5', teamName }],
|
||||||
|
payloadHash: 'sha256:test',
|
||||||
|
status: 'failed_terminal',
|
||||||
|
responseState: 'empty_assistant_turn',
|
||||||
|
attempts: 3,
|
||||||
|
maxAttempts: 3,
|
||||||
|
acceptanceUnknown: false,
|
||||||
|
nextAttemptAt: null,
|
||||||
|
lastAttemptAt: '2026-05-08T06:37:30.000Z',
|
||||||
|
lastObservedAt: '2026-05-08T06:37:33.167Z',
|
||||||
|
acceptedAt: '2026-05-08T06:36:29.651Z',
|
||||||
|
respondedAt: '2026-05-08T06:37:33.167Z',
|
||||||
|
failedAt: '2026-05-08T06:37:47.470Z',
|
||||||
|
inboxReadCommittedAt: null,
|
||||||
|
inboxReadCommitError: null,
|
||||||
|
prePromptCursor: null,
|
||||||
|
postPromptCursor: null,
|
||||||
|
deliveredUserMessageId: 'delivered-1',
|
||||||
|
observedAssistantMessageId: 'assistant-1',
|
||||||
|
observedAssistantPreview: null,
|
||||||
|
observedToolCallNames: [],
|
||||||
|
observedVisibleMessageId: null,
|
||||||
|
visibleReplyMessageId: null,
|
||||||
|
visibleReplyInbox: null,
|
||||||
|
visibleReplyCorrelation: null,
|
||||||
|
lastReason: 'empty_assistant_turn',
|
||||||
|
diagnostics: ['empty_assistant_turn'],
|
||||||
|
createdAt: '2026-05-08T06:36:00.000Z',
|
||||||
|
updatedAt: '2026-05-08T06:37:47.470Z',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
'utf8'
|
||||||
|
);
|
||||||
|
await fs.writeFile(
|
||||||
|
path.join(tmpDir, 'tasks', teamName, `${taskId}.json`),
|
||||||
|
JSON.stringify({
|
||||||
|
id: taskId,
|
||||||
|
displayId: '10d1c1b5',
|
||||||
|
subject: 'Keyboard shortcuts',
|
||||||
|
owner: 'bob',
|
||||||
|
status: 'completed',
|
||||||
|
updatedAt: '2026-05-08T06:40:55.128Z',
|
||||||
|
comments: [
|
||||||
|
{
|
||||||
|
id: 'progress-comment-1',
|
||||||
|
author: 'bob',
|
||||||
|
text: 'Keyboard shortcuts implemented and verified.',
|
||||||
|
createdAt: '2026-05-08T06:39:40.805Z',
|
||||||
|
type: 'regular',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
historyEvents: [
|
||||||
|
{
|
||||||
|
id: 'status-event-1',
|
||||||
|
type: 'status_changed',
|
||||||
|
from: 'in_progress',
|
||||||
|
to: 'completed',
|
||||||
|
actor: 'bob',
|
||||||
|
timestamp: '2026-05-08T06:40:55.128Z',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
'utf8'
|
||||||
|
);
|
||||||
|
|
||||||
|
const service = new TeamMemberRuntimeAdvisoryService({
|
||||||
|
findMemberLogs: vi.fn(async () => []),
|
||||||
|
});
|
||||||
|
const advisory = await service.getMemberAdvisory(teamName, 'bob');
|
||||||
|
|
||||||
|
expect(advisory).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
it('ignores expired retry advisories', async () => {
|
it('ignores expired retry advisories', async () => {
|
||||||
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'claude-team-advisory-'));
|
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'claude-team-advisory-'));
|
||||||
setClaudeBasePathOverride(tmpDir);
|
setClaudeBasePathOverride(tmpDir);
|
||||||
|
|
|
||||||
|
|
@ -122,7 +122,10 @@ vi.mock('@main/utils/pathDecoder', async (importOriginal) => {
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
import { TeamProvisioningService } from '@main/services/team/TeamProvisioningService';
|
import {
|
||||||
|
getMixedLaunchFallbackRecoveryError,
|
||||||
|
TeamProvisioningService,
|
||||||
|
} from '@main/services/team/TeamProvisioningService';
|
||||||
import {
|
import {
|
||||||
clearAutoResumeService,
|
clearAutoResumeService,
|
||||||
getAutoResumeService,
|
getAutoResumeService,
|
||||||
|
|
@ -11050,6 +11053,91 @@ describe('TeamProvisioningService', () => {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
it('materializes members.meta.json before config normalization for a repairable legacy launch', async () => {
|
||||||
|
allowConsoleLogs();
|
||||||
|
const teamName = 'legacy-pure-launch-repair';
|
||||||
|
const leadSessionId = 'legacy-pure-launch-session';
|
||||||
|
writeLaunchConfig(teamName, tempClaudeRoot, leadSessionId, ['alice', 'bob']);
|
||||||
|
vi.mocked(ClaudeBinaryResolver.resolve).mockResolvedValue('/mock/claude');
|
||||||
|
vi.mocked(spawnCli).mockReturnValue(createRunningChild() as any);
|
||||||
|
|
||||||
|
const { svc, membersMetaStore } = createSafeLaunchService();
|
||||||
|
const normalizeSpy = vi.spyOn(svc as any, 'normalizeTeamConfigForLaunch');
|
||||||
|
|
||||||
|
const { runId } = await svc.launchTeam(
|
||||||
|
{
|
||||||
|
teamName,
|
||||||
|
cwd: tempClaudeRoot,
|
||||||
|
providerId: 'codex',
|
||||||
|
providerBackendId: 'codex-native',
|
||||||
|
model: 'gpt-5.4',
|
||||||
|
},
|
||||||
|
() => {}
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(membersMetaStore.writeMembers).toHaveBeenCalledWith(
|
||||||
|
teamName,
|
||||||
|
expect.arrayContaining([
|
||||||
|
expect.objectContaining({ name: 'alice' }),
|
||||||
|
expect.objectContaining({ name: 'bob' }),
|
||||||
|
]),
|
||||||
|
expect.objectContaining({ providerBackendId: 'codex-native' })
|
||||||
|
);
|
||||||
|
expect(membersMetaStore.writeMembers.mock.invocationCallOrder[0]).toBeLessThan(
|
||||||
|
normalizeSpy.mock.invocationCallOrder[0]
|
||||||
|
);
|
||||||
|
|
||||||
|
await svc.cancelProvisioning(runId);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('blocks unsafe old mixed OpenCode launches before config normalization or launch-state cleanup', async () => {
|
||||||
|
allowConsoleLogs();
|
||||||
|
const teamName = 'legacy-mixed-unsafe-launch';
|
||||||
|
const teamDir = path.join(tempTeamsBase, teamName);
|
||||||
|
fs.mkdirSync(teamDir, { recursive: true });
|
||||||
|
fs.writeFileSync(
|
||||||
|
path.join(teamDir, 'config.json'),
|
||||||
|
`${JSON.stringify(
|
||||||
|
{
|
||||||
|
name: teamName,
|
||||||
|
projectPath: tempClaudeRoot,
|
||||||
|
leadSessionId: 'legacy-mixed-unsafe-session',
|
||||||
|
members: [
|
||||||
|
{ name: 'team-lead', agentType: 'team-lead', providerId: 'codex' },
|
||||||
|
{ name: 'jack', role: 'Developer', providerId: 'opencode' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
null,
|
||||||
|
2
|
||||||
|
)}\n`,
|
||||||
|
'utf8'
|
||||||
|
);
|
||||||
|
vi.mocked(ClaudeBinaryResolver.resolve).mockResolvedValue('/mock/claude');
|
||||||
|
vi.mocked(spawnCli).mockReturnValue(createRunningChild() as any);
|
||||||
|
|
||||||
|
const { svc, membersMetaStore } = createSafeLaunchService();
|
||||||
|
const normalizeSpy = vi.spyOn(svc as any, 'normalizeTeamConfigForLaunch');
|
||||||
|
const clearLaunchStateSpy = vi.spyOn(svc as any, 'clearPersistedLaunchState');
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
svc.launchTeam(
|
||||||
|
{
|
||||||
|
teamName,
|
||||||
|
cwd: tempClaudeRoot,
|
||||||
|
providerId: 'codex',
|
||||||
|
providerBackendId: 'codex-native',
|
||||||
|
model: 'gpt-5.4',
|
||||||
|
},
|
||||||
|
() => {}
|
||||||
|
)
|
||||||
|
).rejects.toThrow(getMixedLaunchFallbackRecoveryError());
|
||||||
|
|
||||||
|
expect(membersMetaStore.writeMembers).not.toHaveBeenCalled();
|
||||||
|
expect(normalizeSpy).not.toHaveBeenCalled();
|
||||||
|
expect(clearLaunchStateSpy).not.toHaveBeenCalled();
|
||||||
|
expect(spawnCli).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
it('invalidates config cache after writing OpenCode team config', async () => {
|
it('invalidates config cache after writing OpenCode team config', async () => {
|
||||||
const teamName = 'opencode-config-cache-prime';
|
const teamName = 'opencode-config-cache-prime';
|
||||||
fs.mkdirSync(path.join(tempTeamsBase, teamName), { recursive: true });
|
fs.mkdirSync(path.join(tempTeamsBase, teamName), { recursive: true });
|
||||||
|
|
|
||||||
|
|
@ -120,6 +120,39 @@ describe('TeamProvisioningService (launch roster discovery)', () => {
|
||||||
expect(result.members.map((m: { name: string }) => m.name)).toEqual(['bob']);
|
expect(result.members.map((m: { name: string }) => m.name)).toEqual(['bob']);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('marks pure Claude/Codex legacy config without members.meta.json as repairable', 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: 'legacy-pure',
|
||||||
|
members: [
|
||||||
|
{ name: 'alice', role: 'reviewer', provider: 'anthropic', model: 'claude-opus-4-6' },
|
||||||
|
{ name: 'tom', role: 'developer', provider: 'codex', model: 'gpt-5.4' },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
const report = await (svc as unknown as any).probeLaunchCompatibility(
|
||||||
|
'legacy-pure',
|
||||||
|
configRaw,
|
||||||
|
'anthropic'
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(report).toMatchObject({
|
||||||
|
level: 'repairable',
|
||||||
|
rosterSource: 'config',
|
||||||
|
repairAction: 'materialize-members-meta',
|
||||||
|
});
|
||||||
|
expect(report.members.map((member: { name: string }) => member.name)).toEqual([
|
||||||
|
'alice',
|
||||||
|
'tom',
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
it('rejects inbox fallback when it would reconstruct a mixed OpenCode side lane without members.meta truth', async () => {
|
it('rejects inbox fallback when it would reconstruct a mixed OpenCode side lane without members.meta truth', async () => {
|
||||||
const svc = new TeamProvisioningService(
|
const svc = new TeamProvisioningService(
|
||||||
{} as never,
|
{} as never,
|
||||||
|
|
@ -138,7 +171,7 @@ describe('TeamProvisioningService (launch roster discovery)', () => {
|
||||||
).rejects.toThrow(getMixedLaunchFallbackRecoveryError());
|
).rejects.toThrow(getMixedLaunchFallbackRecoveryError());
|
||||||
});
|
});
|
||||||
|
|
||||||
it('rejects config fallback when it would reconstruct a mixed OpenCode side lane without members.meta truth', async () => {
|
it('marks complete mixed OpenCode config fallback as repairable', async () => {
|
||||||
const svc = new TeamProvisioningService(
|
const svc = new TeamProvisioningService(
|
||||||
{} as never,
|
{} as never,
|
||||||
{ listInboxNames: vi.fn(async () => []) } as never,
|
{ listInboxNames: vi.fn(async () => []) } as never,
|
||||||
|
|
@ -151,6 +184,48 @@ describe('TeamProvisioningService (launch roster discovery)', () => {
|
||||||
members: [{ name: 'tom', role: 'developer', provider: 'opencode', model: 'minimax-m2.5-free' }],
|
members: [{ name: 'tom', role: 'developer', provider: 'opencode', model: 'minimax-m2.5-free' }],
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const report = await (svc as unknown as any).probeLaunchCompatibility('t', configRaw, 'codex');
|
||||||
|
expect(report).toMatchObject({
|
||||||
|
level: 'repairable',
|
||||||
|
rosterSource: 'config',
|
||||||
|
repairAction: 'materialize-members-meta',
|
||||||
|
});
|
||||||
|
expect(report.members).toMatchObject([
|
||||||
|
{ name: 'tom', role: 'developer', providerId: 'opencode', model: 'minimax-m2.5-free' },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects mixed OpenCode config fallback when the side lane is missing an explicit model', 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' }],
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
(svc as unknown as any).resolveLaunchExpectedMembers('t', configRaw, 'codex')
|
||||||
|
).rejects.toThrow(getMixedLaunchFallbackRecoveryError());
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects config fallback when an OpenCode-looking model is missing an explicit provider', 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', model: 'opencode/minimax-m2.5-free' }],
|
||||||
|
});
|
||||||
|
|
||||||
await expect(
|
await expect(
|
||||||
(svc as unknown as any).resolveLaunchExpectedMembers('t', configRaw, 'codex')
|
(svc as unknown as any).resolveLaunchExpectedMembers('t', configRaw, 'codex')
|
||||||
).rejects.toThrow(getMixedLaunchFallbackRecoveryError());
|
).rejects.toThrow(getMixedLaunchFallbackRecoveryError());
|
||||||
|
|
|
||||||
|
|
@ -108,7 +108,8 @@ vi.mock('@renderer/components/ui/tooltip', () => ({
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock('@renderer/components/ui/badge', () => ({
|
vi.mock('@renderer/components/ui/badge', () => ({
|
||||||
Badge: ({ children }: { children: React.ReactNode }) => React.createElement('span', null, children),
|
Badge: ({ children }: { children: React.ReactNode }) =>
|
||||||
|
React.createElement('span', null, children),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock('@renderer/components/ui/button', () => ({
|
vi.mock('@renderer/components/ui/button', () => ({
|
||||||
|
|
@ -263,6 +264,36 @@ describe('TaskDetailDialog changes summary loading', () => {
|
||||||
vi.useRealTimers();
|
vi.useRealTimers();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('shows a zero attachments count in the attachments section header', 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(TaskDetailDialog, {
|
||||||
|
open: true,
|
||||||
|
variant: 'team',
|
||||||
|
teamName: 'team-a',
|
||||||
|
task: { ...makeTask('task-empty-attachments'), workIntervals: [] },
|
||||||
|
taskMap: new Map<string, TeamTaskWithKanban>(),
|
||||||
|
members: [],
|
||||||
|
onClose: vi.fn(),
|
||||||
|
onViewChanges: vi.fn(),
|
||||||
|
})
|
||||||
|
);
|
||||||
|
await Promise.resolve();
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(host.querySelector('[data-testid="section-badge-Attachments"]')?.textContent).toBe('0');
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
root.unmount();
|
||||||
|
await Promise.resolve();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it('does not drop a new task changes request while another task summary is still in flight', async () => {
|
it('does not drop a new task changes request while another task summary is still in flight', async () => {
|
||||||
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
|
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
|
||||||
const first = deferred<TaskChangeSetV2>();
|
const first = deferred<TaskChangeSetV2>();
|
||||||
|
|
@ -529,4 +560,56 @@ describe('TaskDetailDialog changes summary loading', () => {
|
||||||
await Promise.resolve();
|
await Promise.resolve();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('shows total implementation time in the workflow history header', async () => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
vi.setSystemTime(new Date('2026-04-20T10:07:30.000Z'));
|
||||||
|
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
|
||||||
|
|
||||||
|
const task: TeamTaskWithKanban = {
|
||||||
|
...makeTask('task-duration'),
|
||||||
|
workIntervals: [
|
||||||
|
{
|
||||||
|
startedAt: '2026-04-20T10:00:00.000Z',
|
||||||
|
completedAt: '2026-04-20T10:02:30.000Z',
|
||||||
|
},
|
||||||
|
{ startedAt: '2026-04-20T10:05:00.000Z' },
|
||||||
|
],
|
||||||
|
historyEvents: [
|
||||||
|
{
|
||||||
|
id: 'event-created',
|
||||||
|
timestamp: '2026-04-20T10:00:00.000Z',
|
||||||
|
type: 'task_created',
|
||||||
|
status: 'in_progress',
|
||||||
|
actor: 'lead',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
const host = document.createElement('div');
|
||||||
|
document.body.appendChild(host);
|
||||||
|
const root = createRoot(host);
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
root.render(
|
||||||
|
React.createElement(TaskDetailDialog, {
|
||||||
|
open: true,
|
||||||
|
variant: 'team',
|
||||||
|
teamName: 'team-a',
|
||||||
|
task,
|
||||||
|
taskMap: new Map<string, TeamTaskWithKanban>(),
|
||||||
|
members: [],
|
||||||
|
onClose: vi.fn(),
|
||||||
|
})
|
||||||
|
);
|
||||||
|
await Promise.resolve();
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(host.textContent).toContain('Workflow History');
|
||||||
|
expect(host.textContent).toContain('Work time 5m 00s');
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
root.unmount();
|
||||||
|
await Promise.resolve();
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
82
test/shared/utils/taskWorkDuration.test.ts
Normal file
82
test/shared/utils/taskWorkDuration.test.ts
Normal file
|
|
@ -0,0 +1,82 @@
|
||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
|
import {
|
||||||
|
calculateTaskImplementationDuration,
|
||||||
|
formatTaskImplementationDuration,
|
||||||
|
shouldShowTaskImplementationDuration,
|
||||||
|
} from '@shared/utils/taskWorkDuration';
|
||||||
|
|
||||||
|
describe('taskWorkDuration', () => {
|
||||||
|
it('sums completed intervals and the current in-progress interval', () => {
|
||||||
|
const duration = calculateTaskImplementationDuration(
|
||||||
|
{
|
||||||
|
status: 'in_progress',
|
||||||
|
workIntervals: [
|
||||||
|
{
|
||||||
|
startedAt: '2026-05-08T10:00:00.000Z',
|
||||||
|
completedAt: '2026-05-08T10:02:30.000Z',
|
||||||
|
},
|
||||||
|
{ startedAt: '2026-05-08T10:05:00.000Z' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
Date.parse('2026-05-08T10:07:00.000Z')
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(duration).toEqual({
|
||||||
|
elapsedMs: 270_000,
|
||||||
|
hasRunningInterval: true,
|
||||||
|
countedIntervalCount: 2,
|
||||||
|
});
|
||||||
|
expect(shouldShowTaskImplementationDuration(duration)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not keep an open interval running after the task leaves in progress', () => {
|
||||||
|
const duration = calculateTaskImplementationDuration(
|
||||||
|
{
|
||||||
|
status: 'completed',
|
||||||
|
workIntervals: [
|
||||||
|
{
|
||||||
|
startedAt: '2026-05-08T10:00:00.000Z',
|
||||||
|
completedAt: '2026-05-08T10:02:00.000Z',
|
||||||
|
},
|
||||||
|
{ startedAt: '2026-05-08T10:05:00.000Z' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
Date.parse('2026-05-08T10:30:00.000Z')
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(duration).toEqual({
|
||||||
|
elapsedMs: 120_000,
|
||||||
|
hasRunningInterval: false,
|
||||||
|
countedIntervalCount: 1,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('merges overlapping intervals to avoid double counting malformed data', () => {
|
||||||
|
const duration = calculateTaskImplementationDuration(
|
||||||
|
{
|
||||||
|
status: 'completed',
|
||||||
|
workIntervals: [
|
||||||
|
{
|
||||||
|
startedAt: '2026-05-08T10:00:00.000Z',
|
||||||
|
completedAt: '2026-05-08T10:10:00.000Z',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
startedAt: '2026-05-08T10:05:00.000Z',
|
||||||
|
completedAt: '2026-05-08T10:12:00.000Z',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
Date.parse('2026-05-08T10:30:00.000Z')
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(duration.elapsedMs).toBe(720_000);
|
||||||
|
expect(duration.countedIntervalCount).toBe(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('formats seconds, minutes, and hours for compact UI labels', () => {
|
||||||
|
expect(formatTaskImplementationDuration(42_900)).toBe('42s');
|
||||||
|
expect(formatTaskImplementationDuration(65_000)).toBe('1m 05s');
|
||||||
|
expect(formatTaskImplementationDuration(7_260_000)).toBe('2h 01m');
|
||||||
|
});
|
||||||
|
});
|
||||||
Loading…
Reference in a new issue