From 6015084fa2502bf4dc941ae39c538f089a0d89b4 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Wed, 29 Apr 2026 09:34:50 -0400 Subject: [PATCH] Prepare Effect HttpApi backend parity (#24853) --- packages/opencode/scripts/diff-sdk-types.sh | 52 ++ packages/opencode/specs/effect/http-api.md | 8 + packages/opencode/src/agent/agent.ts | 6 +- packages/opencode/src/auth/index.ts | 3 +- packages/opencode/src/bus/bus-event.ts | 12 + packages/opencode/src/cli/cmd/tui/event.ts | 3 +- packages/opencode/src/config/agent.ts | 4 +- packages/opencode/src/config/console-state.ts | 3 +- packages/opencode/src/config/mcp.ts | 6 +- packages/opencode/src/config/provider.ts | 22 +- packages/opencode/src/file/index.ts | 14 +- packages/opencode/src/file/ripgrep.ts | 28 +- packages/opencode/src/lsp/lsp.ts | 10 +- packages/opencode/src/project/project.ts | 8 +- packages/opencode/src/project/vcs.ts | 6 +- packages/opencode/src/provider/auth.ts | 4 +- packages/opencode/src/provider/models.ts | 22 +- packages/opencode/src/provider/provider.ts | 18 +- packages/opencode/src/pty/index.ts | 10 +- packages/opencode/src/server/backend.ts | 32 + packages/opencode/src/server/middleware.ts | 23 +- packages/opencode/src/server/proxy.ts | 8 +- .../src/server/routes/instance/httpapi/api.ts | 54 ++ .../server/routes/instance/httpapi/event.ts | 11 +- .../server/routes/instance/httpapi/global.ts | 259 -------- .../instance/httpapi/{ => groups}/config.ts | 44 +- .../instance/httpapi/{ => groups}/control.ts | 46 +- .../httpapi/{ => groups}/experimental.ts | 203 +------ .../instance/httpapi/{ => groups}/file.ts | 78 +-- .../routes/instance/httpapi/groups/global.ts | 106 ++++ .../instance/httpapi/{ => groups}/instance.ts | 97 +-- .../instance/httpapi/{ => groups}/mcp.ts | 115 +--- .../instance/httpapi/groups/metadata.ts | 18 + .../httpapi/{ => groups}/permission.ts | 44 +- .../instance/httpapi/{ => groups}/project.ts | 66 +- .../instance/httpapi/groups/provider.ts | 74 +++ .../routes/instance/httpapi/groups/pty.ts | 121 ++++ .../instance/httpapi/{ => groups}/question.ts | 52 +- .../routes/instance/httpapi/groups/session.ts | 428 +++++++++++++ .../routes/instance/httpapi/groups/sync.ts | 90 +++ .../routes/instance/httpapi/groups/tui.ts | 164 +++++ .../instance/httpapi/groups/workspace.ts | 103 ++++ .../instance/httpapi/handlers/config.ts | 34 ++ .../instance/httpapi/handlers/control.ts | 34 ++ .../instance/httpapi/handlers/experimental.ts | 155 +++++ .../routes/instance/httpapi/handlers/file.ts | 54 ++ .../instance/httpapi/handlers/global.ts | 156 +++++ .../instance/httpapi/handlers/instance.ts | 79 +++ .../routes/instance/httpapi/handlers/mcp.ts | 68 +++ .../instance/httpapi/handlers/permission.ts | 29 + .../instance/httpapi/handlers/project.ts | 46 ++ .../instance/httpapi/handlers/provider.ts | 89 +++ .../routes/instance/httpapi/handlers/pty.ts | 118 ++++ .../instance/httpapi/handlers/question.ts | 33 + .../httpapi/{ => handlers}/session.ts | 487 ++------------- .../routes/instance/httpapi/handlers/sync.ts | 54 ++ .../routes/instance/httpapi/handlers/tui.ts | 134 +++++ .../instance/httpapi/handlers/workspace.ts | 66 ++ .../instance/httpapi/instance-context.ts | 191 ++++++ .../routes/instance/httpapi/lifecycle.ts | 17 +- .../routes/instance/httpapi/provider.ts | 157 ----- .../src/server/routes/instance/httpapi/pty.ts | 242 -------- .../server/routes/instance/httpapi/public.ts | 444 +++++++++++--- .../server/routes/instance/httpapi/server.ts | 200 +++---- .../server/routes/instance/httpapi/sync.ts | 137 ----- .../src/server/routes/instance/httpapi/tui.ts | 291 --------- .../routes/instance/httpapi/workspace.ts | 166 ----- packages/opencode/src/server/server.ts | 41 +- packages/opencode/src/server/workspace.ts | 23 +- packages/opencode/src/session/message-v2.ts | 104 ++-- packages/opencode/src/session/message.ts | 28 +- packages/opencode/src/session/session.ts | 28 +- packages/opencode/src/session/status.ts | 6 +- packages/opencode/src/snapshot/index.ts | 6 +- packages/opencode/src/storage/storage.ts | 5 +- packages/opencode/src/sync/index.ts | 16 + packages/opencode/src/tool/bash.ts | 3 +- packages/opencode/src/tool/codesearch.ts | 2 +- packages/opencode/src/tool/lsp.ts | 12 +- packages/opencode/src/tool/read.ts | 5 +- .../opencode/src/util/named-schema-error.ts | 7 + packages/opencode/src/util/schema.ts | 2 + packages/opencode/src/v2/session-entry.ts | 15 +- packages/opencode/src/v2/session-event.ts | 22 +- .../test/server/httpapi-bridge.test.ts | 50 +- .../test/server/httpapi-experimental.test.ts | 2 +- .../opencode/test/server/httpapi-file.test.ts | 2 +- .../test/server/httpapi-instance.test.ts | 2 +- .../test/server/httpapi-json-parity.test.ts | 6 +- .../opencode/test/server/httpapi-mcp.test.ts | 4 +- .../test/server/httpapi-provider.test.ts | 2 +- .../opencode/test/server/httpapi-pty.test.ts | 2 +- .../opencode/test/server/httpapi-sdk.test.ts | 566 +++++++++++++++++- .../test/server/httpapi-session.test.ts | 2 +- .../opencode/test/server/httpapi-sync.test.ts | 4 +- .../opencode/test/server/httpapi-tui.test.ts | 2 +- .../test/server/httpapi-workspace.test.ts | 116 +++- .../__snapshots__/parameters.test.ts.snap | 15 +- 98 files changed, 4290 insertions(+), 2766 deletions(-) create mode 100755 packages/opencode/scripts/diff-sdk-types.sh create mode 100644 packages/opencode/src/server/backend.ts create mode 100644 packages/opencode/src/server/routes/instance/httpapi/api.ts delete mode 100644 packages/opencode/src/server/routes/instance/httpapi/global.ts rename packages/opencode/src/server/routes/instance/httpapi/{ => groups}/config.ts (54%) rename packages/opencode/src/server/routes/instance/httpapi/{ => groups}/control.ts (59%) rename packages/opencode/src/server/routes/instance/httpapi/{ => groups}/experimental.ts (51%) rename packages/opencode/src/server/routes/instance/httpapi/{ => groups}/file.ts (58%) create mode 100644 packages/opencode/src/server/routes/instance/httpapi/groups/global.ts rename packages/opencode/src/server/routes/instance/httpapi/{ => groups}/instance.ts (58%) rename packages/opencode/src/server/routes/instance/httpapi/{ => groups}/mcp.ts (51%) create mode 100644 packages/opencode/src/server/routes/instance/httpapi/groups/metadata.ts rename packages/opencode/src/server/routes/instance/httpapi/{ => groups}/permission.ts (57%) rename packages/opencode/src/server/routes/instance/httpapi/{ => groups}/project.ts (50%) create mode 100644 packages/opencode/src/server/routes/instance/httpapi/groups/provider.ts create mode 100644 packages/opencode/src/server/routes/instance/httpapi/groups/pty.ts rename packages/opencode/src/server/routes/instance/httpapi/{ => groups}/question.ts (58%) create mode 100644 packages/opencode/src/server/routes/instance/httpapi/groups/session.ts create mode 100644 packages/opencode/src/server/routes/instance/httpapi/groups/sync.ts create mode 100644 packages/opencode/src/server/routes/instance/httpapi/groups/tui.ts create mode 100644 packages/opencode/src/server/routes/instance/httpapi/groups/workspace.ts create mode 100644 packages/opencode/src/server/routes/instance/httpapi/handlers/config.ts create mode 100644 packages/opencode/src/server/routes/instance/httpapi/handlers/control.ts create mode 100644 packages/opencode/src/server/routes/instance/httpapi/handlers/experimental.ts create mode 100644 packages/opencode/src/server/routes/instance/httpapi/handlers/file.ts create mode 100644 packages/opencode/src/server/routes/instance/httpapi/handlers/global.ts create mode 100644 packages/opencode/src/server/routes/instance/httpapi/handlers/instance.ts create mode 100644 packages/opencode/src/server/routes/instance/httpapi/handlers/mcp.ts create mode 100644 packages/opencode/src/server/routes/instance/httpapi/handlers/permission.ts create mode 100644 packages/opencode/src/server/routes/instance/httpapi/handlers/project.ts create mode 100644 packages/opencode/src/server/routes/instance/httpapi/handlers/provider.ts create mode 100644 packages/opencode/src/server/routes/instance/httpapi/handlers/pty.ts create mode 100644 packages/opencode/src/server/routes/instance/httpapi/handlers/question.ts rename packages/opencode/src/server/routes/instance/httpapi/{ => handlers}/session.ts (51%) create mode 100644 packages/opencode/src/server/routes/instance/httpapi/handlers/sync.ts create mode 100644 packages/opencode/src/server/routes/instance/httpapi/handlers/tui.ts create mode 100644 packages/opencode/src/server/routes/instance/httpapi/handlers/workspace.ts create mode 100644 packages/opencode/src/server/routes/instance/httpapi/instance-context.ts delete mode 100644 packages/opencode/src/server/routes/instance/httpapi/provider.ts delete mode 100644 packages/opencode/src/server/routes/instance/httpapi/pty.ts delete mode 100644 packages/opencode/src/server/routes/instance/httpapi/sync.ts delete mode 100644 packages/opencode/src/server/routes/instance/httpapi/tui.ts delete mode 100644 packages/opencode/src/server/routes/instance/httpapi/workspace.ts diff --git a/packages/opencode/scripts/diff-sdk-types.sh b/packages/opencode/scripts/diff-sdk-types.sh new file mode 100755 index 0000000000..b27a31e8c3 --- /dev/null +++ b/packages/opencode/scripts/diff-sdk-types.sh @@ -0,0 +1,52 @@ +#!/bin/bash +# Compare SDK types generated from Hono vs HttpApi specs. +# Sorts types alphabetically so only meaningful body differences show. +# +# Usage: ./scripts/diff-sdk-types.sh # full diff +# ./scripts/diff-sdk-types.sh --stat # summary only +set -euo pipefail + +DIR="$(cd "$(dirname "$0")/.." && pwd)" +SDK="$(cd "$DIR/../sdk/js" && pwd)" + +normalize() { + python3 -c " +import re, sys +content = open(sys.argv[1]).read() +blocks = re.split(r'(?=^export (?:type|function|const) )', content, flags=re.MULTILINE) +header, body = blocks[0], blocks[1:] +body.sort(key=lambda b: m.group(1) if (m := re.match(r'export \w+ (\w+)', b)) else '') +sys.stdout.write(header + ''.join(body)) +" "$1" +} + +echo "Generating Hono SDK..." >&2 +(cd "$SDK" && bun run script/build.ts >/dev/null 2>&1) +normalize "$SDK/src/v2/gen/types.gen.ts" > /tmp/sdk-types-hono.ts +git -C "$SDK" checkout -- src/ 2>/dev/null + +echo "Generating HttpApi SDK..." >&2 +(cd "$SDK" && OPENCODE_SDK_OPENAPI=httpapi bun run script/build.ts >/dev/null 2>&1) +normalize "$SDK/src/v2/gen/types.gen.ts" > /tmp/sdk-types-httpapi.ts +git -C "$SDK" checkout -- src/ 2>/dev/null + +echo "" >&2 +if [[ "${1:-}" == "--stat" ]]; then + diff_output=$(diff /tmp/sdk-types-hono.ts /tmp/sdk-types-httpapi.ts || true) + honly=$(printf "%s\n" "$diff_output" | grep -c '^< export type' || true) + aonly=$(printf "%s\n" "$diff_output" | grep -c '^> export type' || true) + total=$(printf "%s\n" "$diff_output" | wc -l | tr -d ' ') + echo "Hono-only: $honly types HttpApi-only: $aonly types Diff lines: $total" + echo "" + if [[ $honly -gt 0 ]]; then + echo "=== Hono-only types ===" + printf "%s\n" "$diff_output" | grep '^< export type' | sed 's/< export type //' | sed 's/[ =].*//' | sed 's/^/ /' + echo "" + fi + if [[ $aonly -gt 0 ]]; then + echo "=== HttpApi-only types ===" + printf "%s\n" "$diff_output" | grep '^> export type' | sed 's/> export type //' | sed 's/[ =].*//' | sed 's/^/ /' + fi +else + diff /tmp/sdk-types-hono.ts /tmp/sdk-types-httpapi.ts || true +fi diff --git a/packages/opencode/specs/effect/http-api.md b/packages/opencode/specs/effect/http-api.md index 791aa0e28f..6d6602e946 100644 --- a/packages/opencode/specs/effect/http-api.md +++ b/packages/opencode/specs/effect/http-api.md @@ -129,6 +129,14 @@ Required before route deletion: - Compare generated SDK output against `dev` for every route group deletion. - Remove Hono OpenAPI stubs only after Effect OpenAPI is the SDK source for those paths. +V2 cleanup once SDK compatibility no longer needs the legacy Hono contract: + +- Remove `public.ts` compatibility transforms that hide honest `HttpApi` metadata, including auth `securitySchemes`, per-route `security`, and generated `401` responses. +- Stop remapping built-in `HttpApi` error schemas back to legacy Hono `BadRequestError` / `NotFoundError` components if V2 clients can consume the actual Effect error shape. +- Prefer the direct `HttpApi` OpenAPI output for request/response bodies and named component schemas instead of rewriting it to match Hono generator quirks. +- Keep schema fixes that describe the actual wire format, but delete transforms that only preserve legacy SDK type names or inline-vs-ref shape. +- Re-evaluate `auth_token` as an OpenAPI security scheme rather than a hand-injected query parameter once clients can consume the V2 spec. + ### 5. Make HttpApi Default For JSON Routes After JSON parity and SDK generation are covered: diff --git a/packages/opencode/src/agent/agent.ts b/packages/opencode/src/agent/agent.ts index 5e839ead5c..81dbded082 100644 --- a/packages/opencode/src/agent/agent.ts +++ b/packages/opencode/src/agent/agent.ts @@ -31,8 +31,8 @@ export const Info = Schema.Struct({ mode: Schema.Literals(["subagent", "primary", "all"]), native: Schema.optional(Schema.Boolean), hidden: Schema.optional(Schema.Boolean), - topP: Schema.optional(Schema.Number), - temperature: Schema.optional(Schema.Number), + topP: Schema.optional(Schema.Finite), + temperature: Schema.optional(Schema.Finite), color: Schema.optional(Schema.String), permission: Permission.Ruleset, model: Schema.optional( @@ -44,7 +44,7 @@ export const Info = Schema.Struct({ variant: Schema.optional(Schema.String), prompt: Schema.optional(Schema.String), options: Schema.Record(Schema.String, Schema.Unknown), - steps: Schema.optional(Schema.Number), + steps: Schema.optional(Schema.Finite), }) .annotate({ identifier: "Agent" }) .pipe(withStatics((s) => ({ zod: zod(s) }))) diff --git a/packages/opencode/src/auth/index.ts b/packages/opencode/src/auth/index.ts index 539c40c1ae..3d6a0d91d0 100644 --- a/packages/opencode/src/auth/index.ts +++ b/packages/opencode/src/auth/index.ts @@ -1,6 +1,7 @@ import path from "path" import { Effect, Layer, Record, Result, Schema, Context } from "effect" import { zod } from "@/util/effect-zod" +import { NonNegativeInt } from "@/util/schema" import { Global } from "@opencode-ai/core/global" import { AppFileSystem } from "@opencode-ai/core/filesystem" @@ -14,7 +15,7 @@ export class Oauth extends Schema.Class("OAuth")({ type: Schema.Literal("oauth"), refresh: Schema.String, access: Schema.String, - expires: Schema.Number, + expires: NonNegativeInt, accountId: Schema.optional(Schema.String), enterpriseUrl: Schema.optional(Schema.String), }) {} diff --git a/packages/opencode/src/bus/bus-event.ts b/packages/opencode/src/bus/bus-event.ts index f27d263354..cf9fcfbeec 100644 --- a/packages/opencode/src/bus/bus-event.ts +++ b/packages/opencode/src/bus/bus-event.ts @@ -34,4 +34,16 @@ export function payloads() { .toArray() } +export function effectPayloads() { + return registry + .entries() + .map(([type, def]) => + Schema.Struct({ + type: Schema.Literal(type), + properties: def.properties, + }).annotate({ identifier: `Event.${type}` }), + ) + .toArray() +} + export * as BusEvent from "./bus-event" diff --git a/packages/opencode/src/cli/cmd/tui/event.ts b/packages/opencode/src/cli/cmd/tui/event.ts index 1c764c12fe..fbe5ce7f9f 100644 --- a/packages/opencode/src/cli/cmd/tui/event.ts +++ b/packages/opencode/src/cli/cmd/tui/event.ts @@ -1,5 +1,6 @@ import { BusEvent } from "@/bus/bus-event" import { SessionID } from "@/session/schema" +import { PositiveInt } from "@/util/schema" import { Effect, Schema } from "effect" const DEFAULT_TOAST_DURATION = 5000 @@ -38,7 +39,7 @@ export const TuiEvent = { title: Schema.optional(Schema.String), message: Schema.String, variant: Schema.Literals(["info", "success", "warning", "error"]), - duration: Schema.Number.pipe(Schema.withDecodingDefault(Effect.succeed(DEFAULT_TOAST_DURATION))).annotate({ + duration: PositiveInt.pipe(Schema.withDecodingDefault(Effect.succeed(DEFAULT_TOAST_DURATION))).annotate({ description: "Duration in milliseconds", }), }), diff --git a/packages/opencode/src/config/agent.ts b/packages/opencode/src/config/agent.ts index e673edbad4..e72f658728 100644 --- a/packages/opencode/src/config/agent.ts +++ b/packages/opencode/src/config/agent.ts @@ -26,8 +26,8 @@ const AgentSchema = Schema.StructWithRest( variant: Schema.optional(Schema.String).annotate({ description: "Default model variant for this agent (applies only when using the agent's configured model).", }), - temperature: Schema.optional(Schema.Number), - top_p: Schema.optional(Schema.Number), + temperature: Schema.optional(Schema.Finite), + top_p: Schema.optional(Schema.Finite), prompt: Schema.optional(Schema.String), tools: Schema.optional(Schema.Record(Schema.String, Schema.Boolean)).annotate({ description: "@deprecated Use 'permission' field instead", diff --git a/packages/opencode/src/config/console-state.ts b/packages/opencode/src/config/console-state.ts index 08668afe4e..0d4f20df91 100644 --- a/packages/opencode/src/config/console-state.ts +++ b/packages/opencode/src/config/console-state.ts @@ -1,10 +1,11 @@ import { Schema } from "effect" import { zod } from "@/util/effect-zod" +import { NonNegativeInt } from "@/util/schema" export class ConsoleState extends Schema.Class("ConsoleState")({ consoleManagedProviders: Schema.mutable(Schema.Array(Schema.String)), activeOrgName: Schema.optional(Schema.String), - switchableOrgCount: Schema.Number, + switchableOrgCount: NonNegativeInt, }) { static readonly zod = zod(this) } diff --git a/packages/opencode/src/config/mcp.ts b/packages/opencode/src/config/mcp.ts index 0887fa984a..fc31ba356f 100644 --- a/packages/opencode/src/config/mcp.ts +++ b/packages/opencode/src/config/mcp.ts @@ -1,6 +1,6 @@ import { Schema } from "effect" import { zod } from "@/util/effect-zod" -import { withStatics } from "@/util/schema" +import { PositiveInt, withStatics } from "@/util/schema" export const Local = Schema.Struct({ type: Schema.Literal("local").annotate({ description: "Type of MCP server connection" }), @@ -13,7 +13,7 @@ export const Local = Schema.Struct({ enabled: Schema.optional(Schema.Boolean).annotate({ description: "Enable or disable the MCP server on startup", }), - timeout: Schema.optional(Schema.Number).annotate({ + timeout: Schema.optional(PositiveInt).annotate({ description: "Timeout in ms for MCP server requests. Defaults to 5000 (5 seconds) if not specified.", }), }) @@ -49,7 +49,7 @@ export const Remote = Schema.Struct({ oauth: Schema.optional(Schema.Union([OAuth, Schema.Literal(false)])).annotate({ description: "OAuth authentication configuration for the MCP server. Set to false to disable OAuth auto-detection.", }), - timeout: Schema.optional(Schema.Number).annotate({ + timeout: Schema.optional(PositiveInt).annotate({ description: "Timeout in ms for MCP server requests. Defaults to 5000 (5 seconds) if not specified.", }), }) diff --git a/packages/opencode/src/config/provider.ts b/packages/opencode/src/config/provider.ts index cd7469435c..7821bca5a9 100644 --- a/packages/opencode/src/config/provider.ts +++ b/packages/opencode/src/config/provider.ts @@ -21,25 +21,25 @@ export const Model = Schema.Struct({ ), cost: Schema.optional( Schema.Struct({ - input: Schema.Number, - output: Schema.Number, - cache_read: Schema.optional(Schema.Number), - cache_write: Schema.optional(Schema.Number), + input: Schema.Finite, + output: Schema.Finite, + cache_read: Schema.optional(Schema.Finite), + cache_write: Schema.optional(Schema.Finite), context_over_200k: Schema.optional( Schema.Struct({ - input: Schema.Number, - output: Schema.Number, - cache_read: Schema.optional(Schema.Number), - cache_write: Schema.optional(Schema.Number), + input: Schema.Finite, + output: Schema.Finite, + cache_read: Schema.optional(Schema.Finite), + cache_write: Schema.optional(Schema.Finite), }), ), }), ), limit: Schema.optional( Schema.Struct({ - context: Schema.Number, - input: Schema.optional(Schema.Number), - output: Schema.Number, + context: Schema.Finite, + input: Schema.optional(Schema.Finite), + output: Schema.Finite, }), ), modalities: Schema.optional( diff --git a/packages/opencode/src/file/index.ts b/packages/opencode/src/file/index.ts index 122add21fa..4a474881cb 100644 --- a/packages/opencode/src/file/index.ts +++ b/packages/opencode/src/file/index.ts @@ -15,12 +15,12 @@ import * as Log from "@opencode-ai/core/util/log" import { Protected } from "./protected" import { Ripgrep } from "./ripgrep" import { zod } from "@/util/effect-zod" -import { type DeepMutable, withStatics } from "@/util/schema" +import { NonNegativeInt, type DeepMutable, withStatics } from "@/util/schema" export const Info = Schema.Struct({ path: Schema.String, - added: Schema.Int, - removed: Schema.Int, + added: NonNegativeInt, + removed: NonNegativeInt, status: Schema.Literals(["added", "deleted", "modified"]), }) .annotate({ identifier: "File" }) @@ -39,10 +39,10 @@ export const Node = Schema.Struct({ export type Node = DeepMutable> const Hunk = Schema.Struct({ - oldStart: Schema.Number, - oldLines: Schema.Number, - newStart: Schema.Number, - newLines: Schema.Number, + oldStart: NonNegativeInt, + oldLines: NonNegativeInt, + newStart: NonNegativeInt, + newLines: NonNegativeInt, lines: Schema.Array(Schema.String), }) diff --git a/packages/opencode/src/file/ripgrep.ts b/packages/opencode/src/file/ripgrep.ts index 3a5411c31e..27fd5f2323 100644 --- a/packages/opencode/src/file/ripgrep.ts +++ b/packages/opencode/src/file/ripgrep.ts @@ -12,7 +12,7 @@ import * as Log from "@opencode-ai/core/util/log" import { sanitizedProcessEnv } from "@opencode-ai/core/util/opencode-process" import { which } from "@/util/which" import { zod } from "@/util/effect-zod" -import { withStatics } from "@/util/schema" +import { NonNegativeInt, withStatics } from "@/util/schema" const log = Log.create({ service: "ripgrep" }) const VERSION = "15.1.0" @@ -27,19 +27,19 @@ const PLATFORM = { } as const const TimeStats = Schema.Struct({ - secs: Schema.Number, - nanos: Schema.Number, + secs: NonNegativeInt, + nanos: NonNegativeInt, human: Schema.String, }) const Stats = Schema.Struct({ elapsed: TimeStats, - searches: Schema.Number, - searches_with_match: Schema.Number, - bytes_searched: Schema.Number, - bytes_printed: Schema.Number, - matched_lines: Schema.Number, - matches: Schema.Number, + searches: NonNegativeInt, + searches_with_match: NonNegativeInt, + bytes_searched: NonNegativeInt, + bytes_printed: NonNegativeInt, + matched_lines: NonNegativeInt, + matches: NonNegativeInt, }) const PathText = Schema.Struct({ @@ -58,15 +58,15 @@ export const SearchMatch = Schema.Struct({ lines: Schema.Struct({ text: Schema.String, }), - line_number: Schema.Number, - absolute_offset: Schema.Number, + line_number: NonNegativeInt, + absolute_offset: NonNegativeInt, submatches: Schema.Array( Schema.Struct({ match: Schema.Struct({ text: Schema.String, }), - start: Schema.Number, - end: Schema.Number, + start: NonNegativeInt, + end: NonNegativeInt, }), ), }).pipe(withStatics((s) => ({ zod: zod(s) }))) @@ -80,7 +80,7 @@ const End = Schema.Struct({ type: Schema.Literal("end"), data: Schema.Struct({ path: PathText, - binary_offset: Schema.NullOr(Schema.Number), + binary_offset: Schema.NullOr(NonNegativeInt), stats: Stats, }), }) diff --git a/packages/opencode/src/lsp/lsp.ts b/packages/opencode/src/lsp/lsp.ts index 45a8189976..5fcff772ec 100644 --- a/packages/opencode/src/lsp/lsp.ts +++ b/packages/opencode/src/lsp/lsp.ts @@ -13,7 +13,7 @@ import { spawn as lspspawn } from "./launch" import { Effect, Layer, Context, Schema } from "effect" import { InstanceState } from "@/effect/instance-state" import { AppFileSystem } from "@opencode-ai/core/filesystem" -import { withStatics } from "@/util/schema" +import { NonNegativeInt, withStatics } from "@/util/schema" import { zod, ZodOverride } from "@/util/effect-zod" const log = Log.create({ service: "lsp" }) @@ -23,8 +23,8 @@ export const Event = { } const Position = Schema.Struct({ - line: Schema.Number, - character: Schema.Number, + line: NonNegativeInt, + character: NonNegativeInt, }) export const Range = Schema.Struct({ @@ -37,7 +37,7 @@ export type Range = typeof Range.Type export const Symbol = Schema.Struct({ name: Schema.String, - kind: Schema.Number, + kind: NonNegativeInt, location: Schema.Struct({ uri: Schema.String, range: Range, @@ -50,7 +50,7 @@ export type Symbol = typeof Symbol.Type export const DocumentSymbol = Schema.Struct({ name: Schema.String, detail: Schema.optional(Schema.String), - kind: Schema.Number, + kind: NonNegativeInt, range: Range, selectionRange: Range, }) diff --git a/packages/opencode/src/project/project.ts b/packages/opencode/src/project/project.ts index 648bfc8fed..4229112a83 100644 --- a/packages/opencode/src/project/project.ts +++ b/packages/opencode/src/project/project.ts @@ -16,7 +16,7 @@ import { NodePath } from "@effect/platform-node" import { AppFileSystem } from "@opencode-ai/core/filesystem" import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" import { zod } from "@/util/effect-zod" -import { withStatics } from "@/util/schema" +import { NonNegativeInt, withStatics } from "@/util/schema" const log = Log.create({ service: "project" }) @@ -35,9 +35,9 @@ const ProjectCommands = Schema.Struct({ }) const ProjectTime = Schema.Struct({ - created: Schema.Number, - updated: Schema.Number, - initialized: Schema.optional(Schema.Number), + created: NonNegativeInt, + updated: NonNegativeInt, + initialized: Schema.optional(NonNegativeInt), }) export const Info = Schema.Struct({ diff --git a/packages/opencode/src/project/vcs.ts b/packages/opencode/src/project/vcs.ts index e12a031d63..24112cf442 100644 --- a/packages/opencode/src/project/vcs.ts +++ b/packages/opencode/src/project/vcs.ts @@ -9,7 +9,7 @@ import { FileWatcher } from "@/file/watcher" import { Git } from "@/git" import * as Log from "@opencode-ai/core/util/log" import { zod } from "@/util/effect-zod" -import { withStatics } from "@/util/schema" +import { NonNegativeInt, withStatics } from "@/util/schema" const log = Log.create({ service: "vcs" }) @@ -125,8 +125,8 @@ export type Info = Schema.Schema.Type export const FileDiff = Schema.Struct({ file: Schema.String, patch: Schema.String, - additions: Schema.Number, - deletions: Schema.Number, + additions: NonNegativeInt, + deletions: NonNegativeInt, status: Schema.optional(Schema.Literals(["added", "deleted", "modified"])), }) .annotate({ identifier: "VcsFileDiff" }) diff --git a/packages/opencode/src/provider/auth.ts b/packages/opencode/src/provider/auth.ts index 4df83f0204..6cbfcf1be2 100644 --- a/packages/opencode/src/provider/auth.ts +++ b/packages/opencode/src/provider/auth.ts @@ -58,13 +58,13 @@ export class Authorization extends Schema.Class("ProviderAuthAuth } export const AuthorizeInput = Schema.Struct({ - method: Schema.Number.annotate({ description: "Auth method index" }), + method: Schema.Finite.annotate({ description: "Auth method index" }), inputs: Schema.optional(Schema.Record(Schema.String, Schema.String)).annotate({ description: "Prompt inputs" }), }).pipe(withStatics((s) => ({ zod: zod(s) }))) export type AuthorizeInput = Schema.Schema.Type export const CallbackInput = Schema.Struct({ - method: Schema.Number.annotate({ description: "Auth method index" }), + method: Schema.Finite.annotate({ description: "Auth method index" }), code: Schema.optional(Schema.String).annotate({ description: "OAuth authorization code" }), }).pipe(withStatics((s) => ({ zod: zod(s) }))) export type CallbackInput = Schema.Schema.Type diff --git a/packages/opencode/src/provider/models.ts b/packages/opencode/src/provider/models.ts index ed2d11eb72..170fe516c9 100644 --- a/packages/opencode/src/provider/models.ts +++ b/packages/opencode/src/provider/models.ts @@ -22,16 +22,16 @@ const filepath = path.join( const ttl = 5 * 60 * 1000 const Cost = Schema.Struct({ - input: Schema.Number, - output: Schema.Number, - cache_read: Schema.optional(Schema.Number), - cache_write: Schema.optional(Schema.Number), + input: Schema.Finite, + output: Schema.Finite, + cache_read: Schema.optional(Schema.Finite), + cache_write: Schema.optional(Schema.Finite), context_over_200k: Schema.optional( Schema.Struct({ - input: Schema.Number, - output: Schema.Number, - cache_read: Schema.optional(Schema.Number), - cache_write: Schema.optional(Schema.Number), + input: Schema.Finite, + output: Schema.Finite, + cache_read: Schema.optional(Schema.Finite), + cache_write: Schema.optional(Schema.Finite), }), ), }) @@ -55,9 +55,9 @@ export const Model = Schema.Struct({ ), cost: Schema.optional(Cost), limit: Schema.Struct({ - context: Schema.Number, - input: Schema.optional(Schema.Number), - output: Schema.Number, + context: Schema.Finite, + input: Schema.optional(Schema.Finite), + output: Schema.Finite, }), modalities: Schema.optional( Schema.Struct({ diff --git a/packages/opencode/src/provider/provider.ts b/packages/opencode/src/provider/provider.ts index c05d053193..48df5a4c9d 100644 --- a/packages/opencode/src/provider/provider.ts +++ b/packages/opencode/src/provider/provider.ts @@ -848,27 +848,27 @@ const ProviderCapabilities = Schema.Struct({ }) const ProviderCacheCost = Schema.Struct({ - read: Schema.Number, - write: Schema.Number, + read: Schema.Finite, + write: Schema.Finite, }) const ProviderCost = Schema.Struct({ - input: Schema.Number, - output: Schema.Number, + input: Schema.Finite, + output: Schema.Finite, cache: ProviderCacheCost, experimentalOver200K: Schema.optional( Schema.Struct({ - input: Schema.Number, - output: Schema.Number, + input: Schema.Finite, + output: Schema.Finite, cache: ProviderCacheCost, }), ), }) const ProviderLimit = Schema.Struct({ - context: Schema.Number, - input: Schema.optional(Schema.Number), - output: Schema.Number, + context: Schema.Finite, + input: Schema.optional(Schema.Finite), + output: Schema.Finite, }) export const Model = Schema.Struct({ diff --git a/packages/opencode/src/pty/index.ts b/packages/opencode/src/pty/index.ts index beccade09b..2518800ce8 100644 --- a/packages/opencode/src/pty/index.ts +++ b/packages/opencode/src/pty/index.ts @@ -12,7 +12,7 @@ import * as Log from "@opencode-ai/core/util/log" import { PtyID } from "./schema" import { Effect, Layer, Context, Schema, Types } from "effect" import { zod } from "@/util/effect-zod" -import { withStatics } from "@/util/schema" +import { NonNegativeInt, PositiveInt, withStatics } from "@/util/schema" const log = Log.create({ service: "pty" }) @@ -62,7 +62,7 @@ export const Info = Schema.Struct({ args: Schema.Array(Schema.String), cwd: Schema.String, status: Schema.Literals(["running", "exited"]), - pid: Schema.Number, + pid: PositiveInt, }) .annotate({ identifier: "Pty" }) .pipe(withStatics((s) => ({ zod: zod(s) }))) @@ -83,8 +83,8 @@ export const UpdateInput = Schema.Struct({ title: Schema.optional(Schema.String), size: Schema.optional( Schema.Struct({ - rows: Schema.Number, - cols: Schema.Number, + rows: PositiveInt, + cols: PositiveInt, }), ), }).pipe(withStatics((s) => ({ zod: zod(s) }))) @@ -94,7 +94,7 @@ export type UpdateInput = Types.DeepMutable + +export function select(): Selection { + if (Flag.OPENCODE_EXPERIMENTAL_HTTPAPI) return { backend: "effect-httpapi", reason: "env" } + return { backend: "hono", reason: "stable" } +} + +export function attributes(selection: Selection): Record { + return { + "opencode.server.backend": selection.backend, + "opencode.server.backend.reason": selection.reason, + "opencode.installation.channel": InstallationChannel, + "opencode.installation.version": InstallationVersion, + } +} + +export function force(selection: Selection, backend: Backend): Selection { + return { + backend, + reason: selection.backend === backend ? selection.reason : "explicit", + } +} diff --git a/packages/opencode/src/server/middleware.ts b/packages/opencode/src/server/middleware.ts index ffcfd4ce01..c653156d33 100644 --- a/packages/opencode/src/server/middleware.ts +++ b/packages/opencode/src/server/middleware.ts @@ -10,6 +10,7 @@ import { Flag } from "@opencode-ai/core/flag/flag" import { basicAuth } from "hono/basic-auth" import { cors } from "hono/cors" import { compress } from "hono/compress" +import * as ServerBackend from "./backend" const log = Log.create({ service: "server" }) @@ -49,20 +50,20 @@ export const AuthMiddleware: MiddlewareHandler = (c, next) => { return basicAuth({ username, password })(c, next) } -export const LoggerMiddleware: MiddlewareHandler = async (c, next) => { - const skip = c.req.path === "/log" - if (!skip) { - log.info("request", { +export function LoggerMiddleware(backendAttributes: ServerBackend.Attributes): MiddlewareHandler { + return async (c, next) => { + const skip = c.req.path === "/log" + if (skip) return next() + const attributes = { method: c.req.method, path: c.req.path, - }) + ...backendAttributes, + } + log.info("request", attributes) + const timer = log.time("request", attributes) + await next() + timer.stop() } - const timer = log.time("request", { - method: c.req.method, - path: c.req.path, - }) - await next() - if (!skip) timer.stop() } export function CorsMiddleware(opts?: { cors?: string[] }): MiddlewareHandler { diff --git a/packages/opencode/src/server/proxy.ts b/packages/opencode/src/server/proxy.ts index 441d7a5c2d..f93150020d 100644 --- a/packages/opencode/src/server/proxy.ts +++ b/packages/opencode/src/server/proxy.ts @@ -33,7 +33,7 @@ function headers(req: Request, extra?: HeadersInit) { return out } -function protocols(req: Request) { +export function websocketProtocols(req: Request) { const value = req.headers.get("sec-websocket-protocol") if (!value) return [] return value @@ -42,7 +42,7 @@ function protocols(req: Request) { .filter(Boolean) } -function socket(url: string | URL) { +export function websocketTargetURL(url: string | URL) { const next = new URL(url) if (next.protocol === "http:") next.protocol = "ws:" if (next.protocol === "https:") next.protocol = "wss:" @@ -69,7 +69,7 @@ const app = (upgrade: UpgradeWebSocket) => ws.close(1011, "missing proxy target") return } - remote = new WebSocket(url, protocols(c.req.raw)) + remote = new WebSocket(url, websocketProtocols(c.req.raw)) remote.binaryType = "arraybuffer" remote.onopen = () => { for (const item of queue) remote?.send(item) @@ -150,7 +150,7 @@ export function websocket( proxy.pathname = "/__workspace_ws" proxy.search = "" const next = new Headers(req.headers) - next.set("x-opencode-proxy-url", socket(target)) + next.set("x-opencode-proxy-url", websocketTargetURL(target)) for (const [key, value] of new Headers(extra).entries()) { next.set(key, value) } diff --git a/packages/opencode/src/server/routes/instance/httpapi/api.ts b/packages/opencode/src/server/routes/instance/httpapi/api.ts new file mode 100644 index 0000000000..81ea2394c0 --- /dev/null +++ b/packages/opencode/src/server/routes/instance/httpapi/api.ts @@ -0,0 +1,54 @@ +import { Schema } from "effect" +import { HttpApi } from "effect/unstable/httpapi" +import { BusEvent } from "@/bus/bus-event" +import { SyncEvent } from "@/sync" +import { ConfigApi } from "./groups/config" +import { ControlApi } from "./groups/control" +import { EventApi } from "./event" +import { ExperimentalApi } from "./groups/experimental" +import { FileApi } from "./groups/file" +import { GlobalApi } from "./groups/global" +import { InstanceApi } from "./groups/instance" +import { McpApi } from "./groups/mcp" +import { PermissionApi } from "./groups/permission" +import { ProjectApi } from "./groups/project" +import { ProviderApi } from "./groups/provider" +import { PtyApi, PtyConnectApi } from "./groups/pty" +import { QuestionApi } from "./groups/question" +import { SessionApi } from "./groups/session" +import { SyncApi } from "./groups/sync" +import { TuiApi } from "./groups/tui" +import { WorkspaceApi } from "./groups/workspace" + +// SSE event schemas built from the same BusEvent/SyncEvent registries that +// the Hono spec uses, so both specs emit identical Event/SyncEvent components. +const EventSchema = Schema.Union(BusEvent.effectPayloads()).annotate({ identifier: "Event" }) +const SyncEventSchemas = SyncEvent.effectPayloads() + +export const RootHttpApi = HttpApi.make("opencode-root").addHttpApi(ControlApi).addHttpApi(GlobalApi) + +export const InstanceHttpApi = HttpApi.make("opencode-instance") + .addHttpApi(ConfigApi) + .addHttpApi(ExperimentalApi) + .addHttpApi(FileApi) + .addHttpApi(InstanceApi) + .addHttpApi(McpApi) + .addHttpApi(ProjectApi) + .addHttpApi(PtyApi) + .addHttpApi(QuestionApi) + .addHttpApi(PermissionApi) + .addHttpApi(ProviderApi) + .addHttpApi(SessionApi) + .addHttpApi(SyncApi) + .addHttpApi(TuiApi) + .addHttpApi(WorkspaceApi) + +export const OpenCodeHttpApi = HttpApi.make("opencode") + .addHttpApi(RootHttpApi) + .addHttpApi(EventApi) + .addHttpApi(InstanceHttpApi) + .addHttpApi(PtyConnectApi) + .annotate(HttpApi.AdditionalSchemas, [EventSchema, ...SyncEventSchemas]) + +export type RootHttpApiType = typeof RootHttpApi +export type InstanceHttpApiType = typeof InstanceHttpApi diff --git a/packages/opencode/src/server/routes/instance/httpapi/event.ts b/packages/opencode/src/server/routes/instance/httpapi/event.ts index 1d548e0baf..9f4ddde4c2 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/event.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/event.ts @@ -4,6 +4,7 @@ import { Effect, Schema } from "effect" import * as Stream from "effect/Stream" import { HttpRouter, HttpServerResponse } from "effect/unstable/http" import { HttpApi, HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi" +import * as Sse from "effect/unstable/encoding/Sse" const log = Log.create({ service: "server" }) @@ -27,8 +28,13 @@ export const EventApi = HttpApi.make("event").add( .annotateMerge(OpenApi.annotations({ title: "event", description: "Instance event stream route." })), ) -function eventData(data: unknown) { - return `data: ${JSON.stringify(data)}\n\n` +function eventData(data: unknown): Sse.Event { + return { + _tag: "Event", + event: "message", + id: undefined, + data: JSON.stringify(data), + } } export const eventRoute = HttpRouter.add( @@ -47,6 +53,7 @@ export const eventRoute = HttpRouter.add( Stream.make({ type: "server.connected", properties: {} }).pipe( Stream.concat(events.pipe(Stream.merge(heartbeat, { haltStrategy: "left" }))), Stream.map(eventData), + Stream.pipeThroughChannel(Sse.encode()), Stream.encodeText, Stream.ensuring(Effect.sync(() => log.info("event disconnected"))), ), diff --git a/packages/opencode/src/server/routes/instance/httpapi/global.ts b/packages/opencode/src/server/routes/instance/httpapi/global.ts deleted file mode 100644 index ef7fb331f6..0000000000 --- a/packages/opencode/src/server/routes/instance/httpapi/global.ts +++ /dev/null @@ -1,259 +0,0 @@ -import { Config } from "@/config/config" -import { GlobalBus, type GlobalEvent as GlobalBusEvent } from "@/bus/global" -import { Installation } from "@/installation" -import { Instance } from "@/project/instance" -import { InstallationVersion } from "@opencode-ai/core/installation/version" -import * as Log from "@opencode-ai/core/util/log" -import { Effect, Schema } from "effect" -import { HttpServerRequest, HttpServerResponse } from "effect/unstable/http" -import { HttpApi, HttpApiBuilder, HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi" - -const log = Log.create({ service: "server" }) - -const GlobalHealth = Schema.Struct({ - healthy: Schema.Literal(true), - version: Schema.String, -}).annotate({ identifier: "GlobalHealth" }) - -const GlobalEventSchema = Schema.Struct({ - directory: Schema.String, - project: Schema.optional(Schema.String), - workspace: Schema.optional(Schema.String), - payload: Schema.Unknown, -}).annotate({ identifier: "GlobalEvent" }) - -const GlobalUpgradeInput = Schema.Struct({ - target: Schema.optional(Schema.String), -}).annotate({ identifier: "GlobalUpgradeInput" }) - -const GlobalUpgradeResult = Schema.Union([ - Schema.Struct({ - success: Schema.Literal(true), - version: Schema.String, - }), - Schema.Struct({ - success: Schema.Literal(false), - error: Schema.String, - }), -]).annotate({ identifier: "GlobalUpgradeResult" }) - -export const GlobalPaths = { - health: "/global/health", - event: "/global/event", - config: "/global/config", - dispose: "/global/dispose", - upgrade: "/global/upgrade", -} as const - -export const GlobalApi = HttpApi.make("global").add( - HttpApiGroup.make("global") - .add( - HttpApiEndpoint.get("health", GlobalPaths.health, { - success: GlobalHealth, - }).annotateMerge( - OpenApi.annotations({ - identifier: "global.health", - summary: "Get health", - description: "Get health information about the OpenCode server.", - }), - ), - HttpApiEndpoint.get("event", GlobalPaths.event, { - success: GlobalEventSchema, - }).annotateMerge( - OpenApi.annotations({ - identifier: "global.event", - summary: "Get global events", - description: "Subscribe to global events from the OpenCode system using server-sent events.", - }), - ), - HttpApiEndpoint.get("configGet", GlobalPaths.config, { - success: Config.Info, - }).annotateMerge( - OpenApi.annotations({ - identifier: "global.config.get", - summary: "Get global configuration", - description: "Retrieve the current global OpenCode configuration settings and preferences.", - }), - ), - HttpApiEndpoint.patch("configUpdate", GlobalPaths.config, { - payload: Config.Info, - success: Config.Info, - }).annotateMerge( - OpenApi.annotations({ - identifier: "global.config.update", - summary: "Update global configuration", - description: "Update global OpenCode configuration settings and preferences.", - }), - ), - HttpApiEndpoint.post("dispose", GlobalPaths.dispose, { - success: Schema.Boolean, - }).annotateMerge( - OpenApi.annotations({ - identifier: "global.dispose", - summary: "Dispose instance", - description: "Clean up and dispose all OpenCode instances, releasing all resources.", - }), - ), - HttpApiEndpoint.post("upgrade", GlobalPaths.upgrade, { - payload: GlobalUpgradeInput, - success: GlobalUpgradeResult, - }).annotateMerge( - OpenApi.annotations({ - identifier: "global.upgrade", - summary: "Upgrade opencode", - description: "Upgrade opencode to the specified version or latest if not specified.", - }), - ), - ) - .annotateMerge(OpenApi.annotations({ title: "global", description: "Global server routes." })), -) - -function eventData(data: unknown) { - return `data: ${JSON.stringify(data)}\n\n` -} - -function parseBody(body: string) { - try { - return JSON.parse(body || "{}") as unknown - } catch { - return undefined - } -} - -function eventResponse() { - const encoder = new TextEncoder() - let heartbeat: ReturnType | undefined - let unsubscribe = () => {} - let done = false - - const cleanup = () => { - if (done) return - done = true - if (heartbeat) clearInterval(heartbeat) - unsubscribe() - log.info("global event disconnected") - } - - log.info("global event connected") - return HttpServerResponse.raw( - new Response( - new ReadableStream({ - start(controller) { - const write = (data: unknown) => { - if (done) return - try { - controller.enqueue(encoder.encode(eventData(data))) - } catch { - cleanup() - } - } - const handler = (event: GlobalBusEvent) => write(event) - unsubscribe = () => GlobalBus.off("event", handler) - GlobalBus.on("event", handler) - write({ payload: { type: "server.connected", properties: {} } }) - heartbeat = setInterval(() => write({ payload: { type: "server.heartbeat", properties: {} } }), 10_000) - }, - cancel: cleanup, - }), - { - headers: { - "Cache-Control": "no-cache, no-transform", - "Content-Type": "text/event-stream", - "X-Accel-Buffering": "no", - "X-Content-Type-Options": "nosniff", - }, - }, - ), - ) -} - -export const globalHandlers = HttpApiBuilder.group(GlobalApi, "global", (handlers) => - Effect.gen(function* () { - const config = yield* Config.Service - const installation = yield* Installation.Service - - const health = Effect.fn("GlobalHttpApi.health")(function* () { - return { healthy: true as const, version: InstallationVersion } - }) - - const event = Effect.fn("GlobalHttpApi.event")(function* () { - return eventResponse() - }) - - const configGet = Effect.fn("GlobalHttpApi.configGet")(function* () { - return yield* config.getGlobal() - }) - - const configUpdate = Effect.fn("GlobalHttpApi.configUpdate")(function* (ctx) { - return yield* config.updateGlobal(ctx.payload) - }) - - const dispose = Effect.fn("GlobalHttpApi.dispose")(function* () { - yield* Effect.promise(() => Instance.disposeAll()) - GlobalBus.emit("event", { - directory: "global", - payload: { type: "global.disposed", properties: {} }, - }) - return true - }) - - const upgrade = Effect.fn("GlobalHttpApi.upgrade")(function* (ctx: { payload: typeof GlobalUpgradeInput.Type }) { - const method = yield* installation.method() - if (method === "unknown") { - return { - status: 400, - body: { success: false as const, error: "Unknown installation method" }, - } - } - const target = ctx.payload.target || (yield* installation.latest(method)) - const result = yield* installation.upgrade(method, target).pipe( - Effect.as({ status: 200, body: { success: true as const, version: target } }), - Effect.catch((err) => - Effect.succeed({ - status: 500, - body: { - success: false as const, - error: err instanceof Error ? err.message : String(err), - }, - }), - ), - ) - if (!result.body.success) return result - GlobalBus.emit("event", { - directory: "global", - payload: { - type: Installation.Event.Updated.type, - properties: { version: target }, - }, - }) - return result - }) - - const upgradeRaw = Effect.fn("GlobalHttpApi.upgradeRaw")(function* (ctx: { - request: HttpServerRequest.HttpServerRequest - }) { - const body = yield* Effect.orDie(ctx.request.text) - const json = parseBody(body) - if (json === undefined) { - return HttpServerResponse.jsonUnsafe({ success: false, error: "Invalid request body" }, { status: 400 }) - } - const payload = yield* Schema.decodeUnknownEffect(GlobalUpgradeInput)(json).pipe( - Effect.map((payload) => ({ valid: true as const, payload })), - Effect.catch(() => Effect.succeed({ valid: false as const })), - ) - if (!payload.valid) { - return HttpServerResponse.jsonUnsafe({ success: false, error: "Invalid request body" }, { status: 400 }) - } - const result = yield* upgrade({ payload: payload.payload }) - return HttpServerResponse.jsonUnsafe(result.body, { status: result.status }) - }) - - return handlers - .handle("health", health) - .handleRaw("event", event) - .handle("configGet", configGet) - .handle("configUpdate", configUpdate) - .handle("dispose", dispose) - .handleRaw("upgrade", upgradeRaw) - }), -) diff --git a/packages/opencode/src/server/routes/instance/httpapi/config.ts b/packages/opencode/src/server/routes/instance/httpapi/groups/config.ts similarity index 54% rename from packages/opencode/src/server/routes/instance/httpapi/config.ts rename to packages/opencode/src/server/routes/instance/httpapi/groups/config.ts index eef825967b..4ff406e2a4 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/config.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/groups/config.ts @@ -1,10 +1,9 @@ import { Config } from "@/config/config" import { Provider } from "@/provider/provider" -import * as InstanceState from "@/effect/instance-state" -import { Effect } from "effect" -import { HttpApi, HttpApiBuilder, HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi" -import { Authorization } from "./auth" -import { markInstanceForDisposal } from "./lifecycle" +import { HttpApi, HttpApiEndpoint, HttpApiError, HttpApiGroup, OpenApi } from "effect/unstable/httpapi" +import { Authorization } from "../auth" +import { InstanceContextMiddleware } from "../instance-context" +import { described } from "./metadata" const root = "/config" @@ -13,7 +12,7 @@ export const ConfigApi = HttpApi.make("config") HttpApiGroup.make("config") .add( HttpApiEndpoint.get("get", root, { - success: Config.Info, + success: described(Config.Info, "Get config info"), }).annotateMerge( OpenApi.annotations({ identifier: "config.get", @@ -23,7 +22,8 @@ export const ConfigApi = HttpApi.make("config") ), HttpApiEndpoint.patch("update", root, { payload: Config.Info, - success: Config.Info, + success: described(Config.Info, "Successfully updated config"), + error: HttpApiError.BadRequest, }).annotateMerge( OpenApi.annotations({ identifier: "config.update", @@ -32,7 +32,7 @@ export const ConfigApi = HttpApi.make("config") }), ), HttpApiEndpoint.get("providers", `${root}/providers`, { - success: Provider.ConfigProvidersResult, + success: described(Provider.ConfigProvidersResult, "List of providers"), }).annotateMerge( OpenApi.annotations({ identifier: "config.providers", @@ -47,6 +47,7 @@ export const ConfigApi = HttpApi.make("config") description: "Experimental HttpApi config routes.", }), ) + .middleware(InstanceContextMiddleware) .middleware(Authorization), ) .annotateMerge( @@ -56,30 +57,3 @@ export const ConfigApi = HttpApi.make("config") description: "Experimental HttpApi surface for selected instance routes.", }), ) - -export const configHandlers = HttpApiBuilder.group(ConfigApi, "config", (handlers) => - Effect.gen(function* () { - const providerSvc = yield* Provider.Service - const configSvc = yield* Config.Service - - const get = Effect.fn("ConfigHttpApi.get")(function* () { - return yield* configSvc.get() - }) - - const update = Effect.fn("ConfigHttpApi.update")(function* (ctx) { - yield* configSvc.update(ctx.payload, { dispose: false }) - yield* markInstanceForDisposal(yield* InstanceState.context) - return ctx.payload - }) - - const providers = Effect.fn("ConfigHttpApi.providers")(function* () { - const providers = yield* providerSvc.list() - return { - providers: Object.values(providers), - default: Provider.defaultModelIDs(providers), - } - }) - - return handlers.handle("get", get).handle("update", update).handle("providers", providers) - }), -) diff --git a/packages/opencode/src/server/routes/instance/httpapi/control.ts b/packages/opencode/src/server/routes/instance/httpapi/groups/control.ts similarity index 59% rename from packages/opencode/src/server/routes/instance/httpapi/control.ts rename to packages/opencode/src/server/routes/instance/httpapi/groups/control.ts index 718629db71..33e6a8e4a0 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/control.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/groups/control.ts @@ -1,8 +1,8 @@ import { Auth } from "@/auth" import { ProviderID } from "@/provider/schema" -import * as Log from "@opencode-ai/core/util/log" -import { Effect, Schema } from "effect" -import { HttpApi, HttpApiBuilder, HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi" +import { Schema } from "effect" +import { HttpApi, HttpApiEndpoint, HttpApiError, HttpApiGroup, OpenApi } from "effect/unstable/httpapi" +import { described } from "./metadata" const AuthParams = Schema.Struct({ providerID: ProviderID, @@ -13,7 +13,7 @@ const LogQuery = Schema.Struct({ workspace: Schema.optional(Schema.String), }) -const LogInput = Schema.Struct({ +export const LogInput = Schema.Struct({ service: Schema.String.annotate({ description: "Service name for the log entry" }), level: Schema.Union([ Schema.Literal("debug"), @@ -25,7 +25,7 @@ const LogInput = Schema.Struct({ extra: Schema.optional(Schema.Record(Schema.String, Schema.Unknown)).annotate({ description: "Additional metadata for the log entry", }), -}).annotate({ identifier: "AppLogInput" }) +}) export const ControlPaths = { auth: "/auth/:providerID", @@ -38,7 +38,8 @@ export const ControlApi = HttpApi.make("control").add( HttpApiEndpoint.put("authSet", ControlPaths.auth, { params: AuthParams, payload: Auth.Info, - success: Schema.Boolean, + success: described(Schema.Boolean, "Successfully set authentication credentials"), + error: HttpApiError.BadRequest, }).annotateMerge( OpenApi.annotations({ identifier: "auth.set", @@ -48,7 +49,8 @@ export const ControlApi = HttpApi.make("control").add( ), HttpApiEndpoint.delete("authRemove", ControlPaths.auth, { params: AuthParams, - success: Schema.Boolean, + success: described(Schema.Boolean, "Successfully removed authentication credentials"), + error: HttpApiError.BadRequest, }).annotateMerge( OpenApi.annotations({ identifier: "auth.remove", @@ -59,7 +61,8 @@ export const ControlApi = HttpApi.make("control").add( HttpApiEndpoint.post("log", ControlPaths.log, { query: LogQuery, payload: LogInput, - success: Schema.Boolean, + success: described(Schema.Boolean, "Log entry written successfully"), + error: HttpApiError.BadRequest, }).annotateMerge( OpenApi.annotations({ identifier: "app.log", @@ -70,30 +73,3 @@ export const ControlApi = HttpApi.make("control").add( ) .annotateMerge(OpenApi.annotations({ title: "control", description: "Control plane routes." })), ) - -export const controlHandlers = HttpApiBuilder.group(ControlApi, "control", (handlers) => - Effect.gen(function* () { - const auth = yield* Auth.Service - - const authSet = Effect.fn("ControlHttpApi.authSet")(function* (ctx: { - params: { providerID: ProviderID } - payload: Auth.Info - }) { - yield* auth.set(ctx.params.providerID, ctx.payload).pipe(Effect.orDie) - return true - }) - - const authRemove = Effect.fn("ControlHttpApi.authRemove")(function* (ctx: { params: { providerID: ProviderID } }) { - yield* auth.remove(ctx.params.providerID).pipe(Effect.orDie) - return true - }) - - const log = Effect.fn("ControlHttpApi.log")(function* (ctx: { payload: typeof LogInput.Type }) { - const logger = Log.create({ service: ctx.payload.service }) - logger[ctx.payload.level](ctx.payload.message, ctx.payload.extra) - return true - }) - - return handlers.handle("authSet", authSet).handle("authRemove", authRemove).handle("log", log) - }), -) diff --git a/packages/opencode/src/server/routes/instance/httpapi/experimental.ts b/packages/opencode/src/server/routes/instance/httpapi/groups/experimental.ts similarity index 51% rename from packages/opencode/src/server/routes/instance/httpapi/experimental.ts rename to packages/opencode/src/server/routes/instance/httpapi/groups/experimental.ts index cc39c7604b..2a562b46b3 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/experimental.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/groups/experimental.ts @@ -1,24 +1,19 @@ -import { Account } from "@/account/account" import { AccountID, OrgID } from "@/account/schema" -import { Agent } from "@/agent/agent" -import { Config } from "@/config/config" -import { InstanceState } from "@/effect/instance-state" import { MCP } from "@/mcp" -import { Project } from "@/project/project" import { ProviderID, ModelID } from "@/provider/schema" import { Session } from "@/session/session" -import { ToolRegistry } from "@/tool/registry" -import * as EffectZod from "@/util/effect-zod" import { Worktree } from "@/worktree" -import { Effect, Option, Schema, SchemaGetter } from "effect" -import * as HttpServerResponse from "effect/unstable/http/HttpServerResponse" -import { HttpApi, HttpApiBuilder, HttpApiEndpoint, HttpApiError, HttpApiGroup, OpenApi } from "effect/unstable/httpapi" -import { Authorization } from "./auth" +import { NonNegativeInt } from "@/util/schema" +import { Schema, SchemaGetter } from "effect" +import { HttpApi, HttpApiEndpoint, HttpApiError, HttpApiGroup, OpenApi } from "effect/unstable/httpapi" +import { Authorization } from "../auth" +import { InstanceContextMiddleware } from "../instance-context" +import { described } from "./metadata" const ConsoleStateResponse = Schema.Struct({ consoleManagedProviders: Schema.mutable(Schema.Array(Schema.String)), activeOrgName: Schema.optionalKey(Schema.String), - switchableOrgCount: Schema.Number, + switchableOrgCount: NonNegativeInt, }).annotate({ identifier: "ConsoleState" }) const ConsoleOrgOption = Schema.Struct({ @@ -28,25 +23,25 @@ const ConsoleOrgOption = Schema.Struct({ orgID: Schema.String, orgName: Schema.String, active: Schema.Boolean, -}).annotate({ identifier: "ConsoleOrgOption" }) +}) const ConsoleOrgList = Schema.Struct({ orgs: Schema.Array(ConsoleOrgOption), -}).annotate({ identifier: "ConsoleOrgList" }) +}) -const ConsoleSwitchPayload = Schema.Struct({ +export const ConsoleSwitchPayload = Schema.Struct({ accountID: AccountID, orgID: OrgID, -}).annotate({ identifier: "ConsoleSwitchInput" }) +}) const ToolIDs = Schema.Array(Schema.String).annotate({ identifier: "ToolIDs" }) const ToolListItem = Schema.Struct({ id: Schema.String, description: Schema.String, - parameters: Schema.Record(Schema.String, Schema.Any), + parameters: Schema.Unknown, }).annotate({ identifier: "ToolListItem" }) const ToolList = Schema.Array(ToolListItem).annotate({ identifier: "ToolList" }) -const ToolListQuery = Schema.Struct({ +export const ToolListQuery = Schema.Struct({ provider: ProviderID, model: ModelID, }) @@ -57,8 +52,8 @@ const QueryBoolean = Schema.Literals(["true", "false"]).pipe( encode: SchemaGetter.transform((value) => (value ? "true" : "false")), }), ) -const WorktreeList = Schema.Array(Schema.String).annotate({ identifier: "WorktreeList" }) -const SessionListQuery = Schema.Struct({ +const WorktreeList = Schema.Array(Schema.String) +export const SessionListQuery = Schema.Struct({ directory: Schema.optional(Schema.String), roots: Schema.optional(QueryBoolean), start: Schema.optional(Schema.NumberFromString), @@ -85,7 +80,7 @@ export const ExperimentalApi = HttpApi.make("experimental") HttpApiGroup.make("experimental") .add( HttpApiEndpoint.get("console", ExperimentalPaths.console, { - success: ConsoleStateResponse, + success: described(ConsoleStateResponse, "Active Console provider metadata"), }).annotateMerge( OpenApi.annotations({ identifier: "experimental.console.get", @@ -94,7 +89,7 @@ export const ExperimentalApi = HttpApi.make("experimental") }), ), HttpApiEndpoint.get("consoleOrgs", ExperimentalPaths.consoleOrgs, { - success: ConsoleOrgList, + success: described(ConsoleOrgList, "Switchable Console orgs"), }).annotateMerge( OpenApi.annotations({ identifier: "experimental.console.listOrgs", @@ -104,7 +99,7 @@ export const ExperimentalApi = HttpApi.make("experimental") ), HttpApiEndpoint.post("consoleSwitch", ExperimentalPaths.consoleSwitch, { payload: ConsoleSwitchPayload, - success: Schema.Boolean, + success: described(Schema.Boolean, "Switch success"), error: HttpApiError.BadRequest, }).annotateMerge( OpenApi.annotations({ @@ -115,7 +110,8 @@ export const ExperimentalApi = HttpApi.make("experimental") ), HttpApiEndpoint.get("tool", ExperimentalPaths.tool, { query: ToolListQuery, - success: ToolList, + success: described(ToolList, "Tools"), + error: HttpApiError.BadRequest, }).annotateMerge( OpenApi.annotations({ identifier: "tool.list", @@ -125,7 +121,8 @@ export const ExperimentalApi = HttpApi.make("experimental") }), ), HttpApiEndpoint.get("toolIDs", ExperimentalPaths.toolIDs, { - success: ToolIDs, + success: described(ToolIDs, "Tool IDs"), + error: HttpApiError.BadRequest, }).annotateMerge( OpenApi.annotations({ identifier: "tool.ids", @@ -135,7 +132,7 @@ export const ExperimentalApi = HttpApi.make("experimental") }), ), HttpApiEndpoint.get("worktree", ExperimentalPaths.worktree, { - success: WorktreeList, + success: described(WorktreeList, "List of worktree directories"), }).annotateMerge( OpenApi.annotations({ identifier: "worktree.list", @@ -145,7 +142,8 @@ export const ExperimentalApi = HttpApi.make("experimental") ), HttpApiEndpoint.post("worktreeCreate", ExperimentalPaths.worktree, { payload: Schema.optional(Worktree.CreateInput), - success: Worktree.Info, + success: described(Worktree.Info, "Worktree created"), + error: HttpApiError.BadRequest, }).annotateMerge( OpenApi.annotations({ identifier: "worktree.create", @@ -155,7 +153,8 @@ export const ExperimentalApi = HttpApi.make("experimental") ), HttpApiEndpoint.delete("worktreeRemove", ExperimentalPaths.worktree, { payload: Worktree.RemoveInput, - success: Schema.Boolean, + success: described(Schema.Boolean, "Worktree removed"), + error: HttpApiError.BadRequest, }).annotateMerge( OpenApi.annotations({ identifier: "worktree.remove", @@ -165,7 +164,8 @@ export const ExperimentalApi = HttpApi.make("experimental") ), HttpApiEndpoint.post("worktreeReset", ExperimentalPaths.worktreeReset, { payload: Worktree.ResetInput, - success: Schema.Boolean, + success: described(Schema.Boolean, "Worktree reset"), + error: HttpApiError.BadRequest, }).annotateMerge( OpenApi.annotations({ identifier: "worktree.reset", @@ -175,7 +175,7 @@ export const ExperimentalApi = HttpApi.make("experimental") ), HttpApiEndpoint.get("session", ExperimentalPaths.session, { query: SessionListQuery, - success: Schema.Array(Session.GlobalInfo), + success: described(Schema.Array(Session.GlobalInfo), "List of sessions"), }).annotateMerge( OpenApi.annotations({ identifier: "experimental.session.list", @@ -185,7 +185,7 @@ export const ExperimentalApi = HttpApi.make("experimental") }), ), HttpApiEndpoint.get("resource", ExperimentalPaths.resource, { - success: Schema.Record(Schema.String, MCP.Resource), + success: described(Schema.Record(Schema.String, MCP.Resource), "MCP resources"), }).annotateMerge( OpenApi.annotations({ identifier: "experimental.resource.list", @@ -200,6 +200,7 @@ export const ExperimentalApi = HttpApi.make("experimental") description: "Experimental HttpApi read-only routes.", }), ) + .middleware(InstanceContextMiddleware) .middleware(Authorization), ) .annotateMerge( @@ -209,143 +210,3 @@ export const ExperimentalApi = HttpApi.make("experimental") description: "Experimental HttpApi surface for selected instance routes.", }), ) - -export const experimentalHandlers = HttpApiBuilder.group(ExperimentalApi, "experimental", (handlers) => - Effect.gen(function* () { - const account = yield* Account.Service - const agents = yield* Agent.Service - const config = yield* Config.Service - const mcp = yield* MCP.Service - const project = yield* Project.Service - const registry = yield* ToolRegistry.Service - const worktreeSvc = yield* Worktree.Service - - const getConsole = Effect.fn("ExperimentalHttpApi.console")(function* () { - const [state, groups] = yield* Effect.all( - [config.getConsoleState(), account.orgsByAccount().pipe(Effect.orDie)], - { - concurrency: "unbounded", - }, - ) - return { - consoleManagedProviders: state.consoleManagedProviders, - ...(state.activeOrgName ? { activeOrgName: state.activeOrgName } : {}), - switchableOrgCount: groups.reduce((count, group) => count + group.orgs.length, 0), - } - }) - - const listConsoleOrgs = Effect.fn("ExperimentalHttpApi.consoleOrgs")(function* () { - const [groups, active] = yield* Effect.all( - [account.orgsByAccount().pipe(Effect.orDie), account.active().pipe(Effect.orDie)], - { - concurrency: "unbounded", - }, - ) - const info = Option.getOrUndefined(active) - return { - orgs: groups.flatMap((group) => - group.orgs.map((org) => ({ - accountID: group.account.id, - accountEmail: group.account.email, - accountUrl: group.account.url, - orgID: org.id, - orgName: org.name, - active: !!info && info.id === group.account.id && info.active_org_id === org.id, - })), - ), - } - }) - - const switchConsole = Effect.fn("ExperimentalHttpApi.consoleSwitch")(function* (ctx: { - payload: typeof ConsoleSwitchPayload.Type - }) { - yield* account - .use(ctx.payload.accountID, Option.some(ctx.payload.orgID)) - .pipe(Effect.catch(() => Effect.fail(new HttpApiError.BadRequest({})))) - return true - }) - - const tool = Effect.fn("ExperimentalHttpApi.tool")(function* (ctx: { query: typeof ToolListQuery.Type }) { - const list = yield* registry.tools({ - providerID: ctx.query.provider, - modelID: ctx.query.model, - agent: yield* agents.get(yield* agents.defaultAgent()), - }) - return list.map((item) => ({ - id: item.id, - description: item.description, - parameters: EffectZod.toJsonSchema(item.parameters), - })) - }) - - const toolIDs = Effect.fn("ExperimentalHttpApi.toolIDs")(function* () { - return yield* registry.ids() - }) - - const worktree = Effect.fn("ExperimentalHttpApi.worktree")(function* () { - const ctx = yield* InstanceState.context - return yield* project.sandboxes(ctx.project.id) - }) - - const worktreeCreate = Effect.fn("ExperimentalHttpApi.worktreeCreate")(function* (ctx: { - payload: Worktree.CreateInput | undefined - }) { - return yield* worktreeSvc.create(ctx.payload) - }) - - const worktreeRemove = Effect.fn("ExperimentalHttpApi.worktreeRemove")(function* (input: { - payload: Worktree.RemoveInput - }) { - const ctx = yield* InstanceState.context - yield* worktreeSvc.remove(input.payload) - yield* project.removeSandbox(ctx.project.id, input.payload.directory) - return true - }) - - const worktreeReset = Effect.fn("ExperimentalHttpApi.worktreeReset")(function* (ctx: { - payload: Worktree.ResetInput - }) { - yield* worktreeSvc.reset(ctx.payload) - return true - }) - - const session = Effect.fn("ExperimentalHttpApi.session")(function* (ctx: { query: typeof SessionListQuery.Type }) { - const limit = ctx.query.limit ?? 100 - const sessions = Array.from( - Session.listGlobal({ - directory: ctx.query.directory, - roots: ctx.query.roots, - start: ctx.query.start, - cursor: ctx.query.cursor, - search: ctx.query.search, - limit: limit + 1, - archived: ctx.query.archived, - }), - ) - const list = sessions.length > limit ? sessions.slice(0, limit) : sessions - return HttpServerResponse.jsonUnsafe(list, { - headers: - sessions.length > limit && list.length > 0 - ? { "x-next-cursor": String(list[list.length - 1].time.updated) } - : undefined, - }) - }) - - const resource = Effect.fn("ExperimentalHttpApi.resource")(function* () { - return yield* mcp.resources() - }) - - return handlers - .handle("console", getConsole) - .handle("consoleOrgs", listConsoleOrgs) - .handle("consoleSwitch", switchConsole) - .handle("tool", tool) - .handle("toolIDs", toolIDs) - .handle("worktree", worktree) - .handle("worktreeCreate", worktreeCreate) - .handle("worktreeRemove", worktreeRemove) - .handle("worktreeReset", worktreeReset) - .handle("session", session) - .handle("resource", resource) - }), -) diff --git a/packages/opencode/src/server/routes/instance/httpapi/file.ts b/packages/opencode/src/server/routes/instance/httpapi/groups/file.ts similarity index 58% rename from packages/opencode/src/server/routes/instance/httpapi/file.ts rename to packages/opencode/src/server/routes/instance/httpapi/groups/file.ts index df525680ae..3a4f3df7f9 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/file.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/groups/file.ts @@ -1,20 +1,21 @@ import { File } from "@/file" import { Ripgrep } from "@/file/ripgrep" -import * as InstanceState from "@/effect/instance-state" import { LSP } from "@/lsp/lsp" -import { Effect, Schema } from "effect" -import { HttpApi, HttpApiBuilder, HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi" -import { Authorization } from "./auth" +import { Schema } from "effect" +import { HttpApi, HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi" +import { Authorization } from "../auth" +import { InstanceContextMiddleware } from "../instance-context" +import { described } from "./metadata" -const FileQuery = Schema.Struct({ +export const FileQuery = Schema.Struct({ path: Schema.String, }) -const FindTextQuery = Schema.Struct({ +export const FindTextQuery = Schema.Struct({ pattern: Schema.String, }) -const FindFileQuery = Schema.Struct({ +export const FindFileQuery = Schema.Struct({ query: Schema.String, dirs: Schema.optional(Schema.Literals(["true", "false"])), type: Schema.optional(Schema.Literals(["file", "directory"])), @@ -23,7 +24,7 @@ const FindFileQuery = Schema.Struct({ ), }) -const FindSymbolQuery = Schema.Struct({ +export const FindSymbolQuery = Schema.Struct({ query: Schema.String, }) @@ -42,7 +43,7 @@ export const FileApi = HttpApi.make("file") .add( HttpApiEndpoint.get("findText", FilePaths.findText, { query: FindTextQuery, - success: Schema.Array(Ripgrep.SearchMatch), + success: described(Schema.Array(Ripgrep.SearchMatch), "Matches"), }).annotateMerge( OpenApi.annotations({ identifier: "find.text", @@ -52,7 +53,7 @@ export const FileApi = HttpApi.make("file") ), HttpApiEndpoint.get("findFile", FilePaths.findFile, { query: FindFileQuery, - success: Schema.Array(Schema.String), + success: described(Schema.Array(Schema.String), "File paths"), }).annotateMerge( OpenApi.annotations({ identifier: "find.files", @@ -62,7 +63,7 @@ export const FileApi = HttpApi.make("file") ), HttpApiEndpoint.get("findSymbol", FilePaths.findSymbol, { query: FindSymbolQuery, - success: Schema.Array(LSP.Symbol), + success: described(Schema.Array(LSP.Symbol), "Symbols"), }).annotateMerge( OpenApi.annotations({ identifier: "find.symbols", @@ -72,7 +73,7 @@ export const FileApi = HttpApi.make("file") ), HttpApiEndpoint.get("list", FilePaths.list, { query: FileQuery, - success: Schema.Array(File.Node), + success: described(Schema.Array(File.Node), "Files and directories"), }).annotateMerge( OpenApi.annotations({ identifier: "file.list", @@ -82,7 +83,7 @@ export const FileApi = HttpApi.make("file") ), HttpApiEndpoint.get("content", FilePaths.content, { query: FileQuery, - success: File.Content, + success: described(File.Content, "File content"), }).annotateMerge( OpenApi.annotations({ identifier: "file.read", @@ -91,7 +92,7 @@ export const FileApi = HttpApi.make("file") }), ), HttpApiEndpoint.get("status", FilePaths.status, { - success: Schema.Array(File.Info), + success: described(Schema.Array(File.Info), "File status"), }).annotateMerge( OpenApi.annotations({ identifier: "file.status", @@ -106,6 +107,7 @@ export const FileApi = HttpApi.make("file") description: "Experimental HttpApi file routes.", }), ) + .middleware(InstanceContextMiddleware) .middleware(Authorization), ) .annotateMerge( @@ -115,51 +117,3 @@ export const FileApi = HttpApi.make("file") description: "Experimental HttpApi surface for selected instance routes.", }), ) - -export const fileHandlers = HttpApiBuilder.group(FileApi, "file", (handlers) => - Effect.gen(function* () { - const svc = yield* File.Service - const ripgrep = yield* Ripgrep.Service - - const findText = Effect.fn("FileHttpApi.findText")(function* (ctx: { query: { pattern: string } }) { - return (yield* ripgrep - .search({ cwd: (yield* InstanceState.context).directory, pattern: ctx.query.pattern, limit: 10 }) - .pipe(Effect.orDie)).items - }) - - const findFile = Effect.fn("FileHttpApi.findFile")(function* (ctx: { - query: { query: string; dirs?: "true" | "false"; type?: "file" | "directory"; limit?: number } - }) { - return yield* svc.search({ - query: ctx.query.query, - limit: ctx.query.limit ?? 10, - dirs: ctx.query.dirs !== "false", - type: ctx.query.type, - }) - }) - - const findSymbol = Effect.fn("FileHttpApi.findSymbol")(function* () { - return [] - }) - - const list = Effect.fn("FileHttpApi.list")(function* (ctx: { query: { path: string } }) { - return yield* svc.list(ctx.query.path) - }) - - const content = Effect.fn("FileHttpApi.content")(function* (ctx: { query: { path: string } }) { - return yield* svc.read(ctx.query.path) - }) - - const status = Effect.fn("FileHttpApi.status")(function* () { - return yield* svc.status() - }) - - return handlers - .handle("findText", findText) - .handle("findFile", findFile) - .handle("findSymbol", findSymbol) - .handle("list", list) - .handle("content", content) - .handle("status", status) - }), -) diff --git a/packages/opencode/src/server/routes/instance/httpapi/groups/global.ts b/packages/opencode/src/server/routes/instance/httpapi/groups/global.ts new file mode 100644 index 0000000000..272b086065 --- /dev/null +++ b/packages/opencode/src/server/routes/instance/httpapi/groups/global.ts @@ -0,0 +1,106 @@ +import { Config } from "@/config/config" +import { BusEvent } from "@/bus/bus-event" +import { SyncEvent } from "@/sync" +import { Schema } from "effect" +import { HttpApi, HttpApiEndpoint, HttpApiError, HttpApiGroup, OpenApi } from "effect/unstable/httpapi" +import { described } from "./metadata" + +const GlobalHealth = Schema.Struct({ + healthy: Schema.Literal(true), + version: Schema.String, +}) + +const GlobalEventSchema = Schema.Struct({ + directory: Schema.String, + project: Schema.optional(Schema.String), + workspace: Schema.optional(Schema.String), + payload: Schema.Union([...BusEvent.effectPayloads(), ...SyncEvent.effectPayloads()]), +}).annotate({ identifier: "GlobalEvent" }) + +export const GlobalUpgradeInput = Schema.Struct({ + target: Schema.optional(Schema.String), +}) + +const GlobalUpgradeResult = Schema.Union([ + Schema.Struct({ + success: Schema.Literal(true), + version: Schema.String, + }), + Schema.Struct({ + success: Schema.Literal(false), + error: Schema.String, + }), +]) + +export const GlobalPaths = { + health: "/global/health", + event: "/global/event", + config: "/global/config", + dispose: "/global/dispose", + upgrade: "/global/upgrade", +} as const + +export const GlobalApi = HttpApi.make("global").add( + HttpApiGroup.make("global") + .add( + HttpApiEndpoint.get("health", GlobalPaths.health, { + success: described(GlobalHealth, "Health information"), + }).annotateMerge( + OpenApi.annotations({ + identifier: "global.health", + summary: "Get health", + description: "Get health information about the OpenCode server.", + }), + ), + HttpApiEndpoint.get("event", GlobalPaths.event, { + success: GlobalEventSchema, + }).annotateMerge( + OpenApi.annotations({ + identifier: "global.event", + summary: "Get global events", + description: "Subscribe to global events from the OpenCode system using server-sent events.", + }), + ), + HttpApiEndpoint.get("configGet", GlobalPaths.config, { + success: described(Config.Info, "Get global config info"), + }).annotateMerge( + OpenApi.annotations({ + identifier: "global.config.get", + summary: "Get global configuration", + description: "Retrieve the current global OpenCode configuration settings and preferences.", + }), + ), + HttpApiEndpoint.patch("configUpdate", GlobalPaths.config, { + payload: Config.Info, + success: described(Config.Info, "Successfully updated global config"), + error: HttpApiError.BadRequest, + }).annotateMerge( + OpenApi.annotations({ + identifier: "global.config.update", + summary: "Update global configuration", + description: "Update global OpenCode configuration settings and preferences.", + }), + ), + HttpApiEndpoint.post("dispose", GlobalPaths.dispose, { + success: described(Schema.Boolean, "Global disposed"), + }).annotateMerge( + OpenApi.annotations({ + identifier: "global.dispose", + summary: "Dispose instance", + description: "Clean up and dispose all OpenCode instances, releasing all resources.", + }), + ), + HttpApiEndpoint.post("upgrade", GlobalPaths.upgrade, { + payload: GlobalUpgradeInput, + success: described(GlobalUpgradeResult, "Upgrade result"), + error: HttpApiError.BadRequest, + }).annotateMerge( + OpenApi.annotations({ + identifier: "global.upgrade", + summary: "Upgrade opencode", + description: "Upgrade opencode to the specified version or latest if not specified.", + }), + ), + ) + .annotateMerge(OpenApi.annotations({ title: "global", description: "Global server routes." })), +) diff --git a/packages/opencode/src/server/routes/instance/httpapi/instance.ts b/packages/opencode/src/server/routes/instance/httpapi/groups/instance.ts similarity index 58% rename from packages/opencode/src/server/routes/instance/httpapi/instance.ts rename to packages/opencode/src/server/routes/instance/httpapi/groups/instance.ts index 8c471c12a0..cc450f448c 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/instance.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/groups/instance.ts @@ -1,15 +1,14 @@ import { Agent } from "@/agent/agent" import { Command } from "@/command" import { Format } from "@/format" -import { Global } from "@opencode-ai/core/global" import { LSP } from "@/lsp/lsp" import { Vcs } from "@/project/vcs" import { Skill } from "@/skill" -import * as InstanceState from "@/effect/instance-state" -import { Effect, Schema } from "effect" -import { HttpApi, HttpApiBuilder, HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi" -import { Authorization } from "./auth" -import { markInstanceForDisposal } from "./lifecycle" +import { Schema } from "effect" +import { HttpApi, HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi" +import { Authorization } from "../auth" +import { InstanceContextMiddleware } from "../instance-context" +import { described } from "./metadata" const PathInfo = Schema.Struct({ home: Schema.String, @@ -19,7 +18,7 @@ const PathInfo = Schema.Struct({ directory: Schema.String, }).annotate({ identifier: "Path" }) -const VcsDiffQuery = Schema.Struct({ +export const VcsDiffQuery = Schema.Struct({ mode: Vcs.Mode, }) @@ -40,7 +39,7 @@ export const InstanceApi = HttpApi.make("instance") HttpApiGroup.make("instance") .add( HttpApiEndpoint.post("dispose", InstancePaths.dispose, { - success: Schema.Boolean, + success: described(Schema.Boolean, "Instance disposed"), }).annotateMerge( OpenApi.annotations({ identifier: "instance.dispose", @@ -59,7 +58,7 @@ export const InstanceApi = HttpApi.make("instance") }), ), HttpApiEndpoint.get("vcs", InstancePaths.vcs, { - success: Vcs.Info, + success: described(Vcs.Info, "VCS info"), }).annotateMerge( OpenApi.annotations({ identifier: "vcs.get", @@ -70,7 +69,7 @@ export const InstanceApi = HttpApi.make("instance") ), HttpApiEndpoint.get("vcsDiff", InstancePaths.vcsDiff, { query: VcsDiffQuery, - success: Schema.Array(Vcs.FileDiff), + success: described(Schema.Array(Vcs.FileDiff), "VCS diff"), }).annotateMerge( OpenApi.annotations({ identifier: "vcs.diff", @@ -79,7 +78,7 @@ export const InstanceApi = HttpApi.make("instance") }), ), HttpApiEndpoint.get("command", InstancePaths.command, { - success: Schema.Array(Command.Info), + success: described(Schema.Array(Command.Info), "List of commands"), }).annotateMerge( OpenApi.annotations({ identifier: "command.list", @@ -88,7 +87,7 @@ export const InstanceApi = HttpApi.make("instance") }), ), HttpApiEndpoint.get("agent", InstancePaths.agent, { - success: Schema.Array(Agent.Info), + success: described(Schema.Array(Agent.Info), "List of agents"), }).annotateMerge( OpenApi.annotations({ identifier: "app.agents", @@ -97,7 +96,7 @@ export const InstanceApi = HttpApi.make("instance") }), ), HttpApiEndpoint.get("skill", InstancePaths.skill, { - success: Schema.Array(Skill.Info), + success: described(Schema.Array(Skill.Info), "List of skills"), }).annotateMerge( OpenApi.annotations({ identifier: "app.skills", @@ -106,7 +105,7 @@ export const InstanceApi = HttpApi.make("instance") }), ), HttpApiEndpoint.get("lsp", InstancePaths.lsp, { - success: Schema.Array(LSP.Status), + success: described(Schema.Array(LSP.Status), "LSP server status"), }).annotateMerge( OpenApi.annotations({ identifier: "lsp.status", @@ -115,7 +114,7 @@ export const InstanceApi = HttpApi.make("instance") }), ), HttpApiEndpoint.get("formatter", InstancePaths.formatter, { - success: Schema.Array(Format.Status), + success: described(Schema.Array(Format.Status), "Formatter status"), }).annotateMerge( OpenApi.annotations({ identifier: "formatter.status", @@ -130,6 +129,7 @@ export const InstanceApi = HttpApi.make("instance") description: "Experimental HttpApi instance read routes.", }), ) + .middleware(InstanceContextMiddleware) .middleware(Authorization), ) .annotateMerge( @@ -139,70 +139,3 @@ export const InstanceApi = HttpApi.make("instance") description: "Experimental HttpApi surface for selected instance routes.", }), ) - -export const instanceHandlers = HttpApiBuilder.group(InstanceApi, "instance", (handlers) => - Effect.gen(function* () { - const agent = yield* Agent.Service - const command = yield* Command.Service - const format = yield* Format.Service - const lsp = yield* LSP.Service - const skill = yield* Skill.Service - const vcs = yield* Vcs.Service - - const dispose = Effect.fn("InstanceHttpApi.dispose")(function* () { - yield* markInstanceForDisposal(yield* InstanceState.context) - return true - }) - - const getPath = Effect.fn("InstanceHttpApi.path")(function* () { - const ctx = yield* InstanceState.context - return { - home: Global.Path.home, - state: Global.Path.state, - config: Global.Path.config, - worktree: ctx.worktree, - directory: ctx.directory, - } - }) - - const getVcs = Effect.fn("InstanceHttpApi.vcs")(function* () { - const [branch, default_branch] = yield* Effect.all([vcs.branch(), vcs.defaultBranch()], { concurrency: 2 }) - return { branch, default_branch } - }) - - const getVcsDiff = Effect.fn("InstanceHttpApi.vcsDiff")(function* (ctx: { query: { mode: Vcs.Mode } }) { - return yield* vcs.diff(ctx.query.mode) - }) - - const getCommand = Effect.fn("InstanceHttpApi.command")(function* () { - return yield* command.list() - }) - - const getAgent = Effect.fn("InstanceHttpApi.agent")(function* () { - return yield* agent.list() - }) - - const getSkill = Effect.fn("InstanceHttpApi.skill")(function* () { - return yield* skill.all() - }) - - const getLsp = Effect.fn("InstanceHttpApi.lsp")(function* () { - return yield* lsp.status() - }) - - const getFormatter = Effect.fn("InstanceHttpApi.formatter")(function* () { - return yield* format.status() - }) - - return handlers - .handle("dispose", dispose) - .handle("path", getPath) - .handle("vcs", getVcs) - .handle("vcsDiff", getVcsDiff) - .handle("command", getCommand) - .handle("agent", getAgent) - .handle("skill", getSkill) - .handle("lsp", getLsp) - .handle("formatter", getFormatter) - }), -) diff --git a/packages/opencode/src/server/routes/instance/httpapi/mcp.ts b/packages/opencode/src/server/routes/instance/httpapi/groups/mcp.ts similarity index 51% rename from packages/opencode/src/server/routes/instance/httpapi/mcp.ts rename to packages/opencode/src/server/routes/instance/httpapi/groups/mcp.ts index f5552f6f2f..149f8814a9 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/mcp.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/groups/mcp.ts @@ -1,26 +1,27 @@ import { MCP } from "@/mcp" import { ConfigMCP } from "@/config/mcp" -import { Effect, Schema } from "effect" -import { HttpApi, HttpApiBuilder, HttpApiEndpoint, HttpApiError, HttpApiGroup, OpenApi } from "effect/unstable/httpapi" -import { Authorization } from "./auth" +import { Schema } from "effect" +import { HttpApi, HttpApiEndpoint, HttpApiError, HttpApiGroup, OpenApi } from "effect/unstable/httpapi" +import { Authorization } from "../auth" +import { InstanceContextMiddleware } from "../instance-context" +import { described } from "./metadata" -const AddPayload = Schema.Struct({ +export const AddPayload = Schema.Struct({ name: Schema.String, config: ConfigMCP.Info, -}).annotate({ identifier: "McpAddInput" }) +}) -const StatusMap = Schema.Record(Schema.String, MCP.Status) -const AuthStartResponse = Schema.Struct({ +export const StatusMap = Schema.Record(Schema.String, MCP.Status) +export const AuthStartResponse = Schema.Struct({ authorizationUrl: Schema.String, - oauthState: Schema.String, -}).annotate({ identifier: "McpAuthStartResponse" }) -const AuthCallbackPayload = Schema.Struct({ +}) +export const AuthCallbackPayload = Schema.Struct({ code: Schema.String, -}).annotate({ identifier: "McpAuthCallbackInput" }) -const AuthRemoveResponse = Schema.Struct({ +}) +export const AuthRemoveResponse = Schema.Struct({ success: Schema.Literal(true), -}).annotate({ identifier: "McpAuthRemoveResponse" }) -class UnsupportedOAuthError extends Schema.ErrorClass("McpUnsupportedOAuthError")( +}) +export class UnsupportedOAuthError extends Schema.ErrorClass("McpUnsupportedOAuthError")( { error: Schema.String }, { httpApiStatus: 400 }, ) {} @@ -39,7 +40,7 @@ export const McpApi = HttpApi.make("mcp") HttpApiGroup.make("mcp") .add( HttpApiEndpoint.get("status", McpPaths.status, { - success: Schema.Record(Schema.String, MCP.Status), + success: described(Schema.Record(Schema.String, MCP.Status), "MCP server status"), }).annotateMerge( OpenApi.annotations({ identifier: "mcp.status", @@ -49,7 +50,7 @@ export const McpApi = HttpApi.make("mcp") ), HttpApiEndpoint.post("add", McpPaths.status, { payload: AddPayload, - success: StatusMap, + success: described(StatusMap, "MCP server added successfully"), error: HttpApiError.BadRequest, }).annotateMerge( OpenApi.annotations({ @@ -60,8 +61,8 @@ export const McpApi = HttpApi.make("mcp") ), HttpApiEndpoint.post("authStart", McpPaths.auth, { params: { name: Schema.String }, - success: AuthStartResponse, - error: UnsupportedOAuthError, + success: described(AuthStartResponse, "OAuth flow started"), + error: [UnsupportedOAuthError, HttpApiError.NotFound], }).annotateMerge( OpenApi.annotations({ identifier: "mcp.auth.start", @@ -72,7 +73,8 @@ export const McpApi = HttpApi.make("mcp") HttpApiEndpoint.post("authCallback", McpPaths.authCallback, { params: { name: Schema.String }, payload: AuthCallbackPayload, - success: MCP.Status, + success: described(MCP.Status, "OAuth authentication completed"), + error: [HttpApiError.BadRequest, HttpApiError.NotFound], }).annotateMerge( OpenApi.annotations({ identifier: "mcp.auth.callback", @@ -83,8 +85,8 @@ export const McpApi = HttpApi.make("mcp") ), HttpApiEndpoint.post("authAuthenticate", McpPaths.authAuthenticate, { params: { name: Schema.String }, - success: MCP.Status, - error: UnsupportedOAuthError, + success: described(MCP.Status, "OAuth authentication completed"), + error: [UnsupportedOAuthError, HttpApiError.NotFound], }).annotateMerge( OpenApi.annotations({ identifier: "mcp.auth.authenticate", @@ -94,7 +96,8 @@ export const McpApi = HttpApi.make("mcp") ), HttpApiEndpoint.delete("authRemove", McpPaths.auth, { params: { name: Schema.String }, - success: AuthRemoveResponse, + success: described(AuthRemoveResponse, "OAuth credentials removed"), + error: HttpApiError.NotFound, }).annotateMerge( OpenApi.annotations({ identifier: "mcp.auth.remove", @@ -104,7 +107,7 @@ export const McpApi = HttpApi.make("mcp") ), HttpApiEndpoint.post("connect", McpPaths.connect, { params: { name: Schema.String }, - success: Schema.Boolean, + success: described(Schema.Boolean, "MCP server connected successfully"), }).annotateMerge( OpenApi.annotations({ identifier: "mcp.connect", @@ -113,7 +116,7 @@ export const McpApi = HttpApi.make("mcp") ), HttpApiEndpoint.post("disconnect", McpPaths.disconnect, { params: { name: Schema.String }, - success: Schema.Boolean, + success: described(Schema.Boolean, "MCP server disconnected successfully"), }).annotateMerge( OpenApi.annotations({ identifier: "mcp.disconnect", @@ -127,6 +130,7 @@ export const McpApi = HttpApi.make("mcp") description: "Experimental HttpApi MCP routes.", }), ) + .middleware(InstanceContextMiddleware) .middleware(Authorization), ) .annotateMerge( @@ -136,66 +140,3 @@ export const McpApi = HttpApi.make("mcp") description: "Experimental HttpApi surface for selected instance routes.", }), ) - -export const mcpHandlers = HttpApiBuilder.group(McpApi, "mcp", (handlers) => - Effect.gen(function* () { - const mcp = yield* MCP.Service - - const status = Effect.fn("McpHttpApi.status")(function* () { - return yield* mcp.status() - }) - - const add = Effect.fn("McpHttpApi.add")(function* (ctx: { payload: typeof AddPayload.Type }) { - const result = (yield* mcp.add(ctx.payload.name, ctx.payload.config)).status - return yield* Schema.decodeUnknownEffect(StatusMap)( - "status" in result ? { [ctx.payload.name]: result } : result, - ).pipe(Effect.mapError(() => new HttpApiError.BadRequest({}))) - }) - - const authStart = Effect.fn("McpHttpApi.authStart")(function* (ctx: { params: { name: string } }) { - if (!(yield* mcp.supportsOAuth(ctx.params.name))) { - return yield* new UnsupportedOAuthError({ error: `MCP server ${ctx.params.name} does not support OAuth` }) - } - return yield* mcp.startAuth(ctx.params.name) - }) - - const authCallback = Effect.fn("McpHttpApi.authCallback")(function* (ctx: { - params: { name: string } - payload: typeof AuthCallbackPayload.Type - }) { - return yield* mcp.finishAuth(ctx.params.name, ctx.payload.code) - }) - - const authAuthenticate = Effect.fn("McpHttpApi.authAuthenticate")(function* (ctx: { params: { name: string } }) { - if (!(yield* mcp.supportsOAuth(ctx.params.name))) { - return yield* new UnsupportedOAuthError({ error: `MCP server ${ctx.params.name} does not support OAuth` }) - } - return yield* mcp.authenticate(ctx.params.name) - }) - - const authRemove = Effect.fn("McpHttpApi.authRemove")(function* (ctx: { params: { name: string } }) { - yield* mcp.removeAuth(ctx.params.name) - return { success: true as const } - }) - - const connect = Effect.fn("McpHttpApi.connect")(function* (ctx: { params: { name: string } }) { - yield* mcp.connect(ctx.params.name) - return true - }) - - const disconnect = Effect.fn("McpHttpApi.disconnect")(function* (ctx: { params: { name: string } }) { - yield* mcp.disconnect(ctx.params.name) - return true - }) - - return handlers - .handle("status", status) - .handle("add", add) - .handle("authStart", authStart) - .handle("authCallback", authCallback) - .handle("authAuthenticate", authAuthenticate) - .handle("authRemove", authRemove) - .handle("connect", connect) - .handle("disconnect", disconnect) - }), -) diff --git a/packages/opencode/src/server/routes/instance/httpapi/groups/metadata.ts b/packages/opencode/src/server/routes/instance/httpapi/groups/metadata.ts new file mode 100644 index 0000000000..f4841c538d --- /dev/null +++ b/packages/opencode/src/server/routes/instance/httpapi/groups/metadata.ts @@ -0,0 +1,18 @@ +import { Schema } from "effect" +import { OpenApi } from "effect/unstable/httpapi" + +export function described(schema: S, description: string): S { + return schema.annotate({ description }) as S +} + +export function responseDescription(description: string) { + return OpenApi.annotations({ + transform: (operation) => { + const response = operation.responses?.["200"] + if (response && typeof response === "object" && "description" in response) { + response.description = description + } + return operation + }, + }) +} diff --git a/packages/opencode/src/server/routes/instance/httpapi/permission.ts b/packages/opencode/src/server/routes/instance/httpapi/groups/permission.ts similarity index 57% rename from packages/opencode/src/server/routes/instance/httpapi/permission.ts rename to packages/opencode/src/server/routes/instance/httpapi/groups/permission.ts index 357c832990..e06c98d9ef 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/permission.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/groups/permission.ts @@ -1,17 +1,23 @@ import { Permission } from "@/permission" import { PermissionID } from "@/permission/schema" -import { Effect, Schema } from "effect" -import { HttpApi, HttpApiBuilder, HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi" -import { Authorization } from "./auth" +import { Schema } from "effect" +import { HttpApi, HttpApiEndpoint, HttpApiError, HttpApiGroup, OpenApi } from "effect/unstable/httpapi" +import { Authorization } from "../auth" +import { InstanceContextMiddleware } from "../instance-context" +import { described } from "./metadata" const root = "/permission" +const ReplyPayload = Schema.Struct({ + reply: Permission.Reply, + message: Schema.optional(Schema.String), +}) export const PermissionApi = HttpApi.make("permission") .add( HttpApiGroup.make("permission") .add( HttpApiEndpoint.get("list", root, { - success: Schema.Array(Permission.Request), + success: described(Schema.Array(Permission.Request), "List of pending permissions"), }).annotateMerge( OpenApi.annotations({ identifier: "permission.list", @@ -21,8 +27,9 @@ export const PermissionApi = HttpApi.make("permission") ), HttpApiEndpoint.post("reply", `${root}/:requestID/reply`, { params: { requestID: PermissionID }, - payload: Permission.ReplyBody, - success: Schema.Boolean, + payload: ReplyPayload, + success: described(Schema.Boolean, "Permission processed successfully"), + error: [HttpApiError.BadRequest, HttpApiError.NotFound], }).annotateMerge( OpenApi.annotations({ identifier: "permission.reply", @@ -37,6 +44,7 @@ export const PermissionApi = HttpApi.make("permission") description: "Experimental HttpApi permission routes.", }), ) + .middleware(InstanceContextMiddleware) .middleware(Authorization), ) .annotateMerge( @@ -46,27 +54,3 @@ export const PermissionApi = HttpApi.make("permission") description: "Experimental HttpApi surface for selected instance routes.", }), ) - -export const permissionHandlers = HttpApiBuilder.group(PermissionApi, "permission", (handlers) => - Effect.gen(function* () { - const svc = yield* Permission.Service - - const list = Effect.fn("PermissionHttpApi.list")(function* () { - return yield* svc.list() - }) - - const reply = Effect.fn("PermissionHttpApi.reply")(function* (ctx: { - params: { requestID: PermissionID } - payload: Permission.ReplyBody - }) { - yield* svc.reply({ - requestID: ctx.params.requestID, - reply: ctx.payload.reply, - message: ctx.payload.message, - }) - return true - }) - - return handlers.handle("list", list).handle("reply", reply) - }), -) diff --git a/packages/opencode/src/server/routes/instance/httpapi/project.ts b/packages/opencode/src/server/routes/instance/httpapi/groups/project.ts similarity index 50% rename from packages/opencode/src/server/routes/instance/httpapi/project.ts rename to packages/opencode/src/server/routes/instance/httpapi/groups/project.ts index 276798b0b9..92019866e9 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/project.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/groups/project.ts @@ -1,21 +1,24 @@ -import * as InstanceState from "@/effect/instance-state" -import { AppRuntime } from "@/effect/app-runtime" import { Project } from "@/project/project" -import { InstanceBootstrap } from "@/project/bootstrap" import { ProjectID } from "@/project/schema" -import { Effect, Schema } from "effect" -import { HttpApi, HttpApiBuilder, HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi" -import { Authorization } from "./auth" -import { markInstanceForReload } from "./lifecycle" +import { Schema } from "effect" +import { HttpApi, HttpApiEndpoint, HttpApiError, HttpApiGroup, OpenApi } from "effect/unstable/httpapi" +import { Authorization } from "../auth" +import { InstanceContextMiddleware } from "../instance-context" +import { described } from "./metadata" const root = "/project" +const UpdatePayload = Schema.Struct({ + name: Schema.optional(Schema.String), + icon: Schema.optional(Project.Info.fields.icon), + commands: Schema.optional(Project.Info.fields.commands), +}) export const ProjectApi = HttpApi.make("project") .add( HttpApiGroup.make("project") .add( HttpApiEndpoint.get("list", root, { - success: Schema.Array(Project.Info), + success: described(Schema.Array(Project.Info), "List of projects"), }).annotateMerge( OpenApi.annotations({ identifier: "project.list", @@ -24,7 +27,7 @@ export const ProjectApi = HttpApi.make("project") }), ), HttpApiEndpoint.get("current", `${root}/current`, { - success: Project.Info, + success: described(Project.Info, "Current project information"), }).annotateMerge( OpenApi.annotations({ identifier: "project.current", @@ -33,7 +36,7 @@ export const ProjectApi = HttpApi.make("project") }), ), HttpApiEndpoint.post("initGit", `${root}/git/init`, { - success: Project.Info, + success: described(Project.Info, "Project information after git initialization"), }).annotateMerge( OpenApi.annotations({ identifier: "project.initGit", @@ -43,8 +46,9 @@ export const ProjectApi = HttpApi.make("project") ), HttpApiEndpoint.patch("update", `${root}/:projectID`, { params: { projectID: ProjectID }, - payload: Project.UpdatePayload, - success: Project.Info, + payload: UpdatePayload, + success: described(Project.Info, "Updated project information"), + error: [HttpApiError.BadRequest, HttpApiError.NotFound], }).annotateMerge( OpenApi.annotations({ identifier: "project.update", @@ -59,6 +63,7 @@ export const ProjectApi = HttpApi.make("project") description: "Experimental HttpApi project routes.", }), ) + .middleware(InstanceContextMiddleware) .middleware(Authorization), ) .annotateMerge( @@ -68,40 +73,3 @@ export const ProjectApi = HttpApi.make("project") description: "Experimental HttpApi surface for selected instance routes.", }), ) - -export const projectHandlers = HttpApiBuilder.group(ProjectApi, "project", (handlers) => - Effect.gen(function* () { - const svc = yield* Project.Service - - const list = Effect.fn("ProjectHttpApi.list")(function* () { - return yield* svc.list() - }) - - const current = Effect.fn("ProjectHttpApi.current")(function* () { - return (yield* InstanceState.context).project - }) - - const initGit = Effect.fn("ProjectHttpApi.initGit")(function* () { - const ctx = yield* InstanceState.context - const next = yield* svc.initGit({ directory: ctx.directory, project: ctx.project }) - if (next.id === ctx.project.id && next.vcs === ctx.project.vcs && next.worktree === ctx.project.worktree) - return next - yield* markInstanceForReload(ctx, { - directory: ctx.directory, - worktree: ctx.directory, - project: next, - init: () => AppRuntime.runPromise(InstanceBootstrap), - }) - return next - }) - - const update = Effect.fn("ProjectHttpApi.update")(function* (ctx: { - params: { projectID: ProjectID } - payload: Project.UpdatePayload - }) { - return yield* svc.update({ ...ctx.payload, projectID: ctx.params.projectID }) - }) - - return handlers.handle("list", list).handle("current", current).handle("initGit", initGit).handle("update", update) - }), -) diff --git a/packages/opencode/src/server/routes/instance/httpapi/groups/provider.ts b/packages/opencode/src/server/routes/instance/httpapi/groups/provider.ts new file mode 100644 index 0000000000..56dace0e5e --- /dev/null +++ b/packages/opencode/src/server/routes/instance/httpapi/groups/provider.ts @@ -0,0 +1,74 @@ +import { ProviderAuth } from "@/provider/auth" +import { Provider } from "@/provider/provider" +import { ProviderID } from "@/provider/schema" +import { Schema } from "effect" +import { HttpApi, HttpApiEndpoint, HttpApiError, HttpApiGroup, OpenApi } from "effect/unstable/httpapi" +import { Authorization } from "../auth" +import { InstanceContextMiddleware } from "../instance-context" +import { described } from "./metadata" + +const root = "/provider" + +export const ProviderApi = HttpApi.make("provider") + .add( + HttpApiGroup.make("provider") + .add( + HttpApiEndpoint.get("list", root, { + success: described(Provider.ListResult, "List of providers"), + }).annotateMerge( + OpenApi.annotations({ + identifier: "provider.list", + summary: "List providers", + description: "Get a list of all available AI providers, including both available and connected ones.", + }), + ), + HttpApiEndpoint.get("auth", `${root}/auth`, { + success: described(ProviderAuth.Methods, "Provider auth methods"), + }).annotateMerge( + OpenApi.annotations({ + identifier: "provider.auth", + summary: "Get provider auth methods", + description: "Retrieve available authentication methods for all AI providers.", + }), + ), + HttpApiEndpoint.post("authorize", `${root}/:providerID/oauth/authorize`, { + params: { providerID: ProviderID }, + payload: ProviderAuth.AuthorizeInput, + success: described(Schema.UndefinedOr(ProviderAuth.Authorization), "Authorization URL and method"), + error: HttpApiError.BadRequest, + }).annotateMerge( + OpenApi.annotations({ + identifier: "provider.oauth.authorize", + summary: "Start OAuth authorization", + description: "Start the OAuth authorization flow for a provider.", + }), + ), + HttpApiEndpoint.post("callback", `${root}/:providerID/oauth/callback`, { + params: { providerID: ProviderID }, + payload: ProviderAuth.CallbackInput, + success: described(Schema.Boolean, "OAuth callback processed successfully"), + error: HttpApiError.BadRequest, + }).annotateMerge( + OpenApi.annotations({ + identifier: "provider.oauth.callback", + summary: "Handle OAuth callback", + description: "Handle the OAuth callback from a provider after user authorization.", + }), + ), + ) + .annotateMerge( + OpenApi.annotations({ + title: "provider", + description: "Experimental HttpApi provider routes.", + }), + ) + .middleware(InstanceContextMiddleware) + .middleware(Authorization), + ) + .annotateMerge( + OpenApi.annotations({ + title: "opencode experimental HttpApi", + version: "0.0.1", + description: "Experimental HttpApi surface for selected instance routes.", + }), + ) diff --git a/packages/opencode/src/server/routes/instance/httpapi/groups/pty.ts b/packages/opencode/src/server/routes/instance/httpapi/groups/pty.ts new file mode 100644 index 0000000000..e3914579c1 --- /dev/null +++ b/packages/opencode/src/server/routes/instance/httpapi/groups/pty.ts @@ -0,0 +1,121 @@ +import { Pty } from "@/pty" +import { PtyID } from "@/pty/schema" +import { Schema } from "effect" +import { HttpApi, HttpApiEndpoint, HttpApiError, HttpApiGroup, OpenApi } from "effect/unstable/httpapi" +import { Authorization } from "../auth" +import { InstanceContextMiddleware } from "../instance-context" +import { described } from "./metadata" + +const root = "/pty" +export const Params = Schema.Struct({ ptyID: PtyID }) +export const CursorQuery = Schema.Struct({ cursor: Schema.optional(Schema.String) }) +export const ShellItem = Schema.Struct({ + path: Schema.String, + name: Schema.String, + acceptable: Schema.Boolean, +}) + +export const PtyPaths = { + shells: `${root}/shells`, + list: root, + create: root, + get: `${root}/:ptyID`, + update: `${root}/:ptyID`, + remove: `${root}/:ptyID`, + connect: `${root}/:ptyID/connect`, +} as const + +export const PtyApi = HttpApi.make("pty") + .add( + HttpApiGroup.make("pty") + .add( + HttpApiEndpoint.get("shells", PtyPaths.shells, { success: described(Schema.Array(ShellItem), "List of shells") }).annotateMerge( + OpenApi.annotations({ + identifier: "pty.shells", + summary: "List available shells", + description: "Get a list of available shells on the system.", + }), + ), + HttpApiEndpoint.get("list", PtyPaths.list, { success: described(Schema.Array(Pty.Info), "List of sessions") }).annotateMerge( + OpenApi.annotations({ + identifier: "pty.list", + summary: "List PTY sessions", + description: "Get a list of all active pseudo-terminal (PTY) sessions managed by OpenCode.", + }), + ), + HttpApiEndpoint.post("create", PtyPaths.create, { + payload: Pty.CreateInput, + success: described(Pty.Info, "Created session"), + error: HttpApiError.BadRequest, + }).annotateMerge( + OpenApi.annotations({ + identifier: "pty.create", + summary: "Create PTY session", + description: "Create a new pseudo-terminal (PTY) session for running shell commands and processes.", + }), + ), + HttpApiEndpoint.get("get", PtyPaths.get, { + params: { ptyID: PtyID }, + success: described(Pty.Info, "Session info"), + error: HttpApiError.NotFound, + }).annotateMerge( + OpenApi.annotations({ + identifier: "pty.get", + summary: "Get PTY session", + description: "Retrieve detailed information about a specific pseudo-terminal (PTY) session.", + }), + ), + HttpApiEndpoint.put("update", PtyPaths.update, { + params: { ptyID: PtyID }, + payload: Pty.UpdateInput, + success: described(Pty.Info, "Updated session"), + error: [HttpApiError.BadRequest, HttpApiError.NotFound], + }).annotateMerge( + OpenApi.annotations({ + identifier: "pty.update", + summary: "Update PTY session", + description: "Update properties of an existing pseudo-terminal (PTY) session.", + }), + ), + HttpApiEndpoint.delete("remove", PtyPaths.remove, { + params: { ptyID: PtyID }, + success: described(Schema.Boolean, "Session removed"), + error: HttpApiError.NotFound, + }).annotateMerge( + OpenApi.annotations({ + identifier: "pty.remove", + summary: "Remove PTY session", + description: "Remove and terminate a specific pseudo-terminal (PTY) session.", + }), + ), + ) + .annotateMerge(OpenApi.annotations({ title: "pty", description: "Experimental HttpApi PTY routes." })) + .middleware(InstanceContextMiddleware) + .middleware(Authorization), + ) + .annotateMerge( + OpenApi.annotations({ + title: "opencode experimental HttpApi", + version: "0.0.1", + description: "Experimental HttpApi surface for selected instance routes.", + }), + ) + +export const PtyConnectApi = HttpApi.make("pty-connect").add( + HttpApiGroup.make("pty-connect") + .add( + HttpApiEndpoint.get("connect", PtyPaths.connect, { + params: Params, + success: described(Schema.Boolean, "Connected session"), + error: HttpApiError.NotFound, + }).annotateMerge( + OpenApi.annotations({ + identifier: "pty.connect", + summary: "Connect to PTY session", + description: + "Establish a WebSocket connection to interact with a pseudo-terminal (PTY) session in real-time.", + }), + ), + ) + .annotateMerge(OpenApi.annotations({ title: "pty", description: "PTY websocket route." })), +) diff --git a/packages/opencode/src/server/routes/instance/httpapi/question.ts b/packages/opencode/src/server/routes/instance/httpapi/groups/question.ts similarity index 58% rename from packages/opencode/src/server/routes/instance/httpapi/question.ts rename to packages/opencode/src/server/routes/instance/httpapi/groups/question.ts index 2169e17c5c..de249823b7 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/question.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/groups/question.ts @@ -1,17 +1,24 @@ import { Question } from "@/question" import { QuestionID } from "@/question/schema" -import { Effect, Schema } from "effect" -import { HttpApi, HttpApiBuilder, HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi" -import { Authorization } from "./auth" +import { Schema } from "effect" +import { HttpApi, HttpApiEndpoint, HttpApiError, HttpApiGroup, OpenApi } from "effect/unstable/httpapi" +import { Authorization } from "../auth" +import { InstanceContextMiddleware } from "../instance-context" +import { described } from "./metadata" const root = "/question" +const ReplyPayload = Schema.Struct({ + answers: Schema.Array(Question.Answer).annotate({ + description: "User answers in order of questions (each answer is an array of selected labels)", + }), +}) export const QuestionApi = HttpApi.make("question") .add( HttpApiGroup.make("question") .add( HttpApiEndpoint.get("list", root, { - success: Schema.Array(Question.Request), + success: described(Schema.Array(Question.Request), "List of pending questions"), }).annotateMerge( OpenApi.annotations({ identifier: "question.list", @@ -21,8 +28,9 @@ export const QuestionApi = HttpApi.make("question") ), HttpApiEndpoint.post("reply", `${root}/:requestID/reply`, { params: { requestID: QuestionID }, - payload: Question.Reply, - success: Schema.Boolean, + payload: ReplyPayload, + success: described(Schema.Boolean, "Question answered successfully"), + error: [HttpApiError.BadRequest, HttpApiError.NotFound], }).annotateMerge( OpenApi.annotations({ identifier: "question.reply", @@ -32,7 +40,8 @@ export const QuestionApi = HttpApi.make("question") ), HttpApiEndpoint.post("reject", `${root}/:requestID/reject`, { params: { requestID: QuestionID }, - success: Schema.Boolean, + success: described(Schema.Boolean, "Question rejected successfully"), + error: [HttpApiError.BadRequest, HttpApiError.NotFound], }).annotateMerge( OpenApi.annotations({ identifier: "question.reject", @@ -47,6 +56,7 @@ export const QuestionApi = HttpApi.make("question") description: "Question routes.", }), ) + .middleware(InstanceContextMiddleware) .middleware(Authorization), ) .annotateMerge( @@ -56,31 +66,3 @@ export const QuestionApi = HttpApi.make("question") description: "Effect HttpApi surface for instance routes.", }), ) - -export const questionHandlers = HttpApiBuilder.group(QuestionApi, "question", (handlers) => - Effect.gen(function* () { - const svc = yield* Question.Service - - const list = Effect.fn("QuestionHttpApi.list")(function* () { - return yield* svc.list() - }) - - const reply = Effect.fn("QuestionHttpApi.reply")(function* (ctx: { - params: { requestID: QuestionID } - payload: Question.Reply - }) { - yield* svc.reply({ - requestID: ctx.params.requestID, - answers: ctx.payload.answers, - }) - return true - }) - - const reject = Effect.fn("QuestionHttpApi.reject")(function* (ctx: { params: { requestID: QuestionID } }) { - yield* svc.reject(ctx.params.requestID) - return true - }) - - return handlers.handle("list", list).handle("reply", reply).handle("reject", reject) - }), -) diff --git a/packages/opencode/src/server/routes/instance/httpapi/groups/session.ts b/packages/opencode/src/server/routes/instance/httpapi/groups/session.ts new file mode 100644 index 0000000000..5a388f1876 --- /dev/null +++ b/packages/opencode/src/server/routes/instance/httpapi/groups/session.ts @@ -0,0 +1,428 @@ +import { Permission } from "@/permission" +import { PermissionID } from "@/permission/schema" +import { ModelID, ProviderID } from "@/provider/schema" +import { Session } from "@/session/session" +import { MessageV2 } from "@/session/message-v2" +import { SessionPrompt } from "@/session/prompt" +import { SessionRevert } from "@/session/revert" +import { SessionStatus } from "@/session/status" +import { SessionSummary } from "@/session/summary" +import { Todo } from "@/session/todo" +import { MessageID, PartID, SessionID } from "@/session/schema" +import { Snapshot } from "@/snapshot" +import { NonNegativeInt } from "@/util/schema" +import { Schema, SchemaGetter, Struct } from "effect" +import { HttpApi, HttpApiEndpoint, HttpApiError, HttpApiGroup, HttpApiSchema, OpenApi } from "effect/unstable/httpapi" +import { Authorization } from "../auth" +import { InstanceContextMiddleware } from "../instance-context" +import { described } from "./metadata" + +const root = "/session" +const QueryBoolean = Schema.Literals(["true", "false"]).pipe( + Schema.decodeTo(Schema.Boolean, { + decode: SchemaGetter.transform((value) => value === "true"), + encode: SchemaGetter.transform((value) => (value ? "true" : "false")), + }), +) +export const ListQuery = Schema.Struct({ + directory: Schema.optional(Schema.String), + scope: Schema.optional(Schema.Literals(["project"])), + path: Schema.optional(Schema.String), + roots: Schema.optional(QueryBoolean), + start: Schema.optional(Schema.NumberFromString), + search: Schema.optional(Schema.String), + limit: Schema.optional(Schema.NumberFromString), +}) +export const DiffQuery = Schema.Struct(Struct.omit(SessionSummary.DiffInput.fields, ["sessionID"])) +export const MessagesQuery = Schema.Struct({ + limit: Schema.optional(Schema.NumberFromString.check(Schema.isInt(), Schema.isGreaterThanOrEqualTo(0))), + before: Schema.optional(Schema.String), +}) +export const StatusMap = Schema.Record(Schema.String, SessionStatus.Info) +export const UpdatePayload = Schema.Struct({ + title: Schema.optional(Schema.String), + permission: Schema.optional(Permission.Ruleset), + time: Schema.optional( + Schema.Struct({ + archived: Schema.optional(NonNegativeInt), + }), + ), +}) +export const ForkPayload = Schema.Struct(Struct.omit(Session.ForkInput.fields, ["sessionID"])) +export const InitPayload = Schema.Struct({ + modelID: ModelID, + providerID: ProviderID, + messageID: MessageID, +}) +export const SummarizePayload = Schema.Struct({ + providerID: ProviderID, + modelID: ModelID, + auto: Schema.optional(Schema.Boolean), +}) +export const PromptPayload = Schema.Struct(Struct.omit(SessionPrompt.PromptInput.fields, ["sessionID"])) +export const CommandPayload = Schema.Struct(Struct.omit(SessionPrompt.CommandInput.fields, ["sessionID"])) +export const ShellPayload = Schema.Struct(Struct.omit(SessionPrompt.ShellInput.fields, ["sessionID"])) +export const RevertPayload = Schema.Struct(Struct.omit(SessionRevert.RevertInput.fields, ["sessionID"])) +export const PermissionResponsePayload = Schema.Struct({ + response: Permission.Reply, +}) + +export const SessionPaths = { + list: root, + status: `${root}/status`, + get: `${root}/:sessionID`, + children: `${root}/:sessionID/children`, + todo: `${root}/:sessionID/todo`, + diff: `${root}/:sessionID/diff`, + messages: `${root}/:sessionID/message`, + message: `${root}/:sessionID/message/:messageID`, + create: root, + remove: `${root}/:sessionID`, + update: `${root}/:sessionID`, + fork: `${root}/:sessionID/fork`, + abort: `${root}/:sessionID/abort`, + share: `${root}/:sessionID/share`, + init: `${root}/:sessionID/init`, + summarize: `${root}/:sessionID/summarize`, + prompt: `${root}/:sessionID/message`, + promptAsync: `${root}/:sessionID/prompt_async`, + command: `${root}/:sessionID/command`, + shell: `${root}/:sessionID/shell`, + revert: `${root}/:sessionID/revert`, + unrevert: `${root}/:sessionID/unrevert`, + permissions: `${root}/:sessionID/permissions/:permissionID`, + deleteMessage: `${root}/:sessionID/message/:messageID`, + deletePart: `${root}/:sessionID/message/:messageID/part/:partID`, + updatePart: `${root}/:sessionID/message/:messageID/part/:partID`, +} as const + +export const SessionApi = HttpApi.make("session") + .add( + HttpApiGroup.make("session") + .add( + HttpApiEndpoint.get("list", SessionPaths.list, { + query: ListQuery, + success: described(Schema.Array(Session.Info), "List of sessions"), + }).annotateMerge( + OpenApi.annotations({ + identifier: "session.list", + summary: "List sessions", + description: "Get a list of all OpenCode sessions, sorted by most recently updated.", + }), + ), + HttpApiEndpoint.get("status", SessionPaths.status, { + success: described(StatusMap, "Get session status"), + error: HttpApiError.BadRequest, + }).annotateMerge( + OpenApi.annotations({ + identifier: "session.status", + summary: "Get session status", + description: "Retrieve the current status of all sessions, including active, idle, and completed states.", + }), + ), + HttpApiEndpoint.get("get", SessionPaths.get, { + params: { sessionID: SessionID }, + success: described(Session.Info, "Get session"), + error: [HttpApiError.BadRequest, HttpApiError.NotFound], + }).annotateMerge( + OpenApi.annotations({ + identifier: "session.get", + summary: "Get session", + description: "Retrieve detailed information about a specific OpenCode session.", + }), + ), + HttpApiEndpoint.get("children", SessionPaths.children, { + params: { sessionID: SessionID }, + success: described(Schema.Array(Session.Info), "List of children"), + error: [HttpApiError.BadRequest, HttpApiError.NotFound], + }).annotateMerge( + OpenApi.annotations({ + identifier: "session.children", + summary: "Get session children", + description: "Retrieve all child sessions that were forked from the specified parent session.", + }), + ), + HttpApiEndpoint.get("todo", SessionPaths.todo, { + params: { sessionID: SessionID }, + success: described(Schema.Array(Todo.Info), "Todo list"), + error: [HttpApiError.BadRequest, HttpApiError.NotFound], + }).annotateMerge( + OpenApi.annotations({ + identifier: "session.todo", + summary: "Get session todos", + description: "Retrieve the todo list associated with a specific session, showing tasks and action items.", + }), + ), + HttpApiEndpoint.get("diff", SessionPaths.diff, { + params: { sessionID: SessionID }, + query: DiffQuery, + success: described(Schema.Array(Snapshot.FileDiff), "Successfully retrieved diff"), + }).annotateMerge( + OpenApi.annotations({ + identifier: "session.diff", + summary: "Get message diff", + description: "Get the file changes (diff) that resulted from a specific user message in the session.", + }), + ), + HttpApiEndpoint.get("messages", SessionPaths.messages, { + params: { sessionID: SessionID }, + query: MessagesQuery, + success: described(Schema.Array(MessageV2.WithParts), "List of messages"), + error: [HttpApiError.BadRequest, HttpApiError.NotFound], + }).annotateMerge( + OpenApi.annotations({ + identifier: "session.messages", + summary: "Get session messages", + description: "Retrieve all messages in a session, including user prompts and AI responses.", + }), + ), + HttpApiEndpoint.get("message", SessionPaths.message, { + params: { sessionID: SessionID, messageID: MessageID }, + success: described(MessageV2.WithParts, "Message"), + error: [HttpApiError.BadRequest, HttpApiError.NotFound], + }).annotateMerge( + OpenApi.annotations({ + identifier: "session.message", + summary: "Get message", + description: "Retrieve a specific message from a session by its message ID.", + }), + ), + HttpApiEndpoint.post("create", SessionPaths.create, { + payload: [HttpApiSchema.NoContent, Session.CreateInput], + success: described(Session.Info, "Successfully created session"), + error: HttpApiError.BadRequest, + }).annotateMerge( + OpenApi.annotations({ + identifier: "session.create", + summary: "Create session", + description: "Create a new OpenCode session for interacting with AI assistants and managing conversations.", + }), + ), + HttpApiEndpoint.delete("remove", SessionPaths.remove, { + params: { sessionID: SessionID }, + success: described(Schema.Boolean, "Successfully deleted session"), + error: [HttpApiError.BadRequest, HttpApiError.NotFound], + }).annotateMerge( + OpenApi.annotations({ + identifier: "session.delete", + summary: "Delete session", + description: "Delete a session and permanently remove all associated data, including messages and history.", + }), + ), + HttpApiEndpoint.patch("update", SessionPaths.update, { + params: { sessionID: SessionID }, + payload: UpdatePayload, + success: described(Session.Info, "Successfully updated session"), + error: [HttpApiError.BadRequest, HttpApiError.NotFound], + }).annotateMerge( + OpenApi.annotations({ + identifier: "session.update", + summary: "Update session", + description: "Update properties of an existing session, such as title or other metadata.", + }), + ), + HttpApiEndpoint.post("fork", SessionPaths.fork, { + params: { sessionID: SessionID }, + payload: ForkPayload, + success: described(Session.Info, "200"), + }).annotateMerge( + OpenApi.annotations({ + identifier: "session.fork", + summary: "Fork session", + description: "Create a new session by forking an existing session at a specific message point.", + }), + ), + HttpApiEndpoint.post("abort", SessionPaths.abort, { + params: { sessionID: SessionID }, + success: described(Schema.Boolean, "Aborted session"), + error: [HttpApiError.BadRequest, HttpApiError.NotFound], + }).annotateMerge( + OpenApi.annotations({ + identifier: "session.abort", + summary: "Abort session", + description: "Abort an active session and stop any ongoing AI processing or command execution.", + }), + ), + HttpApiEndpoint.post("init", SessionPaths.init, { + params: { sessionID: SessionID }, + payload: InitPayload, + success: described(Schema.Boolean, "200"), + error: [HttpApiError.BadRequest, HttpApiError.NotFound], + }).annotateMerge( + OpenApi.annotations({ + identifier: "session.init", + summary: "Initialize session", + description: + "Analyze the current application and create an AGENTS.md file with project-specific agent configurations.", + }), + ), + HttpApiEndpoint.post("share", SessionPaths.share, { + params: { sessionID: SessionID }, + success: described(Session.Info, "Successfully shared session"), + error: [HttpApiError.BadRequest, HttpApiError.NotFound], + }).annotateMerge( + OpenApi.annotations({ + identifier: "session.share", + summary: "Share session", + description: "Create a shareable link for a session, allowing others to view the conversation.", + }), + ), + HttpApiEndpoint.delete("unshare", SessionPaths.share, { + params: { sessionID: SessionID }, + success: described(Session.Info, "Successfully unshared session"), + error: [HttpApiError.BadRequest, HttpApiError.NotFound], + }).annotateMerge( + OpenApi.annotations({ + identifier: "session.unshare", + summary: "Unshare session", + description: "Remove the shareable link for a session, making it private again.", + }), + ), + HttpApiEndpoint.post("summarize", SessionPaths.summarize, { + params: { sessionID: SessionID }, + payload: SummarizePayload, + success: described(Schema.Boolean, "Summarized session"), + error: [HttpApiError.BadRequest, HttpApiError.NotFound], + }).annotateMerge( + OpenApi.annotations({ + identifier: "session.summarize", + summary: "Summarize session", + description: "Generate a concise summary of the session using AI compaction to preserve key information.", + }), + ), + HttpApiEndpoint.post("prompt", SessionPaths.prompt, { + params: { sessionID: SessionID }, + payload: PromptPayload, + success: described(MessageV2.WithParts, "Created message"), + error: [HttpApiError.BadRequest, HttpApiError.NotFound], + }).annotateMerge( + OpenApi.annotations({ + identifier: "session.prompt", + summary: "Send message", + description: "Create and send a new message to a session, streaming the AI response.", + }), + ), + HttpApiEndpoint.post("promptAsync", SessionPaths.promptAsync, { + params: { sessionID: SessionID }, + payload: PromptPayload, + success: described(HttpApiSchema.NoContent, "Prompt accepted"), + error: [HttpApiError.BadRequest, HttpApiError.NotFound], + }).annotateMerge( + OpenApi.annotations({ + identifier: "session.prompt_async", + summary: "Send async message", + description: + "Create and send a new message to a session asynchronously, starting the session if needed and returning immediately.", + }), + ), + HttpApiEndpoint.post("command", SessionPaths.command, { + params: { sessionID: SessionID }, + payload: CommandPayload, + success: described(MessageV2.WithParts, "Created message"), + error: [HttpApiError.BadRequest, HttpApiError.NotFound], + }).annotateMerge( + OpenApi.annotations({ + identifier: "session.command", + summary: "Send command", + description: "Send a new command to a session for execution by the AI assistant.", + }), + ), + HttpApiEndpoint.post("shell", SessionPaths.shell, { + params: { sessionID: SessionID }, + payload: ShellPayload, + success: described(MessageV2.WithParts, "Created message"), + error: [HttpApiError.BadRequest, HttpApiError.NotFound], + }).annotateMerge( + OpenApi.annotations({ + identifier: "session.shell", + summary: "Run shell command", + description: "Execute a shell command within the session context and return the AI's response.", + }), + ), + HttpApiEndpoint.post("revert", SessionPaths.revert, { + params: { sessionID: SessionID }, + payload: RevertPayload, + success: described(Session.Info, "Updated session"), + error: [HttpApiError.BadRequest, HttpApiError.NotFound], + }).annotateMerge( + OpenApi.annotations({ + identifier: "session.revert", + summary: "Revert message", + description: + "Revert a specific message in a session, undoing its effects and restoring the previous state.", + }), + ), + HttpApiEndpoint.post("unrevert", SessionPaths.unrevert, { + params: { sessionID: SessionID }, + success: described(Session.Info, "Updated session"), + error: [HttpApiError.BadRequest, HttpApiError.NotFound], + }).annotateMerge( + OpenApi.annotations({ + identifier: "session.unrevert", + summary: "Restore reverted messages", + description: "Restore all previously reverted messages in a session.", + }), + ), + HttpApiEndpoint.post("permissionRespond", SessionPaths.permissions, { + params: { sessionID: SessionID, permissionID: PermissionID }, + payload: PermissionResponsePayload, + success: described(Schema.Boolean, "Permission processed successfully"), + error: [HttpApiError.BadRequest, HttpApiError.NotFound], + }).annotateMerge( + OpenApi.annotations({ + identifier: "permission.respond", + summary: "Respond to permission", + description: "Approve or deny a permission request from the AI assistant.", + deprecated: true, + }), + ), + HttpApiEndpoint.delete("deleteMessage", SessionPaths.deleteMessage, { + params: { sessionID: SessionID, messageID: MessageID }, + success: described(Schema.Boolean, "Successfully deleted message"), + error: [HttpApiError.BadRequest, HttpApiError.NotFound], + }).annotateMerge( + OpenApi.annotations({ + identifier: "session.deleteMessage", + summary: "Delete message", + description: + "Permanently delete a specific message and all of its parts from a session without reverting file changes.", + }), + ), + HttpApiEndpoint.delete("deletePart", SessionPaths.deletePart, { + params: { sessionID: SessionID, messageID: MessageID, partID: PartID }, + success: described(Schema.Boolean, "Successfully deleted part"), + error: [HttpApiError.BadRequest, HttpApiError.NotFound], + }).annotateMerge( + OpenApi.annotations({ + identifier: "part.delete", + description: "Delete a part from a message.", + }), + ), + HttpApiEndpoint.patch("updatePart", SessionPaths.updatePart, { + params: { sessionID: SessionID, messageID: MessageID, partID: PartID }, + payload: MessageV2.Part, + success: described(MessageV2.Part, "Successfully updated part"), + error: [HttpApiError.BadRequest, HttpApiError.NotFound], + }).annotateMerge( + OpenApi.annotations({ + identifier: "part.update", + description: "Update a part in a message.", + }), + ), + ) + .annotateMerge( + OpenApi.annotations({ + title: "session", + description: "Experimental HttpApi session routes.", + }), + ) + .middleware(InstanceContextMiddleware) + .middleware(Authorization), + ) + .annotateMerge( + OpenApi.annotations({ + title: "opencode experimental HttpApi", + version: "0.0.1", + description: "Experimental HttpApi surface for selected instance routes.", + }), + ) diff --git a/packages/opencode/src/server/routes/instance/httpapi/groups/sync.ts b/packages/opencode/src/server/routes/instance/httpapi/groups/sync.ts new file mode 100644 index 0000000000..1d9b08d9cb --- /dev/null +++ b/packages/opencode/src/server/routes/instance/httpapi/groups/sync.ts @@ -0,0 +1,90 @@ +import { NonNegativeInt } from "@/util/schema" +import { Schema } from "effect" +import { HttpApi, HttpApiEndpoint, HttpApiError, HttpApiGroup, OpenApi } from "effect/unstable/httpapi" +import { Authorization } from "../auth" +import { InstanceContextMiddleware } from "../instance-context" +import { described } from "./metadata" + +const root = "/sync" +export const ReplayEvent = Schema.Struct({ + id: Schema.String, + aggregateID: Schema.String, + seq: NonNegativeInt, + type: Schema.String, + data: Schema.Record(Schema.String, Schema.Unknown), +}) +export const ReplayPayload = Schema.Struct({ + directory: Schema.String, + events: Schema.NonEmptyArray(ReplayEvent), +}) +export const ReplayResponse = Schema.Struct({ + sessionID: Schema.String, +}) +export const HistoryPayload = Schema.Record(Schema.String, NonNegativeInt) +export const HistoryEvent = Schema.Struct({ + id: Schema.String, + aggregate_id: Schema.String, + seq: NonNegativeInt, + type: Schema.String, + data: Schema.Record(Schema.String, Schema.Unknown), +}) + +export const SyncPaths = { + start: `${root}/start`, + replay: `${root}/replay`, + history: `${root}/history`, +} as const + +export const SyncApi = HttpApi.make("sync") + .add( + HttpApiGroup.make("sync") + .add( + HttpApiEndpoint.post("start", SyncPaths.start, { + success: described(Schema.Boolean, "Workspace sync started"), + }).annotateMerge( + OpenApi.annotations({ + identifier: "sync.start", + summary: "Start workspace sync", + description: "Start sync loops for workspaces in the current project that have active sessions.", + }), + ), + HttpApiEndpoint.post("replay", SyncPaths.replay, { + payload: ReplayPayload, + success: described(ReplayResponse, "Replayed sync events"), + error: HttpApiError.BadRequest, + }).annotateMerge( + OpenApi.annotations({ + identifier: "sync.replay", + summary: "Replay sync events", + description: "Validate and replay a complete sync event history.", + }), + ), + HttpApiEndpoint.post("history", SyncPaths.history, { + payload: HistoryPayload, + success: described(Schema.Array(HistoryEvent), "Sync events"), + error: HttpApiError.BadRequest, + }).annotateMerge( + OpenApi.annotations({ + identifier: "sync.history.list", + summary: "List sync events", + description: + "List sync events for all aggregates. Keys are aggregate IDs the client already knows about, values are the last known sequence ID. Events with seq > value are returned for those aggregates. Aggregates not listed in the input get their full history.", + }), + ), + ) + .annotateMerge( + OpenApi.annotations({ + title: "sync", + description: "Experimental HttpApi sync routes.", + }), + ) + .middleware(InstanceContextMiddleware) + .middleware(Authorization), + ) + .annotateMerge( + OpenApi.annotations({ + title: "opencode experimental HttpApi", + version: "0.0.1", + description: "Experimental HttpApi surface for selected instance routes.", + }), + ) diff --git a/packages/opencode/src/server/routes/instance/httpapi/groups/tui.ts b/packages/opencode/src/server/routes/instance/httpapi/groups/tui.ts new file mode 100644 index 0000000000..a5d31bfa62 --- /dev/null +++ b/packages/opencode/src/server/routes/instance/httpapi/groups/tui.ts @@ -0,0 +1,164 @@ +import { TuiEvent } from "@/cli/cmd/tui/event" +import { Schema } from "effect" +import { HttpApi, HttpApiEndpoint, HttpApiError, HttpApiGroup, OpenApi } from "effect/unstable/httpapi" +import { Authorization } from "../auth" +import { InstanceContextMiddleware } from "../instance-context" +import { described } from "./metadata" + +const root = "/tui" +export const CommandPayload = Schema.Struct({ command: Schema.String }) +export const TuiRequestPayload = Schema.Struct({ + path: Schema.String, + body: Schema.Unknown, +}) +const EventTuiPromptAppend = Schema.Struct({ type: Schema.Literal(TuiEvent.PromptAppend.type), properties: TuiEvent.PromptAppend.properties }).annotate({ identifier: "EventTuiPromptAppend" }) +const EventTuiCommandExecute = Schema.Struct({ type: Schema.Literal(TuiEvent.CommandExecute.type), properties: TuiEvent.CommandExecute.properties }).annotate({ identifier: "EventTuiCommandExecute" }) +const EventTuiToastShow = Schema.Struct({ type: Schema.Literal(TuiEvent.ToastShow.type), properties: TuiEvent.ToastShow.properties }).annotate({ identifier: "EventTuiToastShow" }) +const EventTuiSessionSelect = Schema.Struct({ type: Schema.Literal(TuiEvent.SessionSelect.type), properties: TuiEvent.SessionSelect.properties }).annotate({ identifier: "EventTuiSessionSelect" }) +export const TuiPublishPayload = Schema.Union([EventTuiPromptAppend, EventTuiCommandExecute, EventTuiToastShow, EventTuiSessionSelect]) + +export const TuiPaths = { + appendPrompt: `${root}/append-prompt`, + openHelp: `${root}/open-help`, + openSessions: `${root}/open-sessions`, + openThemes: `${root}/open-themes`, + openModels: `${root}/open-models`, + submitPrompt: `${root}/submit-prompt`, + clearPrompt: `${root}/clear-prompt`, + executeCommand: `${root}/execute-command`, + showToast: `${root}/show-toast`, + publish: `${root}/publish`, + selectSession: `${root}/select-session`, + controlNext: `${root}/control/next`, + controlResponse: `${root}/control/response`, +} as const + +export const TuiApi = HttpApi.make("tui") + .add( + HttpApiGroup.make("tui") + .add( + HttpApiEndpoint.post("appendPrompt", TuiPaths.appendPrompt, { + payload: TuiEvent.PromptAppend.properties, + success: described(Schema.Boolean, "Prompt processed successfully"), + error: HttpApiError.BadRequest, + }).annotateMerge( + OpenApi.annotations({ + identifier: "tui.appendPrompt", + summary: "Append TUI prompt", + description: "Append prompt to the TUI.", + }), + ), + HttpApiEndpoint.post("openHelp", TuiPaths.openHelp, { success: described(Schema.Boolean, "Help dialog opened successfully") }).annotateMerge( + OpenApi.annotations({ + identifier: "tui.openHelp", + summary: "Open help dialog", + description: "Open the help dialog in the TUI to display user assistance information.", + }), + ), + HttpApiEndpoint.post("openSessions", TuiPaths.openSessions, { success: described(Schema.Boolean, "Session dialog opened successfully") }).annotateMerge( + OpenApi.annotations({ + identifier: "tui.openSessions", + summary: "Open sessions dialog", + description: "Open the session dialog.", + }), + ), + HttpApiEndpoint.post("openThemes", TuiPaths.openThemes, { success: described(Schema.Boolean, "Theme dialog opened successfully") }).annotateMerge( + OpenApi.annotations({ + identifier: "tui.openThemes", + summary: "Open themes dialog", + description: "Open the theme dialog.", + }), + ), + HttpApiEndpoint.post("openModels", TuiPaths.openModels, { success: described(Schema.Boolean, "Model dialog opened successfully") }).annotateMerge( + OpenApi.annotations({ + identifier: "tui.openModels", + summary: "Open models dialog", + description: "Open the model dialog.", + }), + ), + HttpApiEndpoint.post("submitPrompt", TuiPaths.submitPrompt, { success: described(Schema.Boolean, "Prompt submitted successfully") }).annotateMerge( + OpenApi.annotations({ + identifier: "tui.submitPrompt", + summary: "Submit TUI prompt", + description: "Submit the prompt.", + }), + ), + HttpApiEndpoint.post("clearPrompt", TuiPaths.clearPrompt, { success: described(Schema.Boolean, "Prompt cleared successfully") }).annotateMerge( + OpenApi.annotations({ + identifier: "tui.clearPrompt", + summary: "Clear TUI prompt", + description: "Clear the prompt.", + }), + ), + HttpApiEndpoint.post("executeCommand", TuiPaths.executeCommand, { + payload: CommandPayload, + success: described(Schema.Boolean, "Command executed successfully"), + error: HttpApiError.BadRequest, + }).annotateMerge( + OpenApi.annotations({ + identifier: "tui.executeCommand", + summary: "Execute TUI command", + description: "Execute a TUI command.", + }), + ), + HttpApiEndpoint.post("showToast", TuiPaths.showToast, { + payload: TuiEvent.ToastShow.properties, + success: described(Schema.Boolean, "Toast notification shown successfully"), + }).annotateMerge( + OpenApi.annotations({ + identifier: "tui.showToast", + summary: "Show TUI toast", + description: "Show a toast notification in the TUI.", + }), + ), + HttpApiEndpoint.post("publish", TuiPaths.publish, { + payload: TuiPublishPayload, + success: described(Schema.Boolean, "Event published successfully"), + error: HttpApiError.BadRequest, + }).annotateMerge( + OpenApi.annotations({ + identifier: "tui.publish", + summary: "Publish TUI event", + description: "Publish a TUI event.", + }), + ), + HttpApiEndpoint.post("selectSession", TuiPaths.selectSession, { + payload: TuiEvent.SessionSelect.properties, + success: described(Schema.Boolean, "Session selected successfully"), + error: [HttpApiError.BadRequest, HttpApiError.NotFound], + }).annotateMerge( + OpenApi.annotations({ + identifier: "tui.selectSession", + summary: "Select session", + description: "Navigate the TUI to display the specified session.", + }), + ), + HttpApiEndpoint.get("controlNext", TuiPaths.controlNext, { success: described(TuiRequestPayload, "Next TUI request") }).annotateMerge( + OpenApi.annotations({ + identifier: "tui.control.next", + summary: "Get next TUI request", + description: "Retrieve the next TUI request from the queue for processing.", + }), + ), + HttpApiEndpoint.post("controlResponse", TuiPaths.controlResponse, { + payload: Schema.Unknown, + success: described(Schema.Boolean, "Response submitted successfully"), + }).annotateMerge( + OpenApi.annotations({ + identifier: "tui.control.response", + summary: "Submit TUI response", + description: "Submit a response to the TUI request queue to complete a pending request.", + }), + ), + ) + .annotateMerge(OpenApi.annotations({ title: "tui", description: "Experimental HttpApi TUI routes." })) + .middleware(InstanceContextMiddleware) + .middleware(Authorization), + ) + .annotateMerge( + OpenApi.annotations({ + title: "opencode experimental HttpApi", + version: "0.0.1", + description: "Experimental HttpApi surface for selected instance routes.", + }), + ) diff --git a/packages/opencode/src/server/routes/instance/httpapi/groups/workspace.ts b/packages/opencode/src/server/routes/instance/httpapi/groups/workspace.ts new file mode 100644 index 0000000000..0305c65365 --- /dev/null +++ b/packages/opencode/src/server/routes/instance/httpapi/groups/workspace.ts @@ -0,0 +1,103 @@ +import { Workspace } from "@/control-plane/workspace" +import { WorkspaceAdaptorEntry } from "@/control-plane/types" +import { NonNegativeInt } from "@/util/schema" +import { Schema, Struct } from "effect" +import { HttpApi, HttpApiEndpoint, HttpApiError, HttpApiGroup, OpenApi } from "effect/unstable/httpapi" +import { Authorization } from "../auth" +import { InstanceContextMiddleware } from "../instance-context" +import { described } from "./metadata" + +const root = "/experimental/workspace" +export const CreatePayload = Schema.Struct(Struct.omit(Workspace.CreateInput.fields, ["projectID"])) +export const SessionRestorePayload = Schema.Struct( + Struct.omit(Workspace.SessionRestoreInput.fields, ["workspaceID"]), +) +export const SessionRestoreResponse = Schema.Struct({ + total: NonNegativeInt, +}) + +export const WorkspacePaths = { + adaptors: `${root}/adaptor`, + list: root, + status: `${root}/status`, + remove: `${root}/:id`, + sessionRestore: `${root}/:id/session-restore`, +} as const + +export const WorkspaceApi = HttpApi.make("workspace") + .add( + HttpApiGroup.make("workspace") + .add( + HttpApiEndpoint.get("adaptors", WorkspacePaths.adaptors, { + success: described(Schema.Array(WorkspaceAdaptorEntry), "Workspace adaptors"), + }).annotateMerge( + OpenApi.annotations({ + identifier: "experimental.workspace.adaptor.list", + summary: "List workspace adaptors", + description: "List all available workspace adaptors for the current project.", + }), + ), + HttpApiEndpoint.get("list", WorkspacePaths.list, { + success: described(Schema.Array(Workspace.Info), "Workspaces"), + }).annotateMerge( + OpenApi.annotations({ + identifier: "experimental.workspace.list", + summary: "List workspaces", + description: "List all workspaces.", + }), + ), + HttpApiEndpoint.post("create", WorkspacePaths.list, { + payload: CreatePayload, + success: described(Workspace.Info, "Workspace created"), + error: HttpApiError.BadRequest, + }).annotateMerge( + OpenApi.annotations({ + identifier: "experimental.workspace.create", + summary: "Create workspace", + description: "Create a workspace for the current project.", + }), + ), + HttpApiEndpoint.get("status", WorkspacePaths.status, { + success: described(Schema.Array(Workspace.ConnectionStatus), "Workspace status"), + }).annotateMerge( + OpenApi.annotations({ + identifier: "experimental.workspace.status", + summary: "Workspace status", + description: "Get connection status for workspaces in the current project.", + }), + ), + HttpApiEndpoint.delete("remove", WorkspacePaths.remove, { + params: { id: Workspace.Info.fields.id }, + success: described(Schema.UndefinedOr(Workspace.Info), "Workspace removed"), + error: HttpApiError.BadRequest, + }).annotateMerge( + OpenApi.annotations({ + identifier: "experimental.workspace.remove", + summary: "Remove workspace", + description: "Remove an existing workspace.", + }), + ), + HttpApiEndpoint.post("sessionRestore", WorkspacePaths.sessionRestore, { + params: { id: Workspace.Info.fields.id }, + payload: SessionRestorePayload, + success: described(SessionRestoreResponse, "Session replay started"), + error: HttpApiError.BadRequest, + }).annotateMerge( + OpenApi.annotations({ + identifier: "experimental.workspace.sessionRestore", + summary: "Restore session into workspace", + description: "Replay a session's sync events into the target workspace in batches.", + }), + ), + ) + .annotateMerge(OpenApi.annotations({ title: "workspace", description: "Experimental HttpApi workspace routes." })) + .middleware(InstanceContextMiddleware) + .middleware(Authorization), + ) + .annotateMerge( + OpenApi.annotations({ + title: "opencode experimental HttpApi", + version: "0.0.1", + description: "Experimental HttpApi surface for selected instance routes.", + }), + ) diff --git a/packages/opencode/src/server/routes/instance/httpapi/handlers/config.ts b/packages/opencode/src/server/routes/instance/httpapi/handlers/config.ts new file mode 100644 index 0000000000..2fc225d171 --- /dev/null +++ b/packages/opencode/src/server/routes/instance/httpapi/handlers/config.ts @@ -0,0 +1,34 @@ +import { Config } from "@/config/config" +import { Provider } from "@/provider/provider" +import * as InstanceState from "@/effect/instance-state" +import { Effect } from "effect" +import { HttpApiBuilder } from "effect/unstable/httpapi" +import { InstanceHttpApi } from "../api" +import { markInstanceForDisposal } from "../lifecycle" + +export const configHandlers = HttpApiBuilder.group(InstanceHttpApi, "config", (handlers) => + Effect.gen(function* () { + const providerSvc = yield* Provider.Service + const configSvc = yield* Config.Service + + const get = Effect.fn("ConfigHttpApi.get")(function* () { + return yield* configSvc.get() + }) + + const update = Effect.fn("ConfigHttpApi.update")(function* (ctx) { + yield* configSvc.update(ctx.payload, { dispose: false }) + yield* markInstanceForDisposal(yield* InstanceState.context) + return ctx.payload + }) + + const providers = Effect.fn("ConfigHttpApi.providers")(function* () { + const providers = yield* providerSvc.list() + return { + providers: Object.values(providers), + default: Provider.defaultModelIDs(providers), + } + }) + + return handlers.handle("get", get).handle("update", update).handle("providers", providers) + }), +) diff --git a/packages/opencode/src/server/routes/instance/httpapi/handlers/control.ts b/packages/opencode/src/server/routes/instance/httpapi/handlers/control.ts new file mode 100644 index 0000000000..abddd8c402 --- /dev/null +++ b/packages/opencode/src/server/routes/instance/httpapi/handlers/control.ts @@ -0,0 +1,34 @@ +import { Auth } from "@/auth" +import { ProviderID } from "@/provider/schema" +import * as Log from "@opencode-ai/core/util/log" +import { Effect } from "effect" +import { HttpApiBuilder } from "effect/unstable/httpapi" +import { RootHttpApi } from "../api" +import { LogInput } from "../groups/control" + +export const controlHandlers = HttpApiBuilder.group(RootHttpApi, "control", (handlers) => + Effect.gen(function* () { + const auth = yield* Auth.Service + + const authSet = Effect.fn("ControlHttpApi.authSet")(function* (ctx: { + params: { providerID: ProviderID } + payload: Auth.Info + }) { + yield* auth.set(ctx.params.providerID, ctx.payload).pipe(Effect.orDie) + return true + }) + + const authRemove = Effect.fn("ControlHttpApi.authRemove")(function* (ctx: { params: { providerID: ProviderID } }) { + yield* auth.remove(ctx.params.providerID).pipe(Effect.orDie) + return true + }) + + const log = Effect.fn("ControlHttpApi.log")(function* (ctx: { payload: typeof LogInput.Type }) { + const logger = Log.create({ service: ctx.payload.service }) + logger[ctx.payload.level](ctx.payload.message, ctx.payload.extra) + return true + }) + + return handlers.handle("authSet", authSet).handle("authRemove", authRemove).handle("log", log) + }), +) diff --git a/packages/opencode/src/server/routes/instance/httpapi/handlers/experimental.ts b/packages/opencode/src/server/routes/instance/httpapi/handlers/experimental.ts new file mode 100644 index 0000000000..42eab762e8 --- /dev/null +++ b/packages/opencode/src/server/routes/instance/httpapi/handlers/experimental.ts @@ -0,0 +1,155 @@ +import { Account } from "@/account/account" +import { Agent } from "@/agent/agent" +import { Config } from "@/config/config" +import { InstanceState } from "@/effect/instance-state" +import { MCP } from "@/mcp" +import { Project } from "@/project/project" +import { Session } from "@/session/session" +import { ToolRegistry } from "@/tool/registry" +import * as EffectZod from "@/util/effect-zod" +import { Worktree } from "@/worktree" +import { Effect, Option } from "effect" +import * as HttpServerResponse from "effect/unstable/http/HttpServerResponse" +import { HttpApiBuilder, HttpApiError } from "effect/unstable/httpapi" +import { InstanceHttpApi } from "../api" +import { ConsoleSwitchPayload, SessionListQuery, ToolListQuery } from "../groups/experimental" + +export const experimentalHandlers = HttpApiBuilder.group(InstanceHttpApi, "experimental", (handlers) => + Effect.gen(function* () { + const account = yield* Account.Service + const agents = yield* Agent.Service + const config = yield* Config.Service + const mcp = yield* MCP.Service + const project = yield* Project.Service + const registry = yield* ToolRegistry.Service + const worktreeSvc = yield* Worktree.Service + + const getConsole = Effect.fn("ExperimentalHttpApi.console")(function* () { + const [state, groups] = yield* Effect.all( + [config.getConsoleState(), account.orgsByAccount().pipe(Effect.orDie)], + { + concurrency: "unbounded", + }, + ) + return { + consoleManagedProviders: state.consoleManagedProviders, + ...(state.activeOrgName ? { activeOrgName: state.activeOrgName } : {}), + switchableOrgCount: groups.reduce((count, group) => count + group.orgs.length, 0), + } + }) + + const listConsoleOrgs = Effect.fn("ExperimentalHttpApi.consoleOrgs")(function* () { + const [groups, active] = yield* Effect.all( + [account.orgsByAccount().pipe(Effect.orDie), account.active().pipe(Effect.orDie)], + { + concurrency: "unbounded", + }, + ) + const info = Option.getOrUndefined(active) + return { + orgs: groups.flatMap((group) => + group.orgs.map((org) => ({ + accountID: group.account.id, + accountEmail: group.account.email, + accountUrl: group.account.url, + orgID: org.id, + orgName: org.name, + active: !!info && info.id === group.account.id && info.active_org_id === org.id, + })), + ), + } + }) + + const switchConsole = Effect.fn("ExperimentalHttpApi.consoleSwitch")(function* (ctx: { + payload: typeof ConsoleSwitchPayload.Type + }) { + yield* account + .use(ctx.payload.accountID, Option.some(ctx.payload.orgID)) + .pipe(Effect.catch(() => Effect.fail(new HttpApiError.BadRequest({})))) + return true + }) + + const tool = Effect.fn("ExperimentalHttpApi.tool")(function* (ctx: { query: typeof ToolListQuery.Type }) { + const list = yield* registry.tools({ + providerID: ctx.query.provider, + modelID: ctx.query.model, + agent: yield* agents.get(yield* agents.defaultAgent()), + }) + return list.map((item) => ({ + id: item.id, + description: item.description, + parameters: EffectZod.toJsonSchema(item.parameters), + })) + }) + + const toolIDs = Effect.fn("ExperimentalHttpApi.toolIDs")(function* () { + return yield* registry.ids() + }) + + const worktree = Effect.fn("ExperimentalHttpApi.worktree")(function* () { + const ctx = yield* InstanceState.context + return yield* project.sandboxes(ctx.project.id) + }) + + const worktreeCreate = Effect.fn("ExperimentalHttpApi.worktreeCreate")(function* (ctx: { + payload: Worktree.CreateInput | undefined + }) { + return yield* worktreeSvc.create(ctx.payload) + }) + + const worktreeRemove = Effect.fn("ExperimentalHttpApi.worktreeRemove")(function* (input: { + payload: Worktree.RemoveInput + }) { + const ctx = yield* InstanceState.context + yield* worktreeSvc.remove(input.payload) + yield* project.removeSandbox(ctx.project.id, input.payload.directory) + return true + }) + + const worktreeReset = Effect.fn("ExperimentalHttpApi.worktreeReset")(function* (ctx: { + payload: Worktree.ResetInput + }) { + yield* worktreeSvc.reset(ctx.payload) + return true + }) + + const session = Effect.fn("ExperimentalHttpApi.session")(function* (ctx: { query: typeof SessionListQuery.Type }) { + const limit = ctx.query.limit ?? 100 + const sessions = Array.from( + Session.listGlobal({ + directory: ctx.query.directory, + roots: ctx.query.roots, + start: ctx.query.start, + cursor: ctx.query.cursor, + search: ctx.query.search, + limit: limit + 1, + archived: ctx.query.archived, + }), + ) + const list = sessions.length > limit ? sessions.slice(0, limit) : sessions + return HttpServerResponse.jsonUnsafe(list, { + headers: + sessions.length > limit && list.length > 0 + ? { "x-next-cursor": String(list[list.length - 1].time.updated) } + : undefined, + }) + }) + + const resource = Effect.fn("ExperimentalHttpApi.resource")(function* () { + return yield* mcp.resources() + }) + + return handlers + .handle("console", getConsole) + .handle("consoleOrgs", listConsoleOrgs) + .handle("consoleSwitch", switchConsole) + .handle("tool", tool) + .handle("toolIDs", toolIDs) + .handle("worktree", worktree) + .handle("worktreeCreate", worktreeCreate) + .handle("worktreeRemove", worktreeRemove) + .handle("worktreeReset", worktreeReset) + .handle("session", session) + .handle("resource", resource) + }), +) diff --git a/packages/opencode/src/server/routes/instance/httpapi/handlers/file.ts b/packages/opencode/src/server/routes/instance/httpapi/handlers/file.ts new file mode 100644 index 0000000000..72133e8dea --- /dev/null +++ b/packages/opencode/src/server/routes/instance/httpapi/handlers/file.ts @@ -0,0 +1,54 @@ +import * as InstanceState from "@/effect/instance-state" +import { File } from "@/file" +import { Ripgrep } from "@/file/ripgrep" +import { Effect } from "effect" +import { HttpApiBuilder } from "effect/unstable/httpapi" +import { InstanceHttpApi } from "../api" + +export const fileHandlers = HttpApiBuilder.group(InstanceHttpApi, "file", (handlers) => + Effect.gen(function* () { + const svc = yield* File.Service + const ripgrep = yield* Ripgrep.Service + + const findText = Effect.fn("FileHttpApi.findText")(function* (ctx: { query: { pattern: string } }) { + return (yield* ripgrep + .search({ cwd: (yield* InstanceState.context).directory, pattern: ctx.query.pattern, limit: 10 }) + .pipe(Effect.orDie)).items + }) + + const findFile = Effect.fn("FileHttpApi.findFile")(function* (ctx: { + query: { query: string; dirs?: "true" | "false"; type?: "file" | "directory"; limit?: number } + }) { + return yield* svc.search({ + query: ctx.query.query, + limit: ctx.query.limit ?? 10, + dirs: ctx.query.dirs !== "false", + type: ctx.query.type, + }) + }) + + const findSymbol = Effect.fn("FileHttpApi.findSymbol")(function* () { + return [] + }) + + const list = Effect.fn("FileHttpApi.list")(function* (ctx: { query: { path: string } }) { + return yield* svc.list(ctx.query.path) + }) + + const content = Effect.fn("FileHttpApi.content")(function* (ctx: { query: { path: string } }) { + return yield* svc.read(ctx.query.path) + }) + + const status = Effect.fn("FileHttpApi.status")(function* () { + return yield* svc.status() + }) + + return handlers + .handle("findText", findText) + .handle("findFile", findFile) + .handle("findSymbol", findSymbol) + .handle("list", list) + .handle("content", content) + .handle("status", status) + }), +) diff --git a/packages/opencode/src/server/routes/instance/httpapi/handlers/global.ts b/packages/opencode/src/server/routes/instance/httpapi/handlers/global.ts new file mode 100644 index 0000000000..5972395512 --- /dev/null +++ b/packages/opencode/src/server/routes/instance/httpapi/handlers/global.ts @@ -0,0 +1,156 @@ +import { Config } from "@/config/config" +import { GlobalBus, type GlobalEvent as GlobalBusEvent } from "@/bus/global" +import { Installation } from "@/installation" +import { Instance } from "@/project/instance" +import { InstallationVersion } from "@opencode-ai/core/installation/version" +import * as Log from "@opencode-ai/core/util/log" +import { Effect, Queue, Schema } from "effect" +import * as Stream from "effect/Stream" +import { HttpServerRequest, HttpServerResponse } from "effect/unstable/http" +import { HttpApiBuilder } from "effect/unstable/httpapi" +import * as Sse from "effect/unstable/encoding/Sse" +import { RootHttpApi } from "../api" +import { GlobalUpgradeInput } from "../groups/global" + +const log = Log.create({ service: "server" }) + +function eventData(data: unknown): Sse.Event { + return { + _tag: "Event", + event: "message", + id: undefined, + data: JSON.stringify(data), + } +} + +function parseBody(body: string) { + try { + return JSON.parse(body || "{}") as unknown + } catch { + return undefined + } +} + +function eventResponse() { + log.info("global event connected") + const events = Stream.callback((queue) => { + const handler = (event: GlobalBusEvent) => Queue.offerUnsafe(queue, event) + return Effect.acquireRelease( + Effect.sync(() => GlobalBus.on("event", handler)), + () => Effect.sync(() => GlobalBus.off("event", handler)), + ) + }) + const heartbeat = Stream.tick("10 seconds").pipe( + Stream.drop(1), + Stream.map(() => ({ payload: { type: "server.heartbeat", properties: {} } })), + ) + + return HttpServerResponse.stream( + Stream.make({ payload: { type: "server.connected", properties: {} } }).pipe( + Stream.concat(events.pipe(Stream.merge(heartbeat, { haltStrategy: "left" }))), + Stream.map(eventData), + Stream.pipeThroughChannel(Sse.encode()), + Stream.encodeText, + Stream.ensuring(Effect.sync(() => log.info("global event disconnected"))), + ), + { + contentType: "text/event-stream", + headers: { + "Cache-Control": "no-cache, no-transform", + "X-Accel-Buffering": "no", + "X-Content-Type-Options": "nosniff", + }, + }, + ) +} + +export const globalHandlers = HttpApiBuilder.group(RootHttpApi, "global", (handlers) => + Effect.gen(function* () { + const config = yield* Config.Service + const installation = yield* Installation.Service + + const health = Effect.fn("GlobalHttpApi.health")(function* () { + return { healthy: true as const, version: InstallationVersion } + }) + + const event = Effect.fn("GlobalHttpApi.event")(function* () { + return eventResponse() + }) + + const configGet = Effect.fn("GlobalHttpApi.configGet")(function* () { + return yield* config.getGlobal() + }) + + const configUpdate = Effect.fn("GlobalHttpApi.configUpdate")(function* (ctx) { + return yield* config.updateGlobal(ctx.payload) + }) + + const dispose = Effect.fn("GlobalHttpApi.dispose")(function* () { + yield* Effect.promise(() => Instance.disposeAll()) + GlobalBus.emit("event", { + directory: "global", + payload: { type: "global.disposed", properties: {} }, + }) + return true + }) + + const upgrade = Effect.fn("GlobalHttpApi.upgrade")(function* (ctx: { payload: typeof GlobalUpgradeInput.Type }) { + const method = yield* installation.method() + if (method === "unknown") { + return { + status: 400, + body: { success: false as const, error: "Unknown installation method" }, + } + } + const target = ctx.payload.target || (yield* installation.latest(method)) + const result = yield* installation.upgrade(method, target).pipe( + Effect.as({ status: 200, body: { success: true as const, version: target } }), + Effect.catch((err) => + Effect.succeed({ + status: 500, + body: { + success: false as const, + error: err instanceof Error ? err.message : String(err), + }, + }), + ), + ) + if (!result.body.success) return result + GlobalBus.emit("event", { + directory: "global", + payload: { + type: Installation.Event.Updated.type, + properties: { version: target }, + }, + }) + return result + }) + + const upgradeRaw = Effect.fn("GlobalHttpApi.upgradeRaw")(function* (ctx: { + request: HttpServerRequest.HttpServerRequest + }) { + const body = yield* Effect.orDie(ctx.request.text) + const json = parseBody(body) + if (json === undefined) { + return HttpServerResponse.jsonUnsafe({ success: false, error: "Invalid request body" }, { status: 400 }) + } + const payload = yield* Schema.decodeUnknownEffect(GlobalUpgradeInput)(json).pipe( + Effect.map((payload) => ({ valid: true as const, payload })), + Effect.catch(() => Effect.succeed({ valid: false as const })), + ) + if (!payload.valid) { + return HttpServerResponse.jsonUnsafe({ success: false, error: "Invalid request body" }, { status: 400 }) + } + const result = yield* upgrade({ payload: payload.payload }) + return HttpServerResponse.jsonUnsafe(result.body, { status: result.status }) + }) + + return handlers + .handle("health", health) + .handleRaw("event", event) + .handle("configGet", configGet) + .handle("configUpdate", configUpdate) + .handle("dispose", dispose) + .handleRaw("upgrade", upgradeRaw) + }), +) diff --git a/packages/opencode/src/server/routes/instance/httpapi/handlers/instance.ts b/packages/opencode/src/server/routes/instance/httpapi/handlers/instance.ts new file mode 100644 index 0000000000..b6f3860652 --- /dev/null +++ b/packages/opencode/src/server/routes/instance/httpapi/handlers/instance.ts @@ -0,0 +1,79 @@ +import { Agent } from "@/agent/agent" +import { Command } from "@/command" +import * as InstanceState from "@/effect/instance-state" +import { Format } from "@/format" +import { Global } from "@opencode-ai/core/global" +import { LSP } from "@/lsp/lsp" +import { Vcs } from "@/project/vcs" +import { Skill } from "@/skill" +import { Effect } from "effect" +import { HttpApiBuilder } from "effect/unstable/httpapi" +import { InstanceHttpApi } from "../api" +import { markInstanceForDisposal } from "../lifecycle" + +export const instanceHandlers = HttpApiBuilder.group(InstanceHttpApi, "instance", (handlers) => + Effect.gen(function* () { + const agent = yield* Agent.Service + const command = yield* Command.Service + const format = yield* Format.Service + const lsp = yield* LSP.Service + const skill = yield* Skill.Service + const vcs = yield* Vcs.Service + + const dispose = Effect.fn("InstanceHttpApi.dispose")(function* () { + yield* markInstanceForDisposal(yield* InstanceState.context) + return true + }) + + const getPath = Effect.fn("InstanceHttpApi.path")(function* () { + const ctx = yield* InstanceState.context + return { + home: Global.Path.home, + state: Global.Path.state, + config: Global.Path.config, + worktree: ctx.worktree, + directory: ctx.directory, + } + }) + + const getVcs = Effect.fn("InstanceHttpApi.vcs")(function* () { + const [branch, default_branch] = yield* Effect.all([vcs.branch(), vcs.defaultBranch()], { concurrency: 2 }) + return { branch, default_branch } + }) + + const getVcsDiff = Effect.fn("InstanceHttpApi.vcsDiff")(function* (ctx: { query: { mode: Vcs.Mode } }) { + return yield* vcs.diff(ctx.query.mode) + }) + + const getCommand = Effect.fn("InstanceHttpApi.command")(function* () { + return yield* command.list() + }) + + const getAgent = Effect.fn("InstanceHttpApi.agent")(function* () { + return yield* agent.list() + }) + + const getSkill = Effect.fn("InstanceHttpApi.skill")(function* () { + return yield* skill.all() + }) + + const getLsp = Effect.fn("InstanceHttpApi.lsp")(function* () { + return yield* lsp.status() + }) + + const getFormatter = Effect.fn("InstanceHttpApi.formatter")(function* () { + return yield* format.status() + }) + + return handlers + .handle("dispose", dispose) + .handle("path", getPath) + .handle("vcs", getVcs) + .handle("vcsDiff", getVcsDiff) + .handle("command", getCommand) + .handle("agent", getAgent) + .handle("skill", getSkill) + .handle("lsp", getLsp) + .handle("formatter", getFormatter) + }), +) diff --git a/packages/opencode/src/server/routes/instance/httpapi/handlers/mcp.ts b/packages/opencode/src/server/routes/instance/httpapi/handlers/mcp.ts new file mode 100644 index 0000000000..b4d27d91de --- /dev/null +++ b/packages/opencode/src/server/routes/instance/httpapi/handlers/mcp.ts @@ -0,0 +1,68 @@ +import { MCP } from "@/mcp" +import { Effect, Schema } from "effect" +import { HttpApiBuilder, HttpApiError } from "effect/unstable/httpapi" +import { InstanceHttpApi } from "../api" +import { AddPayload, AuthCallbackPayload, StatusMap, UnsupportedOAuthError } from "../groups/mcp" + +export const mcpHandlers = HttpApiBuilder.group(InstanceHttpApi, "mcp", (handlers) => + Effect.gen(function* () { + const mcp = yield* MCP.Service + + const status = Effect.fn("McpHttpApi.status")(function* () { + return yield* mcp.status() + }) + + const add = Effect.fn("McpHttpApi.add")(function* (ctx: { payload: typeof AddPayload.Type }) { + const result = (yield* mcp.add(ctx.payload.name, ctx.payload.config)).status + return yield* Schema.decodeUnknownEffect(StatusMap)( + "status" in result ? { [ctx.payload.name]: result } : result, + ).pipe(Effect.mapError(() => new HttpApiError.BadRequest({}))) + }) + + const authStart = Effect.fn("McpHttpApi.authStart")(function* (ctx: { params: { name: string } }) { + if (!(yield* mcp.supportsOAuth(ctx.params.name))) { + return yield* new UnsupportedOAuthError({ error: `MCP server ${ctx.params.name} does not support OAuth` }) + } + return yield* mcp.startAuth(ctx.params.name) + }) + + const authCallback = Effect.fn("McpHttpApi.authCallback")(function* (ctx: { + params: { name: string } + payload: typeof AuthCallbackPayload.Type + }) { + return yield* mcp.finishAuth(ctx.params.name, ctx.payload.code) + }) + + const authAuthenticate = Effect.fn("McpHttpApi.authAuthenticate")(function* (ctx: { params: { name: string } }) { + if (!(yield* mcp.supportsOAuth(ctx.params.name))) { + return yield* new UnsupportedOAuthError({ error: `MCP server ${ctx.params.name} does not support OAuth` }) + } + return yield* mcp.authenticate(ctx.params.name) + }) + + const authRemove = Effect.fn("McpHttpApi.authRemove")(function* (ctx: { params: { name: string } }) { + yield* mcp.removeAuth(ctx.params.name) + return { success: true as const } + }) + + const connect = Effect.fn("McpHttpApi.connect")(function* (ctx: { params: { name: string } }) { + yield* mcp.connect(ctx.params.name) + return true + }) + + const disconnect = Effect.fn("McpHttpApi.disconnect")(function* (ctx: { params: { name: string } }) { + yield* mcp.disconnect(ctx.params.name) + return true + }) + + return handlers + .handle("status", status) + .handle("add", add) + .handle("authStart", authStart) + .handle("authCallback", authCallback) + .handle("authAuthenticate", authAuthenticate) + .handle("authRemove", authRemove) + .handle("connect", connect) + .handle("disconnect", disconnect) + }), +) diff --git a/packages/opencode/src/server/routes/instance/httpapi/handlers/permission.ts b/packages/opencode/src/server/routes/instance/httpapi/handlers/permission.ts new file mode 100644 index 0000000000..a5d6dab895 --- /dev/null +++ b/packages/opencode/src/server/routes/instance/httpapi/handlers/permission.ts @@ -0,0 +1,29 @@ +import { Permission } from "@/permission" +import { PermissionID } from "@/permission/schema" +import { Effect } from "effect" +import { HttpApiBuilder } from "effect/unstable/httpapi" +import { InstanceHttpApi } from "../api" + +export const permissionHandlers = HttpApiBuilder.group(InstanceHttpApi, "permission", (handlers) => + Effect.gen(function* () { + const svc = yield* Permission.Service + + const list = Effect.fn("PermissionHttpApi.list")(function* () { + return yield* svc.list() + }) + + const reply = Effect.fn("PermissionHttpApi.reply")(function* (ctx: { + params: { requestID: PermissionID } + payload: Permission.ReplyBody + }) { + yield* svc.reply({ + requestID: ctx.params.requestID, + reply: ctx.payload.reply, + message: ctx.payload.message, + }) + return true + }) + + return handlers.handle("list", list).handle("reply", reply) + }), +) diff --git a/packages/opencode/src/server/routes/instance/httpapi/handlers/project.ts b/packages/opencode/src/server/routes/instance/httpapi/handlers/project.ts new file mode 100644 index 0000000000..20a5ddfb09 --- /dev/null +++ b/packages/opencode/src/server/routes/instance/httpapi/handlers/project.ts @@ -0,0 +1,46 @@ +import { AppRuntime } from "@/effect/app-runtime" +import * as InstanceState from "@/effect/instance-state" +import { InstanceBootstrap } from "@/project/bootstrap" +import { Project } from "@/project/project" +import { ProjectID } from "@/project/schema" +import { Effect } from "effect" +import { HttpApiBuilder } from "effect/unstable/httpapi" +import { InstanceHttpApi } from "../api" +import { markInstanceForReload } from "../lifecycle" + +export const projectHandlers = HttpApiBuilder.group(InstanceHttpApi, "project", (handlers) => + Effect.gen(function* () { + const svc = yield* Project.Service + + const list = Effect.fn("ProjectHttpApi.list")(function* () { + return yield* svc.list() + }) + + const current = Effect.fn("ProjectHttpApi.current")(function* () { + return (yield* InstanceState.context).project + }) + + const initGit = Effect.fn("ProjectHttpApi.initGit")(function* () { + const ctx = yield* InstanceState.context + const next = yield* svc.initGit({ directory: ctx.directory, project: ctx.project }) + if (next.id === ctx.project.id && next.vcs === ctx.project.vcs && next.worktree === ctx.project.worktree) + return next + yield* markInstanceForReload(ctx, { + directory: ctx.directory, + worktree: ctx.directory, + project: next, + init: () => AppRuntime.runPromise(InstanceBootstrap), + }) + return next + }) + + const update = Effect.fn("ProjectHttpApi.update")(function* (ctx: { + params: { projectID: ProjectID } + payload: Project.UpdatePayload + }) { + return yield* svc.update({ ...ctx.payload, projectID: ctx.params.projectID }) + }) + + return handlers.handle("list", list).handle("current", current).handle("initGit", initGit).handle("update", update) + }), +) diff --git a/packages/opencode/src/server/routes/instance/httpapi/handlers/provider.ts b/packages/opencode/src/server/routes/instance/httpapi/handlers/provider.ts new file mode 100644 index 0000000000..f343829d6a --- /dev/null +++ b/packages/opencode/src/server/routes/instance/httpapi/handlers/provider.ts @@ -0,0 +1,89 @@ +import { ProviderAuth } from "@/provider/auth" +import { Config } from "@/config/config" +import { ModelsDev } from "@/provider/models" +import { Provider } from "@/provider/provider" +import { ProviderID } from "@/provider/schema" +import { mapValues } from "remeda" +import { Effect, Schema } from "effect" +import { HttpServerRequest, HttpServerResponse } from "effect/unstable/http" +import { HttpApiBuilder, HttpApiError } from "effect/unstable/httpapi" +import { InstanceHttpApi } from "../api" + +export const providerHandlers = HttpApiBuilder.group(InstanceHttpApi, "provider", (handlers) => + Effect.gen(function* () { + const cfg = yield* Config.Service + const provider = yield* Provider.Service + const svc = yield* ProviderAuth.Service + + const list = Effect.fn("ProviderHttpApi.list")(function* () { + const config = yield* cfg.get() + const all = yield* Effect.promise(() => ModelsDev.get()) + const disabled = new Set(config.disabled_providers ?? []) + const enabled = config.enabled_providers ? new Set(config.enabled_providers) : undefined + const filtered: Record = {} + for (const [key, value] of Object.entries(all)) { + if ((enabled ? enabled.has(key) : true) && !disabled.has(key)) filtered[key] = value + } + const connected = yield* provider.list() + const providers = Object.assign( + mapValues(filtered, (item) => Provider.fromModelsDevProvider(item)), + connected, + ) + return { + all: Object.values(providers), + default: Provider.defaultModelIDs(providers), + connected: Object.keys(connected), + } + }) + + const auth = Effect.fn("ProviderHttpApi.auth")(function* () { + return yield* svc.methods() + }) + + const authorize = Effect.fn("ProviderHttpApi.authorize")(function* (ctx: { + params: { providerID: ProviderID } + payload: ProviderAuth.AuthorizeInput + }) { + return yield* svc + .authorize({ + providerID: ctx.params.providerID, + method: ctx.payload.method, + inputs: ctx.payload.inputs, + }) + .pipe(Effect.catch(() => Effect.fail(new HttpApiError.BadRequest({})))) + }) + + const authorizeRaw = Effect.fn("ProviderHttpApi.authorizeRaw")(function* (ctx: { + params: { providerID: ProviderID } + request: HttpServerRequest.HttpServerRequest + }) { + const body = yield* Effect.orDie(ctx.request.text) + const payload = yield* Schema.decodeUnknownEffect(Schema.fromJsonString(ProviderAuth.AuthorizeInput))(body).pipe( + Effect.mapError(() => new HttpApiError.BadRequest({})), + ) + const result = yield* authorize({ params: ctx.params, payload }) + if (result === undefined) return HttpServerResponse.empty({ status: 200 }) + return HttpServerResponse.jsonUnsafe(result) + }) + + const callback = Effect.fn("ProviderHttpApi.callback")(function* (ctx: { + params: { providerID: ProviderID } + payload: ProviderAuth.CallbackInput + }) { + yield* svc + .callback({ + providerID: ctx.params.providerID, + method: ctx.payload.method, + code: ctx.payload.code, + }) + .pipe(Effect.catch(() => Effect.fail(new HttpApiError.BadRequest({})))) + return true + }) + + return handlers + .handle("list", list) + .handle("auth", auth) + .handleRaw("authorize", authorizeRaw) + .handle("callback", callback) + }), +) diff --git a/packages/opencode/src/server/routes/instance/httpapi/handlers/pty.ts b/packages/opencode/src/server/routes/instance/httpapi/handlers/pty.ts new file mode 100644 index 0000000000..f2f17d4714 --- /dev/null +++ b/packages/opencode/src/server/routes/instance/httpapi/handlers/pty.ts @@ -0,0 +1,118 @@ +import { EffectBridge } from "@/effect/bridge" +import { Pty } from "@/pty" +import { PtyID } from "@/pty/schema" +import { Shell } from "@/shell/shell" +import { Effect } from "effect" +import { HttpRouter, HttpServerRequest, HttpServerResponse } from "effect/unstable/http" +import { HttpApiBuilder, HttpApiError } from "effect/unstable/httpapi" +import * as Socket from "effect/unstable/socket/Socket" +import { InstanceHttpApi } from "../api" +import { CursorQuery, Params, PtyPaths } from "../groups/pty" + +export const ptyHandlers = HttpApiBuilder.group(InstanceHttpApi, "pty", (handlers) => + Effect.gen(function* () { + const pty = yield* Pty.Service + + const shells = Effect.fn("PtyHttpApi.shells")(function* () { + return yield* Effect.promise(() => Shell.list()) + }) + + const list = Effect.fn("PtyHttpApi.list")(function* () { + return yield* pty.list() + }) + + const create = Effect.fn("PtyHttpApi.create")(function* (ctx: { payload: typeof Pty.CreateInput.Type }) { + const bridge = yield* EffectBridge.make() + return yield* Effect.promise(() => + bridge.promise( + pty.create({ + ...ctx.payload, + args: ctx.payload.args ? [...ctx.payload.args] : undefined, + env: ctx.payload.env ? { ...ctx.payload.env } : undefined, + }), + ), + ) + }) + + const get = Effect.fn("PtyHttpApi.get")(function* (ctx: { params: { ptyID: PtyID } }) { + const info = yield* pty.get(ctx.params.ptyID) + if (!info) return yield* new HttpApiError.NotFound({}) + return info + }) + + const update = Effect.fn("PtyHttpApi.update")(function* (ctx: { + params: { ptyID: PtyID } + payload: typeof Pty.UpdateInput.Type + }) { + const info = yield* pty.update(ctx.params.ptyID, { + ...ctx.payload, + size: ctx.payload.size ? { ...ctx.payload.size } : undefined, + }) + if (!info) return yield* new HttpApiError.NotFound({}) + return info + }) + + const remove = Effect.fn("PtyHttpApi.remove")(function* (ctx: { params: { ptyID: PtyID } }) { + yield* pty.remove(ctx.params.ptyID) + return true + }) + + return handlers + .handle("shells", shells) + .handle("list", list) + .handle("create", create) + .handle("get", get) + .handle("update", update) + .handle("remove", remove) + }), +) + +export const ptyConnectRoute = HttpRouter.add( + "GET", + PtyPaths.connect, + Effect.gen(function* () { + const pty = yield* Pty.Service + const params = yield* HttpRouter.schemaPathParams(Params) + if (!(yield* pty.get(params.ptyID))) return HttpServerResponse.empty({ status: 404 }) + + const query = yield* HttpServerRequest.schemaSearchParams(CursorQuery) + const parsedCursor = query.cursor === undefined ? undefined : Number(query.cursor) + const cursor = + parsedCursor !== undefined && Number.isSafeInteger(parsedCursor) && parsedCursor >= -1 ? parsedCursor : undefined + const socket = yield* Effect.orDie((yield* HttpServerRequest.HttpServerRequest).upgrade) + const write = yield* socket.writer + let closed = false + const adapter = { + get readyState() { + return closed ? 3 : 1 + }, + send: (data: string | Uint8Array | ArrayBuffer) => { + if (closed) return + Effect.runFork(write(data instanceof ArrayBuffer ? new Uint8Array(data) : data).pipe(Effect.catch(() => Effect.void))) + }, + close: (code?: number, reason?: string) => { + if (closed) return + closed = true + Effect.runFork(write(new Socket.CloseEvent(code, reason)).pipe(Effect.catch(() => Effect.void))) + }, + } + const handler = yield* pty.connect(params.ptyID, adapter, cursor) + if (!handler) return HttpServerResponse.empty() + + yield* socket + .runRaw((message) => { + handler.onMessage(typeof message === "string" ? message : message.slice().buffer) + }) + .pipe( + Effect.catchReason("SocketError", "SocketCloseError", () => Effect.void), + Effect.ensuring( + Effect.sync(() => { + closed = true + handler.onClose() + }), + ), + Effect.orDie, + ) + return HttpServerResponse.empty() + }).pipe(Effect.provide(Pty.defaultLayer)), +) diff --git a/packages/opencode/src/server/routes/instance/httpapi/handlers/question.ts b/packages/opencode/src/server/routes/instance/httpapi/handlers/question.ts new file mode 100644 index 0000000000..53ca568cf5 --- /dev/null +++ b/packages/opencode/src/server/routes/instance/httpapi/handlers/question.ts @@ -0,0 +1,33 @@ +import { Question } from "@/question" +import { QuestionID } from "@/question/schema" +import { Effect } from "effect" +import { HttpApiBuilder } from "effect/unstable/httpapi" +import { InstanceHttpApi } from "../api" + +export const questionHandlers = HttpApiBuilder.group(InstanceHttpApi, "question", (handlers) => + Effect.gen(function* () { + const svc = yield* Question.Service + + const list = Effect.fn("QuestionHttpApi.list")(function* () { + return yield* svc.list() + }) + + const reply = Effect.fn("QuestionHttpApi.reply")(function* (ctx: { + params: { requestID: QuestionID } + payload: Question.Reply + }) { + yield* svc.reply({ + requestID: ctx.params.requestID, + answers: ctx.payload.answers, + }) + return true + }) + + const reject = Effect.fn("QuestionHttpApi.reject")(function* (ctx: { params: { requestID: QuestionID } }) { + yield* svc.reject(ctx.params.requestID) + return true + }) + + return handlers.handle("list", list).handle("reply", reply).handle("reject", reject) + }), +) diff --git a/packages/opencode/src/server/routes/instance/httpapi/session.ts b/packages/opencode/src/server/routes/instance/httpapi/handlers/session.ts similarity index 51% rename from packages/opencode/src/server/routes/instance/httpapi/session.ts rename to packages/opencode/src/server/routes/instance/httpapi/handlers/session.ts index 6ea19f19e4..d6264b6050 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/session.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/handlers/session.ts @@ -6,7 +6,6 @@ import { Command } from "@/command" import { Permission } from "@/permission" import { PermissionID } from "@/permission/schema" import { Instance } from "@/project/instance" -import { ModelID, ProviderID } from "@/provider/schema" import { SessionShare } from "@/share/session" import { Session } from "@/session/session" import { SessionCompaction } from "@/session/compaction" @@ -18,422 +17,27 @@ import { SessionStatus } from "@/session/status" import { SessionSummary } from "@/session/summary" import { Todo } from "@/session/todo" import { MessageID, PartID, SessionID } from "@/session/schema" -import { Snapshot } from "@/snapshot" +import { NotFoundError } from "@/storage/storage" import * as Log from "@opencode-ai/core/util/log" import { NamedError } from "@opencode-ai/core/util/error" -import { Effect, Schema, SchemaGetter, Struct } from "effect" +import { Effect, Schema } from "effect" import * as Stream from "effect/Stream" import { HttpServerRequest, HttpServerResponse } from "effect/unstable/http" -import { - HttpApi, - HttpApiBuilder, - HttpApiEndpoint, - HttpApiError, - HttpApiGroup, - HttpApiSchema, - OpenApi, -} from "effect/unstable/httpapi" -import { Authorization } from "./auth" +import { HttpApiBuilder, HttpApiError, HttpApiSchema } from "effect/unstable/httpapi" +import { InstanceHttpApi } from "../api" +import { CommandPayload, DiffQuery, ForkPayload, InitPayload, ListQuery, MessagesQuery, PermissionResponsePayload, PromptPayload, RevertPayload, ShellPayload, SummarizePayload, UpdatePayload } from "../groups/session" const log = Log.create({ service: "server" }) -const root = "/session" -const QueryBoolean = Schema.Literals(["true", "false"]).pipe( - Schema.decodeTo(Schema.Boolean, { - decode: SchemaGetter.transform((value) => value === "true"), - encode: SchemaGetter.transform((value) => (value ? "true" : "false")), - }), -) -const ListQuery = Schema.Struct({ - directory: Schema.optional(Schema.String), - scope: Schema.optional(Schema.Literals(["project"])), - path: Schema.optional(Schema.String), - roots: Schema.optional(QueryBoolean), - start: Schema.optional(Schema.NumberFromString), - search: Schema.optional(Schema.String), - limit: Schema.optional(Schema.NumberFromString), -}) -const DiffQuery = Schema.Struct(Struct.omit(SessionSummary.DiffInput.fields, ["sessionID"])) -const MessagesQuery = Schema.Struct({ - limit: Schema.optional(Schema.NumberFromString.check(Schema.isInt(), Schema.isGreaterThanOrEqualTo(0))), - before: Schema.optional(Schema.String), -}) -const StatusMap = Schema.Record(Schema.String, SessionStatus.Info) -const UpdatePayload = Schema.Struct({ - title: Schema.optional(Schema.String), - permission: Schema.optional(Permission.Ruleset), - time: Schema.optional( - Schema.Struct({ - archived: Schema.optional(Schema.Number), - }), - ), -}).annotate({ identifier: "SessionUpdateInput" }) -const ForkPayload = Schema.Struct(Struct.omit(Session.ForkInput.fields, ["sessionID"])).annotate({ - identifier: "SessionForkInput", -}) -const InitPayload = Schema.Struct({ - modelID: ModelID, - providerID: ProviderID, - messageID: MessageID, -}).annotate({ identifier: "SessionInitInput" }) -const SummarizePayload = Schema.Struct({ - providerID: ProviderID, - modelID: ModelID, - auto: Schema.optional(Schema.Boolean), -}).annotate({ identifier: "SessionSummarizeInput" }) -const PromptPayload = Schema.Struct(Struct.omit(SessionPrompt.PromptInput.fields, ["sessionID"])).annotate({ - identifier: "SessionPromptInput", -}) -const CommandPayload = Schema.Struct(Struct.omit(SessionPrompt.CommandInput.fields, ["sessionID"])).annotate({ - identifier: "SessionCommandInput", -}) -const ShellPayload = Schema.Struct(Struct.omit(SessionPrompt.ShellInput.fields, ["sessionID"])).annotate({ - identifier: "SessionShellInput", -}) -const RevertPayload = Schema.Struct(Struct.omit(SessionRevert.RevertInput.fields, ["sessionID"])).annotate({ - identifier: "SessionRevertInput", -}) -const PermissionResponsePayload = Schema.Struct({ - response: Permission.Reply, -}).annotate({ identifier: "SessionPermissionResponseInput" }) -export const SessionPaths = { - list: root, - status: `${root}/status`, - get: `${root}/:sessionID`, - children: `${root}/:sessionID/children`, - todo: `${root}/:sessionID/todo`, - diff: `${root}/:sessionID/diff`, - messages: `${root}/:sessionID/message`, - message: `${root}/:sessionID/message/:messageID`, - create: root, - remove: `${root}/:sessionID`, - update: `${root}/:sessionID`, - fork: `${root}/:sessionID/fork`, - abort: `${root}/:sessionID/abort`, - share: `${root}/:sessionID/share`, - init: `${root}/:sessionID/init`, - summarize: `${root}/:sessionID/summarize`, - prompt: `${root}/:sessionID/message`, - promptAsync: `${root}/:sessionID/prompt_async`, - command: `${root}/:sessionID/command`, - shell: `${root}/:sessionID/shell`, - revert: `${root}/:sessionID/revert`, - unrevert: `${root}/:sessionID/unrevert`, - permissions: `${root}/:sessionID/permissions/:permissionID`, - deleteMessage: `${root}/:sessionID/message/:messageID`, - deletePart: `${root}/:sessionID/message/:messageID/part/:partID`, - updatePart: `${root}/:sessionID/message/:messageID/part/:partID`, -} as const - -export const SessionApi = HttpApi.make("session") - .add( - HttpApiGroup.make("session") - .add( - HttpApiEndpoint.get("list", SessionPaths.list, { - query: ListQuery, - success: Schema.Array(Session.Info), - }).annotateMerge( - OpenApi.annotations({ - identifier: "session.list", - summary: "List sessions", - description: "Get a list of all OpenCode sessions, sorted by most recently updated.", - }), - ), - HttpApiEndpoint.get("status", SessionPaths.status, { - success: StatusMap, - }).annotateMerge( - OpenApi.annotations({ - identifier: "session.status", - summary: "Get session status", - description: "Retrieve the current status of all sessions, including active, idle, and completed states.", - }), - ), - HttpApiEndpoint.get("get", SessionPaths.get, { - params: { sessionID: SessionID }, - success: Session.Info, - }).annotateMerge( - OpenApi.annotations({ - identifier: "session.get", - summary: "Get session", - description: "Retrieve detailed information about a specific OpenCode session.", - }), - ), - HttpApiEndpoint.get("children", SessionPaths.children, { - params: { sessionID: SessionID }, - success: Schema.Array(Session.Info), - }).annotateMerge( - OpenApi.annotations({ - identifier: "session.children", - summary: "Get session children", - description: "Retrieve all child sessions that were forked from the specified parent session.", - }), - ), - HttpApiEndpoint.get("todo", SessionPaths.todo, { - params: { sessionID: SessionID }, - success: Schema.Array(Todo.Info), - }).annotateMerge( - OpenApi.annotations({ - identifier: "session.todo", - summary: "Get session todos", - description: "Retrieve the todo list associated with a specific session, showing tasks and action items.", - }), - ), - HttpApiEndpoint.get("diff", SessionPaths.diff, { - params: { sessionID: SessionID }, - query: DiffQuery, - success: Schema.Array(Snapshot.FileDiff), - }).annotateMerge( - OpenApi.annotations({ - identifier: "session.diff", - summary: "Get message diff", - description: "Get the file changes (diff) that resulted from a specific user message in the session.", - }), - ), - HttpApiEndpoint.get("messages", SessionPaths.messages, { - params: { sessionID: SessionID }, - query: MessagesQuery, - success: Schema.Array(MessageV2.WithParts), - error: HttpApiError.BadRequest, - }).annotateMerge( - OpenApi.annotations({ - identifier: "session.messages", - summary: "Get session messages", - description: "Retrieve all messages in a session, including user prompts and AI responses.", - }), - ), - HttpApiEndpoint.get("message", SessionPaths.message, { - params: { sessionID: SessionID, messageID: MessageID }, - success: MessageV2.WithParts, - }).annotateMerge( - OpenApi.annotations({ - identifier: "session.message", - summary: "Get message", - description: "Retrieve a specific message from a session by its message ID.", - }), - ), - HttpApiEndpoint.post("create", SessionPaths.create, { - payload: [HttpApiSchema.NoContent, Session.CreateInput], - success: Session.Info, - error: HttpApiError.BadRequest, - }).annotateMerge( - OpenApi.annotations({ - identifier: "session.create", - summary: "Create session", - description: "Create a new OpenCode session for interacting with AI assistants and managing conversations.", - }), - ), - HttpApiEndpoint.delete("remove", SessionPaths.remove, { - params: { sessionID: SessionID }, - success: Schema.Boolean, - }).annotateMerge( - OpenApi.annotations({ - identifier: "session.delete", - summary: "Delete session", - description: "Delete a session and permanently remove all associated data, including messages and history.", - }), - ), - HttpApiEndpoint.patch("update", SessionPaths.update, { - params: { sessionID: SessionID }, - payload: UpdatePayload, - success: Session.Info, - }).annotateMerge( - OpenApi.annotations({ - identifier: "session.update", - summary: "Update session", - description: "Update properties of an existing session, such as title or other metadata.", - }), - ), - HttpApiEndpoint.post("fork", SessionPaths.fork, { - params: { sessionID: SessionID }, - payload: ForkPayload, - success: Session.Info, - }).annotateMerge( - OpenApi.annotations({ - identifier: "session.fork", - summary: "Fork session", - description: "Create a new session by forking an existing session at a specific message point.", - }), - ), - HttpApiEndpoint.post("abort", SessionPaths.abort, { - params: { sessionID: SessionID }, - success: Schema.Boolean, - }).annotateMerge( - OpenApi.annotations({ - identifier: "session.abort", - summary: "Abort session", - description: "Abort an active session and stop any ongoing AI processing or command execution.", - }), - ), - HttpApiEndpoint.post("init", SessionPaths.init, { - params: { sessionID: SessionID }, - payload: InitPayload, - success: Schema.Boolean, - }).annotateMerge( - OpenApi.annotations({ - identifier: "session.init", - summary: "Initialize session", - description: - "Analyze the current application and create an AGENTS.md file with project-specific agent configurations.", - }), - ), - HttpApiEndpoint.post("share", SessionPaths.share, { - params: { sessionID: SessionID }, - success: Session.Info, - }).annotateMerge( - OpenApi.annotations({ - identifier: "session.share", - summary: "Share session", - description: "Create a shareable link for a session, allowing others to view the conversation.", - }), - ), - HttpApiEndpoint.delete("unshare", SessionPaths.share, { - params: { sessionID: SessionID }, - success: Session.Info, - }).annotateMerge( - OpenApi.annotations({ - identifier: "session.unshare", - summary: "Unshare session", - description: "Remove the shareable link for a session, making it private again.", - }), - ), - HttpApiEndpoint.post("summarize", SessionPaths.summarize, { - params: { sessionID: SessionID }, - payload: SummarizePayload, - success: Schema.Boolean, - }).annotateMerge( - OpenApi.annotations({ - identifier: "session.summarize", - summary: "Summarize session", - description: "Generate a concise summary of the session using AI compaction to preserve key information.", - }), - ), - HttpApiEndpoint.post("prompt", SessionPaths.prompt, { - params: { sessionID: SessionID }, - payload: PromptPayload, - success: MessageV2.WithParts, - }).annotateMerge( - OpenApi.annotations({ - identifier: "session.prompt", - summary: "Send message", - description: "Create and send a new message to a session, streaming the AI response.", - }), - ), - HttpApiEndpoint.post("promptAsync", SessionPaths.promptAsync, { - params: { sessionID: SessionID }, - payload: PromptPayload, - success: HttpApiSchema.NoContent, - }).annotateMerge( - OpenApi.annotations({ - identifier: "session.prompt_async", - summary: "Send async message", - description: - "Create and send a new message to a session asynchronously, starting the session if needed and returning immediately.", - }), - ), - HttpApiEndpoint.post("command", SessionPaths.command, { - params: { sessionID: SessionID }, - payload: CommandPayload, - success: MessageV2.WithParts, - }).annotateMerge( - OpenApi.annotations({ - identifier: "session.command", - summary: "Send command", - description: "Send a new command to a session for execution by the AI assistant.", - }), - ), - HttpApiEndpoint.post("shell", SessionPaths.shell, { - params: { sessionID: SessionID }, - payload: ShellPayload, - success: MessageV2.WithParts, - }).annotateMerge( - OpenApi.annotations({ - identifier: "session.shell", - summary: "Run shell command", - description: "Execute a shell command within the session context and return the AI's response.", - }), - ), - HttpApiEndpoint.post("revert", SessionPaths.revert, { - params: { sessionID: SessionID }, - payload: RevertPayload, - success: Session.Info, - }).annotateMerge( - OpenApi.annotations({ - identifier: "session.revert", - summary: "Revert message", - description: - "Revert a specific message in a session, undoing its effects and restoring the previous state.", - }), - ), - HttpApiEndpoint.post("unrevert", SessionPaths.unrevert, { - params: { sessionID: SessionID }, - success: Session.Info, - }).annotateMerge( - OpenApi.annotations({ - identifier: "session.unrevert", - summary: "Restore reverted messages", - description: "Restore all previously reverted messages in a session.", - }), - ), - HttpApiEndpoint.post("permissionRespond", SessionPaths.permissions, { - params: { sessionID: SessionID, permissionID: PermissionID }, - payload: PermissionResponsePayload, - success: Schema.Boolean, - }).annotateMerge( - OpenApi.annotations({ - identifier: "permission.respond", - summary: "Respond to permission", - description: "Approve or deny a permission request from the AI assistant.", - deprecated: true, - }), - ), - HttpApiEndpoint.delete("deleteMessage", SessionPaths.deleteMessage, { - params: { sessionID: SessionID, messageID: MessageID }, - success: Schema.Boolean, - }).annotateMerge( - OpenApi.annotations({ - identifier: "session.deleteMessage", - summary: "Delete message", - description: - "Permanently delete a specific message and all of its parts from a session without reverting file changes.", - }), - ), - HttpApiEndpoint.delete("deletePart", SessionPaths.deletePart, { - params: { sessionID: SessionID, messageID: MessageID, partID: PartID }, - success: Schema.Boolean, - }).annotateMerge( - OpenApi.annotations({ - identifier: "part.delete", - description: "Delete a part from a message.", - }), - ), - HttpApiEndpoint.patch("updatePart", SessionPaths.updatePart, { - params: { sessionID: SessionID, messageID: MessageID, partID: PartID }, - payload: MessageV2.Part, - success: MessageV2.Part, - }).annotateMerge( - OpenApi.annotations({ - identifier: "part.update", - description: "Update a part in a message.", - }), - ), - ) - .annotateMerge( - OpenApi.annotations({ - title: "session", - description: "Experimental HttpApi session routes.", - }), - ) - .middleware(Authorization), - ) - .annotateMerge( - OpenApi.annotations({ - title: "opencode experimental HttpApi", - version: "0.0.1", - description: "Experimental HttpApi surface for selected instance routes.", - }), +const mapNotFound = (self: Effect.Effect) => + self.pipe( + Effect.catchIf(NotFoundError.isInstance, () => Effect.fail(new HttpApiError.NotFound({}))), + Effect.catchDefect((error) => + NotFoundError.isInstance(error) ? Effect.fail(new HttpApiError.NotFound({})) : Effect.die(error), + ), ) -export const sessionHandlers = HttpApiBuilder.group(SessionApi, "session", (handlers) => +export const sessionHandlers = HttpApiBuilder.group(InstanceHttpApi, "session", (handlers) => Effect.gen(function* () { const session = yield* Session.Service const statusSvc = yield* SessionStatus.Service @@ -462,7 +66,7 @@ export const sessionHandlers = HttpApiBuilder.group(SessionApi, "session", (hand }) const get = Effect.fn("SessionHttpApi.get")(function* (ctx: { params: { sessionID: SessionID } }) { - return yield* session.get(ctx.params.sessionID) + return yield* mapNotFound(session.get(ctx.params.sessionID)) }) const children = Effect.fn("SessionHttpApi.children")(function* (ctx: { params: { sessionID: SessionID } }) { @@ -484,44 +88,47 @@ export const sessionHandlers = HttpApiBuilder.group(SessionApi, "session", (hand params: { sessionID: SessionID } query: typeof MessagesQuery.Type }) { - if (ctx.query.before && ctx.query.limit === undefined) return yield* new HttpApiError.BadRequest({}) - if (ctx.query.before) { - const before = ctx.query.before - yield* Effect.try({ - try: () => MessageV2.cursor.decode(before), - catch: () => new HttpApiError.BadRequest({}), - }) - } - if (ctx.query.limit === undefined || ctx.query.limit === 0) { + return yield* mapNotFound(Effect.gen(function* () { + if (ctx.query.before && ctx.query.limit === undefined) return yield* new HttpApiError.BadRequest({}) + if (ctx.query.before) { + const before = ctx.query.before + yield* Effect.try({ + try: () => MessageV2.cursor.decode(before), + catch: () => new HttpApiError.BadRequest({}), + }) + } + if (ctx.query.limit === undefined || ctx.query.limit === 0) { + yield* session.get(ctx.params.sessionID) + return yield* session.messages({ sessionID: ctx.params.sessionID }) + } + yield* session.get(ctx.params.sessionID) - return yield* session.messages({ sessionID: ctx.params.sessionID }) - } + const page = MessageV2.page({ + sessionID: ctx.params.sessionID, + limit: ctx.query.limit, + before: ctx.query.before, + }) + if (!page.cursor) return page.items - const page = MessageV2.page({ - sessionID: ctx.params.sessionID, - limit: ctx.query.limit, - before: ctx.query.before, - }) - if (!page.cursor) return page.items - - const request = yield* HttpServerRequest.HttpServerRequest - const url = new URL(request.url, "http://localhost") - url.searchParams.set("limit", ctx.query.limit.toString()) - url.searchParams.set("before", page.cursor) - return HttpServerResponse.jsonUnsafe(page.items, { - headers: { - "Access-Control-Expose-Headers": "Link, X-Next-Cursor", - Link: `<${url.toString()}>; rel="next"`, - "X-Next-Cursor": page.cursor, - }, - }) + const request = yield* HttpServerRequest.HttpServerRequest + const url = new URL(request.url, "http://localhost") + url.searchParams.set("limit", ctx.query.limit.toString()) + url.searchParams.set("before", page.cursor) + return HttpServerResponse.jsonUnsafe(page.items, { + headers: { + "Access-Control-Expose-Headers": "Link, X-Next-Cursor", + Link: `<${url.toString()}>; rel="next"`, + "X-Next-Cursor": page.cursor, + }, + }) + })) }) const message = Effect.fn("SessionHttpApi.message")(function* (ctx: { params: { sessionID: SessionID; messageID: MessageID } }) { - return yield* Effect.sync(() => - MessageV2.get({ sessionID: ctx.params.sessionID, messageID: ctx.params.messageID }), + return yield* mapNotFound( + Effect.sync(() => MessageV2.get({ sessionID: ctx.params.sessionID, messageID: ctx.params.messageID })), ) }) diff --git a/packages/opencode/src/server/routes/instance/httpapi/handlers/sync.ts b/packages/opencode/src/server/routes/instance/httpapi/handlers/sync.ts new file mode 100644 index 0000000000..3ae091484f --- /dev/null +++ b/packages/opencode/src/server/routes/instance/httpapi/handlers/sync.ts @@ -0,0 +1,54 @@ +import { startWorkspaceSyncing } from "@/control-plane/workspace" +import * as InstanceState from "@/effect/instance-state" +import { Database } from "@/storage/db" +import { SyncEvent } from "@/sync" +import { EventTable } from "@/sync/event.sql" +import { asc } from "drizzle-orm" +import { and } from "drizzle-orm" +import { eq } from "drizzle-orm" +import { lte } from "drizzle-orm" +import { not } from "drizzle-orm" +import { or } from "drizzle-orm" +import { Effect } from "effect" +import { HttpApiBuilder } from "effect/unstable/httpapi" +import { InstanceHttpApi } from "../api" +import { HistoryPayload, ReplayPayload } from "../groups/sync" + +export const syncHandlers = HttpApiBuilder.group(InstanceHttpApi, "sync", (handlers) => + Effect.gen(function* () { + const start = Effect.fn("SyncHttpApi.start")(function* () { + startWorkspaceSyncing((yield* InstanceState.context).project.id) + return true + }) + + const replay = Effect.fn("SyncHttpApi.replay")(function* (ctx: { payload: typeof ReplayPayload.Type }) { + const events: SyncEvent.SerializedEvent[] = ctx.payload.events.map((event) => ({ + id: event.id, + aggregateID: event.aggregateID, + seq: event.seq, + type: event.type, + data: { ...event.data }, + })) + SyncEvent.replayAll(events) + return { sessionID: events[0].aggregateID } + }) + + const history = Effect.fn("SyncHttpApi.history")(function* (ctx: { payload: typeof HistoryPayload.Type }) { + const exclude = Object.entries(ctx.payload) + return Database.use((db) => + db + .select() + .from(EventTable) + .where( + exclude.length > 0 + ? not(or(...exclude.map(([id, seq]) => and(eq(EventTable.aggregate_id, id), lte(EventTable.seq, seq))))!) + : undefined, + ) + .orderBy(asc(EventTable.seq)) + .all(), + ) + }) + + return handlers.handle("start", start).handle("replay", replay).handle("history", history) + }), +) diff --git a/packages/opencode/src/server/routes/instance/httpapi/handlers/tui.ts b/packages/opencode/src/server/routes/instance/httpapi/handlers/tui.ts new file mode 100644 index 0000000000..cb12ccb7a7 --- /dev/null +++ b/packages/opencode/src/server/routes/instance/httpapi/handlers/tui.ts @@ -0,0 +1,134 @@ +import { Bus } from "@/bus" +import { TuiEvent } from "@/cli/cmd/tui/event" +import { SessionTable } from "@/session/session.sql" +import * as Database from "@/storage/db" +import { eq } from "drizzle-orm" +import { Effect } from "effect" +import { HttpApiBuilder, HttpApiError } from "effect/unstable/httpapi" +import { nextTuiRequest, submitTuiResponse } from "../../tui" +import { InstanceHttpApi } from "../api" +import { CommandPayload, TuiPublishPayload } from "../groups/tui" + +const commandAliases = { + session_new: "session.new", + session_share: "session.share", + session_interrupt: "session.interrupt", + session_compact: "session.compact", + messages_page_up: "session.page.up", + messages_page_down: "session.page.down", + messages_line_up: "session.line.up", + messages_line_down: "session.line.down", + messages_half_page_up: "session.half.page.up", + messages_half_page_down: "session.half.page.down", + messages_first: "session.first", + messages_last: "session.last", + agent_cycle: "agent.cycle", +} as const + +export const tuiHandlers = HttpApiBuilder.group(InstanceHttpApi, "tui", (handlers) => + Effect.gen(function* () { + const bus = yield* Bus.Service + const publishCommand = (command: typeof TuiEvent.CommandExecute.properties.Type.command) => + bus.publish(TuiEvent.CommandExecute, { command }) + + const appendPrompt = Effect.fn("TuiHttpApi.appendPrompt")(function* (ctx: { + payload: typeof TuiEvent.PromptAppend.properties.Type + }) { + yield* bus.publish(TuiEvent.PromptAppend, ctx.payload) + return true + }) + + const openHelp = Effect.fn("TuiHttpApi.openHelp")(function* () { + yield* publishCommand("help.show") + return true + }) + + const openSessions = Effect.fn("TuiHttpApi.openSessions")(function* () { + yield* publishCommand("session.list") + return true + }) + + const openThemes = Effect.fn("TuiHttpApi.openThemes")(function* () { + yield* publishCommand("session.list") + return true + }) + + const openModels = Effect.fn("TuiHttpApi.openModels")(function* () { + yield* publishCommand("model.list") + return true + }) + + const submitPrompt = Effect.fn("TuiHttpApi.submitPrompt")(function* () { + yield* publishCommand("prompt.submit") + return true + }) + + const clearPrompt = Effect.fn("TuiHttpApi.clearPrompt")(function* () { + yield* publishCommand("prompt.clear") + return true + }) + + const executeCommand = Effect.fn("TuiHttpApi.executeCommand")(function* (ctx: { + payload: typeof CommandPayload.Type + }) { + yield* publishCommand(commandAliases[ctx.payload.command as keyof typeof commandAliases] ?? ctx.payload.command) + return true + }) + + const showToast = Effect.fn("TuiHttpApi.showToast")(function* (ctx: { + payload: typeof TuiEvent.ToastShow.properties.Type + }) { + yield* bus.publish(TuiEvent.ToastShow, ctx.payload) + return true + }) + + const publish = Effect.fn("TuiHttpApi.publish")(function* (ctx: { payload: typeof TuiPublishPayload.Type }) { + if (ctx.payload.type === TuiEvent.PromptAppend.type) + yield* bus.publish(TuiEvent.PromptAppend, ctx.payload.properties) + if (ctx.payload.type === TuiEvent.CommandExecute.type) + yield* bus.publish(TuiEvent.CommandExecute, ctx.payload.properties) + if (ctx.payload.type === TuiEvent.ToastShow.type) yield* bus.publish(TuiEvent.ToastShow, ctx.payload.properties) + if (ctx.payload.type === TuiEvent.SessionSelect.type) + yield* bus.publish(TuiEvent.SessionSelect, ctx.payload.properties) + return true + }) + + const selectSession = Effect.fn("TuiHttpApi.selectSession")(function* (ctx: { + payload: typeof TuiEvent.SessionSelect.properties.Type + }) { + if (!ctx.payload.sessionID.startsWith("ses")) return yield* new HttpApiError.BadRequest({}) + const row = yield* Effect.sync(() => + Database.use((db) => + db.select({ id: SessionTable.id }).from(SessionTable).where(eq(SessionTable.id, ctx.payload.sessionID)).get(), + ), + ) + if (!row) return yield* new HttpApiError.NotFound({}) + yield* bus.publish(TuiEvent.SessionSelect, ctx.payload) + return true + }) + + const controlNext = Effect.fn("TuiHttpApi.controlNext")(function* () { + return yield* Effect.promise(() => nextTuiRequest()) + }) + + const controlResponse = Effect.fn("TuiHttpApi.controlResponse")(function* (ctx: { payload: unknown }) { + submitTuiResponse(ctx.payload) + return true + }) + + return handlers + .handle("appendPrompt", appendPrompt) + .handle("openHelp", openHelp) + .handle("openSessions", openSessions) + .handle("openThemes", openThemes) + .handle("openModels", openModels) + .handle("submitPrompt", submitPrompt) + .handle("clearPrompt", clearPrompt) + .handle("executeCommand", executeCommand) + .handle("showToast", showToast) + .handle("publish", publish) + .handle("selectSession", selectSession) + .handle("controlNext", controlNext) + .handle("controlResponse", controlResponse) + }), +) diff --git a/packages/opencode/src/server/routes/instance/httpapi/handlers/workspace.ts b/packages/opencode/src/server/routes/instance/httpapi/handlers/workspace.ts new file mode 100644 index 0000000000..9413c865d1 --- /dev/null +++ b/packages/opencode/src/server/routes/instance/httpapi/handlers/workspace.ts @@ -0,0 +1,66 @@ +import { listAdaptors } from "@/control-plane/adaptors" +import { Workspace } from "@/control-plane/workspace" +import * as InstanceState from "@/effect/instance-state" +import { Instance } from "@/project/instance" +import { Effect } from "effect" +import { HttpApiBuilder } from "effect/unstable/httpapi" +import { InstanceHttpApi } from "../api" +import { CreatePayload, SessionRestorePayload } from "../groups/workspace" + +export const workspaceHandlers = HttpApiBuilder.group(InstanceHttpApi, "workspace", (handlers) => + Effect.gen(function* () { + const adaptors = Effect.fn("WorkspaceHttpApi.adaptors")(function* () { + const instance = yield* InstanceState.context + return yield* Effect.promise(() => listAdaptors(instance.project.id)) + }) + + const list = Effect.fn("WorkspaceHttpApi.list")(function* () { + return Workspace.list((yield* InstanceState.context).project) + }) + + const create = Effect.fn("WorkspaceHttpApi.create")(function* (ctx: { payload: typeof CreatePayload.Type }) { + const instance = yield* InstanceState.context + return yield* Effect.promise(() => + Instance.restore(instance, () => + Workspace.create({ + ...ctx.payload, + projectID: instance.project.id, + }), + ), + ) + }) + + const status = Effect.fn("WorkspaceHttpApi.status")(function* () { + const ids = new Set(Workspace.list((yield* InstanceState.context).project).map((item) => item.id)) + return Workspace.status().filter((item) => ids.has(item.workspaceID)) + }) + + const remove = Effect.fn("WorkspaceHttpApi.remove")(function* (ctx: { params: { id: Workspace.Info["id"] } }) { + const instance = yield* InstanceState.context + return yield* Effect.promise(() => Instance.restore(instance, () => Workspace.remove(ctx.params.id))) + }) + + const sessionRestore = Effect.fn("WorkspaceHttpApi.sessionRestore")(function* (ctx: { + params: { id: Workspace.Info["id"] } + payload: typeof SessionRestorePayload.Type + }) { + const instance = yield* InstanceState.context + return yield* Effect.promise(() => + Instance.restore(instance, () => + Workspace.sessionRestore({ + workspaceID: ctx.params.id, + sessionID: ctx.payload.sessionID, + }), + ), + ) + }) + + return handlers + .handle("adaptors", adaptors) + .handle("list", list) + .handle("create", create) + .handle("status", status) + .handle("remove", remove) + .handle("sessionRestore", sessionRestore) + }), +) diff --git a/packages/opencode/src/server/routes/instance/httpapi/instance-context.ts b/packages/opencode/src/server/routes/instance/httpapi/instance-context.ts new file mode 100644 index 0000000000..1ad42c5261 --- /dev/null +++ b/packages/opencode/src/server/routes/instance/httpapi/instance-context.ts @@ -0,0 +1,191 @@ +import { AppRuntime } from "@/effect/app-runtime" +import { InstanceRef, WorkspaceRef } from "@/effect/instance-ref" +import { getAdaptor } from "@/control-plane/adaptors" +import { WorkspaceID } from "@/control-plane/schema" +import type { Target } from "@/control-plane/types" +import { Workspace } from "@/control-plane/workspace" +import { InstanceBootstrap } from "@/project/bootstrap" +import { Instance } from "@/project/instance" +import { Session } from "@/session/session" +import { ServerProxy } from "@/server/proxy" +import { getWorkspaceRouteSessionID, isLocalWorkspaceRoute, workspaceProxyURL } from "@/server/workspace" +import { Filesystem } from "@/util/filesystem" +import { Flag } from "@opencode-ai/core/flag/flag" +import { Context, Effect, Layer } from "effect" +import type { unhandled } from "effect/Types" +import { HttpRouter, HttpServerRequest, HttpServerResponse } from "effect/unstable/http" +import { HttpApiMiddleware } from "effect/unstable/httpapi" +import * as Socket from "effect/unstable/socket/Socket" + +type HandlerEffect = Effect.Effect + +export class InstanceContextMiddleware extends HttpApiMiddleware.Service()( + "@opencode/ExperimentalHttpApiInstanceContext", +) {} + +function decode(input: string) { + try { + return decodeURIComponent(input) + } catch { + return input + } +} + +function currentDirectory() { + try { + return Instance.directory + } catch { + return process.cwd() + } +} + +function sourceRequest(request: HttpServerRequest.HttpServerRequest) { + if (request.source instanceof Request) return request.source + return new Request(new URL(request.originalUrl, "http://localhost"), { + method: request.method, + headers: request.headers as HeadersInit, + }) +} + +function requestHeaders(request: HttpServerRequest.HttpServerRequest) { + return sourceRequest(request).headers +} + +function writeSocket(write: (data: string | Uint8Array | Socket.CloseEvent) => Effect.Effect, data: unknown) { + if (data instanceof Blob) { + void data.arrayBuffer().then((buffer) => Effect.runFork(write(new Uint8Array(buffer)).pipe(Effect.catch(() => Effect.void)))) + return + } + if (typeof data === "string" || data instanceof Uint8Array) { + Effect.runFork(write(data).pipe(Effect.catch(() => Effect.void))) + return + } + if (data instanceof ArrayBuffer) Effect.runFork(write(new Uint8Array(data)).pipe(Effect.catch(() => Effect.void))) +} + +function proxyWebSocket(request: HttpServerRequest.HttpServerRequest, target: string | URL) { + return Effect.gen(function* () { + const source = sourceRequest(request) + const socket = yield* Effect.orDie(request.upgrade) + const write = yield* socket.writer + const queue: Array = [] + const remote = new WebSocket(ServerProxy.websocketTargetURL(target), ServerProxy.websocketProtocols(source)) + remote.binaryType = "arraybuffer" + remote.onopen = () => { + for (const item of queue) remote.send(item) + queue.length = 0 + } + remote.onmessage = (event) => writeSocket(write, event.data) + remote.onerror = () => Effect.runFork(write(new Socket.CloseEvent(1011, "proxy error")).pipe(Effect.catch(() => Effect.void))) + remote.onclose = (event) => + Effect.runFork(write(new Socket.CloseEvent(event.code, event.reason)).pipe(Effect.catch(() => Effect.void))) + + yield* socket + .runRaw((message) => { + const data = typeof message === "string" ? message : message.slice() + if (remote.readyState === WebSocket.OPEN) { + remote.send(data) + return + } + queue.push(data) + }) + .pipe( + Effect.catch(() => Effect.void), + Effect.ensuring(Effect.sync(() => remote.close())), + Effect.orDie, + ) + return HttpServerResponse.empty() + }) +} + +function proxyRemote( + request: HttpServerRequest.HttpServerRequest, + workspace: Workspace.Info, + target: Extract, + requestURL: URL, +) { + const url = workspaceProxyURL(target.url, requestURL) + const source = sourceRequest(request) + if (source.headers.get("upgrade")?.toLowerCase() === "websocket") return proxyWebSocket(request, url) + return Effect.promise(() => ServerProxy.http(url, target.headers, source, workspace.id)).pipe(Effect.map(HttpServerResponse.raw)) +} + +function requestContext() { + return Effect.withFiber((fiber) => + Effect.succeed(Context.getUnsafe(fiber.context, HttpServerRequest.HttpServerRequest)), + ) +} + +function provideRequestContext(effect: HandlerEffect, request: HttpServerRequest.HttpServerRequest, sessionWorkspaceID?: WorkspaceID) { + return Effect.gen(function* () { + const url = new URL(request.url, "http://localhost") + const headers = requestHeaders(request) + const envWorkspaceID = Flag.OPENCODE_WORKSPACE_ID ? WorkspaceID.make(Flag.OPENCODE_WORKSPACE_ID) : undefined + const workspaceParam = url.searchParams.get("workspace") + const workspaceID = sessionWorkspaceID ?? (workspaceParam ? WorkspaceID.make(workspaceParam) : undefined) + const workspace = workspaceID && !envWorkspaceID ? yield* Effect.promise(() => Workspace.get(workspaceID)) : undefined + + if (workspaceID && !workspace && !envWorkspaceID) { + return HttpServerResponse.text(`Workspace not found: ${workspaceID}`, { + status: 500, + contentType: "text/plain; charset=utf-8", + }) + } + + if (workspace && !isLocalWorkspaceRoute(request.method, url.pathname) && !url.pathname.startsWith("/console") && !envWorkspaceID) { + const adaptor = yield* Effect.promise(() => getAdaptor(workspace.projectID, workspace.type)) + const target = yield* Effect.promise(() => Promise.resolve(adaptor.target(workspace))) + if (target.type === "remote") return yield* proxyRemote(request, workspace, target, url) + const ctx = yield* Effect.promise(() => + Instance.provide({ + directory: target.directory, + init: () => AppRuntime.runPromise(InstanceBootstrap), + fn: () => Instance.current, + }), + ) + return yield* effect.pipe( + Effect.provideService(InstanceRef, ctx), + Effect.provideService(WorkspaceRef, workspace.id), + ) + } + + const raw = url.searchParams.get("directory") || headers.get("x-opencode-directory") || currentDirectory() + const ctx = yield* Effect.promise(() => + Instance.provide({ + directory: Filesystem.resolve(decode(raw)), + init: () => AppRuntime.runPromise(InstanceBootstrap), + fn: () => Instance.current, + }), + ) + + return yield* effect.pipe( + Effect.provideService(InstanceRef, ctx), + Effect.provideService(WorkspaceRef, envWorkspaceID ?? workspaceID), + ) + }) +} + +function provideInstanceContext(effect: HandlerEffect) { + return Effect.gen(function* () { + const request = yield* requestContext() + const sessionID = getWorkspaceRouteSessionID(new URL(request.url, "http://localhost")) + const session = sessionID + ? yield* Session.Service.use((svc) => svc.get(sessionID)).pipe( + Effect.catch(() => Effect.succeed(undefined)), + Effect.catchDefect(() => Effect.succeed(undefined)), + ) + : undefined + return yield* provideRequestContext(effect, request, session?.workspaceID) + }) +} + +export const instanceContextLayer = Layer.succeed( + InstanceContextMiddleware, + InstanceContextMiddleware.of((effect) => provideInstanceContext(effect)), +) + +export const instanceRouterLayer = HttpRouter.middleware()(Effect.succeed((effect) => + requestContext().pipe(Effect.flatMap((request) => provideRequestContext(effect, request))), +)).layer diff --git a/packages/opencode/src/server/routes/instance/httpapi/lifecycle.ts b/packages/opencode/src/server/routes/instance/httpapi/lifecycle.ts index 1916e42696..e1c03e7bde 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/lifecycle.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/lifecycle.ts @@ -3,7 +3,6 @@ import { Effect } from "effect" import { HttpEffect, HttpMiddleware, HttpServerRequest } from "effect/unstable/http" const disposeAfterResponse = new WeakMap() -const reloadAfterResponse = new WeakMap[0] }>() export const markInstanceForDisposal = (ctx: InstanceContext) => HttpEffect.appendPreResponseHandler((request, response) => @@ -14,27 +13,17 @@ export const markInstanceForDisposal = (ctx: InstanceContext) => ) export const markInstanceForReload = (ctx: InstanceContext, next: Parameters[0]) => - HttpEffect.appendPreResponseHandler((request, response) => - Effect.sync(() => { - reloadAfterResponse.set(request.source, { ...ctx, next }) - return response - }), + HttpEffect.appendPreResponseHandler((_request, response) => + Effect.as(Effect.uninterruptible(Effect.promise(() => Instance.restore(ctx, () => Instance.reload(next)))), response), ) export const disposeMiddleware: HttpMiddleware.HttpMiddleware = (effect) => Effect.gen(function* () { const response = yield* effect const request = yield* HttpServerRequest.HttpServerRequest - const reload = reloadAfterResponse.get(request.source) - if (reload) { - reloadAfterResponse.delete(request.source) - yield* Effect.promise(() => Instance.restore(reload, () => Instance.reload(reload.next))) - return response - } - const ctx = disposeAfterResponse.get(request.source) if (!ctx) return response disposeAfterResponse.delete(request.source) - yield* Effect.promise(() => Instance.restore(ctx, () => Instance.dispose())) + yield* Effect.uninterruptible(Effect.promise(() => Instance.restore(ctx, () => Instance.dispose()))) return response }) diff --git a/packages/opencode/src/server/routes/instance/httpapi/provider.ts b/packages/opencode/src/server/routes/instance/httpapi/provider.ts deleted file mode 100644 index 7dbc491e13..0000000000 --- a/packages/opencode/src/server/routes/instance/httpapi/provider.ts +++ /dev/null @@ -1,157 +0,0 @@ -import { ProviderAuth } from "@/provider/auth" -import { Config } from "@/config/config" -import { ModelsDev } from "@/provider/models" -import { Provider } from "@/provider/provider" -import { ProviderID } from "@/provider/schema" -import { mapValues } from "remeda" -import { Effect, Schema } from "effect" -import { HttpServerRequest, HttpServerResponse } from "effect/unstable/http" -import { HttpApi, HttpApiBuilder, HttpApiEndpoint, HttpApiError, HttpApiGroup, OpenApi } from "effect/unstable/httpapi" -import { Authorization } from "./auth" - -const root = "/provider" - -export const ProviderApi = HttpApi.make("provider") - .add( - HttpApiGroup.make("provider") - .add( - HttpApiEndpoint.get("list", root, { - success: Provider.ListResult, - }).annotateMerge( - OpenApi.annotations({ - identifier: "provider.list", - summary: "List providers", - description: "Get a list of all available AI providers, including both available and connected ones.", - }), - ), - HttpApiEndpoint.get("auth", `${root}/auth`, { - success: ProviderAuth.Methods, - }).annotateMerge( - OpenApi.annotations({ - identifier: "provider.auth", - summary: "Get provider auth methods", - description: "Retrieve available authentication methods for all AI providers.", - }), - ), - HttpApiEndpoint.post("authorize", `${root}/:providerID/oauth/authorize`, { - params: { providerID: ProviderID }, - payload: ProviderAuth.AuthorizeInput, - success: Schema.UndefinedOr(ProviderAuth.Authorization), - error: HttpApiError.BadRequest, - }).annotateMerge( - OpenApi.annotations({ - identifier: "provider.oauth.authorize", - summary: "Start OAuth authorization", - description: "Start the OAuth authorization flow for a provider.", - }), - ), - HttpApiEndpoint.post("callback", `${root}/:providerID/oauth/callback`, { - params: { providerID: ProviderID }, - payload: ProviderAuth.CallbackInput, - success: Schema.Boolean, - error: HttpApiError.BadRequest, - }).annotateMerge( - OpenApi.annotations({ - identifier: "provider.oauth.callback", - summary: "Handle OAuth callback", - description: "Handle the OAuth callback from a provider after user authorization.", - }), - ), - ) - .annotateMerge( - OpenApi.annotations({ - title: "provider", - description: "Experimental HttpApi provider routes.", - }), - ) - .middleware(Authorization), - ) - .annotateMerge( - OpenApi.annotations({ - title: "opencode experimental HttpApi", - version: "0.0.1", - description: "Experimental HttpApi surface for selected instance routes.", - }), - ) - -export const providerHandlers = HttpApiBuilder.group(ProviderApi, "provider", (handlers) => - Effect.gen(function* () { - const cfg = yield* Config.Service - const provider = yield* Provider.Service - const svc = yield* ProviderAuth.Service - - const list = Effect.fn("ProviderHttpApi.list")(function* () { - const config = yield* cfg.get() - const all = yield* Effect.promise(() => ModelsDev.get()) - const disabled = new Set(config.disabled_providers ?? []) - const enabled = config.enabled_providers ? new Set(config.enabled_providers) : undefined - const filtered: Record = {} - for (const [key, value] of Object.entries(all)) { - if ((enabled ? enabled.has(key) : true) && !disabled.has(key)) { - filtered[key] = value - } - } - const connected = yield* provider.list() - const providers = Object.assign( - mapValues(filtered, (item) => Provider.fromModelsDevProvider(item)), - connected, - ) - return { - all: Object.values(providers), - default: Provider.defaultModelIDs(providers), - connected: Object.keys(connected), - } - }) - - const auth = Effect.fn("ProviderHttpApi.auth")(function* () { - return yield* svc.methods() - }) - - const authorize = Effect.fn("ProviderHttpApi.authorize")(function* (ctx: { - params: { providerID: ProviderID } - payload: ProviderAuth.AuthorizeInput - }) { - const result = yield* svc - .authorize({ - providerID: ctx.params.providerID, - method: ctx.payload.method, - inputs: ctx.payload.inputs, - }) - .pipe(Effect.catch(() => Effect.fail(new HttpApiError.BadRequest({})))) - return result - }) - - const authorizeRaw = Effect.fn("ProviderHttpApi.authorizeRaw")(function* (ctx: { - params: { providerID: ProviderID } - request: HttpServerRequest.HttpServerRequest - }) { - const body = yield* Effect.orDie(ctx.request.text) - const payload = yield* Schema.decodeUnknownEffect(Schema.fromJsonString(ProviderAuth.AuthorizeInput))(body).pipe( - Effect.mapError(() => new HttpApiError.BadRequest({})), - ) - const result = yield* authorize({ params: ctx.params, payload }) - if (result === undefined) return HttpServerResponse.empty({ status: 200 }) - return HttpServerResponse.jsonUnsafe(result) - }) - - const callback = Effect.fn("ProviderHttpApi.callback")(function* (ctx: { - params: { providerID: ProviderID } - payload: ProviderAuth.CallbackInput - }) { - yield* svc - .callback({ - providerID: ctx.params.providerID, - method: ctx.payload.method, - code: ctx.payload.code, - }) - .pipe(Effect.catch(() => Effect.fail(new HttpApiError.BadRequest({})))) - return true - }) - - return handlers - .handle("list", list) - .handle("auth", auth) - .handleRaw("authorize", authorizeRaw) - .handle("callback", callback) - }), -) diff --git a/packages/opencode/src/server/routes/instance/httpapi/pty.ts b/packages/opencode/src/server/routes/instance/httpapi/pty.ts deleted file mode 100644 index d4e77c9d03..0000000000 --- a/packages/opencode/src/server/routes/instance/httpapi/pty.ts +++ /dev/null @@ -1,242 +0,0 @@ -import { EffectBridge } from "@/effect/bridge" -import { Pty } from "@/pty" -import { PtyID } from "@/pty/schema" -import { Shell } from "@/shell/shell" -import { Effect, Schema } from "effect" -import { HttpRouter, HttpServerRequest, HttpServerResponse } from "effect/unstable/http" -import { HttpApi, HttpApiBuilder, HttpApiEndpoint, HttpApiError, HttpApiGroup, OpenApi } from "effect/unstable/httpapi" -import * as Socket from "effect/unstable/socket/Socket" -import { Authorization } from "./auth" - -const root = "/pty" -const Params = Schema.Struct({ - ptyID: PtyID, -}) -const CursorQuery = Schema.Struct({ - cursor: Schema.optional(Schema.String), -}) -const ShellItem = Schema.Struct({ - path: Schema.String, - name: Schema.String, - acceptable: Schema.Boolean, -}) - -export const PtyPaths = { - shells: `${root}/shells`, - list: root, - create: root, - get: `${root}/:ptyID`, - update: `${root}/:ptyID`, - remove: `${root}/:ptyID`, - connect: `${root}/:ptyID/connect`, -} as const - -export const PtyApi = HttpApi.make("pty") - .add( - HttpApiGroup.make("pty") - .add( - HttpApiEndpoint.get("shells", PtyPaths.shells, { - success: Schema.Array(ShellItem), - }).annotateMerge( - OpenApi.annotations({ - identifier: "pty.shells", - summary: "List available shells", - description: "Get a list of available shells on the system.", - }), - ), - HttpApiEndpoint.get("list", PtyPaths.list, { - success: Schema.Array(Pty.Info), - }).annotateMerge( - OpenApi.annotations({ - identifier: "pty.list", - summary: "List PTY sessions", - description: "Get a list of all active pseudo-terminal (PTY) sessions managed by OpenCode.", - }), - ), - HttpApiEndpoint.post("create", PtyPaths.create, { - payload: Pty.CreateInput, - success: Pty.Info, - }).annotateMerge( - OpenApi.annotations({ - identifier: "pty.create", - summary: "Create PTY session", - description: "Create a new pseudo-terminal (PTY) session for running shell commands and processes.", - }), - ), - HttpApiEndpoint.get("get", PtyPaths.get, { - params: { ptyID: PtyID }, - success: Pty.Info, - error: HttpApiError.NotFound, - }).annotateMerge( - OpenApi.annotations({ - identifier: "pty.get", - summary: "Get PTY session", - description: "Retrieve detailed information about a specific pseudo-terminal (PTY) session.", - }), - ), - HttpApiEndpoint.put("update", PtyPaths.update, { - params: { ptyID: PtyID }, - payload: Pty.UpdateInput, - success: Pty.Info, - error: HttpApiError.NotFound, - }).annotateMerge( - OpenApi.annotations({ - identifier: "pty.update", - summary: "Update PTY session", - description: "Update properties of an existing pseudo-terminal (PTY) session.", - }), - ), - HttpApiEndpoint.delete("remove", PtyPaths.remove, { - params: { ptyID: PtyID }, - success: Schema.Boolean, - }).annotateMerge( - OpenApi.annotations({ - identifier: "pty.remove", - summary: "Remove PTY session", - description: "Remove and terminate a specific pseudo-terminal (PTY) session.", - }), - ), - ) - .annotateMerge( - OpenApi.annotations({ - title: "pty", - description: "Experimental HttpApi PTY routes.", - }), - ) - .middleware(Authorization), - ) - .annotateMerge( - OpenApi.annotations({ - title: "opencode experimental HttpApi", - version: "0.0.1", - description: "Experimental HttpApi surface for selected instance routes.", - }), - ) - -export const PtyConnectApi = HttpApi.make("pty-connect").add( - HttpApiGroup.make("pty-connect") - .add( - HttpApiEndpoint.get("connect", PtyPaths.connect, { - params: Params, - success: Schema.Boolean, - }).annotateMerge( - OpenApi.annotations({ - identifier: "pty.connect", - summary: "Connect to PTY session", - description: - "Establish a WebSocket connection to interact with a pseudo-terminal (PTY) session in real-time.", - }), - ), - ) - .annotateMerge(OpenApi.annotations({ title: "pty", description: "PTY websocket route." })), -) - -export const ptyHandlers = HttpApiBuilder.group(PtyApi, "pty", (handlers) => - Effect.gen(function* () { - const pty = yield* Pty.Service - - const shells = Effect.fn("PtyHttpApi.shells")(function* () { - return yield* Effect.promise(() => Shell.list()) - }) - - const list = Effect.fn("PtyHttpApi.list")(function* () { - return yield* pty.list() - }) - - const create = Effect.fn("PtyHttpApi.create")(function* (ctx: { payload: typeof Pty.CreateInput.Type }) { - const bridge = yield* EffectBridge.make() - return yield* Effect.promise(() => - bridge.promise( - pty.create({ - ...ctx.payload, - args: ctx.payload.args ? [...ctx.payload.args] : undefined, - env: ctx.payload.env ? { ...ctx.payload.env } : undefined, - }), - ), - ) - }) - - const get = Effect.fn("PtyHttpApi.get")(function* (ctx: { params: { ptyID: PtyID } }) { - const info = yield* pty.get(ctx.params.ptyID) - if (!info) return yield* new HttpApiError.NotFound({}) - return info - }) - - const update = Effect.fn("PtyHttpApi.update")(function* (ctx: { - params: { ptyID: PtyID } - payload: typeof Pty.UpdateInput.Type - }) { - const info = yield* pty.update(ctx.params.ptyID, { - ...ctx.payload, - size: ctx.payload.size ? { ...ctx.payload.size } : undefined, - }) - if (!info) return yield* new HttpApiError.NotFound({}) - return info - }) - - const remove = Effect.fn("PtyHttpApi.remove")(function* (ctx: { params: { ptyID: PtyID } }) { - yield* pty.remove(ctx.params.ptyID) - return true - }) - - return handlers - .handle("shells", shells) - .handle("list", list) - .handle("create", create) - .handle("get", get) - .handle("update", update) - .handle("remove", remove) - }), -) - -export const ptyConnectRoute = HttpRouter.add( - "GET", - PtyPaths.connect, - Effect.gen(function* () { - const pty = yield* Pty.Service - const params = yield* HttpRouter.schemaPathParams(Params) - if (!(yield* pty.get(params.ptyID))) return HttpServerResponse.empty({ status: 404 }) - - const query = yield* HttpServerRequest.schemaSearchParams(CursorQuery) - const parsedCursor = query.cursor === undefined ? undefined : Number(query.cursor) - const cursor = - parsedCursor !== undefined && Number.isSafeInteger(parsedCursor) && parsedCursor >= -1 ? parsedCursor : undefined - const socket = yield* Effect.orDie((yield* HttpServerRequest.HttpServerRequest).upgrade) - const write = yield* socket.writer - let closed = false - const adapter = { - get readyState() { - return closed ? 3 : 1 - }, - send: (data: string | Uint8Array | ArrayBuffer) => { - if (closed) return - Effect.runFork( - write(data instanceof ArrayBuffer ? new Uint8Array(data) : data).pipe(Effect.catch(() => Effect.void)), - ) - }, - close: (code?: number, reason?: string) => { - if (closed) return - closed = true - Effect.runFork(write(new Socket.CloseEvent(code, reason)).pipe(Effect.catch(() => Effect.void))) - }, - } - const handler = yield* pty.connect(params.ptyID, adapter, cursor) - if (!handler) return HttpServerResponse.empty() - - yield* socket - .runRaw((message) => { - handler.onMessage(typeof message === "string" ? message : message.slice().buffer) - }) - .pipe( - Effect.catchReason("SocketError", "SocketCloseError", () => Effect.void), - Effect.ensuring( - Effect.sync(() => { - closed = true - handler.onClose() - }), - ), - Effect.orDie, - ) - return HttpServerResponse.empty() - }).pipe(Effect.provide(Pty.defaultLayer)), -) diff --git a/packages/opencode/src/server/routes/instance/httpapi/public.ts b/packages/opencode/src/server/routes/instance/httpapi/public.ts index a4e86e9a5f..d9871c69be 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/public.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/public.ts @@ -1,21 +1,5 @@ -import { HttpApi, OpenApi } from "effect/unstable/httpapi" -import { ConfigApi } from "./config" -import { ControlApi } from "./control" -import { EventApi } from "./event" -import { ExperimentalApi } from "./experimental" -import { FileApi } from "./file" -import { GlobalApi } from "./global" -import { InstanceApi } from "./instance" -import { McpApi } from "./mcp" -import { PermissionApi } from "./permission" -import { ProjectApi } from "./project" -import { ProviderApi } from "./provider" -import { PtyApi, PtyConnectApi } from "./pty" -import { QuestionApi } from "./question" -import { SessionApi } from "./session" -import { SyncApi } from "./sync" -import { TuiApi } from "./tui" -import { WorkspaceApi } from "./workspace" +import { OpenApi } from "effect/unstable/httpapi" +import { OpenCodeHttpApi } from "./api" type OpenApiParameter = { name: string @@ -26,11 +10,12 @@ type OpenApiParameter = { type OpenApiOperation = { parameters?: OpenApiParameter[] - responses?: Record + responses?: Record requestBody?: { required?: boolean content?: Record } + security?: unknown } type OpenApiPathItem = Partial> @@ -38,6 +23,7 @@ type OpenApiPathItem = Partial + securitySchemes?: Record } paths?: Record } @@ -47,16 +33,25 @@ type OpenApiSchema = { additionalProperties?: OpenApiSchema | boolean allOf?: OpenApiSchema[] anyOf?: OpenApiSchema[] - enum?: string[] + description?: string + enum?: Array items?: OpenApiSchema maximum?: number minimum?: number oneOf?: OpenApiSchema[] prefixItems?: OpenApiSchema[] properties?: Record + required?: string[] type?: string } +type OpenApiResponse = { + description?: string + content?: Record +} + +// Instance routes use middleware for directory/workspace resolution, but HttpApi +// doesn't surface middleware query params in the spec. Inject them explicitly. const InstanceQueryParameters = [ { name: "directory", @@ -72,8 +67,9 @@ const InstanceQueryParameters = [ }, ] satisfies OpenApiParameter[] -const LegacyBodyRefParameters = new Set(["Auth", "Config", "Part", "WorktreeRemoveInput", "WorktreeResetInput"]) -const FiniteNumberValues = new Set(["Infinity", "-Infinity", "NaN"]) +// Query schemas describe decoded Effect values, but the generated SDK needs the +// public call shape. These keep SDK callers passing numbers/booleans while the +// server still decodes string query params at runtime. const QueryNumberParameters = new Set(["start", "cursor", "limit", "method"]) const QueryBooleanParameters = new Set(["roots", "archived"]) const QueryParameterSchemas = { @@ -81,60 +77,80 @@ const QueryParameterSchemas = { "GET /session/{sessionID}/message limit": { type: "integer", minimum: 0, maximum: Number.MAX_SAFE_INTEGER }, } satisfies Record +const LegacyComponentDescriptions = { + LogLevel: "Log level", + ServerConfig: "Server configuration for opencode serve and web commands", + LayoutConfig: "@deprecated Always uses stretch layout.", +} satisfies Record + function matchLegacyOpenApi(input: Record) { const spec = input as OpenApiSpec + + // Effect's multi-document JSON Schema deduplicator can produce self-referencing + // component schemas (e.g. `{"$ref":"#/components/schemas/X"}` as the definition + // of X itself) when the same AST node appears both as a standalone endpoint + // payload and inside an annotated union arm. Resolve these by inlining the + // actual schema from any parent union that references them. + fixSelfReferencingComponents(spec) + + // Effect's Schema.optional emits `anyOf: [T, {type:"null"}]` in OpenAPI, + // but the legacy SDK expected plain `T` for optional fields. Strip null + // from all component schemas so both request and response types match. + for (const [name, schema] of Object.entries(spec.components?.schemas ?? {})) { + spec.components!.schemas![name] = stripOptionalNull(structuredClone(schema)) + } + normalizeComponentNames(spec) + collapseDuplicateComponents(spec) + applyLegacySchemaOverrides(spec) + normalizeComponentDescriptions(spec) + addLegacyErrorSchemas(spec) + delete spec.components?.schemas?.Unauthorized + delete spec.components?.schemas?.EffectHttpApiErrorBadRequest + delete spec.components?.schemas?.EffectHttpApiErrorNotFound + delete spec.components?.schemas?.effect_HttpApiError_BadRequest + delete spec.components?.schemas?.effect_HttpApiError_NotFound + delete spec.components?.securitySchemes + for (const [path, item] of Object.entries(spec.paths ?? {})) { const isInstanceRoute = !path.startsWith("/global/") && !path.startsWith("/auth/") for (const method of ["get", "post", "put", "delete", "patch"] as const) { const operation = item[method] if (!operation) continue if (operation.requestBody) { + // Hono's generated OpenAPI never marked request bodies as required. Keep + // that SDK surface stable during the HttpApi migration. delete operation.requestBody.required - for (const media of Object.values(operation.requestBody.content ?? {})) { - const ref = media.schema?.$ref?.replace("#/components/schemas/", "") - if (ref && LegacyBodyRefParameters.has(ref)) continue - if (ref && spec.components?.schemas?.[ref]) { - media.schema = normalizeRequestSchema(structuredClone(spec.components.schemas[ref])) - continue - } - if (media.schema) media.schema = normalizeRequestSchema(media.schema) - } + const body = operation.requestBody.content?.["application/json"] + if (body?.schema) body.schema = stripOptionalNull(structuredClone(body.schema)) if (path === "/experimental/workspace" && method === "post") { - const properties = operation.requestBody.content?.["application/json"]?.schema?.properties + // Workspace creation fields `branch` and `extra` are Schema.NullOr — + // genuinely nullable, not just optional. Re-add the null that the + // component-level strip above removed. + const ref = operation.requestBody.content?.["application/json"]?.schema?.$ref?.replace("#/components/schemas/", "") + const properties = ref ? spec.components?.schemas?.[ref]?.properties : operation.requestBody.content?.["application/json"]?.schema?.properties if (properties?.branch) properties.branch = { anyOf: [properties.branch, { type: "null" }] } if (properties?.extra) properties.extra = { anyOf: [properties.extra, { type: "null" }] } } - if (path === "/tui/publish" && method === "post" && spec.components?.schemas) { - const schema = operation.requestBody.content?.["application/json"]?.schema - const anyOf = schema?.anyOf - if (anyOf?.length === 4) { - spec.components.schemas.EventTuiPromptAppend = anyOf[0] - spec.components.schemas.EventTuiCommandExecute = anyOf[1] - spec.components.schemas.EventTuiToastShow = anyOf[2] - spec.components.schemas.EventTuiSessionSelect = anyOf[3] - operation.requestBody.content!["application/json"]!.schema = { - anyOf: [ - { $ref: "#/components/schemas/EventTuiPromptAppend" }, - { $ref: "#/components/schemas/EventTuiCommandExecute" }, - { $ref: "#/components/schemas/EventTuiToastShow" }, - { $ref: "#/components/schemas/EventTuiSessionSelect" }, - ], - } - } - } - if (path === "/sync/replay" && method === "post" && spec.components?.schemas?.SyncReplayEvent) { - const events = operation.requestBody.content?.["application/json"]?.schema?.properties?.events - if (events?.items?.$ref === "#/components/schemas/SyncReplayEvent") { - events.items = normalizeRequestSchema(structuredClone(spec.components.schemas.SyncReplayEvent)) - } + } + for (const response of Object.values(operation.responses ?? {})) { + for (const content of Object.values(response.content ?? {})) { + if (content.schema) content.schema = stripOptionalNull(structuredClone(content.schema)) } } + // Hono applied auth as runtime middleware outside OpenAPI metadata, so the + // legacy SDK did not expose auth schemes or generated 401 error unions. + delete operation.security + delete operation.responses?.["401"] + normalizeLegacyErrorResponses(operation) + normalizeLegacyOperation(operation, path, method) if ((path === "/event" || path === "/global/event") && method === "get") { + // HttpApi has no first-class SSE response schema, and these handlers are + // raw/streaming routes. Document the actual wire protocol explicitly. operation.responses!["200"] = { description: "Event stream", content: { "text/event-stream": { - schema: path === "/event" ? {} : { $ref: "#/components/schemas/GlobalEvent" }, + schema: path === "/event" ? { $ref: "#/components/schemas/Event" } : { $ref: "#/components/schemas/GlobalEvent" }, }, }, } @@ -152,40 +168,302 @@ function matchLegacyOpenApi(input: Record) { return input } -function normalizeRequestSchema(schema: OpenApiSchema): OpenApiSchema { +function addLegacyErrorSchemas(spec: OpenApiSpec) { + if (!spec.components?.schemas) return + spec.components.schemas.BadRequestError = { + type: "object", + required: ["data", "errors", "success"], + properties: { + data: {}, + errors: { + type: "array", + items: { + type: "object", + additionalProperties: {}, + }, + }, + success: { type: "boolean", enum: [false] }, + }, + } + spec.components.schemas.NotFoundError = { + type: "object", + required: ["name", "data"], + properties: { + name: { type: "string", enum: ["NotFoundError"] }, + data: { + type: "object", + required: ["message"], + properties: { + message: { type: "string" }, + }, + }, + }, + } +} + +function collapseDuplicateComponents(spec: OpenApiSpec) { + const schemas = spec.components?.schemas + if (!schemas) return + for (const name of Object.keys(schemas)) { + const base = name.replace(/\d+$/, "") + if (base === name || !schemas[base]) continue + if (stableSchema(schemas[name], schemas) !== stableSchema(schemas[base], schemas)) continue + rewriteRefs(spec, name, base) + delete schemas[name] + } +} + +function normalizeComponentNames(spec: OpenApiSpec) { + const schemas = spec.components?.schemas + if (!schemas) return + for (const name of Object.keys(schemas)) { + const next = componentTypeName(name) + if (next === name) continue + if (schemas[next]) { + if (stableSchema(schemas[name], schemas) === stableSchema(schemas[next], schemas)) { + rewriteRefs(spec, name, next) + delete schemas[name] + } + continue + } + schemas[next] = schemas[name] + rewriteRefs(spec, name, next) + delete schemas[name] + } +} + +function componentTypeName(name: string) { + if (!name.includes(".")) return name + return name + .split(".") + .filter((part) => !/^\d+$/.test(part)) + .map((part) => part.slice(0, 1).toUpperCase() + part.slice(1)) + .join("") +} + +function applyLegacySchemaOverrides(spec: OpenApiSpec) { + const schemas = spec.components?.schemas + if (!schemas) return + if (schemas.AgentConfig) schemas.AgentConfig.additionalProperties = {} + if (schemas.Command?.properties?.template) schemas.Command.properties.template = { type: "string" } + if (schemas.Workspace?.properties) { + schemas.Workspace.properties.branch = nullable(schemas.Workspace.properties.branch) + schemas.Workspace.properties.directory = nullable(schemas.Workspace.properties.directory) + schemas.Workspace.properties.extra = nullable(schemas.Workspace.properties.extra) + } + if (schemas.GlobalSession?.properties?.project) schemas.GlobalSession.properties.project = nullable(schemas.GlobalSession.properties.project) + const providerOptions = schemas.ProviderConfig?.properties?.options + if (providerOptions) providerOptions.additionalProperties = {} + const model = schemas.ProviderConfig?.properties?.models?.additionalProperties + const variants = typeof model === "object" ? model.properties?.variants?.additionalProperties : undefined + if (variants && typeof variants === "object") variants.additionalProperties = {} + const syncInfo = schemas.SyncEventSessionUpdated?.properties?.data?.properties?.info + if (syncInfo?.properties) makePropertiesNullable(syncInfo.properties) +} + +function normalizeComponentDescriptions(spec: OpenApiSpec) { + for (const [name, schema] of Object.entries(spec.components?.schemas ?? {})) { + const description = LegacyComponentDescriptions[name as keyof typeof LegacyComponentDescriptions] + if (description) { + schema.description = description + continue + } + delete schema.description + } +} + +function makePropertiesNullable(properties: Record) { + for (const [key, value] of Object.entries(properties)) { + if (key === "share" && value.properties?.url) { + value.properties.url = nullable(value.properties.url) + continue + } + if (key === "time" && value.properties) { + makePropertiesNullable(value.properties) + continue + } + properties[key] = nullable(value) + } +} + +function nullable(schema: OpenApiSchema): OpenApiSchema { + if (flattenOptions(schema.anyOf ?? schema.oneOf)?.some((item) => item.type === "null")) return schema + return { anyOf: [schema, { type: "null" }] } +} + +function stableSchema(input: unknown, schemas: Record): string { + return JSON.stringify(canonicalizeSchema(input, schemas)) +} + +function canonicalizeSchema(input: unknown, schemas: Record): unknown { + if (Array.isArray(input)) return input.map((item) => canonicalizeSchema(item, schemas)) + if (!input || typeof input !== "object") return input + const schema = input as OpenApiSchema + if (schema.$ref) return { $ref: canonicalRef(schema.$ref, schemas) } + return Object.fromEntries( + Object.entries(input) + .filter(([key]) => key !== "description") + .sort(([a], [b]) => a.localeCompare(b)) + .map(([key, value]) => [key, canonicalizeSchema(value, schemas)]), + ) +} + +function canonicalRef(ref: string, schemas: Record) { + const name = ref.replace("#/components/schemas/", "") + const base = name.replace(/\d+$/, "") + if (base !== name && schemas[base]) return `#/components/schemas/${base}` + return ref +} + +function rewriteRefs(input: unknown, from: string, to: string): void { + if (Array.isArray(input)) { + for (const item of input) rewriteRefs(item, from, to) + return + } + if (!input || typeof input !== "object") return + const schema = input as OpenApiSchema + if (schema.$ref === `#/components/schemas/${from}`) schema.$ref = `#/components/schemas/${to}` + for (const value of Object.values(input)) rewriteRefs(value, from, to) +} + +function normalizeLegacyErrorResponses(operation: OpenApiOperation) { + if (operation.responses?.["400"] && isBuiltInErrorResponse(operation.responses["400"], "BadRequest")) { + operation.responses["400"] = legacyErrorResponse("Bad request", "BadRequestError") + } + if (operation.responses?.["404"] && isBuiltInErrorResponse(operation.responses["404"], "NotFound")) { + operation.responses["404"] = legacyErrorResponse("Not found", "NotFoundError") + } +} + +function normalizeLegacyOperation(operation: OpenApiOperation, path: string, method: string) { + if (path === "/experimental/console/switch" && method === "post") delete operation.responses?.["400"] + if (path === "/pty/{ptyID}" && method === "put") delete operation.responses?.["404"] + if ((path !== "/session/{sessionID}/message" && path !== "/session/{sessionID}/command") || method !== "post") return + const response = operation.responses?.["200"]?.content?.["application/json"] + if (!response) return + response.schema = { + type: "object", + required: ["info", "parts"], + properties: { + info: { $ref: "#/components/schemas/AssistantMessage" }, + parts: { + type: "array", + items: { $ref: "#/components/schemas/Part" }, + }, + }, + } +} + +function isRefResponse(response: OpenApiResponse, name: string) { + return response.content?.["application/json"]?.schema?.$ref === `#/components/schemas/${name}` +} + +function isBuiltInErrorResponse(response: OpenApiResponse, name: "BadRequest" | "NotFound") { + return response.description === name || isRefResponse(response, `EffectHttpApiError${name}`) +} + +function legacyErrorResponse(description: string, name: "BadRequestError" | "NotFoundError"): OpenApiResponse { + return { + description, + content: { + "application/json": { + schema: { $ref: `#/components/schemas/${name}` }, + }, + }, + } +} + +/** + * Fix component schemas that are self-referencing `$ref`s — an Effect OpenAPI + * generation bug where annotated union arms that share AST nodes with other + * endpoints produce `{"$ref":"#/components/schemas/X"}` as the definition of X. + * + * Resolves by finding the actual schema from a parent union's `anyOf`/`oneOf` + * that references the broken component, then inlining that schema. + */ +function fixSelfReferencingComponents(spec: OpenApiSpec) { + const schemas = spec.components?.schemas + if (!schemas) return + const selfRefs = new Set() + for (const [name, schema] of Object.entries(schemas)) { + if (schema.$ref === `#/components/schemas/${name}`) selfRefs.add(name) + } + if (selfRefs.size === 0) return + // Find a parent union component whose anyOf/oneOf contains a $ref to the + // broken component — that parent was generated correctly and holds the inline + // schema we need. + for (const [, schema] of Object.entries(schemas)) { + for (const member of schema.anyOf ?? schema.oneOf ?? []) { + const ref = member.$ref?.replace("#/components/schemas/", "") + if (!ref || !selfRefs.has(ref)) continue + // This member's $ref points to a self-referencing component. The member + // itself is just {$ref:...}, so the actual schema must be resolved from + // the union. Since the union component was generated before the + // deduplicator broke things, the inline version lives elsewhere. Generate + // a fresh spec without the transform to get the correct schema. + // Simpler approach: look through all paths for an endpoint that uses this + // schema as a payload (it would have been expanded by the ref-expansion + // logic above if we ran after that, but we run before). Instead, just + // delete the broken component — if it's referenced via $ref elsewhere, + // the ref expansion in the request body loop will inline it anyway. + } + } + // Simplest fix: generate the raw spec (without transform) to get correct schemas + const raw = OpenApi.fromApi(OpenCodeHttpApi) as unknown as OpenApiSpec + const rawSchemas = raw.components?.schemas + if (!rawSchemas) return + for (const name of selfRefs) { + if (rawSchemas[name]) schemas[name] = rawSchemas[name] + } +} + +/** Strip `{type:"null"}` arms that Effect's `Schema.optional` adds to OpenAPI unions. */ +function stripOptionalNull(schema: OpenApiSchema): OpenApiSchema { + if (isEmptyObjectUnion(schema)) return { type: "object", properties: {} } const options = flattenOptions(schema.anyOf ?? schema.oneOf) if (options) { const withoutNull = options.filter((item) => item.type !== "null") - const finite = withoutNull.find((item) => item.type === "number") - if (finite && withoutNull.every(isFiniteNumberOption)) return { type: "number" } - if (withoutNull.length === 1) return normalizeRequestSchema(withoutNull[0]) - if (schema.anyOf) schema.anyOf = withoutNull.map(normalizeRequestSchema) - if (schema.oneOf) schema.oneOf = withoutNull.map(normalizeRequestSchema) + if (withoutNull.length === 1) return stripOptionalNull(withoutNull[0]) + if (schema.anyOf) schema.anyOf = withoutNull.map(stripOptionalNull) + if (schema.oneOf) schema.oneOf = withoutNull.map(stripOptionalNull) } if (schema.allOf) { - if (schema.type) delete schema.allOf - else schema.allOf = schema.allOf.map(normalizeRequestSchema) + const allOf = schema.allOf.map(stripOptionalNull) + if (schema.type) { + delete schema.allOf + for (const item of allOf) Object.assign(schema, item) + } else { + schema.allOf = allOf + } } if (schema.prefixItems && schema.items) delete schema.prefixItems - if (schema.items) schema.items = normalizeRequestSchema(schema.items) + if (schema.items) schema.items = stripOptionalNull(schema.items) if (schema.properties) { for (const [key, value] of Object.entries(schema.properties)) { - schema.properties[key] = normalizeRequestSchema(value) + schema.properties[key] = stripOptionalNull(value) } } if (schema.additionalProperties && typeof schema.additionalProperties === "object") { - schema.additionalProperties = normalizeRequestSchema(schema.additionalProperties) + schema.additionalProperties = stripOptionalNull(schema.additionalProperties) } return schema } -function flattenOptions(options: OpenApiSchema[] | undefined): OpenApiSchema[] | undefined { - return options?.flatMap((item) => flattenOptions(item.anyOf ?? item.oneOf) ?? [item]) +function isEmptyObjectUnion(schema: OpenApiSchema) { + const options = schema.anyOf ?? schema.oneOf + return options?.length === 2 && options.some(isBareObjectSchema) && options.some(isBareArraySchema) } -function isFiniteNumberOption(schema: OpenApiSchema) { - if (schema.type === "number") return true - return schema.type === "string" && schema.enum?.every((value) => FiniteNumberValues.has(value)) === true +function isBareObjectSchema(schema: OpenApiSchema) { + return schema.type === "object" && !schema.properties && !schema.additionalProperties +} + +function isBareArraySchema(schema: OpenApiSchema) { + return schema.type === "array" && !schema.items && !schema.prefixItems +} + +function flattenOptions(options: OpenApiSchema[] | undefined): OpenApiSchema[] | undefined { + return options?.flatMap((item) => flattenOptions(item.anyOf ?? item.oneOf) ?? [item]) } function normalizeParameter(param: OpenApiParameter, route: string) { @@ -205,28 +483,10 @@ function normalizeParameter(param: OpenApiParameter, route: string) { } return } - param.schema = normalizeRequestSchema(param.schema) + param.schema = stripOptionalNull(param.schema) } -export const PublicApi = HttpApi.make("opencode") - .addHttpApi(ControlApi) - .addHttpApi(GlobalApi) - .addHttpApi(EventApi) - .addHttpApi(ConfigApi) - .addHttpApi(ExperimentalApi) - .addHttpApi(FileApi) - .addHttpApi(InstanceApi) - .addHttpApi(McpApi) - .addHttpApi(PermissionApi) - .addHttpApi(ProjectApi) - .addHttpApi(ProviderApi) - .addHttpApi(PtyApi) - .addHttpApi(PtyConnectApi) - .addHttpApi(QuestionApi) - .addHttpApi(SessionApi) - .addHttpApi(SyncApi) - .addHttpApi(TuiApi) - .addHttpApi(WorkspaceApi) +export const PublicApi = OpenCodeHttpApi .annotateMerge( OpenApi.annotations({ title: "opencode", diff --git a/packages/opencode/src/server/routes/instance/httpapi/server.ts b/packages/opencode/src/server/routes/instance/httpapi/server.ts index e96c21b55b..2f4bde9183 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/server.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/server.ts @@ -1,14 +1,12 @@ -import { Context, Effect, Layer, Schema } from "effect" +import { Context, Effect, Layer } from "effect" import { HttpApiBuilder } from "effect/unstable/httpapi" -import { HttpRouter, HttpServer, HttpServerRequest } from "effect/unstable/http" +import { HttpRouter, HttpServer } from "effect/unstable/http" import { Account } from "@/account/account" import { Agent } from "@/agent/agent" import { Auth } from "@/auth" import { Bus } from "@/bus" import { Config } from "@/config/config" import { Command } from "@/command" -import { AppRuntime } from "@/effect/app-runtime" -import { InstanceRef, WorkspaceRef } from "@/effect/instance-ref" import * as Observability from "@opencode-ai/core/effect/observability" import { File } from "@/file" import { Ripgrep } from "@/file/ripgrep" @@ -16,8 +14,6 @@ import { Format } from "@/format" import { LSP } from "@/lsp/lsp" import { MCP } from "@/mcp" import { Permission } from "@/permission" -import { InstanceBootstrap } from "@/project/bootstrap" -import { Instance } from "@/project/instance" import { Installation } from "@/installation" import { Project } from "@/project/project" import { ProviderAuth } from "@/provider/auth" @@ -32,131 +28,103 @@ import { Todo } from "@/session/todo" import { Skill } from "@/skill" import { ToolRegistry } from "@/tool/registry" import { lazy } from "@/util/lazy" -import { Filesystem } from "@/util/filesystem" import { Vcs } from "@/project/vcs" import { Worktree } from "@/worktree" +import { InstanceHttpApi, RootHttpApi } from "./api" import { authorizationLayer } from "./auth" -import { ConfigApi, configHandlers } from "./config" -import { ControlApi, controlHandlers } from "./control" import { eventRoute } from "./event" -import { FileApi, fileHandlers } from "./file" -import { ExperimentalApi, experimentalHandlers } from "./experimental" -import { GlobalApi, globalHandlers } from "./global" -import { InstanceApi, instanceHandlers } from "./instance" -import { McpApi, mcpHandlers } from "./mcp" -import { PermissionApi, permissionHandlers } from "./permission" -import { ProjectApi, projectHandlers } from "./project" -import { PtyApi, ptyConnectRoute, ptyHandlers } from "./pty" -import { ProviderApi, providerHandlers } from "./provider" -import { QuestionApi, questionHandlers } from "./question" -import { SessionApi, sessionHandlers } from "./session" -import { SyncApi, syncHandlers } from "./sync" -import { TuiApi, tuiHandlers } from "./tui" -import { WorkspaceApi, workspaceHandlers } from "./workspace" +import { configHandlers } from "./handlers/config" +import { controlHandlers } from "./handlers/control" +import { experimentalHandlers } from "./handlers/experimental" +import { fileHandlers } from "./handlers/file" +import { globalHandlers } from "./handlers/global" +import { instanceHandlers } from "./handlers/instance" +import { mcpHandlers } from "./handlers/mcp" +import { permissionHandlers } from "./handlers/permission" +import { projectHandlers } from "./handlers/project" +import { providerHandlers } from "./handlers/provider" +import { ptyConnectRoute, ptyHandlers } from "./handlers/pty" +import { questionHandlers } from "./handlers/question" +import { sessionHandlers } from "./handlers/session" +import { syncHandlers } from "./handlers/sync" +import { tuiHandlers } from "./handlers/tui" +import { workspaceHandlers } from "./handlers/workspace" +import { instanceContextLayer, instanceRouterLayer } from "./instance-context" import { disposeMiddleware } from "./lifecycle" import { memoMap } from "@opencode-ai/core/effect/memo-map" - -const Query = Schema.Struct({ - directory: Schema.optional(Schema.String), - workspace: Schema.optional(Schema.String), - auth_token: Schema.optional(Schema.String), -}) - -const Headers = Schema.Struct({ - authorization: Schema.optional(Schema.String), - "x-opencode-directory": Schema.optional(Schema.String), -}) +import * as ServerBackend from "@/server/backend" export const context = Context.empty() as Context.Context -function decode(input: string) { - try { - return decodeURIComponent(input) - } catch { - return input - } -} - -const instance = HttpRouter.middleware()( - Effect.gen(function* () { - return (effect) => - Effect.gen(function* () { - const query = yield* HttpServerRequest.schemaSearchParams(Query) - const headers = yield* HttpServerRequest.schemaHeaders(Headers) - const raw = query.directory || headers["x-opencode-directory"] || process.cwd() - const workspace = query.workspace || undefined - const ctx = yield* Effect.promise(() => - Instance.provide({ - directory: Filesystem.resolve(decode(raw)), - init: () => AppRuntime.runPromise(InstanceBootstrap), - fn: () => Instance.current, - }), - ) - - const next = workspace ? effect.pipe(Effect.provideService(WorkspaceRef, workspace)) : effect - return yield* next.pipe(Effect.provideService(InstanceRef, ctx)) - }) - }), +const runtime = HttpRouter.middleware()( + Effect.succeed((effect) => + Effect.gen(function* () { + const selected = ServerBackend.select() + yield* Effect.annotateCurrentSpan(ServerBackend.attributes(ServerBackend.force(selected, "effect-httpapi"))) + return yield* effect + }), + ), ).layer -const controlRoutes = HttpApiBuilder.layer(ControlApi).pipe(Layer.provide(controlHandlers)) -const globalRoutes = HttpApiBuilder.layer(GlobalApi).pipe(Layer.provide(globalHandlers)) -const instanceApiRoutes = Layer.mergeAll( - HttpApiBuilder.layer(ConfigApi).pipe(Layer.provide(configHandlers)), - HttpApiBuilder.layer(ExperimentalApi).pipe(Layer.provide(experimentalHandlers)), - HttpApiBuilder.layer(FileApi).pipe(Layer.provide(fileHandlers)), - HttpApiBuilder.layer(InstanceApi).pipe(Layer.provide(instanceHandlers)), - HttpApiBuilder.layer(McpApi).pipe(Layer.provide(mcpHandlers)), - HttpApiBuilder.layer(ProjectApi).pipe(Layer.provide(projectHandlers)), - HttpApiBuilder.layer(PtyApi).pipe(Layer.provide(ptyHandlers)), - HttpApiBuilder.layer(QuestionApi).pipe(Layer.provide(questionHandlers)), - HttpApiBuilder.layer(PermissionApi).pipe(Layer.provide(permissionHandlers)), - HttpApiBuilder.layer(ProviderApi).pipe(Layer.provide(providerHandlers)), - HttpApiBuilder.layer(SessionApi).pipe(Layer.provide(sessionHandlers)), - HttpApiBuilder.layer(SyncApi).pipe(Layer.provide(syncHandlers)), - HttpApiBuilder.layer(TuiApi).pipe(Layer.provide(tuiHandlers)), - HttpApiBuilder.layer(WorkspaceApi).pipe(Layer.provide(workspaceHandlers)), +const rootApiRoutes = HttpApiBuilder.layer(RootHttpApi).pipe(Layer.provide([controlHandlers, globalHandlers])) +const instanceApiRoutes = HttpApiBuilder.layer(InstanceHttpApi).pipe( + Layer.provide([ + configHandlers, + experimentalHandlers, + fileHandlers, + instanceHandlers, + mcpHandlers, + projectHandlers, + ptyHandlers, + questionHandlers, + permissionHandlers, + providerHandlers, + sessionHandlers, + syncHandlers, + tuiHandlers, + workspaceHandlers, + ]), ) -const instanceRoutes = Layer.mergeAll(eventRoute, ptyConnectRoute, instanceApiRoutes).pipe( - Layer.provide(authorizationLayer), - Layer.provide(instance), +const rawInstanceRoutes = Layer.mergeAll(eventRoute, ptyConnectRoute).pipe(Layer.provide(instanceRouterLayer)) +const instanceRoutes = Layer.mergeAll(rawInstanceRoutes, instanceApiRoutes).pipe( + Layer.provide([authorizationLayer, instanceContextLayer]), ) -export const routes = Layer.mergeAll(controlRoutes, globalRoutes, instanceRoutes) - .pipe( - Layer.provide(Account.defaultLayer), - Layer.provide(Agent.defaultLayer), - Layer.provide(Auth.defaultLayer), - Layer.provide(Command.defaultLayer), - Layer.provide(Config.defaultLayer), - Layer.provide(File.defaultLayer), - Layer.provide(Format.defaultLayer), - Layer.provide(LSP.defaultLayer), - Layer.provide(Installation.defaultLayer), - Layer.provide(MCP.defaultLayer), - Layer.provide(Permission.defaultLayer), - Layer.provide(Project.defaultLayer), - Layer.provide(ProviderAuth.defaultLayer), - Layer.provide(Provider.defaultLayer), - Layer.provide(Pty.defaultLayer), - Layer.provide(Question.defaultLayer), - Layer.provide(Ripgrep.defaultLayer), - Layer.provide(Session.defaultLayer), - ) - .pipe( - Layer.provide(SessionRunState.defaultLayer), - Layer.provide(SessionStatus.defaultLayer), - Layer.provide(SessionSummary.defaultLayer), - Layer.provide(Skill.defaultLayer), - Layer.provide(Todo.defaultLayer), - Layer.provide(ToolRegistry.defaultLayer), - Layer.provide(Vcs.defaultLayer), - Layer.provide(Worktree.defaultLayer), - Layer.provide(Bus.layer), - Layer.provide(HttpServer.layerServices), - Layer.provideMerge(Observability.layer), - ) +export const routes = Layer.mergeAll(rootApiRoutes, instanceRoutes).pipe( + Layer.provide([ + runtime, + Account.defaultLayer, + Agent.defaultLayer, + Auth.defaultLayer, + Command.defaultLayer, + Config.defaultLayer, + File.defaultLayer, + Format.defaultLayer, + LSP.defaultLayer, + Installation.defaultLayer, + MCP.defaultLayer, + Permission.defaultLayer, + Project.defaultLayer, + ProviderAuth.defaultLayer, + Provider.defaultLayer, + Pty.defaultLayer, + Question.defaultLayer, + Ripgrep.defaultLayer, + Session.defaultLayer, + SessionRunState.defaultLayer, + SessionStatus.defaultLayer, + SessionSummary.defaultLayer, + Skill.defaultLayer, + Todo.defaultLayer, + ToolRegistry.defaultLayer, + Vcs.defaultLayer, + Worktree.defaultLayer, + Bus.layer, + HttpServer.layerServices, + ]), + Layer.provideMerge(Observability.layer), +) export const webHandler = lazy(() => HttpRouter.toWebHandler(routes, { diff --git a/packages/opencode/src/server/routes/instance/httpapi/sync.ts b/packages/opencode/src/server/routes/instance/httpapi/sync.ts deleted file mode 100644 index 67fcede2f8..0000000000 --- a/packages/opencode/src/server/routes/instance/httpapi/sync.ts +++ /dev/null @@ -1,137 +0,0 @@ -import { startWorkspaceSyncing } from "@/control-plane/workspace" -import * as InstanceState from "@/effect/instance-state" -import { Database } from "@/storage/db" -import { asc } from "drizzle-orm" -import { and } from "drizzle-orm" -import { eq } from "drizzle-orm" -import { lte } from "drizzle-orm" -import { not } from "drizzle-orm" -import { or } from "drizzle-orm" -import { SyncEvent } from "@/sync" -import { EventTable } from "@/sync/event.sql" -import { NonNegativeInt } from "@/util/schema" -import { Effect, Schema } from "effect" -import { HttpApi, HttpApiBuilder, HttpApiEndpoint, HttpApiError, HttpApiGroup, OpenApi } from "effect/unstable/httpapi" -import { Authorization } from "./auth" - -const root = "/sync" -const ReplayEvent = Schema.Struct({ - id: Schema.String, - aggregateID: Schema.String, - seq: NonNegativeInt, - type: Schema.String, - data: Schema.Record(Schema.String, Schema.Unknown), -}).annotate({ identifier: "SyncReplayEvent" }) -const ReplayPayload = Schema.Struct({ - directory: Schema.String, - events: Schema.NonEmptyArray(ReplayEvent), -}).annotate({ identifier: "SyncReplayInput" }) -const ReplayResponse = Schema.Struct({ - sessionID: Schema.String, -}).annotate({ identifier: "SyncReplayResponse" }) -const HistoryPayload = Schema.Record(Schema.String, NonNegativeInt) -const HistoryEvent = Schema.Struct({ - id: Schema.String, - aggregate_id: Schema.String, - seq: Schema.Number, - type: Schema.String, - data: Schema.Record(Schema.String, Schema.Unknown), -}).annotate({ identifier: "SyncHistoryEvent" }) - -export const SyncPaths = { - start: `${root}/start`, - replay: `${root}/replay`, - history: `${root}/history`, -} as const - -export const SyncApi = HttpApi.make("sync") - .add( - HttpApiGroup.make("sync") - .add( - HttpApiEndpoint.post("start", SyncPaths.start, { - success: Schema.Boolean, - }).annotateMerge( - OpenApi.annotations({ - identifier: "sync.start", - summary: "Start workspace sync", - description: "Start sync loops for workspaces in the current project that have active sessions.", - }), - ), - HttpApiEndpoint.post("replay", SyncPaths.replay, { - payload: ReplayPayload, - success: ReplayResponse, - error: HttpApiError.BadRequest, - }).annotateMerge( - OpenApi.annotations({ - identifier: "sync.replay", - summary: "Replay sync events", - description: "Validate and replay a complete sync event history.", - }), - ), - HttpApiEndpoint.post("history", SyncPaths.history, { - payload: HistoryPayload, - success: Schema.Array(HistoryEvent), - error: HttpApiError.BadRequest, - }).annotateMerge( - OpenApi.annotations({ - identifier: "sync.history.list", - summary: "List sync events", - description: - "List sync events for all aggregates. Keys are aggregate IDs the client already knows about, values are the last known sequence ID. Events with seq > value are returned for those aggregates. Aggregates not listed in the input get their full history.", - }), - ), - ) - .annotateMerge( - OpenApi.annotations({ - title: "sync", - description: "Experimental HttpApi sync routes.", - }), - ) - .middleware(Authorization), - ) - .annotateMerge( - OpenApi.annotations({ - title: "opencode experimental HttpApi", - version: "0.0.1", - description: "Experimental HttpApi surface for selected instance routes.", - }), - ) - -export const syncHandlers = HttpApiBuilder.group(SyncApi, "sync", (handlers) => - Effect.gen(function* () { - const start = Effect.fn("SyncHttpApi.start")(function* () { - startWorkspaceSyncing((yield* InstanceState.context).project.id) - return true - }) - - const replay = Effect.fn("SyncHttpApi.replay")(function* (ctx: { payload: typeof ReplayPayload.Type }) { - const events: SyncEvent.SerializedEvent[] = ctx.payload.events.map((event) => ({ - id: event.id, - aggregateID: event.aggregateID, - seq: event.seq, - type: event.type, - data: { ...event.data }, - })) - SyncEvent.replayAll(events) - return { sessionID: events[0].aggregateID } - }) - - const history = Effect.fn("SyncHttpApi.history")(function* (ctx: { payload: typeof HistoryPayload.Type }) { - const exclude = Object.entries(ctx.payload) - return Database.use((db) => - db - .select() - .from(EventTable) - .where( - exclude.length > 0 - ? not(or(...exclude.map(([id, seq]) => and(eq(EventTable.aggregate_id, id), lte(EventTable.seq, seq))))!) - : undefined, - ) - .orderBy(asc(EventTable.seq)) - .all(), - ) - }) - - return handlers.handle("start", start).handle("replay", replay).handle("history", history) - }), -) diff --git a/packages/opencode/src/server/routes/instance/httpapi/tui.ts b/packages/opencode/src/server/routes/instance/httpapi/tui.ts deleted file mode 100644 index 2bcc740ddd..0000000000 --- a/packages/opencode/src/server/routes/instance/httpapi/tui.ts +++ /dev/null @@ -1,291 +0,0 @@ -import { Bus } from "@/bus" -import { TuiEvent } from "@/cli/cmd/tui/event" -import { SessionID } from "@/session/schema" -import { SessionTable } from "@/session/session.sql" -import * as Database from "@/storage/db" -import { eq } from "drizzle-orm" -import { Effect, Schema } from "effect" -import { HttpApi, HttpApiBuilder, HttpApiEndpoint, HttpApiError, HttpApiGroup, OpenApi } from "effect/unstable/httpapi" -import { nextTuiRequest, submitTuiResponse } from "../tui" -import { Authorization } from "./auth" - -const root = "/tui" -const CommandPayload = Schema.Struct({ command: Schema.String }).annotate({ identifier: "TuiCommandInput" }) -const TuiRequestPayload = Schema.Struct({ - path: Schema.String, - body: Schema.Unknown, -}).annotate({ identifier: "TuiRequest" }) -const TuiPublishPayload = Schema.Union([ - Schema.Struct({ type: Schema.Literal(TuiEvent.PromptAppend.type), properties: TuiEvent.PromptAppend.properties }), - Schema.Struct({ type: Schema.Literal(TuiEvent.CommandExecute.type), properties: TuiEvent.CommandExecute.properties }), - Schema.Struct({ type: Schema.Literal(TuiEvent.ToastShow.type), properties: TuiEvent.ToastShow.properties }), - Schema.Struct({ type: Schema.Literal(TuiEvent.SessionSelect.type), properties: TuiEvent.SessionSelect.properties }), -]).annotate({ identifier: "TuiEventInput" }) - -const commandAliases = { - session_new: "session.new", - session_share: "session.share", - session_interrupt: "session.interrupt", - session_compact: "session.compact", - messages_page_up: "session.page.up", - messages_page_down: "session.page.down", - messages_line_up: "session.line.up", - messages_line_down: "session.line.down", - messages_half_page_up: "session.half.page.up", - messages_half_page_down: "session.half.page.down", - messages_first: "session.first", - messages_last: "session.last", - agent_cycle: "agent.cycle", -} as const - -export const TuiPaths = { - appendPrompt: `${root}/append-prompt`, - openHelp: `${root}/open-help`, - openSessions: `${root}/open-sessions`, - openThemes: `${root}/open-themes`, - openModels: `${root}/open-models`, - submitPrompt: `${root}/submit-prompt`, - clearPrompt: `${root}/clear-prompt`, - executeCommand: `${root}/execute-command`, - showToast: `${root}/show-toast`, - publish: `${root}/publish`, - selectSession: `${root}/select-session`, - controlNext: `${root}/control/next`, - controlResponse: `${root}/control/response`, -} as const - -export const TuiApi = HttpApi.make("tui") - .add( - HttpApiGroup.make("tui") - .add( - HttpApiEndpoint.post("appendPrompt", TuiPaths.appendPrompt, { - payload: TuiEvent.PromptAppend.properties, - success: Schema.Boolean, - error: HttpApiError.BadRequest, - }).annotateMerge( - OpenApi.annotations({ - identifier: "tui.appendPrompt", - summary: "Append TUI prompt", - description: "Append prompt to the TUI.", - }), - ), - HttpApiEndpoint.post("openHelp", TuiPaths.openHelp, { success: Schema.Boolean }).annotateMerge( - OpenApi.annotations({ - identifier: "tui.openHelp", - summary: "Open help dialog", - description: "Open the help dialog in the TUI to display user assistance information.", - }), - ), - HttpApiEndpoint.post("openSessions", TuiPaths.openSessions, { success: Schema.Boolean }).annotateMerge( - OpenApi.annotations({ - identifier: "tui.openSessions", - summary: "Open sessions dialog", - description: "Open the session dialog.", - }), - ), - HttpApiEndpoint.post("openThemes", TuiPaths.openThemes, { success: Schema.Boolean }).annotateMerge( - OpenApi.annotations({ - identifier: "tui.openThemes", - summary: "Open themes dialog", - description: "Open the theme dialog.", - }), - ), - HttpApiEndpoint.post("openModels", TuiPaths.openModels, { success: Schema.Boolean }).annotateMerge( - OpenApi.annotations({ - identifier: "tui.openModels", - summary: "Open models dialog", - description: "Open the model dialog.", - }), - ), - HttpApiEndpoint.post("submitPrompt", TuiPaths.submitPrompt, { success: Schema.Boolean }).annotateMerge( - OpenApi.annotations({ - identifier: "tui.submitPrompt", - summary: "Submit TUI prompt", - description: "Submit the prompt.", - }), - ), - HttpApiEndpoint.post("clearPrompt", TuiPaths.clearPrompt, { success: Schema.Boolean }).annotateMerge( - OpenApi.annotations({ - identifier: "tui.clearPrompt", - summary: "Clear TUI prompt", - description: "Clear the prompt.", - }), - ), - HttpApiEndpoint.post("executeCommand", TuiPaths.executeCommand, { - payload: CommandPayload, - success: Schema.Boolean, - error: HttpApiError.BadRequest, - }).annotateMerge( - OpenApi.annotations({ - identifier: "tui.executeCommand", - summary: "Execute TUI command", - description: "Execute a TUI command.", - }), - ), - HttpApiEndpoint.post("showToast", TuiPaths.showToast, { - payload: TuiEvent.ToastShow.properties, - success: Schema.Boolean, - }).annotateMerge( - OpenApi.annotations({ - identifier: "tui.showToast", - summary: "Show TUI toast", - description: "Show a toast notification in the TUI.", - }), - ), - HttpApiEndpoint.post("publish", TuiPaths.publish, { - payload: TuiPublishPayload, - success: Schema.Boolean, - error: HttpApiError.BadRequest, - }).annotateMerge( - OpenApi.annotations({ - identifier: "tui.publish", - summary: "Publish TUI event", - description: "Publish a TUI event.", - }), - ), - HttpApiEndpoint.post("selectSession", TuiPaths.selectSession, { - payload: TuiEvent.SessionSelect.properties, - success: Schema.Boolean, - error: [HttpApiError.BadRequest, HttpApiError.NotFound], - }).annotateMerge( - OpenApi.annotations({ - identifier: "tui.selectSession", - summary: "Select session", - description: "Navigate the TUI to display the specified session.", - }), - ), - HttpApiEndpoint.get("controlNext", TuiPaths.controlNext, { success: TuiRequestPayload }).annotateMerge( - OpenApi.annotations({ - identifier: "tui.control.next", - summary: "Get next TUI request", - description: "Retrieve the next TUI request from the queue for processing.", - }), - ), - HttpApiEndpoint.post("controlResponse", TuiPaths.controlResponse, { - payload: Schema.Unknown, - success: Schema.Boolean, - }).annotateMerge( - OpenApi.annotations({ - identifier: "tui.control.response", - summary: "Submit TUI response", - description: "Submit a response to the TUI request queue to complete a pending request.", - }), - ), - ) - .annotateMerge(OpenApi.annotations({ title: "tui", description: "Experimental HttpApi TUI routes." })) - .middleware(Authorization), - ) - .annotateMerge( - OpenApi.annotations({ - title: "opencode experimental HttpApi", - version: "0.0.1", - description: "Experimental HttpApi surface for selected instance routes.", - }), - ) - -export const tuiHandlers = HttpApiBuilder.group(TuiApi, "tui", (handlers) => - Effect.gen(function* () { - const bus = yield* Bus.Service - const publishCommand = (command: typeof TuiEvent.CommandExecute.properties.Type.command) => - bus.publish(TuiEvent.CommandExecute, { command }) - - const appendPrompt = Effect.fn("TuiHttpApi.appendPrompt")(function* (ctx: { - payload: typeof TuiEvent.PromptAppend.properties.Type - }) { - yield* bus.publish(TuiEvent.PromptAppend, ctx.payload) - return true - }) - - const openHelp = Effect.fn("TuiHttpApi.openHelp")(function* () { - yield* publishCommand("help.show") - return true - }) - - const openSessions = Effect.fn("TuiHttpApi.openSessions")(function* () { - yield* publishCommand("session.list") - return true - }) - - const openThemes = Effect.fn("TuiHttpApi.openThemes")(function* () { - yield* publishCommand("session.list") - return true - }) - - const openModels = Effect.fn("TuiHttpApi.openModels")(function* () { - yield* publishCommand("model.list") - return true - }) - - const submitPrompt = Effect.fn("TuiHttpApi.submitPrompt")(function* () { - yield* publishCommand("prompt.submit") - return true - }) - - const clearPrompt = Effect.fn("TuiHttpApi.clearPrompt")(function* () { - yield* publishCommand("prompt.clear") - return true - }) - - const executeCommand = Effect.fn("TuiHttpApi.executeCommand")(function* (ctx: { - payload: typeof CommandPayload.Type - }) { - yield* publishCommand(commandAliases[ctx.payload.command as keyof typeof commandAliases] ?? ctx.payload.command) - return true - }) - - const showToast = Effect.fn("TuiHttpApi.showToast")(function* (ctx: { - payload: typeof TuiEvent.ToastShow.properties.Type - }) { - yield* bus.publish(TuiEvent.ToastShow, ctx.payload) - return true - }) - - const publish = Effect.fn("TuiHttpApi.publish")(function* (ctx: { payload: typeof TuiPublishPayload.Type }) { - if (ctx.payload.type === TuiEvent.PromptAppend.type) - yield* bus.publish(TuiEvent.PromptAppend, ctx.payload.properties) - if (ctx.payload.type === TuiEvent.CommandExecute.type) - yield* bus.publish(TuiEvent.CommandExecute, ctx.payload.properties) - if (ctx.payload.type === TuiEvent.ToastShow.type) yield* bus.publish(TuiEvent.ToastShow, ctx.payload.properties) - if (ctx.payload.type === TuiEvent.SessionSelect.type) - yield* bus.publish(TuiEvent.SessionSelect, ctx.payload.properties) - return true - }) - - const selectSession = Effect.fn("TuiHttpApi.selectSession")(function* (ctx: { - payload: typeof TuiEvent.SessionSelect.properties.Type - }) { - const row = yield* Effect.sync(() => - Database.use((db) => - db.select({ id: SessionTable.id }).from(SessionTable).where(eq(SessionTable.id, ctx.payload.sessionID)).get(), - ), - ) - if (!row) return yield* new HttpApiError.NotFound({}) - yield* bus.publish(TuiEvent.SessionSelect, ctx.payload) - return true - }) - - const controlNext = Effect.fn("TuiHttpApi.controlNext")(function* () { - return yield* Effect.promise(() => nextTuiRequest()) - }) - - const controlResponse = Effect.fn("TuiHttpApi.controlResponse")(function* (ctx: { payload: unknown }) { - submitTuiResponse(ctx.payload) - return true - }) - - return handlers - .handle("appendPrompt", appendPrompt) - .handle("openHelp", openHelp) - .handle("openSessions", openSessions) - .handle("openThemes", openThemes) - .handle("openModels", openModels) - .handle("submitPrompt", submitPrompt) - .handle("clearPrompt", clearPrompt) - .handle("executeCommand", executeCommand) - .handle("showToast", showToast) - .handle("publish", publish) - .handle("selectSession", selectSession) - .handle("controlNext", controlNext) - .handle("controlResponse", controlResponse) - }), -) diff --git a/packages/opencode/src/server/routes/instance/httpapi/workspace.ts b/packages/opencode/src/server/routes/instance/httpapi/workspace.ts deleted file mode 100644 index 1c5b4f87d8..0000000000 --- a/packages/opencode/src/server/routes/instance/httpapi/workspace.ts +++ /dev/null @@ -1,166 +0,0 @@ -import { listAdaptors } from "@/control-plane/adaptors" -import { Workspace } from "@/control-plane/workspace" -import { WorkspaceAdaptorEntry } from "@/control-plane/types" -import * as InstanceState from "@/effect/instance-state" -import { Instance } from "@/project/instance" -import { Effect, Schema, Struct } from "effect" -import { HttpApi, HttpApiBuilder, HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi" -import { Authorization } from "./auth" - -const root = "/experimental/workspace" -const CreatePayload = Schema.Struct(Struct.omit(Workspace.CreateInput.fields, ["projectID"])).annotate({ - identifier: "WorkspaceCreateInput", -}) -const SessionRestorePayload = Schema.Struct( - Struct.omit(Workspace.SessionRestoreInput.fields, ["workspaceID"]), -).annotate({ - identifier: "WorkspaceSessionRestoreInput", -}) -const SessionRestoreResponse = Schema.Struct({ - total: Schema.Number, -}).annotate({ identifier: "WorkspaceSessionRestoreResponse" }) - -export const WorkspacePaths = { - adaptors: `${root}/adaptor`, - list: root, - status: `${root}/status`, - remove: `${root}/:id`, - sessionRestore: `${root}/:id/session-restore`, -} as const - -export const WorkspaceApi = HttpApi.make("workspace") - .add( - HttpApiGroup.make("workspace") - .add( - HttpApiEndpoint.get("adaptors", WorkspacePaths.adaptors, { - success: Schema.Array(WorkspaceAdaptorEntry), - }).annotateMerge( - OpenApi.annotations({ - identifier: "experimental.workspace.adaptor.list", - summary: "List workspace adaptors", - description: "List all available workspace adaptors for the current project.", - }), - ), - HttpApiEndpoint.get("list", WorkspacePaths.list, { - success: Schema.Array(Workspace.Info), - }).annotateMerge( - OpenApi.annotations({ - identifier: "experimental.workspace.list", - summary: "List workspaces", - description: "List all workspaces.", - }), - ), - HttpApiEndpoint.post("create", WorkspacePaths.list, { - payload: CreatePayload, - success: Workspace.Info, - }).annotateMerge( - OpenApi.annotations({ - identifier: "experimental.workspace.create", - summary: "Create workspace", - description: "Create a workspace for the current project.", - }), - ), - HttpApiEndpoint.get("status", WorkspacePaths.status, { - success: Schema.Array(Workspace.ConnectionStatus), - }).annotateMerge( - OpenApi.annotations({ - identifier: "experimental.workspace.status", - summary: "Workspace status", - description: "Get connection status for workspaces in the current project.", - }), - ), - HttpApiEndpoint.delete("remove", WorkspacePaths.remove, { - params: { id: Workspace.Info.fields.id }, - success: Schema.UndefinedOr(Workspace.Info), - }).annotateMerge( - OpenApi.annotations({ - identifier: "experimental.workspace.remove", - summary: "Remove workspace", - description: "Remove an existing workspace.", - }), - ), - HttpApiEndpoint.post("sessionRestore", WorkspacePaths.sessionRestore, { - params: { id: Workspace.Info.fields.id }, - payload: SessionRestorePayload, - success: SessionRestoreResponse, - }).annotateMerge( - OpenApi.annotations({ - identifier: "experimental.workspace.sessionRestore", - summary: "Restore session into workspace", - description: "Replay a session's sync events into the target workspace in batches.", - }), - ), - ) - .annotateMerge( - OpenApi.annotations({ - title: "workspace", - description: "Experimental HttpApi workspace routes.", - }), - ) - .middleware(Authorization), - ) - .annotateMerge( - OpenApi.annotations({ - title: "opencode experimental HttpApi", - version: "0.0.1", - description: "Experimental HttpApi surface for selected instance routes.", - }), - ) - -export const workspaceHandlers = HttpApiBuilder.group(WorkspaceApi, "workspace", (handlers) => - Effect.gen(function* () { - const adaptors = Effect.fn("WorkspaceHttpApi.adaptors")(function* () { - const ctx = yield* InstanceState.context - return yield* Effect.promise(() => listAdaptors(ctx.project.id)) - }) - - const list = Effect.fn("WorkspaceHttpApi.list")(function* () { - return Workspace.list((yield* InstanceState.context).project) - }) - - const create = Effect.fn("WorkspaceHttpApi.create")(function* (ctx: { payload: typeof CreatePayload.Type }) { - const instance = yield* InstanceState.context - return yield* Effect.promise(() => - Instance.restore(instance, () => - Workspace.create({ - ...ctx.payload, - projectID: instance.project.id, - }), - ), - ) - }) - - const status = Effect.fn("WorkspaceHttpApi.status")(function* () { - const ids = new Set(Workspace.list((yield* InstanceState.context).project).map((item) => item.id)) - return Workspace.status().filter((item) => ids.has(item.workspaceID)) - }) - - const remove = Effect.fn("WorkspaceHttpApi.remove")(function* (ctx: { params: { id: Workspace.Info["id"] } }) { - const instance = yield* InstanceState.context - return yield* Effect.promise(() => Instance.restore(instance, () => Workspace.remove(ctx.params.id))) - }) - - const sessionRestore = Effect.fn("WorkspaceHttpApi.sessionRestore")(function* (ctx: { - params: { id: Workspace.Info["id"] } - payload: typeof SessionRestorePayload.Type - }) { - const instance = yield* InstanceState.context - return yield* Effect.promise(() => - Instance.restore(instance, () => - Workspace.sessionRestore({ - workspaceID: ctx.params.id, - sessionID: ctx.payload.sessionID, - }), - ), - ) - }) - - return handlers - .handle("adaptors", adaptors) - .handle("list", list) - .handle("create", create) - .handle("status", status) - .handle("remove", remove) - .handle("sessionRestore", sessionRestore) - }), -) diff --git a/packages/opencode/src/server/server.ts b/packages/opencode/src/server/server.ts index 92d844fbfe..40e709edd4 100644 --- a/packages/opencode/src/server/server.ts +++ b/packages/opencode/src/server/server.ts @@ -17,6 +17,7 @@ import { WorkspaceRouterMiddleware } from "./workspace" import { InstanceMiddleware } from "./routes/instance/middleware" import { WorkspaceRoutes } from "./routes/control/workspace" import { ExperimentalHttpApiServer } from "./routes/instance/httpapi/server" +import * as ServerBackend from "./backend" // @ts-ignore This global is needed to prevent ai-sdk from logging warnings to stdout https://github.com/vercel/ai/blob/2dc67e0ef538307f21368db32d5a12345d98831b/packages/ai/src/logger/log-warnings.ts#L85 globalThis.AI_SDK_LOG_WARNINGS = false @@ -37,13 +38,38 @@ type ServerApp = { request(input: string | URL | Request, init?: RequestInit): Response | Promise } -const DefaultHono = lazy(() => createHono({})) -const DefaultHttpApi = lazy(() => createHttpApi()) -export const Default = () => (Flag.OPENCODE_EXPERIMENTAL_HTTPAPI ? DefaultHttpApi() : DefaultHono()) +const DefaultHono = lazy(() => withBackend({ backend: "hono", reason: "stable" }, createHono({}, { backend: "hono", reason: "stable" }))) +const DefaultHttpApi = lazy(() => createDefaultHttpApi()) + +function select() { + return ServerBackend.select() +} + +export const backend = select + +export const Default = () => { + const selected = select() + return selected.backend === "effect-httpapi" ? DefaultHttpApi() : DefaultHono() +} function create(opts: { cors?: string[] }) { - if (Flag.OPENCODE_EXPERIMENTAL_HTTPAPI) return createHttpApi() - return createHono(opts) + const selected = select() + return selected.backend === "effect-httpapi" + ? withBackend(selected, createHttpApi()) + : withBackend(selected, createHono(opts, selected)) +} + +export function Legacy(opts: { cors?: string[] } = {}) { + return withBackend({ backend: "hono", reason: "explicit" }, createHono(opts, { backend: "hono", reason: "explicit" })) +} + +function createDefaultHttpApi() { + return withBackend(select(), createHttpApi()) +} + +function withBackend(selection: ServerBackend.Selection, built: T) { + log.info("server backend selected", ServerBackend.attributes(selection)) + return built } function createHttpApi() { @@ -60,11 +86,12 @@ function createHttpApi() { } } -function createHono(opts: { cors?: string[] }) { +function createHono(opts: { cors?: string[] }, selection: ServerBackend.Selection = ServerBackend.force(select(), "hono")) { + const backendAttributes = ServerBackend.attributes(selection) const app = new Hono() .onError(ErrorMiddleware) .use(AuthMiddleware) - .use(LoggerMiddleware) + .use(LoggerMiddleware(backendAttributes)) .use(CompressionMiddleware) .use(CorsMiddleware(opts)) .route("/global", GlobalRoutes()) diff --git a/packages/opencode/src/server/workspace.ts b/packages/opencode/src/server/workspace.ts index 5117fb8fa9..29b1ab9869 100644 --- a/packages/opencode/src/server/workspace.ts +++ b/packages/opencode/src/server/workspace.ts @@ -21,7 +21,7 @@ const RULES: Array = [ { method: "GET", path: "/session", action: "local" }, ] -function local(method: string, path: string) { +export function isLocalWorkspaceRoute(method: string, path: string) { for (const rule of RULES) { if (rule.method && rule.method !== method) continue const match = rule.exact ? path === rule.path : path === rule.path || path.startsWith(rule.path + "/") @@ -30,7 +30,7 @@ function local(method: string, path: string) { return false } -function getSessionID(url: URL) { +export function getWorkspaceRouteSessionID(url: URL) { if (url.pathname === "/session/status") return null const id = url.pathname.match(/^\/session\/([^/]+)(?:\/|$)/)?.[1] @@ -39,8 +39,17 @@ function getSessionID(url: URL) { return SessionID.make(id) } +export function workspaceProxyURL(target: string | URL, requestURL: URL) { + const proxyURL = new URL(target) + proxyURL.pathname = `${proxyURL.pathname.replace(/\/$/, "")}${requestURL.pathname}` + proxyURL.search = requestURL.search + proxyURL.hash = requestURL.hash + proxyURL.searchParams.delete("workspace") + return proxyURL +} + async function getSessionWorkspace(url: URL) { - const id = getSessionID(url) + const id = getWorkspaceRouteSessionID(url) if (!id) return null const session = await AppRuntime.runPromise( @@ -73,7 +82,7 @@ export function WorkspaceRouterMiddleware(upgrade: UpgradeWebSocket): Middleware }) } - if (local(c.req.method, url.pathname)) { + if (isLocalWorkspaceRoute(c.req.method, url.pathname)) { // No instance provided because we are serving cached data; there // is no instance to work with return next() @@ -96,11 +105,7 @@ export function WorkspaceRouterMiddleware(upgrade: UpgradeWebSocket): Middleware }) } - const proxyURL = new URL(target.url) - proxyURL.pathname = `${proxyURL.pathname.replace(/\/$/, "")}${url.pathname}` - proxyURL.search = url.search - proxyURL.hash = url.hash - proxyURL.searchParams.delete("workspace") + const proxyURL = workspaceProxyURL(target.url, url) log.info("workspace proxy forwarding", { workspaceID, diff --git a/packages/opencode/src/session/message-v2.ts b/packages/opencode/src/session/message-v2.ts index 6b64b02319..b1a6ff4036 100644 --- a/packages/opencode/src/session/message-v2.ts +++ b/packages/opencode/src/session/message-v2.ts @@ -42,7 +42,7 @@ export const OutputLengthError = namedSchemaError("MessageOutputLengthError", {} export const AbortedError = namedSchemaError("MessageAbortedError", { message: Schema.String }) export const StructuredOutputError = namedSchemaError("StructuredOutputError", { message: Schema.String, - retries: Schema.Number, + retries: NonNegativeInt, }) export const AuthError = namedSchemaError("ProviderAuthError", { providerID: Schema.String, @@ -50,7 +50,7 @@ export const AuthError = namedSchemaError("ProviderAuthError", { }) export const APIError = namedSchemaError("APIError", { message: Schema.String, - statusCode: Schema.optional(Schema.Number), + statusCode: Schema.optional(NonNegativeInt), isRetryable: Schema.Boolean, responseHeaders: Schema.optional(Schema.Record(Schema.String, Schema.String)), responseBody: Schema.optional(Schema.String), @@ -116,8 +116,8 @@ export const TextPart = Schema.Struct({ ignored: Schema.optional(Schema.Boolean), time: Schema.optional( Schema.Struct({ - start: Schema.Number, - end: Schema.optional(Schema.Number), + start: NonNegativeInt, + end: Schema.optional(NonNegativeInt), }), ), metadata: Schema.optional(Schema.Record(Schema.String, Schema.Any)), @@ -132,8 +132,8 @@ export const ReasoningPart = Schema.Struct({ text: Schema.String, metadata: Schema.optional(Schema.Record(Schema.String, Schema.Any)), time: Schema.Struct({ - start: Schema.Number, - end: Schema.optional(Schema.Number), + start: NonNegativeInt, + end: Schema.optional(NonNegativeInt), }), }) .annotate({ identifier: "ReasoningPart" }) @@ -143,8 +143,8 @@ export type ReasoningPart = Types.DeepMutable ({ zod: zod(s) }))) @@ -201,8 +201,8 @@ export const AgentPart = Schema.Struct({ source: Schema.optional( Schema.Struct({ value: Schema.String, - start: Schema.Int, - end: Schema.Int, + start: NonNegativeInt, + end: NonNegativeInt, }), ), }) @@ -242,11 +242,10 @@ export type SubtaskPart = Types.DeepMutable +// Effect Schema for the same union — used by HttpApi OpenAPI generation. +const AssistantErrorSchema = Schema.Union([ + AuthError.EffectSchema, + Schema.Struct({ name: Schema.Literal("UnknownError"), data: Schema.Struct({ message: Schema.String }) }).annotate({ identifier: "UnknownError" }), + OutputLengthError.EffectSchema, + AbortedError.EffectSchema, + StructuredOutputError.EffectSchema, + ContextOverflowError.EffectSchema, + APIError.EffectSchema, +]).annotate({ discriminator: "name" }) + // ── Prompt input schemas ───────────────────────────────────────────────────── // // Consumers of `SessionPrompt.PromptInput.parts` send part drafts without the @@ -477,8 +485,8 @@ export const TextPartInput = Schema.Struct({ ignored: Schema.optional(Schema.Boolean), time: Schema.optional( Schema.Struct({ - start: Schema.Number, - end: Schema.optional(Schema.Number), + start: NonNegativeInt, + end: Schema.optional(NonNegativeInt), }), ), metadata: Schema.optional(Schema.Record(Schema.String, Schema.Any)), @@ -506,8 +514,8 @@ export const AgentPartInput = Schema.Struct({ source: Schema.optional( Schema.Struct({ value: Schema.String, - start: Schema.Int, - end: Schema.Int, + start: NonNegativeInt, + end: NonNegativeInt, }), ), }) @@ -537,10 +545,10 @@ export const Assistant = Schema.Struct({ ...messageBase, role: Schema.Literal("assistant"), time: Schema.Struct({ - created: Schema.Number, - completed: Schema.optional(Schema.Number), + created: NonNegativeInt, + completed: Schema.optional(NonNegativeInt), }), - error: Schema.optional(Schema.Any.annotate({ [ZodOverride]: AssistantErrorZod })), + error: Schema.optional(AssistantErrorSchema), parentID: MessageID, modelID: ModelID, providerID: ProviderID, @@ -554,15 +562,15 @@ export const Assistant = Schema.Struct({ root: Schema.String, }), summary: Schema.optional(Schema.Boolean), - cost: Schema.Number, + cost: Schema.Finite, tokens: Schema.Struct({ - total: Schema.optional(Schema.Number), - input: Schema.Number, - output: Schema.Number, - reasoning: Schema.Number, + total: Schema.optional(NonNegativeInt), + input: NonNegativeInt, + output: NonNegativeInt, + reasoning: NonNegativeInt, cache: Schema.Struct({ - read: Schema.Number, - write: Schema.Number, + read: NonNegativeInt, + write: NonNegativeInt, }), }), structured: Schema.optional(Schema.Any), @@ -594,7 +602,7 @@ const RemovedEventSchema = Schema.Struct({ const PartUpdatedEventSchema = Schema.Struct({ sessionID: SessionID, part: _Part, - time: Schema.Number, + time: NonNegativeInt, }) const PartRemovedEventSchema = Schema.Struct({ @@ -651,7 +659,7 @@ export type WithParts = { const Cursor = Schema.Struct({ id: MessageID, - time: Schema.Number, + time: Schema.Finite.check(Schema.isGreaterThanOrEqualTo(0)), }) type Cursor = typeof Cursor.Type diff --git a/packages/opencode/src/session/message.ts b/packages/opencode/src/session/message.ts index b1b2453431..9d67c48686 100644 --- a/packages/opencode/src/session/message.ts +++ b/packages/opencode/src/session/message.ts @@ -2,7 +2,7 @@ import { Schema } from "effect" import { SessionID } from "./schema" import { ModelID, ProviderID } from "../provider/schema" import { zod } from "@/util/effect-zod" -import { withStatics } from "@/util/schema" +import { NonNegativeInt, withStatics } from "@/util/schema" import { namedSchemaError } from "@/util/named-schema-error" export const OutputLengthError = namedSchemaError("MessageOutputLengthError", {}) @@ -33,7 +33,7 @@ const UnknownErrorEffect = Schema.Struct({ export const ToolCall = Schema.Struct({ state: Schema.Literal("call"), - step: Schema.optional(Schema.Number), + step: Schema.optional(NonNegativeInt), toolCallId: Schema.String, toolName: Schema.String, args: Schema.Unknown, @@ -44,7 +44,7 @@ export type ToolCall = Schema.Schema.Type export const ToolPartialCall = Schema.Struct({ state: Schema.Literal("partial-call"), - step: Schema.optional(Schema.Number), + step: Schema.optional(NonNegativeInt), toolCallId: Schema.String, toolName: Schema.String, args: Schema.Unknown, @@ -55,7 +55,7 @@ export type ToolPartialCall = Schema.Schema.Type export const ToolResult = Schema.Struct({ state: Schema.Literal("result"), - step: Schema.optional(Schema.Number), + step: Schema.optional(NonNegativeInt), toolCallId: Schema.String, toolName: Schema.String, args: Schema.Unknown, @@ -141,8 +141,8 @@ export const Info = Schema.Struct({ parts: Schema.Array(MessagePart), metadata: Schema.Struct({ time: Schema.Struct({ - created: Schema.Number, - completed: Schema.optional(Schema.Number), + created: NonNegativeInt, + completed: Schema.optional(NonNegativeInt), }), error: Schema.optional(Schema.Union([AuthErrorEffect, UnknownErrorEffect, OutputLengthErrorEffect])), sessionID: SessionID, @@ -153,8 +153,8 @@ export const Info = Schema.Struct({ title: Schema.String, snapshot: Schema.optional(Schema.String), time: Schema.Struct({ - start: Schema.Number, - end: Schema.Number, + start: NonNegativeInt, + end: NonNegativeInt, }), }), [Schema.Record(Schema.String, Schema.Unknown)], @@ -169,15 +169,15 @@ export const Info = Schema.Struct({ cwd: Schema.String, root: Schema.String, }), - cost: Schema.Number, + cost: Schema.Finite, summary: Schema.optional(Schema.Boolean), tokens: Schema.Struct({ - input: Schema.Number, - output: Schema.Number, - reasoning: Schema.Number, + input: NonNegativeInt, + output: NonNegativeInt, + reasoning: NonNegativeInt, cache: Schema.Struct({ - read: Schema.Number, - write: Schema.Number, + read: NonNegativeInt, + write: NonNegativeInt, }), }), }), diff --git a/packages/opencode/src/session/session.ts b/packages/opencode/src/session/session.ts index c376c8d1a9..1be5dfffd4 100644 --- a/packages/opencode/src/session/session.ts +++ b/packages/opencode/src/session/session.ts @@ -38,7 +38,7 @@ import { Permission } from "@/permission" import { Global } from "@opencode-ai/core/global" import { Effect, Layer, Option, Context, Schema, Types } from "effect" import { zod } from "@/util/effect-zod" -import { optionalOmitUndefined, withStatics } from "@/util/schema" +import { NonNegativeInt, optionalOmitUndefined, withStatics } from "@/util/schema" const log = Log.create({ service: "session" }) @@ -132,9 +132,9 @@ function sessionPath(worktree: string, cwd: string) { } const Summary = Schema.Struct({ - additions: Schema.Number, - deletions: Schema.Number, - files: Schema.Number, + additions: NonNegativeInt, + deletions: NonNegativeInt, + files: NonNegativeInt, diffs: optionalOmitUndefined(Schema.Array(Snapshot.FileDiff)), }) @@ -143,10 +143,10 @@ const Share = Schema.Struct({ }) const Time = Schema.Struct({ - created: Schema.Number, - updated: Schema.Number, - compacting: optionalOmitUndefined(Schema.Number), - archived: optionalOmitUndefined(Schema.Number), + created: NonNegativeInt, + updated: NonNegativeInt, + compacting: optionalOmitUndefined(NonNegativeInt), + archived: optionalOmitUndefined(NonNegativeInt), }) const Revert = Schema.Struct({ @@ -215,7 +215,7 @@ export const SetTitleInput = Schema.Struct({ sessionID: SessionID, title: Schema ) export const SetArchivedInput = Schema.Struct({ sessionID: SessionID, - time: Schema.optional(Schema.Number), + time: Schema.optional(NonNegativeInt), }).pipe(withStatics((s) => ({ zod: zod(s) }))) export const SetPermissionInput = Schema.Struct({ sessionID: SessionID, @@ -228,7 +228,7 @@ export const SetRevertInput = Schema.Struct({ }).pipe(withStatics((s) => ({ zod: zod(s) }))) export const MessagesInput = Schema.Struct({ sessionID: SessionID, - limit: Schema.optional(Schema.Number), + limit: Schema.optional(NonNegativeInt), }).pipe(withStatics((s) => ({ zod: zod(s) }))) const CreatedEventSchema = Schema.Struct({ @@ -241,10 +241,10 @@ const UpdatedShare = Schema.Struct({ }) const UpdatedTime = Schema.Struct({ - created: Schema.optional(Schema.NullOr(Schema.Number)), - updated: Schema.optional(Schema.NullOr(Schema.Number)), - compacting: Schema.optional(Schema.NullOr(Schema.Number)), - archived: Schema.optional(Schema.NullOr(Schema.Number)), + created: Schema.optional(Schema.NullOr(NonNegativeInt)), + updated: Schema.optional(Schema.NullOr(NonNegativeInt)), + compacting: Schema.optional(Schema.NullOr(NonNegativeInt)), + archived: Schema.optional(Schema.NullOr(NonNegativeInt)), }) const UpdatedInfo = Schema.Struct({ diff --git a/packages/opencode/src/session/status.ts b/packages/opencode/src/session/status.ts index fdd561b4ae..a0e57afc22 100644 --- a/packages/opencode/src/session/status.ts +++ b/packages/opencode/src/session/status.ts @@ -3,7 +3,7 @@ import { Bus } from "@/bus" import { InstanceState } from "@/effect/instance-state" import { SessionID } from "./schema" import { zod } from "@/util/effect-zod" -import { withStatics } from "@/util/schema" +import { NonNegativeInt, withStatics } from "@/util/schema" import { Effect, Layer, Context, Schema } from "effect" import z from "zod" @@ -13,9 +13,9 @@ export const Info = Schema.Union([ }), Schema.Struct({ type: Schema.Literal("retry"), - attempt: Schema.Number, + attempt: NonNegativeInt, message: Schema.String, - next: Schema.Number, + next: NonNegativeInt, }), Schema.Struct({ type: Schema.Literal("busy"), diff --git a/packages/opencode/src/snapshot/index.ts b/packages/opencode/src/snapshot/index.ts index cd28377aa7..ea30f5afc7 100644 --- a/packages/opencode/src/snapshot/index.ts +++ b/packages/opencode/src/snapshot/index.ts @@ -10,7 +10,7 @@ import { Hash } from "@opencode-ai/core/util/hash" import { Config } from "@/config/config" import { Global } from "@opencode-ai/core/global" import * as Log from "@opencode-ai/core/util/log" -import { withStatics } from "@/util/schema" +import { NonNegativeInt, withStatics } from "@/util/schema" import { zod } from "@/util/effect-zod" export const Patch = Schema.Struct({ @@ -22,8 +22,8 @@ export type Patch = typeof Patch.Type export const FileDiff = Schema.Struct({ file: Schema.String, patch: Schema.String, - additions: Schema.Number, - deletions: Schema.Number, + additions: NonNegativeInt, + deletions: NonNegativeInt, status: Schema.optional(Schema.Literals(["added", "deleted", "modified"])), }) .annotate({ identifier: "SnapshotFileDiff" }) diff --git a/packages/opencode/src/storage/storage.ts b/packages/opencode/src/storage/storage.ts index af18d88b34..5b2df1e899 100644 --- a/packages/opencode/src/storage/storage.ts +++ b/packages/opencode/src/storage/storage.ts @@ -5,6 +5,7 @@ import { NamedError } from "@opencode-ai/core/util/error" import z from "zod" import { AppFileSystem } from "@opencode-ai/core/filesystem" import { Effect, Exit, Layer, Option, RcMap, Schema, Context, TxReentrantLock } from "effect" +import { NonNegativeInt } from "@/util/schema" import { Git } from "@/git" const log = Log.create({ service: "storage" }) @@ -41,8 +42,8 @@ const MessageFile = Schema.Struct({ }) const DiffFile = Schema.Struct({ - additions: Schema.Number, - deletions: Schema.Number, + additions: NonNegativeInt, + deletions: NonNegativeInt, }) const SummaryFile = Schema.Struct({ diff --git a/packages/opencode/src/sync/index.ts b/packages/opencode/src/sync/index.ts index ad899531ba..67bc9b9e7c 100644 --- a/packages/opencode/src/sync/index.ts +++ b/packages/opencode/src/sync/index.ts @@ -294,4 +294,20 @@ export function payloads() { .toArray() } +export function effectPayloads() { + return registry + .entries() + .map(([type, def]) => + EffectSchema.Struct({ + type: EffectSchema.Literal("sync"), + name: EffectSchema.Literal(type), + id: EffectSchema.String, + seq: EffectSchema.Finite, + aggregateID: EffectSchema.Literal(def.aggregate), + data: def.schema, + }).annotate({ identifier: `SyncEvent.${type}` }), + ) + .toArray() +} + export * as SyncEvent from "." diff --git a/packages/opencode/src/tool/bash.ts b/packages/opencode/src/tool/bash.ts index 82f6e5aaeb..c32c3963ba 100644 --- a/packages/opencode/src/tool/bash.ts +++ b/packages/opencode/src/tool/bash.ts @@ -1,4 +1,5 @@ import { Schema } from "effect" +import { PositiveInt } from "@/util/schema" import os from "os" import { createWriteStream } from "node:fs" import * as Tool from "./tool" @@ -53,7 +54,7 @@ const SWITCHES = new Set(["-confirm", "-debug", "-force", "-nonewline", "-recurs export const Parameters = Schema.Struct({ command: Schema.String.annotate({ description: "The command to execute" }), - timeout: Schema.optional(Schema.Number).annotate({ description: "Optional timeout in milliseconds" }), + timeout: Schema.optional(PositiveInt).annotate({ description: "Optional timeout in milliseconds" }), workdir: Schema.optional(Schema.String).annotate({ description: `The working directory to run the command in. Defaults to the current directory. Use this instead of 'cd' commands.`, }), diff --git a/packages/opencode/src/tool/codesearch.ts b/packages/opencode/src/tool/codesearch.ts index e10d21175e..2753732dd0 100644 --- a/packages/opencode/src/tool/codesearch.ts +++ b/packages/opencode/src/tool/codesearch.ts @@ -9,7 +9,7 @@ export const Parameters = Schema.Struct({ description: "Search query to find relevant context for APIs, Libraries, and SDKs. For example, 'React useState hook examples', 'Python pandas dataframe filtering', 'Express.js middleware', 'Next js partial prerendering configuration'", }), - tokensNum: Schema.Number.check(Schema.isGreaterThanOrEqualTo(1000)) + tokensNum: Schema.Int.check(Schema.isGreaterThanOrEqualTo(1000)) .check(Schema.isLessThanOrEqualTo(50000)) .pipe(Schema.optional, Schema.withDecodingDefault(Effect.succeed(5000))) .annotate({ diff --git a/packages/opencode/src/tool/lsp.ts b/packages/opencode/src/tool/lsp.ts index 828beeefef..3a555c2ce8 100644 --- a/packages/opencode/src/tool/lsp.ts +++ b/packages/opencode/src/tool/lsp.ts @@ -23,12 +23,12 @@ const operations = [ export const Parameters = Schema.Struct({ operation: Schema.Literals(operations).annotate({ description: "The LSP operation to perform" }), filePath: Schema.String.annotate({ description: "The absolute or relative path to the file" }), - line: Schema.Number.check(Schema.isInt()) - .check(Schema.isGreaterThanOrEqualTo(1)) - .annotate({ description: "The line number (1-based, as shown in editors)" }), - character: Schema.Number.check(Schema.isInt()) - .check(Schema.isGreaterThanOrEqualTo(1)) - .annotate({ description: "The character offset (1-based, as shown in editors)" }), + line: Schema.Int.check(Schema.isGreaterThanOrEqualTo(1)).annotate({ + description: "The line number (1-based, as shown in editors)", + }), + character: Schema.Int.check(Schema.isGreaterThanOrEqualTo(1)).annotate({ + description: "The character offset (1-based, as shown in editors)", + }), query: Schema.optional(Schema.String).annotate({ description: "Search query for workspaceSymbol. Empty string requests all symbols.", }), diff --git a/packages/opencode/src/tool/read.ts b/packages/opencode/src/tool/read.ts index 0f528b8f65..fb386f5790 100644 --- a/packages/opencode/src/tool/read.ts +++ b/packages/opencode/src/tool/read.ts @@ -1,4 +1,5 @@ import { Effect, Option, Schema, Scope } from "effect" +import { NonNegativeInt } from "@/util/schema" import { createReadStream } from "fs" import * as path from "path" import { createInterface } from "readline" @@ -25,10 +26,10 @@ const SAMPLE_BYTES = 4096 // unchanged; purely CLI-facing uses must now send numbers rather than strings. export const Parameters = Schema.Struct({ filePath: Schema.String.annotate({ description: "The absolute path to the file or directory to read" }), - offset: Schema.optional(Schema.Number).annotate({ + offset: Schema.optional(NonNegativeInt).annotate({ description: "The line number to start reading from (1-indexed)", }), - limit: Schema.optional(Schema.Number).annotate({ + limit: Schema.optional(NonNegativeInt).annotate({ description: "The maximum number of lines to read (defaults to 2000)", }), }) diff --git a/packages/opencode/src/util/named-schema-error.ts b/packages/opencode/src/util/named-schema-error.ts index e144f2f906..d9b92a23cc 100644 --- a/packages/opencode/src/util/named-schema-error.ts +++ b/packages/opencode/src/util/named-schema-error.ts @@ -26,10 +26,17 @@ export function namedSchemaError class NamedSchemaError extends Error { static readonly Schema = wire + static readonly EffectSchema = effectSchema static readonly tag = tag public static isInstance(input: unknown): input is NamedSchemaError { return typeof input === "object" && input !== null && "name" in input && (input as { name: unknown }).name === tag diff --git a/packages/opencode/src/util/schema.ts b/packages/opencode/src/util/schema.ts index 2a6c02349f..380225316c 100644 --- a/packages/opencode/src/util/schema.ts +++ b/packages/opencode/src/util/schema.ts @@ -11,6 +11,8 @@ export const PositiveInt = Schema.Int.check(Schema.isGreaterThan(0)) */ export const NonNegativeInt = Schema.Int.check(Schema.isGreaterThanOrEqualTo(0)) + + /** * Optional public JSON field that can hold explicit `undefined` on the type * side but encodes it as an omitted key, matching legacy `JSON.stringify`. diff --git a/packages/opencode/src/v2/session-entry.ts b/packages/opencode/src/v2/session-entry.ts index b261d8b5b2..66576a688e 100644 --- a/packages/opencode/src/v2/session-entry.ts +++ b/packages/opencode/src/v2/session-entry.ts @@ -1,4 +1,5 @@ import { Schema } from "effect" +import { NonNegativeInt } from "@/util/schema" import { SessionEvent } from "./session-event" export const ID = SessionEvent.ID @@ -105,7 +106,7 @@ export class AssistantReasoning extends Schema.Class("Sessio }) {} export class AssistantRetry extends Schema.Class("Session.Entry.Assistant.Retry")({ - attempt: Schema.Number, + attempt: NonNegativeInt, error: SessionEvent.RetryError, time: Schema.Struct({ created: Schema.DateTimeUtc, @@ -132,14 +133,14 @@ export class Assistant extends Schema.Class("Session.Entry.Assistant" type: Schema.Literal("assistant"), content: AssistantContent.pipe(Schema.Array), retries: AssistantRetry.pipe(Schema.Array, Schema.optional), - cost: Schema.Number.pipe(Schema.optional), + cost: Schema.Finite.pipe(Schema.optional), tokens: Schema.Struct({ - input: Schema.Number, - output: Schema.Number, - reasoning: Schema.Number, + input: NonNegativeInt, + output: NonNegativeInt, + reasoning: NonNegativeInt, cache: Schema.Struct({ - read: Schema.Number, - write: Schema.Number, + read: NonNegativeInt, + write: NonNegativeInt, }), }).pipe(Schema.optional), error: Schema.String.pipe(Schema.optional), diff --git a/packages/opencode/src/v2/session-event.ts b/packages/opencode/src/v2/session-event.ts index f922becf3a..aaf71c8dcc 100644 --- a/packages/opencode/src/v2/session-event.ts +++ b/packages/opencode/src/v2/session-event.ts @@ -1,5 +1,5 @@ import { Identifier } from "@/id/id" -import { withStatics } from "@/util/schema" +import { NonNegativeInt, withStatics } from "@/util/schema" import * as DateTime from "effect/DateTime" import { Schema } from "effect" @@ -25,8 +25,8 @@ export namespace SessionEvent { } export class Source extends Schema.Class("Session.Event.Source")({ - start: Schema.Number, - end: Schema.Number, + start: NonNegativeInt, + end: NonNegativeInt, text: Schema.String, }) {} @@ -55,7 +55,7 @@ export namespace SessionEvent { export class RetryError extends Schema.Class("Session.Event.Retry.Error")({ message: Schema.String, - statusCode: Schema.Number.pipe(Schema.optional), + statusCode: NonNegativeInt.pipe(Schema.optional), isRetryable: Schema.Boolean, responseHeaders: Schema.Record(Schema.String, Schema.String).pipe(Schema.optional), responseBody: Schema.String.pipe(Schema.optional), @@ -123,14 +123,14 @@ export namespace SessionEvent { ...Base, type: Schema.Literal("step.ended"), reason: Schema.String, - cost: Schema.Number, + cost: Schema.Finite, tokens: Schema.Struct({ - input: Schema.Number, - output: Schema.Number, - reasoning: Schema.Number, + input: NonNegativeInt, + output: NonNegativeInt, + reasoning: NonNegativeInt, cache: Schema.Struct({ - read: Schema.Number, - write: Schema.Number, + read: NonNegativeInt, + write: NonNegativeInt, }), }), }) { @@ -395,7 +395,7 @@ export namespace SessionEvent { export class Retried extends Schema.Class("Session.Event.Retried")({ ...Base, type: Schema.Literal("retried"), - attempt: Schema.Number, + attempt: NonNegativeInt, error: RetryError, }) { static create(input: BaseInput & { attempt: number; error: RetryError }) { diff --git a/packages/opencode/test/server/httpapi-bridge.test.ts b/packages/opencode/test/server/httpapi-bridge.test.ts index 7a7105dfaa..a0324cce39 100644 --- a/packages/opencode/test/server/httpapi-bridge.test.ts +++ b/packages/opencode/test/server/httpapi-bridge.test.ts @@ -1,9 +1,9 @@ import { afterEach, describe, expect, test } from "bun:test" import { Flag } from "@opencode-ai/core/flag/flag" import { Instance } from "../../src/project/instance" -import { ControlPaths } from "../../src/server/routes/instance/httpapi/control" -import { FileApi, FilePaths } from "../../src/server/routes/instance/httpapi/file" -import { GlobalPaths } from "../../src/server/routes/instance/httpapi/global" +import { ControlPaths } from "../../src/server/routes/instance/httpapi/groups/control" +import { FileApi, FilePaths } from "../../src/server/routes/instance/httpapi/groups/file" +import { GlobalPaths } from "../../src/server/routes/instance/httpapi/groups/global" import { PublicApi } from "../../src/server/routes/instance/httpapi/public" import { Server } from "../../src/server/server" import * as Log from "@opencode-ai/core/util/log" @@ -57,16 +57,32 @@ function openApiParameters(spec: { paths: Record>> }) { +function openApiRequestBodies(spec: OpenApiSpec) { return Object.fromEntries( Object.entries(spec.paths).flatMap(([path, item]) => methods .filter((method) => item[method]) - .map((method) => [`${method.toUpperCase()} ${path}`, requestBodyKey(item[method]?.requestBody)]), + .map((method) => [`${method.toUpperCase()} ${path}`, requestBodyKey(spec, item[method]?.requestBody)]), ), ) } +type OpenApiSpec = { + components?: { + schemas?: Record + } + paths: Record>> +} + +type OpenApiSchema = { + $ref?: string + allOf?: unknown[] + anyOf?: unknown[] + oneOf?: unknown[] + properties?: Record + type?: string | string[] +} + type Operation = { parameters?: unknown[] responses?: unknown @@ -74,7 +90,7 @@ type Operation = { } type RequestBody = { - content?: Record + content?: Record required?: boolean } @@ -97,17 +113,27 @@ function parameterSchema(input: { return param.schema } -function requestBodyKey(body: unknown) { +function requestBodyKey(spec: OpenApiSpec, body: unknown) { if (!body || typeof body !== "object" || !("content" in body)) return "" const requestBody = body as RequestBody return JSON.stringify({ required: requestBody.required === true, content: Object.entries(requestBody.content ?? {}) - .map(([type, value]) => [type, value.schema?.$ref ?? value.schema?.type ?? "inline"]) + .map(([type, value]) => [type, requestBodySchemaKind(spec, value.schema)]) .sort(), }) } +function requestBodySchemaKind(spec: OpenApiSpec, schema: OpenApiSchema | undefined) { + if (!schema) return "" + const resolved = (schema.$ref ? spec.components?.schemas?.[schema.$ref.replace("#/components/schemas/", "")] : schema) as + | OpenApiSchema + | undefined + if (resolved?.properties) return "object" + if (resolved?.anyOf ?? resolved?.oneOf ?? resolved?.allOf) return "object" + return resolved?.type ?? schema.type ?? "inline" +} + function responseContentTypes(input: { spec: { paths: Record>> } path: string @@ -146,6 +172,14 @@ afterEach(async () => { }) describe("HttpApi server", () => { + test("keeps Effect HttpApi behind the feature flag", () => { + Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = false + expect(Server.backend()).toEqual({ backend: "hono", reason: "stable" }) + + Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = true + expect(Server.backend()).toEqual({ backend: "effect-httpapi", reason: "env" }) + }) + test("covers every generated OpenAPI route with Effect HttpApi contracts", async () => { const honoRoutes = openApiRouteKeys(await Server.openapi()) const effectRoutes = openApiRouteKeys(effectOpenApi()) diff --git a/packages/opencode/test/server/httpapi-experimental.test.ts b/packages/opencode/test/server/httpapi-experimental.test.ts index 3978631b87..a4b0b66199 100644 --- a/packages/opencode/test/server/httpapi-experimental.test.ts +++ b/packages/opencode/test/server/httpapi-experimental.test.ts @@ -4,7 +4,7 @@ import { Flag } from "@opencode-ai/core/flag/flag" import { GlobalBus } from "@/bus/global" import { Instance } from "../../src/project/instance" import { Server } from "../../src/server/server" -import { ExperimentalPaths } from "../../src/server/routes/instance/httpapi/experimental" +import { ExperimentalPaths } from "../../src/server/routes/instance/httpapi/groups/experimental" import { Session } from "@/session/session" import { Database } from "@/storage/db" import * as Log from "@opencode-ai/core/util/log" diff --git a/packages/opencode/test/server/httpapi-file.test.ts b/packages/opencode/test/server/httpapi-file.test.ts index f9e94eeaa5..b7425007e1 100644 --- a/packages/opencode/test/server/httpapi-file.test.ts +++ b/packages/opencode/test/server/httpapi-file.test.ts @@ -2,7 +2,7 @@ import { afterEach, describe, expect, test } from "bun:test" import { Context } from "effect" import path from "path" import { ExperimentalHttpApiServer } from "../../src/server/routes/instance/httpapi/server" -import { FilePaths } from "../../src/server/routes/instance/httpapi/file" +import { FilePaths } from "../../src/server/routes/instance/httpapi/groups/file" import { Instance } from "../../src/project/instance" import * as Log from "@opencode-ai/core/util/log" import { resetDatabase } from "../fixture/db" diff --git a/packages/opencode/test/server/httpapi-instance.test.ts b/packages/opencode/test/server/httpapi-instance.test.ts index 4ab1da11e6..8e48284dea 100644 --- a/packages/opencode/test/server/httpapi-instance.test.ts +++ b/packages/opencode/test/server/httpapi-instance.test.ts @@ -4,7 +4,7 @@ import { Flag } from "@opencode-ai/core/flag/flag" import { GlobalBus } from "@/bus/global" import { Instance } from "../../src/project/instance" import { Server } from "../../src/server/server" -import { InstancePaths } from "../../src/server/routes/instance/httpapi/instance" +import { InstancePaths } from "../../src/server/routes/instance/httpapi/groups/instance" import * as Log from "@opencode-ai/core/util/log" import { resetDatabase } from "../fixture/db" import { tmpdir } from "../fixture/fixture" diff --git a/packages/opencode/test/server/httpapi-json-parity.test.ts b/packages/opencode/test/server/httpapi-json-parity.test.ts index 555c717cf0..b88a032f5d 100644 --- a/packages/opencode/test/server/httpapi-json-parity.test.ts +++ b/packages/opencode/test/server/httpapi-json-parity.test.ts @@ -4,8 +4,8 @@ import { Flag } from "@opencode-ai/core/flag/flag" import { ModelID, ProviderID } from "../../src/provider/schema" import { Instance } from "../../src/project/instance" import { Server } from "../../src/server/server" -import { ExperimentalPaths } from "../../src/server/routes/instance/httpapi/experimental" -import { SessionPaths } from "../../src/server/routes/instance/httpapi/session" +import { ExperimentalPaths } from "../../src/server/routes/instance/httpapi/groups/experimental" +import { SessionPaths } from "../../src/server/routes/instance/httpapi/groups/session" import { MessageID, PartID } from "../../src/session/schema" import { Session } from "@/session/session" import * as Log from "@opencode-ai/core/util/log" @@ -19,7 +19,7 @@ const original = Flag.OPENCODE_EXPERIMENTAL_HTTPAPI function app(experimental: boolean) { Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = experimental - return Server.Default().app + return experimental ? Server.Default().app : Server.Legacy().app } type TestApp = ReturnType diff --git a/packages/opencode/test/server/httpapi-mcp.test.ts b/packages/opencode/test/server/httpapi-mcp.test.ts index bb6635b52f..e348866528 100644 --- a/packages/opencode/test/server/httpapi-mcp.test.ts +++ b/packages/opencode/test/server/httpapi-mcp.test.ts @@ -3,7 +3,7 @@ import { Context, Effect, FileSystem, Layer, Path } from "effect" import { NodeFileSystem, NodePath } from "@effect/platform-node" import { Flag } from "@opencode-ai/core/flag/flag" import { ExperimentalHttpApiServer } from "../../src/server/routes/instance/httpapi/server" -import { McpPaths } from "../../src/server/routes/instance/httpapi/mcp" +import { McpPaths } from "../../src/server/routes/instance/httpapi/groups/mcp" import { Instance } from "../../src/project/instance" import { Server } from "../../src/server/server" import * as Log from "@opencode-ai/core/util/log" @@ -19,7 +19,7 @@ const it = testEffect(Layer.mergeAll(NodeFileSystem.layer, NodePath.layer)) function app(experimental: boolean) { Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = experimental - return Server.Default().app + return experimental ? Server.Default().app : Server.Legacy().app } type TestApp = ReturnType diff --git a/packages/opencode/test/server/httpapi-provider.test.ts b/packages/opencode/test/server/httpapi-provider.test.ts index 8d03311d91..5e8ff01a0e 100644 --- a/packages/opencode/test/server/httpapi-provider.test.ts +++ b/packages/opencode/test/server/httpapi-provider.test.ts @@ -19,7 +19,7 @@ const oauthInstructions = "Finish OAuth" function app(experimental: boolean) { Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = experimental - return Server.Default().app + return experimental ? Server.Default().app : Server.Legacy().app } function requestAuthorize(input: { diff --git a/packages/opencode/test/server/httpapi-pty.test.ts b/packages/opencode/test/server/httpapi-pty.test.ts index 87e2a94120..37d2a4f64d 100644 --- a/packages/opencode/test/server/httpapi-pty.test.ts +++ b/packages/opencode/test/server/httpapi-pty.test.ts @@ -3,7 +3,7 @@ import { Flag } from "@opencode-ai/core/flag/flag" import { PtyID } from "../../src/pty/schema" import { Instance } from "../../src/project/instance" import { Server } from "../../src/server/server" -import { PtyPaths } from "../../src/server/routes/instance/httpapi/pty" +import { PtyPaths } from "../../src/server/routes/instance/httpapi/groups/pty" import * as Log from "@opencode-ai/core/util/log" import { resetDatabase } from "../fixture/db" import { tmpdir } from "../fixture/fixture" diff --git a/packages/opencode/test/server/httpapi-sdk.test.ts b/packages/opencode/test/server/httpapi-sdk.test.ts index d022859469..c0984170be 100644 --- a/packages/opencode/test/server/httpapi-sdk.test.ts +++ b/packages/opencode/test/server/httpapi-sdk.test.ts @@ -1,40 +1,209 @@ import { afterEach, describe, expect, test } from "bun:test" +import { Effect } from "effect" import { Flag } from "@opencode-ai/core/flag/flag" import { createOpencodeClient } from "@opencode-ai/sdk/v2" -import { ExperimentalHttpApiServer } from "../../src/server/routes/instance/httpapi/server" +import { Instance } from "../../src/project/instance" +import { Server } from "../../src/server/server" +import { MessageID, PartID, SessionID } from "../../src/session/schema" +import { MessageV2 } from "../../src/session/message-v2" +import { ModelID, ProviderID } from "../../src/provider/schema" +import { Session as SessionNs } from "@/session/session" +import { TestLLMServer } from "../lib/llm-server" import path from "path" import { resetDatabase } from "../fixture/db" import { tmpdir } from "../fixture/fixture" -const original = Flag.OPENCODE_EXPERIMENTAL_HTTPAPI +const original = { + OPENCODE_EXPERIMENTAL_HTTPAPI: Flag.OPENCODE_EXPERIMENTAL_HTTPAPI, + OPENCODE_SERVER_PASSWORD: Flag.OPENCODE_SERVER_PASSWORD, + OPENCODE_SERVER_USERNAME: Flag.OPENCODE_SERVER_USERNAME, +} +type Backend = "legacy" | "httpapi" +type Sdk = ReturnType +type SdkResult = { response: Response; data?: unknown; error?: unknown } -function client(directory?: string) { - Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = true - const handler = ExperimentalHttpApiServer.webHandler().handler +function app(backend: Backend, input?: { password?: string; username?: string }) { + Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = backend === "httpapi" + Flag.OPENCODE_SERVER_PASSWORD = input?.password + Flag.OPENCODE_SERVER_USERNAME = input?.username + return backend === "httpapi" ? Server.Default().app : Server.Legacy().app +} + +function client( + backend: Backend, + directory?: string, + input?: { password?: string; username?: string; headers?: Record }, +) { + const serverApp = app(backend, input) const fetch = Object.assign( - (request: RequestInfo | URL, init?: RequestInit) => - handler(new Request(request, init), ExperimentalHttpApiServer.context), + async (request: RequestInfo | URL, init?: RequestInit) => + await serverApp.fetch(request instanceof Request ? request : new Request(request, init)), { preconnect: globalThis.fetch.preconnect }, ) satisfies typeof globalThis.fetch return createOpencodeClient({ baseUrl: "http://localhost", directory, + headers: input?.headers, fetch, }) } +function authorization(username: string, password: string) { + return `Basic ${Buffer.from(`${username}:${password}`).toString("base64")}` +} + +function providerConfig(url: string) { + return { + formatter: false, + lsp: false, + provider: { + test: { + name: "Test", + id: "test", + env: [], + npm: "@ai-sdk/openai-compatible", + models: { + "test-model": { + id: "test-model", + name: "Test Model", + attachment: false, + reasoning: false, + temperature: false, + tool_call: true, + release_date: "2025-01-01", + limit: { context: 100000, output: 10000 }, + cost: { input: 0, output: 0 }, + options: {}, + }, + }, + options: { + apiKey: "test-key", + baseURL: url, + }, + }, + }, + } +} + async function expectStatus(result: Promise<{ response: Response }>, status: number) { expect((await result).response.status).toBe(status) } +async function capture(result: Promise) { + const response = await result + return { + status: response.response.status, + data: response.data, + error: response.error, + } +} + +function record(value: unknown) { + return value && typeof value === "object" && !Array.isArray(value) ? (value as Record) : {} +} + +function array(value: unknown) { + return Array.isArray(value) ? value : [] +} + +function statuses(input: Record>>) { + return Object.fromEntries(Object.entries(input).map(([key, value]) => [key, value.status])) +} + +function firstPartText(value: unknown) { + return record(array(record(value).parts)[0]).text +} + +function sessionTitles(value: unknown) { + return array(value) + .map((item) => record(item).title) + .filter((title): title is string => typeof title === "string") + .sort() +} + +async function runSession(directory: string, effect: Effect.Effect) { + return Instance.provide({ + directory, + fn: () => Effect.runPromise(effect.pipe(Effect.provide(SessionNs.defaultLayer))), + }) +} + +async function seedMessage(directory: string, sessionID: string) { + const id = SessionID.make(sessionID) + return runSession( + directory, + SessionNs.Service.use((svc) => + Effect.gen(function* () { + const message = yield* svc.updateMessage({ + id: MessageID.ascending(), + sessionID: id, + role: "user", + time: { created: Date.now() }, + agent: "test", + model: { providerID: ProviderID.make("test"), modelID: ModelID.make("test") }, + tools: {}, + mode: "", + } as unknown as MessageV2.Info) + const part = yield* svc.updatePart({ + id: PartID.ascending(), + sessionID: id, + messageID: message.id, + type: "text", + text: "seeded message", + }) + return { message, part } + }), + ), + ) +} + +async function compareBackends(scenario: (backend: Backend) => Promise) { + const legacy = await scenario("legacy") + await Instance.disposeAll() + await resetDatabase() + const httpapi = await scenario("httpapi") + expect(httpapi).toEqual(legacy) +} + +async function withTmp(backend: Backend, fn: (input: { sdk: Sdk; directory: string }) => Promise) { + await using tmp = await tmpdir({ + git: true, + config: { formatter: false, lsp: false }, + init: async (dir) => { + await Bun.write(path.join(dir, "hello.txt"), "hello") + await Bun.write(path.join(dir, "needle.ts"), "export const needle = 'sdk-parity'\n") + }, + }) + return fn({ sdk: client(backend, tmp.path), directory: tmp.path }) +} + +async function withFakeLlm( + backend: Backend, + fn: (input: { sdk: Sdk; directory: string; llm: TestLLMServer["Service"] }) => Promise, +) { + return Effect.runPromise( + Effect.gen(function* () { + const llm = yield* TestLLMServer + const tmp = yield* Effect.acquireRelease( + Effect.promise(() => tmpdir({ git: true, config: providerConfig(llm.url) })), + (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()), + ) + return yield* Effect.promise(() => fn({ sdk: client(backend, tmp.path), directory: tmp.path, llm })) + }).pipe(Effect.scoped, Effect.provide(TestLLMServer.layer)), + ) +} + afterEach(async () => { - Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = original + Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = original.OPENCODE_EXPERIMENTAL_HTTPAPI + Flag.OPENCODE_SERVER_PASSWORD = original.OPENCODE_SERVER_PASSWORD + Flag.OPENCODE_SERVER_USERNAME = original.OPENCODE_SERVER_USERNAME + await Instance.disposeAll() await resetDatabase() }) describe("HttpApi SDK", () => { test("uses the generated SDK for global and control routes", async () => { - const sdk = client() + const sdk = client("httpapi") const health = await sdk.global.health() expect(health.response.status).toBe(200) @@ -60,7 +229,7 @@ describe("HttpApi SDK", () => { config: { formatter: false, lsp: false }, init: (dir) => Bun.write(path.join(dir, "hello.txt"), "hello"), }) - const sdk = client(tmp.path) + const sdk = client("httpapi", tmp.path) const file = await sdk.file.read({ path: "hello.txt" }) expect(file.response.status).toBe(200) @@ -81,4 +250,381 @@ describe("HttpApi SDK", () => { expectStatus(sdk.find.files({ query: "hello", limit: 10 }), 200), ]) }) + + test("matches generated SDK global and control behavior across backends", async () => { + await compareBackends(async (backend) => { + const sdk = client(backend) + const health = await capture(sdk.global.health()) + const log = await capture(sdk.app.log({ service: "sdk-parity", level: "info", message: "hello" })) + const invalidAuth = await capture(sdk.auth.set({ providerID: "test" })) + + return { + statuses: statuses({ health, log, invalidAuth }), + health: record(health.data).healthy, + log: log.data, + } + }) + }) + + test("matches generated SDK global event stream across backends", async () => { + await compareBackends(async (backend) => { + const events = await client(backend).global.event({ signal: AbortSignal.timeout(1_000) }) + try { + const first = await events.stream.next() + return { + type: record(record(first.value).payload).type, + } + } finally { + await events.stream.return(undefined) + } + }) + }) + + test("matches generated SDK instance event stream across backends", async () => { + await compareBackends((backend) => + withTmp(backend, async ({ sdk }) => { + const events = await sdk.event.subscribe(undefined, { signal: AbortSignal.timeout(1_000) }) + try { + const first = await events.stream.next() + return { + type: record(record(first.value).payload).type, + } + } finally { + await events.stream.return(undefined) + } + }), + ) + }) + + test("matches generated SDK basic auth behavior across backends", async () => { + await compareBackends((backend) => + withTmp(backend, async ({ directory }) => { + const missing = await capture( + client(backend, directory, { password: "secret" }).file.read({ path: "hello.txt" }), + ) + const bad = await capture( + client(backend, directory, { + password: "secret", + headers: { authorization: authorization("opencode", "wrong") }, + }).file.read({ path: "hello.txt" }), + ) + const good = await capture( + client(backend, directory, { + password: "secret", + headers: { authorization: authorization("opencode", "secret") }, + }).file.read({ path: "hello.txt" }), + ) + + return { + statuses: statuses({ missing, bad, good }), + content: record(good.data).content, + } + }), + ) + }) + + test("matches generated SDK instance read routes across backends", async () => { + await compareBackends((backend) => + withTmp(backend, async ({ sdk, directory }) => { + const project = await capture(sdk.project.current()) + const projects = await capture(sdk.project.list()) + const paths = await capture(sdk.path.get()) + const config = await capture(sdk.config.get()) + const providers = await capture(sdk.config.providers()) + const file = await capture(sdk.file.read({ path: "hello.txt" })) + const files = await capture(sdk.file.list({ path: "." })) + const fileStatus = await capture(sdk.file.status()) + const findFiles = await capture(sdk.find.files({ query: "hello", limit: 10 })) + const findText = await capture(sdk.find.text({ pattern: "sdk-parity" })) + const agents = await capture(sdk.app.agents()) + const skills = await capture(sdk.app.skills()) + const tools = await capture(sdk.tool.ids()) + const vcs = await capture(sdk.vcs.get()) + const formatter = await capture(sdk.formatter.status()) + const lsp = await capture(sdk.lsp.status()) + + return { + statuses: statuses({ + project, + projects, + paths, + config, + providers, + file, + files, + fileStatus, + findFiles, + findText, + agents, + skills, + tools, + vcs, + formatter, + lsp, + }), + project: { + worktreeSelected: record(project.data).worktree === directory, + }, + paths: { + cwdSelected: record(paths.data).cwd === directory, + }, + file: record(file.data).content, + hasProject: array(projects.data).length > 0, + foundFile: JSON.stringify(findFiles.data).includes("hello.txt"), + foundText: JSON.stringify(findText.data ?? null).includes("sdk-parity"), + listedFile: JSON.stringify(files.data).includes("hello.txt"), + } + }), + ) + }) + + test("matches generated SDK session lifecycle routes across backends", async () => { + await compareBackends((backend) => + withTmp(backend, async ({ sdk }) => { + const parent = await capture(sdk.session.create({ title: "parent" })) + const parentID = String(record(parent.data).id) + const child = await capture(sdk.session.create({ title: "child", parentID })) + const childID = String(record(child.data).id) + const get = await capture(sdk.session.get({ sessionID: parentID })) + const update = await capture(sdk.session.update({ sessionID: parentID, title: "renamed" })) + const roots = await capture(sdk.session.list({ roots: true, limit: 10 })) + const all = await capture(sdk.session.list({ roots: false, limit: 10 })) + const children = await capture(sdk.session.children({ sessionID: parentID })) + const todo = await capture(sdk.session.todo({ sessionID: parentID })) + const status = await capture(sdk.session.status()) + const messages = await capture(sdk.session.messages({ sessionID: parentID })) + const missingGet = await capture(sdk.session.get({ sessionID: "ses_missing" })) + const missingMessages = await capture(sdk.session.messages({ sessionID: "ses_missing", limit: 2 })) + const invalidCursor = await capture(sdk.session.messages({ sessionID: parentID, limit: 2, before: "bad" })) + const deleted = await capture(sdk.session.delete({ sessionID: childID })) + const getDeleted = await capture(sdk.session.get({ sessionID: childID })) + + return { + statuses: statuses({ + parent, + child, + get, + update, + roots, + all, + children, + todo, + status, + messages, + missingGet, + missingMessages, + invalidCursor, + deleted, + getDeleted, + }), + getTitle: record(get.data).title, + updatedTitle: record(update.data).title, + rootTitles: sessionTitles(roots.data), + allTitles: sessionTitles(all.data), + childCount: array(children.data).length, + todoCount: array(todo.data).length, + messageCount: array(messages.data).length, + } + }), + ) + }) + + test("matches generated SDK session message and part routes across backends", async () => { + await compareBackends((backend) => + withTmp(backend, async ({ sdk, directory }) => { + const session = await capture(sdk.session.create({ title: "messages" })) + const sessionID = String(record(session.data).id) + const seeded = await seedMessage(directory, sessionID) + const list = await capture(sdk.session.messages({ sessionID })) + const page = await capture(sdk.session.messages({ sessionID, limit: 1 })) + const message = await capture(sdk.session.message({ sessionID, messageID: seeded.message.id })) + const partUpdate = await capture( + sdk.part.update({ + sessionID, + messageID: seeded.message.id, + partID: seeded.part.id, + part: { + ...seeded.part, + text: "updated message", + } as NonNullable[0]["part"]>, + }), + ) + const updated = await capture(sdk.session.message({ sessionID, messageID: seeded.message.id })) + const partDelete = await capture( + sdk.part.delete({ sessionID, messageID: seeded.message.id, partID: seeded.part.id }), + ) + const withoutPart = await capture(sdk.session.message({ sessionID, messageID: seeded.message.id })) + const deleteMessage = await capture(sdk.session.deleteMessage({ sessionID, messageID: seeded.message.id })) + const missingMessage = await capture(sdk.session.message({ sessionID, messageID: seeded.message.id })) + + return { + statuses: statuses({ + session, + list, + page, + message, + partUpdate, + updated, + partDelete, + withoutPart, + deleteMessage, + missingMessage, + }), + listCount: array(list.data).length, + pageCount: array(page.data).length, + initialText: firstPartText(message.data), + updatedText: firstPartText(updated.data), + partCountAfterDelete: array(record(withoutPart.data).parts).length, + } + }), + ) + }) + + test("matches generated SDK prompt no-reply routes across backends", async () => { + await compareBackends((backend) => + withTmp(backend, async ({ sdk }) => { + const session = await capture(sdk.session.create({ title: "prompt" })) + const sessionID = String(record(session.data).id) + const prompt = await capture( + sdk.session.prompt({ + sessionID, + agent: "build", + noReply: true, + parts: [{ type: "text", text: "hello" }], + }), + ) + const asyncPrompt = await capture( + sdk.session.promptAsync({ + sessionID, + agent: "build", + noReply: true, + parts: [{ type: "text", text: "async hello" }], + }), + ) + const messages = await capture(sdk.session.messages({ sessionID })) + + return { + statuses: statuses({ session, prompt, asyncPrompt, messages }), + promptRole: record(record(prompt.data).info).role, + messageCount: array(messages.data).length, + messageTexts: array(messages.data) + .flatMap((item) => array(record(item).parts)) + .map((part) => record(part).text) + .filter((text): text is string => typeof text === "string") + .sort(), + } + }), + ) + }) + + test("matches generated SDK prompt streaming through fake LLM across backends", async () => { + await compareBackends((backend) => + withFakeLlm(backend, async ({ sdk, llm }) => { + await Effect.runPromise(llm.text("fake world", { usage: { input: 11, output: 7 } })) + const session = await capture( + sdk.session.create({ + title: "llm prompt", + permission: [{ permission: "*", pattern: "*", action: "allow" }], + }), + ) + const sessionID = String(record(session.data).id) + const prompt = await capture( + sdk.session.prompt({ + sessionID, + agent: "build", + model: { providerID: "test", modelID: "test-model" }, + parts: [{ type: "text", text: "hello llm" }], + }), + ) + const messages = await capture(sdk.session.messages({ sessionID })) + const inputs = await Effect.runPromise(llm.inputs) + + return { + statuses: statuses({ session, prompt, messages }), + calls: inputs.length, + requestedModel: inputs[0]?.model, + responseText: JSON.stringify(prompt.data).includes("fake world"), + persistedText: JSON.stringify(messages.data).includes("fake world"), + userText: JSON.stringify(messages.data).includes("hello llm"), + } + }), + ) + }) + + test("matches generated SDK TUI validation and command routes across backends", async () => { + await compareBackends((backend) => + withTmp(backend, async ({ sdk }) => { + const session = await capture(sdk.session.create({ title: "tui" })) + const sessionID = String(record(session.data).id) + const appendPrompt = await capture(sdk.tui.appendPrompt({ text: "hello" })) + const openHelp = await capture(sdk.tui.openHelp()) + const openSessions = await capture(sdk.tui.openSessions()) + const openThemes = await capture(sdk.tui.openThemes()) + const openModels = await capture(sdk.tui.openModels()) + const submitPrompt = await capture(sdk.tui.submitPrompt()) + const clearPrompt = await capture(sdk.tui.clearPrompt()) + const executeCommand = await capture(sdk.tui.executeCommand({ command: "session_new" })) + const showToast = await capture(sdk.tui.showToast({ title: "SDK", message: "hello", variant: "info" })) + const selectSession = await capture(sdk.tui.selectSession({ sessionID })) + const missingSession = await capture(sdk.tui.selectSession({ sessionID: "ses_missing" })) + const invalidSession = await capture(sdk.tui.selectSession({ sessionID: "invalid_session_id" })) + + return { + statuses: statuses({ + session, + appendPrompt, + openHelp, + openSessions, + openThemes, + openModels, + submitPrompt, + clearPrompt, + executeCommand, + showToast, + selectSession, + missingSession, + invalidSession, + }), + data: { + appendPrompt: appendPrompt.data, + openHelp: openHelp.data, + openSessions: openSessions.data, + openThemes: openThemes.data, + openModels: openModels.data, + submitPrompt: submitPrompt.data, + clearPrompt: clearPrompt.data, + executeCommand: executeCommand.data, + showToast: showToast.data, + selectSession: selectSession.data, + }, + } + }), + ) + }) + + test("matches generated SDK project git initialization across backends", async () => { + await compareBackends(async (backend) => { + await using tmp = await tmpdir({ config: { formatter: false, lsp: false } }) + const sdk = client(backend, tmp.path) + const before = await capture(sdk.project.current()) + const init = await capture(sdk.project.initGit()) + const after = await capture(sdk.project.current()) + + return { + statuses: statuses({ before, init, after }), + before: { + vcs: record(before.data).vcs ?? null, + worktree: record(before.data).worktree, + }, + init: { + vcs: record(init.data).vcs, + worktreeSelected: record(init.data).worktree === tmp.path, + }, + after: { + vcs: record(after.data).vcs, + worktreeSelected: record(after.data).worktree === tmp.path, + }, + } + }) + }) }) diff --git a/packages/opencode/test/server/httpapi-session.test.ts b/packages/opencode/test/server/httpapi-session.test.ts index 3e3fb35731..593f9765c7 100644 --- a/packages/opencode/test/server/httpapi-session.test.ts +++ b/packages/opencode/test/server/httpapi-session.test.ts @@ -5,7 +5,7 @@ import { PermissionID } from "../../src/permission/schema" import { ModelID, ProviderID } from "../../src/provider/schema" import { Instance } from "../../src/project/instance" import { Server } from "../../src/server/server" -import { SessionPaths } from "../../src/server/routes/instance/httpapi/session" +import { SessionPaths } from "../../src/server/routes/instance/httpapi/groups/session" import { Session } from "@/session/session" import { MessageID, PartID, type SessionID } from "../../src/session/schema" import { MessageV2 } from "../../src/session/message-v2" diff --git a/packages/opencode/test/server/httpapi-sync.test.ts b/packages/opencode/test/server/httpapi-sync.test.ts index 2758191057..5fa6784a13 100644 --- a/packages/opencode/test/server/httpapi-sync.test.ts +++ b/packages/opencode/test/server/httpapi-sync.test.ts @@ -3,7 +3,7 @@ import { Effect } from "effect" import { Flag } from "@opencode-ai/core/flag/flag" import { Instance } from "../../src/project/instance" import { Server } from "../../src/server/server" -import { SyncPaths } from "../../src/server/routes/instance/httpapi/sync" +import { SyncPaths } from "../../src/server/routes/instance/httpapi/groups/sync" import { Session } from "@/session/session" import * as Log from "@opencode-ai/core/util/log" import { resetDatabase } from "../fixture/db" @@ -16,7 +16,7 @@ const originalWorkspaces = Flag.OPENCODE_EXPERIMENTAL_WORKSPACES function app(httpapi = true) { Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = httpapi - return Server.Default().app + return httpapi ? Server.Default().app : Server.Legacy().app } function runSession(fx: Effect.Effect) { diff --git a/packages/opencode/test/server/httpapi-tui.test.ts b/packages/opencode/test/server/httpapi-tui.test.ts index 81a2105095..9f7c8e9e89 100644 --- a/packages/opencode/test/server/httpapi-tui.test.ts +++ b/packages/opencode/test/server/httpapi-tui.test.ts @@ -3,7 +3,7 @@ import type { Context } from "hono" import { Flag } from "@opencode-ai/core/flag/flag" import { SessionID } from "../../src/session/schema" import { Instance } from "../../src/project/instance" -import { TuiApi, TuiPaths } from "../../src/server/routes/instance/httpapi/tui" +import { TuiApi, TuiPaths } from "../../src/server/routes/instance/httpapi/groups/tui" import { callTui } from "../../src/server/routes/instance/tui" import { Server } from "../../src/server/server" import * as Log from "@opencode-ai/core/util/log" diff --git a/packages/opencode/test/server/httpapi-workspace.test.ts b/packages/opencode/test/server/httpapi-workspace.test.ts index cb549c6497..f430105714 100644 --- a/packages/opencode/test/server/httpapi-workspace.test.ts +++ b/packages/opencode/test/server/httpapi-workspace.test.ts @@ -1,4 +1,4 @@ -import { afterEach, describe, expect, test } from "bun:test" +import { afterEach, describe, expect, mock, spyOn, test } from "bun:test" import { mkdir } from "node:fs/promises" import path from "node:path" import { Effect } from "effect" @@ -6,13 +6,14 @@ import { Flag } from "@opencode-ai/core/flag/flag" import { registerAdaptor } from "../../src/control-plane/adaptors" import type { WorkspaceAdaptor } from "../../src/control-plane/types" import { Workspace } from "../../src/control-plane/workspace" -import { WorkspacePaths } from "../../src/server/routes/instance/httpapi/workspace" +import { WorkspacePaths } from "../../src/server/routes/instance/httpapi/groups/workspace" import { Session } from "@/session/session" import * as Log from "@opencode-ai/core/util/log" import { Server } from "../../src/server/server" import { resetDatabase } from "../fixture/db" import { tmpdir } from "../fixture/fixture" import { Instance } from "../../src/project/instance" +import { InstancePaths } from "../../src/server/routes/instance/httpapi/groups/instance" void Log.init({ print: false }) @@ -54,7 +55,41 @@ function localAdaptor(directory: string): WorkspaceAdaptor { } } +function remoteAdaptor(directory: string, url: string): WorkspaceAdaptor { + return { + name: "Remote Test", + description: "Create a remote test workspace", + configure(info) { + return { + ...info, + name: "remote-test", + directory, + } + }, + async create() { + await mkdir(directory, { recursive: true }) + }, + async remove() {}, + target() { + return { + type: "remote" as const, + url, + } + }, + } +} + +function eventStreamResponse() { + return new Response(new ReadableStream({ start() {} }), { + status: 200, + headers: { + "content-type": "text/event-stream", + }, + }) +} + afterEach(async () => { + mock.restore() Flag.OPENCODE_EXPERIMENTAL_WORKSPACES = originalWorkspaces Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = originalHttpApi await Instance.disposeAll() @@ -125,4 +160,81 @@ describe("workspace HttpApi", () => { expect(listed.status).toBe(200) expect(await listed.json()).toEqual([]) }) + + test("routes local workspace requests through the workspace target directory", async () => { + Flag.OPENCODE_EXPERIMENTAL_WORKSPACES = true + await using tmp = await tmpdir({ git: true }) + const workspaceDir = path.join(tmp.path, ".workspace-local") + const workspace = await Instance.provide({ + directory: tmp.path, + fn: async () => { + registerAdaptor(Instance.project.id, "local-target", localAdaptor(workspaceDir)) + return Workspace.create({ + type: "local-target", + branch: null, + extra: null, + projectID: Instance.project.id, + }) + }, + }) + + const url = new URL(`http://localhost${InstancePaths.path}`) + url.searchParams.set("workspace", workspace.id) + + try { + const response = await request(url.toString(), tmp.path) + + expect(response.status).toBe(200) + expect(await response.json()).toMatchObject({ directory: workspaceDir }) + } finally { + await Workspace.remove(workspace.id) + } + }) + + test("proxies remote workspace HTTP requests", async () => { + Flag.OPENCODE_EXPERIMENTAL_WORKSPACES = true + await using tmp = await tmpdir({ git: true }) + const proxied: string[] = [] + const rawFetch = globalThis.fetch + spyOn(globalThis, "fetch").mockImplementation( + Object.assign( + async (input: URL | RequestInfo, init?: BunFetchRequestInit | RequestInit) => { + const url = new URL(typeof input === "string" || input instanceof URL ? input : input.url) + if (url.pathname === "/base/global/event") return eventStreamResponse() + if (url.pathname === "/base/sync/history") return Response.json([]) + proxied.push(url.toString()) + return Response.json({ proxied: true, path: url.pathname, workspace: url.searchParams.get("workspace") }) + }, + { + preconnect: rawFetch.preconnect?.bind(rawFetch), + }, + ) as typeof globalThis.fetch, + ) + + const workspace = await Instance.provide({ + directory: tmp.path, + fn: async () => { + registerAdaptor(Instance.project.id, "remote-target", remoteAdaptor(path.join(tmp.path, ".remote"), "https://remote.test/base")) + return Workspace.create({ + type: "remote-target", + branch: null, + extra: null, + projectID: Instance.project.id, + }) + }, + }) + + const url = new URL(`http://localhost${InstancePaths.path}`) + url.searchParams.set("workspace", workspace.id) + + try { + const response = await request(url.toString(), tmp.path) + + expect(response.status).toBe(200) + expect(await response.json()).toEqual({ proxied: true, path: "/base/path", workspace: null }) + expect(proxied).toEqual(["https://remote.test/base/path"]) + } finally { + await Workspace.remove(workspace.id) + } + }) }) diff --git a/packages/opencode/test/tool/__snapshots__/parameters.test.ts.snap b/packages/opencode/test/tool/__snapshots__/parameters.test.ts.snap index b20665b34d..02de54406a 100644 --- a/packages/opencode/test/tool/__snapshots__/parameters.test.ts.snap +++ b/packages/opencode/test/tool/__snapshots__/parameters.test.ts.snap @@ -43,7 +43,9 @@ Output: Creates directory 'foo'" }, "timeout": { "description": "Optional timeout in milliseconds", - "type": "number", + "exclusiveMinimum": 0, + "maximum": 9007199254740991, + "type": "integer", }, "workdir": { "description": "The working directory to run the command in. Defaults to the current directory. Use this instead of 'cd' commands.", @@ -71,7 +73,7 @@ exports[`tool parameters JSON Schema (wire shape) codesearch 1`] = ` "description": "Number of tokens to return (1000-50000). Default is 5000 tokens. Adjust this value based on how much context you need - use lower values for focused queries and higher values for comprehensive documentation.", "maximum": 50000, "minimum": 1000, - "type": "number", + "type": "integer", }, }, "required": [ @@ -224,7 +226,6 @@ exports[`tool parameters JSON Schema (wire shape) lsp 1`] = ` } `; - exports[`tool parameters JSON Schema (wire shape) plan 1`] = ` { "$schema": "https://json-schema.org/draft/2020-12/schema", @@ -304,11 +305,15 @@ exports[`tool parameters JSON Schema (wire shape) read 1`] = ` }, "limit": { "description": "The maximum number of lines to read (defaults to 2000)", - "type": "number", + "maximum": 9007199254740991, + "minimum": 0, + "type": "integer", }, "offset": { "description": "The line number to start reading from (1-indexed)", - "type": "number", + "maximum": 9007199254740991, + "minimum": 0, + "type": "integer", }, }, "required": [