mirror of
https://github.com/anomalyco/opencode.git
synced 2026-11-15 04:27:20 +03:00
fix(core): keep last known remote config when refresh fails (#53667)
Fixes #53666 Co-authored-by: Ayush Thakur <51413362+Ayushlm10@users.noreply.github.com>
This commit is contained in:
committed by
GitHub
Unverified
parent
a80ca68c96
commit
f108fa4928
+11
-11
@@ -144,19 +144,19 @@ export const layer = (options?: Options) =>
|
||||
|
||||
const loadWellknownEntry = Effect.fnUntraced(function* (entry: WellKnown.Entry) {
|
||||
const auth = entry.manifest.auth
|
||||
if (!auth) return []
|
||||
const credential = (yield* credentials.list(entry.integrationID)).at(-1)
|
||||
if (!credential || credential.value.type !== "key") return []
|
||||
const variables = { [auth.env]: credential.value.key }
|
||||
const configs = yield* wellknown
|
||||
.resolve(entry, variables)
|
||||
.pipe(
|
||||
Effect.catch(() =>
|
||||
Effect.logWarning("failed to load wellknown config", { source: entry.origin }).pipe(
|
||||
Effect.as([] as const),
|
||||
),
|
||||
const variables = auth && credential?.value.type === "key" ? { [auth.env]: credential.value.key } : undefined
|
||||
// A failed refresh, including a missing credential, keeps the last resolved config so remote providers
|
||||
// and allowlists do not disappear; only removing the source clears it.
|
||||
const configs = yield* (
|
||||
variables ? wellknown.resolve(entry, variables) : Effect.fail(new Error("No usable wellknown credential"))
|
||||
).pipe(
|
||||
Effect.catch((error) =>
|
||||
Effect.logWarning("failed to load wellknown config", { source: entry.origin, error }).pipe(
|
||||
Effect.andThen(wellknown.cached(entry.origin)),
|
||||
),
|
||||
)
|
||||
),
|
||||
)
|
||||
return yield* Effect.forEach(configs, (config) =>
|
||||
ConfigVariable.substitute({
|
||||
type: "virtual",
|
||||
|
||||
@@ -48,7 +48,10 @@ export interface Interface {
|
||||
readonly refresh: () => Effect.Effect<boolean, Error>
|
||||
readonly add: (origin: string) => Effect.Effect<Entry, Error>
|
||||
readonly remove: (origin: string) => Effect.Effect<void>
|
||||
/** Resolves and caches the configuration for a registered source. */
|
||||
readonly resolve: (entry: Entry, variables: Readonly<Record<string, string>>) => Effect.Effect<Config[], Error>
|
||||
/** Returns the last configuration resolved for an origin, kept until the source is removed. */
|
||||
readonly cached: (origin: string) => Effect.Effect<Config[]>
|
||||
}
|
||||
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/WellKnown") {}
|
||||
@@ -97,6 +100,9 @@ const resolveEntry = Effect.fnUntraced(function* (entry: Entry, variables: Reado
|
||||
|
||||
const sourcesKey = "wellknown:sources"
|
||||
const Sources = Schema.Array(Schema.String)
|
||||
const manifestKey = (origin: string) => `wellknown:manifest:${origin}`
|
||||
const configKey = (origin: string) => `wellknown:config:${origin}`
|
||||
const Configs = Schema.Array(Config)
|
||||
|
||||
const layer = Layer.effect(
|
||||
Service,
|
||||
@@ -107,7 +113,22 @@ const layer = Layer.effect(
|
||||
const cache = yield* Ref.make(new Map<string, Entry>())
|
||||
const lock = Semaphore.makeUnsafe(1)
|
||||
const loadEntry = Effect.fn("WellKnown.loadEntry")(function* (origin: string) {
|
||||
const manifest = yield* inspect(origin).pipe(Effect.provideService(HttpClient.HttpClient, http))
|
||||
const manifest = yield* inspect(origin).pipe(
|
||||
Effect.provideService(HttpClient.HttpClient, http),
|
||||
Effect.tap((manifest) => kv.set(manifestKey(origin), manifest)),
|
||||
// An unreachable source keeps its last manifest so its remote config still loads after a restart.
|
||||
Effect.catch((error) =>
|
||||
kv
|
||||
.get(manifestKey(origin))
|
||||
.pipe(
|
||||
Effect.flatMap((cached) =>
|
||||
Schema.is(Manifest)(cached)
|
||||
? Effect.logWarning("failed to load wellknown manifest", { origin, error }).pipe(Effect.as(cached))
|
||||
: Effect.fail(error),
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
return { origin, integrationID: Integration.ID.make(origin), manifest }
|
||||
})
|
||||
|
||||
@@ -167,6 +188,8 @@ const layer = Layer.effect(
|
||||
sourcesKey,
|
||||
origins.filter((item) => item !== origin),
|
||||
)
|
||||
yield* kv.remove(manifestKey(origin))
|
||||
yield* kv.remove(configKey(origin))
|
||||
yield* Ref.update(cache, (current) => {
|
||||
const next = new Map(current)
|
||||
next.delete(origin)
|
||||
@@ -176,9 +199,15 @@ const layer = Layer.effect(
|
||||
},
|
||||
(effect, _value) => lock.withPermit(effect),
|
||||
),
|
||||
resolve: Effect.fn("WellKnown.resolveEntry")((entry, variables) =>
|
||||
resolveEntry(entry, variables).pipe(Effect.provideService(HttpClient.HttpClient, http)),
|
||||
),
|
||||
resolve: Effect.fn("WellKnown.resolveEntry")(function* (entry, variables) {
|
||||
const configs = yield* resolveEntry(entry, variables).pipe(Effect.provideService(HttpClient.HttpClient, http))
|
||||
yield* kv.set(configKey(entry.origin), configs)
|
||||
return configs
|
||||
}),
|
||||
cached: Effect.fn("WellKnown.cached")(function* (origin) {
|
||||
const value = yield* kv.get(configKey(origin))
|
||||
return Schema.is(Configs)(value) ? [...value] : []
|
||||
}),
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -4,7 +4,7 @@ import { describe, expect, test } from "bun:test"
|
||||
import { Effect, Fiber, Layer, Logger, Schema, Stream } from "effect"
|
||||
import { FastCheck } from "effect/testing"
|
||||
import { Config } from "@opencode/core/config"
|
||||
import { Directory, Document, Event, Info } from "@opencode/schema/config"
|
||||
import { Directory, Document, type Entry, Event, Info } from "@opencode/schema/config"
|
||||
import { ConfigModel } from "@opencode/schema/config/model"
|
||||
import { ConfigProvider } from "@opencode/schema/config/provider"
|
||||
import { AppNodeBuilder } from "@opencode/core/effect/app-node-builder"
|
||||
@@ -21,6 +21,7 @@ import { Project } from "@opencode/core/project"
|
||||
import { Provider } from "@opencode/core/provider"
|
||||
import { AbsolutePath } from "@opencode/core/schema"
|
||||
import { WellKnown } from "@opencode/core/wellknown"
|
||||
import { KV } from "@opencode/core/kv"
|
||||
import { Integration } from "@opencode/schema/integration"
|
||||
import { emptyCredentialNode, emptyWellknownNode } from "../fixture/config-nodes"
|
||||
import { location } from "../fixture/location"
|
||||
@@ -512,6 +513,7 @@ describe("Config", () => {
|
||||
available
|
||||
? Effect.succeed([{ shell: variables.TOKEN }])
|
||||
: Effect.fail(new Error("expired credential")),
|
||||
cached: () => Effect.succeed([]),
|
||||
}),
|
||||
),
|
||||
deps: [],
|
||||
@@ -550,6 +552,145 @@ describe("Config", () => {
|
||||
),
|
||||
)
|
||||
|
||||
it.live("keeps the last resolved wellknown config across failures and restarts until its source is removed", () =>
|
||||
Effect.acquireUseRelease(
|
||||
Effect.promise(() => tmpdir()),
|
||||
(tmp) => {
|
||||
let manifestStatus = 200
|
||||
let configStatus = 200
|
||||
let requests = 0
|
||||
let key: string | undefined = "secret"
|
||||
const server = Bun.serve({
|
||||
port: 0,
|
||||
fetch(request) {
|
||||
const url = new URL(request.url)
|
||||
if (url.pathname === "/.well-known/opencode") {
|
||||
if (manifestStatus !== 200) return new Response("Unavailable", { status: manifestStatus })
|
||||
return Response.json({
|
||||
auth: { command: ["login"], env: "TOKEN" },
|
||||
remote_config: { url: `${url.origin}/config`, headers: { authorization: "Bearer {env:TOKEN}" } },
|
||||
})
|
||||
}
|
||||
requests++
|
||||
if (configStatus !== 200) return new Response("Unavailable", { status: configStatus })
|
||||
return Response.json({
|
||||
config: {
|
||||
enabled_providers: ["remote"],
|
||||
provider: { remote: { npm: "@ai-sdk/openai-compatible", models: { chat: {} } } },
|
||||
},
|
||||
})
|
||||
},
|
||||
})
|
||||
const origin = server.url.origin
|
||||
const integrationID = Integration.ID.make(origin)
|
||||
const credentialNode = makeGlobalNode({
|
||||
service: Credential.Service,
|
||||
layer: Layer.mock(Credential.Service)({
|
||||
list: () =>
|
||||
Effect.succeed(
|
||||
key === undefined
|
||||
? []
|
||||
: [
|
||||
new Credential.Info({
|
||||
id: Credential.ID.create(),
|
||||
integrationID,
|
||||
label: "default",
|
||||
value: Credential.Key.make({ type: "key", key }),
|
||||
}),
|
||||
],
|
||||
),
|
||||
}),
|
||||
deps: [],
|
||||
})
|
||||
const global = path.join(tmp.path, "global")
|
||||
const project = path.join(tmp.path, "project")
|
||||
const remote = (entries: readonly Entry[]) => ({
|
||||
provider: Config.latest(entries, "providers")?.remote?.package,
|
||||
policies: Config.latest(entries, "experimental")?.policies,
|
||||
})
|
||||
const loaded = {
|
||||
provider: "aisdk:@ai-sdk/openai-compatible",
|
||||
policies: [
|
||||
{ action: "provider.use", resource: "*", effect: "deny" },
|
||||
{ action: "provider.use", resource: "remote", effect: "allow" },
|
||||
],
|
||||
} as const
|
||||
const removed = { provider: undefined, policies: undefined }
|
||||
return Effect.gen(function* () {
|
||||
yield* Effect.promise(() => fs.mkdir(project, { recursive: true }))
|
||||
const kv = Layer.succeedContext(yield* Layer.build(LayerNode.compile(KV.node)))
|
||||
// Each build is a fresh process sharing one persisted KV store.
|
||||
const instance = () =>
|
||||
AppNodeBuilder.build(LayerNode.group([Config.node, Bus.node, WellKnown.node]), [
|
||||
Location.node.replace(
|
||||
Layer.succeed(
|
||||
Location.Service,
|
||||
Location.Service.of(location({ directory: AbsolutePath.make(project) })),
|
||||
),
|
||||
),
|
||||
Global.node.replace(Global.layerWith({ config: global, home: path.join(global, "home") })),
|
||||
Credential.node.replace(credentialNode),
|
||||
KV.node.replace(kv),
|
||||
Watcher.node.replace(Watcher.testLayer),
|
||||
])
|
||||
const services = Effect.gen(function* () {
|
||||
const config = yield* Config.Service
|
||||
const bus = yield* Bus.Service
|
||||
// Each reload also rewrites a local file so Config publishes an update to wait on.
|
||||
const reload = Effect.fnUntraced(function* (shell: string, trigger: Effect.Effect<unknown, Error>) {
|
||||
const updated = yield* bus
|
||||
.subscribe(Event.Updated)
|
||||
.pipe(Stream.take(1), Stream.runCollect, Effect.forkScoped({ startImmediately: true }))
|
||||
yield* Effect.promise(() => fs.writeFile(path.join(project, "opencode.json"), JSON.stringify({ shell })))
|
||||
yield* trigger
|
||||
yield* Fiber.join(updated)
|
||||
return remote(yield* config.entries())
|
||||
})
|
||||
const switched = bus.publish(
|
||||
Credential.Event.Switched,
|
||||
{ credentialID: Credential.ID.create(), integrationID },
|
||||
{ global: true },
|
||||
)
|
||||
return { config, bus, wellknown: yield* WellKnown.Service, reload, switched }
|
||||
})
|
||||
|
||||
yield* Effect.gen(function* () {
|
||||
const warm = yield* services
|
||||
expect(yield* warm.reload("initial", warm.wellknown.add(origin))).toEqual(loaded)
|
||||
|
||||
configStatus = 503
|
||||
const before = requests
|
||||
expect(yield* warm.reload("unavailable", warm.switched)).toEqual(loaded)
|
||||
expect(requests).toBeGreaterThan(before)
|
||||
|
||||
key = undefined
|
||||
expect(yield* warm.reload("logged-out", warm.switched)).toEqual(loaded)
|
||||
|
||||
manifestStatus = 503
|
||||
const updated = yield* warm.bus
|
||||
.subscribe(WellKnown.Event.Updated)
|
||||
.pipe(Stream.take(1), Stream.runCollect, Effect.forkScoped({ startImmediately: true }))
|
||||
expect(yield* warm.wellknown.refresh()).toBe(false)
|
||||
yield* Effect.sleep("50 millis")
|
||||
expect(updated.pollUnsafe()).toBeUndefined()
|
||||
}).pipe(Effect.scoped, Effect.provide(instance()))
|
||||
|
||||
key = "secret"
|
||||
yield* Effect.gen(function* () {
|
||||
const cold = yield* services
|
||||
expect(remote(yield* cold.config.entries())).toEqual(loaded)
|
||||
// The wellknown plugin loads these entries at startup and dies if they fail.
|
||||
expect(yield* cold.wellknown.entries()).toHaveLength(1)
|
||||
expect(yield* cold.reload("removed", cold.wellknown.remove(origin))).toEqual(removed)
|
||||
manifestStatus = 200
|
||||
expect(yield* cold.reload("re-added", cold.wellknown.add(origin))).toEqual(removed)
|
||||
}).pipe(Effect.scoped, Effect.provide(instance()))
|
||||
}).pipe(Effect.ensuring(Effect.promise(() => server.stop(true))))
|
||||
},
|
||||
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
|
||||
),
|
||||
)
|
||||
|
||||
it.live("logs redacted source-aware diagnostics for every config source", () => {
|
||||
const output: Array<Record<string, unknown>> = []
|
||||
const logger = Logger.map(Logger.formatStructured, (entry) => {
|
||||
@@ -613,6 +754,7 @@ describe("Config", () => {
|
||||
remove: () => Effect.die("unused Wellknown.remove"),
|
||||
// Exercise the loader boundary against a malformed implementation response.
|
||||
resolve: () => Effect.succeed([null as unknown as WellKnown.Config]),
|
||||
cached: () => Effect.succeed([]),
|
||||
}),
|
||||
),
|
||||
deps: [],
|
||||
|
||||
@@ -31,6 +31,7 @@ export const emptyWellknownNode = makeGlobalNode({
|
||||
add: () => Effect.die("unused Wellknown.add"),
|
||||
remove: () => Effect.die("unused Wellknown.remove"),
|
||||
resolve: () => Effect.die("unused Wellknown.resolve"),
|
||||
cached: () => Effect.succeed([]),
|
||||
}),
|
||||
),
|
||||
deps: [],
|
||||
|
||||
Reference in New Issue
Block a user