Compare commits

..

2 Commits

Author SHA1 Message Date
Kit Langton a58b3e9b37 refactor(core): generate static imports for the migration manifest 2026-08-10 23:26:36 -04:00
Kit Langton 518af92c5b refactor(core): move the models.dev catalog cache from disk to KV (#41649) 2026-08-10 22:40:41 -04:00
8 changed files with 231 additions and 248 deletions
+4 -5
View File
@@ -194,11 +194,10 @@ async function formatTypescript(input: string) {
function renderRegistry(names: string[]) {
return `import type { DatabaseMigration } from "./migration"
${names.map((name, index) => `import m${index.toString().padStart(2, "0")} from "./migration/${name}"`).join("\n")}
export const migrations: DatabaseMigration.Migration[] = (
await Promise.all([
${names.map((name) => ` import("./migration/${name}"),`).join("\n")}
])
).map((module) => module.default)
export const migrations = [
${names.map((_, index) => ` m${index.toString().padStart(2, "0")},`).join("\n")}
] satisfies DatabaseMigration.Migration[]
`
}
@@ -27,10 +27,8 @@ export const Plugin = define({
const changes = yield* PubSub.sliding<string>(1)
const lock = Semaphore.makeUnsafe(1)
const start = yield* fs.resolve(location.directory)
const root = yield* fs.resolve(location.project.directory)
const home = yield* fs.resolve(global.home)
const project = discovery.project && FSUtil.contains(root, start)
const stop = FSUtil.contains(home, start) ? home : root
const stop = yield* fs.resolve(location.project.directory)
const project = discovery.project && FSUtil.contains(stop, start)
const globalFile = yield* fs.resolve(join(global.config, "AGENTS.md"))
const loaded: { current: Loaded } = { current: { type: "available", files: [] } }
+84 -45
View File
@@ -1,47 +1,86 @@
import type { DatabaseMigration } from "./migration"
import m00 from "./migration/20260127222353_familiar_lady_ursula"
import m01 from "./migration/20260211171708_add_project_commands"
import m02 from "./migration/20260213144116_wakeful_the_professor"
import m03 from "./migration/20260225215848_workspace"
import m04 from "./migration/20260227213759_add_session_workspace_id"
import m05 from "./migration/20260228203230_blue_harpoon"
import m06 from "./migration/20260303231226_add_workspace_fields"
import m07 from "./migration/20260309230000_move_org_to_state"
import m08 from "./migration/20260312043431_session_message_cursor"
import m09 from "./migration/20260323234822_events"
import m10 from "./migration/20260410174513_workspace-name"
import m11 from "./migration/20260413175956_chief_energizer"
import m12 from "./migration/20260423070820_add_icon_url_override"
import m13 from "./migration/20260427172553_slow_nightmare"
import m14 from "./migration/20260428004200_add_session_path"
import m15 from "./migration/20260501142318_next_venus"
import m16 from "./migration/20260504145000_add_sync_owner"
import m17 from "./migration/20260507164347_add_workspace_time"
import m18 from "./migration/20260510033149_session_usage"
import m19 from "./migration/20260511000411_data_migration_state"
import m20 from "./migration/20260511173437_session-metadata"
import m21 from "./migration/20260601010001_normalize_storage_paths"
import m22 from "./migration/20260601202201_amazing_prowler"
import m23 from "./migration/20260602002951_lowly_union_jack"
import m24 from "./migration/20260602182828_add_project_directories"
import m25 from "./migration/20260603001617_session_message_projection_indexes"
import m26 from "./migration/20260603040000_session_message_projection_order"
import m27 from "./migration/20260603141458_session_input_inbox"
import m28 from "./migration/20260603160727_jittery_ezekiel_stane"
import m29 from "./migration/20260604172448_event_sourced_session_input"
import m30 from "./migration/20260605003541_add_session_context_snapshot"
import m31 from "./migration/20260605042240_add_context_epoch_agent"
import m32 from "./migration/20260611035744_credential"
import m33 from "./migration/20260611192811_lush_chimera"
import m34 from "./migration/20260612174303_project_dir_strategy"
import m35 from "./migration/20260622142730_simplify_session_context_epoch"
import m36 from "./migration/20260622170816_reset_v2_session_state"
import m37 from "./migration/20260622202450_simplify_session_input"
import m38 from "./migration/20260804233008_loose_psylocke"
import m39 from "./migration/20260805200742_import_legacy_credentials"
import m40 from "./migration/20260808023530_workspace_domain"
export const migrations: DatabaseMigration.Migration[] = (
await Promise.all([
import("./migration/20260127222353_familiar_lady_ursula"),
import("./migration/20260211171708_add_project_commands"),
import("./migration/20260213144116_wakeful_the_professor"),
import("./migration/20260225215848_workspace"),
import("./migration/20260227213759_add_session_workspace_id"),
import("./migration/20260228203230_blue_harpoon"),
import("./migration/20260303231226_add_workspace_fields"),
import("./migration/20260309230000_move_org_to_state"),
import("./migration/20260312043431_session_message_cursor"),
import("./migration/20260323234822_events"),
import("./migration/20260410174513_workspace-name"),
import("./migration/20260413175956_chief_energizer"),
import("./migration/20260423070820_add_icon_url_override"),
import("./migration/20260427172553_slow_nightmare"),
import("./migration/20260428004200_add_session_path"),
import("./migration/20260501142318_next_venus"),
import("./migration/20260504145000_add_sync_owner"),
import("./migration/20260507164347_add_workspace_time"),
import("./migration/20260510033149_session_usage"),
import("./migration/20260511000411_data_migration_state"),
import("./migration/20260511173437_session-metadata"),
import("./migration/20260601010001_normalize_storage_paths"),
import("./migration/20260601202201_amazing_prowler"),
import("./migration/20260602002951_lowly_union_jack"),
import("./migration/20260602182828_add_project_directories"),
import("./migration/20260603001617_session_message_projection_indexes"),
import("./migration/20260603040000_session_message_projection_order"),
import("./migration/20260603141458_session_input_inbox"),
import("./migration/20260603160727_jittery_ezekiel_stane"),
import("./migration/20260604172448_event_sourced_session_input"),
import("./migration/20260605003541_add_session_context_snapshot"),
import("./migration/20260605042240_add_context_epoch_agent"),
import("./migration/20260611035744_credential"),
import("./migration/20260611192811_lush_chimera"),
import("./migration/20260612174303_project_dir_strategy"),
import("./migration/20260622142730_simplify_session_context_epoch"),
import("./migration/20260622170816_reset_v2_session_state"),
import("./migration/20260622202450_simplify_session_input"),
import("./migration/20260804233008_loose_psylocke"),
import("./migration/20260805200742_import_legacy_credentials"),
import("./migration/20260808023530_workspace_domain"),
])
).map((module) => module.default)
export const migrations = [
m00,
m01,
m02,
m03,
m04,
m05,
m06,
m07,
m08,
m09,
m10,
m11,
m12,
m13,
m14,
m15,
m16,
m17,
m18,
m19,
m20,
m21,
m22,
m23,
m24,
m25,
m26,
m27,
m28,
m29,
m30,
m31,
m32,
m33,
m34,
m35,
m36,
m37,
m38,
m39,
m40,
] satisfies DatabaseMigration.Migration[]
+63 -57
View File
@@ -1,11 +1,8 @@
import path from "path"
import { Context, Duration, Effect, Layer, Option, Schedule, Schema } from "effect"
import { Context, Duration, Effect, Layer, Option, Schedule, Schema, Semaphore } from "effect"
import { HttpClient, HttpClientRequest } from "effect/unstable/http"
import { ModelsDev } from "@opencode-ai/schema/models-dev"
import { Money } from "@opencode-ai/schema/money"
import { App } from "./app"
import { Global } from "@opencode-ai/util/global"
import { Flock } from "@opencode-ai/util/flock"
import { Hash } from "@opencode-ai/util/hash"
import { FSUtil } from "@opencode-ai/util/fs-util"
import { Bus } from "./bus"
@@ -13,6 +10,7 @@ import { makeGlobalNode } from "@opencode-ai/util/effect/app-node"
import { httpClient } from "@opencode-ai/util/effect/app-node-platform"
import { Model } from "./model"
import { Provider } from "./provider"
import { KV } from "./kv"
export const CatalogModelStatus = Schema.Literals(["alpha", "beta", "deprecated"])
export type CatalogModelStatus = typeof CatalogModelStatus.Type
@@ -537,6 +535,18 @@ export type Options = typeof Options.Type
export class Service extends Context.Service<Service, Interface>()("@opencode/ModelsDev") {}
const CatalogJson = Schema.fromJsonString(Schema.Record(Schema.String, Schema.Unknown))
const Cache = Schema.Struct({
updatedAt: Schema.Number,
body: CatalogJson,
})
const defaultSource = "https://models.opencode.ai"
function cacheKey(source: string) {
if (source === defaultSource) return "models-dev:catalog"
return `models-dev:catalog:${Hash.fast(source)}`
}
export const layer = (options?: Options) =>
Layer.effect(
Service,
@@ -544,7 +554,7 @@ export const layer = (options?: Options) =>
const fs = yield* FSUtil.Service
const bus = yield* Bus.Service
const app = yield* App.Metadata
const global = yield* Global.Service
const kv = yield* KV.Service
const http = HttpClient.filterStatusOk(
(yield* HttpClient.HttpClient).pipe(
HttpClient.retryTransient({
@@ -555,21 +565,28 @@ export const layer = (options?: Options) =>
),
)
const source = options?.url || "https://models.opencode.ai"
const source = options?.url || defaultSource
const fetch = options?.fetch ?? true
const userAgent = App.useragent(app)
const filepath = path.join(
global.cache,
source === "https://models.opencode.ai" ? "models.json" : `models-${Hash.fast(source)}.json`,
)
const key = cacheKey(source)
const ttl = Duration.minutes(5)
const lockKey = `models-dev:${filepath}`
const lock = Semaphore.makeUnsafe(1)
const loadFromCache = Effect.fnUntraced(function* () {
const value = yield* kv.get(key)
const cached = Schema.decodeUnknownOption(Cache)(value)
if (Option.isSome(cached))
return {
catalog: cached.value.body as Record<string, SourceProvider>,
updatedAt: cached.value.updatedAt,
}
if (value !== undefined) yield* kv.remove(key)
})
const fresh = Effect.fnUntraced(function* () {
const stat = yield* fs.stat(filepath).pipe(Effect.catch(() => Effect.succeed(undefined)))
if (!stat) return false
const mtime = Option.getOrElse(stat.mtime, () => new Date(0)).getTime()
return Date.now() - mtime < Duration.toMillis(ttl)
const cached = yield* loadFromCache()
if (!cached) return false
return Date.now() - cached.updatedAt < Duration.toMillis(ttl)
})
const fetchApi = Effect.fn("ModelsDev.fetchApi")(function* () {
@@ -581,15 +598,12 @@ export const layer = (options?: Options) =>
)
})
const loadFromDisk = fs.readJson(options?.file ?? filepath).pipe(
Effect.map((input) => input as Record<string, SourceProvider>),
Effect.catch((error) => {
if (options?.file === undefined && error._tag === "FileSystemError" && error.method === "readJson") {
return fs.remove(filepath, { force: true }).pipe(Effect.ignore, Effect.as(undefined))
}
return Effect.succeed(undefined)
}),
)
const loadFromFile = options?.file
? fs.readJson(options.file).pipe(
Effect.map((input) => input as Record<string, SourceProvider>),
Effect.catch(() => Effect.succeed(undefined)),
)
: Effect.succeed(undefined)
const loadSnapshot = Effect.sync(() =>
typeof OPENCODE_MODELS_DEV === "undefined" ? undefined : OPENCODE_MODELS_DEV,
@@ -597,33 +611,27 @@ export const layer = (options?: Options) =>
const fetchAndWrite = Effect.fn("ModelsDev.fetchAndWrite")(function* () {
const text = yield* fetchApi()
const tempfile = `${filepath}.${process.pid}.${Date.now()}.tmp`
yield* fs.writeWithDirs(tempfile, text).pipe(
Effect.andThen(fs.rename(tempfile, filepath)),
Effect.catch((error) =>
Effect.gen(function* () {
yield* fs.remove(tempfile, { force: true }).pipe(Effect.ignore)
return yield* Effect.fail(error)
}),
),
)
return text
const catalog = (yield* Schema.decodeUnknownEffect(CatalogJson)(text)) as Record<string, SourceProvider>
yield* kv.set(key, { updatedAt: Date.now(), body: text })
return catalog
})
const populate = Effect.gen(function* () {
const fromDisk = yield* loadFromDisk
if (fromDisk) return normalize(fromDisk)
const fromFile = yield* loadFromFile
if (fromFile) return normalize(fromFile)
const cached = options?.file ? undefined : yield* loadFromCache()
if (cached) return normalize(cached.catalog)
const bundled = yield* loadSnapshot
if (bundled) return normalize(bundled)
if (!fetch) return []
// Flock is cross-process: concurrent opencode CLIs can race on this cache file.
const text = yield* Effect.scoped(
const catalog = yield* lock.withPermit(
Effect.gen(function* () {
yield* Flock.effect(lockKey)
const stored = options?.file ? undefined : yield* loadFromCache()
if (stored) return stored.catalog
return yield* fetchAndWrite()
}),
)
return normalize(JSON.parse(text) as Record<string, SourceProvider>)
return normalize(catalog)
}).pipe(Effect.withSpan("ModelsDev.populate"), Effect.orDie)
const [cachedGet, invalidate] = yield* Effect.cachedInvalidateWithTTL(populate, Duration.infinity)
@@ -631,21 +639,19 @@ export const layer = (options?: Options) =>
const get = (): Effect.Effect<readonly Snapshot[]> => cachedGet
const refresh = Effect.fn("ModelsDev.refresh")(function* (force = false) {
if (!force && (yield* fresh())) return
yield* Effect.scoped(
Effect.gen(function* () {
yield* Flock.effect(lockKey)
// Re-check under the lock: another process may have refreshed between
// our outer check and lock acquisition.
if (!force && (yield* fresh())) return
yield* fetchAndWrite()
yield* invalidate
yield* bus.publish(ModelsDev.Event.Refreshed, {})
}),
).pipe(
Effect.tapCause((cause) => Effect.logError("Failed to fetch models.dev", { cause: cause })),
Effect.ignore,
)
yield* lock
.withPermit(
Effect.gen(function* () {
if (!force && (yield* fresh())) return
yield* fetchAndWrite()
yield* invalidate
yield* bus.publish(ModelsDev.Event.Refreshed, {})
}),
)
.pipe(
Effect.tapCause((cause) => Effect.logError("Failed to fetch models.dev", { cause: cause })),
Effect.ignore,
)
})
if (fetch && !process.argv.includes("--get-yargs-completions")) {
@@ -661,7 +667,7 @@ export function configured(options?: Options) {
return makeGlobalNode({
service: Service,
layer: layer(options),
deps: [FSUtil.node, Bus.node, App.node, Global.node, httpClient],
deps: [FSUtil.node, Bus.node, App.node, KV.node, httpClient],
})
}
@@ -24,7 +24,6 @@ const it = testEffect(Layer.empty)
const instructionLayer = (input: {
config?: string
home?: string
locationServiceLayer: Layer.Layer<Location.Service>
filesystemLayer?: Layer.Layer<FSUtil.Service>
project?: boolean
@@ -35,15 +34,7 @@ const instructionLayer = (input: {
LayerNode.group([InstructionDiscovery.node, Bus.node, FSUtil.node, Global.node, Location.node, Watcher.node]),
[
[InstructionDiscovery.node, InstructionDiscovery.configured({ project: input.project })],
[
Global.node,
input.config || input.home
? Global.layerWith({
...(input.config ? { config: input.config } : {}),
...(input.home ? { home: input.home } : {}),
})
: tempGlobalLayer,
],
[Global.node, input.config ? Global.layerWith({ config: input.config }) : tempGlobalLayer],
[Location.node, input.locationServiceLayer],
[Watcher.node, watcher],
...(input.filesystemLayer ? [[FSUtil.node, input.filesystemLayer] as const] : []),
@@ -121,13 +112,10 @@ describe("ConfigInstructionPlugin.Plugin", () => {
).pipe(
Effect.flatMap((tmp) => {
const global = path.join(tmp.path, "global")
const home = path.join(tmp.path, "home")
const shared = path.join(home, "code")
const project = path.join(shared, "repo")
const project = path.join(tmp.path, "project")
const directory = path.join(project, "packages", "core")
const outside = path.join(tmp.path, "AGENTS.md")
const globalFile = path.join(global, "AGENTS.md")
const sharedFile = path.join(shared, "AGENTS.md")
const projectFile = path.join(project, "AGENTS.md")
const packageFile = path.join(directory, "AGENTS.md")
return Effect.gen(function* () {
@@ -136,7 +124,6 @@ describe("ConfigInstructionPlugin.Plugin", () => {
await fs.mkdir(directory, { recursive: true })
await fs.writeFile(outside, "outside")
await fs.writeFile(globalFile, "global")
await fs.writeFile(sharedFile, "shared")
await fs.writeFile(projectFile, "project")
await fs.writeFile(packageFile, "package")
})
@@ -148,20 +135,13 @@ describe("ConfigInstructionPlugin.Plugin", () => {
{ path: packageFile, type: "file" },
{ path: path.join(project, "packages", "AGENTS.md"), type: "file" },
{ path: projectFile, type: "file" },
{ path: sharedFile, type: "file" },
{ path: path.join(home, "AGENTS.md"), type: "file" },
])
expect(yield* watcher.subscriptions()).not.toContainEqual({
path: path.join(tmp.path, "AGENTS.md"),
type: "file",
})
const initialized = yield* readInitial(yield* discovery.load())
expect(initialized.text).toBe(
[
`Instructions from: ${globalFile}\nglobal`,
`Instructions from: ${packageFile}\npackage`,
`Instructions from: ${projectFile}\nproject`,
`Instructions from: ${sharedFile}\nshared`,
].join("\n\n"),
)
expect(initialized.text).not.toContain("outside")
@@ -179,7 +159,6 @@ describe("ConfigInstructionPlugin.Plugin", () => {
"These instructions replace all previously loaded ambient instructions.",
`Instructions from: ${globalFile}\nglobal`,
`Instructions from: ${projectFile}\nproject`,
`Instructions from: ${sharedFile}\nshared`,
].join("\n\n"),
)
@@ -187,8 +166,6 @@ describe("ConfigInstructionPlugin.Plugin", () => {
yield* emitAndWait({ type: "delete", path: globalFile })
yield* Effect.promise(() => fs.rm(projectFile))
yield* emitAndWait({ type: "delete", path: projectFile })
yield* Effect.promise(() => fs.rm(sharedFile))
yield* emitAndWait({ type: "delete", path: sharedFile })
expect((yield* readUpdate(yield* discovery.load(), initialized)).text).toBe(
"Previously loaded instructions no longer apply.",
)
@@ -196,7 +173,6 @@ describe("ConfigInstructionPlugin.Plugin", () => {
Effect.provide(
instructionLayer({
config: global,
home,
locationServiceLayer: Layer.succeed(
Location.Service,
Location.Service.of(
@@ -239,17 +215,15 @@ describe("ConfigInstructionPlugin.Plugin", () => {
),
)
it.live("discovers a newly created instruction file above the project root", () =>
it.live("discovers a newly created instruction file in an intermediate directory", () =>
Effect.acquireRelease(
Effect.promise(() => tmpdir()),
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
).pipe(
Effect.flatMap((tmp) => {
const home = path.join(tmp.path, "home")
const shared = path.join(home, "code")
const project = path.join(shared, "repo")
const intermediate = path.join(shared, "AGENTS.md")
const directory = path.join(project, "core")
const project = path.join(tmp.path, "project")
const intermediate = path.join(project, "packages", "AGENTS.md")
const directory = path.join(project, "packages", "core")
const projectFile = path.join(project, "AGENTS.md")
return Effect.gen(function* () {
yield* Effect.promise(() => fs.mkdir(directory, { recursive: true }))
@@ -261,7 +235,7 @@ describe("ConfigInstructionPlugin.Plugin", () => {
yield* emitAndWait({ type: "create", path: intermediate })
expect((yield* readInitial(yield* discovery.load())).text).toBe(
[`Instructions from: ${projectFile}\nproject`, `Instructions from: ${intermediate}\nintermediate`].join(
[`Instructions from: ${intermediate}\nintermediate`, `Instructions from: ${projectFile}\nproject`].join(
"\n\n",
),
)
@@ -269,48 +243,6 @@ describe("ConfigInstructionPlugin.Plugin", () => {
Effect.provide(
instructionLayer({
config: path.join(tmp.path, "global"),
home,
locationServiceLayer: Layer.succeed(
Location.Service,
Location.Service.of(
location(
{ directory: AbsolutePath.make(directory) },
{ projectDirectory: AbsolutePath.make(project) },
),
),
),
}),
),
)
}),
),
)
it.live("stops instruction candidates at the project root outside home", () =>
Effect.acquireRelease(
Effect.promise(() => tmpdir()),
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
).pipe(
Effect.flatMap((tmp) => {
const global = path.join(tmp.path, "global")
const home = path.join(tmp.path, "home")
const project = path.join(tmp.path, "scratch", "repo")
const directory = path.join(project, "packages", "core")
return Effect.gen(function* () {
yield* Effect.promise(() => fs.mkdir(directory, { recursive: true }))
yield* start()
const watcher = yield* Watcher.Test
expect(yield* watcher.subscriptions()).toEqual([
{ path: path.join(global, "AGENTS.md"), type: "file" },
{ path: path.join(directory, "AGENTS.md"), type: "file" },
{ path: path.join(project, "packages", "AGENTS.md"), type: "file" },
{ path: path.join(project, "AGENTS.md"), type: "file" },
])
}).pipe(
Effect.provide(
instructionLayer({
config: global,
home,
locationServiceLayer: Layer.succeed(
Location.Service,
Location.Service.of(
+65 -55
View File
@@ -1,19 +1,17 @@
import { describe, expect, beforeEach, afterAll, test } from "bun:test"
import { describe, expect, test } from "bun:test"
import { Money } from "@opencode-ai/schema/money"
import { Effect, Layer, Ref } from "effect"
import { HttpClient, HttpClientResponse } from "effect/unstable/http"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { LayerNodePlatform } from "@opencode-ai/util/effect/app-node-platform"
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
import { Global } from "@opencode-ai/util/global"
import { KV } from "@opencode-ai/core/kv"
import { Model } from "@opencode-ai/core/model"
import { ModelsDev } from "@opencode-ai/core/models-dev"
import { Provider } from "@opencode-ai/core/provider"
import { it } from "./lib/effect"
import { readFile, rm, writeFile, utimes, mkdir } from "fs/promises"
import path from "path"
const cacheFile = path.join(Global.Path.cache, "models.json")
const cacheKey = "models-dev:catalog"
test("normalizes permissive interleaved values to compatibility", () => {
expect(Model.compatibility("reasoning_text")).toEqual({ reasoningField: "reasoning_text" })
@@ -164,7 +162,18 @@ const makeMockClient = (state: Ref.Ref<MockState>) =>
}),
)
const buildLayer = (state: Ref.Ref<MockState>, options: ModelsDev.Options = { fetch: false }) =>
interface MockCache {
readonly values: Map<string, KV.Value>
}
const makeMockKV = (cache: MockCache) =>
Layer.mock(KV.Service, {
get: (key) => Effect.sync(() => cache.values.get(key)),
set: (key, value) => Effect.sync(() => cache.values.set(key, value)).pipe(Effect.asVoid),
remove: (key) => Effect.sync(() => cache.values.delete(key)).pipe(Effect.asVoid),
})
const buildLayer = (state: Ref.Ref<MockState>, cache: MockCache, options: ModelsDev.Options = { fetch: false }) =>
// Layer.fresh is required because the ModelsDev implementation is a module-level Layer constant,
// and Effect.provide uses a process-global MemoMap by default — without fresh,
// every test would reuse the cachedInvalidateWithTTL state from the first run.
@@ -172,31 +181,20 @@ const buildLayer = (state: Ref.Ref<MockState>, options: ModelsDev.Options = { fe
AppNodeBuilder.build(ModelsDev.node, [
[ModelsDev.node, ModelsDev.configured(options)],
[LayerNodePlatform.httpClient, Layer.succeed(HttpClient.HttpClient, makeMockClient(state))],
[KV.node, makeMockKV(cache)],
]),
)
const writeCacheText = (text: string, mtimeMs?: number) =>
Effect.promise(async () => {
await mkdir(Global.Path.cache, { recursive: true })
await writeFile(cacheFile, text)
if (mtimeMs !== undefined) {
const t = mtimeMs / 1000
await utimes(cacheFile, t, t)
}
})
const makeCache = (): MockCache => ({ values: new Map() })
const writeCache = (data: object, mtimeMs?: number) => writeCacheText(JSON.stringify(data), mtimeMs)
const writeCacheText = (cache: MockCache, text: string, updatedAt = Date.now()) =>
cache.values.set(cacheKey, { updatedAt, body: text })
const provided = <A, E>(state: Ref.Ref<MockState>, eff: Effect.Effect<A, E, ModelsDev.Service>) =>
eff.pipe(Effect.provide(buildLayer(state)))
const writeCache = (cache: MockCache, data: object, updatedAt?: number) =>
writeCacheText(cache, JSON.stringify(data), updatedAt)
beforeEach(async () => {
await rm(cacheFile, { force: true })
})
afterAll(async () => {
await rm(cacheFile, { force: true })
})
const provided = <A, E>(state: Ref.Ref<MockState>, cache: MockCache, eff: Effect.Effect<A, E, ModelsDev.Service>) =>
eff.pipe(Effect.provide(buildLayer(state, cache)))
const initialState: MockState = {
body: JSON.stringify(fixture),
@@ -205,12 +203,14 @@ const initialState: MockState = {
}
describe("ModelsDev Service", () => {
it.live("get() returns normalized snapshots from disk when cache file exists", () =>
it.live("get() returns normalized snapshots from KV when a cache entry exists", () =>
Effect.gen(function* () {
yield* writeCache(fixture)
const cache = makeCache()
writeCache(cache, fixture)
const state = yield* Ref.make(initialState)
const result = yield* provided(
state,
cache,
ModelsDev.Service.use((s) => s.get()),
)
expect(result).toEqual(fixtureSnapshot)
@@ -219,11 +219,13 @@ describe("ModelsDev Service", () => {
}),
)
it.live("get() returns empty catalog when disk empty, fetch disabled, and no bundled snapshot is injected", () =>
it.live("get() returns empty catalog when KV is empty, fetch disabled, and no bundled snapshot is injected", () =>
Effect.gen(function* () {
const cache = makeCache()
const state = yield* Ref.make(initialState)
const result = yield* provided(
state,
cache,
ModelsDev.Service.use((s) => s.get()),
)
expect(result).toEqual([])
@@ -232,14 +234,15 @@ describe("ModelsDev Service", () => {
}),
)
it.live("get() recovers from a corrupted cache file by fetching a fresh catalog", () =>
it.live("get() recovers from a corrupted KV entry by fetching a fresh catalog", () =>
Effect.gen(function* () {
yield* writeCacheText("{")
const cache = makeCache()
writeCacheText(cache, "{")
const state = yield* Ref.make({ ...initialState, body: JSON.stringify(fixture2) })
const context = yield* Layer.build(buildLayer(state, { fetch: true }))
const context = yield* Layer.build(buildLayer(state, cache, { fetch: true }))
const result = yield* ModelsDev.Service.use((s) => s.get()).pipe(Effect.provide(context))
expect(result).toEqual(fixture2Snapshot)
expect(yield* Effect.promise(() => readFile(cacheFile, "utf8"))).toBe(JSON.stringify(fixture2))
expect(cache.values.get(cacheKey)).toMatchObject({ body: JSON.stringify(fixture2) })
const final = yield* Ref.get(state)
expect(final.calls.length).toBe(1)
}),
@@ -247,9 +250,10 @@ describe("ModelsDev Service", () => {
it.live("uses the default models URL when the configured URL is empty", () =>
Effect.gen(function* () {
const cache = makeCache()
const state = yield* Ref.make(initialState)
yield* ModelsDev.Service.use((service) => service.get()).pipe(
Effect.provide(buildLayer(state, { url: "", fetch: true })),
Effect.provide(buildLayer(state, cache, { url: "", fetch: true })),
)
expect((yield* Ref.get(state)).calls[0]?.url).toBe("https://models.opencode.ai/api.json")
}),
@@ -257,32 +261,31 @@ describe("ModelsDev Service", () => {
it.live("get() is single-flight under concurrent calls", () =>
Effect.gen(function* () {
yield* writeCache(fixture)
const cache = makeCache()
const state = yield* Ref.make(initialState)
const results = yield* provided(
state,
Effect.gen(function* () {
const svc = yield* ModelsDev.Service
return yield* Effect.all([svc.get(), svc.get(), svc.get(), svc.get(), svc.get()], {
concurrency: "unbounded",
})
}),
)
const results = yield* Effect.gen(function* () {
const svc = yield* ModelsDev.Service
return yield* Effect.all([svc.get(), svc.get(), svc.get(), svc.get(), svc.get()], {
concurrency: "unbounded",
})
}).pipe(Effect.provide(buildLayer(state, cache, { fetch: true })))
for (const result of results) expect(result).toEqual(fixtureSnapshot)
expect((yield* Ref.get(state)).calls.length).toBe(1)
}),
)
it.live("get() caches across calls (later disk writes are ignored until invalidate)", () =>
it.live("get() caches across calls (later KV writes are ignored until invalidate)", () =>
Effect.gen(function* () {
yield* writeCache(fixture)
const cache = makeCache()
writeCache(cache, fixture)
const state = yield* Ref.make(initialState)
const first = yield* provided(
state,
cache,
Effect.gen(function* () {
const svc = yield* ModelsDev.Service
const a = yield* svc.get()
// mutate disk between calls — cache should mask the change
yield* writeCache(fixture2)
writeCache(cache, fixture2)
const b = yield* svc.get()
return { a, b }
}),
@@ -294,10 +297,12 @@ describe("ModelsDev Service", () => {
it.live("refresh(true) fetches via HttpClient and updates the cache", () =>
Effect.gen(function* () {
yield* writeCache(fixture)
const cache = makeCache()
writeCache(cache, fixture)
const state = yield* Ref.make({ ...initialState, body: JSON.stringify(fixture2) })
const result = yield* provided(
state,
cache,
Effect.gen(function* () {
const svc = yield* ModelsDev.Service
const before = yield* svc.get()
@@ -308,6 +313,7 @@ describe("ModelsDev Service", () => {
)
expect(result.before).toEqual(fixtureSnapshot)
expect(result.after).toEqual(fixture2Snapshot)
expect(cache.values.get(cacheKey)).toMatchObject({ body: JSON.stringify(fixture2) })
const final = yield* Ref.get(state)
expect(final.calls.length).toBe(1)
expect(final.calls[0].url).toContain("/api.json")
@@ -315,13 +321,14 @@ describe("ModelsDev Service", () => {
}),
)
it.live("refresh(false) skips fetch when on-disk file is fresh", () =>
it.live("refresh(false) skips fetch when the KV entry is fresh", () =>
Effect.gen(function* () {
// Fresh: mtime within the 5-minute TTL.
yield* writeCache(fixture, Date.now() - 1000)
const cache = makeCache()
writeCache(cache, fixture, Date.now() - 1000)
const state = yield* Ref.make({ ...initialState, body: JSON.stringify(fixture2) })
yield* provided(
state,
cache,
ModelsDev.Service.use((s) => s.refresh(false)),
)
const final = yield* Ref.get(state)
@@ -329,13 +336,14 @@ describe("ModelsDev Service", () => {
}),
)
it.live("refresh(false) fetches when on-disk file is stale", () =>
it.live("refresh(false) fetches when the KV entry is stale", () =>
Effect.gen(function* () {
// Stale: mtime 10 minutes ago, beyond the 5-minute TTL.
yield* writeCache(fixture, Date.now() - 10 * 60 * 1000)
const cache = makeCache()
writeCache(cache, fixture, Date.now() - 10 * 60 * 1000)
const state = yield* Ref.make({ ...initialState, body: JSON.stringify(fixture2) })
const after = yield* provided(
state,
cache,
Effect.gen(function* () {
const svc = yield* ModelsDev.Service
yield* svc.refresh(false)
@@ -350,10 +358,12 @@ describe("ModelsDev Service", () => {
it.live("refresh swallows HTTP errors and leaves cache intact", () =>
Effect.gen(function* () {
yield* writeCache(fixture)
const cache = makeCache()
writeCache(cache, fixture)
const state = yield* Ref.make({ ...initialState, status: 500, body: "boom" })
const result = yield* provided(
state,
cache,
Effect.gen(function* () {
const svc = yield* ModelsDev.Service
yield* svc.refresh(true)
@@ -19,9 +19,8 @@ V2 loads:
1. The global file at `$XDG_CONFIG_HOME/opencode/AGENTS.md`, normally
`~/.config/opencode/AGENTS.md`.
2. Every `AGENTS.md` from the current Location up to and including the home
directory when the Location is inside it. For Locations outside home, the
scan stops at the project root.
2. Every `AGENTS.md` from the current Location up to and including the project
root.
For example, when the Location is `packages/web`, OpenCode can load all three
project files below:
@@ -36,9 +35,9 @@ my-project/
```
The files are combined rather than selecting a single winner. They are rendered
in this order: global, then files from the Location toward home or the project
in this order: global, then project files from the Location toward the project
root. OpenCode does not resolve conflicts between their contents, so keep broad
guidance global and put scoped guidance in the relevant directory.
guidance global and put scoped guidance in the relevant project directory.
If the Location is outside the project root, only the global file is loaded.
Setting `OPENCODE_DISABLE_PROJECT_CONFIG=1` also skips project `AGENTS.md`
+2 -2
View File
@@ -504,8 +504,8 @@ require a V2 rewrite. See [Skills](/skills).
### Instruction files
Existing `AGENTS.md` files stay in place. V2 discovers the global `~/.config/opencode/AGENTS.md` and ambient `AGENTS.md`
files from the current directory up to home. For projects outside home, discovery stops at the project root.
Existing `AGENTS.md` files stay in place. V2 discovers the global `~/.config/opencode/AGENTS.md` and project `AGENTS.md`
files from the current directory up to the project root.
If a V1 setup relied on a `CLAUDE.md` fallback, move that guidance into the applicable `AGENTS.md`. V2 currently only
discovers `AGENTS.md`; because non-API V1 behavior is intended to remain compatible, also run `/report` with the affected