Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 10069a424c | |||
| be1eb706c5 | |||
| 779616bf6c | |||
| bee094ff7b | |||
| e48992a6b4 | |||
| 678d8f6ab2 |
@@ -356,6 +356,7 @@
|
||||
"@opencode-ai/plugin": "workspace:*",
|
||||
"@opencode-ai/script": "workspace:*",
|
||||
"@opencode-ai/sdk": "workspace:*",
|
||||
"@opencode-ai/server": "workspace:*",
|
||||
"@opencode-ai/util": "workspace:*",
|
||||
"@openrouter/ai-sdk-provider": "2.5.1",
|
||||
"@opentui/core": "0.1.99",
|
||||
@@ -501,7 +502,11 @@
|
||||
"packages/server": {
|
||||
"name": "@opencode-ai/server",
|
||||
"version": "1.4.3",
|
||||
"dependencies": {
|
||||
"effect": "catalog:",
|
||||
},
|
||||
"devDependencies": {
|
||||
"@typescript/native-preview": "catalog:",
|
||||
"typescript": "catalog:",
|
||||
},
|
||||
},
|
||||
|
||||
@@ -111,6 +111,7 @@
|
||||
"@octokit/rest": "catalog:",
|
||||
"@openauthjs/openauth": "catalog:",
|
||||
"@opencode-ai/plugin": "workspace:*",
|
||||
"@opencode-ai/server": "workspace:*",
|
||||
"@opencode-ai/script": "workspace:*",
|
||||
"@opencode-ai/sdk": "workspace:*",
|
||||
"@opencode-ai/util": "workspace:*",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import z from "zod"
|
||||
import { Effect, Exit, Layer, PubSub, Scope, Context, Stream } from "effect"
|
||||
import { EffectLogger } from "@/effect/logger"
|
||||
import { EffectBridge } from "@/effect/bridge"
|
||||
import { Log } from "../util/log"
|
||||
import { BusEvent } from "./bus-event"
|
||||
import { GlobalBus } from "./global"
|
||||
@@ -128,6 +128,7 @@ export namespace Bus {
|
||||
function on<T>(pubsub: PubSub.PubSub<T>, type: string, callback: (event: T) => unknown) {
|
||||
return Effect.gen(function* () {
|
||||
log.info("subscribing", { type })
|
||||
const bridge = yield* EffectBridge.make()
|
||||
const scope = yield* Scope.make()
|
||||
const subscription = yield* Scope.provide(scope)(PubSub.subscribe(pubsub))
|
||||
|
||||
@@ -147,7 +148,7 @@ export namespace Bus {
|
||||
|
||||
return () => {
|
||||
log.info("unsubscribing", { type })
|
||||
Effect.runFork(Scope.close(scope, Exit.void).pipe(Effect.provide(EffectLogger.layer)))
|
||||
bridge.fork(Scope.close(scope, Exit.void))
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { BusEvent } from "@/bus/bus-event"
|
||||
import { InstanceState } from "@/effect/instance-state"
|
||||
import { EffectBridge } from "@/effect/bridge"
|
||||
import type { InstanceContext } from "@/project/instance"
|
||||
import { SessionID, MessageID } from "@/session/schema"
|
||||
import { Effect, Layer, Context } from "effect"
|
||||
import { EffectLogger } from "@/effect/logger"
|
||||
import z from "zod"
|
||||
import { Config } from "../config/config"
|
||||
import { MCP } from "../mcp"
|
||||
@@ -82,6 +82,7 @@ export namespace Command {
|
||||
|
||||
const init = Effect.fn("Command.state")(function* (ctx: InstanceContext) {
|
||||
const cfg = yield* config.get()
|
||||
const bridge = yield* EffectBridge.make()
|
||||
const commands: Record<string, Info> = {}
|
||||
|
||||
commands[Default.INIT] = {
|
||||
@@ -125,7 +126,7 @@ export namespace Command {
|
||||
source: "mcp",
|
||||
description: prompt.description,
|
||||
get template() {
|
||||
return Effect.runPromise(
|
||||
return bridge.promise(
|
||||
mcp
|
||||
.getPrompt(
|
||||
prompt.client,
|
||||
@@ -141,7 +142,6 @@ export namespace Command {
|
||||
.map((message) => (message.content.type === "text" ? message.content.text : ""))
|
||||
.join("\n") || "",
|
||||
),
|
||||
Effect.provide(EffectLogger.layer),
|
||||
),
|
||||
)
|
||||
},
|
||||
|
||||
@@ -12,6 +12,10 @@ export const WorkspaceContext = {
|
||||
return context.provide({ workspaceID: input.workspaceID as string }, () => input.fn())
|
||||
},
|
||||
|
||||
restore<R>(workspaceID: string, fn: () => R): R {
|
||||
return context.provide({ workspaceID }, fn)
|
||||
},
|
||||
|
||||
get workspaceID() {
|
||||
try {
|
||||
return context.use().workspaceID
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
import { Effect, Fiber } from "effect"
|
||||
import { WorkspaceContext } from "@/control-plane/workspace-context"
|
||||
import { Instance, type InstanceContext } from "@/project/instance"
|
||||
import { LocalContext } from "@/util/local-context"
|
||||
import { InstanceRef, WorkspaceRef } from "./instance-ref"
|
||||
import { attachWith } from "./run-service"
|
||||
|
||||
export namespace EffectBridge {
|
||||
export interface Shape {
|
||||
readonly promise: <A, E, R>(effect: Effect.Effect<A, E, R>) => Promise<A>
|
||||
readonly fork: <A, E, R>(effect: Effect.Effect<A, E, R>) => Fiber.Fiber<A, E>
|
||||
}
|
||||
|
||||
function restore<R>(instance: InstanceContext | undefined, workspace: string | undefined, fn: () => R): R {
|
||||
if (instance && workspace !== undefined) {
|
||||
return WorkspaceContext.restore(workspace, () => Instance.restore(instance, fn))
|
||||
}
|
||||
if (instance) return Instance.restore(instance, fn)
|
||||
if (workspace !== undefined) return WorkspaceContext.restore(workspace, fn)
|
||||
return fn()
|
||||
}
|
||||
|
||||
export function make(): Effect.Effect<Shape> {
|
||||
return Effect.gen(function* () {
|
||||
const ctx = yield* Effect.context()
|
||||
const value = yield* InstanceRef
|
||||
const instance =
|
||||
value ??
|
||||
(() => {
|
||||
try {
|
||||
return Instance.current
|
||||
} catch (err) {
|
||||
if (!(err instanceof LocalContext.NotFound)) throw err
|
||||
}
|
||||
})()
|
||||
const workspace = (yield* WorkspaceRef) ?? WorkspaceContext.workspaceID
|
||||
const attach = <A, E, R>(effect: Effect.Effect<A, E, R>) => attachWith(effect, { instance, workspace })
|
||||
const wrap = <A, E, R>(effect: Effect.Effect<A, E, R>) =>
|
||||
attach(effect).pipe(Effect.provide(ctx)) as Effect.Effect<A, E, never>
|
||||
|
||||
return {
|
||||
promise: <A, E, R>(effect: Effect.Effect<A, E, R>) =>
|
||||
restore(instance, workspace, () => Effect.runPromise(wrap(effect))),
|
||||
fork: <A, E, R>(effect: Effect.Effect<A, E, R>) =>
|
||||
restore(instance, workspace, () => Effect.runFork(wrap(effect))),
|
||||
} satisfies Shape
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -5,14 +5,31 @@ import { LocalContext } from "@/util/local-context"
|
||||
import { InstanceRef, WorkspaceRef } from "./instance-ref"
|
||||
import { Observability } from "./oltp"
|
||||
import { WorkspaceContext } from "@/control-plane/workspace-context"
|
||||
import type { InstanceContext } from "@/project/instance"
|
||||
|
||||
export const memoMap = Layer.makeMemoMapUnsafe()
|
||||
|
||||
type Refs = {
|
||||
instance?: InstanceContext
|
||||
workspace?: string
|
||||
}
|
||||
|
||||
export function attachWith<A, E, R>(effect: Effect.Effect<A, E, R>, refs: Refs): Effect.Effect<A, E, R> {
|
||||
if (!refs.instance && !refs.workspace) return effect
|
||||
if (!refs.instance) return effect.pipe(Effect.provideService(WorkspaceRef, refs.workspace))
|
||||
if (!refs.workspace) return effect.pipe(Effect.provideService(InstanceRef, refs.instance))
|
||||
return effect.pipe(
|
||||
Effect.provideService(InstanceRef, refs.instance),
|
||||
Effect.provideService(WorkspaceRef, refs.workspace),
|
||||
)
|
||||
}
|
||||
|
||||
export function attach<A, E, R>(effect: Effect.Effect<A, E, R>): Effect.Effect<A, E, R> {
|
||||
try {
|
||||
const ctx = Instance.current
|
||||
const workspaceID = WorkspaceContext.workspaceID
|
||||
return effect.pipe(Effect.provideService(InstanceRef, ctx), Effect.provideService(WorkspaceRef, workspaceID))
|
||||
return attachWith(effect, {
|
||||
instance: Instance.current,
|
||||
workspace: WorkspaceContext.workspaceID,
|
||||
})
|
||||
} catch (err) {
|
||||
if (!(err instanceof LocalContext.NotFound)) throw err
|
||||
}
|
||||
|
||||
@@ -25,7 +25,7 @@ import { Bus } from "@/bus"
|
||||
import { TuiEvent } from "@/cli/cmd/tui/event"
|
||||
import open from "open"
|
||||
import { Effect, Exit, Layer, Option, Context, Stream } from "effect"
|
||||
import { EffectLogger } from "@/effect/logger"
|
||||
import { EffectBridge } from "@/effect/bridge"
|
||||
import { InstanceState } from "@/effect/instance-state"
|
||||
import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"
|
||||
import * as CrossSpawnSpawner from "@/effect/cross-spawn-spawner"
|
||||
@@ -471,25 +471,24 @@ export namespace MCP {
|
||||
Effect.catch(() => Effect.succeed([] as number[])),
|
||||
)
|
||||
|
||||
function watch(s: State, name: string, client: MCPClient, timeout?: number) {
|
||||
function watch(s: State, name: string, client: MCPClient, bridge: EffectBridge.Shape, timeout?: number) {
|
||||
client.setNotificationHandler(ToolListChangedNotificationSchema, async () => {
|
||||
log.info("tools list changed notification received", { server: name })
|
||||
if (s.clients[name] !== client || s.status[name]?.status !== "connected") return
|
||||
|
||||
const listed = await Effect.runPromise(defs(name, client, timeout).pipe(Effect.provide(EffectLogger.layer)))
|
||||
const listed = await bridge.promise(defs(name, client, timeout))
|
||||
if (!listed) return
|
||||
if (s.clients[name] !== client || s.status[name]?.status !== "connected") return
|
||||
|
||||
s.defs[name] = listed
|
||||
await Effect.runPromise(
|
||||
bus.publish(ToolsChanged, { server: name }).pipe(Effect.ignore, Effect.provide(EffectLogger.layer)),
|
||||
)
|
||||
await bridge.promise(bus.publish(ToolsChanged, { server: name }).pipe(Effect.ignore))
|
||||
})
|
||||
}
|
||||
|
||||
const state = yield* InstanceState.make<State>(
|
||||
Effect.fn("MCP.state")(function* () {
|
||||
const cfg = yield* cfgSvc.get()
|
||||
const bridge = yield* EffectBridge.make()
|
||||
const config = cfg.mcp ?? {}
|
||||
const s: State = {
|
||||
status: {},
|
||||
@@ -518,7 +517,7 @@ export namespace MCP {
|
||||
if (result.mcpClient) {
|
||||
s.clients[key] = result.mcpClient
|
||||
s.defs[key] = result.defs!
|
||||
watch(s, key, result.mcpClient, mcp.timeout)
|
||||
watch(s, key, result.mcpClient, bridge, mcp.timeout)
|
||||
}
|
||||
}),
|
||||
{ concurrency: "unbounded" },
|
||||
@@ -565,11 +564,12 @@ export namespace MCP {
|
||||
listed: MCPToolDef[],
|
||||
timeout?: number,
|
||||
) {
|
||||
const bridge = yield* EffectBridge.make()
|
||||
yield* closeClient(s, name)
|
||||
s.status[name] = { status: "connected" }
|
||||
s.clients[name] = client
|
||||
s.defs[name] = listed
|
||||
watch(s, name, client, timeout)
|
||||
watch(s, name, client, bridge, timeout)
|
||||
return s.status[name]
|
||||
})
|
||||
|
||||
|
||||
@@ -18,7 +18,7 @@ import { gitlabAuthPlugin as GitlabAuthPlugin } from "opencode-gitlab-auth"
|
||||
import { PoeAuthPlugin } from "opencode-poe-auth"
|
||||
import { CloudflareAIGatewayAuthPlugin, CloudflareWorkersAuthPlugin } from "./cloudflare"
|
||||
import { Effect, Layer, Context, Stream } from "effect"
|
||||
import { EffectLogger } from "@/effect/logger"
|
||||
import { EffectBridge } from "@/effect/bridge"
|
||||
import { InstanceState } from "@/effect/instance-state"
|
||||
import { errorMessage } from "@/util/error"
|
||||
import { PluginLoader } from "./loader"
|
||||
@@ -90,14 +90,6 @@ export namespace Plugin {
|
||||
return result
|
||||
}
|
||||
|
||||
function publishPluginError(bus: Bus.Interface, message: string) {
|
||||
Effect.runFork(
|
||||
bus
|
||||
.publish(Session.Event.Error, { error: new NamedError.Unknown({ message }).toObject() })
|
||||
.pipe(Effect.provide(EffectLogger.layer)),
|
||||
)
|
||||
}
|
||||
|
||||
async function applyPlugin(load: PluginLoader.Loaded, input: PluginInput, hooks: Hooks[]) {
|
||||
const plugin = readV1Plugin(load.mod, load.spec, "server", "detect")
|
||||
if (plugin) {
|
||||
@@ -120,6 +112,11 @@ export namespace Plugin {
|
||||
const state = yield* InstanceState.make<State>(
|
||||
Effect.fn("Plugin.state")(function* (ctx) {
|
||||
const hooks: Hooks[] = []
|
||||
const bridge = yield* EffectBridge.make()
|
||||
|
||||
function publishPluginError(message: string) {
|
||||
bridge.fork(bus.publish(Session.Event.Error, { error: new NamedError.Unknown({ message }).toObject() }))
|
||||
}
|
||||
|
||||
const { Server } = yield* Effect.promise(() => import("../server/server"))
|
||||
|
||||
@@ -187,24 +184,24 @@ export namespace Plugin {
|
||||
if (stage === "install") {
|
||||
const parsed = parsePluginSpecifier(spec)
|
||||
log.error("failed to install plugin", { pkg: parsed.pkg, version: parsed.version, error: message })
|
||||
publishPluginError(bus, `Failed to install plugin ${parsed.pkg}@${parsed.version}: ${message}`)
|
||||
publishPluginError(`Failed to install plugin ${parsed.pkg}@${parsed.version}: ${message}`)
|
||||
return
|
||||
}
|
||||
|
||||
if (stage === "compatibility") {
|
||||
log.warn("plugin incompatible", { path: spec, error: message })
|
||||
publishPluginError(bus, `Plugin ${spec} skipped: ${message}`)
|
||||
publishPluginError(`Plugin ${spec} skipped: ${message}`)
|
||||
return
|
||||
}
|
||||
|
||||
if (stage === "entry") {
|
||||
log.error("failed to resolve plugin server entry", { path: spec, error: message })
|
||||
publishPluginError(bus, `Failed to load plugin ${spec}: ${message}`)
|
||||
publishPluginError(`Failed to load plugin ${spec}: ${message}`)
|
||||
return
|
||||
}
|
||||
|
||||
log.error("failed to load plugin", { path: spec, target: resolved?.entry, error: message })
|
||||
publishPluginError(bus, `Failed to load plugin ${spec}: ${message}`)
|
||||
publishPluginError(`Failed to load plugin ${spec}: ${message}`)
|
||||
},
|
||||
},
|
||||
}),
|
||||
|
||||
@@ -19,7 +19,7 @@ import { iife } from "@/util/iife"
|
||||
import { Global } from "../global"
|
||||
import path from "path"
|
||||
import { Effect, Layer, Context } from "effect"
|
||||
import { EffectLogger } from "@/effect/logger"
|
||||
import { EffectBridge } from "@/effect/bridge"
|
||||
import { InstanceState } from "@/effect/instance-state"
|
||||
import { AppFileSystem } from "@/filesystem"
|
||||
import { isRecord } from "@/util/record"
|
||||
@@ -1043,6 +1043,7 @@ export namespace Provider {
|
||||
const state = yield* InstanceState.make<State>(() =>
|
||||
Effect.gen(function* () {
|
||||
using _ = log.time("state")
|
||||
const bridge = yield* EffectBridge.make()
|
||||
const cfg = yield* config.get()
|
||||
const modelsDev = yield* Effect.promise(() => ModelsDev.get())
|
||||
const database = mapValues(modelsDev, fromModelsDevProvider)
|
||||
@@ -1223,8 +1224,7 @@ export namespace Provider {
|
||||
|
||||
const options = yield* Effect.promise(() =>
|
||||
plugin.auth!.loader!(
|
||||
() =>
|
||||
Effect.runPromise(auth.get(providerID).pipe(Effect.orDie, Effect.provide(EffectLogger.layer))) as any,
|
||||
() => bridge.promise(auth.get(providerID).pipe(Effect.orDie)) as any,
|
||||
database[plugin.auth!.provider],
|
||||
),
|
||||
)
|
||||
|
||||
@@ -10,7 +10,7 @@ import { Shell } from "@/shell/shell"
|
||||
import { Plugin } from "@/plugin"
|
||||
import { PtyID } from "./schema"
|
||||
import { Effect, Layer, Context } from "effect"
|
||||
import { EffectLogger } from "@/effect/logger"
|
||||
import { EffectBridge } from "@/effect/bridge"
|
||||
|
||||
export namespace Pty {
|
||||
const log = Log.create({ service: "pty" })
|
||||
@@ -173,6 +173,7 @@ export namespace Pty {
|
||||
|
||||
const create = Effect.fn("Pty.create")(function* (input: CreateInput) {
|
||||
const s = yield* InstanceState.get(state)
|
||||
const bridge = yield* EffectBridge.make()
|
||||
const id = PtyID.ascending()
|
||||
const command = input.command || Shell.preferred()
|
||||
const args = input.args || []
|
||||
@@ -256,8 +257,8 @@ export namespace Pty {
|
||||
if (session.info.status === "exited") return
|
||||
log.info("session exited", { id, exitCode })
|
||||
session.info.status = "exited"
|
||||
Effect.runFork(bus.publish(Event.Exited, { id, exitCode }).pipe(Effect.provide(EffectLogger.layer)))
|
||||
Effect.runFork(remove(id).pipe(Effect.provide(EffectLogger.layer)))
|
||||
bridge.fork(bus.publish(Event.Exited, { id, exitCode }))
|
||||
bridge.fork(remove(id))
|
||||
}),
|
||||
)
|
||||
yield* bus.publish(Event.Created, { info })
|
||||
|
||||
@@ -3,88 +3,33 @@ import { memoMap } from "@/effect/run-service"
|
||||
import { Question } from "@/question"
|
||||
import { QuestionID } from "@/question/schema"
|
||||
import { lazy } from "@/util/lazy"
|
||||
import { Effect, Layer, Schema } from "effect"
|
||||
import { makeQuestionHandler, questionApi } from "@opencode-ai/server"
|
||||
import { Effect, Layer } from "effect"
|
||||
import { HttpRouter, HttpServer } from "effect/unstable/http"
|
||||
import { HttpApi, HttpApiBuilder, HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
|
||||
import { HttpApiBuilder } from "effect/unstable/httpapi"
|
||||
import type { Handler } from "hono"
|
||||
|
||||
const root = "/experimental/httpapi/question"
|
||||
const Reply = Schema.Struct({
|
||||
answers: Schema.Array(Question.Answer).annotate({
|
||||
description: "User answers in order of questions (each answer is an array of selected labels)",
|
||||
}),
|
||||
})
|
||||
|
||||
const Api = HttpApi.make("question")
|
||||
.add(
|
||||
HttpApiGroup.make("question")
|
||||
.add(
|
||||
HttpApiEndpoint.get("list", root, {
|
||||
success: Schema.Array(Question.Request),
|
||||
}).annotateMerge(
|
||||
OpenApi.annotations({
|
||||
identifier: "question.list",
|
||||
summary: "List pending questions",
|
||||
description: "Get all pending question requests across all sessions.",
|
||||
}),
|
||||
),
|
||||
HttpApiEndpoint.post("reply", `${root}/:requestID/reply`, {
|
||||
params: { requestID: QuestionID },
|
||||
payload: Reply,
|
||||
success: Schema.Boolean,
|
||||
}).annotateMerge(
|
||||
OpenApi.annotations({
|
||||
identifier: "question.reply",
|
||||
summary: "Reply to question request",
|
||||
description: "Provide answers to a question request from the AI assistant.",
|
||||
}),
|
||||
),
|
||||
)
|
||||
.annotateMerge(
|
||||
OpenApi.annotations({
|
||||
title: "question",
|
||||
description: "Experimental HttpApi question routes.",
|
||||
}),
|
||||
),
|
||||
)
|
||||
.annotateMerge(
|
||||
OpenApi.annotations({
|
||||
title: "opencode experimental HttpApi",
|
||||
version: "0.0.1",
|
||||
description: "Experimental HttpApi surface for selected instance routes.",
|
||||
}),
|
||||
)
|
||||
|
||||
const QuestionLive = HttpApiBuilder.group(
|
||||
Api,
|
||||
"question",
|
||||
Effect.fn("QuestionHttpApi.handlers")(function* (handlers) {
|
||||
const QuestionLive = makeQuestionHandler({
|
||||
list: Effect.fn("QuestionHttpApi.host.list")(function* () {
|
||||
const svc = yield* Question.Service
|
||||
|
||||
const list = Effect.fn("QuestionHttpApi.list")(function* () {
|
||||
return yield* svc.list()
|
||||
})
|
||||
|
||||
const reply = Effect.fn("QuestionHttpApi.reply")(function* (ctx: {
|
||||
params: { requestID: QuestionID }
|
||||
payload: Schema.Schema.Type<typeof Reply>
|
||||
}) {
|
||||
yield* svc.reply({
|
||||
requestID: ctx.params.requestID,
|
||||
answers: ctx.payload.answers,
|
||||
})
|
||||
return true
|
||||
})
|
||||
|
||||
return handlers.handle("list", list).handle("reply", reply)
|
||||
return yield* svc.list()
|
||||
}),
|
||||
).pipe(Layer.provide(Question.defaultLayer))
|
||||
reply: Effect.fn("QuestionHttpApi.host.reply")(function* (input) {
|
||||
const svc = yield* Question.Service
|
||||
yield* svc.reply({
|
||||
requestID: QuestionID.make(input.requestID),
|
||||
answers: input.answers,
|
||||
})
|
||||
}),
|
||||
}).pipe(Layer.provide(Question.defaultLayer))
|
||||
|
||||
const web = lazy(() =>
|
||||
HttpRouter.toWebHandler(
|
||||
Layer.mergeAll(
|
||||
AppLayer,
|
||||
HttpApiBuilder.layer(Api, { openapiPath: `${root}/doc` }).pipe(
|
||||
HttpApiBuilder.layer(questionApi, { openapiPath: `${root}/doc` }).pipe(
|
||||
Layer.provide(QuestionLive),
|
||||
Layer.provide(HttpServer.layerServices),
|
||||
),
|
||||
|
||||
@@ -48,6 +48,7 @@ import { EffectLogger } from "@/effect/logger"
|
||||
import { InstanceState } from "@/effect/instance-state"
|
||||
import { TaskTool, type TaskPromptOps } from "@/tool/task"
|
||||
import { SessionRunState } from "./run-state"
|
||||
import { EffectBridge } from "@/effect/bridge"
|
||||
|
||||
// @ts-ignore
|
||||
globalThis.AI_SDK_LOG_WARNINGS = false
|
||||
@@ -105,11 +106,7 @@ export namespace SessionPrompt {
|
||||
const sys = yield* SystemPrompt.Service
|
||||
const llm = yield* LLM.Service
|
||||
const runner = Effect.fn("SessionPrompt.runner")(function* () {
|
||||
const ctx = yield* Effect.context()
|
||||
return {
|
||||
promise: <A, E>(effect: Effect.Effect<A, E>) => Effect.runPromiseWith(ctx)(effect),
|
||||
fork: <A, E>(effect: Effect.Effect<A, E>) => Effect.runForkWith(ctx)(effect),
|
||||
}
|
||||
return yield* EffectBridge.make()
|
||||
})
|
||||
const ops = Effect.fn("SessionPrompt.ops")(function* () {
|
||||
const run = yield* runner()
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { expect, test } from "bun:test"
|
||||
import { Context, Effect, Layer, Logger } from "effect"
|
||||
import { AppRuntime } from "../../src/effect/app-runtime"
|
||||
import { EffectBridge } from "../../src/effect/bridge"
|
||||
import { InstanceRef } from "../../src/effect/instance-ref"
|
||||
import { EffectLogger } from "../../src/effect/logger"
|
||||
import { makeRuntime } from "../../src/effect/run-service"
|
||||
@@ -59,3 +60,33 @@ test("AppRuntime attaches InstanceRef from ALS", async () => {
|
||||
|
||||
expect(dir).toBe(tmp.path)
|
||||
})
|
||||
|
||||
test("EffectBridge preserves logger and instance context across async boundaries", async () => {
|
||||
await using tmp = await tmpdir({ git: true })
|
||||
|
||||
const result = await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: () =>
|
||||
AppRuntime.runPromise(
|
||||
Effect.gen(function* () {
|
||||
const bridge = yield* EffectBridge.make()
|
||||
return yield* Effect.promise(() =>
|
||||
Promise.resolve().then(() =>
|
||||
bridge.promise(
|
||||
Effect.gen(function* () {
|
||||
return {
|
||||
directory: (yield* InstanceRef)?.directory,
|
||||
...check(yield* Effect.service(Logger.CurrentLoggers)),
|
||||
}
|
||||
}),
|
||||
),
|
||||
),
|
||||
)
|
||||
}),
|
||||
),
|
||||
})
|
||||
|
||||
expect(result.directory).toBe(tmp.path)
|
||||
expect(result.effectLogger).toBe(true)
|
||||
expect(result.defaultLogger).toBe(false)
|
||||
})
|
||||
|
||||
@@ -9,16 +9,22 @@
|
||||
"./openapi": "./src/openapi.ts",
|
||||
"./definition": "./src/definition/index.ts",
|
||||
"./definition/api": "./src/definition/api.ts",
|
||||
"./api": "./src/api/index.ts"
|
||||
"./definition/question": "./src/definition/question.ts",
|
||||
"./api": "./src/api/index.ts",
|
||||
"./api/question": "./src/api/question.ts"
|
||||
},
|
||||
"files": [
|
||||
"dist"
|
||||
],
|
||||
"scripts": {
|
||||
"typecheck": "tsc --noEmit",
|
||||
"typecheck": "tsgo --noEmit",
|
||||
"build": "tsc"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@typescript/native-preview": "catalog:",
|
||||
"typescript": "catalog:"
|
||||
},
|
||||
"dependencies": {
|
||||
"effect": "catalog:"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1 +1,2 @@
|
||||
export {}
|
||||
export { makeQuestionHandler } from "./question.js"
|
||||
export type { QuestionOps } from "./question.js"
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
import { Effect, Schema } from "effect"
|
||||
import { HttpApiBuilder } from "effect/unstable/httpapi"
|
||||
import { QuestionReply, QuestionRequest, questionApi } from "../definition/question.js"
|
||||
|
||||
export interface QuestionOps<R = never> {
|
||||
readonly list: () => Effect.Effect<ReadonlyArray<unknown>, never, R>
|
||||
readonly reply: (input: {
|
||||
requestID: string
|
||||
answers: Schema.Schema.Type<typeof QuestionReply>["answers"]
|
||||
}) => Effect.Effect<void, never, R>
|
||||
}
|
||||
|
||||
export const makeQuestionHandler = <R>(ops: QuestionOps<R>) =>
|
||||
HttpApiBuilder.group(
|
||||
questionApi,
|
||||
"question",
|
||||
Effect.fn("QuestionHttpApi.handlers")(function* (handlers) {
|
||||
const decode = Schema.decodeUnknownSync(Schema.Array(QuestionRequest))
|
||||
|
||||
const list = Effect.fn("QuestionHttpApi.list")(function* () {
|
||||
return decode(yield* ops.list())
|
||||
})
|
||||
|
||||
const reply = Effect.fn("QuestionHttpApi.reply")(function* (ctx: {
|
||||
params: { requestID: string }
|
||||
payload: Schema.Schema.Type<typeof QuestionReply>
|
||||
}) {
|
||||
yield* ops.reply({
|
||||
requestID: ctx.params.requestID,
|
||||
answers: ctx.payload.answers,
|
||||
})
|
||||
return true
|
||||
})
|
||||
|
||||
return handlers.handle("list", list).handle("reply", reply)
|
||||
}),
|
||||
)
|
||||
@@ -1,6 +1,12 @@
|
||||
import type { ServerApi } from "../types.js"
|
||||
import { HttpApi, OpenApi } from "effect/unstable/httpapi"
|
||||
import { questionApi } from "./question.js"
|
||||
|
||||
export const api: ServerApi = {
|
||||
name: "opencode",
|
||||
groups: [],
|
||||
}
|
||||
export const api = HttpApi.make("opencode")
|
||||
.addHttpApi(questionApi)
|
||||
.annotateMerge(
|
||||
OpenApi.annotations({
|
||||
title: "opencode experimental HttpApi",
|
||||
version: "0.0.1",
|
||||
description: "Experimental HttpApi surface for selected instance routes.",
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -1 +1,2 @@
|
||||
export { api } from "./api.js"
|
||||
export { questionApi, QuestionReply, QuestionRequest } from "./question.js"
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
import { Schema } from "effect"
|
||||
import { HttpApi, HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
|
||||
|
||||
const root = "/experimental/httpapi/question"
|
||||
|
||||
// Temporary transport-local schemas until canonical question schemas move into packages/core.
|
||||
export const QuestionID = Schema.String.annotate({ identifier: "QuestionID" })
|
||||
export const SessionID = Schema.String.annotate({ identifier: "SessionID" })
|
||||
export const MessageID = Schema.String.annotate({ identifier: "MessageID" })
|
||||
|
||||
export class QuestionOption extends Schema.Class<QuestionOption>("QuestionOption")({
|
||||
label: Schema.String.annotate({
|
||||
description: "Display text (1-5 words, concise)",
|
||||
}),
|
||||
description: Schema.String.annotate({
|
||||
description: "Explanation of choice",
|
||||
}),
|
||||
}) {}
|
||||
|
||||
const base = {
|
||||
question: Schema.String.annotate({
|
||||
description: "Complete question",
|
||||
}),
|
||||
header: Schema.String.annotate({
|
||||
description: "Very short label (max 30 chars)",
|
||||
}),
|
||||
options: Schema.Array(QuestionOption).annotate({
|
||||
description: "Available choices",
|
||||
}),
|
||||
multiple: Schema.optional(Schema.Boolean).annotate({
|
||||
description: "Allow selecting multiple choices",
|
||||
}),
|
||||
}
|
||||
|
||||
export class QuestionInfo extends Schema.Class<QuestionInfo>("QuestionInfo")({
|
||||
...base,
|
||||
custom: Schema.optional(Schema.Boolean).annotate({
|
||||
description: "Allow typing a custom answer (default: true)",
|
||||
}),
|
||||
}) {}
|
||||
|
||||
export class QuestionTool extends Schema.Class<QuestionTool>("QuestionTool")({
|
||||
messageID: MessageID,
|
||||
callID: Schema.String,
|
||||
}) {}
|
||||
|
||||
export class QuestionRequest extends Schema.Class<QuestionRequest>("QuestionRequest")({
|
||||
id: QuestionID,
|
||||
sessionID: SessionID,
|
||||
questions: Schema.Array(QuestionInfo).annotate({
|
||||
description: "Questions to ask",
|
||||
}),
|
||||
tool: Schema.optional(QuestionTool),
|
||||
}) {}
|
||||
|
||||
export const QuestionAnswer = Schema.Array(Schema.String).annotate({ identifier: "QuestionAnswer" })
|
||||
|
||||
export class QuestionReply extends Schema.Class<QuestionReply>("QuestionReply")({
|
||||
answers: Schema.Array(QuestionAnswer).annotate({
|
||||
description: "User answers in order of questions (each answer is an array of selected labels)",
|
||||
}),
|
||||
}) {}
|
||||
|
||||
export const questionApi = HttpApi.make("question").add(
|
||||
HttpApiGroup.make("question")
|
||||
.add(
|
||||
HttpApiEndpoint.get("list", root, {
|
||||
success: Schema.Array(QuestionRequest),
|
||||
}).annotateMerge(
|
||||
OpenApi.annotations({
|
||||
identifier: "question.list",
|
||||
summary: "List pending questions",
|
||||
description: "Get all pending question requests across all sessions.",
|
||||
}),
|
||||
),
|
||||
HttpApiEndpoint.post("reply", `${root}/:requestID/reply`, {
|
||||
params: { requestID: QuestionID },
|
||||
payload: QuestionReply,
|
||||
success: Schema.Boolean,
|
||||
}).annotateMerge(
|
||||
OpenApi.annotations({
|
||||
identifier: "question.reply",
|
||||
summary: "Reply to question request",
|
||||
description: "Provide answers to a question request from the AI assistant.",
|
||||
}),
|
||||
),
|
||||
)
|
||||
.annotateMerge(
|
||||
OpenApi.annotations({
|
||||
title: "question",
|
||||
description: "Experimental HttpApi question routes.",
|
||||
}),
|
||||
),
|
||||
)
|
||||
@@ -1,3 +1,6 @@
|
||||
export { openapi } from "./openapi.js"
|
||||
export { makeQuestionHandler } from "./api/question.js"
|
||||
export { api } from "./definition/api.js"
|
||||
export { questionApi, QuestionReply, QuestionRequest } from "./definition/question.js"
|
||||
export type { OpenApiSpec, ServerApi } from "./types.js"
|
||||
export type { QuestionOps } from "./api/question.js"
|
||||
|
||||
@@ -1,14 +1,5 @@
|
||||
import { OpenApi } from "effect/unstable/httpapi"
|
||||
import { api } from "./definition/api.js"
|
||||
import type { OpenApiSpec } from "./types.js"
|
||||
|
||||
export function openapi(): OpenApiSpec {
|
||||
return {
|
||||
openapi: "3.1.1",
|
||||
info: {
|
||||
title: api.name,
|
||||
version: "0.0.0",
|
||||
description: "Contract-first server package scaffold.",
|
||||
},
|
||||
paths: {},
|
||||
}
|
||||
}
|
||||
export const openapi = (): OpenApiSpec => OpenApi.fromApi(api)
|
||||
|
||||
@@ -1,14 +1,5 @@
|
||||
export interface ServerApi {
|
||||
readonly name: string
|
||||
readonly groups: readonly string[]
|
||||
}
|
||||
import type { HttpApi, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
|
||||
|
||||
export interface OpenApiSpec {
|
||||
readonly openapi: string
|
||||
readonly info: {
|
||||
readonly title: string
|
||||
readonly version: string
|
||||
readonly description: string
|
||||
}
|
||||
readonly paths: Record<string, never>
|
||||
}
|
||||
export type ServerApi = HttpApi.HttpApi<string, HttpApiGroup.Any>
|
||||
|
||||
export type OpenApiSpec = OpenApi.OpenAPISpec
|
||||
|
||||
Reference in New Issue
Block a user