mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-18 21:53:12 -04:00
Compare commits
18 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 34f9e71df8 | |||
| 02f3f3cb3e | |||
| b0c3a16ead | |||
| 594c395576 | |||
| b7402c264d | |||
| 3f79699bce | |||
| 67fe76057e | |||
| bac474aaa0 | |||
| 98c717cb5b | |||
| 5c8d46ab4b | |||
| d5e83fefda | |||
| 46378dda50 | |||
| b38d9d812f | |||
| 8df039d261 | |||
| c92fb2d41b | |||
| 958308c913 | |||
| 16390ca47d | |||
| 5ceeeb3e2c |
@@ -7,6 +7,7 @@ import { useMcpToggle } from "@/context/mcp"
|
||||
import { useWorkspaceLocation } from "@/context/location"
|
||||
import { useServerSDK } from "@/context/server-sdk"
|
||||
import { useData } from "@/context/server"
|
||||
import { pluginLabel } from "@/utils/plugin"
|
||||
import { ExternalLink } from "./external-link"
|
||||
|
||||
type SkillItem = {
|
||||
@@ -101,10 +102,10 @@ export const ProjectSettingsExtensions: Component = () => {
|
||||
() => (serverSDK.connection.status() === "connected" ? directorySDK().directory : undefined),
|
||||
(directory) => serverSDK.api.plugin.list({ location: { directory } }).then((result) => result.data),
|
||||
)
|
||||
const globalPlugins = createMemo(() => (globalPluginList.latest ?? []).map((item) => item.id))
|
||||
const globalPlugins = createMemo(() => (globalPluginList.latest ?? []).map(pluginLabel))
|
||||
const projectPlugins = createMemo(() => {
|
||||
const shared = new Set(globalPlugins())
|
||||
return (projectPluginList.latest ?? []).map((item) => item.id).filter((name) => !shared.has(name))
|
||||
return (projectPluginList.latest ?? []).map(pluginLabel).filter((name) => !shared.has(name))
|
||||
})
|
||||
|
||||
const serverSkills = createMemo(() => data.location.skill.list() ?? [])
|
||||
|
||||
@@ -6,6 +6,7 @@ import { useLanguage } from "@/context/language"
|
||||
import { useData } from "@/context/server"
|
||||
import { useServerSDK } from "@/context/server-sdk"
|
||||
import { useMcpToggle } from "@/context/mcp"
|
||||
import { pluginLabel } from "@/utils/plugin"
|
||||
import { ExternalLink } from "../external-link"
|
||||
import { InlineServerSelect } from "./parts/server-select"
|
||||
import "./settings-v2.css"
|
||||
@@ -44,7 +45,9 @@ export const SettingsExtensionsV2: Component = () => {
|
||||
() => serverSdk.connection.status() === "connected",
|
||||
() => serverSdk.api.plugin.list().then((result) => result.data),
|
||||
)
|
||||
const plugins = createMemo<PluginRowItem[]>(() => (pluginList.latest ?? []).map((item) => ({ name: item.id })))
|
||||
const plugins = createMemo<PluginRowItem[]>(() =>
|
||||
(pluginList.latest ?? []).map((item) => ({ name: pluginLabel(item) })),
|
||||
)
|
||||
|
||||
createEffect(() => {
|
||||
if (serverSdk.connection.status() !== "connected") return
|
||||
|
||||
@@ -6,6 +6,7 @@ import { useMcpToggle } from "@/context/mcp"
|
||||
import { useWorkspaceLocation } from "@/context/location"
|
||||
import { useData } from "@/context/server"
|
||||
import { useServerSDK } from "@/context/server-sdk"
|
||||
import { pluginLabel } from "@/utils/plugin"
|
||||
|
||||
const pluginEmptyMessage = (value: string, file: string): JSXElement => {
|
||||
const parts = value.split(file)
|
||||
@@ -38,7 +39,7 @@ export function StatusPopoverBody(props: { shown: boolean }) {
|
||||
() => (props.shown ? sdk().directory : undefined),
|
||||
(directory) => serverSDK.api.plugin.list({ location: { directory } }).then((result) => result.data),
|
||||
)
|
||||
const plugins = createMemo(() => (pluginList.latest ?? []).map((item) => item.id))
|
||||
const plugins = createMemo(() => (pluginList.latest ?? []).map(pluginLabel))
|
||||
const pluginCount = createMemo(() => plugins().length)
|
||||
const pluginEmpty = createMemo(() => pluginEmptyMessage(language.t("dialog.plugins.empty"), "opencode.json"))
|
||||
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
import type { PluginInfo } from "@opencode-ai/client"
|
||||
|
||||
export function pluginLabel(plugin: PluginInfo) {
|
||||
if (plugin.id) return plugin.id
|
||||
if (plugin.source.type === "package") return plugin.source.package
|
||||
if (plugin.source.type === "local") return plugin.source.path
|
||||
return plugin.source.type
|
||||
}
|
||||
@@ -109,7 +109,7 @@ for (const item of targets) {
|
||||
external: ["node-gyp"],
|
||||
format: "esm",
|
||||
minify: true,
|
||||
sourcemap: "inline",
|
||||
sourcemap: Script.channel === "dev" || Script.channel === "local" ? "inline" : "none",
|
||||
splitting: true,
|
||||
compile: {
|
||||
autoloadBunfig: false,
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
#!/usr/bin/env bun
|
||||
|
||||
import { NodeFileSystem } from "@effect/platform-node"
|
||||
import { Service } from "@opencode-ai/client/effect/service"
|
||||
import { ServiceStatus } from "@opencode-ai/protocol/groups/health"
|
||||
import { Schema } from "effect"
|
||||
import { Effect, Schema } from "effect"
|
||||
import fs from "node:fs/promises"
|
||||
import os from "node:os"
|
||||
import path from "node:path"
|
||||
@@ -63,28 +64,22 @@ try {
|
||||
})
|
||||
if (unauthorizedOpenApi.status !== 401)
|
||||
throw new Error("Compiled service exposed application routes without authentication")
|
||||
const unauthorizedStop = await fetch(new URL("/api/service/stop", info.url), {
|
||||
const stopRoute = await fetch(new URL("/api/service/stop", info.url), {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
headers: { ...headers, "content-type": "application/json" },
|
||||
body: JSON.stringify({ instanceID: info.id }),
|
||||
signal: AbortSignal.timeout(5_000),
|
||||
})
|
||||
if (unauthorizedStop.status !== 401) throw new Error("Compiled service accepted unauthenticated stop")
|
||||
if (stopRoute.status !== 404) throw new Error("Compiled service exposed the removed HTTP stop route")
|
||||
|
||||
const winner = processes.find((process) => process.pid === info.pid)
|
||||
const loser = processes.find((process) => process.pid !== info.pid)
|
||||
if (!winner || !loser) throw new Error("Compiled contenders did not elect one registered owner")
|
||||
if (!(await exitsWithin(loser, 10_000))) throw new Error("Losing compiled contender did not exit")
|
||||
|
||||
const stopped = await Schema.decodeUnknownPromise(ServiceStatus.StopResponse)(
|
||||
await fetch(new URL("/api/service/stop", info.url), {
|
||||
method: "POST",
|
||||
headers: { ...headers, "content-type": "application/json" },
|
||||
body: JSON.stringify({ instanceID: info.id }),
|
||||
signal: AbortSignal.timeout(5_000),
|
||||
}).then((response) => response.json()),
|
||||
await Effect.runPromise(
|
||||
Service.stop({ file: registration }).pipe(Effect.provide(NodeFileSystem.layer)),
|
||||
)
|
||||
if (!stopped.accepted) throw new Error("Compiled service rejected exact-instance stop")
|
||||
if (!(await exitsWithin(winner, 10_000))) throw new Error("Compiled service did not stop")
|
||||
for (let attempt = 0; attempt < 200 && (await Bun.file(registration).exists()); attempt++) await Bun.sleep(25)
|
||||
if (await Bun.file(registration).exists()) throw new Error("Compiled service registration was not removed")
|
||||
|
||||
@@ -275,15 +275,36 @@ const Root = Spec.make(typeof OPENCODE_CLI_NAME === "string" ? OPENCODE_CLI_NAME
|
||||
Spec.make("stop", { description: "Stop the background server" }),
|
||||
Spec.make("get", {
|
||||
description: "Get service configuration",
|
||||
params: { key: Argument.string("key").pipe(Argument.optional) },
|
||||
params: {
|
||||
key: Argument.string("key").pipe(Argument.withDescription("Service setting or env"), Argument.optional),
|
||||
name: Argument.string("name").pipe(
|
||||
Argument.withDescription("Environment variable name"),
|
||||
Argument.optional,
|
||||
),
|
||||
},
|
||||
}),
|
||||
Spec.make("set", {
|
||||
description: "Set service configuration",
|
||||
params: { key: Argument.string("key"), value: Argument.string("value") },
|
||||
params: {
|
||||
key: Argument.string("key").pipe(Argument.withDescription("Service setting or env")),
|
||||
value: Argument.string("value").pipe(
|
||||
Argument.withDescription("Setting value or environment variable name"),
|
||||
),
|
||||
nestedValue: Argument.string("env-value").pipe(
|
||||
Argument.withDescription("Environment variable value"),
|
||||
Argument.optional,
|
||||
),
|
||||
},
|
||||
}),
|
||||
Spec.make("unset", {
|
||||
description: "Unset service configuration",
|
||||
params: { key: Argument.string("key") },
|
||||
params: {
|
||||
key: Argument.string("key").pipe(Argument.withDescription("Service setting or env")),
|
||||
name: Argument.string("name").pipe(
|
||||
Argument.withDescription("Environment variable name"),
|
||||
Argument.optional,
|
||||
),
|
||||
},
|
||||
}),
|
||||
],
|
||||
}),
|
||||
|
||||
@@ -4,7 +4,7 @@ import { run } from "@opencode-ai/tui"
|
||||
import { Commands } from "../commands"
|
||||
import { Runtime } from "../../framework/runtime"
|
||||
import { Config } from "../../config"
|
||||
import { Context, Effect, FileSystem, Option } from "effect"
|
||||
import { Context, Effect, FileSystem, Option, Queue } from "effect"
|
||||
import { ServerConnection } from "../../services/server-connection"
|
||||
import { Updater } from "../../services/updater"
|
||||
import { UpdatePreflight } from "../../services/update-preflight"
|
||||
@@ -19,11 +19,21 @@ export default Runtime.handler(Commands, (input) =>
|
||||
if (requestedDirectory !== undefined) process.chdir(requestedDirectory)
|
||||
const preflight = UpdatePreflight.make()
|
||||
yield* Effect.addFinalizer(() => Effect.promise(() => preflight.close()))
|
||||
const serviceStarts = yield* Queue.unbounded<{
|
||||
readonly reason: "missing" | "version-mismatch"
|
||||
readonly previousVersion?: string
|
||||
}>()
|
||||
yield* Queue.take(serviceStarts).pipe(
|
||||
Effect.flatMap((event) => Effect.logInfo("background service starting", event)),
|
||||
Effect.forever,
|
||||
Effect.forkScoped,
|
||||
)
|
||||
const server = yield* ServerConnection.resolve({
|
||||
server: requestedServer,
|
||||
standalone: input.standalone,
|
||||
mismatch: "replace",
|
||||
onStart: (reason, previousVersion) => {
|
||||
Queue.offerUnsafe(serviceStarts, { reason, previousVersion })
|
||||
if (reason === "version-mismatch" && preflight.begin(previousVersion)) return
|
||||
process.stderr.write(
|
||||
reason === "version-mismatch"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { EOL } from "node:os"
|
||||
import { Effect } from "effect"
|
||||
import { OpenCode } from "@opencode-ai/client"
|
||||
import { OpenCode, type PluginInfo } from "@opencode-ai/client"
|
||||
import { Service } from "@opencode-ai/client/effect/service"
|
||||
import { Commands } from "../../commands"
|
||||
import { Runtime } from "../../../framework/runtime"
|
||||
@@ -14,11 +14,18 @@ export default Runtime.handler(
|
||||
const endpoint = found ?? (yield* Service.ensure(options))
|
||||
const client = OpenCode.make({ baseUrl: endpoint.url, headers: Service.headers(endpoint) })
|
||||
const response = yield* Effect.promise(() => client.plugin.list({ location: { directory: process.cwd() } }))
|
||||
const plugins = response.data.toSorted((a, b) => a.id.localeCompare(b.id))
|
||||
const plugins = response.data.toSorted((a, b) => name(a).localeCompare(name(b)))
|
||||
if (plugins.length === 0) {
|
||||
process.stdout.write("No plugins loaded" + EOL)
|
||||
return
|
||||
}
|
||||
process.stdout.write(plugins.map((plugin) => plugin.id).join(EOL) + EOL)
|
||||
process.stdout.write(plugins.map(name).join(EOL) + EOL)
|
||||
}),
|
||||
)
|
||||
|
||||
function name(plugin: PluginInfo) {
|
||||
if (plugin.id) return plugin.id
|
||||
if (plugin.source.type === "package") return plugin.source.package
|
||||
if (plugin.source.type === "local") return plugin.source.path
|
||||
return plugin.source.type
|
||||
}
|
||||
|
||||
@@ -7,6 +7,8 @@ import { ServiceConfig } from "../../../services/service-config"
|
||||
export default Runtime.handler(
|
||||
Commands.commands.service.commands.get,
|
||||
Effect.fn("cli.service.get")(function* (input) {
|
||||
process.stdout.write((yield* ServiceConfig.get(Option.getOrUndefined(input.key))) + EOL)
|
||||
process.stdout.write(
|
||||
(yield* ServiceConfig.get(Option.getOrUndefined(input.key), Option.getOrUndefined(input.name))) + EOL,
|
||||
)
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Effect } from "effect"
|
||||
import { Effect, Option } from "effect"
|
||||
import { Commands } from "../../commands"
|
||||
import { Runtime } from "../../../framework/runtime"
|
||||
import { ServiceConfig } from "../../../services/service-config"
|
||||
@@ -6,6 +6,6 @@ import { ServiceConfig } from "../../../services/service-config"
|
||||
export default Runtime.handler(
|
||||
Commands.commands.service.commands.set,
|
||||
Effect.fn("cli.service.set")(function* (input) {
|
||||
yield* ServiceConfig.set(input.key, input.value)
|
||||
yield* ServiceConfig.set(input.key, input.value, Option.getOrUndefined(input.nestedValue))
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Effect } from "effect"
|
||||
import { Effect, Option } from "effect"
|
||||
import { Commands } from "../../commands"
|
||||
import { Runtime } from "../../../framework/runtime"
|
||||
import { ServiceConfig } from "../../../services/service-config"
|
||||
@@ -6,6 +6,6 @@ import { ServiceConfig } from "../../../services/service-config"
|
||||
export default Runtime.handler(
|
||||
Commands.commands.service.commands.unset,
|
||||
Effect.fn("cli.service.unset")(function* (input) {
|
||||
yield* ServiceConfig.unset(input.key)
|
||||
yield* ServiceConfig.unset(input.key, Option.getOrUndefined(input.name))
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -59,6 +59,21 @@ const Handlers = Runtime.handlers(Commands, {
|
||||
|
||||
Effect.gen(function* () {
|
||||
yield* Heap.listen
|
||||
const runFork = Effect.runForkWith(yield* Effect.context<never>())
|
||||
const uncaughtException = (cause: Error, origin: "uncaughtException" | "unhandledRejection") => {
|
||||
runFork(Effect.logError("uncaught exception", { cause, origin }))
|
||||
}
|
||||
const unhandledRejection = (cause: unknown) => {
|
||||
runFork(Effect.logError("unhandled rejection", { cause }))
|
||||
}
|
||||
process.on("uncaughtException", uncaughtException)
|
||||
process.on("unhandledRejection", unhandledRejection)
|
||||
yield* Effect.addFinalizer(() =>
|
||||
Effect.sync(() => {
|
||||
process.off("uncaughtException", uncaughtException)
|
||||
process.off("unhandledRejection", unhandledRejection)
|
||||
}),
|
||||
)
|
||||
yield* Effect.logInfo("cli starting", {
|
||||
version: OPENCODE_VERSION,
|
||||
channel: OPENCODE_CHANNEL,
|
||||
@@ -67,6 +82,12 @@ Effect.gen(function* () {
|
||||
})
|
||||
return yield* Runtime.run(Commands, Handlers, { version: OPENCODE_VERSION })
|
||||
}).pipe(
|
||||
Effect.catchCause((cause) =>
|
||||
Effect.logError("cli process failed", {
|
||||
cause,
|
||||
args: process.argv.slice(2),
|
||||
}).pipe(Effect.andThen(Effect.failCause(cause))),
|
||||
),
|
||||
Effect.annotateLogs({ role: "cli" }),
|
||||
Effect.provide(Config.layer),
|
||||
Effect.provide(Updater.layer),
|
||||
|
||||
@@ -117,7 +117,6 @@ const processEffect = Effect.fnUntraced(function* (options: Options) {
|
||||
serviceOptions === undefined
|
||||
? undefined
|
||||
: {
|
||||
instanceID,
|
||||
onListen: (address, shutdown) =>
|
||||
Effect.gen(function* () {
|
||||
if (!config.password) yield* ServiceConfig.password(password)
|
||||
@@ -180,27 +179,38 @@ const register = Effect.fnUntraced(function* (
|
||||
password,
|
||||
}
|
||||
const encoded = yield* encodeInfo(info)
|
||||
const current = fs.readFileString(file).pipe(
|
||||
Effect.flatMap(decodeInfo),
|
||||
Effect.orElseSucceed(() => undefined),
|
||||
)
|
||||
const owns = (found: Info | undefined) =>
|
||||
found?.id === info.id &&
|
||||
const current = fs.readFileString(file).pipe(Effect.flatMap(decodeInfo))
|
||||
const owns = (found: Info) =>
|
||||
found.id === info.id &&
|
||||
found.version === info.version &&
|
||||
found.url === info.url &&
|
||||
found.pid === info.pid &&
|
||||
found.password === info.password
|
||||
yield* fs.writeFileString(temp, encoded, { mode: 0o600 }).pipe(Effect.andThen(fs.rename(temp, file)))
|
||||
yield* current.pipe(
|
||||
Effect.filterOrFail(owns),
|
||||
Effect.repeat(Schedule.spaced("5 seconds")),
|
||||
Effect.tapError(() =>
|
||||
Effect.logWarning("managed service registration lost; shutting down", {
|
||||
Effect.catchCause((cause) =>
|
||||
Effect.logWarning("managed service registration check failed; shutting down", {
|
||||
cause,
|
||||
serviceID: id,
|
||||
servicePID: process.pid,
|
||||
registration: file,
|
||||
}),
|
||||
}).pipe(Effect.andThen(Effect.failCause(cause))),
|
||||
),
|
||||
Effect.tap((found) =>
|
||||
owns(found)
|
||||
? Effect.void
|
||||
: Effect.logWarning("managed service registration replaced; shutting down", {
|
||||
serviceID: id,
|
||||
servicePID: process.pid,
|
||||
registration: file,
|
||||
observedServiceID: found.id,
|
||||
observedServicePID: found.pid,
|
||||
observedVersion: found.version,
|
||||
observedURL: found.url,
|
||||
}),
|
||||
),
|
||||
Effect.filterOrFail(owns),
|
||||
Effect.repeat(Schedule.spaced("5 seconds")),
|
||||
Effect.ignore,
|
||||
Effect.andThen(shutdown),
|
||||
Effect.forkScoped,
|
||||
|
||||
@@ -15,10 +15,11 @@ export const Info = Schema.Struct({
|
||||
hostname: Schema.optional(Schema.String),
|
||||
port: Schema.optional(Schema.Int.check(Schema.isGreaterThanOrEqualTo(1), Schema.isLessThanOrEqualTo(65_535))),
|
||||
password: Schema.optional(Schema.String),
|
||||
env: Schema.optional(Schema.Record(Schema.String, Schema.String)),
|
||||
})
|
||||
export type Info = typeof Info.Type
|
||||
|
||||
const keys = ["hostname", "port", "password"] as const
|
||||
const keys = ["hostname", "port", "password", "env"] as const
|
||||
type Key = (typeof keys)[number]
|
||||
|
||||
const decodeInfo = Schema.decodeUnknownEffect(Schema.fromJsonString(Info))
|
||||
@@ -76,7 +77,7 @@ export const migrateConfig = Effect.fnUntraced(function* (legacy: string, file:
|
||||
})
|
||||
|
||||
function configKey(key: string): Key {
|
||||
if (key === "hostname" || key === "port" || key === "password") return key
|
||||
if (key === "hostname" || key === "port" || key === "password" || key === "env") return key
|
||||
throw new Error(`Unknown service config key: ${key}`)
|
||||
}
|
||||
|
||||
@@ -104,6 +105,7 @@ export const options = Effect.fnUntraced(function* (input: { readonly checkVersi
|
||||
return {
|
||||
file,
|
||||
version: input.checkVersion ? OPENCODE_VERSION : undefined,
|
||||
env: (yield* read()).env,
|
||||
command: [
|
||||
...selfCommand(),
|
||||
"serve",
|
||||
@@ -141,12 +143,14 @@ export const password = Effect.fn("cli.service-config.password")(function* (valu
|
||||
return next
|
||||
})
|
||||
|
||||
export const get = Effect.fn("cli.service-config.get")(function* (key?: string) {
|
||||
export const get = Effect.fn("cli.service-config.get")(function* (key?: string, name?: string) {
|
||||
if (key === undefined) {
|
||||
const { password: _password, ...safe } = yield* read()
|
||||
return JSON.stringify(safe, null, 2)
|
||||
}
|
||||
switch (configKey(key)) {
|
||||
const selected = configKey(key)
|
||||
if (selected !== "env" && name !== undefined) throw new Error(`Usage: opencode service get ${selected}`)
|
||||
switch (selected) {
|
||||
case "hostname": {
|
||||
return (yield* read()).hostname ?? ""
|
||||
}
|
||||
@@ -157,12 +161,19 @@ export const get = Effect.fn("cli.service-config.get")(function* (key?: string)
|
||||
case "password": {
|
||||
return yield* password()
|
||||
}
|
||||
case "env": {
|
||||
const env = (yield* read()).env ?? {}
|
||||
return name === undefined ? JSON.stringify(env, null, 2) : (env[name] ?? "")
|
||||
}
|
||||
}
|
||||
throw new Error(`Unknown service config key: ${key}`)
|
||||
})
|
||||
|
||||
export const set = Effect.fn("cli.service-config.set")(function* (key: string, value: string) {
|
||||
switch (configKey(key)) {
|
||||
export const set = Effect.fn("cli.service-config.set")(function* (key: string, value: string, nestedValue?: string) {
|
||||
const selected = configKey(key)
|
||||
if (selected !== "env" && nestedValue !== undefined)
|
||||
throw new Error(`Usage: opencode service set ${selected} <value>`)
|
||||
switch (selected) {
|
||||
case "hostname": {
|
||||
yield* Service.stop(yield* options())
|
||||
yield* write({ ...(yield* read()), hostname: value })
|
||||
@@ -180,11 +191,20 @@ export const set = Effect.fn("cli.service-config.set")(function* (key: string, v
|
||||
yield* password(value)
|
||||
return
|
||||
}
|
||||
case "env": {
|
||||
if (nestedValue === undefined) throw new Error("Usage: opencode service set env <key> <value>")
|
||||
yield* Service.stop(yield* options())
|
||||
const existing = yield* read()
|
||||
yield* write({ ...existing, env: { ...existing.env, [value]: nestedValue } })
|
||||
return
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
export const unset = Effect.fn("cli.service-config.unset")(function* (key: string) {
|
||||
switch (configKey(key)) {
|
||||
export const unset = Effect.fn("cli.service-config.unset")(function* (key: string, name?: string) {
|
||||
const selected = configKey(key)
|
||||
if (selected !== "env" && name !== undefined) throw new Error(`Usage: opencode service unset ${selected}`)
|
||||
switch (selected) {
|
||||
case "hostname": {
|
||||
yield* Service.stop(yield* options())
|
||||
const { hostname: _hostname, ...next } = yield* read()
|
||||
@@ -203,6 +223,15 @@ export const unset = Effect.fn("cli.service-config.unset")(function* (key: strin
|
||||
yield* write(next)
|
||||
return
|
||||
}
|
||||
case "env": {
|
||||
if (name === undefined) throw new Error("Usage: opencode service unset env <key>")
|
||||
yield* Service.stop(yield* options())
|
||||
const existing = yield* read()
|
||||
const { [name]: _removed, ...env } = existing.env ?? {}
|
||||
const { env: _existingEnv, ...rest } = existing
|
||||
yield* write(Object.keys(env).length === 0 ? rest : { ...rest, env })
|
||||
return
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@@ -60,6 +60,44 @@ test("local channel stores service config with the local service filename", asyn
|
||||
}
|
||||
})
|
||||
|
||||
test("service config manages environment variables", async () => {
|
||||
const root = await fs.mkdtemp(path.join(os.tmpdir(), "opencode-service-env-"))
|
||||
const layer = Global.layerWith({ config: path.join(root, "config"), state: path.join(root, "state") })
|
||||
try {
|
||||
await Effect.runPromise(
|
||||
ServiceConfig.set("env", "OPENCODE_SERVICE_ENV_TEST", "configured").pipe(
|
||||
Effect.provide(layer),
|
||||
Effect.provide(NodeFileSystem.layer),
|
||||
),
|
||||
)
|
||||
expect(
|
||||
await Effect.runPromise(
|
||||
ServiceConfig.get("env", "OPENCODE_SERVICE_ENV_TEST").pipe(
|
||||
Effect.provide(layer),
|
||||
Effect.provide(NodeFileSystem.layer),
|
||||
),
|
||||
),
|
||||
).toBe("configured")
|
||||
expect(
|
||||
(
|
||||
await Effect.runPromise(
|
||||
ServiceConfig.options().pipe(Effect.provide(layer), Effect.provide(NodeFileSystem.layer)),
|
||||
)
|
||||
).env,
|
||||
).toEqual({ OPENCODE_SERVICE_ENV_TEST: "configured" })
|
||||
|
||||
await Effect.runPromise(
|
||||
ServiceConfig.unset("env", "OPENCODE_SERVICE_ENV_TEST").pipe(
|
||||
Effect.provide(layer),
|
||||
Effect.provide(NodeFileSystem.layer),
|
||||
),
|
||||
)
|
||||
expect(await Bun.file(path.join(root, "config", "service-local.json")).json()).toEqual({})
|
||||
} finally {
|
||||
await fs.rm(root, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
test("service filenames share release channels and identify preview channels", () => {
|
||||
expect(ServiceConfig.filename("latest")).toBe("service.json")
|
||||
expect(ServiceConfig.filename("dev")).toBe("service.json")
|
||||
|
||||
@@ -41,13 +41,8 @@ import type { Config } from "@opencode-ai/schema/config"
|
||||
export type Endpoint0_0Output = { readonly healthy: true; readonly version: string; readonly pid: number }
|
||||
export type HealthGetOperation<E = never> = () => Effect.Effect<Endpoint0_0Output, E>
|
||||
|
||||
export type Endpoint0_1Input = { readonly instanceID: string }
|
||||
export type Endpoint0_1Output = { readonly accepted: boolean }
|
||||
export type HealthStopOperation<E = never> = (input: Endpoint0_1Input) => Effect.Effect<Endpoint0_1Output, E>
|
||||
|
||||
export interface HealthApi<E = never> {
|
||||
readonly get: HealthGetOperation<E>
|
||||
readonly stop: HealthStopOperation<E>
|
||||
}
|
||||
|
||||
export type Endpoint1_0Output = { readonly urls: ReadonlyArray<string> }
|
||||
|
||||
@@ -6,8 +6,6 @@ import { HttpApiClient } from "effect/unstable/httpapi"
|
||||
import { ClientApi } from "../../contract"
|
||||
import type {
|
||||
Endpoint0_0Output,
|
||||
Endpoint0_1Input,
|
||||
Endpoint0_1Output,
|
||||
Endpoint1_0Output,
|
||||
Endpoint2_0Input,
|
||||
Endpoint2_0Output,
|
||||
@@ -248,12 +246,7 @@ const preserveStream =
|
||||
const Endpoint0_0 = (raw: RawClient["server.health"]) => () =>
|
||||
preserveEffect<Endpoint0_0Output>()(raw["health.get"]({}).pipe(Effect.mapError(mapClientError)))
|
||||
|
||||
const Endpoint0_1 = (raw: RawClient["server.health"]) => (input: Endpoint0_1Input) =>
|
||||
preserveEffect<Endpoint0_1Output>()(
|
||||
raw["health.stop"]({ payload: { instanceID: input["instanceID"] } }).pipe(Effect.mapError(mapClientError)),
|
||||
)
|
||||
|
||||
const adaptGroup0 = (raw: RawClient["server.health"]) => ({ get: Endpoint0_0(raw), stop: Endpoint0_1(raw) })
|
||||
const adaptGroup0 = (raw: RawClient["server.health"]) => ({ get: Endpoint0_0(raw) })
|
||||
|
||||
const Endpoint1_0 = (raw: RawClient["server.server"]) => () =>
|
||||
preserveEffect<Endpoint1_0Output>()(raw["server.get"]({}).pipe(Effect.mapError(mapClientError)))
|
||||
|
||||
@@ -71,7 +71,7 @@ export const ensure = Effect.fn("service.ensure")(function* (options: EnsureOpti
|
||||
if (command === undefined) return yield* Effect.fail(new Error("Missing service command"))
|
||||
return yield* Effect.try({
|
||||
try: () => {
|
||||
return spawnServiceContender(command, args)
|
||||
return spawnServiceContender(command, args, options.env)
|
||||
},
|
||||
catch: (cause) => new Error("Failed to start server", { cause }),
|
||||
})
|
||||
@@ -87,7 +87,7 @@ export const ensure = Effect.fn("service.ensure")(function* (options: EnsureOpti
|
||||
}
|
||||
if (timeouts.count >= 3) {
|
||||
yield* announce("missing")
|
||||
yield* evict(info, options, timing)
|
||||
yield* terminate(info, options, timing)
|
||||
timeouts = undefined
|
||||
lastSpawn = Date.now() - spawnDelay
|
||||
}
|
||||
@@ -100,7 +100,7 @@ export const ensure = Effect.fn("service.ensure")(function* (options: EnsureOpti
|
||||
return yield* Effect.fail(new Error("Background service failed to start"))
|
||||
if (compatible) return Option.none<LocalService>()
|
||||
yield* announce("version-mismatch", service.version)
|
||||
yield* kill(service, options, timing).pipe(Effect.ignore)
|
||||
yield* terminate(service.info, options, timing).pipe(Effect.ignore)
|
||||
lastSpawn = 0
|
||||
return Option.none<LocalService>()
|
||||
} else if (lastSpawn === 0 && info !== undefined) lastSpawn = Date.now()
|
||||
@@ -133,8 +133,8 @@ export const ensure = Effect.fn("service.ensure")(function* (options: EnsureOpti
|
||||
|
||||
/** Stop the registered local service. */
|
||||
export const stop = Effect.fn("service.stop")(function* (options: StopOptions = {}) {
|
||||
const existing = yield* find(options)
|
||||
if (existing !== undefined) yield* kill(existing, options, defaultEnsureTiming)
|
||||
const info = yield* read(options.file)
|
||||
if (info !== undefined) yield* terminate(info, options, defaultEnsureTiming)
|
||||
})
|
||||
|
||||
function fallback() {
|
||||
@@ -243,12 +243,6 @@ const registered = Effect.fnUntraced(function* (file?: string, allowLegacy = fal
|
||||
return { info, ...(yield* probeResult(info, allowLegacy, timeout)) }
|
||||
})
|
||||
|
||||
// Health-checked lookup without the version gate: lifecycle operations must be
|
||||
// able to see (and replace or stop) a server from a different version.
|
||||
const find = Effect.fnUntraced(function* (options: { readonly file?: string }) {
|
||||
return (yield* registered(options.file, true)).service
|
||||
})
|
||||
|
||||
// 50ms cadence bounded at ~5s, shared by stop escalation and each ensure
|
||||
// discovery window.
|
||||
const poll = (timing: EnsureTiming) =>
|
||||
@@ -269,59 +263,21 @@ function same(left: Info, right: Info) {
|
||||
return left.id === right.id && left.version === right.version && left.url === right.url && left.pid === right.pid
|
||||
}
|
||||
|
||||
const evict = Effect.fnUntraced(function* (info: Info, options: { readonly file?: string }, timing: EnsureTiming) {
|
||||
const terminate = Effect.fnUntraced(function* (info: Info, options: { readonly file?: string }, timing: EnsureTiming) {
|
||||
const current = yield* read(options.file)
|
||||
if (current === undefined || !same(current, info)) return
|
||||
yield* signal(info.pid, "SIGTERM")
|
||||
const done = yield* stopped(info.pid).pipe(Effect.retry(poll(timing)), Effect.option)
|
||||
if (Option.isSome(done)) return
|
||||
|
||||
if (Option.isNone(done)) {
|
||||
const latest = yield* read(options.file)
|
||||
if (latest === undefined || !same(latest, info)) return
|
||||
yield* signal(info.pid, "SIGKILL")
|
||||
yield* stopped(info.pid).pipe(Effect.retry(poll(timing)))
|
||||
}
|
||||
const latest = yield* read(options.file)
|
||||
if (latest === undefined || !same(latest, info)) return
|
||||
yield* signal(info.pid, "SIGKILL")
|
||||
yield* stopped(info.pid).pipe(Effect.retry(poll(timing)))
|
||||
})
|
||||
|
||||
const kill = Effect.fnUntraced(function* (
|
||||
service: LocalService,
|
||||
options: { readonly file?: string },
|
||||
timing: EnsureTiming,
|
||||
) {
|
||||
const requested = yield* requestStop(service, timing.requestTimeout)
|
||||
if (requested === "rejected") return
|
||||
if (requested === "unsupported") {
|
||||
// A stale registration may point at a reused PID. Authenticate again
|
||||
// immediately before the legacy signal fallback.
|
||||
const current = yield* find(options)
|
||||
if (current === undefined || !same(current.info, service.info)) return
|
||||
yield* signal(service.info.pid, "SIGTERM")
|
||||
}
|
||||
const done = yield* stopped(service.info.pid).pipe(Effect.retry(poll(timing)), Effect.option)
|
||||
if (Option.isSome(done)) return
|
||||
|
||||
const latest = yield* find(options)
|
||||
if (latest === undefined || !same(latest.info, service.info)) return
|
||||
yield* signal(service.info.pid, "SIGKILL")
|
||||
yield* stopped(service.info.pid).pipe(Effect.retry(poll(timing)))
|
||||
})
|
||||
|
||||
const decodeStopResponse = Schema.decodeUnknownOption(ServiceStatus.StopResponse)
|
||||
|
||||
const requestStop = Effect.fnUntraced(function* (service: LocalService, timeout = defaultEnsureTiming.requestTimeout) {
|
||||
if (service.info.id === undefined || service.legacy) return "unsupported" as const
|
||||
const response = yield* Effect.tryPromise(() =>
|
||||
fetch(new URL("/api/service/stop", service.info.url), {
|
||||
method: "POST",
|
||||
headers: { ...headers(service.endpoint), "content-type": "application/json" },
|
||||
body: JSON.stringify({ instanceID: service.info.id }),
|
||||
signal: AbortSignal.timeout(timeout),
|
||||
}),
|
||||
).pipe(Effect.option, Effect.map(Option.getOrUndefined))
|
||||
if (response === undefined || response.status === 404 || response.status === 405) return "unsupported" as const
|
||||
const body = yield* Effect.tryPromise(() => response.json()).pipe(Effect.option, Effect.map(Option.getOrUndefined))
|
||||
const decoded = decodeStopResponse(body)
|
||||
if (!response.ok || Option.isNone(decoded) || !decoded.value.accepted) return "rejected" as const
|
||||
return "accepted" as const
|
||||
const fs = yield* FileSystem.FileSystem
|
||||
yield* fs.remove(options.file ?? fallback()).pipe(Effect.ignore)
|
||||
})
|
||||
|
||||
/** Effect-based local service lifecycle operations. */
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
import type {
|
||||
HealthGetOutput,
|
||||
HealthStopInput,
|
||||
HealthStopOutput,
|
||||
ServerGetOutput,
|
||||
LocationGetInput,
|
||||
LocationGetOutput,
|
||||
@@ -367,18 +365,6 @@ export function make(options: ClientOptions) {
|
||||
{ method: "GET", path: `/api/health`, successStatus: 200, declaredStatuses: [401, 400], empty: false },
|
||||
requestOptions,
|
||||
),
|
||||
stop: (input: HealthStopInput, requestOptions?: RequestOptions) =>
|
||||
request<HealthStopOutput>(
|
||||
{
|
||||
method: "POST",
|
||||
path: `/api/service/stop`,
|
||||
body: { instanceID: input["instanceID"] },
|
||||
successStatus: 200,
|
||||
declaredStatuses: [401, 400],
|
||||
empty: false,
|
||||
},
|
||||
requestOptions,
|
||||
),
|
||||
},
|
||||
server: {
|
||||
get: (requestOptions?: RequestOptions) =>
|
||||
|
||||
@@ -2,8 +2,6 @@ export type JsonValue = null | boolean | number | string | Array<JsonValue> | {
|
||||
|
||||
export type ServiceHealth = { healthy: true; version: string; pid: number }
|
||||
|
||||
export type ServiceStopResponse = { accepted: boolean }
|
||||
|
||||
export type ModelRef = { id: string; providerID: string; variant?: string }
|
||||
|
||||
export type ProviderSettings = { [x: string]: any }
|
||||
@@ -12,7 +10,11 @@ export type AgentColor = string
|
||||
|
||||
export type PermissionEffect = "allow" | "deny" | "ask"
|
||||
|
||||
export type PluginInfo = { id: string }
|
||||
export type PluginSource =
|
||||
| { type: "builtin" }
|
||||
| { type: "package"; package: string }
|
||||
| { type: "local"; path: string }
|
||||
| { type: "sdk" }
|
||||
|
||||
export type SessionForkBoundary = { type: "before"; messageID: string } | { type: "through"; messageID: string }
|
||||
|
||||
@@ -198,6 +200,10 @@ export type ProviderRequest = {
|
||||
|
||||
export type PermissionRule = { action: string; resource: string; effect: PermissionEffect }
|
||||
|
||||
export type PluginInfo =
|
||||
| { id: string; source: PluginSource; status: "active"; tui: boolean }
|
||||
| { id?: string; source: PluginSource; status: "failed"; error: string; tui: boolean }
|
||||
|
||||
export type TokenUsageInfo = {
|
||||
input: number
|
||||
output: number
|
||||
@@ -2273,10 +2279,6 @@ export const isWorktreeError = (value: unknown): value is WorktreeError =>
|
||||
|
||||
export type HealthGetOutput = ServiceHealth
|
||||
|
||||
export type HealthStopInput = { readonly instanceID: { readonly instanceID: string }["instanceID"] }
|
||||
|
||||
export type HealthStopOutput = ServiceStopResponse
|
||||
|
||||
export type ServerGetOutput = { urls: Array<string> }
|
||||
|
||||
export type LocationGetInput = {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { readFile } from "node:fs/promises"
|
||||
import { readFile, rm } from "node:fs/promises"
|
||||
import { homedir } from "node:os"
|
||||
import { join } from "node:path"
|
||||
import type { DiscoverOptions, Endpoint, Info, EnsureOptions, StopOptions } from "../service.js"
|
||||
@@ -10,7 +10,7 @@ import {
|
||||
} from "../service-contender.js"
|
||||
import { defaultEnsureTiming, ensureTiming, type EnsureTiming } from "../service-timing.js"
|
||||
import { matchesVersion } from "../service-version.js"
|
||||
import type { ServiceHealth, ServiceStopResponse } from "./generated/types.js"
|
||||
import type { ServiceHealth } from "./generated/types.js"
|
||||
|
||||
export * from "../service.js"
|
||||
|
||||
@@ -51,7 +51,7 @@ export async function ensure(options: EnsureOptions = {}): Promise<Endpoint> {
|
||||
const [command, ...args] = options.command ?? ["opencode", "serve", "--service"]
|
||||
if (command === undefined) throw new Error("Missing service command")
|
||||
try {
|
||||
return spawnServiceContender(command, args)
|
||||
return spawnServiceContender(command, args, options.env)
|
||||
} catch (cause) {
|
||||
throw new Error("Failed to start server", { cause })
|
||||
}
|
||||
@@ -68,7 +68,7 @@ export async function ensure(options: EnsureOptions = {}): Promise<Endpoint> {
|
||||
}
|
||||
if (timeouts.count >= 3) {
|
||||
announce("missing")
|
||||
await evict(registration.info, options, timing)
|
||||
await terminate(registration.info, options, timing)
|
||||
timeouts = undefined
|
||||
lastSpawn = Date.now() - spawnDelay
|
||||
}
|
||||
@@ -82,7 +82,7 @@ export async function ensure(options: EnsureOptions = {}): Promise<Endpoint> {
|
||||
if (compatible && service.state === "failed") throw new Error("Background service failed to start")
|
||||
if (!compatible) {
|
||||
announce("version-mismatch", service.version)
|
||||
await kill(service, options, timing).catch(() => undefined)
|
||||
await terminate(service.info, options, timing).catch(() => undefined)
|
||||
lastSpawn = 0
|
||||
}
|
||||
} else {
|
||||
@@ -110,8 +110,8 @@ export async function ensure(options: EnsureOptions = {}): Promise<Endpoint> {
|
||||
|
||||
/** Stop the registered local service. */
|
||||
export async function stop(options: StopOptions = {}) {
|
||||
const existing = await find(options)
|
||||
if (existing !== undefined) await kill(existing, options, defaultEnsureTiming)
|
||||
const info = await read(options.file)
|
||||
if (info !== undefined) await terminate(info, options, defaultEnsureTiming)
|
||||
}
|
||||
|
||||
function fallback() {
|
||||
@@ -199,10 +199,6 @@ async function registered(file?: string, allowLegacy = false, timeout?: number)
|
||||
return { info, ...(await probeResult(info, allowLegacy, timeout)) }
|
||||
}
|
||||
|
||||
async function find(options: { readonly file?: string }) {
|
||||
return (await registered(options.file, true)).service
|
||||
}
|
||||
|
||||
function signal(pid: number, name: NodeJS.Signals) {
|
||||
try {
|
||||
process.kill(pid, name)
|
||||
@@ -230,47 +226,19 @@ function same(left: Info, right: Info) {
|
||||
return left.id === right.id && left.version === right.version && left.url === right.url && left.pid === right.pid
|
||||
}
|
||||
|
||||
async function evict(info: Info, options: { readonly file?: string }, timing: EnsureTiming) {
|
||||
async function terminate(info: Info, options: { readonly file?: string }, timing: EnsureTiming) {
|
||||
const current = await read(options.file)
|
||||
if (current === undefined || !same(current, info)) return
|
||||
signal(info.pid, "SIGTERM")
|
||||
if (await waitUntilStopped(info.pid, timing)) return
|
||||
|
||||
if (!(await waitUntilStopped(info.pid, timing))) {
|
||||
const latest = await read(options.file)
|
||||
if (latest === undefined || !same(latest, info)) return
|
||||
signal(info.pid, "SIGKILL")
|
||||
if (!(await waitUntilStopped(info.pid, timing))) throw new Error(`Server process ${info.pid} is still running`)
|
||||
}
|
||||
const latest = await read(options.file)
|
||||
if (latest === undefined || !same(latest, info)) return
|
||||
signal(info.pid, "SIGKILL")
|
||||
if (!(await waitUntilStopped(info.pid, timing))) throw new Error(`Server process ${info.pid} is still running`)
|
||||
}
|
||||
|
||||
async function kill(service: LocalService, options: { readonly file?: string }, timing: EnsureTiming) {
|
||||
const requested = await requestStop(service, timing.requestTimeout)
|
||||
if (requested === "rejected") return
|
||||
if (requested === "unsupported") {
|
||||
const current = await find(options)
|
||||
if (current === undefined || !same(current.info, service.info)) return
|
||||
signal(service.info.pid, "SIGTERM")
|
||||
}
|
||||
if (await waitUntilStopped(service.info.pid, timing)) return
|
||||
|
||||
const latest = await find(options)
|
||||
if (latest === undefined || !same(latest.info, service.info)) return
|
||||
signal(service.info.pid, "SIGKILL")
|
||||
if (!(await waitUntilStopped(service.info.pid, timing)))
|
||||
throw new Error(`Server process ${service.info.pid} is still running`)
|
||||
}
|
||||
|
||||
async function requestStop(service: LocalService, timeout = defaultEnsureTiming.requestTimeout) {
|
||||
if (service.info.id === undefined || service.legacy) return "unsupported" as const
|
||||
const response = await fetch(new URL("/api/service/stop", service.info.url), {
|
||||
method: "POST",
|
||||
headers: { ...headers(service.endpoint), "content-type": "application/json" },
|
||||
body: JSON.stringify({ instanceID: service.info.id }),
|
||||
signal: AbortSignal.timeout(timeout),
|
||||
}).catch(() => undefined)
|
||||
if (response === undefined || response.status === 404 || response.status === 405) return "unsupported" as const
|
||||
const body = (await response.json().catch(() => undefined)) as ServiceStopResponse | undefined
|
||||
if (!response.ok || body?.accepted !== true) return "rejected" as const
|
||||
return "accepted" as const
|
||||
await rm(options.file ?? fallback(), { force: true })
|
||||
}
|
||||
|
||||
function delay(milliseconds: number) {
|
||||
|
||||
@@ -10,8 +10,16 @@ export type ServiceContender = {
|
||||
|
||||
const stderrLimit = 8 * 1024
|
||||
|
||||
export function spawnServiceContender(command: string, args: ReadonlyArray<string>): ServiceContender {
|
||||
const child = spawn(command, args, { detached: true, stdio: ["ignore", "ignore", "pipe"] })
|
||||
export function spawnServiceContender(
|
||||
command: string,
|
||||
args: ReadonlyArray<string>,
|
||||
env?: Readonly<Record<string, string>>,
|
||||
): ServiceContender {
|
||||
const child = spawn(command, args, {
|
||||
detached: true,
|
||||
stdio: ["ignore", "ignore", "pipe"],
|
||||
env: { ...process.env, ...env },
|
||||
})
|
||||
let error: Error | undefined
|
||||
let closed = false
|
||||
let stderr = Buffer.alloc(0)
|
||||
|
||||
@@ -28,6 +28,8 @@ export type EnsureReason = "missing" | "version-mismatch"
|
||||
export type EnsureOptions = DiscoverOptions & {
|
||||
/** Service command and arguments. Defaults to `opencode serve --service`. */
|
||||
readonly command?: ReadonlyArray<string>
|
||||
/** Environment variables added to the inherited service process environment. */
|
||||
readonly env?: Readonly<Record<string, string>>
|
||||
/** Called once before spawning a new service process. */
|
||||
readonly onStart?: (reason: EnsureReason, previousVersion?: string) => void
|
||||
}
|
||||
|
||||
@@ -11,6 +11,8 @@ if (mode === "record-start") {
|
||||
await writeFile(registration + ".started", "")
|
||||
process.exit(1)
|
||||
}
|
||||
if (mode === "environment")
|
||||
await writeFile(registration + ".environment", process.env.OPENCODE_SERVICE_ENV_TEST ?? "")
|
||||
if (mode === "signal") process.kill(process.pid, process.platform === "win32" ? "SIGTERM" : "SIGKILL")
|
||||
|
||||
if (mode === "delayed" || mode === "delayed-failed" || mode === "coordinated" || mode === "coordinated-failed-loser") {
|
||||
@@ -28,7 +30,7 @@ if (mode === "delayed" || mode === "delayed-failed" || mode === "coordinated" ||
|
||||
|
||||
let requests = 0
|
||||
let version = "test"
|
||||
if (mode === "old" || mode === "reject-stop") version = "old"
|
||||
if (mode === "old") version = "old"
|
||||
if (mode === "incompatible") version = "1.9.0"
|
||||
if (mode === "compatible" || mode === "delayed-compatible") version = "2.1.0-next.1"
|
||||
const id = crypto.randomUUID()
|
||||
@@ -36,17 +38,6 @@ const server = Bun.serve({
|
||||
port: 0,
|
||||
async fetch(request) {
|
||||
const pathname = new URL(request.url).pathname
|
||||
if (pathname === "/api/service/stop" && mode === "reject-stop") {
|
||||
await appendFile(registration + ".stop-attempts", process.pid + "\n")
|
||||
return Response.json({ accepted: false })
|
||||
}
|
||||
if (pathname === "/api/service/stop" && mode === "graceful") {
|
||||
const body = await request.json()
|
||||
if (typeof body !== "object" || body === null || body.instanceID !== id) return Response.json({ accepted: false })
|
||||
await writeFile(registration + ".stop", JSON.stringify(body))
|
||||
setTimeout(shutdown, 25)
|
||||
return Response.json({ accepted: true })
|
||||
}
|
||||
if (pathname !== "/api/health") return new Response(null, { status: 404 })
|
||||
requests += 1
|
||||
if (mode === "starting") await writeFile(registration + ".health-request", "")
|
||||
@@ -63,7 +54,7 @@ const server = Bun.serve({
|
||||
if (mode === "starting" && !(await Bun.file(registration + ".release").exists()))
|
||||
return Response.json({ healthy: true, version, pid: process.pid }, { status: 503 })
|
||||
if (mode === "failed-owner") return Response.json({ healthy: true, version, pid: process.pid }, { status: 500 })
|
||||
if (mode === "starting" || mode === "graceful" || mode === "reject-stop")
|
||||
if (mode === "starting" || mode === "graceful")
|
||||
return Response.json({ healthy: true, version, pid: process.pid })
|
||||
return Response.json({ healthy: true, version, pid: process.pid })
|
||||
},
|
||||
@@ -81,9 +72,10 @@ await writeFile(
|
||||
)
|
||||
await rename(registration + ".tmp", registration)
|
||||
|
||||
function shutdown() {
|
||||
async function shutdown(signal?: NodeJS.Signals) {
|
||||
if (signal !== undefined) await writeFile(registration + ".signal", signal)
|
||||
server.stop(true)
|
||||
process.exit()
|
||||
}
|
||||
process.on("SIGTERM", shutdown)
|
||||
process.on("SIGINT", shutdown)
|
||||
process.on("SIGTERM", () => void shutdown("SIGTERM"))
|
||||
process.on("SIGINT", () => void shutdown("SIGINT"))
|
||||
|
||||
@@ -59,6 +59,26 @@ test("ensures a missing service with native promises", async () => {
|
||||
}
|
||||
})
|
||||
|
||||
test("adds configured environment variables with native promises", async () => {
|
||||
const directory = await temp()
|
||||
const registration = join(directory, "service.json")
|
||||
const endpoint = await ensure({
|
||||
file: registration,
|
||||
version: "test",
|
||||
command: [process.execPath, fixture, registration, "environment"],
|
||||
env: { OPENCODE_SERVICE_ENV_TEST: "configured" },
|
||||
})
|
||||
const info = await Bun.file(registration).json()
|
||||
|
||||
try {
|
||||
expect(endpoint.url).toBe(info.url)
|
||||
expect(await Bun.file(registration + ".environment").text()).toBe("configured")
|
||||
} finally {
|
||||
process.kill(info.pid, "SIGTERM")
|
||||
await waitForExit(info.pid)
|
||||
}
|
||||
})
|
||||
|
||||
test("waits for a live contender when another native contender fails", async () => {
|
||||
const directory = await temp()
|
||||
const registration = join(directory, "service.json")
|
||||
@@ -126,13 +146,13 @@ test("evicts an unresponsive registered service before starting its replacement"
|
||||
await waitForExit(replacement.pid)
|
||||
})
|
||||
|
||||
test("requests graceful stop of the exact service instance", async () => {
|
||||
test("signals the registered service process", async () => {
|
||||
const registration = await setup("graceful")
|
||||
const info = await Bun.file(registration).json()
|
||||
|
||||
await Service.stop({ file: registration })
|
||||
|
||||
expect(await Bun.file(registration + ".stop").json()).toEqual({ instanceID: info.id })
|
||||
expect(await Bun.file(registration + ".signal").text()).toBe("SIGTERM")
|
||||
expect(await Bun.file(registration).exists()).toBe(false)
|
||||
})
|
||||
|
||||
async function setup(mode: string) {
|
||||
|
||||
@@ -191,22 +191,6 @@ test("integration connections optionally submit a form answer", async () => {
|
||||
expect(await requests[3].json()).toEqual({ methodID: "device" })
|
||||
})
|
||||
|
||||
test("health.stop sends exact replacement identity", async () => {
|
||||
let request: Request | undefined
|
||||
const client = OpenCode.make({
|
||||
baseUrl: "http://localhost:3000",
|
||||
fetch: async (input, init) => {
|
||||
request = input instanceof Request ? input : new Request(input, init)
|
||||
return Response.json({ accepted: true })
|
||||
},
|
||||
})
|
||||
|
||||
expect(await client.health.stop({ instanceID: "instance" })).toEqual({ accepted: true })
|
||||
expect(request?.method).toBe("POST")
|
||||
expect(request?.url).toBe("http://localhost:3000/api/service/stop")
|
||||
expect(await request?.json()).toEqual({ instanceID: "instance" })
|
||||
})
|
||||
|
||||
test("MCP resource catalog uses the public HTTP contract", async () => {
|
||||
let request: Request | undefined
|
||||
const client = OpenCode.make({
|
||||
|
||||
@@ -68,6 +68,28 @@ test("reuses a compatible registered service", async () => {
|
||||
expect(existing.exitCode).toBe(null)
|
||||
})
|
||||
|
||||
test("adds configured environment variables when starting a service", async () => {
|
||||
const directory = await temp()
|
||||
const registration = join(directory, "service.json")
|
||||
const endpoint = await run(
|
||||
ensure({
|
||||
file: registration,
|
||||
version: "test",
|
||||
command: [process.execPath, fixture, registration, "environment"],
|
||||
env: { OPENCODE_SERVICE_ENV_TEST: "configured" },
|
||||
}),
|
||||
)
|
||||
const info = await Bun.file(registration).json()
|
||||
|
||||
try {
|
||||
expect(endpoint.url).toBe(info.url)
|
||||
expect(await Bun.file(registration + ".environment").text()).toBe("configured")
|
||||
} finally {
|
||||
process.kill(info.pid, "SIGTERM")
|
||||
await waitForExit(info.pid)
|
||||
}
|
||||
})
|
||||
|
||||
test("replaces an incompatible registered service", async () => {
|
||||
const directory = await temp()
|
||||
const registration = join(directory, "service.json")
|
||||
@@ -143,40 +165,36 @@ test("evicts an unresponsive registered service before starting its replacement"
|
||||
await waitForExit(replacement.pid)
|
||||
})
|
||||
|
||||
test("requests graceful stop of the exact service instance", async () => {
|
||||
test("signals an unresponsive registered service process", async () => {
|
||||
const directory = await temp()
|
||||
const registration = join(directory, "service.json")
|
||||
const process = spawn(registration, "graceful")
|
||||
const process = spawn(registration, "hanging")
|
||||
await waitForFile(registration)
|
||||
const info = await Bun.file(registration).json()
|
||||
|
||||
await run(Service.stop({ file: registration }))
|
||||
await process.exited
|
||||
expect(await Bun.file(registration + ".stop").json()).toEqual({ instanceID: info.id })
|
||||
expect(await Bun.file(registration + ".signal").text()).toBe("SIGTERM")
|
||||
expect(await Bun.file(registration).exists()).toBe(false)
|
||||
})
|
||||
|
||||
test("does not spawn contenders while an incompatible service rejects replacement", async () => {
|
||||
test("signals an incompatible service before starting its replacement", async () => {
|
||||
const directory = await temp()
|
||||
const registration = join(directory, "service.json")
|
||||
const contender = join(directory, "contender.json")
|
||||
const existing = spawn(registration, "reject-stop")
|
||||
const existing = spawn(registration, "old")
|
||||
await waitForFile(registration)
|
||||
const controller = new AbortController()
|
||||
const starting = Effect.runPromise(
|
||||
const endpoint = await run(
|
||||
ensure({
|
||||
file: registration,
|
||||
version: "test",
|
||||
command: [process.execPath, fixture, contender, "record-start"],
|
||||
}).pipe(Effect.provide(NodeFileSystem.layer)),
|
||||
{ signal: controller.signal },
|
||||
command: [process.execPath, fixture, registration, "delayed", "10"],
|
||||
}),
|
||||
)
|
||||
const replacement = await Bun.file(registration).json()
|
||||
|
||||
await waitForLines(registration + ".stop-attempts", 2)
|
||||
controller.abort()
|
||||
await starting.catch(() => undefined)
|
||||
|
||||
expect(await Bun.file(contender + ".started").exists()).toBe(false)
|
||||
expect(existing.exitCode).toBe(null)
|
||||
expect(await existing.exited).toBe(0)
|
||||
expect(endpoint.url).toBe(replacement.url)
|
||||
process.kill(replacement.pid, "SIGTERM")
|
||||
await waitForExit(replacement.pid)
|
||||
})
|
||||
|
||||
test("a legacy health response is still replaced", async () => {
|
||||
@@ -344,17 +362,6 @@ async function waitForFile(file: string) {
|
||||
throw new Error(`Timed out waiting for ${file}`)
|
||||
}
|
||||
|
||||
async function waitForLines(file: string, count: number) {
|
||||
for (let attempt = 0; attempt < 600; attempt++) {
|
||||
const text = await Bun.file(file)
|
||||
.text()
|
||||
.catch(() => "")
|
||||
if (text.trim().split("\n").length >= count) return
|
||||
await Bun.sleep(5)
|
||||
}
|
||||
throw new Error(`Timed out waiting for ${count} lines in ${file}`)
|
||||
}
|
||||
|
||||
async function health(url: string) {
|
||||
return fetch(new URL("/api/health", url), { signal: AbortSignal.timeout(1_000) }).then((response) => response.json())
|
||||
}
|
||||
|
||||
@@ -109,7 +109,7 @@ const layer = Layer.effect(
|
||||
get: Effect.fn("Agent.get")(function* (id) {
|
||||
return state.get().agents.get(id)
|
||||
}),
|
||||
resolve: Effect.fn("Agent.resolve")(function* (id) {
|
||||
resolve: Effect.fnUntraced(function* (id) {
|
||||
if (id !== undefined) return state.get().agents.get(ID.make(id))
|
||||
return selectedDefault()
|
||||
}),
|
||||
|
||||
@@ -426,7 +426,7 @@ export const layer = (options?: Options) =>
|
||||
)
|
||||
|
||||
return Service.of({
|
||||
entries: Effect.fn("Config.entries")(function* () {
|
||||
entries: Effect.fnUntraced(function* () {
|
||||
return configs
|
||||
}),
|
||||
update,
|
||||
|
||||
@@ -149,7 +149,7 @@ export function normalize(input: unknown): Result {
|
||||
"agents",
|
||||
migratedAgents,
|
||||
nativeAgents,
|
||||
isRecord(input.agent) || isRecord(input.mode) || isRecord(input.agents),
|
||||
migratedSmallModel !== undefined || isRecord(input.agent) || isRecord(input.mode) || isRecord(input.agents),
|
||||
diagnostics,
|
||||
)
|
||||
|
||||
|
||||
@@ -120,9 +120,13 @@ export class EffectSQLiteSession<TRelations extends AnyRelations> extends SQLite
|
||||
|
||||
private execute(query: Query, params: unknown[], method: SQLiteExecuteMethod | "values") {
|
||||
const statement = this.client.unsafe(query.sql, params)
|
||||
if (method === "values") return statement.values
|
||||
if (method === "get") return statement.withoutTransform.pipe(Effect.map((rows) => rows[0]))
|
||||
return statement.withoutTransform
|
||||
if (method === "values") return statement.values.pipe(Effect.withTracerEnabled(false))
|
||||
if (method === "get")
|
||||
return statement.withoutTransform.pipe(
|
||||
Effect.map((rows) => rows[0]),
|
||||
Effect.withTracerEnabled(false),
|
||||
)
|
||||
return statement.withoutTransform.pipe(Effect.withTracerEnabled(false))
|
||||
}
|
||||
|
||||
private isInTransaction() {
|
||||
|
||||
@@ -138,7 +138,7 @@ export const make = Effect.gen(function* () {
|
||||
scope: yield* Scope.Scope,
|
||||
}
|
||||
|
||||
const settle = Effect.fn("Job.settle")(function* (id: string, token: object, exit: Exit.Exit<string, unknown>) {
|
||||
const settle = Effect.fnUntraced(function* (id: string, token: object, exit: Exit.Exit<string, unknown>) {
|
||||
const completed_at = yield* Clock.currentTimeMillis
|
||||
const result = yield* SynchronizedRef.modify(state.jobs, (jobs): readonly [FinishResult, Map<string, Active>] => {
|
||||
const job = jobs.get(id)
|
||||
@@ -170,7 +170,7 @@ export const make = Effect.gen(function* () {
|
||||
return result.info
|
||||
})
|
||||
|
||||
const fork = Effect.fn("Job.fork")(function* (
|
||||
const fork = Effect.fnUntraced(function* (
|
||||
scope: Scope.Scope,
|
||||
id: string,
|
||||
token: object,
|
||||
@@ -192,7 +192,7 @@ export const make = Effect.gen(function* () {
|
||||
return snapshot(job)
|
||||
})
|
||||
|
||||
const start: Interface["start"] = Effect.fn("Job.start")(function* (input) {
|
||||
const start: Interface["start"] = Effect.fnUntraced(function* (input) {
|
||||
return yield* Effect.uninterruptibleMask((restore) =>
|
||||
Effect.gen(function* () {
|
||||
const id = input.id ?? Identifier.ascending("job")
|
||||
@@ -247,7 +247,7 @@ export const make = Effect.gen(function* () {
|
||||
return { info: snapshot(job), timedOut: true }
|
||||
})
|
||||
|
||||
const removeBlock = Effect.fn("Job.removeBlock")(function* (input: BlockInput) {
|
||||
const removeBlock = Effect.fnUntraced(function* (input: BlockInput) {
|
||||
yield* SynchronizedRef.update(state.jobs, (jobs) => {
|
||||
const job = jobs.get(input.id)
|
||||
if (!job || job.info.status !== "running" || job.isBackgrounded) return jobs
|
||||
@@ -258,7 +258,7 @@ export const make = Effect.gen(function* () {
|
||||
})
|
||||
})
|
||||
|
||||
const block: Interface["block"] = Effect.fn("Job.block")(function* (input) {
|
||||
const block: Interface["block"] = Effect.fnUntraced(function* (input) {
|
||||
const result = yield* SynchronizedRef.modify(state.jobs, (jobs): readonly [BlockStart, Map<string, Active>] => {
|
||||
const job = jobs.get(input.id)
|
||||
if (!job) return [{ type: "missing" }, jobs]
|
||||
|
||||
@@ -65,7 +65,7 @@ const layer = Layer.effect(
|
||||
const fs = yield* FSUtil.Service
|
||||
const location = yield* Location.Service
|
||||
|
||||
const resolve = Effect.fn("LocationMutation.resolve")(function* (input: ResolveInput) {
|
||||
const resolve = Effect.fnUntraced(function* (input: ResolveInput) {
|
||||
const absolute = path.resolve(location.directory, input.path)
|
||||
if (FSUtil.contains(location.directory, absolute)) {
|
||||
return {
|
||||
|
||||
@@ -152,14 +152,14 @@ const layer = Layer.effect(
|
||||
)
|
||||
})
|
||||
|
||||
const configured = Effect.fn("Permission.configured")(function* (sessionID: SessionSchema.ID, agentID?: Agent.ID) {
|
||||
const configured = Effect.fnUntraced(function* (sessionID: SessionSchema.ID, agentID?: Agent.ID) {
|
||||
const session = yield* sessions.get(sessionID)
|
||||
if (!session) return yield* new SessionErrors.NotFoundError({ sessionID })
|
||||
const agent = yield* agents.resolve(agentID ?? session.agent)
|
||||
return agent?.permissions ?? missingAgentPermissions
|
||||
})
|
||||
|
||||
const allowsAll = Effect.fn("Permission.allowsAll")(function* (input: {
|
||||
const allowsAll = Effect.fnUntraced(function* (input: {
|
||||
readonly sessionID: SessionSchema.ID
|
||||
readonly action: string
|
||||
readonly agent?: Agent.ID
|
||||
|
||||
@@ -39,7 +39,7 @@ const layer = Layer.effect(
|
||||
Effect.gen(function* () {
|
||||
const { db } = yield* Database.Service
|
||||
|
||||
const list = Effect.fn("PermissionSaved.list")(function* (input?: ListInput) {
|
||||
const list = Effect.fnUntraced(function* (input?: ListInput) {
|
||||
const rows = yield* db
|
||||
.select()
|
||||
.from(PermissionTable)
|
||||
|
||||
+47
-13
@@ -1,10 +1,10 @@
|
||||
export * as Plugin from "./plugin.js"
|
||||
export { Event, ID, Info } from "@opencode-ai/schema/plugin"
|
||||
export { Event, ID, Info, Source } from "@opencode-ai/schema/plugin"
|
||||
|
||||
import { Plugin } from "@opencode-ai/schema/plugin"
|
||||
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
|
||||
import { App } from "./app.js"
|
||||
import { Context, Effect, Exit, Layer, Logger, References, Scope, Semaphore } from "effect"
|
||||
import { Cause, Context, Effect, Exit, Layer, Logger, References, Scope, Semaphore } from "effect"
|
||||
import { Agent } from "./agent.js"
|
||||
import { AISDK } from "./aisdk.js"
|
||||
import { Catalog } from "./catalog.js"
|
||||
@@ -23,11 +23,17 @@ import { Tool } from "./tool.js"
|
||||
import { PluginHooks } from "./plugin/hooks.js"
|
||||
|
||||
export interface Interface {
|
||||
readonly activate: (plugins: readonly Versioned[]) => Effect.Effect<void>
|
||||
readonly activate: (
|
||||
plugins: readonly Versioned[],
|
||||
failures?: readonly Extract<Plugin.Info, { readonly status: "failed" }>[],
|
||||
) => Effect.Effect<void>
|
||||
readonly list: () => Effect.Effect<Plugin.Info[]>
|
||||
}
|
||||
|
||||
export type Versioned = import("@opencode-ai/plugin/effect/plugin").Plugin & { readonly version: string }
|
||||
export type Versioned = import("@opencode-ai/plugin/effect/plugin").Plugin & {
|
||||
readonly version: string
|
||||
readonly source?: Plugin.Source
|
||||
}
|
||||
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/Plugin") {}
|
||||
|
||||
@@ -38,6 +44,7 @@ const layer = Layer.effect(
|
||||
const scope = yield* Scope.make()
|
||||
const active = new Map<Plugin.ID, { readonly plugin: Versioned; readonly scope: Scope.Closeable }>()
|
||||
const lock = Semaphore.makeUnsafe(1)
|
||||
let inventory: Plugin.Info[] = []
|
||||
let host: Parameters<import("@opencode-ai/plugin/effect/plugin").Plugin["effect"]>[0]
|
||||
|
||||
const load = Effect.fnUntraced(function* (plugin: Versioned) {
|
||||
@@ -56,15 +63,18 @@ const layer = Layer.effect(
|
||||
Effect.onExit((exit) => (Exit.isFailure(exit) ? Scope.close(child, exit) : Effect.void)),
|
||||
Effect.exit,
|
||||
)
|
||||
if (Exit.isSuccess(loaded)) return child
|
||||
if (Exit.isSuccess(loaded)) return { scope: child } as const
|
||||
yield* Effect.logWarning("failed to load plugin", {
|
||||
"plugin.id": plugin.id,
|
||||
cause: loaded.cause,
|
||||
})
|
||||
return undefined
|
||||
return { error: Cause.pretty(loaded.cause) } as const
|
||||
})
|
||||
|
||||
const activate = Effect.fn("Plugin.activate")(function* (plugins: readonly Versioned[]) {
|
||||
const activate = Effect.fn("Plugin.activate")(function* (
|
||||
plugins: readonly Versioned[],
|
||||
failures: readonly Extract<Plugin.Info, { readonly status: "failed" }>[] = [],
|
||||
) {
|
||||
const definitions = plugins.map((plugin) => ({ ...plugin, id: Plugin.ID.make(plugin.id) }))
|
||||
const ids = new Set<Plugin.ID>()
|
||||
for (const definition of definitions) {
|
||||
@@ -85,26 +95,40 @@ const layer = Layer.effect(
|
||||
const candidate = next[index]
|
||||
return definition.id === candidate?.id && definition.version === candidate.version
|
||||
})
|
||||
)
|
||||
) {
|
||||
const nextInventory = [...Array.from(active.values(), (entry) => activeInfo(entry.plugin)), ...failures]
|
||||
if (JSON.stringify(inventory) === JSON.stringify(nextInventory)) return
|
||||
inventory = nextInventory
|
||||
yield* bus.publish(Plugin.Event.Updated, {})
|
||||
return
|
||||
}
|
||||
|
||||
yield* State.batch(
|
||||
Effect.gen(function* () {
|
||||
const nextInventory: Plugin.Info[] = []
|
||||
for (const definition of definitions) {
|
||||
const previous = active.get(definition.id)
|
||||
active.delete(definition.id)
|
||||
if (previous) yield* Scope.close(previous.scope, Exit.void).pipe(Effect.ignore)
|
||||
|
||||
const loaded = yield* load(definition)
|
||||
if (loaded) {
|
||||
active.set(definition.id, { plugin: definition, scope: loaded })
|
||||
if (loaded.scope !== undefined) {
|
||||
active.set(definition.id, { plugin: definition, scope: loaded.scope })
|
||||
nextInventory.push(activeInfo(definition))
|
||||
continue
|
||||
}
|
||||
nextInventory.push({
|
||||
id: definition.id,
|
||||
source: definition.source ?? { type: "builtin" },
|
||||
status: "failed",
|
||||
error: loaded.error,
|
||||
tui: definition.tui ?? false,
|
||||
})
|
||||
|
||||
if (!previous) continue
|
||||
const restored = yield* load(previous.plugin)
|
||||
if (restored) {
|
||||
active.set(definition.id, { plugin: previous.plugin, scope: restored })
|
||||
if (restored.scope !== undefined) {
|
||||
active.set(definition.id, { plugin: previous.plugin, scope: restored.scope })
|
||||
continue
|
||||
}
|
||||
yield* Effect.logError("failed to restore plugin; deactivating", {
|
||||
@@ -119,6 +143,7 @@ const layer = Layer.effect(
|
||||
yield* Effect.forEach(removed, ([, entry]) => Scope.close(entry.scope, Exit.void).pipe(Effect.ignore), {
|
||||
discard: true,
|
||||
})
|
||||
inventory = [...nextInventory, ...failures]
|
||||
}),
|
||||
)
|
||||
yield* bus.publish(Plugin.Event.Updated, {})
|
||||
@@ -136,7 +161,7 @@ const layer = Layer.effect(
|
||||
const service = Service.of({
|
||||
activate,
|
||||
list: Effect.fn("Plugin.list")(function* () {
|
||||
return Array.from(active.keys()).map((id) => ({ id }))
|
||||
return inventory
|
||||
}),
|
||||
})
|
||||
host = yield* PluginHost.make(service)
|
||||
@@ -144,6 +169,15 @@ const layer = Layer.effect(
|
||||
}),
|
||||
)
|
||||
|
||||
function activeInfo(plugin: Versioned): Plugin.Info {
|
||||
return {
|
||||
id: Plugin.ID.make(plugin.id),
|
||||
source: plugin.source ?? { type: "builtin" },
|
||||
status: "active",
|
||||
tui: plugin.tui ?? false,
|
||||
}
|
||||
}
|
||||
|
||||
export const node = makeLocationNode({
|
||||
service: Service,
|
||||
layer,
|
||||
|
||||
@@ -68,7 +68,7 @@ const layer = Layer.effect(
|
||||
return { dispose }
|
||||
})
|
||||
|
||||
const trigger: Interface["trigger"] = Effect.fn("PluginHooks.trigger")(function* (domain, name, event) {
|
||||
const trigger: Interface["trigger"] = Effect.fnUntraced(function* (domain, name, event) {
|
||||
for (const callback of callbacks.get(key(domain, name)) ?? []) {
|
||||
const result: Effect.Effect<void, Failures[typeof domain][typeof name]> = Reflect.apply(callback, undefined, [
|
||||
event,
|
||||
|
||||
@@ -7,7 +7,7 @@ import { Effect } from "effect"
|
||||
const urls = [/^https:\/\/mcp\.cloudflare\.com\/mcp$/, /^https:\/\/executor\.sh\/[^/]+\/mcp$/]
|
||||
|
||||
export const Plugin = define({
|
||||
id: "opencode.mcp.codemode-exclusion",
|
||||
id: "opencode.mcp.codemode.exclusion",
|
||||
effect: Effect.fn(function* (ctx) {
|
||||
yield* ctx.mcp.transform((draft) => {
|
||||
for (const [, server] of draft.list()) {
|
||||
|
||||
@@ -6,7 +6,7 @@ import { ModelsDev } from "../models-dev.js"
|
||||
import { Provider } from "../provider.js"
|
||||
|
||||
export const ModelsDevPlugin = define({
|
||||
id: "opencode.models-dev",
|
||||
id: "opencode.models.dev",
|
||||
effect: Effect.fn(function* (ctx) {
|
||||
const modelsDev = yield* ModelsDev.Service
|
||||
const bus = yield* Bus.Service
|
||||
@@ -55,8 +55,13 @@ export const ModelsDevPlugin = define({
|
||||
})
|
||||
|
||||
function environmentNames(provider: ModelsDev.Snapshot) {
|
||||
if (provider.info.id !== Provider.ID.azure) return [...provider.environment]
|
||||
return [...provider.environment.filter((name) => name.endsWith("_API_KEY")), "AZURE_COGNITIVE_SERVICES_API_KEY"]
|
||||
if (provider.info.id === Provider.ID.azure)
|
||||
return [...provider.environment.filter((name) => name.endsWith("_API_KEY")), "AZURE_COGNITIVE_SERVICES_API_KEY"]
|
||||
// models.dev advertises project, location, and the ADC credentials file path for
|
||||
// Vertex. Those configure Google auth rather than carrying a key, so only the
|
||||
// Express Mode key may become a credential; GoogleVertexPlugin handles activation.
|
||||
if (provider.info.id === Provider.ID.googleVertex) return ["GOOGLE_VERTEX_API_KEY"]
|
||||
return [...provider.environment]
|
||||
}
|
||||
|
||||
function snapshots(data: readonly ModelsDev.Snapshot[]) {
|
||||
|
||||
@@ -60,7 +60,7 @@ function selectMantleModel(sdk: MantleSDK, modelID: string) {
|
||||
}
|
||||
|
||||
export const AmazonBedrockPlugin = define({
|
||||
id: "opencode.provider.amazon-bedrock",
|
||||
id: "opencode.provider.amazon.bedrock",
|
||||
effect: Effect.fn(function* (ctx) {
|
||||
yield* ctx.catalog.transform((evt) => {
|
||||
for (const item of evt.provider.list()) {
|
||||
|
||||
@@ -10,7 +10,7 @@ import { configuredSettings } from "./configured.js"
|
||||
const providerID = Provider.ID.make("cloudflare-ai-gateway")
|
||||
|
||||
export const CloudflareAIGatewayPlugin = define({
|
||||
id: "opencode.provider.cloudflare-ai-gateway",
|
||||
id: "opencode.provider.cloudflare.ai.gateway",
|
||||
effect: Effect.fn(function* (ctx) {
|
||||
const configured = yield* configuredSettings(providerID)
|
||||
const form = iife(() => {
|
||||
|
||||
@@ -10,7 +10,7 @@ import { configuredSettings } from "./configured.js"
|
||||
const providerID = Provider.ID.make("cloudflare-workers-ai")
|
||||
|
||||
export const CloudflareWorkersAIPlugin = define({
|
||||
id: "opencode.provider.cloudflare-workers-ai",
|
||||
id: "opencode.provider.cloudflare.workers.ai",
|
||||
effect: Effect.fn(function* (ctx) {
|
||||
const configured = yield* configuredSettings(providerID)
|
||||
const form = iife(() => {
|
||||
|
||||
@@ -146,7 +146,7 @@ const oauth = (app: App.Info) =>
|
||||
}) satisfies IntegrationOAuthMethodRegistration
|
||||
|
||||
export const GithubCopilotPlugin = define({
|
||||
id: "opencode.provider.github-copilot",
|
||||
id: "opencode.provider.github.copilot",
|
||||
effect: Effect.fn(function* (ctx) {
|
||||
const catalog = yield* Catalog.Service
|
||||
const bus = yield* Bus.Service
|
||||
|
||||
@@ -26,6 +26,7 @@ function resolveLocation(options: Record<string, any>) {
|
||||
|
||||
function vertexEndpoint(location: string) {
|
||||
if (location === "global") return "aiplatform.googleapis.com"
|
||||
if (location === "eu" || location === "us") return `aiplatform.${location}.rep.googleapis.com`
|
||||
return `${location}-aiplatform.googleapis.com`
|
||||
}
|
||||
|
||||
@@ -55,7 +56,7 @@ function authFetch(fetchWithRuntimeOptions?: unknown) {
|
||||
}
|
||||
|
||||
export const GoogleVertexPlugin = define({
|
||||
id: "opencode.provider.google-vertex",
|
||||
id: "opencode.provider.google.vertex",
|
||||
effect: Effect.fn(function* (ctx) {
|
||||
yield* ctx.catalog.transform((evt) => {
|
||||
for (const item of evt.provider.list()) {
|
||||
@@ -71,6 +72,9 @@ export const GoogleVertexPlugin = define({
|
||||
const project = resolveProject(item.provider.settings ?? {})
|
||||
const location = String(resolveLocation(item.provider.settings ?? {}))
|
||||
evt.provider.update(item.provider.id, (provider) => {
|
||||
// Vertex authenticates through ADC rather than a key credential, so a
|
||||
// resolvable project is what makes the provider usable.
|
||||
if (project && provider.activation === "auto") provider.activation = "enabled"
|
||||
provider.settings = {
|
||||
...provider.settings,
|
||||
...(project ? { project } : {}),
|
||||
@@ -111,13 +115,18 @@ export const GoogleVertexPlugin = define({
|
||||
if (evt.package !== "@ai-sdk/google-vertex") return
|
||||
const mod = yield* Effect.promise(() => import("@ai-sdk/google-vertex"))
|
||||
const project = resolveProject(evt.options)
|
||||
const location = resolveLocation(evt.options)
|
||||
const location = String(resolveLocation(evt.options))
|
||||
const options = { ...evt.options }
|
||||
delete options.fetch
|
||||
evt.sdk = mod.createVertex({
|
||||
...options,
|
||||
project,
|
||||
location,
|
||||
...((location === "eu" || location === "us") && project && !options.apiKey && !options.baseURL
|
||||
? {
|
||||
baseURL: `https://${vertexEndpoint(location)}/v1beta1/projects/${project}/locations/${location}/publishers/google`,
|
||||
}
|
||||
: {}),
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -2,7 +2,7 @@ import { Effect } from "effect"
|
||||
import { define } from "@opencode-ai/plugin/effect/plugin"
|
||||
|
||||
export const OpenAICompatiblePlugin = define({
|
||||
id: "opencode.provider.openai-compatible",
|
||||
id: "opencode.provider.openai.compatible",
|
||||
effect: Effect.fn(function* (ctx) {
|
||||
yield* ctx.aisdk.hook(
|
||||
"sdk",
|
||||
|
||||
@@ -6,7 +6,7 @@ import { Provider } from "../../provider.js"
|
||||
import { importModule } from "@opencode-ai/util/runtime-import"
|
||||
|
||||
export const SapAICorePlugin = define({
|
||||
id: "opencode.provider.sap-ai-core",
|
||||
id: "opencode.provider.sap.ai.core",
|
||||
effect: Effect.fn(function* (ctx) {
|
||||
const npm = yield* Npm.Service
|
||||
yield* ctx.aisdk.hook(
|
||||
|
||||
@@ -65,7 +65,7 @@ export function cortexFetch(upstream: FetchLike = fetch) {
|
||||
}
|
||||
|
||||
export const SnowflakeCortexPlugin = define({
|
||||
id: "opencode.provider.snowflake-cortex",
|
||||
id: "opencode.provider.snowflake.cortex",
|
||||
effect: Effect.fn(function* (ctx) {
|
||||
yield* ctx.aisdk.hook(
|
||||
"sdk",
|
||||
|
||||
@@ -35,7 +35,7 @@ export const layer = Layer.effect(
|
||||
return Service.of({
|
||||
register: (plugin) =>
|
||||
Effect.sync(() => {
|
||||
plugins.set(plugin.id, { ...plugin, version: String(++revision) })
|
||||
plugins.set(plugin.id, { ...plugin, version: String(++revision), source: { type: "sdk" } })
|
||||
}).pipe(Effect.andThen(bus.publish(Updated, {})), Effect.asVoid),
|
||||
all: () => [...plugins.values()],
|
||||
})
|
||||
|
||||
@@ -2,7 +2,7 @@ export * as PluginSupervisor from "./supervisor.js"
|
||||
|
||||
import type { Plugin as PluginDefinition } from "@opencode-ai/plugin/effect/plugin"
|
||||
import { Event } from "@opencode-ai/schema/config"
|
||||
import { Context, Deferred, Effect, Layer, Schema, Stream } from "effect"
|
||||
import { Cause, Context, Deferred, Effect, Layer, Schema, Stream } from "effect"
|
||||
import path from "path"
|
||||
import { pathToFileURL } from "url"
|
||||
import { ConfigPluginSource } from "../config/plugin/source.js"
|
||||
@@ -19,12 +19,14 @@ const PluginModule = Schema.Struct({
|
||||
default: Schema.Union([
|
||||
Schema.Struct({
|
||||
id: Schema.String,
|
||||
tui: Schema.optional(Schema.Boolean),
|
||||
effect: Schema.declare<PluginDefinition["effect"]>(
|
||||
(input): input is PluginDefinition["effect"] => typeof input === "function",
|
||||
),
|
||||
}),
|
||||
Schema.Struct({
|
||||
id: Schema.String,
|
||||
tui: Schema.optional(Schema.Boolean),
|
||||
setup: Schema.declare<Parameters<typeof PluginPromise.fromPromise>[0]["setup"]>(
|
||||
(input): input is Parameters<typeof PluginPromise.fromPromise>[0]["setup"] => typeof input === "function",
|
||||
),
|
||||
@@ -42,10 +44,12 @@ const resolve = Effect.fn("PluginSupervisor.resolve")(function* (
|
||||
const definitions = [...pre, ...post]
|
||||
const enabled = new Set(definitions.map((plugin) => plugin.id))
|
||||
const packages = new Map<string, Plugin.Versioned>()
|
||||
const failures = new Map<string, Extract<Plugin.Info, { readonly status: "failed" }>>()
|
||||
const plugins = () => [...definitions, ...packages.values()]
|
||||
|
||||
for (const operation of operations) {
|
||||
if (operation.type === "remove") {
|
||||
if (operation.target === "*") failures.clear()
|
||||
plugins()
|
||||
.filter((plugin) => matches(operation.target, plugin.id))
|
||||
.forEach((plugin) => enabled.delete(plugin.id))
|
||||
@@ -65,21 +69,35 @@ const resolve = Effect.fn("PluginSupervisor.resolve")(function* (
|
||||
|
||||
const plugin = yield* load(operation).pipe(
|
||||
Effect.catchCause((cause) =>
|
||||
Effect.logWarning("failed to load plugin", { target: operation.target, cause }).pipe(Effect.as(undefined)),
|
||||
Effect.logWarning("failed to load plugin", { target: operation.target, cause }).pipe(
|
||||
Effect.as({ error: Cause.pretty(cause) }),
|
||||
),
|
||||
),
|
||||
)
|
||||
if (!plugin) continue
|
||||
if ("error" in plugin) {
|
||||
failures.set(operation.target, {
|
||||
source: pluginSource(operation.target),
|
||||
status: "failed",
|
||||
error: plugin.error,
|
||||
tui: false,
|
||||
})
|
||||
continue
|
||||
}
|
||||
failures.delete(operation.target)
|
||||
const previous = packages.get(operation.target)
|
||||
if (previous) enabled.delete(previous.id)
|
||||
packages.set(operation.target, plugin)
|
||||
enabled.add(plugin.id)
|
||||
}
|
||||
|
||||
return [
|
||||
...pre.filter((plugin) => enabled.has(plugin.id)),
|
||||
...Array.from(packages.values()).filter((plugin) => enabled.has(plugin.id)),
|
||||
...post.filter((plugin) => enabled.has(plugin.id)),
|
||||
]
|
||||
return {
|
||||
plugins: [
|
||||
...pre.filter((plugin) => enabled.has(plugin.id)),
|
||||
...Array.from(packages.values()).filter((plugin) => enabled.has(plugin.id)),
|
||||
...post.filter((plugin) => enabled.has(plugin.id)),
|
||||
],
|
||||
failures: [...failures.values()],
|
||||
}
|
||||
})
|
||||
|
||||
const load = Effect.fn("PluginSupervisor.load")(function* (
|
||||
@@ -89,7 +107,7 @@ const load = Effect.fn("PluginSupervisor.load")(function* (
|
||||
const entrypoint = path.isAbsolute(operation.target)
|
||||
? pathToFileURL(operation.target).href
|
||||
: (yield* npm.add(operation.target, { subpaths: ["server", ""] })).entrypoint
|
||||
if (!entrypoint) return
|
||||
if (!entrypoint) return yield* Effect.fail(new Error(`Plugin entrypoint not found: ${operation.target}`))
|
||||
// Bun currently ignores query parameters when caching file:// imports.
|
||||
const source =
|
||||
operation.mtime === undefined
|
||||
@@ -103,7 +121,9 @@ const load = Effect.fn("PluginSupervisor.load")(function* (
|
||||
const plugin = "effect" in value ? value : PluginPromise.fromPromise(value)
|
||||
return {
|
||||
id: plugin.id,
|
||||
tui: plugin.tui,
|
||||
version: JSON.stringify(operation),
|
||||
source: pluginSource(operation.target),
|
||||
effect: (host) => plugin.effect({ ...host, options: operation.options }),
|
||||
} satisfies Plugin.Versioned
|
||||
})
|
||||
@@ -129,13 +149,20 @@ export const layer = Layer.effect(
|
||||
// Resolve OpenCode's internal plugins with their privileged Location services.
|
||||
const internal = yield* PluginInternal.list()
|
||||
// Combine internal plugins with host-contributed SDK plugins in boot order.
|
||||
const pre = [...internal.pre.map((plugin) => ({ ...plugin, version: "internal" })), ...sdk.all()]
|
||||
const post = internal.post.map((plugin) => ({ ...plugin, version: "internal" }))
|
||||
const pre = [
|
||||
...internal.pre.map((plugin) => ({ ...plugin, version: "internal", source: { type: "builtin" as const } })),
|
||||
...sdk.all(),
|
||||
]
|
||||
const post = internal.post.map((plugin) => ({
|
||||
...plugin,
|
||||
version: "internal",
|
||||
source: { type: "builtin" as const },
|
||||
}))
|
||||
const operations = yield* sources.operations()
|
||||
// Apply config operations and load enabled package plugins into one ordered generation.
|
||||
const plugins = yield* resolve(pre, post, operations)
|
||||
const resolved = yield* resolve(pre, post, operations)
|
||||
// Replace the active generation in one scoped, batched activation.
|
||||
yield* registry.activate(plugins)
|
||||
yield* registry.activate(resolved.plugins, resolved.failures)
|
||||
})
|
||||
const updates = Stream.merge(sources.changes(), bus.subscribe([Event.Updated, SdkPlugins.Updated])).pipe(
|
||||
// Make accepted work visible to flush before coalescing the burst.
|
||||
@@ -172,4 +199,9 @@ const nodeDeps = [
|
||||
PluginInternal.requirements,
|
||||
] as const
|
||||
|
||||
function pluginSource(target: string): Plugin.Source {
|
||||
if (path.isAbsolute(target)) return { type: "local", path: target }
|
||||
return { type: "package", package: target }
|
||||
}
|
||||
|
||||
export const node = makeLocationNode({ service: Service, layer, deps: nodeDeps })
|
||||
|
||||
@@ -33,7 +33,7 @@ export const Plugins = [OpenAIPlugin, GooglePlugin, AnthropicPlugin, KimiPlugin,
|
||||
|
||||
function make(id: string, select: (modelID: string) => string | undefined) {
|
||||
return define({
|
||||
id: `opencode.system-prompt.${id}`,
|
||||
id: `opencode.prompt.${id}`,
|
||||
effect: Effect.fn(`SystemPromptPlugin.${id}`)(function* (ctx) {
|
||||
yield* ctx.session.hook("context", (event) =>
|
||||
Effect.gen(function* () {
|
||||
|
||||
@@ -48,6 +48,17 @@ const builtins = new Map<string, () => Promise<unknown>>([
|
||||
["@opencode-ai/ai/providers/azure/chat", () => import("@opencode-ai/ai/providers/azure/chat")],
|
||||
["@opencode-ai/ai/providers/azure/responses", () => import("@opencode-ai/ai/providers/azure/responses")],
|
||||
["@opencode-ai/ai/providers/google", () => import("@opencode-ai/ai/providers/google")],
|
||||
["@opencode-ai/ai/providers/google-vertex", () => import("@opencode-ai/ai/providers/google-vertex")],
|
||||
["@opencode-ai/ai/providers/google-vertex/gemini", () => import("@opencode-ai/ai/providers/google-vertex/gemini")],
|
||||
["@opencode-ai/ai/providers/google-vertex/chat", () => import("@opencode-ai/ai/providers/google-vertex/chat")],
|
||||
[
|
||||
"@opencode-ai/ai/providers/google-vertex/responses",
|
||||
() => import("@opencode-ai/ai/providers/google-vertex/responses"),
|
||||
],
|
||||
[
|
||||
"@opencode-ai/ai/providers/google-vertex/messages",
|
||||
() => import("@opencode-ai/ai/providers/google-vertex/messages"),
|
||||
],
|
||||
["@opencode-ai/ai/providers/openai", () => import("@opencode-ai/ai/providers/openai")],
|
||||
["@opencode-ai/ai/providers/openai/chat", () => import("@opencode-ai/ai/providers/openai/chat")],
|
||||
["@opencode-ai/ai/providers/openai/responses", () => import("@opencode-ai/ai/providers/openai/responses")],
|
||||
|
||||
@@ -231,7 +231,8 @@ export const layer = Layer.effect(
|
||||
http: {
|
||||
headers: SessionModelHeaders.make(session, app),
|
||||
},
|
||||
promptCacheKey: SessionPromptCacheKey.make(session.id),
|
||||
// TODO: Persist cache lineage so nested forks reuse the root session's cache key.
|
||||
promptCacheKey: SessionPromptCacheKey.make(session.fork?.sessionID ?? session.id),
|
||||
system: context.system,
|
||||
messages: boundImages(unsupportedParts(context.messages, resolved.capabilities)),
|
||||
tools: Array.from(hooked, ([name, tool]) => ({ ...tool, name })),
|
||||
|
||||
@@ -54,7 +54,7 @@ const layer = Layer.effect(
|
||||
const decodeMessage = Schema.decodeUnknownEffect(SessionMessage.Info)
|
||||
|
||||
return Service.of({
|
||||
get: Effect.fn("SessionStore.get")(function* (sessionID) {
|
||||
get: Effect.fnUntraced(function* (sessionID) {
|
||||
const row = yield* db.select().from(SessionTable).where(eq(SessionTable.id, sessionID)).get().pipe(Effect.orDie)
|
||||
return row ? fromRow(row) : undefined
|
||||
}),
|
||||
|
||||
@@ -5,7 +5,8 @@ import { Money } from "@opencode-ai/schema/money"
|
||||
import type { TokenUsage } from "@opencode-ai/schema/token-usage"
|
||||
import type { Model } from "../model.js"
|
||||
|
||||
const safe = (value: number | undefined) => Math.max(0, Number.isFinite(value) ? (value ?? 0) : 0)
|
||||
const finite = (value: number) => (Number.isFinite(value) ? value : 0)
|
||||
const safe = (value: number | undefined) => Math.max(0, finite(value ?? 0))
|
||||
|
||||
export const tokens = (usage: Usage | undefined): TokenUsage.Info => ({
|
||||
input: safe(usage?.nonCachedInputTokens),
|
||||
@@ -26,10 +27,10 @@ export function calculateCost(costs: Model.Info["cost"], usage: TokenUsage.Info)
|
||||
const cost = tier ?? costs.find((cost) => cost.tier === undefined)
|
||||
if (!cost) return Money.USD.zero
|
||||
return Money.USD.make(
|
||||
(usage.input * cost.input +
|
||||
(usage.output + usage.reasoning) * cost.output +
|
||||
usage.cache.read * cost.cache.read +
|
||||
usage.cache.write * cost.cache.write) /
|
||||
(usage.input * finite(cost.input) +
|
||||
(usage.output + usage.reasoning) * finite(cost.output) +
|
||||
usage.cache.read * finite(cost.cache.read) +
|
||||
usage.cache.write * finite(cost.cache.write)) /
|
||||
1_000_000,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -101,7 +101,7 @@ export const layer = (options?: ShellSelect.Options) =>
|
||||
}),
|
||||
)
|
||||
|
||||
const require = Effect.fn("Shell.require")(function* (id: Shell.ID) {
|
||||
const require = Effect.fnUntraced(function* (id: Shell.ID) {
|
||||
const session = sessions.get(id)
|
||||
if (!session) return yield* new NotFoundError({ id })
|
||||
return session
|
||||
@@ -153,7 +153,7 @@ export const layer = (options?: ShellSelect.Options) =>
|
||||
|
||||
const name = () => resolve().pipe(Effect.map(ShellSelect.name))
|
||||
|
||||
const output = Effect.fn("Shell.output")(function* (id: Shell.ID, input?: Shell.OutputInput) {
|
||||
const output = Effect.fnUntraced(function* (id: Shell.ID, input?: Shell.OutputInput) {
|
||||
const session = yield* require(id)
|
||||
const cursor = input?.cursor ?? 0
|
||||
const limit = input?.limit ?? 65536
|
||||
|
||||
@@ -153,7 +153,7 @@ const ARITY: Record<string, number> = {
|
||||
"yarn run": 3,
|
||||
}
|
||||
|
||||
export const scan = Effect.fn("ShellParse.scan")(function* (
|
||||
export const scan = Effect.fnUntraced(function* (
|
||||
command: string,
|
||||
shell: string,
|
||||
cwd: string,
|
||||
@@ -163,7 +163,7 @@ export const scan = Effect.fn("ShellParse.scan")(function* (
|
||||
return yield* scanLegacy(command, shell, cwd)
|
||||
})
|
||||
|
||||
const scanLegacy = Effect.fn("ShellParse.scanLegacy")(function* (command: string, shell: string, cwd: string) {
|
||||
const scanLegacy = Effect.fnUntraced(function* (command: string, shell: string, cwd: string) {
|
||||
const parsers = yield* Effect.promise(load)
|
||||
const powershell = ShellSelect.ps(shell)
|
||||
const tree = (powershell ? parsers.ps : parsers.bash).parse(command)
|
||||
|
||||
@@ -97,10 +97,7 @@ export function create<State, DraftApi>(options: Options<State, DraftApi>): Inte
|
||||
const materialize = Effect.fnUntraced(function* () {
|
||||
const next = options.initial()
|
||||
const api = options.draft(next)
|
||||
for (const transform of transforms)
|
||||
yield* apply(transform.run, api).pipe(
|
||||
Effect.withSpan("State.reload.update", { attributes: { state: options.name ?? "anonymous" } }),
|
||||
)
|
||||
for (const transform of transforms) yield* apply(transform.run, api)
|
||||
yield* commit(next)
|
||||
})
|
||||
|
||||
|
||||
@@ -51,7 +51,7 @@ const layer = Layer.effect(
|
||||
const global = yield* Global.Service
|
||||
const directory = path.join(global.data, DIRECTORY)
|
||||
|
||||
const truncate = Effect.fn("ToolOutput.truncate")(function* (result: Result) {
|
||||
const truncate = Effect.fnUntraced(function* (result: Result) {
|
||||
if (result.metadata?.truncated !== undefined) return result
|
||||
const content =
|
||||
typeof result.content === "string" ? [{ type: "text" as const, text: result.content }] : (result.content ?? [])
|
||||
|
||||
@@ -50,7 +50,7 @@ const layer = Layer.effect(
|
||||
const image = yield* Image.Service
|
||||
|
||||
type NormalizedItem = Tool.Content | "decode" | "size"
|
||||
const normalizeImages = Effect.fn("Tool.normalizeImages")(function* (content: ReadonlyArray<Tool.Content>) {
|
||||
const normalizeImages = Effect.fnUntraced(function* (content: ReadonlyArray<Tool.Content>) {
|
||||
const normalized = yield* Effect.forEach(content, (item): Effect.Effect<NormalizedItem> => {
|
||||
if (item.type !== "file" || !item.mime.startsWith("image/")) return Effect.succeed(item)
|
||||
const base64 = /^data:[^,]*;base64,(.*)$/s.exec(item.uri)?.[1]
|
||||
|
||||
@@ -208,7 +208,7 @@ export const Plugin = {
|
||||
)
|
||||
yield* context.progress({ shellID: info.id })
|
||||
|
||||
const captureShell = Effect.fn("ShellTool.captureShell")(function* () {
|
||||
const captureShell = Effect.fnUntraced(function* () {
|
||||
const configured = Config.latest(yield* config.entries(), "tool_output")
|
||||
const maxLines = configured?.max_lines ?? ToolOutput.MAX_LINES
|
||||
const maxBytes = configured?.max_bytes ?? ToolOutput.MAX_BYTES
|
||||
@@ -228,7 +228,7 @@ export const Plugin = {
|
||||
}
|
||||
})
|
||||
|
||||
const settleShell = Effect.fn("ShellTool.settleShell")(function* () {
|
||||
const settleShell = Effect.fnUntraced(function* () {
|
||||
const final = yield* shell.wait(info.id)
|
||||
const capture = yield* captureShell()
|
||||
|
||||
|
||||
@@ -4,7 +4,6 @@ import type { Context as PluginContext } from "@opencode-ai/plugin/effect/plugin
|
||||
import { ToolFailure } from "@opencode-ai/ai"
|
||||
import { Effect, Schema, Semaphore } from "effect"
|
||||
import { HttpClientError } from "effect/unstable/http"
|
||||
import { Config } from "../../config.js"
|
||||
import { Form } from "../../form.js"
|
||||
import { Permission } from "../../permission.js"
|
||||
import { WebSearch } from "../../websearch.js"
|
||||
@@ -30,7 +29,6 @@ export const Plugin = {
|
||||
effect: Effect.fn("WebSearchTool.Plugin")(function* (ctx: PluginContext) {
|
||||
const permission = yield* Permission.Service
|
||||
const forms = yield* Form.Service
|
||||
const config = yield* Config.Service
|
||||
const websearch = yield* WebSearch.Service
|
||||
|
||||
yield* ctx.tool
|
||||
@@ -97,9 +95,7 @@ export const Plugin = {
|
||||
if (response.status === "cancelled")
|
||||
return yield* Effect.fail(new Error("Web search cancelled"))
|
||||
if (response.answer.choice === "disable") {
|
||||
yield* config.update((draft) => {
|
||||
draft.websearch = false
|
||||
})
|
||||
yield* websearch.select(false)
|
||||
return yield* new WebSearch.DisabledError()
|
||||
}
|
||||
const selection =
|
||||
@@ -131,11 +127,7 @@ export const Plugin = {
|
||||
(providerID !== "random" && !providers.some((provider) => provider.id === providerID))
|
||||
)
|
||||
return yield* new WebSearch.ProviderRequiredError()
|
||||
yield* config.update((draft) => {
|
||||
draft.websearch = {
|
||||
provider: providerID === "random" ? "random" : WebSearch.ID.make(providerID),
|
||||
}
|
||||
})
|
||||
yield* websearch.select(providerID === "random" ? "random" : WebSearch.ID.make(providerID))
|
||||
if (providerID !== "random") return WebSearch.ID.make(providerID)
|
||||
return providers[Math.floor(Math.random() * providers.length)]?.id
|
||||
}),
|
||||
@@ -206,7 +198,10 @@ export const Plugin = {
|
||||
|
||||
yield* ctx.session.hook("context", (event) =>
|
||||
Effect.gen(function* () {
|
||||
const disabled = Config.latest(yield* config.entries(), "websearch") === false
|
||||
const disabled = yield* websearch.default().pipe(
|
||||
Effect.as(false),
|
||||
Effect.catchTag("WebSearch.Disabled", () => Effect.succeed(true)),
|
||||
)
|
||||
if (disabled) delete event.tools[name]
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
export * as WebSearch from "./websearch.js"
|
||||
|
||||
import { WebSearch } from "@opencode-ai/schema/websearch"
|
||||
import { Context, Effect, Layer, Schema } from "effect"
|
||||
import { Context, Effect, Layer, Option, Schema } from "effect"
|
||||
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
|
||||
import { Bus } from "./bus.js"
|
||||
import { KV } from "./kv.js"
|
||||
import { State } from "./state.js"
|
||||
|
||||
export const ID = WebSearch.ID
|
||||
@@ -24,6 +25,10 @@ export type Result = WebSearch.Result
|
||||
export const Response = WebSearch.Response
|
||||
export type Response = WebSearch.Response
|
||||
|
||||
export const ProviderKey = "websearch:provider"
|
||||
export const Selection = Schema.Union([ID, Schema.Literal("random"), Schema.Literal(false)])
|
||||
export type Selection = typeof Selection.Type
|
||||
|
||||
export interface ProviderImplementation extends Provider {
|
||||
readonly execute: (input: ProviderInput) => Effect.Effect<readonly Result[], unknown>
|
||||
}
|
||||
@@ -49,6 +54,7 @@ export type Error = ProviderRequiredError | ProviderNotFoundError | DisabledErro
|
||||
export interface Interface extends State.Transformable<Draft> {
|
||||
readonly providers: () => Effect.Effect<readonly Provider[]>
|
||||
readonly default: () => Effect.Effect<Provider | undefined, DisabledError>
|
||||
readonly select: (selection: Selection) => Effect.Effect<void>
|
||||
readonly query: (input: Input) => Effect.Effect<Response, Error>
|
||||
}
|
||||
|
||||
@@ -56,14 +62,14 @@ export class Service extends Context.Service<Service, Interface>()("@opencode/We
|
||||
|
||||
type Data = {
|
||||
readonly providers: Map<ID, ProviderImplementation>
|
||||
selection?: ID | "random" | false
|
||||
selection?: Selection
|
||||
}
|
||||
|
||||
export type Draft = {
|
||||
add: (provider: ProviderImplementation) => void
|
||||
default: {
|
||||
get: () => ID | "random" | false | undefined
|
||||
set: (selection: ID | "random" | false) => void
|
||||
get: () => Selection | undefined
|
||||
set: (selection: Selection) => void
|
||||
}
|
||||
}
|
||||
|
||||
@@ -71,6 +77,7 @@ const layer = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const bus = yield* Bus.Service
|
||||
const kv = yield* KV.Service
|
||||
const decodeResults = Schema.decodeUnknownEffect(Schema.Array(Result))
|
||||
const state = State.create<Data, Draft>({
|
||||
initial: () => ({ providers: new Map() }),
|
||||
@@ -91,12 +98,16 @@ const layer = Layer.effect(
|
||||
|
||||
const defaultProvider = Effect.fn("WebSearch.default")(function* () {
|
||||
const data = state.get()
|
||||
if (data.selection === false) return yield* new DisabledError()
|
||||
if (data.selection === "random") {
|
||||
const stored = data.selection === undefined ? yield* kv.get(ProviderKey) : undefined
|
||||
const decoded = Schema.decodeUnknownOption(Selection)(stored)
|
||||
if (stored !== undefined && Option.isNone(decoded)) yield* kv.remove(ProviderKey)
|
||||
const selection = data.selection ?? Option.getOrUndefined(decoded)
|
||||
if (selection === false) return yield* new DisabledError()
|
||||
if (selection === "random") {
|
||||
const providers = Array.from(data.providers.values())
|
||||
return providers[Math.floor(Math.random() * providers.length)]
|
||||
}
|
||||
return data.selection ? data.providers.get(data.selection) : undefined
|
||||
return selection ? data.providers.get(selection) : undefined
|
||||
})
|
||||
|
||||
const resolve = Effect.fn("WebSearch.resolve")(function* (input: Input) {
|
||||
@@ -120,6 +131,9 @@ const layer = Layer.effect(
|
||||
const provider = yield* defaultProvider()
|
||||
return provider && { id: provider.id, name: provider.name }
|
||||
}),
|
||||
select: Effect.fn("WebSearch.select")(function* (selection) {
|
||||
yield* kv.set(ProviderKey, selection)
|
||||
}),
|
||||
query: Effect.fn("WebSearch.query")(function* (input) {
|
||||
const provider = yield* resolve(input)
|
||||
const results = yield* provider.execute({ query: input.query }).pipe(
|
||||
@@ -135,5 +149,5 @@ const layer = Layer.effect(
|
||||
export const node = makeLocationNode({
|
||||
service: Service,
|
||||
layer,
|
||||
deps: [Bus.node],
|
||||
deps: [Bus.node, KV.node],
|
||||
})
|
||||
|
||||
@@ -150,6 +150,16 @@ describe("ConfigNormalize", () => {
|
||||
})
|
||||
|
||||
test("migrates the legacy small model to the title agent", () => {
|
||||
const result = normalized({ small_model: "anthropic/claude-haiku-4-5" })
|
||||
expect(result.encoded.agents).toEqual({
|
||||
title: {
|
||||
model: { providerID: "anthropic", model: "claude-haiku-4-5" },
|
||||
},
|
||||
})
|
||||
expect(result.diagnostics).toEqual([])
|
||||
})
|
||||
|
||||
test("merges the legacy small model with the title agent", () => {
|
||||
const result = normalized({
|
||||
small_model: "anthropic/claude-haiku-4-5",
|
||||
agent: { title: { prompt: "Custom title prompt" } },
|
||||
|
||||
@@ -44,7 +44,9 @@ describe("PluginSupervisor config", () => {
|
||||
const plugins = yield* Plugin.Service
|
||||
yield* ready()
|
||||
expect(
|
||||
(yield* plugins.list()).map((plugin) => plugin.id).filter((id) => id.startsWith("opencode.provider.")),
|
||||
(yield* plugins.list())
|
||||
.flatMap((plugin) => (plugin.id ? [plugin.id] : []))
|
||||
.filter((id) => id.startsWith("opencode.provider.")),
|
||||
).toEqual([Plugin.ID.make("opencode.provider.openai")])
|
||||
}),
|
||||
),
|
||||
@@ -64,10 +66,20 @@ describe("PluginSupervisor config", () => {
|
||||
Effect.gen(function* () {
|
||||
yield* ready()
|
||||
const agents = yield* Agent.Service
|
||||
const plugins = yield* Plugin.Service
|
||||
expect(yield* agents.get(Agent.ID.make("configured"))).toMatchObject({
|
||||
description: "Loaded from config",
|
||||
mode: "subagent",
|
||||
})
|
||||
expect((yield* plugins.list()).find((plugin) => plugin.id === "config-promise-plugin")).toEqual({
|
||||
id: Plugin.ID.make("config-promise-plugin"),
|
||||
source: {
|
||||
type: "local",
|
||||
path: path.join(import.meta.dir, "../plugin/fixtures/config-promise-plugin.ts"),
|
||||
},
|
||||
status: "active",
|
||||
tui: true,
|
||||
})
|
||||
}),
|
||||
),
|
||||
)
|
||||
@@ -143,6 +155,7 @@ describe("PluginSupervisor config", () => {
|
||||
Effect.gen(function* () {
|
||||
yield* ready()
|
||||
const agents = yield* Agent.Service
|
||||
const plugins = yield* Plugin.Service
|
||||
expect(yield* agents.get(Agent.ID.make("configured"))).toMatchObject({
|
||||
description: "Loaded after invalid plugins",
|
||||
})
|
||||
@@ -150,6 +163,12 @@ describe("PluginSupervisor config", () => {
|
||||
path.join(import.meta.dir, "../plugin/fixtures/missing-plugin.ts"),
|
||||
path.join(import.meta.dir, "../plugin/fixtures/invalid-plugin.ts"),
|
||||
])
|
||||
expect(
|
||||
(yield* plugins.list()).filter((plugin) => plugin.status === "failed").map((plugin) => plugin.source),
|
||||
).toEqual([
|
||||
{ type: "local", path: path.join(import.meta.dir, "../plugin/fixtures/missing-plugin.ts") },
|
||||
{ type: "local", path: path.join(import.meta.dir, "../plugin/fixtures/invalid-plugin.ts") },
|
||||
])
|
||||
}),
|
||||
).pipe(Effect.provide(Logger.layer([logger])))
|
||||
})
|
||||
@@ -246,10 +265,12 @@ describe("PluginSupervisor config", () => {
|
||||
Effect.gen(function* () {
|
||||
yield* ready()
|
||||
const plugins = yield* Plugin.Service
|
||||
const ids = (yield* plugins.list()).map((plugin) => String(plugin.id))
|
||||
const inventory = yield* plugins.list()
|
||||
const ids = inventory.map((plugin) => String(plugin.id))
|
||||
expect(ids).toContain("opencode.agent")
|
||||
expect(ids).toContain("static-sdk")
|
||||
expect(ids).not.toContain("config-promise-plugin")
|
||||
expect(inventory.find((plugin) => plugin.id === "static-sdk")?.source).toEqual({ type: "sdk" })
|
||||
|
||||
const agents = yield* Agent.Service
|
||||
expect(yield* agents.get(Agent.ID.make("directory"))).toBeUndefined()
|
||||
|
||||
@@ -6,7 +6,7 @@ import { expect, test } from "bun:test"
|
||||
import { SqliteClient } from "@effect/sql-sqlite-bun"
|
||||
import { eq, sql } from "drizzle-orm"
|
||||
import { integer, sqliteTable, text } from "drizzle-orm/sqlite-core"
|
||||
import { Effect } from "effect"
|
||||
import { Effect, Tracer } from "effect"
|
||||
import type { SqlClient as SqlClientService } from "effect/unstable/sql/SqlClient"
|
||||
import { isSqlError } from "effect/unstable/sql/SqlError"
|
||||
import { EffectDrizzleSqlite } from "@opencode-ai/core/database/drizzle"
|
||||
@@ -49,6 +49,31 @@ test("selects rows through Effect-yieldable query builders", async () => {
|
||||
)
|
||||
})
|
||||
|
||||
test("suppresses statement spans", async () => {
|
||||
const spans: Tracer.NativeSpan[] = []
|
||||
const tracer = Tracer.make({
|
||||
span(options) {
|
||||
const span = new Tracer.NativeSpan(options)
|
||||
spans.push(span)
|
||||
return span
|
||||
},
|
||||
})
|
||||
|
||||
await Effect.runPromise(
|
||||
Effect.gen(function* () {
|
||||
const db = yield* makeDb
|
||||
yield* db.transaction((tx) => tx.insert(users).values({ name: "Grace" }))
|
||||
yield* db.select().from(users)
|
||||
}).pipe(
|
||||
Effect.provideService(Tracer.Tracer, tracer),
|
||||
Effect.provide(SqliteClient.layer({ filename: ":memory:", disableWAL: true })),
|
||||
Effect.scoped,
|
||||
),
|
||||
)
|
||||
|
||||
expect(spans.map((span) => span.name)).not.toContain("sql.execute")
|
||||
})
|
||||
|
||||
test("commits successful transactions", async () => {
|
||||
await run(
|
||||
Effect.gen(function* () {
|
||||
|
||||
@@ -428,10 +428,18 @@ describe("LocationServiceMap", () => {
|
||||
),
|
||||
)
|
||||
for (let attempt = 0; attempt < 100; attempt++) {
|
||||
if ((yield* registry.list()).length === 0) break
|
||||
if ((yield* registry.list()).some((plugin) => plugin.status === "failed")) break
|
||||
yield* Effect.sleep("20 millis")
|
||||
}
|
||||
expect(yield* registry.list()).toEqual([])
|
||||
expect(yield* registry.list()).toEqual([
|
||||
{
|
||||
id: Plugin.ID.make("failing-plugin"),
|
||||
source: { type: "local", path: path.join(import.meta.dir, "plugin/fixtures/failing-plugin.ts") },
|
||||
status: "failed",
|
||||
error: expect.stringContaining("plugin failed"),
|
||||
tui: false,
|
||||
},
|
||||
])
|
||||
|
||||
yield* Effect.promise(() => fs.writeFile(file, JSON.stringify({ plugins: ["-*", "opencode.agent"] })))
|
||||
for (let attempt = 0; attempt < 100; attempt++) {
|
||||
|
||||
@@ -89,13 +89,7 @@ describe("Plugin", () => {
|
||||
yield* host.mcp.connect({ location, server: "routed" }).pipe(Effect.orDie)
|
||||
yield* host.mcp.disconnect({ location, server: "routed" }).pipe(Effect.orDie)
|
||||
expect((yield* host.mcp.list({ location }).pipe(Effect.orDie)).location.directory).toBe(target)
|
||||
expect(routed).toEqual([
|
||||
"add:/target",
|
||||
"remove:/target",
|
||||
"connect:/target",
|
||||
"disconnect:/target",
|
||||
"list:/target",
|
||||
])
|
||||
expect(routed).toEqual(["add:/target", "remove:/target", "connect:/target", "disconnect:/target", "list:/target"])
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -138,9 +132,22 @@ describe("Plugin", () => {
|
||||
expect(updates).toBe(2)
|
||||
expect((yield* agents.get(Agent.ID.make("configured")))?.description).toBe("second")
|
||||
|
||||
yield* plugins.activate(
|
||||
[versioned(managed(), "2")],
|
||||
[
|
||||
{
|
||||
source: { type: "package", package: "broken" },
|
||||
status: "failed",
|
||||
error: "failed to resolve",
|
||||
tui: false,
|
||||
},
|
||||
],
|
||||
)
|
||||
expect(updates).toBe(3)
|
||||
|
||||
yield* plugins.activate([])
|
||||
expect(yield* agents.get(Agent.ID.make("configured"))).toBeUndefined()
|
||||
expect(updates).toBe(3)
|
||||
expect(updates).toBe(4)
|
||||
yield* unsubscribe
|
||||
}),
|
||||
)
|
||||
@@ -160,7 +167,7 @@ describe("Plugin", () => {
|
||||
.pipe(Effect.exit)
|
||||
|
||||
expect(Exit.isFailure(result)).toBe(true)
|
||||
expect(yield* plugins.list()).toEqual([{ id: active }])
|
||||
expect(yield* plugins.list()).toEqual([{ id: active, source: { type: "builtin" }, status: "active", tui: false }])
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -189,12 +196,24 @@ describe("Plugin", () => {
|
||||
})
|
||||
|
||||
yield* plugins.activate([versioned(good), versioned(bad)])
|
||||
expect(yield* plugins.list()).toEqual([{ id: Plugin.ID.make("good") }])
|
||||
expect(yield* plugins.list()).toEqual([
|
||||
{ id: Plugin.ID.make("good"), source: { type: "builtin" }, status: "active", tui: false },
|
||||
{
|
||||
id: Plugin.ID.make("bad"),
|
||||
source: { type: "builtin" },
|
||||
status: "failed",
|
||||
error: expect.stringContaining("materialization failed"),
|
||||
tui: false,
|
||||
},
|
||||
])
|
||||
expect((yield* agents.get(Agent.ID.make("configured")))?.description).toBe("loaded")
|
||||
|
||||
fail = false
|
||||
yield* plugins.activate([versioned(good), versioned(bad, "2")])
|
||||
expect(yield* plugins.list()).toEqual([{ id: Plugin.ID.make("good") }, { id: Plugin.ID.make("bad") }])
|
||||
expect(yield* plugins.list()).toEqual([
|
||||
{ id: Plugin.ID.make("good"), source: { type: "builtin" }, status: "active", tui: false },
|
||||
{ id: Plugin.ID.make("bad"), source: { type: "builtin" }, status: "active", tui: false },
|
||||
])
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -229,7 +248,15 @@ describe("Plugin", () => {
|
||||
yield* plugins.activate([versioned(previous)])
|
||||
yield* plugins.activate([versioned(replacement, "2")])
|
||||
|
||||
expect(yield* plugins.list()).toEqual([{ id: Plugin.ID.make("managed") }])
|
||||
expect(yield* plugins.list()).toEqual([
|
||||
{
|
||||
id: Plugin.ID.make("managed"),
|
||||
source: { type: "builtin" },
|
||||
status: "failed",
|
||||
error: expect.stringContaining("replacement failed"),
|
||||
tui: false,
|
||||
},
|
||||
])
|
||||
expect((yield* agents.get(Agent.ID.make("configured")))?.description).toBe("previous")
|
||||
}),
|
||||
)
|
||||
@@ -261,7 +288,15 @@ describe("Plugin", () => {
|
||||
yield* plugins.activate([versioned(previous)])
|
||||
yield* plugins.activate([versioned(replacement, "2")])
|
||||
|
||||
expect(yield* plugins.list()).toEqual([])
|
||||
expect(yield* plugins.list()).toEqual([
|
||||
{
|
||||
id: Plugin.ID.make("managed"),
|
||||
source: { type: "builtin" },
|
||||
status: "failed",
|
||||
error: expect.stringContaining("replacement failed"),
|
||||
tui: false,
|
||||
},
|
||||
])
|
||||
expect(yield* agents.get(Agent.ID.make("configured"))).toBeUndefined()
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -2,6 +2,7 @@ import { Plugin } from "@opencode-ai/plugin"
|
||||
|
||||
export default Plugin.define({
|
||||
id: "config-promise-plugin",
|
||||
tui: true,
|
||||
setup: async (ctx) => {
|
||||
await ctx.agent.transform((agents) => {
|
||||
agents.update("configured", (agent) => {
|
||||
|
||||
@@ -421,8 +421,48 @@ describe("ModelsDevPlugin", () => {
|
||||
expect(yield* integrations.get(Integration.ID.make("google-vertex"))).toBeDefined()
|
||||
expect(yield* integrations.get(Integration.ID.make("azure-cognitive-services"))).toBeUndefined()
|
||||
expect(yield* integrations.get(Integration.ID.make("google-vertex-anthropic"))).toBeUndefined()
|
||||
expect(ProviderPlugins.map((plugin) => plugin.id)).not.toContain("opencode.provider.azure-cognitive-services")
|
||||
expect(ProviderPlugins.map((plugin) => plugin.id)).not.toContain("opencode.provider.google-vertex-anthropic")
|
||||
expect(ProviderPlugins.map((plugin) => plugin.id)).not.toContain("opencode.provider.azure.cognitive.services")
|
||||
expect(ProviderPlugins.map((plugin) => plugin.id)).not.toContain("opencode.provider.google.vertex.anthropic")
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("advertises only key-bearing Google Vertex environment variables", () =>
|
||||
Effect.gen(function* () {
|
||||
const integrations = yield* Integration.Service
|
||||
const catalog = yield* Catalog.Service
|
||||
|
||||
yield* ModelsDevPlugin.effect(
|
||||
host({
|
||||
catalog: catalogHost(catalog),
|
||||
integration: integrationHost(integrations),
|
||||
}),
|
||||
).pipe(
|
||||
Effect.provideService(
|
||||
ModelsDev.Service,
|
||||
ModelsDev.Service.of({
|
||||
get: () =>
|
||||
Effect.succeed([
|
||||
{
|
||||
info: {
|
||||
id: Provider.ID.make("google-vertex"),
|
||||
name: "Google Vertex",
|
||||
activation: "auto",
|
||||
package: Provider.aisdk("@ai-sdk/google-vertex"),
|
||||
},
|
||||
environment: ["GOOGLE_VERTEX_PROJECT", "GOOGLE_VERTEX_LOCATION", "GOOGLE_APPLICATION_CREDENTIALS"],
|
||||
models: [],
|
||||
},
|
||||
] satisfies readonly ModelsDev.Snapshot[]),
|
||||
refresh: () => Effect.void,
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
// Vertex authenticates through ADC; project, location, and the credentials
|
||||
// file path are configuration, not API keys.
|
||||
expect(yield* integrations.get(Integration.ID.make("google-vertex"))).toMatchObject({
|
||||
methods: [{ type: "key" }, { type: "env", names: ["GOOGLE_VERTEX_API_KEY"] }],
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
|
||||
@@ -141,6 +141,52 @@ describe("GoogleVertexPlugin", () => {
|
||||
),
|
||||
)
|
||||
|
||||
it.effect("enables the provider when a project resolves and leaves it automatic otherwise", () =>
|
||||
withEnv(
|
||||
{
|
||||
GOOGLE_VERTEX_PROJECT: undefined,
|
||||
GOOGLE_CLOUD_PROJECT: undefined,
|
||||
GCP_PROJECT: undefined,
|
||||
GCLOUD_PROJECT: undefined,
|
||||
},
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
const catalog = yield* Catalog.Service
|
||||
yield* catalog.transform((catalog) =>
|
||||
catalog.provider.update(Provider.ID.make("google-vertex"), (provider) => {
|
||||
provider.package = Provider.aisdk("@ai-sdk/google-vertex")
|
||||
}),
|
||||
)
|
||||
yield* addPlugin()
|
||||
|
||||
expect(required(yield* catalog.provider.get(Provider.ID.make("google-vertex"))).activation).toBe("auto")
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
it.effect("enables the provider when a project resolves from env", () =>
|
||||
withEnv(
|
||||
{
|
||||
GOOGLE_VERTEX_PROJECT: undefined,
|
||||
GOOGLE_CLOUD_PROJECT: "adc-project",
|
||||
GCP_PROJECT: undefined,
|
||||
GCLOUD_PROJECT: undefined,
|
||||
},
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
const catalog = yield* Catalog.Service
|
||||
yield* catalog.transform((catalog) =>
|
||||
catalog.provider.update(Provider.ID.make("google-vertex"), (provider) => {
|
||||
provider.package = Provider.aisdk("@ai-sdk/google-vertex")
|
||||
}),
|
||||
)
|
||||
yield* addPlugin()
|
||||
|
||||
expect(required(yield* catalog.provider.get(Provider.ID.make("google-vertex"))).activation).toBe("enabled")
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
it.effect("resolves the advertised GOOGLE_VERTEX_PROJECT env for provider updates and SDKs", () =>
|
||||
withEnv(
|
||||
{
|
||||
@@ -230,7 +276,7 @@ describe("GoogleVertexPlugin", () => {
|
||||
),
|
||||
)
|
||||
|
||||
it.effect("keeps OpenAI-compatible Vertex endpoint templates regional for eu", () =>
|
||||
it.effect("uses the REP endpoint for OpenAI-compatible Vertex multi-regions", () =>
|
||||
Effect.gen(function* () {
|
||||
const catalog = yield* Catalog.Service
|
||||
yield* catalog.transform((catalog) =>
|
||||
@@ -248,11 +294,65 @@ describe("GoogleVertexPlugin", () => {
|
||||
const provider = required(yield* catalog.provider.get(Provider.ID.make("google-vertex")))
|
||||
expect(provider).toMatchObject({
|
||||
package: "aisdk:@ai-sdk/openai-compatible",
|
||||
settings: { baseURL: "https://eu-aiplatform.googleapis.com/v1/projects/config-project/locations/eu" },
|
||||
settings: {
|
||||
baseURL: "https://aiplatform.eu.rep.googleapis.com/v1/projects/config-project/locations/eu",
|
||||
},
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("uses the REP endpoint for the native Vertex SDK in multi-regions", () =>
|
||||
withEnv(
|
||||
{
|
||||
GOOGLE_CLOUD_PROJECT: "env-project",
|
||||
GOOGLE_VERTEX_LOCATION: "us",
|
||||
},
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
vertexOptions.length = 0
|
||||
const aisdk = yield* AISDK.Service
|
||||
yield* addPlugin()
|
||||
yield* aisdk.runSDK({
|
||||
model: Model.Info.make({
|
||||
...Model.Info.default(Provider.ID.make("google-vertex"), Model.ID.make("gemini")),
|
||||
modelID: Model.ID.make("gemini"),
|
||||
package: "aisdk:@ai-sdk/google-vertex",
|
||||
}),
|
||||
package: "@ai-sdk/google-vertex",
|
||||
options: { name: "google-vertex" },
|
||||
})
|
||||
expect(vertexOptions).toHaveLength(1)
|
||||
expect(vertexOptions[0].baseURL).toBe(
|
||||
"https://aiplatform.us.rep.googleapis.com/v1beta1/projects/env-project/locations/us/publishers/google",
|
||||
)
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
it.effect("preserves custom native Vertex base URLs in multi-regions", () =>
|
||||
Effect.gen(function* () {
|
||||
vertexOptions.length = 0
|
||||
const aisdk = yield* AISDK.Service
|
||||
yield* addPlugin()
|
||||
yield* aisdk.runSDK({
|
||||
model: Model.Info.make({
|
||||
...Model.Info.default(Provider.ID.make("google-vertex"), Model.ID.make("gemini")),
|
||||
modelID: Model.ID.make("gemini"),
|
||||
package: "aisdk:@ai-sdk/google-vertex",
|
||||
}),
|
||||
package: "@ai-sdk/google-vertex",
|
||||
options: {
|
||||
name: "google-vertex",
|
||||
project: "project",
|
||||
location: "eu",
|
||||
baseURL: "https://vertex.example/v1",
|
||||
},
|
||||
})
|
||||
expect(vertexOptions).toHaveLength(1)
|
||||
expect(vertexOptions[0].baseURL).toBe("https://vertex.example/v1")
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("defaults location to us-central1 when only project is configured", () =>
|
||||
withEnv(
|
||||
{
|
||||
|
||||
@@ -43,10 +43,10 @@ function withEnv<A, E, R>(vars: Record<string, string | undefined>, effect: () =
|
||||
describe("SnowflakeCortexPlugin", () => {
|
||||
it.effect("is registered in ProviderPlugins before OpenAICompatiblePlugin", () =>
|
||||
Effect.sync(() => {
|
||||
expect(ProviderPlugins.map((item) => item.id)).toContain("opencode.provider.snowflake-cortex")
|
||||
expect(ProviderPlugins.map((item) => item.id)).toContain("opencode.provider.snowflake.cortex")
|
||||
const ids = ProviderPlugins.map((p) => p.id)
|
||||
expect(ids.indexOf("opencode.provider.snowflake-cortex")).toBeLessThan(
|
||||
ids.indexOf("opencode.provider.openai-compatible"),
|
||||
expect(ids.indexOf("opencode.provider.snowflake.cortex")).toBeLessThan(
|
||||
ids.indexOf("opencode.provider.openai.compatible"),
|
||||
)
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -48,12 +48,12 @@ describe("SystemPromptPlugin", () => {
|
||||
|
||||
test("uses granular IDs with a common prefix", () => {
|
||||
expect(SystemPromptPlugin.Plugins.map((plugin) => plugin.id)).toEqual([
|
||||
"opencode.system-prompt.openai",
|
||||
"opencode.system-prompt.google",
|
||||
"opencode.system-prompt.anthropic",
|
||||
"opencode.system-prompt.kimi",
|
||||
"opencode.system-prompt.arcee",
|
||||
"opencode.system-prompt.meta",
|
||||
"opencode.prompt.openai",
|
||||
"opencode.prompt.google",
|
||||
"opencode.prompt.anthropic",
|
||||
"opencode.prompt.kimi",
|
||||
"opencode.prompt.arcee",
|
||||
"opencode.prompt.meta",
|
||||
])
|
||||
})
|
||||
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { Effect } from "effect"
|
||||
import { Provider } from "@opencode-ai/core/provider"
|
||||
|
||||
describe("Provider", () => {
|
||||
test("loads Vertex native provider entrypoints", async () => {
|
||||
const packages = [
|
||||
"@opencode-ai/ai/providers/google-vertex",
|
||||
"@opencode-ai/ai/providers/google-vertex/gemini",
|
||||
"@opencode-ai/ai/providers/google-vertex/chat",
|
||||
"@opencode-ai/ai/providers/google-vertex/responses",
|
||||
"@opencode-ai/ai/providers/google-vertex/messages",
|
||||
]
|
||||
|
||||
for (const specifier of packages) {
|
||||
const loaded = await Effect.runPromise(Provider.loadPackage(specifier))
|
||||
expect(loaded.model).toBeFunction()
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -197,6 +197,29 @@ test("calculates step cost using the matching context tier", () => {
|
||||
).toBeCloseTo(0.0002926)
|
||||
})
|
||||
|
||||
test("ignores malformed model cost fields", () => {
|
||||
const costs = [
|
||||
{
|
||||
input: Money.USDPerMillionTokens.make(3),
|
||||
output: Money.USDPerMillionTokens.make(15),
|
||||
cache: {
|
||||
read: Money.USDPerMillionTokens.make(0.3),
|
||||
write: Money.USDPerMillionTokens.make(3.75),
|
||||
},
|
||||
},
|
||||
]
|
||||
Object.assign(costs[0], { input: {} })
|
||||
|
||||
expect(
|
||||
SessionUsage.calculateCost(costs, {
|
||||
input: 1_000_000,
|
||||
output: 100_000,
|
||||
reasoning: 0,
|
||||
cache: { read: 0, write: 0 },
|
||||
}),
|
||||
).toBe(Money.USD.make(1.5))
|
||||
})
|
||||
|
||||
test("does not apply an ineligible tier without base pricing", () => {
|
||||
expect(
|
||||
SessionUsage.calculateCost(
|
||||
|
||||
@@ -186,28 +186,6 @@ describe("WebFetchTool helpers", () => {
|
||||
)
|
||||
})
|
||||
|
||||
test("parses malformed tag prefixes in linear time without a regex prepass", () => {
|
||||
const small = "<a".repeat(250_000)
|
||||
const large = "<a".repeat(1_000_000)
|
||||
const start = Bun.nanoseconds()
|
||||
WebFetchTool.convertHTMLToMarkdown(small)
|
||||
const smallDuration = Bun.nanoseconds() - start
|
||||
const next = Bun.nanoseconds()
|
||||
WebFetchTool.convertHTMLToMarkdown(large)
|
||||
const largeDuration = Bun.nanoseconds() - next
|
||||
expect(largeDuration).toBeLessThan(smallDuration * 10)
|
||||
})
|
||||
|
||||
test("caps escaped prose and backtick-heavy pre output at the webfetch response ceiling", () => {
|
||||
const prose = `<p>${"*".repeat(WebFetchTool.MAX_RESPONSE_BYTES)}</p>`
|
||||
const code = `<pre>${"`".repeat(WebFetchTool.MAX_RESPONSE_BYTES - 11)}</pre>`
|
||||
const proseOutput = WebFetchTool.convertHTMLToMarkdown(prose)
|
||||
const codeOutput = WebFetchTool.convertHTMLToMarkdown(code)
|
||||
expect(Buffer.byteLength(proseOutput)).toBeLessThanOrEqual(WebFetchTool.MAX_RESPONSE_BYTES)
|
||||
expect(Buffer.byteLength(codeOutput)).toBeLessThanOrEqual(WebFetchTool.MAX_RESPONSE_BYTES)
|
||||
expect(codeOutput.startsWith("~~~\n")).toBe(true)
|
||||
})
|
||||
|
||||
test("does not confuse source NUL text with buffered code", () => {
|
||||
expect(WebFetchTool.convertHTMLToMarkdown(`<p>before \u00000\u0000 after</p><pre>code</pre>`)).toBe(
|
||||
`before \u00000\u0000 after\n\n\`\`\`\ncode\n\`\`\``,
|
||||
|
||||
@@ -29,7 +29,7 @@ const webSearchToolNode = makeLocationNode({
|
||||
yield* registerToolPlugin(WebSearchTool.Plugin, { websearch: webSearchHost(websearch) })
|
||||
}),
|
||||
),
|
||||
deps: [Tool.node, Permission.node, WebSearch.node, Form.node, Config.node],
|
||||
deps: [Tool.node, Permission.node, WebSearch.node, Form.node],
|
||||
})
|
||||
|
||||
const sessionID = Session.ID.make("ses_websearch_test")
|
||||
@@ -93,6 +93,7 @@ const websearch = Layer.succeed(
|
||||
if (selection === false) return yield* new WebSearch.DisabledError()
|
||||
return selection ? providers.find((provider) => provider.id === selection) : undefined
|
||||
}),
|
||||
select: (next) => Effect.sync(() => (selection = next)),
|
||||
query: (input) =>
|
||||
Effect.gen(function* () {
|
||||
queries.push(input)
|
||||
|
||||
@@ -3,10 +3,11 @@ import { Effect, Exit, Scope } from "effect"
|
||||
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
||||
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
|
||||
import { Bus } from "@opencode-ai/core/bus"
|
||||
import { KV } from "@opencode-ai/core/kv"
|
||||
import { WebSearch } from "@opencode-ai/core/websearch"
|
||||
import { testEffect } from "./lib/effect"
|
||||
|
||||
const it = testEffect(AppNodeBuilder.build(LayerNode.group([WebSearch.node, Bus.node])))
|
||||
const it = testEffect(AppNodeBuilder.build(LayerNode.group([WebSearch.node, Bus.node, KV.node])))
|
||||
|
||||
const register = (id: string) =>
|
||||
Effect.gen(function* () {
|
||||
@@ -80,6 +81,31 @@ describe("WebSearch", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("persists the selected provider in KV", () =>
|
||||
Effect.gen(function* () {
|
||||
const parallel = yield* register("parallel")
|
||||
const websearch = yield* WebSearch.Service
|
||||
const kv = yield* KV.Service
|
||||
|
||||
yield* websearch.select(parallel.providerID)
|
||||
|
||||
expect(yield* kv.get(WebSearch.ProviderKey)).toBe(parallel.providerID)
|
||||
expect((yield* websearch.query({ query: "remembered" })).providerID).toBe(parallel.providerID)
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("keeps config transforms above the persisted selection", () =>
|
||||
Effect.gen(function* () {
|
||||
const exa = yield* register("exa")
|
||||
const parallel = yield* register("parallel")
|
||||
const websearch = yield* WebSearch.Service
|
||||
yield* websearch.select(parallel.providerID)
|
||||
yield* websearch.transform((draft) => draft.default.set(exa.providerID))
|
||||
|
||||
expect((yield* websearch.query({ query: "configured" })).providerID).toBe(exa.providerID)
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("chooses a registered provider for random selection", () =>
|
||||
Effect.gen(function* () {
|
||||
yield* register("exa")
|
||||
|
||||
@@ -37,6 +37,7 @@ export interface Context {
|
||||
|
||||
export interface Plugin<R = Scope.Scope> {
|
||||
readonly id: string
|
||||
readonly tui?: boolean
|
||||
readonly effect: (context: Context) => Effect.Effect<void, never, R>
|
||||
}
|
||||
|
||||
|
||||
@@ -67,6 +67,7 @@ function compileEndpoint(endpoint: HttpApiEndpoint.Top) {
|
||||
export function fromPromise(plugin: Plugin) {
|
||||
return define({
|
||||
id: plugin.id,
|
||||
tui: plugin.tui,
|
||||
effect: (host) =>
|
||||
Effect.gen(function* () {
|
||||
const [{ ClientApi }, { OpenCodeEvent }] = yield* Effect.promise(() =>
|
||||
|
||||
@@ -38,6 +38,7 @@ export type Cleanup = () => Promise<void> | void
|
||||
|
||||
export interface Plugin {
|
||||
readonly id: string
|
||||
readonly tui?: boolean
|
||||
readonly setup: (context: Context) => Promise<Cleanup | void> | Cleanup | void
|
||||
}
|
||||
|
||||
|
||||
@@ -48,58 +48,6 @@
|
||||
"summary": "Check server health"
|
||||
}
|
||||
},
|
||||
"/api/service/stop": {
|
||||
"post": {
|
||||
"tags": ["health"],
|
||||
"operationId": "v2.health.stop",
|
||||
"parameters": [],
|
||||
"security": [],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "ServiceStopResponse",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ServiceStopResponse"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "InvalidRequestError",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/InvalidRequestErrorEncoded"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"401": {
|
||||
"description": "UnauthorizedError",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/UnauthorizedErrorEncoded"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Request graceful shutdown of one exact managed server instance.",
|
||||
"summary": "Stop the managed server",
|
||||
"requestBody": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ServiceStopRequest"
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/server": {
|
||||
"get": {
|
||||
"tags": ["server"],
|
||||
@@ -9783,26 +9731,6 @@
|
||||
"required": ["_tag", "message"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"ServiceStopRequest": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"instanceID": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": ["instanceID"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"ServiceStopResponse": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"accepted": {
|
||||
"type": "boolean"
|
||||
}
|
||||
},
|
||||
"required": ["accepted"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"Union_1": {
|
||||
"anyOf": [
|
||||
{
|
||||
|
||||
@@ -9,16 +9,6 @@ export namespace ServiceStatus {
|
||||
pid: Schema.Int.check(Schema.isGreaterThanOrEqualTo(0)),
|
||||
}).annotate({ identifier: "ServiceHealth" })
|
||||
export type Health = typeof Health.Type
|
||||
|
||||
export const StopRequest = Schema.Struct({
|
||||
instanceID: Schema.String,
|
||||
}).annotate({ identifier: "ServiceStopRequest" })
|
||||
export type StopRequest = typeof StopRequest.Type
|
||||
|
||||
export const StopResponse = Schema.Struct({
|
||||
accepted: Schema.Boolean,
|
||||
}).annotate({ identifier: "ServiceStopResponse" })
|
||||
export type StopResponse = typeof StopResponse.Type
|
||||
}
|
||||
|
||||
export const HealthGroup = HttpApiGroup.make("server.health")
|
||||
@@ -33,16 +23,4 @@ export const HealthGroup = HttpApiGroup.make("server.health")
|
||||
}),
|
||||
),
|
||||
)
|
||||
.add(
|
||||
HttpApiEndpoint.post("health.stop", "/api/service/stop", {
|
||||
payload: ServiceStatus.StopRequest,
|
||||
success: ServiceStatus.StopResponse,
|
||||
}).annotateMerge(
|
||||
OpenApi.annotations({
|
||||
identifier: "v2.health.stop",
|
||||
summary: "Stop the managed server",
|
||||
description: "Request graceful shutdown of one exact managed server instance.",
|
||||
}),
|
||||
),
|
||||
)
|
||||
.annotateMerge(OpenApi.annotations({ title: "health" }))
|
||||
|
||||
@@ -15,7 +15,7 @@ export const PluginGroup = HttpApiGroup.make("server.plugin")
|
||||
OpenApi.annotations({
|
||||
identifier: "v2.plugin.list",
|
||||
summary: "List plugins",
|
||||
description: "Retrieve currently loaded plugins.",
|
||||
description: "Retrieve enabled server plugins and their current status.",
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
@@ -2,14 +2,35 @@ export * as Plugin from "./plugin.js"
|
||||
|
||||
import { Schema } from "effect"
|
||||
import { ephemeral, inventory } from "./event.js"
|
||||
import { optional } from "./schema.js"
|
||||
|
||||
export const ID = Schema.String.pipe(Schema.brand("Plugin.ID"))
|
||||
export type ID = typeof ID.Type
|
||||
|
||||
export interface Info extends Schema.Schema.Type<typeof Info> {}
|
||||
export const Info = Schema.Struct({
|
||||
id: ID,
|
||||
}).annotate({ identifier: "Plugin.Info" })
|
||||
export const Source = Schema.Union([
|
||||
Schema.Struct({ type: Schema.Literal("builtin") }),
|
||||
Schema.Struct({ type: Schema.Literal("package"), package: Schema.String }),
|
||||
Schema.Struct({ type: Schema.Literal("local"), path: Schema.String }),
|
||||
Schema.Struct({ type: Schema.Literal("sdk") }),
|
||||
]).annotate({ identifier: "Plugin.Source" })
|
||||
export type Source = typeof Source.Type
|
||||
|
||||
export const Info = Schema.Union([
|
||||
Schema.Struct({
|
||||
id: ID,
|
||||
source: Source,
|
||||
status: Schema.Literal("active"),
|
||||
tui: Schema.Boolean,
|
||||
}),
|
||||
Schema.Struct({
|
||||
id: ID.pipe(optional),
|
||||
source: Source,
|
||||
status: Schema.Literal("failed"),
|
||||
error: Schema.String,
|
||||
tui: Schema.Boolean,
|
||||
}),
|
||||
]).annotate({ identifier: "Plugin.Info" })
|
||||
export type Info = typeof Info.Type
|
||||
|
||||
const Added = ephemeral({
|
||||
type: "plugin.added",
|
||||
|
||||
@@ -4,17 +4,15 @@ import { Api } from "../api"
|
||||
import { ServerInfo } from "../server-info"
|
||||
|
||||
export const HealthHandler = HttpApiBuilder.group(Api, "server.health", (handlers) =>
|
||||
handlers
|
||||
.handle("health.get", () =>
|
||||
Effect.gen(function* () {
|
||||
const info = yield* ServerInfo.Service
|
||||
return {
|
||||
healthy: true as const,
|
||||
version: info.app.version ?? "unknown",
|
||||
// Runtimes without OS process identity (workerd) report 0.
|
||||
pid: process.pid ?? 0,
|
||||
}
|
||||
}),
|
||||
)
|
||||
.handle("health.stop", () => Effect.succeed({ accepted: false })),
|
||||
handlers.handle("health.get", () =>
|
||||
Effect.gen(function* () {
|
||||
const info = yield* ServerInfo.Service
|
||||
return {
|
||||
healthy: true as const,
|
||||
version: info.app.version ?? "unknown",
|
||||
// Runtimes without OS process identity (workerd) report 0.
|
||||
pid: process.pid ?? 0,
|
||||
}
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
@@ -1,12 +1,10 @@
|
||||
export * as ServerProcess from "./process"
|
||||
|
||||
import { NodeHttpServer, NodeHttpServerRequest } from "@effect/platform-node"
|
||||
import { NodeHttpServer } from "@effect/platform-node"
|
||||
import { SessionRestart } from "@opencode-ai/core/session/execution/restart"
|
||||
import { ServiceStatus } from "@opencode-ai/protocol/groups/health"
|
||||
import { hasPtyConnectTicketURL } from "@opencode-ai/protocol/groups/pty"
|
||||
import { Cause, Context, Deferred, Effect, Exit, Layer, Option, Ref, Schema, Scope } from "effect"
|
||||
import { Cause, Context, Deferred, Effect, Exit, Layer, Option, Ref, Scope } from "effect"
|
||||
import { HttpMiddleware, HttpRouter, HttpServer, HttpServerRequest, HttpServerResponse } from "effect/unstable/http"
|
||||
import { randomUUID } from "node:crypto"
|
||||
import { createServer } from "node:http"
|
||||
import { ServerAuth } from "./auth"
|
||||
import { isAllowedCorsOrigin } from "./cors"
|
||||
@@ -18,7 +16,6 @@ import { Status } from "./service-status"
|
||||
import type { ServerOptions } from "./options"
|
||||
|
||||
export interface Lifecycle<E = never, R = never> {
|
||||
readonly instanceID: string
|
||||
readonly onListen: (
|
||||
address: HttpServer.Address,
|
||||
shutdown: Effect.Effect<void>,
|
||||
@@ -51,16 +48,13 @@ export const start = Effect.fn("ServerProcess.start")(function* <E, R>(
|
||||
const hostname = options.hostname ?? "127.0.0.1"
|
||||
const port = Option.fromNullishOr(options.port)
|
||||
const shutdown = yield* Deferred.make<void>()
|
||||
const status = yield* Status.make({
|
||||
instanceID: lifecycle?.instanceID ?? randomUUID(),
|
||||
managed: lifecycle !== undefined,
|
||||
})
|
||||
const status = yield* Status.make()
|
||||
const bound = yield* listen({ hostname, port })
|
||||
const application = yield* Ref.make(Option.none<App>())
|
||||
// Request fibers may continue inbound trace context, but must not inherit the server startup parent.
|
||||
yield* bound.http
|
||||
.serve(
|
||||
dispatch(password, status, application, shutdown, options.app?.version ?? "unknown").pipe(
|
||||
dispatch(password, status, application, options.app?.version ?? "unknown").pipe(
|
||||
HttpMiddleware.cors({ allowedOrigins: isAllowedCorsOrigin, maxAge: 86_400 }),
|
||||
),
|
||||
errorResponseLogger,
|
||||
@@ -163,22 +157,15 @@ function dispatch(
|
||||
password: string,
|
||||
status: Status.Interface,
|
||||
application: Ref.Ref<Option.Option<App>>,
|
||||
shutdown: Deferred.Deferred<void>,
|
||||
version: string,
|
||||
): App {
|
||||
const auth = ServerAuth.Config.of({ password: Option.some(password), username: "opencode" })
|
||||
return Effect.gen(function* () {
|
||||
const request = yield* HttpServerRequest.HttpServerRequest
|
||||
const url = new URL(request.url, "http://localhost")
|
||||
const lifecycle =
|
||||
request.method === "GET" && url.pathname === "/api/health"
|
||||
? "health"
|
||||
: request.method === "POST" && url.pathname === "/api/service/stop"
|
||||
? "stop"
|
||||
: undefined
|
||||
if (lifecycle !== undefined) {
|
||||
if (request.method === "GET" && url.pathname === "/api/health") {
|
||||
if (!(yield* authorizedRequest(request, auth))) return unauthorized()
|
||||
return yield* control(request, lifecycle, status, () => Deferred.doneUnsafe(shutdown, Effect.void), version)
|
||||
return yield* healthResponse(status, version)
|
||||
}
|
||||
const state = yield* status.current
|
||||
const app = yield* Ref.get(application)
|
||||
@@ -196,33 +183,6 @@ function unauthorized() {
|
||||
})
|
||||
}
|
||||
|
||||
const control = Effect.fnUntraced(function* (
|
||||
request: HttpServerRequest.HttpServerRequest,
|
||||
route: "health" | "stop",
|
||||
status: Status.Interface,
|
||||
stop: () => void,
|
||||
version: string,
|
||||
) {
|
||||
if (route === "health") return yield* healthResponse(status, version)
|
||||
const body = yield* request.json.pipe(Effect.option)
|
||||
const input = Option.isSome(body) ? Schema.decodeUnknownOption(ServiceStatus.StopRequest)(body.value) : Option.none()
|
||||
if (Option.isNone(input)) return HttpServerResponse.jsonUnsafe({ code: "invalid_request" }, { status: 400 })
|
||||
const accepted = yield* status.requestStop(input.value)
|
||||
if (accepted) {
|
||||
const response = NodeHttpServerRequest.toServerResponse(request)
|
||||
yield* Effect.sync(() => {
|
||||
const complete = () => {
|
||||
response.off("finish", complete)
|
||||
response.off("close", complete)
|
||||
stop()
|
||||
}
|
||||
response.once("finish", complete)
|
||||
response.once("close", complete)
|
||||
})
|
||||
}
|
||||
return HttpServerResponse.jsonUnsafe({ accepted })
|
||||
})
|
||||
|
||||
const healthResponse = Effect.fnUntraced(function* (status: Status.Interface, version: string) {
|
||||
const state = yield* status.current
|
||||
return HttpServerResponse.jsonUnsafe(
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
export * as Status from "./service-status"
|
||||
|
||||
import { ServiceStatus } from "@opencode-ai/protocol/groups/health"
|
||||
import { Effect, Ref } from "effect"
|
||||
|
||||
export type State =
|
||||
@@ -14,14 +13,9 @@ export interface Interface {
|
||||
readonly ready: Effect.Effect<void>
|
||||
readonly fail: Effect.Effect<void>
|
||||
readonly beginStopping: Effect.Effect<void>
|
||||
readonly requestStop: (request: ServiceStatus.StopRequest) => Effect.Effect<boolean>
|
||||
}
|
||||
|
||||
export const make = Effect.fnUntraced(function* (options: {
|
||||
readonly instanceID: string
|
||||
readonly managed: boolean
|
||||
readonly initial?: State
|
||||
}) {
|
||||
export const make = Effect.fnUntraced(function* (options: { readonly initial?: State } = {}) {
|
||||
const current = yield* Ref.make(options.initial ?? ({ type: "starting" } satisfies State))
|
||||
const beginStopping = Ref.update(current, (status) =>
|
||||
status.type === "stopping" ? status : ({ type: "stopping" } satisfies State),
|
||||
@@ -32,9 +26,5 @@ export const make = Effect.fnUntraced(function* (options: {
|
||||
ready: Ref.update(current, (status) => (status.type === "starting" ? ({ type: "ready" } satisfies State) : status)),
|
||||
fail: Ref.update(current, (status) => (status.type === "starting" ? ({ type: "failed" } satisfies State) : status)),
|
||||
beginStopping,
|
||||
requestStop: (request) => {
|
||||
if (!options.managed || request.instanceID !== options.instanceID) return Effect.succeed(false)
|
||||
return beginStopping.pipe(Effect.as(true))
|
||||
},
|
||||
} satisfies Interface
|
||||
})
|
||||
|
||||
@@ -5,7 +5,7 @@ import { Status } from "../src/service-status"
|
||||
|
||||
it.effect("moves from starting to ready", () =>
|
||||
Effect.gen(function* () {
|
||||
const status = yield* Status.make({ instanceID: "one", managed: false })
|
||||
const status = yield* Status.make()
|
||||
expect(yield* status.current).toEqual({ type: "starting" })
|
||||
yield* status.ready
|
||||
expect(yield* status.current).toEqual({ type: "ready" })
|
||||
@@ -14,7 +14,7 @@ it.effect("moves from starting to ready", () =>
|
||||
|
||||
it.effect("keeps a startup failure until shutdown", () =>
|
||||
Effect.gen(function* () {
|
||||
const status = yield* Status.make({ instanceID: "one", managed: true })
|
||||
const status = yield* Status.make()
|
||||
yield* status.fail
|
||||
yield* status.ready
|
||||
yield* status.fail
|
||||
@@ -22,24 +22,13 @@ it.effect("keeps a startup failure until shutdown", () =>
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("stops only the addressed managed instance", () =>
|
||||
Effect.gen(function* () {
|
||||
const status = yield* Status.make({ instanceID: "one", managed: true })
|
||||
|
||||
expect(yield* status.requestStop({ instanceID: "other" })).toBe(false)
|
||||
expect(yield* status.current).toEqual({ type: "starting" })
|
||||
expect(yield* status.requestStop({ instanceID: "one" })).toBe(true)
|
||||
expect(yield* status.current).toEqual({ type: "stopping" })
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("keeps stopping after shutdown begins", () =>
|
||||
Effect.gen(function* () {
|
||||
const status = yield* Status.make({ instanceID: "one", managed: true })
|
||||
const status = yield* Status.make()
|
||||
|
||||
yield* status.beginStopping
|
||||
expect(yield* status.current).toEqual({ type: "stopping" })
|
||||
expect(yield* status.requestStop({ instanceID: "one" })).toBe(true)
|
||||
yield* status.beginStopping
|
||||
expect(yield* status.current).toEqual({ type: "stopping" })
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -1,16 +1,14 @@
|
||||
import { CliRenderEvents, TextAttributes, type ScrollBoxRenderable } from "@opentui/core"
|
||||
import { useKeyboard, useRenderer, useTerminalDimensions } from "@opentui/solid"
|
||||
import { createEffect, createMemo, createSignal, onCleanup, onMount } from "solid-js"
|
||||
import { createEffect, createMemo, createSignal, onCleanup } from "solid-js"
|
||||
import { useConfig } from "../config"
|
||||
import { useClipboard } from "../context/clipboard"
|
||||
import { Keymap } from "../context/keymap"
|
||||
import { getScrollAcceleration } from "../util/scroll"
|
||||
import { useDialog } from "../ui/dialog"
|
||||
import { useTheme } from "../context/theme"
|
||||
import { useToast } from "../ui/toast"
|
||||
|
||||
export function DialogErrorDetails(props: { title: string; error: string; onBack: () => void }) {
|
||||
const dialog = useDialog()
|
||||
const clipboard = useClipboard()
|
||||
const toast = useToast()
|
||||
const theme = useTheme("elevated")
|
||||
@@ -20,19 +18,21 @@ export function DialogErrorDetails(props: { title: string; error: string; onBack
|
||||
const config = useConfig().data
|
||||
const [copied, setCopied] = createSignal(false)
|
||||
const [scrollable, setScrollable] = createSignal(false)
|
||||
const height = createMemo(() => Math.max(3, Math.floor(dimensions().height / 2) - 5))
|
||||
const [height, setHeight] = createSignal(1)
|
||||
const maxHeight = createMemo(() => Math.max(3, Math.floor(dimensions().height / 2) - 5))
|
||||
let scroll: ScrollBoxRenderable | undefined
|
||||
let measure: (() => void) | undefined
|
||||
|
||||
onMount(() => dialog.setSize("large"))
|
||||
|
||||
createEffect(() => {
|
||||
dimensions()
|
||||
props.error
|
||||
if (measure) renderer.off(CliRenderEvents.FRAME, measure)
|
||||
measure = () => {
|
||||
measure = undefined
|
||||
setScrollable(Boolean(scroll && scroll.scrollHeight > scroll.viewport.height))
|
||||
if (!scroll) return
|
||||
const next = Math.max(1, Math.min(maxHeight(), scroll.scrollHeight))
|
||||
setHeight(next)
|
||||
setScrollable(scroll.scrollHeight > next)
|
||||
}
|
||||
renderer.once(CliRenderEvents.FRAME, measure)
|
||||
renderer.requestRender()
|
||||
@@ -61,15 +61,15 @@ export function DialogErrorDetails(props: { title: string; error: string; onBack
|
||||
if (!scrollable()) return
|
||||
if (event.name === "up") return scroll?.scrollBy(-1)
|
||||
if (event.name === "down") return scroll?.scrollBy(1)
|
||||
if (event.name === "pageup") return scroll?.scrollBy(-height())
|
||||
if (event.name === "pagedown") return scroll?.scrollBy(height())
|
||||
if (event.name === "pageup") return scroll?.scrollBy(-maxHeight())
|
||||
if (event.name === "pagedown") return scroll?.scrollBy(maxHeight())
|
||||
if (event.name === "home") return scroll?.scrollTo(0)
|
||||
if (event.name === "end" && scroll) return scroll.scrollTo(scroll.scrollHeight)
|
||||
})
|
||||
|
||||
return (
|
||||
<box paddingLeft={4} paddingRight={4} paddingBottom={1} gap={1}>
|
||||
<box flexDirection="row" justifyContent="space-between">
|
||||
<box paddingBottom={1} gap={1}>
|
||||
<box flexDirection="row" justifyContent="space-between" paddingLeft={2} paddingRight={2}>
|
||||
<text attributes={TextAttributes.BOLD} fg={theme.text.default}>
|
||||
{props.title}
|
||||
</text>
|
||||
@@ -77,7 +77,6 @@ export function DialogErrorDetails(props: { title: string; error: string; onBack
|
||||
esc
|
||||
</text>
|
||||
</box>
|
||||
<text fg={theme.text.feedback.error.default}>✗ Failed</text>
|
||||
<box
|
||||
backgroundColor={overlayTheme.background.default}
|
||||
paddingLeft={2}
|
||||
@@ -96,7 +95,7 @@ export function DialogErrorDetails(props: { title: string; error: string; onBack
|
||||
</text>
|
||||
</scrollbox>
|
||||
</box>
|
||||
<box flexDirection="row" justifyContent="space-between">
|
||||
<box flexDirection="row" justifyContent="space-between" paddingLeft={2} paddingRight={2}>
|
||||
<text>
|
||||
<span style={{ fg: theme.text.default }}>
|
||||
<b>{scrollable() ? "↑/↓" : ""}</b>
|
||||
|
||||
@@ -11,7 +11,7 @@ import type {
|
||||
FormValue,
|
||||
} from "@opencode-ai/client"
|
||||
import open from "open"
|
||||
import { createMemo, createSignal, onCleanup, onMount, Show } from "solid-js"
|
||||
import { createEffect, createMemo, createSignal, onCleanup, onMount, Show } from "solid-js"
|
||||
import { useClipboard } from "../context/clipboard"
|
||||
import { useData } from "../context/data"
|
||||
import { useClient } from "../context/client"
|
||||
@@ -70,18 +70,33 @@ export function connectionSummary(integration: IntegrationInfo) {
|
||||
}
|
||||
|
||||
export function DialogIntegration(
|
||||
props: { onConnected?: OnIntegrationConnected; integrationID?: string; connectionOnly?: boolean } = {},
|
||||
props: { onConnected?: OnIntegrationConnected; integrationID?: string; autoConnect?: boolean } = {},
|
||||
) {
|
||||
const data = useData()
|
||||
const dialog = useDialog()
|
||||
const theme = useTheme("elevated")
|
||||
const integrations = createMemo(() =>
|
||||
integrationOptions(data.location.integration.list() ?? []).filter(
|
||||
(integration) => props.integrationID === undefined || integration.id === props.integrationID,
|
||||
),
|
||||
)
|
||||
|
||||
createEffect(() => {
|
||||
if (!props.autoConnect) return
|
||||
const integration = integrations()[0]
|
||||
if (!integration) return
|
||||
const methods = connectMethods(integration)
|
||||
if (credentialConnections(integration).length) {
|
||||
manageConnections(integration, methods, dialog, props.onConnected)
|
||||
return
|
||||
}
|
||||
selectMethod(integration, methods, dialog, props.onConnected)
|
||||
})
|
||||
|
||||
const options = createMemo(() => {
|
||||
const providers = data.location.websearch.list() ?? []
|
||||
const providersByID = new Map(providers.map((provider) => [provider.id, provider]))
|
||||
const integrations = integrationOptions(data.location.integration.list() ?? []).filter(
|
||||
(integration) => props.integrationID === undefined || integration.id === props.integrationID,
|
||||
)
|
||||
return integrations.map((integration) => {
|
||||
return integrations().map((integration) => {
|
||||
const methods = connectMethods(integration)
|
||||
const provider = providersByID.get(integration.id)
|
||||
const credentials = credentialConnections(integration)
|
||||
|
||||
@@ -10,6 +10,7 @@ import { TextAttributes } from "@opentui/core"
|
||||
import type { McpServer } from "@opencode-ai/client"
|
||||
import { useToast } from "../ui/toast"
|
||||
import { DialogErrorDetails } from "./dialog-error-details"
|
||||
import { DialogIntegration } from "./dialog-integration"
|
||||
|
||||
function statusError(status: McpServer["status"]) {
|
||||
if (status.status === "failed") return status.error
|
||||
@@ -90,18 +91,27 @@ export function DialogMcp() {
|
||||
return server ? statusError(server.status) : undefined
|
||||
})
|
||||
|
||||
const open = (name: string | undefined) => {
|
||||
const select = (name: string | undefined) => {
|
||||
const server = servers().find((entry) => entry.name === name)
|
||||
if (!server || !statusError(server.status)) return
|
||||
if (!server) return
|
||||
if (server.status.status === "needs_auth" && server.integrationID) {
|
||||
dialog.replace(() => <DialogIntegration integrationID={server.integrationID} autoConnect />)
|
||||
return
|
||||
}
|
||||
if (!statusError(server.status)) return
|
||||
setDetail(server)
|
||||
}
|
||||
|
||||
// Connected servers disconnect; everything else (disabled, failed, needs_auth) retries a
|
||||
// connection. The mcp.status.changed event refreshes the list, so no manual sync is needed.
|
||||
// Auth-gated servers enter the integration flow; other inactive states retry the connection.
|
||||
// The mcp.status.changed event refreshes the list, so no manual sync is needed.
|
||||
const toggle = (name: string) => {
|
||||
if (loading() !== null) return
|
||||
const server = servers().find((entry) => entry.name === name)
|
||||
if (!server || server.status.status === "pending") return
|
||||
if (server.status.status === "needs_auth" && server.integrationID) {
|
||||
select(name)
|
||||
return
|
||||
}
|
||||
setLoading(name)
|
||||
const current = data.location.default()
|
||||
const input = { server: name, location: { directory: current.directory, workspace: current.workspaceID } }
|
||||
@@ -119,7 +129,7 @@ export function DialogMcp() {
|
||||
options={options()}
|
||||
preserveSelection
|
||||
onMove={(option) => setFocused(option.value as string)}
|
||||
onSelect={(option) => open(option.value as string)}
|
||||
onSelect={(option) => select(option.value as string)}
|
||||
actions={[
|
||||
{
|
||||
title: toggleTitle(),
|
||||
|
||||
@@ -23,6 +23,7 @@ import { useStorage } from "../context/storage"
|
||||
import { useConfig } from "../config"
|
||||
import { withTimestampedFallback } from "@opencode-ai/util/session-title-fallback"
|
||||
import { projectName } from "../util/project"
|
||||
import { useLocation } from "../context/location"
|
||||
|
||||
export function DialogSessionList() {
|
||||
const dialog = useDialog()
|
||||
@@ -36,6 +37,7 @@ export function DialogSessionList() {
|
||||
const sessionTabs = useSessionTabs()
|
||||
const config = useConfig().data
|
||||
const toast = useToast()
|
||||
const activeLocation = useLocation()
|
||||
const [filter, setFilter] = createSignal("")
|
||||
const shortcuts = Keymap.useShortcuts()
|
||||
const [search, setSearch] = createDebouncedSignal("", 150)
|
||||
@@ -44,13 +46,21 @@ export function DialogSessionList() {
|
||||
initial: { allProjects: config.tabs?.scope !== "cwd" },
|
||||
})
|
||||
const allProjects = () => prefs.allProjects
|
||||
const pickerLocation = () =>
|
||||
(route.data.type === "session" ? data.session.get(route.data.sessionID)?.location : undefined) ??
|
||||
activeLocation.ref ??
|
||||
data.location.default()
|
||||
|
||||
const [searchResults, { mutate: setSearchResults }] = createResource(
|
||||
() => ({ query: search().trim(), allProjects: allProjects() }),
|
||||
async ({ query, allProjects }) => {
|
||||
() => ({
|
||||
query: search().trim(),
|
||||
allProjects: allProjects(),
|
||||
location: pickerLocation(),
|
||||
}),
|
||||
async ({ query, allProjects, location }) => {
|
||||
try {
|
||||
if (!data.location.info()) await data.location.sync()
|
||||
const current = data.location.info()
|
||||
if (!data.location.info(location)) await data.location.sync(location)
|
||||
const current = data.location.info(location)
|
||||
if (!current) throw new Error("Location unavailable")
|
||||
const response = await client.api.session.list({
|
||||
...(allProjects
|
||||
@@ -78,7 +88,7 @@ export function DialogSessionList() {
|
||||
const currentSessionID = createMemo(() => (route.data.type === "session" ? route.data.sessionID : undefined))
|
||||
const localSessions = createMemo(() => {
|
||||
const query = filter().trim().toLowerCase()
|
||||
const current = data.location.info()
|
||||
const current = data.location.info(pickerLocation())
|
||||
const sessions = data.session
|
||||
.list()
|
||||
.filter(
|
||||
@@ -125,7 +135,7 @@ export function DialogSessionList() {
|
||||
return hint && local.session.slots().length > 0 ? [{ title: "switch", label: hint }] : []
|
||||
})
|
||||
const currentProjectName = createMemo(() => {
|
||||
const current = data.location.info()
|
||||
const current = data.location.info(pickerLocation())
|
||||
if (!current) return ""
|
||||
const project = data.project.get(current.project.id)
|
||||
return projectName(project) ?? ""
|
||||
|
||||
@@ -1,68 +1,114 @@
|
||||
import type { PluginInfo } from "@opencode-ai/client"
|
||||
import { Plugin } from "@opencode-ai/plugin/tui"
|
||||
import { createEffect, createMemo, createSignal, Show } from "solid-js"
|
||||
import { createEffect, createMemo, createResource, createSignal, onMount, Show } from "solid-js"
|
||||
import { DialogErrorDetails } from "../../component/dialog-error-details"
|
||||
import { usePlugin } from "../../plugin/context"
|
||||
import { DialogSelect, type DialogSelectOption } from "../../ui/dialog-select"
|
||||
import { useDialog } from "../../ui/dialog"
|
||||
import { DialogErrorDetails } from "../../component/dialog-error-details"
|
||||
|
||||
const id = "opencode.plugins"
|
||||
|
||||
function View(props: { context: Plugin.Context; plugins: ReturnType<typeof usePlugin> }) {
|
||||
type Entry =
|
||||
| { readonly key: string; readonly runtime: "server"; readonly plugin: PluginInfo }
|
||||
| {
|
||||
readonly key: string
|
||||
readonly runtime: "tui"
|
||||
readonly id?: string
|
||||
readonly target: string
|
||||
readonly status: "active" | "inactive" | "failed"
|
||||
readonly error?: string
|
||||
}
|
||||
|
||||
export function PluginsDialog(props: {
|
||||
context: Plugin.Context
|
||||
plugins: ReturnType<typeof usePlugin>
|
||||
server?: () => readonly PluginInfo[]
|
||||
}) {
|
||||
const dialog = useDialog()
|
||||
const [locked, setLocked] = createSignal(false)
|
||||
const [focused, setFocused] = createSignal<string>()
|
||||
const [detail, setDetail] = createSignal<{ title: string; error: string }>()
|
||||
const dialog = useDialog()
|
||||
const options = createMemo(() => {
|
||||
const builtins = props.plugins
|
||||
const [detail, setDetail] = createSignal<Entry>()
|
||||
const [initial, setInitial] = createSignal<string>()
|
||||
const [server] = createResource(
|
||||
() => (props.server ? undefined : (props.context.location ?? props.context.data.location.default())),
|
||||
(location) => props.context.client.plugin.list({ location }).then((result) => result.data),
|
||||
)
|
||||
onMount(() => dialog.setSize("medium"))
|
||||
const entries = createMemo<Entry[]>(() => {
|
||||
const builtins: Entry[] = props.plugins
|
||||
.registered()
|
||||
.filter((plugin) => plugin.id !== id && plugin.source === "builtin")
|
||||
.map(
|
||||
(plugin): DialogSelectOption<string> => ({
|
||||
title: plugin.id,
|
||||
value: plugin.id,
|
||||
category: "Built-in",
|
||||
footer: plugin.active ? "active" : "inactive",
|
||||
footerColor: plugin.active
|
||||
? props.context.theme.text.feedback.success.default
|
||||
: props.context.theme.text.subdued,
|
||||
}),
|
||||
)
|
||||
const external = props.plugins
|
||||
.map((plugin) => ({
|
||||
key: `tui:${plugin.id}`,
|
||||
runtime: "tui" as const,
|
||||
id: plugin.id,
|
||||
target: plugin.id,
|
||||
status: plugin.active ? ("active" as const) : ("inactive" as const),
|
||||
}))
|
||||
const external: Entry[] = props.plugins
|
||||
.list()
|
||||
.filter((plugin) => plugin.status !== "unsupported")
|
||||
.map(
|
||||
(plugin): DialogSelectOption<string> => ({
|
||||
title: plugin.id ?? plugin.target,
|
||||
value: plugin.id ?? plugin.target,
|
||||
category: "External",
|
||||
searchText: plugin.target,
|
||||
footer: plugin.status,
|
||||
footerColor:
|
||||
plugin.status === "active"
|
||||
? props.context.theme.text.feedback.success.default
|
||||
: plugin.status === "failed"
|
||||
? props.context.theme.text.feedback.error.default
|
||||
: props.context.theme.text.subdued,
|
||||
}),
|
||||
)
|
||||
return [...builtins, ...external].sort((a, b) => a.title.localeCompare(b.title))
|
||||
.map((plugin) => ({
|
||||
key: `tui:${plugin.id ?? plugin.target}`,
|
||||
runtime: "tui" as const,
|
||||
id: plugin.id,
|
||||
target: plugin.target,
|
||||
status: plugin.status,
|
||||
error: plugin.status === "failed" ? plugin.error : undefined,
|
||||
}))
|
||||
const serverEntries: Entry[] = (props.server?.() ?? server() ?? []).map((plugin) => ({
|
||||
key: `server:${plugin.id ?? source(plugin, props.context)}`,
|
||||
runtime: "server" as const,
|
||||
plugin,
|
||||
}))
|
||||
return [
|
||||
...[...builtins, ...external].sort((a, b) => label(a, props.context).localeCompare(label(b, props.context))),
|
||||
...serverEntries.sort((a, b) => label(a, props.context).localeCompare(label(b, props.context))),
|
||||
]
|
||||
})
|
||||
|
||||
const failure = (value: string | undefined) =>
|
||||
props.plugins.list().find((plugin) => {
|
||||
if (plugin.status !== "failed") return false
|
||||
return (plugin.id ?? plugin.target) === value
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
if (focused()) return
|
||||
const first = options()[0]
|
||||
if (first) setFocused(first.value)
|
||||
if (initial()) return
|
||||
const first = entries().find((entry) => entry.runtime === "tui")
|
||||
if (!first) return
|
||||
setInitial(first.key)
|
||||
setFocused(first.key)
|
||||
})
|
||||
|
||||
const toggle = (plugin: DialogSelectOption<string>) => {
|
||||
if (locked()) return
|
||||
const current = props.plugins.registered().find((item) => item.id === plugin.value)
|
||||
const options = createMemo(() =>
|
||||
entries().map(
|
||||
(entry): DialogSelectOption<string> => ({
|
||||
title: label(entry, props.context),
|
||||
value: entry.key,
|
||||
category: entry.runtime === "tui" ? "TUI" : "Server",
|
||||
searchText: entry.runtime === "tui" ? entry.target : source(entry.plugin, props.context),
|
||||
footer: status(entry) === "active" ? undefined : status(entry),
|
||||
footerColor:
|
||||
status(entry) === "failed"
|
||||
? props.context.theme.text.feedback.error.default
|
||||
: props.context.theme.text.subdued,
|
||||
gutter:
|
||||
status(entry) === "active"
|
||||
? () => <text fg={props.context.theme.text.feedback.success.default}>✓</text>
|
||||
: status(entry) === "failed"
|
||||
? () => <text fg={props.context.theme.text.feedback.error.default}>✗</text>
|
||||
: undefined,
|
||||
}),
|
||||
),
|
||||
)
|
||||
const focusedEntry = createMemo(() => entries().find((entry) => entry.key === focused()))
|
||||
const focusedTui = createMemo(() => {
|
||||
const entry = focusedEntry()
|
||||
if (entry?.runtime !== "tui" || !entry.id) return
|
||||
return entry
|
||||
})
|
||||
const toggleTitle = createMemo(() => {
|
||||
const entry = focusedTui()
|
||||
if (!entry) return "toggle"
|
||||
return props.plugins.registered().find((plugin) => plugin.id === entry.id)?.active ? "disable" : "enable"
|
||||
})
|
||||
const toggle = (entry: Entry | undefined) => {
|
||||
if (locked() || entry?.runtime !== "tui" || !entry.id) return
|
||||
const current = props.plugins.registered().find((plugin) => plugin.id === entry.id)
|
||||
if (!current) return
|
||||
setLocked(true)
|
||||
void (current.active ? props.plugins.deactivate(current.id) : props.plugins.activate(current.id))
|
||||
@@ -70,21 +116,15 @@ function View(props: { context: Plugin.Context; plugins: ReturnType<typeof usePl
|
||||
if (ok) return
|
||||
props.context.ui.toast.show({ variant: "error", message: `Failed to update plugin ${current.id}` })
|
||||
})
|
||||
.catch((error) => {
|
||||
.catch((cause) => {
|
||||
props.context.ui.toast.show({
|
||||
variant: "error",
|
||||
message: error instanceof Error ? error.message : String(error),
|
||||
message: cause instanceof Error ? cause.message : String(cause),
|
||||
})
|
||||
})
|
||||
.finally(() => setLocked(false))
|
||||
}
|
||||
|
||||
const select = (plugin: DialogSelectOption<string>) => {
|
||||
const failed = failure(plugin.value)
|
||||
if (!failed || failed.status !== "failed") return toggle(plugin)
|
||||
setDetail({ title: failed.target, error: failed.error })
|
||||
}
|
||||
|
||||
return (
|
||||
<box>
|
||||
<Show
|
||||
@@ -93,33 +133,42 @@ function View(props: { context: Plugin.Context; plugins: ReturnType<typeof usePl
|
||||
<DialogSelect
|
||||
title="Plugins"
|
||||
options={options()}
|
||||
current={initial()}
|
||||
locked={locked()}
|
||||
preserveSelection={true}
|
||||
onMove={(option) => setFocused(option.value)}
|
||||
actions={[
|
||||
{
|
||||
title: "toggle",
|
||||
command: "plugins.toggle",
|
||||
disabled: (option) => {
|
||||
const failed = failure(option?.value)
|
||||
return Boolean(failed && !("id" in failed && failed.id))
|
||||
},
|
||||
onTrigger: toggle,
|
||||
},
|
||||
]}
|
||||
onSelect={select}
|
||||
onSelect={(option) => {
|
||||
const entry = entries().find((entry) => entry.key === option.value)
|
||||
if (pluginError(entry)) setDetail(entry)
|
||||
}}
|
||||
actions={
|
||||
focusedTui()
|
||||
? [
|
||||
{
|
||||
title: toggleTitle(),
|
||||
command: "plugins.toggle",
|
||||
onTrigger: (option) => toggle(entries().find((entry) => entry.key === option.value)),
|
||||
},
|
||||
]
|
||||
: []
|
||||
}
|
||||
footer={
|
||||
<Show when={failure(focused())}>
|
||||
<text fg={props.context.theme.text.subdued}>enter to view error</text>
|
||||
<Show when={pluginError(focusedEntry())}>
|
||||
<text>
|
||||
<span style={{ fg: props.context.theme.text.default }}>
|
||||
<b>enter</b>
|
||||
</span>
|
||||
<span style={{ fg: props.context.theme.text.subdued }}> view error</span>
|
||||
</text>
|
||||
</Show>
|
||||
}
|
||||
/>
|
||||
}
|
||||
>
|
||||
{(item) => (
|
||||
{(entry) => (
|
||||
<DialogErrorDetails
|
||||
title={`Plugin: ${item().title}`}
|
||||
error={item().error}
|
||||
title={`${entry().runtime === "tui" ? "TUI" : "Server"} plugin: ${label(entry(), props.context)}`}
|
||||
error={pluginError(entry()) ?? "Unknown plugin error"}
|
||||
onBack={() => {
|
||||
setDetail()
|
||||
dialog.setSize("medium")
|
||||
@@ -131,6 +180,27 @@ function View(props: { context: Plugin.Context; plugins: ReturnType<typeof usePl
|
||||
)
|
||||
}
|
||||
|
||||
function label(entry: Entry, context: Plugin.Context) {
|
||||
if (entry.runtime === "tui") return entry.id ?? entry.target
|
||||
return entry.plugin.id ?? source(entry.plugin, context)
|
||||
}
|
||||
|
||||
function source(plugin: PluginInfo, context: Plugin.Context) {
|
||||
if (plugin.source.type === "package") return plugin.source.package
|
||||
if (plugin.source.type === "local") return context.ui.format.path(plugin.source.path)
|
||||
return plugin.source.type
|
||||
}
|
||||
|
||||
function status(entry: Entry) {
|
||||
if (entry.runtime === "server") return entry.plugin.status
|
||||
return entry.status
|
||||
}
|
||||
|
||||
function pluginError(entry: Entry | undefined) {
|
||||
if (entry?.runtime === "server") return entry.plugin.status === "failed" ? entry.plugin.error : undefined
|
||||
return entry?.error
|
||||
}
|
||||
|
||||
function Commands(props: { context: Plugin.Context }) {
|
||||
const plugins = usePlugin()
|
||||
props.context.keymap.layer(() => ({
|
||||
@@ -143,7 +213,7 @@ function Commands(props: { context: Plugin.Context }) {
|
||||
slash: { name: "plugins" },
|
||||
palette: true,
|
||||
run() {
|
||||
props.context.ui.dialog.show(() => <View context={props.context} plugins={plugins} />)
|
||||
props.context.ui.dialog.show(() => <PluginsDialog context={props.context} plugins={plugins} />)
|
||||
},
|
||||
},
|
||||
],
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
/** @jsxImportSource @opentui/solid */
|
||||
import { testRender } from "@opentui/solid"
|
||||
import { expect, test } from "bun:test"
|
||||
import { onMount } from "solid-js"
|
||||
import { DialogMcp } from "../../../src/component/dialog-mcp"
|
||||
import { ConfigProvider } from "../../../src/config"
|
||||
import { ClientProvider } from "../../../src/context/client"
|
||||
import { DataProvider, useData } from "../../../src/context/data"
|
||||
import { Keymap } from "../../../src/context/keymap"
|
||||
import { ThemeProvider } from "../../../src/context/theme"
|
||||
import { DialogProvider, useDialog } from "../../../src/ui/dialog"
|
||||
import { ToastProvider } from "../../../src/ui/toast"
|
||||
import { createApi, createEventStream, createFetch, json } from "../../fixture/tui-client"
|
||||
import { emptyThemeSource } from "../../fixture/fixture"
|
||||
import { TestTuiContexts } from "../../fixture/tui-environment"
|
||||
import { createTuiResolvedConfig } from "../../fixture/tui-runtime"
|
||||
|
||||
test.each(["enter", "space"])("starts OAuth with %s for an MCP server requiring authentication", async (key) => {
|
||||
const fixture = await renderMcp()
|
||||
|
||||
try {
|
||||
await fixture.app.waitForFrame((frame) => frame.includes("Sign in required"))
|
||||
if (key === "enter") fixture.app.mockInput.pressEnter()
|
||||
else fixture.app.mockInput.pressKey(" ")
|
||||
await fixture.app.waitForFrame((frame) => frame.includes("Waiting for authorization"))
|
||||
|
||||
expect(fixture.oauth).toBe(1)
|
||||
expect(fixture.connect).toBe(0)
|
||||
} finally {
|
||||
fixture.app.renderer.destroy()
|
||||
}
|
||||
})
|
||||
|
||||
async function renderMcp() {
|
||||
const events = createEventStream()
|
||||
let oauth = 0
|
||||
let connect = 0
|
||||
const calls = createFetch((url, request) => {
|
||||
const location = {
|
||||
directory: process.cwd(),
|
||||
project: { id: "proj_test", directory: process.cwd(), canonical: process.cwd() },
|
||||
}
|
||||
if (url.pathname === "/api/mcp")
|
||||
return json({
|
||||
location,
|
||||
data: [{ name: "linear", status: { status: "needs_auth" }, integrationID: "mcp_linear" }],
|
||||
})
|
||||
if (url.pathname === "/api/integration")
|
||||
return json({
|
||||
location,
|
||||
data: [
|
||||
{
|
||||
id: "mcp_linear",
|
||||
name: "linear",
|
||||
methods: [{ type: "oauth", id: "mcp_linear", label: "linear" }],
|
||||
connections: [],
|
||||
},
|
||||
],
|
||||
})
|
||||
if (url.pathname === "/api/integration/mcp_linear/connect/oauth" && request.method === "POST") {
|
||||
oauth++
|
||||
return json({
|
||||
location,
|
||||
data: {
|
||||
attemptID: "attempt_linear",
|
||||
mode: "auto",
|
||||
url: "https://linear.example.com/oauth",
|
||||
instructions: "Authorize linear in your browser.",
|
||||
},
|
||||
})
|
||||
}
|
||||
if (url.pathname === "/api/integration/mcp_linear/connect/oauth/attempt_linear") {
|
||||
if (request.method === "DELETE") return new Response(null, { status: 204 })
|
||||
return json({ location, data: { status: "pending" } })
|
||||
}
|
||||
if (url.pathname === "/api/mcp/linear/connect" && request.method === "POST") {
|
||||
connect++
|
||||
return new Response(null, { status: 204 })
|
||||
}
|
||||
return undefined
|
||||
}, events)
|
||||
|
||||
function Probe() {
|
||||
const data = useData()
|
||||
const dialog = useDialog()
|
||||
onMount(() => {
|
||||
void Promise.all([data.location.mcp.server.sync(), data.location.integration.sync()]).then(() =>
|
||||
dialog.replace(() => <DialogMcp />),
|
||||
)
|
||||
})
|
||||
return null
|
||||
}
|
||||
|
||||
const app = await testRender(
|
||||
() => (
|
||||
<TestTuiContexts>
|
||||
<ConfigProvider config={createTuiResolvedConfig()}>
|
||||
<Keymap.Provider>
|
||||
<ToastProvider>
|
||||
<ClientProvider api={createApi(calls.fetch)}>
|
||||
<DataProvider>
|
||||
<ThemeProvider mode="dark" source={emptyThemeSource}>
|
||||
<DialogProvider>
|
||||
<Probe />
|
||||
</DialogProvider>
|
||||
</ThemeProvider>
|
||||
</DataProvider>
|
||||
</ClientProvider>
|
||||
</ToastProvider>
|
||||
</Keymap.Provider>
|
||||
</ConfigProvider>
|
||||
</TestTuiContexts>
|
||||
),
|
||||
{ width: 100, height: 30, kittyKeyboard: true },
|
||||
)
|
||||
app.renderer.start()
|
||||
return {
|
||||
app,
|
||||
get oauth() {
|
||||
return oauth
|
||||
},
|
||||
get connect() {
|
||||
return connect
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
/** @jsxImportSource @opentui/solid */
|
||||
import { expect, test } from "bun:test"
|
||||
import { testRender } from "@opentui/solid"
|
||||
import { onMount } from "solid-js"
|
||||
import { DialogSessionList } from "../../../src/component/dialog-session-list"
|
||||
import { ConfigProvider } from "../../../src/config"
|
||||
import { ArgsProvider } from "../../../src/context/args"
|
||||
import { ClientProvider } from "../../../src/context/client"
|
||||
import { DataProvider, useData } from "../../../src/context/data"
|
||||
import { Keymap } from "../../../src/context/keymap"
|
||||
import { LocalProvider } from "../../../src/context/local"
|
||||
import { LocationProvider } from "../../../src/context/location"
|
||||
import { PermissionProvider } from "../../../src/context/permission"
|
||||
import { RouteProvider, useRoute } from "../../../src/context/route"
|
||||
import { TuiAppProvider } from "../../../src/context/runtime"
|
||||
import { SessionTabsProvider } from "../../../src/context/session-tabs"
|
||||
import { StorageProvider, useStorage } from "../../../src/context/storage"
|
||||
import { ThemeProvider } from "../../../src/context/theme"
|
||||
import { DialogProvider, useDialog } from "../../../src/ui/dialog"
|
||||
import { ToastProvider } from "../../../src/ui/toast"
|
||||
import { createApi, createEventStream, createFetch, json } from "../../fixture/tui-client"
|
||||
import { emptyThemeSource, tmpdir } from "../../fixture/fixture"
|
||||
import { TestTuiContexts } from "../../fixture/tui-environment"
|
||||
import { createTuiResolvedConfig } from "../../fixture/tui-runtime"
|
||||
|
||||
test("scopes sessions to the active session location", async () => {
|
||||
const active = "/tmp/opencode/project-b"
|
||||
const events = createEventStream()
|
||||
const requestedProjects: string[] = []
|
||||
const calls = createFetch((url) => {
|
||||
if (url.pathname === "/api/location") {
|
||||
const directory = url.searchParams.get("location[directory]") ?? process.cwd()
|
||||
const project = directory === active ? "proj_b" : "proj_a"
|
||||
return json({ directory, project: { id: project, directory, canonical: directory } })
|
||||
}
|
||||
if (url.pathname !== "/api/session") return undefined
|
||||
const project = url.searchParams.get("project") ?? ""
|
||||
requestedProjects.push(project)
|
||||
return json({
|
||||
data: [
|
||||
{
|
||||
id: project === "proj_b" ? "ses_b" : "ses_a",
|
||||
projectID: project,
|
||||
cost: 0,
|
||||
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
|
||||
time: { created: 1, updated: 2 },
|
||||
title: project === "proj_b" ? "Project B session" : "Project A session",
|
||||
location: { directory: project === "proj_b" ? active : process.cwd() },
|
||||
},
|
||||
],
|
||||
cursor: {},
|
||||
})
|
||||
}, events)
|
||||
const temporary = await tmpdir()
|
||||
let storage!: ReturnType<typeof useStorage>
|
||||
|
||||
function Probe() {
|
||||
const data = useData()
|
||||
const dialog = useDialog()
|
||||
const route = useRoute()
|
||||
storage = useStorage()
|
||||
onMount(() => {
|
||||
data.session.remember({
|
||||
id: "ses_active",
|
||||
projectID: "proj_b",
|
||||
cost: 0,
|
||||
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
|
||||
time: { created: 1, updated: 3 },
|
||||
title: "Active session",
|
||||
location: { directory: active },
|
||||
})
|
||||
route.navigate({ type: "session", sessionID: "ses_active" })
|
||||
dialog.replace(() => <DialogSessionList />)
|
||||
})
|
||||
return null
|
||||
}
|
||||
|
||||
const app = await testRender(
|
||||
() => (
|
||||
<TestTuiContexts paths={{ state: temporary.path }}>
|
||||
<TuiAppProvider value={{ name: "test", version: "test", channel: "test" }}>
|
||||
<StorageProvider>
|
||||
<ArgsProvider>
|
||||
<ConfigProvider config={createTuiResolvedConfig()}>
|
||||
<Keymap.Provider>
|
||||
<ToastProvider>
|
||||
<RouteProvider>
|
||||
<ClientProvider api={createApi(calls.fetch)}>
|
||||
<PermissionProvider>
|
||||
<DataProvider>
|
||||
<LocationProvider>
|
||||
<SessionTabsProvider>
|
||||
<ThemeProvider mode="dark" source={emptyThemeSource}>
|
||||
<LocalProvider>
|
||||
<DialogProvider>
|
||||
<Probe />
|
||||
</DialogProvider>
|
||||
</LocalProvider>
|
||||
</ThemeProvider>
|
||||
</SessionTabsProvider>
|
||||
</LocationProvider>
|
||||
</DataProvider>
|
||||
</PermissionProvider>
|
||||
</ClientProvider>
|
||||
</RouteProvider>
|
||||
</ToastProvider>
|
||||
</Keymap.Provider>
|
||||
</ConfigProvider>
|
||||
</ArgsProvider>
|
||||
</StorageProvider>
|
||||
</TuiAppProvider>
|
||||
</TestTuiContexts>
|
||||
),
|
||||
{ width: 100, height: 30, kittyKeyboard: true },
|
||||
)
|
||||
app.renderer.start()
|
||||
|
||||
try {
|
||||
const frame = await app.waitForFrame((value) => value.includes("Project B session"))
|
||||
expect(frame).not.toContain("Project A session")
|
||||
expect(requestedProjects.at(-1)).toBe("proj_b")
|
||||
} finally {
|
||||
app.renderer.destroy()
|
||||
await storage.flush()
|
||||
await temporary[Symbol.asyncDispose]()
|
||||
}
|
||||
})
|
||||
@@ -48,58 +48,6 @@
|
||||
"summary": "Check server health"
|
||||
}
|
||||
},
|
||||
"/api/service/stop": {
|
||||
"post": {
|
||||
"tags": ["health"],
|
||||
"operationId": "v2.health.stop",
|
||||
"parameters": [],
|
||||
"security": [],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "ServiceStopResponse",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ServiceStopResponse"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "InvalidRequestError",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/InvalidRequestErrorEncoded"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"401": {
|
||||
"description": "UnauthorizedError",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/UnauthorizedErrorEncoded"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Request graceful shutdown of one exact managed server instance.",
|
||||
"summary": "Stop the managed server",
|
||||
"requestBody": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ServiceStopRequest"
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/server": {
|
||||
"get": {
|
||||
"tags": ["server"],
|
||||
@@ -9783,26 +9731,6 @@
|
||||
"required": ["_tag", "message"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"ServiceStopRequest": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"instanceID": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": ["instanceID"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"ServiceStopResponse": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"accepted": {
|
||||
"type": "boolean"
|
||||
}
|
||||
},
|
||||
"required": ["accepted"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"Union_1": {
|
||||
"anyOf": [
|
||||
{
|
||||
|
||||
@@ -48,58 +48,6 @@
|
||||
"summary": "Check server health"
|
||||
}
|
||||
},
|
||||
"/api/service/stop": {
|
||||
"post": {
|
||||
"tags": ["health"],
|
||||
"operationId": "v2.health.stop",
|
||||
"parameters": [],
|
||||
"security": [],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "ServiceStopResponse",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ServiceStopResponse"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "InvalidRequestError",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/InvalidRequestErrorEncoded"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"401": {
|
||||
"description": "UnauthorizedError",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/UnauthorizedErrorEncoded"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Request graceful shutdown of one exact managed server instance.",
|
||||
"summary": "Stop the managed server",
|
||||
"requestBody": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ServiceStopRequest"
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/server": {
|
||||
"get": {
|
||||
"tags": ["server"],
|
||||
@@ -9783,26 +9731,6 @@
|
||||
"required": ["_tag", "message"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"ServiceStopRequest": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"instanceID": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": ["instanceID"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"ServiceStopResponse": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"accepted": {
|
||||
"type": "boolean"
|
||||
}
|
||||
},
|
||||
"required": ["accepted"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"Union_1": {
|
||||
"anyOf": [
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user