From 6219fe1482a3877bc9c16c57d82118de78f3d4b5 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Thu, 16 Apr 2026 22:13:50 -0400 Subject: [PATCH 1/2] refactor: move workspace routes onto HttpApi --- .../src/control-plane/adaptors/index.ts | 21 ++- .../src/control-plane/adaptors/worktree.ts | 6 +- packages/opencode/src/control-plane/types.ts | 37 +++-- .../opencode/src/control-plane/workspace.ts | 66 +++++++-- .../src/server/instance/httpapi/server.ts | 3 + .../src/server/instance/httpapi/workspace.ts | 136 ++++++++++++++++++ .../opencode/src/server/instance/index.ts | 18 ++- .../opencode/src/server/instance/workspace.ts | 39 ++--- 8 files changed, 264 insertions(+), 62 deletions(-) create mode 100644 packages/opencode/src/server/instance/httpapi/workspace.ts diff --git a/packages/opencode/src/control-plane/adaptors/index.ts b/packages/opencode/src/control-plane/adaptors/index.ts index 291e392eab..b1627bab7d 100644 --- a/packages/opencode/src/control-plane/adaptors/index.ts +++ b/packages/opencode/src/control-plane/adaptors/index.ts @@ -1,12 +1,23 @@ import { lazy } from "@/util/lazy" +import { Schema } from "effect" +import z from "zod" import type { ProjectID } from "@/project/schema" import type { WorkspaceAdaptor } from "../types" -export type WorkspaceAdaptorEntry = { - type: string - name: string - description: string -} +const WorkspaceAdaptorEntryZod = z.object({ + type: z.string(), + name: z.string(), + description: z.string(), +}) + +const _WorkspaceAdaptorEntry = Schema.Struct({ + type: Schema.String, + name: Schema.String, + description: Schema.String, +}) + +export const WorkspaceAdaptorEntry = Object.assign(_WorkspaceAdaptorEntry, { zod: WorkspaceAdaptorEntryZod }) +export type WorkspaceAdaptorEntry = Schema.Schema.Type const BUILTIN: Record Promise> = { worktree: lazy(async () => (await import("./worktree")).WorktreeAdaptor), diff --git a/packages/opencode/src/control-plane/adaptors/worktree.ts b/packages/opencode/src/control-plane/adaptors/worktree.ts index 2bfb7debaa..121fd9213b 100644 --- a/packages/opencode/src/control-plane/adaptors/worktree.ts +++ b/packages/opencode/src/control-plane/adaptors/worktree.ts @@ -4,9 +4,9 @@ import { Worktree } from "@/worktree" import { type WorkspaceAdaptor, WorkspaceInfo } from "../types" const WorktreeConfig = z.object({ - name: WorkspaceInfo.shape.name, - branch: WorkspaceInfo.shape.branch.unwrap(), - directory: WorkspaceInfo.shape.directory.unwrap(), + name: WorkspaceInfo.zod.shape.name, + branch: WorkspaceInfo.zod.shape.branch.unwrap(), + directory: WorkspaceInfo.zod.shape.directory.unwrap(), }) export const WorktreeAdaptor: WorkspaceAdaptor = { diff --git a/packages/opencode/src/control-plane/types.ts b/packages/opencode/src/control-plane/types.ts index 3961cd0e2a..082f31089e 100644 --- a/packages/opencode/src/control-plane/types.ts +++ b/packages/opencode/src/control-plane/types.ts @@ -1,17 +1,34 @@ import z from "zod" +import { Schema } from "effect" import { ProjectID } from "@/project/schema" import { WorkspaceID } from "./schema" -export const WorkspaceInfo = z.object({ - id: WorkspaceID.zod, - type: z.string(), - name: z.string(), - branch: z.string().nullable(), - directory: z.string().nullable(), - extra: z.unknown().nullable(), - projectID: ProjectID.zod, -}) -export type WorkspaceInfo = z.infer +const WorkspaceInfoZod = z + .object({ + id: WorkspaceID.zod, + type: z.string(), + name: z.string(), + branch: z.string().nullable(), + directory: z.string().nullable(), + extra: z.unknown().nullable(), + projectID: ProjectID.zod, + }) + .meta({ + ref: "Workspace", + }) + +const _WorkspaceInfo = Schema.Struct({ + id: WorkspaceID, + type: Schema.String, + name: Schema.String, + branch: Schema.NullOr(Schema.String), + directory: Schema.NullOr(Schema.String), + extra: Schema.NullOr(Schema.Unknown), + projectID: ProjectID, +}).annotate({ identifier: "Workspace" }) + +export const WorkspaceInfo = Object.assign(_WorkspaceInfo, { zod: WorkspaceInfoZod }) +export type WorkspaceInfo = Schema.Schema.Type export type Target = | { diff --git a/packages/opencode/src/control-plane/workspace.ts b/packages/opencode/src/control-plane/workspace.ts index 3af11707e8..db41fd5d59 100644 --- a/packages/opencode/src/control-plane/workspace.ts +++ b/packages/opencode/src/control-plane/workspace.ts @@ -25,18 +25,25 @@ import { errorData } from "@/util/error" import { AppRuntime } from "@/effect/app-runtime" import { EventSequenceTable } from "@/sync/event.sql" import { waitEvent } from "./util" +import { Schema } from "effect" -export const Info = WorkspaceInfo.meta({ - ref: "Workspace", -}) -export type Info = z.infer +export const Info = WorkspaceInfo +export type Info = WorkspaceInfo -export const ConnectionStatus = z.object({ +const ConnectionStatusZod = z.object({ workspaceID: WorkspaceID.zod, status: z.enum(["connected", "connecting", "disconnected", "error"]), error: z.string().optional(), }) -export type ConnectionStatus = z.infer + +const _ConnectionStatus = Schema.Struct({ + workspaceID: WorkspaceID, + status: Schema.Literals(["connected", "connecting", "disconnected", "error"]), + error: Schema.optional(Schema.String), +}) + +export const ConnectionStatus = Object.assign(_ConnectionStatus, { zod: ConnectionStatusZod }) +export type ConnectionStatus = Schema.Schema.Type const Restore = z.object({ workspaceID: WorkspaceID.zod, @@ -59,7 +66,7 @@ export const Event = { }), ), Restore: BusEvent.define("workspace.restore", Restore), - Status: BusEvent.define("workspace.status", ConnectionStatus), + Status: BusEvent.define("workspace.status", ConnectionStatus.zod), } function fromRow(row: typeof WorkspaceTable.$inferSelect): Info { @@ -76,12 +83,29 @@ function fromRow(row: typeof WorkspaceTable.$inferSelect): Info { const CreateInput = z.object({ id: WorkspaceID.zod.optional(), - type: Info.shape.type, - branch: Info.shape.branch, + type: WorkspaceInfo.zod.shape.type, + branch: WorkspaceInfo.zod.shape.branch, projectID: ProjectID.zod, - extra: Info.shape.extra, + extra: WorkspaceInfo.zod.shape.extra, }) +const CreateBodyZod = z.object({ + id: WorkspaceID.zod.optional(), + type: WorkspaceInfo.zod.shape.type, + branch: WorkspaceInfo.zod.shape.branch, + extra: WorkspaceInfo.zod.shape.extra, +}) + +const _CreateBody = Schema.Struct({ + id: Schema.optional(WorkspaceID), + type: Schema.String, + branch: Schema.NullOr(Schema.String), + extra: Schema.NullOr(Schema.Unknown), +}) + +export const CreateBody = Object.assign(_CreateBody, { zod: CreateBodyZod }) +export type CreateBody = Schema.Schema.Type + export const create = fn(CreateInput, async (input) => { const id = WorkspaceID.ascending(input.id) const adaptor = await getAdaptor(input.projectID, input.type) @@ -140,6 +164,28 @@ const SessionRestoreInput = z.object({ sessionID: SessionID.zod, }) +const SessionRestoreBodyZod = z.object({ + sessionID: SessionID.zod, +}) + +const _SessionRestoreBody = Schema.Struct({ + sessionID: SessionID, +}) + +export const SessionRestoreBody = Object.assign(_SessionRestoreBody, { zod: SessionRestoreBodyZod }) +export type SessionRestoreBody = Schema.Schema.Type + +const SessionRestoreResultZod = z.object({ + total: z.number().int().min(0), +}) + +const _SessionRestoreResult = Schema.Struct({ + total: Schema.Number, +}) + +export const SessionRestoreResult = Object.assign(_SessionRestoreResult, { zod: SessionRestoreResultZod }) +export type SessionRestoreResult = Schema.Schema.Type + export const sessionRestore = fn(SessionRestoreInput, async (input) => { log.info("session restore requested", { workspaceID: input.workspaceID, diff --git a/packages/opencode/src/server/instance/httpapi/server.ts b/packages/opencode/src/server/instance/httpapi/server.ts index 362d0970b9..2834e32d25 100644 --- a/packages/opencode/src/server/instance/httpapi/server.ts +++ b/packages/opencode/src/server/instance/httpapi/server.ts @@ -13,6 +13,7 @@ import { Filesystem } from "@/util" import { PermissionApi, permissionHandlers } from "./permission" import { ProviderApi, providerHandlers } from "./provider" import { QuestionApi, questionHandlers } from "./question" +import { WorkspaceApi, workspaceHandlers } from "./workspace" const Query = Schema.Struct({ directory: Schema.optional(Schema.String), @@ -108,11 +109,13 @@ const instance = HttpRouter.middleware()( const QuestionSecured = QuestionApi.middleware(Authorization) const PermissionSecured = PermissionApi.middleware(Authorization) const ProviderSecured = ProviderApi.middleware(Authorization) +const WorkspaceSecured = WorkspaceApi.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(WorkspaceSecured).pipe(Layer.provide(workspaceHandlers)), ).pipe( Layer.provide(auth), Layer.provide(normalize), diff --git a/packages/opencode/src/server/instance/httpapi/workspace.ts b/packages/opencode/src/server/instance/httpapi/workspace.ts new file mode 100644 index 0000000000..84c3feea2b --- /dev/null +++ b/packages/opencode/src/server/instance/httpapi/workspace.ts @@ -0,0 +1,136 @@ +import { listAdaptors, WorkspaceAdaptorEntry } from "@/control-plane/adaptors" +import { Workspace } from "@/control-plane/workspace" +import { WorkspaceID } from "@/control-plane/schema" +import { Instance } from "@/project/instance" +import { Effect, Schema } from "effect" +import { HttpApi, HttpApiBuilder, HttpApiEndpoint, HttpApiError, HttpApiGroup, OpenApi } from "effect/unstable/httpapi" + +const root = "/experimental/workspace" + +export const WorkspaceApi = HttpApi.make("workspace") + .add( + HttpApiGroup.make("workspace") + .add( + HttpApiEndpoint.get("adaptors", `${root}/adaptor`, { + success: Schema.Array(WorkspaceAdaptorEntry), + }).annotateMerge( + OpenApi.annotations({ + identifier: "experimental.workspace.adaptor.list", + summary: "List workspace adaptors", + description: "List all available workspace adaptors for the current project.", + }), + ), + HttpApiEndpoint.get("list", root, { + success: Schema.Array(Workspace.Info), + }).annotateMerge( + OpenApi.annotations({ + identifier: "experimental.workspace.list", + summary: "List workspaces", + description: "List all workspaces.", + }), + ), + HttpApiEndpoint.get("status", `${root}/status`, { + success: Schema.Array(Workspace.ConnectionStatus), + }).annotateMerge( + OpenApi.annotations({ + identifier: "experimental.workspace.status", + summary: "Workspace status", + description: "Get connection status for workspaces in the current project.", + }), + ), + HttpApiEndpoint.post("create", root, { + payload: Workspace.CreateBody, + success: Workspace.Info, + }).annotateMerge( + OpenApi.annotations({ + identifier: "experimental.workspace.create", + summary: "Create workspace", + description: "Create a workspace for the current project.", + }), + ), + HttpApiEndpoint.delete("remove", `${root}/:id`, { + params: { id: WorkspaceID }, + success: Schema.optional(Workspace.Info), + }).annotateMerge( + OpenApi.annotations({ + identifier: "experimental.workspace.remove", + summary: "Remove workspace", + description: "Remove an existing workspace.", + }), + ), + HttpApiEndpoint.post("sessionRestore", `${root}/:id/session-restore`, { + params: { id: WorkspaceID }, + payload: Workspace.SessionRestoreBody, + success: Workspace.SessionRestoreResult, + }).annotateMerge( + OpenApi.annotations({ + identifier: "experimental.workspace.sessionRestore", + summary: "Restore session into workspace", + description: "Replay a session's sync events into the target workspace in batches.", + }), + ), + ) + .annotateMerge( + OpenApi.annotations({ + title: "workspace", + description: "Experimental HttpApi workspace routes.", + }), + ), + ) + .annotateMerge( + OpenApi.annotations({ + title: "opencode experimental HttpApi", + version: "0.0.1", + description: "Experimental HttpApi surface for selected instance routes.", + }), + ) + +const adaptors = Effect.fn("WorkspaceHttpApi.adaptors")(function* () { + return yield* Effect.promise(() => listAdaptors(Instance.project.id)) +}) + +const list = Effect.fn("WorkspaceHttpApi.list")(function* () { + return Workspace.list(Instance.project) +}) + +const status = Effect.fn("WorkspaceHttpApi.status")(function* () { + const ids = new Set(Workspace.list(Instance.project).map((item) => item.id)) + return Workspace.status().filter((item) => ids.has(item.workspaceID)) +}) + +const create = Effect.fn("WorkspaceHttpApi.create")(function* (ctx: { payload: Workspace.CreateBody }) { + return yield* Effect.promise(() => + Workspace.create({ + projectID: Instance.project.id, + ...ctx.payload, + }), + ).pipe(Effect.catch(() => Effect.fail(new HttpApiError.BadRequest({})))) +}) + +const remove = Effect.fn("WorkspaceHttpApi.remove")(function* (ctx: { params: { id: WorkspaceID } }) { + return yield* Effect.promise(() => Workspace.remove(ctx.params.id)).pipe( + Effect.catch(() => Effect.fail(new HttpApiError.BadRequest({}))), + ) +}) + +const sessionRestore = Effect.fn("WorkspaceHttpApi.sessionRestore")(function* (ctx: { + params: { id: WorkspaceID } + payload: Workspace.SessionRestoreBody +}) { + return yield* Effect.promise(() => + Workspace.sessionRestore({ + workspaceID: ctx.params.id, + sessionID: ctx.payload.sessionID, + }), + ).pipe(Effect.catch(() => Effect.fail(new HttpApiError.BadRequest({})))) +}) + +export const workspaceHandlers = HttpApiBuilder.group(WorkspaceApi, "workspace", (handlers) => + handlers + .handle("adaptors", adaptors) + .handle("list", list) + .handle("status", status) + .handle("create", create) + .handle("remove", remove) + .handle("sessionRestore", sessionRestore), +) diff --git a/packages/opencode/src/server/instance/index.ts b/packages/opencode/src/server/instance/index.ts index 9ef6da63ac..e1ef3a0928 100644 --- a/packages/opencode/src/server/instance/index.ts +++ b/packages/opencode/src/server/instance/index.ts @@ -1,7 +1,7 @@ import { describeRoute, resolver, validator } from "hono-openapi" import { Hono } from "hono" import type { UpgradeWebSocket } from "hono/ws" -import { Effect } from "effect" +import { Context, Effect } from "effect" import z from "zod" import { Format } from "../../format" import { TuiRoutes } from "./tui" @@ -41,12 +41,16 @@ export const InstanceRoutes = (upgrade: UpgradeWebSocket): Hono => { if (Flag.OPENCODE_EXPERIMENTAL_HTTPAPI) { const handler = ExperimentalHttpApiServer.webHandler().handler - app - .all("/question", (c) => handler(c.req.raw)) - .all("/question/*", (c) => handler(c.req.raw)) - .all("/permission", (c) => handler(c.req.raw)) - .all("/permission/*", (c) => handler(c.req.raw)) - .all("/provider/auth", (c) => handler(c.req.raw)) + const context = Context.empty() as Context.Context + app.all("/question", (c) => handler(c.req.raw, context)) + app.all("/question/*", (c) => handler(c.req.raw, context)) + app.all("/permission", (c) => handler(c.req.raw, context)) + app.all("/permission/*", (c) => handler(c.req.raw, context)) + app.all("/experimental/workspace", (c) => handler(c.req.raw, context)) + app.all("/experimental/workspace/*", (c) => handler(c.req.raw, context)) + app.all("/experimental/workspace/adaptor", (c) => handler(c.req.raw, context)) + app.all("/experimental/workspace/status", (c) => handler(c.req.raw, context)) + app.all("/provider/auth", (c) => handler(c.req.raw, context)) } return app diff --git a/packages/opencode/src/server/instance/workspace.ts b/packages/opencode/src/server/instance/workspace.ts index 59369ef8e7..6c406c79e5 100644 --- a/packages/opencode/src/server/instance/workspace.ts +++ b/packages/opencode/src/server/instance/workspace.ts @@ -4,6 +4,8 @@ import z from "zod" import { listAdaptors } from "../../control-plane/adaptors" import { Workspace } from "../../control-plane/workspace" import { Instance } from "../../project/instance" +import { WorkspaceID } from "../../control-plane/schema" +import { WorkspaceAdaptorEntry } from "../../control-plane/adaptors" import { errors } from "../error" import { lazy } from "../../util/lazy" import { Log } from "@/util" @@ -24,15 +26,7 @@ export const WorkspaceRoutes = lazy(() => description: "Workspace adaptors", content: { "application/json": { - schema: resolver( - z.array( - z.object({ - type: z.string(), - name: z.string(), - description: z.string(), - }), - ), - ), + schema: resolver(WorkspaceAdaptorEntry.zod.array()), }, }, }, @@ -53,19 +47,14 @@ export const WorkspaceRoutes = lazy(() => description: "Workspace created", content: { "application/json": { - schema: resolver(Workspace.Info), + schema: resolver(Workspace.Info.zod), }, }, }, ...errors(400), }, }), - validator( - "json", - Workspace.create.schema.omit({ - projectID: true, - }), - ), + validator("json", Workspace.CreateBody.zod), async (c) => { const body = c.req.valid("json") const workspace = await Workspace.create({ @@ -86,7 +75,7 @@ export const WorkspaceRoutes = lazy(() => description: "Workspaces", content: { "application/json": { - schema: resolver(z.array(Workspace.Info)), + schema: resolver(Workspace.Info.zod.array()), }, }, }, @@ -107,7 +96,7 @@ export const WorkspaceRoutes = lazy(() => description: "Workspace status", content: { "application/json": { - schema: resolver(z.array(Workspace.ConnectionStatus)), + schema: resolver(Workspace.ConnectionStatus.zod.array()), }, }, }, @@ -129,7 +118,7 @@ export const WorkspaceRoutes = lazy(() => description: "Workspace removed", content: { "application/json": { - schema: resolver(Workspace.Info.optional()), + schema: resolver(Workspace.Info.zod.optional()), }, }, }, @@ -139,7 +128,7 @@ export const WorkspaceRoutes = lazy(() => validator( "param", z.object({ - id: Workspace.Info.shape.id, + id: WorkspaceID.zod, }), ), async (c) => { @@ -158,19 +147,15 @@ export const WorkspaceRoutes = lazy(() => description: "Session replay started", content: { "application/json": { - schema: resolver( - z.object({ - total: z.number().int().min(0), - }), - ), + schema: resolver(Workspace.SessionRestoreResult.zod), }, }, }, ...errors(400), }, }), - validator("param", z.object({ id: Workspace.Info.shape.id })), - validator("json", Workspace.sessionRestore.schema.omit({ workspaceID: true })), + validator("param", z.object({ id: WorkspaceID.zod })), + validator("json", Workspace.SessionRestoreBody.zod), async (c) => { const { id } = c.req.valid("param") const body = c.req.valid("json") From db077a5488fc8be70664f1bdda7049a88efc354c Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Thu, 16 Apr 2026 22:33:39 -0400 Subject: [PATCH 2/2] fix: trim workspace HttpApi slice to read routes --- .../src/control-plane/adaptors/index.ts | 17 ++--- .../src/control-plane/adaptors/worktree.ts | 6 +- packages/opencode/src/control-plane/types.ts | 26 ++----- .../opencode/src/control-plane/workspace.ts | 61 +++------------- .../src/server/instance/httpapi/workspace.ts | 69 +------------------ .../opencode/src/server/instance/index.ts | 18 ++--- .../opencode/src/server/instance/workspace.ts | 17 +++-- 7 files changed, 47 insertions(+), 167 deletions(-) diff --git a/packages/opencode/src/control-plane/adaptors/index.ts b/packages/opencode/src/control-plane/adaptors/index.ts index b1627bab7d..aaa477db28 100644 --- a/packages/opencode/src/control-plane/adaptors/index.ts +++ b/packages/opencode/src/control-plane/adaptors/index.ts @@ -1,23 +1,16 @@ import { lazy } from "@/util/lazy" import { Schema } from "effect" -import z from "zod" +import { zod } from "@/util/effect-zod" +import { withStatics } from "@/util/schema" import type { ProjectID } from "@/project/schema" import type { WorkspaceAdaptor } from "../types" -const WorkspaceAdaptorEntryZod = z.object({ - type: z.string(), - name: z.string(), - description: z.string(), -}) - -const _WorkspaceAdaptorEntry = Schema.Struct({ +export const WorkspaceAdaptorEntry = Schema.Struct({ type: Schema.String, name: Schema.String, description: Schema.String, -}) - -export const WorkspaceAdaptorEntry = Object.assign(_WorkspaceAdaptorEntry, { zod: WorkspaceAdaptorEntryZod }) -export type WorkspaceAdaptorEntry = Schema.Schema.Type +}).pipe(withStatics((s) => ({ zod: zod(s) }))) +export type WorkspaceAdaptorEntry = Schema.Schema.Type const BUILTIN: Record Promise> = { worktree: lazy(async () => (await import("./worktree")).WorktreeAdaptor), diff --git a/packages/opencode/src/control-plane/adaptors/worktree.ts b/packages/opencode/src/control-plane/adaptors/worktree.ts index 121fd9213b..d85e09c107 100644 --- a/packages/opencode/src/control-plane/adaptors/worktree.ts +++ b/packages/opencode/src/control-plane/adaptors/worktree.ts @@ -4,9 +4,9 @@ import { Worktree } from "@/worktree" import { type WorkspaceAdaptor, WorkspaceInfo } from "../types" const WorktreeConfig = z.object({ - name: WorkspaceInfo.zod.shape.name, - branch: WorkspaceInfo.zod.shape.branch.unwrap(), - directory: WorkspaceInfo.zod.shape.directory.unwrap(), + name: z.string(), + branch: z.string(), + directory: z.string(), }) export const WorktreeAdaptor: WorkspaceAdaptor = { diff --git a/packages/opencode/src/control-plane/types.ts b/packages/opencode/src/control-plane/types.ts index 082f31089e..39c2d45f18 100644 --- a/packages/opencode/src/control-plane/types.ts +++ b/packages/opencode/src/control-plane/types.ts @@ -2,22 +2,10 @@ import z from "zod" import { Schema } from "effect" import { ProjectID } from "@/project/schema" import { WorkspaceID } from "./schema" +import { zod } from "@/util/effect-zod" +import { withStatics } from "@/util/schema" -const WorkspaceInfoZod = z - .object({ - id: WorkspaceID.zod, - type: z.string(), - name: z.string(), - branch: z.string().nullable(), - directory: z.string().nullable(), - extra: z.unknown().nullable(), - projectID: ProjectID.zod, - }) - .meta({ - ref: "Workspace", - }) - -const _WorkspaceInfo = Schema.Struct({ +export const WorkspaceInfo = Schema.Struct({ id: WorkspaceID, type: Schema.String, name: Schema.String, @@ -25,10 +13,10 @@ const _WorkspaceInfo = Schema.Struct({ directory: Schema.NullOr(Schema.String), extra: Schema.NullOr(Schema.Unknown), projectID: ProjectID, -}).annotate({ identifier: "Workspace" }) - -export const WorkspaceInfo = Object.assign(_WorkspaceInfo, { zod: WorkspaceInfoZod }) -export type WorkspaceInfo = Schema.Schema.Type +}) + .annotate({ identifier: "Workspace" }) + .pipe(withStatics((s) => ({ zod: zod(s) }))) +export type WorkspaceInfo = Schema.Schema.Type export type Target = | { diff --git a/packages/opencode/src/control-plane/workspace.ts b/packages/opencode/src/control-plane/workspace.ts index db41fd5d59..d4044e56a0 100644 --- a/packages/opencode/src/control-plane/workspace.ts +++ b/packages/opencode/src/control-plane/workspace.ts @@ -26,24 +26,18 @@ import { AppRuntime } from "@/effect/app-runtime" import { EventSequenceTable } from "@/sync/event.sql" import { waitEvent } from "./util" import { Schema } from "effect" +import { zod } from "@/util/effect-zod" +import { withStatics } from "@/util/schema" export const Info = WorkspaceInfo export type Info = WorkspaceInfo -const ConnectionStatusZod = z.object({ - workspaceID: WorkspaceID.zod, - status: z.enum(["connected", "connecting", "disconnected", "error"]), - error: z.string().optional(), -}) - -const _ConnectionStatus = Schema.Struct({ +export const ConnectionStatus = Schema.Struct({ workspaceID: WorkspaceID, status: Schema.Literals(["connected", "connecting", "disconnected", "error"]), error: Schema.optional(Schema.String), -}) - -export const ConnectionStatus = Object.assign(_ConnectionStatus, { zod: ConnectionStatusZod }) -export type ConnectionStatus = Schema.Schema.Type +}).pipe(withStatics((s) => ({ zod: zod(s) }))) +export type ConnectionStatus = Schema.Schema.Type const Restore = z.object({ workspaceID: WorkspaceID.zod, @@ -83,29 +77,12 @@ function fromRow(row: typeof WorkspaceTable.$inferSelect): Info { const CreateInput = z.object({ id: WorkspaceID.zod.optional(), - type: WorkspaceInfo.zod.shape.type, - branch: WorkspaceInfo.zod.shape.branch, + type: zod(Schema.String), + branch: zod(Schema.NullOr(Schema.String)), projectID: ProjectID.zod, - extra: WorkspaceInfo.zod.shape.extra, + extra: zod(Schema.NullOr(Schema.Unknown)), }) -const CreateBodyZod = z.object({ - id: WorkspaceID.zod.optional(), - type: WorkspaceInfo.zod.shape.type, - branch: WorkspaceInfo.zod.shape.branch, - extra: WorkspaceInfo.zod.shape.extra, -}) - -const _CreateBody = Schema.Struct({ - id: Schema.optional(WorkspaceID), - type: Schema.String, - branch: Schema.NullOr(Schema.String), - extra: Schema.NullOr(Schema.Unknown), -}) - -export const CreateBody = Object.assign(_CreateBody, { zod: CreateBodyZod }) -export type CreateBody = Schema.Schema.Type - export const create = fn(CreateInput, async (input) => { const id = WorkspaceID.ascending(input.id) const adaptor = await getAdaptor(input.projectID, input.type) @@ -164,28 +141,6 @@ const SessionRestoreInput = z.object({ sessionID: SessionID.zod, }) -const SessionRestoreBodyZod = z.object({ - sessionID: SessionID.zod, -}) - -const _SessionRestoreBody = Schema.Struct({ - sessionID: SessionID, -}) - -export const SessionRestoreBody = Object.assign(_SessionRestoreBody, { zod: SessionRestoreBodyZod }) -export type SessionRestoreBody = Schema.Schema.Type - -const SessionRestoreResultZod = z.object({ - total: z.number().int().min(0), -}) - -const _SessionRestoreResult = Schema.Struct({ - total: Schema.Number, -}) - -export const SessionRestoreResult = Object.assign(_SessionRestoreResult, { zod: SessionRestoreResultZod }) -export type SessionRestoreResult = Schema.Schema.Type - export const sessionRestore = fn(SessionRestoreInput, async (input) => { log.info("session restore requested", { workspaceID: input.workspaceID, diff --git a/packages/opencode/src/server/instance/httpapi/workspace.ts b/packages/opencode/src/server/instance/httpapi/workspace.ts index 84c3feea2b..bd78efef9a 100644 --- a/packages/opencode/src/server/instance/httpapi/workspace.ts +++ b/packages/opencode/src/server/instance/httpapi/workspace.ts @@ -1,9 +1,8 @@ import { listAdaptors, WorkspaceAdaptorEntry } from "@/control-plane/adaptors" import { Workspace } from "@/control-plane/workspace" -import { WorkspaceID } from "@/control-plane/schema" import { Instance } from "@/project/instance" import { Effect, Schema } from "effect" -import { HttpApi, HttpApiBuilder, HttpApiEndpoint, HttpApiError, HttpApiGroup, OpenApi } from "effect/unstable/httpapi" +import { HttpApi, HttpApiBuilder, HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi" const root = "/experimental/workspace" @@ -38,37 +37,6 @@ export const WorkspaceApi = HttpApi.make("workspace") description: "Get connection status for workspaces in the current project.", }), ), - HttpApiEndpoint.post("create", root, { - payload: Workspace.CreateBody, - success: Workspace.Info, - }).annotateMerge( - OpenApi.annotations({ - identifier: "experimental.workspace.create", - summary: "Create workspace", - description: "Create a workspace for the current project.", - }), - ), - HttpApiEndpoint.delete("remove", `${root}/:id`, { - params: { id: WorkspaceID }, - success: Schema.optional(Workspace.Info), - }).annotateMerge( - OpenApi.annotations({ - identifier: "experimental.workspace.remove", - summary: "Remove workspace", - description: "Remove an existing workspace.", - }), - ), - HttpApiEndpoint.post("sessionRestore", `${root}/:id/session-restore`, { - params: { id: WorkspaceID }, - payload: Workspace.SessionRestoreBody, - success: Workspace.SessionRestoreResult, - }).annotateMerge( - OpenApi.annotations({ - identifier: "experimental.workspace.sessionRestore", - summary: "Restore session into workspace", - description: "Replay a session's sync events into the target workspace in batches.", - }), - ), ) .annotateMerge( OpenApi.annotations({ @@ -98,39 +66,6 @@ const status = Effect.fn("WorkspaceHttpApi.status")(function* () { return Workspace.status().filter((item) => ids.has(item.workspaceID)) }) -const create = Effect.fn("WorkspaceHttpApi.create")(function* (ctx: { payload: Workspace.CreateBody }) { - return yield* Effect.promise(() => - Workspace.create({ - projectID: Instance.project.id, - ...ctx.payload, - }), - ).pipe(Effect.catch(() => Effect.fail(new HttpApiError.BadRequest({})))) -}) - -const remove = Effect.fn("WorkspaceHttpApi.remove")(function* (ctx: { params: { id: WorkspaceID } }) { - return yield* Effect.promise(() => Workspace.remove(ctx.params.id)).pipe( - Effect.catch(() => Effect.fail(new HttpApiError.BadRequest({}))), - ) -}) - -const sessionRestore = Effect.fn("WorkspaceHttpApi.sessionRestore")(function* (ctx: { - params: { id: WorkspaceID } - payload: Workspace.SessionRestoreBody -}) { - return yield* Effect.promise(() => - Workspace.sessionRestore({ - workspaceID: ctx.params.id, - sessionID: ctx.payload.sessionID, - }), - ).pipe(Effect.catch(() => Effect.fail(new HttpApiError.BadRequest({})))) -}) - export const workspaceHandlers = HttpApiBuilder.group(WorkspaceApi, "workspace", (handlers) => - handlers - .handle("adaptors", adaptors) - .handle("list", list) - .handle("status", status) - .handle("create", create) - .handle("remove", remove) - .handle("sessionRestore", sessionRestore), + handlers.handle("adaptors", adaptors).handle("list", list).handle("status", status), ) diff --git a/packages/opencode/src/server/instance/index.ts b/packages/opencode/src/server/instance/index.ts index e1ef3a0928..b37d53871a 100644 --- a/packages/opencode/src/server/instance/index.ts +++ b/packages/opencode/src/server/instance/index.ts @@ -42,15 +42,15 @@ export const InstanceRoutes = (upgrade: UpgradeWebSocket): Hono => { if (Flag.OPENCODE_EXPERIMENTAL_HTTPAPI) { const handler = ExperimentalHttpApiServer.webHandler().handler const context = Context.empty() as Context.Context - app.all("/question", (c) => handler(c.req.raw, context)) - app.all("/question/*", (c) => handler(c.req.raw, context)) - app.all("/permission", (c) => handler(c.req.raw, context)) - app.all("/permission/*", (c) => handler(c.req.raw, context)) - app.all("/experimental/workspace", (c) => handler(c.req.raw, context)) - app.all("/experimental/workspace/*", (c) => handler(c.req.raw, context)) - app.all("/experimental/workspace/adaptor", (c) => handler(c.req.raw, context)) - app.all("/experimental/workspace/status", (c) => handler(c.req.raw, context)) - app.all("/provider/auth", (c) => handler(c.req.raw, context)) + app.get("/question", (c) => handler(c.req.raw, context)) + app.post("/question/:requestID/reply", (c) => handler(c.req.raw, context)) + app.post("/question/:requestID/reject", (c) => handler(c.req.raw, context)) + app.get("/permission", (c) => handler(c.req.raw, context)) + app.post("/permission/:requestID/reply", (c) => handler(c.req.raw, context)) + app.get("/experimental/workspace", (c) => handler(c.req.raw, context)) + app.get("/experimental/workspace/adaptor", (c) => handler(c.req.raw, context)) + app.get("/experimental/workspace/status", (c) => handler(c.req.raw, context)) + app.get("/provider/auth", (c) => handler(c.req.raw, context)) } return app diff --git a/packages/opencode/src/server/instance/workspace.ts b/packages/opencode/src/server/instance/workspace.ts index 6c406c79e5..a23c8b6517 100644 --- a/packages/opencode/src/server/instance/workspace.ts +++ b/packages/opencode/src/server/instance/workspace.ts @@ -2,9 +2,9 @@ import { Hono } from "hono" import { describeRoute, resolver, validator } from "hono-openapi" import z from "zod" import { listAdaptors } from "../../control-plane/adaptors" +import { WorkspaceID } from "../../control-plane/schema" import { Workspace } from "../../control-plane/workspace" import { Instance } from "../../project/instance" -import { WorkspaceID } from "../../control-plane/schema" import { WorkspaceAdaptorEntry } from "../../control-plane/adaptors" import { errors } from "../error" import { lazy } from "../../util/lazy" @@ -54,7 +54,12 @@ export const WorkspaceRoutes = lazy(() => ...errors(400), }, }), - validator("json", Workspace.CreateBody.zod), + validator( + "json", + Workspace.create.schema.omit({ + projectID: true, + }), + ), async (c) => { const body = c.req.valid("json") const workspace = await Workspace.create({ @@ -147,7 +152,11 @@ export const WorkspaceRoutes = lazy(() => description: "Session replay started", content: { "application/json": { - schema: resolver(Workspace.SessionRestoreResult.zod), + schema: resolver( + z.object({ + total: z.number().int().min(0), + }), + ), }, }, }, @@ -155,7 +164,7 @@ export const WorkspaceRoutes = lazy(() => }, }), validator("param", z.object({ id: WorkspaceID.zod })), - validator("json", Workspace.SessionRestoreBody.zod), + validator("json", Workspace.sessionRestore.schema.omit({ workspaceID: true })), async (c) => { const { id } = c.req.valid("param") const body = c.req.valid("json")