Compare commits
15 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| eba2217601 | |||
| e29058c346 | |||
| 49894330d9 | |||
| cdc7d5f2ea | |||
| ec201623fb | |||
| 386091b79a | |||
| 1e4b7b5451 | |||
| 5cd178ba70 | |||
| 97eb9fdee8 | |||
| 5a04de231e | |||
| bb3509b5ff | |||
| 4a67905266 | |||
| c4e33d3168 | |||
| 872cdff2ab | |||
| 361d7001d0 |
@@ -33,3 +33,4 @@ simonklee
|
||||
-spider-yamet clawdbot/llm psychosis, spam pinging the team
|
||||
thdxr
|
||||
-toastythebot
|
||||
-davidbernat looks to be a clawdbot that spams team and sends super weird emails, doesnt appear to be a real person
|
||||
|
||||
@@ -451,6 +451,7 @@
|
||||
"@typescript/native-preview": "catalog:",
|
||||
"drizzle-kit": "catalog:",
|
||||
"drizzle-orm": "catalog:",
|
||||
"prettier": "3.6.2",
|
||||
"typescript": "catalog:",
|
||||
"vscode-languageserver-types": "3.17.5",
|
||||
"why-is-node-running": "3.2.2",
|
||||
|
||||
+4
-4
@@ -1,8 +1,8 @@
|
||||
{
|
||||
"nodeModules": {
|
||||
"x86_64-linux": "sha256-+G3/s18NZO1Dpc5TsZyix2Npodzei25Svw3nTjfzXW8=",
|
||||
"aarch64-linux": "sha256-39HPencmRYRbyCk/cZIdPFk6ocY1AMlyuN9j25zAKzI=",
|
||||
"aarch64-darwin": "sha256-043korPEjSHKiZ3P+EfWyOfKpgOC7CBpviccviaDa0o=",
|
||||
"x86_64-darwin": "sha256-vsZ7e//rL9e7Cl5kl/Xplvi1fqayljxTLwRSbxvCxeM="
|
||||
"x86_64-linux": "sha256-V1Rt2k7ujkqGw4pDkn++WALTy1fAugvoKLhKvwFKkss=",
|
||||
"aarch64-linux": "sha256-ho0AuGbJ1qw9Hvb3EbGC8f0lWqqgUslvda/wTe32MFo=",
|
||||
"aarch64-darwin": "sha256-hdUyNmp+snwtnBckHXsPMgNFUYS1sYDdngkk+AXVqzc=",
|
||||
"x86_64-darwin": "sha256-P57LpQNF8fplFKQBBIukhOKbIugbViyBUIUjClXohuk="
|
||||
}
|
||||
}
|
||||
|
||||
@@ -69,6 +69,7 @@
|
||||
"@typescript/native-preview": "catalog:",
|
||||
"drizzle-kit": "catalog:",
|
||||
"drizzle-orm": "catalog:",
|
||||
"prettier": "3.6.2",
|
||||
"typescript": "catalog:",
|
||||
"vscode-languageserver-types": "3.17.5",
|
||||
"why-is-node-running": "3.2.2",
|
||||
|
||||
@@ -1,462 +1,174 @@
|
||||
# HttpApi migration
|
||||
|
||||
Practical notes for an eventual migration of `packages/opencode` server routes from the current Hono handlers to Effect `HttpApi`, either as a full replacement or as a parallel surface.
|
||||
Plan for replacing instance Hono route implementations with Effect `HttpApi` while preserving behavior, OpenAPI, and SDK output during the transition.
|
||||
|
||||
## Goal
|
||||
## End State
|
||||
|
||||
Use Effect `HttpApi` where it gives us a better typed contract for:
|
||||
- JSON route contracts and handlers live in `src/server/routes/instance/httpapi/*`.
|
||||
- Route modules own their `HttpApiGroup`, schemas, handlers, and route-level middleware.
|
||||
- `httpapi/server.ts` only composes groups, instance lookup, observability, and the web handler bridge.
|
||||
- Hono route implementations are deleted once their `HttpApi` replacements are default, tested, and represented in the SDK/OpenAPI pipeline.
|
||||
- Streaming, SSE, and websocket routes move later through Effect HTTP primitives or another explicit replacement plan; they do not need to fit `HttpApi` if `HttpApi` is the wrong abstraction.
|
||||
|
||||
- route definition
|
||||
- request decoding and validation
|
||||
- typed success and error responses
|
||||
- OpenAPI generation
|
||||
- handler composition inside Effect
|
||||
## Current State
|
||||
|
||||
This should be treated as a later-stage HTTP boundary migration, not a prerequisite for ongoing service, route-handler, or schema work.
|
||||
- `OPENCODE_EXPERIMENTAL_HTTPAPI` gates the bridge. Default behavior still uses Hono.
|
||||
- The bridge mounts selected paths in `server/routes/instance/index.ts` before legacy Hono routes.
|
||||
- Legacy Hono routes remain for default behavior and for `hono-openapi` SDK generation.
|
||||
- `HttpApi` auth is independent of Hono auth.
|
||||
- `Authorization` is attached in each route module, not centrally wrapped in `server.ts`.
|
||||
- Auth supports Basic auth and the legacy `auth_token` query parameter through `HttpApiSecurity.apiKey`.
|
||||
- Instance context is provided by `httpapi/server.ts` using `directory`, `workspace`, and `x-opencode-directory`.
|
||||
- `Observability.layer` is provided in the Effect route layer and deduplicated through the shared `memoMap`.
|
||||
|
||||
## Core model
|
||||
## Migration Rules
|
||||
|
||||
`HttpApi` is definition-first.
|
||||
- Preserve runtime behavior first. Semantic changes, new error behavior, or route shape changes need separate PRs.
|
||||
- Migrate one route group, or one coherent subset of a route group, at a time.
|
||||
- Reuse existing services. Do not re-architect service logic during HTTP boundary migration.
|
||||
- Effect Schema owns route DTOs. Keep `.zod` only as compatibility for remaining Hono/OpenAPI surfaces.
|
||||
- Regenerate the SDK after schema or OpenAPI-affecting changes and verify the diff is expected.
|
||||
- Do not delete a Hono route until the SDK/OpenAPI pipeline no longer depends on its Hono `describeRoute` entry.
|
||||
|
||||
- `HttpApi` is the root API
|
||||
- `HttpApiGroup` groups related endpoints
|
||||
- `HttpApiEndpoint` defines a single route and its request / response schemas
|
||||
- handlers are implemented separately from the contract
|
||||
## Schema Notes
|
||||
|
||||
This is a better fit once route inputs and outputs are already moving toward Effect Schema-first models.
|
||||
- Use `Schema.Struct(...).annotate({ identifier })` for named OpenAPI refs when handlers return plain objects.
|
||||
- Use `Schema.Class` only when the handler returns real class instances or the constructor requirement is intentional.
|
||||
- Keep nested anonymous shapes as `Schema.Struct` unless a named SDK type is useful.
|
||||
- Avoid parallel hand-written Zod and Effect definitions for the same route boundary.
|
||||
|
||||
## Why it is relevant here
|
||||
## Phases
|
||||
|
||||
The current route-effectification work is already pushing handlers toward:
|
||||
### 1. Stabilize The Bridge
|
||||
|
||||
- one `AppRuntime.runPromise(Effect.gen(...))` body
|
||||
- yielding services from context
|
||||
- using typed Effect errors instead of Promise wrappers
|
||||
Before porting more routes, cover the bridge behavior that every route depends on.
|
||||
|
||||
That work is a good prerequisite for `HttpApi`. Once the handler body is already a composed Effect, the remaining migration is mostly about replacing the Hono route declaration and validator layer.
|
||||
- Add tests that hit the Hono-mounted `HttpApi` bridge, not just `HttpApiBuilder.layer` directly.
|
||||
- Cover auth disabled, Basic auth success, `auth_token` success, missing credentials, and bad credentials.
|
||||
- Cover `directory` and `x-opencode-directory` instance selection.
|
||||
- Verify generated SDK output remains unchanged for non-SDK work.
|
||||
- Fix or remove any implemented-but-unmounted `HttpApi` groups.
|
||||
|
||||
## What HttpApi gives us
|
||||
### 2. Complete The Inventory
|
||||
|
||||
### Contracts
|
||||
Create a route inventory from the actual Hono registrations and classify each route.
|
||||
|
||||
Request params, query, payload, success payloads, and typed error payloads are declared in one place using Effect Schema.
|
||||
Statuses:
|
||||
|
||||
### Validation and decoding
|
||||
- `bridged`: served through the `HttpApi` bridge when the flag is on.
|
||||
- `implemented`: `HttpApi` group exists but is not mounted through Hono.
|
||||
- `next`: good JSON candidate for near-term porting.
|
||||
- `later`: portable, but needs schema/service cleanup first.
|
||||
- `special`: SSE, websocket, streaming, or UI bridge behavior that likely needs raw Effect HTTP rather than `HttpApi`.
|
||||
|
||||
Incoming data is decoded through Effect Schema instead of hand-maintained Zod validators per route.
|
||||
### 3. Finish JSON Route Parity
|
||||
|
||||
### OpenAPI
|
||||
Port remaining JSON routes in small batches.
|
||||
|
||||
`HttpApi` can derive OpenAPI from the API definition, which overlaps with the current `describeRoute(...)` and `resolver(...)` pattern.
|
||||
Good near-term candidates:
|
||||
|
||||
### Typed errors
|
||||
- top-level reads: `GET /path`, `GET /vcs`, `GET /vcs/diff`, `GET /command`, `GET /agent`, `GET /skill`, `GET /lsp`, `GET /formatter`
|
||||
- simple mutations: `POST /instance/dispose`
|
||||
- experimental JSON reads: console, tool, worktree list, resource list
|
||||
- deferred JSON mutations: `PATCH /config`, project git init, workspace/worktree create/remove/reset, file search, MCP auth flows
|
||||
|
||||
`Schema.TaggedErrorClass` maps naturally to endpoint error contracts.
|
||||
Keep large or stateful groups for later:
|
||||
|
||||
## Likely fit for opencode
|
||||
- `session`
|
||||
- `sync`
|
||||
- process-level experimental routes
|
||||
|
||||
Best fit first:
|
||||
### 4. Move OpenAPI And SDK Generation
|
||||
|
||||
- JSON request / response endpoints
|
||||
- route groups that already mostly delegate into services
|
||||
- endpoints whose request and response models can be defined with Effect Schema
|
||||
Hono routes cannot be deleted while `hono-openapi` is the source of SDK generation.
|
||||
|
||||
Harder / later fit:
|
||||
Required before route deletion:
|
||||
|
||||
- SSE endpoints
|
||||
- websocket endpoints
|
||||
- streaming handlers
|
||||
- routes with heavy Hono-specific middleware assumptions
|
||||
- Generate the public OpenAPI surface from Effect `HttpApi` for ported routes.
|
||||
- Keep operation IDs, schemas, status codes, and SDK type names stable unless the change is intentional.
|
||||
- Compare generated SDK output against `dev` for every route group deletion.
|
||||
- Remove Hono OpenAPI stubs only after Effect OpenAPI is the SDK source for those paths.
|
||||
|
||||
## Current blockers and gaps
|
||||
### 5. Make HttpApi Default For JSON Routes
|
||||
|
||||
### Schema split
|
||||
After JSON parity and SDK generation are covered:
|
||||
|
||||
Many route boundaries still use Zod-first validators. That does not block all experimentation, but full `HttpApi` adoption is easier after the domain and boundary types are more consistently Schema-first with `.zod` compatibility only where needed.
|
||||
- Flip the bridge default for ported JSON routes.
|
||||
- Keep a short-lived fallback flag for the old Hono implementation.
|
||||
- Run the same tests against both the default and fallback path during rollout.
|
||||
- Stop adding new Hono handlers for JSON routes once the default flips.
|
||||
|
||||
### Mixed handler styles
|
||||
### 6. Delete Hono Route Implementations
|
||||
|
||||
Many current `server/routes/instance/*.ts` handlers still mix composed Effect code with smaller Promise- or ALS-backed seams. Migrating those to consistent `Effect.gen(...)` handlers is the low-risk step to do first.
|
||||
Delete Hono routes group-by-group after each group meets the deletion criteria.
|
||||
|
||||
### Non-JSON routes
|
||||
Deletion criteria:
|
||||
|
||||
The server currently includes SSE, websocket, and streaming-style endpoints. Those should not be the first `HttpApi` targets.
|
||||
- `HttpApi` route is mounted by default.
|
||||
- Behavior is covered by bridge-level tests.
|
||||
- OpenAPI/SDK generation comes from Effect for that path.
|
||||
- SDK diff is zero or explicitly accepted.
|
||||
- Legacy Hono route is no longer needed as a fallback.
|
||||
|
||||
### Existing Hono integration
|
||||
After deleting a group:
|
||||
|
||||
The current server composition, middleware, and docs flow are Hono-centered today. That suggests a parallel or incremental adoption plan is safer than a flag day rewrite.
|
||||
- Remove its Hono route file or dead endpoints.
|
||||
- Remove its `.route(...)` registration from `instance/index.ts`.
|
||||
- Remove duplicate Zod-only route DTOs if Effect Schema now owns the type.
|
||||
- Regenerate SDK and verify output.
|
||||
|
||||
## Recommended strategy
|
||||
### 7. Replace Special Routes
|
||||
|
||||
### 1. Finish the prerequisites first
|
||||
Special routes need explicit designs before Hono can disappear completely.
|
||||
|
||||
- continue route-handler effectification in `server/routes/instance/*.ts`
|
||||
- continue schema migration toward Effect Schema-first DTOs and errors
|
||||
- keep removing service facades
|
||||
- `event`: SSE
|
||||
- `pty`: websocket
|
||||
- `tui`: UI/control bridge behavior
|
||||
- streaming `session` endpoints
|
||||
|
||||
### 2. Start with one parallel group
|
||||
Use raw Effect HTTP routes where `HttpApi` does not fit. The goal is deleting Hono implementations, not forcing every transport shape through `HttpApi`.
|
||||
|
||||
Introduce one small `HttpApi` group for plain JSON endpoints only. Good initial candidates are the least stateful endpoints in:
|
||||
## Current Route Status
|
||||
|
||||
- `server/routes/instance/question.ts`
|
||||
- `server/routes/instance/provider.ts`
|
||||
- `server/routes/instance/permission.ts`
|
||||
| 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 |
|
||||
|
||||
Avoid `session.ts`, SSE, websocket, and TUI-facing routes first.
|
||||
## Next PRs
|
||||
|
||||
Recommended first slice:
|
||||
|
||||
- start with `question`
|
||||
- start with `GET /question`
|
||||
- start with `POST /question/:requestID/reply`
|
||||
|
||||
Why `question` first:
|
||||
|
||||
- already JSON-only
|
||||
- already delegates into an Effect service
|
||||
- proves list + mutation + params + payload + OpenAPI in one small slice
|
||||
- avoids the harder streaming and middleware cases
|
||||
|
||||
### 3. Reuse existing services
|
||||
|
||||
Do not re-architect business logic during the HTTP migration. `HttpApi` handlers should call the same Effect services already used by the Hono handlers.
|
||||
|
||||
### 4. Bridge into Hono behind a feature flag
|
||||
|
||||
The `HttpApi` routes are bridged into the Hono server via `HttpRouter.toWebHandler` with a shared `memoMap`. This means:
|
||||
|
||||
- one process, one port — no separate server
|
||||
- the Effect handler shares layer instances with `AppRuntime` (same `Question.Service`, etc.)
|
||||
- Effect middleware handles auth and instance lookup independently from Hono middleware
|
||||
- Hono's `.all()` catch-all intercepts matching paths before the Hono route handlers
|
||||
|
||||
The bridge is gated behind `OPENCODE_EXPERIMENTAL_HTTPAPI` (or `OPENCODE_EXPERIMENTAL`). When the flag is off (default), all requests go through the original Hono handlers unchanged.
|
||||
|
||||
```ts
|
||||
// in instance/index.ts
|
||||
if (Flag.OPENCODE_EXPERIMENTAL_HTTPAPI) {
|
||||
const handler = ExperimentalHttpApiServer.webHandler().handler
|
||||
app.all("/question", (c) => handler(c.req.raw)).all("/question/*", (c) => handler(c.req.raw))
|
||||
}
|
||||
```
|
||||
|
||||
The Hono route handlers are always registered (after the bridge) so `hono-openapi` generates the OpenAPI spec entries that feed SDK codegen. When the flag is on, these handlers are dead code — the `.all()` bridge matches first.
|
||||
|
||||
### 5. Observability
|
||||
|
||||
The `webHandler` provides `Observability.layer` via `Layer.provideMerge`. Since the `memoMap` is shared with `AppRuntime`, the tracing provider is deduplicated — no extra initialization cost.
|
||||
|
||||
This gives:
|
||||
|
||||
- **spans**: `Effect.fn("QuestionHttpApi.list")` etc. appear in traces alongside service-layer spans
|
||||
- **HTTP logs**: `HttpMiddleware.logger` emits structured `Effect.log` entries with `http.method`, `http.url`, `http.status` annotations, flowing to motel via `OtlpLogger`
|
||||
|
||||
### 6. Migrate JSON route groups gradually
|
||||
|
||||
As each route group is ported to `HttpApi`:
|
||||
|
||||
1. add `.get(...)` / `.post(...)` bridge entries to the flag block in `server/routes/instance/index.ts`
|
||||
2. for partial ports (e.g. only `GET /provider/auth`), bridge only the specific path
|
||||
3. keep the legacy Hono route registered behind it for OpenAPI / SDK generation until the spec pipeline changes
|
||||
4. verify SDK output is unchanged
|
||||
|
||||
Leave streaming-style endpoints on Hono until there is a clear reason to move them.
|
||||
|
||||
## Schema rule for HttpApi work
|
||||
|
||||
Every `HttpApi` slice should follow `specs/effect/schema.md` and the Schema -> Zod interop rule in `specs/effect/migration.md`.
|
||||
|
||||
Default rule:
|
||||
|
||||
- Effect Schema owns the type
|
||||
- `.zod` exists only as a compatibility surface
|
||||
- do not introduce a new hand-written Zod schema for a type that is already migrating to Effect Schema
|
||||
|
||||
Practical implication for `HttpApi` migration:
|
||||
|
||||
- if a route boundary already depends on a shared DTO, ID, input, output, or tagged error, migrate that model to Effect Schema first or in the same change
|
||||
- if an existing Hono route or tool still needs Zod, derive it with `@/util/effect-zod`
|
||||
- avoid maintaining parallel Zod and Effect definitions for the same request or response type
|
||||
|
||||
Ordering for a route-group migration:
|
||||
|
||||
1. move implicated shared `schema.ts` leaf types to Effect Schema first
|
||||
2. move exported `Info` / `Input` / `Output` route DTOs to Effect Schema
|
||||
3. move tagged route-facing errors to `Schema.TaggedErrorClass` where needed
|
||||
4. switch existing Zod boundary validators to derived `.zod`
|
||||
5. define the `HttpApi` contract from the canonical Effect schemas
|
||||
6. regenerate the SDK (`./packages/sdk/js/script/build.ts`) and verify zero diff against `dev`
|
||||
|
||||
SDK shape rule:
|
||||
|
||||
- every schema migration must preserve the generated SDK output byte-for-byte **unless the new ref is intentional** (see Schema.Class vs Schema.Struct below)
|
||||
- if an unintended diff appears in `packages/sdk/js/src/v2/gen/types.gen.ts`, the migration introduced an unintended API surface change — fix it before merging
|
||||
|
||||
### Schema.Class vs Schema.Struct
|
||||
|
||||
The pattern choice determines whether a schema becomes a **named** export in the SDK or stays **anonymous inline**.
|
||||
|
||||
**Schema.Class** emits a named `$ref` in OpenAPI via its identifier → produces a named `export type Foo = ...` in `types.gen.ts`:
|
||||
|
||||
```ts
|
||||
export class Info extends Schema.Class<Info>("FooConfig")({ ... }) {
|
||||
static readonly zod = zod(this)
|
||||
}
|
||||
```
|
||||
|
||||
**Schema.Struct** stays anonymous and is inlined everywhere it is referenced:
|
||||
|
||||
```ts
|
||||
export const Info = Schema.Struct({ ... }).pipe(
|
||||
withStatics((s) => ({ zod: zod(s) })),
|
||||
)
|
||||
export type Info = Schema.Schema.Type<typeof Info>
|
||||
```
|
||||
|
||||
When to use each:
|
||||
|
||||
- Use **Schema.Class** when:
|
||||
- the original Zod had `.meta({ ref: ... })` (preserve the existing named SDK type byte-for-byte)
|
||||
- the schema is a top-level endpoint request or response (SDK consumers benefit from a stable importable name)
|
||||
- Use **Schema.Struct** when:
|
||||
- the type is only used as a nested field inside another named schema
|
||||
- the original Zod was anonymous and promoting it would bloat SDK types with no import value
|
||||
|
||||
Promoting a previously-anonymous schema to Schema.Class is acceptable when it is top-level or endpoint-facing, but call it out in the PR — it is an additive SDK change (`export type Foo = ...` newly appears) even if it preserves the JSON shape.
|
||||
|
||||
Schemas that are **not** pure objects (enums, unions, records, tuples) cannot use Schema.Class. For those — and for pure-object schemas where handlers populate plain objects rather than class instances — add `.annotate({ identifier: "FooName" })` to get the same named-ref behavior without the `instanceof` requirement:
|
||||
|
||||
```ts
|
||||
export const Action = Schema.Literals(["ask", "allow", "deny"]).annotate({ identifier: "PermissionActionConfig" })
|
||||
```
|
||||
|
||||
Temporary exception:
|
||||
|
||||
- it is acceptable to keep a route-local Zod schema for the first spike only when the type is boundary-local and migrating it would create unrelated churn
|
||||
- if that happens, leave a short note so the type does not become a permanent second source of truth
|
||||
|
||||
## First vertical slice
|
||||
|
||||
The first `HttpApi` spike should be intentionally small and repeatable.
|
||||
|
||||
Chosen slice:
|
||||
|
||||
- group: `question`
|
||||
- endpoints: `GET /question` and `POST /question/:requestID/reply`
|
||||
|
||||
Non-goals:
|
||||
|
||||
- no `session` routes
|
||||
- no SSE or websocket routes
|
||||
- no auth redesign
|
||||
- no broad service refactor
|
||||
|
||||
Behavior rule:
|
||||
|
||||
- preserve current runtime behavior first
|
||||
- treat semantic changes such as introducing new `404` behavior as a separate follow-up unless they are required to make the contract honest
|
||||
|
||||
Add `POST /question/:requestID/reject` only after the first two endpoints work cleanly.
|
||||
|
||||
## Repeatable slice template
|
||||
|
||||
Use the same sequence for each route group.
|
||||
|
||||
1. Pick one JSON-only route group that already mostly delegates into services.
|
||||
2. Identify the shared DTOs, IDs, and errors implicated by that slice.
|
||||
3. Apply the schema migration ordering above so those types are Effect Schema-first.
|
||||
4. Define the `HttpApi` contract separately from the handlers.
|
||||
5. Implement handlers by yielding the existing service from context.
|
||||
6. Mount the new surface in parallel behind the `OPENCODE_EXPERIMENTAL_HTTPAPI` bridge.
|
||||
7. Regenerate the SDK and verify zero diff against `dev` (see SDK shape rule above).
|
||||
8. Add one end-to-end test and one OpenAPI-focused test.
|
||||
9. Compare ergonomics before migrating the next endpoint.
|
||||
|
||||
Rule of thumb:
|
||||
|
||||
- migrate one route group at a time
|
||||
- migrate one or two endpoints first, not the whole file
|
||||
- keep business logic in the existing service
|
||||
- keep the first spike easy to delete if the experiment is not worth continuing
|
||||
|
||||
## Example structure
|
||||
|
||||
Placement rule:
|
||||
|
||||
- keep `HttpApi` code under `src/server`, not `src/effect`
|
||||
- `src/effect` should stay focused on runtimes, layers, instance state, and shared Effect plumbing
|
||||
- place each `HttpApi` slice next to the HTTP boundary it serves
|
||||
- for instance-scoped routes, prefer `src/server/routes/instance/httpapi/*`
|
||||
- if control-plane routes ever migrate, prefer `src/server/routes/control/httpapi/*`
|
||||
|
||||
Suggested file layout for a repeatable spike:
|
||||
|
||||
- `src/server/routes/instance/httpapi/question.ts` — contract and handler layer for one route group
|
||||
- `src/server/routes/instance/httpapi/server.ts` — bridged Effect HTTP layer that composes all groups
|
||||
- route or OpenAPI verification should live alongside the existing server tests; there is no dedicated `question-httpapi` test file on this branch
|
||||
|
||||
Suggested responsibilities:
|
||||
|
||||
- `question.ts` defines the `HttpApi` contract and `HttpApiBuilder.group(...)` handlers
|
||||
- `server.ts` composes all route groups into one `HttpRouter.toWebHandler(...)` bridge with shared middleware (auth, instance lookup)
|
||||
- tests should verify the bridged routes through the normal server surface
|
||||
|
||||
## Example migration shape
|
||||
|
||||
Each route-group spike should follow the same shape.
|
||||
|
||||
### 1. Contract
|
||||
|
||||
- define an experimental `HttpApi`
|
||||
- define one `HttpApiGroup`
|
||||
- define endpoint params, payload, success, and error schemas from canonical Effect schemas
|
||||
- annotate summary, description, and operation ids explicitly so generated docs are stable
|
||||
|
||||
### 2. Handler layer
|
||||
|
||||
- implement with `HttpApiBuilder.group(api, groupName, ...)`
|
||||
- yield the existing Effect service from context
|
||||
- keep handler bodies thin
|
||||
- keep transport mapping at the HTTP boundary only
|
||||
|
||||
### 3. Bridged server
|
||||
|
||||
- the Effect HTTP layer is composed in `httpapi/server.ts`
|
||||
- it is mounted into the Hono app via `HttpRouter.toWebHandler(...)`
|
||||
- routes keep their normal instance paths and are gated by the `OPENCODE_EXPERIMENTAL_HTTPAPI` flag
|
||||
- the legacy Hono handlers stay registered after the bridge so current OpenAPI / SDK generation still works
|
||||
|
||||
### 4. Verification
|
||||
|
||||
- seed real state through the existing service
|
||||
- call the bridged endpoints with the flag enabled
|
||||
- assert that the service behavior is unchanged
|
||||
- assert that the generated OpenAPI contains the migrated paths and schemas
|
||||
|
||||
## Boundary composition
|
||||
|
||||
The Effect `HttpApi` layer owns its own auth and instance middleware, but it is currently mounted inside the existing Hono server.
|
||||
|
||||
### Auth
|
||||
|
||||
- the bridged `HttpApi` layer implements auth as an `HttpApiMiddleware.Service` using `HttpApiSecurity.basic`
|
||||
- each route group's `HttpApi` is wrapped with `.middleware(Authorization)` before being served
|
||||
- this is independent of the Hono auth layer; the current bridge keeps the responsibility local to the `HttpApi` slice
|
||||
|
||||
### Instance and workspace lookup
|
||||
|
||||
- the bridged `HttpApi` layer resolves instance context via an `HttpRouter.middleware` that reads `x-opencode-directory` headers and `directory` query params
|
||||
- this is the Effect equivalent of the Hono `WorkspaceRouterMiddleware`
|
||||
- `HttpApi` handlers yield services from context and assume the correct instance has already been provided
|
||||
|
||||
### Error mapping
|
||||
|
||||
- keep domain and service errors typed in the service layer
|
||||
- declare typed transport errors on the endpoint only when the route can actually return them intentionally
|
||||
- request decoding failures are transport-level `400`s handled by Effect `HttpApi` automatically
|
||||
- storage or lookup failures that are part of the route contract should be declared as typed endpoint errors
|
||||
|
||||
## Exit criteria for the spike
|
||||
|
||||
The first slice is successful if:
|
||||
|
||||
- the bridged endpoints serve correctly through the existing Hono host when the flag is enabled
|
||||
- the handlers reuse the existing Effect service
|
||||
- request decoding and response shapes are schema-defined from canonical Effect schemas
|
||||
- any remaining Zod boundary usage is derived from `.zod` or clearly temporary
|
||||
- OpenAPI is generated from the `HttpApi` contract
|
||||
- the tests are straightforward enough that the next slice feels mechanical
|
||||
|
||||
## Learnings
|
||||
|
||||
### Schema
|
||||
|
||||
- `Schema.Class` works well for route DTOs such as `Question.Request`, `Question.Info`, and `Question.Reply`.
|
||||
- scalar or collection schemas such as `Question.Answer` should stay as schemas and use helpers like `withStatics(...)` instead of being forced into classes.
|
||||
- if an `HttpApi` success schema uses `Schema.Class`, the handler or underlying service needs to return real schema instances rather than plain objects. `Schema.Class`'s Declaration AST enforces `input instanceof self || input.[ClassTypeId]` during encode (see effect-smol `Schema.ts:10479-10484`). Plain objects from zod parse fail with `Expected Foo, got {...}`. This surfaced on `GET /config` where the service returns zod-parsed plain objects and `Config.InfoSchema` referenced `ConfigProvider.Info` (class). The fix was to convert pure-object classes to `Schema.Struct(...).annotate({ identifier: "..." })` — same named SDK `$ref`, no instance requirement. Verified byte-identical `types.gen.ts` vs `dev`.
|
||||
- internal event payloads can stay anonymous when we want to avoid adding extra named OpenAPI component churn for non-route shapes.
|
||||
- `Schema.Class` emits named `$ref` in OpenAPI — only use it for types that already had `.meta({ ref })` in the old Zod schema **and** when the handler/service returns real instances. For schemas that need a named `$ref` but are populated from plain objects, use `Schema.Struct(...).annotate({ identifier: "..." })` instead. Inner/nested types should stay as `Schema.Struct` to avoid SDK shape changes.
|
||||
|
||||
### Integration
|
||||
|
||||
- `HttpRouter.toWebHandler` with the shared `memoMap` from `run-service.ts` cleanly bridges Effect routes into Hono — one process, one port, shared layer instances.
|
||||
- `Observability.layer` must be explicitly provided via `Layer.provideMerge` in the routes layer for OTEL spans and HTTP logs to flow. The `memoMap` deduplicates it with `AppRuntime` — no extra cost.
|
||||
- `HttpMiddleware.logger` (enabled by default when `disableLogger` is not set) emits structured `Effect.log` entries with `http.method`, `http.url`, `http.status` — these flow through `OtlpLogger` to motel.
|
||||
- Hono OpenAPI stubs must remain registered for SDK codegen until the SDK pipeline reads from the Effect OpenAPI spec instead.
|
||||
- the `OPENCODE_EXPERIMENTAL_HTTPAPI` flag gates the bridge at the Hono router level — default off, no behavior change unless opted in.
|
||||
|
||||
## Route inventory
|
||||
|
||||
Status legend:
|
||||
|
||||
- `bridged` - Effect HttpApi slice exists and is bridged into Hono behind the flag
|
||||
- `done` - Effect HttpApi slice exists but not yet bridged
|
||||
- `next` - good near-term candidate
|
||||
- `later` - possible, but not first wave
|
||||
- `defer` - not a good early `HttpApi` target
|
||||
|
||||
Current instance route inventory:
|
||||
|
||||
- `question` - `bridged`
|
||||
endpoints: `GET /question`, `POST /question/:requestID/reply`, `POST /question/:requestID/reject`
|
||||
- `permission` - `bridged`
|
||||
endpoints: `GET /permission`, `POST /permission/:requestID/reply`
|
||||
- `provider` - `bridged`
|
||||
endpoints: `GET /provider`, `GET /provider/auth`, `POST /provider/:providerID/oauth/authorize`, `POST /provider/:providerID/oauth/callback`
|
||||
- `config` - `bridged` (partial)
|
||||
bridged endpoints: `GET /config`, `GET /config/providers`
|
||||
defer `PATCH /config` for now
|
||||
- `project` - `bridged` (partial)
|
||||
bridged endpoints: `GET /project`, `GET /project/current`
|
||||
defer git-init mutation first
|
||||
- `workspace` - `bridged`
|
||||
best small reads: `GET /experimental/workspace/adaptor`, `GET /experimental/workspace`, `GET /experimental/workspace/status`
|
||||
defer create/remove mutations first
|
||||
- `file` - `bridged` (partial)
|
||||
bridged endpoints: `GET /file`, `GET /file/content`, `GET /file/status`
|
||||
defer search endpoints first
|
||||
- `mcp` - `bridged` (partial)
|
||||
bridged endpoints: `GET /mcp`
|
||||
defer interactive OAuth/auth flows first
|
||||
- `session` - `defer`
|
||||
large, stateful, mixes CRUD with prompt/shell/command/share/revert flows and a streaming route
|
||||
- `event` - `defer`
|
||||
SSE only
|
||||
- `global` - `defer`
|
||||
mixed bag with SSE and process-level side effects
|
||||
- `pty` - `defer`
|
||||
websocket-heavy route surface
|
||||
- `tui` - `defer`
|
||||
queue-style UI bridge, weak early `HttpApi` fit
|
||||
|
||||
Recommended near-term sequence:
|
||||
|
||||
1. `workspace` read endpoints (`GET /experimental/workspace/adaptor`, `GET /experimental/workspace`, `GET /experimental/workspace/status`)
|
||||
2. `file` JSON read endpoints
|
||||
3. `mcp` JSON read endpoints
|
||||
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.
|
||||
|
||||
## Checklist
|
||||
|
||||
- [x] add one small spike that defines an `HttpApi` group for a simple JSON route set
|
||||
- [x] use Effect Schema request / response types for that slice
|
||||
- [x] keep the underlying service calls identical to the current handlers
|
||||
- [x] compare generated OpenAPI against the current Hono/OpenAPI setup
|
||||
- [x] document how auth, instance lookup, and error mapping would compose in the new stack
|
||||
- [x] bridge Effect routes into Hono via `toWebHandler` with shared `memoMap`
|
||||
- [x] gate behind `OPENCODE_EXPERIMENTAL_HTTPAPI` flag
|
||||
- [x] verify OTEL spans and HTTP logs flow to motel
|
||||
- [x] bridge question, permission, and provider auth routes
|
||||
- [x] port remaining provider endpoints (`GET /provider`, OAuth mutations)
|
||||
- [x] port `config` providers read endpoint
|
||||
- [x] port `project` read endpoints (`GET /project`, `GET /project/current`)
|
||||
- [x] port `GET /config` full read endpoint
|
||||
- [x] port `workspace` read endpoints
|
||||
- [x] port `file` JSON read endpoints
|
||||
- [x] port `mcp` status read endpoint
|
||||
- [ ] decide when to remove the flag and make Effect routes the default
|
||||
|
||||
## Rule of thumb
|
||||
|
||||
Do not start with the hardest route file.
|
||||
|
||||
If `HttpApi` is adopted here, it should arrive after the handler body is already Effect-native and after the relevant request / response models have moved to Effect Schema.
|
||||
- [x] Add first `HttpApi` JSON route slices.
|
||||
- [x] Bridge selected `HttpApi` routes into Hono behind `OPENCODE_EXPERIMENTAL_HTTPAPI`.
|
||||
- [x] Reuse existing Effect services in handlers.
|
||||
- [x] Provide auth, instance lookup, and observability in the Effect route layer.
|
||||
- [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.
|
||||
- [ ] Port remaining JSON routes.
|
||||
- [ ] Generate SDK/OpenAPI from Effect routes.
|
||||
- [ ] Flip ported JSON routes to default-on with fallback.
|
||||
- [ ] Delete replaced Hono route implementations.
|
||||
- [ ] Replace SSE/websocket/streaming Hono routes with non-Hono implementations.
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import path from "path"
|
||||
import z from "zod"
|
||||
import { AppFileSystem } from "@opencode-ai/shared/filesystem"
|
||||
import { Cause, Context, Effect, Fiber, Layer, Queue, Stream } from "effect"
|
||||
import { Cause, Context, Effect, Fiber, Layer, Queue, Schema, Stream } from "effect"
|
||||
import type { PlatformError } from "effect/PlatformError"
|
||||
import { FetchHttpClient, HttpClient, HttpClientRequest } from "effect/unstable/http"
|
||||
import { ChildProcess } from "effect/unstable/process"
|
||||
@@ -12,6 +11,8 @@ import { Global } from "@/global"
|
||||
import { Log } from "@/util"
|
||||
import { sanitizedProcessEnv } from "@/util/opencode-process"
|
||||
import { which } from "@/util/which"
|
||||
import { zod } from "@/util/effect-zod"
|
||||
import { withStatics } from "@/util/schema"
|
||||
|
||||
const log = Log.create({ service: "ripgrep" })
|
||||
const VERSION = "15.1.0"
|
||||
@@ -25,83 +26,82 @@ const PLATFORM = {
|
||||
"x64-win32": { platform: "x86_64-pc-windows-msvc", extension: "zip" },
|
||||
} as const
|
||||
|
||||
const Stats = z.object({
|
||||
elapsed: z.object({
|
||||
secs: z.number(),
|
||||
nanos: z.number(),
|
||||
human: z.string(),
|
||||
}),
|
||||
searches: z.number(),
|
||||
searches_with_match: z.number(),
|
||||
bytes_searched: z.number(),
|
||||
bytes_printed: z.number(),
|
||||
matched_lines: z.number(),
|
||||
matches: z.number(),
|
||||
const TimeStats = Schema.Struct({
|
||||
secs: Schema.Number,
|
||||
nanos: Schema.Number,
|
||||
human: Schema.String,
|
||||
})
|
||||
|
||||
const Begin = z.object({
|
||||
type: z.literal("begin"),
|
||||
data: z.object({
|
||||
path: z.object({
|
||||
text: z.string(),
|
||||
}),
|
||||
const Stats = Schema.Struct({
|
||||
elapsed: TimeStats,
|
||||
searches: Schema.Number,
|
||||
searches_with_match: Schema.Number,
|
||||
bytes_searched: Schema.Number,
|
||||
bytes_printed: Schema.Number,
|
||||
matched_lines: Schema.Number,
|
||||
matches: Schema.Number,
|
||||
})
|
||||
|
||||
const PathText = Schema.Struct({
|
||||
text: Schema.String,
|
||||
})
|
||||
|
||||
const Begin = Schema.Struct({
|
||||
type: Schema.Literal("begin"),
|
||||
data: Schema.Struct({
|
||||
path: PathText,
|
||||
}),
|
||||
})
|
||||
|
||||
export const Match = z.object({
|
||||
type: z.literal("match"),
|
||||
data: z.object({
|
||||
path: z.object({
|
||||
text: z.string(),
|
||||
}),
|
||||
lines: z.object({
|
||||
text: z.string(),
|
||||
}),
|
||||
line_number: z.number(),
|
||||
absolute_offset: z.number(),
|
||||
submatches: z.array(
|
||||
z.object({
|
||||
match: z.object({
|
||||
text: z.string(),
|
||||
}),
|
||||
start: z.number(),
|
||||
end: z.number(),
|
||||
export const SearchMatch = Schema.Struct({
|
||||
path: PathText,
|
||||
lines: Schema.Struct({
|
||||
text: Schema.String,
|
||||
}),
|
||||
line_number: Schema.Number,
|
||||
absolute_offset: Schema.Number,
|
||||
submatches: Schema.Array(
|
||||
Schema.Struct({
|
||||
match: Schema.Struct({
|
||||
text: Schema.String,
|
||||
}),
|
||||
),
|
||||
}),
|
||||
start: Schema.Number,
|
||||
end: Schema.Number,
|
||||
}),
|
||||
),
|
||||
}).pipe(withStatics((s) => ({ zod: zod(s) })))
|
||||
|
||||
export const Match = Schema.Struct({
|
||||
type: Schema.Literal("match"),
|
||||
data: SearchMatch,
|
||||
})
|
||||
|
||||
const End = z.object({
|
||||
type: z.literal("end"),
|
||||
data: z.object({
|
||||
path: z.object({
|
||||
text: z.string(),
|
||||
}),
|
||||
binary_offset: z.number().nullable(),
|
||||
const End = Schema.Struct({
|
||||
type: Schema.Literal("end"),
|
||||
data: Schema.Struct({
|
||||
path: PathText,
|
||||
binary_offset: Schema.NullOr(Schema.Number),
|
||||
stats: Stats,
|
||||
}),
|
||||
})
|
||||
|
||||
const Summary = z.object({
|
||||
type: z.literal("summary"),
|
||||
data: z.object({
|
||||
elapsed_total: z.object({
|
||||
human: z.string(),
|
||||
nanos: z.number(),
|
||||
secs: z.number(),
|
||||
}),
|
||||
const Summary = Schema.Struct({
|
||||
type: Schema.Literal("summary"),
|
||||
data: Schema.Struct({
|
||||
elapsed_total: TimeStats,
|
||||
stats: Stats,
|
||||
}),
|
||||
})
|
||||
|
||||
const Result = z.union([Begin, Match, End, Summary])
|
||||
const Result = Schema.Union([Begin, Match, End, Summary])
|
||||
const decodeResult = Schema.decodeUnknownEffect(Schema.fromJsonString(Result))
|
||||
|
||||
export type Result = z.infer<typeof Result>
|
||||
export type Match = z.infer<typeof Match>
|
||||
export type Result = Schema.Schema.Type<typeof Result>
|
||||
export type Match = Schema.Schema.Type<typeof Match>
|
||||
export type Item = Match["data"]
|
||||
export type Begin = z.infer<typeof Begin>
|
||||
export type End = z.infer<typeof End>
|
||||
export type Summary = z.infer<typeof Summary>
|
||||
export type Begin = Schema.Schema.Type<typeof Begin>
|
||||
export type End = Schema.Schema.Type<typeof End>
|
||||
export type Summary = Schema.Schema.Type<typeof Summary>
|
||||
export type Row = Match["data"]
|
||||
|
||||
export interface SearchResult {
|
||||
@@ -187,10 +187,7 @@ function row(data: Row): Row {
|
||||
}
|
||||
|
||||
function parse(line: string) {
|
||||
return Effect.try({
|
||||
try: () => Result.parse(JSON.parse(line)),
|
||||
catch: (cause) => new Error("invalid ripgrep output", { cause }),
|
||||
})
|
||||
return decodeResult(line).pipe(Effect.mapError((cause) => new Error("invalid ripgrep output", { cause })))
|
||||
}
|
||||
|
||||
function fail(queue: Queue.Queue<string, PlatformError | Error | Cause.Done>, err: PlatformError | Error) {
|
||||
|
||||
@@ -14,6 +14,7 @@ export const LANGUAGE_EXTENSIONS: Record<string, string> = {
|
||||
".cc": "cpp",
|
||||
".c++": "cpp",
|
||||
".cs": "csharp",
|
||||
".csx": "csharp",
|
||||
".css": "css",
|
||||
".d": "d",
|
||||
".pas": "pascal",
|
||||
|
||||
@@ -703,31 +703,10 @@ export const Zls: Info = {
|
||||
export const CSharp: Info = {
|
||||
id: "csharp",
|
||||
root: NearestRoot([".slnx", ".sln", ".csproj", "global.json"]),
|
||||
extensions: [".cs"],
|
||||
extensions: [".cs", ".csx"],
|
||||
async spawn(root) {
|
||||
let bin = which("roslyn-language-server")
|
||||
if (!bin) {
|
||||
if (!which("dotnet")) {
|
||||
log.error(".NET SDK is required to install roslyn-language-server")
|
||||
return
|
||||
}
|
||||
|
||||
if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return
|
||||
log.info("installing roslyn-language-server via dotnet tool")
|
||||
const proc = Process.spawn(["dotnet", "tool", "install", "--global", "roslyn-language-server", "--prerelease"], {
|
||||
stdout: "pipe",
|
||||
stderr: "pipe",
|
||||
stdin: "pipe",
|
||||
})
|
||||
const exit = await proc.exited
|
||||
if (exit !== 0) {
|
||||
log.error("Failed to install roslyn-language-server")
|
||||
return
|
||||
}
|
||||
|
||||
bin = path.join(Global.Path.bin, "roslyn-language-server" + (process.platform === "win32" ? ".exe" : ""))
|
||||
log.info(`installed roslyn-language-server`, { bin })
|
||||
}
|
||||
const bin = await getRoslynLanguageServer()
|
||||
if (!bin) return
|
||||
|
||||
return {
|
||||
process: spawn(bin, ["--stdio", "--autoLoadProjects"], {
|
||||
@@ -737,6 +716,135 @@ export const CSharp: Info = {
|
||||
},
|
||||
}
|
||||
|
||||
export const Razor: Info = {
|
||||
id: "razor",
|
||||
root: NearestRoot([".slnx", ".sln", ".csproj", "global.json"]),
|
||||
extensions: [".razor", ".cshtml"],
|
||||
async spawn(root) {
|
||||
const bin = await getRoslynLanguageServer()
|
||||
if (!bin) return
|
||||
|
||||
const razor = await findVscodeRazorExtension()
|
||||
if (!razor) {
|
||||
log.info("VS Code C# extension with Razor support not found, skipping Razor LSP")
|
||||
return
|
||||
}
|
||||
|
||||
log.info("using VS Code Razor extension for roslyn-language-server", { extension: razor.extension })
|
||||
return {
|
||||
process: spawn(
|
||||
bin,
|
||||
[
|
||||
"--stdio",
|
||||
"--autoLoadProjects",
|
||||
`--razorSourceGenerator=${razor.compiler}`,
|
||||
`--razorDesignTimePath=${razor.targets}`,
|
||||
"--extension",
|
||||
razor.extension,
|
||||
],
|
||||
{
|
||||
cwd: root,
|
||||
},
|
||||
),
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
let roslynLanguageServerInstall: Promise<string | undefined> | undefined
|
||||
|
||||
async function getRoslynLanguageServer() {
|
||||
const existing = which("roslyn-language-server")
|
||||
if (existing) return existing
|
||||
|
||||
const global = await roslynLanguageServerGlobalPath()
|
||||
if (global) return global
|
||||
|
||||
roslynLanguageServerInstall ||= installRoslynLanguageServer().finally(() => {
|
||||
roslynLanguageServerInstall = undefined
|
||||
})
|
||||
return roslynLanguageServerInstall
|
||||
}
|
||||
|
||||
async function installRoslynLanguageServer() {
|
||||
if (!which("dotnet")) {
|
||||
log.error(".NET SDK is required to install roslyn-language-server")
|
||||
return
|
||||
}
|
||||
|
||||
if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return
|
||||
log.info("installing roslyn-language-server via dotnet tool")
|
||||
const proc = Process.spawn(["dotnet", "tool", "install", "--global", "roslyn-language-server", "--prerelease"], {
|
||||
stdout: "pipe",
|
||||
stderr: "pipe",
|
||||
stdin: "pipe",
|
||||
})
|
||||
const exit = await proc.exited
|
||||
if (exit !== 0) {
|
||||
log.error("Failed to install roslyn-language-server")
|
||||
return
|
||||
}
|
||||
|
||||
const resolved = which("roslyn-language-server")
|
||||
if (resolved) {
|
||||
log.info(`installed roslyn-language-server`, { bin: resolved })
|
||||
return resolved
|
||||
}
|
||||
|
||||
const global = await roslynLanguageServerGlobalPath()
|
||||
if (global) {
|
||||
log.info(`installed roslyn-language-server`, { bin: global })
|
||||
return global
|
||||
}
|
||||
|
||||
log.error("Installed roslyn-language-server but could not resolve executable")
|
||||
}
|
||||
|
||||
async function roslynLanguageServerGlobalPath() {
|
||||
const bin = path.join(
|
||||
process.env.DOTNET_CLI_HOME ?? os.homedir(),
|
||||
".dotnet",
|
||||
"tools",
|
||||
"roslyn-language-server" + (process.platform === "win32" ? ".cmd" : ""),
|
||||
)
|
||||
return (await pathExists(bin)) ? bin : undefined
|
||||
}
|
||||
|
||||
async function findVscodeRazorExtension() {
|
||||
const roots = [
|
||||
process.env.VSCODE_EXTENSIONS,
|
||||
path.join(os.homedir(), ".vscode", "extensions"),
|
||||
path.join(os.homedir(), ".vscode-insiders", "extensions"),
|
||||
path.join(os.homedir(), ".vscode-server", "extensions"),
|
||||
path.join(os.homedir(), ".vscode-server-insiders", "extensions"),
|
||||
].filter((item) => item !== undefined)
|
||||
|
||||
for (const root of [...new Set(roots)]) {
|
||||
const entries = await fs.readdir(root, { withFileTypes: true }).catch(() => [])
|
||||
const candidates = await Promise.all(
|
||||
entries
|
||||
.filter((entry) => entry.isDirectory() && entry.name.startsWith("ms-dotnettools.csharp-"))
|
||||
.map(async (entry) => ({
|
||||
path: path.join(root, entry.name, ".razorExtension"),
|
||||
modified: (await fs.stat(path.join(root, entry.name)).catch(() => undefined))?.mtimeMs ?? 0,
|
||||
})),
|
||||
)
|
||||
for (const entry of candidates.sort((a, b) => b.modified - a.modified).map((candidate) => candidate.path)) {
|
||||
const result = {
|
||||
compiler: path.join(entry, "Microsoft.CodeAnalysis.Razor.Compiler.dll"),
|
||||
targets: path.join(entry, "Targets", "Microsoft.NET.Sdk.Razor.DesignTime.targets"),
|
||||
extension: path.join(entry, "Microsoft.VisualStudioCode.RazorExtension.dll"),
|
||||
}
|
||||
if (
|
||||
(await pathExists(result.compiler)) &&
|
||||
(await pathExists(result.targets)) &&
|
||||
(await pathExists(result.extension))
|
||||
) {
|
||||
return result
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const FSharp: Info = {
|
||||
id: "fsharp",
|
||||
root: NearestRoot([".slnx", ".sln", ".fsproj", "global.json"]),
|
||||
|
||||
@@ -390,6 +390,16 @@ export async function CodexAuthPlugin(input: PluginInput): Promise<Hooks> {
|
||||
output: 0,
|
||||
cache: { read: 0, write: 0 },
|
||||
}
|
||||
|
||||
// gpt-5.5 models temporarily have restricted context window size for codex plans
|
||||
if (model.id.includes("gpt-5.5")) {
|
||||
model.limit = {
|
||||
context: 400_000,
|
||||
//@ts-expect-error incorrect type for v1 sdk but works
|
||||
input: 272_000,
|
||||
output: 128_000,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
|
||||
@@ -21,7 +21,7 @@ export const FileRoutes = lazy(() =>
|
||||
description: "Matches",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(Ripgrep.Match.shape.data.array()),
|
||||
schema: resolver(Ripgrep.SearchMatch.zod.array()),
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Instance } from "@/project/instance"
|
||||
import * as InstanceState from "@/effect/instance-state"
|
||||
import { Project } from "@/project"
|
||||
import { Effect, Layer, Schema } from "effect"
|
||||
import { HttpApi, HttpApiBuilder, HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
|
||||
@@ -54,7 +54,7 @@ export const projectHandlers = Layer.unwrap(
|
||||
})
|
||||
|
||||
const current = Effect.fn("ProjectHttpApi.current")(function* () {
|
||||
return Instance.project
|
||||
return (yield* InstanceState.context).project
|
||||
})
|
||||
|
||||
return HttpApiBuilder.group(ProjectApi, "project", (handlers) =>
|
||||
|
||||
@@ -787,6 +787,7 @@ NOTE: At any point in time through this workflow you should feel free to ask the
|
||||
const shellName = (
|
||||
process.platform === "win32" ? path.win32.basename(sh, ".exe") : path.basename(sh)
|
||||
).toLowerCase()
|
||||
const cwd = ctx.directory
|
||||
const invocations: Record<string, { args: string[] }> = {
|
||||
nu: { args: ["-c", input.command] },
|
||||
fish: { args: ["-c", input.command] },
|
||||
@@ -795,12 +796,13 @@ NOTE: At any point in time through this workflow you should feel free to ask the
|
||||
"-l",
|
||||
"-c",
|
||||
`
|
||||
__oc_cwd=$PWD
|
||||
[[ -f ~/.zshenv ]] && source ~/.zshenv >/dev/null 2>&1 || true
|
||||
[[ -f "\${ZDOTDIR:-$HOME}/.zshrc" ]] && source "\${ZDOTDIR:-$HOME}/.zshrc" >/dev/null 2>&1 || true
|
||||
cd "$__oc_cwd"
|
||||
cd -- "$1"
|
||||
eval ${JSON.stringify(input.command)}
|
||||
`,
|
||||
"opencode",
|
||||
cwd,
|
||||
],
|
||||
},
|
||||
bash: {
|
||||
@@ -808,12 +810,13 @@ NOTE: At any point in time through this workflow you should feel free to ask the
|
||||
"-l",
|
||||
"-c",
|
||||
`
|
||||
__oc_cwd=$PWD
|
||||
shopt -s expand_aliases
|
||||
[[ -f ~/.bashrc ]] && source ~/.bashrc >/dev/null 2>&1 || true
|
||||
cd "$__oc_cwd"
|
||||
cd -- "$1"
|
||||
eval ${JSON.stringify(input.command)}
|
||||
`,
|
||||
"opencode",
|
||||
cwd,
|
||||
],
|
||||
},
|
||||
cmd: { args: ["/c", input.command] },
|
||||
@@ -823,7 +826,6 @@ NOTE: At any point in time through this workflow you should feel free to ask the
|
||||
}
|
||||
|
||||
const args = (invocations[shellName] ?? invocations[""]).args
|
||||
const cwd = ctx.directory
|
||||
const shellEnv = yield* plugin.trigger(
|
||||
"shell.env",
|
||||
{ cwd, sessionID: input.sessionID, callID: part.callID },
|
||||
|
||||
@@ -58,7 +58,7 @@ Git Safety Protocol:
|
||||
- NEVER skip hooks (--no-verify, --no-gpg-sign, etc) unless the user explicitly requests it
|
||||
- NEVER run force push to main/master, warn the user if they request it
|
||||
- Avoid git commit --amend. ONLY use --amend when ALL conditions are met:
|
||||
(1) User explicitly requested amend, OR commit SUCCEEDED but pre-commit hook auto-modified files that need including
|
||||
(1) User explicitly requested amend, OR the commit succeeded and pre-commit hooks auto-modified files that need including — verify by checking `git log` that HEAD is the new commit before amending
|
||||
(2) HEAD commit was created by you in this conversation (verify: git log -1 --format='%an %ae')
|
||||
(3) Commit has NOT been pushed to remote (verify: git status shows "Your branch is ahead")
|
||||
- CRITICAL: If commit FAILED or was REJECTED by hook, NEVER amend - fix the issue and create a NEW commit
|
||||
|
||||
@@ -0,0 +1,134 @@
|
||||
import { afterEach, describe, expect, test } from "bun:test"
|
||||
import type { UpgradeWebSocket } from "hono/ws"
|
||||
import { Flag } from "../../src/flag/flag"
|
||||
import { Instance } from "../../src/project/instance"
|
||||
import { InstanceRoutes } from "../../src/server/routes/instance"
|
||||
import { FilePaths } from "../../src/server/routes/instance/httpapi/file"
|
||||
import { Log } from "../../src/util"
|
||||
import { resetDatabase } from "../fixture/db"
|
||||
import { tmpdir } from "../fixture/fixture"
|
||||
|
||||
void Log.init({ print: false })
|
||||
|
||||
const original = {
|
||||
OPENCODE_EXPERIMENTAL_HTTPAPI: Flag.OPENCODE_EXPERIMENTAL_HTTPAPI,
|
||||
OPENCODE_SERVER_PASSWORD: Flag.OPENCODE_SERVER_PASSWORD,
|
||||
OPENCODE_SERVER_USERNAME: Flag.OPENCODE_SERVER_USERNAME,
|
||||
}
|
||||
|
||||
const websocket = (() => () => new Response(null, { status: 501 })) as unknown as UpgradeWebSocket
|
||||
|
||||
function app(input?: { password?: string; username?: string }) {
|
||||
Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = true
|
||||
Flag.OPENCODE_SERVER_PASSWORD = input?.password
|
||||
Flag.OPENCODE_SERVER_USERNAME = input?.username
|
||||
return InstanceRoutes(websocket)
|
||||
}
|
||||
|
||||
function authorization(username: string, password: string) {
|
||||
return `Basic ${Buffer.from(`${username}:${password}`).toString("base64")}`
|
||||
}
|
||||
|
||||
function fileUrl(input?: { directory?: string; token?: string }) {
|
||||
const url = new URL(`http://localhost${FilePaths.content}`)
|
||||
url.searchParams.set("path", "hello.txt")
|
||||
if (input?.directory) url.searchParams.set("directory", input.directory)
|
||||
if (input?.token) url.searchParams.set("auth_token", input.token)
|
||||
return url
|
||||
}
|
||||
|
||||
afterEach(async () => {
|
||||
Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = original.OPENCODE_EXPERIMENTAL_HTTPAPI
|
||||
Flag.OPENCODE_SERVER_PASSWORD = original.OPENCODE_SERVER_PASSWORD
|
||||
Flag.OPENCODE_SERVER_USERNAME = original.OPENCODE_SERVER_USERNAME
|
||||
await Instance.disposeAll()
|
||||
await resetDatabase()
|
||||
})
|
||||
|
||||
describe("HttpApi Hono bridge", () => {
|
||||
test("allows requests when auth is disabled", async () => {
|
||||
await using tmp = await tmpdir({ git: true })
|
||||
await Bun.write(`${tmp.path}/hello.txt`, "hello")
|
||||
|
||||
const response = await app().request(fileUrl(), {
|
||||
headers: {
|
||||
"x-opencode-directory": tmp.path,
|
||||
},
|
||||
})
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
expect(await response.json()).toMatchObject({ content: "hello" })
|
||||
})
|
||||
|
||||
test("provides instance context to bridged handlers", async () => {
|
||||
await using tmp = await tmpdir({ git: true })
|
||||
|
||||
const response = await app().request("/project/current", {
|
||||
headers: {
|
||||
"x-opencode-directory": tmp.path,
|
||||
},
|
||||
})
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
expect(await response.json()).toMatchObject({ worktree: tmp.path })
|
||||
})
|
||||
|
||||
test("requires credentials when auth is enabled", async () => {
|
||||
await using tmp = await tmpdir({ git: true })
|
||||
await Bun.write(`${tmp.path}/hello.txt`, "hello")
|
||||
|
||||
const [missing, bad, good] = await Promise.all([
|
||||
app({ password: "secret" }).request(fileUrl(), {
|
||||
headers: { "x-opencode-directory": tmp.path },
|
||||
}),
|
||||
app({ password: "secret" }).request(fileUrl(), {
|
||||
headers: {
|
||||
authorization: authorization("opencode", "wrong"),
|
||||
"x-opencode-directory": tmp.path,
|
||||
},
|
||||
}),
|
||||
app({ password: "secret" }).request(fileUrl(), {
|
||||
headers: {
|
||||
authorization: authorization("opencode", "secret"),
|
||||
"x-opencode-directory": tmp.path,
|
||||
},
|
||||
}),
|
||||
])
|
||||
|
||||
expect(missing.status).toBe(401)
|
||||
expect(bad.status).toBe(401)
|
||||
expect(good.status).toBe(200)
|
||||
})
|
||||
|
||||
test("accepts auth_token query credentials", async () => {
|
||||
await using tmp = await tmpdir({ git: true })
|
||||
await Bun.write(`${tmp.path}/hello.txt`, "hello")
|
||||
|
||||
const response = await app({ password: "secret" }).request(
|
||||
fileUrl({ token: Buffer.from("opencode:secret").toString("base64") }),
|
||||
{
|
||||
headers: {
|
||||
"x-opencode-directory": tmp.path,
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
})
|
||||
|
||||
test("selects instance from query before directory header", async () => {
|
||||
await using header = await tmpdir({ git: true })
|
||||
await using query = await tmpdir({ git: true })
|
||||
await Bun.write(`${header.path}/hello.txt`, "header")
|
||||
await Bun.write(`${query.path}/hello.txt`, "query")
|
||||
|
||||
const response = await app().request(fileUrl({ directory: query.path }), {
|
||||
headers: {
|
||||
"x-opencode-directory": header.path,
|
||||
},
|
||||
})
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
expect(await response.json()).toMatchObject({ content: "query" })
|
||||
})
|
||||
})
|
||||
@@ -1055,28 +1055,26 @@ unix("shell captures stdout and stderr in completed tool output", () =>
|
||||
)
|
||||
|
||||
unix("shell completes a fast command on the preferred shell", () =>
|
||||
withSh(() =>
|
||||
provideTmpdirInstance(
|
||||
(dir) =>
|
||||
Effect.gen(function* () {
|
||||
const { prompt, run, chat } = yield* boot()
|
||||
const result = yield* prompt.shell({
|
||||
sessionID: chat.id,
|
||||
agent: "build",
|
||||
command: "pwd",
|
||||
})
|
||||
provideTmpdirInstance(
|
||||
(dir) =>
|
||||
Effect.gen(function* () {
|
||||
const { prompt, run, chat } = yield* boot()
|
||||
const result = yield* prompt.shell({
|
||||
sessionID: chat.id,
|
||||
agent: "build",
|
||||
command: "pwd",
|
||||
})
|
||||
|
||||
expect(result.info.role).toBe("assistant")
|
||||
const tool = completedTool(result.parts)
|
||||
if (!tool) return
|
||||
expect(result.info.role).toBe("assistant")
|
||||
const tool = completedTool(result.parts)
|
||||
if (!tool) return
|
||||
|
||||
expect(tool.state.input.command).toBe("pwd")
|
||||
expect(tool.state.output).toContain(dir)
|
||||
expect(tool.state.metadata.output).toContain(dir)
|
||||
yield* run.assertNotBusy(chat.id)
|
||||
}),
|
||||
{ git: true, config: cfg },
|
||||
),
|
||||
expect(tool.state.input.command).toBe("pwd")
|
||||
expect(tool.state.output).toContain(dir)
|
||||
expect(tool.state.metadata.output).toContain(dir)
|
||||
yield* run.assertNotBusy(chat.id)
|
||||
}),
|
||||
{ git: true, config: cfg },
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@@ -16,7 +16,7 @@ OpenCode comes with several built-in LSP servers for popular languages:
|
||||
| astro | .astro | Auto-installs for Astro projects |
|
||||
| bash | .sh, .bash, .zsh, .ksh | Auto-installs bash-language-server |
|
||||
| clangd | .c, .cpp, .cc, .cxx, .c++, .h, .hpp, .hh, .hxx, .h++ | Auto-installs for C/C++ projects |
|
||||
| csharp | .cs | `.NET SDK` installed |
|
||||
| csharp | .cs, .csx | `.NET SDK` installed |
|
||||
| clojure-lsp | .clj, .cljs, .cljc, .edn | `clojure-lsp` command available |
|
||||
| dart | .dart | `dart` command available |
|
||||
| deno | .ts, .tsx, .js, .jsx, .mjs | `deno` command available (auto-detects deno.json/deno.jsonc) |
|
||||
@@ -36,6 +36,7 @@ OpenCode comes with several built-in LSP servers for popular languages:
|
||||
| php intelephense | .php | Auto-installs for PHP projects |
|
||||
| prisma | .prisma | `prisma` command available |
|
||||
| pyright | .py, .pyi | `pyright` dependency installed |
|
||||
| razor | .razor, .cshtml | `.NET SDK` and VS Code C# extension installed |
|
||||
| ruby-lsp (rubocop) | .rb, .rake, .gemspec, .ru | `ruby` and `gem` commands available |
|
||||
| rust | .rs | `rust-analyzer` command available |
|
||||
| sourcekit-lsp | .swift, .objc, .objcpp | `swift` installed (`xcode` on macOS) |
|
||||
|
||||
+77
-43
@@ -57,6 +57,19 @@ function lines(prs: PR[]) {
|
||||
return prs.map((x) => `- #${x.number}: ${x.title}`).join("\n") || "(none)"
|
||||
}
|
||||
|
||||
function group(title: string) {
|
||||
if (process.env.GITHUB_ACTIONS !== "true") {
|
||||
console.log(title)
|
||||
return { [Symbol.dispose]() {} }
|
||||
}
|
||||
console.log(`::group::${title}`)
|
||||
return {
|
||||
[Symbol.dispose]() {
|
||||
console.log("::endgroup::")
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
async function typecheck() {
|
||||
console.log(" Running typecheck...")
|
||||
|
||||
@@ -81,6 +94,39 @@ async function build() {
|
||||
}
|
||||
}
|
||||
|
||||
async function validate() {
|
||||
if (!(await typecheck())) return false
|
||||
if (!(await build())) return false
|
||||
return true
|
||||
}
|
||||
|
||||
async function commitSmokeChanges() {
|
||||
const out = await $`git status --porcelain`.text()
|
||||
if (!out.trim()) {
|
||||
console.log("Smoke check passed")
|
||||
return true
|
||||
}
|
||||
|
||||
try {
|
||||
await $`git add -A`
|
||||
await $`git commit -m "Fix beta integration"`
|
||||
} catch (err) {
|
||||
console.log(`Failed to commit smoke fixes: ${err}`)
|
||||
return false
|
||||
}
|
||||
|
||||
if (!(await validate())) return false
|
||||
|
||||
const left = await $`git status --porcelain`.text()
|
||||
if (!left.trim()) {
|
||||
console.log("Smoke check passed")
|
||||
return true
|
||||
}
|
||||
|
||||
console.log(`Smoke check left uncommitted changes:\n${left}`)
|
||||
return false
|
||||
}
|
||||
|
||||
async function install() {
|
||||
console.log(" Regenerating bun.lock...")
|
||||
|
||||
@@ -143,11 +189,15 @@ async function fix(pr: PR, files: string[], prs: PR[], applied: number[], idx: n
|
||||
}
|
||||
|
||||
async function smoke(prs: PR[], applied: number[]) {
|
||||
console.log("\nRunning final smoke check with opencode...")
|
||||
console.log("\nRunning final smoke check...")
|
||||
|
||||
if (await validate()) return commitSmokeChanges()
|
||||
|
||||
console.log("\nTrying to fix final smoke check with opencode...")
|
||||
|
||||
const done = lines(prs.filter((x) => applied.includes(x.number)))
|
||||
const prompt = [
|
||||
"The beta merge batch is complete.",
|
||||
"The beta merge batch is complete, but the deterministic final smoke check failed.",
|
||||
`Merged PRs on HEAD:\n${done}`,
|
||||
"Run `bun typecheck` at the repo root.",
|
||||
"Run `./script/build.ts --single` in `packages/opencode`.",
|
||||
@@ -162,38 +212,8 @@ async function smoke(prs: PR[], applied: number[]) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (!(await typecheck())) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (!(await build())) {
|
||||
return false
|
||||
}
|
||||
|
||||
const out = await $`git status --porcelain`.text()
|
||||
if (!out.trim()) {
|
||||
console.log("Smoke check passed")
|
||||
return true
|
||||
}
|
||||
|
||||
try {
|
||||
await $`git add -A`
|
||||
await $`git commit -m "Fix beta integration"`
|
||||
} catch (err) {
|
||||
console.log(`Failed to commit smoke fixes: ${err}`)
|
||||
return false
|
||||
}
|
||||
|
||||
if (!(await typecheck())) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (!(await build())) {
|
||||
return false
|
||||
}
|
||||
|
||||
console.log("Smoke check passed")
|
||||
return true
|
||||
if (!(await validate())) return false
|
||||
return commitSmokeChanges()
|
||||
}
|
||||
|
||||
async function main() {
|
||||
@@ -220,8 +240,8 @@ async function main() {
|
||||
const failed: FailedPR[] = []
|
||||
|
||||
for (const [idx, pr] of prs.entries()) {
|
||||
console.log(`\nProcessing PR ${idx + 1}/${prs.length} #${pr.number}: ${pr.title}`)
|
||||
|
||||
console.log()
|
||||
using _ = group(`Processing PR ${idx + 1}/${prs.length} #${pr.number}: ${pr.title}`)
|
||||
console.log(" Fetching PR head...")
|
||||
try {
|
||||
await $`git fetch origin pull/${pr.number}/head:pr/${pr.number}`
|
||||
@@ -294,17 +314,13 @@ async function main() {
|
||||
throw new Error(`${failed.length} PR(s) failed to merge`)
|
||||
}
|
||||
|
||||
if (applied.length > 0) {
|
||||
console.log("\nSkipping final smoke check")
|
||||
}
|
||||
|
||||
console.log("\nChecking if beta branch has changes...")
|
||||
await $`git fetch origin beta`
|
||||
|
||||
const localTree = await $`git rev-parse beta^{tree}`.text()
|
||||
const localTree = (await $`git rev-parse beta^{tree}`.text()).trim()
|
||||
const remoteTrees = (await $`git log origin/dev..origin/beta --format=%T`.text()).split("\n")
|
||||
|
||||
const matchIdx = remoteTrees.indexOf(localTree.trim())
|
||||
const matchIdx = remoteTrees.indexOf(localTree)
|
||||
if (matchIdx !== -1) {
|
||||
if (matchIdx !== 0) {
|
||||
console.log(`Beta branch contains this sync, but additional commits exist after it. Leaving beta branch as is.`)
|
||||
@@ -314,7 +330,25 @@ async function main() {
|
||||
return
|
||||
}
|
||||
|
||||
console.log("Force pushing beta branch...")
|
||||
if (!(await smoke(prs, applied))) throw new Error("Final smoke check failed")
|
||||
|
||||
await $`git fetch origin beta`
|
||||
|
||||
const validatedTree = (await $`git rev-parse beta^{tree}`.text()).trim()
|
||||
const remoteTreesAfterSmoke = (await $`git log origin/dev..origin/beta --format=%T`.text()).split("\n")
|
||||
const matchIdxAfterSmoke = remoteTreesAfterSmoke.indexOf(validatedTree)
|
||||
if (matchIdxAfterSmoke !== -1) {
|
||||
if (matchIdxAfterSmoke !== 0) {
|
||||
console.log(
|
||||
`Beta branch contains this validated sync, but additional commits exist after it. Leaving beta branch as is.`,
|
||||
)
|
||||
} else {
|
||||
console.log("Validated beta branch now matches remote contents, no push needed")
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
console.log("Force pushing validated beta branch...")
|
||||
await $`git push origin beta --force --no-verify`
|
||||
|
||||
console.log("Successfully synced beta branch")
|
||||
|
||||
Reference in New Issue
Block a user