test: port instance HttpApi path/vcs read coverage to Effect

This commit is contained in:
Kit Langton
2026-04-30 11:07:00 -04:00
committed by GitHub
parent 62e1335388
commit dddfcbf0d8
23 changed files with 712 additions and 216 deletions

View File

@@ -0,0 +1,24 @@
import { Effect } from "effect"
const inputDecoder = new TextDecoder("utf-8", { fatal: true })
export function handlePtyInput(
handler: { onMessage: (message: string | ArrayBuffer) => void },
message: string | Uint8Array,
) {
if (typeof message === "string") {
handler.onMessage(message)
return Effect.void
}
return Effect.try({
try: () => inputDecoder.decode(message),
catch: () => new Error("invalid PTY websocket input"),
}).pipe(
Effect.catch(() => Effect.succeed(undefined)),
Effect.flatMap((decoded) => {
if (decoded === undefined) return Effect.void
handler.onMessage(decoded)
return Effect.void
}),
)
}

View File

@@ -0,0 +1,12 @@
const opencodeOrigin = /^https:\/\/([a-z0-9-]+\.)*opencode\.ai$/
export function isAllowedCorsOrigin(input: string | undefined, opts?: { cors?: string[] }) {
if (!input) return true
if (input.startsWith("http://localhost:")) return true
if (input.startsWith("http://127.0.0.1:")) return true
if (input.startsWith("oc://renderer")) return true
if (input === "tauri://localhost" || input === "http://tauri.localhost" || input === "https://tauri.localhost")
return true
if (opencodeOrigin.test(input)) return true
return opts?.cors?.includes(input) ?? false
}

View File

@@ -11,6 +11,7 @@ import { basicAuth } from "hono/basic-auth"
import { cors } from "hono/cors"
import { compress } from "hono/compress"
import * as ServerBackend from "./backend"
import { isAllowedCorsOrigin } from "./cors"
const log = Log.create({ service: "server" })
@@ -70,16 +71,7 @@ export function CorsMiddleware(opts?: { cors?: string[] }): MiddlewareHandler {
return cors({
maxAge: 86_400,
origin(input) {
if (!input) return
if (input.startsWith("http://localhost:")) return input
if (input.startsWith("http://127.0.0.1:")) return input
if (input.startsWith("oc://renderer")) return input
if (input === "tauri://localhost" || input === "http://tauri.localhost" || input === "https://tauri.localhost")
return input
if (/^https:\/\/([a-z0-9-]+\.)*opencode\.ai$/.test(input)) return input
if (opts?.cors?.includes(input)) return input
if (isAllowedCorsOrigin(input, opts)) return input
},
})
}

View File

