Clone project host setup from composer

Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
Jinwoo-H
2026-06-11 12:33:59 -07:00
co-authored by Orca
parent 7951a1f3c1
commit b57f9e4914
14 changed files with 868 additions and 20 deletions
@@ -317,8 +317,9 @@ Needs:
Current branch status: partially implemented. Ready project-host setups can be
selected from the composer `Run on` menu, and known hosts that still need setup
now appear as setup-target rows instead of being invisible. The composer can
import an existing folder for those hosts and then switch to the new ready
setup. Inline clone/provision actions for those rows are still missing.
import an existing folder for those hosts and can clone onto non-SSH
local/runtime hosts, then switch to the new ready setup. SSH clone and
provisioning actions are still missing.
### 5. Project Setup Flow
@@ -335,7 +336,9 @@ Needs:
Current branch status: existing-folder setup is partially implemented through
API/CLI plumbing and a project settings import form for known local, SSH, and
active runtime hosts. Clone, provisioning, and bulk setup are not complete.
active runtime hosts. Composer clone setup is implemented for local/runtime
hosts by composing the existing clone APIs with existing-folder setup. SSH
clone, provisioning, and bulk setup are not complete.
### 6. Sidebar Row Model
@@ -479,11 +482,12 @@ Already partially landed:
set up yet
- inline `Run on` import-existing-folder setup for known hosts where the
selected project is not set up yet
- inline `Run on` clone setup for not-yet-set-up non-SSH local/runtime hosts
Not complete yet:
- clone/provision flows
- inline clone/provision actions from the composer `Run on` menu
- SSH clone/provision flows
- durable cloned setup metadata while `Repo` remains the compatibility source
- bulk setup flows and setup for hosts that are not already known to the client
- project settings split into global and host-specific ownership
- host settings/capability UI aligned with project setup
+429
View File
@@ -0,0 +1,429 @@
# Project-First Host Model Handoff
## What We Discussed
We started from the multi-host / VM / SSH work that made machines visible in
the sidebar. That model is useful operationally:
```text
Host
Project
Workspace
```
It answers questions like which host is online, where a workspace is running,
and whether an SSH target is disconnected. But as the default durable product
model, it makes Orca feel like the user is switching between machine silos.
The direction we converged on is project-first:
```text
Project
Host setup
Workspace
```
In plain English: a host is a place where a project can run. The host should not
usually be the object that owns the user's project.
## Decision
Move Orca toward this durable model:
```text
Project -> ProjectHostSetup -> Workspace
```
Where:
- `Project` is the durable repo/project identity the user recognizes.
- `Host` is a local Mac, SSH target, remote runtime, VM, remote server, or
future Orca-provisioned cloud VM.
- `ProjectHostSetup` means "this project is available on this host at this
path, with this setup state and host-specific settings."
- `Workspace` is a branch/task/worktree running from one project setup on one
host.
This model handles local-only projects, remote-only projects, Linux-only
projects, GPU-only projects, work-machine-only projects, and future cloud VMs
without changing the user's primary mental model.
## Superset Comparison
Superset is the closer reference for Orca's desired data model.
The relevant Superset shape is:
```text
Project + Host -> Workspace
```
Lessons for Orca:
- Project identity is durable.
- A host is where a project can be materialized.
- Workspace creation targets both a project and a host.
- The same project can be set up on multiple hosts.
- If the project is not available on a host, creation can block and offer setup.
- Project settings can include host-specific location and worktree settings.
That maps well to Orca because Orca already has projects/repos, worktrees,
agents, terminals, source control, and host-aware runtime routing.
## Cmux Comparison
Cmux is more session/workspace-first than project-first.
The relevant Cmux shape is closer to:
```text
Workspace/session -> local or remote execution context
```
Lessons for Orca:
- SSH should feel first class.
- Remote terminals, file views, browser panes, and localhost routing should
follow the remote execution context.
- Reconnect and persistence behavior matter.
- Cmux is a useful reference for SSH/session polish, not for Orca's durable
project/setup data model.
## UX Direction
### Sidebar
The default long-term sidebar should be project-first.
For a single-host project, avoid noisy host nesting:
```text
Orca
feature-a
feature-b
```
For a multi-host project, show host context only where it helps:
```text
Orca
Local Mac
feature-a
openclaw 2
fix-ssh-agent-status
```
Host-first grouping can remain as a filter or operational view. It is valuable
for seeing online/offline state and troubleshooting, but it should not be the
only durable organization model.
Disconnected host behavior should distinguish configured hosts from relevant
project hosts:
- Show a disconnected host when it has setup history or workspaces for the
current project.
- Do not show a never-used SSH target in the project sidebar just because it is
configured.
- Use clear disconnected wording/actions instead of only a gray status dot.
### Workspace Creation
Creating a workspace should ask:
1. Which project?
2. Which host should run it?
3. What branch/task/workspace name?
If the project is not set up on the selected host, Orca should offer:
- clone the project to that host
- import an existing folder on that host
- select a different host
The user should not need to understand compatibility repo ids.
### Project Setup
"Add project" and "make this project available on another host" are related but
different actions.
Important flows:
- import a local folder as a new project
- import an SSH folder as a new project
- set up an existing project on another host
- clone an existing project onto a selected host
- when adding a new host, optionally initialize one or more projects there
- later, provision an Orca cloud VM and materialize selected projects there
### Settings
Settings need explicit ownership:
| Setting type | Owner | Examples |
| --- | --- | --- |
| Client setting | desktop client | theme, local UI preferences |
| Host setting | machine/runtime | SSH connection, display name, health, server version |
| Project setting | durable project | project name, provider identity |
| Project-host setup setting | project on one host | checkout path, worktree base path, setup script |
A host dropdown or host table inside project settings is likely enough for
host-specific project settings, similar to existing Windows/WSL-specific
settings patterns.
## How Many Things Need To Change?
The short answer is: **12 major change surfaces** need to fit the new model.
Some are already partially implemented on this branch. The model is only
complete when all 12 speak the same project-first language.
## The 12 Change Surfaces
### 1. Shared Data Model
`Repo` currently mixes durable project identity with host-local checkout facts.
Needs:
- first-class `Project`
- first-class `Host`
- first-class `ProjectHostSetup`
- explicit workspace ownership by `projectId`, `hostId`, and
`projectHostSetupId`
- repo-shaped compatibility projection while older APIs remain
Status: partially implemented.
### 2. Persistence And Migration
Existing users need a boring migration.
Needs:
- derive one project per reliable durable identity
- derive one host setup per existing repo checkout
- avoid merging same-name folders unless provider/setup identity is reliable
- preserve old ids or aliases where compatibility requires it
- backfill existing workspaces with project/setup ownership when safe
Status: partially implemented.
### 3. Runtime And Request Ownership
The UI can be project-first, but execution still happens on a host.
Needs:
- route terminals, agents, filesystem, browser, source control, hooks, and
automations through the workspace's owning host
- avoid using the currently focused host as a hidden global default for
workspace-owned operations
- scope cancellation and stale-response handling to the host/setup that owns the
request
Status: partially implemented by multi-host groundwork; still needs an audit.
### 4. Workspace Creation
Creation must target a project and host, not just a repo id.
Needs:
- project picker
- run-on host picker
- unavailable-host reasons
- inline clone/import setup actions
- compatibility resolver from `{ projectId, hostId }` to current repo/setup
internals while old APIs remain
Status: partially implemented. Ready setups and setup-target rows exist in the
composer; inline import exists; inline clone is wired for non-SSH local/runtime
hosts; SSH clone and provisioning remain.
### 5. Project Setup Flow
"Add repo" becomes a family of project/setup flows.
Needs:
- import existing folder on local host
- import existing folder over SSH
- clone project onto selected host
- set up an existing project on another host
- bulk setup when adding a new host
- future cloud provisioning hook
Status: existing-folder setup is partially implemented; clone-on-host is wired
for local/runtime from the composer; SSH clone, provisioning, and bulk setup
remain.
### 6. Sidebar Row Model
The sidebar should be built from projects, hosts, setups, and workspaces.
Needs:
- project-first grouping
- host labels/subgroups only when useful
- host filters and online/offline status retained
- clear disconnected-host behavior
- drag/reorder rules for projects, host sections, and workspaces
Status: partially implemented. Default all-host Projects view keeps projects
outermost; mixed-host cards show host context; disconnected-host polish remains.
### 7. Project Settings
Project settings need a global area plus host-specific setup sections.
Needs:
- project-global settings
- host-specific paths, worktree paths, setup scripts, and platform constraints
- host dropdown/table inside settings
- provider-neutral source-control settings
Status: in progress. Available-hosts and setup-specific navigation exist; full
ownership split remains.
### 8. Host Settings
Host settings should describe the machine/runtime, not duplicate every project
setting.
Needs:
- connection details
- display name
- health/status
- server version and protocol compatibility
- platform/capabilities
- host-wide defaults and overrides
Status: multi-host settings groundwork exists; needs alignment with
project-host setup settings.
### 9. Version And Capability Compatibility
New clients and old servers will coexist.
Needs:
- host capability probing
- fallback projection when project/setup APIs are missing
- disabled states with specific reasons
- structured errors for unsupported old-server actions
- old client / new server behavior that degrades safely
Status: runtime compatibility exists; project/setup capability gating remains.
### 10. Caches And Local State
Some caches are project-global; many are host/setup-local.
Needs:
- classify caches as project, host, setup, or workspace scoped
- include host/setup ids in cache keys for refs, git status, filesystem state,
capabilities, terminals, browser sessions, and remote results
- prevent a response from one host from overwriting another host's state for the
same project
Status: partially addressed for host partitioning; needs a project/setup audit.
### 11. CLI And API
External commands should speak project-first language.
Needs:
- `orca project list`
- `orca project setups`
- `orca project setup-existing-folder --project <id> --host <id> --path <path>`
- `orca worktree create --project <id> --host <id> ...`
- `orca worktree create --project-host-setup <id> ...`
- compatibility aliases for old repo/worktree commands
- structured availability errors
Status: partially implemented.
### 12. Tests And Verification
This crosses storage, routing, UI, SSH, and compatibility.
Needs:
- migration tests
- selector/sidebar grouping tests
- create-workspace tests
- setup-on-host tests
- settings ownership tests
- SSH end-to-end validation
- version mismatch tests
- Electron validation for sidebar, creation, and settings
Status: model/API tests exist; full end-to-end UI, SSH, and version-skew
validation remain.
## Implementation Shape
Use an additive migration:
1. Keep existing `Repo` APIs alive while adding project/setup records.
2. Use conservative identity matching so only reliably linked checkouts merge
into one project.
3. Teach creation flows to resolve `{ projectId, hostId }` into the current
backend through a ready setup.
4. Add setup-on-host flows for local, SSH, runtime, and future cloud hosts.
5. Split settings into client, host, project, and project-host setup ownership.
6. Audit host-local caches and runtime routing.
7. Move visible default UX to project-first once behavior is real.
8. Preserve a host-first operational view/filter for troubleshooting.
9. Validate migration, creation, settings, sidebar, SSH, and version skew before
calling the model complete.
## Current Branch Status
Already partially landed:
- shared `Project` and `ProjectHostSetup` types
- compatibility projection from existing `Repo[]`
- persisted compatibility fields for projects and project-host setups
- renderer hydration and fallback for older runtimes
- project-aware sidebar grouping in existing repo grouping paths
- default all-host Projects sidebar keeps projects outermost
- host context badges for mixed-host project groups
- project-host-aware workspace creation target resolver
- optional workspace metadata for `projectId`, `hostId`, and
`projectHostSetupId`
- setup-existing-folder API plumbing through local IPC/preload/runtime paths
- project settings existing-folder setup form for known local, SSH, and active
runtime hosts
- CLI commands for listing projects/setups and creating worktrees by
project/setup
- composer `Run on` selector when a project has multiple ready setups
- setup-target `Run on` rows for known hosts where the selected project is not
set up yet
- inline import-existing-folder setup from the composer
- inline clone setup from the composer for not-yet-set-up non-SSH
local/runtime hosts
Still needed:
- finish SSH clone/provision flows
- durable cloned setup metadata while `Repo` remains the compatibility source
- bulk setup flows and setup for newly added hosts
- project settings split into global and host-specific ownership
- host settings/capability UI aligned with project setup
- complete cache/request ownership audit
- complete version-skew capability gating
- full Electron and SSH end-to-end validation
## Final Product Position
Keep hosts visible, but do not make them the durable outermost object.
Projects should be the top-level user concept. Hosts, VMs, SSH targets, remote
servers, and future Orca cloud machines are places where a project can be set up
and where a workspace can run.
+18 -7
View File
@@ -750,12 +750,18 @@ Landed so far:
- Added an inline composer action for those not-yet-set-up hosts to import an
existing folder on that host. After import, the composer switches to the new
ready setup so workspace creation can continue on that host.
- Added an inline composer clone action for not-yet-set-up non-SSH hosts. It
seeds a GitHub HTTPS clone URL when durable project identity is available,
lets the user paste a different URL, clones into the selected parent
directory through the existing local/runtime clone APIs, then links the
resulting checkout as the project's host setup.
- Added tests for local repos, SSH repos, same-provider multi-host grouping,
no-identity same-name non-grouping, selector cache behavior, persistence
backfill, repo mutation synchronization, renderer hydration, and runtime RPC
routing. Sidebar row-builder tests now cover project-first multi-host grouping
and same-name repo separation without project identity. Workspace target tests
cover local-only fallback, focused-host setup selection, explicit
backfill, repo mutation synchronization, renderer hydration, runtime RPC
routing, local/runtime clone setup composition, and GitHub clone URL
inference. Sidebar row-builder tests now cover project-first multi-host
grouping and same-name repo separation without project identity. Workspace
target tests cover local-only fallback, focused-host setup selection, explicit
project-plus-host resolution, same-name non-merging, and unavailable setup
reasons.
@@ -768,13 +774,18 @@ Important limitation:
through their repo compatibility record. Settings now expose setup-specific
host panes and existing-folder setup, but still use repo compatibility records
underneath.
- SSH clone setup is not implemented yet. SSH hosts can import an existing
folder today; true SSH clone should either reuse a remote git-provider exec
path or add a remote clone flow with progress, abort, safe path derivation,
cleanup, and auth parity.
- The current clone setup action records the resulting checkout by calling the
existing-folder setup path, so durable `setupMethod: cloned` semantics are not
complete while the compatibility projection remains repo-backed.
Remaining end-to-end work:
- add inline clone/provision actions for `Run on` hosts where the project is not
set up yet
- broaden setup-on-host flows beyond known local, SSH, and active runtime hosts
- add clone/provision setup-on-host flows
- finish SSH clone, provisioning, and bulk setup-on-host flows
- split settings into explicit client, host, project, and project-host setup
scopes
- validate the default project-first sidebar view in Electron and continue
@@ -74,6 +74,12 @@ type NewWorkspaceComposerCardProps = {
path: string,
kind: 'git' | 'folder'
) => Promise<boolean>
projectHostCloneUrl: string | null
onProjectHostCloneSetup: (
option: NeedsSetupProjectHostOption,
url: string,
destination: string
) => Promise<boolean>
primaryActionLabel: string
name: string
onNameValueChange: (value: string) => void
@@ -258,6 +264,8 @@ export default function NewWorkspaceComposerCard({
selectedProjectHostSetupId,
onProjectHostSetupChange,
onProjectHostExistingFolderSetup,
projectHostCloneUrl,
onProjectHostCloneSetup,
primaryActionLabel,
name,
onNameValueChange,
@@ -302,6 +310,9 @@ export default function NewWorkspaceComposerCard({
const [pendingSetupPath, setPendingSetupPath] = React.useState('')
const [pendingSetupKind, setPendingSetupKind] = React.useState<'git' | 'folder'>('git')
const [pendingSetupImporting, setPendingSetupImporting] = React.useState(false)
const [pendingCloneUrl, setPendingCloneUrl] = React.useState('')
const [pendingCloneDestination, setPendingCloneDestination] = React.useState('')
const [pendingCloneImporting, setPendingCloneImporting] = React.useState(false)
const openModal = useAppStore((s) => s.openModal)
const activeModal = useAppStore((s) => s.activeModal)
const defaultTuiAgent = useAppStore((s) => s.settings?.defaultTuiAgent ?? null)
@@ -418,9 +429,13 @@ export default function NewWorkspaceComposerCard({
},
[onProjectHostSetupChange]
)
const handleNeedsSetupHostSelect = React.useCallback((option: NeedsSetupProjectHostOption) => {
setPendingSetupHostOptionId(option.id)
}, [])
const handleNeedsSetupHostSelect = React.useCallback(
(option: NeedsSetupProjectHostOption) => {
setPendingSetupHostOptionId(option.id)
setPendingCloneUrl(projectHostCloneUrl ?? '')
},
[projectHostCloneUrl]
)
const handleImportExistingFolder = React.useCallback(async (): Promise<void> => {
if (!pendingSetupHostOption || !pendingSetupPath.trim()) {
return
@@ -441,6 +456,34 @@ export default function NewWorkspaceComposerCard({
setPendingSetupImporting(false)
}
}, [onProjectHostExistingFolderSetup, pendingSetupHostOption, pendingSetupKind, pendingSetupPath])
const canClonePendingSetup =
!!pendingSetupHostOption && !pendingSetupHostOption.hostId.startsWith('ssh:')
const handleCloneProjectHost = React.useCallback(async (): Promise<void> => {
if (!pendingSetupHostOption || !pendingCloneUrl.trim() || !pendingCloneDestination.trim()) {
return
}
setPendingCloneImporting(true)
try {
const imported = await onProjectHostCloneSetup(
pendingSetupHostOption,
pendingCloneUrl.trim(),
pendingCloneDestination.trim()
)
if (imported) {
setPendingSetupHostOptionId(null)
setPendingCloneUrl(projectHostCloneUrl ?? '')
setPendingCloneDestination('')
}
} finally {
setPendingCloneImporting(false)
}
}, [
onProjectHostCloneSetup,
pendingCloneDestination,
pendingCloneUrl,
pendingSetupHostOption,
projectHostCloneUrl
])
useContextualTour(
'workspace-creation',
eligibleRepos.length > 0 && Boolean(repoId),
@@ -542,10 +585,67 @@ export default function NewWorkspaceComposerCard({
<div className="text-xs font-medium text-foreground">
{translate(
'auto.components.NewWorkspaceComposerCard.setupHostExistingFolderTitle',
'Import existing folder on {{value0}}',
'Set up {{value0}}',
{ value0: pendingSetupHostOption.label }
)}
</div>
{canClonePendingSetup ? (
<div className="space-y-2 rounded-sm border border-border/70 bg-background/50 p-2">
<div className="text-[11px] font-medium text-muted-foreground">
{translate(
'auto.components.NewWorkspaceComposerCard.cloneProjectOnHost',
'Clone project'
)}
</div>
<Input
value={pendingCloneUrl}
onChange={(event) => setPendingCloneUrl(event.target.value)}
placeholder={translate(
'auto.components.NewWorkspaceComposerCard.cloneUrlPlaceholder',
'https://github.com/owner/repo.git'
)}
className="h-8 min-w-0 text-xs"
/>
<div className="grid min-w-0 gap-2 sm:grid-cols-[minmax(0,1fr)_auto]">
<Input
value={pendingCloneDestination}
onChange={(event) => setPendingCloneDestination(event.target.value)}
placeholder={translate(
'auto.components.NewWorkspaceComposerCard.cloneDestinationPlaceholder',
'/parent/directory/on/host'
)}
className="h-8 min-w-0 text-xs"
/>
<Button
type="button"
size="xs"
disabled={
!pendingCloneUrl.trim() ||
!pendingCloneDestination.trim() ||
pendingCloneImporting
}
onClick={() => void handleCloneProjectHost()}
className="shrink-0"
>
{pendingCloneImporting
? translate(
'auto.components.NewWorkspaceComposerCard.cloningHostSetup',
'Cloning...'
)
: translate(
'auto.components.NewWorkspaceComposerCard.cloneHostSetup',
'Clone'
)}
</Button>
</div>
</div>
) : null}
<div className="text-[11px] font-medium text-muted-foreground">
{translate(
'auto.components.NewWorkspaceComposerCard.importExistingFolderOnHost',
'Import existing folder'
)}
</div>
<div className="grid min-w-0 gap-2 sm:grid-cols-[minmax(0,1fr)_7rem]">
<Input
value={pendingSetupPath}
@@ -89,6 +89,7 @@ import {
type NeedsSetupProjectHostOption,
type ProjectHostSetupOption
} from '@/lib/project-host-setup-options'
import { getProjectHostCloneUrl } from '@/lib/project-host-clone-url'
import { buildExecutionHostRegistry } from '../../../shared/execution-host-registry'
import { getHostDisplayLabelOverrides } from '../../../shared/host-setting-overrides'
import { queueNewWorkspaceTerminalFocus } from '@/lib/new-workspace-terminal-focus'
@@ -165,6 +166,12 @@ export type ComposerCardProps = {
path: string,
kind: 'git' | 'folder'
) => Promise<boolean>
projectHostCloneUrl: string | null
onProjectHostCloneSetup: (
option: NeedsSetupProjectHostOption,
url: string,
destination: string
) => Promise<boolean>
name: string
onNameValueChange: (value: string) => void
onSmartGitHubItemSelect: (item: GitHubWorkItem) => void
@@ -311,6 +318,7 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS
openSettingsPage: s.openSettingsPage,
openSettingsTarget: s.openSettingsTarget,
setupProjectExistingFolder: s.setupProjectExistingFolder,
setupProjectClone: s.setupProjectClone,
prefetchWorktreeCreateBase: s.prefetchWorktreeCreateBase,
prefetchWorkItems: s.prefetchWorkItems,
fetchSparsePresets: s.fetchSparsePresets
@@ -326,6 +334,7 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS
openSettingsPage,
openSettingsTarget,
setupProjectExistingFolder,
setupProjectClone,
prefetchWorktreeCreateBase,
prefetchWorkItems,
fetchSparsePresets
@@ -390,6 +399,10 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS
const selectedRepo = eligibleRepos.find((repo) => repo.id === repoId)
const selectedProjectId =
selectedWorkspaceTarget.status === 'ready' ? selectedWorkspaceTarget.target.projectId : null
const selectedProject = selectedProjectId
? (projects.find((project) => project.id === selectedProjectId) ?? null)
: null
const projectHostCloneUrl = getProjectHostCloneUrl(selectedProject)
const selectedProjectHostSetupId =
selectedWorkspaceTarget.status === 'ready'
? selectedWorkspaceTarget.target.projectHostSetupId
@@ -1732,6 +1745,27 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS
},
[handleRepoChange, selectedRepo?.displayName, setupProjectExistingFolder]
)
const handleProjectHostCloneSetup = useCallback(
async (
option: NeedsSetupProjectHostOption,
url: string,
destination: string
): Promise<boolean> => {
const result = await setupProjectClone({
projectId: option.projectId,
hostId: option.hostId,
url,
destination,
displayName: selectedRepo?.displayName
})
if (!result) {
return false
}
handleRepoChange(result.repo.id)
return true
},
[handleRepoChange, selectedRepo?.displayName, setupProjectClone]
)
const showProjectRequiredError = useCallback((): void => {
setProjectError('Choose or add a project before creating a workspace.')
@@ -2602,6 +2636,8 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS
selectedProjectHostSetupId,
onProjectHostSetupChange: handleProjectHostSetupChange,
onProjectHostExistingFolderSetup: handleProjectHostExistingFolderSetup,
projectHostCloneUrl,
onProjectHostCloneSetup: handleProjectHostCloneSetup,
name,
onNameValueChange: handleNameValueChange,
onSmartGitHubItemSelect: handleSmartGitHubItemSelect,
+7 -1
View File
@@ -994,7 +994,13 @@
"e5db1b0419": "Combined setup command",
"runOn": "Run on",
"addProjectBeforeWorkspace": "Add a project before creating a workspace.",
"setupHostExistingFolderTitle": "Import existing folder on {{value0}}",
"setupHostExistingFolderTitle": "Set up {{value0}}",
"cloneProjectOnHost": "Clone project",
"cloneUrlPlaceholder": "https://github.com/owner/repo.git",
"cloneDestinationPlaceholder": "/parent/directory/on/host",
"cloningHostSetup": "Cloning...",
"cloneHostSetup": "Clone",
"importExistingFolderOnHost": "Import existing folder",
"setupHostExistingFolderPlaceholder": "/path/to/project/on/host",
"setupKindGit": "Git repo",
"setupKindFolder": "Folder",
+7 -1
View File
@@ -994,7 +994,13 @@
"e5db1b0419": "組み合わせセットアップコマンド",
"runOn": "実行先",
"addProjectBeforeWorkspace": "ワークスペースを作成する前にプロジェクトを追加します。",
"setupHostExistingFolderTitle": "Import existing folder on {{value0}}",
"setupHostExistingFolderTitle": "Set up {{value0}}",
"cloneProjectOnHost": "Clone project",
"cloneUrlPlaceholder": "https://github.com/owner/repo.git",
"cloneDestinationPlaceholder": "/parent/directory/on/host",
"cloningHostSetup": "Cloning...",
"cloneHostSetup": "Clone",
"importExistingFolderOnHost": "Import existing folder",
"setupHostExistingFolderPlaceholder": "/path/to/project/on/host",
"setupKindGit": "Git repo",
"setupKindFolder": "Folder",
+7 -1
View File
@@ -994,7 +994,13 @@
"e5db1b0419": "결합된 설정 명령",
"runOn": "실행 위치",
"addProjectBeforeWorkspace": "워크스페이스를 만들기 전에 프로젝트를 추가하세요.",
"setupHostExistingFolderTitle": "Import existing folder on {{value0}}",
"setupHostExistingFolderTitle": "Set up {{value0}}",
"cloneProjectOnHost": "Clone project",
"cloneUrlPlaceholder": "https://github.com/owner/repo.git",
"cloneDestinationPlaceholder": "/parent/directory/on/host",
"cloningHostSetup": "Cloning...",
"cloneHostSetup": "Clone",
"importExistingFolderOnHost": "Import existing folder",
"setupHostExistingFolderPlaceholder": "/path/to/project/on/host",
"setupKindGit": "Git repo",
"setupKindFolder": "Folder",
+7 -1
View File
@@ -994,7 +994,13 @@
"e5db1b0419": "组合设置命令",
"runOn": "运行位置",
"addProjectBeforeWorkspace": "在创建工作区之前添加项目。",
"setupHostExistingFolderTitle": "Import existing folder on {{value0}}",
"setupHostExistingFolderTitle": "Set up {{value0}}",
"cloneProjectOnHost": "Clone project",
"cloneUrlPlaceholder": "https://github.com/owner/repo.git",
"cloneDestinationPlaceholder": "/parent/directory/on/host",
"cloningHostSetup": "Cloning...",
"cloneHostSetup": "Clone",
"importExistingFolderOnHost": "Import existing folder",
"setupHostExistingFolderPlaceholder": "/path/to/project/on/host",
"setupKindGit": "Git repo",
"setupKindFolder": "Folder",
@@ -0,0 +1,46 @@
import { describe, expect, it } from 'vitest'
import type { Project } from '../../../shared/types'
import { getProjectHostCloneUrl } from './project-host-clone-url'
function createProject(overrides: Partial<Project> = {}): Project {
return {
id: 'project-1',
displayName: 'Project',
badgeColor: '#000',
sourceRepoIds: ['repo-1'],
createdAt: 1,
updatedAt: 1,
...overrides
}
}
describe('getProjectHostCloneUrl', () => {
it('builds a GitHub HTTPS clone URL from provider identity', () => {
expect(
getProjectHostCloneUrl(
createProject({
providerIdentity: {
provider: 'github',
owner: ' stablyai ',
repo: ' orca '
}
})
)
).toBe('https://github.com/stablyai/orca.git')
})
it('returns null when provider identity is missing or incomplete', () => {
expect(getProjectHostCloneUrl(createProject())).toBeNull()
expect(
getProjectHostCloneUrl(
createProject({
providerIdentity: {
provider: 'github',
owner: '',
repo: 'orca'
}
})
)
).toBeNull()
})
})
@@ -0,0 +1,14 @@
import type { Project } from '../../../shared/types'
export function getProjectHostCloneUrl(project: Project | null | undefined): string | null {
const identity = project?.providerIdentity
if (!identity || identity.provider !== 'github') {
return null
}
const owner = identity.owner.trim()
const repo = identity.repo.trim()
if (!owner || !repo) {
return null
}
return `https://github.com/${owner}/${repo}.git`
}
+130
View File
@@ -36,6 +36,7 @@ const sshRepo: Repo = {
const reposList = vi.fn()
const reposAdd = vi.fn()
const reposPickFolder = vi.fn()
const reposClone = vi.fn()
const reposRemove = vi.fn()
const reposUpdate = vi.fn()
const reposReorder = vi.fn()
@@ -50,6 +51,7 @@ beforeEach(() => {
reposList.mockReset()
reposAdd.mockReset()
reposPickFolder.mockReset()
reposClone.mockReset()
reposRemove.mockReset()
reposUpdate.mockReset()
reposReorder.mockReset()
@@ -66,6 +68,7 @@ beforeEach(() => {
repos: {
list: reposList,
add: reposAdd,
clone: reposClone,
pickFolder: reposPickFolder,
remove: reposRemove,
update: reposUpdate,
@@ -410,6 +413,133 @@ describe('repo slice runtime routing', () => {
expect(runtimeEnvironmentCall).not.toHaveBeenCalled()
})
it('clones a project locally before aligning it as a host setup', async () => {
const project: Project = {
id: 'project-1',
displayName: 'Project',
badgeColor: '#000',
sourceRepoIds: ['local-repo'],
createdAt: 1,
updatedAt: 1
}
const clonedRepo = { ...localRepo, path: '/workspace/project' }
const setup: ProjectHostSetup = {
id: clonedRepo.id,
projectId: project.id,
hostId: 'local',
repoId: clonedRepo.id,
path: clonedRepo.path,
displayName: clonedRepo.displayName,
setupState: 'ready',
setupMethod: 'legacy-repo',
createdAt: 1,
updatedAt: 1
}
reposClone.mockResolvedValue(clonedRepo)
projectsSetupExistingFolder.mockResolvedValue({ project, setup, repo: clonedRepo })
const store = createTestStore()
await expect(
store.getState().setupProjectClone({
projectId: project.id,
hostId: 'local',
url: 'https://github.com/stablyai/orca.git',
destination: '/workspace',
displayName: 'Project'
})
).resolves.toEqual({
project,
setup,
repo: { ...clonedRepo, executionHostId: 'local' }
})
expect(reposClone).toHaveBeenCalledWith({
url: 'https://github.com/stablyai/orca.git',
destination: '/workspace'
})
expect(projectsSetupExistingFolder).toHaveBeenCalledWith({
projectId: project.id,
hostId: 'local',
path: clonedRepo.path,
kind: 'git',
displayName: 'Project'
})
})
it('clones a project on a runtime host before aligning it as a host setup', async () => {
const project: Project = {
id: 'project-1',
displayName: 'Project',
badgeColor: '#000',
sourceRepoIds: ['remote-repo'],
createdAt: 1,
updatedAt: 1
}
const clonedRepo = { ...remoteRepo, path: '/srv/project' }
const setup: ProjectHostSetup = {
id: clonedRepo.id,
projectId: project.id,
hostId: 'local',
repoId: clonedRepo.id,
path: clonedRepo.path,
displayName: clonedRepo.displayName,
setupState: 'ready',
setupMethod: 'legacy-repo',
createdAt: 1,
updatedAt: 1
}
runtimeEnvironmentCall
.mockResolvedValueOnce({
id: 'rpc-clone',
ok: true,
result: { repo: clonedRepo },
_meta: { runtimeId: 'runtime-remote' }
})
.mockResolvedValueOnce({
id: 'rpc-setup',
ok: true,
result: { result: { project, setup, repo: clonedRepo } },
_meta: { runtimeId: 'runtime-remote' }
})
const store = createTestStore()
await expect(
store.getState().setupProjectClone({
projectId: project.id,
hostId: 'runtime:env-1',
url: 'https://github.com/stablyai/orca.git',
destination: '/srv',
displayName: 'Project'
})
).resolves.toEqual({
project,
setup: { ...setup, hostId: 'runtime:env-1', executionHostId: 'runtime:env-1' },
repo: { ...clonedRepo, executionHostId: 'runtime:env-1' }
})
expect(runtimeEnvironmentCall).toHaveBeenNthCalledWith(1, {
selector: 'env-1',
method: 'repo.clone',
params: {
url: 'https://github.com/stablyai/orca.git',
destination: '/srv'
},
timeoutMs: 10 * 60_000
})
expect(runtimeEnvironmentCall).toHaveBeenNthCalledWith(2, {
selector: 'env-1',
method: 'projectHostSetup.setupExistingFolder',
params: {
projectId: project.id,
hostId: 'runtime:env-1',
path: clonedRepo.path,
kind: 'git',
displayName: 'Project'
},
timeoutMs: 15_000
})
})
it('keeps runtime ownership when a runtime repo is moved between groups', async () => {
runtimeEnvironmentCall.mockResolvedValue({
id: 'rpc-move',
+46
View File
@@ -12,6 +12,7 @@ import type {
ProjectHostSetup,
ProjectGroupImportResult,
NestedRepoScanResult,
ProjectHostSetupCloneArgs,
ProjectHostSetupExistingFolderArgs,
ProjectHostSetupResult
} from '../../../../shared/types'
@@ -278,6 +279,7 @@ export type RepoSlice = {
setupProjectExistingFolder: (
args: ProjectHostSetupExistingFolderArgs
) => Promise<ProjectHostSetupResult | null>
setupProjectClone: (args: ProjectHostSetupCloneArgs) => Promise<ProjectHostSetupResult | null>
addNonGitFolder: (path: string) => Promise<Repo | null>
scanNestedRepos: (
path: string,
@@ -714,6 +716,50 @@ export const createRepoSlice: StateCreator<AppState, [], [], RepoSlice> = (set,
}
},
setupProjectClone: async (args) => {
try {
const parsedHost = parseExecutionHostId(args.hostId)
if (parsedHost?.kind === 'ssh') {
throw new Error(
'Clone setup is not available for SSH hosts yet. Import an existing folder on that host instead.'
)
}
const target = getProjectSetupRuntimeTarget(args.hostId)
const repo =
target.kind === 'local'
? await window.api.repos.clone({
url: args.url,
destination: args.destination
})
: (
await callRuntimeRpc<{ repo: Repo }>(
target,
'repo.clone',
{
url: args.url,
destination: args.destination
},
{ timeoutMs: 10 * 60_000 }
)
).repo
return await get().setupProjectExistingFolder({
projectId: args.projectId,
hostId: args.hostId,
path: repo.path,
kind: 'git',
displayName: args.displayName
})
} catch (err) {
console.error('Failed to clone project on host:', err)
const message = err instanceof Error ? err.message : String(err)
toast.error(translate('auto.store.slices.repos.c6e022ddfc', 'Failed to add project'), {
description: message,
duration: ERROR_TOAST_DURATION
})
return null
}
},
addRepo: async () => {
const target = getActiveRuntimeTarget(get().settings)
if (target.kind !== 'local') {
+8
View File
@@ -133,6 +133,14 @@ export type ProjectHostSetupExistingFolderArgs = {
displayName?: string
}
export type ProjectHostSetupCloneArgs = {
projectId: string
hostId: ExecutionHostId
url: string
destination: string
displayName?: string
}
export type ProjectHostSetupResult = {
project: Project
setup: ProjectHostSetup