diff --git a/packages/opencode/script/http-route-inventory.ts b/packages/opencode/script/http-route-inventory.ts new file mode 100644 index 0000000000..24b2cd47e0 --- /dev/null +++ b/packages/opencode/script/http-route-inventory.ts @@ -0,0 +1,138 @@ +#!/usr/bin/env bun + +import type { Hono } from "hono" +import type { UpgradeWebSocket } from "hono/ws" +import { WorkspacePaths } from "../src/server/routes/instance/httpapi/workspace" +import { FilePaths } from "../src/server/routes/instance/httpapi/file" +import { McpPaths } from "../src/server/routes/instance/httpapi/mcp" +import { Flag } from "../src/flag/flag" +import { ControlPlaneRoutes } from "../src/server/routes/control" +import { WorkspaceRoutes } from "../src/server/routes/control/workspace" +import { InstanceRoutes } from "../src/server/routes/instance" +import { UIRoutes } from "../src/server/routes/ui" + +type Method = "GET" | "POST" | "PUT" | "PATCH" | "DELETE" | "ALL" + +interface Route { + surface: string + method: Method + path: string + status: string +} + +const methodOrder = new Map([ + ["GET", 0], + ["POST", 1], + ["PUT", 2], + ["PATCH", 3], + ["DELETE", 4], + ["ALL", 5], +]) + +const bridged = new Set([ + key("GET", "/question"), + key("POST", "/question/:requestID/reply"), + key("POST", "/question/:requestID/reject"), + key("GET", "/permission"), + key("POST", "/permission/:requestID/reply"), + key("GET", "/config"), + key("GET", "/config/providers"), + key("GET", "/provider"), + key("GET", "/provider/auth"), + key("POST", "/provider/:providerID/oauth/authorize"), + key("POST", "/provider/:providerID/oauth/callback"), + key("GET", "/project"), + key("GET", "/project/current"), + key("GET", FilePaths.list), + key("GET", FilePaths.content), + key("GET", FilePaths.status), + key("GET", McpPaths.status), + ...Object.values(WorkspacePaths).map((path) => key("GET", path)), +]) + +const topLevelNext = new Set([ + key("GET", "/path"), + key("GET", "/vcs"), + key("GET", "/vcs/diff"), + key("GET", "/command"), + key("GET", "/agent"), + key("GET", "/skill"), + key("GET", "/lsp"), + key("GET", "/formatter"), +]) + +function key(method: string, path: string) { + return `${method} ${path}` +} + +function normalize(prefix: string, route: string) { + if (!prefix) return route + if (route === "/") return prefix + return `${prefix}${route}`.replaceAll(/\/+/g, "/") +} + +function routes(surface: string, app: Hono, prefix = "") { + const seen = new Map() + for (const route of app.routes as Array<{ method: Method; path: string }>) { + if (surface !== "ui" && route.method === "ALL" && route.path === "/*") continue + const path = normalize(prefix, route.path) + seen.set(key(route.method, path), { + surface, + method: route.method, + path, + status: classify(route.method, path, surface), + }) + } + return [...seen.values()].toSorted(compare) +} + +function compare(a: Route, b: Route) { + return ( + a.surface.localeCompare(b.surface) || + a.path.localeCompare(b.path) || + (methodOrder.get(a.method) ?? 99) - (methodOrder.get(b.method) ?? 99) + ) +} + +function classify(method: Method, path: string, surface: string) { + if (bridged.has(key(method, path))) return "bridged" + if (topLevelNext.has(key(method, path))) return "next" + if (surface === "ui") return "special" + if (path === "/event") return "special" + if (path.startsWith("/pty") || path.startsWith("/tui")) return "special" + if (path.startsWith("/session") || path.startsWith("/sync")) return "later" + if (path.startsWith("/experimental")) return method === "GET" ? "next" : "later" + if (path.startsWith("/mcp")) return "later" + if (path === "/instance/dispose") return "next" + return "later" +} + +function table(items: Route[]) { + return [ + "| Surface | Method | Path | Status |", + "| --- | --- | --- | --- |", + ...items.map((item) => `| ${item.surface} | \`${item.method}\` | \`${item.path}\` | \`${item.status}\` |`), + ].join("\n") +} + +Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = false + +const websocket = (() => () => new Response(null, { status: 501 })) as unknown as UpgradeWebSocket +const inventory = [ + ...routes("control", ControlPlaneRoutes()), + ...routes("workspace", WorkspaceRoutes(), "/experimental/workspace"), + ...routes("instance", InstanceRoutes(websocket)), + ...routes("ui", UIRoutes()), +].toSorted(compare) + +await Bun.write( + new URL("../specs/effect/http-route-inventory.md", import.meta.url), + `# Http Route Inventory + +Generated from Hono route registrations by \`packages/opencode/script/http-route-inventory.ts\`. + +Status meanings are defined in \`specs/effect/http-api.md\`. + +${table(inventory)} +`, +) diff --git a/packages/opencode/specs/effect/http-api.md b/packages/opencode/specs/effect/http-api.md index ad9fcb2ba5..10e7d4d730 100644 --- a/packages/opencode/specs/effect/http-api.md +++ b/packages/opencode/specs/effect/http-api.md @@ -53,6 +53,12 @@ Before porting more routes, cover the bridge behavior that every route depends o Create a route inventory from the actual Hono registrations and classify each route. +The generated inventory lives in `specs/effect/http-route-inventory.md` and is produced by: + +```bash +bun run script/http-route-inventory.ts +``` + Statuses: - `bridged`: served through the `HttpApi` bridge when the flag is on. @@ -130,31 +136,31 @@ Use raw Effect HTTP routes where `HttpApi` does not fit. The goal is deleting Ho ## Current Route Status -| Area | Status | Notes | -| ------------------------ | ----------------- | -------------------------------------------------------------- | -| `question` | `bridged` | `GET /question`, reply, reject | -| `permission` | `bridged` | list and reply | -| `provider` | `bridged` | list, auth, OAuth authorize/callback | -| `config` | `bridged` partial | reads only; mutation remains Hono | -| `project` | `bridged` partial | reads only; git-init remains Hono | -| `file` | `bridged` partial | list/content/status only | -| `mcp` | `bridged` partial | status only | -| `workspace` | `implemented` | `HttpApi` group exists, but bridge mounting needs verification | -| top-level instance reads | `next` | path, vcs, command, agent, skill, lsp, formatter | -| experimental JSON routes | `next/later` | console, tool, worktree, resource, global session list | -| `session` | `later/special` | large stateful surface plus streaming | -| `sync` | `later` | process/control side effects | -| `event` | `special` | SSE | -| `pty` | `special` | websocket | -| `tui` | `special` | UI bridge | +| Area | Status | Notes | +| ------------------------ | ----------------- | --------------------------------------------------------------------- | +| `question` | `bridged` | `GET /question`, reply, reject | +| `permission` | `bridged` | list and reply | +| `provider` | `bridged` | list, auth, OAuth authorize/callback | +| `config` | `bridged` partial | reads only; `PATCH /config` remains Hono | +| `project` | `bridged` partial | reads only; update and git-init remain Hono | +| `file` | `bridged` partial | `GET /file`, `GET /file/content`, `GET /file/status` | +| `mcp` | `bridged` partial | `GET /mcp` only | +| `workspace` | `bridged` partial | reads only; create/remove/session-restore remain Hono | +| top-level instance reads | `next` | `GET /path`, `/vcs`, `/vcs/diff`, `/command`, `/agent`, `/skill`, etc. | +| experimental JSON routes | `next/later` | console, tool, worktree, resource, global session list | +| `session` | `later/special` | large stateful surface plus streaming | +| `sync` | `later` | process/control side effects | +| `event` | `special` | SSE | +| `pty` | `special` | websocket | +| `tui` | `special` | UI bridge | + +See `specs/effect/http-route-inventory.md` for exact paths and per-route status. ## Next PRs 1. Add bridge-level auth and instance-context tests for the current `HttpApi` bridge. -2. Produce a generated route inventory from Hono registrations and update `Current Route Status` with exact paths. -3. Fix the `workspace` status: mount it if it should be reachable, or remove it from the composed `HttpApi` layer. -4. Port the top-level JSON reads. -5. Start the Effect OpenAPI/SDK generation path for already-bridged routes. +2. Port the top-level JSON reads. +3. Start the Effect OpenAPI/SDK generation path for already-bridged routes. ## Checklist @@ -165,8 +171,8 @@ Use raw Effect HTTP routes where `HttpApi` does not fit. The goal is deleting Ho - [x] Attach auth middleware in route modules. - [x] Support `auth_token` as a query security scheme. - [ ] Add bridge-level auth and instance tests. -- [ ] Complete exact Hono route inventory. -- [ ] Resolve implemented-but-unmounted route groups. +- [x] Complete exact Hono route inventory. +- [x] Resolve implemented-but-unmounted route groups. - [ ] Port remaining JSON routes. - [ ] Generate SDK/OpenAPI from Effect routes. - [ ] Flip ported JSON routes to default-on with fallback. diff --git a/packages/opencode/specs/effect/http-route-inventory.md b/packages/opencode/specs/effect/http-route-inventory.md new file mode 100644 index 0000000000..29c83df0aa --- /dev/null +++ b/packages/opencode/specs/effect/http-route-inventory.md @@ -0,0 +1,119 @@ +# Http Route Inventory + +Generated from Hono route registrations by `packages/opencode/script/http-route-inventory.ts`. + +Status meanings are defined in `specs/effect/http-api.md`. + +| Surface | Method | Path | Status | +| --- | --- | --- | --- | +| control | `PUT` | `/auth/:providerID` | `later` | +| control | `DELETE` | `/auth/:providerID` | `later` | +| control | `GET` | `/doc` | `later` | +| control | `POST` | `/log` | `later` | +| instance | `GET` | `/agent` | `next` | +| instance | `GET` | `/command` | `next` | +| instance | `GET` | `/config` | `bridged` | +| instance | `PATCH` | `/config` | `later` | +| instance | `GET` | `/config/providers` | `bridged` | +| instance | `GET` | `/event` | `special` | +| instance | `GET` | `/experimental/console` | `next` | +| instance | `GET` | `/experimental/console/orgs` | `next` | +| instance | `POST` | `/experimental/console/switch` | `later` | +| instance | `GET` | `/experimental/resource` | `next` | +| instance | `GET` | `/experimental/session` | `next` | +| instance | `GET` | `/experimental/tool` | `next` | +| instance | `GET` | `/experimental/tool/ids` | `next` | +| instance | `GET` | `/experimental/worktree` | `next` | +| instance | `POST` | `/experimental/worktree` | `later` | +| instance | `DELETE` | `/experimental/worktree` | `later` | +| instance | `POST` | `/experimental/worktree/reset` | `later` | +| instance | `GET` | `/file` | `bridged` | +| instance | `GET` | `/file/content` | `bridged` | +| instance | `GET` | `/file/status` | `bridged` | +| instance | `GET` | `/find` | `later` | +| instance | `GET` | `/find/file` | `later` | +| instance | `GET` | `/find/symbol` | `later` | +| instance | `GET` | `/formatter` | `next` | +| instance | `POST` | `/instance/dispose` | `next` | +| instance | `GET` | `/lsp` | `next` | +| instance | `GET` | `/mcp` | `bridged` | +| instance | `POST` | `/mcp` | `later` | +| instance | `POST` | `/mcp/:name/auth` | `later` | +| instance | `DELETE` | `/mcp/:name/auth` | `later` | +| instance | `POST` | `/mcp/:name/auth/authenticate` | `later` | +| instance | `POST` | `/mcp/:name/auth/callback` | `later` | +| instance | `POST` | `/mcp/:name/connect` | `later` | +| instance | `POST` | `/mcp/:name/disconnect` | `later` | +| instance | `GET` | `/path` | `next` | +| instance | `GET` | `/permission` | `bridged` | +| instance | `POST` | `/permission/:requestID/reply` | `bridged` | +| instance | `GET` | `/project` | `bridged` | +| instance | `PATCH` | `/project/:projectID` | `later` | +| instance | `GET` | `/project/current` | `bridged` | +| instance | `POST` | `/project/git/init` | `later` | +| instance | `GET` | `/provider` | `bridged` | +| instance | `POST` | `/provider/:providerID/oauth/authorize` | `bridged` | +| instance | `POST` | `/provider/:providerID/oauth/callback` | `bridged` | +| instance | `GET` | `/provider/auth` | `bridged` | +| instance | `GET` | `/pty` | `special` | +| instance | `POST` | `/pty` | `special` | +| instance | `GET` | `/pty/:ptyID` | `special` | +| instance | `PUT` | `/pty/:ptyID` | `special` | +| instance | `DELETE` | `/pty/:ptyID` | `special` | +| instance | `GET` | `/pty/:ptyID/connect` | `special` | +| instance | `GET` | `/question` | `bridged` | +| instance | `POST` | `/question/:requestID/reject` | `bridged` | +| instance | `POST` | `/question/:requestID/reply` | `bridged` | +| instance | `GET` | `/session` | `later` | +| instance | `POST` | `/session` | `later` | +| instance | `GET` | `/session/:sessionID` | `later` | +| instance | `PATCH` | `/session/:sessionID` | `later` | +| instance | `DELETE` | `/session/:sessionID` | `later` | +| instance | `POST` | `/session/:sessionID/abort` | `later` | +| instance | `GET` | `/session/:sessionID/children` | `later` | +| instance | `POST` | `/session/:sessionID/command` | `later` | +| instance | `GET` | `/session/:sessionID/diff` | `later` | +| instance | `POST` | `/session/:sessionID/fork` | `later` | +| instance | `POST` | `/session/:sessionID/init` | `later` | +| instance | `GET` | `/session/:sessionID/message` | `later` | +| instance | `POST` | `/session/:sessionID/message` | `later` | +| instance | `GET` | `/session/:sessionID/message/:messageID` | `later` | +| instance | `DELETE` | `/session/:sessionID/message/:messageID` | `later` | +| instance | `PATCH` | `/session/:sessionID/message/:messageID/part/:partID` | `later` | +| instance | `DELETE` | `/session/:sessionID/message/:messageID/part/:partID` | `later` | +| instance | `POST` | `/session/:sessionID/permissions/:permissionID` | `later` | +| instance | `POST` | `/session/:sessionID/prompt_async` | `later` | +| instance | `POST` | `/session/:sessionID/revert` | `later` | +| instance | `POST` | `/session/:sessionID/share` | `later` | +| instance | `DELETE` | `/session/:sessionID/share` | `later` | +| instance | `POST` | `/session/:sessionID/shell` | `later` | +| instance | `POST` | `/session/:sessionID/summarize` | `later` | +| instance | `GET` | `/session/:sessionID/todo` | `later` | +| instance | `POST` | `/session/:sessionID/unrevert` | `later` | +| instance | `GET` | `/session/status` | `later` | +| instance | `GET` | `/skill` | `next` | +| instance | `POST` | `/sync/history` | `later` | +| instance | `POST` | `/sync/replay` | `later` | +| instance | `POST` | `/sync/start` | `later` | +| instance | `POST` | `/tui/append-prompt` | `special` | +| instance | `POST` | `/tui/clear-prompt` | `special` | +| instance | `GET` | `/tui/control/next` | `special` | +| instance | `POST` | `/tui/control/response` | `special` | +| instance | `POST` | `/tui/execute-command` | `special` | +| instance | `POST` | `/tui/open-help` | `special` | +| instance | `POST` | `/tui/open-models` | `special` | +| instance | `POST` | `/tui/open-sessions` | `special` | +| instance | `POST` | `/tui/open-themes` | `special` | +| instance | `POST` | `/tui/publish` | `special` | +| instance | `POST` | `/tui/select-session` | `special` | +| instance | `POST` | `/tui/show-toast` | `special` | +| instance | `POST` | `/tui/submit-prompt` | `special` | +| instance | `GET` | `/vcs` | `next` | +| instance | `GET` | `/vcs/diff` | `next` | +| ui | `ALL` | `/*` | `special` | +| workspace | `GET` | `/experimental/workspace` | `bridged` | +| workspace | `POST` | `/experimental/workspace` | `later` | +| workspace | `DELETE` | `/experimental/workspace/:id` | `later` | +| workspace | `POST` | `/experimental/workspace/:id/session-restore` | `later` | +| workspace | `GET` | `/experimental/workspace/adaptor` | `bridged` | +| workspace | `GET` | `/experimental/workspace/status` | `bridged` |