From 592218216699f7ede08d92c4ebe333c247f858b6 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Mon, 13 Apr 2026 23:15:07 -0400 Subject: [PATCH 1/4] add experimental file HttpApi read slice Move the shared file DTOs to Effect Schema, add a parallel experimental file HttpApi surface for list/content/status, and cover the new read-only slice with a server test. --- packages/opencode/src/file/index.ts | 103 +++++++++--------- packages/opencode/src/server/instance/file.ts | 6 +- .../src/server/instance/httpapi/file.ts | 96 ++++++++++++++++ .../src/server/instance/httpapi/index.ts | 7 +- .../opencode/test/server/file-httpapi.test.ts | 45 ++++++++ 5 files changed, 199 insertions(+), 58 deletions(-) create mode 100644 packages/opencode/src/server/instance/httpapi/file.ts create mode 100644 packages/opencode/test/server/file-httpapi.test.ts diff --git a/packages/opencode/src/file/index.ts b/packages/opencode/src/file/index.ts index 6730957f23..df9d4e3994 100644 --- a/packages/opencode/src/file/index.ts +++ b/packages/opencode/src/file/index.ts @@ -3,7 +3,8 @@ import { InstanceState } from "@/effect/instance-state" import { makeRuntime } from "@/effect/run-service" import { AppFileSystem } from "@/filesystem" import { Git } from "@/git" -import { Effect, Layer, Context } from "effect" +import { zod } from "@/util/effect-zod" +import { Effect, Layer, Context, Schema } from "effect" import * as Stream from "effect/Stream" import { formatPatch, structuredPatch } from "diff" import fuzzysort from "fuzzysort" @@ -17,62 +18,56 @@ import { Protected } from "./protected" import { Ripgrep } from "./ripgrep" export namespace File { - export const Info = z - .object({ - path: z.string(), - added: z.number().int(), - removed: z.number().int(), - status: z.enum(["added", "deleted", "modified"]), - }) - .meta({ - ref: "File", - }) + export class Info extends Schema.Class("File")({ + path: Schema.String, + added: Schema.Number, + removed: Schema.Number, + status: Schema.Union([Schema.Literal("added"), Schema.Literal("deleted"), Schema.Literal("modified")]), + }) { + static readonly zod = zod(this) + } - export type Info = z.infer + export class Node extends Schema.Class("FileNode")({ + name: Schema.String, + path: Schema.String, + absolute: Schema.String, + type: Schema.Union([Schema.Literal("file"), Schema.Literal("directory")]), + ignored: Schema.Boolean, + }) { + static readonly zod = zod(this) + } - export const Node = z - .object({ - name: z.string(), - path: z.string(), - absolute: z.string(), - type: z.enum(["file", "directory"]), - ignored: z.boolean(), - }) - .meta({ - ref: "FileNode", - }) - export type Node = z.infer + export class Hunk extends Schema.Class("FileContentHunk")({ + oldStart: Schema.Number, + oldLines: Schema.Number, + newStart: Schema.Number, + newLines: Schema.Number, + lines: Schema.Array(Schema.String), + }) { + static readonly zod = zod(this) + } - export const Content = z - .object({ - type: z.enum(["text", "binary"]), - content: z.string(), - diff: z.string().optional(), - patch: z - .object({ - oldFileName: z.string(), - newFileName: z.string(), - oldHeader: z.string().optional(), - newHeader: z.string().optional(), - hunks: z.array( - z.object({ - oldStart: z.number(), - oldLines: z.number(), - newStart: z.number(), - newLines: z.number(), - lines: z.array(z.string()), - }), - ), - index: z.string().optional(), - }) - .optional(), - encoding: z.literal("base64").optional(), - mimeType: z.string().optional(), - }) - .meta({ - ref: "FileContent", - }) - export type Content = z.infer + export class Patch extends Schema.Class("FileContentPatch")({ + oldFileName: Schema.String, + newFileName: Schema.String, + oldHeader: Schema.optional(Schema.String), + newHeader: Schema.optional(Schema.String), + hunks: Schema.Array(Hunk), + index: Schema.optional(Schema.String), + }) { + static readonly zod = zod(this) + } + + export class Content extends Schema.Class("FileContent")({ + type: Schema.Union([Schema.Literal("text"), Schema.Literal("binary")]), + content: Schema.String, + diff: Schema.optional(Schema.String), + patch: Schema.optional(Patch), + encoding: Schema.optional(Schema.Literal("base64")), + mimeType: Schema.optional(Schema.String), + }) { + static readonly zod = zod(this) + } export const Event = { Edited: BusEvent.define( diff --git a/packages/opencode/src/server/instance/file.ts b/packages/opencode/src/server/instance/file.ts index db5e227770..bf5072c76c 100644 --- a/packages/opencode/src/server/instance/file.ts +++ b/packages/opencode/src/server/instance/file.ts @@ -126,7 +126,7 @@ export const FileRoutes = lazy(() => description: "Files and directories", content: { "application/json": { - schema: resolver(File.Node.array()), + schema: resolver(z.array(File.Node.zod)), }, }, }, @@ -159,7 +159,7 @@ export const FileRoutes = lazy(() => description: "File content", content: { "application/json": { - schema: resolver(File.Content), + schema: resolver(File.Content.zod), }, }, }, @@ -192,7 +192,7 @@ export const FileRoutes = lazy(() => description: "File status", content: { "application/json": { - schema: resolver(File.Info.array()), + schema: resolver(z.array(File.Info.zod)), }, }, }, diff --git a/packages/opencode/src/server/instance/httpapi/file.ts b/packages/opencode/src/server/instance/httpapi/file.ts new file mode 100644 index 0000000000..d637912bb0 --- /dev/null +++ b/packages/opencode/src/server/instance/httpapi/file.ts @@ -0,0 +1,96 @@ +import { AppLayer } from "@/effect/app-runtime" +import { memoMap } from "@/effect/run-service" +import { File } from "@/file" +import { lazy } from "@/util/lazy" +import { Effect, Layer, Schema } from "effect" +import { HttpRouter, HttpServer } from "effect/unstable/http" +import { HttpApi, HttpApiBuilder, HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi" +import type { Handler } from "hono" + +const root = "/experimental/httpapi/file" + +const Api = HttpApi.make("file") + .add( + HttpApiGroup.make("file") + .add( + HttpApiEndpoint.get("list", root, { + query: { path: Schema.optional(Schema.String) }, + success: Schema.Array(File.Node), + }).annotateMerge( + OpenApi.annotations({ + identifier: "file.list", + summary: "List files", + description: "List files and directories in a specified path.", + }), + ), + HttpApiEndpoint.get("content", `${root}/content`, { + query: { path: Schema.String }, + success: File.Content, + }).annotateMerge( + OpenApi.annotations({ + identifier: "file.read", + summary: "Read file", + description: "Read the content of a specified file.", + }), + ), + HttpApiEndpoint.get("status", `${root}/status`, { + success: Schema.Array(File.Info), + }).annotateMerge( + OpenApi.annotations({ + identifier: "file.status", + summary: "Get file status", + description: "Get the git status of all files in the project.", + }), + ), + ) + .annotateMerge( + OpenApi.annotations({ + title: "file", + description: "Experimental HttpApi file routes.", + }), + ), + ) + .annotateMerge( + OpenApi.annotations({ + title: "opencode experimental HttpApi", + version: "0.0.1", + description: "Experimental HttpApi surface for selected instance routes.", + }), + ) + +const list = Effect.fn("FileHttpApi.list")(function* (ctx: { query: { path?: string } }) { + const svc = yield* File.Service + return Schema.decodeUnknownSync(Schema.Array(File.Node))(yield* svc.list(ctx.query.path)) +}) + +const content = Effect.fn("FileHttpApi.content")(function* (ctx: { query: { path: string } }) { + const svc = yield* File.Service + return Schema.decodeUnknownSync(File.Content)(yield* svc.read(ctx.query.path)) +}) + +const status = Effect.fn("FileHttpApi.status")(function* () { + const svc = yield* File.Service + return Schema.decodeUnknownSync(Schema.Array(File.Info))(yield* svc.status()) +}) + +const FileLive = HttpApiBuilder.group(Api, "file", (handlers) => + handlers.handle("list", list).handle("content", content).handle("status", status), +) + +const web = lazy(() => + HttpRouter.toWebHandler( + Layer.mergeAll( + AppLayer, + HttpApiBuilder.layer(Api, { openapiPath: `${root}/doc` }).pipe( + Layer.provide(FileLive), + Layer.provide(HttpServer.layerServices), + ), + ), + { + disableLogger: true, + memoMap, + }, + ), +) + +export const FileHttpApiHandler: Handler = (c, _next) => web().handler(c.req.raw) diff --git a/packages/opencode/src/server/instance/httpapi/index.ts b/packages/opencode/src/server/instance/httpapi/index.ts index 523041de84..f2f8518972 100644 --- a/packages/opencode/src/server/instance/httpapi/index.ts +++ b/packages/opencode/src/server/instance/httpapi/index.ts @@ -1,7 +1,12 @@ import { lazy } from "@/util/lazy" import { Hono } from "hono" +import { FileHttpApiHandler } from "./file" import { QuestionHttpApiHandler } from "./question" export const HttpApiRoutes = lazy(() => - new Hono().all("/question", QuestionHttpApiHandler).all("/question/*", QuestionHttpApiHandler), + new Hono() + .all("/question", QuestionHttpApiHandler) + .all("/question/*", QuestionHttpApiHandler) + .all("/file", FileHttpApiHandler) + .all("/file/*", FileHttpApiHandler), ) diff --git a/packages/opencode/test/server/file-httpapi.test.ts b/packages/opencode/test/server/file-httpapi.test.ts new file mode 100644 index 0000000000..e849a1acee --- /dev/null +++ b/packages/opencode/test/server/file-httpapi.test.ts @@ -0,0 +1,45 @@ +import { describe, expect, test } from "bun:test" +import path from "path" +import { Server } from "../../src/server/server" +import { Log } from "../../src/util/log" +import { tmpdir } from "../fixture/fixture" + +Log.init({ print: false }) + +describe("experimental file httpapi", () => { + test("lists files, reads content, reports status, and serves docs", async () => { + await using tmp = await tmpdir({ + git: true, + init: async (dir) => { + await Bun.write(path.join(dir, "note.txt"), "hello") + }, + }) + const app = Server.Default().app + const headers = { + "content-type": "application/json", + "x-opencode-directory": tmp.path, + } + + const list = await app.request("/experimental/httpapi/file?path=.", { headers }) + expect(list.status).toBe(200) + const items = await list.json() + expect(items.some((item: { name: string }) => item.name === "note.txt")).toBe(true) + + const read = await app.request("/experimental/httpapi/file/content?path=note.txt", { headers }) + expect(read.status).toBe(200) + const content = await read.json() + expect(content.type).toBe("text") + expect(content.content).toContain("hello") + + const status = await app.request("/experimental/httpapi/file/status", { headers }) + expect(status.status).toBe(200) + expect(Array.isArray(await status.json())).toBe(true) + + const doc = await app.request("/experimental/httpapi/file/doc", { headers }) + expect(doc.status).toBe(200) + const spec = await doc.json() + expect(spec.paths["/experimental/httpapi/file"]?.get?.operationId).toBe("file.list") + expect(spec.paths["/experimental/httpapi/file/content"]?.get?.operationId).toBe("file.read") + expect(spec.paths["/experimental/httpapi/file/status"]?.get?.operationId).toBe("file.status") + }) +}) From a1cb64954a2866962ca2bdff689043cdbf265c98 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Tue, 14 Apr 2026 09:23:16 -0400 Subject: [PATCH 2/4] test(file): dispose instances after httpapi test Align the file HttpApi server test with the shared instance cleanup pattern used by the other experimental slices. --- packages/opencode/test/server/file-httpapi.test.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/packages/opencode/test/server/file-httpapi.test.ts b/packages/opencode/test/server/file-httpapi.test.ts index e849a1acee..15b6985296 100644 --- a/packages/opencode/test/server/file-httpapi.test.ts +++ b/packages/opencode/test/server/file-httpapi.test.ts @@ -1,11 +1,16 @@ -import { describe, expect, test } from "bun:test" +import { afterEach, describe, expect, test } from "bun:test" import path from "path" +import { Instance } from "../../src/project/instance" import { Server } from "../../src/server/server" import { Log } from "../../src/util/log" import { tmpdir } from "../fixture/fixture" Log.init({ print: false }) +afterEach(async () => { + await Instance.disposeAll() +}) + describe("experimental file httpapi", () => { test("lists files, reads content, reports status, and serves docs", async () => { await using tmp = await tmpdir({ From e34bbb394d0dcedf044237b126ccd17ac289f7b5 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Tue, 14 Apr 2026 19:40:05 -0400 Subject: [PATCH 3/4] refactor(file): use effectful HttpApi group builder Build the file HttpApi handlers with an effectful group callback so the file service is resolved once at layer construction time and the endpoint handlers close over the resulting methods. --- .../src/server/instance/httpapi/file.ts | 33 ++++++++++--------- 1 file changed, 18 insertions(+), 15 deletions(-) diff --git a/packages/opencode/src/server/instance/httpapi/file.ts b/packages/opencode/src/server/instance/httpapi/file.ts index d637912bb0..6287a5308f 100644 --- a/packages/opencode/src/server/instance/httpapi/file.ts +++ b/packages/opencode/src/server/instance/httpapi/file.ts @@ -58,24 +58,27 @@ const Api = HttpApi.make("file") }), ) -const list = Effect.fn("FileHttpApi.list")(function* (ctx: { query: { path?: string } }) { - const svc = yield* File.Service - return Schema.decodeUnknownSync(Schema.Array(File.Node))(yield* svc.list(ctx.query.path)) -}) +const FileLive = HttpApiBuilder.group( + Api, + "file", + Effect.fn("FileHttpApi.handlers")(function* (handlers) { + const svc = yield* File.Service -const content = Effect.fn("FileHttpApi.content")(function* (ctx: { query: { path: string } }) { - const svc = yield* File.Service - return Schema.decodeUnknownSync(File.Content)(yield* svc.read(ctx.query.path)) -}) + const list = Effect.fn("FileHttpApi.list")(function* (ctx: { query: { path?: string } }) { + return Schema.decodeUnknownSync(Schema.Array(File.Node))(yield* svc.list(ctx.query.path)) + }) -const status = Effect.fn("FileHttpApi.status")(function* () { - const svc = yield* File.Service - return Schema.decodeUnknownSync(Schema.Array(File.Info))(yield* svc.status()) -}) + const content = Effect.fn("FileHttpApi.content")(function* (ctx: { query: { path: string } }) { + return Schema.decodeUnknownSync(File.Content)(yield* svc.read(ctx.query.path)) + }) -const FileLive = HttpApiBuilder.group(Api, "file", (handlers) => - handlers.handle("list", list).handle("content", content).handle("status", status), -) + const status = Effect.fn("FileHttpApi.status")(function* () { + return Schema.decodeUnknownSync(Schema.Array(File.Info))(yield* svc.status()) + }) + + return handlers.handle("list", list).handle("content", content).handle("status", status) + }), +).pipe(Layer.provide(File.defaultLayer)) const web = lazy(() => HttpRouter.toWebHandler( From 8baf829f9729f55dd1b3cee6629a28c12d3c3371 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Tue, 14 Apr 2026 21:07:50 -0400 Subject: [PATCH 4/4] refactor(file): use Schema.Literals --- packages/opencode/src/file/index.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/opencode/src/file/index.ts b/packages/opencode/src/file/index.ts index df9d4e3994..71cdafc77b 100644 --- a/packages/opencode/src/file/index.ts +++ b/packages/opencode/src/file/index.ts @@ -22,7 +22,7 @@ export namespace File { path: Schema.String, added: Schema.Number, removed: Schema.Number, - status: Schema.Union([Schema.Literal("added"), Schema.Literal("deleted"), Schema.Literal("modified")]), + status: Schema.Literals(["added", "deleted", "modified"]), }) { static readonly zod = zod(this) } @@ -31,7 +31,7 @@ export namespace File { name: Schema.String, path: Schema.String, absolute: Schema.String, - type: Schema.Union([Schema.Literal("file"), Schema.Literal("directory")]), + type: Schema.Literals(["file", "directory"]), ignored: Schema.Boolean, }) { static readonly zod = zod(this) @@ -59,7 +59,7 @@ export namespace File { } export class Content extends Schema.Class("FileContent")({ - type: Schema.Union([Schema.Literal("text"), Schema.Literal("binary")]), + type: Schema.Literals(["text", "binary"]), content: Schema.String, diff: Schema.optional(Schema.String), patch: Schema.optional(Patch),