diff --git a/packages/core/src/location.ts b/packages/core/src/location.ts index ede1ba239d9..10ae6045ad8 100644 --- a/packages/core/src/location.ts +++ b/packages/core/src/location.ts @@ -1,5 +1,6 @@ import { Context, Effect, Layer } from "effect" import { Info, Ref, response } from "@opencode/schema/location" +import { FSUtil } from "@opencode/util/fs-util" import { Project } from "./project.js" import { LayerNode } from "@opencode/util/effect/layer-node" import { makeLocationNode, tags } from "@opencode/util/effect/app-node" @@ -20,8 +21,12 @@ const layer = (ref: Ref) => Layer.effect( Service, Effect.gen(function* () { + const fs = yield* FSUtil.Service const project = yield* Project.Service - const resolved = yield* project.resolve(ref.directory) + // Location.node builds alongside FileSystem.node, so avoid creating or unarchiving + // a Project row for a local path that FileSystem.node is about to reject. + const persist = ref.workspaceID !== undefined || (yield* fs.isDir(ref.directory)) + const resolved = yield* project.resolve(ref.directory, { persist }) return Service.of({ directory: ref.directory, workspaceID: ref.workspaceID, @@ -35,5 +40,5 @@ export const boundNode = (ref: Ref) => makeLocationNode({ service: Service, layer: layer(ref), - deps: [Project.node], + deps: [FSUtil.node, Project.node], }) diff --git a/packages/core/src/project/icon.ts b/packages/core/src/project/icon.ts index e9208aac754..3a1a5ca1d89 100644 --- a/packages/core/src/project/icon.ts +++ b/packages/core/src/project/icon.ts @@ -55,7 +55,9 @@ export const refresh = Effect.fn("ProjectIcon.refresh")(function* ( }) export const discover = Effect.fn("ProjectIcon.discover")(function* (fs: FSUtil.Interface, directory: string) { - const root = yield* fs.realPath(directory) + const resolved = yield* fs.realPath(directory).pipe(Effect.orElseSucceed(() => undefined)) + if (!resolved) return + const root = resolved const children = yield* fs.readDirectoryEntries(root).pipe(Effect.orElseSucceed(() => [])) const catalogs = children .filter((e) => e.type === "Directory" && !e.name.startsWith(".")) diff --git a/packages/core/test/location-missing-project.test.ts b/packages/core/test/location-missing-project.test.ts new file mode 100644 index 00000000000..cc6726f6e4e --- /dev/null +++ b/packages/core/test/location-missing-project.test.ts @@ -0,0 +1,71 @@ +import fs from "fs/promises" +import path from "path" +import { describe, expect } from "bun:test" +import { eq } from "drizzle-orm" +import { Effect, Exit } from "effect" +import { AppNodeBuilder } from "@opencode/core/effect/app-node-builder" +import { LayerNode } from "@opencode/util/effect/layer-node" +import { Global } from "@opencode/util/global" +import { Bus } from "@opencode/core/bus" +import { Database } from "@opencode/core/database/database" +import { Location } from "@opencode/core/location" +import { LocationServiceMap } from "@opencode/core/location-services" +import { Project } from "@opencode/core/project" +import { ProjectTable } from "@opencode/core/project/sql" +import { AbsolutePath } from "@opencode/core/schema" +import { tempGlobalLayer } from "./fixture/global" +import { offlineModels } from "./fixture/models" +import { tmpdirScoped } from "./fixture/tmpdir" +import { testEffect } from "./lib/effect" + +const it = testEffect( + AppNodeBuilder.build(LayerNode.group([Database.node, Bus.node, Project.node, LocationServiceMap.node]), [ + Global.node.replace(tempGlobalLayer), + offlineModels, + ]), +) + +const load = (directory: AbsolutePath) => + Effect.gen(function* () { + const locations = yield* LocationServiceMap.Service + return yield* Location.Service.pipe( + Effect.provide(locations.get(Location.Ref.make({ directory }))), + Effect.scoped, + ) + }) + +describe("Location missing project persistence", () => { + it.live("does not persist a project row when a local directory is missing", () => + Effect.gen(function* () { + const dir = yield* tmpdirScoped() + const missing = AbsolutePath.make(path.join(dir.path, "deleted-worktree")) + const projects = yield* Project.Service + + expect(Exit.isFailure(yield* Effect.exit(load(missing)))).toBe(true) + expect((yield* projects.list({ archived: true })).some((item) => item.canonical === missing)).toBe(false) + + yield* Effect.promise(() => fs.mkdir(missing)) + const resolved = yield* load(missing) + expect(resolved.project.canonical).toBe(missing) + expect((yield* projects.list({ archived: true })).some((item) => item.id === resolved.project.id)).toBe(true) + }), + ) + + it.live("does not unarchive a project when its removed directory is requested", () => + Effect.gen(function* () { + const dir = yield* tmpdirScoped() + const removed = AbsolutePath.make(path.join(dir.path, "removed-project")) + yield* Effect.promise(() => fs.mkdir(removed)) + const db = (yield* Database.Service).db + const projects = yield* Project.Service + const created = yield* projects.resolve(removed) + + yield* Effect.promise(() => fs.rm(removed, { recursive: true, force: true })) + yield* db.update(ProjectTable).set({ time_archived: 1 }).where(eq(ProjectTable.id, created.id)).run() + + expect(Exit.isFailure(yield* Effect.exit(load(removed)))).toBe(true) + const after = (yield* projects.list({ archived: true })).find((item) => item.id === created.id) + expect(after?.time.archived).toBe(1) + }), + ) +}) diff --git a/packages/core/test/project-icon.test.ts b/packages/core/test/project-icon.test.ts index 3a5fce254b8..1c7844e3ec3 100644 --- a/packages/core/test/project-icon.test.ts +++ b/packages/core/test/project-icon.test.ts @@ -1,7 +1,7 @@ import { expect } from "bun:test" import path from "path" import fs from "fs/promises" -import { Effect, Fiber, Stream } from "effect" +import { Effect, Exit, Fiber, Stream, Tracer } from "effect" import { AppNodeBuilder } from "@opencode/core/effect/app-node-builder" import { LayerNode } from "@opencode/util/effect/layer-node" import { Project } from "@opencode/core/project" @@ -166,3 +166,33 @@ it.live("Composer backgrounds stay inert and malformed, oversized or escaping ma expect(yield* ProjectIcon.discover(filesystem, tmp.path)).toBeUndefined() }), ) + +it.live("returns undefined without recording span failures when the directory is missing", () => { + const spans: Tracer.NativeSpan[] = [] + const tracer = Tracer.make({ + span(options) { + const span = new Tracer.NativeSpan(options) + spans.push(span) + return span + }, + }) + return Effect.gen(function* () { + const tmp = yield* tmpdirScoped() + const missing = path.join(tmp.path, "missing") + const filesystem = yield* FSUtil.Service + const db = (yield* Database.Service).db + const id = Project.ID.make("missing-icons") + yield* db + .insert(ProjectTable) + .values({ id, worktree: AbsolutePath.make(missing), sandboxes: [] }) + .run() + + expect(yield* ProjectIcon.refresh(filesystem, db, id)).toBeUndefined() + + const iconSpans = spans.filter((span) => span.name.startsWith("ProjectIcon.")) + expect(iconSpans.map((span) => span.name)).toEqual(["ProjectIcon.refresh", "ProjectIcon.discover"]) + for (const span of iconSpans) { + expect(span.status._tag === "Ended" && Exit.isSuccess(span.status.exit)).toBe(true) + } + }).pipe(Effect.provideService(Tracer.Tracer, tracer)) +})