From e926dff5186c25e87259b6d90b121551c58c7f37 Mon Sep 17 00:00:00 2001 From: LukeParkerDev <10430890+Hona@users.noreply.github.com> Date: Wed, 2 Sep 2026 10:14:48 +1000 Subject: [PATCH 1/2] fix(app): keep sync failures out of location recovery --- .../session-location-missing.spec.ts | 33 ++- .../regression/session-location-sync.spec.ts | 202 ++++++++++++++++++ .../app/src/runtime/server/errors.test.ts | 43 +++- packages/app/src/runtime/server/errors.ts | 13 ++ packages/app/src/session/route.tsx | 8 +- .../app/src/session/session-resolution.ts | 8 +- packages/app/src/workspaces/location.tsx | 75 +++++-- .../test-browser/session-resolution.test.ts | 38 +++- .../client/src/promise/generated/client.ts | 2 +- .../client/src/promise/generated/types.ts | 9 + packages/client/src/solid/data.ts | 67 ++++-- packages/client/test/solid-data.test.ts | 172 +++++++++++++++ packages/protocol/openapi.json | 30 +++ packages/protocol/src/api.ts | 4 +- packages/protocol/src/errors.ts | 11 + packages/protocol/src/groups/location.ts | 2 + packages/protocol/test/location.test.ts | 24 +++ packages/server/src/handlers/location.ts | 53 +++-- packages/server/src/routes.ts | 2 + packages/server/test/location.test.ts | 134 ++++++++++++ 20 files changed, 858 insertions(+), 72 deletions(-) create mode 100644 packages/app/e2e/regression/session-location-sync.spec.ts create mode 100644 packages/protocol/test/location.test.ts create mode 100644 packages/server/test/location.test.ts diff --git a/packages/app/e2e/regression/session-location-missing.spec.ts b/packages/app/e2e/regression/session-location-missing.spec.ts index 3167bd3bade7..f7880e9aff13 100644 --- a/packages/app/e2e/regression/session-location-missing.spec.ts +++ b/packages/app/e2e/regression/session-location-missing.spec.ts @@ -1,6 +1,6 @@ import { expect, test } from "@playwright/test" import { base64Encode } from "@opencode-ai/util/encode" -import type { OpenCodeEvent } from "@opencode-ai/client/promise" +import type { LocationNotFoundError, OpenCodeEvent } from "@opencode-ai/client/promise" import { fixture } from "../smoke/session-timeline.fixture" import { mockOpenCodeServer } from "../utils/mock-server" @@ -11,6 +11,8 @@ test("keeps history visible and recovers the composer by choosing another direct const session = { id: sessionID, projectID: fixture.project.id, directory, title: "Missing location" } const events: OpenCodeEvent[] = [] const moves: unknown[] = [] + let missing = false + let refreshes = 0 await mockOpenCodeServer(page, { directory: destination, project: { ...fixture.project, worktree: destination }, @@ -23,13 +25,26 @@ test("keeps history visible and recovers the composer by choosing another direct }), }) await page.route("**/api/**", (route) => { - if (new URL(route.request().url()).searchParams.get("location[directory]") !== directory) return route.fallback() - return route.fulfill({ status: 500, body: "", headers: { "access-control-allow-origin": "*" } }) + const url = new URL(route.request().url()) + if (missing && url.pathname === `/api/session/${sessionID}`) { + refreshes++ + if (refreshes === 1) + return route.fulfill({ status: 503, body: "", headers: { "access-control-allow-origin": "*" } }) + } + if (url.pathname !== "/api/location" || url.searchParams.get("location[directory]") !== directory) + return route.fallback() + missing = true + return route.fulfill({ + status: 404, + json: { _tag: "LocationNotFoundError", directory, message: "Missing directory" } satisfies LocationNotFoundError, + headers: { "access-control-allow-origin": "*" }, + }) }) await page.goto(`/server/${base64Encode(fixture.serverKey)}/session/${sessionID}`) await expect(page.getByText("Keep this session history", { exact: true })).toBeVisible() await expect(page.getByRole("status")).toContainText("Session location unavailable") await expect(page.getByRole("status")).toContainText(directory) + expect(refreshes).toBe(2) await expect(page.getByRole("textbox", { name: "Prompt", exact: true })).toHaveCount(0) const choose = page.getByRole("button", { name: "Choose directory", exact: true }) await expect(choose).toBeEnabled() @@ -116,8 +131,16 @@ for (const create of [false, true]) { await page.route("**/api/**", async (route) => { const url = new URL(route.request().url()) const headers = { "access-control-allow-origin": "*" } - if (url.searchParams.get("location[directory]") === directory) - return route.fulfill({ status: 500, body: "", headers }) + if (url.pathname === "/api/location" && url.searchParams.get("location[directory]") === directory) + return route.fulfill({ + status: 404, + json: { + _tag: "LocationNotFoundError", + directory, + message: "Missing directory", + } satisfies LocationNotFoundError, + headers, + }) if (url.pathname === `/api/worktree/${fixture.project.id}`) { if (route.request().method() === "GET") { listingRequested.resolve() diff --git a/packages/app/e2e/regression/session-location-sync.spec.ts b/packages/app/e2e/regression/session-location-sync.spec.ts new file mode 100644 index 000000000000..ed517b904152 --- /dev/null +++ b/packages/app/e2e/regression/session-location-sync.spec.ts @@ -0,0 +1,202 @@ +import { expect, test } from "@playwright/test" +import { base64Encode } from "@opencode-ai/util/encode" +import type { LocationNotFoundError } from "@opencode-ai/client/promise" +import { fixture } from "../smoke/session-timeline.fixture" +import { mockOpenCodeServer } from "../utils/mock-server" +import { installSseTransport } from "../utils/sse-transport" + +for (const endpoint of ["/api/location", "/api/agent"]) { + for (const recover of [false, true]) { + test(`keeps the composer when ${endpoint} ${recover ? "recovers on retry" : "fails"}`, async ({ page }) => { + const directory = "/projects/working-tree" + const sessionID = "ses_location_sync_failure" + await mockOpenCodeServer(page, { + directory: fixture.directory, + project: fixture.project, + provider: fixture.provider, + sessions: [{ id: sessionID, projectID: fixture.project.id, directory, title: "Workspace sync" }], + fileList: () => [], + pageMessages: () => ({ + items: [{ id: "msg_saved", type: "user", text: "Keep working in this worktree", time: { created: 1 } }], + }), + }) + let requests = 0 + await page.route("**/api/**", (route) => { + const url = new URL(route.request().url()) + if (url.pathname !== endpoint || url.searchParams.get("location[directory]") !== directory) + return route.fallback() + requests++ + if (recover && requests > 1) return route.fallback() + return route.fulfill({ status: 500, body: "", headers: { "access-control-allow-origin": "*" } }) + }) + const failure = page.waitForResponse( + (response) => new URL(response.url()).pathname === endpoint && response.status() === 500, + ) + const recovered = recover + ? page.waitForResponse((response) => { + const url = new URL(response.url()) + return ( + url.pathname === endpoint && url.searchParams.get("location[directory]") === directory && response.ok() + ) + }) + : undefined + await page.goto(`/server/${base64Encode(fixture.serverKey)}/session/${sessionID}`) + await failure + await expect(page.getByText("Keep working in this worktree", { exact: true })).toBeVisible() + const prompt = page.getByRole("textbox", { name: "Prompt", exact: true }) + await expect(prompt).toBeEditable() + await prompt.fill("Continue after reconnecting") + await expect(prompt).toHaveText("Continue after reconnecting") + if (recovered) { + await recovered + await expect(prompt).toHaveText("Continue after reconnecting") + expect(requests).toBe(2) + } + await expect(page.getByText("Session location unavailable", { exact: true })).toHaveCount(0) + await expect(page.getByRole("button", { name: "Choose directory", exact: true })).toHaveCount(0) + await page.screenshot({ path: test.info().outputPath("location-sync.png") }) + }) + } +} + +test("checks current session placement before offering to recover a missing location", async ({ page }) => { + const directory = "/projects/old-tree" + const destination = "/projects/current-tree" + const sessionID = "ses_location_moved_while_loading" + const session = { id: sessionID, projectID: fixture.project.id, directory, title: "Moved session" } + const refresh = Promise.withResolvers() + const release = Promise.withResolvers() + await mockOpenCodeServer(page, { + directory: fixture.directory, + project: fixture.project, + provider: fixture.provider, + sessions: [session], + fileList: () => [], + pageMessages: () => ({ + items: [{ id: "msg_saved", type: "user", text: "Follow the session move", time: { created: 1 } }], + }), + }) + await page.route("**/api/**", async (route) => { + const url = new URL(route.request().url()) + if (url.pathname === "/api/location" && url.searchParams.get("location[directory]") === directory) { + session.directory = destination + return route.fulfill({ + status: 404, + json: { + _tag: "LocationNotFoundError", + directory, + message: "Missing directory", + } satisfies LocationNotFoundError, + headers: { "access-control-allow-origin": "*" }, + }) + } + if (url.pathname === `/api/session/${sessionID}` && session.directory === destination) { + refresh.resolve() + await release.promise + } + return route.fallback() + }) + const resolved = page.waitForResponse((response) => { + const url = new URL(response.url()) + return ( + url.pathname === "/api/location" && url.searchParams.get("location[directory]") === destination && response.ok() + ) + }) + await page.goto(`/server/${base64Encode(fixture.serverKey)}/session/${sessionID}`) + await refresh.promise + const prompt = page.getByRole("textbox", { name: "Prompt", exact: true }) + await expect(prompt).toBeEditable() + await prompt.fill("Keep this draft") + await expect(page.getByText("Session location unavailable", { exact: true })).toHaveCount(0) + release.resolve() + await resolved + await expect(prompt).toHaveText("Keep this draft") + await expect(page.getByText("Follow the session move", { exact: true })).toBeVisible() + await expect(page.getByRole("button", { name: "Choose directory", exact: true })).toHaveCount(0) +}) + +test("refreshes a session moved during disconnection without losing the draft", async ({ page }) => { + const directory = "/projects/before-reconnect" + const destination = "/projects/after-reconnect" + const sessionID = "ses_location_reconnect" + const session = { id: sessionID, projectID: fixture.project.id, directory, title: "Reconnected session" } + const transport = await installSseTransport(page, { server: fixture.serverKey }) + await mockOpenCodeServer(page, { + directory: fixture.directory, + project: fixture.project, + provider: fixture.provider, + sessions: [session], + fileList: () => [], + pageMessages: () => ({ + items: [{ id: "msg_saved", type: "user", text: "Resume in the current worktree", time: { created: 1 } }], + }), + }) + await page.goto(`/server/${base64Encode(fixture.serverKey)}/session/${sessionID}`) + const prompt = page.getByRole("textbox", { name: "Prompt", exact: true }) + await expect(prompt).toBeEditable() + await prompt.fill("Draft before disconnect") + const connection = await transport.waitForConnection() + const resolved = page.waitForResponse((response) => { + const url = new URL(response.url()) + return ( + url.pathname === "/api/location" && url.searchParams.get("location[directory]") === destination && response.ok() + ) + }) + session.directory = destination + await transport.close() + await transport.waitForConnection({ after: connection.id }) + await resolved + await expect(prompt).toHaveText("Draft before disconnect") + await expect(page.getByText("Resume in the current worktree", { exact: true })).toBeVisible() + await expect(page.getByText("Session location unavailable", { exact: true })).toHaveCount(0) +}) + +test("ignores an old missing-location response after reconnecting", async ({ page }) => { + const directory = "/projects/reconnected-tree" + const sessionID = "ses_location_stale_response" + const requested = Promise.withResolvers() + const release = Promise.withResolvers() + const transport = await installSseTransport(page, { server: fixture.serverKey }) + await mockOpenCodeServer(page, { + directory: fixture.directory, + project: fixture.project, + provider: fixture.provider, + sessions: [{ id: sessionID, projectID: fixture.project.id, directory }], + fileList: () => [], + pageMessages: () => ({ items: [] }), + }) + let requests = 0 + await page.route("**/api/location?**", async (route) => { + if (new URL(route.request().url()).searchParams.get("location[directory]") !== directory) return route.fallback() + requests++ + if (requests > 1) return route.fallback() + requested.resolve() + await release.promise + return route.fulfill({ + status: 404, + json: { _tag: "LocationNotFoundError", directory, message: "Missing directory" } satisfies LocationNotFoundError, + headers: { "access-control-allow-origin": "*" }, + }) + }) + await page.goto(`/server/${base64Encode(fixture.serverKey)}/session/${sessionID}`) + await requested.promise + const prompt = page.getByRole("textbox", { name: "Prompt", exact: true }) + await expect(prompt).toBeEditable() + await prompt.fill("Keep typing here") + const connection = await transport.waitForConnection() + const metadata = page.waitForResponse((response) => new URL(response.url()).pathname === `/api/session/${sessionID}`) + const resolved = page.waitForResponse((response) => { + const url = new URL(response.url()) + return ( + url.pathname === "/api/location" && url.searchParams.get("location[directory]") === directory && response.ok() + ) + }) + await transport.close() + await transport.waitForConnection({ after: connection.id }) + await metadata + release.resolve() + await resolved + await expect(prompt).toHaveText("Keep typing here") + await expect(page.getByRole("button", { name: "Choose directory", exact: true })).toHaveCount(0) + expect(requests).toBe(2) +}) diff --git a/packages/app/src/runtime/server/errors.test.ts b/packages/app/src/runtime/server/errors.test.ts index 2634bb156b85..8ac6a8b48181 100644 --- a/packages/app/src/runtime/server/errors.test.ts +++ b/packages/app/src/runtime/server/errors.test.ts @@ -1,7 +1,12 @@ import { describe, expect, test } from "bun:test" -import type { SessionNotFoundError } from "@opencode-ai/client/promise" +import type { LocationNotFoundError, SessionNotFoundError } from "@opencode-ai/client/promise" import type { ConfigInvalidError, ProviderModelNotFoundError } from "./errors" -import { formatServerError, isSessionNotFoundError, parseReadableConfigInvalidError } from "./errors" +import { + formatServerError, + isLocationNotFoundError, + isSessionNotFoundError, + parseReadableConfigInvalidError, +} from "./errors" function fill(text: string, vars?: Record) { if (!vars) return text @@ -32,6 +37,40 @@ function useLanguageMock() { const language = useLanguageMock() +describe("isLocationNotFoundError", () => { + const location = { directory: "/projects/missing" } + const body = { + _tag: "LocationNotFoundError", + directory: location.directory, + message: "Missing directory", + } satisfies LocationNotFoundError + + test("requires a confirmed missing local directory for this location", () => { + expect(isLocationNotFoundError(new Error("Missing directory", { cause: { body, status: 404 } }), location)).toBe( + true, + ) + expect(isLocationNotFoundError(new Error("Missing directory", { cause: body }), location)).toBe(true) + expect(isLocationNotFoundError(body, location)).toBe(true) + expect(isLocationNotFoundError(body, { directory: "/projects/other" })).toBe(false) + expect(isLocationNotFoundError(body, { ...location, workspaceID: "wrk_remote" })).toBe(false) + expect(isLocationNotFoundError({ ...body, workspaceID: "wrk_remote" }, location)).toBe(false) + }) + + test("does not classify connection, configuration, or generic HTTP failures as missing", () => { + for (const error of [ + new TypeError("Failed to fetch"), + new DOMException("Aborted", "AbortError"), + new Error("Not Found", { cause: { status: 404, body: "Not Found" } }), + new Error("Internal Server Error", { cause: { status: 500, body: "" } }), + { _tag: "ConfigInvalidError", directory: location.directory }, + { _tag: "LocationNotFoundError" }, + undefined, + ]) { + expect(isLocationNotFoundError(error, location)).toBe(false) + } + }) +}) + describe("parseReadableConfigInvalidError", () => { test("formats issues with file path", () => { const error = { diff --git a/packages/app/src/runtime/server/errors.ts b/packages/app/src/runtime/server/errors.ts index 71cbbf8ca833..14acfe482b03 100644 --- a/packages/app/src/runtime/server/errors.ts +++ b/packages/app/src/runtime/server/errors.ts @@ -1,3 +1,5 @@ +import type { LocationRef } from "@opencode-ai/client/promise" + export type ConfigInvalidError = { name: "ConfigInvalidError" data: { @@ -61,6 +63,17 @@ export function isSessionNotFoundError(error: unknown, sessionID: string) { return value._tag === "SessionNotFoundError" && value.sessionID === sessionID } +export function isLocationNotFoundError(error: unknown, location: LocationRef) { + const unwrapped = unwrapNamedError(error) + if (typeof unwrapped !== "object" || unwrapped === null) return false + const value = unwrapped as Record + return ( + value._tag === "LocationNotFoundError" && + value.directory === location.directory && + value.workspaceID === location.workspaceID + ) +} + function isConfigInvalidErrorLike(error: unknown): error is ConfigInvalidError { if (typeof error !== "object" || error === null) return false const o = error as Record diff --git a/packages/app/src/session/route.tsx b/packages/app/src/session/route.tsx index db2b8a069298..13ddf827df9c 100644 --- a/packages/app/src/session/route.tsx +++ b/packages/app/src/session/route.tsx @@ -116,7 +116,7 @@ function ResolvedTargetSessionRoute() { const current = createSessionResolution( () => params.id, () => data.session, - { children: true }, + { children: true, connected: () => server.ctx.sdk.connection.status() === "connected" }, ) const directory = createMemo(() => current()?.location.directory) @@ -133,7 +133,11 @@ function ResolvedTargetSessionRoute() { > }> {(value) => ( - + current()?.location.workspaceID} + sessionID={() => params.id} + > diff --git a/packages/app/src/session/session-resolution.ts b/packages/app/src/session/session-resolution.ts index 357f14fffc20..7778636be6e6 100644 --- a/packages/app/src/session/session-resolution.ts +++ b/packages/app/src/session/session-resolution.ts @@ -34,7 +34,7 @@ type Resolution = { id: string; store: SessionStore } & ( export function createSessionResolution( sessionID: () => string | undefined, sessions: () => SessionStore, - options?: { children?: boolean }, + options?: { children?: boolean; connected?: () => boolean }, ) { const cached = createMemo(() => { const id = sessionID() @@ -46,15 +46,15 @@ export function createSessionResolution( // Start independent reads before constructing the selected view, including // when its metadata is cached but its transcript has never been loaded. createRenderEffect( - on([sessionID, sessions] as const, ([id, store]) => { - if (!id) return + on([sessionID, sessions, () => options?.connected?.() ?? true] as const, ([id, store, connected]) => { + if (!id || !connected) return let stale = false onCleanup(() => { stale = true }) // The timeline owns message errors; metadata resolution stays independent. void store.message.sync(id).catch(() => undefined) - if (cached() && !options?.children) { + if (cached() && !options?.children && !options?.connected) { setStatus({ id, store, state: "settled" }) return } diff --git a/packages/app/src/workspaces/location.tsx b/packages/app/src/workspaces/location.tsx index 0a4c0add45db..8e62bab03a17 100644 --- a/packages/app/src/workspaces/location.tsx +++ b/packages/app/src/workspaces/location.tsx @@ -1,8 +1,11 @@ import { createSimpleContext } from "@opencode-ai/ui/context" import type { LocationGetOutput, LocationRef } from "@opencode-ai/client/promise" -import { type Accessor, createEffect, createMemo, createSignal } from "solid-js" +import { retry } from "@opencode-ai/util/retry" +import { type Accessor, createEffect, createMemo, onCleanup } from "solid-js" +import { createStore } from "solid-js/store" import { type LocationContext, useServerSDK } from "@/runtime/server/client" import { useData } from "@/runtime/server/current" +import { isLocationNotFoundError } from "@/runtime/server/errors" export type { LocationContext } from "@/runtime/server/client" export type WorkspaceLocation = LocationContext & { @@ -13,32 +16,64 @@ export type WorkspaceLocation = LocationContext & { const context = createSimpleContext({ name: "Location", - init: (props: { directory: string | Accessor; workspaceID?: string | Accessor }) => { + init: (props: { + directory: string | Accessor + workspaceID?: string | Accessor + sessionID?: Accessor + }) => { const serverSDK = useServerSDK() const data = useData() - const ref = createMemo(() => ({ - directory: typeof props.directory === "function" ? props.directory() : props.directory, - workspaceID: typeof props.workspaceID === "function" ? props.workspaceID() : props.workspaceID, - })) + const ref = createMemo( + () => ({ + directory: typeof props.directory === "function" ? props.directory() : props.directory, + workspaceID: typeof props.workspaceID === "function" ? props.workspaceID() : props.workspaceID, + }), + undefined, + { + equals: (previous, next) => previous.directory === next.directory && previous.workspaceID === next.workspaceID, + }, + ) const current = createMemo(() => data.location.info(ref())) - const [error, setError] = createSignal<{ readonly location: LocationRef; readonly cause: unknown }>() - let generation = 0 + const [state, setState] = createStore<{ error?: WorkspaceLocation["error"] }>({}) createEffect(() => { const location = ref() + const sessionID = props.sessionID?.() + let stale = false + onCleanup(() => { + stale = true + }) + setState("error", undefined) if (serverSDK.connection.status() !== "connected") return - const attempt = ++generation - setError(undefined) - void data.location.sync(location).catch((cause) => { - const latest = ref() - if ( - generation !== attempt || - latest.directory !== location.directory || - latest.workspaceID !== location.workspaceID - ) - return - setError({ location, cause }) + void retry(() => (stale ? Promise.resolve() : data.location.syncInfo(location)), { + retryIf: (cause) => !stale && !isLocationNotFoundError(cause, location), }) + .then( + () => { + if (stale) return + // Ancillary services failing do not mean the directory is missing. + void retry(() => (stale ? Promise.resolve() : data.location.sync(location)), { + retryIf: () => !stale, + }).catch(() => undefined) + }, + async (cause) => { + if (stale || !isLocationNotFoundError(cause, location)) return + if (sessionID) { + // Finish route hydration before checking for a move missed by this client. + await data.session.sync(sessionID, { children: true }).catch(() => undefined) + if (stale) return + data.session.invalidate(sessionID) + await retry(() => (stale ? Promise.resolve() : data.session.sync(sessionID)), { + retryIf: () => !stale, + }) + const current = data.session.get(sessionID)?.location + if (current?.directory !== location.directory || current.workspaceID !== location.workspaceID) return + } + if (stale) return + setState("error", { location, cause }) + }, + ) + .catch(() => undefined) }) const location = createMemo(() => serverSDK.ensureDirSdkContext(current()?.directory ?? ref().directory)) @@ -46,7 +81,7 @@ const context = createSimpleContext({ ...location(), ref: ref(), current: current(), - error: error(), + error: state.error, })) }, }) diff --git a/packages/app/test-browser/session-resolution.test.ts b/packages/app/test-browser/session-resolution.test.ts index 614c5c2de6f8..1c71c36d55dd 100644 --- a/packages/app/test-browser/session-resolution.test.ts +++ b/packages/app/test-browser/session-resolution.test.ts @@ -1,5 +1,6 @@ import { expect, test } from "bun:test" import { createRoot, createSignal } from "solid-js" +import { createStore } from "solid-js/store" import { createSessionResolution } from "@/session/session-resolution" type Session = { id: string; directory: string } @@ -34,9 +35,10 @@ function createFixture(initial: Record = {}) { }, }, }, - settle(id: string) { - setCache({ ...cache(), [id]: sessionOf(id) }) + settle(id: string, directory = `/dir/${id}`) { + setCache({ ...cache(), [id]: { id, directory } }) deferred.get(id)?.resolve(undefined) + deferred.delete(id) }, fail(id: string, error: unknown) { deferred.get(id)?.reject(error) @@ -59,6 +61,38 @@ const flush = async () => { await Promise.resolve() } +test("refreshes the current session on reconnect while keeping cached content visible", async () => { + await createRoot(async (dispose) => { + const fixture = createFixture({ ses_a: sessionOf("ses_a") }) + const [connection, setConnection] = createStore({ connected: false }) + const current = createSessionResolution( + () => "ses_a", + () => fixture.sessions, + { connected: () => connection.connected }, + ) + + expect(current()).toEqual(sessionOf("ses_a")) + expect(fixture.resolves).toEqual([]) + await flush() + setConnection("connected", true) + expect(fixture.resolves).toEqual(["ses_a"]) + fixture.settle("ses_a") + await flush() + + setConnection("connected", false) + expect(current()).toEqual(sessionOf("ses_a")) + expect(fixture.resolves).toEqual(["ses_a"]) + setConnection("connected", true) + expect(fixture.resolves).toEqual(["ses_a", "ses_a"]) + expect(current()).toEqual(sessionOf("ses_a")) + expect(fixture.messages.syncs).toEqual(["ses_a", "ses_a"]) + fixture.settle("ses_a", "/worktrees/moved") + await flush() + expect(current()?.directory).toBe("/worktrees/moved") + dispose() + }) +}) + test("starts metadata and messages in parallel once the route has a session ID", async () => { await createRoot(async (dispose) => { const fixture = createFixture() diff --git a/packages/client/src/promise/generated/client.ts b/packages/client/src/promise/generated/client.ts index 62319ccffa8c..1be6eda745fd 100644 --- a/packages/client/src/promise/generated/client.ts +++ b/packages/client/src/promise/generated/client.ts @@ -425,7 +425,7 @@ export function make(options: ClientOptions) { path: `/api/location`, query: { location: input?.["location"] }, successStatus: 200, - declaredStatuses: [401, 400], + declaredStatuses: [404, 401, 400], empty: false, }, requestOptions, diff --git a/packages/client/src/promise/generated/types.ts b/packages/client/src/promise/generated/types.ts index eab93ff68143..a5208559ab2d 100644 --- a/packages/client/src/promise/generated/types.ts +++ b/packages/client/src/promise/generated/types.ts @@ -2349,6 +2349,15 @@ export type InvalidRequestError = { export const isInvalidRequestError = (value: unknown): value is InvalidRequestError => typeof value === "object" && value !== null && "_tag" in value && value["_tag"] === "InvalidRequestError" +export type LocationNotFoundError = { + readonly _tag: "LocationNotFoundError" + readonly directory: string + readonly workspaceID?: string + readonly message: string +} +export const isLocationNotFoundError = (value: unknown): value is LocationNotFoundError => + typeof value === "object" && value !== null && "_tag" in value && value["_tag"] === "LocationNotFoundError" + export type AgentNotFoundError = { readonly _tag: "AgentNotFoundError" readonly agentID: string diff --git a/packages/client/src/solid/data.ts b/packages/client/src/solid/data.ts index c18f2bb6c37b..be6b77c038b6 100644 --- a/packages/client/src/solid/data.ts +++ b/packages/client/src/solid/data.ts @@ -217,6 +217,7 @@ export function createData(config: CreateDataInput) { ) const messageIndex = new Map>() const sync = createSync() + const sessionReads = new Set>() let activeUpdates: Map | undefined function setSessionActive(sessionID: string, status: DataSessionStatus) { @@ -657,6 +658,12 @@ export function createData(config: CreateDataInput) { return } case "session.moved": { + // Family reads can include children that are not cached yet. + for (const moves of sessionReads) + moves.set(event.data.sessionID, { + ...event.data, + projectID: event.data.projectID ?? moves.get(event.data.sessionID)?.projectID, + }) const current = store.session.info[event.data.sessionID] if (current) { const previous = { @@ -1511,28 +1518,44 @@ export function createData(config: CreateDataInput) { }, sync(sessionID: string, options?: { children?: boolean }) { return sync.run(options?.children ? `session.family:${sessionID}` : `session:${sessionID}`, async () => { - const [info, children] = await Promise.all([ - api().session.get({ sessionID }), - options?.children - ? api() - .session.list({ parentID: sessionID, order: "desc" }) - .then((response) => response.data) - : [], - ]) - const sessions = [info, ...children] - batch(() => { - setStore( - "session", - "info", - produce((draft) => { - for (const session of sessions) draft[session.id] = session - }), - ) - for (const session of sessions) { - sync.complete(`session:${session.id}`) - registerSession(session.id) - } - }) + const moves = new Map() + sessionReads.add(moves) + try { + const [info, children] = await Promise.all([ + api().session.get({ sessionID }), + options?.children + ? api() + .session.list({ parentID: sessionID, order: "desc" }) + .then((response) => response.data) + : [], + ]) + const sessions = [info, ...children] + batch(() => { + setStore( + "session", + "info", + produce((draft) => { + for (const session of sessions) { + const moved = moves.get(session.id) + draft[session.id] = moved + ? { + ...session, + location: moved.location, + projectID: moved.projectID ?? session.projectID, + subpath: moved.subpath, + } + : session + } + }), + ) + for (const session of sessions) { + sync.complete(`session:${session.id}`) + registerSession(session.id) + } + }) + } finally { + sessionReads.delete(moves) + } }) }, invalidate(sessionID: string) { diff --git a/packages/client/test/solid-data.test.ts b/packages/client/test/solid-data.test.ts index 79251e607bb4..9d70df263300 100644 --- a/packages/client/test/solid-data.test.ts +++ b/packages/client/test/solid-data.test.ts @@ -130,6 +130,178 @@ test("preserves a live session rename across concurrent session and family reads } }) +for (const cached of [false, true]) { + test(`preserves a move during a ${cached ? "cached" : "uncached"} session read only until the next read`, async () => { + const gate = Promise.withResolvers() + const listeners = new Set[0]>() + const stale: SessionInfo = { ...session(0), subpath: "old", location: { directory: "/old", workspaceID: "old" } } + const authoritative: SessionInfo = { + ...session(0), + projectID: "authoritative", + location: { directory: "/authoritative" }, + subpath: "new", + } + let requests = 0 + const api = OpenCode.make({ + baseUrl: "http://opencode.local", + fetch: async (input, init) => { + const request = input instanceof Request ? input : new Request(input, init) + if (!request.url.endsWith(`/api/session/${stale.id}`)) throw new Error(`Unexpected request: ${request.url}`) + requests++ + if (requests > 1) return Response.json({ data: authoritative }) + await gate.promise + return Response.json({ data: stale }) + }, + }) + const setup = createRoot((dispose) => ({ + data: createData({ + api: () => api, + directory: "/project", + event: { + on: () => () => {}, + listen(handler) { + listeners.add(handler) + return () => listeners.delete(handler) + }, + }, + }), + dispose, + })) + + try { + if (cached) setup.data.session.remember(stale) + setup.data.session.invalidate(stale.id) + const initial = setup.data.session.sync(stale.id) + await wait(() => requests === 1) + const event: OpenCodeEvent = { + id: "evt_moved", + created: 1, + type: "session.moved", + durable: { aggregateID: stale.id, seq: 1, version: 1 }, + data: { sessionID: stale.id, location: { directory: "/moved" } }, + } + listeners.forEach((listener) => listener({ name: event.type, details: event })) + if (cached) { + expect(setup.data.session.get(stale.id)?.location.directory).toBe("/moved") + expect(setup.data.session.get(stale.id)?.projectID).toBe(stale.projectID) + expect(setup.data.session.get(stale.id)?.subpath).toBeUndefined() + } + gate.resolve() + await initial + + expect(setup.data.session.get(stale.id)).toEqual({ + ...stale, + location: { directory: "/moved" }, + subpath: undefined, + }) + setup.data.session.invalidate(stale.id) + await setup.data.session.sync(stale.id) + expect(setup.data.session.get(stale.id)).toEqual(authoritative) + expect(requests).toBe(2) + } finally { + gate.resolve() + setup.dispose() + } + }) +} + +for (const first of ["family", "ordinary"] as const) { + test(`preserves the latest parent and child moves across concurrent reads when ${first} finishes first`, async () => { + const gates = { family: Promise.withResolvers(), ordinary: Promise.withResolvers() } + const listeners = new Set[0]>() + const parent = { ...session(0), subpath: "old-parent" } + const child = { ...session(0), id: "ses_child", parentID: parent.id, subpath: "old-child" } + let requests = 0 + const api = OpenCode.make({ + baseUrl: "http://opencode.local", + fetch: async (input, init) => { + const request = input instanceof Request ? input : new Request(input, init) + const url = new URL(request.url) + requests++ + if (url.pathname === `/api/session/${child.id}`) { + await gates.ordinary.promise + return Response.json({ data: child }) + } + if (url.pathname === `/api/session/${parent.id}`) { + await gates.family.promise + return Response.json({ data: parent }) + } + if (url.pathname === "/api/session" && url.searchParams.get("parentID") === parent.id) { + await gates.family.promise + return Response.json({ data: [child], cursor: {} }) + } + throw new Error(`Unexpected request: ${request.url}`) + }, + }) + const setup = createRoot((dispose) => ({ + data: createData({ + api: () => api, + directory: "/project", + event: { + on: () => () => {}, + listen(handler) { + listeners.add(handler) + return () => listeners.delete(handler) + }, + }, + }), + dispose, + })) + + try { + const reads = { + family: setup.data.session.sync(parent.id, { children: true }), + ordinary: setup.data.session.sync(child.id), + } + await wait(() => requests === 3) + for (const seq of [1, 2]) { + for (const info of [parent, child]) { + const event: OpenCodeEvent = { + id: `evt_moved_${info.id}_${seq}`, + created: seq, + type: "session.moved", + durable: { aggregateID: info.id, seq, version: 1 }, + data: { + sessionID: info.id, + location: { directory: `/moved/${info.id}/${seq}` }, + ...(seq === 1 ? { projectID: `project_${info.id}`, subpath: "intermediate" } : {}), + }, + } + listeners.forEach((listener) => listener({ name: event.type, details: event })) + } + } + gates[first].resolve() + await reads[first] + expect(setup.data.session.get(child.id)).toEqual({ + ...child, + location: { directory: `/moved/${child.id}/2` }, + projectID: `project_${child.id}`, + subpath: undefined, + }) + gates.family.resolve() + gates.ordinary.resolve() + await Promise.all(Object.values(reads)) + + for (const info of [parent, child]) { + expect(setup.data.session.get(info.id)).toEqual({ + ...info, + location: { directory: `/moved/${info.id}/2` }, + projectID: `project_${info.id}`, + subpath: undefined, + }) + setup.data.session.invalidate(info.id) + await setup.data.session.sync(info.id) + expect(setup.data.session.get(info.id)).toEqual(info) + } + expect(requests).toBe(5) + } finally { + gates.family.resolve() + gates.ordinary.resolve() + setup.dispose() + } + }) +} + test("updates authoritative cached project metadata from live events", async () => { const listeners = new Set[0]>() const original: Project = { diff --git a/packages/protocol/openapi.json b/packages/protocol/openapi.json index 1f6b06d12247..768bff3b4087 100644 --- a/packages/protocol/openapi.json +++ b/packages/protocol/openapi.json @@ -177,6 +177,16 @@ } } } + }, + "404": { + "description": "LocationNotFoundError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/LocationNotFoundErrorEncoded" + } + } + } } }, "description": "Resolve the requested location or the server default location.", @@ -15801,6 +15811,26 @@ "required": ["directory"], "additionalProperties": false }, + "LocationNotFoundErrorEncoded": { + "type": "object", + "properties": { + "_tag": { + "type": "string", + "enum": ["LocationNotFoundError"] + }, + "directory": { + "type": "string" + }, + "workspaceID": { + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": ["_tag", "directory", "message"], + "additionalProperties": false + }, "Mcp.LocalConfigEncoded": { "type": "object", "properties": { diff --git a/packages/protocol/src/api.ts b/packages/protocol/src/api.ts index 358918030f6d..3716de9b919b 100644 --- a/packages/protocol/src/api.ts +++ b/packages/protocol/src/api.ts @@ -37,7 +37,6 @@ import { ConfigGroup } from "./groups/config.js" import { WorkspaceGroup } from "./groups/workspace.js" type LocationGroups = - | HttpApiGroup.AddMiddleware | HttpApiGroup.AddMiddleware | HttpApiGroup.AddMiddleware | HttpApiGroup.AddMiddleware @@ -86,6 +85,7 @@ type ApiGroups< > = | typeof HealthGroup | typeof ServerGroup + | typeof LocationGroup | typeof DebugGroup | typeof MigrationGroup | typeof WorktreeGroup @@ -153,7 +153,7 @@ const makeApiFromGroup = < HttpApi.make("server") .add(HealthGroup) .add(ServerGroup) - .add(LocationGroup.middleware(locationMiddleware)) + .add(LocationGroup) .add(AgentGroup.middleware(locationMiddleware)) .add(PluginGroup.middleware(locationMiddleware)) .add(makeSessionGroup(sessionLocationMiddleware)) diff --git a/packages/protocol/src/errors.ts b/packages/protocol/src/errors.ts index 54f8b396638f..fabc78499b20 100644 --- a/packages/protocol/src/errors.ts +++ b/packages/protocol/src/errors.ts @@ -1,5 +1,6 @@ import { Schema } from "effect" import { Skill } from "@opencode-ai/schema/skill" +import { optional } from "@opencode-ai/schema/schema" export class InvalidRequestError extends Schema.TaggedError()( "InvalidRequestError", @@ -91,6 +92,16 @@ export class ProjectNotFoundError extends Schema.TaggedError()( + "LocationNotFoundError", + { + directory: Schema.String, + workspaceID: optional(Schema.String), + message: Schema.String, + }, + { httpApiStatus: 404 }, +) {} + export class AgentNotFoundError extends Schema.TaggedError()( "AgentNotFoundError", { diff --git a/packages/protocol/src/groups/location.ts b/packages/protocol/src/groups/location.ts index 4723b8a46a93..1e56aedd4417 100644 --- a/packages/protocol/src/groups/location.ts +++ b/packages/protocol/src/groups/location.ts @@ -1,6 +1,7 @@ import { Location } from "@opencode-ai/schema/location" import { Schema } from "effect" import { HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi" +import { LocationNotFoundError } from "../errors.js" export const LocationQuery = Schema.Struct({ location: Schema.optional( @@ -31,6 +32,7 @@ export const LocationGroup = HttpApiGroup.make("server.location") HttpApiEndpoint.get("location.get", "/api/location", { query: LocationQuery, success: Location.Info, + error: LocationNotFoundError, }) .annotateMerge(locationQueryOpenApi) .annotateMerge( diff --git a/packages/protocol/test/location.test.ts b/packages/protocol/test/location.test.ts new file mode 100644 index 000000000000..0727663fae38 --- /dev/null +++ b/packages/protocol/test/location.test.ts @@ -0,0 +1,24 @@ +import { expect, test } from "bun:test" +import { Schema } from "effect" +import { OpenApi } from "effect/unstable/httpapi" +import { ClientApi } from "../src/client.js" +import { LocationNotFoundError } from "../src/errors.js" + +test("location errors omit an absent workspace ID", () => { + expect( + Schema.encodeSync(LocationNotFoundError)( + new LocationNotFoundError({ directory: "/missing", workspaceID: undefined, message: "Missing directory" }), + ), + ).toEqual({ _tag: "LocationNotFoundError", directory: "/missing", message: "Missing directory" }) +}) + +test("only location.get declares LocationNotFoundError", () => { + const document = OpenApi.fromApi(ClientApi) + const paths = Object.entries(document.paths).filter(([, operation]) => + JSON.stringify(operation).includes("LocationNotFoundError"), + ) + expect(paths.map(([path]) => path)).toEqual(["/api/location"]) + expect(document.paths["/api/location"]?.get?.responses?.["404"]).toMatchObject({ + description: "LocationNotFoundError", + }) +}) diff --git a/packages/server/src/handlers/location.ts b/packages/server/src/handlers/location.ts index ded8c8c2e0ac..cd3f3480f659 100644 --- a/packages/server/src/handlers/location.ts +++ b/packages/server/src/handlers/location.ts @@ -1,18 +1,47 @@ import { Location } from "@opencode-ai/core/location" -import { Effect } from "effect" +import { LocationServiceMap } from "@opencode-ai/core/location-service-map" +import { LocationNotFoundError } from "@opencode-ai/protocol/errors" +import { FSUtil } from "@opencode-ai/util/fs-util" +import { Effect, Schema } from "effect" import { HttpApiBuilder } from "effect/unstable/httpapi" import { Api } from "../api" +import { requestRef } from "../location" export const LocationHandler = HttpApiBuilder.group(Api, "server.location", (handlers) => - handlers.handle( - "location.get", - Effect.fn(function* () { - const location = yield* Location.Service - return new Location.Info({ - directory: location.directory, - workspaceID: location.workspaceID, - project: location.project, - }) - }), - ), + Effect.gen(function* () { + const locations = yield* LocationServiceMap.Service + const fs = yield* FSUtil.Service + return handlers.handle( + "location.get", + Effect.fn(function* (ctx) { + const ref = requestRef(ctx.request) + // Workspace placement owns its filesystem. Check local paths before booting location services. + if (ref.workspaceID === undefined) { + const info = yield* fs.stat(ref.directory).pipe( + Effect.catchIf( + (error) => + error.reason._tag === "NotFound" || + (error.reason._tag === "BadResource" && + Schema.is(Schema.Struct({ code: Schema.Literal("ENOTDIR") }))(error.cause)), + () => Effect.undefined, + ), + Effect.orDie, + ) + if (info?.type !== "Directory") + return yield* new LocationNotFoundError({ + directory: ref.directory, + message: `Location directory not found: ${ref.directory}`, + }) + } + return yield* Effect.gen(function* () { + const location = yield* Location.Service + return new Location.Info({ + directory: location.directory, + workspaceID: location.workspaceID, + project: location.project, + }) + }).pipe(Effect.provide(locations.get(ref))) + }), + ) + }), ) diff --git a/packages/server/src/routes.ts b/packages/server/src/routes.ts index da582455f44d..7f33ab9e69cb 100644 --- a/packages/server/src/routes.ts +++ b/packages/server/src/routes.ts @@ -20,6 +20,7 @@ import { ShellSelect } from "@opencode-ai/core/shell/select" import { Job } from "@opencode-ai/core/job" import { Mcp } from "@opencode-ai/core/mcp/index" import { Global } from "@opencode-ai/util/global" +import { FSUtil } from "@opencode-ai/util/fs-util" import { InstructionDiscovery } from "@opencode-ai/core/instruction-discovery" import { LocationServiceMap } from "@opencode-ai/core/location-service-map" import { LocationActivity } from "@opencode-ai/core/location-activity" @@ -50,6 +51,7 @@ import type { ServerOptions } from "./options" const applicationServiceNodes = [ Global.node, + FSUtil.node, Database.node, Bus.node, EventLogger.node, diff --git a/packages/server/test/location.test.ts b/packages/server/test/location.test.ts new file mode 100644 index 000000000000..72427962c7fb --- /dev/null +++ b/packages/server/test/location.test.ts @@ -0,0 +1,134 @@ +import fs from "node:fs/promises" +import path from "node:path" +import { expect } from "bun:test" +import { Workspace } from "@opencode-ai/schema/workspace" +import { FSUtil } from "@opencode-ai/util/fs-util" +import { Effect, Layer, PlatformError } from "effect" +import { tmpdir } from "../../core/test/fixture/tmpdir" +import { it } from "../../core/test/lib/effect" +import { ServerFetch } from "../src/fetch" + +const options = { + database: { path: ":memory:" }, + models: { fetch: false }, + fs: { filewatcher: false }, +} as const + +it.live("location.get confirms missing and non-directory paths before loading location services", () => + Effect.gen(function* () { + const tmp = yield* Effect.acquireDisposable(Effect.promise(() => tmpdir("opencode-location-"))) + const file = path.join(tmp.path, "file") + yield* Effect.promise(() => fs.writeFile(file, "not a directory")) + const handler = yield* ServerFetch.make({ ...options, config: { directory: tmp.path, project: false } }) + yield* Effect.forEach([path.join(tmp.path, "missing"), file, path.join(file, "child")], (directory) => + Effect.gen(function* () { + const url = new URL("http://opencode.local/api/location") + url.searchParams.set("location[directory]", directory) + const response = yield* Effect.promise(() => handler(new Request(url))) + expect(response.status).toBe(404) + expect(yield* Effect.promise(() => response.json())).toEqual({ + _tag: "LocationNotFoundError", + directory, + message: `Location directory not found: ${directory}`, + }) + }), + ) + const loaded = yield* Effect.promise(() => handler(new Request("http://opencode.local/api/debug/location"))) + expect(yield* Effect.promise(() => loaded.json())).toEqual([]) + }), +) + +it.live("location.get resolves existing directories and detects their later removal through headers", () => + Effect.gen(function* () { + const tmp = yield* Effect.acquireDisposable(Effect.promise(() => tmpdir("opencode-location-"))) + const directory = path.join(tmp.path, "project") + yield* Effect.promise(() => fs.mkdir(directory)) + const handler = yield* ServerFetch.make({ ...options, config: { directory: tmp.path, project: false } }) + const request = () => + new Request("http://opencode.local/api/location", { + headers: { "x-opencode-directory": encodeURIComponent(directory) }, + }) + const response = yield* Effect.promise(() => handler(request())) + expect(response.status).toBe(200) + expect(yield* Effect.promise(() => response.json())).toMatchObject({ directory }) + yield* Effect.promise(() => fs.rm(directory, { recursive: true })) + const missing = yield* Effect.promise(() => handler(request())) + expect(missing.status).toBe(404) + expect(yield* Effect.promise(() => missing.json())).toMatchObject({ _tag: "LocationNotFoundError", directory }) + }), +) + +it.live("location.get does not classify permission, IO, symlink, or config failures as missing", () => + Effect.gen(function* () { + const tmp = yield* Effect.acquireDisposable(Effect.promise(() => tmpdir("opencode-location-"))) + const directory = path.join(tmp.path, "project") + yield* Effect.promise(() => fs.mkdir(directory)) + yield* Effect.promise(() => fs.writeFile(path.join(directory, "opencode.json"), "{}")) + const handler = yield* ServerFetch.make( + { ...options, config: { directory: tmp.path } }, + { + overrides: [ + FSUtil.node.replace( + FSUtil.node.mapLayer((layer) => + Layer.effect( + FSUtil.Service, + Effect.map(FSUtil.Service, (fs) => ({ + ...fs, + stat: (target: string) => { + const name = path.basename(target) + if (name !== "PermissionDenied" && name !== "Unknown" && name !== "BadResource") + return fs.stat(target) + return Effect.fail( + PlatformError.systemError({ + _tag: name, + module: "FileSystem", + method: "stat", + cause: { code: name === "BadResource" ? "ELOOP" : "EIO" }, + }), + ) + }, + readFileStringSafe: (target: string) => + target === path.join(directory, "opencode.json") + ? Effect.fail( + PlatformError.systemError({ + _tag: "Unknown", + module: "FileSystem", + method: "readFileString", + }), + ) + : fs.readFileStringSafe(target), + })), + ).pipe(Layer.provide(layer)), + ), + ), + ], + }, + ) + yield* Effect.forEach(["PermissionDenied", "Unknown", "BadResource", "project"], (name) => + Effect.gen(function* () { + const url = new URL("http://opencode.local/api/location") + url.searchParams.set("location[directory]", path.join(tmp.path, name)) + const response = yield* Effect.promise(() => handler(new Request(url))) + expect(response.status).toBe(500) + expect(yield* Effect.promise(() => response.text())).not.toContain("LocationNotFoundError") + }), + ) + }), +) + +it.live("location.get preserves workspace placement for paths absent on the host", () => + Effect.gen(function* () { + const tmp = yield* Effect.acquireDisposable(Effect.promise(() => tmpdir("opencode-location-"))) + const handler = yield* ServerFetch.make({ ...options, config: { directory: tmp.path, project: false } }) + const url = new URL("http://opencode.local/api/location") + url.searchParams.set("location[directory]", path.join(tmp.path, "workspace-only")) + const workspaceID = Workspace.ID.create() + url.searchParams.set("location[workspace]", workspaceID) + const response = yield* Effect.promise(() => handler(new Request(url))) + expect(response.status).toBe(200) + expect(yield* Effect.promise(() => response.json())).toMatchObject({ + directory: path.join(tmp.path, "workspace-only"), + workspaceID, + }) + }), +) From 2d682d70fdeaa988e758f7544530af1bfaf5e942 Mon Sep 17 00:00:00 2001 From: LukeParkerDev <10430890+Hona@users.noreply.github.com> Date: Wed, 2 Sep 2026 11:14:11 +1000 Subject: [PATCH 2/2] fix(app): separate location identity from sync failures --- .../session-location-missing.spec.ts | 216 ------------------ .../regression/session-location-sync.spec.ts | 109 +++++---- .../app/src/new-session/composer-adapter.ts | 1 + .../app/src/runtime/server/errors.test.ts | 43 +--- packages/app/src/runtime/server/errors.ts | 13 -- .../src/session/composer/location-missing.tsx | 136 ----------- packages/app/src/session/composer/region.tsx | 37 +-- packages/app/src/session/route.tsx | 6 +- .../timeline/session-workspace-menu.tsx | 1 + packages/app/src/workspaces/create.test.ts | 77 +++++-- packages/app/src/workspaces/create.ts | 5 +- packages/app/src/workspaces/location.tsx | 46 +--- .../client/src/promise/generated/client.ts | 2 +- .../client/src/promise/generated/types.ts | 9 - packages/client/src/solid/data.ts | 67 ++---- packages/client/test/solid-data.test.ts | 172 -------------- packages/protocol/openapi.json | 30 --- packages/protocol/src/api.ts | 4 +- packages/protocol/src/errors.ts | 11 - packages/protocol/src/groups/location.ts | 2 - packages/protocol/test/location.test.ts | 24 -- packages/server/src/handlers/location.ts | 53 +---- packages/server/src/routes.ts | 2 - packages/server/test/location.test.ts | 134 ----------- 24 files changed, 178 insertions(+), 1022 deletions(-) delete mode 100644 packages/app/e2e/regression/session-location-missing.spec.ts delete mode 100644 packages/app/src/session/composer/location-missing.tsx delete mode 100644 packages/protocol/test/location.test.ts delete mode 100644 packages/server/test/location.test.ts diff --git a/packages/app/e2e/regression/session-location-missing.spec.ts b/packages/app/e2e/regression/session-location-missing.spec.ts deleted file mode 100644 index f7880e9aff13..000000000000 --- a/packages/app/e2e/regression/session-location-missing.spec.ts +++ /dev/null @@ -1,216 +0,0 @@ -import { expect, test } from "@playwright/test" -import { base64Encode } from "@opencode-ai/util/encode" -import type { LocationNotFoundError, OpenCodeEvent } from "@opencode-ai/client/promise" -import { fixture } from "../smoke/session-timeline.fixture" -import { mockOpenCodeServer } from "../utils/mock-server" - -test("keeps history visible and recovers the composer by choosing another directory", async ({ page }) => { - const directory = "/projects/deleted" - const destination = "/projects/restored" - const sessionID = "ses_missing_location" - const session = { id: sessionID, projectID: fixture.project.id, directory, title: "Missing location" } - const events: OpenCodeEvent[] = [] - const moves: unknown[] = [] - let missing = false - let refreshes = 0 - await mockOpenCodeServer(page, { - directory: destination, - project: { ...fixture.project, worktree: destination }, - provider: fixture.provider, - sessions: [session], - events: () => events.splice(0), - fileList: () => [], - pageMessages: () => ({ - items: [{ id: "msg_saved", type: "user", text: "Keep this session history", time: { created: 1 } }], - }), - }) - await page.route("**/api/**", (route) => { - const url = new URL(route.request().url()) - if (missing && url.pathname === `/api/session/${sessionID}`) { - refreshes++ - if (refreshes === 1) - return route.fulfill({ status: 503, body: "", headers: { "access-control-allow-origin": "*" } }) - } - if (url.pathname !== "/api/location" || url.searchParams.get("location[directory]") !== directory) - return route.fallback() - missing = true - return route.fulfill({ - status: 404, - json: { _tag: "LocationNotFoundError", directory, message: "Missing directory" } satisfies LocationNotFoundError, - headers: { "access-control-allow-origin": "*" }, - }) - }) - await page.goto(`/server/${base64Encode(fixture.serverKey)}/session/${sessionID}`) - await expect(page.getByText("Keep this session history", { exact: true })).toBeVisible() - await expect(page.getByRole("status")).toContainText("Session location unavailable") - await expect(page.getByRole("status")).toContainText(directory) - expect(refreshes).toBe(2) - await expect(page.getByRole("textbox", { name: "Prompt", exact: true })).toHaveCount(0) - const choose = page.getByRole("button", { name: "Choose directory", exact: true }) - await expect(choose).toBeEnabled() - await choose.click() - const dialog = page.getByRole("dialog", { name: "Choose directory", exact: true }) - await expect(dialog.getByRole("combobox")).toBeFocused() - await dialog.getByRole("combobox").press("Escape") - await expect(dialog).toHaveCount(0) - await expect(choose).toBeFocused() - expect(moves).toEqual([]) - - // The server can still reject a destination after it was selected in the picker. - await page.route(`**/api/session/${sessionID}/move`, (route) => { - if (route.request().method() !== "POST") return route.fallback() - moves.push(route.request().postDataJSON()) - return route.fulfill({ - status: 400, - json: { _tag: "InvalidRequestError", message: "Destination is unavailable" }, - headers: { "access-control-allow-origin": "*" }, - }) - }) - await choose.press("Enter") - await expect(dialog.getByRole("combobox")).toBeFocused() - await dialog.getByRole("combobox").fill(destination) - await dialog.getByRole("combobox").press("Enter") - await expect(dialog.locator(".directory-picker-selection")).toHaveText(destination) - await dialog.getByRole("button", { name: "Select folder", exact: true }).click() - await expect(page.getByText("Failed to move session", { exact: true })).toBeVisible() - await expect(choose).toBeEnabled() - await expect(page.getByRole("textbox", { name: "Prompt", exact: true })).toHaveCount(0) - await expect(choose).toBeFocused() - expect(moves).toEqual([{ directory: destination }]) - - await page.route(`**/api/session/${sessionID}/move`, (route) => { - if (route.request().method() !== "POST") return route.fallback() - moves.push(route.request().postDataJSON()) - session.directory = destination - events.push({ - id: "evt_location_recovered", - type: "session.moved", - created: 2, - durable: { aggregateID: sessionID, seq: 1, version: 1 }, - data: { sessionID, location: { directory: destination }, projectID: fixture.project.id }, - }) - return route.fulfill({ status: 204, headers: { "access-control-allow-origin": "*" } }) - }) - await choose.click() - await dialog.getByRole("combobox").fill(destination) - await dialog.getByRole("combobox").press("Enter") - await expect(dialog.locator(".directory-picker-selection")).toHaveText(destination) - await dialog.getByRole("button", { name: "Select folder", exact: true }).click() - await expect(page.getByText("Session location unavailable", { exact: true })).toHaveCount(0) - await expect(page.getByRole("textbox", { name: "Prompt", exact: true })).toBeEditable() - await expect(page.locator('[data-action="composer-model"]')).toBeVisible() - await expect(page.getByText("Keep this session history", { exact: true })).toBeVisible() - await expect(page).toHaveURL(new RegExp(`/session/${sessionID}$`)) - expect(moves).toEqual([{ directory: destination }, { directory: destination }]) -}) - -for (const create of [false, true]) { - test(`recovers into ${create ? "a new" : "an existing"} worktree without resolving the deleted location`, async ({ - page, - }) => { - const canonical = "/projects/repository" - const directory = "/projects/deleted-worktree" - const destination = create ? "/projects/new-worktree" : "/projects/existing-worktree" - const sessionID = "ses_worktree_recovery" - const events: OpenCodeEvent[] = [] - const requests: { operation: string; body: unknown }[] = [] - let listing = Promise.withResolvers() - let listingRequested = Promise.withResolvers() - const moving = Promise.withResolvers() - const moveRequested = Promise.withResolvers() - await mockOpenCodeServer(page, { - directory: canonical, - project: { ...fixture.project, worktree: canonical }, - provider: fixture.provider, - sessions: [{ id: sessionID, projectID: fixture.project.id, directory }], - pageMessages: () => ({ - items: [{ id: "msg_worktree", type: "user", text: "Recover my worktree", time: { created: 1 } }], - }), - events: () => events.splice(0), - }) - await page.route("**/api/**", async (route) => { - const url = new URL(route.request().url()) - const headers = { "access-control-allow-origin": "*" } - if (url.pathname === "/api/location" && url.searchParams.get("location[directory]") === directory) - return route.fulfill({ - status: 404, - json: { - _tag: "LocationNotFoundError", - directory, - message: "Missing directory", - } satisfies LocationNotFoundError, - headers, - }) - if (url.pathname === `/api/worktree/${fixture.project.id}`) { - if (route.request().method() === "GET") { - listingRequested.resolve() - await listing.promise - return route.fulfill({ - json: [ - { directory: canonical, strategy: null }, - { directory: "/projects/existing-worktree", strategy: "git" }, - ...Array.from({ length: 20 }, (_, index) => ({ - directory: `/projects/worktree-${index}`, - strategy: "git", - })), - ], - headers, - }) - } - if (route.request().method() === "POST") { - requests.push({ operation: "create", body: route.request().postDataJSON() }) - return route.fulfill({ - json: { directory: destination, name: "new-worktree", branch: "new-worktree" }, - headers, - }) - } - } - if (url.pathname === `/api/session/${sessionID}/move` && route.request().method() === "POST") { - requests.push({ operation: "move", body: route.request().postDataJSON() }) - moveRequested.resolve() - await moving.promise - events.push({ - id: "evt_worktree_recovered", - type: "session.moved", - created: 2, - durable: { aggregateID: sessionID, seq: 1, version: 1 }, - data: { sessionID, location: { directory: destination }, projectID: fixture.project.id }, - }) - return route.fulfill({ status: 204, headers }) - } - return route.fallback() - }) - await page.goto(`/server/${base64Encode(fixture.serverKey)}/session/${sessionID}`) - await page.getByRole("button", { name: "Choose worktree", exact: true }).click() - await listingRequested.promise - await expect(page.getByRole("menuitem", { name: "Loading", exact: true })).toBeVisible() - await expect(page.getByRole("menuitem", { name: "New workspace", exact: true })).toBeVisible() - await expect(page.getByText("Recover my worktree", { exact: true })).toBeVisible() - await expect(page.getByText("Session location unavailable", { exact: true })).toBeVisible() - listing.resolve() - const existing = page.getByRole("menuitem", { name: "existing-worktree", exact: true }) - await expect(existing).toBeVisible() - await expect(existing).toHaveCSS("height", "28px") - await page.keyboard.press("Escape") - await expect(page.getByRole("menu")).toHaveCount(0) - listing = Promise.withResolvers() - listingRequested = Promise.withResolvers() - await page.getByRole("button", { name: "Choose worktree", exact: true }).click() - await listingRequested.promise - await expect(page.getByRole("menuitem", { name: "existing-worktree", exact: true })).toBeVisible() - await expect(page.getByText("Recover my worktree", { exact: true })).toBeVisible() - listing.resolve() - await page.getByRole("menuitem", { name: create ? "New workspace" : "existing-worktree", exact: true }).click() - await moveRequested.promise - await expect(page.getByRole("button", { name: "Moving session…", exact: true })).toBeDisabled() - await expect(page.getByRole("button", { name: "Choose worktree", exact: true })).toBeDisabled() - moving.resolve() - await expect(page.getByText("Session location unavailable", { exact: true })).toHaveCount(0) - await expect(page.getByRole("textbox", { name: "Prompt", exact: true })).toBeEditable() - await expect(page.getByText("Recover my worktree", { exact: true })).toBeVisible() - expect(requests).toEqual([ - ...(create ? [{ operation: "create", body: { strategy: "git", from: canonical, directory: "/projects/" } }] : []), - { operation: "move", body: { directory: destination } }, - ]) - }) -} diff --git a/packages/app/e2e/regression/session-location-sync.spec.ts b/packages/app/e2e/regression/session-location-sync.spec.ts index ed517b904152..f281b263475d 100644 --- a/packages/app/e2e/regression/session-location-sync.spec.ts +++ b/packages/app/e2e/regression/session-location-sync.spec.ts @@ -1,6 +1,5 @@ -import { expect, test } from "@playwright/test" +import { expect, test, type Page } from "@playwright/test" import { base64Encode } from "@opencode-ai/util/encode" -import type { LocationNotFoundError } from "@opencode-ai/client/promise" import { fixture } from "../smoke/session-timeline.fixture" import { mockOpenCodeServer } from "../utils/mock-server" import { installSseTransport } from "../utils/sse-transport" @@ -8,6 +7,7 @@ import { installSseTransport } from "../utils/sse-transport" for (const endpoint of ["/api/location", "/api/agent"]) { for (const recover of [false, true]) { test(`keeps the composer when ${endpoint} ${recover ? "recovers on retry" : "fails"}`, async ({ page }) => { + const recovery = recoveryRequests(page) const directory = "/projects/working-tree" const sessionID = "ses_location_sync_failure" await mockOpenCodeServer(page, { @@ -32,14 +32,14 @@ for (const endpoint of ["/api/location", "/api/agent"]) { const failure = page.waitForResponse( (response) => new URL(response.url()).pathname === endpoint && response.status() === 500, ) - const recovered = recover - ? page.waitForResponse((response) => { - const url = new URL(response.url()) - return ( - url.pathname === endpoint && url.searchParams.get("location[directory]") === directory && response.ok() - ) - }) - : undefined + const settled = page.waitForResponse((response) => { + const url = new URL(response.url()) + return ( + url.pathname === endpoint && + url.searchParams.get("location[directory]") === directory && + (recover ? response.ok() : requests === 3 && response.status() === 500) + ) + }) await page.goto(`/server/${base64Encode(fixture.serverKey)}/session/${sessionID}`) await failure await expect(page.getByText("Keep working in this worktree", { exact: true })).toBeVisible() @@ -47,25 +47,27 @@ for (const endpoint of ["/api/location", "/api/agent"]) { await expect(prompt).toBeEditable() await prompt.fill("Continue after reconnecting") await expect(prompt).toHaveText("Continue after reconnecting") - if (recovered) { - await recovered - await expect(prompt).toHaveText("Continue after reconnecting") - expect(requests).toBe(2) - } + await settled + await expect(prompt).toBeEditable() + await expect(prompt).toHaveText("Continue after reconnecting") + expect(requests).toBe(recover ? 2 : 3) await expect(page.getByText("Session location unavailable", { exact: true })).toHaveCount(0) await expect(page.getByRole("button", { name: "Choose directory", exact: true })).toHaveCount(0) + expect(recovery).toEqual([]) await page.screenshot({ path: test.info().outputPath("location-sync.png") }) }) } } -test("checks current session placement before offering to recover a missing location", async ({ page }) => { +test("follows a live session move while the agent catalog is still loading", async ({ page }) => { + const recovery = recoveryRequests(page) const directory = "/projects/old-tree" const destination = "/projects/current-tree" const sessionID = "ses_location_moved_while_loading" const session = { id: sessionID, projectID: fixture.project.id, directory, title: "Moved session" } - const refresh = Promise.withResolvers() + const requested = Promise.withResolvers() const release = Promise.withResolvers() + const transport = await installSseTransport(page, { server: fixture.serverKey }) await mockOpenCodeServer(page, { directory: fixture.directory, project: fixture.project, @@ -78,44 +80,51 @@ test("checks current session placement before offering to recover a missing loca }) await page.route("**/api/**", async (route) => { const url = new URL(route.request().url()) - if (url.pathname === "/api/location" && url.searchParams.get("location[directory]") === directory) { - session.directory = destination - return route.fulfill({ - status: 404, - json: { - _tag: "LocationNotFoundError", - directory, - message: "Missing directory", - } satisfies LocationNotFoundError, - headers: { "access-control-allow-origin": "*" }, - }) - } - if (url.pathname === `/api/session/${sessionID}` && session.directory === destination) { - refresh.resolve() + if (url.pathname === "/api/agent" && url.searchParams.get("location[directory]") === directory) { + requested.resolve() await release.promise } return route.fallback() }) - const resolved = page.waitForResponse((response) => { - const url = new URL(response.url()) - return ( - url.pathname === "/api/location" && url.searchParams.get("location[directory]") === destination && response.ok() - ) - }) await page.goto(`/server/${base64Encode(fixture.serverKey)}/session/${sessionID}`) - await refresh.promise + await requested.promise const prompt = page.getByRole("textbox", { name: "Prompt", exact: true }) await expect(prompt).toBeEditable() await prompt.fill("Keep this draft") await expect(page.getByText("Session location unavailable", { exact: true })).toHaveCount(0) - release.resolve() + await transport.waitForConnection() + const resolved = page.waitForResponse((response) => { + const url = new URL(response.url()) + return ( + url.pathname === "/api/agent" && url.searchParams.get("location[directory]") === destination && response.ok() + ) + }) + session.directory = destination + await transport.send({ + id: "evt_location_moved_while_loading", + type: "session.moved", + created: 2, + durable: { aggregateID: sessionID, seq: 1, version: 1 }, + data: { sessionID, location: { directory: destination }, projectID: fixture.project.id }, + }) await resolved + const delayed = page.waitForResponse((response) => { + const url = new URL(response.url()) + return url.pathname === "/api/agent" && url.searchParams.get("location[directory]") === directory && response.ok() + }) + release.resolve() + await delayed + await expect(prompt).toBeEditable() await expect(prompt).toHaveText("Keep this draft") await expect(page.getByText("Follow the session move", { exact: true })).toBeVisible() + await expect(page.locator('[data-type="location-switched"]').getByText(destination, { exact: true })).toBeVisible() + await expect(page.getByText("Session location unavailable", { exact: true })).toHaveCount(0) await expect(page.getByRole("button", { name: "Choose directory", exact: true })).toHaveCount(0) + expect(recovery).toEqual([]) }) test("refreshes a session moved during disconnection without losing the draft", async ({ page }) => { + const recovery = recoveryRequests(page) const directory = "/projects/before-reconnect" const destination = "/projects/after-reconnect" const sessionID = "ses_location_reconnect" @@ -146,12 +155,16 @@ test("refreshes a session moved during disconnection without losing the draft", await transport.close() await transport.waitForConnection({ after: connection.id }) await resolved + await expect(prompt).toBeEditable() await expect(prompt).toHaveText("Draft before disconnect") await expect(page.getByText("Resume in the current worktree", { exact: true })).toBeVisible() await expect(page.getByText("Session location unavailable", { exact: true })).toHaveCount(0) + await expect(page.getByRole("button", { name: "Choose directory", exact: true })).toHaveCount(0) + expect(recovery).toEqual([]) }) -test("ignores an old missing-location response after reconnecting", async ({ page }) => { +test("ignores an old failed location read after reconnecting", async ({ page }) => { + const recovery = recoveryRequests(page) const directory = "/projects/reconnected-tree" const sessionID = "ses_location_stale_response" const requested = Promise.withResolvers() @@ -173,8 +186,8 @@ test("ignores an old missing-location response after reconnecting", async ({ pag requested.resolve() await release.promise return route.fulfill({ - status: 404, - json: { _tag: "LocationNotFoundError", directory, message: "Missing directory" } satisfies LocationNotFoundError, + status: 500, + body: "", headers: { "access-control-allow-origin": "*" }, }) }) @@ -196,7 +209,19 @@ test("ignores an old missing-location response after reconnecting", async ({ pag await metadata release.resolve() await resolved + await expect(prompt).toBeEditable() await expect(prompt).toHaveText("Keep typing here") + await expect(page.getByText("Session location unavailable", { exact: true })).toHaveCount(0) await expect(page.getByRole("button", { name: "Choose directory", exact: true })).toHaveCount(0) expect(requests).toBe(2) + expect(recovery).toEqual([]) }) + +function recoveryRequests(page: Page) { + const requests: string[] = [] + page.on("request", (request) => { + const path = new URL(request.url()).pathname + if (request.method() === "POST" && /^\/api\/(session\/[^/]+\/move$|worktree(?:\/|$))/.test(path)) requests.push(path) + }) + return requests +} diff --git a/packages/app/src/new-session/composer-adapter.ts b/packages/app/src/new-session/composer-adapter.ts index 1a5a98faf12e..0b17dbdf4f0e 100644 --- a/packages/app/src/new-session/composer-adapter.ts +++ b/packages/app/src/new-session/composer-adapter.ts @@ -194,6 +194,7 @@ async function resolveSessionDirectory(input: { return createWorktree({ api: input.serverSDK.api, + data: input.data, directory: input.projectDirectory, project: input.data.location.info({ directory: input.projectDirectory })?.project, branch: input.branch, diff --git a/packages/app/src/runtime/server/errors.test.ts b/packages/app/src/runtime/server/errors.test.ts index 8ac6a8b48181..2634bb156b85 100644 --- a/packages/app/src/runtime/server/errors.test.ts +++ b/packages/app/src/runtime/server/errors.test.ts @@ -1,12 +1,7 @@ import { describe, expect, test } from "bun:test" -import type { LocationNotFoundError, SessionNotFoundError } from "@opencode-ai/client/promise" +import type { SessionNotFoundError } from "@opencode-ai/client/promise" import type { ConfigInvalidError, ProviderModelNotFoundError } from "./errors" -import { - formatServerError, - isLocationNotFoundError, - isSessionNotFoundError, - parseReadableConfigInvalidError, -} from "./errors" +import { formatServerError, isSessionNotFoundError, parseReadableConfigInvalidError } from "./errors" function fill(text: string, vars?: Record) { if (!vars) return text @@ -37,40 +32,6 @@ function useLanguageMock() { const language = useLanguageMock() -describe("isLocationNotFoundError", () => { - const location = { directory: "/projects/missing" } - const body = { - _tag: "LocationNotFoundError", - directory: location.directory, - message: "Missing directory", - } satisfies LocationNotFoundError - - test("requires a confirmed missing local directory for this location", () => { - expect(isLocationNotFoundError(new Error("Missing directory", { cause: { body, status: 404 } }), location)).toBe( - true, - ) - expect(isLocationNotFoundError(new Error("Missing directory", { cause: body }), location)).toBe(true) - expect(isLocationNotFoundError(body, location)).toBe(true) - expect(isLocationNotFoundError(body, { directory: "/projects/other" })).toBe(false) - expect(isLocationNotFoundError(body, { ...location, workspaceID: "wrk_remote" })).toBe(false) - expect(isLocationNotFoundError({ ...body, workspaceID: "wrk_remote" }, location)).toBe(false) - }) - - test("does not classify connection, configuration, or generic HTTP failures as missing", () => { - for (const error of [ - new TypeError("Failed to fetch"), - new DOMException("Aborted", "AbortError"), - new Error("Not Found", { cause: { status: 404, body: "Not Found" } }), - new Error("Internal Server Error", { cause: { status: 500, body: "" } }), - { _tag: "ConfigInvalidError", directory: location.directory }, - { _tag: "LocationNotFoundError" }, - undefined, - ]) { - expect(isLocationNotFoundError(error, location)).toBe(false) - } - }) -}) - describe("parseReadableConfigInvalidError", () => { test("formats issues with file path", () => { const error = { diff --git a/packages/app/src/runtime/server/errors.ts b/packages/app/src/runtime/server/errors.ts index 14acfe482b03..71cbbf8ca833 100644 --- a/packages/app/src/runtime/server/errors.ts +++ b/packages/app/src/runtime/server/errors.ts @@ -1,5 +1,3 @@ -import type { LocationRef } from "@opencode-ai/client/promise" - export type ConfigInvalidError = { name: "ConfigInvalidError" data: { @@ -63,17 +61,6 @@ export function isSessionNotFoundError(error: unknown, sessionID: string) { return value._tag === "SessionNotFoundError" && value.sessionID === sessionID } -export function isLocationNotFoundError(error: unknown, location: LocationRef) { - const unwrapped = unwrapNamedError(error) - if (typeof unwrapped !== "object" || unwrapped === null) return false - const value = unwrapped as Record - return ( - value._tag === "LocationNotFoundError" && - value.directory === location.directory && - value.workspaceID === location.workspaceID - ) -} - function isConfigInvalidErrorLike(error: unknown): error is ConfigInvalidError { if (typeof error !== "object" || error === null) return false const o = error as Record diff --git a/packages/app/src/session/composer/location-missing.tsx b/packages/app/src/session/composer/location-missing.tsx deleted file mode 100644 index 25527f6fb807..000000000000 --- a/packages/app/src/session/composer/location-missing.tsx +++ /dev/null @@ -1,136 +0,0 @@ -import { Button } from "@opencode-ai/ui/button" -import { useDialog } from "@opencode-ai/ui/context/dialog" -import { DockShell, DockTray } from "@opencode-ai/ui/dock-surface" -import { Icon } from "@opencode-ai/ui/icon" -import { Menu } from "@opencode-ai/ui/menu" -import { getFilename } from "@opencode-ai/util/path" -import { createMutation } from "@tanstack/solid-query" -import { createEffect, createResource, For, Show } from "solid-js" -import { createStore } from "solid-js/store" -import { useLanguage } from "@/runtime/i18n/language" -import { useServerSDK } from "@/runtime/server/client" -import { useData } from "@/runtime/server/current" -import { showToast } from "@/shell/notifications/toast" -import { useDirectoryPicker } from "@/workspaces/selection/picker" -import { createWorktree } from "@/workspaces/create" - -export function SessionLocationMissing(props: { sessionID: string; projectID: string; directory: string }) { - const language = useLanguage() - const sdk = useServerSDK() - const data = useData() - const dialog = useDialog() - const pickDirectory = useDirectoryPicker() - const [state, setState] = createStore({ restoreFocus: false, worktreesOpen: false }) - const project = () => data.project.get(props.projectID) - const [worktrees] = createResource( - () => (state.worktreesOpen ? props.projectID : undefined), - async (projectID) => { - try { - await sdk.api.worktree.refresh({ projectID }) - return await sdk.api.worktree.list({ projectID }) - } catch { - showToast({ variant: "error", title: language.t("session.location.worktreesFailed") }) - return [] - } - }, - // Seed latest so even the first fetch does not enter Suspense. - { initialValue: [] }, - ) - let button: HTMLButtonElement | undefined - - const move = createMutation(() => ({ - mutationFn: async (input: { sessionID: string; directory?: string }) => { - // A deleted worktree cannot resolve its own location. Create from the - // project's saved canonical checkout instead. - const current = project() - const destination = - input.directory ?? - (current && - (await createWorktree({ - api: sdk.api, - directory: current.canonical, - project: { id: current.id, canonical: current.canonical, directory: current.canonical }, - }))) - if (!destination) return - await sdk.api.session.move({ sessionID: input.sessionID, directory: destination }) - }, - onError: (error) => { - setState("restoreFocus", true) - showToast({ - variant: "error", - title: language.t("workspace.move.failed"), - description: error instanceof Error ? error.message : language.t("common.requestFailed"), - }) - }, - })) - - createEffect(() => { - if (!state.restoreFocus || move.isPending || dialog.active) return - setState("restoreFocus", false) - button?.focus() - }) - - function choose() { - if (move.isPending) return - const sessionID = props.sessionID - pickDirectory({ - server: sdk.server, - title: language.t("session.location.choose"), - onSelect: (result) => { - const directory = Array.isArray(result) ? result[0] : result - if (directory) move.mutate({ sessionID, directory }) - if (!directory) setState("restoreFocus", true) - }, - }) - } - - return ( -
- -
- -
-
{language.t("session.location.unavailable")}
-
{props.directory}
-
{language.t("session.location.description")}
-
-
-
- - - setState("worktreesOpen", open)}> - - {language.t("session.location.worktree")} - - - - move.mutate({ sessionID: props.sessionID })} disabled={move.isPending}> - - {language.t("workspace.new")} - - - {language.t("common.loading")} - - item.strategy && item.directory !== props.directory)}> - {(worktree) => ( - move.mutate({ sessionID: props.sessionID, directory: worktree.directory })} - disabled={move.isPending} - > - - {getFilename(worktree.directory)} - - )} - - - - - - - -
- ) -} diff --git a/packages/app/src/session/composer/region.tsx b/packages/app/src/session/composer/region.tsx index 375cc771acb8..2634b1e97d63 100644 --- a/packages/app/src/session/composer/region.tsx +++ b/packages/app/src/session/composer/region.tsx @@ -4,7 +4,7 @@ import { useDialog } from "@opencode-ai/ui/context/dialog" import { isScrollKeyTarget, scrollKey, scrollKeyOwner } from "@opencode-ai/ui/scroll-view" import { makeEventListener } from "@solid-primitives/event-listener" import { useNavigate } from "@solidjs/router" -import { createEffect, createMemo, on, onMount, Show } from "solid-js" +import { createEffect, on, onMount } from "solid-js" import { Composer } from "@/composer/composer" import { createComposerModel, type ComposerModel } from "@/composer/model" import { useComposerState } from "@/composer/persistence" @@ -32,7 +32,6 @@ import { SessionQueuePanel } from "./queue-panel" import { resolveSessionComposerSelection } from "./selection" import { createSessionRequestModel } from "../requests/model" import { useSettings } from "@/settings/model" -import { SessionLocationMissing } from "./location-missing" export function createActiveSessionRegion(input: { session: SessionModel @@ -220,14 +219,6 @@ export function ActiveSessionComposerRegion(props: { onResponseSubmit: () => void }) { const settings = useSettings() - const location = useWorkspaceLocation() - const missing = createMemo(() => { - const error = location().error - const current = props.session.data.info()?.location - if (error && current?.directory === error.location.directory && current.workspaceID === error.location.workspaceID) - return current.directory - return undefined - }) const region = createSessionComposerRegionController({ state: props.model.region.state, parentID: props.session.data.parentID, @@ -256,26 +247,12 @@ export function ActiveSessionComposerRegion(props: { - -
- -
- - } - > - {(directory) => ( - - )} -
+
+ +
+ +
+
} /> ) diff --git a/packages/app/src/session/route.tsx b/packages/app/src/session/route.tsx index 13ddf827df9c..9cdb3d07d22d 100644 --- a/packages/app/src/session/route.tsx +++ b/packages/app/src/session/route.tsx @@ -133,11 +133,7 @@ function ResolvedTargetSessionRoute() { > }> {(value) => ( - current()?.location.workspaceID} - sessionID={() => params.id} - > + current()?.location.workspaceID}> diff --git a/packages/app/src/session/timeline/session-workspace-menu.tsx b/packages/app/src/session/timeline/session-workspace-menu.tsx index 5c688c98f147..0ad08de184ee 100644 --- a/packages/app/src/session/timeline/session-workspace-menu.tsx +++ b/packages/app/src/session/timeline/session-workspace-menu.tsx @@ -60,6 +60,7 @@ export function SessionWorkspaceMenu(props: { selection === "create" ? await createWorktree({ api: sdk.api, + data, directory: props.directory, project: data.location.info({ directory: props.directory })?.project, }) diff --git a/packages/app/src/workspaces/create.test.ts b/packages/app/src/workspaces/create.test.ts index 1b41cc59fde8..61961f2ec131 100644 --- a/packages/app/src/workspaces/create.test.ts +++ b/packages/app/src/workspaces/create.test.ts @@ -1,5 +1,7 @@ import { describe, expect, test } from "bun:test" import { OpenCode } from "@opencode-ai/client/promise" +import { createData } from "@opencode-ai/client/solid" +import { createRoot } from "solid-js" import { createWorktree } from "./create" describe("worktree creation", () => { @@ -44,28 +46,44 @@ describe("worktree creation", () => { ), }) - expect( - await createWorktree({ - api, + await createRoot(async (dispose) => { + const data = createData({ + api: () => api, directory: input.directory, - project: input.cached ? project : undefined, - branch: "clone-only", - }), - ).toBe("/created") - expect(await requests.find((request) => request.method === "POST")?.json()).toEqual({ - strategy: "git", - from: input.canonical, - branch: "clone-only", - directory: input.parent, + event: { on: () => () => {}, listen: () => () => {} }, + }) + try { + expect( + await createWorktree({ + api, + data, + directory: input.directory, + project: input.cached ? project : undefined, + branch: "clone-only", + }), + ).toBe("/created") + expect(await requests.find((request) => request.method === "POST")?.json()).toEqual({ + strategy: "git", + from: input.canonical, + branch: "clone-only", + directory: input.parent, + }) + expect(requests.find((request) => request.method === "POST")?.url).toBe( + "http://localhost:3000/api/worktree/proj_clone", + ) + expect( + requests + .filter((request) => request.method === "GET") + .map((request) => new URL(request.url).searchParams.get("location[directory]")), + ).toEqual(input.cached ? ["/created"] : [input.directory, "/created"]) + expect(data.location.info({ directory: "/created" })).toEqual({ directory: "/created", project }) + const count = requests.length + await data.location.syncInfo({ directory: "/created" }) + expect(requests).toHaveLength(count) + } finally { + dispose() + } }) - expect(requests.find((request) => request.method === "POST")?.url).toBe( - "http://localhost:3000/api/worktree/proj_clone", - ) - expect( - requests - .filter((request) => request.method === "GET") - .map((request) => new URL(request.url).searchParams.get("location[directory]")), - ).toEqual(input.cached ? ["/created"] : [input.directory, "/created"]) }) test("does not fall back to a shared project when location lookup fails", async () => { @@ -81,10 +99,21 @@ describe("worktree creation", () => { ), }) - await expect(createWorktree({ api, directory: "/copies/repo" })).rejects.toMatchObject({ - reason: "UnexpectedStatus", - cause: { status: 503 }, + await createRoot(async (dispose) => { + const data = createData({ + api: () => api, + directory: "/copies/repo", + event: { on: () => () => {}, listen: () => () => {} }, + }) + try { + await expect(createWorktree({ api, data, directory: "/copies/repo" })).rejects.toMatchObject({ + reason: "UnexpectedStatus", + cause: { status: 503 }, + }) + expect(requests.map((request) => request.method)).toEqual(["GET"]) + } finally { + dispose() + } }) - expect(requests.map((request) => request.method)).toEqual(["GET"]) }) }) diff --git a/packages/app/src/workspaces/create.ts b/packages/app/src/workspaces/create.ts index 9cc66e802721..f487bac42786 100644 --- a/packages/app/src/workspaces/create.ts +++ b/packages/app/src/workspaces/create.ts @@ -1,8 +1,10 @@ import type { LocationGetOutput, OpenCodeClient } from "@opencode-ai/client/promise" +import type { Data } from "@opencode-ai/client/solid" import { getDirectory } from "@opencode-ai/util/path" export async function createWorktree(input: { api: Pick + data: Pick directory: string project?: LocationGetOutput["project"] branch?: string @@ -15,6 +17,7 @@ export async function createWorktree(input: { branch: input.branch, directory: getDirectory(project.canonical), }) - await input.api.location.get({ location: { directory: created.directory } }) + // Populate the client cache before the destination session mounts. + await input.data.location.syncInfo({ directory: created.directory }) return created.directory } diff --git a/packages/app/src/workspaces/location.tsx b/packages/app/src/workspaces/location.tsx index 8e62bab03a17..2bd83bc3e27c 100644 --- a/packages/app/src/workspaces/location.tsx +++ b/packages/app/src/workspaces/location.tsx @@ -2,25 +2,18 @@ import { createSimpleContext } from "@opencode-ai/ui/context" import type { LocationGetOutput, LocationRef } from "@opencode-ai/client/promise" import { retry } from "@opencode-ai/util/retry" import { type Accessor, createEffect, createMemo, onCleanup } from "solid-js" -import { createStore } from "solid-js/store" import { type LocationContext, useServerSDK } from "@/runtime/server/client" import { useData } from "@/runtime/server/current" -import { isLocationNotFoundError } from "@/runtime/server/errors" export type { LocationContext } from "@/runtime/server/client" export type WorkspaceLocation = LocationContext & { readonly ref: LocationRef readonly current: LocationGetOutput | undefined - readonly error: { readonly location: LocationRef; readonly cause: unknown } | undefined } const context = createSimpleContext({ name: "Location", - init: (props: { - directory: string | Accessor - workspaceID?: string | Accessor - sessionID?: Accessor - }) => { + init: (props: { directory: string | Accessor; workspaceID?: string | Accessor }) => { const serverSDK = useServerSDK() const data = useData() const ref = createMemo( @@ -34,46 +27,18 @@ const context = createSimpleContext({ }, ) const current = createMemo(() => data.location.info(ref())) - const [state, setState] = createStore<{ error?: WorkspaceLocation["error"] }>({}) createEffect(() => { const location = ref() - const sessionID = props.sessionID?.() let stale = false onCleanup(() => { stale = true }) - setState("error", undefined) if (serverSDK.connection.status() !== "connected") return - void retry(() => (stale ? Promise.resolve() : data.location.syncInfo(location)), { - retryIf: (cause) => !stale && !isLocationNotFoundError(cause, location), - }) - .then( - () => { - if (stale) return - // Ancillary services failing do not mean the directory is missing. - void retry(() => (stale ? Promise.resolve() : data.location.sync(location)), { - retryIf: () => !stale, - }).catch(() => undefined) - }, - async (cause) => { - if (stale || !isLocationNotFoundError(cause, location)) return - if (sessionID) { - // Finish route hydration before checking for a move missed by this client. - await data.session.sync(sessionID, { children: true }).catch(() => undefined) - if (stale) return - data.session.invalidate(sessionID) - await retry(() => (stale ? Promise.resolve() : data.session.sync(sessionID)), { - retryIf: () => !stale, - }) - const current = data.session.get(sessionID)?.location - if (current?.directory !== location.directory || current.workspaceID !== location.workspaceID) return - } - if (stale) return - setState("error", { location, cause }) - }, - ) - .catch(() => undefined) + // A failed sync does not prove the directory is missing. Keep recovery local to reads. + void retry(() => (stale ? Promise.resolve() : data.location.sync(location)), { + retryIf: () => !stale, + }).catch(() => undefined) }) const location = createMemo(() => serverSDK.ensureDirSdkContext(current()?.directory ?? ref().directory)) @@ -81,7 +46,6 @@ const context = createSimpleContext({ ...location(), ref: ref(), current: current(), - error: state.error, })) }, }) diff --git a/packages/client/src/promise/generated/client.ts b/packages/client/src/promise/generated/client.ts index 1be6eda745fd..62319ccffa8c 100644 --- a/packages/client/src/promise/generated/client.ts +++ b/packages/client/src/promise/generated/client.ts @@ -425,7 +425,7 @@ export function make(options: ClientOptions) { path: `/api/location`, query: { location: input?.["location"] }, successStatus: 200, - declaredStatuses: [404, 401, 400], + declaredStatuses: [401, 400], empty: false, }, requestOptions, diff --git a/packages/client/src/promise/generated/types.ts b/packages/client/src/promise/generated/types.ts index a5208559ab2d..eab93ff68143 100644 --- a/packages/client/src/promise/generated/types.ts +++ b/packages/client/src/promise/generated/types.ts @@ -2349,15 +2349,6 @@ export type InvalidRequestError = { export const isInvalidRequestError = (value: unknown): value is InvalidRequestError => typeof value === "object" && value !== null && "_tag" in value && value["_tag"] === "InvalidRequestError" -export type LocationNotFoundError = { - readonly _tag: "LocationNotFoundError" - readonly directory: string - readonly workspaceID?: string - readonly message: string -} -export const isLocationNotFoundError = (value: unknown): value is LocationNotFoundError => - typeof value === "object" && value !== null && "_tag" in value && value["_tag"] === "LocationNotFoundError" - export type AgentNotFoundError = { readonly _tag: "AgentNotFoundError" readonly agentID: string diff --git a/packages/client/src/solid/data.ts b/packages/client/src/solid/data.ts index be6b77c038b6..c18f2bb6c37b 100644 --- a/packages/client/src/solid/data.ts +++ b/packages/client/src/solid/data.ts @@ -217,7 +217,6 @@ export function createData(config: CreateDataInput) { ) const messageIndex = new Map>() const sync = createSync() - const sessionReads = new Set>() let activeUpdates: Map | undefined function setSessionActive(sessionID: string, status: DataSessionStatus) { @@ -658,12 +657,6 @@ export function createData(config: CreateDataInput) { return } case "session.moved": { - // Family reads can include children that are not cached yet. - for (const moves of sessionReads) - moves.set(event.data.sessionID, { - ...event.data, - projectID: event.data.projectID ?? moves.get(event.data.sessionID)?.projectID, - }) const current = store.session.info[event.data.sessionID] if (current) { const previous = { @@ -1518,44 +1511,28 @@ export function createData(config: CreateDataInput) { }, sync(sessionID: string, options?: { children?: boolean }) { return sync.run(options?.children ? `session.family:${sessionID}` : `session:${sessionID}`, async () => { - const moves = new Map() - sessionReads.add(moves) - try { - const [info, children] = await Promise.all([ - api().session.get({ sessionID }), - options?.children - ? api() - .session.list({ parentID: sessionID, order: "desc" }) - .then((response) => response.data) - : [], - ]) - const sessions = [info, ...children] - batch(() => { - setStore( - "session", - "info", - produce((draft) => { - for (const session of sessions) { - const moved = moves.get(session.id) - draft[session.id] = moved - ? { - ...session, - location: moved.location, - projectID: moved.projectID ?? session.projectID, - subpath: moved.subpath, - } - : session - } - }), - ) - for (const session of sessions) { - sync.complete(`session:${session.id}`) - registerSession(session.id) - } - }) - } finally { - sessionReads.delete(moves) - } + const [info, children] = await Promise.all([ + api().session.get({ sessionID }), + options?.children + ? api() + .session.list({ parentID: sessionID, order: "desc" }) + .then((response) => response.data) + : [], + ]) + const sessions = [info, ...children] + batch(() => { + setStore( + "session", + "info", + produce((draft) => { + for (const session of sessions) draft[session.id] = session + }), + ) + for (const session of sessions) { + sync.complete(`session:${session.id}`) + registerSession(session.id) + } + }) }) }, invalidate(sessionID: string) { diff --git a/packages/client/test/solid-data.test.ts b/packages/client/test/solid-data.test.ts index 9d70df263300..79251e607bb4 100644 --- a/packages/client/test/solid-data.test.ts +++ b/packages/client/test/solid-data.test.ts @@ -130,178 +130,6 @@ test("preserves a live session rename across concurrent session and family reads } }) -for (const cached of [false, true]) { - test(`preserves a move during a ${cached ? "cached" : "uncached"} session read only until the next read`, async () => { - const gate = Promise.withResolvers() - const listeners = new Set[0]>() - const stale: SessionInfo = { ...session(0), subpath: "old", location: { directory: "/old", workspaceID: "old" } } - const authoritative: SessionInfo = { - ...session(0), - projectID: "authoritative", - location: { directory: "/authoritative" }, - subpath: "new", - } - let requests = 0 - const api = OpenCode.make({ - baseUrl: "http://opencode.local", - fetch: async (input, init) => { - const request = input instanceof Request ? input : new Request(input, init) - if (!request.url.endsWith(`/api/session/${stale.id}`)) throw new Error(`Unexpected request: ${request.url}`) - requests++ - if (requests > 1) return Response.json({ data: authoritative }) - await gate.promise - return Response.json({ data: stale }) - }, - }) - const setup = createRoot((dispose) => ({ - data: createData({ - api: () => api, - directory: "/project", - event: { - on: () => () => {}, - listen(handler) { - listeners.add(handler) - return () => listeners.delete(handler) - }, - }, - }), - dispose, - })) - - try { - if (cached) setup.data.session.remember(stale) - setup.data.session.invalidate(stale.id) - const initial = setup.data.session.sync(stale.id) - await wait(() => requests === 1) - const event: OpenCodeEvent = { - id: "evt_moved", - created: 1, - type: "session.moved", - durable: { aggregateID: stale.id, seq: 1, version: 1 }, - data: { sessionID: stale.id, location: { directory: "/moved" } }, - } - listeners.forEach((listener) => listener({ name: event.type, details: event })) - if (cached) { - expect(setup.data.session.get(stale.id)?.location.directory).toBe("/moved") - expect(setup.data.session.get(stale.id)?.projectID).toBe(stale.projectID) - expect(setup.data.session.get(stale.id)?.subpath).toBeUndefined() - } - gate.resolve() - await initial - - expect(setup.data.session.get(stale.id)).toEqual({ - ...stale, - location: { directory: "/moved" }, - subpath: undefined, - }) - setup.data.session.invalidate(stale.id) - await setup.data.session.sync(stale.id) - expect(setup.data.session.get(stale.id)).toEqual(authoritative) - expect(requests).toBe(2) - } finally { - gate.resolve() - setup.dispose() - } - }) -} - -for (const first of ["family", "ordinary"] as const) { - test(`preserves the latest parent and child moves across concurrent reads when ${first} finishes first`, async () => { - const gates = { family: Promise.withResolvers(), ordinary: Promise.withResolvers() } - const listeners = new Set[0]>() - const parent = { ...session(0), subpath: "old-parent" } - const child = { ...session(0), id: "ses_child", parentID: parent.id, subpath: "old-child" } - let requests = 0 - const api = OpenCode.make({ - baseUrl: "http://opencode.local", - fetch: async (input, init) => { - const request = input instanceof Request ? input : new Request(input, init) - const url = new URL(request.url) - requests++ - if (url.pathname === `/api/session/${child.id}`) { - await gates.ordinary.promise - return Response.json({ data: child }) - } - if (url.pathname === `/api/session/${parent.id}`) { - await gates.family.promise - return Response.json({ data: parent }) - } - if (url.pathname === "/api/session" && url.searchParams.get("parentID") === parent.id) { - await gates.family.promise - return Response.json({ data: [child], cursor: {} }) - } - throw new Error(`Unexpected request: ${request.url}`) - }, - }) - const setup = createRoot((dispose) => ({ - data: createData({ - api: () => api, - directory: "/project", - event: { - on: () => () => {}, - listen(handler) { - listeners.add(handler) - return () => listeners.delete(handler) - }, - }, - }), - dispose, - })) - - try { - const reads = { - family: setup.data.session.sync(parent.id, { children: true }), - ordinary: setup.data.session.sync(child.id), - } - await wait(() => requests === 3) - for (const seq of [1, 2]) { - for (const info of [parent, child]) { - const event: OpenCodeEvent = { - id: `evt_moved_${info.id}_${seq}`, - created: seq, - type: "session.moved", - durable: { aggregateID: info.id, seq, version: 1 }, - data: { - sessionID: info.id, - location: { directory: `/moved/${info.id}/${seq}` }, - ...(seq === 1 ? { projectID: `project_${info.id}`, subpath: "intermediate" } : {}), - }, - } - listeners.forEach((listener) => listener({ name: event.type, details: event })) - } - } - gates[first].resolve() - await reads[first] - expect(setup.data.session.get(child.id)).toEqual({ - ...child, - location: { directory: `/moved/${child.id}/2` }, - projectID: `project_${child.id}`, - subpath: undefined, - }) - gates.family.resolve() - gates.ordinary.resolve() - await Promise.all(Object.values(reads)) - - for (const info of [parent, child]) { - expect(setup.data.session.get(info.id)).toEqual({ - ...info, - location: { directory: `/moved/${info.id}/2` }, - projectID: `project_${info.id}`, - subpath: undefined, - }) - setup.data.session.invalidate(info.id) - await setup.data.session.sync(info.id) - expect(setup.data.session.get(info.id)).toEqual(info) - } - expect(requests).toBe(5) - } finally { - gates.family.resolve() - gates.ordinary.resolve() - setup.dispose() - } - }) -} - test("updates authoritative cached project metadata from live events", async () => { const listeners = new Set[0]>() const original: Project = { diff --git a/packages/protocol/openapi.json b/packages/protocol/openapi.json index 768bff3b4087..1f6b06d12247 100644 --- a/packages/protocol/openapi.json +++ b/packages/protocol/openapi.json @@ -177,16 +177,6 @@ } } } - }, - "404": { - "description": "LocationNotFoundError", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/LocationNotFoundErrorEncoded" - } - } - } } }, "description": "Resolve the requested location or the server default location.", @@ -15811,26 +15801,6 @@ "required": ["directory"], "additionalProperties": false }, - "LocationNotFoundErrorEncoded": { - "type": "object", - "properties": { - "_tag": { - "type": "string", - "enum": ["LocationNotFoundError"] - }, - "directory": { - "type": "string" - }, - "workspaceID": { - "type": "string" - }, - "message": { - "type": "string" - } - }, - "required": ["_tag", "directory", "message"], - "additionalProperties": false - }, "Mcp.LocalConfigEncoded": { "type": "object", "properties": { diff --git a/packages/protocol/src/api.ts b/packages/protocol/src/api.ts index 3716de9b919b..358918030f6d 100644 --- a/packages/protocol/src/api.ts +++ b/packages/protocol/src/api.ts @@ -37,6 +37,7 @@ import { ConfigGroup } from "./groups/config.js" import { WorkspaceGroup } from "./groups/workspace.js" type LocationGroups = + | HttpApiGroup.AddMiddleware | HttpApiGroup.AddMiddleware | HttpApiGroup.AddMiddleware | HttpApiGroup.AddMiddleware @@ -85,7 +86,6 @@ type ApiGroups< > = | typeof HealthGroup | typeof ServerGroup - | typeof LocationGroup | typeof DebugGroup | typeof MigrationGroup | typeof WorktreeGroup @@ -153,7 +153,7 @@ const makeApiFromGroup = < HttpApi.make("server") .add(HealthGroup) .add(ServerGroup) - .add(LocationGroup) + .add(LocationGroup.middleware(locationMiddleware)) .add(AgentGroup.middleware(locationMiddleware)) .add(PluginGroup.middleware(locationMiddleware)) .add(makeSessionGroup(sessionLocationMiddleware)) diff --git a/packages/protocol/src/errors.ts b/packages/protocol/src/errors.ts index fabc78499b20..54f8b396638f 100644 --- a/packages/protocol/src/errors.ts +++ b/packages/protocol/src/errors.ts @@ -1,6 +1,5 @@ import { Schema } from "effect" import { Skill } from "@opencode-ai/schema/skill" -import { optional } from "@opencode-ai/schema/schema" export class InvalidRequestError extends Schema.TaggedError()( "InvalidRequestError", @@ -92,16 +91,6 @@ export class ProjectNotFoundError extends Schema.TaggedError()( - "LocationNotFoundError", - { - directory: Schema.String, - workspaceID: optional(Schema.String), - message: Schema.String, - }, - { httpApiStatus: 404 }, -) {} - export class AgentNotFoundError extends Schema.TaggedError()( "AgentNotFoundError", { diff --git a/packages/protocol/src/groups/location.ts b/packages/protocol/src/groups/location.ts index 1e56aedd4417..4723b8a46a93 100644 --- a/packages/protocol/src/groups/location.ts +++ b/packages/protocol/src/groups/location.ts @@ -1,7 +1,6 @@ import { Location } from "@opencode-ai/schema/location" import { Schema } from "effect" import { HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi" -import { LocationNotFoundError } from "../errors.js" export const LocationQuery = Schema.Struct({ location: Schema.optional( @@ -32,7 +31,6 @@ export const LocationGroup = HttpApiGroup.make("server.location") HttpApiEndpoint.get("location.get", "/api/location", { query: LocationQuery, success: Location.Info, - error: LocationNotFoundError, }) .annotateMerge(locationQueryOpenApi) .annotateMerge( diff --git a/packages/protocol/test/location.test.ts b/packages/protocol/test/location.test.ts deleted file mode 100644 index 0727663fae38..000000000000 --- a/packages/protocol/test/location.test.ts +++ /dev/null @@ -1,24 +0,0 @@ -import { expect, test } from "bun:test" -import { Schema } from "effect" -import { OpenApi } from "effect/unstable/httpapi" -import { ClientApi } from "../src/client.js" -import { LocationNotFoundError } from "../src/errors.js" - -test("location errors omit an absent workspace ID", () => { - expect( - Schema.encodeSync(LocationNotFoundError)( - new LocationNotFoundError({ directory: "/missing", workspaceID: undefined, message: "Missing directory" }), - ), - ).toEqual({ _tag: "LocationNotFoundError", directory: "/missing", message: "Missing directory" }) -}) - -test("only location.get declares LocationNotFoundError", () => { - const document = OpenApi.fromApi(ClientApi) - const paths = Object.entries(document.paths).filter(([, operation]) => - JSON.stringify(operation).includes("LocationNotFoundError"), - ) - expect(paths.map(([path]) => path)).toEqual(["/api/location"]) - expect(document.paths["/api/location"]?.get?.responses?.["404"]).toMatchObject({ - description: "LocationNotFoundError", - }) -}) diff --git a/packages/server/src/handlers/location.ts b/packages/server/src/handlers/location.ts index cd3f3480f659..ded8c8c2e0ac 100644 --- a/packages/server/src/handlers/location.ts +++ b/packages/server/src/handlers/location.ts @@ -1,47 +1,18 @@ import { Location } from "@opencode-ai/core/location" -import { LocationServiceMap } from "@opencode-ai/core/location-service-map" -import { LocationNotFoundError } from "@opencode-ai/protocol/errors" -import { FSUtil } from "@opencode-ai/util/fs-util" -import { Effect, Schema } from "effect" +import { Effect } from "effect" import { HttpApiBuilder } from "effect/unstable/httpapi" import { Api } from "../api" -import { requestRef } from "../location" export const LocationHandler = HttpApiBuilder.group(Api, "server.location", (handlers) => - Effect.gen(function* () { - const locations = yield* LocationServiceMap.Service - const fs = yield* FSUtil.Service - return handlers.handle( - "location.get", - Effect.fn(function* (ctx) { - const ref = requestRef(ctx.request) - // Workspace placement owns its filesystem. Check local paths before booting location services. - if (ref.workspaceID === undefined) { - const info = yield* fs.stat(ref.directory).pipe( - Effect.catchIf( - (error) => - error.reason._tag === "NotFound" || - (error.reason._tag === "BadResource" && - Schema.is(Schema.Struct({ code: Schema.Literal("ENOTDIR") }))(error.cause)), - () => Effect.undefined, - ), - Effect.orDie, - ) - if (info?.type !== "Directory") - return yield* new LocationNotFoundError({ - directory: ref.directory, - message: `Location directory not found: ${ref.directory}`, - }) - } - return yield* Effect.gen(function* () { - const location = yield* Location.Service - return new Location.Info({ - directory: location.directory, - workspaceID: location.workspaceID, - project: location.project, - }) - }).pipe(Effect.provide(locations.get(ref))) - }), - ) - }), + handlers.handle( + "location.get", + Effect.fn(function* () { + const location = yield* Location.Service + return new Location.Info({ + directory: location.directory, + workspaceID: location.workspaceID, + project: location.project, + }) + }), + ), ) diff --git a/packages/server/src/routes.ts b/packages/server/src/routes.ts index 7f33ab9e69cb..da582455f44d 100644 --- a/packages/server/src/routes.ts +++ b/packages/server/src/routes.ts @@ -20,7 +20,6 @@ import { ShellSelect } from "@opencode-ai/core/shell/select" import { Job } from "@opencode-ai/core/job" import { Mcp } from "@opencode-ai/core/mcp/index" import { Global } from "@opencode-ai/util/global" -import { FSUtil } from "@opencode-ai/util/fs-util" import { InstructionDiscovery } from "@opencode-ai/core/instruction-discovery" import { LocationServiceMap } from "@opencode-ai/core/location-service-map" import { LocationActivity } from "@opencode-ai/core/location-activity" @@ -51,7 +50,6 @@ import type { ServerOptions } from "./options" const applicationServiceNodes = [ Global.node, - FSUtil.node, Database.node, Bus.node, EventLogger.node, diff --git a/packages/server/test/location.test.ts b/packages/server/test/location.test.ts deleted file mode 100644 index 72427962c7fb..000000000000 --- a/packages/server/test/location.test.ts +++ /dev/null @@ -1,134 +0,0 @@ -import fs from "node:fs/promises" -import path from "node:path" -import { expect } from "bun:test" -import { Workspace } from "@opencode-ai/schema/workspace" -import { FSUtil } from "@opencode-ai/util/fs-util" -import { Effect, Layer, PlatformError } from "effect" -import { tmpdir } from "../../core/test/fixture/tmpdir" -import { it } from "../../core/test/lib/effect" -import { ServerFetch } from "../src/fetch" - -const options = { - database: { path: ":memory:" }, - models: { fetch: false }, - fs: { filewatcher: false }, -} as const - -it.live("location.get confirms missing and non-directory paths before loading location services", () => - Effect.gen(function* () { - const tmp = yield* Effect.acquireDisposable(Effect.promise(() => tmpdir("opencode-location-"))) - const file = path.join(tmp.path, "file") - yield* Effect.promise(() => fs.writeFile(file, "not a directory")) - const handler = yield* ServerFetch.make({ ...options, config: { directory: tmp.path, project: false } }) - yield* Effect.forEach([path.join(tmp.path, "missing"), file, path.join(file, "child")], (directory) => - Effect.gen(function* () { - const url = new URL("http://opencode.local/api/location") - url.searchParams.set("location[directory]", directory) - const response = yield* Effect.promise(() => handler(new Request(url))) - expect(response.status).toBe(404) - expect(yield* Effect.promise(() => response.json())).toEqual({ - _tag: "LocationNotFoundError", - directory, - message: `Location directory not found: ${directory}`, - }) - }), - ) - const loaded = yield* Effect.promise(() => handler(new Request("http://opencode.local/api/debug/location"))) - expect(yield* Effect.promise(() => loaded.json())).toEqual([]) - }), -) - -it.live("location.get resolves existing directories and detects their later removal through headers", () => - Effect.gen(function* () { - const tmp = yield* Effect.acquireDisposable(Effect.promise(() => tmpdir("opencode-location-"))) - const directory = path.join(tmp.path, "project") - yield* Effect.promise(() => fs.mkdir(directory)) - const handler = yield* ServerFetch.make({ ...options, config: { directory: tmp.path, project: false } }) - const request = () => - new Request("http://opencode.local/api/location", { - headers: { "x-opencode-directory": encodeURIComponent(directory) }, - }) - const response = yield* Effect.promise(() => handler(request())) - expect(response.status).toBe(200) - expect(yield* Effect.promise(() => response.json())).toMatchObject({ directory }) - yield* Effect.promise(() => fs.rm(directory, { recursive: true })) - const missing = yield* Effect.promise(() => handler(request())) - expect(missing.status).toBe(404) - expect(yield* Effect.promise(() => missing.json())).toMatchObject({ _tag: "LocationNotFoundError", directory }) - }), -) - -it.live("location.get does not classify permission, IO, symlink, or config failures as missing", () => - Effect.gen(function* () { - const tmp = yield* Effect.acquireDisposable(Effect.promise(() => tmpdir("opencode-location-"))) - const directory = path.join(tmp.path, "project") - yield* Effect.promise(() => fs.mkdir(directory)) - yield* Effect.promise(() => fs.writeFile(path.join(directory, "opencode.json"), "{}")) - const handler = yield* ServerFetch.make( - { ...options, config: { directory: tmp.path } }, - { - overrides: [ - FSUtil.node.replace( - FSUtil.node.mapLayer((layer) => - Layer.effect( - FSUtil.Service, - Effect.map(FSUtil.Service, (fs) => ({ - ...fs, - stat: (target: string) => { - const name = path.basename(target) - if (name !== "PermissionDenied" && name !== "Unknown" && name !== "BadResource") - return fs.stat(target) - return Effect.fail( - PlatformError.systemError({ - _tag: name, - module: "FileSystem", - method: "stat", - cause: { code: name === "BadResource" ? "ELOOP" : "EIO" }, - }), - ) - }, - readFileStringSafe: (target: string) => - target === path.join(directory, "opencode.json") - ? Effect.fail( - PlatformError.systemError({ - _tag: "Unknown", - module: "FileSystem", - method: "readFileString", - }), - ) - : fs.readFileStringSafe(target), - })), - ).pipe(Layer.provide(layer)), - ), - ), - ], - }, - ) - yield* Effect.forEach(["PermissionDenied", "Unknown", "BadResource", "project"], (name) => - Effect.gen(function* () { - const url = new URL("http://opencode.local/api/location") - url.searchParams.set("location[directory]", path.join(tmp.path, name)) - const response = yield* Effect.promise(() => handler(new Request(url))) - expect(response.status).toBe(500) - expect(yield* Effect.promise(() => response.text())).not.toContain("LocationNotFoundError") - }), - ) - }), -) - -it.live("location.get preserves workspace placement for paths absent on the host", () => - Effect.gen(function* () { - const tmp = yield* Effect.acquireDisposable(Effect.promise(() => tmpdir("opencode-location-"))) - const handler = yield* ServerFetch.make({ ...options, config: { directory: tmp.path, project: false } }) - const url = new URL("http://opencode.local/api/location") - url.searchParams.set("location[directory]", path.join(tmp.path, "workspace-only")) - const workspaceID = Workspace.ID.create() - url.searchParams.set("location[workspace]", workspaceID) - const response = yield* Effect.promise(() => handler(new Request(url))) - expect(response.status).toBe(200) - expect(yield* Effect.promise(() => response.json())).toMatchObject({ - directory: path.join(tmp.path, "workspace-only"), - workspaceID, - }) - }), -)