mirror of
https://github.com/anomalyco/opencode.git
synced 2026-07-10 04:38:31 +00:00
feat(core): improve runtime observability
This commit is contained in:
parent
a9b7bd9e2f
commit
c3d26c4912
6 changed files with 112 additions and 40 deletions
|
|
@ -1,10 +1,10 @@
|
|||
#!/usr/bin/env bun
|
||||
|
||||
import { NodeFileSystem, NodeRuntime, NodeServices } from "@effect/platform-node"
|
||||
import { Effect, Layer, Logger, References } from "effect"
|
||||
import { NodeRuntime, NodeServices } from "@effect/platform-node"
|
||||
import { Effect } from "effect"
|
||||
import { Commands } from "./commands/commands"
|
||||
import { Runtime } from "./framework/runtime"
|
||||
import { Logging } from "@opencode-ai/core/observability/logging"
|
||||
import { Observability } from "@opencode-ai/core/observability"
|
||||
import { Updater } from "./services/updater"
|
||||
import { InstallationChannel, InstallationVersion, InstallationLocal } from "@opencode-ai/core/installation/version"
|
||||
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
||||
|
|
@ -12,12 +12,6 @@ import { LayerNode } from "@opencode-ai/core/effect/layer-node"
|
|||
import { Global } from "@opencode-ai/core/global"
|
||||
import { AppProcess } from "@opencode-ai/core/process"
|
||||
|
||||
const LoggingLayer = Logger.layer(Logging.loggers(), { mergeWithExisting: false }).pipe(
|
||||
Layer.provide(NodeFileSystem.layer),
|
||||
Layer.orDie,
|
||||
Layer.merge(Layer.succeed(References.MinimumLogLevel, Logging.minimumLogLevel())),
|
||||
)
|
||||
|
||||
const Handlers = Runtime.handlers(Commands, {
|
||||
$: () => import("./commands/handlers/default"),
|
||||
api: () => import("./commands/handlers/api"),
|
||||
|
|
@ -53,7 +47,7 @@ Effect.logInfo("cli starting", {
|
|||
Effect.annotateLogs({ role: "cli" }),
|
||||
Effect.provide(Updater.layer),
|
||||
Effect.provide(AppNodeBuilder.build(LayerNode.group([Global.node, AppProcess.node]))),
|
||||
Effect.provide(LoggingLayer),
|
||||
Effect.provide(Observability.layer),
|
||||
Effect.provide(NodeServices.layer),
|
||||
Effect.scoped,
|
||||
Effect.tap(() => Effect.sync(() => process.exit(0))),
|
||||
|
|
|
|||
24
packages/core/src/event-logger.ts
Normal file
24
packages/core/src/event-logger.ts
Normal 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] })
|
||||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
45
packages/core/test/event-logger.test.ts
Normal file
45
packages/core/test/event-logger.test.ts
Normal 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" }) }],
|
||||
])
|
||||
})
|
||||
})
|
||||
|
|
@ -3,6 +3,7 @@ import { LayerNode } from "@opencode-ai/core/effect/layer-node"
|
|||
import { httpClient } from "@opencode-ai/core/effect/app-node-platform"
|
||||
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
||||
import { EventV2 } from "@opencode-ai/core/event"
|
||||
import { EventLogger } from "@opencode-ai/core/event-logger"
|
||||
import { Credential } from "@opencode-ai/core/credential"
|
||||
import { PermissionSaved } from "@opencode-ai/core/permission/saved"
|
||||
import { PtyTicket } from "@opencode-ai/core/pty/ticket"
|
||||
|
|
@ -31,6 +32,7 @@ import { sessionLocationLayer } from "./middleware/session-location"
|
|||
const applicationServices = LayerNode.group([
|
||||
Database.node,
|
||||
EventV2.node,
|
||||
EventLogger.node,
|
||||
httpClient,
|
||||
ToolOutputStore.cleanupNode,
|
||||
Job.node,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue