Skip to content

Commit e268d50

Browse files
Apply PR #18173: feat(bus): migrate Bus to Effect service with PubSub
2 parents 0b46ea9 + 009d77c commit e268d50

12 files changed

Lines changed: 452 additions & 131 deletions

File tree

packages/opencode/src/bus/bus-event.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import z from "zod"
2-
import type { ZodType } from "zod"
2+
import type { ZodObject, ZodRawShape } from "zod"
33
import { Log } from "../util/log"
44

55
export namespace BusEvent {
@@ -9,7 +9,7 @@ export namespace BusEvent {
99

1010
const registry = new Map<string, Definition>()
1111

12-
export function define<Type extends string, Properties extends ZodType>(type: Type, properties: Properties) {
12+
export function define<Type extends string, Properties extends ZodObject<ZodRawShape>>(type: Type, properties: Properties) {
1313
const result = {
1414
type,
1515
properties,

packages/opencode/src/bus/global.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ export const GlobalBus = new EventEmitter<{
44
event: [
55
{
66
directory?: string
7-
payload: any
7+
payload: { type: string; properties: Record<string, unknown> }
88
},
99
]
1010
}>()

packages/opencode/src/bus/index.ts

Lines changed: 88 additions & 73 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,13 @@
11
import z from "zod"
2+
import { Effect, Layer, PubSub, ServiceMap, Stream } from "effect"
23
import { Log } from "../util/log"
34
import { Instance } from "../project/instance"
45
import { BusEvent } from "./bus-event"
56
import { GlobalBus } from "./global"
7+
import { runCallbackInstance, runPromiseInstance } from "../effect/runtime"
68

79
export namespace Bus {
810
const log = Log.create({ service: "bus" })
9-
type Subscription = (event: any) => void
1011

1112
export const InstanceDisposed = BusEvent.define(
1213
"server.instance.disposed",
@@ -15,91 +16,105 @@ export namespace Bus {
1516
}),
1617
)
1718

18-
const state = Instance.state(
19-
() => {
20-
const subscriptions = new Map<any, Subscription[]>()
19+
// ---------------------------------------------------------------------------
20+
// Service definition
21+
// ---------------------------------------------------------------------------
2122

22-
return {
23-
subscriptions,
23+
type Payload<D extends BusEvent.Definition = BusEvent.Definition> = {
24+
type: D["type"]
25+
properties: z.infer<D["properties"]>
26+
}
27+
28+
export interface Interface {
29+
readonly publish: <D extends BusEvent.Definition>(
30+
def: D,
31+
properties: z.output<D["properties"]>,
32+
) => Effect.Effect<void>
33+
readonly subscribe: <D extends BusEvent.Definition>(def: D) => Stream.Stream<Payload<D>>
34+
readonly subscribeAll: () => Stream.Stream<Payload>
35+
}
36+
37+
export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/Bus") {}
38+
39+
export const layer = Layer.effect(
40+
Service,
41+
Effect.gen(function* () {
42+
const pubsubs = new Map<string, PubSub.PubSub<Payload>>()
43+
const wildcardPubSub = yield* PubSub.unbounded<Payload>()
44+
45+
const getOrCreate = Effect.fnUntraced(function* (type: string) {
46+
let ps = pubsubs.get(type)
47+
if (!ps) {
48+
ps = yield* PubSub.unbounded<Payload>()
49+
pubsubs.set(type, ps)
50+
}
51+
return ps
52+
})
53+
54+
function publish<D extends BusEvent.Definition>(
55+
def: D,
56+
properties: z.output<D["properties"]>,
57+
) {
58+
return Effect.gen(function* () {
59+
const payload: Payload = { type: def.type, properties }
60+
log.info("publishing", { type: def.type })
61+
62+
const ps = pubsubs.get(def.type)
63+
if (ps) yield* PubSub.publish(ps, payload)
64+
yield* PubSub.publish(wildcardPubSub, payload)
65+
66+
GlobalBus.emit("event", {
67+
directory: Instance.directory,
68+
payload,
69+
})
70+
})
2471
}
25-
},
26-
async (entry) => {
27-
const wildcard = entry.subscriptions.get("*")
28-
if (!wildcard) return
29-
const event = {
30-
type: InstanceDisposed.type,
31-
properties: {
32-
directory: Instance.directory,
33-
},
72+
73+
function subscribe<D extends BusEvent.Definition>(def: D): Stream.Stream<Payload<D>> {
74+
log.info("subscribing", { type: def.type })
75+
return Stream.unwrap(
76+
Effect.gen(function* () {
77+
const ps = yield* getOrCreate(def.type)
78+
return Stream.fromPubSub(ps) as Stream.Stream<Payload<D>>
79+
}),
80+
).pipe(Stream.ensuring(Effect.sync(() => log.info("unsubscribing", { type: def.type }))))
3481
}
35-
for (const sub of [...wildcard]) {
36-
sub(event)
82+
83+
function subscribeAll(): Stream.Stream<Payload> {
84+
log.info("subscribing", { type: "*" })
85+
return Stream.fromPubSub(wildcardPubSub).pipe(
86+
Stream.ensuring(Effect.sync(() => log.info("unsubscribing", { type: "*" }))),
87+
)
3788
}
38-
},
89+
90+
return Service.of({ publish, subscribe, subscribeAll })
91+
}),
3992
)
4093

41-
export async function publish<Definition extends BusEvent.Definition>(
42-
def: Definition,
43-
properties: z.output<Definition["properties"]>,
44-
) {
45-
const payload = {
46-
type: def.type,
47-
properties,
48-
}
49-
log.info("publishing", {
50-
type: def.type,
51-
})
52-
const pending = []
53-
for (const key of [def.type, "*"]) {
54-
const match = state().subscriptions.get(key)
55-
for (const sub of match ?? []) {
56-
pending.push(sub(payload))
57-
}
58-
}
59-
GlobalBus.emit("event", {
60-
directory: Instance.directory,
61-
payload,
62-
})
63-
return Promise.all(pending)
94+
// ---------------------------------------------------------------------------
95+
// Legacy adapters — plain function API wrapping the Effect service
96+
// ---------------------------------------------------------------------------
97+
98+
function runStream(stream: (svc: Interface) => Stream.Stream<Payload>, callback: (event: any) => void) {
99+
return runCallbackInstance(
100+
Service.use((svc) =>
101+
stream(svc).pipe(Stream.runForEach((msg) => Effect.sync(() => callback(msg)))),
102+
),
103+
)
64104
}
65105

66-
export function subscribe<Definition extends BusEvent.Definition>(
67-
def: Definition,
68-
callback: (event: { type: Definition["type"]; properties: z.infer<Definition["properties"]> }) => void,
69-
) {
70-
return raw(def.type, callback)
106+
export function publish<D extends BusEvent.Definition>(def: D, properties: z.output<D["properties"]>) {
107+
return runPromiseInstance(Service.use((svc) => svc.publish(def, properties)))
71108
}
72109

73-
export function once<Definition extends BusEvent.Definition>(
74-
def: Definition,
75-
callback: (event: {
76-
type: Definition["type"]
77-
properties: z.infer<Definition["properties"]>
78-
}) => "done" | undefined,
110+
export function subscribe<D extends BusEvent.Definition>(
111+
def: D,
112+
callback: (event: Payload<D>) => void,
79113
) {
80-
const unsub = subscribe(def, (event) => {
81-
if (callback(event)) unsub()
82-
})
114+
return runStream((svc) => svc.subscribe(def), callback)
83115
}
84116

85117
export function subscribeAll(callback: (event: any) => void) {
86-
return raw("*", callback)
87-
}
88-
89-
function raw(type: string, callback: (event: any) => void) {
90-
log.info("subscribing", { type })
91-
const subscriptions = state().subscriptions
92-
let match = subscriptions.get(type) ?? []
93-
match.push(callback)
94-
subscriptions.set(type, match)
95-
96-
return () => {
97-
log.info("unsubscribing", { type })
98-
const match = subscriptions.get(type)
99-
if (!match) return
100-
const index = match.indexOf(callback)
101-
if (index === -1) return
102-
match.splice(index, 1)
103-
}
118+
return runStream((svc) => svc.subscribeAll(), callback)
104119
}
105120
}

packages/opencode/src/control-plane/workspace.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -124,7 +124,7 @@ export namespace Workspace {
124124
await parseSSE(res.body, stop, (event) => {
125125
GlobalBus.emit("event", {
126126
directory: space.id,
127-
payload: event,
127+
payload: event as { type: string; properties: Record<string, unknown> },
128128
})
129129
})
130130
// Wait 250ms and retry if SSE connection fails

packages/opencode/src/effect/instances.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { Effect, Layer, LayerMap, ServiceMap } from "effect"
2+
import { Bus } from "@/bus"
23
import { File } from "@/file"
34
import { FileTime } from "@/file/time"
45
import { FileWatcher } from "@/file/watcher"
@@ -16,6 +17,7 @@ import { registerDisposer } from "./instance-registry"
1617
export { InstanceContext } from "./instance-context"
1718

1819
export type InstanceServices =
20+
| Bus.Service
1921
| Question.Service
2022
| PermissionNext.Service
2123
| ProviderAuth.Service
@@ -36,6 +38,7 @@ export type InstanceServices =
3638
function lookup(_key: string) {
3739
const ctx = Layer.sync(InstanceContext, () => InstanceContext.of(Instance.current))
3840
return Layer.mergeAll(
41+
Layer.fresh(Bus.layer),
3942
Layer.fresh(Question.layer),
4043
Layer.fresh(PermissionNext.layer),
4144
Layer.fresh(ProviderAuth.defaultLayer),

packages/opencode/src/effect/runtime.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,12 @@ export function runPromiseInstance<A, E>(effect: Effect.Effect<A, E, InstanceSer
1818
return runtime.runPromise(effect.pipe(Effect.provide(Instances.get(Instance.directory))))
1919
}
2020

21+
export function runCallbackInstance<A, E>(
22+
effect: Effect.Effect<A, E, InstanceServices>,
23+
): (interruptor?: number) => void {
24+
return runtime.runCallback(effect.pipe(Effect.provide(Instances.get(Instance.directory))))
25+
}
26+
2127
export function disposeRuntime() {
2228
return runtime.dispose()
2329
}

packages/opencode/src/format/index.ts

Lines changed: 44 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,7 @@ import { InstanceContext } from "@/effect/instance-context"
44
import path from "path"
55
import { mergeDeep } from "remeda"
66
import z from "zod"
7-
import { Bus } from "../bus"
87
import { Config } from "../config/config"
9-
import { File } from "../file"
108
import { Instance } from "../project/instance"
119
import { Process } from "../util/process"
1210
import { Log } from "../util/log"
@@ -27,6 +25,7 @@ export namespace Format {
2725
export type Status = z.infer<typeof Status>
2826

2927
export interface Interface {
28+
readonly run: (filepath: string) => Effect.Effect<void>
3029
readonly status: () => Effect.Effect<Status[]>
3130
}
3231

@@ -87,45 +86,45 @@ export namespace Format {
8786
return out
8887
}
8988

90-
yield* Effect.acquireRelease(
91-
Effect.sync(() =>
92-
Bus.subscribe(
93-
File.Event.Edited,
94-
Instance.bind(async (payload) => {
95-
const file = payload.properties.file
96-
const ext = path.extname(file)
97-
log.info("formatting", { file })
98-
99-
for (const item of await match(ext)) {
100-
const replaced = item.cmd.map((x) => x.replace("$FILE", file))
101-
log.info("running", { replaced })
102-
try {
103-
const proc = Process.spawn(replaced, {
104-
cwd: instance.directory,
105-
env: { ...process.env, ...item.fmt.environment },
106-
stdout: "ignore",
107-
stderr: "ignore",
108-
})
109-
const exit = await proc.exited
110-
if (exit !== 0)
111-
log.error("failed", {
112-
command: item.cmd,
113-
...item.fmt.environment,
114-
})
115-
} catch (error) {
116-
log.error("failed to format file", {
117-
error,
118-
command: item.cmd,
119-
...item.fmt.environment,
120-
file,
121-
})
122-
}
89+
const run = Effect.fn("Format.run")(function* (filepath: string) {
90+
log.info("formatting", { file: filepath })
91+
const ext = path.extname(filepath)
92+
93+
for (const item of yield* Effect.promise(() => match(ext))) {
94+
log.info("running", { command: item.cmd })
95+
yield* Effect.tryPromise({
96+
try: async () => {
97+
const proc = Process.spawn(
98+
item.cmd.map((x) => x.replace("$FILE", filepath)),
99+
{
100+
cwd: instance.directory,
101+
env: { ...process.env, ...item.fmt.environment },
102+
stdout: "ignore",
103+
stderr: "ignore",
104+
},
105+
)
106+
const exit = await proc.exited
107+
if (exit !== 0) {
108+
log.error("failed", {
109+
command: item.cmd,
110+
...item.fmt.environment,
111+
})
123112
}
124-
}),
125-
),
126-
),
127-
(unsubscribe) => Effect.sync(unsubscribe),
128-
)
113+
},
114+
catch: (error) => {
115+
log.error("failed to format file", {
116+
error,
117+
command: item.cmd,
118+
...item.fmt.environment,
119+
file: filepath,
120+
})
121+
return error
122+
},
123+
}).pipe(Effect.ignore)
124+
}
125+
})
126+
127+
log.info("init")
129128

130129
const status = Effect.fn("Format.status")(function* () {
131130
const result: Status[] = []
@@ -140,10 +139,14 @@ export namespace Format {
140139
return result
141140
})
142141

143-
return Service.of({ status })
142+
return Service.of({ run, status })
144143
}),
145144
)
146145

146+
export async function run(filepath: string) {
147+
return runPromiseInstance(Service.use((s) => s.run(filepath)))
148+
}
149+
147150
export async function status() {
148151
return runPromiseInstance(Service.use((s) => s.status()))
149152
}

packages/opencode/src/tool/apply_patch.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import { createTwoFilesPatch, diffLines } from "diff"
1010
import { assertExternalDirectory } from "./external-directory"
1111
import { trimDiff } from "./edit"
1212
import { LSP } from "../lsp"
13+
import { Format } from "../format"
1314
import { Filesystem } from "../util/filesystem"
1415
import DESCRIPTION from "./apply_patch.txt"
1516
import { File } from "../file"
@@ -220,6 +221,7 @@ export const ApplyPatchTool = Tool.define("apply_patch", {
220221
}
221222

222223
if (edited) {
224+
await Format.run(edited)
223225
await Bus.publish(File.Event.Edited, {
224226
file: edited,
225227
})

0 commit comments

Comments
 (0)