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")