feat(team): add worktree readiness checks
This commit is contained in:
parent
ea25e6ba58
commit
9fe9f81046
25 changed files with 1258 additions and 92 deletions
|
|
@ -11,6 +11,7 @@ import {
|
||||||
TEAM_ALIVE_LIST,
|
TEAM_ALIVE_LIST,
|
||||||
TEAM_CANCEL_PROVISIONING,
|
TEAM_CANCEL_PROVISIONING,
|
||||||
TEAM_CREATE,
|
TEAM_CREATE,
|
||||||
|
TEAM_CREATE_INITIAL_GIT_COMMIT,
|
||||||
TEAM_CREATE_CONFIG,
|
TEAM_CREATE_CONFIG,
|
||||||
TEAM_CREATE_TASK,
|
TEAM_CREATE_TASK,
|
||||||
TEAM_DELETE_DRAFT,
|
TEAM_DELETE_DRAFT,
|
||||||
|
|
@ -37,6 +38,8 @@ import {
|
||||||
TEAM_GET_TASK_EXACT_LOG_SUMMARIES,
|
TEAM_GET_TASK_EXACT_LOG_SUMMARIES,
|
||||||
TEAM_GET_TASK_LOG_STREAM,
|
TEAM_GET_TASK_LOG_STREAM,
|
||||||
TEAM_GET_TASK_LOG_STREAM_SUMMARY,
|
TEAM_GET_TASK_LOG_STREAM_SUMMARY,
|
||||||
|
TEAM_GET_WORKTREE_GIT_STATUS,
|
||||||
|
TEAM_INITIALIZE_GIT_REPOSITORY,
|
||||||
TEAM_KILL_PROCESS,
|
TEAM_KILL_PROCESS,
|
||||||
TEAM_LAUNCH,
|
TEAM_LAUNCH,
|
||||||
TEAM_LEAD_ACTIVITY,
|
TEAM_LEAD_ACTIVITY,
|
||||||
|
|
@ -130,6 +133,7 @@ import { TeamMembersMetaStore } from '../services/team/TeamMembersMetaStore';
|
||||||
import { TeamMetaStore } from '../services/team/TeamMetaStore';
|
import { TeamMetaStore } from '../services/team/TeamMetaStore';
|
||||||
import { buildAddMemberSpawnMessage } from '../services/team/TeamProvisioningService';
|
import { buildAddMemberSpawnMessage } from '../services/team/TeamProvisioningService';
|
||||||
import { TeamTaskAttachmentStore } from '../services/team/TeamTaskAttachmentStore';
|
import { TeamTaskAttachmentStore } from '../services/team/TeamTaskAttachmentStore';
|
||||||
|
import { TeamWorktreeGitService } from '../services/team/TeamWorktreeGitService';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
validateFromField,
|
validateFromField,
|
||||||
|
|
@ -205,6 +209,7 @@ import type {
|
||||||
TeamTask,
|
TeamTask,
|
||||||
TeamTaskStatus,
|
TeamTaskStatus,
|
||||||
TeamUpdateConfigRequest,
|
TeamUpdateConfigRequest,
|
||||||
|
TeamWorktreeGitStatus,
|
||||||
TeamViewSnapshot,
|
TeamViewSnapshot,
|
||||||
ToolApprovalFileContent,
|
ToolApprovalFileContent,
|
||||||
ToolApprovalSettings,
|
ToolApprovalSettings,
|
||||||
|
|
@ -514,6 +519,7 @@ let boardTaskExactLogDetailService: BoardTaskExactLogDetailService | null = null
|
||||||
const attachmentStore = new TeamAttachmentStore();
|
const attachmentStore = new TeamAttachmentStore();
|
||||||
const taskAttachmentStore = new TeamTaskAttachmentStore();
|
const taskAttachmentStore = new TeamTaskAttachmentStore();
|
||||||
const teamMetaStore = new TeamMetaStore();
|
const teamMetaStore = new TeamMetaStore();
|
||||||
|
const worktreeGitService = new TeamWorktreeGitService();
|
||||||
|
|
||||||
const ALLOWED_ATTACHMENT_TYPES = new Set([
|
const ALLOWED_ATTACHMENT_TYPES = new Set([
|
||||||
'image/png',
|
'image/png',
|
||||||
|
|
@ -525,6 +531,16 @@ const ALLOWED_ATTACHMENT_TYPES = new Set([
|
||||||
]);
|
]);
|
||||||
const MAX_ATTACHMENT_SIZE = 10 * 1024 * 1024; // 10MB per file
|
const MAX_ATTACHMENT_SIZE = 10 * 1024 * 1024; // 10MB per file
|
||||||
|
|
||||||
|
function isValidStoredAttachmentMimeType(value: unknown): value is string {
|
||||||
|
if (typeof value !== 'string') return false;
|
||||||
|
const v = value.trim();
|
||||||
|
if (!v) return false;
|
||||||
|
if (v.length > 200) return false;
|
||||||
|
if (v.includes('\0') || /[\r\n]/.test(v)) return false;
|
||||||
|
const slash = v.indexOf('/');
|
||||||
|
return slash > 0 && slash < v.length - 1;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Prevents GC from collecting Notification objects in the deprecated showTeamNativeNotification.
|
* Prevents GC from collecting Notification objects in the deprecated showTeamNativeNotification.
|
||||||
* @see https://blog.bloomca.me/2025/02/22/electron-mac-notifications.html
|
* @see https://blog.bloomca.me/2025/02/22/electron-mac-notifications.html
|
||||||
|
|
@ -574,6 +590,9 @@ export function registerTeamHandlers(ipcMain: IpcMain): void {
|
||||||
ipcMain.handle(TEAM_SET_TOOL_ACTIVITY_TRACKING, handleSetToolActivityTracking);
|
ipcMain.handle(TEAM_SET_TOOL_ACTIVITY_TRACKING, handleSetToolActivityTracking);
|
||||||
ipcMain.handle(TEAM_GET_CLAUDE_LOGS, handleGetClaudeLogs);
|
ipcMain.handle(TEAM_GET_CLAUDE_LOGS, handleGetClaudeLogs);
|
||||||
ipcMain.handle(TEAM_PREPARE_PROVISIONING, handlePrepareProvisioning);
|
ipcMain.handle(TEAM_PREPARE_PROVISIONING, handlePrepareProvisioning);
|
||||||
|
ipcMain.handle(TEAM_GET_WORKTREE_GIT_STATUS, handleGetWorktreeGitStatus);
|
||||||
|
ipcMain.handle(TEAM_INITIALIZE_GIT_REPOSITORY, handleInitializeGitRepository);
|
||||||
|
ipcMain.handle(TEAM_CREATE_INITIAL_GIT_COMMIT, handleCreateInitialGitCommit);
|
||||||
ipcMain.handle(TEAM_CREATE, handleCreateTeam);
|
ipcMain.handle(TEAM_CREATE, handleCreateTeam);
|
||||||
ipcMain.handle(TEAM_LAUNCH, handleLaunchTeam);
|
ipcMain.handle(TEAM_LAUNCH, handleLaunchTeam);
|
||||||
ipcMain.handle(TEAM_PROVISIONING_STATUS, handleProvisioningStatus);
|
ipcMain.handle(TEAM_PROVISIONING_STATUS, handleProvisioningStatus);
|
||||||
|
|
@ -652,6 +671,9 @@ export function removeTeamHandlers(ipcMain: IpcMain): void {
|
||||||
ipcMain.removeHandler(TEAM_SET_TOOL_ACTIVITY_TRACKING);
|
ipcMain.removeHandler(TEAM_SET_TOOL_ACTIVITY_TRACKING);
|
||||||
ipcMain.removeHandler(TEAM_GET_CLAUDE_LOGS);
|
ipcMain.removeHandler(TEAM_GET_CLAUDE_LOGS);
|
||||||
ipcMain.removeHandler(TEAM_PREPARE_PROVISIONING);
|
ipcMain.removeHandler(TEAM_PREPARE_PROVISIONING);
|
||||||
|
ipcMain.removeHandler(TEAM_GET_WORKTREE_GIT_STATUS);
|
||||||
|
ipcMain.removeHandler(TEAM_INITIALIZE_GIT_REPOSITORY);
|
||||||
|
ipcMain.removeHandler(TEAM_CREATE_INITIAL_GIT_COMMIT);
|
||||||
ipcMain.removeHandler(TEAM_CREATE);
|
ipcMain.removeHandler(TEAM_CREATE);
|
||||||
ipcMain.removeHandler(TEAM_LAUNCH);
|
ipcMain.removeHandler(TEAM_LAUNCH);
|
||||||
ipcMain.removeHandler(TEAM_PROVISIONING_STATUS);
|
ipcMain.removeHandler(TEAM_PROVISIONING_STATUS);
|
||||||
|
|
@ -820,6 +842,54 @@ async function handleGetProjectBranch(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function validateProjectPathInput(
|
||||||
|
projectPath: unknown
|
||||||
|
): { valid: true; value: string } | { valid: false; error: string } {
|
||||||
|
if (typeof projectPath !== 'string' || projectPath.trim().length === 0) {
|
||||||
|
return { valid: false, error: 'projectPath must be a non-empty string' };
|
||||||
|
}
|
||||||
|
return { valid: true, value: path.normalize(projectPath.trim()) };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleGetWorktreeGitStatus(
|
||||||
|
_event: IpcMainInvokeEvent,
|
||||||
|
projectPath: unknown
|
||||||
|
): Promise<IpcResult<TeamWorktreeGitStatus>> {
|
||||||
|
const validated = validateProjectPathInput(projectPath);
|
||||||
|
if (!validated.valid) {
|
||||||
|
return { success: false, error: validated.error };
|
||||||
|
}
|
||||||
|
return wrapTeamHandler('getWorktreeGitStatus', () =>
|
||||||
|
worktreeGitService.getStatus(validated.value)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleInitializeGitRepository(
|
||||||
|
_event: IpcMainInvokeEvent,
|
||||||
|
projectPath: unknown
|
||||||
|
): Promise<IpcResult<TeamWorktreeGitStatus>> {
|
||||||
|
const validated = validateProjectPathInput(projectPath);
|
||||||
|
if (!validated.valid) {
|
||||||
|
return { success: false, error: validated.error };
|
||||||
|
}
|
||||||
|
return wrapTeamHandler('initializeGitRepository', () =>
|
||||||
|
worktreeGitService.initializeRepository(validated.value)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleCreateInitialGitCommit(
|
||||||
|
_event: IpcMainInvokeEvent,
|
||||||
|
projectPath: unknown
|
||||||
|
): Promise<IpcResult<TeamWorktreeGitStatus>> {
|
||||||
|
const validated = validateProjectPathInput(projectPath);
|
||||||
|
if (!validated.valid) {
|
||||||
|
return { success: false, error: validated.error };
|
||||||
|
}
|
||||||
|
return wrapTeamHandler('createInitialGitCommit', () =>
|
||||||
|
worktreeGitService.createInitialCommit(validated.value)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
async function handleListTeams(_event: IpcMainInvokeEvent): Promise<IpcResult<TeamSummary[]>> {
|
async function handleListTeams(_event: IpcMainInvokeEvent): Promise<IpcResult<TeamSummary[]>> {
|
||||||
setCurrentMainOp('team:list');
|
setCurrentMainOp('team:list');
|
||||||
const startedAt = Date.now();
|
const startedAt = Date.now();
|
||||||
|
|
@ -4267,10 +4337,9 @@ async function handleAddTaskComment(
|
||||||
if (
|
if (
|
||||||
typeof a.id !== 'string' ||
|
typeof a.id !== 'string' ||
|
||||||
typeof a.filename !== 'string' ||
|
typeof a.filename !== 'string' ||
|
||||||
typeof a.mimeType !== 'string' ||
|
!isValidStoredAttachmentMimeType(a.mimeType) ||
|
||||||
typeof a.base64Data !== 'string' ||
|
typeof a.base64Data !== 'string' ||
|
||||||
a.base64Data.length === 0 ||
|
a.base64Data.length === 0
|
||||||
!ALLOWED_ATTACHMENT_TYPES.has(a.mimeType)
|
|
||||||
) {
|
) {
|
||||||
throw new Error('Invalid attachment data');
|
throw new Error('Invalid attachment data');
|
||||||
}
|
}
|
||||||
|
|
@ -4283,7 +4352,7 @@ async function handleAddTaskComment(
|
||||||
vTask.value!,
|
vTask.value!,
|
||||||
safeId,
|
safeId,
|
||||||
a.filename,
|
a.filename,
|
||||||
a.mimeType,
|
a.mimeType.trim(),
|
||||||
a.base64Data
|
a.base64Data
|
||||||
);
|
);
|
||||||
savedAttachments.push(meta);
|
savedAttachments.push(meta);
|
||||||
|
|
@ -4386,11 +4455,8 @@ async function handleSaveTaskAttachment(
|
||||||
if (typeof filename !== 'string' || filename.trim().length === 0) {
|
if (typeof filename !== 'string' || filename.trim().length === 0) {
|
||||||
return { success: false, error: 'filename must be a non-empty string' };
|
return { success: false, error: 'filename must be a non-empty string' };
|
||||||
}
|
}
|
||||||
if (typeof mimeType !== 'string' || !ALLOWED_ATTACHMENT_TYPES.has(mimeType)) {
|
if (!isValidStoredAttachmentMimeType(mimeType)) {
|
||||||
return {
|
return { success: false, error: 'Invalid mimeType' };
|
||||||
success: false,
|
|
||||||
error: `mimeType must be one of: ${[...ALLOWED_ATTACHMENT_TYPES].join(', ')}`,
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
if (typeof base64Data !== 'string' || base64Data.length === 0) {
|
if (typeof base64Data !== 'string' || base64Data.length === 0) {
|
||||||
return { success: false, error: 'base64Data must be a non-empty string' };
|
return { success: false, error: 'base64Data must be a non-empty string' };
|
||||||
|
|
@ -4407,7 +4473,7 @@ async function handleSaveTaskAttachment(
|
||||||
vTask.value!,
|
vTask.value!,
|
||||||
safeAttId,
|
safeAttId,
|
||||||
filename,
|
filename,
|
||||||
mimeType,
|
mimeType.trim(),
|
||||||
base64Data
|
base64Data
|
||||||
);
|
);
|
||||||
// Write metadata into the task JSON
|
// Write metadata into the task JSON
|
||||||
|
|
@ -4430,7 +4496,7 @@ async function handleGetTaskAttachment(
|
||||||
if (typeof attachmentId !== 'string' || attachmentId.trim().length === 0) {
|
if (typeof attachmentId !== 'string' || attachmentId.trim().length === 0) {
|
||||||
return { success: false, error: 'attachmentId must be a non-empty string' };
|
return { success: false, error: 'attachmentId must be a non-empty string' };
|
||||||
}
|
}
|
||||||
if (typeof mimeType !== 'string' || !ALLOWED_ATTACHMENT_TYPES.has(mimeType)) {
|
if (!isValidStoredAttachmentMimeType(mimeType)) {
|
||||||
return { success: false, error: 'Invalid mimeType' };
|
return { success: false, error: 'Invalid mimeType' };
|
||||||
}
|
}
|
||||||
const safeAttId = attachmentId.trim();
|
const safeAttId = attachmentId.trim();
|
||||||
|
|
@ -4439,7 +4505,7 @@ async function handleGetTaskAttachment(
|
||||||
}
|
}
|
||||||
|
|
||||||
return wrapTeamHandler('getTaskAttachment', () =>
|
return wrapTeamHandler('getTaskAttachment', () =>
|
||||||
taskAttachmentStore.getAttachment(vTeam.value!, vTask.value!, safeAttId, mimeType)
|
taskAttachmentStore.getAttachment(vTeam.value!, vTask.value!, safeAttId, mimeType.trim())
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -4457,7 +4523,7 @@ async function handleDeleteTaskAttachment(
|
||||||
if (typeof attachmentId !== 'string' || attachmentId.trim().length === 0) {
|
if (typeof attachmentId !== 'string' || attachmentId.trim().length === 0) {
|
||||||
return { success: false, error: 'attachmentId must be a non-empty string' };
|
return { success: false, error: 'attachmentId must be a non-empty string' };
|
||||||
}
|
}
|
||||||
if (typeof mimeType !== 'string' || !ALLOWED_ATTACHMENT_TYPES.has(mimeType)) {
|
if (!isValidStoredAttachmentMimeType(mimeType)) {
|
||||||
return { success: false, error: 'Invalid mimeType' };
|
return { success: false, error: 'Invalid mimeType' };
|
||||||
}
|
}
|
||||||
const safeAttId = attachmentId.trim();
|
const safeAttId = attachmentId.trim();
|
||||||
|
|
@ -4466,7 +4532,12 @@ async function handleDeleteTaskAttachment(
|
||||||
}
|
}
|
||||||
|
|
||||||
return wrapTeamHandler('deleteTaskAttachment', async () => {
|
return wrapTeamHandler('deleteTaskAttachment', async () => {
|
||||||
await taskAttachmentStore.deleteAttachment(vTeam.value!, vTask.value!, safeAttId, mimeType);
|
await taskAttachmentStore.deleteAttachment(
|
||||||
|
vTeam.value!,
|
||||||
|
vTask.value!,
|
||||||
|
safeAttId,
|
||||||
|
mimeType.trim()
|
||||||
|
);
|
||||||
// Remove metadata from task JSON
|
// Remove metadata from task JSON
|
||||||
await getTeamDataService().removeTaskAttachment(vTeam.value!, vTask.value!, safeAttId);
|
await getTeamDataService().removeTaskAttachment(vTeam.value!, vTask.value!, safeAttId);
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -209,9 +209,7 @@ export class ChangeExtractorService {
|
||||||
return ledgerResult;
|
return ledgerResult;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!includeDetails) {
|
if (await this.tryBackfillOpenCodeLedger(resolvedInput)) {
|
||||||
this.enqueueOpenCodeLedgerBackfill(resolvedInput);
|
|
||||||
} else if (await this.tryBackfillOpenCodeLedger(resolvedInput)) {
|
|
||||||
const backfilledLedgerResult = await this.readLedgerTaskChanges(resolvedInput);
|
const backfilledLedgerResult = await this.readLedgerTaskChanges(resolvedInput);
|
||||||
if (backfilledLedgerResult) {
|
if (backfilledLedgerResult) {
|
||||||
await this.recordTaskChangePresence(
|
await this.recordTaskChangePresence(
|
||||||
|
|
@ -435,6 +433,28 @@ export class ChangeExtractorService {
|
||||||
}
|
}
|
||||||
this.openCodeBackfillCache.delete(cacheKey);
|
this.openCodeBackfillCache.delete(cacheKey);
|
||||||
|
|
||||||
|
if (deliveryContextRecords.length === 0) {
|
||||||
|
this.openCodeBackfillCache.set(cacheKey, {
|
||||||
|
backfilledAt: 0,
|
||||||
|
expiresAt: Date.now() + this.openCodeBackfillCacheTtl,
|
||||||
|
});
|
||||||
|
void appendOpenCodeTaskChangeDiag({
|
||||||
|
event: 'backfill_skipped',
|
||||||
|
reason: 'no-delivery-context',
|
||||||
|
teamName: input.teamName,
|
||||||
|
taskId: input.taskId,
|
||||||
|
displayId: input.taskMeta?.displayId ?? null,
|
||||||
|
memberName: input.effectiveOptions.owner ?? null,
|
||||||
|
projectDir,
|
||||||
|
workspaceRoot,
|
||||||
|
sourceGeneration,
|
||||||
|
deliveryRecordCount: 0,
|
||||||
|
deliveryContextFingerprint,
|
||||||
|
attributionMode: OPEN_CODE_AUTO_BACKFILL_ATTRIBUTION_MODE,
|
||||||
|
}).catch(() => undefined);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
const existing = this.openCodeBackfillInFlight.get(cacheKey);
|
const existing = this.openCodeBackfillInFlight.get(cacheKey);
|
||||||
if (existing) {
|
if (existing) {
|
||||||
return existing;
|
return existing;
|
||||||
|
|
@ -454,14 +474,6 @@ export class ChangeExtractorService {
|
||||||
return promise;
|
return promise;
|
||||||
}
|
}
|
||||||
|
|
||||||
private enqueueOpenCodeLedgerBackfill(input: ResolvedTaskChangeComputeInput): void {
|
|
||||||
void this.tryBackfillOpenCodeLedger(input).catch((error) => {
|
|
||||||
logger.debug(
|
|
||||||
`Background OpenCode ledger backfill failed for ${input.teamName}/${input.taskId}: ${error instanceof Error ? error.message : String(error)}`
|
|
||||||
);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
private async runOpenCodeBackfill(
|
private async runOpenCodeBackfill(
|
||||||
input: ResolvedTaskChangeComputeInput,
|
input: ResolvedTaskChangeComputeInput,
|
||||||
projectDir: string,
|
projectDir: string,
|
||||||
|
|
|
||||||
|
|
@ -1504,6 +1504,20 @@ function nowIso(): string {
|
||||||
return new Date().toISOString();
|
return new Date().toISOString();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function isOpenCodePromptDeliveryRetryAttemptDue(input: {
|
||||||
|
attemptDue: boolean;
|
||||||
|
ledgerRecord: Pick<OpenCodePromptDeliveryLedgerRecord, 'status' | 'responseState'>;
|
||||||
|
}): boolean {
|
||||||
|
if (!input.attemptDue) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
input.ledgerRecord.status === 'retry_scheduled' ||
|
||||||
|
input.ledgerRecord.status === 'failed_retryable' ||
|
||||||
|
isOpenCodePromptDeliveryRetryableResponseState(input.ledgerRecord.responseState)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function createInitialMemberSpawnStatusEntry(): MemberSpawnStatusEntry {
|
function createInitialMemberSpawnStatusEntry(): MemberSpawnStatusEntry {
|
||||||
const updatedAt = nowIso();
|
const updatedAt = nowIso();
|
||||||
return {
|
return {
|
||||||
|
|
@ -5573,9 +5587,10 @@ export class TeamProvisioningService {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const retryDueBeforeObserve =
|
const retryDueBeforeObserve = isOpenCodePromptDeliveryRetryAttemptDue({
|
||||||
attemptDue &&
|
attemptDue,
|
||||||
(ledgerRecord.status === 'retry_scheduled' || ledgerRecord.status === 'failed_retryable');
|
ledgerRecord,
|
||||||
|
});
|
||||||
if (ledgerRecord.status !== 'pending' && adapter.observeMessageDelivery) {
|
if (ledgerRecord.status !== 'pending' && adapter.observeMessageDelivery) {
|
||||||
const observed = await adapter.observeMessageDelivery({
|
const observed = await adapter.observeMessageDelivery({
|
||||||
...(runtimeRunId ? { runId: runtimeRunId } : {}),
|
...(runtimeRunId ? { runId: runtimeRunId } : {}),
|
||||||
|
|
|
||||||
158
src/main/services/team/TeamWorktreeGitService.ts
Normal file
158
src/main/services/team/TeamWorktreeGitService.ts
Normal file
|
|
@ -0,0 +1,158 @@
|
||||||
|
import { execFile } from 'child_process';
|
||||||
|
import * as fs from 'fs';
|
||||||
|
import * as path from 'path';
|
||||||
|
|
||||||
|
import type { TeamWorktreeGitStatus } from '@shared/types';
|
||||||
|
|
||||||
|
const GIT_TIMEOUT_MS = 20_000;
|
||||||
|
|
||||||
|
function execGit(args: string[], cwd: string): Promise<string> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
execFile(
|
||||||
|
'git',
|
||||||
|
args,
|
||||||
|
{ cwd, timeout: GIT_TIMEOUT_MS, maxBuffer: 1024 * 1024 },
|
||||||
|
(error, stdout, stderr) => {
|
||||||
|
if (error) {
|
||||||
|
const message = String(stderr || error.message || 'git command failed').trim();
|
||||||
|
reject(new Error(message));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
resolve(String(stdout).trim());
|
||||||
|
}
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeGitError(error: unknown): string {
|
||||||
|
return error instanceof Error ? error.message : String(error);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isGitUnavailable(error: unknown): boolean {
|
||||||
|
const message = normalizeGitError(error).toLowerCase();
|
||||||
|
return message.includes('enoent') || message.includes('git: command not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function assertUsableDirectory(projectPath: string): Promise<string> {
|
||||||
|
const trimmed = projectPath.trim();
|
||||||
|
if (!trimmed || !path.isAbsolute(trimmed)) {
|
||||||
|
throw new Error('Project path must be an absolute directory path.');
|
||||||
|
}
|
||||||
|
const stat = await fs.promises.stat(trimmed).catch(() => null);
|
||||||
|
if (!stat?.isDirectory()) {
|
||||||
|
throw new Error(`Project path is not a directory: ${trimmed}`);
|
||||||
|
}
|
||||||
|
return await fs.promises.realpath(trimmed).catch(() => trimmed);
|
||||||
|
}
|
||||||
|
|
||||||
|
function blockedStatus(
|
||||||
|
projectPath: string,
|
||||||
|
reason: NonNullable<TeamWorktreeGitStatus['reason']>,
|
||||||
|
message: string,
|
||||||
|
overrides: Partial<TeamWorktreeGitStatus> = {}
|
||||||
|
): TeamWorktreeGitStatus {
|
||||||
|
return {
|
||||||
|
projectPath,
|
||||||
|
isGitRepo: false,
|
||||||
|
hasHead: false,
|
||||||
|
canUseWorktrees: false,
|
||||||
|
reason,
|
||||||
|
message,
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export class TeamWorktreeGitService {
|
||||||
|
async getStatus(projectPath: string): Promise<TeamWorktreeGitStatus> {
|
||||||
|
let cwd: string;
|
||||||
|
try {
|
||||||
|
cwd = await assertUsableDirectory(projectPath);
|
||||||
|
} catch (error) {
|
||||||
|
return blockedStatus(projectPath.trim(), 'invalid_project_path', normalizeGitError(error));
|
||||||
|
}
|
||||||
|
|
||||||
|
let rootPath: string;
|
||||||
|
try {
|
||||||
|
const rootRaw = await execGit(['rev-parse', '--show-toplevel'], cwd);
|
||||||
|
rootPath = await fs.promises.realpath(rootRaw).catch(() => rootRaw);
|
||||||
|
} catch (error) {
|
||||||
|
if (isGitUnavailable(error)) {
|
||||||
|
return blockedStatus(cwd, 'git_unavailable', 'Git is not available on this machine.');
|
||||||
|
}
|
||||||
|
return blockedStatus(
|
||||||
|
cwd,
|
||||||
|
'not_git_repo',
|
||||||
|
'Worktree isolation requires a Git repository. This project is not a Git repo yet.'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const hasHead = await execGit(['rev-parse', '--verify', 'HEAD'], cwd)
|
||||||
|
.then(() => true)
|
||||||
|
.catch(() => false);
|
||||||
|
const branch = await execGit(['branch', '--show-current'], cwd)
|
||||||
|
.then((value) => value || undefined)
|
||||||
|
.catch(() => undefined);
|
||||||
|
|
||||||
|
if (!hasHead) {
|
||||||
|
return blockedStatus(
|
||||||
|
cwd,
|
||||||
|
'missing_head',
|
||||||
|
'Create an initial commit before using worktrees. We will not commit files automatically.',
|
||||||
|
{
|
||||||
|
isGitRepo: true,
|
||||||
|
rootPath,
|
||||||
|
branch,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
projectPath: cwd,
|
||||||
|
isGitRepo: true,
|
||||||
|
hasHead: true,
|
||||||
|
canUseWorktrees: true,
|
||||||
|
rootPath,
|
||||||
|
branch,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async initializeRepository(projectPath: string): Promise<TeamWorktreeGitStatus> {
|
||||||
|
const current = await this.getStatus(projectPath);
|
||||||
|
if (current.isGitRepo) {
|
||||||
|
return current;
|
||||||
|
}
|
||||||
|
if (current.reason !== 'not_git_repo') {
|
||||||
|
return current;
|
||||||
|
}
|
||||||
|
|
||||||
|
const cwd = await assertUsableDirectory(projectPath);
|
||||||
|
await execGit(['init'], cwd);
|
||||||
|
return this.getStatus(cwd);
|
||||||
|
}
|
||||||
|
|
||||||
|
async createInitialCommit(projectPath: string): Promise<TeamWorktreeGitStatus> {
|
||||||
|
const current = await this.getStatus(projectPath);
|
||||||
|
if (!current.isGitRepo || !current.rootPath) {
|
||||||
|
return current;
|
||||||
|
}
|
||||||
|
if (current.hasHead) {
|
||||||
|
return current;
|
||||||
|
}
|
||||||
|
|
||||||
|
await execGit(['add', '-A'], current.rootPath);
|
||||||
|
await execGit(
|
||||||
|
[
|
||||||
|
'-c',
|
||||||
|
'user.name=Agent Teams',
|
||||||
|
'-c',
|
||||||
|
'user.email=agent-teams@local',
|
||||||
|
'commit',
|
||||||
|
'--allow-empty',
|
||||||
|
'-m',
|
||||||
|
'chore: initial commit',
|
||||||
|
],
|
||||||
|
current.rootPath
|
||||||
|
);
|
||||||
|
return this.getStatus(current.rootPath);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -91,7 +91,8 @@ export function isOpenCodePromptDeliveryRetryableResponseState(
|
||||||
state === 'empty_assistant_turn' ||
|
state === 'empty_assistant_turn' ||
|
||||||
state === 'tool_error' ||
|
state === 'tool_error' ||
|
||||||
state === 'reconcile_failed' ||
|
state === 'reconcile_failed' ||
|
||||||
state === 'not_observed'
|
state === 'not_observed' ||
|
||||||
|
state === 'session_stale'
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -255,6 +255,15 @@ export const TEAM_LAUNCH = 'team:launch';
|
||||||
/** Warm up provisioning runtime before create */
|
/** Warm up provisioning runtime before create */
|
||||||
export const TEAM_PREPARE_PROVISIONING = 'team:prepareProvisioning';
|
export const TEAM_PREPARE_PROVISIONING = 'team:prepareProvisioning';
|
||||||
|
|
||||||
|
/** Inspect whether a project can support git worktree isolation */
|
||||||
|
export const TEAM_GET_WORKTREE_GIT_STATUS = 'team:getWorktreeGitStatus';
|
||||||
|
|
||||||
|
/** Initialize a git repository for worktree isolation */
|
||||||
|
export const TEAM_INITIALIZE_GIT_REPOSITORY = 'team:initializeGitRepository';
|
||||||
|
|
||||||
|
/** Create the first commit required by git worktrees */
|
||||||
|
export const TEAM_CREATE_INITIAL_GIT_COMMIT = 'team:createInitialGitCommit';
|
||||||
|
|
||||||
/** Get provisioning status by runId */
|
/** Get provisioning status by runId */
|
||||||
export const TEAM_PROVISIONING_STATUS = 'team:provisioningStatus';
|
export const TEAM_PROVISIONING_STATUS = 'team:provisioningStatus';
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -118,6 +118,7 @@ import {
|
||||||
TEAM_CANCEL_PROVISIONING,
|
TEAM_CANCEL_PROVISIONING,
|
||||||
TEAM_CHANGE,
|
TEAM_CHANGE,
|
||||||
TEAM_CREATE,
|
TEAM_CREATE,
|
||||||
|
TEAM_CREATE_INITIAL_GIT_COMMIT,
|
||||||
TEAM_CREATE_CONFIG,
|
TEAM_CREATE_CONFIG,
|
||||||
TEAM_CREATE_TASK,
|
TEAM_CREATE_TASK,
|
||||||
TEAM_DELETE_DRAFT,
|
TEAM_DELETE_DRAFT,
|
||||||
|
|
@ -144,6 +145,8 @@ import {
|
||||||
TEAM_GET_TASK_EXACT_LOG_SUMMARIES,
|
TEAM_GET_TASK_EXACT_LOG_SUMMARIES,
|
||||||
TEAM_GET_TASK_LOG_STREAM,
|
TEAM_GET_TASK_LOG_STREAM,
|
||||||
TEAM_GET_TASK_LOG_STREAM_SUMMARY,
|
TEAM_GET_TASK_LOG_STREAM_SUMMARY,
|
||||||
|
TEAM_GET_WORKTREE_GIT_STATUS,
|
||||||
|
TEAM_INITIALIZE_GIT_REPOSITORY,
|
||||||
TEAM_KILL_PROCESS,
|
TEAM_KILL_PROCESS,
|
||||||
TEAM_LAUNCH,
|
TEAM_LAUNCH,
|
||||||
TEAM_LEAD_ACTIVITY,
|
TEAM_LEAD_ACTIVITY,
|
||||||
|
|
@ -315,6 +318,7 @@ import type {
|
||||||
TeamTaskStatus,
|
TeamTaskStatus,
|
||||||
TeamUpdateConfigRequest,
|
TeamUpdateConfigRequest,
|
||||||
TeamViewSnapshot,
|
TeamViewSnapshot,
|
||||||
|
TeamWorktreeGitStatus,
|
||||||
ToolApprovalEvent,
|
ToolApprovalEvent,
|
||||||
ToolApprovalFileContent,
|
ToolApprovalFileContent,
|
||||||
ToolApprovalSettings,
|
ToolApprovalSettings,
|
||||||
|
|
@ -892,6 +896,21 @@ const electronAPI: ElectronAPI = {
|
||||||
modelVerificationMode
|
modelVerificationMode
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
getWorktreeGitStatus: async (projectPath: string) => {
|
||||||
|
return invokeIpcWithResult<TeamWorktreeGitStatus>(TEAM_GET_WORKTREE_GIT_STATUS, projectPath);
|
||||||
|
},
|
||||||
|
initializeGitRepository: async (projectPath: string) => {
|
||||||
|
return invokeIpcWithResult<TeamWorktreeGitStatus>(
|
||||||
|
TEAM_INITIALIZE_GIT_REPOSITORY,
|
||||||
|
projectPath
|
||||||
|
);
|
||||||
|
},
|
||||||
|
createInitialGitCommit: async (projectPath: string) => {
|
||||||
|
return invokeIpcWithResult<TeamWorktreeGitStatus>(
|
||||||
|
TEAM_CREATE_INITIAL_GIT_COMMIT,
|
||||||
|
projectPath
|
||||||
|
);
|
||||||
|
},
|
||||||
createTeam: async (request: TeamCreateRequest) => {
|
createTeam: async (request: TeamCreateRequest) => {
|
||||||
return invokeIpcWithResult<TeamCreateResponse>(TEAM_CREATE, request);
|
return invokeIpcWithResult<TeamCreateResponse>(TEAM_CREATE, request);
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -67,6 +67,7 @@ import type {
|
||||||
TeamProvisioningModelVerificationMode,
|
TeamProvisioningModelVerificationMode,
|
||||||
TeamProvisioningPrepareResult,
|
TeamProvisioningPrepareResult,
|
||||||
TeamProvisioningProgress,
|
TeamProvisioningProgress,
|
||||||
|
TeamWorktreeGitStatus,
|
||||||
TeamsAPI,
|
TeamsAPI,
|
||||||
TeamSummary,
|
TeamSummary,
|
||||||
TeamTask,
|
TeamTask,
|
||||||
|
|
@ -755,6 +756,22 @@ export class HttpAPIClient implements ElectronAPI {
|
||||||
): Promise<TeamProvisioningPrepareResult> => {
|
): Promise<TeamProvisioningPrepareResult> => {
|
||||||
throw new Error('Team provisioning is not available in browser mode');
|
throw new Error('Team provisioning is not available in browser mode');
|
||||||
},
|
},
|
||||||
|
getWorktreeGitStatus: async (projectPath: string): Promise<TeamWorktreeGitStatus> => {
|
||||||
|
return {
|
||||||
|
projectPath,
|
||||||
|
isGitRepo: false,
|
||||||
|
hasHead: false,
|
||||||
|
canUseWorktrees: false,
|
||||||
|
reason: 'git_error',
|
||||||
|
message: 'Worktree Git setup is not available in browser mode',
|
||||||
|
};
|
||||||
|
},
|
||||||
|
initializeGitRepository: async (_projectPath: string): Promise<TeamWorktreeGitStatus> => {
|
||||||
|
throw new Error('Git repository initialization is not available in browser mode');
|
||||||
|
},
|
||||||
|
createInitialGitCommit: async (_projectPath: string): Promise<TeamWorktreeGitStatus> => {
|
||||||
|
throw new Error('Initial Git commit is not available in browser mode');
|
||||||
|
},
|
||||||
createTeam: async (_request: TeamCreateRequest): Promise<TeamCreateResponse> => {
|
createTeam: async (_request: TeamCreateRequest): Promise<TeamCreateResponse> => {
|
||||||
throw new Error('Team provisioning is not available in browser mode');
|
throw new Error('Team provisioning is not available in browser mode');
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -324,6 +324,10 @@ function hastToText(node: HastNode): string {
|
||||||
return '';
|
return '';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function extractLanguageFromClassName(className?: string): string {
|
||||||
|
return /(?:^|\s)language-([^\s]+)/.exec(className ?? '')?.[1] ?? '';
|
||||||
|
}
|
||||||
|
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
// Component factories
|
// Component factories
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
|
|
@ -583,8 +587,8 @@ function createViewerMarkdownComponents(
|
||||||
const isBlock = (hasLanguage ?? false) || isMultiLine;
|
const isBlock = (hasLanguage ?? false) || isMultiLine;
|
||||||
|
|
||||||
if (isBlock) {
|
if (isBlock) {
|
||||||
const lang = codeClassName?.replace('language-', '') ?? '';
|
const lang = extractLanguageFromClassName(codeClassName);
|
||||||
const raw = typeof children === 'string' ? children : '';
|
const raw = typeof children === 'string' ? children : extractTextFromReactNode(children);
|
||||||
const text = raw.replace(/\n$/, '');
|
const text = raw.replace(/\n$/, '');
|
||||||
const lines = text.split('\n');
|
const lines = text.split('\n');
|
||||||
return (
|
return (
|
||||||
|
|
|
||||||
|
|
@ -131,6 +131,12 @@ import {
|
||||||
import { TeammateRuntimeCompatibilityNotice } from './TeammateRuntimeCompatibilityNotice';
|
import { TeammateRuntimeCompatibilityNotice } from './TeammateRuntimeCompatibilityNotice';
|
||||||
import { computeEffectiveTeamModel } from './TeamModelSelector';
|
import { computeEffectiveTeamModel } from './TeamModelSelector';
|
||||||
import { getNextSuggestedTeamName } from './teamNameSets';
|
import { getNextSuggestedTeamName } from './teamNameSets';
|
||||||
|
import {
|
||||||
|
getWorktreeGitBlockingMessage,
|
||||||
|
getWorktreeGitControlDisabledReason,
|
||||||
|
useWorktreeGitReadiness,
|
||||||
|
WorktreeGitReadinessBanner,
|
||||||
|
} from './WorktreeGitReadinessBanner';
|
||||||
|
|
||||||
import type { MemberDraft } from '@renderer/components/team/members/MembersEditorSection';
|
import type { MemberDraft } from '@renderer/components/team/members/MembersEditorSection';
|
||||||
|
|
||||||
|
|
@ -549,6 +555,20 @@ export const CreateTeamDialog = ({
|
||||||
() => (syncModelsWithLead ? members.map(clearMemberModelOverrides) : members),
|
() => (syncModelsWithLead ? members.map(clearMemberModelOverrides) : members),
|
||||||
[members, syncModelsWithLead]
|
[members, syncModelsWithLead]
|
||||||
);
|
);
|
||||||
|
const hasSelectedWorktreeIsolation =
|
||||||
|
!soloTeam &&
|
||||||
|
effectiveMemberDrafts.some((member) => !member.removedAt && member.isolation === 'worktree');
|
||||||
|
const worktreeGitReadiness = useWorktreeGitReadiness(
|
||||||
|
effectiveCwd || null,
|
||||||
|
open && canCreate && !soloTeam
|
||||||
|
);
|
||||||
|
const worktreeIsolationDisabledReason =
|
||||||
|
!soloTeam && canCreate ? getWorktreeGitControlDisabledReason(worktreeGitReadiness) : null;
|
||||||
|
const worktreeGitBlockingMessage = getWorktreeGitBlockingMessage(
|
||||||
|
worktreeGitReadiness,
|
||||||
|
hasSelectedWorktreeIsolation
|
||||||
|
);
|
||||||
|
const worktreeGitBlocksSubmission = Boolean(worktreeGitBlockingMessage);
|
||||||
const tmuxRuntime = useTmuxRuntimeReadiness(open && canCreate);
|
const tmuxRuntime = useTmuxRuntimeReadiness(open && canCreate);
|
||||||
|
|
||||||
const selectedMemberProviders = useMemo<TeamProviderId[]>(() => {
|
const selectedMemberProviders = useMemo<TeamProviderId[]>(() => {
|
||||||
|
|
@ -1425,7 +1445,8 @@ export const CreateTeamDialog = ({
|
||||||
isNameProvisioning ||
|
isNameProvisioning ||
|
||||||
!requestValidation.valid ||
|
!requestValidation.valid ||
|
||||||
!!modelValidationError ||
|
!!modelValidationError ||
|
||||||
teammateRuntimeCompatibility.blocksSubmission;
|
teammateRuntimeCompatibility.blocksSubmission ||
|
||||||
|
worktreeGitBlocksSubmission;
|
||||||
|
|
||||||
const internalArgs = useMemo(() => {
|
const internalArgs = useMemo(() => {
|
||||||
const args: string[] = [];
|
const args: string[] = [];
|
||||||
|
|
@ -1569,6 +1590,10 @@ export const CreateTeamDialog = ({
|
||||||
setLocalError(teammateRuntimeCompatibility.message);
|
setLocalError(teammateRuntimeCompatibility.message);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (worktreeGitBlockingMessage) {
|
||||||
|
setLocalError(worktreeGitBlockingMessage);
|
||||||
|
return;
|
||||||
|
}
|
||||||
setFieldErrors({});
|
setFieldErrors({});
|
||||||
setLocalError(null);
|
setLocalError(null);
|
||||||
setIsSubmitting(true);
|
setIsSubmitting(true);
|
||||||
|
|
@ -1781,6 +1806,7 @@ export const CreateTeamDialog = ({
|
||||||
onSyncModelsWithTeammatesChange={handleSyncModelsWithLeadChange}
|
onSyncModelsWithTeammatesChange={handleSyncModelsWithLeadChange}
|
||||||
showWorktreeIsolationControls={!soloTeam}
|
showWorktreeIsolationControls={!soloTeam}
|
||||||
teammateWorktreeDefault={teammateWorktreeDefault}
|
teammateWorktreeDefault={teammateWorktreeDefault}
|
||||||
|
worktreeIsolationDisabledReason={worktreeIsolationDisabledReason}
|
||||||
onTeammateWorktreeDefaultChange={setTeammateWorktreeDefault}
|
onTeammateWorktreeDefaultChange={setTeammateWorktreeDefault}
|
||||||
disableGeminiOption={isGeminiUiFrozen()}
|
disableGeminiOption={isGeminiUiFrozen()}
|
||||||
leadModelIssueText={leadModelIssueText}
|
leadModelIssueText={leadModelIssueText}
|
||||||
|
|
@ -1802,17 +1828,22 @@ export const CreateTeamDialog = ({
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
headerBottom={
|
headerBottom={
|
||||||
soloTeam ? (
|
<div className="space-y-2">
|
||||||
<div className="flex items-start gap-2 rounded-md border border-sky-500/20 bg-sky-500/5 px-3 py-2">
|
{soloTeam ? (
|
||||||
<Info className="mt-0.5 size-3.5 shrink-0 text-sky-400" />
|
<div className="flex items-start gap-2 rounded-md border border-sky-500/20 bg-sky-500/5 px-3 py-2">
|
||||||
<p className="text-[11px] leading-relaxed text-sky-300">
|
<Info className="mt-0.5 size-3.5 shrink-0 text-sky-400" />
|
||||||
Only the team lead (main process) will be started — no teammates will be
|
<p className="text-[11px] leading-relaxed text-sky-300">
|
||||||
spawned. Works like a regular Claude session but with access to the task board
|
Only the team lead (main process) will be started — no teammates will
|
||||||
for planning. Saves tokens by avoiding teammate coordination overhead. You can
|
be spawned. Works like a regular Claude session but with access to the task
|
||||||
add members later from the team settings.
|
board for planning. Saves tokens by avoiding teammate coordination overhead.
|
||||||
</p>
|
You can add members later from the team settings.
|
||||||
</div>
|
</p>
|
||||||
) : null
|
</div>
|
||||||
|
) : null}
|
||||||
|
{!soloTeam && canCreate ? (
|
||||||
|
<WorktreeGitReadinessBanner state={worktreeGitReadiness} />
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -140,6 +140,12 @@ import {
|
||||||
OPENCODE_TEAM_LEAD_DISABLED_REASON,
|
OPENCODE_TEAM_LEAD_DISABLED_REASON,
|
||||||
TeamModelSelector,
|
TeamModelSelector,
|
||||||
} from './TeamModelSelector';
|
} from './TeamModelSelector';
|
||||||
|
import {
|
||||||
|
getWorktreeGitBlockingMessage,
|
||||||
|
getWorktreeGitControlDisabledReason,
|
||||||
|
useWorktreeGitReadiness,
|
||||||
|
WorktreeGitReadinessBanner,
|
||||||
|
} from './WorktreeGitReadinessBanner';
|
||||||
|
|
||||||
import type { ActiveTeamRef } from './CreateTeamDialog';
|
import type { ActiveTeamRef } from './CreateTeamDialog';
|
||||||
import type { MemberDraft } from '@renderer/components/team/members/membersEditorTypes';
|
import type { MemberDraft } from '@renderer/components/team/members/membersEditorTypes';
|
||||||
|
|
@ -1282,6 +1288,17 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
|
||||||
? ''
|
? ''
|
||||||
: selectedProjectPath.trim();
|
: selectedProjectPath.trim();
|
||||||
const effectiveCwd = cwdMode === 'project' ? selectedProjectCwd : customCwd.trim();
|
const effectiveCwd = cwdMode === 'project' ? selectedProjectCwd : customCwd.trim();
|
||||||
|
const hasSelectedWorktreeIsolation =
|
||||||
|
isLaunchMode &&
|
||||||
|
effectiveMemberDrafts.some((member) => !member.removedAt && member.isolation === 'worktree');
|
||||||
|
const worktreeGitReadiness = useWorktreeGitReadiness(effectiveCwd || null, open && isLaunchMode);
|
||||||
|
const worktreeIsolationDisabledReason = isLaunchMode
|
||||||
|
? getWorktreeGitControlDisabledReason(worktreeGitReadiness)
|
||||||
|
: null;
|
||||||
|
const worktreeGitBlockingMessage = getWorktreeGitBlockingMessage(
|
||||||
|
worktreeGitReadiness,
|
||||||
|
hasSelectedWorktreeIsolation
|
||||||
|
);
|
||||||
const prepareRuntimeStatusSignature = useMemo(
|
const prepareRuntimeStatusSignature = useMemo(
|
||||||
() =>
|
() =>
|
||||||
buildProviderPrepareRuntimeStatusSignature(
|
buildProviderPrepareRuntimeStatusSignature(
|
||||||
|
|
@ -1726,13 +1743,21 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
|
||||||
const validationErrors = useMemo(() => {
|
const validationErrors = useMemo(() => {
|
||||||
const errors: string[] = [];
|
const errors: string[] = [];
|
||||||
if (!effectiveCwd) errors.push('Working directory is required');
|
if (!effectiveCwd) errors.push('Working directory is required');
|
||||||
|
if (worktreeGitBlockingMessage) errors.push(worktreeGitBlockingMessage);
|
||||||
if (isSchedule) {
|
if (isSchedule) {
|
||||||
if (!effectiveTeamName) errors.push('Team is required');
|
if (!effectiveTeamName) errors.push('Team is required');
|
||||||
if (!promptDraft.value.trim()) errors.push('Prompt is required');
|
if (!promptDraft.value.trim()) errors.push('Prompt is required');
|
||||||
if (!cronExpression.trim()) errors.push('Cron expression is required');
|
if (!cronExpression.trim()) errors.push('Cron expression is required');
|
||||||
}
|
}
|
||||||
return errors;
|
return errors;
|
||||||
}, [effectiveCwd, isSchedule, effectiveTeamName, promptDraft.value, cronExpression]);
|
}, [
|
||||||
|
effectiveCwd,
|
||||||
|
worktreeGitBlockingMessage,
|
||||||
|
isSchedule,
|
||||||
|
effectiveTeamName,
|
||||||
|
promptDraft.value,
|
||||||
|
cronExpression,
|
||||||
|
]);
|
||||||
const modelValidationError = useMemo(() => {
|
const modelValidationError = useMemo(() => {
|
||||||
const leadError = getTeamModelSelectionError(
|
const leadError = getTeamModelSelectionError(
|
||||||
selectedProviderId,
|
selectedProviderId,
|
||||||
|
|
@ -2422,6 +2447,7 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
|
||||||
onSyncModelsWithTeammatesChange={setSyncModelsWithLead}
|
onSyncModelsWithTeammatesChange={setSyncModelsWithLead}
|
||||||
showWorktreeIsolationControls
|
showWorktreeIsolationControls
|
||||||
teammateWorktreeDefault={teammateWorktreeDefault}
|
teammateWorktreeDefault={teammateWorktreeDefault}
|
||||||
|
worktreeIsolationDisabledReason={worktreeIsolationDisabledReason}
|
||||||
onTeammateWorktreeDefaultChange={setTeammateWorktreeDefault}
|
onTeammateWorktreeDefaultChange={setTeammateWorktreeDefault}
|
||||||
leadWarningText={leadRuntimeWarningText}
|
leadWarningText={leadRuntimeWarningText}
|
||||||
memberWarningById={combinedMemberRuntimeWarningById}
|
memberWarningById={combinedMemberRuntimeWarningById}
|
||||||
|
|
@ -2429,6 +2455,7 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
|
||||||
memberModelIssueById={memberModelIssueById}
|
memberModelIssueById={memberModelIssueById}
|
||||||
softDeleteMembers
|
softDeleteMembers
|
||||||
disableGeminiOption={isGeminiUiFrozen()}
|
disableGeminiOption={isGeminiUiFrozen()}
|
||||||
|
headerBottom={<WorktreeGitReadinessBanner state={worktreeGitReadiness} />}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5">
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,243 @@
|
||||||
|
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||||
|
|
||||||
|
import { api } from '@renderer/api';
|
||||||
|
import { Button } from '@renderer/components/ui/button';
|
||||||
|
import { AlertTriangle, CheckCircle2, GitBranch, Loader2 } from 'lucide-react';
|
||||||
|
|
||||||
|
import type { TeamWorktreeGitStatus } from '@shared/types';
|
||||||
|
|
||||||
|
interface WorktreeGitReadinessState {
|
||||||
|
status: TeamWorktreeGitStatus | null;
|
||||||
|
loading: boolean;
|
||||||
|
actionLoading: 'init' | 'commit' | null;
|
||||||
|
error: string | null;
|
||||||
|
refresh: () => Promise<void>;
|
||||||
|
initializeRepository: () => Promise<void>;
|
||||||
|
createInitialCommit: () => Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useWorktreeGitReadiness(
|
||||||
|
projectPath: string | null,
|
||||||
|
enabled: boolean
|
||||||
|
): WorktreeGitReadinessState {
|
||||||
|
const [status, setStatus] = useState<TeamWorktreeGitStatus | null>(null);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [actionLoading, setActionLoading] = useState<'init' | 'commit' | null>(null);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const refresh = useCallback(async () => {
|
||||||
|
if (!enabled || !projectPath?.trim()) {
|
||||||
|
setStatus(null);
|
||||||
|
setError(null);
|
||||||
|
setLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
setStatus(await api.teams.getWorktreeGitStatus(projectPath));
|
||||||
|
} catch (err) {
|
||||||
|
setStatus(null);
|
||||||
|
setError(err instanceof Error ? err.message : 'Failed to inspect Git repository');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [enabled, projectPath]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
if (!enabled || !projectPath?.trim()) {
|
||||||
|
setStatus(null);
|
||||||
|
setError(null);
|
||||||
|
setLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
void api.teams
|
||||||
|
.getWorktreeGitStatus(projectPath)
|
||||||
|
.then((nextStatus) => {
|
||||||
|
if (!cancelled) setStatus(nextStatus);
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
if (!cancelled) {
|
||||||
|
setStatus(null);
|
||||||
|
setError(err instanceof Error ? err.message : 'Failed to inspect Git repository');
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
if (!cancelled) setLoading(false);
|
||||||
|
});
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [enabled, projectPath]);
|
||||||
|
|
||||||
|
const initializeRepository = useCallback(async () => {
|
||||||
|
if (!projectPath?.trim()) return;
|
||||||
|
setActionLoading('init');
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
setStatus(await api.teams.initializeGitRepository(projectPath));
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : 'Failed to initialize Git repository');
|
||||||
|
} finally {
|
||||||
|
setActionLoading(null);
|
||||||
|
}
|
||||||
|
}, [projectPath]);
|
||||||
|
|
||||||
|
const createInitialCommit = useCallback(async () => {
|
||||||
|
if (!projectPath?.trim()) return;
|
||||||
|
setActionLoading('commit');
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
setStatus(await api.teams.createInitialGitCommit(projectPath));
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : 'Failed to create initial Git commit');
|
||||||
|
} finally {
|
||||||
|
setActionLoading(null);
|
||||||
|
}
|
||||||
|
}, [projectPath]);
|
||||||
|
|
||||||
|
return useMemo(
|
||||||
|
() => ({
|
||||||
|
status,
|
||||||
|
loading,
|
||||||
|
actionLoading,
|
||||||
|
error,
|
||||||
|
refresh,
|
||||||
|
initializeRepository,
|
||||||
|
createInitialCommit,
|
||||||
|
}),
|
||||||
|
[actionLoading, createInitialCommit, error, initializeRepository, loading, refresh, status]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getWorktreeGitBlockingMessage(
|
||||||
|
state: Pick<WorktreeGitReadinessState, 'status' | 'loading' | 'error'>,
|
||||||
|
hasSelectedWorktreeIsolation: boolean
|
||||||
|
): string | null {
|
||||||
|
if (!hasSelectedWorktreeIsolation) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (state.loading) {
|
||||||
|
return 'Checking Git repository status before enabling worktree isolation.';
|
||||||
|
}
|
||||||
|
if (state.error) {
|
||||||
|
return state.error;
|
||||||
|
}
|
||||||
|
if (!state.status) {
|
||||||
|
return 'Worktree isolation requires a Git repository with an initial commit.';
|
||||||
|
}
|
||||||
|
return state.status.canUseWorktrees ? null : (state.status.message ?? null);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getWorktreeGitControlDisabledReason(
|
||||||
|
state: Pick<WorktreeGitReadinessState, 'status' | 'loading' | 'error'>
|
||||||
|
): string | null {
|
||||||
|
if (state.loading) {
|
||||||
|
return 'Checking Git repository status...';
|
||||||
|
}
|
||||||
|
if (state.error) {
|
||||||
|
return state.error;
|
||||||
|
}
|
||||||
|
if (!state.status) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return state.status.canUseWorktrees ? null : (state.status.message ?? null);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function WorktreeGitReadinessBanner({
|
||||||
|
state,
|
||||||
|
showReady = false,
|
||||||
|
}: {
|
||||||
|
state: WorktreeGitReadinessState;
|
||||||
|
showReady?: boolean;
|
||||||
|
}): React.JSX.Element | null {
|
||||||
|
const { status, loading, actionLoading, error, initializeRepository, createInitialCommit } =
|
||||||
|
state;
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-start gap-2 rounded-md border border-sky-500/20 bg-sky-500/5 px-3 py-2 text-[11px] leading-relaxed text-sky-300">
|
||||||
|
<Loader2 className="mt-0.5 size-3.5 shrink-0 animate-spin" />
|
||||||
|
<p>Checking Git repository status for teammate worktrees...</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<div className="bg-red-500/8 flex items-start gap-2 rounded-md border border-red-500/25 px-3 py-2 text-[11px] leading-relaxed text-red-200">
|
||||||
|
<AlertTriangle className="mt-0.5 size-3.5 shrink-0" />
|
||||||
|
<p>{error}</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!status) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (status.canUseWorktrees) {
|
||||||
|
if (!showReady) return null;
|
||||||
|
return (
|
||||||
|
<div className="flex items-start gap-2 rounded-md border border-emerald-500/20 bg-emerald-500/5 px-3 py-2 text-[11px] leading-relaxed text-emerald-300">
|
||||||
|
<CheckCircle2 className="mt-0.5 size-3.5 shrink-0" />
|
||||||
|
<p>
|
||||||
|
Git worktrees are ready
|
||||||
|
{status.branch ? ` on branch ${status.branch}` : ''}.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="bg-amber-500/8 space-y-2 rounded-md border border-amber-500/25 px-3 py-2 text-[11px] leading-relaxed text-amber-100">
|
||||||
|
<div className="flex items-start gap-2">
|
||||||
|
<GitBranch className="mt-0.5 size-3.5 shrink-0 text-amber-300" />
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<p className="font-medium text-amber-100">Worktree isolation needs Git setup</p>
|
||||||
|
<p className="mt-0.5 text-amber-100/85">
|
||||||
|
{status.message ??
|
||||||
|
'Worktree isolation requires a Git repository with an initial commit.'}
|
||||||
|
</p>
|
||||||
|
{status.reason === 'missing_head' ? (
|
||||||
|
<p className="mt-1 text-amber-100/70">
|
||||||
|
The initial commit action stages and commits all current files with message{' '}
|
||||||
|
<span className="font-mono">chore: initial commit</span>.
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap gap-2 pl-5">
|
||||||
|
{status.reason === 'not_git_repo' ? (
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="h-7 text-[11px]"
|
||||||
|
disabled={actionLoading !== null}
|
||||||
|
onClick={initializeRepository}
|
||||||
|
>
|
||||||
|
{actionLoading === 'init' ? <Loader2 className="mr-1.5 size-3 animate-spin" /> : null}
|
||||||
|
Initialize Git repository
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
{status.reason === 'missing_head' ? (
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="h-7 border-amber-400/50 text-[11px] text-amber-100 hover:bg-amber-500/15"
|
||||||
|
disabled={actionLoading !== null}
|
||||||
|
onClick={createInitialCommit}
|
||||||
|
>
|
||||||
|
{actionLoading === 'commit' ? <Loader2 className="mr-1.5 size-3 animate-spin" /> : null}
|
||||||
|
Create initial commit
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -76,6 +76,7 @@ interface MemberDraftRowProps {
|
||||||
disableGeminiOption?: boolean;
|
disableGeminiOption?: boolean;
|
||||||
modelIssueText?: string | null;
|
modelIssueText?: string | null;
|
||||||
showWorktreeIsolationControls?: boolean;
|
showWorktreeIsolationControls?: boolean;
|
||||||
|
worktreeIsolationDisabledReason?: string | null;
|
||||||
onWorktreeIsolationChange?: (id: string, enabled: boolean) => void;
|
onWorktreeIsolationChange?: (id: string, enabled: boolean) => void;
|
||||||
lockedModelAction?: {
|
lockedModelAction?: {
|
||||||
label: string;
|
label: string;
|
||||||
|
|
@ -124,6 +125,7 @@ export const MemberDraftRow = ({
|
||||||
disableGeminiOption = false,
|
disableGeminiOption = false,
|
||||||
modelIssueText,
|
modelIssueText,
|
||||||
showWorktreeIsolationControls = false,
|
showWorktreeIsolationControls = false,
|
||||||
|
worktreeIsolationDisabledReason,
|
||||||
onWorktreeIsolationChange,
|
onWorktreeIsolationChange,
|
||||||
lockedModelAction,
|
lockedModelAction,
|
||||||
}: MemberDraftRowProps): React.JSX.Element => {
|
}: MemberDraftRowProps): React.JSX.Element => {
|
||||||
|
|
@ -218,6 +220,8 @@ export const MemberDraftRow = ({
|
||||||
: lockProviderModel
|
: lockProviderModel
|
||||||
? (lockedModelAction?.description ?? modelLockReason)
|
? (lockedModelAction?.description ?? modelLockReason)
|
||||||
: undefined;
|
: undefined;
|
||||||
|
const worktreeIsolationDisabled =
|
||||||
|
isRemoved || Boolean(worktreeIsolationDisabledReason && member.isolation !== 'worktree');
|
||||||
const hasModelIssue = Boolean(modelIssueText);
|
const hasModelIssue = Boolean(modelIssueText);
|
||||||
const runtimeSummary = formatTeamModelSummary(
|
const runtimeSummary = formatTeamModelSummary(
|
||||||
effectiveProviderId,
|
effectiveProviderId,
|
||||||
|
|
@ -349,13 +353,13 @@ export const MemberDraftRow = ({
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
'flex h-8 shrink-0 cursor-pointer items-center gap-1.5 rounded-md border border-[var(--color-border)] px-2 text-xs text-[var(--color-text-secondary)]',
|
'flex h-8 shrink-0 cursor-pointer items-center gap-1.5 rounded-md border border-[var(--color-border)] px-2 text-xs text-[var(--color-text-secondary)]',
|
||||||
isRemoved && 'cursor-not-allowed opacity-50'
|
worktreeIsolationDisabled && 'cursor-not-allowed opacity-50'
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<Checkbox
|
<Checkbox
|
||||||
id={`member-${member.id}-worktree-isolation`}
|
id={`member-${member.id}-worktree-isolation`}
|
||||||
checked={member.isolation === 'worktree'}
|
checked={member.isolation === 'worktree'}
|
||||||
disabled={isRemoved}
|
disabled={worktreeIsolationDisabled}
|
||||||
onCheckedChange={(checked) =>
|
onCheckedChange={(checked) =>
|
||||||
onWorktreeIsolationChange?.(member.id, checked === true)
|
onWorktreeIsolationChange?.(member.id, checked === true)
|
||||||
}
|
}
|
||||||
|
|
@ -364,7 +368,7 @@ export const MemberDraftRow = ({
|
||||||
htmlFor={`member-${member.id}-worktree-isolation`}
|
htmlFor={`member-${member.id}-worktree-isolation`}
|
||||||
className={cn(
|
className={cn(
|
||||||
'flex cursor-pointer items-center gap-1.5 text-xs font-normal',
|
'flex cursor-pointer items-center gap-1.5 text-xs font-normal',
|
||||||
isRemoved && 'cursor-not-allowed'
|
worktreeIsolationDisabled && 'cursor-not-allowed'
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<GitBranch className="size-3.5 shrink-0" />
|
<GitBranch className="size-3.5 shrink-0" />
|
||||||
|
|
@ -373,8 +377,9 @@ export const MemberDraftRow = ({
|
||||||
</div>
|
</div>
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipContent side="top" className="max-w-64 text-xs leading-relaxed">
|
<TooltipContent side="top" className="max-w-64 text-xs leading-relaxed">
|
||||||
Run this teammate in a separate git worktree. Apply/reject changes targets that
|
{worktreeIsolationDisabledReason && member.isolation !== 'worktree'
|
||||||
worktree, not the lead workspace.
|
? worktreeIsolationDisabledReason
|
||||||
|
: 'Run this teammate in a separate git worktree. Apply/reject changes targets that worktree, not the lead workspace.'}
|
||||||
</TooltipContent>
|
</TooltipContent>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
|
||||||
|
|
@ -117,6 +117,7 @@ export interface MembersEditorSectionProps {
|
||||||
addMemberLockReason?: string;
|
addMemberLockReason?: string;
|
||||||
showWorktreeIsolationControls?: boolean;
|
showWorktreeIsolationControls?: boolean;
|
||||||
teammateWorktreeDefault?: boolean;
|
teammateWorktreeDefault?: boolean;
|
||||||
|
worktreeIsolationDisabledReason?: string | null;
|
||||||
onTeammateWorktreeDefaultChange?: (enabled: boolean) => void;
|
onTeammateWorktreeDefaultChange?: (enabled: boolean) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -154,6 +155,7 @@ export const MembersEditorSection = ({
|
||||||
addMemberLockReason,
|
addMemberLockReason,
|
||||||
showWorktreeIsolationControls = false,
|
showWorktreeIsolationControls = false,
|
||||||
teammateWorktreeDefault = false,
|
teammateWorktreeDefault = false,
|
||||||
|
worktreeIsolationDisabledReason,
|
||||||
onTeammateWorktreeDefaultChange,
|
onTeammateWorktreeDefaultChange,
|
||||||
}: MembersEditorSectionProps): React.JSX.Element => {
|
}: MembersEditorSectionProps): React.JSX.Element => {
|
||||||
const [jsonEditorOpen, setJsonEditorOpen] = useState(false);
|
const [jsonEditorOpen, setJsonEditorOpen] = useState(false);
|
||||||
|
|
@ -247,6 +249,9 @@ export const MembersEditorSection = ({
|
||||||
};
|
};
|
||||||
|
|
||||||
const updateMemberIsolation = (memberId: string, enabled: boolean): void => {
|
const updateMemberIsolation = (memberId: string, enabled: boolean): void => {
|
||||||
|
if (enabled && worktreeIsolationDisabledReason) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
onChange(
|
onChange(
|
||||||
members.map((c) =>
|
members.map((c) =>
|
||||||
c.id === memberId ? { ...c, isolation: enabled ? 'worktree' : undefined } : c
|
c.id === memberId ? { ...c, isolation: enabled ? 'worktree' : undefined } : c
|
||||||
|
|
@ -255,6 +260,9 @@ export const MembersEditorSection = ({
|
||||||
};
|
};
|
||||||
|
|
||||||
const updateTeammateWorktreeDefault = (enabled: boolean): void => {
|
const updateTeammateWorktreeDefault = (enabled: boolean): void => {
|
||||||
|
if (enabled && worktreeIsolationDisabledReason) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
onTeammateWorktreeDefaultChange?.(enabled);
|
onTeammateWorktreeDefaultChange?.(enabled);
|
||||||
onChange(
|
onChange(
|
||||||
members.map((member) =>
|
members.map((member) =>
|
||||||
|
|
@ -348,10 +356,14 @@ export const MembersEditorSection = ({
|
||||||
{!hideContent && (
|
{!hideContent && (
|
||||||
<>
|
<>
|
||||||
{showWorktreeIsolationControls ? (
|
{showWorktreeIsolationControls ? (
|
||||||
<div className="flex items-center gap-2 rounded-md border border-[var(--color-border)] bg-[var(--color-surface)] px-2.5 py-2">
|
<div
|
||||||
|
className="flex items-center gap-2 rounded-md border border-[var(--color-border)] bg-[var(--color-surface)] px-2.5 py-2"
|
||||||
|
title={worktreeIsolationDisabledReason ?? undefined}
|
||||||
|
>
|
||||||
<Checkbox
|
<Checkbox
|
||||||
id={worktreeDefaultControlId}
|
id={worktreeDefaultControlId}
|
||||||
checked={teammateWorktreeDefault}
|
checked={teammateWorktreeDefault}
|
||||||
|
disabled={Boolean(worktreeIsolationDisabledReason && !teammateWorktreeDefault)}
|
||||||
onCheckedChange={(checked) => updateTeammateWorktreeDefault(checked === true)}
|
onCheckedChange={(checked) => updateTeammateWorktreeDefault(checked === true)}
|
||||||
/>
|
/>
|
||||||
<Label
|
<Label
|
||||||
|
|
@ -386,6 +398,7 @@ export const MembersEditorSection = ({
|
||||||
onModelChange={updateMemberModel}
|
onModelChange={updateMemberModel}
|
||||||
onEffortChange={updateMemberEffort}
|
onEffortChange={updateMemberEffort}
|
||||||
showWorktreeIsolationControls={showWorktreeIsolationControls}
|
showWorktreeIsolationControls={showWorktreeIsolationControls}
|
||||||
|
worktreeIsolationDisabledReason={worktreeIsolationDisabledReason}
|
||||||
onWorktreeIsolationChange={updateMemberIsolation}
|
onWorktreeIsolationChange={updateMemberIsolation}
|
||||||
inheritedProviderId={inheritedProviderId}
|
inheritedProviderId={inheritedProviderId}
|
||||||
inheritedModel={inheritedModel}
|
inheritedModel={inheritedModel}
|
||||||
|
|
|
||||||
|
|
@ -48,6 +48,7 @@ interface TeamRosterEditorSectionProps {
|
||||||
memberModelIssueById?: Record<string, string | null | undefined>;
|
memberModelIssueById?: Record<string, string | null | undefined>;
|
||||||
showWorktreeIsolationControls?: boolean;
|
showWorktreeIsolationControls?: boolean;
|
||||||
teammateWorktreeDefault?: boolean;
|
teammateWorktreeDefault?: boolean;
|
||||||
|
worktreeIsolationDisabledReason?: string | null;
|
||||||
onTeammateWorktreeDefaultChange?: (enabled: boolean) => void;
|
onTeammateWorktreeDefaultChange?: (enabled: boolean) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -92,6 +93,7 @@ export const TeamRosterEditorSection = ({
|
||||||
memberModelIssueById,
|
memberModelIssueById,
|
||||||
showWorktreeIsolationControls = false,
|
showWorktreeIsolationControls = false,
|
||||||
teammateWorktreeDefault = false,
|
teammateWorktreeDefault = false,
|
||||||
|
worktreeIsolationDisabledReason,
|
||||||
onTeammateWorktreeDefaultChange,
|
onTeammateWorktreeDefaultChange,
|
||||||
}: TeamRosterEditorSectionProps): React.JSX.Element => {
|
}: TeamRosterEditorSectionProps): React.JSX.Element => {
|
||||||
return (
|
return (
|
||||||
|
|
@ -122,6 +124,7 @@ export const TeamRosterEditorSection = ({
|
||||||
memberModelIssueById={memberModelIssueById}
|
memberModelIssueById={memberModelIssueById}
|
||||||
showWorktreeIsolationControls={showWorktreeIsolationControls}
|
showWorktreeIsolationControls={showWorktreeIsolationControls}
|
||||||
teammateWorktreeDefault={teammateWorktreeDefault}
|
teammateWorktreeDefault={teammateWorktreeDefault}
|
||||||
|
worktreeIsolationDisabledReason={worktreeIsolationDisabledReason}
|
||||||
onTeammateWorktreeDefaultChange={onTeammateWorktreeDefaultChange}
|
onTeammateWorktreeDefaultChange={onTeammateWorktreeDefaultChange}
|
||||||
headerExtra={
|
headerExtra={
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
|
|
|
||||||
|
|
@ -85,6 +85,7 @@ import type {
|
||||||
TeamTaskStatus,
|
TeamTaskStatus,
|
||||||
TeamUpdateConfigRequest,
|
TeamUpdateConfigRequest,
|
||||||
TeamViewSnapshot,
|
TeamViewSnapshot,
|
||||||
|
TeamWorktreeGitStatus,
|
||||||
ToolApprovalEvent,
|
ToolApprovalEvent,
|
||||||
ToolApprovalFileContent,
|
ToolApprovalFileContent,
|
||||||
ToolApprovalSettings,
|
ToolApprovalSettings,
|
||||||
|
|
@ -450,6 +451,9 @@ export interface TeamsAPI {
|
||||||
limitContext?: boolean,
|
limitContext?: boolean,
|
||||||
modelVerificationMode?: TeamProvisioningModelVerificationMode
|
modelVerificationMode?: TeamProvisioningModelVerificationMode
|
||||||
) => Promise<TeamProvisioningPrepareResult>;
|
) => Promise<TeamProvisioningPrepareResult>;
|
||||||
|
getWorktreeGitStatus: (projectPath: string) => Promise<TeamWorktreeGitStatus>;
|
||||||
|
initializeGitRepository: (projectPath: string) => Promise<TeamWorktreeGitStatus>;
|
||||||
|
createInitialGitCommit: (projectPath: string) => Promise<TeamWorktreeGitStatus>;
|
||||||
createTeam: (request: TeamCreateRequest) => Promise<TeamCreateResponse>;
|
createTeam: (request: TeamCreateRequest) => Promise<TeamCreateResponse>;
|
||||||
getProvisioningStatus: (runId: string) => Promise<TeamProvisioningProgress>;
|
getProvisioningStatus: (runId: string) => Promise<TeamProvisioningProgress>;
|
||||||
cancelProvisioning: (runId: string) => Promise<void>;
|
cancelProvisioning: (runId: string) => Promise<void>;
|
||||||
|
|
|
||||||
|
|
@ -1233,6 +1233,24 @@ export interface TeamProvisioningMemberInput {
|
||||||
fastMode?: TeamFastMode;
|
fastMode?: TeamFastMode;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type TeamWorktreeGitBlockReason =
|
||||||
|
| 'invalid_project_path'
|
||||||
|
| 'not_git_repo'
|
||||||
|
| 'missing_head'
|
||||||
|
| 'git_unavailable'
|
||||||
|
| 'git_error';
|
||||||
|
|
||||||
|
export interface TeamWorktreeGitStatus {
|
||||||
|
projectPath: string;
|
||||||
|
isGitRepo: boolean;
|
||||||
|
hasHead: boolean;
|
||||||
|
canUseWorktrees: boolean;
|
||||||
|
rootPath?: string;
|
||||||
|
branch?: string;
|
||||||
|
reason?: TeamWorktreeGitBlockReason;
|
||||||
|
message?: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface TeamCreateRequest {
|
export interface TeamCreateRequest {
|
||||||
teamName: string;
|
teamName: string;
|
||||||
displayName?: string;
|
displayName?: string;
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,6 @@
|
||||||
import * as os from 'os';
|
import * as os from 'os';
|
||||||
|
import * as fs from 'fs';
|
||||||
|
import * as path from 'path';
|
||||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
import type {
|
import type {
|
||||||
BoardTaskActivityDetailResult,
|
BoardTaskActivityDetailResult,
|
||||||
|
|
@ -130,6 +132,7 @@ import {
|
||||||
removeTeamHandlers,
|
removeTeamHandlers,
|
||||||
} from '../../../src/main/ipc/teams';
|
} from '../../../src/main/ipc/teams';
|
||||||
import { ConfigManager } from '../../../src/main/services/infrastructure/ConfigManager';
|
import { ConfigManager } from '../../../src/main/services/infrastructure/ConfigManager';
|
||||||
|
import { getAppDataPath } from '../../../src/main/utils/pathDecoder';
|
||||||
|
|
||||||
describe('ipc teams handlers', () => {
|
describe('ipc teams handlers', () => {
|
||||||
const handlers = new Map<string, (...args: unknown[]) => Promise<unknown>>();
|
const handlers = new Map<string, (...args: unknown[]) => Promise<unknown>>();
|
||||||
|
|
@ -408,6 +411,43 @@ describe('ipc teams handlers', () => {
|
||||||
expect(service.getTaskChangePresence).toHaveBeenCalledWith('my-team');
|
expect(service.getTaskChangePresence).toHaveBeenCalledWith('my-team');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('returns stored task attachments with source-code MIME types', async () => {
|
||||||
|
const handler = handlers.get(TEAM_GET_TASK_ATTACHMENT);
|
||||||
|
expect(handler).toBeDefined();
|
||||||
|
|
||||||
|
const taskId = 'task-js';
|
||||||
|
const attachmentId = 'att-js';
|
||||||
|
const attachmentDir = path.join(
|
||||||
|
getAppDataPath(),
|
||||||
|
'task-attachments',
|
||||||
|
'my-team',
|
||||||
|
taskId
|
||||||
|
);
|
||||||
|
await fs.promises.rm(attachmentDir, { recursive: true, force: true });
|
||||||
|
await fs.promises.mkdir(attachmentDir, { recursive: true });
|
||||||
|
await fs.promises.writeFile(
|
||||||
|
path.join(attachmentDir, `${attachmentId}--script.js`),
|
||||||
|
'const calculator = 1;\n'
|
||||||
|
);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = (await handler!(
|
||||||
|
{} as never,
|
||||||
|
'my-team',
|
||||||
|
taskId,
|
||||||
|
attachmentId,
|
||||||
|
'text/javascript'
|
||||||
|
)) as { success: boolean; data?: string; error?: string };
|
||||||
|
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
expect(Buffer.from(result.data ?? '', 'base64').toString('utf8')).toBe(
|
||||||
|
'const calculator = 1;\n'
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
await fs.promises.rm(attachmentDir, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
it('returns explicit exact task-log summaries for a task', async () => {
|
it('returns explicit exact task-log summaries for a task', async () => {
|
||||||
boardTaskExactLogsService.getTaskExactLogSummaries.mockResolvedValueOnce({
|
boardTaskExactLogsService.getTaskExactLogSummaries.mockResolvedValueOnce({
|
||||||
items: [
|
items: [
|
||||||
|
|
|
||||||
|
|
@ -67,6 +67,65 @@ async function writeTaskFile(
|
||||||
return taskPath;
|
return taskPath;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function writeOpenCodeDeliveryLedger(
|
||||||
|
baseDir: string,
|
||||||
|
overrides?: Partial<{
|
||||||
|
memberName: string;
|
||||||
|
laneId: string;
|
||||||
|
runtimeSessionId: string;
|
||||||
|
inboxMessageId: string;
|
||||||
|
deliveredUserMessageId: string;
|
||||||
|
observedAssistantMessageId: string | null;
|
||||||
|
taskId: string;
|
||||||
|
displayId: string;
|
||||||
|
teamName: string;
|
||||||
|
}>
|
||||||
|
): Promise<string> {
|
||||||
|
const memberName = overrides?.memberName ?? 'bob';
|
||||||
|
const laneId = overrides?.laneId ?? `secondary:opencode:${memberName}`;
|
||||||
|
const filePath = path.join(
|
||||||
|
baseDir,
|
||||||
|
'teams',
|
||||||
|
overrides?.teamName ?? TEAM_NAME,
|
||||||
|
'.opencode-runtime',
|
||||||
|
'lanes',
|
||||||
|
encodeURIComponent(laneId),
|
||||||
|
'opencode-prompt-delivery-ledger.json'
|
||||||
|
);
|
||||||
|
await fs.mkdir(path.dirname(filePath), { recursive: true });
|
||||||
|
await fs.writeFile(
|
||||||
|
filePath,
|
||||||
|
JSON.stringify(
|
||||||
|
{
|
||||||
|
data: [
|
||||||
|
{
|
||||||
|
teamName: overrides?.teamName ?? TEAM_NAME,
|
||||||
|
memberName,
|
||||||
|
laneId,
|
||||||
|
runtimeSessionId: overrides?.runtimeSessionId ?? 'session-1',
|
||||||
|
inboxMessageId: overrides?.inboxMessageId ?? 'user-1',
|
||||||
|
deliveredUserMessageId: overrides?.deliveredUserMessageId ?? 'user-1',
|
||||||
|
observedAssistantMessageId: overrides?.observedAssistantMessageId ?? null,
|
||||||
|
prePromptCursor: null,
|
||||||
|
postPromptCursor: null,
|
||||||
|
taskRefs: [
|
||||||
|
{
|
||||||
|
taskId: overrides?.taskId ?? TASK_ID,
|
||||||
|
displayId: overrides?.displayId ?? 'abc12345',
|
||||||
|
teamName: overrides?.teamName ?? TEAM_NAME,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
null,
|
||||||
|
2
|
||||||
|
),
|
||||||
|
'utf8'
|
||||||
|
);
|
||||||
|
return filePath;
|
||||||
|
}
|
||||||
|
|
||||||
function persistedEntryPath(baseDir: string): string {
|
function persistedEntryPath(baseDir: string): string {
|
||||||
return path.join(baseDir, 'task-change-summaries', encodeURIComponent(TEAM_NAME), `${TASK_ID}.json`);
|
return path.join(baseDir, 'task-change-summaries', encodeURIComponent(TEAM_NAME), `${TASK_ID}.json`);
|
||||||
}
|
}
|
||||||
|
|
@ -873,6 +932,7 @@ describe('ChangeExtractorService', () => {
|
||||||
const projectPath = path.join(tmpDir, 'repo');
|
const projectPath = path.join(tmpDir, 'repo');
|
||||||
await fs.mkdir(projectDir, { recursive: true });
|
await fs.mkdir(projectDir, { recursive: true });
|
||||||
await fs.mkdir(projectPath, { recursive: true });
|
await fs.mkdir(projectPath, { recursive: true });
|
||||||
|
await writeOpenCodeDeliveryLedger(tmpDir);
|
||||||
|
|
||||||
const backfillOpenCodeTaskLedger = vi.fn(async (input: any) => {
|
const backfillOpenCodeTaskLedger = vi.fn(async (input: any) => {
|
||||||
const bundleDir = path.join(input.projectDir, '.board-task-changes', 'bundles');
|
const bundleDir = path.join(input.projectDir, '.board-task-changes', 'bundles');
|
||||||
|
|
@ -1063,7 +1123,7 @@ describe('ChangeExtractorService', () => {
|
||||||
expect(backfillOpenCodeTaskLedger).not.toHaveBeenCalled();
|
expect(backfillOpenCodeTaskLedger).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('queues OpenCode backfill for summary-only requests without blocking board rendering', async () => {
|
it('awaits OpenCode backfill for summary-only requests with delivery context before falling back', async () => {
|
||||||
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'change-extractor-service-'));
|
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'change-extractor-service-'));
|
||||||
setClaudeBasePathOverride(tmpDir);
|
setClaudeBasePathOverride(tmpDir);
|
||||||
await writeTaskFile(tmpDir, { displayId: 'abc12345', owner: 'bob' });
|
await writeTaskFile(tmpDir, { displayId: 'abc12345', owner: 'bob' });
|
||||||
|
|
@ -1071,6 +1131,7 @@ describe('ChangeExtractorService', () => {
|
||||||
const projectPath = path.join(tmpDir, 'repo');
|
const projectPath = path.join(tmpDir, 'repo');
|
||||||
await fs.mkdir(projectDir, { recursive: true });
|
await fs.mkdir(projectDir, { recursive: true });
|
||||||
await fs.mkdir(projectPath, { recursive: true });
|
await fs.mkdir(projectPath, { recursive: true });
|
||||||
|
await writeOpenCodeDeliveryLedger(tmpDir);
|
||||||
const pendingBackfill = deferred<any>();
|
const pendingBackfill = deferred<any>();
|
||||||
const backfillOpenCodeTaskLedger = vi.fn(() => pendingBackfill.promise);
|
const backfillOpenCodeTaskLedger = vi.fn(() => pendingBackfill.promise);
|
||||||
const workerClient = {
|
const workerClient = {
|
||||||
|
|
@ -1105,10 +1166,13 @@ describe('ChangeExtractorService', () => {
|
||||||
{ getMeta: vi.fn(async () => ({ providerId: 'opencode' })) } as any
|
{ getMeta: vi.fn(async () => ({ providerId: 'opencode' })) } as any
|
||||||
);
|
);
|
||||||
|
|
||||||
const result = await service.getTaskChanges(TEAM_NAME, TASK_ID, SUMMARY_OPTIONS);
|
let settled = false;
|
||||||
|
const request = service
|
||||||
|
.getTaskChanges(TEAM_NAME, TASK_ID, { ...SUMMARY_OPTIONS, owner: 'bob' })
|
||||||
|
.finally(() => {
|
||||||
|
settled = true;
|
||||||
|
});
|
||||||
|
|
||||||
expect(result.files).toHaveLength(0);
|
|
||||||
expect(workerClient.computeTaskChanges).toHaveBeenCalledTimes(1);
|
|
||||||
await vi.waitFor(() => {
|
await vi.waitFor(() => {
|
||||||
expect(backfillOpenCodeTaskLedger).toHaveBeenCalledWith(
|
expect(backfillOpenCodeTaskLedger).toHaveBeenCalledWith(
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
|
|
@ -1116,10 +1180,14 @@ describe('ChangeExtractorService', () => {
|
||||||
taskId: TASK_ID,
|
taskId: TASK_ID,
|
||||||
projectDir,
|
projectDir,
|
||||||
workspaceRoot: projectPath,
|
workspaceRoot: projectPath,
|
||||||
|
deliveryContextPath: expect.stringContaining('delivery-context.json'),
|
||||||
attributionMode: 'strict-delivery',
|
attributionMode: 'strict-delivery',
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
expect(settled).toBe(false);
|
||||||
|
expect(workerClient.computeTaskChanges).not.toHaveBeenCalled();
|
||||||
pendingBackfill.resolve({
|
pendingBackfill.resolve({
|
||||||
schemaVersion: 1,
|
schemaVersion: 1,
|
||||||
providerId: 'opencode',
|
providerId: 'opencode',
|
||||||
|
|
@ -1137,6 +1205,10 @@ describe('ChangeExtractorService', () => {
|
||||||
notices: [],
|
notices: [],
|
||||||
diagnostics: [],
|
diagnostics: [],
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const result = await request;
|
||||||
|
expect(result.files).toHaveLength(0);
|
||||||
|
expect(workerClient.computeTaskChanges).toHaveBeenCalledTimes(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('does not reuse a negative OpenCode backfill cache entry after delivery context appears', async () => {
|
it('does not reuse a negative OpenCode backfill cache entry after delivery context appears', async () => {
|
||||||
|
|
@ -1202,51 +1274,17 @@ describe('ChangeExtractorService', () => {
|
||||||
owner: 'bob',
|
owner: 'bob',
|
||||||
status: 'completed',
|
status: 'completed',
|
||||||
});
|
});
|
||||||
expect(backfillOpenCodeTaskLedger).toHaveBeenCalledTimes(1);
|
expect(backfillOpenCodeTaskLedger).not.toHaveBeenCalled();
|
||||||
expect(backfillOpenCodeTaskLedger.mock.calls[0]?.[0]?.deliveryContextPath).toBeUndefined();
|
|
||||||
|
|
||||||
const deliveryLedgerPath = path.join(
|
await writeOpenCodeDeliveryLedger(tmpDir);
|
||||||
tmpDir,
|
|
||||||
'teams',
|
|
||||||
TEAM_NAME,
|
|
||||||
'.opencode-runtime',
|
|
||||||
'lanes',
|
|
||||||
encodeURIComponent('secondary:opencode:bob'),
|
|
||||||
'opencode-prompt-delivery-ledger.json'
|
|
||||||
);
|
|
||||||
await fs.mkdir(path.dirname(deliveryLedgerPath), { recursive: true });
|
|
||||||
await fs.writeFile(
|
|
||||||
deliveryLedgerPath,
|
|
||||||
JSON.stringify(
|
|
||||||
{
|
|
||||||
data: [
|
|
||||||
{
|
|
||||||
teamName: TEAM_NAME,
|
|
||||||
memberName: 'bob',
|
|
||||||
laneId: 'secondary:opencode:bob',
|
|
||||||
runtimeSessionId: 'session-1',
|
|
||||||
inboxMessageId: 'user-1',
|
|
||||||
deliveredUserMessageId: 'user-1',
|
|
||||||
observedAssistantMessageId: null,
|
|
||||||
prePromptCursor: null,
|
|
||||||
postPromptCursor: null,
|
|
||||||
taskRefs: [{ taskId: TASK_ID, displayId: 'abc12345', teamName: TEAM_NAME }],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
null,
|
|
||||||
2
|
|
||||||
),
|
|
||||||
'utf8'
|
|
||||||
);
|
|
||||||
|
|
||||||
await service.getTaskChanges(TEAM_NAME, TASK_ID, {
|
await service.getTaskChanges(TEAM_NAME, TASK_ID, {
|
||||||
owner: 'bob',
|
owner: 'bob',
|
||||||
status: 'completed',
|
status: 'completed',
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(backfillOpenCodeTaskLedger).toHaveBeenCalledTimes(2);
|
expect(backfillOpenCodeTaskLedger).toHaveBeenCalledTimes(1);
|
||||||
expect(backfillOpenCodeTaskLedger.mock.calls[1]?.[0]?.deliveryContextPath).toEqual(
|
expect(backfillOpenCodeTaskLedger.mock.calls[0]?.[0]?.deliveryContextPath).toEqual(
|
||||||
expect.stringContaining('delivery-context.json')
|
expect.stringContaining('delivery-context.json')
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,43 @@
|
||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
|
import {
|
||||||
|
isOpenCodePromptDeliveryObserveLaterResponseState,
|
||||||
|
isOpenCodePromptDeliveryRetryableResponseState,
|
||||||
|
} from '@main/services/team/opencode/delivery/OpenCodePromptDeliveryWatchdog';
|
||||||
|
import { isOpenCodePromptDeliveryRetryAttemptDue } from '@main/services/team/TeamProvisioningService';
|
||||||
|
|
||||||
|
describe('OpenCodePromptDeliveryWatchdog retry policy', () => {
|
||||||
|
it('treats stale OpenCode sessions as retryable after observation', () => {
|
||||||
|
expect(isOpenCodePromptDeliveryObserveLaterResponseState('session_stale')).toBe(true);
|
||||||
|
expect(isOpenCodePromptDeliveryRetryableResponseState('session_stale')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not retry prompt indexing states before OpenCode has had a chance to answer', () => {
|
||||||
|
expect(isOpenCodePromptDeliveryObserveLaterResponseState('prompt_not_indexed')).toBe(true);
|
||||||
|
expect(isOpenCodePromptDeliveryRetryableResponseState('prompt_not_indexed')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('lets due accepted stale-session records proceed to a fresh send attempt', () => {
|
||||||
|
expect(
|
||||||
|
isOpenCodePromptDeliveryRetryAttemptDue({
|
||||||
|
attemptDue: true,
|
||||||
|
ledgerRecord: {
|
||||||
|
status: 'accepted',
|
||||||
|
responseState: 'session_stale',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps non-due stale-session records in observation mode', () => {
|
||||||
|
expect(
|
||||||
|
isOpenCodePromptDeliveryRetryAttemptDue({
|
||||||
|
attemptDue: false,
|
||||||
|
ledgerRecord: {
|
||||||
|
status: 'accepted',
|
||||||
|
responseState: 'session_stale',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -3277,6 +3277,155 @@ describe('TeamProvisioningService', () => {
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('retries due stale OpenCode sessions instead of observing forever', async () => {
|
||||||
|
const svc = new TeamProvisioningService();
|
||||||
|
const sendMessageToMember = vi.fn(async (input: Record<string, unknown>) => ({
|
||||||
|
ok: true,
|
||||||
|
providerId: 'opencode',
|
||||||
|
memberName: String(input.memberName),
|
||||||
|
sessionId: 'oc-session-bob',
|
||||||
|
prePromptCursor: `cursor-${sendMessageToMember.mock.calls.length}`,
|
||||||
|
responseObservation: {
|
||||||
|
state: 'pending',
|
||||||
|
deliveredUserMessageId: `oc-user-${sendMessageToMember.mock.calls.length}`,
|
||||||
|
assistantMessageId: null,
|
||||||
|
toolCallNames: [],
|
||||||
|
visibleMessageToolCallId: null,
|
||||||
|
visibleReplyMessageId: null,
|
||||||
|
visibleReplyCorrelation: null,
|
||||||
|
latestAssistantPreview: null,
|
||||||
|
reason: 'assistant_response_pending',
|
||||||
|
},
|
||||||
|
diagnostics: [],
|
||||||
|
}));
|
||||||
|
const observeMessageDelivery = vi.fn(async (input: Record<string, unknown>) => ({
|
||||||
|
ok: true,
|
||||||
|
providerId: 'opencode',
|
||||||
|
memberName: String(input.memberName),
|
||||||
|
sessionId: 'oc-session-bob',
|
||||||
|
responseObservation: {
|
||||||
|
state: 'session_stale',
|
||||||
|
deliveredUserMessageId: null,
|
||||||
|
assistantMessageId: null,
|
||||||
|
toolCallNames: [],
|
||||||
|
visibleMessageToolCallId: null,
|
||||||
|
visibleReplyMessageId: null,
|
||||||
|
visibleReplyCorrelation: null,
|
||||||
|
latestAssistantPreview: null,
|
||||||
|
reason: 'resolved_behavior_changed:old->new',
|
||||||
|
},
|
||||||
|
diagnostics: ['OpenCode session reconcile skipped because the stored session is stale'],
|
||||||
|
}));
|
||||||
|
svc.setRuntimeAdapterRegistry(
|
||||||
|
new TeamRuntimeAdapterRegistry([
|
||||||
|
{
|
||||||
|
providerId: 'opencode',
|
||||||
|
prepare: vi.fn(),
|
||||||
|
launch: vi.fn(),
|
||||||
|
reconcile: vi.fn(),
|
||||||
|
stop: vi.fn(),
|
||||||
|
sendMessageToMember,
|
||||||
|
observeMessageDelivery,
|
||||||
|
} as any,
|
||||||
|
])
|
||||||
|
);
|
||||||
|
|
||||||
|
(svc as any).getTrackedRunId = vi.fn(() => 'run-1');
|
||||||
|
(svc as any).provisioningRunByTeam.set('team-a', 'run-1');
|
||||||
|
(svc as any).setSecondaryRuntimeRun({
|
||||||
|
teamName: 'team-a',
|
||||||
|
runId: 'opencode-run-bob',
|
||||||
|
providerId: 'opencode',
|
||||||
|
laneId: 'secondary:opencode:bob',
|
||||||
|
memberName: 'bob',
|
||||||
|
cwd: '/repo',
|
||||||
|
});
|
||||||
|
(svc as any).configReader = {
|
||||||
|
getConfig: vi.fn(async () => ({
|
||||||
|
projectPath: '/repo',
|
||||||
|
members: [
|
||||||
|
{ name: 'team-lead', providerId: 'codex', model: 'gpt-5.4' },
|
||||||
|
{ name: 'bob', providerId: 'opencode', model: 'minimax-m2.5-free' },
|
||||||
|
],
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
(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.deliverOpenCodeMemberMessage('team-a', {
|
||||||
|
memberName: 'bob',
|
||||||
|
text: 'hello bob',
|
||||||
|
messageId: 'msg-stale-session',
|
||||||
|
source: 'watcher',
|
||||||
|
inboxTimestamp: '2026-04-25T10:00:00.000Z',
|
||||||
|
})
|
||||||
|
).resolves.toMatchObject({
|
||||||
|
delivered: true,
|
||||||
|
responsePending: true,
|
||||||
|
responseState: 'pending',
|
||||||
|
});
|
||||||
|
|
||||||
|
const ledgerPath = getOpenCodeLaneScopedRuntimeFilePath({
|
||||||
|
teamsBasePath: tempTeamsBase,
|
||||||
|
teamName: 'team-a',
|
||||||
|
laneId: 'secondary:opencode:bob',
|
||||||
|
fileName: 'opencode-prompt-delivery-ledger.json',
|
||||||
|
});
|
||||||
|
const ledgerEnvelope = JSON.parse(await fsPromises.readFile(ledgerPath, 'utf8')) as {
|
||||||
|
data: Array<{
|
||||||
|
status: string;
|
||||||
|
responseState: string;
|
||||||
|
nextAttemptAt: string | null;
|
||||||
|
lastReason: string | null;
|
||||||
|
}>;
|
||||||
|
};
|
||||||
|
Object.assign(ledgerEnvelope.data[0], {
|
||||||
|
status: 'accepted',
|
||||||
|
responseState: 'session_stale',
|
||||||
|
nextAttemptAt: '2000-01-01T00:00:00.000Z',
|
||||||
|
lastReason: 'resolved_behavior_changed:old->new',
|
||||||
|
});
|
||||||
|
await fsPromises.writeFile(ledgerPath, JSON.stringify(ledgerEnvelope, null, 2), 'utf8');
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
svc.deliverOpenCodeMemberMessage('team-a', {
|
||||||
|
memberName: 'bob',
|
||||||
|
text: 'hello bob',
|
||||||
|
messageId: 'msg-stale-session',
|
||||||
|
source: 'watcher',
|
||||||
|
inboxTimestamp: '2026-04-25T10:00:00.000Z',
|
||||||
|
})
|
||||||
|
).resolves.toMatchObject({
|
||||||
|
delivered: true,
|
||||||
|
responsePending: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(observeMessageDelivery).toHaveBeenCalledTimes(1);
|
||||||
|
expect(sendMessageToMember).toHaveBeenCalledTimes(2);
|
||||||
|
expect(sendMessageToMember.mock.calls[1]?.[0]).toMatchObject({
|
||||||
|
runId: 'opencode-run-bob',
|
||||||
|
teamName: 'team-a',
|
||||||
|
laneId: 'secondary:opencode:bob',
|
||||||
|
memberName: 'bob',
|
||||||
|
cwd: '/repo',
|
||||||
|
messageId: 'msg-stale-session',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it('keeps OpenCode ack-only plain-text responses pending instead of committing read', async () => {
|
it('keeps OpenCode ack-only plain-text responses pending instead of committing read', 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>) => ({
|
||||||
|
|
|
||||||
70
test/main/services/team/TeamWorktreeGitService.test.ts
Normal file
70
test/main/services/team/TeamWorktreeGitService.test.ts
Normal file
|
|
@ -0,0 +1,70 @@
|
||||||
|
import { execFile } from 'child_process';
|
||||||
|
import * as fs from 'fs/promises';
|
||||||
|
import * as os from 'os';
|
||||||
|
import * as path from 'path';
|
||||||
|
|
||||||
|
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
|
import { TeamWorktreeGitService } from '../../../../src/main/services/team/TeamWorktreeGitService';
|
||||||
|
|
||||||
|
function execGit(args: string[], cwd: string): Promise<string> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
execFile('git', args, { cwd }, (error, stdout, stderr) => {
|
||||||
|
if (error) {
|
||||||
|
reject(new Error(String(stderr || error.message).trim()));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
resolve(String(stdout).trim());
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('TeamWorktreeGitService', () => {
|
||||||
|
let tempRoot = '';
|
||||||
|
let projectPath = '';
|
||||||
|
let service: TeamWorktreeGitService;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'team-worktree-git-'));
|
||||||
|
projectPath = path.join(tempRoot, 'project');
|
||||||
|
await fs.mkdir(projectPath, { recursive: true });
|
||||||
|
await fs.writeFile(path.join(projectPath, 'README.md'), 'hello\n', 'utf8');
|
||||||
|
service = new TeamWorktreeGitService();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
await fs.rm(tempRoot, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reports non-git projects as blocked but initializable', async () => {
|
||||||
|
await expect(service.getStatus(projectPath)).resolves.toMatchObject({
|
||||||
|
isGitRepo: false,
|
||||||
|
hasHead: false,
|
||||||
|
canUseWorktrees: false,
|
||||||
|
reason: 'not_git_repo',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('initializes git without silently creating a commit', async () => {
|
||||||
|
await expect(service.initializeRepository(projectPath)).resolves.toMatchObject({
|
||||||
|
isGitRepo: true,
|
||||||
|
hasHead: false,
|
||||||
|
canUseWorktrees: false,
|
||||||
|
reason: 'missing_head',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('creates an explicit initial commit for all current files', async () => {
|
||||||
|
await service.initializeRepository(projectPath);
|
||||||
|
|
||||||
|
await expect(service.createInitialCommit(projectPath)).resolves.toMatchObject({
|
||||||
|
isGitRepo: true,
|
||||||
|
hasHead: true,
|
||||||
|
canUseWorktrees: true,
|
||||||
|
});
|
||||||
|
await expect(execGit(['log', '--format=%s', '-1'], projectPath)).resolves.toBe(
|
||||||
|
'chore: initial commit'
|
||||||
|
);
|
||||||
|
await expect(execGit(['ls-files'], projectPath)).resolves.toContain('README.md');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -0,0 +1,91 @@
|
||||||
|
import React, { act } from 'react';
|
||||||
|
import { createRoot } from 'react-dom/client';
|
||||||
|
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
|
vi.mock('@renderer/api', () => ({
|
||||||
|
api: {
|
||||||
|
openExternal: vi.fn(),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('@renderer/hooks/useTheme', () => ({
|
||||||
|
useTheme: () => ({ isLight: false }),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('@renderer/store', () => ({
|
||||||
|
useStore: (selector: (state: Record<string, unknown>) => unknown) =>
|
||||||
|
selector({
|
||||||
|
teams: [],
|
||||||
|
openTeamTab: vi.fn(),
|
||||||
|
searchMatchItemIds: new Set<string>(),
|
||||||
|
searchQuery: '',
|
||||||
|
searchMatches: [],
|
||||||
|
currentSearchIndex: -1,
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('@renderer/components/team/TaskTooltip', () => ({
|
||||||
|
TaskTooltip: ({ children }: { children: React.ReactNode }) =>
|
||||||
|
React.createElement(React.Fragment, null, children),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('@renderer/components/team/members/MemberHoverCard', () => ({
|
||||||
|
MemberHoverCard: ({ children }: { children: React.ReactNode }) =>
|
||||||
|
React.createElement(React.Fragment, null, children),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('@renderer/components/chat/viewers/FileLink', () => ({
|
||||||
|
FileLink: ({ children }: { children: React.ReactNode }) =>
|
||||||
|
React.createElement('span', null, children),
|
||||||
|
isRelativeUrl: () => false,
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { MarkdownViewer } from '@renderer/components/chat/viewers/MarkdownViewer';
|
||||||
|
|
||||||
|
describe('MarkdownViewer code blocks', () => {
|
||||||
|
afterEach(() => {
|
||||||
|
document.body.innerHTML = '';
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders highlighted fenced code content instead of an empty copy-only block', 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(
|
||||||
|
<MarkdownViewer
|
||||||
|
content={[
|
||||||
|
'Содержимое файла `472/script.js`:',
|
||||||
|
'',
|
||||||
|
'```javascript',
|
||||||
|
'const calculator = {',
|
||||||
|
" displayValue: '0',",
|
||||||
|
'};',
|
||||||
|
'',
|
||||||
|
'function updateDisplay() {',
|
||||||
|
" const display = document.querySelector('.calculator-screen');",
|
||||||
|
' display.value = calculator.displayValue;',
|
||||||
|
'}',
|
||||||
|
'```',
|
||||||
|
].join('\n')}
|
||||||
|
maxHeight="max-h-none"
|
||||||
|
bare
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
await Promise.resolve();
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(host.textContent).toContain('Содержимое файла');
|
||||||
|
expect(host.textContent).toContain('const calculator');
|
||||||
|
expect(host.textContent).toContain('function updateDisplay');
|
||||||
|
expect(host.textContent).toContain('calculator.displayValue');
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
root.unmount();
|
||||||
|
await Promise.resolve();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -43,6 +43,25 @@ vi.mock('@renderer/api', () => ({
|
||||||
getSavedRequest: vi.fn(async () => null),
|
getSavedRequest: vi.fn(async () => null),
|
||||||
replaceMembers: vi.fn(async () => {}),
|
replaceMembers: vi.fn(async () => {}),
|
||||||
prepareProvisioning: vi.fn(async () => ({})),
|
prepareProvisioning: vi.fn(async () => ({})),
|
||||||
|
getWorktreeGitStatus: vi.fn(async (projectPath: string) => ({
|
||||||
|
projectPath,
|
||||||
|
isGitRepo: true,
|
||||||
|
hasHead: true,
|
||||||
|
canUseWorktrees: true,
|
||||||
|
})),
|
||||||
|
initializeGitRepository: vi.fn(async (projectPath: string) => ({
|
||||||
|
projectPath,
|
||||||
|
isGitRepo: true,
|
||||||
|
hasHead: false,
|
||||||
|
canUseWorktrees: false,
|
||||||
|
reason: 'missing_head',
|
||||||
|
})),
|
||||||
|
createInitialGitCommit: vi.fn(async (projectPath: string) => ({
|
||||||
|
projectPath,
|
||||||
|
isGitRepo: true,
|
||||||
|
hasHead: true,
|
||||||
|
canUseWorktrees: true,
|
||||||
|
})),
|
||||||
},
|
},
|
||||||
tmux: {
|
tmux: {
|
||||||
getStatus: vi.fn(() =>
|
getStatus: vi.fn(() =>
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,66 @@
|
||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
|
import {
|
||||||
|
getWorktreeGitBlockingMessage,
|
||||||
|
getWorktreeGitControlDisabledReason,
|
||||||
|
} from '@renderer/components/team/dialogs/WorktreeGitReadinessBanner';
|
||||||
|
|
||||||
|
describe('WorktreeGitReadinessBanner helpers', () => {
|
||||||
|
it('does not block submit when no teammate selected worktree isolation', () => {
|
||||||
|
expect(
|
||||||
|
getWorktreeGitBlockingMessage(
|
||||||
|
{
|
||||||
|
loading: false,
|
||||||
|
error: null,
|
||||||
|
status: {
|
||||||
|
projectPath: '/project',
|
||||||
|
isGitRepo: false,
|
||||||
|
hasHead: false,
|
||||||
|
canUseWorktrees: false,
|
||||||
|
reason: 'not_git_repo',
|
||||||
|
message: 'not ready',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
false
|
||||||
|
)
|
||||||
|
).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('blocks selected worktree isolation until git has a HEAD commit', () => {
|
||||||
|
const state = {
|
||||||
|
loading: false,
|
||||||
|
error: null,
|
||||||
|
status: {
|
||||||
|
projectPath: '/project',
|
||||||
|
isGitRepo: true,
|
||||||
|
hasHead: false,
|
||||||
|
canUseWorktrees: false,
|
||||||
|
reason: 'missing_head' as const,
|
||||||
|
message: 'Create an initial commit before using worktrees.',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(getWorktreeGitBlockingMessage(state, true)).toBe(
|
||||||
|
'Create an initial commit before using worktrees.'
|
||||||
|
);
|
||||||
|
expect(getWorktreeGitControlDisabledReason(state)).toBe(
|
||||||
|
'Create an initial commit before using worktrees.'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('allows worktree controls when git worktrees are ready', () => {
|
||||||
|
const state = {
|
||||||
|
loading: false,
|
||||||
|
error: null,
|
||||||
|
status: {
|
||||||
|
projectPath: '/project',
|
||||||
|
isGitRepo: true,
|
||||||
|
hasHead: true,
|
||||||
|
canUseWorktrees: true,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(getWorktreeGitBlockingMessage(state, true)).toBeNull();
|
||||||
|
expect(getWorktreeGitControlDisabledReason(state)).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
Loading…
Reference in a new issue