Skip to content

Commit b6b4125

Browse files
committed
refactor(vcs): effectify VcsService as scoped service
Convert Vcs from Instance.state namespace to an Effect ServiceMap.Service on the Instances LayerMap. Uses Instance.bind for the Bus.subscribe callback (same ALS pattern as FileWatcherService). Branch state is managed in the layer closure with scope-based cleanup.
1 parent 22d7399 commit b6b4125

File tree

10 files changed

+162
-131
lines changed

10 files changed

+162
-131
lines changed
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
import { ServiceMap } from "effect"
2+
import type { Project } from "@/project/project"
3+
4+
export declare namespace InstanceContext {
5+
export interface Shape {
6+
readonly directory: string
7+
readonly project: Project.Info
8+
}
9+
}
10+
11+
export class InstanceContext extends ServiceMap.Service<InstanceContext, InstanceContext.Shape>()(
12+
"opencode/InstanceContext",
13+
) {}

packages/opencode/src/effect/instances.ts

Lines changed: 10 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,24 +1,21 @@
11
import { Effect, Layer, LayerMap, ServiceMap } from "effect"
22
import { registerDisposer } from "./instance-registry"
3+
import { InstanceContext } from "./instance-context"
34
import { ProviderAuthService } from "@/provider/auth-service"
45
import { QuestionService } from "@/question/service"
56
import { PermissionService } from "@/permission/service"
67
import { FileWatcherService } from "@/file/watcher"
8+
import { VcsService } from "@/project/vcs"
79
import { Instance } from "@/project/instance"
8-
import type { Project } from "@/project/project"
910

10-
export declare namespace InstanceContext {
11-
export interface Shape {
12-
readonly directory: string
13-
readonly project: Project.Info
14-
}
15-
}
16-
17-
export class InstanceContext extends ServiceMap.Service<InstanceContext, InstanceContext.Shape>()(
18-
"opencode/InstanceContext",
19-
) {}
11+
export { InstanceContext } from "./instance-context"
2012

21-
export type InstanceServices = QuestionService | PermissionService | ProviderAuthService | FileWatcherService
13+
export type InstanceServices =
14+
| QuestionService
15+
| PermissionService
16+
| ProviderAuthService
17+
| FileWatcherService
18+
| VcsService
2219

2320
function lookup(directory: string) {
2421
const project = Instance.project
@@ -28,6 +25,7 @@ function lookup(directory: string) {
2825
Layer.fresh(PermissionService.layer),
2926
Layer.fresh(ProviderAuthService.layer),
3027
Layer.fresh(FileWatcherService.layer),
28+
Layer.fresh(VcsService.layer),
3129
).pipe(Layer.provide(ctx))
3230
}
3331

packages/opencode/src/file/watcher.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { BusEvent } from "@/bus/bus-event"
22
import { Bus } from "@/bus"
3-
import { InstanceContext } from "@/effect/instances"
3+
import { InstanceContext } from "@/effect/instance-context"
44
import { Instance } from "@/project/instance"
55
import z from "zod"
66
import { Log } from "../util/log"

packages/opencode/src/permission/service.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { Bus } from "@/bus"
22
import { BusEvent } from "@/bus/bus-event"
3-
import { InstanceContext } from "@/effect/instances"
3+
import { InstanceContext } from "@/effect/instance-context"
44
import { ProjectID } from "@/project/schema"
55
import { MessageID, SessionID } from "@/session/schema"
66
import { PermissionTable } from "@/session/session.sql"

packages/opencode/src/project/bootstrap.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ import { Project } from "./project"
77
import { Bus } from "../bus"
88
import { Command } from "../command"
99
import { Instance } from "./instance"
10-
import { Vcs } from "./vcs"
10+
import { VcsService } from "./vcs"
1111
import { Log } from "@/util/log"
1212
import { ShareNext } from "@/share/share-next"
1313
import { Snapshot } from "../snapshot"
@@ -22,7 +22,7 @@ export async function InstanceBootstrap() {
2222
await LSP.init()
2323
await runPromiseInstance(FileWatcherService.use((service) => service.init()))
2424
File.init()
25-
Vcs.init()
25+
await runPromiseInstance(VcsService.use((s) => s.init()))
2626
Snapshot.init()
2727
Truncate.init()
2828

Lines changed: 47 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,12 @@
11
import { BusEvent } from "@/bus/bus-event"
22
import { Bus } from "@/bus"
3-
import path from "path"
43
import z from "zod"
54
import { Log } from "@/util/log"
65
import { Instance } from "./instance"
6+
import { InstanceContext } from "@/effect/instance-context"
77
import { FileWatcher } from "@/file/watcher"
88
import { git } from "@/util/git"
9+
import { Effect, Layer, ServiceMap } from "effect"
910

1011
const log = Log.create({ service: "vcs" })
1112

@@ -27,50 +28,57 @@ export namespace Vcs {
2728
ref: "VcsInfo",
2829
})
2930
export type Info = z.infer<typeof Info>
31+
}
3032

