feat(plugin): add vcs backend registration api
This commit is contained in:
parent
38502f7268
commit
66878b4c53
12 changed files with 309 additions and 8 deletions
|
|
@ -47,6 +47,7 @@ import { ReadToolFileSystem } from "./tool/read-filesystem"
|
|||
import { ToolRegistry } from "./tool/registry"
|
||||
import { ToolOutputStore } from "./tool-output-store"
|
||||
import { Vcs } from "./vcs"
|
||||
import { VcsBackends } from "./vcs/backends"
|
||||
|
||||
export { LocationServiceMap } from "./location-service-map"
|
||||
|
||||
|
|
@ -97,6 +98,7 @@ export const locationServices = LayerNode.group([
|
|||
SessionTitle.node,
|
||||
Snapshot.node,
|
||||
SessionRunnerLLM.node,
|
||||
VcsBackends.node,
|
||||
Vcs.node,
|
||||
])
|
||||
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@ import { SkillV2 } from "./skill"
|
|||
import { State } from "./state"
|
||||
import { ToolRegistry } from "./tool/registry"
|
||||
import { ToolHooks } from "./tool/hooks"
|
||||
import { VcsBackends } from "./vcs/backends"
|
||||
|
||||
export const ID = Plugin.ID
|
||||
export type ID = typeof ID.Type
|
||||
|
|
@ -167,6 +168,7 @@ export const node = makeLocationNode({
|
|||
SkillV2.node,
|
||||
ToolRegistry.toolsNode,
|
||||
ToolHooks.node,
|
||||
VcsBackends.node,
|
||||
PluginRuntime.node,
|
||||
],
|
||||
})
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@ import { AbsolutePath, type DeepMutable } from "../schema"
|
|||
import { SkillV2 } from "../skill"
|
||||
import { Tools } from "../tool/tools"
|
||||
import { ToolHooks } from "../tool/hooks"
|
||||
import { VcsBackends } from "../vcs/backends"
|
||||
import { WorkspaceV2 } from "../workspace"
|
||||
|
||||
const mutable = <T>(value: T) => value as DeepMutable<T>
|
||||
|
|
@ -33,6 +34,7 @@ export const make = Effect.fn("PluginHost.make")(function* (plugin: PluginV2.Int
|
|||
const skill = yield* SkillV2.Service
|
||||
const tools = yield* Tools.Service
|
||||
const toolHooks = yield* ToolHooks.Service
|
||||
const vcsBackends = yield* VcsBackends.Service
|
||||
const runtime = yield* PluginRuntime.Service
|
||||
const locationInfo = () =>
|
||||
new Location.Info({
|
||||
|
|
@ -291,6 +293,9 @@ export const make = Effect.fn("PluginHost.make")(function* (plugin: PluginV2.Int
|
|||
}),
|
||||
},
|
||||
},
|
||||
vcs: {
|
||||
register: (backend) => vcsBackends.register(backend),
|
||||
},
|
||||
session: {
|
||||
create: (input) =>
|
||||
runtime.session.create({
|
||||
|
|
|
|||
|
|
@ -33,6 +33,7 @@ import { SkillV2 } from "../skill"
|
|||
import { State } from "../state"
|
||||
import { ToolRegistry } from "../tool/registry"
|
||||
import { Tools } from "../tool/tools"
|
||||
import { VcsBackends } from "../vcs/backends"
|
||||
import { HttpClient } from "effect/unstable/http"
|
||||
import { AgentPlugin } from "./agent"
|
||||
import { CommandPlugin } from "./command"
|
||||
|
|
@ -67,6 +68,7 @@ export type Requirements =
|
|||
| Shell.Service
|
||||
| SkillV2.Service
|
||||
| Tools.Service
|
||||
| VcsBackends.Service
|
||||
|
||||
export interface Plugin<R = never> {
|
||||
readonly id: string
|
||||
|
|
@ -102,6 +104,7 @@ const layer = Layer.effectDiscard(
|
|||
Context.make(Ripgrep.Service, yield* Ripgrep.Service),
|
||||
Context.make(Shell.Service, yield* Shell.Service),
|
||||
Context.make(Tools.Service, yield* Tools.Service),
|
||||
Context.make(VcsBackends.Service, yield* VcsBackends.Service),
|
||||
Context.make(PluginRuntime.Service, yield* PluginRuntime.Service),
|
||||
)
|
||||
const add = (input: Plugin<Requirements | Scope.Scope>) =>
|
||||
|
|
@ -158,6 +161,7 @@ export const node = makeLocationNode({
|
|||
Ripgrep.node,
|
||||
Shell.node,
|
||||
ToolRegistry.toolsNode,
|
||||
VcsBackends.node,
|
||||
PluginRuntime.node,
|
||||
SdkPlugins.node,
|
||||
],
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import { makeLocationNode } from "./effect/app-node"
|
|||
import { FSUtil } from "./fs-util"
|
||||
import { Location } from "./location"
|
||||
import { AppProcess } from "./process"
|
||||
import { VcsBackends } from "./vcs/backends"
|
||||
import { VcsGit } from "./vcs/git"
|
||||
import { VcsHg } from "./vcs/hg"
|
||||
|
||||
|
|
@ -17,16 +18,13 @@ export interface DiffOptions {
|
|||
}
|
||||
|
||||
export interface Interface {
|
||||
readonly status: () => Effect.Effect<FileStatus[]>
|
||||
readonly diff: (mode: Mode, options?: DiffOptions) => Effect.Effect<FileDiff.Info[]>
|
||||
readonly status: () => Effect.Effect<readonly FileStatus[]>
|
||||
readonly diff: (mode: Mode, options?: DiffOptions) => Effect.Effect<readonly FileDiff.Info[]>
|
||||
}
|
||||
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/v2/Vcs") {}
|
||||
|
||||
// Adapter seam: one working-copy implementation per VCS type, selected by the
|
||||
// resolved location. Locations without a supported VCS degrade to empty
|
||||
// results so callers never need to special-case.
|
||||
const adapter = (proc: AppProcess.Interface, fs: FSUtil.Interface, location: Location.Interface) => {
|
||||
const builtIn = (proc: AppProcess.Interface, fs: FSUtil.Interface, location: Location.Interface) => {
|
||||
const scope = { directory: location.directory, worktree: location.project.directory }
|
||||
if (location.vcs?.type === "git") return VcsGit.make(proc, scope)
|
||||
if (location.vcs?.type === "hg") return VcsHg.make(proc, fs, scope)
|
||||
|
|
@ -38,13 +36,29 @@ const layer = Layer.effect(
|
|||
const proc = yield* AppProcess.Service
|
||||
const fs = yield* FSUtil.Service
|
||||
const location = yield* Location.Service
|
||||
const impl = adapter(proc, fs, location)
|
||||
const backends = yield* VcsBackends.Service
|
||||
const native = builtIn(proc, fs, location)
|
||||
let warned = false
|
||||
|
||||
const adapter = Effect.fnUntraced(function* () {
|
||||
if (native) return native
|
||||
if (!location.vcs) return undefined
|
||||
const plugin = backends.get(location.vcs.type)
|
||||
if (!plugin && !warned) {
|
||||
warned = true
|
||||
yield* Effect.logWarning("vcs backend declared but not registered", { type: location.vcs.type })
|
||||
}
|
||||
return plugin
|
||||
})
|
||||
|
||||
return Service.of({
|
||||
status: Effect.fn("Vcs.status")(function* () {
|
||||
const impl = yield* adapter()
|
||||
if (!impl) return []
|
||||
return yield* impl.status()
|
||||
}),
|
||||
diff: Effect.fn("Vcs.diff")(function* (mode: Mode, options?: DiffOptions) {
|
||||
const impl = yield* adapter()
|
||||
if (!impl) return []
|
||||
return yield* impl.diff(mode, options)
|
||||
}),
|
||||
|
|
@ -55,5 +69,5 @@ const layer = Layer.effect(
|
|||
export const node = makeLocationNode({
|
||||
service: Service,
|
||||
layer: layer,
|
||||
deps: [AppProcess.node, FSUtil.node, Location.node],
|
||||
deps: [AppProcess.node, FSUtil.node, Location.node, VcsBackends.node],
|
||||
})
|
||||
|
|
|
|||
103
packages/core/src/vcs/backends.ts
Normal file
103
packages/core/src/vcs/backends.ts
Normal file
|
|
@ -0,0 +1,103 @@
|
|||
export * as VcsBackends from "./backends"
|
||||
|
||||
import { Vcs } from "@opencode-ai/plugin/v2/effect"
|
||||
import { FileDiff } from "@opencode-ai/schema/file-diff"
|
||||
import { FileStatus } from "@opencode-ai/schema/vcs"
|
||||
import { Context, Effect, Exit, Layer, Option, Schema } from "effect"
|
||||
import type { Scope } from "effect"
|
||||
import { ConfigVcs } from "../config/vcs"
|
||||
import { makeLocationNode } from "../effect/app-node"
|
||||
import { Location } from "../location"
|
||||
|
||||
export interface Interface {
|
||||
readonly register: (backend: Vcs.Backend) => Effect.Effect<void, Vcs.RegistrationError, Scope.Scope>
|
||||
readonly get: (type: string) => Vcs.Adapter | undefined
|
||||
}
|
||||
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/v2/VcsBackends") {}
|
||||
|
||||
const decodeType = Schema.decodeUnknownOption(ConfigVcs.Type)
|
||||
const decodeStatus = Schema.decodeUnknownOption(Schema.Array(FileStatus))
|
||||
const decodeDiff = Schema.decodeUnknownOption(Schema.Array(FileDiff.Info))
|
||||
|
||||
interface Entry {
|
||||
readonly backend: Vcs.Backend
|
||||
adapter?: Vcs.Adapter
|
||||
}
|
||||
|
||||
const layer = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const location = yield* Location.Service
|
||||
const registry = new Map<string, Entry>()
|
||||
|
||||
return Service.of({
|
||||
register: (backend) =>
|
||||
Effect.gen(function* () {
|
||||
if (Option.isNone(decodeType(backend.type))) {
|
||||
return yield* new Vcs.RegistrationError({
|
||||
type: backend.type,
|
||||
message: `Invalid vcs backend type '${backend.type}'`,
|
||||
})
|
||||
}
|
||||
if (registry.has(backend.type)) {
|
||||
return yield* new Vcs.RegistrationError({
|
||||
type: backend.type,
|
||||
message: `Vcs backend '${backend.type}' is already registered`,
|
||||
})
|
||||
}
|
||||
registry.set(backend.type, { backend })
|
||||
yield* Effect.addFinalizer(() => Effect.sync(() => registry.delete(backend.type)))
|
||||
}),
|
||||
get: (type) => {
|
||||
const vcs = location.vcs
|
||||
const entry = registry.get(type)
|
||||
if (!entry || vcs?.type !== type) return undefined
|
||||
entry.adapter ??= guard(type, () =>
|
||||
entry.backend.make({
|
||||
directory: location.directory,
|
||||
worktree: location.project.directory,
|
||||
store: vcs.store,
|
||||
}),
|
||||
)
|
||||
return entry.adapter
|
||||
},
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
export const node = makeLocationNode({ service: Service, layer: layer, deps: [Location.node] })
|
||||
|
||||
function guard(type: string, make: () => Vcs.Adapter): Vcs.Adapter {
|
||||
let underlying: Vcs.Adapter | undefined
|
||||
const adapter = Effect.sync(() => (underlying ??= make()))
|
||||
return {
|
||||
status: () => adapter.pipe(Effect.flatMap((impl) => impl.status()), sanitize(type, "status", decodeStatus)),
|
||||
diff: (mode, options) =>
|
||||
adapter.pipe(
|
||||
Effect.flatMap((impl) => impl.diff(mode, options)),
|
||||
sanitize(type, "diff", decodeDiff),
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
function sanitize<A>(type: string, operation: string, decode: (input: unknown) => Option.Option<readonly A[]>) {
|
||||
return <E, R>(effect: Effect.Effect<readonly A[], E, R>) =>
|
||||
effect.pipe(
|
||||
Effect.exit,
|
||||
Effect.flatMap((exit) => {
|
||||
if (Exit.isFailure(exit)) {
|
||||
return Effect.logWarning("vcs backend failed", { type, operation, cause: exit.cause }).pipe(
|
||||
Effect.as([] as readonly A[]),
|
||||
)
|
||||
}
|
||||
return Option.match(decode(exit.value), {
|
||||
onNone: () =>
|
||||
Effect.logWarning("vcs backend returned invalid data", { type, operation }).pipe(
|
||||
Effect.as([] as readonly A[]),
|
||||
),
|
||||
onSome: (value) => Effect.succeed(value),
|
||||
})
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
|
@ -18,6 +18,7 @@ import { Reference } from "@opencode-ai/core/reference"
|
|||
import { SkillV2 } from "@opencode-ai/core/skill"
|
||||
import { ToolHooks } from "@opencode-ai/core/tool/hooks"
|
||||
import { ToolRegistry } from "@opencode-ai/core/tool/registry"
|
||||
import { VcsBackends } from "@opencode-ai/core/vcs/backends"
|
||||
import { Effect, Layer } from "effect"
|
||||
import { tempLocationLayer } from "../fixture/location"
|
||||
|
||||
|
|
@ -50,6 +51,7 @@ export const PluginTestLayer = AppNodeBuilder.build(
|
|||
SkillV2.node,
|
||||
ToolHooks.node,
|
||||
ToolRegistry.toolsNode,
|
||||
VcsBackends.node,
|
||||
]),
|
||||
[
|
||||
[Location.node, tempLocationLayer],
|
||||
|
|
|
|||
|
|
@ -64,6 +64,9 @@ export function host(overrides: Overrides = {}): PluginContext {
|
|||
command: () => Effect.die("unused session.command"),
|
||||
interrupt: () => Effect.die("unused session.interrupt"),
|
||||
},
|
||||
vcs: overrides.vcs ?? {
|
||||
register: () => Effect.die("unused vcs.register"),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
129
packages/core/test/vcs-backends.test.ts
Normal file
129
packages/core/test/vcs-backends.test.ts
Normal file
|
|
@ -0,0 +1,129 @@
|
|||
import { describe, expect } from "bun:test"
|
||||
import { Effect, Exit, Layer, Scope } from "effect"
|
||||
import { Vcs as PluginVcs } from "@opencode-ai/plugin/v2/effect"
|
||||
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
|
||||
import { Location } from "@opencode-ai/core/location"
|
||||
import { AbsolutePath } from "@opencode-ai/core/schema"
|
||||
import { Vcs } from "@opencode-ai/core/vcs"
|
||||
import { VcsBackends } from "@opencode-ai/core/vcs/backends"
|
||||
import { location } from "./fixture/location"
|
||||
import { it } from "./lib/effect"
|
||||
|
||||
const directory = AbsolutePath.make("/repo")
|
||||
|
||||
const provide = Effect.provide(
|
||||
LayerNode.compile(LayerNode.group([Vcs.node, VcsBackends.node]), [
|
||||
[
|
||||
Location.node,
|
||||
Layer.succeed(
|
||||
Location.Service,
|
||||
Location.Service.of(
|
||||
location({ directory }, { vcs: { type: "fake", store: AbsolutePath.make("/repo/.fake") } }),
|
||||
),
|
||||
),
|
||||
],
|
||||
]),
|
||||
)
|
||||
|
||||
const status = [{ file: "a.txt", additions: 1, deletions: 0, status: "added" as const }]
|
||||
const diff = [{ file: "a.txt", patch: "+hello", additions: 1, deletions: 0, status: "added" as const }]
|
||||
|
||||
const backend = (overrides: Partial<PluginVcs.Adapter> = {}): PluginVcs.Backend => ({
|
||||
type: "fake",
|
||||
make: () => ({
|
||||
status: () => Effect.succeed(status),
|
||||
diff: () => Effect.succeed(diff),
|
||||
...overrides,
|
||||
}),
|
||||
})
|
||||
|
||||
const register = (input: PluginVcs.Backend) =>
|
||||
Effect.gen(function* () {
|
||||
const backends = yield* VcsBackends.Service
|
||||
return yield* backends.register(input)
|
||||
})
|
||||
|
||||
describe("VcsBackends", () => {
|
||||
it.live("serves status and diff through a registered backend", () =>
|
||||
Effect.gen(function* () {
|
||||
yield* register(backend())
|
||||
const vcs = yield* Vcs.Service
|
||||
expect(yield* vcs.status()).toEqual(status)
|
||||
expect(yield* vcs.diff("working")).toEqual(diff)
|
||||
}).pipe(Effect.scoped, provide),
|
||||
)
|
||||
|
||||
it.live("passes the location scope to the adapter factory", () =>
|
||||
Effect.gen(function* () {
|
||||
let scope: PluginVcs.AdapterScope | undefined
|
||||
yield* register({
|
||||
type: "fake",
|
||||
make: (input) => {
|
||||
scope = input
|
||||
return backend().make(input)
|
||||
},
|
||||
})
|
||||
yield* (yield* Vcs.Service).status()
|
||||
expect(scope).toEqual({ directory: "/repo", worktree: "/repo", store: "/repo/.fake" })
|
||||
}).pipe(Effect.scoped, provide),
|
||||
)
|
||||
|
||||
it.live("returns empty results when no backend is registered", () =>
|
||||
Effect.gen(function* () {
|
||||
const vcs = yield* Vcs.Service
|
||||
expect(yield* vcs.status()).toEqual([])
|
||||
expect(yield* vcs.diff("working")).toEqual([])
|
||||
}).pipe(Effect.scoped, provide),
|
||||
)
|
||||
|
||||
it.live("frees the type when the registration scope closes", () =>
|
||||
Effect.gen(function* () {
|
||||
const backends = yield* VcsBackends.Service
|
||||
const scope = yield* Scope.make()
|
||||
yield* backends.register(backend()).pipe(Scope.provide(scope))
|
||||
expect((yield* (yield* Vcs.Service).status()).length).toBe(1)
|
||||
yield* Scope.close(scope, Exit.void)
|
||||
expect(yield* (yield* Vcs.Service).status()).toEqual([])
|
||||
yield* register(backend())
|
||||
}).pipe(Effect.scoped, provide),
|
||||
)
|
||||
|
||||
it.live("rejects duplicate and reserved types", () =>
|
||||
Effect.gen(function* () {
|
||||
yield* register(backend())
|
||||
const duplicate = yield* register(backend()).pipe(Effect.exit)
|
||||
expect(Exit.isFailure(duplicate)).toBe(true)
|
||||
const reserved = yield* register({ ...backend(), type: "git" }).pipe(Effect.exit)
|
||||
expect(Exit.isFailure(reserved)).toBe(true)
|
||||
const invalid = yield* register({ ...backend(), type: "Not A Slug" }).pipe(Effect.exit)
|
||||
expect(Exit.isFailure(invalid)).toBe(true)
|
||||
}).pipe(Effect.scoped, provide),
|
||||
)
|
||||
|
||||
it.live("degrades failing adapters to empty results", () =>
|
||||
Effect.gen(function* () {
|
||||
yield* register(
|
||||
backend({
|
||||
status: () => Effect.die(new Error("backend exploded")),
|
||||
diff: () => Effect.sync(() => {
|
||||
throw new Error("sync explosion")
|
||||
}),
|
||||
}),
|
||||
)
|
||||
const vcs = yield* Vcs.Service
|
||||
expect(yield* vcs.status()).toEqual([])
|
||||
expect(yield* vcs.diff("working")).toEqual([])
|
||||
}).pipe(Effect.scoped, provide),
|
||||
)
|
||||
|
||||
it.live("drops rows that fail schema validation", () =>
|
||||
Effect.gen(function* () {
|
||||
yield* register(
|
||||
backend({
|
||||
status: () => Effect.succeed([{ file: "a.txt", additions: -1, deletions: 0, status: "added" }]),
|
||||
}),
|
||||
)
|
||||
expect(yield* (yield* Vcs.Service).status()).toEqual([])
|
||||
}).pipe(Effect.scoped, provide),
|
||||
)
|
||||
})
|
||||
|
|
@ -10,6 +10,7 @@ import type { SkillHooks } from "./skill.js"
|
|||
import type { Reload } from "./registration.js"
|
||||
import type { ToolDomain } from "./tool.js"
|
||||
import type { SessionDomain } from "./runtime.js"
|
||||
import type { VcsDomain } from "./vcs.js"
|
||||
|
||||
export interface PluginContext {
|
||||
readonly options: PluginOptions
|
||||
|
|
@ -23,4 +24,5 @@ export interface PluginContext {
|
|||
readonly skill: SkillHooks & Reload
|
||||
readonly tool: ToolDomain
|
||||
readonly session: SessionDomain
|
||||
readonly vcs: VcsDomain
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,3 +4,5 @@ export type { Plugin } from "./plugin.js"
|
|||
export * as Tool from "./tool.js"
|
||||
export type { ToolDomain, ToolExecuteBeforeEvent, ToolExecuteAfterEvent } from "./tool.js"
|
||||
export type { SessionDomain } from "./runtime.js"
|
||||
export { Vcs } from "./vcs.js"
|
||||
export type { VcsDomain } from "./vcs.js"
|
||||
|
|
|
|||
33
packages/plugin/src/v2/effect/vcs.ts
Normal file
33
packages/plugin/src/v2/effect/vcs.ts
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
export * as Vcs from "./vcs.js"
|
||||
|
||||
import { FileDiff } from "@opencode-ai/schema/file-diff"
|
||||
import { FileStatus, Mode } from "@opencode-ai/schema/vcs"
|
||||
import { Schema, type Effect, type Scope } from "effect"
|
||||
|
||||
export class RegistrationError extends Schema.TaggedErrorClass<RegistrationError>()("Vcs.RegistrationError", {
|
||||
type: Schema.String,
|
||||
message: Schema.String,
|
||||
}) {}
|
||||
|
||||
export interface AdapterScope {
|
||||
readonly directory: string
|
||||
readonly worktree: string
|
||||
readonly store: string
|
||||
}
|
||||
|
||||
export interface Adapter {
|
||||
readonly status: () => Effect.Effect<readonly FileStatus[]>
|
||||
readonly diff: (
|
||||
mode: Mode,
|
||||
options?: { readonly context?: number },
|
||||
) => Effect.Effect<readonly FileDiff.Info[]>
|
||||
}
|
||||
|
||||
export interface Backend {
|
||||
readonly type: string
|
||||
readonly make: (scope: AdapterScope) => Adapter
|
||||
}
|
||||
|
||||
export interface VcsDomain {
|
||||
readonly register: (backend: Backend) => Effect.Effect<void, RegistrationError, Scope.Scope>
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue