(
sourceWorkspaceId: string,
newName?: string,
sourceMessageId?: string,
pendingAutoTitle?: boolean
)
| 6396 | } |
| 6397 | |
| 6398 | async fork( |
| 6399 | sourceWorkspaceId: string, |
| 6400 | newName?: string, |
| 6401 | sourceMessageId?: string, |
| 6402 | pendingAutoTitle?: boolean |
| 6403 | ): Promise<Result<{ metadata: FrontendWorkspaceMetadata; projectPath: string }>> { |
| 6404 | try { |
| 6405 | const sourceMetadataResult = await this.aiService.getWorkspaceMetadata(sourceWorkspaceId); |
| 6406 | if (!sourceMetadataResult.success) { |
| 6407 | return Err(`Failed to get source workspace metadata: ${sourceMetadataResult.error}`); |
| 6408 | } |
| 6409 | const sourceMetadata = sourceMetadataResult.data; |
| 6410 | const partialSnapshot = |
| 6411 | sourceMessageId == null ? await this.historyService.readPartial(sourceWorkspaceId) : null; |
| 6412 | const foundProjectPath = sourceMetadata.projectPath; |
| 6413 | const projectName = sourceMetadata.projectName; |
| 6414 | const sourceRuntimeConfig = sourceMetadata.runtimeConfig; |
| 6415 | |
| 6416 | // Policy: do not allow creating new workspaces (including via fork) with a disallowed runtime. |
| 6417 | if (this.policyService?.isEnforced()) { |
| 6418 | if (!this.policyService.isRuntimeAllowed(sourceRuntimeConfig)) { |
| 6419 | return Err("Forking this workspace is not allowed by policy (runtime disabled)"); |
| 6420 | } |
| 6421 | } |
| 6422 | |
| 6423 | // Trust gate: block fork for untrusted projects. |
| 6424 | // Same defense-in-depth as create() — the frontend shows a dialog, |
| 6425 | // but forking is a secondary creation path that needs backend gating. |
| 6426 | const projectConfig = this.config |
| 6427 | .loadConfigOrDefault() |
| 6428 | .projects.get(stripTrailingSlashes(foundProjectPath)); |
| 6429 | if (!projectConfig?.trusted) { |
| 6430 | return Err( |
| 6431 | "This project must be trusted before creating workspaces. Trust the project in Settings → Security, or create a workspace from the project page." |
| 6432 | ); |
| 6433 | } |
| 6434 | |
| 6435 | // Auto-generate branch name (and title) when user omits one (seamless fork). |
| 6436 | // Uses pattern: {parentName}-{N} for branch, "{parentTitle} (N)" for title. |
| 6437 | const isAutoName = newName == null; |
| 6438 | // Fetch all metadata upfront for both branch name and title collision checks. |
| 6439 | const allMetadata = isAutoName ? await this.config.getAllWorkspaceMetadata() : []; |
| 6440 | let resolvedName: string; |
| 6441 | if (isAutoName) { |
| 6442 | const existingNamesSet = new Set( |
| 6443 | allMetadata.filter((m) => m.projectPath === foundProjectPath).map((m) => m.name) |
| 6444 | ); |
| 6445 | // Also include local branch names to avoid silently reusing stale branches that |
| 6446 | // were left behind on disk but no longer exist in config metadata. |
| 6447 | try { |
| 6448 | for (const branchName of await listLocalBranches(foundProjectPath)) { |
| 6449 | existingNamesSet.add(branchName); |
| 6450 | } |
| 6451 | } catch (error) { |
| 6452 | log.debug("Failed to list local branches for fork auto-name preflight", { |
| 6453 | projectPath: foundProjectPath, |
| 6454 | error: getErrorMessage(error), |
| 6455 | }); |
no test coverage detected