diff --git a/packages/app/e2e/regression/session-location-sync.spec.ts b/packages/app/e2e/regression/session-location-sync.spec.ts index e662b6aebca..edfd13d7324 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).toBeGreaterThanOrEqual(recover ? 2 : 3) + 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([]) @@ -59,50 +59,6 @@ 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" }, -]) { - test(`does not recover from an unrelated 404 (${error._tag}, ${"directory" in error ? error.directory : error.path})`, 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, json: error, 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" @@ -261,7 +217,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).toBeGreaterThanOrEqual(2) + expect(requests).toBe(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 5dee6b7bc81..01dea1622c2 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 } from "@playwright/test" +import { expect, test, type Page } from "@playwright/test" import { base64Encode } from "@opencode/util/encode" import { fixture } from "../smoke/session-timeline.fixture" import { mockOpenCodeServer } from "../utils/mock-server" @@ -6,9 +6,15 @@ import { installSseTransport } from "../utils/sse-transport" test("keeps a session in a removed worktree readable and movable", async ({ page }) => { const missing = "/projects/removed-worktree" + const sessionDirectory = `${missing}/src` const destination = fixture.directory const sessionID = "ses_removed_worktree" - const session = { id: sessionID, projectID: fixture.project.id, directory: missing, title: "Removed worktree" } + const session = { + id: sessionID, + projectID: fixture.project.id, + directory: sessionDirectory, + title: "Removed worktree", + } const transport = await installSseTransport(page, { server: fixture.serverKey }) await mockOpenCodeServer(page, { directory: destination, @@ -20,30 +26,14 @@ 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 page.route("**/api/**", (route) => { - const url = new URL(route.request().url()) - if (url.searchParams.get("location[directory]") !== missing) return route.fallback() - if (url.pathname === "/api/location") - return route.fulfill({ - status: 404, - json: { _tag: "LocationNotFoundError", directory: missing, message: `Location not found: ${missing}` }, - headers: { "access-control-allow-origin": "*" }, - }) - if (!["/api/location", "/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 removedInventory(page, missing) await page.route(`**/api/session/${sessionID}/move`, (route) => route.fulfill({ status: 204, body: "" })) await page.goto(`/server/${base64Encode(fixture.serverKey)}/session/${sessionID}`, { waitUntil: "domcontentloaded" }) await expect(page.getByText("Saved conversation in removed worktree", { exact: true })).toBeVisible() const prompt = page.getByRole("textbox", { name: "Prompt", exact: true }) await expect(page.getByRole("status")).toContainText("Session location unavailable") - await expect(page.getByRole("status")).toContainText(missing) + await expect(page.getByRole("status")).toContainText(sessionDirectory) await expect(prompt).toHaveCount(0) await expect(page.getByRole("button", { name: "Choose directory", exact: true })).toBeEnabled() await transport.waitForConnection() @@ -90,15 +80,7 @@ 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 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": "*" }, - }) - }) + await removedInventory(page, source, () => missing) let moves = 0 await page.route(`**/api/session/${sessionID}/move`, (route) => { moves++ @@ -116,18 +98,9 @@ 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() @@ -188,30 +161,13 @@ test("ignores a stale missing result after the session moves", async ({ page }) sessions: [session], pageMessages: () => ({ items: [] }), }) - await page.route("**/api/location?**", async (route) => { - if (new URL(route.request().url()).searchParams.get("location[directory]") === source) { - 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": "*" }, - }) - } - return route.fallback() - }) + await removedInventory(page, source, undefined, { requested, release }) 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", @@ -220,17 +176,7 @@ 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) @@ -249,14 +195,7 @@ test("moves a removed-worktree session into an existing worktree", async ({ page sessions: [session], pageMessages: () => ({ items: [] }), }) - 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 removedInventory(page, source, undefined, { destination }) 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") @@ -280,3 +219,149 @@ test("moves a removed-worktree session into an existing worktree", async ({ page await expect(page.getByRole("textbox", { name: "Prompt", exact: true })).toBeEditable() 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 }) => { + const source = "/projects/unregistered-worktree" + const sessionID = "ses_unregistered_worktree" + 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 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 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() + return route.fulfill({ + json: [ + { directory: fixture.directory }, + ...(!pruned || !missing() ? [{ directory: source, strategy: "git" }] : []), + ...(options.destination ? [{ directory: options.destination, strategy: "git" }] : []), + ], + 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": "*" } }) + }) +} diff --git a/packages/app/src/session/route.tsx b/packages/app/src/session/route.tsx index 911098dee98..be2e3a0422c 100644 --- a/packages/app/src/session/route.tsx +++ b/packages/app/src/session/route.tsx @@ -3,7 +3,6 @@ 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" @@ -30,6 +29,7 @@ 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,13 +49,21 @@ export function TargetSessionRouteContent() { onCleanup(() => { stale = true }) - void server.ctx.sdk.api.location.get({ location: { directory } }).then( - () => undefined, - (error) => { - if (!stale && isLocationNotFoundError(error) && error.directory === directory) - setLocationState("missing", { sessionID: session.id, projectID: project.id, directory }) - }, - ) + 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) }) 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 f6ee95283d7..e78020b7442 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, 404], + declaredStatuses: [400, 401], empty: false, }, requestOptions, diff --git a/packages/client/src/promise/generated/types.ts b/packages/client/src/promise/generated/types.ts index e767142f71c..217cc570e5b 100644 --- a/packages/client/src/promise/generated/types.ts +++ b/packages/client/src/promise/generated/types.ts @@ -2518,14 +2518,6 @@ 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 f3e36dd8f1e..e837cff1165 100644 --- a/packages/protocol/src/api.ts +++ b/packages/protocol/src/api.ts @@ -66,7 +66,9 @@ type FormGroups = R typeof makeFormGroup > -type LocationGroup = ReturnType +type LocationGroup = ReturnType< + typeof makeLocationGroup +> type MixedMiddlewareGroups< LocationId extends HttpApiMiddleware.AnyId, @@ -92,7 +94,7 @@ type ApiGroups< | typeof PersistentPtyGroup | typeof CredentialGroup | LocationGroups - | LocationGroup + | LocationGroup | FormGroups | SessionGroups | MixedMiddlewareGroups @@ -152,7 +154,7 @@ const makeApiFromGroup = < > => HttpApi.make("server") .add(ServerGroup) - .add(makeLocationGroup()) + .add(makeLocationGroup(locationMiddleware)) .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 a318f45272f..5e2ee1705c8 100644 --- a/packages/protocol/src/errors.ts +++ b/packages/protocol/src/errors.ts @@ -129,15 +129,6 @@ 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 8fc6cb567d8..ffcce9a4cd2 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 { Schema } from "effect" -import { HttpApiEndpoint, HttpApiGroup, HttpApiSchema, OpenApi } from "effect/unstable/httpapi" -import { LocationNotFoundError, ServiceUnavailableError } from "../errors.js" +import { Context, Schema } from "effect" +import { HttpApiEndpoint, HttpApiGroup, HttpApiMiddleware, HttpApiSchema, OpenApi } from "effect/unstable/httpapi" +import { ServiceUnavailableError } from "../errors.js" export const LocationQuery = Schema.Struct({ location: Schema.optional( @@ -26,16 +26,18 @@ export const locationQueryOpenApi = OpenApi.annotations({ }, }) -// 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 = () => +// 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, +) => 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 5db75ff483d..bfd7eec2785 100644 --- a/packages/server/src/handlers/location.ts +++ b/packages/server/src/handlers/location.ts @@ -1,41 +1,22 @@ import { Location } from "@opencode/core/location" import { LocationServiceMap } from "@opencode/core/location-service-map" -import { LocationNotFoundError, ServiceUnavailableError } from "@opencode/protocol/errors" -import { FSUtil } from "@opencode/util/fs-util" +import { ServiceUnavailableError } from "@opencode/protocol/errors" 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* (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))) + Effect.fn(function* () { + const location = yield* Location.Service + return new Location.Info({ + directory: location.directory, + project: location.project, + }) }), ) .handle("location.reload", () => diff --git a/packages/server/src/routes.ts b/packages/server/src/routes.ts index ed760a380a1..c4f746f1d82 100644 --- a/packages/server/src/routes.ts +++ b/packages/server/src/routes.ts @@ -22,7 +22,6 @@ 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" @@ -52,7 +51,6 @@ import type { ServerOptions } from "./options" const applicationServiceNodes = [ Global.node, - FSUtil.node, Database.node, Bus.node, EventLogger.node, @@ -170,7 +168,6 @@ 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 deleted file mode 100644 index 92e51054186..00000000000 --- a/packages/server/test/location-get.test.ts +++ /dev/null @@ -1,86 +0,0 @@ -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([]) - }), -)