diff --git a/packages/app/e2e/regression/session-location-sync.spec.ts b/packages/app/e2e/regression/session-location-sync.spec.ts index edfd13d7324..a04d37b27f8 100644 --- a/packages/app/e2e/regression/session-location-sync.spec.ts +++ b/packages/app/e2e/regression/session-location-sync.spec.ts @@ -37,7 +37,7 @@ for (const endpoint of ["/api/location", "/api/agent"]) { return ( url.pathname === endpoint && url.searchParams.get("location[directory]") === directory && - (recover ? response.ok() : requests === 3 && response.status() === 500) + (recover ? response.ok() : requests >= 3 && response.status() === 500) ) }) await page.goto(`/server/${base64Encode(fixture.serverKey)}/session/${sessionID}`) @@ -50,7 +50,7 @@ for (const endpoint of ["/api/location", "/api/agent"]) { await settled await expect(prompt).toBeEditable() await expect(prompt).toHaveText("Continue after reconnecting") - expect(requests).toBe(recover ? 2 : 3) + expect(requests).toBeGreaterThanOrEqual(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([]) @@ -59,6 +59,53 @@ for (const endpoint of ["/api/location", "/api/agent"]) { } } +for (const error of [ + { _tag: "FileNotFoundError", path: "/projects/current", message: "Unrelated file" }, + { _tag: "LocationNotFoundError", directory: "/projects/other", message: "Other location" }, + undefined, +]) { + test(`does not recover from an unrelated 404 (${error?._tag ?? "no typed error"})`, async ({ page }) => { + const recovery = recoveryRequests(page) + const directory = "/projects/current" + const sessionID = "ses_location_unrelated_404" + await mockOpenCodeServer(page, { + directory: fixture.directory, + project: fixture.project, + provider: fixture.provider, + sessions: [{ id: sessionID, projectID: fixture.project.id, directory, title: "Unrelated error" }], + fileList: () => [], + pageMessages: () => ({ + items: [{ id: "msg_saved", type: "user", text: "Continue in the current worktree", time: { created: 1 } }], + }), + }) + await page.route("**/api/location?**", (route) => { + if (new URL(route.request().url()).searchParams.get("location[directory]") !== directory) return route.fallback() + return route.fulfill({ + status: 404, + ...(error ? { json: error } : { body: "" }), + headers: { "access-control-allow-origin": "*" }, + }) + }) + const failure = page.waitForResponse((response) => { + const url = new URL(response.url()) + return ( + url.pathname === "/api/location" && + url.searchParams.get("location[directory]") === directory && + response.status() === 404 + ) + }) + await page.goto(`/server/${base64Encode(fixture.serverKey)}/session/${sessionID}`) + await failure + await expect(page.getByText("Continue in the current worktree", { exact: true })).toBeVisible() + const prompt = page.getByRole("textbox", { name: "Prompt", exact: true }) + await expect(prompt).toBeEditable() + await prompt.fill("Keep this draft") + await expect(prompt).toHaveText("Keep this draft") + await expect(page.getByText("Session location unavailable", { exact: true })).toHaveCount(0) + expect(recovery).toEqual([]) + }) +} + test("follows a live session move while the agent catalog is still loading", async ({ page }) => { const recovery = recoveryRequests(page) const directory = "/projects/old-tree" @@ -217,7 +264,7 @@ test("ignores an old failed location read after reconnecting", async ({ page }) 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(requests).toBeGreaterThanOrEqual(2) expect(recovery).toEqual([]) }) diff --git a/packages/app/e2e/regression/session-removed-worktree.spec.ts b/packages/app/e2e/regression/session-removed-worktree.spec.ts index 01dea1622c2..04c76d64d72 100644 --- a/packages/app/e2e/regression/session-removed-worktree.spec.ts +++ b/packages/app/e2e/regression/session-removed-worktree.spec.ts @@ -1,4 +1,4 @@ -import { expect, test, type Page } from "@playwright/test" +import { expect, test } from "@playwright/test" import { base64Encode } from "@opencode/util/encode" import { fixture } from "../smoke/session-timeline.fixture" import { mockOpenCodeServer } from "../utils/mock-server" @@ -26,7 +26,27 @@ test("keeps a session in a removed worktree readable and movable", async ({ page items: [{ id: "msg_saved", type: "user", text: "Saved conversation in removed worktree", time: { created: 1 } }], }), }) - await removedInventory(page, missing) + await page.route("**/api/**", (route) => { + const url = new URL(route.request().url()) + if (url.searchParams.get("location[directory]") !== sessionDirectory) return route.fallback() + if (url.pathname === "/api/location") + return route.fulfill({ + status: 404, + json: { + _tag: "LocationNotFoundError", + directory: sessionDirectory, + message: `Location not found: ${sessionDirectory}`, + }, + headers: { "access-control-allow-origin": "*" }, + }) + if (!["/api/agent", "/api/provider", "/api/model", "/api/model/default"].includes(url.pathname)) + return route.fallback() + return route.fulfill({ + status: 500, + json: { _tag: "ServiceUnavailableError", message: "Unable to boot removed location" }, + headers: { "access-control-allow-origin": "*" }, + }) + }) await page.route(`**/api/session/${sessionID}/move`, (route) => route.fulfill({ status: 204, body: "" })) await page.goto(`/server/${base64Encode(fixture.serverKey)}/session/${sessionID}`, { waitUntil: "domcontentloaded" }) @@ -80,7 +100,15 @@ test("preserves a draft when a worktree disappears and resumes after choosing a items: [{ id: "msg_draft", type: "user", text: "Saved draft history", time: { created: 1 } }], }), }) - await removedInventory(page, source, () => missing) + await page.route("**/api/location?**", (route) => { + const url = new URL(route.request().url()) + if (url.searchParams.get("location[directory]") !== source || !missing) return route.fallback() + return route.fulfill({ + status: 404, + json: { _tag: "LocationNotFoundError", directory: source, message: `Location not found: ${source}` }, + headers: { "access-control-allow-origin": "*" }, + }) + }) let moves = 0 await page.route(`**/api/session/${sessionID}/move`, (route) => { moves++ @@ -98,9 +126,18 @@ test("preserves a draft when a worktree disappears and resumes after choosing a await expect(prompt).toBeEditable() await prompt.fill("A draft to keep after moving") const connection = await transport.waitForConnection() + const missingResponse = page.waitForResponse((response) => { + const url = new URL(response.url()) + return ( + url.pathname === "/api/location" && + url.searchParams.get("location[directory]") === source && + response.status() === 404 + ) + }) missing = true await transport.close() await transport.waitForConnection({ after: connection.id }) + await missingResponse await expect(page.getByRole("status")).toContainText("Session location unavailable") await expect(prompt).toHaveCount(0) await expect(page.getByText("Saved draft history", { exact: true })).toBeVisible() @@ -161,13 +198,28 @@ test("ignores a stale missing result after the session moves", async ({ page }) sessions: [session], pageMessages: () => ({ items: [] }), }) - await removedInventory(page, source, undefined, { requested, release }) + await page.route("**/api/location?**", async (route) => { + if (new URL(route.request().url()).searchParams.get("location[directory]") !== source) return route.fallback() + requested.resolve() + await release.promise + return route.fulfill({ + status: 404, + json: { _tag: "LocationNotFoundError", directory: source, message: `Location not found: ${source}` }, + 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("Draft in new worktree") await transport.waitForConnection() + const next = 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.send({ id: "evt_stale_probe_moved", @@ -176,7 +228,17 @@ test("ignores a stale missing result after the session moves", async ({ page }) durable: { aggregateID: sessionID, seq: 1, version: 1 }, data: { sessionID, location: { directory: destination }, projectID: fixture.project.id }, }) + await next + const old = page.waitForResponse((response) => { + const url = new URL(response.url()) + return ( + url.pathname === "/api/location" && + url.searchParams.get("location[directory]") === source && + response.status() === 404 + ) + }) release.resolve() + await old await expect(prompt).toBeEditable() await expect(prompt).toHaveText("Draft in new worktree") await expect(page.getByText("Session location unavailable", { exact: true })).toHaveCount(0) @@ -195,7 +257,14 @@ test("moves a removed-worktree session into an existing worktree", async ({ page sessions: [session], pageMessages: () => ({ items: [] }), }) - await removedInventory(page, source, undefined, { destination }) + await page.route("**/api/location?**", (route) => { + if (new URL(route.request().url()).searchParams.get("location[directory]") !== source) return route.fallback() + return route.fulfill({ + status: 404, + json: { _tag: "LocationNotFoundError", directory: source, message: `Location not found: ${source}` }, + headers: { "access-control-allow-origin": "*" }, + }) + }) await page.route(`**/api/session/${sessionID}/move`, (route) => route.fulfill({ status: 204, body: "" })) await page.goto(`/server/${base64Encode(fixture.serverKey)}/session/${sessionID}`) await expect(page.getByRole("status")).toContainText("Session location unavailable") @@ -220,7 +289,7 @@ test("moves a removed-worktree session into an existing worktree", async ({ page await expect(page.getByText("Session location unavailable", { exact: true })).toHaveCount(0) }) -test("keeps the composer when the session directory was never a registered managed worktree", async ({ page }) => { +test("recovers a missing session directory without consulting the worktree inventory", async ({ page }) => { const source = "/projects/unregistered-worktree" const sessionID = "ses_unregistered_worktree" await mockOpenCodeServer(page, { @@ -232,136 +301,30 @@ test("keeps the composer when the session directory was never a registered manag items: [{ id: "msg_saved", type: "user", text: "Keep this session", time: { created: 1 } }], }), }) - let refreshes = 0 - await page.route("**/api/worktree/refresh", (route) => { - refreshes++ - return route.fulfill({ status: 204, headers: { "access-control-allow-origin": "*" } }) + const inventory: string[] = [] + page.on("request", (request) => { + if (new URL(request.url()).pathname.startsWith("/api/worktree")) inventory.push(request.url()) }) - const listed = page.waitForResponse( - (response) => new URL(response.url()).pathname === "/api/worktree" && response.ok(), - ) - await page.goto(`/server/${base64Encode(fixture.serverKey)}/session/${sessionID}`) - await listed - await expect(page.getByText("Keep this session", { exact: true })).toBeVisible() - const prompt = page.getByRole("textbox", { name: "Prompt", exact: true }) - await expect(prompt).toBeEditable() - await prompt.fill("Keep this draft") - await expect(prompt).toHaveText("Keep this draft") - await expect(page.getByRole("status")).toHaveCount(0) - expect(refreshes).toBe(0) -}) - -for (const phase of ["before", "refresh", "after"] as const) { - test(`keeps the composer when the worktree ${phase} request fails with 500`, async ({ page }) => { - const source = "/projects/registered-worktree" - const sessionID = `ses_worktree_${phase}_failure` - await mockOpenCodeServer(page, { - directory: fixture.directory, - project: fixture.project, - provider: fixture.provider, - sessions: [{ id: sessionID, projectID: fixture.project.id, directory: source }], - pageMessages: () => ({ items: [{ id: "msg_saved", type: "user", text: "Keep working", time: { created: 1 } }] }), - }) - let lists = 0 - await page.route("**/api/worktree?**", (route) => { - if (route.request().method() !== "GET") return route.fallback() - lists++ - if ((phase === "before" && lists === 1) || (phase === "after" && lists === 2)) - return route.fulfill({ status: 500, body: "", headers: { "access-control-allow-origin": "*" } }) - return route.fulfill({ json: [{ directory: fixture.directory }, { directory: source, strategy: "git" }] }) - }) - await page.route("**/api/worktree/refresh", (route) => - route.fulfill({ status: phase === "refresh" ? 500 : 204, headers: { "access-control-allow-origin": "*" } }), - ) - const failed = page.waitForResponse( - (response) => - ["/api/worktree", "/api/worktree/refresh"].includes(new URL(response.url()).pathname) && - response.status() === 500, - ) - await page.goto(`/server/${base64Encode(fixture.serverKey)}/session/${sessionID}`) - await failed - await expect(page.getByText("Keep working", { exact: true })).toBeVisible() - const prompt = page.getByRole("textbox", { name: "Prompt", exact: true }) - await expect(prompt).toBeEditable() - await prompt.fill("Keep the draft") - await expect(prompt).toHaveText("Keep the draft") - await expect(page.getByRole("status")).toHaveCount(0) - }) -} - -test("ignores a failed inventory refresh from before reconnecting", async ({ page }) => { - const source = "/projects/reconnected-worktree" - const sessionID = "ses_worktree_reconnect" - 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: source }], - pageMessages: () => ({ - items: [{ id: "msg_saved", type: "user", text: "Reconnect safely", time: { created: 1 } }], - }), - }) - await page.route("**/api/worktree?**", (route) => - route.fulfill({ json: [{ directory: fixture.directory }, { directory: source, strategy: "git" }] }), - ) - let refreshes = 0 - await page.route("**/api/worktree/refresh", async (route) => { - refreshes++ - if (refreshes === 1) { - requested.resolve() - await release.promise - } - return route.fulfill({ status: 500, body: "", 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("Draft while reconnecting") - const connection = await transport.waitForConnection() - await transport.close() - await transport.waitForConnection({ after: connection.id }) - const failed = page.waitForResponse( - (response) => new URL(response.url()).pathname === "/api/worktree/refresh" && response.status() === 500, - ) - release.resolve() - await failed - await expect(page.getByText("Reconnect safely", { exact: true })).toBeVisible() - await expect(prompt).toBeEditable() - await expect(prompt).toHaveText("Draft while reconnecting") - await expect(page.getByRole("status")).toHaveCount(0) -}) - -async function removedInventory( - page: Page, - source: string, - missing: () => boolean = () => true, - options: { - destination?: string - requested?: PromiseWithResolvers - release?: PromiseWithResolvers - } = {}, -) { - let pruned = false - await page.route("**/api/worktree?**", (route) => { - if (route.request().method() !== "GET") return route.fallback() + await page.route("**/api/location?**", (route) => { + if (new URL(route.request().url()).searchParams.get("location[directory]") !== source) return route.fallback() return route.fulfill({ - json: [ - { directory: fixture.directory }, - ...(!pruned || !missing() ? [{ directory: source, strategy: "git" }] : []), - ...(options.destination ? [{ directory: options.destination, strategy: "git" }] : []), - ], + status: 404, + json: { _tag: "LocationNotFoundError", directory: source, message: `Location not found: ${source}` }, headers: { "access-control-allow-origin": "*" }, }) }) - await page.route("**/api/worktree/refresh", async (route) => { - if (route.request().method() !== "POST") return route.fallback() - options.requested?.resolve() - await options.release?.promise - if (missing()) pruned = true - return route.fulfill({ status: 204, headers: { "access-control-allow-origin": "*" } }) + const missing = page.waitForResponse((response) => { + const url = new URL(response.url()) + return ( + url.pathname === "/api/location" && + url.searchParams.get("location[directory]") === source && + response.status() === 404 + ) }) -} + await page.goto(`/server/${base64Encode(fixture.serverKey)}/session/${sessionID}`) + await missing + await expect(page.getByText("Keep this session", { exact: true })).toBeVisible() + await expect(page.getByRole("status")).toContainText("Session location unavailable") + await expect(page.getByRole("textbox", { name: "Prompt", exact: true })).toHaveCount(0) + expect(inventory).toEqual([]) +}) diff --git a/packages/app/src/session/route.tsx b/packages/app/src/session/route.tsx index be2e3a0422c..77bc364280a 100644 --- a/packages/app/src/session/route.tsx +++ b/packages/app/src/session/route.tsx @@ -3,6 +3,7 @@ import { createStore } from "solid-js/store" import { useParams } from "@solidjs/router" import { DataProvider } from "@opencode/session-ui/context" import { SessionUserMessage } from "@opencode/session-ui/message" +import { isLocationNotFoundError } from "@opencode/client/promise" import { TextShimmer } from "@opencode/ui/text-shimmer" import { CommentsProvider } from "@/composer/comments" import { readPromptPresentation } from "@/composer/comment-note" @@ -29,7 +30,6 @@ import { createSessionResolution } from "./session-resolution" import { SessionScreen } from "./screen" import { PreparingComposer } from "./preparing-composer" import type { MissingLocation } from "./composer/location-missing" -import { containsDirectory } from "@/workspaces/paths" export function TargetSessionRouteContent() { const params = useParams<{ serverKey: string; id: string }>() @@ -49,21 +49,18 @@ export function TargetSessionRouteContent() { onCleanup(() => { stale = true }) - const api = server.ctx.sdk.api.worktree - // A missing row alone cannot distinguish a deleted worktree from a directory - // that was never registered. Preserve positive evidence before refresh prunes it. - const coversSession = (item: { directory: string; strategy?: string }) => - !!item.strategy && containsDirectory(item.directory, directory) - void api - .list({ projectID: project.id }) - .then(async (before) => { - if (stale || !before.some(coversSession)) return - await api.refresh({ projectID: project.id }) - const after = await api.list({ projectID: project.id }) - if (stale || after.some(coversSession) || server.ctx.sdk.connection.status() !== "connected") return - setLocationState("missing", { sessionID: session.id, projectID: project.id, directory }) - }) - .catch(() => undefined) + void server.ctx.sdk.api.location.get({ location: { directory } }).then( + () => undefined, + (error) => { + if ( + !stale && + server.ctx.sdk.connection.status() === "connected" && + isLocationNotFoundError(error) && + error.directory === directory + ) + setLocationState("missing", { sessionID: session.id, projectID: project.id, directory }) + }, + ) }) const missing = createMemo(() => { const session = data.session.get(params.id) diff --git a/packages/client/src/promise/generated/client.ts b/packages/client/src/promise/generated/client.ts index e78020b7442..f6ee95283d7 100644 --- a/packages/client/src/promise/generated/client.ts +++ b/packages/client/src/promise/generated/client.ts @@ -447,7 +447,7 @@ export function make(options: ClientOptions) { path: `/api/location`, query: { location: input?.["location"] }, successStatus: 200, - declaredStatuses: [400, 401], + declaredStatuses: [400, 401, 404], empty: false, }, requestOptions, diff --git a/packages/client/src/promise/generated/types.ts b/packages/client/src/promise/generated/types.ts index 217cc570e5b..e767142f71c 100644 --- a/packages/client/src/promise/generated/types.ts +++ b/packages/client/src/promise/generated/types.ts @@ -2518,6 +2518,14 @@ export type UnauthorizedError = { readonly _tag: "UnauthorizedError"; readonly m export const isUnauthorizedError = (value: unknown): value is UnauthorizedError => typeof value === "object" && value !== null && "_tag" in value && value["_tag"] === "UnauthorizedError" +export type LocationNotFoundError = { + readonly _tag: "LocationNotFoundError" + readonly directory: 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 ServiceUnavailableError = { readonly _tag: "ServiceUnavailableError" readonly message: string diff --git a/packages/protocol/src/api.ts b/packages/protocol/src/api.ts index e837cff1165..f3e36dd8f1e 100644 --- a/packages/protocol/src/api.ts +++ b/packages/protocol/src/api.ts @@ -66,9 +66,7 @@ type FormGroups = R typeof makeFormGroup > -type LocationGroup = ReturnType< - typeof makeLocationGroup -> +type LocationGroup = ReturnType type MixedMiddlewareGroups< LocationId extends HttpApiMiddleware.AnyId, @@ -94,7 +92,7 @@ type ApiGroups< | typeof PersistentPtyGroup | typeof CredentialGroup | LocationGroups - | LocationGroup + | LocationGroup | FormGroups | SessionGroups | MixedMiddlewareGroups @@ -154,7 +152,7 @@ const makeApiFromGroup = < > => HttpApi.make("server") .add(ServerGroup) - .add(makeLocationGroup(locationMiddleware)) + .add(makeLocationGroup()) .add(AgentGroup.middleware(locationMiddleware)) .add(PluginGroup.middleware(locationMiddleware)) .add(makeSessionGroup(sessionLocationMiddleware, formLocationMiddleware)) diff --git a/packages/protocol/src/errors.ts b/packages/protocol/src/errors.ts index 5e2ee1705c8..a318f45272f 100644 --- a/packages/protocol/src/errors.ts +++ b/packages/protocol/src/errors.ts @@ -129,6 +129,15 @@ export class FileNotFoundError extends Schema.TaggedError()( { httpApiStatus: 404 }, ) {} +export class LocationNotFoundError extends Schema.TaggedError()( + "LocationNotFoundError", + { + directory: 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 ffcce9a4cd2..8fc6cb567d8 100644 --- a/packages/protocol/src/groups/location.ts +++ b/packages/protocol/src/groups/location.ts @@ -1,7 +1,7 @@ import { Location } from "@opencode/schema/location" -import { Context, Schema } from "effect" -import { HttpApiEndpoint, HttpApiGroup, HttpApiMiddleware, HttpApiSchema, OpenApi } from "effect/unstable/httpapi" -import { ServiceUnavailableError } from "../errors.js" +import { Schema } from "effect" +import { HttpApiEndpoint, HttpApiGroup, HttpApiSchema, OpenApi } from "effect/unstable/httpapi" +import { LocationNotFoundError, ServiceUnavailableError } from "../errors.js" export const LocationQuery = Schema.Struct({ location: Schema.optional( @@ -26,18 +26,16 @@ export const locationQueryOpenApi = OpenApi.annotations({ }, }) -// Middleware is applied per endpoint: reload acts on every loaded location and -// must not boot the caller's location first. -export const makeLocationGroup = ( - locationMiddleware: Context.Key, -) => +// Get checks the path before entering the location scope; reload acts on all +// loaded locations and must not boot the caller's location first. +export const makeLocationGroup = () => HttpApiGroup.make("server.location") .add( HttpApiEndpoint.get("location.get", "/api/location", { query: LocationQuery, success: Location.PublicInfo, + error: LocationNotFoundError, }) - .middleware(locationMiddleware) .annotateMerge(locationQueryOpenApi) .annotateMerge( OpenApi.annotations({ diff --git a/packages/server/src/handlers/location.ts b/packages/server/src/handlers/location.ts index bfd7eec2785..5db75ff483d 100644 --- a/packages/server/src/handlers/location.ts +++ b/packages/server/src/handlers/location.ts @@ -1,22 +1,41 @@ import { Location } from "@opencode/core/location" import { LocationServiceMap } from "@opencode/core/location-service-map" -import { ServiceUnavailableError } from "@opencode/protocol/errors" +import { LocationNotFoundError, ServiceUnavailableError } from "@opencode/protocol/errors" +import { FSUtil } from "@opencode/util/fs-util" import { Cause, 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* () { - const location = yield* Location.Service - return new Location.Info({ - directory: location.directory, - project: location.project, - }) + Effect.fn(function* (ctx) { + const ref = requestRef(ctx.request) + const missing = () => + new LocationNotFoundError({ directory: ref.directory, message: `Location not found: ${ref.directory}` }) + const stat = yield* fs.stat(ref.directory).pipe( + Effect.catchTag("PlatformError", (error) => { + if (error.reason._tag === "NotFound") return missing() + if ( + error.reason._tag === "BadResource" && + error.reason.cause instanceof Error && + "code" in error.reason.cause && + error.reason.cause.code === "ENOTDIR" + ) + return missing() + return Effect.die(error) + }), + ) + if (stat.type !== "Directory") return yield* missing() + return yield* Effect.gen(function* () { + const location = yield* Location.Service + return new Location.Info({ directory: location.directory, project: location.project }) + }).pipe(Effect.provide(locations.get(ref))) }), ) .handle("location.reload", () => diff --git a/packages/server/src/routes.ts b/packages/server/src/routes.ts index c4f746f1d82..ed760a380a1 100644 --- a/packages/server/src/routes.ts +++ b/packages/server/src/routes.ts @@ -22,6 +22,7 @@ import { ShellSelect } from "@opencode/core/shell/select" import { Job } from "@opencode/core/job" import { Mcp } from "@opencode/core/mcp/index" import { Global } from "@opencode/util/global" +import { FSUtil } from "@opencode/util/fs-util" import { InstructionDiscovery } from "@opencode/core/instruction-discovery" import { LocationServiceMap } from "@opencode/core/location-service-map" import { LocationActivity } from "@opencode/core/location-activity" @@ -51,6 +52,7 @@ import type { ServerOptions } from "./options" const applicationServiceNodes = [ Global.node, + FSUtil.node, Database.node, Bus.node, EventLogger.node, @@ -168,6 +170,7 @@ function makeRoutes( PermissionSaved.Service, PluginUpdate.Service, Project.Service, + FSUtil.Service, WellKnown.Service, )(context), ), diff --git a/packages/server/test/location-get.test.ts b/packages/server/test/location-get.test.ts new file mode 100644 index 00000000000..92e51054186 --- /dev/null +++ b/packages/server/test/location-get.test.ts @@ -0,0 +1,86 @@ +import fs from "node:fs/promises" +import path from "node:path" +import { expect } from "bun:test" +import { Effect } from "effect" +import { tmpdirScoped } from "../../core/test/fixture/tmpdir" +import { it } from "../../core/test/lib/effect" +import { startServer } from "./fixture/server" + +it.live("reads an existing directory through the existing location endpoint", () => + Effect.gen(function* () { + const tmp = yield* tmpdirScoped() + const existing = path.join(tmp.path, "existing") + yield* Effect.promise(() => fs.mkdir(existing)) + const server = yield* startServer(tmp.path) + const url = new URL("/api/location", server.base) + url.searchParams.set("location[directory]", existing) + const response = yield* Effect.promise(() => fetch(url, { headers: server.headers })) + expect(response.status).toBe(200) + expect((yield* Effect.promise(() => response.json())).directory).toBe(existing) + + const header = yield* Effect.promise(() => + fetch(new URL("/api/location", server.base), { + headers: { ...server.headers, "x-opencode-directory": encodeURIComponent(existing) }, + }), + ) + expect((yield* Effect.promise(() => header.json())).directory).toBe(existing) + + const precedence = yield* Effect.promise(() => + fetch(url, { + headers: { ...server.headers, "x-opencode-directory": encodeURIComponent(path.join(tmp.path, "removed")) }, + }), + ) + expect((yield* Effect.promise(() => precedence.json())).directory).toBe(existing) + + const defaultLocation = yield* Effect.promise(() => + fetch(new URL("/api/location", server.base), { headers: server.headers }), + ) + expect(defaultLocation.status).toBe(200) + expect((yield* Effect.promise(() => defaultLocation.json())).directory).toBe(process.cwd()) + + yield* Effect.promise(() => fs.rmdir(existing)) + const removed = yield* Effect.promise(() => fetch(url, { headers: server.headers })) + expect(removed.status).toBe(404) + expect(yield* Effect.promise(() => removed.json())).toMatchObject({ + _tag: "LocationNotFoundError", + directory: existing, + }) + }), +) + +it.live("reports only a missing or non-directory location as a typed 404 before booting", () => + Effect.gen(function* () { + const tmp = yield* tmpdirScoped() + const missing = path.join(tmp.path, "removed") + const file = path.join(tmp.path, "file") + const loop = path.join(tmp.path, "loop") + yield* Effect.promise(async () => { + await fs.writeFile(file, "content") + await fs.symlink(loop, loop) + }) + const server = yield* startServer(tmp.path) + const get = (directory: string) => + Effect.promise(async () => { + const url = new URL("/api/location", server.base) + url.searchParams.set("location[directory]", directory) + const response = await fetch(url, { headers: server.headers }) + return { status: response.status, body: await response.json() } + }) + + for (const directory of [missing, file, path.join(file, "child")]) { + expect(yield* get(directory)).toMatchObject({ + status: 404, + body: { _tag: "LocationNotFoundError", directory }, + }) + } + expect((yield* get(loop)).status).toBe(500) + const probe = yield* Effect.promise(() => + fetch(new URL("/api/location/probe", server.base), { headers: server.headers }), + ) + expect(probe.status).toBe(404) + const loaded = yield* Effect.promise(async () => + (await fetch(new URL("/api/debug/location", server.base), { headers: server.headers })).json(), + ) + expect(loaded).toEqual([]) + }), +)