Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
195 changes: 128 additions & 67 deletions packages/core/src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { isDeepStrictEqual } from "node:util"
import { applyEdits, modify, type ParseError, parse } from "jsonc-parser"
import { Context, Effect, FiberMap, Layer, Option, PubSub, Ref, Schema, Semaphore, Stream } from "effect"
import { Directory, Document, Info, type Patch, type Entry, Event } from "@opencode/schema/config"
import { Integration } from "@opencode/schema/integration"
import { Credential } from "./credential.js"
import { Bus } from "./bus.js"
import { Watcher } from "./filesystem/watcher.js"
Expand All @@ -24,7 +25,14 @@ export function latest<K extends keyof Info>(entries: readonly Entry[], key: K):
?.info[key]
}

export interface Snapshot {
readonly entries: Entry[]
readonly blocked: boolean
}

export interface Interface {
/** Internal atomic read used by synchronous config transforms. */
readonly current: () => Snapshot
/** Returns location config documents and discovery sources from lowest to highest priority. */
readonly entries: () => Effect.Effect<Entry[]>
/** Compatibility roots consumed by internal compatibility plugins. */
Expand Down Expand Up @@ -70,16 +78,18 @@ export const testLayer = (
claude: [],
agents: [],
},
blocked = false,
) =>
Layer.effectContext(
Effect.gen(function* () {
const entries = yield* Ref.make(initial)
const snapshot = yield* Ref.make<Snapshot>({ entries: initial, blocked })
const updates = yield* PubSub.unbounded<Watcher.Update>()
const service = Test.of({
entries: () => Ref.get(entries),
current: () => Ref.getUnsafe(snapshot),
entries: () => Ref.get(snapshot).pipe(Effect.map((value) => value.entries)),
compatibility: () => Effect.succeed(compatibility),
changes: () => Stream.fromPubSub(updates),
setEntries: (next) => Ref.set(entries, next),
setEntries: (entries) => Ref.update(snapshot, (value) => ({ ...value, entries })),
emitChange: (update) => PubSub.publish(updates, update).pipe(Effect.asVoid),
})
return Context.empty().pipe(Context.add(Service, service), Context.add(Test, service))
Expand Down Expand Up @@ -142,44 +152,86 @@ export const layer = (options?: Options) =>
return new Document({ type: "document", path: AbsolutePath.make(filepath), info })
})

const loadWellknownEntry = Effect.fnUntraced(function* (entry: WellKnown.Entry) {
const auth = entry.manifest.auth
if (!auth) return []
type Resolved = {
readonly credentialID: Credential.ID
readonly key: string
readonly documents: Document[]
}
const loadWellknownEntry = Effect.fnUntraced(function* (entry: WellKnown.Entry, previous?: Resolved) {
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),
if (!credential || credential.value.type !== "key") return { blocked: false }
const key = credential.value.key
const fallback =
previous?.credentialID === credential.id && previous.key === key
? { value: previous, blocked: false }
: { blocked: true }
const auth = entry.manifest.auth
if (!auth) return fallback
const variables = { [auth.env]: key }
const documents = wellknown.resolve(entry, variables).pipe(
Effect.flatMap((configs) =>
Effect.forEach(configs, (config) =>
ConfigVariable.substitute({
type: "virtual",
source: entry.origin,
dir: entry.origin,
text: JSON.stringify(config),
env: variables,
}).pipe(
Effect.flatMap((text) => parseInfo(text, entry.origin)),
Effect.flatMap((info) =>
info
? Effect.succeed(new Document({ type: "document", info }))
: Effect.fail(new Error("Invalid wellknown config")),
),
),
),
)
return yield* Effect.forEach(configs, (config) =>
ConfigVariable.substitute({
type: "virtual",
source: entry.origin,
dir: entry.origin,
text: JSON.stringify(config),
env: variables,
}).pipe(
Effect.flatMap((text) => parseInfo(text, entry.origin)),
Effect.map((info) => (info ? new Document({ type: "document", info }) : undefined)),
),
).pipe(Effect.map((documents) => documents.filter((document) => document !== undefined)))
)
return yield* documents.pipe(
Effect.matchEffect({
onFailure: () =>
Effect.logWarning("failed to load wellknown config", { source: entry.origin }).pipe(
Effect.as(fallback),
),
onSuccess: (documents) =>
Effect.succeed({
value: { credentialID: credential.id, key, documents },
blocked: false,
}),
}),
)
})

const loadWellknown = Effect.fn("Config.loadWellknown")(function* () {
const entries = yield* wellknown
.entries()
.pipe(
Effect.catch((error) =>
Effect.logWarning("failed to discover wellknown config", { error }).pipe(Effect.as([] as const)),
),
)
return yield* Effect.forEach(entries, loadWellknownEntry).pipe(Effect.map((documents) => documents.flat()))
const loadWellknown = Effect.fn("Config.loadWellknown")(function* (previous: ReadonlyMap<string, Resolved>) {
const entries = yield* wellknown.entries().pipe(
Effect.catch((error) =>
Effect.gen(function* () {
yield* Effect.logWarning("failed to discover wellknown config", { error })
const snapshot = wellknown.snapshot()
return (yield* wellknown.origins()).map(
(origin) =>
snapshot.find((entry) => entry.origin === origin) ?? {
origin,
integrationID: Integration.ID.make(origin),
manifest: {},
},
)
}),
),
)
const loaded = yield* Effect.forEach(entries, (entry) =>
loadWellknownEntry(entry, previous.get(entry.origin)).pipe(
Effect.map((result) => ({ origin: entry.origin, ...result })),
),
)
return {
documents: loaded.flatMap((result) => result.value?.documents ?? []),
resolved: new Map(
loaded.flatMap((result): [string, Resolved][] => (result.value ? [[result.origin, result.value]] : [])),
),
blocked: loaded.some((result) => result.blocked),
}
})

const loadDirectory = Effect.fnUntraced(function* (directory: AbsolutePath) {
Expand All @@ -191,7 +243,10 @@ export const layer = (options?: Options) =>
]
})

const load = Effect.fn("Config.load")(function* (sources: ConfigDiscovery.Sources) {
const load = Effect.fn("Config.load")(function* (
sources: ConfigDiscovery.Sources,
previous: ReadonlyMap<string, Resolved>,
) {
const direct = yield* Effect.forEach(sources.direct, (filepath) => loadFile(filepath)).pipe(
Effect.orDie,
Effect.map((entries) => entries.filter((entry): entry is Document => entry !== undefined)),
Expand Down Expand Up @@ -227,19 +282,26 @@ export const layer = (options?: Options) =>
Effect.orDie,
Effect.map((entries) => entries.flat()),
)
return [
...(yield* loadWellknown().pipe(Effect.orDie)),
...globalSupplementary,
...explicit,
...direct,
...projectSupplementary,
...content,
]
const remote = yield* loadWellknown(previous).pipe(Effect.orDie)
return {
entries: [
...remote.documents,
...globalSupplementary,
...explicit,
...direct,
...projectSupplementary,
...content,
],
resolved: remote.resolved,
blocked: remote.blocked,
}
})

const initial = yield* ConfigDiscovery.discover(options)
let sources = initial
let configs = yield* load(initial)
const loaded = yield* load(initial, new Map())
let resolved = loaded.resolved
let snapshot: Snapshot = { entries: loaded.entries, blocked: loaded.blocked }
const updates = yield* PubSub.unbounded<Watcher.Update>()
const reloads = yield* PubSub.sliding<void>(1)
// Readiness rescans recover writes made before a watch attached.
Expand All @@ -265,14 +327,17 @@ export const layer = (options?: Options) =>
const reload = Effect.fn("Config.reload")(
function* () {
const discovered = yield* ConfigDiscovery.discover(options)
const next = yield* load(discovered)
const loaded = yield* load(discovered, resolved)
yield* reconcile(discovered)
const compatibilityChanged =
!isDeepStrictEqual(sources.claude, discovered.claude) ||
!isDeepStrictEqual(sources.agents, discovered.agents)
if (isDeepStrictEqual(configs, next) && !compatibilityChanged) return
const next: Snapshot = { entries: loaded.entries, blocked: loaded.blocked }
const changed = !isDeepStrictEqual(snapshot, next) || compatibilityChanged
sources = discovered
configs = next
resolved = loaded.resolved
snapshot = next
if (!changed) return
yield* bus.publish(Event.Updated, {})
},
(effect) => reloadLock.withPermit(effect),
Expand All @@ -289,10 +354,7 @@ export const layer = (options?: Options) =>
)
yield* bus.subscribe(Credential.Event.Switched).pipe(
Stream.filterEffect((event) =>
wellknown.entries().pipe(
Effect.map((entries) => entries.some((entry) => entry.integrationID === event.data.integrationID)),
Effect.orElseSucceed(() => false),
),
wellknown.origins().pipe(Effect.map((origins) => origins.includes(event.data.integrationID))),
),
Stream.runForEach(() =>
reload().pipe(Effect.catchCause((cause) => Effect.logError("failed to reload wellknown config", { cause }))),
Expand All @@ -307,19 +369,16 @@ export const layer = (options?: Options) =>
)
yield* Effect.sleep("10 minutes").pipe(
Effect.andThen(
Effect.suspend(() => {
if (!wellknown.snapshot().length) return Effect.void
return Effect.gen(function* () {
const changed = yield* wellknown
.refresh()
.pipe(
Effect.catch((error) =>
Effect.logWarning("failed to refresh wellknown manifests", { error }).pipe(Effect.as(false)),
),
)
if (!changed) yield* reload()
}).pipe(Effect.catchCause((cause) => Effect.logWarning("failed to refresh wellknown config", { cause })))
}),
Effect.gen(function* () {
const changed = yield* wellknown
.refresh()
.pipe(
Effect.catch((error) =>
Effect.logWarning("failed to refresh wellknown manifests", { error }).pipe(Effect.as(false)),
),
)
if (!changed) yield* reload()
}).pipe(Effect.catchCause((cause) => Effect.logWarning("failed to refresh wellknown config", { cause }))),
),
Effect.forever,
Effect.forkScoped({ startImmediately: true }),
Expand All @@ -330,7 +389,8 @@ export const layer = (options?: Options) =>
function* (patch: Patch) {
const directory = initial.global ?? AbsolutePath.make(globalService.config)
const candidates = ConfigDiscovery.names.map((name) => path.join(directory, name))
const filepath = (yield* Effect.filter(candidates, fs.isFile)).at(-1) ?? path.join(directory, "opencode.jsonc")
const filepath =
(yield* Effect.filter(candidates, fs.isFile)).at(-1) ?? path.join(directory, "opencode.jsonc")
const text = (yield* fs.readFileStringSafe(filepath)) ?? "{}\n"
const updated = yield* Effect.try({
try: () =>
Expand All @@ -349,8 +409,9 @@ export const layer = (options?: Options) =>
)

return Service.of({
current: () => snapshot,
entries: Effect.fnUntraced(function* () {
return configs
return snapshot.entries
}),
compatibility: () =>
Effect.all({
Expand Down
13 changes: 9 additions & 4 deletions packages/core/src/config/plugin/policy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,13 +13,13 @@ export const Plugin = define({
effect: Effect.fn(function* (ctx) {
const config = yield* Config.Service
const managed = yield* ManagedPolicy.Service
const loaded = yield* ConfigEntryObserver.observe(config, ctx.event, ctx.provider.reload())
yield* ConfigEntryObserver.observe(config, ctx.event, ctx.provider.reload())
// Authored documents reverse so user-global policy outranks repository policy; organization statements
// from the connected Console follow every authored one and have the final say.
const policies = () => {
const policies = (snapshot = config.current()) => {
const organization = managed.current()
return [
...loaded.entries
...snapshot.entries
.filter((entry): entry is Document => entry.type === "document")
.toReversed()
.flatMap((entry) => entry.info.experimental?.policies ?? [])
Expand All @@ -33,7 +33,12 @@ export const Plugin = define({
]
}
yield* ctx.provider.transform((providers) => {
const current = policies()
const snapshot = config.current()
if (snapshot.blocked) {
for (const record of providers.list()) providers.remove(record.provider.id)
return
}
const current = policies(snapshot)
for (const record of providers.list()) {
const policy = current.findLast(
(policy) => policy.action === "provider.use" && Wildcard.match(record.provider.id, policy.resource),
Expand Down
9 changes: 5 additions & 4 deletions packages/core/src/config/plugin/provider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,13 +15,13 @@ export const Plugin = define({
id: "opencode.config.provider",
effect: Effect.fn(function* (ctx) {
const config = yield* Config.Service
const loaded = yield* ConfigEntryObserver.observe(
yield* ConfigEntryObserver.observe(
config,
ctx.event,
ctx.integration.reload().pipe(Effect.andThen(ctx.provider.reload())),
)
yield* ctx.integration.transform((integrations) => {
for (const [id, provider] of configuredProviders(loaded.entries)) {
for (const [id, provider] of configuredProviders(config.current().entries)) {
const integrationID = id
if (!integrations.get(integrationID)) {
integrations.method.update({
Expand Down Expand Up @@ -52,8 +52,9 @@ export const Plugin = define({
>(),
}
yield* ctx.provider.transform((providers) => {
const snapshot = config.current()
const next: typeof sources.models = new Map()
for (const [id, item] of configuredProviders(loaded.entries)) {
for (const [id, item] of configuredProviders(snapshot.entries)) {
const providerID = id
const current = providers.get(providerID)
const source = providers.get(item.canonical ?? current?.provider.canonical ?? providerID)
Expand Down Expand Up @@ -88,7 +89,7 @@ export const Plugin = define({
}
next.set(item, { providerID, models: definitions })
}
sources.defaultModel = Config.latest(loaded.entries, "model")
sources.defaultModel = Config.latest(snapshot.entries, "model")
sources.models = next
})

Expand Down
Loading
Loading