From 0d703d39e7cc1b4d442f763d5baef643b5953da8 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Wed, 12 Aug 2026 16:47:55 -0400 Subject: [PATCH] fix(core): adopt pre-project sessions on directory resolution (#42100) --- .../src/context/global-sync/event-reducer.ts | 13 ++++ packages/app/src/context/server-session.ts | 12 ++++ .../client/src/promise/generated/types.ts | 11 +++ packages/core/src/bus.ts | 5 +- packages/core/src/project.ts | 52 +++++++++----- packages/core/src/session/projector.ts | 47 +++++++++++- packages/core/test/session-create.test.ts | 71 +++++++++++++++++++ packages/core/test/session-move.test.ts | 39 ++++++++++ packages/schema/src/durable-event-manifest.ts | 3 +- packages/schema/src/project-directories.ts | 47 +++++++++++- packages/schema/test/event-manifest.test.ts | 1 + packages/tui/src/context/data.tsx | 13 ++++ 12 files changed, 290 insertions(+), 24 deletions(-) diff --git a/packages/app/src/context/global-sync/event-reducer.ts b/packages/app/src/context/global-sync/event-reducer.ts index 771d3607f00..c8437a78dcf 100644 --- a/packages/app/src/context/global-sync/event-reducer.ts +++ b/packages/app/src/context/global-sync/event-reducer.ts @@ -1,4 +1,5 @@ import { Binary } from "@opencode-ai/core/util/binary" +import { ProjectDirectories } from "@opencode-ai/schema/project-directories" import { produce, reconcile, type SetStoreFunction, type Store } from "solid-js/store" import type { Message, Part, Project, Todo } from "@/types" import type { @@ -187,6 +188,18 @@ export function applyDirectoryEvent(input: { input.setStore("sessionTotal", (value) => Math.max(0, value - 1)) break } + case "project.directory.resolved": { + const properties = event.properties as { projectID: string; directory: string; previous: string } + input.store.session.forEach((session, index) => { + const adopted = ProjectDirectories.adopt( + { projectID: session.projectID, directory: session.location.directory }, + properties, + ) + if (!adopted) return + input.setStore("session", index, (current) => ({ ...current, ...adopted })) + }) + break + } case "session.renamed": { const properties = event.properties as { sessionID: string; title: string } const result = Binary.search(input.store.session, properties.sessionID, (session) => session.id) diff --git a/packages/app/src/context/server-session.ts b/packages/app/src/context/server-session.ts index 903cd4ee628..5f23ca39e6c 100644 --- a/packages/app/src/context/server-session.ts +++ b/packages/app/src/context/server-session.ts @@ -1,4 +1,5 @@ import { Binary } from "@opencode-ai/core/util/binary" +import { ProjectDirectories } from "@opencode-ai/schema/project-directories" import { retry } from "@opencode-ai/core/util/retry" import type { OpenCodeEvent, SessionApi, SessionInfo, SessionMessageInfo } from "@opencode-ai/client/promise" import type { Message, Part, Todo } from "@/types" @@ -894,6 +895,17 @@ export function createServerSession( } const applyV2 = (event: OpenCodeEvent) => { + if (event.type === "project.directory.resolved") { + Object.values(data.info).forEach((info) => { + if (!info) return + const adopted = ProjectDirectories.adopt( + { projectID: info.projectID, directory: info.location.directory }, + event.data, + ) + if (adopted) remember({ ...info, ...adopted }) + }) + return + } if (!("data" in event) || !("sessionID" in event.data) || typeof event.data.sessionID !== "string") return const sessionID = event.data.sessionID const reduction = v2.reduce(data.session_message[sessionID] ?? [], event) diff --git a/packages/client/src/promise/generated/types.ts b/packages/client/src/promise/generated/types.ts index a3c4d34a0fc..cdc40a123ca 100644 --- a/packages/client/src/promise/generated/types.ts +++ b/packages/client/src/promise/generated/types.ts @@ -837,6 +837,16 @@ export type ProjectDirectoriesUpdated = { data: { projectID: string } } +export type ProjectDirectoryResolved = { + id: string + created: number + metadata?: { [x: string]: any } + type: "project.directory.resolved" + durable: { aggregateID: string; seq: number; version: 1 } + location?: LocationRef + data: { projectID: string; directory: string; previous: string } +} + export type CommandUpdated = { id: string created: number @@ -2093,6 +2103,7 @@ export type V2Event = | PluginAdded | PluginUpdated | ProjectDirectoriesUpdated + | ProjectDirectoryResolved | CommandUpdated | ConfigUpdated | SkillUpdated diff --git a/packages/core/src/bus.ts b/packages/core/src/bus.ts index c1f2210c93a..91846b2a427 100644 --- a/packages/core/src/bus.ts +++ b/packages/core/src/bus.ts @@ -6,7 +6,7 @@ import type { EventLog } from "@opencode-ai/schema/event-log" import { and, asc, eq, gt, lte, sql } from "drizzle-orm" import { Database } from "./database/database.js" import { EventSequenceTable, EventTable } from "./event/sql.js" -import { Location } from "./location.js" +import type { Location } from "@opencode-ai/schema/location" import { KeyedMutex } from "./effect/keyed-mutex.js" import { makeGlobalNode } from "@opencode-ai/util/effect/app-node" import { isDeepStrictEqual } from "node:util" @@ -183,6 +183,9 @@ export function configured(options?: Options) { layer: Layer.effect( Service, Effect.gen(function* () { + // Deferred import: a static one would close the module cycle + // bus → location → project → bus and hit the node bindings in TDZ. + const { Location } = yield* Effect.promise(() => import("./location.js")) const pubsub = { live: yield* PubSub.unbounded(), durable: new Map>>(), diff --git a/packages/core/src/project.ts b/packages/core/src/project.ts index 4f1e57897d8..755779d11c1 100644 --- a/packages/core/src/project.ts +++ b/packages/core/src/project.ts @@ -5,7 +5,9 @@ import { ChildProcess } from "effect/unstable/process" import { asc, desc } from "drizzle-orm" import path from "path" import { AbsolutePath } from "./schema.js" +import { Bus } from "./bus.js" import { Database } from "./database/database.js" +import { Event } from "@opencode-ai/schema/project-directories" import { FSUtil } from "@opencode-ai/util/fs-util" import { Git } from "./git.js" import { AppProcess } from "@opencode-ai/util/process" @@ -91,28 +93,40 @@ const layer = Layer.effect( const fs = yield* FSUtil.Service const git = yield* Git.Service const proc = yield* AppProcess.Service + const bus = yield* Bus.Service const db = (yield* Database.Service).db const projectDirectories = yield* ProjectDirectories.Service + const announcing = new Set() const persist = Effect.fnUntraced(function* (project: Resolved) { - yield* db - .transaction((tx) => - Effect.gen(function* () { - yield* upsertProject(tx, project) - if (!project.vcs) return - yield* projectDirectories.create({ projectID: project.id, directory: project.canonical }, tx) - if (project.directory === project.canonical) return - yield* projectDirectories.create( - { - projectID: project.id, - directory: project.directory, - strategy: project.vcs.type === "git" ? "git_worktree" : undefined, - }, - tx, - ) - }), - ) - .pipe(Effect.orDie) + yield* upsertProject(db, project).pipe(Effect.orDie) + if (!project.vcs) return project + const directories: ProjectDirectories.CreateInput[] = [{ projectID: project.id, directory: project.canonical }] + if (project.directory !== project.canonical) + directories.push({ + projectID: project.id, + directory: project.directory, + strategy: project.vcs.type === "git" ? "git_worktree" : undefined, + }) + // A missing directory row means this directory's resolution is a new durable + // fact (copy.ts registers copy directories directly; those never strand + // sessions and never announce). The row insert commits atomically with the + // event, so a crash between checks retries on the next resolve instead of + // stranding the announcement. The in-flight set keeps concurrent resolves + // from publishing the same fact twice. + for (const item of directories) { + const key = item.projectID + "\u0000" + item.directory + if (announcing.has(key)) continue + announcing.add(key) + yield* Effect.gen(function* () { + if (yield* projectDirectories.get({ projectID: item.projectID, directory: item.directory })) return + yield* bus.publish( + Event.Resolved, + { projectID: item.projectID, directory: item.directory, previous: project.previous ?? ID.global }, + { commit: () => Effect.asVoid(projectDirectories.create(item)) }, + ) + }).pipe(Effect.ensuring(Effect.sync(() => announcing.delete(key)))) + } return project }) @@ -250,5 +264,5 @@ const layer = Layer.effect( export const node = makeGlobalNode({ service: Service, layer: layer, - deps: [Database.node, FSUtil.node, Git.node, AppProcess.node, ProjectDirectories.node], + deps: [Bus.node, Database.node, FSUtil.node, Git.node, AppProcess.node, ProjectDirectories.node], }) diff --git a/packages/core/src/session/projector.ts b/packages/core/src/session/projector.ts index 55d7fca5b53..1393536df55 100644 --- a/packages/core/src/session/projector.ts +++ b/packages/core/src/session/projector.ts @@ -1,7 +1,8 @@ export * as SessionProjector from "./projector.js" -import { and, asc, desc, eq, gt, gte, lt, lte, sql } from "drizzle-orm" +import { and, asc, desc, eq, gt, gte, inArray, lt, lte, sql } from "drizzle-orm" import { DateTime, Effect, Layer, Schema, Stream } from "effect" +import path from "path" import { Database } from "../database/database.js" import { Bus } from "../bus.js" import { makeGlobalNode } from "@opencode-ai/util/effect/app-node" @@ -15,7 +16,10 @@ import { Workspace } from "../workspace.js" import { InstructionState } from "./instruction-state.js" import { SessionInboxTable, SessionMessageTable, SessionTable } from "./sql.js" import { Slug } from "../util/slug.js" +import { FSUtil } from "@opencode-ai/util/fs-util" import { Money } from "@opencode-ai/schema/money" +import { Event } from "@opencode-ai/schema/project-directories" +import { Project } from "@opencode-ai/schema/project" import { AbsolutePath, RelativePath } from "../schema.js" import type { SessionSchema } from "./schema.js" @@ -435,6 +439,47 @@ const layer = Layer.effectDiscard( yield* InstructionState.reset(db, event.data.sessionID) }), ) + // Sessions whose ownership came from the directory's previous resolution + // follow its new identity. Location, transcript, instructions, and recency + // are untouched: the session did not move, its directory got identified. + yield* bus.project(Event.Resolved, (event) => + Effect.gen(function* () { + const stale = [event.data.previous, Project.ID.global].filter((id) => id !== event.data.projectID) + if (stale.length === 0) return + const rows = yield* db + .select({ id: SessionTable.id, directory: SessionTable.directory }) + .from(SessionTable) + .where( + and( + inArray(SessionTable.project_id, stale), + // Lexicographic range narrows the scan to prefix neighbors without + // LIKE escaping; FSUtil.contains below decides containment exactly. + gte(SessionTable.directory, event.data.directory), + lte(SessionTable.directory, AbsolutePath.make(event.data.directory + "\uffff")), + ), + ) + .all() + .pipe(Effect.orDie) + yield* Effect.forEach( + rows, + (row) => { + if (!FSUtil.contains(event.data.directory, row.directory)) return Effect.void + return db + .update(SessionTable) + .set({ + project_id: event.data.projectID, + path: RelativePath.make(path.relative(event.data.directory, row.directory).replaceAll("\\", "/")), + // Self-assignment suppresses the column's $onUpdate: adoption is not activity. + time_updated: sql`${SessionTable.time_updated}`, + }) + .where(eq(SessionTable.id, row.id)) + .run() + .pipe(Effect.orDie) + }, + { discard: true }, + ) + }), + ) yield* bus.project(SessionEvent.Deleted, (event) => db.delete(SessionTable).where(eq(SessionTable.id, event.data.sessionID)).run().pipe(Effect.orDie), ) diff --git a/packages/core/test/session-create.test.ts b/packages/core/test/session-create.test.ts index 4904efc07ab..57ebf17d0e2 100644 --- a/packages/core/test/session-create.test.ts +++ b/packages/core/test/session-create.test.ts @@ -1,4 +1,6 @@ import { describe, expect } from "bun:test" +import { $ } from "bun" +import fs from "fs/promises" import path from "path" import { DateTime, Effect, Layer, Stream } from "effect" import { Money } from "@opencode-ai/schema/money" @@ -7,6 +9,7 @@ import { asc, eq } from "drizzle-orm" import { Database } from "@opencode-ai/core/database/database" import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" import { LayerNode } from "@opencode-ai/util/effect/layer-node" +import { Hash } from "@opencode-ai/util/hash" import { Bus } from "@opencode-ai/core/bus" import { EventTable } from "@opencode-ai/core/event/sql" import { Location } from "@opencode-ai/core/location" @@ -53,6 +56,15 @@ const it = testEffect( ], ), ) +const liveIt = testEffect( + AppNodeBuilder.build( + LayerNode.group([Database.node, Bus.node, Project.node, SessionProjector.node, SessionStore.node, Session.node]), + [ + [Bus.node, Bus.configured({ persist: true })], + [SessionExecution.node, SessionExecution.noopLayer], + ], + ), +) const location = Location.Ref.make({ directory: AbsolutePath.make("/project") }) const id = Session.ID.create() @@ -78,6 +90,65 @@ function withTmp(f: (directory: string) => Effect.Effect) { } describe("Session.create", () => { + liveIt.live("follows the directory's project identity established after creation", () => + withTmp((directory) => + Effect.gen(function* () { + const session = yield* Session.Service + const projects = yield* Project.Service + const { db } = yield* Database.Service + const ref = Location.Ref.make({ directory: AbsolutePath.make(directory) }) + const nested = Location.Ref.make({ directory: AbsolutePath.make(path.join(directory, "packages", "app")) }) + const created = yield* session.create({ location: ref, title: "Before git" }) + const child = yield* session.create({ location: nested, title: "Nested before git" }) + const originalUpdated = created.time.updated + + yield* Effect.promise(async () => { + await $`git init -q`.cwd(directory) + await $`git config user.email test@example.com`.cwd(directory) + await $`git config user.name Test`.cwd(directory) + await fs.writeFile(path.join(directory, "README.md"), "test\n") + await $`git add README.md`.cwd(directory) + await $`git commit -qm initial`.cwd(directory) + await $`git remote add origin git@github.com:owner/adopted.git`.cwd(directory) + }) + + const project = yield* projects.resolve(ref.directory) + const repeat = yield* projects.resolve(ref.directory) + const adopted = yield* session.get(created.id) + const nestedAdopted = yield* session.get(child.id) + const page = yield* session.list({ project: project.id }) + const log = Array.from(yield* Stream.runCollect(logEvents(session, created.id))) + + expect(created.projectID).toBe(Project.ID.global) + expect(project.id).toBe(Project.ID.make(Hash.fast("git-remote:github.com/owner/adopted"))) + expect(repeat.id).toBe(project.id) + expect(page.data.map((item) => item.id)).toEqual(expect.arrayContaining([created.id, child.id])) + expect(adopted).toMatchObject({ + projectID: project.id, + location: ref, + subpath: undefined, + time: { updated: originalUpdated }, + }) + expect(nestedAdopted).toMatchObject({ + projectID: project.id, + location: nested, + subpath: RelativePath.make("packages/app"), + }) + // Adoption is a project-domain fact; the session log records nothing new. + expect(log.map((event) => event.type)).toEqual(["session.created"]) + expect(yield* session.messages({ sessionID: created.id })).toEqual([]) + // Repeated resolution announces the directory's identity exactly once. + const announced = yield* db + .select({ type: EventTable.type }) + .from(EventTable) + .where(eq(EventTable.aggregate_id, project.id)) + .all() + .pipe(Effect.orDie) + expect(announced.map((event) => event.type)).toEqual(["project.directory.resolved.1"]) + }), + ), + ) + it.effect("persists a missing title until one is generated or supplied", () => Effect.gen(function* () { const session = yield* Session.Service diff --git a/packages/core/test/session-move.test.ts b/packages/core/test/session-move.test.ts index 4dc77547387..fba36496879 100644 --- a/packages/core/test/session-move.test.ts +++ b/packages/core/test/session-move.test.ts @@ -1,6 +1,7 @@ import { describe, expect } from "bun:test" import path from "path" import { Effect, Layer } from "effect" +import { Event } from "@opencode-ai/schema/project-directories" import { Bus } from "@opencode-ai/core/bus" import { Database } from "@opencode-ai/core/database/database" import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" @@ -8,6 +9,7 @@ import { Location } from "@opencode-ai/core/location" import { Project } from "@opencode-ai/core/project" import { AbsolutePath } from "@opencode-ai/core/schema" import { Session } from "@opencode-ai/core/session" +import { SessionEvent } from "@opencode-ai/core/session/event" import { SessionExecution } from "@opencode-ai/core/session/execution" import { SessionProjector } from "@opencode-ai/core/session/projector" import { SessionStore } from "@opencode-ai/core/session/store" @@ -75,4 +77,41 @@ describe("Session.move", () => { ), ), ) + + it.effect("keeps a moved session out of its former directory's new identity", () => + Effect.acquireRelease( + Effect.promise(() => tmpdir()), + (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()), + ).pipe( + Effect.flatMap((tmp) => + Effect.gen(function* () { + const session = yield* Session.Service + const bus = yield* Bus.Service + const previous = AbsolutePath.make(path.join(tmp.path, "previous")) + const destination = AbsolutePath.make(tmp.path) + const created = yield* session.create({ location: Location.Ref.make({ directory: previous }) }) + + // Moves are admitted through the inbox and applied by the drain; + // publish the applied move directly since execution is a no-op here. + yield* bus.publish(SessionEvent.Moved, { + sessionID: created.id, + location: Location.Ref.make({ directory: destination }), + projectID: Project.ID.global, + }) + // The former directory becomes a project after the session left it. + yield* bus.publish(Event.Resolved, { + projectID: Project.ID.make("adopting"), + directory: previous, + previous: Project.ID.global, + }) + + expect(yield* session.get(created.id)).toMatchObject({ + projectID: Project.ID.global, + location: { directory: destination }, + subpath: undefined, + }) + }), + ), + ), + ) }) diff --git a/packages/schema/src/durable-event-manifest.ts b/packages/schema/src/durable-event-manifest.ts index a077b1fa32f..c1b56e14652 100644 --- a/packages/schema/src/durable-event-manifest.ts +++ b/packages/schema/src/durable-event-manifest.ts @@ -1,6 +1,7 @@ export * as DurableEventManifest from "./durable-event-manifest.js" import { Event } from "./event.js" +import { ProjectDirectories } from "./project-directories.js" import { SessionEvent } from "./session-event.js" export const SessionDurable = { @@ -8,4 +9,4 @@ export const SessionDurable = { schema: SessionEvent.Durable, } as const -export const Durable = Event.durableMap(SessionEvent.DurableDefinitions) +export const Durable = Event.durableMap([...SessionEvent.DurableDefinitions, ProjectDirectories.Event.Resolved]) diff --git a/packages/schema/src/project-directories.ts b/packages/schema/src/project-directories.ts index aa5d51f0cfa..bea76edd66b 100644 --- a/packages/schema/src/project-directories.ts +++ b/packages/schema/src/project-directories.ts @@ -1,10 +1,53 @@ export * as ProjectDirectories from "./project-directories.js" -import { ephemeral, inventory } from "./event.js" +import { durable, ephemeral, inventory } from "./event.js" +import { AbsolutePath } from "./schema.js" import { Project } from "./project.js" const Updated = ephemeral({ type: "project.directories.updated", schema: { projectID: Project.ID }, }) -export const Event = { Updated, Definitions: inventory(Updated) } + +/** + * A directory's resolution changed: it now resolves to `projectID` where it + * previously resolved to `previous` (`global` when the directory had no + * stable identity yet, e.g. before `git init`). Sessions whose ownership + * came from the previous resolution follow the new identity by projection. + */ +const Resolved = durable({ + type: "project.directory.resolved", + durable: { aggregate: "projectID", version: 1 }, + schema: { + projectID: Project.ID, + directory: AbsolutePath, + previous: Project.ID, + }, +}) +export const Event = { Updated, Resolved, Definitions: inventory(Updated, Resolved) } + +/** + * Client-side mirror of the server's `project.directory.resolved` session fold. + * Returns the ownership update for a cached session, or undefined when the + * session does not follow the resolution. Plain strings: callers hold + * generated client types, and the server projection remains authoritative. + */ +export function adopt( + session: { readonly projectID: string; readonly directory: string }, + event: { readonly projectID: string; readonly directory: string; readonly previous: string }, +) { + if (session.projectID !== event.previous && session.projectID !== Project.ID.global) return + if (session.projectID === event.projectID) return + const inside = + session.directory === event.directory || + session.directory.startsWith(event.directory + "/") || + session.directory.startsWith(event.directory + "\\") + if (!inside) return + return { + projectID: event.projectID, + subpath: + session.directory === event.directory + ? undefined + : session.directory.slice(event.directory.length + 1).replaceAll("\\", "/"), + } +} diff --git a/packages/schema/test/event-manifest.test.ts b/packages/schema/test/event-manifest.test.ts index 4ab87f1c4e0..a13075df114 100644 --- a/packages/schema/test/event-manifest.test.ts +++ b/packages/schema/test/event-manifest.test.ts @@ -114,6 +114,7 @@ describe("public event manifest", () => { "session.revert.staged.1", "session.revert.cleared.1", "session.revert.committed.1", + "project.directory.resolved.1", ].toSorted(), ) expect(SessionEvent.DurableDefinitions).toEqual([ diff --git a/packages/tui/src/context/data.tsx b/packages/tui/src/context/data.tsx index 7d82a68e319..dc159399300 100644 --- a/packages/tui/src/context/data.tsx +++ b/packages/tui/src/context/data.tsx @@ -39,6 +39,7 @@ import { createSimpleContext } from "./helper" import { useClient } from "./client" import { nonEmptyToolContent } from "../util/tool-display" import type { SessionInbox } from "@opencode-ai/schema/session-inbox" +import { ProjectDirectories } from "@opencode-ai/schema/project-directories" import { createEffect, createSignal, onCleanup } from "solid-js" export type DataSessionStatus = "idle" | "running" @@ -457,6 +458,18 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ } break } + case "project.directory.resolved": { + for (const [sessionID, info] of Object.entries(store.session.info)) { + const adopted = ProjectDirectories.adopt( + { projectID: info.projectID, directory: info.location.directory }, + event.data, + ) + if (!adopted) continue + setStore("session", "info", sessionID, "projectID", adopted.projectID) + setStore("session", "info", sessionID, "subpath", adopted.subpath) + } + break + } case "session.inbox.delivered": { const admitted = store.session.input[event.data.sessionID]?.includes(event.data.inboxID) ?? false removePending(event.data.sessionID, event.data.inboxID)