mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-28 04:26:11 +00:00
Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5e567a18ce |
@@ -36,6 +36,8 @@ type Draft = {
|
||||
|
||||
export interface Interface extends State.Transformable<Draft> {
|
||||
readonly list: () => Effect.Effect<Info[]>
|
||||
/** Schedules daily refresh checks in the Location scope without waiting for Git. */
|
||||
readonly refresh: () => Effect.Effect<void>
|
||||
}
|
||||
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/Reference") {}
|
||||
@@ -48,6 +50,25 @@ const layer = Layer.effect(
|
||||
const cache = yield* RepositoryCache.Service
|
||||
const scope = yield* Scope.Scope
|
||||
const materialized = new Map<string, Info>()
|
||||
const refresh = Effect.fn("Reference.refresh")(function* () {
|
||||
yield* Effect.forEach(
|
||||
Array.from(materialized.values()),
|
||||
(reference) =>
|
||||
Effect.gen(function* () {
|
||||
if (reference.source.type !== "git") return
|
||||
yield* cache.ensure({
|
||||
reference: Repository.parseRemote(reference.source.repository),
|
||||
branch: reference.source.branch,
|
||||
refresh: "daily",
|
||||
})
|
||||
}).pipe(
|
||||
Effect.catchCause((cause) =>
|
||||
Effect.logWarning("failed to materialize reference", { name: reference.name, cause }),
|
||||
),
|
||||
),
|
||||
{ concurrency: 4, discard: true },
|
||||
).pipe(Effect.forkIn(scope), Effect.asVoid)
|
||||
})
|
||||
const state = State.create<Data, Draft>({
|
||||
name: "reference",
|
||||
initial: () => ({ sources: new Map() }),
|
||||
@@ -92,17 +113,8 @@ const layer = Layer.effect(
|
||||
source,
|
||||
}),
|
||||
)
|
||||
yield* cache.ensure({ reference: repository, branch: source.branch, refresh: true }).pipe(
|
||||
Effect.catchCause((cause) =>
|
||||
Effect.logWarning("failed to materialize reference", {
|
||||
name,
|
||||
repository: source.repository,
|
||||
cause,
|
||||
}),
|
||||
),
|
||||
Effect.forkIn(scope),
|
||||
)
|
||||
}
|
||||
yield* refresh()
|
||||
yield* bus.publish(Reference.Event.Updated, {})
|
||||
}),
|
||||
})
|
||||
@@ -110,6 +122,7 @@ const layer = Layer.effect(
|
||||
return Service.of({
|
||||
transform: state.transform,
|
||||
reload: state.reload,
|
||||
refresh,
|
||||
list: Effect.fn("Reference.list")(function* () {
|
||||
return Array.from(materialized.values())
|
||||
}),
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
* observe the checkout move underneath them.
|
||||
*/
|
||||
import path from "path"
|
||||
import { Context, Effect, Layer, Schema } from "effect"
|
||||
import { Clock, Context, Duration, Effect, Layer, Option, Schema } from "effect"
|
||||
import { FSUtil } from "@opencode-ai/util/fs-util"
|
||||
import { Git } from "./git.js"
|
||||
import { Global } from "@opencode-ai/util/global"
|
||||
@@ -14,6 +14,13 @@ import { Repository } from "./repository.js"
|
||||
import { AbsolutePath } from "./schema.js"
|
||||
import { makeGlobalNode } from "@opencode-ai/util/effect/app-node"
|
||||
import { EffectFlock } from "@opencode-ai/util/effect-flock"
|
||||
import { KV } from "./kv.js"
|
||||
|
||||
const Refresh = Schema.Struct({
|
||||
attemptedAt: Schema.Number,
|
||||
refreshedAt: Schema.optionalKey(Schema.Number),
|
||||
})
|
||||
const refreshInterval = Duration.toMillis(Duration.days(1))
|
||||
|
||||
export type Result = {
|
||||
readonly repository: string
|
||||
@@ -27,7 +34,8 @@ export type Result = {
|
||||
|
||||
export type EnsureInput = {
|
||||
readonly reference: Repository.RemoteReference
|
||||
readonly refresh?: boolean
|
||||
/** `daily` throttles existing checkouts; `true` forces a refresh. */
|
||||
readonly refresh?: boolean | "daily"
|
||||
readonly branch?: string
|
||||
}
|
||||
|
||||
@@ -105,122 +113,128 @@ export const validateBranch = Effect.fn("RepositoryCache.validateBranch")(functi
|
||||
})
|
||||
})
|
||||
|
||||
const layer: Layer.Layer<Service, never, FSUtil.Service | Git.Service | EffectFlock.Service | Global.Service> =
|
||||
Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const fs = yield* FSUtil.Service
|
||||
const git = yield* Git.Service
|
||||
const flock = yield* EffectFlock.Service
|
||||
const global = yield* Global.Service
|
||||
const layer = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const fs = yield* FSUtil.Service
|
||||
const git = yield* Git.Service
|
||||
const flock = yield* EffectFlock.Service
|
||||
const global = yield* Global.Service
|
||||
const kv = yield* KV.Service
|
||||
|
||||
return Service.of({
|
||||
ensure: Effect.fn("RepositoryCache.ensure")(function* (input) {
|
||||
if (input.branch) yield* validateBranch(input.branch)
|
||||
return Service.of({
|
||||
ensure: Effect.fn("RepositoryCache.ensure")(function* (input) {
|
||||
if (input.branch) yield* validateBranch(input.branch)
|
||||
|
||||
const repository = input.reference.label
|
||||
const localPath = Repository.cachePath(global.repos, input.reference, input.branch)
|
||||
const cloneTarget = Repository.parse(input.reference.remote) ?? input.reference
|
||||
const repository = input.reference.label
|
||||
const localPath = Repository.cachePath(global.repos, input.reference, input.branch)
|
||||
const key = `repository-cache:${localPath}`
|
||||
const cloneTarget = Repository.parse(input.reference.remote) ?? input.reference
|
||||
|
||||
return yield* flock
|
||||
.withLock(
|
||||
Effect.gen(function* () {
|
||||
yield* cacheOperation(fs.ensureDir(path.dirname(localPath)), "ensure cache directory", localPath)
|
||||
return yield* flock
|
||||
.withLock(
|
||||
Effect.gen(function* () {
|
||||
yield* cacheOperation(fs.ensureDir(path.dirname(localPath)), "ensure cache directory", localPath)
|
||||
|
||||
const existing = yield* git.repo.discover(AbsolutePath.make(localPath))
|
||||
const origin = existing ? yield* git.remote.get(existing) : undefined
|
||||
const originReference = origin ? Repository.parse(origin) : undefined
|
||||
// Discovery walks upward, so an enclosing repository with a
|
||||
// matching origin could masquerade as the cache entry; reuse
|
||||
// requires the checkout to live exactly at the cache path.
|
||||
const worktree = existing ? yield* fs.resolve(localPath) : undefined
|
||||
const reuse = Boolean(
|
||||
existing &&
|
||||
existing.worktree === worktree &&
|
||||
originReference &&
|
||||
Repository.same(originReference, cloneTarget),
|
||||
)
|
||||
if (!reuse && (yield* fs.existsSafe(localPath))) {
|
||||
yield* cacheOperation(fs.remove(localPath, { recursive: true }), "remove stale cache", localPath)
|
||||
}
|
||||
const existing = yield* git.repo.discover(AbsolutePath.make(localPath))
|
||||
const origin = existing ? yield* git.remote.get(existing) : undefined
|
||||
const originReference = origin ? Repository.parse(origin) : undefined
|
||||
// Discovery walks upward, so an enclosing repository with a
|
||||
// matching origin could masquerade as the cache entry; reuse
|
||||
// requires the checkout to live exactly at the cache path.
|
||||
const worktree = existing ? yield* fs.resolve(localPath) : undefined
|
||||
const reuse = Boolean(
|
||||
existing &&
|
||||
existing.worktree === worktree &&
|
||||
originReference &&
|
||||
Repository.same(originReference, cloneTarget),
|
||||
)
|
||||
if (!reuse && (yield* fs.existsSafe(localPath))) {
|
||||
yield* cacheOperation(fs.remove(localPath, { recursive: true }), "remove stale cache", localPath)
|
||||
}
|
||||
|
||||
const status = !reuse
|
||||
? ("cloned" as const)
|
||||
: input.refresh
|
||||
? ("refreshed" as const)
|
||||
: ("cached" as const)
|
||||
const now = yield* Clock.currentTimeMillis
|
||||
const previous = Option.getOrUndefined(Schema.decodeUnknownOption(Refresh)(yield* kv.get(key)))
|
||||
const refresh =
|
||||
input.refresh === "daily" ? !previous || now - previous.attemptedAt >= refreshInterval : input.refresh
|
||||
const status = !reuse ? ("cloned" as const) : refresh ? ("refreshed" as const) : ("cached" as const)
|
||||
|
||||
if (status === "cloned") {
|
||||
yield* git.repo
|
||||
.clone({
|
||||
remote: input.reference.remote,
|
||||
directory: AbsolutePath.make(localPath),
|
||||
branch: input.branch,
|
||||
})
|
||||
.pipe(Effect.mapError((error) => new CloneFailedError({ repository, message: error.message })))
|
||||
}
|
||||
// Record attempts before network work so offline/auth failures don't retry on every prompt.
|
||||
if (status !== "cached") yield* kv.set(key, { ...previous, attemptedAt: now })
|
||||
|
||||
if (status === "refreshed") {
|
||||
if (!existing)
|
||||
return yield* new FetchFailedError({ repository, message: "Repository is unavailable" })
|
||||
if (status === "cloned") {
|
||||
yield* git.repo
|
||||
.clone({
|
||||
remote: input.reference.remote,
|
||||
directory: AbsolutePath.make(localPath),
|
||||
branch: input.branch,
|
||||
})
|
||||
.pipe(Effect.mapError((error) => new CloneFailedError({ repository, message: error.message })))
|
||||
}
|
||||
|
||||
if (status === "refreshed") {
|
||||
if (!existing) return yield* new FetchFailedError({ repository, message: "Repository is unavailable" })
|
||||
yield* git.sync
|
||||
.fetchRemotes(existing)
|
||||
.pipe(Effect.mapError((error) => new FetchFailedError({ repository, message: error.message })))
|
||||
|
||||
if (input.branch) {
|
||||
yield* git.sync
|
||||
.fetchRemotes(existing)
|
||||
.fetchBranch(existing, { branch: input.branch })
|
||||
.pipe(Effect.mapError((error) => new FetchFailedError({ repository, message: error.message })))
|
||||
|
||||
if (input.branch) {
|
||||
yield* git.sync
|
||||
.fetchBranch(existing, { branch: input.branch })
|
||||
.pipe(Effect.mapError((error) => new FetchFailedError({ repository, message: error.message })))
|
||||
}
|
||||
|
||||
// Checking out the tracked ref before resetting keeps the
|
||||
// checkout self-healing even if it was left on another
|
||||
// branch.
|
||||
const branch = input.branch ?? (yield* git.history.defaultRemoteBranch(existing))
|
||||
if (branch) {
|
||||
yield* git.sync
|
||||
.checkoutRemoteBranch(existing, { branch })
|
||||
.pipe(
|
||||
Effect.mapError(
|
||||
(error) => new CheckoutFailedError({ repository, branch, message: error.message }),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
const target = branch ?? (yield* git.history.branch(existing))
|
||||
yield* git.sync
|
||||
.resetHard(existing, target ? `origin/${target}` : "HEAD")
|
||||
.pipe(Effect.mapError((error) => new ResetFailedError({ repository, message: error.message })))
|
||||
}
|
||||
|
||||
const checkout = yield* git.repo.discover(AbsolutePath.make(localPath))
|
||||
// Checking out the tracked ref before resetting keeps the
|
||||
// checkout self-healing even if it was left on another
|
||||
// branch.
|
||||
const branch = input.branch ?? (yield* git.history.defaultRemoteBranch(existing))
|
||||
if (branch) {
|
||||
yield* git.sync
|
||||
.checkoutRemoteBranch(existing, { branch })
|
||||
.pipe(
|
||||
Effect.mapError(
|
||||
(error) => new CheckoutFailedError({ repository, branch, message: error.message }),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
return {
|
||||
repository,
|
||||
host: input.reference.host,
|
||||
remote: input.reference.remote,
|
||||
localPath,
|
||||
status,
|
||||
head: checkout ? yield* git.history.head(checkout) : undefined,
|
||||
branch: checkout ? yield* git.history.branch(checkout) : undefined,
|
||||
} satisfies Result
|
||||
}),
|
||||
`repository-cache:${localPath}`,
|
||||
)
|
||||
.pipe(
|
||||
Effect.mapError((error) =>
|
||||
isError(error) ? error : new LockFailedError({ localPath, message: errorMessage(error) }),
|
||||
),
|
||||
)
|
||||
}),
|
||||
})
|
||||
}),
|
||||
)
|
||||
const target = branch ?? (yield* git.history.branch(existing))
|
||||
yield* git.sync
|
||||
.resetHard(existing, target ? `origin/${target}` : "HEAD")
|
||||
.pipe(Effect.mapError((error) => new ResetFailedError({ repository, message: error.message })))
|
||||
}
|
||||
|
||||
if (status !== "cached")
|
||||
yield* kv.set(key, { attemptedAt: now, refreshedAt: yield* Clock.currentTimeMillis })
|
||||
|
||||
const checkout = yield* git.repo.discover(AbsolutePath.make(localPath))
|
||||
|
||||
return {
|
||||
repository,
|
||||
host: input.reference.host,
|
||||
remote: input.reference.remote,
|
||||
localPath,
|
||||
status,
|
||||
head: checkout ? yield* git.history.head(checkout) : undefined,
|
||||
branch: checkout ? yield* git.history.branch(checkout) : undefined,
|
||||
} satisfies Result
|
||||
}),
|
||||
key,
|
||||
)
|
||||
.pipe(
|
||||
Effect.mapError((error) =>
|
||||
isError(error) ? error : new LockFailedError({ localPath, message: errorMessage(error) }),
|
||||
),
|
||||
)
|
||||
}),
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
export const node = makeGlobalNode({
|
||||
service: Service,
|
||||
layer,
|
||||
deps: [EffectFlock.node, FSUtil.node, Git.node, Global.node],
|
||||
deps: [EffectFlock.node, FSUtil.node, Git.node, Global.node, KV.node],
|
||||
})
|
||||
|
||||
function errorMessage(error: unknown) {
|
||||
|
||||
@@ -56,6 +56,7 @@ import { fileURLToPath } from "url"
|
||||
import { SessionEnvironment } from "./session/environment.js"
|
||||
import { SessionHistory } from "./session/history.js"
|
||||
import { InstructionEntry } from "./session/instruction-entry.js"
|
||||
import { Reference } from "./reference.js"
|
||||
|
||||
// get project -> project.locations
|
||||
//
|
||||
@@ -660,11 +661,14 @@ const layer = Layer.effect(
|
||||
)
|
||||
// Commit a staged revert only after preparation succeeds, before admitting new work.
|
||||
if (session.revert) yield* SessionRevert.commit(session).pipe(Effect.provideService(Bus.Service, bus))
|
||||
return yield* SessionInbox.admit(db, bus, {
|
||||
const admitted = yield* SessionInbox.admit(db, bus, {
|
||||
id: messageID,
|
||||
sessionID: session.id,
|
||||
item,
|
||||
})
|
||||
const references = yield* Reference.Service.pipe(Effect.provide(locations.get(session.location)))
|
||||
yield* references.refresh()
|
||||
return admitted
|
||||
}).pipe(
|
||||
Effect.catchDefect((defect) =>
|
||||
defect instanceof SessionInbox.LifecycleConflict
|
||||
|
||||
@@ -2,17 +2,19 @@ import { LocationServiceMap } from "@opencode-ai/core/location-service-map"
|
||||
import type { LocationServices } from "@opencode-ai/core/location-services"
|
||||
import { PluginHooks } from "@opencode-ai/core/plugin/hooks"
|
||||
import { PluginSupervisor } from "@opencode-ai/core/plugin/supervisor-service"
|
||||
import { Reference } from "@opencode-ai/core/reference"
|
||||
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
|
||||
import { Effect, Layer, LayerMap } from "effect"
|
||||
|
||||
// Plain-prompt unit fixtures use virtual directories and need only the admission hook services.
|
||||
// Plain-prompt unit fixtures use virtual directories without configured references.
|
||||
export const promptLocationLayer = Layer.effect(
|
||||
LocationServiceMap.Service,
|
||||
LayerMap.make(
|
||||
() =>
|
||||
Layer.merge(
|
||||
Layer.mergeAll(
|
||||
LayerNode.compile(PluginHooks.node),
|
||||
Layer.succeed(PluginSupervisor.Service, { flush: Effect.void }),
|
||||
Layer.mock(Reference.Service, { refresh: () => Effect.void }),
|
||||
) as Layer.Layer<LocationServices>,
|
||||
),
|
||||
)
|
||||
|
||||
@@ -2,19 +2,121 @@ import { describe, expect } from "bun:test"
|
||||
import fs from "fs/promises"
|
||||
import path from "path"
|
||||
import { pathToFileURL } from "url"
|
||||
import { Effect, Layer } from "effect"
|
||||
import { Clock, Duration, Effect, Layer, Schema } from "effect"
|
||||
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
||||
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
|
||||
import { Global } from "@opencode-ai/util/global"
|
||||
import { Repository } from "@opencode-ai/core/repository"
|
||||
import { RepositoryCache } from "@opencode-ai/core/repository-cache"
|
||||
import { branch, git, gitRemote } from "./fixture/git"
|
||||
import { Database } from "@opencode-ai/core/database/database"
|
||||
import { KV } from "@opencode-ai/core/kv"
|
||||
import { branch, commit, git, gitRemote } from "./fixture/git"
|
||||
import { tmpdir } from "./fixture/tmpdir"
|
||||
import { testEffect } from "./lib/effect"
|
||||
|
||||
const it = testEffect(Layer.empty)
|
||||
|
||||
describe("RepositoryCache", () => {
|
||||
it.live("persists the daily throttle across cache recreation and serializes competing refreshes", () =>
|
||||
withRemote((fixture) =>
|
||||
Effect.gen(function* () {
|
||||
const initial = yield* Effect.gen(function* () {
|
||||
const cache = yield* RepositoryCache.Service
|
||||
return yield* cache.ensure({ reference: fixture.reference, refresh: "daily" })
|
||||
}).pipe(Effect.provide(cacheLayer(fixture.root)))
|
||||
expect(initial.status).toBe("cloned")
|
||||
yield* Effect.promise(() => commit(fixture.source, "two\n", "advance main"))
|
||||
|
||||
yield* Effect.gen(function* () {
|
||||
const cache = yield* RepositoryCache.Service
|
||||
const kv = yield* KV.Service
|
||||
expect((yield* cache.ensure({ reference: fixture.reference, refresh: "daily" })).status).toBe("cached")
|
||||
expect(yield* read(path.join(initial.localPath, "README.md"))).toBe("one\n")
|
||||
const yesterday = (yield* Clock.currentTimeMillis) - Duration.toMillis(Duration.days(1))
|
||||
yield* kv.set(`repository-cache:${initial.localPath}`, { attemptedAt: yesterday, refreshedAt: yesterday })
|
||||
}).pipe(Effect.provide(cacheLayer(fixture.root)))
|
||||
|
||||
const results = yield* Effect.all(
|
||||
[0, 1].map(() =>
|
||||
Effect.gen(function* () {
|
||||
const cache = yield* RepositoryCache.Service
|
||||
return yield* cache.ensure({ reference: fixture.reference, refresh: "daily" })
|
||||
}).pipe(Effect.provide(cacheLayer(fixture.root))),
|
||||
),
|
||||
{ concurrency: "unbounded" },
|
||||
)
|
||||
expect(results.map((result) => result.status).toSorted()).toEqual(["cached", "refreshed"])
|
||||
expect(yield* read(path.join(initial.localPath, "README.md"))).toBe("two\n")
|
||||
|
||||
// A missing checkout must be recreated even when the persisted timestamp is recent.
|
||||
yield* Effect.promise(() => fs.rm(initial.localPath, { recursive: true }))
|
||||
yield* Effect.gen(function* () {
|
||||
const cache = yield* RepositoryCache.Service
|
||||
expect((yield* cache.ensure({ reference: fixture.reference, refresh: "daily" })).status).toBe("cloned")
|
||||
}).pipe(Effect.provide(cacheLayer(fixture.root)))
|
||||
expect(yield* read(path.join(initial.localPath, "README.md"))).toBe("two\n")
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
it.live("throttles failed refresh attempts without marking them successful", () =>
|
||||
withRemote((fixture) =>
|
||||
Effect.gen(function* () {
|
||||
const cache = yield* RepositoryCache.Service
|
||||
const kv = yield* KV.Service
|
||||
const initial = yield* cache.ensure({ reference: fixture.reference, refresh: "daily" })
|
||||
const key = `repository-cache:${initial.localPath}`
|
||||
const yesterday = (yield* Clock.currentTimeMillis) - Duration.toMillis(Duration.days(1))
|
||||
yield* kv.set(key, { attemptedAt: yesterday, refreshedAt: yesterday })
|
||||
yield* Effect.promise(() =>
|
||||
fs.rename(path.join(fixture.root, "origin.git"), path.join(fixture.root, "offline.git")),
|
||||
)
|
||||
|
||||
const error = yield* Effect.flip(cache.ensure({ reference: fixture.reference, refresh: "daily" }))
|
||||
expect(error).toBeInstanceOf(RepositoryCache.FetchFailedError)
|
||||
const stored = yield* kv.get(key)
|
||||
const stamp = Schema.decodeUnknownSync(
|
||||
Schema.Struct({ attemptedAt: Schema.Number, refreshedAt: Schema.Number }),
|
||||
)(stored)
|
||||
expect(stamp.attemptedAt).toBeGreaterThan(yesterday)
|
||||
expect(stamp.refreshedAt).toBe(yesterday)
|
||||
expect((yield* cache.ensure({ reference: fixture.reference, refresh: "daily" })).status).toBe("cached")
|
||||
expect(yield* kv.get(key)).toEqual(stored)
|
||||
expect(yield* read(path.join(initial.localPath, "README.md"))).toBe("one\n")
|
||||
|
||||
yield* Effect.promise(async () => {
|
||||
await fs.rename(path.join(fixture.root, "offline.git"), path.join(fixture.root, "origin.git"))
|
||||
await commit(fixture.source, "two\n", "advance main")
|
||||
})
|
||||
yield* kv.set(key, { attemptedAt: yesterday, refreshedAt: yesterday })
|
||||
expect((yield* cache.ensure({ reference: fixture.reference, refresh: "daily" })).status).toBe("refreshed")
|
||||
expect(yield* read(path.join(initial.localPath, "README.md"))).toBe("two\n")
|
||||
expect(yield* kv.get(key)).not.toEqual(stored)
|
||||
}).pipe(Effect.provide(cacheLayer(fixture.root))),
|
||||
),
|
||||
)
|
||||
|
||||
it.live("refreshes existing untracked checkouts and keeps branch freshness independent", () =>
|
||||
withRemote((fixture) =>
|
||||
Effect.gen(function* () {
|
||||
const cache = yield* RepositoryCache.Service
|
||||
const kv = yield* KV.Service
|
||||
yield* Effect.promise(() => branch(fixture.source, "feature", "feature\n"))
|
||||
const main = yield* cache.ensure({ reference: fixture.reference, refresh: "daily" })
|
||||
const feature = yield* cache.ensure({ reference: fixture.reference, branch: "feature", refresh: "daily" })
|
||||
yield* kv.remove(`repository-cache:${feature.localPath}`)
|
||||
yield* Effect.promise(() => commit(fixture.source, "new feature\n", "advance feature"))
|
||||
|
||||
expect((yield* cache.ensure({ reference: fixture.reference, refresh: "daily" })).status).toBe("cached")
|
||||
expect(
|
||||
(yield* cache.ensure({ reference: fixture.reference, branch: "feature", refresh: "daily" })).status,
|
||||
).toBe("refreshed")
|
||||
expect(yield* read(path.join(main.localPath, "README.md"))).toBe("one\n")
|
||||
expect(yield* read(path.join(feature.localPath, "README.md"))).toBe("new feature\n")
|
||||
}).pipe(Effect.provide(cacheLayer(fixture.root))),
|
||||
),
|
||||
)
|
||||
|
||||
it.live("replaces a stale cache directory before cloning", () =>
|
||||
withRemote((fixture) =>
|
||||
Effect.gen(function* () {
|
||||
@@ -120,8 +222,9 @@ describe("RepositoryCache", () => {
|
||||
})
|
||||
|
||||
function cacheLayer(root: string) {
|
||||
return AppNodeBuilder.build(RepositoryCache.node, [
|
||||
return AppNodeBuilder.build(LayerNode.group([RepositoryCache.node, KV.node]), [
|
||||
[Global.node, Global.layerWith({ state: path.join(root, "state"), repos: path.join(root, "repos") })],
|
||||
[Database.node, Database.configured({ path: path.join(root, "cache.sqlite") })],
|
||||
])
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, expect } from "bun:test"
|
||||
import { DateTime, Effect, Fiber, Layer, LayerMap, Schema, Stream } from "effect"
|
||||
import { mkdtemp, rm } from "fs/promises"
|
||||
import { Clock, DateTime, Duration, Effect, Fiber, Layer, LayerMap, Queue, Schema, Stream } from "effect"
|
||||
import { mkdir, mkdtemp, rm, symlink } from "fs/promises"
|
||||
import { tmpdir } from "os"
|
||||
import path from "path"
|
||||
import { pathToFileURL } from "url"
|
||||
@@ -31,6 +31,12 @@ import { PluginSupervisor } from "@opencode-ai/core/plugin/supervisor"
|
||||
import { PluginHooks } from "@opencode-ai/core/plugin/hooks"
|
||||
import { Snapshot } from "@opencode-ai/core/snapshot"
|
||||
import { testEffect } from "./lib/effect"
|
||||
import { Reference } from "@opencode-ai/core/reference"
|
||||
import { RepositoryCache } from "@opencode-ai/core/repository-cache"
|
||||
import { Global } from "@opencode-ai/util/global"
|
||||
import { EffectFlock } from "@opencode-ai/util/effect-flock"
|
||||
import { KV } from "@opencode-ai/core/kv"
|
||||
import { gitRemote, git, commit } from "./fixture/git"
|
||||
|
||||
const executionCalls: Session.ID[] = []
|
||||
const interruptCalls: Session.ID[] = []
|
||||
@@ -58,48 +64,51 @@ const execution = Layer.succeed(
|
||||
awaitIdle: () => Effect.void,
|
||||
}),
|
||||
)
|
||||
const locations = Layer.effect(
|
||||
LocationServiceMap.Service,
|
||||
LayerMap.make(
|
||||
() =>
|
||||
// These operations resolve Location services lazily and must wait for plugin-projected state.
|
||||
// oxlint-disable-next-line typescript-eslint/no-unsafe-type-assertion
|
||||
Layer.unwrap(
|
||||
Effect.sync(() => {
|
||||
let ready = false
|
||||
return Layer.mergeAll(
|
||||
LayerNode.compile(PluginHooks.node),
|
||||
Layer.mock(Image.Service, {
|
||||
normalize: (_resource, content) =>
|
||||
ready
|
||||
? Effect.succeed(content.content.length > 5 * 1024 * 1024 ? { ...content, content: "AA==" } : content)
|
||||
: Effect.die(new Error("Image service used before plugins were ready")),
|
||||
}),
|
||||
Layer.mock(Snapshot.Service, {
|
||||
capture: () =>
|
||||
ready ? Effect.undefined : Effect.die(new Error("Snapshot used before plugins were ready")),
|
||||
restore: () =>
|
||||
ready ? Effect.void : Effect.die(new Error("Snapshot used before plugins were ready")),
|
||||
}),
|
||||
Layer.succeed(
|
||||
PluginSupervisor.Service,
|
||||
PluginSupervisor.Service.of({ flush: Effect.sync(() => (ready = true)) }),
|
||||
),
|
||||
)
|
||||
}),
|
||||
) as unknown as Layer.Layer<LocationServices>,
|
||||
),
|
||||
)
|
||||
const it = testEffect(
|
||||
const locations = (references: Layer.Layer<Reference.Service>) =>
|
||||
Layer.effect(
|
||||
LocationServiceMap.Service,
|
||||
LayerMap.make(
|
||||
() =>
|
||||
// These operations resolve Location services lazily and must wait for plugin-projected state.
|
||||
// oxlint-disable-next-line typescript-eslint/no-unsafe-type-assertion
|
||||
Layer.unwrap(
|
||||
Effect.sync(() => {
|
||||
let ready = false
|
||||
return Layer.mergeAll(
|
||||
references,
|
||||
LayerNode.compile(PluginHooks.node),
|
||||
Layer.mock(Image.Service, {
|
||||
normalize: (_resource, content) =>
|
||||
ready
|
||||
? Effect.succeed(
|
||||
content.content.length > 5 * 1024 * 1024 ? { ...content, content: "AA==" } : content,
|
||||
)
|
||||
: Effect.die(new Error("Image service used before plugins were ready")),
|
||||
}),
|
||||
Layer.mock(Snapshot.Service, {
|
||||
capture: () =>
|
||||
ready ? Effect.undefined : Effect.die(new Error("Snapshot used before plugins were ready")),
|
||||
restore: () => (ready ? Effect.void : Effect.die(new Error("Snapshot used before plugins were ready"))),
|
||||
}),
|
||||
Layer.succeed(
|
||||
PluginSupervisor.Service,
|
||||
PluginSupervisor.Service.of({ flush: Effect.sync(() => (ready = true)) }),
|
||||
),
|
||||
)
|
||||
}),
|
||||
) as unknown as Layer.Layer<LocationServices>,
|
||||
),
|
||||
)
|
||||
const sessionLayer = (references = Layer.mock(Reference.Service, { refresh: () => Effect.void })) =>
|
||||
AppNodeBuilder.build(
|
||||
LayerNode.group([Database.node, Bus.node, SessionProjector.node, SessionStore.node, Session.node]),
|
||||
[
|
||||
[Bus.node, Bus.configured({ persist: true })],
|
||||
[SessionExecution.node, execution],
|
||||
[LocationServiceMap.node, locations],
|
||||
[LocationServiceMap.node, locations(references)],
|
||||
],
|
||||
),
|
||||
)
|
||||
)
|
||||
const it = testEffect(sessionLayer())
|
||||
const sessionID = Session.ID.make("ses_prompt_test")
|
||||
const messageID = SessionMessage.ID.create()
|
||||
|
||||
@@ -170,6 +179,118 @@ const assistantRow = (id: SessionMessage.ID, seq: number) => {
|
||||
}
|
||||
|
||||
describe("Session.prompt", () => {
|
||||
it.live("refreshes stale references after admission without blocking the prompt (#45562)", () =>
|
||||
Effect.gen(function* () {
|
||||
const root = yield* Effect.acquireRelease(
|
||||
Effect.promise(() => mkdtemp(path.join(tmpdir(), "reference-refresh-"))),
|
||||
(root) => Effect.promise(() => rm(root, { recursive: true, force: true })),
|
||||
)
|
||||
const fixture = yield* Effect.promise(() => gitRemote(root))
|
||||
yield* Effect.promise(async () => {
|
||||
await mkdir(path.join(root, "owner"))
|
||||
await symlink(path.join(root, "origin.git"), path.join(root, "owner", "repo.git"))
|
||||
})
|
||||
const previous = process.env.OPENCODE_REPO_CLONE_GITHUB_BASE_URL
|
||||
process.env.OPENCODE_REPO_CLONE_GITHUB_BASE_URL = pathToFileURL(root + "/").href
|
||||
yield* Effect.addFinalizer(() =>
|
||||
Effect.sync(() => {
|
||||
if (previous === undefined) delete process.env.OPENCODE_REPO_CLONE_GITHUB_BASE_URL
|
||||
else process.env.OPENCODE_REPO_CLONE_GITHUB_BASE_URL = previous
|
||||
}),
|
||||
)
|
||||
yield* Effect.gen(function* () {
|
||||
const cache = yield* RepositoryCache.Service
|
||||
const kv = yield* KV.Service
|
||||
const flock = yield* EffectFlock.Service
|
||||
const completed = yield* Queue.unbounded<void>()
|
||||
yield* Effect.gen(function* () {
|
||||
const references = yield* Reference.Service
|
||||
yield* references.transform((editor) =>
|
||||
editor.add("example", Reference.GitSource.make({ type: "git", repository: "owner/repo", branch: "main" })),
|
||||
)
|
||||
yield* Queue.take(completed).pipe(Effect.timeout("5 seconds"))
|
||||
const initial = (yield* references.list())[0]
|
||||
expect(yield* Effect.promise(() => Bun.file(path.join(initial.path, "README.md")).text())).toBe("one\n")
|
||||
yield* Effect.promise(async () => {
|
||||
await Bun.write(path.join(fixture.source, "new-file.txt"), "new\n")
|
||||
await git(fixture.source, "add", "new-file.txt")
|
||||
await commit(fixture.source, "two\n", "advance main")
|
||||
})
|
||||
yield* Effect.gen(function* () {
|
||||
yield* setup
|
||||
const session = yield* Session.Service
|
||||
const database = yield* Database.Service
|
||||
const attach = () =>
|
||||
session.prompt({
|
||||
sessionID,
|
||||
text: "Inspect @example",
|
||||
files: [
|
||||
{ uri: pathToFileURL(initial.path).href, name: initial.name },
|
||||
{ uri: pathToFileURL(path.join(initial.path, "README.md")).href, name: "README.md" },
|
||||
],
|
||||
resume: false,
|
||||
})
|
||||
// A same-day prompt and config reload must keep the existing checkout.
|
||||
const cached = yield* attach()
|
||||
yield* Queue.take(completed).pipe(Effect.timeout("2 seconds"))
|
||||
yield* references.reload()
|
||||
yield* Queue.take(completed).pipe(Effect.timeout("2 seconds"))
|
||||
expect(cached.payload.files?.map((file) => Buffer.from(file.data, "base64").toString("utf8"))).toEqual([
|
||||
".git/\nREADME.md",
|
||||
"one\n",
|
||||
])
|
||||
expect(yield* Effect.promise(() => Bun.file(path.join(initial.path, "README.md")).text())).toBe("one\n")
|
||||
|
||||
const key = `repository-cache:${initial.path}`
|
||||
const yesterday = (yield* Clock.currentTimeMillis) - Duration.toMillis(Duration.days(1))
|
||||
yield* kv.set(key, { attemptedAt: yesterday, refreshedAt: yesterday })
|
||||
const admitted = yield* Effect.gen(function* () {
|
||||
// Hold the checkout lock to prove admission doesn't wait for Git.
|
||||
yield* flock.acquire(key)
|
||||
const message = yield* attach().pipe(Effect.scoped, Effect.timeout("2 seconds"))
|
||||
expect(yield* SessionInbox.find(database.db, message.id)).toBeDefined()
|
||||
expect(message.payload.files?.map((file) => Buffer.from(file.data, "base64").toString("utf8"))).toEqual([
|
||||
".git/\nREADME.md",
|
||||
"one\n",
|
||||
])
|
||||
return message
|
||||
}).pipe(Effect.scoped)
|
||||
|
||||
// The background refresh survives the submitting request's scope.
|
||||
yield* Queue.take(completed).pipe(Effect.timeout("5 seconds"))
|
||||
const reloaded = yield* attach()
|
||||
expect(reloaded.payload.files?.map((file) => Buffer.from(file.data, "base64").toString("utf8"))).toEqual([
|
||||
".git/\nnew-file.txt\nREADME.md",
|
||||
"two\n",
|
||||
])
|
||||
expect(
|
||||
(yield* session.prompt({ sessionID, id: admitted.id, text: "retry", resume: false })).payload,
|
||||
).toEqual(admitted.payload)
|
||||
}).pipe(Effect.provide(sessionLayer(Layer.succeed(Reference.Service, references)).pipe(Layer.fresh)))
|
||||
}).pipe(
|
||||
Effect.provide(
|
||||
AppNodeBuilder.build(Reference.node, [
|
||||
[Global.node, Global.layerWith({ state: path.join(root, "state"), repos: path.join(root, "repos") })],
|
||||
[
|
||||
RepositoryCache.node,
|
||||
Layer.succeed(RepositoryCache.Service, {
|
||||
ensure: (input) => cache.ensure(input).pipe(Effect.tap(() => Queue.offer(completed, undefined))),
|
||||
}),
|
||||
],
|
||||
]),
|
||||
),
|
||||
)
|
||||
}).pipe(
|
||||
Effect.scoped,
|
||||
Effect.provide(
|
||||
AppNodeBuilder.build(LayerNode.group([RepositoryCache.node, KV.node, EffectFlock.node]), [
|
||||
[Global.node, Global.layerWith({ state: path.join(root, "state"), repos: path.join(root, "repos") })],
|
||||
]),
|
||||
),
|
||||
)
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("exposes the execution registry", () =>
|
||||
Effect.gen(function* () {
|
||||
activeSessions.add(sessionID)
|
||||
|
||||
@@ -19,6 +19,7 @@ import { SessionProjector } from "@opencode-ai/core/session/projector"
|
||||
import { SessionStore } from "@opencode-ai/core/session/store"
|
||||
import { SessionInbox } from "@opencode-ai/core/session/inbox"
|
||||
import { Skill } from "@opencode-ai/core/skill"
|
||||
import { Reference } from "@opencode-ai/core/reference"
|
||||
import { testEffect } from "./lib/effect"
|
||||
|
||||
const location = Location.Ref.make({ directory: AbsolutePath.make("/project") })
|
||||
@@ -33,6 +34,7 @@ const info = Skill.Info.make({
|
||||
content: "Use Effect",
|
||||
})
|
||||
const skills = Layer.mergeAll(
|
||||
Layer.mock(Reference.Service, { refresh: () => Effect.void }),
|
||||
LayerNode.compile(PluginHooks.node),
|
||||
Layer.mock(Skill.Service, {
|
||||
get: (id) => Effect.succeed(id === info.id ? info : undefined),
|
||||
|
||||
@@ -98,23 +98,36 @@ Git references also support shorthand:
|
||||
|
||||
### Cloning and storage
|
||||
|
||||
OpenCode normalizes a remote and stores one checkout under its global data
|
||||
directory at `opencode/repos/<host>/<repository-path>`. On a typical Linux
|
||||
OpenCode normalizes a remote and stores one checkout per remote and branch
|
||||
under its global data directory. Without an explicit branch, the checkout is
|
||||
stored at `opencode/repos/<host>/<repository-path>`. On a typical Linux
|
||||
installation, for example, `Effect-TS/effect` is stored at:
|
||||
|
||||
```text
|
||||
~/.local/share/opencode/repos/github.com/Effect-TS/effect
|
||||
```
|
||||
|
||||
Missing repositories are cloned. Existing checkouts are fetched and reset to
|
||||
the requested branch, or to the remote default branch when `branch` is omitted.
|
||||
Materialization runs asynchronously when references load or reload, so a new
|
||||
reference can appear before its checkout is ready. Clone and refresh failures
|
||||
are logged and do not stop other references from loading.
|
||||
An explicit branch adds an encoded `@<branch>` suffix to the checkout path.
|
||||
|
||||
Missing repositories are cloned. When references load or reload, and after a
|
||||
user prompt is admitted in their Location, OpenCode checks them in the
|
||||
background. Existing checkouts are eligible for one automatic refresh attempt
|
||||
every 24 hours. A refresh fetches and resets to the requested branch, or to the
|
||||
remote default branch when `branch` is omitted.
|
||||
|
||||
Refresh timestamps persist across service restarts and are shared by Locations
|
||||
using the same checkout. Failed refresh attempts are logged and remain subject
|
||||
to the 24-hour limit. There is no periodic polling while a Location is unused.
|
||||
|
||||
Prompts do not wait for background refreshes. An attachment can therefore
|
||||
contain older content even if a later tool read sees the updated checkout.
|
||||
Initial cloning is also asynchronous, so a new reference can appear before its
|
||||
checkout is ready. Clone and refresh failures do not stop other references
|
||||
from loading.
|
||||
|
||||
<Callout type="warning">
|
||||
The cache has one checkout per normalized remote, not one per branch. Do not configure the same repository at multiple
|
||||
branches; only one branch can be exposed. Avoid editing cached checkouts because a refresh resets them.
|
||||
Cached checkouts are shared and can update while an agent is using them. Avoid editing cached checkouts because a
|
||||
refresh resets them.
|
||||
</Callout>
|
||||
|
||||
## Description and visibility
|
||||
|
||||
Reference in New Issue
Block a user