@@ -15,6 +15,7 @@ export const AddPayload = Schema.Struct({
export const StatusMap = Schema.Record(Schema.String, MCP.Status)
export const AuthStartResponse = Schema.Struct({
authorizationUrl: Schema.String,
oauthState: Schema.String,
})
export const AuthCallbackPayload = Schema.Struct({
code: Schema.String,

View File

@@ -10,7 +10,6 @@ import { SessionSummary } from "@/session/summary"
import { Todo } from "@/session/todo"
import { MessageID, PartID, SessionID } from "@/session/schema"
import { Snapshot } from "@/snapshot"
import { NonNegativeInt } from "@/util/schema"
import { Schema, SchemaGetter, Struct } from "effect"
import { HttpApi, HttpApiEndpoint, HttpApiError, HttpApiGroup, HttpApiSchema, OpenApi } from "effect/unstable/httpapi"
import { Authorization } from "../middleware/authorization"
@@ -45,7 +44,7 @@ export const UpdatePayload = Schema.Struct({
permission: Schema.optional(Permission.Ruleset),
time: Schema.optional(
Schema.Struct({
archived: Schema.optional(NonNegativeInt),
archived: Schema.optional(Session.ArchivedTimestamp),
}),
),
})

View File

@@ -1,6 +1,7 @@
import { EffectBridge } from "@/effect/bridge"
import { Pty } from "@/pty"
import { PtyID } from "@/pty/schema"
import { handlePtyInput } from "@/pty/input"
import { Shell } from "@/shell/shell"
import { Effect } from "effect"
import { HttpRouter, HttpServerRequest, HttpServerResponse } from "effect/unstable/http"
@@ -102,9 +103,7 @@ export const ptyConnectRoute = HttpRouter.add(
if (!handler) return HttpServerResponse.empty()
yield* socket
.runRaw((message) => {
handler.onMessage(typeof message === "string" ? message : message.slice().buffer)
})
.runRaw((message) => handlePtyInput(handler, message))
.pipe(
Effect.catchReason("SocketError", "SocketCloseError", () => Effect.void),
Effect.ensuring(

View File

@@ -62,7 +62,7 @@ export const sessionHandlers = HttpApiBuilder.group(InstanceHttpApi, "session",
return Instance.restore(instance, () =>
Array.from(
Session.list({
directory: ctx.query.directory,
directory: ctx.query.scope === "project" ? undefined : ctx.query.directory,
scope: ctx.query.scope,
path: ctx.query.path,
roots: ctx.query.roots,

View File

@@ -28,8 +28,8 @@ const commandAliases = {
export const tuiHandlers = HttpApiBuilder.group(InstanceHttpApi, "tui", (handlers) =>
Effect.gen(function* () {
const bus = yield* Bus.Service
const publishCommand = (command: typeof TuiEvent.CommandExecute.properties.Type.command) =>
bus.publish(TuiEvent.CommandExecute, { command })
const publishCommand = (command: typeof TuiEvent.CommandExecute.properties.Type.command | undefined) =>
bus.publish(TuiEvent.CommandExecute, { command } as typeof TuiEvent.CommandExecute.properties.Type)
const appendPrompt = Effect.fn("TuiHttpApi.appendPrompt")(function* (ctx: {
payload: typeof TuiEvent.PromptAppend.properties.Type
@@ -71,7 +71,8 @@ export const tuiHandlers = HttpApiBuilder.group(InstanceHttpApi, "tui", (handler
const executeCommand = Effect.fn("TuiHttpApi.executeCommand")(function* (ctx: {
payload: typeof CommandPayload.Type
}) {
yield* publishCommand(commandAliases[ctx.payload.command as keyof typeof commandAliases] ?? ctx.payload.command)
// Legacy only publishes known aliases; unknown commands become undefined.
yield* publishCommand(commandAliases[ctx.payload.command as keyof typeof commandAliases])
return true
})

View File

@@ -1,32 +1,63 @@
import type { WorkspaceID } from "@/control-plane/schema"
import { WorkspaceContext } from "@/control-plane/workspace-context"
import { WorkspaceRef } from "@/effect/instance-ref"
import { Instance, type InstanceContext } from "@/project/instance"
import { Effect } from "effect"
import { HttpEffect, HttpMiddleware, HttpServerRequest } from "effect/unstable/http"
const disposeAfterResponse = new WeakMap<object, InstanceContext>()
type MarkedInstance = {
ctx: InstanceContext
workspaceID?: WorkspaceID
}
export const markInstanceForDisposal = (ctx: InstanceContext) =>
HttpEffect.appendPreResponseHandler((request, response) =>
Effect.sync(() => {
disposeAfterResponse.set(request.source, ctx)
return response
// Disposal is requested by an endpoint handler, but must run from the outer
// server middleware after the response has been produced. The original Request
// object is the stable handoff key between those two phases.
const disposeAfterResponse = new WeakMap<object, MarkedInstance>()
const mark = (ctx: InstanceContext) =>
Effect.gen(function* () {
return { ctx, workspaceID: yield* WorkspaceRef }
})
// Instance.dispose/reload still publish events through legacy ALS helpers.
// Effect request handlers carry these values in services, so bridge them back
// into the legacy contexts only around the lifecycle operation.
const restoreMarked = <A>(marked: MarkedInstance, fn: () => A) =>
Effect.promise(() =>
WorkspaceContext.provide({
workspaceID: marked.workspaceID,
fn: () => Instance.restore(marked.ctx, fn),
}),
)
export const markInstanceForDisposal = (ctx: InstanceContext) =>
Effect.gen(function* () {
const marked = yield* mark(ctx)
return yield* HttpEffect.appendPreResponseHandler((request, response) =>
Effect.sync(() => {
// The response is sent before disposeMiddleware performs the teardown.
disposeAfterResponse.set(request.source, marked)
return response
}),
)
})
export const markInstanceForReload = (ctx: InstanceContext, next: Parameters<typeof Instance.reload>[0]) =>
HttpEffect.appendPreResponseHandler((_request, response) =>
Effect.as(
Effect.uninterruptible(Effect.promise(() => Instance.restore(ctx, () => Instance.reload(next)))),
response,
),
)
Effect.gen(function* () {
const marked = yield* mark(ctx)
return yield* HttpEffect.appendPreResponseHandler((_request, response) =>
Effect.as(Effect.uninterruptible(restoreMarked(marked, () => Instance.reload(next))), response),
)
})
export const disposeMiddleware: HttpMiddleware.HttpMiddleware = (effect) =>
Effect.gen(function* () {
const response = yield* effect
const request = yield* HttpServerRequest.HttpServerRequest
const ctx = disposeAfterResponse.get(request.source)
if (!ctx) return response
const marked = disposeAfterResponse.get(request.source)
if (!marked) return response
disposeAfterResponse.delete(request.source)
yield* Effect.uninterruptible(Effect.promise(() => Instance.restore(ctx, () => Instance.dispose())))
yield* Effect.uninterruptible(restoreMarked(marked, () => Instance.dispose()))
return response
})

View File

@@ -1,6 +1,6 @@
import { Context, Effect, Layer } from "effect"
import { HttpApiBuilder } from "effect/unstable/httpapi"
import { HttpRouter, HttpServer } from "effect/unstable/http"
import { HttpMiddleware, HttpRouter, HttpServer } from "effect/unstable/http"
import * as Socket from "effect/unstable/socket/Socket"
import { Account } from "@/account/account"
import { Agent } from "@/agent/agent"
@@ -31,6 +31,7 @@ import { ToolRegistry } from "@/tool/registry"
import { lazy } from "@/util/lazy"
import { Vcs } from "@/project/vcs"
import { Worktree } from "@/worktree"
import { isAllowedCorsOrigin } from "@/server/cors"
import { InstanceHttpApi, RootHttpApi } from "./api"
import { ServerAuthConfig, authorizationLayer } from "./middleware/authorization"
import { eventRoute } from "./event"
@@ -55,7 +56,6 @@ import { workspaceRouterMiddleware, workspaceRoutingLayer } from "./middleware/w
import { disposeMiddleware } from "./lifecycle"
import { memoMap } from "@opencode-ai/core/effect/memo-map"
import * as ServerBackend from "@/server/backend"
import type { Predicate } from "effect/Predicate"
export const context = Context.makeUnsafe<unknown>(new Map())
@@ -69,6 +69,11 @@ const runtime = HttpRouter.middleware()(
),
).layer
const cors = HttpRouter.middleware(HttpMiddleware.cors({
allowedOrigins: isAllowedCorsOrigin,
maxAge: 86_400,
}), { global: true })
const rootApiRoutes = HttpApiBuilder.layer(RootHttpApi).pipe(Layer.provide([controlHandlers, globalHandlers]))
const instanceApiRoutes = HttpApiBuilder.layer(InstanceHttpApi).pipe(
Layer.provide([
@@ -105,24 +110,8 @@ const instanceRoutes = Layer.mergeAll(rawInstanceRoutes, instanceApiRoutes).pipe
)
export const routes = Layer.mergeAll(rootApiRoutes, instanceRoutes).pipe(
Layer.provide(
HttpRouter.cors({
maxAge: 86_400,
allowedOrigins: ((input) => {
return (
!input ||
input.startsWith("http://localhost:") ||
input.startsWith("http://127.0.0.1:") ||
input.startsWith("oc://renderer") ||
input === "tauri://localhost" ||
input === "http://tauri.localhost" ||
input === "https://tauri.localhost" ||
/^https:\/\/([a-z0-9-]+\.)*opencode\.ai$/.test(input)
)
}) as Predicate<string> as any,
}),
),
Layer.provide([
cors,
runtime,
Account.defaultLayer,
Agent.defaultLayer,

View File

@@ -17,6 +17,7 @@ import { ServerProxy } from "./proxy"
type Rule = { method?: string; path: string; exact?: boolean; action: "local" | "forward" }
const RULES: Array<Rule> = [
{ path: "/experimental/workspace", action: "local" },
{ path: "/session/status", action: "forward" },
{ method: "GET", path: "/session", action: "local" },
]

View File

@@ -142,11 +142,15 @@ const Share = Schema.Struct({
url: Schema.String,
})
// Legacy HTTP accepted any number here, and persisted data may already contain
// negative values. Keep archive timestamps permissive while other clocks stay non-negative.
export const ArchivedTimestamp = Schema.Number
const Time = Schema.Struct({
created: NonNegativeInt,
updated: NonNegativeInt,
compacting: optionalOmitUndefined(NonNegativeInt),
archived: optionalOmitUndefined(NonNegativeInt),
archived: optionalOmitUndefined(ArchivedTimestamp),
})
const Revert = Schema.Struct({
@@ -215,7 +219,7 @@ export const SetTitleInput = Schema.Struct({ sessionID: SessionID, title: Schema
)
export const SetArchivedInput = Schema.Struct({
sessionID: SessionID,
time: Schema.optional(NonNegativeInt),
time: Schema.optional(ArchivedTimestamp),
}).pipe(withStatics((s) => ({ zod: zod(s) })))
export const SetPermissionInput = Schema.Struct({
sessionID: SessionID,
@@ -244,7 +248,7 @@ const UpdatedTime = Schema.Struct({
created: Schema.optional(Schema.NullOr(NonNegativeInt)),
updated: Schema.optional(Schema.NullOr(NonNegativeInt)),
compacting: Schema.optional(Schema.NullOr(NonNegativeInt)),
archived: Schema.optional(Schema.NullOr(NonNegativeInt)),
archived: Schema.optional(Schema.NullOr(ArchivedTimestamp)),
})
const UpdatedInfo = Schema.Struct({