mirror of
https://github.com/stablyai/orca.git
synced 2026-10-26 16:02:42 +00:00
reland(workspace): set project location from the create-worktree host picker (#14965)
* feat(workspace): reland set project location from the create-worktree host picker Relands #14868 (reverted by #14912) with a fix for the regression that caused the revert: setting a project location could change the path before Orca used it. The retarget-after-setup path read the raw store record to find a just-created setup, because the memoized picker options had not refreshed yet: useAppStore.getState().projectHostSetups.find( (candidate) => candidate.id === setupId && candidate.setupState === 'ready' ) That hand-rolls a second selection path that skips every rule the option builder applies — repo eligibility, ephemeral-VM and runtime-owned SSH host exclusion, and the one-setup-per-host dedupe whose own comment notes that resolveWorkspaceCreationTarget takes the first project+host match and ignores the rest. So the composer could be retargeted at a setup other than the canonical one for that host, pointing creation at a different location than the one chosen. Resolves through buildProjectHostSetupOptions against fresh store state instead, so the fallback and the steady-state picker agree by construction. STA-4547 * fix(workspace): sanitize the clone prefill and drop an abandoned set-location Review follow-ups on this PR. The "Clone from URL" prefill seeded the field with the verbatim `git remote` URL, which can embed a PAT (`https://x-access-token:ghp_...@github.com/...`). The clone then runs on the *target* host, writing that token into its .git/config — a credential the user never typed into this flow, now readable by anyone on a shared host. Strip it with the same sanitizer `getProvisionedRootRecipeRepoUrl` already applies to the ephemeral-VM recipe URL. Extracted to resolveProjectCloneUrlPrefill so the rule is directly testable. The dialog also stays dismissable while a submit is in flight, and an SSH clone is unbounded. A clone the user backed out of minutes earlier still called onReady, silently moving the run target and resetting start-from under a form they had since pointed at another host. Drop the result if the dialog went away. * fix(workspace): re-arm the abandoned guard on mount StrictMode runs mount/cleanup/mount, so latching `abandoned` on the first cleanup left it true for the rest of the session and permanently suppressed onReady — the app wraps its root in StrictMode. Reset it on mount.
This commit is contained in:
@@ -0,0 +1,68 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { ProjectHostSetup } from '../../../shared/project-types'
|
||||
import type { Repo } from '../../../shared/repo-types'
|
||||
import { mergeProjectHostSetupCompatibilityState } from './project-host-compatibility'
|
||||
|
||||
const PROJECT_ID = 'github:acme/orca'
|
||||
|
||||
function repo(overrides: Partial<Repo> & Pick<Repo, 'id'>): Repo {
|
||||
return {
|
||||
path: `/src/${overrides.id}`,
|
||||
displayName: 'orca',
|
||||
addedAt: 1,
|
||||
upstream: { owner: 'acme', repo: 'orca' },
|
||||
...overrides
|
||||
} as Repo
|
||||
}
|
||||
|
||||
function pendingSetup(overrides: Partial<ProjectHostSetup> = {}): ProjectHostSetup {
|
||||
return {
|
||||
id: `${PROJECT_ID}::ssh:devbox`,
|
||||
projectId: PROJECT_ID,
|
||||
hostId: 'ssh:devbox',
|
||||
repoId: '',
|
||||
path: '',
|
||||
displayName: 'orca',
|
||||
setupState: 'not-set-up',
|
||||
setupMethod: 'pending',
|
||||
createdAt: 1,
|
||||
updatedAt: 1,
|
||||
...overrides
|
||||
} as ProjectHostSetup
|
||||
}
|
||||
|
||||
describe('mergeProjectHostSetupCompatibilityState', () => {
|
||||
it('keeps a placeholder for a host with no repo yet', () => {
|
||||
const merged = mergeProjectHostSetupCompatibilityState(
|
||||
{ projects: [], projectHostSetups: [pendingSetup()] },
|
||||
[repo({ id: 'local-repo' })]
|
||||
)
|
||||
|
||||
expect(merged.projectHostSetups.map((setup) => setup.hostId)).toEqual(['local', 'ssh:devbox'])
|
||||
})
|
||||
|
||||
// Setting a location on a host that already had a placeholder projects a ready setup for
|
||||
// the same project+host; the placeholder must not survive to shadow it.
|
||||
it('drops a placeholder once a repo covers the same project and host', () => {
|
||||
const merged = mergeProjectHostSetupCompatibilityState(
|
||||
{ projects: [], projectHostSetups: [pendingSetup()] },
|
||||
[repo({ id: 'local-repo' }), repo({ id: 'devbox-repo', executionHostId: 'ssh:devbox' })]
|
||||
)
|
||||
|
||||
expect(merged.projectHostSetups).toHaveLength(2)
|
||||
expect(merged.projectHostSetups.every((setup) => setup.setupState === 'ready')).toBe(true)
|
||||
expect(merged.projectHostSetups.map((setup) => setup.id)).toEqual(['local-repo', 'devbox-repo'])
|
||||
})
|
||||
|
||||
it('keeps the placeholder when the repo covers a different host', () => {
|
||||
const merged = mergeProjectHostSetupCompatibilityState(
|
||||
{ projects: [], projectHostSetups: [pendingSetup({ hostId: 'ssh:other' })] },
|
||||
[repo({ id: 'devbox-repo', executionHostId: 'ssh:devbox' })]
|
||||
)
|
||||
|
||||
expect(merged.projectHostSetups.map((setup) => setup.hostId)).toEqual([
|
||||
'ssh:devbox',
|
||||
'ssh:other'
|
||||
])
|
||||
})
|
||||
})
|
||||
@@ -23,6 +23,10 @@ export function isRepoBackedProjectHostSetup(
|
||||
return repoId.length > 0 && (currentRepoIds.has(repoId) || setup.id === repoId)
|
||||
}
|
||||
|
||||
function projectHostKey(setup: Pick<ProjectHostSetup, 'projectId' | 'hostId'>): string {
|
||||
return `${setup.projectId}\u0000${setup.hostId}`
|
||||
}
|
||||
|
||||
export function mergeProjectHostSetupCompatibilityState(
|
||||
state: Pick<PersistedState, 'projects' | 'projectHostSetups'>,
|
||||
repos: readonly Repo[]
|
||||
@@ -35,6 +39,7 @@ export function mergeProjectHostSetupCompatibilityState(
|
||||
const currentRepoIds = new Set(repos.map((repo) => repo.id))
|
||||
const projectedProjectIds = new Set(projection.projects.map((project) => project.id))
|
||||
const projectedSetupIds = new Set(projection.setups.map((setup) => setup.id))
|
||||
const projectedHosts = new Set(projection.setups.map(projectHostKey))
|
||||
// Why: legacy/repo-backed setup rows reuse the repo id; keep only independent rows so repo deletion leaves no ghosts.
|
||||
const independentSetups = (state.projectHostSetups ?? [])
|
||||
.filter((setup) => {
|
||||
@@ -48,6 +53,10 @@ export function mergeProjectHostSetupCompatibilityState(
|
||||
const remappedProjectId = succession.remappedProjectIds.get(setup.projectId)
|
||||
return remappedProjectId ? { ...setup, projectId: remappedProjectId } : setup
|
||||
})
|
||||
// Why: a project resolves to one setup per host. Once a repo projection covers that
|
||||
// pair, a leftover placeholder is a ghost that shadows the ready row — it sorts first
|
||||
// and reads back as "not set up". Runs after the remap so renamed rows are caught too.
|
||||
.filter((setup) => !projectedHosts.has(projectHostKey(setup)))
|
||||
const independentProjectIds = new Set(independentSetups.map((setup) => setup.projectId))
|
||||
const independentProjects = (state.projects ?? [])
|
||||
.filter(
|
||||
|
||||
Reference in New Issue
Block a user