diff options
| author | opencode-agent[bot] <opencode-agent[bot]@users.noreply.github.com> | 2026-03-18 01:05:16 +0000 |
|---|---|---|
| committer | opencode-agent[bot] <opencode-agent[bot]@users.noreply.github.com> | 2026-03-18 01:05:16 +0000 |
| commit | bc949af6235703225161d65b286fa9ecdbe27f1c (patch) | |
| tree | 7d79ad00200dea3bf03ea8f73942291b3d3cf636 /packages | |
| parent | 9e7c136de7283fc564dbb213f8a492260bbedac5 (diff) | |
| download | opencode-bc949af6235703225161d65b286fa9ecdbe27f1c.tar.gz opencode-bc949af6235703225161d65b286fa9ecdbe27f1c.zip | |
chore: generate
Diffstat (limited to 'packages')
| -rw-r--r-- | packages/opencode/src/effect/instance-context.ts | 21 | ||||
| -rw-r--r-- | packages/opencode/src/effect/instances.ts | 125 | ||||
| -rw-r--r-- | packages/opencode/src/project/instance.ts | 309 | ||||
| -rw-r--r-- | packages/opencode/src/snapshot/index.ts | 871 | ||||
| -rw-r--r-- | packages/opencode/test/fixture/instance.ts | 74 | ||||
| -rw-r--r-- | packages/sdk/js/src/v2/gen/types.gen.ts | 112 | ||||
| -rw-r--r-- | packages/sdk/openapi.json | 326 |
7 files changed, 836 insertions, 1002 deletions
diff --git a/packages/opencode/src/effect/instance-context.ts b/packages/opencode/src/effect/instance-context.ts index af5f9236f..fd4590190 100644 --- a/packages/opencode/src/effect/instance-context.ts +++ b/packages/opencode/src/effect/instance-context.ts @@ -1,15 +1,14 @@ -import { ServiceMap } from "effect"; -import type { Project } from "@/project/project"; +import { ServiceMap } from "effect" +import type { Project } from "@/project/project" export declare namespace InstanceContext { - export interface Shape { - readonly directory: string; - readonly worktree: string; - readonly project: Project.Info; - } + export interface Shape { + readonly directory: string + readonly worktree: string + readonly project: Project.Info + } } -export class InstanceContext extends ServiceMap.Service< - InstanceContext, - InstanceContext.Shape ->()("opencode/InstanceContext") {} +export class InstanceContext extends ServiceMap.Service<InstanceContext, InstanceContext.Shape>()( + "opencode/InstanceContext", +) {} diff --git a/packages/opencode/src/effect/instances.ts b/packages/opencode/src/effect/instances.ts index 075663f08..16186f729 100644 --- a/packages/opencode/src/effect/instances.ts +++ b/packages/opencode/src/effect/instances.ts @@ -1,31 +1,31 @@ -import { Effect, Layer, LayerMap, ServiceMap } from "effect"; -import { FileService } from "@/file"; -import { FileTimeService } from "@/file/time"; -import { FileWatcherService } from "@/file/watcher"; -import { FormatService } from "@/format"; -import { PermissionService } from "@/permission/service"; -import { Instance } from "@/project/instance"; -import { VcsService } from "@/project/vcs"; -import { ProviderAuthService } from "@/provider/auth-service"; -import { QuestionService } from "@/question/service"; -import { SkillService } from "@/skill/skill"; -import { SnapshotService } from "@/snapshot"; -import { InstanceContext } from "./instance-context"; -import { registerDisposer } from "./instance-registry"; +import { Effect, Layer, LayerMap, ServiceMap } from "effect" +import { FileService } from "@/file" +import { FileTimeService } from "@/file/time" +import { FileWatcherService } from "@/file/watcher" +import { FormatService } from "@/format" +import { PermissionService } from "@/permission/service" +import { Instance } from "@/project/instance" +import { VcsService } from "@/project/vcs" +import { ProviderAuthService } from "@/provider/auth-service" +import { QuestionService } from "@/question/service" +import { SkillService } from "@/skill/skill" +import { SnapshotService } from "@/snapshot" +import { InstanceContext } from "./instance-context" +import { registerDisposer } from "./instance-registry" -export { InstanceContext } from "./instance-context"; +export { InstanceContext } from "./instance-context" export type InstanceServices = - | QuestionService - | PermissionService - | ProviderAuthService - | FileWatcherService - | VcsService - | FileTimeService - | FormatService - | FileService - | SkillService - | SnapshotService; + | QuestionService + | PermissionService + | ProviderAuthService + | FileWatcherService + | VcsService + | FileTimeService + | FormatService + | FileService + | SkillService + | SnapshotService // NOTE: LayerMap only passes the key (directory string) to lookup, but we need // the full instance context (directory, worktree, project). We read from the @@ -34,50 +34,41 @@ export type InstanceServices = // This should go away once the old Instance type is removed and lookup can load // the full context directly. function lookup(_key: string) { - const ctx = Layer.sync(InstanceContext, () => - InstanceContext.of(Instance.current), - ); - return Layer.mergeAll( - Layer.fresh(QuestionService.layer), - Layer.fresh(PermissionService.layer), - Layer.fresh(ProviderAuthService.layer), - Layer.fresh(FileWatcherService.layer).pipe(Layer.orDie), - Layer.fresh(VcsService.layer), - Layer.fresh(FileTimeService.layer).pipe(Layer.orDie), - Layer.fresh(FormatService.layer), - Layer.fresh(FileService.layer), - Layer.fresh(SkillService.layer), - Layer.fresh(SnapshotService.layer), - ).pipe(Layer.provide(ctx)); + const ctx = Layer.sync(InstanceContext, () => InstanceContext.of(Instance.current)) + return Layer.mergeAll( + Layer.fresh(QuestionService.layer), + Layer.fresh(PermissionService.layer), + Layer.fresh(ProviderAuthService.layer), + Layer.fresh(FileWatcherService.layer).pipe(Layer.orDie), + Layer.fresh(VcsService.layer), + Layer.fresh(FileTimeService.layer).pipe(Layer.orDie), + Layer.fresh(FormatService.layer), + Layer.fresh(FileService.layer), + Layer.fresh(SkillService.layer), + Layer.fresh(SnapshotService.layer), + ).pipe(Layer.provide(ctx)) } -export class Instances extends ServiceMap.Service< - Instances, - LayerMap.LayerMap<string, InstanceServices> ->()("opencode/Instances") { - static readonly layer = Layer.effect( - Instances, - Effect.gen(function* () { - const layerMap = yield* LayerMap.make(lookup, { - idleTimeToLive: Infinity, - }); - const unregister = registerDisposer((directory) => - Effect.runPromise(layerMap.invalidate(directory)), - ); - yield* Effect.addFinalizer(() => Effect.sync(unregister)); - return Instances.of(layerMap); - }), - ); +export class Instances extends ServiceMap.Service<Instances, LayerMap.LayerMap<string, InstanceServices>>()( + "opencode/Instances", +) { + static readonly layer = Layer.effect( + Instances, + Effect.gen(function* () { + const layerMap = yield* LayerMap.make(lookup, { + idleTimeToLive: Infinity, + }) + const unregister = registerDisposer((directory) => Effect.runPromise(layerMap.invalidate(directory))) + yield* Effect.addFinalizer(() => Effect.sync(unregister)) + return Instances.of(layerMap) + }), + ) - static get( - directory: string, - ): Layer.Layer<InstanceServices, never, Instances> { - return Layer.unwrap( - Instances.use((map) => Effect.succeed(map.get(directory))), - ); - } + static get(directory: string): Layer.Layer<InstanceServices, never, Instances> { + return Layer.unwrap(Instances.use((map) => Effect.succeed(map.get(directory)))) + } - static invalidate(directory: string): Effect.Effect<void, never, Instances> { - return Instances.use((map) => map.invalidate(directory)); - } + static invalidate(directory: string): Effect.Effect<void, never, Instances> { + return Instances.use((map) => map.invalidate(directory)) + } } diff --git a/packages/opencode/src/project/instance.ts b/packages/opencode/src/project/instance.ts index 61f6dd793..607554016 100644 --- a/packages/opencode/src/project/instance.ts +++ b/packages/opencode/src/project/instance.ts @@ -1,185 +1,166 @@ -import { GlobalBus } from "@/bus/global"; -import { disposeInstance } from "@/effect/instance-registry"; -import { Filesystem } from "@/util/filesystem"; -import { iife } from "@/util/iife"; -import { Log } from "@/util/log"; -import { Context } from "../util/context"; -import { Project } from "./project"; -import { State } from "./state"; +import { GlobalBus } from "@/bus/global" +import { disposeInstance } from "@/effect/instance-registry" +import { Filesystem } from "@/util/filesystem" +import { iife } from "@/util/iife" +import { Log } from "@/util/log" +import { Context } from "../util/context" +import { Project } from "./project" +import { State } from "./state" interface Context { - directory: string; - worktree: string; - project: Project.Info; + directory: string + worktree: string + project: Project.Info } -const context = Context.create<Context>("instance"); -const cache = new Map<string, Promise<Context>>(); +const context = Context.create<Context>("instance") +const cache = new Map<string, Promise<Context>>() const disposal = { - all: undefined as Promise<void> | undefined, -}; + all: undefined as Promise<void> | undefined, +} function emit(directory: string) { - GlobalBus.emit("event", { - directory, - payload: { - type: "server.instance.disposed", - properties: { - directory, - }, - }, - }); + GlobalBus.emit("event", { + directory, + payload: { + type: "server.instance.disposed", + properties: { + directory, + }, + }, + }) } -function boot(input: { - directory: string; - init?: () => Promise<any>; - project?: Project.Info; - worktree?: string; -}) { - return iife(async () => { - const ctx = - input.project && input.worktree - ? { - directory: input.directory, - worktree: input.worktree, - project: input.project, - } - : await Project.fromDirectory(input.directory).then( - ({ project, sandbox }) => ({ - directory: input.directory, - worktree: sandbox, - project, - }), - ); - await context.provide(ctx, async () => { - await input.init?.(); - }); - return ctx; - }); +function boot(input: { directory: string; init?: () => Promise<any>; project?: Project.Info; worktree?: string }) { + return iife(async () => { + const ctx = + input.project && input.worktree + ? { + directory: input.directory, + worktree: input.worktree, + project: input.project, + } + : await Project.fromDirectory(input.directory).then(({ project, sandbox }) => ({ + directory: input.directory, + worktree: sandbox, + project, + })) + await context.provide(ctx, async () => { + await input.init?.() + }) + return ctx + }) } function track(directory: string, next: Promise<Context>) { - const task = next.catch((error) => { - if (cache.get(directory) === task) cache.delete(directory); - throw error; - }); - cache.set(directory, task); - return task; + const task = next.catch((error) => { + if (cache.get(directory) === task) cache.delete(directory) + throw error + }) + cache.set(directory, task) + return task } export const Instance = { - async provide<R>(input: { - directory: string; - init?: () => Promise<any>; - fn: () => R; - }): Promise<R> { - const directory = Filesystem.resolve(input.directory); - let existing = cache.get(directory); - if (!existing) { - Log.Default.info("creating instance", { directory }); - existing = track( - directory, - boot({ - directory, - init: input.init, - }), - ); - } - const ctx = await existing; - return context.provide(ctx, async () => { - return input.fn(); - }); - }, - get current() { - return context.use(); - }, - get directory() { - return context.use().directory; - }, - get worktree() { - return context.use().worktree; - }, - get project() { - return context.use().project; - }, - /** - * Check if a path is within the project boundary. - * Returns true if path is inside Instance.directory OR Instance.worktree. - * Paths within the worktree but outside the working directory should not trigger external_directory permission. - */ - containsPath(filepath: string) { - if (Filesystem.contains(Instance.directory, filepath)) return true; - // Non-git projects set worktree to "/" which would match ANY absolute path. - // Skip worktree check in this case to preserve external_directory permissions. - if (Instance.worktree === "/") return false; - return Filesystem.contains(Instance.worktree, filepath); - }, - /** - * Captures the current instance ALS context and returns a wrapper that - * restores it when called. Use this for callbacks that fire outside the - * instance async context (native addons, event emitters, timers, etc.). - */ - bind<F extends (...args: any[]) => any>(fn: F): F { - const ctx = context.use(); - return ((...args: any[]) => context.provide(ctx, () => fn(...args))) as F; - }, - state<S>( - init: () => S, - dispose?: (state: Awaited<S>) => Promise<void>, - ): () => S { - return State.create(() => Instance.directory, init, dispose); - }, - async reload(input: { - directory: string; - init?: () => Promise<any>; - project?: Project.Info; - worktree?: string; - }) { - const directory = Filesystem.resolve(input.directory); - Log.Default.info("reloading instance", { directory }); - await Promise.all([State.dispose(directory), disposeInstance(directory)]); - cache.delete(directory); - const next = track(directory, boot({ ...input, directory })); - emit(directory); - return await next; - }, - async dispose() { - const directory = Instance.directory; - Log.Default.info("disposing instance", { directory }); - await Promise.all([State.dispose(directory), disposeInstance(directory)]); - cache.delete(directory); - emit(directory); - }, - async disposeAll() { - if (disposal.all) return disposal.all; + async provide<R>(input: { directory: string; init?: () => Promise<any>; fn: () => R }): Promise<R> { + const directory = Filesystem.resolve(input.directory) + let existing = cache.get(directory) + if (!existing) { + Log.Default.info("creating instance", { directory }) + existing = track( + directory, + boot({ + directory, + init: input.init, + }), + ) + } + const ctx = await existing + return context.provide(ctx, async () => { + return input.fn() + }) + }, + get current() { + return context.use() + }, + get directory() { + return context.use().directory + }, + get worktree() { + return context.use().worktree + }, + get project() { + return context.use().project + }, + /** + * Check if a path is within the project boundary. + * Returns true if path is inside Instance.directory OR Instance.worktree. + * Paths within the worktree but outside the working directory should not trigger external_directory permission. + */ + containsPath(filepath: string) { + if (Filesystem.contains(Instance.directory, filepath)) return true + // Non-git projects set worktree to "/" which would match ANY absolute path. + // Skip worktree check in this case to preserve external_directory permissions. + if (Instance.worktree === "/") return false + return Filesystem.contains(Instance.worktree, filepath) + }, + /** + * Captures the current instance ALS context and returns a wrapper that + * restores it when called. Use this for callbacks that fire outside the + * instance async context (native addons, event emitters, timers, etc.). + */ + bind<F extends (...args: any[]) => any>(fn: F): F { + const ctx = context.use() + return ((...args: any[]) => context.provide(ctx, () => fn(...args))) as F + }, + state<S>(init: () => S, dispose?: (state: Awaited<S>) => Promise<void>): () => S { + return State.create(() => Instance.directory, init, dispose) + }, + async reload(input: { directory: string; init?: () => Promise<any>; project?: Project.Info; worktree?: string }) { + const directory = Filesystem.resolve(input.directory) + Log.Default.info("reloading instance", { directory }) + await Promise.all([State.dispose(directory), disposeInstance(directory)]) + cache.delete(directory) + const next = track(directory, boot({ ...input, directory })) + emit(directory) + return await next + }, + async dispose() { + const directory = Instance.directory + Log.Default.info("disposing instance", { directory }) + await Promise.all([State.dispose(directory), disposeInstance(directory)]) + cache.delete(directory) + emit(directory) + }, + async disposeAll() { + if (disposal.all) return disposal.all - disposal.all = iife(async () => { - Log.Default.info("disposing all instances"); - const entries = [...cache.entries()]; - for (const [key, value] of entries) { - if (cache.get(key) !== value) continue; + disposal.all = iife(async () => { + Log.Default.info("disposing all instances") + const entries = [...cache.entries()] + for (const [key, value] of entries) { + if (cache.get(key) !== value) continue - const ctx = await value.catch((error) => { - Log.Default.warn("instance dispose failed", { key, error }); - return undefined; - }); + const ctx = await value.catch((error) => { + Log.Default.warn("instance dispose failed", { key, error }) + return undefined + }) - if (!ctx) { - if (cache.get(key) === value) cache.delete(key); - continue; - } + if (!ctx) { + if (cache.get(key) === value) cache.delete(key) + continue + } - if (cache.get(key) !== value) continue; + if (cache.get(key) !== value) continue - await context.provide(ctx, async () => { - await Instance.dispose(); - }); - } - }).finally(() => { - disposal.all = undefined; - }); + await context.provide(ctx, async () => { + await Instance.dispose() + }) + } + }).finally(() => { + disposal.all = undefined + }) - return disposal.all; - }, -}; + return disposal.all + }, +} diff --git a/packages/opencode/src/snapshot/index.ts b/packages/opencode/src/snapshot/index.ts index ccba830b8..a9489451c 100644 --- a/packages/opencode/src/snapshot/index.ts +++ b/packages/opencode/src/snapshot/index.ts @@ -1,516 +1,381 @@ -import { - NodeChildProcessSpawner, - NodeFileSystem, - NodePath, -} from "@effect/platform-node"; -import { - Cause, - Duration, - Effect, - FileSystem, - Layer, - Schedule, - ServiceMap, - Stream, -} from "effect"; -import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; -import path from "path"; -import z from "zod"; -import { InstanceContext } from "@/effect/instance-context"; -import { runPromiseInstance } from "@/effect/runtime"; -import { Config } from "../config/config"; -import { Global } from "../global"; -import { Log } from "../util/log"; - -const log = Log.create({ service: "snapshot" }); -const PRUNE = "7.days"; +import { NodeChildProcessSpawner, NodeFileSystem, NodePath } from "@effect/platform-node" +import { Cause, Duration, Effect, FileSystem, Layer, Schedule, ServiceMap, Stream } from "effect" +import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process" +import path from "path" +import z from "zod" +import { InstanceContext } from "@/effect/instance-context" +import { runPromiseInstance } from "@/effect/runtime" +import { Config } from "../config/config" +import { Global } from "../global" +import { Log } from "../util/log" + +const log = Log.create({ service: "snapshot" }) +const PRUNE = "7.days" // Common git config flags shared across snapshot operations -const GIT_CORE = ["-c", "core.longpaths=true", "-c", "core.symlinks=true"]; -const GIT_CFG = ["-c", "core.autocrlf=false", ...GIT_CORE]; -const GIT_CFG_QUOTE = [...GIT_CFG, "-c", "core.quotepath=false"]; +const GIT_CORE = ["-c", "core.longpaths=true", "-c", "core.symlinks=true"] +const GIT_CFG = ["-c", "core.autocrlf=false", ...GIT_CORE] +const GIT_CFG_QUOTE = [...GIT_CFG, "-c", "core.quotepath=false"] interface GitResult { - readonly code: ChildProcessSpawner.ExitCode; - readonly text: string; - readonly stderr: string; + readonly code: ChildProcessSpawner.ExitCode + readonly text: string + readonly stderr: string } export namespace Snapshot { - export const Patch = z.object({ - hash: z.string(), - files: z.string().array(), - }); - export type Patch = z.infer<typeof Patch>; - - export const FileDiff = z - .object({ - file: z.string(), - before: z.string(), - after: z.string(), - additions: z.number(), - deletions: z.number(), - status: z.enum(["added", "deleted", "modified"]).optional(), - }) - .meta({ - ref: "FileDiff", - }); - export type FileDiff = z.infer<typeof FileDiff>; - - // Promise facade — existing callers use these - export function init() { - void runPromiseInstance(SnapshotService.use((s) => s.init())); - } - - export async function cleanup() { - return runPromiseInstance(SnapshotService.use((s) => s.cleanup())); - } - - export async function track() { - return runPromiseInstance(SnapshotService.use((s) => s.track())); - } - - export async function patch(hash: string) { - return runPromiseInstance(SnapshotService.use((s) => s.patch(hash))); - } - - export async function restore(snapshot: string) { - return runPromiseInstance(SnapshotService.use((s) => s.restore(snapshot))); - } - - export async function revert(patches: Patch[]) { - return runPromiseInstance(SnapshotService.use((s) => s.revert(patches))); - } - - export async function diff(hash: string) { - return runPromiseInstance(SnapshotService.use((s) => s.diff(hash))); - } - - export async function diffFull(from: string, to: string) { - return runPromiseInstance(SnapshotService.use((s) => s.diffFull(from, to))); - } + export const Patch = z.object({ + hash: z.string(), + files: z.string().array(), + }) + export type Patch = z.infer<typeof Patch> + + export const FileDiff = z + .object({ + file: z.string(), + before: z.string(), + after: z.string(), + additions: z.number(), + deletions: z.number(), + status: z.enum(["added", "deleted", "modified"]).optional(), + }) + .meta({ + ref: "FileDiff", + }) + export type FileDiff = z.infer<typeof FileDiff> + + // Promise facade — existing callers use these + export function init() { + void runPromiseInstance(SnapshotService.use((s) => s.init())) + } + + export async function cleanup() { + return runPromiseInstance(SnapshotService.use((s) => s.cleanup())) + } + + export async function track() { + return runPromiseInstance(SnapshotService.use((s) => s.track())) + } + + export async function patch(hash: string) { + return runPromiseInstance(SnapshotService.use((s) => s.patch(hash))) + } + + export async function restore(snapshot: string) { + return runPromiseInstance(SnapshotService.use((s) => s.restore(snapshot))) + } + + export async function revert(patches: Patch[]) { + return runPromiseInstance(SnapshotService.use((s) => s.revert(patches))) + } + + export async function diff(hash: string) { + return runPromiseInstance(SnapshotService.use((s) => s.diff(hash))) + } + + export async function diffFull(from: string, to: string) { + return runPromiseInstance(SnapshotService.use((s) => s.diffFull(from, to))) + } } export namespace SnapshotService { - export interface Service { - readonly init: () => Effect.Effect<void>; - readonly cleanup: () => Effect.Effect<void>; - readonly track: () => Effect.Effect<string | undefined>; - readonly patch: (hash: string) => Effect.Effect<Snapshot.Patch>; - readonly restore: (snapshot: string) => Effect.Effect<void>; - readonly revert: (patches: Snapshot.Patch[]) => Effect.Effect<void>; - readonly diff: (hash: string) => Effect.Effect<string>; - readonly diffFull: ( - from: string, - to: string, - ) => Effect.Effect<Snapshot.FileDiff[]>; - } + export interface Service { + readonly init: () => Effect.Effect<void> + readonly cleanup: () => Effect.Effect<void> + readonly track: () => Effect.Effect<string | undefined> + readonly patch: (hash: string) => Effect.Effect<Snapshot.Patch> + readonly restore: (snapshot: string) => Effect.Effect<void> + readonly revert: (patches: Snapshot.Patch[]) => Effect.Effect<void> + readonly diff: (hash: string) => Effect.Effect<string> + readonly diffFull: (from: string, to: string) => Effect.Effect<Snapshot.FileDiff[]> + } } -export class SnapshotService extends ServiceMap.Service< - SnapshotService, - SnapshotService.Service ->()("@opencode/Snapshot") { - static readonly layer = Layer.effect( - SnapshotService, - Effect.gen(function* () { - const ctx = yield* InstanceContext; - const fileSystem = yield* FileSystem.FileSystem; - const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; - const { directory, worktree, project } = ctx; - const isGit = project.vcs === "git"; - const snapshotGit = path.join(Global.Path.data, "snapshot", project.id); - - const gitArgs = (cmd: string[]) => [ - "--git-dir", - snapshotGit, - "--work-tree", - worktree, - ...cmd, - ]; - - // Run git with nothrow semantics — always returns a result, never fails - const git = ( - args: string[], - opts?: { cwd?: string; env?: Record<string, string> }, - ): Effect.Effect<GitResult> => - Effect.gen(function* () { - const command = ChildProcess.make("git", args, { - cwd: opts?.cwd, - env: opts?.env, - extendEnv: true, - }); - const handle = yield* spawner.spawn(command); - const [text, stderr] = yield* Effect.all( - [ - Stream.mkString(Stream.decodeText(handle.stdout)), - Stream.mkString(Stream.decodeText(handle.stderr)), - ], - { concurrency: 2 }, - ); - const code = yield* handle.exitCode; - return { code, text, stderr }; - }).pipe( - Effect.scoped, - Effect.catch((err) => - Effect.succeed({ - code: ChildProcessSpawner.ExitCode(1), - text: "", - stderr: String(err), - }), - ), - ); - - // FileSystem helpers — orDie converts PlatformError to defects - const exists = (p: string) => fileSystem.exists(p).pipe(Effect.orDie); - const mkdir = (p: string) => - fileSystem.makeDirectory(p, { recursive: true }).pipe(Effect.orDie); - const writeFile = (p: string, content: string) => - fileSystem.writeFileString(p, content).pipe(Effect.orDie); - const readFile = (p: string) => - fileSystem - .readFileString(p) - .pipe(Effect.catch(() => Effect.succeed(""))); - const removeFile = (p: string) => - fileSystem.remove(p).pipe(Effect.catch(() => Effect.void)); - - // --- internal Effect helpers --- - - const isEnabled = Effect.gen(function* () { - if (!isGit) return false; - const cfg = yield* Effect.promise(() => Config.get()); - return cfg.snapshot !== false; - }); - - const excludesPath = Effect.gen(function* () { - const result = yield* git( - ["rev-parse", "--path-format=absolute", "--git-path", "info/exclude"], - { - cwd: worktree, - }, - ); - const file = result.text.trim(); - if (!file) return undefined; - if (!(yield* exists(file))) return undefined; - return file; - }); - - const syncExclude = Effect.gen(function* () { - const file = yield* excludesPath; - const target = path.join(snapshotGit, "info", "exclude"); - yield* mkdir(path.join(snapshotGit, "info")); - if (!file) { - yield* writeFile(target, ""); - return; - } - const text = yield* readFile(file); - yield* writeFile(target, text); - }); - - const add = Effect.gen(function* () { - yield* syncExclude; - yield* git([...GIT_CFG, ...gitArgs(["add", "."])], { cwd: directory }); - }); - - // --- service methods --- - - const cleanup = Effect.fn("SnapshotService.cleanup")(function* () { - if (!(yield* isEnabled)) return; - if (!(yield* exists(snapshotGit))) return; - const result = yield* git(gitArgs(["gc", `--prune=${PRUNE}`]), { - cwd: directory, - }); - if (result.code !== 0) { - log.warn("cleanup failed", { - exitCode: result.code, - stderr: result.stderr, - }); - return; - } - log.info("cleanup", { prune: PRUNE }); - }); - - const track = Effect.fn("SnapshotService.track")(function* () { - if (!(yield* isEnabled)) return undefined; - const existed = yield* exists(snapshotGit); - yield* mkdir(snapshotGit); - if (!existed) { - yield* git(["init"], { - env: { GIT_DIR: snapshotGit, GIT_WORK_TREE: worktree }, - }); - yield* git([ - "--git-dir", - snapshotGit, - "config", - "core.autocrlf", - "false", - ]); - yield* git([ - "--git-dir", - snapshotGit, - "config", - "core.longpaths", - "true", - ]); - yield* git([ - "--git-dir", - snapshotGit, - "config", - "core.symlinks", - "true", - ]); - yield* git([ - "--git-dir", - snapshotGit, - "config", - "core.fsmonitor", - "false", - ]); - log.info("initialized"); - } - yield* add; - const result = yield* git(gitArgs(["write-tree"]), { cwd: directory }); - const hash = result.text.trim(); - log.info("tracking", { hash, cwd: directory, git: snapshotGit }); - return hash; - }); - - const patch = Effect.fn("SnapshotService.patch")(function* ( - hash: string, - ) { - yield* add; - const result = yield* git( - [ - ...GIT_CFG_QUOTE, - ...gitArgs([ - "diff", - "--no-ext-diff", - "--name-only", - hash, - "--", - ".", - ]), - ], - { cwd: directory }, - ); - - if (result.code !== 0) { - log.warn("failed to get diff", { hash, exitCode: result.code }); - return { hash, files: [] } as Snapshot.Patch; - } - - return { - hash, - files: result.text - .trim() - .split("\n") - .map((x: string) => x.trim()) - .filter(Boolean) - .map((x: string) => path.join(worktree, x).replaceAll("\\", "/")), - } as Snapshot.Patch; - }); - - const restore = Effect.fn("SnapshotService.restore")(function* ( - snapshot: string, - ) { - log.info("restore", { commit: snapshot }); - const result = yield* git( - [...GIT_CORE, ...gitArgs(["read-tree", snapshot])], - { cwd: worktree }, - ); - if (result.code === 0) { - const checkout = yield* git( - [...GIT_CORE, ...gitArgs(["checkout-index", "-a", "-f"])], - { cwd: worktree }, - ); - if (checkout.code === 0) return; - log.error("failed to restore snapshot", { - snapshot, - exitCode: checkout.code, - stderr: checkout.stderr, - }); - return; - } - log.error("failed to restore snapshot", { - snapshot, - exitCode: result.code, - stderr: result.stderr, - }); - }); - - const revert = Effect.fn("SnapshotService.revert")(function* ( - patches: Snapshot.Patch[], - ) { - const seen = new Set<string>(); - for (const item of patches) { - for (const file of item.files) { - if (seen.has(file)) continue; - log.info("reverting", { file, hash: item.hash }); - const result = yield* git( - [...GIT_CORE, ...gitArgs(["checkout", item.hash, "--", file])], - { - cwd: worktree, - }, - ); - if (result.code !== 0) { - const relativePath = path.relative(worktree, file); - const checkTree = yield* git( - [ - ...GIT_CORE, - ...gitArgs(["ls-tree", item.hash, "--", relativePath]), - ], - { - cwd: worktree, - }, - ); - if (checkTree.code === 0 && checkTree.text.trim()) { - log.info( - "file existed in snapshot but checkout failed, keeping", - { file }, - ); - } else { - log.info("file did not exist in snapshot, deleting", { file }); - yield* removeFile(file); - } - } - seen.add(file); - } - } - }); - - const diff = Effect.fn("SnapshotService.diff")(function* (hash: string) { - yield* add; - const result = yield* git( - [ - ...GIT_CFG_QUOTE, - ...gitArgs(["diff", "--no-ext-diff", hash, "--", "."]), - ], - { - cwd: worktree, - }, - ); - - if (result.code !== 0) { - log.warn("failed to get diff", { - hash, - exitCode: result.code, - stderr: result.stderr, - }); - return ""; - } - - return result.text.trim(); - }); - - const diffFull = Effect.fn("SnapshotService.diffFull")(function* ( - from: string, - to: string, - ) { - const result: Snapshot.FileDiff[] = []; - const status = new Map<string, "added" | "deleted" | "modified">(); - - const statuses = yield* git( - [ - ...GIT_CFG_QUOTE, - ...gitArgs([ - "diff", - "--no-ext-diff", - "--name-status", - "--no-renames", - from, - to, - "--", - ".", - ]), - ], - { cwd: directory }, - ); - - for (const line of statuses.text.trim().split("\n")) { - if (!line) continue; - const [code, file] = line.split("\t"); - if (!code || !file) continue; - const kind = code.startsWith("A") - ? "added" - : code.startsWith("D") - ? "deleted" - : "modified"; - status.set(file, kind); - } - - const numstat = yield* git( - [ - ...GIT_CFG_QUOTE, - ...gitArgs([ - "diff", - "--no-ext-diff", - "--no-renames", - "--numstat", - from, - to, - "--", - ".", - ]), - ], - { cwd: directory }, - ); - - for (const line of numstat.text.trim().split("\n")) { - if (!line) continue; - const [additions, deletions, file] = line.split("\t"); - const isBinaryFile = additions === "-" && deletions === "-"; - const [before, after] = isBinaryFile - ? ["", ""] - : yield* Effect.all( - [ - git([ - ...GIT_CFG, - ...gitArgs(["show", `${from}:${file}`]), - ]).pipe(Effect.map((r) => r.text)), - git([...GIT_CFG, ...gitArgs(["show", `${to}:${file}`])]).pipe( - Effect.map((r) => r.text), - ), - ], - { concurrency: 2 }, - ); - const added = isBinaryFile ? 0 : parseInt(additions!); - const deleted = isBinaryFile ? 0 : parseInt(deletions!); - result.push({ - file: file!, - before, - after, - additions: Number.isFinite(added) ? added : 0, - deletions: Number.isFinite(deleted) ? deleted : 0, - status: status.get(file!) ?? "modified", - }); - } - return result; - }); - - // Start hourly cleanup fiber — scoped to instance lifetime - yield* cleanup().pipe( - Effect.catchCause((cause) => { - log.error("cleanup loop failed", { cause: Cause.pretty(cause) }); - return Effect.void; - }), - Effect.repeat(Schedule.spaced(Duration.hours(1))), - Effect.forkScoped, - ); - - return SnapshotService.of({ - init: Effect.fn("SnapshotService.init")(function* () {}), - cleanup, - track, - patch, - restore, - revert, - diff, - diffFull, - }); - }), - ).pipe( - Layer.provide(NodeChildProcessSpawner.layer), - Layer.provide(NodeFileSystem.layer), - Layer.provide(NodePath.layer), - ); +export class SnapshotService extends ServiceMap.Service<SnapshotService, SnapshotService.Service>()( + "@opencode/Snapshot", +) { + static readonly layer = Layer.effect( + SnapshotService, + Effect.gen(function* () { + const ctx = yield* InstanceContext + const fileSystem = yield* FileSystem.FileSystem + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner + const { directory, worktree, project } = ctx + const isGit = project.vcs === "git" + const snapshotGit = path.join(Global.Path.data, "snapshot", project.id) + + const gitArgs = (cmd: string[]) => ["--git-dir", snapshotGit, "--work-tree", worktree, ...cmd] + + // Run git with nothrow semantics — always returns a result, never fails + const git = (args: string[], opts?: { cwd?: string; env?: Record<string, string> }): Effect.Effect<GitResult> => + Effect.gen(function* () { + const command = ChildProcess.make("git", args, { + cwd: opts?.cwd, + env: opts?.env, + extendEnv: true, + }) + const handle = yield* spawner.spawn(command) + const [text, stderr] = yield* Effect.all( + [Stream.mkString(Stream.decodeText(handle.stdout)), Stream.mkString(Stream.decodeText(handle.stderr))], + { concurrency: 2 }, + ) + const code = yield* handle.exitCode + return { code, text, stderr } + }).pipe( + Effect.scoped, + Effect.catch((err) => + Effect.succeed({ + code: ChildProcessSpawner.ExitCode(1), + text: "", + stderr: String(err), + }), + ), + ) + + // FileSystem helpers — orDie converts PlatformError to defects + const exists = (p: string) => fileSystem.exists(p).pipe(Effect.orDie) + const mkdir = (p: string) => fileSystem.makeDirectory(p, { recursive: true }).pipe(Effect.orDie) + const writeFile = (p: string, content: string) => fileSystem.writeFileString(p, content).pipe(Effect.orDie) + const readFile = (p: string) => fileSystem.readFileString(p).pipe(Effect.catch(() => Effect.succeed(""))) + const removeFile = (p: string) => fileSystem.remove(p).pipe(Effect.catch(() => Effect.void)) + + // --- internal Effect helpers --- + + const isEnabled = Effect.gen(function* () { + if (!isGit) return false + const cfg = yield* Effect.promise(() => Config.get()) + return cfg.snapshot !== false + }) + + const excludesPath = Effect.gen(function* () { + const result = yield* git(["rev-parse", "--path-format=absolute", "--git-path", "info/exclude"], { + cwd: worktree, + }) + const file = result.text.trim() + if (!file) return undefined + if (!(yield* exists(file))) return undefined + return file + }) + + const syncExclude = Effect.gen(function* () { + const file = yield* excludesPath + const target = path.join(snapshotGit, "info", "exclude") + yield* mkdir(path.join(snapshotGit, "info")) + if (!file) { + yield* writeFile(target, "") + return + } + const text = yield* readFile(file) + yield* writeFile(target, text) + }) + + const add = Effect.gen(function* () { + yield* syncExclude + yield* git([...GIT_CFG, ...gitArgs(["add", "."])], { cwd: directory }) + }) + + // --- service methods --- + + const cleanup = Effect.fn("SnapshotService.cleanup")(function* () { + if (!(yield* isEnabled)) return + if (!(yield* exists(snapshotGit))) return + const result = yield* git(gitArgs(["gc", `--prune=${PRUNE}`]), { + cwd: directory, + }) + if (result.code !== 0) { + log.warn("cleanup failed", { + exitCode: result.code, + stderr: result.stderr, + }) + return + } + log.info("cleanup", { prune: PRUNE }) + }) + + const track = Effect.fn("SnapshotService.track")(function* () { + if (!(yield* isEnabled)) return undefined + const existed = yield* exists(snapshotGit) + yield* mkdir(snapshotGit) + if (!existed) { + yield* git(["init"], { + env: { GIT_DIR: snapshotGit, GIT_WORK_TREE: worktree }, + }) + yield* git(["--git-dir", snapshotGit, "config", "core.autocrlf", "false"]) + yield* git(["--git-dir", snapshotGit, "config", "core.longpaths", "true"]) + yield* git(["--git-dir", snapshotGit, "config", "core.symlinks", "true"]) + yield* git(["--git-dir", snapshotGit, "config", "core.fsmonitor", "false"]) + log.info("initialized") + } + yield* add + const result = yield* git(gitArgs(["write-tree"]), { cwd: directory }) + const hash = result.text.trim() + log.info("tracking", { hash, cwd: directory, git: snapshotGit }) + return hash + }) + + const patch = Effect.fn("SnapshotService.patch")(function* (hash: string) { + yield* add + const result = yield* git( + [...GIT_CFG_QUOTE, ...gitArgs(["diff", "--no-ext-diff", "--name-only", hash, "--", "."])], + { cwd: directory }, + ) + + if (result.code !== 0) { + log.warn("failed to get diff", { hash, exitCode: result.code }) + return { hash, files: [] } as Snapshot.Patch + } + + return { + hash, + files: result.text + .trim() + .split("\n") + .map((x: string) => x.trim()) + .filter(Boolean) + .map((x: string) => path.join(worktree, x).replaceAll("\\", "/")), + } as Snapshot.Patch + }) + + const restore = Effect.fn("SnapshotService.restore")(function* (snapshot: string) { + log.info("restore", { commit: snapshot }) + const result = yield* git([...GIT_CORE, ...gitArgs(["read-tree", snapshot])], { cwd: worktree }) + if (result.code === 0) { + const checkout = yield* git([...GIT_CORE, ...gitArgs(["checkout-index", "-a", "-f"])], { cwd: worktree }) + if (checkout.code === 0) return + log.error("failed to restore snapshot", { + snapshot, + exitCode: checkout.code, + stderr: checkout.stderr, + }) + return + } + log.error("failed to restore snapshot", { + snapshot, + exitCode: result.code, + stderr: result.stderr, + }) + }) + + const revert = Effect.fn("SnapshotService.revert")(function* (patches: Snapshot.Patch[]) { + const seen = new Set<string>() + for (const item of patches) { + for (const file of item.files) { + if (seen.has(file)) continue + log.info("reverting", { file, hash: item.hash }) + const result = yield* git([...GIT_CORE, ...gitArgs(["checkout", item.hash, "--", file])], { + cwd: worktree, + }) + if (result.code !== 0) { + const relativePath = path.relative(worktree, file) + const checkTree = yield* git([...GIT_CORE, ...gitArgs(["ls-tree", item.hash, "--", relativePath])], { + cwd: worktree, + }) + if (checkTree.code === 0 && checkTree.text.trim()) { + log.info("file existed in snapshot but checkout failed, keeping", { file }) + } else { + log.info("file did not exist in snapshot, deleting", { file }) + yield* removeFile(file) + } + } + seen.add(file) + } + } + }) + + const diff = Effect.fn("SnapshotService.diff")(function* (hash: string) { + yield* add + const result = yield* git([...GIT_CFG_QUOTE, ...gitArgs(["diff", "--no-ext-diff", hash, "--", "."])], { + cwd: worktree, + }) + + if (result.code !== 0) { + log.warn("failed to get diff", { + hash, + exitCode: result.code, + stderr: result.stderr, + }) + return "" + } + + return result.text.trim() + }) + + const diffFull = Effect.fn("SnapshotService.diffFull")(function* (from: string, to: string) { + const result: Snapshot.FileDiff[] = [] + const status = new Map<string, "added" | "deleted" | "modified">() + + const statuses = yield* git( + [ + ...GIT_CFG_QUOTE, + ...gitArgs(["diff", "--no-ext-diff", "--name-status", "--no-renames", from, to, "--", "."]), + ], + { cwd: directory }, + ) + + for (const line of statuses.text.trim().split("\n")) { + if (!line) continue + const [code, file] = line.split("\t") + if (!code || !file) continue + const kind = code.startsWith("A") ? "added" : code.startsWith("D") ? "deleted" : "modified" + status.set(file, kind) + } + + const numstat = yield* git( + [...GIT_CFG_QUOTE, ...gitArgs(["diff", "--no-ext-diff", "--no-renames", "--numstat", from, to, "--", "."])], + { cwd: directory }, + ) + + for (const line of numstat.text.trim().split("\n")) { + if (!line) continue + const [additions, deletions, file] = line.split("\t") + const isBinaryFile = additions === "-" && deletions === "-" + const [before, after] = isBinaryFile + ? ["", ""] + : yield* Effect.all( + [ + git([...GIT_CFG, ...gitArgs(["show", `${from}:${file}`])]).pipe(Effect.map((r) => r.text)), + git([...GIT_CFG, ...gitArgs(["show", `${to}:${file}`])]).pipe(Effect.map((r) => r.text)), + ], + { concurrency: 2 }, + ) + const added = isBinaryFile ? 0 : parseInt(additions!) + const deleted = isBinaryFile ? 0 : parseInt(deletions!) + result.push({ + file: file!, + before, + after, + additions: Number.isFinite(added) ? added : 0, + deletions: Number.isFinite(deleted) ? deleted : 0, + status: status.get(file!) ?? "modified", + }) + } + return result + }) + + // Start hourly cleanup fiber — scoped to instance lifetime + yield* cleanup().pipe( + Effect.catchCause((cause) => { + log.error("cleanup loop failed", { cause: Cause.pretty(cause) }) + return Effect.void + }), + Effect.repeat(Schedule.spaced(Duration.hours(1))), + Effect.forkScoped, + ) + + return SnapshotService.of({ + init: Effect.fn("SnapshotService.init")(function* () {}), + cleanup, + track, + patch, + restore, + revert, + diff, + diffFull, + }) + }), + ).pipe( + Layer.provide(NodeChildProcessSpawner.layer), + Layer.provide(NodeFileSystem.layer), + Layer.provide(NodePath.layer), + ) } diff --git a/packages/opencode/test/fixture/instance.ts b/packages/opencode/test/fixture/instance.ts index 1a7096b63..ce880d70d 100644 --- a/packages/opencode/test/fixture/instance.ts +++ b/packages/opencode/test/fixture/instance.ts @@ -1,14 +1,14 @@ -import { ConfigProvider, Layer, ManagedRuntime } from "effect"; -import { InstanceContext } from "../../src/effect/instance-context"; -import { Instance } from "../../src/project/instance"; +import { ConfigProvider, Layer, ManagedRuntime } from "effect" +import { InstanceContext } from "../../src/effect/instance-context" +import { Instance } from "../../src/project/instance" /** ConfigProvider that enables the experimental file watcher. */ export const watcherConfigLayer = ConfigProvider.layer( - ConfigProvider.fromUnknown({ - OPENCODE_EXPERIMENTAL_FILEWATCHER: "true", - OPENCODE_EXPERIMENTAL_DISABLE_FILEWATCHER: "false", - }), -); + ConfigProvider.fromUnknown({ + OPENCODE_EXPERIMENTAL_FILEWATCHER: "true", + OPENCODE_EXPERIMENTAL_DISABLE_FILEWATCHER: "false", + }), +) /** * Boot an Instance with the given service layers and run `body` with @@ -19,35 +19,33 @@ export const watcherConfigLayer = ConfigProvider.layer( * Pass extra layers via `options.provide` (e.g. ConfigProvider.layer). */ export function withServices<S>( - directory: string, - layer: Layer.Layer<S, any, InstanceContext>, - body: (rt: ManagedRuntime.ManagedRuntime<S, never>) => Promise<void>, - options?: { provide?: Layer.Layer<never>[] }, + directory: string, + layer: Layer.Layer<S, any, InstanceContext>, + body: (rt: ManagedRuntime.ManagedRuntime<S, never>) => Promise<void>, + options?: { provide?: Layer.Layer<never>[] }, ) { - return Instance.provide({ - directory, - fn: async () => { - const ctx = Layer.sync(InstanceContext, () => - InstanceContext.of({ - directory: Instance.directory, - worktree: Instance.worktree, - project: Instance.project, - }), - ); - let resolved: Layer.Layer<S> = Layer.fresh(layer).pipe( - Layer.provide(ctx), - ) as any; - if (options?.provide) { - for (const l of options.provide) { - resolved = resolved.pipe(Layer.provide(l)) as any; - } - } - const rt = ManagedRuntime.make(resolved); - try { - await body(rt); - } finally { - await rt.dispose(); - } - }, - }); + return Instance.provide({ + directory, + fn: async () => { + const ctx = Layer.sync(InstanceContext, () => + InstanceContext.of({ + directory: Instance.directory, + worktree: Instance.worktree, + project: Instance.project, + }), + ) + let resolved: Layer.Layer<S> = Layer.fresh(layer).pipe(Layer.provide(ctx)) as any + if (options?.provide) { + for (const l of options.provide) { + resolved = resolved.pipe(Layer.provide(l)) as any + } + } + const rt = ManagedRuntime.make(resolved) + try { + await body(rt) + } finally { + await rt.dispose() + } + }, + }) } diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts index fd80a51a2..9c5ca274e 100644 --- a/packages/sdk/js/src/v2/gen/types.gen.ts +++ b/packages/sdk/js/src/v2/gen/types.gen.ts @@ -47,6 +47,13 @@ export type EventProjectUpdated = { properties: Project } +export type EventFileEdited = { + type: "file.edited" + properties: { + file: string + } +} + export type EventServerInstanceDisposed = { type: "server.instance.disposed" properties: { @@ -54,6 +61,50 @@ export type EventServerInstanceDisposed = { } } +export type EventFileWatcherUpdated = { + type: "file.watcher.updated" + properties: { + file: string + event: "add" | "change" | "unlink" + } +} + +export type PermissionRequest = { + id: string + sessionID: string + permission: string + patterns: Array<string> + metadata: { + [key: string]: unknown + } + always: Array<string> + tool?: { + messageID: string + callID: string + } +} + +export type EventPermissionAsked = { + type: "permission.asked" + properties: PermissionRequest +} + +export type EventPermissionReplied = { + type: "permission.replied" + properties: { + sessionID: string + requestID: string + reply: "once" | "always" | "reject" + } +} + +export type EventVcsBranchUpdated = { + type: "vcs.branch.updated" + properties: { + branch?: string + } +} + export type QuestionOption = { /** * Display text (1-5 words, concise) @@ -125,57 +176,6 @@ export type EventQuestionRejected = { } } -export type PermissionRequest = { - id: string - sessionID: string - permission: string - patterns: Array<string> - metadata: { - [key: string]: unknown - } - always: Array<string> - tool?: { - messageID: string - callID: string - } -} - -export type EventPermissionAsked = { - type: "permission.asked" - properties: PermissionRequest -} - -export type EventPermissionReplied = { - type: "permission.replied" - properties: { - sessionID: string - requestID: string - reply: "once" | "always" | "reject" - } -} - -export type EventFileWatcherUpdated = { - type: "file.watcher.updated" - properties: { - file: string - event: "add" | "change" | "unlink" - } -} - -export type EventVcsBranchUpdated = { - type: "vcs.branch.updated" - properties: { - branch?: string - } -} - -export type EventFileEdited = { - type: "file.edited" - properties: { - file: string - } -} - export type EventServerConnected = { type: "server.connected" properties: { @@ -961,15 +961,15 @@ export type Event = | EventInstallationUpdated | EventInstallationUpdateAvailable | EventProjectUpdated + | EventFileEdited | EventServerInstanceDisposed - | EventQuestionAsked - | EventQuestionReplied - | EventQuestionRejected + | EventFileWatcherUpdated | EventPermissionAsked | EventPermissionReplied - | EventFileWatcherUpdated | EventVcsBranchUpdated - | EventFileEdited + | EventQuestionAsked + | EventQuestionReplied + | EventQuestionRejected | EventServerConnected | EventGlobalDisposed | EventLspClientDiagnostics diff --git a/packages/sdk/openapi.json b/packages/sdk/openapi.json index 2f7e9952e..c6d79b11e 100644 --- a/packages/sdk/openapi.json +++ b/packages/sdk/openapi.json @@ -7043,171 +7043,75 @@ }, "required": ["type", "properties"] }, - "Event.server.instance.disposed": { + "Event.file.edited": { "type": "object", "properties": { "type": { "type": "string", - "const": "server.instance.disposed" + "const": "file.edited" }, "properties": { "type": "object", "properties": { - "directory": { - "type": "string" - } - }, - "required": ["directory"] - } - }, - "required": ["type", "properties"] - }, - "QuestionOption": { - "type": "object", - "properties": { - "label": { - "description": "Display text (1-5 words, concise)", - "type": "string" - }, - "description": { - "description": "Explanation of choice", - "type": "string" - } - }, - "required": ["label", "description"] - }, - "QuestionInfo": { - "type": "object", - "properties": { - "question": { - "description": "Complete question", - "type": "string" - }, - "header": { - "description": "Very short label (max 30 chars)", - "type": "string" - }, - "options": { - "description": "Available choices", - "type": "array", - "items": { - "$ref": "#/components/schemas/QuestionOption" - } - }, - "multiple": { - "description": "Allow selecting multiple choices", - "type": "boolean" - }, - "custom": { - "description": "Allow typing a custom answer (default: true)", - "type": "boolean" - } - }, - "required": ["question", "header", "options"] - }, - "QuestionRequest": { - "type": "object", - "properties": { - "id": { - "type": "string", - "pattern": "^que.*" - }, - "sessionID": { - "type": "string", - "pattern": "^ses.*" - }, - "questions": { - "description": "Questions to ask", - "type": "array", - "items": { - "$ref": "#/components/schemas/QuestionInfo" - } - }, - "tool": { - "type": "object", - "properties": { - "messageID": { - "type": "string", - "pattern": "^msg.*" - }, - "callID": { + "file": { "type": "string" } }, - "required": ["messageID", "callID"] - } - }, - "required": ["id", "sessionID", "questions"] - }, - "Event.question.asked": { - "type": "object", - "properties": { - "type": { - "type": "string", - "const": "question.asked" - }, - "properties": { - "$ref": "#/components/schemas/QuestionRequest" + "required": ["file"] } }, "required": ["type", "properties"] }, - "QuestionAnswer": { - "type": "array", - "items": { - "type": "string" - } - }, - "Event.question.replied": { + "Event.server.instance.disposed": { "type": "object", "properties": { "type": { "type": "string", - "const": "question.replied" + "const": "server.instance.disposed" }, "properties": { "type": "object", "properties": { - "sessionID": { - "type": "string", - "pattern": "^ses.*" - }, - "requestID": { - "type": "string", - "pattern": "^que.*" - }, - "answers": { - "type": "array", - "items": { - "$ref": "#/components/schemas/QuestionAnswer" - } + "directory": { + "type": "string" } }, - "required": ["sessionID", "requestID", "answers"] + "required": ["directory"] } }, "required": ["type", "properties"] }, - "Event.question.rejected": { + "Event.file.watcher.updated": { "type": "object", "properties": { "type": { "type": "string", - "const": "question.rejected" + "const": "file.watcher.updated" }, "properties": { "type": "object", "properties": { - "sessionID": { - "type": "string", - "pattern": "^ses.*" + "file": { + "type": "string" }, - "requestID": { - "type": "string", - "pattern": "^que.*" + "event": { + "anyOf": [ + { + "type": "string", + "const": "add" + }, + { + "type": "string", + "const": "change" + }, + { + "type": "string", + "const": "unlink" + } + ] } }, - "required": ["sessionID", "requestID"] + "required": ["file", "event"] } }, "required": ["type", "properties"] @@ -7302,74 +7206,170 @@ }, "required": ["type", "properties"] }, - "Event.file.watcher.updated": { + "Event.vcs.branch.updated": { "type": "object", "properties": { "type": { "type": "string", - "const": "file.watcher.updated" + "const": "vcs.branch.updated" }, "properties": { "type": "object", "properties": { - "file": { + "branch": { "type": "string" + } + } + } + }, + "required": ["type", "properties"] + }, + "QuestionOption": { + "type": "object", + "properties": { + "label": { + "description": "Display text (1-5 words, concise)", + "type": "string" + }, + "description": { + "description": "Explanation of choice", + "type": "string" + } + }, + "required": ["label", "description"] + }, + "QuestionInfo": { + "type": "object", + "properties": { + "question": { + "description": "Complete question", + "type": "string" + }, + "header": { + "description": "Very short label (max 30 chars)", + "type": "string" + }, + "options": { + "description": "Available choices", + "type": "array", + "items": { + "$ref": "#/components/schemas/QuestionOption" + } + }, + "multiple": { + "description": "Allow selecting multiple choices", + "type": "boolean" + }, + "custom": { + "description": "Allow typing a custom answer (default: true)", + "type": "boolean" + } + }, + "required": ["question", "header", "options"] + }, + "QuestionRequest": { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^que.*" + }, + "sessionID": { + "type": "string", + "pattern": "^ses.*" + }, + "questions": { + "description": "Questions to ask", + "type": "array", + "items": { + "$ref": "#/components/schemas/QuestionInfo" + } + }, + "tool": { + "type": "object", + "properties": { + "messageID": { + "type": "string", + "pattern": "^msg.*" }, - "event": { - "anyOf": [ - { - "type": "string", - "const": "add" - }, - { - "type": "string", - "const": "change" - }, - { - "type": "string", - "const": "unlink" - } - ] + "callID": { + "type": "string" } }, - "required": ["file", "event"] + "required": ["messageID", "callID"] + } + }, + "required": ["id", "sessionID", "questions"] + }, + "Event.question.asked": { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "question.asked" + }, + "properties": { + "$ref": "#/components/schemas/QuestionRequest" } }, "required": ["type", "properties"] }, - "Event.vcs.branch.updated": { + "QuestionAnswer": { + "type": "array", + "items": { + "type": "string" + } + }, + "Event.question.replied": { "type": "object", "properties": { "type": { "type": "string", - "const": "vcs.branch.updated" + "const": "question.replied" }, "properties": { "type": "object", "properties": { - "branch": { - "type": "string" + "sessionID": { + "type": "string", + "pattern": "^ses.*" + }, + "requestID": { + "type": "string", + "pattern": "^que.*" + }, + "answers": { + "type": "array", + "items": { + "$ref": "#/components/schemas/QuestionAnswer" + } } - } + }, + "required": ["sessionID", "requestID", "answers"] } }, "required": ["type", "properties"] }, - "Event.file.edited": { + "Event.question.rejected": { "type": "object", "properties": { "type": { "type": "string", - "const": "file.edited" + "const": "question.rejected" }, "properties": { "type": "object", "properties": { - "file": { - "type": "string" + "sessionID": { + "type": "string", + "pattern": "^ses.*" + }, + "requestID": { + "type": "string", + "pattern": "^que.*" } }, - "required": ["file"] + "required": ["sessionID", "requestID"] } }, "required": ["type", "properties"] @@ -9609,16 +9609,13 @@ "$ref": "#/components/schemas/Event.project.updated" }, { - "$ref": "#/components/schemas/Event.server.instance.disposed" - }, - { - "$ref": "#/components/schemas/Event.question.asked" + "$ref": "#/components/schemas/Event.file.edited" }, { - "$ref": "#/components/schemas/Event.question.replied" + "$ref": "#/components/schemas/Event.server.instance.disposed" }, { - "$ref": "#/components/schemas/Event.question.rejected" + "$ref": "#/components/schemas/Event.file.watcher.updated" }, { "$ref": "#/components/schemas/Event.permission.asked" @@ -9627,13 +9624,16 @@ "$ref": "#/components/schemas/Event.permission.replied" }, { - "$ref": "#/components/schemas/Event.file.watcher.updated" + "$ref": "#/components/schemas/Event.vcs.branch.updated" }, { - "$ref": "#/components/schemas/Event.vcs.branch.updated" + "$ref": "#/components/schemas/Event.question.asked" }, { - "$ref": "#/components/schemas/Event.file.edited" + "$ref": "#/components/schemas/Event.question.replied" + }, + { + "$ref": "#/components/schemas/Event.question.rejected" }, { "$ref": "#/components/schemas/Event.server.connected" |
