Skip to content

Commit df9e1d9

Browse files
authored
feat(httpapi): bridge config update endpoint (#24387)
1 parent 75a22f8 commit df9e1d9

6 files changed

Lines changed: 111 additions & 8 deletions

File tree

packages/opencode/specs/effect/http-api.md

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,23 @@ Use this checklist for each small HttpApi migration PR:
4343
7. Add tests that hit the Hono-mounted bridge via `InstanceRoutes`, not only the raw `HttpApi` web handler, when the route depends on auth or instance context.
4444
8. Run `bun typecheck` from `packages/opencode`, relevant `bun run test:ci ...` tests from `packages/opencode`, and `./packages/sdk/js/script/build.ts` from the repo root.
4545

46+
## Hono Deletion Checklist
47+
48+
Use this checklist before deleting any Hono route implementation. A route being `bridged` is not enough.
49+
50+
1. `HttpApi` parity is complete for the route path, method, auth behavior, query parameters, request body, response status, response headers, and error status.
51+
2. The route is mounted by default, not only behind `OPENCODE_EXPERIMENTAL_HTTPAPI`.
52+
3. If a fallback flag exists, tests cover both the default `HttpApi` path and the fallback Hono path until the fallback is removed.
53+
4. OpenAPI generation uses the Effect `HttpApi` route as the source for that path.
54+
5. Generated SDK output is unchanged from the Hono-generated contract, or the SDK diff is intentionally reviewed and accepted.
55+
6. The legacy Hono `describeRoute`, validator, and handler for that path are removed.
56+
7. Any duplicate Zod-only DTOs are deleted or kept only as `.zod` compatibility on the canonical Effect Schema.
57+
8. Bridge tests exist for auth, instance selection, success response, and route-specific side effects.
58+
9. Mutation routes prove persisted side effects and cleanup behavior in tests. If the mutation disposes/reloads the active instance, disposal happens through an explicit post-response lifecycle hook rather than inline handler teardown.
59+
10. Streaming, SSE, websocket, and UI bridge routes have a specific non-Hono replacement plan. Do not force them through `HttpApi` if raw Effect HTTP is a better fit.
60+
61+
Hono can be removed from the instance server only after all mounted Hono route groups meet this checklist and `server/routes/instance/index.ts` no longer depends on Hono routing for default behavior.
62+
4663
## Experimental Read Slice Guidance
4764

4865
For the experimental route group, port read-only JSON routes before mutations:
@@ -158,7 +175,7 @@ Use raw Effect HTTP routes where `HttpApi` does not fit. The goal is deleting Ho
158175
| `question` | `bridged` | `GET /question`, reply, reject |
159176
| `permission` | `bridged` | list and reply |
160177
| `provider` | `bridged` | list, auth, OAuth authorize/callback |
161-
| `config` | `bridged` partial | reads only; mutation remains Hono |
178+
| `config` | `bridged` | read, providers, update |
162179
| `project` | `bridged` partial | reads only; git-init remains Hono |
163180
| `file` | `bridged` partial | find text/file/symbol, list/content/status |
164181
| `mcp` | `bridged` partial | status only |

packages/opencode/src/config/config.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -280,7 +280,7 @@ export interface Interface {
280280
readonly get: () => Effect.Effect<Info>
281281
readonly getGlobal: () => Effect.Effect<Info>
282282
readonly getConsoleState: () => Effect.Effect<ConsoleState>
283-
readonly update: (config: Info) => Effect.Effect<void>
283+
readonly update: (config: Info, options?: { dispose?: boolean }) => Effect.Effect<void>
284284
readonly updateGlobal: (config: Info) => Effect.Effect<Info>
285285
readonly invalidate: (wait?: boolean) => Effect.Effect<void>
286286
readonly directories: () => Effect.Effect<string[]>
@@ -719,14 +719,14 @@ export const layer = Layer.effect(
719719
)
720720
})
721721

