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
26 changed files with 752 additions and 212 deletions
@@ -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`.
@@ -5,6 +5,7 @@ import * as Stream from "effect/Stream"
import { HttpServerResponse } from "effect/unstable/http" import { HttpServerResponse } from "effect/unstable/http"
import { HttpApi, HttpApiBuilder, HttpApiEndpoint, HttpApiGroup, HttpApiSchema, OpenApi } from "effect/unstable/httpapi" import { HttpApi, HttpApiBuilder, HttpApiEndpoint, HttpApiGroup, HttpApiSchema, OpenApi } from "effect/unstable/httpapi"
import * as Sse from "effect/unstable/encoding/Sse" import * as Sse from "effect/unstable/encoding/Sse"
import { WorkspaceRoutingQuery } from "./middleware/workspace-routing"
const log = Log.create({ service: "server" }) const log = Log.create({ service: "server" })
@@ -16,6 +17,7 @@ export const EventApi = HttpApi.make("event").add(
HttpApiGroup.make("event") HttpApiGroup.make("event")
.add( .add(
HttpApiEndpoint.get("subscribe", EventPaths.event, { HttpApiEndpoint.get("subscribe", EventPaths.event, {
query: WorkspaceRoutingQuery,
success: Schema.String.pipe(HttpApiSchema.asText({ contentType: "text/event-stream" })), success: Schema.String.pipe(HttpApiSchema.asText({ contentType: "text/event-stream" })),
}).annotateMerge( }).annotateMerge(
OpenApi.annotations({ OpenApi.annotations({
@@ -3,7 +3,7 @@ import { Provider } from "@/provider/provider"
import { HttpApi, HttpApiEndpoint, HttpApiError, HttpApiGroup, OpenApi } from "effect/unstable/httpapi" import { HttpApi, HttpApiEndpoint, HttpApiError, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
import { Authorization } from "../middleware/authorization" import { Authorization } from "../middleware/authorization"
import { InstanceContextMiddleware } from "../middleware/instance-context" import { InstanceContextMiddleware } from "../middleware/instance-context"
import { WorkspaceRoutingMiddleware } from "../middleware/workspace-routing" import { WorkspaceRoutingMiddleware, WorkspaceRoutingQuery } from "../middleware/workspace-routing"
import { described } from "./metadata" import { described } from "./metadata"
const root = "/config" const root = "/config"
@@ -13,6 +13,7 @@ export const ConfigApi = HttpApi.make("config")
HttpApiGroup.make("config") HttpApiGroup.make("config")
.add( .add(
HttpApiEndpoint.get("get", root, { HttpApiEndpoint.get("get", root, {
query: WorkspaceRoutingQuery,
success: described(Config.Info, "Get config info"), success: described(Config.Info, "Get config info"),
}).annotateMerge( }).annotateMerge(
OpenApi.annotations({ OpenApi.annotations({
@@ -22,6 +23,7 @@ export const ConfigApi = HttpApi.make("config")
}), }),
), ),
HttpApiEndpoint.patch("update", root, { HttpApiEndpoint.patch("update", root, {
query: WorkspaceRoutingQuery,
payload: Config.Info, payload: Config.Info,
success: described(Config.Info, "Successfully updated config"), success: described(Config.Info, "Successfully updated config"),
error: HttpApiError.BadRequest, error: HttpApiError.BadRequest,
@@ -33,6 +35,7 @@ export const ConfigApi = HttpApi.make("config")
}), }),
), ),
HttpApiEndpoint.get("providers", `${root}/providers`, { HttpApiEndpoint.get("providers", `${root}/providers`, {
query: WorkspaceRoutingQuery,
success: described(Provider.ConfigProvidersResult, "List of providers"), success: described(Provider.ConfigProvidersResult, "List of providers"),
}).annotateMerge( }).annotateMerge(
OpenApi.annotations({ OpenApi.annotations({
@@ -8,7 +8,11 @@ import { Schema, SchemaGetter } from "effect"
import { HttpApi, HttpApiEndpoint, HttpApiError, HttpApiGroup, OpenApi } from "effect/unstable/httpapi" import { HttpApi, HttpApiEndpoint, HttpApiError, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
import { Authorization } from "../middleware/authorization" import { Authorization } from "../middleware/authorization"
import { InstanceContextMiddleware } from "../middleware/instance-context" 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" import { described } from "./metadata"
const ConsoleStateResponse = Schema.Struct({ const ConsoleStateResponse = Schema.Struct({
@@ -82,6 +86,7 @@ export const ExperimentalApi = HttpApi.make("experimental")
HttpApiGroup.make("experimental") HttpApiGroup.make("experimental")
.add( .add(
HttpApiEndpoint.get("console", ExperimentalPaths.console, { HttpApiEndpoint.get("console", ExperimentalPaths.console, {
query: WorkspaceRoutingQuery,
success: described(ConsoleStateResponse, "Active Console provider metadata"), success: described(ConsoleStateResponse, "Active Console provider metadata"),
error: HttpApiError.InternalServerError, error: HttpApiError.InternalServerError,
}).annotateMerge( }).annotateMerge(
@@ -92,6 +97,7 @@ export const ExperimentalApi = HttpApi.make("experimental")
}), }),
), ),
HttpApiEndpoint.get("consoleOrgs", ExperimentalPaths.consoleOrgs, { HttpApiEndpoint.get("consoleOrgs", ExperimentalPaths.consoleOrgs, {
query: WorkspaceRoutingQuery,
success: described(ConsoleOrgList, "Switchable Console orgs"), success: described(ConsoleOrgList, "Switchable Console orgs"),
error: HttpApiError.InternalServerError, error: HttpApiError.InternalServerError,
}).annotateMerge( }).annotateMerge(
@@ -102,6 +108,7 @@ export const ExperimentalApi = HttpApi.make("experimental")
}), }),
), ),
HttpApiEndpoint.post("consoleSwitch", ExperimentalPaths.consoleSwitch, { HttpApiEndpoint.post("consoleSwitch", ExperimentalPaths.consoleSwitch, {
query: WorkspaceRoutingQuery,
payload: ConsoleSwitchPayload, payload: ConsoleSwitchPayload,
success: described(Schema.Boolean, "Switch success"), success: described(Schema.Boolean, "Switch success"),
error: HttpApiError.BadRequest, error: HttpApiError.BadRequest,
@@ -125,6 +132,7 @@ export const ExperimentalApi = HttpApi.make("experimental")
}), }),
), ),
HttpApiEndpoint.get("toolIDs", ExperimentalPaths.toolIDs, { HttpApiEndpoint.get("toolIDs", ExperimentalPaths.toolIDs, {
query: WorkspaceRoutingQuery,
success: described(ToolIDs, "Tool IDs"), success: described(ToolIDs, "Tool IDs"),
error: HttpApiError.BadRequest, error: HttpApiError.BadRequest,
}).annotateMerge( }).annotateMerge(
@@ -136,6 +144,7 @@ export const ExperimentalApi = HttpApi.make("experimental")
}), }),
), ),
HttpApiEndpoint.get("worktree", ExperimentalPaths.worktree, { HttpApiEndpoint.get("worktree", ExperimentalPaths.worktree, {
query: WorkspaceRoutingQuery,
success: described(WorktreeList, "List of worktree directories"), success: described(WorktreeList, "List of worktree directories"),
}).annotateMerge( }).annotateMerge(
OpenApi.annotations({ OpenApi.annotations({
@@ -145,6 +154,7 @@ export const ExperimentalApi = HttpApi.make("experimental")
}), }),
), ),
HttpApiEndpoint.post("worktreeCreate", ExperimentalPaths.worktree, { HttpApiEndpoint.post("worktreeCreate", ExperimentalPaths.worktree, {
query: WorkspaceRoutingQuery,
payload: Schema.optional(Worktree.CreateInput), payload: Schema.optional(Worktree.CreateInput),
success: described(Worktree.Info, "Worktree created"), success: described(Worktree.Info, "Worktree created"),
error: HttpApiError.BadRequest, error: HttpApiError.BadRequest,
@@ -156,6 +166,7 @@ export const ExperimentalApi = HttpApi.make("experimental")
}), }),
), ),
HttpApiEndpoint.delete("worktreeRemove", ExperimentalPaths.worktree, { HttpApiEndpoint.delete("worktreeRemove", ExperimentalPaths.worktree, {
query: WorkspaceRoutingQuery,
payload: Worktree.RemoveInput, payload: Worktree.RemoveInput,
success: described(Schema.Boolean, "Worktree removed"), success: described(Schema.Boolean, "Worktree removed"),
error: HttpApiError.BadRequest, error: HttpApiError.BadRequest,
@@ -167,6 +178,7 @@ export const ExperimentalApi = HttpApi.make("experimental")
}), }),
), ),
HttpApiEndpoint.post("worktreeReset", ExperimentalPaths.worktreeReset, { HttpApiEndpoint.post("worktreeReset", ExperimentalPaths.worktreeReset, {
query: WorkspaceRoutingQuery,
payload: Worktree.ResetInput, payload: Worktree.ResetInput,
success: described(Schema.Boolean, "Worktree reset"), success: described(Schema.Boolean, "Worktree reset"),
error: HttpApiError.BadRequest, error: HttpApiError.BadRequest,
@@ -189,6 +201,7 @@ export const ExperimentalApi = HttpApi.make("experimental")
}), }),
), ),
HttpApiEndpoint.get("resource", ExperimentalPaths.resource, { HttpApiEndpoint.get("resource", ExperimentalPaths.resource, {
query: WorkspaceRoutingQuery,
success: described(Schema.Record(Schema.String, MCP.Resource), "MCP resources"), success: described(Schema.Record(Schema.String, MCP.Resource), "MCP resources"),
}).annotateMerge( }).annotateMerge(
OpenApi.annotations({ OpenApi.annotations({
@@ -5,7 +5,11 @@ import { Schema } from "effect"
import { HttpApi, HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi" import { HttpApi, HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
import { Authorization } from "../middleware/authorization" import { Authorization } from "../middleware/authorization"
import { InstanceContextMiddleware } from "../middleware/instance-context" 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" import { described } from "./metadata"
export const FileQuery = Schema.Struct({ export const FileQuery = Schema.Struct({
@@ -97,6 +101,7 @@ export const FileApi = HttpApi.make("file")
}), }),
), ),
HttpApiEndpoint.get("status", FilePaths.status, { HttpApiEndpoint.get("status", FilePaths.status, {
query: WorkspaceRoutingQuery,
success: described(Schema.Array(File.Info), "File status"), success: described(Schema.Array(File.Info), "File status"),
}).annotateMerge( }).annotateMerge(
OpenApi.annotations({ OpenApi.annotations({
@@ -8,7 +8,11 @@ import { Schema } from "effect"
import { HttpApi, HttpApiEndpoint, HttpApiGroup, HttpApiSchema, OpenApi } from "effect/unstable/httpapi" import { HttpApi, HttpApiEndpoint, HttpApiGroup, HttpApiSchema, OpenApi } from "effect/unstable/httpapi"
import { Authorization } from "../middleware/authorization" import { Authorization } from "../middleware/authorization"
import { InstanceContextMiddleware } from "../middleware/instance-context" 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" import { described } from "./metadata"
const PathInfo = Schema.Struct({ const PathInfo = Schema.Struct({
@@ -55,6 +59,7 @@ export const InstanceApi = HttpApi.make("instance")
HttpApiGroup.make("instance") HttpApiGroup.make("instance")
.add( .add(
HttpApiEndpoint.post("dispose", InstancePaths.dispose, { HttpApiEndpoint.post("dispose", InstancePaths.dispose, {
query: WorkspaceRoutingQuery,
success: described(Schema.Boolean, "Instance disposed"), success: described(Schema.Boolean, "Instance disposed"),
}).annotateMerge( }).annotateMerge(
OpenApi.annotations({ OpenApi.annotations({
@@ -64,6 +69,7 @@ export const InstanceApi = HttpApi.make("instance")
}), }),
), ),
HttpApiEndpoint.get("path", InstancePaths.path, { HttpApiEndpoint.get("path", InstancePaths.path, {
query: WorkspaceRoutingQuery,
success: PathInfo, success: PathInfo,
}).annotateMerge( }).annotateMerge(
OpenApi.annotations({ OpenApi.annotations({
@@ -74,6 +80,7 @@ export const InstanceApi = HttpApi.make("instance")
}), }),
), ),
HttpApiEndpoint.get("vcs", InstancePaths.vcs, { HttpApiEndpoint.get("vcs", InstancePaths.vcs, {
query: WorkspaceRoutingQuery,
success: described(Vcs.Info, "VCS info"), success: described(Vcs.Info, "VCS info"),
}).annotateMerge( }).annotateMerge(
OpenApi.annotations({ OpenApi.annotations({
@@ -84,6 +91,7 @@ export const InstanceApi = HttpApi.make("instance")
}), }),
), ),
HttpApiEndpoint.get("vcsStatus", InstancePaths.vcsStatus, { HttpApiEndpoint.get("vcsStatus", InstancePaths.vcsStatus, {
query: WorkspaceRoutingQuery,
success: described(Schema.Array(Vcs.FileStatus), "VCS status"), success: described(Schema.Array(Vcs.FileStatus), "VCS status"),
}).annotateMerge( }).annotateMerge(
OpenApi.annotations({ OpenApi.annotations({
@@ -103,6 +111,7 @@ export const InstanceApi = HttpApi.make("instance")
}), }),
), ),
HttpApiEndpoint.get("vcsDiffRaw", InstancePaths.vcsDiffRaw, { HttpApiEndpoint.get("vcsDiffRaw", InstancePaths.vcsDiffRaw, {
query: WorkspaceRoutingQuery,
success: described( success: described(
Schema.String.pipe(HttpApiSchema.asText({ contentType: "text/x-diff; charset=utf-8" })), Schema.String.pipe(HttpApiSchema.asText({ contentType: "text/x-diff; charset=utf-8" })),
"Raw VCS diff", "Raw VCS diff",
@@ -115,6 +124,7 @@ export const InstanceApi = HttpApi.make("instance")
}), }),
), ),
HttpApiEndpoint.post("vcsApply", InstancePaths.vcsApply, { HttpApiEndpoint.post("vcsApply", InstancePaths.vcsApply, {
query: WorkspaceRoutingQuery,
payload: Vcs.ApplyInput, payload: Vcs.ApplyInput,
success: described(Vcs.ApplyResult, "VCS patch applied"), success: described(Vcs.ApplyResult, "VCS patch applied"),
error: ApiVcsApplyError, error: ApiVcsApplyError,
@@ -126,6 +136,7 @@ export const InstanceApi = HttpApi.make("instance")
}), }),
), ),
HttpApiEndpoint.get("command", InstancePaths.command, { HttpApiEndpoint.get("command", InstancePaths.command, {
query: WorkspaceRoutingQuery,
success: described(Schema.Array(Command.Info), "List of commands"), success: described(Schema.Array(Command.Info), "List of commands"),
}).annotateMerge( }).annotateMerge(
OpenApi.annotations({ OpenApi.annotations({
@@ -135,6 +146,7 @@ export const InstanceApi = HttpApi.make("instance")
}), }),
), ),
HttpApiEndpoint.get("agent", InstancePaths.agent, { HttpApiEndpoint.get("agent", InstancePaths.agent, {
query: WorkspaceRoutingQuery,
success: described(Schema.Array(Agent.Info), "List of agents"), success: described(Schema.Array(Agent.Info), "List of agents"),
}).annotateMerge( }).annotateMerge(
OpenApi.annotations({ OpenApi.annotations({
@@ -144,6 +156,7 @@ export const InstanceApi = HttpApi.make("instance")
}), }),
), ),
HttpApiEndpoint.get("skill", InstancePaths.skill, { HttpApiEndpoint.get("skill", InstancePaths.skill, {
query: WorkspaceRoutingQuery,
success: described(Schema.Array(Skill.Info), "List of skills"), success: described(Schema.Array(Skill.Info), "List of skills"),
}).annotateMerge( }).annotateMerge(
OpenApi.annotations({ OpenApi.annotations({
@@ -153,6 +166,7 @@ export const InstanceApi = HttpApi.make("instance")
}), }),
), ),
HttpApiEndpoint.get("lsp", InstancePaths.lsp, { HttpApiEndpoint.get("lsp", InstancePaths.lsp, {
query: WorkspaceRoutingQuery,
success: described(Schema.Array(LSP.Status), "LSP server status"), success: described(Schema.Array(LSP.Status), "LSP server status"),
}).annotateMerge( }).annotateMerge(
OpenApi.annotations({ OpenApi.annotations({
@@ -162,6 +176,7 @@ export const InstanceApi = HttpApi.make("instance")
}), }),
), ),
HttpApiEndpoint.get("formatter", InstancePaths.formatter, { HttpApiEndpoint.get("formatter", InstancePaths.formatter, {
query: WorkspaceRoutingQuery,
success: described(Schema.Array(Format.Status), "Formatter status"), success: described(Schema.Array(Format.Status), "Formatter status"),
}).annotateMerge( }).annotateMerge(
OpenApi.annotations({ OpenApi.annotations({
@@ -4,7 +4,7 @@ import { Schema } from "effect"
import { HttpApi, HttpApiEndpoint, HttpApiError, HttpApiGroup, OpenApi } from "effect/unstable/httpapi" import { HttpApi, HttpApiEndpoint, HttpApiError, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
import { Authorization } from "../middleware/authorization" import { Authorization } from "../middleware/authorization"
import { InstanceContextMiddleware } from "../middleware/instance-context" import { InstanceContextMiddleware } from "../middleware/instance-context"
import { WorkspaceRoutingMiddleware } from "../middleware/workspace-routing" import { WorkspaceRoutingMiddleware, WorkspaceRoutingQuery } from "../middleware/workspace-routing"
import { described } from "./metadata" import { described } from "./metadata"
export const AddPayload = Schema.Struct({ export const AddPayload = Schema.Struct({
@@ -42,6 +42,7 @@ export const McpApi = HttpApi.make("mcp")
HttpApiGroup.make("mcp") HttpApiGroup.make("mcp")
.add( .add(
HttpApiEndpoint.get("status", McpPaths.status, { HttpApiEndpoint.get("status", McpPaths.status, {
query: WorkspaceRoutingQuery,
success: described(Schema.Record(Schema.String, MCP.Status), "MCP server status"), success: described(Schema.Record(Schema.String, MCP.Status), "MCP server status"),
}).annotateMerge( }).annotateMerge(
OpenApi.annotations({ OpenApi.annotations({
@@ -51,6 +52,7 @@ export const McpApi = HttpApi.make("mcp")
}), }),
), ),
HttpApiEndpoint.post("add", McpPaths.status, { HttpApiEndpoint.post("add", McpPaths.status, {
query: WorkspaceRoutingQuery,
payload: AddPayload, payload: AddPayload,
success: described(StatusMap, "MCP server added successfully"), success: described(StatusMap, "MCP server added successfully"),
error: HttpApiError.BadRequest, error: HttpApiError.BadRequest,
@@ -63,6 +65,7 @@ export const McpApi = HttpApi.make("mcp")
), ),
HttpApiEndpoint.post("authStart", McpPaths.auth, { HttpApiEndpoint.post("authStart", McpPaths.auth, {
params: { name: Schema.String }, params: { name: Schema.String },
query: WorkspaceRoutingQuery,
success: described(AuthStartResponse, "OAuth flow started"), success: described(AuthStartResponse, "OAuth flow started"),
error: [UnsupportedOAuthError, HttpApiError.NotFound], error: [UnsupportedOAuthError, HttpApiError.NotFound],
}).annotateMerge( }).annotateMerge(
@@ -74,6 +77,7 @@ export const McpApi = HttpApi.make("mcp")
), ),
HttpApiEndpoint.post("authCallback", McpPaths.authCallback, { HttpApiEndpoint.post("authCallback", McpPaths.authCallback, {
params: { name: Schema.String }, params: { name: Schema.String },
query: WorkspaceRoutingQuery,
payload: AuthCallbackPayload, payload: AuthCallbackPayload,
success: described(MCP.Status, "OAuth authentication completed"), success: described(MCP.Status, "OAuth authentication completed"),
error: [HttpApiError.BadRequest, HttpApiError.NotFound], error: [HttpApiError.BadRequest, HttpApiError.NotFound],
@@ -87,6 +91,7 @@ export const McpApi = HttpApi.make("mcp")
), ),
HttpApiEndpoint.post("authAuthenticate", McpPaths.authAuthenticate, { HttpApiEndpoint.post("authAuthenticate", McpPaths.authAuthenticate, {
params: { name: Schema.String }, params: { name: Schema.String },
query: WorkspaceRoutingQuery,
success: described(MCP.Status, "OAuth authentication completed"), success: described(MCP.Status, "OAuth authentication completed"),
error: [UnsupportedOAuthError, HttpApiError.NotFound], error: [UnsupportedOAuthError, HttpApiError.NotFound],
}).annotateMerge( }).annotateMerge(
@@ -98,6 +103,7 @@ export const McpApi = HttpApi.make("mcp")
), ),
HttpApiEndpoint.delete("authRemove", McpPaths.auth, { HttpApiEndpoint.delete("authRemove", McpPaths.auth, {
params: { name: Schema.String }, params: { name: Schema.String },
query: WorkspaceRoutingQuery,
success: described(AuthRemoveResponse, "OAuth credentials removed"), success: described(AuthRemoveResponse, "OAuth credentials removed"),
error: HttpApiError.NotFound, error: HttpApiError.NotFound,
}).annotateMerge( }).annotateMerge(
@@ -109,6 +115,7 @@ export const McpApi = HttpApi.make("mcp")
), ),
HttpApiEndpoint.post("connect", McpPaths.connect, { HttpApiEndpoint.post("connect", McpPaths.connect, {
params: { name: Schema.String }, params: { name: Schema.String },
query: WorkspaceRoutingQuery,
success: described(Schema.Boolean, "MCP server connected successfully"), success: described(Schema.Boolean, "MCP server connected successfully"),
}).annotateMerge( }).annotateMerge(
OpenApi.annotations({ OpenApi.annotations({
@@ -118,6 +125,7 @@ export const McpApi = HttpApi.make("mcp")
), ),
HttpApiEndpoint.post("disconnect", McpPaths.disconnect, { HttpApiEndpoint.post("disconnect", McpPaths.disconnect, {
params: { name: Schema.String }, params: { name: Schema.String },
query: WorkspaceRoutingQuery,
success: described(Schema.Boolean, "MCP server disconnected successfully"), success: described(Schema.Boolean, "MCP server disconnected successfully"),
}).annotateMerge( }).annotateMerge(
OpenApi.annotations({ OpenApi.annotations({
@@ -4,7 +4,7 @@ import { Schema } from "effect"
import { HttpApi, HttpApiEndpoint, HttpApiError, HttpApiGroup, OpenApi } from "effect/unstable/httpapi" import { HttpApi, HttpApiEndpoint, HttpApiError, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
import { Authorization } from "../middleware/authorization" import { Authorization } from "../middleware/authorization"
import { InstanceContextMiddleware } from "../middleware/instance-context" import { InstanceContextMiddleware } from "../middleware/instance-context"
import { WorkspaceRoutingMiddleware } from "../middleware/workspace-routing" import { WorkspaceRoutingMiddleware, WorkspaceRoutingQuery } from "../middleware/workspace-routing"
import { described } from "./metadata" import { described } from "./metadata"
const root = "/permission" const root = "/permission"
@@ -18,6 +18,7 @@ export const PermissionApi = HttpApi.make("permission")
HttpApiGroup.make("permission") HttpApiGroup.make("permission")
.add( .add(
HttpApiEndpoint.get("list", root, { HttpApiEndpoint.get("list", root, {
query: WorkspaceRoutingQuery,
success: described(Schema.Array(Permission.Request), "List of pending permissions"), success: described(Schema.Array(Permission.Request), "List of pending permissions"),
}).annotateMerge( }).annotateMerge(
OpenApi.annotations({ OpenApi.annotations({
@@ -28,6 +29,7 @@ export const PermissionApi = HttpApi.make("permission")
), ),
HttpApiEndpoint.post("reply", `${root}/:requestID/reply`, { HttpApiEndpoint.post("reply", `${root}/:requestID/reply`, {
params: { requestID: PermissionID }, params: { requestID: PermissionID },
query: WorkspaceRoutingQuery,
payload: ReplyPayload, payload: ReplyPayload,
success: described(Schema.Boolean, "Permission processed successfully"), success: described(Schema.Boolean, "Permission processed successfully"),
error: [HttpApiError.BadRequest, HttpApiError.NotFound], error: [HttpApiError.BadRequest, HttpApiError.NotFound],
@@ -4,7 +4,7 @@ import { Schema } from "effect"
import { HttpApi, HttpApiEndpoint, HttpApiError, HttpApiGroup, OpenApi } from "effect/unstable/httpapi" import { HttpApi, HttpApiEndpoint, HttpApiError, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
import { Authorization } from "../middleware/authorization" import { Authorization } from "../middleware/authorization"
import { InstanceContextMiddleware } from "../middleware/instance-context" import { InstanceContextMiddleware } from "../middleware/instance-context"
import { WorkspaceRoutingMiddleware } from "../middleware/workspace-routing" import { WorkspaceRoutingMiddleware, WorkspaceRoutingQuery } from "../middleware/workspace-routing"
import { described } from "./metadata" import { described } from "./metadata"
const root = "/project" const root = "/project"
@@ -19,6 +19,7 @@ export const ProjectApi = HttpApi.make("project")
HttpApiGroup.make("project") HttpApiGroup.make("project")
.add( .add(
HttpApiEndpoint.get("list", root, { HttpApiEndpoint.get("list", root, {
query: WorkspaceRoutingQuery,
success: described(Schema.Array(Project.Info), "List of projects"), success: described(Schema.Array(Project.Info), "List of projects"),
}).annotateMerge( }).annotateMerge(
OpenApi.annotations({ OpenApi.annotations({
@@ -28,6 +29,7 @@ export const ProjectApi = HttpApi.make("project")
}), }),
), ),
HttpApiEndpoint.get("current", `${root}/current`, { HttpApiEndpoint.get("current", `${root}/current`, {
query: WorkspaceRoutingQuery,
success: described(Project.Info, "Current project information"), success: described(Project.Info, "Current project information"),
}).annotateMerge( }).annotateMerge(
OpenApi.annotations({ OpenApi.annotations({
@@ -37,6 +39,7 @@ export const ProjectApi = HttpApi.make("project")
}), }),
), ),
HttpApiEndpoint.post("initGit", `${root}/git/init`, { HttpApiEndpoint.post("initGit", `${root}/git/init`, {
query: WorkspaceRoutingQuery,
success: described(Project.Info, "Project information after git initialization"), success: described(Project.Info, "Project information after git initialization"),
}).annotateMerge( }).annotateMerge(
OpenApi.annotations({ OpenApi.annotations({
@@ -47,6 +50,7 @@ export const ProjectApi = HttpApi.make("project")
), ),
HttpApiEndpoint.patch("update", `${root}/:projectID`, { HttpApiEndpoint.patch("update", `${root}/:projectID`, {
params: { projectID: ProjectID }, params: { projectID: ProjectID },
query: WorkspaceRoutingQuery,
payload: UpdatePayload, payload: UpdatePayload,
success: described(Project.Info, "Updated project information"), success: described(Project.Info, "Updated project information"),
error: [HttpApiError.BadRequest, HttpApiError.NotFound], error: [HttpApiError.BadRequest, HttpApiError.NotFound],
@@ -5,7 +5,7 @@ import { Schema } from "effect"
import { HttpApi, HttpApiEndpoint, HttpApiError, HttpApiGroup, OpenApi } from "effect/unstable/httpapi" import { HttpApi, HttpApiEndpoint, HttpApiError, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
import { Authorization } from "../middleware/authorization" import { Authorization } from "../middleware/authorization"
import { InstanceContextMiddleware } from "../middleware/instance-context" import { InstanceContextMiddleware } from "../middleware/instance-context"
import { WorkspaceRoutingMiddleware } from "../middleware/workspace-routing" import { WorkspaceRoutingMiddleware, WorkspaceRoutingQuery } from "../middleware/workspace-routing"
import { described } from "./metadata" import { described } from "./metadata"
const root = "/provider" const root = "/provider"
@@ -15,6 +15,7 @@ export const ProviderApi = HttpApi.make("provider")
HttpApiGroup.make("provider") HttpApiGroup.make("provider")
.add( .add(
HttpApiEndpoint.get("list", root, { HttpApiEndpoint.get("list", root, {
query: WorkspaceRoutingQuery,
success: described(Provider.ListResult, "List of providers"), success: described(Provider.ListResult, "List of providers"),
}).annotateMerge( }).annotateMerge(
OpenApi.annotations({ OpenApi.annotations({
@@ -24,6 +25,7 @@ export const ProviderApi = HttpApi.make("provider")
}), }),
), ),
HttpApiEndpoint.get("auth", `${root}/auth`, { HttpApiEndpoint.get("auth", `${root}/auth`, {
query: WorkspaceRoutingQuery,
success: described(ProviderAuth.Methods, "Provider auth methods"), success: described(ProviderAuth.Methods, "Provider auth methods"),
}).annotateMerge( }).annotateMerge(
OpenApi.annotations({ OpenApi.annotations({
@@ -34,6 +36,7 @@ export const ProviderApi = HttpApi.make("provider")
), ),
HttpApiEndpoint.post("authorize", `${root}/:providerID/oauth/authorize`, { HttpApiEndpoint.post("authorize", `${root}/:providerID/oauth/authorize`, {
params: { providerID: ProviderID }, params: { providerID: ProviderID },
query: WorkspaceRoutingQuery,
payload: ProviderAuth.AuthorizeInput, payload: ProviderAuth.AuthorizeInput,
success: described(Schema.UndefinedOr(ProviderAuth.Authorization), "Authorization URL and method"), success: described(Schema.UndefinedOr(ProviderAuth.Authorization), "Authorization URL and method"),
error: HttpApiError.BadRequest, error: HttpApiError.BadRequest,
@@ -46,6 +49,7 @@ export const ProviderApi = HttpApi.make("provider")
), ),
HttpApiEndpoint.post("callback", `${root}/:providerID/oauth/callback`, { HttpApiEndpoint.post("callback", `${root}/:providerID/oauth/callback`, {
params: { providerID: ProviderID }, params: { providerID: ProviderID },
query: WorkspaceRoutingQuery,
payload: ProviderAuth.CallbackInput, payload: ProviderAuth.CallbackInput,
success: described(Schema.Boolean, "OAuth callback processed successfully"), success: described(Schema.Boolean, "OAuth callback processed successfully"),
error: HttpApiError.BadRequest, error: HttpApiError.BadRequest,
@@ -5,7 +5,11 @@ import { Schema } from "effect"
import { HttpApi, HttpApiEndpoint, HttpApiError, HttpApiGroup, OpenApi } from "effect/unstable/httpapi" import { HttpApi, HttpApiEndpoint, HttpApiError, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
import { Authorization } from "../middleware/authorization" import { Authorization } from "../middleware/authorization"
import { InstanceContextMiddleware } from "../middleware/instance-context" 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 { ApiNotFoundError } from "../errors"
import { described } from "./metadata" import { described } from "./metadata"
@@ -37,6 +41,7 @@ export const PtyApi = HttpApi.make("pty")
HttpApiGroup.make("pty") HttpApiGroup.make("pty")
.add( .add(
HttpApiEndpoint.get("shells", PtyPaths.shells, { HttpApiEndpoint.get("shells", PtyPaths.shells, {
query: WorkspaceRoutingQuery,
success: described(Schema.Array(ShellItem), "List of shells"), success: described(Schema.Array(ShellItem), "List of shells"),
}).annotateMerge( }).annotateMerge(
OpenApi.annotations({ OpenApi.annotations({
@@ -46,6 +51,7 @@ export const PtyApi = HttpApi.make("pty")
}), }),
), ),
HttpApiEndpoint.get("list", PtyPaths.list, { HttpApiEndpoint.get("list", PtyPaths.list, {
query: WorkspaceRoutingQuery,
success: described(Schema.Array(Pty.Info), "List of sessions"), success: described(Schema.Array(Pty.Info), "List of sessions"),
}).annotateMerge( }).annotateMerge(
OpenApi.annotations({ OpenApi.annotations({
@@ -55,6 +61,7 @@ export const PtyApi = HttpApi.make("pty")
}), }),
), ),
HttpApiEndpoint.post("create", PtyPaths.create, { HttpApiEndpoint.post("create", PtyPaths.create, {
query: WorkspaceRoutingQuery,
payload: Pty.CreateInput, payload: Pty.CreateInput,
success: described(Pty.Info, "Created session"), success: described(Pty.Info, "Created session"),
error: HttpApiError.BadRequest, error: HttpApiError.BadRequest,
@@ -67,6 +74,7 @@ export const PtyApi = HttpApi.make("pty")
), ),
HttpApiEndpoint.get("get", PtyPaths.get, { HttpApiEndpoint.get("get", PtyPaths.get, {
params: { ptyID: PtyID }, params: { ptyID: PtyID },
query: WorkspaceRoutingQuery,
success: described(Pty.Info, "Session info"), success: described(Pty.Info, "Session info"),
error: ApiNotFoundError, error: ApiNotFoundError,
}).annotateMerge( }).annotateMerge(
@@ -78,6 +86,7 @@ export const PtyApi = HttpApi.make("pty")
), ),
HttpApiEndpoint.put("update", PtyPaths.update, { HttpApiEndpoint.put("update", PtyPaths.update, {
params: { ptyID: PtyID }, params: { ptyID: PtyID },
query: WorkspaceRoutingQuery,
payload: Pty.UpdateInput, payload: Pty.UpdateInput,
success: described(Pty.Info, "Updated session"), success: described(Pty.Info, "Updated session"),
error: [HttpApiError.BadRequest, ApiNotFoundError], error: [HttpApiError.BadRequest, ApiNotFoundError],
@@ -90,6 +99,7 @@ export const PtyApi = HttpApi.make("pty")
), ),
HttpApiEndpoint.delete("remove", PtyPaths.remove, { HttpApiEndpoint.delete("remove", PtyPaths.remove, {
params: { ptyID: PtyID }, params: { ptyID: PtyID },
query: WorkspaceRoutingQuery,
success: described(Schema.Boolean, "Session removed"), success: described(Schema.Boolean, "Session removed"),
error: ApiNotFoundError, error: ApiNotFoundError,
}).annotateMerge( }).annotateMerge(
@@ -101,6 +111,7 @@ export const PtyApi = HttpApi.make("pty")
), ),
HttpApiEndpoint.post("connectToken", PtyPaths.connectToken, { HttpApiEndpoint.post("connectToken", PtyPaths.connectToken, {
params: { ptyID: PtyID }, params: { ptyID: PtyID },
query: WorkspaceRoutingQuery,
success: described(PtyTicket.ConnectToken, "WebSocket connect token"), success: described(PtyTicket.ConnectToken, "WebSocket connect token"),
error: [HttpApiError.Forbidden, ApiNotFoundError], error: [HttpApiError.Forbidden, ApiNotFoundError],
}).annotateMerge( }).annotateMerge(
@@ -129,6 +140,7 @@ export const PtyConnectApi = HttpApi.make("pty-connect").add(
.add( .add(
HttpApiEndpoint.get("connect", PtyPaths.connect, { HttpApiEndpoint.get("connect", PtyPaths.connect, {
params: Params, params: Params,
query: WorkspaceRoutingQuery,
success: described(Schema.Boolean, "Connected session"), success: described(Schema.Boolean, "Connected session"),
error: [HttpApiError.Forbidden, HttpApiError.NotFound], error: [HttpApiError.Forbidden, HttpApiError.NotFound],
}).annotateMerge( }).annotateMerge(
@@ -4,7 +4,7 @@ import { Schema } from "effect"
import { HttpApi, HttpApiEndpoint, HttpApiError, HttpApiGroup, OpenApi } from "effect/unstable/httpapi" import { HttpApi, HttpApiEndpoint, HttpApiError, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
import { Authorization } from "../middleware/authorization" import { Authorization } from "../middleware/authorization"
import { InstanceContextMiddleware } from "../middleware/instance-context" import { InstanceContextMiddleware } from "../middleware/instance-context"
import { WorkspaceRoutingMiddleware } from "../middleware/workspace-routing" import { WorkspaceRoutingMiddleware, WorkspaceRoutingQuery } from "../middleware/workspace-routing"
import { described } from "./metadata" import { described } from "./metadata"
const root = "/question" const root = "/question"
@@ -19,6 +19,7 @@ export const QuestionApi = HttpApi.make("question")
HttpApiGroup.make("question") HttpApiGroup.make("question")
.add( .add(
HttpApiEndpoint.get("list", root, { HttpApiEndpoint.get("list", root, {
query: WorkspaceRoutingQuery,
success: described(Schema.Array(Question.Request), "List of pending questions"), success: described(Schema.Array(Question.Request), "List of pending questions"),
}).annotateMerge( }).annotateMerge(
OpenApi.annotations({ OpenApi.annotations({
@@ -29,6 +30,7 @@ export const QuestionApi = HttpApi.make("question")
), ),
HttpApiEndpoint.post("reply", `${root}/:requestID/reply`, { HttpApiEndpoint.post("reply", `${root}/:requestID/reply`, {
params: { requestID: QuestionID }, params: { requestID: QuestionID },
query: WorkspaceRoutingQuery,
payload: ReplyPayload, payload: ReplyPayload,
success: described(Schema.Boolean, "Question answered successfully"), success: described(Schema.Boolean, "Question answered successfully"),
error: [HttpApiError.BadRequest, HttpApiError.NotFound], error: [HttpApiError.BadRequest, HttpApiError.NotFound],
@@ -41,6 +43,7 @@ export const QuestionApi = HttpApi.make("question")
), ),
HttpApiEndpoint.post("reject", `${root}/:requestID/reject`, { HttpApiEndpoint.post("reject", `${root}/:requestID/reject`, {
params: { requestID: QuestionID }, params: { requestID: QuestionID },
query: WorkspaceRoutingQuery,
success: described(Schema.Boolean, "Question rejected successfully"), success: described(Schema.Boolean, "Question rejected successfully"),
error: [HttpApiError.BadRequest, HttpApiError.NotFound], error: [HttpApiError.BadRequest, HttpApiError.NotFound],
}).annotateMerge( }).annotateMerge(
@@ -14,7 +14,11 @@ import { Schema, SchemaGetter, Struct } from "effect"
import { HttpApi, HttpApiEndpoint, HttpApiError, HttpApiGroup, HttpApiSchema, OpenApi } from "effect/unstable/httpapi" import { HttpApi, HttpApiEndpoint, HttpApiError, HttpApiGroup, HttpApiSchema, OpenApi } from "effect/unstable/httpapi"
import { Authorization } from "../middleware/authorization" import { Authorization } from "../middleware/authorization"
import { InstanceContextMiddleware } from "../middleware/instance-context" 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 { ApiNotFoundError } from "../errors"
import { described } from "./metadata" import { described } from "./metadata"
@@ -116,6 +120,7 @@ export const SessionApi = HttpApi.make("session")
}), }),
), ),
HttpApiEndpoint.get("status", SessionPaths.status, { HttpApiEndpoint.get("status", SessionPaths.status, {
query: WorkspaceRoutingQuery,
success: described(StatusMap, "Get session status"), success: described(StatusMap, "Get session status"),
error: HttpApiError.BadRequest, error: HttpApiError.BadRequest,
}).annotateMerge( }).annotateMerge(
@@ -127,6 +132,7 @@ export const SessionApi = HttpApi.make("session")
), ),
HttpApiEndpoint.get("get", SessionPaths.get, { HttpApiEndpoint.get("get", SessionPaths.get, {
params: { sessionID: SessionID }, params: { sessionID: SessionID },
query: WorkspaceRoutingQuery,
success: described(Session.Info, "Get session"), success: described(Session.Info, "Get session"),
error: [HttpApiError.BadRequest, ApiNotFoundError], error: [HttpApiError.BadRequest, ApiNotFoundError],
}).annotateMerge( }).annotateMerge(
@@ -138,6 +144,7 @@ export const SessionApi = HttpApi.make("session")
), ),
HttpApiEndpoint.get("children", SessionPaths.children, { HttpApiEndpoint.get("children", SessionPaths.children, {
params: { sessionID: SessionID }, params: { sessionID: SessionID },
query: WorkspaceRoutingQuery,
success: described(Schema.Array(Session.Info), "List of children"), success: described(Schema.Array(Session.Info), "List of children"),
error: [HttpApiError.BadRequest, HttpApiError.NotFound], error: [HttpApiError.BadRequest, HttpApiError.NotFound],
}).annotateMerge( }).annotateMerge(
@@ -149,6 +156,7 @@ export const SessionApi = HttpApi.make("session")
), ),
HttpApiEndpoint.get("todo", SessionPaths.todo, { HttpApiEndpoint.get("todo", SessionPaths.todo, {
params: { sessionID: SessionID }, params: { sessionID: SessionID },
query: WorkspaceRoutingQuery,
success: described(Schema.Array(Todo.Info), "Todo list"), success: described(Schema.Array(Todo.Info), "Todo list"),
error: [HttpApiError.BadRequest, HttpApiError.NotFound], error: [HttpApiError.BadRequest, HttpApiError.NotFound],
}).annotateMerge( }).annotateMerge(
@@ -183,6 +191,7 @@ export const SessionApi = HttpApi.make("session")
), ),
HttpApiEndpoint.get("message", SessionPaths.message, { HttpApiEndpoint.get("message", SessionPaths.message, {
params: { sessionID: SessionID, messageID: MessageID }, params: { sessionID: SessionID, messageID: MessageID },
query: WorkspaceRoutingQuery,
success: described(MessageV2.WithParts, "Message"), success: described(MessageV2.WithParts, "Message"),
error: [HttpApiError.BadRequest, ApiNotFoundError], error: [HttpApiError.BadRequest, ApiNotFoundError],
}).annotateMerge( }).annotateMerge(
@@ -193,6 +202,7 @@ export const SessionApi = HttpApi.make("session")
}), }),
), ),
HttpApiEndpoint.post("create", SessionPaths.create, { HttpApiEndpoint.post("create", SessionPaths.create, {
query: WorkspaceRoutingQuery,
payload: [HttpApiSchema.NoContent, Session.CreateInput], payload: [HttpApiSchema.NoContent, Session.CreateInput],
success: described(Session.Info, "Successfully created session"), success: described(Session.Info, "Successfully created session"),
error: HttpApiError.BadRequest, error: HttpApiError.BadRequest,
@@ -205,6 +215,7 @@ export const SessionApi = HttpApi.make("session")
), ),
HttpApiEndpoint.delete("remove", SessionPaths.remove, { HttpApiEndpoint.delete("remove", SessionPaths.remove, {
params: { sessionID: SessionID }, params: { sessionID: SessionID },
query: WorkspaceRoutingQuery,
success: described(Schema.Boolean, "Successfully deleted session"), success: described(Schema.Boolean, "Successfully deleted session"),
error: [HttpApiError.BadRequest, ApiNotFoundError], error: [HttpApiError.BadRequest, ApiNotFoundError],
}).annotateMerge( }).annotateMerge(
@@ -216,6 +227,7 @@ export const SessionApi = HttpApi.make("session")
), ),
HttpApiEndpoint.patch("update", SessionPaths.update, { HttpApiEndpoint.patch("update", SessionPaths.update, {
params: { sessionID: SessionID }, params: { sessionID: SessionID },
query: WorkspaceRoutingQuery,
payload: UpdatePayload, payload: UpdatePayload,
success: described(Session.Info, "Successfully updated session"), success: described(Session.Info, "Successfully updated session"),
error: [HttpApiError.BadRequest, ApiNotFoundError], error: [HttpApiError.BadRequest, ApiNotFoundError],
@@ -228,6 +240,7 @@ export const SessionApi = HttpApi.make("session")
), ),
HttpApiEndpoint.post("fork", SessionPaths.fork, { HttpApiEndpoint.post("fork", SessionPaths.fork, {
params: { sessionID: SessionID }, params: { sessionID: SessionID },
query: WorkspaceRoutingQuery,
payload: ForkPayload, payload: ForkPayload,
success: described(Session.Info, "200"), success: described(Session.Info, "200"),
error: ApiNotFoundError, error: ApiNotFoundError,
@@ -240,6 +253,7 @@ export const SessionApi = HttpApi.make("session")
), ),
HttpApiEndpoint.post("abort", SessionPaths.abort, { HttpApiEndpoint.post("abort", SessionPaths.abort, {
params: { sessionID: SessionID }, params: { sessionID: SessionID },
query: WorkspaceRoutingQuery,
success: described(Schema.Boolean, "Aborted session"), success: described(Schema.Boolean, "Aborted session"),
error: [HttpApiError.BadRequest, HttpApiError.NotFound], error: [HttpApiError.BadRequest, HttpApiError.NotFound],
}).annotateMerge( }).annotateMerge(
@@ -251,6 +265,7 @@ export const SessionApi = HttpApi.make("session")
), ),
HttpApiEndpoint.post("init", SessionPaths.init, { HttpApiEndpoint.post("init", SessionPaths.init, {
params: { sessionID: SessionID }, params: { sessionID: SessionID },
query: WorkspaceRoutingQuery,
payload: InitPayload, payload: InitPayload,
success: described(Schema.Boolean, "200"), success: described(Schema.Boolean, "200"),
error: [HttpApiError.BadRequest, HttpApiError.NotFound], error: [HttpApiError.BadRequest, HttpApiError.NotFound],
@@ -264,6 +279,7 @@ export const SessionApi = HttpApi.make("session")
), ),
HttpApiEndpoint.post("share", SessionPaths.share, { HttpApiEndpoint.post("share", SessionPaths.share, {
params: { sessionID: SessionID }, params: { sessionID: SessionID },
query: WorkspaceRoutingQuery,
success: described(Session.Info, "Successfully shared session"), success: described(Session.Info, "Successfully shared session"),
error: [HttpApiError.InternalServerError, ApiNotFoundError], error: [HttpApiError.InternalServerError, ApiNotFoundError],
}).annotateMerge( }).annotateMerge(
@@ -275,6 +291,7 @@ export const SessionApi = HttpApi.make("session")
), ),
HttpApiEndpoint.delete("unshare", SessionPaths.share, { HttpApiEndpoint.delete("unshare", SessionPaths.share, {
params: { sessionID: SessionID }, params: { sessionID: SessionID },
query: WorkspaceRoutingQuery,
success: described(Session.Info, "Successfully unshared session"), success: described(Session.Info, "Successfully unshared session"),
error: [HttpApiError.InternalServerError, ApiNotFoundError], error: [HttpApiError.InternalServerError, ApiNotFoundError],
}).annotateMerge( }).annotateMerge(
@@ -286,6 +303,7 @@ export const SessionApi = HttpApi.make("session")
), ),
HttpApiEndpoint.post("summarize", SessionPaths.summarize, { HttpApiEndpoint.post("summarize", SessionPaths.summarize, {
params: { sessionID: SessionID }, params: { sessionID: SessionID },
query: WorkspaceRoutingQuery,
payload: SummarizePayload, payload: SummarizePayload,
success: described(Schema.Boolean, "Summarized session"), success: described(Schema.Boolean, "Summarized session"),
error: [HttpApiError.BadRequest, ApiNotFoundError], error: [HttpApiError.BadRequest, ApiNotFoundError],
@@ -298,6 +316,7 @@ export const SessionApi = HttpApi.make("session")
), ),
HttpApiEndpoint.post("prompt", SessionPaths.prompt, { HttpApiEndpoint.post("prompt", SessionPaths.prompt, {
params: { sessionID: SessionID }, params: { sessionID: SessionID },
query: WorkspaceRoutingQuery,
payload: PromptPayload, payload: PromptPayload,
success: described(MessageV2.WithParts, "Created message"), success: described(MessageV2.WithParts, "Created message"),
error: [HttpApiError.BadRequest, HttpApiError.NotFound], error: [HttpApiError.BadRequest, HttpApiError.NotFound],
@@ -310,6 +329,7 @@ export const SessionApi = HttpApi.make("session")
), ),
HttpApiEndpoint.post("promptAsync", SessionPaths.promptAsync, { HttpApiEndpoint.post("promptAsync", SessionPaths.promptAsync, {
params: { sessionID: SessionID }, params: { sessionID: SessionID },
query: WorkspaceRoutingQuery,
payload: PromptPayload, payload: PromptPayload,
success: described(HttpApiSchema.NoContent, "Prompt accepted"), success: described(HttpApiSchema.NoContent, "Prompt accepted"),
error: [HttpApiError.BadRequest, HttpApiError.NotFound], error: [HttpApiError.BadRequest, HttpApiError.NotFound],
@@ -323,6 +343,7 @@ export const SessionApi = HttpApi.make("session")
), ),
HttpApiEndpoint.post("command", SessionPaths.command, { HttpApiEndpoint.post("command", SessionPaths.command, {
params: { sessionID: SessionID }, params: { sessionID: SessionID },
query: WorkspaceRoutingQuery,
payload: CommandPayload, payload: CommandPayload,
success: described(MessageV2.WithParts, "Created message"), success: described(MessageV2.WithParts, "Created message"),
error: [HttpApiError.BadRequest, HttpApiError.NotFound], error: [HttpApiError.BadRequest, HttpApiError.NotFound],
@@ -335,6 +356,7 @@ export const SessionApi = HttpApi.make("session")
), ),
HttpApiEndpoint.post("shell", SessionPaths.shell, { HttpApiEndpoint.post("shell", SessionPaths.shell, {
params: { sessionID: SessionID }, params: { sessionID: SessionID },
query: WorkspaceRoutingQuery,
payload: ShellPayload, payload: ShellPayload,
success: described(MessageV2.WithParts, "Created message"), success: described(MessageV2.WithParts, "Created message"),
error: [HttpApiError.BadRequest, HttpApiError.NotFound], error: [HttpApiError.BadRequest, HttpApiError.NotFound],
@@ -347,6 +369,7 @@ export const SessionApi = HttpApi.make("session")
), ),
HttpApiEndpoint.post("revert", SessionPaths.revert, { HttpApiEndpoint.post("revert", SessionPaths.revert, {
params: { sessionID: SessionID }, params: { sessionID: SessionID },
query: WorkspaceRoutingQuery,
payload: RevertPayload, payload: RevertPayload,
success: described(Session.Info, "Updated session"), success: described(Session.Info, "Updated session"),
error: [HttpApiError.BadRequest, HttpApiError.NotFound], error: [HttpApiError.BadRequest, HttpApiError.NotFound],
@@ -360,6 +383,7 @@ export const SessionApi = HttpApi.make("session")
), ),
HttpApiEndpoint.post("unrevert", SessionPaths.unrevert, { HttpApiEndpoint.post("unrevert", SessionPaths.unrevert, {
params: { sessionID: SessionID }, params: { sessionID: SessionID },
query: WorkspaceRoutingQuery,
success: described(Session.Info, "Updated session"), success: described(Session.Info, "Updated session"),
error: [HttpApiError.BadRequest, HttpApiError.NotFound], error: [HttpApiError.BadRequest, HttpApiError.NotFound],
}).annotateMerge( }).annotateMerge(
@@ -371,6 +395,7 @@ export const SessionApi = HttpApi.make("session")
), ),
HttpApiEndpoint.post("permissionRespond", SessionPaths.permissions, { HttpApiEndpoint.post("permissionRespond", SessionPaths.permissions, {
params: { sessionID: SessionID, permissionID: PermissionID }, params: { sessionID: SessionID, permissionID: PermissionID },
query: WorkspaceRoutingQuery,
payload: PermissionResponsePayload, payload: PermissionResponsePayload,
success: described(Schema.Boolean, "Permission processed successfully"), success: described(Schema.Boolean, "Permission processed successfully"),
error: [HttpApiError.BadRequest, HttpApiError.NotFound], error: [HttpApiError.BadRequest, HttpApiError.NotFound],
@@ -384,6 +409,7 @@ export const SessionApi = HttpApi.make("session")
), ),
HttpApiEndpoint.delete("deleteMessage", SessionPaths.deleteMessage, { HttpApiEndpoint.delete("deleteMessage", SessionPaths.deleteMessage, {
params: { sessionID: SessionID, messageID: MessageID }, params: { sessionID: SessionID, messageID: MessageID },
query: WorkspaceRoutingQuery,
success: described(Schema.Boolean, "Successfully deleted message"), success: described(Schema.Boolean, "Successfully deleted message"),
error: [HttpApiError.BadRequest, HttpApiError.NotFound], error: [HttpApiError.BadRequest, HttpApiError.NotFound],
}).annotateMerge( }).annotateMerge(
@@ -396,6 +422,7 @@ export const SessionApi = HttpApi.make("session")
), ),
HttpApiEndpoint.delete("deletePart", SessionPaths.deletePart, { HttpApiEndpoint.delete("deletePart", SessionPaths.deletePart, {
params: { sessionID: SessionID, messageID: MessageID, partID: PartID }, params: { sessionID: SessionID, messageID: MessageID, partID: PartID },
query: WorkspaceRoutingQuery,
success: described(Schema.Boolean, "Successfully deleted part"), success: described(Schema.Boolean, "Successfully deleted part"),
error: [HttpApiError.BadRequest, HttpApiError.NotFound], error: [HttpApiError.BadRequest, HttpApiError.NotFound],
}).annotateMerge( }).annotateMerge(
@@ -406,6 +433,7 @@ export const SessionApi = HttpApi.make("session")
), ),
HttpApiEndpoint.patch("updatePart", SessionPaths.updatePart, { HttpApiEndpoint.patch("updatePart", SessionPaths.updatePart, {
params: { sessionID: SessionID, messageID: MessageID, partID: PartID }, params: { sessionID: SessionID, messageID: MessageID, partID: PartID },
query: WorkspaceRoutingQuery,
payload: MessageV2.Part, payload: MessageV2.Part,
success: described(MessageV2.Part, "Successfully updated part"), success: described(MessageV2.Part, "Successfully updated part"),
error: [HttpApiError.BadRequest, HttpApiError.NotFound], error: [HttpApiError.BadRequest, HttpApiError.NotFound],
@@ -4,7 +4,7 @@ import { Schema } from "effect"
import { HttpApi, HttpApiEndpoint, HttpApiError, HttpApiGroup, OpenApi } from "effect/unstable/httpapi" import { HttpApi, HttpApiEndpoint, HttpApiError, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
import { Authorization } from "../middleware/authorization" import { Authorization } from "../middleware/authorization"
import { InstanceContextMiddleware } from "../middleware/instance-context" import { InstanceContextMiddleware } from "../middleware/instance-context"
import { WorkspaceRoutingMiddleware } from "../middleware/workspace-routing" import { WorkspaceRoutingMiddleware, WorkspaceRoutingQuery } from "../middleware/workspace-routing"
import { described } from "./metadata" import { described } from "./metadata"
const root = "/sync" const root = "/sync"
@@ -46,6 +46,7 @@ export const SyncApi = HttpApi.make("sync")
HttpApiGroup.make("sync") HttpApiGroup.make("sync")
.add( .add(
HttpApiEndpoint.post("start", SyncPaths.start, { HttpApiEndpoint.post("start", SyncPaths.start, {
query: WorkspaceRoutingQuery,
success: described(Schema.Boolean, "Workspace sync started"), success: described(Schema.Boolean, "Workspace sync started"),
}).annotateMerge( }).annotateMerge(
OpenApi.annotations({ OpenApi.annotations({
@@ -55,6 +56,7 @@ export const SyncApi = HttpApi.make("sync")
}), }),
), ),
HttpApiEndpoint.post("replay", SyncPaths.replay, { HttpApiEndpoint.post("replay", SyncPaths.replay, {
query: WorkspaceRoutingQuery,
payload: ReplayPayload, payload: ReplayPayload,
success: described(ReplayResponse, "Replayed sync events"), success: described(ReplayResponse, "Replayed sync events"),
error: HttpApiError.BadRequest, error: HttpApiError.BadRequest,
@@ -66,6 +68,7 @@ export const SyncApi = HttpApi.make("sync")
}), }),
), ),
HttpApiEndpoint.post("steal", SyncPaths.steal, { HttpApiEndpoint.post("steal", SyncPaths.steal, {
query: WorkspaceRoutingQuery,
payload: SessionPayload, payload: SessionPayload,
success: described(SessionPayload, "Session stolen into workspace"), success: described(SessionPayload, "Session stolen into workspace"),
error: HttpApiError.BadRequest, error: HttpApiError.BadRequest,
@@ -77,6 +80,7 @@ export const SyncApi = HttpApi.make("sync")
}), }),
), ),
HttpApiEndpoint.post("history", SyncPaths.history, { HttpApiEndpoint.post("history", SyncPaths.history, {
query: WorkspaceRoutingQuery,
payload: HistoryPayload, payload: HistoryPayload,
success: described(Schema.Array(HistoryEvent), "Sync events"), success: described(Schema.Array(HistoryEvent), "Sync events"),
error: HttpApiError.BadRequest, error: HttpApiError.BadRequest,
@@ -4,7 +4,7 @@ import { Schema } from "effect"
import { HttpApi, HttpApiEndpoint, HttpApiError, HttpApiGroup, OpenApi } from "effect/unstable/httpapi" import { HttpApi, HttpApiEndpoint, HttpApiError, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
import { Authorization } from "../middleware/authorization" import { Authorization } from "../middleware/authorization"
import { InstanceContextMiddleware } from "../middleware/instance-context" import { InstanceContextMiddleware } from "../middleware/instance-context"
import { WorkspaceRoutingMiddleware } from "../middleware/workspace-routing" import { WorkspaceRoutingMiddleware, WorkspaceRoutingQuery } from "../middleware/workspace-routing"
import { ApiNotFoundError } from "../errors" import { ApiNotFoundError } from "../errors"
import { described } from "./metadata" import { described } from "./metadata"
@@ -54,6 +54,7 @@ export const TuiApi = HttpApi.make("tui")
HttpApiGroup.make("tui") HttpApiGroup.make("tui")
.add( .add(
HttpApiEndpoint.post("appendPrompt", TuiPaths.appendPrompt, { HttpApiEndpoint.post("appendPrompt", TuiPaths.appendPrompt, {
query: WorkspaceRoutingQuery,
payload: TuiEvent.PromptAppend.properties, payload: TuiEvent.PromptAppend.properties,
success: described(Schema.Boolean, "Prompt processed successfully"), success: described(Schema.Boolean, "Prompt processed successfully"),
error: HttpApiError.BadRequest, error: HttpApiError.BadRequest,
@@ -65,6 +66,7 @@ export const TuiApi = HttpApi.make("tui")
}), }),
), ),
HttpApiEndpoint.post("openHelp", TuiPaths.openHelp, { HttpApiEndpoint.post("openHelp", TuiPaths.openHelp, {
query: WorkspaceRoutingQuery,
success: described(Schema.Boolean, "Help dialog opened successfully"), success: described(Schema.Boolean, "Help dialog opened successfully"),
}).annotateMerge( }).annotateMerge(
OpenApi.annotations({ OpenApi.annotations({
@@ -74,6 +76,7 @@ export const TuiApi = HttpApi.make("tui")
}), }),
), ),
HttpApiEndpoint.post("openSessions", TuiPaths.openSessions, { HttpApiEndpoint.post("openSessions", TuiPaths.openSessions, {
query: WorkspaceRoutingQuery,
success: described(Schema.Boolean, "Session dialog opened successfully"), success: described(Schema.Boolean, "Session dialog opened successfully"),
}).annotateMerge( }).annotateMerge(
OpenApi.annotations({ OpenApi.annotations({
@@ -83,6 +86,7 @@ export const TuiApi = HttpApi.make("tui")
}), }),
), ),
HttpApiEndpoint.post("openThemes", TuiPaths.openThemes, { HttpApiEndpoint.post("openThemes", TuiPaths.openThemes, {
query: WorkspaceRoutingQuery,
success: described(Schema.Boolean, "Theme dialog opened successfully"), success: described(Schema.Boolean, "Theme dialog opened successfully"),
}).annotateMerge( }).annotateMerge(
OpenApi.annotations({ OpenApi.annotations({
@@ -92,6 +96,7 @@ export const TuiApi = HttpApi.make("tui")
}), }),
), ),
HttpApiEndpoint.post("openModels", TuiPaths.openModels, { HttpApiEndpoint.post("openModels", TuiPaths.openModels, {
query: WorkspaceRoutingQuery,
success: described(Schema.Boolean, "Model dialog opened successfully"), success: described(Schema.Boolean, "Model dialog opened successfully"),
}).annotateMerge( }).annotateMerge(
OpenApi.annotations({ OpenApi.annotations({
@@ -101,6 +106,7 @@ export const TuiApi = HttpApi.make("tui")
}), }),
), ),
HttpApiEndpoint.post("submitPrompt", TuiPaths.submitPrompt, { HttpApiEndpoint.post("submitPrompt", TuiPaths.submitPrompt, {
query: WorkspaceRoutingQuery,
success: described(Schema.Boolean, "Prompt submitted successfully"), success: described(Schema.Boolean, "Prompt submitted successfully"),
}).annotateMerge( }).annotateMerge(
OpenApi.annotations({ OpenApi.annotations({
@@ -110,6 +116,7 @@ export const TuiApi = HttpApi.make("tui")
}), }),
), ),
HttpApiEndpoint.post("clearPrompt", TuiPaths.clearPrompt, { HttpApiEndpoint.post("clearPrompt", TuiPaths.clearPrompt, {
query: WorkspaceRoutingQuery,
success: described(Schema.Boolean, "Prompt cleared successfully"), success: described(Schema.Boolean, "Prompt cleared successfully"),
}).annotateMerge( }).annotateMerge(
OpenApi.annotations({ OpenApi.annotations({
@@ -119,6 +126,7 @@ export const TuiApi = HttpApi.make("tui")
}), }),
), ),
HttpApiEndpoint.post("executeCommand", TuiPaths.executeCommand, { HttpApiEndpoint.post("executeCommand", TuiPaths.executeCommand, {
query: WorkspaceRoutingQuery,
payload: CommandPayload, payload: CommandPayload,
success: described(Schema.Boolean, "Command executed successfully"), success: described(Schema.Boolean, "Command executed successfully"),
error: HttpApiError.BadRequest, error: HttpApiError.BadRequest,
@@ -130,6 +138,7 @@ export const TuiApi = HttpApi.make("tui")
}), }),
), ),
HttpApiEndpoint.post("showToast", TuiPaths.showToast, { HttpApiEndpoint.post("showToast", TuiPaths.showToast, {
query: WorkspaceRoutingQuery,
payload: TuiEvent.ToastShow.properties, payload: TuiEvent.ToastShow.properties,
success: described(Schema.Boolean, "Toast notification shown successfully"), success: described(Schema.Boolean, "Toast notification shown successfully"),
}).annotateMerge( }).annotateMerge(
@@ -140,6 +149,7 @@ export const TuiApi = HttpApi.make("tui")
}), }),
), ),
HttpApiEndpoint.post("publish", TuiPaths.publish, { HttpApiEndpoint.post("publish", TuiPaths.publish, {
query: WorkspaceRoutingQuery,
payload: TuiPublishPayload, payload: TuiPublishPayload,
success: described(Schema.Boolean, "Event published successfully"), success: described(Schema.Boolean, "Event published successfully"),
error: HttpApiError.BadRequest, error: HttpApiError.BadRequest,
@@ -151,6 +161,7 @@ export const TuiApi = HttpApi.make("tui")
}), }),
), ),
HttpApiEndpoint.post("selectSession", TuiPaths.selectSession, { HttpApiEndpoint.post("selectSession", TuiPaths.selectSession, {
query: WorkspaceRoutingQuery,
payload: TuiEvent.SessionSelect.properties, payload: TuiEvent.SessionSelect.properties,
success: described(Schema.Boolean, "Session selected successfully"), success: described(Schema.Boolean, "Session selected successfully"),
error: [HttpApiError.BadRequest, ApiNotFoundError], error: [HttpApiError.BadRequest, ApiNotFoundError],
@@ -162,6 +173,7 @@ export const TuiApi = HttpApi.make("tui")
}), }),
), ),
HttpApiEndpoint.get("controlNext", TuiPaths.controlNext, { HttpApiEndpoint.get("controlNext", TuiPaths.controlNext, {
query: WorkspaceRoutingQuery,
success: described(TuiRequestPayload, "Next TUI request"), success: described(TuiRequestPayload, "Next TUI request"),
}).annotateMerge( }).annotateMerge(
OpenApi.annotations({ OpenApi.annotations({
@@ -171,6 +183,7 @@ export const TuiApi = HttpApi.make("tui")
}), }),
), ),
HttpApiEndpoint.post("controlResponse", TuiPaths.controlResponse, { HttpApiEndpoint.post("controlResponse", TuiPaths.controlResponse, {
query: WorkspaceRoutingQuery,
payload: Schema.Unknown, payload: Schema.Unknown,
success: described(Schema.Boolean, "Response submitted successfully"), success: described(Schema.Boolean, "Response submitted successfully"),
}).annotateMerge( }).annotateMerge(
@@ -3,46 +3,31 @@ import { SessionMessage } from "@/v2/session-message"
import { Schema } from "effect" import { Schema } from "effect"
import { HttpApiEndpoint, HttpApiError, HttpApiGroup, OpenApi } from "effect/unstable/httpapi" import { HttpApiEndpoint, HttpApiError, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
import { Authorization } from "../../middleware/authorization" 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") export const MessageGroup = HttpApiGroup.make("v2.message")
.add( .add(
HttpApiEndpoint.get("messages", "/api/session/:sessionID/message", { HttpApiEndpoint.get("messages", "/api/session/:sessionID/message", {
params: { sessionID: SessionID }, params: { sessionID: SessionID },
query: Schema.Union([ query: MessagesQuery,
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" }),
success: Schema.Struct({ success: Schema.Struct({
items: Schema.Array(SessionMessage.Message), items: Schema.Array(SessionMessage.Message),
cursor: Schema.Struct({ cursor: Schema.Struct({
@@ -1,4 +1,3 @@
import { WorkspaceID } from "@/control-plane/schema"
import { SessionID } from "@/session/schema" import { SessionID } from "@/session/schema"
import { SessionMessage } from "@/v2/session-message" import { SessionMessage } from "@/v2/session-message"
import { Prompt } from "@/v2/session-prompt" import { Prompt } from "@/v2/session-prompt"
@@ -6,62 +5,41 @@ import { SessionV2 } from "@/v2/session"
import { Schema, SchemaGetter } from "effect" import { Schema, SchemaGetter } from "effect"
import { HttpApiEndpoint, HttpApiError, HttpApiGroup, HttpApiSchema, OpenApi } from "effect/unstable/httpapi" import { HttpApiEndpoint, HttpApiError, HttpApiGroup, HttpApiSchema, OpenApi } from "effect/unstable/httpapi"
import { Authorization } from "../../middleware/authorization" import { Authorization } from "../../middleware/authorization"
import { WorkspaceRoutingQuery, WorkspaceRoutingQueryFields } from "../../middleware/workspace-routing"
export const SessionGroup = HttpApiGroup.make("v2.session") const QueryBoolean = Schema.Literals(["true", "false"]).pipe(
.add( Schema.decodeTo(Schema.Boolean, {
HttpApiEndpoint.get("sessions", "/api/session", { decode: SchemaGetter.transform((value) => value === "true"),
query: Schema.Union([ encode: SchemaGetter.transform((value) => (value ? "true" : "false")),
Schema.Struct({ }),
)
export const SessionsQuery = Schema.Struct({
...WorkspaceRoutingQueryFields,
limit: Schema.optional( limit: Schema.optional(
Schema.NumberFromString.check( Schema.NumberFromString.check(Schema.isInt(), Schema.isGreaterThanOrEqualTo(1), Schema.isLessThanOrEqualTo(200)),
Schema.isInt(),
Schema.isGreaterThanOrEqualTo(1),
Schema.isLessThanOrEqualTo(200),
),
).annotate({ ).annotate({
description: "Maximum number of sessions to return. Defaults to the newest 50 sessions.", 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({ 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.", 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.optional(Schema.String),
path: Schema.String.pipe(Schema.optional), roots: Schema.optional(QueryBoolean),
workspace: WorkspaceID.pipe(Schema.optional), start: Schema.optional(Schema.NumberFromString),
roots: Schema.Literals(["true", "false"]) search: Schema.optional(Schema.String),
.pipe( cursor: Schema.optional(
Schema.decodeTo(Schema.Boolean, { Schema.String.annotate({
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: description:
"Opaque pagination cursor returned as cursor.previous or cursor.next in the previous response. Do not combine with order.", "Opaque pagination cursor returned as cursor.previous or cursor.next in the previous response. Do not combine with order or filters.",
}), }),
order: Schema.optional(Schema.Never), ),
directory: Schema.optional(Schema.Never), }).annotate({ identifier: "V2SessionsQuery" })
path: Schema.optional(Schema.Never),
workspace: Schema.optional(Schema.Never), export const SessionGroup = HttpApiGroup.make("v2.session")
roots: Schema.optional(Schema.Never), .add(
start: Schema.optional(Schema.Never), HttpApiEndpoint.get("sessions", "/api/session", {
search: Schema.optional(Schema.Never), query: SessionsQuery,
}),
]).annotate({ identifier: "V2SessionsQuery" }),
success: Schema.Struct({ success: Schema.Struct({
items: Schema.Array(SessionV2.Info), items: Schema.Array(SessionV2.Info),
cursor: Schema.Struct({ cursor: Schema.Struct({
@@ -82,6 +60,7 @@ export const SessionGroup = HttpApiGroup.make("v2.session")
.add( .add(
HttpApiEndpoint.post("prompt", "/api/session/:sessionID/prompt", { HttpApiEndpoint.post("prompt", "/api/session/:sessionID/prompt", {
params: { sessionID: SessionID }, params: { sessionID: SessionID },
query: WorkspaceRoutingQuery,
payload: Schema.Struct({ payload: Schema.Struct({
prompt: Prompt, prompt: Prompt,
delivery: SessionV2.Delivery.pipe(Schema.optional), delivery: SessionV2.Delivery.pipe(Schema.optional),
@@ -98,6 +77,7 @@ export const SessionGroup = HttpApiGroup.make("v2.session")
.add( .add(
HttpApiEndpoint.post("compact", "/api/session/:sessionID/compact", { HttpApiEndpoint.post("compact", "/api/session/:sessionID/compact", {
params: { sessionID: SessionID }, params: { sessionID: SessionID },
query: WorkspaceRoutingQuery,
success: HttpApiSchema.NoContent, success: HttpApiSchema.NoContent,
}).annotateMerge( }).annotateMerge(
OpenApi.annotations({ OpenApi.annotations({
@@ -110,6 +90,7 @@ export const SessionGroup = HttpApiGroup.make("v2.session")
.add( .add(
HttpApiEndpoint.post("wait", "/api/session/:sessionID/wait", { HttpApiEndpoint.post("wait", "/api/session/:sessionID/wait", {
params: { sessionID: SessionID }, params: { sessionID: SessionID },
query: WorkspaceRoutingQuery,
success: HttpApiSchema.NoContent, success: HttpApiSchema.NoContent,
}).annotateMerge( }).annotateMerge(
OpenApi.annotations({ OpenApi.annotations({
@@ -122,6 +103,7 @@ export const SessionGroup = HttpApiGroup.make("v2.session")
.add( .add(
HttpApiEndpoint.get("context", "/api/session/:sessionID/context", { HttpApiEndpoint.get("context", "/api/session/:sessionID/context", {
params: { sessionID: SessionID }, params: { sessionID: SessionID },
query: WorkspaceRoutingQuery,
success: Schema.Array(SessionMessage.Message), success: Schema.Array(SessionMessage.Message),
}).annotateMerge( }).annotateMerge(
OpenApi.annotations({ OpenApi.annotations({
@@ -5,7 +5,7 @@ import { HttpApi, HttpApiEndpoint, HttpApiError, HttpApiGroup, HttpApiSchema, Op
import { ApiVcsApplyError } from "./instance" import { ApiVcsApplyError } from "./instance"
import { Authorization } from "../middleware/authorization" import { Authorization } from "../middleware/authorization"
import { InstanceContextMiddleware } from "../middleware/instance-context" import { InstanceContextMiddleware } from "../middleware/instance-context"
import { WorkspaceRoutingMiddleware } from "../middleware/workspace-routing" import { WorkspaceRoutingMiddleware, WorkspaceRoutingQuery } from "../middleware/workspace-routing"
import { described } from "./metadata" import { described } from "./metadata"
const root = "/experimental/workspace" const root = "/experimental/workspace"
@@ -40,6 +40,7 @@ export const WorkspaceApi = HttpApi.make("workspace")
HttpApiGroup.make("workspace") HttpApiGroup.make("workspace")
.add( .add(
HttpApiEndpoint.get("adapters", WorkspacePaths.adapters, { HttpApiEndpoint.get("adapters", WorkspacePaths.adapters, {
query: WorkspaceRoutingQuery,
success: described(Schema.Array(WorkspaceAdapterEntry), "Workspace adapters"), success: described(Schema.Array(WorkspaceAdapterEntry), "Workspace adapters"),
}).annotateMerge( }).annotateMerge(
OpenApi.annotations({ OpenApi.annotations({
@@ -49,6 +50,7 @@ export const WorkspaceApi = HttpApi.make("workspace")
}), }),
), ),
HttpApiEndpoint.get("list", WorkspacePaths.list, { HttpApiEndpoint.get("list", WorkspacePaths.list, {
query: WorkspaceRoutingQuery,
success: described(Schema.Array(Workspace.Info), "Workspaces"), success: described(Schema.Array(Workspace.Info), "Workspaces"),
}).annotateMerge( }).annotateMerge(
OpenApi.annotations({ OpenApi.annotations({
@@ -58,6 +60,7 @@ export const WorkspaceApi = HttpApi.make("workspace")
}), }),
), ),
HttpApiEndpoint.post("create", WorkspacePaths.list, { HttpApiEndpoint.post("create", WorkspacePaths.list, {
query: WorkspaceRoutingQuery,
payload: CreatePayload, payload: CreatePayload,
success: described(Workspace.Info, "Workspace created"), success: described(Workspace.Info, "Workspace created"),
error: HttpApiError.BadRequest, error: HttpApiError.BadRequest,
@@ -69,6 +72,7 @@ export const WorkspaceApi = HttpApi.make("workspace")
}), }),
), ),
HttpApiEndpoint.post("syncList", WorkspacePaths.syncList, { HttpApiEndpoint.post("syncList", WorkspacePaths.syncList, {
query: WorkspaceRoutingQuery,
success: described(HttpApiSchema.NoContent, "Workspace list synced"), success: described(HttpApiSchema.NoContent, "Workspace list synced"),
}).annotateMerge( }).annotateMerge(
OpenApi.annotations({ OpenApi.annotations({
@@ -78,6 +82,7 @@ export const WorkspaceApi = HttpApi.make("workspace")
}), }),
), ),
HttpApiEndpoint.get("status", WorkspacePaths.status, { HttpApiEndpoint.get("status", WorkspacePaths.status, {
query: WorkspaceRoutingQuery,
success: described(Schema.Array(Workspace.ConnectionStatus), "Workspace status"), success: described(Schema.Array(Workspace.ConnectionStatus), "Workspace status"),
}).annotateMerge( }).annotateMerge(
OpenApi.annotations({ OpenApi.annotations({
@@ -88,6 +93,7 @@ export const WorkspaceApi = HttpApi.make("workspace")
), ),
HttpApiEndpoint.delete("remove", WorkspacePaths.remove, { HttpApiEndpoint.delete("remove", WorkspacePaths.remove, {
params: { id: Workspace.Info.fields.id }, params: { id: Workspace.Info.fields.id },
query: WorkspaceRoutingQuery,
success: described(Schema.UndefinedOr(Workspace.Info), "Workspace removed"), success: described(Schema.UndefinedOr(Workspace.Info), "Workspace removed"),
error: HttpApiError.BadRequest, error: HttpApiError.BadRequest,
}).annotateMerge( }).annotateMerge(
@@ -98,6 +104,7 @@ export const WorkspaceApi = HttpApi.make("workspace")
}), }),
), ),
HttpApiEndpoint.post("warp", WorkspacePaths.warp, { HttpApiEndpoint.post("warp", WorkspacePaths.warp, {
query: WorkspaceRoutingQuery,
payload: WarpPayload, payload: WarpPayload,
success: described(HttpApiSchema.NoContent, "Session warped"), success: described(HttpApiSchema.NoContent, "Session warped"),
error: [ApiWorkspaceWarpError, ApiVcsApplyError], error: [ApiWorkspaceWarpError, ApiVcsApplyError],
@@ -34,6 +34,7 @@ export const messageHandlers = HttpApiBuilder.group(InstanceHttpApi, "v2.message
return handlers.handle( return handlers.handle(
"messages", "messages",
Effect.fn(function* (ctx) { Effect.fn(function* (ctx) {
if (ctx.query.cursor && ctx.query.order !== undefined) return yield* new HttpApiError.BadRequest({})
const decoded = yield* Effect.try({ const decoded = yield* Effect.try({
try: () => (ctx.query.cursor ? cursor.decode(ctx.query.cursor) : undefined), try: () => (ctx.query.cursor ? cursor.decode(ctx.query.cursor) : undefined),
catch: () => new HttpApiError.BadRequest({}), catch: () => new HttpApiError.BadRequest({}),
@@ -22,6 +22,31 @@ type SessionCursor = typeof SessionCursor.Type
const decodeCursor = Schema.decodeUnknownSync(SessionCursor) 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 = { const sessionCursor = {
encode( encode(
session: SessionV2.Info, session: SessionV2.Info,
@@ -46,10 +71,12 @@ export const sessionHandlers = HttpApiBuilder.group(InstanceHttpApi, "v2.session
.handle( .handle(
"sessions", "sessions",
Effect.fn(function* (ctx) { Effect.fn(function* (ctx) {
if (ctx.query.cursor && hasCursorFilter(ctx.query)) return yield* new HttpApiError.BadRequest({})
const decoded = yield* Effect.try({ const decoded = yield* Effect.try({
try: () => (ctx.query.cursor ? sessionCursor.decode(ctx.query.cursor) : undefined), try: () => (ctx.query.cursor ? sessionCursor.decode(ctx.query.cursor) : undefined),
catch: () => new HttpApiError.BadRequest({}), catch: () => new HttpApiError.BadRequest({}),
}) })
if (hasCursorRoutingMismatch(ctx.query, decoded)) return yield* new HttpApiError.BadRequest({})
const order = decoded?.order ?? ctx.query.order ?? "desc" const order = decoded?.order ?? ctx.query.order ?? "desc"
const filters = decoded ?? { const filters = decoded ?? {
directory: ctx.query.directory, directory: ctx.query.directory,
@@ -24,6 +24,8 @@ export const WorkspaceRoutingQueryFields = {
workspace: Schema.optional(Schema.String), workspace: Schema.optional(Schema.String),
} }
export const WorkspaceRoutingQuery = Schema.Struct(WorkspaceRoutingQueryFields)
type RemoteTarget = Extract<Target, { type: "remote" }> type RemoteTarget = Extract<Target, { type: "remote" }>
type RequestPlan = Data.TaggedEnum<{ type RequestPlan = Data.TaggedEnum<{
@@ -51,30 +51,14 @@ type OpenApiResponse = {
content?: Record<string, { schema?: OpenApiSchema }> 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 // Query schemas describe decoded Effect values, but the generated SDK needs the
// public call shape. These keep SDK callers passing numbers/booleans while the // public call shape. These keep SDK callers passing numbers/booleans while the
// server still decodes string query params at runtime. // 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 QueryBooleanParameters = new Set(["roots", "archived"])
const QueryParameterSchemas = { const QueryParameterSchemas = {
"GET /find/file limit": { type: "integer", minimum: 1, maximum: 200 }, "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}/diff messageID": { type: "string", pattern: "^msg.*" },
"GET /session/{sessionID}/message limit": { type: "integer", minimum: 0, maximum: Number.MAX_SAFE_INTEGER }, "GET /session/{sessionID}/message limit": { type: "integer", minimum: 0, maximum: Number.MAX_SAFE_INTEGER },
} satisfies Record<string, OpenApiSchema> } satisfies Record<string, OpenApiSchema>
@@ -122,7 +106,6 @@ function matchLegacyOpenApi(input: Record<string, unknown>) {
delete spec.components?.securitySchemes delete spec.components?.securitySchemes
for (const [path, item] of Object.entries(spec.paths ?? {})) { 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) { for (const method of ["get", "post", "put", "delete", "patch"] as const) {
const operation = item[method] const operation = item[method]
if (!operation) continue if (!operation) continue
@@ -183,14 +166,8 @@ function matchLegacyOpenApi(input: Record<string, unknown>) {
}, },
} }
} }
if (!isInstanceRoute) continue const route = `${method.toUpperCase()} ${path}`
operation.parameters = [ for (const param of operation.parameters ?? []) normalizeParameter(param, route)
...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}`)
} }
} }
return input return input
@@ -38,6 +38,10 @@ import { type Scenario } from "./types"
void (await import("@opencode-ai/core/util/log")).init({ print: false }) 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[] = [ const scenarios: Scenario[] = [
http.protected http.protected
.get("/global/health", "global.health") .get("/global/health", "global.health")
@@ -598,6 +602,64 @@ const scenarios: Scenario[] = [
}, },
"none", "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 http.protected
.get("/api/session/{sessionID}/context", "v2.session.context") .get("/api/session/{sessionID}/context", "v2.session.context")
.at((ctx) => ({ .at((ctx) => ({
@@ -620,6 +682,53 @@ const scenarios: Scenario[] = [
}, },
"none", "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 http.protected
.post("/api/session/{sessionID}/prompt", "v2.session.prompt.invalid") .post("/api/session/{sessionID}/prompt", "v2.session.prompt.invalid")
.at((ctx) => ({ .at((ctx) => ({
@@ -1,14 +1,53 @@
import { afterEach, describe, expect } from "bun:test" import { afterEach, describe, expect } from "bun:test"
import { Effect } from "effect" import { Effect } from "effect"
import { OpenApi } from "effect/unstable/httpapi"
import { Flag } from "@opencode-ai/core/flag/flag" import { Flag } from "@opencode-ai/core/flag/flag"
import { Server } from "../../src/server/server" import { Server } from "../../src/server/server"
import { SessionID } from "../../src/session/schema" 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 { resetDatabase } from "../fixture/db"
import { disposeAllInstances, tmpdir } from "../fixture/fixture" import { disposeAllInstances, tmpdir } from "../fixture/fixture"
import { it } from "../lib/effect" import { it } from "../lib/effect"
const originalWorkspaces = Flag.OPENCODE_EXPERIMENTAL_WORKSPACES 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() { function app() {
return Server.Default().app return Server.Default().app
} }
@@ -27,6 +66,29 @@ function withTmp<A, E, R>(
).pipe(Effect.flatMap(fn)) ).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 () => { afterEach(async () => {
Flag.OPENCODE_EXPERIMENTAL_WORKSPACES = originalWorkspaces Flag.OPENCODE_EXPERIMENTAL_WORKSPACES = originalWorkspaces
await disposeAllInstances() 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) 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( it.live(
"session list accepts directory and workspace", "session list accepts directory and workspace",
withTmp({ config: { formatter: false, lsp: false } }, (tmp) => withTmp({ config: { formatter: false, lsp: false } }, (tmp) =>
+20
View File
@@ -4163,6 +4163,13 @@ export class Session3 extends HeyApiClient {
parameters?: { parameters?: {
directory?: string directory?: string
workspace?: string workspace?: string
limit?: number
order?: "asc" | "desc"
path?: string
roots?: boolean | "true" | "false"
start?: number
search?: string
cursor?: string
}, },
options?: Options<never, ThrowOnError>, options?: Options<never, ThrowOnError>,
) { ) {
@@ -4173,6 +4180,13 @@ export class Session3 extends HeyApiClient {
args: [ args: [
{ in: "query", key: "directory" }, { in: "query", key: "directory" },
{ in: "query", key: "workspace" }, { 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 sessionID: string
directory?: string directory?: string
workspace?: string workspace?: string
limit?: number
order?: "asc" | "desc"
cursor?: string
}, },
options?: Options<never, ThrowOnError>, options?: Options<never, ThrowOnError>,
) { ) {
@@ -4342,6 +4359,9 @@ export class Session3 extends HeyApiClient {
{ in: "path", key: "sessionID" }, { in: "path", key: "sessionID" },
{ in: "query", key: "directory" }, { in: "query", key: "directory" },
{ in: "query", key: "workspace" }, { in: "query", key: "workspace" },
{ in: "query", key: "limit" },
{ in: "query", key: "order" },
{ in: "query", key: "cursor" },
], ],
}, },
], ],
+99 -83
View File
@@ -5,6 +5,12 @@ export type ClientOptions = {
} }
export type Event = export type Event =
| EventTuiPromptAppend
| EventTuiCommandExecute
| EventTuiToastShow1
| EventTuiSessionSelect
| EventServerConnected
| EventGlobalDisposed
| EventServerInstanceDisposed | EventServerInstanceDisposed
| EventFileEdited | EventFileEdited
| EventFileWatcherUpdated | EventFileWatcherUpdated
@@ -24,10 +30,6 @@ export type Event =
| EventSessionStatus | EventSessionStatus
| EventSessionIdle | EventSessionIdle
| EventSessionCompacted | EventSessionCompacted
| EventTuiPromptAppend
| EventTuiCommandExecute
| EventTuiToastShow1
| EventTuiSessionSelect
| EventMcpToolsChanged | EventMcpToolsChanged
| EventMcpBrowserOpenFailed | EventMcpBrowserOpenFailed
| EventCommandExecuted | EventCommandExecuted
@@ -75,8 +77,6 @@ export type Event =
| EventSessionNextCompactionStarted | EventSessionNextCompactionStarted
| EventSessionNextCompactionDelta | EventSessionNextCompactionDelta
| EventSessionNextCompactionEnded | EventSessionNextCompactionEnded
| EventServerConnected
| EventGlobalDisposed
export type OAuth = { export type OAuth = {
type: "oauth" type: "oauth"
@@ -103,6 +103,61 @@ export type WellKnownAuth = {
export type Auth = OAuth | ApiAuth | 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 = { export type PermissionRequest = {
id: string id: string
sessionID: string sessionID: string
@@ -280,61 +335,6 @@ export type SessionStatus =
type: "busy" 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 = { export type Project = {
id: string id: string
worktree: string worktree: string
@@ -778,6 +778,12 @@ export type GlobalEvent = {
project?: string project?: string
workspace?: string workspace?: string
payload: payload:
| EventTuiPromptAppend
| EventTuiCommandExecute
| EventTuiToastShow
| EventTuiSessionSelect
| EventServerConnected
| EventGlobalDisposed
| EventServerInstanceDisposed | EventServerInstanceDisposed
| EventFileEdited | EventFileEdited
| EventFileWatcherUpdated | EventFileWatcherUpdated
@@ -797,10 +803,6 @@ export type GlobalEvent = {
| EventSessionStatus | EventSessionStatus
| EventSessionIdle | EventSessionIdle
| EventSessionCompacted | EventSessionCompacted
| EventTuiPromptAppend
| EventTuiCommandExecute
| EventTuiToastShow
| EventTuiSessionSelect
| EventMcpToolsChanged | EventMcpToolsChanged
| EventMcpBrowserOpenFailed | EventMcpBrowserOpenFailed
| EventCommandExecuted | EventCommandExecuted
@@ -848,8 +850,6 @@ export type GlobalEvent = {
| EventSessionNextCompactionStarted | EventSessionNextCompactionStarted
| EventSessionNextCompactionDelta | EventSessionNextCompactionDelta
| EventSessionNextCompactionEnded | EventSessionNextCompactionEnded
| EventServerConnected
| EventGlobalDisposed
| SyncEventMessageUpdated | SyncEventMessageUpdated
| SyncEventMessageRemoved | SyncEventMessageRemoved
| SyncEventMessagePartUpdated | 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 = { export type EventServerInstanceDisposed = {
id: string id: string
type: "server.instance.disposed" 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 = { export type SessionInfo = {
id: string id: string
parentID?: string parentID?: string
@@ -6235,6 +6235,16 @@ export type V2SessionListData = {
query?: { query?: {
directory?: string directory?: string
workspace?: 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" url: "/api/session"
} }
@@ -6352,6 +6362,12 @@ export type V2SessionMessagesData = {
query?: { query?: {
directory?: string directory?: string
workspace?: 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" url: "/api/session/{sessionID}/message"
} }