From c9b24ef027dbe5b338123e43ae395892650f8cd3 Mon Sep 17 00:00:00 2001 From: Dax Raad Date: Fri, 3 Jul 2026 20:34:29 -0400 Subject: [PATCH] feat(core): reload config on filesystem changes --- packages/app/src/context/file/watcher.test.ts | 10 +- packages/app/src/context/file/watcher.ts | 2 +- packages/app/src/pages/session.tsx | 2 +- packages/client/src/effect/index.ts | 1 + packages/client/src/promise/api.ts | 2 + .../client/src/promise/generated/types.ts | 10 +- packages/client/src/promise/index.ts | 1 + packages/core/src/catalog.ts | 15 +- packages/core/src/config.ts | 120 ++++++---- packages/core/src/config/experimental.ts | 20 -- packages/core/src/config/plugin/command.ts | 33 ++- .../core/src/filesystem/location-watcher.ts | 83 +++++++ packages/core/src/filesystem/watcher.ts | 220 ++++++++++-------- packages/core/src/location-services.ts | 6 +- packages/core/src/plugin/host.ts | 9 +- packages/core/src/plugin/promise.ts | 5 +- packages/core/src/policy.ts | 48 ---- packages/core/src/skill.ts | 4 +- packages/core/src/v1/config/config.ts | 4 - packages/core/src/v1/config/migrate.ts | 1 - packages/core/test/catalog.test.ts | 20 +- packages/core/test/config/command.test.ts | 19 +- packages/core/test/config/config.test.ts | 96 ++++---- packages/core/test/filesystem/watcher.test.ts | 28 ++- packages/core/test/location-layer.test.ts | 27 +-- packages/core/test/plugin.test.ts | 23 +- packages/core/test/plugin/host.ts | 5 +- packages/core/test/policy.test.ts | 85 ------- packages/core/test/skill.test.ts | 4 +- packages/opencode/src/tool/apply_patch.ts | 3 +- packages/opencode/src/tool/edit.ts | 5 +- packages/opencode/src/tool/write.ts | 3 +- .../test/server/httpapi-v2-location.test.ts | 2 +- packages/plugin/src/v2/effect/context.ts | 2 + packages/plugin/src/v2/effect/event.ts | 11 +- packages/plugin/src/v2/effect/index.ts | 1 + packages/plugin/src/v2/promise/context.ts | 2 + packages/plugin/src/v2/promise/event.ts | 3 + packages/plugin/src/v2/promise/index.ts | 1 + packages/schema/src/config.ts | 10 + packages/schema/src/event-manifest.ts | 6 +- packages/schema/src/filesystem-v1.ts | 1 + packages/schema/src/filesystem-watcher.ts | 13 -- packages/schema/src/filesystem.ts | 11 +- packages/schema/src/index.ts | 1 + packages/schema/src/v1/filesystem.ts | 11 + packages/schema/test/event-manifest.test.ts | 6 +- packages/sdk/js/src/v2/gen/types.gen.ts | 143 +++++++----- 48 files changed, 612 insertions(+), 526 deletions(-) delete mode 100644 packages/core/src/config/experimental.ts create mode 100644 packages/core/src/filesystem/location-watcher.ts delete mode 100644 packages/core/src/policy.ts delete mode 100644 packages/core/test/policy.test.ts create mode 100644 packages/plugin/src/v2/promise/event.ts create mode 100644 packages/schema/src/config.ts create mode 100644 packages/schema/src/filesystem-v1.ts delete mode 100644 packages/schema/src/filesystem-watcher.ts create mode 100644 packages/schema/src/v1/filesystem.ts diff --git a/packages/app/src/context/file/watcher.test.ts b/packages/app/src/context/file/watcher.test.ts index 9536b52536b..dbe745ff7d9 100644 --- a/packages/app/src/context/file/watcher.test.ts +++ b/packages/app/src/context/file/watcher.test.ts @@ -7,7 +7,7 @@ describe("file watcher invalidation", () => { const refresh: string[] = [] invalidateFromWatcher( { - type: "file.watcher.updated", + type: "filesystem.changed", properties: { file: "src/new.ts", event: "add", @@ -32,7 +32,7 @@ describe("file watcher invalidation", () => { invalidateFromWatcher( { - type: "file.watcher.updated", + type: "filesystem.changed", properties: { file: "src/open.ts", event: "change", @@ -63,7 +63,7 @@ describe("file watcher invalidation", () => { invalidateFromWatcher( { - type: "file.watcher.updated", + type: "filesystem.changed", properties: { file: "src", event: "change", @@ -81,7 +81,7 @@ describe("file watcher invalidation", () => { invalidateFromWatcher( { - type: "file.watcher.updated", + type: "filesystem.changed", properties: { file: "src/file.ts", event: "change", @@ -111,7 +111,7 @@ describe("file watcher invalidation", () => { invalidateFromWatcher( { - type: "file.watcher.updated", + type: "filesystem.changed", properties: { file: ".git/index.lock", event: "change", diff --git a/packages/app/src/context/file/watcher.ts b/packages/app/src/context/file/watcher.ts index fbf71992791..1dcaeffd255 100644 --- a/packages/app/src/context/file/watcher.ts +++ b/packages/app/src/context/file/watcher.ts @@ -16,7 +16,7 @@ type WatcherOps = { } export function invalidateFromWatcher(event: WatcherEvent, ops: WatcherOps) { - if (event.type !== "file.watcher.updated") return + if (event.type !== "filesystem.changed") return const props = typeof event.properties === "object" && event.properties ? (event.properties as Record) : undefined const rawPath = typeof props?.file === "string" ? props.file : undefined diff --git a/packages/app/src/pages/session.tsx b/packages/app/src/pages/session.tsx index 36be14546bb..25b24599545 100644 --- a/packages/app/src/pages/session.tsx +++ b/packages/app/src/pages/session.tsx @@ -820,7 +820,7 @@ export default function Page() { ) const stopVcs = sdk().event.listen((evt) => { - if (evt.details.type !== "file.watcher.updated") return + if (evt.details.type !== "filesystem.changed") return const props = typeof evt.details.properties === "object" && evt.details.properties ? (evt.details.properties as Record) diff --git a/packages/client/src/effect/index.ts b/packages/client/src/effect/index.ts index 50b85da6277..8cde84695c9 100644 --- a/packages/client/src/effect/index.ts +++ b/packages/client/src/effect/index.ts @@ -8,6 +8,7 @@ export type { AppApi, CatalogApi, CommandApi, + EventApi, IntegrationApi, ModelApi, PluginApi, diff --git a/packages/client/src/promise/api.ts b/packages/client/src/promise/api.ts index a0801569429..e7d1c27d259 100644 --- a/packages/client/src/promise/api.ts +++ b/packages/client/src/promise/api.ts @@ -1,6 +1,7 @@ import type { AgentApi as EffectAgentApi, CommandApi as EffectCommandApi, + EventApi as EffectEventApi, IntegrationApi as EffectIntegrationApi, ModelApi as EffectModelApi, PluginApi as EffectPluginApi, @@ -25,6 +26,7 @@ type PromisifyApi = { export type AgentApi = PromisifyApi> export type CommandApi = PromisifyApi> +export type EventApi = PromisifyApi> export type IntegrationApi = PromisifyApi> export type ModelApi = PromisifyApi> export type PluginApi = PromisifyApi> diff --git a/packages/client/src/promise/generated/types.ts b/packages/client/src/promise/generated/types.ts index e0372cd57e3..ff069630364 100644 --- a/packages/client/src/promise/generated/types.ts +++ b/packages/client/src/promise/generated/types.ts @@ -4923,9 +4923,9 @@ export type EventSubscribeOutput = readonly id: string readonly created: number readonly metadata?: { readonly [x: string]: unknown } - readonly type: "file.edited" + readonly type: "filesystem.changed" readonly location?: { readonly directory: string; readonly workspaceID?: string } - readonly data: { readonly file: string } + readonly data: { readonly file: string; readonly event: "add" | "change" | "unlink" } } | { readonly id: string @@ -4991,7 +4991,7 @@ export type EventSubscribeOutput = readonly id: string readonly created: number readonly metadata?: { readonly [x: string]: unknown } - readonly type: "skill.updated" + readonly type: "config.updated" readonly location?: { readonly directory: string; readonly workspaceID?: string } readonly data: {} } @@ -4999,9 +4999,9 @@ export type EventSubscribeOutput = readonly id: string readonly created: number readonly metadata?: { readonly [x: string]: unknown } - readonly type: "file.watcher.updated" + readonly type: "skill.updated" readonly location?: { readonly directory: string; readonly workspaceID?: string } - readonly data: { readonly file: string; readonly event: "add" | "change" | "unlink" } + readonly data: {} } | { readonly id: string diff --git a/packages/client/src/promise/index.ts b/packages/client/src/promise/index.ts index 9203fe5477b..fd889c64e55 100644 --- a/packages/client/src/promise/index.ts +++ b/packages/client/src/promise/index.ts @@ -3,6 +3,7 @@ export type { AgentApi, CatalogApi, CommandApi, + EventApi, IntegrationApi, ModelApi, PluginApi, diff --git a/packages/core/src/catalog.ts b/packages/core/src/catalog.ts index 78688ce12c9..ab34db8ea45 100644 --- a/packages/core/src/catalog.ts +++ b/packages/core/src/catalog.ts @@ -1,12 +1,11 @@ export * as Catalog from "./catalog" import { makeLocationNode } from "./effect/app-node" -import { Array, Context, Effect, Layer, Option, Order, pipe, Schema } from "effect" +import { Array, Context, Effect, Layer, Option, Order, pipe } from "effect" import { Catalog } from "@opencode-ai/schema/catalog" import { ModelV2 } from "./model" import { ProviderV2 } from "./provider" import { EventV2 } from "./event" -import { Policy } from "./policy" import { State } from "./state" import { Integration } from "./integration" @@ -17,8 +16,6 @@ export type ProviderRecord = { export type DefaultModel = { providerID: ProviderV2.ID; modelID: ModelV2.ID } -export const PolicyActions = Schema.Literals(["provider.use"]) - export const Event = Catalog.Event type Data = { @@ -65,7 +62,6 @@ const layer = Layer.effect( Service, Effect.gen(function* () { const events = yield* EventV2.Service - const policy = yield* Policy.Service const integrations = yield* Integration.Service const available = (provider: ProviderV2.Info, integration: Integration.Info | undefined) => { @@ -159,13 +155,6 @@ const layer = Layer.effect( return result }, finalize: Effect.fn("CatalogV2.finalize")(function* (catalog) { - if (policy.hasStatements()) { - for (const record of [...catalog.provider.list()]) { - if ((yield* policy.evaluate("provider.use", record.provider.id, "allow")) === "deny") { - catalog.provider.remove(record.provider.id) - } - } - } yield* events.publish(Event.Updated, {}) }), }) @@ -294,4 +283,4 @@ const layer = Layer.effect( const SMALL_MODEL_RE = /\b(nano|flash|lite|mini|haiku|small|fast)\b/ -export const node = makeLocationNode({ service: Service, layer, deps: [EventV2.node, Policy.node, Integration.node] }) +export const node = makeLocationNode({ service: Service, layer, deps: [EventV2.node, Integration.node] }) diff --git a/packages/core/src/config.ts b/packages/core/src/config.ts index 9d03d09c38d..9e829fb7402 100644 --- a/packages/core/src/config.ts +++ b/packages/core/src/config.ts @@ -3,18 +3,19 @@ export * as Config from "./config" import { makeLocationNode } from "./effect/app-node" import path from "path" import { type ParseError, parse } from "jsonc-parser" -import { Context, Effect, Layer, Option, Schema } from "effect" +import { Context, Effect, Fiber, Layer, Option, PubSub, Schema, Stream } from "effect" import { Permission } from "@opencode-ai/schema/permission" +import { Config as ConfigSchema } from "@opencode-ai/schema/config" +import { EventV2 } from "./event" +import { Watcher } from "./filesystem/watcher" import { FSUtil } from "./fs-util" import { Global } from "./global" import { Location } from "./location" -import { Policy } from "./policy" import { AbsolutePath } from "./schema" import { ConfigAgent } from "./config/agent" import { ConfigAttachments } from "./config/attachments" import { ConfigCompaction } from "./config/compaction" import { ConfigCommand } from "./config/command" -import { ConfigExperimental } from "./config/experimental" import { ConfigFormatter } from "./config/formatter" import { ConfigLSP } from "./config/lsp" import { ConfigMCP } from "./config/mcp" @@ -102,7 +103,6 @@ export class Info extends Schema.Class("Config.Info")({ plugins: ConfigPlugin.Plugins.pipe(Schema.optional).annotate({ description: "Ordered external plugin packages to load", }), - experimental: ConfigExperimental.Experimental.pipe(Schema.optional), providers: Schema.Record(Schema.String, ConfigProvider.Info).pipe(Schema.optional), }) {} @@ -138,7 +138,8 @@ const layer = Layer.effect( const fs = yield* FSUtil.Service const global = yield* Global.Service const location = yield* Location.Service - const policy = yield* Policy.Service + const watcher = yield* Watcher.Service + const events = yield* EventV2.Service const names = ["opencode.json", "opencode.jsonc"] const decodeOptions = { errors: "all", onExcessProperty: "ignore", propertyOrder: "original" } as const const decodeInfo = Schema.decodeUnknownOption(Info, decodeOptions) @@ -170,45 +171,78 @@ const layer = Layer.effect( ] }) - const globalDirectory = AbsolutePath.make(global.config) - const locationIsGlobal = path.resolve(location.directory) === path.resolve(global.config) - // Read configuration once when this location opens. Later calls reuse these - // values until the location is reopened. - const discovered = locationIsGlobal - ? [] - : yield* fs - .up({ - targets: [".opencode", ...names.toReversed()], - start: location.directory, - stop: location.project.directory, - }) - .pipe(Effect.orDie) - const directories = [ - globalDirectory, - ...discovered - .filter((item) => path.basename(item) === ".opencode") - .toReversed() - .map((directory) => AbsolutePath.make(directory)), + const discover = Effect.fn("Config.discover")(function* () { + const globalDirectory = AbsolutePath.make(global.config) + const locationIsGlobal = path.resolve(location.directory) === path.resolve(global.config) + const discovered = locationIsGlobal + ? [] + : yield* fs + .up({ + targets: [".opencode", ...names.toReversed()], + start: location.directory, + stop: location.project.directory, + }) + .pipe(Effect.orDie) + const directories = [ + globalDirectory, + ...discovered + .filter((item) => path.basename(item) === ".opencode") + .toReversed() + .map((directory) => AbsolutePath.make(directory)), + ] + const directPaths = discovered.filter((item) => path.basename(item) !== ".opencode").toReversed() + const direct = yield* Effect.forEach(directPaths, loadFile).pipe( + Effect.orDie, + Effect.map((configs) => configs.filter((config): config is Document => config !== undefined)), + ) + const supplementary = yield* Effect.forEach(directories, loadDirectory).pipe(Effect.orDie) + return { + entries: [...(supplementary[0] ?? []), ...direct, ...supplementary.slice(1).flat()], + directories, + files: directPaths, + } + }) + + const initial = yield* discover() + let configs = initial.entries + const updates = yield* PubSub.unbounded() + const subscriptions = new Map>() + const targets = (snapshot: typeof initial) => [ + ...snapshot.directories.map((path) => ({ path, type: "directory" as const })), + ...snapshot.files + .filter((file) => !snapshot.directories.some((directory) => FSUtil.contains(directory, file))) + .map((path) => ({ path, type: "file" as const })), ] - // A config closer to the opened directory should win over one higher up. - // Search starts nearby, so reverse the results before applying them. - const directPaths = discovered.filter((item) => path.basename(item) !== ".opencode").toReversed() - const direct = yield* Effect.forEach(directPaths, loadFile).pipe( - Effect.orDie, - Effect.map((configs) => configs.filter((config): config is Document => config !== undefined)), - ) - const supplementary = yield* Effect.forEach(directories, loadDirectory).pipe(Effect.orDie) - // Apply general settings first and more specific settings last: - // global config, project files, then `.opencode` files. - const configs = [...(supplementary[0] ?? []), ...direct, ...supplementary.slice(1).flat()] - // Rules use the opposite order so a user-global rule can override a - // repository rule. Statement order inside each file stays unchanged. - yield* policy.load( - configs - .filter((config): config is Document => config.type === "document") - .toReversed() - .flatMap((config) => config.info.experimental?.policies ?? []), + const reconcile = Effect.fn("Config.reconcileWatches")(function* (snapshot: typeof initial) { + const next = new Map(targets(snapshot).map((target) => [JSON.stringify(target), target])) + for (const [key, stop] of subscriptions) { + if (next.has(key)) continue + yield* stop + subscriptions.delete(key) + } + for (const [key, target] of next) { + if (subscriptions.has(key)) continue + const fiber = yield* watcher.subscribe(target).pipe( + Stream.runForEach((update) => PubSub.publish(updates, update)), + Effect.forkScoped({ startImmediately: true }), + ) + subscriptions.set(key, Fiber.interrupt(fiber)) + } + }) + + yield* Stream.fromPubSub(updates).pipe( + Stream.debounce("100 millis"), + Stream.runForEach((update) => + Effect.gen(function* () { + const next = yield* discover() + configs = next.entries + yield* reconcile(next) + yield* events.publish(ConfigSchema.Event.Updated, {}) + }).pipe(Effect.catchCause((cause) => Effect.logError("failed to reload config", { path: update.path, cause }))), + ), + Effect.forkScoped({ startImmediately: true }), ) + yield* reconcile(initial) return Service.of({ entries: Effect.fn("Config.entries")(function* () { @@ -221,5 +255,5 @@ const layer = Layer.effect( export const node = makeLocationNode({ service: Service, layer, - deps: [FSUtil.node, Global.node, Location.node, Policy.node], + deps: [Watcher.node, EventV2.node, FSUtil.node, Global.node, Location.node], }) diff --git a/packages/core/src/config/experimental.ts b/packages/core/src/config/experimental.ts deleted file mode 100644 index 8b38a225b4c..00000000000 --- a/packages/core/src/config/experimental.ts +++ /dev/null @@ -1,20 +0,0 @@ -export * as ConfigExperimental from "./experimental" - -import { Schema } from "effect" -import { Catalog } from "../catalog" -import { Policy } from "../policy" - -// Each core domain exports the policy actions it supports. Adding an action to -// this union makes it valid in authored config while keeping Policy generic. -export const PolicyAction = Schema.Union([Catalog.PolicyActions]) - -class PolicyConfig extends Schema.Class("ConfigV2.Experimental.Policy")({ - ...Policy.Info.fields, - action: PolicyAction, -}) {} - -export { PolicyConfig as Policy } - -export class Experimental extends Schema.Class("ConfigV2.Experimental")({ - policies: PolicyConfig.pipe(Schema.Array, Schema.optional), -}) {} diff --git a/packages/core/src/config/plugin/command.ts b/packages/core/src/config/plugin/command.ts index bb7a030cbba..43ba4cd3758 100644 --- a/packages/core/src/config/plugin/command.ts +++ b/packages/core/src/config/plugin/command.ts @@ -2,7 +2,7 @@ export * as ConfigCommandPlugin from "./command" import { define } from "../../plugin/internal" import path from "path" -import { Effect, Option, Schema } from "effect" +import { Effect, Option, Schema, Stream } from "effect" import { CommandV2 } from "../../command" import { Config } from "../../config" import { FSUtil } from "../../fs-util" @@ -17,16 +17,19 @@ export const Plugin = define({ effect: Effect.fn(function* (ctx) { const config = yield* Config.Service const fs = yield* FSUtil.Service - const documents = yield* Effect.forEach(yield* config.entries(), (entry) => { - if (entry.type === "document") return Effect.succeed([{ commands: entry.info.commands }]) - return loadDirectory(fs, entry.path).pipe( - Effect.map((commands) => [ - { commands: Object.fromEntries(commands.map((command) => [command.name, command.info])) }, - ]), - ) - }).pipe(Effect.map((documents) => documents.flat())) + const load = Effect.fn("ConfigCommandPlugin.load")(function* () { + return yield* Effect.forEach(yield* config.entries(), (entry) => { + if (entry.type === "document") return Effect.succeed([{ commands: entry.info.commands }]) + return loadDirectory(fs, entry.path).pipe( + Effect.map((commands) => [ + { commands: Object.fromEntries(commands.map((command) => [command.name, command.info])) }, + ]), + ) + }).pipe(Effect.map((documents) => documents.flat())) + }) + const loaded = { documents: yield* load() } yield* ctx.command.transform((draft) => { - for (const document of documents) { + for (const document of loaded.documents) { for (const [name, command] of Object.entries(document.commands ?? {})) { draft.update(name, (item) => { item.template = command.template @@ -44,6 +47,16 @@ export const Plugin = define({ } } }) + yield* ctx.event.subscribe().pipe( + Stream.filter((event) => event.type === "config.updated"), + Stream.runForEach(() => + load().pipe( + Effect.tap((documents) => Effect.sync(() => (loaded.documents = documents))), + Effect.andThen(ctx.command.reload()), + ), + ), + Effect.forkScoped({ startImmediately: true }), + ) }), }) diff --git a/packages/core/src/filesystem/location-watcher.ts b/packages/core/src/filesystem/location-watcher.ts new file mode 100644 index 00000000000..1566d3d8ff6 --- /dev/null +++ b/packages/core/src/filesystem/location-watcher.ts @@ -0,0 +1,83 @@ +export * as LocationWatcher from "./location-watcher" + +import { makeLocationNode } from "../effect/app-node" +import { Context, Effect, Layer, Stream } from "effect" +import { FileSystem } from "@opencode-ai/schema/filesystem" +import os from "os" +import path from "path" +import { Config } from "../config" +import { EventV2 } from "../event" +import { FSUtil } from "../fs-util" +import { Git } from "../git" +import { Location } from "../location" +import { Watcher } from "./watcher" +import { Ignore } from "./ignore" +import { Protected } from "./protected" + +function protecteds(dir: string) { + return Protected.paths().filter((item) => { + const relative = path.relative(dir, item) + return relative !== "" && !relative.startsWith("..") && !path.isAbsolute(relative) + }) +} + +export interface Interface {} + +export class Service extends Context.Service()("@opencode/LocationWatcher") {} + +const layer = Layer.effect( + Service, + Effect.gen(function* () { + const location = yield* Location.Service + const watcher = yield* Watcher.Service + const events = yield* EventV2.Service + 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 }) + } + + 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({ startImmediately: true })) + } + } + + 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({ + service: Service, + layer, + deps: [Watcher.node, FSUtil.node, Location.node, Config.node, Git.node, EventV2.node], +}) diff --git a/packages/core/src/filesystem/watcher.ts b/packages/core/src/filesystem/watcher.ts index 1efc9d69074..2febe93b76a 100644 --- a/packages/core/src/filesystem/watcher.ts +++ b/packages/core/src/filesystem/watcher.ts @@ -3,26 +3,20 @@ export * as Watcher from "./watcher" // @ts-ignore import { createWrapper } from "@parcel/watcher/wrapper" import type ParcelWatcher from "@parcel/watcher" -import { makeLocationNode } from "../effect/app-node" -import { Cause, Context, Effect, Layer } from "effect" -import { FileSystemWatcher } from "@opencode-ai/schema/filesystem-watcher" -import os from "os" -import path from "path" -import { Config } from "../config" -import { EventV2 } from "../event" +import { FileSystem } from "@opencode-ai/schema/filesystem" +import { makeGlobalNode } from "../effect/app-node" +import { Cause, Context, Effect, Layer, PubSub, Scope, Stream } from "effect" +import { KeyedMutex } from "../effect/keyed-mutex" import { Flag } from "../flag/flag" -import { FSUtil } from "../fs-util" -import { Git } from "../git" -import { Location } from "../location" import { lazy } from "../util/lazy" -import { Ignore } from "./ignore" -import { Protected } from "./protected" +import { watch as watchFileSystem } from "node:fs" +import path from "path" declare const OPENCODE_LIBC: string | undefined const SUBSCRIBE_TIMEOUT_MS = 10_000 -export const Event = FileSystemWatcher.Event +export const Event = { Updated: FileSystem.Event.Changed } const watcher = lazy((): typeof import("@parcel/watcher") | undefined => { try { @@ -42,108 +36,132 @@ function getBackend() { if (process.platform === "linux") return "inotify" } -function protecteds(dir: string) { - return Protected.paths().filter((item) => { - const relative = path.relative(dir, item) - return relative !== "" && !relative.startsWith("..") && !path.isAbsolute(relative) - }) +export const hasNativeBinding = () => !!watcher() +export type Update = ParcelWatcher.Event + +export type WatchInput = + | { readonly path: string; readonly type: "file" } + | { readonly path: string; readonly type: "directory"; readonly ignore?: readonly string[] } + +export interface Interface { + readonly subscribe: (input: WatchInput) => Stream.Stream } -export const hasNativeBinding = () => !!watcher() - -export interface Interface {} - -export class Service extends Context.Service()("@opencode/v2/FileWatcher") {} +export class Service extends Context.Service()("@opencode/Watcher") {} const layer = Layer.effect( Service, Effect.gen(function* () { - if (Flag.OPENCODE_DISABLE_FILEWATCHER) return Service.of({}) - const backend = getBackend() - const location = yield* Location.Service - if (path.resolve(location.directory) === path.resolve(os.homedir())) { - yield* Effect.logInfo("watcher skipped home directory", { directory: location.directory }) - return Service.of({}) - } - if (!backend) { - yield* Effect.logError("watcher backend not supported", { - directory: location.directory, - platform: process.platform, - }) - return Service.of({}) + const native = watcher() + if (Flag.OPENCODE_DISABLE_FILEWATCHER) { + return Service.of({ subscribe: () => Stream.empty }) } - const w = watcher() - if (!w) return Service.of({}) - - yield* Effect.logInfo("watcher backend", { directory: location.directory, platform: process.platform, backend }) - const events = yield* EventV2.Service - const fs = yield* FSUtil.Service - const git = yield* Git.Service - const context = yield* Effect.context() - const runFork = Effect.runForkWith(context) - const subscriptions: ParcelWatcher.AsyncSubscription[] = [] - yield* Effect.addFinalizer(() => - Effect.promise(() => Promise.allSettled(subscriptions.map((subscription) => subscription.unsubscribe()))), - ) - - const callback: ParcelWatcher.SubscribeCallback = (_error, updates) => { - if (_error) runFork(Effect.logError("watcher callback failed", { error: _error })) - for (const update of updates) { - if (update.type === "create") runFork(events.publish(Event.Updated, { file: update.path, event: "add" })) - if (update.type === "update") runFork(events.publish(Event.Updated, { file: update.path, event: "change" })) - if (update.type === "delete") runFork(events.publish(Event.Updated, { file: update.path, event: "unlink" })) - } + type Entry = { + readonly pubsub: PubSub.PubSub + readonly subscription: { readonly unsubscribe: () => Promise } + refs: number } + const entries = new Map() + const locks = KeyedMutex.makeUnsafe() - const subscribe = (directory: string, ignore: string[]) => { - const pending = w.subscribe(directory, callback, { ignore, backend }) - return Effect.promise(() => pending).pipe( - Effect.tap((subscription) => - Effect.sync(() => subscriptions.push(subscription)).pipe( - Effect.andThen(Effect.logInfo("watcher subscribed", { directory, backend, ignores: ignore.length })), - ), - ), - Effect.timeout(SUBSCRIBE_TIMEOUT_MS), - Effect.catchCause((cause) => { - pending.then((subscription) => subscription.unsubscribe()).catch(() => {}) - return Effect.logError("failed to subscribe", { directory, cause: Cause.pretty(cause) }) + const acquire = Effect.fn("Watcher.acquire")(function* (input: WatchInput) { + const scope = yield* Scope.Scope + const target = path.resolve(input.path) + const directory = input.type === "file" ? path.dirname(target) : target + const ignore = [...new Set(input.type === "directory" ? (input.ignore ?? []) : [])].toSorted() + const id = JSON.stringify([input.type, target, ignore]) + const pubsub = yield* locks.withLock(id)( + Effect.gen(function* () { + const existing = entries.get(id) + if (existing) { + existing.refs++ + return existing.pubsub + } + const pubsub = yield* PubSub.unbounded() + const subscription = yield* input.type === "file" + ? Effect.sync(() => { + const subscription = watchFileSystem(directory, { recursive: false }, (_event, file) => { + if (file && path.resolve(directory, file.toString()) !== target) return + PubSub.publishUnsafe(pubsub, { + path: target, + type: "update", + } satisfies Update) + }) + subscription.on("error", (error) => + Effect.runFork(Effect.logError("watcher callback failed", { path: target, error })), + ) + return { unsubscribe: () => Promise.resolve(subscription.close()) } + }) + : subscribeDirectory(native, backend, directory, ignore, pubsub) + if (subscription) { + entries.set(id, { pubsub, subscription, refs: 1 }) + yield* Effect.logInfo("watcher started", { + path: target, + type: input.type, + backend: input.type === "file" ? "node" : backend, + ignores: ignore.length, + }) + return pubsub + } + yield* PubSub.shutdown(pubsub) + return pubsub }), ) - } - 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 ?? []) - yield* Effect.forkScoped( - subscribe(location.directory, [...Ignore.PATTERNS, ...config, ...protecteds(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* Effect.forkScoped(subscribe(vcs, ignore)) - } - } - - return Service.of({}) - }).pipe( - Effect.catchCause((cause) => { - return Effect.logError("failed to init watcher service", { cause: Cause.pretty(cause) }).pipe( - Effect.as(Service.of({})), + yield* Scope.addFinalizer( + scope, + locks.withLock(id)( + Effect.gen(function* () { + const entry = entries.get(id) + if (!entry) return + entry.refs-- + if (entry.refs > 0) return + entries.delete(id) + yield* Effect.promise(() => entry.subscription.unsubscribe()).pipe(Effect.ignore) + yield* PubSub.shutdown(entry.pubsub) + yield* Effect.logInfo("watcher stopped", { path: target, type: input.type }) + }), + ), ) - }), - ), + return pubsub + }) + + const subscribe = (input: WatchInput) => + Stream.unwrap(acquire(input).pipe(Effect.map((pubsub) => Stream.fromPubSub(pubsub)))) + + return Service.of({ subscribe }) + }), ) -export const node = makeLocationNode({ - service: Service, - layer, - deps: [FSUtil.node, Location.node, Config.node, Git.node, EventV2.node], -}) +export const node = makeGlobalNode({ service: Service, layer, deps: [] }) + +function subscribeDirectory( + native: typeof import("@parcel/watcher") | undefined, + backend: ParcelWatcher.BackendType | undefined, + directory: string, + ignore: string[], + pubsub: PubSub.PubSub, +) { + if (!native || !backend) { + return Effect.logError("watcher backend not supported", { directory, platform: process.platform }).pipe( + Effect.as(undefined), + ) + } + const callback: ParcelWatcher.SubscribeCallback = (error, updates) => { + if (error) Effect.runFork(Effect.logError("watcher callback failed", { error })) + for (const update of updates) PubSub.publishUnsafe(pubsub, update) + } + const pending = native.subscribe(directory, callback, { ignore, backend }) + return Effect.promise(() => pending).pipe( + Effect.timeout(SUBSCRIBE_TIMEOUT_MS), + Effect.catchCause((cause) => { + pending.then((subscription) => subscription.unsubscribe()).catch(() => {}) + return Effect.logError("failed to subscribe", { + directory, + cause: Cause.pretty(cause), + }).pipe(Effect.as(undefined)) + }), + ) +} diff --git a/packages/core/src/location-services.ts b/packages/core/src/location-services.ts index f0f2d63e7f1..2a4e0d6ef67 100644 --- a/packages/core/src/location-services.ts +++ b/packages/core/src/location-services.ts @@ -11,7 +11,7 @@ import { FileSystem } from "./filesystem" import { FileSystemSearch } from "./filesystem/search" import { Generate } from "./generate" import { Form } from "./form" -import { Watcher } from "./filesystem/watcher" +import { LocationWatcher } from "./filesystem/location-watcher" import { Image } from "./image" import { Integration } from "./integration" import { Location } from "./location" @@ -21,7 +21,6 @@ import { MCP } from "./mcp/index" import { PermissionV2 } from "./permission" import { PluginV2 } from "./plugin" import { PluginInternal } from "./plugin/internal" -import { Policy } from "./policy" import { ProjectCopy } from "./project/copy" import { Pty } from "./pty" import { QuestionV2 } from "./question" @@ -50,7 +49,6 @@ export { LocationServiceMap } from "./location-service-map" const locationServiceNodes = [ Location.node, - Policy.node, Config.node, AgentV2.node, CommandV2.node, @@ -64,7 +62,7 @@ const locationServiceNodes = [ ProjectCopy.refreshNode, FileSystemSearch.node, FileSystem.node, - Watcher.node, + LocationWatcher.node, Pty.node, Shell.node, SkillV2.node, diff --git a/packages/core/src/plugin/host.ts b/packages/core/src/plugin/host.ts index bf3420a023d..d25555eb378 100644 --- a/packages/core/src/plugin/host.ts +++ b/packages/core/src/plugin/host.ts @@ -1,12 +1,14 @@ export * as PluginHost from "./host" import type { PluginContext } from "@opencode-ai/plugin/v2/effect" -import { Effect, Schema } from "effect" +import { EventManifest } from "@opencode-ai/schema/event-manifest" +import { Effect, Schema, Stream } from "effect" import { AgentV2 } from "../agent" import { AISDK } from "../aisdk" import { Catalog } from "../catalog" import { CommandV2 } from "../command" import { Credential } from "../credential" +import { EventV2 } from "../event" import { Integration } from "../integration" import { Location } from "../location" import { ModelV2 } from "../model" @@ -21,12 +23,14 @@ import { ToolHooks } from "../tool/hooks" import { WorkspaceV2 } from "../workspace" const mutable = (value: T) => value as DeepMutable +const isEvent = Schema.is(Schema.Union(EventManifest.ServerDefinitions)) export const make = Effect.fn("PluginHost.make")(function* (plugin: PluginV2.Interface) { const agents = yield* AgentV2.Service const aisdk = yield* AISDK.Service const catalog = yield* Catalog.Service const commands = yield* CommandV2.Service + const events = yield* EventV2.Service const integration = yield* Integration.Service const location = yield* Location.Service const reference = yield* Reference.Service @@ -155,6 +159,9 @@ export const make = Effect.fn("PluginHost.make")(function* (plugin: PluginV2.Int callback(draft) }), }, + event: { + subscribe: () => events.live().pipe(Stream.filter(isEvent)), + }, integration: { list: () => response(integration.list()), get: (input) => response(integration.get(Integration.ID.make(input.integrationID))), diff --git a/packages/core/src/plugin/promise.ts b/packages/core/src/plugin/promise.ts index 956c530176b..b315d3214a1 100644 --- a/packages/core/src/plugin/promise.ts +++ b/packages/core/src/plugin/promise.ts @@ -2,7 +2,7 @@ export * as PluginPromise from "./promise" import { define } from "@opencode-ai/plugin/v2/effect" import type { Plugin, PluginContext } from "@opencode-ai/plugin/v2/promise" -import { Effect, Scope } from "effect" +import { Effect, Scope, Stream } from "effect" type HostRegistration = { readonly dispose: Effect.Effect } type Registration = { readonly dispose: () => Promise } @@ -73,6 +73,9 @@ export function fromPromise(plugin: Plugin) { transform: transform(host.command), reload: () => run(host.command.reload()), }, + event: { + subscribe: () => Stream.toAsyncIterable(host.event.subscribe()), + }, integration: { list: (input) => run(host.integration.list(input)), get: (input) => run(host.integration.get(input)), diff --git a/packages/core/src/policy.ts b/packages/core/src/policy.ts deleted file mode 100644 index d1dbb3e37c8..00000000000 --- a/packages/core/src/policy.ts +++ /dev/null @@ -1,48 +0,0 @@ -export * as Policy from "./policy" - -import { makeLocationNode } from "./effect/app-node" -import { Context, Effect, Layer, Schema } from "effect" -import { Wildcard } from "./util/wildcard" -import { Location } from "./location" - -const PolicyEffect = Schema.Literals(["allow", "deny"]).annotate({ identifier: "Policy.Effect" }) -export { PolicyEffect as Effect } -export type Effect = typeof PolicyEffect.Type - -export class Info extends Schema.Class("Policy.Info")({ - action: Schema.String, - effect: PolicyEffect, - resource: Schema.String, -}) {} - -export interface Interface { - readonly load: (statements: Info[]) => Effect.Effect - readonly evaluate: (action: string, resource: string, fallback: Effect) => Effect.Effect - readonly hasStatements: () => boolean -} - -export class Service extends Context.Service()("@opencode/v2/Policy") {} - -const layer = Layer.effect( - Service, - Effect.gen(function* () { - let statements: Info[] = [] - yield* Location.Service - - return Service.of({ - load: Effect.fn("Policy.load")(function* (input) { - statements = input - }), - hasStatements: () => statements.length > 0, - evaluate: Effect.fn("Policy.evaluate")(function* (action, resource, fallback) { - return ( - statements.findLast( - (statement) => Wildcard.match(action, statement.action) && Wildcard.match(resource, statement.resource), - )?.effect ?? fallback - ) - }), - }) - }), -) - -export const node = makeLocationNode({ service: Service, layer, deps: [Location.node] }) diff --git a/packages/core/src/skill.ts b/packages/core/src/skill.ts index c448eae5496..511e02af875 100644 --- a/packages/core/src/skill.ts +++ b/packages/core/src/skill.ts @@ -3,7 +3,7 @@ export * as SkillV2 from "./skill" import { makeLocationNode } from "./effect/app-node" import path from "path" import { Context, Effect, Layer, Schema, Stream, Types } from "effect" -import { FileSystemWatcher } from "@opencode-ai/schema/filesystem-watcher" +import { FileSystem } from "@opencode-ai/schema/filesystem" import { Skill } from "@opencode-ai/schema/skill" import { AgentV2 } from "./agent" import { ConfigMarkdown } from "./config/markdown" @@ -153,7 +153,7 @@ const layer = Layer.effect( yield* events.publish(Event.Updated, {}).pipe(Effect.asVoid) }) - yield* events.subscribe(FileSystemWatcher.Event.Updated).pipe( + yield* events.subscribe(FileSystem.Event.Changed).pipe( Stream.runForEach((event) => invalidate(event.data.file)), Effect.forkScoped({ startImmediately: true }), ) diff --git a/packages/core/src/v1/config/config.ts b/packages/core/src/v1/config/config.ts index 2e773f71e25..d32af99812f 100644 --- a/packages/core/src/v1/config/config.ts +++ b/packages/core/src/v1/config/config.ts @@ -2,7 +2,6 @@ export * as ConfigV1 from "./config" import { Schema } from "effect" import { NonNegativeInt, PositiveInt, type DeepMutable } from "../../schema" -import { ConfigExperimental } from "../../config/experimental" import { ConfigReference } from "../../config/reference" import { ConfigAgentV1 } from "./agent" import { ConfigAttachmentV1 } from "./attachment" @@ -179,9 +178,6 @@ export const Info = Schema.Struct({ mcp_timeout: Schema.optional(PositiveInt).annotate({ description: "Timeout in milliseconds for model context protocol (MCP) requests", }), - policies: Schema.optional(Schema.mutable(Schema.Array(ConfigExperimental.Policy))).annotate({ - description: "Policy statements applied to supported resources, such as provider access", - }), }), ), }).annotate({ identifier: "Config" }) diff --git a/packages/core/src/v1/config/migrate.ts b/packages/core/src/v1/config/migrate.ts index 2a9e1c73832..046f29c4317 100644 --- a/packages/core/src/v1/config/migrate.ts +++ b/packages/core/src/v1/config/migrate.ts @@ -78,7 +78,6 @@ export function migrate(info: typeof ConfigV1.Info.Type) { plugins: info.plugin?.map((plugin) => typeof plugin === "string" ? plugin : { package: plugin[0], options: plugin[1] }, ), - experimental: info.experimental?.policies && { policies: info.experimental.policies }, providers: providers(info.provider), } } diff --git a/packages/core/test/catalog.test.ts b/packages/core/test/catalog.test.ts index 6c736cde1e1..04bc0068881 100644 --- a/packages/core/test/catalog.test.ts +++ b/packages/core/test/catalog.test.ts @@ -8,7 +8,6 @@ import { LayerNode } from "@opencode-ai/core/effect/layer-node" import { EventV2 } from "@opencode-ai/core/event" import { Location } from "@opencode-ai/core/location" import { ModelV2 } from "@opencode-ai/core/model" -import { Policy } from "@opencode-ai/core/policy" import { ProviderV2 } from "@opencode-ai/core/provider" import { AbsolutePath } from "@opencode-ai/core/schema" import { location } from "./fixture/location" @@ -24,7 +23,7 @@ const locationLayer = Layer.succeed( Location.Service.of(location({ directory: AbsolutePath.make("test") })), ) const catalogLayer = AppNodeBuilder.build( - LayerNode.group([Catalog.node, EventV2.node, Credential.node, Integration.node, Policy.node]), + LayerNode.group([Catalog.node, EventV2.node, Credential.node, Integration.node]), [[Location.node, locationLayer]], ) const it = testEffect(catalogLayer) @@ -333,21 +332,4 @@ describe("CatalogV2", () => { expect((yield* catalog.model.small(providerID))?.id).toMatch("expensive-mini") }), ) - - it.effect("removes providers denied by policy after loading", () => - Effect.gen(function* () { - const catalog = yield* Catalog.Service - const policy = yield* Policy.Service - const providerID = ProviderV2.ID.make("blocked") - yield* policy.load([new Policy.Info({ effect: "deny", action: "provider.use", resource: "blocked" })]) - yield* catalog.transform((catalog) => { - catalog.provider.update(providerID, () => {}) - catalog.model.update(providerID, ModelV2.ID.make("model"), () => {}) - }) - - expect(yield* catalog.provider.all()).toEqual([]) - expect(yield* catalog.model.all()).toEqual([]) - expect(yield* catalog.provider.get(providerID)).toBeUndefined() - }), - ) }) diff --git a/packages/core/test/config/command.test.ts b/packages/core/test/config/command.test.ts index 6c7f2ecc029..a8126d00f6e 100644 --- a/packages/core/test/config/command.test.ts +++ b/packages/core/test/config/command.test.ts @@ -1,13 +1,15 @@ import fs from "fs/promises" import path from "path" import { describe, expect } from "bun:test" -import { Effect, Schema } from "effect" +import { Effect, PubSub, Schema, Stream } from "effect" +import { Config as ConfigSchema } from "@opencode-ai/schema/config" import { CommandV2 } from "@opencode-ai/core/command" import { Config } from "@opencode-ai/core/config" import { ConfigCommandPlugin } from "@opencode-ai/core/config/plugin/command" import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" import { LayerNode } from "@opencode-ai/core/effect/layer-node" import { FSUtil } from "@opencode-ai/core/fs-util" +import { EventV2 } from "@opencode-ai/core/event" import { Location } from "@opencode-ai/core/location" import { MCP } from "@opencode-ai/core/mcp/index" import { ModelV2 } from "@opencode-ai/core/model" @@ -19,7 +21,7 @@ import { testEffect } from "../lib/effect" import { host } from "../plugin/host" const it = testEffect( - AppNodeBuilder.build(LayerNode.group([CommandV2.node, FSUtil.node]), [ + AppNodeBuilder.build(LayerNode.group([CommandV2.node, EventV2.node, FSUtil.node]), [ [MCP.node, emptyMcpLayer], [Config.node, emptyConfigLayer], [Location.node, testLocationLayer], @@ -53,6 +55,9 @@ Review files`, }) const command = yield* CommandV2.Service + const events = yield* EventV2.Service + const update = yield* events.publish(ConfigSchema.Event.Updated, {}) + const updates = yield* PubSub.unbounded() yield* ConfigCommandPlugin.Plugin.effect( host({ command: { @@ -60,6 +65,7 @@ Review files`, transform: command.transform, reload: command.reload, }, + event: { subscribe: () => Stream.fromPubSub(updates) }, }), ).pipe( Effect.provideService( @@ -93,6 +99,15 @@ Review files`, CommandV2.Info.make({ name: "empty", template: "" }), CommandV2.Info.make({ name: "nested/docs", template: "Write docs" }), ]) + + yield* Effect.promise(() => fs.writeFile(path.join(tmp.path, "commands", "review.md"), "Review again")) + yield* Effect.sleep("10 millis") + yield* PubSub.publish(updates, update) + for (let attempt = 0; attempt < 100; attempt++) { + if ((yield* command.get("review"))?.template === "Review again") break + yield* Effect.sleep("10 millis") + } + expect((yield* command.get("review"))?.template).toBe("Review again") }), ), ), diff --git a/packages/core/test/config/config.test.ts b/packages/core/test/config/config.test.ts index e46644abaee..f3c7b20909e 100644 --- a/packages/core/test/config/config.test.ts +++ b/packages/core/test/config/config.test.ts @@ -1,18 +1,20 @@ import path from "path" import fs from "fs/promises" import { describe, expect } from "bun:test" -import { Effect, Layer, Schema } from "effect" +import { Effect, Fiber, Layer, PubSub, Schema, Stream } from "effect" import { FastCheck } from "effect/testing" import { Config } from "@opencode-ai/core/config" +import { Config as ConfigSchema } from "@opencode-ai/schema/config" import { ConfigProvider } from "@opencode-ai/core/config/provider" import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" import { LayerNode } from "@opencode-ai/core/effect/layer-node" import { ConfigMigrateV1 } from "@opencode-ai/core/v1/config/migrate" import { ConfigV1 } from "@opencode-ai/core/v1/config/config" import { FSUtil } from "@opencode-ai/core/fs-util" +import { Watcher } from "@opencode-ai/core/filesystem/watcher" +import { EventV2 } from "@opencode-ai/core/event" import { Global } from "@opencode-ai/core/global" import { Location } from "@opencode-ai/core/location" -import { Policy } from "@opencode-ai/core/policy" import { Project } from "@opencode-ai/core/project" import { AbsolutePath } from "@opencode-ai/core/schema" import { location } from "../fixture/location" @@ -26,6 +28,7 @@ function testLayer( globalDirectory = path.join(directory, "global"), projectDirectory = directory, vcs?: Project.Vcs, + watcher?: Layer.Layer, ) { const locationLayer = Layer.succeed( Location.Service, @@ -36,9 +39,10 @@ function testLayer( ), ), ) - return AppNodeBuilder.build(LayerNode.group([Config.node, Policy.node]), [ + return AppNodeBuilder.build(LayerNode.group([Config.node, EventV2.node]), [ [Location.node, locationLayer], [Global.node, Global.layerWith({ config: globalDirectory })], + ...(watcher ? ([[Watcher.node, watcher]] as const) : []), ]) } @@ -52,6 +56,52 @@ const provider = { } describe("Config", () => { + it.live("reloads external config and publishes directory updates", () => + Effect.acquireRelease( + Effect.promise(() => tmpdir()), + (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()), + ).pipe( + Effect.flatMap((tmp) => + Effect.gen(function* () { + const global = path.join(tmp.path, "global") + const project = path.join(tmp.path, "project") + const file = path.join(global, "opencode.json") + yield* Effect.promise(async () => { + await fs.mkdir(global, { recursive: true }) + await fs.mkdir(project, { recursive: true }) + await fs.writeFile(file, JSON.stringify({ shell: "first" })) + }) + const updates = yield* PubSub.unbounded() + const watcher = Layer.succeed( + Watcher.Service, + Watcher.Service.of({ + subscribe: () => Stream.fromPubSub(updates), + }), + ) + + return yield* Effect.gen(function* () { + const config = yield* Config.Service + const events = yield* EventV2.Service + const changed = yield* events + .subscribe(ConfigSchema.Event.Updated) + .pipe(Stream.take(1), Stream.runCollect, Effect.forkScoped) + yield* Effect.sleep("10 millis") + + yield* PubSub.publish(updates, { + type: "update", + path: path.join(global, "commands", "review.md"), + } satisfies Watcher.Update) + yield* Effect.promise(() => fs.writeFile(file, JSON.stringify({ shell: "second" }))) + yield* PubSub.publish(updates, { type: "update", path: file } satisfies Watcher.Update) + + expect(yield* Fiber.join(changed)).toHaveLength(1) + expect(Config.latest(yield* config.entries(), "shell")).toBe("second") + }).pipe(Effect.provide(testLayer(project, global, project, undefined, watcher))) + }), + ), + ), + ) + it.effect("returns the latest defined scalar from priority-ordered documents", () => Effect.sync(() => { const entries = [ @@ -274,7 +324,6 @@ describe("Config", () => { const file = path.join(tmp.path, "opencode.json") const contents = JSON.stringify({ shell: "/bin/zsh", - experimental: { policies: [{ effect: "deny", action: "provider.use", resource: "openai" }] }, providers: { local: provider }, }) yield* Effect.promise(() => fs.writeFile(file, contents)) @@ -285,11 +334,6 @@ describe("Config", () => { expect(documents[0]?.info.$schema).toBeUndefined() expect(documents[0]?.info.shell).toBe("/bin/zsh") - expect(documents[0]?.info.experimental?.policies?.[0]).toEqual({ - effect: "deny", - action: "provider.use", - resource: "openai", - }) expect(yield* Effect.promise(() => fs.readFile(file, "utf8"))).toBe(contents) }).pipe(Effect.provide(testLayer(tmp.path))) }), @@ -723,40 +767,6 @@ describe("Config", () => { ), ) - it.live("loads policy statements in reverse config order", () => - Effect.acquireRelease( - Effect.promise(() => tmpdir()), - (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()), - ).pipe( - Effect.flatMap((tmp) => { - const global = path.join(tmp.path, "global") - return Effect.gen(function* () { - yield* Effect.promise(async () => { - await fs.mkdir(global, { recursive: true }) - await fs.writeFile( - path.join(global, "opencode.json"), - JSON.stringify({ - experimental: { policies: [{ effect: "deny", action: "provider.use", resource: "openai" }] }, - }), - ) - await fs.writeFile( - path.join(tmp.path, "opencode.json"), - JSON.stringify({ - experimental: { policies: [{ effect: "allow", action: "provider.use", resource: "openai" }] }, - }), - ) - }) - - return yield* Effect.gen(function* () { - const policy = yield* Policy.Service - - expect(yield* policy.evaluate("provider.use", "openai", "allow")).toBe("deny") - }).pipe(Effect.provide(testLayer(tmp.path, global))) - }) - }), - ), - ) - it.live("loads global, ancestor, and .opencode configuration up to the project boundary", () => Effect.acquireRelease( Effect.promise(() => tmpdir()), diff --git a/packages/core/test/filesystem/watcher.test.ts b/packages/core/test/filesystem/watcher.test.ts index 21395204682..323ba4c4ba4 100644 --- a/packages/core/test/filesystem/watcher.test.ts +++ b/packages/core/test/filesystem/watcher.test.ts @@ -8,7 +8,9 @@ import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" import { LayerNode } from "@opencode-ai/core/effect/layer-node" import { EventV2 } from "@opencode-ai/core/event" import { FSUtil } from "@opencode-ai/core/fs-util" +import { LocationWatcher } from "@opencode-ai/core/filesystem/location-watcher" import { Watcher } from "@opencode-ai/core/filesystem/watcher" +import { FileSystem } from "@opencode-ai/schema/filesystem" import { Location } from "@opencode-ai/core/location" import { AbsolutePath } from "@opencode-ai/core/schema" import { location } from "../fixture/location" @@ -34,7 +36,7 @@ function provide(directory: string, vcs?: Location.Interface["vcs"]) { Location.Service.of(location({ directory: AbsolutePath.make(directory) }, { vcs })), ) return Effect.provide( - AppNodeBuilder.build(Watcher.node, [ + AppNodeBuilder.build(LocationWatcher.node, [ [Config.node, configLayer], [Location.node, locationLayer], ]), @@ -66,7 +68,7 @@ function wait(check: (event: WatcherEvent) => boolean) { return Effect.gen(function* () { const events = yield* EventV2.Service const deferred = yield* Deferred.make() - const fiber = yield* events.subscribe(Watcher.Event.Updated).pipe( + const fiber = yield* events.subscribe(FileSystem.Event.Changed).pipe( Stream.runForEach((event) => { if (!check(event.data)) return Effect.void return Deferred.succeed(deferred, event.data).pipe(Effect.asVoid) @@ -136,7 +138,27 @@ function ready(directory: string) { }) } -describeWatcher("Watcher", () => { +describeWatcher("LocationWatcher", () => { + it.live("limits file watches to the exact target", () => + withTmp((directory) => + Effect.gen(function* () { + const fs = yield* FSUtil.Service + const watcher = yield* Watcher.Service + const target = path.join(directory, "opencode.json") + const sibling = path.join(directory, "other.json") + const update = yield* watcher + .subscribe({ path: target, type: "file" }) + .pipe(Stream.take(1), Stream.runHead, Effect.forkScoped({ startImmediately: true })) + yield* Effect.yieldNow + + yield* fs.writeFileString(sibling, "sibling") + yield* fs.writeFileString(target, "target") + + expect((yield* Fiber.join(update)).valueOrUndefined?.path).toBe(target) + }).pipe(Effect.provide(AppNodeBuilder.build(Watcher.node))), + ), + ) + it.live("publishes root create, update, and delete events", () => withTmp( (directory) => diff --git a/packages/core/test/location-layer.test.ts b/packages/core/test/location-layer.test.ts index 77b7207c42b..0f17b0dd2bd 100644 --- a/packages/core/test/location-layer.test.ts +++ b/packages/core/test/location-layer.test.ts @@ -51,27 +51,18 @@ describe("LocationServiceMap", () => { ), ) - it.live("isolates location state while sharing location policy with catalog", () => + it.live("isolates catalog state by location", () => Effect.acquireRelease( Effect.promise(() => Promise.all([tmpdir(), tmpdir()])), (dirs) => Effect.promise(() => Promise.all(dirs.map((dir) => dir[Symbol.asyncDispose]())).then(() => undefined)), ).pipe( Effect.flatMap(([blocked, allowed]) => Effect.gen(function* () { - yield* Effect.promise(() => - fs.writeFile( - path.join(blocked.path, "opencode.json"), - JSON.stringify({ - experimental: { policies: [{ effect: "deny", action: "provider.use", resource: "test" }] }, - }), - ), - ) - - const update = (directory: string) => + const update = (directory: string, providerID: ProviderV2.ID) => Effect.gen(function* () { yield* Reference.Service const catalog = yield* Catalog.Service - yield* catalog.transform((editor) => editor.provider.update(ProviderV2.ID.make("test"), () => {})) + yield* catalog.transform((editor) => editor.provider.update(providerID, () => {})) const registry = yield* ToolRegistry.Service // Tool plugins register during the forked PluginInternal boot; wait for // every expected tool rather than relying on batch ordering. @@ -103,8 +94,11 @@ describe("LocationServiceMap", () => { ), ) - const blockedState = yield* update(blocked.path) - expect(blockedState.providers.some((provider) => provider.id === ProviderV2.ID.make("test"))).toBe(false) + const blockedID = ProviderV2.ID.make("blocked-location") + const allowedID = ProviderV2.ID.make("allowed-location") + const blockedState = yield* update(blocked.path, blockedID) + expect(blockedState.providers.some((provider) => provider.id === blockedID)).toBe(true) + expect(blockedState.providers.some((provider) => provider.id === allowedID)).toBe(false) expect(blockedState.tools.map((tool) => tool.name).sort()).toEqual([ "edit", "glob", @@ -119,8 +113,9 @@ describe("LocationServiceMap", () => { "websearch", "write", ]) - const allowedState = yield* update(allowed.path) - expect(allowedState.providers.some((provider) => provider.id === ProviderV2.ID.make("test"))).toBe(true) + const allowedState = yield* update(allowed.path, allowedID) + expect(allowedState.providers.some((provider) => provider.id === allowedID)).toBe(true) + expect(allowedState.providers.some((provider) => provider.id === blockedID)).toBe(false) expect(allowedState.tools.map((tool) => tool.name).sort()).toEqual([ "edit", "glob", diff --git a/packages/core/test/plugin.test.ts b/packages/core/test/plugin.test.ts index 27f1d040611..42f2a768ec3 100644 --- a/packages/core/test/plugin.test.ts +++ b/packages/core/test/plugin.test.ts @@ -1,8 +1,11 @@ import { describe, expect } from "bun:test" -import { Effect, Exit, Fiber, Schema } from "effect" +import { Effect, Exit, Fiber, Schema, Stream } from "effect" import { define } from "@opencode-ai/plugin/v2/effect" +import { Config as ConfigSchema } from "@opencode-ai/schema/config" import { AgentV2 } from "@opencode-ai/core/agent" +import { EventV2 } from "@opencode-ai/core/event" import { PluginV2 } from "@opencode-ai/core/plugin" +import { PluginHost } from "@opencode-ai/core/plugin/host" import { SessionV2 } from "@opencode-ai/core/session" import { SessionMessage } from "@opencode-ai/core/session/message" import { Tool } from "@opencode-ai/core/tool/tool" @@ -14,6 +17,24 @@ import { PluginTestLayer } from "./plugin/fixture" const it = testEffect(PluginTestLayer) describe("PluginV2", () => { + it.live("exposes public events through the plugin context", () => + Effect.gen(function* () { + const plugins = yield* PluginV2.Service + const events = yield* EventV2.Service + const host = yield* PluginHost.make(plugins) + const received = yield* host.event.subscribe().pipe( + Stream.filter((event) => event.type === "config.updated"), + Stream.runHead, + Effect.forkScoped({ startImmediately: true }), + ) + yield* Effect.sleep("10 millis") + + yield* events.publish(ConfigSchema.Event.Updated, {}) + + expect((yield* Fiber.join(received)).valueOrUndefined?.type).toBe("config.updated") + }), + ) + it.effect("waits for a plugin and returns immediately once active", () => Effect.gen(function* () { const plugins = yield* PluginV2.Service diff --git a/packages/core/test/plugin/host.ts b/packages/core/test/plugin/host.ts index c5f168f265e..63ac18e31ed 100644 --- a/packages/core/test/plugin/host.ts +++ b/packages/core/test/plugin/host.ts @@ -6,7 +6,7 @@ import { Integration } from "@opencode-ai/core/integration" import { ModelV2 } from "@opencode-ai/core/model" import { ProviderV2 } from "@opencode-ai/core/provider" import type { IntegrationEnvMethod, IntegrationKeyMethod, IntegrationOAuthMethod } from "@opencode-ai/sdk/v2/types" -import { Effect } from "effect" +import { Effect, Stream } from "effect" type Overrides = Partial> @@ -39,6 +39,9 @@ export function host(overrides: Overrides = {}): PluginContext { transform: () => Effect.die("unused command.transform"), reload: () => Effect.die("unused command.reload"), }, + event: overrides.event ?? { + subscribe: () => Stream.empty, + }, integration: overrides.integration ?? { list: () => Effect.die("unused integration.list"), get: () => Effect.die("unused integration.get"), diff --git a/packages/core/test/policy.test.ts b/packages/core/test/policy.test.ts deleted file mode 100644 index 1428c1b830f..00000000000 --- a/packages/core/test/policy.test.ts +++ /dev/null @@ -1,85 +0,0 @@ -import { describe, expect } from "bun:test" -import { Effect, Layer } from "effect" -import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" -import { Location } from "@opencode-ai/core/location" -import { Policy } from "@opencode-ai/core/policy" -import { AbsolutePath } from "@opencode-ai/core/schema" -import { location } from "./fixture/location" -import { testEffect } from "./lib/effect" - -const it = testEffect( - AppNodeBuilder.build(Policy.node, [ - [ - Location.node, - Layer.succeed(Location.Service, Location.Service.of(location({ directory: AbsolutePath.make("test") }))), - ], - ]), -) - -describe("Policy", () => { - it.effect("returns the caller's fallback when no statement matches", () => - Effect.gen(function* () { - const policy = yield* Policy.Service - - expect(yield* policy.evaluate("provider.use", "anthropic", "allow")).toBe("allow") - expect(yield* policy.evaluate("provider.use", "anthropic", "deny")).toBe("deny") - }), - ) - - it.effect("evaluates wildcard provider rules in written order", () => - Effect.gen(function* () { - const policy = yield* Policy.Service - yield* policy.load([ - new Policy.Info({ - effect: "deny", - action: "provider.*", - resource: "*", - }), - new Policy.Info({ - effect: "allow", - action: "provider.use", - resource: "anthropic", - }), - ]) - - expect(yield* policy.evaluate("provider.use", "anthropic", "allow")).toBe("allow") - expect(yield* policy.evaluate("provider.use", "openai", "allow")).toBe("deny") - }), - ) - - it.effect("matches action and resource independently", () => - Effect.gen(function* () { - const policy = yield* Policy.Service - yield* policy.load([ - new Policy.Info({ - effect: "deny", - action: "provider.*", - resource: "company-*", - }), - ]) - - expect(yield* policy.evaluate("provider.use", "company-stable", "allow")).toBe("deny") - expect(yield* policy.evaluate("plugin.load", "company-stable", "allow")).toBe("allow") - }), - ) - - it.effect("uses the last matching loaded statement", () => - Effect.gen(function* () { - const policy = yield* Policy.Service - yield* policy.load([ - new Policy.Info({ - effect: "allow", - action: "provider.use", - resource: "openai", - }), - new Policy.Info({ - effect: "deny", - action: "provider.use", - resource: "openai", - }), - ]) - - expect(yield* policy.evaluate("provider.use", "openai", "allow")).toBe("deny") - }), - ) -}) diff --git a/packages/core/test/skill.test.ts b/packages/core/test/skill.test.ts index 38ca4cfb929..7e57610afa3 100644 --- a/packages/core/test/skill.test.ts +++ b/packages/core/test/skill.test.ts @@ -10,7 +10,7 @@ import { FSUtil } from "@opencode-ai/core/fs-util" import { AbsolutePath } from "@opencode-ai/core/schema" import { SkillV2 } from "@opencode-ai/core/skill" import { SkillDiscovery } from "@opencode-ai/core/skill/discovery" -import { FileSystemWatcher } from "@opencode-ai/schema/filesystem-watcher" +import { FileSystem } from "@opencode-ai/schema/filesystem" import { tmpdir } from "./fixture/tmpdir" import { testEffect } from "./lib/effect" @@ -206,7 +206,7 @@ metadata: waitForSkillUpdate(), ({ deferred }) => events - .publish(FileSystemWatcher.Event.Updated, { file, event: "change" }) + .publish(FileSystem.Event.Changed, { file, event: "change" }) .pipe(Effect.andThen(Deferred.await(deferred)), Effect.timeout("1 second")), ({ fiber }) => Fiber.interrupt(fiber), ) diff --git a/packages/opencode/src/tool/apply_patch.ts b/packages/opencode/src/tool/apply_patch.ts index f9201be8a7d..312bef9f4fc 100644 --- a/packages/opencode/src/tool/apply_patch.ts +++ b/packages/opencode/src/tool/apply_patch.ts @@ -12,6 +12,7 @@ import { LSP } from "@/lsp/lsp" import { FSUtil } from "@opencode-ai/core/fs-util" import DESCRIPTION from "./apply_patch.txt" import { FileSystem } from "@opencode-ai/core/filesystem" +import { FileSystemV1 } from "@opencode-ai/schema/filesystem-v1" import { Format } from "../format" import * as Bom from "@/util/bom" @@ -253,7 +254,7 @@ export const ApplyPatchTool = Tool.define( if (yield* format.file(edited)) { yield* Bom.syncFile(afs, edited, change.bom) } - yield* events.publish(FileSystem.Event.Edited, { file: edited }) + yield* events.publish(FileSystemV1.Event.Edited, { file: edited }) } } diff --git a/packages/opencode/src/tool/edit.ts b/packages/opencode/src/tool/edit.ts index a92e4720c0f..7e13b859972 100644 --- a/packages/opencode/src/tool/edit.ts +++ b/packages/opencode/src/tool/edit.ts @@ -10,6 +10,7 @@ import { LSP } from "@/lsp/lsp" import { createTwoFilesPatch, diffLines } from "diff" import DESCRIPTION from "./edit.txt" import { FileSystem } from "@opencode-ai/core/filesystem" +import { FileSystemV1 } from "@opencode-ai/schema/filesystem-v1" import { Watcher } from "@opencode-ai/core/filesystem/watcher" import { EventV2Bridge } from "@/event-v2-bridge" import { Format } from "../format" @@ -112,7 +113,7 @@ export const EditTool = Tool.define( if (yield* format.file(filePath)) { contentNew = yield* Bom.syncFile(afs, filePath, desiredBom) } - yield* events.publish(FileSystem.Event.Edited, { file: filePath }) + yield* events.publish(FileSystemV1.Event.Edited, { file: filePath }) yield* events.publish(Watcher.Event.Updated, { file: filePath, event: "add", @@ -156,7 +157,7 @@ export const EditTool = Tool.define( if (yield* format.file(filePath)) { contentNew = yield* Bom.syncFile(afs, filePath, desiredBom) } - yield* events.publish(FileSystem.Event.Edited, { file: filePath }) + yield* events.publish(FileSystemV1.Event.Edited, { file: filePath }) yield* events.publish(Watcher.Event.Updated, { file: filePath, event: "change", diff --git a/packages/opencode/src/tool/write.ts b/packages/opencode/src/tool/write.ts index 37be6d8c47b..7d9aa8326a2 100644 --- a/packages/opencode/src/tool/write.ts +++ b/packages/opencode/src/tool/write.ts @@ -7,6 +7,7 @@ import { createTwoFilesPatch } from "diff" import DESCRIPTION from "./write.txt" import { EventV2Bridge } from "@/event-v2-bridge" import { FileSystem } from "@opencode-ai/core/filesystem" +import { FileSystemV1 } from "@opencode-ai/schema/filesystem-v1" import { Watcher } from "@opencode-ai/core/filesystem/watcher" import { Format } from "../format" import { FSUtil } from "@opencode-ai/core/fs-util" @@ -65,7 +66,7 @@ export const WriteTool = Tool.define( if (yield* format.file(filepath)) { yield* Bom.syncFile(fs, filepath, desiredBom) } - yield* events.publish(FileSystem.Event.Edited, { file: filepath }) + yield* events.publish(FileSystemV1.Event.Edited, { file: filepath }) yield* events.publish(Watcher.Event.Updated, { file: filepath, event: exists ? "change" : "add", diff --git a/packages/opencode/test/server/httpapi-v2-location.test.ts b/packages/opencode/test/server/httpapi-v2-location.test.ts index cf009812b86..1de6007b79d 100644 --- a/packages/opencode/test/server/httpapi-v2-location.test.ts +++ b/packages/opencode/test/server/httpapi-v2-location.test.ts @@ -82,7 +82,7 @@ describe("v2 location HttpApi", () => { expect( Schema.decodeUnknownSync(Event)({ id: "evt_test", - type: "file.watcher.updated", + type: "filesystem.changed", location: { directory: "/tmp/project" }, data: {}, }), diff --git a/packages/plugin/src/v2/effect/context.ts b/packages/plugin/src/v2/effect/context.ts index db2f4c0ee67..219dc165810 100644 --- a/packages/plugin/src/v2/effect/context.ts +++ b/packages/plugin/src/v2/effect/context.ts @@ -3,6 +3,7 @@ import type { AgentHooks } from "./agent.js" import type { AISDKHooks } from "./aisdk.js" import type { CatalogHooks } from "./catalog.js" import type { CommandHooks } from "./command.js" +import type { EventHooks } from "./event.js" import type { IntegrationHooks } from "./integration.js" import type { PluginDomain } from "./plugin.js" import type { ReferenceHooks } from "./reference.js" @@ -16,6 +17,7 @@ export interface PluginContext { readonly aisdk: AISDKHooks readonly catalog: CatalogHooks readonly command: CommandHooks + readonly event: EventHooks readonly integration: IntegrationHooks readonly plugin: PluginDomain readonly reference: ReferenceHooks diff --git a/packages/plugin/src/v2/effect/event.ts b/packages/plugin/src/v2/effect/event.ts index e6ea7cf0ce9..49ad375d676 100644 --- a/packages/plugin/src/v2/effect/event.ts +++ b/packages/plugin/src/v2/effect/event.ts @@ -1,10 +1,3 @@ -import type { Event as SDKEvent } from "@opencode-ai/sdk/v2/types" -import type { Stream } from "effect" +import type { EventApi } from "@opencode-ai/client/effect/api" -export type EventMap = { - [Item in SDKEvent as Item["type"]]: Item -} - -export interface Event { - subscribe(type: Type): Stream.Stream -} +export interface EventHooks extends Pick, "subscribe"> {} diff --git a/packages/plugin/src/v2/effect/index.ts b/packages/plugin/src/v2/effect/index.ts index 12b5971fef5..2ebd0215b5a 100644 --- a/packages/plugin/src/v2/effect/index.ts +++ b/packages/plugin/src/v2/effect/index.ts @@ -5,6 +5,7 @@ export type { AgentDraft, AgentHooks } from "./agent.js" export type { AISDKHooks } from "./aisdk.js" export type { CatalogDraft, CatalogHooks, CatalogProviderRecord } from "./catalog.js" export type { CommandDraft, CommandHooks } from "./command.js" +export type { EventHooks } from "./event.js" export type { IntegrationDraft, IntegrationHooks, IntegrationMethodRegistration } from "./integration.js" export type { ReferenceDraft, ReferenceHooks } from "./reference.js" export type { SkillDraft, SkillHooks } from "./skill.js" diff --git a/packages/plugin/src/v2/promise/context.ts b/packages/plugin/src/v2/promise/context.ts index 652deee9bb3..5e67e449613 100644 --- a/packages/plugin/src/v2/promise/context.ts +++ b/packages/plugin/src/v2/promise/context.ts @@ -3,6 +3,7 @@ import type { AgentHooks } from "./agent.js" import type { AISDKHooks } from "./aisdk.js" import type { CatalogHooks } from "./catalog.js" import type { CommandHooks } from "./command.js" +import type { EventHooks } from "./event.js" import type { IntegrationHooks } from "./integration.js" import type { PluginDomain } from "./plugin.js" import type { ReferenceHooks } from "./reference.js" @@ -15,6 +16,7 @@ export interface PluginContext { readonly aisdk: AISDKHooks readonly catalog: CatalogHooks readonly command: CommandHooks + readonly event: EventHooks readonly integration: IntegrationHooks readonly plugin: PluginDomain readonly reference: ReferenceHooks diff --git a/packages/plugin/src/v2/promise/event.ts b/packages/plugin/src/v2/promise/event.ts new file mode 100644 index 00000000000..5330f70c7c0 --- /dev/null +++ b/packages/plugin/src/v2/promise/event.ts @@ -0,0 +1,3 @@ +import type { EventApi } from "@opencode-ai/client/promise/api" + +export interface EventHooks extends Pick {} diff --git a/packages/plugin/src/v2/promise/index.ts b/packages/plugin/src/v2/promise/index.ts index 1050463e700..594ff7da3c4 100644 --- a/packages/plugin/src/v2/promise/index.ts +++ b/packages/plugin/src/v2/promise/index.ts @@ -6,6 +6,7 @@ export type { AgentDraft, AgentHooks } from "./agent.js" export type { AISDKHooks } from "./aisdk.js" export type { CatalogDraft, CatalogHooks, CatalogProviderRecord } from "./catalog.js" export type { CommandDraft, CommandHooks } from "./command.js" +export type { EventHooks } from "./event.js" export type { IntegrationDraft, IntegrationHooks, IntegrationMethodRegistration } from "./integration.js" export type { ReferenceDraft, ReferenceHooks } from "./reference.js" export type { SessionHooks } from "./runtime.js" diff --git a/packages/schema/src/config.ts b/packages/schema/src/config.ts new file mode 100644 index 00000000000..92b07b0e39e --- /dev/null +++ b/packages/schema/src/config.ts @@ -0,0 +1,10 @@ +export * as Config from "./config.js" + +import { ephemeral, inventory } from "./event.js" + +const Updated = ephemeral({ + type: "config.updated", + schema: {}, +}) + +export const Event = { Updated, Definitions: inventory(Updated) } diff --git a/packages/schema/src/event-manifest.ts b/packages/schema/src/event-manifest.ts index e17ff96bc96..6b2b806f819 100644 --- a/packages/schema/src/event-manifest.ts +++ b/packages/schema/src/event-manifest.ts @@ -3,10 +3,11 @@ export * as EventManifest from "./event-manifest.js" import { Agent } from "./agent.js" import { Catalog } from "./catalog.js" import { Command } from "./command.js" +import { Config } from "./config.js" import { Durable } from "./durable-event-manifest.js" import { Event } from "./event.js" import { FileSystem } from "./filesystem.js" -import { FileSystemWatcher } from "./filesystem-watcher.js" +import { FileSystemV1 } from "./filesystem-v1.js" import { Form } from "./form.js" import { InstallationEvent } from "./installation-event.js" import { Integration } from "./integration.js" @@ -60,8 +61,8 @@ const featureDefinitions = Event.inventory( ...Plugin.Event.Definitions, ...ProjectDirectories.Event.Definitions, ...Command.Event.Definitions, + ...Config.Event.Definitions, ...Skill.Event.Definitions, - ...FileSystemWatcher.Event.Definitions, ...Pty.Event.Definitions, ...Shell.Event.Definitions, ...Question.Event.Definitions, @@ -97,6 +98,7 @@ export const Definitions = Event.inventory( ...TuiEvent.Definitions, ...McpEvent.Definitions, ...LegacyEvent.Definitions, + ...FileSystemV1.Event.Definitions, ...Project.Event.Definitions, ...SessionStatusEvent.Definitions, ...QuestionV1.Event.Definitions, diff --git a/packages/schema/src/filesystem-v1.ts b/packages/schema/src/filesystem-v1.ts new file mode 100644 index 00000000000..60e1a91853f --- /dev/null +++ b/packages/schema/src/filesystem-v1.ts @@ -0,0 +1 @@ +export * from "./v1/filesystem.js" diff --git a/packages/schema/src/filesystem-watcher.ts b/packages/schema/src/filesystem-watcher.ts deleted file mode 100644 index debe0914d11..00000000000 --- a/packages/schema/src/filesystem-watcher.ts +++ /dev/null @@ -1,13 +0,0 @@ -export * as FileSystemWatcher from "./filesystem-watcher.js" - -import { Schema } from "effect" -import { ephemeral, inventory } from "./event.js" - -const Updated = ephemeral({ - type: "file.watcher.updated", - schema: { - file: Schema.String, - event: Schema.Literals(["add", "change", "unlink"]), - }, -}) -export const Event = { Updated, Definitions: inventory(Updated) } diff --git a/packages/schema/src/filesystem.ts b/packages/schema/src/filesystem.ts index 3599e48c7ca..3f95e97a5b5 100644 --- a/packages/schema/src/filesystem.ts +++ b/packages/schema/src/filesystem.ts @@ -5,11 +5,14 @@ import { optional } from "./schema.js" import { ephemeral, inventory } from "./event.js" import { NonNegativeInt, PositiveInt, RelativePath } from "./schema.js" -const Edited = ephemeral({ - type: "file.edited", - schema: { file: Schema.String }, +const Changed = ephemeral({ + type: "filesystem.changed", + schema: { + file: Schema.String, + event: Schema.Literals(["add", "change", "unlink"]), + }, }) -export const Event = { Edited, Definitions: inventory(Edited) } +export const Event = { Changed, Definitions: inventory(Changed) } export interface Entry extends Schema.Schema.Type {} export const Entry = Schema.Struct({ diff --git a/packages/schema/src/index.ts b/packages/schema/src/index.ts index fb2ef17b960..1454fab1cf4 100644 --- a/packages/schema/src/index.ts +++ b/packages/schema/src/index.ts @@ -1,5 +1,6 @@ export { Agent } from "./agent.js" export { Command } from "./command.js" +export { Config } from "./config.js" export { Connection } from "./connection.js" export { Credential } from "./credential.js" export { Event } from "./event.js" diff --git a/packages/schema/src/v1/filesystem.ts b/packages/schema/src/v1/filesystem.ts new file mode 100644 index 00000000000..a1756fefd39 --- /dev/null +++ b/packages/schema/src/v1/filesystem.ts @@ -0,0 +1,11 @@ +export * as FileSystemV1 from "./filesystem.js" + +import { Schema } from "effect" +import { ephemeral, inventory } from "../event.js" + +const Edited = ephemeral({ + type: "file.edited", + schema: { file: Schema.String }, +}) + +export const Event = { Edited, Definitions: inventory(Edited) } diff --git a/packages/schema/test/event-manifest.test.ts b/packages/schema/test/event-manifest.test.ts index 873e415c61b..487a942d9a1 100644 --- a/packages/schema/test/event-manifest.test.ts +++ b/packages/schema/test/event-manifest.test.ts @@ -1,6 +1,7 @@ import { describe, expect, test } from "bun:test" import { Agent, + Config, FileSystem, Form, Integration, @@ -11,6 +12,7 @@ import { Workspace, } from "../src/index.js" import { EventManifest } from "../src/event-manifest.js" +import { FileSystemV1 } from "../src/filesystem-v1.js" import { IdeEvent } from "../src/ide-event.js" import { McpEvent } from "../src/mcp-event.js" import { SessionEvent } from "../src/session-event.js" @@ -59,7 +61,9 @@ describe("public event manifest", () => { expect(EventManifest.Latest.get("project.updated")).toBe(Project.Event.Updated) expect(Agent.Event.Definitions).toEqual([Agent.Event.Updated]) expect(Project.Event.Definitions).toEqual([Project.Event.Updated]) - expect(FileSystem.Event.Definitions).toEqual([FileSystem.Event.Edited]) + expect(Config.Event.Definitions).toEqual([Config.Event.Updated]) + expect(FileSystem.Event.Definitions).toEqual([FileSystem.Event.Changed]) + expect(FileSystemV1.Event.Definitions).toEqual([FileSystemV1.Event.Edited]) expect(Integration.Event.Definitions).toEqual([Integration.Event.Updated, Integration.Event.ConnectionUpdated]) expect(Permission.Event.Definitions).toEqual([Permission.Event.Asked, Permission.Event.Replied]) expect(Form.Event.Definitions).toEqual([Form.Event.Created, Form.Event.Replied, Form.Event.Cancelled]) diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts index c6933692813..524d7ea8541 100644 --- a/packages/sdk/js/src/v2/gen/types.gen.ts +++ b/packages/sdk/js/src/v2/gen/types.gen.ts @@ -58,15 +58,15 @@ export type Event = | EventSessionError | EventInstallationUpdated | EventInstallationUpdateAvailable - | EventFileEdited + | EventFilesystemChanged | EventReferenceUpdated | EventPermissionV2Asked | EventPermissionV2Replied | EventPluginAdded | EventProjectDirectoriesUpdated | EventCommandUpdated + | EventConfigUpdated | EventSkillUpdated - | EventFileWatcherUpdated | EventPtyCreated | EventPtyUpdated | EventPtyExited @@ -91,6 +91,7 @@ export type Event = | EventMcpToolsChanged | EventMcpStatusChanged | EventCommandExecuted + | EventFileEdited | EventProjectUpdated | EventSessionStatus | EventSessionIdle @@ -1280,9 +1281,10 @@ export type GlobalEvent = { } | { id: string - type: "file.edited" + type: "filesystem.changed" properties: { file: string + event: "add" | "change" | "unlink" } } | { @@ -1339,17 +1341,16 @@ export type GlobalEvent = { } | { id: string - type: "skill.updated" + type: "config.updated" properties: { [key: string]: unknown } } | { id: string - type: "file.watcher.updated" + type: "skill.updated" properties: { - file: string - event: "add" | "change" | "unlink" + [key: string]: unknown } } | { @@ -1576,6 +1577,13 @@ export type GlobalEvent = { messageID: string } } + | { + id: string + type: "file.edited" + properties: { + file: string + } + } | { id: string type: "project.updated" @@ -2123,7 +2131,6 @@ export type Config = { primary_tools?: Array continue_loop_on_deny?: boolean mcp_timeout?: number - policies?: Array } } @@ -3059,15 +3066,15 @@ export type V2Event = | SessionError | InstallationUpdated | InstallationUpdateAvailable - | FileEdited + | FilesystemChanged | ReferenceUpdated | PermissionV2Asked | PermissionV2Replied | PluginAdded | ProjectDirectoriesUpdated | CommandUpdated + | ConfigUpdated | SkillUpdated - | FileWatcherUpdated | PtyCreated | PtyUpdated | PtyExited @@ -3092,6 +3099,7 @@ export type V2Event = | McpToolsChanged | McpStatusChanged | CommandExecuted + | FileEdited | ProjectUpdated | SessionStatus2 | SessionIdle @@ -4167,14 +4175,6 @@ export type ConfigV2ReferenceLocal = { hidden?: boolean } -export type PolicyEffect = "allow" | "deny" - -export type ConfigV2ExperimentalPolicy = { - action: "provider.use" - effect: PolicyEffect - resource: string -} - export type ProjectDirectory = { directory: string strategy?: string @@ -5880,16 +5880,17 @@ export type InstallationUpdateAvailable = { } } -export type FileEdited = { +export type FilesystemChanged = { id: string created: number metadata?: { [key: string]: unknown } - type: "file.edited" + type: "filesystem.changed" location?: LocationRef data: { file: string + event: "add" | "change" | "unlink" } } @@ -5981,6 +5982,19 @@ export type CommandUpdated = { } } +export type ConfigUpdated = { + id: string + created: number + metadata?: { + [key: string]: unknown + } + type: "config.updated" + location?: LocationRef + data: { + [key: string]: unknown + } +} + export type SkillUpdated = { id: string created: number @@ -5994,20 +6008,6 @@ export type SkillUpdated = { } } -export type FileWatcherUpdated = { - id: string - created: number - metadata?: { - [key: string]: unknown - } - type: "file.watcher.updated" - location?: LocationRef - data: { - file: string - event: "add" | "change" | "unlink" - } -} - export type PtyCreated = { id: string created: number @@ -6408,6 +6408,19 @@ export type CommandExecuted = { } } +export type FileEdited = { + id: string + created: number + metadata?: { + [key: string]: unknown + } + type: "file.edited" + location?: LocationRef + data: { + file: string + } +} + export type ProjectUpdated = { id: string created: number @@ -7209,11 +7222,12 @@ export type EventInstallationUpdateAvailable = { } } -export type EventFileEdited = { +export type EventFilesystemChanged = { id: string - type: "file.edited" + type: "filesystem.changed" properties: { file: string + event: "add" | "change" | "unlink" } } @@ -7275,20 +7289,19 @@ export type EventCommandUpdated = { } } -export type EventSkillUpdated = { +export type EventConfigUpdated = { id: string - type: "skill.updated" + type: "config.updated" properties: { [key: string]: unknown } } -export type EventFileWatcherUpdated = { +export type EventSkillUpdated = { id: string - type: "file.watcher.updated" + type: "skill.updated" properties: { - file: string - event: "add" | "change" | "unlink" + [key: string]: unknown } } @@ -7508,6 +7521,14 @@ export type EventCommandExecuted = { } } +export type EventFileEdited = { + id: string + type: "file.edited" + properties: { + file: string + } +} + export type EventProjectUpdated = { id: string type: "project.updated" @@ -10279,16 +10300,17 @@ export type SessionCompactionDelta2 = { } } -export type FileEdited2 = { +export type FilesystemChanged2 = { id: string created: number metadata?: { [key: string]: unknown } - type: "file.edited" + type: "filesystem.changed" location?: LocationRef2 data: { file: string + event: "add" | "change" | "unlink" } } @@ -10384,6 +10406,21 @@ export type CommandUpdated2 = { | Array } +export type ConfigUpdated2 = { + id: string + created: number + metadata?: { + [key: string]: unknown + } + type: "config.updated" + location?: LocationRef2 + data: + | { + [key: string]: unknown + } + | Array +} + export type SkillUpdated2 = { id: string created: number @@ -10399,20 +10436,6 @@ export type SkillUpdated2 = { | Array } -export type FileWatcherUpdated2 = { - id: string - created: number - metadata?: { - [key: string]: unknown - } - type: "file.watcher.updated" - location?: LocationRef2 - data: { - file: string - event: "add" | "change" | "unlink" - } -} - export type PtyV2 = { id: string title: string @@ -11162,15 +11185,15 @@ export type V2EventV2 = | SessionRevertStaged2 | SessionRevertCleared2 | SessionRevertCommitted2 - | FileEdited2 + | FilesystemChanged2 | ReferenceUpdated2 | PermissionV2Asked2 | PermissionV2Replied2 | PluginAdded2 | ProjectDirectoriesUpdated2 | CommandUpdated2 + | ConfigUpdated2 | SkillUpdated2 - | FileWatcherUpdated2 | PtyCreated2 | PtyUpdated2 | PtyExited2