Explorar el Código

feat(server): add v2 session API endpoints (#31822)

Dax hace 2 meses
padre
commit
8bf0675997

+ 25 - 0
packages/opencode/test/server/httpapi-exercise/index.ts

@@ -646,6 +646,7 @@ const scenarios: Scenario[] = [
     object(body)
     check(body.healthy === true, "v2 server should report healthy")
   }),
+  http.protected.get("/api/location", "v2.location.get").json(200, object),
   http.protected.get("/api/agent", "v2.agent.list").json(200, locationData(array)),
   http.protected.get("/api/model", "v2.model.list").json(200, locationData(array)),
   http.protected.get("/api/provider", "v2.provider.list").json(200, locationData(array)),
@@ -698,6 +699,14 @@ const scenarios: Scenario[] = [
       headers: ctx.headers(),
     }))
     .json(200, data(array)),
+  http.protected
+    .get("/api/session/{sessionID}/question", "v2.session.question.list")
+    .seeded((ctx) => ctx.session({ title: "Question list owner" }))
+    .at((ctx) => ({
+      path: route("/api/session/{sessionID}/question", { sessionID: ctx.state.id }),
+      headers: ctx.headers(),
+    }))
+    .json(200, data(array)),
   http.protected
     .post("/api/session/{sessionID}/permission/{requestID}/reply", "v2.session.permission.reply")
     .seeded((ctx) => ctx.session({ title: "Permission owner" }))
@@ -807,6 +816,22 @@ const scenarios: Scenario[] = [
       headers: ctx.headers(),
     }))
     .status(400, undefined, "none"),
+  http.protected
+    .post("/api/session", "v2.session.create")
+    .at((ctx) => ({
+      path: "/api/session",
+      headers: { ...ctx.headers(), "content-type": "application/json" },
+      body: {},
+    }))
+    .json(200, data(object)),
+  http.protected
+    .get("/api/session/{sessionID}", "v2.session.get")
+    .seeded((ctx) => ctx.session({ title: "Session get" }))
+    .at((ctx) => ({
+      path: route("/api/session/{sessionID}", { sessionID: ctx.state.id }),
+      headers: ctx.headers(),
+    }))
+    .json(200, data(object)),
   http.protected
     .get("/api/session/{sessionID}/context", "v2.session.context")
     .at((ctx) => ({

+ 123 - 0
packages/sdk/js/src/v2/gen/sdk.gen.ts

@@ -92,6 +92,7 @@ import type {
   GlobalUpgradeResponses,
   InstanceDisposeErrors,
   InstanceDisposeResponses,
+  LocationRef,
   LspStatusErrors,
   LspStatusResponses,
   McpAddErrors,
@@ -274,6 +275,8 @@ import type {
   V2FsReadResponses,
   V2HealthGetErrors,
   V2HealthGetResponses,
+  V2LocationGetErrors,
+  V2LocationGetResponses,
   V2ModelListErrors,
   V2ModelListResponses,
   V2PermissionRequestListErrors,
@@ -294,6 +297,10 @@ import type {
   V2SessionCompactResponses,
   V2SessionContextErrors,
   V2SessionContextResponses,
+  V2SessionCreateErrors,
+  V2SessionCreateResponses,
+  V2SessionGetErrors,
+  V2SessionGetResponses,
   V2SessionListErrors,
   V2SessionListResponses,
   V2SessionMessagesErrors,
@@ -304,6 +311,8 @@ import type {
   V2SessionPermissionReplyResponses,
   V2SessionPromptErrors,
   V2SessionPromptResponses,
+  V2SessionQuestionListErrors,
+  V2SessionQuestionListResponses,
   V2SessionQuestionRejectErrors,
   V2SessionQuestionRejectResponses,
   V2SessionQuestionReplyErrors,
@@ -5013,6 +5022,30 @@ export class Health extends HeyApiClient {
   }
 }
 
+export class Location extends HeyApiClient {
+  /**
+   * Get location
+   *
+   * Resolve the requested location or the server default location.
+   */
+  public get<ThrowOnError extends boolean = false>(
+    parameters?: {
+      location?: {
+        directory?: string
+        workspace?: string
+      }
+    },
+    options?: Options<never, ThrowOnError>,
+  ) {
+    const params = buildClientParams([parameters], [{ args: [{ in: "query", key: "location" }] }])
+    return (options?.client ?? this.client).get<V2LocationGetResponses, V2LocationGetErrors, ThrowOnError>({
+      url: "/api/location",
+      ...options,
+      ...params,
+    })
+  }
+}
+
 export class Agent extends HeyApiClient {
   /**
    * List agents
@@ -5106,6 +5139,29 @@ export class Permission2 extends HeyApiClient {
 }
 
 export class Question2 extends HeyApiClient {
+  /**
+   * List session question requests
+   *
+   * Retrieve pending question requests owned by a session.
+   */
+  public list<ThrowOnError extends boolean = false>(
+    parameters: {
+      sessionID: string
+    },
+    options?: Options<never, ThrowOnError>,
+  ) {
+    const params = buildClientParams([parameters], [{ args: [{ in: "path", key: "sessionID" }] }])
+    return (options?.client ?? this.client).get<
+      V2SessionQuestionListResponses,
+      V2SessionQuestionListErrors,
+      ThrowOnError
+    >({
+      url: "/api/session/{sessionID}/question",
+      ...options,
+      ...params,
+    })
+  }
+
   /**
    * Reply to pending question request
    *
@@ -5225,6 +5281,68 @@ export class Session3 extends HeyApiClient {
     })
   }
 
+  /**
+   * Create session
+   *
+   * Create a session at the requested location.
+   */
+  public create<ThrowOnError extends boolean = false>(
+    parameters?: {
+      id?: string
+      agent?: string
+      model?: {
+        id: string
+        providerID: string
+        variant?: string
+      }
+      location?: LocationRef
+    },
+    options?: Options<never, ThrowOnError>,
+  ) {
+    const params = buildClientParams(
+      [parameters],
+      [
+        {
+          args: [
+            { in: "body", key: "id" },
+            { in: "body", key: "agent" },
+            { in: "body", key: "model" },
+            { in: "body", key: "location" },
+          ],
+        },
+      ],
+    )
+    return (options?.client ?? this.client).post<V2SessionCreateResponses, V2SessionCreateErrors, ThrowOnError>({
+      url: "/api/session",
+      ...options,
+      ...params,
+      headers: {
+        "Content-Type": "application/json",
+        ...options?.headers,
+        ...params.headers,
+      },
+    })
+  }
+
+  /**
+   * Get session
+   *
+   * Retrieve a session by ID.
+   */
+  public get<ThrowOnError extends boolean = false>(
+    parameters: {
+      sessionID: string
+    },
+    options?: Options<never, ThrowOnError>,
+  ) {
+    const params = buildClientParams([parameters], [{ args: [{ in: "path", key: "sessionID" }] }])
+    return (options?.client ?? this.client).get<V2SessionGetResponses, V2SessionGetErrors, ThrowOnError>({
+      url: "/api/session/{sessionID}",
+      ...options,
+      ...params,
+    })
+  }
+
   /**
    * Send message
    *
@@ -5779,6 +5897,11 @@ export class V2 extends HeyApiClient {
     return (this._health ??= new Health({ client: this.client }))
   }
 
+  private _location?: Location
+  get location(): Location {
+    return (this._location ??= new Location({ client: this.client }))
+  }
+
   private _agent?: Agent
   get agent(): Agent {
     return (this._agent ??= new Agent({ client: this.client }))

+ 154 - 6
packages/sdk/js/src/v2/gen/types.gen.ts

@@ -2737,18 +2737,18 @@ export type InvalidCursorError = {
   message: string
 }
 
-export type ConflictError = {
-  _tag: "ConflictError"
-  message: string
-  resource?: string
-}
-
 export type SessionNotFoundError = {
   _tag: "SessionNotFoundError"
   sessionID: string
   message: string
 }
 
+export type ConflictError = {
+  _tag: "ConflictError"
+  message: string
+  resource?: string
+}
+
 export type ServiceUnavailableError = {
   _tag: "ServiceUnavailableError"
   message: string
@@ -9453,6 +9453,40 @@ export type V2HealthGetResponses = {
 
 export type V2HealthGetResponse = V2HealthGetResponses[keyof V2HealthGetResponses]
 
+export type V2LocationGetData = {
+  body?: never
+  path?: never
+  query?: {
+    location?: {
+      directory?: string
+      workspace?: string
+    }
+  }
+  url: "/api/location"
+}
+
+export type V2LocationGetErrors = {
+  /**
+   * InvalidRequestError
+   */
+  400: InvalidRequestError
+  /**
+   * UnauthorizedError
+   */
+  401: UnauthorizedError
+}
+
+export type V2LocationGetError = V2LocationGetErrors[keyof V2LocationGetErrors]
+
+export type V2LocationGetResponses = {
+  /**
+   * Location.Info
+   */
+  200: LocationInfo
+}
+
+export type V2LocationGetResponse = V2LocationGetResponses[keyof V2LocationGetResponses]
+
 export type V2AgentListData = {
   body?: never
   path?: never
@@ -9531,6 +9565,83 @@ export type V2SessionListResponses = {
 
 export type V2SessionListResponse = V2SessionListResponses[keyof V2SessionListResponses]
 
+export type V2SessionCreateData = {
+  body: {
+    id?: string
+    agent?: string
+    model?: {
+      id: string
+      providerID: string
+      variant?: string
+    }
+    location?: LocationRef
+  }
+  path?: never
+  query?: never
+  url: "/api/session"
+}
+
+export type V2SessionCreateErrors = {
+  /**
+   * InvalidRequestError
+   */
+  400: InvalidRequestError
+  /**
+   * UnauthorizedError
+   */
+  401: UnauthorizedError
+}
+
+export type V2SessionCreateError = V2SessionCreateErrors[keyof V2SessionCreateErrors]
+
+export type V2SessionCreateResponses = {
+  /**
+   * Success
+   */
+  200: {
+    data: SessionV2Info
+  }
+}
+
+export type V2SessionCreateResponse = V2SessionCreateResponses[keyof V2SessionCreateResponses]
+
+export type V2SessionGetData = {
+  body?: never
+  path: {
+    sessionID: string
+  }
+  query?: never
+  url: "/api/session/{sessionID}"
+}
+
+export type V2SessionGetErrors = {
+  /**
+   * InvalidRequestError
+   */
+  400: InvalidRequestError
+  /**
+   * UnauthorizedError
+   */
+  401: UnauthorizedError
+  /**
+   * SessionNotFoundError
+   */
+  404: SessionNotFoundError
+}
+
+export type V2SessionGetError = V2SessionGetErrors[keyof V2SessionGetErrors]
+
+export type V2SessionGetResponses = {
+  /**
+   * Success
+   */
+  200: {
+    data: SessionV2Info
+  }
+}
+
+export type V2SessionGetResponse = V2SessionGetResponses[keyof V2SessionGetResponses]
+
 export type V2SessionPromptData = {
   body: {
     id?: string
@@ -10310,6 +10421,43 @@ export type V2QuestionRequestListResponses = {
 
 export type V2QuestionRequestListResponse = V2QuestionRequestListResponses[keyof V2QuestionRequestListResponses]
 
+export type V2SessionQuestionListData = {
+  body?: never
+  path: {
+    sessionID: string
+  }
+  query?: never
+  url: "/api/session/{sessionID}/question"
+}
+
+export type V2SessionQuestionListErrors = {
+  /**
+   * InvalidRequestError
+   */
+  400: InvalidRequestError
+  /**
+   * UnauthorizedError
+   */
+  401: UnauthorizedError
+  /**
+   * SessionNotFoundError
+   */
+  404: SessionNotFoundError
+}
+
+export type V2SessionQuestionListError = V2SessionQuestionListErrors[keyof V2SessionQuestionListErrors]
+
+export type V2SessionQuestionListResponses = {
+  /**
+   * Success
+   */
+  200: {
+    data: Array<QuestionV2Request>
+  }
+}
+
+export type V2SessionQuestionListResponse = V2SessionQuestionListResponses[keyof V2SessionQuestionListResponses]
+
 export type V2SessionQuestionReplyData = {
   body: QuestionV2Reply
   path: {

+ 2 - 0
packages/server/src/api.ts

@@ -14,9 +14,11 @@ import { HealthGroup } from "./groups/health"
 import { QuestionGroup } from "./groups/question"
 import { ReferenceGroup } from "./groups/reference"
 import { Authorization } from "./middleware/authorization"
+import { LocationGroup } from "./groups/location"
 
 export const Api = HttpApi.make("server")
   .add(HealthGroup)
+  .add(LocationGroup)
   .add(AgentGroup)
   .add(SessionGroup)
   .add(MessageGroup)

+ 18 - 1
packages/server/src/groups/location.ts

@@ -5,7 +5,7 @@ import { AbsolutePath } from "@opencode-ai/core/schema"
 import { WorkspaceV2 } from "@opencode-ai/core/workspace"
 import { Effect, Layer, Schema } from "effect"
 import { HttpServerRequest } from "effect/unstable/http"
-import { HttpApiMiddleware, OpenApi } from "effect/unstable/httpapi"
+import { HttpApiEndpoint, HttpApiGroup, HttpApiMiddleware, OpenApi } from "effect/unstable/httpapi"
 
 export const LocationQuery = Schema.Struct({
   location: Schema.optional(
@@ -54,6 +54,23 @@ export class LocationMiddleware extends HttpApiMiddleware.Service<
   }
 >()("@opencode/HttpApiLocation") {}
 
+export const LocationGroup = HttpApiGroup.make("server.location")
+  .add(
+    HttpApiEndpoint.get("location.get", "/api/location", {
+      query: LocationQuery,
+      success: Location.Info,
+    })
+      .annotateMerge(locationQueryOpenApi)
+      .annotateMerge(
+        OpenApi.annotations({
+          identifier: "v2.location.get",
+          summary: "Get location",
+          description: "Resolve the requested location or the server default location.",
+        }),
+      ),
+  )
+  .middleware(LocationMiddleware)
+
 function ref(request: HttpServerRequest.HttpServerRequest): Location.Ref {
   const query = new URL(request.url, "http://localhost").searchParams
   const workspaceID = query.get("location[workspace]") || request.headers["x-opencode-workspace"]

+ 15 - 0
packages/server/src/groups/question.ts

@@ -24,6 +24,21 @@ export const QuestionGroup = HttpApiGroup.make("server.question")
   )
   .annotateMerge(OpenApi.annotations({ title: "questions", description: "Experimental question routes." }))
   .middleware(LocationMiddleware)
+  .add(
+    HttpApiEndpoint.get("session.question.list", "/api/session/:sessionID/question", {
+      params: { sessionID: SessionV2.ID },
+      success: Schema.Struct({ data: Schema.Array(QuestionV2.Request) }),
+      error: SessionNotFoundError,
+    })
+      .middleware(SessionLocationMiddleware)
+      .annotateMerge(
+        OpenApi.annotations({
+          identifier: "v2.session.question.list",
+          summary: "List session question requests",
+          description: "Retrieve pending question requests owned by a session.",
+        }),
+      ),
+  )
   .add(
     HttpApiEndpoint.post("session.question.reply", "/api/session/:sessionID/question/:requestID/reply", {
       params: { sessionID: SessionV2.ID, requestID: QuestionV2.ID },

+ 36 - 0
packages/server/src/groups/session.ts

@@ -16,6 +16,9 @@ import {
   UnknownError,
 } from "../errors"
 import { SessionLocationMiddleware } from "../middleware/session-location"
+import { AgentV2 } from "@opencode-ai/core/agent"
+import { ModelV2 } from "@opencode-ai/core/model"
+import { Location } from "@opencode-ai/core/location"
 
 const SessionsQueryFields = {
   workspace: WorkspaceV2.ID.pipe(Schema.optional),
@@ -105,6 +108,39 @@ export const SessionGroup = HttpApiGroup.make("server.session")
       }),
     ),
   )
+  .add(
+    HttpApiEndpoint.post("session.create", "/api/session", {
+      payload: Schema.Struct({
+        id: SessionV2.ID.pipe(Schema.optional),
+        agent: AgentV2.ID.pipe(Schema.optional),
+        model: ModelV2.Ref.pipe(Schema.optional),
+        location: Location.Ref.pipe(Schema.optional),
+      }),
+      success: Schema.Struct({ data: SessionV2.Info }),
+    })
+      .annotateMerge(
+        OpenApi.annotations({
+          identifier: "v2.session.create",
+          summary: "Create session",
+          description: "Create a session at the requested location.",
+        }),
+      ),
+  )
+  .add(
+    HttpApiEndpoint.get("session.get", "/api/session/:sessionID", {
+      params: { sessionID: SessionV2.ID },
+      success: Schema.Struct({ data: SessionV2.Info }),
+      error: SessionNotFoundError,
+    })
+      .middleware(SessionLocationMiddleware)
+      .annotateMerge(
+        OpenApi.annotations({
+          identifier: "v2.session.get",
+          summary: "Get session",
+          description: "Retrieve a session by ID.",
+        }),
+      ),
+  )
   .add(
     HttpApiEndpoint.post("session.prompt", "/api/session/:sessionID/prompt", {
       params: { sessionID: SessionV2.ID },

+ 2 - 0
packages/server/src/handlers.ts

@@ -18,9 +18,11 @@ import { HealthHandler } from "./handlers/health"
 import { QuestionHandler } from "./handlers/question"
 import { ReferenceHandler } from "./handlers/reference"
 import * as SessionExecutionLocal from "@opencode-ai/core/session/execution/local"
+import { LocationHandler } from "./handlers/location"
 
 export const handlers = Layer.mergeAll(
   HealthHandler,
+  LocationHandler,
   AgentHandler,
   SessionHandler,
   MessageHandler,

+ 18 - 0
packages/server/src/handlers/location.ts

@@ -0,0 +1,18 @@
+import { Location } from "@opencode-ai/core/location"
+import { Effect } from "effect"
+import { HttpApiBuilder } from "effect/unstable/httpapi"
+import { Api } from "../api"
+
+export const LocationHandler = HttpApiBuilder.group(Api, "server.location", (handlers) =>
+  handlers.handle(
+    "location.get",
+    Effect.fn(function* () {
+      const location = yield* Location.Service
+      return new Location.Info({
+        directory: location.directory,
+        workspaceID: location.workspaceID,
+        project: location.project,
+      })
+    }),
+  ),
+)

+ 7 - 0
packages/server/src/handlers/question.ts

@@ -29,6 +29,13 @@ export const QuestionHandler = HttpApiBuilder.group(Api, "server.question", (han
           return yield* response((yield* QuestionV2.Service).list())
         }),
       )
+      .handle(
+        "session.question.list",
+        Effect.fn(function* (ctx) {
+          const requests = yield* (yield* QuestionV2.Service).list()
+          return { data: requests.filter((request) => request.sessionID === ctx.params.sessionID) }
+        }),
+      )
       .handle(
         "session.question.reply",
         Effect.fn(function* (ctx) {

+ 31 - 0
packages/server/src/handlers/session.ts

@@ -10,6 +10,7 @@ import {
   SessionNotFoundError,
   UnknownError,
 } from "../errors"
+import { AbsolutePath } from "@opencode-ai/core/schema"
 
 const DefaultSessionsLimit = 50
 
@@ -61,6 +62,36 @@ export const SessionHandler = HttpApiBuilder.group(Api, "server.session", (handl
           }
         }),
       )
+      .handle(
+        "session.create",
+        Effect.fn(function* (ctx) {
+          return {
+            data: yield* session.create({
+              id: ctx.payload.id,
+              agent: ctx.payload.agent,
+              model: ctx.payload.model,
+              location: ctx.payload.location ?? { directory: AbsolutePath.make(process.cwd()) },
+            }),
+          }
+        }),
+      )
+      .handle(
+        "session.get",
+        Effect.fn(function* (ctx) {
+          return {
+            data: yield* session.get(ctx.params.sessionID).pipe(
+              Effect.catchTag(
+                "Session.NotFoundError",
+                (error) =>
+                  new SessionNotFoundError({
+                    sessionID: error.sessionID,
+                    message: `Session not found: ${error.sessionID}`,
+                  }),
+              ),
+            ),
+          }
+        }),
+      )
       .handle(
         "session.prompt",
         Effect.fn(function* (ctx) {