diff --git a/packages/core/src/session/context-epoch.ts b/packages/core/src/session/context-epoch.ts index 65b17a86a4fb..361648acc29c 100644 --- a/packages/core/src/session/context-epoch.ts +++ b/packages/core/src/session/context-epoch.ts @@ -14,6 +14,7 @@ import { SessionSchema } from "./schema" import { SessionContextEpochTable } from "./sql" type DatabaseService = Database.Interface["db"] +type Transaction = Parameters[0]>[0] interface Prepared { readonly baseline: string @@ -109,7 +110,7 @@ const find = Effect.fn("SessionContextEpoch.find")(function* (db: DatabaseServic }) export const reset = Effect.fn("SessionContextEpoch.reset")(function* ( - db: DatabaseService, + db: DatabaseService | Transaction, sessionID: SessionSchema.ID, ) { yield* db diff --git a/packages/opencode/src/project/project.ts b/packages/opencode/src/project/project.ts index ab8f1663dd63..c4b82852d675 100644 --- a/packages/opencode/src/project/project.ts +++ b/packages/opencode/src/project/project.ts @@ -4,6 +4,7 @@ import { Database } from "@opencode-ai/core/database/database" import { ProjectDirectoryTable, ProjectTable } from "@opencode-ai/core/project/sql" import { ProjectDirectories } from "@opencode-ai/core/project/directories" import { SessionTable } from "@opencode-ai/core/session/sql" +import { SessionContextEpoch } from "@opencode-ai/core/session/context-epoch" import { WorkspaceTable } from "@opencode-ai/core/control-plane/workspace.sql" import { Flag } from "@opencode-ai/core/flag/flag" import { GlobalBus } from "@/bus/global" @@ -22,6 +23,7 @@ import { RuntimeFlags } from "@/effect/runtime-flags" import { EventV2Bridge } from "@/event-v2-bridge" import { EventV2 } from "@opencode-ai/core/event" import { Project } from "@opencode-ai/schema/project" +import path from "path" export const Info = Project.Info export type Info = Types.DeepMutable> @@ -192,6 +194,70 @@ export const layer = Layer.effect( .pipe(Effect.orDie) }) + // Repair persisted absolute paths after the checkout was moved outside OpenCode. + // This is not a semantic Session move: there is no source checkout or change transfer. + // Keep every affected projection in one transaction so a restart cannot expose mixed paths. + const relocateProject = Effect.fn("Project.relocateProject")(function* (input: { + projectID: ProjectV2.ID + source: string + destination: string + }) { + return yield* db + .transaction( + (d) => + Effect.gen(function* () { + const row = yield* d.select().from(ProjectTable).where(eq(ProjectTable.id, input.projectID)).get() + if (!row) return yield* Effect.die(new Error("Project disappeared during relocation")) + if (row.worktree !== input.source) return fromRow(row) + + const destination = AbsolutePath.make(input.destination) + const sessions = yield* d + .select({ id: SessionTable.id, directory: SessionTable.directory }) + .from(SessionTable) + .where(eq(SessionTable.project_id, input.projectID)) + .all() + const moved = sessions.filter((session) => FSUtil.contains(input.source, session.directory)) + + yield* Effect.forEach( + moved, + (session) => + Effect.gen(function* () { + yield* d + .update(SessionTable) + .set({ directory: path.join(destination, path.relative(input.source, session.directory)) }) + .where(eq(SessionTable.id, session.id)) + .run() + yield* SessionContextEpoch.reset(d, session.id) + }), + { concurrency: 1, discard: true }, + ) + + yield* projectDirectories.create({ projectID: input.projectID, directory: destination }, d) + yield* projectDirectories.remove( + { projectID: input.projectID, directory: AbsolutePath.make(input.source) }, + d, + ) + + const updated = yield* d + .update(ProjectTable) + .set({ + worktree: destination, + sandboxes: row.sandboxes.filter( + (sandbox) => sandbox !== input.source && sandbox !== input.destination, + ), + time_updated: Date.now(), + }) + .where(eq(ProjectTable.id, input.projectID)) + .returning() + .get() + if (!updated) return yield* Effect.die(new Error("Project disappeared during relocation")) + return fromRow(updated) + }), + { behavior: "immediate" }, + ) + .pipe(Effect.orDie) + }) + const saveProjectDirectory = Effect.fn("Project.saveProjectDirectory")(function* (input: { projectID: ProjectV2.ID directory: string @@ -220,7 +286,7 @@ export const layer = Layer.effect( const projectID = ProjectV2.ID.make(data.id) yield* migrateProjectId(data.previous ? ProjectV2.ID.make(data.previous) : undefined, projectID) const row = yield* db.select().from(ProjectTable).where(eq(ProjectTable.id, projectID)).get().pipe(Effect.orDie) - const existing = row + const persisted = row ? fromRow(row) : { id: projectID, @@ -229,6 +295,22 @@ export const layer = Layer.effect( sandboxes: [] as string[], time: { created: Date.now(), updated: Date.now() }, } + const relocationCandidate = row && projectID !== ProjectV2.ID.global && persisted.worktree !== worktree + const primaryMissing = relocationCandidate + ? yield* fs.stat(persisted.worktree).pipe( + Effect.as(false), + Effect.catchReason("PlatformError", "NotFound", () => Effect.succeed(true)), + Effect.orDie, + ) + : false + const existing = + relocationCandidate && primaryMissing + ? yield* relocateProject({ + projectID, + source: persisted.worktree, + destination: worktree, + }) + : persisted if (flags.experimentalIconDiscovery) yield* discover(existing).pipe(Effect.ignore, Effect.forkIn(scope)) diff --git a/packages/opencode/test/project/project.test.ts b/packages/opencode/test/project/project.test.ts index 05e205cd8704..6367d0038bae 100644 --- a/packages/opencode/test/project/project.test.ts +++ b/packages/opencode/test/project/project.test.ts @@ -6,10 +6,10 @@ import path from "path" import { tmpdirScoped } from "../fixture/fixture" import { GlobalBus } from "../../src/bus/global" import { Database } from "@opencode-ai/core/database/database" -import { ProjectTable } from "@opencode-ai/core/project/sql" -import { SessionTable } from "@opencode-ai/core/session/sql" +import { ProjectDirectoryTable, ProjectTable } from "@opencode-ai/core/project/sql" +import { SessionContextEpochTable, SessionTable } from "@opencode-ai/core/session/sql" import { WorkspaceTable } from "@opencode-ai/core/control-plane/workspace.sql" -import { eq } from "drizzle-orm" +import { eq, inArray } from "drizzle-orm" import { Hash } from "@opencode-ai/core/util/hash" import { SessionID } from "@/session/schema" import { WorkspaceV2 } from "@opencode-ai/core/workspace" @@ -23,6 +23,7 @@ import { ProjectDirectories } from "@opencode-ai/core/project/directories" import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" import { testEffect } from "../lib/effect" import { RuntimeFlags } from "@/effect/runtime-flags" +import { AbsolutePath } from "@opencode-ai/core/schema" const encoder = new TextEncoder() @@ -366,6 +367,8 @@ describe("Project.fromDirectory with worktrees", () => { const next = yield* project.fromDirectory(clone) expect(next.project.id).toBe(result.project.id) + expect(next.project.worktree).toBe(tmp) + expect(next.project.sandboxes).toContain(clone) }), ) @@ -403,6 +406,89 @@ describe("Project.fromDirectory with worktrees", () => { expect(result.project.sandboxes).not.toContain(tmp) }), ) + + it.live("relocates a missing primary checkout and its sessions", () => + Effect.gen(function* () { + const { db } = yield* Database.Service + const project = yield* Project.Service + const tmp = yield* tmpdirScoped({ git: true }) + const original = yield* project.fromDirectory(tmp) + const moved = `${tmp}-moved` + const rootSession = SessionID.make(`ses_${crypto.randomUUID()}`) + const nestedSession = SessionID.make(`ses_${crypto.randomUUID()}`) + yield* Effect.addFinalizer(() => Effect.promise(() => $`rm -rf ${moved}`.quiet().nothrow()).pipe(Effect.ignore)) + yield* db + .insert(SessionTable) + .values([ + { + id: rootSession, + project_id: original.project.id, + slug: rootSession, + directory: tmp, + title: "root", + version: "test", + time_created: 1, + time_updated: 1, + }, + { + id: nestedSession, + project_id: original.project.id, + slug: nestedSession, + directory: path.join(tmp, "packages", "app"), + path: "packages/app", + title: "nested", + version: "test", + time_created: 2, + time_updated: 2, + }, + ]) + .run() + .pipe(Effect.orDie) + yield* db + .insert(SessionContextEpochTable) + .values([ + { session_id: rootSession, baseline: "root", snapshot: {}, baseline_seq: 0 }, + { session_id: nestedSession, baseline: "nested", snapshot: {}, baseline_seq: 0 }, + ]) + .run() + .pipe(Effect.orDie) + yield* Effect.promise(() => $`mv ${tmp} ${moved}`.quiet()) + + const [result, concurrent] = yield* Effect.all([project.fromDirectory(moved), project.fromDirectory(moved)], { + concurrency: "unbounded", + }) + const sessions = yield* db + .select({ id: SessionTable.id, directory: SessionTable.directory, path: SessionTable.path }) + .from(SessionTable) + .where(eq(SessionTable.project_id, original.project.id)) + .all() + .pipe(Effect.orDie) + const directories = yield* db + .select({ directory: ProjectDirectoryTable.directory }) + .from(ProjectDirectoryTable) + .where(eq(ProjectDirectoryTable.project_id, original.project.id)) + .all() + .pipe(Effect.orDie) + const epochs = yield* db + .select({ sessionID: SessionContextEpochTable.session_id }) + .from(SessionContextEpochTable) + .where(inArray(SessionContextEpochTable.session_id, [rootSession, nestedSession])) + .all() + .pipe(Effect.orDie) + + expect(result.project.worktree).toBe(moved) + expect(concurrent.project.worktree).toBe(moved) + expect(result.project.sandboxes).toEqual([]) + expect(sessions).toContainEqual({ id: rootSession, directory: moved, path: null }) + expect(sessions).toContainEqual({ + id: nestedSession, + directory: path.join(moved, "packages", "app"), + path: "packages/app", + }) + expect(directories).toEqual([{ directory: AbsolutePath.make(moved) }]) + expect(epochs).toEqual([]) + }), + ) }) describe("Project.discover", () => {