diff --git a/packages/client/src/promise/generated/types.ts b/packages/client/src/promise/generated/types.ts index 399b6932a19d..cb4b03ae8b13 100644 --- a/packages/client/src/promise/generated/types.ts +++ b/packages/client/src/promise/generated/types.ts @@ -974,7 +974,7 @@ export type WorktreeResolved = { type: "worktree.resolved" durable: { aggregateID: string; seq: number; version: 1 } location?: LocationRef - data: { projectID: string; directory: string; previous: string } + data: { projectID: string; directory: string; previous: string; adopted?: Array } } export type CommandUpdated = { diff --git a/packages/client/src/solid/data.ts b/packages/client/src/solid/data.ts index fff68de95889..53196acc3e55 100644 --- a/packages/client/src/solid/data.ts +++ b/packages/client/src/solid/data.ts @@ -617,7 +617,22 @@ export function createData(config: CreateDataInput) { } case "worktree.resolved": { for (const [sessionID, info] of Object.entries(store.session.info)) { - const adopted = Worktree.adopt({ projectID: info.projectID, directory: info.location.directory }, event.data) + const explicit = event.data.adopted?.includes(info.projectID) + const directory = explicit ? store.project.info[info.projectID]?.canonical : info.location.directory + if (!directory) { + if (info.location.workspaceID) continue + result.session.invalidate(sessionID) + void result.session.sync(sessionID) + continue + } + const adopted = Worktree.adopt( + { + projectID: info.projectID, + directory, + workspaceID: info.location.workspaceID, + }, + event.data, + ) if (!adopted) continue setStore("session", "info", sessionID, "projectID", adopted.projectID) setStore("session", "info", sessionID, "subpath", adopted.subpath) diff --git a/packages/client/test/solid-data.test.ts b/packages/client/test/solid-data.test.ts index 520fcdff4679..36d6b14e8b25 100644 --- a/packages/client/test/solid-data.test.ts +++ b/packages/client/test/solid-data.test.ts @@ -154,6 +154,106 @@ test("updates authoritative cached project metadata from live events", async () } }) +test("adopts cached directory-project sessions when their repository is resolved", async () => { + const listeners = new Set[0]>() + const refreshed: SessionInfo = { + ...session(0), + id: "ses_uncached", + projectID: "repository", + location: { directory: "/unknown-alias" }, + subpath: "app", + } + const api = OpenCode.make({ + baseUrl: "http://opencode.local", + fetch: async (input, init) => { + const request = input instanceof Request ? input : new Request(input, init) + if (!request.url.endsWith("/api/session/ses_uncached")) throw new Error(`Unexpected request: ${request.url}`) + return Response.json({ data: refreshed }) + }, + }) + const setup = createRoot((dispose) => ({ + data: createData({ + api: () => api, + directory: "/repo", + event: { + on: () => () => {}, + listen(handler) { + listeners.add(handler) + return () => listeners.delete(handler) + }, + }, + }), + dispose, + })) + + try { + const sessions: SessionInfo[] = [ + { ...session(0), id: "ses_root", projectID: "directory-root", location: { directory: "/repo" } }, + { ...session(0), id: "ses_nested", projectID: "directory-nested", location: { directory: "/repo/app" } }, + { + ...session(0), + id: "ses_alias", + projectID: "directory-nested", + location: { directory: "/repo/alias/../app" }, + }, + { ...session(0), id: "ses_symlink", projectID: "directory-nested", location: { directory: "/shortcut" } }, + { ...refreshed, projectID: "directory-uncached" }, + { ...session(0), id: "ses_global", projectID: "global", location: { directory: "/repo/legacy" } }, + { ...session(0), id: "ses_escaped", projectID: "global", location: { directory: "/repo/../other" } }, + { ...session(0), id: "ses_other", projectID: "other-repository", location: { directory: "/repo/vendor" } }, + { ...session(0), id: "ses_sibling", projectID: "global", location: { directory: "/repo-other" } }, + { + ...session(0), + id: "ses_remote", + projectID: "directory-root", + location: { directory: "/repo", workspaceID: "workspace-remote" }, + }, + ] + sessions.forEach((item) => setup.data.session.remember(item)) + for (const project of [ + { id: "directory-root", canonical: "/repo" }, + { id: "directory-nested", canonical: "/repo/app" }, + ]) { + const updated: OpenCodeEvent = { + id: `evt_${project.id}`, + created: 0, + type: "project.updated", + data: { ...project, time: { created: 0, updated: 0 }, sandboxes: [] }, + } + listeners.forEach((listener) => listener({ name: updated.type, details: updated })) + } + + const resolved: OpenCodeEvent = { + id: "evt_repository_resolved", + created: 1, + type: "worktree.resolved", + durable: { aggregateID: "repository", seq: 0, version: 1 }, + data: { + projectID: "repository", + directory: "/repo", + previous: "global", + adopted: ["directory-root", "directory-nested", "directory-uncached"], + }, + } + listeners.forEach((listener) => listener({ name: resolved.type, details: resolved })) + + expect(setup.data.session.get("ses_root")?.projectID).toBe("repository") + expect(setup.data.session.get("ses_root")?.subpath).toBeUndefined() + expect(setup.data.session.get("ses_nested")).toMatchObject({ projectID: "repository", subpath: "app" }) + expect(setup.data.session.get("ses_alias")).toMatchObject({ projectID: "repository", subpath: "app" }) + expect(setup.data.session.get("ses_symlink")).toMatchObject({ projectID: "repository", subpath: "app" }) + expect(setup.data.session.get("ses_global")).toMatchObject({ projectID: "repository", subpath: "legacy" }) + expect(setup.data.session.get("ses_escaped")?.projectID).toBe("global") + expect(setup.data.session.get("ses_other")?.projectID).toBe("other-repository") + expect(setup.data.session.get("ses_sibling")?.projectID).toBe("global") + expect(setup.data.session.get("ses_remote")?.projectID).toBe("directory-root") + await wait(() => setup.data.session.get("ses_uncached")?.projectID === "repository") + expect(setup.data.session.get("ses_uncached")?.subpath).toBe("app") + } finally { + setup.dispose() + } +}) + test("refreshes global credential events across every loaded location and workspace", async () => { const listeners = new Set[0]>() const requests: URL[] = [] diff --git a/packages/client/test/worktree.test.ts b/packages/client/test/worktree.test.ts new file mode 100644 index 000000000000..2037af98aa3e --- /dev/null +++ b/packages/client/test/worktree.test.ts @@ -0,0 +1,80 @@ +import { describe, expect, test } from "bun:test" +import { Schema } from "effect" +import { Worktree } from "@opencode-ai/schema/worktree" + +describe("Worktree.adopt", () => { + const event = { + projectID: "repository", + directory: "/repo", + previous: "previous", + adopted: ["directory-root", "directory-nested"], + } + + test("adopts explicitly superseded directory projects", () => { + expect(Worktree.adopt({ projectID: "directory-root", directory: "/repo" }, event)).toEqual({ + projectID: "repository", + subpath: undefined, + }) + expect(Worktree.adopt({ projectID: "directory-nested", directory: "/repo/packages/app" }, event)).toEqual({ + projectID: "repository", + subpath: "packages/app", + }) + }) + + test("preserves existing previous-project and global adoption", () => { + expect(Worktree.adopt({ projectID: "previous", directory: "/repo/packages/app" }, event)).toEqual({ + projectID: "repository", + subpath: "packages/app", + }) + expect(Worktree.adopt({ projectID: "global", directory: "/repo/packages/app" }, event)).toEqual({ + projectID: "repository", + subpath: "packages/app", + }) + }) + + test("leaves unrelated projects, sibling directories, and workspaces unchanged", () => { + expect(Worktree.adopt({ projectID: "other", directory: "/repo/vendor" }, event)).toBeUndefined() + expect(Worktree.adopt({ projectID: "global", directory: "/repo-other" }, event)).toBeUndefined() + expect(Worktree.adopt({ projectID: "repository", directory: "/repo" }, event)).toBeUndefined() + expect( + Worktree.adopt({ projectID: "directory-root", directory: "/repo", workspaceID: "remote" }, event), + ).toBeUndefined() + }) + + test("normalizes Windows directory separators", () => { + expect( + Worktree.adopt( + { projectID: "directory-nested", directory: "C:\\repo\\packages\\app" }, + { ...event, directory: "C:\\repo" }, + ), + ).toEqual({ projectID: "repository", subpath: "packages/app" }) + expect( + Worktree.adopt( + { projectID: "directory-nested", directory: "c:/Repo/packages/App" }, + { ...event, directory: "C:\\repo" }, + ), + ).toEqual({ projectID: "repository", subpath: "packages/App" }) + }) + + test("normalizes aliases and rejects paths that leave the repository", () => { + expect(Worktree.adopt({ projectID: "directory-nested", directory: "/repo/alias/../packages/app" }, event)).toEqual({ + projectID: "repository", + subpath: "packages/app", + }) + expect(Worktree.adopt({ projectID: "global", directory: "/repo/../other" }, event)).toBeUndefined() + expect(Worktree.adopt({ projectID: "global", directory: "/app" }, { ...event, directory: "/" })).toEqual({ + projectID: "repository", + subpath: "app", + }) + }) + + test("decodes existing durable events without adopted project IDs", () => { + expect( + Schema.decodeUnknownSync(Worktree.Event.Resolved.data)({ + projectID: "repository", + directory: "/repo", + previous: "global", + }), + ).toEqual({ projectID: "repository", directory: "/repo", previous: "global" }) + }) +}) diff --git a/packages/core/src/project.ts b/packages/core/src/project.ts index bde093f056ec..d27285550dd1 100644 --- a/packages/core/src/project.ts +++ b/packages/core/src/project.ts @@ -2,7 +2,7 @@ export * as Project from "./project.js" import { Context, Effect, Layer, Schema } from "effect" import { ChildProcess } from "effect/unstable/process" -import { and, asc, desc, eq } from "drizzle-orm" +import { and, asc, desc, eq, gte, isNull, lte } from "drizzle-orm" import path from "path" import { AbsolutePath } from "./schema.js" import { Bus } from "./bus.js" @@ -104,7 +104,12 @@ const layer = Layer.effect( .pipe(Effect.orDie) yield* upsertProject(db, project).pipe(Effect.orDie) if (previous && previous.canonical !== project.canonical) { - const row = yield* db.select().from(ProjectTable).where(eq(ProjectTable.id, project.id)).get().pipe(Effect.orDie) + const row = yield* db + .select() + .from(ProjectTable) + .where(eq(ProjectTable.id, project.id)) + .get() + .pipe(Effect.orDie) if (row) yield* bus.publish(ProjectSchema.Event.Updated, fromRow(row)) } if (!project.vcs) return project @@ -135,9 +140,38 @@ const layer = Layer.effect( .get() .pipe(Effect.orDie) if (stored) return + const directory = AbsolutePath.make(yield* fs.resolve(item.directory)) + const markerless = yield* db + .select({ id: ProjectTable.id, directory: ProjectTable.worktree }) + .from(ProjectTable) + .where( + and( + isNull(ProjectTable.vcs), + gte(ProjectTable.worktree, directory), + lte(ProjectTable.worktree, AbsolutePath.make(directory + "\uffff")), + ), + ) + .all() + .pipe(Effect.orDie) + const adopted = yield* Effect.filter(markerless, (candidate) => + Effect.gen(function* () { + if (candidate.id === item.projectID) return false + if (!FSUtil.contains(directory, candidate.directory)) return false + const markers = yield* fs + .up({ targets: [".git", ".hg"], start: candidate.directory, stop: directory, mode: "first" }) + .pipe(Effect.orElseSucceed(() => [])) + if (!markers[0]) return false + return (yield* fs.resolve(path.dirname(markers[0]))) === directory + }), + ) yield* bus.publish( Worktree.Event.Resolved, - { projectID: item.projectID, directory: item.directory, previous: project.previous ?? ID.global }, + { + projectID: item.projectID, + directory: item.directory, + previous: project.previous ?? ID.global, + ...(adopted.length ? { adopted: adopted.map((candidate) => candidate.id) } : {}), + }, { commit: () => db @@ -256,12 +290,7 @@ const layer = Layer.effect( return node ? ID.make(node) : undefined }) - const hgDiscover = Effect.fnUntraced(function* (input: AbsolutePath) { - const dotHg = yield* fs.up({ targets: [".hg"], start: input, mode: "first" }).pipe( - Effect.map((matches) => matches[0]), - Effect.orElseSucceed(() => undefined), - ) - if (!dotHg) return undefined + const hgDiscover = Effect.fnUntraced(function* (dotHg: AbsolutePath) { const worktree = AbsolutePath.make(path.dirname(dotHg)) const store = AbsolutePath.make(dotHg) const previous = yield* cached(store) @@ -275,7 +304,15 @@ const layer = Layer.effect( }) const resolve = Effect.fn("Project.resolve")(function* (input: AbsolutePath) { - const repo = yield* git.repo.discover(input) + const directory = AbsolutePath.make(yield* fs.resolve(input)) + const marker = yield* fs.up({ targets: [".git", ".hg"], start: directory, mode: "first" }).pipe( + Effect.map((matches) => matches[0]), + Effect.orElseSucceed(() => undefined), + ) + const repo = + marker && path.basename(marker) === ".git" + ? yield* git.repo.discover(AbsolutePath.make(path.dirname(marker))) + : undefined if (repo) { const previous = yield* cached(repo.commonDirectory) const id = (yield* remote(repo)) ?? previous ?? (yield* rootCommit(repo)) @@ -295,10 +332,14 @@ const layer = Layer.effect( }) } - const hg = yield* hgDiscover(input) + const hg = marker && path.basename(marker) === ".hg" ? yield* hgDiscover(AbsolutePath.make(marker)) : undefined if (hg) return yield* persist({ ...hg, canonical: hg.directory }) - const directory = AbsolutePath.make(path.parse(input).root) - return yield* persist({ id: ID.global, directory, canonical: directory, vcs: undefined }) + return yield* persist({ + id: ID.make(Hash.fast(`directory:${directory}`)), + directory, + canonical: directory, + vcs: undefined, + }) }) return Service.of({ list, update, resolve }) diff --git a/packages/core/src/session/projector.ts b/packages/core/src/session/projector.ts index 164db2d508c0..f4be4a8f9c3c 100644 --- a/packages/core/src/session/projector.ts +++ b/packages/core/src/session/projector.ts @@ -1,6 +1,6 @@ export * as SessionProjector from "./projector.js" -import { and, asc, desc, eq, gt, gte, inArray, lt, lte, sql } from "drizzle-orm" +import { and, asc, desc, eq, gt, gte, inArray, isNull, lt, lte, or, sql } from "drizzle-orm" import { DateTime, Effect, Layer, Schema, Stream } from "effect" import path from "path" import { Database } from "../database/database.js" @@ -23,6 +23,7 @@ import { Worktree } from "@opencode-ai/schema/worktree" import { Project } from "@opencode-ai/schema/project" import { AbsolutePath, RelativePath } from "../schema.js" import type { SessionSchema } from "./schema.js" +import { ProjectTable } from "../project/sql.js" type DatabaseService = Database.Interface["db"] type CurrentDurableEvent = Extract @@ -478,18 +479,34 @@ const layer = Layer.effectDiscard( // are untouched: the session did not move, its directory got identified. yield* bus.project(Worktree.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 candidates = [ + ...new Set( + [event.data.previous, Project.ID.global, ...(event.data.adopted ?? [])].filter( + (id) => id !== event.data.projectID, + ), + ), + ] + if (candidates.length === 0) return const rows = yield* db - .select({ id: SessionTable.id, directory: SessionTable.directory }) + .select({ + id: SessionTable.id, + directory: SessionTable.directory, + projectID: SessionTable.project_id, + canonical: ProjectTable.worktree, + }) .from(SessionTable) + .innerJoin(ProjectTable, eq(SessionTable.project_id, ProjectTable.id)) .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")), + inArray(SessionTable.project_id, candidates), + isNull(SessionTable.workspace_id), + or( + event.data.adopted?.length ? inArray(SessionTable.project_id, event.data.adopted) : undefined, + and( + gte(SessionTable.directory, event.data.directory), + lte(SessionTable.directory, AbsolutePath.make(event.data.directory + "\uffff")), + ), + ), ), ) .all() @@ -497,12 +514,15 @@ const layer = Layer.effectDiscard( yield* Effect.forEach( rows, (row) => { - if (!FSUtil.contains(event.data.directory, row.directory)) return Effect.void + const directory = event.data.adopted?.includes(row.projectID) + ? row.canonical + : AbsolutePath.make(path.resolve(row.directory)) + if (!FSUtil.contains(event.data.directory, 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("\\", "/")), + path: RelativePath.make(path.relative(event.data.directory, directory).replaceAll("\\", "/")), // Self-assignment suppresses the column's $onUpdate: adoption is not activity. time_updated: sql`${SessionTable.time_updated}`, }) diff --git a/packages/core/test/project.test.ts b/packages/core/test/project.test.ts index 814d6560400c..0d964a9f61d8 100644 --- a/packages/core/test/project.test.ts +++ b/packages/core/test/project.test.ts @@ -145,24 +145,54 @@ async function rootCommit(dir: string) { } describe("Project.resolve", () => { - it.live("returns global for non-git directory", () => + it.live("creates distinct deterministic projects for exact markerless directories", () => Effect.gen(function* () { const tmp = yield* Effect.acquireRelease( Effect.promise(() => tmpdir()), (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()), ) const project = yield* Project.Service + const nested = path.join(tmp.path, "notes", "drafts") + yield* Effect.promise(() => fs.mkdir(nested, { recursive: true })) const result = yield* project.resolve(abs(tmp.path)) + const repeated = yield* project.resolve(abs(`${tmp.path}${path.sep}.`)) + const child = yield* project.resolve(abs(nested)) - expect(result.id).toBe(Project.ID.make("global")) - expect(path.resolve(result.directory)).toBe(path.parse(tmp.path).root) + expect(result.id).not.toBe(Project.ID.global) + expect(repeated.id).toBe(result.id) + expect(child.id).not.toBe(result.id) + expect(result.directory).toBe(yield* real(tmp.path)) + expect(child.directory).toBe(yield* real(nested)) expect(result.canonical).toBe(result.directory) expect(result.previous).toBeUndefined() expect(result.vcs).toBeUndefined() }), ) + it.live("repository markers override markerless directory projects", () => + Effect.gen(function* () { + const tmp = yield* Effect.acquireRelease( + Effect.promise(() => tmpdir()), + (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()), + ) + const nested = path.join(tmp.path, "packages", "app") + yield* Effect.promise(() => fs.mkdir(nested, { recursive: true })) + const project = yield* Project.Service + const root = yield* project.resolve(abs(tmp.path)) + const child = yield* project.resolve(abs(nested)) + + yield* Effect.promise(() => initRepo(tmp.path, { commit: true })) + const repository = yield* project.resolve(abs(nested)) + + expect(root.id).not.toBe(child.id) + expect(repository.id).not.toBe(root.id) + expect(repository.id).not.toBe(child.id) + expect(repository.directory).toBe(yield* real(tmp.path)) + expect(repository.vcs?.type).toBe("git") + }), + ) + it.live("does not publish project updates for first or repeated resolutions", () => Effect.gen(function* () { const tmp = yield* Effect.acquireRelease( @@ -411,6 +441,48 @@ describe("Project.resolve", () => { }), ) + it.live("prefers the nearest mercurial marker over an outer git repository", () => + Effect.gen(function* () { + const tmp = yield* Effect.acquireRelease( + Effect.promise(() => tmpdir()), + (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()), + ) + const nested = path.join(tmp.path, "nested") + yield* Effect.promise(async () => { + await initRepo(tmp.path, { commit: true }) + await fs.mkdir(path.join(nested, ".hg"), { recursive: true }) + await fs.mkdir(path.join(nested, "app")) + }) + const project = yield* Project.Service + + const result = yield* project.resolve(abs(path.join(nested, "app"))) + + expect(result.vcs?.type).toBe("hg") + expect(result.directory).toBe(yield* real(nested)) + }), + ) + + it.live("prefers the nearest git marker over an outer mercurial repository", () => + Effect.gen(function* () { + const tmp = yield* Effect.acquireRelease( + Effect.promise(() => tmpdir()), + (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()), + ) + const nested = path.join(tmp.path, "nested") + yield* Effect.promise(async () => { + await fs.mkdir(path.join(tmp.path, ".hg")) + await fs.mkdir(path.join(nested, "app"), { recursive: true }) + await initRepo(nested, { commit: true }) + }) + const project = yield* Project.Service + + const result = yield* project.resolve(abs(path.join(nested, "app"))) + + expect(result.vcs?.type).toBe("git") + expect(result.directory).toBe(yield* real(nested)) + }), + ) + it.live("returns global id for unreadable mercurial metadata", () => Effect.gen(function* () { const tmp = yield* Effect.acquireRelease( diff --git a/packages/core/test/session-create.test.ts b/packages/core/test/session-create.test.ts index 576bdd23092e..c02f7ab432fe 100644 --- a/packages/core/test/session-create.test.ts +++ b/packages/core/test/session-create.test.ts @@ -95,14 +95,37 @@ describe("Session.create", () => { 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 aliased = Location.Ref.make({ + directory: AbsolutePath.make([path.join(directory, "alias"), "..", "packages", "app"].join(path.sep)), + }) 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 + const alias = yield* session.create({ location: aliased, title: "Aliased before git" }) + const bus = yield* Bus.Service + const store = yield* SessionStore.Service + yield* session.prompt({ sessionID: created.id, text: "Preserved history", resume: false }) + yield* SessionInbox.promote(db, bus, created.id, "steer") + const pending = yield* session.prompt({ sessionID: created.id, text: "Preserved inbox", resume: false }) + yield* store.claim(created.id) + const before = yield* db + .select() + .from(SessionTable) + .where(eq(SessionTable.id, created.id)) + .get() + .pipe(Effect.orDie) + if (!before) return yield* Effect.die(new Error("created session not found")) 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) + }) + const unborn = yield* projects.resolve(ref.directory) + const unbornRoot = yield* session.get(created.id) + const unbornNested = yield* session.get(child.id) + const unbornAlias = yield* session.get(alias.id) + + yield* Effect.promise(async () => { await fs.writeFile(path.join(directory, "README.md"), "test\n") await $`git add README.md`.cwd(directory) await $`git commit -qm initial`.cwd(directory) @@ -113,10 +136,25 @@ describe("Session.create", () => { const repeat = yield* projects.resolve(ref.directory) const adopted = yield* session.get(created.id) const nestedAdopted = yield* session.get(child.id) + const aliasAdopted = yield* session.get(alias.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(created.projectID).not.toBe(Project.ID.global) + expect(child.projectID).not.toBe(created.projectID) + expect(alias.projectID).toBe(child.projectID) + expect(unborn.id).toBe(Project.ID.global) + expect(unbornRoot).toMatchObject({ projectID: Project.ID.global, subpath: undefined }) + expect(unbornNested).toMatchObject({ projectID: Project.ID.global, subpath: "packages/app" }) + expect(unbornAlias).toMatchObject({ projectID: Project.ID.global, subpath: "packages/app" }) + expect( + yield* db + .select({ data: EventTable.data }) + .from(EventTable) + .where(eq(EventTable.aggregate_id, Project.ID.global)) + .get() + .pipe(Effect.orDie), + ).toMatchObject({ data: { adopted: expect.arrayContaining([created.projectID, child.projectID]) } }) 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])) @@ -124,16 +162,37 @@ describe("Session.create", () => { projectID: project.id, location: ref, subpath: undefined, - time: { updated: originalUpdated }, + time: { updated: DateTime.makeUnsafe(before.time_updated) }, }) expect(nestedAdopted).toMatchObject({ projectID: project.id, location: nested, subpath: RelativePath.make("packages/app"), }) + expect(aliasAdopted).toMatchObject({ + projectID: project.id, + location: aliased, + 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([]) + expect(log.map((event) => event.type)).toEqual([ + "session.created", + "session.inbox.enqueued", + "session.inbox.delivered", + "session.inbox.enqueued", + ]) + expect(yield* session.messages({ sessionID: created.id })).toMatchObject([ + { id: expect.any(String), type: "user", text: "Preserved history" }, + ]) + expect(yield* SessionInbox.find(db, pending.id)).toMatchObject({ payload: { text: "Preserved inbox" } }) + expect( + yield* db.select().from(SessionTable).where(eq(SessionTable.id, created.id)).get().pipe(Effect.orDie), + ).toMatchObject({ + time_created: before.time_created, + time_updated: before.time_updated, + time_suspended: before.time_suspended, + resume_attempts: before.resume_attempts, + }) // Repeated resolution announces the directory's identity exactly once. const announced = yield* db .select({ type: EventTable.type }) @@ -146,6 +205,46 @@ describe("Session.create", () => { ), ) + liveIt.live("does not adopt nested repositories or sessions in another workspace", () => + withTmp((directory) => + Effect.gen(function* () { + const session = yield* Session.Service + const projects = yield* Project.Service + const outer = path.join(directory, "outer") + const nested = path.join(outer, "nested") + const child = AbsolutePath.make(path.join(nested, "app")) + yield* Effect.promise(() => fs.mkdir(child, { recursive: true })) + + const local = yield* session.create({ location: Location.Ref.make({ directory: AbsolutePath.make(outer) }) }) + const inside = yield* session.create({ location: Location.Ref.make({ directory: child }) }) + const remote = yield* session.create({ + location: Location.Ref.make({ + directory: AbsolutePath.make(outer), + workspaceID: Workspace.ID.make("wrk_remote"), + }), + }) + + yield* Effect.promise(async () => { + for (const root of [nested, outer]) { + await $`git init -q`.cwd(root) + await $`git config user.email test@example.com`.cwd(root) + await $`git config user.name Test`.cwd(root) + await $`git commit --allow-empty -qm initial`.cwd(root) + } + }) + + const parent = yield* projects.resolve(AbsolutePath.make(outer)) + expect((yield* session.get(local.id)).projectID).toBe(parent.id) + expect((yield* session.get(inside.id)).projectID).toBe(inside.projectID) + expect((yield* session.get(remote.id)).projectID).toBe(remote.projectID) + + const repository = yield* projects.resolve(AbsolutePath.make(nested)) + expect((yield* session.get(inside.id)).projectID).toBe(repository.id) + expect((yield* session.get(remote.id)).projectID).toBe(remote.projectID) + }), + ), + ) + 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-generate.test.ts b/packages/core/test/session-generate.test.ts index 8313800f127d..63fc89485864 100644 --- a/packages/core/test/session-generate.test.ts +++ b/packages/core/test/session-generate.test.ts @@ -132,6 +132,7 @@ const it = testEffect( LayerNode.group([ Database.node, Bus.node, + Project.node, SessionProjector.node, SessionStore.node, Agent.node, @@ -200,6 +201,7 @@ const setup = Effect.gen(function* () { const { db } = yield* Database.Service const bus = yield* Bus.Service const agents = yield* Agent.Service + const projects = yield* Project.Service const instructionBuiltIns = yield* InstructionBuiltIns.Service yield* agents.transform((draft) => draft.update(Agent.ID.make("build"), (agent) => { @@ -210,7 +212,7 @@ const setup = Effect.gen(function* () { .insert(SessionTable) .values({ id: sessionID, - project_id: Project.ID.global, + project_id: (yield* projects.resolve(AbsolutePath.make("/project"))).id, slug: "generate-test", directory: "/project", title: "Generate test", diff --git a/packages/schema/src/worktree.ts b/packages/schema/src/worktree.ts index 0f203b62a911..66b4c2392c69 100644 --- a/packages/schema/src/worktree.ts +++ b/packages/schema/src/worktree.ts @@ -57,27 +57,46 @@ const Resolved = durable({ projectID: Project.ID, directory: AbsolutePath, previous: Project.ID, + adopted: optional(Schema.Array(Project.ID)), }, }) export const Event = { Updated, Resolved, Definitions: inventory(Updated, Resolved) } export function adopt( - session: { readonly projectID: string; readonly directory: string }, - event: { readonly projectID: string; readonly directory: string; readonly previous: string }, + session: { readonly projectID: string; readonly directory: string; readonly workspaceID?: string }, + event: { + readonly projectID: string + readonly directory: string + readonly previous: string + readonly adopted?: ReadonlyArray + }, ) { - if (session.projectID !== event.previous && session.projectID !== Project.ID.global) return + if (session.workspaceID) return + if ( + session.projectID !== event.previous && + session.projectID !== Project.ID.global && + !event.adopted?.includes(session.projectID) + ) + 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 + const normalize = (value: string) => + value + .replaceAll("\\", "/") + .split("/") + .reduce((result, segment) => { + if (!segment || segment === ".") return result + if (segment === "..") return result.slice(0, result.lastIndexOf("/")) + return `${result}/${segment}` + }, "") + const directory = normalize(session.directory) + const root = normalize(event.directory) + const windows = /^\/[a-z]:/i.test(root) + const key = windows ? directory.toLowerCase() : directory + const parent = windows ? root.toLowerCase() : root + if (key !== parent && !key.startsWith(parent + "/")) return return { projectID: event.projectID, - subpath: - session.directory === event.directory - ? undefined - : session.directory.slice(event.directory.length + 1).replaceAll("\\", "/"), + subpath: key === parent ? undefined : directory.slice(root.length + 1), } }