fix(app): detect missing session locations through typed 404

This commit is contained in:
Brendonovich
2026-09-30 09:17:10 +00:00
committed by opencode-agent[bot]
parent ccf1d72523
commit fdbf7267fd
11 changed files with 302 additions and 174 deletions
@@ -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([])
})
@@ -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<void>()
const release = Promise.withResolvers<void>()
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<void>
release?: PromiseWithResolvers<void>
} = {},
) {
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([])
})
}
+12 -15
View File
@@ -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
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 })
})
.catch(() => undefined)
},
)
})
const missing = createMemo(() => {
const session = data.session.get(params.id)
@@ -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,
@@ -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
+3 -5
View File
@@ -66,9 +66,7 @@ type FormGroups<LocationId extends HttpApiMiddleware.AnyId, LocationService> = R
typeof makeFormGroup<LocationId, LocationService>
>
type LocationGroup<LocationId extends HttpApiMiddleware.AnyId, LocationService> = ReturnType<
typeof makeLocationGroup<LocationId, LocationService>
>
type LocationGroup = ReturnType<typeof makeLocationGroup>
type MixedMiddlewareGroups<
LocationId extends HttpApiMiddleware.AnyId,
@@ -94,7 +92,7 @@ type ApiGroups<
| typeof PersistentPtyGroup
| typeof CredentialGroup
| LocationGroups<LocationId>
| LocationGroup<LocationId, LocationService>
| LocationGroup
| FormGroups<LocationId, LocationService>
| SessionGroups<SessionLocationId, SessionLocationService, FormLocationId, FormLocationService>
| MixedMiddlewareGroups<LocationId, LocationService, SessionLocationId, SessionLocationService>
@@ -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))
+9
View File
@@ -129,6 +129,15 @@ export class FileNotFoundError extends Schema.TaggedError<FileNotFoundError>()(
{ httpApiStatus: 404 },
) {}
export class LocationNotFoundError extends Schema.TaggedError<LocationNotFoundError>()(
"LocationNotFoundError",
{
directory: Schema.String,
message: Schema.String,
},
{ httpApiStatus: 404 },
) {}
export class AgentNotFoundError extends Schema.TaggedError<AgentNotFoundError>()(
"AgentNotFoundError",
{
+7 -9
View File
@@ -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 = <LocationId extends HttpApiMiddleware.AnyId, LocationService>(
locationMiddleware: Context.Key<LocationId, LocationService>,
) =>
// 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({
+25 -6
View File
@@ -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* () {
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,
})
return new Location.Info({ directory: location.directory, project: location.project })
}).pipe(Effect.provide(locations.get(ref)))
}),
)
.handle("location.reload", () =>
+3
View File
@@ -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<AuthError, AuthServices>(
PermissionSaved.Service,
PluginUpdate.Service,
Project.Service,
FSUtil.Service,
WellKnown.Service,
)(context),
),
+86
View File
@@ -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([])
}),
)