diff --git a/packages/core/src/instance.ts b/packages/core/src/instance.ts index 6368c95a64c1..e51a6fefc645 100644 --- a/packages/core/src/instance.ts +++ b/packages/core/src/instance.ts @@ -21,6 +21,7 @@ import { LocationWatcher } from "./filesystem/location-watcher.js" import { Integration } from "./integration.js" import { Location } from "./location.js" import { LocationLifecycle } from "./location-lifecycle.js" +import { LocationProject } from "./location-project.js" import { FileAccess } from "./file-access.js" import { ModelResolver } from "./model-resolver.js" import { Mcp } from "./mcp/index.js" @@ -79,6 +80,7 @@ const nodes = [ WorktreeStrategies.node, FileSystemSearch.node, FileSystem.node, + LocationProject.node, ShellSelect.node, Pty.node, Shell.node, diff --git a/packages/core/src/location-lifecycle.ts b/packages/core/src/location-lifecycle.ts index b9b825ecdb35..051a1b03d557 100644 --- a/packages/core/src/location-lifecycle.ts +++ b/packages/core/src/location-lifecycle.ts @@ -29,7 +29,6 @@ const layer = Layer.effect( const rpc = yield* Rpc.Service const project = yield* Project.Service const scope = yield* Effect.scope - yield* project.activate(location.project.id) const unsubscribe = yield* bus.listen((event) => { if (!isSessionEvent(event) || !event.location) return Effect.void const ref = LocationServiceMap.canonical(event.location) diff --git a/packages/core/src/location-project.ts b/packages/core/src/location-project.ts new file mode 100644 index 000000000000..9ebfec7cd8fb --- /dev/null +++ b/packages/core/src/location-project.ts @@ -0,0 +1,23 @@ +export * as LocationProject from "./location-project.js" + +import { Effect, Layer } from "effect" +import { makeLocationNode } from "@opencode/util/effect/app-node" +import { FileSystem } from "./filesystem.js" +import { Location } from "./location.js" +import { Project } from "./project.js" + +// Location resolves its Project without saving it. Saving it and recording activity waits for FileSystem to accept +// the directory, so requests for a folder that no longer exists leave the Project list alone. Profiles whose +// FileSystem does not check the host, like workerd, still save the Project. +export const node = makeLocationNode({ + name: "location-project", + layer: Layer.effectDiscard( + Effect.gen(function* () { + const location = yield* Location.Service + const project = yield* Project.Service + yield* project.resolve(location.directory) + yield* project.activate(location.project.id) + }), + ), + deps: [Location.node, Project.node, FileSystem.node], +}) diff --git a/packages/core/src/location.ts b/packages/core/src/location.ts index ede1ba239d9c..bba23f9301f9 100644 --- a/packages/core/src/location.ts +++ b/packages/core/src/location.ts @@ -21,7 +21,8 @@ const layer = (ref: Ref) => Service, Effect.gen(function* () { const project = yield* Project.Service - const resolved = yield* project.resolve(ref.directory) + // LocationProject saves the Project once FileSystem has accepted the directory. + const resolved = yield* project.resolve(ref.directory, { persist: false }) return Service.of({ directory: ref.directory, workspaceID: ref.workspaceID, diff --git a/packages/server/test/fetch.test.ts b/packages/server/test/fetch.test.ts index 35307b96532c..822b48bd8a58 100644 --- a/packages/server/test/fetch.test.ts +++ b/packages/server/test/fetch.test.ts @@ -6,6 +6,7 @@ import { Agent } from "@opencode/schema/agent" import { Integration } from "@opencode/schema/integration" import { ServerInfo } from "@opencode/protocol/groups/server" import { Effect, Schedule, Schema } from "effect" +import { Project } from "@opencode/schema/project" import { Session } from "@opencode/schema/session" import { tmpdir } from "../../core/test/fixture/tmpdir" import { it } from "../../core/test/lib/effect" @@ -38,6 +39,21 @@ it.live("returns LocationNotFoundError for a missing folder and recovers once it const session = Schema.decodeUnknownSync(Schema.Struct({ data: Session.Info }))( yield* Effect.promise(() => created.json()), ).data + // Failed boots must leave the Project that session creation saved alone: recorded activity would keep the + // hourly sweep from archiving it while the folder is gone. + const activity = () => + Effect.promise(() => + handler(new Request("http://opencode.local/api/project")).then((response) => response.json()), + ).pipe( + Effect.map( + (body) => + Schema.decodeUnknownSync(Schema.Array(Project.Info))(body).find( + (project) => project.canonical === directory, + )?.time.active, + ), + ) + const active = yield* activity() + expect(active).toBeGreaterThan(0) const endpoints = [ "/api/model", "/api/integration", @@ -60,6 +76,7 @@ it.live("returns LocationNotFoundError for a missing folder and recovers once it message: `Location not found: ${directory}`, }) } + expect(yield* activity()).toBe(active) yield* Effect.promise(() => fs.mkdir(directory)) const recovered = yield* Effect.promise(() => handler( @@ -72,6 +89,33 @@ it.live("returns LocationNotFoundError for a missing folder and recovers once it }), ) +it.live("does not save a project for a missing folder", () => + Effect.gen(function* () { + const config = yield* Effect.acquireDisposable(Effect.promise(() => tmpdir("opencode-missing-project-"))) + const directory = path.join(config.path, "deleted") + const handler = yield* ServerFetch.make({ ...options, config: { directory: config.path } }) + const status = () => + Effect.promise(() => + handler( + new Request("http://opencode.local/api/vcs/status", { + headers: { "x-opencode-directory": encodeURIComponent(directory) }, + }), + ), + ).pipe(Effect.map((response) => response.status)) + const projects = () => + Effect.promise(() => + handler(new Request("http://opencode.local/api/project")).then((response) => response.json()), + ) + + expect(yield* status()).toBe(404) + expect(yield* projects()).not.toContainEqual(expect.objectContaining({ canonical: directory })) + + yield* Effect.promise(() => fs.mkdir(directory)) + expect(yield* status()).toBe(200) + expect(yield* projects()).toContainEqual(expect.objectContaining({ canonical: directory })) + }), +) + it.live("manages a session whose directory was deleted", () => Effect.gen(function* () { const config = yield* Effect.acquireDisposable(Effect.promise(() => tmpdir("opencode-deleted-directory-"))) diff --git a/packages/server/test/workerd.test.ts b/packages/server/test/workerd.test.ts index ed2099473c74..89acc8275dc2 100644 --- a/packages/server/test/workerd.test.ts +++ b/packages/server/test/workerd.test.ts @@ -1,4 +1,6 @@ import { expect } from "bun:test" +import os from "node:os" +import path from "node:path" import { Effect } from "effect" import { makeDurableObjectStorage } from "../../core/test/fixture/durable-object-storage" import { it } from "../../core/test/lib/effect" @@ -31,3 +33,33 @@ it.live("boots the workerd profile over durable object storage", () => expect(body).toMatchObject({ version: "workerd-test" }) }), ) + +// The profile has no host filesystem to check, so a location whose directory is not on this host still +// records its project. +it.live("records the project of a location whose directory is not on the host", () => + Effect.gen(function* () { + const handler = yield* ServerWorkerd.create({ + storage: makeDurableObjectStorage(), + app: { version: "workerd-test" }, + config: { content: "{}" }, + models: { fetch: false }, + }) + const directory = path.join(os.tmpdir(), `opencode-workerd-${crypto.randomUUID()}`, "project") + + const location = yield* Effect.promise(() => + handler(new Request(`http://opencode.local/api/location?location[directory]=${encodeURIComponent(directory)}`)), + ) + expect(location.status).toBe(200) + const { project } = yield* Effect.promise(() => location.json()) + const updated = yield* Effect.promise(() => + handler( + new Request(`http://opencode.local/api/project/${project.id}`, { + method: "PATCH", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ name: "Remote" }), + }), + ), + ) + expect(updated.status).toBe(200) + }), +)