feat(core): improve runtime observability

This commit is contained in:
Dax Raad 2026-07-05 15:53:50 -04:00
commit c3d26c4912
6 changed files with 112 additions and 40 deletions

View file

@ -0,0 +1,24 @@
export * as EventLogger from "./event-logger"
import { Effect, Layer } from "effect"
import { makeGlobalNode } from "./effect/app-node"
import { EventV2 } from "./event"
const Types = new Set([
"agent.updated",
"catalog.updated",
"command.updated",
"config.updated",
])
export const layer = Layer.effectDiscard(
Effect.gen(function* () {
const events = yield* EventV2.Service
const unsubscribe = yield* events.listen((event) =>
Types.has(event.type) ? Effect.logInfo("event", { event }) : Effect.void,
)
yield* Effect.addFinalizer(() => unsubscribe)
}),
)
export const node = makeGlobalNode({ name: "event-logger", layer, deps: [EventV2.node] })

View file

@ -34,46 +34,53 @@ const layer = Layer.effect(
const fs = yield* FSUtil.Service
const git = yield* Git.Service
const configService = yield* Config.Service
const config = (yield* configService.entries())
.filter((entry): entry is Config.Document => entry.type === "document")
.flatMap((item) => item.info.watcher?.ignore ?? [])
const publish = (update: { type: "create" | "update" | "delete"; path: string }) =>
events.publish(FileSystem.Event.Changed, {
file: update.path,
event: update.type === "create" ? "add" : update.type === "update" ? "change" : "unlink",
})
if (path.resolve(location.directory) !== path.resolve(os.homedir())) {
yield* watcher
.subscribe({
path: location.directory,
type: "directory",
ignore: [...Ignore.PATTERNS, ...config, ...protecteds(location.directory)],
})
.pipe(Stream.runForEach(publish), Effect.forkScoped({ startImmediately: true }))
} else {
yield* Effect.logInfo("location watcher skipped home directory", { directory: location.directory })
}
yield* Effect.gen(function* () {
const config = (yield* configService.entries())
.filter((entry): entry is Config.Document => entry.type === "document")
.flatMap((item) => item.info.watcher?.ignore ?? [])
const home = path.resolve(location.directory) === path.resolve(os.homedir())
if (location.vcs?.type === "git") {
const resolved = (yield* git.repo.discover(location.directory))?.gitDirectory
const vcs = resolved ? yield* fs.realPath(resolved).pipe(Effect.catch(() => Effect.succeed(resolved))) : undefined
if (vcs && !config.includes(".git") && !config.includes(vcs) && (!resolved || !config.includes(resolved))) {
const ignore = (yield* fs.readDirectoryEntries(vcs).pipe(Effect.catch(() => Effect.succeed([])))).flatMap(
(entry) => (entry.name === "HEAD" ? [] : [entry.name]),
)
if (!home) {
yield* watcher
.subscribe({ path: vcs, type: "directory", ignore })
.pipe(Stream.runForEach(publish), Effect.forkScoped({ startImmediately: true }))
.subscribe({
path: location.directory,
type: "directory",
ignore: [...Ignore.PATTERNS, ...config, ...protecteds(location.directory)],
})
.pipe(Stream.runForEach(publish), Effect.forkScoped)
}
}
if (home) {
yield* Effect.logInfo("location watcher skipped home directory", { directory: location.directory })
}
if (location.vcs?.type === "git") {
const resolved = (yield* git.repo.discover(location.directory))?.gitDirectory
const vcs = resolved
? yield* fs.realPath(resolved).pipe(Effect.catch(() => Effect.succeed(resolved)))
: undefined
if (vcs && !config.includes(".git") && !config.includes(vcs) && (!resolved || !config.includes(resolved))) {
const ignore = (yield* fs.readDirectoryEntries(vcs).pipe(Effect.catch(() => Effect.succeed([])))).flatMap(
(entry) => (entry.name === "HEAD" ? [] : [entry.name]),
)
yield* watcher
.subscribe({ path: vcs, type: "directory", ignore })
.pipe(Stream.runForEach(publish), Effect.forkScoped)
}
}
}).pipe(
Effect.withSpan("LocationWatcher.start", { attributes: { directory: location.directory } }),
Effect.catchCause((cause) => Effect.logError("failed to init location watcher service", { cause })),
Effect.forkScoped,
)
return Service.of({})
}).pipe(
Effect.catchCause((cause) =>
Effect.logError("failed to init location watcher service", { cause }).pipe(Effect.as(Service.of({}))),
),
),
}),
)
export const node = makeLocationNode({

View file

@ -224,7 +224,7 @@ const layer = Layer.effect(
})
return Service.of({ capture, files, diff, preview, restore, checkout })
}),
}).pipe(Effect.withSpan("Snapshot.boot")),
)
export const node = makeLocationNode({

View file

@ -0,0 +1,45 @@
import { describe, expect, test } from "bun:test"
import { Effect, Layer, Logger } from "effect"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
import { Database } from "@opencode-ai/core/database/database"
import { EventV2 } from "@opencode-ai/core/event"
import { EventLogger } from "@opencode-ai/core/event-logger"
import { Agent } from "@opencode-ai/schema/agent"
import { Catalog } from "@opencode-ai/schema/catalog"
import { Command } from "@opencode-ai/schema/command"
import { Config } from "@opencode-ai/schema/config"
import { McpEvent } from "@opencode-ai/schema/mcp-event"
const UnlistedUpdated = EventV2.ephemeral({ type: "test.updated", schema: {} })
describe("EventLogger", () => {
test("logs explicitly listed updated events", async () => {
const output = new Array<ReturnType<typeof Logger.formatStructured.log>>()
const logger = Logger.map(Logger.formatStructured, (entry) => {
output.push(entry)
})
await Effect.gen(function* () {
const events = yield* EventV2.Service
yield* events.publish(Agent.Event.Updated, {})
yield* events.publish(Catalog.Event.Updated, {})
yield* events.publish(Command.Event.Updated, {})
yield* events.publish(Config.Event.Updated, {})
yield* events.publish(McpEvent.StatusChanged, { server: "example" })
yield* events.publish(UnlistedUpdated, {})
}).pipe(
Effect.provide(AppNodeBuilder.build(LayerNode.group([Database.node, EventV2.node, EventLogger.node]))),
Effect.provide(Logger.layer([logger])),
Effect.scoped,
Effect.runPromise,
)
expect(output.map((entry) => entry.message)).toEqual([
["event", { event: expect.objectContaining({ type: "agent.updated" }) }],
["event", { event: expect.objectContaining({ type: "catalog.updated" }) }],
["event", { event: expect.objectContaining({ type: "command.updated" }) }],
["event", { event: expect.objectContaining({ type: "config.updated" }) }],
])
})
})