mirror of
https://gitee.com/zhangsken/opencode.git
synced 2026-10-16 02:24:10 +08:00
fix(app): detect missing session locations through typed 404
This commit is contained in:
committed by
opencode-agent[bot]
parent
ccf1d72523
commit
fdbf7267fd
@@ -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([])
|
||||
})
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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",
|
||||
{
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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", () =>
|
||||
|
||||
@@ -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),
|
||||
),
|
||||
|
||||
@@ -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([])
|
||||
}),
|
||||
)
|
||||
Reference in New Issue
Block a user