diff --git a/packages/core/src/location-services.ts b/packages/core/src/location-services.ts index 45ce648695..c1e2fda6af 100644 --- a/packages/core/src/location-services.ts +++ b/packages/core/src/location-services.ts @@ -1,4 +1,5 @@ import { Effect, Layer, LayerMap } from "effect" +import { existsSync } from "fs" import path from "path" import { Agent } from "./agent.js" import { AISDK } from "./aisdk.js" @@ -146,7 +147,7 @@ export function buildLocationServiceMap( Layer.provide(LayerNode.compile(location.hoisted)), ) }, - { idleTimeToLive: "60 minutes" }, + { idleTimeToLive: (ref) => (existsSync(ref.directory) ? "60 minutes" : 0) }, ), (inner) => ({ ...inner, diff --git a/packages/core/src/session.ts b/packages/core/src/session.ts index 4432691718..81a9a903ce 100644 --- a/packages/core/src/session.ts +++ b/packages/core/src/session.ts @@ -1,7 +1,7 @@ export * as Session from "./session.js" export * from "./session/schema.js" -import { Effect, Layer, Schema, Context, RcMap, Stream, Scope } from "effect" +import { Cause, Effect, Layer, Schema, Context, RcMap, Stream, Scope } from "effect" import { ListAnchor } from "@opencode-ai/schema/session" import { and, asc, desc, eq, gt, isNull, like, lt, or, type SQL } from "drizzle-orm" import { Project } from "./project.js" @@ -156,6 +156,11 @@ export class DestinationNotDirectoryError extends Schema.TaggedError()( + "Session.DestinationUnavailableError", + { directory: AbsolutePath }, +) {} export const MessageNotFoundError = SessionRevert.MessageNotFoundError export type MessageNotFoundError = SessionRevert.MessageNotFoundError @@ -218,7 +223,10 @@ export interface Interface { directory: AbsolutePath workspaceID?: Location.Ref["workspaceID"] delivery?: SessionInbox.Delivery - }) => Effect.Effect + }) => Effect.Effect< + void, + NotFoundError | DestinationNotFoundError | DestinationNotDirectoryError | DestinationUnavailableError + > readonly prompt: (input: { id?: SessionMessage.ID sessionID: SessionSchema.ID @@ -772,12 +780,22 @@ const layer = Layer.effect( if (!info) return yield* new DestinationNotFoundError({ directory }) if (info.type !== "Directory") return yield* new DestinationNotDirectoryError({ directory }) const project = yield* projects.resolve(directory) - yield* persistProject(project) const payload: SessionInbox.MovePayload = { location: Location.Ref.make({ directory, workspaceID: input.workspaceID }), projectID: project.id, subpath: RelativePath.make(path.relative(project.directory, directory).replaceAll("\\", "/")), } + yield* Location.Service.pipe( + Effect.provide(locations.get(payload.location)), + Effect.scoped, + Effect.catchCause((cause) => { + if (Cause.hasInterruptsOnly(cause)) return Effect.failCause(cause) + return Effect.logWarning("session move destination unavailable", { directory, cause }).pipe( + Effect.andThen(Effect.fail(new DestinationUnavailableError({ directory }))), + ) + }), + ) + yield* persistProject(project) const item = SessionInbox.Item.make({ type: "move", payload, diff --git a/packages/core/test/location-layer.test.ts b/packages/core/test/location-layer.test.ts index 25c4e13e7c..e9c4d09135 100644 --- a/packages/core/test/location-layer.test.ts +++ b/packages/core/test/location-layer.test.ts @@ -43,6 +43,28 @@ const itWithSdk = testEffect( ) describe("LocationServiceMap", () => { + it.live("retries a location after its missing directory is recreated", () => + Effect.acquireRelease( + Effect.promise(() => tmpdir()), + (dir) => Effect.promise(() => dir[Symbol.asyncDispose]()), + ).pipe( + Effect.flatMap((dir) => + Effect.gen(function* () { + const locations = yield* LocationServiceMap.Service + const directory = path.join(dir.path, "recreated") + const ref = Location.Ref.make({ directory: AbsolutePath.make(directory) }) + + const first = yield* Location.Service.pipe(Effect.provide(locations.get(ref)), Effect.scoped, Effect.exit) + expect(first._tag).toBe("Failure") + + yield* Effect.promise(() => fs.mkdir(directory)) + const location = yield* Location.Service.pipe(Effect.provide(locations.get(ref)), Effect.scoped) + expect(location.directory).toBe(ref.directory) + }), + ), + ), + ) + itWithSdk.live("preserves embedded SDK plugins after Location eviction", () => Effect.acquireRelease( Effect.promise(() => tmpdir()), diff --git a/packages/core/test/session-move.test.ts b/packages/core/test/session-move.test.ts index d97e1ccf77..82f895f5b3 100644 --- a/packages/core/test/session-move.test.ts +++ b/packages/core/test/session-move.test.ts @@ -1,12 +1,14 @@ import { describe, expect } from "bun:test" import path from "path" import { mkdir, rm } from "fs/promises" -import { Effect } from "effect" +import { Effect, Layer, LayerMap } from "effect" import { Worktree } from "@opencode-ai/schema/worktree" import { Bus } from "@opencode-ai/core/bus" import { Database } from "@opencode-ai/core/database/database" import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" import { Location } from "@opencode-ai/core/location" +import { LocationServiceMap } from "@opencode-ai/core/location-service-map" +import type { LocationServices } from "@opencode-ai/core/location-services" import { Project } from "@opencode-ai/core/project" import { AbsolutePath } from "@opencode-ai/core/schema" import { Session } from "@opencode-ai/core/session" @@ -28,8 +30,47 @@ const it = testEffect( ], ), ) +const unavailableLocations = Layer.effect( + LocationServiceMap.Service, + LayerMap.make( + () => Layer.effectDiscard(Effect.fail(new Error("broken location"))) as unknown as Layer.Layer, + ), +) +const itWithUnavailableDestination = testEffect( + AppNodeBuilder.build( + LayerNode.group([Database.node, Bus.node, SessionProjector.node, SessionStore.node, Session.node]), + [ + [Project.node, globalProjectLayer], + [SessionExecution.node, SessionExecution.noopLayer], + [LocationServiceMap.node, unavailableLocations], + ], + ), +) describe("Session.move", () => { + itWithUnavailableDestination.effect("rejects an unavailable destination before admitting the move", () => + Effect.acquireRelease( + Effect.promise(() => tmpdir()), + (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()), + ).pipe( + Effect.flatMap((tmp) => + Effect.gen(function* () { + const session = yield* Session.Service + const source = AbsolutePath.make(path.join(tmp.path, "source")) + const destination = AbsolutePath.make(path.join(tmp.path, "destination")) + yield* Effect.promise(() => Promise.all([mkdir(source), mkdir(destination)])) + const created = yield* session.create({ location: Location.Ref.make({ directory: source }) }) + + const error = yield* session.move({ sessionID: created.id, directory: destination }).pipe(Effect.flip) + + expect(error).toEqual(new Session.DestinationUnavailableError({ directory: destination })) + expect((yield* session.get(created.id)).location.directory).toBe(source) + expect(yield* session.inbox(created.id)).toEqual([]) + }), + ), + ), + ) + it.effect("applies a move immediately when the source directory no longer exists", () => Effect.acquireRelease( Effect.promise(() => tmpdir()), diff --git a/packages/server/src/handlers/session.ts b/packages/server/src/handlers/session.ts index d4a82e1288..506702ff26 100644 --- a/packages/server/src/handlers/session.ts +++ b/packages/server/src/handlers/session.ts @@ -240,6 +240,9 @@ export const SessionHandler = HttpApiBuilder.group(Api, "server.session", (handl Effect.catchTag("Session.DestinationNotDirectoryError", (error) => Effect.fail(new InvalidRequestError({ message: `Not a directory: ${error.directory}` })), ), + Effect.catchTag("Session.DestinationUnavailableError", (error) => + Effect.fail(new InvalidRequestError({ message: `Directory is unavailable: ${error.directory}` })), + ), ) return HttpApiSchema.NoContent.make() }),