diff --git a/.opencode/skills/effect/SKILL.md b/.opencode/skills/effect/SKILL.md index 78216ab01c..3a44fa88dc 100644 --- a/.opencode/skills/effect/SKILL.md +++ b/.opencode/skills/effect/SKILL.md @@ -28,3 +28,11 @@ Use the current Effect v4 / effect-smol source, not memory or older Effect v2/v3 - In tests, prefer the repo's existing Effect test helpers and live tests for filesystem, git, child process, locks, or timing behavior. - Do not introduce `any`, non-null assertions, unchecked casts, or older Effect APIs just to satisfy types. - Do not answer from memory. Verify against `.opencode/references/effect-smol` or nearby code first. + +## Testing Patterns + +- Use `testEffect(...)` from `packages/opencode/test/lib/effect.ts` for tests that exercise Effect services, layers, runtime context, scoped resources, or platform integrations. +- Use `it.live(...)` for filesystem, git repositories, HTTP servers, sockets, child processes, locks, real time, and other live platform behavior. +- Run tests from package directories such as `packages/opencode`; never run package tests from the repo root. +- Prefer explicit test layers over ad hoc managed runtimes. Keep dependency provisioning visible in the test file. +- Use scoped fixtures and finalizers for resources that must be cleaned up, including temporary directories, flags, databases, fibers, servers, and global state. diff --git a/package.json b/package.json index d4e3deb1e0..7f7fc7c5e9 100644 --- a/package.json +++ b/package.json @@ -7,11 +7,13 @@ "packageManager": "bun@1.3.13", "scripts": { "dev": "bun run --cwd packages/opencode --conditions=browser src/index.ts", - "dev-setup": "bun run --cwd packages/opencode --conditions=browser src/index.ts dev-setup", + "dev:desktop": "bun --cwd packages/desktop-electron dev", + "dev:web": "bun --cwd packages/app dev", + "dev:console": "ulimit -n 10240 2>/dev/null; bun run --cwd packages/console/app dev", "dev:storybook": "bun --cwd packages/storybook storybook", "lint": "oxlint", "typecheck": "bun turbo typecheck", - "postinstall": "bun run --cwd packages/opencode fix-node-pty && bun run script/setup-git.ts", + "postinstall": "bun run --cwd packages/opencode fix-node-pty", "prepare": "husky", "random": "echo 'Random script'", "hello": "echo 'Hello World!'", @@ -31,8 +33,8 @@ "@types/cross-spawn": "6.0.6", "@octokit/rest": "22.0.0", "@hono/zod-validator": "0.4.2", - "@opentui/core": "0.1.105", - "@opentui/solid": "0.1.105", + "@opentui/core": "0.2.2", + "@opentui/solid": "0.2.2", "ulid": "3.0.1", "@kobalte/core": "0.13.11", "@types/luxon": "3.7.1", @@ -74,6 +76,8 @@ "@solidjs/meta": "0.29.4", "@solidjs/router": "0.15.4", "@solidjs/start": "https://pkg.pr.new/@solidjs/start@dfb2020", + "@sentry/solid": "10.36.0", + "@sentry/vite-plugin": "4.6.0", "solid-js": "1.9.12", "vite-plugin-solid": "2.11.10", "@lydell/node-pty": "1.2.0-beta.10" @@ -82,7 +86,6 @@ "devDependencies": { "@actions/artifact": "5.0.1", "@tsconfig/bun": "catalog:", - "@types/bun": "catalog:", "@types/mime-types": "3.0.1", "@typescript/native-preview": "catalog:", "glob": "13.0.5", @@ -93,6 +96,7 @@ "semver": "^7.6.0", "sst": "3.18.10", "turbo": "2.8.13", + "@types/bun": "catalog:", "@changesets/changelog-github": "^0.5.1", "@changesets/cli": "^2.27.10" }, diff --git a/packages/core/package.json b/packages/core/package.json index 4f5b23705d..65dbc39ed8 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -43,7 +43,8 @@ "rotating-file-stream": "3.2.9", "semver": "^7.6.3", "xdg-basedir": "5.1.0", - "zod": "catalog:" + "zod": "catalog:", + "rotating-file-stream": "3.2.9" }, "overrides": { "drizzle-orm": "catalog:" diff --git a/packages/core/src/flag/flag.ts b/packages/core/src/flag/flag.ts index ff01b402d0..aca0259756 100644 --- a/packages/core/src/flag/flag.ts +++ b/packages/core/src/flag/flag.ts @@ -54,7 +54,9 @@ export const Flag = { // Experimental KILO_EXPERIMENTAL, - KILO_EXPERIMENTAL_FILEWATCHER: Config.boolean("KILO_EXPERIMENTAL_FILEWATCHER").pipe(Config.withDefault(false)), + KILO_EXPERIMENTAL_FILEWATCHER: Config.boolean("KILO_EXPERIMENTAL_FILEWATCHER").pipe( + Config.withDefault(false), + ), KILO_EXPERIMENTAL_DISABLE_FILEWATCHER: Config.boolean("KILO_EXPERIMENTAL_DISABLE_FILEWATCHER").pipe( Config.withDefault(false), ), diff --git a/packages/core/src/global.ts b/packages/core/src/global.ts index a514d48d93..1c7003408e 100644 --- a/packages/core/src/global.ts +++ b/packages/core/src/global.ts @@ -5,6 +5,7 @@ import os from "os" import { Context, Effect, Layer } from "effect" import { Flock } from "./util/flock" import { markNoIndex } from "./kilocode/spotlight" // kilocode_change +import { Flag } from "./flag/flag" const app = "kilo" // kilocode_change // kilocode_change start @@ -21,6 +22,7 @@ const cache = path.join(clean(xdgCache)!, app) const config = path.join(clean(xdgConfig)!, app) const state = path.join(clean(xdgState)!, app) // kilocode_change end +const tmp = path.join(os.tmpdir(), app) const paths = { get home() { @@ -32,6 +34,7 @@ const paths = { cache, config, state, + tmp, } export const Path = paths @@ -42,6 +45,7 @@ await Promise.all([ fs.mkdir(Path.data, { recursive: true }), fs.mkdir(Path.config, { recursive: true }), fs.mkdir(Path.state, { recursive: true }), + fs.mkdir(Path.tmp, { recursive: true }), fs.mkdir(Path.log, { recursive: true }), fs.mkdir(Path.bin, { recursive: true }), ]) @@ -58,23 +62,34 @@ export interface Interface { readonly cache: string readonly config: string readonly state: string + readonly tmp: string readonly bin: string readonly log: string } +export function make(input: Partial = {}): Interface { + return { + home: Path.home, + data: Path.data, + cache: Path.cache, + config: Flag.KILO_CONFIG_DIR ?? Path.config, + state: Path.state, + tmp: Path.tmp, + bin: Path.bin, + log: Path.log, + ...input, + } +} + export const layer = Layer.effect( Service, - Effect.gen(function* () { - return Service.of({ - home: Path.home, - data: Path.data, - cache: Path.cache, - config: Path.config, - state: Path.state, - bin: Path.bin, - log: Path.log, - }) - }), + Effect.sync(() => Service.of(make())), ) +export const layerWith = (input: Partial) => + Layer.effect( + Service, + Effect.sync(() => Service.of(make(input))), + ) + export * as Global from "./global" diff --git a/packages/core/src/npm.ts b/packages/core/src/npm.ts index 92e4042768..8dac8faf01 100644 --- a/packages/core/src/npm.ts +++ b/packages/core/src/npm.ts @@ -120,13 +120,17 @@ export const layer = Layer.effect( } })() - if (yield* afs.existsSafe(dir)) { + if (yield* afs.existsSafe(path.join(dir, "node_modules", name))) { return resolveEntryPoint(name, path.join(dir, "node_modules", name)) } const tree = yield* reify({ dir, add: [pkg] }) const first = tree.edgesOut.values().next().value?.to - if (!first) return yield* new InstallFailedError({ add: [pkg], dir }) + if (!first) { + const result = resolveEntryPoint(name, path.join(dir, "node_modules", name)) + if (Option.isSome(result.entrypoint)) return result + return yield* new InstallFailedError({ add: [pkg], dir }) + } return resolveEntryPoint(first.name, first.path) }, Effect.scoped) diff --git a/packages/core/test/fixture/effect-flock-worker.ts b/packages/core/test/fixture/effect-flock-worker.ts index 3dc3ee2c8b..c442a62cf5 100644 --- a/packages/core/test/fixture/effect-flock-worker.ts +++ b/packages/core/test/fixture/effect-flock-worker.ts @@ -18,20 +18,17 @@ function sleep(ms: number) { return new Promise((resolve) => setTimeout(resolve, ms)) } -const msg: Msg = JSON.parse(process.argv[2]!) +const msg: Msg = JSON.parse(process.argv[2]) -const testGlobal = Layer.succeed( - Global.Service, - Global.Service.of({ - home: os.homedir(), - data: os.tmpdir(), - cache: os.tmpdir(), - config: os.tmpdir(), - state: os.tmpdir(), - bin: os.tmpdir(), - log: os.tmpdir(), - }), -) +const testGlobal = Global.layerWith({ + home: os.homedir(), + data: os.tmpdir(), + cache: os.tmpdir(), + config: os.tmpdir(), + state: os.tmpdir(), + bin: os.tmpdir(), + log: os.tmpdir(), +}) const testLayer = EffectFlock.layer.pipe(Layer.provide(testGlobal), Layer.provide(AppFileSystem.defaultLayer)) diff --git a/packages/core/test/global.test.ts b/packages/core/test/global.test.ts new file mode 100644 index 0000000000..4e13e88424 --- /dev/null +++ b/packages/core/test/global.test.ts @@ -0,0 +1,16 @@ +import { describe, expect, test } from "bun:test" +import fs from "fs/promises" +import os from "os" +import path from "path" +import { Global } from "@opencode-ai/core/global" + +describe("global paths", () => { + test("tmp path is under the system temp directory", () => { + expect(Global.Path.tmp).toBe(path.join(os.tmpdir(), "opencode")) + expect(Global.make().tmp).toBe(Global.Path.tmp) + }) + + test("tmp path is created on module load", async () => { + expect((await fs.stat(Global.Path.tmp)).isDirectory()).toBe(true) + }) +}) diff --git a/packages/core/test/npm.test.ts b/packages/core/test/npm.test.ts index 3e94a08692..3d0767aaff 100644 --- a/packages/core/test/npm.test.ts +++ b/packages/core/test/npm.test.ts @@ -1,7 +1,12 @@ import fs from "fs/promises" import path from "path" import { describe, expect, test } from "bun:test" +import { NodeFileSystem } from "@effect/platform-node" +import { Effect, Layer, Option } from "effect" +import { AppFileSystem } from "@opencode-ai/core/filesystem" +import { Global } from "@opencode-ai/core/global" import { Npm } from "@opencode-ai/core/npm" +import { EffectFlock } from "@opencode-ai/core/util/effect-flock" import { tmpdir } from "./fixture/tmpdir" const win = process.platform === "win32" @@ -15,6 +20,14 @@ const writePackage = (dir: string, pkg: Record) => }), ) +const npmLayer = (cache: string) => + Npm.layer.pipe( + Layer.provide(EffectFlock.layer), + Layer.provide(AppFileSystem.layer), + Layer.provide(Global.layerWith({ cache, state: path.join(cache, "state") })), + Layer.provide(NodeFileSystem.layer), + ) + describe("Npm.sanitize", () => { test("keeps normal scoped package specs unchanged", () => { expect(Npm.sanitize("@opencode/acme")).toBe("@opencode/acme") @@ -29,6 +42,28 @@ describe("Npm.sanitize", () => { }) }) +describe("Npm.add", () => { + test("reifies when package cache directory exists without the package installed", async () => { + await using tmp = await tmpdir() + await fs.mkdir(path.join(tmp.path, "fixture-provider")) + await writePackage(path.join(tmp.path, "fixture-provider"), { + name: "fixture-provider", + main: "index.js", + }) + await Bun.write(path.join(tmp.path, "fixture-provider", "index.js"), "export const fixture = true\n") + + const spec = `fixture-provider@file:${path.join(tmp.path, "fixture-provider")}` + await fs.mkdir(path.join(tmp.path, "cache", "packages", Npm.sanitize(spec)), { recursive: true }) + + const entry = await Effect.gen(function* () { + const npm = yield* Npm.Service + return yield* npm.add(spec) + }).pipe(Effect.scoped, Effect.provide(npmLayer(path.join(tmp.path, "cache"))), Effect.runPromise) + + expect(Option.isSome(entry.entrypoint)).toBe(true) + }) +}) + describe("Npm.install", () => { test("respects omit from project .npmrc", async () => { await using tmp = await tmpdir() diff --git a/packages/core/test/util/effect-flock.test.ts b/packages/core/test/util/effect-flock.test.ts index 9e8bc24ace..76cee4f8e0 100644 --- a/packages/core/test/util/effect-flock.test.ts +++ b/packages/core/test/util/effect-flock.test.ts @@ -93,18 +93,15 @@ async function waitForFile(file: string, timeout = 3_000) { // Test layer // --------------------------------------------------------------------------- -const testGlobal = Layer.succeed( - Global.Service, - Global.Service.of({ - home: os.homedir(), - data: os.tmpdir(), - cache: os.tmpdir(), - config: os.tmpdir(), - state: os.tmpdir(), - bin: os.tmpdir(), - log: os.tmpdir(), - }), -) +const testGlobal = Global.layerWith({ + home: os.homedir(), + data: os.tmpdir(), + cache: os.tmpdir(), + config: os.tmpdir(), + state: os.tmpdir(), + bin: os.tmpdir(), + log: os.tmpdir(), +}) const testLayer = EffectFlock.layer.pipe(Layer.provide(testGlobal), Layer.provide(AppFileSystem.defaultLayer)) diff --git a/packages/core/tsconfig.json b/packages/core/tsconfig.json index 160c631887..4bb417f5b4 100644 --- a/packages/core/tsconfig.json +++ b/packages/core/tsconfig.json @@ -3,13 +3,6 @@ "extends": "@tsconfig/bun/tsconfig.json", "compilerOptions": { "noUncheckedIndexedAccess": false, - "types": ["bun"], - "plugins": [ - { - "name": "@effect/language-service", - "transform": "@effect/language-service/transform", - "namespaceImportPackages": ["effect", "@effect/*"] - } - ] + "types": ["bun"] } } diff --git a/packages/extensions/zed/extension.toml b/packages/extensions/zed/extension.toml index b7679756f2..93f06c8ee2 100644 --- a/packages/extensions/zed/extension.toml +++ b/packages/extensions/zed/extension.toml @@ -1,7 +1,7 @@ id = "kilo" name = "Kilo" description = "The open source coding agent." -version = "7.2.42" +version = "1.14.33" schema_version = 1 authors = ["Anomaly"] repository = "https://github.com/Kilo-Org/kilocode" @@ -11,26 +11,26 @@ name = "Kilo" icon = "./icons/opencode.svg" [agent_servers.opencode.targets.darwin-aarch64] -archive = "https://github.com/Kilo-Org/kilocode/releases/download/v7.2.42/opencode-darwin-arm64.zip" +archive = "https://github.com/Kilo-Org/kilocode/releases/download/v1.14.33/opencode-darwin-arm64.zip" cmd = "./opencode" args = ["acp"] [agent_servers.opencode.targets.darwin-x86_64] -archive = "https://github.com/Kilo-Org/kilocode/releases/download/v7.2.42/opencode-darwin-x64.zip" +archive = "https://github.com/Kilo-Org/kilocode/releases/download/v1.14.33/opencode-darwin-x64.zip" cmd = "./opencode" args = ["acp"] [agent_servers.opencode.targets.linux-aarch64] -archive = "https://github.com/Kilo-Org/kilocode/releases/download/v7.2.42/opencode-linux-arm64.tar.gz" +archive = "https://github.com/Kilo-Org/kilocode/releases/download/v1.14.33/opencode-linux-arm64.tar.gz" cmd = "./opencode" args = ["acp"] [agent_servers.opencode.targets.linux-x86_64] -archive = "https://github.com/Kilo-Org/kilocode/releases/download/v7.2.42/opencode-linux-x64.tar.gz" +archive = "https://github.com/Kilo-Org/kilocode/releases/download/v1.14.33/opencode-linux-x64.tar.gz" cmd = "./opencode" args = ["acp"] [agent_servers.opencode.targets.windows-x86_64] -archive = "https://github.com/Kilo-Org/kilocode/releases/download/v7.2.42/opencode-windows-x64.zip" +archive = "https://github.com/Kilo-Org/kilocode/releases/download/v1.14.33/opencode-windows-x64.zip" cmd = "./opencode.exe" args = ["acp"] diff --git a/packages/kilo-gateway/src/server/routes.ts b/packages/kilo-gateway/src/server/routes.ts index 0282e2f94b..e2738dcbbb 100644 --- a/packages/kilo-gateway/src/server/routes.ts +++ b/packages/kilo-gateway/src/server/routes.ts @@ -32,7 +32,7 @@ interface KiloRoutesDeps extends ImportDeps { Auth: Auth ModelCache: ModelCache z: Z - Instance: ImportDeps["Instance"] & { disposeAll(): Promise } + InstanceStore: { disposeAllInstances(): Promise } } const FIM_TIMEOUT_MS = 30_000 @@ -79,6 +79,7 @@ export function createKiloRoutes(deps: KiloRoutesDeps) { SessionCreatedEvent, Identifier, ModelCache, + InstanceStore, } = deps const Organization = z.object({ @@ -210,7 +211,7 @@ export function createKiloRoutes(deps: KiloRoutesDeps) { ModelCache.clear("kilo") clearModesCache() - await Instance.disposeAll() + await InstanceStore.disposeAllInstances() return c.json(true) }, diff --git a/packages/opencode/package.json b/packages/opencode/package.json index cf6dd8c913..4e94df6920 100644 --- a/packages/opencode/package.json +++ b/packages/opencode/package.json @@ -6,7 +6,6 @@ "license": "MIT", "private": true, "scripts": { - "prepare": "effect-language-service patch || true", "typecheck": "tsgo --noEmit", "test": "bun run script/test-runner.ts", "test:ci": "bun run script/test-runner.ts --ci", diff --git a/packages/opencode/script/build.ts b/packages/opencode/script/build.ts index 6bd15d07d8..7e32932090 100755 --- a/packages/opencode/script/build.ts +++ b/packages/opencode/script/build.ts @@ -53,6 +53,7 @@ console.log(`Loaded ${migrations.length} migrations`) const singleFlag = process.argv.includes("--single") const baselineFlag = process.argv.includes("--baseline") const skipInstall = process.argv.includes("--skip-install") +const sourcemapsFlag = process.argv.includes("--sourcemaps") const plugin = createSolidTransformPlugin() // kilocode_change - packages/app was removed; the web UI embed step is no longer applicable @@ -200,6 +201,7 @@ for (const item of targets) { external: ["node-gyp", ...LanceDBRuntime.external], // kilocode_change format: "esm", minify: true, + sourcemap: sourcemapsFlag ? "linked" : "none", splitting: true, compile: { autoloadBunfig: false, diff --git a/packages/opencode/specs/effect/http-api.md b/packages/opencode/specs/effect/http-api.md index dba8f3b2e4..8a4897ba41 100644 --- a/packages/opencode/specs/effect/http-api.md +++ b/packages/opencode/specs/effect/http-api.md @@ -12,14 +12,16 @@ Plan for replacing instance Hono route implementations with Effect `HttpApi` whi ## Current State -- `KILO_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`. +- `KILO_EXPERIMENTAL_HTTPAPI` selects the backend at server startup. Default is still `hono`. +- `server/backend.ts` picks one of `effect-httpapi` or `hono`; `server.ts` builds either a pure Effect `HttpApi` web handler or the legacy Hono app accordingly. The earlier in-Hono "bridge" model has been replaced by this fork-at-startup. +- Legacy Hono routes remain mounted for the `hono` backend and remain the source for `hono-openapi` SDK generation. +- An Effect `HttpApi` OpenAPI surface exists (`OpenApi.fromApi(PublicApi)` in `cli/cmd/generate.ts --httpapi`, `KILO_SDK_OPENAPI=httpapi` in `packages/sdk/js/script/build.ts`) but is opt-in. The default SDK generation is still Hono. +- `httpapi/public.ts` carries the Hono-compat normalization for the Effect-generated OpenAPI surface (auth scheme strip, request-body required flag, optional `null` arms, `BadRequestError` / `NotFoundError` remap, `$ref` self-cycle fix, `auth_token` query injection). Today's Effect-generated SDK is not byte-identical to the Hono-generated SDK — see Phase 4. +- Auth is centrally configured for the Effect backend via Effect `Config` (`refactor: use Effect config for HttpApi authorization`, `Fix HttpApi raw route authorization`) rather than re-attached in each route module. - 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-kilo-directory`. - `Observability.layer` is provided in the Effect route layer and deduplicated through the shared `memoMap`. +- CORS middleware is wired into both backends (`feat(httpapi): add CORS middleware to instance routes`). ## Migration Rules @@ -122,10 +124,19 @@ Keep large or stateful groups for later: Hono routes cannot be deleted while `hono-openapi` is the source of SDK generation. +Status: the Effect `HttpApi` OpenAPI surface is **implemented and opt-in** (`bun dev generate --httpapi`, `KILO_SDK_OPENAPI=httpapi`). Default SDK generation still uses Hono. `httpapi/public.ts` applies the Hono-compat normalization layer to the Effect output. Diff against the Hono-generated spec still shows real gaps that must be closed before the SDK can flip: + +- Branded-type `pattern` constraints on ID schemas are not propagated to the Effect output (~169 missing). +- Per-property `description` annotations are not propagated through `Schema.Struct` to the Effect output (~107 missing). +- `Event.*` and `SyncEvent.*` component names use dotted form in Hono and PascalCase in Effect (~50 differences, breaks SDK type names). +- Effect's component deduper emits numbered duplicates (`Session9`, `SyncEvent.session.updated.11`) that need a name-collision fix. +- Cosmetic-only diffs (`additionalProperties: false`, `const` vs `enum`, MAX_SAFE_INTEGER `maximum`, `propertyNames`) can be normalized in `public.ts` if they would otherwise change SDK output. + Required before route deletion: -- Generate the public OpenAPI surface from Effect `HttpApi` for ported routes. +- Close the diff above so Effect-generated SDK output matches the Hono-generated SDK output for every retained path. - Keep operation IDs, schemas, status codes, and SDK type names stable unless the change is intentional. +- Flip `packages/sdk/js/script/build.ts` default to `httpapi` and regenerate. - 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. @@ -187,7 +198,7 @@ Use raw Effect HTTP routes where `HttpApi` does not fit. The goal is deleting Ho | `project` | `bridged` | list, current, git init, update | | `file` | `bridged` partial | find text/file/symbol, list/content/status | | `mcp` | `bridged` | status, add, OAuth, connect/disconnect | -| `workspace` | `bridged` | adaptor/list/status/create/remove/session-restore | +| `workspace` | `bridged` | adapter/list/status/create/remove/session-restore | | top-level instance routes | `bridged` | path, vcs, command, agent, skill, lsp, formatter, dispose | | experimental JSON routes | `bridged` | console, tool, worktree list/mutations, global session list, resource list | | `session` | `bridged` | read, lifecycle, prompt, message/part mutations, revert, permission reply | @@ -279,7 +290,7 @@ This checklist tracks bridge parity only. Checked routes are available through t ### Workspace Routes -- [x] `GET /experimental/workspace/adaptor` - list workspace adaptors. +- [x] `GET /experimental/workspace/adapter` - list workspace adapters. - [x] `POST /experimental/workspace` - create workspace. - [x] `GET /experimental/workspace` - list workspaces. - [x] `GET /experimental/workspace/status` - workspace status. @@ -365,25 +376,26 @@ Prefer smaller PRs from here so route behavior and SDK/OpenAPI fallout stays rev 8. [x] Bridge session read routes: list, status, get, children, todo, diff, messages. 9. [x] Bridge session lifecycle mutation routes: create, delete, update, fork, abort. 10. [x] Bridge remaining session mutation and prompt routes. -11. [ ] Replace event SSE with non-Hono Effect HTTP. -12. [x] Replace pty websocket/control routes with non-Hono Effect HTTP. -13. [x] Replace tui bridge routes or explicitly isolate them behind a non-Hono compatibility layer. -14. [ ] Switch OpenAPI/SDK generation to Effect routes and compare SDK output. -15. [ ] Flip ported JSON routes default-on, keep a short fallback, then delete replaced Hono route files. +11. [ ] Replace event SSE with non-Hono Effect HTTP. The Effect backend has a raw Effect HTTP `httpapi/event.ts`; the Hono backend still uses `hono/streaming` `streamSSE`. Either port Hono `/event` to raw Effect HTTP for the fallback window, or skip and delete it together with Hono in step 15. +12. [x] Replace pty websocket/control routes with non-Hono Effect HTTP for the Effect backend. Hono `pty.ts` remains in the Hono backend. +13. [x] Replace tui bridge routes or explicitly isolate them behind a non-Hono compatibility layer for the Effect backend. Hono `tui.ts` remains in the Hono backend. +14. [ ] Switch OpenAPI/SDK generation to Effect routes and compare SDK output. Effect path is implemented and opt-in via `--httpapi` / `KILO_SDK_OPENAPI=httpapi`. Close the schema-shape gaps in `public.ts` (branded `pattern`, per-property `description`, `Event.*` / `SyncEvent.*` naming, dedup collisions), then flip `packages/sdk/js/script/build.ts` default. +15. [ ] Flip `backend.ts` default from `hono` to `effect-httpapi`, keep `KILO_EXPERIMENTAL_HTTPAPI` (or its inverse) as a short fallback flag, then delete replaced Hono route files. ## Checklist - [x] Add first `HttpApi` JSON route slices. -- [x] Bridge selected `HttpApi` routes into Hono behind `KILO_EXPERIMENTAL_HTTPAPI`. +- [x] Bridge selected `HttpApi` routes behind `KILO_EXPERIMENTAL_HTTPAPI`. (Now backend-fork-at-startup rather than in-Hono path mounting.) - [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] Centralize auth via Effect `Config` for the Effect backend. - [x] Support `auth_token` as a query security scheme. - [x] Add bridge-level auth and instance tests. - [x] Complete exact Hono route inventory. - [x] Resolve implemented-but-unmounted route groups. - [x] Port remaining top-level JSON reads. -- [ ] Generate SDK/OpenAPI from Effect routes. -- [ ] Flip ported JSON routes to default-on with fallback. +- [x] Implement Effect `HttpApi` OpenAPI generation behind `--httpapi` / `KILO_SDK_OPENAPI=httpapi`. +- [ ] Close Effect-vs-Hono OpenAPI schema-shape gaps and flip the SDK generator default. +- [ ] Flip the runtime backend default from `hono` to `effect-httpapi`, with a short fallback flag. - [ ] Delete replaced Hono route implementations. -- [ ] Replace SSE/websocket/streaming Hono routes with non-Hono implementations. +- [ ] Replace SSE/websocket/streaming Hono routes with non-Hono implementations (or remove with the rest of Hono). diff --git a/packages/opencode/specs/effect/schema.md b/packages/opencode/specs/effect/schema.md index c4f9769224..e755457e61 100644 --- a/packages/opencode/specs/effect/schema.md +++ b/packages/opencode/specs/effect/schema.md @@ -353,7 +353,7 @@ piecewise. - [ ] `src/cli/cmd/tui/event.ts` - [ ] `src/cli/ui.ts` - [ ] `src/command/index.ts` -- [x] `src/control-plane/adaptors/worktree.ts` +- [x] `src/control-plane/adapters/worktree.ts` - [x] `src/control-plane/types.ts` - [x] `src/control-plane/workspace.ts` - [ ] `src/file/index.ts` diff --git a/packages/opencode/src/agent/agent.ts b/packages/opencode/src/agent/agent.ts index 30dc00db9a..39f4350936 100644 --- a/packages/opencode/src/agent/agent.ts +++ b/packages/opencode/src/agent/agent.ts @@ -87,6 +87,7 @@ export const layer = Layer.effect( // kilocode_change start - include global config dirs so agents can read them without prompting const whitelistedDirs = [ Truncate.GLOB, + path.join(Global.Path.tmp, "*"), ...skillDirs.map((dir) => path.join(dir, "*")), path.join(Global.Path.config, "*"), ...KilocodePaths.globalDirs().map((dir) => path.join(dir, "*")), diff --git a/packages/opencode/src/cli/bootstrap.ts b/packages/opencode/src/cli/bootstrap.ts index 2604e703ea..da90ec4033 100644 --- a/packages/opencode/src/cli/bootstrap.ts +++ b/packages/opencode/src/cli/bootstrap.ts @@ -1,17 +1,17 @@ -import { AppRuntime } from "@/effect/app-runtime" -import { InstanceBootstrap } from "../project/bootstrap" import { Instance } from "../project/instance" +import { InstanceStore } from "../project/instance-store" +import { getBootstrapRunEffect } from "../effect/app-runtime" export async function bootstrap(directory: string, cb: () => Promise) { return Instance.provide({ directory, - init: () => AppRuntime.runPromise(InstanceBootstrap), + init: await getBootstrapRunEffect(), fn: async () => { try { const result = await cb() return result } finally { - await Instance.dispose() + await InstanceStore.disposeInstance(Instance.current) } }, }) diff --git a/packages/opencode/src/cli/cmd/export.ts b/packages/opencode/src/cli/cmd/export.ts index 247aa86363..f374e60b57 100644 --- a/packages/opencode/src/cli/cmd/export.ts +++ b/packages/opencode/src/cli/cmd/export.ts @@ -244,10 +244,7 @@ export const ExportCommand = cmd({ output: process.stderr, }) - const sessions = [] - for await (const session of Session.list()) { - sessions.push(session) - } + const sessions = await AppRuntime.runPromise(Session.Service.use((svc) => svc.list())) if (sessions.length === 0) { prompts.log.error("No sessions found", { diff --git a/packages/opencode/src/cli/cmd/github.ts b/packages/opencode/src/cli/cmd/github.ts index ac2b34e2a9..3ede6b13c5 100644 --- a/packages/opencode/src/cli/cmd/github.ts +++ b/packages/opencode/src/cli/cmd/github.ts @@ -212,7 +212,7 @@ export const GithubInstallCommand = cmd({ const app = await getAppInfo() await installGitHubApp() - const providers = await ModelsDev.get().then((p) => { + const providers = await AppRuntime.runPromise(ModelsDev.Service.use((s) => s.get())).then((p) => { // TODO: add guide for copilot, for now just hide it delete p["github-copilot"] return p diff --git a/packages/opencode/src/cli/cmd/models.ts b/packages/opencode/src/cli/cmd/models.ts index 02814d0522..06c1dfdd42 100644 --- a/packages/opencode/src/cli/cmd/models.ts +++ b/packages/opencode/src/cli/cmd/models.ts @@ -1,19 +1,17 @@ -import type { Argv } from "yargs" -import { Instance } from "../../project/instance" + +import { EOL } from "os" +import { Effect } from "effect" import { Provider } from "@/provider/provider" import { ProviderID } from "../../provider/schema" import { ModelsDev } from "@/provider/models" -import { cmd } from "./cmd" +import { effectCmd, fail } from "../effect-cmd" import { UI } from "../ui" -import { EOL } from "os" -import { AppRuntime } from "@/effect/app-runtime" -import { Effect } from "effect" -export const ModelsCommand = cmd({ +export const ModelsCommand = effectCmd({ command: "models [provider]", describe: "list all available models", - builder: (yargs: Argv) => { - return yargs + builder: (yargs) => + yargs .positional("provider", { describe: "provider ID to filter models by", type: "string", @@ -26,65 +24,46 @@ export const ModelsCommand = cmd({ .option("refresh", { describe: "refresh the models cache from models.dev", type: "boolean", - }) - }, - handler: async (args) => { + }), + handler: Effect.fn("Cli.models")(function* (args) { if (args.refresh) { - await ModelsDev.refresh(true) + yield* ModelsDev.Service.use((s) => s.refresh(true)) UI.println(UI.Style.TEXT_SUCCESS_BOLD + "Models cache refreshed" + UI.Style.TEXT_NORMAL) } - await Instance.provide({ - directory: process.cwd(), - async fn() { - await AppRuntime.runPromise( - Effect.gen(function* () { - const svc = yield* Provider.Service - const providers = yield* svc.list() + const provider = yield* Provider.Service + const providers = yield* provider.list() - const print = (providerID: ProviderID, verbose?: boolean) => { - const provider = providers[providerID] - const sorted = Object.entries(provider.models).sort(([a], [b]) => a.localeCompare(b)) - for (const [modelID, model] of sorted) { - process.stdout.write(`${providerID}/${modelID}`) - process.stdout.write(EOL) - if (verbose) { - process.stdout.write(JSON.stringify(model, null, 2)) - process.stdout.write(EOL) - } - } - } + const print = (providerID: ProviderID, verbose?: boolean) => { + const p = providers[providerID] + const sorted = Object.entries(p.models).sort(([a], [b]) => a.localeCompare(b)) + for (const [modelID, model] of sorted) { + process.stdout.write(`${providerID}/${modelID}`) + process.stdout.write(EOL) + if (verbose) { + process.stdout.write(JSON.stringify(model, null, 2)) + process.stdout.write(EOL) + } + } + } - if (args.provider) { - const providerID = ProviderID.make(args.provider) - const provider = providers[providerID] - if (!provider) { - yield* Effect.sync(() => UI.error(`Provider not found: ${args.provider}`)) - return - } + if (args.provider) { + const providerID = ProviderID.make(args.provider) + if (!providers[providerID]) return yield* fail(`Provider not found: ${args.provider}`) + print(providerID, args.verbose) + return + } - yield* Effect.sync(() => print(providerID, args.verbose)) - return - } - - // kilocode_change start - const ids = Object.keys(providers).sort((a, b) => { - const aIsKilo = a === "kilo" || a.startsWith("opencode") - const bIsKilo = b === "kilo" || b.startsWith("opencode") - if (aIsKilo && !bIsKilo) return -1 - if (!aIsKilo && bIsKilo) return 1 - return a.localeCompare(b) - }) - // kilocode_change end - - yield* Effect.sync(() => { - for (const providerID of ids) { - print(ProviderID.make(providerID), args.verbose) - } - }) - }), - ) - }, + // kilocode_change start + const ids = Object.keys(providers).sort((a, b) => { + const aIsKilo = a === "kilo" || a.startsWith("opencode") + const bIsKilo = b === "kilo" || b.startsWith("opencode") + if (aIsKilo && !bIsKilo) return -1 + if (!aIsKilo && bIsKilo) return 1 + return a.localeCompare(b) }) - }, + // kilocode_change end + + for (const providerID of ids) print(ProviderID.make(providerID), args.verbose) + }), }) diff --git a/packages/opencode/src/cli/cmd/providers.ts b/packages/opencode/src/cli/cmd/providers.ts index a81431b16e..fcf91e8c16 100644 --- a/packages/opencode/src/cli/cmd/providers.ts +++ b/packages/opencode/src/cli/cmd/providers.ts @@ -4,6 +4,9 @@ import { cmd } from "./cmd" import * as prompts from "@clack/prompts" import { UI } from "../ui" import { ModelsDev } from "@/provider/models" + +const getModels = () => AppRuntime.runPromise(ModelsDev.Service.use((s) => s.get())) +const refreshModels = () => AppRuntime.runPromise(ModelsDev.Service.use((s) => s.refresh(true))) import { map, pipe, sortBy, values } from "remeda" import path from "path" import os from "os" @@ -156,28 +159,38 @@ async function handlePluginAuth(plugin: { auth: PluginAuth }, provider: string, } if (method.type === "api") { - if (method.authorize) { - const key = await prompts.password({ - message: "Enter your API key", - validate: (x) => (x && x.length > 0 ? undefined : "Required"), - }) - if (prompts.isCancel(key)) throw new UI.CancelledError() + const key = await prompts.password({ + message: "Enter your API key", + validate: (x) => (x && x.length > 0 ? undefined : "Required"), + }) + if (prompts.isCancel(key)) throw new UI.CancelledError() - const result = await method.authorize(inputs) - if (result.type === "failed") { - prompts.log.error("Failed to authorize") - } - if (result.type === "success") { - const saveProvider = result.provider ?? provider - await put(saveProvider, { - type: "api", - key: result.key ?? key, - }) - prompts.log.success("Login successful") - } + const metadata = Object.keys(inputs).length ? { metadata: inputs } : {} + if (!method.authorize) { + await put(provider, { + type: "api", + key, + ...metadata, + }) prompts.outro("Done") return true } + + const result = await method.authorize(inputs) + if (result.type === "failed") { + prompts.log.error("Failed to authorize") + } + if (result.type === "success") { + const saveProvider = result.provider ?? provider + await put(saveProvider, { + type: "api", + key: result.key ?? key, + ...metadata, + }) + prompts.log.success("Login successful") + } + prompts.outro("Done") + return true } return false @@ -241,7 +254,7 @@ export const ProvidersListCommand = cmd({ return Object.entries(yield* auth.all()) }), ) - const database = await ModelsDev.get() + const database = await getModels() for (const [providerID, result] of results) { const name = database[providerID]?.name || providerID @@ -334,14 +347,14 @@ export const ProvidersLoginCommand = cmd({ prompts.outro("Done") return } - await ModelsDev.refresh(true).catch(() => {}) + await refreshModels().catch(() => {}) const config = await AppRuntime.runPromise(Config.Service.use((cfg) => cfg.get())) const disabled = new Set(config.disabled_providers ?? []) const enabled = config.enabled_providers ? new Set(config.enabled_providers) : undefined - const providers = await ModelsDev.get().then((x) => { + const providers = await getModels().then((x) => { const filtered: Record = {} for (const [key, value] of Object.entries(x)) { if ((enabled ? enabled.has(key) : true) && !disabled.has(key)) { @@ -512,7 +525,7 @@ export const ProvidersLogoutCommand = cmd({ prompts.log.error("No credentials found") return } - const database = await ModelsDev.get() + const database = await getModels() const selected = await prompts.select({ message: "Select provider", options: credentials.map(([key, value]) => ({ diff --git a/packages/opencode/src/cli/cmd/remote.ts b/packages/opencode/src/cli/cmd/remote.ts index f3ea09aea1..7d681db7a4 100644 --- a/packages/opencode/src/cli/cmd/remote.ts +++ b/packages/opencode/src/cli/cmd/remote.ts @@ -3,6 +3,7 @@ import { cmd } from "./cmd" import { bootstrap } from "../bootstrap" import { KiloSessions } from "@/kilo-sessions/kilo-sessions" import { Instance } from "@/project/instance" +import { InstanceStore } from "@/project/instance-store" export const RemoteCommand = cmd({ command: "remote", @@ -17,7 +18,7 @@ export const RemoteCommand = cmd({ const shutdown = async () => { try { KiloSessions.disableRemote() - await Instance.dispose() + await InstanceStore.disposeInstance(Instance.current) } finally { abort.abort() } diff --git a/packages/opencode/src/cli/cmd/serve.ts b/packages/opencode/src/cli/cmd/serve.ts index 85f7255f5f..7490016ed7 100644 --- a/packages/opencode/src/cli/cmd/serve.ts +++ b/packages/opencode/src/cli/cmd/serve.ts @@ -2,7 +2,7 @@ import { Server } from "../../server/server" import { cmd } from "./cmd" import { withNetworkOptions, resolveNetworkOptions } from "../network" import { Flag } from "@opencode-ai/core/flag/flag" -import { Instance } from "../../project/instance" // kilocode_change +import { InstanceStore } from "../../project/instance-store" // kilocode_change export const ServeCommand = cmd({ command: "serve", @@ -20,7 +20,7 @@ export const ServeCommand = cmd({ const abort = new AbortController() const shutdown = async () => { try { - await Instance.disposeAll() + await InstanceStore.disposeAllInstances() await server.stop(true) } finally { abort.abort() diff --git a/packages/opencode/src/cli/cmd/session.ts b/packages/opencode/src/cli/cmd/session.ts index 709d7f0849..30a4e6d5ea 100644 --- a/packages/opencode/src/cli/cmd/session.ts +++ b/packages/opencode/src/cli/cmd/session.ts @@ -113,7 +113,9 @@ export const SessionListCommand = cmd({ // kilocode_change start const sessions = args.all ? [...Session.listGlobal({ roots: true, limit: args.maxCount, search: args.search })] - : [...Session.list({ roots: true, limit: args.maxCount, search: args.search })] + : await AppRuntime.runPromise( + Session.Service.use((svc) => svc.list({ roots: true, limit: args.maxCount, search: args.search })), + ) // kilocode_change end // kilocode_change start diff --git a/packages/opencode/src/cli/cmd/tui/app.tsx b/packages/opencode/src/cli/cmd/tui/app.tsx index 0ef6977fb2..b07c4d2462 100644 --- a/packages/opencode/src/cli/cmd/tui/app.tsx +++ b/packages/opencode/src/cli/cmd/tui/app.tsx @@ -140,6 +140,8 @@ export function tui(input: { process.on("exit", resetTerminalState) // kilocode_change const renderer = await createCliRenderer(rendererConfig(input.config)) + // Prewarm palette before ThemeProvider mounts so `system` theme avoids a first-paint fallback flash. + void renderer.getPalette({ size: 16 }).catch(() => undefined) const mode = (await renderer.waitForThemeMode(1000)) ?? "dark" await render(() => { diff --git a/packages/opencode/src/cli/cmd/tui/component/dialog-provider.tsx b/packages/opencode/src/cli/cmd/tui/component/dialog-provider.tsx index e0c443a35e..fef5031678 100644 --- a/packages/opencode/src/cli/cmd/tui/component/dialog-provider.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/dialog-provider.tsx @@ -40,7 +40,7 @@ export function createDialogProviderOptions() { description: KiloProvider.PROVIDER_DESCRIPTIONS[provider.id], // kilocode_change footer: consoleManaged ? sync.data.console_state.activeOrgName : undefined, category: provider.id in PROVIDER_PRIORITY ? "Popular" : "Other", - gutter: connected && onboarded() ? : undefined, + gutter: connected && onboarded() ? () => : undefined, async onSelect() { if (consoleManaged) return diff --git a/packages/opencode/src/cli/cmd/tui/component/dialog-session-list.tsx b/packages/opencode/src/cli/cmd/tui/component/dialog-session-list.tsx index 53bdbfc5dd..f4c38ca89e 100644 --- a/packages/opencode/src/cli/cmd/tui/component/dialog-session-list.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/dialog-session-list.tsx @@ -193,7 +193,7 @@ export function DialogSessionList() { value: x.id, category, footer, - gutter: isWorking ? : undefined, + gutter: isWorking ? () => : undefined, } }) }) diff --git a/packages/opencode/src/cli/cmd/tui/component/dialog-workspace-create.tsx b/packages/opencode/src/cli/cmd/tui/component/dialog-workspace-create.tsx index 79d14ab13f..713f429725 100644 --- a/packages/opencode/src/cli/cmd/tui/component/dialog-workspace-create.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/dialog-workspace-create.tsx @@ -10,7 +10,7 @@ import { errorMessage } from "@/util/error" import { useSDK } from "../context/sdk" import { useToast } from "../ui/toast" -type Adaptor = { +type Adapter = { type: string name: string description: string @@ -108,26 +108,26 @@ export function DialogWorkspaceCreate(props: { onSelect: (workspaceID: string) = const sdk = useSDK() const toast = useToast() const [creating, setCreating] = createSignal() - const [adaptors, setAdaptors] = createSignal() + const [adapters, setAdapters] = createSignal() onMount(() => { dialog.setSize("medium") void (async () => { const dir = sync.path.directory || sdk.directory - const url = new URL("/experimental/workspace/adaptor", sdk.url) + const url = new URL("/experimental/workspace/adapter", sdk.url) if (dir) url.searchParams.set("directory", dir) const res = await sdk .fetch(url) - .then((x) => x.json() as Promise) + .then((x) => x.json() as Promise) .catch(() => undefined) if (!res) { toast.show({ - message: "Failed to load workspace adaptors", + message: "Failed to load workspace adapters", variant: "error", }) return } - setAdaptors(res) + setAdapters(res) })() }) @@ -142,13 +142,13 @@ export function DialogWorkspaceCreate(props: { onSelect: (workspaceID: string) = }, ] } - const list = adaptors() + const list = adapters() if (!list) { return [ { title: "Loading workspaces...", value: "loading" as const, - description: "Fetching available workspace adaptors", + description: "Fetching available workspace adapters", }, ] } diff --git a/packages/opencode/src/cli/cmd/tui/component/logo.tsx b/packages/opencode/src/cli/cmd/tui/component/logo.tsx index 5814b31387..2362cd56ca 100644 --- a/packages/opencode/src/cli/cmd/tui/component/logo.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/logo.tsx @@ -1,4 +1,5 @@ import { BoxRenderable, MouseButton, MouseEvent, RGBA, TextAttributes } from "@opentui/core" +import { useRenderer } from "@opentui/solid" import { For, createMemo, createSignal, onCleanup, onMount, type JSX } from "solid-js" import { useTheme, tint } from "@tui/context/theme" import { KiloLogo } from "./kilo-logo" // kilocode_change diff --git a/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx b/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx index 8f50468362..7f4c70b107 100644 --- a/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx @@ -12,11 +12,12 @@ import { useRoute } from "@tui/context/route" import { useProject } from "@tui/context/project" import { useSync } from "@tui/context/sync" import { useEvent } from "@tui/context/event" -import { useEditorContext, type EditorSelection } from "@tui/context/editor" +import { editorSelectionKey, useEditorContext, type EditorSelection } from "@tui/context/editor" import { MessageID, PartID } from "@/session/schema" import { createStore, produce, unwrap } from "solid-js/store" import { useKeybind } from "@tui/context/keybind" import { usePromptHistory, type PromptInfo } from "./history" +import { computePromptTraits } from "./traits" import { assign } from "./part" import { usePromptStash } from "./stash" import { DialogStash } from "../dialog-stash" @@ -84,16 +85,30 @@ function fadeColor(color: RGBA, alpha: number) { return RGBA.fromValues(color.r, color.g, color.b, color.a * alpha) } -function getEditorSelectionKey(selection: EditorSelection) { - return [ - selection.filePath, - selection.text, - selection.source ?? "", - selection.selection.start.line, - selection.selection.start.character, - selection.selection.end.line, - selection.selection.end.character, - ].join("-") +function hasEditorRangeSelection(selection: EditorSelection["ranges"][number]) { + return ( + selection.selection.start.line !== selection.selection.end.line || + selection.selection.start.character !== selection.selection.end.character + ) +} + +function getEditorRangeLabel(selection: EditorSelection["ranges"][number]) { + if (!hasEditorRangeSelection(selection)) return + if (selection.selection.start.line === selection.selection.end.line) return `#${selection.selection.start.line}` + return `#${selection.selection.start.line}-${selection.selection.end.line}` +} + +function formatEditorContext(selection: EditorSelection) { + const selected = selection.ranges.filter(hasEditorRangeSelection) + if (selected.length === 0) + return `Note: The user opened the file "${selection.filePath}". This may or may not be relevant to the current task.\n` + + const ranges = selected.map((range, index) => { + const prefix = selected.length > 1 ? `Selection ${index + 1}: ` : "" + return `Note: The user selected ${prefix}${getEditorRangeLabel(range)} from "${selection.filePath}". \`\`\`${range.text}\`\`\`\n\n` + }) + + return `${ranges.join("\n")} This may or may not be relevant to the current task.\n` } let stashed: { prompt: PromptInfo; cursor: number } | undefined @@ -125,13 +140,21 @@ export function Prompt(props: PromptProps) { const list = createMemo(() => props.placeholders?.normal ?? []) const shell = createMemo(() => props.placeholders?.shell ?? []) const fileContextEnabled = createMemo(() => kv.get("file_context_enabled", true)) - const editorPath = createMemo(() => (fileContextEnabled() ? editor.selection()?.filePath : undefined)) - const editorSelectionLabel = createMemo(() => { - const selection = fileContextEnabled() ? editor.selection()?.selection : undefined + const [dismissedEditorSelectionKey, setDismissedEditorSelectionKey] = createSignal() + const editorContext = createMemo(() => { + const selection = fileContextEnabled() ? editor.selection() : undefined if (!selection) return - if (selection.start.line === selection.end.line && selection.start.character === selection.end.character) return - if (selection.start.line === selection.end.line) return `#${selection.start.line}` - return `#${selection.start.line}-${selection.end.line}` + return editorSelectionKey(selection) === dismissedEditorSelectionKey() ? undefined : selection + }) + const editorPath = createMemo(() => editorContext()?.filePath) + const editorSelectionLabel = createMemo(() => { + const ranges = editorContext()?.ranges + if (!ranges) return + const first = ranges.find(hasEditorRangeSelection) ?? ranges[0] + if (!first) return + return [getEditorRangeLabel(first), ranges.length > 1 ? `+${ranges.length - 1}` : undefined] + .filter(Boolean) + .join(" ") }) const editorFileLabel = createMemo(() => { const value = editorPath() @@ -147,6 +170,7 @@ export function Prompt(props: PromptProps) { if (!file) return return Locale.truncateMiddle(file, Math.max(12, Math.min(48, Math.floor(dimensions().width / 3)))) }) + const [editorContextHover, setEditorContextHover] = createSignal(false) let lastSubmittedEditorSelectionKey: string | undefined const [auto, setAuto] = createSignal() const currentProviderLabel = createMemo(() => local.model.parsed().provider) @@ -163,6 +187,11 @@ export function Prompt(props: PromptProps) { } } + function dismissEditorContext() { + setDismissedEditorSelectionKey(editorSelectionKey(editorContext())) + editor.clearSelection() + } + const textareaKeybindings = useTextareaKeybindings() const fileStyleId = syntax().getStyleId("extmark.file")! @@ -294,6 +323,16 @@ export function Prompt(props: PromptProps) { dialog.clear() }, }, + { + title: "Remove editor context", + value: "prompt.editor_context.clear", + category: "Prompt", + enabled: Boolean(editorContext()), + onSelect: (dialog) => { + dismissEditorContext() + dialog.clear() + }, + }, { title: "Paste", value: "prompt.paste", @@ -529,17 +568,11 @@ export function Prompt(props: PromptProps) { createEffect(() => { if (!input || input.isDestroyed) return - const capture = - store.mode === "normal" - ? auto()?.visible - ? (["escape", "navigate", "submit", "tab"] as const) - : (["tab"] as const) - : undefined - input.traits = { - capture, - suspend: !!props.disabled || store.mode === "shell", - status: store.mode === "shell" ? "SHELL" : undefined, - } + input.traits = computePromptTraits({ + mode: store.mode, + disabled: !!props.disabled, + autocompleteVisible: !!auto()?.visible, + }) }) function restoreExtmarksFromParts(parts: PromptInfo["parts"]) { @@ -770,35 +803,21 @@ export function Prompt(props: PromptProps) { // Capture mode before it gets reset const currentMode = store.mode const variant = local.model.variant.current() - const editorSelection = fileContextEnabled() ? editor.selection() : undefined - const editorSelectionKey = editorSelection ? getEditorSelectionKey(editorSelection) : undefined + const editorSelection = editorContext() + const currentEditorSelectionKey = editorSelectionKey(editorSelection) const editorParts = - editorSelection && editorSelectionKey !== lastSubmittedEditorSelectionKey + editorSelection && currentEditorSelectionKey !== lastSubmittedEditorSelectionKey ? [ { id: PartID.ascending(), type: "text" as const, - text: (() => { - const start = editorSelection.selection.start - const end = editorSelection.selection.end - - let text = "" - if (start.line === end.line && start.character === end.character) { - text = `Note: The user opened the file "${editorSelection.filePath}".` - } else if (start.line === end.line) { - text = `Note: The user selected line ${start.line + 1} from "${editorSelection.filePath}". \`\`\`${editorSelection.text}\`\`\`\n\n` - } else { - text = `Note: The user selected lines ${start.line + 1} to ${end.line + 1} from "${editorSelection.filePath}". \`\`\`${editorSelection.text}\`\`\`\n\n` - } - - return `${text} This may or may not be relevant to the current task.\n` - })(), + text: formatEditorContext(editorSelection), synthetic: true, metadata: { kind: "editor_context", source: editorSelection.source ?? "editor", filePath: editorSelection.filePath, - selection: editorSelection.selection, + ranges: editorSelection.ranges, }, }, ] @@ -865,7 +884,7 @@ export function Prompt(props: PromptProps) { ], }) .catch(() => {}) - lastSubmittedEditorSelectionKey = editorSelectionKey + lastSubmittedEditorSelectionKey = currentEditorSelectionKey } toast.dismiss() // kilocode_change - dismiss persistent config warning on first submit history.append({ @@ -1450,7 +1469,18 @@ export function Prompt(props: PromptProps) { {/* kilocode_change end */} - {(file) => {file()}} + + {(file) => ( + setEditorContextHover(true)} + onMouseOut={() => setEditorContextHover(false)} + onMouseUp={dismissEditorContext} + > + {editorContextHover() ? `x ${file()}` : file()} + + )} + diff --git a/packages/opencode/src/cli/cmd/tui/component/prompt/traits.ts b/packages/opencode/src/cli/cmd/tui/component/prompt/traits.ts new file mode 100644 index 0000000000..e47a1aeba5 --- /dev/null +++ b/packages/opencode/src/cli/cmd/tui/component/prompt/traits.ts @@ -0,0 +1,31 @@ +import type { EditorTraits } from "@opentui/core" + +export type PromptMode = "normal" | "shell" + +export interface PromptTraitsInput { + mode: PromptMode + disabled: boolean + autocompleteVisible: boolean +} + +/** + * Compute the textarea editor traits for the prompt. + * + * `traits.suspend` gates the textarea's keybinding actions (backspace, + * delete-word, arrow movement, undo/redo, etc.). Shell mode is an active + * editing mode — only `disabled` should suspend the textarea, otherwise + * users can type in shell mode but cannot delete or move the cursor. + */ +export function computePromptTraits(input: PromptTraitsInput): EditorTraits { + const capture = + input.mode === "normal" + ? input.autocompleteVisible + ? (["escape", "navigate", "submit", "tab"] as const) + : (["tab"] as const) + : undefined + return { + capture, + suspend: input.disabled, + status: input.mode === "shell" ? "SHELL" : undefined, + } +} diff --git a/packages/opencode/src/cli/cmd/tui/context/editor-zed.ts b/packages/opencode/src/cli/cmd/tui/context/editor-zed.ts index 5f01fb712a..008a2d1324 100644 --- a/packages/opencode/src/cli/cmd/tui/context/editor-zed.ts +++ b/packages/opencode/src/cli/cmd/tui/context/editor-zed.ts @@ -12,6 +12,9 @@ const ZedEditorRowSchema = z.object({ workspace_paths: z.string().nullable(), timestamp: z.string(), buffer_path: z.string().nullable(), +}) + +const ZedSelectionRowSchema = z.object({ selection_start: z.number().nullable(), selection_end: z.number().nullable(), }) @@ -24,6 +27,7 @@ const utf8 = new TextEncoder() type ZedEditorRow = z.infer type ZedActiveEditorRow = ZedEditorRow & { item_kind: "Editor"; editor_id: number } +type ZedSelectionRow = z.infer export type ZedSelectionResult = | { type: "selection"; selection: EditorSelection } @@ -36,7 +40,21 @@ export async function resolveZedSelection(dbPath: string, cwd = process.cwd()): const row = active.row if (!row.buffer_path) return { type: "empty" } - if (row.selection_start == null || row.selection_end == null) return { type: "unavailable" } + + const selections = queryZedEditorSelections(dbPath, row) + if (selections.type !== "selections") return selections + const byteRanges = selections.selections + .flatMap((selection) => { + if (selection.selection_start == null || selection.selection_end == null) return [] + return [ + { + start: Math.min(selection.selection_start, selection.selection_end), + end: Math.max(selection.selection_start, selection.selection_end), + }, + ] + }) + .sort((left, right) => left.start - right.start || left.end - right.end) + if (byteRanges.length === 0) return { type: "unavailable" } const contents = queryZedEditorContents(dbPath, row) const text = @@ -47,16 +65,21 @@ export async function resolveZedSelection(dbPath: string, cwd = process.cwd()): .catch(() => undefined) if (text == null) return { type: "unavailable" } - const startOffset = utf8ByteOffsetToStringIndex(text, Math.min(row.selection_start, row.selection_end)) - const endOffset = utf8ByteOffsetToStringIndex(text, Math.max(row.selection_start, row.selection_end)) + const ranges = byteRanges.map((range) => { + const startOffset = utf8ByteOffsetToStringIndex(text, range.start) + const endOffset = utf8ByteOffsetToStringIndex(text, range.end) + return { + text: text.slice(startOffset, endOffset), + selection: offsetsToSelection(text, startOffset, endOffset), + } + }) return { type: "selection", selection: { - text: text.slice(startOffset, endOffset), filePath: row.buffer_path, source: "zed", - selection: offsetsToSelection(text, startOffset, endOffset), + ranges, }, } } @@ -73,14 +96,11 @@ function queryZedActiveEditor(dbPath: string, cwd: string) { i.workspace_id as workspace_id, w.paths as workspace_paths, w.timestamp as timestamp, - e.buffer_path as buffer_path, - s.start as selection_start, - s.end as selection_end + e.buffer_path as buffer_path from items i join panes p on p.pane_id = i.pane_id and p.workspace_id = i.workspace_id join workspaces w on w.workspace_id = i.workspace_id left join editors e on e.item_id = i.item_id and e.workspace_id = i.workspace_id - left join editor_selections s on s.editor_id = e.item_id and s.workspace_id = e.workspace_id where i.active = 1 and p.active = 1 order by w.timestamp desc`, ) @@ -108,6 +128,34 @@ function queryZedActiveEditor(dbPath: string, cwd: string) { } } +function queryZedEditorSelections(dbPath: string, row: ZedActiveEditorRow) { + let db: Database | undefined + try { + db = new Database(dbPath, { readonly: true }) + const raw = db + .query( + `select + start as selection_start, + end as selection_end + from editor_selections + where editor_id = $editorID and workspace_id = $workspaceID`, + ) + .all({ $editorID: row.editor_id, $workspaceID: row.workspace_id }) + + const selections = raw.flatMap((selection) => { + const parsed = ZedSelectionRowSchema.safeParse(selection) + return parsed.success ? [parsed.data] : [] + }) + + if (raw.length > 0 && selections.length === 0) return { type: "unavailable" as const } + return { type: "selections" as const, selections } + } catch { + return { type: "unavailable" as const } + } finally { + db?.close() + } +} + function queryZedEditorContents(dbPath: string, row: ZedActiveEditorRow) { let db: Database | undefined try { @@ -141,13 +189,20 @@ export function resolveZedDbPath() { path.join(os.homedir(), ".local", "share", "zed", "db", "0-stable", "db.sqlite"), ].filter((item): item is string => Boolean(item)) - return candidates.find((item) => Filesystem.stat(item)?.isFile()) + return candidates.find((item) => isFile(item)) +} + +function isFile(item: string) { + try { + return Filesystem.stat(item)?.isFile() === true + } catch { + return false + } } function scoreZedWorkspace(workspacePaths: string | null, cwd: string) { return zedWorkspacePaths(workspacePaths).reduce((score, item) => { - if (pathContains(item, cwd)) return Math.max(score, 2) - if (pathContains(cwd, item)) return Math.max(score, 1) + if (pathContains(item, cwd)) return Math.max(score, path.resolve(item).length) return score }, 0) } diff --git a/packages/opencode/src/cli/cmd/tui/context/editor.ts b/packages/opencode/src/cli/cmd/tui/context/editor.ts index b826d09fd6..b6e4850d6c 100644 --- a/packages/opencode/src/cli/cmd/tui/context/editor.ts +++ b/packages/opencode/src/cli/cmd/tui/context/editor.ts @@ -28,16 +28,46 @@ const PositionSchema = z.object({ character: z.number(), }) -const EditorSelectionSchema = z.object({ +const EditorSelectionRangeSchema = z.object({ text: z.string(), - filePath: z.string(), - source: z.enum(["websocket", "zed"]).optional(), selection: z.object({ start: PositionSchema, end: PositionSchema, }), }) +const EditorSelectionSchema = z + .union([ + z.object({ + filePath: z.string(), + source: z.enum(["websocket", "zed"]).optional(), + ranges: z.array(EditorSelectionRangeSchema).min(1), + }), + z.object({ + text: z.string(), + filePath: z.string(), + source: z.enum(["websocket", "zed"]).optional(), + selection: z.object({ + start: PositionSchema, + end: PositionSchema, + }), + }), + ]) + .transform((value) => + "ranges" in value + ? value + : { + filePath: value.filePath, + source: value.source, + ranges: [ + { + text: value.text, + selection: value.selection, + }, + ], + }, + ) + const EditorMentionSchema = z.object({ filePath: z.string(), lineStart: z.number(), @@ -262,6 +292,7 @@ export const { use: useEditorContext, provider: EditorContextProvider } = create return store.selection }, clearSelection() { + lastZedSelectionKey = undefined setStore("selection", undefined) }, onMention(listener: (mention: EditorMention) => void) { @@ -352,15 +383,17 @@ function readEditorLockFile(filePath: string): EditorLockFile | undefined { } } -function editorSelectionKey(selection: EditorSelection | undefined) { +export function editorSelectionKey(selection: EditorSelection | undefined) { if (!selection) return "" return [ selection.filePath, - selection.selection.start.line, - selection.selection.start.character, - selection.selection.end.line, - selection.selection.end.character, - selection.text, + ...selection.ranges.flatMap((range) => [ + range.selection.start.line, + range.selection.start.character, + range.selection.end.line, + range.selection.end.character, + range.text, + ]), ].join("\0") } diff --git a/packages/opencode/src/cli/cmd/tui/context/theme.tsx b/packages/opencode/src/cli/cmd/tui/context/theme.tsx index 82a81f6d7a..30142079fc 100644 --- a/packages/opencode/src/cli/cmd/tui/context/theme.tsx +++ b/packages/opencode/src/cli/cmd/tui/context/theme.tsx @@ -430,12 +430,16 @@ export const { use: useTheme, provider: ThemeProvider } = createSimpleContext({ // kilocode_change start - safe fallback to kilo import if store lookup fails const values = createMemo(() => { const active = store.themes[store.active] - if (active) return resolveTheme(active, store.mode) + if (active) { + return resolveTheme(active, store.mode) + } const saved = kv.get("theme") if (typeof saved === "string") { const theme = store.themes[saved] - if (theme) return resolveTheme(theme, store.mode) + if (theme) { + return resolveTheme(theme, store.mode) + } } return resolveTheme(store.themes.kilo, store.mode) // kilocode_change diff --git a/packages/opencode/src/cli/cmd/tui/thread.ts b/packages/opencode/src/cli/cmd/tui/thread.ts index ed53b464d1..4f2832201a 100644 --- a/packages/opencode/src/cli/cmd/tui/thread.ts +++ b/packages/opencode/src/cli/cmd/tui/thread.ts @@ -74,6 +74,12 @@ async function input(value?: string) { return piped + "\n" + value } +export function resolveThreadDirectory(project?: string, envPWD = process.env.PWD, cwd = process.cwd()) { + const root = Filesystem.resolve(envPWD ?? cwd) + if (project) return Filesystem.resolve(path.isAbsolute(project) ? project : path.join(root, project)) + return Filesystem.resolve(cwd) +} + export const TuiThreadCommand = cmd({ command: "$0 [project]", describe: "start kilo tui", // kilocode_change @@ -143,10 +149,7 @@ export const TuiThreadCommand = cmd({ // Resolve relative --project paths from PWD, then use the real cwd after // chdir so the thread and worker share the same directory key. - const root = Filesystem.resolve(process.env.PWD ?? process.cwd()) - const next = args.project - ? Filesystem.resolve(path.isAbsolute(args.project) ? args.project : path.join(root, args.project)) - : Filesystem.resolve(process.cwd()) + const next = resolveThreadDirectory(args.project) const file = await target() try { process.chdir(next) diff --git a/packages/opencode/src/cli/cmd/tui/ui/dialog-select.tsx b/packages/opencode/src/cli/cmd/tui/ui/dialog-select.tsx index b6c937f411..4d68c44308 100644 --- a/packages/opencode/src/cli/cmd/tui/ui/dialog-select.tsx +++ b/packages/opencode/src/cli/cmd/tui/ui/dialog-select.tsx @@ -42,7 +42,7 @@ export interface DialogSelectOption { categoryView?: JSX.Element disabled?: boolean bg?: RGBA - gutter?: JSX.Element + gutter?: () => JSX.Element margin?: JSX.Element onSelect?: (ctx: DialogContext) => void } @@ -407,7 +407,7 @@ function Option(props: { active?: boolean current?: boolean footer?: JSX.Element | string - gutter?: JSX.Element + gutter?: () => JSX.Element onMouseOver?: () => void }) { const { theme } = useTheme() @@ -422,7 +422,7 @@ function Option(props: { - {props.gutter} + {props.gutter?.()} AppRuntime.runPromise(InstanceBootstrap), + init: await getBootstrapRunEffect(), fn: async () => { await upgrade().catch(() => {}) }, @@ -89,7 +89,7 @@ export const rpc = { async shutdown() { Log.Default.info("worker shutting down") - await Instance.disposeAll() + await InstanceStore.disposeAllInstances() if (server) await server.stop(true) // kilocode_change start - Clear the Rpc message channel so the worker's event loop can drain and // exit naturally. Without this, the active onmessage handle keeps the diff --git a/packages/opencode/src/cli/cmd/web.ts b/packages/opencode/src/cli/cmd/web.ts index 9843b8dcf1..6aaaf393a3 100644 --- a/packages/opencode/src/cli/cmd/web.ts +++ b/packages/opencode/src/cli/cmd/web.ts @@ -3,7 +3,7 @@ import { UI } from "../ui" import { cmd } from "./cmd" import { withNetworkOptions, resolveNetworkOptions } from "../network" import { Flag } from "@opencode-ai/core/flag/flag" -import { Instance } from "../../project/instance" // kilocode_change +import { InstanceStore } from "../../project/instance-store" // kilocode_change import open from "open" import { networkInterfaces } from "os" @@ -80,7 +80,7 @@ export const WebCommand = cmd({ const abort = new AbortController() const shutdown = async () => { try { - await Instance.disposeAll() + await InstanceStore.disposeAllInstances() await server.stop(true) } finally { abort.abort() diff --git a/packages/opencode/src/cli/effect-cmd.ts b/packages/opencode/src/cli/effect-cmd.ts new file mode 100644 index 0000000000..cc4dd2ed7e --- /dev/null +++ b/packages/opencode/src/cli/effect-cmd.ts @@ -0,0 +1,50 @@ +import type { Argv } from "yargs" +import { Effect, Schema } from "effect" +import { AppRuntime, type AppServices } from "@/effect/app-runtime" +import { InstanceStore } from "@/project/instance-store" +import { cmd } from "./cmd/cmd" + +/** + * User-visible command failure. Throw via `fail("...")` from an effectCmd handler + * to surface a printed message + non-zero exit. Recognised by the global error + * formatter in `src/cli/error.ts` (FormatError), so the existing top-level + * catch + cleanup in `src/index.ts` runs normally. + */ +export class CliError extends Schema.TaggedErrorClass()("CliError", { + message: Schema.String, + exitCode: Schema.optional(Schema.Number), +}) {} + +export const fail = (message: string, exitCode = 1) => Effect.fail(new CliError({ message, exitCode })) + +/** + * Effect-native CLI command builder. Wraps yargs `cmd()` so the handler body is + * an `Effect` with `InstanceRef` provided and any `AppServices` yieldable. + * + * Errors propagate to the existing top-level handler in `src/index.ts`; use + * `fail("...")` for user-visible domain failures (clean exit, formatted message). + * + * Handlers are typically `Effect.fn("Cli.")(function*(args) { ... })`, + * which adds a named tracing span per CLI invocation. Once all commands use + * `effectCmd`, swapping the underlying `cmd()` factory for effect/cli's + * `Command.make(...)` won't touch any handler bodies. + */ +export const effectCmd = (opts: { + command: string | readonly string[] + describe: string | false + builder?: (yargs: Argv) => Argv + /** Defaults to process.cwd(). Override for commands that take a directory positional. */ + directory?: (args: Args) => string + handler: (args: Args) => Effect.Effect +}) => + cmd<{}, Args>({ + command: opts.command, + describe: opts.describe, + builder: opts.builder as never, + async handler(rawArgs) { + // yargs typing wraps Args in ArgumentsCamelCase>; cast at the boundary. + const args = rawArgs as unknown as Args + const directory = opts.directory?.(args) ?? process.cwd() + await AppRuntime.runPromise(InstanceStore.Service.use((s) => s.provide({ directory }, opts.handler(args)))) + }, + }) diff --git a/packages/opencode/src/cli/error.ts b/packages/opencode/src/cli/error.ts index e64051caf4..8b5097c182 100644 --- a/packages/opencode/src/cli/error.ts +++ b/packages/opencode/src/cli/error.ts @@ -15,6 +15,13 @@ function isTaggedError(error: unknown, tag: string): boolean { } export function FormatError(input: unknown) { + // CliError: domain failure surfaced from an effectCmd handler via fail("...") + if (isTaggedError(input, "CliError")) { + const data = input as ErrorLike & { exitCode?: number } + if (data.exitCode != null) process.exitCode = data.exitCode + return data.message ?? "" + } + // MCPFailed: { name: string } if (NamedError.hasName(input, "MCPFailed")) { return `MCP server "${(input as ErrorLike).data?.name}" failed. Note, opencode does not support MCP authentication yet.` diff --git a/packages/opencode/src/config/config.ts b/packages/opencode/src/config/config.ts index e3c11d2afe..6ff5a90704 100644 --- a/packages/opencode/src/config/config.ts +++ b/packages/opencode/src/config/config.ts @@ -3,7 +3,7 @@ import path from "path" import { pathToFileURL } from "url" import os from "os" import z from "zod" -import { mergeDeep, pipe } from "remeda" +import { mergeDeep } from "remeda" import { Global } from "@opencode-ai/core/global" import fsNode from "fs/promises" import { NamedError } from "@opencode-ai/core/util/error" @@ -11,7 +11,8 @@ import { Flag } from "@opencode-ai/core/flag/flag" import { Auth } from "../auth" import { Env } from "../env" import { applyEdits, findNodeAtLocation, modify, parseTree } from "jsonc-parser" // kilocode_change - parseTree/findNodeAtLocation used in patchJsonc -import { Instance, type InstanceContext } from "../project/instance" +import { type InstanceContext } from "../project/instance" +import { InstanceStore } from "../project/instance-store" import { InstallationLocal, InstallationVersion } from "@opencode-ai/core/installation/version" import { existsSync } from "fs" import { GlobalBus } from "@/bus/global" @@ -23,7 +24,7 @@ import { AppFileSystem } from "@opencode-ai/core/filesystem" import { InstanceState } from "@/effect/instance-state" import { Context, Duration, Effect, Exit, Fiber, Layer, Option, Schema } from "effect" import { EffectFlock } from "@opencode-ai/core/util/effect-flock" -import { InstanceRef } from "@/effect/instance-ref" +import { containsPath } from "../project/instance-context" import { zod } from "@/util/effect-zod" import { NonNegativeInt, PositiveInt, withStatics, type DeepMutable } from "@/util/schema" import { ConfigAgent } from "./agent" @@ -55,8 +56,13 @@ import { unique } from "remeda" const log = Log.create({ service: "config" }) // Custom merge function that concatenates array fields instead of replacing them +// Keep remeda's deep conditional merge type out of hot config-loading paths; TS profiling showed it dominates here. +function mergeConfig(target: Info, source: Info): Info { + return mergeDeep(target, source) as Info +} + function mergeConfigConcatArrays(target: Info, source: Info): Info { - const merged = mergeDeep(target, source) + const merged = mergeConfig(target, source) if (target.instructions && source.instructions) { merged.instructions = Array.from(new Set([...target.instructions, ...source.instructions])) } @@ -83,7 +89,7 @@ export const Warning = z.object({ }) export type Warning = z.infer -const { toWarning, caught: caughtWarning, handleInvalid } = KilocodeConfig +const { caught: caughtWarning } = KilocodeConfig // kilocode_change end async function resolveLoadedPlugins(config: T, filepath: string) { @@ -474,16 +480,14 @@ export const layer = Layer.effect( const loadGlobal = Effect.fnUntraced(function* () { yield* Effect.promise(() => KilocodeConfig.migrateBashPermission()) // kilocode_change - let result: Info = pipe( - {}, - mergeDeep(yield* loadFile(path.join(Global.Path.config, "config.json"))), - // kilocode_change start - mergeDeep(yield* loadFile(path.join(Global.Path.config, "kilo.json"))), - mergeDeep(yield* loadFile(path.join(Global.Path.config, "kilo.jsonc"))), - // kilocode_change end - mergeDeep(yield* loadFile(path.join(Global.Path.config, "opencode.json"))), - mergeDeep(yield* loadFile(path.join(Global.Path.config, "opencode.jsonc"))), - ) + let result: Info = {} + result = mergeConfig(result, yield* loadFile(path.join(Global.Path.config, "config.json"))) + // kilocode_change start + result = mergeConfig(result, yield* loadFile(path.join(Global.Path.config, "kilo.json"))) + result = mergeConfig(result, yield* loadFile(path.join(Global.Path.config, "kilo.jsonc"))) + // kilocode_change end + result = mergeConfig(result, yield* loadFile(path.join(Global.Path.config, "opencode.json"))) + result = mergeConfig(result, yield* loadFile(path.join(Global.Path.config, "opencode.jsonc"))) const legacy = path.join(Global.Path.config, "config") if (existsSync(legacy)) { @@ -493,7 +497,7 @@ export const layer = Layer.effect( const { provider, model, ...rest } = mod.default if (provider && model) result.model = `${provider}/${model}` result["$schema"] = "https://app.kilo.ai/config.json" // kilocode_change - result = mergeDeep(result, rest) + result = mergeConfig(result, rest) await fsNode.writeFile(path.join(Global.Path.config, "config.json"), JSON.stringify(result, null, 2)) await fsNode.unlink(legacy) }) @@ -575,7 +579,7 @@ export const layer = Layer.effect( const pluginScopeForSource = Effect.fnUntraced(function* (source: string) { if (source.startsWith("http://") || source.startsWith("https://")) return "global" if (source === "KILO_CONFIG_CONTENT") return "local" - if (yield* InstanceRef.use((ctx) => Effect.succeed(Instance.containsPath(source, ctx)))) return "local" + if (containsPath(source, ctx)) return "local" return "global" }) @@ -955,12 +959,18 @@ export const layer = Layer.effect( writable, }) // kilocode_change end - if (options?.dispose !== false) yield* Effect.promise(() => Instance.dispose()) + if (options?.dispose !== false) { + // Fail loudly if no instance is bound — silently skipping would + // mask "config update without an active instance" bugs. The throw + // comes from `Instance.current` inside `InstanceState.context`. + const ctx = yield* InstanceState.context + yield* Effect.promise(() => InstanceStore.disposeInstance(ctx)) + } }) const invalidate = Effect.fn("Config.invalidate")(function* (wait?: boolean) { yield* invalidateGlobal - const task = Instance.disposeAll() + const task = InstanceStore.disposeAllInstances() .catch(() => undefined) .finally(() => GlobalBus.emit("event", { @@ -984,16 +994,20 @@ export const layer = Layer.effect( const patch = writableGlobal(config) let next: Info + let changed: boolean if (!file.endsWith(".jsonc")) { const existing = ConfigParse.effectSchema(Info, ConfigParse.jsonc(before, file), file) // kilocode_change - use `patch` (writableGlobal) so empty-string sentinels are stripped via undefined const merged = KilocodeConfig.mergeConfig(writable(existing), patch) - yield* fs.writeFileString(file, JSON.stringify(merged, null, 2)).pipe(Effect.orDie) + const serialized = JSON.stringify(merged, null, 2) + changed = serialized !== before + if (changed) yield* fs.writeFileString(file, serialized).pipe(Effect.orDie) next = merged } else { const updated = patchJsonc(before, patch) next = ConfigParse.effectSchema(Info, ConfigParse.jsonc(updated, file), file) - yield* fs.writeFileString(file, updated).pipe(Effect.orDie) + changed = updated !== before + if (changed) yield* fs.writeFileString(file, updated).pipe(Effect.orDie) } // kilocode_change start - skip dispose when caller opts out @@ -1013,7 +1027,8 @@ export const layer = Layer.effect( } // kilocode_change end - yield* invalidate() + // Only tear down running instances if the config actually changed. + if (changed) yield* invalidate() return next }) diff --git a/packages/opencode/src/control-plane/adapters/index.ts b/packages/opencode/src/control-plane/adapters/index.ts new file mode 100644 index 0000000000..963e2a2ed5 --- /dev/null +++ b/packages/opencode/src/control-plane/adapters/index.ts @@ -0,0 +1,45 @@ +import type { ProjectID } from "@/project/schema" +import type { WorkspaceAdapter, WorkspaceAdapterEntry } from "../types" +import { WorktreeAdapter } from "./worktree" + +const BUILTIN: Record = { + worktree: WorktreeAdapter, +} + +const state = new Map>() + +export function getAdapter(projectID: ProjectID, type: string): WorkspaceAdapter { + const custom = state.get(projectID)?.get(type) + if (custom) return custom + + const builtin = BUILTIN[type] + if (builtin) return builtin + + throw new Error(`Unknown workspace adapter: ${type}`) +} + +export async function listAdapters(projectID: ProjectID): Promise { + const builtin = await Promise.all( + Object.entries(BUILTIN).map(async ([type, adapter]) => { + return { + type, + name: adapter.name, + description: adapter.description, + } + }), + ) + const custom = [...(state.get(projectID)?.entries() ?? [])].map(([type, adapter]) => ({ + type, + name: adapter.name, + description: adapter.description, + })) + return [...builtin, ...custom] +} + +// Plugins can be loaded per-project so we need to scope them. If you +// want to install a global one pass `ProjectID.global` +export function registerAdapter(projectID: ProjectID, type: string, adapter: WorkspaceAdapter) { + const adapters = state.get(projectID) ?? new Map() + adapters.set(type, adapter) + state.set(projectID, adapters) +} diff --git a/packages/opencode/src/control-plane/adaptors/worktree.ts b/packages/opencode/src/control-plane/adapters/worktree.ts similarity index 60% rename from packages/opencode/src/control-plane/adaptors/worktree.ts rename to packages/opencode/src/control-plane/adapters/worktree.ts index 9c080daa38..af8f5d8d43 100644 --- a/packages/opencode/src/control-plane/adaptors/worktree.ts +++ b/packages/opencode/src/control-plane/adapters/worktree.ts @@ -1,7 +1,5 @@ import { Schema } from "effect" -import { AppRuntime } from "@/effect/app-runtime" -import { Worktree } from "@/worktree" -import { type WorkspaceAdaptor, WorkspaceInfo } from "../types" +import { type WorkspaceAdapter, WorkspaceInfo } from "../types" const WorktreeConfig = Schema.Struct({ name: WorkspaceInfo.fields.name, @@ -10,19 +8,26 @@ const WorktreeConfig = Schema.Struct({ }) const decodeWorktreeConfig = Schema.decodeUnknownSync(WorktreeConfig) -export const WorktreeAdaptor: WorkspaceAdaptor = { +async function loadWorktree() { + const [{ AppRuntime }, { Worktree }] = await Promise.all([import("@/effect/app-runtime"), import("@/worktree")]) + return { AppRuntime, Worktree } +} + +export const WorktreeAdapter: WorkspaceAdapter = { name: "Worktree", description: "Create a git worktree", async configure(info) { - const worktree = await AppRuntime.runPromise(Worktree.Service.use((svc) => svc.makeWorktreeInfo())) + const { AppRuntime, Worktree } = await loadWorktree() + const next = await AppRuntime.runPromise(Worktree.Service.use((svc) => svc.makeWorktreeInfo())) return { ...info, - name: worktree.name, - branch: worktree.branch, - directory: worktree.directory, + name: next.name, + branch: next.branch, + directory: next.directory, } }, async create(info) { + const { AppRuntime, Worktree } = await loadWorktree() const config = decodeWorktreeConfig(info) await AppRuntime.runPromise( Worktree.Service.use((svc) => @@ -35,6 +40,7 @@ export const WorktreeAdaptor: WorkspaceAdaptor = { ) }, async remove(info) { + const { AppRuntime, Worktree } = await loadWorktree() const config = decodeWorktreeConfig(info) await AppRuntime.runPromise(Worktree.Service.use((svc) => svc.remove({ directory: config.directory }))) }, diff --git a/packages/opencode/src/control-plane/adaptors/index.ts b/packages/opencode/src/control-plane/adaptors/index.ts deleted file mode 100644 index 651d09cc21..0000000000 --- a/packages/opencode/src/control-plane/adaptors/index.ts +++ /dev/null @@ -1,46 +0,0 @@ -import { lazy } from "@/util/lazy" -import type { ProjectID } from "@/project/schema" -import type { WorkspaceAdaptor, WorkspaceAdaptorEntry } from "../types" - -const BUILTIN: Record Promise> = { - worktree: lazy(async () => (await import("./worktree")).WorktreeAdaptor), -} - -const state = new Map>() - -export async function getAdaptor(projectID: ProjectID, type: string): Promise { - const custom = state.get(projectID)?.get(type) - if (custom) return custom - - const builtin = BUILTIN[type] - if (builtin) return builtin() - - throw new Error(`Unknown workspace adaptor: ${type}`) -} - -export async function listAdaptors(projectID: ProjectID): Promise { - const builtin = await Promise.all( - Object.entries(BUILTIN).map(async ([type, init]) => { - const adaptor = await init() - return { - type, - name: adaptor.name, - description: adaptor.description, - } - }), - ) - const custom = [...(state.get(projectID)?.entries() ?? [])].map(([type, adaptor]) => ({ - type, - name: adaptor.name, - description: adaptor.description, - })) - return [...builtin, ...custom] -} - -// Plugins can be loaded per-project so we need to scope them. If you -// want to install a global one pass `ProjectID.global` -export function registerAdaptor(projectID: ProjectID, type: string, adaptor: WorkspaceAdaptor) { - const adaptors = state.get(projectID) ?? new Map() - adaptors.set(type, adaptor) - state.set(projectID, adaptors) -} diff --git a/packages/opencode/src/control-plane/dev/README.md b/packages/opencode/src/control-plane/dev/README.md new file mode 100644 index 0000000000..74d68a75a8 --- /dev/null +++ b/packages/opencode/src/control-plane/dev/README.md @@ -0,0 +1,19 @@ +This is a plugin to simulate a remote environment locally. Add this to `.opencode/opencode.jsonc`: + +```json + "plugin": ["../packages/opencode/src/control-plane/dev/debug-workspace-plugin.ts"], +``` + +In a separate terminal, run a separate OpenCode server. This will act like a remote server and the local instance will proxy all requests to it: + +``` +./packages/opencode/script/run-workspace-server +``` + +With the plugin install, you can now run OpenCode and create a `debug` workspace type. This will create a "remote" workspace which talks to the second workspace server started above. + +How this works: + +- The workspace server needs to know the workspace id and port to run. It waits for this information to be written to a file and starts the server when the data is written. +- The debug plugin writes this information in the `create` call to the workspace. So create a `debug` workspace will always kick off a new external server. +- The server script watches for file changes, so whenver you create a new `debug` workspace it will restart with the new information. This means that there is only ever one working `debug` workspace at a time; when you create a new one all previous sessions will show that it can't connect because previous debug workspaces do not exist. diff --git a/packages/opencode/src/control-plane/sse.ts b/packages/opencode/src/control-plane/sse.ts deleted file mode 100644 index 003093a003..0000000000 --- a/packages/opencode/src/control-plane/sse.ts +++ /dev/null @@ -1,66 +0,0 @@ -export async function parseSSE( - body: ReadableStream, - signal: AbortSignal, - onEvent: (event: unknown) => void, -) { - const reader = body.getReader() - const decoder = new TextDecoder() - let buf = "" - let last = "" - let retry = 1000 - - const abort = () => { - void reader.cancel().catch(() => undefined) - } - - signal.addEventListener("abort", abort) - - try { - while (!signal.aborted) { - const chunk = await reader.read().catch(() => ({ done: true, value: undefined as Uint8Array | undefined })) - if (chunk.done) break - - buf += decoder.decode(chunk.value, { stream: true }) - buf = buf.replace(/\r\n/g, "\n").replace(/\r/g, "\n") - - const chunks = buf.split("\n\n") - buf = chunks.pop() ?? "" - - chunks.forEach((chunk) => { - const data: string[] = [] - chunk.split("\n").forEach((line) => { - if (line.startsWith("data:")) { - data.push(line.replace(/^data:\s*/, "")) - return - } - if (line.startsWith("id:")) { - last = line.replace(/^id:\s*/, "") - return - } - if (line.startsWith("retry:")) { - const parsed = Number.parseInt(line.replace(/^retry:\s*/, ""), 10) - if (!Number.isNaN(parsed)) retry = parsed - } - }) - - if (!data.length) return - const raw = data.join("\n") - try { - onEvent(JSON.parse(raw)) - } catch { - onEvent({ - type: "sse.message", - properties: { - data: raw, - id: last || undefined, - retry, - }, - }) - } - }) - } - } finally { - signal.removeEventListener("abort", abort) - reader.releaseLock() - } -} diff --git a/packages/opencode/src/control-plane/types.ts b/packages/opencode/src/control-plane/types.ts index af16c04902..7f3aad7ed1 100644 --- a/packages/opencode/src/control-plane/types.ts +++ b/packages/opencode/src/control-plane/types.ts @@ -17,12 +17,12 @@ export const WorkspaceInfo = Schema.Struct({ .pipe(withStatics((s) => ({ zod: zod(s) }))) export type WorkspaceInfo = DeepMutable> -export const WorkspaceAdaptorEntry = Schema.Struct({ +export const WorkspaceAdapterEntry = Schema.Struct({ type: Schema.String, name: Schema.String, description: Schema.String, }).pipe(withStatics((s) => ({ zod: zod(s) }))) -export type WorkspaceAdaptorEntry = Schema.Schema.Type +export type WorkspaceAdapterEntry = Schema.Schema.Type export type Target = | { @@ -35,7 +35,7 @@ export type Target = headers?: HeadersInit } -export type WorkspaceAdaptor = { +export type WorkspaceAdapter = { name: string description: string configure(info: WorkspaceInfo): WorkspaceInfo | Promise diff --git a/packages/opencode/src/control-plane/util.ts b/packages/opencode/src/control-plane/util.ts index 023c2ae150..35bc87163b 100644 --- a/packages/opencode/src/control-plane/util.ts +++ b/packages/opencode/src/control-plane/util.ts @@ -1,22 +1,23 @@ import { GlobalBus, type GlobalEvent } from "@/bus/global" +import { Effect } from "effect" export function waitEvent(input: { timeout: number; signal?: AbortSignal; fn: (event: GlobalEvent) => boolean }) { - if (input.signal?.aborted) return Promise.reject(input.signal.reason ?? new Error("Request aborted")) + if (input.signal?.aborted) return Effect.fail(input.signal.reason ?? new Error("Request aborted")) - return new Promise((resolve, reject) => { + return Effect.callback((resume) => { const abort = () => { cleanup() - reject(input.signal?.reason ?? new Error("Request aborted")) + resume(Effect.fail(input.signal?.reason ?? new Error("Request aborted"))) } const handler = (event: GlobalEvent) => { try { if (!input.fn(event)) return cleanup() - resolve() + resume(Effect.void) } catch (error) { cleanup() - reject(error) + resume(Effect.fail(error)) } } @@ -28,10 +29,11 @@ export function waitEvent(input: { timeout: number; signal?: AbortSignal; fn: (e const timeout = setTimeout(() => { cleanup() - reject(new Error("Timed out waiting for global event")) + resume(Effect.fail(new Error("Timed out waiting for global event"))) }, input.timeout) GlobalBus.on("event", handler) input.signal?.addEventListener("abort", abort, { once: true }) + return Effect.sync(cleanup) }) } diff --git a/packages/opencode/src/control-plane/workspace.ts b/packages/opencode/src/control-plane/workspace.ts index 0762cb34af..a821f13377 100644 --- a/packages/opencode/src/control-plane/workspace.ts +++ b/packages/opencode/src/control-plane/workspace.ts @@ -1,6 +1,5 @@ -import { Schema } from "effect" -import { setTimeout as sleep } from "node:timers/promises" -import { fn } from "@/util/fn" +import { Context, Effect, FiberMap, Layer, Schema, Stream } from "effect" +import { FetchHttpClient, HttpBody, HttpClient, HttpClientError, HttpClientRequest } from "effect/unstable/http" import { Database } from "@/storage/db" import { asc } from "drizzle-orm" import { eq } from "drizzle-orm" @@ -17,17 +16,16 @@ import { Filesystem } from "@/util/filesystem" import { ProjectID } from "@/project/schema" import { Slug } from "@opencode-ai/core/util/slug" import { WorkspaceTable } from "./workspace.sql" -import { getAdaptor } from "./adaptors" +import { getAdapter } from "./adapters" import { type WorkspaceInfo, WorkspaceInfo as WorkspaceInfoSchema } from "./types" import { WorkspaceID } from "./schema" -import { parseSSE } from "./sse" import { Session } from "@/session/session" import { SessionTable } from "@/session/session.sql" import { SessionID } from "@/session/schema" import { errorData } from "@/util/error" -import { AppRuntime } from "@/effect/app-runtime" import { waitEvent } from "./util" import { WorkspaceContext } from "./workspace-context" +import { EffectBridge } from "@/effect/bridge" import { NonNegativeInt, withStatics } from "@/util/schema" import { zod as effectZod, zodObject } from "@/util/effect-zod" @@ -76,6 +74,11 @@ function fromRow(row: typeof WorkspaceTable.$inferSelect): Info { } } +const db = (fn: (d: Parameters[0] extends (trx: infer D) => any ? D : never) => T) => + Effect.sync(() => Database.use(fn)) + +const log = Log.create({ service: "workspace-sync" }) + export const CreateInput = Schema.Struct({ id: Schema.optional(WorkspaceID), type: Info.fields.type, @@ -85,286 +88,759 @@ export const CreateInput = Schema.Struct({ }).pipe(withStatics((s) => ({ zod: effectZod(s), zodObject: zodObject(s) }))) export type CreateInput = Schema.Schema.Type -export const create = fn(CreateInput.zod, async (input) => { - const id = WorkspaceID.ascending(input.id) - const adaptor = await getAdaptor(input.projectID, input.type) - - const config = await adaptor.configure({ ...input, id, name: Slug.create(), directory: null }) - - const info: Info = { - id, - type: config.type, - branch: config.branch ?? null, - name: config.name ?? null, - directory: config.directory ?? null, - extra: config.extra ?? null, - projectID: input.projectID, - } - - Database.use((db) => { - db.insert(WorkspaceTable) - .values({ - id: info.id, - type: info.type, - branch: info.branch, - name: info.name, - directory: info.directory, - extra: info.extra, - project_id: info.projectID, - }) - .run() - }) - - const env = { - KILO_AUTH_CONTENT: JSON.stringify(await AppRuntime.runPromise(Auth.Service.use((auth) => auth.all()))), - KILO_WORKSPACE_ID: config.id, - KILO_EXPERIMENTAL_WORKSPACES: "true", - OTEL_EXPORTER_OTLP_HEADERS: process.env.OTEL_EXPORTER_OTLP_HEADERS, - OTEL_EXPORTER_OTLP_ENDPOINT: process.env.OTEL_EXPORTER_OTLP_ENDPOINT, - OTEL_RESOURCE_ATTRIBUTES: process.env.OTEL_RESOURCE_ATTRIBUTES, - } - await adaptor.create(config, env) - - startSync(info) - - await waitEvent({ - timeout: TIMEOUT, - fn(event) { - if (event.workspace === info.id && event.payload.type === Event.Status.type) { - const { status } = event.payload.properties - return status === "error" || status === "connected" - } - return false - }, - }) - - return info -}) - export const SessionRestoreInput = Schema.Struct({ workspaceID: WorkspaceID, sessionID: SessionID, }).pipe(withStatics((s) => ({ zod: effectZod(s), zodObject: zodObject(s) }))) export type SessionRestoreInput = Schema.Schema.Type -export const sessionRestore = fn(SessionRestoreInput.zod, async (input) => { - log.info("session restore requested", { - workspaceID: input.workspaceID, - sessionID: input.sessionID, - }) - try { - const space = await get(input.workspaceID) - if (!space) throw new Error(`Workspace not found: ${input.workspaceID}`) +export class SyncHttpError extends Schema.TaggedErrorClass()("WorkspaceSyncHttpError", { + message: Schema.String, + status: Schema.Number, + body: Schema.optional(Schema.String), +}) {} - const adaptor = await getAdaptor(space.projectID, space.type) - const target = await adaptor.target(space) +export class WorkspaceNotFoundError extends Schema.TaggedErrorClass()( + "WorkspaceNotFoundError", + { + message: Schema.String, + workspaceID: WorkspaceID, + }, +) {} - // Need to switch the workspace of the session - SyncEvent.run(Session.Event.Updated, { - sessionID: input.sessionID, - info: { - workspaceID: input.workspaceID, - }, - }) +export class SessionEventsNotFoundError extends Schema.TaggedErrorClass()( + "WorkspaceSessionEventsNotFoundError", + { + message: Schema.String, + sessionID: SessionID, + }, +) {} - const rows = Database.use((db) => - db - .select({ - id: EventTable.id, - aggregateID: EventTable.aggregate_id, - seq: EventTable.seq, - type: EventTable.type, - data: EventTable.data, - }) - .from(EventTable) - .where(eq(EventTable.aggregate_id, input.sessionID)) - .orderBy(asc(EventTable.seq)) - .all(), - ) - if (rows.length === 0) throw new Error(`No events found for session: ${input.sessionID}`) +export class SessionRestoreHttpError extends Schema.TaggedErrorClass()( + "WorkspaceSessionRestoreHttpError", + { + message: Schema.String, + workspaceID: WorkspaceID, + sessionID: SessionID, + status: Schema.Number, + body: Schema.String, + }, +) {} - const all = rows +export class SyncTimeoutError extends Schema.TaggedErrorClass()("WorkspaceSyncTimeoutError", { + message: Schema.String, + state: Schema.Record(Schema.String, Schema.Number), +}) {} + +export class SyncAbortedError extends Schema.TaggedErrorClass()("WorkspaceSyncAbortedError", { + message: Schema.String, + cause: Schema.optional(Schema.Defect), +}) {} + +type CreateError = Auth.AuthError +type SessionRestoreError = + | WorkspaceNotFoundError + | SessionEventsNotFoundError + | SessionRestoreHttpError + | HttpClientError.HttpClientError +type WaitForSyncError = SyncTimeoutError | SyncAbortedError +type SyncLoopError = SyncHttpError | HttpClientError.HttpClientError + +export interface Interface { + readonly create: (input: CreateInput) => Effect.Effect + readonly sessionRestore: (input: SessionRestoreInput) => Effect.Effect<{ total: number }, SessionRestoreError> + readonly list: (project: Project.Info) => Effect.Effect + readonly get: (id: WorkspaceID) => Effect.Effect + readonly remove: (id: WorkspaceID) => Effect.Effect + readonly status: () => Effect.Effect + readonly isSyncing: (workspaceID: WorkspaceID) => Effect.Effect + readonly waitForSync: ( + workspaceID: WorkspaceID, + state: Record, + signal?: AbortSignal, + ) => Effect.Effect + readonly startWorkspaceSyncing: (projectID: ProjectID) => Effect.Effect +} + +export class Service extends Context.Service()("@opencode/Workspace") {} + +export const layer = Layer.effect( + Service, + Effect.gen(function* () { + const auth = yield* Auth.Service + const session = yield* Session.Service + const http = yield* HttpClient.HttpClient + const sync = yield* SyncEvent.Service + const connections = new Map() + const syncFibers = yield* FiberMap.make() + + const setStatus = (id: WorkspaceID, status: ConnectionStatus["status"]) => { + const prev = connections.get(id) + if (prev?.status === status) return + const next = { workspaceID: id, status } + connections.set(id, next) - const size = 10 - const sets = Array.from({ length: Math.ceil(all.length / size) }, (_, i) => all.slice(i * size, (i + 1) * size)) - const total = sets.length - log.info("session restore prepared", { - workspaceID: input.workspaceID, - sessionID: input.sessionID, - workspaceType: space.type, - directory: space.directory, - target: target.type === "remote" ? String(route(target.url, "/sync/replay")) : target.directory, - events: all.length, - batches: total, - first: all[0]?.seq, - last: all.at(-1)?.seq, - }) - GlobalBus.emit("event", { - directory: "global", - workspace: input.workspaceID, - payload: { - type: Event.Restore.type, - properties: { - workspaceID: input.workspaceID, - sessionID: input.sessionID, - total, - step: 0, - }, - }, - }) - for (const [i, events] of sets.entries()) { - log.info("session restore batch starting", { - workspaceID: input.workspaceID, - sessionID: input.sessionID, - step: i + 1, - total, - events: events.length, - first: events[0]?.seq, - last: events.at(-1)?.seq, - target: target.type === "remote" ? String(route(target.url, "/sync/replay")) : target.directory, - }) - if (target.type === "local") { - SyncEvent.replayAll(events) - log.info("session restore batch replayed locally", { - workspaceID: input.workspaceID, - sessionID: input.sessionID, - step: i + 1, - total, - events: events.length, - }) - } else { - const url = route(target.url, "/sync/replay") - const headers = new Headers(target.headers) - headers.set("content-type", "application/json") - const res = await fetch(url, { - method: "POST", - headers, - body: JSON.stringify({ - directory: space.directory ?? "", - events, - }), - }) - if (!res.ok) { - const body = await res.text() - log.error("session restore batch failed", { - workspaceID: input.workspaceID, - sessionID: input.sessionID, - step: i + 1, - total, - status: res.status, - body, - }) - throw new Error( - `Failed to replay session ${input.sessionID} into workspace ${input.workspaceID}: HTTP ${res.status} ${body}`, - ) - } - log.info("session restore batch posted", { - workspaceID: input.workspaceID, - sessionID: input.sessionID, - step: i + 1, - total, - status: res.status, - }) - } GlobalBus.emit("event", { directory: "global", - workspace: input.workspaceID, + workspace: id, payload: { - type: Event.Restore.type, - properties: { - workspaceID: input.workspaceID, - sessionID: input.sessionID, - total, - step: i + 1, - }, + type: Event.Status.type, + properties: next, }, }) } - log.info("session restore complete", { - workspaceID: input.workspaceID, - sessionID: input.sessionID, - batches: total, + const connectSSE = Effect.fn("Workspace.connectSSE")(function* ( + url: URL | string, + headers: HeadersInit | undefined, + ) { + const response = yield* http.execute( + HttpClientRequest.get(route(url, "/global/event"), { + headers: new Headers(headers), + accept: "text/event-stream", + }), + ) + if (response.status < 200 || response.status >= 300) { + return yield* new SyncHttpError({ + message: `Workspace sync HTTP failure: ${response.status}`, + status: response.status, + }) + } + return response.stream }) - return { - total, - } - } catch (err) { - log.error("session restore failed", { - workspaceID: input.workspaceID, - sessionID: input.sessionID, - error: errorData(err), + const parseSSE = Effect.fn("Workspace.parseSSE")(function* ( + stream: Stream.Stream, + onEvent: (event: unknown) => Effect.Effect, + ) { + yield* stream.pipe( + Stream.decodeText(), + Stream.splitLines, + Stream.mapAccum( + () => ({ data: [] as string[], id: undefined as string | undefined, retry: 1000 }), + (state, line) => { + if (line === "") { + if (!state.data.length) return [state, []] + return [{ ...state, data: [] }, [{ data: state.data.join("\n"), id: state.id, retry: state.retry }]] + } + + const index = line.indexOf(":") + const field = index === -1 ? line : line.slice(0, index) + const value = index === -1 ? "" : line.slice(index + (line[index + 1] === " " ? 2 : 1)) + + if (field === "data") return [{ ...state, data: [...state.data, value] }, []] + if (field === "id") return [{ ...state, id: value }, []] + if (field === "retry") { + const retry = Number.parseInt(value, 10) + return [Number.isNaN(retry) ? state : { ...state, retry }, []] + } + return [state, []] + }, + { + onHalt: (state) => + state.data.length ? [{ data: state.data.join("\n"), id: state.id, retry: state.retry }] : [], + }, + ), + Stream.map((event) => { + try { + return JSON.parse(event.data) as unknown + } catch { + return { + type: "sse.message", + properties: { + data: event.data, + id: event.id || undefined, + retry: event.retry, + }, + } + } + }), + Stream.runForEach(onEvent), + ) }) - throw err - } -}) -export function list(project: Project.Info) { - const rows = Database.use((db) => - db.select().from(WorkspaceTable).where(eq(WorkspaceTable.project_id, project.id)).all(), - ) - const spaces = rows.map(fromRow).sort((a, b) => a.id.localeCompare(b.id)) - return spaces -} + const syncHistory = Effect.fn("Workspace.syncHistory")(function* ( + space: Info, + url: URL | string, + headers: HeadersInit | undefined, + ) { + const sessionIDs = yield* db((db) => + db + .select({ id: SessionTable.id }) + .from(SessionTable) + .where(eq(SessionTable.workspace_id, space.id)) + .all() + .map((row) => row.id), + ) + const state = sessionIDs.length + ? Object.fromEntries( + (yield* db((db) => + db.select().from(EventSequenceTable).where(inArray(EventSequenceTable.aggregate_id, sessionIDs)).all(), + )).map((row) => [row.aggregate_id, row.seq]), + ) + : {} -export const get = fn(WorkspaceID.zod, async (id) => { - const row = Database.use((db) => db.select().from(WorkspaceTable).where(eq(WorkspaceTable.id, id)).get()) - if (!row) return - return fromRow(row) -}) + log.info("syncing workspace history", { + workspaceID: space.id, + sessions: sessionIDs.length, + known: Object.keys(state).length, + }) -export const remove = fn(WorkspaceID.zod, async (id) => { - const sessions = Database.use((db) => - db.select({ id: SessionTable.id }).from(SessionTable).where(eq(SessionTable.workspace_id, id)).all(), - ) - for (const session of sessions) { - await AppRuntime.runPromise(Session.Service.use((svc) => svc.remove(session.id))) - } + const response = yield* http.execute( + HttpClientRequest.post(route(url, "/sync/history"), { + headers: new Headers(headers), + body: HttpBody.jsonUnsafe(state), + }), + ) - const row = Database.use((db) => db.select().from(WorkspaceTable).where(eq(WorkspaceTable.id, id)).get()) + if (response.status < 200 || response.status >= 300) { + const body = yield* response.text + return yield* new SyncHttpError({ + message: `Workspace history HTTP failure: ${response.status} ${body}`, + status: response.status, + body, + }) + } - if (row) { - stopSync(id) + const events = (yield* response.json) as HistoryEvent[] - const info = fromRow(row) - try { - const adaptor = await getAdaptor(info.projectID, row.type) - await adaptor.remove(info) - } catch { - log.error("adaptor not available when removing workspace", { type: row.type }) - } - Database.use((db) => db.delete(WorkspaceTable).where(eq(WorkspaceTable.id, id)).run()) - return info - } -}) + log.info("workspace history synced", { + workspaceID: space.id, + events: events.length, + }) + + yield* Effect.promise(async () => { + await WorkspaceContext.provide({ + workspaceID: space.id, + async fn() { + await Effect.runPromise( + Effect.forEach( + events, + (event) => + sync.replay( + { + id: event.id, + aggregateID: event.aggregate_id, + seq: event.seq, + type: event.type, + data: event.data, + }, + { publish: true }, + ), + { discard: true }, + ), + ) + }, + }) + }) + }) + + const syncWorkspaceLoop = Effect.fn("Workspace.syncWorkspaceLoop")(function* (space: Info) { + const adapter = getAdapter(space.projectID, space.type) + const target = yield* EffectBridge.fromPromise(() => adapter.target(space)) + + if (target.type === "local") return + + let attempt = 0 + + while (true) { + log.info("connecting to global sync", { workspace: space.name }) + setStatus(space.id, "connecting") + + const stream = yield* connectSSE(target.url, target.headers).pipe( + Effect.tap(() => syncHistory(space, target.url, target.headers)), + Effect.catch((err) => + Effect.sync(() => { + setStatus(space.id, "error") + log.info("failed to connect to global sync", { + workspace: space.name, + err, + }) + return null + }), + ), + ) + + if (stream) { + attempt = 0 + + log.info("global sync connected", { workspace: space.name }) + setStatus(space.id, "connected") + + yield* parseSSE(stream, (evt) => + Effect.gen(function* () { + if (!evt || typeof evt !== "object" || !("payload" in evt)) return + const payload = evt.payload as { type?: string; syncEvent?: SyncEvent.SerializedEvent } + if (payload.type === "server.heartbeat") return + + if (payload.type === "sync" && payload.syncEvent) { + const failed = yield* sync.replay(payload.syncEvent).pipe( + Effect.as(false), + Effect.catchCause((error) => + Effect.sync(() => { + log.info("failed to replay global event", { + workspaceID: space.id, + error, + }) + return true + }), + ), + ) + if (failed) return + } + + try { + const event = evt as { directory?: string; project?: string; payload: unknown } + GlobalBus.emit("event", { + directory: event.directory, + project: event.project, + workspace: space.id, + payload: event.payload, + }) + } catch (error) { + log.info("failed to replay global event", { + workspaceID: space.id, + error, + }) + } + }), + ) + + log.info("disconnected from global sync: " + space.id) + setStatus(space.id, "disconnected") + } + + // Back off reconnect attempts up to 2 minutes while the workspace + // stays unavailable. + yield* Effect.sleep(`${Math.min(120_000, 1_000 * 2 ** attempt)} millis`) + attempt += 1 + } + }) + + const startSync = Effect.fn("Workspace.startSync")(function* (space: Info) { + if (!Flag.KILO_EXPERIMENTAL_WORKSPACES) return + + const adapter = getAdapter(space.projectID, space.type) + const target = yield* EffectBridge.fromPromise(() => adapter.target(space)) + + if (target.type === "local") { + setStatus(space.id, (yield* Effect.promise(() => Filesystem.exists(target.directory))) ? "connected" : "error") + return + } + + const exists = yield* FiberMap.has(syncFibers, space.id) + if (exists && connections.get(space.id)?.status !== "error") return + + setStatus(space.id, "disconnected") + + yield* FiberMap.run( + syncFibers, + space.id, + // TODO: look into `tapError` to set the status but still + // allow the fiber to fail and automatically get removed + syncWorkspaceLoop(space).pipe( + Effect.catch((error) => + Effect.sync(() => { + setStatus(space.id, "error") + log.warn("workspace listener failed", { + workspaceID: space.id, + error, + }) + }), + ), + ), + ) + }) + + const stopSync = Effect.fn("Workspace.stopSync")(function* (id: WorkspaceID) { + yield* FiberMap.remove(syncFibers, id) + connections.delete(id) + }) + + const create = Effect.fn("Workspace.create")(function* (input: CreateInput) { + const id = WorkspaceID.ascending(input.id) + const adapter = getAdapter(input.projectID, input.type) + const config = yield* EffectBridge.fromPromise(() => + adapter.configure({ ...input, id, name: Slug.create(), directory: null }), + ) + + const info: Info = { + id, + type: config.type, + branch: config.branch ?? null, + name: config.name ?? null, + directory: config.directory ?? null, + extra: config.extra ?? null, + projectID: input.projectID, + } + + yield* db((db) => { + db.insert(WorkspaceTable) + .values({ + id: info.id, + type: info.type, + branch: info.branch, + name: info.name, + directory: info.directory, + extra: info.extra, + project_id: info.projectID, + }) + .run() + }) + + const env = { + KILO_AUTH_CONTENT: JSON.stringify(yield* auth.all()), + KILO_WORKSPACE_ID: config.id, + KILO_EXPERIMENTAL_WORKSPACES: "true", + OTEL_EXPORTER_OTLP_HEADERS: process.env.OTEL_EXPORTER_OTLP_HEADERS, + OTEL_EXPORTER_OTLP_ENDPOINT: process.env.OTEL_EXPORTER_OTLP_ENDPOINT, + OTEL_RESOURCE_ATTRIBUTES: process.env.OTEL_RESOURCE_ATTRIBUTES, + } + + yield* EffectBridge.fromPromise(() => adapter.create(config, env)) + yield* Effect.all( + [ + waitEvent({ + timeout: TIMEOUT, + fn(event) { + if (event.workspace === info.id && event.payload.type === Event.Status.type) { + const { status } = event.payload.properties + return status === "error" || status === "connected" + } + return false + }, + }), + startSync(info), + ], + { concurrency: 2, discard: true }, + ) + + return info + }) + + const sessionRestore = Effect.fn("Workspace.sessionRestore")(function* (input: SessionRestoreInput) { + return yield* Effect.gen(function* () { + log.info("session restore requested", { + workspaceID: input.workspaceID, + sessionID: input.sessionID, + }) + + const space = yield* get(input.workspaceID) + if (!space) + return yield* new WorkspaceNotFoundError({ + message: `Workspace not found: ${input.workspaceID}`, + workspaceID: input.workspaceID, + }) + + const adapter = getAdapter(space.projectID, space.type) + const target = yield* EffectBridge.fromPromise(() => adapter.target(space)) + + yield* sync.run(Session.Event.Updated, { + sessionID: input.sessionID, + info: { + workspaceID: input.workspaceID, + }, + }) + + const rows = yield* db((db) => + db + .select({ + id: EventTable.id, + aggregateID: EventTable.aggregate_id, + seq: EventTable.seq, + type: EventTable.type, + data: EventTable.data, + }) + .from(EventTable) + .where(eq(EventTable.aggregate_id, input.sessionID)) + .orderBy(asc(EventTable.seq)) + .all(), + ) + if (rows.length === 0) + return yield* new SessionEventsNotFoundError({ + message: `No events found for session: ${input.sessionID}`, + sessionID: input.sessionID, + }) + + const size = 10 + // TODO: look into using effect APIs to process this in chunks + const sets = Array.from({ length: Math.ceil(rows.length / size) }, (_, i) => + rows.slice(i * size, (i + 1) * size), + ) + const total = sets.length + + log.info("session restore prepared", { + workspaceID: input.workspaceID, + sessionID: input.sessionID, + workspaceType: space.type, + directory: space.directory, + target: target.type === "remote" ? String(route(target.url, "/sync/replay")) : target.directory, + events: rows.length, + batches: total, + first: rows[0]?.seq, + last: rows.at(-1)?.seq, + }) + + yield* Effect.sync(() => + GlobalBus.emit("event", { + directory: "global", + workspace: input.workspaceID, + payload: { + type: Event.Restore.type, + properties: { + workspaceID: input.workspaceID, + sessionID: input.sessionID, + total, + step: 0, + }, + }, + }), + ) + + for (const [i, events] of sets.entries()) { + log.info("session restore batch starting", { + workspaceID: input.workspaceID, + sessionID: input.sessionID, + step: i + 1, + total, + events: events.length, + first: events[0]?.seq, + last: events.at(-1)?.seq, + target: target.type === "remote" ? String(route(target.url, "/sync/replay")) : target.directory, + }) + + if (target.type === "local") { + yield* sync.replayAll(events) + log.info("session restore batch replayed locally", { + workspaceID: input.workspaceID, + sessionID: input.sessionID, + step: i + 1, + total, + events: events.length, + }) + } else { + const url = route(target.url, "/sync/replay") + const res = yield* http.execute( + HttpClientRequest.post(url, { + headers: new Headers(target.headers), + body: HttpBody.jsonUnsafe({ + directory: space.directory ?? "", + events, + }), + }), + ) + + if (res.status < 200 || res.status >= 300) { + const body = yield* res.text + log.error("session restore batch failed", { + workspaceID: input.workspaceID, + sessionID: input.sessionID, + step: i + 1, + total, + status: res.status, + body, + }) + return yield* new SessionRestoreHttpError({ + message: `Failed to replay session ${input.sessionID} into workspace ${input.workspaceID}: HTTP ${res.status} ${body}`, + workspaceID: input.workspaceID, + sessionID: input.sessionID, + status: res.status, + body, + }) + } + + log.info("session restore batch posted", { + workspaceID: input.workspaceID, + sessionID: input.sessionID, + step: i + 1, + total, + status: res.status, + }) + } + + yield* Effect.sync(() => + GlobalBus.emit("event", { + directory: "global", + workspace: input.workspaceID, + payload: { + type: Event.Restore.type, + properties: { + workspaceID: input.workspaceID, + sessionID: input.sessionID, + total, + step: i + 1, + }, + }, + }), + ) + } + + log.info("session restore complete", { + workspaceID: input.workspaceID, + sessionID: input.sessionID, + batches: total, + }) + + return { total } + }).pipe( + Effect.tapError((err) => + Effect.sync(() => + log.error("session restore failed", { + workspaceID: input.workspaceID, + sessionID: input.sessionID, + error: errorData(err), + }), + ), + ), + ) + }) + + const list = Effect.fn("Workspace.list")(function* (project: Project.Info) { + return yield* db((db) => + db + .select() + .from(WorkspaceTable) + .where(eq(WorkspaceTable.project_id, project.id)) + .all() + .map(fromRow) + .sort((a, b) => a.id.localeCompare(b.id)), + ) + }) + + const get = Effect.fn("Workspace.get")(function* (id: WorkspaceID) { + const row = yield* db((db) => db.select().from(WorkspaceTable).where(eq(WorkspaceTable.id, id)).get()) + if (!row) return + return fromRow(row) + }) + + const remove = Effect.fn("Workspace.remove")(function* (id: WorkspaceID) { + const sessions = yield* db((db) => + db.select({ id: SessionTable.id }).from(SessionTable).where(eq(SessionTable.workspace_id, id)).all(), + ) + yield* Effect.forEach(sessions, (sessionInfo) => session.remove(sessionInfo.id), { discard: true }) + + const row = yield* db((db) => db.select().from(WorkspaceTable).where(eq(WorkspaceTable.id, id)).get()) + if (!row) return + + yield* stopSync(id) + + const info = fromRow(row) + yield* Effect.catchCause( + Effect.gen(function* () { + const adapter = getAdapter(info.projectID, row.type) + yield* EffectBridge.fromPromise(() => adapter.remove(info)) + }), + () => + Effect.sync(() => { + log.error("adapter not available when removing workspace", { type: row.type }) + }), + ) + + yield* db((db) => db.delete(WorkspaceTable).where(eq(WorkspaceTable.id, id)).run()) + return info + }) + + const status = Effect.fn("Workspace.status")(function* () { + return [...connections.values()] + }) + + const isSyncing = Effect.fn("Workspace.isSyncing")(function* (workspaceID: WorkspaceID) { + const exists = yield* FiberMap.has(syncFibers, workspaceID) + return exists && connections.get(workspaceID)?.status !== "error" + }) + + const waitForSync = Effect.fn("Workspace.waitForSync")(function* ( + workspaceID: WorkspaceID, + state: Record, + signal?: AbortSignal, + ) { + if (synced(state)) return + + yield* Effect.catch( + waitEvent({ + timeout: TIMEOUT, + signal, + fn(event) { + if (event.workspace !== workspaceID && event.payload.type !== "sync") { + return false + } + return synced(state) + }, + }), + (): Effect.Effect => + signal?.aborted + ? Effect.fail( + new SyncAbortedError({ + message: signal.reason instanceof Error ? signal.reason.message : "Request aborted", + cause: signal.reason, + }), + ) + : Effect.fail( + new SyncTimeoutError({ + message: `Timed out waiting for sync fence: ${JSON.stringify(state)}`, + state, + }), + ), + ) + }) + + const startWorkspaceSyncing = Effect.fn("Workspace.startWorkspaceSyncing")(function* (projectID: ProjectID) { + // This session table join makes this query only return + // workspaces that have sessions + const rows = yield* db((db) => + db + .selectDistinct({ workspace: WorkspaceTable }) + .from(WorkspaceTable) + .innerJoin(SessionTable, eq(SessionTable.workspace_id, WorkspaceTable.id)) + .where(eq(WorkspaceTable.project_id, projectID)) + .all(), + ) + + for (const { workspace } of rows) { + yield* startSync(fromRow(workspace)).pipe( + Effect.catch((error) => + Effect.sync(() => { + setStatus(workspace.id, "error") + log.warn("workspace sync failed to start", { + workspaceID: workspace.id, + error, + }) + }), + ), + Effect.forkDetach, + ) + } + }) + + return Service.of({ + create, + sessionRestore, + list, + get, + remove, + status, + isSyncing, + waitForSync, + startWorkspaceSyncing, + }) + }), +) + +export const defaultLayer = layer.pipe( + Layer.provide(Auth.defaultLayer), + Layer.provide(Session.defaultLayer), + Layer.provide(SyncEvent.defaultLayer), + Layer.provide(FetchHttpClient.layer), +) -const connections = new Map() -const aborts = new Map() const TIMEOUT = 5000 -function setStatus(id: WorkspaceID, status: ConnectionStatus["status"]) { - const prev = connections.get(id) - if (prev?.status === status) return - const next = { workspaceID: id, status } - connections.set(id, next) - - if (status === "error") { - aborts.delete(id) - } - - GlobalBus.emit("event", { - directory: "global", - workspace: id, - payload: { - type: Event.Status.type, - properties: next, - }, - }) -} - -export function status(): ConnectionStatus[] { - return [...connections.values()] +type HistoryEvent = { + id: string + aggregate_id: string + seq: number + type: string + data: Record } function synced(state: Record) { @@ -389,32 +865,6 @@ function synced(state: Record) { }) } -export async function isSyncing(workspaceID: WorkspaceID) { - return aborts.has(workspaceID) -} - -export async function waitForSync(workspaceID: WorkspaceID, state: Record, signal?: AbortSignal) { - if (synced(state)) return - - try { - await waitEvent({ - timeout: TIMEOUT, - signal, - fn(event) { - if (event.workspace !== workspaceID && event.payload.type !== "sync") { - return false - } - return synced(state) - }, - }) - } catch { - if (signal?.aborted) throw signal.reason ?? new Error("Request aborted") - throw new Error(`Timed out waiting for sync fence: ${JSON.stringify(state)}`) - } -} - -const log = Log.create({ service: "workspace-sync" }) - function route(url: string | URL, path: string) { const next = new URL(url) next.pathname = `${next.pathname.replace(/\/$/, "")}${path}` @@ -423,198 +873,4 @@ function route(url: string | URL, path: string) { return next } -async function connectSSE(url: URL | string, headers: HeadersInit | undefined, signal: AbortSignal) { - const res = await fetch(route(url, "/global/event"), { - method: "GET", - headers, - signal, - }) - - if (!res.ok) throw new Error(`Workspace sync HTTP failure: ${res.status}`) - if (!res.body) throw new Error("No response body from global sync") - - return res.body -} - -async function syncHistory(space: Info, url: URL | string, headers: HeadersInit | undefined, signal: AbortSignal) { - const sessionIDs = Database.use((db) => - db - .select({ id: SessionTable.id }) - .from(SessionTable) - .where(eq(SessionTable.workspace_id, space.id)) - .all() - .map((row) => row.id), - ) - const state = sessionIDs.length - ? Object.fromEntries( - Database.use((db) => - db.select().from(EventSequenceTable).where(inArray(EventSequenceTable.aggregate_id, sessionIDs)).all(), - ).map((row) => [row.aggregate_id, row.seq]), - ) - : {} - - log.info("syncing workspace history", { - workspaceID: space.id, - sessions: sessionIDs.length, - known: Object.keys(state).length, - }) - - const requestHeaders = new Headers(headers) - requestHeaders.set("content-type", "application/json") - - const res = await fetch(route(url, "/sync/history"), { - method: "POST", - headers: requestHeaders, - body: JSON.stringify(state), - signal, - }) - - if (!res.ok) { - const body = await res.text() - throw new Error(`Workspace history HTTP failure: ${res.status} ${body}`) - } - - const events = await res.json() - - return WorkspaceContext.provide({ - workspaceID: space.id, - fn: () => { - for (const event of events) { - SyncEvent.replay( - { - id: event.id, - aggregateID: event.aggregate_id, - seq: event.seq, - type: event.type, - data: event.data, - }, - { publish: true }, - ) - } - }, - }) - - log.info("workspace history synced", { - workspaceID: space.id, - events: events.length, - }) -} - -async function syncWorkspaceLoop(space: Info, signal: AbortSignal) { - const adaptor = await getAdaptor(space.projectID, space.type) - const target = await adaptor.target(space) - - if (target.type === "local") return null - - let attempt = 0 - - while (!signal.aborted) { - log.info("connecting to global sync", { workspace: space.name }) - setStatus(space.id, "connecting") - - let stream - try { - stream = await connectSSE(target.url, target.headers, signal) - await syncHistory(space, target.url, target.headers, signal) - } catch (err) { - stream = null - setStatus(space.id, "error") - log.info("failed to connect to global sync", { - workspace: space.name, - err, - }) - } - - if (stream) { - attempt = 0 - - log.info("global sync connected", { workspace: space.name }) - setStatus(space.id, "connected") - - await parseSSE(stream, signal, (evt: any) => { - try { - if (!("payload" in evt)) return - if (evt.payload.type === "server.heartbeat") return - - if (evt.payload.type === "sync") { - SyncEvent.replay(evt.payload.syncEvent as SyncEvent.SerializedEvent) - } - - GlobalBus.emit("event", { - directory: evt.directory, - project: evt.project, - workspace: space.id, - payload: evt.payload, - }) - } catch (err) { - log.info("failed to replay global event", { - workspaceID: space.id, - error: err, - }) - } - }) - - log.info("disconnected from global sync: " + space.id) - setStatus(space.id, "disconnected") - } - - // Back off reconnect attempts up to 2 minutes while the workspace - // stays unavailable. - await sleep(Math.min(120_000, 1_000 * 2 ** attempt)) - attempt += 1 - } -} - -async function startSync(space: Info) { - if (!Flag.KILO_EXPERIMENTAL_WORKSPACES) return - - const adaptor = await getAdaptor(space.projectID, space.type) - const target = await adaptor.target(space) - - if (target.type === "local") { - void Filesystem.exists(target.directory).then((exists) => { - setStatus(space.id, exists ? "connected" : "error") - }) - return - } - - if (aborts.has(space.id)) return true - - setStatus(space.id, "disconnected") - - const abort = new AbortController() - aborts.set(space.id, abort) - - void syncWorkspaceLoop(space, abort.signal).catch((error) => { - aborts.delete(space.id) - - setStatus(space.id, "error") - log.warn("workspace listener failed", { - workspaceID: space.id, - error, - }) - }) -} - -function stopSync(id: WorkspaceID) { - aborts.get(id)?.abort() - aborts.delete(id) - connections.delete(id) -} - -export function startWorkspaceSyncing(projectID: ProjectID) { - const spaces = Database.use((db) => - db - .select({ workspace: WorkspaceTable }) - .from(WorkspaceTable) - .innerJoin(SessionTable, eq(SessionTable.workspace_id, WorkspaceTable.id)) - .where(eq(WorkspaceTable.project_id, projectID)) - .all(), - ) - - for (const row of new Map(spaces.map((row) => [row.workspace.id, row.workspace])).values()) { - void startSync(fromRow(row)) - } -} - export * as Workspace from "./workspace" diff --git a/packages/opencode/src/effect/app-runtime.ts b/packages/opencode/src/effect/app-runtime.ts index fdd3053622..66f3a9b378 100644 --- a/packages/opencode/src/effect/app-runtime.ts +++ b/packages/opencode/src/effect/app-runtime.ts @@ -1,4 +1,4 @@ -import { Layer, ManagedRuntime } from "effect" +import { Effect, Layer, ManagedRuntime } from "effect" import { attach } from "./run-service" import * as Observability from "@opencode-ai/core/effect/observability" @@ -14,6 +14,7 @@ import { FileWatcher } from "@/file/watcher" import { Storage } from "@/storage/storage" import { Snapshot } from "@/snapshot" import { Plugin } from "@/plugin" +import { ModelsDev } from "@/provider/models" import { Provider } from "@/provider/provider" import { ProviderAuth } from "@/provider/auth" import { Agent } from "@/agent/agent" @@ -39,13 +40,17 @@ import { Command } from "@/command" import { Truncate } from "@/tool/truncate" import { ToolRegistry } from "@/tool/registry" import { Format } from "@/format" +import { InstanceBootstrap } from "@/project/bootstrap" +import { InstanceStore } from "@/project/instance-store" import { Project } from "@/project/project" import { Vcs } from "@/project/vcs" +import { Workspace } from "@/control-plane/workspace" import { Worktree } from "@/worktree" import { Pty } from "@/pty" import { Installation } from "@/installation" import { ShareNext } from "@/share/share-next" import { SessionShare } from "@/share/session" +import { SyncEvent } from "@/sync" import { Npm } from "@opencode-ai/core/npm" import { memoMap } from "@opencode-ai/core/effect/memo-map" @@ -63,6 +68,7 @@ export const AppLayer = Layer.mergeAll( Storage.defaultLayer, Snapshot.defaultLayer, Plugin.defaultLayer, + ModelsDev.defaultLayer, Provider.defaultLayer, ProviderAuth.defaultLayer, Agent.defaultLayer, @@ -88,17 +94,24 @@ export const AppLayer = Layer.mergeAll( Truncate.defaultLayer, ToolRegistry.defaultLayer, Format.defaultLayer, + InstanceBootstrap.defaultLayer, + InstanceStore.defaultLayer, Project.defaultLayer, Vcs.defaultLayer, + Workspace.defaultLayer, Worktree.defaultLayer, Pty.defaultLayer, Installation.defaultLayer, ShareNext.defaultLayer, SessionShare.defaultLayer, + SyncEvent.defaultLayer, ).pipe(Layer.provideMerge(Observability.layer)) const rt = ManagedRuntime.make(AppLayer, { memoMap }) type Runtime = Pick + +/** Services provided by AppRuntime — i.e. what an Effect run via AppRuntime.runPromise can yield. */ +export type AppServices = ManagedRuntime.ManagedRuntime.Services const wrap = (effect: Parameters[0]) => attach(effect as never) as never export const AppRuntime: Runtime = { @@ -119,3 +132,15 @@ export const AppRuntime: Runtime = { }, dispose: () => rt.dispose(), } + +let bootstrapRun: Promise> +export function getBootstrapRunEffect(): Promise> { + if (!bootstrapRun) { + bootstrapRun = AppRuntime.runPromise( + Effect.gen(function* () { + return (yield* InstanceBootstrap.Service).run + }), + ) + } + return bootstrapRun +} diff --git a/packages/opencode/src/effect/bridge.ts b/packages/opencode/src/effect/bridge.ts index 281cfa010c..16d8f93669 100644 --- a/packages/opencode/src/effect/bridge.ts +++ b/packages/opencode/src/effect/bridge.ts @@ -1,4 +1,4 @@ -import { Effect, Fiber } from "effect" +import { Effect, Exit, Fiber } from "effect" import { WorkspaceContext } from "@/control-plane/workspace-context" import { Instance, type InstanceContext } from "@/project/instance" import type { WorkspaceID } from "@/control-plane/schema" @@ -9,6 +9,7 @@ import { attachWith } from "./run-service" export interface Shape { readonly promise: (effect: Effect.Effect) => Promise readonly fork: (effect: Effect.Effect) => Fiber.Fiber + readonly run: (effect: Effect.Effect) => Effect.Effect } function restore(instance: InstanceContext | undefined, workspace: WorkspaceID | undefined, fn: () => R): R { @@ -20,6 +21,25 @@ function restore(instance: InstanceContext | undefined, workspace: WorkspaceI return fn() } +/** + * Bridge from Effect into a Promise-returning JS callback while installing + * legacy `Instance.context` and `WorkspaceContext` AsyncLocalStorage for + * the duration of the callback. Effect's `InstanceRef`/`WorkspaceRef` do + * not propagate across async/await boundaries inside `Effect.promise(() => + * async fn)` callbacks that re-enter Effect via `AppRuntime.runPromise`, + * but Node's AsyncLocalStorage does. Use this whenever an Effect crosses + * into JS that may itself spawn new Effect runtimes (workspace adapters, + * legacy plugins, etc.). + * + * Mirrors `Effect.promise` but restores legacy ALS first. + */ +export const fromPromise = (fn: () => Promise | T): Effect.Effect => + Effect.gen(function* () { + const instance = yield* InstanceRef + const workspace = yield* WorkspaceRef + return yield* Effect.promise(() => Promise.resolve(restore(instance, workspace, () => fn()))) + }) + export function make(): Effect.Effect { return Effect.gen(function* () { const ctx = yield* Effect.context() @@ -43,6 +63,14 @@ export function make(): Effect.Effect { restore(instance, workspace, () => Effect.runPromise(wrap(effect))), fork: (effect: Effect.Effect) => restore(instance, workspace, () => Effect.runFork(wrap(effect))), + run: (effect: Effect.Effect) => + Effect.callback((resume) => { + restore(instance, workspace, () => + Effect.runPromiseExit(wrap(effect)).then((exit) => + resume(Exit.isSuccess(exit) ? Effect.succeed(exit.value) : Effect.failCause(exit.cause)), + ), + ) + }), } satisfies Shape }) } diff --git a/packages/opencode/src/effect/config-service.ts b/packages/opencode/src/effect/config-service.ts new file mode 100644 index 0000000000..7fd1572d65 --- /dev/null +++ b/packages/opencode/src/effect/config-service.ts @@ -0,0 +1,67 @@ +import { Config, Context, Effect, Layer } from "effect" + +type ConfigMap = Record> + +/** + * The service shape inferred from an object of Effect `Config` definitions. + */ +export type Shape = { + readonly [Key in keyof Fields]: Config.Success +} + +/** + * A Context service class with generated layers for config-backed services. + */ +export type ServiceClass = Context.ServiceClass & { + /** Provide already-parsed config, useful in tests. */ + readonly layer: (input: Service) => Layer.Layer + /** Parse config once from the active Effect ConfigProvider and provide the service. */ + readonly defaultLayer: Layer.Layer +} + +/** + * Create a Context service whose implementation is derived from Effect `Config`. + * + * This keeps Effect `Config` as the source of truth for env names, defaults, and + * validation while generating a typed service plus convenient production/test + * layers. + * + * ```ts + * class ServerAuthConfig extends ConfigService.Service()( + * "@opencode/ServerAuthConfig", + * { + * password: Config.string("KILO_SERVER_PASSWORD").pipe(Config.option), + * username: Config.string("KILO_SERVER_USERNAME").pipe(Config.withDefault("opencode")), + * }, + * ) {} + * + * const live = ServerAuthConfig.defaultLayer + * const test = ServerAuthConfig.layer({ password: Option.some("secret"), username: "kit" }) + * ``` + */ +export const Service = + () => + (id: Id, fields: Fields) => { + class ConfigTag extends Context.Service>()(id) { + static layer(input: Shape) { + return Layer.succeed(this, this.of(input)) + } + + static get defaultLayer() { + return Layer.effect( + this, + Config.all(fields) + .asEffect() + .pipe( + // oxlint-disable-next-line typescript-eslint/no-unsafe-type-assertion -- Config.all preserves the field shape, but its conditional return type also supports iterable inputs. + Effect.map((config) => this.of(config as Shape)), + ), + ) + } + } + + // oxlint-disable-next-line typescript-eslint/no-unsafe-type-assertion -- The generated class carries typed static helpers. + return ConfigTag as ServiceClass> + } + +export * as ConfigService from "./config-service" diff --git a/packages/opencode/src/effect/run-service.ts b/packages/opencode/src/effect/run-service.ts index 28f1068c36..1f3802e80c 100644 --- a/packages/opencode/src/effect/run-service.ts +++ b/packages/opencode/src/effect/run-service.ts @@ -1,4 +1,4 @@ -import { Effect, Layer, ManagedRuntime } from "effect" +import { Effect, Fiber, Layer, ManagedRuntime } from "effect" import * as Context from "effect/Context" import { Instance } from "@/project/instance" import { LocalContext } from "@/util/local-context" @@ -24,15 +24,20 @@ export function attachWith(effect: Effect.Effect, refs: Refs): } export function attach(effect: Effect.Effect): Effect.Effect { - try { - return attachWith(effect, { - instance: Instance.current, - workspace: WorkspaceContext.workspaceID, - }) - } catch (err) { - if (!(err instanceof LocalContext.NotFound)) throw err - } - return effect + const workspace = WorkspaceContext.workspaceID + const instance = (() => { + try { + return Instance.current + } catch (err) { + if (!(err instanceof LocalContext.NotFound)) throw err + } + })() + if (instance && workspace !== undefined) return attachWith(effect, { instance, workspace }) + const fiber = Fiber.getCurrent() + return attachWith(effect, { + instance: instance ?? (fiber ? Context.getReferenceUnsafe(fiber.context, InstanceRef) : undefined), + workspace: workspace ?? (fiber ? Context.getReferenceUnsafe(fiber.context, WorkspaceRef) : undefined), + }) } export function makeRuntime(service: Context.Service, layer: Layer.Layer) { diff --git a/packages/opencode/src/effect/service-use.ts b/packages/opencode/src/effect/service-use.ts new file mode 100644 index 0000000000..a93cdecbb1 --- /dev/null +++ b/packages/opencode/src/effect/service-use.ts @@ -0,0 +1,38 @@ +import { Context, Effect } from "effect" + +type EffectMethod = (...args: ReadonlyArray) => Effect.Effect + +type ServiceUse = { + readonly [Key in keyof Shape as Shape[Key] extends EffectMethod ? Key : never]: Shape[Key] extends ( + ...args: infer Args + ) => infer Return + ? Args extends ReadonlyArray + ? Return extends Effect.Effect + ? (...args: Args) => Effect.Effect + : never + : never + : never +} + +export const serviceUse = (tag: Context.Service) => { + // This is the only dynamic boundary: TypeScript knows the accessor shape, + // but Proxy property names are runtime values. + const access = new Proxy( + {}, + { + get: (_, key) => { + if (typeof key !== "string") return undefined + return (...args: unknown[]) => + tag.use((service) => { + // oxlint-disable-next-line typescript-eslint/no-unsafe-type-assertion -- Proxy keys are checked at runtime. + const method = service[key as keyof Shape] + if (typeof method !== "function") return Effect.die(new Error(`Service method not found: ${key}`)) + // oxlint-disable-next-line typescript-eslint/no-unsafe-type-assertion -- ServiceUse exposes only Effect-returning methods. + return (method as (...args: unknown[]) => Effect.Effect)(...args) + }) + }, + }, + ) + // oxlint-disable-next-line typescript-eslint/no-unsafe-type-assertion -- Proxy implements the mapped accessor surface lazily. + return access as ServiceUse +} diff --git a/packages/opencode/src/file/index.ts b/packages/opencode/src/file/index.ts index 983680e1fc..4d5fc499fd 100644 --- a/packages/opencode/src/file/index.ts +++ b/packages/opencode/src/file/index.ts @@ -11,7 +11,7 @@ import fuzzysort from "fuzzysort" import ignore from "ignore" import path from "path" import { Global } from "@opencode-ai/core/global" -import { Instance } from "../project/instance" +import { containsPath } from "../project/instance-context" import * as Log from "@opencode-ai/core/util/log" import { Protected } from "./protected" import { Ripgrep } from "./ripgrep" @@ -508,7 +508,7 @@ export const layer = Layer.effect( const ctx = yield* InstanceState.context const full = path.join(ctx.directory, file) - if (!Instance.containsPath(full, ctx)) { + if (!containsPath(full, ctx)) { throw new Error("Access denied: path escapes project directory") } @@ -595,7 +595,7 @@ export const layer = Layer.effect( } const resolved = dir ? path.join(ctx.directory, dir) : ctx.directory - if (!Instance.containsPath(resolved, ctx)) { + if (!containsPath(resolved, ctx)) { throw new Error("Access denied: path escapes project directory") } diff --git a/packages/opencode/src/file/watcher.ts b/packages/opencode/src/file/watcher.ts index 83fbf99b7e..a37ad6a474 100644 --- a/packages/opencode/src/file/watcher.ts +++ b/packages/opencode/src/file/watcher.ts @@ -10,7 +10,6 @@ import { BusEvent } from "@/bus/bus-event" import { InstanceState } from "@/effect/instance-state" import { Flag } from "@opencode-ai/core/flag/flag" import { Git } from "@/git" -import { Instance } from "@/project/instance" import { lazy } from "@/util/lazy" import { Config } from "@/config/config" import { FileIgnore } from "./ignore" @@ -76,25 +75,27 @@ export const layer = Layer.effect( function* () { if (yield* Flag.KILO_EXPERIMENTAL_DISABLE_FILEWATCHER) return - log.info("init", { directory: Instance.directory }) + const ctx = yield* InstanceState.context + + log.info("init", { directory: ctx.directory }) const backend = getBackend() if (!backend) { - log.error("watcher backend not supported", { directory: Instance.directory, platform: process.platform }) + log.error("watcher backend not supported", { directory: ctx.directory, platform: process.platform }) return } const w = watcher() if (!w) return - log.info("watcher backend", { directory: Instance.directory, platform: process.platform, backend }) + log.info("watcher backend", { directory: ctx.directory, platform: process.platform, backend }) const subs: ParcelWatcher.AsyncSubscription[] = [] yield* Effect.addFinalizer(() => Effect.promise(() => Promise.allSettled(subs.map((sub) => sub.unsubscribe()))), ) - const cb: ParcelWatcher.SubscribeCallback = Instance.bind((err, evts) => { + const cb: ParcelWatcher.SubscribeCallback = InstanceState.bind((err, evts) => { if (err) return for (const evt of evts) { if (evt.type === "create") void Bus.publish(Event.Updated, { file: evt.path, event: "add" }) @@ -122,19 +123,14 @@ export const layer = Layer.effect( const cfgIgnores = cfg.watcher?.ignore ?? [] if (yield* Flag.KILO_EXPERIMENTAL_FILEWATCHER) { - yield* subscribe(Instance.directory, [ - ...FileIgnore.PATTERNS, - ...cfgIgnores, - ...protecteds(Instance.directory), - ]) + yield* subscribe(ctx.directory, [...FileIgnore.PATTERNS, ...cfgIgnores, ...protecteds(ctx.directory)]) } - if (Instance.project.vcs === "git") { + if (ctx.project.vcs === "git") { const result = yield* git.run(["rev-parse", "--git-dir"], { - cwd: Instance.project.worktree, + cwd: ctx.worktree, }) - const vcsDir = - result.exitCode === 0 ? path.resolve(Instance.project.worktree, result.text().trim()) : undefined + const vcsDir = result.exitCode === 0 ? path.resolve(ctx.worktree, result.text().trim()) : undefined if (vcsDir && !cfgIgnores.includes(".git") && !cfgIgnores.includes(vcsDir)) { const ignore = (yield* Effect.promise(() => readdir(vcsDir).catch(() => []))).filter( (entry) => entry !== "HEAD", diff --git a/packages/opencode/src/index.ts b/packages/opencode/src/index.ts index 697bb0cb91..8e4fe6ead4 100644 --- a/packages/opencode/src/index.ts +++ b/packages/opencode/src/index.ts @@ -38,7 +38,7 @@ import { RemoteCommand } from "./cli/cmd/remote" // kilocode_change import { DevSetupCommand, DevAliasCommand } from "./kilocode/cli/dev-setup" // kilocode_change // kilocode_change start - Import telemetry, instance disposal, and legacy migration import { Telemetry } from "@kilocode/kilo-telemetry" -import { Instance } from "./project/instance" // kilocode_change +import { InstanceStore } from "./project/instance-store" // kilocode_change import { migrateLegacyKiloAuth, ENV_FEATURE, ENV_VERSION } from "@kilocode/kilo-gateway" // kilocode_change - set feature for tracking. 'serve' is spawned by other services @@ -319,7 +319,7 @@ try { await Telemetry.shutdown() // kilocode_change end - await Instance.disposeAll() // kilocode_change - safety net disposal (no-op if already disposed) + await InstanceStore.disposeAllInstances() // kilocode_change - safety net disposal (no-op if already disposed) // Some subprocesses don't react properly to SIGTERM and similar signals. // Most notably, some docker-container-based MCP servers don't handle such signals unless diff --git a/packages/opencode/src/kilocode/agent/index.ts b/packages/opencode/src/kilocode/agent/index.ts index 8fe3caeb62..2360b17587 100644 --- a/packages/opencode/src/kilocode/agent/index.ts +++ b/packages/opencode/src/kilocode/agent/index.ts @@ -5,6 +5,7 @@ import { Glob } from "@opencode-ai/core/util/glob" import * as Truncate from "../../tool/truncate" import { Config } from "../../config/config" import { Instance } from "../../project/instance" +import { InstanceStore } from "../../project/instance-store" import { makeRuntime } from "@/effect/run-service" import z from "zod" import path from "path" @@ -489,5 +490,5 @@ export async function remove(name: string) { if (!found) throw new RemoveError({ name, message: "no agent file found on disk" }) - await Instance.dispose() + await InstanceStore.disposeInstance(Instance.current) } diff --git a/packages/opencode/src/kilocode/server/instance.ts b/packages/opencode/src/kilocode/server/instance.ts index b0dc6b0cc0..fc374f35be 100644 --- a/packages/opencode/src/kilocode/server/instance.ts +++ b/packages/opencode/src/kilocode/server/instance.ts @@ -20,6 +20,7 @@ import { errors } from "../../server/error" import { ModelCache } from "../../provider/model-cache" import { Database } from "../../storage/db" import { Instance } from "../../project/instance" +import { InstanceStore } from "../../project/instance-store" import { Session } from "../../session/session" import { Identifier } from "../../id/id" import { SessionTable, MessageTable, PartTable } from "../../session/session.sql" @@ -48,6 +49,7 @@ export function register(app: Hono): Hono { z, Database, Instance, + InstanceStore, SessionTable, MessageTable, PartTable, diff --git a/packages/opencode/src/kilocode/server/server.ts b/packages/opencode/src/kilocode/server/server.ts index 487892585e..221a0e16dd 100644 --- a/packages/opencode/src/kilocode/server/server.ts +++ b/packages/opencode/src/kilocode/server/server.ts @@ -3,7 +3,7 @@ // Imported by ../../server/server.ts with minimal kilocode_change markers. import { ModelCache } from "../../provider/model-cache" -import { Instance } from "../../project/instance" +import { InstanceStore } from "../../project/instance-store" /** Extra paths to skip request logging for */ export function skipLogging(path: string): boolean { @@ -21,7 +21,7 @@ export function corsOrigin(input: string): string | undefined { /** Invalidate model cache and provider state after auth change */ export async function authChanged(providerID: string) { ModelCache.clear(providerID) - await Instance.disposeAll() + await InstanceStore.disposeAllInstances() } export const DOC_TITLE = "kilo" diff --git a/packages/opencode/src/lsp/lsp.ts b/packages/opencode/src/lsp/lsp.ts index f12e692e9a..201da1a9ea 100644 --- a/packages/opencode/src/lsp/lsp.ts +++ b/packages/opencode/src/lsp/lsp.ts @@ -12,7 +12,7 @@ import { Process } from "@/util/process" import { spawn as lspspawn } from "./launch" import { Effect, Layer, Context, Schema } from "effect" import { InstanceState } from "@/effect/instance-state" -import { AppFileSystem } from "@opencode-ai/core/filesystem" +import { containsPath } from "@/project/instance-context" import { TsClient } from "../kilocode/ts-client" // kilocode_change import { NonNegativeInt, withStatics } from "@/util/schema" import { zod, ZodOverride } from "@/util/effect-zod" @@ -222,12 +222,7 @@ export const layer = Layer.effect( const getClients = Effect.fnUntraced(function* (file: string) { const ctx = yield* InstanceState.context - if ( - !AppFileSystem.contains(ctx.directory, file) && - (ctx.worktree === "/" || !AppFileSystem.contains(ctx.worktree, file)) - ) { - return [] as LSPClient.Info[] - } + if (!containsPath(file, ctx)) return [] as LSPClient.Info[] const s = yield* InstanceState.get(state) return yield* Effect.promise(async () => { const extension = path.parse(file).ext || file diff --git a/packages/opencode/src/mcp/index.ts b/packages/opencode/src/mcp/index.ts index 5a31920945..c209f14d5d 100644 --- a/packages/opencode/src/mcp/index.ts +++ b/packages/opencode/src/mcp/index.ts @@ -138,6 +138,11 @@ function isMcpConfigured(entry: McpEntry): entry is ConfigMCP.Info { const sanitize = (s: string) => s.replace(/[^a-zA-Z0-9_-]/g, "_") +function remoteURL(key: string, value: string) { + if (URL.canParse(value)) return new URL(value) + log.warn("invalid remote mcp url", { key }) +} + // Convert MCP tool definition to AI SDK Tool type function convertMcpTool(mcpTool: MCPToolDef, client: MCPClient, timeout?: number): Tool { const inputSchema = mcpTool.inputSchema @@ -291,6 +296,13 @@ export const layer = Layer.effect( ) { const oauthDisabled = mcp.oauth === false const oauthConfig = typeof mcp.oauth === "object" ? mcp.oauth : undefined + const url = remoteURL(key, mcp.url) + if (!url) { + return { + client: undefined as MCPClient | undefined, + status: { status: "failed" as const, error: `Invalid MCP URL for "${key}"` }, + } + } let authProvider: McpOAuthProvider | undefined if (!oauthDisabled) { @@ -315,14 +327,14 @@ export const layer = Layer.effect( const transports: Array<{ name: string; transport: TransportWithAuth }> = [ { name: "StreamableHTTP", - transport: new StreamableHTTPClientTransport(new URL(mcp.url), { + transport: new StreamableHTTPClientTransport(url, { authProvider, requestInit: mcp.headers ? { headers: mcp.headers } : undefined, }), }, { name: "SSE", - transport: new SSEClientTransport(new URL(mcp.url), { + transport: new SSEClientTransport(url, { authProvider, requestInit: mcp.headers ? { headers: mcp.headers } : undefined, }), @@ -749,6 +761,8 @@ export const layer = Layer.effect( if (!mcpConfig) throw new Error(`MCP server ${mcpName} not found or disabled`) if (mcpConfig.type !== "remote") throw new Error(`MCP server ${mcpName} is not a remote server`) if (mcpConfig.oauth === false) throw new Error(`MCP server ${mcpName} has OAuth explicitly disabled`) + const url = remoteURL(mcpName, mcpConfig.url) + if (!url) throw new Error(`Invalid MCP URL for "${mcpName}"`) // OAuth config is optional - if not provided, we'll use auto-discovery const oauthConfig = typeof mcpConfig.oauth === "object" ? mcpConfig.oauth : undefined @@ -781,7 +795,7 @@ export const layer = Layer.effect( auth, ) - const transport = new StreamableHTTPClientTransport(new URL(mcpConfig.url), { authProvider }) + const transport = new StreamableHTTPClientTransport(url, { authProvider }) return yield* Effect.tryPromise({ try: () => { diff --git a/packages/opencode/src/plugin/azure.ts b/packages/opencode/src/plugin/azure.ts new file mode 100644 index 0000000000..6f54dfce1f --- /dev/null +++ b/packages/opencode/src/plugin/azure.ts @@ -0,0 +1,26 @@ +import type { Hooks, PluginInput } from "@kilocode/plugin" + +export async function AzureAuthPlugin(_input: PluginInput): Promise { + const prompts = [] + if (!process.env.AZURE_RESOURCE_NAME) { + prompts.push({ + type: "text" as const, + key: "resourceName", + message: "Enter Azure Resource Name", + placeholder: "e.g. my-models", + }) + } + + return { + auth: { + provider: "azure", + methods: [ + { + type: "api", + label: "API key", + prompts, + }, + ], + }, + } +} diff --git a/packages/opencode/src/plugin/codex.ts b/packages/opencode/src/plugin/codex.ts index c511dad375..d24a0fa959 100644 --- a/packages/opencode/src/plugin/codex.ts +++ b/packages/opencode/src/plugin/codex.ts @@ -14,6 +14,17 @@ const ISSUER = "https://auth.openai.com" const CODEX_API_ENDPOINT = "https://chatgpt.com/backend-api/codex/responses" const OAUTH_PORT = 1455 const OAUTH_POLLING_SAFETY_MARGIN_MS = 3000 +const ALLOWED_MODELS = new Set([ + "gpt-5.1-codex", + "gpt-5.1-codex-max", + "gpt-5.1-codex-mini", + "gpt-5.2", + "gpt-5.2-codex", + "gpt-5.3-codex", + "gpt-5.4", + "gpt-5.4-mini", + "gpt-5.5", +]) interface PkceCodes { verifier: string @@ -364,51 +375,45 @@ function waitForOAuthCallback(pkce: PkceCodes, state: string): Promise { return { + provider: { + id: "openai", + async models(provider, ctx) { + if (ctx.auth?.type !== "oauth") return provider.models + + return Object.fromEntries( + Object.entries(provider.models) + .filter(([, model]) => { + if (ALLOWED_MODELS.has(model.api.id)) return true + const match = model.api.id.match(/^gpt-(\d+\.\d+)/) + return match ? parseFloat(match[1]) > 5.4 : false + }) + .map(([modelID, model]) => [ + modelID, + { + ...model, + cost: { + input: 0, + output: 0, + cache: { read: 0, write: 0 }, + }, + limit: model.id.includes("gpt-5.5") + ? { + context: 400_000, + input: 272_000, + output: 128_000, + } + : model.limit, + }, + ]), + ) + }, + }, auth: { provider: "openai", - async loader(getAuth, provider) { + async loader(getAuth) { const auth = await getAuth() if (auth.type !== "oauth") return {} - // Filter models to only allowed Codex models for OAuth - const allowedModels = new Set([ - "gpt-5.1-codex", - "gpt-5.1-codex-max", - "gpt-5.1-codex-mini", - "gpt-5.2", - "gpt-5.2-codex", - "gpt-5.3-codex", - "gpt-5.4", - "gpt-5.4-mini", - "gpt-5.5", - ]) - for (const [modelId, model] of Object.entries(provider.models)) { - if (modelId.includes("codex")) continue - if (allowedModels.has(model.api.id)) continue - const match = model.api.id.match(/^gpt-(\d+\.\d+)/) - if (match && parseFloat(match[1]) > 5.4) continue - delete provider.models[modelId] - } - - // Zero out costs for Codex (included with ChatGPT subscription) - for (const model of Object.values(provider.models)) { - model.cost = { - input: 0, - 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 { apiKey: OAUTH_DUMMY_KEY, async fetch(requestInput: RequestInfo | URL, init?: RequestInit) { diff --git a/packages/opencode/src/plugin/index.ts b/packages/opencode/src/plugin/index.ts index 66442fae2c..bae2adfcf4 100644 --- a/packages/opencode/src/plugin/index.ts +++ b/packages/opencode/src/plugin/index.ts @@ -3,7 +3,7 @@ import type { PluginInput, Plugin as PluginInstance, PluginModule, - WorkspaceAdaptor as PluginWorkspaceAdaptor, + WorkspaceAdapter as PluginWorkspaceAdapter, } from "@kilocode/plugin" import { Config } from "@/config/config" import { Bus } from "../bus" @@ -17,15 +17,16 @@ import { CopilotAuthPlugin } from "./github-copilot/copilot" import { gitlabAuthPlugin as GitlabAuthPlugin } from "opencode-gitlab-auth" import { PoeAuthPlugin } from "opencode-poe-auth" import { CloudflareAIGatewayAuthPlugin, CloudflareWorkersAuthPlugin } from "./cloudflare" +import { AzureAuthPlugin } from "./azure" import { Effect, Layer, Context, Stream } from "effect" import { EffectBridge } from "@/effect/bridge" import { InstanceState } from "@/effect/instance-state" import { errorMessage } from "@/util/error" import { PluginLoader } from "./loader" import { parsePluginSpecifier, readPluginId, readV1Plugin, resolvePluginId } from "./shared" -import { registerAdaptor } from "@/control-plane/adaptors" -import type { WorkspaceAdaptor } from "@/control-plane/types" import { KiloAuthPlugin } from "@kilocode/kilo-gateway" // kilocode_change +import { registerAdapter } from "@/control-plane/adapters" +import type { WorkspaceAdapter } from "@/control-plane/types" const log = Log.create({ service: "plugin" }) @@ -60,11 +61,13 @@ const INTERNAL_PLUGINS: PluginInstance[] = [ KiloAuthPlugin, CodexAuthPlugin, CopilotAuthPlugin, - GitlabAuthPlugin as unknown as PluginInstance, - PoeAuthPlugin as unknown as PluginInstance, - CloudflareWorkersAuthPlugin as unknown as PluginInstance, - CloudflareAIGatewayAuthPlugin as unknown as PluginInstance, -] // kilocode_change end + GitlabAuthPlugin, + PoeAuthPlugin, + CloudflareWorkersAuthPlugin, + CloudflareAIGatewayAuthPlugin, + AzureAuthPlugin, +] +// kilocode_change end function isServerPlugin(value: unknown): value is PluginInstance { return typeof value === "function" @@ -139,8 +142,8 @@ export const layer = Layer.effect( worktree: ctx.worktree, directory: ctx.directory, experimental_workspace: { - register(type: string, adaptor: PluginWorkspaceAdaptor) { - registerAdaptor(ctx.project.id, type, adaptor as WorkspaceAdaptor) + register(type: string, adapter: PluginWorkspaceAdapter) { + registerAdapter(ctx.project.id, type, adapter as WorkspaceAdapter) }, }, get serverUrl(): URL { diff --git a/packages/opencode/src/project/bootstrap.ts b/packages/opencode/src/project/bootstrap.ts index c296df5cf5..30a7d5fb0a 100644 --- a/packages/opencode/src/project/bootstrap.ts +++ b/packages/opencode/src/project/bootstrap.ts @@ -7,39 +7,76 @@ import * as Project from "./project" import * as Vcs from "./vcs" import { Bus } from "../bus" import { Command } from "../command" -import { Instance } from "./instance" -import * as Log from "@opencode-ai/core/util/log" +import { InstanceState } from "@/effect/instance-state" import { FileWatcher } from "@/file/watcher" import { KilocodeBootstrap } from "@/kilocode/bootstrap" // kilocode_change -import * as Effect from "effect/Effect" +// import { ShareNext } from "@/share/share-next" // kilocode_change - handled by KilocodeBootstrap +import { Context, Effect, Layer } from "effect" import { Config } from "@/config/config" -export const InstanceBootstrap = Effect.gen(function* () { - Log.Default.info("bootstrapping", { directory: Instance.directory }) - // everything depends on config so eager load it for nice traces - yield* Config.Service.use((svc) => svc.get()) - // Plugin can mutate config so it has to be initialized before anything else. - yield* Plugin.Service.use((svc) => svc.init()) - // kilocode_change start - bootstrap Kilo session ingest/remote subscriptions instead of ShareNext - yield* Effect.promise(() => KilocodeBootstrap.init()).pipe(Effect.forkDetach) - // kilocode_change end - yield* Effect.all( - [ - LSP.Service, - // ShareNext.Service, kilocode_change - Format.Service, - File.Service, - FileWatcher.Service, - Vcs.Service, - Snapshot.Service, - ].map((s) => Effect.forkDetach(s.use((i) => i.init()))), - ).pipe(Effect.withSpan("InstanceBootstrap.init")) +export interface Interface { + readonly run: Effect.Effect +} - yield* Bus.Service.use((svc) => - svc.subscribeCallback(Command.Event.Executed, async (payload) => { - if (payload.properties.name === Command.Default.INIT) { - Project.setInitialized(Instance.project.id) - } - }), - ) -}).pipe(Effect.withSpan("InstanceBootstrap")) +export class Service extends Context.Service()("@opencode/InstanceBootstrap") {} + +export const layer = Layer.effect( + Service, + Effect.gen(function* () { + // Yield each bootstrap dep at layer init so `run` itself has R = never. + // This breaks the circular declaration loop through Config → Instance → InstanceStore + // (instance-store.ts only yields this Service tag, never the impl-side services). + const bus = yield* Bus.Service + const config = yield* Config.Service + const file = yield* File.Service + const fileWatcher = yield* FileWatcher.Service + const format = yield* Format.Service + const lsp = yield* LSP.Service + const plugin = yield* Plugin.Service + // const shareNext = yield* ShareNext.Service // kilocode_change - handled by KilocodeBootstrap + const snapshot = yield* Snapshot.Service + const vcs = yield* Vcs.Service + + const run = Effect.gen(function* () { + const ctx = yield* InstanceState.context + yield* Effect.logInfo("bootstrapping", { directory: ctx.directory }) + // everything depends on config so eager load it for nice traces + yield* config.get() + // Plugin can mutate config so it has to be initialized before anything else. + yield* plugin.init() + yield* Effect.promise(() => KilocodeBootstrap.init()).pipe(Effect.forkDetach) // kilocode_change + yield* Effect.all( + [lsp, /* shareNext, kilocode_change - handled by KilocodeBootstrap */ format, file, fileWatcher, vcs, snapshot].map( + (s) => Effect.forkDetach(s.init()), + ), + ).pipe(Effect.withSpan("InstanceBootstrap.init")) + + const projectID = ctx.project.id + yield* bus.subscribeCallback(Command.Event.Executed, async (payload) => { + if (payload.properties.name === Command.Default.INIT) { + Project.setInitialized(projectID) + } + }) + }).pipe(Effect.withSpan("InstanceBootstrap")) + + return Service.of({ run }) + }), +) + +export const defaultLayer: Layer.Layer = layer.pipe( + Layer.provide([ + Bus.layer, + Config.defaultLayer, + File.defaultLayer, + FileWatcher.defaultLayer, + Format.defaultLayer, + LSP.defaultLayer, + Plugin.defaultLayer, + Project.defaultLayer, + // ShareNext.defaultLayer, // kilocode_change - handled by KilocodeBootstrap + Snapshot.defaultLayer, + Vcs.defaultLayer, + ]), +) + +export * as InstanceBootstrap from "./bootstrap" diff --git a/packages/opencode/src/project/instance-context.ts b/packages/opencode/src/project/instance-context.ts new file mode 100644 index 0000000000..b281f492d4 --- /dev/null +++ b/packages/opencode/src/project/instance-context.ts @@ -0,0 +1,24 @@ +import { LocalContext } from "@/util/local-context" +import { AppFileSystem } from "@opencode-ai/core/filesystem" +import type * as Project from "./project" + +export interface InstanceContext { + directory: string + worktree: string + project: Project.Info +} + +export const context = LocalContext.create("instance") + +/** + * Check if a path is within the project boundary. + * Returns true if path is inside ctx.directory OR ctx.worktree. + * Paths within the worktree but outside the working directory should not trigger external_directory permission. + */ +export function containsPath(filepath: string, ctx: InstanceContext): boolean { + if (AppFileSystem.contains(ctx.directory, filepath)) return true + // Non-git projects set worktree to "/" which would match ANY absolute path. + // Skip worktree check in this case to preserve external_directory permissions. + if (ctx.worktree === "/") return false + return AppFileSystem.contains(ctx.worktree, filepath) +} diff --git a/packages/opencode/src/project/instance-store.ts b/packages/opencode/src/project/instance-store.ts new file mode 100644 index 0000000000..00075be64b --- /dev/null +++ b/packages/opencode/src/project/instance-store.ts @@ -0,0 +1,207 @@ +import { GlobalBus } from "@/bus/global" +import { WorkspaceContext } from "@/control-plane/workspace-context" +import { InstanceRef } from "@/effect/instance-ref" +import { disposeInstance as runDisposers } from "@/effect/instance-registry" +import { makeRuntime } from "@/effect/run-service" +import { AppFileSystem } from "@opencode-ai/core/filesystem" +import { Context, Deferred, Duration, Effect, Exit, Layer, Scope } from "effect" +import { type InstanceContext } from "./instance-context" +import * as Project from "./project" + +export interface LoadInput { + directory: string + /** + * Additional setup to run after the default InstanceBootstrap. + * Mainly used by tests for env-var setup or file writes that need the instance ALS context. + */ + init?: Effect.Effect + worktree?: string + project?: Project.Info +} + +export interface Interface { + readonly load: (input: LoadInput) => Effect.Effect + readonly reload: (input: LoadInput) => Effect.Effect + readonly dispose: (ctx: InstanceContext) => Effect.Effect + readonly disposeAll: () => Effect.Effect + readonly provide: ( + input: LoadInput, + effect: Effect.Effect, + ) => Effect.Effect +} + +export class Service extends Context.Service()("@opencode/InstanceStore") {} + +interface Entry { + readonly deferred: Deferred.Deferred +} + +export const layer: Layer.Layer = Layer.effect( + Service, + Effect.gen(function* () { + const project = yield* Project.Service + const scope = yield* Scope.Scope + const cache = new Map() + + const boot = (input: LoadInput & { directory: string }) => + Effect.gen(function* () { + const ctx: InstanceContext = + input.project && input.worktree + ? { + directory: input.directory, + worktree: input.worktree, + project: input.project, + } + : yield* project.fromDirectory(input.directory).pipe( + Effect.map((result) => ({ + directory: input.directory, + worktree: result.sandbox, + project: result.project, + })), + ) + if (input.init) yield* input.init.pipe(Effect.provideService(InstanceRef, ctx)) + return ctx + }).pipe(Effect.withSpan("InstanceStore.boot")) + + const removeEntry = (directory: string, entry: Entry) => + Effect.sync(() => { + if (cache.get(directory) !== entry) return false + cache.delete(directory) + return true + }) + + const completeLoad = (directory: string, input: LoadInput, entry: Entry) => + Effect.gen(function* () { + const exit = yield* Effect.exit(boot({ ...input, directory })) + if (Exit.isFailure(exit)) yield* removeEntry(directory, entry) + yield* Deferred.done(entry.deferred, exit).pipe(Effect.asVoid) + }) + + const emitDisposed = (input: { directory: string; project?: string }) => + Effect.sync(() => + GlobalBus.emit("event", { + directory: input.directory, + project: input.project, + workspace: WorkspaceContext.workspaceID, + payload: { + type: "server.instance.disposed", + properties: { + directory: input.directory, + }, + }, + }), + ) + + const disposeContext = Effect.fn("InstanceStore.disposeContext")(function* (ctx: InstanceContext) { + yield* Effect.logInfo("disposing instance", { directory: ctx.directory }) + yield* Effect.promise(() => runDisposers(ctx.directory)) + yield* emitDisposed({ directory: ctx.directory, project: ctx.project.id }) + }) + + const disposeEntry = Effect.fnUntraced(function* (directory: string, entry: Entry, ctx: InstanceContext) { + if (cache.get(directory) !== entry) return false + yield* disposeContext(ctx) + if (cache.get(directory) !== entry) return false + cache.delete(directory) + return true + }) + + const load = (input: LoadInput): Effect.Effect => { + const directory = AppFileSystem.resolve(input.directory) + return Effect.uninterruptibleMask((restore) => + Effect.gen(function* () { + const existing = cache.get(directory) + if (existing) return yield* restore(Deferred.await(existing.deferred)) + + const entry: Entry = { deferred: Deferred.makeUnsafe() } + cache.set(directory, entry) + yield* Effect.gen(function* () { + yield* Effect.logInfo("creating instance", { directory }) + yield* completeLoad(directory, input, entry) + }).pipe(Effect.forkIn(scope, { startImmediately: true })) + return yield* restore(Deferred.await(entry.deferred)) + }), + ).pipe(Effect.withSpan("InstanceStore.load")) + } + + const reload = (input: LoadInput): Effect.Effect => { + const directory = AppFileSystem.resolve(input.directory) + return Effect.uninterruptibleMask((restore) => + Effect.gen(function* () { + const previous = cache.get(directory) + const entry: Entry = { deferred: Deferred.makeUnsafe() } + cache.set(directory, entry) + yield* Effect.gen(function* () { + yield* Effect.logInfo("reloading instance", { directory }) + if (previous) { + yield* Deferred.await(previous.deferred).pipe(Effect.ignore) + yield* Effect.promise(() => runDisposers(directory)) + yield* emitDisposed({ directory, project: input.project?.id }) + } + yield* completeLoad(directory, input, entry) + }).pipe(Effect.forkIn(scope, { startImmediately: true })) + return yield* restore(Deferred.await(entry.deferred)) + }), + ).pipe(Effect.withSpan("InstanceStore.reload")) + } + + const dispose = Effect.fn("InstanceStore.dispose")(function* (ctx: InstanceContext) { + const entry = cache.get(ctx.directory) + if (!entry) return yield* disposeContext(ctx) + + const exit = yield* Deferred.await(entry.deferred).pipe(Effect.exit) + if (Exit.isFailure(exit)) return yield* removeEntry(ctx.directory, entry).pipe(Effect.asVoid) + if (exit.value !== ctx) return + yield* disposeEntry(ctx.directory, entry, ctx).pipe(Effect.asVoid) + }) + + const disposeAllOnce = Effect.fnUntraced(function* () { + yield* Effect.logInfo("disposing all instances") + yield* Effect.forEach( + [...cache.entries()], + (item) => + Effect.gen(function* () { + const exit = yield* Deferred.await(item[1].deferred).pipe(Effect.exit) + if (Exit.isFailure(exit)) { + yield* Effect.logWarning("instance dispose failed", { key: item[0], cause: exit.cause }) + yield* removeEntry(item[0], item[1]) + return + } + yield* disposeEntry(item[0], item[1], exit.value) + }), + { discard: true }, + ) + }) + + const cachedDisposeAll = yield* Effect.cachedWithTTL(disposeAllOnce(), Duration.zero) + const disposeAll = Effect.fn("InstanceStore.disposeAll")(function* () { + return yield* cachedDisposeAll + }) + + const provide = (input: LoadInput, effect: Effect.Effect): Effect.Effect => + load(input).pipe(Effect.flatMap((ctx) => effect.pipe(Effect.provideService(InstanceRef, ctx)))) + + yield* Effect.addFinalizer(() => disposeAll().pipe(Effect.ignore)) + + return Service.of({ + load, + reload, + dispose, + disposeAll, + provide, + }) + }), +) + +export const defaultLayer = layer.pipe(Layer.provide(Project.defaultLayer)) + +export const runtime = makeRuntime(Service, defaultLayer) + +// Promise-returning helpers for callers without an Effect runtime in scope. +// They route through `runtime` (not a yielded Service from a fresh runtime) +// so they share the cache that `Instance.provide` populates. +export const disposeInstance = (ctx: InstanceContext) => runtime.runPromise((store) => store.dispose(ctx)) +export const disposeAllInstances = () => runtime.runPromise((store) => store.disposeAll()) +export const reloadInstance = (input: LoadInput) => runtime.runPromise((store) => store.reload(input)) + +export * as InstanceStore from "./instance-store" diff --git a/packages/opencode/src/project/instance.ts b/packages/opencode/src/project/instance.ts index 623e886231..5b2bcf6b32 100644 --- a/packages/opencode/src/project/instance.ts +++ b/packages/opencode/src/project/instance.ts @@ -1,77 +1,16 @@ -import { GlobalBus } from "@/bus/global" -import { disposeInstance } from "@/effect/instance-registry" -import { makeRuntime } from "@/effect/run-service" -import { AppFileSystem } from "@opencode-ai/core/filesystem" -import { iife } from "@/util/iife" -import * as Log from "@opencode-ai/core/util/log" -import { LocalContext } from "@/util/local-context" -import * as Project from "./project" -import { WorkspaceContext } from "@/control-plane/workspace-context" +import { Effect } from "effect" +import { context, type InstanceContext } from "./instance-context" +import { InstanceStore } from "./instance-store" -export interface InstanceContext { - directory: string - worktree: string - project: Project.Info -} - -const context = LocalContext.create("instance") -const cache = new Map>() -const project = makeRuntime(Project.Service, Project.defaultLayer) - -const disposal = { - all: undefined as Promise | undefined, -} - -function boot(input: { directory: string; init?: () => Promise; worktree?: string; project?: Project.Info }) { - return iife(async () => { - const ctx = - input.project && input.worktree - ? { - directory: input.directory, - worktree: input.worktree, - project: input.project, - } - : await project - .runPromise((svc) => svc.fromDirectory(input.directory)) - .then(({ project, sandbox }) => ({ - directory: input.directory, - worktree: sandbox, - project, - })) - await context.provide(ctx, async () => { - await input.init?.() - }) - return ctx - }) -} - -function track(directory: string, next: Promise) { - const task = next.catch((error) => { - if (cache.get(directory) === task) cache.delete(directory) - throw error - }) - cache.set(directory, task) - return task -} +export type { InstanceContext } from "./instance-context" +export type { LoadInput } from "./instance-store" export const Instance = { - async provide(input: { directory: string; init?: () => Promise; fn: () => R }): Promise { - const directory = AppFileSystem.resolve(input.directory) - let existing = cache.get(directory) - if (!existing) { - Log.Default.info("creating instance", { directory }) - existing = track( - directory, - boot({ - directory, - init: input.init, - }), - ) - } - const ctx = await existing - return context.provide(ctx, async () => { - return input.fn() - }) + async provide(input: { directory: string; init?: Effect.Effect; fn: () => R }): Promise { + const ctx = await InstanceStore.runtime.runPromise((store) => + store.load({ directory: input.directory, init: input.init }), + ) + return context.provide(ctx, async () => input.fn()) }, get current() { return context.use() @@ -86,19 +25,6 @@ export const Instance = { return context.use().project }, - /** - * Check if a path is within the project boundary. - * Returns true if path is inside Instance.directory OR Instance.worktree. - * Paths within the worktree but outside the working directory should not trigger external_directory permission. - */ - containsPath(filepath: string, ctx?: InstanceContext) { - const instance = ctx ?? Instance - if (AppFileSystem.contains(instance.directory, filepath)) return true - // Non-git projects set worktree to "/" which would match ANY absolute path. - // Skip worktree check in this case to preserve external_directory permissions. - if (instance.worktree === "/") return false - return AppFileSystem.contains(instance.worktree, filepath) - }, /** * Captures the current instance ALS context and returns a wrapper that * restores it when called. Use this for callbacks that fire outside the @@ -116,75 +42,4 @@ export const Instance = { restore(ctx: InstanceContext, fn: () => R): R { return context.provide(ctx, fn) }, - async reload(input: { directory: string; init?: () => Promise; project?: Project.Info; worktree?: string }) { - const directory = AppFileSystem.resolve(input.directory) - Log.Default.info("reloading instance", { directory }) - await disposeInstance(directory) - cache.delete(directory) - const next = track(directory, boot({ ...input, directory })) - - GlobalBus.emit("event", { - directory, - project: input.project?.id, - workspace: WorkspaceContext.workspaceID, - payload: { - type: "server.instance.disposed", - properties: { - directory, - }, - }, - }) - - return await next - }, - async dispose() { - const directory = Instance.directory - const project = Instance.project - Log.Default.info("disposing instance", { directory }) - await disposeInstance(directory) - cache.delete(directory) - - GlobalBus.emit("event", { - directory, - project: project.id, - workspace: WorkspaceContext.workspaceID, - payload: { - type: "server.instance.disposed", - properties: { - directory, - }, - }, - }) - }, - async disposeAll() { - if (disposal.all) return disposal.all - - disposal.all = iife(async () => { - Log.Default.info("disposing all instances") - const entries = [...cache.entries()] - for (const [key, value] of entries) { - if (cache.get(key) !== value) continue - - const ctx = await value.catch((error) => { - Log.Default.warn("instance dispose failed", { key, error }) - return undefined - }) - - if (!ctx) { - if (cache.get(key) === value) cache.delete(key) - continue - } - - if (cache.get(key) !== value) continue - - await context.provide(ctx, async () => { - await Instance.dispose() - }) - } - }).finally(() => { - disposal.all = undefined - }) - - return disposal.all - }, } diff --git a/packages/opencode/src/project/project.ts b/packages/opencode/src/project/project.ts index 89399aacd5..8af38eace1 100644 --- a/packages/opencode/src/project/project.ts +++ b/packages/opencode/src/project/project.ts @@ -17,20 +17,21 @@ import { NodePath } from "@effect/platform-node" import { AppFileSystem } from "@opencode-ai/core/filesystem" import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" import { zod } from "@/util/effect-zod" -import { NonNegativeInt, withStatics } from "@/util/schema" +import { NonNegativeInt, optionalOmitUndefined, withStatics } from "@/util/schema" +import { serviceUse } from "@/effect/service-use" const log = Log.create({ service: "project" }) const ProjectVcs = Schema.Literal("git") const ProjectIcon = Schema.Struct({ - url: Schema.optional(Schema.String), - override: Schema.optional(Schema.String), - color: Schema.optional(Schema.String), + url: optionalOmitUndefined(Schema.String), + override: optionalOmitUndefined(Schema.String), + color: optionalOmitUndefined(Schema.String), }) const ProjectCommands = Schema.Struct({ - start: Schema.optional( + start: optionalOmitUndefined( Schema.String.annotate({ description: "Startup script to run when creating a new workspace (worktree)" }), ), }) @@ -38,16 +39,16 @@ const ProjectCommands = Schema.Struct({ const ProjectTime = Schema.Struct({ created: NonNegativeInt, updated: NonNegativeInt, - initialized: Schema.optional(NonNegativeInt), + initialized: optionalOmitUndefined(NonNegativeInt), }) export const Info = Schema.Struct({ id: ProjectID, worktree: Schema.String, - vcs: Schema.optional(ProjectVcs), - name: Schema.optional(Schema.String), - icon: Schema.optional(ProjectIcon), - commands: Schema.optional(ProjectCommands), + vcs: optionalOmitUndefined(ProjectVcs), + name: optionalOmitUndefined(Schema.String), + icon: optionalOmitUndefined(ProjectIcon), + commands: optionalOmitUndefined(ProjectCommands), time: ProjectTime, sandboxes: Schema.Array(Schema.String), }) @@ -181,7 +182,7 @@ export const layer: Layer.Layer< return yield* fs.readFileString(pathSvc.join(dir, "kilo")).pipe( // kilocode change end Effect.map((x) => x.trim()), - Effect.map(ProjectID.make), + Effect.map((x) => ProjectID.make(x)), Effect.catch(() => Effect.void), ) }) @@ -488,6 +489,8 @@ export const defaultLayer = layer.pipe( Layer.provide(NodePath.layer), ) +export const use = serviceUse(Service) + export function list() { return Database.use((db) => db diff --git a/packages/opencode/src/provider/auth.ts b/packages/opencode/src/provider/auth.ts index cb9a859ee8..edd038de36 100644 --- a/packages/opencode/src/provider/auth.ts +++ b/packages/opencode/src/provider/auth.ts @@ -3,7 +3,7 @@ import { Auth } from "@/auth" import { InstanceState } from "@/effect/instance-state" import { zod } from "@/util/effect-zod" import { namedSchemaError } from "@/util/named-schema-error" -import { withStatics } from "@/util/schema" +import { optionalOmitUndefined, withStatics } from "@/util/schema" import { Plugin } from "../plugin" import { ProviderID } from "./schema" import { Array as Arr, Effect, Layer, Record, Result, Context, Schema } from "effect" @@ -12,6 +12,7 @@ import { Array as Arr, Effect, Layer, Record, Result, Context, Schema } from "ef import { Telemetry } from "@kilocode/kilo-telemetry" import { ModelCache } from "./model-cache" import { Instance } from "@/project/instance" +import { InstanceStore } from "@/project/instance-store" // kilocode_change end const When = Schema.Struct({ @@ -24,14 +25,14 @@ const TextPrompt = Schema.Struct({ type: Schema.Literal("text"), key: Schema.String, message: Schema.String, - placeholder: Schema.optional(Schema.String), - when: Schema.optional(When), + placeholder: optionalOmitUndefined(Schema.String), + when: optionalOmitUndefined(When), }) const SelectOption = Schema.Struct({ label: Schema.String, value: Schema.String, - hint: Schema.optional(Schema.String), + hint: optionalOmitUndefined(Schema.String), }) const SelectPrompt = Schema.Struct({ @@ -39,7 +40,7 @@ const SelectPrompt = Schema.Struct({ key: Schema.String, message: Schema.String, options: Schema.Array(SelectOption), - when: Schema.optional(When), + when: optionalOmitUndefined(When), }) const Prompt = Schema.Union([TextPrompt, SelectPrompt]) @@ -47,7 +48,7 @@ const Prompt = Schema.Union([TextPrompt, SelectPrompt]) export class Method extends Schema.Class("ProviderAuthMethod")({ type: Schema.Literals(["oauth", "api"]), label: Schema.String, - prompts: Schema.optional(Schema.Array(Prompt)), + prompts: optionalOmitUndefined(Schema.Array(Prompt)), }) { static readonly zod = zod(this) } @@ -141,23 +142,25 @@ export const layer: Layer.Layer = item.methods.map((method) => ({ type: method.type, label: method.label, - prompts: method.prompts?.map((prompt) => { - if (prompt.type === "select") { + ...(method.prompts && { + prompts: method.prompts.map((prompt) => { + if (prompt.type === "select") { + return { + type: "select" as const, + key: prompt.key, + message: prompt.message, + options: prompt.options, + ...(prompt.when && { when: prompt.when }), + } + } return { - type: "select" as const, + type: "text" as const, key: prompt.key, message: prompt.message, - options: prompt.options, - when: prompt.when, + ...(prompt.placeholder && { placeholder: prompt.placeholder }), + ...(prompt.when && { when: prompt.when }), } - } - return { - type: "text" as const, - key: prompt.key, - message: prompt.message, - placeholder: prompt.placeholder, - when: prompt.when, - } + }), }), })), ), @@ -231,7 +234,7 @@ export const layer: Layer.Layer = } Telemetry.trackAuthSuccess(input.providerID) ModelCache.clear(input.providerID) - yield* Effect.promise(() => Instance.disposeAll()) + yield* Effect.promise(() => InstanceStore.disposeAllInstances()) // kilocode_change end }) diff --git a/packages/opencode/src/provider/models.ts b/packages/opencode/src/provider/models.ts index ae085b08c2..5aca1a394b 100644 --- a/packages/opencode/src/provider/models.ts +++ b/packages/opencode/src/provider/models.ts @@ -1,13 +1,13 @@ import { Global } from "@opencode-ai/core/global" -import * as Log from "@opencode-ai/core/util/log" import path from "path" -import { Schema } from "effect" +import { Context, Duration, Effect, Layer, Option, Schedule, Schema } from "effect" +import { FetchHttpClient, HttpClient, HttpClientRequest } from "effect/unstable/http" import { Installation } from "../installation" import { Flag } from "@opencode-ai/core/flag/flag" -import { lazy } from "@/util/lazy" -import { Filesystem } from "@/util/filesystem" import { Flock } from "@opencode-ai/core/util/flock" import { Hash } from "@opencode-ai/core/util/hash" +import { AppFileSystem } from "@opencode-ai/core/filesystem" +import { withTransientReadRetry } from "@/util/effect-http-client" // kilocode_change start import { Config } from "../config/config" import { ModelCache } from "./model-cache" @@ -15,18 +15,14 @@ import { Auth } from "../auth" import { AI_SDK_PROVIDERS, KILO_OPENROUTER_BASE, PROMPTS } from "@kilocode/kilo-gateway" // kilocode_change end -// Try to import bundled snapshot (generated at build time) -// Falls back to undefined in dev mode when snapshot doesn't exist -/* @ts-ignore */ - // kilocode_change start -const normalizeKiloBaseURL = (baseURL: string | undefined, orgId: string | undefined): string | undefined => { +const normalizeKiloBaseURL = (baseURL: string | undefined, org: string | undefined): string | undefined => { if (!baseURL) return undefined const trimmed = baseURL.replace(/\/+$/, "") - if (orgId) { + if (org) { if (trimmed.includes("/api/organizations/")) return trimmed - if (trimmed.endsWith("/api")) return `${trimmed}/organizations/${orgId}` - return `${trimmed}/api/organizations/${orgId}` + if (trimmed.endsWith("/api")) return `${trimmed}/organizations/${org}` + return `${trimmed}/api/organizations/${org}` } if (trimmed.includes("/openrouter")) return trimmed if (trimmed.endsWith("/api")) return `${trimmed}/openrouter` @@ -34,14 +30,6 @@ const normalizeKiloBaseURL = (baseURL: string | undefined, orgId: string | undef } // kilocode_change end -const log = Log.create({ service: "models.dev" }) -const source = url() -const filepath = path.join( - Global.Path.cache, - source === "https://models.dev" ? "models.json" : `models-${Hash.fast(source)}.json`, -) -const ttl = 5 * 60 * 1000 - const Cost = Schema.Struct({ input: Schema.Finite, output: Schema.Finite, @@ -128,167 +116,187 @@ export const Provider = Schema.Struct({ export type Provider = Schema.Schema.Type -function url() { - return Flag.KILO_MODELS_URL || "https://models.dev" +export interface Interface { + readonly get: () => Effect.Effect> + readonly refresh: (force?: boolean) => Effect.Effect } -function fresh() { - return Date.now() - Number(Filesystem.stat(filepath)?.mtimeMs ?? 0) < ttl -} +export class Service extends Context.Service()("@opencode/ModelsDev") {} -function skip(force: boolean) { - return !force && fresh() -} +export const layer: Layer.Layer = Layer.effect( + Service, + Effect.gen(function* () { + const fs = yield* AppFileSystem.Service + const http = HttpClient.filterStatusOk(withTransientReadRetry(yield* HttpClient.HttpClient)) -const fetchApi = async () => { - const result = await fetch(`${url()}/api.json`, { - headers: { "User-Agent": Installation.USER_AGENT }, - signal: AbortSignal.timeout(10000), - }) - return { ok: result.ok, text: await result.text() } -} + const source = Flag.KILO_MODELS_URL || "https://models.dev" + const filepath = path.join( + Global.Path.cache, + source === "https://models.dev" ? "models.json" : `models-${Hash.fast(source)}.json`, + ) + const ttl = Duration.minutes(5) + const lockKey = `models-dev:${filepath}` -export const Data = lazy(async () => { - const result = await Filesystem.readJson(Flag.KILO_MODELS_PATH ?? filepath).catch(() => {}) - if (result) return result - // @ts-ignore - const snapshot = await import("./models-snapshot.js") - .then((m) => m.snapshot as Record) - .catch(() => undefined) - if (snapshot) return snapshot - if (Flag.KILO_DISABLE_MODELS_FETCH) return {} - return Flock.withLock(`models-dev:${filepath}`, async () => { - const result = await Filesystem.readJson(Flag.KILO_MODELS_PATH ?? filepath).catch(() => {}) - if (result) return result - const result2 = await fetchApi() - if (result2.ok) { - await Filesystem.write(filepath, result2.text).catch((e) => { - log.error("Failed to write models cache", { error: e }) - }) - } - return JSON.parse(result2.text) - }) -}) - -export async function get() { - const result = await Data() - // kilocode_change start - const providers = result as Record - - if (providers["kilo"]) { - delete providers["kilo"] - } - - // Inject kilo provider with dynamic model fetching - // Skip injection entirely when enabled_providers is set and doesn't include "kilo", - // or when "kilo" is in disabled_providers. This prevents unnecessary network calls - // to the Kilo API for teams using only their own providers (e.g. LiteLLM). - const config = await Config.get() - const disabled = new Set(config.disabled_providers ?? []) - const enabled = config.enabled_providers ? new Set(config.enabled_providers) : null - const kiloAllowed = (!enabled || enabled.has("kilo")) && !disabled.has("kilo") - - if (kiloAllowed && !providers["kilo"]) { - const kiloOptions = config.provider?.kilo?.options - // resolve org ID from auth (OAuth accountId) not just config - const kiloAuth = await Auth.get("kilo") - const kiloOrgId = - kiloOptions?.kilocodeOrganizationId ?? (kiloAuth?.type === "oauth" ? kiloAuth.accountId : undefined) - const normalizedBaseURL = normalizeKiloBaseURL(kiloOptions?.baseURL, kiloOrgId) - const kiloFetchOptions = { - ...(normalizedBaseURL ? { baseURL: normalizedBaseURL } : {}), - ...(kiloOrgId ? { kilocodeOrganizationId: kiloOrgId } : {}), - } - const defaultBaseURL = kiloOrgId - ? `https://api.kilo.ai/api/organizations/${kiloOrgId}` - : "https://api.kilo.ai/api/openrouter" - const providerBaseURL = normalizedBaseURL ?? defaultBaseURL - const ensureTrailingSlash = (value: string): string => (value.endsWith("/") ? value : `${value}/`) - const apertisConfig = config.provider?.apertis?.options - const apertisBaseURL = apertisConfig?.baseURL ?? "https://api.apertis.ai/v1" - const apertisFetchOptions = { - ...(apertisConfig?.baseURL ? { baseURL: apertisConfig.baseURL } : {}), - } - - const [kiloModels, apertisModels] = await Promise.all([ - ModelCache.fetch("kilo", kiloFetchOptions).catch(() => ({})), - !providers["apertis"] - ? ModelCache.fetch("apertis", apertisFetchOptions).catch(() => ({})) - : Promise.resolve(null), - ]) - - providers["kilo"] = { - id: "kilo", - name: "Kilo Gateway", - env: ["KILO_API_KEY"], - api: ensureTrailingSlash(KILO_OPENROUTER_BASE), - npm: "@kilocode/kilo-gateway", - models: kiloModels, - } - if (Object.keys(kiloModels).length === 0) { - ModelCache.refresh("kilo", kiloFetchOptions).catch(() => {}) - } - - if (!providers["apertis"] && apertisModels !== null) { - providers["apertis"] = { - id: "apertis", - name: "Apertis", - env: ["APERTIS_API_KEY"], - api: apertisBaseURL, - npm: "@ai-sdk/openai-compatible", - models: apertisModels, - } - if (Object.keys(apertisModels).length === 0) { - ModelCache.refresh("apertis", apertisFetchOptions).catch(() => {}) - } - } - } else if (!providers["apertis"]) { - const apertisConfig = config.provider?.apertis?.options - const apertisBaseURL = apertisConfig?.baseURL ?? "https://api.apertis.ai/v1" - const apertisFetchOptions = { - ...(apertisConfig?.baseURL ? { baseURL: apertisConfig.baseURL } : {}), - } - const apertisModels = await ModelCache.fetch("apertis", apertisFetchOptions).catch(() => ({})) - providers["apertis"] = { - id: "apertis", - name: "Apertis", - env: ["APERTIS_API_KEY"], - api: apertisBaseURL, - npm: "@ai-sdk/openai-compatible", - models: apertisModels, - } - if (Object.keys(apertisModels).length === 0) { - ModelCache.refresh("apertis", apertisFetchOptions).catch(() => {}) - } - } - - return providers - // kilocode_change end -} - -export async function refresh(force = false) { - if (skip(force)) return Data.reset() - await Flock.withLock(`models-dev:${filepath}`, async () => { - if (skip(force)) return Data.reset() - const result = await fetchApi() - if (!result.ok) return - await Filesystem.write(filepath, result.text) - Data.reset() - }).catch((e) => { - log.error("Failed to fetch models.dev", { - error: e, + const fresh = Effect.fnUntraced(function* () { + const stat = yield* fs.stat(filepath).pipe(Effect.catch(() => Effect.succeed(undefined))) + if (!stat) return false + const mtime = Option.getOrElse(stat.mtime, () => new Date(0)).getTime() + return Date.now() - mtime < Duration.toMillis(ttl) }) - }) -} -if (!Flag.KILO_DISABLE_MODELS_FETCH && !process.argv.includes("--get-yargs-completions")) { - void refresh() - setInterval( - async () => { - await refresh() - }, - 60 * 1000 * 60, - ).unref() -} + const fetchApi = Effect.fn("ModelsDev.fetchApi")(function* () { + return yield* HttpClientRequest.get(`${source}/api.json`).pipe( + HttpClientRequest.setHeader("User-Agent", Installation.USER_AGENT), + http.execute, + Effect.flatMap((res) => res.text), + Effect.timeout("10 seconds"), + ) + }) + + const loadFromDisk = fs.readJson(Flag.KILO_MODELS_PATH ?? filepath).pipe( + Effect.catch(() => Effect.succeed(undefined)), + Effect.map((v) => v as Record | undefined), + ) + + // Bundled at build time; absent in dev — `tryPromise` covers both. + const loadSnapshot = Effect.tryPromise({ + // @ts-ignore — generated at build time, may not exist in dev + try: () => import("./models-snapshot.js").then((m) => m.snapshot as Record | undefined), + catch: () => undefined, + }).pipe(Effect.catch(() => Effect.succeed(undefined))) + + const fetchAndWrite = Effect.fn("ModelsDev.fetchAndWrite")(function* () { + const text = yield* fetchApi() + yield* fs.writeWithDirs(filepath, text) + return text + }) + + const populate = Effect.gen(function* () { + const fromDisk = yield* loadFromDisk + if (fromDisk) return fromDisk + const snapshot = yield* loadSnapshot + if (snapshot) return snapshot + if (Flag.KILO_DISABLE_MODELS_FETCH) return {} + // Flock is cross-process: concurrent opencode CLIs can race on this cache file. + const text = yield* Effect.scoped( + Effect.gen(function* () { + yield* Flock.effect(lockKey) + return yield* fetchAndWrite() + }), + ) + return JSON.parse(text) as Record + }).pipe(Effect.withSpan("ModelsDev.populate"), Effect.orDie) + + const [cachedGet, invalidate] = yield* Effect.cachedInvalidateWithTTL(populate, Duration.infinity) + + // kilocode_change start + const get = Effect.fn("ModelsDev.get")(function* () { + const providers = { ...(yield* cachedGet) } + delete providers["kilo"] + + const config = yield* Effect.promise(() => Config.get()) + const disabled = new Set(config.disabled_providers ?? []) + const enabled = config.enabled_providers ? new Set(config.enabled_providers) : undefined + const kiloAllowed = (!enabled || enabled.has("kilo")) && !disabled.has("kilo") + const apt = config.provider?.apertis?.options + const aptBase = apt?.baseURL ?? "https://api.apertis.ai/v1" + const aptFetch = { + ...(apt?.baseURL ? { baseURL: apt.baseURL } : {}), + } + + if (kiloAllowed) { + const opts = config.provider?.kilo?.options + const auth = yield* Effect.promise(() => Auth.get("kilo")) + const org = opts?.kilocodeOrganizationId ?? (auth?.type === "oauth" ? auth.accountId : undefined) + const base = normalizeKiloBaseURL(opts?.baseURL, org) + const fetch = { + ...(base ? { baseURL: base } : {}), + ...(org ? { kilocodeOrganizationId: org } : {}), + } + const [kilo, apertis] = yield* Effect.all( + [ + Effect.promise(() => ModelCache.fetch("kilo", fetch).catch(() => ({}))), + providers["apertis"] + ? Effect.succeed(null) + : Effect.promise(() => ModelCache.fetch("apertis", aptFetch).catch(() => ({}))), + ], + { concurrency: 2 }, + ) + + providers["kilo"] = { + id: "kilo", + name: "Kilo Gateway", + env: ["KILO_API_KEY"], + api: KILO_OPENROUTER_BASE.endsWith("/") ? KILO_OPENROUTER_BASE : `${KILO_OPENROUTER_BASE}/`, + npm: "@kilocode/kilo-gateway", + models: kilo, + } + if (Object.keys(kilo).length === 0) { + yield* Effect.sync(() => void ModelCache.refresh("kilo", fetch).catch(() => {})) + } + if (!providers["apertis"] && apertis !== null) { + providers["apertis"] = { + id: "apertis", + name: "Apertis", + env: ["APERTIS_API_KEY"], + api: aptBase, + npm: "@ai-sdk/openai-compatible", + models: apertis, + } + if (Object.keys(apertis).length === 0) { + yield* Effect.sync(() => void ModelCache.refresh("apertis", aptFetch).catch(() => {})) + } + } + return providers + } + + if (!providers["apertis"]) { + const apertis = yield* Effect.promise(() => ModelCache.fetch("apertis", aptFetch).catch(() => ({}))) + providers["apertis"] = { + id: "apertis", + name: "Apertis", + env: ["APERTIS_API_KEY"], + api: aptBase, + npm: "@ai-sdk/openai-compatible", + models: apertis, + } + if (Object.keys(apertis).length === 0) { + yield* Effect.sync(() => void ModelCache.refresh("apertis", aptFetch).catch(() => {})) + } + } + return providers + }) + // kilocode_change end + + const refresh = Effect.fn("ModelsDev.refresh")(function* (force = false) { + if (!force && (yield* fresh())) return + yield* Effect.scoped( + Effect.gen(function* () { + yield* Flock.effect(lockKey) + // Re-check under the lock: another process may have refreshed between + // our outer check and lock acquisition. + if (!force && (yield* fresh())) return + yield* fetchAndWrite() + yield* invalidate + }), + ).pipe( + Effect.tapCause((cause) => Effect.logError("Failed to fetch models.dev", { cause })), + Effect.ignore, + ) + }) + + if (!Flag.KILO_DISABLE_MODELS_FETCH && !process.argv.includes("--get-yargs-completions")) { + // Schedule.spaced runs the effect once, then waits between completions. + yield* Effect.forkScoped(refresh().pipe(Effect.repeat(Schedule.spaced("60 minutes")), Effect.ignore)) + } + + return Service.of({ get, refresh }) + }), +) + +export const defaultLayer: Layer.Layer = layer.pipe( + Layer.provide(FetchHttpClient.layer), + Layer.provide(AppFileSystem.defaultLayer), +) export * as ModelsDev from "./models" diff --git a/packages/opencode/src/provider/provider.ts b/packages/opencode/src/provider/provider.ts index e4bae22ed8..fd07f5f743 100644 --- a/packages/opencode/src/provider/provider.ts +++ b/packages/opencode/src/provider/provider.ts @@ -25,7 +25,7 @@ import { EffectBridge } from "@/effect/bridge" import { InstanceState } from "@/effect/instance-state" import { AppFileSystem } from "@opencode-ai/core/filesystem" import { isRecord } from "@/util/record" -import { withStatics } from "@/util/schema" +import { optionalOmitUndefined, withStatics } from "@/util/schema" import * as ProviderTransform from "./transform" import { ModelID, ProviderID } from "./schema" @@ -214,12 +214,26 @@ function custom(dep: CustomDep): Record { }), azure: Effect.fnUntraced(function* (provider: Info) { const env = yield* dep.env() + const auth = yield* dep.auth(provider.id) const resource = iife(() => { - const name = provider.options?.resourceName - if (typeof name === "string" && name.trim() !== "") return name - return env["AZURE_RESOURCE_NAME"] + return [ + provider.options?.resourceName, + auth?.type === "api" ? auth.metadata?.resourceName : undefined, + env["AZURE_RESOURCE_NAME"], + ].find((name) => typeof name === "string" && name.trim() !== "") }) + if (!resource && !provider.options?.baseURL) { + return { + autoload: false, + async getModel() { + throw new Error( + "AZURE_RESOURCE_NAME is missing, set it using env var or reconnecting the azure provider and setting it", + ) + }, + } + } + return { autoload: false, async getModel(sdk: any, modelID: string, options?: Record) { @@ -230,11 +244,16 @@ function custom(dep: CustomDep): Record { return sdk.responses(modelID) } }, - options: {}, - vars(_options) { - return { - ...(resource && { AZURE_RESOURCE_NAME: resource }), + options: { + resourceName: resource, + }, + vars(_options): Record { + if (resource) { + return { + AZURE_RESOURCE_NAME: resource, + } } + return {} }, } }), @@ -871,7 +890,7 @@ const ProviderCost = Schema.Struct({ input: Schema.Finite, output: Schema.Finite, cache: ProviderCacheCost, - experimentalOver200K: Schema.optional( + experimentalOver200K: optionalOmitUndefined( Schema.Struct({ input: Schema.Finite, output: Schema.Finite, @@ -882,7 +901,7 @@ const ProviderCost = Schema.Struct({ const ProviderLimit = Schema.Struct({ context: Schema.Finite, - input: Schema.optional(Schema.Finite), + input: optionalOmitUndefined(Schema.Finite), output: Schema.Finite, }) @@ -891,7 +910,7 @@ export const Model = Schema.Struct({ providerID: ProviderID, api: ProviderApiInfo, name: Schema.String, - family: Schema.optional(Schema.String), + family: optionalOmitUndefined(Schema.String), capabilities: ProviderCapabilities, cost: ProviderCost, limit: ProviderLimit, @@ -899,7 +918,7 @@ export const Model = Schema.Struct({ options: Schema.Record(Schema.String, Schema.Any), headers: Schema.Record(Schema.String, Schema.String), release_date: Schema.String, - variants: Schema.optional(Schema.Record(Schema.String, Schema.Record(Schema.String, Schema.Any))), + variants: optionalOmitUndefined(Schema.Record(Schema.String, Schema.Record(Schema.String, Schema.Any))), ...KILO_MODEL_SCHEMA_EXTENSIONS, // kilocode_change }) .annotate({ identifier: "Model" }) @@ -911,7 +930,7 @@ export const Info = Schema.Struct({ name: Schema.String, source: Schema.Literals(["env", "config", "custom", "api"]), env: Schema.Array(Schema.String), - key: Schema.optional(Schema.String), + key: optionalOmitUndefined(Schema.String), options: Schema.Record(Schema.String, Schema.Any), models: Schema.Record(Schema.String, Model), }) @@ -1072,7 +1091,7 @@ export function fromModelsDevProvider(provider: ModelsDev.Provider): Info { const layer: Layer.Layer< Service, never, - Config.Service | Auth.Service | Plugin.Service | AppFileSystem.Service | Env.Service + Config.Service | Auth.Service | Plugin.Service | AppFileSystem.Service | Env.Service | ModelsDev.Service > = Layer.effect( Service, Effect.gen(function* () { @@ -1081,13 +1100,14 @@ const layer: Layer.Layer< const auth = yield* Auth.Service const env = yield* Env.Service const plugin = yield* Plugin.Service + const modelsDevSvc = yield* ModelsDev.Service const state = yield* InstanceState.make(() => Effect.gen(function* () { using _ = log.time("state") const bridge = yield* EffectBridge.make() const cfg = yield* config.get() - const modelsDev = yield* Effect.promise(() => ModelsDev.get()) + const modelsDev = yield* modelsDevSvc.get() const database = mapValues(modelsDev, fromModelsDevProvider) const providers: Record = {} as Record @@ -1138,6 +1158,33 @@ const layer: Layer.Layer< return true } + for (const hook of plugins) { + const p = hook.provider + const models = p?.models + if (!p || !models) continue + + const providerID = ProviderID.make(p.id) + if (disabled.has(providerID)) continue + + const provider = database[providerID] + if (!provider) continue + const pluginAuth = yield* auth.get(providerID).pipe(Effect.orDie) + + provider.models = yield* Effect.promise(async () => { + const next = await models(provider, { auth: pluginAuth }) + return Object.fromEntries( + Object.entries(next).map(([id, model]) => [ + id, + { + ...model, + id: ModelID.make(id), + providerID, + }, + ]), + ) + }) + } + // extend database from config for (const [providerID, provider] of configProviders) { const existing = database[providerID] @@ -1344,33 +1391,6 @@ const layer: Layer.Layer< }) } - for (const hook of plugins) { - const p = hook.provider - const models = p?.models - if (!p || !models) continue - - const providerID = ProviderID.make(p.id) - if (disabled.has(providerID)) continue - - const provider = providers[providerID] - if (!provider) continue - const pluginAuth = yield* auth.get(providerID).pipe(Effect.orDie) - - provider.models = yield* Effect.promise(async () => { - const next = await models(provider, { auth: pluginAuth }) - return Object.fromEntries( - Object.entries(next).map(([id, model]) => [ - id, - { - ...model, - id: ModelID.make(id), - providerID, - }, - ]), - ) - }) - } - for (const [id, provider] of Object.entries(providers)) { const providerID = ProviderID.make(id) if (!isProviderAllowed(providerID)) { @@ -1760,6 +1780,7 @@ export const defaultLayer = Layer.suspend(() => Layer.provide(Config.defaultLayer), Layer.provide(Auth.defaultLayer), Layer.provide(Plugin.defaultLayer), + Layer.provide(ModelsDev.defaultLayer), ), ) diff --git a/packages/opencode/src/provider/transform.ts b/packages/opencode/src/provider/transform.ts index 3bb75e2e7b..efa5474f6e 100644 --- a/packages/opencode/src/provider/transform.ts +++ b/packages/opencode/src/provider/transform.ts @@ -1115,7 +1115,17 @@ export function providerOptions(model: Provider.Model, options: { [x: string]: a } // kilocode_change end - const key = sdkKey(model.api.npm) ?? model.providerID + // AI SDK packages that resolve providerOptionsName by splitting the + // provider name on "." (e.g. "wafer.ai" -> "wafer") need the same + // logic here so the key we write matches the key they read. + // Other SDKs (xai, mistral, groq, cohere, etc.) use hardcoded keys + // like "xai" or "cohere" - applying .split(".")[0] would break those. + const usesDotSplitOptions = + model.api.npm === "@ai-sdk/openai-compatible" || + model.api.npm === "@ai-sdk/openai" || + model.api.npm === "@ai-sdk/anthropic" + + const key = sdkKey(model.api.npm) ?? (usesDotSplitOptions ? model.providerID.split(".")[0] : model.providerID) // @ai-sdk/azure delegates to OpenAIChatLanguageModel which reads from // providerOptions["openai"], but OpenAIResponsesLanguageModel checks // "azure" first. Pass both so model options work on either code path. diff --git a/packages/opencode/src/pty/index.ts b/packages/opencode/src/pty/index.ts index 1d8dadd04c..0c79d5df13 100644 --- a/packages/opencode/src/pty/index.ts +++ b/packages/opencode/src/pty/index.ts @@ -5,7 +5,6 @@ import { InstanceState } from "@/effect/instance-state" import { EffectBridge } from "@/effect/bridge" import { lazy } from "@opencode-ai/core/util/lazy" import { Plugin } from "@/plugin" -import { Instance } from "@/project/instance" import { Shell } from "@/shell/shell" import type { Proc } from "#pty" import * as Log from "@opencode-ai/core/util/log" @@ -239,42 +238,38 @@ export const layer = Layer.effect( subscribers: new Map(), } s.sessions.set(id, session) - proc.onData( - Instance.bind((chunk) => { - session.cursor += chunk.length + proc.onData((chunk) => { + session.cursor += chunk.length - for (const [key, ws] of session.subscribers.entries()) { - if (ws.readyState !== 1) { - session.subscribers.delete(key) - continue - } - if (sock(ws) !== key) { - session.subscribers.delete(key) - continue - } - try { - ws.send(chunk) - } catch { - session.subscribers.delete(key) - } + for (const [key, ws] of session.subscribers.entries()) { + if (ws.readyState !== 1) { + session.subscribers.delete(key) + continue } + if (sock(ws) !== key) { + session.subscribers.delete(key) + continue + } + try { + ws.send(chunk) + } catch { + session.subscribers.delete(key) + } + } - session.buffer += chunk - if (session.buffer.length <= BUFFER_LIMIT) return - const excess = session.buffer.length - BUFFER_LIMIT - session.buffer = session.buffer.slice(excess) - session.bufferCursor += excess - }), - ) - proc.onExit( - Instance.bind(({ exitCode }) => { - if (session.info.status === "exited") return - log.info("session exited", { id, exitCode }) - session.info.status = "exited" - bridge.fork(bus.publish(Event.Exited, { id, exitCode })) - bridge.fork(remove(id)) - }), - ) + session.buffer += chunk + if (session.buffer.length <= BUFFER_LIMIT) return + const excess = session.buffer.length - BUFFER_LIMIT + session.buffer = session.buffer.slice(excess) + session.bufferCursor += excess + }) + proc.onExit(({ exitCode }) => { + if (session.info.status === "exited") return + log.info("session exited", { id, exitCode }) + session.info.status = "exited" + bridge.fork(bus.publish(Event.Exited, { id, exitCode })) + bridge.fork(remove(id)) + }) yield* bus.publish(Event.Created, { info }) return info }) diff --git a/packages/opencode/src/pty/input.ts b/packages/opencode/src/pty/input.ts new file mode 100644 index 0000000000..0e4ea9a61a --- /dev/null +++ b/packages/opencode/src/pty/input.ts @@ -0,0 +1,24 @@ +import { Effect } from "effect" + +const inputDecoder = new TextDecoder("utf-8", { fatal: true }) + +export function handlePtyInput( + handler: { onMessage: (message: string | ArrayBuffer) => void }, + message: string | Uint8Array, +) { + if (typeof message === "string") { + handler.onMessage(message) + return Effect.void + } + return Effect.try({ + try: () => inputDecoder.decode(message), + catch: () => new Error("invalid PTY websocket input"), + }).pipe( + Effect.catch(() => Effect.succeed(undefined)), + Effect.flatMap((decoded) => { + if (decoded === undefined) return Effect.void + handler.onMessage(decoded) + return Effect.void + }), + ) +} diff --git a/packages/opencode/src/server/cors.ts b/packages/opencode/src/server/cors.ts new file mode 100644 index 0000000000..178d68835e --- /dev/null +++ b/packages/opencode/src/server/cors.ts @@ -0,0 +1,19 @@ +import * as KiloServer from "@/kilocode/server/server" // kilocode_change + +const opencodeOrigin = /^https:\/\/([a-z0-9-]+\.)*opencode\.ai$/ + +export type CorsOptions = { readonly cors?: ReadonlyArray } + +export function isAllowedCorsOrigin(input: string | undefined, opts?: CorsOptions) { + if (!input) return true + if (input.startsWith("http://localhost:")) return true + if (input.startsWith("http://127.0.0.1:")) return true + if (input.startsWith("oc://renderer")) return true + if (input === "tauri://localhost" || input === "http://tauri.localhost" || input === "https://tauri.localhost") + return true + if (opencodeOrigin.test(input)) return true + // kilocode_change start + if (KiloServer.corsOrigin(input)) return true + // kilocode_change end + return opts?.cors?.includes(input) ?? false +} diff --git a/packages/opencode/src/server/fence.ts b/packages/opencode/src/server/fence.ts index 7b1ae8ed67..b42343a59f 100644 --- a/packages/opencode/src/server/fence.ts +++ b/packages/opencode/src/server/fence.ts @@ -5,6 +5,8 @@ import { EventSequenceTable } from "@/sync/event.sql" import { Workspace } from "@/control-plane/workspace" import type { WorkspaceID } from "@/control-plane/schema" import * as Log from "@opencode-ai/core/util/log" +import { AppRuntime } from "@/effect/app-runtime" +import { Effect } from "effect" const HEADER = "x-kilo-sync" type State = Record @@ -54,16 +56,22 @@ export function parse(headers: Headers) { ) as State } +export function waitEffect(workspaceID: WorkspaceID, state: State, signal?: AbortSignal) { + return Effect.gen(function* () { + log.info("waiting for state", { + workspaceID, + state, + }) + yield* Workspace.Service.use((workspace) => workspace.waitForSync(workspaceID, state, signal)) + log.info("state fully synced", { + workspaceID, + state, + }) + }) +} + export async function wait(workspaceID: WorkspaceID, state: State, signal?: AbortSignal) { - log.info("waiting for state", { - workspaceID, - state, - }) - await Workspace.waitForSync(workspaceID, state, signal) - log.info("state fully synced", { - workspaceID, - state, - }) + await AppRuntime.runPromise(waitEffect(workspaceID, state, signal)) } export const FenceMiddleware: MiddlewareHandler = async (c, next) => { diff --git a/packages/opencode/src/server/middleware.ts b/packages/opencode/src/server/middleware.ts index 46fae0a067..be816af6b5 100644 --- a/packages/opencode/src/server/middleware.ts +++ b/packages/opencode/src/server/middleware.ts @@ -12,6 +12,7 @@ import { cors } from "hono/cors" import { compress } from "hono/compress" import * as KiloServer from "@/kilocode/server/server" // kilocode_change import * as ServerBackend from "./backend" +import { isAllowedCorsOrigin, type CorsOptions } from "./cors" const log = Log.create({ service: "server" }) @@ -67,25 +68,11 @@ export function LoggerMiddleware(backendAttributes: ServerBackend.Attributes): M } } -export function CorsMiddleware(opts?: { cors?: string[] }): MiddlewareHandler { +export function CorsMiddleware(opts?: CorsOptions): MiddlewareHandler { return cors({ maxAge: 86_400, origin(input) { - if (!input) return - - if (input.startsWith("http://localhost:")) return input - if (input.startsWith("http://127.0.0.1:")) return input - if (input === "tauri://localhost" || input === "http://tauri.localhost" || input === "https://tauri.localhost") - return input - - if (/^https:\/\/([a-z0-9-]+\.)*opencode\.ai$/.test(input)) return input - - // kilocode_change start - const kilo = KiloServer.corsOrigin(input) - if (kilo) return kilo - // kilocode_change end - - if (opts?.cors?.includes(input)) return input + if (isAllowedCorsOrigin(input, opts)) return input }, }) } diff --git a/packages/opencode/src/server/proxy.ts b/packages/opencode/src/server/proxy.ts index 130a935275..57662511e1 100644 --- a/packages/opencode/src/server/proxy.ts +++ b/packages/opencode/src/server/proxy.ts @@ -4,6 +4,7 @@ import * as Log from "@opencode-ai/core/util/log" import * as Fence from "./fence" import type { WorkspaceID } from "@/control-plane/schema" import { Workspace } from "@/control-plane/workspace" +import { AppRuntime } from "@/effect/app-runtime" import { ProxyUtil } from "./proxy-util" import { Effect, Stream } from "effect" import { FetchHttpClient, HttpBody, HttpClient, HttpClientRequest } from "effect/unstable/http" @@ -74,8 +75,7 @@ function statusText(response: unknown) { export function httpEffect(url: string | URL, extra: HeadersInit | undefined, req: Request, workspaceID: WorkspaceID) { return Effect.gen(function* () { - // kilocode_change - await Workspace.isSyncing (returns Promise) - const syncing = yield* Effect.promise(() => Workspace.isSyncing(workspaceID)) + const syncing = yield* Workspace.Service.use((workspace) => workspace.isSyncing(workspaceID)) if (!syncing) { return new Response(`broken sync connection for workspace: ${workspaceID}`, { status: 503, @@ -104,7 +104,7 @@ export function httpEffect(url: string | URL, extra: HeadersInit | undefined, re next.delete("content-encoding") next.delete("content-length") - if (sync) yield* Effect.promise(() => Fence.wait(workspaceID, sync, req.signal)) + if (sync) yield* Fence.waitEffect(workspaceID, sync, req.signal) const body = yield* Stream.toReadableStreamEffect(response.stream.pipe(Stream.catchCause(() => Stream.empty))) return new Response(body, { status: response.status, @@ -118,7 +118,7 @@ export function httpEffect(url: string | URL, extra: HeadersInit | undefined, re } export function http(url: string | URL, extra: HeadersInit | undefined, req: Request, workspaceID: WorkspaceID) { - return Effect.runPromise(httpEffect(url, extra, req, workspaceID)) + return AppRuntime.runPromise(httpEffect(url, extra, req, workspaceID)) } export function websocket( diff --git a/packages/opencode/src/server/routes/control/workspace.ts b/packages/opencode/src/server/routes/control/workspace.ts index 19fbc757fb..21a7810ce1 100644 --- a/packages/opencode/src/server/routes/control/workspace.ts +++ b/packages/opencode/src/server/routes/control/workspace.ts @@ -1,9 +1,11 @@ import { Hono } from "hono" import { describeRoute, resolver, validator } from "hono-openapi" import z from "zod" -import { listAdaptors } from "@/control-plane/adaptors" +import { Effect } from "effect" +import { listAdapters } from "@/control-plane/adapters" import { Workspace } from "@/control-plane/workspace" -import { WorkspaceAdaptorEntry } from "@/control-plane/types" +import { AppRuntime } from "@/effect/app-runtime" +import { WorkspaceAdapterEntry } from "@/control-plane/types" import { zodObject } from "@/util/effect-zod" import { Instance } from "@/project/instance" import { errors } from "../../error" @@ -16,24 +18,24 @@ const log = Log.create({ service: "server.workspace" }) export const WorkspaceRoutes = lazy(() => new Hono() .get( - "/adaptor", + "/adapter", describeRoute({ - summary: "List workspace adaptors", - description: "List all available workspace adaptors for the current project.", - operationId: "experimental.workspace.adaptor.list", + summary: "List workspace adapters", + description: "List all available workspace adapters for the current project.", + operationId: "experimental.workspace.adapter.list", responses: { 200: { - description: "Workspace adaptors", + description: "Workspace adapters", content: { "application/json": { - schema: resolver(z.array(zodObject(WorkspaceAdaptorEntry))), + schema: resolver(z.array(zodObject(WorkspaceAdapterEntry))), }, }, }, }, }), async (c) => { - return c.json(await listAdaptors(Instance.project.id)) + return c.json(await listAdapters(Instance.project.id)) }, ) .post( @@ -62,10 +64,14 @@ export const WorkspaceRoutes = lazy(() => ), async (c) => { const body = c.req.valid("json") as Omit - const workspace = await Workspace.create({ - projectID: Instance.project.id, - ...body, - }) + const workspace = await AppRuntime.runPromise( + Workspace.Service.use((svc) => + svc.create({ + projectID: Instance.project.id, + ...body, + }), + ), + ) return c.json(workspace) }, ) @@ -87,7 +93,7 @@ export const WorkspaceRoutes = lazy(() => }, }), async (c) => { - return c.json(Workspace.list(Instance.project)) + return c.json(await AppRuntime.runPromise(Workspace.Service.use((svc) => svc.list(Instance.project)))) }, ) .get( @@ -108,8 +114,11 @@ export const WorkspaceRoutes = lazy(() => }, }), async (c) => { - const ids = new Set(Workspace.list(Instance.project).map((item) => item.id)) - return c.json(Workspace.status().filter((item) => ids.has(item.workspaceID))) + const result = await AppRuntime.runPromise( + Workspace.Service.use((svc) => Effect.all([svc.list(Instance.project), svc.status()])), + ) + const ids = new Set(result[0].map((item) => item.id)) + return c.json(result[1].filter((item) => ids.has(item.workspaceID))) }, ) .delete( @@ -138,7 +147,7 @@ export const WorkspaceRoutes = lazy(() => ), async (c) => { const { id } = c.req.valid("param") - return c.json(await Workspace.remove(id)) + return c.json(await AppRuntime.runPromise(Workspace.Service.use((svc) => svc.remove(id)))) }, ) .post( @@ -174,10 +183,14 @@ export const WorkspaceRoutes = lazy(() => directory: Instance.directory, }) try { - const result = await Workspace.sessionRestore({ - workspaceID: id, - ...body, - }) + const result = await AppRuntime.runPromise( + Workspace.Service.use((svc) => + svc.sessionRestore({ + workspaceID: id, + ...body, + }), + ), + ) log.info("session restore route complete", { workspaceID: id, sessionID: body.sessionID, diff --git a/packages/opencode/src/server/routes/global.ts b/packages/opencode/src/server/routes/global.ts index 26529c22bd..2f99510cfa 100644 --- a/packages/opencode/src/server/routes/global.ts +++ b/packages/opencode/src/server/routes/global.ts @@ -8,7 +8,7 @@ import { SyncEvent } from "@/sync" import { GlobalBus } from "@/bus/global" import { AppRuntime } from "@/effect/app-runtime" import { AsyncQueue } from "@/util/queue" -import { Instance } from "../../project/instance" +import { InstanceStore } from "../../project/instance-store" import { Installation } from "@/installation" import { InstallationVersion } from "@opencode-ai/core/installation/version" import * as Log from "@opencode-ai/core/util/log" @@ -213,7 +213,7 @@ export const GlobalRoutes = lazy(() => }, }), async (c) => { - await Config.invalidate() // kilocode_change - reset cached global config so re-init reads fresh data from disk + await Config.invalidate(true) // kilocode_change - also disposes instances; awaiting matches upstream behavior GlobalBus.emit("event", { directory: "global", payload: { diff --git a/packages/opencode/src/server/routes/instance/AGENTS.md b/packages/opencode/src/server/routes/instance/AGENTS.md new file mode 100644 index 0000000000..c94fa64af7 --- /dev/null +++ b/packages/opencode/src/server/routes/instance/AGENTS.md @@ -0,0 +1,8 @@ +# Instance Route Parity + +This directory contains the legacy Hono instance routes and the experimental Effect HttpApi implementation under `httpapi/`. Keep them behaviorally aligned. + +- When adding, removing, or changing a legacy Hono route, update the matching Effect HttpApi group and handler in `httpapi/` in the same change unless the route is intentionally unsupported. +- When changing an Effect HttpApi route, verify the legacy Hono route has the same public behavior, request shape, response shape, status codes, and instance/workspace routing semantics. +- Keep OpenAPI/SDK-visible schemas aligned. If a difference is only an OpenAPI generation artifact, prefer fixing the source schema first; use `httpapi/public.ts` normalization only for compatibility shims that cannot be represented cleanly in the source schema. +- Add or update parity coverage in `test/server/httpapi-bridge.test.ts` or the focused HttpApi tests when behavior or schema parity could regress. diff --git a/packages/opencode/src/server/routes/instance/httpapi/AGENTS.md b/packages/opencode/src/server/routes/instance/httpapi/AGENTS.md new file mode 100644 index 0000000000..757d7aed0c --- /dev/null +++ b/packages/opencode/src/server/routes/instance/httpapi/AGENTS.md @@ -0,0 +1,35 @@ +# HttpApi Route Patterns + +Use `HttpApiBuilder.group(...)` for normal HTTP endpoints, including streaming HTTP responses such as server-sent events. Handlers should yield stable services once while building the handler layer, then close over those services in endpoint implementations. + +```ts +export const sessionHandlers = HttpApiBuilder.group(InstanceHttpApi, "session", (handlers) => + Effect.gen(function* () { + const session = yield* Session.Service + + return handlers.handle("list", () => session.list()) + }), +) +``` + +For SSE endpoints, stay in `HttpApiBuilder.group(...)` and return `HttpServerResponse.stream(...)` from the handler. Annotate the endpoint success schema with `HttpApiSchema.asText({ contentType: "text/event-stream" })` so OpenAPI documents the stream content type. + +Use raw `HttpRouter.use(...)` only for routes that do not fit the request/response HttpApi model, such as WebSocket upgrade routes or catch-all fallback routes. Yield stable services at route-layer construction and close over them in `router.add(...)` callbacks. + +```ts +export const rawRoute = HttpRouter.use((router) => + Effect.gen(function* () { + const pty = yield* Pty.Service + + yield* router.add("GET", PtyPaths.connect, (request) => connectPty(request, pty)) + }), +) +``` + +Avoid `Effect.provide(SomeLayer)` inside request handlers or raw route callbacks. Stable layers should be provided once at the application/layer boundary, not rebuilt or scoped per request. + +Avoid `HttpRouter.provideRequest(...)` unless the dependency is intentionally request-level. Prefer `HttpRouter.use(...)` for stable app services. + +Use `Effect.provideService(...)` in middleware only for request-derived context, such as `WorkspaceRouteContext`, `InstanceRef`, or `WorkspaceRef`. Do not use it to smuggle stable services through request effects when they can be yielded at layer construction. + +When adding middleware, compose it at the layer boundary and keep the route tree explicit in `server.ts`. Shared router middleware such as auth, workspace routing, and instance context should stay visible where routes are assembled. diff --git a/packages/opencode/src/server/routes/instance/httpapi/event.ts b/packages/opencode/src/server/routes/instance/httpapi/event.ts index 9f4ddde4c2..25e810753e 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/event.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/event.ts @@ -2,8 +2,8 @@ import { Bus } from "@/bus" import * as Log from "@opencode-ai/core/util/log" import { Effect, Schema } from "effect" import * as Stream from "effect/Stream" -import { HttpRouter, HttpServerResponse } from "effect/unstable/http" -import { HttpApi, HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi" +import { HttpServerResponse } from "effect/unstable/http" +import { HttpApi, HttpApiBuilder, HttpApiEndpoint, HttpApiGroup, HttpApiSchema, OpenApi } from "effect/unstable/httpapi" import * as Sse from "effect/unstable/encoding/Sse" const log = Log.create({ service: "server" }) @@ -16,7 +16,7 @@ export const EventApi = HttpApi.make("event").add( HttpApiGroup.make("event") .add( HttpApiEndpoint.get("subscribe", EventPaths.event, { - success: Schema.Unknown, + success: Schema.String.pipe(HttpApiSchema.asText({ contentType: "text/event-stream" })), }).annotateMerge( OpenApi.annotations({ identifier: "event.subscribe", @@ -37,34 +37,41 @@ function eventData(data: unknown): Sse.Event { } } -export const eventRoute = HttpRouter.add( - "GET", - EventPaths.event, +function eventResponse(bus: Bus.Interface) { + const events = bus.subscribeAll().pipe(Stream.takeUntil((event) => event.type === Bus.InstanceDisposed.type)) + const heartbeat = Stream.tick("10 seconds").pipe( + Stream.drop(1), + Stream.map(() => ({ type: "server.heartbeat", properties: {} })), + ) + + log.info("event connected") + return HttpServerResponse.stream( + Stream.make({ type: "server.connected", properties: {} }).pipe( + Stream.concat(events.pipe(Stream.merge(heartbeat, { haltStrategy: "left" }))), + Stream.map(eventData), + Stream.pipeThroughChannel(Sse.encode()), + Stream.encodeText, + Stream.ensuring(Effect.sync(() => log.info("event disconnected"))), + ), + { + contentType: "text/event-stream", + headers: { + "Cache-Control": "no-cache, no-transform", + "X-Accel-Buffering": "no", + "X-Content-Type-Options": "nosniff", + }, + }, + ) +} + +export const eventHandlers = HttpApiBuilder.group(EventApi, "event", (handlers) => Effect.gen(function* () { const bus = yield* Bus.Service - const events = bus.subscribeAll().pipe(Stream.takeUntil((event) => event.type === Bus.InstanceDisposed.type)) - const heartbeat = Stream.tick("10 seconds").pipe( - Stream.drop(1), - Stream.map(() => ({ type: "server.heartbeat", properties: {} })), + return handlers.handleRaw( + "subscribe", + Effect.fn("EventHttpApi.subscribe")(function* () { + return eventResponse(bus) + }), ) - - log.info("event connected") - return HttpServerResponse.stream( - Stream.make({ type: "server.connected", properties: {} }).pipe( - Stream.concat(events.pipe(Stream.merge(heartbeat, { haltStrategy: "left" }))), - Stream.map(eventData), - Stream.pipeThroughChannel(Sse.encode()), - Stream.encodeText, - Stream.ensuring(Effect.sync(() => log.info("event disconnected"))), - ), - { - contentType: "text/event-stream", - headers: { - "Cache-Control": "no-cache, no-transform", - "X-Accel-Buffering": "no", - "X-Content-Type-Options": "nosniff", - }, - }, - ) - }).pipe(Effect.provide(Bus.layer)), + }), ) diff --git a/packages/opencode/src/server/routes/instance/httpapi/groups/mcp.ts b/packages/opencode/src/server/routes/instance/httpapi/groups/mcp.ts index e9caf0cd9d..b30714c196 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/groups/mcp.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/groups/mcp.ts @@ -15,6 +15,7 @@ export const AddPayload = Schema.Struct({ export const StatusMap = Schema.Record(Schema.String, MCP.Status) export const AuthStartResponse = Schema.Struct({ authorizationUrl: Schema.String, + oauthState: Schema.String, }) export const AuthCallbackPayload = Schema.Struct({ code: Schema.String, diff --git a/packages/opencode/src/server/routes/instance/httpapi/groups/session.ts b/packages/opencode/src/server/routes/instance/httpapi/groups/session.ts index bc26a9e597..77d064ff5a 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/groups/session.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/groups/session.ts @@ -10,7 +10,6 @@ import { SessionSummary } from "@/session/summary" import { Todo } from "@/session/todo" import { MessageID, PartID, SessionID } from "@/session/schema" import { Snapshot } from "@/snapshot" -import { NonNegativeInt } from "@/util/schema" import { Schema, SchemaGetter, Struct } from "effect" import { HttpApi, HttpApiEndpoint, HttpApiError, HttpApiGroup, HttpApiSchema, OpenApi } from "effect/unstable/httpapi" import { Authorization } from "../middleware/authorization" @@ -45,7 +44,7 @@ export const UpdatePayload = Schema.Struct({ permission: Schema.optional(Permission.Ruleset), time: Schema.optional( Schema.Struct({ - archived: Schema.optional(NonNegativeInt), + archived: Schema.optional(Session.ArchivedTimestamp), }), ), }) diff --git a/packages/opencode/src/server/routes/instance/httpapi/groups/workspace.ts b/packages/opencode/src/server/routes/instance/httpapi/groups/workspace.ts index 268e84f2ec..08e9e044bb 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/groups/workspace.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/groups/workspace.ts @@ -1,5 +1,5 @@ import { Workspace } from "@/control-plane/workspace" -import { WorkspaceAdaptorEntry } from "@/control-plane/types" +import { WorkspaceAdapterEntry } from "@/control-plane/types" import { NonNegativeInt } from "@/util/schema" import { Schema, Struct } from "effect" import { HttpApi, HttpApiEndpoint, HttpApiError, HttpApiGroup, OpenApi } from "effect/unstable/httpapi" @@ -9,14 +9,17 @@ import { WorkspaceRoutingMiddleware } from "../middleware/workspace-routing" import { described } from "./metadata" const root = "/experimental/workspace" -export const CreatePayload = Schema.Struct(Struct.omit(Workspace.CreateInput.fields, ["projectID"])) +export const CreatePayload = Schema.Struct({ + ...Struct.omit(Workspace.CreateInput.fields, ["projectID", "extra"]), + extra: Schema.optional(Workspace.CreateInput.fields.extra), +}) export const SessionRestorePayload = Schema.Struct(Struct.omit(Workspace.SessionRestoreInput.fields, ["workspaceID"])) export const SessionRestoreResponse = Schema.Struct({ total: NonNegativeInt, }) export const WorkspacePaths = { - adaptors: `${root}/adaptor`, + adapters: `${root}/adapter`, list: root, status: `${root}/status`, remove: `${root}/:id`, @@ -27,13 +30,13 @@ export const WorkspaceApi = HttpApi.make("workspace") .add( HttpApiGroup.make("workspace") .add( - HttpApiEndpoint.get("adaptors", WorkspacePaths.adaptors, { - success: described(Schema.Array(WorkspaceAdaptorEntry), "Workspace adaptors"), + HttpApiEndpoint.get("adapters", WorkspacePaths.adapters, { + success: described(Schema.Array(WorkspaceAdapterEntry), "Workspace adapters"), }).annotateMerge( OpenApi.annotations({ - identifier: "experimental.workspace.adaptor.list", - summary: "List workspace adaptors", - description: "List all available workspace adaptors for the current project.", + identifier: "experimental.workspace.adapter.list", + summary: "List workspace adapters", + description: "List all available workspace adapters for the current project.", }), ), HttpApiEndpoint.get("list", WorkspacePaths.list, { diff --git a/packages/opencode/src/server/routes/instance/httpapi/handlers/global.ts b/packages/opencode/src/server/routes/instance/httpapi/handlers/global.ts index cd1bebec47..bcad2832e2 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/handlers/global.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/handlers/global.ts @@ -1,7 +1,7 @@ import { Config } from "@/config/config" import { GlobalBus, type GlobalEvent as GlobalBusEvent } from "@/bus/global" import { Installation } from "@/installation" -import { Instance } from "@/project/instance" +import { InstanceStore } from "@/project/instance-store" import { InstallationVersion } from "@opencode-ai/core/installation/version" import * as Log from "@opencode-ai/core/util/log" import { Effect, Queue, Schema } from "effect" @@ -68,6 +68,7 @@ export const globalHandlers = HttpApiBuilder.group(RootHttpApi, "global", (handl Effect.gen(function* () { const config = yield* Config.Service const installation = yield* Installation.Service + const store = yield* InstanceStore.Service const health = Effect.fn("GlobalHttpApi.health")(function* () { return { healthy: true as const, version: InstallationVersion } @@ -86,7 +87,7 @@ export const globalHandlers = HttpApiBuilder.group(RootHttpApi, "global", (handl }) const dispose = Effect.fn("GlobalHttpApi.dispose")(function* () { - yield* Effect.promise(() => Instance.disposeAll()) + yield* store.disposeAll() GlobalBus.emit("event", { directory: "global", payload: { type: "global.disposed", properties: {} }, diff --git a/packages/opencode/src/server/routes/instance/httpapi/handlers/project.ts b/packages/opencode/src/server/routes/instance/httpapi/handlers/project.ts index ae2761ac32..3c1dd350db 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/handlers/project.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/handlers/project.ts @@ -1,6 +1,5 @@ import { AppRuntime } from "@/effect/app-runtime" import * as InstanceState from "@/effect/instance-state" -import { InstanceBootstrap } from "@/project/bootstrap" import { Project } from "@/project/project" import { ProjectID } from "@/project/schema" import { Effect } from "effect" @@ -29,7 +28,6 @@ export const projectHandlers = HttpApiBuilder.group(InstanceHttpApi, "project", directory: ctx.directory, worktree: ctx.directory, project: next, - init: () => AppRuntime.runPromise(InstanceBootstrap), }) return next }) diff --git a/packages/opencode/src/server/routes/instance/httpapi/handlers/provider.ts b/packages/opencode/src/server/routes/instance/httpapi/handlers/provider.ts index c8689eabab..f9df530a92 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/handlers/provider.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/handlers/provider.ts @@ -17,7 +17,7 @@ export const providerHandlers = HttpApiBuilder.group(InstanceHttpApi, "provider" const list = Effect.fn("ProviderHttpApi.list")(function* () { const config = yield* cfg.get() - const all = yield* Effect.promise(() => ModelsDev.get()) + const all = yield* ModelsDev.Service.use((s) => s.get()) const disabled = new Set(config.disabled_providers ?? []) const enabled = config.enabled_providers ? new Set(config.enabled_providers) : undefined const filtered: Record = {} diff --git a/packages/opencode/src/server/routes/instance/httpapi/handlers/pty.ts b/packages/opencode/src/server/routes/instance/httpapi/handlers/pty.ts index 8558ee793c..cc7c385b3e 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/handlers/pty.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/handlers/pty.ts @@ -1,6 +1,6 @@ -import { EffectBridge } from "@/effect/bridge" import { Pty } from "@/pty" import { PtyID } from "@/pty/schema" +import { handlePtyInput } from "@/pty/input" import { Shell } from "@/shell/shell" import { Effect } from "effect" import { HttpRouter, HttpServerRequest, HttpServerResponse } from "effect/unstable/http" @@ -22,16 +22,11 @@ export const ptyHandlers = HttpApiBuilder.group(InstanceHttpApi, "pty", (handler }) const create = Effect.fn("PtyHttpApi.create")(function* (ctx: { payload: typeof Pty.CreateInput.Type }) { - const bridge = yield* EffectBridge.make() - return yield* Effect.promise(() => - bridge.promise( - pty.create({ - ...ctx.payload, - args: ctx.payload.args ? [...ctx.payload.args] : undefined, - env: ctx.payload.env ? { ...ctx.payload.env } : undefined, - }), - ), - ) + return yield* pty.create({ + ...ctx.payload, + args: ctx.payload.args ? [...ctx.payload.args] : undefined, + env: ctx.payload.env ? { ...ctx.payload.env } : undefined, + }) }) const get = Effect.fn("PtyHttpApi.get")(function* (ctx: { params: { ptyID: PtyID } }) { @@ -67,54 +62,60 @@ export const ptyHandlers = HttpApiBuilder.group(InstanceHttpApi, "pty", (handler }), ) -export const ptyConnectRoute = HttpRouter.add( - "GET", - PtyPaths.connect, +export const ptyConnectRoute = HttpRouter.use((router) => Effect.gen(function* () { const pty = yield* Pty.Service - const params = yield* HttpRouter.schemaPathParams(Params) - if (!(yield* pty.get(params.ptyID))) return HttpServerResponse.empty({ status: 404 }) + yield* router.add( + "GET", + PtyPaths.connect, + Effect.gen(function* () { + const params = yield* HttpRouter.schemaPathParams(Params) + if (!(yield* pty.get(params.ptyID))) return HttpServerResponse.empty({ status: 404 }) - const query = yield* HttpServerRequest.schemaSearchParams(CursorQuery) - const parsedCursor = query.cursor === undefined ? undefined : Number(query.cursor) - const cursor = - parsedCursor !== undefined && Number.isSafeInteger(parsedCursor) && parsedCursor >= -1 ? parsedCursor : undefined - const socket = yield* Effect.orDie((yield* HttpServerRequest.HttpServerRequest).upgrade) - const write = yield* socket.writer - let closed = false - const adapter = { - get readyState() { - return closed ? 3 : 1 - }, - send: (data: string | Uint8Array | ArrayBuffer) => { - if (closed) return - Effect.runFork( - write(data instanceof ArrayBuffer ? new Uint8Array(data) : data).pipe(Effect.catch(() => Effect.void)), - ) - }, - close: (code?: number, reason?: string) => { - if (closed) return - closed = true - Effect.runFork(write(new Socket.CloseEvent(code, reason)).pipe(Effect.catch(() => Effect.void))) - }, - } - const handler = yield* pty.connect(params.ptyID, adapter, cursor) - if (!handler) return HttpServerResponse.empty() - - yield* socket - .runRaw((message) => { - handler.onMessage(typeof message === "string" ? message : message.slice().buffer) - }) - .pipe( - Effect.catchReason("SocketError", "SocketCloseError", () => Effect.void), - Effect.ensuring( - Effect.sync(() => { + const query = yield* HttpServerRequest.schemaSearchParams(CursorQuery) + const parsedCursor = query.cursor === undefined ? undefined : Number(query.cursor) + const cursor = + parsedCursor !== undefined && Number.isSafeInteger(parsedCursor) && parsedCursor >= -1 + ? parsedCursor + : undefined + const socket = yield* Effect.orDie((yield* HttpServerRequest.HttpServerRequest).upgrade) + const write = yield* socket.writer + const services = yield* Effect.context() + const writeScoped = (effect: Effect.Effect) => { + Effect.runForkWith(services)(effect.pipe(Effect.catch(() => Effect.void))) + } + let closed = false + const adapter = { + get readyState() { + return closed ? 3 : 1 + }, + send: (data: string | Uint8Array | ArrayBuffer) => { + if (closed) return + writeScoped(write(data instanceof ArrayBuffer ? new Uint8Array(data) : data)) + }, + close: (code?: number, reason?: string) => { + if (closed) return closed = true - handler.onClose() - }), - ), - Effect.orDie, - ) - return HttpServerResponse.empty() - }).pipe(Effect.provide(Pty.defaultLayer)), + writeScoped(write(new Socket.CloseEvent(code, reason))) + }, + } + const handler = yield* pty.connect(params.ptyID, adapter, cursor) + if (!handler) return HttpServerResponse.empty() + + yield* socket + .runRaw((message) => handlePtyInput(handler, message)) + .pipe( + Effect.catchReason("SocketError", "SocketCloseError", () => Effect.void), + Effect.ensuring( + Effect.sync(() => { + closed = true + handler.onClose() + }), + ), + Effect.orDie, + ) + return HttpServerResponse.empty() + }), + ) + }), ) diff --git a/packages/opencode/src/server/routes/instance/httpapi/handlers/session.ts b/packages/opencode/src/server/routes/instance/httpapi/handlers/session.ts index 65c90b9529..8cc969f483 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/handlers/session.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/handlers/session.ts @@ -1,11 +1,10 @@ import * as InstanceState from "@/effect/instance-state" -import { AppRuntime } from "@/effect/app-runtime" +import { InstanceRef, WorkspaceRef } from "@/effect/instance-ref" import { Agent } from "@/agent/agent" import { Bus } from "@/bus" import { Command } from "@/command" import { Permission } from "@/permission" import { PermissionID } from "@/permission/schema" -import { Instance } from "@/project/instance" import { SessionShare } from "@/share/session" import { Session } from "@/session/session" import { SessionCompaction } from "@/session/compaction" @@ -18,9 +17,8 @@ import { SessionSummary } from "@/session/summary" import { Todo } from "@/session/todo" import { MessageID, PartID, SessionID } from "@/session/schema" import { NotFoundError } from "@/storage/storage" -import * as Log from "@opencode-ai/core/util/log" import { NamedError } from "@opencode-ai/core/util/error" -import { Effect, Schema } from "effect" +import { Cause, Effect, Schema, Scope } from "effect" import * as Stream from "effect/Stream" import { HttpServerRequest, HttpServerResponse } from "effect/unstable/http" import { HttpApiBuilder, HttpApiError, HttpApiSchema } from "effect/unstable/httpapi" @@ -40,8 +38,6 @@ import { UpdatePayload, } from "../groups/session" -const log = Log.create({ service: "server" }) - const mapNotFound = (self: Effect.Effect) => self.pipe( Effect.catchIf(NotFoundError.isInstance, () => Effect.fail(new HttpApiError.NotFound({}))), @@ -53,25 +49,29 @@ const mapNotFound = (self: Effect.Effect) => export const sessionHandlers = HttpApiBuilder.group(InstanceHttpApi, "session", (handlers) => Effect.gen(function* () { const session = yield* Session.Service + const shareSvc = yield* SessionShare.Service + const promptSvc = yield* SessionPrompt.Service + const revertSvc = yield* SessionRevert.Service + const compactSvc = yield* SessionCompaction.Service + const runState = yield* SessionRunState.Service + const agentSvc = yield* Agent.Service + const permissionSvc = yield* Permission.Service const statusSvc = yield* SessionStatus.Service const todoSvc = yield* Todo.Service const summary = yield* SessionSummary.Service + const bus = yield* Bus.Service + const scope = yield* Scope.Scope const list = Effect.fn("SessionHttpApi.list")(function* (ctx: { query: typeof ListQuery.Type }) { - const instance = yield* InstanceState.context - return Instance.restore(instance, () => - Array.from( - Session.list({ - directory: ctx.query.directory, - scope: ctx.query.scope, - path: ctx.query.path, - roots: ctx.query.roots, - start: ctx.query.start, - search: ctx.query.search, - limit: ctx.query.limit, - }), - ), - ) + return yield* session.list({ + directory: ctx.query.scope === "project" ? undefined : ctx.query.directory, + scope: ctx.query.scope, + path: ctx.query.path, + roots: ctx.query.roots, + start: ctx.query.start, + search: ctx.query.search, + limit: ctx.query.limit, + }) }) const status = Effect.fn("SessionHttpApi.status")(function* () { @@ -148,14 +148,7 @@ export const sessionHandlers = HttpApiBuilder.group(InstanceHttpApi, "session", }) const create = Effect.fn("SessionHttpApi.create")(function* (ctx: { payload?: Session.CreateInput }) { - const instance = yield* InstanceState.context - return yield* Effect.promise(() => - Instance.restore(instance, () => - AppRuntime.runPromise( - SessionShare.Service.use((svc) => svc.create(ctx.payload)).pipe(Effect.provide(SessionShare.defaultLayer)), - ), - ), - ) + return yield* shareSvc.create(ctx.payload) }) const createRaw = Effect.fn("SessionHttpApi.createRaw")(function* (ctx: { @@ -175,14 +168,7 @@ export const sessionHandlers = HttpApiBuilder.group(InstanceHttpApi, "session", }) const remove = Effect.fn("SessionHttpApi.remove")(function* (ctx: { params: { sessionID: SessionID } }) { - const instance = yield* InstanceState.context - yield* Effect.promise(() => - Instance.restore(instance, () => - AppRuntime.runPromise( - Session.Service.use((svc) => svc.remove(ctx.params.sessionID)).pipe(Effect.provide(Session.defaultLayer)), - ), - ), - ) + yield* session.remove(ctx.params.sessionID) return true }) @@ -190,60 +176,31 @@ export const sessionHandlers = HttpApiBuilder.group(InstanceHttpApi, "session", params: { sessionID: SessionID } payload: typeof UpdatePayload.Type }) { - const instance = yield* InstanceState.context - return yield* Effect.promise(() => - Instance.restore(instance, () => - AppRuntime.runPromise( - Session.Service.use((svc) => - Effect.gen(function* () { - const current = yield* svc.get(ctx.params.sessionID) - if (ctx.payload.title !== undefined) { - yield* svc.setTitle({ sessionID: ctx.params.sessionID, title: ctx.payload.title }) - } - if (ctx.payload.permission !== undefined) { - yield* svc.setPermission({ - sessionID: ctx.params.sessionID, - permission: Permission.merge(current.permission ?? [], ctx.payload.permission), - }) - } - if (ctx.payload.time?.archived !== undefined) { - yield* svc.setArchived({ sessionID: ctx.params.sessionID, time: ctx.payload.time.archived }) - } - return yield* svc.get(ctx.params.sessionID) - }), - ).pipe(Effect.provide(Session.defaultLayer)), - ), - ), - ) + const current = yield* session.get(ctx.params.sessionID) + if (ctx.payload.title !== undefined) { + yield* session.setTitle({ sessionID: ctx.params.sessionID, title: ctx.payload.title }) + } + if (ctx.payload.permission !== undefined) { + yield* session.setPermission({ + sessionID: ctx.params.sessionID, + permission: Permission.merge(current.permission ?? [], ctx.payload.permission), + }) + } + if (ctx.payload.time?.archived !== undefined) { + yield* session.setArchived({ sessionID: ctx.params.sessionID, time: ctx.payload.time.archived }) + } + return yield* session.get(ctx.params.sessionID) }) const fork = Effect.fn("SessionHttpApi.fork")(function* (ctx: { params: { sessionID: SessionID } payload: typeof ForkPayload.Type }) { - const instance = yield* InstanceState.context - return yield* Effect.promise(() => - Instance.restore(instance, () => - AppRuntime.runPromise( - Session.Service.use((svc) => - svc.fork({ sessionID: ctx.params.sessionID, messageID: ctx.payload.messageID }), - ).pipe(Effect.provide(Session.defaultLayer)), - ), - ), - ) + return yield* session.fork({ sessionID: ctx.params.sessionID, messageID: ctx.payload.messageID }) }) const abort = Effect.fn("SessionHttpApi.abort")(function* (ctx: { params: { sessionID: SessionID } }) { - const instance = yield* InstanceState.context - yield* Effect.promise(() => - Instance.restore(instance, () => - AppRuntime.runPromise( - SessionPrompt.Service.use((svc) => svc.cancel(ctx.params.sessionID)).pipe( - Effect.provide(SessionPrompt.defaultLayer), - ), - ), - ), - ) + yield* promptSvc.cancel(ctx.params.sessionID) return true }) @@ -251,98 +208,45 @@ export const sessionHandlers = HttpApiBuilder.group(InstanceHttpApi, "session", params: { sessionID: SessionID } payload: typeof InitPayload.Type }) { - const instance = yield* InstanceState.context - yield* Effect.promise(() => - Instance.restore(instance, () => - AppRuntime.runPromise( - SessionPrompt.Service.use((svc) => - svc.command({ - sessionID: ctx.params.sessionID, - messageID: ctx.payload.messageID, - model: `${ctx.payload.providerID}/${ctx.payload.modelID}`, - command: Command.Default.INIT, - arguments: "", - }), - ).pipe(Effect.provide(SessionPrompt.defaultLayer)), - ), - ), - ) + yield* promptSvc.command({ + sessionID: ctx.params.sessionID, + messageID: ctx.payload.messageID, + model: `${ctx.payload.providerID}/${ctx.payload.modelID}`, + command: Command.Default.INIT, + arguments: "", + }) return true }) const share = Effect.fn("SessionHttpApi.share")(function* (ctx: { params: { sessionID: SessionID } }) { - const instance = yield* InstanceState.context - return yield* Effect.promise(() => - Instance.restore(instance, () => - AppRuntime.runPromise( - Effect.gen(function* () { - const share = yield* SessionShare.Service - const session = yield* Session.Service - yield* share.share(ctx.params.sessionID) - return yield* session.get(ctx.params.sessionID) - }).pipe(Effect.provide(SessionShare.defaultLayer)), - ), - ), - ) + yield* shareSvc.share(ctx.params.sessionID).pipe(Effect.mapError(() => new HttpApiError.BadRequest({}))) + return yield* session.get(ctx.params.sessionID) }) const unshare = Effect.fn("SessionHttpApi.unshare")(function* (ctx: { params: { sessionID: SessionID } }) { - const instance = yield* InstanceState.context - return yield* Effect.promise(() => - Instance.restore(instance, () => - AppRuntime.runPromise( - Effect.gen(function* () { - const share = yield* SessionShare.Service - const session = yield* Session.Service - yield* share.unshare(ctx.params.sessionID) - return yield* session.get(ctx.params.sessionID) - }).pipe(Effect.provide(SessionShare.defaultLayer)), - ), - ), - ) + yield* shareSvc.unshare(ctx.params.sessionID).pipe(Effect.mapError(() => new HttpApiError.BadRequest({}))) + return yield* session.get(ctx.params.sessionID) }) const summarize = Effect.fn("SessionHttpApi.summarize")(function* (ctx: { params: { sessionID: SessionID } payload: typeof SummarizePayload.Type }) { - const instance = yield* InstanceState.context - yield* Effect.promise(() => - Instance.restore(instance, () => - AppRuntime.runPromise( - Effect.gen(function* () { - const session = yield* Session.Service - const revert = yield* SessionRevert.Service - const compact = yield* SessionCompaction.Service - const prompt = yield* SessionPrompt.Service - const agent = yield* Agent.Service + yield* revertSvc.cleanup(yield* session.get(ctx.params.sessionID)) + const messages = yield* session.messages({ sessionID: ctx.params.sessionID }) + const defaultAgent = yield* agentSvc.defaultAgent() + const currentAgent = messages.findLast((message) => message.info.role === "user")?.info.agent ?? defaultAgent - yield* revert.cleanup(yield* session.get(ctx.params.sessionID)) - const messages = yield* session.messages({ sessionID: ctx.params.sessionID }) - const defaultAgent = yield* agent.defaultAgent() - const currentAgent = - messages.findLast((message) => message.info.role === "user")?.info.agent ?? defaultAgent - - yield* compact.create({ - sessionID: ctx.params.sessionID, - agent: currentAgent, - model: { - providerID: ctx.payload.providerID, - modelID: ctx.payload.modelID, - }, - auto: ctx.payload.auto ?? false, - }) - yield* prompt.loop({ sessionID: ctx.params.sessionID }) - }).pipe( - Effect.provide(SessionRevert.defaultLayer), - Effect.provide(SessionCompaction.defaultLayer), - Effect.provide(SessionPrompt.defaultLayer), - Effect.provide(Agent.defaultLayer), - Effect.provide(Session.defaultLayer), - ), - ), - ), - ) + yield* compactSvc.create({ + sessionID: ctx.params.sessionID, + agent: currentAgent, + model: { + providerID: ctx.payload.providerID, + modelID: ctx.payload.modelID, + }, + auto: ctx.payload.auto ?? false, + }) + yield* promptSvc.loop({ sessionID: ctx.params.sessionID }) return true }) @@ -351,20 +255,15 @@ export const sessionHandlers = HttpApiBuilder.group(InstanceHttpApi, "session", payload: typeof PromptPayload.Type }) { const instance = yield* InstanceState.context + const workspace = yield* InstanceState.workspaceID return HttpServerResponse.stream( Stream.fromEffect( - Effect.promise(() => - Instance.restore(instance, () => - AppRuntime.runPromise( - SessionPrompt.Service.use((svc) => - svc.prompt({ - ...ctx.payload, - sessionID: ctx.params.sessionID, - } as unknown as SessionPrompt.PromptInput), - ).pipe(Effect.provide(SessionPrompt.defaultLayer)), - ), - ), - ), + promptSvc + .prompt({ + ...ctx.payload, + sessionID: ctx.params.sessionID, + }) + .pipe(Effect.provideService(InstanceRef, instance), Effect.provideService(WorkspaceRef, workspace)), ).pipe( Stream.map((message) => JSON.stringify(message)), Stream.encodeText, @@ -377,24 +276,18 @@ export const sessionHandlers = HttpApiBuilder.group(InstanceHttpApi, "session", params: { sessionID: SessionID } payload: typeof PromptPayload.Type }) { - const instance = yield* InstanceState.context - yield* Effect.sync(() => { - Instance.restore(instance, () => { - void AppRuntime.runPromise( - SessionPrompt.Service.use((svc) => - svc.prompt({ ...ctx.payload, sessionID: ctx.params.sessionID } as unknown as SessionPrompt.PromptInput), - ).pipe(Effect.provide(SessionPrompt.defaultLayer)), - ).catch((error) => { - log.error("prompt_async failed", { sessionID: ctx.params.sessionID, error }) - void Bus.publish(Session.Event.Error, { + yield* promptSvc.prompt({ ...ctx.payload, sessionID: ctx.params.sessionID }).pipe( + Effect.catchCause((cause) => + Effect.gen(function* () { + yield* Effect.logError("prompt_async failed", { sessionID: ctx.params.sessionID, cause }) + yield* bus.publish(Session.Event.Error, { sessionID: ctx.params.sessionID, - error: new NamedError.Unknown({ - message: error instanceof Error ? error.message : String(error), - }).toObject(), + error: new NamedError.Unknown({ message: Cause.pretty(cause) }).toObject(), }) - }) - }) - }) + }), + ), + Effect.forkIn(scope, { startImmediately: true }), + ) return HttpApiSchema.NoContent.make() }) @@ -402,111 +295,47 @@ export const sessionHandlers = HttpApiBuilder.group(InstanceHttpApi, "session", params: { sessionID: SessionID } payload: typeof CommandPayload.Type }) { - const instance = yield* InstanceState.context - return yield* Effect.promise(() => - Instance.restore(instance, () => - AppRuntime.runPromise( - SessionPrompt.Service.use((svc) => - svc.command({ ...ctx.payload, sessionID: ctx.params.sessionID } as SessionPrompt.CommandInput), - ).pipe(Effect.provide(SessionPrompt.defaultLayer)), - ), - ), - ) + return yield* promptSvc.command({ ...ctx.payload, sessionID: ctx.params.sessionID }) }) const shell = Effect.fn("SessionHttpApi.shell")(function* (ctx: { params: { sessionID: SessionID } payload: typeof ShellPayload.Type }) { - const instance = yield* InstanceState.context - return yield* Effect.promise(() => - Instance.restore(instance, () => - AppRuntime.runPromise( - SessionPrompt.Service.use((svc) => - svc.shell({ ...ctx.payload, sessionID: ctx.params.sessionID } as SessionPrompt.ShellInput), - ).pipe(Effect.provide(SessionPrompt.defaultLayer)), - ), - ), - ) + return yield* promptSvc.shell({ ...ctx.payload, sessionID: ctx.params.sessionID }) }) const revert = Effect.fn("SessionHttpApi.revert")(function* (ctx: { params: { sessionID: SessionID } payload: typeof RevertPayload.Type }) { - const instance = yield* InstanceState.context - log.info("revert", ctx.payload) - return yield* Effect.promise(() => - Instance.restore(instance, () => - AppRuntime.runPromise( - SessionRevert.Service.use((svc) => svc.revert({ sessionID: ctx.params.sessionID, ...ctx.payload })).pipe( - Effect.provide(SessionRevert.defaultLayer), - ), - ), - ), - ) + return yield* revertSvc.revert({ sessionID: ctx.params.sessionID, ...ctx.payload }) }) const unrevert = Effect.fn("SessionHttpApi.unrevert")(function* (ctx: { params: { sessionID: SessionID } }) { - const instance = yield* InstanceState.context - return yield* Effect.promise(() => - Instance.restore(instance, () => - AppRuntime.runPromise( - SessionRevert.Service.use((svc) => svc.unrevert({ sessionID: ctx.params.sessionID })).pipe( - Effect.provide(SessionRevert.defaultLayer), - ), - ), - ), - ) + return yield* revertSvc.unrevert({ sessionID: ctx.params.sessionID }) }) const permissionRespond = Effect.fn("SessionHttpApi.permissionRespond")(function* (ctx: { params: { permissionID: PermissionID } payload: typeof PermissionResponsePayload.Type }) { - const instance = yield* InstanceState.context - yield* Effect.promise(() => - Instance.restore(instance, () => - AppRuntime.runPromise( - Permission.Service.use((svc) => - svc.reply({ requestID: ctx.params.permissionID, reply: ctx.payload.response }), - ).pipe(Effect.provide(Permission.defaultLayer)), - ), - ), - ) + yield* permissionSvc.reply({ requestID: ctx.params.permissionID, reply: ctx.payload.response }) return true }) const deleteMessage = Effect.fn("SessionHttpApi.deleteMessage")(function* (ctx: { params: { sessionID: SessionID; messageID: MessageID } }) { - const instance = yield* InstanceState.context - yield* Effect.promise(() => - Instance.restore(instance, () => - AppRuntime.runPromise( - Effect.gen(function* () { - const state = yield* SessionRunState.Service - const session = yield* Session.Service - yield* state.assertNotBusy(ctx.params.sessionID) - yield* session.removeMessage(ctx.params) - }).pipe(Effect.provide(SessionRunState.defaultLayer), Effect.provide(Session.defaultLayer)), - ), - ), - ) + yield* runState.assertNotBusy(ctx.params.sessionID) + yield* session.removeMessage(ctx.params) return true }) const deletePart = Effect.fn("SessionHttpApi.deletePart")(function* (ctx: { params: { sessionID: SessionID; messageID: MessageID; partID: PartID } }) { - const instance = yield* InstanceState.context - yield* Effect.promise(() => - Instance.restore(instance, () => - AppRuntime.runPromise( - Session.Service.use((svc) => svc.removePart(ctx.params)).pipe(Effect.provide(Session.defaultLayer)), - ), - ), - ) + yield* session.removePart(ctx.params) return true }) @@ -524,14 +353,7 @@ export const sessionHandlers = HttpApiBuilder.group(InstanceHttpApi, "session", `Part mismatch: body.id='${payload.id}' vs partID='${ctx.params.partID}', body.messageID='${payload.messageID}' vs messageID='${ctx.params.messageID}', body.sessionID='${payload.sessionID}' vs sessionID='${ctx.params.sessionID}'`, ) } - const instance = yield* InstanceState.context - return yield* Effect.promise(() => - Instance.restore(instance, () => - AppRuntime.runPromise( - Session.Service.use((svc) => svc.updatePart(payload)).pipe(Effect.provide(Session.defaultLayer)), - ), - ), - ) + return yield* session.updatePart(payload) }) return handlers diff --git a/packages/opencode/src/server/routes/instance/httpapi/handlers/sync.ts b/packages/opencode/src/server/routes/instance/httpapi/handlers/sync.ts index 3ae091484f..f4a2f315cd 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/handlers/sync.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/handlers/sync.ts @@ -1,4 +1,4 @@ -import { startWorkspaceSyncing } from "@/control-plane/workspace" +import { Workspace } from "@/control-plane/workspace" import * as InstanceState from "@/effect/instance-state" import { Database } from "@/storage/db" import { SyncEvent } from "@/sync" @@ -9,15 +9,24 @@ import { eq } from "drizzle-orm" import { lte } from "drizzle-orm" import { not } from "drizzle-orm" import { or } from "drizzle-orm" -import { Effect } from "effect" +import { Effect, Scope } from "effect" import { HttpApiBuilder } from "effect/unstable/httpapi" import { InstanceHttpApi } from "../api" import { HistoryPayload, ReplayPayload } from "../groups/sync" +import * as Log from "@opencode-ai/core/util/log" + +const log = Log.create({ service: "server.sync" }) export const syncHandlers = HttpApiBuilder.group(InstanceHttpApi, "sync", (handlers) => Effect.gen(function* () { + const workspace = yield* Workspace.Service + const scope = yield* Scope.Scope + const sync = yield* SyncEvent.Service + const start = Effect.fn("SyncHttpApi.start")(function* () { - startWorkspaceSyncing((yield* InstanceState.context).project.id) + yield* workspace + .startWorkspaceSyncing((yield* InstanceState.context).project.id) + .pipe(Effect.ignore, Effect.forkIn(scope)) return true }) @@ -29,8 +38,22 @@ export const syncHandlers = HttpApiBuilder.group(InstanceHttpApi, "sync", (handl type: event.type, data: { ...event.data }, })) - SyncEvent.replayAll(events) - return { sessionID: events[0].aggregateID } + const source = events[0].aggregateID + log.info("sync replay requested", { + sessionID: source, + events: events.length, + first: events[0]?.seq, + last: events.at(-1)?.seq, + directory: ctx.payload.directory, + }) + yield* sync.replayAll(events) + log.info("sync replay complete", { + sessionID: source, + events: events.length, + first: events[0]?.seq, + last: events.at(-1)?.seq, + }) + return { sessionID: source } }) const history = Effect.fn("SyncHttpApi.history")(function* (ctx: { payload: typeof HistoryPayload.Type }) { diff --git a/packages/opencode/src/server/routes/instance/httpapi/handlers/tui.ts b/packages/opencode/src/server/routes/instance/httpapi/handlers/tui.ts index cb12ccb7a7..c7c447ce85 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/handlers/tui.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/handlers/tui.ts @@ -28,8 +28,8 @@ const commandAliases = { export const tuiHandlers = HttpApiBuilder.group(InstanceHttpApi, "tui", (handlers) => Effect.gen(function* () { const bus = yield* Bus.Service - const publishCommand = (command: typeof TuiEvent.CommandExecute.properties.Type.command) => - bus.publish(TuiEvent.CommandExecute, { command }) + const publishCommand = (command: typeof TuiEvent.CommandExecute.properties.Type.command | undefined) => + bus.publish(TuiEvent.CommandExecute, { command } as typeof TuiEvent.CommandExecute.properties.Type) const appendPrompt = Effect.fn("TuiHttpApi.appendPrompt")(function* (ctx: { payload: typeof TuiEvent.PromptAppend.properties.Type @@ -71,7 +71,8 @@ export const tuiHandlers = HttpApiBuilder.group(InstanceHttpApi, "tui", (handler const executeCommand = Effect.fn("TuiHttpApi.executeCommand")(function* (ctx: { payload: typeof CommandPayload.Type }) { - yield* publishCommand(commandAliases[ctx.payload.command as keyof typeof commandAliases] ?? ctx.payload.command) + // Legacy only publishes known aliases; unknown commands become undefined. + yield* publishCommand(commandAliases[ctx.payload.command as keyof typeof commandAliases]) return true }) diff --git a/packages/opencode/src/server/routes/instance/httpapi/handlers/workspace.ts b/packages/opencode/src/server/routes/instance/httpapi/handlers/workspace.ts index 9413c865d1..570f355e57 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/handlers/workspace.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/handlers/workspace.ts @@ -1,62 +1,58 @@ -import { listAdaptors } from "@/control-plane/adaptors" +import { listAdapters } from "@/control-plane/adapters" import { Workspace } from "@/control-plane/workspace" import * as InstanceState from "@/effect/instance-state" -import { Instance } from "@/project/instance" import { Effect } from "effect" -import { HttpApiBuilder } from "effect/unstable/httpapi" +import { HttpApiBuilder, HttpApiError } from "effect/unstable/httpapi" import { InstanceHttpApi } from "../api" import { CreatePayload, SessionRestorePayload } from "../groups/workspace" export const workspaceHandlers = HttpApiBuilder.group(InstanceHttpApi, "workspace", (handlers) => Effect.gen(function* () { - const adaptors = Effect.fn("WorkspaceHttpApi.adaptors")(function* () { + const workspace = yield* Workspace.Service + + const adapters = Effect.fn("WorkspaceHttpApi.adapters")(function* () { const instance = yield* InstanceState.context - return yield* Effect.promise(() => listAdaptors(instance.project.id)) + return yield* Effect.promise(() => listAdapters(instance.project.id)) }) const list = Effect.fn("WorkspaceHttpApi.list")(function* () { - return Workspace.list((yield* InstanceState.context).project) + return yield* workspace.list((yield* InstanceState.context).project) }) const create = Effect.fn("WorkspaceHttpApi.create")(function* (ctx: { payload: typeof CreatePayload.Type }) { const instance = yield* InstanceState.context - return yield* Effect.promise(() => - Instance.restore(instance, () => - Workspace.create({ - ...ctx.payload, - projectID: instance.project.id, - }), - ), - ) + return yield* workspace + .create({ + ...ctx.payload, + extra: ctx.payload.extra ?? null, + projectID: instance.project.id, + }) + .pipe(Effect.mapError(() => new HttpApiError.BadRequest({}))) }) const status = Effect.fn("WorkspaceHttpApi.status")(function* () { - const ids = new Set(Workspace.list((yield* InstanceState.context).project).map((item) => item.id)) - return Workspace.status().filter((item) => ids.has(item.workspaceID)) + const ids = new Set((yield* workspace.list((yield* InstanceState.context).project)).map((item) => item.id)) + return (yield* workspace.status()).filter((item) => ids.has(item.workspaceID)) }) const remove = Effect.fn("WorkspaceHttpApi.remove")(function* (ctx: { params: { id: Workspace.Info["id"] } }) { - const instance = yield* InstanceState.context - return yield* Effect.promise(() => Instance.restore(instance, () => Workspace.remove(ctx.params.id))) + return yield* workspace.remove(ctx.params.id) }) const sessionRestore = Effect.fn("WorkspaceHttpApi.sessionRestore")(function* (ctx: { params: { id: Workspace.Info["id"] } payload: typeof SessionRestorePayload.Type }) { - const instance = yield* InstanceState.context - return yield* Effect.promise(() => - Instance.restore(instance, () => - Workspace.sessionRestore({ - workspaceID: ctx.params.id, - sessionID: ctx.payload.sessionID, - }), - ), - ) + return yield* workspace + .sessionRestore({ + workspaceID: ctx.params.id, + sessionID: ctx.payload.sessionID, + }) + .pipe(Effect.mapError(() => new HttpApiError.BadRequest({}))) }) return handlers - .handle("adaptors", adaptors) + .handle("adapters", adapters) .handle("list", list) .handle("create", create) .handle("status", status) diff --git a/packages/opencode/src/server/routes/instance/httpapi/lifecycle.ts b/packages/opencode/src/server/routes/instance/httpapi/lifecycle.ts index c93261a0be..53d54e2a81 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/lifecycle.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/lifecycle.ts @@ -1,32 +1,52 @@ -import { Instance, type InstanceContext } from "@/project/instance" +import { EffectBridge } from "@/effect/bridge" +import type { InstanceContext } from "@/project/instance" +import { InstanceStore } from "@/project/instance-store" import { Effect } from "effect" import { HttpEffect, HttpMiddleware, HttpServerRequest } from "effect/unstable/http" -const disposeAfterResponse = new WeakMap() +type MarkedInstance = { + ctx: InstanceContext + store: InstanceStore.Interface + bridge: EffectBridge.Shape +} + +// Disposal is requested by an endpoint handler, but must run from the outer +// server middleware after the response has been produced. The original Request +// object is the stable handoff key between those two phases. +const disposeAfterResponse = new WeakMap() + +const mark = (ctx: InstanceContext) => + Effect.gen(function* () { + return { ctx, store: yield* InstanceStore.Service, bridge: yield* EffectBridge.make() } + }) export const markInstanceForDisposal = (ctx: InstanceContext) => - HttpEffect.appendPreResponseHandler((request, response) => - Effect.sync(() => { - disposeAfterResponse.set(request.source, ctx) - return response - }), - ) + Effect.gen(function* () { + const marked = yield* mark(ctx) + return yield* HttpEffect.appendPreResponseHandler((request, response) => + Effect.sync(() => { + // The response is sent before disposeMiddleware performs the teardown. + disposeAfterResponse.set(request.source, marked) + return response + }), + ) + }) -export const markInstanceForReload = (ctx: InstanceContext, next: Parameters[0]) => - HttpEffect.appendPreResponseHandler((_request, response) => - Effect.as( - Effect.uninterruptible(Effect.promise(() => Instance.restore(ctx, () => Instance.reload(next)))), - response, - ), - ) +export const markInstanceForReload = (ctx: InstanceContext, next: InstanceStore.LoadInput) => + Effect.gen(function* () { + const marked = yield* mark(ctx) + return yield* HttpEffect.appendPreResponseHandler((_request, response) => + Effect.as(Effect.uninterruptible(marked.bridge.run(marked.store.reload(next))), response), + ) + }) export const disposeMiddleware: HttpMiddleware.HttpMiddleware = (effect) => Effect.gen(function* () { const response = yield* effect const request = yield* HttpServerRequest.HttpServerRequest - const ctx = disposeAfterResponse.get(request.source) - if (!ctx) return response + const marked = disposeAfterResponse.get(request.source) + if (!marked) return response disposeAfterResponse.delete(request.source) - yield* Effect.uninterruptible(Effect.promise(() => Instance.restore(ctx, () => Instance.dispose()))) + yield* Effect.uninterruptible(marked.bridge.run(marked.store.dispose(marked.ctx))) return response }) diff --git a/packages/opencode/src/server/routes/instance/httpapi/middleware/authorization.ts b/packages/opencode/src/server/routes/instance/httpapi/middleware/authorization.ts index e68dc683ec..65109cc56d 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/middleware/authorization.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/middleware/authorization.ts @@ -1,49 +1,63 @@ -import { Effect, Encoding, Layer, Redacted, Schema } from "effect" -import { HttpApiMiddleware, HttpApiSecurity } from "effect/unstable/httpapi" -import { Flag } from "@opencode-ai/core/flag/flag" +import { ConfigService } from "@/effect/config-service" +import { Config, Context, Effect, Encoding, Layer, Option, Redacted } from "effect" +import { HttpRouter, HttpServerRequest, HttpServerResponse } from "effect/unstable/http" +import { HttpApiError, HttpApiMiddleware, HttpApiSecurity } from "effect/unstable/httpapi" -class Unauthorized extends Schema.TaggedErrorClass()( - "Unauthorized", - { message: Schema.String }, - { httpApiStatus: 401 }, -) {} +const AUTH_TOKEN_QUERY = "auth_token" +const UNAUTHORIZED = 401 export class Authorization extends HttpApiMiddleware.Service()( "@opencode/ExperimentalHttpApiAuthorization", { - error: Unauthorized, + error: HttpApiError.UnauthorizedNoContent, security: { basic: HttpApiSecurity.basic, - authToken: HttpApiSecurity.apiKey({ in: "query", key: "auth_token" }), + authToken: HttpApiSecurity.apiKey({ in: "query", key: AUTH_TOKEN_QUERY }), }, }, ) {} -const emptyCredential = { - username: "", - password: Redacted.make(""), -} +export class ServerAuthConfig extends ConfigService.Service()( + "@opencode/ExperimentalHttpApiServerAuthConfig", + { + password: Config.string("KILO_SERVER_PASSWORD").pipe(Config.option), + username: Config.string("KILO_SERVER_USERNAME").pipe(Config.withDefault("kilo")), // kilocode_change + }, +) {} function validateCredential( effect: Effect.Effect, - credential: { readonly username: string; readonly password: typeof emptyCredential.password }, + credential: { readonly username: string; readonly password: Redacted.Redacted }, + config: Context.Service.Shape, ) { return Effect.gen(function* () { - if (!Flag.KILO_SERVER_PASSWORD) return yield* effect - - // kilocode_change start - default to "kilo" to match Hono AuthMiddleware (middleware.ts:47) - if (credential.username !== (Flag.KILO_SERVER_USERNAME ?? "kilo")) { - return yield* new Unauthorized({ message: "Unauthorized" }) - } - // kilocode_change end - if (Redacted.value(credential.password) !== Flag.KILO_SERVER_PASSWORD) { - return yield* new Unauthorized({ message: "Unauthorized" }) - } + if (!isAuthRequired(config)) return yield* effect + if (!isCredentialAuthorized(credential, config)) return yield* new HttpApiError.Unauthorized({}) return yield* effect }) } +function isAuthRequired(config: Context.Service.Shape) { + return Option.isSome(config.password) && config.password.value !== "" +} + +function isCredentialAuthorized( + credential: { readonly username: string; readonly password: Redacted.Redacted }, + config: Context.Service.Shape, +) { + return ( + Option.isSome(config.password) && + credential.username === config.username && + Redacted.value(credential.password) === config.password.value + ) +} + function decodeCredential(input: string) { + const emptyCredential = { + username: "", + password: Redacted.make(""), + } + return Encoding.decodeBase64String(input) .asEffect() .pipe( @@ -61,13 +75,54 @@ function decodeCredential(input: string) { ) } -export const authorizationLayer = Layer.succeed( - Authorization, - Authorization.of({ - basic: (effect, { credential }) => validateCredential(effect, credential), - authToken: (effect, { credential }) => +function validateRawCredential( + effect: Effect.Effect, + credential: { readonly username: string; readonly password: Redacted.Redacted }, + config: Context.Service.Shape, +) { + if (!isAuthRequired(config)) return effect + if (!isCredentialAuthorized(credential, config)) + return Effect.succeed(HttpServerResponse.empty({ status: UNAUTHORIZED })) + return effect +} + +export const authorizationRouterMiddleware = HttpRouter.middleware()( + Effect.gen(function* () { + const config = yield* ServerAuthConfig + if (!isAuthRequired(config)) return (effect) => effect + + return (effect) => Effect.gen(function* () { - return yield* validateCredential(effect, yield* decodeCredential(Redacted.value(credential))) - }), + const request = yield* HttpServerRequest.HttpServerRequest + const match = /^Basic\s+(.+)$/i.exec(request.headers.authorization ?? "") + if (match) { + return yield* decodeCredential(match[1]).pipe( + Effect.flatMap((credential) => validateRawCredential(effect, credential, config)), + ) + } + + const token = new URL(request.url, "http://localhost").searchParams.get(AUTH_TOKEN_QUERY) + if (token) { + return yield* decodeCredential(token).pipe( + Effect.flatMap((credential) => validateRawCredential(effect, credential, config)), + ) + } + + return yield* validateRawCredential(effect, { username: "", password: Redacted.make("") }, config) + }) + }), +) + +export const authorizationLayer = Layer.effect( + Authorization, + Effect.gen(function* () { + const config = yield* ServerAuthConfig + return Authorization.of({ + basic: (effect, { credential }) => validateCredential(effect, credential, config), + authToken: (effect, { credential }) => + decodeCredential(Redacted.value(credential)).pipe( + Effect.flatMap((decoded) => validateCredential(effect, decoded, config)), + ), + }) }), ) diff --git a/packages/opencode/src/server/routes/instance/httpapi/middleware/instance-context.ts b/packages/opencode/src/server/routes/instance/httpapi/middleware/instance-context.ts index c80f1caeb6..0e82da31b3 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/middleware/instance-context.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/middleware/instance-context.ts @@ -1,9 +1,6 @@ -import { InstanceRef, WorkspaceRef } from "@/effect/instance-ref" -import { AppRuntime } from "@/effect/app-runtime" +import { WorkspaceRef } from "@/effect/instance-ref" import { InstanceBootstrap } from "@/project/bootstrap" -import { Instance } from "@/project/instance" -import type { InstanceContext } from "@/project/instance" -import { Filesystem } from "@/util/filesystem" +import { InstanceStore } from "@/project/instance-store" import { Effect, Layer } from "effect" import { HttpRouter, HttpServerResponse } from "effect/unstable/http" import { HttpApiMiddleware } from "effect/unstable/httpapi" @@ -24,32 +21,33 @@ function decode(input: string): string { } } -function makeInstanceContext(directory: string): Effect.Effect { - return Effect.promise(() => - Instance.provide({ - directory: Filesystem.resolve(decode(directory)), - init: () => AppRuntime.runPromise(InstanceBootstrap), - fn: () => Instance.current, - }), - ) -} - function provideInstanceContext( effect: Effect.Effect, + store: InstanceStore.Interface, + bootstrap: InstanceBootstrap.Interface, ): Effect.Effect { return Effect.gen(function* () { const route = yield* WorkspaceRouteContext - const ctx = yield* makeInstanceContext(route.directory) - return yield* effect.pipe( - Effect.provideService(InstanceRef, ctx), - Effect.provideService(WorkspaceRef, route.workspaceID), + return yield* store.provide( + { directory: decode(route.directory), init: bootstrap.run }, + effect.pipe(Effect.provideService(WorkspaceRef, route.workspaceID)), ) }) } -export const instanceContextLayer = Layer.succeed( +export const instanceContextLayer = Layer.effect( InstanceContextMiddleware, - InstanceContextMiddleware.of((effect) => provideInstanceContext(effect)), + Effect.gen(function* () { + const store = yield* InstanceStore.Service + const bootstrap = yield* InstanceBootstrap.Service + return InstanceContextMiddleware.of((effect) => provideInstanceContext(effect, store, bootstrap)) + }), ) -export const instanceRouterMiddleware = HttpRouter.middleware()((effect) => provideInstanceContext(effect)) +export const instanceRouterMiddleware = HttpRouter.middleware()( + Effect.gen(function* () { + const store = yield* InstanceStore.Service + const bootstrap = yield* InstanceBootstrap.Service + return (effect) => provideInstanceContext(effect, store, bootstrap) + }), +) diff --git a/packages/opencode/src/server/routes/instance/httpapi/middleware/proxy.ts b/packages/opencode/src/server/routes/instance/httpapi/middleware/proxy.ts index 549dac40cc..e354dccbfa 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/middleware/proxy.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/middleware/proxy.ts @@ -1,13 +1,6 @@ import { ProxyUtil } from "@/server/proxy-util" import { Effect, Stream } from "effect" -import { - FetchHttpClient, - HttpBody, - HttpClient, - HttpClientRequest, - HttpServerRequest, - HttpServerResponse, -} from "effect/unstable/http" +import { HttpBody, HttpClient, HttpClientRequest, HttpServerRequest, HttpServerResponse } from "effect/unstable/http" import * as Socket from "effect/unstable/socket/Socket" function webSource(request: HttpServerRequest.HttpServerRequest): Request | undefined { @@ -66,12 +59,13 @@ function statusText(response: unknown) { } export function http( + client: HttpClient.HttpClient, url: string | URL, extra: HeadersInit | undefined, request: HttpServerRequest.HttpServerRequest, ): Effect.Effect { return Effect.gen(function* () { - const response = yield* HttpClient.execute( + const response = yield* client.execute( HttpClientRequest.make(request.method as never)(url, { headers: ProxyUtil.headers(request.headers as HeadersInit, extra), body: requestBody(request), @@ -86,10 +80,7 @@ export function http( statusText: statusText(response), headers, }) - }).pipe( - Effect.provide(FetchHttpClient.layer), - Effect.catch(() => Effect.succeed(HttpServerResponse.empty({ status: 500 }))), - ) + }).pipe(Effect.catch(() => Effect.succeed(HttpServerResponse.empty({ status: 500 })))) } export * as HttpApiProxy from "./proxy" diff --git a/packages/opencode/src/server/routes/instance/httpapi/middleware/workspace-routing.ts b/packages/opencode/src/server/routes/instance/httpapi/middleware/workspace-routing.ts index c06572bd68..5d3932a942 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/middleware/workspace-routing.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/middleware/workspace-routing.ts @@ -1,15 +1,15 @@ -import { getAdaptor } from "@/control-plane/adaptors" +import { getAdapter } from "@/control-plane/adapters" import { WorkspaceID } from "@/control-plane/schema" import type { Target } from "@/control-plane/types" import { Workspace } from "@/control-plane/workspace" -import { Instance } from "@/project/instance" +import { EffectBridge } from "@/effect/bridge" import { Session } from "@/session/session" import { HttpApiProxy } from "./proxy" import * as Fence from "@/server/fence" import { getWorkspaceRouteSessionID, isLocalWorkspaceRoute, workspaceProxyURL } from "@/server/workspace" import { Flag } from "@opencode-ai/core/flag/flag" import { Context, Data, Effect, Layer } from "effect" -import { HttpRouter, HttpServerRequest, HttpServerResponse } from "effect/unstable/http" +import { HttpClient, HttpRouter, HttpServerRequest, HttpServerResponse } from "effect/unstable/http" import { HttpApiMiddleware } from "effect/unstable/httpapi" import * as Socket from "effect/unstable/socket/Socket" @@ -43,14 +43,6 @@ export class WorkspaceRoutingMiddleware extends HttpApiMiddleware.Service< } >()("@opencode/ExperimentalHttpApiWorkspaceRouting") {} -function currentDirectory(): string { - try { - return Instance.directory - } catch { - return process.cwd() - } -} - function requestURL(request: HttpServerRequest.HttpServerRequest): URL { return new URL(request.url, "http://localhost") } @@ -65,7 +57,7 @@ function selectedWorkspaceID(url: URL, sessionWorkspaceID?: WorkspaceID): Worksp } function defaultDirectory(request: HttpServerRequest.HttpServerRequest, url: URL): string { - return url.searchParams.get("directory") || request.headers["x-kilo-directory"] || currentDirectory() + return url.searchParams.get("directory") || request.headers["x-kilo-directory"] || process.cwd() } function shouldStayOnControlPlane(request: HttpServerRequest.HttpServerRequest, url: URL): boolean { @@ -75,9 +67,9 @@ function shouldStayOnControlPlane(request: HttpServerRequest.HttpServerRequest, function resolveWorkspace( id: WorkspaceID | undefined, envWorkspaceID: WorkspaceID | undefined, -): Effect.Effect { +): Effect.Effect { if (!id || envWorkspaceID) return Effect.void - return Effect.promise(() => Workspace.get(id)) + return Workspace.Service.use((workspace) => workspace.get(id)) } function missingWorkspaceResponse(id: WorkspaceID): HttpServerResponse.HttpServerResponse { @@ -88,20 +80,19 @@ function missingWorkspaceResponse(id: WorkspaceID): HttpServerResponse.HttpServe } function resolveTarget(workspace: Workspace.Info): Effect.Effect { - return Effect.gen(function* () { - const adaptor = yield* Effect.promise(() => getAdaptor(workspace.projectID, workspace.type)) - return yield* Effect.promise(() => Promise.resolve(adaptor.target(workspace))) - }) + const adapter = getAdapter(workspace.projectID, workspace.type) + return EffectBridge.fromPromise(() => adapter.target(workspace)) } function proxyRemote( + client: HttpClient.HttpClient, request: HttpServerRequest.HttpServerRequest, workspace: Workspace.Info, target: RemoteTarget, url: URL, -): Effect.Effect { +): Effect.Effect { return Effect.gen(function* () { - const syncing = yield* Effect.promise(() => Workspace.isSyncing(workspace.id)) + const syncing = yield* Workspace.Service.use((svc) => svc.isSyncing(workspace.id)) if (!syncing) { return HttpServerResponse.text(`broken sync connection for workspace: ${workspace.id}`, { status: 503, @@ -111,12 +102,19 @@ function proxyRemote( const proxyURL = workspaceProxyURL(target.url, url) const headers = request.headers as Record if (headers["upgrade"]?.toLowerCase() === "websocket") return yield* HttpApiProxy.websocket(request, proxyURL) - const response = yield* HttpApiProxy.http(proxyURL, target.headers, request) + const response = yield* HttpApiProxy.http(client, proxyURL, target.headers, request) const sync = Fence.parse(new Headers(response.headers)) - if (sync) - yield* Effect.promise(() => - Fence.wait(workspace.id, sync, request.source instanceof Request ? request.source.signal : undefined), + if (sync) { + const syncFailure = yield* Fence.waitEffect( + workspace.id, + sync, + request.source instanceof Request ? request.source.signal : undefined, + ).pipe( + Effect.as(undefined), + Effect.catch((error) => Effect.succeed(HttpServerResponse.text(error.message, { status: 503 }))), ) + if (syncFailure) return syncFailure + } return response }) } @@ -125,7 +123,7 @@ function planWorkspaceRequest( request: HttpServerRequest.HttpServerRequest, url: URL, workspace: Workspace.Info, -): Effect.Effect { +): Effect.Effect { return Effect.gen(function* () { const target = yield* resolveTarget(workspace) if (target.type === "remote") return RequestPlan.Remote({ request, workspace, target, url }) @@ -136,7 +134,7 @@ function planWorkspaceRequest( function planRequest( request: HttpServerRequest.HttpServerRequest, sessionWorkspaceID?: WorkspaceID, -): Effect.Effect { +): Effect.Effect { return Effect.gen(function* () { const url = requestURL(request) const envWorkspaceID = configuredWorkspaceID() @@ -156,31 +154,25 @@ function planRequest( } function routeWorkspace( + client: HttpClient.HttpClient, effect: Effect.Effect, plan: RequestPlan, -): Effect.Effect { +): Effect.Effect { return RequestPlan.$match(plan, { MissingWorkspace: ({ workspaceID }) => Effect.succeed(missingWorkspaceResponse(workspaceID)), - Remote: ({ request, workspace, target, url }) => proxyRemote(request, workspace, target, url), + Remote: ({ request, workspace, target, url }) => proxyRemote(client, request, workspace, target, url), Local: ({ directory, workspaceID }) => effect.pipe(Effect.provideService(WorkspaceRouteContext, WorkspaceRouteContext.of({ directory, workspaceID }))), }) } -function routeWorkspaceRequest( - effect: Effect.Effect, - request: HttpServerRequest.HttpServerRequest, - sessionWorkspaceID?: WorkspaceID, -): Effect.Effect { - return Effect.flatMap(planRequest(request, sessionWorkspaceID), (plan) => routeWorkspace(effect, plan)) -} - function routeHttpApiWorkspace( + client: HttpClient.HttpClient, effect: Effect.Effect, ): Effect.Effect< HttpServerResponse.HttpServerResponse, E, - Session.Service | HttpServerRequest.HttpServerRequest | Socket.WebSocketConstructor + Session.Service | Workspace.Service | HttpServerRequest.HttpServerRequest | Socket.WebSocketConstructor > { return Effect.gen(function* () { const request = yield* HttpServerRequest.HttpServerRequest @@ -188,7 +180,8 @@ function routeHttpApiWorkspace( const session = sessionID ? yield* Session.Service.use((svc) => svc.get(sessionID)).pipe(Effect.catchDefect(() => Effect.void)) : undefined - return yield* routeWorkspaceRequest(effect, request, session?.workspaceID) + const plan = yield* planRequest(request, session?.workspaceID) + return yield* routeWorkspace(client, effect, plan) }) } @@ -196,8 +189,13 @@ export const workspaceRoutingLayer = Layer.effect( WorkspaceRoutingMiddleware, Effect.gen(function* () { const makeWebSocket = yield* Socket.WebSocketConstructor + const workspace = yield* Workspace.Service + const client = yield* HttpClient.HttpClient return WorkspaceRoutingMiddleware.of((effect) => - routeHttpApiWorkspace(effect).pipe(Effect.provideService(Socket.WebSocketConstructor, makeWebSocket)), + routeHttpApiWorkspace(client, effect).pipe( + Effect.provideService(Socket.WebSocketConstructor, makeWebSocket), + Effect.provideService(Workspace.Service, workspace), + ), ) }), ) @@ -205,12 +203,16 @@ export const workspaceRoutingLayer = Layer.effect( export const workspaceRouterMiddleware = HttpRouter.middleware<{ provides: WorkspaceRouteContext }>()( Effect.gen(function* () { const makeWebSocket = yield* Socket.WebSocketConstructor + const workspace = yield* Workspace.Service + const client = yield* HttpClient.HttpClient return (effect) => Effect.gen(function* () { const request = yield* HttpServerRequest.HttpServerRequest - return yield* routeWorkspaceRequest(effect, request).pipe( - Effect.provideService(Socket.WebSocketConstructor, makeWebSocket), - ) - }) + const plan = yield* planRequest(request) + return yield* routeWorkspace(client, effect, plan) + }).pipe( + Effect.provideService(Socket.WebSocketConstructor, makeWebSocket), + Effect.provideService(Workspace.Service, workspace), + ) }), ) diff --git a/packages/opencode/src/server/routes/instance/httpapi/public.ts b/packages/opencode/src/server/routes/instance/httpapi/public.ts index 17d6e0d063..c9668336ae 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/public.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/public.ts @@ -39,6 +39,7 @@ type OpenApiSchema = { maximum?: number minimum?: number oneOf?: OpenApiSchema[] + pattern?: string prefixItems?: OpenApiSchema[] properties?: Record required?: string[] @@ -74,9 +75,18 @@ const QueryNumberParameters = new Set(["start", "cursor", "limit", "method"]) const QueryBooleanParameters = new Set(["roots", "archived"]) const QueryParameterSchemas = { "GET /find/file limit": { type: "integer", minimum: 1, maximum: 200 }, + "GET /session/{sessionID}/diff messageID": { type: "string", pattern: "^msg.*" }, "GET /session/{sessionID}/message limit": { type: "integer", minimum: 0, maximum: Number.MAX_SAFE_INTEGER }, } satisfies Record +const PathParameterSchemas = { + sessionID: { type: "string", pattern: "^ses.*" }, + messageID: { type: "string", pattern: "^msg.*" }, + partID: { type: "string", pattern: "^prt.*" }, + permissionID: { type: "string", pattern: "^per.*" }, + ptyID: { type: "string", pattern: "^pty.*" }, +} satisfies Record + const LegacyComponentDescriptions = { LogLevel: "Log level", ServerConfig: "Server configuration for opencode serve and web commands", @@ -428,6 +438,11 @@ function fixSelfReferencingComponents(spec: OpenApiSpec) { /** Strip `{type:"null"}` arms that Effect's `Schema.optional` adds to OpenAPI unions. */ function stripOptionalNull(schema: OpenApiSchema): OpenApiSchema { + if (schema.allOf?.length === 1) { + const [constraint] = schema.allOf + delete schema.allOf + return stripOptionalNull({ ...schema, ...constraint }) + } if (isEmptyObjectUnion(schema)) return { type: "object", properties: {} } const options = flattenOptions(schema.anyOf ?? schema.oneOf) if (options) { @@ -476,25 +491,40 @@ function flattenOptions(options: OpenApiSchema[] | undefined): OpenApiSchema[] | } function normalizeParameter(param: OpenApiParameter, route: string) { - if (param.in !== "query" || !param.schema || typeof param.schema !== "object") return - const override = QueryParameterSchemas[`${route} ${param.name}` as keyof typeof QueryParameterSchemas] - if (override) { - param.schema = override + if (!param.schema || typeof param.schema !== "object") return + if (param.in === "path") { + param.schema = pathParameterSchema(route, param.name) ?? stripOptionalNull(param.schema) return } - if (QueryNumberParameters.has(param.name)) { - param.schema = { type: "number" } - return - } - if (QueryBooleanParameters.has(param.name)) { - param.schema = { - anyOf: [{ type: "boolean" }, { type: "string", enum: ["true", "false"] }], + if (param.in === "query") { + const override = QueryParameterSchemas[`${route} ${param.name}` as keyof typeof QueryParameterSchemas] + if (override) { + param.schema = override + return + } + if (QueryNumberParameters.has(param.name)) { + param.schema = { type: "number" } + return + } + if (QueryBooleanParameters.has(param.name)) { + param.schema = { + anyOf: [{ type: "boolean" }, { type: "string", enum: ["true", "false"] }], + } + return } - return } param.schema = stripOptionalNull(param.schema) } +function pathParameterSchema(route: string, name: string) { + if (name in PathParameterSchemas) return PathParameterSchemas[name as keyof typeof PathParameterSchemas] + if (name === "id" && route.startsWith("DELETE /experimental/workspace/")) return { type: "string", pattern: "^wrk.*" } + if (name === "id" && route.startsWith("POST /experimental/workspace/")) return { type: "string", pattern: "^wrk.*" } + if (name === "requestID" && route.startsWith("POST /permission/")) return { type: "string", pattern: "^per.*" } + if (name === "requestID" && route.startsWith("POST /question/")) return { type: "string", pattern: "^que.*" } + return undefined +} + export const PublicApi = OpenCodeHttpApi.annotateMerge( OpenApi.annotations({ title: "opencode", diff --git a/packages/opencode/src/server/routes/instance/httpapi/server.ts b/packages/opencode/src/server/routes/instance/httpapi/server.ts index c0fb5a20a0..767bfc31db 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/server.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/server.ts @@ -1,7 +1,8 @@ import { Context, Effect, Layer } from "effect" import { HttpApiBuilder } from "effect/unstable/httpapi" -import { HttpRouter, HttpServer } from "effect/unstable/http" +import { FetchHttpClient, HttpClient, HttpMiddleware, HttpRouter, HttpServer } from "effect/unstable/http" import * as Socket from "effect/unstable/socket/Socket" +import { AppFileSystem } from "@opencode-ai/core/filesystem" import { Account } from "@/account/account" import { Agent } from "@/agent/agent" import { Auth } from "@/auth" @@ -10,30 +11,45 @@ import { Config } from "@/config/config" import { Command } from "@/command" import * as Observability from "@opencode-ai/core/effect/observability" import { File } from "@/file" +import { FileWatcher } from "@/file/watcher" import { Ripgrep } from "@/file/ripgrep" import { Format } from "@/format" import { LSP } from "@/lsp/lsp" import { MCP } from "@/mcp" import { Permission } from "@/permission" import { Installation } from "@/installation" +import { InstanceBootstrap } from "@/project/bootstrap" +import { InstanceStore } from "@/project/instance-store" +import { Plugin } from "@/plugin" import { Project } from "@/project/project" import { ProviderAuth } from "@/provider/auth" +import { ModelsDev } from "@/provider/models" import { Provider } from "@/provider/provider" import { Pty } from "@/pty" import { Question } from "@/question" import { Session } from "@/session/session" +import { SessionCompaction } from "@/session/compaction" +import { SessionPrompt } from "@/session/prompt" +import { SessionRevert } from "@/session/revert" import { SessionRunState } from "@/session/run-state" import { SessionStatus } from "@/session/status" import { SessionSummary } from "@/session/summary" import { Todo } from "@/session/todo" +import { SessionShare } from "@/share/session" +import { ShareNext } from "@/share/share-next" import { Skill } from "@/skill" +import { Snapshot } from "@/snapshot" +import { SyncEvent } from "@/sync" import { ToolRegistry } from "@/tool/registry" import { lazy } from "@/util/lazy" import { Vcs } from "@/project/vcs" import { Worktree } from "@/worktree" +import { Workspace } from "@/control-plane/workspace" +import { isAllowedCorsOrigin, type CorsOptions } from "@/server/cors" +import { serveUIEffect } from "@/server/routes/ui" import { InstanceHttpApi, RootHttpApi } from "./api" -import { authorizationLayer } from "./middleware/authorization" -import { eventRoute } from "./event" +import { ServerAuthConfig, authorizationLayer, authorizationRouterMiddleware } from "./middleware/authorization" +import { EventApi, eventHandlers } from "./event" import { configHandlers } from "./handlers/config" import { controlHandlers } from "./handlers/control" import { experimentalHandlers } from "./handlers/experimental" @@ -56,7 +72,7 @@ import { disposeMiddleware } from "./lifecycle" import { memoMap } from "@opencode-ai/core/effect/memo-map" import * as ServerBackend from "@/server/backend" -export const context = Context.empty() as Context.Context +export const context = Context.makeUnsafe(new Map()) const runtime = HttpRouter.middleware()( Effect.succeed((effect) => @@ -68,7 +84,24 @@ const runtime = HttpRouter.middleware()( ), ).layer +const cors = (corsOptions?: CorsOptions) => + HttpRouter.middleware( + HttpMiddleware.cors({ + allowedOrigins: (origin) => isAllowedCorsOrigin(origin, corsOptions), + maxAge: 86_400, + }), + { global: true }, + ) + const rootApiRoutes = HttpApiBuilder.layer(RootHttpApi).pipe(Layer.provide([controlHandlers, globalHandlers])) +const instanceRouterLayer = authorizationRouterMiddleware + .combine(instanceRouterMiddleware) + .combine(workspaceRouterMiddleware) + .layer.pipe(Layer.provide(Socket.layerWebSocketConstructorGlobal), Layer.provide(ServerAuthConfig.defaultLayer)) +const eventApiRoutes = HttpApiBuilder.layer(EventApi).pipe( + Layer.provide(eventHandlers), + Layer.provide(instanceRouterLayer), +) const instanceApiRoutes = HttpApiBuilder.layer(InstanceHttpApi).pipe( Layer.provide([ configHandlers, @@ -88,61 +121,92 @@ const instanceApiRoutes = HttpApiBuilder.layer(InstanceHttpApi).pipe( ]), ) -const rawInstanceRoutes = Layer.mergeAll(eventRoute, ptyConnectRoute).pipe( - Layer.provide( - instanceRouterMiddleware - .combine(workspaceRouterMiddleware) - .layer.pipe(Layer.provide(Socket.layerWebSocketConstructorGlobal)), - ), -) +const rawInstanceRoutes = Layer.mergeAll(ptyConnectRoute).pipe(Layer.provide(instanceRouterLayer)) const instanceRoutes = Layer.mergeAll(rawInstanceRoutes, instanceApiRoutes).pipe( Layer.provide([ - authorizationLayer, + authorizationLayer.pipe(Layer.provide(ServerAuthConfig.defaultLayer)), workspaceRoutingLayer.pipe(Layer.provide(Socket.layerWebSocketConstructorGlobal)), instanceContextLayer, ]), ) -export const routes = Layer.mergeAll(rootApiRoutes, instanceRoutes).pipe( - Layer.provide([ - runtime, - Account.defaultLayer, - Agent.defaultLayer, - Auth.defaultLayer, - Command.defaultLayer, - Config.defaultLayer, - File.defaultLayer, - Format.defaultLayer, - LSP.defaultLayer, - Installation.defaultLayer, - MCP.defaultLayer, - Permission.defaultLayer, - Project.defaultLayer, - ProviderAuth.defaultLayer, - Provider.defaultLayer, - Pty.defaultLayer, - Question.defaultLayer, - Ripgrep.defaultLayer, - Session.defaultLayer, - SessionRunState.defaultLayer, - SessionStatus.defaultLayer, - SessionSummary.defaultLayer, - Skill.defaultLayer, - Todo.defaultLayer, - ToolRegistry.defaultLayer, - Vcs.defaultLayer, - Worktree.defaultLayer, - Bus.layer, - HttpServer.layerServices, - ]), - Layer.provideMerge(Observability.layer), -) +const uiRoute = HttpRouter.use((router) => + Effect.gen(function* () { + const fs = yield* AppFileSystem.Service + const client = yield* HttpClient.HttpClient + yield* router.add("*", "/*", (request) => serveUIEffect(request, { fs, client })) + }), +).pipe(Layer.provide(authorizationRouterMiddleware.layer.pipe(Layer.provide(ServerAuthConfig.defaultLayer)))) -export const webHandler = lazy(() => +export function createRoutes(corsOptions?: CorsOptions) { + return Layer.mergeAll(rootApiRoutes, eventApiRoutes, instanceRoutes, uiRoute).pipe( + Layer.provide([ + cors(corsOptions), + runtime, + Account.defaultLayer, + Agent.defaultLayer, + Auth.defaultLayer, + Command.defaultLayer, + Config.defaultLayer, + File.defaultLayer, + FileWatcher.defaultLayer, + Format.defaultLayer, + LSP.defaultLayer, + Installation.defaultLayer, + InstanceBootstrap.defaultLayer, + InstanceStore.defaultLayer, + MCP.defaultLayer, + ModelsDev.defaultLayer, + Permission.defaultLayer, + Plugin.defaultLayer, + Project.defaultLayer, + ProviderAuth.defaultLayer, + Provider.defaultLayer, + Pty.defaultLayer, + Question.defaultLayer, + Ripgrep.defaultLayer, + Session.defaultLayer, + SessionCompaction.defaultLayer, + SessionPrompt.defaultLayer, + SessionRevert.defaultLayer, + SessionShare.defaultLayer, + SessionRunState.defaultLayer, + SessionStatus.defaultLayer, + SessionSummary.defaultLayer, + ShareNext.defaultLayer, + Snapshot.defaultLayer, + SyncEvent.defaultLayer, + Skill.defaultLayer, + Todo.defaultLayer, + ToolRegistry.defaultLayer, + Vcs.defaultLayer, + Workspace.defaultLayer, + Worktree.defaultLayer, + Bus.layer, + AppFileSystem.defaultLayer, + FetchHttpClient.layer, + HttpServer.layerServices, + ]), + Layer.provideMerge(Observability.layer), + ) +} + +export const routes = createRoutes() + +const defaultWebHandler = lazy(() => HttpRouter.toWebHandler(routes, { memoMap, middleware: disposeMiddleware, }), ) +export function webHandler(corsOptions?: CorsOptions) { + if (!corsOptions?.cors?.length) return defaultWebHandler() + return HttpRouter.toWebHandler(createRoutes(corsOptions), { + // Server-level CORS options are dynamic; don't reuse the default route layer memoized without them. + memoMap: Layer.makeMemoMapUnsafe(), + middleware: disposeMiddleware, + }) +} + export * as ExperimentalHttpApiServer from "./server" diff --git a/packages/opencode/src/server/routes/instance/index.ts b/packages/opencode/src/server/routes/instance/index.ts index 962f9c1ad5..f754aa95a3 100644 --- a/packages/opencode/src/server/routes/instance/index.ts +++ b/packages/opencode/src/server/routes/instance/index.ts @@ -6,6 +6,7 @@ import z from "zod" import { Format } from "@/format" import { TuiRoutes } from "./tui" import { Instance } from "@/project/instance" +import { InstanceStore } from "@/project/instance-store" import { Vcs } from "@/project/vcs" import { Agent } from "@/agent/agent" import { Skill } from "@/skill" @@ -64,7 +65,7 @@ export const InstanceRoutes = (upgrade: UpgradeWebSocket): Hono => { }, }), async (c) => { - await Instance.dispose() + await InstanceStore.disposeInstance(Instance.current) return c.json(true) }, ) diff --git a/packages/opencode/src/server/routes/instance/middleware.ts b/packages/opencode/src/server/routes/instance/middleware.ts index 6bcbcc6ffd..f8a330fea2 100644 --- a/packages/opencode/src/server/routes/instance/middleware.ts +++ b/packages/opencode/src/server/routes/instance/middleware.ts @@ -1,7 +1,6 @@ import type { MiddlewareHandler } from "hono" import { Instance } from "@/project/instance" -import { InstanceBootstrap } from "@/project/bootstrap" -import { AppRuntime } from "@/effect/app-runtime" +import { getBootstrapRunEffect } from "@/effect/app-runtime" import { AppFileSystem } from "@opencode-ai/core/filesystem" import { WorkspaceContext } from "@/control-plane/workspace-context" import { WorkspaceID } from "@/control-plane/schema" @@ -24,7 +23,7 @@ export function InstanceMiddleware(workspaceID?: WorkspaceID): MiddlewareHandler async fn() { return Instance.provide({ directory, - init: () => AppRuntime.runPromise(InstanceBootstrap), + init: await getBootstrapRunEffect(), async fn() { return next() }, diff --git a/packages/opencode/src/server/routes/instance/project.ts b/packages/opencode/src/server/routes/instance/project.ts index b9f86b1839..dbca75c195 100644 --- a/packages/opencode/src/server/routes/instance/project.ts +++ b/packages/opencode/src/server/routes/instance/project.ts @@ -2,13 +2,13 @@ import { Hono } from "hono" import { describeRoute, validator } from "hono-openapi" import { resolver } from "hono-openapi" import { Instance } from "@/project/instance" +import { InstanceStore } from "@/project/instance-store" import { Project } from "@/project/project" import z from "zod" import { ProjectID } from "@/project/schema" import { errors } from "../../error" import { lazy } from "@/util/lazy" -import { InstanceBootstrap } from "@/project/bootstrap" -import { AppRuntime } from "@/effect/app-runtime" +import { getBootstrapRunEffect } from "@/effect/app-runtime" import { jsonRequest, runRequest } from "./trace" export const ProjectRoutes = lazy(() => @@ -82,12 +82,7 @@ export const ProjectRoutes = lazy(() => Project.Service.use((svc) => svc.initGit({ directory: dir, project: prev })), ) if (next.id === prev.id && next.vcs === prev.vcs && next.worktree === prev.worktree) return c.json(next) - await Instance.reload({ - directory: dir, - worktree: dir, - project: next, - init: () => AppRuntime.runPromise(InstanceBootstrap), - }) + await InstanceStore.reloadInstance({ directory: dir, worktree: dir, project: next, init: await getBootstrapRunEffect() }) return c.json(next) }, ) diff --git a/packages/opencode/src/server/routes/instance/provider.ts b/packages/opencode/src/server/routes/instance/provider.ts index 851b9078d7..7ecbc8c84d 100644 --- a/packages/opencode/src/server/routes/instance/provider.ts +++ b/packages/opencode/src/server/routes/instance/provider.ts @@ -36,7 +36,7 @@ export const ProviderRoutes = lazy(() => const svc = yield* Provider.Service const cfg = yield* Config.Service const config = yield* cfg.get() - const all = yield* Effect.promise(() => ModelsDev.get()) + const all = yield* ModelsDev.Service.use((s) => s.get()) const disabled = new Set(config.disabled_providers ?? []) const enabled = config.enabled_providers ? new Set(config.enabled_providers) : undefined const filtered: Record = {} diff --git a/packages/opencode/src/server/routes/instance/session.ts b/packages/opencode/src/server/routes/instance/session.ts index f382a305c0..84138b4226 100644 --- a/packages/opencode/src/server/routes/instance/session.ts +++ b/packages/opencode/src/server/routes/instance/session.ts @@ -78,18 +78,22 @@ export const SessionRoutes = lazy(() => ), async (c) => { const query = c.req.valid("query") - const sessions: Session.Info[] = [] - for await (const session of Session.list({ - directory: query.scope === "project" ? undefined : query.directory, - path: query.path, - roots: queryBoolean(query.roots), - start: query.start, - search: query.search, - limit: query.limit, - })) { - sessions.push(session) - } - return c.json(sessions) + return c.json( + await runRequest( + "SessionRoutes.list", + c, + Session.Service.use((svc) => + svc.list({ + directory: query.scope === "project" ? undefined : query.directory, + path: query.path, + roots: queryBoolean(query.roots), + start: query.start, + search: query.search, + limit: query.limit, + }), + ), + ), + ) }, ) .get( diff --git a/packages/opencode/src/server/routes/instance/sync.ts b/packages/opencode/src/server/routes/instance/sync.ts index b480477774..b7bf413d4e 100644 --- a/packages/opencode/src/server/routes/instance/sync.ts +++ b/packages/opencode/src/server/routes/instance/sync.ts @@ -12,7 +12,8 @@ import { eq } from "drizzle-orm" import { EventTable } from "@/sync/event.sql" import { lazy } from "@/util/lazy" import * as Log from "@opencode-ai/core/util/log" -import { startWorkspaceSyncing } from "@/control-plane/workspace" +import { Workspace } from "@/control-plane/workspace" +import { AppRuntime } from "@/effect/app-runtime" import { Instance } from "@/project/instance" import { errors } from "../../error" @@ -46,7 +47,9 @@ export const SyncRoutes = lazy(() => }, }), async (c) => { - startWorkspaceSyncing(Instance.project.id) + void AppRuntime.runPromise( + Workspace.Service.use((workspace) => workspace.startWorkspaceSyncing(Instance.project.id)), + ) return c.json(true) }, ) @@ -91,7 +94,7 @@ export const SyncRoutes = lazy(() => last: events.at(-1)?.seq, directory: body.directory, }) - SyncEvent.replayAll(events) + await AppRuntime.runPromise(SyncEvent.use.replayAll(events)) log.info("sync replay complete", { sessionID: source, diff --git a/packages/opencode/src/server/routes/ui.ts b/packages/opencode/src/server/routes/ui.ts index e1e3fac0bb..c89aa36814 100644 --- a/packages/opencode/src/server/routes/ui.ts +++ b/packages/opencode/src/server/routes/ui.ts @@ -1,9 +1,13 @@ import { Flag } from "@opencode-ai/core/flag/flag" +import { AppFileSystem } from "@opencode-ai/core/filesystem" +import { Effect, Stream } from "effect" +import { HttpBody, HttpClient, HttpClientRequest, HttpServerRequest, HttpServerResponse } from "effect/unstable/http" import { Hono } from "hono" // import { proxy } from "hono/proxy" // kilocode_change - proxy import removed import { getMimeType } from "hono/utils/mime" // import { createHash } from "node:crypto" // kilocode_change import fs from "node:fs/promises" +import { ProxyUtil } from "../proxy-util" const embeddedUIPromise = Flag.KILO_DISABLE_EMBEDDED_WEB_UI ? Promise.resolve(null) @@ -12,34 +16,113 @@ const embeddedUIPromise = Flag.KILO_DISABLE_EMBEDDED_WEB_UI const DEFAULT_CSP = "default-src 'self'; script-src 'self' 'wasm-unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; font-src 'self' data:; media-src 'self' data:; connect-src 'self' data:" +const UI_UPSTREAM = new URL("https://app.opencode.ai") // kilocode_change start - csp function removed, used by proxy fallback to app.opencode.ai // const csp = (hash = "") => // `default-src 'self'; script-src 'self' 'wasm-unsafe-eval'${hash ? ` 'sha256-${hash}'` : ""}; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; font-src 'self' data:; media-src 'self' data:; connect-src 'self' data:` // kilocode_change end -export const UIRoutes = (): Hono => - new Hono().all("/*", async (c) => { - const embeddedWebUI = await embeddedUIPromise - const path = c.req.path +function themePreloadHash(body: string) { + return body.match(/]*\bsrc\s*=)[^>]*\bid=(['"])oc-theme-preload-script\1[^>]*>([\s\S]*?)<\/script>/i) +} + +function requestBody(request: HttpServerRequest.HttpServerRequest) { + if (request.method === "GET" || request.method === "HEAD") return HttpBody.empty + const len = request.headers["content-length"] + return HttpBody.stream(request.stream, request.headers["content-type"], len === undefined ? undefined : Number(len)) +} + +function proxyResponseHeaders(headers: Record) { + const result = new Headers(headers) + // FetchHttpClient exposes decoded response bodies, so forwarding upstream + // transfer metadata makes browsers decode already-decoded assets again. + result.delete("content-encoding") + result.delete("content-length") + return result +} + +function upstreamURL(path: string) { + return new URL(path, UI_UPSTREAM).toString() +} + +function embeddedUI() { + if (Flag.KILO_DISABLE_EMBEDDED_WEB_UI) return Promise.resolve(null) + return embeddedUIPromise +} + +export async function serveUI(request: Request) { + const embeddedWebUI = await embeddedUI() + const path = new URL(request.url).pathname + + if (embeddedWebUI) { + const match = embeddedWebUI[path.replace(/^\//, "")] ?? embeddedWebUI["index.html"] ?? null + if (!match) return Response.json({ error: "Not Found" }, { status: 404 }) + + if (await fs.exists(match)) { + const mime = getMimeType(match) ?? "text/plain" + const headers = new Headers({ "content-type": mime }) + if (mime.startsWith("text/html")) headers.set("content-security-policy", DEFAULT_CSP) + return new Response(new Uint8Array(await fs.readFile(match)), { headers }) + } + + return Response.json({ error: "Not Found" }, { status: 404 }) + } + + const response = await proxy(upstreamURL(path), { + raw: request, + headers: ProxyUtil.headers(request, { host: UI_UPSTREAM.host }), + }) + const match = response.headers.get("content-type")?.includes("text/html") + ? themePreloadHash(await response.clone().text()) + : undefined + const hash = match ? createHash("sha256").update(match[2]).digest("base64") : "" + response.headers.set("Content-Security-Policy", csp(hash)) + return response +} + +export function serveUIEffect( + request: HttpServerRequest.HttpServerRequest, + services: { fs: AppFileSystem.Interface; client: HttpClient.HttpClient }, +) { + return Effect.gen(function* () { + const embeddedWebUI = yield* Effect.promise(() => embeddedUI()) + const path = new URL(request.url, "http://localhost").pathname if (embeddedWebUI) { const match = embeddedWebUI[path.replace(/^\//, "")] ?? embeddedWebUI["index.html"] ?? null - if (!match) return c.json({ error: "Not Found" }, 404) + if (!match) return HttpServerResponse.jsonUnsafe({ error: "Not Found" }, { status: 404 }) - if (await fs.exists(match)) { + if (yield* services.fs.existsSafe(match)) { const mime = getMimeType(match) ?? "text/plain" - c.header("Content-Type", mime) - if (mime.startsWith("text/html")) { - c.header("Content-Security-Policy", DEFAULT_CSP) - } - return c.body(new Uint8Array(await fs.readFile(match))) - } else { - return c.json({ error: "Not Found" }, 404) + const headers = new Headers({ "content-type": mime }) + if (mime.startsWith("text/html")) headers.set("content-security-policy", DEFAULT_CSP) + return HttpServerResponse.raw(yield* services.fs.readFile(match), { headers }) } - } else { - // kilocode_change start - return 404 instead of proxying to app.opencode.ai - return c.json({ error: "Not Found" }, 404) - // kilocode_change end + return HttpServerResponse.jsonUnsafe({ error: "Not Found" }, { status: 404 }) } + + const response = yield* services.client.execute( + HttpClientRequest.make(request.method)(upstreamURL(path), { + headers: ProxyUtil.headers(request.headers, { host: UI_UPSTREAM.host }), + body: requestBody(request), + }), + ) + const headers = proxyResponseHeaders(response.headers) + + if (response.headers["content-type"]?.includes("text/html")) { + const body = yield* response.text + const match = themePreloadHash(body) + headers.set("Content-Security-Policy", csp(match ? createHash("sha256").update(match[2]).digest("base64") : "")) + return HttpServerResponse.text(body, { status: response.status, headers }) + } + + headers.set("Content-Security-Policy", csp()) + return HttpServerResponse.stream(response.stream.pipe(Stream.catchCause(() => Stream.empty)), { + status: response.status, + headers, + }) }) +} + +export const UIRoutes = (): Hono => new Hono().all("/*", (c) => serveUI(c.req.raw)) diff --git a/packages/opencode/src/server/server.ts b/packages/opencode/src/server/server.ts index 816a81c341..8240ad45bd 100644 --- a/packages/opencode/src/server/server.ts +++ b/packages/opencode/src/server/server.ts @@ -19,6 +19,7 @@ import { WorkspaceRoutes } from "./routes/control/workspace" import * as KiloServer from "@/kilocode/server/server" // kilocode_change import { ExperimentalHttpApiServer } from "./routes/instance/httpapi/server" import * as ServerBackend from "./backend" +import type { CorsOptions } from "./cors" // @ts-ignore This global is needed to prevent ai-sdk from logging warnings to stdout https://github.com/vercel/ai/blob/2dc67e0ef538307f21368db32d5a12345d98831b/packages/ai/src/logger/log-warnings.ts#L85 globalThis.AI_SDK_LOG_WARNINGS = false @@ -39,6 +40,13 @@ type ServerApp = { request(input: string | URL | Request, init?: RequestInit): Response | Promise } +type ListenOptions = CorsOptions & { + port: number + hostname: string + mdns?: boolean + mdnsDomain?: string +} + const DefaultHono = lazy(() => withBackend({ backend: "hono", reason: "stable" }, createHono({}, { backend: "hono", reason: "stable" })), ) @@ -55,14 +63,14 @@ export const Default = () => { return selected.backend === "effect-httpapi" ? DefaultHttpApi() : DefaultHono() } -function create(opts: { cors?: string[] }) { +function create(opts: ListenOptions) { const selected = select() return selected.backend === "effect-httpapi" - ? withBackend(selected, createHttpApi()) + ? withBackend(selected, createHttpApi(opts)) : withBackend(selected, createHono(opts, selected)) } -export function Legacy(opts: { cors?: string[] } = {}) { +export function Legacy(opts: CorsOptions = {}) { return withBackend({ backend: "hono", reason: "explicit" }, createHono(opts, { backend: "hono", reason: "explicit" })) } @@ -75,8 +83,8 @@ function withBackend(selection: return built } -function createHttpApi() { - const handler = ExperimentalHttpApiServer.webHandler().handler +function createHttpApi(corsOptions?: CorsOptions) { + const handler = ExperimentalHttpApiServer.webHandler(corsOptions).handler const app: ServerApp = { fetch: (request: Request) => handler(request, ExperimentalHttpApiServer.context), request(input, init) { @@ -89,10 +97,7 @@ function createHttpApi() { } } -function createHono( - opts: { cors?: string[] }, - selection: ServerBackend.Selection = ServerBackend.force(select(), "hono"), -) { +function createHono(opts: CorsOptions, selection: ServerBackend.Selection = ServerBackend.force(select(), "hono")) { const backendAttributes = ServerBackend.attributes(selection) const app = new Hono() .onError(ErrorMiddleware) @@ -152,13 +157,7 @@ export async function openapi() { export let url: URL -export async function listen(opts: { - port: number - hostname: string - mdns?: boolean - mdnsDomain?: string - cors?: string[] -}): Promise { +export async function listen(opts: ListenOptions): Promise { const built = create(opts) const server = await built.runtime.listen(opts) diff --git a/packages/opencode/src/server/workspace.ts b/packages/opencode/src/server/workspace.ts index fccef680fa..fc4d073a19 100644 --- a/packages/opencode/src/server/workspace.ts +++ b/packages/opencode/src/server/workspace.ts @@ -1,15 +1,14 @@ import type { MiddlewareHandler } from "hono" import type { UpgradeWebSocket } from "hono/ws" -import { getAdaptor } from "@/control-plane/adaptors" +import { getAdapter } from "@/control-plane/adapters" import { WorkspaceID } from "@/control-plane/schema" import { WorkspaceContext } from "@/control-plane/workspace-context" import { Workspace } from "@/control-plane/workspace" import { Flag } from "@opencode-ai/core/flag/flag" -import { InstanceBootstrap } from "@/project/bootstrap" +import { getBootstrapRunEffect, AppRuntime } from "@/effect/app-runtime" import { Instance } from "@/project/instance" import { Session } from "@/session/session" import { SessionID } from "@/session/schema" -import { AppRuntime } from "@/effect/app-runtime" import { Effect } from "effect" import * as Log from "@opencode-ai/core/util/log" import { ServerProxy } from "./proxy" @@ -17,6 +16,7 @@ import { ServerProxy } from "./proxy" type Rule = { method?: string; path: string; exact?: boolean; action: "local" | "forward" } const RULES: Array = [ + { path: "/experimental/workspace", action: "local" }, { path: "/session/status", action: "forward" }, { method: "GET", path: "/session", action: "local" }, ] @@ -71,7 +71,9 @@ export function WorkspaceRouterMiddleware(upgrade: UpgradeWebSocket): Middleware return next() } - const workspace = await Workspace.get(WorkspaceID.make(workspaceID)) + const workspace = await AppRuntime.runPromise( + Workspace.Service.use((svc) => svc.get(WorkspaceID.make(workspaceID))), + ) if (!workspace) { return new Response(`Workspace not found: ${workspaceID}`, { @@ -88,16 +90,17 @@ export function WorkspaceRouterMiddleware(upgrade: UpgradeWebSocket): Middleware return next() } - const adaptor = await getAdaptor(workspace.projectID, workspace.type) - const target = await adaptor.target(workspace) + const adapter = getAdapter(workspace.projectID, workspace.type) + const target = await adapter.target(workspace) if (target.type === "local") { + const init = await getBootstrapRunEffect() return WorkspaceContext.provide({ workspaceID: WorkspaceID.make(workspaceID), fn: () => Instance.provide({ directory: target.directory, - init: () => AppRuntime.runPromise(InstanceBootstrap), + init, async fn() { return next() }, diff --git a/packages/opencode/src/session/instruction.ts b/packages/opencode/src/session/instruction.ts index eaeeb6f737..cff5587be2 100644 --- a/packages/opencode/src/session/instruction.ts +++ b/packages/opencode/src/session/instruction.ts @@ -1,4 +1,3 @@ -import os from "os" import path from "path" import { Effect, Layer, Context } from "effect" import { FetchHttpClient, HttpClient, HttpClientRequest } from "effect/unstable/http" @@ -8,30 +7,15 @@ import { Flag } from "@opencode-ai/core/flag/flag" import { AppFileSystem } from "@opencode-ai/core/filesystem" import { withTransientReadRetry } from "@/util/effect-http-client" import { Global } from "@opencode-ai/core/global" -import * as Log from "@opencode-ai/core/util/log" import type { MessageV2 } from "./message-v2" import type { MessageID } from "./schema" -const log = Log.create({ service: "instruction" }) - const FILES = [ "AGENTS.md", ...(Flag.KILO_DISABLE_CLAUDE_CODE_PROMPT ? [] : ["CLAUDE.md"]), "CONTEXT.md", // deprecated ] -function globalFiles() { - const files = [] - if (Flag.KILO_CONFIG_DIR) { - files.push(path.join(Flag.KILO_CONFIG_DIR, "AGENTS.md")) - } - files.push(path.join(Global.Path.config, "AGENTS.md")) - if (!Flag.KILO_DISABLE_CLAUDE_CODE_PROMPT) { - files.push(path.join(os.homedir(), ".claude", "CLAUDE.md")) - } - return files -} - function extract(messages: MessageV2.WithParts[]) { const paths = new Set() for (const msg of messages) { @@ -63,176 +47,185 @@ export interface Interface { export class Service extends Context.Service()("@opencode/Instruction") {} -export const layer: Layer.Layer = - Layer.effect( - Service, - Effect.gen(function* () { - const cfg = yield* Config.Service - const fs = yield* AppFileSystem.Service - const http = HttpClient.filterStatusOk(withTransientReadRetry(yield* HttpClient.HttpClient)) +export const layer: Layer.Layer< + Service, + never, + AppFileSystem.Service | Config.Service | Global.Service | HttpClient.HttpClient +> = Layer.effect( + Service, + Effect.gen(function* () { + const cfg = yield* Config.Service + const fs = yield* AppFileSystem.Service + const global = yield* Global.Service + const http = HttpClient.filterStatusOk(withTransientReadRetry(yield* HttpClient.HttpClient)) + const globalFiles = [ + // kilocode_change start - prefer KILO_CONFIG_DIR profile when set + ...(Flag.KILO_CONFIG_DIR ? [path.join(Flag.KILO_CONFIG_DIR, "AGENTS.md")] : []), + // kilocode_change end + path.join(global.config, "AGENTS.md"), + ...(!Flag.KILO_DISABLE_CLAUDE_CODE_PROMPT ? [path.join(global.home, ".claude", "CLAUDE.md")] : []), + ] - const state = yield* InstanceState.make( - Effect.fn("Instruction.state")(() => - Effect.succeed({ - // Track which instruction files have already been attached for a given assistant message. - claims: new Map>(), - }), - ), - ) + const state = yield* InstanceState.make( + Effect.fn("Instruction.state")(() => + Effect.succeed({ + // Track which instruction files have already been attached for a given assistant message. + claims: new Map>(), + }), + ), + ) - const relative = Effect.fnUntraced(function* (instruction: string) { - const ctx = yield* InstanceState.context - if (!Flag.KILO_DISABLE_PROJECT_CONFIG) { - return yield* fs - .globUp(instruction, ctx.directory, ctx.worktree) - .pipe(Effect.catch(() => Effect.succeed([] as string[]))) - } - if (!Flag.KILO_CONFIG_DIR) { - log.warn( - `Skipping relative instruction "${instruction}" - no KILO_CONFIG_DIR set while project config is disabled`, - ) - return [] - } + const relative = Effect.fnUntraced(function* (instruction: string) { + const ctx = yield* InstanceState.context + if (!Flag.KILO_DISABLE_PROJECT_CONFIG) { return yield* fs - .globUp(instruction, Flag.KILO_CONFIG_DIR, Flag.KILO_CONFIG_DIR) + .globUp(instruction, ctx.directory, ctx.worktree) .pipe(Effect.catch(() => Effect.succeed([] as string[]))) - }) + } + // kilocode_change - prefer KILO_CONFIG_DIR profile when set, else fall back to global.config + const root = Flag.KILO_CONFIG_DIR ?? global.config + return yield* fs + .globUp(instruction, root, root) + .pipe(Effect.catch(() => Effect.succeed([] as string[]))) + }) - const read = Effect.fnUntraced(function* (filepath: string) { - return yield* fs.readFileString(filepath).pipe(Effect.catch(() => Effect.succeed(""))) - }) + const read = Effect.fnUntraced(function* (filepath: string) { + return yield* fs.readFileString(filepath).pipe(Effect.catch(() => Effect.succeed(""))) + }) - const fetch = Effect.fnUntraced(function* (url: string) { - const res = yield* http.execute(HttpClientRequest.get(url)).pipe( - Effect.timeout(5000), - Effect.catch(() => Effect.succeed(null)), - ) - if (!res) return "" - const body = yield* res.arrayBuffer.pipe(Effect.catch(() => Effect.succeed(new ArrayBuffer(0)))) - return new TextDecoder().decode(body) - }) + const fetch = Effect.fnUntraced(function* (url: string) { + const res = yield* http.execute(HttpClientRequest.get(url)).pipe( + Effect.timeout(5000), + Effect.catch(() => Effect.succeed(null)), + ) + if (!res) return "" + const body = yield* res.arrayBuffer.pipe(Effect.catch(() => Effect.succeed(new ArrayBuffer(0)))) + return new TextDecoder().decode(body) + }) - const clear = Effect.fn("Instruction.clear")(function* (messageID: MessageID) { - const s = yield* InstanceState.get(state) - s.claims.delete(messageID) - }) + const clear = Effect.fn("Instruction.clear")(function* (messageID: MessageID) { + const s = yield* InstanceState.get(state) + s.claims.delete(messageID) + }) - const systemPaths = Effect.fn("Instruction.systemPaths")(function* () { - const config = yield* cfg.get() - const ctx = yield* InstanceState.context - const paths = new Set() + const systemPaths = Effect.fn("Instruction.systemPaths")(function* () { + const config = yield* cfg.get() + const ctx = yield* InstanceState.context + const paths = new Set() - for (const file of globalFiles()) { - if (yield* fs.existsSafe(file)) { - paths.add(path.resolve(file)) + for (const file of globalFiles) { + if (yield* fs.existsSafe(file)) { + paths.add(path.resolve(file)) + break + } + } + + // The first project-level match wins so we don't stack AGENTS.md/CLAUDE.md from every ancestor. + if (!Flag.KILO_DISABLE_PROJECT_CONFIG) { + for (const file of FILES) { + const matches = yield* fs.findUp(file, ctx.directory, ctx.worktree) + if (matches.length > 0) { + matches.forEach((item) => paths.add(path.resolve(item))) break } } + } - // The first project-level match wins so we don't stack AGENTS.md/CLAUDE.md from every ancestor. - if (!Flag.KILO_DISABLE_PROJECT_CONFIG) { - for (const file of FILES) { - const matches = yield* fs.findUp(file, ctx.directory, ctx.worktree) - if (matches.length > 0) { - matches.forEach((item) => paths.add(path.resolve(item))) - break - } - } + if (config.instructions) { + for (const raw of config.instructions) { + if (raw.startsWith("https://") || raw.startsWith("http://")) continue + const instruction = raw.startsWith("~/") ? path.join(global.home, raw.slice(2)) : raw + const matches = yield* ( + path.isAbsolute(instruction) + ? fs.glob(path.basename(instruction), { + cwd: path.dirname(instruction), + absolute: true, + include: "file", + }) + : relative(instruction) + ).pipe(Effect.catch(() => Effect.succeed([] as string[]))) + matches.forEach((item) => paths.add(path.resolve(item))) } + } - if (config.instructions) { - for (const raw of config.instructions) { - if (raw.startsWith("https://") || raw.startsWith("http://")) continue - const instruction = raw.startsWith("~/") ? path.join(os.homedir(), raw.slice(2)) : raw - const matches = yield* ( - path.isAbsolute(instruction) - ? fs.glob(path.basename(instruction), { - cwd: path.dirname(instruction), - absolute: true, - include: "file", - }) - : relative(instruction) - ).pipe(Effect.catch(() => Effect.succeed([] as string[]))) - matches.forEach((item) => paths.add(path.resolve(item))) - } - } + return paths + }) - return paths - }) + const system = Effect.fn("Instruction.system")(function* () { + const config = yield* cfg.get() + const paths = yield* systemPaths() + const urls = (config.instructions ?? []).filter( + (item) => item.startsWith("https://") || item.startsWith("http://"), + ) - const system = Effect.fn("Instruction.system")(function* () { - const config = yield* cfg.get() - const paths = yield* systemPaths() - const urls = (config.instructions ?? []).filter( - (item) => item.startsWith("https://") || item.startsWith("http://"), - ) + const files = yield* Effect.forEach(Array.from(paths), read, { concurrency: 8 }) + const remote = yield* Effect.forEach(urls, fetch, { concurrency: 4 }) - const files = yield* Effect.forEach(Array.from(paths), read, { concurrency: 8 }) - const remote = yield* Effect.forEach(urls, fetch, { concurrency: 4 }) + return [ + ...Array.from(paths).flatMap((item, i) => (files[i] ? [`Instructions from: ${item}\n${files[i]}`] : [])), + ...urls.flatMap((item, i) => (remote[i] ? [`Instructions from: ${item}\n${remote[i]}`] : [])), + ] + }) - return [ - ...Array.from(paths).flatMap((item, i) => (files[i] ? [`Instructions from: ${item}\n${files[i]}`] : [])), - ...urls.flatMap((item, i) => (remote[i] ? [`Instructions from: ${item}\n${remote[i]}`] : [])), - ] - }) + const find = Effect.fn("Instruction.find")(function* (dir: string) { + for (const file of FILES) { + const filepath = path.resolve(path.join(dir, file)) + if (yield* fs.existsSafe(filepath)) return filepath + } + return undefined + }) - const find = Effect.fn("Instruction.find")(function* (dir: string) { - for (const file of FILES) { - const filepath = path.resolve(path.join(dir, file)) - if (yield* fs.existsSafe(filepath)) return filepath - } - }) + const resolve = Effect.fn("Instruction.resolve")(function* ( + messages: MessageV2.WithParts[], + filepath: string, + messageID: MessageID, + ) { + const sys = yield* systemPaths() + const already = extract(messages) + const results: { filepath: string; content: string }[] = [] + const s = yield* InstanceState.get(state) + const root = path.resolve(yield* InstanceState.directory) - const resolve = Effect.fn("Instruction.resolve")(function* ( - messages: MessageV2.WithParts[], - filepath: string, - messageID: MessageID, - ) { - const sys = yield* systemPaths() - const already = extract(messages) - const results: { filepath: string; content: string }[] = [] - const s = yield* InstanceState.get(state) - const root = path.resolve(yield* InstanceState.directory) - - const target = path.resolve(filepath) - let current = path.dirname(target) - - // Walk upward from the file being read and attach nearby instruction files once per message. - while (current.startsWith(root) && current !== root) { - const found = yield* find(current) - if (!found || found === target || sys.has(found) || already.has(found)) { - current = path.dirname(current) - continue - } - - let set = s.claims.get(messageID) - if (!set) { - set = new Set() - s.claims.set(messageID, set) - } - if (set.has(found)) { - current = path.dirname(current) - continue - } - - set.add(found) - const content = yield* read(found) - if (content) { - results.push({ filepath: found, content: `Instructions from: ${found}\n${content}` }) - } + const target = path.resolve(filepath) + let current = path.dirname(target) + // Walk upward from the file being read and attach nearby instruction files once per message. + while (current.startsWith(root) && current !== root) { + const found = yield* find(current) + if (!found || found === target || sys.has(found) || already.has(found)) { current = path.dirname(current) + continue } - return results - }) + let set = s.claims.get(messageID) + if (!set) { + set = new Set() + s.claims.set(messageID, set) + } + if (set.has(found)) { + current = path.dirname(current) + continue + } - return Service.of({ clear, systemPaths, system, find, resolve }) - }), - ) + set.add(found) + const content = yield* read(found) + if (content) { + results.push({ filepath: found, content: `Instructions from: ${found}\n${content}` }) + } + + current = path.dirname(current) + } + + return results + }) + + return Service.of({ clear, systemPaths, system, find, resolve }) + }), +) export const defaultLayer = layer.pipe( Layer.provide(Config.defaultLayer), + Layer.provide(Global.layer), Layer.provide(AppFileSystem.defaultLayer), Layer.provide(FetchHttpClient.layer), ) diff --git a/packages/opencode/src/session/llm.ts b/packages/opencode/src/session/llm.ts index f770a2eb36..39164727d1 100644 --- a/packages/opencode/src/session/llm.ts +++ b/packages/opencode/src/session/llm.ts @@ -3,11 +3,11 @@ import * as Log from "@opencode-ai/core/util/log" import { Context, Effect, Layer, Record } from "effect" import * as Stream from "effect/Stream" import { streamText, wrapLanguageModel, type ModelMessage, type Tool, tool, jsonSchema } from "ai" -import { mergeDeep, pipe } from "remeda" +import { mergeDeep } from "remeda" import { GitLabWorkflowLanguageModel } from "gitlab-ai-provider" import { ProviderTransform } from "@/provider/transform" import { Config } from "@/config/config" -import { Instance } from "@/project/instance" +import { InstanceState } from "@/effect/instance-state" import type { Agent } from "@/agent/agent" import type { MessageV2 } from "./message-v2" import { Plugin } from "@/plugin" @@ -36,6 +36,10 @@ const log = Log.create({ service: "llm" }) export const OUTPUT_TOKEN_MAX = ProviderTransform.OUTPUT_TOKEN_MAX type Result = Awaited> +// Avoid re-instantiating remeda's deep merge types in this hot LLM path; the runtime behavior is still mergeDeep. +const mergeOptions = (target: Record, source: Record | undefined): Record => + mergeDeep(target, source ?? {}) as Record + export type StreamInput = { user: MessageV2.User sessionID: string @@ -145,12 +149,7 @@ const live: Layer.Layer< sessionID: input.sessionID, providerOptions: item.options, }) - const options: Record = pipe( - base, - mergeDeep(input.model.options), - mergeDeep(input.agent.options), - mergeDeep(variant), - ) + const options = mergeOptions(mergeOptions(mergeOptions(base, input.model.options), input.agent.options), variant) if (isOpenaiOauth) { // kilocode_change start - prepend soul to instructions options.instructions = SystemPrompt.soul() + "\n" + system.join("\n") @@ -291,7 +290,7 @@ const live: Layer.Layer< const bridge = yield* EffectBridge.make() const approvedToolsForSession = new Set() - workflowModel.approvalHandler = Instance.bind(async (approvalTools) => { + workflowModel.approvalHandler = InstanceState.bind(async (approvalTools) => { const uniqueNames = [...new Set(approvalTools.map((t: { name: string }) => t.name))] as string[] // Auto-approve tools that were already approved in this session // (prevents infinite approval loops for server-side MCP tools) @@ -353,6 +352,10 @@ const live: Layer.Layer< }) : undefined + const opencodeProjectID = input.model.providerID.startsWith("opencode") + ? (yield* InstanceState.context).project.id + : undefined + return streamText({ onError(error) { l.error("stream error", { @@ -392,7 +395,7 @@ const live: Layer.Layer< headers: { ...(input.model.providerID.startsWith("kilo") // kilocode_change ? { - "x-kilo-project": Instance.project.id, + "x-kilo-project": opencodeProjectID, "x-kilo-session": input.sessionID, "x-kilo-request": input.user.id, "x-kilo-client": Flag.KILO_CLIENT, diff --git a/packages/opencode/src/session/message-v2.ts b/packages/opencode/src/session/message-v2.ts index eeaa80dfb5..45871615c2 100644 --- a/packages/opencode/src/session/message-v2.ts +++ b/packages/opencode/src/session/message-v2.ts @@ -843,7 +843,7 @@ export const toModelMessagesEffect = Effect.fnUntraced(function* ( return { type: "content", value: [ - { type: "text", text: outputObject.text }, + ...(outputObject.text ? [{ type: "text", text: outputObject.text }] : []), ...attachments.map((attachment) => ({ type: "media", mediaType: attachment.mime, @@ -1011,10 +1011,18 @@ export const toModelMessagesEffect = Effect.fnUntraced(function* ( }) } if (part.type === "reasoning") { + if (differentModel) { + if (part.text.trim().length > 0) + assistantMessage.parts.push({ + type: "text", + text: part.text, + }) + continue + } assistantMessage.parts.push({ type: "reasoning", text: part.text, - ...(differentModel ? {} : { providerMetadata: part.metadata }), + providerMetadata: part.metadata, }) } } diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index f8dd0dbd2f..091e5d74c4 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -53,7 +53,7 @@ import { AppFileSystem } from "@opencode-ai/core/filesystem" import { Truncate } from "@/tool/truncate" import { decodeDataUrl } from "@/util/data-url" import { Process } from "@/util/process" -import { Cause, Effect, Exit, Latch, Layer, Option, Scope, Context, Schema } from "effect" +import { Cause, Effect, Exit, Latch, Layer, Option, Scope, Context, Schema, Types } from "effect" import { zod } from "@/util/effect-zod" import { withStatics } from "@/util/schema" import * as EffectLogger from "@opencode-ai/core/effect/logger" @@ -140,7 +140,7 @@ export const layer = Layer.effect( const resolvePromptParts = Effect.fn("SessionPrompt.resolvePromptParts")(function* (template: string) { const ctx = yield* InstanceState.context - const parts: PromptInput["parts"] = [{ type: "text", text: template }] + const parts: Types.DeepMutable = [{ type: "text", text: template }] const files = ConfigMarkdown.files(template) const seen = new Set() yield* Effect.forEach( @@ -265,7 +265,8 @@ export const layer = Layer.effect( const assistantMessage = input.messages.findLast((msg) => msg.info.role === "assistant") if (input.agent.name !== "plan" && assistantMessage?.info.agent === "plan") { - const plan = Session.plan(input.session) + const ctx = yield* InstanceState.context + const plan = Session.plan(input.session, ctx) if (!(yield* fsys.existsSafe(plan))) return input.messages const part = yield* sessions.updatePart({ id: PartID.ascending(), @@ -281,7 +282,8 @@ export const layer = Layer.effect( if (input.agent.name !== "plan" || assistantMessage?.info.agent === "plan") return input.messages - const plan = Session.plan(input.session) + const ctx = yield* InstanceState.context + const plan = Session.plan(input.session, ctx) const exists = yield* fsys.existsSafe(plan) if (!exists) yield* fsys.ensureDir(path.dirname(plan)).pipe(Effect.catch(Effect.die)) const part = yield* sessions.updatePart({ @@ -477,9 +479,18 @@ NOTE: At any point in time through this workflow you should feel free to ask the { tool: key, sessionID: ctx.sessionID, callID: opts.toolCallId }, { args }, ) - yield* ctx.ask({ permission: key, metadata: {}, patterns: ["*"], always: ["*"] }) - const result: Awaited>> = yield* Effect.promise(() => - execute(args, opts), + const result: Awaited>> = yield* Effect.gen(function* () { + yield* ctx.ask({ permission: key, metadata: {}, patterns: ["*"], always: ["*"] }) + return yield* Effect.promise(() => execute(args, opts)) + }).pipe( + Effect.withSpan("Tool.execute", { + attributes: { + "tool.name": key, + "tool.call_id": opts.toolCallId, + "session.id": ctx.sessionID, + "message.id": input.processor.message.id, + }, + }), ) yield* plugin.trigger( "tool.execute.after", @@ -1053,7 +1064,7 @@ NOTE: At any point in time through this workflow you should feel free to ask the case "file:": { log.info("file", { mime: part.mime }) const filepath = fileURLToPath(part.url) - if (yield* fsys.isDir(filepath)) part.mime = "application/x-directory" + const mime = (yield* fsys.isDir(filepath)) ? "application/x-directory" : part.mime const { read } = yield* registry.named() const execRead = (args: Parameters[0], extra?: Tool.Context["extra"]) => { @@ -1072,7 +1083,7 @@ NOTE: At any point in time through this workflow you should feel free to ask the .pipe(Effect.onInterrupt(() => Effect.sync(() => controller.abort()))) } - if (part.mime === "text/plain") { + if (mime === "text/plain") { let offset: number | undefined let limit: number | undefined const range = { start: url.searchParams.get("start"), end: url.searchParams.get("end") } @@ -1130,7 +1141,7 @@ NOTE: At any point in time through this workflow you should feel free to ask the })), ) } else { - pieces.push({ ...part, messageID: info.id, sessionID: input.sessionID }) + pieces.push({ ...part, mime, messageID: info.id, sessionID: input.sessionID }) } } else { const error = Cause.squash(exit.cause) @@ -1151,7 +1162,7 @@ NOTE: At any point in time through this workflow you should feel free to ask the return pieces } - if (part.mime === "application/x-directory") { + if (mime === "application/x-directory") { const args = { filePath: filepath } const exit = yield* execRead(args, { includeDirectoryFiles: true }).pipe(Effect.exit) // kilocode_change inline folder files if (Exit.isFailure(exit)) { @@ -1187,7 +1198,7 @@ NOTE: At any point in time through this workflow you should feel free to ask the synthetic: true, text: exit.value.output, }, - { ...part, messageID: info.id, sessionID: input.sessionID }, + { ...part, mime, messageID: info.id, sessionID: input.sessionID }, ] } @@ -1205,9 +1216,9 @@ NOTE: At any point in time through this workflow you should feel free to ask the sessionID: input.sessionID, type: "file", url: - `data:${part.mime};base64,` + + `data:${mime};base64,` + Buffer.from(yield* fsys.readFile(filepath).pipe(Effect.catch(Effect.die))).toString("base64"), - mime: part.mime, + mime, filename: part.filename!, source: part.source, }, @@ -1577,7 +1588,7 @@ NOTE: At any point in time through this workflow you should feel free to ask the const [skills, env, instructions, modelMsgs] = yield* Effect.all([ sys.skills(agent), - Effect.sync(() => sys.environment(model, lastUser.editorContext)), // kilocode_change + sys.environment(model, lastUser.editorContext), // kilocode_change instruction.system().pipe(Effect.orDie), MessageV2.toModelMessagesEffect(msgs, model), ]) diff --git a/packages/opencode/src/session/revert.ts b/packages/opencode/src/session/revert.ts index 4e1abd24e7..8a527c8a28 100644 --- a/packages/opencode/src/session/revert.ts +++ b/packages/opencode/src/session/revert.ts @@ -38,6 +38,7 @@ export const layer = Layer.effect( const bus = yield* Bus.Service const summary = yield* SessionSummary.Service const state = yield* SessionRunState.Service + const sync = yield* SyncEvent.Service const revert = Effect.fn("SessionRevert.revert")(function* (input: RevertInput) { yield* state.assertNotBusy(input.sessionID) @@ -135,7 +136,7 @@ export const layer = Layer.effect( remove.push(msg) } for (const msg of remove) { - SyncEvent.run(MessageV2.Event.Removed, { + yield* sync.run(MessageV2.Event.Removed, { sessionID, messageID: msg.info.id, }) @@ -147,7 +148,7 @@ export const layer = Layer.effect( const removeParts = target.parts.slice(idx) target.parts = target.parts.slice(0, idx) for (const part of removeParts) { - SyncEvent.run(MessageV2.Event.PartRemoved, { + yield* sync.run(MessageV2.Event.PartRemoved, { sessionID, messageID: target.info.id, partID: part.id, @@ -170,6 +171,7 @@ export const defaultLayer = Layer.suspend(() => Layer.provide(Storage.defaultLayer), Layer.provide(Bus.layer), Layer.provide(SessionSummary.defaultLayer), + Layer.provide(SyncEvent.defaultLayer), ), ) diff --git a/packages/opencode/src/session/session.ts b/packages/opencode/src/session/session.ts index 28680995a1..2b9f644e15 100644 --- a/packages/opencode/src/session/session.ts +++ b/packages/opencode/src/session/session.ts @@ -18,8 +18,9 @@ import { PartTable, SessionTable } from "./session.sql" import { Storage } from "@/storage/storage" import * as Log from "@opencode-ai/core/util/log" import { MessageV2 } from "./message-v2" -import { Instance } from "../project/instance" +import type { InstanceContext } from "../project/instance" import { InstanceState } from "@/effect/instance-state" +import { Instance } from "@/project/instance" // kilocode_change - children() uses Instance.current to scope by project_id import { Snapshot } from "@/snapshot" import { ProjectID } from "../project/schema" import { WorkspaceID } from "../control-plane/schema" @@ -139,11 +140,15 @@ const Share = Schema.Struct({ url: Schema.String, }) +// Legacy HTTP accepted negative values here. Keep archive timestamps permissive +// while excluding non-finite values that cannot round-trip through JSON. +export const ArchivedTimestamp = Schema.Finite + const Time = Schema.Struct({ created: NonNegativeInt, updated: NonNegativeInt, compacting: optionalOmitUndefined(NonNegativeInt), - archived: optionalOmitUndefined(NonNegativeInt), + archived: optionalOmitUndefined(ArchivedTimestamp), }) const Revert = Schema.Struct({ @@ -214,7 +219,7 @@ export const SetTitleInput = Schema.Struct({ sessionID: SessionID, title: Schema ) export const SetArchivedInput = Schema.Struct({ sessionID: SessionID, - time: Schema.optional(NonNegativeInt), + time: Schema.optional(ArchivedTimestamp), }).pipe(withStatics((s) => ({ zod: zod(s) }))) export const SetPermissionInput = Schema.Struct({ sessionID: SessionID, @@ -229,6 +234,16 @@ export const MessagesInput = Schema.Struct({ sessionID: SessionID, limit: Schema.optional(NonNegativeInt), }).pipe(withStatics((s) => ({ zod: zod(s) }))) +export type ListInput = { + directory?: string + scope?: "project" + path?: string + workspaceID?: WorkspaceID + roots?: boolean + start?: number + search?: string + limit?: number +} const CreatedEventSchema = Schema.Struct({ sessionID: SessionID, @@ -243,7 +258,7 @@ const UpdatedTime = Schema.Struct({ created: Schema.optional(Schema.NullOr(NonNegativeInt)), updated: Schema.optional(Schema.NullOr(NonNegativeInt)), compacting: Schema.optional(Schema.NullOr(NonNegativeInt)), - archived: Schema.optional(Schema.NullOr(NonNegativeInt)), + archived: Schema.optional(Schema.NullOr(ArchivedTimestamp)), }) const UpdatedInfo = Schema.Struct({ @@ -310,9 +325,9 @@ export const Event = { // kilocode_change end } -export function plan(input: { slug: string; time: { created: number } }) { - const base = Instance.project.vcs - ? path.join(Instance.worktree, ".kilo", "plans") // kilocode_change +export function plan(input: { slug: string; time: { created: number } }, instance: InstanceContext) { + const base = instance.project.vcs + ? path.join(instance.worktree, ".kilo", "plans") // kilocode_change : path.join(Global.Path.data, "plans") return path.join(base, [input.time.created, input.slug].join("-") + ".md") } @@ -403,6 +418,7 @@ export class BusyError extends Error { } export interface Interface { + readonly list: (input?: ListInput) => Effect.Effect readonly create: (input?: { parentID?: SessionID title?: string @@ -457,11 +473,12 @@ export type Patch = Types.DeepMutable["dat const db = (fn: (d: Parameters[0] extends (trx: infer D) => any ? D : never) => T) => Effect.sync(() => Database.use(fn)) -export const layer: Layer.Layer = Layer.effect( +export const layer: Layer.Layer = Layer.effect( Service, Effect.gen(function* () { const bus = yield* Bus.Service const storage = yield* Storage.Service + const sync = yield* SyncEvent.Service const createNext = Effect.fn("Session.createNext")(function* (input: { id?: SessionID @@ -491,7 +508,7 @@ export const layer: Layer.Layer = } log.info("created", result) - yield* Effect.sync(() => SyncEvent.run(Event.Created, { sessionID: result.id, info: result })) + yield* sync.run(Event.Created, { sessionID: result.id, info: result }) if (!Flag.KILO_EXPERIMENTAL_WORKSPACES) { // This only exist for backwards compatibility. We should not be @@ -511,6 +528,11 @@ export const layer: Layer.Layer = return fromRow(row) }) + const list = Effect.fn("Session.list")(function* (input?: ListInput) { + const ctx = yield* InstanceState.context + return Array.from(listByProject({ projectID: ctx.project.id, ...(input ?? {}) })) + }) + // kilocode_change start - scope by project_id when instance context is available const children = Effect.fn("Session.children")(function* (parentID: SessionID) { const ctx = yield* Effect.try({ try: () => Instance.current, catch: () => undefined }).pipe(Effect.option) @@ -553,10 +575,8 @@ export const layer: Layer.Layer = ) } // kilocode_change end - yield* Effect.sync(() => { - SyncEvent.run(Event.Deleted, { sessionID, info: session }, { publish: hasInstance }) - SyncEvent.remove(sessionID) - }) + yield* sync.run(Event.Deleted, { sessionID, info: session }, { publish: hasInstance }) + yield* sync.remove(sessionID) } catch (e) { log.error(e) } @@ -683,8 +703,7 @@ export const layer: Layer.Layer = return session }) - const patch = (sessionID: SessionID, info: Patch) => - Effect.sync(() => SyncEvent.run(Event.Updated, { sessionID, info })) + const patch = (sessionID: SessionID, info: Patch) => sync.run(Event.Updated, { sessionID, info }) const touch = Effect.fn("Session.touch")(function* (sessionID: SessionID) { yield* patch(sessionID, { time: { updated: Date.now() } }) @@ -741,12 +760,10 @@ export const layer: Layer.Layer = sessionID: SessionID messageID: MessageID }) { - yield* Effect.sync(() => - SyncEvent.run(MessageV2.Event.Removed, { - sessionID: input.sessionID, - messageID: input.messageID, - }), - ) + yield* sync.run(MessageV2.Event.Removed, { + sessionID: input.sessionID, + messageID: input.messageID, + }) return input.messageID }) @@ -755,13 +772,11 @@ export const layer: Layer.Layer = messageID: MessageID partID: PartID }) { - yield* Effect.sync(() => - SyncEvent.run(MessageV2.Event.PartRemoved, { - sessionID: input.sessionID, - messageID: input.messageID, - partID: input.partID, - }), - ) + yield* sync.run(MessageV2.Event.PartRemoved, { + sessionID: input.sessionID, + messageID: input.messageID, + partID: input.partID, + }) return input.partID }) @@ -787,6 +802,7 @@ export const layer: Layer.Layer = }) return Service.of({ + list, create, fork, touch, @@ -812,32 +828,30 @@ export const layer: Layer.Layer = }), ) -export const defaultLayer = layer.pipe(Layer.provide(Bus.layer), Layer.provide(Storage.defaultLayer)) +export const defaultLayer = layer.pipe( + Layer.provide(Bus.layer), + Layer.provide(Storage.defaultLayer), + Layer.provide(SyncEvent.defaultLayer), +) -export function* list(input?: { - directory?: string - scope?: "project" - path?: string - workspaceID?: WorkspaceID - roots?: boolean - start?: number - search?: string - limit?: number -}) { - const project = Instance.project +function* listByProject( + input: ListInput & { + projectID: ProjectID + }, +) { // kilocode_change start - KiloSession.filters keeps sessions visible across project_id changes // (see PR #8875). That directory-anchored filter conflicts with upstream's path-prefix filter, // so bypass it when input.path is provided and fall back to the plain project_id base. const conditions = - input?.path !== undefined - ? [eq(SessionTable.project_id, project.id)] - : KiloSession.filters({ projectID: project.id, directory: input?.directory }) + input.path !== undefined + ? [eq(SessionTable.project_id, input.projectID)] + : KiloSession.filters({ projectID: input.projectID, directory: input.directory }) // kilocode_change end - if (input?.workspaceID) { + if (input.workspaceID) { conditions.push(eq(SessionTable.workspace_id, input.workspaceID)) } - if (input?.path !== undefined) { + if (input.path !== undefined) { if (input.path) { const conds = [eq(SessionTable.path, input.path), like(SessionTable.path, `${input.path}/%`)] @@ -847,24 +861,24 @@ export function* list(input?: { : or(...conds)!, ) } - } else if (input?.scope !== "project" && !Flag.KILO_EXPERIMENTAL_WORKSPACES) { + } else if (input.scope !== "project" && !Flag.KILO_EXPERIMENTAL_WORKSPACES) { // kilocode_change start - directory filtering handled by KiloSession.filters above - // if (input?.directory) { + // if (input.directory) { // conditions.push(eq(SessionTable.directory, input.directory)) // } // kilocode_change end } - if (input?.roots) { + if (input.roots) { conditions.push(isNull(SessionTable.parent_id)) } - if (input?.start) { + if (input.start) { conditions.push(gte(SessionTable.time_updated, input.start)) } - if (input?.search) { + if (input.search) { conditions.push(like(SessionTable.title, `%${input.search}%`)) } - const limit = input?.limit ?? 100 + const limit = input.limit ?? 100 const rows = Database.use((db) => db diff --git a/packages/opencode/src/session/system.ts b/packages/opencode/src/session/system.ts index 1e12f55ab4..64a5446e6e 100644 --- a/packages/opencode/src/session/system.ts +++ b/packages/opencode/src/session/system.ts @@ -1,7 +1,7 @@ import { Context, Effect, Layer } from "effect" import { Global } from "@opencode-ai/core/global" // kilocode_change -import { Instance } from "../project/instance" +import { InstanceState } from "@/effect/instance-state" import PROMPT_ANTHROPIC from "./prompt/anthropic.txt" import PROMPT_DEFAULT from "./prompt/default.txt" @@ -80,7 +80,7 @@ export function provider(model: Provider.Model) { } export interface Interface { - readonly environment: (model: Provider.Model, editorContext?: EditorContext) => string[] // kilocode_change + readonly environment: (model: Provider.Model, editorContext?: EditorContext) => Effect.Effect // kilocode_change readonly skills: (agent: Agent.Info) => Effect.Effect } @@ -92,17 +92,16 @@ export const layer = Layer.effect( const skill = yield* Skill.Service return Service.of({ - environment(model, editorContext) { - // kilocode_change - const project = Instance.project + environment: Effect.fn("SystemPrompt.environment")(function* (model: Provider.Model, editorContext?: EditorContext) { + const ctx = yield* InstanceState.context return [ [ `You are powered by the model named ${model.api.id}. The exact model ID is ${model.providerID}/${model.api.id}`, `Here is some useful information about the environment you are running in:`, ``, - ` Working directory: ${Instance.directory}`, - ` Workspace root folder: ${Instance.worktree}`, - ` Is directory a git repo: ${project.vcs === "git" ? "yes" : "no"}`, + ` Working directory: ${ctx.directory}`, + ` Workspace root folder: ${ctx.worktree}`, + ` Is directory a git repo: ${ctx.project.vcs === "git" ? "yes" : "no"}`, ` Platform: ${process.platform}`, ` Today's date: ${new Date().toDateString()}`, ` Project config: .kilo/command/*.md, .kilo/agent/*.md, kilo.json, AGENTS.md. Put new commands and agents in .kilo/. Do not use .kilocode/ or .opencode/.`, // kilocode_change @@ -111,7 +110,7 @@ export const layer = Layer.effect( ``, ].join("\n"), ] - }, + }), skills: Effect.fn("SystemPrompt.skills")(function* (agent: Agent.Info) { if (Permission.disabled(["skill"], agent.permission).has("skill")) return diff --git a/packages/opencode/src/share/session.ts b/packages/opencode/src/share/session.ts index 5ab180f94f..f66341d97a 100644 --- a/packages/opencode/src/share/session.ts +++ b/packages/opencode/src/share/session.ts @@ -21,20 +21,19 @@ export const layer = Layer.effect( const session = yield* Session.Service const shareNext = yield* ShareNext.Service const scope = yield* Scope.Scope + const sync = yield* SyncEvent.Service const share = Effect.fn("SessionShare.share")(function* (sessionID: SessionID) { const conf = yield* cfg.get() if (conf.share === "disabled") throw new Error("Sharing is disabled in configuration") const result = yield* shareNext.create(sessionID) - yield* Effect.sync(() => - SyncEvent.run(Session.Event.Updated, { sessionID, info: { share: { url: result.url } } }), - ) + yield* sync.run(Session.Event.Updated, { sessionID, info: { share: { url: result.url } } }) return result }) const unshare = Effect.fn("SessionShare.unshare")(function* (sessionID: SessionID) { yield* shareNext.remove(sessionID) - yield* Effect.sync(() => SyncEvent.run(Session.Event.Updated, { sessionID, info: { share: { url: null } } })) + yield* sync.run(Session.Event.Updated, { sessionID, info: { share: { url: null } } }) }) const create = Effect.fn("SessionShare.create")(function* (input?: Session.CreateInput) { @@ -54,6 +53,7 @@ export const defaultLayer = layer.pipe( Layer.provide(ShareNext.defaultLayer), Layer.provide(Session.defaultLayer), Layer.provide(Config.defaultLayer), + Layer.provide(SyncEvent.defaultLayer), ) export * as SessionShare from "./session" diff --git a/packages/opencode/src/skill/index.ts b/packages/opencode/src/skill/index.ts index 44b080e46b..f3fcb76ff4 100644 --- a/packages/opencode/src/skill/index.ts +++ b/packages/opencode/src/skill/index.ts @@ -1,4 +1,3 @@ -import os from "os" import path from "path" import { pathToFileURL } from "url" import z from "zod" @@ -23,7 +22,8 @@ import { rm } from "fs/promises" // kilocode_change import { BUILTIN_SKILLS } from "../kilocode/skills/builtin" // kilocode_change const log = Log.create({ service: "skill" }) -const EXTERNAL_DIRS = [".claude", ".agents"] +const CLAUDE_EXTERNAL_DIR = ".claude" +const AGENTS_EXTERNAL_DIR = ".agents" // kilocode_change start export const BUILTIN_LOCATION = "builtin" // kilocode_change end @@ -153,20 +153,25 @@ const discoverSkills = Effect.fnUntraced(function* ( config: Config.Interface, discovery: Discovery.Interface, fsys: AppFileSystem.Interface, + global: Global.Interface, directory: string, worktree: string, ) { const state: ScanState = { matches: new Set(), dirs: new Set() } + const externalDirs: string[] = [] if (!Flag.KILO_DISABLE_EXTERNAL_SKILLS) { - for (const dir of EXTERNAL_DIRS) { - const root = path.join(Global.Path.home, dir) + if (!Flag.KILO_DISABLE_CLAUDE_CODE_SKILLS) externalDirs.push(CLAUDE_EXTERNAL_DIR) + externalDirs.push(AGENTS_EXTERNAL_DIR) + + for (const dir of externalDirs) { + const root = path.join(global.home, dir) if (!(yield* fsys.isDir(root))) continue yield* scan(state, root, EXTERNAL_SKILL_PATTERN, { dot: true, scope: "global" }) } const upDirs = yield* fsys - .up({ targets: EXTERNAL_DIRS, start: directory, stop: worktree }) + .up({ targets: externalDirs, start: directory, stop: worktree }) .pipe(Effect.catch(() => Effect.succeed([] as string[]))) for (const root of upDirs) { @@ -181,7 +186,7 @@ const discoverSkills = Effect.fnUntraced(function* ( const cfg = yield* config.get() for (const item of cfg.skills?.paths ?? []) { - const expanded = item.startsWith("~/") ? path.join(os.homedir(), item.slice(2)) : item + const expanded = item.startsWith("~/") ? path.join(global.home, item.slice(2)) : item const dir = path.isAbsolute(expanded) ? expanded : path.join(directory, expanded) if (!(yield* fsys.isDir(dir))) { log.warn("skill path not found", { path: dir }) @@ -233,13 +238,14 @@ export const layer = Layer.effect( const config = yield* Config.Service const bus = yield* Bus.Service const fsys = yield* AppFileSystem.Service + const global = yield* Global.Service const discovered = yield* InstanceState.make( Effect.fn("Skill.discovery")(function* (ctx) { - return yield* discoverSkills(config, discovery, fsys, ctx.directory, ctx.worktree) + return yield* discoverSkills(config, discovery, fsys, global, ctx.directory, ctx.worktree) }), ) const state = yield* InstanceState.make( - Effect.fn("Skill.state")(function* (ctx) { + Effect.fn("Skill.state")(function* () { const s: State = { skills: {}, dirs: new Set() } yield* loadSkills(s, yield* InstanceState.get(discovered), bus) return s @@ -276,6 +282,7 @@ export const defaultLayer = layer.pipe( Layer.provide(Config.defaultLayer), Layer.provide(Bus.layer), Layer.provide(AppFileSystem.defaultLayer), + Layer.provide(Global.layer), ) // kilocode_change start - legacy promise helpers for Kilo callsites diff --git a/packages/opencode/src/storage/db.ts b/packages/opencode/src/storage/db.ts index 2eabda3c72..1834178677 100644 --- a/packages/opencode/src/storage/db.ts +++ b/packages/opencode/src/storage/db.ts @@ -47,6 +47,13 @@ type Client = SQLiteBunDatabase type Journal = { sql: string; timestamp: number; name: string }[] +// Drizzle's migrate overloads trigger expensive variance checks here; narrow to the journal overload we actually use. +const migrateFromJournal = migrate as unknown as (db: SQLiteBunDatabase, entries: Journal) => void + +function applyMigrations(db: SQLiteBunDatabase, entries: Journal) { + migrateFromJournal(db, entries) +} + function time(tag: string) { const match = /^(\d{4})(\d{2})(\d{2})(\d{2})(\d{2})(\d{2})/.exec(tag) if (!match) return 0 @@ -107,7 +114,7 @@ export const Client = lazy(() => { item.sql = "select 1;" } } - migrate(db, entries) + applyMigrations(db, entries) } return db diff --git a/packages/opencode/src/sync/README.md b/packages/opencode/src/sync/README.md index 546cf3ced4..cb7e875648 100644 --- a/packages/opencode/src/sync/README.md +++ b/packages/opencode/src/sync/README.md @@ -94,7 +94,7 @@ Importantly, **sync events automatically re-publish as bus events**. This makes ### Event shape -- The shape of the events are slightly different. A sync event has the `type`, `id`, `seq`, `aggregateID`, and `data` fields. A bus event has the `type` and `properties` fields. `data` and `properties` are largely the same thing. This conversion is automatically handled when the sync system re-published the event throught the bus. +- The shape of the events are slightly different. A sync event has the `type`, `id`, `seq`, `aggregateID`, and `data` fields. A bus event has the `type` and `properties` fields. `data` and `properties` are largely the same thing. This conversion is automatically handled when the sync system re-published the event through the bus. The reason for this is because sync events need to track more information. I chose not to copy the `properties` naming to more clearly disambiguate the event types. @@ -112,9 +112,9 @@ The system install projectors in `server/projectors.js`. It calls `SyncEvent.ini This allows you to "reshape" an event from the sync system before it's published to the bus. This should be avoided, but might be necessary for temporary backwards compat. -The only time we use this is the `session.updated` event. Previously this event contained the entire session object. The sync even only contains the fields updated. We convert the event to contain to full object for backwards compatibility (but ideally we'd remove this). +The only time we use this is the `session.updated` event. Previously this event contained the entire session object. The sync event only contains the fields updated. We convert the event to contain the full object for backwards compatibility (but ideally we'd remove this). -It's very important that types are correct when working with events. Event definitions have a `schema` which carries the defintiion of the event shape (provided by a zod schema, inferred into a TypeScript type). Examples: +It's very important that types are correct when working with events. Event definitions have a `schema` which carries the definition of the event shape (provided by a zod schema, inferred into a TypeScript type). Examples: ```ts // The schema from `Updated` typechecks the object correctly diff --git a/packages/opencode/src/sync/index.ts b/packages/opencode/src/sync/index.ts index 399fc2d28a..141955d2a6 100644 --- a/packages/opencode/src/sync/index.ts +++ b/packages/opencode/src/sync/index.ts @@ -4,14 +4,17 @@ import { eq } from "drizzle-orm" import { GlobalBus } from "@/bus/global" import { Bus as ProjectBus } from "@/bus" import { BusEvent } from "@/bus/bus-event" -import { Instance } from "@/project/instance" +import type { InstanceContext } from "@/project/instance" import { EventSequenceTable, EventTable } from "./event.sql" -import { WorkspaceContext } from "@/control-plane/workspace-context" +import type { WorkspaceID } from "@/control-plane/schema" import { EventID } from "./schema" import { Flag } from "@opencode-ai/core/flag/flag" -import { Schema as EffectSchema } from "effect" +import { Context, Effect, Layer, Schema as EffectSchema } from "effect" import { zodObject } from "@/util/effect-zod" import type { DeepMutable } from "@/util/schema" +import { makeRuntime } from "@/effect/run-service" +import { serviceUse } from "@/effect/service-use" +import { InstanceState } from "@/effect/instance-state" // Keep `Event["data"]` mutable because projectors mutate the persisted shape // when writing to the database. Bus payloads (`Properties`) stay readonly — @@ -45,6 +48,142 @@ export type SerializedEvent = Event & type ProjectorFunc = (db: Database.TxOrDb, data: unknown) => void type ConvertEvent = (type: string, data: Event["data"]) => unknown | Promise +type PublishContext = { + instance?: InstanceContext + workspace?: WorkspaceID +} + +export interface Interface { + readonly run: ( + def: Def, + data: Event["data"], + options?: { publish?: boolean }, + ) => Effect.Effect + readonly replay: (event: SerializedEvent, options?: { publish: boolean }) => Effect.Effect + readonly replayAll: (events: SerializedEvent[], options?: { publish: boolean }) => Effect.Effect + readonly remove: (aggregateID: string) => Effect.Effect +} + +export class Service extends Context.Service()("@opencode/SyncEvent") {} + +export const layer = Layer.effect(Service)( + Effect.gen(function* () { + const replay: Interface["replay"] = Effect.fn("SyncEvent.replay")(function* (event, options) { + const def = registry.get(event.type) + if (!def) { + throw new Error(`Unknown event type: ${event.type}`) + } + + const row = Database.use((db) => + db + .select({ seq: EventSequenceTable.seq }) + .from(EventSequenceTable) + .where(eq(EventSequenceTable.aggregate_id, event.aggregateID)) + .get(), + ) + + const latest = row?.seq ?? -1 + if (event.seq <= latest) return + + const expected = latest + 1 + if (event.seq !== expected) { + throw new Error( + `Sequence mismatch for aggregate "${event.aggregateID}": expected ${expected}, got ${event.seq}`, + ) + } + + const publish = !!options?.publish + const context = publish + ? { + instance: yield* InstanceState.context, + workspace: yield* InstanceState.workspaceID, + } + : undefined + process(def, event, { publish, context }) + }) + + const replayAll: Interface["replayAll"] = Effect.fn("SyncEvent.replayAll")(function* (events, options) { + const source = events[0]?.aggregateID + if (!source) return undefined + if (events.some((item) => item.aggregateID !== source)) { + throw new Error("Replay events must belong to the same session") + } + const start = events[0].seq + for (const [i, item] of events.entries()) { + const seq = start + i + if (item.seq !== seq) { + throw new Error(`Replay sequence mismatch at index ${i}: expected ${seq}, got ${item.seq}`) + } + } + for (const item of events) { + yield* replay(item, options) + } + return source + }) + + const run: Interface["run"] = Effect.fn("SyncEvent.run")(function* (def, data, options) { + const agg = (data as Record)[def.aggregate] + // This should never happen: we've enforced it via typescript in + // the definition + if (agg == null) { + throw new Error(`SyncEvent.run: "${def.aggregate}" required but not found: ${JSON.stringify(data)}`) + } + + if (def.version !== versions.get(def.type)) { + throw new Error(`SyncEvent.run: running old versions of events is not allowed: ${def.type}`) + } + + const { publish = true } = options || {} + const context = publish + ? { + instance: yield* InstanceState.context, + workspace: yield* InstanceState.workspaceID, + } + : undefined + + // Note that this is an "immediate" transaction which is critical. + // We need to make sure we can safely read and write with nothing + // else changing the data from under us + Database.transaction( + (tx) => { + const id = EventID.ascending() + const row = tx + .select({ seq: EventSequenceTable.seq }) + .from(EventSequenceTable) + .where(eq(EventSequenceTable.aggregate_id, agg)) + .get() + const seq = row?.seq != null ? row.seq + 1 : 0 + + const event = { id, seq, aggregateID: agg, data } + process(def, event, { publish, context }) + }, + { + behavior: "immediate", + }, + ) + }) + + const remove: Interface["remove"] = Effect.fn("SyncEvent.remove")(function* (aggregateID) { + Database.transaction((tx) => { + tx.delete(EventSequenceTable).where(eq(EventSequenceTable.aggregate_id, aggregateID)).run() + tx.delete(EventTable).where(eq(EventTable.aggregate_id, aggregateID)).run() + }) + }) + + return Service.of({ + run, + replay, + replayAll, + remove, + }) + }), +) + +export const defaultLayer = layer + +export const use = serviceUse(Service) + +const runtime = makeRuntime(Service, defaultLayer) export const registry = new Map() let projectors: Map | undefined @@ -121,7 +260,11 @@ export function project( return [def, func as ProjectorFunc] } -function process(def: Def, event: Event, options: { publish: boolean }) { +function process( + def: Def, + event: Event, + options: { publish: boolean; context?: PublishContext }, +) { if (projectors == null) { throw new Error("No projectors available. Call `SyncEvent.init` to install projectors") } @@ -160,6 +303,10 @@ function process(def: Def, event: Event, options: { Database.effect(() => { if (options?.publish) { + if (!options.context?.instance) { + throw new Error("SyncEvent.process: publish requires instance context") + } + const result = convertEvent(def.type, event.data) const publish = (data: unknown) => ProjectBus.publish(def, data as Properties) if (result instanceof Promise) { @@ -169,9 +316,9 @@ function process(def: Def, event: Event, options: { } GlobalBus.emit("event", { - directory: Instance.directory, - project: Instance.project.id, - workspace: WorkspaceContext.workspaceID, + directory: options.context.instance.directory, + project: options.context.instance.project.id, + workspace: options.context.workspace, payload: { type: "sync", syncEvent: { @@ -186,92 +333,19 @@ function process(def: Def, event: Event, options: { } export function replay(event: SerializedEvent, options?: { publish: boolean }) { - const def = registry.get(event.type) - if (!def) { - throw new Error(`Unknown event type: ${event.type}`) - } - - const row = Database.use((db) => - db - .select({ seq: EventSequenceTable.seq }) - .from(EventSequenceTable) - .where(eq(EventSequenceTable.aggregate_id, event.aggregateID)) - .get(), - ) - - const latest = row?.seq ?? -1 - if (event.seq <= latest) { - return - } - - const expected = latest + 1 - if (event.seq !== expected) { - throw new Error(`Sequence mismatch for aggregate "${event.aggregateID}": expected ${expected}, got ${event.seq}`) - } - - process(def, event, { publish: !!options?.publish }) + return runtime.runSync((sync) => sync.replay(event, options)) } export function replayAll(events: SerializedEvent[], options?: { publish: boolean }) { - const source = events[0]?.aggregateID - if (!source) return - if (events.some((item) => item.aggregateID !== source)) { - throw new Error("Replay events must belong to the same session") - } - const start = events[0].seq - for (const [i, item] of events.entries()) { - const seq = start + i - if (item.seq !== seq) { - throw new Error(`Replay sequence mismatch at index ${i}: expected ${seq}, got ${item.seq}`) - } - } - for (const item of events) { - replay(item, options) - } - return source + return runtime.runSync((sync) => sync.replayAll(events, options)) } export function run(def: Def, data: Event["data"], options?: { publish?: boolean }) { - const agg = (data as Record)[def.aggregate] - // This should never happen: we've enforced it via typescript in - // the definition - if (agg == null) { - throw new Error(`SyncEvent.run: "${def.aggregate}" required but not found: ${JSON.stringify(data)}`) - } - - if (def.version !== versions.get(def.type)) { - throw new Error(`SyncEvent.run: running old versions of events is not allowed: ${def.type}`) - } - - const { publish = true } = options || {} - - // Note that this is an "immediate" transaction which is critical. - // We need to make sure we can safely read and write with nothing - // else changing the data from under us - Database.transaction( - (tx) => { - const id = EventID.ascending() - const row = tx - .select({ seq: EventSequenceTable.seq }) - .from(EventSequenceTable) - .where(eq(EventSequenceTable.aggregate_id, agg)) - .get() - const seq = row?.seq != null ? row.seq + 1 : 0 - - const event = { id, seq, aggregateID: agg, data } - process(def, event, { publish }) - }, - { - behavior: "immediate", - }, - ) + return runtime.runSync((sync) => sync.run(def, data, options)) } export function remove(aggregateID: string) { - Database.transaction((tx) => { - tx.delete(EventSequenceTable).where(eq(EventSequenceTable.aggregate_id, aggregateID)).run() - tx.delete(EventTable).where(eq(EventTable.aggregate_id, aggregateID)).run() - }) + return runtime.runSync((sync) => sync.remove(aggregateID)) } export function payloads() { diff --git a/packages/opencode/src/tool/apply_patch.ts b/packages/opencode/src/tool/apply_patch.ts index 186272d968..bc588bfae9 100644 --- a/packages/opencode/src/tool/apply_patch.ts +++ b/packages/opencode/src/tool/apply_patch.ts @@ -3,7 +3,7 @@ import { Effect, Schema } from "effect" import * as Tool from "./tool" import { Bus } from "../bus" import { FileWatcher } from "../file/watcher" -import { Instance } from "../project/instance" +import { InstanceState } from "@/effect/instance-state" import { Patch } from "../patch" import { createTwoFilesPatch, diffLines } from "diff" import { assertExternalDirectoryEffect } from "./external-directory" @@ -55,6 +55,8 @@ export const ApplyPatchTool = Tool.define( return yield* Effect.fail(new Error("apply_patch verification failed: no hunks found")) } + const instance = yield* InstanceState.context + // Validate file paths and check permissions const fileChanges: Array<{ filePath: string @@ -72,7 +74,7 @@ export const ApplyPatchTool = Tool.define( let totalDiff = "" for (const hunk of hunks) { - const filePath = path.resolve(Instance.directory, hunk.path) + const filePath = path.resolve(instance.directory, hunk.path) yield* assertExternalDirectoryEffect(ctx, filePath) switch (hunk.type) { @@ -140,7 +142,7 @@ export const ApplyPatchTool = Tool.define( if (change.removed) deletions += change.count || 0 } - const movePath = hunk.move_path ? path.resolve(Instance.directory, hunk.move_path) : undefined + const movePath = hunk.move_path ? path.resolve(instance.directory, hunk.move_path) : undefined yield* assertExternalDirectoryEffect(ctx, movePath) fileChanges.push({ @@ -199,7 +201,7 @@ export const ApplyPatchTool = Tool.define( // Build per-file metadata for UI rendering (used for both permission and result) const files = fileChanges.map((change) => ({ filePath: change.filePath, - relativePath: path.relative(Instance.worktree, change.movePath ?? change.filePath).replaceAll("\\", "/"), + relativePath: path.relative(instance.worktree, change.movePath ?? change.filePath).replaceAll("\\", "/"), type: change.type, patch: change.diff, additions: change.additions, @@ -208,7 +210,7 @@ export const ApplyPatchTool = Tool.define( })) // Check permissions if needed - const relativePaths = fileChanges.map((c) => path.relative(Instance.worktree, c.filePath).replaceAll("\\", "/")) + const relativePaths = fileChanges.map((c) => path.relative(instance.worktree, c.filePath).replaceAll("\\", "/")) yield* ctx.ask({ permission: "edit", patterns: relativePaths, @@ -277,13 +279,13 @@ export const ApplyPatchTool = Tool.define( // Generate output summary const summaryLines = fileChanges.map((change) => { if (change.type === "add") { - return `A ${path.relative(Instance.worktree, change.filePath).replaceAll("\\", "/")}` + return `A ${path.relative(instance.worktree, change.filePath).replaceAll("\\", "/")}` } if (change.type === "delete") { - return `D ${path.relative(Instance.worktree, change.filePath).replaceAll("\\", "/")}` + return `D ${path.relative(instance.worktree, change.filePath).replaceAll("\\", "/")}` } const target = change.movePath ?? change.filePath - return `M ${path.relative(Instance.worktree, target).replaceAll("\\", "/")}` + return `M ${path.relative(instance.worktree, target).replaceAll("\\", "/")}` }) let output = `Success. Updated the following files:\n${summaryLines.join("\n")}` @@ -298,7 +300,7 @@ export const ApplyPatchTool = Tool.define( const target = change.movePath ?? change.filePath const block = LSP.Diagnostic.report(target, diagnostics[AppFileSystem.normalizePath(target)] ?? []) if (!block) continue - const rel = path.relative(Instance.worktree, target).replaceAll("\\", "/") + const rel = path.relative(instance.worktree, target).replaceAll("\\", "/") output += `\n\nLSP errors detected in ${rel}, please fix:\n${block}` } diff --git a/packages/opencode/src/tool/bash.ts b/packages/opencode/src/tool/bash.ts index ef3095d046..f29230548d 100644 --- a/packages/opencode/src/tool/bash.ts +++ b/packages/opencode/src/tool/bash.ts @@ -6,7 +6,7 @@ import * as Tool from "./tool" import path from "path" import DESCRIPTION from "./bash.txt" import * as Log from "@opencode-ai/core/util/log" -import { Instance } from "../project/instance" +import { containsPath, type InstanceContext } from "../project/instance-context" import { lazy } from "@/util/lazy" import { Language, type Node } from "web-tree-sitter" @@ -14,6 +14,7 @@ import { AppFileSystem } from "@opencode-ai/core/filesystem" import { fileURLToPath } from "url" import { Config } from "@/config/config" import { Flag } from "@opencode-ai/core/flag/flag" +import { Global } from "@opencode-ai/core/global" import { Shell } from "@/shell/shell" import { BashArity } from "@/permission/arity" @@ -383,7 +384,13 @@ export const BashTool = Tool.define( return yield* resolvePath(next, cwd, shell) }) - const collect = Effect.fn("BashTool.collect")(function* (root: Node, cwd: string, ps: boolean, shell: string) { + const collect = Effect.fn("BashTool.collect")(function* ( + root: Node, + cwd: string, + ps: boolean, + shell: string, + instance: InstanceContext, + ) { const scan: Scan = { dirs: new Set(), patterns: new Set(), @@ -410,7 +417,7 @@ export const BashTool = Tool.define( for (const arg of pathArgs(command, ps)) { const resolved = yield* argPath(arg, cwd, ps, shell) log.info("resolved path", { arg, resolved }) - if (!resolved || Instance.containsPath(resolved)) continue + if (!resolved || containsPath(resolved, instance)) continue const dir = (yield* fs.isDir(resolved)) ? resolved : path.dirname(resolved) scan.dirs.add(dir) if (kind !== "read") scan.access = "unknown" // kilocode_change @@ -613,6 +620,7 @@ export const BashTool = Tool.define( return { description: DESCRIPTION.replaceAll("${directory}", instance.directory) + .replaceAll("${tmp}", Global.Path.tmp) .replaceAll("${os}", process.platform) .replaceAll("${shell}", name) .replaceAll("${chaining}", chain) @@ -621,9 +629,10 @@ export const BashTool = Tool.define( parameters: Parameters, execute: (params: Schema.Schema.Type, ctx: Tool.Context) => Effect.gen(function* () { + const executeInstance = yield* InstanceState.context const cwd = params.workdir - ? yield* resolvePath(params.workdir, Instance.directory, shell) - : Instance.directory + ? yield* resolvePath(params.workdir, executeInstance.directory, shell) + : executeInstance.directory if (params.timeout !== undefined && params.timeout < 0) { throw new Error(`Invalid timeout value: ${params.timeout}. Timeout must be a positive number.`) } @@ -634,9 +643,9 @@ export const BashTool = Tool.define( const tree = yield* Effect.acquireRelease(parse(params.command, ps), (tree) => Effect.sync(() => tree.delete()), ) - const scan = yield* collect(tree.rootNode, cwd, ps, shell) + const scan = yield* collect(tree.rootNode, cwd, ps, shell, executeInstance) // kilocode_change start - if (!Instance.containsPath(cwd)) { + if (!containsPath(cwd, executeInstance)) { scan.dirs.add(cwd) scan.access = "unknown" } diff --git a/packages/opencode/src/tool/bash.txt b/packages/opencode/src/tool/bash.txt index c2fe873791..a131ed7e63 100644 --- a/packages/opencode/src/tool/bash.txt +++ b/packages/opencode/src/tool/bash.txt @@ -4,6 +4,8 @@ Be aware: OS: ${os}, Shell: ${shell} All commands run in the current working directory by default. Use the `workdir` parameter if you need to run a command in a different directory. AVOID using `cd && ` patterns - use `workdir` instead. +Use `${tmp}` for temporary work outside the workspace. This directory has already been created, already exists, and is pre-approved for external directory access. + IMPORTANT: This tool is for terminal operations like git, npm, docker, etc. DO NOT use it for file operations (reading, writing, editing, searching, finding files) - use the specialized tools for this instead. Before executing the command, please follow these steps: diff --git a/packages/opencode/src/tool/edit.ts b/packages/opencode/src/tool/edit.ts index 92ccff2c92..b2da52a00c 100644 --- a/packages/opencode/src/tool/edit.ts +++ b/packages/opencode/src/tool/edit.ts @@ -13,7 +13,7 @@ import { File } from "../file" import { FileWatcher } from "../file/watcher" import { Bus } from "../bus" import { Format } from "../format" -import { Instance } from "../project/instance" +import { InstanceState } from "@/effect/instance-state" import { Snapshot } from "@/snapshot" import { assertExternalDirectoryEffect } from "./external-directory" import { AppFileSystem } from "@opencode-ai/core/filesystem" @@ -101,9 +101,10 @@ export const EditTool = Tool.define( throw new Error("No changes to apply: oldString and newString are identical.") } + const instance = yield* InstanceState.context const filePath = path.isAbsolute(params.filePath) ? params.filePath - : path.join(Instance.directory, params.filePath) + : path.join(instance.directory, params.filePath) yield* assertExternalDirectoryEffect(ctx, filePath) let diff = "" @@ -127,7 +128,7 @@ export const EditTool = Tool.define( cachedFilediff = buildFileDiff(filePath, contentOld, contentNew) // kilocode_change yield* ctx.ask({ permission: "edit", - patterns: [path.relative(Instance.worktree, filePath)], + patterns: [path.relative(instance.worktree, filePath)], always: ["*"], metadata: { filepath: filePath, @@ -176,7 +177,7 @@ export const EditTool = Tool.define( cachedFilediff = buildFileDiff(filePath, contentOld, contentNew) // kilocode_change yield* ctx.ask({ permission: "edit", - patterns: [path.relative(Instance.worktree, filePath)], + patterns: [path.relative(instance.worktree, filePath)], always: ["*"], metadata: { filepath: filePath, @@ -229,7 +230,7 @@ export const EditTool = Tool.define( diff, filediff, // kilocode_change }, - title: `${path.relative(Instance.worktree, filePath)}`, + title: `${path.relative(instance.worktree, filePath)}`, output, } }), diff --git a/packages/opencode/src/tool/lsp.ts b/packages/opencode/src/tool/lsp.ts index 3a555c2ce8..6f1532ca0c 100644 --- a/packages/opencode/src/tool/lsp.ts +++ b/packages/opencode/src/tool/lsp.ts @@ -3,7 +3,7 @@ import * as Tool from "./tool" import path from "path" import { LSP } from "@/lsp/lsp" import DESCRIPTION from "./lsp.txt" -import { Instance } from "../project/instance" +import { InstanceState } from "@/effect/instance-state" import { pathToFileURL } from "url" import { assertExternalDirectoryEffect } from "./external-directory" import { AppFileSystem } from "@opencode-ai/core/filesystem" @@ -44,7 +44,8 @@ export const LspTool = Tool.define( parameters: Parameters, execute: (args: Schema.Schema.Type, ctx: Tool.Context) => Effect.gen(function* () { - const file = path.isAbsolute(args.filePath) ? args.filePath : path.join(Instance.directory, args.filePath) + const instance = yield* InstanceState.context + const file = path.isAbsolute(args.filePath) ? args.filePath : path.join(instance.directory, args.filePath) yield* assertExternalDirectoryEffect(ctx, file) const meta = args.operation === "workspaceSymbol" @@ -61,7 +62,7 @@ export const LspTool = Tool.define( const uri = pathToFileURL(file).href const position = { file, line: args.line - 1, character: args.character - 1 } - const relPath = path.relative(Instance.worktree, file) + const relPath = path.relative(instance.worktree, file) const detail = args.operation === "workspaceSymbol" ? "" diff --git a/packages/opencode/src/tool/plan.ts b/packages/opencode/src/tool/plan.ts index 98c9b06e68..5ea70ef87c 100644 --- a/packages/opencode/src/tool/plan.ts +++ b/packages/opencode/src/tool/plan.ts @@ -2,7 +2,7 @@ import path from "path" import { Effect, Schema } from "effect" import * as Tool from "./tool" import { Session } from "@/session/session" -import { Instance } from "../project/instance" +import { InstanceState } from "@/effect/instance-state" import EXIT_DESCRIPTION from "./plan-exit.txt" export const Parameters = Schema.Struct({}) @@ -18,8 +18,9 @@ export const PlanExitTool = Tool.define( parameters: Parameters, execute: (_params: {}, ctx: Tool.Context) => Effect.gen(function* () { + const instance = yield* InstanceState.context const info = yield* session.get(ctx.sessionID) - const plan = path.relative(Instance.worktree, Session.plan(info)) + const plan = path.relative(instance.worktree, Session.plan(info, instance)) return { title: "Planning complete", output: `Plan is ready at ${plan}. Ending planning turn.`, diff --git a/packages/opencode/src/tool/read.ts b/packages/opencode/src/tool/read.ts index ab68d4b29e..b2b8d643da 100644 --- a/packages/opencode/src/tool/read.ts +++ b/packages/opencode/src/tool/read.ts @@ -9,10 +9,10 @@ import * as Tool from "./tool" import { AppFileSystem } from "@opencode-ai/core/filesystem" import { LSP } from "@/lsp/lsp" import DESCRIPTION from "./read.txt" -import { Instance } from "../project/instance" +import { InstanceState } from "@/effect/instance-state" import { assertExternalDirectoryEffect } from "./external-directory" import { Instruction } from "../session/instruction" -import { isImageAttachment, isPdfAttachment, sniffAttachmentMime } from "@/util/media" +import { isPdfAttachment, sniffAttachmentMime } from "@/util/media" // kilocode_change start import * as Encoding from "../kilocode/encoding" // kilocode_change end @@ -24,6 +24,7 @@ const MAX_BYTES = 50 * 1024 const MAX_BYTES_LABEL = `${MAX_BYTES / 1024} KB` const SAMPLE_BYTES = 4096 const DIRECTORY_CONCURRENCY = 8 // kilocode_change +const SUPPORTED_IMAGE_MIMES = new Set(["image/jpeg", "image/png", "image/gif", "image/webp"]) // `offset` and `limit` were originally `z.coerce.number()` — the runtime // coercion was useful when the tool was called from a shell but serves no @@ -165,7 +166,11 @@ export const ReadTool = Tool.define( filepath: string content: string } - const readDirectoryFiles = Effect.fn("ReadTool.readDirectoryFiles")(function* (filepath: string, items: string[]) { + const readDirectoryFiles = Effect.fn("ReadTool.readDirectoryFiles")(function* ( + filepath: string, + items: string[], + directory: string, + ) { const entries = yield* fs.readDirectoryEntries(filepath).pipe(Effect.catch(() => Effect.succeed([]))) const types = new Map(entries.map((entry) => [entry.name, entry.type])) const files = yield* Effect.forEach( @@ -182,7 +187,7 @@ export const ReadTool = Tool.define( Effect.catch(() => Effect.void), ) if (!file) return - const rel = path.relative(Instance.directory, child).replaceAll("\\", "/") + const rel = path.relative(directory, child).replaceAll("\\", "/") const note = file.cut || file.more ? "\n\n(File truncated)" : "" return { filepath: child, @@ -199,18 +204,15 @@ export const ReadTool = Tool.define( params: Schema.Schema.Type, ctx: Tool.Context, ) { - if (params.offset !== undefined && params.offset < 1) { - return yield* Effect.fail(new Error("offset must be greater than or equal to 1")) - } - + const instance = yield* InstanceState.context let filepath = params.filePath if (!path.isAbsolute(filepath)) { - filepath = path.resolve(Instance.directory, filepath) + filepath = path.resolve(instance.directory, filepath) } if (process.platform === "win32") { filepath = AppFileSystem.normalizePath(filepath) } - const title = path.relative(Instance.worktree, filepath) + const title = path.relative(instance.worktree, filepath) const stat = yield* fs.stat(filepath).pipe( Effect.catchIf( @@ -236,13 +238,13 @@ export const ReadTool = Tool.define( if (stat.type === "Directory") { const items = yield* list(filepath) const limit = params.limit ?? DEFAULT_READ_LIMIT - const offset = params.offset ?? 1 + const offset = params.offset || 1 const start = offset - 1 const sliced = items.slice(start, start + limit) const truncated = start + sliced.length < items.length // kilocode_change start const expand = Boolean(ctx.extra?.["includeDirectoryFiles"]) - const loaded = expand ? yield* readDirectoryFiles(filepath, sliced) : [] + const loaded = expand ? yield* readDirectoryFiles(filepath, sliced, instance.directory) : [] const content = loaded.map((item) => item.content).join("\n\n") // kilocode_change end @@ -275,7 +277,9 @@ export const ReadTool = Tool.define( const sample = yield* readSample(filepath, Number(stat.size), SAMPLE_BYTES) const mime = sniffAttachmentMime(sample, AppFileSystem.mimeType(filepath)) - if (isImageAttachment(mime) || isPdfAttachment(mime)) { + const isImage = SUPPORTED_IMAGE_MIMES.has(mime) + + if (isImage || isPdfAttachment(mime)) { const bytes = yield* fs.readFile(filepath) const msg = isPdfAttachment(mime) ? "PDF read successfully" : "Image read successfully" return { @@ -301,7 +305,7 @@ export const ReadTool = Tool.define( } const file = yield* Effect.promise(() => - lines(filepath, { limit: params.limit ?? DEFAULT_READ_LIMIT, offset: params.offset ?? 1 }), + lines(filepath, { limit: params.limit ?? DEFAULT_READ_LIMIT, offset: params.offset || 1 }), ) if (file.count < file.offset && !(file.count === 0 && file.offset === 1)) { return yield* Effect.fail( diff --git a/packages/opencode/src/tool/registry.ts b/packages/opencode/src/tool/registry.ts index 64059afda7..de97549b85 100644 --- a/packages/opencode/src/tool/registry.ts +++ b/packages/opencode/src/tool/registry.ts @@ -159,7 +159,16 @@ export const layer: Layer.Layer< ...(out.truncated && { outputPath: out.outputPath }), }, } - }), + }).pipe( + Effect.withSpan("Tool.execute", { + attributes: { + "tool.name": id, + "session.id": toolCtx.sessionID, + "message.id": toolCtx.messageID, + ...(toolCtx.callID ? { "tool.call_id": toolCtx.callID } : {}), + }, + }), + ), } } diff --git a/packages/opencode/src/tool/task.ts b/packages/opencode/src/tool/task.ts index c4652fe700..62ed942e82 100644 --- a/packages/opencode/src/tool/task.ts +++ b/packages/opencode/src/tool/task.ts @@ -65,9 +65,9 @@ export const TaskTool = Tool.define( const canTask = next.permission.some((rule) => rule.permission === id) const canTodo = next.permission.some((rule) => rule.permission === "todowrite") + const parent = yield* sessions.get(ctx.sessionID) // kilocode_change start — inherit edit/bash/MCP restrictions from calling agent const caller = yield* agent.get(ctx.agent) - const parent = yield* Effect.promise(() => Session.get(SessionID.make(ctx.sessionID))) const rules = KiloTask.inherited({ caller, session: parent, mcp: cfg.mcp }) // kilocode_change end @@ -81,6 +81,9 @@ export const TaskTool = Tool.define( parentID: ctx.sessionID, title: params.description + ` (@${next.name} subagent)`, permission: [ + ...(parent.permission ?? []).filter( + (rule) => rule.permission === "external_directory" || rule.action === "deny", + ), ...(canTodo ? [] : [ diff --git a/packages/opencode/src/tool/write.ts b/packages/opencode/src/tool/write.ts index 59575bfa71..343586b0f6 100644 --- a/packages/opencode/src/tool/write.ts +++ b/packages/opencode/src/tool/write.ts @@ -10,7 +10,7 @@ import { File } from "../file" import { FileWatcher } from "../file/watcher" import { Format } from "../format" import { AppFileSystem } from "@opencode-ai/core/filesystem" -import { Instance } from "../project/instance" +import { InstanceState } from "@/effect/instance-state" import { trimDiff, buildFileDiff } from "./edit" // kilocode_change import { assertExternalDirectoryEffect } from "./external-directory" import { filterDiagnostics } from "./diagnostics" // kilocode_change @@ -40,9 +40,10 @@ export const WriteTool = Tool.define( parameters: Parameters, execute: (params: { content: string; filePath: string }, ctx: Tool.Context) => Effect.gen(function* () { + const instance = yield* InstanceState.context const filepath = path.isAbsolute(params.filePath) ? params.filePath - : path.join(Instance.directory, params.filePath) + : path.join(instance.directory, params.filePath) yield* assertExternalDirectoryEffect(ctx, filepath) const exists = yield* fs.existsSafe(filepath) @@ -60,7 +61,7 @@ export const WriteTool = Tool.define( const filediff = buildFileDiff(filepath, contentOld, contentNew) // kilocode_change yield* ctx.ask({ permission: "edit", - patterns: [path.relative(Instance.worktree, filepath)], + patterns: [path.relative(instance.worktree, filepath)], always: ["*"], metadata: { filepath, @@ -99,7 +100,7 @@ export const WriteTool = Tool.define( output += yield* Effect.promise(() => ConfigValidation.check(filepath)) // kilocode_change return { - title: path.relative(Instance.worktree, filepath), + title: path.relative(instance.worktree, filepath), metadata: { diagnostics: filterDiagnostics(diagnostics, [normalizedFilepath]), // kilocode_change filepath, diff --git a/packages/opencode/src/worktree/index.ts b/packages/opencode/src/worktree/index.ts index a0789246fc..25ec5747f4 100644 --- a/packages/opencode/src/worktree/index.ts +++ b/packages/opencode/src/worktree/index.ts @@ -2,7 +2,6 @@ import z from "zod" import { NamedError } from "@opencode-ai/core/util/error" import { Global } from "@opencode-ai/core/global" import { Instance } from "../project/instance" -import { InstanceBootstrap } from "../project/bootstrap" import { Project } from "@/project/project" import { Database } from "@/storage/db" import { eq } from "drizzle-orm" @@ -256,7 +255,6 @@ export const layer: Layer.Layer< const booted = yield* Effect.promise(() => Instance.provide({ directory: info.directory, - init: () => BootstrapRuntime.runPromise(InstanceBootstrap), fn: () => undefined, }) .then(() => true) diff --git a/packages/opencode/test/agent/agent.test.ts b/packages/opencode/test/agent/agent.test.ts index 82d8596289..8209ca1c5f 100644 --- a/packages/opencode/test/agent/agent.test.ts +++ b/packages/opencode/test/agent/agent.test.ts @@ -1,10 +1,11 @@ import { afterEach, test, expect } from "bun:test" import { Effect } from "effect" import path from "path" -import { provideInstance, tmpdir } from "../fixture/fixture" +import { disposeAllInstances, provideInstance, tmpdir } from "../fixture/fixture" import { Instance } from "../../src/project/instance" import { Agent } from "../../src/agent/agent" import { Permission } from "../../src/permission" +import { Global } from "@opencode-ai/core/global" // Helper to evaluate permission for a tool with wildcard pattern function evalPerm(agent: Agent.Info | undefined, permission: string): Permission.Action | undefined { @@ -17,7 +18,7 @@ function load(dir: string, fn: (svc: Agent.Interface) => Effect.Effect) { } afterEach(async () => { - await Instance.disposeAll() + await disposeAllInstances() }) test("returns default native agents when no config", async () => { @@ -170,7 +171,7 @@ test("explore agent denies edit and write", async () => { }) }) -test("explore agent asks for external directories and allows Truncate.GLOB", async () => { +test("explore agent asks for external directories and allows whitelisted external paths", async () => { const { Truncate } = await import("../../src/tool/truncate") await using tmp = await tmpdir() await Instance.provide({ @@ -180,6 +181,9 @@ test("explore agent asks for external directories and allows Truncate.GLOB", asy expect(explore).toBeDefined() expect(Permission.evaluate("external_directory", "/some/other/path", explore!.permission).action).toBe("ask") expect(Permission.evaluate("external_directory", Truncate.GLOB, explore!.permission).action).toBe("allow") + expect( + Permission.evaluate("external_directory", path.join(Global.Path.tmp, "agent-work"), explore!.permission).action, + ).toBe("allow") }, }) }) @@ -638,6 +642,20 @@ test("Truncate.GLOB is allowed even when user denies external_directory globally }) }) +test("global tmp directory children are allowed for external_directory", async () => { + await using tmp = await tmpdir() + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const build = await load(tmp.path, (svc) => svc.get("build")) + expect( + Permission.evaluate("external_directory", path.join(Global.Path.tmp, "scratch"), build!.permission).action, + ).toBe("allow") + expect(Permission.evaluate("external_directory", "/some/other/path", build!.permission).action).toBe("ask") + }, + }) +}) + test("Truncate.GLOB is allowed even when user denies external_directory per-agent", async () => { const { Truncate } = await import("../../src/tool/truncate") await using tmp = await tmpdir({ diff --git a/packages/opencode/test/bus/bus-effect.test.ts b/packages/opencode/test/bus/bus-effect.test.ts index 0daf8fe6a6..101d3be72b 100644 --- a/packages/opencode/test/bus/bus-effect.test.ts +++ b/packages/opencode/test/bus/bus-effect.test.ts @@ -4,7 +4,7 @@ import { Bus } from "../../src/bus" import { BusEvent } from "../../src/bus/bus-event" import { Instance } from "../../src/project/instance" import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" -import { provideInstance, provideTmpdirInstance, tmpdirScoped } from "../fixture/fixture" +import { disposeAllInstances, provideInstance, provideTmpdirInstance, tmpdirScoped } from "../fixture/fixture" import { testEffect } from "../lib/effect" const TestEvent = { @@ -151,7 +151,7 @@ describe("Bus (Effect-native)", () => { }).pipe(provideInstance(dir)) // Dispose from OUTSIDE the instance scope - yield* Effect.promise(() => Instance.disposeAll()) + yield* Effect.promise(disposeAllInstances) yield* Deferred.await(disposed).pipe(Effect.timeout("2 seconds")) expect(types).toContain("test.effect.ping") diff --git a/packages/opencode/test/bus/bus-integration.test.ts b/packages/opencode/test/bus/bus-integration.test.ts index 2808344577..7e2138ea81 100644 --- a/packages/opencode/test/bus/bus-integration.test.ts +++ b/packages/opencode/test/bus/bus-integration.test.ts @@ -3,7 +3,7 @@ import { Schema } from "effect" import { Bus } from "../../src/bus" import { BusEvent } from "../../src/bus/bus-event" import { Instance } from "../../src/project/instance" -import { tmpdir } from "../fixture/fixture" +import { disposeAllInstances, tmpdir } from "../fixture/fixture" const TestEvent = BusEvent.define("test.integration", Schema.Struct({ value: Schema.Number })) @@ -12,7 +12,7 @@ function withInstance(directory: string, fn: () => Promise) { } describe("Bus integration: acquireRelease subscriber pattern", () => { - afterEach(() => Instance.disposeAll()) + afterEach(() => disposeAllInstances()) test("subscriber via callback facade receives events and cleans up on unsub", async () => { await using tmp = await tmpdir() @@ -78,7 +78,7 @@ describe("Bus integration: acquireRelease subscriber pattern", () => { await Bun.sleep(10) }) - await Instance.disposeAll() + await disposeAllInstances() await Bun.sleep(50) expect(received).toEqual([1]) diff --git a/packages/opencode/test/bus/bus.test.ts b/packages/opencode/test/bus/bus.test.ts index cdacdd5179..b24b79b33b 100644 --- a/packages/opencode/test/bus/bus.test.ts +++ b/packages/opencode/test/bus/bus.test.ts @@ -3,7 +3,7 @@ import { Schema } from "effect" import { Bus } from "../../src/bus" import { BusEvent } from "../../src/bus/bus-event" import { Instance } from "../../src/project/instance" -import { tmpdir } from "../fixture/fixture" +import { disposeAllInstances, tmpdir } from "../fixture/fixture" const TestEvent = { Ping: BusEvent.define("test.ping", Schema.Struct({ value: Schema.Number })), @@ -15,7 +15,7 @@ function withInstance(directory: string, fn: () => Promise) { } describe("Bus", () => { - afterEach(() => Instance.disposeAll()) + afterEach(() => disposeAllInstances()) describe("publish + subscribe", () => { test("subscriber is live immediately after subscribe returns", async () => { @@ -208,8 +208,8 @@ describe("Bus", () => { await Bun.sleep(10) }) - // Instance.disposeAll triggers the finalizer which publishes InstanceDisposed - await Instance.disposeAll() + // disposeAllInstances triggers the finalizer which publishes InstanceDisposed + await disposeAllInstances() await Bun.sleep(50) expect(received).toContain("test.ping") diff --git a/packages/opencode/test/cli/cmd/tui/prompt-traits.test.ts b/packages/opencode/test/cli/cmd/tui/prompt-traits.test.ts new file mode 100644 index 0000000000..34a16aedd6 --- /dev/null +++ b/packages/opencode/test/cli/cmd/tui/prompt-traits.test.ts @@ -0,0 +1,38 @@ +import { describe, expect, test } from "bun:test" +import { computePromptTraits } from "../../../../src/cli/cmd/tui/component/prompt/traits" + +describe("computePromptTraits", () => { + test("normal mode without autocomplete only captures tab", () => { + const traits = computePromptTraits({ mode: "normal", disabled: false, autocompleteVisible: false }) + expect(traits.capture).toEqual(["tab"]) + expect(traits.suspend).toBe(false) + expect(traits.status).toBeUndefined() + }) + + test("normal mode with autocomplete captures navigation keys", () => { + const traits = computePromptTraits({ mode: "normal", disabled: false, autocompleteVisible: true }) + expect(traits.capture).toEqual(["escape", "navigate", "submit", "tab"]) + expect(traits.suspend).toBe(false) + expect(traits.status).toBeUndefined() + }) + + test("shell mode does not suspend the textarea", () => { + // Suspending the textarea would gate every keybinding action + // (backspace, delete-word-backward, arrow movement, etc.) — see + // @opentui/core 0.2.x TextareaRenderable.handleKeyPress. Shell mode is + // an active editing mode, so suspend must stay off. + const traits = computePromptTraits({ mode: "shell", disabled: false, autocompleteVisible: false }) + expect(traits.suspend).toBe(false) + }) + + test("shell mode disables capture and labels the prompt", () => { + const traits = computePromptTraits({ mode: "shell", disabled: false, autocompleteVisible: false }) + expect(traits.capture).toBeUndefined() + expect(traits.status).toBe("SHELL") + }) + + test("disabled suspends regardless of mode", () => { + expect(computePromptTraits({ mode: "normal", disabled: true, autocompleteVisible: false }).suspend).toBe(true) + expect(computePromptTraits({ mode: "shell", disabled: true, autocompleteVisible: false }).suspend).toBe(true) + }) +}) diff --git a/packages/opencode/test/cli/cmd/tui/sync.test.tsx b/packages/opencode/test/cli/cmd/tui/sync.test.tsx index 0c9d91a072..69558a9c45 100644 --- a/packages/opencode/test/cli/cmd/tui/sync.test.tsx +++ b/packages/opencode/test/cli/cmd/tui/sync.test.tsx @@ -11,7 +11,7 @@ import { SDKProvider, type EventSource } from "../../../../src/cli/cmd/tui/conte import { SyncProvider, useSync } from "../../../../src/cli/cmd/tui/context/sync" import { ToastProvider } from "../../../../src/cli/cmd/tui/ui/toast" // kilocode_change import { Instance } from "../../../../src/project/instance" // kilocode_change -import { tmpdir } from "../../../fixture/fixture" +import { disposeAllInstances, tmpdir } from "../../../fixture/fixture" const worktree = "/tmp/opencode" const directory = `${worktree}/packages/opencode` @@ -151,7 +151,7 @@ describe("tui sync", () => { expect(session.at(-1)?.searchParams.get("path")).toBeNull() } finally { app.renderer.destroy() - await Instance.disposeAll() // kilocode_change + await disposeAllInstances() // kilocode_change Global.Path.state = previous } }) diff --git a/packages/opencode/test/cli/tui/editor-context-zed.test.ts b/packages/opencode/test/cli/tui/editor-context-zed.test.ts index 4c5491461e..5238d2859c 100644 --- a/packages/opencode/test/cli/tui/editor-context-zed.test.ts +++ b/packages/opencode/test/cli/tui/editor-context-zed.test.ts @@ -1,7 +1,9 @@ import { Database } from "bun:sqlite" +import { mkdir, symlink } from "node:fs/promises" +import os from "node:os" import path from "node:path" -import { expect, test } from "bun:test" -import { offsetToPosition, resolveZedSelection } from "../../../src/cli/cmd/tui/context/editor-zed" +import { expect, spyOn, test } from "bun:test" +import { offsetToPosition, resolveZedDbPath, resolveZedSelection } from "../../../src/cli/cmd/tui/context/editor-zed" import { tmpdir } from "../../fixture/fixture" type ZedFixtureOptions = { @@ -10,6 +12,7 @@ type ZedFixtureOptions = { editor?: boolean selectionStart?: number | null selectionEnd?: number | null + selections?: Array<{ start: number | null; end: number | null }> contents?: string } @@ -30,10 +33,16 @@ async function writeZedFixture(dir: string, options: ZedFixtureOptions = {}) { db.run("insert into items values (1, 1, 1, 1, ?)", [options.itemKind ?? "Editor"]) if (options.editor !== false) { db.run("insert into editors values (1, 1, ?, ?)", [filePath, contents]) - db.run("insert into editor_selections values (1, 1, ?, ?)", [ - options.selectionStart === undefined ? 4 : options.selectionStart, - options.selectionEnd === undefined ? 7 : options.selectionEnd, - ]) + ;( + options.selections ?? [ + { + start: options.selectionStart === undefined ? 4 : options.selectionStart, + end: options.selectionEnd === undefined ? 7 : options.selectionEnd, + }, + ] + ).forEach((selection) => + db.run("insert into editor_selections values (1, 1, ?, ?)", [selection.start, selection.end]), + ) } db.close() @@ -59,6 +68,23 @@ test("offsetToPosition converts Zed offsets to 1-based editor positions", () => }) }) +test("resolveZedDbPath skips candidates that cannot be stated", async () => { + await using tmp = await tmpdir() + const loop = path.join(tmp.path, "loop") + await symlink(loop, loop) + const home = spyOn(os, "homedir").mockImplementation(() => tmp.path) + const previous = process.env.KILO_ZED_DB + process.env.KILO_ZED_DB = loop + + try { + expect(resolveZedDbPath()).toBeUndefined() + } finally { + if (previous === undefined) delete process.env.KILO_ZED_DB + else process.env.KILO_ZED_DB = previous + home.mockRestore() + } +}) + test("resolveZedSelection returns active editor selection", async () => { await using tmp = await tmpdir() const fixture = await writeZedFixture(tmp.path) @@ -66,13 +92,59 @@ test("resolveZedSelection returns active editor selection", async () => { expect(await resolveZedSelection(fixture.dbPath, tmp.path)).toEqual({ type: "selection", selection: { - text: "two", filePath: fixture.filePath, source: "zed", - selection: { - start: { line: 2, character: 1 }, - end: { line: 2, character: 4 }, + ranges: [ + { + text: "two", + selection: { + start: { line: 2, character: 1 }, + end: { line: 2, character: 4 }, + }, + }, + ], + }, + }) +}) + +test("resolveZedSelection returns all active editor selections sorted by offset", async () => { + await using tmp = await tmpdir() + const contents = "one\ntwo\nthree\nfour" + const fixture = await writeZedFixture(tmp.path, { + contents, + selections: [ + { + start: utf8ByteOffset(contents, contents.indexOf("four")), + end: utf8ByteOffset(contents, contents.indexOf("four") + 4), }, + { + start: utf8ByteOffset(contents, contents.indexOf("two")), + end: utf8ByteOffset(contents, contents.indexOf("two") + 3), + }, + ], + }) + + expect(await resolveZedSelection(fixture.dbPath, tmp.path)).toEqual({ + type: "selection", + selection: { + filePath: fixture.filePath, + source: "zed", + ranges: [ + { + text: "two", + selection: { + start: { line: 2, character: 1 }, + end: { line: 2, character: 4 }, + }, + }, + { + text: "four", + selection: { + start: { line: 4, character: 1 }, + end: { line: 4, character: 5 }, + }, + }, + ], }, }) }) @@ -90,13 +162,17 @@ test("resolveZedSelection converts Zed UTF-8 byte offsets to string offsets", as expect(await resolveZedSelection(fixture.dbPath, tmp.path)).toEqual({ type: "selection", selection: { - text: "TARGET", filePath: fixture.filePath, source: "zed", - selection: { - start: { line: 4, character: 1 }, - end: { line: 4, character: 7 }, - }, + ranges: [ + { + text: "TARGET", + selection: { + start: { line: 4, character: 1 }, + end: { line: 4, character: 7 }, + }, + }, + ], }, }) }) @@ -114,13 +190,17 @@ test("resolveZedSelection handles non-ASCII text inside the selected range", asy expect(await resolveZedSelection(fixture.dbPath, tmp.path)).toEqual({ type: "selection", selection: { - text: "выбор", filePath: fixture.filePath, source: "zed", - selection: { - start: { line: 3, character: 1 }, - end: { line: 3, character: 6 }, - }, + ranges: [ + { + text: "выбор", + selection: { + start: { line: 3, character: 1 }, + end: { line: 3, character: 6 }, + }, + }, + ], }, }) }) @@ -138,13 +218,17 @@ test("resolveZedSelection handles emoji before the selected range", async () => expect(await resolveZedSelection(fixture.dbPath, tmp.path)).toEqual({ type: "selection", selection: { - text: "TARGET", filePath: fixture.filePath, source: "zed", - selection: { - start: { line: 2, character: 1 }, - end: { line: 2, character: 7 }, - }, + ranges: [ + { + text: "TARGET", + selection: { + start: { line: 2, character: 1 }, + end: { line: 2, character: 7 }, + }, + }, + ], }, }) }) @@ -162,13 +246,17 @@ test("resolveZedSelection handles reversed Zed byte offsets", async () => { expect(await resolveZedSelection(fixture.dbPath, tmp.path)).toEqual({ type: "selection", selection: { - text: "TARGET", filePath: fixture.filePath, source: "zed", - selection: { - start: { line: 3, character: 1 }, - end: { line: 3, character: 7 }, - }, + ranges: [ + { + text: "TARGET", + selection: { + start: { line: 3, character: 1 }, + end: { line: 3, character: 7 }, + }, + }, + ], }, }) }) @@ -182,6 +270,71 @@ test("resolveZedSelection returns empty when no workspace matches", async () => expect(await resolveZedSelection(fixture.dbPath, tmp.path)).toEqual({ type: "empty" }) }) +test("resolveZedSelection matches a Zed workspace that contains the session directory", async () => { + await using tmp = await tmpdir() + const fixture = await writeZedFixture(tmp.path) + + expect(await resolveZedSelection(fixture.dbPath, path.join(tmp.path, "packages", "app"))).toEqual({ + type: "selection", + selection: { + filePath: fixture.filePath, + source: "zed", + ranges: [ + { + text: "two", + selection: { + start: { line: 2, character: 1 }, + end: { line: 2, character: 4 }, + }, + }, + ], + }, + }) +}) + +test("resolveZedSelection prefers the most specific containing Zed workspace", async () => { + await using tmp = await tmpdir() + const fixture = await writeZedFixture(tmp.path) + const child = path.join(tmp.path, "packages") + const childFile = path.join(child, "child.ts") + await mkdir(child, { recursive: true }) + await Bun.write(childFile, "child") + + const db = new Database(fixture.dbPath) + db.run("insert into workspaces values (2, ?, ?)", [JSON.stringify([child]), "2026-01-01"]) + db.run("insert into panes values (2, 2, 1)") + db.run("insert into items values (2, 2, 2, 1, ?)", ["Editor"]) + db.run("insert into editors values (2, 2, ?, ?)", [childFile, "child"]) + db.run("insert into editor_selections values (2, 2, 0, 5)") + db.close() + + expect(await resolveZedSelection(fixture.dbPath, path.join(child, "app"))).toEqual({ + type: "selection", + selection: { + filePath: childFile, + source: "zed", + ranges: [ + { + text: "child", + selection: { + start: { line: 1, character: 1 }, + end: { line: 1, character: 6 }, + }, + }, + ], + }, + }) +}) + +test("resolveZedSelection ignores a Zed workspace nested inside the session directory", async () => { + await using tmp = await tmpdir() + const child = path.join(tmp.path, "effect-lab") + await mkdir(child, { recursive: true }) + const fixture = await writeZedFixture(child) + + expect(await resolveZedSelection(fixture.dbPath, tmp.path)).toEqual({ type: "empty" }) +}) + test("resolveZedSelection returns unavailable when a Zed terminal is active", async () => { await using tmp = await tmpdir() const fixture = await writeZedFixture(tmp.path, { itemKind: "Terminal", editor: false }) diff --git a/packages/opencode/test/cli/tui/editor-context.test.tsx b/packages/opencode/test/cli/tui/editor-context.test.tsx index 6202a9f30a..881c8db11b 100644 --- a/packages/opencode/test/cli/tui/editor-context.test.tsx +++ b/packages/opencode/test/cli/tui/editor-context.test.tsx @@ -190,13 +190,17 @@ test("useEditorContext resets selection when reconnecting", async () => { serverInfo: { name: "test", version: "0.0.0" }, }) expect(mounted.editor.selection()).toEqual({ - text: "foo", filePath: path.join(startupDirectory, "file.ts"), source: "websocket", - selection: { - start: { line: 1, character: 1 }, - end: { line: 1, character: 4 }, - }, + ranges: [ + { + text: "foo", + selection: { + start: { line: 1, character: 1 }, + end: { line: 1, character: 4 }, + }, + }, + ], }) mounted.editor.reconnect(startupDirectory) diff --git a/packages/opencode/test/cli/tui/thread.test.ts b/packages/opencode/test/cli/tui/thread.test.ts index 35e26e6830..53b7488c26 100644 --- a/packages/opencode/test/cli/tui/thread.test.ts +++ b/packages/opencode/test/cli/tui/thread.test.ts @@ -1,122 +1,28 @@ -import { afterEach, describe, expect, mock, spyOn, test } from "bun:test" +import { describe, expect, test } from "bun:test" import fs from "fs/promises" import path from "path" import { tmpdir } from "../../fixture/fixture" -import * as App from "../../../src/cli/cmd/tui/app" -import { Rpc } from "@/util/rpc" -import { UI } from "../../../src/cli/ui" -import * as Timeout from "../../../src/util/timeout" -import * as Network from "../../../src/cli/network" -import * as Win32 from "../../../src/cli/cmd/tui/win32" -import { TuiConfig } from "../../../src/cli/cmd/tui/config/tui" - -const stop = new Error("stop") -const seen = { - tui: [] as string[], -} - -function setup() { - // Intentionally avoid mock.module() here: Bun keeps module overrides in cache - // and mock.restore() does not reset mock.module values. If this switches back - // to module mocks, later suites can see mocked @/config/tui and fail (e.g. - // plugin-loader tests expecting real TuiConfig.waitForDependencies). See: - // https://github.com/oven-sh/bun/issues/7823 and #12823. - spyOn(App, "tui").mockImplementation(async (input) => { - if (input.directory) seen.tui.push(input.directory) - throw stop - }) - spyOn(Rpc, "client").mockImplementation(() => ({ - call: async () => ({ url: "http://127.0.0.1" }) as never, - on: () => () => {}, - })) - spyOn(UI, "error").mockImplementation(() => {}) - spyOn(Timeout, "withTimeout").mockImplementation((input) => input) - spyOn(Network, "resolveNetworkOptions").mockResolvedValue({ - mdns: false, - port: 0, - hostname: "127.0.0.1", - mdnsDomain: "opencode.local", - cors: [], - }) - spyOn(Win32, "win32DisableProcessedInput").mockImplementation(() => {}) - spyOn(Win32, "win32InstallCtrlCGuard").mockReturnValue(undefined) -} +import { resolveThreadDirectory } from "../../../src/cli/cmd/tui/thread" describe("tui thread", () => { - afterEach(() => { - mock.restore() - }) - - async function call(project?: string) { - const { TuiThreadCommand } = await import("../../../src/cli/cmd/tui/thread") - const args: Parameters>[0] = { - _: [], - $0: "kilo", // kilocode_change - project, - prompt: "hi", - model: undefined, - agent: undefined, - session: undefined, - continue: false, - fork: false, - "cloud-fork": undefined, // kilocode_change - cloudFork: undefined, // kilocode_change - port: 0, - hostname: "127.0.0.1", - mdns: false, - "mdns-domain": "kilo.local", // kilocode_change - mdnsDomain: "kilo.local", // kilocode_change - cors: [], - } - return TuiThreadCommand.handler(args) - } - async function check(project?: string) { - setup() - const cwd = process.cwd() - const pwd = process.env.PWD - const worker = globalThis.Worker - const tty = Object.getOwnPropertyDescriptor(process.stdin, "isTTY") await using tmp = await tmpdir({ git: true }) const link = path.join(path.dirname(tmp.path), path.basename(tmp.path) + "-link") const type = process.platform === "win32" ? "junction" : "dir" - seen.tui.length = 0 - await fs.symlink(tmp.path, link, type) - - Object.defineProperty(process.stdin, "isTTY", { - configurable: true, - value: true, - }) - globalThis.Worker = class extends EventTarget { - onerror = null - onmessage = null - onmessageerror = null - postMessage() {} - terminate() {} - } as unknown as typeof Worker try { - process.chdir(tmp.path) - process.env.PWD = link - await expect(call(project)).rejects.toBe(stop) - expect(seen.tui[0]).toBe(tmp.path) + await fs.symlink(tmp.path, link, type) + expect(resolveThreadDirectory(project, link, tmp.path)).toBe(tmp.path) } finally { - process.chdir(cwd) - if (pwd === undefined) delete process.env.PWD - else process.env.PWD = pwd - if (tty) Object.defineProperty(process.stdin, "isTTY", tty) - else delete (process.stdin as { isTTY?: boolean }).isTTY - globalThis.Worker = worker await fs.rm(link, { recursive: true, force: true }).catch(() => undefined) } } - // serial because both modify real env vars - test.serial("uses the real cwd when PWD points at a symlink", async () => { + test("uses the real cwd when PWD points at a symlink", async () => { await check() }) - test.serial("uses the real cwd after resolving a relative project from PWD", async () => { + test("uses the real cwd after resolving a relative project from PWD", async () => { await check(".") }) }) diff --git a/packages/opencode/test/config/agent-color.test.ts b/packages/opencode/test/config/agent-color.test.ts index 48dacd3e41..a05275d545 100644 --- a/packages/opencode/test/config/agent-color.test.ts +++ b/packages/opencode/test/config/agent-color.test.ts @@ -1,67 +1,59 @@ import { test, expect } from "bun:test" -import { Effect } from "effect" +import { Effect, Layer } from "effect" +import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" import path from "path" -import { provideInstance, tmpdir } from "../fixture/fixture" -import { Instance } from "../../src/project/instance" +import { provideInstance, tmpdirScoped } from "../fixture/fixture" import { Config } from "@/config/config" import { Agent as AgentSvc } from "../../src/agent/agent" import { Color } from "@/util/color" import { AppRuntime } from "../../src/effect/app-runtime" +import { testEffect } from "../lib/effect" -const load = () => AppRuntime.runPromise(Config.Service.use((svc) => svc.get())) -const agent = (dir: string, fn: (svc: AgentSvc.Interface) => Effect.Effect) => - Effect.runPromise(provideInstance(dir)(AgentSvc.Service.use(fn)).pipe(Effect.provide(AgentSvc.defaultLayer))) +const it = testEffect(Layer.mergeAll(AgentSvc.defaultLayer, CrossSpawnSpawner.defaultLayer)) -test("agent color parsed from project config", async () => { - await using tmp = await tmpdir({ - init: async (dir) => { - await Bun.write( - path.join(dir, "opencode.json"), - JSON.stringify({ - $schema: "https://app.kilo.ai/config.json", - agent: { - code: { color: "#FFA500" }, // kilocode_change - plan: { color: "primary" }, - }, - }), - ) - }, - }) - await Instance.provide({ - directory: tmp.path, - fn: async () => { - const cfg = await Config.get() +const writeConfig = (dir: string, agent: Config.Info["agent"]) => + Effect.promise(() => + Bun.write( + path.join(dir, "opencode.json"), + JSON.stringify({ + $schema: "https://app.kilo.ai/config.json", // kilocode_change + agent, + }), + ), + ) + +it.live("agent color parsed from project config", () => + Effect.gen(function* () { + const dir = yield* tmpdirScoped() + yield* writeConfig(dir, { + code: { color: "#FFA500" }, // kilocode_change + plan: { color: "primary" }, + }) + + yield* Effect.gen(function* () { + const cfg = yield* Effect.promise(() => AppRuntime.runPromise(Config.Service.use((svc) => svc.get()))) expect(cfg.agent?.["code"]?.color).toBe("#FFA500") // kilocode_change expect(cfg.agent?.["plan"]?.color).toBe("primary") - }, - }) -}) + }).pipe(provideInstance(dir)) + }), +) -test("Agent.get includes color from config", async () => { - await using tmp = await tmpdir({ - init: async (dir) => { - await Bun.write( - path.join(dir, "opencode.json"), - JSON.stringify({ - $schema: "https://app.kilo.ai/config.json", - agent: { - plan: { color: "#A855F7" }, - build: { color: "accent" }, - }, - }), - ) - }, - }) - await Instance.provide({ - directory: tmp.path, - fn: async () => { - const plan = await agent(tmp.path, (svc) => svc.get("plan")) +it.live("Agent.get includes color from config", () => + Effect.gen(function* () { + const dir = yield* tmpdirScoped() + yield* writeConfig(dir, { + plan: { color: "#A855F7" }, + build: { color: "accent" }, + }) + + yield* Effect.gen(function* () { + const plan = yield* AgentSvc.Service.use((svc) => svc.get("plan")) expect(plan?.color).toBe("#A855F7") - const build = await agent(tmp.path, (svc) => svc.get("build")) + const build = yield* AgentSvc.Service.use((svc) => svc.get("build")) expect(build?.color).toBe("accent") - }, - }) -}) + }).pipe(provideInstance(dir)) + }), +) test("Color.hexToAnsiBold converts valid hex to ANSI", () => { const result = Color.hexToAnsiBold("#FFA500") diff --git a/packages/opencode/test/config/config.test.ts b/packages/opencode/test/config/config.test.ts index efd9c3aeeb..f180d4c504 100644 --- a/packages/opencode/test/config/config.test.ts +++ b/packages/opencode/test/config/config.test.ts @@ -12,7 +12,7 @@ import { Account } from "../../src/account/account" import { AccessToken, AccountID, OrgID } from "../../src/account/schema" import { AppFileSystem } from "@opencode-ai/core/filesystem" import { Env } from "../../src/env" -import { provideTmpdirInstance } from "../fixture/fixture" +import { disposeAllInstances, provideTmpdirInstance } from "../fixture/fixture" import { tmpdir } from "../fixture/fixture" import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" import { testEffect } from "../lib/effect" @@ -108,7 +108,7 @@ async function check(map: (dir: string) => string) { }, }) } finally { - await Instance.disposeAll() + await disposeAllInstances() ;(Global.Path as { config: string }).config = prev await clear() } diff --git a/packages/opencode/test/control-plane/adaptors.test.ts b/packages/opencode/test/control-plane/adapters.test.ts similarity index 68% rename from packages/opencode/test/control-plane/adaptors.test.ts rename to packages/opencode/test/control-plane/adapters.test.ts index a8e490226b..762bb5d57e 100644 --- a/packages/opencode/test/control-plane/adaptors.test.ts +++ b/packages/opencode/test/control-plane/adapters.test.ts @@ -1,5 +1,5 @@ import { describe, expect, test } from "bun:test" -import { getAdaptor, registerAdaptor } from "../../src/control-plane/adaptors" +import { getAdapter, registerAdapter } from "../../src/control-plane/adapters" import { ProjectID } from "../../src/project/schema" import type { WorkspaceInfo } from "../../src/control-plane/types" @@ -15,7 +15,7 @@ function info(projectID: WorkspaceInfo["projectID"], type: string): WorkspaceInf } } -function adaptor(dir: string) { +function adapter(dir: string) { return { name: dir, description: dir, @@ -33,19 +33,19 @@ function adaptor(dir: string) { } } -describe("control-plane/adaptors", () => { - test("isolates custom adaptors by project", async () => { +describe("control-plane/adapters", () => { + test("isolates custom adapters by project", async () => { const type = `demo-${Math.random().toString(36).slice(2)}` const one = ProjectID.make(`project-${Math.random().toString(36).slice(2)}`) const two = ProjectID.make(`project-${Math.random().toString(36).slice(2)}`) - registerAdaptor(one, type, adaptor("/one")) - registerAdaptor(two, type, adaptor("/two")) + registerAdapter(one, type, adapter("/one")) + registerAdapter(two, type, adapter("/two")) - expect(await (await getAdaptor(one, type)).target(info(one, type))).toEqual({ + expect(await (await getAdapter(one, type)).target(info(one, type))).toEqual({ type: "local", directory: "/one", }) - expect(await (await getAdaptor(two, type)).target(info(two, type))).toEqual({ + expect(await (await getAdapter(two, type)).target(info(two, type))).toEqual({ type: "local", directory: "/two", }) @@ -54,16 +54,16 @@ describe("control-plane/adaptors", () => { test("latest install wins within a project", async () => { const type = `demo-${Math.random().toString(36).slice(2)}` const id = ProjectID.make(`project-${Math.random().toString(36).slice(2)}`) - registerAdaptor(id, type, adaptor("/one")) + registerAdapter(id, type, adapter("/one")) - expect(await (await getAdaptor(id, type)).target(info(id, type))).toEqual({ + expect(await (await getAdapter(id, type)).target(info(id, type))).toEqual({ type: "local", directory: "/one", }) - registerAdaptor(id, type, adaptor("/two")) + registerAdapter(id, type, adapter("/two")) - expect(await (await getAdaptor(id, type)).target(info(id, type))).toEqual({ + expect(await (await getAdapter(id, type)).target(info(id, type))).toEqual({ type: "local", directory: "/two", }) diff --git a/packages/opencode/test/control-plane/sse.test.ts b/packages/opencode/test/control-plane/sse.test.ts deleted file mode 100644 index 78a8341c0e..0000000000 --- a/packages/opencode/test/control-plane/sse.test.ts +++ /dev/null @@ -1,56 +0,0 @@ -import { afterEach, describe, expect, test } from "bun:test" -import { parseSSE } from "../../src/control-plane/sse" -import { resetDatabase } from "../fixture/db" - -afterEach(async () => { - await resetDatabase() -}) - -function stream(chunks: string[]) { - return new ReadableStream({ - start(controller) { - const encoder = new TextEncoder() - chunks.forEach((chunk) => controller.enqueue(encoder.encode(chunk))) - controller.close() - }, - }) -} - -describe("control-plane/sse", () => { - test("parses JSON events with CRLF and multiline data blocks", async () => { - const events: unknown[] = [] - const stop = new AbortController() - - await parseSSE( - stream([ - 'data: {"type":"one","properties":{"ok":true}}\r\n\r\n', - 'data: {"type":"two",\r\ndata: "properties":{"n":2}}\r\n\r\n', - ]), - stop.signal, - (event) => events.push(event), - ) - - expect(events).toEqual([ - { type: "one", properties: { ok: true } }, - { type: "two", properties: { n: 2 } }, - ]) - }) - - test("falls back to sse.message for non-json payload", async () => { - const events: unknown[] = [] - const stop = new AbortController() - - await parseSSE(stream(["id: abc\nretry: 1500\ndata: hello world\n\n"]), stop.signal, (event) => events.push(event)) - - expect(events).toEqual([ - { - type: "sse.message", - properties: { - data: "hello world", - id: "abc", - retry: 1500, - }, - }, - ]) - }) -}) diff --git a/packages/opencode/test/control-plane/workspace.test.ts b/packages/opencode/test/control-plane/workspace.test.ts new file mode 100644 index 0000000000..08656ef63a --- /dev/null +++ b/packages/opencode/test/control-plane/workspace.test.ts @@ -0,0 +1,1526 @@ +import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test" +import fs from "node:fs/promises" +import Http from "node:http" +import path from "node:path" +import { setTimeout as delay } from "node:timers/promises" +import { NodeHttpServer } from "@effect/platform-node" +import { Effect, Layer } from "effect" +import { HttpServer, HttpServerRequest, HttpServerResponse } from "effect/unstable/http" +import { asc, eq } from "drizzle-orm" +import * as Log from "@opencode-ai/core/util/log" +import { Flag } from "@opencode-ai/core/flag/flag" +import { GlobalBus, type GlobalEvent } from "@/bus/global" +import { Database } from "@/storage/db" +import { ProjectID } from "@/project/schema" +import { ProjectTable } from "@/project/project.sql" +import { Instance } from "@/project/instance" +import { Session as SessionNs } from "@/session/session" +import { SessionID, MessageID, PartID } from "@/session/schema" +import { SessionTable } from "@/session/session.sql" +import { ModelID, ProviderID } from "@/provider/schema" +import { SyncEvent } from "@/sync" +import { EventSequenceTable, EventTable } from "@/sync/event.sql" +import { resetDatabase } from "../fixture/db" +import { disposeAllInstances, provideTmpdirInstance, tmpdir } from "../fixture/fixture" +import { testEffect } from "../lib/effect" +import { registerAdapter } from "../../src/control-plane/adapters" +import { WorkspaceID } from "../../src/control-plane/schema" +import { WorkspaceTable } from "../../src/control-plane/workspace.sql" +import type { Target, WorkspaceAdapter, WorkspaceInfo } from "../../src/control-plane/types" +import * as WorkspaceOld from "../../src/control-plane/workspace" +import { AppRuntime } from "@/effect/app-runtime" + +void Log.init({ print: false }) + +const testServerLayer = Layer.mergeAll( + NodeHttpServer.layer(Http.createServer, { host: "127.0.0.1", port: 0 }), + WorkspaceOld.defaultLayer, + SessionNs.defaultLayer, +) +const it = testEffect(testServerLayer) + +const originalWorkspacesFlag = Flag.KILO_EXPERIMENTAL_WORKSPACES +const originalEnv = { + KILO_AUTH_CONTENT: process.env.KILO_AUTH_CONTENT, + OTEL_EXPORTER_OTLP_HEADERS: process.env.OTEL_EXPORTER_OTLP_HEADERS, + OTEL_EXPORTER_OTLP_ENDPOINT: process.env.OTEL_EXPORTER_OTLP_ENDPOINT, + OTEL_RESOURCE_ATTRIBUTES: process.env.OTEL_RESOURCE_ATTRIBUTES, +} + +type RecordedCreate = { + info: WorkspaceInfo + env: Record + from?: WorkspaceInfo +} + +type RecordedAdapter = { + adapter: WorkspaceAdapter + calls: { + configure: WorkspaceInfo[] + create: RecordedCreate[] + remove: WorkspaceInfo[] + target: WorkspaceInfo[] + } +} + +type FetchCall = { + url: URL + method: string + headers: Headers + bodyText?: string + json?: unknown +} + +function unique(prefix: string) { + return `${prefix}-${Math.random().toString(36).slice(2)}` +} + +function restoreEnv() { + Object.entries(originalEnv).forEach(([key, value]) => { + if (value === undefined) { + delete process.env[key] + return + } + process.env[key] = value + }) +} + +beforeEach(() => { + Database.close() + Flag.KILO_EXPERIMENTAL_WORKSPACES = true + restoreEnv() +}) + +afterEach(async () => { + mock.restore() + await disposeAllInstances() + Flag.KILO_EXPERIMENTAL_WORKSPACES = originalWorkspacesFlag + restoreEnv() + await resetDatabase() +}) + +async function withInstance(fn: (dir: string) => T | Promise) { + await using tmp = await tmpdir({ git: true }) + return Instance.provide({ + directory: tmp.path, + fn: () => fn(tmp.path), + }) +} + +const runWorkspace = (effect: Effect.Effect) => AppRuntime.runPromise(effect) +const createWorkspace = (input: WorkspaceOld.CreateInput) => + runWorkspace(WorkspaceOld.Service.use((workspace) => workspace.create(input))) +const restoreWorkspaceSession = (input: WorkspaceOld.SessionRestoreInput) => + runWorkspace(WorkspaceOld.Service.use((workspace) => workspace.sessionRestore(input))) +const listWorkspaces = (project: Parameters[0]) => + runWorkspace(WorkspaceOld.Service.use((workspace) => workspace.list(project))) +const getWorkspace = (id: WorkspaceID) => runWorkspace(WorkspaceOld.Service.use((workspace) => workspace.get(id))) +const removeWorkspace = (id: WorkspaceID) => runWorkspace(WorkspaceOld.Service.use((workspace) => workspace.remove(id))) +const workspaceStatus = () => runWorkspace(WorkspaceOld.Service.use((workspace) => workspace.status())) +const isWorkspaceSyncing = (id: WorkspaceID) => + runWorkspace(WorkspaceOld.Service.use((workspace) => workspace.isSyncing(id))) +const startWorkspaceSyncing = (projectID: ProjectID) => { + void runWorkspace(WorkspaceOld.Service.use((workspace) => workspace.startWorkspaceSyncing(projectID))) +} +const waitForWorkspaceSync = (workspaceID: WorkspaceID, state: Record, signal?: AbortSignal) => + runWorkspace(WorkspaceOld.Service.use((workspace) => workspace.waitForSync(workspaceID, state, signal))) + +function captureGlobalEvents() { + const events: GlobalEvent[] = [] + const handler = (event: GlobalEvent) => events.push(event) + GlobalBus.on("event", handler) + return { + events, + dispose() { + GlobalBus.off("event", handler) + }, + } +} + +async function eventually(fn: () => T | Promise, timeout = 1500) { + const started = Date.now() + let last: unknown + while (Date.now() - started < timeout) { + try { + return await fn() + } catch (err) { + last = err + await delay(10) + } + } + throw last ?? new Error("Timed out waiting for condition") +} + +function eventuallyEffect(effect: Effect.Effect, timeout = 1500) { + return Effect.gen(function* () { + const started = Date.now() + let last: unknown + while (Date.now() - started < timeout) { + const exit = yield* Effect.exit(effect) + if (exit._tag === "Success") return + last = exit.cause + yield* Effect.sleep("10 millis") + } + throw last ?? new Error("Timed out waiting for condition") + }) +} + +function recordedAdapter(input: { + target: (info: WorkspaceInfo) => Target | Promise + configure?: (info: WorkspaceInfo) => WorkspaceInfo | Promise + create?: (info: WorkspaceInfo, env: Record, from?: WorkspaceInfo) => Promise + remove?: (info: WorkspaceInfo) => Promise +}): RecordedAdapter { + const calls: RecordedAdapter["calls"] = { + configure: [], + create: [], + remove: [], + target: [], + } + + return { + calls, + adapter: { + name: "recorded", + description: "recorded", + configure(info) { + calls.configure.push(structuredClone(info)) + return input.configure?.(info) ?? info + }, + async create(info, env, from) { + calls.create.push({ + info: structuredClone(info), + env: { ...env }, + from: from ? structuredClone(from) : undefined, + }) + await input.create?.(info, env, from) + }, + async remove(info) { + calls.remove.push(structuredClone(info)) + await input.remove?.(info) + }, + target(info) { + calls.target.push(structuredClone(info)) + return input.target(info) + }, + }, + } +} + +function localAdapter(dir: string, input?: { createDir?: boolean; remove?: (info: WorkspaceInfo) => Promise }) { + return recordedAdapter({ + configure(info) { + return { ...info, directory: dir } + }, + async create() { + if (input?.createDir === false) return + await fs.mkdir(dir, { recursive: true }) + }, + remove: input?.remove, + target() { + return { type: "local", directory: dir } + }, + }) +} + +function remoteAdapter(url: string, input?: { directory?: string | null; headers?: HeadersInit }) { + return recordedAdapter({ + configure(info) { + return { ...info, directory: input?.directory ?? info.directory } + }, + target() { + return { type: "remote", url, headers: input?.headers } + }, + }) +} + +function eventStreamResponse(events: unknown[] = [], keepOpen = true) { + const encoder = new TextEncoder() + return new Response( + new ReadableStream({ + start(controller) { + if (keepOpen) controller.enqueue(encoder.encode(":\n\n")) + events.forEach((event) => controller.enqueue(encoder.encode(`data: ${JSON.stringify(event)}\n\n`))) + if (!keepOpen) controller.close() + }, + }), + { status: 200, headers: { "content-type": "text/event-stream" } }, + ) +} + +function serverUrl() { + return Effect.gen(function* () { + return HttpServer.formatAddress((yield* HttpServer.HttpServer).address) + }) +} + +function workspaceInfo(projectID: ProjectID, type: string, input?: Partial): WorkspaceInfo { + return { + id: input?.id ?? WorkspaceID.ascending(), + type, + name: input?.name ?? unique("workspace"), + branch: input?.branch ?? null, + directory: input?.directory ?? null, + extra: input?.extra ?? null, + projectID, + } +} + +function insertWorkspace(info: WorkspaceInfo) { + Database.use((db) => + db + .insert(WorkspaceTable) + .values({ + id: info.id, + type: info.type, + branch: info.branch, + name: info.name, + directory: info.directory, + extra: info.extra, + project_id: info.projectID, + }) + .run(), + ) +} + +function insertProject(id: ProjectID, worktree: string) { + Database.use((db) => + db + .insert(ProjectTable) + .values({ + id, + worktree, + vcs: null, + name: null, + time_created: Date.now(), + time_updated: Date.now(), + sandboxes: [], + }) + .run(), + ) +} + +function attachSessionToWorkspace(sessionID: SessionID, workspaceID: WorkspaceID) { + Database.use((db) => + db.update(SessionTable).set({ workspace_id: workspaceID }).where(eq(SessionTable.id, sessionID)).run(), + ) +} + +function sessionSequence(sessionID: SessionID) { + return Database.use((db) => + db + .select({ seq: EventSequenceTable.seq }) + .from(EventSequenceTable) + .where(eq(EventSequenceTable.aggregate_id, sessionID)) + .get(), + )?.seq +} + +function eventRows(sessionID: SessionID) { + return Database.use((db) => + db + .select({ seq: EventTable.seq, type: EventTable.type, data: EventTable.data }) + .from(EventTable) + .where(eq(EventTable.aggregate_id, sessionID)) + .orderBy(asc(EventTable.seq)) + .all(), + ) +} + +function sessionUpdatedType() { + return SyncEvent.versionedType(SessionNs.Event.Updated.type, SessionNs.Event.Updated.version) +} + +function replaceSessionEvents(sessionID: SessionID, count: number) { + Database.use((db) => { + db.delete(EventSequenceTable).where(eq(EventSequenceTable.aggregate_id, sessionID)).run() + if (count === 0) return + + db.insert(EventSequenceTable) + .values({ aggregate_id: sessionID, seq: count - 1 }) + .run() + db.insert(EventTable) + .values( + Array.from({ length: count }, (_, i) => ({ + id: `evt_${unique(`manual-${i}`)}`, + aggregate_id: sessionID, + seq: i, + type: sessionUpdatedType(), + data: { sessionID, info: { title: `manual ${i}` } }, + })), + ) + .run() + }) +} + +describe("workspace-old schemas and exports", () => { + test("keeps the historical event type names", () => { + expect(WorkspaceOld.Event.Ready.type).toBe("workspace.ready") + expect(WorkspaceOld.Event.Failed.type).toBe("workspace.failed") + expect(WorkspaceOld.Event.Restore.type).toBe("workspace.restore") + expect(WorkspaceOld.Event.Status.type).toBe("workspace.status") + }) + + test("validates create input with workspace id, project id, branch, type, and extra", () => { + const input = { + id: WorkspaceID.ascending("wrk_schema_create"), + type: "worktree", + branch: "feature/schema", + projectID: ProjectID.make("project-schema"), + extra: { nested: true }, + } + + expect(WorkspaceOld.CreateInput.zod.parse(input)).toEqual(input) + expect(() => WorkspaceOld.CreateInput.zod.parse({ ...input, id: "bad" })).toThrow() + expect(() => WorkspaceOld.CreateInput.zod.parse({ ...input, branch: 1 })).toThrow() + }) + + test("validates session restore input", () => { + const input = { + workspaceID: WorkspaceID.ascending("wrk_schema_restore"), + sessionID: SessionID.descending("ses_schema_restore"), + } + + expect(WorkspaceOld.SessionRestoreInput.zod.parse(input)).toEqual(input) + expect(() => WorkspaceOld.SessionRestoreInput.zod.parse({ ...input, workspaceID: "bad" })).toThrow() + expect(() => WorkspaceOld.SessionRestoreInput.zod.parse({ ...input, sessionID: "bad" })).toThrow() + }) +}) + +describe("workspace-old CRUD", () => { + test("get returns undefined for a missing workspace", async () => { + await withInstance(async () => { + expect(await getWorkspace(WorkspaceID.ascending("wrk_missing_get"))).toBeUndefined() + }) + }) + + test("list maps database rows, filters by project, and sorts by id", async () => { + await withInstance(async () => { + const otherProjectID = ProjectID.make("project-other") + insertProject(otherProjectID, "/tmp/other") + const a = workspaceInfo(Instance.project.id, "manual", { + id: WorkspaceID.ascending("wrk_a_list"), + branch: "a", + directory: "/a", + extra: { a: true }, + }) + const b = workspaceInfo(Instance.project.id, "manual", { + id: WorkspaceID.ascending("wrk_b_list"), + branch: "b", + directory: "/b", + extra: ["b"], + }) + const other = workspaceInfo(otherProjectID, "manual", { id: WorkspaceID.ascending("wrk_c_list") }) + insertWorkspace(b) + insertWorkspace(other) + insertWorkspace(a) + + expect(await listWorkspaces(Instance.project)).toEqual([a, b]) + }) + }) + + test("create configures, persists, creates, starts local sync, and passes environment", async () => { + await withInstance(async (dir) => { + process.env.KILO_AUTH_CONTENT = JSON.stringify({ test: { type: "api", key: "secret" } }) + process.env.OTEL_EXPORTER_OTLP_HEADERS = "authorization=otel" + process.env.OTEL_EXPORTER_OTLP_ENDPOINT = "https://otel.test" + process.env.OTEL_RESOURCE_ATTRIBUTES = "service.name=opencode-test" + + const workspaceID = WorkspaceID.ascending("wrk_create_local") + const type = unique("create-local") + const targetDir = path.join(dir, "created-local") + const recorded = recordedAdapter({ + configure(info) { + return { + ...info, + branch: "configured-branch", + name: "Configured Name", + directory: targetDir, + extra: { configured: true }, + } + }, + async create() { + await fs.mkdir(targetDir, { recursive: true }) + }, + target() { + return { type: "local", directory: targetDir } + }, + }) + registerAdapter(Instance.project.id, type, recorded.adapter) + + const info = await createWorkspace({ + id: workspaceID, + type, + branch: null, + projectID: Instance.project.id, + extra: null, + }) + + expect(info).toEqual({ + id: workspaceID, + type, + branch: "configured-branch", + name: "Configured Name", + directory: targetDir, + extra: { configured: true }, + projectID: Instance.project.id, + }) + expect(await getWorkspace(workspaceID)).toEqual(info) + expect(await listWorkspaces(Instance.project)).toEqual([info]) + expect(recorded.calls.configure).toHaveLength(1) + expect(recorded.calls.configure[0]).toMatchObject({ id: workspaceID, type, directory: null }) + expect(recorded.calls.create).toHaveLength(1) + expect(recorded.calls.create[0].info).toEqual(info) + expect(JSON.parse(recorded.calls.create[0].env.KILO_AUTH_CONTENT ?? "{}")).toEqual({ + test: { type: "api", key: "secret" }, + }) + expect(recorded.calls.create[0].env.KILO_WORKSPACE_ID).toBe(workspaceID) + expect(recorded.calls.create[0].env.KILO_EXPERIMENTAL_WORKSPACES).toBe("true") + expect(recorded.calls.create[0].env.OTEL_EXPORTER_OTLP_HEADERS).toBe("authorization=otel") + expect(recorded.calls.create[0].env.OTEL_EXPORTER_OTLP_ENDPOINT).toBe("https://otel.test") + expect(recorded.calls.create[0].env.OTEL_RESOURCE_ATTRIBUTES).toBe("service.name=opencode-test") + expect((await workspaceStatus()).find((item) => item.workspaceID === workspaceID)?.status).toBe("connected") + + await removeWorkspace(workspaceID) + expect((await workspaceStatus()).find((item) => item.workspaceID === workspaceID)?.status).toBeUndefined() + }) + }) + + test("create propagates configure failures and does not insert a workspace", async () => { + await withInstance(async () => { + const type = unique("configure-failure") + registerAdapter( + Instance.project.id, + type, + recordedAdapter({ + configure() { + throw new Error("configure exploded") + }, + target() { + return { type: "local", directory: "/unused" } + }, + }).adapter, + ) + + await expect( + createWorkspace({ type, branch: null, projectID: Instance.project.id, extra: null }), + ).rejects.toThrow("configure exploded") + expect(await listWorkspaces(Instance.project)).toEqual([]) + }) + }) + + test("create leaves the inserted row when adapter create fails", async () => { + await withInstance(async () => { + const type = unique("create-failure") + const recorded = recordedAdapter({ + async create() { + throw new Error("create exploded") + }, + target() { + return { type: "local", directory: "/unused" } + }, + }) + registerAdapter(Instance.project.id, type, recorded.adapter) + + await expect( + createWorkspace({ type, branch: "branch", projectID: Instance.project.id, extra: { x: 1 } }), + ).rejects.toThrow("create exploded") + + const rows = await listWorkspaces(Instance.project) + expect(rows).toHaveLength(1) + expect(rows[0]).toMatchObject({ type, branch: "branch", extra: { x: 1 } }) + expect(recorded.calls.target).toHaveLength(0) + await removeWorkspace(rows[0].id) + }) + }) + + test("create returns after a local workspace reports error", async () => { + await withInstance(async (dir) => { + const type = unique("local-error") + const missing = path.join(dir, "missing-local-target") + const recorded = localAdapter(missing, { createDir: false }) + registerAdapter(Instance.project.id, type, recorded.adapter) + + const info = await createWorkspace({ type, branch: null, projectID: Instance.project.id, extra: null }) + + expect(info.directory).toBe(missing) + expect((await workspaceStatus()).find((item) => item.workspaceID === info.id)?.status).toBe("error") + await removeWorkspace(info.id) + }) + }) + + it.live("remote create connects to routed event and history endpoints", () => { + const calls: FetchCall[] = [] + return Effect.gen(function* () { + yield* HttpServer.serveEffect()( + Effect.gen(function* () { + const req = yield* HttpServerRequest.HttpServerRequest + const bodyText = yield* req.text + const call = { + url: new URL(req.url, "http://localhost"), + method: req.method, + headers: new Headers(req.headers), + bodyText, + json: bodyText ? JSON.parse(bodyText) : undefined, + } + calls.push(call) + if (call.url.pathname === "/base/global/event") + return HttpServerResponse.fromWeb(eventStreamResponse([], false)) + if (call.url.pathname === "/base/sync/history") return yield* HttpServerResponse.json([]) + return HttpServerResponse.text("unexpected", { status: 500 }) + }), + ) + const url = yield* serverUrl() + yield* provideTmpdirInstance( + (dir) => + Effect.gen(function* () { + const workspace = yield* WorkspaceOld.Service + const type = unique("remote-create") + const recorded = remoteAdapter(`${url}/base/?ignored=1#hash`, { directory: dir }) + registerAdapter(Instance.project.id, type, recorded.adapter) + + const info = yield* workspace.create({ type, branch: null, projectID: Instance.project.id, extra: null }) + + expect( + calls.map((call) => `${call.method} ${call.url.pathname}${call.url.search}${call.url.hash}`), + ).toEqual(["GET /base/global/event", "POST /base/sync/history"]) + expect(calls[1].json).toEqual({}) + expect((yield* workspace.status()).find((item) => item.workspaceID === info.id)?.status).toBe("connected") + expect(yield* workspace.isSyncing(info.id)).toBe(true) + + yield* workspace.remove(info.id) + expect(yield* workspace.isSyncing(info.id)).toBe(false) + expect((yield* workspace.status()).find((item) => item.workspaceID === info.id)?.status).toBeUndefined() + }), + { git: true }, + ) + }) + }) + + test("remove returns undefined for a missing workspace", async () => { + await withInstance(async () => { + expect(await removeWorkspace(WorkspaceID.ascending("wrk_missing_remove"))).toBeUndefined() + }) + }) + + test("remove deletes the workspace, associated sessions, adapter resources, and status", async () => { + await withInstance(async (dir) => { + const type = unique("remove-local") + const recorded = localAdapter(path.join(dir, "remove-local")) + registerAdapter(Instance.project.id, type, recorded.adapter) + const info = await createWorkspace({ type, branch: null, projectID: Instance.project.id, extra: null }) + const one = await AppRuntime.runPromise(SessionNs.Service.use((svc) => svc.create({}))) + const two = await AppRuntime.runPromise(SessionNs.Service.use((svc) => svc.create({}))) + attachSessionToWorkspace(one.id, info.id) + attachSessionToWorkspace(two.id, info.id) + + const removed = await removeWorkspace(info.id) + + expect(removed).toEqual(info) + expect(await getWorkspace(info.id)).toBeUndefined() + expect(recorded.calls.remove).toEqual([info]) + expect((await workspaceStatus()).find((item) => item.workspaceID === info.id)?.status).toBeUndefined() + expect( + Database.use((db) => + db.select({ id: SessionTable.id }).from(SessionTable).where(eq(SessionTable.workspace_id, info.id)).all(), + ), + ).toEqual([]) + }) + }) + + test("remove still deletes the row when the adapter cannot remove resources", async () => { + await withInstance(async () => { + const type = unique("remove-throws") + const info = workspaceInfo(Instance.project.id, type, { id: WorkspaceID.ascending("wrk_remove_throws") }) + registerAdapter( + Instance.project.id, + type, + recordedAdapter({ + async remove() { + throw new Error("remove exploded") + }, + target() { + return { type: "local", directory: "/unused" } + }, + }).adapter, + ) + insertWorkspace(info) + + expect(await removeWorkspace(info.id)).toEqual(info) + expect(await getWorkspace(info.id)).toBeUndefined() + }) + }) +}) + +describe("workspace-old sync state", () => { + test("startWorkspaceSyncing is disabled by the experimental workspace flag", async () => { + await withInstance(async (dir) => { + Flag.KILO_EXPERIMENTAL_WORKSPACES = false + const type = unique("flag-disabled") + const info = workspaceInfo(Instance.project.id, type) + const session = await AppRuntime.runPromise(SessionNs.Service.use((svc) => svc.create({}))) + attachSessionToWorkspace(session.id, info.id) + insertWorkspace(info) + registerAdapter(Instance.project.id, type, localAdapter(path.join(dir, "flag-disabled")).adapter) + + startWorkspaceSyncing(Instance.project.id) + await delay(25) + + expect((await workspaceStatus()).find((item) => item.workspaceID === info.id)?.status).toBeUndefined() + }) + }) + + test("startWorkspaceSyncing starts only workspaces with sessions", async () => { + await withInstance(async (dir) => { + const withSessionType = unique("with-session") + const withoutSessionType = unique("without-session") + const withSession = workspaceInfo(Instance.project.id, withSessionType) + const withoutSession = workspaceInfo(Instance.project.id, withoutSessionType) + const withSessionDir = path.join(dir, "with-session") + const withoutSessionDir = path.join(dir, "without-session") + await fs.mkdir(withSessionDir, { recursive: true }) + await fs.mkdir(withoutSessionDir, { recursive: true }) + insertWorkspace(withSession) + insertWorkspace(withoutSession) + registerAdapter(Instance.project.id, withSessionType, localAdapter(withSessionDir).adapter) + registerAdapter(Instance.project.id, withoutSessionType, localAdapter(withoutSessionDir).adapter) + attachSessionToWorkspace( + (await AppRuntime.runPromise(SessionNs.Service.use((svc) => svc.create({})))).id, + withSession.id, + ) + + startWorkspaceSyncing(Instance.project.id) + + await eventually(() => + workspaceStatus().then((status) => + expect(status.find((item) => item.workspaceID === withSession.id)?.status).toBe("connected"), + ), + ) + expect((await workspaceStatus()).find((item) => item.workspaceID === withoutSession.id)?.status).toBeUndefined() + await removeWorkspace(withSession.id) + await removeWorkspace(withoutSession.id) + }) + }) + + test("local start reports error when the target directory is missing", async () => { + await withInstance(async (dir) => { + const type = unique("missing-local") + const info = workspaceInfo(Instance.project.id, type) + insertWorkspace(info) + registerAdapter( + Instance.project.id, + type, + localAdapter(path.join(dir, "missing-target"), { createDir: false }).adapter, + ) + attachSessionToWorkspace( + (await AppRuntime.runPromise(SessionNs.Service.use((svc) => svc.create({})))).id, + info.id, + ) + + startWorkspaceSyncing(Instance.project.id) + + await eventually(() => + workspaceStatus().then((status) => + expect(status.find((item) => item.workspaceID === info.id)?.status).toBe("error"), + ), + ) + expect(await isWorkspaceSyncing(info.id)).toBe(false) + await removeWorkspace(info.id) + }) + }) + + test("duplicate local status updates are suppressed", async () => { + await withInstance(async (dir) => { + const captured = captureGlobalEvents() + try { + const type = unique("dedupe-local") + const info = workspaceInfo(Instance.project.id, type) + const target = path.join(dir, "dedupe-local") + await fs.mkdir(target, { recursive: true }) + insertWorkspace(info) + registerAdapter(Instance.project.id, type, localAdapter(target).adapter) + attachSessionToWorkspace( + (await AppRuntime.runPromise(SessionNs.Service.use((svc) => svc.create({})))).id, + info.id, + ) + + startWorkspaceSyncing(Instance.project.id) + startWorkspaceSyncing(Instance.project.id) + + await eventually(() => + workspaceStatus().then((status) => + expect(status.find((item) => item.workspaceID === info.id)?.status).toBe("connected"), + ), + ) + expect( + captured.events.filter( + (event) => event.workspace === info.id && event.payload.type === WorkspaceOld.Event.Status.type, + ), + ).toHaveLength(1) + await removeWorkspace(info.id) + } finally { + captured.dispose() + } + }) + }) + + it.live("remote start emits disconnected, connecting, and connected then refuses duplicate listeners", () => { + const calls: FetchCall[] = [] + return Effect.gen(function* () { + yield* HttpServer.serveEffect()( + Effect.gen(function* () { + const req = yield* HttpServerRequest.HttpServerRequest + const bodyText = yield* req.text + const call = { + url: new URL(req.url, "http://localhost"), + method: req.method, + headers: new Headers(req.headers), + bodyText, + json: bodyText ? JSON.parse(bodyText) : undefined, + } + calls.push(call) + if (call.url.pathname === "/sync/global/event") return HttpServerResponse.fromWeb(eventStreamResponse()) + if (call.url.pathname === "/sync/sync/history") return HttpServerResponse.fromWeb(Response.json([])) + return HttpServerResponse.text("unexpected", { status: 500 }) + }), + ) + const url = yield* serverUrl() + yield* provideTmpdirInstance( + () => + Effect.gen(function* () { + const workspace = yield* WorkspaceOld.Service + const sessionSvc = yield* SessionNs.Service + const captured = captureGlobalEvents() + try { + const type = unique("remote-start") + const info = workspaceInfo(Instance.project.id, type) + insertWorkspace(info) + registerAdapter(Instance.project.id, type, remoteAdapter(`${url}/sync`).adapter) + attachSessionToWorkspace((yield* sessionSvc.create({})).id, info.id) + + yield* workspace.startWorkspaceSyncing(Instance.project.id) + yield* eventuallyEffect( + Effect.gen(function* () { + expect((yield* workspace.status()).find((item) => item.workspaceID === info.id)?.status).toBe( + "connected", + ) + }), + ) + yield* workspace.startWorkspaceSyncing(Instance.project.id) + yield* Effect.sleep("25 millis") + + expect( + captured.events + .filter( + (event) => event.workspace === info.id && event.payload.type === WorkspaceOld.Event.Status.type, + ) + .map((event) => event.payload.properties.status), + ).toEqual(["disconnected", "connecting", "connected"]) + expect(calls.filter((call) => call.url.pathname === "/sync/global/event")).toHaveLength(1) + expect(calls.filter((call) => call.url.pathname === "/sync/sync/history")).toHaveLength(1) + expect(yield* workspace.isSyncing(info.id)).toBe(true) + + yield* workspace.remove(info.id) + expect(yield* workspace.isSyncing(info.id)).toBe(false) + } finally { + captured.dispose() + } + }), + { git: true }, + ) + }) + }) + + it.live("remote connection HTTP failures set error and clear syncing", () => + Effect.gen(function* () { + yield* HttpServer.serveEffect()( + Effect.gen(function* () { + const req = yield* HttpServerRequest.HttpServerRequest + if (new URL(req.url, "http://localhost").pathname === "/failed/global/event") + return HttpServerResponse.text("nope", { status: 503 }) + return HttpServerResponse.fromWeb(Response.json([])) + }), + ) + const url = yield* serverUrl() + yield* provideTmpdirInstance( + () => + Effect.gen(function* () { + const workspace = yield* WorkspaceOld.Service + const sessionSvc = yield* SessionNs.Service + const type = unique("remote-connect-fail") + const info = workspaceInfo(Instance.project.id, type) + insertWorkspace(info) + registerAdapter(Instance.project.id, type, remoteAdapter(`${url}/failed`).adapter) + attachSessionToWorkspace((yield* sessionSvc.create({})).id, info.id) + + yield* workspace.startWorkspaceSyncing(Instance.project.id) + + yield* eventuallyEffect( + Effect.gen(function* () { + expect((yield* workspace.status()).find((item) => item.workspaceID === info.id)?.status).toBe("error") + }), + ) + expect(yield* workspace.isSyncing(info.id)).toBe(false) + yield* workspace.remove(info.id) + }), + { git: true }, + ) + }), + ) + + it.live("remote history HTTP failures set error", () => + Effect.gen(function* () { + yield* HttpServer.serveEffect()( + Effect.gen(function* () { + const req = yield* HttpServerRequest.HttpServerRequest + const url = new URL(req.url, "http://localhost") + if (url.pathname === "/history-failed/global/event") + return HttpServerResponse.fromWeb(eventStreamResponse([], false)) + if (url.pathname === "/history-failed/sync/history") + return HttpServerResponse.text("history failed", { status: 500 }) + return HttpServerResponse.fromWeb(Response.json([])) + }), + ) + const url = yield* serverUrl() + yield* provideTmpdirInstance( + () => + Effect.gen(function* () { + const workspace = yield* WorkspaceOld.Service + const sessionSvc = yield* SessionNs.Service + const type = unique("remote-history-fail") + const info = workspaceInfo(Instance.project.id, type) + insertWorkspace(info) + registerAdapter(Instance.project.id, type, remoteAdapter(`${url}/history-failed`).adapter) + attachSessionToWorkspace((yield* sessionSvc.create({})).id, info.id) + + yield* workspace.startWorkspaceSyncing(Instance.project.id) + + yield* eventuallyEffect( + Effect.gen(function* () { + expect((yield* workspace.status()).find((item) => item.workspaceID === info.id)?.status).toBe("error") + }), + ) + expect(yield* workspace.isSyncing(info.id)).toBe(false) + yield* workspace.remove(info.id) + }), + { git: true }, + ) + }), + ) + + it.live("sync history sends the local sequence fence and replays returned events in workspace context", () => { + const historyBodies: unknown[] = [] + let historySessionID: SessionID | undefined + let historyNextSeq = 0 + return Effect.gen(function* () { + yield* HttpServer.serveEffect()( + Effect.gen(function* () { + const req = yield* HttpServerRequest.HttpServerRequest + const bodyText = yield* req.text + const url = new URL(req.url, "http://localhost") + if (url.pathname === "/history/global/event") return HttpServerResponse.fromWeb(eventStreamResponse()) + if (url.pathname === "/history/sync/history") { + historyBodies.push(bodyText ? JSON.parse(bodyText) : undefined) + return HttpServerResponse.fromWeb( + Response.json([ + { + id: `evt_${unique("history")}`, + aggregate_id: historySessionID!, + seq: historyNextSeq, + type: sessionUpdatedType(), + data: { sessionID: historySessionID!, info: { title: "from history" } }, + }, + ]), + ) + } + return HttpServerResponse.text("unexpected", { status: 500 }) + }), + ) + const url = yield* serverUrl() + yield* provideTmpdirInstance( + () => + Effect.gen(function* () { + const workspace = yield* WorkspaceOld.Service + const sessionSvc = yield* SessionNs.Service + const captured = captureGlobalEvents() + try { + const type = unique("history-replay") + const info = workspaceInfo(Instance.project.id, type) + insertWorkspace(info) + registerAdapter(Instance.project.id, type, remoteAdapter(`${url}/history`).adapter) + const session = yield* sessionSvc.create({ title: "before history" }) + attachSessionToWorkspace(session.id, info.id) + historySessionID = session.id + historyNextSeq = (sessionSequence(session.id) ?? -1) + 1 + + yield* workspace.startWorkspaceSyncing(Instance.project.id) + + yield* eventuallyEffect( + Effect.gen(function* () { + expect((yield* sessionSvc.get(session.id)).title).toBe("from history") + }), + ) + expect(historyBodies).toEqual([{ [session.id]: historyNextSeq - 1 }]) + expect( + captured.events.some( + (event) => + event.workspace === info.id && + event.payload.type === "sync" && + event.payload.syncEvent.seq === historyNextSeq, + ), + ).toBe(true) + yield* workspace.remove(info.id) + } finally { + captured.dispose() + } + }), + { git: true }, + ) + }) + }) + + it.live("SSE forwards non-heartbeat events and ignores heartbeats", () => + Effect.gen(function* () { + yield* HttpServer.serveEffect()( + Effect.gen(function* () { + const req = yield* HttpServerRequest.HttpServerRequest + const url = new URL(req.url, "http://localhost") + if (url.pathname === "/sse-forward/global/event") + return HttpServerResponse.fromWeb( + eventStreamResponse( + [ + { directory: "remote-dir", project: "remote-project", payload: { type: "server.heartbeat" } }, + { + directory: "remote-dir", + project: "remote-project", + payload: { type: "custom.remote", properties: { ok: true } }, + }, + ], + false, + ), + ) + if (url.pathname === "/sse-forward/sync/history") return HttpServerResponse.fromWeb(Response.json([])) + return HttpServerResponse.text("unexpected", { status: 500 }) + }), + ) + const url = yield* serverUrl() + yield* provideTmpdirInstance( + () => + Effect.gen(function* () { + const workspace = yield* WorkspaceOld.Service + const sessionSvc = yield* SessionNs.Service + const captured = captureGlobalEvents() + try { + const type = unique("sse-forward") + const info = workspaceInfo(Instance.project.id, type) + insertWorkspace(info) + registerAdapter(Instance.project.id, type, remoteAdapter(`${url}/sse-forward`).adapter) + attachSessionToWorkspace((yield* sessionSvc.create({})).id, info.id) + + yield* workspace.startWorkspaceSyncing(Instance.project.id) + + yield* eventuallyEffect( + Effect.sync(() => + expect( + captured.events.some( + (event) => event.workspace === info.id && event.payload.type === "custom.remote", + ), + ).toBe(true), + ), + ) + expect( + captured.events.some( + (event) => event.workspace === info.id && event.payload.type === "server.heartbeat", + ), + ).toBe(false) + expect( + captured.events.find((event) => event.workspace === info.id && event.payload.type === "custom.remote"), + ).toMatchObject({ + directory: "remote-dir", + project: "remote-project", + payload: { properties: { ok: true } }, + }) + yield* workspace.remove(info.id) + } finally { + captured.dispose() + } + }), + { git: true }, + ) + }), + ) + + it.live("SSE sync events are replayed and forwarded", () => { + let sseSessionID: SessionID | undefined + let sseNextSeq = 0 + return Effect.gen(function* () { + yield* HttpServer.serveEffect()( + Effect.gen(function* () { + const req = yield* HttpServerRequest.HttpServerRequest + const url = new URL(req.url, "http://localhost") + if (url.pathname === "/sse-sync/global/event") + return HttpServerResponse.fromWeb( + eventStreamResponse( + [ + { + directory: "remote-dir", + project: "remote-project", + payload: { + type: "sync", + syncEvent: { + id: `evt_${unique("sse")}`, + aggregateID: sseSessionID!, + seq: sseNextSeq, + type: sessionUpdatedType(), + data: { sessionID: sseSessionID!, info: { title: "from sse" } }, + }, + }, + }, + ], + false, + ), + ) + if (url.pathname === "/sse-sync/sync/history") return HttpServerResponse.fromWeb(Response.json([])) + return HttpServerResponse.text("unexpected", { status: 500 }) + }), + ) + const url = yield* serverUrl() + yield* provideTmpdirInstance( + () => + Effect.gen(function* () { + const workspace = yield* WorkspaceOld.Service + const sessionSvc = yield* SessionNs.Service + const captured = captureGlobalEvents() + try { + const type = unique("sse-sync") + const info = workspaceInfo(Instance.project.id, type) + insertWorkspace(info) + registerAdapter(Instance.project.id, type, remoteAdapter(`${url}/sse-sync`).adapter) + const session = yield* sessionSvc.create({ title: "before sse" }) + attachSessionToWorkspace(session.id, info.id) + sseSessionID = session.id + sseNextSeq = (sessionSequence(session.id) ?? -1) + 1 + + yield* workspace.startWorkspaceSyncing(Instance.project.id) + + yield* eventuallyEffect( + Effect.gen(function* () { + expect((yield* sessionSvc.get(session.id)).title).toBe("from sse") + }), + ) + expect( + captured.events.some( + (event) => + event.workspace === info.id && + event.payload.type === "sync" && + event.payload.syncEvent.seq === sseNextSeq, + ), + ).toBe(true) + yield* workspace.remove(info.id) + } finally { + captured.dispose() + } + }), + { git: true }, + ) + }) + }) +}) + +describe("workspace-old waitForSync", () => { + test("returns immediately for an empty fence", async () => { + await withInstance(async () => { + await expect(waitForWorkspaceSync(WorkspaceID.ascending("wrk_wait_empty"), {})).resolves.toBeUndefined() + }) + }) + + test("returns immediately when the stored sequence already satisfies the fence", async () => { + await withInstance(async () => { + const sessionID = SessionID.descending("ses_wait_done") + Database.use((db) => db.insert(EventSequenceTable).values({ aggregate_id: sessionID, seq: 4 }).run()) + + await expect( + waitForWorkspaceSync(WorkspaceID.ascending("wrk_wait_done"), { [sessionID]: 4 }), + ).resolves.toBeUndefined() + await expect( + waitForWorkspaceSync(WorkspaceID.ascending("wrk_wait_done_2"), { [sessionID]: 3 }), + ).resolves.toBeUndefined() + }) + }) + + test("waits until the database reaches the requested sequence and a workspace event arrives", async () => { + await withInstance(async () => { + const workspaceID = WorkspaceID.ascending("wrk_wait_event") + const sessionID = SessionID.descending("ses_wait_event") + Database.use((db) => db.insert(EventSequenceTable).values({ aggregate_id: sessionID, seq: 1 }).run()) + + const waited = waitForWorkspaceSync(workspaceID, { [sessionID]: 2 }) + await delay(10) + Database.use((db) => + db.update(EventSequenceTable).set({ seq: 2 }).where(eq(EventSequenceTable.aggregate_id, sessionID)).run(), + ) + GlobalBus.emit("event", { workspace: workspaceID, payload: { type: "anything" } }) + + await expect(waited).resolves.toBeUndefined() + }) + }) + + test("a sync event for a different workspace can also release the fence", async () => { + await withInstance(async () => { + const workspaceID = WorkspaceID.ascending("wrk_wait_sync_any") + const sessionID = SessionID.descending("ses_wait_sync_any") + Database.use((db) => db.insert(EventSequenceTable).values({ aggregate_id: sessionID, seq: 0 }).run()) + + const waited = waitForWorkspaceSync(workspaceID, { [sessionID]: 1 }) + await delay(10) + Database.use((db) => + db.update(EventSequenceTable).set({ seq: 1 }).where(eq(EventSequenceTable.aggregate_id, sessionID)).run(), + ) + GlobalBus.emit("event", { + workspace: WorkspaceID.ascending("wrk_other_workspace"), + payload: { type: "sync" }, + }) + + await expect(waited).resolves.toBeUndefined() + }) + }) + + test("rejects with the abort reason when aborted", async () => { + await withInstance(async () => { + const abort = new AbortController() + const reason = new Error("caller aborted") + const waited = waitForWorkspaceSync( + WorkspaceID.ascending("wrk_wait_abort"), + { [SessionID.descending("ses_wait_abort")]: 1 }, + abort.signal, + ) + abort.abort(reason) + + await expect(waited).rejects.toMatchObject({ + _tag: "WorkspaceSyncAbortedError", + message: reason.message, + cause: reason, + }) + }) + }) + + test("times out with the requested fence in the error message", async () => { + await withInstance(async () => { + const sessionID = SessionID.descending("ses_wait_timeout") + + await expect(waitForWorkspaceSync(WorkspaceID.ascending("wrk_wait_timeout"), { [sessionID]: 1 })).rejects.toThrow( + `Timed out waiting for sync fence: {"${sessionID}":1}`, + ) + }) + }, 7000) +}) + +describe("workspace-old sessionRestore", () => { + test("throws when the workspace is missing", async () => { + await withInstance(async () => { + await expect( + restoreWorkspaceSession({ + workspaceID: WorkspaceID.ascending("wrk_restore_missing"), + sessionID: SessionID.descending("ses_restore_missing_workspace"), + }), + ).rejects.toThrow("Workspace not found: wrk_restore_missing") + }) + }) + + test("throws when switching a missing session fails", async () => { + await withInstance(async (dir) => { + const type = unique("restore-missing-session") + const info = workspaceInfo(Instance.project.id, type, { directory: dir }) + insertWorkspace(info) + registerAdapter(Instance.project.id, type, localAdapter(dir).adapter) + + await expect( + restoreWorkspaceSession({ workspaceID: info.id, sessionID: SessionID.descending("ses_missing_restore") }), + ).rejects.toThrow("NotFoundError") + await removeWorkspace(info.id) + }) + }) + + it.live("posts remote replay batches of 10, emits progress, and includes the workspace update event", () => { + const replay: FetchCall[] = [] + return Effect.gen(function* () { + yield* HttpServer.serveEffect()( + Effect.gen(function* () { + const req = yield* HttpServerRequest.HttpServerRequest + const bodyText = yield* req.text + const call = { + url: new URL(req.url, "http://localhost"), + method: req.method, + headers: new Headers(req.headers), + bodyText, + json: bodyText ? JSON.parse(bodyText) : undefined, + } + if (call.url.pathname === "/restore/sync/replay") { + replay.push(call) + return HttpServerResponse.fromWeb(Response.json({ ok: true })) + } + return HttpServerResponse.text("unexpected", { status: 500 }) + }), + ) + const url = yield* serverUrl() + yield* provideTmpdirInstance( + (dir) => + Effect.gen(function* () { + const workspace = yield* WorkspaceOld.Service + const sessionSvc = yield* SessionNs.Service + const captured = captureGlobalEvents() + try { + const type = unique("restore-remote") + const info = workspaceInfo(Instance.project.id, type, { directory: dir }) + insertWorkspace(info) + registerAdapter( + Instance.project.id, + type, + remoteAdapter(`${url}/restore/?ignored=1#hash`, { + directory: dir, + headers: { authorization: "Bearer restore" }, + }).adapter, + ) + const session = yield* sessionSvc.create({ title: "restore remote" }) + replaceSessionEvents(session.id, 24) + + const result = yield* workspace.sessionRestore({ workspaceID: info.id, sessionID: session.id }) + + expect(result).toEqual({ total: 3 }) + expect(replay).toHaveLength(3) + expect(replay.map((call) => call.url.pathname + call.url.search + call.url.hash)).toEqual([ + "/restore/sync/replay", + "/restore/sync/replay", + "/restore/sync/replay", + ]) + expect(replay.every((call) => call.headers.get("authorization") === "Bearer restore")).toBe(true) + expect(replay.every((call) => call.headers.get("content-type") === "application/json")).toBe(true) + expect(replay.map((call) => (call.json as { events: unknown[] }).events.length)).toEqual([10, 10, 5]) + expect(replay.map((call) => (call.json as { directory: string }).directory)).toEqual([dir, dir, dir]) + expect( + replay.flatMap((call) => + (call.json as { events: Array<{ seq: number }> }).events.map((event) => event.seq), + ), + ).toEqual(Array.from({ length: 25 }, (_, i) => i)) + expect( + (replay[2].json as { events: Array<{ seq: number; type: string; data: unknown }> }).events.at(-1), + ).toMatchObject({ + seq: 24, + type: sessionUpdatedType(), + data: { sessionID: session.id, info: { workspaceID: info.id } }, + }) + expect((yield* sessionSvc.get(session.id)).workspaceID).toBe(info.id) + expect( + captured.events + .filter( + (event) => event.workspace === info.id && event.payload.type === WorkspaceOld.Event.Restore.type, + ) + .map((event) => event.payload.properties.step), + ).toEqual([0, 1, 2, 3]) + yield* workspace.remove(info.id) + } finally { + captured.dispose() + } + }), + { git: true }, + ) + }) + }) + + it.live("remote restore sends an empty directory string when the workspace directory is null", () => { + const replay: FetchCall[] = [] + return Effect.gen(function* () { + yield* HttpServer.serveEffect()( + Effect.gen(function* () { + const req = yield* HttpServerRequest.HttpServerRequest + const bodyText = yield* req.text + replay.push({ + url: new URL(req.url, "http://localhost"), + method: req.method, + headers: new Headers(req.headers), + bodyText, + json: bodyText ? JSON.parse(bodyText) : undefined, + }) + return HttpServerResponse.fromWeb(Response.json({ ok: true })) + }), + ) + const url = yield* serverUrl() + yield* provideTmpdirInstance( + () => + Effect.gen(function* () { + const workspace = yield* WorkspaceOld.Service + const sessionSvc = yield* SessionNs.Service + const type = unique("restore-null-dir") + const info = workspaceInfo(Instance.project.id, type, { directory: null }) + insertWorkspace(info) + registerAdapter(Instance.project.id, type, remoteAdapter(`${url}/null-dir`, { directory: null }).adapter) + const session = yield* sessionSvc.create({ title: "null dir" }) + replaceSessionEvents(session.id, 0) + + expect(yield* workspace.sessionRestore({ workspaceID: info.id, sessionID: session.id })).toEqual({ + total: 1, + }) + expect((replay[0].json as { directory: string }).directory).toBe("") + expect((replay[0].json as { events: unknown[] }).events).toHaveLength(1) + yield* workspace.remove(info.id) + }), + { git: true }, + ) + }) + }) + + it.live("remote restore failures include status and body and do not emit completed batch progress", () => { + const replay: FetchCall[] = [] + return Effect.gen(function* () { + yield* HttpServer.serveEffect()( + Effect.gen(function* () { + const req = yield* HttpServerRequest.HttpServerRequest + const bodyText = yield* req.text + replay.push({ + url: new URL(req.url, "http://localhost"), + method: req.method, + headers: new Headers(req.headers), + bodyText, + json: bodyText ? JSON.parse(bodyText) : undefined, + }) + return HttpServerResponse.text("replay failed", { status: 503 }) + }), + ) + const url = yield* serverUrl() + yield* provideTmpdirInstance( + (dir) => + Effect.gen(function* () { + const workspace = yield* WorkspaceOld.Service + const sessionSvc = yield* SessionNs.Service + const captured = captureGlobalEvents() + try { + const type = unique("restore-remote-fail") + const info = workspaceInfo(Instance.project.id, type, { directory: dir }) + insertWorkspace(info) + registerAdapter(Instance.project.id, type, remoteAdapter(`${url}/fail`, { directory: dir }).adapter) + const session = yield* sessionSvc.create({ title: "restore fail" }) + replaceSessionEvents(session.id, 11) + + const error = yield* Effect.flip( + workspace.sessionRestore({ workspaceID: info.id, sessionID: session.id }), + ) + expect((error as Error).message).toContain( + `Failed to replay session ${session.id} into workspace ${info.id}: HTTP 503 replay failed`, + ) + + expect(replay).toHaveLength(1) + expect( + captured.events + .filter( + (event) => event.workspace === info.id && event.payload.type === WorkspaceOld.Event.Restore.type, + ) + .map((event) => event.payload.properties.step), + ).toEqual([0]) + yield* workspace.remove(info.id) + } finally { + captured.dispose() + } + }), + { git: true }, + ) + }) + }) + + it.live("local restore replays batches and emits progress", () => + provideTmpdirInstance( + (dir) => + Effect.gen(function* () { + const workspace = yield* WorkspaceOld.Service + const sessionSvc = yield* SessionNs.Service + const captured = captureGlobalEvents() + try { + const type = unique("restore-local") + const info = workspaceInfo(Instance.project.id, type, { directory: dir }) + insertWorkspace(info) + registerAdapter(Instance.project.id, type, localAdapter(dir).adapter) + const session = yield* sessionSvc.create({ title: "restore local" }) + replaceSessionEvents(session.id, 20) + + expect(yield* workspace.sessionRestore({ workspaceID: info.id, sessionID: session.id })).toEqual({ + total: 3, + }) + expect((yield* sessionSvc.get(session.id)).workspaceID).toBe(info.id) + expect(eventRows(session.id).map((row) => row.seq)).toEqual(Array.from({ length: 21 }, (_, i) => i)) + expect( + captured.events + .filter( + (event) => event.workspace === info.id && event.payload.type === WorkspaceOld.Event.Restore.type, + ) + .map((event) => event.payload.properties.step), + ).toEqual([0, 1, 2, 3]) + yield* workspace.remove(info.id) + } finally { + captured.dispose() + } + }), + { git: true }, + ), + ) + + it.live("session restore includes real message and part events in sequence order", () => { + const replay: FetchCall[] = [] + return Effect.gen(function* () { + yield* HttpServer.serveEffect()( + Effect.gen(function* () { + const req = yield* HttpServerRequest.HttpServerRequest + const bodyText = yield* req.text + replay.push({ + url: new URL(req.url, "http://localhost"), + method: req.method, + headers: new Headers(req.headers), + bodyText, + json: bodyText ? JSON.parse(bodyText) : undefined, + }) + return HttpServerResponse.fromWeb(Response.json({ ok: true })) + }), + ) + const url = yield* serverUrl() + yield* provideTmpdirInstance( + (dir) => + Effect.gen(function* () { + const workspace = yield* WorkspaceOld.Service + const sessionSvc = yield* SessionNs.Service + const type = unique("restore-real-events") + const info = workspaceInfo(Instance.project.id, type, { directory: dir }) + insertWorkspace(info) + registerAdapter(Instance.project.id, type, remoteAdapter(`${url}/real`, { directory: dir }).adapter) + const session = yield* sessionSvc.create({ title: "real events" }) + for (let i = 0; i < 3; i++) { + const msg = yield* sessionSvc.updateMessage({ + id: MessageID.ascending(), + role: "user", + sessionID: session.id, + agent: "build", + model: { providerID: ProviderID.make("test"), modelID: ModelID.make("test") }, + time: { created: Date.now() }, + }) + yield* sessionSvc.updatePart({ + id: PartID.ascending(), + sessionID: session.id, + messageID: msg.id, + type: "text", + text: `message ${i}`, + }) + } + const before = eventRows(session.id) + + expect(yield* workspace.sessionRestore({ workspaceID: info.id, sessionID: session.id })).toEqual({ + total: 1, + }) + + const posted = (replay[0].json as { events: Array<{ seq: number; type: string }> }).events + expect(posted.map((event) => event.seq)).toEqual([...before.map((row) => row.seq), before.at(-1)!.seq + 1]) + expect(posted.map((event) => event.type).slice(0, -1)).toEqual(before.map((row) => row.type)) + expect(posted.at(-1)?.type).toBe(sessionUpdatedType()) + yield* workspace.remove(info.id) + }), + { git: true }, + ) + }) + }) +}) diff --git a/packages/opencode/test/effect/app-runtime-logger.test.ts b/packages/opencode/test/effect/app-runtime-logger.test.ts index dc88c60bf8..fe9516ef99 100644 --- a/packages/opencode/test/effect/app-runtime-logger.test.ts +++ b/packages/opencode/test/effect/app-runtime-logger.test.ts @@ -1,12 +1,15 @@ -import { expect, test } from "bun:test" +import { expect } from "bun:test" import { Context, Effect, Layer, Logger } from "effect" +import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" import { AppRuntime } from "../../src/effect/app-runtime" import { EffectBridge } from "@/effect/bridge" import { InstanceRef } from "../../src/effect/instance-ref" import * as EffectLogger from "@opencode-ai/core/effect/logger" import { makeRuntime } from "../../src/effect/run-service" -import { Instance } from "../../src/project/instance" -import { tmpdir } from "../fixture/fixture" +import { provideInstance, tmpdirScoped } from "../fixture/fixture" +import { testEffect } from "../lib/effect" + +const it = testEffect(CrossSpawnSpawner.defaultLayer) function check(loggers: ReadonlySet>) { return { @@ -17,56 +20,58 @@ function check(loggers: ReadonlySet>) { } } -test("makeRuntime installs EffectLogger through Observability.layer", async () => { - class Dummy extends Context.Service Effect.Effect> }>()( - "@test/Dummy", - ) {} +it.live("makeRuntime installs EffectLogger through Observability.layer", () => + Effect.gen(function* () { + class Dummy extends Context.Service Effect.Effect> }>()( + "@test/Dummy", + ) {} - const layer = Layer.effect( - Dummy, - Effect.gen(function* () { - return Dummy.of({ - current: () => Effect.map(Effect.service(Logger.CurrentLoggers), check), - }) - }), - ) + const layer = Layer.effect( + Dummy, + Effect.gen(function* () { + return Dummy.of({ + current: () => Effect.map(Effect.service(Logger.CurrentLoggers), check), + }) + }), + ) - const rt = makeRuntime(Dummy, layer) - const current = await rt.runPromise((svc) => svc.current()) + const current = yield* Effect.promise(() => makeRuntime(Dummy, layer).runPromise((svc) => svc.current())) - expect(current.effectLogger).toBe(true) - expect(current.defaultLogger).toBe(false) -}) + expect(current.effectLogger).toBe(true) + expect(current.defaultLogger).toBe(false) + }), +) -test("AppRuntime also installs EffectLogger through Observability.layer", async () => { - const current = await AppRuntime.runPromise(Effect.map(Effect.service(Logger.CurrentLoggers), check)) +it.live("AppRuntime also installs EffectLogger through Observability.layer", () => + Effect.gen(function* () { + const current = yield* Effect.promise(() => + AppRuntime.runPromise(Effect.map(Effect.service(Logger.CurrentLoggers), check)), + ) - expect(current.effectLogger).toBe(true) - expect(current.defaultLogger).toBe(false) -}) + expect(current.effectLogger).toBe(true) + expect(current.defaultLogger).toBe(false) + }), +) -test("AppRuntime attaches InstanceRef from ALS", async () => { - await using tmp = await tmpdir({ git: true }) - - const dir = await Instance.provide({ - directory: tmp.path, - fn: () => +it.live("AppRuntime attaches InstanceRef from ALS", () => + Effect.gen(function* () { + const dir = yield* tmpdirScoped({ git: true }) + const current = yield* Effect.promise(() => AppRuntime.runPromise( Effect.gen(function* () { return (yield* InstanceRef)?.directory }), ), - }) + ).pipe(provideInstance(dir)) - expect(dir).toBe(tmp.path) -}) + expect(current).toBe(dir) + }), +) -test("EffectBridge preserves logger and instance context across async boundaries", async () => { - await using tmp = await tmpdir({ git: true }) - - const result = await Instance.provide({ - directory: tmp.path, - fn: () => +it.live("EffectBridge preserves logger and instance context across async boundaries", () => + Effect.gen(function* () { + const dir = yield* tmpdirScoped({ git: true }) + const result = yield* Effect.promise(() => AppRuntime.runPromise( Effect.gen(function* () { const bridge = yield* EffectBridge.make() @@ -84,9 +89,10 @@ test("EffectBridge preserves logger and instance context across async boundaries ) }), ), - }) + ).pipe(provideInstance(dir)) - expect(result.directory).toBe(tmp.path) - expect(result.effectLogger).toBe(true) - expect(result.defaultLogger).toBe(false) -}) + expect(result.directory).toBe(dir) + expect(result.effectLogger).toBe(true) + expect(result.defaultLogger).toBe(false) + }), +) diff --git a/packages/opencode/test/effect/config-service.test.ts b/packages/opencode/test/effect/config-service.test.ts new file mode 100644 index 0000000000..be6f977363 --- /dev/null +++ b/packages/opencode/test/effect/config-service.test.ts @@ -0,0 +1,65 @@ +import { describe, expect } from "bun:test" +import { Config, ConfigProvider, Context, Effect, Layer, Option } from "effect" +import { ConfigService } from "../../src/effect/config-service" +import { it } from "../lib/effect" + +class TestConfig extends ConfigService.Service()("@test/ConfigService", { + name: Config.string("NAME"), + token: Config.string("TOKEN").pipe(Config.option), + port: Config.number("PORT").pipe(Config.withDefault(3000)), +}) {} + +const fromConfig = (input: Record) => + TestConfig.defaultLayer.pipe(Layer.provide(ConfigProvider.layer(ConfigProvider.fromUnknown(input)))) + +const readConfig = TestConfig.useSync((config) => config) + +describe("ConfigService", () => { + it.effect("defaultLayer parses values from the active ConfigProvider", () => + Effect.gen(function* () { + const config = yield* readConfig.pipe( + Effect.provide( + fromConfig({ + NAME: "kit", + TOKEN: "secret", + PORT: "4096", + }), + ), + ) + + expect(config.name).toBe("kit") + expect(config.token).toEqual(Option.some("secret")) + expect(config.port).toBe(4096) + }), + ) + + it.effect("defaultLayer applies Effect Config defaults", () => + Effect.gen(function* () { + const config = yield* readConfig.pipe(Effect.provide(fromConfig({ NAME: "kit" }))) + + expect(config.name).toBe("kit") + expect(config.token).toEqual(Option.none()) + expect(config.port).toBe(3000) + }), + ) + + it.effect("layer provides an already parsed service value", () => + Effect.gen(function* () { + const config = yield* readConfig.pipe( + Effect.provide( + TestConfig.layer({ + name: "direct", + token: Option.some("parsed"), + port: 9000, + }), + ), + ) + + expect(config).toEqual({ + name: "direct", + token: Option.some("parsed"), + port: 9000, + } satisfies Context.Service.Shape) + }), + ) +}) diff --git a/packages/opencode/test/effect/instance-state.test.ts b/packages/opencode/test/effect/instance-state.test.ts index 710c244748..0a8972ca4a 100644 --- a/packages/opencode/test/effect/instance-state.test.ts +++ b/packages/opencode/test/effect/instance-state.test.ts @@ -1,482 +1,394 @@ -import { afterEach, expect, test } from "bun:test" -import { Deferred, Duration, Effect, Exit, Fiber, Layer, ManagedRuntime, Context } from "effect" +import { afterEach, expect } from "bun:test" +import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" +import { $ } from "bun" +import { Context, Deferred, Duration, Effect, Exit, Fiber, Layer } from "effect" import { InstanceState } from "@/effect/instance-state" -import { InstanceRef } from "../../src/effect/instance-ref" +import { InstanceStore } from "../../src/project/instance-store" import { Instance } from "../../src/project/instance" -import { tmpdir } from "../fixture/fixture" +import { disposeAllInstances, provideInstance, tmpdirScoped } from "../fixture/fixture" +import { testEffect } from "../lib/effect" -async function access(state: InstanceState.InstanceState, dir: string) { - return Instance.provide({ - directory: dir, - fn: () => Effect.runPromise(InstanceState.get(state)), - }) -} +const it = testEffect(CrossSpawnSpawner.defaultLayer) + +const access = (state: InstanceState.InstanceState, dir: string) => + InstanceState.get(state).pipe(provideInstance(dir)) + +const tmpdirGitScoped = Effect.gen(function* () { + const dir = yield* tmpdirScoped({ git: true }) + yield* Effect.promise(() => $`git commit --allow-empty --amend -m ${`root commit ${dir}`}`.cwd(dir).quiet()) + return dir +}) afterEach(async () => { - await Instance.disposeAll() + await disposeAllInstances() }) -test("InstanceState caches values per directory", async () => { - await using tmp = await tmpdir() - let n = 0 +it.live("InstanceState caches values per directory", () => + Effect.gen(function* () { + const dir = yield* tmpdirScoped() + let n = 0 + const state = yield* InstanceState.make(() => Effect.sync(() => ({ n: ++n }))) - await Effect.runPromise( - Effect.scoped( - Effect.gen(function* () { - const state = yield* InstanceState.make(() => Effect.sync(() => ({ n: ++n }))) + const a = yield* access(state, dir) + const b = yield* access(state, dir) - const a = yield* Effect.promise(() => access(state, tmp.path)) - const b = yield* Effect.promise(() => access(state, tmp.path)) + expect(a).toBe(b) + expect(n).toBe(1) + }), +) - expect(a).toBe(b) - expect(n).toBe(1) - }), - ), - ) -}) +it.live("InstanceState isolates directories", () => + Effect.gen(function* () { + const one = yield* tmpdirScoped() + const two = yield* tmpdirScoped() + let n = 0 + const state = yield* InstanceState.make((dir) => Effect.sync(() => ({ dir, n: ++n }))) -test("InstanceState isolates directories", async () => { - await using one = await tmpdir() - await using two = await tmpdir() - let n = 0 + const a = yield* access(state, one) + const b = yield* access(state, two) + const c = yield* access(state, one) - await Effect.runPromise( - Effect.scoped( - Effect.gen(function* () { - const state = yield* InstanceState.make((dir) => Effect.sync(() => ({ dir, n: ++n }))) + expect(a).toBe(c) + expect(a).not.toBe(b) + expect(n).toBe(2) + }), +) - const a = yield* Effect.promise(() => access(state, one.path)) - const b = yield* Effect.promise(() => access(state, two.path)) - const c = yield* Effect.promise(() => access(state, one.path)) - - expect(a).toBe(c) - expect(a).not.toBe(b) - expect(n).toBe(2) - }), - ), - ) -}) - -test("InstanceState invalidates on reload", async () => { - await using tmp = await tmpdir() - const seen: string[] = [] - let n = 0 - - await Effect.runPromise( - Effect.scoped( - Effect.gen(function* () { - const state = yield* InstanceState.make(() => - Effect.acquireRelease( - Effect.sync(() => ({ n: ++n })), - (value) => - Effect.sync(() => { - seen.push(String(value.n)) - }), - ), - ) - - const a = yield* Effect.promise(() => access(state, tmp.path)) - yield* Effect.promise(() => Instance.reload({ directory: tmp.path })) - const b = yield* Effect.promise(() => access(state, tmp.path)) - - expect(a).not.toBe(b) - expect(seen).toEqual(["1"]) - }), - ), - ) -}) - -test("InstanceState invalidates on disposeAll", async () => { - await using one = await tmpdir() - await using two = await tmpdir() - const seen: string[] = [] - - await Effect.runPromise( - Effect.scoped( - Effect.gen(function* () { - const state = yield* InstanceState.make((ctx) => - Effect.acquireRelease( - Effect.sync(() => ({ dir: ctx.directory })), - (value) => - Effect.sync(() => { - seen.push(value.dir) - }), - ), - ) - - yield* Effect.promise(() => access(state, one.path)) - yield* Effect.promise(() => access(state, two.path)) - yield* Effect.promise(() => Instance.disposeAll()) - - expect(seen.sort()).toEqual([one.path, two.path].sort()) - }), - ), - ) -}) - -test("InstanceState.get reads the current directory lazily", async () => { - await using one = await tmpdir() - await using two = await tmpdir() - - interface Api { - readonly get: () => Effect.Effect - } - - class Test extends Context.Service()("@test/InstanceStateLazy") { - static readonly layer = Layer.effect( - Test, - Effect.gen(function* () { - const state = yield* InstanceState.make((ctx) => Effect.sync(() => ctx.directory)) - const get = InstanceState.get(state) - - return Test.of({ - get: Effect.fn("Test.get")(function* () { - return yield* get +it.live("InstanceState invalidates on reload", () => + Effect.gen(function* () { + const dir = yield* tmpdirScoped() + const seen: string[] = [] + let n = 0 + const state = yield* InstanceState.make(() => + Effect.acquireRelease( + Effect.sync(() => ({ n: ++n })), + (value) => + Effect.sync(() => { + seen.push(String(value.n)) }), - }) - }), - ) - } - - const rt = ManagedRuntime.make(Test.layer) - - try { - const a = await Instance.provide({ - directory: one.path, - fn: () => rt.runPromise(Test.use((svc) => svc.get())), - }) - const b = await Instance.provide({ - directory: two.path, - fn: () => rt.runPromise(Test.use((svc) => svc.get())), - }) - - expect(a).toBe(one.path) - expect(b).toBe(two.path) - } finally { - await rt.dispose() - } -}) - -test("InstanceState preserves directory across async boundaries", async () => { - await using one = await tmpdir({ git: true }) - await using two = await tmpdir({ git: true }) - await using three = await tmpdir({ git: true }) - - interface Api { - readonly get: () => Effect.Effect<{ directory: string; worktree: string; project: string }> - } - - class Test extends Context.Service()("@test/InstanceStateAsync") { - static readonly layer = Layer.effect( - Test, - Effect.gen(function* () { - const state = yield* InstanceState.make((ctx) => - Effect.sync(() => ({ - directory: ctx.directory, - worktree: ctx.worktree, - project: ctx.project.id, - })), - ) - - return Test.of({ - get: Effect.fn("Test.get")(function* () { - yield* Effect.promise(() => Bun.sleep(1)) - yield* Effect.sleep(Duration.millis(1)) - for (let i = 0; i < 100; i++) { - yield* Effect.yieldNow - } - for (let i = 0; i < 100; i++) { - yield* Effect.promise(() => Promise.resolve()) - } - yield* Effect.sleep(Duration.millis(2)) - yield* Effect.promise(() => Bun.sleep(1)) - return yield* InstanceState.get(state) - }), - }) - }), - ) - } - - const rt = ManagedRuntime.make(Test.layer) - - try { - const [a, b, c] = await Promise.all([ - Instance.provide({ - directory: one.path, - fn: () => rt.runPromise(Test.use((svc) => svc.get())), - }), - Instance.provide({ - directory: two.path, - fn: () => rt.runPromise(Test.use((svc) => svc.get())), - }), - Instance.provide({ - directory: three.path, - fn: () => rt.runPromise(Test.use((svc) => svc.get())), - }), - ]) - - expect(a).toEqual({ directory: one.path, worktree: one.path, project: a.project }) - expect(b).toEqual({ directory: two.path, worktree: two.path, project: b.project }) - expect(c).toEqual({ directory: three.path, worktree: three.path, project: c.project }) - expect(a.project).not.toBe(b.project) - expect(a.project).not.toBe(c.project) - expect(b.project).not.toBe(c.project) - } finally { - await rt.dispose() - } -}) - -test("InstanceState survives high-contention concurrent access", async () => { - const N = 20 - const dirs = await Promise.all(Array.from({ length: N }, () => tmpdir())) - - interface Api { - readonly get: () => Effect.Effect - } - - class Test extends Context.Service()("@test/HighContention") { - static readonly layer = Layer.effect( - Test, - Effect.gen(function* () { - const state = yield* InstanceState.make((ctx) => Effect.sync(() => ctx.directory)) - - return Test.of({ - get: Effect.fn("Test.get")(function* () { - // Interleave many async hops to maximize chance of ALS corruption - for (let i = 0; i < 10; i++) { - yield* Effect.promise(() => Bun.sleep(Math.random() * 3)) - yield* Effect.yieldNow - yield* Effect.promise(() => Promise.resolve()) - } - return yield* InstanceState.get(state) - }), - }) - }), - ) - } - - const rt = ManagedRuntime.make(Test.layer) - - try { - const results = await Promise.all( - dirs.map((d) => - Instance.provide({ - directory: d.path, - fn: () => rt.runPromise(Test.use((svc) => svc.get())), - }), ), ) - for (let i = 0; i < N; i++) { - expect(results[i]).toBe(dirs[i].path) + const a = yield* access(state, dir) + yield* Effect.promise(() => InstanceStore.reloadInstance({ directory: dir })) + const b = yield* access(state, dir) + + expect(a).not.toBe(b) + expect(seen).toEqual(["1"]) + }), +) + +it.live("InstanceState invalidates on disposeAll", () => + Effect.gen(function* () { + const one = yield* tmpdirScoped() + const two = yield* tmpdirScoped() + const seen: string[] = [] + const state = yield* InstanceState.make((ctx) => + Effect.acquireRelease( + Effect.sync(() => ({ dir: ctx.directory })), + (value) => + Effect.sync(() => { + seen.push(value.dir) + }), + ), + ) + + yield* access(state, one) + yield* access(state, two) + yield* Effect.promise(disposeAllInstances) + + expect(seen.sort()).toEqual([one, two].sort()) + }), +) + +it.live("InstanceState.get reads the current directory lazily", () => + Effect.gen(function* () { + const one = yield* tmpdirScoped() + const two = yield* tmpdirScoped() + + interface Api { + readonly get: () => Effect.Effect } - } finally { - await rt.dispose() - for (const d of dirs) await d[Symbol.asyncDispose]() - } -}) -test("InstanceState correct after interleaved init and dispose", async () => { - await using one = await tmpdir() - await using two = await tmpdir() + class Test extends Context.Service()("@test/InstanceStateLazy") { + static readonly layer = Layer.effect( + Test, + Effect.gen(function* () { + const state = yield* InstanceState.make((ctx) => Effect.sync(() => ctx.directory)) + const get = InstanceState.get(state) - interface Api { - readonly get: () => Effect.Effect - } + return Test.of({ + get: Effect.fn("Test.get")(function* () { + return yield* get + }), + }) + }), + ) + } - class Test extends Context.Service()("@test/InterleavedDispose") { - static readonly layer = Layer.effect( - Test, - Effect.gen(function* () { - const state = yield* InstanceState.make((ctx) => - Effect.promise(async () => { - await Bun.sleep(5) // slow init - return ctx.directory - }), - ) + yield* Effect.gen(function* () { + const a = yield* Test.use((svc) => svc.get()).pipe(provideInstance(one)) + const b = yield* Test.use((svc) => svc.get()).pipe(provideInstance(two)) - return Test.of({ - get: Effect.fn("Test.get")(function* () { - return yield* InstanceState.get(state) - }), - }) + expect(a).toBe(one) + expect(b).toBe(two) + }).pipe(Effect.provide(Test.layer)) + }), +) + +it.live("InstanceState preserves directory across async boundaries", () => + Effect.gen(function* () { + const one = yield* tmpdirGitScoped + const two = yield* tmpdirGitScoped + const three = yield* tmpdirGitScoped + + interface Api { + readonly get: () => Effect.Effect<{ directory: string; worktree: string; project: string }> + } + + class Test extends Context.Service()("@test/InstanceStateAsync") { + static readonly layer = Layer.effect( + Test, + Effect.gen(function* () { + const state = yield* InstanceState.make((ctx) => + Effect.sync(() => ({ + directory: ctx.directory, + worktree: ctx.worktree, + project: ctx.project.id, + })), + ) + + return Test.of({ + get: Effect.fn("Test.get")(function* () { + yield* Effect.promise(() => Bun.sleep(1)) + yield* Effect.sleep(Duration.millis(1)) + for (let i = 0; i < 100; i++) { + yield* Effect.yieldNow + } + for (let i = 0; i < 100; i++) { + yield* Effect.promise(() => Promise.resolve()) + } + yield* Effect.sleep(Duration.millis(2)) + yield* Effect.promise(() => Bun.sleep(1)) + return yield* InstanceState.get(state) + }), + }) + }), + ) + } + + yield* Effect.gen(function* () { + const [a, b, c] = yield* Effect.all( + [one, two, three].map((dir) => Test.use((svc) => svc.get()).pipe(provideInstance(dir))), + { concurrency: "unbounded" }, + ) + + expect(a).toEqual({ directory: one, worktree: one, project: a.project }) + expect(b).toEqual({ directory: two, worktree: two, project: b.project }) + expect(c).toEqual({ directory: three, worktree: three, project: c.project }) + expect(a.project).not.toBe(b.project) + expect(a.project).not.toBe(c.project) + expect(b.project).not.toBe(c.project) + }).pipe(Effect.provide(Test.layer)) + }), +) + +it.live("InstanceState survives high-contention concurrent access", () => + Effect.gen(function* () { + const dirs = yield* Effect.all( + Array.from({ length: 20 }, () => tmpdirScoped()), + { concurrency: "unbounded" }, + ) + + interface Api { + readonly get: () => Effect.Effect + } + + class Test extends Context.Service()("@test/HighContention") { + static readonly layer = Layer.effect( + Test, + Effect.gen(function* () { + const state = yield* InstanceState.make((ctx) => Effect.sync(() => ctx.directory)) + + return Test.of({ + get: Effect.fn("Test.get")(function* () { + for (let i = 0; i < 10; i++) { + yield* Effect.promise(() => Bun.sleep(Math.random() * 3)) + yield* Effect.yieldNow + yield* Effect.promise(() => Promise.resolve()) + } + return yield* InstanceState.get(state) + }), + }) + }), + ) + } + + yield* Effect.gen(function* () { + const results = yield* Effect.all( + dirs.map((dir) => Test.use((svc) => svc.get()).pipe(provideInstance(dir))), + { concurrency: "unbounded" }, + ) + + expect(results).toEqual(dirs) + }).pipe(Effect.provide(Test.layer)) + }), +) + +it.live("InstanceState correct after interleaved init and dispose", () => + Effect.gen(function* () { + const one = yield* tmpdirScoped() + const two = yield* tmpdirScoped() + + interface Api { + readonly get: () => Effect.Effect + } + + class Test extends Context.Service()("@test/InterleavedDispose") { + static readonly layer = Layer.effect( + Test, + Effect.gen(function* () { + const state = yield* InstanceState.make((ctx) => + Effect.promise(async () => { + await Bun.sleep(5) + return ctx.directory + }), + ) + + return Test.of({ + get: Effect.fn("Test.get")(function* () { + return yield* InstanceState.get(state) + }), + }) + }), + ) + } + + yield* Effect.gen(function* () { + const a = yield* Test.use((svc) => svc.get()).pipe(provideInstance(one)) + expect(a).toBe(one) + + const [, b] = yield* Effect.all( + [ + Effect.promise(() => InstanceStore.reloadInstance({ directory: one })), + Test.use((svc) => svc.get()).pipe(provideInstance(two)), + ], + { concurrency: "unbounded" }, + ) + expect(b).toBe(two) + + const c = yield* Test.use((svc) => svc.get()).pipe(provideInstance(one)) + expect(c).toBe(one) + }).pipe(Effect.provide(Test.layer)) + }), +) + +it.live("InstanceState mutation in one directory does not leak to another", () => + Effect.gen(function* () { + const one = yield* tmpdirScoped() + const two = yield* tmpdirScoped() + const state = yield* InstanceState.make(() => Effect.sync(() => ({ count: 0 }))) + + const s1 = yield* access(state, one) + s1.count = 42 + + const s2 = yield* access(state, two) + expect(s2.count).toBe(0) + + const s1again = yield* access(state, one) + expect(s1again.count).toBe(42) + expect(s1again).toBe(s1) + }), +) + +it.live("InstanceState dedupes concurrent lookups", () => + Effect.gen(function* () { + const dir = yield* tmpdirScoped() + let n = 0 + const state = yield* InstanceState.make(() => + Effect.promise(async () => { + n += 1 + await Bun.sleep(10) + return { n } }), ) - } - const rt = ManagedRuntime.make(Test.layer) + const [a, b] = yield* Effect.all([access(state, dir), access(state, dir)], { concurrency: "unbounded" }) + expect(a).toBe(b) + expect(n).toBe(1) + }), +) - try { - // Init both directories - const a = await Instance.provide({ - directory: one.path, - fn: () => rt.runPromise(Test.use((svc) => svc.get())), - }) - expect(a).toBe(one.path) +it.live("InstanceState survives deferred resume from the same instance context", () => + Effect.gen(function* () { + const dir = yield* tmpdirScoped({ git: true }) - // Dispose one directory, access the other concurrently - const [, b] = await Promise.all([ - Instance.reload({ directory: one.path }), - Instance.provide({ - directory: two.path, - fn: () => rt.runPromise(Test.use((svc) => svc.get())), - }), - ]) - expect(b).toBe(two.path) - - // Re-access disposed directory - should get fresh state - const c = await Instance.provide({ - directory: one.path, - fn: () => rt.runPromise(Test.use((svc) => svc.get())), - }) - expect(c).toBe(one.path) - } finally { - await rt.dispose() - } -}) - -test("InstanceState mutation in one directory does not leak to another", async () => { - await using one = await tmpdir() - await using two = await tmpdir() - - await Effect.runPromise( - Effect.scoped( - Effect.gen(function* () { - const state = yield* InstanceState.make(() => Effect.sync(() => ({ count: 0 }))) - - // Mutate state in directory one - const s1 = yield* Effect.promise(() => access(state, one.path)) - s1.count = 42 - - // Access directory two — should be independent - const s2 = yield* Effect.promise(() => access(state, two.path)) - expect(s2.count).toBe(0) - - // Confirm directory one still has the mutation - const s1again = yield* Effect.promise(() => access(state, one.path)) - expect(s1again.count).toBe(42) - expect(s1again).toBe(s1) // same reference - }), - ), - ) -}) - -test("InstanceState dedupes concurrent lookups", async () => { - await using tmp = await tmpdir() - let n = 0 - - await Effect.runPromise( - Effect.scoped( - Effect.gen(function* () { - const state = yield* InstanceState.make(() => - Effect.promise(async () => { - n += 1 - await Bun.sleep(10) - return { n } - }), - ) - - const [a, b] = yield* Effect.promise(() => Promise.all([access(state, tmp.path), access(state, tmp.path)])) - expect(a).toBe(b) - expect(n).toBe(1) - }), - ), - ) -}) - -test("InstanceState survives deferred resume from the same instance context", async () => { - await using tmp = await tmpdir({ git: true }) - - interface Api { - readonly get: (gate: Deferred.Deferred) => Effect.Effect - } - - class Test extends Context.Service()("@test/DeferredResume") { - static readonly layer = Layer.effect( - Test, - Effect.gen(function* () { - const state = yield* InstanceState.make((ctx) => Effect.sync(() => ctx.directory)) - - return Test.of({ - get: Effect.fn("Test.get")(function* (gate: Deferred.Deferred) { - yield* Deferred.await(gate) - return yield* InstanceState.get(state) - }), - }) - }), - ) - } - - const rt = ManagedRuntime.make(Test.layer) - - try { - const gate = await Effect.runPromise(Deferred.make()) - const fiber = await Instance.provide({ - directory: tmp.path, - fn: () => Promise.resolve(rt.runFork(Test.use((svc) => svc.get(gate)))), - }) - - await Instance.provide({ - directory: tmp.path, - fn: () => Effect.runPromise(Deferred.succeed(gate, void 0)), - }) - const exit = await Effect.runPromise(Fiber.await(fiber)) - - expect(Exit.isSuccess(exit)).toBe(true) - if (Exit.isSuccess(exit)) { - expect(exit.value).toBe(tmp.path) + interface Api { + readonly get: (gate: Deferred.Deferred) => Effect.Effect } - } finally { - await rt.dispose() - } -}) -test("InstanceState survives deferred resume outside ALS when InstanceRef is set", async () => { - await using tmp = await tmpdir({ git: true }) + class Test extends Context.Service()("@test/DeferredResume") { + static readonly layer = Layer.effect( + Test, + Effect.gen(function* () { + const state = yield* InstanceState.make((ctx) => Effect.sync(() => ctx.directory)) - interface Api { - readonly get: (gate: Deferred.Deferred) => Effect.Effect - } - - class Test extends Context.Service()("@test/DeferredResumeOutside") { - static readonly layer = Layer.effect( - Test, - Effect.gen(function* () { - const state = yield* InstanceState.make((ctx) => Effect.sync(() => ctx.directory)) - - return Test.of({ - get: Effect.fn("Test.get")(function* (gate: Deferred.Deferred) { - yield* Deferred.await(gate) - return yield* InstanceState.get(state) - }), - }) - }), - ) - } - - const rt = ManagedRuntime.make(Test.layer) - - try { - const gate = await Effect.runPromise(Deferred.make()) - // Provide InstanceRef so the fiber carries the context even when - // the deferred is resolved from outside Instance.provide ALS. - const fiber = await Instance.provide({ - directory: tmp.path, - fn: () => - Promise.resolve( - rt.runFork(Test.use((svc) => svc.get(gate)).pipe(Effect.provideService(InstanceRef, Instance.current))), - ), - }) - - // Resume from outside any Instance.provide — ALS is NOT set here - await Effect.runPromise(Deferred.succeed(gate, void 0)) - const exit = await Effect.runPromise(Fiber.await(fiber)) - - expect(Exit.isSuccess(exit)).toBe(true) - if (Exit.isSuccess(exit)) { - expect(exit.value).toBe(tmp.path) + return Test.of({ + get: Effect.fn("Test.get")(function* (gate: Deferred.Deferred) { + yield* Deferred.await(gate) + return yield* InstanceState.get(state) + }), + }) + }), + ) } - } finally { - await rt.dispose() - } -}) + + yield* Effect.gen(function* () { + const gate = yield* Deferred.make() + const fiber = yield* Test.use((svc) => svc.get(gate)).pipe(provideInstance(dir), Effect.forkScoped) + + yield* Deferred.succeed(gate, undefined).pipe(provideInstance(dir)) + const exit = yield* Fiber.await(fiber) + + expect(Exit.isSuccess(exit)).toBe(true) + if (Exit.isSuccess(exit)) expect(exit.value).toBe(dir) + }).pipe(Effect.provide(Test.layer)) + }), +) + +it.live("InstanceState survives deferred resume outside ALS when InstanceRef is set", () => + Effect.gen(function* () { + const dir = yield* tmpdirScoped({ git: true }) + + interface Api { + readonly get: (gate: Deferred.Deferred) => Effect.Effect + } + + class Test extends Context.Service()("@test/DeferredResumeOutside") { + static readonly layer = Layer.effect( + Test, + Effect.gen(function* () { + const state = yield* InstanceState.make((ctx) => Effect.sync(() => ctx.directory)) + + return Test.of({ + get: Effect.fn("Test.get")(function* (gate: Deferred.Deferred) { + yield* Deferred.await(gate) + return yield* InstanceState.get(state) + }), + }) + }), + ) + } + + yield* Effect.gen(function* () { + const gate = yield* Deferred.make() + const fiber = yield* Test.use((svc) => svc.get(gate)).pipe(provideInstance(dir), Effect.forkScoped) + + yield* Deferred.succeed(gate, undefined) + const exit = yield* Fiber.await(fiber) + + expect(Exit.isSuccess(exit)).toBe(true) + if (Exit.isSuccess(exit)) expect(exit.value).toBe(dir) + }).pipe(Effect.provide(Test.layer)) + }), +) diff --git a/packages/opencode/test/effect/run-service.test.ts b/packages/opencode/test/effect/run-service.test.ts index b5f1a1d09b..16538bb8ae 100644 --- a/packages/opencode/test/effect/run-service.test.ts +++ b/packages/opencode/test/effect/run-service.test.ts @@ -1,46 +1,89 @@ -import { expect, test } from "bun:test" +import { expect } from "bun:test" import { Effect, Layer, Context } from "effect" +import { InstanceRef } from "../../src/effect/instance-ref" import { makeRuntime } from "../../src/effect/run-service" +import { ProjectID } from "../../src/project/schema" +import { it } from "../lib/effect" class Shared extends Context.Service()("@test/Shared") {} +const testDirectory = "/tmp/opencode-test" -test("makeRuntime shares dependent layers through the shared memo map", async () => { - let n = 0 +it.live("makeRuntime shares dependent layers through the shared memo map", () => + Effect.gen(function* () { + let n = 0 - const shared = Layer.effect( - Shared, - Effect.sync(() => { - n += 1 - return Shared.of({ id: n }) + const shared = Layer.effect( + Shared, + Effect.sync(() => { + n += 1 + return Shared.of({ id: n }) + }), + ) + + class One extends Context.Service Effect.Effect }>()("@test/One") {} + const one = Layer.effect( + One, + Effect.gen(function* () { + const svc = yield* Shared + return One.of({ + get: Effect.fn("One.get")(() => Effect.succeed(svc.id)), + }) + }), + ).pipe(Layer.provide(shared)) + + class Two extends Context.Service Effect.Effect }>()("@test/Two") {} + const two = Layer.effect( + Two, + Effect.gen(function* () { + const svc = yield* Shared + return Two.of({ + get: Effect.fn("Two.get")(() => Effect.succeed(svc.id)), + }) + }), + ).pipe(Layer.provide(shared)) + + const { runPromise: runOne } = makeRuntime(One, one) + const { runPromise: runTwo } = makeRuntime(Two, two) + + expect(yield* Effect.promise(() => runOne((svc) => svc.get()))).toBe(1) + expect(yield* Effect.promise(() => runTwo((svc) => svc.get()))).toBe(1) + expect(n).toBe(1) + }), +) + +it.live("makeRuntime inherits InstanceRef from the current fiber", () => + Effect.gen(function* () { + class NeedsInstance extends Context.Service< + NeedsInstance, + { readonly directory: () => Effect.Effect } + >()("@test/NeedsInstance") {} + + const runtime = makeRuntime( + NeedsInstance, + Layer.succeed( + NeedsInstance, + NeedsInstance.of({ + directory: () => + Effect.gen(function* () { + return (yield* InstanceRef)?.directory + }), + }), + ), + ) + + const actual = yield* Effect.promise(() => runtime.runPromise((svc) => svc.directory())) + + expect(actual).toBe(testDirectory) + }).pipe( + Effect.provideService(InstanceRef, { + directory: testDirectory, + worktree: testDirectory, + project: { + id: ProjectID.global, + worktree: testDirectory, + time: { created: 0, updated: 0 }, + sandboxes: [], + }, }), - ) - - class One extends Context.Service Effect.Effect }>()("@test/One") {} - const one = Layer.effect( - One, - Effect.gen(function* () { - const svc = yield* Shared - return One.of({ - get: Effect.fn("One.get")(() => Effect.succeed(svc.id)), - }) - }), - ).pipe(Layer.provide(shared)) - - class Two extends Context.Service Effect.Effect }>()("@test/Two") {} - const two = Layer.effect( - Two, - Effect.gen(function* () { - const svc = yield* Shared - return Two.of({ - get: Effect.fn("Two.get")(() => Effect.succeed(svc.id)), - }) - }), - ).pipe(Layer.provide(shared)) - - const { runPromise: runOne } = makeRuntime(One, one) - const { runPromise: runTwo } = makeRuntime(Two, two) - - expect(await runOne((svc) => svc.get())).toBe(1) - expect(await runTwo((svc) => svc.get())).toBe(1) - expect(n).toBe(1) -}) + ), +) diff --git a/packages/opencode/test/effect/runner.test.ts b/packages/opencode/test/effect/runner.test.ts index ee99050a8c..0f5783bfc4 100644 --- a/packages/opencode/test/effect/runner.test.ts +++ b/packages/opencode/test/effect/runner.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, test } from "bun:test" +import { describe, expect } from "bun:test" import { Deferred, Effect, Exit, Fiber, Ref, Scope } from "effect" import { Runner } from "@/effect/runner" import { it } from "../lib/effect" @@ -115,8 +115,16 @@ describe("Runner", () => { Effect.gen(function* () { const s = yield* Scope.Scope const runner = Runner.make(s) - const fiber = yield* runner.ensureRunning(Effect.never.pipe(Effect.as("never"))).pipe(Effect.forkChild) - yield* Effect.sleep("10 millis") + const started = yield* Deferred.make() + const fiber = yield* runner + .ensureRunning( + Effect.gen(function* () { + yield* Deferred.succeed(started, void 0) + return yield* Effect.never.pipe(Effect.as("never")) + }), + ) + .pipe(Effect.forkChild) + yield* Deferred.await(started) expect(runner.busy).toBe(true) expect(runner.state._tag).toBe("Running") @@ -190,58 +198,52 @@ describe("Runner", () => { }), ) - test("cancel does not deadlock when replacement work starts before interrupted run exits", async () => { - function defer() { - let resolve!: () => void - const promise = new Promise((done) => { - resolve = done - }) - return { promise, resolve } - } + it.live( + "cancel does not deadlock when replacement work starts before interrupted run exits", + Effect.gen(function* () { + const s = yield* Scope.Scope + const hit = yield* Deferred.make() + const hold = yield* Deferred.make() + const done = yield* Deferred.make() - function fail(ms: number, msg: string) { - return new Promise((_, reject) => { - setTimeout(() => reject(new Error(msg)), ms) - }) - } + yield* Effect.gen(function* () { + const runner = Runner.make(s) + const first = Effect.never.pipe( + Effect.onInterrupt(() => Deferred.succeed(hit, undefined)), + Effect.ensuring(Deferred.await(hold)), + Effect.as("first"), + ) - const s = await Effect.runPromise(Scope.make()) - const hit = defer() - const hold = defer() - const done = defer() - try { - const runner = Runner.make(s) - const first = Effect.never.pipe( - Effect.onInterrupt(() => Effect.sync(() => hit.resolve())), - Effect.ensuring(Effect.promise(() => hold.promise)), - Effect.as("first"), + const a = yield* runner.ensureRunning(first).pipe(Effect.exit, Effect.forkChild) + yield* Effect.sleep("10 millis") + + const stop = yield* runner.cancel.pipe(Effect.forkChild) + yield* Deferred.await(hit).pipe(Effect.timeout("250 millis")) + + const b = yield* runner.ensureRunning(Deferred.await(done).pipe(Effect.as("second"))).pipe(Effect.forkChild) + yield* Effect.yieldNow + expect(runner.busy).toBe(true) + + yield* Deferred.succeed(hold, undefined) + const stopExit = yield* Fiber.await(stop).pipe(Effect.timeout("250 millis")) + expect(Exit.isSuccess(stopExit)).toBe(true) + + expect(runner.busy).toBe(true) + yield* Deferred.succeed(done, undefined) + expect(yield* Fiber.join(b).pipe(Effect.timeout("250 millis"))).toBe("second") + expect(runner.busy).toBe(false) + + const exit = yield* Fiber.join(a) + expect(Exit.isFailure(exit)).toBe(true) + }).pipe( + Effect.ensuring( + Effect.all([Deferred.succeed(hold, undefined), Deferred.succeed(done, undefined)], { discard: true }).pipe( + Effect.ignore, + ), + ), ) - - const a = Effect.runPromiseExit(runner.ensureRunning(first)) - await Bun.sleep(10) - - const stop = Effect.runPromise(runner.cancel) - await Promise.race([hit.promise, fail(250, "cancel did not interrupt running work")]) - - const b = Effect.runPromise(runner.ensureRunning(Effect.promise(() => done.promise).pipe(Effect.as("second")))) - expect(runner.busy).toBe(true) - - hold.resolve() - await Promise.race([stop, fail(250, "cancel deadlocked while replacement run was active")]) - - expect(runner.busy).toBe(true) - done.resolve() - expect(await b).toBe("second") - expect(runner.busy).toBe(false) - - const exit = await a - expect(Exit.isFailure(exit)).toBe(true) - } finally { - hold.resolve() - done.resolve() - await Promise.race([Effect.runPromise(Scope.close(s, Exit.void)), fail(1000, "runner scope did not close")]) - } - }) + }), + ) // --- shell semantics --- @@ -261,14 +263,25 @@ describe("Runner", () => { Effect.gen(function* () { const s = yield* Scope.Scope const runner = Runner.make(s) - const fiber = yield* runner.ensureRunning(Effect.never.pipe(Effect.as("x"))).pipe(Effect.forkChild) - yield* Effect.sleep("10 millis") + const started = yield* Deferred.make() + const fiber = yield* runner + .ensureRunning( + Effect.gen(function* () { + yield* Deferred.succeed(started, undefined) + return yield* Effect.never.pipe(Effect.as("x")) + }), + ) + .pipe(Effect.forkChild) + yield* Deferred.await(started).pipe(Effect.timeout("250 millis")) + yield* Effect.gen(function* () { + while (runner.state._tag !== "Running") yield* Effect.yieldNow + }).pipe(Effect.timeout("250 millis")) const exit = yield* runner.startShell(Effect.succeed("nope")).pipe(Effect.exit) expect(Exit.isFailure(exit)).toBe(true) yield* runner.cancel - yield* Fiber.await(fiber) + yield* Fiber.await(fiber).pipe(Effect.timeout("250 millis")) }), ) diff --git a/packages/opencode/test/file/index.test.ts b/packages/opencode/test/file/index.test.ts index 3d3d3b94ac..48ed7f09cd 100644 --- a/packages/opencode/test/file/index.test.ts +++ b/packages/opencode/test/file/index.test.ts @@ -6,10 +6,10 @@ import fs from "fs/promises" import { File } from "../../src/file" import { Instance } from "../../src/project/instance" import { Filesystem } from "@/util/filesystem" -import { provideInstance, tmpdir } from "../fixture/fixture" +import { disposeAllInstances, provideInstance, tmpdir } from "../fixture/fixture" afterEach(async () => { - await Instance.disposeAll() + await disposeAllInstances() }) const init = () => run(File.Service.use((svc) => svc.init())) @@ -938,7 +938,7 @@ describe("file/index Filesystem patterns", () => { }, }) - await Instance.disposeAll() + await disposeAllInstances() await fs.writeFile(path.join(tmp.path, "after.ts"), "after", "utf-8") await fs.rm(path.join(tmp.path, "before.ts")) diff --git a/packages/opencode/test/file/path-traversal.test.ts b/packages/opencode/test/file/path-traversal.test.ts index a52af7023a..3a5ce2323e 100644 --- a/packages/opencode/test/file/path-traversal.test.ts +++ b/packages/opencode/test/file/path-traversal.test.ts @@ -5,6 +5,7 @@ import fs from "fs/promises" import { Filesystem } from "@/util/filesystem" import { File } from "../../src/file" import { Instance } from "../../src/project/instance" +import { containsPath } from "../../src/project/instance-context" import { provideInstance, tmpdir } from "../fixture/fixture" const run = (eff: Effect.Effect) => @@ -121,15 +122,15 @@ describe("File.list path traversal protection", () => { }) }) -describe("Instance.containsPath", () => { +describe("containsPath", () => { test("returns true for path inside directory", async () => { await using tmp = await tmpdir({ git: true }) await Instance.provide({ directory: tmp.path, fn: () => { - expect(Instance.containsPath(path.join(tmp.path, "foo.txt"))).toBe(true) - expect(Instance.containsPath(path.join(tmp.path, "src", "file.ts"))).toBe(true) + expect(containsPath(path.join(tmp.path, "foo.txt"), Instance.current)).toBe(true) + expect(containsPath(path.join(tmp.path, "src", "file.ts"), Instance.current)).toBe(true) }, }) }) @@ -143,11 +144,11 @@ describe("Instance.containsPath", () => { directory: subdir, fn: () => { // .opencode at worktree root, but we're running from packages/lib - expect(Instance.containsPath(path.join(tmp.path, ".opencode", "state"))).toBe(true) + expect(containsPath(path.join(tmp.path, ".opencode", "state"), Instance.current)).toBe(true) // sibling package should also be accessible - expect(Instance.containsPath(path.join(tmp.path, "packages", "other", "file.ts"))).toBe(true) + expect(containsPath(path.join(tmp.path, "packages", "other", "file.ts"), Instance.current)).toBe(true) // worktree root itself - expect(Instance.containsPath(tmp.path)).toBe(true) + expect(containsPath(tmp.path, Instance.current)).toBe(true) }, }) }) @@ -158,8 +159,8 @@ describe("Instance.containsPath", () => { await Instance.provide({ directory: tmp.path, fn: () => { - expect(Instance.containsPath("/etc/passwd")).toBe(false) - expect(Instance.containsPath("/tmp/other-project")).toBe(false) + expect(containsPath("/etc/passwd", Instance.current)).toBe(false) + expect(containsPath("/tmp/other-project", Instance.current)).toBe(false) }, }) }) @@ -170,7 +171,7 @@ describe("Instance.containsPath", () => { await Instance.provide({ directory: tmp.path, fn: () => { - expect(Instance.containsPath(path.join(tmp.path, "..", "escape.txt"))).toBe(false) + expect(containsPath(path.join(tmp.path, "..", "escape.txt"), Instance.current)).toBe(false) }, }) }) @@ -182,8 +183,8 @@ describe("Instance.containsPath", () => { directory: tmp.path, fn: () => { expect(Instance.directory).toBe(Instance.worktree) - expect(Instance.containsPath(path.join(tmp.path, "file.txt"))).toBe(true) - expect(Instance.containsPath("/etc/passwd")).toBe(false) + expect(containsPath(path.join(tmp.path, "file.txt"), Instance.current)).toBe(true) + expect(containsPath("/etc/passwd", Instance.current)).toBe(false) }, }) }) @@ -195,9 +196,9 @@ describe("Instance.containsPath", () => { directory: tmp.path, fn: () => { // worktree is "/" for non-git projects, but containsPath should NOT allow all paths - expect(Instance.containsPath(path.join(tmp.path, "file.txt"))).toBe(true) - expect(Instance.containsPath("/etc/passwd")).toBe(false) - expect(Instance.containsPath("/tmp/other")).toBe(false) + expect(containsPath(path.join(tmp.path, "file.txt"), Instance.current)).toBe(true) + expect(containsPath("/etc/passwd", Instance.current)).toBe(false) + expect(containsPath("/tmp/other", Instance.current)).toBe(false) }, }) }) diff --git a/packages/opencode/test/file/watcher.test.ts b/packages/opencode/test/file/watcher.test.ts index bed79fb63b..ef71483661 100644 --- a/packages/opencode/test/file/watcher.test.ts +++ b/packages/opencode/test/file/watcher.test.ts @@ -3,7 +3,7 @@ import { afterEach, describe, expect, test } from "bun:test" import fs from "fs/promises" import path from "path" import { ConfigProvider, Deferred, Effect, Layer, ManagedRuntime, Option } from "effect" -import { tmpdir } from "../fixture/fixture" +import { disposeAllInstances, tmpdir } from "../fixture/fixture" import { Bus } from "../../src/bus" import { Config } from "@/config/config" import { FileWatcher } from "../../src/file/watcher" @@ -147,7 +147,7 @@ function ready(directory: string) { describeWatcher("FileWatcher", () => { afterEach(async () => { - await Instance.disposeAll() + await disposeAllInstances() }) test("publishes root create, update, and delete events", async () => { diff --git a/packages/opencode/test/fixture/db.ts b/packages/opencode/test/fixture/db.ts index 4e83d0b906..07b42d9946 100644 --- a/packages/opencode/test/fixture/db.ts +++ b/packages/opencode/test/fixture/db.ts @@ -1,9 +1,9 @@ import { rm } from "fs/promises" -import { Instance } from "../../src/project/instance" import { Database } from "@/storage/db" +import { disposeAllInstances } from "./fixture" export async function resetDatabase() { - await Instance.disposeAll().catch(() => undefined) + await disposeAllInstances().catch(() => undefined) Database.close() await rm(Database.Path, { force: true }).catch(() => undefined) await rm(`${Database.Path}-wal`, { force: true }).catch(() => undefined) diff --git a/packages/opencode/test/fixture/fixture.ts b/packages/opencode/test/fixture/fixture.ts index 9ef0e6a26a..a920a899aa 100644 --- a/packages/opencode/test/fixture/fixture.ts +++ b/packages/opencode/test/fixture/fixture.ts @@ -8,10 +8,15 @@ import type * as Scope from "effect/Scope" import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process" import type { Config } from "@/config/config" import { InstanceRef } from "../../src/effect/instance-ref" +import { InstanceStore } from "../../src/project/instance-store" import { Instance } from "../../src/project/instance" import { TestLLMServer } from "../lib/llm-server" import { remove as cleanup } from "../kilocode/cleanup" // kilocode_change +// Re-export for test ergonomics. The implementation lives next to the runtime +// it consumes; see `InstanceStore.disposeAllInstances` for the rationale. +export { disposeAllInstances } from "../../src/project/instance-store" + // Strip null bytes from paths (defensive fix for CI environment issues) function sanitizePath(p: string): string { return p.replace(/\0/g, "") @@ -141,7 +146,7 @@ export function provideTmpdirInstance( ? Effect.promise(() => Instance.provide({ directory: path, - fn: () => Instance.dispose(), + fn: () => InstanceStore.disposeInstance(Instance.current), }), ).pipe(Effect.ignore) : Effect.void, diff --git a/packages/opencode/test/kilocode/agent-global-config-dirs.test.ts b/packages/opencode/test/kilocode/agent-global-config-dirs.test.ts index e0296061e9..552a7a9a4e 100644 --- a/packages/opencode/test/kilocode/agent-global-config-dirs.test.ts +++ b/packages/opencode/test/kilocode/agent-global-config-dirs.test.ts @@ -1,13 +1,13 @@ // kilocode_change - new file import { afterEach, test, expect } from "bun:test" -import { tmpdir } from "../fixture/fixture" +import { disposeAllInstances, tmpdir } from "../fixture/fixture" import { Instance } from "../../src/project/instance" import { Agent } from "../../src/agent/agent" import { Permission } from "../../src/permission" import { Global } from "@opencode-ai/core/global" afterEach(async () => { - await Instance.disposeAll() + await disposeAllInstances() }) test("code agent allows global config directory reads by default", async () => { diff --git a/packages/opencode/test/kilocode/agent-skill-permissions.test.ts b/packages/opencode/test/kilocode/agent-skill-permissions.test.ts index 9742d17760..2a89499e9d 100644 --- a/packages/opencode/test/kilocode/agent-skill-permissions.test.ts +++ b/packages/opencode/test/kilocode/agent-skill-permissions.test.ts @@ -1,12 +1,12 @@ // kilocode_change - new file import { afterEach, test, expect } from "bun:test" -import { tmpdir } from "../fixture/fixture" +import { disposeAllInstances, tmpdir } from "../fixture/fixture" import { Instance } from "../../src/project/instance" import { Agent } from "../../src/agent/agent" import { Permission } from "../../src/permission" afterEach(async () => { - await Instance.disposeAll() + await disposeAllInstances() }) function action(name: string, ruleset: Permission.Ruleset) { diff --git a/packages/opencode/test/kilocode/builtin-skills.test.ts b/packages/opencode/test/kilocode/builtin-skills.test.ts index 89c9bd04c3..cdb215b7e0 100644 --- a/packages/opencode/test/kilocode/builtin-skills.test.ts +++ b/packages/opencode/test/kilocode/builtin-skills.test.ts @@ -3,10 +3,10 @@ import path from "path" import { Skill } from "../../src/skill" import { Instance } from "../../src/project/instance" import { BUILTIN_SKILLS } from "../../src/kilocode/skills/builtin" -import { tmpdir } from "../fixture/fixture" +import { disposeAllInstances, tmpdir } from "../fixture/fixture" afterEach(async () => { - await Instance.disposeAll() + await disposeAllInstances() }) test("built-in skills are present in empty project", async () => { diff --git a/packages/opencode/test/kilocode/config-resilience.test.ts b/packages/opencode/test/kilocode/config-resilience.test.ts index bcb064716b..fd2e084d6c 100644 --- a/packages/opencode/test/kilocode/config-resilience.test.ts +++ b/packages/opencode/test/kilocode/config-resilience.test.ts @@ -3,10 +3,10 @@ import path from "path" import { Config } from "../../src/config/config" import { Instance } from "../../src/project/instance" import { Filesystem } from "../../src/util/filesystem" -import { tmpdir } from "../fixture/fixture" +import { disposeAllInstances, tmpdir } from "../fixture/fixture" afterEach(async () => { - await Instance.disposeAll() + await disposeAllInstances() await Config.invalidate() }) diff --git a/packages/opencode/test/kilocode/config-validation.test.ts b/packages/opencode/test/kilocode/config-validation.test.ts index 403c369cdd..afee79ac5f 100644 --- a/packages/opencode/test/kilocode/config-validation.test.ts +++ b/packages/opencode/test/kilocode/config-validation.test.ts @@ -5,10 +5,10 @@ import { ConfigValidation } from "../../src/kilocode/config-validation" import { Instance } from "../../src/project/instance" import { Config } from "../../src/config/config" import { Filesystem } from "../../src/util/filesystem" -import { tmpdir } from "../fixture/fixture" +import { disposeAllInstances, tmpdir } from "../fixture/fixture" afterEach(async () => { - await Instance.disposeAll() + await disposeAllInstances() }) describe("ConfigValidation.check", () => { diff --git a/packages/opencode/test/kilocode/config/config.test.ts b/packages/opencode/test/kilocode/config/config.test.ts index 820fe88757..43e1e266a0 100644 --- a/packages/opencode/test/kilocode/config/config.test.ts +++ b/packages/opencode/test/kilocode/config/config.test.ts @@ -15,7 +15,7 @@ import { Config } from "../../../src/config/config" import { Env } from "../../../src/env" import { Instance } from "../../../src/project/instance" import { Filesystem } from "../../../src/util/filesystem" -import { tmpdir } from "../../fixture/fixture" +import { disposeAllInstances, tmpdir } from "../../fixture/fixture" const infra = CrossSpawnSpawner.defaultLayer.pipe( Layer.provideMerge(Layer.mergeAll(NodeFileSystem.layer, NodePath.layer)), @@ -52,7 +52,7 @@ async function writeConfig(dir: string, config: object, name = "kilo.json") { describe("kilocode indexing config", () => { afterEach(async () => { - await Instance.disposeAll() + await disposeAllInstances() await clear(true) }) diff --git a/packages/opencode/test/kilocode/config/indexing-default-plugin.test.ts b/packages/opencode/test/kilocode/config/indexing-default-plugin.test.ts index 37b2ac48e3..33e05a5a56 100644 --- a/packages/opencode/test/kilocode/config/indexing-default-plugin.test.ts +++ b/packages/opencode/test/kilocode/config/indexing-default-plugin.test.ts @@ -13,7 +13,7 @@ import { EffectFlock } from "@opencode-ai/core/util/effect-flock" import { Filesystem } from "../../../src/util/filesystem" import { Instance } from "../../../src/project/instance" import { Npm } from "@opencode-ai/core/npm" -import { tmpdir } from "../../fixture/fixture" +import { disposeAllInstances, tmpdir } from "../../fixture/fixture" const infra = CrossSpawnSpawner.defaultLayer.pipe( Layer.provideMerge(Layer.mergeAll(NodeFileSystem.layer, NodePath.layer)), @@ -46,7 +46,7 @@ const clear = (wait = false) => describe("kilocode default indexing plugin", () => { afterEach(async () => { - await Instance.disposeAll() + await disposeAllInstances() await clear(true) }) diff --git a/packages/opencode/test/kilocode/cost-propagation.test.ts b/packages/opencode/test/kilocode/cost-propagation.test.ts index b5d4aa1abc..4f2d85dd5a 100644 --- a/packages/opencode/test/kilocode/cost-propagation.test.ts +++ b/packages/opencode/test/kilocode/cost-propagation.test.ts @@ -13,13 +13,13 @@ import { Session } from "../../src/session/session" import { MessageV2 } from "../../src/session/message-v2" import { MessageID } from "../../src/session/schema" import * as Log from "@opencode-ai/core/util/log" -import { provideTmpdirInstance } from "../fixture/fixture" +import { disposeAllInstances, provideTmpdirInstance } from "../fixture/fixture" import { testEffect } from "../lib/effect" Log.init({ print: false }) afterEach(async () => { - await Instance.disposeAll() + await disposeAllInstances() }) const ref = { diff --git a/packages/opencode/test/kilocode/edit-permission-filediff.test.ts b/packages/opencode/test/kilocode/edit-permission-filediff.test.ts index 9a7fe524be..9af88d1cf1 100644 --- a/packages/opencode/test/kilocode/edit-permission-filediff.test.ts +++ b/packages/opencode/test/kilocode/edit-permission-filediff.test.ts @@ -9,7 +9,7 @@ import path from "path" import { Effect, Layer, ManagedRuntime } from "effect" import { EditTool } from "../../src/tool/edit" import { Instance } from "../../src/project/instance" -import { tmpdir } from "../fixture/fixture" +import { disposeAllInstances, tmpdir } from "../fixture/fixture" import { LSP } from "../../src/lsp/lsp" import { AppFileSystem } from "@opencode-ai/core/filesystem" import { Format } from "../../src/format" @@ -34,7 +34,7 @@ afterAll(async () => { }) afterEach(async () => { - await Instance.disposeAll() + await disposeAllInstances() }) const resolve = () => diff --git a/packages/opencode/test/kilocode/external-directory-boundary.test.ts b/packages/opencode/test/kilocode/external-directory-boundary.test.ts index e02cc557fb..05a51b7b8a 100644 --- a/packages/opencode/test/kilocode/external-directory-boundary.test.ts +++ b/packages/opencode/test/kilocode/external-directory-boundary.test.ts @@ -3,6 +3,7 @@ import { Effect } from "effect" import path from "path" import type { Permission } from "../../src/permission" import { Instance } from "../../src/project/instance" +import { InstanceStore } from "../../src/project/instance-store" import { SessionID, MessageID } from "../../src/session/schema" import { assertExternalDirectory } from "../../src/tool/external-directory" import type { Tool } from "../../src/tool/tool" @@ -48,7 +49,7 @@ describe("kilocode external directory boundaries", () => { try { await assertExternalDirectory(ctx, file) } finally { - await Instance.dispose() + await InstanceStore.disposeInstance(Instance.current) } }, }) @@ -72,7 +73,7 @@ describe("kilocode external directory boundaries", () => { try { await assertExternalDirectory(ctx, file) } finally { - await Instance.dispose() + await InstanceStore.disposeInstance(Instance.current) } }, }) diff --git a/packages/opencode/test/kilocode/indexing-startup.test.ts b/packages/opencode/test/kilocode/indexing-startup.test.ts index a3e14b0f43..ccf10bbf66 100644 --- a/packages/opencode/test/kilocode/indexing-startup.test.ts +++ b/packages/opencode/test/kilocode/indexing-startup.test.ts @@ -8,7 +8,7 @@ import { InstanceBootstrap } from "../../src/project/bootstrap" import { Instance } from "../../src/project/instance" import { Server } from "../../src/server/server" import * as Log from "@opencode-ai/core/util/log" -import { tmpdir } from "../fixture/fixture" +import { disposeAllInstances, tmpdir } from "../fixture/fixture" Log.init({ print: false }) @@ -67,7 +67,7 @@ afterEach(async () => { else process.env["KILO_CONFIG_DIR"] = configDir if (disabled === undefined) delete process.env["KILO_DISABLE_CODEBASE_INDEXING"] else process.env["KILO_DISABLE_CODEBASE_INDEXING"] = disabled - await Instance.disposeAll() + await disposeAllInstances() }) describe("indexing startup degradation", () => { @@ -167,7 +167,7 @@ describe("indexing startup degradation", () => { }, }) - await Instance.disposeAll() + await disposeAllInstances() gate.resolve({ requiresRestart: false }) await new Promise((resolve) => setTimeout(resolve, 0)) diff --git a/packages/opencode/test/kilocode/indexing-worktree.test.ts b/packages/opencode/test/kilocode/indexing-worktree.test.ts index ae1bc2a761..1187f951e3 100644 --- a/packages/opencode/test/kilocode/indexing-worktree.test.ts +++ b/packages/opencode/test/kilocode/indexing-worktree.test.ts @@ -5,7 +5,7 @@ import { AppRuntime } from "../../src/effect/app-runtime" import { KiloIndexing } from "../../src/kilocode/indexing" import { InstanceBootstrap } from "../../src/project/bootstrap" import { Instance } from "../../src/project/instance" -import { tmpdir } from "../fixture/fixture" +import { disposeAllInstances, tmpdir } from "../fixture/fixture" const cfg: Partial = { plugin: ["@kilocode/kilo-indexing"], @@ -27,7 +27,7 @@ const configDir = process.env["KILO_CONFIG_DIR"] afterEach(async () => { if (configDir === undefined) delete process.env["KILO_CONFIG_DIR"] else process.env["KILO_CONFIG_DIR"] = configDir - await Instance.disposeAll() + await disposeAllInstances() }) describe("indexing worktree disable", () => { diff --git a/packages/opencode/test/kilocode/lsp-typescript-lightweight.test.ts b/packages/opencode/test/kilocode/lsp-typescript-lightweight.test.ts index 59debcf9bf..caad026aa1 100644 --- a/packages/opencode/test/kilocode/lsp-typescript-lightweight.test.ts +++ b/packages/opencode/test/kilocode/lsp-typescript-lightweight.test.ts @@ -9,9 +9,10 @@ import { TsClient } from "../../src/kilocode/ts-client" import { TsCheck } from "../../src/kilocode/ts-check" import { Flag } from "@opencode-ai/core/flag/flag" import { Instance, type InstanceContext } from "../../src/project/instance" +import { disposeAllInstances } from "../fixture/fixture" afterEach(async () => { - await Instance.disposeAll() + await disposeAllInstances() }) // Typescript.spawn doesn't use ctx, so a cast-through is fine for these tests. diff --git a/packages/opencode/test/kilocode/permission/external-directory-allow.test.ts b/packages/opencode/test/kilocode/permission/external-directory-allow.test.ts index 97799632b1..32bb258ad1 100644 --- a/packages/opencode/test/kilocode/permission/external-directory-allow.test.ts +++ b/packages/opencode/test/kilocode/permission/external-directory-allow.test.ts @@ -14,7 +14,7 @@ import { Shell } from "../../../src/shell/shell" import { Truncate } from "../../../src/tool/truncate" import { BashTool } from "../../../src/tool/bash" import { Plugin } from "../../../src/plugin" -import { tmpdir } from "../../fixture/fixture" +import { disposeAllInstances, tmpdir } from "../../fixture/fixture" import { ConfigProtection } from "../../../src/kilocode/permission/config-paths" const runtime = ManagedRuntime.make( @@ -125,7 +125,7 @@ async function wait(count: number) { } afterEach(async () => { - await Instance.disposeAll() + await disposeAllInstances() }) describe("external_directory allow config protection", () => { diff --git a/packages/opencode/test/kilocode/session-fork-remap.test.ts b/packages/opencode/test/kilocode/session-fork-remap.test.ts index e79af7c3ea..c32df342b3 100644 --- a/packages/opencode/test/kilocode/session-fork-remap.test.ts +++ b/packages/opencode/test/kilocode/session-fork-remap.test.ts @@ -4,12 +4,12 @@ import { Session } from "../../src/session/session" import { MessageV2 } from "../../src/session/message-v2" import { MessageID, PartID, SessionID } from "../../src/session/schema" import * as Log from "@opencode-ai/core/util/log" -import { tmpdir } from "../fixture/fixture" +import { disposeAllInstances, tmpdir } from "../fixture/fixture" Log.init({ print: false }) afterEach(async () => { - await Instance.disposeAll() + await disposeAllInstances() }) function taskPart(input: { messageID: string; sessionID: string; childSessionID: string }): MessageV2.ToolPart { diff --git a/packages/opencode/test/kilocode/session-list.test.ts b/packages/opencode/test/kilocode/session-list.test.ts index 6cdf8da733..417255a64b 100644 --- a/packages/opencode/test/kilocode/session-list.test.ts +++ b/packages/opencode/test/kilocode/session-list.test.ts @@ -6,12 +6,12 @@ import { Session } from "../../src/session/session" import { SessionTable } from "../../src/session/session.sql" import { Database, eq } from "../../src/storage/db" import * as Log from "@opencode-ai/core/util/log" -import { tmpdir } from "../fixture/fixture" +import { disposeAllInstances, tmpdir } from "../fixture/fixture" Log.init({ print: false }) afterEach(async () => { - await Instance.disposeAll() + await disposeAllInstances() }) describe("Kilo Session.list", () => { diff --git a/packages/opencode/test/kilocode/sessions/remote-sender.test.ts b/packages/opencode/test/kilocode/sessions/remote-sender.test.ts index 85ebd29537..b3d6d21359 100644 --- a/packages/opencode/test/kilocode/sessions/remote-sender.test.ts +++ b/packages/opencode/test/kilocode/sessions/remote-sender.test.ts @@ -1,5 +1,6 @@ import { describe, expect, test } from "bun:test" import { afterEach, mock, spyOn } from "bun:test" +import { Effect } from "effect" import { RemoteSender } from "../../../src/kilo-sessions/remote-sender" import type { RemoteWS } from "../../../src/kilo-sessions/remote-ws" import type { RemoteProtocol } from "../../../src/kilo-sessions/remote-protocol" @@ -318,7 +319,7 @@ describe("RemoteSender", () => { directory: "/tmp/test", log: nolog, subscribe: fakeBus().subscribe, - provide: async (input: { directory: string; init?: () => Promise; fn: () => R }) => input.fn(), + provide: async (input: { directory: string; init?: Effect.Effect; fn: () => R }) => input.fn(), }) sender.handle({ @@ -350,7 +351,7 @@ describe("RemoteSender", () => { directory: "/tmp/test", log: nolog, subscribe: fakeBus().subscribe, - provide: async (input: { directory: string; init?: () => Promise; fn: () => R }) => input.fn(), + provide: async (input: { directory: string; init?: Effect.Effect; fn: () => R }) => input.fn(), }) sender.handle({ @@ -380,7 +381,7 @@ describe("RemoteSender", () => { directory: "/tmp/test", log: nolog, subscribe: fakeBus().subscribe, - provide: async (input: { directory: string; init?: () => Promise; fn: () => R }) => input.fn(), + provide: async (input: { directory: string; init?: Effect.Effect; fn: () => R }) => input.fn(), }) sender.handle({ @@ -408,7 +409,7 @@ describe("RemoteSender", () => { directory: "/tmp/test", log: nolog, subscribe: fakeBus().subscribe, - provide: async (input: { directory: string; init?: () => Promise; fn: () => R }) => input.fn(), + provide: async (input: { directory: string; init?: Effect.Effect; fn: () => R }) => input.fn(), }) sender.handle({ @@ -499,7 +500,7 @@ describe("RemoteSender", () => { directory: "/tmp/test", log: nolog, subscribe: fakeBus().subscribe, - provide: async (input: { directory: string; init?: () => Promise; fn: () => R }) => input.fn(), + provide: async (input: { directory: string; init?: Effect.Effect; fn: () => R }) => input.fn(), }) sender.handle({ diff --git a/packages/opencode/test/kilocode/snapshot-freeze-repro.test.ts b/packages/opencode/test/kilocode/snapshot-freeze-repro.test.ts index 743b3755f9..d8283158d4 100644 --- a/packages/opencode/test/kilocode/snapshot-freeze-repro.test.ts +++ b/packages/opencode/test/kilocode/snapshot-freeze-repro.test.ts @@ -20,13 +20,13 @@ import { Session } from "../../src/session/session" import { Snapshot } from "../../src/snapshot" import { Filesystem } from "../../src/util/filesystem" import * as Log from "@opencode-ai/core/util/log" -import { tmpdir } from "../fixture/fixture" +import { disposeAllInstances, tmpdir } from "../fixture/fixture" Log.init({ print: false }) afterEach(async () => { mock.restore() - await Instance.disposeAll() + await disposeAllInstances() }) test("pathological diffFull workload finishes quickly and does not block abort", async () => { diff --git a/packages/opencode/test/kilocode/stats-subagent-cost.test.ts b/packages/opencode/test/kilocode/stats-subagent-cost.test.ts index 5195aa1eaa..1213a22357 100644 --- a/packages/opencode/test/kilocode/stats-subagent-cost.test.ts +++ b/packages/opencode/test/kilocode/stats-subagent-cost.test.ts @@ -11,12 +11,12 @@ import { ProviderID, ModelID } from "../../src/provider/schema" import { Session } from "../../src/session/session" import { MessageID, PartID } from "../../src/session/schema" import * as Log from "@opencode-ai/core/util/log" -import { tmpdir } from "../fixture/fixture" +import { disposeAllInstances, tmpdir } from "../fixture/fixture" Log.init({ print: false }) afterEach(async () => { - await Instance.disposeAll() + await disposeAllInstances() }) const ref = { diff --git a/packages/opencode/test/kilocode/tool-encoding.test.ts b/packages/opencode/test/kilocode/tool-encoding.test.ts index 42f21e95ee..13dba83a83 100644 --- a/packages/opencode/test/kilocode/tool-encoding.test.ts +++ b/packages/opencode/test/kilocode/tool-encoding.test.ts @@ -23,7 +23,7 @@ import { ReadTool } from "../../src/tool/read" import * as Tool from "../../src/tool/tool" import { Truncate } from "../../src/tool/truncate" import { WriteTool } from "../../src/tool/write" -import { provideTmpdirInstance } from "../fixture/fixture" +import { disposeAllInstances, provideTmpdirInstance } from "../fixture/fixture" import { testEffect } from "../lib/effect" const ctx = { @@ -38,7 +38,7 @@ const ctx = { } afterEach(async () => { - await Instance.disposeAll() + await disposeAllInstances() }) const it = testEffect( diff --git a/packages/opencode/test/kilocode/tool-registry-indexing-import-failure.test.ts b/packages/opencode/test/kilocode/tool-registry-indexing-import-failure.test.ts index 8b2a7ec790..465e3d5095 100644 --- a/packages/opencode/test/kilocode/tool-registry-indexing-import-failure.test.ts +++ b/packages/opencode/test/kilocode/tool-registry-indexing-import-failure.test.ts @@ -2,7 +2,7 @@ import { afterEach, describe, expect, mock, spyOn } from "bun:test" import { Effect, Layer } from "effect" import * as Log from "@opencode-ai/core/util/log" import { Instance } from "../../src/project/instance" -import { provideTmpdirInstance } from "../fixture/fixture" +import { disposeAllInstances, provideTmpdirInstance } from "../fixture/fixture" import * as CrossSpawnSpawner from "@opencode-ai/core/cross-spawn-spawner" import { testEffect } from "../lib/effect" @@ -18,7 +18,7 @@ const node = CrossSpawnSpawner.defaultLayer const it = testEffect(Layer.mergeAll(ToolRegistry.defaultLayer, node)) afterEach(async () => { - await Instance.disposeAll() + await disposeAllInstances() }) describe("kilocode tool registry indexing import failure", () => { diff --git a/packages/opencode/test/kilocode/tool-registry-indexing.test.ts b/packages/opencode/test/kilocode/tool-registry-indexing.test.ts index 531e577746..b776ca5f3e 100644 --- a/packages/opencode/test/kilocode/tool-registry-indexing.test.ts +++ b/packages/opencode/test/kilocode/tool-registry-indexing.test.ts @@ -8,7 +8,7 @@ import { KiloToolRegistry } from "../../src/kilocode/tool/registry" import { ToolRegistry } from "../../src/tool/registry" import type * as Tool from "../../src/tool/tool" import { Instance } from "../../src/project/instance" -import { provideTmpdirInstance } from "../fixture/fixture" +import { disposeAllInstances, provideTmpdirInstance } from "../fixture/fixture" import * as CrossSpawnSpawner from "@opencode-ai/core/cross-spawn-spawner" import { testEffect } from "../lib/effect" @@ -16,7 +16,7 @@ const node = CrossSpawnSpawner.defaultLayer const it = testEffect(Layer.mergeAll(ToolRegistry.defaultLayer, node)) afterEach(async () => { - await Instance.disposeAll() + await disposeAllInstances() }) describe("kilocode tool registry indexing", () => { diff --git a/packages/opencode/test/kilocode/tool-registry-semantic-import-failure.test.ts b/packages/opencode/test/kilocode/tool-registry-semantic-import-failure.test.ts index 427f639ad1..89d85c6fe6 100644 --- a/packages/opencode/test/kilocode/tool-registry-semantic-import-failure.test.ts +++ b/packages/opencode/test/kilocode/tool-registry-semantic-import-failure.test.ts @@ -2,7 +2,7 @@ import { afterEach, describe, expect, mock, spyOn } from "bun:test" import { Effect, Layer } from "effect" import * as Log from "@opencode-ai/core/util/log" import { Instance } from "../../src/project/instance" -import { provideTmpdirInstance } from "../fixture/fixture" +import { disposeAllInstances, provideTmpdirInstance } from "../fixture/fixture" import * as CrossSpawnSpawner from "@opencode-ai/core/cross-spawn-spawner" import { testEffect } from "../lib/effect" @@ -24,7 +24,7 @@ const node = CrossSpawnSpawner.defaultLayer const it = testEffect(Layer.mergeAll(ToolRegistry.defaultLayer, node)) afterEach(async () => { - await Instance.disposeAll() + await disposeAllInstances() }) describe("kilocode tool registry semantic tool import failure", () => { diff --git a/packages/opencode/test/kilocode/tool-task-model.test.ts b/packages/opencode/test/kilocode/tool-task-model.test.ts index e3e511bed2..2699f1e516 100644 --- a/packages/opencode/test/kilocode/tool-task-model.test.ts +++ b/packages/opencode/test/kilocode/tool-task-model.test.ts @@ -15,7 +15,7 @@ import { ModelID, ProviderID } from "../../src/provider/schema" import { TaskTool, type TaskPromptOps } from "../../src/tool/task" import { Truncate } from "../../src/tool/truncate" import { ToolRegistry } from "../../src/tool/registry" -import { provideTmpdirInstance } from "../fixture/fixture" +import { disposeAllInstances, provideTmpdirInstance } from "../fixture/fixture" import { testEffect } from "../lib/effect" const state = path.join(Global.Path.state, "model.json") @@ -23,7 +23,7 @@ const state = path.join(Global.Path.state, "model.json") afterEach(async () => { process.env.KILO_CLIENT = "cli" await fs.rm(state, { force: true }).catch(() => undefined) - await Instance.disposeAll() + await disposeAllInstances() }) beforeAll(async () => { diff --git a/packages/opencode/test/mcp/lifecycle.test.ts b/packages/opencode/test/mcp/lifecycle.test.ts index 1b459481f3..59fa54ceab 100644 --- a/packages/opencode/test/mcp/lifecycle.test.ts +++ b/packages/opencode/test/mcp/lifecycle.test.ts @@ -1,4 +1,5 @@ import { test, expect, mock, beforeEach } from "bun:test" +import { InstanceStore } from "../../src/project/instance-store" import { Effect } from "effect" import type { MCP as MCPNS } from "../../src/mcp/index" @@ -197,7 +198,7 @@ function withInstance( fn: async () => { await Effect.runPromise(MCP.Service.use(fn).pipe(Effect.provide(MCP.defaultLayer))) // dispose instance to clean up state between tests - await Instance.dispose() + await InstanceStore.disposeInstance(Instance.current) }, }) } diff --git a/packages/opencode/test/permission-task.test.ts b/packages/opencode/test/permission-task.test.ts index 5ce7eee939..d4f9192c76 100644 --- a/packages/opencode/test/permission-task.test.ts +++ b/packages/opencode/test/permission-task.test.ts @@ -2,13 +2,13 @@ import { afterEach, describe, test, expect } from "bun:test" import { Permission } from "../src/permission" import { Config } from "@/config/config" import { Instance } from "../src/project/instance" -import { tmpdir } from "./fixture/fixture" +import { disposeAllInstances, tmpdir } from "./fixture/fixture" import { AppRuntime } from "../src/effect/app-runtime" const load = () => AppRuntime.runPromise(Config.Service.use((svc) => svc.get())) afterEach(async () => { - await Instance.disposeAll() + await disposeAllInstances() }) describe("Permission.evaluate for permission.task", () => { diff --git a/packages/opencode/test/permission/next.test.ts b/packages/opencode/test/permission/next.test.ts index 7fe35a1228..8e77ff8970 100644 --- a/packages/opencode/test/permission/next.test.ts +++ b/packages/opencode/test/permission/next.test.ts @@ -10,7 +10,8 @@ import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" import { Permission } from "../../src/permission" import { PermissionID } from "../../src/permission/schema" import { Instance } from "../../src/project/instance" -import { provideInstance, provideTmpdirInstance, tmpdirScoped } from "../fixture/fixture" +import { InstanceStore } from "../../src/project/instance-store" +import { disposeAllInstances, provideInstance, provideTmpdirInstance, tmpdirScoped } from "../fixture/fixture" import { testEffect } from "../lib/effect" import { MessageID, SessionID } from "../../src/session/schema" @@ -19,7 +20,7 @@ const env = Layer.mergeAll(Permission.layer.pipe(Layer.provide(bus)), bus, Cross const it = testEffect(env) afterEach(async () => { - await Instance.disposeAll() + await disposeAllInstances() }) // kilocode_change start @@ -1110,7 +1111,9 @@ it.live("pending permission rejects on instance dispose", () => }).pipe(run, Effect.forkScoped) expect(yield* waitForPending(1).pipe(run)).toHaveLength(1) - yield* Effect.promise(() => Instance.provide({ directory: dir, fn: () => void Instance.dispose() })) + yield* Effect.promise(() => + Instance.provide({ directory: dir, fn: () => void InstanceStore.disposeInstance(Instance.current) }), + ) const exit = yield* Fiber.await(fiber) expect(Exit.isFailure(exit)).toBe(true) @@ -1133,7 +1136,7 @@ it.live("pending permission rejects on instance reload", () => }).pipe(run, Effect.forkScoped) expect(yield* waitForPending(1).pipe(run)).toHaveLength(1) - yield* Effect.promise(() => Instance.reload({ directory: dir })) + yield* Effect.promise(() => InstanceStore.reloadInstance({ directory: dir })) const exit = yield* Fiber.await(fiber) expect(Exit.isFailure(exit)).toBe(true) @@ -1230,7 +1233,7 @@ it.live("ask - abort should clear pending request", () => const pending = yield* waitForPending(1).pipe(run) expect(pending).toHaveLength(1) - yield* Effect.promise(() => Instance.reload({ directory: dir })) + yield* Effect.promise(() => InstanceStore.reloadInstance({ directory: dir })) const exit = yield* Fiber.await(fiber) expect(Exit.isFailure(exit)).toBe(true) diff --git a/packages/opencode/test/plugin/loader-shared.test.ts b/packages/opencode/test/plugin/loader-shared.test.ts index 523ef18b7a..6cf9dbe47e 100644 --- a/packages/opencode/test/plugin/loader-shared.test.ts +++ b/packages/opencode/test/plugin/loader-shared.test.ts @@ -3,7 +3,7 @@ import { Effect } from "effect" import fs from "fs/promises" import path from "path" import { pathToFileURL } from "url" -import { tmpdir } from "../fixture/fixture" +import { disposeAllInstances, tmpdir } from "../fixture/fixture" import { Filesystem } from "@/util/filesystem" const disableDefault = process.env.KILO_DISABLE_DEFAULT_PLUGINS @@ -24,7 +24,7 @@ afterAll(() => { }) afterEach(async () => { - await Instance.disposeAll() + await disposeAllInstances() }) async function load(dir: string) { diff --git a/packages/opencode/test/plugin/trigger.test.ts b/packages/opencode/test/plugin/trigger.test.ts index dc4c081208..d9c0dfa8c2 100644 --- a/packages/opencode/test/plugin/trigger.test.ts +++ b/packages/opencode/test/plugin/trigger.test.ts @@ -1,18 +1,18 @@ -import { afterAll, afterEach, describe, expect, test } from "bun:test" -import { Effect } from "effect" +import { afterAll, describe, expect } from "bun:test" +import { Effect, Layer } from "effect" +import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" import path from "path" import { pathToFileURL } from "url" -import { tmpdir } from "../fixture/fixture" +import { ModelID, ProviderID } from "../../src/provider/schema" +import { provideTmpdirInstance } from "../fixture/fixture" +import { testEffect } from "../lib/effect" const disableDefault = process.env.KILO_DISABLE_DEFAULT_PLUGINS process.env.KILO_DISABLE_DEFAULT_PLUGINS = "1" const { Plugin } = await import("../../src/plugin/index") -const { Instance } = await import("../../src/project/instance") - -afterEach(async () => { - await Instance.disposeAll() -}) +const it = testEffect(Layer.mergeAll(Plugin.defaultLayer, CrossSpawnSpawner.defaultLayer)) +const systemHook = "experimental.chat.system.transform" afterAll(() => { if (disableDefault === undefined) { @@ -22,95 +22,81 @@ afterAll(() => { process.env.KILO_DISABLE_DEFAULT_PLUGINS = disableDefault }) -async function project(source: string) { - return tmpdir({ - init: async (dir) => { +function withProject(source: string, self: Effect.Effect) { + return provideTmpdirInstance((dir) => + Effect.gen(function* () { const file = path.join(dir, "plugin.ts") - await Bun.write(file, source) - await Bun.write( - path.join(dir, "opencode.json"), - JSON.stringify( - { - $schema: "https://opencode.ai/config.json", - plugin: [pathToFileURL(file).href], - }, - null, - 2, - ), + yield* Effect.all( + [ + Effect.promise(() => Bun.write(file, source)), + Effect.promise(() => + Bun.write( + path.join(dir, "opencode.json"), + JSON.stringify( + { + $schema: "https://opencode.ai/config.json", + plugin: [pathToFileURL(file).href], + }, + null, + 2, + ), + ), + ), + ], + { discard: true, concurrency: 2 }, ) - }, - }) + return yield* self + }), + ) } +const triggerSystemTransform = Effect.fn("PluginTriggerTest.triggerSystemTransform")(function* () { + const plugin = yield* Plugin.Service + const out = { system: [] as string[] } + yield* plugin.trigger( + systemHook, + { + model: { + providerID: ProviderID.anthropic, + modelID: ModelID.make("claude-sonnet-4-6"), + }, + }, + out, + ) + return out.system +}) + describe("plugin.trigger", () => { - test("runs synchronous hooks without crashing", async () => { - await using tmp = await project( + it.live("runs synchronous hooks without crashing", () => + withProject( [ "export default async () => ({", - ' "experimental.chat.system.transform": (_input, output) => {', + ` ${JSON.stringify(systemHook)}: (_input, output) => {`, ' output.system.unshift("sync")', " },", "})", "", ].join("\n"), - ) + Effect.gen(function* () { + expect(yield* triggerSystemTransform()).toEqual(["sync"]) + }), + ), + ) - const out = await Instance.provide({ - directory: tmp.path, - fn: async () => - Effect.gen(function* () { - const plugin = yield* Plugin.Service - const out = { system: [] as string[] } - yield* plugin.trigger( - "experimental.chat.system.transform", - { - model: { - providerID: "anthropic", - modelID: "claude-sonnet-4-6", - } as any, - }, - out, - ) - return out - }).pipe(Effect.provide(Plugin.defaultLayer), Effect.runPromise), - }) - - expect(out.system).toEqual(["sync"]) - }) - - test("awaits asynchronous hooks", async () => { - await using tmp = await project( + it.live("awaits asynchronous hooks", () => + withProject( [ "export default async () => ({", - ' "experimental.chat.system.transform": async (_input, output) => {', + ` ${JSON.stringify(systemHook)}: async (_input, output) => {`, " await Bun.sleep(1)", ' output.system.unshift("async")', " },", "})", "", ].join("\n"), - ) - - const out = await Instance.provide({ - directory: tmp.path, - fn: async () => - Effect.gen(function* () { - const plugin = yield* Plugin.Service - const out = { system: [] as string[] } - yield* plugin.trigger( - "experimental.chat.system.transform", - { - model: { - providerID: "anthropic", - modelID: "claude-sonnet-4-6", - } as any, - }, - out, - ) - return out - }).pipe(Effect.provide(Plugin.defaultLayer), Effect.runPromise), - }) - - expect(out.system).toEqual(["async"]) - }) + Effect.gen(function* () { + expect(yield* triggerSystemTransform()).toEqual(["async"]) + }), + ), + ) }) diff --git a/packages/opencode/test/plugin/workspace-adapter.test.ts b/packages/opencode/test/plugin/workspace-adapter.test.ts new file mode 100644 index 0000000000..300ffad36c --- /dev/null +++ b/packages/opencode/test/plugin/workspace-adapter.test.ts @@ -0,0 +1,109 @@ +import { afterAll, afterEach, describe, expect } from "bun:test" +import { Effect, Layer } from "effect" +import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" +import path from "path" +import { pathToFileURL } from "url" +import { disposeAllInstances, provideTmpdirInstance } from "../fixture/fixture" +import { testEffect } from "../lib/effect" + +const disableDefault = process.env.KILO_DISABLE_DEFAULT_PLUGINS +process.env.KILO_DISABLE_DEFAULT_PLUGINS = "1" + +const { Flag } = await import("@opencode-ai/core/flag/flag") +const { Plugin } = await import("../../src/plugin/index") +const { Workspace } = await import("../../src/control-plane/workspace") +const { Instance } = await import("../../src/project/instance") +const it = testEffect(Layer.mergeAll(Plugin.defaultLayer, Workspace.defaultLayer, CrossSpawnSpawner.defaultLayer)) + +const experimental = Flag.KILO_EXPERIMENTAL_WORKSPACES + +Flag.KILO_EXPERIMENTAL_WORKSPACES = true + +afterEach(async () => { + await disposeAllInstances() +}) + +afterAll(() => { + if (disableDefault === undefined) { + delete process.env.KILO_DISABLE_DEFAULT_PLUGINS + } else { + process.env.KILO_DISABLE_DEFAULT_PLUGINS = disableDefault + } + + Flag.KILO_EXPERIMENTAL_WORKSPACES = experimental +}) + +describe("plugin.workspace", () => { + it.live("plugin can install a workspace adapter", () => + provideTmpdirInstance((dir) => + Effect.gen(function* () { + const type = `plug-${Math.random().toString(36).slice(2)}` + const file = path.join(dir, "plugin.ts") + const mark = path.join(dir, "created.json") + const space = path.join(dir, "space") + yield* Effect.promise(() => + Bun.write( + file, + [ + "export default async ({ experimental_workspace }) => {", + ` experimental_workspace.register(${JSON.stringify(type)}, {`, + ' name: "plug",', + ' description: "plugin workspace adapter",', + " configure(input) {", + ` return { ...input, name: "plug", branch: "plug/main", directory: ${JSON.stringify(space)} }`, + " },", + " async create(input) {", + ` await Bun.write(${JSON.stringify(mark)}, JSON.stringify(input))`, + " },", + " async remove() {},", + " target(input) {", + ' return { type: "local", directory: input.directory }', + " },", + " })", + " return {}", + "}", + "", + ].join("\n"), + ), + ) + + yield* Effect.promise(() => + Bun.write( + path.join(dir, "opencode.json"), + JSON.stringify( + { + $schema: "https://opencode.ai/config.json", + plugin: [pathToFileURL(file).href], + }, + null, + 2, + ), + ), + ) + + const plugin = yield* Plugin.Service + yield* plugin.init() + const workspace = yield* Workspace.Service + const info = yield* workspace.create({ + type, + branch: null, + extra: { key: "value" }, + projectID: Instance.project.id, + }) + + expect(info.type).toBe(type) + expect(info.name).toBe("plug") + expect(info.branch).toBe("plug/main") + expect(info.directory).toBe(space) + expect(info.extra).toEqual({ key: "value" }) + expect(JSON.parse(yield* Effect.promise(() => Bun.file(mark).text()))).toMatchObject({ + type, + name: "plug", + branch: "plug/main", + directory: space, + extra: { key: "value" }, + }) + }), + ), + ) +}) diff --git a/packages/opencode/test/plugin/workspace-adaptor.test.ts b/packages/opencode/test/plugin/workspace-adaptor.test.ts deleted file mode 100644 index cb950bda9c..0000000000 --- a/packages/opencode/test/plugin/workspace-adaptor.test.ts +++ /dev/null @@ -1,109 +0,0 @@ -import { afterAll, afterEach, describe, expect, test } from "bun:test" -import { Effect } from "effect" -import path from "path" -import { pathToFileURL } from "url" -import { tmpdir } from "../fixture/fixture" - -const disableDefault = process.env.KILO_DISABLE_DEFAULT_PLUGINS -process.env.KILO_DISABLE_DEFAULT_PLUGINS = "1" - -const { Flag } = await import("@opencode-ai/core/flag/flag") -const { Plugin } = await import("../../src/plugin/index") -const { Workspace } = await import("../../src/control-plane/workspace") -const { Instance } = await import("../../src/project/instance") - -const experimental = Flag.KILO_EXPERIMENTAL_WORKSPACES - -Flag.KILO_EXPERIMENTAL_WORKSPACES = true - -afterEach(async () => { - await Instance.disposeAll() -}) - -afterAll(() => { - if (disableDefault === undefined) { - delete process.env.KILO_DISABLE_DEFAULT_PLUGINS - } else { - process.env.KILO_DISABLE_DEFAULT_PLUGINS = disableDefault - } - - Flag.KILO_EXPERIMENTAL_WORKSPACES = experimental -}) - -describe("plugin.workspace", () => { - test("plugin can install a workspace adaptor", async () => { - await using tmp = await tmpdir({ - init: async (dir) => { - const type = `plug-${Math.random().toString(36).slice(2)}` - const file = path.join(dir, "plugin.ts") - const mark = path.join(dir, "created.json") - const space = path.join(dir, "space") - await Bun.write( - file, - [ - "export default async ({ experimental_workspace }) => {", - ` experimental_workspace.register(${JSON.stringify(type)}, {`, - ' name: "plug",', - ' description: "plugin workspace adaptor",', - " configure(input) {", - ` return { ...input, name: "plug", branch: "plug/main", directory: ${JSON.stringify(space)} }`, - " },", - " async create(input) {", - ` await Bun.write(${JSON.stringify(mark)}, JSON.stringify(input))`, - " },", - " async remove() {},", - " target(input) {", - ' return { type: "local", directory: input.directory }', - " },", - " })", - " return {}", - "}", - "", - ].join("\n"), - ) - - await Bun.write( - path.join(dir, "opencode.json"), - JSON.stringify( - { - $schema: "https://opencode.ai/config.json", - plugin: [pathToFileURL(file).href], - }, - null, - 2, - ), - ) - - return { mark, space, type } - }, - }) - - const info = await Instance.provide({ - directory: tmp.path, - fn: async () => - Effect.gen(function* () { - const plugin = yield* Plugin.Service - yield* plugin.init() - return Workspace.create({ - type: tmp.extra.type, - branch: null, - extra: { key: "value" }, - projectID: Instance.project.id, - }) - }).pipe(Effect.provide(Plugin.defaultLayer), Effect.runPromise), - }) - - expect(info.type).toBe(tmp.extra.type) - expect(info.name).toBe("plug") - expect(info.branch).toBe("plug/main") - expect(info.directory).toBe(tmp.extra.space) - expect(info.extra).toEqual({ key: "value" }) - expect(JSON.parse(await Bun.file(tmp.extra.mark).text())).toMatchObject({ - type: tmp.extra.type, - name: "plug", - branch: "plug/main", - directory: tmp.extra.space, - extra: { key: "value" }, - }) - }) -}) diff --git a/packages/opencode/test/project/instance.test.ts b/packages/opencode/test/project/instance.test.ts new file mode 100644 index 0000000000..852c58ef41 --- /dev/null +++ b/packages/opencode/test/project/instance.test.ts @@ -0,0 +1,273 @@ +import { afterEach, describe, expect } from "bun:test" +import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" +import { Effect, Fiber, Layer } from "effect" +import { InstanceRef } from "../../src/effect/instance-ref" +import { registerDisposer } from "../../src/effect/instance-registry" +import { Instance } from "../../src/project/instance" +import { InstanceStore } from "../../src/project/instance-store" +import { disposeAllInstances, tmpdirScoped } from "../fixture/fixture" +import { testEffect } from "../lib/effect" + +const it = testEffect(Layer.mergeAll(InstanceStore.defaultLayer, CrossSpawnSpawner.defaultLayer)) + +afterEach(async () => { + await disposeAllInstances() +}) + +describe("InstanceStore", () => { + it.live("loads instance context without installing ALS for the caller", () => + Effect.gen(function* () { + const dir = yield* tmpdirScoped({ git: true }) + const store = yield* InstanceStore.Service + const ctx = yield* store.load({ directory: dir }) + + expect(ctx.directory).toBe(dir) + expect(ctx.worktree).toBe(dir) + expect(() => Instance.current).toThrow() + }), + ) + + it.live("runs load init with InstanceRef provided", () => + Effect.gen(function* () { + const dir = yield* tmpdirScoped({ git: true }) + const store = yield* InstanceStore.Service + let initializedDirectory: string | undefined + + yield* store.load({ + directory: dir, + init: Effect.gen(function* () { + initializedDirectory = (yield* InstanceRef)?.directory + }), + }) + + expect(initializedDirectory).toBe(dir) + expect(() => Instance.current).toThrow() + }), + ) + + it.live("caches loaded instance context by directory", () => + Effect.gen(function* () { + const dir = yield* tmpdirScoped({ git: true }) + const store = yield* InstanceStore.Service + let initialized = 0 + + const first = yield* store.load({ + directory: dir, + init: Effect.sync(() => { + initialized++ + }), + }) + const second = yield* store.load({ + directory: dir, + init: Effect.sync(() => { + initialized++ + }), + }) + + expect(second).toBe(first) + expect(initialized).toBe(1) + }), + ) + + it.live("dedupes concurrent loads while init is in flight", () => + Effect.gen(function* () { + const dir = yield* tmpdirScoped({ git: true }) + const store = yield* InstanceStore.Service + const started = Promise.withResolvers() + const release = Promise.withResolvers() + let initialized = 0 + + const first = yield* store + .load({ + directory: dir, + init: Effect.promise(async () => { + initialized++ + started.resolve() + await release.promise + }), + }) + .pipe(Effect.forkScoped) + + yield* Effect.promise(() => started.promise) + + const second = yield* store + .load({ + directory: dir, + init: Effect.sync(() => { + initialized++ + }), + }) + .pipe(Effect.forkScoped) + + expect(initialized).toBe(1) + release.resolve() + + const [firstCtx, secondCtx] = yield* Effect.all([Fiber.join(first), Fiber.join(second)]) + expect(secondCtx).toBe(firstCtx) + expect(initialized).toBe(1) + }), + ) + + it.live("removes failed loads from the cache", () => + Effect.gen(function* () { + const dir = yield* tmpdirScoped({ git: true }) + const store = yield* InstanceStore.Service + let attempts = 0 + + const failed = yield* store + .load({ + directory: dir, + init: Effect.sync(() => { + attempts++ + throw new Error("init failed") + }), + }) + .pipe( + Effect.as(false), + Effect.catchCause(() => Effect.succeed(true)), + ) + + expect(failed).toBe(true) + + const ctx = yield* store.load({ + directory: dir, + init: Effect.sync(() => { + attempts++ + }), + }) + + expect(ctx.directory).toBe(dir) + expect(attempts).toBe(2) + }), + ) + + it.live("reload replaces the cached context", () => + Effect.gen(function* () { + const dir = yield* tmpdirScoped({ git: true }) + const store = yield* InstanceStore.Service + + const first = yield* store.load({ directory: dir }) + const second = yield* store.reload({ directory: dir }) + const cached = yield* store.load({ directory: dir }) + + expect(second).not.toBe(first) + expect(cached).toBe(second) + }), + ) + + it.live("stale dispose does not delete an in-flight reload", () => + Effect.gen(function* () { + const dir = yield* tmpdirScoped({ git: true }) + const store = yield* InstanceStore.Service + const reloading = Promise.withResolvers() + const releaseReload = Promise.withResolvers() + const disposed: Array = [] + const off = registerDisposer(async (directory) => { + disposed.push(directory) + }) + yield* Effect.addFinalizer(() => Effect.sync(off)) + + const first = yield* store.load({ directory: dir }) + const reload = yield* store + .reload({ + directory: dir, + init: Effect.promise(async () => { + reloading.resolve() + await releaseReload.promise + }), + }) + .pipe(Effect.forkScoped) + + yield* Effect.promise(() => reloading.promise) + const staleDispose = yield* store.dispose(first).pipe(Effect.forkScoped) + releaseReload.resolve() + + const second = yield* Fiber.join(reload) + yield* Fiber.join(staleDispose) + + expect(disposed).toEqual([dir]) + expect(yield* store.load({ directory: dir })).toBe(second) + }), + ) + + it.live("dedupes concurrent disposeAll calls", () => + Effect.gen(function* () { + const dir = yield* tmpdirScoped({ git: true }) + const store = yield* InstanceStore.Service + const disposing = Promise.withResolvers() + const releaseDispose = Promise.withResolvers() + const disposed: Array = [] + const off = registerDisposer(async (directory) => { + disposed.push(directory) + disposing.resolve() + await releaseDispose.promise + }) + yield* Effect.addFinalizer(() => Effect.sync(off)) + + yield* store.load({ directory: dir }) + const first = yield* store.disposeAll().pipe(Effect.forkScoped) + yield* Effect.promise(() => disposing.promise) + const second = yield* store.disposeAll().pipe(Effect.forkScoped) + + expect(disposed).toEqual([dir]) + releaseDispose.resolve() + yield* Effect.all([Fiber.join(first), Fiber.join(second)]) + expect(disposed).toEqual([dir]) + }), + ) + + it.live("re-arms disposeAll after completion", () => + Effect.gen(function* () { + const dir1 = yield* tmpdirScoped({ git: true }) + const dir2 = yield* tmpdirScoped({ git: true }) + const store = yield* InstanceStore.Service + const disposed: Array = [] + const off = registerDisposer(async (directory) => { + disposed.push(directory) + }) + yield* Effect.addFinalizer(() => Effect.sync(off)) + + yield* store.load({ directory: dir1 }) + yield* store.disposeAll() + expect(disposed).toEqual([dir1]) + + yield* store.load({ directory: dir2 }) + yield* store.disposeAll() + expect(disposed).toEqual([dir1, dir2]) + }), + ) + + it.live("keeps Instance.provide as the legacy ALS wrapper", () => + Effect.gen(function* () { + const dir = yield* tmpdirScoped({ git: true }) + + const directory = yield* Effect.promise(() => + Instance.provide({ + directory: dir, + fn: () => Instance.directory, + }), + ) + + expect(directory).toBe(dir) + expect(() => Instance.current).toThrow() + }), + ) + + it.live("does not install legacy ALS around Effect init", () => + Effect.gen(function* () { + const dir = yield* tmpdirScoped() + + const directory = yield* Effect.promise(() => + Instance.provide({ + directory: dir, + init: Effect.sync(() => { + expect(() => Instance.current).toThrow() + }), + fn: () => Instance.directory, + }), + ) + + expect(directory).toBe(dir) + }), + ) +}) diff --git a/packages/opencode/test/project/vcs.test.ts b/packages/opencode/test/project/vcs.test.ts index a2a5cff601..0d0e46fe48 100644 --- a/packages/opencode/test/project/vcs.test.ts +++ b/packages/opencode/test/project/vcs.test.ts @@ -3,7 +3,7 @@ import { afterEach, describe, expect, test } from "bun:test" import { Effect } from "effect" import fs from "fs/promises" import path from "path" -import { tmpdir } from "../fixture/fixture" +import { disposeAllInstances, tmpdir } from "../fixture/fixture" import { AppRuntime } from "../../src/effect/app-runtime" import { FileWatcher } from "../../src/file/watcher" import { Instance } from "../../src/project/instance" @@ -85,7 +85,7 @@ function nextBranchUpdate(directory: string, timeout = 10_000) { describeVcs("Vcs", () => { afterEach(async () => { - await Instance.disposeAll() + await disposeAllInstances() }) test("branch() returns current branch name", async () => { @@ -158,7 +158,7 @@ describeVcs("Vcs", () => { describe("Vcs diff", () => { afterEach(async () => { - await Instance.disposeAll() + await disposeAllInstances() }) test("defaultBranch() falls back to main", async () => { diff --git a/packages/opencode/test/project/worktree.test.ts b/packages/opencode/test/project/worktree.test.ts index 44a25a8e6b..806c47615b 100644 --- a/packages/opencode/test/project/worktree.test.ts +++ b/packages/opencode/test/project/worktree.test.ts @@ -5,8 +5,9 @@ import path from "path" import { Cause, Effect, Exit, Layer } from "effect" import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" import { Instance } from "../../src/project/instance" +import { InstanceStore } from "../../src/project/instance-store" import { Worktree } from "../../src/worktree" -import { provideInstance, provideTmpdirInstance } from "../fixture/fixture" +import { disposeAllInstances, provideInstance, provideTmpdirInstance } from "../fixture/fixture" import { testEffect } from "../lib/effect" const it = testEffect(Layer.mergeAll(Worktree.defaultLayer, CrossSpawnSpawner.defaultLayer)) @@ -37,7 +38,7 @@ async function waitReady() { } describe("Worktree", () => { - afterEach(() => Instance.disposeAll()) + afterEach(() => disposeAllInstances()) describe("makeWorktreeInfo", () => { it.live("returns info with name, branch, and directory", () => @@ -136,7 +137,11 @@ describe("Worktree", () => { expect(props.name).toBe(info.name) expect(props.branch).toBe(info.branch) - yield* Effect.promise(() => Instance.dispose()).pipe(provideInstance(info.directory)) + yield* Effect.promise(() => + InstanceStore.runtime.runPromise((s) => + s.load({ directory: info.directory }).pipe(Effect.flatMap(s.dispose)), + ), + ) yield* Effect.promise(() => Bun.sleep(100)) yield* svc.remove({ directory: info.directory }) }), @@ -156,7 +161,11 @@ describe("Worktree", () => { expect(info.branch).toBe("opencode/test-workspace") yield* Effect.promise(() => ready) - yield* Effect.promise(() => Instance.dispose()).pipe(provideInstance(info.directory)) + yield* Effect.promise(() => + InstanceStore.runtime.runPromise((s) => + s.load({ directory: info.directory }).pipe(Effect.flatMap(s.dispose)), + ), + ) yield* Effect.promise(() => Bun.sleep(100)) yield* svc.remove({ directory: info.directory }) }), diff --git a/packages/opencode/test/provider/amazon-bedrock.test.ts b/packages/opencode/test/provider/amazon-bedrock.test.ts index fd306d3b3a..89aa5e2e65 100644 --- a/packages/opencode/test/provider/amazon-bedrock.test.ts +++ b/packages/opencode/test/provider/amazon-bedrock.test.ts @@ -45,10 +45,10 @@ test("Bedrock: config region takes precedence over AWS_REGION env var", async () }) await Instance.provide({ directory: tmp.path, - init: async () => { + init: Effect.promise(async () => { set("AWS_REGION", "us-east-1") set("AWS_PROFILE", "default") - }, + }).pipe(Effect.asVoid), fn: async () => { const providers = await list() expect(providers[ProviderID.amazonBedrock]).toBeDefined() @@ -70,10 +70,10 @@ test("Bedrock: falls back to AWS_REGION env var when no config region", async () }) await Instance.provide({ directory: tmp.path, - init: async () => { + init: Effect.promise(async () => { set("AWS_REGION", "eu-west-1") set("AWS_PROFILE", "default") - }, + }).pipe(Effect.asVoid), fn: async () => { const providers = await list() expect(providers[ProviderID.amazonBedrock]).toBeDefined() @@ -125,11 +125,11 @@ test("Bedrock: loads when bearer token from auth.json is present", async () => { await Instance.provide({ directory: tmp.path, - init: async () => { + init: Effect.promise(async () => { set("AWS_PROFILE", "") set("AWS_ACCESS_KEY_ID", "") set("AWS_BEARER_TOKEN_BEDROCK", "") - }, + }).pipe(Effect.asVoid), fn: async () => { const providers = await list() expect(providers[ProviderID.amazonBedrock]).toBeDefined() @@ -171,10 +171,10 @@ test("Bedrock: config profile takes precedence over AWS_PROFILE env var", async }) await Instance.provide({ directory: tmp.path, - init: async () => { + init: Effect.promise(async () => { set("AWS_PROFILE", "default") set("AWS_ACCESS_KEY_ID", "test-key-id") - }, + }).pipe(Effect.asVoid), fn: async () => { const providers = await list() expect(providers[ProviderID.amazonBedrock]).toBeDefined() @@ -203,9 +203,9 @@ test("Bedrock: includes custom endpoint in options when specified", async () => }) await Instance.provide({ directory: tmp.path, - init: async () => { + init: Effect.promise(async () => { set("AWS_PROFILE", "default") - }, + }).pipe(Effect.asVoid), fn: async () => { const providers = await list() expect(providers[ProviderID.amazonBedrock]).toBeDefined() @@ -236,12 +236,12 @@ test("Bedrock: autoloads when AWS_WEB_IDENTITY_TOKEN_FILE is present", async () }) await Instance.provide({ directory: tmp.path, - init: async () => { + init: Effect.promise(async () => { set("AWS_WEB_IDENTITY_TOKEN_FILE", "/var/run/secrets/eks.amazonaws.com/serviceaccount/token") set("AWS_ROLE_ARN", "arn:aws:iam::123456789012:role/my-eks-role") set("AWS_PROFILE", "") set("AWS_ACCESS_KEY_ID", "") - }, + }).pipe(Effect.asVoid), fn: async () => { const providers = await list() expect(providers[ProviderID.amazonBedrock]).toBeDefined() @@ -279,9 +279,9 @@ test("Bedrock: model with us. prefix should not be double-prefixed", async () => }) await Instance.provide({ directory: tmp.path, - init: async () => { + init: Effect.promise(async () => { set("AWS_PROFILE", "default") - }, + }).pipe(Effect.asVoid), fn: async () => { const providers = await list() expect(providers[ProviderID.amazonBedrock]).toBeDefined() @@ -316,9 +316,9 @@ test("Bedrock: model with global. prefix should not be prefixed", async () => { }) await Instance.provide({ directory: tmp.path, - init: async () => { + init: Effect.promise(async () => { set("AWS_PROFILE", "default") - }, + }).pipe(Effect.asVoid), fn: async () => { const providers = await list() expect(providers[ProviderID.amazonBedrock]).toBeDefined() @@ -352,9 +352,9 @@ test("Bedrock: model with eu. prefix should not be double-prefixed", async () => }) await Instance.provide({ directory: tmp.path, - init: async () => { + init: Effect.promise(async () => { set("AWS_PROFILE", "default") - }, + }).pipe(Effect.asVoid), fn: async () => { const providers = await list() expect(providers[ProviderID.amazonBedrock]).toBeDefined() @@ -388,9 +388,9 @@ test("Bedrock: model without prefix in US region should get us. prefix added", a }) await Instance.provide({ directory: tmp.path, - init: async () => { + init: Effect.promise(async () => { set("AWS_PROFILE", "default") - }, + }).pipe(Effect.asVoid), fn: async () => { const providers = await list() expect(providers[ProviderID.amazonBedrock]).toBeDefined() diff --git a/packages/opencode/test/provider/models.test.ts b/packages/opencode/test/provider/models.test.ts new file mode 100644 index 0000000000..9e723262ef --- /dev/null +++ b/packages/opencode/test/provider/models.test.ts @@ -0,0 +1,259 @@ +import { describe, expect, beforeAll, beforeEach, afterAll } from "bun:test" +import { Effect, Layer, Ref } from "effect" +import { HttpClient, HttpClientResponse } from "effect/unstable/http" +import { AppFileSystem } from "@opencode-ai/core/filesystem" +import { Flag } from "@opencode-ai/core/flag/flag" +import { Global } from "@opencode-ai/core/global" +import { ModelsDev } from "../../src/provider/models" +import { it } from "../lib/effect" +import { rm, writeFile, utimes, mkdir } from "fs/promises" +import path from "path" + +// test/preload.ts pins KILO_MODELS_PATH to a fixture so other tests can +// resolve providers without network. These tests need to drive the on-disk +// cache themselves and silence the eager refresh fork. Save/restore around +// the suite — never leak the mutation to subsequent test files in the same +// bun process. +const ORIGINAL_MODELS_PATH = Flag.KILO_MODELS_PATH +const ORIGINAL_DISABLE_FETCH = Flag.KILO_DISABLE_MODELS_FETCH +beforeAll(() => { + Flag.KILO_MODELS_PATH = undefined + Flag.KILO_DISABLE_MODELS_FETCH = true +}) +afterAll(() => { + Flag.KILO_MODELS_PATH = ORIGINAL_MODELS_PATH + Flag.KILO_DISABLE_MODELS_FETCH = ORIGINAL_DISABLE_FETCH +}) + +const cacheFile = path.join(Global.Path.cache, "models.json") + +const fixture: Record = { + acme: { + id: "acme", + name: "Acme", + env: ["ACME_API_KEY"], + models: { + "acme-1": { + id: "acme-1", + name: "Acme One", + release_date: "2026-01-01", + attachment: false, + reasoning: false, + temperature: true, + tool_call: true, + limit: { context: 128000, output: 8192 }, + }, + }, + }, +} + +const fixture2: Record = { + beta: { + id: "beta", + name: "Beta", + env: ["BETA_API_KEY"], + models: { + "beta-1": { + id: "beta-1", + name: "Beta One", + release_date: "2026-02-01", + attachment: false, + reasoning: true, + temperature: false, + tool_call: false, + limit: { context: 64000, output: 4096 }, + }, + }, + }, +} + +interface MockState { + body: string + status: number + calls: Array<{ url: string }> +} + +const makeMockClient = (state: Ref.Ref) => + HttpClient.make((request) => + Effect.gen(function* () { + yield* Ref.update(state, (s) => ({ ...s, calls: [...s.calls, { url: request.url }] })) + const s = yield* Ref.get(state) + return HttpClientResponse.fromWeb(request, new Response(s.body, { status: s.status })) + }), + ) + +const buildLayer = (state: Ref.Ref) => + // Layer.fresh is required: ModelsDev.layer is a module-level Layer constant, + // and Effect.provide uses a process-global MemoMap by default — without fresh, + // every test would reuse the cachedInvalidateWithTTL state from the first run. + Layer.fresh(ModelsDev.layer).pipe( + Layer.provide(Layer.succeed(HttpClient.HttpClient, makeMockClient(state))), + Layer.provide(AppFileSystem.defaultLayer), + ) + +const writeCache = (data: object, mtimeMs?: number) => + Effect.promise(async () => { + await mkdir(Global.Path.cache, { recursive: true }) + await writeFile(cacheFile, JSON.stringify(data)) + if (mtimeMs !== undefined) { + const t = mtimeMs / 1000 + await utimes(cacheFile, t, t) + } + }) + +const provided = (state: Ref.Ref, eff: Effect.Effect) => + eff.pipe(Effect.provide(buildLayer(state))) + +beforeEach(async () => { + await rm(cacheFile, { force: true }) +}) + +afterAll(async () => { + await rm(cacheFile, { force: true }) +}) + +const initialState: MockState = { + body: JSON.stringify(fixture), + status: 200, + calls: [], +} + +describe("ModelsDev Service", () => { + it.live("get() returns providers from disk when cache file exists", () => + Effect.gen(function* () { + yield* writeCache(fixture) + const state = yield* Ref.make(initialState) + const result = yield* provided( + state, + ModelsDev.Service.use((s) => s.get()), + ) + expect(result).toEqual(fixture) + const final = yield* Ref.get(state) + expect(final.calls).toEqual([]) + }), + ) + + it.live("get() returns {} when disk empty and fetch disabled", () => + Effect.gen(function* () { + const state = yield* Ref.make(initialState) + const result = yield* provided( + state, + ModelsDev.Service.use((s) => s.get()), + ) + expect(result).toEqual({}) + const final = yield* Ref.get(state) + expect(final.calls).toEqual([]) + }), + ) + + it.live("get() is single-flight under concurrent calls", () => + Effect.gen(function* () { + yield* writeCache(fixture) + const state = yield* Ref.make(initialState) + const results = yield* provided( + state, + Effect.gen(function* () { + const svc = yield* ModelsDev.Service + return yield* Effect.all([svc.get(), svc.get(), svc.get(), svc.get(), svc.get()], { + concurrency: "unbounded", + }) + }), + ) + for (const result of results) expect(result).toEqual(fixture) + }), + ) + + it.live("get() caches across calls (later disk writes are ignored until invalidate)", () => + Effect.gen(function* () { + yield* writeCache(fixture) + const state = yield* Ref.make(initialState) + const first = yield* provided( + state, + Effect.gen(function* () { + const svc = yield* ModelsDev.Service + const a = yield* svc.get() + // mutate disk between calls — cache should mask the change + yield* writeCache(fixture2) + const b = yield* svc.get() + return { a, b } + }), + ) + expect(first.a).toEqual(fixture) + expect(first.b).toEqual(fixture) + }), + ) + + it.live("refresh(true) fetches via HttpClient and updates the cache", () => + Effect.gen(function* () { + yield* writeCache(fixture) + const state = yield* Ref.make({ ...initialState, body: JSON.stringify(fixture2) }) + const result = yield* provided( + state, + Effect.gen(function* () { + const svc = yield* ModelsDev.Service + const before = yield* svc.get() + yield* svc.refresh(true) + const after = yield* svc.get() + return { before, after } + }), + ) + expect(result.before).toEqual(fixture) + expect(result.after).toEqual(fixture2) + const final = yield* Ref.get(state) + expect(final.calls.length).toBe(1) + expect(final.calls[0].url).toContain("/api.json") + }), + ) + + it.live("refresh(false) skips fetch when on-disk file is fresh", () => + Effect.gen(function* () { + // Fresh: mtime within the 5-minute TTL. + yield* writeCache(fixture, Date.now() - 1000) + const state = yield* Ref.make({ ...initialState, body: JSON.stringify(fixture2) }) + yield* provided( + state, + ModelsDev.Service.use((s) => s.refresh(false)), + ) + const final = yield* Ref.get(state) + expect(final.calls).toEqual([]) + }), + ) + + it.live("refresh(false) fetches when on-disk file is stale", () => + Effect.gen(function* () { + // Stale: mtime 10 minutes ago, beyond the 5-minute TTL. + yield* writeCache(fixture, Date.now() - 10 * 60 * 1000) + const state = yield* Ref.make({ ...initialState, body: JSON.stringify(fixture2) }) + const after = yield* provided( + state, + Effect.gen(function* () { + const svc = yield* ModelsDev.Service + yield* svc.refresh(false) + return yield* svc.get() + }), + ) + const final = yield* Ref.get(state) + expect(final.calls.length).toBe(1) + expect(after).toEqual(fixture2) + }), + ) + + it.live("refresh swallows HTTP errors and leaves cache intact", () => + Effect.gen(function* () { + yield* writeCache(fixture) + const state = yield* Ref.make({ ...initialState, status: 500, body: "boom" }) + const result = yield* provided( + state, + Effect.gen(function* () { + const svc = yield* ModelsDev.Service + yield* svc.refresh(true) + return yield* svc.get() + }), + ) + expect(result).toEqual(fixture) + // withTransientReadRetry retries 5xx, so calls may be > 1. + const final = yield* Ref.get(state) + expect(final.calls.length).toBeGreaterThanOrEqual(1) + }), + ) +}) diff --git a/packages/opencode/test/provider/provider.test.ts b/packages/opencode/test/provider/provider.test.ts index 37c8254ab0..9c012cb029 100644 --- a/packages/opencode/test/provider/provider.test.ts +++ b/packages/opencode/test/provider/provider.test.ts @@ -5,6 +5,7 @@ import path from "path" import { tmpdir } from "../fixture/fixture" import { Global } from "@opencode-ai/core/global" import { Instance } from "../../src/project/instance" +import { InstanceStore } from "../../src/project/instance-store" import { Plugin } from "../../src/plugin/index" import { ModelsDev } from "@/provider/models" import { Provider } from "@/provider/provider" @@ -85,9 +86,9 @@ test("provider loaded from env variable", async () => { }) await Instance.provide({ directory: tmp.path, - init: async () => { + init: Effect.promise(async () => { set("ANTHROPIC_API_KEY", "test-api-key") - }, + }).pipe(Effect.asVoid), fn: async () => { const providers = await list() expect(providers[ProviderID.anthropic]).toBeDefined() @@ -122,9 +123,9 @@ test("provider OAuth auth overrides inherited env variable", async () => { }) await Instance.provide({ directory: tmp.path, - init: async () => { + init: Effect.promise(async () => { set("OPENAI_API_KEY", "test-openai-key") - }, + }).pipe(Effect.asVoid), fn: async () => { const providers = await list() const provider = providers[ProviderID.openai] @@ -184,9 +185,9 @@ test("disabled_providers excludes provider", async () => { }) await Instance.provide({ directory: tmp.path, - init: async () => { + init: Effect.promise(async () => { set("ANTHROPIC_API_KEY", "test-api-key") - }, + }).pipe(Effect.asVoid), fn: async () => { const providers = await list() expect(providers[ProviderID.anthropic]).toBeUndefined() @@ -208,10 +209,10 @@ test("enabled_providers restricts to only listed providers", async () => { }) await Instance.provide({ directory: tmp.path, - init: async () => { + init: Effect.promise(async () => { set("ANTHROPIC_API_KEY", "test-api-key") set("OPENAI_API_KEY", "test-openai-key") - }, + }).pipe(Effect.asVoid), fn: async () => { const providers = await list() expect(providers[ProviderID.anthropic]).toBeDefined() @@ -238,9 +239,9 @@ test("model whitelist filters models for provider", async () => { }) await Instance.provide({ directory: tmp.path, - init: async () => { + init: Effect.promise(async () => { set("ANTHROPIC_API_KEY", "test-api-key") - }, + }).pipe(Effect.asVoid), fn: async () => { const providers = await list() expect(providers[ProviderID.anthropic]).toBeDefined() @@ -269,9 +270,9 @@ test("model blacklist excludes specific models", async () => { }) await Instance.provide({ directory: tmp.path, - init: async () => { + init: Effect.promise(async () => { set("ANTHROPIC_API_KEY", "test-api-key") - }, + }).pipe(Effect.asVoid), fn: async () => { const providers = await list() expect(providers[ProviderID.anthropic]).toBeDefined() @@ -304,9 +305,9 @@ test("custom model alias via config", async () => { }) await Instance.provide({ directory: tmp.path, - init: async () => { + init: Effect.promise(async () => { set("ANTHROPIC_API_KEY", "test-api-key") - }, + }).pipe(Effect.asVoid), fn: async () => { const providers = await list() expect(providers[ProviderID.anthropic]).toBeDefined() @@ -441,9 +442,9 @@ test("env variable takes precedence, config merges options", async () => { }) await Instance.provide({ directory: tmp.path, - init: async () => { + init: Effect.promise(async () => { set("ANTHROPIC_API_KEY", "env-api-key") - }, + }).pipe(Effect.asVoid), fn: async () => { const providers = await list() expect(providers[ProviderID.anthropic]).toBeDefined() @@ -467,9 +468,9 @@ test("getModel returns model for valid provider/model", async () => { }) await Instance.provide({ directory: tmp.path, - init: async () => { + init: Effect.promise(async () => { set("ANTHROPIC_API_KEY", "test-api-key") - }, + }).pipe(Effect.asVoid), fn: async () => { const model = await getModel(ProviderID.anthropic, ModelID.make("claude-sonnet-4-20250514")) expect(model).toBeDefined() @@ -494,9 +495,9 @@ test("getModel throws ModelNotFoundError for invalid model", async () => { }) await Instance.provide({ directory: tmp.path, - init: async () => { + init: Effect.promise(async () => { set("ANTHROPIC_API_KEY", "test-api-key") - }, + }).pipe(Effect.asVoid), fn: async () => { expect(getModel(ProviderID.anthropic, ModelID.make("nonexistent-model"))).rejects.toThrow() }, @@ -547,9 +548,9 @@ test("defaultModel returns first available model when no config set", async () = }) await Instance.provide({ directory: tmp.path, - init: async () => { + init: Effect.promise(async () => { set("ANTHROPIC_API_KEY", "test-api-key") - }, + }).pipe(Effect.asVoid), fn: async () => { const model = await defaultModel() expect(model.providerID).toBeDefined() @@ -572,9 +573,9 @@ test("defaultModel respects config model setting", async () => { }) await Instance.provide({ directory: tmp.path, - init: async () => { + init: Effect.promise(async () => { set("ANTHROPIC_API_KEY", "test-api-key") - }, + }).pipe(Effect.asVoid), fn: async () => { const model = await defaultModel() expect(String(model.providerID)).toBe("anthropic") @@ -687,9 +688,9 @@ test("model options are merged from existing model", async () => { }) await Instance.provide({ directory: tmp.path, - init: async () => { + init: Effect.promise(async () => { set("ANTHROPIC_API_KEY", "test-api-key") - }, + }).pipe(Effect.asVoid), fn: async () => { const providers = await list() const model = providers[ProviderID.anthropic].models["claude-sonnet-4-20250514"] @@ -716,9 +717,9 @@ test("provider removed when all models filtered out", async () => { }) await Instance.provide({ directory: tmp.path, - init: async () => { + init: Effect.promise(async () => { set("ANTHROPIC_API_KEY", "test-api-key") - }, + }).pipe(Effect.asVoid), fn: async () => { const providers = await list() expect(providers[ProviderID.anthropic]).toBeUndefined() @@ -739,9 +740,9 @@ test("closest finds model by partial match", async () => { }) await Instance.provide({ directory: tmp.path, - init: async () => { + init: Effect.promise(async () => { set("ANTHROPIC_API_KEY", "test-api-key") - }, + }).pipe(Effect.asVoid), fn: async () => { const result = await closest(ProviderID.anthropic, ["sonnet-4"]) expect(result).toBeDefined() @@ -794,9 +795,9 @@ test("getModel uses realIdByKey for aliased models", async () => { }) await Instance.provide({ directory: tmp.path, - init: async () => { + init: Effect.promise(async () => { set("ANTHROPIC_API_KEY", "test-api-key") - }, + }).pipe(Effect.asVoid), fn: async () => { const providers = await list() expect(providers[ProviderID.anthropic].models["my-sonnet"]).toBeDefined() @@ -909,9 +910,9 @@ test("model inherits properties from existing database model", async () => { }) await Instance.provide({ directory: tmp.path, - init: async () => { + init: Effect.promise(async () => { set("ANTHROPIC_API_KEY", "test-api-key") - }, + }).pipe(Effect.asVoid), fn: async () => { const providers = await list() const model = providers[ProviderID.anthropic].models["claude-sonnet-4-20250514"] @@ -937,9 +938,9 @@ test("disabled_providers prevents loading even with env var", async () => { }) await Instance.provide({ directory: tmp.path, - init: async () => { + init: Effect.promise(async () => { set("OPENAI_API_KEY", "test-openai-key") - }, + }).pipe(Effect.asVoid), fn: async () => { const providers = await list() expect(providers[ProviderID.openai]).toBeUndefined() @@ -961,10 +962,10 @@ test("enabled_providers with empty array allows no providers", async () => { }) await Instance.provide({ directory: tmp.path, - init: async () => { + init: Effect.promise(async () => { set("ANTHROPIC_API_KEY", "test-api-key") set("OPENAI_API_KEY", "test-openai-key") - }, + }).pipe(Effect.asVoid), fn: async () => { const providers = await list() expect(Object.keys(providers).length).toBe(0) @@ -991,9 +992,9 @@ test("whitelist and blacklist can be combined", async () => { }) await Instance.provide({ directory: tmp.path, - init: async () => { + init: Effect.promise(async () => { set("ANTHROPIC_API_KEY", "test-api-key") - }, + }).pipe(Effect.asVoid), fn: async () => { const providers = await list() expect(providers[ProviderID.anthropic]).toBeDefined() @@ -1100,9 +1101,9 @@ test("getSmallModel returns appropriate small model", async () => { }) await Instance.provide({ directory: tmp.path, - init: async () => { + init: Effect.promise(async () => { set("ANTHROPIC_API_KEY", "test-api-key") - }, + }).pipe(Effect.asVoid), fn: async () => { const model = await getSmallModel(ProviderID.anthropic) expect(model).toBeDefined() @@ -1125,9 +1126,9 @@ test("getSmallModel respects config small_model override", async () => { }) await Instance.provide({ directory: tmp.path, - init: async () => { + init: Effect.promise(async () => { set("ANTHROPIC_API_KEY", "test-api-key") - }, + }).pipe(Effect.asVoid), fn: async () => { const model = await getSmallModel(ProviderID.anthropic) expect(model).toBeDefined() @@ -1173,10 +1174,10 @@ test("multiple providers can be configured simultaneously", async () => { }) await Instance.provide({ directory: tmp.path, - init: async () => { + init: Effect.promise(async () => { set("ANTHROPIC_API_KEY", "test-anthropic-key") set("OPENAI_API_KEY", "test-openai-key") - }, + }).pipe(Effect.asVoid), fn: async () => { const providers = await list() expect(providers[ProviderID.anthropic]).toBeDefined() @@ -1252,9 +1253,9 @@ test("model alias name defaults to alias key when id differs", async () => { }) await Instance.provide({ directory: tmp.path, - init: async () => { + init: Effect.promise(async () => { set("ANTHROPIC_API_KEY", "test-api-key") - }, + }).pipe(Effect.asVoid), fn: async () => { const providers = await list() expect(providers[ProviderID.anthropic].models["sonnet"].name).toBe("sonnet") @@ -1292,9 +1293,9 @@ test("provider with multiple env var options only includes apiKey when single en }) await Instance.provide({ directory: tmp.path, - init: async () => { + init: Effect.promise(async () => { set("MULTI_ENV_KEY_1", "test-key") - }, + }).pipe(Effect.asVoid), fn: async () => { const providers = await list() expect(providers[ProviderID.make("multi-env")]).toBeDefined() @@ -1334,9 +1335,9 @@ test("provider with single env var includes apiKey automatically", async () => { }) await Instance.provide({ directory: tmp.path, - init: async () => { + init: Effect.promise(async () => { set("SINGLE_ENV_KEY", "my-api-key") - }, + }).pipe(Effect.asVoid), fn: async () => { const providers = await list() expect(providers[ProviderID.make("single-env")]).toBeDefined() @@ -1371,9 +1372,9 @@ test("model cost overrides existing cost values", async () => { }) await Instance.provide({ directory: tmp.path, - init: async () => { + init: Effect.promise(async () => { set("ANTHROPIC_API_KEY", "test-api-key") - }, + }).pipe(Effect.asVoid), fn: async () => { const providers = await list() const model = providers[ProviderID.anthropic].models["claude-sonnet-4-20250514"] @@ -1450,11 +1451,11 @@ test("disabled_providers and enabled_providers interaction", async () => { }) await Instance.provide({ directory: tmp.path, - init: async () => { + init: Effect.promise(async () => { set("ANTHROPIC_API_KEY", "test-anthropic") set("OPENAI_API_KEY", "test-openai") set("GOOGLE_GENERATIVE_AI_API_KEY", "test-google") - }, + }).pipe(Effect.asVoid), fn: async () => { const providers = await list() // anthropic: in enabled, not in disabled = allowed @@ -1608,10 +1609,10 @@ test("provider env fallback - second env var used if first missing", async () => }) await Instance.provide({ directory: tmp.path, - init: async () => { + init: Effect.promise(async () => { // Only set fallback, not primary set("FALLBACK_KEY", "fallback-api-key") - }, + }).pipe(Effect.asVoid), fn: async () => { const providers = await list() // Provider should load because fallback env var is set @@ -1633,9 +1634,9 @@ test("getModel returns consistent results", async () => { }) await Instance.provide({ directory: tmp.path, - init: async () => { + init: Effect.promise(async () => { set("ANTHROPIC_API_KEY", "test-api-key") - }, + }).pipe(Effect.asVoid), fn: async () => { const model1 = await getModel(ProviderID.anthropic, ModelID.make("claude-sonnet-4-20250514")) const model2 = await getModel(ProviderID.anthropic, ModelID.make("claude-sonnet-4-20250514")) @@ -1694,9 +1695,9 @@ test("ModelNotFoundError includes suggestions for typos", async () => { }) await Instance.provide({ directory: tmp.path, - init: async () => { + init: Effect.promise(async () => { set("ANTHROPIC_API_KEY", "test-api-key") - }, + }).pipe(Effect.asVoid), fn: async () => { try { await getModel(ProviderID.anthropic, ModelID.make("claude-sonet-4")) // typo: sonet instead of sonnet @@ -1722,9 +1723,9 @@ test("ModelNotFoundError for provider includes suggestions", async () => { }) await Instance.provide({ directory: tmp.path, - init: async () => { + init: Effect.promise(async () => { set("ANTHROPIC_API_KEY", "test-api-key") - }, + }).pipe(Effect.asVoid), fn: async () => { try { await getModel(ProviderID.make("antropic"), ModelID.make("claude-sonnet-4")) // typo: antropic @@ -1770,9 +1771,9 @@ test("getProvider returns provider info", async () => { }) await Instance.provide({ directory: tmp.path, - init: async () => { + init: Effect.promise(async () => { set("ANTHROPIC_API_KEY", "test-api-key") - }, + }).pipe(Effect.asVoid), fn: async () => { const provider = await getProvider(ProviderID.anthropic) expect(provider).toBeDefined() @@ -1794,9 +1795,9 @@ test("closest returns undefined when no partial match found", async () => { }) await Instance.provide({ directory: tmp.path, - init: async () => { + init: Effect.promise(async () => { set("ANTHROPIC_API_KEY", "test-api-key") - }, + }).pipe(Effect.asVoid), fn: async () => { const result = await closest(ProviderID.anthropic, ["nonexistent-xyz-model"]) expect(result).toBeUndefined() @@ -1817,9 +1818,9 @@ test("closest checks multiple query terms in order", async () => { }) await Instance.provide({ directory: tmp.path, - init: async () => { + init: Effect.promise(async () => { set("ANTHROPIC_API_KEY", "test-api-key") - }, + }).pipe(Effect.asVoid), fn: async () => { // First term won't match, second will const result = await closest(ProviderID.anthropic, ["nonexistent", "haiku"]) @@ -1889,9 +1890,9 @@ test("provider options are deeply merged", async () => { }) await Instance.provide({ directory: tmp.path, - init: async () => { + init: Effect.promise(async () => { set("ANTHROPIC_API_KEY", "test-api-key") - }, + }).pipe(Effect.asVoid), fn: async () => { const providers = await list() // Custom options should be merged @@ -1927,9 +1928,9 @@ test("custom model inherits npm package from models.dev provider config", async }) await Instance.provide({ directory: tmp.path, - init: async () => { + init: Effect.promise(async () => { set("OPENAI_API_KEY", "test-api-key") - }, + }).pipe(Effect.asVoid), fn: async () => { const providers = await list() const model = providers[ProviderID.openai].models["my-custom-model"] @@ -1962,9 +1963,9 @@ test("custom model inherits api.url from models.dev provider", async () => { }) await Instance.provide({ directory: tmp.path, - init: async () => { + init: Effect.promise(async () => { set("OPENROUTER_API_KEY", "test-api-key") - }, + }).pipe(Effect.asVoid), fn: async () => { const providers = await list() expect(providers[ProviderID.openrouter]).toBeDefined() @@ -2095,9 +2096,9 @@ test("model variants are generated for reasoning models", async () => { }) await Instance.provide({ directory: tmp.path, - init: async () => { + init: Effect.promise(async () => { set("ANTHROPIC_API_KEY", "test-api-key") - }, + }).pipe(Effect.asVoid), fn: async () => { const providers = await list() // Claude sonnet 4 has reasoning capability @@ -2133,9 +2134,9 @@ test("model variants can be disabled via config", async () => { }) await Instance.provide({ directory: tmp.path, - init: async () => { + init: Effect.promise(async () => { set("ANTHROPIC_API_KEY", "test-api-key") - }, + }).pipe(Effect.asVoid), fn: async () => { const providers = await list() const model = providers[ProviderID.anthropic].models["claude-sonnet-4-20250514"] @@ -2176,9 +2177,9 @@ test("model variants can be customized via config", async () => { }) await Instance.provide({ directory: tmp.path, - init: async () => { + init: Effect.promise(async () => { set("ANTHROPIC_API_KEY", "test-api-key") - }, + }).pipe(Effect.asVoid), fn: async () => { const providers = await list() const model = providers[ProviderID.anthropic].models["claude-sonnet-4-20250514"] @@ -2215,9 +2216,9 @@ test("disabled key is stripped from variant config", async () => { }) await Instance.provide({ directory: tmp.path, - init: async () => { + init: Effect.promise(async () => { set("ANTHROPIC_API_KEY", "test-api-key") - }, + }).pipe(Effect.asVoid), fn: async () => { const providers = await list() const model = providers[ProviderID.anthropic].models["claude-sonnet-4-20250514"] @@ -2253,9 +2254,9 @@ test("all variants can be disabled via config", async () => { }) await Instance.provide({ directory: tmp.path, - init: async () => { + init: Effect.promise(async () => { set("ANTHROPIC_API_KEY", "test-api-key") - }, + }).pipe(Effect.asVoid), fn: async () => { const providers = await list() const model = providers[ProviderID.anthropic].models["claude-sonnet-4-20250514"] @@ -2291,9 +2292,9 @@ test("variant config merges with generated variants", async () => { }) await Instance.provide({ directory: tmp.path, - init: async () => { + init: Effect.promise(async () => { set("ANTHROPIC_API_KEY", "test-api-key") - }, + }).pipe(Effect.asVoid), fn: async () => { const providers = await list() const model = providers[ProviderID.anthropic].models["claude-sonnet-4-20250514"] @@ -2329,9 +2330,9 @@ test("variants filtered in second pass for database models", async () => { }) await Instance.provide({ directory: tmp.path, - init: async () => { + init: Effect.promise(async () => { set("OPENAI_API_KEY", "test-api-key") - }, + }).pipe(Effect.asVoid), fn: async () => { const providers = await list() const model = providers[ProviderID.openai].models["gpt-5"] @@ -2433,9 +2434,9 @@ test("Google Vertex: retains baseURL for custom proxy", async () => { await Instance.provide({ directory: tmp.path, - init: async () => { + init: Effect.promise(async () => { set("GOOGLE_APPLICATION_CREDENTIALS", "test-creds") - }, + }).pipe(Effect.asVoid), fn: async () => { const providers = await list() expect(providers[ProviderID.make("vertex-proxy")]).toBeDefined() @@ -2478,9 +2479,9 @@ test("Google Vertex: supports OpenAI compatible models", async () => { await Instance.provide({ directory: tmp.path, - init: async () => { + init: Effect.promise(async () => { set("GOOGLE_APPLICATION_CREDENTIALS", "test-creds") - }, + }).pipe(Effect.asVoid), fn: async () => { const providers = await list() const model = providers[ProviderID.make("vertex-openai")].models["gpt-4"] @@ -2504,11 +2505,11 @@ test("cloudflare-ai-gateway loads with env variables", async () => { }) await Instance.provide({ directory: tmp.path, - init: async () => { + init: Effect.promise(async () => { set("CLOUDFLARE_ACCOUNT_ID", "test-account") set("CLOUDFLARE_GATEWAY_ID", "test-gateway") set("CLOUDFLARE_API_TOKEN", "test-token") - }, + }).pipe(Effect.asVoid), fn: async () => { const providers = await list() expect(providers[ProviderID.make("cloudflare-ai-gateway")]).toBeDefined() @@ -2536,11 +2537,11 @@ test("cloudflare-ai-gateway forwards config metadata options", async () => { }) await Instance.provide({ directory: tmp.path, - init: async () => { + init: Effect.promise(async () => { set("CLOUDFLARE_ACCOUNT_ID", "test-account") set("CLOUDFLARE_GATEWAY_ID", "test-gateway") set("CLOUDFLARE_API_TOKEN", "test-token") - }, + }).pipe(Effect.asVoid), fn: async () => { const providers = await list() expect(providers[ProviderID.make("cloudflare-ai-gateway")]).toBeDefined() @@ -2609,7 +2610,7 @@ test("plugin config providers persist after instance dispose", async () => { // kilocode_change start await Instance.provide({ directory: tmp.path, - fn: () => Instance.dispose(), + fn: () => InstanceStore.disposeInstance(Instance.current), }) // kilocode_change end @@ -2646,10 +2647,10 @@ test("plugin config enabled and disabled providers are honored", async () => { await Instance.provide({ directory: tmp.path, - init: async () => { + init: Effect.promise(async () => { set("ANTHROPIC_API_KEY", "test-anthropic-key") set("OPENAI_API_KEY", "test-openai-key") - }, + }).pipe(Effect.asVoid), fn: async () => { const providers = await list() expect(providers[ProviderID.anthropic]).toBeDefined() diff --git a/packages/opencode/test/question/question.test.ts b/packages/opencode/test/question/question.test.ts index 4dc92ec6c2..2f74627155 100644 --- a/packages/opencode/test/question/question.test.ts +++ b/packages/opencode/test/question/question.test.ts @@ -1,8 +1,9 @@ import { afterEach, test, expect } from "bun:test" import { Question } from "../../src/question" import { Instance } from "../../src/project/instance" +import { InstanceStore } from "../../src/project/instance-store" import { QuestionID } from "../../src/question/schema" -import { tmpdir } from "../fixture/fixture" +import { disposeAllInstances, tmpdir } from "../fixture/fixture" import { SessionID } from "../../src/session/schema" import { AppRuntime } from "../../src/effect/app-runtime" @@ -17,7 +18,7 @@ const reply = (input: { requestID: QuestionID; answers: ReadonlyArray AppRuntime.runPromise(Question.Service.use((svc) => svc.reject(id))) afterEach(async () => { - await Instance.disposeAll() + await disposeAllInstances() }) /** Reject all pending questions so dangling Deferred fibers don't hang the test. */ @@ -449,7 +450,7 @@ test("pending question rejects on instance dispose", async () => { fn: async () => { const items = await list() expect(items).toHaveLength(1) - await Instance.dispose() + await InstanceStore.disposeInstance(Instance.current) }, }) @@ -484,7 +485,7 @@ test("pending question rejects on instance reload", async () => { fn: async () => { const items = await list() expect(items).toHaveLength(1) - await Instance.reload({ directory: tmp.path }) + await InstanceStore.reloadInstance({ directory: tmp.path }) }, }) diff --git a/packages/opencode/test/server/AGENTS.md b/packages/opencode/test/server/AGENTS.md new file mode 100644 index 0000000000..bed2b52695 --- /dev/null +++ b/packages/opencode/test/server/AGENTS.md @@ -0,0 +1,15 @@ +# Server Test Guide + +Use these patterns for server and HttpApi middleware tests in this directory. + +- Prefer focused middleware tests with tiny fake routes over full API route trees when testing routing, context, proxying, or middleware policy. +- Use `testEffect(...)` with `NodeHttpServer.layerTest` for the primary in-test server and make relative `HttpClient` requests against it. +- Use `HttpRouter.add(...)` probe routes that expose the context under test, such as `WorkspaceRouteContext`, `InstanceRef`, or `WorkspaceRef`. +- Compose middleware in the same order as production when testing interactions, for example `instanceRouterMiddleware.combine(workspaceRouterMiddleware)`. +- For secondary upstream servers, build Effect `NodeHttpServer.layer(...)` into the current test scope with `Layer.build(...)` so the listener stays alive until the test scope exits. +- Avoid `Bun.serve` when testing Effect HTTP middleware. Keep the test in the Effect HTTP stack unless the production path being tested is Bun-specific. +- For WebSocket paths, use `Socket.makeWebSocket(...)` from the test client and assert protocol forwarding or frame relay when relevant. +- Use scoped test layers for flags, database reset, and other global mutable state. Restore flags and reset state in finalizers. +- Use `tmpdirScoped({ git: true })` plus `Project.use.fromDirectory(dir)` for project-backed requests. +- If a test needs persisted state without matching runtime state, keep direct database setup inside a narrowly named helper that explains that state. +- Add comments for non-obvious test topology, especially tests involving both the local test server and a fake upstream server. diff --git a/packages/opencode/test/server/httpapi-authorization.test.ts b/packages/opencode/test/server/httpapi-authorization.test.ts new file mode 100644 index 0000000000..c3bab23ac7 --- /dev/null +++ b/packages/opencode/test/server/httpapi-authorization.test.ts @@ -0,0 +1,103 @@ +import { NodeHttpServer } from "@effect/platform-node" +import { describe, expect } from "bun:test" +import { Effect, Layer, Option, Schema } from "effect" +import { HttpClient, HttpClientRequest, HttpRouter } from "effect/unstable/http" +import { HttpApi, HttpApiBuilder, HttpApiEndpoint, HttpApiGroup } from "effect/unstable/httpapi" +import { + Authorization, + ServerAuthConfig, + authorizationLayer, +} from "../../src/server/routes/instance/httpapi/middleware/authorization" +import { testEffect } from "../lib/effect" + +const Api = HttpApi.make("test-authorization").add( + HttpApiGroup.make("test") + .add( + HttpApiEndpoint.get("probe", "/probe", { + success: Schema.String, + }), + ) + .middleware(Authorization), +) + +const handlers = HttpApiBuilder.group(Api, "test", (handlers) => handlers.handle("probe", () => Effect.succeed("ok"))) + +const apiLayer = HttpRouter.serve( + HttpApiBuilder.layer(Api).pipe(Layer.provide(handlers), Layer.provide(authorizationLayer)), + { disableListenLog: true, disableLogger: true }, +).pipe(Layer.provideMerge(NodeHttpServer.layerTest)) + +const noAuthLayer = ServerAuthConfig.layer({ password: Option.none(), username: "opencode" }) +const secretLayer = ServerAuthConfig.layer({ password: Option.some("secret"), username: "opencode" }) +const kitSecretLayer = ServerAuthConfig.layer({ password: Option.some("secret"), username: "kit" }) + +const it = testEffect(apiLayer.pipe(Layer.provide(noAuthLayer))) +const itSecret = testEffect(apiLayer.pipe(Layer.provide(secretLayer))) +const itKitSecret = testEffect(apiLayer.pipe(Layer.provide(kitSecretLayer))) + +const basic = (username: string, password: string) => + `Basic ${Buffer.from(`${username}:${password}`).toString("base64")}` + +const token = (username: string, password: string) => Buffer.from(`${username}:${password}`).toString("base64") + +const getProbe = (headers?: Record) => + HttpClientRequest.get("/probe").pipe( + headers ? HttpClientRequest.setHeaders(headers) : (request) => request, + HttpClient.execute, + ) + +describe("HttpApi authorization middleware", () => { + it.live("allows requests when server password is not configured", () => + Effect.gen(function* () { + const response = yield* getProbe() + + expect(response.status).toBe(200) + expect(yield* response.json).toBe("ok") + }), + ) + + itSecret.live("requires configured password for basic auth", () => + Effect.gen(function* () { + const [missing, badPassword, good] = yield* Effect.all( + [ + getProbe(), + getProbe({ authorization: basic("opencode", "wrong") }), + getProbe({ authorization: basic("opencode", "secret") }), + ], + { concurrency: "unbounded" }, + ) + + expect(missing.status).toBe(401) + expect(badPassword.status).toBe(401) + expect(good.status).toBe(200) + }), + ) + + itKitSecret.live("respects configured basic auth username", () => + Effect.gen(function* () { + const [defaultUser, configuredUser] = yield* Effect.all( + [getProbe({ authorization: basic("opencode", "secret") }), getProbe({ authorization: basic("kit", "secret") })], + { concurrency: "unbounded" }, + ) + + expect(defaultUser.status).toBe(401) + expect(configuredUser.status).toBe(200) + }), + ) + + itSecret.live("accepts auth token query credentials", () => + Effect.gen(function* () { + const response = yield* HttpClient.get(`/probe?auth_token=${encodeURIComponent(token("opencode", "secret"))}`) + + expect(response.status).toBe(200) + }), + ) + + itSecret.live("rejects malformed auth token query credentials", () => + Effect.gen(function* () { + const response = yield* HttpClient.get("/probe?auth_token=not-base64") + + expect(response.status).toBe(401) + }), + ) +}) diff --git a/packages/opencode/test/server/httpapi-bridge.test.ts b/packages/opencode/test/server/httpapi-bridge.test.ts index 2d7cccf4f1..237d606907 100644 --- a/packages/opencode/test/server/httpapi-bridge.test.ts +++ b/packages/opencode/test/server/httpapi-bridge.test.ts @@ -2,14 +2,17 @@ import { afterEach, describe, expect, test } from "bun:test" import { Flag } from "@opencode-ai/core/flag/flag" import { Instance } from "../../src/project/instance" import { ControlPaths } from "../../src/server/routes/instance/httpapi/groups/control" -import { FileApi, FilePaths } from "../../src/server/routes/instance/httpapi/groups/file" +import { FilePaths } from "../../src/server/routes/instance/httpapi/groups/file" import { GlobalPaths } from "../../src/server/routes/instance/httpapi/groups/global" import { PublicApi } from "../../src/server/routes/instance/httpapi/public" +import { ExperimentalHttpApiServer } from "../../src/server/routes/instance/httpapi/server" import { Server } from "../../src/server/server" import * as Log from "@opencode-ai/core/util/log" +import { ConfigProvider, Layer } from "effect" +import { HttpRouter } from "effect/unstable/http" import { OpenApi } from "effect/unstable/httpapi" import { resetDatabase } from "../fixture/db" -import { tmpdir } from "../fixture/fixture" +import { disposeAllInstances, tmpdir } from "../fixture/fixture" void Log.init({ print: false }) @@ -30,7 +33,26 @@ function app(input?: { password?: string; username?: string }) { Flag.KILO_EXPERIMENTAL_HTTPAPI = true Flag.KILO_SERVER_PASSWORD = input?.password Flag.KILO_SERVER_USERNAME = input?.username - return Server.Default().app + + const handler = HttpRouter.toWebHandler( + ExperimentalHttpApiServer.routes.pipe( + Layer.provide( + ConfigProvider.layer( + ConfigProvider.fromUnknown({ + KILO_SERVER_PASSWORD: input?.password, + KILO_SERVER_USERNAME: input?.username, + }), + ), + ), + ), + { disableLogger: true }, + ).handler + return { + fetch: (request: Request) => handler(request, ExperimentalHttpApiServer.context), + request(input: string | URL | Request, init?: RequestInit) { + return this.fetch(input instanceof Request ? input : new Request(new URL(input, "http://localhost"), init)) + }, + } } function openApiRouteKeys(spec: { paths: Record>> }) { @@ -94,10 +116,26 @@ type RequestBody = { required?: boolean } -function parameterKey(param: unknown) { - if (!param || typeof param !== "object" || !("in" in param) || !("name" in param)) return - if (typeof param.in !== "string" || typeof param.name !== "string") return - return `${param.in}:${param.name}:${"required" in param && param.required === true}` +function parameterKey(param: unknown): string | undefined { + if (!param || typeof param !== "object" || !("in" in param) || !("name" in param)) return undefined + if (typeof param.in !== "string" || typeof param.name !== "string") return undefined + return `${param.in}:${param.name}:${"required" in param && param.required === true}:${stableSchema( + "schema" in param ? param.schema : undefined, + )}` +} + +function stableSchema(input: unknown): string { + return JSON.stringify(sortSchema(input)) +} + +function sortSchema(input: unknown): unknown { + if (Array.isArray(input)) return input.map(sortSchema) + if (!input || typeof input !== "object") return input + return Object.fromEntries( + Object.entries(input) + .sort(([left], [right]) => left.localeCompare(right)) + .map(([key, value]) => [key, sortSchema(value)]), + ) } function parameterSchema(input: { @@ -105,27 +143,29 @@ function parameterSchema(input: { path: string method: (typeof methods)[number] name: string -}) { +}): unknown { const param = input.spec.paths[input.path]?.[input.method]?.parameters?.find( (param) => !!param && typeof param === "object" && "name" in param && param.name === input.name, ) - if (!param || typeof param !== "object" || !("schema" in param)) return + if (!param || typeof param !== "object" || !("schema" in param)) return undefined return param.schema } function requestBodyKey(spec: OpenApiSpec, body: unknown) { if (!body || typeof body !== "object" || !("content" in body)) return "" + // oxlint-disable-next-line typescript-eslint/no-unsafe-type-assertion -- Guarded above; test helper only needs this OpenAPI subset. const requestBody = body as RequestBody return JSON.stringify({ required: requestBody.required === true, content: Object.entries(requestBody.content ?? {}) - .map(([type, value]) => [type, requestBodySchemaKind(spec, value.schema)]) - .sort(), + .map(([type, value]) => [type, requestBodySchemaKind(spec, value.schema)] as const) + .sort(([left], [right]) => left.localeCompare(right)), }) } function requestBodySchemaKind(spec: OpenApiSpec, schema: OpenApiSchema | undefined) { if (!schema) return "" + // oxlint-disable-next-line typescript-eslint/no-unsafe-type-assertion -- `$ref` lookup is constrained to OpenAPI schema components in this test helper. const resolved = ( schema.$ref ? spec.components?.schemas?.[schema.$ref.replace("#/components/schemas/", "")] : schema ) as OpenApiSchema | undefined @@ -142,6 +182,7 @@ function responseContentTypes(input: { }) { const responses = input.spec.paths[input.path]?.[input.method]?.responses if (!responses || typeof responses !== "object" || !(input.status in responses)) return [] + // oxlint-disable-next-line typescript-eslint/no-unsafe-type-assertion -- Guarded dynamic OpenAPI response lookup. const response = (responses as Record)[input.status] if (!response || typeof response !== "object" || !("content" in response)) return [] const content = (response as { content?: unknown }).content @@ -167,7 +208,7 @@ afterEach(async () => { Flag.KILO_EXPERIMENTAL_HTTPAPI = original.KILO_EXPERIMENTAL_HTTPAPI Flag.KILO_SERVER_PASSWORD = original.KILO_SERVER_PASSWORD Flag.KILO_SERVER_USERNAME = original.KILO_SERVER_USERNAME - await Instance.disposeAll() + await disposeAllInstances() await resetDatabase() }) @@ -240,6 +281,18 @@ describe("HttpApi server", () => { }) }) + test("matches SDK-affecting request schema details", () => { + const effect = effectOpenApi() + const sessionUpdate = effect.paths["/session/{sessionID}"]?.patch?.requestBody + const sessionUpdateSchema = + typeof sessionUpdate === "object" && sessionUpdate && "content" in sessionUpdate + ? sessionUpdate.content?.["application/json"]?.schema + : undefined + const sessionUpdateProperties = sessionUpdateSchema?.properties as Record | undefined + const time = sessionUpdateProperties?.time + expect(time?.properties?.archived).toEqual({ type: "number" }) + }) + test("documents event routes as server-sent events", () => { const effect = effectOpenApi() diff --git a/packages/opencode/test/server/httpapi-config.test.ts b/packages/opencode/test/server/httpapi-config.test.ts index e903fc4046..cb9c8002f4 100644 --- a/packages/opencode/test/server/httpapi-config.test.ts +++ b/packages/opencode/test/server/httpapi-config.test.ts @@ -6,7 +6,7 @@ import { Instance } from "../../src/project/instance" import { Server } from "../../src/server/server" import * as Log from "@opencode-ai/core/util/log" import { resetDatabase } from "../fixture/db" -import { tmpdir } from "../fixture/fixture" +import { disposeAllInstances, tmpdir } from "../fixture/fixture" void Log.init({ print: false }) @@ -37,7 +37,7 @@ async function waitDisposed(directory: string) { afterEach(async () => { Flag.KILO_EXPERIMENTAL_HTTPAPI = original - await Instance.disposeAll() + await disposeAllInstances() await resetDatabase() }) diff --git a/packages/opencode/test/server/httpapi-cors.test.ts b/packages/opencode/test/server/httpapi-cors.test.ts new file mode 100644 index 0000000000..d17dd14c87 --- /dev/null +++ b/packages/opencode/test/server/httpapi-cors.test.ts @@ -0,0 +1,89 @@ +import { NodeHttpServer, NodeServices } from "@effect/platform-node" +import { Flag } from "@opencode-ai/core/flag/flag" +import { describe, expect } from "bun:test" +import { Config, Effect, Layer } from "effect" +import { HttpClient, HttpClientRequest, HttpRouter, HttpServer } from "effect/unstable/http" +import * as Socket from "effect/unstable/socket/Socket" +import { Server } from "../../src/server/server" +import { InstancePaths } from "../../src/server/routes/instance/httpapi/groups/instance" +import { ExperimentalHttpApiServer } from "../../src/server/routes/instance/httpapi/server" +import { resetDatabase } from "../fixture/db" +import { testEffect } from "../lib/effect" + +const testStateLayer = Layer.effectDiscard( + Effect.gen(function* () { + const original = { + KILO_EXPERIMENTAL_HTTPAPI: Flag.KILO_EXPERIMENTAL_HTTPAPI, + KILO_SERVER_PASSWORD: Flag.KILO_SERVER_PASSWORD, + } + Flag.KILO_EXPERIMENTAL_HTTPAPI = true + Flag.KILO_SERVER_PASSWORD = "secret" + yield* Effect.promise(() => resetDatabase()) + yield* Effect.addFinalizer(() => + Effect.promise(async () => { + Flag.KILO_EXPERIMENTAL_HTTPAPI = original.KILO_EXPERIMENTAL_HTTPAPI + Flag.KILO_SERVER_PASSWORD = original.KILO_SERVER_PASSWORD + await resetDatabase() + }), + ) + }), +) + +const servedRoutes: Layer.Layer = HttpRouter.serve( + ExperimentalHttpApiServer.routes, + { disableListenLog: true, disableLogger: true }, +) + +const it = testEffect( + Layer.mergeAll( + testStateLayer, + servedRoutes.pipe( + Layer.provide(Socket.layerWebSocketConstructorGlobal), + Layer.provideMerge(NodeHttpServer.layerTest), + Layer.provideMerge(NodeServices.layer), + ), + ), +) + +describe("HttpApi CORS", () => { + it.live("allows browser preflight requests without credentials", () => + Effect.gen(function* () { + const response = yield* HttpClientRequest.options(InstancePaths.path).pipe( + HttpClientRequest.setHeaders({ + origin: "http://localhost:3000", + "access-control-request-method": "GET", + "access-control-request-headers": "authorization", + }), + HttpClient.execute, + ) + + expect(response.status).toBe(204) + expect(response.headers["access-control-allow-origin"]).toBe("http://localhost:3000") + expect(response.headers["access-control-allow-headers"]).toBe("authorization") + }), + ) + + it.live("uses custom CORS origins passed to the server", () => + Effect.gen(function* () { + const listener = yield* Effect.acquireRelease( + Effect.promise(() => Server.listen({ hostname: "127.0.0.1", port: 0, cors: ["https://custom.example"] })), + (listener) => Effect.promise(() => listener.stop(true)), + ) + + const response = yield* Effect.promise(() => + fetch(new URL(InstancePaths.path, listener.url), { + method: "OPTIONS", + headers: { + origin: "https://custom.example", + "access-control-request-method": "GET", + "access-control-request-headers": "authorization", + }, + }), + ) + + expect(response.status).toBe(204) + expect(response.headers.get("access-control-allow-origin")).toBe("https://custom.example") + expect(response.headers.get("access-control-allow-headers")).toBe("authorization") + }), + ) +}) diff --git a/packages/opencode/test/server/httpapi-event.test.ts b/packages/opencode/test/server/httpapi-event.test.ts index f2fa5786c7..3b83712745 100644 --- a/packages/opencode/test/server/httpapi-event.test.ts +++ b/packages/opencode/test/server/httpapi-event.test.ts @@ -5,15 +5,15 @@ import { Server } from "../../src/server/server" import { EventPaths } from "../../src/server/routes/instance/httpapi/event" import * as Log from "@opencode-ai/core/util/log" import { resetDatabase } from "../fixture/db" -import { tmpdir } from "../fixture/fixture" +import { disposeAllInstances, tmpdir } from "../fixture/fixture" void Log.init({ print: false }) const original = Flag.KILO_EXPERIMENTAL_HTTPAPI -function app() { - Flag.KILO_EXPERIMENTAL_HTTPAPI = true - return Server.Default().app +function app(experimental = true) { + Flag.KILO_EXPERIMENTAL_HTTPAPI = experimental + return experimental ? Server.Default().app : Server.Legacy().app } async function readFirstChunk(response: Response) { @@ -29,7 +29,7 @@ async function readFirstChunk(response: Response) { afterEach(async () => { Flag.KILO_EXPERIMENTAL_HTTPAPI = original - await Instance.disposeAll() + await disposeAllInstances() await resetDatabase() }) @@ -45,4 +45,13 @@ describe("event HttpApi bridge", () => { expect(response.headers.get("x-content-type-options")).toBe("nosniff") expect(await readFirstChunk(response)).toContain('data: {"type":"server.connected","properties":{}}\n\n') }) + + test("matches legacy first event frame", async () => { + await using tmp = await tmpdir({ git: true, config: { formatter: false, lsp: false } }) + const headers = { "x-kilo-directory": tmp.path } + const legacy = await app(false).request(EventPaths.event, { headers }) + const effect = await app(true).request(EventPaths.event, { headers }) + + expect(await readFirstChunk(effect)).toBe(await readFirstChunk(legacy)) + }) }) diff --git a/packages/opencode/test/server/httpapi-experimental.test.ts b/packages/opencode/test/server/httpapi-experimental.test.ts index 3330e68ea5..3279f99b3e 100644 --- a/packages/opencode/test/server/httpapi-experimental.test.ts +++ b/packages/opencode/test/server/httpapi-experimental.test.ts @@ -10,7 +10,7 @@ import { Database } from "@/storage/db" import * as Log from "@opencode-ai/core/util/log" import { Worktree } from "../../src/worktree" import { resetDatabase } from "../fixture/db" -import { tmpdir } from "../fixture/fixture" +import { disposeAllInstances, tmpdir } from "../fixture/fixture" void Log.init({ print: false }) @@ -50,7 +50,7 @@ async function waitReady(directory: string) { afterEach(async () => { Flag.KILO_EXPERIMENTAL_HTTPAPI = original - await Instance.disposeAll() + await disposeAllInstances() await resetDatabase() }) diff --git a/packages/opencode/test/server/httpapi-file.test.ts b/packages/opencode/test/server/httpapi-file.test.ts index 627db9aebd..754ff13fd7 100644 --- a/packages/opencode/test/server/httpapi-file.test.ts +++ b/packages/opencode/test/server/httpapi-file.test.ts @@ -6,7 +6,7 @@ import { FilePaths } from "../../src/server/routes/instance/httpapi/groups/file" import { Instance } from "../../src/project/instance" import * as Log from "@opencode-ai/core/util/log" import { resetDatabase } from "../fixture/db" -import { tmpdir } from "../fixture/fixture" +import { disposeAllInstances, tmpdir } from "../fixture/fixture" void Log.init({ print: false }) @@ -28,7 +28,7 @@ function request(route: string, directory: string, query?: Record { - await Instance.disposeAll() + await disposeAllInstances() await resetDatabase() }) diff --git a/packages/opencode/test/server/httpapi-instance-context.test.ts b/packages/opencode/test/server/httpapi-instance-context.test.ts new file mode 100644 index 0000000000..a15156060d --- /dev/null +++ b/packages/opencode/test/server/httpapi-instance-context.test.ts @@ -0,0 +1,237 @@ +import { NodeHttpServer, NodeServices } from "@effect/platform-node" +import { Flag } from "@opencode-ai/core/flag/flag" +import { GlobalBus } from "@/bus/global" +import { describe, expect } from "bun:test" +import { Effect, Fiber, Layer } from "effect" +import { HttpClient, HttpClientRequest, HttpRouter, HttpServerResponse } from "effect/unstable/http" +import * as Socket from "effect/unstable/socket/Socket" +import { mkdir } from "node:fs/promises" +import path from "node:path" +import { registerAdapter } from "../../src/control-plane/adapters" +import type { WorkspaceAdapter } from "../../src/control-plane/types" +import { Workspace } from "../../src/control-plane/workspace" +import { InstanceRef, WorkspaceRef } from "../../src/effect/instance-ref" +import { InstanceBootstrap } from "../../src/project/bootstrap" +import { Instance } from "../../src/project/instance" +import { InstanceStore } from "../../src/project/instance-store" +import { Project } from "../../src/project/project" +import { disposeMiddleware, markInstanceForDisposal } from "../../src/server/routes/instance/httpapi/lifecycle" +import { instanceRouterMiddleware } from "../../src/server/routes/instance/httpapi/middleware/instance-context" +import { workspaceRouterMiddleware } from "../../src/server/routes/instance/httpapi/middleware/workspace-routing" +import { resetDatabase } from "../fixture/db" +import { disposeAllInstances, tmpdirScoped } from "../fixture/fixture" +import { testEffect } from "../lib/effect" + +const testStateLayer = Layer.effectDiscard( + Effect.gen(function* () { + const originalWorkspaces = Flag.KILO_EXPERIMENTAL_WORKSPACES + yield* Effect.promise(() => resetDatabase()) + Flag.KILO_EXPERIMENTAL_WORKSPACES = true + yield* Effect.addFinalizer(() => + Effect.promise(async () => { + Flag.KILO_EXPERIMENTAL_WORKSPACES = originalWorkspaces + await disposeAllInstances() + await resetDatabase() + }), + ) + }), +) + +const it = testEffect( + Layer.mergeAll( + testStateLayer, + NodeHttpServer.layerTest, + NodeServices.layer, + InstanceBootstrap.defaultLayer, + InstanceStore.defaultLayer, + Project.defaultLayer, + Workspace.defaultLayer, + ), +) + +const instanceContextTestLayer = instanceRouterMiddleware + .combine(workspaceRouterMiddleware) + .layer.pipe(Layer.provide(Socket.layerWebSocketConstructorGlobal)) + +const localAdapter = (directory: string): WorkspaceAdapter => ({ + name: "Local Test", + description: "Create a local test workspace", + configure: (info) => ({ ...info, name: "local-test", directory }), + create: async () => { + await mkdir(directory, { recursive: true }) + }, + async remove() {}, + target: () => ({ type: "local" as const, directory }), +}) + +const createLocalWorkspace = (input: { projectID: Project.Info["id"]; type: string; directory: string }) => + Effect.acquireRelease( + Effect.gen(function* () { + registerAdapter(input.projectID, input.type, localAdapter(input.directory)) + const workspace = yield* Workspace.Service + return yield* workspace.create({ + type: input.type, + branch: null, + extra: null, + projectID: input.projectID, + }) + }), + (info) => Workspace.Service.use((workspace) => workspace.remove(info.id)).pipe(Effect.ignore), + ) + +const probeInstanceContext = Effect.gen(function* () { + const instance = yield* InstanceRef + const workspaceID = yield* WorkspaceRef + return yield* HttpServerResponse.json({ + directory: instance?.directory, + worktree: instance?.worktree, + projectID: instance?.project.id, + workspaceID, + }) +}) + +const serveProbe = (probePath: HttpRouter.PathInput = "/probe") => + HttpRouter.add("GET", probePath, probeInstanceContext).pipe( + Layer.provide(instanceContextTestLayer), + HttpRouter.serve, + Layer.build, + ) + +const waitDisposedEvent = Effect.promise( + () => + new Promise<{ directory?: string; workspace?: string }>((resolve, reject) => { + const timer = setTimeout(() => { + GlobalBus.off("event", onEvent) + reject(new Error("timed out waiting for instance disposal")) + }, 10_000) + + function onEvent(event: { directory?: string; workspace?: string; payload: { type?: string } }) { + if (event.payload.type !== "server.instance.disposed") return + clearTimeout(timer) + GlobalBus.off("event", onEvent) + resolve({ directory: event.directory, workspace: event.workspace }) + } + + GlobalBus.on("event", onEvent) + }), +) + +const serveDisposeProbe = () => + HttpRouter.serve( + HttpRouter.add( + "POST", + "/dispose-probe", + Effect.gen(function* () { + const instance = yield* InstanceRef + if (!instance) return HttpServerResponse.empty({ status: 500 }) + yield* markInstanceForDisposal(instance) + return yield* HttpServerResponse.json(true) + }), + ).pipe(Layer.provide(instanceContextTestLayer)), + { middleware: disposeMiddleware, disableListenLog: true, disableLogger: true }, + ).pipe(Layer.build) + +describe("HttpApi instance context middleware", () => { + it.live("provides instance context from the routed directory", () => + Effect.gen(function* () { + const dir = yield* tmpdirScoped({ git: true }) + const project = yield* Project.use.fromDirectory(dir) + yield* serveProbe() + + const response = yield* HttpClient.get(`/probe?directory=${encodeURIComponent(dir)}`) + + expect(response.status).toBe(200) + expect(yield* response.json).toEqual({ + directory: dir, + worktree: dir, + projectID: project.project.id, + }) + }), + ) + + it.live("falls back to the raw directory when URI decoding fails", () => + Effect.gen(function* () { + yield* serveProbe() + + const response = yield* HttpClient.get("/probe?directory=%25E0%25A4%25A") + + expect(response.status).toBe(200) + expect(yield* response.json).toMatchObject({ + directory: path.join(process.cwd(), "%E0%A4%A"), + }) + }), + ) + + it.live("provides selected workspace id on control-plane routes", () => + Effect.gen(function* () { + const dir = yield* tmpdirScoped({ git: true }) + const project = yield* Project.use.fromDirectory(dir) + const workspaceDir = path.join(dir, ".workspace-local") + const workspace = yield* createLocalWorkspace({ + projectID: project.project.id, + type: "instance-context-workspace-ref", + directory: workspaceDir, + }) + yield* serveProbe("/session") + + const response = yield* HttpClientRequest.get(`/session?workspace=${workspace.id}`).pipe( + HttpClientRequest.setHeader("x-kilo-directory", dir), + HttpClient.execute, + ) + + expect(response.status).toBe(200) + expect(yield* response.json).toMatchObject({ + directory: dir, + workspaceID: workspace.id, + }) + }), + ) + + it.live("uses workspace routing output instead of raw directory hints", () => + Effect.gen(function* () { + const dir = yield* tmpdirScoped({ git: true }) + const project = yield* Project.use.fromDirectory(dir) + const workspaceDir = path.join(dir, ".workspace-local") + const workspace = yield* createLocalWorkspace({ + projectID: project.project.id, + type: "instance-context-routing-output", + directory: workspaceDir, + }) + yield* serveProbe() + + const response = yield* HttpClientRequest.get(`/probe?workspace=${workspace.id}`).pipe( + HttpClientRequest.setHeader("x-kilo-directory", dir), + HttpClient.execute, + ) + + expect(response.status).toBe(200) + expect(yield* response.json).toMatchObject({ + directory: workspaceDir, + workspaceID: workspace.id, + }) + }), + ) + + it.live("preserves selected workspace id on instance disposal events", () => + Effect.gen(function* () { + const dir = yield* tmpdirScoped({ git: true }) + const project = yield* Project.use.fromDirectory(dir) + const workspaceDir = path.join(dir, ".workspace-local") + const workspace = yield* createLocalWorkspace({ + projectID: project.project.id, + type: "instance-context-dispose-event", + directory: workspaceDir, + }) + yield* serveDisposeProbe() + const disposed = yield* waitDisposedEvent.pipe(Effect.forkScoped) + + const response = yield* HttpClientRequest.post(`/dispose-probe?workspace=${workspace.id}`).pipe( + HttpClient.execute, + ) + + expect(response.status).toBe(200) + expect(yield* response.json).toBe(true) + expect(yield* Fiber.join(disposed)).toEqual({ directory: workspaceDir, workspace: workspace.id }) + }), + ) +}) diff --git a/packages/opencode/test/server/httpapi-instance.legacy.test.ts b/packages/opencode/test/server/httpapi-instance.legacy.test.ts new file mode 100644 index 0000000000..3db2b993ad --- /dev/null +++ b/packages/opencode/test/server/httpapi-instance.legacy.test.ts @@ -0,0 +1,138 @@ +import { afterEach, describe, expect, test } from "bun:test" +import { Flag } from "@opencode-ai/core/flag/flag" +import { GlobalBus } from "@/bus/global" +import { Instance } from "../../src/project/instance" +import { Server } from "../../src/server/server" +import { InstancePaths } from "../../src/server/routes/instance/httpapi/groups/instance" +import * as Log from "@opencode-ai/core/util/log" +import { resetDatabase } from "../fixture/db" +import { disposeAllInstances, tmpdir } from "../fixture/fixture" + +void Log.init({ print: false }) + +const original = Flag.KILO_EXPERIMENTAL_HTTPAPI + +function app() { + Flag.KILO_EXPERIMENTAL_HTTPAPI = true + return Server.Default().app +} + +async function waitDisposed(directory: string) { + return await new Promise((resolve, reject) => { + const timer = setTimeout(() => { + GlobalBus.off("event", onEvent) + reject(new Error("timed out waiting for instance disposal")) + }, 10_000) + + function onEvent(event: { directory?: string; payload: { type?: string } }) { + if (event.payload.type !== "server.instance.disposed" || event.directory !== directory) return + clearTimeout(timer) + GlobalBus.off("event", onEvent) + resolve() + } + + GlobalBus.on("event", onEvent) + }) +} + +afterEach(async () => { + Flag.KILO_EXPERIMENTAL_HTTPAPI = original + await disposeAllInstances() + await resetDatabase() +}) + +describe("instance HttpApi", () => { + test("serves catalog read endpoints through Hono bridge", async () => { + await using tmp = await tmpdir({ config: { formatter: false, lsp: false } }) + + const [commands, agents, skills, lsp, formatter] = await Promise.all([ + app().request(InstancePaths.command, { headers: { "x-kilo-directory": tmp.path } }), + app().request(InstancePaths.agent, { headers: { "x-kilo-directory": tmp.path } }), + app().request(InstancePaths.skill, { headers: { "x-kilo-directory": tmp.path } }), + app().request(InstancePaths.lsp, { headers: { "x-kilo-directory": tmp.path } }), + app().request(InstancePaths.formatter, { headers: { "x-kilo-directory": tmp.path } }), + ]) + + expect(commands.status).toBe(200) + expect(await commands.json()).toContainEqual(expect.objectContaining({ name: "init", source: "command" })) + + expect(agents.status).toBe(200) + expect(await agents.json()).toContainEqual(expect.objectContaining({ name: "build", mode: "primary" })) + + expect(skills.status).toBe(200) + expect(await skills.json()).toBeArray() + + expect(lsp.status).toBe(200) + expect(await lsp.json()).toEqual([]) + + expect(formatter.status).toBe(200) + expect(await formatter.json()).toEqual([]) + }) + + test("serves project git init through Hono bridge", async () => { + await using tmp = await tmpdir({ config: { formatter: false, lsp: false } }) + const disposed = waitDisposed(tmp.path) + + const response = await app().request("/project/git/init", { + method: "POST", + headers: { "x-kilo-directory": tmp.path }, + }) + + expect(response.status).toBe(200) + expect(await response.json()).toMatchObject({ vcs: "git", worktree: tmp.path }) + await disposed + + const current = await app().request("/project/current", { headers: { "x-kilo-directory": tmp.path } }) + expect(current.status).toBe(200) + expect(await current.json()).toMatchObject({ vcs: "git", worktree: tmp.path }) + }) + + test("serves project update through Hono bridge", async () => { + await using tmp = await tmpdir({ config: { formatter: false, lsp: false } }) + + const current = await app().request("/project/current", { headers: { "x-kilo-directory": tmp.path } }) + expect(current.status).toBe(200) + const project = (await current.json()) as { id: string } + + const response = await app().request(`/project/${project.id}`, { + method: "PATCH", + headers: { "x-kilo-directory": tmp.path, "content-type": "application/json" }, + body: JSON.stringify({ name: "patched-project", commands: { start: "bun dev" } }), + }) + + expect(response.status).toBe(200) + expect(await response.json()).toMatchObject({ + id: project.id, + name: "patched-project", + commands: { start: "bun dev" }, + }) + + const list = await app().request("/project", { headers: { "x-kilo-directory": tmp.path } }) + expect(list.status).toBe(200) + expect(await list.json()).toContainEqual( + expect.objectContaining({ id: project.id, name: "patched-project", commands: { start: "bun dev" } }), + ) + }) + + test("serves instance dispose through Hono bridge", async () => { + await using tmp = await tmpdir() + + const disposed = new Promise((resolve) => { + const onEvent = (event: { directory?: string; payload: { type?: string } }) => { + if (event.payload.type !== "server.instance.disposed") return + GlobalBus.off("event", onEvent) + resolve(event.directory) + } + GlobalBus.on("event", onEvent) + }) + + const response = await app().request(InstancePaths.dispose, { + method: "POST", + headers: { "x-kilo-directory": tmp.path }, + }) + + expect(response.status).toBe(200) + expect(await response.json()).toBe(true) + expect(await disposed).toBe(tmp.path) + }) +}) diff --git a/packages/opencode/test/server/httpapi-instance.test.ts b/packages/opencode/test/server/httpapi-instance.test.ts index cba31d9508..08fba950c0 100644 --- a/packages/opencode/test/server/httpapi-instance.test.ts +++ b/packages/opencode/test/server/httpapi-instance.test.ts @@ -1,48 +1,121 @@ +import { NodeHttpServer, NodeServices } from "@effect/platform-node" +import { Flag } from "@opencode-ai/core/flag/flag" import { afterEach, describe, expect, test } from "bun:test" import path from "path" -import { Flag } from "@opencode-ai/core/flag/flag" +import { Config, Effect, FileSystem, Layer, Path } from "effect" +import { HttpClient, HttpClientRequest, HttpRouter, HttpServer } from "effect/unstable/http" +import * as Socket from "effect/unstable/socket/Socket" +// kilocode_change start - Hono-bridge tests still cover routes that haven't migrated to the Effect HttpApi import { GlobalBus } from "@/bus/global" -import { Instance } from "../../src/project/instance" import { Server } from "../../src/server/server" -import { InstancePaths } from "../../src/server/routes/instance/httpapi/groups/instance" -import * as Log from "@opencode-ai/core/util/log" -import { resetDatabase } from "../fixture/db" import { tmpdir } from "../fixture/fixture" +// kilocode_change end +import { InstancePaths } from "../../src/server/routes/instance/httpapi/groups/instance" +import { ExperimentalHttpApiServer } from "../../src/server/routes/instance/httpapi/server" +import { resetDatabase } from "../fixture/db" +import { disposeAllInstances, tmpdirScoped } from "../fixture/fixture" +import { testEffect } from "../lib/effect" -void Log.init({ print: false }) +// Flip the experimental HttpApi flag so backend selection telemetry on the +// production routes reports the right backend, and reset the database around +// the test so per-instance state does not leak between runs. resetDatabase() +// already calls disposeAllInstances(), so we don't repeat it. +const testStateLayer = Layer.effectDiscard( + Effect.gen(function* () { + const originalHttpApi = Flag.KILO_EXPERIMENTAL_HTTPAPI + Flag.KILO_EXPERIMENTAL_HTTPAPI = true + yield* Effect.promise(() => resetDatabase()) + yield* Effect.addFinalizer(() => + Effect.promise(async () => { + Flag.KILO_EXPERIMENTAL_HTTPAPI = originalHttpApi + await resetDatabase() + }), + ) + }), +) -const original = Flag.KILO_EXPERIMENTAL_HTTPAPI +// Mount the production HttpApi route tree on a real Node HTTP server bound to +// 127.0.0.1:0 and a fetch-based HttpClient that prepends the server URL. This +// keeps the test wired through the same route layer production uses, without +// going through Server.Default()/Hono. +const servedRoutes: Layer.Layer = HttpRouter.serve( + ExperimentalHttpApiServer.routes, + { disableListenLog: true, disableLogger: true }, +) -function app() { - Flag.KILO_EXPERIMENTAL_HTTPAPI = true - return Server.Default().app -} +const httpApiServerLayer = servedRoutes.pipe( + Layer.provide(Socket.layerWebSocketConstructorGlobal), + Layer.provideMerge(NodeHttpServer.layerTest), + Layer.provideMerge(NodeServices.layer), +) -async function waitDisposed(directory: string) { - return await new Promise((resolve, reject) => { - const timer = setTimeout(() => { - GlobalBus.off("event", onEvent) - reject(new Error("timed out waiting for instance disposal")) - }, 10_000) +const it = testEffect(Layer.mergeAll(testStateLayer, httpApiServerLayer)) - function onEvent(event: { directory?: string; payload: { type?: string } }) { - if (event.payload.type !== "server.instance.disposed" || event.directory !== directory) return - clearTimeout(timer) - GlobalBus.off("event", onEvent) - resolve() - } - - GlobalBus.on("event", onEvent) - }) -} - -afterEach(async () => { - Flag.KILO_EXPERIMENTAL_HTTPAPI = original - await Instance.disposeAll() - await resetDatabase() -}) +const directoryHeader = (dir: string) => HttpClientRequest.setHeader("x-kilo-directory", dir) describe("instance HttpApi", () => { + it.live("serves path and VCS read endpoints", () => + Effect.gen(function* () { + const dir = yield* tmpdirScoped({ git: true }) + const fs = yield* FileSystem.FileSystem + const path = yield* Path.Path + yield* fs.writeFileString(path.join(dir, "changed.txt"), "hello") + + const [paths, vcs, diff] = yield* Effect.all( + [ + HttpClientRequest.get(InstancePaths.path).pipe(directoryHeader(dir), HttpClient.execute), + HttpClientRequest.get(InstancePaths.vcs).pipe(directoryHeader(dir), HttpClient.execute), + HttpClientRequest.get(InstancePaths.vcsDiff).pipe( + HttpClientRequest.setUrlParam("mode", "git"), + directoryHeader(dir), + HttpClient.execute, + ), + ], + { concurrency: "unbounded" }, + ) + + expect(paths.status).toBe(200) + expect(yield* paths.json).toMatchObject({ directory: dir, worktree: dir }) + + expect(vcs.status).toBe(200) + expect(yield* vcs.json).toMatchObject({ branch: expect.any(String) }) + + expect(diff.status).toBe(200) + expect(yield* diff.json).toContainEqual( + expect.objectContaining({ file: "changed.txt", additions: 1, status: "added" }), + ) + }), + ) + + // kilocode_change start - Hono-bridge tests cover routes still served via Server.Default() + function app() { + Flag.KILO_EXPERIMENTAL_HTTPAPI = true + return Server.Default().app + } + + async function waitDisposed(directory: string) { + return await new Promise((resolve, reject) => { + const timer = setTimeout(() => { + GlobalBus.off("event", onEvent) + reject(new Error("timed out waiting for instance disposal")) + }, 10_000) + + function onEvent(event: { directory?: string; payload: { type?: string } }) { + if (event.payload.type !== "server.instance.disposed" || event.directory !== directory) return + clearTimeout(timer) + GlobalBus.off("event", onEvent) + resolve() + } + + GlobalBus.on("event", onEvent) + }) + } + + afterEach(async () => { + await disposeAllInstances() + await resetDatabase() + }) + test("serves path and VCS read endpoints through Hono bridge", async () => { await using tmp = await tmpdir({ git: true }) await Bun.write(path.join(tmp.path, "changed.txt"), "hello") @@ -68,7 +141,7 @@ describe("instance HttpApi", () => { ) }) - // kilocode_change - skip until Kilo's Instance context threads through the Effect HttpApi bridge. + // skip until Kilo's Instance context threads through the Effect HttpApi bridge. // The /agent handler 500s via the bridge (agent.list's InstanceState lookup drops context mid-request). // Bridge is gated behind KILO_EXPERIMENTAL_HTTPAPI, not enabled in any production client. test.skip("serves catalog read endpoints through Hono bridge", async () => { @@ -164,4 +237,5 @@ describe("instance HttpApi", () => { expect(await response.json()).toBe(true) expect(await disposed).toBe(tmp.path) }) + // kilocode_change end }) diff --git a/packages/opencode/test/server/httpapi-json-parity.test.ts b/packages/opencode/test/server/httpapi-json-parity.test.ts index afa1e29dbb..a11c7de8c8 100644 --- a/packages/opencode/test/server/httpapi-json-parity.test.ts +++ b/packages/opencode/test/server/httpapi-json-parity.test.ts @@ -5,12 +5,17 @@ import { ModelID, ProviderID } from "../../src/provider/schema" import { Instance } from "../../src/project/instance" import { Server } from "../../src/server/server" import { ExperimentalPaths } from "../../src/server/routes/instance/httpapi/groups/experimental" +import { FilePaths } from "../../src/server/routes/instance/httpapi/groups/file" +import { GlobalPaths } from "../../src/server/routes/instance/httpapi/groups/global" +import { InstancePaths } from "../../src/server/routes/instance/httpapi/groups/instance" +import { McpPaths } from "../../src/server/routes/instance/httpapi/groups/mcp" +import { PtyPaths } from "../../src/server/routes/instance/httpapi/groups/pty" import { SessionPaths } from "../../src/server/routes/instance/httpapi/groups/session" import { MessageID, PartID } from "../../src/session/schema" import { Session } from "@/session/session" import * as Log from "@opencode-ai/core/util/log" import { resetDatabase } from "../fixture/db" -import { provideInstance, tmpdir } from "../fixture/fixture" +import { disposeAllInstances, provideInstance, tmpdir } from "../fixture/fixture" import { it } from "../lib/effect" void Log.init({ print: false }) @@ -84,11 +89,94 @@ function expectJsonParity(input: { afterEach(async () => { Flag.KILO_EXPERIMENTAL_HTTPAPI = original - await Instance.disposeAll() + await disposeAllInstances() await resetDatabase() }) describe("HttpApi JSON parity", () => { + it.live( + "matches legacy JSON shape for safe GET endpoints", + withTmp( + { + git: true, + config: { + formatter: false, + lsp: false, + mcp: { + demo: { + type: "local", + command: ["echo", "demo"], + enabled: false, + }, + }, + }, + }, + (tmp) => + Effect.gen(function* () { + yield* Effect.promise(() => Bun.write(`${tmp.path}/hello.txt`, "hello\n")) + + const headers = { "x-kilo-directory": tmp.path } + const legacy = app(false) + const httpapi = app(true) + + yield* Effect.forEach( + [ + { label: "global.health", path: GlobalPaths.health, headers: {} }, + { label: "global.config", path: GlobalPaths.config, headers: {} }, + { label: "instance.path", path: InstancePaths.path, headers }, + { label: "instance.vcs", path: InstancePaths.vcs, headers }, + { label: "instance.vcsDiff", path: `${InstancePaths.vcsDiff}?mode=git`, headers }, + { label: "instance.command", path: InstancePaths.command, headers }, + { label: "instance.agent", path: InstancePaths.agent, headers }, + { label: "instance.skill", path: InstancePaths.skill, headers }, + { label: "instance.lsp", path: InstancePaths.lsp, headers }, + { label: "instance.formatter", path: InstancePaths.formatter, headers }, + { label: "config.get", path: "/config", headers }, + { label: "config.providers", path: "/config/providers", headers }, + { label: "project.list", path: "/project", headers }, + { label: "project.current", path: "/project/current", headers }, + { label: "provider.list", path: "/provider", headers }, + { label: "provider.auth", path: "/provider/auth", headers }, + { label: "permission.list", path: "/permission", headers }, + { label: "question.list", path: "/question", headers }, + { label: "mcp.status", path: McpPaths.status, headers }, + { label: "pty.shells", path: PtyPaths.shells, headers }, + { label: "pty.list", path: PtyPaths.list, headers }, + { label: "file.list", path: `${FilePaths.list}?${new URLSearchParams({ path: "." })}`, headers }, + { + label: "file.content", + path: `${FilePaths.content}?${new URLSearchParams({ path: "hello.txt" })}`, + headers, + }, + { label: "file.status", path: FilePaths.status, headers }, + { + label: "find.file", + path: `${FilePaths.findFile}?${new URLSearchParams({ query: "hello", dirs: "false" })}`, + headers, + }, + { + label: "find.text", + path: `${FilePaths.findText}?${new URLSearchParams({ pattern: "hello" })}`, + headers, + }, + { + label: "find.symbol", + path: `${FilePaths.findSymbol}?${new URLSearchParams({ query: "hello" })}`, + headers, + }, + { label: "experimental.console", path: ExperimentalPaths.console, headers }, + { label: "experimental.consoleOrgs", path: ExperimentalPaths.consoleOrgs, headers }, + { label: "experimental.toolIDs", path: ExperimentalPaths.toolIDs, headers }, + { label: "experimental.worktree", path: ExperimentalPaths.worktree, headers }, + { label: "experimental.resource", path: ExperimentalPaths.resource, headers }, + ], + (input) => expectJsonParity({ ...input, legacy, httpapi }), + { concurrency: 1 }, + ) + }), + ), + ) + it.live( "matches legacy JSON shape for session read endpoints", withTmp({ git: true, config: { formatter: false, lsp: false } }, (tmp) => diff --git a/packages/opencode/test/server/httpapi-mcp-oauth.test.ts b/packages/opencode/test/server/httpapi-mcp-oauth.test.ts new file mode 100644 index 0000000000..829f899605 --- /dev/null +++ b/packages/opencode/test/server/httpapi-mcp-oauth.test.ts @@ -0,0 +1,76 @@ +import { NodeHttpServer } from "@effect/platform-node" +import { Session } from "@/session/session" +import { describe, expect } from "bun:test" +import { Effect, Layer } from "effect" +import { HttpClient, HttpClientRequest, HttpRouter } from "effect/unstable/http" +import { HttpApi, HttpApiBuilder } from "effect/unstable/httpapi" +import { McpApi, McpPaths } from "../../src/server/routes/instance/httpapi/groups/mcp" +import { Authorization } from "../../src/server/routes/instance/httpapi/middleware/authorization" +import { InstanceContextMiddleware } from "../../src/server/routes/instance/httpapi/middleware/instance-context" +import { + WorkspaceRouteContext, + WorkspaceRoutingMiddleware, +} from "../../src/server/routes/instance/httpapi/middleware/workspace-routing" +import { testEffect } from "../lib/effect" + +const TestHttpApi = HttpApi.make("opencode-instance").addHttpApi(McpApi) +const fakeSession = Layer.mock(Session.Service)({}) +const testMcpHandlers = HttpApiBuilder.group(TestHttpApi, "mcp", (handlers) => + Effect.succeed( + handlers + .handle("status", () => Effect.die("unexpected MCP status")) + .handle("add", () => Effect.die("unexpected MCP add")) + .handle("authStart", () => + Effect.succeed({ authorizationUrl: "https://auth.example/start", oauthState: "state-123" }), + ) + .handle("authCallback", () => Effect.die("unexpected MCP authCallback")) + .handle("authAuthenticate", () => Effect.die("unexpected MCP authAuthenticate")) + .handle("authRemove", () => Effect.die("unexpected MCP authRemove")) + .handle("connect", () => Effect.die("unexpected MCP connect")) + .handle("disconnect", () => Effect.die("unexpected MCP disconnect")), + ), +) + +const passthroughAuthorization = Layer.succeed( + Authorization, + Authorization.of({ + basic: (effect) => effect, + authToken: (effect) => effect, + }), +) + +const passthroughInstanceContext = Layer.succeed( + InstanceContextMiddleware, + InstanceContextMiddleware.of((effect) => effect), +) + +const testWorkspaceRouting = Layer.succeed( + WorkspaceRoutingMiddleware, + WorkspaceRoutingMiddleware.of((effect) => + effect.pipe(Effect.provideService(WorkspaceRouteContext, WorkspaceRouteContext.of({ directory: process.cwd() }))), + ), +) + +const it = testEffect( + HttpRouter.serve( + HttpApiBuilder.layer(TestHttpApi).pipe( + Layer.provide(testMcpHandlers), + Layer.provide([passthroughAuthorization, passthroughInstanceContext, testWorkspaceRouting, fakeSession]), + ), + { disableListenLog: true, disableLogger: true }, + ).pipe(Layer.provideMerge(NodeHttpServer.layerTest)), +) + +describe("mcp HttpApi OAuth", () => { + it.live("preserves oauth state when starting OAuth", () => + Effect.gen(function* () { + const response = yield* HttpClientRequest.post(McpPaths.auth.replace(":name", "demo")).pipe(HttpClient.execute) + + expect(response.status).toBe(200) + expect(yield* response.json).toEqual({ + authorizationUrl: "https://auth.example/start", + oauthState: "state-123", + }) + }), + ) +}) diff --git a/packages/opencode/test/server/httpapi-mcp.test.ts b/packages/opencode/test/server/httpapi-mcp.test.ts index 012777a3a2..a0fa72b10e 100644 --- a/packages/opencode/test/server/httpapi-mcp.test.ts +++ b/packages/opencode/test/server/httpapi-mcp.test.ts @@ -5,10 +5,11 @@ import { Flag } from "@opencode-ai/core/flag/flag" import { ExperimentalHttpApiServer } from "../../src/server/routes/instance/httpapi/server" import { McpPaths } from "../../src/server/routes/instance/httpapi/groups/mcp" import { Instance } from "../../src/project/instance" +import { InstanceStore } from "../../src/project/instance-store" import { Server } from "../../src/server/server" import * as Log from "@opencode-ai/core/util/log" import { resetDatabase } from "../fixture/db" -import { provideInstance, tmpdir } from "../fixture/fixture" +import { disposeAllInstances, provideInstance, tmpdir } from "../fixture/fixture" import { testEffect } from "../lib/effect" void Log.init({ print: false }) @@ -57,7 +58,9 @@ function withMcpProject(self: (dir: string) => Effect.Effect) }), ) yield* Effect.addFinalizer(() => - Effect.promise(() => Instance.provide({ directory: dir, fn: () => Instance.dispose() })).pipe(Effect.ignore), + Effect.promise(() => + Instance.provide({ directory: dir, fn: () => InstanceStore.disposeInstance(Instance.current) }), + ).pipe(Effect.ignore), ) return yield* self(dir).pipe(provideInstance(dir)) @@ -76,7 +79,7 @@ const readResponse = Effect.fnUntraced(function* (input: { app: TestApp; path: s afterEach(async () => { Flag.KILO_EXPERIMENTAL_HTTPAPI = original - await Instance.disposeAll() + await disposeAllInstances() await resetDatabase() }) diff --git a/packages/opencode/test/server/httpapi-provider.test.ts b/packages/opencode/test/server/httpapi-provider.test.ts index 538189991f..84d699d43b 100644 --- a/packages/opencode/test/server/httpapi-provider.test.ts +++ b/packages/opencode/test/server/httpapi-provider.test.ts @@ -3,10 +3,11 @@ import { Effect, FileSystem, Layer, Path } from "effect" import { NodeFileSystem, NodePath } from "@effect/platform-node" import { Flag } from "@opencode-ai/core/flag/flag" import { Instance } from "../../src/project/instance" +import { InstanceStore } from "../../src/project/instance-store" import { Server } from "../../src/server/server" import * as Log from "@opencode-ai/core/util/log" import { resetDatabase } from "../fixture/db" -import { provideInstance } from "../fixture/fixture" +import { disposeAllInstances, provideInstance } from "../fixture/fixture" import { testEffect } from "../lib/effect" void Log.init({ print: false }) @@ -89,7 +90,9 @@ function withProviderProject(self: (dir: string) => Effect.Effect - Effect.promise(() => Instance.provide({ directory: dir, fn: () => Instance.dispose() })).pipe(Effect.ignore), + Effect.promise(() => + Instance.provide({ directory: dir, fn: () => InstanceStore.disposeInstance(Instance.current) }), + ).pipe(Effect.ignore), ) return yield* self(dir).pipe(provideInstance(dir)) @@ -98,7 +101,7 @@ function withProviderProject(self: (dir: string) => Effect.Effect { Flag.KILO_EXPERIMENTAL_HTTPAPI = original - await Instance.disposeAll() + await disposeAllInstances() await resetDatabase() }) diff --git a/packages/opencode/test/server/httpapi-pty-websocket.test.ts b/packages/opencode/test/server/httpapi-pty-websocket.test.ts new file mode 100644 index 0000000000..81ee952d96 --- /dev/null +++ b/packages/opencode/test/server/httpapi-pty-websocket.test.ts @@ -0,0 +1,16 @@ +import { describe, expect, test } from "bun:test" +import { Effect } from "effect" +import { handlePtyInput } from "../../src/pty/input" + +describe("pty HttpApi websocket input", () => { + test("does not forward invalid binary frames to the PTY handler", async () => { + const messages: Array = [] + const handler = { onMessage: (message: string | ArrayBuffer) => messages.push(message) } + + await Effect.runPromise(handlePtyInput(handler, "ready")) + await Effect.runPromise(handlePtyInput(handler, new Uint8Array([0xff, 0xfe, 0xfd]))) + await Effect.runPromise(handlePtyInput(handler, new TextEncoder().encode("hello"))) + + expect(messages).toEqual(["ready", "hello"]) + }) +}) diff --git a/packages/opencode/test/server/httpapi-pty.test.ts b/packages/opencode/test/server/httpapi-pty.test.ts index 30d6950111..b8deb9a7db 100644 --- a/packages/opencode/test/server/httpapi-pty.test.ts +++ b/packages/opencode/test/server/httpapi-pty.test.ts @@ -1,4 +1,5 @@ import { afterEach, describe, expect, test } from "bun:test" +import { NodeHttpServer, NodeServices } from "@effect/platform-node" import { Flag } from "@opencode-ai/core/flag/flag" import { PtyID } from "../../src/pty/schema" import { Instance } from "../../src/project/instance" @@ -6,21 +7,63 @@ import { Server } from "../../src/server/server" import { PtyPaths } from "../../src/server/routes/instance/httpapi/groups/pty" import * as Log from "@opencode-ai/core/util/log" import { resetDatabase } from "../fixture/db" -import { tmpdir } from "../fixture/fixture" +import { disposeAllInstances, tmpdir, tmpdirScoped } from "../fixture/fixture" +import { Config, Effect, Layer, Queue, Schema } from "effect" +import { HttpClient, HttpClientRequest, HttpRouter, HttpServer } from "effect/unstable/http" +import * as Socket from "effect/unstable/socket/Socket" +import { ExperimentalHttpApiServer } from "../../src/server/routes/instance/httpapi/server" +import { Pty } from "../../src/pty" +import { testEffect } from "../lib/effect" void Log.init({ print: false }) const original = Flag.KILO_EXPERIMENTAL_HTTPAPI const testPty = process.platform === "win32" ? test.skip : test +const testStateLayer = Layer.effectDiscard( + Effect.gen(function* () { + Flag.KILO_EXPERIMENTAL_HTTPAPI = true + yield* Effect.promise(() => resetDatabase()) + yield* Effect.addFinalizer(() => + Effect.promise(async () => { + Flag.KILO_EXPERIMENTAL_HTTPAPI = original + await resetDatabase() + }), + ) + }), +) + +const servedRoutes: Layer.Layer = HttpRouter.serve( + ExperimentalHttpApiServer.routes, + { disableListenLog: true, disableLogger: true }, +) + +const effectIt = testEffect( + Layer.mergeAll( + testStateLayer, + Socket.layerWebSocketConstructorGlobal, + servedRoutes.pipe( + Layer.provide(Socket.layerWebSocketConstructorGlobal), + Layer.provideMerge(NodeHttpServer.layerTest), + Layer.provideMerge(NodeServices.layer), + ), + ), +) + function app() { Flag.KILO_EXPERIMENTAL_HTTPAPI = true return Server.Default().app } +function serverUrl() { + return HttpServer.HttpServer.use((server) => Effect.succeed(HttpServer.formatAddress(server.address))) +} + +const directoryHeader = (dir: string) => HttpClientRequest.setHeader("x-kilo-directory", dir) + afterEach(async () => { Flag.KILO_EXPERIMENTAL_HTTPAPI = original - await Instance.disposeAll() + await disposeAllInstances() await resetDatabase() }) @@ -85,4 +128,48 @@ describe("pty HttpApi bridge", () => { }) expect(response.status).toBe(404) }) + ;(process.platform === "win32" ? effectIt.live.skip : effectIt.live)( + "serves PTY websocket output and input through Effect routes", + () => + Effect.gen(function* () { + const dir = yield* tmpdirScoped({ git: true, config: { formatter: false, lsp: false } }) + const created = yield* HttpClientRequest.post(PtyPaths.create).pipe( + directoryHeader(dir), + HttpClientRequest.bodyJson({ command: "/bin/cat", title: "websocket" }), + Effect.flatMap(HttpClient.execute), + ) + expect(created.status).toBe(200) + const info = yield* Schema.decodeUnknownEffect(Pty.Info)(yield* created.json) + + const socket = yield* Socket.makeWebSocket( + `${(yield* serverUrl()).replace(/^http/, "ws")}${PtyPaths.connect.replace(":ptyID", info.id)}?cursor=-1&directory=${encodeURIComponent(dir)}`, + { closeCodeIsError: () => false }, + ) + const messages = yield* Queue.unbounded() + yield* socket + .runRaw((message) => + Queue.offer(messages, typeof message === "string" ? message : new TextDecoder().decode(message)), + ) + .pipe(Effect.catch(() => Effect.void)) + .pipe(Effect.forkScoped) + const write = yield* socket.writer + + const takeUntil = (expected: string, seen = ""): Effect.Effect => + Effect.gen(function* () { + const next = seen + (yield* Queue.take(messages).pipe(Effect.timeout("5 seconds"))) + if (next.includes(expected)) return next + return yield* takeUntil(expected, next) + }) + + yield* write("ping-route\n") + expect(yield* takeUntil("ping-route")).toContain("ping-route") + yield* write(new Socket.CloseEvent(1000, "done")).pipe(Effect.catch(() => Effect.void)) + + const removed = yield* HttpClientRequest.delete(PtyPaths.remove.replace(":ptyID", info.id)).pipe( + directoryHeader(dir), + HttpClient.execute, + ) + expect(removed.status).toBe(200) + }), + ) }) diff --git a/packages/opencode/test/server/httpapi-raw-route-auth.test.ts b/packages/opencode/test/server/httpapi-raw-route-auth.test.ts new file mode 100644 index 0000000000..eec03a7ea6 --- /dev/null +++ b/packages/opencode/test/server/httpapi-raw-route-auth.test.ts @@ -0,0 +1,89 @@ +import { afterEach, describe, expect, test } from "bun:test" +import { ConfigProvider, Layer } from "effect" +import { HttpRouter } from "effect/unstable/http" +import { Flag } from "@opencode-ai/core/flag/flag" +import { Instance } from "../../src/project/instance" +import { EventPaths } from "../../src/server/routes/instance/httpapi/event" +import { PtyPaths } from "../../src/server/routes/instance/httpapi/groups/pty" +import { ExperimentalHttpApiServer } from "../../src/server/routes/instance/httpapi/server" +import { PtyID } from "../../src/pty/schema" +import { resetDatabase } from "../fixture/db" +import { disposeAllInstances, tmpdir } from "../fixture/fixture" +import * as Log from "@opencode-ai/core/util/log" + +void Log.init({ print: false }) + +const originalHttpApi = Flag.KILO_EXPERIMENTAL_HTTPAPI + +function app(input: { password?: string; username?: string }) { + Flag.KILO_EXPERIMENTAL_HTTPAPI = true + const handler = HttpRouter.toWebHandler( + ExperimentalHttpApiServer.routes.pipe( + Layer.provide( + ConfigProvider.layer( + ConfigProvider.fromUnknown({ + KILO_SERVER_PASSWORD: input.password, + KILO_SERVER_USERNAME: input.username, + }), + ), + ), + ), + { disableLogger: true }, + ).handler + + return { + fetch: (request: Request) => handler(request, ExperimentalHttpApiServer.context), + request(input: string | URL | Request, init?: RequestInit) { + return this.fetch(input instanceof Request ? input : new Request(new URL(input, "http://localhost"), init)) + }, + } +} + +function basic(username: string, password: string) { + return `Basic ${Buffer.from(`${username}:${password}`).toString("base64")}` +} + +async function cancelBody(response: Response) { + await response.body?.cancel().catch(() => {}) +} + +afterEach(async () => { + Flag.KILO_EXPERIMENTAL_HTTPAPI = originalHttpApi + await disposeAllInstances() + await resetDatabase() +}) + +describe("HttpApi raw route authorization", () => { + test("requires configured auth before opening the raw instance event stream", async () => { + await using tmp = await tmpdir({ git: true, config: { formatter: false, lsp: false } }) + const server = app({ password: "secret" }) + const headers = { "x-kilo-directory": tmp.path } + + const missing = await server.request(EventPaths.event, { headers }) + await cancelBody(missing) + expect(missing.status).toBe(401) + + const authed = await server.request(EventPaths.event, { + headers: { ...headers, authorization: basic("opencode", "secret") }, + }) + await cancelBody(authed) + expect(authed.status).toBe(200) + }) + + test("requires configured auth before resolving the raw PTY websocket route", async () => { + await using tmp = await tmpdir({ git: true, config: { formatter: false, lsp: false } }) + const server = app({ password: "secret" }) + const route = PtyPaths.connect.replace(":ptyID", PtyID.ascending()) + const headers = { "x-kilo-directory": tmp.path } + + const missing = await server.request(route, { headers }) + await cancelBody(missing) + expect(missing.status).toBe(401) + + const authed = await server.request(route, { + headers: { ...headers, authorization: basic("opencode", "secret") }, + }) + await cancelBody(authed) + expect(authed.status).toBe(404) + }) +}) diff --git a/packages/opencode/test/server/httpapi-sdk.test.ts b/packages/opencode/test/server/httpapi-sdk.test.ts index 08f9520e17..ac409d09d5 100644 --- a/packages/opencode/test/server/httpapi-sdk.test.ts +++ b/packages/opencode/test/server/httpapi-sdk.test.ts @@ -1,9 +1,11 @@ import { afterEach, describe, expect } from "bun:test" -import { Effect } from "effect" +import { ConfigProvider, Effect, Layer } from "effect" import type * as Scope from "effect/Scope" +import { HttpRouter } from "effect/unstable/http" import { Flag } from "@opencode-ai/core/flag/flag" import { createKiloClient } from "@kilocode/sdk/v2" import { Instance } from "../../src/project/instance" +import { ExperimentalHttpApiServer } from "../../src/server/routes/instance/httpapi/server" import { Server } from "../../src/server/server" import { MessageID, PartID, SessionID } from "../../src/session/schema" import { MessageV2 } from "../../src/session/message-v2" @@ -13,7 +15,7 @@ import { Session as SessionNs } from "@/session/session" import { TestLLMServer } from "../lib/llm-server" import path from "path" import { resetDatabase } from "../fixture/db" -import { tmpdir } from "../fixture/fixture" +import { disposeAllInstances, tmpdir } from "../fixture/fixture" import { it } from "../lib/effect" const original = { @@ -33,7 +35,27 @@ function app(backend: Backend, input?: { password?: string; username?: string }) Flag.KILO_EXPERIMENTAL_HTTPAPI = backend === "httpapi" Flag.KILO_SERVER_PASSWORD = input?.password Flag.KILO_SERVER_USERNAME = input?.username - return backend === "httpapi" ? Server.Default().app : Server.Legacy().app + if (backend === "legacy") return Server.Legacy().app + + const handler = HttpRouter.toWebHandler( + ExperimentalHttpApiServer.routes.pipe( + Layer.provide( + ConfigProvider.layer( + ConfigProvider.fromUnknown({ + KILO_SERVER_PASSWORD: input?.password, + KILO_SERVER_USERNAME: input?.username, + }), + ), + ), + ), + { disableLogger: true }, + ).handler + return { + fetch: (request: Request) => handler(request, ExperimentalHttpApiServer.context), + request(input: string | URL | Request, init?: RequestInit) { + return this.fetch(input instanceof Request ? input : new Request(new URL(input, "http://localhost"), init)) + }, + } } function client( @@ -123,7 +145,7 @@ function firstEvent(open: () => Promise<{ stream: AsyncIterator }>) { } function record(value: unknown) { - return value && typeof value === "object" && !Array.isArray(value) ? (value as Record) : {} + return value && typeof value === "object" && !Array.isArray(value) ? Object.fromEntries(Object.entries(value)) : {} } function array(value: unknown) { @@ -147,7 +169,7 @@ function sessionTitles(value: unknown) { function resetState() { return Effect.promise(async () => { - await Instance.disposeAll() + await disposeAllInstances() await resetDatabase() }) } @@ -253,7 +275,7 @@ afterEach(async () => { Flag.KILO_EXPERIMENTAL_HTTPAPI = original.KILO_EXPERIMENTAL_HTTPAPI Flag.KILO_SERVER_PASSWORD = original.KILO_SERVER_PASSWORD Flag.KILO_SERVER_USERNAME = original.KILO_SERVER_USERNAME - await Instance.disposeAll() + await disposeAllInstances() await resetDatabase() }) @@ -402,7 +424,7 @@ describe("HttpApi SDK", () => { lsp, }), project: { worktreeSelected: record(project.data).worktree === directory }, - paths: { cwdSelected: record(paths.data).cwd === directory }, + paths: { directorySelected: record(paths.data).directory === directory }, file: record(file.data).content, hasProject: array(projects.data).length > 0, foundFile: JSON.stringify(findFiles.data).includes("hello.txt"), diff --git a/packages/opencode/test/server/httpapi-session.test.ts b/packages/opencode/test/server/httpapi-session.test.ts index 33a57fbd15..b91e568666 100644 --- a/packages/opencode/test/server/httpapi-session.test.ts +++ b/packages/opencode/test/server/httpapi-session.test.ts @@ -1,26 +1,36 @@ import { afterEach, describe, expect } from "bun:test" +import { mkdir } from "node:fs/promises" +import path from "node:path" import { Effect } from "effect" import { Flag } from "@opencode-ai/core/flag/flag" +import { registerAdapter } from "../../src/control-plane/adapters" +import type { WorkspaceAdapter } from "../../src/control-plane/types" +import { Workspace } from "../../src/control-plane/workspace" import { PermissionID } from "../../src/permission/schema" import { ModelID, ProviderID } from "../../src/provider/schema" import { Instance } from "../../src/project/instance" +import { Project } from "../../src/project/project" import { Server } from "../../src/server/server" import { SessionPaths } from "../../src/server/routes/instance/httpapi/groups/session" import { Session } from "@/session/session" import { MessageID, PartID, type SessionID } from "../../src/session/schema" import { MessageV2 } from "../../src/session/message-v2" +import { Database } from "@/storage/db" +import { SessionTable } from "@/session/session.sql" import * as Log from "@opencode-ai/core/util/log" +import { eq } from "drizzle-orm" import { resetDatabase } from "../fixture/db" -import { tmpdir } from "../fixture/fixture" +import { disposeAllInstances, tmpdir } from "../fixture/fixture" import { it } from "../lib/effect" void Log.init({ print: false }) const original = Flag.KILO_EXPERIMENTAL_HTTPAPI +const originalWorkspaces = Flag.KILO_EXPERIMENTAL_WORKSPACES -function app() { - Flag.KILO_EXPERIMENTAL_HTTPAPI = true - return Server.Default().app +function app(experimental = true) { + Flag.KILO_EXPERIMENTAL_HTTPAPI = experimental + return experimental ? Server.Default().app : Server.Legacy().app } function runSession(fx: Effect.Effect) { @@ -72,10 +82,38 @@ function createTextMessage(directory: string, sessionID: SessionID, text: string ) } +const localAdapter = (directory: string): WorkspaceAdapter => ({ + name: "Local Test", + description: "Create a local test workspace", + configure: (info) => ({ ...info, name: "local-test", directory }), + create: async () => { + await mkdir(directory, { recursive: true }) + }, + async remove() {}, + target: () => ({ type: "local" as const, directory }), +}) + +const createLocalWorkspace = (input: { projectID: Project.Info["id"]; type: string; directory: string }) => + Effect.gen(function* () { + registerAdapter(input.projectID, input.type, localAdapter(input.directory)) + return yield* Workspace.Service.use((svc) => + svc.create({ + type: input.type, + branch: null, + extra: null, + projectID: input.projectID, + }), + ).pipe(Effect.provide(Workspace.defaultLayer)) + }) + function request(path: string, init?: RequestInit) { return Effect.promise(async () => app().request(path, init)) } +function requestWithBackend(experimental: boolean, path: string, init?: RequestInit) { + return Effect.promise(async () => app(experimental).request(path, init)) +} + function json(response: Response) { return Effect.promise(async () => { if (response.status !== 200) throw new Error(await response.text()) @@ -99,7 +137,8 @@ function withTmp( afterEach(async () => { Flag.KILO_EXPERIMENTAL_HTTPAPI = original - await Instance.disposeAll() + Flag.KILO_EXPERIMENTAL_WORKSPACES = originalWorkspaces + await disposeAllInstances() await resetDatabase() }) @@ -217,6 +256,129 @@ describe("session HttpApi", () => { ), ) + it.live( + "persists selected workspace id when creating a session", + withTmp({ git: true, config: { formatter: false, lsp: false, share: "disabled" } }, (tmp) => + Effect.gen(function* () { + Flag.KILO_EXPERIMENTAL_WORKSPACES = true + const project = yield* Project.use.fromDirectory(tmp.path).pipe(Effect.provide(Project.defaultLayer)) + const workspace = yield* createLocalWorkspace({ + projectID: project.project.id, + type: "session-create-workspace", + directory: path.join(tmp.path, ".workspace-local"), + }) + + const created = yield* requestJson(`${SessionPaths.create}?workspace=${workspace.id}`, { + method: "POST", + headers: { "x-kilo-directory": tmp.path, "content-type": "application/json" }, + body: JSON.stringify({ title: "workspace session" }), + }) + + expect(created).toMatchObject({ id: created.id, workspaceID: workspace.id }) + expect( + yield* Effect.sync(() => + Database.use((db) => + db + .select({ workspaceID: SessionTable.workspace_id }) + .from(SessionTable) + .where(eq(SessionTable.id, created.id)) + .get(), + ), + ), + ).toEqual({ workspaceID: workspace.id }) + }), + ), + ) + + it.live( + "matches legacy archived timestamp validation", + withTmp({ git: true, config: { formatter: false, lsp: false } }, (tmp) => + Effect.gen(function* () { + const headers = { "x-kilo-directory": tmp.path, "content-type": "application/json" } + const legacy = yield* createSession(tmp.path, { title: "legacy" }) + const effect = yield* createSession(tmp.path, { title: "effect" }) + const body = JSON.stringify({ time: { archived: -1 } }) + + const legacyResponse = yield* requestWithBackend( + false, + pathFor(SessionPaths.update, { sessionID: legacy.id }), + { + method: "PATCH", + headers, + body, + }, + ) + expect(legacyResponse.status).toBe(200) + expect((yield* json(legacyResponse)).time.archived).toBe(-1) + + const effectResponse = yield* requestWithBackend(true, pathFor(SessionPaths.update, { sessionID: effect.id }), { + method: "PATCH", + headers, + body, + }) + expect(effectResponse.status).toBe(legacyResponse.status) + expect((yield* json(effectResponse)).time.archived).toBe(-1) + }), + ), + ) + + it.live( + "matches legacy project-scoped path and directory precedence", + withTmp({ git: true, config: { formatter: false, lsp: false } }, (tmp) => + Effect.gen(function* () { + const currentDir = path.join(tmp.path, "packages", "opencode", "src") + yield* Effect.promise(() => mkdir(currentDir, { recursive: true })) + + const pathSession = yield* createSession(currentDir) + const pathlessSession = yield* createSession(currentDir) + yield* Effect.sync(() => + Database.use((db) => + db.update(SessionTable).set({ path: null }).where(eq(SessionTable.id, pathlessSession.id)).run(), + ), + ) + + const query = new URLSearchParams({ + scope: "project", + path: "packages/opencode/src", + directory: currentDir, + }) + const headers = { "x-kilo-directory": tmp.path } + const legacy = (yield* json( + yield* requestWithBackend(false, `${SessionPaths.list}?${query}`, { headers }), + )).map((item) => item.id) + const effect = (yield* json( + yield* requestWithBackend(true, `${SessionPaths.list}?${query}`, { headers }), + )).map((item) => item.id) + + expect(legacy).toContain(pathSession.id) + expect(legacy).not.toContain(pathlessSession.id) + expect(effect).toEqual(legacy) + }), + ), + ) + + it.live( + "matches legacy paginated message link headers", + withTmp({ git: true, config: { formatter: false, lsp: false } }, (tmp) => + Effect.gen(function* () { + const headers = { "x-kilo-directory": tmp.path } + const session = yield* createSession(tmp.path, { title: "messages" }) + yield* createTextMessage(tmp.path, session.id, "first") + yield* createTextMessage(tmp.path, session.id, "second") + const route = `${pathFor(SessionPaths.messages, { sessionID: session.id })}?limit=1` + + const legacy = yield* requestWithBackend(false, route, { headers }) + const effect = yield* requestWithBackend(true, route, { headers }) + + expect(effect.headers.get("x-next-cursor")).toBe(legacy.headers.get("x-next-cursor")) + expect(effect.headers.get("link")).toBe(legacy.headers.get("link")) + expect(effect.headers.get("access-control-expose-headers")).toBe( + legacy.headers.get("access-control-expose-headers"), + ) + }), + ), + ) + it.live( "serves message mutation routes through Hono bridge", withTmp({ git: true, config: { formatter: false, lsp: false } }, (tmp) => diff --git a/packages/opencode/test/server/httpapi-sync.test.ts b/packages/opencode/test/server/httpapi-sync.test.ts index 13f0436fc4..5d6ad98f8b 100644 --- a/packages/opencode/test/server/httpapi-sync.test.ts +++ b/packages/opencode/test/server/httpapi-sync.test.ts @@ -1,4 +1,4 @@ -import { afterEach, describe, expect, test } from "bun:test" +import { afterEach, describe, expect, mock, spyOn, test } from "bun:test" import { Effect } from "effect" import { Flag } from "@opencode-ai/core/flag/flag" import { Instance } from "../../src/project/instance" @@ -7,7 +7,7 @@ import { SyncPaths } from "../../src/server/routes/instance/httpapi/groups/sync" import { Session } from "@/session/session" import * as Log from "@opencode-ai/core/util/log" import { resetDatabase } from "../fixture/db" -import { tmpdir } from "../fixture/fixture" +import { disposeAllInstances, tmpdir } from "../fixture/fixture" void Log.init({ print: false }) @@ -24,9 +24,10 @@ function runSession(fx: Effect.Effect) { } afterEach(async () => { + mock.restore() Flag.KILO_EXPERIMENTAL_HTTPAPI = originalHttpApi Flag.KILO_EXPERIMENTAL_WORKSPACES = originalWorkspaces - await Instance.disposeAll() + await disposeAllInstances() await resetDatabase() }) @@ -35,6 +36,7 @@ describe("sync HttpApi", () => { Flag.KILO_EXPERIMENTAL_WORKSPACES = true await using tmp = await tmpdir({ git: true, config: { formatter: false, lsp: false } }) const headers = { "x-kilo-directory": tmp.path, "content-type": "application/json" } + const info = spyOn(Log.create({ service: "server.sync" }), "info") const session = await Instance.provide({ directory: tmp.path, @@ -78,6 +80,8 @@ describe("sync HttpApi", () => { }) expect(replayed.status).toBe(200) expect(await replayed.json()).toEqual({ sessionID: session.id }) + expect(info.mock.calls.some(([message]) => message === "sync replay requested")).toBe(true) + expect(info.mock.calls.some(([message]) => message === "sync replay complete")).toBe(true) }) test("matches legacy seq validation", async () => { diff --git a/packages/opencode/test/server/httpapi-tui.test.ts b/packages/opencode/test/server/httpapi-tui.test.ts index 10312b9022..bb2f03db4c 100644 --- a/packages/opencode/test/server/httpapi-tui.test.ts +++ b/packages/opencode/test/server/httpapi-tui.test.ts @@ -1,6 +1,8 @@ import { afterEach, describe, expect, test } from "bun:test" import type { Context } from "hono" import { Flag } from "@opencode-ai/core/flag/flag" +import { GlobalBus } from "../../src/bus/global" +import { TuiEvent } from "../../src/cli/cmd/tui/event" import { SessionID } from "../../src/session/schema" import { Instance } from "../../src/project/instance" import { TuiApi, TuiPaths } from "../../src/server/routes/instance/httpapi/groups/tui" @@ -9,15 +11,26 @@ import { Server } from "../../src/server/server" import * as Log from "@opencode-ai/core/util/log" import { OpenApi } from "effect/unstable/httpapi" import { resetDatabase } from "../fixture/db" -import { tmpdir } from "../fixture/fixture" +import { disposeAllInstances, tmpdir } from "../fixture/fixture" void Log.init({ print: false }) const original = Flag.KILO_EXPERIMENTAL_HTTPAPI -function app() { - Flag.KILO_EXPERIMENTAL_HTTPAPI = true - return Server.Default().app +function app(experimental = true) { + Flag.KILO_EXPERIMENTAL_HTTPAPI = experimental + return experimental ? Server.Default().app : Server.Legacy().app +} + +function nextCommandExecute() { + return new Promise((resolve) => { + const listener = (event: { payload: { type?: string; properties?: { command?: unknown } } }) => { + if (event.payload.type !== TuiEvent.CommandExecute.type) return + GlobalBus.off("event", listener) + resolve(event.payload.properties?.command) + } + GlobalBus.on("event", listener) + }) } async function expectTrue(path: string, headers: Record, body?: unknown) { @@ -32,7 +45,7 @@ async function expectTrue(path: string, headers: Record, body?: afterEach(async () => { Flag.KILO_EXPERIMENTAL_HTTPAPI = original - await Instance.disposeAll() + await disposeAllInstances() await resetDatabase() }) @@ -72,6 +85,27 @@ describe("tui HttpApi bridge", () => { expect(missing.status).toBe(404) }) + test("matches legacy unknown execute command behavior", async () => { + await using tmp = await tmpdir({ git: true, config: { formatter: false, lsp: false } }) + const headers = { "x-kilo-directory": tmp.path, "content-type": "application/json" } + const body = JSON.stringify({ command: "unknown_command" }) + + const legacyCommand = nextCommandExecute() + const legacy = await app(false).request(TuiPaths.executeCommand, { method: "POST", headers, body }) + expect(legacy.status).toBe(200) + expect(await legacy.json()).toBe(true) + + const effectCommand = nextCommandExecute() + const effect = await app().request(TuiPaths.executeCommand, { method: "POST", headers, body }) + expect(effect.status).toBe(200) + expect(await effect.json()).toBe(true) + + const legacyPublished = await legacyCommand + const effectPublished = await effectCommand + expect(effectPublished).toBe(legacyPublished) + expect(legacyPublished).toBeUndefined() + }) + test("serves TUI control queue through experimental Effect routes", async () => { await using tmp = await tmpdir({ git: true, config: { formatter: false, lsp: false } }) const pending = callTui({ req: { json: async () => ({ value: 1 }), path: "/demo" } } as unknown as Context) diff --git a/packages/opencode/test/server/httpapi-ui.test.ts b/packages/opencode/test/server/httpapi-ui.test.ts new file mode 100644 index 0000000000..77013d0dd3 --- /dev/null +++ b/packages/opencode/test/server/httpapi-ui.test.ts @@ -0,0 +1,245 @@ +import { afterEach, describe, expect, test } from "bun:test" +import { Flag } from "@opencode-ai/core/flag/flag" +import * as Log from "@opencode-ai/core/util/log" +import { ConfigProvider, Effect, Layer } from "effect" +import { + HttpClient, + HttpClientRequest, + HttpClientResponse, + HttpRouter, + HttpServer, + HttpServerRequest, + HttpServerResponse, +} from "effect/unstable/http" +import { AppFileSystem } from "@opencode-ai/core/filesystem" +import { + ServerAuthConfig, + authorizationRouterMiddleware, +} from "../../src/server/routes/instance/httpapi/middleware/authorization" +import { ExperimentalHttpApiServer } from "../../src/server/routes/instance/httpapi/server" +import { serveUIEffect } from "../../src/server/routes/ui" +import { Server } from "../../src/server/server" + +void Log.init({ print: false }) + +const original = { + KILO_EXPERIMENTAL_HTTPAPI: Flag.KILO_EXPERIMENTAL_HTTPAPI, + KILO_DISABLE_EMBEDDED_WEB_UI: Flag.KILO_DISABLE_EMBEDDED_WEB_UI, + KILO_SERVER_PASSWORD: Flag.KILO_SERVER_PASSWORD, + KILO_SERVER_USERNAME: Flag.KILO_SERVER_USERNAME, + envPassword: process.env.KILO_SERVER_PASSWORD, + envUsername: process.env.KILO_SERVER_USERNAME, +} + +afterEach(() => { + Flag.KILO_EXPERIMENTAL_HTTPAPI = original.KILO_EXPERIMENTAL_HTTPAPI + Flag.KILO_DISABLE_EMBEDDED_WEB_UI = original.KILO_DISABLE_EMBEDDED_WEB_UI + Flag.KILO_SERVER_PASSWORD = original.KILO_SERVER_PASSWORD + Flag.KILO_SERVER_USERNAME = original.KILO_SERVER_USERNAME + restoreEnv("KILO_SERVER_PASSWORD", original.envPassword) + restoreEnv("KILO_SERVER_USERNAME", original.envUsername) +}) + +function restoreEnv(key: string, value: string | undefined) { + if (value === undefined) { + delete process.env[key] + return + } + process.env[key] = value +} + +function app(input?: { password?: string; username?: string }) { + const handler = HttpRouter.toWebHandler( + ExperimentalHttpApiServer.routes.pipe( + Layer.provide( + ConfigProvider.layer( + ConfigProvider.fromUnknown({ + KILO_SERVER_PASSWORD: input?.password, + KILO_SERVER_USERNAME: input?.username, + }), + ), + ), + ), + { disableLogger: true }, + ).handler + return { + request(input: string | URL | Request, init?: RequestInit) { + return handler( + input instanceof Request ? input : new Request(new URL(input, "http://localhost"), init), + ExperimentalHttpApiServer.context, + ) + }, + } +} + +function uiApp(input?: { password?: string; username?: string; client?: Layer.Layer }) { + const handler = HttpRouter.toWebHandler( + HttpRouter.use((router) => + Effect.gen(function* () { + const fs = yield* AppFileSystem.Service + const client = yield* HttpClient.HttpClient + yield* router.add("*", "/*", (request) => serveUIEffect(request, { fs, client })) + }), + ).pipe( + Layer.provide(authorizationRouterMiddleware.layer.pipe(Layer.provide(ServerAuthConfig.defaultLayer))), + Layer.provide([ + AppFileSystem.defaultLayer, + input?.client ?? httpClient(new Response("ui")), + HttpServer.layerServices, + ConfigProvider.layer( + ConfigProvider.fromUnknown({ + KILO_SERVER_PASSWORD: input?.password, + KILO_SERVER_USERNAME: input?.username, + }), + ), + ]), + ), + { disableLogger: true }, + ).handler + return { + request(input: string | URL | Request, init?: RequestInit) { + return handler( + input instanceof Request ? input : new Request(new URL(input, "http://localhost"), init), + ExperimentalHttpApiServer.context, + ) + }, + } +} + +function httpClient(response: Response, onRequest?: (request: HttpClientRequest.HttpClientRequest) => void) { + return Layer.succeed( + HttpClient.HttpClient, + HttpClient.make((request) => { + onRequest?.(request) + return Effect.succeed(HttpClientResponse.fromWeb(request, response)) + }), + ) +} + +describe("HttpApi UI fallback", () => { + test("serves the web UI through the experimental backend", async () => { + Flag.KILO_EXPERIMENTAL_HTTPAPI = true + Flag.KILO_DISABLE_EMBEDDED_WEB_UI = true + let proxiedUrl: string | undefined + + const response = await uiApp({ + client: httpClient( + new Response("opencode", { headers: { "content-type": "text/html" } }), + (request) => { + proxiedUrl = request.url + }, + ), + }).request("/") + + expect(response.status).toBe(200) + expect(response.headers.get("content-type")).toContain("text/html") + expect(await response.text()).toBe("opencode") + expect(proxiedUrl).toBe("https://app.opencode.ai/") + }) + + test("strips upstream transfer encoding headers from proxied assets", async () => { + Flag.KILO_EXPERIMENTAL_HTTPAPI = true + Flag.KILO_DISABLE_EMBEDDED_WEB_UI = true + let proxiedUrl: string | undefined + + const response = await Effect.runPromise( + Effect.gen(function* () { + const fs = yield* AppFileSystem.Service + const client = yield* HttpClient.HttpClient + return yield* serveUIEffect(HttpServerRequest.fromWeb(new Request("http://localhost/assets/app.js")), { + fs, + client, + }) + }).pipe( + Effect.provide( + Layer.mergeAll( + AppFileSystem.defaultLayer, + Layer.succeed( + HttpClient.HttpClient, + HttpClient.make((request) => { + proxiedUrl = request.url + return Effect.succeed( + HttpClientResponse.fromWeb( + request, + new Response("console.log('ok')", { + headers: { + "content-encoding": "br", + "content-length": "999", + "content-type": "text/javascript", + }, + }), + ), + ) + }), + ), + ), + ), + Effect.map(HttpServerResponse.toWeb), + ), + ) + + expect(response.status).toBe(200) + expect(proxiedUrl).toBe("https://app.opencode.ai/assets/app.js") + expect(response.headers.get("content-encoding")).toBeNull() + expect(response.headers.get("content-length")).not.toBe("999") + expect(response.headers.get("content-type")).toContain("text/javascript") + expect(await response.text()).toBe("console.log('ok')") + }) + + test("keeps matched API routes ahead of the UI fallback", async () => { + Flag.KILO_EXPERIMENTAL_HTTPAPI = true + + const response = await Server.Default().app.request("/session/nope") + + expect(response.status).toBe(404) + }) + + test("requires server password for the web UI", async () => { + Flag.KILO_EXPERIMENTAL_HTTPAPI = true + Flag.KILO_DISABLE_EMBEDDED_WEB_UI = true + + const response = await uiApp({ password: "secret", username: "opencode" }).request("/") + + expect(response.status).toBe(401) + }) + + test("accepts auth token for the web UI", async () => { + Flag.KILO_EXPERIMENTAL_HTTPAPI = true + Flag.KILO_DISABLE_EMBEDDED_WEB_UI = true + + const response = await uiApp({ + password: "secret", + username: "opencode", + client: httpClient(new Response("opencode", { headers: { "content-type": "text/html" } })), + }).request(`/?auth_token=${btoa("opencode:secret")}`) + + expect(response.status).toBe(200) + expect(await response.text()).toBe("opencode") + }) + + test("accepts basic auth for the web UI", async () => { + Flag.KILO_EXPERIMENTAL_HTTPAPI = true + Flag.KILO_DISABLE_EMBEDDED_WEB_UI = true + + const response = await uiApp({ password: "secret", username: "opencode" }).request("/", { + headers: { authorization: `Basic ${btoa("opencode:secret")}` }, + }) + + expect(response.status).toBe(200) + }) + + test("allows web UI preflight without auth", async () => { + Flag.KILO_EXPERIMENTAL_HTTPAPI = true + + const response = await app({ password: "secret", username: "opencode" }).request("/", { + method: "OPTIONS", + headers: { + origin: "http://localhost:3000", + "access-control-request-method": "GET", + }, + }) + + expect(response.status).toBe(204) + expect(response.headers.get("access-control-allow-origin")).toBe("http://localhost:3000") + }) +}) diff --git a/packages/opencode/test/server/httpapi-workspace-routing.test.ts b/packages/opencode/test/server/httpapi-workspace-routing.test.ts new file mode 100644 index 0000000000..fefce0f029 --- /dev/null +++ b/packages/opencode/test/server/httpapi-workspace-routing.test.ts @@ -0,0 +1,471 @@ +import { NodeHttpServer, NodeServices } from "@effect/platform-node" +import { Flag } from "@opencode-ai/core/flag/flag" +import { describe, expect } from "bun:test" +import { Context, Effect, Layer, Queue } from "effect" +import { + FetchHttpClient, + HttpClient, + HttpClientRequest, + HttpRouter, + HttpServer, + HttpServerRequest, + HttpServerResponse, +} from "effect/unstable/http" +import * as Socket from "effect/unstable/socket/Socket" +import Http from "node:http" +import { mkdir } from "node:fs/promises" +import path from "node:path" +import { registerAdapter } from "../../src/control-plane/adapters" +import { WorkspaceID } from "../../src/control-plane/schema" +import type { WorkspaceAdapter } from "../../src/control-plane/types" +import { Workspace } from "../../src/control-plane/workspace" +import { WorkspaceTable } from "../../src/control-plane/workspace.sql" +import { Project } from "../../src/project/project" +import { WorkspacePaths } from "../../src/server/routes/instance/httpapi/groups/workspace" +import { + WorkspaceRouteContext, + workspaceRouterMiddleware, +} from "../../src/server/routes/instance/httpapi/middleware/workspace-routing" +import { Database } from "../../src/storage/db" +import { resetDatabase } from "../fixture/db" +import { tmpdirScoped } from "../fixture/fixture" +import { testEffect } from "../lib/effect" + +const testStateLayer = Layer.effectDiscard( + Effect.gen(function* () { + const originalWorkspaces = Flag.KILO_EXPERIMENTAL_WORKSPACES + yield* Effect.promise(() => resetDatabase()) + Flag.KILO_EXPERIMENTAL_WORKSPACES = true + yield* Effect.addFinalizer(() => + Effect.promise(async () => { + Flag.KILO_EXPERIMENTAL_WORKSPACES = originalWorkspaces + await resetDatabase() + }), + ) + }), +) + +const it = testEffect( + Layer.mergeAll( + testStateLayer, + NodeHttpServer.layerTest, + NodeServices.layer, + Project.defaultLayer, + Workspace.defaultLayer, + Socket.layerWebSocketConstructorGlobal, + ), +) + +type ProxiedRequest = { + url: string + method: string + headers: Record +} + +type TestHandler = ( + request: HttpServerRequest.HttpServerRequest, +) => Effect.Effect + +const workspaceRoutingTestLayer = workspaceRouterMiddleware.layer.pipe( + Layer.provide([Socket.layerWebSocketConstructorGlobal, FetchHttpClient.layer]), +) + +const serverUrl = HttpServer.HttpServer.use((server) => Effect.succeed(HttpServer.formatAddress(server.address))) + +const requestURL = (request: { readonly url: string }) => new URL(request.url, "http://localhost") + +const listenAdditionalServer = (handler: TestHandler) => + Effect.gen(function* () { + const context = yield* Layer.build(NodeHttpServer.layer(Http.createServer, { host: "127.0.0.1", port: 0 })) + const server = Context.get(context, HttpServer.HttpServer) + yield* server.serve(HttpServerRequest.HttpServerRequest.use(handler)) + return HttpServer.formatAddress(server.address) + }) + +const localAdapter = (directory: string): WorkspaceAdapter => ({ + name: "Local Test", + description: "Create a local test workspace", + configure: (info) => ({ ...info, name: "local-test", directory }), + create: async () => { + await mkdir(directory, { recursive: true }) + }, + async remove() {}, + target: () => ({ type: "local" as const, directory }), +}) + +const remoteAdapter = (directory: string, url: string, headers?: HeadersInit): WorkspaceAdapter => ({ + name: "Remote Test", + description: "Create a remote test workspace", + configure: (info) => ({ ...info, name: "remote-test", directory }), + create: async () => { + await mkdir(directory, { recursive: true }) + }, + async remove() {}, + target: () => ({ type: "remote" as const, url, headers }), +}) + +const eventStreamResponse = () => + HttpServerResponse.text('data: {"payload":{"type":"server.connected","properties":{}}}\n\n', { + contentType: "text/event-stream", + }) + +const syncResponse = (request: HttpServerRequest.HttpServerRequest) => { + const url = requestURL(request) + if (url.pathname === "/base/global/event") return Effect.succeed(eventStreamResponse()) + if (url.pathname === "/base/sync/history") return HttpServerResponse.json([]) + return undefined +} + +const createWorkspace = (input: { projectID: Project.Info["id"]; type: string; adapter: WorkspaceAdapter }) => + Effect.acquireRelease( + Effect.gen(function* () { + registerAdapter(input.projectID, input.type, input.adapter) + const workspace = yield* Workspace.Service + return yield* workspace.create({ + type: input.type, + branch: null, + extra: null, + projectID: input.projectID, + }) + }), + (info) => Workspace.Service.use((workspace) => workspace.remove(info.id)).pipe(Effect.ignore), + ) + +const createRemoteWorkspace = (input: { + dir: string + projectID: Project.Info["id"] + type: string + url: string + headers?: HeadersInit +}) => + // Workspace.create starts the remote sync loop. The test upstream exposes + // /global/event and /sync/history so middleware proxying sees the remote + // workspace as active, just like production would. + createWorkspace({ + projectID: input.projectID, + type: input.type, + adapter: remoteAdapter(path.join(input.dir, `.${input.type}`), input.url, input.headers), + }) + +const createLocalWorkspace = (input: { projectID: Project.Info["id"]; type: string; directory: string }) => + createWorkspace({ + projectID: input.projectID, + type: input.type, + adapter: localAdapter(input.directory), + }) + +const insertRemoteWorkspaceWithoutSync = (input: { + dir: string + projectID: Project.Info["id"] + type: string + url: string +}) => + Effect.sync(() => { + const id = WorkspaceID.ascending() + registerAdapter(input.projectID, input.type, remoteAdapter(path.join(input.dir, `.${input.type}`), input.url)) + Database.use((db) => db.insert(WorkspaceTable).values({ id, type: input.type, project_id: input.projectID }).run()) + return id + }) + +const startRemoteWorkspaceHttpServer = ( + handler: (request: ProxiedRequest) => Effect.Effect, +) => + listenAdditionalServer((request) => + Effect.gen(function* () { + // Remote workspaces run a sync loop against their target server. These + // bootstrap routes make Workspace.isSyncing(...) true for proxy tests; + // everything else is the request being proxied by the middleware. + const sync = syncResponse(request) + if (sync) return yield* sync + return yield* handler({ url: request.url, method: request.method, headers: request.headers }) + }), + ) + +const listenRemoteWebSocket = () => + listenAdditionalServer((request) => { + const sync = syncResponse(request) + if (sync) return sync + if (requestURL(request).pathname !== "/base/probe") return Effect.succeed(HttpServerResponse.empty({ status: 404 })) + return echoWebSocket(request) + }) + +const echoWebSocket = (request: HttpServerRequest.HttpServerRequest) => + Effect.gen(function* () { + const socket = yield* Effect.orDie(request.upgrade) + const write = yield* socket.writer + yield* socket + .runRaw((message) => write(`echo:${String(message)}`), { + onOpen: write(`protocol:${request.headers["sec-websocket-protocol"] ?? "none"}`).pipe( + Effect.catch(() => Effect.void), + ), + }) + .pipe(Effect.catch(() => Effect.void)) + return HttpServerResponse.empty() + }) + +const serveRouteContextProbe = HttpRouter.add( + "GET", + "/probe", + Effect.gen(function* () { + // The fake route exposes the context installed by the middleware, so tests + // can assert routing decisions without pulling in the production API tree. + const route = yield* WorkspaceRouteContext + return yield* HttpServerResponse.json({ directory: route.directory, workspaceID: route.workspaceID }) + }), +).pipe(Layer.provide(workspaceRoutingTestLayer), HttpRouter.serve, Layer.build) + +describe("HttpApi workspace routing middleware", () => { + it.live("proxies remote workspace HTTP requests through the selected workspace target", () => + Effect.gen(function* () { + const dir = yield* tmpdirScoped({ git: true }) + const project = yield* Project.use.fromDirectory(dir) + let forwarded: ProxiedRequest | undefined + + // This starts a second HTTP server that stands in for the opencode server + // backing a remote workspace. The client below still calls the local test + // server; only the middleware should call this server. + const remoteUrl = yield* startRemoteWorkspaceHttpServer((request) => { + forwarded = request + const url = requestURL(request) + return HttpServerResponse.json( + { + proxied: true, + path: url.pathname, + keep: url.searchParams.get("keep"), + workspace: url.searchParams.get("workspace"), + }, + { status: 201, headers: { "x-remote": "yes" } }, + ) + }) + // The adapter target tells the middleware where to proxy selected remote + // workspace requests. Appending /probe to this base should produce + // `${remoteUrl}/base/probe` on the fake remote server above. + const workspace = yield* createRemoteWorkspace({ + dir, + projectID: project.project.id, + type: "remote-http-target", + url: `${remoteUrl}/base`, + headers: { "x-target-auth": "secret" }, + }) + + // The local /probe handler should not run. Selecting a remote workspace + // should make the middleware call HttpApiProxy.http instead. + yield* HttpRouter.add("PATCH", "/probe", HttpServerResponse.text("route called")).pipe( + Layer.provide(workspaceRoutingTestLayer), + HttpRouter.serve, + Layer.build, + ) + + const response = yield* HttpClientRequest.patch(`/probe?workspace=${workspace.id}&keep=yes`).pipe( + HttpClientRequest.setHeaders({ + "content-type": "application/json", + "x-kilo-directory": "/secret/path", + "x-kilo-workspace": "internal", + }), + HttpClient.execute, + ) + + expect(response.status).toBe(201) + expect(response.headers["x-remote"]).toBe("yes") + expect(yield* response.json).toEqual({ proxied: true, path: "/base/probe", keep: "yes", workspace: null }) + const forwardedURL = forwarded ? requestURL(forwarded) : undefined + // These assertions are the routing contract: append the original path to + // the remote base URL, preserve normal query params, and remove workspace. + expect(forwardedURL?.pathname).toBe("/base/probe") + expect(forwardedURL?.searchParams.get("keep")).toBe("yes") + expect(forwardedURL?.searchParams.get("workspace")).toBeNull() + expect(forwarded?.method).toBe("PATCH") + expect(forwarded?.headers["content-type"]).toBe("application/json") + expect(forwarded?.headers["x-target-auth"]).toBe("secret") + expect(forwarded?.headers["x-kilo-directory"]).toBeUndefined() + expect(forwarded?.headers["x-kilo-workspace"]).toBeUndefined() + }), + ) + + it.live("returns 503 when a remote workspace is not actively syncing", () => + Effect.gen(function* () { + const dir = yield* tmpdirScoped({ git: true }) + const project = yield* Project.use.fromDirectory(dir) + const workspaceID = yield* insertRemoteWorkspaceWithoutSync({ + dir, + projectID: project.project.id, + type: "remote-not-syncing", + url: "http://127.0.0.1:1/base", + }) + + yield* HttpRouter.add("GET", "/probe", HttpServerResponse.text("route called")).pipe( + Layer.provide(workspaceRoutingTestLayer), + HttpRouter.serve, + Layer.build, + ) + + const response = yield* HttpClient.get(`/probe?workspace=${workspaceID}`) + + expect(response.status).toBe(503) + expect(yield* response.text).toBe(`broken sync connection for workspace: ${workspaceID}`) + }), + ) + + it.live("proxies remote workspace WebSocket requests through the selected workspace target", () => + Effect.gen(function* () { + const dir = yield* tmpdirScoped({ git: true }) + const project = yield* Project.use.fromDirectory(dir) + const remoteUrl = yield* listenRemoteWebSocket() + const workspace = yield* createRemoteWorkspace({ + dir, + projectID: project.project.id, + type: "remote-websocket-target", + url: `${remoteUrl}/base`, + }) + + // The client connects to the local test server. The middleware should + // detect the WebSocket upgrade and proxy it to the remote /base/probe. + yield* HttpRouter.add("GET", "/probe", HttpServerResponse.text("route called")).pipe( + Layer.provide(workspaceRoutingTestLayer), + HttpRouter.serve, + Layer.build, + ) + + const socket = yield* Socket.makeWebSocket( + `${(yield* serverUrl).replace(/^http/, "ws")}/probe?workspace=${workspace.id}`, + { + closeCodeIsError: () => false, + protocols: "chat", + }, + ) + const messages = yield* Queue.unbounded() + yield* socket.runRaw((message) => Queue.offer(messages, String(message))).pipe(Effect.forkScoped) + const write = yield* socket.writer + + expect(yield* Queue.take(messages)).toBe("protocol:chat") + yield* write("hello") + expect(yield* Queue.take(messages)).toBe("echo:hello") + }), + ) + + it.live("returns a missing workspace response for unknown workspace ids", () => + Effect.gen(function* () { + const workspaceID = WorkspaceID.ascending("wrk_missing") + // If the middleware resolves the workspace first, this handler is never + // reached and the response should be the middleware error response. + yield* HttpRouter.add("GET", "/probe", HttpServerResponse.text("route called")).pipe( + Layer.provide(workspaceRoutingTestLayer), + HttpRouter.serve, + Layer.build, + ) + + const response = yield* HttpClient.get(`/probe?workspace=${workspaceID}`) + + expect(response.status).toBe(500) + expect(yield* response.text).toBe(`Workspace not found: ${workspaceID}`) + }), + ) + + it.live("keeps control-plane routes local even when workspace is selected", () => + Effect.gen(function* () { + const dir = yield* tmpdirScoped({ git: true }) + const project = yield* Project.use.fromDirectory(dir) + + const workspaceDir = path.join(dir, ".workspace-local") + const workspace = yield* createLocalWorkspace({ + projectID: project.project.id, + type: "control-plane-target", + directory: workspaceDir, + }) + + // GET /session is a control-plane route: it lists sessions for the main + // process and should not be redirected into the selected workspace target. + yield* HttpRouter.add( + "GET", + "/session", + Effect.gen(function* () { + const route = yield* WorkspaceRouteContext + return yield* HttpServerResponse.json({ directory: route.directory, workspaceID: route.workspaceID }) + }), + ).pipe(Layer.provide(workspaceRoutingTestLayer), HttpRouter.serve, Layer.build) + + const response = yield* HttpClient.get(`/session?workspace=${workspace.id}`) + + expect(response.status).toBe(200) + expect(yield* response.json).toEqual({ directory: process.cwd(), workspaceID: workspace.id }) + }), + ) + + it.live("keeps workspace control routes local even when workspace is selected", () => + Effect.gen(function* () { + const dir = yield* tmpdirScoped({ git: true }) + const project = yield* Project.use.fromDirectory(dir) + const workspaceDir = path.join(dir, ".workspace-local") + const workspace = yield* createLocalWorkspace({ + projectID: project.project.id, + type: "workspace-control-plane-target", + directory: workspaceDir, + }) + + // Workspace CRUD/status routes manage the control plane itself. Selecting + // a workspace should preserve the selected id for handlers, but must not + // swap the route context to the workspace target directory. + yield* HttpRouter.add( + "GET", + WorkspacePaths.list, + Effect.gen(function* () { + const route = yield* WorkspaceRouteContext + return yield* HttpServerResponse.json({ directory: route.directory, workspaceID: route.workspaceID }) + }), + ).pipe(Layer.provide(workspaceRoutingTestLayer), HttpRouter.serve, Layer.build) + + const response = yield* HttpClient.get(`${WorkspacePaths.list}?workspace=${workspace.id}`) + + expect(response.status).toBe(200) + expect(yield* response.json).toEqual({ directory: process.cwd(), workspaceID: workspace.id }) + }), + ) + + it.live("uses directory query/header fallback when no workspace is selected", () => + Effect.gen(function* () { + const dir = yield* tmpdirScoped() + const queryDir = path.join(dir, "query-target") + const headerDir = path.join(dir, "header-target") + yield* serveRouteContextProbe + + // Without a selected workspace, the middleware falls back to request + // directory hints before using the process cwd. + const queryResponse = yield* HttpClient.get(`/probe?directory=${encodeURIComponent(queryDir)}`) + const headerResponse = yield* HttpClientRequest.get("/probe").pipe( + HttpClientRequest.setHeader("x-kilo-directory", headerDir), + HttpClient.execute, + ) + + expect(queryResponse.status).toBe(200) + expect(yield* queryResponse.json).toEqual({ directory: queryDir }) + expect(headerResponse.status).toBe(200) + expect(yield* headerResponse.json).toEqual({ directory: headerDir }) + }), + ) + + it.live("routes local workspace requests through WorkspaceRouteContext", () => + Effect.gen(function* () { + const dir = yield* tmpdirScoped({ git: true }) + const project = yield* Project.use.fromDirectory(dir) + + const workspaceDir = path.join(dir, ".workspace-local") + const workspace = yield* createLocalWorkspace({ + projectID: project.project.id, + type: "local-target", + directory: workspaceDir, + }) + + yield* serveRouteContextProbe + + // /probe is not a control-plane route, so selecting a local workspace + // should swap the route context to the workspace target directory. + const response = yield* HttpClient.get(`/probe?workspace=${workspace.id}`) + + expect(response.status).toBe(200) + expect(yield* response.json).toEqual({ + directory: workspaceDir, + workspaceID: workspace.id, + }) + }), + ) +}) diff --git a/packages/opencode/test/server/httpapi-workspace.test.ts b/packages/opencode/test/server/httpapi-workspace.test.ts index fbf2d5c61d..0563ddce67 100644 --- a/packages/opencode/test/server/httpapi-workspace.test.ts +++ b/packages/opencode/test/server/httpapi-workspace.test.ts @@ -1,43 +1,42 @@ -import { afterEach, describe, expect, mock, test } from "bun:test" +import { afterEach, describe, expect, mock } from "bun:test" +import { NodeServices } from "@effect/platform-node" import { mkdir } from "node:fs/promises" import path from "node:path" -import { Effect } from "effect" +import { Effect, Layer } from "effect" import { Flag } from "@opencode-ai/core/flag/flag" -import { registerAdaptor } from "../../src/control-plane/adaptors" -import type { WorkspaceAdaptor } from "../../src/control-plane/types" +import { registerAdapter } from "../../src/control-plane/adapters" +import type { WorkspaceAdapter } from "../../src/control-plane/types" import { Workspace } from "../../src/control-plane/workspace" import { WorkspacePaths } from "../../src/server/routes/instance/httpapi/groups/workspace" import { Session } from "@/session/session" import * as Log from "@opencode-ai/core/util/log" import { Server } from "../../src/server/server" import { resetDatabase } from "../fixture/db" -import { tmpdir } from "../fixture/fixture" +import { disposeAllInstances, provideInstance, tmpdirScoped } from "../fixture/fixture" import { Instance } from "../../src/project/instance" +import { Project } from "../../src/project/project" import { InstancePaths } from "../../src/server/routes/instance/httpapi/groups/instance" import { WorkspaceRef } from "../../src/effect/instance-ref" +import { testEffect } from "../lib/effect" void Log.init({ print: false }) const originalWorkspaces = Flag.KILO_EXPERIMENTAL_WORKSPACES const originalHttpApi = Flag.KILO_EXPERIMENTAL_HTTPAPI +const it = testEffect( + Layer.mergeAll(NodeServices.layer, Project.defaultLayer, Session.defaultLayer, Workspace.defaultLayer), +) -function request(path: string, directory: string, init: RequestInit = {}) { - Flag.KILO_EXPERIMENTAL_HTTPAPI = true - const headers = new Headers(init.headers) - headers.set("x-kilo-directory", directory) - return Server.Default().app.request(path, { ...init, headers }) +function request(path: string, directory: string, init: RequestInit = {}, httpApi = true) { + return Effect.promise(() => { + Flag.KILO_EXPERIMENTAL_HTTPAPI = httpApi + const headers = new Headers(init.headers) + headers.set("x-kilo-directory", directory) + return Promise.resolve(Server.Default().app.request(path, { ...init, headers })) + }) } -function runSession(fx: Effect.Effect, workspaceID?: Workspace.Info["id"]) { - return Effect.runPromise( - fx.pipe( - workspaceID ? Effect.provideService(WorkspaceRef, workspaceID) : (effect) => effect, - Effect.provide(Session.defaultLayer), - ), - ) -} - -function localAdaptor(directory: string): WorkspaceAdaptor { +function localAdapter(directory: string): WorkspaceAdapter { return { name: "Local Test", description: "Create a local test workspace", @@ -61,7 +60,7 @@ function localAdaptor(directory: string): WorkspaceAdaptor { } } -function remoteAdaptor(directory: string, url: string, headers?: HeadersInit): WorkspaceAdaptor { +function remoteAdapter(directory: string, url: string, headers?: HeadersInit): WorkspaceAdapter { return { name: "Remote Test", description: "Create a remote test workspace", @@ -129,250 +128,300 @@ afterEach(async () => { mock.restore() Flag.KILO_EXPERIMENTAL_WORKSPACES = originalWorkspaces Flag.KILO_EXPERIMENTAL_HTTPAPI = originalHttpApi - await Instance.disposeAll() + await disposeAllInstances() await resetDatabase() }) describe("workspace HttpApi", () => { - test.todo("proxies remote workspace websocket through real Effect listener", () => {}) + it.live("serves read endpoints", () => + Effect.gen(function* () { + const dir = yield* tmpdirScoped({ git: true }) - test("serves read endpoints", async () => { - await using tmp = await tmpdir({ git: true }) + const [adapters, workspaces, status] = yield* Effect.all([ + request(WorkspacePaths.adapters, dir), + request(WorkspacePaths.list, dir), + request(WorkspacePaths.status, dir), + ]) - const [adaptors, workspaces, status] = await Promise.all([ - request(WorkspacePaths.adaptors, tmp.path), - request(WorkspacePaths.list, tmp.path), - request(WorkspacePaths.status, tmp.path), - ]) - - expect(adaptors.status).toBe(200) - expect(await adaptors.json()).toEqual([ - { + expect(adapters.status).toBe(200) + expect(yield* Effect.promise(() => adapters.json())).toContainEqual({ type: "worktree", name: "Worktree", description: "Create a git worktree", - }, - ]) - - expect(workspaces.status).toBe(200) - expect(await workspaces.json()).toEqual([]) - - expect(status.status).toBe(200) - expect(await status.json()).toEqual([]) - }) - - test("serves mutation endpoints", async () => { - Flag.KILO_EXPERIMENTAL_WORKSPACES = true - await using tmp = await tmpdir({ git: true }) - await Instance.provide({ - directory: tmp.path, - fn: async () => - registerAdaptor(Instance.project.id, "local-test", localAdaptor(path.join(tmp.path, ".workspace"))), - }) - - const created = await request(WorkspacePaths.list, tmp.path, { - method: "POST", - headers: { "content-type": "application/json" }, - body: JSON.stringify({ type: "local-test", branch: null, extra: null }), - }) - expect(created.status).toBe(200) - const workspace = (await created.json()) as Workspace.Info - expect(workspace).toMatchObject({ type: "local-test", name: "local-test" }) - - const session = await Instance.provide({ - directory: tmp.path, - fn: async () => runSession(Session.Service.use((svc) => svc.create({}))), - }) - const restored = await request(WorkspacePaths.sessionRestore.replace(":id", workspace.id), tmp.path, { - method: "POST", - headers: { "content-type": "application/json" }, - body: JSON.stringify({ sessionID: session.id }), - }) - expect(restored.status).toBe(200) - expect((await restored.json()) as { total: number }).toMatchObject({ total: expect.any(Number) }) - - const removed = await request(WorkspacePaths.remove.replace(":id", workspace.id), tmp.path, { method: "DELETE" }) - expect(removed.status).toBe(200) - expect(await removed.json()).toMatchObject({ id: workspace.id }) - - const listed = await request(WorkspacePaths.list, tmp.path) - expect(listed.status).toBe(200) - expect(await listed.json()).toEqual([]) - }) - - test("routes local workspace requests through the workspace target directory", async () => { - Flag.KILO_EXPERIMENTAL_WORKSPACES = true - await using tmp = await tmpdir({ git: true }) - const workspaceDir = path.join(tmp.path, ".workspace-local") - const workspace = await Instance.provide({ - directory: tmp.path, - fn: async () => { - registerAdaptor(Instance.project.id, "local-target", localAdaptor(workspaceDir)) - return Workspace.create({ - type: "local-target", - branch: null, - extra: null, - projectID: Instance.project.id, - }) - }, - }) - - const url = new URL(`http://localhost${InstancePaths.path}`) - url.searchParams.set("workspace", workspace.id) - - try { - const response = await request(url.toString(), tmp.path) - - expect(response.status).toBe(200) - expect(await response.json()).toMatchObject({ directory: workspaceDir }) - } finally { - await Workspace.remove(workspace.id) - } - }) - - test("proxies remote workspace HTTP requests with sanitized forwarding", async () => { - Flag.KILO_EXPERIMENTAL_WORKSPACES = true - await using tmp = await tmpdir({ git: true }) - const proxied: ProxiedRequest[] = [] - const remote = listenRemoteHttp((request) => { - proxied.push(request) - const url = new URL(request.url) - if (url.pathname === "/base/global/event") return eventStreamResponse() - if (url.pathname === "/base/sync/history") return Response.json([]) - return new Response( - JSON.stringify({ - proxied: true, - path: url.pathname, - keep: url.searchParams.get("keep"), - workspace: url.searchParams.get("workspace"), - }), - { - status: 201, - statusText: "Created", - headers: { - "content-length": "999", - "content-type": "application/json", - "x-remote": "yes", - }, - }, - ) - }) - - const workspace = await Instance.provide({ - directory: tmp.path, - fn: async () => { - registerAdaptor( - Instance.project.id, - "remote-target", - remoteAdaptor(path.join(tmp.path, ".remote"), `http://127.0.0.1:${remote.port}/base`, { - "x-target-auth": "secret", - }), - ) - return Workspace.create({ - type: "remote-target", - branch: null, - extra: null, - projectID: Instance.project.id, - }) - }, - }) - - const url = new URL("http://localhost/config") - url.searchParams.set("workspace", workspace.id) - url.searchParams.set("keep", "yes") - - try { - const response = await request(url.toString(), tmp.path, { - method: "PATCH", - headers: { - "accept-encoding": "br", - "content-type": "application/json", - "x-kilo-workspace": "internal", - }, - body: JSON.stringify({ $schema: "https://opencode.ai/config.json" }), }) - const responseBody = await response.text() - expect({ status: response.status, body: responseBody }).toMatchObject({ status: 201 }) - expect(response.headers.get("content-length")).toBeNull() - expect(response.headers.get("x-remote")).toBe("yes") - expect(JSON.parse(responseBody)).toEqual({ proxied: true, path: "/base/config", keep: "yes", workspace: null }) - const forwarded = proxied.filter((item) => new URL(item.url).pathname === "/base/config") - expect(forwarded).toEqual([ - { - url: `http://127.0.0.1:${remote.port}/base/config?keep=yes`, - method: "PATCH", - headers: expect.objectContaining({ - "content-type": "application/json", - "x-target-auth": "secret", - }), - body: JSON.stringify({ $schema: "https://opencode.ai/config.json" }), - }, - ]) - expect(forwarded[0]?.headers).not.toHaveProperty("x-kilo-directory") - expect(forwarded[0]?.headers).not.toHaveProperty("x-kilo-workspace") - } finally { - remote.stop(true) - await Workspace.remove(workspace.id) - } - }) + expect(workspaces.status).toBe(200) + expect(yield* Effect.promise(() => workspaces.json())).toEqual([]) - test("proxies remote workspace requests selected from session ownership", async () => { - Flag.KILO_EXPERIMENTAL_WORKSPACES = true - await using tmp = await tmpdir({ git: true }) - const proxied: ProxiedRequest[] = [] - const remote = listenRemoteHttp((request) => { - proxied.push(request) - const url = new URL(request.url) - if (url.pathname === "/base/global/event") return eventStreamResponse() - if (url.pathname === "/base/sync/history") return Response.json([]) - return Response.json({ proxied: true, path: new URL(request.url).pathname }) - }) + expect(status.status).toBe(200) + expect(yield* Effect.promise(() => status.json())).toEqual([]) + }), + ) - const workspace = await Instance.provide({ - directory: tmp.path, - fn: async () => { - registerAdaptor( - Instance.project.id, - "remote-session-target", - remoteAdaptor(path.join(tmp.path, ".remote-session"), `http://127.0.0.1:${remote.port}/base`), - ) - return Workspace.create({ - type: "remote-session-target", - branch: null, - extra: null, - projectID: Instance.project.id, - }) - }, - }) - const session = await Instance.provide({ - directory: tmp.path, - fn: async () => - runSession( - Session.Service.use((svc) => svc.create()), - workspace.id, - ), - }) + it.live("serves mutation endpoints", () => + Effect.gen(function* () { + Flag.KILO_EXPERIMENTAL_WORKSPACES = true + const dir = yield* tmpdirScoped({ git: true }) + const project = yield* Project.use.fromDirectory(dir) + registerAdapter(project.project.id, "local-test", localAdapter(path.join(dir, ".workspace"))) - try { - const response = await request(`http://localhost/session/${session.id}/message`, tmp.path, { + const created = yield* request(WorkspacePaths.list, dir, { method: "POST", headers: { "content-type": "application/json" }, - body: JSON.stringify({ parts: [{ type: "text", text: "hello" }] }), + body: JSON.stringify({ type: "local-test", branch: null, extra: null }), + }) + expect(created.status).toBe(200) + const workspace = (yield* Effect.promise(() => created.json())) as Workspace.Info + expect(workspace).toMatchObject({ type: "local-test", name: "local-test" }) + + const session = yield* Session.Service.use((svc) => svc.create({})).pipe(provideInstance(dir)) + const restored = yield* request(WorkspacePaths.sessionRestore.replace(":id", workspace.id), dir, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ sessionID: session.id }), + }) + expect(restored.status).toBe(200) + expect((yield* Effect.promise(() => restored.json())) as { total: number }).toMatchObject({ + total: expect.any(Number), }) - const responseBody = await response.text() - expect({ status: response.status, body: responseBody }).toMatchObject({ status: 200 }) - expect(JSON.parse(responseBody)).toEqual({ proxied: true, path: `/base/session/${session.id}/message` }) - expect(proxied.filter((item) => new URL(item.url).pathname === `/base/session/${session.id}/message`)).toEqual([ - expect.objectContaining({ - url: `http://127.0.0.1:${remote.port}/base/session/${session.id}/message`, + const removed = yield* request(WorkspacePaths.remove.replace(":id", workspace.id), dir, { method: "DELETE" }) + expect(removed.status).toBe(200) + expect(yield* Effect.promise(() => removed.json())).toMatchObject({ id: workspace.id }) + + const listed = yield* request(WorkspacePaths.list, dir) + expect(listed.status).toBe(200) + expect(yield* Effect.promise(() => listed.json())).toEqual([]) + }), + ) + + it.live("creates workspace with the TUI payload shape", () => + Effect.gen(function* () { + Flag.KILO_EXPERIMENTAL_WORKSPACES = true + const dir = yield* tmpdirScoped({ git: true }) + const project = yield* Project.use.fromDirectory(dir) + registerAdapter(project.project.id, "local-test", localAdapter(path.join(dir, ".workspace"))) + + const created = yield* request(WorkspacePaths.list, dir, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ type: "local-test", branch: null }), + }) + + expect(created.status).toBe(200) + expect((yield* Effect.promise(() => created.json())) as Workspace.Info).toMatchObject({ + type: "local-test", + name: "local-test", + extra: null, + }) + }), + ) + + it.live("creates a real git worktree workspace via the builtin adapter", () => + Effect.gen(function* () { + Flag.KILO_EXPERIMENTAL_WORKSPACES = true + const dir = yield* tmpdirScoped({ git: true }) + + const created = yield* request(WorkspacePaths.list, dir, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ type: "worktree", branch: null }), + }) + + const body = yield* Effect.promise(() => created.text()) + expect({ status: created.status, body }).toMatchObject({ status: 200 }) + const workspace = JSON.parse(body) as Workspace.Info + expect(workspace).toMatchObject({ type: "worktree" }) + }), + ) + + it.live("documents legacy Hono accepting the TUI payload shape", () => + Effect.gen(function* () { + Flag.KILO_EXPERIMENTAL_WORKSPACES = true + const dir = yield* tmpdirScoped({ git: true }) + const project = yield* Project.use.fromDirectory(dir) + registerAdapter(project.project.id, "local-test", localAdapter(path.join(dir, ".workspace"))) + + const created = yield* request( + WorkspacePaths.list, + dir, + { method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ type: "local-test", branch: null }), + }, + false, + ) + + expect(created.status).toBe(200) + expect((yield* Effect.promise(() => created.json())) as Workspace.Info).toMatchObject({ + type: "local-test", + name: "local-test", + extra: null, + }) + }), + ) + + it.live("routes local workspace requests through the workspace target directory", () => + Effect.gen(function* () { + Flag.KILO_EXPERIMENTAL_WORKSPACES = true + const dir = yield* tmpdirScoped({ git: true }) + const workspaceDir = path.join(dir, ".workspace-local") + const project = yield* Project.use.fromDirectory(dir) + registerAdapter(project.project.id, "local-target", localAdapter(workspaceDir)) + const created = yield* request(WorkspacePaths.list, dir, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ type: "local-target", branch: null, extra: null }), + }) + const workspace = (yield* Effect.promise(() => created.json())) as Workspace.Info + + const url = new URL(`http://localhost${InstancePaths.path}`) + url.searchParams.set("workspace", workspace.id) + + const response = yield* request(url.toString(), dir) + + expect(response.status).toBe(200) + expect(yield* Effect.promise(() => response.json())).toMatchObject({ directory: workspaceDir }) + yield* request(WorkspacePaths.remove.replace(":id", workspace.id), dir, { method: "DELETE" }) + }), + ) + + it.live("proxies remote workspace HTTP requests with sanitized forwarding", () => + Effect.gen(function* () { + Flag.KILO_EXPERIMENTAL_WORKSPACES = true + const dir = yield* tmpdirScoped({ git: true }) + const proxied: ProxiedRequest[] = [] + const remote = listenRemoteHttp((request) => { + proxied.push(request) + const url = new URL(request.url) + if (url.pathname === "/base/global/event") return eventStreamResponse() + if (url.pathname === "/base/sync/history") return Response.json([]) + return new Response( + JSON.stringify({ + proxied: true, + path: url.pathname, + keep: url.searchParams.get("keep"), + workspace: url.searchParams.get("workspace"), + }), + { + status: 201, + statusText: "Created", + headers: { + "content-length": "999", + "content-type": "application/json", + "x-remote": "yes", + }, + }, + ) + }) + + const project = yield* Project.use.fromDirectory(dir) + registerAdapter( + project.project.id, + "remote-target", + remoteAdapter(path.join(dir, ".remote"), `http://127.0.0.1:${remote.port}/base`, { + "x-target-auth": "secret", }), - ]) - } finally { - remote.stop(true) - await Workspace.remove(workspace.id) - } - }) + ) + const created = yield* request(WorkspacePaths.list, dir, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ type: "remote-target", branch: null, extra: null }), + }) + const workspace = (yield* Effect.promise(() => created.json())) as Workspace.Info + + const url = new URL("http://localhost/config") + url.searchParams.set("workspace", workspace.id) + url.searchParams.set("keep", "yes") + + try { + const response = yield* request(url.toString(), dir, { + method: "PATCH", + headers: { + "accept-encoding": "br", + "content-type": "application/json", + "x-kilo-workspace": "internal", + }, + body: JSON.stringify({ $schema: "https://opencode.ai/config.json" }), + }) + + const responseBody = yield* Effect.promise(() => response.text()) + expect({ status: response.status, body: responseBody }).toMatchObject({ status: 201 }) + expect(response.headers.get("content-length")).toBeNull() + expect(response.headers.get("x-remote")).toBe("yes") + expect(JSON.parse(responseBody)).toEqual({ proxied: true, path: "/base/config", keep: "yes", workspace: null }) + const forwarded = proxied.filter((item) => new URL(item.url).pathname === "/base/config") + expect(forwarded).toEqual([ + { + url: `http://127.0.0.1:${remote.port}/base/config?keep=yes`, + method: "PATCH", + headers: expect.objectContaining({ + "content-type": "application/json", + "x-target-auth": "secret", + }), + body: JSON.stringify({ $schema: "https://opencode.ai/config.json" }), + }, + ]) + expect(forwarded[0]?.headers).not.toHaveProperty("x-kilo-directory") + expect(forwarded[0]?.headers).not.toHaveProperty("x-kilo-workspace") + } finally { + void remote.stop(true) + yield* request(WorkspacePaths.remove.replace(":id", workspace.id), dir, { method: "DELETE" }) + } + }), + ) + + it.live("proxies remote workspace requests selected from session ownership", () => + Effect.gen(function* () { + Flag.KILO_EXPERIMENTAL_WORKSPACES = true + const dir = yield* tmpdirScoped({ git: true }) + const proxied: ProxiedRequest[] = [] + const remote = listenRemoteHttp((request) => { + proxied.push(request) + const url = new URL(request.url) + if (url.pathname === "/base/global/event") return eventStreamResponse() + if (url.pathname === "/base/sync/history") return Response.json([]) + return Response.json({ proxied: true, path: new URL(request.url).pathname }) + }) + + const project = yield* Project.use.fromDirectory(dir) + registerAdapter( + project.project.id, + "remote-session-target", + remoteAdapter(path.join(dir, ".remote-session"), `http://127.0.0.1:${remote.port}/base`), + ) + const created = yield* request(WorkspacePaths.list, dir, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ type: "remote-session-target", branch: null, extra: null }), + }) + const workspace = (yield* Effect.promise(() => created.json())) as Workspace.Info + const session = yield* Session.Service.use((svc) => svc.create()).pipe( + Effect.provideService(WorkspaceRef, workspace.id), + provideInstance(dir), + ) + + try { + const response = yield* request(`http://localhost/session/${session.id}/message`, dir, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ parts: [{ type: "text", text: "hello" }] }), + }) + + const responseBody = yield* Effect.promise(() => response.text()) + expect({ status: response.status, body: responseBody }).toMatchObject({ status: 200 }) + expect(JSON.parse(responseBody)).toEqual({ proxied: true, path: `/base/session/${session.id}/message` }) + expect(proxied.filter((item) => new URL(item.url).pathname === `/base/session/${session.id}/message`)).toEqual([ + expect.objectContaining({ + url: `http://127.0.0.1:${remote.port}/base/session/${session.id}/message`, + method: "POST", + }), + ]) + } finally { + void remote.stop(true) + yield* request(WorkspacePaths.remove.replace(":id", workspace.id), dir, { method: "DELETE" }) + } + }), + ) }) diff --git a/packages/opencode/test/server/project-init-git.test.ts b/packages/opencode/test/server/project-init-git.test.ts index 1de4455476..5f2bd99efe 100644 --- a/packages/opencode/test/server/project-init-git.test.ts +++ b/packages/opencode/test/server/project-init-git.test.ts @@ -1,14 +1,13 @@ -import { afterEach, describe, expect, spyOn, test } from "bun:test" +import { afterEach, describe, expect, test } from "bun:test" import { Effect } from "effect" import path from "path" import { GlobalBus } from "../../src/bus/global" import { Snapshot } from "../../src/snapshot" -import { Instance } from "../../src/project/instance" import { Server } from "../../src/server/server" import { Filesystem } from "@/util/filesystem" import * as Log from "@opencode-ai/core/util/log" import { resetDatabase } from "../fixture/db" -import { provideInstance, tmpdir } from "../fixture/fixture" +import { disposeAllInstances, provideInstance, tmpdir } from "../fixture/fixture" void Log.init({ print: false }) @@ -16,6 +15,9 @@ afterEach(async () => { await resetDatabase() }) +const disposedEvents = (seen: { directory?: string; payload: { type: string } }[], dir: string) => + seen.filter((evt) => evt.directory === dir && evt.payload.type === "server.instance.disposed").length + describe("project.initGit endpoint", () => { test("initializes git and reloads immediately", async () => { await using tmp = await tmpdir() @@ -24,8 +26,6 @@ describe("project.initGit endpoint", () => { const fn = (evt: { directory?: string; payload: { type: string } }) => { seen.push(evt) } - const reload = Instance.reload - const reloadSpy = spyOn(Instance, "reload").mockImplementation((input) => reload(input)) GlobalBus.on("event", fn) try { @@ -42,10 +42,8 @@ describe("project.initGit endpoint", () => { vcs: "git", worktree: tmp.path, }) - expect(reloadSpy).toHaveBeenCalledTimes(1) - expect(seen.some((evt) => evt.directory === tmp.path && evt.payload.type === "server.instance.disposed")).toBe( - true, - ) + // Reload behavior: bus emits exactly one server.instance.disposed for the directory. + expect(disposedEvents(seen, tmp.path)).toBe(1) expect(await Filesystem.exists(path.join(tmp.path, ".git", "opencode"))).toBe(false) const current = await app.request("/project/current", { @@ -69,8 +67,7 @@ describe("project.initGit endpoint", () => { ), ).toBeTruthy() } finally { - await Instance.disposeAll() - reloadSpy.mockRestore() + await disposeAllInstances() GlobalBus.off("event", fn) } }) @@ -82,8 +79,6 @@ describe("project.initGit endpoint", () => { const fn = (evt: { directory?: string; payload: { type: string } }) => { seen.push(evt) } - const reload = Instance.reload - const reloadSpy = spyOn(Instance, "reload").mockImplementation((input) => reload(input)) GlobalBus.on("event", fn) try { @@ -98,10 +93,7 @@ describe("project.initGit endpoint", () => { vcs: "git", worktree: tmp.path, }) - expect( - seen.filter((evt) => evt.directory === tmp.path && evt.payload.type === "server.instance.disposed").length, - ).toBe(0) - expect(reloadSpy).toHaveBeenCalledTimes(0) + expect(disposedEvents(seen, tmp.path)).toBe(0) const current = await app.request("/project/current", { headers: { @@ -114,8 +106,7 @@ describe("project.initGit endpoint", () => { worktree: tmp.path, }) } finally { - await Instance.disposeAll() - reloadSpy.mockRestore() + await disposeAllInstances() GlobalBus.off("event", fn) } }) diff --git a/packages/opencode/test/server/session-actions.test.ts b/packages/opencode/test/server/session-actions.test.ts index 843986ba8c..43f188e741 100644 --- a/packages/opencode/test/server/session-actions.test.ts +++ b/packages/opencode/test/server/session-actions.test.ts @@ -5,7 +5,7 @@ import { Server } from "../../src/server/server" import { Session as SessionNs } from "@/session/session" import type { SessionID } from "../../src/session/schema" import * as Log from "@opencode-ai/core/util/log" -import { tmpdir } from "../fixture/fixture" +import { disposeAllInstances, tmpdir } from "../fixture/fixture" void Log.init({ print: false }) @@ -25,7 +25,7 @@ const svc = { afterEach(async () => { mock.restore() - await Instance.disposeAll() + await disposeAllInstances() }) describe("session action routes", () => { diff --git a/packages/opencode/test/server/session-list.test.ts b/packages/opencode/test/server/session-list.test.ts index e49449ff73..36fb30e38b 100644 --- a/packages/opencode/test/server/session-list.test.ts +++ b/packages/opencode/test/server/session-list.test.ts @@ -3,7 +3,7 @@ import { Effect } from "effect" import { Instance } from "../../src/project/instance" import { Session as SessionNs } from "@/session/session" import * as Log from "@opencode-ai/core/util/log" -import { tmpdir } from "../fixture/fixture" +import { disposeAllInstances, tmpdir } from "../fixture/fixture" import { Flag } from "@opencode-ai/core/flag/flag" import { mkdir } from "fs/promises" import path from "path" @@ -23,11 +23,14 @@ const svc = { create(input?: SessionNs.CreateInput) { return run(SessionNs.Service.use((svc) => svc.create(input))) }, + list(input?: SessionNs.ListInput) { + return run(SessionNs.Service.use((svc) => svc.list(input))) + }, } afterEach(async () => { Flag.KILO_EXPERIMENTAL_WORKSPACES = originalWorkspaces - await Instance.disposeAll() + await disposeAllInstances() }) describe("session.list", () => { @@ -55,7 +58,7 @@ describe("session.list", () => { fn: async () => svc.create({ title: "sibling" }), }) - const ids = [...svc.list()].map((s) => s.id) + const ids = (await svc.list()).map((s) => s.id) expect(ids).toContain(root.id) expect(ids).toContain(parent.id) expect(ids).toContain(current.id) @@ -88,7 +91,7 @@ describe("session.list", () => { fn: async () => svc.create({ title: "sibling" }), }) - const ids = [...svc.list({ directory: path.join(tmp.path, "packages", "opencode") })].map((s) => s.id) + const ids = (await svc.list({ directory: path.join(tmp.path, "packages", "opencode") })).map((s) => s.id) expect(ids).not.toContain(root.id) expect(ids).not.toContain(parent.id) expect(ids).toContain(current.id) @@ -123,9 +126,12 @@ describe("session.list", () => { fn: async () => svc.create({ title: "sibling" }), }) - const pathIDs = [ - ...svc.list({ directory: path.join(tmp.path, "packages", "app"), path: "packages/opencode/src" }), - ].map((s) => s.id) + const pathIDs = ( + await svc.list({ + directory: path.join(tmp.path, "packages", "app"), + path: "packages/opencode/src", + }) + ).map((s) => s.id) expect(pathIDs).not.toContain(parent.id) expect(pathIDs).toContain(current.id) expect(pathIDs).toContain(deeper.id) @@ -155,9 +161,12 @@ describe("session.list", () => { Database.use((db) => db.update(SessionTable).set({ path: null }).where(eq(SessionTable.id, current.id)).run()) Database.use((db) => db.update(SessionTable).set({ path: null }).where(eq(SessionTable.id, sibling.id)).run()) - const pathIDs = [ - ...svc.list({ directory: path.join(tmp.path, "packages", "opencode", "src"), path: "packages/opencode/src" }), - ].map((s) => s.id) + const pathIDs = ( + await svc.list({ + directory: path.join(tmp.path, "packages", "opencode", "src"), + path: "packages/opencode/src", + }) + ).map((s) => s.id) expect(pathIDs).toContain(current.id) expect(pathIDs).not.toContain(sibling.id) }, @@ -172,7 +181,7 @@ describe("session.list", () => { const root = await svc.create({ title: "root-session" }) const child = await svc.create({ title: "child-session", parentID: root.id }) - const sessions = [...svc.list({ roots: true })] + const sessions = await svc.list({ roots: true }) const ids = sessions.map((s) => s.id) expect(ids).toContain(root.id) @@ -189,7 +198,7 @@ describe("session.list", () => { await svc.create({ title: "new-session" }) const futureStart = Date.now() + 86400000 - const sessions = [...svc.list({ start: futureStart })] + const sessions = await svc.list({ start: futureStart }) expect(sessions.length).toBe(0) }, }) @@ -203,7 +212,7 @@ describe("session.list", () => { await svc.create({ title: "unique-search-term-abc" }) await svc.create({ title: "other-session-xyz" }) - const sessions = [...svc.list({ search: "unique-search" })] + const sessions = await svc.list({ search: "unique-search" }) const titles = sessions.map((s) => s.title) expect(titles).toContain("unique-search-term-abc") @@ -221,7 +230,7 @@ describe("session.list", () => { await svc.create({ title: "session-2" }) await svc.create({ title: "session-3" }) - const sessions = [...svc.list({ limit: 2 })] + const sessions = await svc.list({ limit: 2 }) expect(sessions.length).toBe(2) }, }) diff --git a/packages/opencode/test/server/session-messages.test.ts b/packages/opencode/test/server/session-messages.test.ts index b4cc1dd0d4..f59a02c317 100644 --- a/packages/opencode/test/server/session-messages.test.ts +++ b/packages/opencode/test/server/session-messages.test.ts @@ -6,7 +6,7 @@ import { Session as SessionNs } from "@/session/session" import { MessageV2 } from "../../src/session/message-v2" import { MessageID, PartID, type SessionID } from "../../src/session/schema" import * as Log from "@opencode-ai/core/util/log" -import { tmpdir } from "../fixture/fixture" +import { disposeAllInstances, tmpdir } from "../fixture/fixture" void Log.init({ print: false }) @@ -31,7 +31,7 @@ const svc = { } afterEach(async () => { - await Instance.disposeAll() + await disposeAllInstances() }) async function withoutWatcher(fn: () => Promise) { diff --git a/packages/opencode/test/server/session-select.test.ts b/packages/opencode/test/server/session-select.test.ts index 278fba94dc..b3230d4b8a 100644 --- a/packages/opencode/test/server/session-select.test.ts +++ b/packages/opencode/test/server/session-select.test.ts @@ -5,7 +5,7 @@ import type { SessionID } from "../../src/session/schema" import * as Log from "@opencode-ai/core/util/log" import { Instance } from "../../src/project/instance" import { Server } from "../../src/server/server" -import { tmpdir } from "../fixture/fixture" +import { disposeAllInstances, tmpdir } from "../fixture/fixture" void Log.init({ print: false }) @@ -24,7 +24,7 @@ const svc = { } afterEach(async () => { - await Instance.disposeAll() + await disposeAllInstances() }) describe("tui.selectSession endpoint", () => { diff --git a/packages/opencode/test/server/workspace-proxy.test.ts b/packages/opencode/test/server/workspace-proxy.test.ts index 852f29f516..cad7da138b 100644 --- a/packages/opencode/test/server/workspace-proxy.test.ts +++ b/packages/opencode/test/server/workspace-proxy.test.ts @@ -1,26 +1,68 @@ -import { NodeHttpServer } from "@effect/platform-node" +import { NodeHttpServer, NodeServices } from "@effect/platform-node" import Http from "node:http" import { describe, expect } from "bun:test" -import { Effect } from "effect" -import { HttpServer, HttpServerRequest, HttpServerResponse } from "effect/unstable/http" +import { Context, Effect, Layer, Queue } from "effect" +import { FetchHttpClient, HttpClient, HttpServer, HttpServerRequest, HttpServerResponse } from "effect/unstable/http" +import * as Socket from "effect/unstable/socket/Socket" import { HttpApiProxy } from "../../src/server/routes/instance/httpapi/middleware/proxy" import { testEffect } from "../lib/effect" function serverUrl() { + return HttpServer.HttpServer.use((server) => Effect.succeed(HttpServer.formatAddress(server.address))) +} + +const testServerLayer = Layer.mergeAll( + NodeHttpServer.layer(Http.createServer, { host: "127.0.0.1", port: 0 }), + NodeServices.layer, + FetchHttpClient.layer, + Socket.layerWebSocketConstructorGlobal, +) +const it = testEffect(testServerLayer) + +type TestHandler = ( + request: HttpServerRequest.HttpServerRequest, +) => Effect.Effect + +function listenServer(handler: TestHandler) { return Effect.gen(function* () { - return HttpServer.formatAddress((yield* HttpServer.HttpServer).address) + yield* HttpServer.serveEffect()(HttpServerRequest.HttpServerRequest.use(handler)) + return yield* serverUrl() }) } -const testServerLayer = NodeHttpServer.layer(Http.createServer, { host: "127.0.0.1", port: 0 }) -const it = testEffect(testServerLayer) +function listenTestServer(handler: TestHandler) { + return Effect.gen(function* () { + // Build into the current test scope so the listener stays alive until the + // test finishes. Using Effect.provide here would release it immediately. + const context = yield* Layer.build(NodeHttpServer.layer(Http.createServer, { host: "127.0.0.1", port: 0 })) + const server = Context.get(context, HttpServer.HttpServer) + yield* server.serve(HttpServerRequest.HttpServerRequest.use(handler)) + return HttpServer.formatAddress(server.address) + }) +} + +function echoWebSocket(request: HttpServerRequest.HttpServerRequest) { + return Effect.gen(function* () { + const socket = yield* Effect.orDie(request.upgrade) + const write = yield* socket.writer + // The upstream announces the negotiated protocol, then echoes every + // received frame. The assertions use those messages to prove proxy flow. + yield* socket + .runRaw((message) => write(`echo:${String(message)}`), { + onOpen: write(`protocol:${request.headers["sec-websocket-protocol"] ?? "none"}`).pipe( + Effect.catch(() => Effect.void), + ), + }) + .pipe(Effect.catch(() => Effect.void)) + return HttpServerResponse.empty() + }) +} describe("HttpApi workspace proxy", () => { it.live("proxies HTTP request and returns streamed response with status and headers", () => Effect.gen(function* () { - yield* HttpServer.serveEffect()( - Effect.gen(function* () { - const req = yield* HttpServerRequest.HttpServerRequest + const url = yield* listenServer( + Effect.fnUntraced(function* (req: HttpServerRequest.HttpServerRequest) { const body = yield* req.text return yield* HttpServerResponse.json( { path: req.url, method: req.method, body }, @@ -35,12 +77,17 @@ describe("HttpApi workspace proxy", () => { ) }), ) - const url = yield* serverUrl() const request = HttpServerRequest.fromWeb( new Request("http://localhost/session/abc", { method: "POST", body: "request-body" }), ) - const response = yield* HttpApiProxy.http(`${url}/session/abc?keep=yes`, { "x-extra": "injected" }, request) + const httpClient = yield* HttpClient.HttpClient + const response = yield* HttpApiProxy.http( + httpClient, + `${url}/session/abc?keep=yes`, + { "x-extra": "injected" }, + request, + ) expect(response.status).toBe(201) const client = HttpServerResponse.toClientResponse(response) @@ -58,7 +105,8 @@ describe("HttpApi workspace proxy", () => { it.live("returns 500 when remote is unreachable", () => Effect.gen(function* () { const request = HttpServerRequest.fromWeb(new Request("http://localhost/anything")) - const response = yield* HttpApiProxy.http("http://127.0.0.1:1/unreachable", undefined, request) + const httpClient = yield* HttpClient.HttpClient + const response = yield* HttpApiProxy.http(httpClient, "http://127.0.0.1:1/unreachable", undefined, request) expect(response.status).toBe(500) }), @@ -67,14 +115,12 @@ describe("HttpApi workspace proxy", () => { it.live("strips opencode-internal headers and merges extra headers", () => Effect.gen(function* () { let forwarded: Record = {} - yield* HttpServer.serveEffect()( - Effect.gen(function* () { - const req = yield* HttpServerRequest.HttpServerRequest + const url = yield* listenServer((req) => + Effect.sync(() => { forwarded = req.headers return HttpServerResponse.empty() }), ) - const url = yield* serverUrl() const request = HttpServerRequest.fromWeb( new Request("http://localhost/test", { @@ -85,7 +131,8 @@ describe("HttpApi workspace proxy", () => { }, }), ) - yield* HttpApiProxy.http(`${url}/test`, { "x-injected": "extra" }, request) + const httpClient = yield* HttpClient.HttpClient + yield* HttpApiProxy.http(httpClient, `${url}/test`, { "x-injected": "extra" }, request) expect(forwarded["x-kilo-directory"]).toBeUndefined() expect(forwarded["x-kilo-workspace"]).toBeUndefined() @@ -93,4 +140,26 @@ describe("HttpApi workspace proxy", () => { expect(forwarded["x-injected"]).toBe("extra") }), ) + + it.live("proxies websocket messages and protocols", () => + Effect.gen(function* () { + const upstreamUrl = yield* listenTestServer(echoWebSocket) + + // Client -> proxy listener -> HttpApiProxy.websocket -> upstream listener. + // The client never connects to upstream directly. + const proxyUrl = yield* listenServer((request) => HttpApiProxy.websocket(request, `${upstreamUrl}/echo`)) + + const socket = yield* Socket.makeWebSocket(`${proxyUrl.replace(/^http/, "ws")}/proxy`, { + closeCodeIsError: () => false, + protocols: "chat", + }) + const messages = yield* Queue.unbounded() + yield* socket.runRaw((message) => Queue.offer(messages, String(message))).pipe(Effect.forkScoped) + const write = yield* socket.writer + + expect(yield* Queue.take(messages)).toBe("protocol:chat") + yield* write("hello") + expect(yield* Queue.take(messages)).toBe("echo:hello") + }), + ) }) diff --git a/packages/opencode/test/session/instruction.test.ts b/packages/opencode/test/session/instruction.test.ts index fd7048b31a..8050b4b9f5 100644 --- a/packages/opencode/test/session/instruction.test.ts +++ b/packages/opencode/test/session/instruction.test.ts @@ -1,16 +1,77 @@ -import { afterEach, beforeEach, describe, expect, test } from "bun:test" +import { describe, expect, test } from "bun:test" import path from "path" -import { Effect } from "effect" +import { Effect, FileSystem, Layer } from "effect" +import { FetchHttpClient } from "effect/unstable/http" +import { NodeFileSystem } from "@effect/platform-node" +import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" +import { AppFileSystem } from "@opencode-ai/core/filesystem" +import { Config } from "@/config/config" +import { emptyConsoleState } from "@/config/console-state" import { ModelID, ProviderID } from "../../src/provider/schema" import { Instruction } from "../../src/session/instruction" import type { MessageV2 } from "../../src/session/message-v2" -import { Instance } from "../../src/project/instance" import { MessageID, PartID, SessionID } from "../../src/session/schema" import { Global } from "@opencode-ai/core/global" -import { tmpdir } from "../fixture/fixture" +import { provideInstance, provideTmpdirInstance, tmpdirScoped } from "../fixture/fixture" +import { testEffect } from "../lib/effect" -const run = (effect: Effect.Effect) => - Effect.runPromise(effect.pipe(Effect.provide(Instruction.defaultLayer))) +const it = testEffect(Layer.mergeAll(CrossSpawnSpawner.defaultLayer, NodeFileSystem.layer)) + +const configLayer = Layer.succeed( + Config.Service, + Config.Service.of({ + get: () => Effect.succeed({}), + getGlobal: () => Effect.succeed({}), + getConsoleState: () => Effect.succeed(emptyConsoleState), + update: () => Effect.void, + updateGlobal: (config) => Effect.succeed(config), + invalidate: () => Effect.void, + directories: () => Effect.succeed([]), + waitForDependencies: () => Effect.void, + warnings: () => Effect.succeed([]), // kilocode_change + }), +) + +const instructionLayer = (global: Partial) => + Instruction.layer.pipe( + Layer.provide(configLayer), + Layer.provide(AppFileSystem.defaultLayer), + Layer.provide(FetchHttpClient.layer), + Layer.provide(Global.layerWith(global)), + ) + +const provideInstruction = + (global: Partial) => + (self: Effect.Effect) => + self.pipe(Effect.provide(instructionLayer(global))) + +const write = (filepath: string, content: string) => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem + yield* fs.makeDirectory(path.dirname(filepath), { recursive: true }) + yield* fs.writeFileString(filepath, content) + }) + +const writeFiles = (dir: string, files: Record) => + Effect.all( + Object.entries(files).map(([file, content]) => write(path.join(dir, file), content)), + { discard: true }, + ) + +const withFiles = (files: Record, self: (dir: string) => Effect.Effect) => + provideTmpdirInstance((dir) => + Effect.gen(function* () { + yield* writeFiles(dir, files) + return yield* self(dir).pipe(provideInstruction({ home: dir, config: dir })) + }), + ) + +const tmpWithFiles = (files: Record) => + Effect.gen(function* () { + const dir = yield* tmpdirScoped() + yield* writeFiles(dir, files) + return dir + }) function loaded(filepath: string): MessageV2.WithParts[] { const sessionID = SessionID.make("session-loaded-1") @@ -52,336 +113,208 @@ function loaded(filepath: string): MessageV2.WithParts[] { } describe("Instruction.resolve", () => { - test("returns empty when AGENTS.md is at project root (already in systemPaths)", async () => { - await using tmp = await tmpdir({ - init: async (dir) => { - await Bun.write(path.join(dir, "AGENTS.md"), "# Root Instructions") - await Bun.write(path.join(dir, "src", "file.ts"), "const x = 1") - }, - }) - await Instance.provide({ - directory: tmp.path, - fn: () => - run( - Instruction.Service.use((svc) => - Effect.gen(function* () { - const system = yield* svc.systemPaths() - expect(system.has(path.join(tmp.path, "AGENTS.md"))).toBe(true) + it.live("returns empty when AGENTS.md is at project root (already in systemPaths)", () => + withFiles({ "AGENTS.md": "# Root Instructions", "src/file.ts": "const x = 1" }, (dir) => + Effect.gen(function* () { + const svc = yield* Instruction.Service + const system = yield* svc.systemPaths() + expect(system.has(path.join(dir, "AGENTS.md"))).toBe(true) - const results = yield* svc.resolve( - [], - path.join(tmp.path, "src", "file.ts"), - MessageID.make("message-test-1"), - ) - expect(results).toEqual([]) - }), - ), - ), - }) - }) + const results = yield* svc.resolve([], path.join(dir, "src", "file.ts"), MessageID.make("message-test-1")) + expect(results).toEqual([]) + }), + ), + ) - test("returns AGENTS.md from subdirectory (not in systemPaths)", async () => { - await using tmp = await tmpdir({ - init: async (dir) => { - await Bun.write(path.join(dir, "subdir", "AGENTS.md"), "# Subdir Instructions") - await Bun.write(path.join(dir, "subdir", "nested", "file.ts"), "const x = 1") - }, - }) - await Instance.provide({ - directory: tmp.path, - fn: () => - run( - Instruction.Service.use((svc) => - Effect.gen(function* () { - const system = yield* svc.systemPaths() - expect(system.has(path.join(tmp.path, "subdir", "AGENTS.md"))).toBe(false) + it.live("returns AGENTS.md from subdirectory (not in systemPaths)", () => + withFiles({ "subdir/AGENTS.md": "# Subdir Instructions", "subdir/nested/file.ts": "const x = 1" }, (dir) => + Effect.gen(function* () { + const svc = yield* Instruction.Service + const system = yield* svc.systemPaths() + expect(system.has(path.join(dir, "subdir", "AGENTS.md"))).toBe(false) - const results = yield* svc.resolve( - [], - path.join(tmp.path, "subdir", "nested", "file.ts"), - MessageID.make("message-test-2"), - ) - expect(results.length).toBe(1) - expect(results[0].filepath).toBe(path.join(tmp.path, "subdir", "AGENTS.md")) - }), - ), - ), - }) - }) + const results = yield* svc.resolve( + [], + path.join(dir, "subdir", "nested", "file.ts"), + MessageID.make("message-test-2"), + ) + expect(results.length).toBe(1) + expect(results[0].filepath).toBe(path.join(dir, "subdir", "AGENTS.md")) + }), + ), + ) - test("doesn't reload AGENTS.md when reading it directly", async () => { - await using tmp = await tmpdir({ - init: async (dir) => { - await Bun.write(path.join(dir, "subdir", "AGENTS.md"), "# Subdir Instructions") - await Bun.write(path.join(dir, "subdir", "nested", "file.ts"), "const x = 1") - }, - }) - await Instance.provide({ - directory: tmp.path, - fn: () => - run( - Instruction.Service.use((svc) => - Effect.gen(function* () { - const filepath = path.join(tmp.path, "subdir", "AGENTS.md") - const system = yield* svc.systemPaths() - expect(system.has(filepath)).toBe(false) + it.live("doesn't reload AGENTS.md when reading it directly", () => + withFiles({ "subdir/AGENTS.md": "# Subdir Instructions", "subdir/nested/file.ts": "const x = 1" }, (dir) => + Effect.gen(function* () { + const svc = yield* Instruction.Service + const filepath = path.join(dir, "subdir", "AGENTS.md") + const system = yield* svc.systemPaths() + expect(system.has(filepath)).toBe(false) - const results = yield* svc.resolve([], filepath, MessageID.make("message-test-3")) - expect(results).toEqual([]) - }), - ), - ), - }) - }) + const results = yield* svc.resolve([], filepath, MessageID.make("message-test-3")) + expect(results).toEqual([]) + }), + ), + ) - test("does not reattach the same nearby instructions twice for one message", async () => { - await using tmp = await tmpdir({ - init: async (dir) => { - await Bun.write(path.join(dir, "subdir", "AGENTS.md"), "# Subdir Instructions") - await Bun.write(path.join(dir, "subdir", "nested", "file.ts"), "const x = 1") - }, - }) - await Instance.provide({ - directory: tmp.path, - fn: () => - run( - Instruction.Service.use((svc) => - Effect.gen(function* () { - const filepath = path.join(tmp.path, "subdir", "nested", "file.ts") - const id = MessageID.make("message-claim-1") + it.live("does not reattach the same nearby instructions twice for one message", () => + withFiles({ "subdir/AGENTS.md": "# Subdir Instructions", "subdir/nested/file.ts": "const x = 1" }, (dir) => + Effect.gen(function* () { + const svc = yield* Instruction.Service + const filepath = path.join(dir, "subdir", "nested", "file.ts") + const id = MessageID.make("message-claim-1") - const first = yield* svc.resolve([], filepath, id) - const second = yield* svc.resolve([], filepath, id) + const first = yield* svc.resolve([], filepath, id) + const second = yield* svc.resolve([], filepath, id) - expect(first).toHaveLength(1) - expect(first[0].filepath).toBe(path.join(tmp.path, "subdir", "AGENTS.md")) - expect(second).toEqual([]) - }), - ), - ), - }) - }) + expect(first).toHaveLength(1) + expect(first[0].filepath).toBe(path.join(dir, "subdir", "AGENTS.md")) + expect(second).toEqual([]) + }), + ), + ) - test("clear allows nearby instructions to be attached again for the same message", async () => { - await using tmp = await tmpdir({ - init: async (dir) => { - await Bun.write(path.join(dir, "subdir", "AGENTS.md"), "# Subdir Instructions") - await Bun.write(path.join(dir, "subdir", "nested", "file.ts"), "const x = 1") - }, - }) - await Instance.provide({ - directory: tmp.path, - fn: () => - run( - Instruction.Service.use((svc) => - Effect.gen(function* () { - const filepath = path.join(tmp.path, "subdir", "nested", "file.ts") - const id = MessageID.make("message-claim-2") + it.live("clear allows nearby instructions to be attached again for the same message", () => + withFiles({ "subdir/AGENTS.md": "# Subdir Instructions", "subdir/nested/file.ts": "const x = 1" }, (dir) => + Effect.gen(function* () { + const svc = yield* Instruction.Service + const filepath = path.join(dir, "subdir", "nested", "file.ts") + const id = MessageID.make("message-claim-2") - const first = yield* svc.resolve([], filepath, id) - yield* svc.clear(id) - const second = yield* svc.resolve([], filepath, id) + const first = yield* svc.resolve([], filepath, id) + yield* svc.clear(id) + const second = yield* svc.resolve([], filepath, id) - expect(first).toHaveLength(1) - expect(second).toHaveLength(1) - expect(second[0].filepath).toBe(path.join(tmp.path, "subdir", "AGENTS.md")) - }), - ), - ), - }) - }) + expect(first).toHaveLength(1) + expect(second).toHaveLength(1) + expect(second[0].filepath).toBe(path.join(dir, "subdir", "AGENTS.md")) + }), + ), + ) - test("skips instructions already reported by prior read metadata", async () => { - await using tmp = await tmpdir({ - init: async (dir) => { - await Bun.write(path.join(dir, "subdir", "AGENTS.md"), "# Subdir Instructions") - await Bun.write(path.join(dir, "subdir", "nested", "file.ts"), "const x = 1") - }, - }) - await Instance.provide({ - directory: tmp.path, - fn: () => - run( - Instruction.Service.use((svc) => - Effect.gen(function* () { - const agents = path.join(tmp.path, "subdir", "AGENTS.md") - const filepath = path.join(tmp.path, "subdir", "nested", "file.ts") - const id = MessageID.make("message-claim-3") + it.live("skips instructions already reported by prior read metadata", () => + withFiles({ "subdir/AGENTS.md": "# Subdir Instructions", "subdir/nested/file.ts": "const x = 1" }, (dir) => + Effect.gen(function* () { + const svc = yield* Instruction.Service + const agents = path.join(dir, "subdir", "AGENTS.md") + const filepath = path.join(dir, "subdir", "nested", "file.ts") + const id = MessageID.make("message-claim-3") - const results = yield* svc.resolve(loaded(agents), filepath, id) - expect(results).toEqual([]) - }), - ), - ), - }) - }) + const results = yield* svc.resolve(loaded(agents), filepath, id) + expect(results).toEqual([]) + }), + ), + ) test.todo("fetches remote instructions from config URLs via HttpClient", () => {}) }) describe("Instruction.system", () => { - test("loads both project and global AGENTS.md when both exist", async () => { - const originalConfigDir = process.env["KILO_CONFIG_DIR"] - delete process.env["KILO_CONFIG_DIR"] + it.live("loads both project and global AGENTS.md when both exist", () => + Effect.gen(function* () { + const globalTmp = yield* tmpWithFiles({ "AGENTS.md": "# Global Instructions" }) + const projectTmp = yield* tmpWithFiles({ "AGENTS.md": "# Project Instructions" }) - await using globalTmp = await tmpdir({ - init: async (dir) => { - await Bun.write(path.join(dir, "AGENTS.md"), "# Global Instructions") - }, - }) - await using projectTmp = await tmpdir({ - init: async (dir) => { - await Bun.write(path.join(dir, "AGENTS.md"), "# Project Instructions") - }, - }) + yield* Effect.gen(function* () { + const svc = yield* Instruction.Service + const paths = yield* svc.systemPaths() + expect(paths.has(path.join(projectTmp, "AGENTS.md"))).toBe(true) + expect(paths.has(path.join(globalTmp, "AGENTS.md"))).toBe(true) - const originalGlobalConfig = Global.Path.config - ;(Global.Path as { config: string }).config = globalTmp.path - - try { - await Instance.provide({ - directory: projectTmp.path, - fn: () => - run( - Instruction.Service.use((svc) => - Effect.gen(function* () { - const paths = yield* svc.systemPaths() - expect(paths.has(path.join(projectTmp.path, "AGENTS.md"))).toBe(true) - expect(paths.has(path.join(globalTmp.path, "AGENTS.md"))).toBe(true) - - const rules = yield* svc.system() - expect(rules).toHaveLength(2) - expect(rules[0]).toBe( - `Instructions from: ${path.join(globalTmp.path, "AGENTS.md")}\n# Global Instructions`, - ) - expect(rules[1]).toBe( - `Instructions from: ${path.join(projectTmp.path, "AGENTS.md")}\n# Project Instructions`, - ) - }), - ), - ), - }) - } finally { - ;(Global.Path as { config: string }).config = originalGlobalConfig - if (originalConfigDir === undefined) { - delete process.env["KILO_CONFIG_DIR"] - } else { - process.env["KILO_CONFIG_DIR"] = originalConfigDir - } - } - }) + const rules = yield* svc.system() + expect(rules).toHaveLength(2) + expect(rules[0]).toBe(`Instructions from: ${path.join(globalTmp, "AGENTS.md")}\n# Global Instructions`) + expect(rules[1]).toBe(`Instructions from: ${path.join(projectTmp, "AGENTS.md")}\n# Project Instructions`) + }).pipe(provideInstance(projectTmp), provideInstruction({ home: globalTmp, config: globalTmp })) + }), + ) }) -describe("Instruction.systemPaths KILO_CONFIG_DIR", () => { - let originalConfigDir: string | undefined +describe("Instruction.systemPaths global config", () => { + it.live("uses Global.Service config AGENTS.md", () => + Effect.gen(function* () { + const globalTmp = yield* tmpWithFiles({ "AGENTS.md": "# Global Instructions" }) + const projectTmp = yield* tmpdirScoped() - beforeEach(() => { - originalConfigDir = process.env["KILO_CONFIG_DIR"] - }) + yield* Effect.gen(function* () { + const svc = yield* Instruction.Service + const paths = yield* svc.systemPaths() + expect(paths.has(path.join(globalTmp, "AGENTS.md"))).toBe(true) + }).pipe(provideInstance(projectTmp), provideInstruction({ home: globalTmp, config: globalTmp })) + }), + ) - afterEach(() => { - if (originalConfigDir === undefined) { - delete process.env["KILO_CONFIG_DIR"] - } else { - process.env["KILO_CONFIG_DIR"] = originalConfigDir - } - }) - - test("prefers KILO_CONFIG_DIR AGENTS.md over global when both exist", async () => { - await using profileTmp = await tmpdir({ - init: async (dir) => { - await Bun.write(path.join(dir, "AGENTS.md"), "# Profile Instructions") - }, - }) - await using globalTmp = await tmpdir({ - init: async (dir) => { - await Bun.write(path.join(dir, "AGENTS.md"), "# Global Instructions") - }, - }) - await using projectTmp = await tmpdir() - - process.env["KILO_CONFIG_DIR"] = profileTmp.path - const originalGlobalConfig = Global.Path.config - ;(Global.Path as { config: string }).config = globalTmp.path - - try { - await Instance.provide({ - directory: projectTmp.path, - fn: () => - run( - Instruction.Service.use((svc) => - Effect.gen(function* () { - const paths = yield* svc.systemPaths() - expect(paths.has(path.join(profileTmp.path, "AGENTS.md"))).toBe(true) - expect(paths.has(path.join(globalTmp.path, "AGENTS.md"))).toBe(false) - }), - ), - ), + // kilocode_change start - KILO_CONFIG_DIR profile fallback (replaces dropped Kilo-specific tests) + const withConfigDir = + (value: string | undefined) => + (self: Effect.Effect) => + Effect.gen(function* () { + const original = process.env["KILO_CONFIG_DIR"] + if (value === undefined) delete process.env["KILO_CONFIG_DIR"] + else process.env["KILO_CONFIG_DIR"] = value + yield* Effect.addFinalizer(() => + Effect.sync(() => { + if (original === undefined) delete process.env["KILO_CONFIG_DIR"] + else process.env["KILO_CONFIG_DIR"] = original + }), + ) + return yield* self }) - } finally { - ;(Global.Path as { config: string }).config = originalGlobalConfig - } - }) - test("falls back to global AGENTS.md when KILO_CONFIG_DIR has no AGENTS.md", async () => { - await using profileTmp = await tmpdir() - await using globalTmp = await tmpdir({ - init: async (dir) => { - await Bun.write(path.join(dir, "AGENTS.md"), "# Global Instructions") - }, - }) - await using projectTmp = await tmpdir() + it.live("prefers KILO_CONFIG_DIR AGENTS.md over global when both exist", () => + Effect.gen(function* () { + const profileTmp = yield* tmpWithFiles({ "AGENTS.md": "# Profile Instructions" }) + const globalTmp = yield* tmpWithFiles({ "AGENTS.md": "# Global Instructions" }) + const projectTmp = yield* tmpdirScoped() - process.env["KILO_CONFIG_DIR"] = profileTmp.path - const originalGlobalConfig = Global.Path.config - ;(Global.Path as { config: string }).config = globalTmp.path + yield* Effect.gen(function* () { + const svc = yield* Instruction.Service + const paths = yield* svc.systemPaths() + expect(paths.has(path.join(profileTmp, "AGENTS.md"))).toBe(true) + expect(paths.has(path.join(globalTmp, "AGENTS.md"))).toBe(false) + }).pipe( + provideInstance(projectTmp), + provideInstruction({ home: globalTmp, config: globalTmp }), + withConfigDir(profileTmp), + ) + }), + ) - try { - await Instance.provide({ - directory: projectTmp.path, - fn: () => - run( - Instruction.Service.use((svc) => - Effect.gen(function* () { - const paths = yield* svc.systemPaths() - expect(paths.has(path.join(profileTmp.path, "AGENTS.md"))).toBe(false) - expect(paths.has(path.join(globalTmp.path, "AGENTS.md"))).toBe(true) - }), - ), - ), - }) - } finally { - ;(Global.Path as { config: string }).config = originalGlobalConfig - } - }) + it.live("falls back to global AGENTS.md when KILO_CONFIG_DIR has no AGENTS.md", () => + Effect.gen(function* () { + const profileTmp = yield* tmpdirScoped() + const globalTmp = yield* tmpWithFiles({ "AGENTS.md": "# Global Instructions" }) + const projectTmp = yield* tmpdirScoped() - test("uses global AGENTS.md when KILO_CONFIG_DIR is not set", async () => { - await using globalTmp = await tmpdir({ - init: async (dir) => { - await Bun.write(path.join(dir, "AGENTS.md"), "# Global Instructions") - }, - }) - await using projectTmp = await tmpdir() + yield* Effect.gen(function* () { + const svc = yield* Instruction.Service + const paths = yield* svc.systemPaths() + expect(paths.has(path.join(profileTmp, "AGENTS.md"))).toBe(false) + expect(paths.has(path.join(globalTmp, "AGENTS.md"))).toBe(true) + }).pipe( + provideInstance(projectTmp), + provideInstruction({ home: globalTmp, config: globalTmp }), + withConfigDir(profileTmp), + ) + }), + ) - delete process.env["KILO_CONFIG_DIR"] - const originalGlobalConfig = Global.Path.config - ;(Global.Path as { config: string }).config = globalTmp.path + it.live("uses global AGENTS.md when KILO_CONFIG_DIR is not set", () => + Effect.gen(function* () { + const globalTmp = yield* tmpWithFiles({ "AGENTS.md": "# Global Instructions" }) + const projectTmp = yield* tmpdirScoped() - try { - await Instance.provide({ - directory: projectTmp.path, - fn: () => - run( - Instruction.Service.use((svc) => - Effect.gen(function* () { - const paths = yield* svc.systemPaths() - expect(paths.has(path.join(globalTmp.path, "AGENTS.md"))).toBe(true) - }), - ), - ), - }) - } finally { - ;(Global.Path as { config: string }).config = originalGlobalConfig - } - }) + yield* Effect.gen(function* () { + const svc = yield* Instruction.Service + const paths = yield* svc.systemPaths() + expect(paths.has(path.join(globalTmp, "AGENTS.md"))).toBe(true) + }).pipe( + provideInstance(projectTmp), + provideInstruction({ home: globalTmp, config: globalTmp }), + withConfigDir(undefined), + ) + }), + ) + // kilocode_change end }) diff --git a/packages/opencode/test/session/message-v2.test.ts b/packages/opencode/test/session/message-v2.test.ts index 89bae246a7..afd24e7e1b 100644 --- a/packages/opencode/test/session/message-v2.test.ts +++ b/packages/opencode/test/session/message-v2.test.ts @@ -469,6 +469,13 @@ describe("session.message-v2.toModelMessage", () => { }, { ...basePart(assistantID, "a2"), + type: "reasoning", + text: "thinking", + metadata: { openai: { reasoning: "meta" } }, + time: { start: 0 }, + }, + { + ...basePart(assistantID, "a3"), type: "tool", callID: "call-1", tool: "bash", @@ -495,6 +502,7 @@ describe("session.message-v2.toModelMessage", () => { role: "assistant", content: [ { type: "text", text: "done" }, + { type: "text", text: "thinking" }, { type: "tool-call", toolCallId: "call-1", diff --git a/packages/opencode/test/session/retry.test.ts b/packages/opencode/test/session/retry.test.ts index 57184ae485..ba0658b887 100644 --- a/packages/opencode/test/session/retry.test.ts +++ b/packages/opencode/test/session/retry.test.ts @@ -2,28 +2,31 @@ import { describe, expect, test } from "bun:test" import type { NamedError } from "@opencode-ai/core/util/error" import { APICallError } from "ai" import { setTimeout as sleep } from "node:timers/promises" -import { Effect, Schedule } from "effect" +import { Effect, Layer, Schedule } from "effect" +import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" import { SessionRetry } from "../../src/session/retry" import { MessageV2 } from "../../src/session/message-v2" import { ProviderID } from "../../src/provider/schema" -import { AppRuntime } from "../../src/effect/app-runtime" import { SessionID } from "../../src/session/schema" import { SessionStatus } from "../../src/session/status" -import { Instance } from "../../src/project/instance" -import { tmpdir } from "../fixture/fixture" +import { provideTmpdirInstance } from "../fixture/fixture" +import { testEffect } from "../lib/effect" const providerID = ProviderID.make("test") +const it = testEffect(Layer.mergeAll(SessionStatus.defaultLayer, CrossSpawnSpawner.defaultLayer)) function apiError(headers?: Record): MessageV2.APIError { - return new MessageV2.APIError({ - message: "boom", - isRetryable: true, - responseHeaders: headers, - }).toObject() as MessageV2.APIError + return MessageV2.APIError.Schema.parse( + new MessageV2.APIError({ + message: "boom", + isRetryable: true, + responseHeaders: headers, + }).toObject(), + ) } function wrap(message: unknown): ReturnType { - return { data: { message } } as ReturnType + return { name: "", data: { message } } } describe("session.retry.delay", () => { @@ -80,47 +83,36 @@ describe("session.retry.delay", () => { expect(SessionRetry.delay(1, error)).toBe(SessionRetry.RETRY_MAX_DELAY) }) - test("policy updates retry status and increments attempts", async () => { - await using tmp = await tmpdir() - await Instance.provide({ - directory: tmp.path, - fn: async () => { + it.live("policy updates retry status and increments attempts", () => + provideTmpdirInstance(() => + Effect.gen(function* () { const sessionID = SessionID.make("session-retry-test") const error = apiError({ "retry-after-ms": "0" }) + const status = yield* SessionStatus.Service - await Effect.runPromise( - Effect.gen(function* () { - const step = yield* Schedule.toStepWithMetadata( - SessionRetry.policy({ - parse: (err) => err as MessageV2.APIError, - set: (info) => - Effect.promise(() => - AppRuntime.runPromise( - SessionStatus.Service.use((svc) => - svc.set(sessionID, { - type: "retry", - attempt: info.attempt, - message: info.message, - next: info.next, - }), - ), - ), - ), + const step = yield* Schedule.toStepWithMetadata( + SessionRetry.policy({ + parse: (err) => MessageV2.APIError.Schema.parse(err), + set: (info) => + status.set(sessionID, { + type: "retry", + attempt: info.attempt, + message: info.message, + next: info.next, }), - ) - yield* step(error) - yield* step(error) }), ) + yield* step(error) + yield* step(error) - expect(await AppRuntime.runPromise(SessionStatus.Service.use((svc) => svc.get(sessionID)))).toMatchObject({ + expect(yield* status.get(sessionID)).toMatchObject({ type: "retry", attempt: 2, message: "boom", }) - }, - }) - }) + }), + ), + ) }) describe("session.retry.retryable", () => { @@ -173,58 +165,68 @@ describe("session.retry.retryable", () => { const error = new MessageV2.ContextOverflowError({ message: "Input exceeds context window of this model", responseBody: '{"error":{"code":"context_length_exceeded"}}', - }).toObject() as ReturnType + }).toObject() expect(SessionRetry.retryable(error)).toBeUndefined() }) test("retries 500 errors even when isRetryable is false", () => { - const error = new MessageV2.APIError({ - message: "Internal server error", - isRetryable: false, - statusCode: 500, - responseBody: '{"type":"api_error","message":"Internal server error"}', - }).toObject() as MessageV2.APIError + const error = MessageV2.APIError.Schema.parse( + new MessageV2.APIError({ + message: "Internal server error", + isRetryable: false, + statusCode: 500, + responseBody: '{"type":"api_error","message":"Internal server error"}', + }).toObject(), + ) expect(SessionRetry.retryable(error)).toBe("Internal server error") }) test("retries 502 bad gateway errors", () => { - const error = new MessageV2.APIError({ - message: "Bad gateway", - isRetryable: false, - statusCode: 502, - }).toObject() as MessageV2.APIError + const error = MessageV2.APIError.Schema.parse( + new MessageV2.APIError({ + message: "Bad gateway", + isRetryable: false, + statusCode: 502, + }).toObject(), + ) expect(SessionRetry.retryable(error)).toBe("Bad gateway") }) test("retries 503 service unavailable errors", () => { - const error = new MessageV2.APIError({ - message: "Service unavailable", - isRetryable: false, - statusCode: 503, - }).toObject() as MessageV2.APIError + const error = MessageV2.APIError.Schema.parse( + new MessageV2.APIError({ + message: "Service unavailable", + isRetryable: false, + statusCode: 503, + }).toObject(), + ) expect(SessionRetry.retryable(error)).toBe("Service unavailable") }) test("does not retry 4xx errors when isRetryable is false", () => { - const error = new MessageV2.APIError({ - message: "Bad request", - isRetryable: false, - statusCode: 400, - }).toObject() as MessageV2.APIError + const error = MessageV2.APIError.Schema.parse( + new MessageV2.APIError({ + message: "Bad request", + isRetryable: false, + statusCode: 400, + }).toObject(), + ) expect(SessionRetry.retryable(error)).toBeUndefined() }) test("retries ZlibError decompression failures", () => { - const error = new MessageV2.APIError({ - message: "Response decompression failed", - isRetryable: true, - metadata: { code: "ZlibError" }, - }).toObject() as MessageV2.APIError + const error = MessageV2.APIError.Schema.parse( + new MessageV2.APIError({ + message: "Response decompression failed", + isRetryable: true, + metadata: { code: "ZlibError" }, + }).toObject(), + ) const retryable = SessionRetry.retryable(error) expect(retryable).toBeDefined() @@ -261,20 +263,23 @@ describe("session.message-v2.fromError", () => { const result = MessageV2.fromError(error, { providerID }) expect(MessageV2.APIError.isInstance(result)).toBe(true) - expect((result as MessageV2.APIError).data.isRetryable).toBe(true) - expect((result as MessageV2.APIError).data.message).toBe("Connection reset by server") - expect((result as MessageV2.APIError).data.metadata?.code).toBe("ECONNRESET") - expect((result as MessageV2.APIError).data.metadata?.message).toInclude("socket connection") + if (!MessageV2.APIError.isInstance(result)) throw new Error("expected APIError") + expect(result.data.isRetryable).toBe(true) + expect(result.data.message).toBe("Connection reset by server") + expect(result.data.metadata?.code).toBe("ECONNRESET") + expect(result.data.metadata?.message).toInclude("socket connection") }, 15_000, ) test("ECONNRESET socket error is retryable", () => { - const error = new MessageV2.APIError({ - message: "Connection reset by server", - isRetryable: true, - metadata: { code: "ECONNRESET", message: "The socket connection was closed unexpectedly" }, - }).toObject() as MessageV2.APIError + const error = MessageV2.APIError.Schema.parse( + new MessageV2.APIError({ + message: "Connection reset by server", + isRetryable: true, + metadata: { code: "ECONNRESET", message: "The socket connection was closed unexpectedly" }, + }).toObject(), + ) const retryable = SessionRetry.retryable(error) expect(retryable).toBeDefined() @@ -308,7 +313,8 @@ describe("session.message-v2.fromError", () => { responseBody: '{"error":"boom"}', isRetryable: false, }) - const result = MessageV2.fromError(error, { providerID: ProviderID.make("openai") }) as MessageV2.APIError + const result = MessageV2.fromError(error, { providerID: ProviderID.make("openai") }) + if (!MessageV2.APIError.isInstance(result)) throw new Error("expected APIError") expect(result.data.isRetryable).toBe(true) }) @@ -330,7 +336,8 @@ describe("session.message-v2.fromError", () => { ) expect(MessageV2.APIError.isInstance(result)).toBe(true) - expect((result as MessageV2.APIError).data.isRetryable).toBe(true) + if (!MessageV2.APIError.isInstance(result)) throw new Error("expected APIError") + expect(result.data.isRetryable).toBe(true) expect(SessionRetry.retryable(result)).toBe("An error occurred while processing your request.") }) }) diff --git a/packages/opencode/test/session/system.test.ts b/packages/opencode/test/session/system.test.ts index 451989534a..6e5439da58 100644 --- a/packages/opencode/test/session/system.test.ts +++ b/packages/opencode/test/session/system.test.ts @@ -1,69 +1,73 @@ -import { describe, expect, test } from "bun:test" -import path from "path" -import { Effect } from "effect" -import { Agent } from "../../src/agent/agent" -import { Instance } from "../../src/project/instance" +import { describe, expect } from "bun:test" +import { Effect, Layer } from "effect" +import type { Agent } from "../../src/agent/agent" +import { NamedError } from "@opencode-ai/core/util/error" +import { Skill } from "../../src/skill" +import { Permission } from "../../src/permission" import { SystemPrompt } from "../../src/session/system" -import { provideInstance, tmpdir } from "../fixture/fixture" +import { testEffect } from "../lib/effect" -function load(dir: string, fn: (svc: Agent.Interface) => Effect.Effect) { - return Effect.runPromise(provideInstance(dir)(Agent.Service.use(fn)).pipe(Effect.provide(Agent.defaultLayer))) +const skills: Skill.Info[] = [ + { + name: "zeta-skill", + description: "Zeta skill.", + location: "/tmp/zeta-skill/SKILL.md", + content: "# zeta-skill", + }, + { + name: "alpha-skill", + description: "Alpha skill.", + location: "/tmp/alpha-skill/SKILL.md", + content: "# alpha-skill", + }, + { + name: "middle-skill", + description: "Middle skill.", + location: "/tmp/middle-skill/SKILL.md", + content: "# middle-skill", + }, +] + +const build: Agent.Info = { + name: "build", + mode: "primary", + permission: Permission.fromConfig({ "*": "allow" }), + options: {}, } +const it = testEffect( + SystemPrompt.layer.pipe( + Layer.provide( + Layer.succeed( + Skill.Service, + Skill.Service.of({ + get: (name) => Effect.succeed(skills.find((skill) => skill.name === name)), + all: () => Effect.succeed(skills), + dirs: () => Effect.succeed([]), + available: () => Effect.succeed(skills), + }), + ), + ), + ), +) + describe("session.system", () => { - test("skills output is sorted by name and stable across calls", async () => { - await using tmp = await tmpdir({ - git: true, - init: async (dir) => { - for (const [name, description] of [ - ["zeta-skill", "Zeta skill."], - ["alpha-skill", "Alpha skill."], - ["middle-skill", "Middle skill."], - ]) { - const skillDir = path.join(dir, ".opencode", "skill", name) - await Bun.write( - path.join(skillDir, "SKILL.md"), - `--- -name: ${name} -description: ${description} ---- + it.effect("skills output is sorted by name and stable across calls", () => + Effect.gen(function* () { + const prompt = yield* SystemPrompt.Service + const first = yield* prompt.skills(build) + const second = yield* prompt.skills(build) + const output = first ?? (yield* Effect.fail(new NamedError.Unknown({ message: "missing skills output" }))) -# ${name} -`, - ) - } - }, - }) + expect(first).toBe(second) - const home = process.env.KILO_TEST_HOME - process.env.KILO_TEST_HOME = tmp.path + const alpha = output.indexOf("alpha-skill") + const middle = output.indexOf("middle-skill") + const zeta = output.indexOf("zeta-skill") - try { - await Instance.provide({ - directory: tmp.path, - fn: async () => { - const build = await load(tmp.path, (svc) => svc.get("build")) - const runSkills = Effect.gen(function* () { - const svc = yield* SystemPrompt.Service - return yield* svc.skills(build!) - }).pipe(Effect.provide(SystemPrompt.defaultLayer)) - - const first = await Effect.runPromise(runSkills) - const second = await Effect.runPromise(runSkills) - - expect(first).toBe(second) - - const alpha = first!.indexOf("alpha-skill") - const middle = first!.indexOf("middle-skill") - const zeta = first!.indexOf("zeta-skill") - - expect(alpha).toBeGreaterThan(-1) - expect(middle).toBeGreaterThan(alpha) - expect(zeta).toBeGreaterThan(middle) - }, - }) - } finally { - process.env.KILO_TEST_HOME = home - } - }) + expect(alpha).toBeGreaterThan(-1) + expect(middle).toBeGreaterThan(alpha) + expect(zeta).toBeGreaterThan(middle) + }), + ) }) diff --git a/packages/opencode/test/snapshot/snapshot.test.ts b/packages/opencode/test/snapshot/snapshot.test.ts index b85d570dc5..c3216e1c58 100644 --- a/packages/opencode/test/snapshot/snapshot.test.ts +++ b/packages/opencode/test/snapshot/snapshot.test.ts @@ -6,7 +6,7 @@ import { Effect } from "effect" import { Snapshot } from "../../src/snapshot" import { Instance } from "../../src/project/instance" import { Filesystem } from "@/util/filesystem" -import { provideInstance, tmpdir } from "../fixture/fixture" +import { disposeAllInstances, provideInstance, tmpdir } from "../fixture/fixture" // Git always outputs /-separated paths internally. Snapshot.patch() joins them // with path.join (which produces \ on Windows) then normalizes back to /. @@ -14,7 +14,7 @@ import { provideInstance, tmpdir } from "../fixture/fixture" const fwd = (...parts: string[]) => path.join(...parts).replaceAll("\\", "/") afterEach(async () => { - await Instance.disposeAll() + await disposeAllInstances() }) async function bootstrap() { diff --git a/packages/opencode/test/sync/index.test.ts b/packages/opencode/test/sync/index.test.ts index be43aa9190..1557e1ff5d 100644 --- a/packages/opencode/test/sync/index.test.ts +++ b/packages/opencode/test/sync/index.test.ts @@ -1,16 +1,18 @@ -import { describe, test, expect, beforeEach, afterEach, afterAll } from "bun:test" -import { tmpdir } from "../fixture/fixture" -import { Schema } from "effect" +import { describe, expect, beforeEach, afterEach, afterAll } from "bun:test" +import { provideTmpdirInstance } from "../fixture/fixture" +import { Effect, Layer, Schema } from "effect" +import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" import { Bus } from "../../src/bus" -import { Instance } from "../../src/project/instance" import { SyncEvent } from "../../src/sync" import { Database } from "@/storage/db" import { EventTable } from "../../src/sync/event.sql" -import { Identifier } from "../../src/id/id" +import { MessageID } from "../../src/session/schema" import { Flag } from "@opencode-ai/core/flag/flag" import { initProjectors } from "../../src/server/projectors" +import { testEffect } from "../lib/effect" const original = Flag.KILO_EXPERIMENTAL_WORKSPACES +const it = testEffect(Layer.mergeAll(SyncEvent.defaultLayer, CrossSpawnSpawner.defaultLayer)) beforeEach(() => { Database.close() @@ -22,19 +24,6 @@ afterEach(() => { Flag.KILO_EXPERIMENTAL_WORKSPACES = original }) -function withInstance(fn: () => void | Promise) { - return async () => { - await using tmp = await tmpdir() - - await Instance.provide({ - directory: tmp.path, - fn: async () => { - await fn() - }, - }) - } -} - describe("SyncEvent", () => { function setup() { SyncEvent.reset() @@ -59,179 +48,209 @@ describe("SyncEvent", () => { return { Created, Sent } } + function expectDefect(effect: Effect.Effect, pattern: RegExp) { + return Effect.gen(function* () { + const exit = yield* Effect.exit(effect) + if (exit._tag === "Success") throw new Error("Expected effect to fail") + expect(String(exit.cause)).toMatch(pattern) + }) + } + afterAll(() => { SyncEvent.reset() initProjectors() }) describe("run", () => { - test( + it.live( "inserts event row", - withInstance(() => { - const { Created } = setup() - SyncEvent.run(Created, { id: "evt_1", name: "first" }) - const rows = Database.use((db) => db.select().from(EventTable).all()) - expect(rows).toHaveLength(1) - expect(rows[0].type).toBe("item.created.1") - expect(rows[0].aggregate_id).toBe("evt_1") - }), + provideTmpdirInstance(() => + Effect.gen(function* () { + const { Created } = setup() + yield* SyncEvent.use.run(Created, { id: "evt_1", name: "first" }) + const rows = Database.use((db) => db.select().from(EventTable).all()) + expect(rows).toHaveLength(1) + expect(rows[0].type).toBe("item.created.1") + expect(rows[0].aggregate_id).toBe("evt_1") + }), + ), ) - test( + it.live( "increments seq per aggregate", - withInstance(() => { - const { Created } = setup() - SyncEvent.run(Created, { id: "evt_1", name: "first" }) - SyncEvent.run(Created, { id: "evt_1", name: "second" }) - const rows = Database.use((db) => db.select().from(EventTable).all()) - expect(rows).toHaveLength(2) - expect(rows[1].seq).toBe(rows[0].seq + 1) - }), + provideTmpdirInstance(() => + Effect.gen(function* () { + const { Created } = setup() + yield* SyncEvent.use.run(Created, { id: "evt_1", name: "first" }) + yield* SyncEvent.use.run(Created, { id: "evt_1", name: "second" }) + const rows = Database.use((db) => db.select().from(EventTable).all()) + expect(rows).toHaveLength(2) + expect(rows[1].seq).toBe(rows[0].seq + 1) + }), + ), ) - test( + it.live( "uses custom aggregate field from agg()", - withInstance(() => { - const { Sent } = setup() - SyncEvent.run(Sent, { item_id: "evt_1", to: "james" }) - const rows = Database.use((db) => db.select().from(EventTable).all()) - expect(rows).toHaveLength(1) - expect(rows[0].aggregate_id).toBe("evt_1") - }), + provideTmpdirInstance(() => + Effect.gen(function* () { + const { Sent } = setup() + yield* SyncEvent.use.run(Sent, { item_id: "evt_1", to: "james" }) + const rows = Database.use((db) => db.select().from(EventTable).all()) + expect(rows).toHaveLength(1) + expect(rows[0].aggregate_id).toBe("evt_1") + }), + ), ) - test( + it.live( "emits events", - withInstance(async () => { - const { Created } = setup() - const events: Array<{ - type: string - properties: { id: string; name: string } - }> = [] - const received = new Promise((resolve) => { - Bus.subscribeAll((event) => { + provideTmpdirInstance(() => + Effect.gen(function* () { + const { Created } = setup() + const events: Array<{ + type: string + properties: { id: string; name: string } + }> = [] + let resolve = () => {} + const received = new Promise((done) => { + resolve = done + }) + const dispose = Bus.subscribeAll((event) => { events.push(event) resolve() }) - }) - - SyncEvent.run(Created, { id: "evt_1", name: "test" }) - - await received - expect(events).toHaveLength(1) - expect(events[0]).toEqual({ - type: "item.created", - properties: { - id: "evt_1", - name: "test", - }, - }) - }), + try { + yield* SyncEvent.use.run(Created, { id: "evt_1", name: "test" }) + yield* Effect.promise(() => received) + expect(events).toHaveLength(1) + expect(events[0]).toEqual({ + type: "item.created", + properties: { + id: "evt_1", + name: "test", + }, + }) + } finally { + dispose() + } + }), + ), ) }) describe("replay", () => { - test( + it.live( "inserts event from external payload", - withInstance(() => { - const id = Identifier.descending("message") - SyncEvent.replay({ - id: "evt_1", - type: "item.created.1", - seq: 0, - aggregateID: id, - data: { id, name: "replayed" }, - }) - const rows = Database.use((db) => db.select().from(EventTable).all()) - expect(rows).toHaveLength(1) - expect(rows[0].aggregate_id).toBe(id) - }), - ) - - test( - "throws on sequence mismatch", - withInstance(() => { - const id = Identifier.descending("message") - SyncEvent.replay({ - id: "evt_1", - type: "item.created.1", - seq: 0, - aggregateID: id, - data: { id, name: "first" }, - }) - expect(() => - SyncEvent.replay({ + provideTmpdirInstance(() => + Effect.gen(function* () { + const id = MessageID.ascending() + yield* SyncEvent.use.replay({ id: "evt_1", type: "item.created.1", - seq: 5, - aggregateID: id, - data: { id, name: "bad" }, - }), - ).toThrow(/Sequence mismatch/) - }), - ) - - test( - "throws on unknown event type", - withInstance(() => { - expect(() => - SyncEvent.replay({ - id: "evt_1", - type: "unknown.event.1", seq: 0, - aggregateID: "x", - data: {}, - }), - ).toThrow(/Unknown event type/) - }), + aggregateID: id, + data: { id, name: "replayed" }, + }) + const rows = Database.use((db) => db.select().from(EventTable).all()) + expect(rows).toHaveLength(1) + expect(rows[0].aggregate_id).toBe(id) + }), + ), ) - test( - "replayAll accepts later chunks after the first batch", - withInstance(() => { - const { Created } = setup() - const id = Identifier.descending("message") - - const one = SyncEvent.replayAll([ - { + it.live( + "throws on sequence mismatch", + provideTmpdirInstance(() => + Effect.gen(function* () { + const id = MessageID.ascending() + yield* SyncEvent.use.replay({ id: "evt_1", - type: SyncEvent.versionedType(Created.type, Created.version), + type: "item.created.1", seq: 0, aggregateID: id, data: { id, name: "first" }, - }, - { - id: "evt_2", - type: SyncEvent.versionedType(Created.type, Created.version), - seq: 1, - aggregateID: id, - data: { id, name: "second" }, - }, - ]) + }) + yield* expectDefect( + SyncEvent.use.replay({ + id: "evt_1", + type: "item.created.1", + seq: 5, + aggregateID: id, + data: { id, name: "bad" }, + }), + /Sequence mismatch/, + ) + }), + ), + ) - const two = SyncEvent.replayAll([ - { - id: "evt_3", - type: SyncEvent.versionedType(Created.type, Created.version), - seq: 2, - aggregateID: id, - data: { id, name: "third" }, - }, - { - id: "evt_4", - type: SyncEvent.versionedType(Created.type, Created.version), - seq: 3, - aggregateID: id, - data: { id, name: "fourth" }, - }, - ]) + it.live( + "throws on unknown event type", + provideTmpdirInstance(() => + Effect.gen(function* () { + yield* expectDefect( + SyncEvent.use.replay({ + id: "evt_1", + type: "unknown.event.1", + seq: 0, + aggregateID: "x", + data: {}, + }), + /Unknown event type/, + ) + }), + ), + ) - expect(one).toBe(id) - expect(two).toBe(id) + it.live( + "replayAll accepts later chunks after the first batch", + provideTmpdirInstance(() => + Effect.gen(function* () { + const { Created } = setup() + const id = MessageID.ascending() - const rows = Database.use((db) => db.select().from(EventTable).all()) - expect(rows.map((row) => row.seq)).toEqual([0, 1, 2, 3]) - }), + const one = yield* SyncEvent.use.replayAll([ + { + id: "evt_1", + type: SyncEvent.versionedType(Created.type, Created.version), + seq: 0, + aggregateID: id, + data: { id, name: "first" }, + }, + { + id: "evt_2", + type: SyncEvent.versionedType(Created.type, Created.version), + seq: 1, + aggregateID: id, + data: { id, name: "second" }, + }, + ]) + + const two = yield* SyncEvent.use.replayAll([ + { + id: "evt_3", + type: SyncEvent.versionedType(Created.type, Created.version), + seq: 2, + aggregateID: id, + data: { id, name: "third" }, + }, + { + id: "evt_4", + type: SyncEvent.versionedType(Created.type, Created.version), + seq: 3, + aggregateID: id, + data: { id, name: "fourth" }, + }, + ]) + + expect(one).toBe(id) + expect(two).toBe(id) + + const rows = Database.use((db) => db.select().from(EventTable).all()) + expect(rows.map((row) => row.seq)).toEqual([0, 1, 2, 3]) + }), + ), ) }) }) diff --git a/packages/opencode/test/tool/edit.test.ts b/packages/opencode/test/tool/edit.test.ts index 01dc74bb22..2c381ad047 100644 --- a/packages/opencode/test/tool/edit.test.ts +++ b/packages/opencode/test/tool/edit.test.ts @@ -4,7 +4,7 @@ import fs from "fs/promises" import { Effect, Layer, ManagedRuntime } from "effect" import { EditTool } from "../../src/tool/edit" import { Instance } from "../../src/project/instance" -import { tmpdir } from "../fixture/fixture" +import { disposeAllInstances, tmpdir } from "../fixture/fixture" import { LSP } from "@/lsp/lsp" import { AppFileSystem } from "@opencode-ai/core/filesystem" import { Format } from "../../src/format" @@ -26,7 +26,7 @@ const ctx = { } afterEach(async () => { - await Instance.disposeAll() + await disposeAllInstances() }) const runtime = ManagedRuntime.make( diff --git a/packages/opencode/test/tool/lsp.test.ts b/packages/opencode/test/tool/lsp.test.ts index 8a3189cab9..27623375c2 100644 --- a/packages/opencode/test/tool/lsp.test.ts +++ b/packages/opencode/test/tool/lsp.test.ts @@ -11,11 +11,11 @@ import { MessageID, SessionID } from "../../src/session/schema" import { Tool } from "@/tool/tool" import { Truncate } from "@/tool/truncate" import { LspTool } from "../../src/tool/lsp" -import { provideTmpdirInstance } from "../fixture/fixture" +import { disposeAllInstances, provideTmpdirInstance } from "../fixture/fixture" import { testEffect } from "../lib/effect" afterEach(async () => { - await Instance.disposeAll() + await disposeAllInstances() }) const ctx = { diff --git a/packages/opencode/test/tool/read.test.ts b/packages/opencode/test/tool/read.test.ts index 8e697358ae..489befd76e 100644 --- a/packages/opencode/test/tool/read.test.ts +++ b/packages/opencode/test/tool/read.test.ts @@ -13,13 +13,13 @@ import { ReadTool } from "../../src/tool/read" import { Truncate } from "@/tool/truncate" import { Tool } from "@/tool/tool" import { Filesystem } from "@/util/filesystem" -import { provideInstance, tmpdirScoped } from "../fixture/fixture" +import { disposeAllInstances, provideInstance, tmpdirScoped } from "../fixture/fixture" import { testEffect } from "../lib/effect" const FIXTURES_DIR = path.join(import.meta.dir, "fixtures") afterEach(async () => { - await Instance.disposeAll() + await disposeAllInstances() }) const ctx = { @@ -442,6 +442,24 @@ root_type Monster;` expect(result.output).toContain("table Monster") }), ) + + it.live("falls through unsupported image mime types to text", () => + Effect.gen(function* () { + const dir = yield* tmpdirScoped() + const cases = [ + ["image.bmp", "BM text content"], + ["photo.tiff", "II text content"], + ["photo.avif", "avif text content"], + ] as const + + for (const item of cases) { + yield* put(path.join(dir, item[0]), item[1]) + const result = yield* exec(dir, { filePath: path.join(dir, item[0]) }) + expect(result.attachments).toBeUndefined() + expect(result.output).toContain(item[1]) + } + }), + ) }) describe("tool.read loaded instructions", () => { diff --git a/packages/opencode/test/tool/registry.test.ts b/packages/opencode/test/tool/registry.test.ts index ee28e5aca4..710fb5a3ae 100644 --- a/packages/opencode/test/tool/registry.test.ts +++ b/packages/opencode/test/tool/registry.test.ts @@ -5,7 +5,7 @@ import { Effect, Layer } from "effect" import { Instance } from "../../src/project/instance" import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" import { ToolRegistry } from "@/tool/registry" -import { provideTmpdirInstance, tmpdir } from "../fixture/fixture" // kilocode_change +import { disposeAllInstances, provideTmpdirInstance, tmpdir } from "../fixture/fixture" // kilocode_change import { testEffect } from "../lib/effect" const node = CrossSpawnSpawner.defaultLayer @@ -13,7 +13,7 @@ const node = CrossSpawnSpawner.defaultLayer const it = testEffect(Layer.mergeAll(ToolRegistry.defaultLayer, node)) afterEach(async () => { - await Instance.disposeAll() + await disposeAllInstances() }) describe("tool.registry", () => { diff --git a/packages/opencode/test/tool/skill.test.ts b/packages/opencode/test/tool/skill.test.ts index a59f6bce49..90d96cdcf5 100644 --- a/packages/opencode/test/tool/skill.test.ts +++ b/packages/opencode/test/tool/skill.test.ts @@ -8,7 +8,7 @@ import type { Tool } from "@/tool/tool" import { Instance } from "../../src/project/instance" import { SkillTool } from "../../src/tool/skill" import { ToolRegistry } from "@/tool/registry" -import { provideTmpdirInstance } from "../fixture/fixture" +import { disposeAllInstances, provideTmpdirInstance } from "../fixture/fixture" import { SessionID, MessageID } from "../../src/session/schema" import { testEffect } from "../lib/effect" @@ -23,7 +23,7 @@ const baseCtx: Omit = { } afterEach(async () => { - await Instance.disposeAll() + await disposeAllInstances() }) const node = CrossSpawnSpawner.defaultLayer diff --git a/packages/opencode/test/tool/task.test.ts b/packages/opencode/test/tool/task.test.ts index ebdd7e9cc5..5a3c965d3f 100644 --- a/packages/opencode/test/tool/task.test.ts +++ b/packages/opencode/test/tool/task.test.ts @@ -12,11 +12,11 @@ import { ModelID, ProviderID } from "../../src/provider/schema" import { TaskTool, type TaskPromptOps } from "../../src/tool/task" import { Truncate } from "@/tool/truncate" import { ToolRegistry } from "@/tool/registry" -import { provideTmpdirInstance } from "../fixture/fixture" +import { disposeAllInstances, provideTmpdirInstance } from "../fixture/fixture" import { testEffect } from "../lib/effect" afterEach(async () => { - await Instance.disposeAll() + await disposeAllInstances() }) const ref = { diff --git a/packages/opencode/test/tool/write.test.ts b/packages/opencode/test/tool/write.test.ts index cc9f87100c..4931d2a544 100644 --- a/packages/opencode/test/tool/write.test.ts +++ b/packages/opencode/test/tool/write.test.ts @@ -13,7 +13,7 @@ import { Tool } from "@/tool/tool" import { Agent } from "../../src/agent/agent" import { SessionID, MessageID } from "../../src/session/schema" import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" -import { provideTmpdirInstance } from "../fixture/fixture" +import { disposeAllInstances, provideTmpdirInstance } from "../fixture/fixture" import { testEffect } from "../lib/effect" const ctx = { @@ -28,7 +28,7 @@ const ctx = { } afterEach(async () => { - await Instance.disposeAll() + await disposeAllInstances() }) const it = testEffect( diff --git a/packages/opencode/test/workspace/workspace-restore.test.ts b/packages/opencode/test/workspace/workspace-restore.test.ts index 91f052223f..c6814b6a45 100644 --- a/packages/opencode/test/workspace/workspace-restore.test.ts +++ b/packages/opencode/test/workspace/workspace-restore.test.ts @@ -2,8 +2,8 @@ import { afterEach, beforeEach, describe, expect, mock, spyOn, test } from "bun: import fs from "node:fs/promises" import path from "node:path" import { GlobalBus } from "../../src/bus/global" -import { registerAdaptor } from "../../src/control-plane/adaptors" -import type { WorkspaceAdaptor } from "../../src/control-plane/types" +import { registerAdapter } from "../../src/control-plane/adapters" +import type { WorkspaceAdapter } from "../../src/control-plane/types" import { Workspace } from "../../src/control-plane/workspace" import { AppRuntime } from "../../src/effect/app-runtime" import { Flag } from "@opencode-ai/core/flag/flag" @@ -19,7 +19,7 @@ import { SyncEvent } from "../../src/sync" import { EventTable } from "../../src/sync/event.sql" import * as Log from "@opencode-ai/core/util/log" import { resetDatabase } from "../fixture/db" -import { tmpdir } from "../fixture/fixture" +import { disposeAllInstances, tmpdir } from "../fixture/fixture" void Log.init({ print: false }) @@ -32,7 +32,7 @@ beforeEach(() => { afterEach(async () => { mock.restore() - await Instance.disposeAll() + await disposeAllInstances() Flag.KILO_EXPERIMENTAL_WORKSPACES = original await resetDatabase() }) @@ -53,6 +53,14 @@ function updatePart(part: T) { return AppRuntime.runPromise(SessionNs.Service.use((svc) => svc.updatePart(part))) } +function createWorkspace(input: Workspace.CreateInput) { + return AppRuntime.runPromise(Workspace.Service.use((svc) => svc.create(input))) +} + +function sessionRestore(input: Workspace.SessionRestoreInput) { + return AppRuntime.runPromise(Workspace.Service.use((svc) => svc.sessionRestore(input))) +} + async function user(sessionID: SessionID, text: string) { const msg = await updateMessage({ id: MessageID.ascending(), @@ -71,7 +79,7 @@ async function user(sessionID: SessionID, text: string) { }) } -function remote(dir: string, url: string): WorkspaceAdaptor { +function remote(dir: string, url: string): WorkspaceAdapter { return { name: "remote", description: "remote", @@ -94,7 +102,7 @@ function remote(dir: string, url: string): WorkspaceAdaptor { } } -function local(dir: string): WorkspaceAdaptor { +function local(dir: string): WorkspaceAdapter { return { name: "local", description: "local", @@ -166,8 +174,8 @@ describe("Workspace.sessionRestore", () => { const setup = await Instance.provide({ directory: tmp.path, fn: async () => { - registerAdaptor(Instance.project.id, "worktree", remote(dir, "https://workspace.test/base")) - const space = await Workspace.create({ + registerAdapter(Instance.project.id, "worktree", remote(dir, "https://workspace.test/base")) + const space = await createWorkspace({ type: "worktree", branch: null, extra: null, @@ -185,7 +193,7 @@ describe("Workspace.sessionRestore", () => { .orderBy(asc(EventTable.seq)) .all(), ) - const result = await Workspace.sessionRestore({ + const result = await sessionRestore({ workspaceID: space.id, sessionID: session.id, }) @@ -247,8 +255,8 @@ describe("Workspace.sessionRestore", () => { const setup = await Instance.provide({ directory: tmp.path, fn: async () => { - registerAdaptor(Instance.project.id, "local-restore", local(dir)) - const space = await Workspace.create({ + registerAdapter(Instance.project.id, "local-restore", local(dir)) + const space = await createWorkspace({ type: "local-restore", branch: null, extra: null, @@ -258,7 +266,7 @@ describe("Workspace.sessionRestore", () => { for (let i = 0; i < 6; i++) { await user(session.id, `msg ${i}`) } - const result = await Workspace.sessionRestore({ + const result = await sessionRestore({ workspaceID: space.id, sessionID: session.id, }) diff --git a/packages/opencode/tsconfig.json b/packages/opencode/tsconfig.json index 5cb51012ae..f09fca6878 100644 --- a/packages/opencode/tsconfig.json +++ b/packages/opencode/tsconfig.json @@ -12,13 +12,6 @@ "@/*": ["./src/*"], "@tui/*": ["./src/cli/cmd/tui/*"], "@test/*": ["./test/*"] - }, - "plugins": [ - { - "name": "@effect/language-service", - "transform": "@effect/language-service/transform", - "namespaceImportPackages": ["effect", "@effect/*"] - } - ] + } } } diff --git a/packages/plugin/package.json b/packages/plugin/package.json index 588dc1d7d0..63e2639fd6 100644 --- a/packages/plugin/package.json +++ b/packages/plugin/package.json @@ -22,8 +22,8 @@ "zod": "catalog:" }, "peerDependencies": { - "@opentui/core": ">=0.1.105", - "@opentui/solid": ">=0.1.105" + "@opentui/core": ">=0.2.2", + "@opentui/solid": ">=0.2.2" }, "peerDependenciesMeta": { "@opentui/core": { diff --git a/packages/plugin/src/index.ts b/packages/plugin/src/index.ts index b3c6a9b980..f7c3a1d60c 100644 --- a/packages/plugin/src/index.ts +++ b/packages/plugin/src/index.ts @@ -45,7 +45,7 @@ export type WorkspaceTarget = headers?: HeadersInit } -export type WorkspaceAdaptor = { +export type WorkspaceAdapter = { name: string description: string configure(config: WorkspaceInfo): WorkspaceInfo | Promise @@ -60,7 +60,7 @@ export type PluginInput = { directory: string worktree: string experimental_workspace: { - register(type: string, adaptor: WorkspaceAdaptor): void + register(type: string, adapter: WorkspaceAdapter): void } serverUrl: URL $: BunShell diff --git a/packages/sdk/js/src/v2/gen/sdk.gen.ts b/packages/sdk/js/src/v2/gen/sdk.gen.ts index a86c611425..a9130c8d4f 100644 --- a/packages/sdk/js/src/v2/gen/sdk.gen.ts +++ b/packages/sdk/js/src/v2/gen/sdk.gen.ts @@ -34,7 +34,7 @@ import type { ExperimentalConsoleSwitchOrgResponses, ExperimentalResourceListResponses, ExperimentalSessionListResponses, - ExperimentalWorkspaceAdaptorListResponses, + ExperimentalWorkspaceAdapterListResponses, ExperimentalWorkspaceCreateErrors, ExperimentalWorkspaceCreateResponses, ExperimentalWorkspaceListResponses, @@ -574,11 +574,11 @@ export class App extends HeyApiClient { } } -export class Adaptor extends HeyApiClient { +export class Adapter extends HeyApiClient { /** - * List workspace adaptors + * List workspace adapters * - * List all available workspace adaptors for the current project. + * List all available workspace adapters for the current project. */ public list( parameters?: { @@ -598,8 +598,8 @@ export class Adaptor extends HeyApiClient { }, ], ) - return (options?.client ?? this.client).get({ - url: "/experimental/workspace/adaptor", + return (options?.client ?? this.client).get({ + url: "/experimental/workspace/adapter", ...options, ...params, }) @@ -793,9 +793,9 @@ export class Workspace extends HeyApiClient { }) } - private _adaptor?: Adaptor - get adaptor(): Adaptor { - return (this._adaptor ??= new Adaptor({ client: this.client })) + private _adapter?: Adapter + get adapter(): Adapter { + return (this._adapter ??= new Adapter({ client: this.client })) } } diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts index c00cf8d5b8..1d35fbfca2 100644 --- a/packages/sdk/js/src/v2/gen/types.gen.ts +++ b/packages/sdk/js/src/v2/gen/types.gen.ts @@ -629,6 +629,38 @@ export type EventKiloSessionsRemoteStatusChanged = { } } +export type EventWorkspaceReady = { + type: "workspace.ready" + properties: { + name: string + } +} + +export type EventWorkspaceFailed = { + type: "workspace.failed" + properties: { + message: string + } +} + +export type EventWorkspaceRestore = { + type: "workspace.restore" + properties: { + workspaceID: string + sessionID: string + total: number + step: number + } +} + +export type EventWorkspaceStatus = { + type: "workspace.status" + properties: { + workspaceID: string + status: "connected" | "connecting" | "disconnected" | "error" + } +} + export type EventWorktreeReady = { type: "worktree.ready" properties: { @@ -683,38 +715,6 @@ export type EventPtyDeleted = { } } -export type EventWorkspaceReady = { - type: "workspace.ready" - properties: { - name: string - } -} - -export type EventWorkspaceFailed = { - type: "workspace.failed" - properties: { - message: string - } -} - -export type EventWorkspaceRestore = { - type: "workspace.restore" - properties: { - workspaceID: string - sessionID: string - total: number - step: number - } -} - -export type EventWorkspaceStatus = { - type: "workspace.status" - properties: { - workspaceID: string - status: "connected" | "connecting" | "disconnected" | "error" - } -} - export type OutputFormatText = { type: "text" } @@ -1360,16 +1360,16 @@ export type GlobalEvent = { | EventKilocodeAgentManagerStart | EventVcsBranchUpdated | EventKiloSessionsRemoteStatusChanged + | EventWorkspaceReady + | EventWorkspaceFailed + | EventWorkspaceRestore + | EventWorkspaceStatus | EventWorktreeReady | EventWorktreeFailed | EventPtyCreated | EventPtyUpdated | EventPtyExited | EventPtyDeleted - | EventWorkspaceReady - | EventWorkspaceFailed - | EventWorkspaceRestore - | EventWorkspaceStatus | EventMessageUpdated | EventMessageRemoved | EventMessagePartUpdated @@ -2506,16 +2506,16 @@ export type Event = | EventKilocodeAgentManagerStart | EventVcsBranchUpdated | EventKiloSessionsRemoteStatusChanged + | EventWorkspaceReady + | EventWorkspaceFailed + | EventWorkspaceRestore + | EventWorkspaceStatus | EventWorktreeReady | EventWorktreeFailed | EventPtyCreated | EventPtyUpdated | EventPtyExited | EventPtyDeleted - | EventWorkspaceReady - | EventWorkspaceFailed - | EventWorkspaceRestore - | EventWorkspaceStatus | EventMessageUpdated | EventMessageRemoved | EventMessagePartUpdated @@ -2891,19 +2891,19 @@ export type AppLogResponses = { export type AppLogResponse = AppLogResponses[keyof AppLogResponses] -export type ExperimentalWorkspaceAdaptorListData = { +export type ExperimentalWorkspaceAdapterListData = { body?: never path?: never query?: { directory?: string workspace?: string } - url: "/experimental/workspace/adaptor" + url: "/experimental/workspace/adapter" } -export type ExperimentalWorkspaceAdaptorListResponses = { +export type ExperimentalWorkspaceAdapterListResponses = { /** - * Workspace adaptors + * Workspace adapters */ 200: Array<{ type: string @@ -2912,8 +2912,8 @@ export type ExperimentalWorkspaceAdaptorListResponses = { }> } -export type ExperimentalWorkspaceAdaptorListResponse = - ExperimentalWorkspaceAdaptorListResponses[keyof ExperimentalWorkspaceAdaptorListResponses] +export type ExperimentalWorkspaceAdapterListResponse = + ExperimentalWorkspaceAdapterListResponses[keyof ExperimentalWorkspaceAdapterListResponses] export type ExperimentalWorkspaceListData = { body?: never