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_CANCEL_PROVISIONING,
|
||||
TEAM_CREATE,
|
||||
TEAM_CREATE_INITIAL_GIT_COMMIT,
|
||||
TEAM_CREATE_CONFIG,
|
||||
TEAM_CREATE_TASK,
|
||||
TEAM_DELETE_DRAFT,
|
||||
|
|
@ -37,6 +38,8 @@ import {
|
|||
TEAM_GET_TASK_EXACT_LOG_SUMMARIES,
|
||||
TEAM_GET_TASK_LOG_STREAM,
|
||||
TEAM_GET_TASK_LOG_STREAM_SUMMARY,
|
||||
TEAM_GET_WORKTREE_GIT_STATUS,
|
||||
TEAM_INITIALIZE_GIT_REPOSITORY,
|
||||
TEAM_KILL_PROCESS,
|
||||
TEAM_LAUNCH,
|
||||
TEAM_LEAD_ACTIVITY,
|
||||
|
|
@ -130,6 +133,7 @@ import { TeamMembersMetaStore } from '../services/team/TeamMembersMetaStore';
|
|||
import { TeamMetaStore } from '../services/team/TeamMetaStore';
|
||||
import { buildAddMemberSpawnMessage } from '../services/team/TeamProvisioningService';
|
||||
import { TeamTaskAttachmentStore } from '../services/team/TeamTaskAttachmentStore';
|
||||
import { TeamWorktreeGitService } from '../services/team/TeamWorktreeGitService';
|
||||
|
||||
import {
|
||||
validateFromField,
|
||||
|
|
@ -205,6 +209,7 @@ import type {
|
|||
TeamTask,
|
||||
TeamTaskStatus,
|
||||
TeamUpdateConfigRequest,
|
||||
TeamWorktreeGitStatus,
|
||||
TeamViewSnapshot,
|
||||
ToolApprovalFileContent,
|
||||
ToolApprovalSettings,
|
||||
|
|
@ -514,6 +519,7 @@ let boardTaskExactLogDetailService: BoardTaskExactLogDetailService | null = null
|
|||
const attachmentStore = new TeamAttachmentStore();
|
||||
const taskAttachmentStore = new TeamTaskAttachmentStore();
|
||||
const teamMetaStore = new TeamMetaStore();
|
||||
const worktreeGitService = new TeamWorktreeGitService();
|
||||
|
||||
const ALLOWED_ATTACHMENT_TYPES = new Set([
|
||||
'image/png',
|
||||
|
|
@ -525,6 +531,16 @@ const ALLOWED_ATTACHMENT_TYPES = new Set([
|
|||
]);
|
||||
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.
|
||||
* @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_GET_CLAUDE_LOGS, handleGetClaudeLogs);
|
||||
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_LAUNCH, handleLaunchTeam);
|
||||
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_GET_CLAUDE_LOGS);
|
||||
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_LAUNCH);
|
||||
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[]>> {
|
||||
setCurrentMainOp('team:list');
|
||||
const startedAt = Date.now();
|
||||
|
|
@ -4267,10 +4337,9 @@ async function handleAddTaskComment(
|
|||
if (
|
||||
typeof a.id !== 'string' ||
|
||||
typeof a.filename !== 'string' ||
|
||||
typeof a.mimeType !== 'string' ||
|
||||
!isValidStoredAttachmentMimeType(a.mimeType) ||
|
||||
typeof a.base64Data !== 'string' ||
|
||||
a.base64Data.length === 0 ||
|
||||
!ALLOWED_ATTACHMENT_TYPES.has(a.mimeType)
|
||||
a.base64Data.length === 0
|
||||
) {
|
||||
throw new Error('Invalid attachment data');
|
||||
}
|
||||
|
|
@ -4283,7 +4352,7 @@ async function handleAddTaskComment(
|
|||
vTask.value!,
|
||||
safeId,
|
||||
a.filename,
|
||||
a.mimeType,
|
||||
a.mimeType.trim(),
|
||||
a.base64Data
|
||||
);
|
||||
savedAttachments.push(meta);
|
||||
|
|
@ -4386,11 +4455,8 @@ async function handleSaveTaskAttachment(
|
|||
if (typeof filename !== 'string' || filename.trim().length === 0) {
|
||||
return { success: false, error: 'filename must be a non-empty string' };
|
||||
}
|
||||
if (typeof mimeType !== 'string' || !ALLOWED_ATTACHMENT_TYPES.has(mimeType)) {
|
||||
return {
|
||||
success: false,
|
||||
error: `mimeType must be one of: ${[...ALLOWED_ATTACHMENT_TYPES].join(', ')}`,
|
||||
};
|
||||
if (!isValidStoredAttachmentMimeType(mimeType)) {
|
||||
return { success: false, error: 'Invalid mimeType' };
|
||||
}
|
||||
if (typeof base64Data !== 'string' || base64Data.length === 0) {
|
||||
return { success: false, error: 'base64Data must be a non-empty string' };
|
||||
|
|
@ -4407,7 +4473,7 @@ async function handleSaveTaskAttachment(
|
|||
vTask.value!,
|
||||
safeAttId,
|
||||
filename,
|
||||
mimeType,
|
||||
mimeType.trim(),
|
||||
base64Data
|
||||
);
|
||||
// Write metadata into the task JSON
|
||||
|
|
@ -4430,7 +4496,7 @@ async function handleGetTaskAttachment(
|
|||
if (typeof attachmentId !== 'string' || attachmentId.trim().length === 0) {
|
||||
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' };
|
||||
}
|
||||
const safeAttId = attachmentId.trim();
|
||||
|
|
@ -4439,7 +4505,7 @@ async function handleGetTaskAttachment(
|
|||
}
|
||||
|
||||
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) {
|
||||
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' };
|
||||
}
|
||||
const safeAttId = attachmentId.trim();
|
||||
|
|
@ -4466,7 +4532,12 @@ async function handleDeleteTaskAttachment(
|
|||
}
|
||||
|
||||
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
|
||||
await getTeamDataService().removeTaskAttachment(vTeam.value!, vTask.value!, safeAttId);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -209,9 +209,7 @@ export class ChangeExtractorService {
|
|||
return ledgerResult;
|
||||
}
|
||||
|
||||
if (!includeDetails) {
|
||||
this.enqueueOpenCodeLedgerBackfill(resolvedInput);
|
||||
} else if (await this.tryBackfillOpenCodeLedger(resolvedInput)) {
|
||||
if (await this.tryBackfillOpenCodeLedger(resolvedInput)) {
|
||||
const backfilledLedgerResult = await this.readLedgerTaskChanges(resolvedInput);
|
||||
if (backfilledLedgerResult) {
|
||||
await this.recordTaskChangePresence(
|
||||
|
|
@ -435,6 +433,28 @@ export class ChangeExtractorService {
|
|||
}
|
||||
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);
|
||||
if (existing) {
|
||||
return existing;
|
||||
|
|
@ -454,14 +474,6 @@ export class ChangeExtractorService {
|
|||
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(
|
||||
input: ResolvedTaskChangeComputeInput,
|
||||
projectDir: string,
|
||||
|
|
|
|||
|
|
@ -1504,6 +1504,20 @@ function nowIso(): string {
|
|||
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 {
|
||||
const updatedAt = nowIso();
|
||||
return {
|
||||
|
|
@ -5573,9 +5587,10 @@ export class TeamProvisioningService {
|
|||
};
|
||||
}
|
||||
|
||||
const retryDueBeforeObserve =
|
||||
attemptDue &&
|
||||
(ledgerRecord.status === 'retry_scheduled' || ledgerRecord.status === 'failed_retryable');
|
||||
const retryDueBeforeObserve = isOpenCodePromptDeliveryRetryAttemptDue({
|
||||
attemptDue,
|
||||
ledgerRecord,
|
||||
});
|
||||
if (ledgerRecord.status !== 'pending' && adapter.observeMessageDelivery) {
|
||||
const observed = await adapter.observeMessageDelivery({
|
||||
...(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 === 'tool_error' ||
|
||||
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 */
|
||||
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 */
|
||||
export const TEAM_PROVISIONING_STATUS = 'team:provisioningStatus';
|
||||
|
||||
|
|
|
|||
|
|
@ -118,6 +118,7 @@ import {
|
|||
TEAM_CANCEL_PROVISIONING,
|
||||
TEAM_CHANGE,
|
||||
TEAM_CREATE,
|
||||
TEAM_CREATE_INITIAL_GIT_COMMIT,
|
||||
TEAM_CREATE_CONFIG,
|
||||
TEAM_CREATE_TASK,
|
||||
TEAM_DELETE_DRAFT,
|
||||
|
|
@ -144,6 +145,8 @@ import {
|
|||
TEAM_GET_TASK_EXACT_LOG_SUMMARIES,
|
||||
TEAM_GET_TASK_LOG_STREAM,
|
||||
TEAM_GET_TASK_LOG_STREAM_SUMMARY,
|
||||
TEAM_GET_WORKTREE_GIT_STATUS,
|
||||
TEAM_INITIALIZE_GIT_REPOSITORY,
|
||||
TEAM_KILL_PROCESS,
|
||||
TEAM_LAUNCH,
|
||||
TEAM_LEAD_ACTIVITY,
|
||||
|
|
@ -315,6 +318,7 @@ import type {
|
|||
TeamTaskStatus,
|
||||
TeamUpdateConfigRequest,
|
||||
TeamViewSnapshot,
|
||||
TeamWorktreeGitStatus,
|
||||
ToolApprovalEvent,
|
||||
ToolApprovalFileContent,
|
||||
ToolApprovalSettings,
|
||||
|
|
@ -892,6 +896,21 @@ const electronAPI: ElectronAPI = {
|
|||
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) => {
|
||||
return invokeIpcWithResult<TeamCreateResponse>(TEAM_CREATE, request);
|
||||
},
|
||||
|
|
|
|||
|
|
@ -67,6 +67,7 @@ import type {
|
|||
TeamProvisioningModelVerificationMode,
|
||||
TeamProvisioningPrepareResult,
|
||||
TeamProvisioningProgress,
|
||||
TeamWorktreeGitStatus,
|
||||
TeamsAPI,
|
||||
TeamSummary,
|
||||
TeamTask,
|
||||
|
|
@ -755,6 +756,22 @@ export class HttpAPIClient implements ElectronAPI {
|
|||
): Promise<TeamProvisioningPrepareResult> => {
|
||||
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> => {
|
||||
throw new Error('Team provisioning is not available in browser mode');
|
||||
},
|
||||
|
|
|
|||
|
|
@ -324,6 +324,10 @@ function hastToText(node: HastNode): string {
|
|||
return '';
|
||||
}
|
||||
|
||||
function extractLanguageFromClassName(className?: string): string {
|
||||
return /(?:^|\s)language-([^\s]+)/.exec(className ?? '')?.[1] ?? '';
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Component factories
|
||||
// =============================================================================
|
||||
|
|
@ -583,8 +587,8 @@ function createViewerMarkdownComponents(
|
|||
const isBlock = (hasLanguage ?? false) || isMultiLine;
|
||||
|
||||
if (isBlock) {
|
||||
const lang = codeClassName?.replace('language-', '') ?? '';
|
||||
const raw = typeof children === 'string' ? children : '';
|
||||
const lang = extractLanguageFromClassName(codeClassName);
|
||||
const raw = typeof children === 'string' ? children : extractTextFromReactNode(children);
|
||||
const text = raw.replace(/\n$/, '');
|
||||
const lines = text.split('\n');
|
||||
return (
|
||||
|
|
|
|||
|
|
@ -131,6 +131,12 @@ import {
|
|||
import { TeammateRuntimeCompatibilityNotice } from './TeammateRuntimeCompatibilityNotice';
|
||||
import { computeEffectiveTeamModel } from './TeamModelSelector';
|
||||
import { getNextSuggestedTeamName } from './teamNameSets';
|
||||
import {
|
||||
getWorktreeGitBlockingMessage,
|
||||
getWorktreeGitControlDisabledReason,
|
||||
useWorktreeGitReadiness,
|
||||
WorktreeGitReadinessBanner,
|
||||
} from './WorktreeGitReadinessBanner';
|
||||
|
||||
import type { MemberDraft } from '@renderer/components/team/members/MembersEditorSection';
|
||||
|
||||
|
|
@ -549,6 +555,20 @@ export const CreateTeamDialog = ({
|
|||
() => (syncModelsWithLead ? members.map(clearMemberModelOverrides) : members),
|
||||
[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 selectedMemberProviders = useMemo<TeamProviderId[]>(() => {
|
||||
|
|
@ -1425,7 +1445,8 @@ export const CreateTeamDialog = ({
|
|||
isNameProvisioning ||
|
||||
!requestValidation.valid ||
|
||||
!!modelValidationError ||
|
||||
teammateRuntimeCompatibility.blocksSubmission;
|
||||
teammateRuntimeCompatibility.blocksSubmission ||
|
||||
worktreeGitBlocksSubmission;
|
||||
|
||||
const internalArgs = useMemo(() => {
|
||||
const args: string[] = [];
|
||||
|
|
@ -1569,6 +1590,10 @@ export const CreateTeamDialog = ({
|
|||
setLocalError(teammateRuntimeCompatibility.message);
|
||||
return;
|
||||
}
|
||||
if (worktreeGitBlockingMessage) {
|
||||
setLocalError(worktreeGitBlockingMessage);
|
||||
return;
|
||||
}
|
||||
setFieldErrors({});
|
||||
setLocalError(null);
|
||||
setIsSubmitting(true);
|
||||
|
|
@ -1781,6 +1806,7 @@ export const CreateTeamDialog = ({
|
|||
onSyncModelsWithTeammatesChange={handleSyncModelsWithLeadChange}
|
||||
showWorktreeIsolationControls={!soloTeam}
|
||||
teammateWorktreeDefault={teammateWorktreeDefault}
|
||||
worktreeIsolationDisabledReason={worktreeIsolationDisabledReason}
|
||||
onTeammateWorktreeDefaultChange={setTeammateWorktreeDefault}
|
||||
disableGeminiOption={isGeminiUiFrozen()}
|
||||
leadModelIssueText={leadModelIssueText}
|
||||
|
|
@ -1802,17 +1828,22 @@ export const CreateTeamDialog = ({
|
|||
</div>
|
||||
}
|
||||
headerBottom={
|
||||
soloTeam ? (
|
||||
<div className="flex items-start gap-2 rounded-md border border-sky-500/20 bg-sky-500/5 px-3 py-2">
|
||||
<Info className="mt-0.5 size-3.5 shrink-0 text-sky-400" />
|
||||
<p className="text-[11px] leading-relaxed text-sky-300">
|
||||
Only the team lead (main process) will be started — no teammates will be
|
||||
spawned. Works like a regular Claude session but with access to the task board
|
||||
for planning. Saves tokens by avoiding teammate coordination overhead. You can
|
||||
add members later from the team settings.
|
||||
</p>
|
||||
</div>
|
||||
) : null
|
||||
<div className="space-y-2">
|
||||
{soloTeam ? (
|
||||
<div className="flex items-start gap-2 rounded-md border border-sky-500/20 bg-sky-500/5 px-3 py-2">
|
||||
<Info className="mt-0.5 size-3.5 shrink-0 text-sky-400" />
|
||||
<p className="text-[11px] leading-relaxed text-sky-300">
|
||||
Only the team lead (main process) will be started — no teammates will
|
||||
be spawned. Works like a regular Claude session but with access to the task
|
||||
board for planning. Saves tokens by avoiding teammate coordination overhead.
|
||||
You can add members later from the team settings.
|
||||
</p>
|
||||
</div>
|
||||
) : null}
|
||||
{!soloTeam && canCreate ? (
|
||||
<WorktreeGitReadinessBanner state={worktreeGitReadiness} />
|
||||
) : null}
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -140,6 +140,12 @@ import {
|
|||
OPENCODE_TEAM_LEAD_DISABLED_REASON,
|
||||
TeamModelSelector,
|
||||
} from './TeamModelSelector';
|
||||
import {
|
||||
getWorktreeGitBlockingMessage,
|
||||
getWorktreeGitControlDisabledReason,
|
||||
useWorktreeGitReadiness,
|
||||
WorktreeGitReadinessBanner,
|
||||
} from './WorktreeGitReadinessBanner';
|
||||
|
||||
import type { ActiveTeamRef } from './CreateTeamDialog';
|
||||
import type { MemberDraft } from '@renderer/components/team/members/membersEditorTypes';
|
||||
|
|
@ -1282,6 +1288,17 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
|
|||
? ''
|
||||
: selectedProjectPath.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(
|
||||
() =>
|
||||
buildProviderPrepareRuntimeStatusSignature(
|
||||
|
|
@ -1726,13 +1743,21 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
|
|||
const validationErrors = useMemo(() => {
|
||||
const errors: string[] = [];
|
||||
if (!effectiveCwd) errors.push('Working directory is required');
|
||||
if (worktreeGitBlockingMessage) errors.push(worktreeGitBlockingMessage);
|
||||
if (isSchedule) {
|
||||
if (!effectiveTeamName) errors.push('Team is required');
|
||||
if (!promptDraft.value.trim()) errors.push('Prompt is required');
|
||||
if (!cronExpression.trim()) errors.push('Cron expression is required');
|
||||
}
|
||||
return errors;
|
||||
}, [effectiveCwd, isSchedule, effectiveTeamName, promptDraft.value, cronExpression]);
|
||||
}, [
|
||||
effectiveCwd,
|
||||
worktreeGitBlockingMessage,
|
||||
isSchedule,
|
||||
effectiveTeamName,
|
||||
promptDraft.value,
|
||||
cronExpression,
|
||||
]);
|
||||
const modelValidationError = useMemo(() => {
|
||||
const leadError = getTeamModelSelectionError(
|
||||
selectedProviderId,
|
||||
|
|
@ -2422,6 +2447,7 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
|
|||
onSyncModelsWithTeammatesChange={setSyncModelsWithLead}
|
||||
showWorktreeIsolationControls
|
||||
teammateWorktreeDefault={teammateWorktreeDefault}
|
||||
worktreeIsolationDisabledReason={worktreeIsolationDisabledReason}
|
||||
onTeammateWorktreeDefaultChange={setTeammateWorktreeDefault}
|
||||
leadWarningText={leadRuntimeWarningText}
|
||||
memberWarningById={combinedMemberRuntimeWarningById}
|
||||
|
|
@ -2429,6 +2455,7 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
|
|||
memberModelIssueById={memberModelIssueById}
|
||||
softDeleteMembers
|
||||
disableGeminiOption={isGeminiUiFrozen()}
|
||||
headerBottom={<WorktreeGitReadinessBanner state={worktreeGitReadiness} />}
|
||||
/>
|
||||
|
||||
<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;
|
||||
modelIssueText?: string | null;
|
||||
showWorktreeIsolationControls?: boolean;
|
||||
worktreeIsolationDisabledReason?: string | null;
|
||||
onWorktreeIsolationChange?: (id: string, enabled: boolean) => void;
|
||||
lockedModelAction?: {
|
||||
label: string;
|
||||
|
|
@ -124,6 +125,7 @@ export const MemberDraftRow = ({
|
|||
disableGeminiOption = false,
|
||||
modelIssueText,
|
||||
showWorktreeIsolationControls = false,
|
||||
worktreeIsolationDisabledReason,
|
||||
onWorktreeIsolationChange,
|
||||
lockedModelAction,
|
||||
}: MemberDraftRowProps): React.JSX.Element => {
|
||||
|
|
@ -218,6 +220,8 @@ export const MemberDraftRow = ({
|
|||
: lockProviderModel
|
||||
? (lockedModelAction?.description ?? modelLockReason)
|
||||
: undefined;
|
||||
const worktreeIsolationDisabled =
|
||||
isRemoved || Boolean(worktreeIsolationDisabledReason && member.isolation !== 'worktree');
|
||||
const hasModelIssue = Boolean(modelIssueText);
|
||||
const runtimeSummary = formatTeamModelSummary(
|
||||
effectiveProviderId,
|
||||
|
|
@ -349,13 +353,13 @@ export const MemberDraftRow = ({
|
|||
<div
|
||||
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)]',
|
||||
isRemoved && 'cursor-not-allowed opacity-50'
|
||||
worktreeIsolationDisabled && 'cursor-not-allowed opacity-50'
|
||||
)}
|
||||
>
|
||||
<Checkbox
|
||||
id={`member-${member.id}-worktree-isolation`}
|
||||
checked={member.isolation === 'worktree'}
|
||||
disabled={isRemoved}
|
||||
disabled={worktreeIsolationDisabled}
|
||||
onCheckedChange={(checked) =>
|
||||
onWorktreeIsolationChange?.(member.id, checked === true)
|
||||
}
|
||||
|
|
@ -364,7 +368,7 @@ export const MemberDraftRow = ({
|
|||
htmlFor={`member-${member.id}-worktree-isolation`}
|
||||
className={cn(
|
||||
'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" />
|
||||
|
|
@ -373,8 +377,9 @@ export const MemberDraftRow = ({
|
|||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top" className="max-w-64 text-xs leading-relaxed">
|
||||
Run this teammate in a separate git worktree. Apply/reject changes targets that
|
||||
worktree, not the lead workspace.
|
||||
{worktreeIsolationDisabledReason && member.isolation !== 'worktree'
|
||||
? worktreeIsolationDisabledReason
|
||||
: 'Run this teammate in a separate git worktree. Apply/reject changes targets that worktree, not the lead workspace.'}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
) : null}
|
||||
|
|
|
|||
|
|
@ -117,6 +117,7 @@ export interface MembersEditorSectionProps {
|
|||
addMemberLockReason?: string;
|
||||
showWorktreeIsolationControls?: boolean;
|
||||
teammateWorktreeDefault?: boolean;
|
||||
worktreeIsolationDisabledReason?: string | null;
|
||||
onTeammateWorktreeDefaultChange?: (enabled: boolean) => void;
|
||||
}
|
||||
|
||||
|
|
@ -154,6 +155,7 @@ export const MembersEditorSection = ({
|
|||
addMemberLockReason,
|
||||
showWorktreeIsolationControls = false,
|
||||
teammateWorktreeDefault = false,
|
||||
worktreeIsolationDisabledReason,
|
||||
onTeammateWorktreeDefaultChange,
|
||||
}: MembersEditorSectionProps): React.JSX.Element => {
|
||||
const [jsonEditorOpen, setJsonEditorOpen] = useState(false);
|
||||
|
|
@ -247,6 +249,9 @@ export const MembersEditorSection = ({
|
|||
};
|
||||
|
||||
const updateMemberIsolation = (memberId: string, enabled: boolean): void => {
|
||||
if (enabled && worktreeIsolationDisabledReason) {
|
||||
return;
|
||||
}
|
||||
onChange(
|
||||
members.map((c) =>
|
||||
c.id === memberId ? { ...c, isolation: enabled ? 'worktree' : undefined } : c
|
||||
|
|
@ -255,6 +260,9 @@ export const MembersEditorSection = ({
|
|||
};
|
||||
|
||||
const updateTeammateWorktreeDefault = (enabled: boolean): void => {
|
||||
if (enabled && worktreeIsolationDisabledReason) {
|
||||
return;
|
||||
}
|
||||
onTeammateWorktreeDefaultChange?.(enabled);
|
||||
onChange(
|
||||
members.map((member) =>
|
||||
|
|
@ -348,10 +356,14 @@ export const MembersEditorSection = ({
|
|||
{!hideContent && (
|
||||
<>
|
||||
{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
|
||||
id={worktreeDefaultControlId}
|
||||
checked={teammateWorktreeDefault}
|
||||
disabled={Boolean(worktreeIsolationDisabledReason && !teammateWorktreeDefault)}
|
||||
onCheckedChange={(checked) => updateTeammateWorktreeDefault(checked === true)}
|
||||
/>
|
||||
<Label
|
||||
|
|
@ -386,6 +398,7 @@ export const MembersEditorSection = ({
|
|||
onModelChange={updateMemberModel}
|
||||
onEffortChange={updateMemberEffort}
|
||||
showWorktreeIsolationControls={showWorktreeIsolationControls}
|
||||
worktreeIsolationDisabledReason={worktreeIsolationDisabledReason}
|
||||
onWorktreeIsolationChange={updateMemberIsolation}
|
||||
inheritedProviderId={inheritedProviderId}
|
||||
inheritedModel={inheritedModel}
|
||||
|
|
|
|||
|
|
@ -48,6 +48,7 @@ interface TeamRosterEditorSectionProps {
|
|||
memberModelIssueById?: Record<string, string | null | undefined>;
|
||||
showWorktreeIsolationControls?: boolean;
|
||||
teammateWorktreeDefault?: boolean;
|
||||
worktreeIsolationDisabledReason?: string | null;
|
||||
onTeammateWorktreeDefaultChange?: (enabled: boolean) => void;
|
||||
}
|
||||
|
||||
|
|
@ -92,6 +93,7 @@ export const TeamRosterEditorSection = ({
|
|||
memberModelIssueById,
|
||||
showWorktreeIsolationControls = false,
|
||||
teammateWorktreeDefault = false,
|
||||
worktreeIsolationDisabledReason,
|
||||
onTeammateWorktreeDefaultChange,
|
||||
}: TeamRosterEditorSectionProps): React.JSX.Element => {
|
||||
return (
|
||||
|
|
@ -122,6 +124,7 @@ export const TeamRosterEditorSection = ({
|
|||
memberModelIssueById={memberModelIssueById}
|
||||
showWorktreeIsolationControls={showWorktreeIsolationControls}
|
||||
teammateWorktreeDefault={teammateWorktreeDefault}
|
||||
worktreeIsolationDisabledReason={worktreeIsolationDisabledReason}
|
||||
onTeammateWorktreeDefaultChange={onTeammateWorktreeDefaultChange}
|
||||
headerExtra={
|
||||
<div className="space-y-3">
|
||||
|
|
|
|||
|
|
@ -85,6 +85,7 @@ import type {
|
|||
TeamTaskStatus,
|
||||
TeamUpdateConfigRequest,
|
||||
TeamViewSnapshot,
|
||||
TeamWorktreeGitStatus,
|
||||
ToolApprovalEvent,
|
||||
ToolApprovalFileContent,
|
||||
ToolApprovalSettings,
|
||||
|
|
@ -450,6 +451,9 @@ export interface TeamsAPI {
|
|||
limitContext?: boolean,
|
||||
modelVerificationMode?: TeamProvisioningModelVerificationMode
|
||||
) => Promise<TeamProvisioningPrepareResult>;
|
||||
getWorktreeGitStatus: (projectPath: string) => Promise<TeamWorktreeGitStatus>;
|
||||
initializeGitRepository: (projectPath: string) => Promise<TeamWorktreeGitStatus>;
|
||||
createInitialGitCommit: (projectPath: string) => Promise<TeamWorktreeGitStatus>;
|
||||
createTeam: (request: TeamCreateRequest) => Promise<TeamCreateResponse>;
|
||||
getProvisioningStatus: (runId: string) => Promise<TeamProvisioningProgress>;
|
||||
cancelProvisioning: (runId: string) => Promise<void>;
|
||||
|
|
|
|||
|
|
@ -1233,6 +1233,24 @@ export interface TeamProvisioningMemberInput {
|
|||
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 {
|
||||
teamName: string;
|
||||
displayName?: string;
|
||||
|
|
|
|||
|
|
@ -1,4 +1,6 @@
|
|||
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 type {
|
||||
BoardTaskActivityDetailResult,
|
||||
|
|
@ -130,6 +132,7 @@ import {
|
|||
removeTeamHandlers,
|
||||
} from '../../../src/main/ipc/teams';
|
||||
import { ConfigManager } from '../../../src/main/services/infrastructure/ConfigManager';
|
||||
import { getAppDataPath } from '../../../src/main/utils/pathDecoder';
|
||||
|
||||
describe('ipc teams handlers', () => {
|
||||
const handlers = new Map<string, (...args: unknown[]) => Promise<unknown>>();
|
||||
|
|
@ -408,6 +411,43 @@ describe('ipc teams handlers', () => {
|
|||
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 () => {
|
||||
boardTaskExactLogsService.getTaskExactLogSummaries.mockResolvedValueOnce({
|
||||
items: [
|
||||
|
|
|
|||
|
|
@ -67,6 +67,65 @@ async function writeTaskFile(
|
|||
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 {
|
||||
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');
|
||||
await fs.mkdir(projectDir, { recursive: true });
|
||||
await fs.mkdir(projectPath, { recursive: true });
|
||||
await writeOpenCodeDeliveryLedger(tmpDir);
|
||||
|
||||
const backfillOpenCodeTaskLedger = vi.fn(async (input: any) => {
|
||||
const bundleDir = path.join(input.projectDir, '.board-task-changes', 'bundles');
|
||||
|
|
@ -1063,7 +1123,7 @@ describe('ChangeExtractorService', () => {
|
|||
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-'));
|
||||
setClaudeBasePathOverride(tmpDir);
|
||||
await writeTaskFile(tmpDir, { displayId: 'abc12345', owner: 'bob' });
|
||||
|
|
@ -1071,6 +1131,7 @@ describe('ChangeExtractorService', () => {
|
|||
const projectPath = path.join(tmpDir, 'repo');
|
||||
await fs.mkdir(projectDir, { recursive: true });
|
||||
await fs.mkdir(projectPath, { recursive: true });
|
||||
await writeOpenCodeDeliveryLedger(tmpDir);
|
||||
const pendingBackfill = deferred<any>();
|
||||
const backfillOpenCodeTaskLedger = vi.fn(() => pendingBackfill.promise);
|
||||
const workerClient = {
|
||||
|
|
@ -1105,10 +1166,13 @@ describe('ChangeExtractorService', () => {
|
|||
{ 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(() => {
|
||||
expect(backfillOpenCodeTaskLedger).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
|
|
@ -1116,10 +1180,14 @@ describe('ChangeExtractorService', () => {
|
|||
taskId: TASK_ID,
|
||||
projectDir,
|
||||
workspaceRoot: projectPath,
|
||||
deliveryContextPath: expect.stringContaining('delivery-context.json'),
|
||||
attributionMode: 'strict-delivery',
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
expect(settled).toBe(false);
|
||||
expect(workerClient.computeTaskChanges).not.toHaveBeenCalled();
|
||||
pendingBackfill.resolve({
|
||||
schemaVersion: 1,
|
||||
providerId: 'opencode',
|
||||
|
|
@ -1137,6 +1205,10 @@ describe('ChangeExtractorService', () => {
|
|||
notices: [],
|
||||
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 () => {
|
||||
|
|
@ -1202,51 +1274,17 @@ describe('ChangeExtractorService', () => {
|
|||
owner: 'bob',
|
||||
status: 'completed',
|
||||
});
|
||||
expect(backfillOpenCodeTaskLedger).toHaveBeenCalledTimes(1);
|
||||
expect(backfillOpenCodeTaskLedger.mock.calls[0]?.[0]?.deliveryContextPath).toBeUndefined();
|
||||
expect(backfillOpenCodeTaskLedger).not.toHaveBeenCalled();
|
||||
|
||||
const deliveryLedgerPath = path.join(
|
||||
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 writeOpenCodeDeliveryLedger(tmpDir);
|
||||
|
||||
await service.getTaskChanges(TEAM_NAME, TASK_ID, {
|
||||
owner: 'bob',
|
||||
status: 'completed',
|
||||
});
|
||||
|
||||
expect(backfillOpenCodeTaskLedger).toHaveBeenCalledTimes(2);
|
||||
expect(backfillOpenCodeTaskLedger.mock.calls[1]?.[0]?.deliveryContextPath).toEqual(
|
||||
expect(backfillOpenCodeTaskLedger).toHaveBeenCalledTimes(1);
|
||||
expect(backfillOpenCodeTaskLedger.mock.calls[0]?.[0]?.deliveryContextPath).toEqual(
|
||||
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 () => {
|
||||
const svc = new TeamProvisioningService();
|
||||
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),
|
||||
replaceMembers: 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: {
|
||||
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