mirror of
https://gitee.com/zhangsken/opencode.git
synced 2026-10-16 10:30:01 +08:00
fix(app): recover sessions after their worktree is removed
A missing source directory leaves its location-scoped agent/model catalog unavailable and makes New worktree look up the deleted path. Use the known project catalog only when the unlisted source fails to resolve and its catalog is absent, and create replacement worktrees from the project root.
This commit is contained in:
@@ -0,0 +1,64 @@
|
||||
import { expect, test } from "@playwright/test"
|
||||
import { base64Encode } from "@opencode/util/encode"
|
||||
import { fixture } from "../smoke/session-timeline.fixture"
|
||||
import { mockOpenCodeServer } from "../utils/mock-server"
|
||||
import { installSseTransport } from "../utils/sse-transport"
|
||||
|
||||
test("keeps a session in a removed worktree readable and movable", async ({ page }) => {
|
||||
const missing = "/projects/removed-worktree"
|
||||
const destination = fixture.directory
|
||||
const sessionID = "ses_removed_worktree"
|
||||
const session = { id: sessionID, projectID: fixture.project.id, directory: missing, title: "Removed worktree" }
|
||||
const transport = await installSseTransport(page, { server: fixture.serverKey })
|
||||
await mockOpenCodeServer(page, {
|
||||
directory: destination,
|
||||
project: fixture.project,
|
||||
provider: fixture.provider,
|
||||
sessions: [session],
|
||||
fileList: () => [],
|
||||
pageMessages: () => ({
|
||||
items: [{ id: "msg_saved", type: "user", text: "Saved conversation in removed worktree", time: { created: 1 } }],
|
||||
}),
|
||||
})
|
||||
await page.route("**/api/**", (route) => {
|
||||
const url = new URL(route.request().url())
|
||||
if (url.searchParams.get("location[directory]") !== missing) return route.fallback()
|
||||
if (!["/api/location", "/api/agent", "/api/provider", "/api/model", "/api/model/default"].includes(url.pathname))
|
||||
return route.fallback()
|
||||
return route.fulfill({ status: 500, body: "", headers: { "access-control-allow-origin": "*" } })
|
||||
})
|
||||
await page.route(`**/api/session/${sessionID}/move`, (route) => route.fulfill({ status: 204, body: "" }))
|
||||
|
||||
await page.goto(`/server/${base64Encode(fixture.serverKey)}/session/${sessionID}`, { waitUntil: "domcontentloaded" })
|
||||
await expect(page.getByText("Saved conversation in removed worktree", { exact: true })).toBeVisible()
|
||||
const prompt = page.getByRole("textbox", { name: "Prompt", exact: true })
|
||||
await expect(prompt).toBeEditable()
|
||||
await expect(page.locator('[data-action="composer-model"]')).toContainText("Claude Opus 4.6")
|
||||
await prompt.fill("Keep the draft while moving")
|
||||
await expect(prompt).toHaveText("Keep the draft while moving")
|
||||
await transport.waitForConnection()
|
||||
await page.getByRole("button", { name: "Session details" }).click()
|
||||
await page.getByRole("button", { name: "Local repository" }).click()
|
||||
const create = page.waitForRequest(
|
||||
(request) => new URL(request.url()).pathname === "/api/worktree" && request.method() === "POST",
|
||||
{ timeout: 10_000 },
|
||||
)
|
||||
const move = page.waitForRequest(
|
||||
(request) => new URL(request.url()).pathname === `/api/session/${sessionID}/move` && request.method() === "POST",
|
||||
)
|
||||
await page.getByRole("menuitem", { name: "New worktree" }).click()
|
||||
expect((await create).postDataJSON()).toMatchObject({ projectID: fixture.project.id, from: fixture.project.worktree })
|
||||
const created = `${destination}/copy`
|
||||
expect((await move).postDataJSON()).toMatchObject({ directory: created })
|
||||
session.directory = created
|
||||
await transport.send({
|
||||
id: "evt_removed_worktree_moved",
|
||||
type: "session.moved",
|
||||
created: 2,
|
||||
durable: { aggregateID: sessionID, seq: 1, version: 1 },
|
||||
data: { sessionID, location: { directory: created }, projectID: fixture.project.id },
|
||||
})
|
||||
await expect(prompt).toBeEditable()
|
||||
await expect(prompt).toHaveText("Keep the draft while moving")
|
||||
await expect(page.getByText("Saved conversation in removed worktree", { exact: true })).toBeVisible()
|
||||
})
|
||||
@@ -17,13 +17,13 @@ export function createComposerControls(input: { sessionKey: Accessor<string>; mo
|
||||
const local = useLocal()
|
||||
const sdk = useWorkspaceLocation()
|
||||
const data = useData()
|
||||
const providers = useProviders(() => sdk().directory)
|
||||
const providers = useProviders(() => sdk().catalogDirectory)
|
||||
const view = layout.view(input.sessionKey)
|
||||
|
||||
return createMemo<ComposerControls>(() => {
|
||||
return {
|
||||
agents: {
|
||||
available: normalizeAgentList(data.location.agent.list({ directory: sdk().directory }) ?? []),
|
||||
available: normalizeAgentList(data.location.agent.list({ directory: sdk().catalogDirectory }) ?? []),
|
||||
options: local.agent.list().map((agent) => agent.name),
|
||||
current: local.agent.current()?.name ?? "",
|
||||
visible: local.agent.visible(),
|
||||
@@ -34,7 +34,7 @@ export function createComposerControls(input: { sessionKey: Accessor<string>; mo
|
||||
paid: providers.paid().length > 0,
|
||||
loading:
|
||||
!(input.model ?? local.model).ready() ||
|
||||
(local.agent.visible() && data.location.agent.list({ directory: sdk().directory }) === undefined) ||
|
||||
(local.agent.visible() && data.location.agent.list({ directory: sdk().catalogDirectory }) === undefined) ||
|
||||
!providers.ready(),
|
||||
},
|
||||
session: {
|
||||
@@ -67,7 +67,7 @@ export function createComposerModelSelection(input: {
|
||||
},
|
||||
),
|
||||
)
|
||||
const providers = useProviders(() => sdk().directory)
|
||||
const providers = useProviders(() => sdk().catalogDirectory)
|
||||
const connected = createMemo(() => new Set(providers.connected().map((item) => item.id)))
|
||||
|
||||
const valid = (model: Pick<ModelKey, "providerID" | "modelID">) => {
|
||||
|
||||
@@ -9,13 +9,13 @@ export function useConfiguredModel() {
|
||||
const server = useServerSDK()
|
||||
createEffect(
|
||||
on(
|
||||
() => [location().directory, server.connection.status()] as const,
|
||||
() => [location().catalogDirectory, server.connection.status()] as const,
|
||||
([directory]) => {
|
||||
void data.location.config.sync({ directory }).catch(() => undefined)
|
||||
},
|
||||
),
|
||||
)
|
||||
const documents = () => data.location.config.list({ directory: location().directory })
|
||||
const documents = () => data.location.config.list({ directory: location().catalogDirectory })
|
||||
const model = createMemo(() => {
|
||||
const entry = documents()?.findLast((entry) => entry.type === "document" && entry.info.model !== undefined)
|
||||
const model = entry?.type === "document" ? entry.info.model : undefined
|
||||
|
||||
@@ -80,14 +80,14 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
|
||||
const sdk = useWorkspaceLocation()
|
||||
const data = useData()
|
||||
const serverSDK = useServerSDK()
|
||||
const providers = useProviders(() => sdk().directory)
|
||||
const providers = useProviders(() => sdk().catalogDirectory)
|
||||
const models = useModels()
|
||||
const settings = useSettings()
|
||||
const configuredModel = useConfiguredModel()
|
||||
|
||||
const id = createMemo(() => params.id || undefined)
|
||||
const list = createMemo(() =>
|
||||
normalizeAgentList(data.location.agent.list({ directory: sdk().directory }) ?? []).filter(
|
||||
normalizeAgentList(data.location.agent.list({ directory: sdk().catalogDirectory }) ?? []).filter(
|
||||
(item) => item.mode !== "subagent" && !item.hidden,
|
||||
),
|
||||
)
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import { ErrorBoundary, createEffect, createMemo, Show, type ParentProps } from "solid-js"
|
||||
import { ErrorBoundary, createEffect, createMemo, onCleanup, Show, type ParentProps } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { retry } from "@opencode/util/retry"
|
||||
import { useParams } from "@solidjs/router"
|
||||
import { DataProvider } from "@opencode/session-ui/context"
|
||||
import { SessionUserMessage } from "@opencode/session-ui/message"
|
||||
@@ -17,6 +19,7 @@ import { ServerConnection } from "@/runtime/server/registry"
|
||||
import { TerminalProvider } from "@/session/terminal/context"
|
||||
import { useSettingsCommand } from "@/settings/command"
|
||||
import { SessionUIProvider } from "@/shell/routes/session-ui-provider"
|
||||
import { isProjectDirectory } from "@/workspaces/paths"
|
||||
import { useTabs, type PendingSession } from "@/shell/tabs/tabs"
|
||||
import { requireServerKey } from "@/shell/routes/session"
|
||||
import { useSessionModel } from "./model"
|
||||
@@ -33,15 +36,54 @@ export function TargetSessionRouteContent() {
|
||||
const data = useData()
|
||||
const server = useServer()
|
||||
const tabs = useTabs()
|
||||
const directory = createMemo(() => data.session.get(params.id)?.location.directory)
|
||||
const [locationState, setLocationState] = createStore({ unavailableSource: "" })
|
||||
createEffect(() => {
|
||||
const session = data.session.get(params.id)
|
||||
if (!session) return
|
||||
const source = session.location.directory
|
||||
const project = data.project.get(session.projectID)
|
||||
if (
|
||||
!project ||
|
||||
data.location.info({ directory: source }) ||
|
||||
isProjectDirectory({ worktree: project.canonical, sandboxes: project.sandboxes }, source)
|
||||
)
|
||||
return
|
||||
let stale = false
|
||||
onCleanup(() => {
|
||||
stale = true
|
||||
})
|
||||
void retry(() => data.location.syncInfo({ directory: source }), { retryIf: () => !stale }).then(
|
||||
() => {
|
||||
if (!stale) setLocationState("unavailableSource", "")
|
||||
},
|
||||
() => {
|
||||
if (!stale) setLocationState("unavailableSource", source)
|
||||
},
|
||||
)
|
||||
})
|
||||
const catalogDirectory = createMemo(() => {
|
||||
const session = data.session.get(params.id)
|
||||
if (!session) return
|
||||
const source = session.location.directory
|
||||
const project = data.project.get(session.projectID)
|
||||
if (!project || source !== locationState.unavailableSource || data.location.info({ directory: source }))
|
||||
return source
|
||||
if (data.location.provider.list({ directory: source }) && data.location.model.list({ directory: source }))
|
||||
return source
|
||||
// A removed worktree cannot supply a catalog, but its session still belongs to the known project.
|
||||
return project.canonical
|
||||
})
|
||||
|
||||
return (
|
||||
<>
|
||||
<MarkSessionNotificationsViewed sessionID={() => params.id} />
|
||||
<ModelsProvider directory={directory}>
|
||||
<ModelsProvider directory={catalogDirectory}>
|
||||
<TargetSessionSettingsCommand />
|
||||
<SessionRouteErrorBoundary sessionID={params.id} serverKey={requireServerKey(params.serverKey)}>
|
||||
<Show when={tabs.pendingSession(server.key, params.id)} fallback={<ResolvedTargetSessionRoute />}>
|
||||
<Show
|
||||
when={tabs.pendingSession(server.key, params.id)}
|
||||
fallback={<ResolvedTargetSessionRoute catalogDirectory={catalogDirectory} />}
|
||||
>
|
||||
{(pending) => <PreparingSession sessionID={params.id} pending={pending()} />}
|
||||
</Show>
|
||||
</SessionRouteErrorBoundary>
|
||||
@@ -111,7 +153,7 @@ function SessionRouteErrorBoundary(props: ParentProps<{ sessionID?: string; serv
|
||||
)
|
||||
}
|
||||
|
||||
function ResolvedTargetSessionRoute() {
|
||||
function ResolvedTargetSessionRoute(props: { catalogDirectory: () => string | undefined }) {
|
||||
const params = useParams<{ id: string }>()
|
||||
const server = useServer()
|
||||
const tabs = useTabs()
|
||||
@@ -136,8 +178,8 @@ function ResolvedTargetSessionRoute() {
|
||||
>
|
||||
<Show when={directory()} fallback={<PendingSessionState sessionID={params.id} />}>
|
||||
{(value) => (
|
||||
<LocationProvider directory={value}>
|
||||
<SessionUIProvider directory={value()} server={server.key}>
|
||||
<LocationProvider directory={value} catalogDirectory={props.catalogDirectory}>
|
||||
<SessionUIProvider directory={value()} providerDirectory={props.catalogDirectory()} server={server.key}>
|
||||
<TargetSessionPage />
|
||||
</SessionUIProvider>
|
||||
</LocationProvider>
|
||||
|
||||
@@ -69,7 +69,7 @@ export function SessionWorkspaceMenu(props: {
|
||||
api: sdk.api,
|
||||
data,
|
||||
directory: props.directory,
|
||||
project: data.location.info({ directory: props.directory })?.project,
|
||||
project: { id: props.project.id, canonical: props.project.worktree, directory: props.project.worktree },
|
||||
})
|
||||
: selection
|
||||
if (!destination) return
|
||||
|
||||
@@ -14,6 +14,7 @@ import { readLocalImage } from "@/runtime/server/image"
|
||||
export function SessionUIProvider(
|
||||
props: ParentProps<{
|
||||
directory: string
|
||||
providerDirectory?: string
|
||||
server: ServerConnection.Key
|
||||
}>,
|
||||
) {
|
||||
@@ -39,7 +40,7 @@ export function SessionUIProvider(
|
||||
await data.session.sync(sessionID).catch(() => undefined)
|
||||
navigate(href(sessionID))
|
||||
}
|
||||
const providers = useProviders(directory)
|
||||
const providers = useProviders(() => props.providerDirectory ?? directory())
|
||||
const sessionUIData = createMemo(() => ({
|
||||
provider: providers.ready()
|
||||
? { all: providers.all(), default: providers.default(), connected: providers.connected().map((item) => item.id) }
|
||||
|
||||
@@ -9,11 +9,16 @@ export type { LocationContext } from "@/runtime/server/client"
|
||||
export type WorkspaceLocation = LocationContext & {
|
||||
readonly ref: LocationRef
|
||||
readonly current: LocationGetOutput | undefined
|
||||
readonly catalogDirectory: string
|
||||
}
|
||||
|
||||
const context = createSimpleContext({
|
||||
name: "Location",
|
||||
init: (props: { directory: string | Accessor<string>; workspaceID?: string | Accessor<string | undefined> }) => {
|
||||
init: (props: {
|
||||
directory: string | Accessor<string>
|
||||
workspaceID?: string | Accessor<string | undefined>
|
||||
catalogDirectory?: Accessor<string | undefined>
|
||||
}) => {
|
||||
const serverSDK = useServerSDK()
|
||||
const server = useServer()
|
||||
const data = useData()
|
||||
@@ -53,6 +58,7 @@ const context = createSimpleContext({
|
||||
...location(),
|
||||
ref: ref(),
|
||||
current: current(),
|
||||
catalogDirectory: props.catalogDirectory?.() ?? ref().directory,
|
||||
}))
|
||||
},
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user