diff --git a/packages/opencode/specs/effect/http-api.md b/packages/opencode/specs/effect/http-api.md index ad9fcb2ba5..7f19a612b2 100644 --- a/packages/opencode/specs/effect/http-api.md +++ b/packages/opencode/specs/effect/http-api.md @@ -139,8 +139,8 @@ Use raw Effect HTTP routes where `HttpApi` does not fit. The goal is deleting Ho | `project` | `bridged` partial | reads only; git-init remains Hono | | `file` | `bridged` partial | list/content/status only | | `mcp` | `bridged` partial | status only | -| `workspace` | `implemented` | `HttpApi` group exists, but bridge mounting needs verification | -| top-level instance reads | `next` | path, vcs, command, agent, skill, lsp, formatter | +| `workspace` | `bridged` | list, get, enter | +| top-level instance reads | `bridged` partial | path and vcs reads; command, agent, skill, lsp, formatter next | | experimental JSON routes | `next/later` | console, tool, worktree, resource, global session list | | `session` | `later/special` | large stateful surface plus streaming | | `sync` | `later` | process/control side effects | @@ -150,11 +150,9 @@ Use raw Effect HTTP routes where `HttpApi` does not fit. The goal is deleting Ho ## Next PRs -1. Add bridge-level auth and instance-context tests for the current `HttpApi` bridge. -2. Produce a generated route inventory from Hono registrations and update `Current Route Status` with exact paths. -3. Fix the `workspace` status: mount it if it should be reachable, or remove it from the composed `HttpApi` layer. -4. Port the top-level JSON reads. -5. Start the Effect OpenAPI/SDK generation path for already-bridged routes. +1. Produce a generated route inventory from Hono registrations and update `Current Route Status` with exact paths. +2. Continue porting top-level JSON reads. +3. Start the Effect OpenAPI/SDK generation path for already-bridged routes. ## Checklist @@ -164,9 +162,9 @@ Use raw Effect HTTP routes where `HttpApi` does not fit. The goal is deleting Ho - [x] Provide auth, instance lookup, and observability in the Effect route layer. - [x] Attach auth middleware in route modules. - [x] Support `auth_token` as a query security scheme. -- [ ] Add bridge-level auth and instance tests. +- [x] Add bridge-level auth and instance tests. - [ ] Complete exact Hono route inventory. -- [ ] Resolve implemented-but-unmounted route groups. +- [x] Resolve implemented-but-unmounted route groups. - [ ] Port remaining JSON routes. - [ ] Generate SDK/OpenAPI from Effect routes. - [ ] Flip ported JSON routes to default-on with fallback. diff --git a/packages/opencode/src/project/vcs.ts b/packages/opencode/src/project/vcs.ts index e8c6ff2ac7..1c1da97bf1 100644 --- a/packages/opencode/src/project/vcs.ts +++ b/packages/opencode/src/project/vcs.ts @@ -8,7 +8,8 @@ import { AppFileSystem } from "@opencode-ai/shared/filesystem" import { FileWatcher } from "@/file/watcher" import { Git } from "@/git" import { Log } from "@/util" -import z from "zod" +import { zod } from "@/util/effect-zod" +import { withStatics } from "@/util/schema" const log = Log.create({ service: "vcs" }) @@ -101,8 +102,8 @@ const compare = Effect.fnUntraced(function* ( ) }) -export const Mode = z.enum(["git", "branch"]) -export type Mode = z.infer +export const Mode = Schema.Literals(["git", "branch"]).pipe(withStatics((s) => ({ zod: zod(s) }))) +export type Mode = Schema.Schema.Type export const Event = { BranchUpdated: BusEvent.define( @@ -113,28 +114,24 @@ export const Event = { ), } -export const Info = z - .object({ - branch: z.string().optional(), - default_branch: z.string().optional(), - }) - .meta({ - ref: "VcsInfo", - }) -export type Info = z.infer +export const Info = Schema.Struct({ + branch: Schema.optional(Schema.String), + default_branch: Schema.optional(Schema.String), +}) + .annotate({ identifier: "VcsInfo" }) + .pipe(withStatics((s) => ({ zod: zod(s) }))) +export type Info = Schema.Schema.Type -export const FileDiff = z - .object({ - file: z.string(), - patch: z.string(), - additions: z.number(), - deletions: z.number(), - status: z.enum(["added", "deleted", "modified"]).optional(), - }) - .meta({ - ref: "VcsFileDiff", - }) -export type FileDiff = z.infer +export const FileDiff = Schema.Struct({ + file: Schema.String, + patch: Schema.String, + additions: Schema.Number, + deletions: Schema.Number, + status: Schema.optional(Schema.Literals(["added", "deleted", "modified"])), +}) + .annotate({ identifier: "VcsFileDiff" }) + .pipe(withStatics((s) => ({ zod: zod(s) }))) +export type FileDiff = Schema.Schema.Type export interface Interface { readonly init: () => Effect.Effect diff --git a/packages/opencode/src/server/routes/instance/httpapi/instance.ts b/packages/opencode/src/server/routes/instance/httpapi/instance.ts new file mode 100644 index 0000000000..f7c3a02ad1 --- /dev/null +++ b/packages/opencode/src/server/routes/instance/httpapi/instance.ts @@ -0,0 +1,103 @@ +import { Global } from "@/global" +import { Vcs } from "@/project" +import * as InstanceState from "@/effect/instance-state" +import { Effect, Layer, Schema } from "effect" +import { HttpApi, HttpApiBuilder, HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi" +import { Authorization } from "./auth" + +const PathInfo = Schema.Struct({ + home: Schema.String, + state: Schema.String, + config: Schema.String, + worktree: Schema.String, + directory: Schema.String, +}).annotate({ identifier: "Path" }) + +const VcsDiffQuery = Schema.Struct({ + mode: Vcs.Mode, +}) + +export const InstancePaths = { + path: "/path", + vcs: "/vcs", + vcsDiff: "/vcs/diff", +} as const + +export const InstanceApi = HttpApi.make("instance") + .add( + HttpApiGroup.make("instance") + .add( + HttpApiEndpoint.get("path", InstancePaths.path, { + success: PathInfo, + }).annotateMerge( + OpenApi.annotations({ + identifier: "path.get", + summary: "Get paths", + description: "Retrieve the current working directory and related path information for the OpenCode instance.", + }), + ), + HttpApiEndpoint.get("vcs", InstancePaths.vcs, { + success: Vcs.Info, + }).annotateMerge( + OpenApi.annotations({ + identifier: "vcs.get", + summary: "Get VCS info", + description: "Retrieve version control system (VCS) information for the current project, such as git branch.", + }), + ), + HttpApiEndpoint.get("vcsDiff", InstancePaths.vcsDiff, { + query: VcsDiffQuery, + success: Schema.Array(Vcs.FileDiff), + }).annotateMerge( + OpenApi.annotations({ + identifier: "vcs.diff", + summary: "Get VCS diff", + description: "Retrieve the current git diff for the working tree or against the default branch.", + }), + ), + ) + .annotateMerge( + OpenApi.annotations({ + title: "instance", + description: "Experimental HttpApi instance read routes.", + }), + ) + .middleware(Authorization), + ) + .annotateMerge( + OpenApi.annotations({ + title: "opencode experimental HttpApi", + version: "0.0.1", + description: "Experimental HttpApi surface for selected instance routes.", + }), + ) + +export const instanceHandlers = Layer.unwrap( + Effect.gen(function* () { + const vcs = yield* Vcs.Service + + 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) + }) + + return HttpApiBuilder.group(InstanceApi, "instance", (handlers) => + handlers.handle("path", getPath).handle("vcs", getVcs).handle("vcsDiff", getVcsDiff), + ) + }), +).pipe(Layer.provide(Vcs.defaultLayer)) diff --git a/packages/opencode/src/server/routes/instance/httpapi/server.ts b/packages/opencode/src/server/routes/instance/httpapi/server.ts index 14c2550ed2..903cd103ba 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/server.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/server.ts @@ -11,6 +11,7 @@ import { Filesystem } from "@/util" import { authorizationLayer } from "./auth" import { ConfigApi, configHandlers } from "./config" import { FileApi, fileHandlers } from "./file" +import { InstanceApi, instanceHandlers } from "./instance" import { McpApi, mcpHandlers } from "./mcp" import { PermissionApi, permissionHandlers } from "./permission" import { ProjectApi, projectHandlers } from "./project" @@ -63,6 +64,7 @@ const instance = HttpRouter.middleware()( export const routes = Layer.mergeAll( HttpApiBuilder.layer(ConfigApi).pipe(Layer.provide(configHandlers)), 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(QuestionApi).pipe(Layer.provide(questionHandlers)), diff --git a/packages/opencode/src/server/routes/instance/index.ts b/packages/opencode/src/server/routes/instance/index.ts index b899eb1082..bc9d2b2ada 100644 --- a/packages/opencode/src/server/routes/instance/index.ts +++ b/packages/opencode/src/server/routes/instance/index.ts @@ -17,6 +17,7 @@ import { PermissionRoutes } from "./permission" import { Flag } from "@/flag/flag" import { ExperimentalHttpApiServer } from "./httpapi/server" import { FilePaths } from "./httpapi/file" +import { InstancePaths } from "./httpapi/instance" import { McpPaths } from "./httpapi/mcp" import { ProjectRoutes } from "./project" import { SessionRoutes } from "./session" @@ -53,6 +54,9 @@ export const InstanceRoutes = (upgrade: UpgradeWebSocket): Hono => { app.get(FilePaths.list, (c) => handler(c.req.raw, context)) app.get(FilePaths.content, (c) => handler(c.req.raw, context)) app.get(FilePaths.status, (c) => handler(c.req.raw, context)) + app.get(InstancePaths.path, (c) => handler(c.req.raw, context)) + app.get(InstancePaths.vcs, (c) => handler(c.req.raw, context)) + app.get(InstancePaths.vcsDiff, (c) => handler(c.req.raw, context)) app.get(McpPaths.status, (c) => handler(c.req.raw, context)) } @@ -142,7 +146,7 @@ export const InstanceRoutes = (upgrade: UpgradeWebSocket): Hono => { description: "VCS info", content: { "application/json": { - schema: resolver(Vcs.Info), + schema: resolver(Vcs.Info.zod), }, }, }, @@ -168,7 +172,7 @@ export const InstanceRoutes = (upgrade: UpgradeWebSocket): Hono => { description: "VCS diff", content: { "application/json": { - schema: resolver(Vcs.FileDiff.array()), + schema: resolver(Vcs.FileDiff.zod.array()), }, }, }, @@ -177,7 +181,7 @@ export const InstanceRoutes = (upgrade: UpgradeWebSocket): Hono => { validator( "query", z.object({ - mode: Vcs.Mode, + mode: Vcs.Mode.zod, }), ), async (c) => diff --git a/packages/opencode/test/server/httpapi-instance.test.ts b/packages/opencode/test/server/httpapi-instance.test.ts new file mode 100644 index 0000000000..f25d295185 --- /dev/null +++ b/packages/opencode/test/server/httpapi-instance.test.ts @@ -0,0 +1,53 @@ +import { afterEach, describe, expect, test } from "bun:test" +import type { UpgradeWebSocket } from "hono/ws" +import path from "path" +import { Flag } from "../../src/flag/flag" +import { Instance } from "../../src/project/instance" +import { InstanceRoutes } from "../../src/server/routes/instance" +import { InstancePaths } from "../../src/server/routes/instance/httpapi/instance" +import { Log } from "../../src/util" +import { resetDatabase } from "../fixture/db" +import { tmpdir } from "../fixture/fixture" + +void Log.init({ print: false }) + +const original = Flag.OPENCODE_EXPERIMENTAL_HTTPAPI +const websocket = (() => () => new Response(null, { status: 501 })) as unknown as UpgradeWebSocket + +function app() { + Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = true + return InstanceRoutes(websocket) +} + +afterEach(async () => { + Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = original + await Instance.disposeAll() + await resetDatabase() +}) + +describe("instance HttpApi", () => { + test("serves path and VCS read endpoints through Hono bridge", async () => { + await using tmp = await tmpdir({ git: true }) + await Bun.write(path.join(tmp.path, "changed.txt"), "hello") + + const vcsDiff = new URL(`http://localhost${InstancePaths.vcsDiff}`) + vcsDiff.searchParams.set("mode", "git") + + const [paths, vcs, diff] = await Promise.all([ + app().request(InstancePaths.path, { headers: { "x-opencode-directory": tmp.path } }), + app().request(InstancePaths.vcs, { headers: { "x-opencode-directory": tmp.path } }), + app().request(vcsDiff, { headers: { "x-opencode-directory": tmp.path } }), + ]) + + expect(paths.status).toBe(200) + expect(await paths.json()).toMatchObject({ directory: tmp.path, worktree: tmp.path }) + + expect(vcs.status).toBe(200) + expect(await vcs.json()).toMatchObject({ branch: expect.any(String) }) + + expect(diff.status).toBe(200) + expect(await diff.json()).toContainEqual( + expect.objectContaining({ file: "changed.txt", additions: 1, status: "added" }), + ) + }) +})