mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-09 10:59:49 -04:00
Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 6f4934ef43 |
@@ -14,6 +14,7 @@ import { SessionSchema } from "./schema"
|
|||||||
import { SessionContextEpochTable } from "./sql"
|
import { SessionContextEpochTable } from "./sql"
|
||||||
|
|
||||||
type DatabaseService = Database.Interface["db"]
|
type DatabaseService = Database.Interface["db"]
|
||||||
|
type Transaction = Parameters<Parameters<DatabaseService["transaction"]>[0]>[0]
|
||||||
|
|
||||||
interface Prepared {
|
interface Prepared {
|
||||||
readonly baseline: string
|
readonly baseline: string
|
||||||
@@ -109,7 +110,7 @@ const find = Effect.fn("SessionContextEpoch.find")(function* (db: DatabaseServic
|
|||||||
})
|
})
|
||||||
|
|
||||||
export const reset = Effect.fn("SessionContextEpoch.reset")(function* (
|
export const reset = Effect.fn("SessionContextEpoch.reset")(function* (
|
||||||
db: DatabaseService,
|
db: DatabaseService | Transaction,
|
||||||
sessionID: SessionSchema.ID,
|
sessionID: SessionSchema.ID,
|
||||||
) {
|
) {
|
||||||
yield* db
|
yield* db
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { Database } from "@opencode-ai/core/database/database"
|
|||||||
import { ProjectDirectoryTable, ProjectTable } from "@opencode-ai/core/project/sql"
|
import { ProjectDirectoryTable, ProjectTable } from "@opencode-ai/core/project/sql"
|
||||||
import { ProjectDirectories } from "@opencode-ai/core/project/directories"
|
import { ProjectDirectories } from "@opencode-ai/core/project/directories"
|
||||||
import { SessionTable } from "@opencode-ai/core/session/sql"
|
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 { WorkspaceTable } from "@opencode-ai/core/control-plane/workspace.sql"
|
||||||
import { Flag } from "@opencode-ai/core/flag/flag"
|
import { Flag } from "@opencode-ai/core/flag/flag"
|
||||||
import { GlobalBus } from "@/bus/global"
|
import { GlobalBus } from "@/bus/global"
|
||||||
@@ -22,6 +23,7 @@ import { RuntimeFlags } from "@/effect/runtime-flags"
|
|||||||
import { EventV2Bridge } from "@/event-v2-bridge"
|
import { EventV2Bridge } from "@/event-v2-bridge"
|
||||||
import { EventV2 } from "@opencode-ai/core/event"
|
import { EventV2 } from "@opencode-ai/core/event"
|
||||||
import { Project } from "@opencode-ai/schema/project"
|
import { Project } from "@opencode-ai/schema/project"
|
||||||
|
import path from "path"
|
||||||
|
|
||||||
export const Info = Project.Info
|
export const Info = Project.Info
|
||||||
export type Info = Types.DeepMutable<Schema.Schema.Type<typeof Info>>
|
export type Info = Types.DeepMutable<Schema.Schema.Type<typeof Info>>
|
||||||
@@ -192,6 +194,70 @@ export const layer = Layer.effect(
|
|||||||
.pipe(Effect.orDie)
|
.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: {
|
const saveProjectDirectory = Effect.fn("Project.saveProjectDirectory")(function* (input: {
|
||||||
projectID: ProjectV2.ID
|
projectID: ProjectV2.ID
|
||||||
directory: string
|
directory: string
|
||||||
@@ -220,7 +286,7 @@ export const layer = Layer.effect(
|
|||||||
const projectID = ProjectV2.ID.make(data.id)
|
const projectID = ProjectV2.ID.make(data.id)
|
||||||
yield* migrateProjectId(data.previous ? ProjectV2.ID.make(data.previous) : undefined, projectID)
|
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 row = yield* db.select().from(ProjectTable).where(eq(ProjectTable.id, projectID)).get().pipe(Effect.orDie)
|
||||||
const existing = row
|
const persisted = row
|
||||||
? fromRow(row)
|
? fromRow(row)
|
||||||
: {
|
: {
|
||||||
id: projectID,
|
id: projectID,
|
||||||
@@ -229,6 +295,22 @@ export const layer = Layer.effect(
|
|||||||
sandboxes: [] as string[],
|
sandboxes: [] as string[],
|
||||||
time: { created: Date.now(), updated: Date.now() },
|
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))
|
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 { tmpdirScoped } from "../fixture/fixture"
|
||||||
import { GlobalBus } from "../../src/bus/global"
|
import { GlobalBus } from "../../src/bus/global"
|
||||||
import { Database } from "@opencode-ai/core/database/database"
|
import { Database } from "@opencode-ai/core/database/database"
|
||||||
import { ProjectTable } from "@opencode-ai/core/project/sql"
|
import { ProjectDirectoryTable, ProjectTable } from "@opencode-ai/core/project/sql"
|
||||||
import { SessionTable } from "@opencode-ai/core/session/sql"
|
import { SessionContextEpochTable, SessionTable } from "@opencode-ai/core/session/sql"
|
||||||
import { WorkspaceTable } from "@opencode-ai/core/control-plane/workspace.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 { Hash } from "@opencode-ai/core/util/hash"
|
||||||
import { SessionID } from "@/session/schema"
|
import { SessionID } from "@/session/schema"
|
||||||
import { WorkspaceV2 } from "@opencode-ai/core/workspace"
|
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 { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
|
||||||
import { testEffect } from "../lib/effect"
|
import { testEffect } from "../lib/effect"
|
||||||
import { RuntimeFlags } from "@/effect/runtime-flags"
|
import { RuntimeFlags } from "@/effect/runtime-flags"
|
||||||
|
import { AbsolutePath } from "@opencode-ai/core/schema"
|
||||||
|
|
||||||
const encoder = new TextEncoder()
|
const encoder = new TextEncoder()
|
||||||
|
|
||||||
@@ -366,6 +367,8 @@ describe("Project.fromDirectory with worktrees", () => {
|
|||||||
const next = yield* project.fromDirectory(clone)
|
const next = yield* project.fromDirectory(clone)
|
||||||
|
|
||||||
expect(next.project.id).toBe(result.project.id)
|
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)
|
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", () => {
|
describe("Project.discover", () => {
|
||||||
|
|||||||
Reference in New Issue
Block a user