31-
async function currentBranch() {
32-
const result = await git(["rev-parse", "--abbrev-ref", "HEAD"], {
33-
cwd: Instance.worktree,
34-
})
35-
if (result.exitCode !== 0) return
36-
const text = result.text().trim()
37-
if (!text) return
38-
return text
33+
export namespace VcsService {
34+
export interface Service {
35+
readonly init: () => Effect.Effect<void>
36+
readonly branch: () => Effect.Effect<string | undefined>
3937
}
38+
}
4039

41-
const state = Instance.state(
42-
async () => {
43-
if (Instance.project.vcs !== "git") {
44-
return { branch: async () => undefined, unsubscribe: undefined }
45-
}
46-
let current = await currentBranch()
47-
log.info("initialized", { branch: current })
40+
export class VcsService extends ServiceMap.Service<VcsService, VcsService.Service>()("@opencode/Vcs") {
41+
static readonly layer = Layer.effect(
42+
VcsService,
43+
Effect.gen(function* () {
44+
const instance = yield* InstanceContext
45+
let current: string | undefined
4846

49-
const unsubscribe = Bus.subscribe(FileWatcher.Event.Updated, async (evt) => {
50-
if (!evt.properties.file.endsWith("HEAD")) return
51-
const next = await currentBranch()
52-
if (next !== current) {
53-
log.info("branch changed", { from: current, to: next })
54-
current = next
55-
Bus.publish(Event.BranchUpdated, { branch: next })
47+
if (instance.project.vcs === "git") {
48+
const currentBranch = async () => {
49+
const result = await git(["rev-parse", "--abbrev-ref", "HEAD"], {
50+
cwd: instance.project.worktree,
51+
})
52+
if (result.exitCode !== 0) return undefined
53+
const text = result.text().trim()
54+
return text || undefined
5655
}
57-
})
5856

59-
return {
60-
branch: async () => current,
61-
unsubscribe,
62-
}
63-
},
64-
async (state) => {
65-
state.unsubscribe?.()
66-
},
67-
)
57+
current = yield* Effect.promise(() => currentBranch())
58+
log.info("initialized", { branch: current })
6859

69-
export async function init() {
70-
return state()
71-
}
60+
const unsubscribe = Bus.subscribe(
61+
FileWatcher.Event.Updated,
62+
Instance.bind(async (evt) => {
63+
if (!evt.properties.file.endsWith("HEAD")) return
64+
const next = await currentBranch()
65+
if (next !== current) {
66+
log.info("branch changed", { from: current, to: next })
67+
current = next
68+
Bus.publish(Vcs.Event.BranchUpdated, { branch: next })
69+
}
70+
}),
71+
)
7272

73-
export async function branch() {
74-
return await state().then((s) => s.branch())
75-
}
73+
yield* Effect.addFinalizer(() => Effect.sync(unsubscribe))
74+
}
75+
76+
return VcsService.of({
77+
init: Effect.fn("VcsService.init")(function* () {}),
78+
branch: Effect.fn("VcsService.branch")(function* () {
79+
return current
80+
}),
81+
})
82+
}),
83+
)
7684
}

packages/opencode/src/server/server.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,8 @@ import { LSP } from "../lsp"
1414
import { Format } from "../format"
1515
import { TuiRoutes } from "./routes/tui"
1616
import { Instance } from "../project/instance"
17-
import { Vcs } from "../project/vcs"
17+
import { Vcs, VcsService } from "../project/vcs"
18+
import { runPromiseInstance } from "@/effect/runtime"
1819
import { Agent } from "../agent/agent"
1920
import { Skill } from "../skill/skill"
2021
import { Auth } from "../auth"
@@ -330,7 +331,7 @@ export namespace Server {
330331
},
331332
}),
332333
async (c) => {
333-
const branch = await Vcs.branch()
334+
const branch = await runPromiseInstance(VcsService.use((s) => s.branch()))
334335
return c.json({
335336
branch,
336337
})

packages/opencode/test/file/watcher.test.ts

Lines changed: 12 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,10 @@ import { $ } from "bun"
22
import { afterEach, describe, expect, test } from "bun:test"
33
import fs from "fs/promises"
44
import path from "path"
5-
import { ConfigProvider, Deferred, Effect, Fiber, Layer, ManagedRuntime, Option } from "effect"
5+
import { Deferred, Effect, Fiber, Option } from "effect"
66
import { tmpdir } from "../fixture/fixture"
7+
import { watcherConfigLayer, withServices } from "../fixture/instance"
78
import { FileWatcher, FileWatcherService } from "../../src/file/watcher"
8-
import { InstanceContext } from "../../src/effect/instances"
99
import { Instance } from "../../src/project/instance"
1010
import { GlobalBus } from "../../src/bus/global"
1111

@@ -16,35 +16,21 @@ const describeWatcher = FileWatcher.hasNativeBinding() && !process.env.CI ? desc
1616
// Helpers
1717
// ---------------------------------------------------------------------------
1818

19-
const configLayer = ConfigProvider.layer(
20-
ConfigProvider.fromUnknown({
21-
OPENCODE_EXPERIMENTAL_FILEWATCHER: "true",
22-
OPENCODE_EXPERIMENTAL_DISABLE_FILEWATCHER: "false",
23-
}),
24-
)
25-
2619
type BusUpdate = { directory?: string; payload: { type: string; properties: WatcherEvent } }
2720
type WatcherEvent = { file: string; event: "add" | "change" | "unlink" }
2821

29-
/** Run `body` with a live FileWatcherService. Runtime is acquired/released via Effect.scoped. */
22+
/** Run `body` with a live FileWatcherService. */
3023
function withWatcher<E>(directory: string, body: Effect.Effect<void, E>) {
31-
return Instance.provide({
24+
return withServices(
3225
directory,
33-
fn: () =>
34-
Effect.gen(function* () {
35-
const ctx = Layer.sync(InstanceContext, () =>
36-
InstanceContext.of({ directory: Instance.directory, project: Instance.project }),
37-
)
38-
const layer = Layer.fresh(FileWatcherService.layer).pipe(Layer.provide(ctx), Layer.provide(configLayer))
39-
const rt = yield* Effect.acquireRelease(
40-
Effect.sync(() => ManagedRuntime.make(layer)),
41-
(rt) => Effect.promise(() => rt.dispose()),
42-
)
43-
yield* Effect.promise(() => rt.runPromise(FileWatcherService.use((s) => s.init())))
44-
yield* ready(directory)
45-
yield* body
46-
}).pipe(Effect.scoped, Effect.runPromise),
47-
})
26+
FileWatcherService.layer,
27+
async (rt) => {
28+
await rt.runPromise(FileWatcherService.use((s) => s.init()))
29+
await Effect.runPromise(ready(directory))
30+
await Effect.runPromise(body)
31+
},
32+
{ provide: [watcherConfigLayer] },
33+
)
4834
}
4935

5036
function listen(directory: string, check: (evt: WatcherEvent) => boolean, hit: (evt: WatcherEvent) => void) {
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
import { ConfigProvider, Layer, ManagedRuntime } from "effect"
2+
import { InstanceContext } from "../../src/effect/instance-context"
3+
import { Instance } from "../../src/project/instance"
4+
5+
/** ConfigProvider that enables the experimental file watcher. */
6+
export const watcherConfigLayer = ConfigProvider.layer(
7+
ConfigProvider.fromUnknown({
8+
OPENCODE_EXPERIMENTAL_FILEWATCHER: "true",
9+
OPENCODE_EXPERIMENTAL_DISABLE_FILEWATCHER: "false",
10+
}),
11+
)
12+
13+
/**
14+
* Boot an Instance with the given service layers and run `body` with
15+
* the ManagedRuntime. Cleanup is automatic — the runtime is disposed
16+
* and Instance context is torn down when `body` completes.
17+
*
18+
* Layers may depend on InstanceContext (provided automatically).
19+
* Pass extra layers via `options.provide` (e.g. ConfigProvider.layer).
20+
*/
21+
export function withServices<S>(
22+
directory: string,
23+
layer: Layer.Layer<S, any, InstanceContext>,
24+
body: (rt: ManagedRuntime.ManagedRuntime<S, never>) => Promise<void>,
25+
options?: { provide?: Layer.Layer<never>[] },
26+
) {
27+
return Instance.provide({
28+
directory,
29+
fn: async () => {
30+
const ctx = Layer.sync(InstanceContext, () =>
31+
InstanceContext.of({ directory: Instance.directory, project: Instance.project }),
32+
)
33+
let resolved: Layer.Layer<S> = Layer.fresh(layer).pipe(Layer.provide(ctx)) as any
34+
if (options?.provide) {
35+
for (const l of options.provide) {
36+
resolved = resolved.pipe(Layer.provide(l)) as any
37+
}
38+
}
39+
const rt = ManagedRuntime.make(resolved)
40+
try {
41+
await body(rt)
42+
} finally {
43+
await rt.dispose()
44+
}
45+
},
46+
})
47+
}

0 commit comments

Comments
 (0)