mirror of
https://github.com/stablyai/orca.git
synced 2026-10-19 16:03:26 +00:00
Add project host setup existing folder API
Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
@@ -720,6 +720,12 @@ Landed so far:
|
||||
fields to `Worktree`/`WorktreeMeta`, threaded them through worktree merge
|
||||
paths, and stamped them for new local, SSH, folder, and runtime-created
|
||||
workspaces when project-host setup data is available.
|
||||
- Added first-class setup-on-existing-folder plumbing through local IPC,
|
||||
preload, runtime RPC, runtime service, and the renderer repo slice. The new
|
||||
`projectHostSetup.setupExistingFolder` path returns `{ project, setup, repo }`
|
||||
and reuses the existing local/SSH/runtime repo import behavior underneath,
|
||||
with an identity guard so an imported folder does not silently become a
|
||||
different project.
|
||||
- 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
|
||||
@@ -743,6 +749,8 @@ Remaining end-to-end work:
|
||||
- teach workspace creation to resolve `{ projectId, hostId }` through a ready
|
||||
project-host setup
|
||||
- add setup-on-host flows for local paths and SSH paths
|
||||
- add clone/provision setup-on-host flows; existing-folder setup now has an API
|
||||
bridge, but not a complete user-facing project settings flow
|
||||
- split settings into explicit client, host, project, and project-host setup
|
||||
scopes
|
||||
- backfill explicit workspace project/setup/host ownership for existing
|
||||
|
||||
+241
-134
@@ -11,6 +11,8 @@ import type {
|
||||
Repo,
|
||||
ProjectGroup,
|
||||
ProjectGroupImportResult,
|
||||
ProjectHostSetupExistingFolderArgs,
|
||||
ProjectHostSetupResult,
|
||||
NestedRepoScanResult,
|
||||
BaseRefDefaultResult,
|
||||
SparsePreset
|
||||
@@ -61,6 +63,8 @@ import { track } from '../telemetry/client'
|
||||
import { getCohortAtEmit } from '../telemetry/cohort-classifier'
|
||||
import type { RepoMethod } from '../../shared/telemetry-events'
|
||||
import { detectRepoIconAndUpstream } from '../repo-icon-autodetect'
|
||||
import { getProjectHostSetupForRepo } from '../../shared/project-host-setup-projection'
|
||||
import { parseExecutionHostId } from '../../shared/execution-host'
|
||||
|
||||
// Why: `method` answers "which entry point did the user take?", not "what did
|
||||
// they add?" — so the IPC the renderer invoked IS the method. We never send
|
||||
@@ -83,6 +87,180 @@ function emitRepoAdded(method: RepoMethod, alreadyExisted: boolean): void {
|
||||
track('repo_added', { method, ...getCohortAtEmit() })
|
||||
}
|
||||
|
||||
function buildProjectHostSetupResult(store: Store, repo: Repo): ProjectHostSetupResult {
|
||||
const setup = getProjectHostSetupForRepo(store.getProjectHostSetups(), repo)
|
||||
const project = store.getProjects().find((entry) => entry.id === setup.projectId)
|
||||
if (!project) {
|
||||
throw new Error(`Project setup was created without a project record: ${setup.projectId}`)
|
||||
}
|
||||
return { project, setup, repo }
|
||||
}
|
||||
|
||||
function alignRepoWithRequestedProject(
|
||||
store: Store,
|
||||
repo: Repo,
|
||||
projectId: string
|
||||
): ProjectHostSetupResult {
|
||||
let setup = getProjectHostSetupForRepo(store.getProjectHostSetups(), repo)
|
||||
if (setup.projectId !== projectId) {
|
||||
const project = store.getProjects().find((entry) => entry.id === projectId)
|
||||
if (!project?.providerIdentity || project.providerIdentity.provider !== 'github') {
|
||||
throw new Error('Imported folder does not match the selected project identity.')
|
||||
}
|
||||
// Why: setup-on-host is an explicit user action for this project. When the
|
||||
// folder lacks upstream metadata but the selected project has provider
|
||||
// identity, stamp that identity so compatibility projection can merge it.
|
||||
const updated = store.updateRepo(repo.id, {
|
||||
upstream: {
|
||||
owner: project.providerIdentity.owner,
|
||||
repo: project.providerIdentity.repo
|
||||
}
|
||||
})
|
||||
if (!updated) {
|
||||
throw new Error(`Project setup repo disappeared before it could be linked: ${repo.id}`)
|
||||
}
|
||||
repo = updated
|
||||
setup = getProjectHostSetupForRepo(store.getProjectHostSetups(), repo)
|
||||
}
|
||||
return buildProjectHostSetupResult(store, repo)
|
||||
}
|
||||
|
||||
async function addLocalRepoFromPath(
|
||||
store: Store,
|
||||
path: string,
|
||||
kind: 'git' | 'folder' = 'git'
|
||||
): Promise<{ repo: Repo; alreadyExisted: boolean } | { error: string }> {
|
||||
const repoKind = kind === 'folder' ? 'folder' : 'git'
|
||||
if (repoKind === 'git' && !isGitRepo(path)) {
|
||||
return { error: `Not a valid git repository: ${path}` }
|
||||
}
|
||||
|
||||
const existing = store.getRepos().find((r) => r.path === path)
|
||||
if (existing) {
|
||||
return { repo: existing, alreadyExisted: true }
|
||||
}
|
||||
|
||||
const detected = await detectRepoIconAndUpstream({ repoPath: path, kind: repoKind })
|
||||
const repo: Repo = {
|
||||
id: randomUUID(),
|
||||
path,
|
||||
displayName: getRepoName(path),
|
||||
badgeColor: DEFAULT_REPO_BADGE_COLOR,
|
||||
...detected,
|
||||
addedAt: Date.now(),
|
||||
kind: repoKind,
|
||||
...(repoKind === 'git'
|
||||
? {
|
||||
externalWorktreeVisibility: 'hide' as const,
|
||||
externalWorktreeVisibilityLegacy: false
|
||||
}
|
||||
: {})
|
||||
}
|
||||
|
||||
store.addRepo(repo)
|
||||
return { repo, alreadyExisted: false }
|
||||
}
|
||||
|
||||
async function addRemoteRepoFromPath(
|
||||
store: Store,
|
||||
args: {
|
||||
connectionId: string
|
||||
remotePath: string
|
||||
displayName?: string
|
||||
kind?: 'git' | 'folder'
|
||||
}
|
||||
): Promise<{ repo: Repo; alreadyExisted: boolean } | { error: string }> {
|
||||
const gitProvider = getSshGitProvider(args.connectionId)
|
||||
if (!gitProvider) {
|
||||
return { error: `SSH connection "${args.connectionId}" not found or not connected` }
|
||||
}
|
||||
|
||||
let repoKind: 'git' | 'folder' = args.kind ?? 'git'
|
||||
let resolvedPath = args.remotePath
|
||||
|
||||
// Why: `~` is a shell expansion that Node's fs APIs don't understand.
|
||||
// Resolve tilde paths to absolute paths via the relay before storing,
|
||||
// so all downstream fs operations (readDir, stat, etc.) work correctly.
|
||||
if (resolvedPath === '~' || resolvedPath === '~/' || resolvedPath.startsWith('~/')) {
|
||||
const mux = getActiveMultiplexer(args.connectionId)
|
||||
if (mux) {
|
||||
try {
|
||||
const result = (await mux.request('session.resolveHome', {
|
||||
path: resolvedPath
|
||||
})) as { resolvedPath: string }
|
||||
resolvedPath = result.resolvedPath
|
||||
} catch {
|
||||
// Relay may not support resolveHome yet — fall through to raw path
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const existing = store
|
||||
.getRepos()
|
||||
.find((r) => r.connectionId === args.connectionId && r.path === resolvedPath)
|
||||
if (existing) {
|
||||
return { repo: existing, alreadyExisted: true }
|
||||
}
|
||||
|
||||
if (args.kind !== 'folder') {
|
||||
try {
|
||||
const check = await gitProvider.isGitRepoAsync(resolvedPath)
|
||||
if (check.isRepo) {
|
||||
repoKind = 'git'
|
||||
if (check.rootPath) {
|
||||
resolvedPath = check.rootPath
|
||||
}
|
||||
} else {
|
||||
return { error: `Not a valid git repository: ${args.remotePath}` }
|
||||
}
|
||||
} catch (err) {
|
||||
if (err instanceof Error && err.message.includes('Not a valid git repository')) {
|
||||
return { error: err.message }
|
||||
}
|
||||
return { error: `Not a valid git repository: ${args.remotePath}` }
|
||||
}
|
||||
}
|
||||
|
||||
const folderName = getRemoteRepoFolderName(resolvedPath)
|
||||
let displayName = args.displayName || folderName
|
||||
if (!args.displayName && (args.remotePath === '~' || args.remotePath === '~/')) {
|
||||
const sshTarget = store.getSshTarget(args.connectionId)
|
||||
if (sshTarget) {
|
||||
displayName = sshTarget.label
|
||||
}
|
||||
}
|
||||
|
||||
const detected = await detectRepoIconAndUpstream({
|
||||
repoPath: resolvedPath,
|
||||
kind: repoKind,
|
||||
connectionId: args.connectionId
|
||||
})
|
||||
const repo: Repo = {
|
||||
id: randomUUID(),
|
||||
path: resolvedPath,
|
||||
displayName,
|
||||
badgeColor: DEFAULT_REPO_BADGE_COLOR,
|
||||
...detected,
|
||||
addedAt: Date.now(),
|
||||
kind: repoKind,
|
||||
connectionId: args.connectionId,
|
||||
...(repoKind === 'git'
|
||||
? {
|
||||
externalWorktreeVisibility: 'hide' as const,
|
||||
externalWorktreeVisibilityLegacy: false
|
||||
}
|
||||
: {})
|
||||
}
|
||||
|
||||
store.addRepo(repo)
|
||||
const mux = getActiveMultiplexer(args.connectionId)
|
||||
if (mux) {
|
||||
mux.notify('session.registerRoot', { rootPath: resolvedPath })
|
||||
}
|
||||
|
||||
return { repo, alreadyExisted: false }
|
||||
}
|
||||
|
||||
function getRemoteRepoFolderName(remotePath: string): string {
|
||||
const trimmed = remotePath.replace(/[\\/]+$/, '')
|
||||
if (!trimmed) {
|
||||
@@ -146,6 +324,14 @@ const ProjectGroupMoveProjectArgs = z.object({
|
||||
order: z.number().finite().optional()
|
||||
})
|
||||
|
||||
const ProjectHostSetupExistingFolderIpcArgs = z.object({
|
||||
projectId: z.string().min(1),
|
||||
hostId: z.string().min(1),
|
||||
path: z.string().min(1),
|
||||
kind: z.enum(['git', 'folder']).optional(),
|
||||
displayName: z.string().min(1).optional()
|
||||
})
|
||||
|
||||
const ProjectGroupScanNestedArgs = z.object({
|
||||
path: z.string().min(1),
|
||||
connectionId: z.string().min(1).optional(),
|
||||
@@ -434,6 +620,7 @@ export function registerRepoHandlers(mainWindow: BrowserWindow, store: Store): v
|
||||
ipcMain.removeHandler('repos:update')
|
||||
ipcMain.removeHandler('projects:list')
|
||||
ipcMain.removeHandler('projectHostSetups:list')
|
||||
ipcMain.removeHandler('projectHostSetups:setupExistingFolder')
|
||||
ipcMain.removeHandler('projectGroups:list')
|
||||
ipcMain.removeHandler('projectGroups:create')
|
||||
ipcMain.removeHandler('projectGroups:update')
|
||||
@@ -464,6 +651,50 @@ export function registerRepoHandlers(mainWindow: BrowserWindow, store: Store): v
|
||||
|
||||
ipcMain.handle('projectHostSetups:list', () => store.getProjectHostSetups())
|
||||
|
||||
ipcMain.handle(
|
||||
'projectHostSetups:setupExistingFolder',
|
||||
async (
|
||||
_event,
|
||||
rawArgs: ProjectHostSetupExistingFolderArgs
|
||||
): Promise<ProjectHostSetupResult> => {
|
||||
const args = parseProjectGroupIpcArgs(
|
||||
ProjectHostSetupExistingFolderIpcArgs,
|
||||
rawArgs,
|
||||
'project_host_setup_invalid_args'
|
||||
)
|
||||
const parsedHost = parseExecutionHostId(args.hostId)
|
||||
if (!parsedHost) {
|
||||
throw new Error(`Unsupported host: ${args.hostId}`)
|
||||
}
|
||||
const existingProject = store.getProjects().find((project) => project.id === args.projectId)
|
||||
if (!existingProject) {
|
||||
throw new Error(`Project not found: ${args.projectId}`)
|
||||
}
|
||||
|
||||
const result =
|
||||
parsedHost.kind === 'local'
|
||||
? await addLocalRepoFromPath(store, args.path, args.kind)
|
||||
: parsedHost.kind === 'ssh'
|
||||
? await addRemoteRepoFromPath(store, {
|
||||
connectionId: parsedHost.targetId,
|
||||
remotePath: args.path,
|
||||
displayName: args.displayName,
|
||||
kind: args.kind
|
||||
})
|
||||
: {
|
||||
error:
|
||||
'Runtime hosts must be set up through the runtime projectHostSetup.setupExistingFolder RPC.'
|
||||
}
|
||||
if ('error' in result) {
|
||||
throw new Error(result.error)
|
||||
}
|
||||
invalidateAuthorizedRootsCache()
|
||||
notifyReposChanged(mainWindow)
|
||||
emitRepoAdded('folder_picker', result.alreadyExisted)
|
||||
return alignRepoWithRequestedProject(store, result.repo, args.projectId)
|
||||
}
|
||||
)
|
||||
|
||||
ipcMain.handle('projectGroups:list', () => store.getProjectGroups())
|
||||
|
||||
ipcMain.handle('projectGroups:create', (_event, rawArgs: unknown): ProjectGroup => {
|
||||
@@ -678,40 +909,14 @@ export function registerRepoHandlers(mainWindow: BrowserWindow, store: Store): v
|
||||
_event,
|
||||
args: { path: string; kind?: 'git' | 'folder' }
|
||||
): Promise<{ repo: Repo } | { error: string }> => {
|
||||
const repoKind = args.kind === 'folder' ? 'folder' : 'git'
|
||||
if (repoKind === 'git' && !isGitRepo(args.path)) {
|
||||
return { error: `Not a valid git repository: ${args.path}` }
|
||||
const result = await addLocalRepoFromPath(store, args.path, args.kind)
|
||||
if ('error' in result) {
|
||||
return result
|
||||
}
|
||||
|
||||
// Check if already added
|
||||
const existing = store.getRepos().find((r) => r.path === args.path)
|
||||
if (existing) {
|
||||
emitRepoAdded('folder_picker', true)
|
||||
return { repo: existing }
|
||||
}
|
||||
|
||||
const detected = await detectRepoIconAndUpstream({ repoPath: args.path, kind: repoKind })
|
||||
const repo: Repo = {
|
||||
id: randomUUID(),
|
||||
path: args.path,
|
||||
displayName: getRepoName(args.path),
|
||||
badgeColor: DEFAULT_REPO_BADGE_COLOR,
|
||||
...detected,
|
||||
addedAt: Date.now(),
|
||||
kind: repoKind,
|
||||
...(repoKind === 'git'
|
||||
? {
|
||||
externalWorktreeVisibility: 'hide' as const,
|
||||
externalWorktreeVisibilityLegacy: false
|
||||
}
|
||||
: {})
|
||||
}
|
||||
|
||||
store.addRepo(repo)
|
||||
invalidateAuthorizedRootsCache()
|
||||
notifyReposChanged(mainWindow)
|
||||
emitRepoAdded('folder_picker', false)
|
||||
return { repo }
|
||||
emitRepoAdded('folder_picker', result.alreadyExisted)
|
||||
return { repo: result.repo }
|
||||
}
|
||||
)
|
||||
|
||||
@@ -726,111 +931,13 @@ export function registerRepoHandlers(mainWindow: BrowserWindow, store: Store): v
|
||||
kind?: 'git' | 'folder'
|
||||
}
|
||||
): Promise<{ repo: Repo } | { error: string }> => {
|
||||
const gitProvider = getSshGitProvider(args.connectionId)
|
||||
if (!gitProvider) {
|
||||
return { error: `SSH connection "${args.connectionId}" not found or not connected` }
|
||||
const result = await addRemoteRepoFromPath(store, args)
|
||||
if ('error' in result) {
|
||||
return result
|
||||
}
|
||||
|
||||
let repoKind: 'git' | 'folder' = args.kind ?? 'git'
|
||||
let resolvedPath = args.remotePath
|
||||
|
||||
// Why: `~` is a shell expansion that Node's fs APIs don't understand.
|
||||
// Resolve tilde paths to absolute paths via the relay before storing,
|
||||
// so all downstream fs operations (readDir, stat, etc.) work correctly.
|
||||
if (resolvedPath === '~' || resolvedPath === '~/' || resolvedPath.startsWith('~/')) {
|
||||
const mux = getActiveMultiplexer(args.connectionId)
|
||||
if (mux) {
|
||||
try {
|
||||
const result = (await mux.request('session.resolveHome', {
|
||||
path: resolvedPath
|
||||
})) as { resolvedPath: string }
|
||||
resolvedPath = result.resolvedPath
|
||||
} catch {
|
||||
// Relay may not support resolveHome yet — fall through to raw path
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Why: check for duplicates after tilde resolution so that adding `~/`
|
||||
// when `/home/ubuntu` is already stored correctly detects the duplicate.
|
||||
const existing = store
|
||||
.getRepos()
|
||||
.find((r) => r.connectionId === args.connectionId && r.path === resolvedPath)
|
||||
if (existing) {
|
||||
emitRepoAdded('folder_picker', true)
|
||||
return { repo: existing }
|
||||
}
|
||||
|
||||
if (args.kind !== 'folder') {
|
||||
// Why: when kind is not explicitly 'folder', verify the remote path is
|
||||
// a git repo. Return an error on failure so the renderer can show the "Open as
|
||||
// Folder" confirmation dialog — matching the local add-repo behavior
|
||||
// where non-git directories require explicit user consent.
|
||||
try {
|
||||
const check = await gitProvider.isGitRepoAsync(resolvedPath)
|
||||
if (check.isRepo) {
|
||||
repoKind = 'git'
|
||||
if (check.rootPath) {
|
||||
resolvedPath = check.rootPath
|
||||
}
|
||||
} else {
|
||||
return { error: `Not a valid git repository: ${args.remotePath}` }
|
||||
}
|
||||
} catch (err) {
|
||||
if (err instanceof Error && err.message.includes('Not a valid git repository')) {
|
||||
return { error: err.message }
|
||||
}
|
||||
return { error: `Not a valid git repository: ${args.remotePath}` }
|
||||
}
|
||||
}
|
||||
|
||||
const folderName = getRemoteRepoFolderName(resolvedPath)
|
||||
|
||||
// When folderName is the home directory basename (e.g. 'ubuntu'),
|
||||
// use SSH target label for a more descriptive name
|
||||
let displayName = args.displayName || folderName
|
||||
if (!args.displayName && (args.remotePath === '~' || args.remotePath === '~/')) {
|
||||
const sshTarget = store.getSshTarget(args.connectionId)
|
||||
if (sshTarget) {
|
||||
displayName = sshTarget.label
|
||||
}
|
||||
}
|
||||
|
||||
const detected = await detectRepoIconAndUpstream({
|
||||
repoPath: resolvedPath,
|
||||
kind: repoKind,
|
||||
connectionId: args.connectionId
|
||||
})
|
||||
const repo: Repo = {
|
||||
id: randomUUID(),
|
||||
path: resolvedPath,
|
||||
displayName,
|
||||
badgeColor: DEFAULT_REPO_BADGE_COLOR,
|
||||
...detected,
|
||||
addedAt: Date.now(),
|
||||
kind: repoKind,
|
||||
connectionId: args.connectionId,
|
||||
...(repoKind === 'git'
|
||||
? {
|
||||
externalWorktreeVisibility: 'hide' as const,
|
||||
externalWorktreeVisibilityLegacy: false
|
||||
}
|
||||
: {})
|
||||
}
|
||||
|
||||
store.addRepo(repo)
|
||||
notifyReposChanged(mainWindow)
|
||||
|
||||
// Why: register the workspace root with the relay so mutating FS operations
|
||||
// are scoped to this repo's path. Without this, the relay's path ACL would
|
||||
// reject writes to the workspace after the first root is registered.
|
||||
const mux = getActiveMultiplexer(args.connectionId)
|
||||
if (mux) {
|
||||
mux.notify('session.registerRoot', { rootPath: resolvedPath })
|
||||
}
|
||||
|
||||
emitRepoAdded('folder_picker', false)
|
||||
return { repo }
|
||||
emitRepoAdded('folder_picker', result.alreadyExisted)
|
||||
return { repo: result.repo }
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@@ -52,6 +52,8 @@ import type {
|
||||
PersistedUIState,
|
||||
Project,
|
||||
ProjectHostSetup,
|
||||
ProjectHostSetupExistingFolderArgs,
|
||||
ProjectHostSetupResult,
|
||||
Repo,
|
||||
RemoveWorktreeResult,
|
||||
StatsSummary,
|
||||
@@ -87,7 +89,10 @@ import { toRuntimeActivateWorktreeEvent } from '../../shared/runtime-client-even
|
||||
import type { FeatureInteractionId } from '../../shared/feature-interactions'
|
||||
import type { TerminalPaneSplitSource } from '../../shared/feature-education-telemetry'
|
||||
import { FOLDER_WORKSPACE_INSTANCE_SEPARATOR, splitWorktreeId } from '../../shared/worktree-id'
|
||||
import { getProjectHostSetupWorktreeMeta } from '../../shared/project-host-setup-projection'
|
||||
import {
|
||||
getProjectHostSetupForRepo,
|
||||
getProjectHostSetupWorktreeMeta
|
||||
} from '../../shared/project-host-setup-projection'
|
||||
import { clampLinearIssueListLimit } from '../../shared/linear-issue-read-limits'
|
||||
import { isFolderRepo } from '../../shared/repo-kind'
|
||||
import { DEFAULT_WORKSPACE_STATUS_ID } from '../../shared/workspace-statuses'
|
||||
@@ -6448,6 +6453,44 @@ export class OrcaRuntimeService {
|
||||
return this.store?.getProjectHostSetups?.() ?? []
|
||||
}
|
||||
|
||||
async setupProjectExistingFolder(
|
||||
args: ProjectHostSetupExistingFolderArgs
|
||||
): Promise<ProjectHostSetupResult> {
|
||||
if (!this.store) {
|
||||
throw new Error('runtime_unavailable')
|
||||
}
|
||||
const existingProject = this.listProjects().find((project) => project.id === args.projectId)
|
||||
if (!existingProject) {
|
||||
throw new Error(`Project not found: ${args.projectId}`)
|
||||
}
|
||||
let repo = await this.addRepo(args.path, args.kind === 'folder' ? 'folder' : 'git')
|
||||
let setup = getProjectHostSetupForRepo(this.listProjectHostSetups(), repo)
|
||||
if (setup.projectId !== args.projectId) {
|
||||
if (
|
||||
!existingProject.providerIdentity ||
|
||||
existingProject.providerIdentity.provider !== 'github'
|
||||
) {
|
||||
throw new Error('Imported folder does not match the selected project identity.')
|
||||
}
|
||||
const updated = this.store.updateRepo(repo.id, {
|
||||
upstream: {
|
||||
owner: existingProject.providerIdentity.owner,
|
||||
repo: existingProject.providerIdentity.repo
|
||||
}
|
||||
})
|
||||
if (!updated) {
|
||||
throw new Error(`Project setup repo disappeared before it could be linked: ${repo.id}`)
|
||||
}
|
||||
repo = updated
|
||||
setup = getProjectHostSetupForRepo(this.listProjectHostSetups(), repo)
|
||||
}
|
||||
const project = this.listProjects().find((entry) => entry.id === setup.projectId)
|
||||
if (!project) {
|
||||
throw new Error(`Project setup was created without a project record: ${setup.projectId}`)
|
||||
}
|
||||
return { project, setup, repo }
|
||||
}
|
||||
|
||||
listProjectGroups(): ProjectGroup[] {
|
||||
return this.store?.getProjectGroups?.() ?? []
|
||||
}
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
import { z } from 'zod'
|
||||
import { normalizeExecutionHostId } from '../../../../shared/execution-host'
|
||||
import { defineMethod, type RpcMethod } from '../core'
|
||||
import { OptionalString, requiredString } from '../schemas'
|
||||
|
||||
const ProjectHostSetupExistingFolder = z.object({
|
||||
projectId: requiredString('Missing project ID'),
|
||||
hostId: requiredString('Missing host ID').transform((value, ctx) => {
|
||||
const hostId = normalizeExecutionHostId(value)
|
||||
if (!hostId) {
|
||||
ctx.addIssue({ code: 'custom', message: 'Invalid host ID' })
|
||||
return z.NEVER
|
||||
}
|
||||
return hostId
|
||||
}),
|
||||
path: requiredString('Missing project path'),
|
||||
kind: z.enum(['git', 'folder']).optional(),
|
||||
displayName: OptionalString
|
||||
})
|
||||
|
||||
export const PROJECT_RUNTIME_METHODS: RpcMethod[] = [
|
||||
defineMethod({
|
||||
name: 'project.list',
|
||||
params: null,
|
||||
handler: (_params, { runtime }) => ({ projects: runtime.listProjects() })
|
||||
}),
|
||||
defineMethod({
|
||||
name: 'projectHostSetup.list',
|
||||
params: null,
|
||||
handler: (_params, { runtime }) => ({ setups: runtime.listProjectHostSetups() })
|
||||
}),
|
||||
defineMethod({
|
||||
name: 'projectHostSetup.setupExistingFolder',
|
||||
params: ProjectHostSetupExistingFolder,
|
||||
handler: async (params, { runtime }) => ({
|
||||
result: await runtime.setupProjectExistingFolder(params)
|
||||
})
|
||||
})
|
||||
]
|
||||
@@ -353,6 +353,54 @@ describe('repo RPC methods', () => {
|
||||
expect(runtime.listProjectHostSetups).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('routes project-host setup import to the runtime server', async () => {
|
||||
const result = {
|
||||
project: {
|
||||
id: 'project-1',
|
||||
displayName: 'Project',
|
||||
badgeColor: '#737373',
|
||||
sourceRepoIds: ['repo-1'],
|
||||
createdAt: 1,
|
||||
updatedAt: 1
|
||||
},
|
||||
setup: {
|
||||
id: 'repo-1',
|
||||
projectId: 'project-1',
|
||||
hostId: 'local',
|
||||
repoId: 'repo-1',
|
||||
path: '/repo',
|
||||
displayName: 'Project',
|
||||
setupState: 'ready',
|
||||
setupMethod: 'legacy-repo',
|
||||
createdAt: 1,
|
||||
updatedAt: 1
|
||||
},
|
||||
repo: { id: 'repo-1', path: '/repo', displayName: 'Project' }
|
||||
}
|
||||
const runtime = {
|
||||
getRuntimeId: () => 'test-runtime',
|
||||
setupProjectExistingFolder: vi.fn().mockResolvedValue(result)
|
||||
} as unknown as OrcaRuntimeService
|
||||
const dispatcher = new RpcDispatcher({ runtime, methods: REPO_METHODS })
|
||||
|
||||
const response = await dispatcher.dispatch(
|
||||
makeRequest('projectHostSetup.setupExistingFolder', {
|
||||
projectId: 'project-1',
|
||||
hostId: 'runtime:env-1',
|
||||
path: '/repo',
|
||||
kind: 'git'
|
||||
})
|
||||
)
|
||||
|
||||
expect(runtime.setupProjectExistingFolder).toHaveBeenCalledWith({
|
||||
projectId: 'project-1',
|
||||
hostId: 'runtime:env-1',
|
||||
path: '/repo',
|
||||
kind: 'git'
|
||||
})
|
||||
expect(response).toMatchObject({ ok: true, result: { result } })
|
||||
})
|
||||
|
||||
it('allows separate nested-repo imports without a group name', async () => {
|
||||
const runtime = {
|
||||
getRuntimeId: () => 'test-runtime',
|
||||
|
||||
@@ -4,6 +4,7 @@ import { OptionalFiniteNumber, OptionalString, requiredString } from '../schemas
|
||||
import { sanitizeRepoIcon } from '../../../../shared/repo-icon'
|
||||
import { normalizeRepoBadgeColor } from '../../../../shared/repo-badge-color'
|
||||
import { normalizeRepoSourceControlAiOverrides } from '../../../../shared/source-control-ai'
|
||||
import { PROJECT_RUNTIME_METHODS } from './project-runtime-rpc-methods'
|
||||
|
||||
const RepoSelector = z.object({
|
||||
repo: requiredString('Missing repo selector')
|
||||
@@ -156,16 +157,7 @@ export const REPO_METHODS: RpcMethod[] = [
|
||||
params: null,
|
||||
handler: (_params, { runtime }) => ({ repos: runtime.listRepos() })
|
||||
}),
|
||||
defineMethod({
|
||||
name: 'project.list',
|
||||
params: null,
|
||||
handler: (_params, { runtime }) => ({ projects: runtime.listProjects() })
|
||||
}),
|
||||
defineMethod({
|
||||
name: 'projectHostSetup.list',
|
||||
params: null,
|
||||
handler: (_params, { runtime }) => ({ setups: runtime.listProjectHostSetups() })
|
||||
}),
|
||||
...PROJECT_RUNTIME_METHODS,
|
||||
defineMethod({
|
||||
name: 'projectGroup.list',
|
||||
params: null,
|
||||
|
||||
@@ -124,6 +124,8 @@ import type {
|
||||
Repo,
|
||||
ProjectGroup,
|
||||
ProjectHostSetup,
|
||||
ProjectHostSetupExistingFolderArgs,
|
||||
ProjectHostSetupResult,
|
||||
ProjectGroupImportResult,
|
||||
ProjectGroupImportMode,
|
||||
ShellHydrationFailureReason,
|
||||
@@ -777,6 +779,9 @@ export type PreloadApi = {
|
||||
projects: {
|
||||
list: () => Promise<Project[]>
|
||||
listHostSetups: () => Promise<ProjectHostSetup[]>
|
||||
setupExistingFolder: (
|
||||
args: ProjectHostSetupExistingFolderArgs
|
||||
) => Promise<ProjectHostSetupResult>
|
||||
}
|
||||
projectGroups: {
|
||||
list: () => Promise<ProjectGroup[]>
|
||||
|
||||
@@ -505,7 +505,8 @@ const api = {
|
||||
|
||||
projects: {
|
||||
list: () => ipcRenderer.invoke('projects:list'),
|
||||
listHostSetups: () => ipcRenderer.invoke('projectHostSetups:list')
|
||||
listHostSetups: () => ipcRenderer.invoke('projectHostSetups:list'),
|
||||
setupExistingFolder: (args) => ipcRenderer.invoke('projectHostSetups:setupExistingFolder', args)
|
||||
} satisfies PreloadApi['projects'],
|
||||
|
||||
projectGroups: {
|
||||
|
||||
@@ -39,6 +39,7 @@ const reposPickFolder = vi.fn()
|
||||
const reposRemove = vi.fn()
|
||||
const reposUpdate = vi.fn()
|
||||
const reposReorder = vi.fn()
|
||||
const projectsSetupExistingFolder = vi.fn()
|
||||
const projectGroupsMoveProject = vi.fn()
|
||||
const ptyKill = vi.fn()
|
||||
const runtimeEnvironmentCall = vi.fn()
|
||||
@@ -52,6 +53,7 @@ beforeEach(() => {
|
||||
reposRemove.mockReset()
|
||||
reposUpdate.mockReset()
|
||||
reposReorder.mockReset()
|
||||
projectsSetupExistingFolder.mockReset()
|
||||
projectGroupsMoveProject.mockReset()
|
||||
ptyKill.mockReset()
|
||||
runtimeEnvironmentCall.mockReset()
|
||||
@@ -69,6 +71,9 @@ beforeEach(() => {
|
||||
update: reposUpdate,
|
||||
reorder: reposReorder
|
||||
},
|
||||
projects: {
|
||||
setupExistingFolder: projectsSetupExistingFolder
|
||||
},
|
||||
projectGroups: {
|
||||
moveProject: projectGroupsMoveProject
|
||||
},
|
||||
@@ -121,11 +126,16 @@ describe('repo slice runtime routing', () => {
|
||||
const listHostSetups = vi.fn().mockResolvedValue([setup])
|
||||
;(
|
||||
window.api as typeof window.api & {
|
||||
projects?: { list: typeof projectsList; listHostSetups: typeof listHostSetups }
|
||||
projects?: {
|
||||
list: typeof projectsList
|
||||
listHostSetups: typeof listHostSetups
|
||||
setupExistingFolder: typeof projectsSetupExistingFolder
|
||||
}
|
||||
}
|
||||
).projects = {
|
||||
list: projectsList,
|
||||
listHostSetups
|
||||
listHostSetups,
|
||||
setupExistingFolder: projectsSetupExistingFolder
|
||||
}
|
||||
reposList.mockResolvedValue([localRepo])
|
||||
const store = createTestStore()
|
||||
@@ -244,6 +254,110 @@ describe('repo slice runtime routing', () => {
|
||||
expect(reposPickFolder).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('sets up a project on a local host through the project setup API', async () => {
|
||||
const project: Project = {
|
||||
id: 'project-1',
|
||||
displayName: 'Project',
|
||||
badgeColor: '#000',
|
||||
sourceRepoIds: ['local-repo'],
|
||||
createdAt: 1,
|
||||
updatedAt: 1
|
||||
}
|
||||
const setup: ProjectHostSetup = {
|
||||
id: 'local-repo',
|
||||
projectId: project.id,
|
||||
hostId: 'local',
|
||||
repoId: 'local-repo',
|
||||
path: '/local',
|
||||
displayName: 'Local',
|
||||
setupState: 'ready',
|
||||
setupMethod: 'legacy-repo',
|
||||
createdAt: 1,
|
||||
updatedAt: 1
|
||||
}
|
||||
projectsSetupExistingFolder.mockResolvedValue({ project, setup, repo: localRepo })
|
||||
const store = createTestStore()
|
||||
|
||||
await expect(
|
||||
store.getState().setupProjectExistingFolder({
|
||||
projectId: project.id,
|
||||
hostId: 'local',
|
||||
path: '/local',
|
||||
kind: 'git'
|
||||
})
|
||||
).resolves.toEqual({
|
||||
project,
|
||||
setup,
|
||||
repo: { ...localRepo, executionHostId: 'local' }
|
||||
})
|
||||
|
||||
expect(store.getState().repos).toEqual([{ ...localRepo, executionHostId: 'local' }])
|
||||
expect(store.getState().projects).toEqual([project])
|
||||
expect(store.getState().projectHostSetups).toEqual([setup])
|
||||
expect(projectsSetupExistingFolder).toHaveBeenCalledWith({
|
||||
projectId: project.id,
|
||||
hostId: 'local',
|
||||
path: '/local',
|
||||
kind: 'git'
|
||||
})
|
||||
})
|
||||
|
||||
it('sets up a project on the active runtime host through runtime RPC', async () => {
|
||||
const project: Project = {
|
||||
id: 'project-1',
|
||||
displayName: 'Project',
|
||||
badgeColor: '#000',
|
||||
sourceRepoIds: ['remote-repo'],
|
||||
createdAt: 1,
|
||||
updatedAt: 1
|
||||
}
|
||||
const setup: ProjectHostSetup = {
|
||||
id: 'remote-repo',
|
||||
projectId: project.id,
|
||||
hostId: 'local',
|
||||
repoId: 'remote-repo',
|
||||
path: '/srv/project',
|
||||
displayName: 'Remote',
|
||||
setupState: 'ready',
|
||||
setupMethod: 'legacy-repo',
|
||||
createdAt: 1,
|
||||
updatedAt: 1
|
||||
}
|
||||
runtimeEnvironmentCall.mockResolvedValue({
|
||||
id: 'rpc-setup',
|
||||
ok: true,
|
||||
result: { result: { project, setup, repo: remoteRepo } },
|
||||
_meta: { runtimeId: 'runtime-remote' }
|
||||
})
|
||||
const store = createTestStore()
|
||||
store.setState({ settings: { activeRuntimeEnvironmentId: 'env-1' } as never })
|
||||
|
||||
await expect(
|
||||
store.getState().setupProjectExistingFolder({
|
||||
projectId: project.id,
|
||||
hostId: 'runtime:env-1',
|
||||
path: '/srv/project',
|
||||
kind: 'git'
|
||||
})
|
||||
).resolves.toEqual({
|
||||
project,
|
||||
setup: { ...setup, hostId: 'runtime:env-1', executionHostId: 'runtime:env-1' },
|
||||
repo: { ...remoteRepo, executionHostId: 'runtime:env-1' }
|
||||
})
|
||||
|
||||
expect(runtimeEnvironmentCall).toHaveBeenCalledWith({
|
||||
selector: 'env-1',
|
||||
method: 'projectHostSetup.setupExistingFolder',
|
||||
params: {
|
||||
projectId: project.id,
|
||||
hostId: 'runtime:env-1',
|
||||
path: '/srv/project',
|
||||
kind: 'git'
|
||||
},
|
||||
timeoutMs: 15_000
|
||||
})
|
||||
})
|
||||
|
||||
it('keeps runtime ownership when a runtime repo is moved between groups', async () => {
|
||||
runtimeEnvironmentCall.mockResolvedValue({
|
||||
id: 'rpc-move',
|
||||
|
||||
@@ -11,7 +11,9 @@ import type {
|
||||
ProjectGroup,
|
||||
ProjectHostSetup,
|
||||
ProjectGroupImportResult,
|
||||
NestedRepoScanResult
|
||||
NestedRepoScanResult,
|
||||
ProjectHostSetupExistingFolderArgs,
|
||||
ProjectHostSetupResult
|
||||
} from '../../../../shared/types'
|
||||
import {
|
||||
projectHostSetupProjectionFromRepos,
|
||||
@@ -264,6 +266,9 @@ export type RepoSlice = {
|
||||
fetchProjectGroups: () => Promise<void>
|
||||
addRepo: () => Promise<Repo | null>
|
||||
addRepoPath: (path: string, kind?: 'git' | 'folder') => Promise<Repo | null>
|
||||
setupProjectExistingFolder: (
|
||||
args: ProjectHostSetupExistingFolderArgs
|
||||
) => Promise<ProjectHostSetupResult | null>
|
||||
addNonGitFolder: (path: string) => Promise<Repo | null>
|
||||
scanNestedRepos: (
|
||||
path: string,
|
||||
@@ -653,6 +658,53 @@ export const createRepoSlice: StateCreator<AppState, [], [], RepoSlice> = (set,
|
||||
}
|
||||
},
|
||||
|
||||
setupProjectExistingFolder: async (args) => {
|
||||
try {
|
||||
const target = getActiveRuntimeTarget(get().settings)
|
||||
const result =
|
||||
target.kind === 'local'
|
||||
? await window.api.projects.setupExistingFolder(args)
|
||||
: (
|
||||
await callRuntimeRpc<{ result: ProjectHostSetupResult }>(
|
||||
target,
|
||||
'projectHostSetup.setupExistingFolder',
|
||||
args,
|
||||
{ timeoutMs: 15_000 }
|
||||
)
|
||||
).result
|
||||
const repo = repoWithFetchedOwner(result.repo, target)
|
||||
const setup = setupWithFetchedOwner(result.setup, target)
|
||||
set((s) => {
|
||||
const nextRepos = s.repos.some((entry) => entry.id === repo.id)
|
||||
? s.repos.map((entry) => (entry.id === repo.id ? repo : entry))
|
||||
: [...s.repos, repo]
|
||||
const nextProjects = s.projects.some((entry) => entry.id === result.project.id)
|
||||
? s.projects.map((entry) => (entry.id === result.project.id ? result.project : entry))
|
||||
: [...s.projects, result.project]
|
||||
const nextSetups = s.projectHostSetups.some((entry) => entry.id === setup.id)
|
||||
? s.projectHostSetups.map((entry) => (entry.id === setup.id ? setup : entry))
|
||||
: [...s.projectHostSetups, setup]
|
||||
return {
|
||||
repos: nextRepos,
|
||||
projects: nextProjects,
|
||||
projectHostSetups: nextSetups
|
||||
}
|
||||
})
|
||||
toast.success(translate('auto.store.slices.repos.8bb3ad7935', 'Project added'), {
|
||||
description: repo.displayName
|
||||
})
|
||||
return { ...result, repo, setup }
|
||||
} catch (err) {
|
||||
console.error('Failed to set up 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') {
|
||||
|
||||
@@ -125,6 +125,20 @@ export type ProjectHostSetup = {
|
||||
updatedAt: number
|
||||
}
|
||||
|
||||
export type ProjectHostSetupExistingFolderArgs = {
|
||||
projectId: string
|
||||
hostId: ExecutionHostId
|
||||
path: string
|
||||
kind?: RepoKind
|
||||
displayName?: string
|
||||
}
|
||||
|
||||
export type ProjectHostSetupResult = {
|
||||
project: Project
|
||||
setup: ProjectHostSetup
|
||||
repo: Repo
|
||||
}
|
||||
|
||||
export type Repo = {
|
||||
id: string
|
||||
path: string
|
||||
|
||||
Reference in New Issue
Block a user