fix(core): relocate moved project checkouts
Co-authored-by: opencode-agent[bot] <opencode-agent[bot]@users.noreply.github.com>
This commit is contained in:
parent
71c3a7c8f2
commit
6f4934ef43
3 changed files with 174 additions and 5 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
||||
|
|
|
|||
|
|
@ -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", () => {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue