Compare commits

..
Author SHA1 Message Date
Brendonovich 019da0f2bf fix(app): preserve timeline row heights when moving sessions 2026-08-26 03:22:44 +00:00
13 changed files with 94 additions and 496 deletions
@@ -0,0 +1,10 @@
import type { Virtualizer } from "@tanstack/solid-virtual"
export function remeasureTimeline(
virtualizer: Virtualizer<HTMLDivElement, HTMLDivElement>,
content: HTMLDivElement | undefined,
) {
virtualizer.measure()
virtualizer.getVirtualItems()
content?.querySelectorAll<HTMLDivElement>("[data-index]").forEach((element) => virtualizer.measureElement(element))
}
@@ -18,6 +18,7 @@ import { createStore } from "solid-js/store"
import type { createTimelineProjection } from "./projection"
import { observeElementOffsetReconnectAware } from "./observe-element-offset"
import { filterVirtualIndexes } from "./virtual-items"
import { remeasureTimeline } from "./measurement"
const fallbackItemSize = 60
const pendingMarkdown = '[data-component="markdown"]:not([data-markdown-ready])'
@@ -237,7 +238,7 @@ export function createTimelineVirtualizer(input: Input) {
const key = input.sessionKey()
if (measuredSessionKey !== key) {
measuredSessionKey = key
virtualizer.measure()
remeasureTimeline(virtualizer, virtualContent)
}
})
@@ -2,6 +2,7 @@ import { expect, test } from "bun:test"
import { createVirtualizer, defaultRangeExtractor, Virtualizer } from "@tanstack/solid-virtual"
import { createRoot, createSignal } from "solid-js"
import { filterVirtualIndexes } from "@/session/timeline/virtual-items"
import { remeasureTimeline } from "@/session/timeline/measurement"
test("end anchoring survives consecutive resizes when the first scroll write is clamped", () => {
const writes: { offset: number; adjustments?: number }[] = []
@@ -139,6 +140,40 @@ test("explicit measurement refreshes a cached row size with a custom measurer",
expect(virtualizer.getTotalSize()).toBe(120)
})
test("remeasuring a moved session preserves the actual heights of mounted rows", () => {
const root = document.createElement("div")
const content = document.createElement("div")
root.appendChild(content)
const heights = [120, 180, 240]
heights.forEach((height, index) => {
const element = document.createElement("div")
element.dataset.index = String(index)
Object.defineProperty(element, "offsetHeight", { value: height })
content.appendChild(element)
})
const virtualizer = new Virtualizer<HTMLDivElement, HTMLDivElement>({
count: heights.length,
estimateSize: () => 60,
initialRect: { width: 400, height: 600 },
getScrollElement: () => root,
scrollToFn: () => {},
observeElementRect: () => {},
observeElementOffset: () => {},
measureElement: (element) => element.offsetHeight,
})
virtualizer._willUpdate()
virtualizer.getTotalSize()
content.querySelectorAll<HTMLDivElement>("[data-index]").forEach((element) => virtualizer.measureElement(element))
expect(virtualizer.getTotalSize()).toBe(540)
remeasureTimeline(virtualizer, content)
expect(heights.map((_, index) => virtualizer.itemSizeCache.get(index))).toEqual(heights)
expect(virtualizer.getTotalSize()).toBe(540)
})
test("initial rect projects rows before a scroll element connects", () => {
createRoot((dispose) => {
const virtualizer = createVirtualizer<HTMLDivElement, HTMLDivElement>({
@@ -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; adopted?: Array<string> }
data: { projectID: string; directory: string; previous: string }
}
export type CommandUpdated = {
+1 -16
View File
@@ -617,22 +617,7 @@ export function createData(config: CreateDataInput) {
}
case "worktree.resolved": {
for (const [sessionID, info] of Object.entries(store.session.info)) {
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,
)
const adopted = Worktree.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)
-100
View File
@@ -154,106 +154,6 @@ 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<Parameters<CreateDataInput["event"]["listen"]>[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<Parameters<CreateDataInput["event"]["listen"]>[0]>()
const requests: URL[] = []
-80
View File
@@ -1,80 +0,0 @@
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" })
})
})
+13 -54
View File
@@ -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, gte, isNull, lte } from "drizzle-orm"
import { and, asc, desc, eq } from "drizzle-orm"
import path from "path"
import { AbsolutePath } from "./schema.js"
import { Bus } from "./bus.js"
@@ -104,12 +104,7 @@ 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
@@ -140,38 +135,9 @@ 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,
...(adopted.length ? { adopted: adopted.map((candidate) => candidate.id) } : {}),
},
{ projectID: item.projectID, directory: item.directory, previous: project.previous ?? ID.global },
{
commit: () =>
db
@@ -290,7 +256,12 @@ const layer = Layer.effect(
return node ? ID.make(node) : undefined
})
const hgDiscover = Effect.fnUntraced(function* (dotHg: AbsolutePath) {
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 worktree = AbsolutePath.make(path.dirname(dotHg))
const store = AbsolutePath.make(dotHg)
const previous = yield* cached(store)
@@ -304,15 +275,7 @@ const layer = Layer.effect(
})
const resolve = Effect.fn("Project.resolve")(function* (input: AbsolutePath) {
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
const repo = yield* git.repo.discover(input)
if (repo) {
const previous = yield* cached(repo.commonDirectory)
const id = (yield* remote(repo)) ?? previous ?? (yield* rootCommit(repo))
@@ -332,14 +295,10 @@ const layer = Layer.effect(
})
}
const hg = marker && path.basename(marker) === ".hg" ? yield* hgDiscover(AbsolutePath.make(marker)) : undefined
const hg = yield* hgDiscover(input)
if (hg) return yield* persist({ ...hg, canonical: hg.directory })
return yield* persist({
id: ID.make(Hash.fast(`directory:${directory}`)),
directory,
canonical: directory,
vcs: undefined,
})
const directory = AbsolutePath.make(path.parse(input).root)
return yield* persist({ id: ID.global, directory, canonical: directory, vcs: undefined })
})
return Service.of({ list, update, resolve })
+11 -31
View File
@@ -1,6 +1,6 @@
export * as SessionProjector from "./projector.js"
import { and, asc, desc, eq, gt, gte, inArray, isNull, lt, lte, or, 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"
@@ -23,7 +23,6 @@ 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<SessionEvent.Event, { readonly durable: object }>
@@ -479,34 +478,18 @@ 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 candidates = [
...new Set(
[event.data.previous, Project.ID.global, ...(event.data.adopted ?? [])].filter(
(id) => id !== event.data.projectID,
),
),
]
if (candidates.length === 0) return
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,
projectID: SessionTable.project_id,
canonical: ProjectTable.worktree,
})
.select({ id: SessionTable.id, directory: SessionTable.directory })
.from(SessionTable)
.innerJoin(ProjectTable, eq(SessionTable.project_id, ProjectTable.id))
.where(
and(
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")),
),
),
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()
@@ -514,15 +497,12 @@ const layer = Layer.effectDiscard(
yield* Effect.forEach(
rows,
(row) => {
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
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, directory).replaceAll("\\", "/")),
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}`,
})
+3 -75
View File
@@ -145,54 +145,24 @@ async function rootCommit(dir: string) {
}
describe("Project.resolve", () => {
it.live("creates distinct deterministic projects for exact markerless directories", () =>
it.live("returns global for non-git directory", () =>
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).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.id).toBe(Project.ID.make("global"))
expect(path.resolve(result.directory)).toBe(path.parse(tmp.path).root)
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(
@@ -441,48 +411,6 @@ 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(
+5 -104
View File
@@ -96,37 +96,14 @@ 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 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"))
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)
})
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)
@@ -137,25 +114,10 @@ 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).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(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]))
@@ -163,37 +125,16 @@ describe("Session.create", () => {
projectID: project.id,
location: ref,
subpath: undefined,
time: { updated: DateTime.makeUnsafe(before.time_updated) },
time: { updated: originalUpdated },
})
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",
"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,
})
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 })
@@ -206,46 +147,6 @@ 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
+1 -3
View File
@@ -132,7 +132,6 @@ const it = testEffect(
LayerNode.group([
Database.node,
Bus.node,
Project.node,
SessionProjector.node,
SessionStore.node,
Agent.node,
@@ -201,7 +200,6 @@ 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) => {
@@ -212,7 +210,7 @@ const setup = Effect.gen(function* () {
.insert(SessionTable)
.values({
id: sessionID,
project_id: (yield* projects.resolve(AbsolutePath.make("/project"))).id,
project_id: Project.ID.global,
slug: "generate-test",
directory: "/project",
title: "Generate test",
+12 -31
View File
@@ -57,46 +57,27 @@ 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; readonly workspaceID?: string },
event: {
readonly projectID: string
readonly directory: string
readonly previous: string
readonly adopted?: ReadonlyArray<string>
},
session: { readonly projectID: string; readonly directory: string },
event: { readonly projectID: string; readonly directory: string; readonly previous: string },
) {
if (session.workspaceID) return
if (
session.projectID !== event.previous &&
session.projectID !== Project.ID.global &&
!event.adopted?.includes(session.projectID)
)
return
if (session.projectID !== event.previous && session.projectID !== Project.ID.global) return
if (session.projectID === event.projectID) 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
const inside =
session.directory === event.directory ||
session.directory.startsWith(event.directory + "/") ||
session.directory.startsWith(event.directory + "\\")
if (!inside) return
return {
projectID: event.projectID,
subpath: key === parent ? undefined : directory.slice(root.length + 1),
subpath:
session.directory === event.directory
? undefined
: session.directory.slice(event.directory.length + 1).replaceAll("\\", "/"),
}
}