Compare commits

...

3 Commits

Author SHA1 Message Date
Dax Raad 2d26786bb3 chore: merge dev into canonical service API 2026-06-06 00:59:52 -04:00
Dax Raad 1720726220 fix(tui): derive diff hunk offsets 2026-06-05 21:01:50 -04:00
Dax Raad e485f53e59 refactor(server): canonicalize service API 2026-06-05 21:00:30 -04:00
83 changed files with 703 additions and 766 deletions
@@ -1,4 +1,5 @@
import { NodeHttpServer } from "@effect/platform-node"
import { PermissionSaved } from "@opencode-ai/core/permission/saved"
import { Context, Layer, Option } from "effect"
import * as Effect from "effect/Effect"
import { HttpRouter, HttpServer } from "effect/unstable/http"
@@ -34,6 +35,7 @@ function bind(hostname: string, port: number, password: string) {
return Layer.build(
HttpRouter.serve(createRoutes(password), { disableListenLog: true, disableLogger: true }).pipe(
Layer.provideMerge(NodeHttpServer.layer(() => createServer(), { port, host: hostname })),
Layer.provide(PermissionSaved.defaultLayer),
),
).pipe(Effect.map((context) => Context.get(context, HttpServer.HttpServer).address))
}
+13 -8
View File
@@ -5,6 +5,7 @@ import { ServerAuth } from "@opencode-ai/server/auth"
import { Context, Effect, FileSystem, Layer, Option, Schedule, Schema, Scope } from "effect"
import { HttpServer } from "effect/unstable/http"
import { randomBytes, randomUUID } from "crypto"
import { spawn } from "node:child_process"
import path from "path"
export interface Interface {
@@ -108,16 +109,20 @@ export const layer = Layer.effect(
const start = Effect.fn("cli.daemon.start")(function* () {
const existing = yield* healthy().pipe(Effect.option)
const found = Option.getOrUndefined(existing)
if (found?.version === InstallationVersion) return found.url
const compiled = path.basename(process.execPath).replace(/\.exe$/, "") !== "bun"
if (found?.version === InstallationVersion && compiled) return found.url
if (found) yield* stopProcess(found).pipe(Effect.ignore)
yield* Effect.sync(() => {
const compiled = path.basename(process.execPath).replace(/\.exe$/, "") !== "bun"
Bun.spawn([process.execPath, ...(compiled ? [] : [Bun.main]), "serve", "--register"], {
stdin: "ignore",
stdout: "ignore",
stderr: "ignore",
}).unref()
const entrypoint = compiled ? undefined : process.argv[1]
if (!compiled && entrypoint === undefined) return yield* Effect.fail(new Error("Failed to resolve CLI entrypoint"))
yield* Effect.try({
try: () => {
spawn(process.execPath, [...(entrypoint ? [entrypoint] : []), "serve", "--register"], {
detached: true,
stdio: "ignore",
}).unref()
},
catch: (cause) => new Error("Failed to start server", { cause }),
})
return yield* compatible().pipe(
+2 -5
View File
@@ -63,7 +63,7 @@ export type Editor = {
export interface Interface {
readonly transform: State.Interface<Data, Editor>["transform"]
readonly update: (update: State.Transform<Editor>) => Effect.Effect<void, never, Scope.Scope>
readonly update: State.Interface<Data, Editor>["update"]
readonly get: (id: ID) => Effect.Effect<Info | undefined>
readonly default: () => Effect.Effect<Info | undefined>
readonly resolve: (id?: ID | string) => Effect.Effect<Info | undefined>
@@ -113,10 +113,7 @@ export const layer = Layer.effect(
return Service.of({
transform: state.transform,
update: Effect.fn("AgentV2.update")(function* (update) {
const transform = yield* state.transform()
yield* transform(update)
}),
update: state.update,
get: Effect.fn("AgentV2.get")(function* (id) {
return state.get().agents.get(id)
}),
+1 -1
View File
@@ -203,7 +203,7 @@ export const layer = Layer.effect(
event.location?.directory === location.directory && event.location.workspaceID === location.workspaceID,
),
Stream.runForEach((event) =>
state.update((catalog) => plugin.triggerFor(event.data.id, "catalog.transform", catalog, {}), "plugin.added"),
state.mutate((catalog) => plugin.triggerFor(event.data.id, "catalog.transform", catalog, {}), "plugin.added"),
),
Effect.forkIn(scope, { startImmediately: true }),
)
+2
View File
@@ -27,6 +27,7 @@ export type Editor = {
export interface Interface {
readonly transform: State.Interface<Data, Editor>["transform"]
readonly update: State.Interface<Data, Editor>["update"]
readonly get: (name: string) => Effect.Effect<Info | undefined>
readonly list: () => Effect.Effect<Info[]>
}
@@ -54,6 +55,7 @@ export const layer = Layer.effect(
})
return Service.of({
update: state.update,
transform: state.transform,
get: Effect.fn("CommandV2.get")(function* (name) {
return state.get().commands.get(name)
@@ -6,6 +6,7 @@ import { Git } from "../git"
import { Location } from "../location"
import { ProjectV2 } from "../project"
import { SessionV2 } from "../session"
import { SessionExecution } from "../session/execution"
import { SessionEvent } from "../session/event"
import { SessionSchema } from "../session/schema"
import { AbsolutePath, RelativePath } from "../schema"
@@ -124,5 +125,6 @@ export const defaultLayer = layer.pipe(
Layer.provide(Git.defaultLayer),
Layer.provide(EventV2.defaultLayer),
Layer.provide(ProjectV2.defaultLayer),
Layer.provide(SessionExecution.noopLayer),
Layer.provide(SessionV2.defaultLayer),
)
+1 -1
View File
@@ -70,7 +70,7 @@ export const layer = Layer.effectDiscard(
return files.filter((file): file is File => file !== undefined)
})
yield* registry.contribute({
yield* registry.register({
key,
load: observe().pipe(
Effect.map((files) =>
+1 -2
View File
@@ -12,9 +12,8 @@ export const Plugin = PluginV2.define({
effect: Effect.gen(function* () {
const command = yield* CommandV2.Service
const location = yield* Location.Service
const transform = yield* command.transform()
yield* transform((editor) => {
command.update((editor) => {
editor.update("init", (command) => {
command.template = PROMPT_INITIALIZE.replace("${path}", location.project.directory)
command.description = "guided AGENTS.md setup"
+6 -14
View File
@@ -427,20 +427,12 @@ export const layer = Layer.effect(
}),
)
const DefaultDatabase = Database.defaultLayer
const DefaultEvents = EventV2.layer.pipe(Layer.provide(DefaultDatabase))
const DefaultProjector = SessionProjector.layer.pipe(Layer.provide(DefaultEvents), Layer.provide(DefaultDatabase))
const DefaultStore = SessionStore.layer.pipe(Layer.provide(DefaultDatabase))
export const defaultLayer = layer.pipe(
Layer.provide(
Layer.mergeAll(
DefaultDatabase,
DefaultEvents,
DefaultProjector,
DefaultStore,
SessionExecution.noopLayer,
ProjectV2.defaultLayer,
),
),
Layer.provide(SessionExecution.noopLayer),
Layer.provide(SessionStore.defaultLayer),
Layer.provide(SessionProjector.defaultLayer),
Layer.provide(EventV2.defaultLayer),
Layer.provide(Database.defaultLayer),
Layer.provide(ProjectV2.defaultLayer),
Layer.orDie,
)
@@ -34,3 +34,5 @@ export const layer = Layer.effect(
})
}),
)
export const defaultLayer = layer.pipe(Layer.provide(SessionStore.defaultLayer))
+2
View File
@@ -58,3 +58,5 @@ export const layer = Layer.effect(
})
}),
)
export const defaultLayer = layer.pipe(Layer.provide(Database.defaultLayer))
+13 -6
View File
@@ -4,7 +4,7 @@ import { Effect, Scope, Semaphore } from "effect"
import type { Draft, Objectish } from "immer"
/**
* A replayable contribution applied to an editor during rebuild.
* A replayable transform applied to an editor during rebuild.
*
* Transforms are intentionally synchronous and mutation-shaped: domain editors
* hide the draft representation while preserving concise plugin/config code.
@@ -39,15 +39,17 @@ export interface Interface<State extends Objectish, Editor> {
* registration order. Closing the owning Scope removes the slot and rebuilds.
*/
readonly transform: () => Effect.Effect<(transform: Transform<Editor>) => Effect.Effect<void>, never, Scope.Scope>
/** Registers and applies a replayable transform in the current Scope. */
readonly update: (update: Transform<Editor>) => Effect.Effect<void, never, Scope.Scope>
/**
* Mutates the current materialized state directly.
* Mutates the current materialized state directly, once.
*
* This is not replayable contribution state: a later rebuild starts again
* This is not replayable transform state: a later rebuild starts again
* from `initial()` plus active transforms, so direct edits must be reserved
* for current-state adjustments that are intentionally outside the transform
* fold.
*/
readonly update: (update: (editor: Editor) => Effect.Effect<void>, reason?: string) => Effect.Effect<void>
readonly mutate: (update: (editor: Editor) => Effect.Effect<void>, reason?: string) => Effect.Effect<void>
}
export function create<State extends Objectish, Editor>(options: Options<State, Editor>): Interface<State, Editor> {
@@ -69,7 +71,7 @@ export function create<State extends Objectish, Editor>(options: Options<State,
yield* commit(next)
}, semaphore.withPermit)
return {
const result: Interface<State, Editor> = {
get: () => state,
transform: Effect.fn("State.transform")(function* () {
const transform = { update: (_editor: Editor) => {} }
@@ -86,10 +88,15 @@ export function create<State extends Objectish, Editor>(options: Options<State,
yield* rebuild()
})
}),
update: Effect.fn("State.update")(function* (update, reason) {
update: Effect.fn("State.update")(function* (update) {
const transform = yield* result.transform()
yield* transform(update)
}),
mutate: Effect.fn("State.mutate")(function* (update, reason) {
const api = options.editor(state as Draft<State>)
yield* update(api)
if (options.finalize) yield* options.finalize(api, reason)
}, semaphore.withPermit),
}
return result
}
+1 -1
View File
@@ -36,7 +36,7 @@ const builtIns = Layer.effectDiscard(
}),
])
yield* registry.contribute({ key: SystemContext.Key.make("core/builtins"), load: Effect.succeed(context) })
yield* registry.register({ key: SystemContext.Key.make("core/builtins"), load: Effect.succeed(context) })
}),
)
+12 -12
View File
@@ -3,13 +3,13 @@ export * as SystemContextRegistry from "./registry"
import { Context, Effect, Layer, Ref, Scope } from "effect"
import { SystemContext } from "./index"
export interface Contribution {
export interface Entry {
readonly key: SystemContext.Key
readonly load: Effect.Effect<SystemContext.SystemContext>
}
export interface Interface {
readonly contribute: (contribution: Contribution) => Effect.Effect<void, never, Scope.Scope>
readonly register: (entry: Entry) => Effect.Effect<void, never, Scope.Scope>
readonly load: () => Effect.Effect<SystemContext.SystemContext>
}
@@ -18,27 +18,27 @@ export class Service extends Context.Service<Service, Interface>()("@opencode/v2
export const layer = Layer.effect(
Service,
Effect.gen(function* () {
const contributions = yield* Ref.make<ReadonlyArray<Contribution>>([])
const entries = yield* Ref.make<ReadonlyArray<Entry>>([])
return Service.of({
contribute: Effect.fn("SystemContextRegistry.contribute")(function* (contribution) {
register: Effect.fn("SystemContextRegistry.register")(function* (entry) {
yield* Effect.acquireRelease(
Ref.modify(contributions, (current) => {
if (current.some((item) => item.key === contribution.key)) return [false, current]
return [true, [...current, contribution]]
Ref.modify(entries, (current) => {
if (current.some((item) => item.key === entry.key)) return [false, current]
return [true, [...current, entry]]
}).pipe(
Effect.flatMap((added) =>
added ? Effect.void : Effect.die(`Duplicate system context contribution key: ${contribution.key}`),
added ? Effect.void : Effect.die(`Duplicate system context entry key: ${entry.key}`),
),
Effect.as(contribution),
Effect.as(entry),
),
(entry) => Ref.update(contributions, (current) => current.filter((item) => item !== entry)),
(entry) => Ref.update(entries, (current) => current.filter((item) => item !== entry)),
)
}),
load: Effect.fn("SystemContextRegistry.load")(function* () {
const current = (yield* Ref.get(contributions)).toSorted((a, b) => (a.key < b.key ? -1 : a.key > b.key ? 1 : 0))
const current = (yield* Ref.get(entries)).toSorted((a, b) => (a.key < b.key ? -1 : a.key > b.key ? 1 : 0))
return SystemContext.combine(
yield* Effect.forEach(current, (contribution) => contribution.load, { concurrency: "unbounded" }),
yield* Effect.forEach(current, (entry) => entry.load, { concurrency: "unbounded" }),
)
}),
})
+1 -1
View File
@@ -56,7 +56,7 @@ export const layer = Layer.effectDiscard(
const files = yield* FileMutation.Service
const fs = yield* FSUtil.Service
yield* registry.contribute((editor) =>
yield* registry.update((editor) =>
editor.set(name, {
tool: definition,
execute: ({ parameters, assertPermission }) => {
+1 -1
View File
@@ -119,7 +119,7 @@ export const layer = Layer.effectDiscard(
const resources = yield* ToolOutputStore.Service
const config = yield* Config.Service
yield* registry.contribute((editor) =>
yield* registry.update((editor) =>
editor.set(name, {
tool: definition,
outputPaths: (output) => (output.outputPath ? [output.outputPath] : []),
+2 -2
View File
@@ -15,7 +15,7 @@ import { WebSearchTool } from "./websearch"
import { WriteTool } from "./write"
/**
* Composes only the shipped Location-scoped built-in tool contributions.
* Composes only the shipped Location-scoped built-in tool transforms.
* Each tool retains its implementation and focused tests independently. Dynamic
* MCP and plugin tools later use separate scoped ToolRegistry transforms, while
* provider/model filtering belongs to a future materialization phase rather
@@ -25,7 +25,7 @@ import { WriteTool } from "./write"
* TODO: Port the remaining launch-follow-up leaves deliberately: edit fuzzy
* parity, task, LSP,
* repo_clone, repo_overview, plan_exit, and Rune/code mode. Keep MCP and plugin
* contributions separate from this static built-in list.
* transforms separate from this static built-in list.
*/
export const locationLayer = Layer.mergeAll(
ApplyPatchTool.layer,
+1 -1
View File
@@ -102,7 +102,7 @@ export const layer = Layer.effectDiscard(
const files = yield* FileMutation.Service
const fs = yield* FSUtil.Service
yield* registry.contribute((editor) =>
yield* registry.update((editor) =>
editor.set(name, {
tool: definition,
execute: ({ parameters, assertPermission }) => {
+1 -1
View File
@@ -56,7 +56,7 @@ export const layer = Layer.effectDiscard(
const filesystem = yield* FileSystem.Service
const search = yield* LocationSearch.Service
yield* registry.contribute((editor) =>
yield* registry.update((editor) =>
editor.set(name, {
tool: definition,
execute: ({ parameters, assertPermission }) =>
+1 -1
View File
@@ -71,7 +71,7 @@ export const layer = Layer.effectDiscard(
const filesystem = yield* FileSystem.Service
const search = yield* LocationSearch.Service
yield* registry.contribute((editor) =>
yield* registry.update((editor) =>
editor.set(name, {
tool: definition,
execute: ({ parameters, assertPermission }) =>
+1 -1
View File
@@ -54,7 +54,7 @@ export const layer = Layer.effectDiscard(
const registry = yield* ToolRegistry.Service
const question = yield* QuestionV2.Service
yield* registry.contribute((editor) =>
yield* registry.update((editor) =>
editor.set(name, {
tool: definition,
execute: ({ parameters, sessionID, source }) =>
+1 -1
View File
@@ -88,7 +88,7 @@ export const layer = Layer.effectDiscard(
}).pipe(Effect.andThen(() => Effect.promise(() => import("@silvia-odwyer/photon-node")))),
)
yield* registry.contribute((editor) =>
yield* registry.update((editor) =>
editor.set(name, {
tool: definition,
execute: ({ parameters, assertPermission }) => {
+2 -5
View File
@@ -77,7 +77,7 @@ export type Editor = {
export interface Interface {
readonly transform: State.Interface<Data, Editor>["transform"]
readonly contribute: (update: State.Transform<Editor>) => Effect.Effect<void, never, Scope.Scope>
readonly update: State.Interface<Data, Editor>["update"]
readonly definitions: () => Effect.Effect<ReadonlyArray<ReturnType<typeof Tool.toDefinitions>[number]>>
readonly execute: (input: ExecuteInput) => Effect.Effect<ToolResultValue>
readonly settle: (input: ExecuteInput) => Effect.Effect<Settlement>
@@ -213,10 +213,7 @@ export const layer = Layer.effect(
return Service.of({
transform: state.transform,
contribute: Effect.fn("ToolRegistry.contribute")(function* (update) {
const transform = yield* state.transform()
yield* transform(update)
}),
update: state.update,
definitions,
execute,
settle,
+1 -1
View File
@@ -70,7 +70,7 @@ export const layer = Layer.effectDiscard(
toModelOutput: ({ output }) => [toolText({ type: "text", text: output.output })],
})
yield* registry.contribute((editor) =>
yield* registry.update((editor) =>
editor.set(name, {
tool: definition,
outputPaths: (output) => (output.outputPath ? [output.outputPath] : []),
+1 -1
View File
@@ -31,7 +31,7 @@ export const layer = Layer.effectDiscard(
const registry = yield* ToolRegistry.Service
const todos = yield* SessionTodo.Service
yield* registry.contribute((editor) =>
yield* registry.update((editor) =>
editor.set(name, {
tool: definition,
execute: ({ parameters, sessionID, assertPermission }) =>
+1 -1
View File
@@ -138,7 +138,7 @@ export const layer = Layer.effectDiscard(
const http = yield* HttpClient.HttpClient
const resources = yield* ToolOutputStore.Service
yield* registry.contribute((editor) =>
yield* registry.update((editor) =>
editor.set(name, {
tool: definition,
outputPaths: (output) => (output.outputPath ? [output.outputPath] : []),
+1 -1
View File
@@ -196,7 +196,7 @@ export const layer = Layer.effectDiscard(
const config = yield* ConfigService
const resources = yield* ToolOutputStore.Service
yield* registry.contribute((editor) =>
yield* registry.update((editor) =>
editor.set(name, {
tool: definition,
outputPaths: (output) => (output.outputPath ? [output.outputPath] : []),
+1 -1
View File
@@ -55,7 +55,7 @@ export const layer = Layer.effectDiscard(
const mutation = yield* LocationMutation.Service
const files = yield* FileMutation.Service
yield* registry.contribute((editor) =>
yield* registry.update((editor) =>
editor.set(name, {
tool: definition,
execute: ({ parameters, assertPermission }) =>
+1 -1
View File
@@ -59,7 +59,7 @@ describe("AgentV2", () => {
}),
)
it.effect("removes a transform contribution when its scope closes", () =>
it.effect("removes a transform when its scope closes", () =>
Effect.gen(function* () {
const agent = yield* AgentV2.Service
const id = AgentV2.ID.make("scoped")
+1 -1
View File
@@ -45,7 +45,7 @@ describe("PluginV2", () => {
}),
)
it.effect("serializes same-ID additions and leaves one removable contribution", () =>
it.effect("serializes same-ID additions and leaves one removable attachment", () =>
Effect.gen(function* () {
const values = state()
const layerScope = yield* Scope.fork(yield* Scope.Scope)
+2 -2
View File
@@ -132,7 +132,7 @@ const registry = ToolRegistry.layer.pipe(
const agents = AgentV2.layer
const echo = Layer.effectDiscard(
ToolRegistry.Service.use((registry) =>
registry.contribute((editor) => {
registry.update((editor) => {
;(editor.set("echo", {
authorize: (input) =>
Effect.sync(() => {
@@ -181,7 +181,7 @@ const skillBaselines = new Map<AgentV2.ID, string>()
const systemContext = Layer.effectDiscard(
SystemContextRegistry.Service.pipe(
Effect.flatMap((registry) =>
registry.contribute({
registry.register({
key: systemContextKey,
load: Effect.sync(() =>
SystemContext.combine(
@@ -4,7 +4,7 @@ import { SystemContext } from "@opencode-ai/core/system-context"
import { SystemContextRegistry } from "@opencode-ai/core/system-context/registry"
import { testEffect } from "../lib/effect"
const contribution = (key: string, text: string, sourceKey = key) => ({
const entry = (key: string, text: string, sourceKey = key) => ({
key: SystemContext.Key.make(key),
load: Effect.succeed(
SystemContext.make({
@@ -20,7 +20,7 @@ const contribution = (key: string, text: string, sourceKey = key) => ({
const it = testEffect(SystemContextRegistry.layer)
describe("SystemContextRegistry", () => {
it.effect("loads empty system context when there are no contributions", () =>
it.effect("loads empty system context when there are no entries", () =>
Effect.gen(function* () {
const registry = yield* SystemContextRegistry.Service
@@ -28,21 +28,21 @@ describe("SystemContextRegistry", () => {
}),
)
it.effect("loads scoped contributions in stable key order", () =>
it.effect("loads scoped entries in stable key order", () =>
Effect.gen(function* () {
const registry = yield* SystemContextRegistry.Service
yield* registry.contribute(contribution("test/second", "second"))
yield* registry.contribute(contribution("test/first", "first"))
yield* registry.register(entry("test/second", "second"))
yield* registry.register(entry("test/first", "first"))
expect((yield* SystemContext.initialize(yield* registry.load())).baseline).toBe("first\n\nsecond")
}),
)
it.effect("re-evaluates contribution producers on each load", () =>
it.effect("re-evaluates entry producers on each load", () =>
Effect.gen(function* () {
const registry = yield* SystemContextRegistry.Service
let loads = 0
yield* registry.contribute({
yield* registry.register({
key: SystemContext.Key.make("test/dynamic"),
load: Effect.sync(() => {
loads++
@@ -57,11 +57,11 @@ describe("SystemContextRegistry", () => {
}),
)
it.effect("propagates contribution producer failures", () =>
it.effect("propagates entry producer failures", () =>
Effect.gen(function* () {
const registry = yield* SystemContextRegistry.Service
const failure = new Error("contribution failed")
yield* registry.contribute({ key: SystemContext.Key.make("test/failure"), load: Effect.die(failure) })
const failure = new Error("entry failed")
yield* registry.register({ key: SystemContext.Key.make("test/failure"), load: Effect.die(failure) })
const exit = yield* registry.load().pipe(Effect.exit)
@@ -70,11 +70,11 @@ describe("SystemContextRegistry", () => {
}),
)
it.effect("rejects duplicate source keys from separate contributions", () =>
it.effect("rejects duplicate source keys from separate entries", () =>
Effect.gen(function* () {
const registry = yield* SystemContextRegistry.Service
yield* registry.contribute(contribution("test/first", "first", "test/duplicate"))
yield* registry.contribute(contribution("test/second", "second", "test/duplicate"))
yield* registry.register(entry("test/first", "first", "test/duplicate"))
yield* registry.register(entry("test/second", "second", "test/duplicate"))
const exit = yield* registry.load().pipe(Effect.exit)
@@ -86,23 +86,23 @@ describe("SystemContextRegistry", () => {
}),
)
it.effect("rejects duplicate contribution keys", () =>
it.effect("rejects duplicate entry keys", () =>
Effect.gen(function* () {
const registry = yield* SystemContextRegistry.Service
yield* registry.contribute(contribution("test/duplicate", "first"))
yield* registry.register(entry("test/duplicate", "first"))
const exit = yield* registry.contribute(contribution("test/duplicate", "second", "test/other")).pipe(Effect.exit)
const exit = yield* registry.register(entry("test/duplicate", "second", "test/other")).pipe(Effect.exit)
expect(Exit.isFailure(exit)).toBe(true)
if (Exit.isFailure(exit)) expect(Cause.pretty(exit.cause)).toContain("Duplicate system context contribution key")
if (Exit.isFailure(exit)) expect(Cause.pretty(exit.cause)).toContain("Duplicate system context entry key")
}),
)
it.effect("removes a contribution when its owning scope closes", () =>
it.effect("removes an entry when its owning scope closes", () =>
Effect.gen(function* () {
const registry = yield* SystemContextRegistry.Service
const scope = yield* Scope.make()
yield* registry.contribute(contribution("test/scoped", "scoped")).pipe(Scope.provide(scope))
yield* registry.register(entry("test/scoped", "scoped")).pipe(Scope.provide(scope))
expect((yield* SystemContext.initialize(yield* registry.load())).baseline).toBe("scoped")
+1 -1
View File
@@ -142,7 +142,7 @@ function provideLive(directory: string, projectReferences = references({})) {
}
describe("GrepTool", () => {
it.effect("registers the grep contribution", () =>
it.effect("registers the grep transform", () =>
Effect.gen(function* () {
reset()
expect(yield* (yield* ToolRegistry.Service).definitions()).toMatchObject([{ name: "grep" }])
+1 -1
View File
@@ -86,7 +86,7 @@ describe("WebFetchTool helpers", () => {
})
})
describe("WebFetchTool contribution", () => {
describe("WebFetchTool transform", () => {
it.effect("registers and fetches an ordinary hostname HTTP URL without rewriting it", () =>
Effect.gen(function* () {
reset()
+1 -1
View File
@@ -136,7 +136,7 @@ const websearch = WebSearchTool.layer.pipe(
)
const it = testEffect(Layer.mergeAll(registry, permission, http, websearchConfig, resources, websearch))
describe("WebSearchTool contribution", () => {
describe("WebSearchTool transform", () => {
it.effect("registers websearch, asserts query permission, and calls Exa", () =>
Effect.gen(function* () {
requests.length = 0
@@ -281,11 +281,14 @@ function DiffViewer(props: { api: TuiPluginApi }) {
const node = diffNodeByFileIndex.get(entry.fileIndex)
if (!node || node.isDestroyed) return []
const contentY = patchScroll.scrollTop + node.y - patchScroll.viewport.y
return node.getHunkRowOffsets().map((row, hunkIndex) => ({
fileIndex: entry.fileIndex,
hunkIndex,
contentY: contentY + row,
}))
return node.diff
.split("\n")
.flatMap((line, row) => (line.startsWith("@@") ? [row] : []))
.map((row, hunkIndex) => ({
fileIndex: entry.fileIndex,
hunkIndex,
contentY: contentY + row,
}))
})
.sort((left, right) => left.contentY - right.contentY)
const selected = selectedHunk()
@@ -21,7 +21,7 @@ import { SessionApi } from "./groups/session"
import { SyncApi } from "./groups/sync"
import { TuiApi } from "./groups/tui"
import { WorkspaceApi } from "./groups/workspace"
import { V2Api } from "@opencode-ai/server/api"
import { Api } from "@opencode-ai/server/api"
// GlobalEventSchema snapshots the registry after event-producing groups register their variants.
import { GlobalApi } from "./groups/global"
import { Authorization } from "./middleware/authorization"
@@ -70,7 +70,7 @@ export const OpenCodeHttpApi = HttpApi.make("opencode")
.addHttpApi(RootHttpApi)
.addHttpApi(EventApi)
.addHttpApi(InstanceHttpApi)
.addHttpApi(V2Api)
.addHttpApi(Api)
.addHttpApi(PtyConnectApi)
.annotate(HttpApi.AdditionalSchemas, [EventSchema, Question.Replied, Question.Rejected])
@@ -4,7 +4,7 @@ import { HttpEffect, HttpRouter, HttpServerRequest, HttpServerResponse } from "e
import { HttpApiError, HttpApiMiddleware } from "effect/unstable/httpapi"
import { hasPtyConnectTicketURL } from "@/server/shared/pty-ticket"
import { isPublicUIPath } from "@/server/shared/public-ui"
export { V2Authorization, v2AuthorizationLayer } from "@opencode-ai/server/middleware/authorization"
export { Authorization as ServerAuthorization, authorizationLayer as serverAuthorizationLayer } from "@opencode-ai/server/middleware/authorization"
const AUTH_TOKEN_QUERY = "auth_token"
const UNAUTHORIZED = 401
@@ -60,13 +60,13 @@ import { CorsConfig, isAllowedCorsOrigin, type CorsOptions } from "@/server/cors
import { serveUIEffect } from "@/server/shared/ui"
import { ServerAuth } from "@/server/auth"
import { InstanceHttpApi, RootHttpApi } from "./api"
import { V2Api } from "@opencode-ai/server/api"
import { Api } from "@opencode-ai/server/api"
import { PublicApi } from "./public"
import {
authorizationLayer,
authorizationRouterMiddleware,
ptyConnectAuthorizationLayer,
v2AuthorizationLayer,
serverAuthorizationLayer,
} from "./middleware/authorization"
import { EventApi } from "./groups/event"
import { PtyConnectApi } from "./groups/pty"
@@ -88,7 +88,7 @@ import { questionHandlers } from "./handlers/question"
import { sessionHandlers } from "./handlers/session"
import { syncHandlers } from "./handlers/sync"
import { tuiHandlers } from "./handlers/tui"
import { v2Handlers } from "@opencode-ai/server/handlers"
import { handlers } from "@opencode-ai/server/handlers"
import { schemaErrorLayer as v2SchemaErrorLayer } from "@opencode-ai/server/middleware/schema-error"
import { workspaceHandlers } from "./handlers/workspace"
import { instanceContextLayer } from "./middleware/instance-context"
@@ -121,7 +121,7 @@ const cors = (corsOptions?: CorsOptions) =>
const authOnlyRouterLayer = authorizationRouterMiddleware.layer.pipe(Layer.provide(ServerAuth.Config.defaultLayer))
const httpApiAuthLayer = authorizationLayer.pipe(Layer.provide(ServerAuth.Config.defaultLayer))
const ptyConnectHttpApiAuthLayer = ptyConnectAuthorizationLayer.pipe(Layer.provide(ServerAuth.Config.defaultLayer))
const v2HttpApiAuthLayer = v2AuthorizationLayer.pipe(Layer.provide(ServerAuth.Config.defaultLayer))
const serverHttpApiAuthLayer = serverAuthorizationLayer.pipe(Layer.provide(ServerAuth.Config.defaultLayer))
const workspaceRoutingLive = workspaceRoutingLayer.pipe(Layer.provide(Socket.layerWebSocketConstructorGlobal))
const rootApiRoutes = HttpApiBuilder.layer(RootHttpApi).pipe(
Layer.provide([controlHandlers, controlPlaneHandlers, globalHandlers]),
@@ -159,9 +159,9 @@ const instanceApiRoutes = HttpApiBuilder.layer(InstanceHttpApi).pipe(
const instanceRoutes = instanceApiRoutes.pipe(
Layer.provide([httpApiAuthLayer, workspaceRoutingLive, instanceContextLayer, schemaErrorLayer]),
)
const v2Routes = HttpApiBuilder.layer(V2Api).pipe(
Layer.provide(v2Handlers),
Layer.provide([v2HttpApiAuthLayer, v2SchemaErrorLayer]),
const serverRoutes = HttpApiBuilder.layer(Api).pipe(
Layer.provide(handlers),
Layer.provide([serverHttpApiAuthLayer, v2SchemaErrorLayer]),
)
// `OpenApi.fromApi` is non-trivial; defer until /doc is actually hit so
@@ -201,7 +201,7 @@ export function createRoutes(
eventApiRoutes,
ptyConnectApiRoutes,
instanceRoutes,
v2Routes,
serverRoutes,
docRoute,
uiRoute,
).pipe(
+2
View File
@@ -12,6 +12,7 @@ import { makeRuntime } from "@opencode-ai/core/effect/runtime"
import { EventV2Bridge } from "@/event-v2-bridge"
import { EventV2 } from "@opencode-ai/core/event"
import { SessionV2 } from "@opencode-ai/core/session"
import { SessionExecution } from "@opencode-ai/core/session/execution"
import { NotFoundError } from "@/storage/storage"
import { eq } from "drizzle-orm"
@@ -967,6 +968,7 @@ export const defaultLayer = layer.pipe(
Layer.provide(BackgroundJob.defaultLayer),
Layer.provide(Database.defaultLayer),
Layer.provide(EventV2Bridge.defaultLayer),
Layer.provide(SessionExecution.noopLayer),
Layer.provide(SessionV2.defaultLayer),
Layer.provide(RuntimeFlags.defaultLayer),
)
@@ -3,7 +3,7 @@ import { expect, test } from "bun:test"
import path from "path"
import { mkdir } from "fs/promises"
import { createDefaultOpenTuiKeymap } from "@opentui/keymap/opentui"
import type { DiffRenderable, Renderable, ScrollBoxRenderable } from "@opentui/core"
import type { Renderable, ScrollBoxRenderable } from "@opentui/core"
import { testRender, useRenderer } from "@opentui/solid"
import { Global } from "@opencode-ai/core/global"
import type { TuiPluginApi, TuiPluginMeta, TuiRouteCurrent, TuiRouteDefinition } from "@opencode-ai/plugin/tui"
@@ -68,8 +68,6 @@ test("brackets navigate diff hunks", async () => {
await viewer.app.waitFor(() => Boolean(findRenderable(viewer.app.renderer.root, "diff-viewer-patches")))
await viewer.app.flush()
const scroll = findRenderable(viewer.app.renderer.root, "diff-viewer-patches") as ScrollBoxRenderable
const diff = findRenderable(viewer.app.renderer.root, "diff-viewer-patch-0") as DiffRenderable
expect(diff.getHunkRowOffsets()).toEqual([0, 4, 8])
const initial = scroll.scrollTop
expect(TuiKeybind.defaultValue("diff_next_hunk")).toBe("]")
@@ -7,8 +7,8 @@ import { ServerAuth } from "../../src/server/auth"
import {
Authorization,
authorizationLayer,
V2Authorization,
v2AuthorizationLayer,
ServerAuthorization,
serverAuthorizationLayer,
} from "../../src/server/routes/instance/httpapi/middleware/authorization"
import { testEffect } from "../lib/effect"
@@ -26,14 +26,14 @@ const Api = HttpApi.make("test-authorization").add(
.middleware(Authorization),
)
const V2Api = HttpApi.make("test-v2-authorization").add(
const ServerApi = HttpApi.make("test-server-authorization").add(
HttpApiGroup.make("test.v2")
.add(
HttpApiEndpoint.get("probe", "/api/probe", {
success: Schema.String,
}),
)
.middleware(V2Authorization),
.middleware(ServerAuthorization),
)
const handlers = HttpApiBuilder.group(Api, "test", (handlers) =>
@@ -42,7 +42,7 @@ const handlers = HttpApiBuilder.group(Api, "test", (handlers) =>
.handle("missing", () => Effect.fail(new HttpApiError.NotFound({}))),
)
const v2Handlers = HttpApiBuilder.group(V2Api, "test.v2", (handlers) =>
const serverHandlers = HttpApiBuilder.group(ServerApi, "test.v2", (handlers) =>
handlers.handle("probe", () => Effect.succeed("ok")),
)
@@ -52,7 +52,7 @@ const apiLayer = HttpRouter.serve(
).pipe(Layer.provideMerge(NodeHttpServer.layerTest))
const v2ApiLayer = HttpRouter.serve(
HttpApiBuilder.layer(V2Api).pipe(Layer.provide(v2Handlers), Layer.provide(v2AuthorizationLayer)),
HttpApiBuilder.layer(ServerApi).pipe(Layer.provide(serverHandlers), Layer.provide(serverAuthorizationLayer)),
{ disableListenLog: true, disableLogger: true },
).pipe(Layer.provideMerge(NodeHttpServer.layerTest))
@@ -24,7 +24,7 @@ import {
SessionPaths,
} from "../../src/server/routes/instance/httpapi/groups/session"
import { PtyPaths } from "../../src/server/routes/instance/httpapi/groups/pty"
import { MessagesQuery as V2MessagesQuery } from "@opencode-ai/server/groups/v2/message"
import { SessionMessagesQuery } from "@opencode-ai/server/groups/message"
import { QueryBoolean, QueryBooleanOpenApi } from "../../src/server/routes/instance/httpapi/groups/query"
import { resetDatabase } from "../fixture/db"
import { disposeAllInstances, tmpdir } from "../fixture/fixture"
@@ -54,7 +54,7 @@ const openApiDriftRoutes = [
{ method: "get", path: ExperimentalPaths.session, query: ExperimentalSessionListQuery },
{ method: "get", path: ExperimentalPaths.tool, query: ToolListQuery },
{ method: "get", path: InstancePaths.vcsDiff, query: VcsDiffQuery },
{ method: "get", path: "/api/session/:sessionID/message", query: V2MessagesQuery },
{ method: "get", path: "/api/session/:sessionID/message", query: SessionMessagesQuery },
] satisfies Array<{ method: Method; path: string; query: QuerySchema }>
const numericSdkQueryParams = [
@@ -22,6 +22,7 @@ import { MessageID, PartID, SessionID } from "../../src/session/schema"
import { SessionStatus } from "../../src/session/status"
import { SessionSummary } from "../../src/session/summary"
import { SessionV2 } from "@opencode-ai/core/session"
import { SessionExecution } from "@opencode-ai/core/session/execution"
import type { Provider } from "@/provider/provider"
import * as SessionProcessorModule from "../../src/session/processor"
@@ -615,6 +616,7 @@ describe("session.compaction.create", () => {
})
const v2 = yield* SessionV2.Service.use((svc) => svc.messages({ sessionID: info.id })).pipe(
Effect.provide(SessionExecution.noopLayer),
Effect.provide(SessionV2.defaultLayer),
)
expect(v2.at(-1)).toMatchObject({
@@ -40,6 +40,7 @@ import { SessionRunState } from "../../src/session/run-state"
import { MessageID, PartID, SessionID } from "../../src/session/schema"
import { SessionStatus } from "../../src/session/status"
import { SessionV2 } from "@opencode-ai/core/session"
import { SessionExecution } from "@opencode-ai/core/session/execution"
import { Skill } from "../../src/skill"
import { SystemPrompt } from "../../src/session/system"
import { Shell } from "../../src/shell/shell"
@@ -567,6 +568,7 @@ noLLMServer.instance.skip(
})
const messages = yield* SessionV2.Service.use((session) => session.messages({ sessionID: chat.id })).pipe(
Effect.provide(SessionExecution.noopLayer),
Effect.provide(SessionV2.defaultLayer),
)
const { db } = yield* Database.Service
+16 -17
View File
@@ -1,19 +1,20 @@
import { HttpApi, OpenApi } from "effect/unstable/httpapi"
import { SchemaErrorMiddleware } from "./middleware/schema-error"
import { MessageGroup } from "./groups/v2/message"
import { ModelGroup } from "./groups/v2/model"
import { ProviderGroup } from "./groups/v2/provider"
import { SessionGroup } from "./groups/v2/session"
import { PermissionGroup, PermissionSavedGroup, SessionPermissionGroup } from "./groups/v2/permission"
import { FileSystemGroup } from "./groups/v2/fs"
import { CommandGroup } from "./groups/v2/command"
import { SkillGroup } from "./groups/v2/skill"
import { EventGroup } from "./groups/v2/event"
import { AgentGroup } from "./groups/v2/agent"
import { HealthGroup } from "./groups/v2/health"
import { QuestionGroup, SessionQuestionGroup } from "./groups/v2/question"
import { MessageGroup } from "./groups/message"
import { ModelGroup } from "./groups/model"
import { ProviderGroup } from "./groups/provider"
import { SessionGroup } from "./groups/session"
import { PermissionGroup } from "./groups/permission"
import { FileSystemGroup } from "./groups/fs"
import { CommandGroup } from "./groups/command"
import { SkillGroup } from "./groups/skill"
import { EventGroup } from "./groups/event"
import { AgentGroup } from "./groups/agent"
import { HealthGroup } from "./groups/health"
import { QuestionGroup } from "./groups/question"
import { Authorization } from "./middleware/authorization"
export const V2Api = HttpApi.make("v2")
export const Api = HttpApi.make("server")
.add(HealthGroup)
.add(AgentGroup)
.add(SessionGroup)
@@ -21,19 +22,17 @@ export const V2Api = HttpApi.make("v2")
.add(ModelGroup)
.add(ProviderGroup)
.add(PermissionGroup)
.add(SessionPermissionGroup)
.add(PermissionSavedGroup)
.add(FileSystemGroup)
.add(CommandGroup)
.add(SkillGroup)
.add(EventGroup)
.add(QuestionGroup)
.add(SessionQuestionGroup)
.annotateMerge(
OpenApi.annotations({
title: "opencode experimental HttpApi",
title: "opencode HttpApi",
version: "0.0.1",
description: "Experimental HttpApi surface for selected instance routes.",
}),
)
.middleware(Authorization)
.middleware(SchemaErrorMiddleware)
@@ -2,12 +2,11 @@ import { AgentV2 } from "@opencode-ai/core/agent"
import { Location } from "@opencode-ai/core/location"
import { Schema } from "effect"
import { HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
import { V2Authorization } from "../../middleware/authorization"
import { LocationQuery, locationQueryOpenApi, V2LocationMiddleware } from "./location"
import { LocationQuery, locationQueryOpenApi, LocationMiddleware } from "./location"
export const AgentGroup = HttpApiGroup.make("v2.agent")
export const AgentGroup = HttpApiGroup.make("server.agent")
.add(
HttpApiEndpoint.get("agents", "/api/agent", {
HttpApiEndpoint.get("agent.list", "/api/agent", {
query: LocationQuery,
success: Location.response(Schema.Array(AgentV2.Info)),
})
@@ -15,10 +14,9 @@ export const AgentGroup = HttpApiGroup.make("v2.agent")
.annotateMerge(
OpenApi.annotations({
identifier: "v2.agent.list",
summary: "List v2 agents",
description: "Retrieve currently registered v2 agents.",
summary: "List agents",
description: "Retrieve currently registered agents.",
}),
),
)
.middleware(V2LocationMiddleware)
.middleware(V2Authorization)
.middleware(LocationMiddleware)
@@ -2,12 +2,11 @@ import { CommandV2 } from "@opencode-ai/core/command"
import { Location } from "@opencode-ai/core/location"
import { Schema } from "effect"
import { HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
import { V2Authorization } from "../../middleware/authorization"
import { LocationQuery, locationQueryOpenApi, V2LocationMiddleware } from "./location"
import { LocationQuery, locationQueryOpenApi, LocationMiddleware } from "./location"
export const CommandGroup = HttpApiGroup.make("v2.command")
export const CommandGroup = HttpApiGroup.make("server.command")
.add(
HttpApiEndpoint.get("commands", "/api/command", {
HttpApiEndpoint.get("command.list", "/api/command", {
query: LocationQuery,
success: Location.response(Schema.Array(CommandV2.Info)),
})
@@ -15,16 +14,15 @@ export const CommandGroup = HttpApiGroup.make("v2.command")
.annotateMerge(
OpenApi.annotations({
identifier: "v2.command.list",
summary: "List v2 commands",
description: "Retrieve currently registered v2 commands.",
summary: "List commands",
description: "Retrieve currently registered commands.",
}),
),
)
.annotateMerge(
OpenApi.annotations({
title: "v2 commands",
description: "Experimental v2 command routes.",
title: "commands",
description: "Experimental command routes.",
}),
)
.middleware(V2LocationMiddleware)
.middleware(V2Authorization)
.middleware(LocationMiddleware)
@@ -2,8 +2,7 @@ import { EventV2 } from "@opencode-ai/core/event"
import { Location } from "@opencode-ai/core/location"
import { Schema } from "effect"
import { HttpApiEndpoint, HttpApiGroup, HttpApiSchema, OpenApi } from "effect/unstable/httpapi"
import { V2Authorization } from "../../middleware/authorization"
import { LocationQuery, locationQueryOpenApi, V2LocationMiddleware } from "./location"
import { LocationQuery, locationQueryOpenApi, LocationMiddleware } from "./location"
const Event = Schema.Struct({
id: EventV2.ID,
@@ -14,9 +13,9 @@ const Event = Schema.Struct({
data: Schema.Unknown,
})
export const EventGroup = HttpApiGroup.make("v2.event")
export const EventGroup = HttpApiGroup.make("server.event")
.add(
HttpApiEndpoint.get("events", "/api/event", {
HttpApiEndpoint.get("event.subscribe", "/api/event", {
query: LocationQuery,
success: Schema.String.pipe(HttpApiSchema.asText({ contentType: "text/event-stream" })),
})
@@ -24,13 +23,12 @@ export const EventGroup = HttpApiGroup.make("v2.event")
.annotateMerge(
OpenApi.annotations({
identifier: "v2.event.subscribe",
summary: "Subscribe to v2 events",
description: "Subscribe to native EventV2 payloads for a location.",
summary: "Subscribe to events",
description: "Subscribe to native event payloads for a location.",
}),
),
)
.annotateMerge(OpenApi.annotations({ title: "v2 events", description: "Experimental v2 event stream route." }))
.middleware(V2LocationMiddleware)
.middleware(V2Authorization)
.annotateMerge(OpenApi.annotations({ title: "events", description: "Experimental event stream route." }))
.middleware(LocationMiddleware)
export type Event = typeof Event.Type
@@ -3,8 +3,7 @@ import { Location } from "@opencode-ai/core/location"
import { RelativePath } from "@opencode-ai/core/schema"
import { Schema } from "effect"
import { HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
import { V2Authorization } from "../../middleware/authorization"
import { LocationQuery, locationQueryOpenApi, V2LocationMiddleware } from "./location"
import { LocationQuery, locationQueryOpenApi, LocationMiddleware } from "./location"
const ReadQuery = Schema.Struct({
...LocationQuery.fields,
@@ -18,9 +17,9 @@ const ListQuery = Schema.Struct({
reference: Schema.String.pipe(Schema.optional),
})
export const FileSystemGroup = HttpApiGroup.make("v2.fs")
export const FileSystemGroup = HttpApiGroup.make("server.fs")
.add(
HttpApiEndpoint.get("read", "/api/fs/read", {
HttpApiEndpoint.get("fs.read", "/api/fs/read", {
query: ReadQuery,
success: Location.response(FileSystem.Content),
})
@@ -34,7 +33,7 @@ export const FileSystemGroup = HttpApiGroup.make("v2.fs")
),
)
.add(
HttpApiEndpoint.get("list", "/api/fs/list", {
HttpApiEndpoint.get("fs.list", "/api/fs/list", {
query: ListQuery,
success: Location.response(Schema.Array(FileSystem.Entry)),
})
@@ -49,9 +48,8 @@ export const FileSystemGroup = HttpApiGroup.make("v2.fs")
)
.annotateMerge(
OpenApi.annotations({
title: "v2 filesystem",
description: "Experimental v2 location-scoped filesystem routes.",
title: "filesystem",
description: "Experimental location-scoped filesystem routes.",
}),
)
.middleware(V2LocationMiddleware)
.middleware(V2Authorization)
.middleware(LocationMiddleware)
+15
View File
@@ -0,0 +1,15 @@
import { Schema } from "effect"
import { HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
export const HealthGroup = HttpApiGroup.make("server.health")
.add(
HttpApiEndpoint.get("health.get", "/api/health", {
success: Schema.Struct({ healthy: Schema.Literal(true) }),
}).annotateMerge(
OpenApi.annotations({
identifier: "v2.health.get",
summary: "Check server health",
description: "Check whether the API server is ready to accept requests.",
}),
),
)
@@ -1,16 +1,8 @@
import { Catalog } from "@opencode-ai/core/catalog"
import { AgentV2 } from "@opencode-ai/core/agent"
import { CommandV2 } from "@opencode-ai/core/command"
import { Location } from "@opencode-ai/core/location"
import { LocationServiceMap } from "@opencode-ai/core/location-layer"
import { FileSystem } from "@opencode-ai/core/filesystem"
import { PermissionV2 } from "@opencode-ai/core/permission"
import { ProjectReference } from "@opencode-ai/core/project-reference"
import { SkillV2 } from "@opencode-ai/core/skill"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { PluginBoot } from "@opencode-ai/core/plugin/boot"
import { WorkspaceV2 } from "@opencode-ai/core/workspace"
import { QuestionV2 } from "@opencode-ai/core/question"
import { Effect, Layer, Schema } from "effect"
import { HttpServerRequest } from "effect/unstable/http"
import { HttpApiMiddleware, OpenApi } from "effect/unstable/httpapi"
@@ -22,7 +14,7 @@ export const LocationQuery = Schema.Struct({
workspace: Schema.optional(Schema.String),
}),
),
}).annotate({ identifier: "V2LocationQuery" })
}).annotate({ identifier: "LocationQuery" })
export const locationQueryOpenApi = OpenApi.annotations({
transform: (operation) => {
@@ -53,22 +45,14 @@ export function response<A, E, R>(data: Effect.Effect<A, E, R>) {
})
}
export class V2LocationMiddleware extends HttpApiMiddleware.Service<
V2LocationMiddleware,
export type LocationServices = Layer.Success<ReturnType<typeof LocationServiceMap.get>>
export class LocationMiddleware extends HttpApiMiddleware.Service<
LocationMiddleware,
{
provides:
| Catalog.Service
| AgentV2.Service
| CommandV2.Service
| Location.Service
| PluginBoot.Service
| PermissionV2.Service
| ProjectReference.Service
| FileSystem.Service
| SkillV2.Service
| QuestionV2.Service
provides: LocationServices
}
>()("@opencode/ExperimentalHttpApiV2Location") {}
>()("@opencode/HttpApiLocation") {}
function ref(request: HttpServerRequest.HttpServerRequest): Location.Ref {
const query = new URL(request.url, "http://localhost").searchParams
@@ -82,10 +66,10 @@ function ref(request: HttpServerRequest.HttpServerRequest): Location.Ref {
}
export const layer = Layer.effect(
V2LocationMiddleware,
LocationMiddleware,
Effect.gen(function* () {
const locations = yield* LocationServiceMap
return V2LocationMiddleware.of((effect) =>
return LocationMiddleware.of((effect) =>
Effect.gen(function* () {
const request = yield* HttpServerRequest.HttpServerRequest
return yield* effect.pipe(Effect.provide(locations.get(ref(request))))
@@ -2,10 +2,10 @@ import { SessionV2 } from "@opencode-ai/core/session"
import { SessionMessage } from "@opencode-ai/core/session/message"
import { Schema } from "effect"
import { HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
import { InvalidCursorError, SessionNotFoundError, UnknownError } from "../../errors"
import { V2Authorization } from "../../middleware/authorization"
import { InvalidCursorError, SessionNotFoundError, UnknownError } from "../errors"
import { SessionLocationMiddleware } from "../middleware/session-location"
export const MessagesQuery = Schema.Struct({
export const SessionMessagesQuery = Schema.Struct({
limit: Schema.optional(
Schema.NumberFromString.check(Schema.isInt(), Schema.isGreaterThanOrEqualTo(1), Schema.isLessThanOrEqualTo(200)),
).annotate({
@@ -20,34 +20,35 @@ export const MessagesQuery = Schema.Struct({
"Opaque pagination cursor returned as cursor.previous or cursor.next in the previous response. Do not combine with order.",
}),
),
}).annotate({ identifier: "V2SessionMessagesQuery" })
}).annotate({ identifier: "SessionMessagesQuery" })
export const MessageGroup = HttpApiGroup.make("v2.message")
export const MessageGroup = HttpApiGroup.make("server.message")
.add(
HttpApiEndpoint.get("messages", "/api/session/:sessionID/message", {
HttpApiEndpoint.get("session.messages", "/api/session/:sessionID/message", {
params: { sessionID: SessionV2.ID },
query: MessagesQuery,
query: SessionMessagesQuery,
success: Schema.Struct({
data: Schema.Array(SessionMessage.Message),
cursor: Schema.Struct({
previous: Schema.String.pipe(Schema.optional),
next: Schema.String.pipe(Schema.optional),
}),
}).annotate({ identifier: "V2SessionMessagesResponse" }),
}).annotate({ identifier: "SessionMessagesResponse" }),
error: [InvalidCursorError, SessionNotFoundError, UnknownError],
}).annotateMerge(
OpenApi.annotations({
identifier: "v2.session.messages",
summary: "Get v2 session messages",
description:
"Retrieve projected v2 messages for a session. Items keep the requested order across pages; use cursor.next or cursor.previous to move through the ordered timeline.",
}),
),
})
.middleware(SessionLocationMiddleware)
.annotateMerge(
OpenApi.annotations({
identifier: "v2.session.messages",
summary: "Get session messages",
description:
"Retrieve projected messages for a session. Items keep the requested order across pages; use cursor.next or cursor.previous to move through the ordered timeline.",
}),
),
)
.annotateMerge(
OpenApi.annotations({
title: "v2 messages",
description: "Experimental v2 message routes.",
title: "messages",
description: "Experimental message routes.",
}),
)
.middleware(V2Authorization)
@@ -2,13 +2,12 @@ import { ModelV2 } from "@opencode-ai/core/model"
import { Location } from "@opencode-ai/core/location"
import { Schema } from "effect"
import { HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
import { ServiceUnavailableError } from "../../errors"
import { V2Authorization } from "../../middleware/authorization"
import { LocationQuery, locationQueryOpenApi, V2LocationMiddleware } from "./location"
import { ServiceUnavailableError } from "../errors"
import { LocationQuery, locationQueryOpenApi, LocationMiddleware } from "./location"
export const ModelGroup = HttpApiGroup.make("v2.model")
export const ModelGroup = HttpApiGroup.make("server.model")
.add(
HttpApiEndpoint.get("models", "/api/model", {
HttpApiEndpoint.get("model.list", "/api/model", {
query: LocationQuery,
success: Location.response(Schema.Array(ModelV2.Info)),
error: ServiceUnavailableError,
@@ -17,16 +16,15 @@ export const ModelGroup = HttpApiGroup.make("v2.model")
.annotateMerge(
OpenApi.annotations({
identifier: "v2.model.list",
summary: "List v2 models",
description: "Retrieve available v2 models ordered by release date.",
summary: "List models",
description: "Retrieve available models ordered by release date.",
}),
),
)
.annotateMerge(
OpenApi.annotations({
title: "v2 models",
description: "Experimental v2 model routes.",
title: "models",
description: "Experimental model routes.",
}),
)
.middleware(V2LocationMiddleware)
.middleware(V2Authorization)
.middleware(LocationMiddleware)
@@ -5,13 +5,13 @@ import { ProjectV2 } from "@opencode-ai/core/project"
import { SessionV2 } from "@opencode-ai/core/session"
import { Schema } from "effect"
import { HttpApiEndpoint, HttpApiGroup, HttpApiSchema, OpenApi } from "effect/unstable/httpapi"
import { PermissionNotFoundError, SessionNotFoundError } from "../../errors"
import { V2Authorization } from "../../middleware/authorization"
import { LocationQuery, locationQueryOpenApi, V2LocationMiddleware } from "./location"
import { PermissionNotFoundError, SessionNotFoundError } from "../errors"
import { SessionLocationMiddleware } from "../middleware/session-location"
import { LocationQuery, locationQueryOpenApi, LocationMiddleware } from "./location"
export const PermissionGroup = HttpApiGroup.make("v2.permission")
export const PermissionGroup = HttpApiGroup.make("server.permission")
.add(
HttpApiEndpoint.get("permissionRequests", "/api/permission/request", {
HttpApiEndpoint.get("permission.request.list", "/api/permission/request", {
query: LocationQuery,
success: Location.response(Schema.Array(PermissionV2.Request)),
})
@@ -24,49 +24,8 @@ export const PermissionGroup = HttpApiGroup.make("v2.permission")
}),
),
)
.annotateMerge(OpenApi.annotations({ title: "v2 permissions", description: "Experimental v2 permission routes." }))
.middleware(V2LocationMiddleware)
.middleware(V2Authorization)
export const SessionPermissionGroup = HttpApiGroup.make("v2.session.permission")
.add(
HttpApiEndpoint.get("sessionPermissionRequests", "/api/session/:sessionID/permission/request", {
params: { sessionID: SessionV2.ID },
success: Schema.Struct({ data: Schema.Array(PermissionV2.Request) }),
error: SessionNotFoundError,
}).annotateMerge(
OpenApi.annotations({
identifier: "v2.session.permission.list",
summary: "List session permission requests",
description: "Retrieve pending permission requests owned by a session.",
}),
),
)
.add(
HttpApiEndpoint.post("permissionRequestReply", "/api/session/:sessionID/permission/request/:requestID/reply", {
params: { sessionID: SessionV2.ID, requestID: PermissionV2.ID },
payload: Schema.Struct({
reply: PermissionV2.Reply,
message: Schema.String.pipe(Schema.optional),
}),
success: HttpApiSchema.NoContent,
error: [SessionNotFoundError, PermissionNotFoundError],
}).annotateMerge(
OpenApi.annotations({
identifier: "v2.session.permission.reply",
summary: "Reply to pending permission request",
description: "Respond to a pending permission request owned by a session.",
}),
),
)
.annotateMerge(
OpenApi.annotations({ title: "v2 session permissions", description: "Experimental v2 session permission routes." }),
)
.middleware(V2Authorization)
export const PermissionSavedGroup = HttpApiGroup.make("v2.permission.saved")
.add(
HttpApiEndpoint.get("savedPermissions", "/api/permission/saved", {
HttpApiEndpoint.get("permission.saved.list", "/api/permission/saved", {
query: Schema.Struct({ projectID: ProjectV2.ID.pipe(Schema.optional) }),
success: Schema.Struct({ data: Schema.Array(PermissionSaved.Info) }),
}).annotateMerge(
@@ -78,7 +37,7 @@ export const PermissionSavedGroup = HttpApiGroup.make("v2.permission.saved")
),
)
.add(
HttpApiEndpoint.delete("removeSavedPermission", "/api/permission/saved/:id", {
HttpApiEndpoint.delete("permission.saved.remove", "/api/permission/saved/:id", {
params: { id: PermissionSaved.ID },
success: HttpApiSchema.NoContent,
}).annotateMerge(
@@ -89,7 +48,41 @@ export const PermissionSavedGroup = HttpApiGroup.make("v2.permission.saved")
}),
),
)
.annotateMerge(
OpenApi.annotations({ title: "v2 saved permissions", description: "Experimental v2 saved permission routes." }),
.middleware(LocationMiddleware)
.add(
HttpApiEndpoint.get("session.permission.list", "/api/session/:sessionID/permission", {
params: { sessionID: SessionV2.ID },
success: Schema.Struct({ data: Schema.Array(PermissionV2.Request) }),
error: SessionNotFoundError,
})
.middleware(SessionLocationMiddleware)
.annotateMerge(
OpenApi.annotations({
identifier: "v2.session.permission.list",
summary: "List session permission requests",
description: "Retrieve pending permission requests owned by a session.",
}),
),
)
.add(
HttpApiEndpoint.post("session.permission.reply", "/api/session/:sessionID/permission/:requestID/reply", {
params: { sessionID: SessionV2.ID, requestID: PermissionV2.ID },
payload: Schema.Struct({
reply: PermissionV2.Reply,
message: Schema.String.pipe(Schema.optional),
}),
success: HttpApiSchema.NoContent,
error: [SessionNotFoundError, PermissionNotFoundError],
})
.middleware(SessionLocationMiddleware)
.annotateMerge(
OpenApi.annotations({
identifier: "v2.session.permission.reply",
summary: "Reply to pending permission request",
description: "Respond to a pending permission request owned by a session.",
}),
),
)
.annotateMerge(
OpenApi.annotations({ title: "permissions", description: "Experimental permission routes." }),
)
.middleware(V2Authorization)
@@ -2,13 +2,12 @@ import { ProviderV2 } from "@opencode-ai/core/provider"
import { Location } from "@opencode-ai/core/location"
import { Schema } from "effect"
import { HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
import { ProviderNotFoundError, ServiceUnavailableError } from "../../errors"
import { V2Authorization } from "../../middleware/authorization"
import { LocationQuery, locationQueryOpenApi, V2LocationMiddleware } from "./location"
import { ProviderNotFoundError, ServiceUnavailableError } from "../errors"
import { LocationQuery, locationQueryOpenApi, LocationMiddleware } from "./location"
export const ProviderGroup = HttpApiGroup.make("v2.provider")
export const ProviderGroup = HttpApiGroup.make("server.provider")
.add(
HttpApiEndpoint.get("providers", "/api/provider", {
HttpApiEndpoint.get("provider.list", "/api/provider", {
query: LocationQuery,
success: Location.response(Schema.Array(ProviderV2.Info)),
error: ServiceUnavailableError,
@@ -17,13 +16,13 @@ export const ProviderGroup = HttpApiGroup.make("v2.provider")
.annotateMerge(
OpenApi.annotations({
identifier: "v2.provider.list",
summary: "List v2 providers",
description: "Retrieve active v2 AI providers so clients can show provider availability and configuration.",
summary: "List providers",
description: "Retrieve active AI providers so clients can show provider availability and configuration.",
}),
),
)
.add(
HttpApiEndpoint.get("provider", "/api/provider/:providerID", {
HttpApiEndpoint.get("provider.get", "/api/provider/:providerID", {
params: { providerID: ProviderV2.ID },
query: LocationQuery,
success: Location.response(ProviderV2.Info),
@@ -33,17 +32,16 @@ export const ProviderGroup = HttpApiGroup.make("v2.provider")
.annotateMerge(
OpenApi.annotations({
identifier: "v2.provider.get",
summary: "Get v2 provider",
summary: "Get provider",
description:
"Retrieve a single v2 AI provider so clients can inspect its availability and endpoint settings.",
"Retrieve a single AI provider so clients can inspect its availability and endpoint settings.",
}),
),
)
.annotateMerge(
OpenApi.annotations({
title: "v2 providers",
description: "Experimental v2 provider routes.",
title: "providers",
description: "Experimental provider routes.",
}),
)
.middleware(V2LocationMiddleware)
.middleware(V2Authorization)
.middleware(LocationMiddleware)
+60
View File
@@ -0,0 +1,60 @@
import { QuestionV2 } from "@opencode-ai/core/question"
import { Location } from "@opencode-ai/core/location"
import { SessionV2 } from "@opencode-ai/core/session"
import { Schema } from "effect"
import { HttpApiEndpoint, HttpApiGroup, HttpApiSchema, OpenApi } from "effect/unstable/httpapi"
import { QuestionNotFoundError, SessionNotFoundError } from "../errors"
import { SessionLocationMiddleware } from "../middleware/session-location"
import { LocationQuery, locationQueryOpenApi, LocationMiddleware } from "./location"
export const QuestionGroup = HttpApiGroup.make("server.question")
.add(
HttpApiEndpoint.get("question.request.list", "/api/question/request", {
query: LocationQuery,
success: Location.response(Schema.Array(QuestionV2.Request)),
})
.annotateMerge(locationQueryOpenApi)
.annotateMerge(
OpenApi.annotations({
identifier: "v2.question.request.list",
summary: "List pending question requests",
description: "Retrieve pending question requests for a location.",
}),
),
)
.annotateMerge(OpenApi.annotations({ title: "questions", description: "Experimental question routes." }))
.middleware(LocationMiddleware)
.add(
HttpApiEndpoint.post("session.question.reply", "/api/session/:sessionID/question/:requestID/reply", {
params: { sessionID: SessionV2.ID, requestID: QuestionV2.ID },
payload: QuestionV2.Reply,
success: HttpApiSchema.NoContent,
error: [SessionNotFoundError, QuestionNotFoundError],
})
.middleware(SessionLocationMiddleware)
.annotateMerge(
OpenApi.annotations({
identifier: "v2.session.question.reply",
summary: "Reply to pending question request",
description: "Answer a pending question request owned by a session.",
}),
),
)
.add(
HttpApiEndpoint.post("session.question.reject", "/api/session/:sessionID/question/:requestID/reject", {
params: { sessionID: SessionV2.ID, requestID: QuestionV2.ID },
success: HttpApiSchema.NoContent,
error: [SessionNotFoundError, QuestionNotFoundError],
})
.middleware(SessionLocationMiddleware)
.annotateMerge(
OpenApi.annotations({
identifier: "v2.session.question.reject",
summary: "Reject pending question request",
description: "Reject a pending question request owned by a session.",
}),
),
)
.annotateMerge(
OpenApi.annotations({ title: "session questions", description: "Experimental session question routes." }),
)
@@ -14,8 +14,8 @@ import {
ServiceUnavailableError,
SessionNotFoundError,
UnknownError,
} from "../../errors"
import { V2Authorization } from "../../middleware/authorization"
} from "../errors"
import { SessionLocationMiddleware } from "../middleware/session-location"
const SessionsQueryFields = {
workspace: WorkspaceV2.ID.pipe(Schema.optional),
@@ -57,7 +57,7 @@ const encodeSessionsCursor = Schema.encodeSync(SessionsCursorJson)
const decodeSessionsCursor = Schema.decodeUnknownEffect(SessionsCursorJson)
export const SessionsCursor = Schema.String.pipe(
Schema.brand("V2SessionsCursor"),
Schema.brand("SessionsCursor"),
withStatics((schema) => {
const make = schema.make
return {
@@ -82,11 +82,11 @@ export const SessionsQuery = Schema.Struct({
project: ProjectV2.ID.pipe(Schema.optional),
subpath: RelativePath.pipe(Schema.optional),
cursor: SessionsCursorQuery.fields.cursor.pipe(Schema.optional),
}).annotate({ identifier: "V2SessionsQuery" })
}).annotate({ identifier: "SessionsQuery" })
export const SessionGroup = HttpApiGroup.make("v2.session")
export const SessionGroup = HttpApiGroup.make("server.session")
.add(
HttpApiEndpoint.get("sessions", "/api/session", {
HttpApiEndpoint.get("session.list", "/api/session", {
query: SessionsQuery,
success: Schema.Struct({
data: Schema.Array(SessionV2.Info),
@@ -94,19 +94,19 @@ export const SessionGroup = HttpApiGroup.make("v2.session")
previous: SessionsCursor.pipe(Schema.optional),
next: SessionsCursor.pipe(Schema.optional),
}),
}).annotate({ identifier: "V2SessionsResponse" }),
}).annotate({ identifier: "SessionsResponse" }),
error: [InvalidCursorError, InvalidRequestError],
}).annotateMerge(
OpenApi.annotations({
identifier: "v2.session.list",
summary: "List v2 sessions",
summary: "List sessions",
description:
"Retrieve sessions in the requested order. Items keep that order across pages; use cursor.next or cursor.previous to move through the ordered list.",
}),
),
)
.add(
HttpApiEndpoint.post("prompt", "/api/session/:sessionID/prompt", {
HttpApiEndpoint.post("session.prompt", "/api/session/:sessionID/prompt", {
params: { sessionID: SessionV2.ID },
payload: Schema.Struct({
id: SessionMessage.ID.pipe(Schema.optional),
@@ -116,57 +116,64 @@ export const SessionGroup = HttpApiGroup.make("v2.session")
}),
success: Schema.Struct({ data: SessionInput.Admitted }),
error: [ConflictError, SessionNotFoundError],
}).annotateMerge(
OpenApi.annotations({
identifier: "v2.session.prompt",
summary: "Send v2 message",
description: "Durably admit one v2 session input and schedule agent-loop execution unless resume is false.",
}),
),
})
.middleware(SessionLocationMiddleware)
.annotateMerge(
OpenApi.annotations({
identifier: "v2.session.prompt",
summary: "Send message",
description: "Durably admit one session input and schedule agent-loop execution unless resume is false.",
}),
),
)
.add(
HttpApiEndpoint.post("compact", "/api/session/:sessionID/compact", {
HttpApiEndpoint.post("session.compact", "/api/session/:sessionID/compact", {
params: { sessionID: SessionV2.ID },
success: HttpApiSchema.NoContent,
error: [SessionNotFoundError, ServiceUnavailableError],
}).annotateMerge(
OpenApi.annotations({
identifier: "v2.session.compact",
summary: "Compact v2 session",
description: "Compact a v2 session conversation.",
}),
),
})
.middleware(SessionLocationMiddleware)
.annotateMerge(
OpenApi.annotations({
identifier: "v2.session.compact",
summary: "Compact session",
description: "Compact a session conversation.",
}),
),
)
.add(
HttpApiEndpoint.post("wait", "/api/session/:sessionID/wait", {
HttpApiEndpoint.post("session.wait", "/api/session/:sessionID/wait", {
params: { sessionID: SessionV2.ID },
success: HttpApiSchema.NoContent,
error: [SessionNotFoundError, ServiceUnavailableError],
}).annotateMerge(
OpenApi.annotations({
identifier: "v2.session.wait",
summary: "Wait for v2 session",
description: "Wait for a v2 session agent loop to become idle.",
}),
),
})
.middleware(SessionLocationMiddleware)
.annotateMerge(
OpenApi.annotations({
identifier: "v2.session.wait",
summary: "Wait for session",
description: "Wait for a session agent loop to become idle.",
}),
),
)
.add(
HttpApiEndpoint.get("context", "/api/session/:sessionID/context", {
HttpApiEndpoint.get("session.context", "/api/session/:sessionID/context", {
params: { sessionID: SessionV2.ID },
success: Schema.Struct({ data: Schema.Array(SessionMessage.Message) }),
error: [SessionNotFoundError, UnknownError],
}).annotateMerge(
OpenApi.annotations({
identifier: "v2.session.context",
summary: "Get v2 session context",
description: "Retrieve the active context messages for a v2 session (all messages after the last compaction).",
}),
),
})
.middleware(SessionLocationMiddleware)
.annotateMerge(
OpenApi.annotations({
identifier: "v2.session.context",
summary: "Get session context",
description: "Retrieve the active context messages for a session (all messages after the last compaction).",
}),
),
)
.annotateMerge(
OpenApi.annotations({
title: "v2",
description: "Experimental v2 routes.",
title: "sessions",
description: "Experimental session routes.",
}),
)
.middleware(V2Authorization)
@@ -2,12 +2,11 @@ import { SkillV2 } from "@opencode-ai/core/skill"
import { Location } from "@opencode-ai/core/location"
import { Schema } from "effect"
import { HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
import { V2Authorization } from "../../middleware/authorization"
import { LocationQuery, locationQueryOpenApi, V2LocationMiddleware } from "./location"
import { LocationQuery, locationQueryOpenApi, LocationMiddleware } from "./location"
export const SkillGroup = HttpApiGroup.make("v2.skill")
export const SkillGroup = HttpApiGroup.make("server.skill")
.add(
HttpApiEndpoint.get("skills", "/api/skill", {
HttpApiEndpoint.get("skill.list", "/api/skill", {
query: LocationQuery,
success: Location.response(Schema.Array(SkillV2.Info)),
})
@@ -15,16 +14,15 @@ export const SkillGroup = HttpApiGroup.make("v2.skill")
.annotateMerge(
OpenApi.annotations({
identifier: "v2.skill.list",
summary: "List v2 skills",
description: "Retrieve currently registered v2 skills.",
summary: "List skills",
description: "Retrieve currently registered skills.",
}),
),
)
.annotateMerge(
OpenApi.annotations({
title: "v2 skills",
description: "Experimental v2 skill routes.",
title: "skills",
description: "Experimental skill routes.",
}),
)
.middleware(V2LocationMiddleware)
.middleware(V2Authorization)
.middleware(LocationMiddleware)
-17
View File
@@ -1,17 +0,0 @@
import { Schema } from "effect"
import { HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
import { V2Authorization } from "../../middleware/authorization"
export const HealthGroup = HttpApiGroup.make("v2.health")
.add(
HttpApiEndpoint.get("health", "/api/health", {
success: Schema.Struct({ healthy: Schema.Literal(true) }),
}).annotateMerge(
OpenApi.annotations({
identifier: "v2.health.get",
summary: "Check v2 server health",
description: "Check whether the v2 API server is ready to accept requests.",
}),
),
)
.middleware(V2Authorization)
-60
View File
@@ -1,60 +0,0 @@
import { QuestionV2 } from "@opencode-ai/core/question"
import { Location } from "@opencode-ai/core/location"
import { SessionV2 } from "@opencode-ai/core/session"
import { Schema } from "effect"
import { HttpApiEndpoint, HttpApiGroup, HttpApiSchema, OpenApi } from "effect/unstable/httpapi"
import { QuestionNotFoundError, SessionNotFoundError } from "../../errors"
import { V2Authorization } from "../../middleware/authorization"
import { LocationQuery, locationQueryOpenApi, V2LocationMiddleware } from "./location"
export const QuestionGroup = HttpApiGroup.make("v2.question")
.add(
HttpApiEndpoint.get("questionRequests", "/api/question/request", {
query: LocationQuery,
success: Location.response(Schema.Array(QuestionV2.Request)),
})
.annotateMerge(locationQueryOpenApi)
.annotateMerge(
OpenApi.annotations({
identifier: "v2.question.request.list",
summary: "List pending question requests",
description: "Retrieve pending question requests for a location.",
}),
),
)
.annotateMerge(OpenApi.annotations({ title: "v2 questions", description: "Experimental v2 question routes." }))
.middleware(V2LocationMiddleware)
.middleware(V2Authorization)
export const SessionQuestionGroup = HttpApiGroup.make("v2.session.question")
.add(
HttpApiEndpoint.post("questionRequestReply", "/api/session/:sessionID/question/request/:requestID/reply", {
params: { sessionID: SessionV2.ID, requestID: QuestionV2.ID },
payload: QuestionV2.Reply,
success: HttpApiSchema.NoContent,
error: [SessionNotFoundError, QuestionNotFoundError],
}).annotateMerge(
OpenApi.annotations({
identifier: "v2.session.question.reply",
summary: "Reply to pending question request",
description: "Answer a pending question request owned by a session.",
}),
),
)
.add(
HttpApiEndpoint.post("questionRequestReject", "/api/session/:sessionID/question/request/:requestID/reject", {
params: { sessionID: SessionV2.ID, requestID: QuestionV2.ID },
success: HttpApiSchema.NoContent,
error: [SessionNotFoundError, QuestionNotFoundError],
}).annotateMerge(
OpenApi.annotations({
identifier: "v2.session.question.reject",
summary: "Reject pending question request",
description: "Reject a pending question request owned by a session.",
}),
),
)
.annotateMerge(
OpenApi.annotations({ title: "v2 session questions", description: "Experimental v2 session question routes." }),
)
.middleware(V2Authorization)
+32 -48
View File
@@ -2,56 +2,40 @@ import { SessionV2 } from "@opencode-ai/core/session"
import { LocationServiceMap } from "@opencode-ai/core/location-layer"
import { PermissionSaved } from "@opencode-ai/core/permission/saved"
import { Layer } from "effect"
import { layer as v2LocationLayer } from "./groups/v2/location"
import { messageHandlers } from "./handlers/v2/message"
import { modelHandlers } from "./handlers/v2/model"
import { providerHandlers } from "./handlers/v2/provider"
import { sessionHandlers } from "./handlers/v2/session"
import { permissionHandlers, savedPermissionHandlers, sessionPermissionHandlers } from "./handlers/v2/permission"
import { fileSystemHandlers } from "./handlers/v2/fs"
import { commandHandlers } from "./handlers/v2/command"
import { skillHandlers } from "./handlers/v2/skill"
import { eventHandlers } from "./handlers/v2/event"
import { agentHandlers } from "./handlers/v2/agent"
import { healthHandlers } from "./handlers/v2/health"
import { questionHandlers, sessionQuestionHandlers } from "./handlers/v2/question"
import { Database } from "@opencode-ai/core/database/database"
import { EventV2 } from "@opencode-ai/core/event"
import { ProjectV2 } from "@opencode-ai/core/project"
import { layer as locationLayer } from "./groups/location"
import { sessionLocationLayer } from "./middleware/session-location"
import { MessageHandler } from "./handlers/message"
import { ModelHandler } from "./handlers/model"
import { ProviderHandler } from "./handlers/provider"
import { SessionHandler } from "./handlers/session"
import { PermissionHandler } from "./handlers/permission"
import { FileSystemHandler } from "./handlers/fs"
import { CommandHandler } from "./handlers/command"
import { SkillHandler } from "./handlers/skill"
import { EventHandler } from "./handlers/event"
import { AgentHandler } from "./handlers/agent"
import { HealthHandler } from "./handlers/health"
import { QuestionHandler } from "./handlers/question"
import * as SessionExecutionLocal from "@opencode-ai/core/session/execution/local"
import { SessionProjector } from "@opencode-ai/core/session/projector"
import { SessionStore } from "@opencode-ai/core/session/store"
const routedSessions = SessionV2.layer.pipe(
Layer.provide(SessionProjector.layer),
Layer.provide(SessionExecutionLocal.layer),
Layer.provide(LocationServiceMap.layer),
Layer.provide(SessionStore.layer),
Layer.provide(EventV2.layer),
Layer.provide(Database.defaultLayer),
Layer.provide(ProjectV2.defaultLayer),
Layer.orDie,
)
export const v2Handlers = Layer.mergeAll(
healthHandlers,
agentHandlers,
sessionHandlers,
messageHandlers,
modelHandlers,
providerHandlers,
permissionHandlers,
sessionPermissionHandlers,
savedPermissionHandlers,
fileSystemHandlers,
commandHandlers,
skillHandlers,
eventHandlers,
questionHandlers,
sessionQuestionHandlers,
export const handlers = Layer.mergeAll(
HealthHandler,
AgentHandler,
SessionHandler,
MessageHandler,
ModelHandler,
ProviderHandler,
PermissionHandler,
FileSystemHandler,
CommandHandler,
SkillHandler,
EventHandler,
QuestionHandler,
).pipe(
Layer.provide(v2LocationLayer),
Layer.provide(sessionLocationLayer),
Layer.provide(locationLayer),
Layer.provide(SessionV2.defaultLayer),
Layer.provide(SessionExecutionLocal.defaultLayer),
Layer.provide(PermissionSaved.defaultLayer),
Layer.provide(LocationServiceMap.layer),
Layer.provide(PermissionSaved.layer),
Layer.provide(routedSessions),
)
@@ -2,11 +2,11 @@ import { AgentV2 } from "@opencode-ai/core/agent"
import { PluginBoot } from "@opencode-ai/core/plugin/boot"
import { Effect } from "effect"
import { HttpApiBuilder } from "effect/unstable/httpapi"
import { V2Api } from "../../api"
import { response } from "../../groups/v2/location"
import { Api } from "../api"
import { response } from "../groups/location"
export const agentHandlers = HttpApiBuilder.group(V2Api, "v2.agent", (handlers) =>
handlers.handle("agents", () =>
export const AgentHandler = HttpApiBuilder.group(Api, "server.agent", (handlers) =>
handlers.handle("agent.list", () =>
Effect.gen(function* () {
yield* PluginBoot.Service.use((plugin) => plugin.wait())
return yield* response(AgentV2.Service.use((agent) => agent.all()))
+9
View File
@@ -0,0 +1,9 @@
import { CommandV2 } from "@opencode-ai/core/command"
import { Effect } from "effect"
import { HttpApiBuilder } from "effect/unstable/httpapi"
import { Api } from "../api"
import { response } from "../groups/location"
export const CommandHandler = HttpApiBuilder.group(Api, "server.command", (handlers) =>
handlers.handle("command.list", () => response(CommandV2.Service.use((command) => command.list()))),
)
@@ -4,7 +4,7 @@ import { Effect, Stream } from "effect"
import { HttpServerResponse } from "effect/unstable/http"
import { HttpApiBuilder } from "effect/unstable/httpapi"
import * as Sse from "effect/unstable/encoding/Sse"
import { V2Api } from "../../api"
import { Api } from "../api"
function eventData(data: unknown): Sse.Event {
return {
@@ -15,10 +15,10 @@ function eventData(data: unknown): Sse.Event {
}
}
export const eventHandlers = HttpApiBuilder.group(V2Api, "v2.event", (handlers) =>
export const EventHandler = HttpApiBuilder.group(Api, "server.event", (handlers) =>
Effect.gen(function* () {
const events = yield* EventV2.Service
return handlers.handleRaw("events", () =>
return handlers.handleRaw("event.subscribe", () =>
Effect.gen(function* () {
const location = yield* Location.Service
const connected = {
+13
View File
@@ -0,0 +1,13 @@
import { FileSystem } from "@opencode-ai/core/filesystem"
import { Effect } from "effect"
import { HttpApiBuilder } from "effect/unstable/httpapi"
import { Api } from "../api"
import { response } from "../groups/location"
export const FileSystemHandler = HttpApiBuilder.group(Api, "server.fs", (handlers) =>
Effect.gen(function* () {
return handlers
.handle("fs.read", (ctx) => response(FileSystem.Service.use((fs) => fs.read(ctx.query))))
.handle("fs.list", (ctx) => response(FileSystem.Service.use((fs) => fs.list(ctx.query))))
}),
)
+7
View File
@@ -0,0 +1,7 @@
import { Effect } from "effect"
import { HttpApiBuilder } from "effect/unstable/httpapi"
import { Api } from "../api"
export const HealthHandler = HttpApiBuilder.group(Api, "server.health", (handlers) =>
handlers.handle("health.get", () => Effect.succeed({ healthy: true as const })),
)
@@ -2,8 +2,8 @@ import { SessionMessage } from "@opencode-ai/core/session/message"
import { SessionV2 } from "@opencode-ai/core/session"
import { Effect, Schema } from "effect"
import { HttpApiBuilder } from "effect/unstable/httpapi"
import { V2Api } from "../../api"
import { InvalidCursorError, SessionNotFoundError, UnknownError } from "../../errors"
import { Api } from "../api"
import { InvalidCursorError, SessionNotFoundError, UnknownError } from "../errors"
const DefaultMessagesLimit = 50
@@ -24,12 +24,12 @@ const cursor = {
},
}
export const messageHandlers = HttpApiBuilder.group(V2Api, "v2.message", (handlers) =>
export const MessageHandler = HttpApiBuilder.group(Api, "server.message", (handlers) =>
Effect.gen(function* () {
const session = yield* SessionV2.Service
return handlers.handle(
"messages",
"session.messages",
Effect.fn(function* (ctx) {
if (ctx.query.cursor && ctx.query.order !== undefined)
return yield* new InvalidCursorError({ message: "Cursor cannot be combined with order" })
@@ -56,7 +56,7 @@ export const messageHandlers = HttpApiBuilder.group(V2Api, "v2.message", (handle
),
Effect.catchTag("Session.MessageDecodeError", (error) => {
const ref = `err_${crypto.randomUUID().slice(0, 8)}`
return Effect.logError("failed to decode v2 session message").pipe(
return Effect.logError("failed to decode session message").pipe(
Effect.annotateLogs({ ref, sessionID: error.sessionID, messageID: error.messageID }),
Effect.andThen(
Effect.fail(
@@ -2,19 +2,19 @@ import { Catalog } from "@opencode-ai/core/catalog"
import { PluginBoot } from "@opencode-ai/core/plugin/boot"
import { Effect } from "effect"
import { HttpApiBuilder } from "effect/unstable/httpapi"
import { V2Api } from "../../api"
import { ServiceUnavailableError } from "../../errors"
import { response } from "../../groups/v2/location"
import { Api } from "../api"
import { ServiceUnavailableError } from "../errors"
import { response } from "../groups/location"
const catalogUnavailable = new ServiceUnavailableError({
message: "Model catalog is unavailable",
service: "catalog",
})
export const modelHandlers = HttpApiBuilder.group(V2Api, "v2.model", (handlers) =>
export const ModelHandler = HttpApiBuilder.group(Api, "server.model", (handlers) =>
Effect.gen(function* () {
return handlers.handle(
"models",
"model.list",
Effect.fn(function* () {
const catalog = yield* Catalog.Service
const pluginBoot = yield* PluginBoot.Service
@@ -0,0 +1,57 @@
import { Location } from "@opencode-ai/core/location"
import { PermissionV2 } from "@opencode-ai/core/permission"
import { PermissionSaved } from "@opencode-ai/core/permission/saved"
import { Effect } from "effect"
import { HttpApiBuilder, HttpApiSchema } from "effect/unstable/httpapi"
import { Api } from "../api"
import { PermissionNotFoundError } from "../errors"
import { response } from "../groups/location"
function missingRequest(id: PermissionV2.ID) {
return new PermissionNotFoundError({ requestID: id, message: `Permission request not found: ${id}` })
}
export const PermissionHandler = HttpApiBuilder.group(Api, "server.permission", (handlers) =>
Effect.gen(function* () {
return handlers
.handle(
"permission.request.list",
Effect.fn(function* () {
return yield* response((yield* PermissionV2.Service).list())
}),
)
.handle(
"session.permission.list",
Effect.fn(function* (ctx) {
const permission = yield* PermissionV2.Service
return { data: yield* permission.forSession(ctx.params.sessionID) }
}),
)
.handle(
"session.permission.reply",
Effect.fn(function* (ctx) {
const permission = yield* PermissionV2.Service
const request = yield* permission.get(ctx.params.requestID)
if (!request || request.sessionID !== ctx.params.sessionID) return yield* missingRequest(ctx.params.requestID)
yield* permission
.reply({ requestID: ctx.params.requestID, reply: ctx.payload.reply, message: ctx.payload.message })
.pipe(Effect.catchTag("PermissionV2.NotFoundError", () => missingRequest(ctx.params.requestID)))
return HttpApiSchema.NoContent.make()
}),
)
.handle(
"permission.saved.list",
Effect.fn(function* (ctx) {
const location = yield* Location.Service
return { data: yield* (yield* PermissionSaved.Service).list({ projectID: ctx.query.projectID ?? location.project.id }) }
}),
)
.handle(
"permission.saved.remove",
Effect.fn(function* (ctx) {
yield* (yield* PermissionSaved.Service).remove(ctx.params.id)
return HttpApiSchema.NoContent.make()
}),
)
}),
)
@@ -3,20 +3,20 @@ import { PluginBoot } from "@opencode-ai/core/plugin/boot"
import { ProviderV2 } from "@opencode-ai/core/provider"
import { Effect } from "effect"
import { HttpApiBuilder } from "effect/unstable/httpapi"
import { V2Api } from "../../api"
import { ProviderNotFoundError, ServiceUnavailableError } from "../../errors"
import { response } from "../../groups/v2/location"
import { Api } from "../api"
import { ProviderNotFoundError, ServiceUnavailableError } from "../errors"
import { response } from "../groups/location"
const catalogUnavailable = new ServiceUnavailableError({
message: "Provider catalog is unavailable",
service: "catalog",
})
export const providerHandlers = HttpApiBuilder.group(V2Api, "v2.provider", (handlers) =>
export const ProviderHandler = HttpApiBuilder.group(Api, "server.provider", (handlers) =>
Effect.gen(function* () {
return handlers
.handle(
"providers",
"provider.list",
Effect.fn(function* () {
const catalog = yield* Catalog.Service
const pluginBoot = yield* PluginBoot.Service
@@ -25,7 +25,7 @@ export const providerHandlers = HttpApiBuilder.group(V2Api, "v2.provider", (hand
}),
)
.handle(
"provider",
"provider.get",
Effect.fn(function* (ctx) {
const catalog = yield* Catalog.Service
const pluginBoot = yield* PluginBoot.Service
+55
View File
@@ -0,0 +1,55 @@
import { QuestionV2 } from "@opencode-ai/core/question"
import { Effect } from "effect"
import { HttpApiBuilder, HttpApiSchema } from "effect/unstable/httpapi"
import { Api } from "../api"
import { QuestionNotFoundError } from "../errors"
import { response } from "../groups/location"
function missingRequest(id: QuestionV2.ID) {
return new QuestionNotFoundError({ requestID: id, message: `Question request not found: ${id}` })
}
export const QuestionHandler = HttpApiBuilder.group(Api, "server.question", (handlers) =>
Effect.gen(function* () {
const withOwnedQuestion = Effect.fnUntraced(function* <A, E>(
sessionID: QuestionV2.Request["sessionID"],
requestID: QuestionV2.ID,
use: (question: QuestionV2.Interface) => Effect.Effect<A, E>,
) {
const question = yield* QuestionV2.Service
const request = (yield* question.list()).find((request) => request.id === requestID)
if (!request || request.sessionID !== sessionID) return yield* missingRequest(requestID)
return yield* use(question)
})
return handlers
.handle(
"question.request.list",
Effect.fn(function* () {
return yield* response((yield* QuestionV2.Service).list())
}),
)
.handle(
"session.question.reply",
Effect.fn(function* (ctx) {
yield* withOwnedQuestion(ctx.params.sessionID, ctx.params.requestID, (question) =>
question
.reply({ requestID: ctx.params.requestID, answers: ctx.payload.answers })
.pipe(Effect.catchTag("QuestionV2.NotFoundError", () => missingRequest(ctx.params.requestID))),
)
return HttpApiSchema.NoContent.make()
}),
)
.handle(
"session.question.reject",
Effect.fn(function* (ctx) {
yield* withOwnedQuestion(ctx.params.sessionID, ctx.params.requestID, (question) =>
question
.reject(ctx.params.requestID)
.pipe(Effect.catchTag("QuestionV2.NotFoundError", () => missingRequest(ctx.params.requestID))),
)
return HttpApiSchema.NoContent.make()
}),
)
}),
)
@@ -1,25 +1,25 @@
import { SessionV2 } from "@opencode-ai/core/session"
import { DateTime, Effect } from "effect"
import { HttpApiBuilder, HttpApiSchema } from "effect/unstable/httpapi"
import { V2Api } from "../../api"
import { SessionsCursor } from "../../groups/v2/session"
import { Api } from "../api"
import { SessionsCursor } from "../groups/session"
import {
ConflictError,
InvalidCursorError,
ServiceUnavailableError,
SessionNotFoundError,
UnknownError,
} from "../../errors"
} from "../errors"
const DefaultSessionsLimit = 50
export const sessionHandlers = HttpApiBuilder.group(V2Api, "v2.session", (handlers) =>
export const SessionHandler = HttpApiBuilder.group(Api, "server.session", (handlers) =>
Effect.gen(function* () {
const session = yield* SessionV2.Service
return handlers
.handle(
"sessions",
"session.list",
Effect.fn(function* (ctx) {
const query =
ctx.query.cursor !== undefined
@@ -62,7 +62,7 @@ export const sessionHandlers = HttpApiBuilder.group(V2Api, "v2.session", (handle
}),
)
.handle(
"prompt",
"session.prompt",
Effect.fn(function* (ctx) {
return {
data: yield* session
@@ -95,7 +95,7 @@ export const sessionHandlers = HttpApiBuilder.group(V2Api, "v2.session", (handle
}),
)
.handle(
"compact",
"session.compact",
Effect.fn(function* (ctx) {
yield* session.compact({ sessionID: ctx.params.sessionID }).pipe(
Effect.catchTag("Session.NotFoundError", (error) =>
@@ -109,8 +109,8 @@ export const sessionHandlers = HttpApiBuilder.group(V2Api, "v2.session", (handle
Effect.catchTag("Session.OperationUnavailableError", (error) =>
Effect.fail(
new ServiceUnavailableError({
message: `V2 session ${error.operation} is not available yet`,
service: `v2.session.${error.operation}`,
message: `Session ${error.operation} is not available yet`,
service: `session.${error.operation}`,
}),
),
),
@@ -119,7 +119,7 @@ export const sessionHandlers = HttpApiBuilder.group(V2Api, "v2.session", (handle
}),
)
.handle(
"wait",
"session.wait",
Effect.fn(function* (ctx) {
yield* session.wait(ctx.params.sessionID).pipe(
Effect.catchTag("Session.NotFoundError", (error) =>
@@ -133,8 +133,8 @@ export const sessionHandlers = HttpApiBuilder.group(V2Api, "v2.session", (handle
Effect.catchTag("Session.OperationUnavailableError", (error) =>
Effect.fail(
new ServiceUnavailableError({
message: `V2 session ${error.operation} is not available yet`,
service: `v2.session.${error.operation}`,
message: `Session ${error.operation} is not available yet`,
service: `session.${error.operation}`,
}),
),
),
@@ -143,7 +143,7 @@ export const sessionHandlers = HttpApiBuilder.group(V2Api, "v2.session", (handle
}),
)
.handle(
"context",
"session.context",
Effect.fn(function* (ctx) {
return {
data: yield* session.context(ctx.params.sessionID).pipe(
@@ -157,7 +157,7 @@ export const sessionHandlers = HttpApiBuilder.group(V2Api, "v2.session", (handle
),
Effect.catchTag("Session.MessageDecodeError", (error) => {
const ref = `err_${crypto.randomUUID().slice(0, 8)}`
return Effect.logError("failed to decode v2 session message").pipe(
return Effect.logError("failed to decode session message").pipe(
Effect.annotateLogs({ ref, sessionID: error.sessionID, messageID: error.messageID }),
Effect.andThen(
Effect.fail(
+8
View File
@@ -0,0 +1,8 @@
import { SkillV2 } from "@opencode-ai/core/skill"
import { HttpApiBuilder } from "effect/unstable/httpapi"
import { Api } from "../api"
import { response } from "../groups/location"
export const SkillHandler = HttpApiBuilder.group(Api, "server.skill", (handlers) =>
handlers.handle("skill.list", () => response(SkillV2.Service.use((skill) => skill.list()))),
)
@@ -1,9 +0,0 @@
import { CommandV2 } from "@opencode-ai/core/command"
import { Effect } from "effect"
import { HttpApiBuilder } from "effect/unstable/httpapi"
import { V2Api } from "../../api"
import { response } from "../../groups/v2/location"
export const commandHandlers = HttpApiBuilder.group(V2Api, "v2.command", (handlers) =>
handlers.handle("commands", () => response(CommandV2.Service.use((command) => command.list()))),
)
-13
View File
@@ -1,13 +0,0 @@
import { FileSystem } from "@opencode-ai/core/filesystem"
import { Effect } from "effect"
import { HttpApiBuilder } from "effect/unstable/httpapi"
import { V2Api } from "../../api"
import { response } from "../../groups/v2/location"
export const fileSystemHandlers = HttpApiBuilder.group(V2Api, "v2.fs", (handlers) =>
Effect.gen(function* () {
return handlers
.handle("read", (ctx) => response(FileSystem.Service.use((fs) => fs.read(ctx.query))))
.handle("list", (ctx) => response(FileSystem.Service.use((fs) => fs.list(ctx.query))))
}),
)
@@ -1,7 +0,0 @@
import { Effect } from "effect"
import { HttpApiBuilder } from "effect/unstable/httpapi"
import { V2Api } from "../../api"
export const healthHandlers = HttpApiBuilder.group(V2Api, "v2.health", (handlers) =>
handlers.handle("health", () => Effect.succeed({ healthy: true as const })),
)
@@ -1,106 +0,0 @@
import { Database } from "@opencode-ai/core/database/database"
import { LocationServiceMap } from "@opencode-ai/core/location-layer"
import { PermissionV2 } from "@opencode-ai/core/permission"
import { PermissionSaved } from "@opencode-ai/core/permission/saved"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { SessionTable } from "@opencode-ai/core/session/sql"
import { eq } from "drizzle-orm"
import { Effect } from "effect"
import { HttpApiBuilder, HttpApiSchema } from "effect/unstable/httpapi"
import { V2Api } from "../../api"
import { PermissionNotFoundError, SessionNotFoundError } from "../../errors"
import { response } from "../../groups/v2/location"
function missingRequest(id: PermissionV2.ID) {
return new PermissionNotFoundError({ requestID: id, message: `Permission request not found: ${id}` })
}
export const permissionHandlers = HttpApiBuilder.group(V2Api, "v2.permission", (handlers) =>
Effect.gen(function* () {
return handlers.handle(
"permissionRequests",
Effect.fn(function* () {
return yield* response((yield* PermissionV2.Service).list())
}),
)
}),
)
export const sessionPermissionHandlers = HttpApiBuilder.group(V2Api, "v2.session.permission", (handlers) =>
Effect.gen(function* () {
const { db } = yield* Database.Service
const locations = yield* LocationServiceMap
const withSessionPermission = Effect.fnUntraced(function* <A, E>(
sessionID: Parameters<PermissionV2.Interface["forSession"]>[0],
use: (permission: PermissionV2.Interface) => Effect.Effect<A, E>,
) {
const row = yield* db
.select({ directory: SessionTable.directory, workspaceID: SessionTable.workspace_id })
.from(SessionTable)
.where(eq(SessionTable.id, sessionID))
.get()
.pipe(Effect.orDie)
if (!row)
return yield* new SessionNotFoundError({
sessionID,
message: `Session not found: ${sessionID}`,
})
return yield* Effect.gen(function* () {
return yield* use(yield* PermissionV2.Service)
}).pipe(
Effect.scoped,
Effect.provide(
locations.get({ directory: AbsolutePath.make(row.directory), workspaceID: row.workspaceID ?? undefined }),
),
)
})
return handlers
.handle(
"sessionPermissionRequests",
Effect.fn(function* (ctx) {
return yield* withSessionPermission(ctx.params.sessionID, (permission) =>
permission.forSession(ctx.params.sessionID).pipe(Effect.map((data) => ({ data }))),
)
}),
)
.handle(
"permissionRequestReply",
Effect.fn(function* (ctx) {
yield* withSessionPermission(ctx.params.sessionID, (permission) =>
Effect.gen(function* () {
const request = yield* permission.get(ctx.params.requestID)
if (!request || request.sessionID !== ctx.params.sessionID)
return yield* missingRequest(ctx.params.requestID)
yield* permission
.reply({ requestID: ctx.params.requestID, reply: ctx.payload.reply, message: ctx.payload.message })
.pipe(Effect.catchTag("PermissionV2.NotFoundError", () => missingRequest(ctx.params.requestID)))
}),
)
return HttpApiSchema.NoContent.make()
}),
)
}),
)
export const savedPermissionHandlers = HttpApiBuilder.group(V2Api, "v2.permission.saved", (handlers) =>
Effect.gen(function* () {
const saved = yield* PermissionSaved.Service
return handlers
.handle(
"savedPermissions",
Effect.fn(function* (ctx) {
return { data: yield* saved.list({ projectID: ctx.query.projectID }) }
}),
)
.handle(
"removeSavedPermission",
Effect.fn(function* (ctx) {
yield* saved.remove(ctx.params.id)
return HttpApiSchema.NoContent.make()
}),
)
}),
)
@@ -1,97 +0,0 @@
import { Database } from "@opencode-ai/core/database/database"
import { LocationServiceMap } from "@opencode-ai/core/location-layer"
import { QuestionV2 } from "@opencode-ai/core/question"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { SessionTable } from "@opencode-ai/core/session/sql"
import { eq } from "drizzle-orm"
import { Effect } from "effect"
import { HttpApiBuilder, HttpApiSchema } from "effect/unstable/httpapi"
import { V2Api } from "../../api"
import { QuestionNotFoundError, SessionNotFoundError } from "../../errors"
import { response } from "../../groups/v2/location"
function missingRequest(id: QuestionV2.ID) {
return new QuestionNotFoundError({ requestID: id, message: `Question request not found: ${id}` })
}
export const questionHandlers = HttpApiBuilder.group(V2Api, "v2.question", (handlers) =>
Effect.gen(function* () {
return handlers.handle(
"questionRequests",
Effect.fn(function* () {
return yield* response((yield* QuestionV2.Service).list())
}),
)
}),
)
export const sessionQuestionHandlers = HttpApiBuilder.group(V2Api, "v2.session.question", (handlers) =>
Effect.gen(function* () {
const { db } = yield* Database.Service
const locations = yield* LocationServiceMap
const withSessionQuestion = Effect.fnUntraced(function* <A, E>(
sessionID: QuestionV2.Request["sessionID"],
use: (question: QuestionV2.Interface) => Effect.Effect<A, E>,
) {
const row = yield* db
.select({ directory: SessionTable.directory, workspaceID: SessionTable.workspace_id })
.from(SessionTable)
.where(eq(SessionTable.id, sessionID))
.get()
.pipe(Effect.orDie)
if (!row)
return yield* new SessionNotFoundError({
sessionID,
message: `Session not found: ${sessionID}`,
})
return yield* Effect.gen(function* () {
return yield* use(yield* QuestionV2.Service)
}).pipe(
Effect.scoped,
Effect.provide(
locations.get({ directory: AbsolutePath.make(row.directory), workspaceID: row.workspaceID ?? undefined }),
),
)
})
const withOwnedQuestion = Effect.fnUntraced(function* <A, E>(
sessionID: QuestionV2.Request["sessionID"],
requestID: QuestionV2.ID,
use: (question: QuestionV2.Interface) => Effect.Effect<A, E>,
) {
return yield* withSessionQuestion(sessionID, (question) =>
Effect.gen(function* () {
const request = (yield* question.list()).find((request) => request.id === requestID)
if (!request || request.sessionID !== sessionID) return yield* missingRequest(requestID)
return yield* use(question)
}),
)
})
return handlers
.handle(
"questionRequestReply",
Effect.fn(function* (ctx) {
yield* withOwnedQuestion(ctx.params.sessionID, ctx.params.requestID, (question) =>
question
.reply({ requestID: ctx.params.requestID, answers: ctx.payload.answers })
.pipe(Effect.catchTag("QuestionV2.NotFoundError", () => missingRequest(ctx.params.requestID))),
)
return HttpApiSchema.NoContent.make()
}),
)
.handle(
"questionRequestReject",
Effect.fn(function* (ctx) {
yield* withOwnedQuestion(ctx.params.sessionID, ctx.params.requestID, (question) =>
question
.reject(ctx.params.requestID)
.pipe(Effect.catchTag("QuestionV2.NotFoundError", () => missingRequest(ctx.params.requestID))),
)
return HttpApiSchema.NoContent.make()
}),
)
}),
)
-8
View File
@@ -1,8 +0,0 @@
import { SkillV2 } from "@opencode-ai/core/skill"
import { HttpApiBuilder } from "effect/unstable/httpapi"
import { V2Api } from "../../api"
import { response } from "../../groups/v2/location"
export const skillHandlers = HttpApiBuilder.group(V2Api, "v2.skill", (handlers) =>
handlers.handle("skills", () => response(SkillV2.Service.use((skill) => skill.list()))),
)
@@ -7,8 +7,8 @@ import { HttpApiMiddleware } from "effect/unstable/httpapi"
const AUTH_TOKEN_QUERY = "auth_token"
const WWW_AUTHENTICATE = 'Basic realm="Secure Area"'
export class V2Authorization extends HttpApiMiddleware.Service<V2Authorization>()(
"@opencode/ExperimentalHttpApiV2Authorization",
export class Authorization extends HttpApiMiddleware.Service<Authorization>()(
"@opencode/HttpApiAuthorization",
{
error: UnauthorizedError,
},
@@ -40,12 +40,12 @@ function credentialFromRequest(request: HttpServerRequest.HttpServerRequest) {
return Effect.succeed(emptyCredential())
}
export const v2AuthorizationLayer = Layer.effect(
V2Authorization,
export const authorizationLayer = Layer.effect(
Authorization,
Effect.gen(function* () {
const config = yield* ServerAuth.Config
if (!ServerAuth.required(config)) return V2Authorization.of((effect) => effect)
return V2Authorization.of((effect) =>
if (!ServerAuth.required(config)) return Authorization.of((effect) => effect)
return Authorization.of((effect) =>
Effect.gen(function* () {
const request = yield* HttpServerRequest.HttpServerRequest
const credential = yield* credentialFromRequest(request)
@@ -0,0 +1,66 @@
import { Database } from "@opencode-ai/core/database/database"
import { LocationServiceMap } from "@opencode-ai/core/location-layer"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { SessionV2 } from "@opencode-ai/core/session"
import { SessionTable } from "@opencode-ai/core/session/sql"
import { WorkspaceV2 } from "@opencode-ai/core/workspace"
import { eq } from "drizzle-orm"
import { Effect, Layer, Schema } from "effect"
import { HttpRouter } from "effect/unstable/http"
import { HttpApiMiddleware } from "effect/unstable/httpapi"
import { InvalidRequestError, SessionNotFoundError } from "../errors"
import type { LocationServices } from "../groups/location"
export class SessionLocationMiddleware extends HttpApiMiddleware.Service<
SessionLocationMiddleware,
{
provides: LocationServices
}
>()("@opencode/HttpApiSessionLocation", {
error: [InvalidRequestError, SessionNotFoundError],
}) {}
const decodeSessionID = Schema.decodeUnknownEffect(SessionV2.ID)
export const sessionLocationLayer = Layer.effect(
SessionLocationMiddleware,
Effect.gen(function* () {
const { db } = yield* Database.Service
const locations = yield* LocationServiceMap
return SessionLocationMiddleware.of((effect) =>
Effect.gen(function* () {
const route = yield* HttpRouter.RouteContext
const sessionID = yield* decodeSessionID(route.params.sessionID).pipe(
Effect.mapError(
() =>
new InvalidRequestError({
message: "Invalid session ID",
field: "sessionID",
}),
),
)
const row = yield* db
.select({ directory: SessionTable.directory, workspaceID: SessionTable.workspace_id })
.from(SessionTable)
.where(eq(SessionTable.id, sessionID))
.get()
.pipe(Effect.orDie)
if (!row)
return yield* new SessionNotFoundError({
sessionID,
message: `Session not found: ${sessionID}`,
})
return yield* effect.pipe(
Effect.provide(
locations.get({
directory: AbsolutePath.make(row.directory),
workspaceID: row.workspaceID ? WorkspaceV2.ID.make(row.workspaceID) : undefined,
}),
),
)
}),
)
}),
)
+6 -10
View File
@@ -1,21 +1,19 @@
import { Database } from "@opencode-ai/core/database/database"
import { EventV2 } from "@opencode-ai/core/event"
import { LocationServiceMap } from "@opencode-ai/core/location-layer"
import { PermissionSaved } from "@opencode-ai/core/permission/saved"
import { SessionV2 } from "@opencode-ai/core/session"
import { FetchHttpClient, HttpRouter, HttpServer } from "effect/unstable/http"
import { HttpApiBuilder } from "effect/unstable/httpapi"
import { Layer, Option } from "effect"
import { V2Api } from "./api"
import { Api } from "./api"
import { ServerAuth } from "./auth"
import { v2Handlers } from "./handlers"
import { v2AuthorizationLayer } from "./middleware/authorization"
import { handlers } from "./handlers"
import { authorizationLayer } from "./middleware/authorization"
import { schemaErrorLayer } from "./middleware/schema-error"
export function createRoutes(password?: string) {
return HttpApiBuilder.layer(V2Api).pipe(
Layer.provide(v2Handlers),
Layer.provide(v2AuthorizationLayer),
return HttpApiBuilder.layer(Api, { openapiPath: "/openapi.json" }).pipe(
Layer.provide(handlers),
Layer.provide(authorizationLayer),
Layer.provide(schemaErrorLayer),
Layer.provide(
password
@@ -23,8 +21,6 @@ export function createRoutes(password?: string) {
: ServerAuth.Config.defaultLayer,
),
Layer.provide(LocationServiceMap.layer),
Layer.provide(PermissionSaved.layer),
Layer.provide(SessionV2.defaultLayer),
Layer.provide(Database.defaultLayer),
Layer.provide(EventV2.defaultLayer),
Layer.provide(FetchHttpClient.layer),