mirror of
https://github.com/anomalyco/opencode.git
synced 2026-04-26 11:40:49 +00:00
feat(httpapi): bridge instance read endpoints (#24258)
This commit is contained in:
parent
bad732c26a
commit
d5bfaef53d
6 changed files with 193 additions and 36 deletions
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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<typeof Mode>
|
||||
export const Mode = Schema.Literals(["git", "branch"]).pipe(withStatics((s) => ({ zod: zod(s) })))
|
||||
export type Mode = Schema.Schema.Type<typeof Mode>
|
||||
|
||||
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<typeof Info>
|
||||
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<typeof Info>
|
||||
|
||||
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<typeof FileDiff>
|
||||
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<typeof FileDiff>
|
||||
|
||||
export interface Interface {
|
||||
readonly init: () => Effect.Effect<void>
|
||||
|
|
|
|||
103
packages/opencode/src/server/routes/instance/httpapi/instance.ts
Normal file
103
packages/opencode/src/server/routes/instance/httpapi/instance.ts
Normal file
|
|
@ -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))
|
||||
|
|
@ -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)),
|
||||
|
|
|
|||
|
|
@ -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) =>
|
||||
|
|
|
|||
53
packages/opencode/test/server/httpapi-instance.test.ts
Normal file
53
packages/opencode/test/server/httpapi-instance.test.ts
Normal file
|
|
@ -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" }),
|
||||
)
|
||||
})
|
||||
})
|
||||
Loading…
Add table
Add a link
Reference in a new issue