Compare commits

...

1 Commits

Author SHA1 Message Date
Luke Parker 6f4934ef43 fix(core): relocate moved project checkouts
Co-authored-by: opencode-agent[bot] <opencode-agent[bot]@users.noreply.github.com>
2026-06-27 02:57:41 +00:00
3 changed files with 174 additions and 5 deletions
+2 -1
View File
@@ -14,6 +14,7 @@ import { SessionSchema } from "./schema"
import { SessionContextEpochTable } from "./sql"
type DatabaseService = Database.Interface["db"]
type Transaction = Parameters<Parameters<DatabaseService["transaction"]>[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
+83 -1
View File
@@ -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<Schema.Schema.Type<typeof Info>>
@@ -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))
+89 -3
View File
@@ -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", () => {