mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-26 03:26:12 +00:00
Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
019da0f2bf |
@@ -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 = {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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[] = []
|
||||
|
||||
@@ -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" })
|
||||
})
|
||||
})
|
||||
@@ -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 })
|
||||
|
||||
@@ -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}`,
|
||||
})
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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("\\", "/"),
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user