From bd19dc57e25df57d309295a5df4b07e7376c672c Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Wed, 15 Apr 2026 23:24:24 -0400 Subject: [PATCH] feat: add project HttpApi read slice behind OPENCODE_EXPERIMENTAL_HTTPAPI - migrate Project.Info from Zod to Effect Schema (Schema.Class for Info, Schema.Struct for inner Icon/Commands/Time to avoid SDK drift) - derive .zod compat surfaces for Hono routes and UpdateInput - add project httpapi route with list and current endpoints - wire into httpapi/server.ts and bridge behind flag - fix bridge ordering: mount bridge sub-app at / before all Hono routes so Effect handlers always take priority when the flag is on - zero SDK diff confirmed --- packages/opencode/src/project/project.ts | 110 +++++++++--------- .../src/server/instance/httpapi/project.ts | 62 ++++++++++ .../src/server/instance/httpapi/server.ts | 3 + .../opencode/src/server/instance/index.ts | 17 +-- .../opencode/src/server/instance/project.ts | 8 +- 5 files changed, 136 insertions(+), 64 deletions(-) create mode 100644 packages/opencode/src/server/instance/httpapi/project.ts diff --git a/packages/opencode/src/project/project.ts b/packages/opencode/src/project/project.ts index 9c4ed58ce8..0cf920c7d8 100644 --- a/packages/opencode/src/project/project.ts +++ b/packages/opencode/src/project/project.ts @@ -1,4 +1,5 @@ import z from "zod" +import { zod } from "@/util/effect-zod" import { and, Database, eq } from "../storage/db" import { ProjectTable } from "./project.sql" import { SessionTable } from "../session/session.sql" @@ -8,7 +9,7 @@ import { BusEvent } from "@/bus/bus-event" import { GlobalBus } from "@/bus/global" import { which } from "../util/which" import { ProjectID } from "./schema" -import { Effect, Layer, Path, Scope, Context, Stream } from "effect" +import { Effect, Layer, Path, Scope, Context, Stream, Schema } from "effect" import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process" import { NodePath } from "@effect/platform-node" import { AppFileSystem } from "@opencode-ai/shared/filesystem" @@ -17,38 +18,39 @@ import * as CrossSpawnSpawner from "@/effect/cross-spawn-spawner" export namespace Project { const log = Log.create({ service: "project" }) - export const Info = z - .object({ - id: ProjectID.zod, - worktree: z.string(), - vcs: z.literal("git").optional(), - name: z.string().optional(), - icon: z - .object({ - url: z.string().optional(), - override: z.string().optional(), - color: z.string().optional(), - }) - .optional(), - commands: z - .object({ - start: z.string().optional().describe("Startup script to run when creating a new workspace (worktree)"), - }) - .optional(), - time: z.object({ - created: z.number(), - updated: z.number(), - initialized: z.number().optional(), - }), - sandboxes: z.array(z.string()), - }) - .meta({ - ref: "Project", - }) - export type Info = z.infer + const IconSchema = Schema.Struct({ + url: Schema.optional(Schema.String), + override: Schema.optional(Schema.String), + color: Schema.optional(Schema.String), + }) + + const CommandsSchema = Schema.Struct({ + start: Schema.optional( + Schema.String.annotate({ description: "Startup script to run when creating a new workspace (worktree)" }), + ), + }) + + const TimeSchema = Schema.Struct({ + created: Schema.Number, + updated: Schema.Number, + initialized: Schema.optional(Schema.Number), + }) + + export class Info extends Schema.Class("Project")({ + id: ProjectID, + worktree: Schema.String, + vcs: Schema.optional(Schema.Literal("git")), + name: Schema.optional(Schema.String), + icon: Schema.optional(IconSchema), + commands: Schema.optional(CommandsSchema), + time: TimeSchema, + sandboxes: Schema.mutable(Schema.Array(Schema.String)), + }) { + static readonly zod = zod(this) + } export const Event = { - Updated: BusEvent.define("project.updated", Info), + Updated: BusEvent.define("project.updated", Info.zod), } type Row = typeof ProjectTable.$inferSelect @@ -58,10 +60,10 @@ export namespace Project { row.icon_url || row.icon_color ? { url: row.icon_url ?? undefined, color: row.icon_color ?? undefined } : undefined - return { - id: row.id, + return new Info({ + id: row.id as ProjectID, worktree: row.worktree, - vcs: row.vcs ? Info.shape.vcs.parse(row.vcs) : undefined, + vcs: row.vcs === "git" ? "git" : undefined, name: row.name ?? undefined, icon, time: { @@ -71,14 +73,14 @@ export namespace Project { }, sandboxes: row.sandboxes, commands: row.commands ?? undefined, - } + }) } export const UpdateInput = z.object({ projectID: ProjectID.zod, name: z.string().optional(), - icon: Info.shape.icon.optional(), - commands: Info.shape.commands.optional(), + icon: zod(IconSchema).optional(), + commands: zod(CommandsSchema).optional(), }) export type UpdateInput = z.infer @@ -142,7 +144,7 @@ export namespace Project { }), ) - const fakeVcs = Info.shape.vcs.parse(Flag.OPENCODE_FAKE_VCS) + const fakeVcs: Info["vcs"] = Flag.OPENCODE_FAKE_VCS === "git" ? "git" : undefined const resolveGitPath = (cwd: string, name: string) => { if (!name) return cwd @@ -249,27 +251,23 @@ export namespace Project { const row = yield* db((d) => d.select().from(ProjectTable).where(eq(ProjectTable.id, data.id)).get()) const existing = row ? fromRow(row) - : { - id: data.id, + : new Info({ + id: data.id as ProjectID, worktree: data.worktree, vcs: data.vcs, - sandboxes: [] as string[], + sandboxes: [], time: { created: Date.now(), updated: Date.now() }, - } + }) if (Flag.OPENCODE_EXPERIMENTAL_ICON_DISCOVERY) yield* discover(existing).pipe(Effect.ignore, Effect.forkIn(scope)) - const result: Info = { - ...existing, - worktree: data.worktree, - vcs: data.vcs, - time: { ...existing.time, updated: Date.now() }, - } - if (data.sandbox !== result.worktree && !result.sandboxes.includes(data.sandbox)) - result.sandboxes.push(data.sandbox) - result.sandboxes = yield* Effect.forEach( - result.sandboxes, + const sandboxes = + data.sandbox !== existing.worktree && !existing.sandboxes.includes(data.sandbox) + ? [...existing.sandboxes, data.sandbox] + : existing.sandboxes + const filteredSandboxes = yield* Effect.forEach( + sandboxes, (s) => fs.exists(s).pipe( Effect.orDie, @@ -278,6 +276,14 @@ export namespace Project { { concurrency: "unbounded" }, ).pipe(Effect.map((arr) => arr.filter((x): x is string => x !== undefined))) + const result = new Info({ + ...existing, + worktree: data.worktree, + vcs: data.vcs, + time: { ...existing.time, updated: Date.now() }, + sandboxes: filteredSandboxes, + }) + yield* db((d) => d .insert(ProjectTable) diff --git a/packages/opencode/src/server/instance/httpapi/project.ts b/packages/opencode/src/server/instance/httpapi/project.ts new file mode 100644 index 0000000000..ba338fef8c --- /dev/null +++ b/packages/opencode/src/server/instance/httpapi/project.ts @@ -0,0 +1,62 @@ +import { Instance } from "@/project/instance" +import { Project } from "@/project/project" +import { Effect, Layer, Schema } from "effect" +import { HttpApi, HttpApiBuilder, HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi" + +const root = "/project" + +export const ProjectApi = HttpApi.make("project") + .add( + HttpApiGroup.make("project") + .add( + HttpApiEndpoint.get("list", root, { + success: Schema.Array(Project.Info), + }).annotateMerge( + OpenApi.annotations({ + identifier: "project.list", + summary: "List all projects", + description: "Get a list of projects that have been opened with OpenCode.", + }), + ), + HttpApiEndpoint.get("current", `${root}/current`, { + success: Project.Info, + }).annotateMerge( + OpenApi.annotations({ + identifier: "project.current", + summary: "Get current project", + description: "Retrieve the currently active project that OpenCode is working with.", + }), + ), + ) + .annotateMerge( + OpenApi.annotations({ + title: "project", + description: "Project routes.", + }), + ), + ) + .annotateMerge( + OpenApi.annotations({ + title: "opencode HttpApi", + version: "0.0.1", + description: "Effect HttpApi surface for instance routes.", + }), + ) + +export const projectHandlers = Layer.unwrap( + 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 Instance.project + }) + + return HttpApiBuilder.group(ProjectApi, "project", (handlers) => + handlers.handle("list", list).handle("current", current), + ) + }), +).pipe(Layer.provide(Project.defaultLayer)) diff --git a/packages/opencode/src/server/instance/httpapi/server.ts b/packages/opencode/src/server/instance/httpapi/server.ts index 62ffb5940d..608f7e6543 100644 --- a/packages/opencode/src/server/instance/httpapi/server.ts +++ b/packages/opencode/src/server/instance/httpapi/server.ts @@ -11,6 +11,7 @@ import { Instance } from "@/project/instance" import { lazy } from "@/util/lazy" import { Filesystem } from "@/util" import { PermissionApi, permissionHandlers } from "./permission" +import { ProjectApi, projectHandlers } from "./project" import { ProviderApi, providerHandlers } from "./provider" import { QuestionApi, questionHandlers } from "./question" @@ -109,11 +110,13 @@ export namespace ExperimentalHttpApiServer { const QuestionSecured = QuestionApi.middleware(Authorization) const PermissionSecured = PermissionApi.middleware(Authorization) const ProviderSecured = ProviderApi.middleware(Authorization) + const ProjectSecured = ProjectApi.middleware(Authorization) export const routes = Layer.mergeAll( HttpApiBuilder.layer(QuestionSecured).pipe(Layer.provide(questionHandlers)), HttpApiBuilder.layer(PermissionSecured).pipe(Layer.provide(permissionHandlers)), HttpApiBuilder.layer(ProviderSecured).pipe(Layer.provide(providerHandlers)), + HttpApiBuilder.layer(ProjectSecured).pipe(Layer.provide(projectHandlers)), ).pipe( Layer.provide(auth), Layer.provide(normalize), diff --git a/packages/opencode/src/server/instance/index.ts b/packages/opencode/src/server/instance/index.ts index 874790f1cc..2d3ebfcb17 100644 --- a/packages/opencode/src/server/instance/index.ts +++ b/packages/opencode/src/server/instance/index.ts @@ -30,14 +30,7 @@ import { WorkspaceRouterMiddleware } from "./middleware" import { AppRuntime } from "@/effect/app-runtime" export const InstanceRoutes = (upgrade: UpgradeWebSocket): Hono => { - const app = new Hono() - .use(WorkspaceRouterMiddleware(upgrade)) - .route("/project", ProjectRoutes()) - .route("/pty", PtyRoutes(upgrade)) - .route("/config", ConfigRoutes()) - .route("/experimental", ExperimentalRoutes()) - .route("/session", SessionRoutes()) - .route("/permission", PermissionRoutes()) + const app = new Hono().use(WorkspaceRouterMiddleware(upgrade)) if (Flag.OPENCODE_EXPERIMENTAL_HTTPAPI) { const handler = ExperimentalHttpApiServer.webHandler().handler @@ -47,9 +40,17 @@ export const InstanceRoutes = (upgrade: UpgradeWebSocket): Hono => { .all("/permission", (c) => handler(c.req.raw)) .all("/permission/*", (c) => handler(c.req.raw)) .all("/provider/auth", (c) => handler(c.req.raw)) + .all("/project", (c) => handler(c.req.raw)) + .all("/project/*", (c) => handler(c.req.raw)) } return app + .route("/project", ProjectRoutes()) + .route("/pty", PtyRoutes(upgrade)) + .route("/config", ConfigRoutes()) + .route("/experimental", ExperimentalRoutes()) + .route("/session", SessionRoutes()) + .route("/permission", PermissionRoutes()) .route("/question", QuestionRoutes()) .route("/provider", ProviderRoutes()) .route("/sync", SyncRoutes()) diff --git a/packages/opencode/src/server/instance/project.ts b/packages/opencode/src/server/instance/project.ts index 7a8e0353a2..7d13e0f34d 100644 --- a/packages/opencode/src/server/instance/project.ts +++ b/packages/opencode/src/server/instance/project.ts @@ -23,7 +23,7 @@ export const ProjectRoutes = lazy(() => description: "List of projects", content: { "application/json": { - schema: resolver(Project.Info.array()), + schema: resolver(z.array(Project.Info.zod)), }, }, }, @@ -45,7 +45,7 @@ export const ProjectRoutes = lazy(() => description: "Current project information", content: { "application/json": { - schema: resolver(Project.Info), + schema: resolver(Project.Info.zod), }, }, }, @@ -66,7 +66,7 @@ export const ProjectRoutes = lazy(() => description: "Project information after git initialization", content: { "application/json": { - schema: resolver(Project.Info), + schema: resolver(Project.Info.zod), }, }, }, @@ -99,7 +99,7 @@ export const ProjectRoutes = lazy(() => description: "Updated project information", content: { "application/json": { - schema: resolver(Project.Info), + schema: resolver(Project.Info.zod), }, }, },