mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-28 20:46:14 +00:00
Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
45f18040b4 |
@@ -415,7 +415,7 @@ export function make(options: ClientOptions) {
|
||||
path: `/api/location`,
|
||||
query: { location: input?.["location"] },
|
||||
successStatus: 200,
|
||||
declaredStatuses: [401, 400],
|
||||
declaredStatuses: [404, 401, 400],
|
||||
empty: false,
|
||||
},
|
||||
requestOptions,
|
||||
|
||||
@@ -2318,6 +2318,15 @@ export type InvalidRequestError = {
|
||||
export const isInvalidRequestError = (value: unknown): value is InvalidRequestError =>
|
||||
typeof value === "object" && value !== null && "_tag" in value && value["_tag"] === "InvalidRequestError"
|
||||
|
||||
export type LocationDirectoryError = {
|
||||
readonly _tag: "LocationDirectoryError"
|
||||
readonly directory: string
|
||||
readonly reason: "not_found" | "not_directory"
|
||||
readonly message: string
|
||||
}
|
||||
export const isLocationDirectoryError = (value: unknown): value is LocationDirectoryError =>
|
||||
typeof value === "object" && value !== null && "_tag" in value && value["_tag"] === "LocationDirectoryError"
|
||||
|
||||
export type AgentNotFoundError = {
|
||||
readonly _tag: "AgentNotFoundError"
|
||||
readonly agentID: string
|
||||
|
||||
@@ -48,6 +48,7 @@ import {
|
||||
import { createStore, produce, reconcile } from "solid-js/store"
|
||||
import type { SessionInbox } from "@opencode-ai/schema/session-inbox"
|
||||
import { batch, createEffect, createMemo, createSignal, onCleanup } from "solid-js"
|
||||
import { LocationSyncError, type LocationSyncResource } from "./location-sync-error"
|
||||
|
||||
export type DataSessionStatus = "idle" | "running"
|
||||
|
||||
@@ -1616,7 +1617,11 @@ export function createData(config: CreateDataInput) {
|
||||
syncInfo(ref?: LocationRef) {
|
||||
const current = ref ?? defaultLocation()
|
||||
return sync.run(`location:${locationKey(current)}`, async () => {
|
||||
const location = await api().location.get({ location: locationQuery(current) })
|
||||
const location = await api()
|
||||
.location.get({ location: locationQuery(current) })
|
||||
.catch((cause) => {
|
||||
throw new LocationSyncError(current, "info", cause)
|
||||
})
|
||||
const key = locationKey(location)
|
||||
if (!store.location[key]) setStore("location", key, {})
|
||||
setStore("location", key, "info", location)
|
||||
@@ -1628,20 +1633,29 @@ export function createData(config: CreateDataInput) {
|
||||
async sync(ref?: LocationRef) {
|
||||
await result.location.syncInfo(ref)
|
||||
const location = ref ?? defaultLocation()
|
||||
await Promise.all([
|
||||
result.location.vcs.sync(location),
|
||||
result.location.agent.sync(location),
|
||||
result.location.command.sync(location),
|
||||
result.location.integration.sync(location),
|
||||
result.location.mcp.server.sync(location),
|
||||
result.location.mcp.resource.sync(location),
|
||||
result.location.model.sync(location),
|
||||
result.location.provider.sync(location),
|
||||
result.location.reference.sync(location),
|
||||
result.location.skill.sync(location),
|
||||
result.shell.sync(location),
|
||||
result.session.form.sync("global", location),
|
||||
])
|
||||
// Reads commit independently. A rejection identifies the failed resource;
|
||||
// successful reads remain cached and failed reads can be retried.
|
||||
const resources = {
|
||||
vcs: result.location.vcs.sync(location),
|
||||
agent: result.location.agent.sync(location),
|
||||
command: result.location.command.sync(location),
|
||||
integration: result.location.integration.sync(location),
|
||||
"mcp.server": result.location.mcp.server.sync(location),
|
||||
"mcp.resource": result.location.mcp.resource.sync(location),
|
||||
model: result.location.model.sync(location),
|
||||
provider: result.location.provider.sync(location),
|
||||
reference: result.location.reference.sync(location),
|
||||
skill: result.location.skill.sync(location),
|
||||
shell: result.shell.sync(location),
|
||||
form: result.session.form.sync("global", location),
|
||||
} satisfies Record<Exclude<LocationSyncResource, "info">, Promise<void>>
|
||||
await Promise.all(
|
||||
(Object.keys(resources) as (keyof typeof resources)[]).map((resource) =>
|
||||
resources[resource].catch((cause) => {
|
||||
throw new LocationSyncError(location, resource, cause)
|
||||
}),
|
||||
),
|
||||
)
|
||||
},
|
||||
invalidate(ref?: LocationRef) {
|
||||
const location = ref ?? defaultLocation()
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
export * from "./data"
|
||||
export * from "./connection"
|
||||
export * from "./pty"
|
||||
export * from "./location-sync-error"
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
import { ClientError, isLocationDirectoryError, type LocationRef } from "../promise"
|
||||
|
||||
export type LocationSyncResource =
|
||||
| "info"
|
||||
| "vcs"
|
||||
| "agent"
|
||||
| "command"
|
||||
| "integration"
|
||||
| "mcp.server"
|
||||
| "mcp.resource"
|
||||
| "model"
|
||||
| "provider"
|
||||
| "reference"
|
||||
| "skill"
|
||||
| "shell"
|
||||
| "form"
|
||||
|
||||
/** A failed location read, not a claim that the directory is missing. */
|
||||
export class LocationSyncError extends Error {
|
||||
override readonly name = "LocationSyncError"
|
||||
|
||||
constructor(
|
||||
readonly location: LocationRef,
|
||||
readonly resource: LocationSyncResource,
|
||||
cause: unknown,
|
||||
) {
|
||||
super(`Failed to sync ${resource} for ${location.directory}`, { cause })
|
||||
}
|
||||
|
||||
get reason(): "missing" | "transport" | "location" | "resource" {
|
||||
if (
|
||||
this.resource === "info" &&
|
||||
isLocationDirectoryError(this.cause) &&
|
||||
this.cause.directory === this.location.directory
|
||||
)
|
||||
return "missing"
|
||||
if (this.cause instanceof ClientError && this.cause.reason === "Transport") return "transport"
|
||||
return this.resource === "info" ? "location" : "resource"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
import { expect, test } from "bun:test"
|
||||
import { createRoot } from "solid-js"
|
||||
import { OpenCode } from "../src/promise"
|
||||
import { createData, LocationSyncError } from "../src/solid"
|
||||
|
||||
const location = { directory: "/project", project: { id: "project", directory: "/project" } }
|
||||
|
||||
function setup(failure: (path: string) => Response | undefined) {
|
||||
const requests: string[] = []
|
||||
const api = OpenCode.make({
|
||||
baseUrl: "http://opencode.local",
|
||||
fetch: async (input, init) => {
|
||||
const request = input instanceof Request ? input : new Request(input, init)
|
||||
const path = new URL(request.url).pathname
|
||||
requests.push(path)
|
||||
const response = failure(path)
|
||||
if (response) return response
|
||||
if (path === "/api/location") return Response.json(location)
|
||||
const data = path === "/api/mcp/resource" ? { resources: [], templates: [] } : []
|
||||
return Response.json({ location, data })
|
||||
},
|
||||
})
|
||||
return createRoot((dispose) => ({
|
||||
data: createData({
|
||||
api: () => api,
|
||||
directory: location.directory,
|
||||
event: { on: () => () => {}, listen: () => () => {} },
|
||||
connection: { status: () => "connected" },
|
||||
}),
|
||||
requests,
|
||||
dispose,
|
||||
}))
|
||||
}
|
||||
|
||||
test("location lookup failures retain their stage, location, and cause", async () => {
|
||||
const app = setup(() => Response.json({ message: "configuration failed" }, { status: 500 }))
|
||||
try {
|
||||
await expect(app.data.location.syncInfo()).rejects.toMatchObject({
|
||||
name: "LocationSyncError",
|
||||
resource: "info",
|
||||
location: { directory: location.directory },
|
||||
reason: "location",
|
||||
cause: { reason: "UnexpectedStatus", cause: { status: 500 } },
|
||||
})
|
||||
expect(app.requests).toEqual(["/api/location"])
|
||||
expect(app.data.location.info()).toBeUndefined()
|
||||
} finally {
|
||||
app.dispose()
|
||||
}
|
||||
})
|
||||
|
||||
test.each([
|
||||
["vcs", "/api/vcs"],
|
||||
["agent", "/api/agent"],
|
||||
["command", "/api/command"],
|
||||
["integration", "/api/integration"],
|
||||
["mcp.server", "/api/mcp"],
|
||||
["mcp.resource", "/api/mcp/resource"],
|
||||
["model", "/api/model"],
|
||||
["provider", "/api/provider"],
|
||||
["reference", "/api/reference"],
|
||||
["skill", "/api/skill"],
|
||||
["shell", "/api/shell"],
|
||||
["form", "/api/form/request"],
|
||||
])("location sync identifies failed %s resources without discarding the location", async (resource, path) => {
|
||||
let fail = true
|
||||
const app = setup((current) =>
|
||||
fail && current === path ? Response.json({ message: "server restarting" }, { status: 503 }) : undefined,
|
||||
)
|
||||
try {
|
||||
await expect(app.data.location.sync()).rejects.toMatchObject({
|
||||
name: "LocationSyncError",
|
||||
resource,
|
||||
reason: "resource",
|
||||
location: { directory: location.directory },
|
||||
cause: expect.anything(),
|
||||
})
|
||||
expect(app.data.location.info()).toEqual(location)
|
||||
fail = false
|
||||
await app.data.location.sync()
|
||||
expect(app.requests.filter((current) => current === path)).toHaveLength(2)
|
||||
expect(app.requests.filter((current) => current === "/api/location")).toHaveLength(1)
|
||||
} finally {
|
||||
app.dispose()
|
||||
}
|
||||
})
|
||||
|
||||
test("location sync preserves transport errors without claiming the directory is missing", async () => {
|
||||
const app = setup(() => {
|
||||
throw new Error("connection refused")
|
||||
})
|
||||
try {
|
||||
await expect(app.data.location.sync()).rejects.toMatchObject({
|
||||
name: "LocationSyncError",
|
||||
resource: "info",
|
||||
reason: "transport",
|
||||
cause: { reason: "Transport" },
|
||||
})
|
||||
} finally {
|
||||
app.dispose()
|
||||
}
|
||||
})
|
||||
|
||||
test("sync errors preserve the original cause by identity", () => {
|
||||
const cause = new Error("model catalog failed")
|
||||
const error = new LocationSyncError(location, "model", cause)
|
||||
expect(error.cause).toBe(cause)
|
||||
expect(error.message).toContain("model")
|
||||
expect(error.message).toContain(location.directory)
|
||||
})
|
||||
|
||||
test.each(["not_found", "not_directory"])("only explicit %s responses mark a location missing", async (reason) => {
|
||||
const app = setup(() =>
|
||||
Response.json(
|
||||
{
|
||||
_tag: "LocationDirectoryError",
|
||||
directory: location.directory,
|
||||
reason,
|
||||
message: "Directory unavailable",
|
||||
},
|
||||
{ status: 404 },
|
||||
),
|
||||
)
|
||||
try {
|
||||
await expect(app.data.location.sync()).rejects.toMatchObject({
|
||||
reason: "missing",
|
||||
resource: "info",
|
||||
cause: { _tag: "LocationDirectoryError", reason },
|
||||
})
|
||||
} finally {
|
||||
app.dispose()
|
||||
}
|
||||
})
|
||||
|
||||
test("unrelated 404s and resource errors do not imply a missing location", () => {
|
||||
expect(new LocationSyncError(location, "info", { _tag: "ProjectNotFoundError" }).reason).toBe("location")
|
||||
const cause = { _tag: "LocationDirectoryError", directory: location.directory, reason: "not_found" }
|
||||
expect(new LocationSyncError(location, "model", cause).reason).toBe("resource")
|
||||
expect(new LocationSyncError({ directory: "/other" }, "info", cause).reason).toBe("location")
|
||||
})
|
||||
@@ -36,7 +36,6 @@ import { ConfigGroup } from "./groups/config.js"
|
||||
import { WorkspaceGroup } from "./groups/workspace.js"
|
||||
|
||||
type LocationGroups<LocationId extends HttpApiMiddleware.AnyId> =
|
||||
| HttpApiGroup.AddMiddleware<typeof LocationGroup, LocationId>
|
||||
| HttpApiGroup.AddMiddleware<typeof AgentGroup, LocationId>
|
||||
| HttpApiGroup.AddMiddleware<typeof PluginGroup, LocationId>
|
||||
| HttpApiGroup.AddMiddleware<typeof ModelGroup, LocationId>
|
||||
@@ -83,6 +82,7 @@ type ApiGroups<
|
||||
Event extends HttpApiGroup.Constraint,
|
||||
> =
|
||||
| typeof HealthGroup
|
||||
| typeof LocationGroup
|
||||
| typeof ServerGroup
|
||||
| typeof DebugGroup
|
||||
| typeof MigrationGroup
|
||||
@@ -151,7 +151,7 @@ const makeApiFromGroup = <
|
||||
HttpApi.make("server")
|
||||
.add(HealthGroup)
|
||||
.add(ServerGroup)
|
||||
.add(LocationGroup.middleware(locationMiddleware))
|
||||
.add(LocationGroup)
|
||||
.add(AgentGroup.middleware(locationMiddleware))
|
||||
.add(PluginGroup.middleware(locationMiddleware))
|
||||
.add(makeSessionGroup(sessionLocationMiddleware))
|
||||
|
||||
@@ -62,6 +62,16 @@ export class ProviderNotFoundError extends Schema.TaggedError<ProviderNotFoundEr
|
||||
{ httpApiStatus: 404 },
|
||||
) {}
|
||||
|
||||
export class LocationDirectoryError extends Schema.TaggedError<LocationDirectoryError>()(
|
||||
"LocationDirectoryError",
|
||||
{
|
||||
directory: Schema.String,
|
||||
reason: Schema.Literals(["not_found", "not_directory"]),
|
||||
message: Schema.String,
|
||||
},
|
||||
{ httpApiStatus: 404 },
|
||||
) {}
|
||||
|
||||
export class ProjectNotFoundError extends Schema.TaggedError<ProjectNotFoundError>()(
|
||||
"ProjectNotFoundError",
|
||||
{
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Location } from "@opencode-ai/schema/location"
|
||||
import { Schema } from "effect"
|
||||
import { HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
|
||||
import { LocationDirectoryError } from "../errors.js"
|
||||
|
||||
export const LocationQuery = Schema.Struct({
|
||||
location: Schema.optional(
|
||||
@@ -31,6 +32,7 @@ export const LocationGroup = HttpApiGroup.make("server.location")
|
||||
HttpApiEndpoint.get("location.get", "/api/location", {
|
||||
query: LocationQuery,
|
||||
success: Location.Info,
|
||||
error: LocationDirectoryError,
|
||||
})
|
||||
.annotateMerge(locationQueryOpenApi)
|
||||
.annotateMerge(
|
||||
|
||||
@@ -1,18 +1,55 @@
|
||||
import { Location } from "@opencode-ai/core/location"
|
||||
import { LocationServiceMap } from "@opencode-ai/core/location-services"
|
||||
import { Environment } from "@opencode-ai/core/environment/index"
|
||||
import { Workspace } from "@opencode-ai/core/workspace"
|
||||
import { LocationDirectoryError } from "@opencode-ai/protocol/errors"
|
||||
import { Effect } from "effect"
|
||||
import { HttpServerRequest } from "effect/unstable/http"
|
||||
import { HttpApiBuilder } from "effect/unstable/httpapi"
|
||||
import { ChildProcessSpawner } from "effect/unstable/process/ChildProcessSpawner"
|
||||
import { Api } from "../api"
|
||||
import { requestRef } from "../location"
|
||||
|
||||
export const LocationHandler = HttpApiBuilder.group(Api, "server.location", (handlers) =>
|
||||
handlers.handle(
|
||||
"location.get",
|
||||
Effect.fn(function* () {
|
||||
const location = yield* Location.Service
|
||||
return new Location.Info({
|
||||
directory: location.directory,
|
||||
workspaceID: location.workspaceID,
|
||||
project: location.project,
|
||||
})
|
||||
}),
|
||||
),
|
||||
Effect.gen(function* () {
|
||||
const workspace = yield* Workspace.Service
|
||||
const spawner = yield* ChildProcessSpawner
|
||||
const locations = yield* LocationServiceMap.Service
|
||||
return handlers.handle(
|
||||
"location.get",
|
||||
Effect.fn(function* () {
|
||||
const request = yield* HttpServerRequest.HttpServerRequest
|
||||
const ref = requestRef(request)
|
||||
const driver = ref.workspaceID
|
||||
? yield* workspace.connect(ref.workspaceID).pipe(Effect.orDie)
|
||||
: Environment.makeLocalDriver(spawner)
|
||||
// Check the placement's filesystem before booting config, plugins, or MCP.
|
||||
// Those can fail independently and must never imply a missing directory.
|
||||
const kind = yield* Environment.typeFollowing(Environment.makeFiles(driver), ref.directory).pipe(
|
||||
Effect.catchTag(
|
||||
"Environment.NotFound",
|
||||
() =>
|
||||
new LocationDirectoryError({
|
||||
directory: ref.directory,
|
||||
reason: "not_found",
|
||||
message: `Directory not found: ${ref.directory}`,
|
||||
}),
|
||||
),
|
||||
Effect.catchTag("Environment.Failed", (error) => Effect.die(error)),
|
||||
)
|
||||
if (kind !== "directory")
|
||||
return yield* new LocationDirectoryError({
|
||||
directory: ref.directory,
|
||||
reason: "not_directory",
|
||||
message: `Not a directory: ${ref.directory}`,
|
||||
})
|
||||
const location = yield* Location.Service.pipe(Effect.provide(locations.get(ref)))
|
||||
return new Location.Info({
|
||||
directory: location.directory,
|
||||
workspaceID: location.workspaceID,
|
||||
project: location.project,
|
||||
})
|
||||
}),
|
||||
)
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -19,6 +19,7 @@ import { ShellSelect } from "@opencode-ai/core/shell/select"
|
||||
import { Job } from "@opencode-ai/core/job"
|
||||
import { MCP } from "@opencode-ai/core/mcp/index"
|
||||
import { Global } from "@opencode-ai/util/global"
|
||||
import { CrossSpawnSpawner } from "@opencode-ai/util/cross-spawn-spawner"
|
||||
import { InstructionDiscovery } from "@opencode-ai/core/instruction-discovery"
|
||||
import { LocationServiceMap } from "@opencode-ai/core/location-service-map"
|
||||
import { LocationActivity } from "@opencode-ai/core/location-activity"
|
||||
@@ -68,6 +69,7 @@ const applicationServiceNodes = [
|
||||
LocationActivity.node,
|
||||
SessionRestart.node,
|
||||
Workspace.node,
|
||||
CrossSpawnSpawner.node,
|
||||
] as const
|
||||
const applicationServices = LayerNode.group(applicationServiceNodes)
|
||||
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
import fs from "node:fs/promises"
|
||||
import path from "node:path"
|
||||
import { expect } from "bun:test"
|
||||
import { Effect, Layer } from "effect"
|
||||
import { Config } from "@opencode-ai/core/config"
|
||||
import { Environment } from "@opencode-ai/core/environment/index"
|
||||
import { Workspace } from "@opencode-ai/core/workspace"
|
||||
import { tmpdir } from "../../core/test/fixture/tmpdir"
|
||||
import { it } from "../../core/test/lib/effect"
|
||||
import { ServerFetch } from "../src/fetch"
|
||||
|
||||
const options = {
|
||||
app: { version: "test-version" },
|
||||
database: { path: ":memory:" },
|
||||
fs: { filewatcher: false },
|
||||
} as const
|
||||
|
||||
const request = (directory: string, workspace?: string) => {
|
||||
const url = new URL("http://opencode.local/api/location")
|
||||
url.searchParams.set("location[directory]", directory)
|
||||
if (workspace) url.searchParams.set("location[workspace]", workspace)
|
||||
return new Request(url)
|
||||
}
|
||||
|
||||
it.live("distinguishes absent directories, files, and location initialization failures", () =>
|
||||
Effect.gen(function* () {
|
||||
const tmp = yield* Effect.acquireRelease(
|
||||
Effect.promise(() => tmpdir("location-errors-")),
|
||||
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
|
||||
)
|
||||
const handler = yield* ServerFetch.make({ ...options, config: { directory: tmp.path } })
|
||||
const missing = path.join(tmp.path, "missing")
|
||||
const absent = yield* Effect.promise(() => handler(request(missing)))
|
||||
expect(absent.status).toBe(404)
|
||||
expect(yield* Effect.promise(() => absent.json())).toMatchObject({
|
||||
_tag: "LocationDirectoryError",
|
||||
directory: missing,
|
||||
reason: "not_found",
|
||||
})
|
||||
|
||||
yield* Effect.promise(() => fs.mkdir(missing))
|
||||
expect((yield* Effect.promise(() => handler(request(missing)))).status).toBe(200)
|
||||
const link = path.join(tmp.path, "link")
|
||||
yield* Effect.promise(() => fs.symlink(missing, link, "junction"))
|
||||
expect((yield* Effect.promise(() => handler(request(link)))).status).toBe(200)
|
||||
// Recheck the filesystem even when location services were previously cached.
|
||||
yield* Effect.promise(() => fs.rm(missing, { recursive: true }))
|
||||
expect((yield* Effect.promise(() => handler(request(missing)))).status).toBe(404)
|
||||
expect((yield* Effect.promise(() => handler(request(link)))).status).toBe(404)
|
||||
|
||||
const file = path.join(tmp.path, "file")
|
||||
yield* Effect.promise(() => fs.writeFile(file, "not a directory"))
|
||||
const wrongKind = yield* Effect.promise(() => handler(request(file)))
|
||||
expect(wrongKind.status).toBe(404)
|
||||
expect(yield* Effect.promise(() => wrongKind.json())).toMatchObject({
|
||||
_tag: "LocationDirectoryError",
|
||||
directory: file,
|
||||
reason: "not_directory",
|
||||
})
|
||||
|
||||
const broken = yield* ServerFetch.make(
|
||||
{ ...options, config: { directory: tmp.path } },
|
||||
{
|
||||
overrides: [
|
||||
[Config.node, Layer.effect(Config.Service, Effect.die(new Error("configuration initialization failed")))],
|
||||
],
|
||||
},
|
||||
)
|
||||
const failed = yield* Effect.promise(() => broken(request(tmp.path)))
|
||||
expect(failed.status).toBe(500)
|
||||
const protectedHandler = yield* ServerFetch.make({ ...options, password: "test-password" })
|
||||
expect((yield* Effect.promise(() => protectedHandler(request(missing)))).status).toBe(401)
|
||||
}).pipe(Effect.scoped),
|
||||
)
|
||||
|
||||
it.live("checks workspace directories in their own filesystem and does not misclassify access failures", () =>
|
||||
Effect.gen(function* () {
|
||||
const tmp = yield* Effect.acquireRelease(
|
||||
Effect.promise(() => tmpdir("location-workspace-")),
|
||||
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
|
||||
)
|
||||
const memory = Environment.makeMemoryDriver()
|
||||
const files = Environment.makeFiles(memory)
|
||||
const directory = path.join(tmp.path, "workspace-only")
|
||||
yield* files.mkdir(directory)
|
||||
let denied = false
|
||||
const workspace = Workspace.Service.of({
|
||||
create: () => Effect.die("unused"),
|
||||
provision: () => Effect.die("unused"),
|
||||
destroy: () => Effect.die("unused"),
|
||||
connect: () =>
|
||||
Effect.succeed({
|
||||
...memory,
|
||||
overrides: {
|
||||
...memory.overrides,
|
||||
stat: (value: string) =>
|
||||
denied
|
||||
? Effect.fail(new Environment.Failed({ path: value, cause: new Error("permission denied") }))
|
||||
: files.stat(value),
|
||||
},
|
||||
}),
|
||||
})
|
||||
const handler = yield* ServerFetch.make(options, {
|
||||
overrides: [[Workspace.node, Layer.succeed(Workspace.Service, workspace)]],
|
||||
})
|
||||
const id = Workspace.ID.create()
|
||||
const loaded = yield* Effect.promise(() => handler(request(directory, id)))
|
||||
expect(loaded.status).toBe(200)
|
||||
denied = true
|
||||
expect((yield* Effect.promise(() => handler(request(directory, id)))).status).toBe(500)
|
||||
denied = false
|
||||
yield* files.remove(directory)
|
||||
const missing = yield* Effect.promise(() => handler(request(directory, id)))
|
||||
expect(missing.status).toBe(404)
|
||||
expect(yield* Effect.promise(() => missing.json())).toMatchObject({
|
||||
_tag: "LocationDirectoryError",
|
||||
reason: "not_found",
|
||||
})
|
||||
}).pipe(Effect.scoped),
|
||||
)
|
||||
@@ -1,7 +1,10 @@
|
||||
import type { LocationGetOutput, LocationRef } from "@opencode-ai/client"
|
||||
import { LocationSyncError } from "@opencode-ai/client/solid"
|
||||
import { createContext, createMemo, createSignal, onCleanup, useContext, type ParentProps } from "solid-js"
|
||||
import { useClient } from "./client"
|
||||
import { useData } from "./data"
|
||||
import { useToast } from "../ui/toast"
|
||||
import { errorMessage } from "../util/error"
|
||||
|
||||
const context = createContext<{
|
||||
readonly current: LocationGetOutput | undefined
|
||||
@@ -14,6 +17,7 @@ const context = createContext<{
|
||||
export function LocationProvider(props: ParentProps) {
|
||||
const client = useClient()
|
||||
const data = useData()
|
||||
const toast = useToast()
|
||||
const [ref, setRef] = createSignal<LocationRef>()
|
||||
const [error, setError] = createSignal<{ readonly location: LocationRef; readonly cause: unknown }>()
|
||||
let generation = 0
|
||||
@@ -36,7 +40,16 @@ export function LocationProvider(props: ParentProps) {
|
||||
current.workspaceID !== location.workspaceID
|
||||
)
|
||||
return
|
||||
setError({ location, cause })
|
||||
if (client.connection.status() !== "connected") return
|
||||
if (cause instanceof LocationSyncError && cause.reason === "missing") {
|
||||
setError({ location, cause })
|
||||
return
|
||||
}
|
||||
toast.show({
|
||||
variant: "error",
|
||||
message: errorMessage(cause),
|
||||
action: { label: "Retry", run: () => sync(ref()) },
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ import { ClientProvider } from "../../../src/context/client"
|
||||
import { DataProvider, useData } from "../../../src/context/data"
|
||||
import { Keymap } from "../../../src/context/keymap"
|
||||
import { LocationProvider } from "../../../src/context/location"
|
||||
import { ToastProvider } from "../../../src/ui/toast"
|
||||
import { RouteProvider, useRoute } from "../../../src/context/route"
|
||||
import { ThemeProvider } from "../../../src/context/theme"
|
||||
import { Composer } from "../../../src/routes/session/composer"
|
||||
@@ -97,13 +98,15 @@ async function renderComposer(
|
||||
<Keymap.Provider>
|
||||
<ClientProvider api={createApi(calls.fetch)}>
|
||||
<DataProvider directory={process.cwd()}>
|
||||
<LocationProvider>
|
||||
<RouteProvider initialRoute={{ type: "session", sessionID: "parent" }}>
|
||||
<ThemeProvider mode="dark" source={{ discover: async () => ({}) }}>
|
||||
<Content />
|
||||
</ThemeProvider>
|
||||
</RouteProvider>
|
||||
</LocationProvider>
|
||||
<ToastProvider>
|
||||
<LocationProvider>
|
||||
<RouteProvider initialRoute={{ type: "session", sessionID: "parent" }}>
|
||||
<ThemeProvider mode="dark" source={{ discover: async () => ({}) }}>
|
||||
<Content />
|
||||
</ThemeProvider>
|
||||
</RouteProvider>
|
||||
</LocationProvider>
|
||||
</ToastProvider>
|
||||
</DataProvider>
|
||||
</ClientProvider>
|
||||
<AppExit />
|
||||
|
||||
@@ -20,6 +20,7 @@ import { createApi, createEventStream, createFetch, directory, json, worktree }
|
||||
import { emptyThemeSource } from "../../fixture/fixture"
|
||||
import { TestTuiContexts } from "../../fixture/tui-environment"
|
||||
import { createTuiResolvedConfig } from "../../fixture/tui-runtime"
|
||||
import { Toast, ToastProvider, useToast } from "../../../src/ui/toast"
|
||||
|
||||
const formFields = [{ key: "authorization", type: "external", url: "https://example.com" }] satisfies [
|
||||
{
|
||||
@@ -47,10 +48,12 @@ function DataProvider(props: ParentProps) {
|
||||
return (
|
||||
<ConfigProvider config={config}>
|
||||
<DataProviderBase directory={process.cwd()}>
|
||||
<LocationProvider>
|
||||
<SyncLocation />
|
||||
{props.children}
|
||||
</LocationProvider>
|
||||
<ToastProvider>
|
||||
<LocationProvider>
|
||||
<SyncLocation />
|
||||
{props.children}
|
||||
</LocationProvider>
|
||||
</ToastProvider>
|
||||
</DataProviderBase>
|
||||
</ConfigProvider>
|
||||
)
|
||||
@@ -77,6 +80,124 @@ function durable(sessionID: string, seq = 0, version = 1) {
|
||||
return { aggregateID: sessionID, seq, version }
|
||||
}
|
||||
|
||||
test.each(["resource", "server", "transport"])(
|
||||
"location %s failures offer retry instead of choosing a directory",
|
||||
async (kind) => {
|
||||
const events = createEventStream()
|
||||
let fail = false
|
||||
let requests = 0
|
||||
const endpoint = kind === "resource" ? "/api/model" : "/api/location"
|
||||
const calls = createFetch((url) => {
|
||||
if (url.pathname !== endpoint) return
|
||||
requests++
|
||||
if (!fail) return
|
||||
if (kind === "transport") throw new Error("connection refused")
|
||||
return json({ message: "server restarting" }, { status: 503 })
|
||||
}, events)
|
||||
let location!: ReturnType<typeof useLocation>
|
||||
let data!: ReturnType<typeof useData>
|
||||
let toast!: ReturnType<typeof useToast>
|
||||
let client!: ReturnType<typeof useClient>
|
||||
function Probe() {
|
||||
location = useLocation()
|
||||
data = useData()
|
||||
toast = useToast()
|
||||
client = useClient()
|
||||
return (
|
||||
<ThemeProvider mode="dark" source={emptyThemeSource}>
|
||||
<Toast />
|
||||
</ThemeProvider>
|
||||
)
|
||||
}
|
||||
const app = await testRender(() => (
|
||||
<TestTuiContexts>
|
||||
<ClientProvider api={createApi(calls.fetch)}>
|
||||
<DataProvider>
|
||||
<Probe />
|
||||
</DataProvider>
|
||||
</ClientProvider>
|
||||
</TestTuiContexts>
|
||||
))
|
||||
try {
|
||||
await wait(() => client.connection.status() === "connected")
|
||||
await data.location.sync()
|
||||
fail = true
|
||||
data.location.invalidate()
|
||||
location.set({ directory })
|
||||
await wait(() => requests >= 2)
|
||||
await wait(() => toast.currentToast !== null || location.error !== undefined)
|
||||
expect(location.error).toBeUndefined()
|
||||
expect(toast.currentToast?.message).toContain(kind === "resource" ? "model" : "info")
|
||||
expect(toast.currentToast?.action?.label).toBe("Retry")
|
||||
await app.renderOnce()
|
||||
const frame = app.captureCharFrame().split("\n")
|
||||
const row = frame.findIndex((line) => line.includes("Retry"))
|
||||
expect(row).toBeGreaterThanOrEqual(0)
|
||||
expect(frame.join("\n")).not.toContain("Session location unavailable")
|
||||
fail = false
|
||||
const before = requests
|
||||
await app.mockMouse.click(frame[row]!.indexOf("Retry"), row)
|
||||
await wait(() => requests > before)
|
||||
await wait(() => data.location.model.list() !== undefined)
|
||||
expect(location.error).toBeUndefined()
|
||||
expect(toast.currentToast).toBeNull()
|
||||
} finally {
|
||||
app.renderer.destroy()
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
test("confirmed missing locations still show directory recovery and clear on reconnect", async () => {
|
||||
const events = createEventStream()
|
||||
let missing = true
|
||||
const target = `${directory}/missing`
|
||||
const calls = createFetch((url) => {
|
||||
if (url.pathname !== "/api/location" || url.searchParams.get("location[directory]") !== target) return
|
||||
if (missing)
|
||||
return json(
|
||||
{
|
||||
_tag: "LocationDirectoryError",
|
||||
directory: target,
|
||||
reason: "not_found",
|
||||
message: "Directory not found",
|
||||
},
|
||||
{ status: 404 },
|
||||
)
|
||||
return json({ directory: target, project: { id: "proj_test", directory: target } })
|
||||
}, events)
|
||||
let location!: ReturnType<typeof useLocation>
|
||||
let client!: ReturnType<typeof useClient>
|
||||
function Probe() {
|
||||
location = useLocation()
|
||||
client = useClient()
|
||||
return <box />
|
||||
}
|
||||
const app = await testRender(() => (
|
||||
<TestTuiContexts>
|
||||
<ClientProvider api={createApi(calls.fetch)}>
|
||||
<DataProviderBase directory={directory}>
|
||||
<ToastProvider>
|
||||
<LocationProvider>
|
||||
<Probe />
|
||||
</LocationProvider>
|
||||
</ToastProvider>
|
||||
</DataProviderBase>
|
||||
</ClientProvider>
|
||||
</TestTuiContexts>
|
||||
))
|
||||
try {
|
||||
await wait(() => client.connection.status() === "connected")
|
||||
location.set({ directory: target })
|
||||
await wait(() => location.error !== undefined)
|
||||
expect(location.error?.location.directory).toBe(target)
|
||||
missing = false
|
||||
events.disconnect()
|
||||
await wait(() => location.current?.directory === target && location.error === undefined, 4000)
|
||||
} finally {
|
||||
app.renderer.destroy()
|
||||
}
|
||||
})
|
||||
|
||||
test("does not preload session summaries into the data context", async () => {
|
||||
const events = createEventStream()
|
||||
let location = false
|
||||
|
||||
@@ -13,6 +13,7 @@ import { SPINNER_FRAMES } from "../../src/component/spinner-frames"
|
||||
import { ClientProvider } from "../../src/context/client"
|
||||
import { DataProvider } from "../../src/context/data"
|
||||
import { LocationProvider } from "../../src/context/location"
|
||||
import { ToastProvider } from "../../src/ui/toast"
|
||||
import { RouteProvider } from "../../src/context/route"
|
||||
import { TuiAppProvider } from "../../src/context/runtime"
|
||||
import { SessionTabsProvider } from "../../src/context/session-tabs"
|
||||
@@ -74,14 +75,20 @@ for (const orientation of ["horizontal", "vertical"] as const) {
|
||||
<RouteProvider initialRoute={{ type: "home" }}>
|
||||
<ClientProvider api={createApi(createFetch(undefined, createEventStream()).fetch)}>
|
||||
<DataProvider directory={temporary.path}>
|
||||
<LocationProvider>
|
||||
<SessionTabsProvider>
|
||||
<ThemeProvider mode="dark" source={emptyThemeSource}>
|
||||
<Colors />
|
||||
<SessionTabs controller={controller} orientation={orientation} animations={animations()} />
|
||||
</ThemeProvider>
|
||||
</SessionTabsProvider>
|
||||
</LocationProvider>
|
||||
<ToastProvider>
|
||||
<LocationProvider>
|
||||
<SessionTabsProvider>
|
||||
<ThemeProvider mode="dark" source={emptyThemeSource}>
|
||||
<Colors />
|
||||
<SessionTabs
|
||||
controller={controller}
|
||||
orientation={orientation}
|
||||
animations={animations()}
|
||||
/>
|
||||
</ThemeProvider>
|
||||
</SessionTabsProvider>
|
||||
</LocationProvider>
|
||||
</ToastProvider>
|
||||
</DataProvider>
|
||||
</ClientProvider>
|
||||
</RouteProvider>
|
||||
|
||||
@@ -8,6 +8,7 @@ import { ConfigProvider, useConfig } from "../../src/config"
|
||||
import { ClientProvider, useClient } from "../../src/context/client"
|
||||
import { DataProvider, useData } from "../../src/context/data"
|
||||
import { LocationProvider } from "../../src/context/location"
|
||||
import { ToastProvider } from "../../src/ui/toast"
|
||||
import { RouteProvider, useRoute } from "../../src/context/route"
|
||||
import { TuiAppProvider } from "../../src/context/runtime"
|
||||
import { SessionTabsProvider, useSessionTabs } from "../../src/context/session-tabs"
|
||||
@@ -170,11 +171,13 @@ async function renderSessionTabs(
|
||||
>
|
||||
<ClientProvider api={createApi(calls.fetch)}>
|
||||
<DataProvider directory={options?.launchDirectory ?? directory}>
|
||||
<LocationProvider>
|
||||
<SessionTabsProvider>
|
||||
<Probe />
|
||||
</SessionTabsProvider>
|
||||
</LocationProvider>
|
||||
<ToastProvider>
|
||||
<LocationProvider>
|
||||
<SessionTabsProvider>
|
||||
<Probe />
|
||||
</SessionTabsProvider>
|
||||
</LocationProvider>
|
||||
</ToastProvider>
|
||||
</DataProvider>
|
||||
</ClientProvider>
|
||||
</RouteProvider>
|
||||
|
||||
Reference in New Issue
Block a user