add experimental permission HttpApi slice (#22385)
This commit is contained in:
@@ -121,14 +121,13 @@ Why `question` first:
|
|||||||
|
|
||||||
Do not re-architect business logic during the HTTP migration. `HttpApi` handlers should call the same Effect services already used by the Hono handlers.
|
Do not re-architect business logic during the HTTP migration. `HttpApi` handlers should call the same Effect services already used by the Hono handlers.
|
||||||
|
|
||||||
### 4. Run in parallel before replacing
|
### 4. Build in parallel, do not bridge into Hono
|
||||||
|
|
||||||
Prefer mounting an experimental `HttpApi` surface alongside the existing Hono routes first. That lowers migration risk and lets us compare:
|
The `HttpApi` implementation lives under `src/server/instance/httpapi/` as a standalone Effect HTTP server. It is **not mounted into the Hono app**. There is no `toWebHandler` bridge, no Hono `Handler` export, and no `.route()` call wiring it into `experimental.ts`.
|
||||||
|
|
||||||
- handler ergonomics
|
The standalone server (`httpapi/server.ts`) can be started independently and proves the routes work. Tests exercise it via `HttpRouter.serve` with `NodeHttpServer.layerTest`.
|
||||||
- OpenAPI output
|
|
||||||
- auth and middleware integration
|
The goal is to build enough route coverage in the Effect server that the Hono server can eventually be replaced entirely. Until then, the two implementations exist side by side but are completely separate processes.
|
||||||
- test ergonomics
|
|
||||||
|
|
||||||
### 5. Migrate JSON route groups gradually
|
### 5. Migrate JSON route groups gradually
|
||||||
|
|
||||||
@@ -218,17 +217,15 @@ Placement rule:
|
|||||||
|
|
||||||
Suggested file layout for a repeatable spike:
|
Suggested file layout for a repeatable spike:
|
||||||
|
|
||||||
- `src/server/instance/httpapi/question.ts`
|
- `src/server/instance/httpapi/question.ts` — contract and handler layer for one route group
|
||||||
- `src/server/instance/httpapi/index.ts`
|
- `src/server/instance/httpapi/server.ts` — standalone Effect HTTP server that composes all groups
|
||||||
- `test/server/question-httpapi.test.ts`
|
- `test/server/question-httpapi.test.ts` — end-to-end test against the real service
|
||||||
- `test/server/question-httpapi-openapi.test.ts`
|
|
||||||
|
|
||||||
Suggested responsibilities:
|
Suggested responsibilities:
|
||||||
|
|
||||||
- `question.ts` defines the `HttpApi` contract and `HttpApiBuilder.group(...)` handlers for the experimental slice
|
- `question.ts` defines the `HttpApi` contract and `HttpApiBuilder.group(...)` handlers
|
||||||
- `index.ts` combines experimental `HttpApi` groups and exposes the mounted handler or layer
|
- `server.ts` composes all route groups into one `HttpRouter.serve` layer with shared middleware (auth, instance lookup)
|
||||||
- `question-httpapi.test.ts` proves the route works end-to-end against the real service
|
- tests use `ExperimentalHttpApiServer.layerTest` to run against a real in-process HTTP server
|
||||||
- `question-httpapi-openapi.test.ts` proves the generated OpenAPI is acceptable for the migrated endpoints
|
|
||||||
|
|
||||||
## Example migration shape
|
## Example migration shape
|
||||||
|
|
||||||
@@ -248,11 +245,12 @@ Each route-group spike should follow the same shape.
|
|||||||
- keep handler bodies thin
|
- keep handler bodies thin
|
||||||
- keep transport mapping at the HTTP boundary only
|
- keep transport mapping at the HTTP boundary only
|
||||||
|
|
||||||
### 3. Mounting
|
### 3. Standalone server
|
||||||
|
|
||||||
- mount under an experimental prefix such as `/experimental/httpapi`
|
- the Effect HTTP server is self-contained in `httpapi/server.ts`
|
||||||
- keep existing Hono routes unchanged
|
- it is **not** mounted into the Hono app — no bridge, no `toWebHandler`
|
||||||
- expose separate OpenAPI output for the experimental slice first
|
- route paths use the `/experimental/httpapi` prefix so they match the eventual cutover
|
||||||
|
- each route group exposes its own OpenAPI doc endpoint
|
||||||
|
|
||||||
### 4. Verification
|
### 4. Verification
|
||||||
|
|
||||||
@@ -263,53 +261,32 @@ Each route-group spike should follow the same shape.
|
|||||||
|
|
||||||
## Boundary composition
|
## Boundary composition
|
||||||
|
|
||||||
The first slices should keep the existing outer server composition and only replace the route contract and handler layer.
|
The standalone Effect server owns its own middleware stack. It does not share middleware with the Hono server.
|
||||||
|
|
||||||
### Auth
|
### Auth
|
||||||
|
|
||||||
- keep `AuthMiddleware` at the outer Hono app level
|
- the standalone server implements auth as an `HttpApiMiddleware.Service` using `HttpApiSecurity.basic`
|
||||||
- do not duplicate auth checks inside each `HttpApi` group for the first parallel slices
|
- each route group's `HttpApi` is wrapped with `.middleware(Authorization)` before being served
|
||||||
- treat auth as an already-satisfied transport concern before the request reaches the `HttpApi` handler
|
- this is independent of the Hono `AuthMiddleware` — when the Effect server eventually replaces Hono, this becomes the only auth layer
|
||||||
|
|
||||||
Practical rule:
|
|
||||||
|
|
||||||
- if a route is currently protected by the shared server middleware stack, the experimental `HttpApi` route should stay mounted behind that same stack
|
|
||||||
|
|
||||||
### Instance and workspace lookup
|
### Instance and workspace lookup
|
||||||
|
|
||||||
- keep `WorkspaceRouterMiddleware` as the source of truth for resolving `directory`, `workspace`, and session-derived workspace context
|
- the standalone server resolves instance context via an `HttpRouter.middleware` that reads `x-opencode-directory` headers and `directory` query params
|
||||||
- let that middleware provide `Instance.current` and `WorkspaceContext` before the request reaches the `HttpApi` handler
|
- this is the Effect equivalent of the Hono `WorkspaceRouterMiddleware`
|
||||||
- keep the `HttpApi` handlers unaware of path-to-instance lookup details when the existing Hono middleware already handles them
|
- `HttpApi` handlers yield services from context and assume the correct instance has already been provided
|
||||||
|
|
||||||
Practical rule:
|
|
||||||
|
|
||||||
- `HttpApi` handlers should yield services from context and assume the correct instance has already been provided
|
|
||||||
- only move instance lookup into the `HttpApi` layer if we later decide to migrate the outer middleware boundary itself
|
|
||||||
|
|
||||||
### Error mapping
|
### Error mapping
|
||||||
|
|
||||||
- keep domain and service errors typed in the service layer
|
- keep domain and service errors typed in the service layer
|
||||||
- declare typed transport errors on the endpoint only when the route can actually return them intentionally
|
- declare typed transport errors on the endpoint only when the route can actually return them intentionally
|
||||||
- prefer explicit endpoint-level error schemas over relying on the outer Hono `ErrorMiddleware` for expected route behavior
|
- request decoding failures are transport-level `400`s handled by Effect `HttpApi` automatically
|
||||||
|
|
||||||
Practical rule:
|
|
||||||
|
|
||||||
- request decoding failures should remain transport-level `400`s
|
|
||||||
- storage or lookup failures that are part of the route contract should be declared as typed endpoint errors
|
- storage or lookup failures that are part of the route contract should be declared as typed endpoint errors
|
||||||
- unexpected defects can still fall through to the outer error middleware while the slice is experimental
|
|
||||||
|
|
||||||
For the current parallel slices, this means:
|
|
||||||
|
|
||||||
- auth still composes outside `HttpApi`
|
|
||||||
- instance selection still composes outside `HttpApi`
|
|
||||||
- success payloads should be schema-defined from canonical Effect schemas
|
|
||||||
- known route errors should be modeled at the endpoint boundary incrementally instead of all at once
|
|
||||||
|
|
||||||
## Exit criteria for the spike
|
## Exit criteria for the spike
|
||||||
|
|
||||||
The first slice is successful if:
|
The first slice is successful if:
|
||||||
|
|
||||||
- the endpoints run in parallel with the current Hono routes
|
- the standalone Effect server starts and serves the endpoints independently of the Hono server
|
||||||
- the handlers reuse the existing Effect service
|
- the handlers reuse the existing Effect service
|
||||||
- request decoding and response shapes are schema-defined from canonical Effect schemas
|
- request decoding and response shapes are schema-defined from canonical Effect schemas
|
||||||
- any remaining Zod boundary usage is derived from `.zod` or clearly temporary
|
- any remaining Zod boundary usage is derived from `.zod` or clearly temporary
|
||||||
@@ -324,8 +301,8 @@ The first parallel `question` spike gave us a concrete pattern to reuse.
|
|||||||
- scalar or collection schemas such as `Question.Answer` should stay as schemas and use helpers like `withStatics(...)` instead of being forced into classes.
|
- scalar or collection schemas such as `Question.Answer` should stay as schemas and use helpers like `withStatics(...)` instead of being forced into classes.
|
||||||
- if an `HttpApi` success schema uses `Schema.Class`, the handler or underlying service needs to return real schema instances rather than plain objects.
|
- if an `HttpApi` success schema uses `Schema.Class`, the handler or underlying service needs to return real schema instances rather than plain objects.
|
||||||
- internal event payloads can stay anonymous when we want to avoid adding extra named OpenAPI component churn for non-route shapes.
|
- internal event payloads can stay anonymous when we want to avoid adding extra named OpenAPI component churn for non-route shapes.
|
||||||
- the experimental slice should stay mounted in parallel and keep calling the existing service layer unchanged.
|
- the experimental slice should stay as a standalone Effect server and keep calling the existing service layer unchanged.
|
||||||
- compare generated OpenAPI semantically at the route and schema level; in the current setup the exported OpenAPI paths do not include the outer Hono mount prefix.
|
- compare generated OpenAPI semantically at the route and schema level.
|
||||||
|
|
||||||
## Route inventory
|
## Route inventory
|
||||||
|
|
||||||
|
|||||||
@@ -35,7 +35,7 @@ export namespace Agent {
|
|||||||
topP: z.number().optional(),
|
topP: z.number().optional(),
|
||||||
temperature: z.number().optional(),
|
temperature: z.number().optional(),
|
||||||
color: z.string().optional(),
|
color: z.string().optional(),
|
||||||
permission: Permission.Ruleset,
|
permission: Permission.Ruleset.zod,
|
||||||
model: z
|
model: z
|
||||||
.object({
|
.object({
|
||||||
modelID: ModelID.zod,
|
modelID: ModelID.zod,
|
||||||
|
|||||||
@@ -1,10 +1,13 @@
|
|||||||
import { Schema } from "effect"
|
import { Schema } from "effect"
|
||||||
import z from "zod"
|
import z from "zod"
|
||||||
|
|
||||||
import { withStatics } from "@/util/schema"
|
|
||||||
import { Identifier } from "@/id/id"
|
import { Identifier } from "@/id/id"
|
||||||
|
import { ZodOverride } from "@/util/effect-zod"
|
||||||
|
import { withStatics } from "@/util/schema"
|
||||||
|
|
||||||
const workspaceIdSchema = Schema.String.pipe(Schema.brand("WorkspaceID"))
|
const workspaceIdSchema = Schema.String.annotate({ [ZodOverride]: Identifier.schema("workspace") }).pipe(
|
||||||
|
Schema.brand("WorkspaceID"),
|
||||||
|
)
|
||||||
|
|
||||||
export type WorkspaceID = typeof workspaceIdSchema.Type
|
export type WorkspaceID = typeof workspaceIdSchema.Type
|
||||||
|
|
||||||
|
|||||||
@@ -7,75 +7,84 @@ import { Instance } from "@/project/instance"
|
|||||||
import { MessageID, SessionID } from "@/session/schema"
|
import { MessageID, SessionID } from "@/session/schema"
|
||||||
import { PermissionTable } from "@/session/session.sql"
|
import { PermissionTable } from "@/session/session.sql"
|
||||||
import { Database, eq } from "@/storage/db"
|
import { Database, eq } from "@/storage/db"
|
||||||
|
import { zod } from "@/util/effect-zod"
|
||||||
import { Log } from "@/util/log"
|
import { Log } from "@/util/log"
|
||||||
|
import { withStatics } from "@/util/schema"
|
||||||
import { Wildcard } from "@/util/wildcard"
|
import { Wildcard } from "@/util/wildcard"
|
||||||
import { Deferred, Effect, Layer, Schema, Context } from "effect"
|
import { Deferred, Effect, Layer, Schema, Context } from "effect"
|
||||||
import os from "os"
|
import os from "os"
|
||||||
import z from "zod"
|
|
||||||
import { evaluate as evalRule } from "./evaluate"
|
import { evaluate as evalRule } from "./evaluate"
|
||||||
import { PermissionID } from "./schema"
|
import { PermissionID } from "./schema"
|
||||||
|
|
||||||
export namespace Permission {
|
export namespace Permission {
|
||||||
const log = Log.create({ service: "permission" })
|
const log = Log.create({ service: "permission" })
|
||||||
|
|
||||||
export const Action = z.enum(["allow", "deny", "ask"]).meta({
|
export const Action = Schema.Literals(["allow", "deny", "ask"])
|
||||||
ref: "PermissionAction",
|
.annotate({ identifier: "PermissionAction" })
|
||||||
})
|
.pipe(withStatics((s) => ({ zod: zod(s) })))
|
||||||
export type Action = z.infer<typeof Action>
|
export type Action = Schema.Schema.Type<typeof Action>
|
||||||
|
|
||||||
export const Rule = z
|
export class Rule extends Schema.Class<Rule>("PermissionRule")({
|
||||||
.object({
|
permission: Schema.String,
|
||||||
permission: z.string(),
|
pattern: Schema.String,
|
||||||
pattern: z.string(),
|
action: Action,
|
||||||
action: Action,
|
}) {
|
||||||
})
|
static readonly zod = zod(this)
|
||||||
.meta({
|
}
|
||||||
ref: "PermissionRule",
|
|
||||||
})
|
|
||||||
export type Rule = z.infer<typeof Rule>
|
|
||||||
|
|
||||||
export const Ruleset = Rule.array().meta({
|
export const Ruleset = Schema.mutable(Schema.Array(Rule))
|
||||||
ref: "PermissionRuleset",
|
.annotate({ identifier: "PermissionRuleset" })
|
||||||
})
|
.pipe(withStatics((s) => ({ zod: zod(s) })))
|
||||||
export type Ruleset = z.infer<typeof Ruleset>
|
export type Ruleset = Schema.Schema.Type<typeof Ruleset>
|
||||||
|
|
||||||
export const Request = z
|
export class Request extends Schema.Class<Request>("PermissionRequest")({
|
||||||
.object({
|
id: PermissionID,
|
||||||
id: PermissionID.zod,
|
sessionID: SessionID,
|
||||||
sessionID: SessionID.zod,
|
permission: Schema.String,
|
||||||
permission: z.string(),
|
patterns: Schema.Array(Schema.String),
|
||||||
patterns: z.string().array(),
|
metadata: Schema.Record(Schema.String, Schema.Unknown),
|
||||||
metadata: z.record(z.string(), z.any()),
|
always: Schema.Array(Schema.String),
|
||||||
always: z.string().array(),
|
tool: Schema.optional(
|
||||||
tool: z
|
Schema.Struct({
|
||||||
.object({
|
messageID: MessageID,
|
||||||
messageID: MessageID.zod,
|
callID: Schema.String,
|
||||||
callID: z.string(),
|
}),
|
||||||
})
|
),
|
||||||
.optional(),
|
}) {
|
||||||
})
|
static readonly zod = zod(this)
|
||||||
.meta({
|
}
|
||||||
ref: "PermissionRequest",
|
|
||||||
})
|
|
||||||
export type Request = z.infer<typeof Request>
|
|
||||||
|
|
||||||
export const Reply = z.enum(["once", "always", "reject"])
|
export const Reply = Schema.Literals(["once", "always", "reject"]).pipe(withStatics((s) => ({ zod: zod(s) })))
|
||||||
export type Reply = z.infer<typeof Reply>
|
export type Reply = Schema.Schema.Type<typeof Reply>
|
||||||
|
|
||||||
export const Approval = z.object({
|
const reply = {
|
||||||
projectID: ProjectID.zod,
|
reply: Reply,
|
||||||
patterns: z.string().array(),
|
message: Schema.optional(Schema.String),
|
||||||
})
|
}
|
||||||
|
|
||||||
|
export const ReplyBody = Schema.Struct(reply)
|
||||||
|
.annotate({ identifier: "PermissionReplyBody" })
|
||||||
|
.pipe(withStatics((s) => ({ zod: zod(s) })))
|
||||||
|
export type ReplyBody = Schema.Schema.Type<typeof ReplyBody>
|
||||||
|
|
||||||
|
export class Approval extends Schema.Class<Approval>("PermissionApproval")({
|
||||||
|
projectID: ProjectID,
|
||||||
|
patterns: Schema.Array(Schema.String),
|
||||||
|
}) {
|
||||||
|
static readonly zod = zod(this)
|
||||||
|
}
|
||||||
|
|
||||||
export const Event = {
|
export const Event = {
|
||||||
Asked: BusEvent.define("permission.asked", Request),
|
Asked: BusEvent.define("permission.asked", Request.zod),
|
||||||
Replied: BusEvent.define(
|
Replied: BusEvent.define(
|
||||||
"permission.replied",
|
"permission.replied",
|
||||||
z.object({
|
zod(
|
||||||
sessionID: SessionID.zod,
|
Schema.Struct({
|
||||||
requestID: PermissionID.zod,
|
sessionID: SessionID,
|
||||||
reply: Reply,
|
requestID: PermissionID,
|
||||||
}),
|
reply: Reply,
|
||||||
|
}),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -103,20 +112,27 @@ export namespace Permission {
|
|||||||
|
|
||||||
export type Error = DeniedError | RejectedError | CorrectedError
|
export type Error = DeniedError | RejectedError | CorrectedError
|
||||||
|
|
||||||
export const AskInput = Request.partial({ id: true }).extend({
|
export const AskInput = Schema.Struct({
|
||||||
|
...Request.fields,
|
||||||
|
id: Schema.optional(PermissionID),
|
||||||
ruleset: Ruleset,
|
ruleset: Ruleset,
|
||||||
})
|
})
|
||||||
|
.annotate({ identifier: "PermissionAskInput" })
|
||||||
|
.pipe(withStatics((s) => ({ zod: zod(s) })))
|
||||||
|
export type AskInput = Schema.Schema.Type<typeof AskInput>
|
||||||
|
|
||||||
export const ReplyInput = z.object({
|
export const ReplyInput = Schema.Struct({
|
||||||
requestID: PermissionID.zod,
|
requestID: PermissionID,
|
||||||
reply: Reply,
|
...reply,
|
||||||
message: z.string().optional(),
|
|
||||||
})
|
})
|
||||||
|
.annotate({ identifier: "PermissionReplyInput" })
|
||||||
|
.pipe(withStatics((s) => ({ zod: zod(s) })))
|
||||||
|
export type ReplyInput = Schema.Schema.Type<typeof ReplyInput>
|
||||||
|
|
||||||
export interface Interface {
|
export interface Interface {
|
||||||
readonly ask: (input: z.infer<typeof AskInput>) => Effect.Effect<void, Error>
|
readonly ask: (input: AskInput) => Effect.Effect<void, Error>
|
||||||
readonly reply: (input: z.infer<typeof ReplyInput>) => Effect.Effect<void>
|
readonly reply: (input: ReplyInput) => Effect.Effect<void>
|
||||||
readonly list: () => Effect.Effect<Request[]>
|
readonly list: () => Effect.Effect<ReadonlyArray<Request>>
|
||||||
}
|
}
|
||||||
|
|
||||||
interface PendingEntry {
|
interface PendingEntry {
|
||||||
@@ -163,7 +179,7 @@ export namespace Permission {
|
|||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
const ask = Effect.fn("Permission.ask")(function* (input: z.infer<typeof AskInput>) {
|
const ask = Effect.fn("Permission.ask")(function* (input: AskInput) {
|
||||||
const { approved, pending } = yield* InstanceState.get(state)
|
const { approved, pending } = yield* InstanceState.get(state)
|
||||||
const { ruleset, ...request } = input
|
const { ruleset, ...request } = input
|
||||||
let needsAsk = false
|
let needsAsk = false
|
||||||
@@ -183,10 +199,10 @@ export namespace Permission {
|
|||||||
if (!needsAsk) return
|
if (!needsAsk) return
|
||||||
|
|
||||||
const id = request.id ?? PermissionID.ascending()
|
const id = request.id ?? PermissionID.ascending()
|
||||||
const info: Request = {
|
const info = Schema.decodeUnknownSync(Request)({
|
||||||
id,
|
id,
|
||||||
...request,
|
...request,
|
||||||
}
|
})
|
||||||
log.info("asking", { id, permission: info.permission, patterns: info.patterns })
|
log.info("asking", { id, permission: info.permission, patterns: info.patterns })
|
||||||
|
|
||||||
const deferred = yield* Deferred.make<void, RejectedError | CorrectedError>()
|
const deferred = yield* Deferred.make<void, RejectedError | CorrectedError>()
|
||||||
@@ -200,7 +216,7 @@ export namespace Permission {
|
|||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
const reply = Effect.fn("Permission.reply")(function* (input: z.infer<typeof ReplyInput>) {
|
const reply = Effect.fn("Permission.reply")(function* (input: ReplyInput) {
|
||||||
const { approved, pending } = yield* InstanceState.get(state)
|
const { approved, pending } = yield* InstanceState.get(state)
|
||||||
const existing = pending.get(input.requestID)
|
const existing = pending.get(input.requestID)
|
||||||
if (!existing) return
|
if (!existing) return
|
||||||
|
|||||||
@@ -2,9 +2,13 @@ import { Schema } from "effect"
|
|||||||
import z from "zod"
|
import z from "zod"
|
||||||
|
|
||||||
import { Identifier } from "@/id/id"
|
import { Identifier } from "@/id/id"
|
||||||
|
import { ZodOverride } from "@/util/effect-zod"
|
||||||
import { Newtype } from "@/util/schema"
|
import { Newtype } from "@/util/schema"
|
||||||
|
|
||||||
export class PermissionID extends Newtype<PermissionID>()("PermissionID", Schema.String) {
|
export class PermissionID extends Newtype<PermissionID>()(
|
||||||
|
"PermissionID",
|
||||||
|
Schema.String.annotate({ [ZodOverride]: Identifier.schema("permission") }),
|
||||||
|
) {
|
||||||
static ascending(id?: string): PermissionID {
|
static ascending(id?: string): PermissionID {
|
||||||
return this.make(Identifier.ascending("permission", id))
|
return this.make(Identifier.ascending("permission", id))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,9 +2,10 @@ import { Schema } from "effect"
|
|||||||
import z from "zod"
|
import z from "zod"
|
||||||
|
|
||||||
import { Identifier } from "@/id/id"
|
import { Identifier } from "@/id/id"
|
||||||
|
import { ZodOverride } from "@/util/effect-zod"
|
||||||
import { withStatics } from "@/util/schema"
|
import { withStatics } from "@/util/schema"
|
||||||
|
|
||||||
const ptyIdSchema = Schema.String.pipe(Schema.brand("PtyID"))
|
const ptyIdSchema = Schema.String.annotate({ [ZodOverride]: Identifier.schema("pty") }).pipe(Schema.brand("PtyID"))
|
||||||
|
|
||||||
export type PtyID = typeof ptyIdSchema.Type
|
export type PtyID = typeof ptyIdSchema.Type
|
||||||
|
|
||||||
|
|||||||
@@ -2,9 +2,13 @@ import { Schema } from "effect"
|
|||||||
import z from "zod"
|
import z from "zod"
|
||||||
|
|
||||||
import { Identifier } from "@/id/id"
|
import { Identifier } from "@/id/id"
|
||||||
|
import { ZodOverride } from "@/util/effect-zod"
|
||||||
import { Newtype } from "@/util/schema"
|
import { Newtype } from "@/util/schema"
|
||||||
|
|
||||||
export class QuestionID extends Newtype<QuestionID>()("QuestionID", Schema.String) {
|
export class QuestionID extends Newtype<QuestionID>()(
|
||||||
|
"QuestionID",
|
||||||
|
Schema.String.annotate({ [ZodOverride]: Identifier.schema("question") }),
|
||||||
|
) {
|
||||||
static ascending(id?: string): QuestionID {
|
static ascending(id?: string): QuestionID {
|
||||||
return this.make(Identifier.ascending("question", id))
|
return this.make(Identifier.ascending("question", id))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,7 +18,6 @@ import { lazy } from "../../util/lazy"
|
|||||||
import { Effect, Option } from "effect"
|
import { Effect, Option } from "effect"
|
||||||
import { WorkspaceRoutes } from "./workspace"
|
import { WorkspaceRoutes } from "./workspace"
|
||||||
import { Agent } from "@/agent/agent"
|
import { Agent } from "@/agent/agent"
|
||||||
import { HttpApiRoutes } from "./httpapi"
|
|
||||||
|
|
||||||
const ConsoleOrgOption = z.object({
|
const ConsoleOrgOption = z.object({
|
||||||
accountID: z.string(),
|
accountID: z.string(),
|
||||||
@@ -40,7 +39,6 @@ const ConsoleSwitchBody = z.object({
|
|||||||
|
|
||||||
export const ExperimentalRoutes = lazy(() =>
|
export const ExperimentalRoutes = lazy(() =>
|
||||||
new Hono()
|
new Hono()
|
||||||
.route("/httpapi", HttpApiRoutes())
|
|
||||||
.get(
|
.get(
|
||||||
"/console",
|
"/console",
|
||||||
describeRoute({
|
describeRoute({
|
||||||
|
|||||||
@@ -1,7 +0,0 @@
|
|||||||
import { lazy } from "@/util/lazy"
|
|
||||||
import { Hono } from "hono"
|
|
||||||
import { QuestionHttpApiHandler } from "./question"
|
|
||||||
|
|
||||||
export const HttpApiRoutes = lazy(() =>
|
|
||||||
new Hono().all("/question", QuestionHttpApiHandler).all("/question/*", QuestionHttpApiHandler),
|
|
||||||
)
|
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
import { Permission } from "@/permission"
|
||||||
|
import { PermissionID } from "@/permission/schema"
|
||||||
|
import { Effect, Layer, Schema } from "effect"
|
||||||
|
import { HttpApi, HttpApiBuilder, HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
|
||||||
|
|
||||||
|
const root = "/experimental/httpapi/permission"
|
||||||
|
|
||||||
|
export const PermissionApi = HttpApi.make("permission")
|
||||||
|
.add(
|
||||||
|
HttpApiGroup.make("permission")
|
||||||
|
.add(
|
||||||
|
HttpApiEndpoint.get("list", root, {
|
||||||
|
success: Schema.Array(Permission.Request),
|
||||||
|
}).annotateMerge(
|
||||||
|
OpenApi.annotations({
|
||||||
|
identifier: "permission.list",
|
||||||
|
summary: "List pending permissions",
|
||||||
|
description: "Get all pending permission requests across all sessions.",
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
HttpApiEndpoint.post("reply", `${root}/:requestID/reply`, {
|
||||||
|
params: { requestID: PermissionID },
|
||||||
|
payload: Permission.ReplyBody,
|
||||||
|
success: Schema.Boolean,
|
||||||
|
}).annotateMerge(
|
||||||
|
OpenApi.annotations({
|
||||||
|
identifier: "permission.reply",
|
||||||
|
summary: "Respond to permission request",
|
||||||
|
description: "Approve or deny a permission request from the AI assistant.",
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.annotateMerge(
|
||||||
|
OpenApi.annotations({
|
||||||
|
title: "permission",
|
||||||
|
description: "Experimental HttpApi permission routes.",
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.annotateMerge(
|
||||||
|
OpenApi.annotations({
|
||||||
|
title: "opencode experimental HttpApi",
|
||||||
|
version: "0.0.1",
|
||||||
|
description: "Experimental HttpApi surface for selected instance routes.",
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
export const PermissionLive = Layer.unwrap(
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const svc = yield* Permission.Service
|
||||||
|
|
||||||
|
const list = Effect.fn("PermissionHttpApi.list")(function* () {
|
||||||
|
return yield* svc.list()
|
||||||
|
})
|
||||||
|
|
||||||
|
const reply = Effect.fn("PermissionHttpApi.reply")(function* (ctx: {
|
||||||
|
params: { requestID: PermissionID }
|
||||||
|
payload: Permission.ReplyBody
|
||||||
|
}) {
|
||||||
|
yield* svc.reply({
|
||||||
|
requestID: ctx.params.requestID,
|
||||||
|
reply: ctx.payload.reply,
|
||||||
|
message: ctx.payload.message,
|
||||||
|
})
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
|
||||||
|
return HttpApiBuilder.group(PermissionApi, "permission", (handlers) =>
|
||||||
|
handlers.handle("list", list).handle("reply", reply),
|
||||||
|
)
|
||||||
|
}),
|
||||||
|
).pipe(Layer.provide(Permission.defaultLayer))
|
||||||
@@ -1,44 +1,71 @@
|
|||||||
import { AppLayer } from "@/effect/app-runtime"
|
|
||||||
import { memoMap } from "@/effect/run-service"
|
|
||||||
import { Question } from "@/question"
|
import { Question } from "@/question"
|
||||||
import { QuestionID } from "@/question/schema"
|
import { QuestionID } from "@/question/schema"
|
||||||
import { lazy } from "@/util/lazy"
|
import { Effect, Layer, Schema } from "effect"
|
||||||
import { makeQuestionHandler, questionApi } from "@opencode-ai/server"
|
import { HttpApi, HttpApiBuilder, HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
|
||||||
import { Effect, Layer } from "effect"
|
|
||||||
import { HttpRouter, HttpServer } from "effect/unstable/http"
|
|
||||||
import { HttpApiBuilder } from "effect/unstable/httpapi"
|
|
||||||
import type { Handler } from "hono"
|
|
||||||
|
|
||||||
const root = "/experimental/httpapi/question"
|
const root = "/experimental/httpapi/question"
|
||||||
|
|
||||||
const QuestionLive = makeQuestionHandler({
|
export const QuestionApi = HttpApi.make("question")
|
||||||
list: Effect.fn("QuestionHttpApi.host.list")(function* () {
|
.add(
|
||||||
const svc = yield* Question.Service
|
HttpApiGroup.make("question")
|
||||||
return yield* svc.list()
|
.add(
|
||||||
}),
|
HttpApiEndpoint.get("list", root, {
|
||||||
reply: Effect.fn("QuestionHttpApi.host.reply")(function* (input) {
|
success: Schema.Array(Question.Request),
|
||||||
const svc = yield* Question.Service
|
}).annotateMerge(
|
||||||
yield* svc.reply({
|
OpenApi.annotations({
|
||||||
requestID: QuestionID.make(input.requestID),
|
identifier: "question.list",
|
||||||
answers: input.answers,
|
summary: "List pending questions",
|
||||||
})
|
description: "Get all pending question requests across all sessions.",
|
||||||
}),
|
}),
|
||||||
}).pipe(Layer.provide(Question.defaultLayer))
|
),
|
||||||
|
HttpApiEndpoint.post("reply", `${root}/:requestID/reply`, {
|
||||||
const web = lazy(() =>
|
params: { requestID: QuestionID },
|
||||||
HttpRouter.toWebHandler(
|
payload: Question.Reply,
|
||||||
Layer.mergeAll(
|
success: Schema.Boolean,
|
||||||
AppLayer,
|
}).annotateMerge(
|
||||||
HttpApiBuilder.layer(questionApi, { openapiPath: `${root}/doc` }).pipe(
|
OpenApi.annotations({
|
||||||
Layer.provide(QuestionLive),
|
identifier: "question.reply",
|
||||||
Layer.provide(HttpServer.layerServices),
|
summary: "Reply to question request",
|
||||||
|
description: "Provide answers to a question request from the AI assistant.",
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.annotateMerge(
|
||||||
|
OpenApi.annotations({
|
||||||
|
title: "question",
|
||||||
|
description: "Experimental HttpApi question routes.",
|
||||||
|
}),
|
||||||
),
|
),
|
||||||
),
|
)
|
||||||
{
|
.annotateMerge(
|
||||||
disableLogger: true,
|
OpenApi.annotations({
|
||||||
memoMap,
|
title: "opencode experimental HttpApi",
|
||||||
},
|
version: "0.0.1",
|
||||||
),
|
description: "Experimental HttpApi surface for selected instance routes.",
|
||||||
)
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
export const QuestionHttpApiHandler: Handler = (c, _next) => web().handler(c.req.raw)
|
export const QuestionLive = Layer.unwrap(
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const svc = yield* Question.Service
|
||||||
|
|
||||||
|
const list = Effect.fn("QuestionHttpApi.list")(function* () {
|
||||||
|
return yield* svc.list()
|
||||||
|
})
|
||||||
|
|
||||||
|
const reply = Effect.fn("QuestionHttpApi.reply")(function* (ctx: {
|
||||||
|
params: { requestID: QuestionID }
|
||||||
|
payload: Question.Reply
|
||||||
|
}) {
|
||||||
|
yield* svc.reply({
|
||||||
|
requestID: ctx.params.requestID,
|
||||||
|
answers: ctx.payload.answers,
|
||||||
|
})
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
|
||||||
|
return HttpApiBuilder.group(QuestionApi, "question", (handlers) =>
|
||||||
|
handlers.handle("list", list).handle("reply", reply),
|
||||||
|
)
|
||||||
|
}),
|
||||||
|
).pipe(Layer.provide(Question.defaultLayer))
|
||||||
|
|||||||
@@ -0,0 +1,135 @@
|
|||||||
|
import { NodeHttpServer } from "@effect/platform-node"
|
||||||
|
import { Effect, Layer, Redacted, Schema } from "effect"
|
||||||
|
import { HttpApiBuilder, HttpApiMiddleware, HttpApiSecurity } from "effect/unstable/httpapi"
|
||||||
|
import { HttpRouter, HttpServerRequest, HttpServerResponse } from "effect/unstable/http"
|
||||||
|
import { createServer } from "node:http"
|
||||||
|
import { AppRuntime } from "@/effect/app-runtime"
|
||||||
|
import { InstanceRef, WorkspaceRef } from "@/effect/instance-ref"
|
||||||
|
import { Flag } from "@/flag/flag"
|
||||||
|
import { InstanceBootstrap } from "@/project/bootstrap"
|
||||||
|
import { Instance } from "@/project/instance"
|
||||||
|
import { Filesystem } from "@/util/filesystem"
|
||||||
|
import { Permission } from "@/permission"
|
||||||
|
import { Question } from "@/question"
|
||||||
|
import { PermissionApi, PermissionLive } from "./permission"
|
||||||
|
import { QuestionApi, QuestionLive } from "./question"
|
||||||
|
|
||||||
|
const Query = Schema.Struct({
|
||||||
|
directory: Schema.optional(Schema.String),
|
||||||
|
workspace: Schema.optional(Schema.String),
|
||||||
|
auth_token: Schema.optional(Schema.String),
|
||||||
|
})
|
||||||
|
|
||||||
|
const Headers = Schema.Struct({
|
||||||
|
authorization: Schema.optional(Schema.String),
|
||||||
|
"x-opencode-directory": Schema.optional(Schema.String),
|
||||||
|
})
|
||||||
|
|
||||||
|
export namespace ExperimentalHttpApiServer {
|
||||||
|
function text(input: string, status: number, headers?: Record<string, string>) {
|
||||||
|
return HttpServerResponse.text(input, { status, headers })
|
||||||
|
}
|
||||||
|
|
||||||
|
function decode(input: string) {
|
||||||
|
try {
|
||||||
|
return decodeURIComponent(input)
|
||||||
|
} catch {
|
||||||
|
return input
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class Unauthorized extends Schema.TaggedErrorClass<Unauthorized>()(
|
||||||
|
"Unauthorized",
|
||||||
|
{ message: Schema.String },
|
||||||
|
{ httpApiStatus: 401 },
|
||||||
|
) {}
|
||||||
|
|
||||||
|
class Authorization extends HttpApiMiddleware.Service<Authorization>()("@opencode/ExperimentalHttpApiAuthorization", {
|
||||||
|
error: Unauthorized,
|
||||||
|
security: {
|
||||||
|
basic: HttpApiSecurity.basic,
|
||||||
|
},
|
||||||
|
}) {}
|
||||||
|
|
||||||
|
const normalize = HttpRouter.middleware()(
|
||||||
|
Effect.gen(function* () {
|
||||||
|
return (effect) =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const query = yield* HttpServerRequest.schemaSearchParams(Query)
|
||||||
|
if (!query.auth_token) return yield* effect
|
||||||
|
const req = yield* HttpServerRequest.HttpServerRequest
|
||||||
|
const next = req.modify({
|
||||||
|
headers: {
|
||||||
|
...req.headers,
|
||||||
|
authorization: `Basic ${query.auth_token}`,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
return yield* effect.pipe(Effect.provideService(HttpServerRequest.HttpServerRequest, next))
|
||||||
|
})
|
||||||
|
}),
|
||||||
|
).layer
|
||||||
|
|
||||||
|
const auth = Layer.succeed(
|
||||||
|
Authorization,
|
||||||
|
Authorization.of({
|
||||||
|
basic: (effect, { credential }) =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
if (!Flag.OPENCODE_SERVER_PASSWORD) return yield* effect
|
||||||
|
|
||||||
|
const user = Flag.OPENCODE_SERVER_USERNAME ?? "opencode"
|
||||||
|
if (credential.username !== user) {
|
||||||
|
return yield* new Unauthorized({ message: "Unauthorized" })
|
||||||
|
}
|
||||||
|
if (Redacted.value(credential.password) !== Flag.OPENCODE_SERVER_PASSWORD) {
|
||||||
|
return yield* new Unauthorized({ message: "Unauthorized" })
|
||||||
|
}
|
||||||
|
return yield* effect
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
const instance = HttpRouter.middleware()(
|
||||||
|
Effect.gen(function* () {
|
||||||
|
return (effect) =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const query = yield* HttpServerRequest.schemaSearchParams(Query)
|
||||||
|
const headers = yield* HttpServerRequest.schemaHeaders(Headers)
|
||||||
|
const raw = query.directory || headers["x-opencode-directory"] || process.cwd()
|
||||||
|
const workspace = query.workspace || undefined
|
||||||
|
const ctx = yield* Effect.promise(() =>
|
||||||
|
Instance.provide({
|
||||||
|
directory: Filesystem.resolve(decode(raw)),
|
||||||
|
init: () => AppRuntime.runPromise(InstanceBootstrap),
|
||||||
|
fn: () => Instance.current,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
const next = workspace ? effect.pipe(Effect.provideService(WorkspaceRef, workspace)) : effect
|
||||||
|
return yield* next.pipe(Effect.provideService(InstanceRef, ctx))
|
||||||
|
})
|
||||||
|
}),
|
||||||
|
).layer
|
||||||
|
|
||||||
|
const QuestionSecured = QuestionApi.middleware(Authorization)
|
||||||
|
const PermissionSecured = PermissionApi.middleware(Authorization)
|
||||||
|
|
||||||
|
export const routes = Layer.mergeAll(
|
||||||
|
HttpApiBuilder.layer(QuestionSecured, { openapiPath: "/experimental/httpapi/question/doc" }).pipe(
|
||||||
|
Layer.provide(QuestionLive),
|
||||||
|
),
|
||||||
|
HttpApiBuilder.layer(PermissionSecured, { openapiPath: "/experimental/httpapi/permission/doc" }).pipe(
|
||||||
|
Layer.provide(PermissionLive),
|
||||||
|
),
|
||||||
|
).pipe(Layer.provide(auth), Layer.provide(normalize), Layer.provide(instance))
|
||||||
|
|
||||||
|
export const layer = (opts: { hostname: string; port: number }) =>
|
||||||
|
HttpRouter.serve(routes, { disableListenLog: true, disableLogger: true }).pipe(
|
||||||
|
Layer.provideMerge(NodeHttpServer.layer(createServer, { port: opts.port, host: opts.hostname })),
|
||||||
|
)
|
||||||
|
|
||||||
|
export const layerTest = HttpRouter.serve(routes, { disableListenLog: true, disableLogger: true }).pipe(
|
||||||
|
Layer.provideMerge(NodeHttpServer.layerTest),
|
||||||
|
Layer.provideMerge(Question.defaultLayer),
|
||||||
|
Layer.provideMerge(Permission.defaultLayer),
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -33,7 +33,7 @@ export const PermissionRoutes = lazy(() =>
|
|||||||
requestID: PermissionID.zod,
|
requestID: PermissionID.zod,
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
validator("json", z.object({ reply: Permission.Reply, message: z.string().optional() })),
|
validator("json", z.object({ reply: Permission.Reply.zod, message: z.string().optional() })),
|
||||||
async (c) => {
|
async (c) => {
|
||||||
const params = c.req.valid("param")
|
const params = c.req.valid("param")
|
||||||
const json = c.req.valid("json")
|
const json = c.req.valid("json")
|
||||||
@@ -60,7 +60,7 @@ export const PermissionRoutes = lazy(() =>
|
|||||||
description: "List of pending permissions",
|
description: "List of pending permissions",
|
||||||
content: {
|
content: {
|
||||||
"application/json": {
|
"application/json": {
|
||||||
schema: resolver(Permission.Request.array()),
|
schema: resolver(Permission.Request.zod.array()),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -274,7 +274,7 @@ export const SessionRoutes = lazy(() =>
|
|||||||
"json",
|
"json",
|
||||||
z.object({
|
z.object({
|
||||||
title: z.string().optional(),
|
title: z.string().optional(),
|
||||||
permission: Permission.Ruleset.optional(),
|
permission: Permission.Ruleset.zod.optional(),
|
||||||
time: z
|
time: z
|
||||||
.object({
|
.object({
|
||||||
archived: z.number().optional(),
|
archived: z.number().optional(),
|
||||||
@@ -1093,7 +1093,7 @@ export const SessionRoutes = lazy(() =>
|
|||||||
permissionID: PermissionID.zod,
|
permissionID: PermissionID.zod,
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
validator("json", z.object({ response: Permission.Reply })),
|
validator("json", z.object({ response: Permission.Reply.zod })),
|
||||||
async (c) => {
|
async (c) => {
|
||||||
const params = c.req.valid("param")
|
const params = c.req.valid("param")
|
||||||
await AppRuntime.runPromise(
|
await AppRuntime.runPromise(
|
||||||
|
|||||||
@@ -144,7 +144,7 @@ export namespace Session {
|
|||||||
compacting: z.number().optional(),
|
compacting: z.number().optional(),
|
||||||
archived: z.number().optional(),
|
archived: z.number().optional(),
|
||||||
}),
|
}),
|
||||||
permission: Permission.Ruleset.optional(),
|
permission: Permission.Ruleset.zod.optional(),
|
||||||
revert: z
|
revert: z
|
||||||
.object({
|
.object({
|
||||||
messageID: MessageID.zod,
|
messageID: MessageID.zod,
|
||||||
@@ -193,7 +193,7 @@ export namespace Session {
|
|||||||
export const RemoveInput = SessionID.zod
|
export const RemoveInput = SessionID.zod
|
||||||
export const SetTitleInput = z.object({ sessionID: SessionID.zod, title: z.string() })
|
export const SetTitleInput = z.object({ sessionID: SessionID.zod, title: z.string() })
|
||||||
export const SetArchivedInput = z.object({ sessionID: SessionID.zod, time: z.number().optional() })
|
export const SetArchivedInput = z.object({ sessionID: SessionID.zod, time: z.number().optional() })
|
||||||
export const SetPermissionInput = z.object({ sessionID: SessionID.zod, permission: Permission.Ruleset })
|
export const SetPermissionInput = z.object({ sessionID: SessionID.zod, permission: Permission.Ruleset.zod })
|
||||||
export const SetRevertInput = z.object({
|
export const SetRevertInput = z.object({
|
||||||
sessionID: SessionID.zod,
|
sessionID: SessionID.zod,
|
||||||
revert: Info.shape.revert,
|
revert: Info.shape.revert,
|
||||||
|
|||||||
@@ -2,9 +2,10 @@ import { Schema } from "effect"
|
|||||||
import z from "zod"
|
import z from "zod"
|
||||||
|
|
||||||
import { Identifier } from "@/id/id"
|
import { Identifier } from "@/id/id"
|
||||||
|
import { ZodOverride } from "@/util/effect-zod"
|
||||||
import { withStatics } from "@/util/schema"
|
import { withStatics } from "@/util/schema"
|
||||||
|
|
||||||
export const SessionID = Schema.String.pipe(
|
export const SessionID = Schema.String.annotate({ [ZodOverride]: Identifier.schema("session") }).pipe(
|
||||||
Schema.brand("SessionID"),
|
Schema.brand("SessionID"),
|
||||||
withStatics((s) => ({
|
withStatics((s) => ({
|
||||||
descending: (id?: string) => s.make(Identifier.descending("session", id)),
|
descending: (id?: string) => s.make(Identifier.descending("session", id)),
|
||||||
@@ -14,7 +15,7 @@ export const SessionID = Schema.String.pipe(
|
|||||||
|
|
||||||
export type SessionID = Schema.Schema.Type<typeof SessionID>
|
export type SessionID = Schema.Schema.Type<typeof SessionID>
|
||||||
|
|
||||||
export const MessageID = Schema.String.pipe(
|
export const MessageID = Schema.String.annotate({ [ZodOverride]: Identifier.schema("message") }).pipe(
|
||||||
Schema.brand("MessageID"),
|
Schema.brand("MessageID"),
|
||||||
withStatics((s) => ({
|
withStatics((s) => ({
|
||||||
ascending: (id?: string) => s.make(Identifier.ascending("message", id)),
|
ascending: (id?: string) => s.make(Identifier.ascending("message", id)),
|
||||||
@@ -24,7 +25,7 @@ export const MessageID = Schema.String.pipe(
|
|||||||
|
|
||||||
export type MessageID = Schema.Schema.Type<typeof MessageID>
|
export type MessageID = Schema.Schema.Type<typeof MessageID>
|
||||||
|
|
||||||
export const PartID = Schema.String.pipe(
|
export const PartID = Schema.String.annotate({ [ZodOverride]: Identifier.schema("part") }).pipe(
|
||||||
Schema.brand("PartID"),
|
Schema.brand("PartID"),
|
||||||
withStatics((s) => ({
|
withStatics((s) => ({
|
||||||
ascending: (id?: string) => s.make(Identifier.ascending("part", id)),
|
ascending: (id?: string) => s.make(Identifier.ascending("part", id)),
|
||||||
|
|||||||
@@ -2,9 +2,10 @@ import { Schema } from "effect"
|
|||||||
import z from "zod"
|
import z from "zod"
|
||||||
|
|
||||||
import { Identifier } from "@/id/id"
|
import { Identifier } from "@/id/id"
|
||||||
|
import { ZodOverride } from "@/util/effect-zod"
|
||||||
import { withStatics } from "@/util/schema"
|
import { withStatics } from "@/util/schema"
|
||||||
|
|
||||||
export const EventID = Schema.String.pipe(
|
export const EventID = Schema.String.annotate({ [ZodOverride]: Identifier.schema("event") }).pipe(
|
||||||
Schema.brand("EventID"),
|
Schema.brand("EventID"),
|
||||||
withStatics((s) => ({
|
withStatics((s) => ({
|
||||||
ascending: (id?: string) => s.make(Identifier.ascending("event", id)),
|
ascending: (id?: string) => s.make(Identifier.ascending("event", id)),
|
||||||
|
|||||||
@@ -2,9 +2,10 @@ import { Schema } from "effect"
|
|||||||
import z from "zod"
|
import z from "zod"
|
||||||
|
|
||||||
import { Identifier } from "@/id/id"
|
import { Identifier } from "@/id/id"
|
||||||
|
import { ZodOverride } from "@/util/effect-zod"
|
||||||
import { withStatics } from "@/util/schema"
|
import { withStatics } from "@/util/schema"
|
||||||
|
|
||||||
const toolIdSchema = Schema.String.pipe(Schema.brand("ToolID"))
|
const toolIdSchema = Schema.String.annotate({ [ZodOverride]: Identifier.schema("tool") }).pipe(Schema.brand("ToolID"))
|
||||||
|
|
||||||
export type ToolID = typeof toolIdSchema.Type
|
export type ToolID = typeof toolIdSchema.Type
|
||||||
|
|
||||||
|
|||||||
@@ -1,11 +1,21 @@
|
|||||||
import { Schema, SchemaAST } from "effect"
|
import { Schema, SchemaAST } from "effect"
|
||||||
import z from "zod"
|
import z from "zod"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Annotation key for providing a hand-crafted Zod schema that the walker
|
||||||
|
* should use instead of re-deriving from the AST. Attach it via
|
||||||
|
* `Schema.String.annotate({ [ZodOverride]: z.string().startsWith("per") })`.
|
||||||
|
*/
|
||||||
|
export const ZodOverride: unique symbol = Symbol.for("effect-zod/override")
|
||||||
|
|
||||||
export function zod<S extends Schema.Top>(schema: S): z.ZodType<Schema.Schema.Type<S>> {
|
export function zod<S extends Schema.Top>(schema: S): z.ZodType<Schema.Schema.Type<S>> {
|
||||||
return walk(schema.ast) as z.ZodType<Schema.Schema.Type<S>>
|
return walk(schema.ast) as z.ZodType<Schema.Schema.Type<S>>
|
||||||
}
|
}
|
||||||
|
|
||||||
function walk(ast: SchemaAST.AST): z.ZodTypeAny {
|
function walk(ast: SchemaAST.AST): z.ZodTypeAny {
|
||||||
|
const override = (ast.annotations as any)?.[ZodOverride] as z.ZodTypeAny | undefined
|
||||||
|
if (override) return override
|
||||||
|
|
||||||
const out = body(ast)
|
const out = body(ast)
|
||||||
const desc = SchemaAST.resolveDescription(ast)
|
const desc = SchemaAST.resolveDescription(ast)
|
||||||
const ref = SchemaAST.resolveIdentifier(ast)
|
const ref = SchemaAST.resolveIdentifier(ast)
|
||||||
@@ -57,6 +67,12 @@ function opt(ast: SchemaAST.AST): z.ZodTypeAny {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function union(ast: SchemaAST.Union): z.ZodTypeAny {
|
function union(ast: SchemaAST.Union): z.ZodTypeAny {
|
||||||
|
// When every member is a string literal, emit z.enum() so that
|
||||||
|
// JSON Schema produces { "enum": [...] } instead of { "anyOf": [{ "const": ... }] }.
|
||||||
|
if (ast.types.length >= 2 && ast.types.every((t) => t._tag === "Literal" && typeof t.literal === "string")) {
|
||||||
|
return z.enum(ast.types.map((t) => (t as SchemaAST.Literal).literal as string) as [string, ...string[]])
|
||||||
|
}
|
||||||
|
|
||||||
const items = ast.types.map(walk)
|
const items = ast.types.map(walk)
|
||||||
if (items.length === 1) return items[0]
|
if (items.length === 1) return items[0]
|
||||||
if (items.length < 2) return fail(ast)
|
if (items.length < 2) return fail(ast)
|
||||||
|
|||||||
@@ -1,78 +0,0 @@
|
|||||||
import { afterEach, describe, expect, test } from "bun:test"
|
|
||||||
import { AppRuntime } from "../../src/effect/app-runtime"
|
|
||||||
import { Instance } from "../../src/project/instance"
|
|
||||||
import { Question } from "../../src/question"
|
|
||||||
import { Server } from "../../src/server/server"
|
|
||||||
import { SessionID } from "../../src/session/schema"
|
|
||||||
import { Log } from "../../src/util/log"
|
|
||||||
import { tmpdir } from "../fixture/fixture"
|
|
||||||
|
|
||||||
Log.init({ print: false })
|
|
||||||
|
|
||||||
const ask = (input: { sessionID: SessionID; questions: ReadonlyArray<Question.Info> }) =>
|
|
||||||
AppRuntime.runPromise(Question.Service.use((svc) => svc.ask(input)))
|
|
||||||
|
|
||||||
afterEach(async () => {
|
|
||||||
await Instance.disposeAll()
|
|
||||||
})
|
|
||||||
|
|
||||||
describe("experimental question httpapi", () => {
|
|
||||||
test("lists pending questions, replies, and serves docs", async () => {
|
|
||||||
await using tmp = await tmpdir({ git: true })
|
|
||||||
const app = Server.Default().app
|
|
||||||
const headers = {
|
|
||||||
"content-type": "application/json",
|
|
||||||
"x-opencode-directory": tmp.path,
|
|
||||||
}
|
|
||||||
const questions: ReadonlyArray<Question.Info> = [
|
|
||||||
{
|
|
||||||
question: "What would you like to do?",
|
|
||||||
header: "Action",
|
|
||||||
options: [
|
|
||||||
{ label: "Option 1", description: "First option" },
|
|
||||||
{ label: "Option 2", description: "Second option" },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
]
|
|
||||||
|
|
||||||
let pending!: ReturnType<typeof ask>
|
|
||||||
|
|
||||||
await Instance.provide({
|
|
||||||
directory: tmp.path,
|
|
||||||
fn: async () => {
|
|
||||||
pending = ask({
|
|
||||||
sessionID: SessionID.make("ses_test"),
|
|
||||||
questions,
|
|
||||||
})
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
const list = await app.request("/experimental/httpapi/question", {
|
|
||||||
headers,
|
|
||||||
})
|
|
||||||
|
|
||||||
expect(list.status).toBe(200)
|
|
||||||
const items = await list.json()
|
|
||||||
expect(items).toHaveLength(1)
|
|
||||||
expect(items[0]).toMatchObject({ questions })
|
|
||||||
|
|
||||||
const doc = await app.request("/experimental/httpapi/question/doc", {
|
|
||||||
headers,
|
|
||||||
})
|
|
||||||
|
|
||||||
expect(doc.status).toBe(200)
|
|
||||||
const spec = await doc.json()
|
|
||||||
expect(spec.paths["/experimental/httpapi/question"]?.get?.operationId).toBe("question.list")
|
|
||||||
expect(spec.paths["/experimental/httpapi/question/{requestID}/reply"]?.post?.operationId).toBe("question.reply")
|
|
||||||
|
|
||||||
const reply = await app.request(`/experimental/httpapi/question/${items[0].id}/reply`, {
|
|
||||||
method: "POST",
|
|
||||||
headers,
|
|
||||||
body: JSON.stringify({ answers: [["Option 1"]] }),
|
|
||||||
})
|
|
||||||
|
|
||||||
expect(reply.status).toBe(200)
|
|
||||||
expect(await reply.json()).toBe(true)
|
|
||||||
expect(await pending).toEqual([["Option 1"]])
|
|
||||||
})
|
|
||||||
})
|
|
||||||
@@ -1,7 +1,13 @@
|
|||||||
import { describe, expect, test } from "bun:test"
|
import { describe, expect, test } from "bun:test"
|
||||||
import { Schema } from "effect"
|
import { Schema } from "effect"
|
||||||
|
import z from "zod"
|
||||||
|
|
||||||
import { zod } from "../../src/util/effect-zod"
|
import { zod, ZodOverride } from "../../src/util/effect-zod"
|
||||||
|
|
||||||
|
function json(schema: z.ZodTypeAny) {
|
||||||
|
const { $schema: _, ...rest } = z.toJSONSchema(schema)
|
||||||
|
return rest
|
||||||
|
}
|
||||||
|
|
||||||
describe("util.effect-zod", () => {
|
describe("util.effect-zod", () => {
|
||||||
test("converts class schemas for route dto shapes", () => {
|
test("converts class schemas for route dto shapes", () => {
|
||||||
@@ -58,4 +64,126 @@ describe("util.effect-zod", () => {
|
|||||||
test("throws for unsupported tuple schemas", () => {
|
test("throws for unsupported tuple schemas", () => {
|
||||||
expect(() => zod(Schema.Tuple([Schema.String, Schema.Number]))).toThrow("unsupported effect schema")
|
expect(() => zod(Schema.Tuple([Schema.String, Schema.Number]))).toThrow("unsupported effect schema")
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test("string literal unions produce z.enum with enum in JSON Schema", () => {
|
||||||
|
const Action = Schema.Literals(["allow", "deny", "ask"])
|
||||||
|
const out = zod(Action)
|
||||||
|
|
||||||
|
expect(out.parse("allow")).toBe("allow")
|
||||||
|
expect(out.parse("deny")).toBe("deny")
|
||||||
|
expect(() => out.parse("nope")).toThrow()
|
||||||
|
|
||||||
|
// Matches native z.enum JSON Schema output
|
||||||
|
const bridged = json(out)
|
||||||
|
const native = json(z.enum(["allow", "deny", "ask"]))
|
||||||
|
expect(bridged).toEqual(native)
|
||||||
|
expect(bridged.enum).toEqual(["allow", "deny", "ask"])
|
||||||
|
})
|
||||||
|
|
||||||
|
test("ZodOverride annotation provides the Zod schema for branded IDs", () => {
|
||||||
|
const override = z.string().startsWith("per")
|
||||||
|
const ID = Schema.String.annotate({ [ZodOverride]: override }).pipe(Schema.brand("TestID"))
|
||||||
|
|
||||||
|
const Parent = Schema.Struct({ id: ID, name: Schema.String })
|
||||||
|
const out = zod(Parent)
|
||||||
|
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
expect((out as any).parse({ id: "per_abc", name: "test" })).toEqual({ id: "per_abc", name: "test" })
|
||||||
|
|
||||||
|
const schema = json(out) as any
|
||||||
|
expect(schema.properties.id).toEqual({ type: "string", pattern: "^per.*" })
|
||||||
|
})
|
||||||
|
|
||||||
|
test("Schema.Class nested in a parent preserves ref via identifier", () => {
|
||||||
|
class Inner extends Schema.Class<Inner>("MyInner")({
|
||||||
|
value: Schema.String,
|
||||||
|
}) {}
|
||||||
|
|
||||||
|
class Outer extends Schema.Class<Outer>("MyOuter")({
|
||||||
|
inner: Inner,
|
||||||
|
}) {}
|
||||||
|
|
||||||
|
const out = zod(Outer)
|
||||||
|
expect(out.meta()?.ref).toBe("MyOuter")
|
||||||
|
|
||||||
|
const shape = (out as any).shape ?? (out as any)._def?.shape?.()
|
||||||
|
expect(shape.inner.meta()?.ref).toBe("MyInner")
|
||||||
|
})
|
||||||
|
|
||||||
|
test("Schema.Class preserves identifier and uses enum format", () => {
|
||||||
|
class Rule extends Schema.Class<Rule>("PermissionRule")({
|
||||||
|
permission: Schema.String,
|
||||||
|
pattern: Schema.String,
|
||||||
|
action: Schema.Literals(["allow", "deny", "ask"]),
|
||||||
|
}) {}
|
||||||
|
|
||||||
|
const out = zod(Rule)
|
||||||
|
expect(out.meta()?.ref).toBe("PermissionRule")
|
||||||
|
|
||||||
|
const schema = json(out) as any
|
||||||
|
expect(schema.properties.action).toEqual({
|
||||||
|
type: "string",
|
||||||
|
enum: ["allow", "deny", "ask"],
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test("ZodOverride on ID carries pattern through Schema.Class", () => {
|
||||||
|
const ID = Schema.String.annotate({
|
||||||
|
[ZodOverride]: z.string().startsWith("per"),
|
||||||
|
})
|
||||||
|
|
||||||
|
class Request extends Schema.Class<Request>("TestRequest")({
|
||||||
|
id: ID,
|
||||||
|
name: Schema.String,
|
||||||
|
}) {}
|
||||||
|
|
||||||
|
const schema = json(zod(Request)) as any
|
||||||
|
expect(schema.properties.id).toEqual({ type: "string", pattern: "^per.*" })
|
||||||
|
expect(schema.properties.name).toEqual({ type: "string" })
|
||||||
|
})
|
||||||
|
|
||||||
|
test("Permission schemas match original Zod equivalents", () => {
|
||||||
|
const MsgID = Schema.String.annotate({ [ZodOverride]: z.string().startsWith("msg") })
|
||||||
|
const PerID = Schema.String.annotate({ [ZodOverride]: z.string().startsWith("per") })
|
||||||
|
const SesID = Schema.String.annotate({ [ZodOverride]: z.string().startsWith("ses") })
|
||||||
|
|
||||||
|
class Tool extends Schema.Class<Tool>("PermissionTool")({
|
||||||
|
messageID: MsgID,
|
||||||
|
callID: Schema.String,
|
||||||
|
}) {}
|
||||||
|
|
||||||
|
class Request extends Schema.Class<Request>("PermissionRequest")({
|
||||||
|
id: PerID,
|
||||||
|
sessionID: SesID,
|
||||||
|
permission: Schema.String,
|
||||||
|
patterns: Schema.Array(Schema.String),
|
||||||
|
metadata: Schema.Record(Schema.String, Schema.Unknown),
|
||||||
|
always: Schema.Array(Schema.String),
|
||||||
|
tool: Schema.optional(Tool),
|
||||||
|
}) {}
|
||||||
|
|
||||||
|
const bridged = json(zod(Request)) as any
|
||||||
|
expect(bridged.properties.id).toEqual({ type: "string", pattern: "^per.*" })
|
||||||
|
expect(bridged.properties.sessionID).toEqual({ type: "string", pattern: "^ses.*" })
|
||||||
|
expect(bridged.properties.permission).toEqual({ type: "string" })
|
||||||
|
expect(bridged.required?.sort()).toEqual(["id", "sessionID", "permission", "patterns", "metadata", "always"].sort())
|
||||||
|
|
||||||
|
// Tool field is present with the ref from Schema.Class identifier
|
||||||
|
const toolSchema = json(zod(Tool)) as any
|
||||||
|
expect(toolSchema.properties.messageID).toEqual({ type: "string", pattern: "^msg.*" })
|
||||||
|
expect(toolSchema.properties.callID).toEqual({ type: "string" })
|
||||||
|
})
|
||||||
|
|
||||||
|
test("ZodOverride survives Schema.brand", () => {
|
||||||
|
const override = z.string().startsWith("ses")
|
||||||
|
const ID = Schema.String.annotate({ [ZodOverride]: override }).pipe(Schema.brand("SessionID"))
|
||||||
|
|
||||||
|
// The branded schema's AST still has the override
|
||||||
|
class Parent extends Schema.Class<Parent>("Parent")({
|
||||||
|
sessionID: ID,
|
||||||
|
}) {}
|
||||||
|
|
||||||
|
const schema = json(zod(Parent)) as any
|
||||||
|
expect(schema.properties.sessionID).toEqual({ type: "string", pattern: "^ses.*" })
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user