Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions packages/app/e2e/utils/mock-api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -232,6 +232,8 @@ const Group = HttpApiGroup.make("mock")
error: Unsupported,
}),
)
.add(HttpApiEndpoint.post("fsWatch", "/api/experimental/fs/watch", { success: NoContent }))
.add(HttpApiEndpoint.delete("fsUnwatch", "/api/experimental/fs/watch", { success: NoContent }))
.add(HttpApiEndpoint.get("shell", "/api/shell", { success: Json }))
.add(
HttpApiEndpoint.get("shellOutput", "/api/shell/:id/output", {
Expand Down
2 changes: 2 additions & 0 deletions packages/app/e2e/utils/mock-server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1130,6 +1130,8 @@ function mockHandlers(
: entries,
})),
),
fsWatch: () => Effect.void,
fsUnwatch: () => Effect.void,
fsWrite: (ctx) => {
const write = config.onFileWrite

Expand Down
6 changes: 5 additions & 1 deletion packages/app/src/runtime/extension/mounted-session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -93,7 +93,11 @@ export function createMountedSession(session: SessionModel) {
absolute: file.absolute,
get: file.get,
missing: file.notFound,
sync: (path, options) => file.load(path, options),
sync: (path, options) => {
file.watch(path)
return file.load(path, options)
},
unwatch: file.unwatch,
exists: file.exists,
search: (query, options) =>
options?.kind === "any" ? file.searchFilesAndDirectories(query, options) : file.searchFiles(query, options),
Expand Down
22 changes: 22 additions & 0 deletions packages/app/src/workspaces/files/model.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -298,6 +298,26 @@ export const { use: useFile, provider: FileProvider } = createSimpleContext({
onCleanup(stop)
})

// The server watches a file for 24 hours from the latest request, so file tabs ask when they open or are shown,
// and the open tabs ask again on reconnect (a restarted server forgets its watches). Older servers lack the route.
const watch = (input: string) => {
const file = path.normalize(input)
if (!file || path.absolute(file)) return
void serverSDK.api.file.watch({ path: file, location: { directory: scope() } }).catch(() => {})
}

const unwatch = (input: string) => {
const file = path.normalize(input)
if (!file || path.absolute(file)) return
void serverSDK.api.file.unwatch({ path: file, location: { directory: scope() } }).catch(() => {})
}

createEffect(() => {
const stop = serverSDK.event.on("server.connected", () => extensions.files.opened().forEach(watch))

onCleanup(stop)
})

const get = (input: string) => {
const file = path.normalize(input)
const state = store.file[file]
Expand Down Expand Up @@ -348,6 +368,8 @@ export const { use: useFile, provider: FileProvider } = createSimpleContext({
get,
notFound: (input: string) => store.file[path.normalize(input)]?.notFound ?? false,
load,
watch,
unwatch,
exists,
scrollTop,
scrollLeft,
Expand Down
16 changes: 16 additions & 0 deletions packages/client/src/effect/api/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1842,9 +1842,25 @@ export type FileFindInput = {
export type FileFindOutput = { readonly location: Location.PublicRef; readonly data: ReadonlyArray<FileSystem.Entry> }
export type FileFindOperation<E = never> = (input: FileFindInput) => Effect.Effect<FileFindOutput, E>

export type FileWatchInput = {
readonly location?: { readonly directory?: string | undefined } | undefined
readonly path: string
}
export type FileWatchOutput = void
export type FileWatchOperation<E = never> = (input: FileWatchInput) => Effect.Effect<FileWatchOutput, E>

export type FileUnwatchInput = {
readonly location?: { readonly directory?: string | undefined } | undefined
readonly path: string
}
export type FileUnwatchOutput = void
export type FileUnwatchOperation<E = never> = (input: FileUnwatchInput) => Effect.Effect<FileUnwatchOutput, E>

export interface FileApi<E = never> {
readonly list: FileListOperation<E>
readonly find: FileFindOperation<E>
readonly watch: FileWatchOperation<E>
readonly unwatch: FileUnwatchOperation<E>
}

export type CommandListInput = { readonly location?: { readonly directory?: string | undefined } | undefined }
Expand Down
25 changes: 24 additions & 1 deletion packages/client/src/effect/generated/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -195,6 +195,10 @@ import type {
FileListOutput,
FileFindInput,
FileFindOutput,
FileWatchInput,
FileWatchOutput,
FileUnwatchInput,
FileUnwatchOutput,
CommandListInput,
CommandListOutput,
SkillListInput,
Expand Down Expand Up @@ -1272,7 +1276,26 @@ const EndpointFileFind = (raw: RawClient["server.fs"]) => (input: FileFindInput)
}).pipe(Effect.mapError(mapClientError)),
)

const adaptGroupFile = (raw: RawClient["server.fs"]) => ({ list: EndpointFileList(raw), find: EndpointFileFind(raw) })
const EndpointFileWatch = (raw: RawClient["server.fs"]) => (input: FileWatchInput) =>
preserveEffect<FileWatchOutput>()(
raw["fs.watch"]({ query: { location: input["location"] }, payload: { path: input["path"] } }).pipe(
Effect.mapError(mapClientError),
),
)

const EndpointFileUnwatch = (raw: RawClient["server.fs"]) => (input: FileUnwatchInput) =>
preserveEffect<FileUnwatchOutput>()(
raw["fs.unwatch"]({ query: { location: input["location"] }, payload: { path: input["path"] } }).pipe(
Effect.mapError(mapClientError),
),
)

const adaptGroupFile = (raw: RawClient["server.fs"]) => ({
list: EndpointFileList(raw),
find: EndpointFileFind(raw),
watch: EndpointFileWatch(raw),
unwatch: EndpointFileUnwatch(raw),
})

const EndpointCommandList = (raw: RawClient["server.command"]) => (input?: CommandListInput) =>
preserveEffect<CommandListOutput>()(
Expand Down
30 changes: 30 additions & 0 deletions packages/client/src/promise/generated/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -193,6 +193,10 @@ import type {
FileFindOutput,
FileWriteInput,
FileWriteOutput,
FileWatchInput,
FileWatchOutput,
FileUnwatchInput,
FileUnwatchOutput,
CommandListInput,
CommandListOutput,
SkillListInput,
Expand Down Expand Up @@ -1727,6 +1731,32 @@ export function make(options: ClientOptions) {
},
requestOptions,
),
watch: (input: FileWatchInput, requestOptions?: RequestOptions) =>
request<FileWatchOutput>(
{
method: "POST",
path: `/api/experimental/fs/watch`,
query: { location: input["location"] },
body: { path: input["path"] },
successStatus: 204,
declaredStatuses: [400, 401, 404, 503],
empty: true,
},
requestOptions,
),
unwatch: (input: FileUnwatchInput, requestOptions?: RequestOptions) =>
request<FileUnwatchOutput>(
{
method: "DELETE",
path: `/api/experimental/fs/watch`,
query: { location: input["location"] },
body: { path: input["path"] },
successStatus: 204,
declaredStatuses: [400, 401, 404],
empty: true,
},
requestOptions,
),
},
command: {
list: (input?: CommandListInput, requestOptions?: RequestOptions) =>
Expand Down
14 changes: 14 additions & 0 deletions packages/client/src/promise/generated/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5588,6 +5588,20 @@ export type FileWriteInput = {

export type FileWriteOutput = { location: LocationPublicRef; data: FileSystemWrite }

export type FileWatchInput = {
readonly location?: { readonly location?: { readonly directory?: string | undefined } | undefined }["location"]
readonly path: { readonly path: string }["path"]
}

export type FileWatchOutput = void

export type FileUnwatchInput = {
readonly location?: { readonly location?: { readonly directory?: string | undefined } | undefined }["location"]
readonly path: { readonly path: string }["path"]
}

export type FileUnwatchOutput = void

export type CommandListInput = {
readonly location?: { readonly location?: { readonly directory?: string | undefined } | undefined }["location"]
}
Expand Down
2 changes: 1 addition & 1 deletion packages/client/test/promise.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@ test("exposes every standard HTTP API group", () => {
expect(Object.keys(client.integration.command)).toEqual(["connect", "status", "cancel"])
expect(Object.keys(client.websearch)).toEqual(["providers", "query"])
expect(Object.keys(client.voice)).toEqual(["models", "session", "instructions"])
expect(Object.keys(client.file)).toEqual(["read", "list", "find", "write"])
expect(Object.keys(client.file)).toEqual(["read", "list", "find", "write", "watch", "unwatch"])
expect(Object.keys(client.vcs)).toEqual(["init", "get", "base", "status", "branch", "diff"])
expect(Object.keys(client.pty)).toEqual(["list", "create", "get", "update", "remove", "connect"])
expect(Object.keys(client.pty.connect)).toEqual(["token"])
Expand Down
164 changes: 164 additions & 0 deletions packages/core/src/filesystem/file-watch.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,164 @@
export * as FileWatch from "./file-watch.js"

import { makeGlobalNode } from "@opencode/util/effect/app-node"
import { FSUtil } from "@opencode/util/fs-util"
import { FileSystem } from "@opencode/schema/filesystem"
import type { Location } from "@opencode/schema/location"
import {
Cause,
Clock,
Context,
Duration,
Effect,
Exit,
Layer,
Schedule,
Schema,
Scope,
Semaphore,
Stream,
} from "effect"
import { Bus } from "../bus.js"
import { Watcher } from "./watcher.js"

export const TTL = Duration.hours(24)
export const SWEEP_INTERVAL = Duration.minutes(5)
export const MAX_PER_LOCATION = 256

export class DisabledError extends Schema.TaggedError<DisabledError>()("FileWatch.DisabledError", {}) {
override get message() {
return "File watching is disabled"
}
}

export type Input = {
readonly location: Location.Ref
/** Absolute path of the file to watch. */
readonly file: string
}

export interface Interface {
/** Starts watching a file, or resets its expiry when it is already watched. */
readonly watch: (input: Input) => Effect.Effect<void, DisabledError>
readonly unwatch: (input: Input) => Effect.Effect<void>
}

export class Service extends Context.Service<Service, Interface>()("@opencode/FileWatch") {}

export const Options = Schema.Struct({
enabled: Schema.optional(Schema.Boolean),
})
export type Options = typeof Options.Type

type Entry = { expires: number; scope: Scope.Closeable }

// Global rather than location-scoped so watches outlive idle location eviction.
export const layer = (options?: Options) =>
Layer.effect(
Service,
Effect.gen(function* () {
if (options?.enabled === false) {
return Service.of({ watch: () => Effect.fail(new DisabledError()), unwatch: () => Effect.void })
}
const watcher = yield* Watcher.Service
const bus = yield* Bus.Service
const fs = yield* FSUtil.Service
const scope = yield* Effect.scope
const lock = yield* Semaphore.make(1)
const locations = new Map<string, Map<string, Entry>>()
const locationKey = (location: Location.Ref) => JSON.stringify([location.directory, location.workspaceID])

const remove = (key: string, file: string) => {
const files = locations.get(key)
const entry = files?.get(file)
if (!files || !entry) return
files.delete(file)
if (files.size === 0) locations.delete(key)
return entry
}

const stop = (key: string, file: string) =>
Effect.suspend(() => {
const entry = remove(key, file)
if (!entry) return Effect.void
return Scope.close(entry.scope, Exit.void)
})

const start = Effect.fnUntraced(function* (key: string, input: Input) {
const child = yield* Scope.fork(scope)
yield* Effect.gen(function* () {
const updates = yield* watcher.subscribe({ path: input.file, type: "file" })
yield* Stream.runForEach(updates, () =>
Effect.gen(function* () {
const exists = yield* fs.existsSafe(input.file)
yield* bus.publish(
FileSystem.Event.Changed,
{ file: input.file, event: exists ? "change" : "unlink" },
{ location: input.location },
)
}),
)
}).pipe(
Effect.catchCauseIf(

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When this stream ends, the entry stays in locations. This happens if the parent directory is removed (for example rm -rf src && mkdir src, a branch switch that drops the folder, or a build that wipes dist/), or if fs.watch hits ENOENT and the new Watcher path ends the stream. A later watch() then hits the existing branch (line 127) and only bumps expires, so the tab stays stale for up to 24h even though the client keeps asking.

I reproduced this with the native layer: watch src/a.ts, recreate src, call watch() again, write 10 times, and no events arrive.

One fix: drop the entry when the forked fiber exits without being interrupted, so the next request starts a fresh watch.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ended streams are handled in bbe5db0 (with a restart test); failed streams now get the same cleanup in 5d1a520 (with a test).

(cause) => !Cause.hasInterrupts(cause),
(cause) => Effect.logError("file watch failed", { file: input.file, cause }),
),
// The watch ended or failed (e.g. its directory vanished): forget it so the next watch starts a new one.
// Interrupts skip this. The scope closes from another fiber because closing it interrupts this one.
Effect.andThen(
lock.withPermit(
Effect.suspend(() => {
if (locations.get(key)?.get(input.file)?.scope !== child) return Effect.void
remove(key, input.file)
return Scope.close(child, Exit.void).pipe(Effect.forkIn(scope), Effect.asVoid)
}),
),
),
Effect.forkIn(child, { startImmediately: true }),
)
return child
})

const sweep = lock.withPermit(
Effect.gen(function* () {
const now = yield* Clock.currentTimeMillis
const expired = [...locations].flatMap(([key, files]) =>
[...files].filter(([, entry]) => entry.expires <= now).map(([file]) => [key, file] as const),
)
yield* Effect.forEach(expired, ([key, file]) => stop(key, file), { discard: true })
}),
)
yield* sweep.pipe(Effect.repeat(Schedule.spaced(SWEEP_INTERVAL)), Effect.forkScoped)

return Service.of({
watch: (input) =>
lock.withPermit(
Effect.gen(function* () {
const key = locationKey(input.location)
const expires = (yield* Clock.currentTimeMillis) + Duration.toMillis(TTL)
const existing = locations.get(key)?.get(input.file)
if (existing) {
existing.expires = expires
return
}
const full = locations.get(key)
if (full && full.size >= MAX_PER_LOCATION) {
const soonest = [...full].reduce((a, b) => (b[1].expires < a[1].expires ? b : a))
yield* stop(key, soonest[0])
}
const entry = { expires, scope: yield* start(key, input) }
const files = locations.get(key) ?? new Map<string, Entry>()
files.set(input.file, entry)
locations.set(key, files)
}),
),
unwatch: (input) => lock.withPermit(stop(locationKey(input.location), input.file)),
})
}),
)

export function configured(options?: Options) {
return makeGlobalNode({ service: Service, layer: layer(options), deps: [Watcher.node, Bus.node, FSUtil.node] })
}

export const node = configured()
Loading
Loading