mirror of
https://github.com/anomalyco/opencode.git
synced 2026-09-07 09:26:26 +00:00
Compare commits
12
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
78e76bfdec | ||
|
|
26d2d06fba | ||
|
|
c3f874747e | ||
|
|
52685d4517 | ||
|
|
30d1049942 | ||
|
|
21adcb4969 | ||
|
|
c72b535dee | ||
|
|
8d1a9799f4 | ||
|
|
86ba09c6e0 | ||
|
|
41cb354c3e | ||
|
|
23f3f8b6ca | ||
|
|
baab05727d |
@@ -235,6 +235,7 @@ const bucketNew = new sst.cloudflare.Bucket("ZenDataNew")
|
||||
const DISCORD_INCIDENT_WEBHOOK_URL = new sst.Secret("DISCORD_INCIDENT_WEBHOOK_URL")
|
||||
const AWS_SES_ACCESS_KEY_ID = new sst.Secret("AWS_SES_ACCESS_KEY_ID")
|
||||
const AWS_SES_SECRET_ACCESS_KEY = new sst.Secret("AWS_SES_SECRET_ACCESS_KEY")
|
||||
const ENTERPRISE_SALES_INBOX_EMAIL = new sst.Secret("ENTERPRISE_SALES_INBOX_EMAIL")
|
||||
|
||||
const SALESFORCE_CLIENT_ID = new sst.Secret("SALESFORCE_CLIENT_ID")
|
||||
const SALESFORCE_CLIENT_SECRET = new sst.Secret("SALESFORCE_CLIENT_SECRET")
|
||||
@@ -263,6 +264,7 @@ new sst.cloudflare.x.SolidStart("Console", {
|
||||
EMAILOCTOPUS_API_KEY,
|
||||
AWS_SES_ACCESS_KEY_ID,
|
||||
AWS_SES_SECRET_ACCESS_KEY,
|
||||
ENTERPRISE_SALES_INBOX_EMAIL,
|
||||
SALESFORCE_CLIENT_ID,
|
||||
SALESFORCE_CLIENT_SECRET,
|
||||
SALESFORCE_INSTANCE_URL,
|
||||
|
||||
@@ -86,7 +86,12 @@ async function mockServers(page: Page, requests: string[]) {
|
||||
}
|
||||
return json(route, url.pathname === "/api/project" ? [project] : { id: project.id, directory: current.directory })
|
||||
}
|
||||
if (url.pathname === "/api/location") return json(route, { directory: current.directory })
|
||||
if (url.pathname === "/api/location")
|
||||
return json(route, {
|
||||
directory: current.directory,
|
||||
project: { id: current.projectID, directory: current.directory, canonical: current.directory },
|
||||
})
|
||||
if (url.pathname === "/api/worktree") return json(route, [{ directory: current.directory }])
|
||||
if (url.pathname === "/api/vcs")
|
||||
return json(route, {
|
||||
location: { directory: current.directory },
|
||||
|
||||
@@ -396,7 +396,12 @@ async function mockServers(
|
||||
return json(route, { data: [], cursor: {} })
|
||||
if (sessions.some((session) => url.pathname === `/api/session/${session.id}/inbox`))
|
||||
return json(route, { data: [] })
|
||||
if (url.pathname === "/api/location") return json(route, { directory })
|
||||
if (url.pathname === "/api/location")
|
||||
return json(route, {
|
||||
directory,
|
||||
project: { id: remote ? sessionB.projectID : "project-server-a", directory, canonical: directory },
|
||||
})
|
||||
if (url.pathname === "/api/worktree") return json(route, [{ directory }])
|
||||
if (url.pathname === "/api/vcs")
|
||||
return json(route, { location: { directory }, data: { branch: "main", defaultBranch: "main" } })
|
||||
if (url.pathname === "/api/pty/shells") return json(route, { location: { directory }, data: [] })
|
||||
|
||||
@@ -225,13 +225,81 @@ test("vertical tabs show project details, resize, and navigate", async ({ page }
|
||||
await page.mouse.down()
|
||||
await page.mouse.move(resized.x - 200, resized.y + resized.height / 2)
|
||||
await page.mouse.up()
|
||||
await expect(sidebar).toHaveCSS("width", "130px")
|
||||
await expect(sidebar).toHaveCSS("width", "140px")
|
||||
|
||||
await tabB.click()
|
||||
await expect(page).toHaveURL(new RegExp(`${hrefB.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}$`))
|
||||
await expect(tabB).toBeVisible()
|
||||
})
|
||||
|
||||
for (const count of [0, 26]) {
|
||||
test(`vertical navigation labels and icons use the available width with ${count} tabs`, async ({
|
||||
page,
|
||||
}, testInfo) => {
|
||||
await mockServer(page)
|
||||
await page.addInitScript(
|
||||
({ server, directory, count }) => {
|
||||
localStorage.setItem(
|
||||
"settings.v3",
|
||||
JSON.stringify({
|
||||
appearance: { tabLayout: "vertical" },
|
||||
keybinds: { "home.toggle": "alt+home", "tab.new": "ctrl+shift+n" },
|
||||
}),
|
||||
)
|
||||
localStorage.setItem(
|
||||
"opencode.window.browser.dat:tabs",
|
||||
JSON.stringify(
|
||||
Array.from({ length: count }, (_, index) => ({
|
||||
type: "draft",
|
||||
server,
|
||||
directory,
|
||||
draftID: `draft_navigation_${index}`,
|
||||
})),
|
||||
),
|
||||
)
|
||||
},
|
||||
{ server, directory: sessionA.directory, count },
|
||||
)
|
||||
await page.goto("/")
|
||||
|
||||
const sidebar = page.locator('[data-slot="vertical-tabs-sidebar"]')
|
||||
await expect(sidebar).toHaveCSS("width", "260px")
|
||||
await expect(sidebar.locator("[data-titlebar-tab-slot]")).toHaveCount(count)
|
||||
for (const width of [260, 180, 140]) {
|
||||
if (width !== 260) {
|
||||
const handle = await sidebar.locator('[data-component="resize-handle"]').boundingBox()
|
||||
const bounds = await sidebar.boundingBox()
|
||||
if (!handle || !bounds) throw new Error("vertical tab sidebar has no bounding box")
|
||||
await page.mouse.move(handle.x + handle.width / 2, handle.y + handle.height / 2)
|
||||
await page.mouse.down()
|
||||
await page.mouse.move(handle.x + handle.width / 2 + width - bounds.width, handle.y + handle.height / 2)
|
||||
await page.mouse.up()
|
||||
}
|
||||
await expect(sidebar).toHaveCSS("width", `${width}px`)
|
||||
await testInfo.attach(`navigation-${count}-${width}`, {
|
||||
body: await sidebar.screenshot(),
|
||||
contentType: "image/png",
|
||||
})
|
||||
for (const name of ["Home", "New session"]) {
|
||||
const button = sidebar.getByRole("button", { name, exact: true })
|
||||
const label = button.getByText(name, { exact: true })
|
||||
await expect(label).toBeVisible()
|
||||
await expect
|
||||
.poll(() => label.evaluate((element) => element.scrollWidth - element.clientWidth), { message: name })
|
||||
.toBeLessThanOrEqual(1)
|
||||
await expect(button.locator('[data-slot="icon-svg"]')).toHaveCSS("width", "16px")
|
||||
await button.hover()
|
||||
await expect(button.locator('span[aria-hidden="true"]')).toBeVisible()
|
||||
await expect(button.locator('[data-slot="icon-svg"]')).toHaveCSS("width", "16px")
|
||||
await expect
|
||||
.poll(() => button.evaluate((element) => element.scrollWidth - element.clientWidth))
|
||||
.toBeLessThanOrEqual(1)
|
||||
await page.getByRole("main").hover()
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
for (const direction of ["ltr", "rtl"]) {
|
||||
test(`vertical tabs keep Settings pinned while scrolling in ${direction}`, async ({ page }, testInfo) => {
|
||||
await mockServer(page)
|
||||
@@ -359,9 +427,9 @@ for (const profile of [
|
||||
const hint = button.locator('span[aria-hidden="true"]')
|
||||
await expect(hint).toHaveText(row.shortcut)
|
||||
await expect(hint.getByText(row.shortcut, { exact: true })).toHaveCSS("direction", "ltr")
|
||||
await expect(hint).toHaveCSS("opacity", "0")
|
||||
await expect(hint).toBeHidden()
|
||||
await button.hover()
|
||||
await expect(hint).toHaveCSS("opacity", "1")
|
||||
await expect(hint).toBeVisible()
|
||||
await expect
|
||||
.poll(() =>
|
||||
hint.evaluate((element) => {
|
||||
@@ -373,7 +441,7 @@ for (const profile of [
|
||||
)
|
||||
.toBeCloseTo(8, 1)
|
||||
await page.getByRole("main").hover()
|
||||
await expect(hint).toHaveCSS("opacity", "0")
|
||||
await expect(hint).toBeHidden()
|
||||
}
|
||||
|
||||
const home = sidebar.locator('[data-action="vertical-tabs-home"]')
|
||||
@@ -381,10 +449,10 @@ for (const profile of [
|
||||
await home.focus()
|
||||
await page.keyboard.press("Tab")
|
||||
await expect(newSession).toBeFocused()
|
||||
await expect(newSession.locator('span[aria-hidden="true"]')).toHaveCSS("opacity", "1")
|
||||
await expect(newSession.locator('span[aria-hidden="true"]')).toBeVisible()
|
||||
await page.keyboard.press("Shift+Tab")
|
||||
await expect(home).toBeFocused()
|
||||
await expect(home.locator('span[aria-hidden="true"]')).toHaveCSS("opacity", "1")
|
||||
await expect(home.locator('span[aria-hidden="true"]')).toBeVisible()
|
||||
})
|
||||
}
|
||||
|
||||
@@ -560,7 +628,12 @@ async function mockServer(page: Page) {
|
||||
url.pathname === "/api/project" ? [project] : { id: project.id, directory: sessionA.directory },
|
||||
)
|
||||
}
|
||||
if (url.pathname === "/api/location") return json(route, { directory: sessionA.directory })
|
||||
if (url.pathname === "/api/location")
|
||||
return json(route, {
|
||||
directory: sessionA.directory,
|
||||
project: { id: sessionA.projectID, directory: sessionA.directory, canonical: sessionA.directory },
|
||||
})
|
||||
if (url.pathname === "/api/worktree") return json(route, [{ directory: sessionA.directory }])
|
||||
if (url.pathname === "/api/vcs")
|
||||
return json(route, {
|
||||
location: { directory: sessionA.directory },
|
||||
|
||||
@@ -34,6 +34,14 @@ export function createHomeController() {
|
||||
const conn = list[0]
|
||||
if (conn) setSelection({ server: ServerConnection.key(conn) })
|
||||
})
|
||||
createEffect(() => {
|
||||
const ctx = focusedServerCtx()
|
||||
const id = selectedProject()?.id
|
||||
if (!ctx || !id || ctx.sdk.connection.status() !== "connected") return
|
||||
// Selecting a project is the demand for its worktree inventory: the session filter spans its worktrees.
|
||||
const root = ctx.sync.data.project.find((project) => project.id === id)?.worktree
|
||||
if (root) void ctx.sync.worktrees.load(root)
|
||||
})
|
||||
|
||||
function setSelection(next: HomeProjectSelection) {
|
||||
layout.home.setSelection(next)
|
||||
|
||||
@@ -27,7 +27,7 @@ import { sessionLabel, sessionTitle } from "@/session/title"
|
||||
import { showToast } from "@/shell/notifications/toast"
|
||||
import { archiveHomeSession } from "./archive"
|
||||
import type { HomeController } from "../model"
|
||||
import { buildHomeSessionRecords, type HomeSessionRecord } from "./records"
|
||||
import { buildHomeSessionRecords, homeProjectForSession, type HomeSessionRecord } from "./records"
|
||||
|
||||
export type { HomeSessionRecord } from "./records"
|
||||
|
||||
@@ -270,14 +270,7 @@ export function createHomeSessionsController(home: HomeController) {
|
||||
},
|
||||
create: home.project.openNewSession,
|
||||
open: (session: SessionInfo, options?: OpenSessionOptions) => {
|
||||
const directoryKey = pathKey(session.location.directory)
|
||||
const project = home.project
|
||||
.list()
|
||||
.find(
|
||||
(item) =>
|
||||
pathKey(item.worktree) === directoryKey ||
|
||||
item.sandboxes?.some((sandbox) => pathKey(sandbox) === directoryKey),
|
||||
)
|
||||
const project = homeProjectForSession(session, home.project.list())
|
||||
const conn = home.server.focused()
|
||||
if (!conn) return
|
||||
const connKey = ServerConnection.key(conn)
|
||||
|
||||
@@ -36,4 +36,26 @@ describe("buildHomeSessionRecords", () => {
|
||||
|
||||
expect(records.map((record) => record.session.id)).toEqual(["a"])
|
||||
})
|
||||
|
||||
test("labels a worktree session with its project before that project's inventory has loaded", () => {
|
||||
const records = buildHomeSessionRecords({
|
||||
sessions: () => [session("w", "/repo/a/.worktrees/feature", "project-a")],
|
||||
projectDirectories: () => undefined,
|
||||
projects: () => [{ ...opened, name: "Project A" }],
|
||||
})
|
||||
|
||||
expect(records[0]?.project).toMatchObject({ id: "project-a", worktree: "/repo/a" })
|
||||
expect(records[0]?.projectName).toBe("Project A")
|
||||
})
|
||||
|
||||
test("prefers the added project whose directory matches over a sibling entry with the same ID", () => {
|
||||
const nested = { id: "project-a", worktree: "/repo/a/packages/app", expanded: true } as LocalProject
|
||||
const records = buildHomeSessionRecords({
|
||||
sessions: () => [session("n", "/repo/a/packages/app", "project-a")],
|
||||
projectDirectories: () => undefined,
|
||||
projects: () => [opened, nested],
|
||||
})
|
||||
|
||||
expect(records[0]?.project.worktree).toBe("/repo/a/packages/app")
|
||||
})
|
||||
})
|
||||
|
||||
@@ -22,13 +22,7 @@ export function buildHomeSessionRecords(input: {
|
||||
return [...new Map(sessions.map((session) => [session.id, session] as const)).values()]
|
||||
.sort(compareSessionTime)
|
||||
.map((session) => {
|
||||
const directory = pathKey(session.location.directory)
|
||||
const project = input
|
||||
.projects()
|
||||
.find(
|
||||
(item) =>
|
||||
pathKey(item.worktree) === directory || item.sandboxes?.some((sandbox) => pathKey(sandbox) === directory),
|
||||
) ?? {
|
||||
const project = homeProjectForSession(session, input.projects()) ?? {
|
||||
id: session.projectID,
|
||||
worktree: session.location.directory,
|
||||
expanded: false,
|
||||
@@ -36,3 +30,18 @@ export function buildHomeSessionRecords(input: {
|
||||
return { session, project, projectName: displayName(project) }
|
||||
})
|
||||
}
|
||||
|
||||
// Worktree inventories load on demand, so a worktree session may not match any directory yet;
|
||||
// the session's project ID still identifies its added project.
|
||||
export function homeProjectForSession<T extends { id?: string; worktree: string; sandboxes?: readonly string[] }>(
|
||||
session: SessionInfo,
|
||||
projects: readonly T[],
|
||||
) {
|
||||
const directory = pathKey(session.location.directory)
|
||||
return (
|
||||
projects.find(
|
||||
(item) =>
|
||||
pathKey(item.worktree) === directory || item.sandboxes?.some((sandbox) => pathKey(sandbox) === directory),
|
||||
) ?? projects.find((item) => item.id === session.projectID)
|
||||
)
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import { createApiForServer, type ServerApi } from "@/runtime/server/api"
|
||||
import { usePlatform } from "@/runtime/platform/platform"
|
||||
import { ServerConnection } from "./registry"
|
||||
import { createRefCountMap } from "@/runtime/server/refcount"
|
||||
import { createRequestQueue } from "@/runtime/server/request-queue"
|
||||
import { ServerScope } from "@/runtime/server/scope"
|
||||
import { useServer } from "./current"
|
||||
|
||||
@@ -114,8 +115,9 @@ export function createServerTransport(input: { http: ServerConnection.HttpBase;
|
||||
readonly api: ServerApi
|
||||
readonly pty: ReturnType<typeof createPtyClient>
|
||||
} {
|
||||
const queue = createRequestQueue({ fetch: input.fetch ?? globalThis.fetch })
|
||||
const build = (http: ServerConnection.HttpBase) => {
|
||||
const api = createApiForServer({ server: http, fetch: input.fetch })
|
||||
const api = createApiForServer({ server: http, fetch: queue.fetch })
|
||||
return { http, api, pty: createPtyClient(api, { url: http.url }) }
|
||||
}
|
||||
const state = { current: build(input.http) }
|
||||
|
||||
@@ -6,6 +6,7 @@ import { bootstrapGlobal, loadPathQuery, loadProjectsQuery } from "./bootstrap"
|
||||
import { ServerScope } from "@/runtime/server/scope"
|
||||
import type { ServerApi } from "@/runtime/server/api"
|
||||
import type { ServerSync } from "@/runtime/server/sync"
|
||||
import { worktreeInventoryKey } from "@/workspaces/inventory"
|
||||
|
||||
test("bootstraps projects through the native store setter and preserves subsequent updates", async () => {
|
||||
const api = OpenCode.make({
|
||||
@@ -20,7 +21,6 @@ test("bootstraps projects through the native store setter and preserves subseque
|
||||
})
|
||||
if (url.pathname === "/api/project")
|
||||
return Response.json([{ id: "project", canonical: "/repo", time: { created: 1, updated: 1 }, sandboxes: [] }])
|
||||
if (url.pathname === "/api/worktree") return Response.json([{ directory: "/repo" }])
|
||||
throw new Error(`Unexpected request: ${url.pathname}`)
|
||||
},
|
||||
{ preconnect() {} },
|
||||
@@ -47,6 +47,18 @@ test("bootstraps projects through the native store setter and preserves subseque
|
||||
await bootstrapGlobal({ serverAPI: api, scope: ServerScope.local, setGlobalStore: setStore, queryClient })
|
||||
expect(store.project.map((project) => [project.id, project.worktree])).toEqual([["project", "/repo"]])
|
||||
expect(store.config).toEqual({})
|
||||
|
||||
// A refetch keeps the inventory a view already loaded for this project.
|
||||
queryClient.setQueryData(worktreeInventoryKey(ServerScope.local, "/repo/"), [
|
||||
{ directory: "/repo" },
|
||||
{ directory: "/repo/feature", strategy: "git" },
|
||||
])
|
||||
await bootstrapGlobal({ serverAPI: api, scope: ServerScope.local, setGlobalStore: setStore, queryClient })
|
||||
expect(store.project[0]?.sandboxes).toEqual(["/repo/feature"])
|
||||
expect(store.project[0]?.worktrees).toEqual([
|
||||
{ directory: "/repo" },
|
||||
{ directory: "/repo/feature", strategy: "git" },
|
||||
])
|
||||
} finally {
|
||||
queryClient.clear()
|
||||
}
|
||||
@@ -76,70 +88,39 @@ describe("query keys", () => {
|
||||
expect(result).toMatchObject({ directory: "/repo/subpath", worktree: "/repo" })
|
||||
})
|
||||
|
||||
test("loads each project's inventory through its own location using the real client", async () => {
|
||||
const calls: string[] = []
|
||||
test("loads project metadata without enumerating any project's worktrees", async () => {
|
||||
const requests: string[] = []
|
||||
const api = OpenCode.make({
|
||||
baseUrl: "http://localhost:3000",
|
||||
fetch: Object.assign(
|
||||
async (input: RequestInfo | URL, init?: RequestInit) => {
|
||||
const url = new URL(new Request(input, init).url)
|
||||
if (url.pathname === "/api/project")
|
||||
return Response.json([
|
||||
{ id: "b", canonical: "/b", time: { created: 1, updated: 1 }, sandboxes: [] },
|
||||
{ id: "a", canonical: "/a", time: { created: 1, updated: 1 }, sandboxes: [] },
|
||||
])
|
||||
const directory = url.searchParams.get("location[directory]")
|
||||
if (url.pathname !== "/api/worktree" || !directory) throw new Error(`Unexpected request: ${url}`)
|
||||
calls.push(directory)
|
||||
requests.push(url.pathname)
|
||||
if (url.pathname !== "/api/project") throw new Error(`Unexpected request: ${url}`)
|
||||
return Response.json([
|
||||
{ directory },
|
||||
{ directory: `${directory}/clone` },
|
||||
{ directory: `${directory}/copy`, strategy: "git" },
|
||||
...Array.from({ length: 300 }, (_, index) => ({
|
||||
id: `historical-${index.toString().padStart(3, "0")}`,
|
||||
canonical: `/history/${index}`,
|
||||
time: { created: 1, updated: 1 },
|
||||
sandboxes: [],
|
||||
})),
|
||||
{ id: "b", canonical: "/b", time: { created: 1, updated: 1 }, sandboxes: [] },
|
||||
{ id: "a", canonical: "/a", time: { created: 1, updated: 1 }, sandboxes: ["/a/legacy"] },
|
||||
{ id: "test", canonical: "/tmp/opencode-test-1", time: { created: 1, updated: 1 }, sandboxes: [] },
|
||||
])
|
||||
},
|
||||
{ preconnect() {} },
|
||||
),
|
||||
})
|
||||
|
||||
const result = await new QueryClient().fetchQuery(loadProjectsQuery(ServerScope.local, api.project, api.worktree))
|
||||
const result = await new QueryClient().fetchQuery(loadProjectsQuery(ServerScope.local, api.project))
|
||||
|
||||
expect(result.map((project) => project.id)).toEqual(["a", "b"])
|
||||
expect(result.map((project) => project.sandboxes)).toEqual([
|
||||
["/a/clone", "/a/copy"],
|
||||
["/b/clone", "/b/copy"],
|
||||
])
|
||||
expect(result.map((project) => project.worktrees)).toEqual([
|
||||
[{ directory: "/a" }, { directory: "/a/clone" }, { directory: "/a/copy", strategy: "git" }],
|
||||
[{ directory: "/b" }, { directory: "/b/clone" }, { directory: "/b/copy", strategy: "git" }],
|
||||
])
|
||||
expect(calls.toSorted()).toEqual(["/a", "/b"])
|
||||
})
|
||||
|
||||
test("keeps projects whose directory inventory cannot load", async () => {
|
||||
const api = OpenCode.make({
|
||||
baseUrl: "http://localhost:3000",
|
||||
fetch: Object.assign(
|
||||
async (input: RequestInfo | URL, init?: RequestInit) => {
|
||||
const url = new URL(new Request(input, init).url)
|
||||
if (url.pathname === "/api/project")
|
||||
return Response.json([
|
||||
{ id: "a", canonical: "/a", time: { created: 1, updated: 1 }, sandboxes: [] },
|
||||
{ id: "b", canonical: "/b", time: { created: 1, updated: 1 }, sandboxes: [] },
|
||||
])
|
||||
const directory = url.searchParams.get("location[directory]")
|
||||
if (url.pathname !== "/api/worktree" || !directory) throw new Error(`Unexpected request: ${url}`)
|
||||
if (directory === "/b") return Response.json({ message: "unavailable" }, { status: 503 })
|
||||
return Response.json([{ directory: "/a/copy", strategy: "git" }])
|
||||
},
|
||||
{ preconnect() {} },
|
||||
),
|
||||
})
|
||||
|
||||
const result = await new QueryClient().fetchQuery(loadProjectsQuery(ServerScope.local, api.project, api.worktree))
|
||||
|
||||
expect(result.map((project) => ({ id: project.id, sandboxes: project.sandboxes }))).toEqual([
|
||||
{ id: "a", sandboxes: ["/a/copy"] },
|
||||
{ id: "b", sandboxes: [] },
|
||||
expect(requests).toEqual(["/api/project"])
|
||||
expect(result).toHaveLength(302)
|
||||
expect(result.slice(0, 2)).toMatchObject([
|
||||
{ id: "a", worktree: "/a", sandboxes: ["/a/legacy"], worktrees: [{ directory: "/a" }] },
|
||||
{ id: "b", worktree: "/b", sandboxes: [], worktrees: [{ directory: "/b" }] },
|
||||
])
|
||||
expect(result.some((project) => project.id === "test")).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -15,8 +15,7 @@ import { cmp, normalizeProjectInfo } from "./utils"
|
||||
import { formatServerError } from "@/runtime/server/errors"
|
||||
import { QueryClient, queryOptions } from "@tanstack/solid-query"
|
||||
import type { ServerScope } from "@/runtime/server/scope"
|
||||
import type { ServerApi } from "@/runtime/server/api"
|
||||
import { sameDirectory } from "@/workspaces/paths"
|
||||
import { withWorktreeInventory, worktreeInventoryKey } from "@/workspaces/inventory"
|
||||
|
||||
type GlobalStore = {
|
||||
path: Path
|
||||
@@ -64,42 +63,21 @@ type ProjectApi = {
|
||||
readonly list: () => Promise<ProjectListOutput>
|
||||
readonly current: (input?: ProjectCurrentInput) => Promise<ProjectCurrentOutput>
|
||||
}
|
||||
type WorktreeApi = Pick<ServerApi["worktree"], "list">
|
||||
type LocationApi = { readonly get: (input?: LocationGetInput) => Promise<LocationGetOutput> }
|
||||
|
||||
export const loadProjectsQuery = (scope: ServerScope, projects: ProjectApi, worktrees: WorktreeApi) =>
|
||||
// Metadata only. Worktree inventories load per project when a view shows it (see workspaces/inventory).
|
||||
export const loadProjectsQuery = (scope: ServerScope, projects: ProjectApi) =>
|
||||
queryOptions({
|
||||
queryKey: [scope, "project"],
|
||||
queryFn: () =>
|
||||
retry(() =>
|
||||
projects.list().then(async (items) => {
|
||||
return (
|
||||
await Promise.all(
|
||||
items
|
||||
.filter((project) => !!project?.id)
|
||||
.map(async (project) => {
|
||||
const directories = await worktrees
|
||||
.list({ location: { directory: project.canonical } })
|
||||
.catch(() => [
|
||||
{ directory: project.canonical },
|
||||
...(project.sandboxes ?? [])
|
||||
.filter((directory) => !sameDirectory(project.canonical, directory))
|
||||
.map((directory) => ({ directory })),
|
||||
])
|
||||
return normalizeProjectInfo({
|
||||
...project,
|
||||
sandboxes: directories
|
||||
.map((item) => item.directory)
|
||||
.filter((directory) => !sameDirectory(project.canonical, directory)),
|
||||
worktrees: directories,
|
||||
})
|
||||
}),
|
||||
)
|
||||
)
|
||||
projects.list().then((items) =>
|
||||
items
|
||||
.filter((project) => !!project?.id)
|
||||
.map(normalizeProjectInfo)
|
||||
.filter((p) => !!p.worktree && !p.worktree.includes("opencode-test"))
|
||||
.slice()
|
||||
.sort((a, b) => cmp(a.id, b.id))
|
||||
}),
|
||||
.sort((a, b) => cmp(a.id, b.id)),
|
||||
),
|
||||
),
|
||||
})
|
||||
|
||||
@@ -107,7 +85,6 @@ export async function bootstrapGlobal(input: {
|
||||
serverAPI: {
|
||||
readonly location: LocationApi
|
||||
readonly project: ProjectApi
|
||||
readonly worktree: WorktreeApi
|
||||
}
|
||||
scope: ServerScope
|
||||
setGlobalStore: SetStoreFunction<GlobalStore>
|
||||
@@ -117,9 +94,17 @@ export async function bootstrapGlobal(input: {
|
||||
() => input.queryClient.fetchQuery(loadGlobalConfigQuery(input.scope)),
|
||||
() => input.queryClient.fetchQuery(loadPathQuery(input.scope, null, input.serverAPI.location)),
|
||||
() =>
|
||||
input.queryClient
|
||||
.fetchQuery(loadProjectsQuery(input.scope, input.serverAPI.project, input.serverAPI.worktree))
|
||||
.then((data) => input.setGlobalStore("project", data)),
|
||||
input.queryClient.fetchQuery(loadProjectsQuery(input.scope, input.serverAPI.project)).then((data) =>
|
||||
input.setGlobalStore(
|
||||
"project",
|
||||
data.map((project) =>
|
||||
withWorktreeInventory(
|
||||
project,
|
||||
input.queryClient.getQueryData(worktreeInventoryKey(input.scope, project.worktree)),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
]
|
||||
await runAll(slow)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { createRequestQueue } from "./request-queue"
|
||||
|
||||
function setup(input?: { limit?: number; stallMs?: number }) {
|
||||
const pending: Array<{ url: string; resolve: () => void }> = []
|
||||
const logs: Array<{ message: string; data: Record<string, unknown> }> = []
|
||||
let clock = 0
|
||||
const queue = createRequestQueue({
|
||||
limit: input?.limit ?? 2,
|
||||
stallMs: input?.stallMs,
|
||||
now: () => clock,
|
||||
log: (message, data) => logs.push({ message, data }),
|
||||
fetch: Object.assign(
|
||||
(resource: RequestInfo | URL) =>
|
||||
new Promise<Response>((resolve) => {
|
||||
pending.push({ url: new Request(resource).url, resolve: () => resolve(new Response("ok")) })
|
||||
}),
|
||||
{ preconnect() {} },
|
||||
),
|
||||
})
|
||||
const settle = () => new Promise<void>((resolve) => setTimeout(resolve, 0))
|
||||
return { queue, pending, logs, settle, tick: (ms: number) => (clock += ms) }
|
||||
}
|
||||
|
||||
describe("createRequestQueue", () => {
|
||||
test("caps concurrent requests and starts queued ones as slots free up", async () => {
|
||||
const input = setup()
|
||||
const responses = ["/api/a", "/api/b", "/api/c"].map((path) => input.queue.fetch(`http://server${path}`))
|
||||
await input.settle()
|
||||
expect(input.pending.map((item) => new URL(item.url).pathname)).toEqual(["/api/a", "/api/b"])
|
||||
expect(input.queue.queued()).toBe(1)
|
||||
input.pending[0]!.resolve()
|
||||
await input.settle()
|
||||
expect(input.pending.map((item) => new URL(item.url).pathname)).toEqual(["/api/a", "/api/b", "/api/c"])
|
||||
input.pending.forEach((item) => item.resolve())
|
||||
await Promise.all(responses)
|
||||
expect(input.queue.inflight()).toBe(0)
|
||||
})
|
||||
|
||||
test("never counts the event stream against the budget", async () => {
|
||||
const input = setup({ limit: 1 })
|
||||
void input.queue.fetch("http://server/api/session")
|
||||
void input.queue.fetch("http://server/api/event")
|
||||
await input.settle()
|
||||
expect(input.pending.map((item) => new URL(item.url).pathname).toSorted()).toEqual(["/api/event", "/api/session"])
|
||||
expect(input.queue.inflight()).toBe(1)
|
||||
})
|
||||
|
||||
test("aborted requests leave the queue without being sent", async () => {
|
||||
const input = setup({ limit: 1 })
|
||||
const controller = new AbortController()
|
||||
void input.queue.fetch("http://server/api/first")
|
||||
const aborted = input.queue.fetch("http://server/api/second", { signal: controller.signal })
|
||||
controller.abort()
|
||||
await input.settle()
|
||||
input.pending[0]!.resolve()
|
||||
await expect(aborted).rejects.toBeInstanceOf(DOMException)
|
||||
expect(input.pending.map((item) => new URL(item.url).pathname)).toEqual(["/api/first"])
|
||||
expect(input.queue.inflight()).toBe(0)
|
||||
})
|
||||
|
||||
test("a burst that drains promptly is not thrashing", async () => {
|
||||
const input = setup({ stallMs: 5 })
|
||||
const responses = Array.from({ length: 12 }, (_, index) => input.queue.fetch(`http://server/api/${index}`))
|
||||
await input.settle()
|
||||
expect(input.queue.queued()).toBe(10)
|
||||
// Drain two at a time before the stall threshold elapses.
|
||||
for (let round = 0; round < 6; round++) {
|
||||
input.pending.splice(0).forEach((item) => item.resolve())
|
||||
await input.settle()
|
||||
}
|
||||
await Promise.all(responses)
|
||||
await new Promise((resolve) => setTimeout(resolve, 20))
|
||||
expect(input.logs).toEqual([])
|
||||
})
|
||||
|
||||
test("logs what is in flight and queued once per burst after requests stall", async () => {
|
||||
const input = setup({ stallMs: 5 })
|
||||
input.queue.fetch("http://server/api/worktree?location[directory]=%2Fa").catch(() => undefined)
|
||||
input.tick(50)
|
||||
input.queue.fetch("http://server/api/worktree?location[directory]=%2Fb").catch(() => undefined)
|
||||
input.tick(50)
|
||||
input.queue.fetch("http://server/api/worktree?location[directory]=%2Fc").catch(() => undefined)
|
||||
input.tick(100)
|
||||
input.queue.fetch("http://server/api/health").catch(() => undefined)
|
||||
expect(input.logs).toEqual([])
|
||||
input.tick(2_000)
|
||||
await new Promise((resolve) => setTimeout(resolve, 20))
|
||||
expect(input.logs).toEqual([
|
||||
{
|
||||
message: "server thrashing detected",
|
||||
data: {
|
||||
limit: 2,
|
||||
inflight: [
|
||||
{ method: "GET", url: "http://server/api/worktree?location[directory]=%2Fa", ms: 2_200 },
|
||||
{ method: "GET", url: "http://server/api/worktree?location[directory]=%2Fb", ms: 2_150 },
|
||||
],
|
||||
queued: [
|
||||
{ method: "GET", url: "http://server/api/worktree?location[directory]=%2Fc", ms: 2_100 },
|
||||
{ method: "GET", url: "http://server/api/health", ms: 2_000 },
|
||||
],
|
||||
},
|
||||
},
|
||||
])
|
||||
// Still stalled within the rate limit: no repeat.
|
||||
input.tick(2_000)
|
||||
await new Promise((resolve) => setTimeout(resolve, 20))
|
||||
expect(input.logs).toHaveLength(1)
|
||||
input.tick(10_000)
|
||||
await new Promise((resolve) => setTimeout(resolve, 20))
|
||||
expect(input.logs).toHaveLength(2)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,84 @@
|
||||
type Entry = { method: string; url: string; at: number }
|
||||
|
||||
// Chromium allows six connections per origin. The event stream holds one for the life of the
|
||||
// connection and health probes use their own fetch, so the app's API calls stay below that or
|
||||
// a burst stalls probes and user actions inside the browser where nothing can observe it.
|
||||
export const requestQueueLimit = 4
|
||||
|
||||
// A mount legitimately fires a dozen requests at once; only a request that has waited this long
|
||||
// for a slot indicates the server is not keeping up.
|
||||
export const requestStallMs = 2_000
|
||||
|
||||
export function createRequestQueue(input: {
|
||||
fetch: typeof globalThis.fetch
|
||||
limit?: number
|
||||
stallMs?: number
|
||||
log?: (message: string, data: Record<string, unknown>) => void
|
||||
now?: () => number
|
||||
}) {
|
||||
const limit = input.limit ?? requestQueueLimit
|
||||
const stallMs = input.stallMs ?? requestStallMs
|
||||
// Call the browser fetch unbound; `input.fetch(...)` would make `this` the options object.
|
||||
const base = input.fetch
|
||||
const now = input.now ?? Date.now
|
||||
const log = input.log ?? ((message, data) => console.warn(`[server-request-queue] ${message}`, data))
|
||||
const inflight = new Set<Entry>()
|
||||
const waiting: Array<{ entry: Entry; start: () => void }> = []
|
||||
let warned = -Infinity
|
||||
let watcher: ReturnType<typeof setTimeout> | undefined
|
||||
|
||||
const describe = (entry: Entry) => ({ method: entry.method, url: entry.url, ms: now() - entry.at })
|
||||
// Debug exports include the console, so list what the server is busy with while requests wait.
|
||||
const watch = () => {
|
||||
watcher = undefined
|
||||
const oldest = waiting[0]?.entry
|
||||
if (!oldest) return
|
||||
if (now() - oldest.at >= stallMs && now() - warned >= 10_000) {
|
||||
warned = now()
|
||||
log("server thrashing detected", {
|
||||
limit,
|
||||
inflight: [...inflight].map(describe),
|
||||
queued: waiting.map((item) => describe(item.entry)),
|
||||
})
|
||||
}
|
||||
watcher = setTimeout(watch, stallMs)
|
||||
}
|
||||
const release = (entry: Entry) => {
|
||||
inflight.delete(entry)
|
||||
waiting.shift()?.start()
|
||||
}
|
||||
const acquire = (entry: Entry) =>
|
||||
new Promise<void>((resolve) => {
|
||||
const start = () => {
|
||||
entry.at = now()
|
||||
inflight.add(entry)
|
||||
resolve()
|
||||
}
|
||||
if (inflight.size < limit) return start()
|
||||
waiting.push({ entry, start })
|
||||
watcher ??= setTimeout(watch, stallMs)
|
||||
})
|
||||
|
||||
const fetch: typeof globalThis.fetch = Object.assign(
|
||||
async (resource: RequestInfo | URL, init?: RequestInit) => {
|
||||
const request = new Request(resource, init)
|
||||
// The event stream is long-lived; never count it against the request budget.
|
||||
if (new URL(request.url).pathname === "/api/event") return base(request)
|
||||
const entry = { method: request.method, url: request.url, at: now() }
|
||||
await acquire(entry)
|
||||
if (request.signal.aborted) {
|
||||
release(entry)
|
||||
throw request.signal.reason ?? new DOMException("The operation was aborted.", "AbortError")
|
||||
}
|
||||
return base(request).finally(() => release(entry))
|
||||
},
|
||||
// Bun's fetch type carries preconnect; the browser never calls it.
|
||||
{ preconnect: () => {} },
|
||||
)
|
||||
|
||||
return {
|
||||
fetch,
|
||||
inflight: () => inflight.size,
|
||||
queued: () => waiting.length,
|
||||
}
|
||||
}
|
||||
@@ -20,6 +20,8 @@ import { toggleMcp } from "./global-sync/mcp"
|
||||
import { createConnectionSync } from "./server-sync/connection"
|
||||
import { usePlatform } from "@/runtime/platform/platform"
|
||||
import type { Data } from "@opencode-ai/client/solid"
|
||||
import { createWorktreeInventory, withWorktreeInventory } from "@/workspaces/inventory"
|
||||
import { sameDirectory } from "@/workspaces/paths"
|
||||
|
||||
type GlobalStore = {
|
||||
path: Path
|
||||
@@ -79,6 +81,17 @@ export function createServerSyncContextInner(serverSDK: ServerSDK, data: Data) {
|
||||
})
|
||||
|
||||
const queryClient = useQueryClient()
|
||||
const worktrees = createWorktreeInventory({
|
||||
scope: serverSDK.scope,
|
||||
queryClient,
|
||||
api: () => serverSDK.api.worktree,
|
||||
updated: (directory, items) =>
|
||||
setGlobalStore("project", (projects) =>
|
||||
projects.map((project) =>
|
||||
sameDirectory(project.worktree, directory) ? withWorktreeInventory(project, items) : project,
|
||||
),
|
||||
),
|
||||
})
|
||||
const bootstrap = useQuery(() => ({
|
||||
queryKey: [serverSDK.scope, "bootstrap"],
|
||||
queryFn: async () => {
|
||||
@@ -197,17 +210,27 @@ export function createServerSyncContextInner(serverSDK: ServerSDK, data: Data) {
|
||||
|
||||
function applyProjectUpdate(update: Parameters<typeof updateProjectInfo>[1]) {
|
||||
setGlobalStore("project", (projects) =>
|
||||
projects.map((project) => (project.id === update.id ? updateProjectInfo(project, update) : project)),
|
||||
projects.map((project) =>
|
||||
project.id === update.id
|
||||
? // The wire payload carries no worktrees; keep the inventory this project already loaded.
|
||||
withWorktreeInventory(updateProjectInfo(project, update), worktrees.cached(update.canonical))
|
||||
: project,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
const unsub = serverSDK.event.listen((event) => {
|
||||
connection.handleEvent({ type: event.type })
|
||||
if (event.type === "project.updated") applyProjectUpdate(event.data)
|
||||
if (event.type === "worktree.updated") {
|
||||
const root = globalStore.project.find((project) => project.id === event.data.projectID)?.worktree
|
||||
if (root) void worktrees.refresh(root)
|
||||
void bootstrap.refetch()
|
||||
return
|
||||
}
|
||||
|
||||
if (!event.location) {
|
||||
if (event.type === "config.updated" || event.type === "agent.updated" || event.type === "worktree.updated")
|
||||
bootstrap.refetch()
|
||||
if (event.type === "config.updated" || event.type === "agent.updated") bootstrap.refetch()
|
||||
return
|
||||
}
|
||||
|
||||
@@ -216,7 +239,6 @@ export function createServerSyncContextInner(serverSDK: ServerSDK, data: Data) {
|
||||
if (!children.children[key]) return
|
||||
children.mark(key)
|
||||
if (event.type === "config.updated" || event.type === "agent.updated") queue.push(key)
|
||||
if (event.type === "worktree.updated") void bootstrap.refetch()
|
||||
})
|
||||
|
||||
onCleanup(unsub)
|
||||
@@ -261,6 +283,7 @@ export function createServerSyncContextInner(serverSDK: ServerSDK, data: Data) {
|
||||
// bootstrap,
|
||||
updateConfig: updateConfigMutation.mutateAsync,
|
||||
project: projectApi,
|
||||
worktrees,
|
||||
mcp: {
|
||||
toggle: async (directory: string, name: string) => {
|
||||
const key = directoryKey(directory)
|
||||
|
||||
@@ -74,7 +74,7 @@ export default function Layout(props: ParentProps) {
|
||||
class="-end-2"
|
||||
direction="horizontal"
|
||||
size={state.tabsWidth}
|
||||
min={130}
|
||||
min={140}
|
||||
max={520}
|
||||
onResize={(width) => setState("tabsWidth", width)}
|
||||
/>
|
||||
|
||||
@@ -356,10 +356,10 @@ export function Titlebar(props: {
|
||||
aria-label={language.t("home.title")}
|
||||
aria-pressed={layout.route().type === "home"}
|
||||
>
|
||||
<Icon name="grid-plus" />
|
||||
<Icon name="grid-plus" class="shrink-0" />
|
||||
<span class="min-w-0 truncate">{language.t("home.title")}</span>
|
||||
<span
|
||||
class="ms-auto shrink-0 whitespace-nowrap text-v2-text-text-faint opacity-0 group-hover:opacity-100 group-focus-visible:opacity-100"
|
||||
class="ms-auto hidden min-w-0 truncate text-v2-text-text-faint group-hover:block group-focus-visible:block"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<bdi dir="ltr">{command.keybind("home.toggle")}</bdi>
|
||||
@@ -654,10 +654,10 @@ export function Titlebar(props: {
|
||||
onClick={openNewTab}
|
||||
aria-label={language.t("command.session.new")}
|
||||
>
|
||||
<Icon name="edit" />
|
||||
<Icon name="edit" class="shrink-0" />
|
||||
<span class="min-w-0 truncate">{language.t("command.session.new")}</span>
|
||||
<span
|
||||
class="ms-auto shrink-0 whitespace-nowrap text-v2-text-text-faint opacity-0 group-hover:opacity-100 group-focus-visible:opacity-100"
|
||||
class="ms-auto hidden min-w-0 truncate text-v2-text-text-faint group-hover:block group-focus-visible:block"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<bdi dir="ltr">{command.keybind("tab.new")}</bdi>
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { QueryClient } from "@tanstack/solid-query"
|
||||
import type { WorktreeDirectory } from "@opencode-ai/client/promise"
|
||||
import { createWorktreeInventory, withWorktreeInventory, worktreeInventoryKey } from "./inventory"
|
||||
import { ServerScope } from "@/runtime/server/scope"
|
||||
import { normalizeProjectInfo, updateProjectInfo } from "@/runtime/server/global-sync/utils"
|
||||
|
||||
function setup(list: (directory: string) => Promise<WorktreeDirectory[]>) {
|
||||
const client = new QueryClient()
|
||||
const calls: string[] = []
|
||||
const updates: Array<[string, WorktreeDirectory[]]> = []
|
||||
const inventory = createWorktreeInventory({
|
||||
scope: ServerScope.local,
|
||||
queryClient: client,
|
||||
api: () => ({
|
||||
list: (input) => {
|
||||
const directory = input!.location!.directory!
|
||||
calls.push(directory)
|
||||
return list(directory)
|
||||
},
|
||||
}),
|
||||
updated: (directory, items) => updates.push([directory, items]),
|
||||
})
|
||||
return { client, calls, updates, inventory }
|
||||
}
|
||||
|
||||
describe("createWorktreeInventory", () => {
|
||||
test("loads once per project, shares in-flight work, and publishes the result", async () => {
|
||||
const gate = Promise.withResolvers<void>()
|
||||
const setupResult = setup(async (directory) => {
|
||||
await gate.promise
|
||||
return [{ directory }, { directory: `${directory}/feature`, strategy: "git" }]
|
||||
})
|
||||
const first = setupResult.inventory.load("/repo")
|
||||
const second = setupResult.inventory.load("/repo/")
|
||||
expect(setupResult.calls).toEqual(["/repo"])
|
||||
gate.resolve()
|
||||
expect(await first).toHaveLength(2)
|
||||
expect(await second).toHaveLength(2)
|
||||
await setupResult.inventory.load("/repo")
|
||||
expect(setupResult.calls).toEqual(["/repo"])
|
||||
expect(setupResult.updates).toEqual([
|
||||
["/repo", [{ directory: "/repo" }, { directory: "/repo/feature", strategy: "git" }]],
|
||||
])
|
||||
expect(setupResult.inventory.cached("/repo/")).toHaveLength(2)
|
||||
setupResult.client.clear()
|
||||
})
|
||||
|
||||
test("refreshes only inventories a view already loaded", async () => {
|
||||
const setupResult = setup(async (directory) => [{ directory }])
|
||||
await setupResult.inventory.refresh("/never-opened")
|
||||
expect(setupResult.calls).toEqual([])
|
||||
await setupResult.inventory.load("/opened")
|
||||
await setupResult.inventory.refresh("/opened")
|
||||
expect(setupResult.calls).toEqual(["/opened", "/opened"])
|
||||
setupResult.client.clear()
|
||||
})
|
||||
|
||||
test("a failed load is not cached and never rejects the caller", async () => {
|
||||
let fail = true
|
||||
const setupResult = setup(async (directory) => {
|
||||
if (fail) throw new Error("Location unavailable")
|
||||
return [{ directory }]
|
||||
})
|
||||
expect(await setupResult.inventory.load("/repo")).toBeUndefined()
|
||||
expect(setupResult.inventory.cached("/repo")).toBeUndefined()
|
||||
fail = false
|
||||
expect(await setupResult.inventory.load("/repo")).toEqual([{ directory: "/repo" }])
|
||||
expect(setupResult.calls).toEqual(["/repo", "/repo"])
|
||||
setupResult.client.clear()
|
||||
})
|
||||
|
||||
test("keys are partitioned by server and normalized by path", () => {
|
||||
const remote = "https://remote.example" as typeof ServerScope.local
|
||||
expect(worktreeInventoryKey(ServerScope.local, "C:\\Repo\\")).toEqual(
|
||||
worktreeInventoryKey(ServerScope.local, "C:/Repo"),
|
||||
)
|
||||
expect(worktreeInventoryKey(ServerScope.local, "/repo")).not.toEqual(worktreeInventoryKey(remote, "/repo"))
|
||||
})
|
||||
})
|
||||
|
||||
describe("withWorktreeInventory", () => {
|
||||
const metadata = {
|
||||
id: "project",
|
||||
canonical: "/repo",
|
||||
name: "Before",
|
||||
time: { created: 1, updated: 1 },
|
||||
sandboxes: [],
|
||||
}
|
||||
|
||||
test("derives the workspace list from the inventory, excluding the project root", () => {
|
||||
const worktrees = [
|
||||
{ directory: "/repo/" },
|
||||
{ directory: "/repo/feature", strategy: "git" },
|
||||
{ directory: "/elsewhere" },
|
||||
]
|
||||
expect(withWorktreeInventory(normalizeProjectInfo(metadata), worktrees)).toMatchObject({
|
||||
worktree: "/repo",
|
||||
sandboxes: ["/repo/feature", "/elsewhere"],
|
||||
worktrees,
|
||||
})
|
||||
})
|
||||
|
||||
test("leaves metadata untouched without an inventory and survives metadata updates", () => {
|
||||
const project = normalizeProjectInfo(metadata)
|
||||
expect(withWorktreeInventory(project, undefined)).toBe(project)
|
||||
const cached = [{ directory: "/repo" }, { directory: "/repo/feature", strategy: "git" }]
|
||||
const updated = updateProjectInfo(withWorktreeInventory(project, cached), { ...metadata, name: "After" })
|
||||
expect(withWorktreeInventory(updated, cached)).toMatchObject({ name: "After", sandboxes: ["/repo/feature"] })
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,58 @@
|
||||
import type { QueryClient } from "@tanstack/solid-query"
|
||||
import type { WorktreeDirectory } from "@opencode-ai/client/promise"
|
||||
import type { ServerApi } from "@/runtime/server/api"
|
||||
import type { ServerScope } from "@/runtime/server/scope"
|
||||
import type { Project } from "@/runtime/server/types"
|
||||
import { pathKey } from "./path-key"
|
||||
import { sameDirectory } from "./paths"
|
||||
|
||||
export function worktreeInventoryKey(scope: ServerScope, directory: string) {
|
||||
return [scope, "worktree", pathKey(directory)] as const
|
||||
}
|
||||
|
||||
// Project metadata arrives without worktrees; a loaded inventory supplies the workspace list.
|
||||
export function withWorktreeInventory(project: Project, worktrees: readonly WorktreeDirectory[] | undefined): Project {
|
||||
if (!worktrees) return project
|
||||
return {
|
||||
...project,
|
||||
worktrees: [...worktrees],
|
||||
sandboxes: worktrees
|
||||
.map((item) => item.directory)
|
||||
.filter((directory) => !sameDirectory(project.worktree, directory)),
|
||||
}
|
||||
}
|
||||
|
||||
// Listing a project's worktrees boots its Location on the server and runs discovery, so only
|
||||
// projects the user is looking at are loaded. Historical projects stay metadata-only.
|
||||
export function createWorktreeInventory(input: {
|
||||
scope: ServerScope
|
||||
queryClient: QueryClient
|
||||
api: () => Pick<ServerApi["worktree"], "list">
|
||||
updated: (directory: string, worktrees: WorktreeDirectory[]) => void
|
||||
}) {
|
||||
const options = (directory: string) => ({
|
||||
queryKey: worktreeInventoryKey(input.scope, directory),
|
||||
queryFn: () =>
|
||||
input
|
||||
.api()
|
||||
.list({ location: { directory } })
|
||||
.then((items) => {
|
||||
input.updated(directory, items)
|
||||
return items
|
||||
}),
|
||||
// `worktree.updated` and reconnect invalidation drive refreshes; time alone does not re-list.
|
||||
staleTime: Infinity,
|
||||
gcTime: Infinity,
|
||||
retry: false,
|
||||
})
|
||||
return {
|
||||
cached: (directory: string) =>
|
||||
input.queryClient.getQueryData<WorktreeDirectory[]>(worktreeInventoryKey(input.scope, directory)),
|
||||
load: (directory: string) => input.queryClient.fetchQuery(options(directory)).catch(() => undefined),
|
||||
// Only inventories some view already demanded are refreshed.
|
||||
refresh: (directory: string) => {
|
||||
if (!input.queryClient.getQueryState(worktreeInventoryKey(input.scope, directory))) return Promise.resolve()
|
||||
return input.queryClient.fetchQuery({ ...options(directory), staleTime: 0 }).catch(() => undefined)
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -3,7 +3,7 @@ import type { LocationGetOutput, LocationRef } from "@opencode-ai/client/promise
|
||||
import { retry } from "@opencode-ai/util/retry"
|
||||
import { type Accessor, createEffect, createMemo, onCleanup } from "solid-js"
|
||||
import { type LocationContext, useServerSDK } from "@/runtime/server/client"
|
||||
import { useData } from "@/runtime/server/current"
|
||||
import { useData, useServer } from "@/runtime/server/current"
|
||||
export type { LocationContext } from "@/runtime/server/client"
|
||||
|
||||
export type WorkspaceLocation = LocationContext & {
|
||||
@@ -15,6 +15,7 @@ const context = createSimpleContext({
|
||||
name: "Location",
|
||||
init: (props: { directory: string | Accessor<string>; workspaceID?: string | Accessor<string | undefined> }) => {
|
||||
const serverSDK = useServerSDK()
|
||||
const server = useServer()
|
||||
const data = useData()
|
||||
const ref = createMemo(
|
||||
() => ({
|
||||
@@ -40,6 +41,14 @@ const context = createSimpleContext({
|
||||
retryIf: () => !stale,
|
||||
}).catch(() => undefined)
|
||||
})
|
||||
createEffect(() => {
|
||||
const id = current()?.project.id
|
||||
if (!id || serverSDK.connection.status() !== "connected") return
|
||||
// Showing a Location is the demand for its project's worktree inventory (workspace styling, picker).
|
||||
// Key it by the metadata root so the result merges into the same global project record.
|
||||
const root = server.ctx.sync.data.project.find((project) => project.id === id)?.worktree
|
||||
if (root) void server.ctx.sync.worktrees.load(root)
|
||||
})
|
||||
|
||||
const location = createMemo(() => serverSDK.ensureDirSdkContext(current()?.directory ?? ref().directory))
|
||||
return createMemo<WorkspaceLocation>(() => ({
|
||||
|
||||
@@ -1032,16 +1032,17 @@ export function createData(config: CreateDataInput) {
|
||||
if (store.session.info[event.data.sessionID]) {
|
||||
setStore("session", "info", event.data.sessionID, "revert", undefined)
|
||||
}
|
||||
// The projector also deletes inbox items enqueued at or after the boundary without a cancel event.
|
||||
setStore(
|
||||
"session",
|
||||
"pending",
|
||||
event.data.sessionID,
|
||||
(store.session.pending[event.data.sessionID] ?? []).filter((item) => item.id < event.data.to),
|
||||
)
|
||||
// Inbox ordering is server-owned; IDs do not encode delivery order.
|
||||
result.session.pending.invalidate(event.data.sessionID)
|
||||
if (store.session.pending[event.data.sessionID]?.length)
|
||||
refresh(() => result.session.pending.sync(event.data.sessionID))
|
||||
message.update(event.data.sessionID, (draft, index) => {
|
||||
const position = draft.findIndex((item) => item.id >= event.data.to)
|
||||
if (position === -1) return
|
||||
const position = draft.findIndex((item) => item.id === event.data.to)
|
||||
if (position === -1) {
|
||||
result.session.message.invalidate(event.data.sessionID)
|
||||
refresh(() => result.session.message.sync(event.data.sessionID))
|
||||
return
|
||||
}
|
||||
for (const item of draft.splice(position)) index.delete(item.id)
|
||||
})
|
||||
return
|
||||
|
||||
@@ -98,7 +98,7 @@ ${body.phone ? `${body.phone}<br>` : ""}`.trim()
|
||||
return false
|
||||
}),
|
||||
AWS.sendEmail({
|
||||
to: "contact@anoma.ly",
|
||||
to: Resource.ENTERPRISE_SALES_INBOX_EMAIL.value,
|
||||
subject: `Enterprise Inquiry from ${body.name}`,
|
||||
body: emailContent,
|
||||
replyTo: body.email,
|
||||
|
||||
+147
-6
@@ -1,8 +1,8 @@
|
||||
{
|
||||
"version": "7",
|
||||
"dialect": "sqlite",
|
||||
"id": "be60f352-8da1-40e1-8d70-dc41121cfbc5",
|
||||
"prevIds": ["3fb67508-0196-4bae-b2bd-c08ece7583fd"],
|
||||
"id": "685a01ff-9b49-4789-a8a6-f1b5779a866c",
|
||||
"prevIds": ["be60f352-8da1-40e1-8d70-dc41121cfbc5"],
|
||||
"ddl": [
|
||||
{
|
||||
"name": "account_state",
|
||||
@@ -72,6 +72,10 @@
|
||||
"name": "session_v2",
|
||||
"entityType": "tables"
|
||||
},
|
||||
{
|
||||
"name": "timeline",
|
||||
"entityType": "tables"
|
||||
},
|
||||
{
|
||||
"name": "workspace",
|
||||
"entityType": "tables"
|
||||
@@ -940,6 +944,16 @@
|
||||
"entityType": "columns",
|
||||
"table": "session_message"
|
||||
},
|
||||
{
|
||||
"type": "text",
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": null,
|
||||
"generated": null,
|
||||
"name": "timeline_id",
|
||||
"entityType": "columns",
|
||||
"table": "session_message"
|
||||
},
|
||||
{
|
||||
"type": "text",
|
||||
"notNull": true,
|
||||
@@ -1070,6 +1084,16 @@
|
||||
"entityType": "columns",
|
||||
"table": "session_v2"
|
||||
},
|
||||
{
|
||||
"type": "text",
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": null,
|
||||
"generated": null,
|
||||
"name": "timeline_id",
|
||||
"entityType": "columns",
|
||||
"table": "session_v2"
|
||||
},
|
||||
{
|
||||
"type": "text",
|
||||
"notNull": true,
|
||||
@@ -1420,6 +1444,36 @@
|
||||
"entityType": "columns",
|
||||
"table": "session_v2"
|
||||
},
|
||||
{
|
||||
"type": "text",
|
||||
"notNull": false,
|
||||
"autoincrement": false,
|
||||
"default": null,
|
||||
"generated": null,
|
||||
"name": "id",
|
||||
"entityType": "columns",
|
||||
"table": "timeline"
|
||||
},
|
||||
{
|
||||
"type": "text",
|
||||
"notNull": false,
|
||||
"autoincrement": false,
|
||||
"default": null,
|
||||
"generated": null,
|
||||
"name": "base_id",
|
||||
"entityType": "columns",
|
||||
"table": "timeline"
|
||||
},
|
||||
{
|
||||
"type": "integer",
|
||||
"notNull": false,
|
||||
"autoincrement": false,
|
||||
"default": null,
|
||||
"generated": null,
|
||||
"name": "base_seq",
|
||||
"entityType": "columns",
|
||||
"table": "timeline"
|
||||
},
|
||||
{
|
||||
"type": "text",
|
||||
"notNull": false,
|
||||
@@ -1588,13 +1642,13 @@
|
||||
"table": "session_inbox"
|
||||
},
|
||||
{
|
||||
"columns": ["session_id"],
|
||||
"tableTo": "session_v2",
|
||||
"columns": ["timeline_id"],
|
||||
"tableTo": "timeline",
|
||||
"columnsTo": ["id"],
|
||||
"onUpdate": "NO ACTION",
|
||||
"onDelete": "CASCADE",
|
||||
"nameExplicit": false,
|
||||
"name": "fk_session_message_session_id_session_v2_id_fk",
|
||||
"name": "fk_session_message_timeline_id_timeline_id_fk",
|
||||
"entityType": "fks",
|
||||
"table": "session_message"
|
||||
},
|
||||
@@ -1609,6 +1663,17 @@
|
||||
"entityType": "fks",
|
||||
"table": "session_pending"
|
||||
},
|
||||
{
|
||||
"columns": ["timeline_id"],
|
||||
"tableTo": "timeline",
|
||||
"columnsTo": ["id"],
|
||||
"onUpdate": "NO ACTION",
|
||||
"onDelete": "NO ACTION",
|
||||
"nameExplicit": false,
|
||||
"name": "fk_session_v2_timeline_id_timeline_id_fk",
|
||||
"entityType": "fks",
|
||||
"table": "session_v2"
|
||||
},
|
||||
{
|
||||
"columns": ["project_id"],
|
||||
"tableTo": "project",
|
||||
@@ -1620,6 +1685,17 @@
|
||||
"entityType": "fks",
|
||||
"table": "session_v2"
|
||||
},
|
||||
{
|
||||
"columns": ["base_id"],
|
||||
"tableTo": "timeline",
|
||||
"columnsTo": ["id"],
|
||||
"onUpdate": "NO ACTION",
|
||||
"onDelete": "NO ACTION",
|
||||
"nameExplicit": false,
|
||||
"name": "fk_timeline_base_id_timeline_id_fk",
|
||||
"entityType": "fks",
|
||||
"table": "timeline"
|
||||
},
|
||||
{
|
||||
"columns": ["project_id"],
|
||||
"tableTo": "project",
|
||||
@@ -1757,6 +1833,13 @@
|
||||
"table": "session_v2",
|
||||
"entityType": "pks"
|
||||
},
|
||||
{
|
||||
"columns": ["id"],
|
||||
"nameExplicit": false,
|
||||
"name": "timeline_pk",
|
||||
"table": "timeline",
|
||||
"entityType": "pks"
|
||||
},
|
||||
{
|
||||
"columns": ["id"],
|
||||
"nameExplicit": false,
|
||||
@@ -1877,13 +1960,71 @@
|
||||
"isExpression": false
|
||||
}
|
||||
],
|
||||
"isUnique": true,
|
||||
"isUnique": false,
|
||||
"where": null,
|
||||
"origin": "manual",
|
||||
"name": "session_message_session_seq_idx",
|
||||
"entityType": "indexes",
|
||||
"table": "session_message"
|
||||
},
|
||||
{
|
||||
"columns": [
|
||||
{
|
||||
"value": "timeline_id",
|
||||
"isExpression": false
|
||||
},
|
||||
{
|
||||
"value": "seq",
|
||||
"isExpression": false
|
||||
}
|
||||
],
|
||||
"isUnique": true,
|
||||
"where": null,
|
||||
"origin": "manual",
|
||||
"name": "session_message_timeline_seq_idx",
|
||||
"entityType": "indexes",
|
||||
"table": "session_message"
|
||||
},
|
||||
{
|
||||
"columns": [
|
||||
{
|
||||
"value": "timeline_id",
|
||||
"isExpression": false
|
||||
},
|
||||
{
|
||||
"value": "type",
|
||||
"isExpression": false
|
||||
},
|
||||
{
|
||||
"value": "seq",
|
||||
"isExpression": false
|
||||
}
|
||||
],
|
||||
"isUnique": false,
|
||||
"where": null,
|
||||
"origin": "manual",
|
||||
"name": "session_message_timeline_type_seq_idx",
|
||||
"entityType": "indexes",
|
||||
"table": "session_message"
|
||||
},
|
||||
{
|
||||
"columns": [
|
||||
{
|
||||
"value": "timeline_id",
|
||||
"isExpression": false
|
||||
},
|
||||
{
|
||||
"value": "seq",
|
||||
"isExpression": false
|
||||
}
|
||||
],
|
||||
"isUnique": false,
|
||||
"where": "((\"session_message\".\"type\" = 'assistant' AND json_extract(\"session_message\".\"data\", '$.time.completed') IS NULL)\n OR (\"session_message\".\"type\" IN ('shell', 'compaction') AND json_extract(\"session_message\".\"data\", '$.status') = 'running'))",
|
||||
"origin": "manual",
|
||||
"name": "session_message_unsettled_idx",
|
||||
"entityType": "indexes",
|
||||
"table": "session_message"
|
||||
},
|
||||
{
|
||||
"columns": [
|
||||
{
|
||||
|
||||
@@ -206,7 +206,7 @@ function evaluateTemplate(
|
||||
if (position === last) return args.slice(argIndex).join(" ")
|
||||
return args[argIndex]
|
||||
})
|
||||
const withArguments = expanded.replaceAll("$ARGUMENTS", () => input)
|
||||
const withArguments = expanded.replaceAll("$ARGUMENTS", input)
|
||||
const text =
|
||||
placeholders.length === 0 && !template.includes("$ARGUMENTS") && input.trim()
|
||||
? `${withArguments}\n\n${input}`.trim()
|
||||
|
||||
+2
@@ -45,6 +45,7 @@ import m42 from "./migration/20260812181746_session_inbox.js"
|
||||
import m43 from "./migration/20260812213948_worktree.js"
|
||||
import m44 from "./migration/20260819222447_session_viewed_state.js"
|
||||
import m45 from "./migration/20260823191254_nullable_workspace_binding.js"
|
||||
import m46 from "./migration/20260906003536_timeline.js"
|
||||
|
||||
export const migrations = [
|
||||
m00,
|
||||
@@ -93,4 +94,5 @@ export const migrations = [
|
||||
m43,
|
||||
m44,
|
||||
m45,
|
||||
m46,
|
||||
] satisfies DatabaseMigration.Migration[]
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
import { Effect } from "effect"
|
||||
import type { DatabaseMigration } from "../migration.js"
|
||||
|
||||
// Each existing Session, including copied forks, starts with an independent root.
|
||||
// Rebuilds preserve IDs, payloads, sequence numbers and timestamps. Session dependents
|
||||
// are evacuated inside this transaction so engines with mandatory FK cascades are safe.
|
||||
const migration: DatabaseMigration.Migration = {
|
||||
id: "20260906003536_timeline",
|
||||
up(tx) {
|
||||
return Effect.gen(function* () {
|
||||
yield* tx.run(`CREATE TABLE \`timeline\` (
|
||||
\`id\` text PRIMARY KEY,
|
||||
\`base_id\` text,
|
||||
\`base_seq\` integer,
|
||||
CONSTRAINT \`fk_timeline_base_id_timeline_id_fk\` FOREIGN KEY (\`base_id\`) REFERENCES \`timeline\`(\`id\`)
|
||||
);`)
|
||||
// Use the small Session table to map existing messages to independent roots.
|
||||
// The replacement table below enforces NOT NULL on the backfilled IDs.
|
||||
yield* tx.run(`ALTER TABLE session_v2 ADD timeline_id text`)
|
||||
yield* tx.run(`UPDATE session_v2 SET timeline_id = 'tml_' || lower(hex(randomblob(16)))`)
|
||||
yield* tx.run(`INSERT INTO timeline (id) SELECT timeline_id FROM session_v2`)
|
||||
yield* tx.run(`CREATE TABLE \`__new_session_message\` (
|
||||
\`id\` text PRIMARY KEY,
|
||||
\`session_id\` text NOT NULL,
|
||||
\`timeline_id\` text NOT NULL,
|
||||
\`type\` text NOT NULL,
|
||||
\`seq\` integer NOT NULL,
|
||||
\`time_created\` integer NOT NULL,
|
||||
\`time_updated\` integer NOT NULL,
|
||||
\`data\` text NOT NULL,
|
||||
CONSTRAINT \`fk_session_message_timeline_id_timeline_id_fk\` FOREIGN KEY (\`timeline_id\`) REFERENCES \`timeline\`(\`id\`) ON DELETE CASCADE
|
||||
);`)
|
||||
yield* tx.run(
|
||||
`INSERT INTO \`__new_session_message\` (\`id\`, \`session_id\`, \`timeline_id\`, \`type\`, \`seq\`, \`time_created\`, \`time_updated\`, \`data\`) SELECT \`id\`, \`session_id\`, (SELECT timeline_id FROM session_v2 WHERE session_v2.id = session_message.session_id), \`type\`, \`seq\`, \`time_created\`, \`time_updated\`, \`data\` FROM \`session_message\``,
|
||||
)
|
||||
yield* tx.run(`DROP TABLE \`session_message\``)
|
||||
yield* tx.run(`ALTER TABLE \`__new_session_message\` RENAME TO \`session_message\``)
|
||||
yield* tx.run(`CREATE TABLE __timeline_instruction_entry AS SELECT * FROM instruction_entry`)
|
||||
yield* tx.run(`DELETE FROM instruction_entry`)
|
||||
yield* tx.run(`CREATE TABLE __timeline_instruction_state AS SELECT * FROM instruction_state`)
|
||||
yield* tx.run(`DELETE FROM instruction_state`)
|
||||
yield* tx.run(`CREATE TABLE __timeline_session_inbox AS SELECT * FROM session_inbox`)
|
||||
yield* tx.run(`DELETE FROM session_inbox`)
|
||||
yield* tx.run(`CREATE TABLE __timeline_session_pending AS SELECT * FROM session_pending`)
|
||||
yield* tx.run(`DELETE FROM session_pending`)
|
||||
yield* tx.run(`CREATE TABLE \`__new_session_v2\` (
|
||||
\`id\` text PRIMARY KEY,
|
||||
\`timeline_id\` text NOT NULL,
|
||||
\`project_id\` text NOT NULL,
|
||||
\`workspace_id\` text,
|
||||
\`parent_id\` text,
|
||||
\`fork_session_id\` text,
|
||||
\`fork_boundary\` text,
|
||||
\`slug\` text NOT NULL,
|
||||
\`directory\` text NOT NULL,
|
||||
\`path\` text,
|
||||
\`title\` text,
|
||||
\`version\` text NOT NULL,
|
||||
\`share_url\` text,
|
||||
\`summary_additions\` integer,
|
||||
\`summary_deletions\` integer,
|
||||
\`summary_files\` integer,
|
||||
\`summary_diffs\` text,
|
||||
\`metadata\` text,
|
||||
\`cost\` real DEFAULT 0 NOT NULL,
|
||||
\`tokens_input\` integer DEFAULT 0 NOT NULL,
|
||||
\`tokens_output\` integer DEFAULT 0 NOT NULL,
|
||||
\`tokens_reasoning\` integer DEFAULT 0 NOT NULL,
|
||||
\`tokens_cache_read\` integer DEFAULT 0 NOT NULL,
|
||||
\`tokens_cache_write\` integer DEFAULT 0 NOT NULL,
|
||||
\`revert\` text,
|
||||
\`permission\` text,
|
||||
\`agent\` text,
|
||||
\`model\` text,
|
||||
\`time_created\` integer NOT NULL,
|
||||
\`time_updated\` integer NOT NULL,
|
||||
\`time_idle\` integer,
|
||||
\`time_viewed\` integer,
|
||||
\`idle_outcome\` text,
|
||||
\`time_compacting\` integer,
|
||||
\`time_archived\` integer,
|
||||
\`time_suspended\` integer,
|
||||
\`resume_attempts\` integer DEFAULT 0 NOT NULL,
|
||||
CONSTRAINT \`fk_session_v2_timeline_id_timeline_id_fk\` FOREIGN KEY (\`timeline_id\`) REFERENCES \`timeline\`(\`id\`),
|
||||
CONSTRAINT \`fk_session_v2_project_id_project_id_fk\` FOREIGN KEY (\`project_id\`) REFERENCES \`project\`(\`id\`) ON DELETE CASCADE
|
||||
);`)
|
||||
yield* tx.run(
|
||||
`INSERT INTO \`__new_session_v2\` (\`id\`, \`timeline_id\`, \`project_id\`, \`workspace_id\`, \`parent_id\`, \`fork_session_id\`, \`fork_boundary\`, \`slug\`, \`directory\`, \`path\`, \`title\`, \`version\`, \`share_url\`, \`summary_additions\`, \`summary_deletions\`, \`summary_files\`, \`summary_diffs\`, \`metadata\`, \`cost\`, \`tokens_input\`, \`tokens_output\`, \`tokens_reasoning\`, \`tokens_cache_read\`, \`tokens_cache_write\`, \`revert\`, \`permission\`, \`agent\`, \`model\`, \`time_created\`, \`time_updated\`, \`time_idle\`, \`time_viewed\`, \`idle_outcome\`, \`time_compacting\`, \`time_archived\`, \`time_suspended\`, \`resume_attempts\`) SELECT \`id\`, \`timeline_id\`, \`project_id\`, \`workspace_id\`, \`parent_id\`, \`fork_session_id\`, \`fork_boundary\`, \`slug\`, \`directory\`, \`path\`, \`title\`, \`version\`, \`share_url\`, \`summary_additions\`, \`summary_deletions\`, \`summary_files\`, \`summary_diffs\`, \`metadata\`, \`cost\`, \`tokens_input\`, \`tokens_output\`, \`tokens_reasoning\`, \`tokens_cache_read\`, \`tokens_cache_write\`, \`revert\`, \`permission\`, \`agent\`, \`model\`, \`time_created\`, \`time_updated\`, \`time_idle\`, \`time_viewed\`, \`idle_outcome\`, \`time_compacting\`, \`time_archived\`, \`time_suspended\`, \`resume_attempts\` FROM \`session_v2\``,
|
||||
)
|
||||
yield* tx.run(`DROP TABLE \`session_v2\``)
|
||||
yield* tx.run(`ALTER TABLE \`__new_session_v2\` RENAME TO \`session_v2\``)
|
||||
yield* tx.run(`INSERT INTO instruction_entry SELECT * FROM __timeline_instruction_entry`)
|
||||
yield* tx.run(`DROP TABLE __timeline_instruction_entry`)
|
||||
yield* tx.run(`INSERT INTO instruction_state SELECT * FROM __timeline_instruction_state`)
|
||||
yield* tx.run(`DROP TABLE __timeline_instruction_state`)
|
||||
yield* tx.run(`INSERT INTO session_inbox SELECT * FROM __timeline_session_inbox`)
|
||||
yield* tx.run(`DROP TABLE __timeline_session_inbox`)
|
||||
yield* tx.run(`INSERT INTO session_pending SELECT * FROM __timeline_session_pending`)
|
||||
yield* tx.run(`DROP TABLE __timeline_session_pending`)
|
||||
yield* tx.run(`CREATE INDEX \`session_message_session_seq_idx\` ON \`session_message\` (\`session_id\`,\`seq\`);`)
|
||||
yield* tx.run(
|
||||
`CREATE UNIQUE INDEX \`session_message_timeline_seq_idx\` ON \`session_message\` (\`timeline_id\`,\`seq\`);`,
|
||||
)
|
||||
yield* tx.run(
|
||||
`CREATE INDEX \`session_message_timeline_type_seq_idx\` ON \`session_message\` (\`timeline_id\`,\`type\`,\`seq\`);`,
|
||||
)
|
||||
yield* tx.run(`CREATE INDEX \`session_message_unsettled_idx\` ON \`session_message\` (\`timeline_id\`,\`seq\`) WHERE (("session_message"."type" = 'assistant' AND json_extract("session_message"."data", '$.time.completed') IS NULL)
|
||||
OR ("session_message"."type" IN ('shell', 'compaction') AND json_extract("session_message"."data", '$.status') = 'running'));`)
|
||||
yield* tx.run(
|
||||
`CREATE INDEX \`session_message_session_type_seq_idx\` ON \`session_message\` (\`session_id\`,\`type\`,\`seq\`);`,
|
||||
)
|
||||
yield* tx.run(
|
||||
`CREATE INDEX \`session_message_session_time_created_id_idx\` ON \`session_message\` (\`session_id\`,\`time_created\`,\`id\`);`,
|
||||
)
|
||||
yield* tx.run(`CREATE INDEX \`session_message_time_created_idx\` ON \`session_message\` (\`time_created\`);`)
|
||||
yield* tx.run(`CREATE INDEX \`session_v2_project_idx\` ON \`session_v2\` (\`project_id\`);`)
|
||||
yield* tx.run(`CREATE INDEX \`session_v2_workspace_idx\` ON \`session_v2\` (\`workspace_id\`);`)
|
||||
yield* tx.run(`CREATE INDEX \`session_v2_parent_idx\` ON \`session_v2\` (\`parent_id\`);`)
|
||||
yield* tx.run(
|
||||
`CREATE INDEX \`session_v2_time_suspended_idx\` ON \`session_v2\` (\`time_suspended\`) WHERE "session_v2"."time_suspended" is not null;`,
|
||||
)
|
||||
})
|
||||
},
|
||||
}
|
||||
|
||||
export default migration
|
||||
@@ -158,12 +158,13 @@ const schema: Omit<DatabaseMigration.Migration, "id"> = {
|
||||
CREATE TABLE \`session_message\` (
|
||||
\`id\` text PRIMARY KEY,
|
||||
\`session_id\` text NOT NULL,
|
||||
\`timeline_id\` text NOT NULL,
|
||||
\`type\` text NOT NULL,
|
||||
\`seq\` integer NOT NULL,
|
||||
\`time_created\` integer NOT NULL,
|
||||
\`time_updated\` integer NOT NULL,
|
||||
\`data\` text NOT NULL,
|
||||
CONSTRAINT \`fk_session_message_session_id_session_v2_id_fk\` FOREIGN KEY (\`session_id\`) REFERENCES \`session_v2\`(\`id\`) ON DELETE CASCADE
|
||||
CONSTRAINT \`fk_session_message_timeline_id_timeline_id_fk\` FOREIGN KEY (\`timeline_id\`) REFERENCES \`timeline\`(\`id\`) ON DELETE CASCADE
|
||||
);
|
||||
`)
|
||||
yield* tx.run(`
|
||||
@@ -181,6 +182,7 @@ const schema: Omit<DatabaseMigration.Migration, "id"> = {
|
||||
yield* tx.run(`
|
||||
CREATE TABLE \`session_v2\` (
|
||||
\`id\` text PRIMARY KEY,
|
||||
\`timeline_id\` text NOT NULL,
|
||||
\`project_id\` text NOT NULL,
|
||||
\`workspace_id\` text,
|
||||
\`parent_id\` text,
|
||||
@@ -216,9 +218,18 @@ const schema: Omit<DatabaseMigration.Migration, "id"> = {
|
||||
\`time_archived\` integer,
|
||||
\`time_suspended\` integer,
|
||||
\`resume_attempts\` integer DEFAULT 0 NOT NULL,
|
||||
CONSTRAINT \`fk_session_v2_timeline_id_timeline_id_fk\` FOREIGN KEY (\`timeline_id\`) REFERENCES \`timeline\`(\`id\`),
|
||||
CONSTRAINT \`fk_session_v2_project_id_project_id_fk\` FOREIGN KEY (\`project_id\`) REFERENCES \`project\`(\`id\`) ON DELETE CASCADE
|
||||
);
|
||||
`)
|
||||
yield* tx.run(`
|
||||
CREATE TABLE \`timeline\` (
|
||||
\`id\` text PRIMARY KEY,
|
||||
\`base_id\` text,
|
||||
\`base_seq\` integer,
|
||||
CONSTRAINT \`fk_timeline_base_id_timeline_id_fk\` FOREIGN KEY (\`base_id\`) REFERENCES \`timeline\`(\`id\`)
|
||||
);
|
||||
`)
|
||||
yield* tx.run(`
|
||||
CREATE TABLE \`workspace\` (
|
||||
\`id\` text PRIMARY KEY,
|
||||
@@ -249,9 +260,17 @@ const schema: Omit<DatabaseMigration.Migration, "id"> = {
|
||||
yield* tx.run(
|
||||
`CREATE UNIQUE INDEX \`session_inbox_session_enqueued_seq_idx\` ON \`session_inbox\` (\`session_id\`,\`enqueued_seq\`);`,
|
||||
)
|
||||
yield* tx.run(`CREATE INDEX \`session_message_session_seq_idx\` ON \`session_message\` (\`session_id\`,\`seq\`);`)
|
||||
yield* tx.run(
|
||||
`CREATE UNIQUE INDEX \`session_message_session_seq_idx\` ON \`session_message\` (\`session_id\`,\`seq\`);`,
|
||||
`CREATE UNIQUE INDEX \`session_message_timeline_seq_idx\` ON \`session_message\` (\`timeline_id\`,\`seq\`);`,
|
||||
)
|
||||
yield* tx.run(
|
||||
`CREATE INDEX \`session_message_timeline_type_seq_idx\` ON \`session_message\` (\`timeline_id\`,\`type\`,\`seq\`);`,
|
||||
)
|
||||
yield* tx.run(`
|
||||
CREATE INDEX \`session_message_unsettled_idx\` ON \`session_message\` (\`timeline_id\`,\`seq\`) WHERE (("session_message"."type" = 'assistant' AND json_extract("session_message"."data", '$.time.completed') IS NULL)
|
||||
OR ("session_message"."type" IN ('shell', 'compaction') AND json_extract("session_message"."data", '$.status') = 'running'));
|
||||
`)
|
||||
yield* tx.run(
|
||||
`CREATE INDEX \`session_message_session_type_seq_idx\` ON \`session_message\` (\`session_id\`,\`type\`,\`seq\`);`,
|
||||
)
|
||||
|
||||
@@ -10,10 +10,9 @@ import { KVTable } from "../kv/sql.js"
|
||||
import { EventSequenceTable } from "../event/sql.js"
|
||||
import { eq, sql } from "drizzle-orm"
|
||||
import { Global } from "@opencode-ai/util/global"
|
||||
import { existsSync } from "node:fs"
|
||||
import path from "node:path"
|
||||
import type { Database as SQLiteDatabase } from "bun:sqlite"
|
||||
import { Project } from "@opencode-ai/schema/project"
|
||||
import { Timeline } from "../session/timeline.js"
|
||||
|
||||
export type SourceMessage = {
|
||||
readonly id: string
|
||||
@@ -88,10 +87,6 @@ type RunResult = {
|
||||
readonly status: "completed"
|
||||
}
|
||||
|
||||
type Options = {
|
||||
readonly nextDatabasePath?: string
|
||||
}
|
||||
|
||||
type MigrationState = { readonly phase: "sessions"; readonly cursor?: string } | { readonly phase: "completed" }
|
||||
|
||||
type RuntimeState =
|
||||
@@ -99,118 +94,6 @@ type RuntimeState =
|
||||
| { readonly status: "running"; readonly progress: Progress }
|
||||
| { readonly status: "error"; readonly error: string }
|
||||
|
||||
type NextProject = {
|
||||
readonly id: string
|
||||
readonly worktree: string
|
||||
readonly vcs: string | null
|
||||
readonly name: string | null
|
||||
readonly icon_url: string | null
|
||||
readonly icon_url_override: string | null
|
||||
readonly icon_color: string | null
|
||||
readonly time_created: number
|
||||
readonly time_updated: number
|
||||
readonly time_initialized: number | null
|
||||
readonly sandboxes: string
|
||||
readonly commands: string | null
|
||||
}
|
||||
|
||||
type NextColumns<A> = Record<keyof A, "required" | "nullable" | { readonly fallback: keyof A & string }>
|
||||
|
||||
const NEXT_PROJECT_COLUMNS = {
|
||||
id: "required",
|
||||
worktree: "required",
|
||||
vcs: "nullable",
|
||||
name: "nullable",
|
||||
icon_url: "nullable",
|
||||
icon_url_override: { fallback: "icon_url" },
|
||||
icon_color: "nullable",
|
||||
time_created: "required",
|
||||
time_updated: "required",
|
||||
time_initialized: "nullable",
|
||||
sandboxes: "required",
|
||||
commands: "nullable",
|
||||
} satisfies NextColumns<NextProject>
|
||||
|
||||
type NextSession = {
|
||||
readonly id: string
|
||||
readonly project_id: string
|
||||
readonly workspace_id: string | null
|
||||
readonly parent_id: string | null
|
||||
readonly fork_session_id: string | null
|
||||
readonly fork_boundary: string | null
|
||||
readonly slug: string
|
||||
readonly directory: string
|
||||
readonly path: string | null
|
||||
readonly title: string | null
|
||||
readonly version: string
|
||||
readonly share_url: string | null
|
||||
readonly summary_additions: number | null
|
||||
readonly summary_deletions: number | null
|
||||
readonly summary_files: number | null
|
||||
readonly summary_diffs: string | null
|
||||
readonly metadata: string | null
|
||||
readonly cost: number
|
||||
readonly tokens_input: number
|
||||
readonly tokens_output: number
|
||||
readonly tokens_reasoning: number
|
||||
readonly tokens_cache_read: number
|
||||
readonly tokens_cache_write: number
|
||||
readonly revert: string | null
|
||||
readonly permission: string | null
|
||||
readonly agent: string | null
|
||||
readonly model: string | null
|
||||
readonly time_created: number
|
||||
readonly time_updated: number
|
||||
readonly time_compacting: number | null
|
||||
readonly time_archived: number | null
|
||||
readonly time_suspended: number | null
|
||||
}
|
||||
|
||||
const NEXT_SESSION_COLUMNS = {
|
||||
id: "required",
|
||||
project_id: "required",
|
||||
workspace_id: "nullable",
|
||||
parent_id: "nullable",
|
||||
fork_session_id: "nullable",
|
||||
fork_boundary: "nullable",
|
||||
slug: "required",
|
||||
directory: "required",
|
||||
path: "nullable",
|
||||
title: "nullable",
|
||||
version: "required",
|
||||
share_url: "nullable",
|
||||
summary_additions: "nullable",
|
||||
summary_deletions: "nullable",
|
||||
summary_files: "nullable",
|
||||
summary_diffs: "nullable",
|
||||
metadata: "nullable",
|
||||
cost: "required",
|
||||
tokens_input: "required",
|
||||
tokens_output: "required",
|
||||
tokens_reasoning: "required",
|
||||
tokens_cache_read: "required",
|
||||
tokens_cache_write: "required",
|
||||
revert: "nullable",
|
||||
permission: "nullable",
|
||||
agent: "nullable",
|
||||
model: "nullable",
|
||||
time_created: "required",
|
||||
time_updated: "required",
|
||||
time_compacting: "nullable",
|
||||
time_archived: "nullable",
|
||||
time_suspended: "nullable",
|
||||
} satisfies NextColumns<NextSession>
|
||||
|
||||
type NextMessage = {
|
||||
readonly id: string
|
||||
readonly session_id: string
|
||||
readonly type: string
|
||||
readonly seq: number
|
||||
readonly time_created: number
|
||||
readonly time_updated: number
|
||||
readonly data: string
|
||||
}
|
||||
|
||||
const lock = Semaphore.makeUnsafe(1)
|
||||
const MIGRATION_STATE_KEY = "migration.v1-v2"
|
||||
const EVENT_DELETE_BATCH_SIZE = 1_000
|
||||
@@ -513,7 +396,7 @@ function updateProgress(progress: Progress) {
|
||||
if (runtimeState.status === "running") runtimeState = { status: "running", progress }
|
||||
}
|
||||
|
||||
export function run(options: Options = {}): Effect.Effect<RunResult, never, Database.Service | Global.Service> {
|
||||
export function run(): Effect.Effect<RunResult, never, Database.Service | Global.Service> {
|
||||
return lock.withPermit(
|
||||
Effect.gen(function* () {
|
||||
const db = (yield* Database.Service).db
|
||||
@@ -546,7 +429,6 @@ export function run(options: Options = {}): Effect.Effect<RunResult, never, Data
|
||||
}),
|
||||
)
|
||||
.pipe(Effect.orDie)
|
||||
const sourceTotal = yield* countNextSessions(nextPath(options, global.data))
|
||||
const legacyTotal = (yield* db.get<{ value: number }>(sql`SELECT COUNT(*) AS value FROM session`))?.value ?? 0
|
||||
const cursor = state?.phase === "sessions" ? state.cursor : undefined
|
||||
const migrated =
|
||||
@@ -554,12 +436,7 @@ export function run(options: Options = {}): Effect.Effect<RunResult, never, Data
|
||||
? ((yield* db.get<{ value: number }>(sql`SELECT COUNT(*) AS value FROM session WHERE id >= ${cursor}`))
|
||||
?.value ?? 0)
|
||||
: 0
|
||||
const denominator = sourceTotal + legacyTotal
|
||||
updateProgress({ label: "Migrating sessions", numerator: migrated, denominator })
|
||||
yield* importNextDatabase(db, nextPath(options, global.data), (completed) => {
|
||||
updateProgress({ label: "Migrating sessions", numerator: migrated + completed, denominator })
|
||||
})
|
||||
updateProgress({ label: "Migrating sessions", numerator: migrated + sourceTotal, denominator })
|
||||
updateProgress({ label: "Migrating sessions", numerator: migrated, denominator: legacyTotal })
|
||||
const projects = new Set(
|
||||
(yield* db.all<{ id: string }>(sql`SELECT id FROM project`)).map((project) => project.id),
|
||||
)
|
||||
@@ -589,15 +466,21 @@ export function run(options: Options = {}): Effect.Effect<RunResult, never, Data
|
||||
sessionID: nextID.id,
|
||||
projectID: nextID.project_id,
|
||||
})
|
||||
const existing = yield* tx
|
||||
.select({ timelineID: SessionTable.timeline_id })
|
||||
.from(SessionTable)
|
||||
.where(eq(SessionTable.id, SessionSchema.ID.make(nextID.id)))
|
||||
.get()
|
||||
const timelineID = existing?.timelineID ?? (yield* Timeline.create(tx))
|
||||
yield* tx.run(sql`
|
||||
INSERT OR IGNORE INTO session_v2 (
|
||||
id, project_id, workspace_id, parent_id, slug, directory, path, title, version, share_url,
|
||||
id, timeline_id, project_id, workspace_id, parent_id, slug, directory, path, title, version, share_url,
|
||||
summary_additions, summary_deletions, summary_files, summary_diffs, metadata, cost,
|
||||
tokens_input, tokens_output, tokens_reasoning, tokens_cache_read, tokens_cache_write,
|
||||
revert, permission, agent, model, time_created, time_updated, time_compacting, time_archived
|
||||
)
|
||||
SELECT
|
||||
id, ${projectID}, workspace_id, parent_id, slug, directory, path, title, version, share_url,
|
||||
id, ${timelineID}, ${projectID}, workspace_id, parent_id, slug, directory, path, title, version, share_url,
|
||||
summary_additions, summary_deletions, summary_files, summary_diffs, metadata, cost,
|
||||
tokens_input, tokens_output, tokens_reasoning, tokens_cache_read, tokens_cache_write,
|
||||
revert, permission, agent, model, time_created, time_updated, time_compacting, time_archived
|
||||
@@ -627,6 +510,7 @@ export function run(options: Options = {}): Effect.Effect<RunResult, never, Data
|
||||
.values({
|
||||
id: SessionMessage.ID.make(message.id),
|
||||
session_id: SessionSchema.ID.make(message.session_id),
|
||||
timeline_id: next.timeline_id,
|
||||
type: message.type,
|
||||
seq: message.seq,
|
||||
time_created: message.time_created,
|
||||
@@ -657,7 +541,7 @@ export function run(options: Options = {}): Effect.Effect<RunResult, never, Data
|
||||
progress: {
|
||||
label: "Migrating sessions",
|
||||
numerator: (runtimeState.progress.numerator ?? 0) + 1,
|
||||
denominator,
|
||||
denominator: legacyTotal,
|
||||
},
|
||||
}
|
||||
yield* Effect.yieldNow
|
||||
@@ -681,182 +565,6 @@ export function run(options: Options = {}): Effect.Effect<RunResult, never, Data
|
||||
)
|
||||
}
|
||||
|
||||
function nextPath(options: Options, data: string) {
|
||||
if (options.nextDatabasePath) return options.nextDatabasePath
|
||||
if (process.env.OPENCODE_DB === ":memory:") return undefined
|
||||
return path.join(data, "opencode-next.db")
|
||||
}
|
||||
|
||||
function openNextDatabase(sourcePath: string) {
|
||||
return Effect.acquireRelease(
|
||||
Effect.gen(function* () {
|
||||
const sqlite = yield* Effect.promise(() => import("bun:sqlite"))
|
||||
return new sqlite.Database(sourcePath, { readonly: true, strict: true })
|
||||
}),
|
||||
(source) => Effect.sync(() => source.close()),
|
||||
)
|
||||
}
|
||||
|
||||
function countNextSessions(sourcePath: string | undefined) {
|
||||
if (!sourcePath || !existsSync(sourcePath)) return Effect.succeed(0)
|
||||
return Effect.scoped(
|
||||
Effect.gen(function* () {
|
||||
const source = yield* openNextDatabase(sourcePath)
|
||||
if (!isNextDatabase(source)) return 0
|
||||
return source.query<{ value: number }, []>("SELECT COUNT(*) AS value FROM session").get()?.value ?? 0
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
function importNextDatabase(
|
||||
db: Database.Interface["db"],
|
||||
sourcePath: string | undefined,
|
||||
onProgress: (completed: number) => void,
|
||||
): Effect.Effect<void, unknown> {
|
||||
if (!sourcePath || !existsSync(sourcePath)) return Effect.void
|
||||
return Effect.scoped(
|
||||
Effect.gen(function* () {
|
||||
const source = yield* openNextDatabase(sourcePath)
|
||||
if (!isNextDatabase(source)) {
|
||||
yield* Effect.logWarning("Skipped incompatible opencode-next.db", { path: sourcePath })
|
||||
return
|
||||
}
|
||||
source.run("BEGIN")
|
||||
yield* Effect.addFinalizer(() =>
|
||||
Effect.sync(() => {
|
||||
if (source.inTransaction) source.run("ROLLBACK")
|
||||
}),
|
||||
)
|
||||
const projects = new Map(
|
||||
selectNextRows<NextProject>(source, "project", NEXT_PROJECT_COLUMNS).map((project) => [project.id, project]),
|
||||
)
|
||||
const sessions = selectNextRows<NextSession>(source, "session", NEXT_SESSION_COLUMNS)
|
||||
for (const [index, session] of sessions.entries()) {
|
||||
const project = projects.get(session.project_id)
|
||||
const projectID = project ? session.project_id : Project.ID.global
|
||||
if (!project) {
|
||||
yield* Effect.logWarning("Reassigned previous V2 session with missing project", {
|
||||
sessionID: session.id,
|
||||
projectID: session.project_id,
|
||||
})
|
||||
}
|
||||
const messages = source
|
||||
.query<
|
||||
NextMessage,
|
||||
[string]
|
||||
>("SELECT id, session_id, type, seq, time_created, time_updated, data FROM session_message WHERE session_id = ? ORDER BY seq")
|
||||
.all(session.id)
|
||||
yield* db
|
||||
.transaction((tx) =>
|
||||
Effect.gen(function* () {
|
||||
if (project)
|
||||
yield* tx.run(sql`
|
||||
INSERT OR IGNORE INTO project (
|
||||
id, worktree, vcs, name, icon_url, icon_url_override, icon_color,
|
||||
time_created, time_updated, time_initialized, sandboxes, commands
|
||||
) VALUES (
|
||||
${project.id}, ${project.worktree}, ${project.vcs}, ${project.name}, ${project.icon_url},
|
||||
${project.icon_url_override}, ${project.icon_color}, ${project.time_created}, ${project.time_updated},
|
||||
${project.time_initialized}, ${project.sandboxes}, ${project.commands}
|
||||
)
|
||||
`)
|
||||
const existing = yield* tx
|
||||
.select({ id: SessionTable.id })
|
||||
.from(SessionTable)
|
||||
.where(eq(SessionTable.id, SessionSchema.ID.make(session.id)))
|
||||
.get()
|
||||
if (existing) return
|
||||
yield* tx.run(sql`
|
||||
INSERT INTO session_v2 (
|
||||
id, project_id, workspace_id, parent_id, fork_session_id, fork_boundary, slug, directory,
|
||||
path, title, version, share_url, summary_additions, summary_deletions, summary_files,
|
||||
summary_diffs, metadata, cost, tokens_input, tokens_output, tokens_reasoning, tokens_cache_read,
|
||||
tokens_cache_write, revert, permission, agent, model, time_created, time_updated, time_compacting,
|
||||
time_archived, time_suspended
|
||||
) VALUES (
|
||||
${session.id}, ${projectID}, ${session.workspace_id}, ${session.parent_id},
|
||||
${session.fork_session_id}, ${session.fork_boundary}, ${session.slug}, ${session.directory},
|
||||
${session.path}, ${session.title}, ${session.version}, ${session.share_url},
|
||||
${session.summary_additions}, ${session.summary_deletions}, ${session.summary_files},
|
||||
${session.summary_diffs}, ${session.metadata}, ${session.cost}, ${session.tokens_input},
|
||||
${session.tokens_output}, ${session.tokens_reasoning}, ${session.tokens_cache_read},
|
||||
${session.tokens_cache_write}, ${session.revert}, ${session.permission}, ${session.agent},
|
||||
${session.model}, ${session.time_created}, ${session.time_updated}, ${session.time_compacting},
|
||||
${session.time_archived}, ${session.time_suspended}
|
||||
)
|
||||
`)
|
||||
yield* Effect.forEach(messages, (message) =>
|
||||
tx
|
||||
.insert(SessionMessageTable)
|
||||
.values({
|
||||
id: SessionMessage.ID.make(message.id),
|
||||
session_id: SessionSchema.ID.make(message.session_id),
|
||||
type: message.type as SessionMessage.Type,
|
||||
seq: message.seq,
|
||||
time_created: message.time_created,
|
||||
time_updated: message.time_updated,
|
||||
data: sql`${message.data}`,
|
||||
})
|
||||
.run(),
|
||||
)
|
||||
yield* tx
|
||||
.insert(EventSequenceTable)
|
||||
.values({ aggregate_id: session.id, seq: messages.at(-1)?.seq ?? -1 })
|
||||
.onConflictDoUpdate({
|
||||
target: EventSequenceTable.aggregate_id,
|
||||
set: { seq: messages.at(-1)?.seq ?? -1, owner_id: null },
|
||||
})
|
||||
.run()
|
||||
}),
|
||||
)
|
||||
.pipe(Effect.orDie)
|
||||
onProgress(index + 1)
|
||||
yield* Effect.yieldNow
|
||||
}
|
||||
source.run("COMMIT")
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
function isNextDatabase(source: SQLiteDatabase) {
|
||||
const tables = new Set(
|
||||
source
|
||||
.query<{ name: string }, []>("SELECT name FROM sqlite_master WHERE type = 'table'")
|
||||
.all()
|
||||
.map((table) => table.name),
|
||||
)
|
||||
return tables.has("project") && tables.has("session") && tables.has("session_message")
|
||||
}
|
||||
|
||||
function selectNextRows<A>(source: SQLiteDatabase, table: "project" | "session", definition: NextColumns<A>) {
|
||||
const columns = new Set(
|
||||
source
|
||||
.query<{ name: string }, [string]>("SELECT name FROM pragma_table_info(?)")
|
||||
.all(table)
|
||||
.map((column) => column.name),
|
||||
)
|
||||
const missing = Object.entries(definition)
|
||||
.filter(([column, strategy]) => strategy === "required" && !columns.has(column))
|
||||
.map(([column]) => column)
|
||||
if (missing.length)
|
||||
throw new Error(`Incompatible opencode-next.db: ${table} is missing required columns: ${missing.join(", ")}`)
|
||||
const projection = Object.entries(definition).map(([column, strategy]) => {
|
||||
if (columns.has(column)) return `"${column}"`
|
||||
if (
|
||||
typeof strategy === "object" &&
|
||||
strategy !== null &&
|
||||
"fallback" in strategy &&
|
||||
typeof strategy.fallback === "string" &&
|
||||
columns.has(strategy.fallback)
|
||||
)
|
||||
return `"${strategy.fallback}" AS "${column}"`
|
||||
return `NULL AS "${column}"`
|
||||
})
|
||||
return source
|
||||
.query<A, []>(`SELECT ${projection.join(", ")} FROM "${table}"${table === "session" ? ' ORDER BY "id" DESC' : ""}`)
|
||||
.all()
|
||||
}
|
||||
|
||||
function row(
|
||||
source: SourceMessage,
|
||||
message: {
|
||||
|
||||
@@ -274,10 +274,12 @@ export const OpenAIPlugin = define({
|
||||
return
|
||||
}
|
||||
const apiID = draft.modelID ?? draft.id
|
||||
const match = apiID.match(/^gpt-(\d+\.\d+)/)
|
||||
const match = apiID.match(/^gpt-(\d+)(?:\.(\d+))?/)
|
||||
const major = Number(match?.[1])
|
||||
const minor = Number(match?.[2] ?? 0)
|
||||
if (
|
||||
!codexAllowed.has(apiID) &&
|
||||
(codexDisallowed.has(apiID) || !match || Number.parseFloat(match[1]) <= 5.4)
|
||||
(codexDisallowed.has(apiID) || !match || !(major > 5 || (major === 5 && minor > 4)))
|
||||
) {
|
||||
draft.enabled = false
|
||||
return
|
||||
|
||||
@@ -4,7 +4,7 @@ export * from "./session/schema.js"
|
||||
import { Effect, Layer, Schema, Context, Stream } from "effect"
|
||||
import { LLMClient } from "@opencode-ai/ai"
|
||||
import { ListAnchor } from "@opencode-ai/schema/session"
|
||||
import { and, desc, eq } from "drizzle-orm"
|
||||
import { eq } from "drizzle-orm"
|
||||
import { Project } from "./project.js"
|
||||
import { Model } from "@opencode-ai/schema/model"
|
||||
import { Location } from "./location.js"
|
||||
@@ -63,6 +63,7 @@ import { Job } from "./job.js"
|
||||
import type { Command } from "./command.js"
|
||||
import { SessionEnvironment } from "./session/environment.js"
|
||||
import { InstructionEntry } from "./session/instruction-entry.js"
|
||||
import { Timeline } from "./session/timeline.js"
|
||||
|
||||
// get project -> project.locations
|
||||
//
|
||||
@@ -292,19 +293,11 @@ const layer = Layer.effect(
|
||||
}),
|
||||
fork: Effect.fn("Session.fork")(function* (input) {
|
||||
const parent = yield* result.get(input.sessionID)
|
||||
const boundary = yield* db
|
||||
.select({ id: SessionMessageTable.id })
|
||||
.from(SessionMessageTable)
|
||||
.where(
|
||||
and(
|
||||
eq(SessionMessageTable.session_id, input.sessionID),
|
||||
input.boundary.type === "before" ? eq(SessionMessageTable.id, input.boundary.messageID) : undefined,
|
||||
),
|
||||
)
|
||||
.orderBy(desc(SessionMessageTable.seq))
|
||||
.limit(1)
|
||||
.get()
|
||||
.pipe(Effect.orDie)
|
||||
const ranges = yield* Timeline.forSession(db, input.sessionID)
|
||||
const [boundary] = yield* Timeline.rows(db, ranges, {
|
||||
where: input.boundary.type === "before" ? eq(SessionMessageTable.id, input.boundary.messageID) : undefined,
|
||||
limit: 1,
|
||||
})
|
||||
if (!boundary && input.boundary.type === "before")
|
||||
return yield* new MessageNotFoundError({
|
||||
sessionID: input.sessionID,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { and, asc, desc, eq, gte, sql } from "drizzle-orm"
|
||||
import { and, eq, gte, sql } from "drizzle-orm"
|
||||
import { Effect, Schema } from "effect"
|
||||
import { Database } from "../database/database.js"
|
||||
import { MessageDecodeError } from "./error.js"
|
||||
@@ -7,26 +7,25 @@ import { SessionSchema } from "./schema.js"
|
||||
import { Instructions } from "../instructions/index.js"
|
||||
import { InstructionState } from "./instruction-state.js"
|
||||
import { SessionMessageTable } from "./sql.js"
|
||||
import { Timeline } from "./timeline.js"
|
||||
|
||||
type DatabaseService = Database.Interface["db"]
|
||||
|
||||
const decode = Schema.decodeUnknownEffect(SessionMessage.Info)
|
||||
|
||||
export const latestCompaction = Effect.fnUntraced(function* (db: DatabaseService, sessionID: SessionSchema.ID) {
|
||||
return yield* db
|
||||
.select({ seq: SessionMessageTable.seq })
|
||||
.from(SessionMessageTable)
|
||||
.where(
|
||||
and(
|
||||
eq(SessionMessageTable.session_id, sessionID),
|
||||
eq(SessionMessageTable.type, "compaction"),
|
||||
sql`json_extract(${SessionMessageTable.data}, '$.status') = 'completed'`,
|
||||
),
|
||||
)
|
||||
.orderBy(desc(SessionMessageTable.seq))
|
||||
.limit(1)
|
||||
.get()
|
||||
.pipe(Effect.orDie)
|
||||
export const latestCompaction = Effect.fnUntraced(function* (
|
||||
db: DatabaseService,
|
||||
sessionID: SessionSchema.ID,
|
||||
ranges?: readonly Timeline.Range[],
|
||||
) {
|
||||
const [row] = yield* Timeline.rows(db, ranges ?? (yield* Timeline.forSession(db, sessionID)), {
|
||||
where: and(
|
||||
eq(SessionMessageTable.type, "compaction"),
|
||||
sql`json_extract(${SessionMessageTable.data}, '$.status') = 'completed'`,
|
||||
),
|
||||
limit: 1,
|
||||
})
|
||||
return row
|
||||
})
|
||||
|
||||
export const decodeMessageRow = (row: typeof SessionMessageTable.$inferSelect) =>
|
||||
@@ -41,19 +40,12 @@ export const decodeMessageRow = (row: typeof SessionMessageTable.$inferSelect) =
|
||||
)
|
||||
|
||||
const messageEntries = Effect.fnUntraced(function* (db: DatabaseService, sessionID: SessionSchema.ID) {
|
||||
const compaction = yield* latestCompaction(db, sessionID)
|
||||
const rows = yield* db
|
||||
.select()
|
||||
.from(SessionMessageTable)
|
||||
.where(
|
||||
and(
|
||||
eq(SessionMessageTable.session_id, sessionID),
|
||||
compaction ? gte(SessionMessageTable.seq, compaction.seq) : undefined,
|
||||
),
|
||||
)
|
||||
.orderBy(asc(SessionMessageTable.seq))
|
||||
.all()
|
||||
.pipe(Effect.orDie)
|
||||
const ranges = yield* Timeline.forSession(db, sessionID)
|
||||
const compaction = yield* latestCompaction(db, sessionID, ranges)
|
||||
const rows = yield* Timeline.rows(db, ranges, {
|
||||
where: compaction ? gte(SessionMessageTable.seq, compaction.seq) : undefined,
|
||||
order: "asc",
|
||||
})
|
||||
return yield* Effect.forEach(rows, (row) =>
|
||||
decodeMessageRow(row).pipe(Effect.map((message) => ({ seq: row.seq, message }))),
|
||||
)
|
||||
@@ -112,13 +104,11 @@ export const firstUserMessage = Effect.fn("SessionHistory.firstUserMessage")(fun
|
||||
db: DatabaseService,
|
||||
sessionID: SessionSchema.ID,
|
||||
) {
|
||||
const row = yield* db
|
||||
.select()
|
||||
.from(SessionMessageTable)
|
||||
.where(and(eq(SessionMessageTable.session_id, sessionID), eq(SessionMessageTable.type, "user")))
|
||||
.orderBy(asc(SessionMessageTable.seq))
|
||||
.get()
|
||||
.pipe(Effect.orDie)
|
||||
const [row] = yield* Timeline.rows(db, yield* Timeline.forSession(db, sessionID), {
|
||||
where: eq(SessionMessageTable.type, "user"),
|
||||
order: "asc",
|
||||
limit: 1,
|
||||
})
|
||||
if (!row) return undefined
|
||||
const message = yield* decodeMessageRow(row).pipe(Effect.orElseSucceed(() => undefined))
|
||||
return message?.type === "user" ? message : undefined
|
||||
|
||||
@@ -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, desc, eq, gt, gte, inArray, isNull, lt, lte, not, or, sql } from "drizzle-orm"
|
||||
import { DateTime, Effect, Layer, Schema, Stream } from "effect"
|
||||
import path from "path"
|
||||
import { Database } from "../database/database.js"
|
||||
@@ -14,7 +14,7 @@ import { SessionMessageUpdater } from "./message-updater.js"
|
||||
import { SessionInbox } from "./inbox.js"
|
||||
import { Workspace } from "@opencode-ai/schema/workspace"
|
||||
import { InstructionState } from "./instruction-state.js"
|
||||
import { SessionInboxTable, SessionMessageTable, SessionTable } from "./sql.js"
|
||||
import { SessionInboxTable, SessionMessageTable, SessionTable, unsettled } from "./sql.js"
|
||||
import { InstructionEntry } from "./instruction-entry.js"
|
||||
import { Slug } from "../util/slug.js"
|
||||
import { FSUtil } from "@opencode-ai/util/fs-util"
|
||||
@@ -24,6 +24,7 @@ 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"
|
||||
import { Timeline } from "./timeline.js"
|
||||
|
||||
type DatabaseService = Database.Interface["db"]
|
||||
type MessageEvent = Exclude<
|
||||
@@ -115,39 +116,30 @@ const projectFork = Effect.fn("SessionProjector.projectFork")(function* (
|
||||
.pipe(Effect.orDie)
|
||||
if (!parent) return yield* Effect.die(new Error(`Fork parent session not found: ${event.data.parentID}`))
|
||||
const boundary = yield* db
|
||||
.select({ seq: SessionMessageTable.seq })
|
||||
.select()
|
||||
.from(SessionMessageTable)
|
||||
.where(
|
||||
and(
|
||||
eq(SessionMessageTable.session_id, event.data.parentID),
|
||||
eq(SessionMessageTable.id, event.data.boundary.messageID),
|
||||
),
|
||||
)
|
||||
.where(eq(SessionMessageTable.id, event.data.boundary.messageID))
|
||||
.get()
|
||||
.pipe(Effect.orDie)
|
||||
if (!boundary)
|
||||
return yield* Effect.die(new Error(`Fork boundary message not found: ${event.data.boundary.messageID}`))
|
||||
const copied = yield* db
|
||||
.select({ seq: SessionMessageTable.seq })
|
||||
.from(SessionMessageTable)
|
||||
.where(
|
||||
and(
|
||||
eq(SessionMessageTable.session_id, event.data.parentID),
|
||||
event.data.boundary.type === "before"
|
||||
? lt(SessionMessageTable.seq, boundary.seq)
|
||||
: lte(SessionMessageTable.seq, boundary.seq),
|
||||
),
|
||||
)
|
||||
.orderBy(desc(SessionMessageTable.seq))
|
||||
.limit(1)
|
||||
.get()
|
||||
.pipe(Effect.orDie)
|
||||
const ranges = yield* Timeline.ranges(db, boundary.timeline_id)
|
||||
const end = boundary.seq + (event.data.boundary.type === "through" ? 1 : 0)
|
||||
const [copied] = yield* Timeline.rows(db, ranges, { where: lt(SessionMessageTable.seq, end), limit: 1 })
|
||||
const copiedSeq = copied?.seq
|
||||
const [active] = yield* Timeline.rows(db, ranges, {
|
||||
where: and(lt(SessionMessageTable.seq, end), unsettled(SessionMessageTable)),
|
||||
order: "asc",
|
||||
limit: 1,
|
||||
})
|
||||
const base = yield* Timeline.prefix(db, ranges, active?.seq ?? end)
|
||||
const timelineID = yield* Timeline.create(db, base)
|
||||
|
||||
const stored = yield* db
|
||||
.insert(SessionTable)
|
||||
.values({
|
||||
id: event.data.sessionID,
|
||||
timeline_id: timelineID,
|
||||
parent_id: null,
|
||||
fork_session_id: event.data.parentID,
|
||||
fork_boundary: event.data.boundary,
|
||||
@@ -179,26 +171,19 @@ const projectFork = Effect.fn("SessionProjector.projectFork")(function* (
|
||||
if (event.data.instructionEntries)
|
||||
yield* InstructionEntry.initialize(db, event.data.sessionID, event.data.instructionEntries, event.created)
|
||||
|
||||
let cursor = -1
|
||||
while (copiedSeq !== undefined) {
|
||||
const rows = yield* db
|
||||
.select()
|
||||
.from(SessionMessageTable)
|
||||
.where(
|
||||
and(
|
||||
eq(SessionMessageTable.session_id, event.data.parentID),
|
||||
gt(SessionMessageTable.seq, cursor),
|
||||
lt(SessionMessageTable.seq, copiedSeq + 1),
|
||||
// Terminal events for active projections stay on the parent, so forks copy only settled history.
|
||||
sql`${SessionMessageTable.type} != 'assistant' or json_extract(${SessionMessageTable.data}, '$.time.completed') is not null`,
|
||||
sql`${SessionMessageTable.type} != 'shell' or json_extract(${SessionMessageTable.data}, '$.status') != 'running'`,
|
||||
sql`${SessionMessageTable.type} != 'compaction' or json_extract(${SessionMessageTable.data}, '$.status') != 'running'`,
|
||||
),
|
||||
)
|
||||
.orderBy(asc(SessionMessageTable.seq))
|
||||
.limit(ForkBatchSize)
|
||||
.all()
|
||||
.pipe(Effect.orDie)
|
||||
// Active forks only copy the settled suffix after the first omitted message;
|
||||
// the preceding immutable prefix is shared.
|
||||
let cursor = (active?.seq ?? end) - 1
|
||||
while (cursor < (copiedSeq ?? -1)) {
|
||||
const rows = yield* Timeline.rows(db, ranges, {
|
||||
where: and(
|
||||
gt(SessionMessageTable.seq, cursor),
|
||||
lt(SessionMessageTable.seq, end),
|
||||
not(unsettled(SessionMessageTable)),
|
||||
),
|
||||
order: "asc",
|
||||
limit: ForkBatchSize,
|
||||
})
|
||||
if (rows.length === 0) break
|
||||
|
||||
yield* db
|
||||
@@ -207,6 +192,7 @@ const projectFork = Effect.fn("SessionProjector.projectFork")(function* (
|
||||
rows.map((row) => ({
|
||||
id: SessionMessage.ID.make(`${SessionMessage.ID.fromEvent(event.id)}_${row.seq}`),
|
||||
session_id: event.data.sessionID,
|
||||
timeline_id: timelineID,
|
||||
type: row.type,
|
||||
seq: row.seq,
|
||||
time_created: row.time_created,
|
||||
@@ -301,7 +287,10 @@ function run(db: DatabaseService, event: MessageEvent) {
|
||||
.select()
|
||||
.from(SessionMessageTable)
|
||||
.where(
|
||||
and(eq(SessionMessageTable.session_id, event.data.sessionID), eq(SessionMessageTable.type, "assistant")),
|
||||
and(
|
||||
eq(SessionMessageTable.timeline_id, Timeline.current(event.data.sessionID)),
|
||||
eq(SessionMessageTable.type, "assistant"),
|
||||
),
|
||||
)
|
||||
.orderBy(desc(SessionMessageTable.seq))
|
||||
.limit(1)
|
||||
@@ -359,7 +348,7 @@ function run(db: DatabaseService, event: MessageEvent) {
|
||||
.from(SessionMessageTable)
|
||||
.where(
|
||||
and(
|
||||
eq(SessionMessageTable.session_id, event.data.sessionID),
|
||||
eq(SessionMessageTable.timeline_id, Timeline.current(event.data.sessionID)),
|
||||
eq(SessionMessageTable.type, "compaction"),
|
||||
sql`json_extract(${SessionMessageTable.data}, '$.status') = 'running'`,
|
||||
),
|
||||
@@ -390,6 +379,7 @@ function insertMessage(db: DatabaseService, event: SessionEvent.DurableEvent, me
|
||||
.values({
|
||||
id: SessionMessage.ID.make(id),
|
||||
session_id: event.data.sessionID,
|
||||
timeline_id: Timeline.current(event.data.sessionID),
|
||||
type,
|
||||
seq: event.durable.seq,
|
||||
time_created: DateTime.toEpochMillis(message.time.created),
|
||||
@@ -436,10 +426,12 @@ const layer = Layer.effectDiscard(
|
||||
const db = (yield* Database.Service).db
|
||||
yield* bus.project(SessionEvent.Created, (event) =>
|
||||
Effect.gen(function* () {
|
||||
const timelineID = yield* Timeline.create(db)
|
||||
const stored = yield* db
|
||||
.insert(SessionTable)
|
||||
.values({
|
||||
id: event.data.sessionID,
|
||||
timeline_id: timelineID,
|
||||
project_id: event.data.projectID,
|
||||
workspace_id: event.data.location.workspaceID ? Workspace.ID.make(event.data.location.workspaceID) : null,
|
||||
parent_id: event.data.parentID,
|
||||
@@ -539,7 +531,10 @@ const layer = Layer.effectDiscard(
|
||||
}),
|
||||
)
|
||||
yield* bus.project(SessionEvent.Deleted, (event) =>
|
||||
db.delete(SessionTable).where(eq(SessionTable.id, event.data.sessionID)).run().pipe(Effect.orDie),
|
||||
Effect.gen(function* () {
|
||||
yield* db.delete(SessionTable).where(eq(SessionTable.id, event.data.sessionID)).run().pipe(Effect.orDie)
|
||||
yield* Timeline.collect(db)
|
||||
}),
|
||||
)
|
||||
yield* bus.project(SessionEvent.AgentSelected, (event) =>
|
||||
Effect.gen(function* () {
|
||||
@@ -720,20 +715,14 @@ const layer = Layer.effectDiscard(
|
||||
)
|
||||
yield* bus.project(SessionEvent.RevertEvent.Committed, (event) =>
|
||||
Effect.gen(function* () {
|
||||
const boundary = yield* db
|
||||
.select({ seq: SessionMessageTable.seq })
|
||||
.from(SessionMessageTable)
|
||||
.where(
|
||||
and(eq(SessionMessageTable.session_id, event.data.sessionID), eq(SessionMessageTable.id, event.data.to)),
|
||||
)
|
||||
.get()
|
||||
.pipe(Effect.orDie)
|
||||
const boundary = yield* Timeline.find(db, event.data.sessionID, event.data.to)
|
||||
if (!boundary) return yield* Effect.die(new Error(`Revert boundary message not found: ${event.data.to}`))
|
||||
const base = yield* Timeline.prefix(db, yield* Timeline.ranges(db, boundary.timeline_id), boundary.seq)
|
||||
const timelineID = yield* Timeline.create(db, base)
|
||||
yield* db
|
||||
.delete(SessionMessageTable)
|
||||
.where(
|
||||
and(eq(SessionMessageTable.session_id, event.data.sessionID), gte(SessionMessageTable.seq, boundary.seq)),
|
||||
)
|
||||
.update(SessionTable)
|
||||
.set({ timeline_id: timelineID, revert: null, time_updated: event.created })
|
||||
.where(eq(SessionTable.id, event.data.sessionID))
|
||||
.run()
|
||||
.pipe(Effect.orDie)
|
||||
yield* db
|
||||
@@ -746,12 +735,6 @@ const layer = Layer.effectDiscard(
|
||||
)
|
||||
.run()
|
||||
.pipe(Effect.orDie)
|
||||
yield* db
|
||||
.update(SessionTable)
|
||||
.set({ revert: null, time_updated: event.created })
|
||||
.where(eq(SessionTable.id, event.data.sessionID))
|
||||
.run()
|
||||
.pipe(Effect.orDie)
|
||||
yield* InstructionState.reset(db, event.data.sessionID)
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
export * as SessionRevert from "./revert.js"
|
||||
|
||||
import { and, asc, eq, gt } from "drizzle-orm"
|
||||
import { and, eq, gt } from "drizzle-orm"
|
||||
import { Effect, Schema } from "effect"
|
||||
import { Database } from "../database/database.js"
|
||||
import { Bus } from "../bus.js"
|
||||
@@ -12,6 +12,7 @@ import { MessageNotFoundError } from "./error.js"
|
||||
import { SessionMessage } from "./message.js"
|
||||
import { SessionSchema } from "./schema.js"
|
||||
import { SessionMessageTable } from "./sql.js"
|
||||
import { Timeline } from "./timeline.js"
|
||||
|
||||
export { MessageNotFoundError }
|
||||
|
||||
@@ -84,26 +85,12 @@ export const commit = Effect.fn("SessionRevert.commit")(function* (bus: Bus.Inte
|
||||
})
|
||||
|
||||
const plan = Effect.fn("SessionRevert.plan")(function* (db: Database.Interface["db"], input: BoundaryInput) {
|
||||
const boundary = yield* db
|
||||
.select({ seq: SessionMessageTable.seq })
|
||||
.from(SessionMessageTable)
|
||||
.where(and(eq(SessionMessageTable.session_id, input.sessionID), eq(SessionMessageTable.id, input.messageID)))
|
||||
.get()
|
||||
.pipe(Effect.orDie)
|
||||
const boundary = yield* Timeline.find(db, input.sessionID, input.messageID)
|
||||
if (!boundary) return yield* new MessageNotFoundError(input)
|
||||
const rows = yield* db
|
||||
.select()
|
||||
.from(SessionMessageTable)
|
||||
.where(
|
||||
and(
|
||||
eq(SessionMessageTable.session_id, input.sessionID),
|
||||
eq(SessionMessageTable.type, "assistant"),
|
||||
gt(SessionMessageTable.seq, boundary.seq),
|
||||
),
|
||||
)
|
||||
.orderBy(asc(SessionMessageTable.seq))
|
||||
.all()
|
||||
.pipe(Effect.orDie)
|
||||
const rows = yield* Timeline.rows(db, yield* Timeline.forSession(db, input.sessionID), {
|
||||
where: and(eq(SessionMessageTable.type, "assistant"), gt(SessionMessageTable.seq, boundary.seq)),
|
||||
order: "asc",
|
||||
})
|
||||
const decode = Schema.decodeUnknownEffect(SessionMessage.Info)
|
||||
const files = new Map<RelativePath, Snapshot.ID>()
|
||||
for (const row of rows) {
|
||||
|
||||
@@ -17,6 +17,7 @@ import { SessionMessage } from "../message.js"
|
||||
import { SessionSchema } from "../schema.js"
|
||||
import { SessionStore } from "../store.js"
|
||||
import { SessionMessageTable } from "../sql.js"
|
||||
import { Timeline } from "../timeline.js"
|
||||
import { SessionTitle } from "../title.js"
|
||||
import { DrainResult, Service, type Interface } from "./index.js"
|
||||
import { Snapshot } from "../../snapshot.js"
|
||||
@@ -293,7 +294,7 @@ const layer = Layer.effect(
|
||||
.from(SessionMessageTable)
|
||||
.where(
|
||||
and(
|
||||
eq(SessionMessageTable.session_id, sessionID),
|
||||
eq(SessionMessageTable.timeline_id, Timeline.current(sessionID)),
|
||||
eq(SessionMessageTable.type, "compaction"),
|
||||
sql`json_extract(${SessionMessageTable.data}, '$.status') = 'running'`,
|
||||
),
|
||||
|
||||
@@ -58,7 +58,7 @@ export const make = Effect.fn("Session.make")(function* () {
|
||||
return session
|
||||
})
|
||||
const message = Effect.fn("Session.message")(function* (sessionID: SessionSchema.ID, messageID: SessionMessage.ID) {
|
||||
const stored = yield* store.message(messageID)
|
||||
const stored = yield* store.message(messageID, sessionID)
|
||||
return stored?.sessionID === sessionID ? stored.message : undefined
|
||||
})
|
||||
const updateMessage = Effect.fn("Session.updateMessage")(function* (
|
||||
|
||||
@@ -1,5 +1,14 @@
|
||||
import { sqliteTable, text, integer, index, primaryKey, real, uniqueIndex } from "drizzle-orm/sqlite-core"
|
||||
import { sql } from "drizzle-orm"
|
||||
import {
|
||||
sqliteTable,
|
||||
text,
|
||||
integer,
|
||||
index,
|
||||
primaryKey,
|
||||
real,
|
||||
uniqueIndex,
|
||||
type AnySQLiteColumn,
|
||||
} from "drizzle-orm/sqlite-core"
|
||||
import { sql, type SQLWrapper } from "drizzle-orm"
|
||||
import { directoryColumn, pathColumn } from "../database/path.js"
|
||||
import { ProjectTable } from "../project/sql.js"
|
||||
import type { SessionMessage } from "./message.js"
|
||||
@@ -15,14 +24,27 @@ import type { Session } from "@opencode-ai/schema/session"
|
||||
import type { CompactionPayload, MovePayload, SyntheticPayload, UserPayload } from "@opencode-ai/schema/session-inbox"
|
||||
import type { RevertV1 } from "@opencode-ai/schema/session-revert"
|
||||
import type { Schema } from "effect"
|
||||
import type { Timeline } from "./timeline.js"
|
||||
|
||||
type DistributiveOmit<T, K extends PropertyKey> = T extends unknown ? Omit<T, K> : never
|
||||
type SessionMessageData = DistributiveOmit<(typeof SessionMessage.Info)["Encoded"], "type" | "id">
|
||||
|
||||
export const TimelineTable = sqliteTable("timeline", {
|
||||
id: text().$type<Timeline.ID>().primaryKey(),
|
||||
base_id: text()
|
||||
.$type<Timeline.ID>()
|
||||
.references((): AnySQLiteColumn => TimelineTable.id),
|
||||
base_seq: integer(),
|
||||
})
|
||||
|
||||
export const SessionTable = sqliteTable(
|
||||
"session_v2",
|
||||
{
|
||||
id: text().$type<SessionSchema.ID>().primaryKey(),
|
||||
timeline_id: text()
|
||||
.$type<Timeline.ID>()
|
||||
.notNull()
|
||||
.references(() => TimelineTable.id),
|
||||
project_id: text()
|
||||
.$type<Project.ID>()
|
||||
.notNull()
|
||||
@@ -80,23 +102,34 @@ export const SessionMessageTable = sqliteTable(
|
||||
"session_message",
|
||||
{
|
||||
id: text().$type<SessionMessage.ID>().primaryKey(),
|
||||
session_id: text()
|
||||
.$type<SessionSchema.ID>()
|
||||
// Provenance survives deletion of the originating session while forks still reference its history.
|
||||
session_id: text().$type<SessionSchema.ID>().notNull(),
|
||||
timeline_id: text()
|
||||
.$type<Timeline.ID>()
|
||||
.notNull()
|
||||
.references(() => SessionTable.id, { onDelete: "cascade" }),
|
||||
.references(() => TimelineTable.id, { onDelete: "cascade" }),
|
||||
type: text().$type<SessionMessage.Type>().notNull(),
|
||||
seq: integer().notNull(),
|
||||
...Timestamps,
|
||||
data: text({ mode: "json" }).notNull().$type<SessionMessageData>(),
|
||||
},
|
||||
(table) => [
|
||||
uniqueIndex("session_message_session_seq_idx").on(table.session_id, table.seq),
|
||||
// A restored Session ID may have retained messages in another timeline.
|
||||
index("session_message_session_seq_idx").on(table.session_id, table.seq),
|
||||
uniqueIndex("session_message_timeline_seq_idx").on(table.timeline_id, table.seq),
|
||||
index("session_message_timeline_type_seq_idx").on(table.timeline_id, table.type, table.seq),
|
||||
index("session_message_unsettled_idx").on(table.timeline_id, table.seq).where(unsettled(table)),
|
||||
index("session_message_session_type_seq_idx").on(table.session_id, table.type, table.seq),
|
||||
index("session_message_session_time_created_id_idx").on(table.session_id, table.time_created, table.id),
|
||||
index("session_message_time_created_idx").on(table.time_created),
|
||||
],
|
||||
)
|
||||
|
||||
export function unsettled(table: { type: SQLWrapper; data: SQLWrapper }) {
|
||||
return sql`((${table.type} = 'assistant' AND json_extract(${table.data}, '$.time.completed') IS NULL)
|
||||
OR (${table.type} IN ('shell', 'compaction') AND json_extract(${table.data}, '$.status') = 'running'))`
|
||||
}
|
||||
|
||||
export const SessionPendingTable = sqliteTable(
|
||||
"session_pending",
|
||||
{
|
||||
|
||||
@@ -13,6 +13,7 @@ import { SessionMessage } from "./message.js"
|
||||
import { Session } from "@opencode-ai/schema/session"
|
||||
import { SessionMessageTable, SessionTable } from "./sql.js"
|
||||
import { fromRow } from "./info.js"
|
||||
import { Timeline } from "./timeline.js"
|
||||
|
||||
const ListInputBase = {
|
||||
workspaceID: Workspace.ID.pipe(Schema.optional),
|
||||
@@ -56,6 +57,7 @@ export interface Interface {
|
||||
readonly context: (sessionID: Session.ID) => Effect.Effect<SessionMessage.Info[], MessageDecodeError>
|
||||
readonly message: (
|
||||
messageID: SessionMessage.ID,
|
||||
sessionID?: Session.ID,
|
||||
) => Effect.Effect<{ readonly sessionID: Session.ID; readonly message: SessionMessage.Info } | undefined>
|
||||
/**
|
||||
* Top-level Sessions holding an execution claim. Recoverable background
|
||||
@@ -140,49 +142,36 @@ const layer = Layer.effect(
|
||||
const direction = input.cursor?.direction ?? "next"
|
||||
const requestedOrder = input.order ?? "desc"
|
||||
const order = direction === "previous" ? (requestedOrder === "asc" ? "desc" : "asc") : requestedOrder
|
||||
const anchor = input.cursor
|
||||
? yield* db
|
||||
.select({ seq: SessionMessageTable.seq })
|
||||
.from(SessionMessageTable)
|
||||
.where(
|
||||
and(eq(SessionMessageTable.session_id, input.sessionID), eq(SessionMessageTable.id, input.cursor.id)),
|
||||
)
|
||||
.get()
|
||||
.pipe(Effect.orDie)
|
||||
: undefined
|
||||
const anchor = input.cursor ? yield* Timeline.find(db, input.sessionID, input.cursor.id) : undefined
|
||||
if (input.cursor && !anchor) return []
|
||||
const boundary = anchor
|
||||
? order === "asc"
|
||||
? gt(SessionMessageTable.seq, anchor.seq)
|
||||
: lt(SessionMessageTable.seq, anchor.seq)
|
||||
: undefined
|
||||
const where = boundary
|
||||
? and(eq(SessionMessageTable.session_id, input.sessionID), boundary)
|
||||
: eq(SessionMessageTable.session_id, input.sessionID)
|
||||
const query = db
|
||||
.select()
|
||||
.from(SessionMessageTable)
|
||||
.where(where)
|
||||
.orderBy(order === "asc" ? asc(SessionMessageTable.seq) : desc(SessionMessageTable.seq))
|
||||
const rows = yield* (input.limit === undefined ? query.all() : query.limit(input.limit).all()).pipe(
|
||||
Effect.orDie,
|
||||
)
|
||||
const rows = yield* Timeline.rows(db, yield* Timeline.forSession(db, input.sessionID), {
|
||||
where: boundary,
|
||||
order,
|
||||
limit: input.limit,
|
||||
})
|
||||
return yield* Effect.forEach(
|
||||
direction === "previous" ? rows.toReversed() : rows,
|
||||
SessionHistory.decodeMessageRow,
|
||||
)
|
||||
}),
|
||||
context: Effect.fn("SessionStore.context")((sessionID) => SessionHistory.load(db, sessionID)),
|
||||
message: Effect.fn("SessionStore.message")(function* (messageID) {
|
||||
const row = yield* db
|
||||
.select()
|
||||
.from(SessionMessageTable)
|
||||
.where(eq(SessionMessageTable.id, messageID))
|
||||
.get()
|
||||
.pipe(Effect.orDie)
|
||||
message: Effect.fn("SessionStore.message")(function* (messageID, sessionID) {
|
||||
const row = sessionID
|
||||
? yield* Timeline.find(db, sessionID, messageID)
|
||||
: yield* db
|
||||
.select()
|
||||
.from(SessionMessageTable)
|
||||
.where(eq(SessionMessageTable.id, messageID))
|
||||
.get()
|
||||
.pipe(Effect.orDie)
|
||||
return row
|
||||
? {
|
||||
sessionID: Session.ID.make(row.session_id),
|
||||
sessionID: sessionID ?? Session.ID.make(row.session_id),
|
||||
message: yield* SessionHistory.decodeMessageRow(row).pipe(Effect.orDie),
|
||||
}
|
||||
: undefined
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
export * as Timeline from "./timeline.js"
|
||||
|
||||
import { and, asc, desc, eq, lt, sql, type SQL } from "drizzle-orm"
|
||||
import { Effect, Schema } from "effect"
|
||||
import { Session } from "@opencode-ai/schema/session"
|
||||
import { SessionMessage } from "@opencode-ai/schema/session-message"
|
||||
import { statics } from "@opencode-ai/schema/schema"
|
||||
import type { Database } from "../database/database.js"
|
||||
import { SessionMessageTable, SessionTable, TimelineTable } from "./sql.js"
|
||||
|
||||
export const ID = Schema.String.check(Schema.isStartsWith("tml_")).pipe(
|
||||
Schema.brand("Timeline.ID"),
|
||||
statics((schema) => ({ create: () => schema.make(`tml_${crypto.randomUUID()}`) })),
|
||||
)
|
||||
export type ID = typeof ID.Type
|
||||
|
||||
type DB = Omit<Database.Interface["db"], "$client">
|
||||
|
||||
export type Position = { readonly id: ID; readonly seq: number }
|
||||
export type Range = { readonly id: ID; readonly end: number | null }
|
||||
|
||||
export const create = Effect.fn("Timeline.create")(function* (db: DB, base?: Position) {
|
||||
const id = ID.create()
|
||||
yield* db.insert(TimelineTable).values({ id, base_id: base?.id, base_seq: base?.seq }).run().pipe(Effect.orDie)
|
||||
return id
|
||||
})
|
||||
|
||||
export const current = (sessionID: Session.ID) => sql`(SELECT timeline_id FROM ${SessionTable} WHERE id = ${sessionID})`
|
||||
|
||||
/** Resolve ancestry once, then read each physical range using (timeline_id, seq). */
|
||||
export const ranges = Effect.fn("Timeline.ranges")(function* (db: DB, id: ID) {
|
||||
return yield* db
|
||||
.all<Range>(
|
||||
sql`
|
||||
WITH RECURSIVE lineage(id, base_id, base_seq, end, depth) AS (
|
||||
SELECT id, base_id, base_seq, NULL, 0 FROM timeline WHERE id = ${id}
|
||||
UNION ALL
|
||||
SELECT base.id, base.base_id, base.base_seq,
|
||||
CASE WHEN lineage.end IS NULL THEN lineage.base_seq
|
||||
ELSE min(lineage.end, lineage.base_seq) END,
|
||||
lineage.depth + 1
|
||||
FROM timeline AS base JOIN lineage ON base.id = lineage.base_id
|
||||
)
|
||||
SELECT id, end FROM lineage ORDER BY depth ASC
|
||||
`,
|
||||
)
|
||||
.pipe(Effect.orDie)
|
||||
})
|
||||
|
||||
export const forSession = Effect.fn("Timeline.forSession")(function* (db: DB, sessionID: Session.ID) {
|
||||
const session = yield* db
|
||||
.select({ id: SessionTable.timeline_id })
|
||||
.from(SessionTable)
|
||||
.where(eq(SessionTable.id, sessionID))
|
||||
.get()
|
||||
.pipe(Effect.orDie)
|
||||
return session ? yield* ranges(db, session.id) : []
|
||||
})
|
||||
|
||||
export const includes = (ranges: readonly Range[], row: { timeline_id: ID; seq: number }) =>
|
||||
ranges.some((range) => range.id === row.timeline_id && (range.end === null || row.seq < range.end))
|
||||
|
||||
export const find = Effect.fn("Timeline.find")(function* (db: DB, sessionID: Session.ID, messageID: SessionMessage.ID) {
|
||||
const row = yield* db
|
||||
.select()
|
||||
.from(SessionMessageTable)
|
||||
.where(eq(SessionMessageTable.id, messageID))
|
||||
.get()
|
||||
.pipe(Effect.orDie)
|
||||
if (!row) return undefined
|
||||
return includes(yield* forSession(db, sessionID), row) ? row : undefined
|
||||
})
|
||||
|
||||
export const rows = Effect.fn("Timeline.rows")(function* (
|
||||
db: DB,
|
||||
ranges: readonly Range[],
|
||||
input: { readonly where?: SQL; readonly order?: "asc" | "desc"; readonly limit?: number } = {},
|
||||
) {
|
||||
const result: (typeof SessionMessageTable.$inferSelect)[] = []
|
||||
for (const range of input.order === "asc" ? ranges.toReversed() : ranges) {
|
||||
if (input.limit !== undefined && result.length >= input.limit) break
|
||||
const query = db
|
||||
.select()
|
||||
.from(SessionMessageTable)
|
||||
.where(
|
||||
and(
|
||||
eq(SessionMessageTable.timeline_id, range.id),
|
||||
range.end === null ? undefined : lt(SessionMessageTable.seq, range.end),
|
||||
input.where,
|
||||
),
|
||||
)
|
||||
.orderBy(input.order === "asc" ? asc(SessionMessageTable.seq) : desc(SessionMessageTable.seq))
|
||||
result.push(
|
||||
...(yield* (input.limit === undefined ? query.all() : query.limit(input.limit - result.length).all()).pipe(
|
||||
Effect.orDie,
|
||||
)),
|
||||
)
|
||||
}
|
||||
return result
|
||||
})
|
||||
|
||||
/** Root at the physical owner of the last retained message, skipping empty intermediate timelines. */
|
||||
export const prefix = Effect.fn("Timeline.prefix")(function* (db: DB, ranges: readonly Range[], end: number) {
|
||||
const [last] = yield* rows(db, ranges, { where: lt(SessionMessageTable.seq, end), limit: 1 })
|
||||
return last ? { id: last.timeline_id, seq: last.seq + 1 } : undefined
|
||||
})
|
||||
|
||||
/** Session deletion releases a head; references from surviving histories keep their storage alive. */
|
||||
export const collect = Effect.fn("Timeline.collect")(function* (db: DB) {
|
||||
yield* db
|
||||
.run(
|
||||
sql`
|
||||
WITH RECURSIVE retained(id) AS (
|
||||
SELECT timeline_id FROM session_v2
|
||||
UNION
|
||||
SELECT timeline.base_id FROM timeline JOIN retained ON timeline.id = retained.id
|
||||
WHERE timeline.base_id IS NOT NULL
|
||||
)
|
||||
DELETE FROM timeline WHERE id NOT IN (SELECT id FROM retained)
|
||||
`,
|
||||
)
|
||||
.pipe(Effect.orDie)
|
||||
})
|
||||
@@ -3,7 +3,7 @@ export * as SessionTransfer from "./transfer.js"
|
||||
import { SessionTransfer } from "@opencode-ai/schema/session-transfer"
|
||||
import { Tool } from "@opencode-ai/schema/tool"
|
||||
import { Skill } from "@opencode-ai/schema/skill"
|
||||
import { eq } from "drizzle-orm"
|
||||
import { eq, inArray } from "drizzle-orm"
|
||||
import { Clock, Context, DateTime, Effect, Layer, Schema } from "effect"
|
||||
import { map } from "effect/Array"
|
||||
import path from "path"
|
||||
@@ -21,6 +21,7 @@ import { SessionEvent } from "./event.js"
|
||||
import { SessionMessage } from "./message.js"
|
||||
import { SessionProjector } from "./projector.js"
|
||||
import { SessionMessageTable, SessionTable } from "./sql.js"
|
||||
import { Timeline } from "./timeline.js"
|
||||
|
||||
export const Data = SessionTransfer.Data
|
||||
export type Data = SessionTransfer.Data
|
||||
@@ -74,12 +75,31 @@ const layer = Layer.effect(
|
||||
const project = yield* projects.resolve(input.location.directory)
|
||||
yield* upsertProject(db, project).pipe(Effect.orDie)
|
||||
const importedAt = yield* Clock.currentTimeMillis
|
||||
// Related exports may share message IDs. Imports materialize independent
|
||||
// snapshots, so give colliding rows fresh identities instead of stealing ownership.
|
||||
const ids = input.data.messages.map((message) => message.id)
|
||||
const batches = Array.from({ length: Math.ceil(ids.length / 500) }, (_, index) =>
|
||||
ids.slice(index * 500, (index + 1) * 500),
|
||||
)
|
||||
const existing = new Set(
|
||||
(yield* Effect.forEach(batches, (batch) =>
|
||||
db
|
||||
.select({ id: SessionMessageTable.id })
|
||||
.from(SessionMessageTable)
|
||||
.where(inArray(SessionMessageTable.id, batch))
|
||||
.all()
|
||||
.pipe(Effect.orDie),
|
||||
))
|
||||
.flat()
|
||||
.map((row) => row.id),
|
||||
)
|
||||
const messages = input.data.messages.filter(isSettled).map((message, index) => {
|
||||
const encoded = encodeMessage(message)
|
||||
const { id: _, type, ...data } = encoded
|
||||
return {
|
||||
id: message.id,
|
||||
id: existing.has(message.id) ? SessionMessage.ID.create() : message.id,
|
||||
session_id: sessionID,
|
||||
timeline_id: Timeline.current(sessionID),
|
||||
type,
|
||||
seq: index + 1,
|
||||
time_created: DateTime.toEpochMillis(message.time.created),
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { Timeline } from "@opencode-ai/core/session/timeline"
|
||||
import { describe, expect } from "bun:test"
|
||||
import { Deferred, Effect, Exit, Fiber, Stream } from "effect"
|
||||
import { eq } from "drizzle-orm"
|
||||
@@ -37,6 +38,7 @@ const seed = Effect.fn(function* (ref: Location.Ref = a) {
|
||||
yield* database.db
|
||||
.insert(SessionTable)
|
||||
.values({
|
||||
timeline_id: yield* Timeline.create(database.db),
|
||||
id,
|
||||
project_id: Project.ID.global,
|
||||
directory: ref.directory,
|
||||
|
||||
@@ -71,73 +71,6 @@ const it = testEffect(
|
||||
const decode = Schema.decodeUnknownSync(Info)
|
||||
|
||||
describe("ConfigCommandPlugin.Plugin", () => {
|
||||
for (const item of [
|
||||
...["$&", "$$", "$`", "$'"].flatMap((input) => [
|
||||
{ template: "Explain $ARGUMENTS.", input, expected: `Explain ${input}.` },
|
||||
{ template: "Explain $1.", input: `"${input}"`, expected: `Explain ${input}.` },
|
||||
{ template: "Explain.", input, expected: `Explain.\n\n${input}` },
|
||||
]),
|
||||
...["abc", "", "alpha beta", '"alpha beta"', "$1", "$<name>"].map((input) => ({
|
||||
template: "Explain $ARGUMENTS.",
|
||||
input,
|
||||
expected: `Explain ${input}.`,
|
||||
})),
|
||||
{
|
||||
template: "First $1. Rest $2.",
|
||||
input: '"alpha beta" gamma delta',
|
||||
expected: "First alpha beta. Rest gamma delta.",
|
||||
},
|
||||
{ template: "$ARGUMENTS / $ARGUMENTS", input: "$& $$", expected: "$& $$ / $& $$" },
|
||||
]) {
|
||||
it.live(`interpolates ${JSON.stringify(item.template)} with literal input ${JSON.stringify(item.input)}`, () =>
|
||||
Effect.gen(function* () {
|
||||
const command = yield* Command.Service
|
||||
const prompts: { text: string; delivery?: string }[] = []
|
||||
yield* ConfigCommandPlugin.Plugin.effect(
|
||||
host({
|
||||
command: {
|
||||
list: () => Effect.die(new Error("unused command.list")),
|
||||
transform: command.transform,
|
||||
reload: command.reload,
|
||||
},
|
||||
session: {
|
||||
prompt: (input) =>
|
||||
Effect.sync(() => {
|
||||
prompts.push({ text: input.text, delivery: input.delivery })
|
||||
return SessionInbox.User.make({
|
||||
id: SessionMessage.ID.make("msg_test"),
|
||||
sessionID: input.sessionID,
|
||||
timeCreated: DateTime.makeUnsafe(0),
|
||||
type: "user",
|
||||
payload: { text: input.text },
|
||||
delivery: input.delivery ?? "steer",
|
||||
})
|
||||
}),
|
||||
},
|
||||
}),
|
||||
).pipe(
|
||||
Effect.provide(
|
||||
Config.testLayer([
|
||||
new Document({
|
||||
type: "document",
|
||||
info: decode({ commands: { explain: { template: item.template } } }),
|
||||
}),
|
||||
]),
|
||||
),
|
||||
)
|
||||
yield* command.execute({
|
||||
name: "explain",
|
||||
invocation: {
|
||||
sessionID: Session.ID.make("ses_test"),
|
||||
prompt: { text: item.input },
|
||||
delivery: "queue",
|
||||
},
|
||||
})
|
||||
expect(prompts).toEqual([{ text: item.expected, delivery: "queue" }])
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
it.live("loads inline and file-based commands in config order", () =>
|
||||
Effect.acquireDisposable(Effect.promise(() => tmpdir())).pipe(
|
||||
Effect.flatMap((tmp) =>
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { Timeline } from "@opencode-ai/core/session/timeline"
|
||||
import { describe, expect } from "bun:test"
|
||||
import { and, asc, eq } from "drizzle-orm"
|
||||
import { Effect, Schema } from "effect"
|
||||
@@ -52,6 +53,7 @@ const setup = (sessionID: SessionSchema.ID) =>
|
||||
yield* db
|
||||
.insert(SessionTable)
|
||||
.values({
|
||||
timeline_id: yield* Timeline.create(db),
|
||||
id: sessionID,
|
||||
project_id: Project.ID.global,
|
||||
slug: "instruction-state-test",
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { Timeline } from "@opencode-ai/core/session/timeline"
|
||||
import { describe, expect } from "bun:test"
|
||||
import { Cause, Deferred, Effect, Fiber, Layer } from "effect"
|
||||
import { Agent } from "@opencode-ai/core/agent"
|
||||
@@ -43,6 +44,7 @@ function setup(rules: Permission.Ruleset = [], sessionID = Session.ID.make("ses_
|
||||
yield* db
|
||||
.insert(SessionTable)
|
||||
.values({
|
||||
timeline_id: yield* Timeline.create(db),
|
||||
id: sessionID,
|
||||
project_id: Project.ID.global,
|
||||
slug: "test",
|
||||
|
||||
@@ -115,6 +115,15 @@ describe("OpenAIPlugin", () => {
|
||||
model.limit = { context: 1_050_000, input: 922_000, output: 128_000 }
|
||||
})
|
||||
catalog.model.update(Provider.ID.openai, Model.ID.make("gpt-4.1"), () => {})
|
||||
catalog.model.update(Provider.ID.openai, Model.ID.make("gpt-6-astra"), (model) => {
|
||||
model.limit = { context: 1_050_000, input: 922_000, output: 128_000 }
|
||||
})
|
||||
catalog.model.update(Provider.ID.openai, Model.ID.make("gpt-5.10"), (model) => {
|
||||
model.limit = { context: 1_050_000, input: 922_000, output: 128_000 }
|
||||
})
|
||||
catalog.model.update(Provider.ID.openai, Model.ID.make("gpt-5"), () => {})
|
||||
catalog.model.update(Provider.ID.openai, Model.ID.make("gpt-5.04-astra"), () => {})
|
||||
catalog.model.update(Provider.ID.openai, Model.ID.make("gpt-4.99"), () => {})
|
||||
})
|
||||
yield* credentials.create({
|
||||
integrationID: Integration.ID.make("openai"),
|
||||
@@ -161,6 +170,11 @@ describe("OpenAIPlugin", () => {
|
||||
expect(gpt56.enabled).toBe(true)
|
||||
expect(gpt56.limit).toEqual({ context: 400_000, input: 272_000, output: 128_000 })
|
||||
expect(required(yield* catalog.model.get(Provider.ID.openai, Model.ID.make("gpt-4.1"))).enabled).toBe(false)
|
||||
expect(required(yield* catalog.model.get(Provider.ID.openai, Model.ID.make("gpt-6-astra"))).enabled).toBe(true)
|
||||
expect(required(yield* catalog.model.get(Provider.ID.openai, Model.ID.make("gpt-5.10"))).enabled).toBe(true)
|
||||
expect(required(yield* catalog.model.get(Provider.ID.openai, Model.ID.make("gpt-5"))).enabled).toBe(false)
|
||||
expect(required(yield* catalog.model.get(Provider.ID.openai, Model.ID.make("gpt-5.04-astra"))).enabled).toBe(false)
|
||||
expect(required(yield* catalog.model.get(Provider.ID.openai, Model.ID.make("gpt-4.99"))).enabled).toBe(false)
|
||||
}),
|
||||
)
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { Timeline } from "@opencode-ai/core/session/timeline"
|
||||
import { expect, test } from "bun:test"
|
||||
import { LLMClient, LLMEvent, LanguageModel, ToolDefinition, type LLMRequest } from "@opencode-ai/ai"
|
||||
import { OpenAIChat } from "@opencode-ai/ai/protocols"
|
||||
@@ -284,6 +285,7 @@ const insertSession = (id: Session.ID, overrides?: Partial<typeof SessionTable.$
|
||||
yield* db
|
||||
.insert(SessionTable)
|
||||
.values({
|
||||
timeline_id: yield* Timeline.create(db),
|
||||
id,
|
||||
project_id: Project.ID.global,
|
||||
slug: id,
|
||||
|
||||
@@ -500,7 +500,7 @@ describe("Session.create", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("forks a session by replaying a durable fork event into copied projected rows", () =>
|
||||
it.effect("forks a session through shared projected history", () =>
|
||||
Effect.gen(function* () {
|
||||
const session = yield* Session.Service
|
||||
const bus = yield* Bus.Service
|
||||
@@ -523,7 +523,7 @@ describe("Session.create", () => {
|
||||
expect(forked).toMatchObject({ title: "Parent (fork #1)", fork: { sessionID: parent.id } })
|
||||
expect(forked.parentID).toBeUndefined()
|
||||
expect(forkContext).toMatchObject([Expected.user("First"), { type: "synthetic", text: "parent note" }])
|
||||
expect(forkContext.map((message) => message.id)).not.toEqual(parentContext.map((message) => message.id))
|
||||
expect(forkContext.map((message) => message.id)).toEqual(parentContext.map((message) => message.id))
|
||||
expect(history).toHaveLength(1)
|
||||
expect(history[0]).toMatchObject({
|
||||
type: "session.forked",
|
||||
@@ -533,8 +533,10 @@ describe("Session.create", () => {
|
||||
expect(yield* SessionInbox.find(db, forkContext[0].id)).toBeUndefined()
|
||||
expect(yield* SessionInbox.find(db, forkContext[1].id)).toBeUndefined()
|
||||
expect(
|
||||
yield* session.prompt({ id: forkContext[0].id, sessionID: forked.id, text: "First", resume: false }),
|
||||
).toMatchObject({ id: forkContext[0].id, type: "user", payload: { text: "First" } })
|
||||
yield* Effect.flip(
|
||||
session.prompt({ id: forkContext[0].id, sessionID: forked.id, text: "First", resume: false }),
|
||||
),
|
||||
).toMatchObject({ _tag: "Session.PromptConflictError", messageID: forkContext[0].id })
|
||||
|
||||
yield* session.prompt({
|
||||
sessionID: parent.id,
|
||||
@@ -819,7 +821,7 @@ describe("Session.create", () => {
|
||||
boundary: { type: "before", messageID: second.id },
|
||||
})
|
||||
expect(context).toMatchObject([{ text: "First" }])
|
||||
expect(context[0]?.id).not.toBe(first.id)
|
||||
expect(context[0]?.id).toBe(first.id)
|
||||
expect(history[0]).toMatchObject({
|
||||
data: { boundary: { type: "before", messageID: second.id } },
|
||||
})
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { Timeline } from "@opencode-ai/core/session/timeline"
|
||||
import { AIError, TransportError } from "@opencode-ai/ai"
|
||||
import { Database } from "@opencode-ai/core/database/database"
|
||||
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
||||
@@ -1282,11 +1283,13 @@ function seedSessions(
|
||||
.onConflictDoNothing()
|
||||
.run()
|
||||
.pipe(Effect.orDie)
|
||||
const timelines = yield* Effect.forEach(sessionIDs, () => Timeline.create(database.db))
|
||||
yield* database.db
|
||||
.insert(SessionTable)
|
||||
.values(
|
||||
sessionIDs.map((id) => ({
|
||||
sessionIDs.map((id, index) => ({
|
||||
id,
|
||||
timeline_id: timelines[index],
|
||||
project_id: Project.ID.global,
|
||||
slug: id,
|
||||
directory: "/project",
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { Timeline } from "@opencode-ai/core/session/timeline"
|
||||
import { expect } from "bun:test"
|
||||
import { LLMClient, LLMEvent, LLMResponse, LanguageModel, ToolDefinition, type LLMRequest } from "@opencode-ai/ai"
|
||||
import { OpenAIChat } from "@opencode-ai/ai/protocols"
|
||||
@@ -209,6 +210,7 @@ const setup = Effect.gen(function* () {
|
||||
yield* db
|
||||
.insert(SessionTable)
|
||||
.values({
|
||||
timeline_id: yield* Timeline.create(db),
|
||||
id: sessionID,
|
||||
project_id: (yield* projects.resolve(AbsolutePath.make("/project"))).id,
|
||||
slug: "generate-test",
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { Timeline } from "@opencode-ai/core/session/timeline"
|
||||
import { describe, expect } from "bun:test"
|
||||
import { Effect, Fiber, Schema, Stream } from "effect"
|
||||
import { Database } from "@opencode-ai/core/database/database"
|
||||
@@ -106,6 +107,7 @@ describe("Session.log", () => {
|
||||
yield* db
|
||||
.insert(SessionTable)
|
||||
.values({
|
||||
timeline_id: yield* Timeline.create(db),
|
||||
id: sessionID,
|
||||
project_id: Project.ID.global,
|
||||
slug: "empty-log",
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { Timeline } from "@opencode-ai/core/session/timeline"
|
||||
import { describe, expect } from "bun:test"
|
||||
import { DateTime, Effect, Fiber, Option, Schema, Stream } from "effect"
|
||||
import { asc, eq, sql } from "drizzle-orm"
|
||||
@@ -59,7 +60,15 @@ const assistantRow = (
|
||||
} = encodeMessage(
|
||||
SessionMessage.Assistant.make({ id, type: "assistant", agent: build, model, content: [], time, ...usage }),
|
||||
)
|
||||
return { id, session_id: sessionID, type, seq, time_created: DateTime.toEpochMillis(time.created), data }
|
||||
return {
|
||||
id,
|
||||
session_id: sessionID,
|
||||
timeline_id: Timeline.current(sessionID),
|
||||
type,
|
||||
seq,
|
||||
time_created: DateTime.toEpochMillis(time.created),
|
||||
data,
|
||||
}
|
||||
}
|
||||
|
||||
const seedSession = (overrides?: Partial<typeof SessionTable.$inferInsert>) =>
|
||||
@@ -72,6 +81,7 @@ const seedSession = (overrides?: Partial<typeof SessionTable.$inferInsert>) =>
|
||||
yield* db
|
||||
.insert(SessionTable)
|
||||
.values({
|
||||
timeline_id: yield* Timeline.create(db),
|
||||
id: sessionID,
|
||||
project_id: Project.ID.global,
|
||||
slug: "test",
|
||||
@@ -190,9 +200,9 @@ describe("SessionProjector", () => {
|
||||
sessionID,
|
||||
to: boundary,
|
||||
})
|
||||
expect(
|
||||
(yield* db.select({ id: SessionMessageTable.id }).from(SessionMessageTable).all()).map((row) => row.id),
|
||||
).toEqual([earlier])
|
||||
expect((yield* Timeline.rows(db, yield* Timeline.forSession(db, sessionID))).map((row) => row.id)).toEqual([
|
||||
earlier,
|
||||
])
|
||||
expect(yield* db.select().from(SessionTable).where(eq(SessionTable.id, sessionID)).get()).toMatchObject({
|
||||
cost: Money.USD.make(1.25),
|
||||
tokens_input: 10,
|
||||
@@ -271,6 +281,7 @@ describe("SessionProjector", () => {
|
||||
.values({
|
||||
id: messageID,
|
||||
session_id: sessionID,
|
||||
timeline_id: Timeline.current(sessionID),
|
||||
type: "user",
|
||||
seq: 0,
|
||||
data: { text: "valid before corruption", time: { created: 0 } },
|
||||
@@ -476,7 +487,15 @@ describe("SessionProjector", () => {
|
||||
const { id: _, type, ...data } = encodeMessage({ id, type: "synthetic", text: "existing", time: { created } })
|
||||
yield* db
|
||||
.insert(SessionMessageTable)
|
||||
.values({ id, session_id: sessionID, type, seq: 0, time_created: 0, data })
|
||||
.values({
|
||||
id,
|
||||
session_id: sessionID,
|
||||
timeline_id: Timeline.current(sessionID),
|
||||
type,
|
||||
seq: 0,
|
||||
time_created: 0,
|
||||
data,
|
||||
})
|
||||
.run()
|
||||
|
||||
const exit = yield* bus
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { describe, expect } from "bun:test"
|
||||
import { Timeline } from "@opencode-ai/core/session/timeline"
|
||||
import { DateTime, Effect, Fiber, Layer, LayerMap, Schema, Stream } from "effect"
|
||||
import path from "path"
|
||||
import { pathToFileURL } from "url"
|
||||
@@ -113,6 +114,7 @@ const setup = Effect.gen(function* () {
|
||||
yield* db
|
||||
.insert(SessionTable)
|
||||
.values({
|
||||
timeline_id: yield* Timeline.create(db),
|
||||
id: sessionID,
|
||||
project_id: Project.ID.global,
|
||||
slug: "test",
|
||||
@@ -165,7 +167,7 @@ const assistantRow = (id: SessionMessage.ID, seq: number) => {
|
||||
time: { created: DateTime.makeUnsafe(0) },
|
||||
}),
|
||||
)
|
||||
return { id, session_id: sessionID, type, seq, time_created: 0, data }
|
||||
return { id, session_id: sessionID, timeline_id: Timeline.current(sessionID), type, seq, time_created: 0, data }
|
||||
}
|
||||
|
||||
describe("Session.prompt", () => {
|
||||
@@ -277,9 +279,7 @@ describe("Session.prompt", () => {
|
||||
|
||||
expect((yield* session.get(sessionID)).revert).toBeUndefined()
|
||||
expect(
|
||||
(yield* db.select({ id: SessionMessageTable.id }).from(SessionMessageTable).all().pipe(Effect.orDie)).map(
|
||||
(row) => row.id,
|
||||
),
|
||||
(yield* Timeline.rows(db, yield* Timeline.forSession(db, sessionID))).map((row) => row.id),
|
||||
).not.toContainAnyValues([boundary.id, stale])
|
||||
expect(yield* SessionInbox.find(db, boundary.id)).toBeUndefined()
|
||||
}),
|
||||
@@ -813,6 +813,7 @@ describe("Session.prompt", () => {
|
||||
yield* db
|
||||
.insert(SessionTable)
|
||||
.values({
|
||||
timeline_id: yield* Timeline.create(db),
|
||||
id: other,
|
||||
project_id: Project.ID.global,
|
||||
slug: "other",
|
||||
@@ -849,7 +850,15 @@ describe("Session.prompt", () => {
|
||||
})
|
||||
yield* db
|
||||
.insert(SessionMessageTable)
|
||||
.values({ id: messageID, session_id: sessionID, type, seq: 0, time_created: 0, data })
|
||||
.values({
|
||||
id: messageID,
|
||||
session_id: sessionID,
|
||||
timeline_id: Timeline.current(sessionID),
|
||||
type,
|
||||
seq: 0,
|
||||
time_created: 0,
|
||||
data,
|
||||
})
|
||||
.run()
|
||||
.pipe(Effect.orDie)
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { Timeline } from "@opencode-ai/core/session/timeline"
|
||||
import { HttpRecorder } from "@opencode-ai/http-recorder"
|
||||
import { OpenAIChat } from "@opencode-ai/ai/protocols/openai-chat"
|
||||
import { Auth, LLMClient, type LLMClientService, RequestExecutor } from "@opencode-ai/ai/route"
|
||||
@@ -204,6 +205,7 @@ describe("SessionRunnerLLM recorded", () => {
|
||||
yield* db
|
||||
.insert(SessionTable)
|
||||
.values({
|
||||
timeline_id: yield* Timeline.create(db),
|
||||
id: sessionID,
|
||||
project_id: Project.ID.global,
|
||||
slug: "test",
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { Timeline } from "@opencode-ai/core/session/timeline"
|
||||
import { expect, test } from "bun:test"
|
||||
import { Cause, Deferred, Effect, Exit, Fiber, Schema } from "effect"
|
||||
import { eq } from "drizzle-orm"
|
||||
@@ -148,7 +149,14 @@ testEffect(
|
||||
.run()
|
||||
yield* database.db
|
||||
.insert(SessionTable)
|
||||
.values({ id: sessionID, project_id: Project.ID.global, slug: "publish", directory: "/project", version: "test" })
|
||||
.values({
|
||||
timeline_id: yield* Timeline.create(database.db),
|
||||
id: sessionID,
|
||||
project_id: Project.ID.global,
|
||||
slug: "publish",
|
||||
directory: "/project",
|
||||
version: "test",
|
||||
})
|
||||
.run()
|
||||
const publisher = createLLMEventPublisher(
|
||||
{
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { Timeline } from "@opencode-ai/core/session/timeline"
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import {
|
||||
AIError,
|
||||
@@ -495,6 +496,7 @@ const insertSession = (id: Session.ID) =>
|
||||
yield* db
|
||||
.insert(SessionTable)
|
||||
.values({
|
||||
timeline_id: yield* Timeline.create(db),
|
||||
id,
|
||||
project_id: Project.ID.global,
|
||||
slug: id,
|
||||
@@ -1465,6 +1467,7 @@ describe("SessionRunnerLLM", () => {
|
||||
.all()
|
||||
yield* s.bus.remove(forked.id)
|
||||
yield* s.db.delete(SessionTable).where(eq(SessionTable.id, forked.id)).run()
|
||||
yield* Timeline.collect(s.db)
|
||||
yield* Effect.forEach(
|
||||
recorded.map((event) => ({
|
||||
id: event.id,
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { describe, expect } from "bun:test"
|
||||
import { Timeline } from "@opencode-ai/core/session/timeline"
|
||||
import { Agent } from "@opencode-ai/schema/agent"
|
||||
import { Event } from "@opencode-ai/schema/event"
|
||||
import { Model } from "@opencode-ai/schema/model"
|
||||
@@ -44,9 +45,17 @@ describe("SessionStats", () => {
|
||||
yield* db
|
||||
.insert(SessionTable)
|
||||
.values([
|
||||
{ id: sessionID, project_id: projectID, slug: "root", directory: "/stats", version: "test" },
|
||||
{
|
||||
id: sessionID,
|
||||
timeline_id: yield* Timeline.create(db),
|
||||
project_id: projectID,
|
||||
slug: "root",
|
||||
directory: "/stats",
|
||||
version: "test",
|
||||
},
|
||||
{
|
||||
id: childID,
|
||||
timeline_id: yield* Timeline.create(db),
|
||||
project_id: projectID,
|
||||
parent_id: sessionID,
|
||||
slug: "child",
|
||||
@@ -55,6 +64,7 @@ describe("SessionStats", () => {
|
||||
},
|
||||
{
|
||||
id: forkID,
|
||||
timeline_id: yield* Timeline.create(db),
|
||||
project_id: projectID,
|
||||
fork_session_id: sessionID,
|
||||
slug: "fork",
|
||||
@@ -62,8 +72,22 @@ describe("SessionStats", () => {
|
||||
version: "test",
|
||||
time_created: Date.UTC(2026, 0, 4),
|
||||
},
|
||||
{ id: usageOnlyID, project_id: projectID, slug: "usage", directory: "/stats", version: "test" },
|
||||
{ id: otherSessionID, project_id: otherProjectID, slug: "other", directory: "/other", version: "test" },
|
||||
{
|
||||
id: usageOnlyID,
|
||||
timeline_id: yield* Timeline.create(db),
|
||||
project_id: projectID,
|
||||
slug: "usage",
|
||||
directory: "/stats",
|
||||
version: "test",
|
||||
},
|
||||
{
|
||||
id: otherSessionID,
|
||||
timeline_id: yield* Timeline.create(db),
|
||||
project_id: otherProjectID,
|
||||
slug: "other",
|
||||
directory: "/other",
|
||||
version: "test",
|
||||
},
|
||||
])
|
||||
.run()
|
||||
.pipe(Effect.orDie)
|
||||
@@ -288,12 +312,16 @@ function assistant(
|
||||
})
|
||||
}
|
||||
|
||||
function messageRow(
|
||||
sessionID: Session.ID,
|
||||
seq: number,
|
||||
message: SessionMessage.Info,
|
||||
): typeof SessionMessageTable.$inferInsert {
|
||||
function messageRow(sessionID: Session.ID, seq: number, message: SessionMessage.Info) {
|
||||
const encoded = encodeMessage(message)
|
||||
const { id, type, ...data } = encoded
|
||||
return { id: SessionMessage.ID.make(id), session_id: sessionID, type, seq, time_created: encoded.time.created, data }
|
||||
return {
|
||||
id: SessionMessage.ID.make(id),
|
||||
session_id: sessionID,
|
||||
timeline_id: Timeline.current(sessionID),
|
||||
type,
|
||||
seq,
|
||||
time_created: encoded.time.created,
|
||||
data,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { Timeline } from "@opencode-ai/core/session/timeline"
|
||||
import { expect } from "bun:test"
|
||||
import { LanguageModel, LLM, LLMEvent } from "@opencode-ai/ai"
|
||||
import { OpenAIChat } from "@opencode-ai/ai/protocols/openai-chat"
|
||||
@@ -66,7 +67,14 @@ for (const fixture of [
|
||||
.run()
|
||||
yield* db
|
||||
.insert(SessionTable)
|
||||
.values({ id: sessionID, project_id: Project.ID.global, slug: "step", directory: "/project", version: "test" })
|
||||
.values({
|
||||
timeline_id: yield* Timeline.create(db),
|
||||
id: sessionID,
|
||||
project_id: Project.ID.global,
|
||||
slug: "step",
|
||||
directory: "/project",
|
||||
version: "test",
|
||||
})
|
||||
.run()
|
||||
const model = SessionRunnerModel.resolved(
|
||||
LanguageModel.make({ id: "test-model", provider: "test", route: OpenAIChat.route }),
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { Timeline } from "@opencode-ai/core/session/timeline"
|
||||
import { beforeEach, expect } from "bun:test"
|
||||
import { AIError, LLMClient, LLMEvent, LanguageModel, TransportError, type LLMRequest } from "@opencode-ai/ai"
|
||||
import { OpenAIChat } from "@opencode-ai/ai/protocols"
|
||||
@@ -139,6 +140,7 @@ const insertSession = (id: Session.ID, title?: string, created?: number, model?:
|
||||
yield* db
|
||||
.insert(SessionTable)
|
||||
.values({
|
||||
timeline_id: yield* Timeline.create(db),
|
||||
id,
|
||||
project_id: Project.ID.global,
|
||||
slug: id,
|
||||
@@ -339,6 +341,7 @@ it.effect("generates a title for an explicitly requested child session", () =>
|
||||
yield* db
|
||||
.insert(SessionTable)
|
||||
.values({
|
||||
timeline_id: yield* Timeline.create(db),
|
||||
id: sessionID,
|
||||
project_id: Project.ID.global,
|
||||
parent_id: Session.ID.make("ses_title_parent"),
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { Timeline } from "@opencode-ai/core/session/timeline"
|
||||
import { describe, expect } from "bun:test"
|
||||
import { asc, eq } from "drizzle-orm"
|
||||
import { Effect, Schema } from "effect"
|
||||
@@ -43,6 +44,7 @@ describe("Session tool progress", () => {
|
||||
yield* db
|
||||
.insert(SessionTable)
|
||||
.values({
|
||||
timeline_id: yield* Timeline.create(db),
|
||||
id: sessionID,
|
||||
project_id: Project.ID.global,
|
||||
slug: "progress",
|
||||
|
||||
@@ -8,13 +8,11 @@ import { SessionMessage } from "@opencode-ai/core/session/message"
|
||||
import { SessionSchema } from "@opencode-ai/core/session/schema"
|
||||
import { SessionTable } from "@opencode-ai/core/session/sql"
|
||||
import { Project } from "@opencode-ai/core/project"
|
||||
import { ProjectTable } from "@opencode-ai/core/project/sql"
|
||||
import { AbsolutePath } from "@opencode-ai/core/schema"
|
||||
import { Global } from "@opencode-ai/util/global"
|
||||
import { Effect, Fiber, Layer, Logger, Schedule, Schema, Scope } from "effect"
|
||||
import { eq, sql } from "drizzle-orm"
|
||||
import { sql } from "drizzle-orm"
|
||||
import type { SqlClient } from "effect/unstable/sql/SqlClient"
|
||||
import { tmpdir } from "./fixture/tmpdir"
|
||||
import { Timeline } from "@opencode-ai/core/session/timeline"
|
||||
import path from "path"
|
||||
|
||||
const makeDb = EffectDrizzleSqlite.makeWithDefaults()
|
||||
@@ -32,6 +30,7 @@ const session = (
|
||||
overrides: Partial<V1Migration.TransformInput["session"]> = {},
|
||||
): V1Migration.TransformInput["session"] => ({
|
||||
id: SessionSchema.ID.make("ses_test"),
|
||||
timeline_id: Timeline.ID.create(),
|
||||
project_id: Project.ID.global,
|
||||
workspace_id: null,
|
||||
parent_id: null,
|
||||
@@ -831,179 +830,6 @@ describe("V1Migration database workflow", () => {
|
||||
)
|
||||
})
|
||||
|
||||
test("imports previous V2 sessions and messages containing apostrophes", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
const filename = path.join(tmp.path, "opencode-next.db")
|
||||
const sqlite = await import("bun:sqlite")
|
||||
const source = new sqlite.Database(filename)
|
||||
source.run(`
|
||||
CREATE TABLE project (
|
||||
id text PRIMARY KEY, worktree text NOT NULL, vcs text, name text, icon_url text, icon_url_override text,
|
||||
icon_color text, time_created integer NOT NULL, time_updated integer NOT NULL, time_initialized integer,
|
||||
sandboxes text NOT NULL, commands text
|
||||
);
|
||||
CREATE TABLE session (
|
||||
id text PRIMARY KEY, project_id text NOT NULL, workspace_id text, parent_id text, fork_session_id text,
|
||||
fork_boundary text, slug text NOT NULL, directory text NOT NULL, path text, title text, version text NOT NULL,
|
||||
share_url text, summary_additions integer, summary_deletions integer, summary_files integer, summary_diffs text,
|
||||
metadata text, cost real DEFAULT 0 NOT NULL, tokens_input integer DEFAULT 0 NOT NULL,
|
||||
tokens_output integer DEFAULT 0 NOT NULL, tokens_reasoning integer DEFAULT 0 NOT NULL,
|
||||
tokens_cache_read integer DEFAULT 0 NOT NULL, tokens_cache_write integer DEFAULT 0 NOT NULL, revert text,
|
||||
permission text, agent text, model text, time_created integer NOT NULL, time_updated integer NOT NULL,
|
||||
time_compacting integer, time_archived integer, time_suspended integer
|
||||
);
|
||||
CREATE TABLE session_message (
|
||||
id text PRIMARY KEY, session_id text NOT NULL, type text NOT NULL, seq integer NOT NULL,
|
||||
time_created integer NOT NULL, time_updated integer NOT NULL, data text NOT NULL
|
||||
);
|
||||
INSERT INTO project VALUES (
|
||||
'next-project', 'C:/Users/sewer', 'git', 'Source project', NULL, NULL, NULL, 1, 2, NULL, '[]', NULL
|
||||
);
|
||||
INSERT INTO session (
|
||||
id, project_id, slug, directory, title, version, agent, model, time_created, time_updated
|
||||
) VALUES
|
||||
('ses_next', 'next-project', 'next', 'C:/Users/sewer', 'Imported', '2', 'build',
|
||||
'{"id":"model","providerID":"provider"}', 10, 20),
|
||||
('ses_existing', 'next-project', 'source-existing', '/tmp/next', 'Source existing', '2', NULL, NULL, 11, 21),
|
||||
('ses_orphan', 'missing-project', 'orphan', '/tmp/orphan', 'Orphan', '2', NULL, NULL, 12, 22);
|
||||
INSERT INTO session_message VALUES
|
||||
('msg_next', 'ses_next', 'user', 4, 12, 13, '{"text":"from next''s history","time":{"created":12}}'),
|
||||
('msg_source_existing', 'ses_existing', 'user', 2, 12, 13, '{"text":"source","time":{"created":12}}'),
|
||||
('msg_orphan', 'ses_orphan', 'user', 0, 12, 13, '{"text":"orphan","time":{"created":12}}');
|
||||
`)
|
||||
source.close()
|
||||
|
||||
await database(
|
||||
Effect.gen(function* () {
|
||||
const { db } = yield* Database.Service
|
||||
yield* db.run(sql`
|
||||
INSERT INTO project (id, worktree, name, time_created, time_updated, sandboxes)
|
||||
VALUES ('next-project', '/tmp/current', 'Current project', 1, 2, '[]')
|
||||
`)
|
||||
yield* db.run(sql`
|
||||
INSERT INTO session_v2 (id, project_id, slug, directory, title, version, time_created, time_updated)
|
||||
VALUES ('ses_existing', 'next-project', 'current-existing', '/tmp/current', 'Current existing', '2', 1, 2)
|
||||
`)
|
||||
yield* db.run(sql`
|
||||
INSERT INTO session_message (id, session_id, type, seq, time_created, time_updated, data)
|
||||
VALUES ('msg_current_existing', 'ses_existing', 'user', 0, 1, 2, '{"text":"current","time":{"created":1}}')
|
||||
`)
|
||||
|
||||
expect(yield* V1Migration.status()).toEqual({
|
||||
status: "required",
|
||||
})
|
||||
expect(yield* V1Migration.run({ nextDatabasePath: filename })).toEqual({ status: "completed" })
|
||||
expect(yield* V1Migration.status()).toEqual({
|
||||
status: "completed",
|
||||
})
|
||||
expect(yield* db.get(sql`SELECT title, agent, model FROM session_v2 WHERE id = 'ses_next'`)).toEqual({
|
||||
title: "Imported",
|
||||
agent: "build",
|
||||
model: '{"id":"model","providerID":"provider"}',
|
||||
})
|
||||
expect(
|
||||
yield* db
|
||||
.select({ directory: SessionTable.directory })
|
||||
.from(SessionTable)
|
||||
.where(eq(SessionTable.id, SessionSchema.ID.make("ses_next")))
|
||||
.get(),
|
||||
).toEqual({ directory: process.platform === "win32" ? "C:\\Users\\sewer" : "C:/Users/sewer" })
|
||||
expect(yield* db.all(sql`SELECT id, seq, data FROM session_message WHERE session_id = 'ses_next'`)).toEqual([
|
||||
{
|
||||
id: "msg_next",
|
||||
seq: 4,
|
||||
data: '{"text":"from next\'s history","time":{"created":12}}',
|
||||
},
|
||||
])
|
||||
expect(yield* db.get(sql`SELECT seq, owner_id FROM event_sequence WHERE aggregate_id = 'ses_next'`)).toEqual({
|
||||
seq: 4,
|
||||
owner_id: null,
|
||||
})
|
||||
expect(yield* db.get(sql`SELECT title FROM session_v2 WHERE id = 'ses_existing'`)).toEqual({
|
||||
title: "Current existing",
|
||||
})
|
||||
expect(yield* db.all(sql`SELECT id FROM session_message WHERE session_id = 'ses_existing'`)).toEqual([
|
||||
{ id: "msg_current_existing" },
|
||||
])
|
||||
expect(yield* db.get(sql`SELECT project_id FROM session_v2 WHERE id = 'ses_orphan'`)).toEqual({
|
||||
project_id: "global",
|
||||
})
|
||||
expect(yield* db.get(sql`SELECT name, worktree FROM project WHERE id = 'next-project'`)).toEqual({
|
||||
name: "Current project",
|
||||
worktree: "/tmp/current",
|
||||
})
|
||||
yield* db.run(sql`UPDATE project SET worktree = 'C:/Users/sewer' WHERE id = 'next-project'`)
|
||||
expect(
|
||||
yield* db
|
||||
.select({ worktree: ProjectTable.worktree })
|
||||
.from(ProjectTable)
|
||||
.where(eq(ProjectTable.id, Project.ID.make("next-project")))
|
||||
.get(),
|
||||
).toEqual({
|
||||
worktree: AbsolutePath.make(process.platform === "win32" ? "C:\\Users\\sewer" : "C:/Users/sewer"),
|
||||
})
|
||||
expect(yield* db.get(sql`SELECT value FROM kv WHERE key = 'migration.v1-v2'`)).toEqual({
|
||||
value: '{"phase":"completed"}',
|
||||
})
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
test("imports previous V2 databases missing newer nullable columns", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
const filename = path.join(tmp.path, "opencode-next.db")
|
||||
const sqlite = await import("bun:sqlite")
|
||||
const source = new sqlite.Database(filename)
|
||||
source.run(`
|
||||
CREATE TABLE project (
|
||||
id text PRIMARY KEY, worktree text NOT NULL, vcs text, name text, icon_url text,
|
||||
time_created integer NOT NULL, time_updated integer NOT NULL, time_initialized integer,
|
||||
sandboxes text NOT NULL
|
||||
);
|
||||
CREATE TABLE session (
|
||||
id text PRIMARY KEY, project_id text NOT NULL, workspace_id text, parent_id text, fork_session_id text,
|
||||
slug text NOT NULL, directory text NOT NULL, path text, title text, version text NOT NULL,
|
||||
share_url text, summary_additions integer, summary_deletions integer, summary_files integer, summary_diffs text,
|
||||
metadata text, cost real DEFAULT 0 NOT NULL, tokens_input integer DEFAULT 0 NOT NULL,
|
||||
tokens_output integer DEFAULT 0 NOT NULL, tokens_reasoning integer DEFAULT 0 NOT NULL,
|
||||
tokens_cache_read integer DEFAULT 0 NOT NULL, tokens_cache_write integer DEFAULT 0 NOT NULL, revert text,
|
||||
permission text, agent text, model text, time_created integer NOT NULL, time_updated integer NOT NULL,
|
||||
time_compacting integer, time_archived integer
|
||||
);
|
||||
CREATE TABLE session_message (
|
||||
id text PRIMARY KEY, session_id text NOT NULL, type text NOT NULL, seq integer NOT NULL,
|
||||
time_created integer NOT NULL, time_updated integer NOT NULL, data text NOT NULL
|
||||
);
|
||||
INSERT INTO project VALUES (
|
||||
'next-project', '/tmp/next', 'git', 'Source project', 'https://example.com/icon.png', 1, 2, NULL, '[]'
|
||||
);
|
||||
INSERT INTO session (
|
||||
id, project_id, slug, directory, title, version, time_created, time_updated
|
||||
) VALUES ('ses_next', 'next-project', 'next', '/tmp/next', 'Imported', '2', 10, 20);
|
||||
`)
|
||||
source.close()
|
||||
|
||||
await database(
|
||||
Effect.gen(function* () {
|
||||
const database = yield* Database.Service
|
||||
expect(yield* V1Migration.run({ nextDatabasePath: filename })).toEqual({ status: "completed" })
|
||||
expect(
|
||||
yield* database.db.get(sql`SELECT fork_boundary, time_suspended FROM session_v2 WHERE id = 'ses_next'`),
|
||||
).toEqual({ fork_boundary: null, time_suspended: null })
|
||||
expect(
|
||||
yield* database.db.get(
|
||||
sql`SELECT icon_url, icon_url_override, icon_color, commands FROM project WHERE id = 'next-project'`,
|
||||
),
|
||||
).toEqual({
|
||||
icon_url: "https://example.com/icon.png",
|
||||
icon_url_override: "https://example.com/icon.png",
|
||||
icon_color: null,
|
||||
commands: null,
|
||||
})
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
test("derives required status from the durable cursor", async () => {
|
||||
await database(
|
||||
Effect.gen(function* () {
|
||||
@@ -1071,8 +897,9 @@ describe("V1Migration database workflow", () => {
|
||||
yield* db.run(
|
||||
sql`INSERT INTO part (id, message_id, session_id, time_created, time_updated, data) VALUES ('prt_1', ${source.id}, 'ses_test', 1, 2, ${sourcePart.data})`,
|
||||
)
|
||||
const timelineID = yield* Timeline.create(db)
|
||||
yield* db.run(
|
||||
sql`INSERT INTO session_message (id, session_id, type, seq, time_created, time_updated, data) VALUES ('msg_stale', 'ses_test', 'user', 0, 1, 1, '{"text":"stale","time":{"created":1}}')`,
|
||||
sql`INSERT INTO session_message (id, session_id, timeline_id, type, seq, time_created, time_updated, data) VALUES ('msg_stale', 'ses_test', ${timelineID}, 'user', 0, 1, 1, '{"text":"stale","time":{"created":1}}')`,
|
||||
)
|
||||
yield* db.run(sql`INSERT INTO event_sequence (aggregate_id, seq) VALUES ('ses_test', 9)`)
|
||||
yield* db.run(
|
||||
@@ -1142,8 +969,9 @@ describe("V1Migration database workflow", () => {
|
||||
yield* db.run(
|
||||
sql`CREATE TRIGGER fail_b BEFORE UPDATE ON session_v2 WHEN NEW.id = 'ses_b' BEGIN SELECT RAISE(ABORT, 'stop'); END`,
|
||||
)
|
||||
const timelineID = yield* Timeline.create(db)
|
||||
yield* db.run(
|
||||
sql`INSERT INTO session_message (id, session_id, type, seq, time_created, time_updated, data) VALUES ('msg_stale_b', 'ses_b', 'user', 0, 7, 8, '{"text":"stale","time":{"created":7}}')`,
|
||||
sql`INSERT INTO session_message (id, session_id, timeline_id, type, seq, time_created, time_updated, data) VALUES ('msg_stale_b', 'ses_b', ${timelineID}, 'user', 0, 7, 8, '{"text":"stale","time":{"created":7}}')`,
|
||||
)
|
||||
yield* db.run(sql`INSERT INTO event_sequence (aggregate_id, seq, owner_id) VALUES ('ses_b', 7, 'owner')`)
|
||||
yield* db.run(
|
||||
|
||||
@@ -54,6 +54,11 @@
|
||||
"./component/register-spinner": "./src/component/register-spinner.ts"
|
||||
},
|
||||
"imports": {
|
||||
"#plugin-source": {
|
||||
"bun": "./src/plugin/source.bun.ts",
|
||||
"node": "./src/plugin/source.node.ts",
|
||||
"default": "./src/plugin/source.node.ts"
|
||||
},
|
||||
"#attention-sounds": {
|
||||
"bun": "./src/attention-sounds.bun.ts",
|
||||
"node": "./src/attention-sounds.node.ts",
|
||||
|
||||
@@ -14,10 +14,9 @@ import {
|
||||
type ParentProps,
|
||||
} from "solid-js"
|
||||
import path from "path"
|
||||
import { readFile, stat } from "fs/promises"
|
||||
import { stat } from "fs/promises"
|
||||
import { fileURLToPath } from "url"
|
||||
import type { Page } from "@opencode-ai/plugin/tui/context"
|
||||
import { Hash } from "@opencode-ai/util/hash"
|
||||
import { Host } from "@opencode-ai/plugin/host"
|
||||
import { resolveSlots, type Claim } from "./structure"
|
||||
import { createStore, produce, reconcile as reconcileStore, unwrap } from "solid-js/store"
|
||||
@@ -31,7 +30,8 @@ import { errorMessage } from "../util/error"
|
||||
import { builtins } from "./builtins"
|
||||
import { createPluginContext, usePluginHost, type Dispose, type RegisteredSlot, type SlotRender } from "./api"
|
||||
import { createSourceWatcher } from "./watch"
|
||||
import { discoverPluginTargets, freshSpecifier, localSource } from "./discovery"
|
||||
import { discoverPluginTargets, localSource } from "./discovery"
|
||||
import { createPluginSources } from "./source"
|
||||
import { isMissingPath } from "../util/config-directories"
|
||||
import { createMarkdownRenderer } from "./markdown"
|
||||
|
||||
@@ -83,7 +83,6 @@ type Registration = {
|
||||
type Desired = Pick<Registration, "plugin" | "source" | "target" | "version" | "options"> & { enabled: boolean }
|
||||
|
||||
const PluginContext = createContext<Value>()
|
||||
let sourceVersion = Date.now()
|
||||
|
||||
export function PluginProvider(props: ParentProps<{ packages: PackageSource; directories: string[] }>) {
|
||||
const host = usePluginHost()
|
||||
@@ -109,15 +108,6 @@ export function PluginProvider(props: ParentProps<{ packages: PackageSource; dir
|
||||
// One save can emit several watch events. Remember setup failures so those
|
||||
// events do not repeatedly tear down and restore the last good generation.
|
||||
const setupFailures = new Map<string, { version: string; options: Registration["options"]; error: string }>()
|
||||
const sourceVersions = new Map<string, { digest: string; generation: number }>()
|
||||
const sourceGeneration = async (entrypoint: string) => {
|
||||
const digest = Hash.sha256(await readFile(new URL(entrypoint)))
|
||||
const previous = sourceVersions.get(entrypoint)
|
||||
if (previous?.digest === digest) return previous.generation
|
||||
const generation = ++sourceVersion
|
||||
sourceVersions.set(entrypoint, { digest, generation })
|
||||
return generation
|
||||
}
|
||||
const markdown = createMarkdownRenderer(() =>
|
||||
Object.values(store.registrations).flatMap((registration) => (registration.active ? [registration.markdown] : [])),
|
||||
)
|
||||
@@ -241,6 +231,7 @@ export function PluginProvider(props: ParentProps<{ packages: PackageSource; dir
|
||||
clearTimeout(pending)
|
||||
watcher.dispose()
|
||||
}
|
||||
const sources = createPluginSources(watcher.wait)
|
||||
onCleanup(stopWatching)
|
||||
|
||||
// Rebuild the plugin generation as resolve → compare → swap, mirroring the
|
||||
@@ -304,7 +295,7 @@ export function PluginProvider(props: ParentProps<{ packages: PackageSource; dir
|
||||
const memo = local ? undefined : npmFailures.get(target)
|
||||
const resolved = memo
|
||||
? { status: "failed" as const, error: memo }
|
||||
: await resolvePlugin(target, local, options, previous, props.packages, source.install, sourceGeneration).catch(
|
||||
: await resolvePlugin(target, local, options, previous, props.packages, source.install, sources.read).catch(
|
||||
(error) => ({
|
||||
status: "failed" as const,
|
||||
error: errorMessage(error),
|
||||
@@ -533,6 +524,7 @@ export function PluginProvider(props: ParentProps<{ packages: PackageSource; dir
|
||||
),
|
||||
)
|
||||
.then(() => setStore("registrations", reconcileStore({})))
|
||||
.finally(sources.dispose)
|
||||
return disposing
|
||||
}
|
||||
const unregister = lifecycle.add(dispose)
|
||||
@@ -605,7 +597,7 @@ async function resolvePlugin(
|
||||
previous: Registration | undefined,
|
||||
packages: PackageSource,
|
||||
install: boolean,
|
||||
sourceGeneration: (entrypoint: string) => Promise<number>,
|
||||
readSource: ReturnType<typeof createPluginSources>["read"],
|
||||
) {
|
||||
// Package entrypoints never change within a session, so a loaded previous
|
||||
// version needs no re-resolution (which could otherwise hit npm).
|
||||
@@ -616,18 +608,18 @@ async function resolvePlugin(
|
||||
if (!entrypoint) return { status: "unsupported" as const }
|
||||
// Content remains stable across the several mtimes one save may expose to
|
||||
// filesystem watchers, while the generation keeps reverted modules fresh.
|
||||
let generation = local ? await sourceGeneration(entrypoint) : undefined
|
||||
let source = local ? await readSource(entrypoint) : { version: entrypoint, module: await Host.load(entrypoint) }
|
||||
while (true) {
|
||||
const version = generation === undefined ? entrypoint : freshSpecifier(entrypoint, generation)
|
||||
const version = source.version
|
||||
if (previous && previous.version === version && sameOptions(previous.options, options))
|
||||
return { status: "unchanged" as const, plugin: previous.plugin, version }
|
||||
const mod = await Host.load(version)
|
||||
if (generation !== undefined) {
|
||||
const observed = await sourceGeneration(entrypoint)
|
||||
const mod = source.module
|
||||
if (local) {
|
||||
const observed = await readSource(entrypoint)
|
||||
// In-place saves can change the file between hashing and import. Retry
|
||||
// so setup always runs under the generation of the imported bytes.
|
||||
if (generation !== observed) {
|
||||
generation = observed
|
||||
if (version !== observed.version) {
|
||||
source = observed
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { readdir, stat } from "node:fs/promises"
|
||||
import path from "node:path"
|
||||
import { fileURLToPath, pathToFileURL } from "node:url"
|
||||
import { pathToFileURL } from "node:url"
|
||||
import { isMissingPath, localProjectDirectory, projectConfigDirectories } from "../util/config-directories"
|
||||
|
||||
export async function localPluginDirectories(cwd: string, configDirectory: string) {
|
||||
@@ -56,14 +56,3 @@ export function localSource(spec: string, directory: string) {
|
||||
return pathToFileURL(path.resolve(directory, spec))
|
||||
return undefined
|
||||
}
|
||||
|
||||
// Key local plugin imports by a numeric source version so edited sources
|
||||
// re-import fresh instead of hitting the ESM cache. Bun ignores query params
|
||||
// when caching file:// URL imports, so bust with a plain path there; Node keys
|
||||
// its cache on the full URL. Fractional versions break Bun's runtime JSX/solid
|
||||
// plugin hooks, so always truncate them.
|
||||
export function freshSpecifier(entrypoint: string, sourceVersion: number) {
|
||||
const version = Math.trunc(sourceVersion)
|
||||
if (typeof Bun !== "undefined") return `${fileURLToPath(entrypoint).replaceAll("\\", "/")}?mtime=${version}`
|
||||
return `${entrypoint}?mtime=${version}`
|
||||
}
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
import { createRequire } from "node:module"
|
||||
import { readFileSync } from "node:fs"
|
||||
import path from "node:path"
|
||||
import { fileURLToPath, pathToFileURL } from "node:url"
|
||||
import { Host } from "@opencode-ai/plugin/host"
|
||||
import { localSource } from "./discovery"
|
||||
|
||||
let generation = Date.now()
|
||||
|
||||
export async function prepareSource(entrypoint: string, track: (file: string, directory?: boolean) => void) {
|
||||
const files = new Set<string>()
|
||||
const visit = (file: string, search = "") => {
|
||||
if (file.split(path.sep).includes("node_modules")) return
|
||||
if (search) delete require.cache[file + search]
|
||||
if (files.has(file)) return
|
||||
files.add(file)
|
||||
// Bun exposes ESM here too. Delete known keys even when absent: rejected
|
||||
// evaluations are not enumerable, but deletion still invalidates them.
|
||||
delete require.cache[file]
|
||||
track(file)
|
||||
if (!/\.[cm]?[jt]sx?$/.test(file)) return
|
||||
// Scan dependencies only; the normal runtime loader still owns compilation,
|
||||
// package resolution, import attributes, and error reporting.
|
||||
const imports = (() => {
|
||||
try {
|
||||
return new Bun.Transpiler({
|
||||
loader: file.endsWith("tsx") ? "tsx" : file.endsWith("jsx") ? "jsx" : /\.[cm]?ts$/.test(file) ? "ts" : "js",
|
||||
target: "bun",
|
||||
}).scan(readFileSync(file, "utf8")).imports
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
})()
|
||||
for (const item of imports) {
|
||||
const local =
|
||||
item.path.startsWith("./") || item.path.startsWith("../")
|
||||
? new URL(item.path, pathToFileURL(file))
|
||||
: localSource(item.path, path.dirname(file))
|
||||
if (!local) continue
|
||||
const requested = fileURLToPath(local)
|
||||
// Resolving a workspace symlink can erase its node_modules boundary.
|
||||
if (requested.split(path.sep).includes("node_modules")) continue
|
||||
try {
|
||||
visit(
|
||||
item.kind === "require-call"
|
||||
? createRequire(file).resolve(requested)
|
||||
: Bun.resolveSync(requested, path.dirname(file)),
|
||||
local.search,
|
||||
)
|
||||
} catch {
|
||||
// A missing local dependency may appear on the next save. Leave its
|
||||
// actual failure (or optional fallback) to the native loader.
|
||||
track(path.dirname(requested), true)
|
||||
}
|
||||
}
|
||||
}
|
||||
visit(fileURLToPath(entrypoint))
|
||||
return { version: String(++generation), load: () => Host.load(entrypoint) }
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import { registerHooks } from "node:module"
|
||||
import path from "node:path"
|
||||
import { fileURLToPath } from "node:url"
|
||||
import { localSource } from "./discovery"
|
||||
import { Host } from "@opencode-ai/plugin/host"
|
||||
|
||||
let generation = Date.now()
|
||||
|
||||
export async function prepareSource(entrypoint: string, track: (file: string, directory?: boolean) => void) {
|
||||
const version = String(++generation)
|
||||
const fresh = (specifier: string) => {
|
||||
const url = new URL(specifier)
|
||||
url.searchParams.set("__opencode_reload", version)
|
||||
return url.href
|
||||
}
|
||||
const hook = registerHooks({
|
||||
resolve(specifier, context, nextResolve) {
|
||||
if (!context.parentURL || new URL(context.parentURL).searchParams.get("__opencode_reload") !== version)
|
||||
return nextResolve(specifier, context)
|
||||
const local =
|
||||
specifier.startsWith("./") || specifier.startsWith("../")
|
||||
? new URL(specifier, context.parentURL)
|
||||
: localSource(specifier, path.dirname(fileURLToPath(context.parentURL)))
|
||||
if (!local) return nextResolve(specifier, context)
|
||||
if (fileURLToPath(local).split(path.sep).includes("node_modules")) return nextResolve(specifier, context)
|
||||
const resolved = (() => {
|
||||
try {
|
||||
return nextResolve(specifier, context)
|
||||
} catch (error) {
|
||||
track(path.dirname(fileURLToPath(local)), true)
|
||||
throw error
|
||||
}
|
||||
})()
|
||||
if (!resolved.url.startsWith("file:")) return resolved
|
||||
const file = fileURLToPath(resolved.url)
|
||||
if (file.split(path.sep).includes("node_modules")) return resolved
|
||||
track(file)
|
||||
return { ...resolved, url: fresh(resolved.url) }
|
||||
},
|
||||
})
|
||||
const specifier = fresh(entrypoint)
|
||||
return { version: specifier, load: () => Host.load(specifier), dispose: () => hook.deregister() }
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import { readFileSync, readdirSync } from "node:fs"
|
||||
import { fileURLToPath } from "node:url"
|
||||
import { Hash } from "@opencode-ai/util/hash"
|
||||
|
||||
// Keep source fingerprints and import attempts together. Filesystem events
|
||||
// should reload changed local graphs, not repeat unchanged evaluations.
|
||||
export function createPluginSources(watch: (file: string) => Promise<void>) {
|
||||
const sources = new Map<string, Source>()
|
||||
const cleanups: Array<() => void> = []
|
||||
const watching = new Set<Promise<void>>()
|
||||
return {
|
||||
read: async (entrypoint: string) => {
|
||||
await Promise.all(watching)
|
||||
const previous = sources.get(entrypoint)
|
||||
if (previous && [...previous.files].every(([file, item]) => item.digest === digest(file, item.directory)))
|
||||
return previous.loaded
|
||||
|
||||
const files: Source["files"] = new Map()
|
||||
const track = (file: string, directory = false) => {
|
||||
if (files.has(file)) return
|
||||
files.set(file, { digest: digest(file, directory), directory })
|
||||
const pending = watch(file).finally(() => watching.delete(pending))
|
||||
watching.add(pending)
|
||||
}
|
||||
track(fileURLToPath(entrypoint))
|
||||
const { prepareSource } = await import("#plugin-source")
|
||||
const prepared: { version: string; load: () => Promise<unknown>; dispose?: () => void } = await prepareSource(
|
||||
entrypoint,
|
||||
track,
|
||||
)
|
||||
if (prepared.dispose) cleanups.push(prepared.dispose)
|
||||
// Cache the attempt before evaluating it: unchanged failing modules must
|
||||
// not repeat import-time effects on every filesystem notification.
|
||||
const loaded = prepared.load().then((module) => ({ version: prepared.version, module }))
|
||||
sources.set(entrypoint, { loaded, files })
|
||||
return loaded.finally(() => Promise.all(watching))
|
||||
},
|
||||
dispose: () => {
|
||||
for (const cleanup of cleanups.splice(0)) cleanup()
|
||||
sources.clear()
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
type Source = {
|
||||
loaded: Promise<{ version: string; module: unknown }>
|
||||
files: Map<string, { digest: string; directory: boolean }>
|
||||
}
|
||||
|
||||
function digest(file: string, directory: boolean) {
|
||||
try {
|
||||
return Hash.sha256(directory ? JSON.stringify(readdirSync(file).sort()) : readFileSync(file))
|
||||
} catch {
|
||||
return "missing"
|
||||
}
|
||||
}
|
||||
@@ -7,9 +7,9 @@ import { lstat, realpath, stat } from "fs/promises"
|
||||
// kills a direct file watch) and filtered by basename so bursts in busy
|
||||
// directories stay quiet. Symlinked files are additionally watched at their
|
||||
// resolved target, since edits there emit nothing at the link's location.
|
||||
// Directory targets are watched at their root only: edits to nested helper
|
||||
// files do not change the entrypoint mtime and are not detected. Watches are
|
||||
// never torn down individually (a stale watch costs one fs handle and a
|
||||
// Directory targets are watched at their root only; the plugin source loader
|
||||
// adds each resolved local dependency separately, including nested helpers.
|
||||
// Watches are never torn down individually (a stale watch costs one fs handle and a
|
||||
// spurious onChange); all die with dispose(). Missing retryable targets are
|
||||
// polled until they can be armed without relying on a racy chain of ancestor
|
||||
// watches.
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Prompt, type PromptRef } from "../component/prompt"
|
||||
import { createEffect, createMemo, createSignal, Match, onMount, Show, Switch, untrack } from "solid-js"
|
||||
import { createEffect, createMemo, createSignal, onMount, Show, untrack } from "solid-js"
|
||||
import { Logo } from "../component/logo"
|
||||
import { useArgs } from "../context/args"
|
||||
import { useRouteData } from "../context/route"
|
||||
@@ -11,11 +11,10 @@ import { useLocation } from "../context/location"
|
||||
import { FormPrompt } from "./session/form"
|
||||
import { Slot } from "../plugin/render"
|
||||
import { useTerminalDimensions } from "@opentui/solid"
|
||||
import { TextAttributes, type RGBA } from "@opentui/core"
|
||||
import { useTheme } from "../context/theme"
|
||||
import { useUpdateNotification } from "../context/update-notification"
|
||||
import { useExit } from "../context/exit"
|
||||
import { FadeInText } from "../component/fade-in-text"
|
||||
import { stringWidth } from "../util/string-width"
|
||||
|
||||
let once = false
|
||||
const placeholder = {
|
||||
@@ -33,6 +32,7 @@ export function Home() {
|
||||
const data = useData()
|
||||
const location = useLocation()
|
||||
const dimensions = useTerminalDimensions()
|
||||
const [logoWidth, setLogoWidth] = createSignal(0)
|
||||
// Global MCP elicitations can arrive without a session route, so keep them reachable from Home.
|
||||
const currentLocation = () => route.location ?? data.location.default()
|
||||
const forms = createMemo(() => data.session.form.list("global", currentLocation()) ?? [])
|
||||
@@ -87,15 +87,18 @@ export function Home() {
|
||||
>
|
||||
<box flexGrow={1} minHeight={0} />
|
||||
<box height={3} minHeight={0} flexShrink={1} />
|
||||
<box flexShrink={0}>
|
||||
<box
|
||||
flexShrink={0}
|
||||
onSizeChange={function () {
|
||||
setLogoWidth(this.width)
|
||||
}}
|
||||
>
|
||||
<Logo />
|
||||
</box>
|
||||
<box height={1} minHeight={0} flexShrink={1} />
|
||||
<box height={1} flexShrink={0} />
|
||||
<UpdateNotification width={logoWidth()} />
|
||||
<box width="100%" maxWidth={75} zIndex={1000} paddingTop={1} flexShrink={0} position="relative">
|
||||
<Prompt ref={bind} placeholders={placeholder} disabled={forms().length > 0} />
|
||||
<box position="absolute" top="100%" left={0} right={0} alignItems="center">
|
||||
<UpdateNotification />
|
||||
</box>
|
||||
</box>
|
||||
<box flexGrow={1} minHeight={0} />
|
||||
</box>
|
||||
@@ -118,87 +121,55 @@ export function Home() {
|
||||
)
|
||||
}
|
||||
|
||||
function UpdateNotification() {
|
||||
function UpdateNotification(props: { width: number }) {
|
||||
const update = useUpdateNotification()
|
||||
const exit = useExit()
|
||||
const theme = useTheme()
|
||||
const remoteMessage = "A remote server cannot be updated from here. Updating it is recommended."
|
||||
const [hovered, setHovered] = createSignal<"primary" | "close">()
|
||||
const [hovered, setHovered] = createSignal(false)
|
||||
const backdrop = () => (hovered() ? theme.background.action.primary.hovered : theme.background.default)
|
||||
createEffect(() => {
|
||||
update.notification()
|
||||
setHovered(undefined)
|
||||
setHovered(false)
|
||||
})
|
||||
|
||||
return (
|
||||
<Show when={update.notification()} keyed>
|
||||
{(state) => (
|
||||
<box flexShrink={0} marginTop={4} alignItems="center">
|
||||
<Switch>
|
||||
<Match when={state.source === "client" || !state.remote}>
|
||||
<box
|
||||
alignItems="center"
|
||||
paddingLeft={1}
|
||||
paddingRight={1}
|
||||
backgroundColor={hovered() === "primary" ? theme.background.action.primary.hovered : undefined}
|
||||
onMouseOver={() => setHovered("primary")}
|
||||
onMouseOut={() => setHovered(undefined)}
|
||||
onMouseUp={() => update.open?.("notification")}
|
||||
>
|
||||
<UpdateMessage
|
||||
title={state.type === "installed" ? "Update installed" : "Update available"}
|
||||
description={`Version ${state.version} is ${state.type === "installed" ? "installed" : "available"}. Click for more details`}
|
||||
backdrop={
|
||||
hovered() === "primary" ? theme.background.action.primary.hovered : theme.background.default
|
||||
}
|
||||
/>
|
||||
</box>
|
||||
</Match>
|
||||
<Match when={state.type === "available" && state.source === "server" && state.remote}>
|
||||
<box alignItems="center">
|
||||
<UpdateMessage
|
||||
title="Server update available"
|
||||
description={remoteMessage}
|
||||
backdrop={theme.background.default}
|
||||
/>
|
||||
<FadeInText
|
||||
fg={theme.text.subdued}
|
||||
backdrop={hovered() === "close" ? theme.background.action.primary.hovered : theme.background.default}
|
||||
sweepWidth={stringWidth(remoteMessage)}
|
||||
sweepOffset={Math.floor((stringWidth(remoteMessage) - stringWidth("Close")) / 2)}
|
||||
marginTop={1}
|
||||
paddingLeft={1}
|
||||
paddingRight={1}
|
||||
bg={hovered() === "close" ? theme.background.action.primary.hovered : undefined}
|
||||
onMouseOver={() => setHovered("close")}
|
||||
onMouseOut={() => setHovered(undefined)}
|
||||
onMouseUp={update.dismiss}
|
||||
>
|
||||
Close
|
||||
</FadeInText>
|
||||
</box>
|
||||
</Match>
|
||||
</Switch>
|
||||
</box>
|
||||
)}
|
||||
{(state) => {
|
||||
const remote = state.source === "server" && state.remote
|
||||
return (
|
||||
<Show when={!remote || state.type === "available"}>
|
||||
<box
|
||||
flexShrink={0}
|
||||
flexDirection="row"
|
||||
justifyContent="center"
|
||||
width={props.width}
|
||||
maxWidth="100%"
|
||||
gap={1}
|
||||
backgroundColor={hovered() ? theme.background.action.primary.hovered : undefined}
|
||||
onMouseOver={() => setHovered(true)}
|
||||
onMouseOut={() => setHovered(false)}
|
||||
onMouseUp={() => {
|
||||
if (remote) return update.dismiss()
|
||||
if (state.type === "installed") return exit()
|
||||
update.open?.("notification")
|
||||
}}
|
||||
>
|
||||
<FadeInText fg={theme.text.subdued} backdrop={backdrop()}>
|
||||
<Show when={!remote}>
|
||||
<span style={{ fg: theme.text.action.primary.selected }}>
|
||||
{state.type === "installed" ? "/exit" : "/update"}
|
||||
</span>
|
||||
</Show>
|
||||
{remote
|
||||
? "remote server update available"
|
||||
: state.type === "installed"
|
||||
? ` restart to use v${state.version}`
|
||||
: ` to install v${state.version}`}
|
||||
</FadeInText>
|
||||
</box>
|
||||
</Show>
|
||||
)
|
||||
}}
|
||||
</Show>
|
||||
)
|
||||
}
|
||||
|
||||
function UpdateMessage(props: { title: string; description: string; backdrop: RGBA }) {
|
||||
const theme = useTheme()
|
||||
const titleWidth = stringWidth(props.title)
|
||||
const descriptionWidth = stringWidth(props.description)
|
||||
const width = Math.max(titleWidth, descriptionWidth)
|
||||
return (
|
||||
<FadeInText width={width} height={2} wrapMode="none" fg={theme.text.default} backdrop={props.backdrop}>
|
||||
<span style={{ fg: theme.text.action.primary.selected, attributes: TextAttributes.BOLD }}>
|
||||
{" ".repeat(Math.floor((width - titleWidth) / 2))}
|
||||
{props.title}
|
||||
</span>
|
||||
{"\n"}
|
||||
<span style={{ fg: theme.text.subdued }}>
|
||||
{" ".repeat(Math.floor((width - descriptionWidth) / 2))}
|
||||
{props.description}
|
||||
</span>
|
||||
</FadeInText>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -87,7 +87,6 @@ import { useLocation } from "../../context/location"
|
||||
import { Slot } from "../../plugin/render"
|
||||
import { usePlugin } from "../../plugin/context"
|
||||
import {
|
||||
backgroundToolRowIndex,
|
||||
cacheReuseDrop,
|
||||
createSessionRows,
|
||||
messageBoundaryIDs,
|
||||
@@ -95,7 +94,6 @@ import {
|
||||
sessionRowID,
|
||||
turnDuration,
|
||||
turnTokensPerSecond,
|
||||
type BackgroundToolTarget,
|
||||
type CacheUsage,
|
||||
type PartRef,
|
||||
type SessionRow,
|
||||
@@ -144,7 +142,6 @@ const context = createContext<{
|
||||
config: ReturnType<typeof useConfig>["data"]
|
||||
mutatePending: (action: PendingAction, inboxID: string) => Promise<boolean>
|
||||
pendingDelivery: (inboxID: string) => SessionInbox.Delivery | undefined
|
||||
jumpToBackgroundTool: (target: BackgroundToolTarget, beforeMessageID: string) => void
|
||||
}>()
|
||||
|
||||
function use() {
|
||||
@@ -671,25 +668,6 @@ export function Session(props: {
|
||||
alignMessage(messageID, Math.max(0, y - (message?.type === "assistant" ? 1 : 0)))
|
||||
})
|
||||
|
||||
const jumpToBackgroundTool = (target: BackgroundToolTarget, beforeMessageID: string) => {
|
||||
if (firstJump()) clearMessageNavigation()
|
||||
const jump = () => {
|
||||
const index = backgroundToolRowIndex(rows, messages(), target, beforeMessageID)
|
||||
if (index === -1) {
|
||||
if (data.session.message.more(route.sessionID)) prependHistory(0, jump)
|
||||
return
|
||||
}
|
||||
const id = sessionRowID(rows[index]!, boundaries()[index])
|
||||
if (!id) return
|
||||
ensureAllRows(() => {
|
||||
const child = scroll.getRenderable(id)
|
||||
if (!child) return
|
||||
alignMessage(id, Math.max(0, scroll.scrollTop + child.y - scroll.viewport.y - 1))
|
||||
})
|
||||
}
|
||||
jump()
|
||||
}
|
||||
|
||||
function toBottom() {
|
||||
clearMessageNavigation()
|
||||
ensureAllRowsPending = undefined
|
||||
@@ -1310,7 +1288,6 @@ export function Session(props: {
|
||||
config,
|
||||
mutatePending,
|
||||
pendingDelivery: (inboxID) => pendingDeliveries().get(inboxID),
|
||||
jumpToBackgroundTool,
|
||||
}}
|
||||
>
|
||||
<box flexDirection="row" flexGrow={1} minHeight={0}>
|
||||
@@ -2057,15 +2034,8 @@ function SessionSwitchMessageV2(props: { message: SessionMessageInfo }) {
|
||||
function SessionNoticeMessageV2(props: { message: SessionMessageInfo }) {
|
||||
const ctx = use()
|
||||
const theme = useTheme()
|
||||
const renderer = useRenderer()
|
||||
const [hover, setHover] = createSignal(false)
|
||||
const metadata = () => (props.message.type === "synthetic" ? props.message.metadata : undefined)
|
||||
const source = () => stringValue(metadata()?.source)
|
||||
const target = createMemo<BackgroundToolTarget | undefined>(() => {
|
||||
if (source() !== "shell") return
|
||||
const id = stringValue(metadata()?.shellID) ?? stringValue(metadata()?.jobID)
|
||||
return id ? { source: "shell", id } : undefined
|
||||
})
|
||||
const completion = () => source() === "subagent" || source() === "shell"
|
||||
const state = () => stringValue(metadata()?.state)
|
||||
const actor = () => (source() === "shell" ? "Shell" : Locale.titlecase(stringValue(metadata()?.agent) ?? "Subagent"))
|
||||
@@ -2083,7 +2053,6 @@ function SessionNoticeMessageV2(props: { message: SessionMessageInfo }) {
|
||||
const heading = () => `${state() === "completed" ? "↳" : "!"} ${actor()} ${status()}`
|
||||
const suffix = () => Locale.truncateWidth(` · ${description()}`, Math.max(0, ctx.width - 3 - stringWidth(heading())))
|
||||
const color = () => {
|
||||
if (hover()) return theme.text.action.secondary.hovered
|
||||
if (state() === "error") return theme.text.feedback.error.default
|
||||
if (state() === "cancelled") return theme.text.feedback.warning.default
|
||||
return theme.text.feedback.info.default
|
||||
@@ -2097,19 +2066,7 @@ function SessionNoticeMessageV2(props: { message: SessionMessageInfo }) {
|
||||
</InlineToolRow>
|
||||
}
|
||||
>
|
||||
<box
|
||||
id={target() ? `${target()!.source}-completion:${target()!.id}` : undefined}
|
||||
marginLeft={3}
|
||||
onMouseOver={() => {
|
||||
if (target()) setHover(true)
|
||||
}}
|
||||
onMouseOut={() => setHover(false)}
|
||||
onMouseUp={() => {
|
||||
const item = target()
|
||||
if (!item || renderer.getSelection()?.getSelectedText()) return
|
||||
ctx.jumpToBackgroundTool(item, props.message.id)
|
||||
}}
|
||||
>
|
||||
<box marginLeft={3}>
|
||||
<text wrapMode="none">
|
||||
<span style={{ fg: color() }}>{heading()}</span>
|
||||
<span style={{ fg: theme.text.subdued }}>{suffix()}</span>
|
||||
|
||||
@@ -35,8 +35,6 @@ export type SessionRow =
|
||||
| { type: "assistant-footer"; messageID: string }
|
||||
| { type: "turn-usage"; messageIDs: string[]; previousCache?: CacheUsage }
|
||||
|
||||
export type BackgroundToolTarget = { source: "shell"; id: string }
|
||||
|
||||
export function createSessionRows(sessionID: Accessor<string>, onSynced?: (sessionID: string) => void) {
|
||||
const data = useData()
|
||||
const client = useClient()
|
||||
@@ -54,11 +52,8 @@ export function createSessionRows(sessionID: Accessor<string>, onSynced?: (sessi
|
||||
)
|
||||
const visible = queued.size === 0 ? messages : messages.filter((message) => !queued.has(message.id))
|
||||
const boundary = revertBoundary()
|
||||
const rows = reduceSessionRows(
|
||||
boundary ? visible.filter((message) => message.id < boundary) : visible,
|
||||
inputs,
|
||||
turnTokens(),
|
||||
)
|
||||
const cutoff = boundary ? visible.findIndex((message) => message.id === boundary) : -1
|
||||
const rows = reduceSessionRows(cutoff === -1 ? visible : visible.slice(0, cutoff), inputs, turnTokens())
|
||||
partitionPending(rows, pendingPermissions())
|
||||
const position = rows.findIndex((row) => row.type === "message" && inputs.has(row.messageID))
|
||||
rows.splice(
|
||||
@@ -409,29 +404,6 @@ export function sessionRowID(row: SessionRow, boundaryID?: string) {
|
||||
if (row.type === "part") return `session-part:${row.ref.messageID}:${row.ref.partID}`
|
||||
}
|
||||
|
||||
export function backgroundToolRowIndex(
|
||||
rows: SessionRow[],
|
||||
messages: SessionMessageInfo[],
|
||||
target: BackgroundToolTarget,
|
||||
beforeMessageID: string,
|
||||
) {
|
||||
const byID = new Map(messages.map((message) => [message.id, message]))
|
||||
const end = rows.findIndex((row) => row.type === "message" && row.messageID === beforeMessageID)
|
||||
return rows.slice(0, end === -1 ? rows.length : end).findLastIndex((row) => {
|
||||
if (row.type !== "part") return false
|
||||
if (row.ref.partID === target.id) return true
|
||||
const message = byID.get(row.ref.messageID)
|
||||
if (message?.type !== "assistant") return false
|
||||
const part = resolvePart(message, row.ref.partID)
|
||||
return (
|
||||
part?.type === "tool" &&
|
||||
part.name.toLowerCase() === "shell" &&
|
||||
part.state.status !== "streaming" &&
|
||||
part.state.metadata?.shellID === target.id
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
function rowBoundaryMessageID(row: SessionRow, messages: Map<string, SessionMessageInfo>) {
|
||||
if (row.type === "message") {
|
||||
const message = messages.get(row.messageID)
|
||||
|
||||
@@ -1087,6 +1087,19 @@ test("removes committed revert messages from local state", async () => {
|
||||
const sessionID = "session-revert"
|
||||
const calls = createFetch((url) => {
|
||||
if (url.pathname === `/api/session/${sessionID}/message`) return json({ data: [], cursor: {} })
|
||||
if (url.pathname === `/api/session/${sessionID}/inbox`)
|
||||
return json({
|
||||
data: [
|
||||
{
|
||||
id: "msg_001",
|
||||
sessionID,
|
||||
type: "user",
|
||||
payload: { text: "msg_001" },
|
||||
delivery: "steer",
|
||||
timeCreated: 0,
|
||||
},
|
||||
],
|
||||
})
|
||||
}, events)
|
||||
let data!: ReturnType<typeof useData>
|
||||
|
||||
@@ -1132,6 +1145,7 @@ test("removes committed revert messages from local state", async () => {
|
||||
expect(data.session.message.get(sessionID, "msg_002")).toBeUndefined()
|
||||
expect(data.session.message.get(sessionID, "msg_003")).toBeUndefined()
|
||||
// The projector also drops inbox items enqueued at or after the boundary, without a cancel event.
|
||||
await wait(() => data.session.pending.list(sessionID).length === 1)
|
||||
expect(data.session.pending.list(sessionID).map((item) => item.id)).toEqual(["msg_001"])
|
||||
expect(data.session.input.list(sessionID)).toEqual(["msg_001"])
|
||||
expect(data.session.input.has(sessionID, "msg_002")).toBe(false)
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
import { expect, test } from "bun:test"
|
||||
import type { SessionMessageAssistant, SessionMessageAssistantTool, SessionMessageInfo } from "@opencode-ai/client"
|
||||
import type { SessionMessageAssistant, SessionMessageInfo } from "@opencode-ai/client"
|
||||
import { createMemo, createRoot } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import {
|
||||
backgroundToolRowIndex,
|
||||
cacheReuseDrop,
|
||||
messageBoundaryIDs,
|
||||
reduceSessionRows,
|
||||
@@ -259,63 +258,6 @@ test("assigns stable IDs to tool rows for direct navigation", () => {
|
||||
])
|
||||
})
|
||||
|
||||
test("finds background tool launch rows for completion navigation", () => {
|
||||
const messages: SessionMessageInfo[] = [
|
||||
assistant("assistant-1", [
|
||||
{
|
||||
type: "tool",
|
||||
id: "shell-1",
|
||||
name: "shell",
|
||||
state: completed({ shellID: "sh_first", status: "running" }),
|
||||
time: { created: 1 },
|
||||
},
|
||||
]),
|
||||
assistant("assistant-2", [
|
||||
{
|
||||
type: "tool",
|
||||
id: "subagent-1",
|
||||
name: "subagent",
|
||||
state: completed({ sessionID: "child-1", status: "running" }),
|
||||
time: { created: 2 },
|
||||
},
|
||||
]),
|
||||
{
|
||||
type: "synthetic",
|
||||
id: "completion-1",
|
||||
text: "First background run completed",
|
||||
description: "First run",
|
||||
time: { created: 3 },
|
||||
},
|
||||
assistant("assistant-3", [
|
||||
{
|
||||
type: "tool",
|
||||
id: "subagent-2",
|
||||
name: "subagent",
|
||||
state: completed({ sessionID: "child-1", status: "running" }),
|
||||
time: { created: 4 },
|
||||
},
|
||||
{
|
||||
type: "tool",
|
||||
id: "subagent-foreground",
|
||||
name: "subagent",
|
||||
state: completed({ sessionID: "child-1", status: "completed" }),
|
||||
time: { created: 5 },
|
||||
},
|
||||
]),
|
||||
{
|
||||
type: "synthetic",
|
||||
id: "completion-2",
|
||||
text: "Second background run completed",
|
||||
description: "Second run",
|
||||
time: { created: 6 },
|
||||
},
|
||||
]
|
||||
const rows = reduceSessionRows(messages)
|
||||
|
||||
expect(backgroundToolRowIndex(rows, messages, { source: "shell", id: "shell-1" }, "completion-2")).toBe(0)
|
||||
expect(backgroundToolRowIndex(rows, messages, { source: "shell", id: "sh_first" }, "completion-2")).toBe(0)
|
||||
})
|
||||
|
||||
test("groups exploration parts across assistant messages until a delimiter", () => {
|
||||
const messages: SessionMessageInfo[] = [
|
||||
{ type: "user", id: "user-1", text: "Explore", time: { created: 0 } },
|
||||
@@ -620,14 +562,3 @@ function assistant(id: string, content: SessionMessageAssistant["content"]): Ses
|
||||
function pending() {
|
||||
return { status: "streaming" as const, input: "" }
|
||||
}
|
||||
|
||||
function completed(
|
||||
metadata: Record<string, string>,
|
||||
): Extract<SessionMessageAssistantTool["state"], { status: "completed" }> {
|
||||
return {
|
||||
status: "completed",
|
||||
input: {},
|
||||
content: [{ type: "text", text: "Background" }],
|
||||
metadata,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
import { expect, test } from "bun:test"
|
||||
import { type Renderable, ScrollBoxRenderable } from "@opentui/core"
|
||||
import { createTestRenderer } from "@opentui/core/testing"
|
||||
import { Effect, FileSystem } from "effect"
|
||||
import { Global } from "@opencode-ai/util/global"
|
||||
import { createEventStream, createFetch, directory, json } from "./fixture/tui-client"
|
||||
import { tmpdir } from "./fixture/fixture"
|
||||
|
||||
test.each([40, 120])("completion notices do not navigate at width %s", async (width) => {
|
||||
await using state = await tmpdir()
|
||||
const setup = await createTestRenderer({ width, height: 36, useThread: false, kittyKeyboard: true })
|
||||
setup.renderer.start()
|
||||
const session = {
|
||||
id: "ses_notices",
|
||||
title: "Completion notices",
|
||||
projectID: "project",
|
||||
location: { directory },
|
||||
cost: 0,
|
||||
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
|
||||
time: { created: 0, updated: 0 },
|
||||
}
|
||||
const notices = [
|
||||
{ source: "shell", state: "completed", shellID: "shell-1", label: "Shell finished", description: "Done" },
|
||||
{
|
||||
source: "shell",
|
||||
state: "error",
|
||||
jobID: "shell-1",
|
||||
label: "Shell failed",
|
||||
description: "Long command ".repeat(30),
|
||||
},
|
||||
{
|
||||
source: "shell",
|
||||
state: "cancelled",
|
||||
shellID: "shell-1",
|
||||
label: "Shell cancelled",
|
||||
description: "Cancelled command",
|
||||
},
|
||||
{ source: "subagent", state: "completed", sessionID: "child-1", label: "Subagent finished", description: "Done" },
|
||||
{ source: "subagent", state: "error", sessionID: "child-1", label: "Subagent failed", description: "Failed" },
|
||||
{
|
||||
source: "subagent",
|
||||
state: "cancelled",
|
||||
sessionID: "child-1",
|
||||
label: "Subagent cancelled",
|
||||
description: "Cancelled",
|
||||
},
|
||||
]
|
||||
const messages = [
|
||||
{ id: "user-0", type: "user", text: "Run background tasks", time: { created: 0 } },
|
||||
{
|
||||
id: "assistant-0",
|
||||
type: "assistant",
|
||||
agent: "build",
|
||||
model: { providerID: "test", id: "test" },
|
||||
content: [
|
||||
{
|
||||
type: "tool",
|
||||
id: "shell-1",
|
||||
name: "shell",
|
||||
state: {
|
||||
status: "completed",
|
||||
input: { command: "echo done", background: true },
|
||||
content: [{ type: "text", text: "Running" }],
|
||||
metadata: { shellID: "shell-1" },
|
||||
},
|
||||
time: { created: 1, completed: 2 },
|
||||
},
|
||||
],
|
||||
time: { created: 1, completed: 2 },
|
||||
},
|
||||
...Array.from({ length: 20 }, (_, index) => ({
|
||||
id: `history-${index}`,
|
||||
type: "user",
|
||||
text: `History message ${index}`,
|
||||
time: { created: index + 3 },
|
||||
})),
|
||||
...notices.map(({ label, description, ...metadata }, index) => ({
|
||||
id: `notice-${index}`,
|
||||
type: "synthetic",
|
||||
text: label,
|
||||
description,
|
||||
metadata,
|
||||
time: { created: index + 30 },
|
||||
})),
|
||||
]
|
||||
const calls = createFetch((url) => {
|
||||
if (url.pathname === "/api/session") return json({ data: [session], cursor: {} })
|
||||
if (url.pathname === `/api/session/${session.id}`) return json({ data: session })
|
||||
if (url.pathname === `/api/session/${session.id}/message`) return json({ data: messages.toReversed(), cursor: {} })
|
||||
if (url.pathname === `/api/session/${session.id}/inbox`) return json({ data: [] })
|
||||
if (url.pathname === `/api/session/${session.id}/permission`) return json({ data: [] })
|
||||
return undefined
|
||||
}, createEventStream())
|
||||
const server = Bun.serve({ port: 0, idleTimeout: 0, fetch: (request) => calls.fetch(request) })
|
||||
const { run } = await import("../src/app")
|
||||
const task = Effect.runPromise(
|
||||
run({
|
||||
app: { name: "test", version: "test", channel: "test" },
|
||||
server: { endpoint: { url: server.url.toString() } },
|
||||
config: {
|
||||
get: async () => ({ animations: false, tabs: { enabled: false } }),
|
||||
update: async () => ({}),
|
||||
},
|
||||
packages: { prepare: async () => ({ directory: "" }) },
|
||||
args: { sessionID: session.id },
|
||||
terminalHandoff: async () => ({ renderer: setup.renderer, mode: "dark", complete: () => {} }),
|
||||
log: () => {},
|
||||
}).pipe(Effect.provide(Global.layerWith({ state: state.path })), Effect.provide(FileSystem.layerNoop({}))),
|
||||
)
|
||||
try {
|
||||
await setup.waitForFrame((frame) => frame.includes("Subagent cancelled"))
|
||||
await setup.waitForVisualIdle()
|
||||
const find = (root: Renderable): ScrollBoxRenderable | undefined =>
|
||||
root instanceof ScrollBoxRenderable && root.getRenderable("history-19")
|
||||
? root
|
||||
: root.getChildren().map(find).find(Boolean)
|
||||
const scroll = find(setup.renderer.root)
|
||||
if (!scroll) throw new Error("Session scrollbox not found")
|
||||
expect(scroll.scrollTop).toBeGreaterThan(0)
|
||||
const before = scroll.scrollTop
|
||||
for (const notice of notices) {
|
||||
const lines = setup.captureCharFrame().split("\n")
|
||||
const y = lines.findIndex((line) => line.includes(notice.label))
|
||||
expect(y).toBeGreaterThanOrEqual(0)
|
||||
const x = lines[y].indexOf(notice.label)
|
||||
await setup.mockMouse.click(x + 1, y)
|
||||
await setup.waitForVisualIdle()
|
||||
expect(scroll.scrollTop).toBe(before)
|
||||
expect(setup.renderer.currentFocusedRenderable?.id).toBe(scroll.id)
|
||||
expect(setup.captureCharFrame()).toContain(notice.label)
|
||||
}
|
||||
} finally {
|
||||
setup.renderer.destroy()
|
||||
await task
|
||||
await server.stop()
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,731 @@
|
||||
import { expect, test } from "bun:test"
|
||||
import path from "node:path"
|
||||
import { mkdir, rename, symlink } from "node:fs/promises"
|
||||
import { fileURLToPath, pathToFileURL } from "node:url"
|
||||
import { Host } from "@opencode-ai/plugin/host"
|
||||
import "../src/plugin/runtime-plugin-support.bun"
|
||||
import { createPluginSources } from "../src/plugin/source"
|
||||
import { createSourceWatcher } from "../src/plugin/watch"
|
||||
import { createSignal } from "solid-js"
|
||||
import { Plugin } from "@opencode-ai/plugin/tui"
|
||||
import { tmpdir } from "./fixture/fixture"
|
||||
|
||||
test("a fresh local plugin generation observes edited helper exports", async () => {
|
||||
await using sources = await fixture()
|
||||
const entry = new URL("tui.ts", sources.url)
|
||||
const helper = new URL("helper.ts", sources.url)
|
||||
await Bun.write(entry, 'export { value as default } from "./helper.ts"')
|
||||
await Bun.write(helper, 'export const value = "before"')
|
||||
const before = await sources.read(entry.href)
|
||||
expect(before.module).toMatchObject({ default: "before" })
|
||||
await Bun.write(helper, 'export const value = "after"')
|
||||
expect((await sources.read(entry.href)).module).toMatchObject({ default: "after" })
|
||||
expect(before.module).toMatchObject({ default: "before" })
|
||||
})
|
||||
|
||||
test("tracks transitive imports through the Solid runtime transform", async () => {
|
||||
const watched: string[] = []
|
||||
await using sources = await fixture(async (file) => {
|
||||
watched.push(file)
|
||||
})
|
||||
const entry = new URL("tui.tsx", sources.url)
|
||||
const helper = new URL("nested/label.ts", sources.url)
|
||||
await Bun.write(entry, 'export { value as default } from "./panel"')
|
||||
await Bun.write(
|
||||
new URL("panel.tsx", sources.url),
|
||||
'import { label } from "./nested/label"; export const Panel = () => <text>{label}</text>; export const value = label',
|
||||
)
|
||||
await Bun.write(helper, 'export const label = "before"')
|
||||
const before = await sources.read(entry.href)
|
||||
expect(before.module).toMatchObject({ default: "before" })
|
||||
expect(watched).toContain(fileURLToPath(helper))
|
||||
await Bun.write(helper, 'export const label = "after"')
|
||||
const after = await sources.read(entry.href)
|
||||
expect(after.version).not.toBe(before.version)
|
||||
expect(after.module).toMatchObject({ default: "after" })
|
||||
})
|
||||
|
||||
test("unchanged bytes are a no-op, reverted bytes get a fresh module", async () => {
|
||||
await using sources = await fixture()
|
||||
const entry = new URL("tui.ts", sources.url)
|
||||
await Bun.write(entry, "export default { value: 1 }")
|
||||
const first = await sources.read(entry.href)
|
||||
await Bun.write(entry, "export default { value: 1 }")
|
||||
expect(await sources.read(entry.href)).toBe(first)
|
||||
await Bun.write(entry, "export default { value: 2 }")
|
||||
expect((await sources.read(entry.href)).module).toMatchObject({ default: { value: 2 } })
|
||||
await Bun.write(entry, "export default { value: 1 }")
|
||||
const reverted = await sources.read(entry.href)
|
||||
expect(reverted.version).not.toBe(first.version)
|
||||
expect(reverted.module).not.toBe(first.module)
|
||||
expect(reverted.module).toMatchObject({ default: { value: 1 } })
|
||||
})
|
||||
|
||||
test("renamed exports, failed loads, and new dependencies recover without cached helpers", async () => {
|
||||
await using sources = await fixture()
|
||||
const entry = new URL("tui.ts", sources.url)
|
||||
const helper = new URL("helper.ts", sources.url)
|
||||
await Bun.write(entry, 'import { value } from "./helper"; export default value')
|
||||
await Bun.write(helper, "export const value = 1")
|
||||
const before = await sources.read(entry.href)
|
||||
expect(before.module).toMatchObject({ default: 1 })
|
||||
await Bun.write(helper, "export const renamed = 2")
|
||||
await expect(sources.read(entry.href)).rejects.toThrow()
|
||||
expect(before.module).toMatchObject({ default: 1 })
|
||||
await Bun.write(entry, 'import { renamed } from "./helper"; export default renamed')
|
||||
expect((await sources.read(entry.href)).module).toMatchObject({ default: 2 })
|
||||
await Bun.write(helper, 'export { value as renamed } from "./new/leaf"')
|
||||
await expect(sources.read(entry.href)).rejects.toThrow("leaf")
|
||||
await Bun.write(new URL("new/leaf.ts", sources.url), "export const value = 3")
|
||||
expect((await sources.read(entry.href)).module).toMatchObject({ default: 3 })
|
||||
})
|
||||
|
||||
test("shared runtime and ordinary package identities survive plugin generations", async () => {
|
||||
await using sources = await fixture()
|
||||
const entry = new URL("tui.ts", sources.url)
|
||||
await Bun.write(new URL("node_modules/example/package.json", sources.url), '{"type":"module","main":"index.js"}')
|
||||
const library = new URL("node_modules/example/index.js", sources.url)
|
||||
await Bun.write(library, 'export default { value: "package" }')
|
||||
const pkg = await Host.load(library.href)
|
||||
if (typeof pkg !== "object" || pkg === null || !("default" in pkg)) throw new Error("Missing package fixture export")
|
||||
for (const label of ["before", "after"]) {
|
||||
await Bun.write(
|
||||
entry,
|
||||
`import { createSignal } from "solid-js"
|
||||
import { Plugin } from "@opencode-ai/plugin/tui"
|
||||
import value from "example"
|
||||
export { createSignal, Plugin, value }; export const label = ${JSON.stringify(label)}`,
|
||||
)
|
||||
const loaded = (await sources.read(entry.href)).module
|
||||
if (typeof loaded !== "object" || loaded === null) throw new Error("Missing plugin fixture exports")
|
||||
expect("createSignal" in loaded && loaded.createSignal).toBe(createSignal)
|
||||
expect("Plugin" in loaded && loaded.Plugin).toBe(Plugin)
|
||||
expect("value" in loaded && loaded.value).toBe(pkg.default)
|
||||
expect(loaded).toMatchObject({ label })
|
||||
}
|
||||
})
|
||||
|
||||
test("helper import.meta stays anchored to its source, including assets and resolution", async () => {
|
||||
await using sources = await fixture()
|
||||
const entry = new URL("tui.ts", sources.url)
|
||||
const helper = new URL("nested/helper.ts", sources.url)
|
||||
await Bun.write(entry, 'export { default } from "./nested/helper"')
|
||||
await Bun.write(new URL("nested/asset.txt", sources.url), "asset")
|
||||
await Bun.write(
|
||||
helper,
|
||||
`export default {
|
||||
url: import.meta.url, dir: import.meta.dirname, file: import.meta.file,
|
||||
resolved: import.meta.resolve("./asset.txt"),
|
||||
resolvedSync: import.meta.resolveSync("./asset.txt"),
|
||||
asset: await Bun.file(new URL("./asset.txt", import.meta.url)).text(),
|
||||
}`,
|
||||
)
|
||||
expect((await sources.read(entry.href)).module).toMatchObject({
|
||||
default: {
|
||||
url: helper.href,
|
||||
dir: path.dirname(fileURLToPath(helper)),
|
||||
file: "helper.ts",
|
||||
asset: "asset",
|
||||
resolved: new URL("nested/asset.txt", sources.url).href,
|
||||
resolvedSync: fileURLToPath(new URL("nested/asset.txt", sources.url)),
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test("literal dynamic imports and JSON join the source graph", async () => {
|
||||
const watched: string[] = []
|
||||
await using sources = await fixture(async (file) => {
|
||||
watched.push(file)
|
||||
})
|
||||
const entry = new URL("tui.ts", sources.url)
|
||||
const json = new URL("data.json", sources.url)
|
||||
await Bun.write(entry, 'export default (await import("./helper")).default')
|
||||
await Bun.write(new URL("helper.ts", sources.url), 'import data from "./data.json"; export default data.value')
|
||||
await Bun.write(json, '{"value":1}')
|
||||
expect((await sources.read(entry.href)).module).toMatchObject({ default: 1 })
|
||||
expect(watched).toContain(fileURLToPath(json))
|
||||
await Bun.write(json, '{"value":2}')
|
||||
expect((await sources.read(entry.href)).module).toMatchObject({ default: 2 })
|
||||
})
|
||||
|
||||
test("real watchers observe atomic saves to nested, outside-root, and symlinked helpers", async () => {
|
||||
let changes = 0
|
||||
const watcher = createSourceWatcher(() => {
|
||||
changes++
|
||||
})
|
||||
using _watcher = { [Symbol.dispose]: watcher.dispose }
|
||||
await using sources = await fixture(watcher.wait)
|
||||
const entry = new URL("plugin/tui.ts", sources.url)
|
||||
const helper = new URL("shared/nested/helper.ts", sources.url)
|
||||
await Bun.write(helper, "export const value = 1")
|
||||
await Bun.write(entry, 'export { value as default } from "./link"')
|
||||
await symlink(fileURLToPath(helper), fileURLToPath(new URL("plugin/link.ts", sources.url)))
|
||||
expect((await sources.read(entry.href)).module).toMatchObject({ default: 1 })
|
||||
const count = changes
|
||||
await Bun.write(new URL(helper.href + ".new"), "export const value = 2")
|
||||
await rename(new URL(helper.href + ".new"), helper)
|
||||
const deadline = Date.now() + 3000
|
||||
while (Date.now() < deadline) {
|
||||
if (changes > count) break
|
||||
await Bun.sleep(10)
|
||||
}
|
||||
expect(changes).toBeGreaterThan(count)
|
||||
expect((await sources.read(entry.href)).module).toMatchObject({ default: 2 })
|
||||
})
|
||||
|
||||
test.each(["", "?mode=plugin", "?mode=plugin#section"])(
|
||||
"Node reloads the local ESM graph without relocating source files: %s",
|
||||
async (suffix) => {
|
||||
await using dir = await tmpdir()
|
||||
const script = path.join(dir.path, "probe.ts")
|
||||
await Bun.write(
|
||||
script,
|
||||
`
|
||||
import { createPluginSources } from ${JSON.stringify(fileURLToPath(new URL("../src/plugin/source.ts", import.meta.url)))}
|
||||
import { mkdir, symlink, writeFile } from "node:fs/promises"
|
||||
import { fileURLToPath } from "node:url"
|
||||
import assert from "node:assert/strict"
|
||||
const entry = new URL("./entry.mjs", import.meta.url)
|
||||
const helper = new URL("./helper.mjs", import.meta.url)
|
||||
const suffix = ${JSON.stringify(suffix)}
|
||||
const watched = []
|
||||
const sources = createPluginSources(async file => { watched.push(file) })
|
||||
try {
|
||||
const library = new URL("./shared/index.mjs", import.meta.url)
|
||||
await mkdir(new URL("./shared", import.meta.url), { recursive: true })
|
||||
await writeFile(library, 'export default { shared: true }')
|
||||
await mkdir(new URL("./node_modules", import.meta.url), { recursive: true })
|
||||
await symlink(fileURLToPath(new URL("./shared", import.meta.url)), fileURLToPath(new URL("./node_modules/example", import.meta.url)), process.platform === "win32" ? "junction" : "dir")
|
||||
const external = await import(library.href)
|
||||
await writeFile(entry, 'import state from "./node_modules/example/index.mjs"; export { state }; export { value as default, source } from "./helper.mjs' + suffix + '"')
|
||||
await writeFile(helper, 'export const value = 1; export const source = import.meta.url')
|
||||
const initial = await sources.read(entry.href)
|
||||
assert.equal(initial.module.default, 1)
|
||||
assert.equal(initial.module.state, external.default)
|
||||
assert.equal(new URL(initial.module.source).pathname, helper.pathname)
|
||||
assert.equal(new URL(initial.module.source).searchParams.get("mode"), suffix ? "plugin" : null)
|
||||
assert.equal(new URL(initial.module.source).hash, suffix.includes("#") ? "#section" : "")
|
||||
assert.equal(await sources.read(entry.href), initial)
|
||||
await writeFile(helper, 'export const value = 2; export const source = import.meta.url')
|
||||
const updated = (await sources.read(entry.href)).module
|
||||
assert.equal(updated.default, 2)
|
||||
assert.equal(updated.state, external.default)
|
||||
assert.equal(watched.includes(fileURLToPath(library)), false)
|
||||
assert.equal(new URL(updated.source).pathname, helper.pathname)
|
||||
assert.equal(new URL(updated.source).searchParams.get("mode"), suffix ? "plugin" : null)
|
||||
assert.equal(new URL(updated.source).hash, suffix.includes("#") ? "#section" : "")
|
||||
console.log("node graph reload passed")
|
||||
} finally { sources.dispose() }
|
||||
`,
|
||||
)
|
||||
const build = await Bun.build({
|
||||
entrypoints: [script],
|
||||
target: "node",
|
||||
format: "esm",
|
||||
outdir: dir.path,
|
||||
naming: "probe.mjs",
|
||||
})
|
||||
expect(build.success).toBe(true)
|
||||
const child = Bun.spawn(["node", "--no-warnings", path.join(dir.path, "probe.mjs")], {
|
||||
stdout: "pipe",
|
||||
stderr: "pipe",
|
||||
})
|
||||
const [stdout, stderr, exit] = await Promise.all([
|
||||
new Response(child.stdout).text(),
|
||||
new Response(child.stderr).text(),
|
||||
child.exited,
|
||||
])
|
||||
expect({ stdout, stderr, exit }).toEqual({ stdout: "node graph reload passed\n", stderr: "", exit: 0 })
|
||||
},
|
||||
)
|
||||
|
||||
test("computed imports retain the importing helper's resolution base", async () => {
|
||||
await using sources = await fixture()
|
||||
const entry = new URL("tui.ts", sources.url)
|
||||
await Bun.write(entry, 'import { read } from "./nested/reader"; export default await read("./leaf.mjs")')
|
||||
await Bun.write(
|
||||
new URL("nested/reader.ts", sources.url),
|
||||
"export const read = async (name: string) => (await import(name)).default",
|
||||
)
|
||||
await Bun.write(new URL("nested/leaf.mjs", sources.url), 'export default "computed"')
|
||||
expect((await sources.read(entry.href)).module).toMatchObject({ default: "computed" })
|
||||
})
|
||||
|
||||
test("empty source modules remain valid dependencies", async () => {
|
||||
await using sources = await fixture()
|
||||
const entry = new URL("tui.ts", sources.url)
|
||||
await Bun.write(entry, 'import "./empty"; export default "ready"')
|
||||
await Bun.write(new URL("empty.ts", sources.url), "")
|
||||
expect((await sources.read(entry.href)).module).toMatchObject({ default: "ready" })
|
||||
})
|
||||
|
||||
test("folded imports are watched and dead imports need not be installed", async () => {
|
||||
await using sources = await fixture()
|
||||
const entry = new URL("tui.ts", sources.url)
|
||||
const helper = new URL("helper.ts", sources.url)
|
||||
await Bun.write(entry, 'if (false) require("not-installed"); export default (await import("./" + "helper")).default')
|
||||
await Bun.write(helper, 'export default "before"')
|
||||
expect((await sources.read(entry.href)).module).toMatchObject({ default: "before" })
|
||||
await Bun.write(helper, 'export default "after"')
|
||||
expect((await sources.read(entry.href)).module).toMatchObject({ default: "after" })
|
||||
})
|
||||
|
||||
test("cycles retain one canonical entrypoint per generation and old bindings stay pinned", async () => {
|
||||
await using sources = await fixture()
|
||||
const entry = new URL("tui.ts", sources.url)
|
||||
await Bun.write(
|
||||
entry,
|
||||
'import { read } from "./helper"; export const value = {}; export default () => read() === value',
|
||||
)
|
||||
await Bun.write(new URL("helper.ts", sources.url), 'import { value } from "./tui"; export const read = () => value')
|
||||
const before = (await sources.read(entry.href)).module
|
||||
if (typeof before !== "object" || before === null || !("default" in before) || typeof before.default !== "function")
|
||||
throw new Error("Missing cycle fixture")
|
||||
expect(before.default()).toBe(true)
|
||||
await Bun.write(
|
||||
entry,
|
||||
'import { read } from "./helper"; export const value = { changed: true }; export default () => read() === value',
|
||||
)
|
||||
const after = (await sources.read(entry.href)).module
|
||||
if (typeof after !== "object" || after === null || !("default" in after) || typeof after.default !== "function")
|
||||
throw new Error("Missing cycle fixture")
|
||||
expect(after.default()).toBe(true)
|
||||
expect(before.default()).toBe(true)
|
||||
})
|
||||
|
||||
test("old callbacks keep native deferred-import behavior after a failed replacement", async () => {
|
||||
await using sources = await fixture()
|
||||
const entry = new URL("tui.ts", sources.url)
|
||||
const helper = new URL("helper.ts", sources.url)
|
||||
await Bun.write(entry, 'export default async () => (await import("./helper")).default')
|
||||
await Bun.write(helper, 'export default "old helper"')
|
||||
const before = (await sources.read(entry.href)).module
|
||||
if (typeof before !== "object" || before === null || !("default" in before) || typeof before.default !== "function")
|
||||
throw new Error("Missing deferred fixture")
|
||||
await Bun.write(helper, 'export default "new helper"')
|
||||
await Bun.write(entry, 'throw new Error("replacement failed"); export default null')
|
||||
await expect(sources.read(entry.href)).rejects.toThrow("replacement failed")
|
||||
// Best-effort reload retains the registration, not a snapshot of files that
|
||||
// its callbacks have not imported yet.
|
||||
expect(await before.default()).toBe("new helper")
|
||||
})
|
||||
|
||||
test("each helper resolves packages from its own directory", async () => {
|
||||
await using sources = await fixture()
|
||||
const entry = new URL("tui.ts", sources.url)
|
||||
await Bun.write(
|
||||
entry,
|
||||
'import { Plugin } from "@opencode-ai/plugin/tui"; import value from "./nested/helper"; export default { Plugin, value }',
|
||||
)
|
||||
await Bun.write(new URL("nested/helper.ts", sources.url), 'import value from "example"; export default value')
|
||||
for (const directory of ["", "nested/"]) {
|
||||
await Bun.write(
|
||||
new URL(directory + "node_modules/example/package.json", sources.url),
|
||||
'{"type":"module","main":"index.js"}',
|
||||
)
|
||||
await Bun.write(
|
||||
new URL(directory + "node_modules/example/index.js", sources.url),
|
||||
`export default ${JSON.stringify(directory || "root")}`,
|
||||
)
|
||||
}
|
||||
expect((await sources.read(entry.href)).module).toMatchObject({ default: { value: "nested/" } })
|
||||
})
|
||||
|
||||
test("a warm deferred import can retain its native cache after a failed replacement", async () => {
|
||||
await using sources = await fixture()
|
||||
const entry = new URL("tui.ts", sources.url)
|
||||
const helper = new URL("helper.mjs", sources.url)
|
||||
await Bun.write(entry, 'export default async () => (await import("./helper.mjs")).default')
|
||||
await Bun.write(helper, 'export default "cached"')
|
||||
const before = (await sources.read(entry.href)).module
|
||||
if (typeof before !== "object" || before === null || !("default" in before) || typeof before.default !== "function")
|
||||
throw new Error("Missing deferred fixture")
|
||||
expect(await before.default()).toBe("cached")
|
||||
await Bun.write(helper, 'export default "changed"')
|
||||
await Bun.write(entry, 'throw new Error("replacement failed"); export default null')
|
||||
await expect(sources.read(entry.href)).rejects.toThrow("replacement failed")
|
||||
expect(await before.default()).toBe("cached")
|
||||
})
|
||||
|
||||
test("computed-only dependencies remain outside static reload tracking", async () => {
|
||||
const watched: string[] = []
|
||||
await using sources = await fixture(async (file) => {
|
||||
watched.push(file)
|
||||
})
|
||||
const entry = new URL("tui.ts", sources.url)
|
||||
const helper = new URL("helper.mjs", sources.url)
|
||||
await Bun.write(entry, "export default async name => (await import(name)).default")
|
||||
await Bun.write(helper, 'export default "cached"')
|
||||
const before = await sources.read(entry.href)
|
||||
const mod = before.module
|
||||
if (typeof mod !== "object" || mod === null || !("default" in mod) || typeof mod.default !== "function")
|
||||
throw new Error("Missing computed fixture")
|
||||
expect(await mod.default("./helper.mjs")).toBe("cached")
|
||||
await Bun.write(helper, 'export default "changed"')
|
||||
expect(watched).not.toContain(fileURLToPath(helper))
|
||||
expect(await sources.read(entry.href)).toBe(before)
|
||||
})
|
||||
|
||||
test("unchanged evaluation failures do not repeat import-time effects", async () => {
|
||||
await using sources = await fixture()
|
||||
const entry = new URL("tui.ts", sources.url)
|
||||
const code = `import { appendFileSync } from "node:fs"
|
||||
appendFileSync(new URL("./attempts.log", import.meta.url), "attempt\\n")
|
||||
throw new Error("broken evaluation")`
|
||||
await Bun.write(entry, code)
|
||||
await expect(sources.read(entry.href)).rejects.toThrow("broken evaluation")
|
||||
await expect(sources.read(entry.href)).rejects.toThrow("broken evaluation")
|
||||
expect(await Bun.file(new URL("attempts.log", sources.url)).text()).toBe("attempt\n")
|
||||
await Bun.write(entry, code + "\n// another generation")
|
||||
await expect(sources.read(entry.href)).rejects.toThrow("broken evaluation")
|
||||
expect(await Bun.file(new URL("attempts.log", sources.url)).text()).toBe("attempt\nattempt\n")
|
||||
})
|
||||
|
||||
test.each([
|
||||
'export default await import("not-installed-pkg").then(m => m.default, () => "fallback")',
|
||||
'let value; try { value = (await import("not-installed-pkg")).default } catch { value = "fallback" }; export default value',
|
||||
'let value; try { value = require("not-installed-pkg") } catch { value = "fallback" }; export default value',
|
||||
'export default await import("./not-installed").then(m => m.default, () => "fallback")',
|
||||
'let value; try { value = require("./not-installed") } catch { value = "fallback" }; export default value',
|
||||
])("optional dependencies keep their runtime fallback: %s", async (code) => {
|
||||
await using sources = await fixture()
|
||||
const entry = new URL("tui.ts", sources.url)
|
||||
await Bun.write(entry, code)
|
||||
expect((await sources.read(entry.href)).module).toMatchObject({ default: "fallback" })
|
||||
})
|
||||
|
||||
test("installed optional dependencies still resolve beside the importing source", async () => {
|
||||
await using sources = await fixture()
|
||||
const entry = new URL("tui.ts", sources.url)
|
||||
await Bun.write(new URL("node_modules/example/package.json", sources.url), '{"main":"index.cjs"}')
|
||||
await Bun.write(new URL("node_modules/example/index.cjs", sources.url), 'module.exports = "installed"')
|
||||
await Bun.write(
|
||||
entry,
|
||||
`const dynamic = await import("example").then(m => m.default, () => "fallback")
|
||||
let sync; try { sync = require("example") } catch { sync = "fallback" }
|
||||
export default { dynamic, sync }`,
|
||||
)
|
||||
expect((await sources.read(entry.href)).module).toMatchObject({
|
||||
default: { dynamic: "installed", sync: "installed" },
|
||||
})
|
||||
})
|
||||
|
||||
test("computed imports reject asynchronously and capture their argument at the call site", async () => {
|
||||
await using sources = await fixture()
|
||||
const entry = new URL("tui.ts", sources.url)
|
||||
await Bun.write(new URL("one.mjs", sources.url), 'export default "one"')
|
||||
await Bun.write(new URL("two.mjs", sources.url), 'export default "two"')
|
||||
await Bun.write(new URL("data.json", sources.url), '{"value":7}')
|
||||
await Bun.write(
|
||||
entry,
|
||||
`const load = name => import(name)
|
||||
const fallback = await load("not-installed-pkg").then(m => m.default, () => "fallback")
|
||||
let name = "./one.mjs"
|
||||
const pending = import(name)
|
||||
name = "./two.mjs"
|
||||
const json = name => import(name, { with: { type: "json" } })
|
||||
export default { fallback, value: (await pending).default, json: (await json("./data.json")).default.value }`,
|
||||
)
|
||||
expect((await sources.read(entry.href)).module).toMatchObject({
|
||||
default: { fallback: "fallback", value: "one", json: 7 },
|
||||
})
|
||||
})
|
||||
|
||||
test("missing static dependencies still fail the plugin load", async () => {
|
||||
await using sources = await fixture()
|
||||
const entry = new URL("tui.ts", sources.url)
|
||||
await Bun.write(entry, 'import value from "not-installed-pkg"; export default value')
|
||||
await expect(sources.read(entry.href)).rejects.toThrow("not-installed-pkg")
|
||||
})
|
||||
|
||||
test.each([false, true])("plugin errors retain source filenames (during load: %s)", async (duringLoad) => {
|
||||
await using sources = await fixture()
|
||||
const entry = new URL("tui.ts", sources.url)
|
||||
await Bun.write(
|
||||
new URL("nested/helper.ts", sources.url),
|
||||
'export function boom() { void import.meta.url; throw new Error("source trace") }',
|
||||
)
|
||||
await Bun.write(entry, `import { boom } from "./nested/helper"; ${duringLoad ? "boom();" : ""} export default boom`)
|
||||
const error = await (async () => {
|
||||
try {
|
||||
const loaded = (await sources.read(entry.href)).module
|
||||
if (
|
||||
typeof loaded !== "object" ||
|
||||
loaded === null ||
|
||||
!("default" in loaded) ||
|
||||
typeof loaded.default !== "function"
|
||||
)
|
||||
throw new Error("Missing stack fixture")
|
||||
loaded.default()
|
||||
return undefined
|
||||
} catch (error) {
|
||||
return error
|
||||
}
|
||||
})()
|
||||
expect(error).toBeInstanceOf(Error)
|
||||
if (!(error instanceof Error)) throw error
|
||||
expect(error.message).toBe("source trace")
|
||||
expect(error.stack).toMatch(/nested[/\\]helper\.ts:\d+:\d+/)
|
||||
})
|
||||
|
||||
test.each([
|
||||
["static import", 'import value from "example"; export default value', "import"],
|
||||
["dynamic import", 'export default (await import("example")).default', "import"],
|
||||
["literal require", 'export default require("example")', "require"],
|
||||
["computed require", 'const read = name => require(name); export default read("example")', "require"],
|
||||
["require alias", 'const read = require; export default read("example")', "require"],
|
||||
["require.resolve", 'export default require(require.resolve("example"))', "require"],
|
||||
["import.meta.require", 'export default import.meta.require("example")', "require"],
|
||||
["shadowed require", 'const read = require => require("example"); export default read(name => name)', "example"],
|
||||
["computed local require", 'const read = name => require(name); export default read("./data.json").value', "local"],
|
||||
[
|
||||
"computed optional require",
|
||||
'const read = name => { try { return require(name) } catch { return "fallback" } }; export default read("not-installed-pkg")',
|
||||
"fallback",
|
||||
],
|
||||
])("%s preserves direct runtime resolution", async (_name, code, expected) => {
|
||||
await using sources = await fixture()
|
||||
const entry = new URL("tui.ts", sources.url)
|
||||
await Bun.write(
|
||||
new URL("node_modules/example/package.json", sources.url),
|
||||
JSON.stringify({
|
||||
exports: { import: "./import.mjs", require: "./require.cjs" },
|
||||
}),
|
||||
)
|
||||
await Bun.write(new URL("node_modules/example/import.mjs", sources.url), 'export default "import"')
|
||||
await Bun.write(new URL("node_modules/example/require.cjs", sources.url), 'module.exports = "require"')
|
||||
await Bun.write(new URL("data.json", sources.url), '{"value":"local"}')
|
||||
await Bun.write(entry, code)
|
||||
expect(await Host.load(entry.href)).toMatchObject({ default: expected })
|
||||
expect((await sources.read(entry.href)).module).toMatchObject({ default: expected })
|
||||
})
|
||||
|
||||
test.each(["ts", "tsx"])("%s helpers preserve source path globals and lexical bindings", async (extension) => {
|
||||
await using sources = await fixture()
|
||||
const entry = new URL("tui.ts", sources.url)
|
||||
const helper = new URL(`nested/helper.${extension}`, sources.url)
|
||||
await Bun.write(
|
||||
helper,
|
||||
`export default {
|
||||
file: __filename, dir: __dirname,
|
||||
shadowed: ((__filename, __dirname) => [__filename, __dirname])("file", "dir"),
|
||||
asset: await Bun.file(__dirname + "/asset.txt").text(),
|
||||
}`,
|
||||
)
|
||||
await Bun.write(new URL("nested/asset.txt", sources.url), "asset")
|
||||
await Bun.write(entry, `export { default } from "./nested/helper.${extension}"`)
|
||||
const expected = {
|
||||
default: {
|
||||
file: fileURLToPath(helper),
|
||||
dir: path.dirname(fileURLToPath(helper)),
|
||||
shadowed: ["file", "dir"],
|
||||
asset: "asset",
|
||||
},
|
||||
}
|
||||
expect(await Host.load(entry.href)).toMatchObject(expected)
|
||||
expect((await sources.read(entry.href)).module).toMatchObject(expected)
|
||||
})
|
||||
|
||||
test("createRequire retains its explicit package resolution base", async () => {
|
||||
await using sources = await fixture()
|
||||
const entry = new URL("tui.ts", sources.url)
|
||||
for (const directory of ["", "nested/"]) {
|
||||
await Bun.write(new URL(directory + "node_modules/example/package.json", sources.url), '{"main":"index.cjs"}')
|
||||
await Bun.write(
|
||||
new URL(directory + "node_modules/example/index.cjs", sources.url),
|
||||
`module.exports = ${JSON.stringify(directory || "root")}`,
|
||||
)
|
||||
}
|
||||
await Bun.write(
|
||||
entry,
|
||||
`import { createRequire } from "node:module"
|
||||
const require = createRequire(new URL("./nested/helper.ts", import.meta.url))
|
||||
export default require("example")`,
|
||||
)
|
||||
expect(await Host.load(entry.href)).toMatchObject({ default: "nested/" })
|
||||
expect((await sources.read(entry.href)).module).toMatchObject({ default: "nested/" })
|
||||
})
|
||||
|
||||
test("deferred require callbacks use the invalidated native cache", async () => {
|
||||
await using sources = await fixture()
|
||||
const entry = new URL("tui.ts", sources.url)
|
||||
const helper = new URL("data.json", sources.url)
|
||||
await Bun.write(entry, 'export default () => require("./data.json").value')
|
||||
await Bun.write(helper, '{"value":"before"}')
|
||||
const before = (await sources.read(entry.href)).module
|
||||
if (typeof before !== "object" || before === null || !("default" in before) || typeof before.default !== "function")
|
||||
throw new Error("Missing require fixture")
|
||||
await Bun.write(helper, '{"value":"after"}')
|
||||
const after = (await sources.read(entry.href)).module
|
||||
if (typeof after !== "object" || after === null || !("default" in after) || typeof after.default !== "function")
|
||||
throw new Error("Missing require fixture")
|
||||
expect(before.default()).toBe("after")
|
||||
expect(after.default()).toBe("after")
|
||||
})
|
||||
|
||||
test("local package requires select main rather than the ESM module field", async () => {
|
||||
await using sources = await fixture()
|
||||
const entry = new URL("tui.ts", sources.url)
|
||||
await Bun.write(new URL("helper/package.json", sources.url), '{"main":"require.cjs","module":"import.mjs"}')
|
||||
await Bun.write(new URL("helper/require.cjs", sources.url), 'module.exports = "require"')
|
||||
await Bun.write(new URL("helper/import.mjs", sources.url), 'export default "import"')
|
||||
await Bun.write(entry, 'export default require("./helper")')
|
||||
expect(await Host.load(entry.href)).toMatchObject({ default: "require" })
|
||||
expect((await sources.read(entry.href)).module).toMatchObject({ default: "require" })
|
||||
})
|
||||
|
||||
test.each([
|
||||
'import value from "example"; export default value',
|
||||
'export default (await import("example")).default',
|
||||
'import value from "./node_modules/example/index.js"; export default value',
|
||||
])("symlinked package instances remain external: %s", async (code) => {
|
||||
const watched: string[] = []
|
||||
await using sources = await fixture(async (file) => {
|
||||
watched.push(file)
|
||||
})
|
||||
const entry = new URL("plugin/tui.ts", sources.url)
|
||||
const library = new URL("lib/index.js", sources.url)
|
||||
await Bun.write(new URL("lib/package.json", sources.url), '{"type":"module","main":"index.js"}')
|
||||
await Bun.write(library, 'export default { value: "shared" }')
|
||||
await mkdir(new URL("plugin/node_modules", sources.url), { recursive: true })
|
||||
await symlink(
|
||||
fileURLToPath(new URL("lib", sources.url)),
|
||||
fileURLToPath(new URL("plugin/node_modules/example", sources.url)),
|
||||
process.platform === "win32" ? "junction" : "dir",
|
||||
)
|
||||
const host = await Host.load(library.href)
|
||||
if (typeof host !== "object" || host === null || !("default" in host)) throw new Error("Missing package fixture")
|
||||
for (const generation of [1, 2]) {
|
||||
await Bun.write(entry, `${code}; export const generation = ${generation}`)
|
||||
const loaded = (await sources.read(entry.href)).module
|
||||
if (typeof loaded !== "object" || loaded === null || !("default" in loaded))
|
||||
throw new Error("Missing plugin fixture")
|
||||
expect(loaded.default).toBe(host.default)
|
||||
expect(loaded).toMatchObject({ generation })
|
||||
}
|
||||
expect(watched).not.toContain(fileURLToPath(library))
|
||||
})
|
||||
|
||||
test.each(["static", "query", "file-query", "dynamic-query"])(
|
||||
"JSON text %s imports survive graph resolution and edits",
|
||||
async (kind) => {
|
||||
await using sources = await fixture()
|
||||
const entry = new URL("tui.ts", sources.url)
|
||||
const json = new URL("data.json", sources.url)
|
||||
const specifier = kind === "file-query" ? json.href + "?v=1" : "./data.json" + (kind === "static" ? "" : "?v=1")
|
||||
await Bun.write(
|
||||
entry,
|
||||
kind === "dynamic-query"
|
||||
? `const text = (await import(${JSON.stringify(specifier)}, { with: { type: "text" } })).default; export default JSON.parse(text).value`
|
||||
: `import text from ${JSON.stringify(specifier)} with { type: "text" }; export default JSON.parse(text).value`,
|
||||
)
|
||||
await Bun.write(json, '{"value":"before"}')
|
||||
expect(await Host.load(entry.href)).toMatchObject({ default: "before" })
|
||||
expect((await sources.read(entry.href)).module).toMatchObject({ default: "before" })
|
||||
await Bun.write(json, '{"value":"after"}')
|
||||
expect((await sources.read(entry.href)).module).toMatchObject({ default: "after" })
|
||||
},
|
||||
)
|
||||
|
||||
test.each(["mjs", "json"])("literal %s query imports are watched and reloaded natively", async (extension) => {
|
||||
const watched: string[] = []
|
||||
await using sources = await fixture(async (file) => {
|
||||
watched.push(file)
|
||||
})
|
||||
const entry = new URL("tui.ts", sources.url)
|
||||
const helper = new URL(`helper.${extension}`, sources.url)
|
||||
const prefix = extension === "json" ? "" : "export default "
|
||||
await Bun.write(entry, `export default async () => (await import("./helper.${extension}?mode=plugin")).default`)
|
||||
await Bun.write(helper, prefix + '"before"')
|
||||
const before = (await sources.read(entry.href)).module
|
||||
if (typeof before !== "object" || before === null || !("default" in before) || typeof before.default !== "function")
|
||||
throw new Error("Missing query fixture")
|
||||
await Bun.write(helper, prefix + '"after"')
|
||||
await Bun.write(entry, 'throw new Error("replacement failed"); export default null')
|
||||
await expect(sources.read(entry.href)).rejects.toThrow("replacement failed")
|
||||
expect(watched).toContain(fileURLToPath(helper))
|
||||
expect(await before.default()).toBe("after")
|
||||
await Bun.write(entry, `export { default } from "./helper.${extension}?mode=plugin"`)
|
||||
expect((await sources.read(entry.href)).module).toMatchObject({ default: "after" })
|
||||
await Bun.write(helper, prefix + '"repaired"')
|
||||
expect((await sources.read(entry.href)).module).toMatchObject({ default: "repaired" })
|
||||
})
|
||||
|
||||
test("query spellings retain distinct module identities and source URLs", async () => {
|
||||
await using sources = await fixture()
|
||||
const entry = new URL("tui.ts", sources.url)
|
||||
const helper = new URL("helper.mjs", sources.url)
|
||||
await Bun.write(
|
||||
helper,
|
||||
"export default { url: import.meta.url, path: import.meta.path, file: import.meta.file, filename: __filename }",
|
||||
)
|
||||
await Bun.write(
|
||||
entry,
|
||||
'import one from "./helper.mjs?one/path"; import two from "./helper.mjs?two"; export default { same: one === two, ...one }',
|
||||
)
|
||||
const expected = {
|
||||
default: {
|
||||
same: false,
|
||||
url: helper.href + "?one/path",
|
||||
path: fileURLToPath(helper),
|
||||
file: "helper.mjs",
|
||||
filename: fileURLToPath(helper),
|
||||
},
|
||||
}
|
||||
expect(await Host.load(entry.href)).toMatchObject(expected)
|
||||
expect((await sources.read(entry.href)).module).toMatchObject(expected)
|
||||
})
|
||||
|
||||
test("literal file URLs retain source loading and helper edits", async () => {
|
||||
await using sources = await fixture()
|
||||
const entry = new URL("tui.ts", sources.url)
|
||||
const helper = new URL("helper.ts", sources.url)
|
||||
await Bun.write(helper, 'export default "before"')
|
||||
await Bun.write(entry, `export { default } from ${JSON.stringify(helper.href)}`)
|
||||
expect(await Host.load(entry.href)).toMatchObject({ default: "before" })
|
||||
expect((await sources.read(entry.href)).module).toMatchObject({ default: "before" })
|
||||
await Bun.write(helper, 'export default "after"')
|
||||
expect((await sources.read(entry.href)).module).toMatchObject({ default: "after" })
|
||||
})
|
||||
|
||||
test.each(["ts", "tsx"])("%s wildcard package barrels retain live exports", async (extension) => {
|
||||
await using sources = await fixture()
|
||||
const entry = new URL("tui.ts", sources.url)
|
||||
await Bun.write(new URL("node_modules/example/package.json", sources.url), '{"type":"module","main":"index.js"}')
|
||||
await Bun.write(
|
||||
new URL("node_modules/example/index.js", sources.url),
|
||||
'export default { value: "package" }; export let count = 0; export const increment = () => count++',
|
||||
)
|
||||
await Bun.write(
|
||||
new URL(`helper.${extension}`, sources.url),
|
||||
'export * from "example"; import value from "example"; export default value',
|
||||
)
|
||||
await Bun.write(entry, `export { default } from "./helper.${extension}"`)
|
||||
expect(await Host.load(entry.href)).toMatchObject({ default: { value: "package" } })
|
||||
expect((await sources.read(entry.href)).module).toMatchObject({ default: { value: "package" } })
|
||||
await Bun.write(entry, `export { default, count, increment } from "./helper.${extension}"`)
|
||||
const loaded = (await sources.read(entry.href)).module
|
||||
if (
|
||||
typeof loaded !== "object" ||
|
||||
loaded === null ||
|
||||
!("increment" in loaded) ||
|
||||
typeof loaded.increment !== "function"
|
||||
)
|
||||
throw new Error("Missing barrel fixture")
|
||||
expect(loaded).toMatchObject({ default: { value: "package" }, count: 0 })
|
||||
loaded.increment()
|
||||
expect(loaded).toMatchObject({ count: 1 })
|
||||
})
|
||||
|
||||
async function fixture(watch: (file: string) => Promise<void> = async () => {}) {
|
||||
const dir = await tmpdir()
|
||||
const sources = createPluginSources(watch)
|
||||
return {
|
||||
...sources,
|
||||
url: pathToFileURL(dir.path + path.sep),
|
||||
async [Symbol.asyncDispose]() {
|
||||
sources.dispose()
|
||||
await dir[Symbol.asyncDispose]()
|
||||
},
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user