Fix OpenAPI workspace query drift (#26609)

This commit is contained in:
Kit Langton 2026-05-09 21:12:34 -04:00 committed by GitHub
parent 7a79f3a5ea
commit e30d8173c1
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
26 changed files with 752 additions and 212 deletions

View file

@ -0,0 +1,204 @@
# OpenAPI Translation Cleanup Plan
## Goal
Trim `packages/opencode/src/server/routes/instance/httpapi/public.ts` until OpenAPI generation is mostly a direct projection of the `HttpApi` route declarations, without breaking the generated SDK surface.
The main failure mode to eliminate is spec-only behavior: anything that appears in `/doc` or the SDK but is not accepted by runtime `HttpApi` validation.
## Current Culprit
`public.ts` exports `PublicApi` with a large `OpenApi.annotations({ transform })` hook. That hook rewrites the generated spec for legacy SDK compatibility.
The highest-risk rewrite is `InstanceQueryParameters`, which injected `directory` and `workspace` into every instance route in OpenAPI even when the runtime query schema did not accept them. This caused the SDK and `/doc` to advertise calls that could fail with `400` at runtime.
## Non-Negotiables
- Do not break the generated JavaScript SDK without an explicit versioned migration plan.
- Runtime route schemas are the source of truth for accepted params, payloads, and responses.
- `/doc`, generated SDK types, and runtime validation must agree for every endpoint.
- Prefer endpoint or schema annotations over post-generation spec surgery.
- Remove one category of rewrite at a time, with focused compatibility checks.
## PR Checklist
Status legend: `[x]` done locally, `[~]` in progress locally, `[ ]` not started.
Current combined PR scope:
- `[x]` PR 1 drift tests: added OpenAPI/runtime query assertions and a negative fixture in `test/server/httpapi-query-schema-drift.test.ts`.
- `[x]` PR 2 injection removal: removed broad `directory` / `workspace` post-generation injection from `public.ts` and replaced it with explicit runtime query schemas on affected routes.
- `[ ]` PR 3+ cleanup: leave query override, path pattern, error shape, auth, and component-shape rewrites for later PRs.
### PR 1: Add OpenAPI/Runtime Query Drift Tests
- `[x]` Add or extend `packages/opencode/test/server/httpapi-query-schema-drift.test.ts`.
- `[x]` Import `OpenApi.fromApi` and `PublicApi`.
- `[x]` Generate the public spec in-process with `OpenApi.fromApi(PublicApi)`.
- `[x]` Add a route inventory for the existing runtime reproducers: `session`, `file`, `experimental`, and `instance` routes.
- `[x]` For each inventory entry, assert every OpenAPI query parameter is declared by the runtime query schema.
- `[x]` Add a negative regression fixture that fails on spec-only `directory` / `workspace` params.
- `[x]` Keep this part test-only.
Verification:
- `[x]` `bun test --timeout 5000 test/server/httpapi-query-schema-drift.test.ts` from `packages/opencode`.
- `[x]` `bun typecheck` from `packages/opencode`.
### PR 2: Delete Spec-Only Workspace Query Injection
- `[x]` Edit `packages/opencode/src/server/routes/instance/httpapi/public.ts`.
- `[x]` Delete `InstanceQueryParameters`.
- `[x]` Delete the `isInstanceRoute` constant.
- `[x]` Delete the branch that prepends `directory` and `workspace` to every instance operation.
- `[x]` Keep `normalizeParameter(param, route)` for parameters that are actually produced by `HttpApi`.
- `[x]` Add `WorkspaceRoutingQuery` / `WorkspaceRoutingQueryFields` to runtime query schemas for affected routes.
- `[x]` Regenerate SDK and inspect diff. Result: no `directory` / `workspace` request-param removals; generated SDK diff is declaration ordering only.
Notes:
- Added `WorkspaceRoutingQuery` in `middleware/workspace-routing.ts` as the canonical runtime schema for middleware-consumed query params.
- Replaced v2 union-query schemas with plain struct query schemas so `OpenApi.fromApi` emits their query params directly. This intentionally exposes the beta `/api/session` pagination/filter params in the SDK; cursor mutual-exclusion rules now live in the handlers, while `directory` / `workspace` remain allowed with cursors for routing.
Expected code shape:
```ts
for (const param of operation.parameters ?? []) normalizeParameter(param, `${method.toUpperCase()} ${path}`)
```
Verification:
- `[x]` `bun test --timeout 5000 test/server/httpapi-query-schema-drift.test.ts` from `packages/opencode`.
- `[x]` `bun dev generate > /tmp/opencode-openapi.json` from `packages/opencode`.
- `[x]` `./packages/sdk/js/script/build.ts` from repo root.
- `[x]` Inspect SDK diff for removed `directory` / `workspace` params. Result: none after explicit runtime schemas; v2 list/message now also expose their existing beta pagination/filter query params in the SDK.
- `[x]` `bun typecheck` from `packages/opencode`.
### PR 3: Replace Broad Query Type Override Sets With Route-Level Helpers
- Edit `packages/opencode/src/server/routes/instance/httpapi/public.ts`.
- Remove broad name-based assumptions from `QueryNumberParameters` and `QueryBooleanParameters` one field at a time.
- Add shared query schema helpers near route group code if needed, for example in `groups/metadata.ts` or a new `groups/query.ts`.
- Prefer route declarations like `Schema.NumberFromString.check(...)` and boolean string decoders like the existing `QueryBoolean` in `groups/session.ts`.
- Keep only route-specific `QueryParameterSchemas` entries when SDK compatibility requires a public encoded type that Effect OpenAPI cannot emit yet.
Concrete first targets:
- Replace `roots` / `archived` reliance on `QueryBooleanParameters` with explicit route schema helpers.
- Replace `start` / `cursor` / `limit` reliance on `QueryNumberParameters` with explicit route schema constraints where missing.
- Keep `GET /find/file limit`, `GET /session/{sessionID}/diff messageID`, and `GET /session/{sessionID}/message limit` overrides until their route schemas generate identical SDK types directly.
Verification:
- Focused HTTP tests for changed query fields.
- `bun dev generate > /tmp/opencode-openapi.json` from `packages/opencode`.
- `./packages/sdk/js/script/build.ts` from repo root.
- Inspect generated SDK request param types before deleting each override.
- `bun typecheck` from `packages/opencode`.
### PR 4: Move Path Parameter Patterns Into ID Schemas
- Audit `PathParameterSchemas` and `pathParameterSchema()` in `public.ts`.
- Check source schemas in files like `packages/opencode/src/session/schema.ts`, `packages/opencode/src/permission/schema.ts`, and pty schema definitions.
- Add or fix `ZodOverride` / OpenAPI-compatible annotations on branded ID schemas so generated path params include the same patterns without `public.ts` overrides.
- Delete one path override only after generated OpenAPI is unchanged for that param.
Concrete first targets:
- `sessionID`
- `messageID`
- `partID`
- `permissionID`
- `ptyID`
Leave ambiguous route-local `id` overrides for workspace routes until they are renamed or explicitly typed in endpoint params.
Verification:
- `bun dev generate > /tmp/opencode-openapi.json` from `packages/opencode`.
- `./packages/sdk/js/script/build.ts` from repo root.
- Inspect generated path param types and patterns.
- `bun typecheck` from `packages/opencode`.
### PR 5: Replace Built-In Error Rewrites With Declared API Errors
- Edit route group files under `packages/opencode/src/server/routes/instance/httpapi/groups/`.
- Replace SDK-visible `HttpApiError.BadRequest` / `HttpApiError.NotFound` with explicit error schemas from `packages/opencode/src/server/routes/instance/httpapi/errors.ts` or add new ones there.
- Update handlers to fail with the declared API errors at the boundary.
- Remove matching cases from `normalizeLegacyErrorResponses()` only after generated OpenAPI remains SDK-compatible.
- Do this group by group, starting with one small route group.
Concrete first targets:
- `groups/config.ts` `PATCH /config` bad request.
- `groups/session.ts` endpoints that already translate domain not-found errors.
- `groups/file.ts` if any handler currently relies on built-in error shape.
Verification:
- Focused HTTP tests asserting response body shape for changed error paths.
- `bun dev generate > /tmp/opencode-openapi.json` from `packages/opencode`.
- `./packages/sdk/js/script/build.ts` from repo root.
- Inspect SDK error union diff.
- `bun typecheck` from `packages/opencode`.
### PR 6: Remove Auth/Security Spec Rewrites If SDK Can Tolerate It
- Audit `delete operation.security`, `delete operation.responses?.["401"]`, and `delete spec.components?.securitySchemes` in `public.ts`.
- Decide whether SDK should expose auth in generated operation metadata.
- If preserving no-auth SDK surface is required, leave this rewrite and document it as intentional compatibility code.
- If removing it, update SDK generation expectations and docs in the same PR.
Verification:
- `./packages/sdk/js/script/build.ts` from repo root.
- Inspect generated client call signatures and error unions.
- Do not merge if auth churn changes normal SDK call ergonomics unintentionally.
### PR 7: Tackle Component Shape Rewrites One At A Time
- Audit these in `public.ts`: `normalizeComponentNames`, `collapseDuplicateComponents`, `applyLegacySchemaOverrides`, `normalizeComponentDescriptions`, `stripOptionalNull`, `fixSelfReferencingComponents`.
- For each rewrite, make a tiny PR that removes or narrows only that rewrite.
- If generated SDK type names churn broadly, stop and either keep the rewrite or fix `effect-smol` generation first.
Concrete first targets:
- Delete cosmetic `normalizeComponentDescriptions` if SDK output does not change materially.
- Narrow `applyLegacySchemaOverrides` entries that correspond to schemas already fixed at the source.
- Keep `stripOptionalNull` until there is an explicit SDK migration plan, because it likely affects many optional fields.
Verification:
- `bun dev generate > /tmp/opencode-openapi.json` from `packages/opencode`.
- `./packages/sdk/js/script/build.ts` from repo root.
- Inspect generated SDK type-name and optionality diffs.
## Upstream Middleware Query Support
Long-term, `WorkspaceRoutingMiddleware` should declare the query fields it reads once, and `HttpApi` should use that declaration for both runtime validation and OpenAPI generation.
Target in `effect-smol`:
- Extend `HttpApiMiddleware.Service` config with optional query schema support, or add a dedicated middleware query annotation.
- Make runtime request decoding include middleware query schemas.
- Make `OpenApi.fromApi` emit middleware query params for endpoints using that middleware.
Once available, remove `WorkspaceRoutingQueryFields` spreads from route groups and declare `directory` / `workspace` only on `WorkspaceRoutingMiddleware`.
## Suggested PR Order
1. Add drift detection tests only.
2. Remove `InstanceQueryParameters` spec injection; rely on `WorkspaceRoutingQueryFields` already present in runtime schemas.
3. Convert query type overrides into route/schema-level helpers where possible.
4. Convert path parameter overrides into schema annotations or upstream fixes.
5. Replace built-in error response rewrites with explicit declared API errors by route group.
6. Tackle component naming/nullability rewrites only after SDK compatibility snapshots are stable.
## Verification Checklist Per PR
- Focused HTTP tests for changed routes.
- OpenAPI drift tests.
- `bun dev generate > /tmp/opencode-openapi.json` from `packages/opencode`.
- `./packages/sdk/js/script/build.ts` from repo root.
- Inspect generated SDK diff for public API churn.
- `bun typecheck` from `packages/opencode`.

View file

@ -5,6 +5,7 @@ import * as Stream from "effect/Stream"
import { HttpServerResponse } from "effect/unstable/http"
import { HttpApi, HttpApiBuilder, HttpApiEndpoint, HttpApiGroup, HttpApiSchema, OpenApi } from "effect/unstable/httpapi"
import * as Sse from "effect/unstable/encoding/Sse"
import { WorkspaceRoutingQuery } from "./middleware/workspace-routing"
const log = Log.create({ service: "server" })
@ -16,6 +17,7 @@ export const EventApi = HttpApi.make("event").add(
HttpApiGroup.make("event")
.add(
HttpApiEndpoint.get("subscribe", EventPaths.event, {
query: WorkspaceRoutingQuery,
success: Schema.String.pipe(HttpApiSchema.asText({ contentType: "text/event-stream" })),
}).annotateMerge(
OpenApi.annotations({

View file

@ -3,7 +3,7 @@ import { Provider } from "@/provider/provider"
import { HttpApi, HttpApiEndpoint, HttpApiError, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
import { Authorization } from "../middleware/authorization"
import { InstanceContextMiddleware } from "../middleware/instance-context"
import { WorkspaceRoutingMiddleware } from "../middleware/workspace-routing"
import { WorkspaceRoutingMiddleware, WorkspaceRoutingQuery } from "../middleware/workspace-routing"
import { described } from "./metadata"
const root = "/config"
@ -13,6 +13,7 @@ export const ConfigApi = HttpApi.make("config")
HttpApiGroup.make("config")
.add(
HttpApiEndpoint.get("get", root, {
query: WorkspaceRoutingQuery,
success: described(Config.Info, "Get config info"),
}).annotateMerge(
OpenApi.annotations({
@ -22,6 +23,7 @@ export const ConfigApi = HttpApi.make("config")
}),
),
HttpApiEndpoint.patch("update", root, {
query: WorkspaceRoutingQuery,
payload: Config.Info,
success: described(Config.Info, "Successfully updated config"),
error: HttpApiError.BadRequest,
@ -33,6 +35,7 @@ export const ConfigApi = HttpApi.make("config")
}),
),
HttpApiEndpoint.get("providers", `${root}/providers`, {
query: WorkspaceRoutingQuery,
success: described(Provider.ConfigProvidersResult, "List of providers"),
}).annotateMerge(
OpenApi.annotations({

View file

@ -8,7 +8,11 @@ import { Schema, SchemaGetter } from "effect"
import { HttpApi, HttpApiEndpoint, HttpApiError, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
import { Authorization } from "../middleware/authorization"
import { InstanceContextMiddleware } from "../middleware/instance-context"
import { WorkspaceRoutingMiddleware, WorkspaceRoutingQueryFields } from "../middleware/workspace-routing"
import {
WorkspaceRoutingMiddleware,
WorkspaceRoutingQuery,
WorkspaceRoutingQueryFields,
} from "../middleware/workspace-routing"
import { described } from "./metadata"
const ConsoleStateResponse = Schema.Struct({
@ -82,6 +86,7 @@ export const ExperimentalApi = HttpApi.make("experimental")
HttpApiGroup.make("experimental")
.add(
HttpApiEndpoint.get("console", ExperimentalPaths.console, {
query: WorkspaceRoutingQuery,
success: described(ConsoleStateResponse, "Active Console provider metadata"),
error: HttpApiError.InternalServerError,
}).annotateMerge(
@ -92,6 +97,7 @@ export const ExperimentalApi = HttpApi.make("experimental")
}),
),
HttpApiEndpoint.get("consoleOrgs", ExperimentalPaths.consoleOrgs, {
query: WorkspaceRoutingQuery,
success: described(ConsoleOrgList, "Switchable Console orgs"),
error: HttpApiError.InternalServerError,
}).annotateMerge(
@ -102,6 +108,7 @@ export const ExperimentalApi = HttpApi.make("experimental")
}),
),
HttpApiEndpoint.post("consoleSwitch", ExperimentalPaths.consoleSwitch, {
query: WorkspaceRoutingQuery,
payload: ConsoleSwitchPayload,
success: described(Schema.Boolean, "Switch success"),
error: HttpApiError.BadRequest,
@ -125,6 +132,7 @@ export const ExperimentalApi = HttpApi.make("experimental")
}),
),
HttpApiEndpoint.get("toolIDs", ExperimentalPaths.toolIDs, {
query: WorkspaceRoutingQuery,
success: described(ToolIDs, "Tool IDs"),
error: HttpApiError.BadRequest,
}).annotateMerge(
@ -136,6 +144,7 @@ export const ExperimentalApi = HttpApi.make("experimental")
}),
),
HttpApiEndpoint.get("worktree", ExperimentalPaths.worktree, {
query: WorkspaceRoutingQuery,
success: described(WorktreeList, "List of worktree directories"),
}).annotateMerge(
OpenApi.annotations({
@ -145,6 +154,7 @@ export const ExperimentalApi = HttpApi.make("experimental")
}),
),
HttpApiEndpoint.post("worktreeCreate", ExperimentalPaths.worktree, {
query: WorkspaceRoutingQuery,
payload: Schema.optional(Worktree.CreateInput),
success: described(Worktree.Info, "Worktree created"),
error: HttpApiError.BadRequest,
@ -156,6 +166,7 @@ export const ExperimentalApi = HttpApi.make("experimental")
}),
),
HttpApiEndpoint.delete("worktreeRemove", ExperimentalPaths.worktree, {
query: WorkspaceRoutingQuery,
payload: Worktree.RemoveInput,
success: described(Schema.Boolean, "Worktree removed"),
error: HttpApiError.BadRequest,
@ -167,6 +178,7 @@ export const ExperimentalApi = HttpApi.make("experimental")
}),
),
HttpApiEndpoint.post("worktreeReset", ExperimentalPaths.worktreeReset, {
query: WorkspaceRoutingQuery,
payload: Worktree.ResetInput,
success: described(Schema.Boolean, "Worktree reset"),
error: HttpApiError.BadRequest,
@ -189,6 +201,7 @@ export const ExperimentalApi = HttpApi.make("experimental")
}),
),
HttpApiEndpoint.get("resource", ExperimentalPaths.resource, {
query: WorkspaceRoutingQuery,
success: described(Schema.Record(Schema.String, MCP.Resource), "MCP resources"),
}).annotateMerge(
OpenApi.annotations({

View file

@ -5,7 +5,11 @@ import { Schema } from "effect"
import { HttpApi, HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
import { Authorization } from "../middleware/authorization"
import { InstanceContextMiddleware } from "../middleware/instance-context"
import { WorkspaceRoutingMiddleware, WorkspaceRoutingQueryFields } from "../middleware/workspace-routing"
import {
WorkspaceRoutingMiddleware,
WorkspaceRoutingQuery,
WorkspaceRoutingQueryFields,
} from "../middleware/workspace-routing"
import { described } from "./metadata"
export const FileQuery = Schema.Struct({
@ -97,6 +101,7 @@ export const FileApi = HttpApi.make("file")
}),
),
HttpApiEndpoint.get("status", FilePaths.status, {
query: WorkspaceRoutingQuery,
success: described(Schema.Array(File.Info), "File status"),
}).annotateMerge(
OpenApi.annotations({

View file

@ -8,7 +8,11 @@ import { Schema } from "effect"
import { HttpApi, HttpApiEndpoint, HttpApiGroup, HttpApiSchema, OpenApi } from "effect/unstable/httpapi"
import { Authorization } from "../middleware/authorization"
import { InstanceContextMiddleware } from "../middleware/instance-context"
import { WorkspaceRoutingMiddleware, WorkspaceRoutingQueryFields } from "../middleware/workspace-routing"
import {
WorkspaceRoutingMiddleware,
WorkspaceRoutingQuery,
WorkspaceRoutingQueryFields,
} from "../middleware/workspace-routing"
import { described } from "./metadata"
const PathInfo = Schema.Struct({
@ -55,6 +59,7 @@ export const InstanceApi = HttpApi.make("instance")
HttpApiGroup.make("instance")
.add(
HttpApiEndpoint.post("dispose", InstancePaths.dispose, {
query: WorkspaceRoutingQuery,
success: described(Schema.Boolean, "Instance disposed"),
}).annotateMerge(
OpenApi.annotations({
@ -64,6 +69,7 @@ export const InstanceApi = HttpApi.make("instance")
}),
),
HttpApiEndpoint.get("path", InstancePaths.path, {
query: WorkspaceRoutingQuery,
success: PathInfo,
}).annotateMerge(
OpenApi.annotations({
@ -74,6 +80,7 @@ export const InstanceApi = HttpApi.make("instance")
}),
),
HttpApiEndpoint.get("vcs", InstancePaths.vcs, {
query: WorkspaceRoutingQuery,
success: described(Vcs.Info, "VCS info"),
}).annotateMerge(
OpenApi.annotations({
@ -84,6 +91,7 @@ export const InstanceApi = HttpApi.make("instance")
}),
),
HttpApiEndpoint.get("vcsStatus", InstancePaths.vcsStatus, {
query: WorkspaceRoutingQuery,
success: described(Schema.Array(Vcs.FileStatus), "VCS status"),
}).annotateMerge(
OpenApi.annotations({
@ -103,6 +111,7 @@ export const InstanceApi = HttpApi.make("instance")
}),
),
HttpApiEndpoint.get("vcsDiffRaw", InstancePaths.vcsDiffRaw, {
query: WorkspaceRoutingQuery,
success: described(
Schema.String.pipe(HttpApiSchema.asText({ contentType: "text/x-diff; charset=utf-8" })),
"Raw VCS diff",
@ -115,6 +124,7 @@ export const InstanceApi = HttpApi.make("instance")
}),
),
HttpApiEndpoint.post("vcsApply", InstancePaths.vcsApply, {
query: WorkspaceRoutingQuery,
payload: Vcs.ApplyInput,
success: described(Vcs.ApplyResult, "VCS patch applied"),
error: ApiVcsApplyError,
@ -126,6 +136,7 @@ export const InstanceApi = HttpApi.make("instance")
}),
),
HttpApiEndpoint.get("command", InstancePaths.command, {
query: WorkspaceRoutingQuery,
success: described(Schema.Array(Command.Info), "List of commands"),
}).annotateMerge(
OpenApi.annotations({
@ -135,6 +146,7 @@ export const InstanceApi = HttpApi.make("instance")
}),
),
HttpApiEndpoint.get("agent", InstancePaths.agent, {
query: WorkspaceRoutingQuery,
success: described(Schema.Array(Agent.Info), "List of agents"),
}).annotateMerge(
OpenApi.annotations({
@ -144,6 +156,7 @@ export const InstanceApi = HttpApi.make("instance")
}),
),
HttpApiEndpoint.get("skill", InstancePaths.skill, {
query: WorkspaceRoutingQuery,
success: described(Schema.Array(Skill.Info), "List of skills"),
}).annotateMerge(
OpenApi.annotations({
@ -153,6 +166,7 @@ export const InstanceApi = HttpApi.make("instance")
}),
),
HttpApiEndpoint.get("lsp", InstancePaths.lsp, {
query: WorkspaceRoutingQuery,
success: described(Schema.Array(LSP.Status), "LSP server status"),
}).annotateMerge(
OpenApi.annotations({
@ -162,6 +176,7 @@ export const InstanceApi = HttpApi.make("instance")
}),
),
HttpApiEndpoint.get("formatter", InstancePaths.formatter, {
query: WorkspaceRoutingQuery,
success: described(Schema.Array(Format.Status), "Formatter status"),
}).annotateMerge(
OpenApi.annotations({

View file

@ -4,7 +4,7 @@ import { Schema } from "effect"
import { HttpApi, HttpApiEndpoint, HttpApiError, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
import { Authorization } from "../middleware/authorization"
import { InstanceContextMiddleware } from "../middleware/instance-context"
import { WorkspaceRoutingMiddleware } from "../middleware/workspace-routing"
import { WorkspaceRoutingMiddleware, WorkspaceRoutingQuery } from "../middleware/workspace-routing"
import { described } from "./metadata"
export const AddPayload = Schema.Struct({
@ -42,6 +42,7 @@ export const McpApi = HttpApi.make("mcp")
HttpApiGroup.make("mcp")
.add(
HttpApiEndpoint.get("status", McpPaths.status, {
query: WorkspaceRoutingQuery,
success: described(Schema.Record(Schema.String, MCP.Status), "MCP server status"),
}).annotateMerge(
OpenApi.annotations({
@ -51,6 +52,7 @@ export const McpApi = HttpApi.make("mcp")
}),
),
HttpApiEndpoint.post("add", McpPaths.status, {
query: WorkspaceRoutingQuery,
payload: AddPayload,
success: described(StatusMap, "MCP server added successfully"),
error: HttpApiError.BadRequest,
@ -63,6 +65,7 @@ export const McpApi = HttpApi.make("mcp")
),
HttpApiEndpoint.post("authStart", McpPaths.auth, {
params: { name: Schema.String },
query: WorkspaceRoutingQuery,
success: described(AuthStartResponse, "OAuth flow started"),
error: [UnsupportedOAuthError, HttpApiError.NotFound],
}).annotateMerge(
@ -74,6 +77,7 @@ export const McpApi = HttpApi.make("mcp")
),
HttpApiEndpoint.post("authCallback", McpPaths.authCallback, {
params: { name: Schema.String },
query: WorkspaceRoutingQuery,
payload: AuthCallbackPayload,
success: described(MCP.Status, "OAuth authentication completed"),
error: [HttpApiError.BadRequest, HttpApiError.NotFound],
@ -87,6 +91,7 @@ export const McpApi = HttpApi.make("mcp")
),
HttpApiEndpoint.post("authAuthenticate", McpPaths.authAuthenticate, {
params: { name: Schema.String },
query: WorkspaceRoutingQuery,
success: described(MCP.Status, "OAuth authentication completed"),
error: [UnsupportedOAuthError, HttpApiError.NotFound],
}).annotateMerge(
@ -98,6 +103,7 @@ export const McpApi = HttpApi.make("mcp")
),
HttpApiEndpoint.delete("authRemove", McpPaths.auth, {
params: { name: Schema.String },
query: WorkspaceRoutingQuery,
success: described(AuthRemoveResponse, "OAuth credentials removed"),
error: HttpApiError.NotFound,
}).annotateMerge(
@ -109,6 +115,7 @@ export const McpApi = HttpApi.make("mcp")
),
HttpApiEndpoint.post("connect", McpPaths.connect, {
params: { name: Schema.String },
query: WorkspaceRoutingQuery,
success: described(Schema.Boolean, "MCP server connected successfully"),
}).annotateMerge(
OpenApi.annotations({
@ -118,6 +125,7 @@ export const McpApi = HttpApi.make("mcp")
),
HttpApiEndpoint.post("disconnect", McpPaths.disconnect, {
params: { name: Schema.String },
query: WorkspaceRoutingQuery,
success: described(Schema.Boolean, "MCP server disconnected successfully"),
}).annotateMerge(
OpenApi.annotations({

View file

@ -4,7 +4,7 @@ import { Schema } from "effect"
import { HttpApi, HttpApiEndpoint, HttpApiError, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
import { Authorization } from "../middleware/authorization"
import { InstanceContextMiddleware } from "../middleware/instance-context"
import { WorkspaceRoutingMiddleware } from "../middleware/workspace-routing"
import { WorkspaceRoutingMiddleware, WorkspaceRoutingQuery } from "../middleware/workspace-routing"
import { described } from "./metadata"
const root = "/permission"
@ -18,6 +18,7 @@ export const PermissionApi = HttpApi.make("permission")
HttpApiGroup.make("permission")
.add(
HttpApiEndpoint.get("list", root, {
query: WorkspaceRoutingQuery,
success: described(Schema.Array(Permission.Request), "List of pending permissions"),
}).annotateMerge(
OpenApi.annotations({
@ -28,6 +29,7 @@ export const PermissionApi = HttpApi.make("permission")
),
HttpApiEndpoint.post("reply", `${root}/:requestID/reply`, {
params: { requestID: PermissionID },
query: WorkspaceRoutingQuery,
payload: ReplyPayload,
success: described(Schema.Boolean, "Permission processed successfully"),
error: [HttpApiError.BadRequest, HttpApiError.NotFound],

View file

@ -4,7 +4,7 @@ import { Schema } from "effect"
import { HttpApi, HttpApiEndpoint, HttpApiError, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
import { Authorization } from "../middleware/authorization"
import { InstanceContextMiddleware } from "../middleware/instance-context"
import { WorkspaceRoutingMiddleware } from "../middleware/workspace-routing"
import { WorkspaceRoutingMiddleware, WorkspaceRoutingQuery } from "../middleware/workspace-routing"
import { described } from "./metadata"
const root = "/project"
@ -19,6 +19,7 @@ export const ProjectApi = HttpApi.make("project")
HttpApiGroup.make("project")
.add(
HttpApiEndpoint.get("list", root, {
query: WorkspaceRoutingQuery,
success: described(Schema.Array(Project.Info), "List of projects"),
}).annotateMerge(
OpenApi.annotations({
@ -28,6 +29,7 @@ export const ProjectApi = HttpApi.make("project")
}),
),
HttpApiEndpoint.get("current", `${root}/current`, {
query: WorkspaceRoutingQuery,
success: described(Project.Info, "Current project information"),
}).annotateMerge(
OpenApi.annotations({
@ -37,6 +39,7 @@ export const ProjectApi = HttpApi.make("project")
}),
),
HttpApiEndpoint.post("initGit", `${root}/git/init`, {
query: WorkspaceRoutingQuery,
success: described(Project.Info, "Project information after git initialization"),
}).annotateMerge(
OpenApi.annotations({
@ -47,6 +50,7 @@ export const ProjectApi = HttpApi.make("project")
),
HttpApiEndpoint.patch("update", `${root}/:projectID`, {
params: { projectID: ProjectID },
query: WorkspaceRoutingQuery,
payload: UpdatePayload,
success: described(Project.Info, "Updated project information"),
error: [HttpApiError.BadRequest, HttpApiError.NotFound],

View file

@ -5,7 +5,7 @@ import { Schema } from "effect"
import { HttpApi, HttpApiEndpoint, HttpApiError, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
import { Authorization } from "../middleware/authorization"
import { InstanceContextMiddleware } from "../middleware/instance-context"
import { WorkspaceRoutingMiddleware } from "../middleware/workspace-routing"
import { WorkspaceRoutingMiddleware, WorkspaceRoutingQuery } from "../middleware/workspace-routing"
import { described } from "./metadata"
const root = "/provider"
@ -15,6 +15,7 @@ export const ProviderApi = HttpApi.make("provider")
HttpApiGroup.make("provider")
.add(
HttpApiEndpoint.get("list", root, {
query: WorkspaceRoutingQuery,
success: described(Provider.ListResult, "List of providers"),
}).annotateMerge(
OpenApi.annotations({
@ -24,6 +25,7 @@ export const ProviderApi = HttpApi.make("provider")
}),
),
HttpApiEndpoint.get("auth", `${root}/auth`, {
query: WorkspaceRoutingQuery,
success: described(ProviderAuth.Methods, "Provider auth methods"),
}).annotateMerge(
OpenApi.annotations({
@ -34,6 +36,7 @@ export const ProviderApi = HttpApi.make("provider")
),
HttpApiEndpoint.post("authorize", `${root}/:providerID/oauth/authorize`, {
params: { providerID: ProviderID },
query: WorkspaceRoutingQuery,
payload: ProviderAuth.AuthorizeInput,
success: described(Schema.UndefinedOr(ProviderAuth.Authorization), "Authorization URL and method"),
error: HttpApiError.BadRequest,
@ -46,6 +49,7 @@ export const ProviderApi = HttpApi.make("provider")
),
HttpApiEndpoint.post("callback", `${root}/:providerID/oauth/callback`, {
params: { providerID: ProviderID },
query: WorkspaceRoutingQuery,
payload: ProviderAuth.CallbackInput,
success: described(Schema.Boolean, "OAuth callback processed successfully"),
error: HttpApiError.BadRequest,

View file

@ -5,7 +5,11 @@ import { Schema } from "effect"
import { HttpApi, HttpApiEndpoint, HttpApiError, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
import { Authorization } from "../middleware/authorization"
import { InstanceContextMiddleware } from "../middleware/instance-context"
import { WorkspaceRoutingMiddleware, WorkspaceRoutingQueryFields } from "../middleware/workspace-routing"
import {
WorkspaceRoutingMiddleware,
WorkspaceRoutingQuery,
WorkspaceRoutingQueryFields,
} from "../middleware/workspace-routing"
import { ApiNotFoundError } from "../errors"
import { described } from "./metadata"
@ -37,6 +41,7 @@ export const PtyApi = HttpApi.make("pty")
HttpApiGroup.make("pty")
.add(
HttpApiEndpoint.get("shells", PtyPaths.shells, {
query: WorkspaceRoutingQuery,
success: described(Schema.Array(ShellItem), "List of shells"),
}).annotateMerge(
OpenApi.annotations({
@ -46,6 +51,7 @@ export const PtyApi = HttpApi.make("pty")
}),
),
HttpApiEndpoint.get("list", PtyPaths.list, {
query: WorkspaceRoutingQuery,
success: described(Schema.Array(Pty.Info), "List of sessions"),
}).annotateMerge(
OpenApi.annotations({
@ -55,6 +61,7 @@ export const PtyApi = HttpApi.make("pty")
}),
),
HttpApiEndpoint.post("create", PtyPaths.create, {
query: WorkspaceRoutingQuery,
payload: Pty.CreateInput,
success: described(Pty.Info, "Created session"),
error: HttpApiError.BadRequest,
@ -67,6 +74,7 @@ export const PtyApi = HttpApi.make("pty")
),
HttpApiEndpoint.get("get", PtyPaths.get, {
params: { ptyID: PtyID },
query: WorkspaceRoutingQuery,
success: described(Pty.Info, "Session info"),
error: ApiNotFoundError,
}).annotateMerge(
@ -78,6 +86,7 @@ export const PtyApi = HttpApi.make("pty")
),
HttpApiEndpoint.put("update", PtyPaths.update, {
params: { ptyID: PtyID },
query: WorkspaceRoutingQuery,
payload: Pty.UpdateInput,
success: described(Pty.Info, "Updated session"),
error: [HttpApiError.BadRequest, ApiNotFoundError],
@ -90,6 +99,7 @@ export const PtyApi = HttpApi.make("pty")
),
HttpApiEndpoint.delete("remove", PtyPaths.remove, {
params: { ptyID: PtyID },
query: WorkspaceRoutingQuery,
success: described(Schema.Boolean, "Session removed"),
error: ApiNotFoundError,
}).annotateMerge(
@ -101,6 +111,7 @@ export const PtyApi = HttpApi.make("pty")
),
HttpApiEndpoint.post("connectToken", PtyPaths.connectToken, {
params: { ptyID: PtyID },
query: WorkspaceRoutingQuery,
success: described(PtyTicket.ConnectToken, "WebSocket connect token"),
error: [HttpApiError.Forbidden, ApiNotFoundError],
}).annotateMerge(
@ -129,6 +140,7 @@ export const PtyConnectApi = HttpApi.make("pty-connect").add(
.add(
HttpApiEndpoint.get("connect", PtyPaths.connect, {
params: Params,
query: WorkspaceRoutingQuery,
success: described(Schema.Boolean, "Connected session"),
error: [HttpApiError.Forbidden, HttpApiError.NotFound],
}).annotateMerge(

View file

@ -4,7 +4,7 @@ import { Schema } from "effect"
import { HttpApi, HttpApiEndpoint, HttpApiError, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
import { Authorization } from "../middleware/authorization"
import { InstanceContextMiddleware } from "../middleware/instance-context"
import { WorkspaceRoutingMiddleware } from "../middleware/workspace-routing"
import { WorkspaceRoutingMiddleware, WorkspaceRoutingQuery } from "../middleware/workspace-routing"
import { described } from "./metadata"
const root = "/question"
@ -19,6 +19,7 @@ export const QuestionApi = HttpApi.make("question")
HttpApiGroup.make("question")
.add(
HttpApiEndpoint.get("list", root, {
query: WorkspaceRoutingQuery,
success: described(Schema.Array(Question.Request), "List of pending questions"),
}).annotateMerge(
OpenApi.annotations({
@ -29,6 +30,7 @@ export const QuestionApi = HttpApi.make("question")
),
HttpApiEndpoint.post("reply", `${root}/:requestID/reply`, {
params: { requestID: QuestionID },
query: WorkspaceRoutingQuery,
payload: ReplyPayload,
success: described(Schema.Boolean, "Question answered successfully"),
error: [HttpApiError.BadRequest, HttpApiError.NotFound],
@ -41,6 +43,7 @@ export const QuestionApi = HttpApi.make("question")
),
HttpApiEndpoint.post("reject", `${root}/:requestID/reject`, {
params: { requestID: QuestionID },
query: WorkspaceRoutingQuery,
success: described(Schema.Boolean, "Question rejected successfully"),
error: [HttpApiError.BadRequest, HttpApiError.NotFound],
}).annotateMerge(

View file

@ -14,7 +14,11 @@ import { Schema, SchemaGetter, Struct } from "effect"
import { HttpApi, HttpApiEndpoint, HttpApiError, HttpApiGroup, HttpApiSchema, OpenApi } from "effect/unstable/httpapi"
import { Authorization } from "../middleware/authorization"
import { InstanceContextMiddleware } from "../middleware/instance-context"
import { WorkspaceRoutingMiddleware, WorkspaceRoutingQueryFields } from "../middleware/workspace-routing"
import {
WorkspaceRoutingMiddleware,
WorkspaceRoutingQuery,
WorkspaceRoutingQueryFields,
} from "../middleware/workspace-routing"
import { ApiNotFoundError } from "../errors"
import { described } from "./metadata"
@ -116,6 +120,7 @@ export const SessionApi = HttpApi.make("session")
}),
),
HttpApiEndpoint.get("status", SessionPaths.status, {
query: WorkspaceRoutingQuery,
success: described(StatusMap, "Get session status"),
error: HttpApiError.BadRequest,
}).annotateMerge(
@ -127,6 +132,7 @@ export const SessionApi = HttpApi.make("session")
),
HttpApiEndpoint.get("get", SessionPaths.get, {
params: { sessionID: SessionID },
query: WorkspaceRoutingQuery,
success: described(Session.Info, "Get session"),
error: [HttpApiError.BadRequest, ApiNotFoundError],
}).annotateMerge(
@ -138,6 +144,7 @@ export const SessionApi = HttpApi.make("session")
),
HttpApiEndpoint.get("children", SessionPaths.children, {
params: { sessionID: SessionID },
query: WorkspaceRoutingQuery,
success: described(Schema.Array(Session.Info), "List of children"),
error: [HttpApiError.BadRequest, HttpApiError.NotFound],
}).annotateMerge(
@ -149,6 +156,7 @@ export const SessionApi = HttpApi.make("session")
),
HttpApiEndpoint.get("todo", SessionPaths.todo, {
params: { sessionID: SessionID },
query: WorkspaceRoutingQuery,
success: described(Schema.Array(Todo.Info), "Todo list"),
error: [HttpApiError.BadRequest, HttpApiError.NotFound],
}).annotateMerge(
@ -183,6 +191,7 @@ export const SessionApi = HttpApi.make("session")
),
HttpApiEndpoint.get("message", SessionPaths.message, {
params: { sessionID: SessionID, messageID: MessageID },
query: WorkspaceRoutingQuery,
success: described(MessageV2.WithParts, "Message"),
error: [HttpApiError.BadRequest, ApiNotFoundError],
}).annotateMerge(
@ -193,6 +202,7 @@ export const SessionApi = HttpApi.make("session")
}),
),
HttpApiEndpoint.post("create", SessionPaths.create, {
query: WorkspaceRoutingQuery,
payload: [HttpApiSchema.NoContent, Session.CreateInput],
success: described(Session.Info, "Successfully created session"),
error: HttpApiError.BadRequest,
@ -205,6 +215,7 @@ export const SessionApi = HttpApi.make("session")
),
HttpApiEndpoint.delete("remove", SessionPaths.remove, {
params: { sessionID: SessionID },
query: WorkspaceRoutingQuery,
success: described(Schema.Boolean, "Successfully deleted session"),
error: [HttpApiError.BadRequest, ApiNotFoundError],
}).annotateMerge(
@ -216,6 +227,7 @@ export const SessionApi = HttpApi.make("session")
),
HttpApiEndpoint.patch("update", SessionPaths.update, {
params: { sessionID: SessionID },
query: WorkspaceRoutingQuery,
payload: UpdatePayload,
success: described(Session.Info, "Successfully updated session"),
error: [HttpApiError.BadRequest, ApiNotFoundError],
@ -228,6 +240,7 @@ export const SessionApi = HttpApi.make("session")
),
HttpApiEndpoint.post("fork", SessionPaths.fork, {
params: { sessionID: SessionID },
query: WorkspaceRoutingQuery,
payload: ForkPayload,
success: described(Session.Info, "200"),
error: ApiNotFoundError,
@ -240,6 +253,7 @@ export const SessionApi = HttpApi.make("session")
),
HttpApiEndpoint.post("abort", SessionPaths.abort, {
params: { sessionID: SessionID },
query: WorkspaceRoutingQuery,
success: described(Schema.Boolean, "Aborted session"),
error: [HttpApiError.BadRequest, HttpApiError.NotFound],
}).annotateMerge(
@ -251,6 +265,7 @@ export const SessionApi = HttpApi.make("session")
),
HttpApiEndpoint.post("init", SessionPaths.init, {
params: { sessionID: SessionID },
query: WorkspaceRoutingQuery,
payload: InitPayload,
success: described(Schema.Boolean, "200"),
error: [HttpApiError.BadRequest, HttpApiError.NotFound],
@ -264,6 +279,7 @@ export const SessionApi = HttpApi.make("session")
),
HttpApiEndpoint.post("share", SessionPaths.share, {
params: { sessionID: SessionID },
query: WorkspaceRoutingQuery,
success: described(Session.Info, "Successfully shared session"),
error: [HttpApiError.InternalServerError, ApiNotFoundError],
}).annotateMerge(
@ -275,6 +291,7 @@ export const SessionApi = HttpApi.make("session")
),
HttpApiEndpoint.delete("unshare", SessionPaths.share, {
params: { sessionID: SessionID },
query: WorkspaceRoutingQuery,
success: described(Session.Info, "Successfully unshared session"),
error: [HttpApiError.InternalServerError, ApiNotFoundError],
}).annotateMerge(
@ -286,6 +303,7 @@ export const SessionApi = HttpApi.make("session")
),
HttpApiEndpoint.post("summarize", SessionPaths.summarize, {
params: { sessionID: SessionID },
query: WorkspaceRoutingQuery,
payload: SummarizePayload,
success: described(Schema.Boolean, "Summarized session"),
error: [HttpApiError.BadRequest, ApiNotFoundError],
@ -298,6 +316,7 @@ export const SessionApi = HttpApi.make("session")
),
HttpApiEndpoint.post("prompt", SessionPaths.prompt, {
params: { sessionID: SessionID },
query: WorkspaceRoutingQuery,
payload: PromptPayload,
success: described(MessageV2.WithParts, "Created message"),
error: [HttpApiError.BadRequest, HttpApiError.NotFound],
@ -310,6 +329,7 @@ export const SessionApi = HttpApi.make("session")
),
HttpApiEndpoint.post("promptAsync", SessionPaths.promptAsync, {
params: { sessionID: SessionID },
query: WorkspaceRoutingQuery,
payload: PromptPayload,
success: described(HttpApiSchema.NoContent, "Prompt accepted"),
error: [HttpApiError.BadRequest, HttpApiError.NotFound],
@ -323,6 +343,7 @@ export const SessionApi = HttpApi.make("session")
),
HttpApiEndpoint.post("command", SessionPaths.command, {
params: { sessionID: SessionID },
query: WorkspaceRoutingQuery,
payload: CommandPayload,
success: described(MessageV2.WithParts, "Created message"),
error: [HttpApiError.BadRequest, HttpApiError.NotFound],
@ -335,6 +356,7 @@ export const SessionApi = HttpApi.make("session")
),
HttpApiEndpoint.post("shell", SessionPaths.shell, {
params: { sessionID: SessionID },
query: WorkspaceRoutingQuery,
payload: ShellPayload,
success: described(MessageV2.WithParts, "Created message"),
error: [HttpApiError.BadRequest, HttpApiError.NotFound],
@ -347,6 +369,7 @@ export const SessionApi = HttpApi.make("session")
),
HttpApiEndpoint.post("revert", SessionPaths.revert, {
params: { sessionID: SessionID },
query: WorkspaceRoutingQuery,
payload: RevertPayload,
success: described(Session.Info, "Updated session"),
error: [HttpApiError.BadRequest, HttpApiError.NotFound],
@ -360,6 +383,7 @@ export const SessionApi = HttpApi.make("session")
),
HttpApiEndpoint.post("unrevert", SessionPaths.unrevert, {
params: { sessionID: SessionID },
query: WorkspaceRoutingQuery,
success: described(Session.Info, "Updated session"),
error: [HttpApiError.BadRequest, HttpApiError.NotFound],
}).annotateMerge(
@ -371,6 +395,7 @@ export const SessionApi = HttpApi.make("session")
),
HttpApiEndpoint.post("permissionRespond", SessionPaths.permissions, {
params: { sessionID: SessionID, permissionID: PermissionID },
query: WorkspaceRoutingQuery,
payload: PermissionResponsePayload,
success: described(Schema.Boolean, "Permission processed successfully"),
error: [HttpApiError.BadRequest, HttpApiError.NotFound],
@ -384,6 +409,7 @@ export const SessionApi = HttpApi.make("session")
),
HttpApiEndpoint.delete("deleteMessage", SessionPaths.deleteMessage, {
params: { sessionID: SessionID, messageID: MessageID },
query: WorkspaceRoutingQuery,
success: described(Schema.Boolean, "Successfully deleted message"),
error: [HttpApiError.BadRequest, HttpApiError.NotFound],
}).annotateMerge(
@ -396,6 +422,7 @@ export const SessionApi = HttpApi.make("session")
),
HttpApiEndpoint.delete("deletePart", SessionPaths.deletePart, {
params: { sessionID: SessionID, messageID: MessageID, partID: PartID },
query: WorkspaceRoutingQuery,
success: described(Schema.Boolean, "Successfully deleted part"),
error: [HttpApiError.BadRequest, HttpApiError.NotFound],
}).annotateMerge(
@ -406,6 +433,7 @@ export const SessionApi = HttpApi.make("session")
),
HttpApiEndpoint.patch("updatePart", SessionPaths.updatePart, {
params: { sessionID: SessionID, messageID: MessageID, partID: PartID },
query: WorkspaceRoutingQuery,
payload: MessageV2.Part,
success: described(MessageV2.Part, "Successfully updated part"),
error: [HttpApiError.BadRequest, HttpApiError.NotFound],

View file

@ -4,7 +4,7 @@ import { Schema } from "effect"
import { HttpApi, HttpApiEndpoint, HttpApiError, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
import { Authorization } from "../middleware/authorization"
import { InstanceContextMiddleware } from "../middleware/instance-context"
import { WorkspaceRoutingMiddleware } from "../middleware/workspace-routing"
import { WorkspaceRoutingMiddleware, WorkspaceRoutingQuery } from "../middleware/workspace-routing"
import { described } from "./metadata"
const root = "/sync"
@ -46,6 +46,7 @@ export const SyncApi = HttpApi.make("sync")
HttpApiGroup.make("sync")
.add(
HttpApiEndpoint.post("start", SyncPaths.start, {
query: WorkspaceRoutingQuery,
success: described(Schema.Boolean, "Workspace sync started"),
}).annotateMerge(
OpenApi.annotations({
@ -55,6 +56,7 @@ export const SyncApi = HttpApi.make("sync")
}),
),
HttpApiEndpoint.post("replay", SyncPaths.replay, {
query: WorkspaceRoutingQuery,
payload: ReplayPayload,
success: described(ReplayResponse, "Replayed sync events"),
error: HttpApiError.BadRequest,
@ -66,6 +68,7 @@ export const SyncApi = HttpApi.make("sync")
}),
),
HttpApiEndpoint.post("steal", SyncPaths.steal, {
query: WorkspaceRoutingQuery,
payload: SessionPayload,
success: described(SessionPayload, "Session stolen into workspace"),
error: HttpApiError.BadRequest,
@ -77,6 +80,7 @@ export const SyncApi = HttpApi.make("sync")
}),
),
HttpApiEndpoint.post("history", SyncPaths.history, {
query: WorkspaceRoutingQuery,
payload: HistoryPayload,
success: described(Schema.Array(HistoryEvent), "Sync events"),
error: HttpApiError.BadRequest,

View file

@ -4,7 +4,7 @@ import { Schema } from "effect"
import { HttpApi, HttpApiEndpoint, HttpApiError, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
import { Authorization } from "../middleware/authorization"
import { InstanceContextMiddleware } from "../middleware/instance-context"
import { WorkspaceRoutingMiddleware } from "../middleware/workspace-routing"
import { WorkspaceRoutingMiddleware, WorkspaceRoutingQuery } from "../middleware/workspace-routing"
import { ApiNotFoundError } from "../errors"
import { described } from "./metadata"
@ -54,6 +54,7 @@ export const TuiApi = HttpApi.make("tui")
HttpApiGroup.make("tui")
.add(
HttpApiEndpoint.post("appendPrompt", TuiPaths.appendPrompt, {
query: WorkspaceRoutingQuery,
payload: TuiEvent.PromptAppend.properties,
success: described(Schema.Boolean, "Prompt processed successfully"),
error: HttpApiError.BadRequest,
@ -65,6 +66,7 @@ export const TuiApi = HttpApi.make("tui")
}),
),
HttpApiEndpoint.post("openHelp", TuiPaths.openHelp, {
query: WorkspaceRoutingQuery,
success: described(Schema.Boolean, "Help dialog opened successfully"),
}).annotateMerge(
OpenApi.annotations({
@ -74,6 +76,7 @@ export const TuiApi = HttpApi.make("tui")
}),
),
HttpApiEndpoint.post("openSessions", TuiPaths.openSessions, {
query: WorkspaceRoutingQuery,
success: described(Schema.Boolean, "Session dialog opened successfully"),
}).annotateMerge(
OpenApi.annotations({
@ -83,6 +86,7 @@ export const TuiApi = HttpApi.make("tui")
}),
),
HttpApiEndpoint.post("openThemes", TuiPaths.openThemes, {
query: WorkspaceRoutingQuery,
success: described(Schema.Boolean, "Theme dialog opened successfully"),
}).annotateMerge(
OpenApi.annotations({
@ -92,6 +96,7 @@ export const TuiApi = HttpApi.make("tui")
}),
),
HttpApiEndpoint.post("openModels", TuiPaths.openModels, {
query: WorkspaceRoutingQuery,
success: described(Schema.Boolean, "Model dialog opened successfully"),
}).annotateMerge(
OpenApi.annotations({
@ -101,6 +106,7 @@ export const TuiApi = HttpApi.make("tui")
}),
),
HttpApiEndpoint.post("submitPrompt", TuiPaths.submitPrompt, {
query: WorkspaceRoutingQuery,
success: described(Schema.Boolean, "Prompt submitted successfully"),
}).annotateMerge(
OpenApi.annotations({
@ -110,6 +116,7 @@ export const TuiApi = HttpApi.make("tui")
}),
),
HttpApiEndpoint.post("clearPrompt", TuiPaths.clearPrompt, {
query: WorkspaceRoutingQuery,
success: described(Schema.Boolean, "Prompt cleared successfully"),
}).annotateMerge(
OpenApi.annotations({
@ -119,6 +126,7 @@ export const TuiApi = HttpApi.make("tui")
}),
),
HttpApiEndpoint.post("executeCommand", TuiPaths.executeCommand, {
query: WorkspaceRoutingQuery,
payload: CommandPayload,
success: described(Schema.Boolean, "Command executed successfully"),
error: HttpApiError.BadRequest,
@ -130,6 +138,7 @@ export const TuiApi = HttpApi.make("tui")
}),
),
HttpApiEndpoint.post("showToast", TuiPaths.showToast, {
query: WorkspaceRoutingQuery,
payload: TuiEvent.ToastShow.properties,
success: described(Schema.Boolean, "Toast notification shown successfully"),
}).annotateMerge(
@ -140,6 +149,7 @@ export const TuiApi = HttpApi.make("tui")
}),
),
HttpApiEndpoint.post("publish", TuiPaths.publish, {
query: WorkspaceRoutingQuery,
payload: TuiPublishPayload,
success: described(Schema.Boolean, "Event published successfully"),
error: HttpApiError.BadRequest,
@ -151,6 +161,7 @@ export const TuiApi = HttpApi.make("tui")
}),
),
HttpApiEndpoint.post("selectSession", TuiPaths.selectSession, {
query: WorkspaceRoutingQuery,
payload: TuiEvent.SessionSelect.properties,
success: described(Schema.Boolean, "Session selected successfully"),
error: [HttpApiError.BadRequest, ApiNotFoundError],
@ -162,6 +173,7 @@ export const TuiApi = HttpApi.make("tui")
}),
),
HttpApiEndpoint.get("controlNext", TuiPaths.controlNext, {
query: WorkspaceRoutingQuery,
success: described(TuiRequestPayload, "Next TUI request"),
}).annotateMerge(
OpenApi.annotations({
@ -171,6 +183,7 @@ export const TuiApi = HttpApi.make("tui")
}),
),
HttpApiEndpoint.post("controlResponse", TuiPaths.controlResponse, {
query: WorkspaceRoutingQuery,
payload: Schema.Unknown,
success: described(Schema.Boolean, "Response submitted successfully"),
}).annotateMerge(

View file

@ -3,46 +3,31 @@ import { SessionMessage } from "@/v2/session-message"
import { Schema } from "effect"
import { HttpApiEndpoint, HttpApiError, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
import { Authorization } from "../../middleware/authorization"
import { WorkspaceRoutingQueryFields } from "../../middleware/workspace-routing"
export const MessagesQuery = Schema.Struct({
...WorkspaceRoutingQueryFields,
limit: Schema.optional(
Schema.NumberFromString.check(Schema.isInt(), Schema.isGreaterThanOrEqualTo(1), Schema.isLessThanOrEqualTo(200)),
).annotate({
description: "Maximum number of messages to return. When omitted, the endpoint returns its default page size.",
}),
order: Schema.optional(Schema.Union([Schema.Literal("asc"), Schema.Literal("desc")])).annotate({
description: "Message order for the first page. Use desc for newest first or asc for oldest first.",
}),
cursor: Schema.optional(
Schema.String.annotate({
description:
"Opaque pagination cursor returned as cursor.previous or cursor.next in the previous response. Do not combine with order.",
}),
),
}).annotate({ identifier: "V2SessionMessagesQuery" })
export const MessageGroup = HttpApiGroup.make("v2.message")
.add(
HttpApiEndpoint.get("messages", "/api/session/:sessionID/message", {
params: { sessionID: SessionID },
query: Schema.Union([
Schema.Struct({
limit: Schema.optional(
Schema.NumberFromString.check(
Schema.isInt(),
Schema.isGreaterThanOrEqualTo(1),
Schema.isLessThanOrEqualTo(200),
),
).annotate({
description:
"Maximum number of messages to return. When omitted, the endpoint returns its default page size.",
}),
order: Schema.optional(Schema.Union([Schema.Literal("asc"), Schema.Literal("desc")])).annotate({
description: "Message order for the first page. Use desc for newest first or asc for oldest first.",
}),
cursor: Schema.optional(Schema.Never),
}),
Schema.Struct({
limit: Schema.optional(
Schema.NumberFromString.check(
Schema.isInt(),
Schema.isGreaterThanOrEqualTo(1),
Schema.isLessThanOrEqualTo(200),
),
).annotate({
description:
"Maximum number of messages to return. When omitted, the endpoint returns its default page size.",
}),
cursor: Schema.String.annotate({
description:
"Opaque pagination cursor returned as cursor.previous or cursor.next in the previous response. Do not combine with order.",
}),
order: Schema.optional(Schema.Never),
}),
]).annotate({ identifier: "V2SessionMessagesQuery" }),
query: MessagesQuery,
success: Schema.Struct({
items: Schema.Array(SessionMessage.Message),
cursor: Schema.Struct({

View file

@ -1,4 +1,3 @@
import { WorkspaceID } from "@/control-plane/schema"
import { SessionID } from "@/session/schema"
import { SessionMessage } from "@/v2/session-message"
import { Prompt } from "@/v2/session-prompt"
@ -6,62 +5,41 @@ import { SessionV2 } from "@/v2/session"
import { Schema, SchemaGetter } from "effect"
import { HttpApiEndpoint, HttpApiError, HttpApiGroup, HttpApiSchema, OpenApi } from "effect/unstable/httpapi"
import { Authorization } from "../../middleware/authorization"
import { WorkspaceRoutingQuery, WorkspaceRoutingQueryFields } from "../../middleware/workspace-routing"
const QueryBoolean = Schema.Literals(["true", "false"]).pipe(
Schema.decodeTo(Schema.Boolean, {
decode: SchemaGetter.transform((value) => value === "true"),
encode: SchemaGetter.transform((value) => (value ? "true" : "false")),
}),
)
export const SessionsQuery = Schema.Struct({
...WorkspaceRoutingQueryFields,
limit: Schema.optional(
Schema.NumberFromString.check(Schema.isInt(), Schema.isGreaterThanOrEqualTo(1), Schema.isLessThanOrEqualTo(200)),
).annotate({
description: "Maximum number of sessions to return. Defaults to the newest 50 sessions.",
}),
order: Schema.optional(Schema.Union([Schema.Literal("asc"), Schema.Literal("desc")])).annotate({
description: "Session order for the first page. Use desc for newest first or asc for oldest first.",
}),
path: Schema.optional(Schema.String),
roots: Schema.optional(QueryBoolean),
start: Schema.optional(Schema.NumberFromString),
search: Schema.optional(Schema.String),
cursor: Schema.optional(
Schema.String.annotate({
description:
"Opaque pagination cursor returned as cursor.previous or cursor.next in the previous response. Do not combine with order or filters.",
}),
),
}).annotate({ identifier: "V2SessionsQuery" })
export const SessionGroup = HttpApiGroup.make("v2.session")
.add(
HttpApiEndpoint.get("sessions", "/api/session", {
query: Schema.Union([
Schema.Struct({
limit: Schema.optional(
Schema.NumberFromString.check(
Schema.isInt(),
Schema.isGreaterThanOrEqualTo(1),
Schema.isLessThanOrEqualTo(200),
),
).annotate({
description: "Maximum number of sessions to return. Defaults to the newest 50 sessions.",
}),
order: Schema.optional(Schema.Union([Schema.Literal("asc"), Schema.Literal("desc")])).annotate({
description: "Session order for the first page. Use desc for newest first or asc for oldest first.",
}),
directory: Schema.String.pipe(Schema.optional),
path: Schema.String.pipe(Schema.optional),
workspace: WorkspaceID.pipe(Schema.optional),
roots: Schema.Literals(["true", "false"])
.pipe(
Schema.decodeTo(Schema.Boolean, {
decode: SchemaGetter.transform((value) => value === "true"),
encode: SchemaGetter.transform((value) => (value ? "true" : "false")),
}),
)
.pipe(Schema.optional),
start: Schema.NumberFromString.pipe(Schema.optional),
search: Schema.String.pipe(Schema.optional),
cursor: Schema.optional(Schema.Never),
}),
Schema.Struct({
limit: Schema.optional(
Schema.NumberFromString.check(
Schema.isInt(),
Schema.isGreaterThanOrEqualTo(1),
Schema.isLessThanOrEqualTo(200),
),
).annotate({
description: "Maximum number of sessions to return. Defaults to the newest 50 sessions.",
}),
cursor: Schema.String.annotate({
description:
"Opaque pagination cursor returned as cursor.previous or cursor.next in the previous response. Do not combine with order.",
}),
order: Schema.optional(Schema.Never),
directory: Schema.optional(Schema.Never),
path: Schema.optional(Schema.Never),
workspace: Schema.optional(Schema.Never),
roots: Schema.optional(Schema.Never),
start: Schema.optional(Schema.Never),
search: Schema.optional(Schema.Never),
}),
]).annotate({ identifier: "V2SessionsQuery" }),
query: SessionsQuery,
success: Schema.Struct({
items: Schema.Array(SessionV2.Info),
cursor: Schema.Struct({
@ -82,6 +60,7 @@ export const SessionGroup = HttpApiGroup.make("v2.session")
.add(
HttpApiEndpoint.post("prompt", "/api/session/:sessionID/prompt", {
params: { sessionID: SessionID },
query: WorkspaceRoutingQuery,
payload: Schema.Struct({
prompt: Prompt,
delivery: SessionV2.Delivery.pipe(Schema.optional),
@ -98,6 +77,7 @@ export const SessionGroup = HttpApiGroup.make("v2.session")
.add(
HttpApiEndpoint.post("compact", "/api/session/:sessionID/compact", {
params: { sessionID: SessionID },
query: WorkspaceRoutingQuery,
success: HttpApiSchema.NoContent,
}).annotateMerge(
OpenApi.annotations({
@ -110,6 +90,7 @@ export const SessionGroup = HttpApiGroup.make("v2.session")
.add(
HttpApiEndpoint.post("wait", "/api/session/:sessionID/wait", {
params: { sessionID: SessionID },
query: WorkspaceRoutingQuery,
success: HttpApiSchema.NoContent,
}).annotateMerge(
OpenApi.annotations({
@ -122,6 +103,7 @@ export const SessionGroup = HttpApiGroup.make("v2.session")
.add(
HttpApiEndpoint.get("context", "/api/session/:sessionID/context", {
params: { sessionID: SessionID },
query: WorkspaceRoutingQuery,
success: Schema.Array(SessionMessage.Message),
}).annotateMerge(
OpenApi.annotations({

View file

@ -5,7 +5,7 @@ import { HttpApi, HttpApiEndpoint, HttpApiError, HttpApiGroup, HttpApiSchema, Op
import { ApiVcsApplyError } from "./instance"
import { Authorization } from "../middleware/authorization"
import { InstanceContextMiddleware } from "../middleware/instance-context"
import { WorkspaceRoutingMiddleware } from "../middleware/workspace-routing"
import { WorkspaceRoutingMiddleware, WorkspaceRoutingQuery } from "../middleware/workspace-routing"
import { described } from "./metadata"
const root = "/experimental/workspace"
@ -40,6 +40,7 @@ export const WorkspaceApi = HttpApi.make("workspace")
HttpApiGroup.make("workspace")
.add(
HttpApiEndpoint.get("adapters", WorkspacePaths.adapters, {
query: WorkspaceRoutingQuery,
success: described(Schema.Array(WorkspaceAdapterEntry), "Workspace adapters"),
}).annotateMerge(
OpenApi.annotations({
@ -49,6 +50,7 @@ export const WorkspaceApi = HttpApi.make("workspace")
}),
),
HttpApiEndpoint.get("list", WorkspacePaths.list, {
query: WorkspaceRoutingQuery,
success: described(Schema.Array(Workspace.Info), "Workspaces"),
}).annotateMerge(
OpenApi.annotations({
@ -58,6 +60,7 @@ export const WorkspaceApi = HttpApi.make("workspace")
}),
),
HttpApiEndpoint.post("create", WorkspacePaths.list, {
query: WorkspaceRoutingQuery,
payload: CreatePayload,
success: described(Workspace.Info, "Workspace created"),
error: HttpApiError.BadRequest,
@ -69,6 +72,7 @@ export const WorkspaceApi = HttpApi.make("workspace")
}),
),
HttpApiEndpoint.post("syncList", WorkspacePaths.syncList, {
query: WorkspaceRoutingQuery,
success: described(HttpApiSchema.NoContent, "Workspace list synced"),
}).annotateMerge(
OpenApi.annotations({
@ -78,6 +82,7 @@ export const WorkspaceApi = HttpApi.make("workspace")
}),
),
HttpApiEndpoint.get("status", WorkspacePaths.status, {
query: WorkspaceRoutingQuery,
success: described(Schema.Array(Workspace.ConnectionStatus), "Workspace status"),
}).annotateMerge(
OpenApi.annotations({
@ -88,6 +93,7 @@ export const WorkspaceApi = HttpApi.make("workspace")
),
HttpApiEndpoint.delete("remove", WorkspacePaths.remove, {
params: { id: Workspace.Info.fields.id },
query: WorkspaceRoutingQuery,
success: described(Schema.UndefinedOr(Workspace.Info), "Workspace removed"),
error: HttpApiError.BadRequest,
}).annotateMerge(
@ -98,6 +104,7 @@ export const WorkspaceApi = HttpApi.make("workspace")
}),
),
HttpApiEndpoint.post("warp", WorkspacePaths.warp, {
query: WorkspaceRoutingQuery,
payload: WarpPayload,
success: described(HttpApiSchema.NoContent, "Session warped"),
error: [ApiWorkspaceWarpError, ApiVcsApplyError],

View file

@ -34,6 +34,7 @@ export const messageHandlers = HttpApiBuilder.group(InstanceHttpApi, "v2.message
return handlers.handle(
"messages",
Effect.fn(function* (ctx) {
if (ctx.query.cursor && ctx.query.order !== undefined) return yield* new HttpApiError.BadRequest({})
const decoded = yield* Effect.try({
try: () => (ctx.query.cursor ? cursor.decode(ctx.query.cursor) : undefined),
catch: () => new HttpApiError.BadRequest({}),

View file

@ -22,6 +22,31 @@ type SessionCursor = typeof SessionCursor.Type
const decodeCursor = Schema.decodeUnknownSync(SessionCursor)
function hasCursorFilter(query: {
readonly order?: unknown
readonly path?: unknown
readonly roots?: unknown
readonly start?: unknown
readonly search?: unknown
}) {
return (
query.order !== undefined ||
query.path !== undefined ||
query.roots !== undefined ||
query.start !== undefined ||
query.search !== undefined
)
}
function hasCursorRoutingMismatch(
query: { readonly directory?: string; readonly workspace?: string },
decoded: SessionCursor | undefined,
) {
if (!decoded) return false
if (query.directory !== undefined && query.directory !== decoded.directory) return true
return query.workspace !== undefined && query.workspace !== decoded.workspaceID
}
const sessionCursor = {
encode(
session: SessionV2.Info,
@ -46,10 +71,12 @@ export const sessionHandlers = HttpApiBuilder.group(InstanceHttpApi, "v2.session
.handle(
"sessions",
Effect.fn(function* (ctx) {
if (ctx.query.cursor && hasCursorFilter(ctx.query)) return yield* new HttpApiError.BadRequest({})
const decoded = yield* Effect.try({
try: () => (ctx.query.cursor ? sessionCursor.decode(ctx.query.cursor) : undefined),
catch: () => new HttpApiError.BadRequest({}),
})
if (hasCursorRoutingMismatch(ctx.query, decoded)) return yield* new HttpApiError.BadRequest({})
const order = decoded?.order ?? ctx.query.order ?? "desc"
const filters = decoded ?? {
directory: ctx.query.directory,

View file

@ -24,6 +24,8 @@ export const WorkspaceRoutingQueryFields = {
workspace: Schema.optional(Schema.String),
}
export const WorkspaceRoutingQuery = Schema.Struct(WorkspaceRoutingQueryFields)
type RemoteTarget = Extract<Target, { type: "remote" }>
type RequestPlan = Data.TaggedEnum<{

View file

@ -51,30 +51,14 @@ type OpenApiResponse = {
content?: Record<string, { schema?: OpenApiSchema }>
}
// Instance routes use middleware for directory/workspace resolution, but HttpApi
// doesn't surface middleware query params in the spec. Inject them explicitly.
const InstanceQueryParameters = [
{
name: "directory",
in: "query",
required: false,
schema: { type: "string" },
},
{
name: "workspace",
in: "query",
required: false,
schema: { type: "string" },
},
] satisfies OpenApiParameter[]
// Query schemas describe decoded Effect values, but the generated SDK needs the
// public call shape. These keep SDK callers passing numbers/booleans while the
// server still decodes string query params at runtime.
const QueryNumberParameters = new Set(["start", "cursor", "limit", "method"])
const QueryNumberParameters = new Set(["start", "limit", "method"])
const QueryBooleanParameters = new Set(["roots", "archived"])
const QueryParameterSchemas = {
"GET /find/file limit": { type: "integer", minimum: 1, maximum: 200 },
"GET /experimental/session cursor": { type: "number" },
"GET /session/{sessionID}/diff messageID": { type: "string", pattern: "^msg.*" },
"GET /session/{sessionID}/message limit": { type: "integer", minimum: 0, maximum: Number.MAX_SAFE_INTEGER },
} satisfies Record<string, OpenApiSchema>
@ -122,7 +106,6 @@ function matchLegacyOpenApi(input: Record<string, unknown>) {
delete spec.components?.securitySchemes
for (const [path, item] of Object.entries(spec.paths ?? {})) {
const isInstanceRoute = !path.startsWith("/global/") && !path.startsWith("/auth/")
for (const method of ["get", "post", "put", "delete", "patch"] as const) {
const operation = item[method]
if (!operation) continue
@ -183,14 +166,8 @@ function matchLegacyOpenApi(input: Record<string, unknown>) {
},
}
}
if (!isInstanceRoute) continue
operation.parameters = [
...InstanceQueryParameters,
...(operation.parameters ?? []).filter(
(param) => param.in !== "query" || (param.name !== "directory" && param.name !== "workspace"),
),
]
for (const param of operation.parameters) normalizeParameter(param, `${method.toUpperCase()} ${path}`)
const route = `${method.toUpperCase()} ${path}`
for (const param of operation.parameters ?? []) normalizeParameter(param, route)
}
}
return input

View file

@ -38,6 +38,10 @@ import { type Scenario } from "./types"
void (await import("@opencode-ai/core/util/log")).init({ print: false })
function cursor(input: Record<string, unknown>) {
return Buffer.from(JSON.stringify(input)).toString("base64url")
}
const scenarios: Scenario[] = [
http.protected
.get("/global/health", "global.health")
@ -598,6 +602,64 @@ const scenarios: Scenario[] = [
},
"none",
),
http.protected
.get("/api/session", "v2.session.list.filters")
.at((ctx) => ({
path: `/api/session?${new URLSearchParams({
limit: "2",
order: "asc",
path: ".",
roots: "false",
start: "0",
search: "missing",
directory: ctx.directory ?? "",
})}`,
headers: ctx.headers(),
}))
.json(
200,
(body) => {
object(body)
array(body.items)
object(body.cursor)
},
"none",
),
http.protected
.get("/api/session", "v2.session.list.cursor")
.at((ctx) => ({
path: `/api/session?${new URLSearchParams({
limit: "2",
directory: ctx.directory ?? "",
cursor: cursor({
id: "ses_httpapi_missing",
time: 0,
order: "desc",
direction: "next",
directory: ctx.directory,
}),
})}`,
headers: ctx.headers(),
}))
.json(
200,
(body) => {
object(body)
array(body.items)
object(body.cursor)
},
"none",
),
http.protected
.get("/api/session", "v2.session.list.cursor.invalid")
.at((ctx) => ({
path: `/api/session?${new URLSearchParams({
cursor: cursor({ id: "ses_httpapi_missing", time: 0, order: "desc", direction: "next" }),
search: "not-allowed-with-cursor",
})}`,
headers: ctx.headers(),
}))
.status(400, undefined, "none"),
http.protected
.get("/api/session/{sessionID}/context", "v2.session.context")
.at((ctx) => ({
@ -620,6 +682,53 @@ const scenarios: Scenario[] = [
},
"none",
),
http.protected
.get("/api/session/{sessionID}/message", "v2.session.messages.params")
.at((ctx) => ({
path: `${route("/api/session/{sessionID}/message", { sessionID: "ses_httpapi_missing" })}?${new URLSearchParams({
limit: "2",
order: "asc",
})}`,
headers: ctx.headers(),
}))
.json(
200,
(body) => {
object(body)
array(body.items)
object(body.cursor)
},
"none",
),
http.protected
.get("/api/session/{sessionID}/message", "v2.session.messages.cursor")
.at((ctx) => ({
path: `${route("/api/session/{sessionID}/message", { sessionID: "ses_httpapi_missing" })}?${new URLSearchParams({
limit: "2",
directory: ctx.directory ?? "",
cursor: cursor({ id: "msg_httpapi_missing", time: 0, order: "desc", direction: "next" }),
})}`,
headers: ctx.headers(),
}))
.json(
200,
(body) => {
object(body)
array(body.items)
object(body.cursor)
},
"none",
),
http.protected
.get("/api/session/{sessionID}/message", "v2.session.messages.cursor.invalid")
.at((ctx) => ({
path: `${route("/api/session/{sessionID}/message", { sessionID: "ses_httpapi_missing" })}?${new URLSearchParams({
cursor: cursor({ id: "msg_httpapi_missing", time: 0, order: "desc", direction: "next" }),
order: "asc",
})}`,
headers: ctx.headers(),
}))
.status(400, undefined, "none"),
http.protected
.post("/api/session/{sessionID}/prompt", "v2.session.prompt.invalid")
.at((ctx) => ({

View file

@ -1,14 +1,53 @@
import { afterEach, describe, expect } from "bun:test"
import { Effect } from "effect"
import { OpenApi } from "effect/unstable/httpapi"
import { Flag } from "@opencode-ai/core/flag/flag"
import { Server } from "../../src/server/server"
import { SessionID } from "../../src/session/schema"
import { PublicApi } from "../../src/server/routes/instance/httpapi/public"
import {
FilePaths,
FileQuery,
FindFileQuery,
FindTextQuery,
} from "../../src/server/routes/instance/httpapi/groups/file"
import {
ExperimentalPaths,
SessionListQuery as ExperimentalSessionListQuery,
ToolListQuery,
} from "../../src/server/routes/instance/httpapi/groups/experimental"
import { InstancePaths, VcsDiffQuery } from "../../src/server/routes/instance/httpapi/groups/instance"
import {
ListQuery as SessionListQuery,
MessagesQuery,
SessionPaths,
} from "../../src/server/routes/instance/httpapi/groups/session"
import { MessagesQuery as V2MessagesQuery } from "../../src/server/routes/instance/httpapi/groups/v2/message"
import { SessionsQuery as V2SessionsQuery } from "../../src/server/routes/instance/httpapi/groups/v2/session"
import { resetDatabase } from "../fixture/db"
import { disposeAllInstances, tmpdir } from "../fixture/fixture"
import { it } from "../lib/effect"
const originalWorkspaces = Flag.OPENCODE_EXPERIMENTAL_WORKSPACES
type Method = "get" | "post" | "put" | "delete" | "patch"
type QuerySchema = { readonly fields: Record<string, unknown> }
type OpenApiParameter = { readonly name: string; readonly in: string }
type OpenApiOperation = { readonly parameters?: readonly OpenApiParameter[] }
const openApiDriftRoutes = [
{ method: "get", path: SessionPaths.list, query: SessionListQuery },
{ method: "get", path: SessionPaths.messages, query: MessagesQuery },
{ method: "get", path: FilePaths.findFile, query: FindFileQuery },
{ method: "get", path: FilePaths.findText, query: FindTextQuery },
{ method: "get", path: FilePaths.list, query: FileQuery },
{ method: "get", path: ExperimentalPaths.session, query: ExperimentalSessionListQuery },
{ method: "get", path: ExperimentalPaths.tool, query: ToolListQuery },
{ method: "get", path: InstancePaths.vcsDiff, query: VcsDiffQuery },
{ method: "get", path: "/api/session", query: V2SessionsQuery },
{ method: "get", path: "/api/session/:sessionID/message", query: V2MessagesQuery },
] satisfies Array<{ method: Method; path: string; query: QuerySchema }>
function app() {
return Server.Default().app
}
@ -27,6 +66,29 @@ function withTmp<A, E, R>(
).pipe(Effect.flatMap(fn))
}
function openApiPath(path: string) {
return path.replace(/:([A-Za-z0-9_]+)/g, "{$1}")
}
function queryParameters(operation: OpenApiOperation | undefined) {
return (operation?.parameters ?? []).filter((param) => param.in === "query").map((param) => param.name)
}
function assertAdvertisedQueryParamsAreRuntimeFields(input: {
readonly method: Method
readonly operation: OpenApiOperation | undefined
readonly path: string
readonly query: QuerySchema
}) {
const runtimeFields = new Set(Object.keys(input.query.fields))
const advertisedOnly = queryParameters(input.operation).filter((name) => !runtimeFields.has(name))
expect(
advertisedOnly,
`${input.method.toUpperCase()} ${input.path} advertises query params not accepted by runtime schema`,
).toEqual([])
}
afterEach(async () => {
Flag.OPENCODE_EXPERIMENTAL_WORKSPACES = originalWorkspaces
await disposeAllInstances()
@ -44,6 +106,38 @@ describe("httpapi query schema drift", () => {
expect(status, `route ${url} 400'd, query schema is missing routing fields`).not.toBe(400)
}
it.effect(
"OpenAPI workspace query params are declared by runtime query schemas",
Effect.sync(() => {
const spec = OpenApi.fromApi(PublicApi)
for (const route of openApiDriftRoutes) {
assertAdvertisedQueryParamsAreRuntimeFields({
...route,
operation: spec.paths[openApiPath(route.path)]?.[route.method],
})
}
}),
)
it.effect(
"drift assertion catches spec-only workspace query params",
Effect.sync(() => {
expect(() =>
assertAdvertisedQueryParamsAreRuntimeFields({
method: "get",
operation: {
parameters: [
{ name: "directory", in: "query" },
{ name: "workspace", in: "query" },
],
},
path: "/fixture",
query: { fields: {} },
}),
).toThrow("advertises query params not accepted by runtime schema")
}),
)
it.live(
"session list accepts directory and workspace",
withTmp({ config: { formatter: false, lsp: false } }, (tmp) =>

View file

@ -4163,6 +4163,13 @@ export class Session3 extends HeyApiClient {
parameters?: {
directory?: string
workspace?: string
limit?: number
order?: "asc" | "desc"
path?: string
roots?: boolean | "true" | "false"
start?: number
search?: string
cursor?: string
},
options?: Options<never, ThrowOnError>,
) {
@ -4173,6 +4180,13 @@ export class Session3 extends HeyApiClient {
args: [
{ in: "query", key: "directory" },
{ in: "query", key: "workspace" },
{ in: "query", key: "limit" },
{ in: "query", key: "order" },
{ in: "query", key: "path" },
{ in: "query", key: "roots" },
{ in: "query", key: "start" },
{ in: "query", key: "search" },
{ in: "query", key: "cursor" },
],
},
],
@ -4331,6 +4345,9 @@ export class Session3 extends HeyApiClient {
sessionID: string
directory?: string
workspace?: string
limit?: number
order?: "asc" | "desc"
cursor?: string
},
options?: Options<never, ThrowOnError>,
) {
@ -4342,6 +4359,9 @@ export class Session3 extends HeyApiClient {
{ in: "path", key: "sessionID" },
{ in: "query", key: "directory" },
{ in: "query", key: "workspace" },
{ in: "query", key: "limit" },
{ in: "query", key: "order" },
{ in: "query", key: "cursor" },
],
},
],

View file

@ -5,6 +5,12 @@ export type ClientOptions = {
}
export type Event =
| EventTuiPromptAppend
| EventTuiCommandExecute
| EventTuiToastShow1
| EventTuiSessionSelect
| EventServerConnected
| EventGlobalDisposed
| EventServerInstanceDisposed
| EventFileEdited
| EventFileWatcherUpdated
@ -24,10 +30,6 @@ export type Event =
| EventSessionStatus
| EventSessionIdle
| EventSessionCompacted
| EventTuiPromptAppend
| EventTuiCommandExecute
| EventTuiToastShow1
| EventTuiSessionSelect
| EventMcpToolsChanged
| EventMcpBrowserOpenFailed
| EventCommandExecuted
@ -75,8 +77,6 @@ export type Event =
| EventSessionNextCompactionStarted
| EventSessionNextCompactionDelta
| EventSessionNextCompactionEnded
| EventServerConnected
| EventGlobalDisposed
export type OAuth = {
type: "oauth"
@ -103,6 +103,61 @@ export type WellKnownAuth = {
export type Auth = OAuth | ApiAuth | WellKnownAuth
export type EventTuiPromptAppend = {
id: string
type: "tui.prompt.append"
properties: {
text: string
}
}
export type EventTuiCommandExecute = {
id: string
type: "tui.command.execute"
properties: {
command:
| "session.list"
| "session.new"
| "session.share"
| "session.interrupt"
| "session.compact"
| "session.page.up"
| "session.page.down"
| "session.line.up"
| "session.line.down"
| "session.half.page.up"
| "session.half.page.down"
| "session.first"
| "session.last"
| "prompt.clear"
| "prompt.submit"
| "agent.cycle"
| string
}
}
export type EventTuiToastShow = {
id: string
type: "tui.toast.show"
properties: {
title?: string
message: string
variant: "info" | "success" | "warning" | "error"
duration?: number
}
}
export type EventTuiSessionSelect = {
id: string
type: "tui.session.select"
properties: {
/**
* Session ID to navigate to
*/
sessionID: string
}
}
export type PermissionRequest = {
id: string
sessionID: string
@ -280,61 +335,6 @@ export type SessionStatus =
type: "busy"
}
export type EventTuiPromptAppend = {
id: string
type: "tui.prompt.append"
properties: {
text: string
}
}
export type EventTuiCommandExecute = {
id: string
type: "tui.command.execute"
properties: {
command:
| "session.list"
| "session.new"
| "session.share"
| "session.interrupt"
| "session.compact"
| "session.page.up"
| "session.page.down"
| "session.line.up"
| "session.line.down"
| "session.half.page.up"
| "session.half.page.down"
| "session.first"
| "session.last"
| "prompt.clear"
| "prompt.submit"
| "agent.cycle"
| string
}
}
export type EventTuiToastShow = {
id: string
type: "tui.toast.show"
properties: {
title?: string
message: string
variant: "info" | "success" | "warning" | "error"
duration?: number
}
}
export type EventTuiSessionSelect = {
id: string
type: "tui.session.select"
properties: {
/**
* Session ID to navigate to
*/
sessionID: string
}
}
export type Project = {
id: string
worktree: string
@ -778,6 +778,12 @@ export type GlobalEvent = {
project?: string
workspace?: string
payload:
| EventTuiPromptAppend
| EventTuiCommandExecute
| EventTuiToastShow
| EventTuiSessionSelect
| EventServerConnected
| EventGlobalDisposed
| EventServerInstanceDisposed
| EventFileEdited
| EventFileWatcherUpdated
@ -797,10 +803,6 @@ export type GlobalEvent = {
| EventSessionStatus
| EventSessionIdle
| EventSessionCompacted
| EventTuiPromptAppend
| EventTuiCommandExecute
| EventTuiToastShow
| EventTuiSessionSelect
| EventMcpToolsChanged
| EventMcpBrowserOpenFailed
| EventCommandExecuted
@ -848,8 +850,6 @@ export type GlobalEvent = {
| EventSessionNextCompactionStarted
| EventSessionNextCompactionDelta
| EventSessionNextCompactionEnded
| EventServerConnected
| EventGlobalDisposed
| SyncEventMessageUpdated
| SyncEventMessageRemoved
| SyncEventMessagePartUpdated
@ -2318,6 +2318,22 @@ export type SyncEventSessionNextCompactionEnded = {
}
}
export type EventServerConnected = {
id: string
type: "server.connected"
properties: {
[key: string]: unknown
}
}
export type EventGlobalDisposed = {
id: string
type: "global.disposed"
properties: {
[key: string]: unknown
}
}
export type EventServerInstanceDisposed = {
id: string
type: "server.instance.disposed"
@ -3032,22 +3048,6 @@ export type EventSessionNextCompactionEnded = {
}
}
export type EventServerConnected = {
id: string
type: "server.connected"
properties: {
[key: string]: unknown
}
}
export type EventGlobalDisposed = {
id: string
type: "global.disposed"
properties: {
[key: string]: unknown
}
}
export type SessionInfo = {
id: string
parentID?: string
@ -6235,6 +6235,16 @@ export type V2SessionListData = {
query?: {
directory?: string
workspace?: string
limit?: number
order?: "asc" | "desc"
path?: string
roots?: boolean | "true" | "false"
start?: number
search?: string
/**
* Opaque pagination cursor returned as cursor.previous or cursor.next in the previous response. Do not combine with order or filters.
*/
cursor?: string
}
url: "/api/session"
}
@ -6352,6 +6362,12 @@ export type V2SessionMessagesData = {
query?: {
directory?: string
workspace?: string
limit?: number
order?: "asc" | "desc"
/**
* Opaque pagination cursor returned as cursor.previous or cursor.next in the previous response. Do not combine with order.
*/
cursor?: string
}
url: "/api/session/{sessionID}/message"
}