mirror of
https://github.com/anomalyco/opencode.git
synced 2026-04-10 07:54:51 +00:00
Compare commits
2 Commits
server-cle
...
kit/tool-e
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
882a821aab | ||
|
|
91786d2fc1 |
1
.github/workflows/publish.yml
vendored
1
.github/workflows/publish.yml
vendored
@@ -8,7 +8,6 @@ on:
|
||||
- dev
|
||||
- beta
|
||||
- snapshot-*
|
||||
- server-cleanup
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
bump:
|
||||
|
||||
@@ -39,11 +39,6 @@
|
||||
"bun": "./src/pty/pty.bun.ts",
|
||||
"node": "./src/pty/pty.node.ts",
|
||||
"default": "./src/pty/pty.bun.ts"
|
||||
},
|
||||
"#hono": {
|
||||
"bun": "./src/server/adapter.bun.ts",
|
||||
"node": "./src/server/adapter.node.ts",
|
||||
"default": "./src/server/adapter.bun.ts"
|
||||
}
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@@ -398,11 +398,13 @@ export namespace Agent {
|
||||
}),
|
||||
)
|
||||
|
||||
export const defaultLayer = layer.pipe(
|
||||
Layer.provide(Provider.defaultLayer),
|
||||
Layer.provide(Auth.defaultLayer),
|
||||
Layer.provide(Config.defaultLayer),
|
||||
Layer.provide(Skill.defaultLayer),
|
||||
export const defaultLayer = Layer.suspend(() =>
|
||||
layer.pipe(
|
||||
Layer.provide(Provider.defaultLayer),
|
||||
Layer.provide(Auth.defaultLayer),
|
||||
Layer.provide(Config.defaultLayer),
|
||||
Layer.provide(Skill.defaultLayer),
|
||||
),
|
||||
)
|
||||
|
||||
const { runPromise } = makeRuntime(Service, defaultLayer)
|
||||
|
||||
@@ -145,18 +145,12 @@ export const TuiThreadCommand = cmd({
|
||||
),
|
||||
})
|
||||
worker.onerror = (e) => {
|
||||
Log.Default.error("thread error", {
|
||||
message: e.message,
|
||||
filename: e.filename,
|
||||
lineno: e.lineno,
|
||||
colno: e.colno,
|
||||
error: e.error,
|
||||
})
|
||||
Log.Default.error(e)
|
||||
}
|
||||
|
||||
const client = Rpc.client<typeof rpc>(worker)
|
||||
const error = (e: unknown) => {
|
||||
Log.Default.error("process error", { error: errorMessage(e) })
|
||||
Log.Default.error(e)
|
||||
}
|
||||
const reload = () => {
|
||||
client.call("reload", undefined).catch((err) => {
|
||||
|
||||
@@ -4,6 +4,7 @@ import { pathToFileURL } from "url"
|
||||
import os from "os"
|
||||
import { Process } from "../util/process"
|
||||
import z from "zod"
|
||||
import { ModelsDev } from "../provider/models"
|
||||
import { mergeDeep, pipe, unique } from "remeda"
|
||||
import { Global } from "../global"
|
||||
import fsNode from "fs/promises"
|
||||
|
||||
@@ -11,7 +11,6 @@ import path from "path"
|
||||
import z from "zod"
|
||||
import { Global } from "../global"
|
||||
import { Instance } from "../project/instance"
|
||||
import { Filesystem } from "../util/filesystem"
|
||||
import { Log } from "../util/log"
|
||||
import { Protected } from "./protected"
|
||||
import { Ripgrep } from "./ripgrep"
|
||||
@@ -344,6 +343,7 @@ export namespace File {
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const appFs = yield* AppFileSystem.Service
|
||||
const git = yield* Git.Service
|
||||
|
||||
const state = yield* InstanceState.make<State>(
|
||||
Effect.fn("File.state")(() =>
|
||||
@@ -410,6 +410,10 @@ export namespace File {
|
||||
cachedScan = yield* Effect.cached(scan().pipe(Effect.catchCause(() => Effect.void)))
|
||||
})
|
||||
|
||||
const gitText = Effect.fnUntraced(function* (args: string[]) {
|
||||
return (yield* git.run(args, { cwd: Instance.directory })).text()
|
||||
})
|
||||
|
||||
const init = Effect.fn("File.init")(function* () {
|
||||
yield* ensure()
|
||||
})
|
||||
@@ -417,100 +421,87 @@ export namespace File {
|
||||
const status = Effect.fn("File.status")(function* () {
|
||||
if (Instance.project.vcs !== "git") return []
|
||||
|
||||
return yield* Effect.promise(async () => {
|
||||
const diffOutput = (
|
||||
await Git.run(["-c", "core.fsmonitor=false", "-c", "core.quotepath=false", "diff", "--numstat", "HEAD"], {
|
||||
cwd: Instance.directory,
|
||||
const diffOutput = yield* gitText([
|
||||
"-c",
|
||||
"core.fsmonitor=false",
|
||||
"-c",
|
||||
"core.quotepath=false",
|
||||
"diff",
|
||||
"--numstat",
|
||||
"HEAD",
|
||||
])
|
||||
|
||||
const changed: File.Info[] = []
|
||||
|
||||
if (diffOutput.trim()) {
|
||||
for (const line of diffOutput.trim().split("\n")) {
|
||||
const [added, removed, file] = line.split("\t")
|
||||
changed.push({
|
||||
path: file,
|
||||
added: added === "-" ? 0 : parseInt(added, 10),
|
||||
removed: removed === "-" ? 0 : parseInt(removed, 10),
|
||||
status: "modified",
|
||||
})
|
||||
).text()
|
||||
|
||||
const changed: File.Info[] = []
|
||||
|
||||
if (diffOutput.trim()) {
|
||||
for (const line of diffOutput.trim().split("\n")) {
|
||||
const [added, removed, file] = line.split("\t")
|
||||
changed.push({
|
||||
path: file,
|
||||
added: added === "-" ? 0 : parseInt(added, 10),
|
||||
removed: removed === "-" ? 0 : parseInt(removed, 10),
|
||||
status: "modified",
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const untrackedOutput = (
|
||||
await Git.run(
|
||||
[
|
||||
"-c",
|
||||
"core.fsmonitor=false",
|
||||
"-c",
|
||||
"core.quotepath=false",
|
||||
"ls-files",
|
||||
"--others",
|
||||
"--exclude-standard",
|
||||
],
|
||||
{
|
||||
cwd: Instance.directory,
|
||||
},
|
||||
)
|
||||
).text()
|
||||
const untrackedOutput = yield* gitText([
|
||||
"-c",
|
||||
"core.fsmonitor=false",
|
||||
"-c",
|
||||
"core.quotepath=false",
|
||||
"ls-files",
|
||||
"--others",
|
||||
"--exclude-standard",
|
||||
])
|
||||
|
||||
if (untrackedOutput.trim()) {
|
||||
for (const file of untrackedOutput.trim().split("\n")) {
|
||||
try {
|
||||
const content = await Filesystem.readText(path.join(Instance.directory, file))
|
||||
changed.push({
|
||||
path: file,
|
||||
added: content.split("\n").length,
|
||||
removed: 0,
|
||||
status: "added",
|
||||
})
|
||||
} catch {
|
||||
continue
|
||||
}
|
||||
}
|
||||
if (untrackedOutput.trim()) {
|
||||
for (const file of untrackedOutput.trim().split("\n")) {
|
||||
const content = yield* appFs
|
||||
.readFileString(path.join(Instance.directory, file))
|
||||
.pipe(Effect.catch(() => Effect.succeed<string | undefined>(undefined)))
|
||||
if (content === undefined) continue
|
||||
changed.push({
|
||||
path: file,
|
||||
added: content.split("\n").length,
|
||||
removed: 0,
|
||||
status: "added",
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const deletedOutput = (
|
||||
await Git.run(
|
||||
[
|
||||
"-c",
|
||||
"core.fsmonitor=false",
|
||||
"-c",
|
||||
"core.quotepath=false",
|
||||
"diff",
|
||||
"--name-only",
|
||||
"--diff-filter=D",
|
||||
"HEAD",
|
||||
],
|
||||
{
|
||||
cwd: Instance.directory,
|
||||
},
|
||||
)
|
||||
).text()
|
||||
const deletedOutput = yield* gitText([
|
||||
"-c",
|
||||
"core.fsmonitor=false",
|
||||
"-c",
|
||||
"core.quotepath=false",
|
||||
"diff",
|
||||
"--name-only",
|
||||
"--diff-filter=D",
|
||||
"HEAD",
|
||||
])
|
||||
|
||||
if (deletedOutput.trim()) {
|
||||
for (const file of deletedOutput.trim().split("\n")) {
|
||||
changed.push({
|
||||
path: file,
|
||||
added: 0,
|
||||
removed: 0,
|
||||
status: "deleted",
|
||||
})
|
||||
}
|
||||
if (deletedOutput.trim()) {
|
||||
for (const file of deletedOutput.trim().split("\n")) {
|
||||
changed.push({
|
||||
path: file,
|
||||
added: 0,
|
||||
removed: 0,
|
||||
status: "deleted",
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return changed.map((item) => {
|
||||
const full = path.isAbsolute(item.path) ? item.path : path.join(Instance.directory, item.path)
|
||||
return {
|
||||
...item,
|
||||
path: path.relative(Instance.directory, full),
|
||||
}
|
||||
})
|
||||
return changed.map((item) => {
|
||||
const full = path.isAbsolute(item.path) ? item.path : path.join(Instance.directory, item.path)
|
||||
return {
|
||||
...item,
|
||||
path: path.relative(Instance.directory, full),
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
const read = Effect.fn("File.read")(function* (file: string) {
|
||||
const read: Interface["read"] = Effect.fn("File.read")(function* (file: string) {
|
||||
using _ = log.time("read", { file })
|
||||
const full = path.join(Instance.directory, file)
|
||||
|
||||
@@ -558,27 +549,19 @@ export namespace File {
|
||||
)
|
||||
|
||||
if (Instance.project.vcs === "git") {
|
||||
return yield* Effect.promise(async (): Promise<File.Content> => {
|
||||
let diff = (
|
||||
await Git.run(["-c", "core.fsmonitor=false", "diff", "--", file], { cwd: Instance.directory })
|
||||
).text()
|
||||
if (!diff.trim()) {
|
||||
diff = (
|
||||
await Git.run(["-c", "core.fsmonitor=false", "diff", "--staged", "--", file], {
|
||||
cwd: Instance.directory,
|
||||
})
|
||||
).text()
|
||||
}
|
||||
if (diff.trim()) {
|
||||
const original = (await Git.run(["show", `HEAD:${file}`], { cwd: Instance.directory })).text()
|
||||
const patch = structuredPatch(file, file, original, content, "old", "new", {
|
||||
context: Infinity,
|
||||
ignoreWhitespace: true,
|
||||
})
|
||||
return { type: "text", content, patch, diff: formatPatch(patch) }
|
||||
}
|
||||
return { type: "text", content }
|
||||
})
|
||||
let diff = yield* gitText(["-c", "core.fsmonitor=false", "diff", "--", file])
|
||||
if (!diff.trim()) {
|
||||
diff = yield* gitText(["-c", "core.fsmonitor=false", "diff", "--staged", "--", file])
|
||||
}
|
||||
if (diff.trim()) {
|
||||
const original = yield* git.show(Instance.directory, "HEAD", file)
|
||||
const patch = structuredPatch(file, file, original, content, "old", "new", {
|
||||
context: Infinity,
|
||||
ignoreWhitespace: true,
|
||||
})
|
||||
return { type: "text" as const, content, patch, diff: formatPatch(patch) }
|
||||
}
|
||||
return { type: "text" as const, content }
|
||||
}
|
||||
|
||||
return { type: "text" as const, content }
|
||||
@@ -660,7 +643,7 @@ export namespace File {
|
||||
}),
|
||||
)
|
||||
|
||||
export const defaultLayer = layer.pipe(Layer.provide(AppFileSystem.defaultLayer))
|
||||
export const defaultLayer = layer.pipe(Layer.provide(AppFileSystem.defaultLayer), Layer.provide(Git.defaultLayer))
|
||||
|
||||
const { runPromise } = makeRuntime(Service, defaultLayer)
|
||||
|
||||
|
||||
@@ -71,6 +71,7 @@ export namespace FileWatcher {
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const config = yield* Config.Service
|
||||
const git = yield* Git.Service
|
||||
|
||||
const state = yield* InstanceState.make(
|
||||
Effect.fn("FileWatcher.state")(
|
||||
@@ -131,11 +132,9 @@ export namespace FileWatcher {
|
||||
}
|
||||
|
||||
if (Instance.project.vcs === "git") {
|
||||
const result = yield* Effect.promise(() =>
|
||||
Git.run(["rev-parse", "--git-dir"], {
|
||||
cwd: Instance.project.worktree,
|
||||
}),
|
||||
)
|
||||
const result = yield* git.run(["rev-parse", "--git-dir"], {
|
||||
cwd: Instance.project.worktree,
|
||||
})
|
||||
const vcsDir =
|
||||
result.exitCode === 0 ? path.resolve(Instance.project.worktree, result.text().trim()) : undefined
|
||||
if (vcsDir && !cfgIgnores.includes(".git") && !cfgIgnores.includes(vcsDir)) {
|
||||
@@ -161,7 +160,7 @@ export namespace FileWatcher {
|
||||
}),
|
||||
)
|
||||
|
||||
export const defaultLayer = layer.pipe(Layer.provide(Config.defaultLayer))
|
||||
export const defaultLayer = layer.pipe(Layer.provide(Config.defaultLayer), Layer.provide(Git.defaultLayer))
|
||||
|
||||
const { runPromise } = makeRuntime(Service, defaultLayer)
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ import { Log } from "../util/log"
|
||||
import { createOpencodeClient } from "@opencode-ai/sdk"
|
||||
import { Flag } from "../flag/flag"
|
||||
import { CodexAuthPlugin } from "./codex"
|
||||
import { Session } from "../session"
|
||||
import { NamedError } from "@opencode-ai/util/error"
|
||||
import { CopilotAuthPlugin } from "./github-copilot/copilot"
|
||||
import { gitlabAuthPlugin as GitlabAuthPlugin } from "opencode-gitlab-auth"
|
||||
@@ -82,8 +83,7 @@ export namespace Plugin {
|
||||
}
|
||||
|
||||
function publishPluginError(bus: Bus.Interface, message: string) {
|
||||
// TODO: make proper events for this
|
||||
// Effect.runFork(bus.publish(Session.Event.Error, { error: new NamedError.Unknown({ message }).toObject() }))
|
||||
Effect.runFork(bus.publish(Session.Event.Error, { error: new NamedError.Unknown({ message }).toObject() }))
|
||||
}
|
||||
|
||||
async function applyPlugin(load: PluginLoader.Loaded, input: PluginInput, hooks: Hooks[]) {
|
||||
@@ -119,7 +119,7 @@ export namespace Plugin {
|
||||
Authorization: `Basic ${Buffer.from(`${Flag.OPENCODE_SERVER_USERNAME ?? "opencode"}:${Flag.OPENCODE_SERVER_PASSWORD}`).toString("base64")}`,
|
||||
}
|
||||
: undefined,
|
||||
fetch: async (...args) => (await Server.Default()).app.fetch(...args),
|
||||
fetch: async (...args) => Server.Default().app.fetch(...args),
|
||||
})
|
||||
const cfg = yield* config.get()
|
||||
const input: PluginInput = {
|
||||
@@ -205,15 +205,13 @@ export namespace Plugin {
|
||||
return message
|
||||
},
|
||||
}).pipe(
|
||||
Effect.catch(() => {
|
||||
// TODO: make proper events for this
|
||||
// bus.publish(Session.Event.Error, {
|
||||
// error: new NamedError.Unknown({
|
||||
// message: `Failed to load plugin ${load.spec}: ${message}`,
|
||||
// }).toObject(),
|
||||
// })
|
||||
return Effect.void
|
||||
}),
|
||||
Effect.catch((message) =>
|
||||
bus.publish(Session.Event.Error, {
|
||||
error: new NamedError.Unknown({
|
||||
message: `Failed to load plugin ${load.spec}: ${message}`,
|
||||
}).toObject(),
|
||||
}),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,40 +0,0 @@
|
||||
import type { Hono } from "hono"
|
||||
import { createBunWebSocket } from "hono/bun"
|
||||
import type { Adapter } from "./adapter"
|
||||
|
||||
export const adapter: Adapter = {
|
||||
create(app: Hono) {
|
||||
const ws = createBunWebSocket()
|
||||
return {
|
||||
upgradeWebSocket: ws.upgradeWebSocket,
|
||||
async listen(opts) {
|
||||
const args = {
|
||||
fetch: app.fetch,
|
||||
hostname: opts.hostname,
|
||||
idleTimeout: 0,
|
||||
websocket: ws.websocket,
|
||||
} as const
|
||||
const start = (port: number) => {
|
||||
try {
|
||||
return Bun.serve({ ...args, port })
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
}
|
||||
const server = opts.port === 0 ? (start(4096) ?? start(0)) : start(opts.port)
|
||||
if (!server) {
|
||||
throw new Error(`Failed to start server on port ${opts.port}`)
|
||||
}
|
||||
if (!server.port) {
|
||||
throw new Error(`Failed to resolve server address for port ${opts.port}`)
|
||||
}
|
||||
return {
|
||||
port: server.port,
|
||||
stop(close?: boolean) {
|
||||
return Promise.resolve(server.stop(close))
|
||||
},
|
||||
}
|
||||
},
|
||||
}
|
||||
},
|
||||
}
|
||||
@@ -1,66 +0,0 @@
|
||||
import { createAdaptorServer, type ServerType } from "@hono/node-server"
|
||||
import { createNodeWebSocket } from "@hono/node-ws"
|
||||
import type { Hono } from "hono"
|
||||
import type { Adapter } from "./adapter"
|
||||
|
||||
export const adapter: Adapter = {
|
||||
create(app: Hono) {
|
||||
const ws = createNodeWebSocket({ app })
|
||||
return {
|
||||
upgradeWebSocket: ws.upgradeWebSocket,
|
||||
async listen(opts) {
|
||||
const start = (port: number) =>
|
||||
new Promise<ServerType>((resolve, reject) => {
|
||||
const server = createAdaptorServer({ fetch: app.fetch })
|
||||
ws.injectWebSocket(server)
|
||||
const fail = (err: Error) => {
|
||||
cleanup()
|
||||
reject(err)
|
||||
}
|
||||
const ready = () => {
|
||||
cleanup()
|
||||
resolve(server)
|
||||
}
|
||||
const cleanup = () => {
|
||||
server.off("error", fail)
|
||||
server.off("listening", ready)
|
||||
}
|
||||
server.once("error", fail)
|
||||
server.once("listening", ready)
|
||||
server.listen(port, opts.hostname)
|
||||
})
|
||||
|
||||
const server = opts.port === 0 ? await start(4096).catch(() => start(0)) : await start(opts.port)
|
||||
const addr = server.address()
|
||||
if (!addr || typeof addr === "string") {
|
||||
throw new Error(`Failed to resolve server address for port ${opts.port}`)
|
||||
}
|
||||
|
||||
let closing: Promise<void> | undefined
|
||||
return {
|
||||
port: addr.port,
|
||||
stop(close?: boolean) {
|
||||
closing ??= new Promise((resolve, reject) => {
|
||||
server.close((err) => {
|
||||
if (err) {
|
||||
reject(err)
|
||||
return
|
||||
}
|
||||
resolve()
|
||||
})
|
||||
if (close) {
|
||||
if ("closeAllConnections" in server && typeof server.closeAllConnections === "function") {
|
||||
server.closeAllConnections()
|
||||
}
|
||||
if ("closeIdleConnections" in server && typeof server.closeIdleConnections === "function") {
|
||||
server.closeIdleConnections()
|
||||
}
|
||||
}
|
||||
})
|
||||
return closing
|
||||
},
|
||||
}
|
||||
},
|
||||
}
|
||||
},
|
||||
}
|
||||
@@ -1,21 +0,0 @@
|
||||
import type { Hono } from "hono"
|
||||
import type { UpgradeWebSocket } from "hono/ws"
|
||||
|
||||
export type Opts = {
|
||||
port: number
|
||||
hostname: string
|
||||
}
|
||||
|
||||
export type Listener = {
|
||||
port: number
|
||||
stop: (close?: boolean) => Promise<void>
|
||||
}
|
||||
|
||||
export interface Runtime {
|
||||
upgradeWebSocket: UpgradeWebSocket
|
||||
listen(opts: Opts): Promise<Listener>
|
||||
}
|
||||
|
||||
export interface Adapter {
|
||||
create(app: Hono): Runtime
|
||||
}
|
||||
@@ -1,150 +0,0 @@
|
||||
import { Auth } from "@/auth"
|
||||
import { Log } from "@/util/log"
|
||||
import { ProviderID } from "@/provider/schema"
|
||||
import { Hono } from "hono"
|
||||
import { describeRoute, resolver, validator, openAPIRouteHandler } from "hono-openapi"
|
||||
import z from "zod"
|
||||
import { errors } from "../error"
|
||||
import { GlobalRoutes } from "../instance/global"
|
||||
|
||||
export function ControlPlaneRoutes(): Hono {
|
||||
const app = new Hono()
|
||||
return app
|
||||
.route("/global", GlobalRoutes())
|
||||
.put(
|
||||
"/auth/:providerID",
|
||||
describeRoute({
|
||||
summary: "Set auth credentials",
|
||||
description: "Set authentication credentials",
|
||||
operationId: "auth.set",
|
||||
responses: {
|
||||
200: {
|
||||
description: "Successfully set authentication credentials",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(z.boolean()),
|
||||
},
|
||||
},
|
||||
},
|
||||
...errors(400),
|
||||
},
|
||||
}),
|
||||
validator(
|
||||
"param",
|
||||
z.object({
|
||||
providerID: ProviderID.zod,
|
||||
}),
|
||||
),
|
||||
validator("json", Auth.Info.zod),
|
||||
async (c) => {
|
||||
const providerID = c.req.valid("param").providerID
|
||||
const info = c.req.valid("json")
|
||||
await Auth.set(providerID, info)
|
||||
return c.json(true)
|
||||
},
|
||||
)
|
||||
.delete(
|
||||
"/auth/:providerID",
|
||||
describeRoute({
|
||||
summary: "Remove auth credentials",
|
||||
description: "Remove authentication credentials",
|
||||
operationId: "auth.remove",
|
||||
responses: {
|
||||
200: {
|
||||
description: "Successfully removed authentication credentials",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(z.boolean()),
|
||||
},
|
||||
},
|
||||
},
|
||||
...errors(400),
|
||||
},
|
||||
}),
|
||||
validator(
|
||||
"param",
|
||||
z.object({
|
||||
providerID: ProviderID.zod,
|
||||
}),
|
||||
),
|
||||
async (c) => {
|
||||
const providerID = c.req.valid("param").providerID
|
||||
await Auth.remove(providerID)
|
||||
return c.json(true)
|
||||
},
|
||||
)
|
||||
.get(
|
||||
"/doc",
|
||||
openAPIRouteHandler(app, {
|
||||
documentation: {
|
||||
info: {
|
||||
title: "opencode",
|
||||
version: "0.0.3",
|
||||
description: "opencode api",
|
||||
},
|
||||
openapi: "3.1.1",
|
||||
},
|
||||
}),
|
||||
)
|
||||
.use(
|
||||
validator(
|
||||
"query",
|
||||
z.object({
|
||||
directory: z.string().optional(),
|
||||
workspace: z.string().optional(),
|
||||
}),
|
||||
),
|
||||
)
|
||||
.post(
|
||||
"/log",
|
||||
describeRoute({
|
||||
summary: "Write log",
|
||||
description: "Write a log entry to the server logs with specified level and metadata.",
|
||||
operationId: "app.log",
|
||||
responses: {
|
||||
200: {
|
||||
description: "Log entry written successfully",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(z.boolean()),
|
||||
},
|
||||
},
|
||||
},
|
||||
...errors(400),
|
||||
},
|
||||
}),
|
||||
validator(
|
||||
"json",
|
||||
z.object({
|
||||
service: z.string().meta({ description: "Service name for the log entry" }),
|
||||
level: z.enum(["debug", "info", "error", "warn"]).meta({ description: "Log level" }),
|
||||
message: z.string().meta({ description: "Log message" }),
|
||||
extra: z
|
||||
.record(z.string(), z.any())
|
||||
.optional()
|
||||
.meta({ description: "Additional metadata for the log entry" }),
|
||||
}),
|
||||
),
|
||||
async (c) => {
|
||||
const { service, level, message, extra } = c.req.valid("json")
|
||||
const logger = Log.create({ service })
|
||||
|
||||
switch (level) {
|
||||
case "debug":
|
||||
logger.debug(message, extra)
|
||||
break
|
||||
case "info":
|
||||
logger.info(message, extra)
|
||||
break
|
||||
case "error":
|
||||
logger.error(message, extra)
|
||||
break
|
||||
case "warn":
|
||||
logger.warn(message, extra)
|
||||
break
|
||||
}
|
||||
|
||||
return c.json(true)
|
||||
},
|
||||
)
|
||||
}
|
||||
@@ -1,32 +1,52 @@
|
||||
import { describeRoute, resolver, validator } from "hono-openapi"
|
||||
import { Hono } from "hono"
|
||||
import { proxy } from "hono/proxy"
|
||||
import type { UpgradeWebSocket } from "hono/ws"
|
||||
import z from "zod"
|
||||
import { Format } from "../../format"
|
||||
import { TuiRoutes } from "./tui"
|
||||
import { Instance } from "../../project/instance"
|
||||
import { Vcs } from "../../project/vcs"
|
||||
import { Agent } from "../../agent/agent"
|
||||
import { Skill } from "../../skill"
|
||||
import { Global } from "../../global"
|
||||
import { LSP } from "../../lsp"
|
||||
import { Command } from "../../command"
|
||||
import { QuestionRoutes } from "./question"
|
||||
import { PermissionRoutes } from "./permission"
|
||||
import { ProjectRoutes } from "./project"
|
||||
import { SessionRoutes } from "./session"
|
||||
import { PtyRoutes } from "./pty"
|
||||
import { McpRoutes } from "./mcp"
|
||||
import { FileRoutes } from "./file"
|
||||
import { ConfigRoutes } from "./config"
|
||||
import { ExperimentalRoutes } from "./experimental"
|
||||
import { ProviderRoutes } from "./provider"
|
||||
import { EventRoutes } from "./event"
|
||||
import { WorkspaceRouterMiddleware } from "./middleware"
|
||||
import { createHash } from "node:crypto"
|
||||
import * as fs from "node:fs/promises"
|
||||
import { Log } from "../util/log"
|
||||
import { Format } from "../format"
|
||||
import { TuiRoutes } from "./routes/tui"
|
||||
import { Instance } from "../project/instance"
|
||||
import { Vcs } from "../project/vcs"
|
||||
import { Agent } from "../agent/agent"
|
||||
import { Skill } from "../skill"
|
||||
import { Global } from "../global"
|
||||
import { LSP } from "../lsp"
|
||||
import { Command } from "../command"
|
||||
import { Flag } from "../flag/flag"
|
||||
import { QuestionRoutes } from "./routes/question"
|
||||
import { PermissionRoutes } from "./routes/permission"
|
||||
import { Snapshot } from "@/snapshot"
|
||||
import { ProjectRoutes } from "./routes/project"
|
||||
import { SessionRoutes } from "./routes/session"
|
||||
import { PtyRoutes } from "./routes/pty"
|
||||
import { McpRoutes } from "./routes/mcp"
|
||||
import { FileRoutes } from "./routes/file"
|
||||
import { ConfigRoutes } from "./routes/config"
|
||||
import { ExperimentalRoutes } from "./routes/experimental"
|
||||
import { ProviderRoutes } from "./routes/provider"
|
||||
import { EventRoutes } from "./routes/event"
|
||||
import { errorHandler } from "./middleware"
|
||||
import { getMimeType } from "hono/utils/mime"
|
||||
|
||||
export const InstanceRoutes = (upgrade: UpgradeWebSocket): Hono =>
|
||||
new Hono()
|
||||
.use(WorkspaceRouterMiddleware(upgrade))
|
||||
const log = Log.create({ service: "server" })
|
||||
|
||||
const embeddedUIPromise = Flag.OPENCODE_DISABLE_EMBEDDED_WEB_UI
|
||||
? Promise.resolve(null)
|
||||
: // @ts-expect-error - generated file at build time
|
||||
import("opencode-web-ui.gen.ts").then((module) => module.default as Record<string, string>).catch(() => null)
|
||||
|
||||
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 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:`
|
||||
|
||||
export const InstanceRoutes = (upgrade: UpgradeWebSocket, app: Hono = new Hono()) =>
|
||||
app
|
||||
.onError(errorHandler(log))
|
||||
.route("/project", ProjectRoutes())
|
||||
.route("/pty", PtyRoutes(upgrade))
|
||||
.route("/config", ConfigRoutes())
|
||||
@@ -260,3 +280,39 @@ export const InstanceRoutes = (upgrade: UpgradeWebSocket): Hono =>
|
||||
return c.json(await Format.status())
|
||||
},
|
||||
)
|
||||
.all("/*", async (c) => {
|
||||
const embeddedWebUI = await embeddedUIPromise
|
||||
const path = c.req.path
|
||||
|
||||
if (embeddedWebUI) {
|
||||
const match = embeddedWebUI[path.replace(/^\//, "")] ?? embeddedWebUI["index.html"] ?? null
|
||||
if (!match) return c.json({ error: "Not Found" }, 404)
|
||||
|
||||
if (await fs.exists(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)
|
||||
}
|
||||
} else {
|
||||
const response = await proxy(`https://app.opencode.ai${path}`, {
|
||||
...c.req,
|
||||
headers: {
|
||||
...c.req.raw.headers,
|
||||
host: "app.opencode.ai",
|
||||
},
|
||||
})
|
||||
const match = response.headers.get("content-type")?.includes("text/html")
|
||||
? (await response.clone().text()).match(
|
||||
/<script\b(?![^>]*\bsrc\s*=)[^>]*\bid=(['"])oc-theme-preload-script\1[^>]*>([\s\S]*?)<\/script>/i,
|
||||
)
|
||||
: undefined
|
||||
const hash = match ? createHash("sha256").update(match[2]).digest("base64") : ""
|
||||
response.headers.set("Content-Security-Policy", csp(hash))
|
||||
return response
|
||||
}
|
||||
})
|
||||
@@ -3,90 +3,31 @@ import { NamedError } from "@opencode-ai/util/error"
|
||||
import { NotFoundError } from "../storage/db"
|
||||
import { Session } from "../session"
|
||||
import type { ContentfulStatusCode } from "hono/utils/http-status"
|
||||
import type { ErrorHandler, MiddlewareHandler } from "hono"
|
||||
import type { ErrorHandler } from "hono"
|
||||
import { HTTPException } from "hono/http-exception"
|
||||
import { Log } from "../util/log"
|
||||
import { Flag } from "@/flag/flag"
|
||||
import { basicAuth } from "hono/basic-auth"
|
||||
import { cors } from "hono/cors"
|
||||
import { compress } from "hono/compress"
|
||||
import type { Log } from "../util/log"
|
||||
|
||||
const log = Log.create({ service: "server" })
|
||||
|
||||
export const ErrorMiddleware: ErrorHandler = (err, c) => {
|
||||
log.error("failed", {
|
||||
error: err,
|
||||
})
|
||||
if (err instanceof NamedError) {
|
||||
let status: ContentfulStatusCode
|
||||
if (err instanceof NotFoundError) status = 404
|
||||
else if (err instanceof Provider.ModelNotFoundError) status = 400
|
||||
else if (err.name === "ProviderAuthValidationFailed") status = 400
|
||||
else if (err.name.startsWith("Worktree")) status = 400
|
||||
else status = 500
|
||||
return c.json(err.toObject(), { status })
|
||||
}
|
||||
if (err instanceof Session.BusyError) {
|
||||
return c.json(new NamedError.Unknown({ message: err.message }).toObject(), { status: 400 })
|
||||
}
|
||||
if (err instanceof HTTPException) return err.getResponse()
|
||||
const message = err instanceof Error && err.stack ? err.stack : err.toString()
|
||||
return c.json(new NamedError.Unknown({ message }).toObject(), {
|
||||
status: 500,
|
||||
})
|
||||
}
|
||||
|
||||
export const AuthMiddleware: MiddlewareHandler = (c, next) => {
|
||||
// Allow CORS preflight requests to succeed without auth.
|
||||
// Browser clients sending Authorization headers will preflight with OPTIONS.
|
||||
if (c.req.method === "OPTIONS") return next()
|
||||
const password = Flag.OPENCODE_SERVER_PASSWORD
|
||||
if (!password) return next()
|
||||
const username = Flag.OPENCODE_SERVER_USERNAME ?? "opencode"
|
||||
|
||||
if (c.req.query("auth_token")) c.req.raw.headers.set("authorization", `Basic ${c.req.query("auth_token")}`)
|
||||
|
||||
return basicAuth({ username, password })(c, next)
|
||||
}
|
||||
|
||||
export const LoggerMiddleware: MiddlewareHandler = async (c, next) => {
|
||||
const skip = c.req.path === "/log"
|
||||
if (!skip) {
|
||||
log.info("request", {
|
||||
method: c.req.method,
|
||||
path: c.req.path,
|
||||
export function errorHandler(log: Log.Logger): ErrorHandler {
|
||||
return (err, c) => {
|
||||
log.error("failed", {
|
||||
error: err,
|
||||
})
|
||||
if (err instanceof NamedError) {
|
||||
let status: ContentfulStatusCode
|
||||
if (err instanceof NotFoundError) status = 404
|
||||
else if (err instanceof Provider.ModelNotFoundError) status = 400
|
||||
else if (err.name === "ProviderAuthValidationFailed") status = 400
|
||||
else if (err.name.startsWith("Worktree")) status = 400
|
||||
else status = 500
|
||||
return c.json(err.toObject(), { status })
|
||||
}
|
||||
if (err instanceof Session.BusyError) {
|
||||
return c.json(new NamedError.Unknown({ message: err.message }).toObject(), { status: 400 })
|
||||
}
|
||||
if (err instanceof HTTPException) return err.getResponse()
|
||||
const message = err instanceof Error && err.stack ? err.stack : err.toString()
|
||||
return c.json(new NamedError.Unknown({ message }).toObject(), {
|
||||
status: 500,
|
||||
})
|
||||
}
|
||||
const timer = log.time("request", {
|
||||
method: c.req.method,
|
||||
path: c.req.path,
|
||||
})
|
||||
await next()
|
||||
if (!skip) timer.stop()
|
||||
}
|
||||
|
||||
export function CorsMiddleware(opts?: { cors?: string[] }): 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
|
||||
if (opts?.cors?.includes(input)) return input
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
const zipped = compress()
|
||||
export const CompressionMiddleware: MiddlewareHandler = (c, next) => {
|
||||
const path = c.req.path
|
||||
const method = c.req.method
|
||||
if (path === "/event" || path === "/global/event" || path === "/global/sync-event") return next()
|
||||
if (method === "POST" && /\/session\/[^/]+\/(message|prompt_async)$/.test(path)) return next()
|
||||
return zipped(c, next)
|
||||
}
|
||||
|
||||
@@ -3,10 +3,12 @@ import type { UpgradeWebSocket } from "hono/ws"
|
||||
import { getAdaptor } from "@/control-plane/adaptors"
|
||||
import { WorkspaceID } from "@/control-plane/schema"
|
||||
import { Workspace } from "@/control-plane/workspace"
|
||||
import { ServerProxy } from "../proxy"
|
||||
import { ServerProxy } from "./proxy"
|
||||
import { lazy } from "@/util/lazy"
|
||||
import { Filesystem } from "@/util/filesystem"
|
||||
import { Instance } from "@/project/instance"
|
||||
import { InstanceBootstrap } from "@/project/bootstrap"
|
||||
import { InstanceRoutes } from "./instance"
|
||||
|
||||
type Rule = { method?: string; path: string; exact?: boolean; action: "local" | "forward" }
|
||||
|
||||
@@ -25,7 +27,9 @@ function local(method: string, path: string) {
|
||||
}
|
||||
|
||||
export function WorkspaceRouterMiddleware(upgrade: UpgradeWebSocket): MiddlewareHandler {
|
||||
return async (c, next) => {
|
||||
const routes = lazy(() => InstanceRoutes(upgrade))
|
||||
|
||||
return async (c) => {
|
||||
const raw = c.req.query("directory") || c.req.header("x-opencode-directory") || process.cwd()
|
||||
const directory = Filesystem.resolve(
|
||||
(() => {
|
||||
@@ -49,7 +53,7 @@ export function WorkspaceRouterMiddleware(upgrade: UpgradeWebSocket): Middleware
|
||||
directory,
|
||||
init: InstanceBootstrap,
|
||||
async fn() {
|
||||
return next()
|
||||
return routes().fetch(c.req.raw, c.env)
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -73,7 +77,7 @@ export function WorkspaceRouterMiddleware(upgrade: UpgradeWebSocket): Middleware
|
||||
directory: target.directory,
|
||||
init: InstanceBootstrap,
|
||||
async fn() {
|
||||
return next()
|
||||
return routes().fetch(c.req.raw, c.env)
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -81,7 +85,7 @@ export function WorkspaceRouterMiddleware(upgrade: UpgradeWebSocket): Middleware
|
||||
if (local(c.req.method, url.pathname)) {
|
||||
// No instance provided because we are serving cached data; there
|
||||
// is no instance to work with
|
||||
return next()
|
||||
return routes().fetch(c.req.raw, c.env)
|
||||
}
|
||||
|
||||
if (c.req.header("upgrade")?.toLowerCase() === "websocket") {
|
||||
@@ -1,14 +1,24 @@
|
||||
import { generateSpecs } from "hono-openapi"
|
||||
import { Log } from "../util/log"
|
||||
import { describeRoute, generateSpecs, validator, resolver, openAPIRouteHandler } from "hono-openapi"
|
||||
import { Hono } from "hono"
|
||||
import { adapter } from "#hono"
|
||||
import { compress } from "hono/compress"
|
||||
import { createNodeWebSocket } from "@hono/node-ws"
|
||||
import { cors } from "hono/cors"
|
||||
import { basicAuth } from "hono/basic-auth"
|
||||
import type { UpgradeWebSocket } from "hono/ws"
|
||||
import z from "zod"
|
||||
import { Auth } from "../auth"
|
||||
import { Flag } from "../flag/flag"
|
||||
import { ProviderID } from "../provider/schema"
|
||||
import { WorkspaceRouterMiddleware } from "./router"
|
||||
import { errors } from "./error"
|
||||
import { GlobalRoutes } from "./routes/global"
|
||||
import { MDNS } from "./mdns"
|
||||
import { lazy } from "@/util/lazy"
|
||||
import { AuthMiddleware, CompressionMiddleware, CorsMiddleware, ErrorMiddleware, LoggerMiddleware } from "./middleware"
|
||||
import { errorHandler } from "./middleware"
|
||||
import { InstanceRoutes } from "./instance"
|
||||
import { initProjectors } from "./projectors"
|
||||
import { Log } from "@/util/log"
|
||||
import { ControlPlaneRoutes } from "./control"
|
||||
import { UIRoutes } from "./ui"
|
||||
import { createAdaptorServer, type ServerType } from "@hono/node-server"
|
||||
|
||||
// @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
|
||||
@@ -16,8 +26,6 @@ globalThis.AI_SDK_LOG_WARNINGS = false
|
||||
initProjectors()
|
||||
|
||||
export namespace Server {
|
||||
const log = Log.create({ service: "server" })
|
||||
|
||||
export type Listener = {
|
||||
hostname: string
|
||||
port: number
|
||||
@@ -25,31 +33,231 @@ export namespace Server {
|
||||
stop: (close?: boolean) => Promise<void>
|
||||
}
|
||||
|
||||
const log = Log.create({ service: "server" })
|
||||
const zipped = compress()
|
||||
|
||||
const skipCompress = (path: string, method: string) => {
|
||||
if (path === "/event" || path === "/global/event" || path === "/global/sync-event") return true
|
||||
if (method === "POST" && /\/session\/[^/]+\/(message|prompt_async)$/.test(path)) return true
|
||||
return false
|
||||
}
|
||||
|
||||
export const Default = lazy(() => create({}))
|
||||
|
||||
export function ControlPlaneRoutes(upgrade: UpgradeWebSocket, app = new Hono(), opts?: { cors?: string[] }): Hono {
|
||||
return app
|
||||
.onError(errorHandler(log))
|
||||
.use((c, next) => {
|
||||
// Allow CORS preflight requests to succeed without auth.
|
||||
// Browser clients sending Authorization headers will preflight with OPTIONS.
|
||||
if (c.req.method === "OPTIONS") return next()
|
||||
const password = Flag.OPENCODE_SERVER_PASSWORD
|
||||
if (!password) return next()
|
||||
const username = Flag.OPENCODE_SERVER_USERNAME ?? "opencode"
|
||||
|
||||
if (c.req.query("auth_token")) c.req.raw.headers.set("authorization", `Basic ${c.req.query("auth_token")}`)
|
||||
|
||||
return basicAuth({ username, password })(c, next)
|
||||
})
|
||||
.use(async (c, next) => {
|
||||
const skip = c.req.path === "/log"
|
||||
if (!skip) {
|
||||
log.info("request", {
|
||||
method: c.req.method,
|
||||
path: c.req.path,
|
||||
})
|
||||
}
|
||||
const timer = log.time("request", {
|
||||
method: c.req.method,
|
||||
path: c.req.path,
|
||||
})
|
||||
await next()
|
||||
if (!skip) timer.stop()
|
||||
})
|
||||
.use(
|
||||
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
|
||||
if (opts?.cors?.includes(input)) return input
|
||||
},
|
||||
}),
|
||||
)
|
||||
.use((c, next) => {
|
||||
if (skipCompress(c.req.path, c.req.method)) return next()
|
||||
return zipped(c, next)
|
||||
})
|
||||
.route("/global", GlobalRoutes())
|
||||
.put(
|
||||
"/auth/:providerID",
|
||||
describeRoute({
|
||||
summary: "Set auth credentials",
|
||||
description: "Set authentication credentials",
|
||||
operationId: "auth.set",
|
||||
responses: {
|
||||
200: {
|
||||
description: "Successfully set authentication credentials",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(z.boolean()),
|
||||
},
|
||||
},
|
||||
},
|
||||
...errors(400),
|
||||
},
|
||||
}),
|
||||
validator(
|
||||
"param",
|
||||
z.object({
|
||||
providerID: ProviderID.zod,
|
||||
}),
|
||||
),
|
||||
validator("json", Auth.Info.zod),
|
||||
async (c) => {
|
||||
const providerID = c.req.valid("param").providerID
|
||||
const info = c.req.valid("json")
|
||||
await Auth.set(providerID, info)
|
||||
return c.json(true)
|
||||
},
|
||||
)
|
||||
.delete(
|
||||
"/auth/:providerID",
|
||||
describeRoute({
|
||||
summary: "Remove auth credentials",
|
||||
description: "Remove authentication credentials",
|
||||
operationId: "auth.remove",
|
||||
responses: {
|
||||
200: {
|
||||
description: "Successfully removed authentication credentials",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(z.boolean()),
|
||||
},
|
||||
},
|
||||
},
|
||||
...errors(400),
|
||||
},
|
||||
}),
|
||||
validator(
|
||||
"param",
|
||||
z.object({
|
||||
providerID: ProviderID.zod,
|
||||
}),
|
||||
),
|
||||
async (c) => {
|
||||
const providerID = c.req.valid("param").providerID
|
||||
await Auth.remove(providerID)
|
||||
return c.json(true)
|
||||
},
|
||||
)
|
||||
.get(
|
||||
"/doc",
|
||||
openAPIRouteHandler(app, {
|
||||
documentation: {
|
||||
info: {
|
||||
title: "opencode",
|
||||
version: "0.0.3",
|
||||
description: "opencode api",
|
||||
},
|
||||
openapi: "3.1.1",
|
||||
},
|
||||
}),
|
||||
)
|
||||
.use(
|
||||
validator(
|
||||
"query",
|
||||
z.object({
|
||||
directory: z.string().optional(),
|
||||
workspace: z.string().optional(),
|
||||
}),
|
||||
),
|
||||
)
|
||||
.post(
|
||||
"/log",
|
||||
describeRoute({
|
||||
summary: "Write log",
|
||||
description: "Write a log entry to the server logs with specified level and metadata.",
|
||||
operationId: "app.log",
|
||||
responses: {
|
||||
200: {
|
||||
description: "Log entry written successfully",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(z.boolean()),
|
||||
},
|
||||
},
|
||||
},
|
||||
...errors(400),
|
||||
},
|
||||
}),
|
||||
validator(
|
||||
"json",
|
||||
z.object({
|
||||
service: z.string().meta({ description: "Service name for the log entry" }),
|
||||
level: z.enum(["debug", "info", "error", "warn"]).meta({ description: "Log level" }),
|
||||
message: z.string().meta({ description: "Log message" }),
|
||||
extra: z
|
||||
.record(z.string(), z.any())
|
||||
.optional()
|
||||
.meta({ description: "Additional metadata for the log entry" }),
|
||||
}),
|
||||
),
|
||||
async (c) => {
|
||||
const { service, level, message, extra } = c.req.valid("json")
|
||||
const logger = Log.create({ service })
|
||||
|
||||
switch (level) {
|
||||
case "debug":
|
||||
logger.debug(message, extra)
|
||||
break
|
||||
case "info":
|
||||
logger.info(message, extra)
|
||||
break
|
||||
case "error":
|
||||
logger.error(message, extra)
|
||||
break
|
||||
case "warn":
|
||||
logger.warn(message, extra)
|
||||
break
|
||||
}
|
||||
|
||||
return c.json(true)
|
||||
},
|
||||
)
|
||||
.use(WorkspaceRouterMiddleware(upgrade))
|
||||
}
|
||||
|
||||
function create(opts: { cors?: string[] }) {
|
||||
const app = new Hono()
|
||||
const runtime = adapter.create(app)
|
||||
const ws = createNodeWebSocket({ app })
|
||||
return {
|
||||
app: app
|
||||
.onError(ErrorMiddleware)
|
||||
.use(AuthMiddleware)
|
||||
.use(LoggerMiddleware)
|
||||
.use(CompressionMiddleware)
|
||||
.use(CorsMiddleware(opts))
|
||||
.route("/", ControlPlaneRoutes())
|
||||
.route("/", InstanceRoutes(runtime.upgradeWebSocket))
|
||||
.route("/", UIRoutes()),
|
||||
runtime,
|
||||
app: ControlPlaneRoutes(ws.upgradeWebSocket, app, opts),
|
||||
ws,
|
||||
}
|
||||
}
|
||||
|
||||
export function createApp(opts: { cors?: string[] }) {
|
||||
return create(opts).app
|
||||
}
|
||||
|
||||
export async function openapi() {
|
||||
// Build a fresh app with all routes registered directly so
|
||||
// hono-openapi can see describeRoute metadata (`.route()` wraps
|
||||
// handlers when the sub-app has a custom errorHandler, which
|
||||
// strips the metadata symbol).
|
||||
const { app } = create({})
|
||||
const { app, ws } = create({})
|
||||
InstanceRoutes(ws.upgradeWebSocket, app)
|
||||
const result = await generateSpecs(app, {
|
||||
documentation: {
|
||||
info: {
|
||||
@@ -73,21 +281,46 @@ export namespace Server {
|
||||
cors?: string[]
|
||||
}): Promise<Listener> {
|
||||
const built = create(opts)
|
||||
const server = await built.runtime.listen(opts)
|
||||
const start = (port: number) =>
|
||||
new Promise<ServerType>((resolve, reject) => {
|
||||
const server = createAdaptorServer({ fetch: built.app.fetch })
|
||||
built.ws.injectWebSocket(server)
|
||||
const fail = (err: Error) => {
|
||||
cleanup()
|
||||
reject(err)
|
||||
}
|
||||
const ready = () => {
|
||||
cleanup()
|
||||
resolve(server)
|
||||
}
|
||||
const cleanup = () => {
|
||||
server.off("error", fail)
|
||||
server.off("listening", ready)
|
||||
}
|
||||
server.once("error", fail)
|
||||
server.once("listening", ready)
|
||||
server.listen(port, opts.hostname)
|
||||
})
|
||||
|
||||
const server = opts.port === 0 ? await start(4096).catch(() => start(0)) : await start(opts.port)
|
||||
const addr = server.address()
|
||||
if (!addr || typeof addr === "string") {
|
||||
throw new Error(`Failed to resolve server address for port ${opts.port}`)
|
||||
}
|
||||
|
||||
const next = new URL("http://localhost")
|
||||
next.hostname = opts.hostname
|
||||
next.port = String(server.port)
|
||||
next.port = String(addr.port)
|
||||
url = next
|
||||
|
||||
const mdns =
|
||||
opts.mdns &&
|
||||
server.port &&
|
||||
addr.port &&
|
||||
opts.hostname !== "127.0.0.1" &&
|
||||
opts.hostname !== "localhost" &&
|
||||
opts.hostname !== "::1"
|
||||
if (mdns) {
|
||||
MDNS.publish(server.port, opts.mdnsDomain)
|
||||
MDNS.publish(addr.port, opts.mdnsDomain)
|
||||
} else if (opts.mdns) {
|
||||
log.warn("mDNS enabled but hostname is loopback; skipping mDNS publish")
|
||||
}
|
||||
@@ -95,13 +328,27 @@ export namespace Server {
|
||||
let closing: Promise<void> | undefined
|
||||
return {
|
||||
hostname: opts.hostname,
|
||||
port: server.port,
|
||||
port: addr.port,
|
||||
url: next,
|
||||
stop(close?: boolean) {
|
||||
closing ??= (async () => {
|
||||
closing ??= new Promise((resolve, reject) => {
|
||||
if (mdns) MDNS.unpublish()
|
||||
await server.stop(close)
|
||||
})()
|
||||
server.close((err) => {
|
||||
if (err) {
|
||||
reject(err)
|
||||
return
|
||||
}
|
||||
resolve()
|
||||
})
|
||||
if (close) {
|
||||
if ("closeAllConnections" in server && typeof server.closeAllConnections === "function") {
|
||||
server.closeAllConnections()
|
||||
}
|
||||
if ("closeIdleConnections" in server && typeof server.closeIdleConnections === "function") {
|
||||
server.closeIdleConnections()
|
||||
}
|
||||
}
|
||||
})
|
||||
return closing
|
||||
},
|
||||
}
|
||||
|
||||
@@ -1,55 +0,0 @@
|
||||
import { Flag } from "@/flag/flag"
|
||||
import { Hono } from "hono"
|
||||
import { proxy } from "hono/proxy"
|
||||
import { getMimeType } from "hono/utils/mime"
|
||||
import { createHash } from "node:crypto"
|
||||
import fs from "node:fs/promises"
|
||||
|
||||
const embeddedUIPromise = Flag.OPENCODE_DISABLE_EMBEDDED_WEB_UI
|
||||
? Promise.resolve(null)
|
||||
: // @ts-expect-error - generated file at build time
|
||||
import("opencode-web-ui.gen.ts").then((module) => module.default as Record<string, string>).catch(() => null)
|
||||
|
||||
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 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:`
|
||||
|
||||
export const UIRoutes = (): Hono =>
|
||||
new Hono().all("/*", async (c) => {
|
||||
const embeddedWebUI = await embeddedUIPromise
|
||||
const path = c.req.path
|
||||
|
||||
if (embeddedWebUI) {
|
||||
const match = embeddedWebUI[path.replace(/^\//, "")] ?? embeddedWebUI["index.html"] ?? null
|
||||
if (!match) return c.json({ error: "Not Found" }, 404)
|
||||
|
||||
if (await fs.exists(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)
|
||||
}
|
||||
} else {
|
||||
const response = await proxy(`https://app.opencode.ai${path}`, {
|
||||
...c.req,
|
||||
headers: {
|
||||
...c.req.raw.headers,
|
||||
host: "app.opencode.ai",
|
||||
},
|
||||
})
|
||||
const match = response.headers.get("content-type")?.includes("text/html")
|
||||
? (await response.clone().text()).match(
|
||||
/<script\b(?![^>]*\bsrc\s*=)[^>]*\bid=(['"])oc-theme-preload-script\1[^>]*>([\s\S]*?)<\/script>/i,
|
||||
)
|
||||
: undefined
|
||||
const hash = match ? createHash("sha256").update(match[2]).digest("base64") : ""
|
||||
response.headers.set("Content-Security-Policy", csp(hash))
|
||||
return response
|
||||
}
|
||||
})
|
||||
@@ -11,7 +11,11 @@ import { Git } from "@/git"
|
||||
export namespace Storage {
|
||||
const log = Log.create({ service: "storage" })
|
||||
|
||||
type Migration = (dir: string, fs: AppFileSystem.Interface) => Effect.Effect<void, AppFileSystem.Error>
|
||||
type Migration = (
|
||||
dir: string,
|
||||
fs: AppFileSystem.Interface,
|
||||
git: Git.Interface,
|
||||
) => Effect.Effect<void, AppFileSystem.Error>
|
||||
|
||||
export const NotFoundError = NamedError.create(
|
||||
"NotFoundError",
|
||||
@@ -83,7 +87,7 @@ export namespace Storage {
|
||||
}
|
||||
|
||||
const MIGRATIONS: Migration[] = [
|
||||
Effect.fn("Storage.migration.1")(function* (dir: string, fs: AppFileSystem.Interface) {
|
||||
Effect.fn("Storage.migration.1")(function* (dir: string, fs: AppFileSystem.Interface, git: Git.Interface) {
|
||||
const project = path.resolve(dir, "../project")
|
||||
if (!(yield* fs.isDir(project))) return
|
||||
const projectDirs = yield* fs.glob("*", {
|
||||
@@ -110,11 +114,9 @@ export namespace Storage {
|
||||
}
|
||||
if (!worktree) continue
|
||||
if (!(yield* fs.isDir(worktree))) continue
|
||||
const result = yield* Effect.promise(() =>
|
||||
Git.run(["rev-list", "--max-parents=0", "--all"], {
|
||||
cwd: worktree,
|
||||
}),
|
||||
)
|
||||
const result = yield* git.run(["rev-list", "--max-parents=0", "--all"], {
|
||||
cwd: worktree,
|
||||
})
|
||||
const [id] = result
|
||||
.text()
|
||||
.split("\n")
|
||||
@@ -220,6 +222,7 @@ export namespace Storage {
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const fs = yield* AppFileSystem.Service
|
||||
const git = yield* Git.Service
|
||||
const locks = yield* RcMap.make({
|
||||
lookup: () => TxReentrantLock.make(),
|
||||
idleTimeToLive: 0,
|
||||
@@ -236,7 +239,7 @@ export namespace Storage {
|
||||
for (let i = migration; i < MIGRATIONS.length; i++) {
|
||||
log.info("running migration", { index: i })
|
||||
const step = MIGRATIONS[i]!
|
||||
const exit = yield* Effect.exit(step(dir, fs))
|
||||
const exit = yield* Effect.exit(step(dir, fs, git))
|
||||
if (Exit.isFailure(exit)) {
|
||||
log.error("failed to run migration", { index: i, cause: exit.cause })
|
||||
break
|
||||
@@ -327,7 +330,7 @@ export namespace Storage {
|
||||
}),
|
||||
)
|
||||
|
||||
export const defaultLayer = layer.pipe(Layer.provide(AppFileSystem.defaultLayer))
|
||||
export const defaultLayer = layer.pipe(Layer.provide(AppFileSystem.defaultLayer), Layer.provide(Git.defaultLayer))
|
||||
|
||||
const { runPromise } = makeRuntime(Service, defaultLayer)
|
||||
|
||||
|
||||
@@ -1,132 +1,99 @@
|
||||
import z from "zod"
|
||||
import { Effect } from "effect"
|
||||
import { HttpClient, HttpClientRequest } from "effect/unstable/http"
|
||||
import { Tool } from "./tool"
|
||||
import DESCRIPTION from "./codesearch.txt"
|
||||
import { abortAfterAny } from "../util/abort"
|
||||
|
||||
const API_CONFIG = {
|
||||
BASE_URL: "https://mcp.exa.ai",
|
||||
ENDPOINTS: {
|
||||
CONTEXT: "/mcp",
|
||||
},
|
||||
} as const
|
||||
const URL = "https://mcp.exa.ai/mcp"
|
||||
|
||||
interface McpCodeRequest {
|
||||
jsonrpc: string
|
||||
id: number
|
||||
method: string
|
||||
params: {
|
||||
name: string
|
||||
arguments: {
|
||||
query: string
|
||||
tokensNum: number
|
||||
}
|
||||
}
|
||||
}
|
||||
export const CodeSearchTool = Tool.defineEffect(
|
||||
"codesearch",
|
||||
Effect.gen(function* () {
|
||||
const http = yield* HttpClient.HttpClient
|
||||
|
||||
interface McpCodeResponse {
|
||||
jsonrpc: string
|
||||
result: {
|
||||
content: Array<{
|
||||
type: string
|
||||
text: string
|
||||
}>
|
||||
}
|
||||
}
|
||||
return {
|
||||
description: DESCRIPTION,
|
||||
parameters: z.object({
|
||||
query: z
|
||||
.string()
|
||||
.describe(
|
||||
"Search query to find relevant context for APIs, Libraries, and SDKs. For example, 'React useState hook examples', 'Python pandas dataframe filtering', 'Express.js middleware', 'Next js partial prerendering configuration'",
|
||||
),
|
||||
tokensNum: z
|
||||
.number()
|
||||
.min(1000)
|
||||
.max(50000)
|
||||
.default(5000)
|
||||
.describe(
|
||||
"Number of tokens to return (1000-50000). Default is 5000 tokens. Adjust this value based on how much context you need - use lower values for focused queries and higher values for comprehensive documentation.",
|
||||
),
|
||||
}),
|
||||
execute: (params: { query: string; tokensNum: number }, ctx: Tool.Context) =>
|
||||
Effect.gen(function* () {
|
||||
yield* Effect.promise(() =>
|
||||
ctx.ask({
|
||||
permission: "codesearch",
|
||||
patterns: [params.query],
|
||||
always: ["*"],
|
||||
metadata: {
|
||||
query: params.query,
|
||||
tokensNum: params.tokensNum,
|
||||
},
|
||||
}),
|
||||
)
|
||||
|
||||
export const CodeSearchTool = Tool.define("codesearch", {
|
||||
description: DESCRIPTION,
|
||||
parameters: z.object({
|
||||
query: z
|
||||
.string()
|
||||
.describe(
|
||||
"Search query to find relevant context for APIs, Libraries, and SDKs. For example, 'React useState hook examples', 'Python pandas dataframe filtering', 'Express.js middleware', 'Next js partial prerendering configuration'",
|
||||
),
|
||||
tokensNum: z
|
||||
.number()
|
||||
.min(1000)
|
||||
.max(50000)
|
||||
.default(5000)
|
||||
.describe(
|
||||
"Number of tokens to return (1000-50000). Default is 5000 tokens. Adjust this value based on how much context you need - use lower values for focused queries and higher values for comprehensive documentation.",
|
||||
),
|
||||
}),
|
||||
async execute(params, ctx) {
|
||||
await ctx.ask({
|
||||
permission: "codesearch",
|
||||
patterns: [params.query],
|
||||
always: ["*"],
|
||||
metadata: {
|
||||
query: params.query,
|
||||
tokensNum: params.tokensNum,
|
||||
},
|
||||
})
|
||||
const request = HttpClientRequest.post(URL).pipe(
|
||||
HttpClientRequest.setHeaders({
|
||||
accept: "application/json, text/event-stream",
|
||||
"content-type": "application/json",
|
||||
}),
|
||||
HttpClientRequest.bodyJsonUnsafe({
|
||||
jsonrpc: "2.0",
|
||||
id: 1,
|
||||
method: "tools/call",
|
||||
params: {
|
||||
name: "get_code_context_exa",
|
||||
arguments: {
|
||||
query: params.query,
|
||||
tokensNum: params.tokensNum || 5000,
|
||||
},
|
||||
},
|
||||
}),
|
||||
)
|
||||
|
||||
const codeRequest: McpCodeRequest = {
|
||||
jsonrpc: "2.0",
|
||||
id: 1,
|
||||
method: "tools/call",
|
||||
params: {
|
||||
name: "get_code_context_exa",
|
||||
arguments: {
|
||||
query: params.query,
|
||||
tokensNum: params.tokensNum || 5000,
|
||||
},
|
||||
},
|
||||
}
|
||||
const response = yield* http.execute(request).pipe(Effect.timeout("30 seconds"))
|
||||
|
||||
const { signal, clearTimeout } = abortAfterAny(30000, ctx.abort)
|
||||
if (response.status < 200 || response.status >= 300) {
|
||||
const errorText = yield* response.text
|
||||
throw new Error(`Code search error (${response.status}): ${errorText}`)
|
||||
}
|
||||
|
||||
try {
|
||||
const headers: Record<string, string> = {
|
||||
accept: "application/json, text/event-stream",
|
||||
"content-type": "application/json",
|
||||
}
|
||||
const responseText = yield* response.text
|
||||
|
||||
const response = await fetch(`${API_CONFIG.BASE_URL}${API_CONFIG.ENDPOINTS.CONTEXT}`, {
|
||||
method: "POST",
|
||||
headers,
|
||||
body: JSON.stringify(codeRequest),
|
||||
signal,
|
||||
})
|
||||
|
||||
clearTimeout()
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text()
|
||||
throw new Error(`Code search error (${response.status}): ${errorText}`)
|
||||
}
|
||||
|
||||
const responseText = await response.text()
|
||||
|
||||
// Parse SSE response
|
||||
const lines = responseText.split("\n")
|
||||
for (const line of lines) {
|
||||
if (line.startsWith("data: ")) {
|
||||
const data: McpCodeResponse = JSON.parse(line.substring(6))
|
||||
if (data.result && data.result.content && data.result.content.length > 0) {
|
||||
return {
|
||||
output: data.result.content[0].text,
|
||||
title: `Code search: ${params.query}`,
|
||||
metadata: {},
|
||||
// Parse SSE response
|
||||
for (const line of responseText.split("\n")) {
|
||||
if (line.startsWith("data: ")) {
|
||||
const data = JSON.parse(line.substring(6))
|
||||
if (data.result?.content?.[0]?.text) {
|
||||
return {
|
||||
output: data.result.content[0].text,
|
||||
title: `Code search: ${params.query}`,
|
||||
metadata: {},
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
output:
|
||||
"No code snippets or documentation found. Please try a different query, be more specific about the library or programming concept, or check the spelling of framework names.",
|
||||
title: `Code search: ${params.query}`,
|
||||
metadata: {},
|
||||
}
|
||||
} catch (error) {
|
||||
clearTimeout()
|
||||
|
||||
if (error instanceof Error && error.name === "AbortError") {
|
||||
throw new Error("Code search request timed out")
|
||||
}
|
||||
|
||||
throw error
|
||||
return {
|
||||
output:
|
||||
"No code snippets or documentation found. Please try a different query, be more specific about the library or programming concept, or check the spelling of framework names.",
|
||||
title: `Code search: ${params.query}`,
|
||||
metadata: {},
|
||||
}
|
||||
}).pipe(
|
||||
Effect.catchTag("TimeoutError", () => Effect.die(new Error("Code search request timed out"))),
|
||||
Effect.runPromise,
|
||||
),
|
||||
}
|
||||
},
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -28,6 +28,7 @@ import { Glob } from "../util/glob"
|
||||
import path from "path"
|
||||
import { pathToFileURL } from "url"
|
||||
import { Effect, Layer, ServiceMap } from "effect"
|
||||
import { FetchHttpClient, HttpClient } from "effect/unstable/http"
|
||||
import { InstanceState } from "@/effect/instance-state"
|
||||
import { makeRuntime } from "@/effect/run-service"
|
||||
import { Env } from "../env"
|
||||
@@ -80,6 +81,7 @@ export namespace ToolRegistry {
|
||||
| FileTime.Service
|
||||
| Instruction.Service
|
||||
| AppFileSystem.Service
|
||||
| HttpClient.HttpClient
|
||||
> = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
@@ -92,6 +94,7 @@ export namespace ToolRegistry {
|
||||
const read = yield* ReadTool
|
||||
const question = yield* QuestionTool
|
||||
const todo = yield* TodoWriteTool
|
||||
const codesearch = yield* CodeSearchTool
|
||||
|
||||
const state = yield* InstanceState.make<State>(
|
||||
Effect.fn("ToolRegistry.state")(function* (ctx) {
|
||||
@@ -160,7 +163,7 @@ export namespace ToolRegistry {
|
||||
fetch: Tool.init(WebFetchTool),
|
||||
todo: Tool.init(todo),
|
||||
search: Tool.init(WebSearchTool),
|
||||
code: Tool.init(CodeSearchTool),
|
||||
code: Tool.init(codesearch),
|
||||
skill: Tool.init(SkillTool),
|
||||
patch: Tool.init(ApplyPatchTool),
|
||||
question: Tool.init(question),
|
||||
@@ -301,6 +304,7 @@ export namespace ToolRegistry {
|
||||
Layer.provide(FileTime.defaultLayer),
|
||||
Layer.provide(Instruction.defaultLayer),
|
||||
Layer.provide(AppFileSystem.defaultLayer),
|
||||
Layer.provide(FetchHttpClient.layer),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ import { tmpdir } from "../fixture/fixture"
|
||||
import { Bus } from "../../src/bus"
|
||||
import { Config } from "../../src/config/config"
|
||||
import { FileWatcher } from "../../src/file/watcher"
|
||||
import { Git } from "../../src/git"
|
||||
import { Instance } from "../../src/project/instance"
|
||||
|
||||
// Native @parcel/watcher bindings aren't reliably available in CI (missing on Linux, flaky on Windows)
|
||||
@@ -32,6 +33,7 @@ function withWatcher<E>(directory: string, body: Effect.Effect<void, E>) {
|
||||
fn: async () => {
|
||||
const layer: Layer.Layer<FileWatcher.Service, never, never> = FileWatcher.layer.pipe(
|
||||
Layer.provide(Config.defaultLayer),
|
||||
Layer.provide(Git.defaultLayer),
|
||||
Layer.provide(watcherConfigLayer),
|
||||
)
|
||||
const rt = ManagedRuntime.make(layer)
|
||||
|
||||
@@ -1,49 +0,0 @@
|
||||
import { abortAfterAny } from "../../src/util/abort"
|
||||
|
||||
const MB = 1024 * 1024
|
||||
const ITERATIONS = 50
|
||||
|
||||
const heap = () => {
|
||||
Bun.gc(true)
|
||||
return process.memoryUsage().heapUsed / MB
|
||||
}
|
||||
|
||||
const server = Bun.serve({
|
||||
port: 0,
|
||||
fetch() {
|
||||
return new Response("hello from local", {
|
||||
headers: {
|
||||
"content-type": "text/plain",
|
||||
},
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
const url = `http://127.0.0.1:${server.port}`
|
||||
|
||||
async function run() {
|
||||
const { signal, clearTimeout } = abortAfterAny(30000, new AbortController().signal)
|
||||
try {
|
||||
const response = await fetch(url, { signal })
|
||||
await response.text()
|
||||
} finally {
|
||||
clearTimeout()
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
await run()
|
||||
Bun.sleepSync(100)
|
||||
const baseline = heap()
|
||||
|
||||
for (let i = 0; i < ITERATIONS; i++) {
|
||||
await run()
|
||||
}
|
||||
|
||||
Bun.sleepSync(100)
|
||||
const after = heap()
|
||||
process.stdout.write(JSON.stringify({ baseline, after, growth: after - baseline }))
|
||||
} finally {
|
||||
server.stop(true)
|
||||
process.exit(0)
|
||||
}
|
||||
@@ -1,8 +1,21 @@
|
||||
import { describe, test, expect } from "bun:test"
|
||||
import path from "path"
|
||||
import { Instance } from "../../src/project/instance"
|
||||
import { WebFetchTool } from "../../src/tool/webfetch"
|
||||
import { SessionID, MessageID } from "../../src/session/schema"
|
||||
|
||||
const projectRoot = path.join(import.meta.dir, "../..")
|
||||
const worker = path.join(import.meta.dir, "abort-leak-webfetch.ts")
|
||||
const projectRoot = path.join(__dirname, "../..")
|
||||
|
||||
const ctx = {
|
||||
sessionID: SessionID.make("ses_test"),
|
||||
messageID: MessageID.make(""),
|
||||
callID: "",
|
||||
agent: "build",
|
||||
abort: new AbortController().signal,
|
||||
messages: [],
|
||||
metadata: () => {},
|
||||
ask: async () => {},
|
||||
}
|
||||
|
||||
const MB = 1024 * 1024
|
||||
const ITERATIONS = 50
|
||||
@@ -14,38 +27,35 @@ const getHeapMB = () => {
|
||||
|
||||
describe("memory: abort controller leak", () => {
|
||||
test("webfetch does not leak memory over many invocations", async () => {
|
||||
// Measure the abort-timed fetch path in a fresh process so shared tool
|
||||
// runtime state does not dominate the heap signal.
|
||||
const proc = Bun.spawn({
|
||||
cmd: [process.execPath, worker],
|
||||
cwd: projectRoot,
|
||||
stdout: "pipe",
|
||||
stderr: "pipe",
|
||||
env: process.env,
|
||||
await Instance.provide({
|
||||
directory: projectRoot,
|
||||
fn: async () => {
|
||||
const tool = await WebFetchTool.init()
|
||||
|
||||
// Warm up
|
||||
await tool.execute({ url: "https://example.com", format: "text" }, ctx).catch(() => {})
|
||||
|
||||
Bun.gc(true)
|
||||
const baseline = getHeapMB()
|
||||
|
||||
// Run many fetches
|
||||
for (let i = 0; i < ITERATIONS; i++) {
|
||||
await tool.execute({ url: "https://example.com", format: "text" }, ctx).catch(() => {})
|
||||
}
|
||||
|
||||
Bun.gc(true)
|
||||
const after = getHeapMB()
|
||||
const growth = after - baseline
|
||||
|
||||
console.log(`Baseline: ${baseline.toFixed(2)} MB`)
|
||||
console.log(`After ${ITERATIONS} fetches: ${after.toFixed(2)} MB`)
|
||||
console.log(`Growth: ${growth.toFixed(2)} MB`)
|
||||
|
||||
// Memory growth should be minimal - less than 1MB per 10 requests
|
||||
// With the old closure pattern, this would grow ~0.5MB per request
|
||||
expect(growth).toBeLessThan(ITERATIONS / 10)
|
||||
},
|
||||
})
|
||||
|
||||
const [code, stdout, stderr] = await Promise.all([
|
||||
proc.exited,
|
||||
new Response(proc.stdout).text(),
|
||||
new Response(proc.stderr).text(),
|
||||
])
|
||||
|
||||
if (code !== 0) {
|
||||
throw new Error(stderr.trim() || stdout.trim() || `worker exited with code ${code}`)
|
||||
}
|
||||
|
||||
const result = JSON.parse(stdout.trim()) as {
|
||||
baseline: number
|
||||
after: number
|
||||
growth: number
|
||||
}
|
||||
|
||||
console.log(`Baseline: ${result.baseline.toFixed(2)} MB`)
|
||||
console.log(`After ${ITERATIONS} fetches: ${result.after.toFixed(2)} MB`)
|
||||
console.log(`Growth: ${result.growth.toFixed(2)} MB`)
|
||||
|
||||
// Memory growth should be minimal - less than 1MB per 10 requests.
|
||||
expect(result.growth).toBeLessThan(ITERATIONS / 10)
|
||||
}, 60000)
|
||||
|
||||
test("compare closure vs bind pattern directly", async () => {
|
||||
|
||||
@@ -13,6 +13,8 @@ const { PluginLoader } = await import("../../src/plugin/loader")
|
||||
const { readPackageThemes } = await import("../../src/plugin/shared")
|
||||
const { Instance } = await import("../../src/project/instance")
|
||||
const { Npm } = await import("../../src/npm")
|
||||
const { Bus } = await import("../../src/bus")
|
||||
const { Session } = await import("../../src/session")
|
||||
|
||||
afterAll(() => {
|
||||
if (disableDefault === undefined) {
|
||||
@@ -35,6 +37,27 @@ async function load(dir: string) {
|
||||
})
|
||||
}
|
||||
|
||||
async function errs(dir: string) {
|
||||
return Instance.provide({
|
||||
directory: dir,
|
||||
fn: async () => {
|
||||
const errors: string[] = []
|
||||
const off = Bus.subscribe(Session.Event.Error, (evt) => {
|
||||
const error = evt.properties.error
|
||||
if (!error || typeof error !== "object") return
|
||||
if (!("data" in error)) return
|
||||
if (!error.data || typeof error.data !== "object") return
|
||||
if (!("message" in error.data)) return
|
||||
if (typeof error.data.message !== "string") return
|
||||
errors.push(error.data.message)
|
||||
})
|
||||
await Plugin.list()
|
||||
off()
|
||||
return errors
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
describe("plugin.loader.shared", () => {
|
||||
test("loads a file:// plugin function export", async () => {
|
||||
await using tmp = await tmpdir({
|
||||
@@ -161,13 +184,14 @@ describe("plugin.loader.shared", () => {
|
||||
},
|
||||
})
|
||||
|
||||
await load(tmp.path)
|
||||
const errors = await errs(tmp.path)
|
||||
const called = await Bun.file(tmp.extra.mark)
|
||||
.text()
|
||||
.then(() => true)
|
||||
.catch(() => false)
|
||||
|
||||
expect(called).toBe(false)
|
||||
expect(errors.some((x) => x.includes("must export id"))).toBe(true)
|
||||
})
|
||||
|
||||
test("rejects v1 plugin that exports server and tui together", async () => {
|
||||
@@ -199,13 +223,14 @@ describe("plugin.loader.shared", () => {
|
||||
},
|
||||
})
|
||||
|
||||
await load(tmp.path)
|
||||
const errors = await errs(tmp.path)
|
||||
const called = await Bun.file(tmp.extra.mark)
|
||||
.text()
|
||||
.then(() => true)
|
||||
.catch(() => false)
|
||||
|
||||
expect(called).toBe(false)
|
||||
expect(errors.some((x) => x.includes("either server() or tui(), not both"))).toBe(true)
|
||||
})
|
||||
|
||||
test("resolves npm plugin specs with explicit and default versions", async () => {
|
||||
@@ -358,7 +383,8 @@ describe("plugin.loader.shared", () => {
|
||||
const install = spyOn(Npm, "add").mockResolvedValue({ directory: tmp.extra.mod, entrypoint: tmp.extra.mod })
|
||||
|
||||
try {
|
||||
await load(tmp.path)
|
||||
const errors = await errs(tmp.path)
|
||||
expect(errors).toHaveLength(0)
|
||||
expect(await Bun.file(tmp.extra.mark).text()).toBe("called")
|
||||
} finally {
|
||||
install.mockRestore()
|
||||
@@ -410,7 +436,8 @@ describe("plugin.loader.shared", () => {
|
||||
const install = spyOn(Npm, "add").mockResolvedValue({ directory: tmp.extra.mod, entrypoint: tmp.extra.mod })
|
||||
|
||||
try {
|
||||
await load(tmp.path)
|
||||
const errors = await errs(tmp.path)
|
||||
expect(errors).toHaveLength(0)
|
||||
expect(await Bun.file(tmp.extra.mark).text()).toBe("called")
|
||||
} finally {
|
||||
install.mockRestore()
|
||||
@@ -455,13 +482,14 @@ describe("plugin.loader.shared", () => {
|
||||
const install = spyOn(Npm, "add").mockResolvedValue({ directory: tmp.extra.mod, entrypoint: tmp.extra.mod })
|
||||
|
||||
try {
|
||||
await load(tmp.path)
|
||||
const errors = await errs(tmp.path)
|
||||
const called = await Bun.file(tmp.extra.mark)
|
||||
.text()
|
||||
.then(() => true)
|
||||
.catch(() => false)
|
||||
|
||||
expect(called).toBe(false)
|
||||
expect(errors).toHaveLength(0)
|
||||
} finally {
|
||||
install.mockRestore()
|
||||
}
|
||||
@@ -518,12 +546,13 @@ describe("plugin.loader.shared", () => {
|
||||
const install = spyOn(Npm, "add").mockResolvedValue({ directory: tmp.extra.mod, entrypoint: tmp.extra.mod })
|
||||
|
||||
try {
|
||||
await load(tmp.path)
|
||||
const errors = await errs(tmp.path)
|
||||
const called = await Bun.file(tmp.extra.mark)
|
||||
.text()
|
||||
.then(() => true)
|
||||
.catch(() => false)
|
||||
expect(called).toBe(false)
|
||||
expect(errors.some((x) => x.includes("outside plugin directory"))).toBe(true)
|
||||
} finally {
|
||||
install.mockRestore()
|
||||
}
|
||||
@@ -559,49 +588,30 @@ describe("plugin.loader.shared", () => {
|
||||
}
|
||||
})
|
||||
|
||||
test("skips broken plugin when install fails", async () => {
|
||||
test("publishes session.error when install fails", async () => {
|
||||
await using tmp = await tmpdir({
|
||||
init: async (dir) => {
|
||||
const ok = path.join(dir, "ok.ts")
|
||||
const mark = path.join(dir, "ok.txt")
|
||||
await Bun.write(
|
||||
ok,
|
||||
[
|
||||
"export default {",
|
||||
' id: "demo.ok",',
|
||||
" server: async () => {",
|
||||
` await Bun.write(${JSON.stringify(mark)}, "ok")`,
|
||||
" return {}",
|
||||
" },",
|
||||
"}",
|
||||
"",
|
||||
].join("\n"),
|
||||
)
|
||||
await Bun.write(
|
||||
path.join(dir, "opencode.json"),
|
||||
JSON.stringify({ plugin: ["broken-plugin@9.9.9", pathToFileURL(ok).href] }, null, 2),
|
||||
)
|
||||
return { mark }
|
||||
await Bun.write(path.join(dir, "opencode.json"), JSON.stringify({ plugin: ["broken-plugin@9.9.9"] }, null, 2))
|
||||
},
|
||||
})
|
||||
|
||||
const install = spyOn(Npm, "add").mockRejectedValue(new Error("boom"))
|
||||
|
||||
try {
|
||||
await load(tmp.path)
|
||||
expect(install).toHaveBeenCalledWith("broken-plugin@9.9.9")
|
||||
expect(await Bun.file(tmp.extra.mark).text()).toBe("ok")
|
||||
const errors = await errs(tmp.path)
|
||||
|
||||
expect(errors.some((x) => x.includes("Failed to install plugin broken-plugin@9.9.9") && x.includes("boom"))).toBe(
|
||||
true,
|
||||
)
|
||||
} finally {
|
||||
install.mockRestore()
|
||||
}
|
||||
})
|
||||
|
||||
test("continues loading plugins when plugin init throws", async () => {
|
||||
test("publishes session.error when plugin init throws", async () => {
|
||||
await using tmp = await tmpdir({
|
||||
init: async (dir) => {
|
||||
const file = pathToFileURL(path.join(dir, "throws.ts")).href
|
||||
const ok = pathToFileURL(path.join(dir, "ok.ts")).href
|
||||
const mark = path.join(dir, "ok.txt")
|
||||
await Bun.write(
|
||||
path.join(dir, "throws.ts"),
|
||||
[
|
||||
@@ -614,91 +624,51 @@ describe("plugin.loader.shared", () => {
|
||||
"",
|
||||
].join("\n"),
|
||||
)
|
||||
await Bun.write(
|
||||
path.join(dir, "ok.ts"),
|
||||
[
|
||||
"export default {",
|
||||
' id: "demo.ok",',
|
||||
" server: async () => {",
|
||||
` await Bun.write(${JSON.stringify(mark)}, "ok")`,
|
||||
" return {}",
|
||||
" },",
|
||||
"}",
|
||||
"",
|
||||
].join("\n"),
|
||||
)
|
||||
|
||||
await Bun.write(path.join(dir, "opencode.json"), JSON.stringify({ plugin: [file, ok] }, null, 2))
|
||||
await Bun.write(path.join(dir, "opencode.json"), JSON.stringify({ plugin: [file] }, null, 2))
|
||||
|
||||
return { mark }
|
||||
return { file }
|
||||
},
|
||||
})
|
||||
|
||||
await load(tmp.path)
|
||||
expect(await Bun.file(tmp.extra.mark).text()).toBe("ok")
|
||||
const errors = await errs(tmp.path)
|
||||
|
||||
expect(errors.some((x) => x.includes(`Failed to load plugin ${tmp.extra.file}: explode`))).toBe(true)
|
||||
})
|
||||
|
||||
test("continues loading plugins when plugin module has invalid export", async () => {
|
||||
test("publishes session.error when plugin module has invalid export", async () => {
|
||||
await using tmp = await tmpdir({
|
||||
init: async (dir) => {
|
||||
const file = pathToFileURL(path.join(dir, "invalid.ts")).href
|
||||
const ok = pathToFileURL(path.join(dir, "ok.ts")).href
|
||||
const mark = path.join(dir, "ok.txt")
|
||||
await Bun.write(
|
||||
path.join(dir, "invalid.ts"),
|
||||
["export default {", ' id: "demo.invalid",', " nope: true,", "}", ""].join("\n"),
|
||||
)
|
||||
await Bun.write(
|
||||
path.join(dir, "ok.ts"),
|
||||
[
|
||||
"export default {",
|
||||
' id: "demo.ok",',
|
||||
" server: async () => {",
|
||||
` await Bun.write(${JSON.stringify(mark)}, "ok")`,
|
||||
" return {}",
|
||||
" },",
|
||||
"}",
|
||||
"",
|
||||
].join("\n"),
|
||||
)
|
||||
|
||||
await Bun.write(path.join(dir, "opencode.json"), JSON.stringify({ plugin: [file, ok] }, null, 2))
|
||||
await Bun.write(path.join(dir, "opencode.json"), JSON.stringify({ plugin: [file] }, null, 2))
|
||||
|
||||
return { mark }
|
||||
return { file }
|
||||
},
|
||||
})
|
||||
|
||||
await load(tmp.path)
|
||||
expect(await Bun.file(tmp.extra.mark).text()).toBe("ok")
|
||||
const errors = await errs(tmp.path)
|
||||
|
||||
expect(errors.some((x) => x.includes(`Failed to load plugin ${tmp.extra.file}`))).toBe(true)
|
||||
})
|
||||
|
||||
test("continues loading plugins when plugin import fails", async () => {
|
||||
test("publishes session.error when plugin import fails", async () => {
|
||||
await using tmp = await tmpdir({
|
||||
init: async (dir) => {
|
||||
const missing = pathToFileURL(path.join(dir, "missing-plugin.ts")).href
|
||||
const ok = pathToFileURL(path.join(dir, "ok.ts")).href
|
||||
const mark = path.join(dir, "ok.txt")
|
||||
await Bun.write(
|
||||
path.join(dir, "ok.ts"),
|
||||
[
|
||||
"export default {",
|
||||
' id: "demo.ok",',
|
||||
" server: async () => {",
|
||||
` await Bun.write(${JSON.stringify(mark)}, "ok")`,
|
||||
" return {}",
|
||||
" },",
|
||||
"}",
|
||||
"",
|
||||
].join("\n"),
|
||||
)
|
||||
await Bun.write(path.join(dir, "opencode.json"), JSON.stringify({ plugin: [missing, ok] }, null, 2))
|
||||
await Bun.write(path.join(dir, "opencode.json"), JSON.stringify({ plugin: [missing] }, null, 2))
|
||||
|
||||
return { mark }
|
||||
return { missing }
|
||||
},
|
||||
})
|
||||
|
||||
await load(tmp.path)
|
||||
expect(await Bun.file(tmp.extra.mark).text()).toBe("ok")
|
||||
const errors = await errs(tmp.path)
|
||||
|
||||
expect(errors.some((x) => x.includes(`Failed to load plugin ${tmp.extra.missing}`))).toBe(true)
|
||||
})
|
||||
|
||||
test("loads object plugin via plugin.server", async () => {
|
||||
|
||||
@@ -147,7 +147,7 @@ describe("session messages endpoint", () => {
|
||||
|
||||
describe("session.prompt_async error handling", () => {
|
||||
test("prompt_async route has error handler for detached prompt call", async () => {
|
||||
const src = await Bun.file(new URL("../../src/server/instance/session.ts", import.meta.url)).text()
|
||||
const src = await Bun.file(new URL("../../src/server/routes/session.ts", import.meta.url)).text()
|
||||
const start = src.indexOf('"/:sessionID/prompt_async"')
|
||||
const end = src.indexOf('"/:sessionID/command"', start)
|
||||
expect(start).toBeGreaterThan(-1)
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { NodeFileSystem } from "@effect/platform-node"
|
||||
import { FetchHttpClient } from "effect/unstable/http"
|
||||
import { expect } from "bun:test"
|
||||
import { Cause, Effect, Exit, Fiber, Layer } from "effect"
|
||||
import path from "path"
|
||||
@@ -169,6 +170,7 @@ function makeHttp() {
|
||||
const todo = Todo.layer.pipe(Layer.provideMerge(deps))
|
||||
const registry = ToolRegistry.layer.pipe(
|
||||
Layer.provide(Skill.defaultLayer),
|
||||
Layer.provide(FetchHttpClient.layer),
|
||||
Layer.provideMerge(todo),
|
||||
Layer.provideMerge(question),
|
||||
Layer.provideMerge(deps),
|
||||
|
||||
@@ -53,6 +53,7 @@ import { ToolRegistry } from "../../src/tool/registry"
|
||||
import { Truncate } from "../../src/tool/truncate"
|
||||
import { AppFileSystem } from "../../src/filesystem"
|
||||
import * as CrossSpawnSpawner from "../../src/effect/cross-spawn-spawner"
|
||||
import { FetchHttpClient } from "effect/unstable/http"
|
||||
|
||||
Log.init({ print: false })
|
||||
|
||||
@@ -134,6 +135,7 @@ function makeHttp() {
|
||||
const todo = Todo.layer.pipe(Layer.provideMerge(deps))
|
||||
const registry = ToolRegistry.layer.pipe(
|
||||
Layer.provide(Skill.defaultLayer),
|
||||
Layer.provide(FetchHttpClient.layer),
|
||||
Layer.provideMerge(todo),
|
||||
Layer.provideMerge(question),
|
||||
Layer.provideMerge(deps),
|
||||
|
||||
@@ -3,6 +3,7 @@ import fs from "fs/promises"
|
||||
import path from "path"
|
||||
import { Effect, Layer, ManagedRuntime } from "effect"
|
||||
import { AppFileSystem } from "../../src/filesystem"
|
||||
import { Git } from "../../src/git"
|
||||
import { Global } from "../../src/global"
|
||||
import { Storage } from "../../src/storage/storage"
|
||||
import { tmpdir } from "../fixture/fixture"
|
||||
@@ -47,7 +48,7 @@ async function withStorage<T>(
|
||||
root: string,
|
||||
fn: (run: <A, E>(body: Effect.Effect<A, E, Storage.Service>) => Promise<A>) => Promise<T>,
|
||||
) {
|
||||
const rt = ManagedRuntime.make(Storage.layer.pipe(Layer.provide(layer(root))))
|
||||
const rt = ManagedRuntime.make(Storage.layer.pipe(Layer.provide(layer(root)), Layer.provide(Git.defaultLayer)))
|
||||
try {
|
||||
return await fn((body) => rt.runPromise(body))
|
||||
} finally {
|
||||
|
||||
Reference in New Issue
Block a user