Skip to content
Open
Show file tree
Hide file tree
Changes from 2 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/core/src/instance.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -80,6 +81,7 @@ const nodes = [
WorktreeStrategies.node,
FileSystemSearch.node,
FileSystem.node,
LocationProject.node,
ShellSelect.node,
Pty.node,
Shell.node,
Expand Down
1 change: 0 additions & 1 deletion packages/core/src/location-lifecycle.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
23 changes: 23 additions & 0 deletions packages/core/src/location-project.ts
Original file line number Diff line number Diff line change
@@ -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],
})
3 changes: 2 additions & 1 deletion packages/core/src/location.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
44 changes: 44 additions & 0 deletions packages/server/test/fetch.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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",
Expand All @@ -61,6 +77,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(
Expand All @@ -73,6 +90,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 }))
}),
)

type Handler = (request: Request) => Promise<Response>

function occupy(port: number, cancel = false) {
Expand Down
32 changes: 32 additions & 0 deletions packages/server/test/workerd.test.ts
Original file line number Diff line number Diff line change
@@ -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"
Expand Down Expand Up @@ -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)
}),
)
Loading