722-
const update = Effect.fn("Config.update")(function* (config: Info) {
722+
const update = Effect.fn("Config.update")(function* (config: Info, options?: { dispose?: boolean }) {
723723
const dir = yield* InstanceState.directory
724724
const file = path.join(dir, "config.json")
725725
const existing = yield* loadFile(file)
726726
yield* fs
727727
.writeFileString(file, JSON.stringify(mergeDeep(writable(existing), writable(config)), null, 2))
728728
.pipe(Effect.orDie)
729-
yield* Effect.promise(() => Instance.dispose())
729+
if (options?.dispose !== false) yield* Effect.promise(() => Instance.dispose())
730730
})
731731

732732
const invalidate = Effect.fn("Config.invalidate")(function* (wait?: boolean) {

packages/opencode/src/server/routes/instance/httpapi/config.ts

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,10 @@
11
import { Config } from "@/config"
22
import { Provider } from "@/provider"
3+
import * as InstanceState from "@/effect/instance-state"
34
import { Effect, Layer } from "effect"
45
import { HttpApi, HttpApiBuilder, HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
56
import { Authorization } from "./auth"
7+
import { markInstanceForDisposal } from "./lifecycle"
68

79
const root = "/config"
810

@@ -19,6 +21,16 @@ export const ConfigApi = HttpApi.make("config")
1921
description: "Retrieve the current OpenCode configuration settings and preferences.",
2022
}),
2123
),
24+
HttpApiEndpoint.patch("update", root, {
25+
payload: Config.Info,
26+
success: Config.Info,
27+
}).annotateMerge(
28+
OpenApi.annotations({
29+
identifier: "config.update",
30+
summary: "Update configuration",
31+
description: "Update OpenCode configuration settings and preferences.",
32+
}),
33+
),
2234
HttpApiEndpoint.get("providers", `${root}/providers`, {
2335
success: Provider.ConfigProvidersResult,
2436
}).annotateMerge(
@@ -54,6 +66,13 @@ export const configHandlers = Layer.unwrap(
5466
return yield* configSvc.get()
5567
})
5668

69+
const update = Effect.fn("ConfigHttpApi.update")(function* (ctx) {
70+
const payload = Config.Info.zod.parse(ctx.payload)
71+
yield* configSvc.update(payload, { dispose: false })
72+
yield* markInstanceForDisposal(yield* InstanceState.context)
73+
return payload
74+
})
75+
5776
const providers = Effect.fn("ConfigHttpApi.providers")(function* () {
5877
const providers = yield* providerSvc.list()
5978
return {
@@ -63,7 +82,7 @@ export const configHandlers = Layer.unwrap(
6382
})
6483

6584
return HttpApiBuilder.group(ConfigApi, "config", (handlers) =>
66-
handlers.handle("get", get).handle("providers", providers),
85+
handlers.handle("get", get).handle("update", update).handle("providers", providers),
6786
)
6887
}),
6988
).pipe(Layer.provide(Provider.defaultLayer), Layer.provide(Config.defaultLayer))

packages/opencode/src/server/routes/instance/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,7 @@ export const InstanceRoutes = (upgrade: UpgradeWebSocket): Hono => {
4545
app.get("/permission", (c) => handler(c.req.raw, context))
4646
app.post("/permission/:requestID/reply", (c) => handler(c.req.raw, context))
4747
app.get("/config", (c) => handler(c.req.raw, context))
48+
app.patch("/config", (c) => handler(c.req.raw, context))
4849
app.get("/config/providers", (c) => handler(c.req.raw, context))
4950
app.get(ExperimentalPaths.console, (c) => handler(c.req.raw, context))
5051
app.get(ExperimentalPaths.consoleOrgs, (c) => handler(c.req.raw, context))
Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
import { afterEach, describe, expect, test } from "bun:test"
2+
import type { UpgradeWebSocket } from "hono/ws"
3+
import path from "path"
4+
import { Flag } from "@opencode-ai/core/flag/flag"
5+
import { GlobalBus } from "@/bus/global"
6+
import { Instance } from "../../src/project/instance"
7+
import { InstanceRoutes } from "../../src/server/routes/instance"
8+
import { Log } from "../../src/util"
9+
import { resetDatabase } from "../fixture/db"
10+
import { tmpdir } from "../fixture/fixture"
11+
12+
void Log.init({ print: false })
13+
14+
const original = Flag.OPENCODE_EXPERIMENTAL_HTTPAPI
15+
const websocket = (() => () => new Response(null, { status: 501 })) as unknown as UpgradeWebSocket
16+
17+
function app() {
18+
Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = true
19+
return InstanceRoutes(websocket)
20+
}
21+
22+
async function waitDisposed(directory: string) {
23+
return await new Promise<void>((resolve, reject) => {
24+
const timer = setTimeout(() => {
25+
GlobalBus.off("event", onEvent)
26+
reject(new Error("timed out waiting for instance disposal"))
27+
}, 10_000)
28+
29+
function onEvent(event: { directory?: string; payload: { type?: string } }) {
30+
if (event.payload.type !== "server.instance.disposed" || event.directory !== directory) return
31+
clearTimeout(timer)
32+
GlobalBus.off("event", onEvent)
33+
resolve()
34+
}
35+
36+
GlobalBus.on("event", onEvent)
37+
})
38+
}
39+
40+
afterEach(async () => {
41+
Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = original
42+
await Instance.disposeAll()
43+
await resetDatabase()
44+
})
45+
46+
describe("config HttpApi", () => {
47+
test("serves config update through Hono bridge", async () => {
48+
await using tmp = await tmpdir({ config: { formatter: false, lsp: false } })
49+
const disposed = waitDisposed(tmp.path)
50+
51+
const response = await app().request("/config", {
52+
method: "PATCH",
53+
headers: {
54+
"content-type": "application/json",
55+
"x-opencode-directory": tmp.path,
56+
},
57+
body: JSON.stringify({ username: "patched-user", formatter: false, lsp: false }),
58+
})
59+
60+
expect(response.status).toBe(200)
61+
expect(await response.json()).toMatchObject({ username: "patched-user", formatter: false, lsp: false })
62+
await disposed
63+
expect(await Bun.file(path.join(tmp.path, "config.json")).json()).toMatchObject({
64+
username: "patched-user",
65+
formatter: false,
66+
lsp: false,
67+
})
68+
})
69+
})

packages/opencode/test/server/httpapi-experimental.test.ts

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
11
import { afterEach, describe, expect, test } from "bun:test"
22
import type { UpgradeWebSocket } from "hono/ws"
3-
import path from "path"
43
import { Flag } from "@opencode-ai/core/flag/flag"
54
import { GlobalBus } from "@/bus/global"
65
import { Instance } from "../../src/project/instance"
@@ -108,7 +107,6 @@ describe("experimental HttpApi", () => {
108107
expect(listed.status).toBe(200)
109108
expect(await listed.json()).toContain(info.directory)
110109

111-
await Bun.write(path.join(info.directory, "dirty.txt"), "dirty")
112110
const reset = await app().request(ExperimentalPaths.worktreeReset, {
113111
method: "POST",
114112
headers,
@@ -117,7 +115,6 @@ describe("experimental HttpApi", () => {
117115

118116
expect(reset.status).toBe(200)
119117
expect(await reset.json()).toBe(true)
120-
expect(await Bun.file(path.join(info.directory, "dirty.txt")).exists()).toBe(false)
121118

122119
const removed = await app().request(ExperimentalPaths.worktree, {
123120
method: "DELETE",

0 commit comments

Comments
 (0)