Compare commits

..
Author SHA1 Message Date
Dax Raad 78e76bfdec fix(core): give timelines independent identities 2026-09-06 01:21:42 -04:00
Dax Raad 26d2d06fba refactor(core): keep fork selection inside projection 2026-09-05 21:25:19 -04:00
Dax Raad c3f874747e feat(core): share session history through timelines 2026-09-05 21:12:15 -04:00
Dax Raad 52685d4517 fix(tui): simplify home update notices 2026-09-05 02:17:09 -04:00
Luke Parker 30d1049942 fix(app): load worktree inventory on demand and cap concurrent server requests (#47441) 2026-09-05 05:25:43 +00:00
Stefan Avramandslickstef11 21adcb4969 fix(console): route enterprise forms to Chatwoot (#47437)
Co-authored-by: slickstef11 <stefanavram22@gmail.com>
2026-09-05 00:05:40 -04:00
Kit Langton c72b535dee fix(tui): reload local plugin dependency graphs (#47388)
Reload statically discovered local plugin dependencies through native module caches while preserving shared package identities and best-effort last-good registrations.
2026-09-04 23:41:02 -04:00
opencode-agent[bot]andjlongster 8d1a9799f4 fix(tui): remove completion notice links (#47426)
Co-authored-by: jlongster <17031+jlongster@users.noreply.github.com>
2026-09-04 22:46:28 -04:00
David Hill 86ba09c6e0 fix(app): increase vertical tabs minimum width (#47424) 2026-09-05 12:10:12 +10:00
opencode-agent[bot]andHona 41cb354c3e fix(app): preserve vertical navigation label and icon widths (#47418)
Co-authored-by: Hona <10430890+Hona@users.noreply.github.com>
2026-09-05 11:30:12 +10:00
James Long 23f3f8b6ca fix(tui): reference update command (#47415) 2026-09-04 20:43:44 -04:00
Aiden Cline baab05727d fix(core): compare Codex GPT versions by major and minor (#47404) 2026-09-04 19:22:12 -05:00
72 changed files with 2490 additions and 1158 deletions
+2
View File
@@ -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 },
+8
View File
@@ -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")
})
})
+16 -7
View File
@@ -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)
)
}
+3 -1
View File
@@ -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,
}
}
+27 -4
View File
@@ -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)
+1 -1
View File
@@ -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)}
/>
+4 -4
View File
@@ -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"] })
})
})
+58
View File
@@ -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)
},
}
}
+10 -1
View File
@@ -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>(() => ({
+10 -9
View File
@@ -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
View File
@@ -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": [
{
+1 -1
View File
@@ -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
View File
@@ -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
+21 -2
View File
@@ -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\`);`,
)
+13 -305
View File
@@ -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: {
+4 -2
View File
@@ -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
+7 -14
View File
@@ -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,
+26 -36
View File
@@ -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
+48 -65
View File
@@ -1,6 +1,6 @@
export * as SessionProjector from "./projector.js"
import { and, asc, desc, eq, gt, gte, inArray, isNull, lt, lte, or, sql } from "drizzle-orm"
import { and, 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)
}),
)
+7 -20
View File
@@ -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) {
+2 -1
View File
@@ -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'`,
),
+1 -1
View File
@@ -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* (
+39 -6
View File
@@ -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",
{
+18 -29
View File
@@ -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
+123
View File
@@ -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)
})
+22 -2
View File
@@ -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,
-67
View File
@@ -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",
+2
View File
@@ -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,
+7 -5
View File
@@ -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 } },
})
+4 -1
View File
@@ -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",
+2
View File
@@ -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",
+24 -5
View File
@@ -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
+14 -5
View File
@@ -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,
+37 -9
View File
@@ -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,
}
}
+9 -1
View File
@@ -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 }),
+3
View File
@@ -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",
+7 -179
View File
@@ -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(
+5
View File
@@ -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 -22
View File
@@ -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 -12
View File
@@ -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}`
}
+59
View File
@@ -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) }
}
+43
View File
@@ -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() }
}
+56
View File
@@ -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"
}
}
+3 -3
View File
@@ -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.
+52 -81
View File
@@ -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>
)
}
+1 -44
View File
@@ -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>
+2 -30
View File
@@ -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)
+14
View File
@@ -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 -70
View File
@@ -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()
}
})
+731
View File
@@ -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]()
},
}
}