mirror of
https://github.com/anomalyco/opencode.git
synced 2026-11-14 03:23:51 +00:00
fix(server): stop waiting for plugins in request handlers (#62796)
This commit is contained in:
parent
adb0053d09
commit
bcaf561d94
31 changed files with 224 additions and 186 deletions
|
|
@ -202,6 +202,13 @@ const table = sqliteTable("session", {
|
|||
- Local `typecheck` scripts use `bun check`. CI and package publishing run `typecheck:tsc`, which uses the official TypeScript compiler (`@typescript/native`). When the two disagree, `tsc` is the source of truth; reproduce with `bun run typecheck:tsc` from the package directory.
|
||||
- `bun check` aims to match TypeScript 7.1, not the `7.0.2` that `process.versions.typescript` reports (oven-sh/bun#46536). When `bun check` and `tsc` from the pinned `@typescript/native` report different errors, file a minimal, dependency-free repro at https://github.com/oven-sh/bun/issues, with the output of both. `bun check` writes no declaration files, so declaration-emit errors such as TS5088 or TS2883 are expected to appear only in `tsc`; do not report those.
|
||||
|
||||
## Plugin Loading
|
||||
|
||||
- Plugins load asynchronously. Server handlers never wait for them (`Plugin.awaitActivation`), never retry until something appears, and never turn slow plugins into a timeout or 503. This applies to reads and to actions such as `generate.*`, `rpc.call`, `vcs.init` and `websearch.query`.
|
||||
- The server answers with what is loaded now: lists may be empty or partial on a cold Location, and an action whose model, provider or RPC hasn't loaded yet fails with not-found. That is expected, not a bug to fix on the server.
|
||||
- Clients are responsible for making sure what they need is loaded before they call. Long-lived clients refresh on the matching `*.updated` event (`model.updated`, `agent.updated`, `skill.updated`, `integration.updated`, `plugin.updated`, ...). One-shot clients (CLI commands, ACP, SDK callers) wait on their side, for example by polling `plugin.list` or the entry they need. See `packages/cli/src/services/plugin-startup.ts`.
|
||||
- Running the hooks that are registered and waiting for their result is fine (for example `hooks.trigger("session", "http.request", ...)`). What is not allowed is waiting for plugins to load first: if the plugin that adds a hook hasn't loaded yet, the call runs without that hook.
|
||||
|
||||
## Session Core
|
||||
|
||||
- Keep durable events minimal: record irreducible new facts and do not repeat state derivable by folding the ordered aggregate history. Enrich projections and read models with previous or derived state when consumers need self-contained views.
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import { Effect } from "effect"
|
|||
import { OpenCode, type IntegrationInfo, type IntegrationMethod, type OpenCodeClient } from "@opencode/client"
|
||||
import { Service } from "@opencode/client/effect/service"
|
||||
import { ServerConnection } from "../../../services/server-connection"
|
||||
import { waitForPlugins } from "../../../services/plugin-startup"
|
||||
|
||||
export const location = { directory: process.cwd() }
|
||||
|
||||
|
|
@ -15,8 +16,7 @@ export function request<A>(run: (signal: AbortSignal) => Promise<A>) {
|
|||
}
|
||||
|
||||
export const loadIntegrations = Effect.fn("cli.auth.integrations")(function* (client: OpenCodeClient) {
|
||||
// The model endpoint is the existing public readiness boundary for the initial plugin generation.
|
||||
yield* request((signal) => client.model.default({ location }, { signal }))
|
||||
yield* waitForPlugins(client, location.directory)
|
||||
return yield* request((signal) => client.integration.list({ location }, { signal })).pipe(
|
||||
Effect.map((response) =>
|
||||
response.data.toSorted((a, b) => Number(b.id === "opencode-go") - Number(a.id === "opencode-go")),
|
||||
|
|
|
|||
|
|
@ -5,12 +5,14 @@ import { Commands } from "../../commands"
|
|||
import { Runtime } from "../../../framework/runtime"
|
||||
import { Service } from "@opencode/client/effect/service"
|
||||
import { ServerConnection } from "../../../services/server-connection"
|
||||
import { waitForPlugins } from "../../../services/plugin-startup"
|
||||
|
||||
export default Runtime.handler(
|
||||
Commands.commands.debug.commands.agents,
|
||||
Effect.fn("cli.debug.agents")(function* () {
|
||||
const { endpoint } = yield* ServerConnection.resolve()
|
||||
const client = OpenCode.make({ baseUrl: endpoint.url, headers: Service.headers(endpoint) })
|
||||
yield* waitForPlugins(client, process.cwd())
|
||||
const response = yield* Effect.promise(() => client.agent.list({ location: { directory: process.cwd() } }))
|
||||
process.stdout.write(
|
||||
JSON.stringify(
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import { EOL } from "node:os"
|
|||
import { Commands } from "../commands"
|
||||
import { Runtime } from "../../framework/runtime"
|
||||
import { ServerConnection } from "../../services/server-connection"
|
||||
import { waitForPlugins } from "../../services/plugin-startup"
|
||||
|
||||
export default Runtime.handler(
|
||||
Commands.commands.models,
|
||||
|
|
@ -14,6 +15,7 @@ export default Runtime.handler(
|
|||
baseUrl: server.endpoint.url,
|
||||
headers: Service.headers(server.endpoint),
|
||||
})
|
||||
yield* waitForPlugins(client, process.cwd())
|
||||
const response = yield* Effect.promise(() => client.model.list({ location: { directory: process.cwd() } }))
|
||||
const models = response.data
|
||||
.map((model) => `${model.providerID}/${model.id}`)
|
||||
|
|
|
|||
13
packages/cli/src/services/plugin-startup.ts
Normal file
13
packages/cli/src/services/plugin-startup.ts
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
import type { OpenCodeClient } from "@opencode/client"
|
||||
import { Effect, Schedule } from "effect"
|
||||
|
||||
// Catalog reads return whatever is loaded, and a cold Location lists no plugins until its first activation.
|
||||
// One-shot commands read once and exit, so they give that activation a moment before reading.
|
||||
export const waitForPlugins = (client: OpenCodeClient, directory: string) =>
|
||||
Effect.tryPromise((signal) => client.plugin.list({ location: { directory } }, { signal })).pipe(
|
||||
Effect.repeat({
|
||||
until: (plugins) => plugins.data.length > 0,
|
||||
schedule: Schedule.spaced("25 millis").pipe(Schedule.upTo({ duration: "5 seconds" })),
|
||||
}),
|
||||
Effect.ignore,
|
||||
)
|
||||
4
packages/client/src/promise/generated/client.ts
generated
4
packages/client/src/promise/generated/client.ts
generated
|
|
@ -529,7 +529,7 @@ export function make(options: ClientOptions) {
|
|||
path: `/api/agent`,
|
||||
query: { location: input?.["location"] },
|
||||
successStatus: 200,
|
||||
declaredStatuses: [400, 401, 404, 503],
|
||||
declaredStatuses: [400, 401, 404],
|
||||
empty: false,
|
||||
},
|
||||
requestOptions,
|
||||
|
|
@ -2257,7 +2257,7 @@ export function make(options: ClientOptions) {
|
|||
path: `/api/skill`,
|
||||
query: { location: input?.["location"] },
|
||||
successStatus: 200,
|
||||
declaredStatuses: [400, 401, 404, 503],
|
||||
declaredStatuses: [400, 401, 404],
|
||||
empty: false,
|
||||
},
|
||||
requestOptions,
|
||||
|
|
|
|||
20
packages/protocol/openapi.json
generated
20
packages/protocol/openapi.json
generated
|
|
@ -345,16 +345,6 @@
|
|||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"503": {
|
||||
"description": "ServiceUnavailableError",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ServiceUnavailableErrorEncoded"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Retrieve currently registered agents.",
|
||||
|
|
@ -9955,16 +9945,6 @@
|
|||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"503": {
|
||||
"description": "ServiceUnavailableError",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ServiceUnavailableErrorEncoded"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Retrieve currently registered skills.",
|
||||
|
|
|
|||
|
|
@ -3,14 +3,13 @@ import { Location } from "@opencode/schema/location"
|
|||
import { Schema } from "effect"
|
||||
import { HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/http-api"
|
||||
import { LocationQuery, locationQueryOpenApi } from "./location.js"
|
||||
import { AgentNotFoundError, ServiceUnavailableError } from "../errors.js"
|
||||
import { AgentNotFoundError } from "../errors.js"
|
||||
|
||||
export const AgentGroup = HttpApiGroup.make("server.agent")
|
||||
.add(
|
||||
HttpApiEndpoint.get("agent.list", "/api/agent", {
|
||||
query: LocationQuery,
|
||||
success: Location.response(Schema.Array(Agent.Info)),
|
||||
error: ServiceUnavailableError,
|
||||
})
|
||||
.annotateMerge(locationQueryOpenApi)
|
||||
.annotateMerge(
|
||||
|
|
|
|||
|
|
@ -2,7 +2,6 @@ import { Skill } from "@opencode/schema/skill"
|
|||
import { Location } from "@opencode/schema/location"
|
||||
import { Schema } from "effect"
|
||||
import { HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/http-api"
|
||||
import { ServiceUnavailableError } from "../errors.js"
|
||||
import { LocationQuery, locationQueryOpenApi } from "./location.js"
|
||||
|
||||
export const SkillGroup = HttpApiGroup.make("server.skill")
|
||||
|
|
@ -10,7 +9,6 @@ export const SkillGroup = HttpApiGroup.make("server.skill")
|
|||
HttpApiEndpoint.get("skill.list", "/api/skill", {
|
||||
query: LocationQuery,
|
||||
success: Location.response(Schema.Array(Skill.Info)),
|
||||
error: ServiceUnavailableError,
|
||||
})
|
||||
.annotateMerge(locationQueryOpenApi)
|
||||
.annotateMerge(
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@ import { WorkspaceDriver } from "@opencode/core/workspace/driver"
|
|||
import { Plugin } from "@opencode/plugin/effect"
|
||||
import { makeDurableObjectStorage } from "../../core/test/fixture/durable-object-storage"
|
||||
import { OpenCodeWorkerd } from "../src/effect/workerd"
|
||||
import { Deferred, Effect, Fiber, Latch, Layer, Option, Stream } from "effect"
|
||||
import { Deferred, Effect, Fiber, Latch, Layer, Option, Schedule, Stream } from "effect"
|
||||
import { testEffect } from "../../core/test/lib/effect"
|
||||
import { tmpdir } from "../../core/test/fixture/tmpdir"
|
||||
import type { OpenCodeEvent } from "../src/effect"
|
||||
|
|
@ -37,6 +37,15 @@ const sessionID = (fixture: Fixture) => fixture.sdk.Session.ID.create()
|
|||
const location = (fixture: Fixture) =>
|
||||
fixture.sdk.Location.Ref.make({ directory: fixture.sdk.AbsolutePath.make(fixture.directory) })
|
||||
|
||||
// The server doesn't wait for plugins, so an SDK caller waits until the plugin-provided entry it needs is loaded.
|
||||
// Activation takes milliseconds here; the cap stays well inside the 5s test timeout so a miss fails with its name.
|
||||
const loaded = <A, E, R>(name: string, read: Effect.Effect<A, E, R>, ready: (value: A) => boolean) =>
|
||||
read.pipe(
|
||||
Effect.repeat({ until: ready, schedule: Schedule.spaced("10 millis") }),
|
||||
Effect.timeoutOrElse({ duration: "3 seconds", orElse: () => Effect.die(new Error(`${name} never loaded`)) }),
|
||||
Effect.andThen(read),
|
||||
)
|
||||
|
||||
for (const selection of ["explicit", "default"] as const) {
|
||||
it.live(`generate.text uses configured providers with ${selection} model selection`, () =>
|
||||
withEmbedded("opencode-embedded-generate-", (fixture) =>
|
||||
|
|
@ -67,6 +76,9 @@ for (const selection of ["explicit", "default"] as const) {
|
|||
overrides: [llmClient.replace(Layer.succeed(LLMClient.Service, llm))],
|
||||
},
|
||||
)
|
||||
yield* loaded("custom/fictional-chat", opencode.model.list({ location: location(fixture) }), (models) =>
|
||||
models.data.some((model) => model.providerID === "custom" && model.id === "fictional-chat"),
|
||||
)
|
||||
|
||||
const result = yield* opencode.experimental.generate.text({
|
||||
prompt: "Say ready",
|
||||
|
|
@ -89,7 +101,7 @@ for (const selection of ["explicit", "default"] as const) {
|
|||
)
|
||||
}
|
||||
|
||||
it.live("seeds host plugins from CreateOptions and waits for delayed model plugins without self-deadlock", () =>
|
||||
it.live("seeds host plugins from CreateOptions and loads delayed model plugins without self-deadlock", () =>
|
||||
withEmbedded("opencode-embedded-seeded-plugin-", (fixture) =>
|
||||
Effect.gen(function* () {
|
||||
const llm = yield* TestLLM.Test.pipe(
|
||||
|
|
@ -136,10 +148,12 @@ it.live("seeds host plugins from CreateOptions and waits for delayed model plugi
|
|||
},
|
||||
)
|
||||
|
||||
const [defaultModel, generated] = yield* Effect.all(
|
||||
[opencode.model.default({ location: location(fixture) }), opencode.experimental.generate.text({ prompt: "Say ready" })],
|
||||
{ concurrency: "unbounded" },
|
||||
const defaultModel = yield* loaded(
|
||||
"seeded model",
|
||||
opencode.model.default({ location: location(fixture) }),
|
||||
(model) => model.data?.providerID === "seeded",
|
||||
)
|
||||
const generated = yield* opencode.experimental.generate.text({ prompt: "Say ready" })
|
||||
|
||||
expect(listedDuringSetup.length).toBeGreaterThan(0)
|
||||
expect(defaultModel.data).toMatchObject({
|
||||
|
|
@ -184,9 +198,13 @@ it.live("OpenCodeWorkerd seeds plugins before cold model resolution", () =>
|
|||
],
|
||||
})
|
||||
|
||||
const selected = yield* opencode.model.default({
|
||||
location: fixture.sdk.Location.Ref.make({ directory: fixture.sdk.AbsolutePath.make("/workspace") }),
|
||||
})
|
||||
const selected = yield* loaded(
|
||||
"workerd-seeded model",
|
||||
opencode.model.default({
|
||||
location: fixture.sdk.Location.Ref.make({ directory: fixture.sdk.AbsolutePath.make("/workspace") }),
|
||||
}),
|
||||
(model) => model.data?.providerID === "workerd-seeded",
|
||||
)
|
||||
expect(selected.data).toMatchObject({
|
||||
providerID: "workerd-seeded",
|
||||
id: "fictional-chat",
|
||||
|
|
@ -320,6 +338,11 @@ it.live("embedded client exposes plugin-backed web search", () =>
|
|||
})
|
||||
}),
|
||||
})
|
||||
yield* loaded(
|
||||
"embedded-websearch provider",
|
||||
opencode.websearch.providers({ location: location(fixture) }),
|
||||
(providers) => providers.data.some((provider) => provider.id === providerID),
|
||||
)
|
||||
|
||||
const result = yield* opencode.websearch.query({
|
||||
query: "opencode",
|
||||
|
|
|
|||
|
|
@ -2,17 +2,12 @@ import { Agent } from "@opencode/core/agent"
|
|||
import { Effect } from "effect"
|
||||
import { HttpApiBuilder } from "effect/http-api"
|
||||
import { Api } from "../api"
|
||||
import { awaitPlugins, response } from "../location"
|
||||
import { response } from "../location"
|
||||
import { AgentNotFoundError } from "@opencode/protocol/errors"
|
||||
|
||||
export const AgentHandler = HttpApiBuilder.group(Api, "server.agent", (handlers) =>
|
||||
handlers
|
||||
.handle("agent.list", () =>
|
||||
Effect.gen(function* () {
|
||||
yield* awaitPlugins
|
||||
return yield* response(Agent.Service.use((agent) => agent.list()))
|
||||
}),
|
||||
)
|
||||
.handle("agent.list", () => response(Agent.Service.use((agent) => agent.list())))
|
||||
.handle(
|
||||
"agent.get",
|
||||
Effect.fn(function* (ctx) {
|
||||
|
|
|
|||
|
|
@ -15,7 +15,7 @@ import {
|
|||
import { Effect } from "effect"
|
||||
import { type HttpServerRequest, HttpServerResponse } from "effect/http"
|
||||
import { HttpApiSchema } from "effect/http-api"
|
||||
import { awaitPlugins, locationErrors, requestRef, response } from "../location"
|
||||
import { locationErrors, requestRef, response } from "../location"
|
||||
import { missingSession } from "./session-error"
|
||||
|
||||
/** `artifacts.*` in the `server.experimental` group. Every route needs experimental.artifacts in the resolved Location. */
|
||||
|
|
@ -77,7 +77,7 @@ export const makeArtifact = Effect.gen(function* () {
|
|||
) =>
|
||||
Effect.gen(function* () {
|
||||
const ref = yield* resolveLocation(request, options)
|
||||
return yield* awaitPlugins.pipe(Effect.andThen(effect), Effect.provide(locations.get(ref)), locationErrors)
|
||||
return yield* effect.pipe(Effect.provide(locations.get(ref)), locationErrors)
|
||||
})
|
||||
|
||||
const mapCommon = <A, E>(effect: Effect.Effect<A, E>) =>
|
||||
|
|
|
|||
|
|
@ -15,7 +15,7 @@ import { Model } from "@opencode/schema/model"
|
|||
import { Effect } from "effect"
|
||||
import { HttpApiBuilder } from "effect/http-api"
|
||||
import { Api } from "../api"
|
||||
import { awaitPlugins, locationErrors } from "../location"
|
||||
import { locationErrors } from "../location"
|
||||
|
||||
export const GenerateHandler = HttpApiBuilder.group(Api, "server.generate", (handlers) =>
|
||||
Effect.gen(function* () {
|
||||
|
|
@ -24,7 +24,6 @@ export const GenerateHandler = HttpApiBuilder.group(Api, "server.generate", (han
|
|||
const services = locations.get(Location.Ref.make({ directory: AbsolutePath.make(global.config) }))
|
||||
const text = Effect.fn("server.generate.text")(
|
||||
function* (request: { readonly payload: { readonly prompt: string; readonly model?: Model.Ref } }) {
|
||||
yield* awaitPlugins
|
||||
const generate = yield* Generate.Service
|
||||
return { data: { text: yield* generate.text(request.payload).pipe(Effect.mapError(generateError)) } }
|
||||
},
|
||||
|
|
@ -33,7 +32,6 @@ export const GenerateHandler = HttpApiBuilder.group(Api, "server.generate", (han
|
|||
)
|
||||
const image = Effect.fn("server.generate.image")(
|
||||
function* (request: { readonly payload: ImageRequest }) {
|
||||
yield* awaitPlugins
|
||||
const generate = yield* Generate.Service
|
||||
return { data: yield* generate.image(request.payload).pipe(Effect.mapError(generateError)) }
|
||||
},
|
||||
|
|
@ -48,7 +46,6 @@ export const GenerateHandler = HttpApiBuilder.group(Api, "server.generate", (han
|
|||
message: "Decision question names must be unique",
|
||||
field: "questions",
|
||||
})
|
||||
yield* awaitPlugins
|
||||
const generate = yield* Generate.Service
|
||||
return { data: yield* generate.decisions(request.payload).pipe(Effect.mapError(generateError)) }
|
||||
},
|
||||
|
|
|
|||
|
|
@ -1,5 +1,4 @@
|
|||
import { Integration } from "@opencode/core/integration"
|
||||
import { Plugin } from "@opencode/core/plugin"
|
||||
import { Effect } from "effect"
|
||||
import { HttpApiBuilder, HttpApiSchema } from "effect/http-api"
|
||||
import { Api } from "../api"
|
||||
|
|
@ -54,7 +53,6 @@ export const IntegrationHandler = HttpApiBuilder.group(Api, "server.integration"
|
|||
.handle(
|
||||
"integration.list",
|
||||
Effect.fn(function* () {
|
||||
yield* Plugin.awaitActivation
|
||||
const service = yield* Integration.Service
|
||||
const current = yield* statements()
|
||||
return yield* response(
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ import { Model } from "@opencode/core/model"
|
|||
import { Effect } from "effect"
|
||||
import { HttpApiBuilder } from "effect/http-api"
|
||||
import { Api } from "../api"
|
||||
import { awaitPlugins, response } from "../location"
|
||||
import { response } from "../location"
|
||||
|
||||
export const ModelHandler = HttpApiBuilder.group(Api, "server.model", (handlers) =>
|
||||
Effect.gen(function* () {
|
||||
|
|
@ -10,7 +10,6 @@ export const ModelHandler = HttpApiBuilder.group(Api, "server.model", (handlers)
|
|||
.handle(
|
||||
"model.list",
|
||||
Effect.fn(function* (ctx) {
|
||||
yield* awaitPlugins
|
||||
const models = yield* Model.Service
|
||||
return yield* response(models.available(ctx.query.types))
|
||||
}),
|
||||
|
|
@ -18,7 +17,6 @@ export const ModelHandler = HttpApiBuilder.group(Api, "server.model", (handlers)
|
|||
.handle(
|
||||
"model.default",
|
||||
Effect.fn(function* () {
|
||||
yield* awaitPlugins
|
||||
const models = yield* Model.Service
|
||||
return yield* response(models.default())
|
||||
}),
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ import { ProviderNotFoundError } from "@opencode/protocol/errors"
|
|||
import { Effect } from "effect"
|
||||
import { HttpApiBuilder } from "effect/http-api"
|
||||
import { Api } from "../api"
|
||||
import { awaitPlugins, response } from "../location"
|
||||
import { response } from "../location"
|
||||
|
||||
export const ProviderHandler = HttpApiBuilder.group(Api, "server.provider", (handlers) =>
|
||||
Effect.gen(function* () {
|
||||
|
|
@ -11,7 +11,6 @@ export const ProviderHandler = HttpApiBuilder.group(Api, "server.provider", (han
|
|||
.handle(
|
||||
"provider.list",
|
||||
Effect.fn(function* () {
|
||||
yield* awaitPlugins
|
||||
const providers = yield* Provider.Service
|
||||
return yield* response(providers.available())
|
||||
}),
|
||||
|
|
|
|||
|
|
@ -1,5 +1,4 @@
|
|||
import { Rpc } from "@opencode/core/rpc"
|
||||
import { Plugin } from "@opencode/core/plugin"
|
||||
import { RpcError, RpcInternalError } from "@opencode/protocol/errors"
|
||||
import { Effect } from "effect"
|
||||
import { HttpApiBuilder } from "effect/http-api"
|
||||
|
|
@ -8,7 +7,6 @@ import { Api } from "../api"
|
|||
export const RpcHandler = HttpApiBuilder.group(Api, "server.rpc", (handlers) =>
|
||||
handlers.handle("rpc.call", ({ params, payload }) =>
|
||||
Effect.gen(function* () {
|
||||
yield* Plugin.awaitActivation
|
||||
const rpc = yield* Rpc.Service
|
||||
const output = yield* rpc.call(params.rpcID, params.method, payload.input)
|
||||
return output === undefined ? {} : { output }
|
||||
|
|
|
|||
|
|
@ -1,14 +1,8 @@
|
|||
import { Skill } from "@opencode/core/skill"
|
||||
import { Effect } from "effect"
|
||||
import { HttpApiBuilder } from "effect/http-api"
|
||||
import { Api } from "../api"
|
||||
import { awaitPlugins, response } from "../location"
|
||||
import { response } from "../location"
|
||||
|
||||
export const SkillHandler = HttpApiBuilder.group(Api, "server.skill", (handlers) =>
|
||||
handlers.handle("skill.list", () =>
|
||||
Effect.gen(function* () {
|
||||
yield* awaitPlugins
|
||||
return yield* response(Skill.Service.use((skill) => skill.list()))
|
||||
}),
|
||||
),
|
||||
handlers.handle("skill.list", () => response(Skill.Service.use((skill) => skill.list()))),
|
||||
)
|
||||
|
|
|
|||
|
|
@ -3,15 +3,13 @@ import { ServiceUnavailableError } from "@opencode/protocol/errors"
|
|||
import { Effect } from "effect"
|
||||
import { HttpApiBuilder } from "effect/http-api"
|
||||
import { Api } from "../api"
|
||||
import { awaitPlugins, response } from "../location"
|
||||
import { response } from "../location"
|
||||
|
||||
export const UsageHandler = HttpApiBuilder.group(Api, "server.usage", (handlers) =>
|
||||
Effect.gen(function* () {
|
||||
return handlers.handle(
|
||||
"usage.limits",
|
||||
Effect.fn("server.usage.limits")(function* () {
|
||||
// The OpenCode Console plugin registers the sign-in method that refreshes an expired credential.
|
||||
yield* awaitPlugins
|
||||
const limits = yield* UsageLimits.Service
|
||||
return yield* response(
|
||||
limits.get().pipe(
|
||||
|
|
|
|||
|
|
@ -2,7 +2,6 @@ import { Vcs } from "@opencode/core/vcs"
|
|||
import { Project } from "@opencode/core/project"
|
||||
import { Worktree } from "@opencode/core/worktree"
|
||||
import { Bus } from "@opencode/core/bus"
|
||||
import { Plugin } from "@opencode/core/plugin"
|
||||
import { Location } from "@opencode/core/location"
|
||||
import { LocationServiceMap } from "@opencode/core/location-services"
|
||||
import { ConflictError, InvalidRequestError, ServiceUnavailableError } from "@opencode/protocol/errors"
|
||||
|
|
@ -23,7 +22,6 @@ export const VcsHandler = HttpApiBuilder.group(Api, "server.vcs", (handlers) =>
|
|||
const location = yield* Location.Service
|
||||
const directory = location.project.directory
|
||||
const providerID = ctx.query.provider ?? "git"
|
||||
yield* Plugin.awaitActivation
|
||||
const vcs = yield* Vcs.Service
|
||||
yield* vcs.initialize(providerID).pipe(
|
||||
Effect.mapError((error) => {
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@ import { Global } from "@opencode/util/global"
|
|||
import { Effect } from "effect"
|
||||
import { HttpApiBuilder } from "effect/http-api"
|
||||
import { Api } from "../api"
|
||||
import { awaitPlugins, locationErrors } from "../location"
|
||||
import { locationErrors } from "../location"
|
||||
|
||||
export const VoiceHandler = HttpApiBuilder.group(Api, "server.voice", (handlers) =>
|
||||
Effect.gen(function* () {
|
||||
|
|
@ -19,7 +19,6 @@ export const VoiceHandler = HttpApiBuilder.group(Api, "server.voice", (handlers)
|
|||
"voice.models",
|
||||
Effect.fn("server.voice.models")(
|
||||
function* () {
|
||||
yield* awaitPlugins
|
||||
const voice = yield* Voice.Service
|
||||
return { data: yield* voice.models() }
|
||||
},
|
||||
|
|
@ -31,7 +30,6 @@ export const VoiceHandler = HttpApiBuilder.group(Api, "server.voice", (handlers)
|
|||
"voice.session",
|
||||
Effect.fn("server.voice.session")(
|
||||
function* (request) {
|
||||
yield* awaitPlugins
|
||||
const voice = yield* Voice.Service
|
||||
const transport = yield* voice
|
||||
.session(request.payload)
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ import { InvalidRequestError, ServiceUnavailableError } from "@opencode/protocol
|
|||
import { Effect } from "effect"
|
||||
import { HttpApiBuilder } from "effect/http-api"
|
||||
import { Api } from "../api"
|
||||
import { awaitPlugins, response } from "../location"
|
||||
import { response } from "../location"
|
||||
|
||||
export const WebSearchHandler = HttpApiBuilder.group(Api, "server.websearch", (handlers) =>
|
||||
Effect.gen(function* () {
|
||||
|
|
@ -18,8 +18,6 @@ export const WebSearchHandler = HttpApiBuilder.group(Api, "server.websearch", (h
|
|||
.handle(
|
||||
"websearch.query",
|
||||
Effect.fn("server.websearch.query")(function* (request) {
|
||||
// A cold Location must finish activating plugins before their providers can be resolved.
|
||||
yield* awaitPlugins
|
||||
const websearch = yield* WebSearch.Service
|
||||
return yield* response(
|
||||
websearch.query(request.payload).pipe(
|
||||
|
|
|
|||
|
|
@ -1,10 +1,9 @@
|
|||
import { FileSystem } from "@opencode/core/filesystem"
|
||||
import { Location } from "@opencode/core/location"
|
||||
import { Plugin } from "@opencode/core/plugin"
|
||||
import { LocationServiceMap } from "@opencode/core/location-services"
|
||||
import { AbsolutePath } from "@opencode/core/schema"
|
||||
import { Session } from "@opencode/core/session"
|
||||
import { LocationNotFoundError, InvalidRequestError, ServiceUnavailableError } from "@opencode/protocol/errors"
|
||||
import { LocationNotFoundError, InvalidRequestError } from "@opencode/protocol/errors"
|
||||
import { Effect, Layer, Schema } from "effect"
|
||||
import { HttpServerRequest } from "effect/http"
|
||||
import { HttpApiMiddleware } from "effect/http-api"
|
||||
|
|
@ -33,19 +32,6 @@ export function locationErrors<A, E, R>(effect: Effect.Effect<A, E, R>) {
|
|||
)
|
||||
}
|
||||
|
||||
export const awaitPlugins = Plugin.awaitActivation.pipe(
|
||||
Effect.timeoutOrElse({
|
||||
duration: "5 seconds",
|
||||
orElse: () =>
|
||||
Effect.fail(
|
||||
new ServiceUnavailableError({
|
||||
message: "Location plugins did not finish activating",
|
||||
service: "plugin",
|
||||
}),
|
||||
),
|
||||
}),
|
||||
)
|
||||
|
||||
export function response<A, E, R>(data: Effect.Effect<A, E, R>) {
|
||||
return Effect.gen(function* () {
|
||||
const location = yield* Location.Service
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ import { OpenCode } from "@opencode/client"
|
|||
import { Effect } from "effect"
|
||||
import { tmpdirScoped } from "../../core/test/fixture/tmpdir"
|
||||
import { it } from "../../core/test/lib/effect"
|
||||
import { ServerFetch } from "../src/fetch"
|
||||
import { serveRoutes } from "./fixture/routes"
|
||||
|
||||
const client = (handler: (request: Request) => Promise<Response>) =>
|
||||
OpenCode.make({
|
||||
|
|
@ -81,14 +81,16 @@ export default {
|
|||
)
|
||||
})
|
||||
|
||||
const handler = yield* ServerFetch.make({
|
||||
const server = yield* serveRoutes({
|
||||
app: { version: "test" },
|
||||
database: { path: path.join(dir, "opencode.db") },
|
||||
config: { directory: global, project: true, claude: false },
|
||||
models: { fetch: false },
|
||||
fs: { filewatcher: false },
|
||||
})
|
||||
const handler = server.handler
|
||||
const api = client(handler)
|
||||
yield* server.pluginsLoaded(enabled)
|
||||
|
||||
yield* Effect.promise(async () => {
|
||||
// Disabled location returns ForbiddenError
|
||||
|
|
|
|||
37
packages/server/test/fixture/routes.ts
Normal file
37
packages/server/test/fixture/routes.ts
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
import { LocationServiceMap } from "@opencode/core/location-service-map"
|
||||
import { Plugin } from "@opencode/core/plugin"
|
||||
import { Location } from "@opencode/schema/location"
|
||||
import { AbsolutePath } from "@opencode/schema/schema"
|
||||
import type { LayerNode } from "@opencode/util/effect/layer-node"
|
||||
import { Context, Effect, Layer } from "effect"
|
||||
import { HttpEffect, HttpRouter, HttpServer } from "effect/http"
|
||||
import type { ServerOptions } from "../../src/options"
|
||||
import { createRoutes } from "../../src/routes"
|
||||
|
||||
// The server never waits for a Location's plugins to load. A test that sets up a Location does, so it can
|
||||
// exercise plugin-provided entries without polling.
|
||||
export const pluginsLoaded = (context: Context.Context<LocationServiceMap.Service>, directory: string) =>
|
||||
Plugin.awaitActivation.pipe(
|
||||
Effect.provide(
|
||||
Context.get(context, LocationServiceMap.Service).get(
|
||||
Location.Ref.make({ directory: AbsolutePath.make(directory) }),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
// Serves the routes in this process so the test can reach the Location's plugins.
|
||||
export const serveRoutes = Effect.fnUntraced(function* (
|
||||
options: ServerOptions,
|
||||
overrides: LayerNode.Replacements = [],
|
||||
) {
|
||||
const context = yield* Layer.build(
|
||||
createRoutes(options, () => [], overrides).pipe(Layer.provide(HttpServer.layerServices)),
|
||||
)
|
||||
const handler = Context.get(context, HttpRouter.HttpRouter).asHttpEffect().pipe(HttpEffect.toWebHandlerWith(context))
|
||||
return {
|
||||
context,
|
||||
handler,
|
||||
fetch: (input: string | URL, init?: RequestInit) => handler(new Request(input, init)),
|
||||
pluginsLoaded: (directory: string) => pluginsLoaded(context, directory),
|
||||
}
|
||||
})
|
||||
|
|
@ -4,6 +4,7 @@ import { HttpEffect, HttpRouter, HttpServer } from "effect/http"
|
|||
import { tmpdirScoped } from "../../core/test/fixture/tmpdir"
|
||||
import { it } from "../../core/test/lib/effect"
|
||||
import { createRoutes } from "../src/routes"
|
||||
import { pluginsLoaded } from "./fixture/routes"
|
||||
|
||||
it.live("integration policy filters /connect and blocks denied integrations and providers", () =>
|
||||
Effect.gen(function* () {
|
||||
|
|
@ -58,6 +59,7 @@ it.live("integration policy filters /connect and blocks denied integrations and
|
|||
return { status: response.status, body: response.status === 204 ? undefined : await response.json() }
|
||||
})
|
||||
|
||||
yield* pluginsLoaded(context, tmp.path)
|
||||
const integrations = yield* request("GET", "/api/integration")
|
||||
expect(integrations.status).toBe(200)
|
||||
// MCP sign-ins are outside integration policy; `mcp.use` governs MCP servers.
|
||||
|
|
|
|||
|
|
@ -4,9 +4,9 @@ import { expect } from "bun:test"
|
|||
import { Effect } from "effect"
|
||||
import { tmpdir } from "../../core/test/fixture/tmpdir"
|
||||
import { it } from "../../core/test/lib/effect"
|
||||
import { startServer } from "./fixture/server"
|
||||
import { serveRoutes } from "./fixture/routes"
|
||||
|
||||
it.live("waits for plugin initialization before listing models", () =>
|
||||
it.live("lists configured models filtered by type", () =>
|
||||
Effect.gen(function* () {
|
||||
const tmp = yield* Effect.acquireDisposable(Effect.promise(() => tmpdir("opencode-model-endpoint-")))
|
||||
yield* Effect.promise(() =>
|
||||
|
|
@ -23,10 +23,16 @@ it.live("waits for plugin initialization before listing models", () =>
|
|||
}),
|
||||
),
|
||||
)
|
||||
const server = yield* startServer(tmp.path)
|
||||
const url = new URL("/api/model", server.base)
|
||||
const server = yield* serveRoutes({
|
||||
database: { path: ":memory:" },
|
||||
config: { directory: tmp.path },
|
||||
fs: { filewatcher: false },
|
||||
models: { fetch: false },
|
||||
})
|
||||
yield* server.pluginsLoaded(tmp.path)
|
||||
const url = new URL("/api/model", "http://opencode.local")
|
||||
url.searchParams.set("location[directory]", tmp.path)
|
||||
const response = yield* Effect.promise(() => fetch(url, { headers: server.headers }))
|
||||
const response = yield* Effect.promise(() => server.fetch(url))
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
const body: unknown = yield* Effect.promise(() => response.json())
|
||||
|
|
@ -36,14 +42,14 @@ it.live("waits for plugin initialization before listing models", () =>
|
|||
).toBeTrue()
|
||||
|
||||
url.searchParams.set("types", "image")
|
||||
const images = yield* Effect.promise(() => fetch(url, { headers: server.headers }))
|
||||
const images = yield* Effect.promise(() => server.fetch(url))
|
||||
expect(images.status).toBe(200)
|
||||
const imageBody: unknown = yield* Effect.promise(() => images.json())
|
||||
if (!isRecord(imageBody) || !Array.isArray(imageBody["data"])) throw new Error("Expected a model list response")
|
||||
expect(imageBody["data"].some((model) => isRecord(model) && model["providerID"] === "custom")).toBeFalse()
|
||||
|
||||
url.searchParams.append("types", "chat")
|
||||
const both = yield* Effect.promise(() => fetch(url, { headers: server.headers }))
|
||||
const both = yield* Effect.promise(() => server.fetch(url))
|
||||
expect(both.status).toBe(200)
|
||||
const bothBody: unknown = yield* Effect.promise(() => both.json())
|
||||
if (!isRecord(bothBody) || !Array.isArray(bothBody["data"])) throw new Error("Expected a model list response")
|
||||
|
|
|
|||
|
|
@ -1,14 +1,14 @@
|
|||
import { expect } from "bun:test"
|
||||
import { SdkPlugins } from "@opencode/core/plugin/sdk"
|
||||
import { Plugin } from "@opencode/plugin/effect"
|
||||
import { Context, Deferred, Effect, Fiber, Layer } from "effect"
|
||||
import { Context, Deferred, Effect, Layer } from "effect"
|
||||
import { HttpEffect, HttpRouter, HttpServer } from "effect/http"
|
||||
import { tmpdirScoped } from "../../core/test/fixture/tmpdir"
|
||||
import { it } from "../../core/test/lib/effect"
|
||||
import { createRoutes } from "../src/routes"
|
||||
|
||||
it.live(
|
||||
"agent and provider lists wait for plugins while single provider reads stay nonblocking",
|
||||
"catalog reads answer while plugins are still activating",
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
const tmp = yield* tmpdirScoped("opencode-provider-endpoints-")
|
||||
|
|
@ -63,40 +63,26 @@ it.live(
|
|||
}),
|
||||
)
|
||||
})
|
||||
// Config providers and agents activate after SDK plugins, so the lists wait rather than answer with a partial
|
||||
// catalog that a client would cache as loaded.
|
||||
const providers = yield* request("GET", "/api/provider").pipe(Effect.forkScoped)
|
||||
const agents = yield* request("GET", "/api/agent").pipe(Effect.forkScoped)
|
||||
// Config providers and agents activate after SDK plugins. Reads return the current snapshot without waiting,
|
||||
// and clients refresh on the matching *.updated events.
|
||||
yield* request("GET", "/api/provider").pipe(Effect.timeout("2 seconds"))
|
||||
yield* Deferred.await(started)
|
||||
const integrations = yield* request("GET", "/api/integration").pipe(Effect.forkScoped)
|
||||
for (const route of ["/api/provider", "/api/agent", "/api/model", "/api/skill", "/api/integration"]) {
|
||||
const response = yield* request("GET", route).pipe(Effect.timeout("2 seconds"))
|
||||
expect(response.status).toBe(200)
|
||||
}
|
||||
const list = yield* request("GET", "/api/provider").pipe(Effect.timeout("2 seconds"))
|
||||
expect(yield* Effect.promise(() => list.json())).toMatchObject({
|
||||
location: { directory: tmp.path },
|
||||
data: expect.not.arrayContaining([expect.objectContaining({ id: "custom" })]),
|
||||
})
|
||||
const get = yield* request("GET", "/api/provider/custom").pipe(Effect.timeout("2 seconds"))
|
||||
expect(get.status).toBe(404)
|
||||
expect(yield* Effect.promise(() => get.json())).toMatchObject({
|
||||
_tag: "ProviderNotFoundError",
|
||||
providerID: "custom",
|
||||
})
|
||||
expect(providers.pollUnsafe()).toBeUndefined()
|
||||
expect(agents.pollUnsafe()).toBeUndefined()
|
||||
yield* Deferred.succeed(release, undefined)
|
||||
const list = yield* Fiber.join(providers)
|
||||
expect(list.status).toBe(200)
|
||||
expect(yield* Effect.promise(() => list.json())).toMatchObject({
|
||||
location: { directory: tmp.path },
|
||||
data: expect.arrayContaining([expect.objectContaining({ id: "custom" })]),
|
||||
})
|
||||
const agentList = yield* Fiber.join(agents)
|
||||
expect(agentList.status).toBe(200)
|
||||
expect(yield* Effect.promise(() => agentList.json())).toMatchObject({
|
||||
data: expect.arrayContaining([
|
||||
expect.objectContaining({ id: "build" }),
|
||||
expect.objectContaining({ id: "reviewer" }),
|
||||
]),
|
||||
})
|
||||
const discovered = yield* Fiber.join(integrations)
|
||||
expect(discovered.status).toBe(200)
|
||||
expect(yield* Effect.promise(() => discovered.json())).toMatchObject({
|
||||
data: expect.arrayContaining([expect.objectContaining({ id: "opencode" })]),
|
||||
})
|
||||
}),
|
||||
15_000,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import { HttpEffect, HttpRouter, HttpServer } from "effect/http"
|
|||
import { tmpdirScoped } from "../../core/test/fixture/tmpdir"
|
||||
import { it } from "../../core/test/lib/effect"
|
||||
import { createEmbeddedRoutes } from "../src/routes"
|
||||
import { pluginsLoaded } from "./fixture/routes"
|
||||
|
||||
const Broken = Rpc.define({
|
||||
id: "broken",
|
||||
|
|
@ -52,6 +53,7 @@ for (const method of ["handler", "schema"] as const) {
|
|||
const handler = Context.get(context, HttpRouter.HttpRouter)
|
||||
.asHttpEffect()
|
||||
.pipe(HttpEffect.toWebHandlerWith(context))
|
||||
yield* pluginsLoaded(context, directory.path)
|
||||
const url = new URL(`/api/rpc/broken/${method}`, "http://opencode.local")
|
||||
url.searchParams.set("location[directory]", directory.path)
|
||||
const response = yield* Effect.promise(() =>
|
||||
|
|
|
|||
|
|
@ -4,9 +4,9 @@ import { expect } from "bun:test"
|
|||
import { Effect } from "effect"
|
||||
import { tmpdir } from "../../core/test/fixture/tmpdir"
|
||||
import { it } from "../../core/test/lib/effect"
|
||||
import { startServer } from "./fixture/server"
|
||||
import { serveRoutes } from "./fixture/routes"
|
||||
|
||||
it.live("waits for plugin initialization before listing skills", () =>
|
||||
it.live("lists configured skills", () =>
|
||||
Effect.gen(function* () {
|
||||
const tmp = yield* Effect.acquireDisposable(Effect.promise(() => tmpdir("opencode-skill-endpoint-")))
|
||||
yield* Effect.promise(() => fs.mkdir(path.join(tmp.path, "skills", "demo"), { recursive: true }))
|
||||
|
|
@ -19,10 +19,16 @@ it.live("waits for plugin initialization before listing skills", () =>
|
|||
yield* Effect.promise(() =>
|
||||
fs.writeFile(path.join(tmp.path, "opencode.json"), JSON.stringify({ skills: [path.join(tmp.path, "skills")] })),
|
||||
)
|
||||
const server = yield* startServer(tmp.path)
|
||||
const url = new URL("/api/skill", server.base)
|
||||
const server = yield* serveRoutes({
|
||||
database: { path: ":memory:" },
|
||||
config: { directory: tmp.path },
|
||||
fs: { filewatcher: false },
|
||||
models: { fetch: false },
|
||||
})
|
||||
yield* server.pluginsLoaded(tmp.path)
|
||||
const url = new URL("/api/skill", "http://opencode.local")
|
||||
url.searchParams.set("location[directory]", tmp.path)
|
||||
const response = yield* Effect.promise(() => fetch(url, { headers: server.headers }))
|
||||
const response = yield* Effect.promise(() => server.fetch(url))
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
const body: unknown = yield* Effect.promise(() => response.json())
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import { Effect, Layer, Schedule } from "effect"
|
|||
import { tmpdir } from "../../core/test/fixture/tmpdir"
|
||||
import { it } from "../../core/test/lib/effect"
|
||||
import { startServer } from "./fixture/server"
|
||||
import { serveRoutes } from "./fixture/routes"
|
||||
import { ServerFetch } from "../src/fetch"
|
||||
|
||||
it.live(
|
||||
|
|
@ -14,33 +15,37 @@ it.live(
|
|||
Effect.gen(function* () {
|
||||
const tmp = yield* Effect.acquireDisposable(Effect.promise(() => tmpdir("opencode-vcs-init-")))
|
||||
yield* Effect.promise(() => Bun.write(path.join(tmp.path, "hello.txt"), "hello\n"))
|
||||
const server = yield* startServer(path.join(tmp.path, "config"))
|
||||
const url = new URL("/api/vcs/init", server.base)
|
||||
const server = yield* serveRoutes({
|
||||
database: { path: ":memory:" },
|
||||
config: { directory: path.join(tmp.path, "config") },
|
||||
fs: { filewatcher: false },
|
||||
models: { fetch: false },
|
||||
})
|
||||
yield* server.pluginsLoaded(tmp.path)
|
||||
const url = new URL("/api/vcs/init", "http://opencode.local")
|
||||
url.searchParams.set("location[directory]", tmp.path)
|
||||
const before = yield* Effect.promise(() =>
|
||||
fetch(new URL(`/api/location${url.search}`, server.base), { headers: server.headers }),
|
||||
server.fetch(new URL(`/api/location${url.search}`, "http://opencode.local")),
|
||||
)
|
||||
expect(before.status).toBe(200)
|
||||
const original = yield* Effect.promise(() => before.json())
|
||||
const initialized = yield* Effect.promise(() => fetch(url, { method: "POST", headers: server.headers }))
|
||||
const initialized = yield* Effect.promise(() => server.fetch(url, { method: "POST" }))
|
||||
expect(initialized.status).toBe(204)
|
||||
expect(yield* Effect.promise(() => $`git -C ${tmp.path} rev-parse --is-inside-work-tree`.text())).toBe("true\n")
|
||||
url.pathname = "/api/location"
|
||||
const location = yield* Effect.promise(() => fetch(url, { headers: server.headers }))
|
||||
const location = yield* Effect.promise(() => server.fetch(url))
|
||||
expect(location.status).toBe(200)
|
||||
const refreshed = yield* Effect.promise(() => location.json())
|
||||
expect(refreshed).toMatchObject({ project: { directory: tmp.path } })
|
||||
expect(refreshed.project.id).not.toBe(original.project.id)
|
||||
const projects = yield* Effect.promise(() =>
|
||||
fetch(new URL("/api/project", server.base), { headers: server.headers }),
|
||||
)
|
||||
const projects = yield* Effect.promise(() => server.fetch(new URL("/api/project", "http://opencode.local")))
|
||||
expect(yield* Effect.promise(() => projects.json())).toContainEqual(
|
||||
expect.objectContaining({ id: refreshed.project.id, vcs: "git" }),
|
||||
)
|
||||
url.pathname = "/api/vcs"
|
||||
const vcs = yield* Effect.tryPromise({
|
||||
try: async () => {
|
||||
const response = await fetch(url, { headers: server.headers })
|
||||
const response = await server.fetch(url)
|
||||
const body: unknown = await response.json()
|
||||
if (!isRecord(body) || !isRecord(body.data) || body.data.provider !== "git")
|
||||
throw new Error("Git provider not ready")
|
||||
|
|
@ -51,14 +56,14 @@ it.live(
|
|||
expect(vcs).toMatchObject({ data: { provider: "git" } })
|
||||
url.pathname = "/api/vcs/diff"
|
||||
url.searchParams.set("mode", "working")
|
||||
const diff = yield* Effect.promise(() => fetch(url, { headers: server.headers }))
|
||||
const diff = yield* Effect.promise(() => server.fetch(url))
|
||||
expect(diff.status).toBe(200)
|
||||
expect(yield* Effect.promise(() => diff.json())).toMatchObject({
|
||||
data: expect.arrayContaining([expect.objectContaining({ file: "hello.txt" })]),
|
||||
})
|
||||
url.searchParams.delete("mode")
|
||||
url.pathname = "/api/vcs/init"
|
||||
const repeated = yield* Effect.promise(() => fetch(url, { method: "POST", headers: server.headers }))
|
||||
const repeated = yield* Effect.promise(() => server.fetch(url, { method: "POST" }))
|
||||
expect(repeated.status).toBe(409)
|
||||
}),
|
||||
15_000,
|
||||
|
|
@ -85,11 +90,17 @@ it.live(
|
|||
() =>
|
||||
Effect.gen(function* () {
|
||||
const tmp = yield* Effect.acquireDisposable(Effect.promise(() => tmpdir("opencode-vcs-provider-")))
|
||||
const server = yield* startServer(path.join(tmp.path, "config"))
|
||||
const url = new URL("/api/vcs/init", server.base)
|
||||
const server = yield* serveRoutes({
|
||||
database: { path: ":memory:" },
|
||||
config: { directory: path.join(tmp.path, "config") },
|
||||
fs: { filewatcher: false },
|
||||
models: { fetch: false },
|
||||
})
|
||||
yield* server.pluginsLoaded(tmp.path)
|
||||
const url = new URL("/api/vcs/init", "http://opencode.local")
|
||||
url.searchParams.set("location[directory]", tmp.path)
|
||||
url.searchParams.set("provider", "unknown")
|
||||
const unsupported = yield* Effect.promise(() => fetch(url, { method: "POST", headers: server.headers }))
|
||||
const unsupported = yield* Effect.promise(() => server.fetch(url, { method: "POST" }))
|
||||
expect(unsupported.status).toBe(400)
|
||||
expect(yield* Effect.promise(() => unsupported.json())).toMatchObject({
|
||||
_tag: "InvalidRequestError",
|
||||
|
|
@ -98,7 +109,7 @@ it.live(
|
|||
expect(yield* Effect.promise(() => Bun.file(path.join(tmp.path, ".git", "HEAD")).exists())).toBe(false)
|
||||
expect(yield* Effect.promise(() => Bun.file(path.join(tmp.path, ".hg", "requires")).exists())).toBe(false)
|
||||
url.searchParams.set("provider", "git")
|
||||
const initialized = yield* Effect.promise(() => fetch(url, { method: "POST", headers: server.headers }))
|
||||
const initialized = yield* Effect.promise(() => server.fetch(url, { method: "POST" }))
|
||||
expect(initialized.status).toBe(204)
|
||||
expect(yield* Effect.promise(() => Bun.file(path.join(tmp.path, ".git", "HEAD")).exists())).toBe(true)
|
||||
}),
|
||||
|
|
@ -108,49 +119,48 @@ it.live(
|
|||
it.live("returns 501 when a registered VCS provider has no initializer", () =>
|
||||
Effect.gen(function* () {
|
||||
const tmp = yield* Effect.acquireDisposable(Effect.promise(() => tmpdir("opencode-vcs-no-init-")))
|
||||
const handler = yield* ServerFetch.make(
|
||||
const server = yield* serveRoutes(
|
||||
{
|
||||
database: { path: ":memory:" },
|
||||
config: { directory: tmp.path },
|
||||
fs: { filewatcher: false },
|
||||
models: { fetch: false },
|
||||
},
|
||||
{
|
||||
overrides: [
|
||||
SdkPlugins.node.replace(
|
||||
Layer.succeed(
|
||||
SdkPlugins.Service,
|
||||
SdkPlugins.Service.of({
|
||||
register: () => Effect.void,
|
||||
all: () => [
|
||||
{
|
||||
id: "read-only-vcs",
|
||||
revision: "test",
|
||||
effect: (ctx) =>
|
||||
ctx.vcs
|
||||
.transform((editor) => {
|
||||
editor.add({
|
||||
id: "read-only",
|
||||
name: "Read-only VCS",
|
||||
info: () => Effect.succeed({ branch: {} }),
|
||||
branches: () => Effect.succeed([]),
|
||||
status: () => Effect.succeed([]),
|
||||
diff: () => Effect.succeed([]),
|
||||
})
|
||||
[
|
||||
SdkPlugins.node.replace(
|
||||
Layer.succeed(
|
||||
SdkPlugins.Service,
|
||||
SdkPlugins.Service.of({
|
||||
register: () => Effect.void,
|
||||
all: () => [
|
||||
{
|
||||
id: "read-only-vcs",
|
||||
revision: "test",
|
||||
effect: (ctx) =>
|
||||
ctx.vcs
|
||||
.transform((editor) => {
|
||||
editor.add({
|
||||
id: "read-only",
|
||||
name: "Read-only VCS",
|
||||
info: () => Effect.succeed({ branch: {} }),
|
||||
branches: () => Effect.succeed([]),
|
||||
status: () => Effect.succeed([]),
|
||||
diff: () => Effect.succeed([]),
|
||||
})
|
||||
.pipe(Effect.asVoid),
|
||||
},
|
||||
],
|
||||
}),
|
||||
),
|
||||
})
|
||||
.pipe(Effect.asVoid),
|
||||
},
|
||||
],
|
||||
}),
|
||||
),
|
||||
],
|
||||
},
|
||||
),
|
||||
],
|
||||
)
|
||||
yield* server.pluginsLoaded(tmp.path)
|
||||
const url = new URL("http://opencode.local/api/vcs/init")
|
||||
url.searchParams.set("location[directory]", tmp.path)
|
||||
url.searchParams.set("provider", "read-only")
|
||||
const response = yield* Effect.promise(() => handler(new Request(url, { method: "POST" })))
|
||||
const response = yield* Effect.promise(() => server.handler(new Request(url, { method: "POST" })))
|
||||
expect(response.status).toBe(501)
|
||||
expect(yield* Effect.promise(() => response.json())).toMatchObject({
|
||||
_tag: "VcsInitNotSupportedError",
|
||||
|
|
@ -170,17 +180,23 @@ describeHg("Mercurial initialization", () => {
|
|||
Effect.gen(function* () {
|
||||
const tmp = yield* Effect.acquireDisposable(Effect.promise(() => tmpdir("opencode-vcs-hg-init-")))
|
||||
yield* Effect.promise(() => Bun.write(path.join(tmp.path, "hello.txt"), "hello\n"))
|
||||
const server = yield* startServer(path.join(tmp.path, "config"))
|
||||
const url = new URL("/api/vcs/init", server.base)
|
||||
const server = yield* serveRoutes({
|
||||
database: { path: ":memory:" },
|
||||
config: { directory: path.join(tmp.path, "config") },
|
||||
fs: { filewatcher: false },
|
||||
models: { fetch: false },
|
||||
})
|
||||
yield* server.pluginsLoaded(tmp.path)
|
||||
const url = new URL("/api/vcs/init", "http://opencode.local")
|
||||
url.searchParams.set("location[directory]", tmp.path)
|
||||
url.searchParams.set("provider", "hg")
|
||||
const initialized = yield* Effect.promise(() => fetch(url, { method: "POST", headers: server.headers }))
|
||||
const initialized = yield* Effect.promise(() => server.fetch(url, { method: "POST" }))
|
||||
expect(initialized.status).toBe(204)
|
||||
expect(yield* Effect.promise(() => Bun.file(path.join(tmp.path, ".hg", "requires")).exists())).toBe(true)
|
||||
url.pathname = "/api/vcs"
|
||||
const info = yield* Effect.tryPromise({
|
||||
try: async () => {
|
||||
const response = await fetch(url, { headers: server.headers })
|
||||
const response = await server.fetch(url)
|
||||
const body: unknown = await response.json()
|
||||
if (!isRecord(body) || !isRecord(body.data) || body.data.provider !== "hg")
|
||||
throw new Error("Mercurial provider not ready")
|
||||
|
|
@ -191,7 +207,7 @@ describeHg("Mercurial initialization", () => {
|
|||
expect(info).toMatchObject({ data: { provider: "hg" } })
|
||||
url.pathname = "/api/vcs/diff"
|
||||
url.searchParams.set("mode", "working")
|
||||
const diff = yield* Effect.promise(() => fetch(url, { headers: server.headers }))
|
||||
const diff = yield* Effect.promise(() => server.fetch(url))
|
||||
expect(diff.status).toBe(200)
|
||||
expect(yield* Effect.promise(() => diff.json())).toMatchObject({
|
||||
data: expect.arrayContaining([expect.objectContaining({ file: "hello.txt" })]),
|
||||
|
|
@ -199,7 +215,7 @@ describeHg("Mercurial initialization", () => {
|
|||
url.pathname = "/api/vcs/init"
|
||||
url.searchParams.delete("mode")
|
||||
url.searchParams.set("provider", "git")
|
||||
const repeated = yield* Effect.promise(() => fetch(url, { method: "POST", headers: server.headers }))
|
||||
const repeated = yield* Effect.promise(() => server.fetch(url, { method: "POST" }))
|
||||
expect(repeated.status).toBe(409)
|
||||
}),
|
||||
15_000,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue