This commit is contained in:
James Long 2026-04-13 16:54:11 -04:00
commit ba499fb405
28 changed files with 2830 additions and 406 deletions

View file

@ -0,0 +1,101 @@
import { TextAttributes } from "@opentui/core"
import { useTheme } from "../context/theme"
import { useDialog } from "../ui/dialog"
import { createStore } from "solid-js/store"
import { For } from "solid-js"
import { useKeyboard } from "@opentui/solid"
export function DialogSessionDeleteFailed(props: {
session: string
workspace: string
onDelete?: () => boolean | void | Promise<boolean | void>
onRestore?: () => boolean | void | Promise<boolean | void>
onDone?: () => void
}) {
const dialog = useDialog()
const { theme } = useTheme()
const [store, setStore] = createStore({
active: "delete" as "delete" | "restore",
})
const options = [
{
id: "delete" as const,
title: "Delete workspace",
description: "Delete the workspace and all sessions attached to it.",
run: props.onDelete,
},
{
id: "restore" as const,
title: "Restore to new workspace",
description: "Try to restore this session into a new workspace.",
run: props.onRestore,
},
]
async function confirm() {
const result = await options.find((item) => item.id === store.active)?.run?.()
if (result === false) return
props.onDone?.()
if (!props.onDone) dialog.clear()
}
useKeyboard((evt) => {
if (evt.name === "return") {
void confirm()
}
if (evt.name === "left" || evt.name === "up") {
setStore("active", "delete")
}
if (evt.name === "right" || evt.name === "down") {
setStore("active", "restore")
}
})
return (
<box paddingLeft={2} paddingRight={2} gap={1}>
<box flexDirection="row" justifyContent="space-between">
<text attributes={TextAttributes.BOLD} fg={theme.text}>
Failed to Delete Session
</text>
<text fg={theme.textMuted} onMouseUp={() => dialog.clear()}>
esc
</text>
</box>
<text fg={theme.textMuted} wrapMode="word">
{`The session "${props.session}" could not be deleted because the workspace "${props.workspace}" is not available.`}
</text>
<text fg={theme.textMuted} wrapMode="word">
Choose how you want to recover this broken workspace session.
</text>
<box flexDirection="column" paddingBottom={1} gap={1}>
<For each={options}>
{(item) => (
<box
flexDirection="column"
paddingLeft={1}
paddingRight={1}
paddingTop={1}
paddingBottom={1}
backgroundColor={item.id === store.active ? theme.primary : undefined}
onMouseUp={() => {
setStore("active", item.id)
void confirm()
}}
>
<text
attributes={TextAttributes.BOLD}
fg={item.id === store.active ? theme.selectedListItemText : theme.text}
>
{item.title}
</text>
<text fg={item.id === store.active ? theme.selectedListItemText : theme.textMuted} wrapMode="word">
{item.description}
</text>
</box>
)}
</For>
</box>
</box>
)
}

View file

@ -13,8 +13,10 @@ import { DialogSessionRename } from "./dialog-session-rename"
import { Keybind } from "@/util/keybind"
import { createDebouncedSignal } from "../util/signal"
import { useToast } from "../ui/toast"
import { DialogWorkspaceCreate, openWorkspaceSession } from "./dialog-workspace-create"
import { DialogWorkspaceCreate, openWorkspaceSession, restoreWorkspaceSession } from "./dialog-workspace-create"
import { Spinner } from "./spinner"
import { errorMessage } from "@/util/error"
import { DialogSessionDeleteFailed } from "./dialog-session-delete-failed"
type WorkspaceStatus = "connected" | "connecting" | "disconnected" | "error"
@ -30,7 +32,7 @@ export function DialogSessionList() {
const [toDelete, setToDelete] = createSignal<string>()
const [search, setSearch] = createDebouncedSignal("", 150)
const [searchResults] = createResource(search, async (query) => {
const [searchResults, { refetch }] = createResource(search, async (query) => {
if (!query) return undefined
const result = await sdk.client.session.list({ search: query, limit: 30 })
return result.data ?? []
@ -56,6 +58,57 @@ export function DialogSessionList() {
))
}
function recover(session: NonNullable<ReturnType<typeof sessions>[number]>) {
const workspace = project.workspace.get(session.workspaceID!)
const list = () => dialog.replace(() => <DialogSessionList />)
dialog.replace(() => (
<DialogSessionDeleteFailed
session={session.title}
workspace={workspace?.name ?? session.workspaceID!}
onDone={list}
onDelete={async () => {
const current = currentSessionID()
const info = current ? sync.data.session.find((item) => item.id === current) : undefined
const result = await sdk.client.experimental.workspace.remove({ id: session.workspaceID! })
if (result.error) {
toast.show({
variant: "error",
title: "Failed to delete workspace",
message: errorMessage(result.error),
})
return false
}
await project.workspace.sync()
await sync.session.refresh()
if (search()) await refetch()
if (info?.workspaceID === session.workspaceID) {
route.navigate({ type: "home" })
}
return true
}}
onRestore={() => {
dialog.replace(() => (
<DialogWorkspaceCreate
onSelect={(workspaceID) =>
restoreWorkspaceSession({
dialog,
sdk,
sync,
project,
toast,
workspaceID,
sessionID: session.id,
done: list,
})
}
/>
))
return false
}}
/>
))
}
const options = createMemo(() => {
const today = new Date().toDateString()
return sessions()
@ -145,9 +198,42 @@ export function DialogSessionList() {
title: "delete",
onTrigger: async (option) => {
if (toDelete() === option.value) {
sdk.client.session.delete({
sessionID: option.value,
})
const session = sessions().find((item) => item.id === option.value)
const status = session?.workspaceID ? project.workspace.status(session.workspaceID) : undefined
try {
const result = await sdk.client.session.delete({
sessionID: option.value,
})
if (result.error) {
if (session?.workspaceID) {
recover(session)
} else {
toast.show({
variant: "error",
title: "Failed to delete session",
message: errorMessage(result.error),
})
}
setToDelete(undefined)
return
}
} catch (err) {
if (session?.workspaceID) {
recover(session)
} else {
toast.show({
variant: "error",
title: "Failed to delete session",
message: errorMessage(err),
})
}
setToDelete(undefined)
return
}
if (status && status !== "connected") {
await sync.session.refresh()
}
if (search()) await refetch()
setToDelete(undefined)
return
}

View file

@ -6,6 +6,8 @@ import { useSync } from "@tui/context/sync"
import { useProject } from "@tui/context/project"
import { createMemo, createSignal, onMount } from "solid-js"
import { setTimeout as sleep } from "node:timers/promises"
import { errorData, errorMessage } from "@/util/error"
import { Log } from "@/util/log"
import { useSDK } from "../context/sdk"
import { useToast } from "../ui/toast"
@ -15,6 +17,8 @@ type Adaptor = {
description: string
}
const log = Log.Default.clone().tag("service", "tui-workspace")
function scoped(sdk: ReturnType<typeof useSDK>, sync: ReturnType<typeof useSync>, workspaceID: string) {
return createOpencodeClient({
baseUrl: sdk.url,
@ -33,8 +37,19 @@ export async function openWorkspaceSession(input: {
workspaceID: string
}) {
const client = scoped(input.sdk, input.sync, input.workspaceID)
log.info("workspace session create requested", {
workspaceID: input.workspaceID,
})
while (true) {
const result = await client.session.create({ workspaceID: input.workspaceID }).catch(() => undefined)
const result = await client.session
.create({ workspaceID: input.workspaceID, workspace: input.workspaceID })
.catch((err) => {
log.error("workspace session create request failed", {
workspaceID: input.workspaceID,
error: errorData(err),
})
return undefined
})
if (!result) {
input.toast.show({
message: "Failed to create workspace session",
@ -42,26 +57,115 @@ export async function openWorkspaceSession(input: {
})
return
}
if (result.response.status >= 500 && result.response.status < 600) {
log.info("workspace session create response", {
workspaceID: input.workspaceID,
status: result.response?.status,
sessionID: result.data?.id,
})
if (result.response?.status && result.response.status >= 500 && result.response.status < 600) {
log.warn("workspace session create retrying after server error", {
workspaceID: input.workspaceID,
status: result.response.status,
})
await sleep(1000)
continue
}
if (!result.data) {
log.error("workspace session create returned no data", {
workspaceID: input.workspaceID,
status: result.response?.status,
})
input.toast.show({
message: "Failed to create workspace session",
variant: "error",
})
return
}
await sleep(5000)
input.route.navigate({
type: "session",
sessionID: result.data.id,
})
log.info("workspace session create complete", {
workspaceID: input.workspaceID,
sessionID: result.data.id,
})
input.dialog.clear()
return
}
}
export async function restoreWorkspaceSession(input: {
dialog: ReturnType<typeof useDialog>
sdk: ReturnType<typeof useSDK>
sync: ReturnType<typeof useSync>
project: ReturnType<typeof useProject>
toast: ReturnType<typeof useToast>
workspaceID: string
sessionID: string
done?: () => void
}) {
log.info("session restore requested", {
workspaceID: input.workspaceID,
sessionID: input.sessionID,
})
const result = await input.sdk.client.experimental.workspace
.sessionRestore({ id: input.workspaceID, sessionID: input.sessionID })
.catch((err) => {
log.error("session restore request failed", {
workspaceID: input.workspaceID,
sessionID: input.sessionID,
error: errorData(err),
})
return undefined
})
if (!result?.data) {
log.error("session restore failed", {
workspaceID: input.workspaceID,
sessionID: input.sessionID,
status: result?.response?.status,
error: result?.error ? errorData(result.error) : undefined,
})
input.toast.show({
message: `Failed to restore session: ${errorMessage(result?.error ?? "no response")}`,
variant: "error",
})
return
}
log.info("session restore response", {
workspaceID: input.workspaceID,
sessionID: input.sessionID,
status: result.response?.status,
total: result.data.total,
})
await Promise.all([input.project.workspace.sync(), input.sync.session.refresh()]).catch((err) => {
log.error("session restore refresh failed", {
workspaceID: input.workspaceID,
sessionID: input.sessionID,
error: errorData(err),
})
throw err
})
log.info("session restore complete", {
workspaceID: input.workspaceID,
sessionID: input.sessionID,
total: result.data.total,
})
input.toast.show({
message: "Session restored into the new workspace",
variant: "success",
})
input.done?.()
if (input.done) return
input.dialog.clear()
}
export function DialogWorkspaceCreate(props: { onSelect: (workspaceID: string) => Promise<void> | void }) {
const dialog = useDialog()
const sync = useSync()
@ -123,18 +227,41 @@ export function DialogWorkspaceCreate(props: { onSelect: (workspaceID: string) =
const create = async (type: string) => {
if (creating()) return
setCreating(type)
log.info("workspace create requested", {
type,
})
const result = await sdk.client.experimental.workspace.create({ type, branch: null }).catch(() => undefined)
const result = await sdk.client.experimental.workspace.create({ type, branch: null }).catch((err) => {
log.error("workspace create request failed", {
type,
error: errorData(err),
})
return undefined
})
const workspace = result?.data
if (!workspace) {
setCreating(undefined)
log.error("workspace create failed", {
type,
status: result?.response.status,
error: result?.error ? errorData(result.error) : undefined,
})
toast.show({
message: "Failed to create workspace",
message: `Failed to create workspace: ${errorMessage(result?.error ?? "no response")}`,
variant: "error",
})
return
}
log.info("workspace create response", {
type,
workspaceID: workspace.id,
status: result.response?.status,
})
await project.workspace.sync()
log.info("workspace create synced", {
type,
workspaceID: workspace.id,
})
await props.onSelect(workspace.id)
setCreating(undefined)
}

View file

@ -611,8 +611,11 @@ export function Prompt(props: PromptProps) {
}
let sessionID = props.sessionID
console.log("jwl", sessionID)
if (sessionID == null) {
console.log("creating")
const res = await sdk.client.session.create({
workspace: props.workspaceID,
workspaceID: props.workspaceID,
})

View file

@ -21,8 +21,12 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
const sdk = useSDK()
const toast = useToast()
function providers() {
return sync.data?.provider ?? []
}
function isModelValid(model: { providerID: string; modelID: string }) {
const provider = sync.data.provider.find((x) => x.id === model.providerID)
const provider = providers().find((x) => x.id === model.providerID)
return !!provider?.models[model.modelID]
}
@ -178,7 +182,7 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
}
}
const provider = sync.data.provider[0]
const provider = providers()[0]
if (!provider) return undefined
const defaultModel = sync.data.provider_default[provider.id]
const firstModel = Object.values(provider.models)[0]
@ -221,7 +225,7 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
reasoning: false,
}
}
const provider = sync.data.provider.find((x) => x.id === value.providerID)
const provider = providers().find((x) => x.id === value.providerID)
const info = provider?.models[value.modelID]
return {
provider: provider?.name ?? value.providerID,
@ -336,7 +340,7 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
list() {
const m = currentModel()
if (!m) return []
const provider = sync.data.provider.find((x) => x.id === m.providerID)
const provider = providers().find((x) => x.id === m.providerID)
const info = provider?.models[m.modelID]
if (!info?.variants) return []
return Object.keys(info.variants)

View file

@ -3,6 +3,8 @@ import type { GlobalEvent, Event } from "@opencode-ai/sdk/v2"
import { createSimpleContext } from "./helper"
import { createGlobalEmitter } from "@solid-primitives/event-bus"
import { batch, onCleanup, onMount } from "solid-js"
import { Log } from "@/util/log"
import { errorData } from "@/util/error"
export type EventSource = {
subscribe: (handler: (event: GlobalEvent) => void) => Promise<() => void>
@ -19,13 +21,49 @@ export const { use: useSDK, provider: SDKProvider } = createSimpleContext({
}) => {
const abort = new AbortController()
let sse: AbortController | undefined
const log = Log.Default.clone().tag("service", "tui-sdk")
const raw = props.fetch ?? fetch
const traced: typeof fetch = async (input, init) => {
const req = input instanceof Request ? input : new Request(input, init)
const start = Date.now()
try {
const res = await raw(req)
const url = new URL(res.url || req.url)
if (!res.ok || url.searchParams.get("workspace")) {
const body = await res
.clone()
.text()
.catch(() => "")
log.info("sdk fetch", {
method: req.method,
request: req.url,
response: res.url || req.url,
status: res.status,
duration: Date.now() - start,
workspace: url.searchParams.get("workspace"),
body: body.slice(0, 1000),
})
}
return res
} catch (error) {
log.error("sdk fetch failed", {
method: req.method,
request: req.url,
duration: Date.now() - start,
error: errorData(error),
})
throw error
}
}
function createSDK() {
return createOpencodeClient({
baseUrl: props.url,
signal: abort.signal,
directory: props.directory,
fetch: props.fetch,
fetch: traced,
headers: props.headers,
})
}
@ -109,7 +147,7 @@ export const { use: useSDK, provider: SDKProvider } = createSimpleContext({
},
directory: props.directory,
event: emitter,
fetch: props.fetch ?? fetch,
fetch: traced,
url: props.url,
}
},

View file

@ -29,6 +29,7 @@ import { useExit } from "./exit"
import { useArgs } from "./args"
import { batch, createEffect, on } from "solid-js"
import { Log } from "@/util/log"
import { errorData } from "@/util/error"
import { ConsoleState, emptyConsoleState, type ConsoleState as ConsoleStateType } from "@/config/console-state"
export const { use: useSync, provider: SyncProvider } = createSimpleContext({
@ -107,11 +108,20 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
const event = useEvent()
const project = useProject()
const sdk = useSDK()
const log = Log.Default.clone().tag("service", "tui-sync")
event.subscribe((event) => {
switch (event.type) {
case "server.instance.disposed":
bootstrap()
log.info("bootstrap triggered by instance disposal", {
workspace: project.workspace.current(),
})
void bootstrap().catch((error) => {
log.error("bootstrap after disposal failed", {
workspace: project.workspace.current(),
error: errorData(error),
})
})
break
case "permission.replied": {
const requests = store.permission[event.properties.sessionID]
@ -351,30 +361,63 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
const args = useArgs()
async function bootstrap() {
console.log("bootstrapping")
const workspace = project.workspace.current()
const start = Date.now() - 30 * 24 * 60 * 60 * 1000
log.info("bootstrap started", { workspace, path: project.instance.path() })
function track<T>(name: string, promise: Promise<T>) {
return promise
.then((value) => {
log.debug("bootstrap request ok", { name, workspace })
return value
})
.catch((error) => {
log.error("bootstrap request failed", {
name,
workspace,
error: errorData(error),
})
throw error
})
}
const sessionListPromise = sdk.client.session
.list({ start: start })
.then((x) => (x.data ?? []).toSorted((a, b) => a.id.localeCompare(b.id)))
// blocking - include session.list when continuing a session
const providersPromise = sdk.client.config.providers({ workspace }, { throwOnError: true })
const providerListPromise = sdk.client.provider.list({ workspace }, { throwOnError: true })
const consoleStatePromise = sdk.client.experimental.console
.get({ workspace }, { throwOnError: true })
.then((x) => ConsoleState.parse(x.data))
.catch(() => emptyConsoleState)
const agentsPromise = sdk.client.app.agents({ workspace }, { throwOnError: true })
const configPromise = sdk.client.config.get({ workspace }, { throwOnError: true })
const projectPromise = project.sync()
const providersPromise = track(
"config.providers",
sdk.client.config.providers({ workspace }, { throwOnError: true }),
)
const providerListPromise = track(
"provider.list",
sdk.client.provider.list({ workspace }, { throwOnError: true }),
)
const consoleStatePromise = track(
"experimental.console.get",
sdk.client.experimental.console
.get({ workspace }, { throwOnError: true })
.then((x) => ConsoleState.parse(x.data))
.catch((error) => {
log.warn("console state unavailable", {
workspace,
error: errorData(error),
})
return emptyConsoleState
}),
)
const agentsPromise = track("app.agents", sdk.client.app.agents({ workspace }, { throwOnError: true }))
const configPromise = track("config.get", sdk.client.config.get({ workspace }, { throwOnError: true }))
const projectPromise = track("project.sync", project.sync())
const sessionSyncPromise = track("session.list", sessionListPromise)
const blockingRequests: Promise<unknown>[] = [
providersPromise,
providerListPromise,
agentsPromise,
configPromise,
projectPromise,
...(args.continue ? [sessionListPromise] : []),
...(args.continue ? [sessionSyncPromise] : []),
]
await Promise.all(blockingRequests)
@ -384,7 +427,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
const consoleStateResponse = consoleStatePromise
const agentsResponse = agentsPromise.then((x) => x.data ?? [])
const configResponse = configPromise.then((x) => x.data!)
const sessionListResponse = args.continue ? sessionListPromise : undefined
const sessionListResponse = args.continue ? sessionSyncPromise : undefined
return Promise.all([
providersResponse,
@ -414,34 +457,57 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
})
.then(() => {
if (store.status !== "complete") setStore("status", "partial")
log.info("bootstrap partial", { workspace })
// non-blocking
Promise.all([
...(args.continue ? [] : [sessionListPromise.then((sessions) => setStore("session", reconcile(sessions)))]),
consoleStatePromise.then((consoleState) => setStore("console_state", reconcile(consoleState))),
sdk.client.command.list({ workspace }).then((x) => setStore("command", reconcile(x.data ?? []))),
sdk.client.lsp.status({ workspace }).then((x) => setStore("lsp", reconcile(x.data!))),
sdk.client.mcp.status({ workspace }).then((x) => setStore("mcp", reconcile(x.data!))),
sdk.client.experimental.resource
.list({ workspace })
.then((x) => setStore("mcp_resource", reconcile(x.data ?? {}))),
sdk.client.formatter.status({ workspace }).then((x) => setStore("formatter", reconcile(x.data!))),
sdk.client.session.status({ workspace }).then((x) => {
...(args.continue
? []
: [
track("session.list.background", sessionListPromise).then((sessions) =>
setStore("session", reconcile(sessions)),
),
]),
track("experimental.console.get.background", consoleStatePromise).then((consoleState) =>
setStore("console_state", reconcile(consoleState)),
),
track("command.list", sdk.client.command.list({ workspace })).then((x) =>
setStore("command", reconcile(x.data ?? [])),
),
track("lsp.status", sdk.client.lsp.status({ workspace })).then((x) => setStore("lsp", reconcile(x.data!))),
track("mcp.status", sdk.client.mcp.status({ workspace })).then((x) => setStore("mcp", reconcile(x.data!))),
track("experimental.resource.list", sdk.client.experimental.resource.list({ workspace })).then((x) =>
setStore("mcp_resource", reconcile(x.data ?? {})),
),
track("formatter.status", sdk.client.formatter.status({ workspace })).then((x) =>
setStore("formatter", reconcile(x.data!)),
),
track("session.status", sdk.client.session.status({ workspace })).then((x) => {
setStore("session_status", reconcile(x.data!))
}),
sdk.client.provider.auth({ workspace }).then((x) => setStore("provider_auth", reconcile(x.data ?? {}))),
sdk.client.vcs.get({ workspace }).then((x) => setStore("vcs", reconcile(x.data))),
project.workspace.sync(),
]).then(() => {
setStore("status", "complete")
})
track("provider.auth", sdk.client.provider.auth({ workspace })).then((x) =>
setStore("provider_auth", reconcile(x.data ?? {})),
),
track("vcs.get", sdk.client.vcs.get({ workspace })).then((x) => setStore("vcs", reconcile(x.data))),
track("project.workspace.sync", project.workspace.sync()),
])
.then(() => {
log.info("bootstrap complete", { workspace })
setStore("status", "complete")
})
.catch((error) => {
log.error("bootstrap background failed", {
workspace,
error: errorData(error),
})
throw error
})
})
.catch(async (e) => {
Log.Default.error("tui bootstrap failed", {
error: e instanceof Error ? e.message : String(e),
name: e instanceof Error ? e.name : undefined,
stack: e instanceof Error ? e.stack : undefined,
.catch((error) => {
log.error("bootstrap failed", {
workspace,
error: errorData(error),
})
await exit(e)
throw error
})
}
@ -451,7 +517,15 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
() => project.workspace.current(),
() => {
fullSyncedSessions.clear()
void bootstrap()
log.info("bootstrap triggered by workspace change", {
workspace: project.workspace.current(),
})
void bootstrap().catch((error) => {
log.error("bootstrap effect failed", {
workspace: project.workspace.current(),
error: errorData(error),
})
})
},
),
)
@ -474,6 +548,13 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
if (match.found) return store.session[match.index]
return undefined
},
async refresh() {
const start = Date.now() - 30 * 24 * 60 * 60 * 1000
const sessions = await sdk.client.session
.list({ start })
.then((x) => (x.data ?? []).toSorted((a, b) => a.id.localeCompare(b.id)))
setStore("session", reconcile(sessions))
},
status(sessionID: string) {
const session = result.session.get(sessionID)
if (!session) return "idle"

View file

@ -1,11 +1,13 @@
import z from "zod"
import { setTimeout as sleep } from "node:timers/promises"
import { fn } from "@/util/fn"
import { Database, eq } from "@/storage/db"
import { Database, asc, eq } from "@/storage/db"
import { Project } from "@/project/project"
import { BusEvent } from "@/bus/bus-event"
import { GlobalBus } from "@/bus/global"
import { SyncEvent } from "@/sync"
import { EventTable } from "@/sync/event.sql"
import { EventID } from "@/sync/schema"
import { Log } from "@/util/log"
import { Filesystem } from "@/util/filesystem"
import { ProjectID } from "@/project/schema"
@ -15,6 +17,10 @@ import { getAdaptor } from "./adaptors"
import { WorkspaceInfo } from "./types"
import { WorkspaceID } from "./schema"
import { parseSSE } from "./sse"
import { Session } from "@/session"
import { SessionTable } from "@/session/session.sql"
import { SessionID } from "@/session/schema"
import { errorData } from "@/util/error"
export namespace Workspace {
export const Info = WorkspaceInfo.meta({
@ -29,6 +35,13 @@ export namespace Workspace {
})
export type ConnectionStatus = z.infer<typeof ConnectionStatus>
const Restore = z.object({
workspaceID: WorkspaceID.zod,
sessionID: SessionID.zod,
total: z.number().int().min(0),
step: z.number().int().min(0),
})
export const Event = {
Ready: BusEvent.define(
"workspace.ready",
@ -42,6 +55,7 @@ export namespace Workspace {
message: z.string(),
}),
),
Restore: BusEvent.define("workspace.restore", Restore),
Status: BusEvent.define("workspace.status", ConnectionStatus),
}
@ -97,16 +111,186 @@ export namespace Workspace {
await adaptor.create(config)
console.log("starting sync")
startSync(info)
return info
})
const SessionRestoreInput = z.object({
workspaceID: WorkspaceID.zod,
sessionID: SessionID.zod,
})
export const sessionRestore = fn(SessionRestoreInput, async (input) => {
log.info("session restore requested", {
workspaceID: input.workspaceID,
sessionID: input.sessionID,
})
try {
const space = await get(input.workspaceID)
if (!space) throw new Error(`Workspace not found: ${input.workspaceID}`)
const adaptor = await getAdaptor(space.projectID, space.type)
const target = await adaptor.target(space)
const rows = Database.use((db) =>
db
.select({
id: EventTable.id,
aggregateID: EventTable.aggregate_id,
seq: EventTable.seq,
type: EventTable.type,
data: EventTable.data,
})
.from(EventTable)
.where(eq(EventTable.aggregate_id, input.sessionID))
.orderBy(asc(EventTable.seq))
.all(),
)
if (rows.length === 0) throw new Error(`No events found for session: ${input.sessionID}`)
const next = rows.at(-1)!.seq + 1
const all = [
...rows,
{
id: EventID.ascending(),
aggregateID: input.sessionID,
seq: next,
type: SyncEvent.versionedType(Session.Event.Updated.type, Session.Event.Updated.version),
data: {
sessionID: input.sessionID,
info: {
workspaceID: input.workspaceID,
time: {
updated: Date.now(),
},
},
},
},
]
const size = 10
const sets = Array.from({ length: Math.ceil(all.length / size) }, (_, i) => all.slice(i * size, (i + 1) * size))
const total = sets.length
log.info("session restore prepared", {
workspaceID: input.workspaceID,
sessionID: input.sessionID,
workspaceType: space.type,
directory: space.directory,
target: target.type === "remote" ? String(route(target.url, "/sync/replay")) : target.directory,
events: all.length,
batches: total,
first: all[0]?.seq,
last: all.at(-1)?.seq,
})
GlobalBus.emit("event", {
directory: "global",
workspace: input.workspaceID,
payload: {
type: Event.Restore.type,
properties: {
workspaceID: input.workspaceID,
sessionID: input.sessionID,
total,
step: 0,
},
},
})
for (const [i, events] of sets.entries()) {
log.info("session restore batch starting", {
workspaceID: input.workspaceID,
sessionID: input.sessionID,
step: i + 1,
total,
events: events.length,
first: events[0]?.seq,
last: events.at(-1)?.seq,
target: target.type === "remote" ? String(route(target.url, "/sync/replay")) : target.directory,
})
if (target.type === "local") {
SyncEvent.replayAll(events)
log.info("session restore batch replayed locally", {
workspaceID: input.workspaceID,
sessionID: input.sessionID,
step: i + 1,
total,
events: events.length,
})
} else {
const url = route(target.url, "/sync/replay")
const headers = new Headers(target.headers)
headers.set("content-type", "application/json")
const res = await fetch(url, {
method: "POST",
headers,
body: JSON.stringify({
directory: space.directory ?? "",
events,
}),
})
if (!res.ok) {
const body = await res.text()
log.error("session restore batch failed", {
workspaceID: input.workspaceID,
sessionID: input.sessionID,
step: i + 1,
total,
status: res.status,
body,
})
throw new Error(
`Failed to replay session ${input.sessionID} into workspace ${input.workspaceID}: HTTP ${res.status} ${body}`,
)
}
log.info("session restore batch posted", {
workspaceID: input.workspaceID,
sessionID: input.sessionID,
step: i + 1,
total,
status: res.status,
})
}
GlobalBus.emit("event", {
directory: "global",
workspace: input.workspaceID,
payload: {
type: Event.Restore.type,
properties: {
workspaceID: input.workspaceID,
sessionID: input.sessionID,
total,
step: i + 1,
},
},
})
}
log.info("session restore complete", {
workspaceID: input.workspaceID,
sessionID: input.sessionID,
batches: total,
})
return {
total,
}
} catch (err) {
log.error("session restore failed", {
workspaceID: input.workspaceID,
sessionID: input.sessionID,
error: errorData(err),
})
throw err
}
})
export function list(project: Project.Info) {
const rows = Database.use((db) =>
db.select().from(WorkspaceTable).where(eq(WorkspaceTable.project_id, project.id)).all(),
)
const spaces = rows.map(fromRow).sort((a, b) => a.id.localeCompare(b.id))
for (const space of spaces) startSync(space)
return spaces
}
@ -120,13 +304,25 @@ export namespace Workspace {
})
export const remove = fn(WorkspaceID.zod, async (id) => {
const sessions = Database.use((db) =>
db.select({ id: SessionTable.id }).from(SessionTable).where(eq(SessionTable.workspace_id, id)).all(),
)
for (const session of sessions) {
await Session.remove(session.id)
}
const row = Database.use((db) => db.select().from(WorkspaceTable).where(eq(WorkspaceTable.id, id)).get())
if (row) {
stopSync(id)
const info = fromRow(row)
const adaptor = await getAdaptor(info.projectID, row.type)
adaptor.remove(info)
try {
const adaptor = await getAdaptor(info.projectID, row.type)
await adaptor.remove(info)
} catch (err) {
log.error("adaptor not available when removing workspace", { type: row.type })
}
Database.use((db) => db.delete(WorkspaceTable).where(eq(WorkspaceTable.id, id)).run())
return info
}
@ -156,47 +352,75 @@ export namespace Workspace {
const log = Log.create({ service: "workspace-sync" })
async function workspaceEventLoop(space: Info, signal: AbortSignal) {
log.info("starting sync: " + space.id)
function route(url: string | URL, path: string) {
const next = new URL(url)
next.pathname = `${next.pathname.replace(/\/$/, "")}${path}`
next.search = ""
next.hash = ""
return next
}
async function globalEventLoop(space: Info, signal: AbortSignal) {
while (!signal.aborted) {
log.info("connecting to sync: " + space.id)
console.log("connecting to global sync", { workspace: space.name })
setStatus(space.id, "connecting")
const adaptor = await getAdaptor(space.projectID, space.type)
const target = await adaptor.target(space)
if (target.type === "local") return
const res = await fetch(target.url + "/sync/event", { method: "GET", signal }).catch((err: unknown) => {
setStatus(space.id, "error", String(err))
const res = await fetch(route(target.url, "/global/event"), {
method: "GET",
headers: target.headers,
signal,
}).catch((err: unknown) => {
setStatus(space.id, "error")
console.log("failed to connect to global sync", {
workspace: space.name,
error: err,
})
return undefined
})
if (!res || !res.ok || !res.body) {
log.info("failed to connect to sync: " + res?.status)
setStatus(space.id, "error", res ? `HTTP ${res.status}` : "no response")
await sleep(1000)
if (!res || !res.ok || !res.body) {
console.log("failed to connect to global sync", { workspace: space.name })
await sleep(50000)
continue
}
setStatus(space.id, "connected")
await parseSSE(res.body, signal, (evt) => {
const event = evt as SyncEvent.SerializedEvent
console.log("global sync connected", { workspace: space.name })
setStatus(space.id, "connected")
await parseSSE(res.body, signal, (evt: any) => {
try {
if (!event.type.startsWith("server.")) {
SyncEvent.replay(event)
if (!("payload" in evt)) return
if (!evt.payload.type.startsWith("server.")) {
console.log("received workspace sse event", evt)
}
if (evt.payload.type === "sync") {
// This name -> type is temporary
SyncEvent.replay({ ...evt.payload, type: evt.payload.name } as SyncEvent.SerializedEvent)
}
GlobalBus.emit("event", {
directory: evt.directory,
project: evt.project,
workspace: space.id,
payload: evt.payload,
})
} catch (err) {
log.warn("failed to replay sync event", {
console.log("failed to replay global event", {
workspaceID: space.id,
error: err,
})
}
})
setStatus(space.id, "disconnected")
log.info("disconnected to sync: " + space.id)
await sleep(250)
log.info("disconnected from global sync: " + space.id)
await sleep(50000)
}
}
@ -213,9 +437,9 @@ export namespace Workspace {
aborts.set(space.id, abort)
setStatus(space.id, "disconnected")
void workspaceEventLoop(space, abort.signal).catch((error) => {
void globalEventLoop(space, abort.signal).catch((error) => {
setStatus(space.id, "error", String(error))
log.warn("workspace sync listener failed", {
log.warn("workspace listener failed", {
workspaceID: space.id,
error,
})

View file

@ -103,6 +103,7 @@ export namespace PluginLoader {
} catch (error) {
return { ok: false, error }
}
console.log('mod', mod)
if (!mod) return { ok: false, error: new Error(`Plugin ${row.spec} module is empty`) }
return { ok: true, value: { ...row, mod } }
}

View file

@ -11,9 +11,26 @@ import { Session } from "@/session"
import { SessionID } from "@/session/schema"
import { WorkspaceContext } from "@/control-plane/workspace-context"
import { AppRuntime } from "@/effect/app-runtime"
import { Log } from "@/util/log"
type Rule = { method?: string; path: string; exact?: boolean; action: "local" | "forward" }
const hop = new Set([
"connection",
"keep-alive",
"proxy-authenticate",
"proxy-authorization",
"proxy-connection",
"te",
"trailer",
"transfer-encoding",
"upgrade",
"host",
"content-length",
])
const IS_WORKSPACE = process.env.OPENCODE_WORKSPACE === "true"
const RULES: Array<Rule> = [
{ path: "/session/status", action: "forward" },
{ method: "GET", path: "/session", action: "local" },
@ -37,6 +54,40 @@ function getSessionID(url: URL) {
return SessionID.make(id)
}
function sh(value: string) {
return `'${value.replace(/'/g, `'"'"'`)}'`
}
async function curl(url: URL, extra: HeadersInit | undefined, req: Request) {
const headers = new Headers(req.headers)
for (const key of hop) headers.delete(key)
headers.delete("accept-encoding")
headers.delete("x-opencode-directory")
headers.delete("x-opencode-workspace")
if (extra) {
for (const [key, value] of new Headers(extra).entries()) {
headers.set(key, value)
}
}
const parts = ["curl", "-X", req.method]
for (const [key, value] of headers.entries()) {
parts.push("-H", sh(`${key}: ${value}`))
}
if (req.method !== "GET" && req.method !== "HEAD") {
const body = await req
.clone()
.text()
.catch(() => "")
if (body) parts.push("--data-binary", sh(body))
}
parts.push(sh(url.toString()))
return parts.join(" ")
}
async function getSessionWorkspace(url: URL) {
const id = getSessionID(url)
if (!id) return null
@ -46,6 +97,8 @@ async function getSessionWorkspace(url: URL) {
}
export function WorkspaceRouterMiddleware(upgrade: UpgradeWebSocket): MiddlewareHandler {
const log = Log.Default.clone().tag("service", "workspace-router")
return async (c, next) => {
const raw = c.req.query("directory") || c.req.header("x-opencode-directory") || process.cwd()
const directory = Filesystem.resolve(
@ -64,7 +117,7 @@ export function WorkspaceRouterMiddleware(upgrade: UpgradeWebSocket): Middleware
const workspaceID = sessionWorkspaceID || url.searchParams.get("workspace")
// If no workspace is provided we use the project
if (!workspaceID) {
if (!workspaceID || url.pathname.startsWith("/console") || IS_WORKSPACE) {
return Instance.provide({
directory,
init: () => AppRuntime.runPromise(InstanceBootstrap),
@ -77,16 +130,6 @@ export function WorkspaceRouterMiddleware(upgrade: UpgradeWebSocket): Middleware
const workspace = await Workspace.get(WorkspaceID.make(workspaceID))
if (!workspace) {
// Special-case deleting a session in case user's data in a
// weird state. Allow them to forcefully delete a synced session
// even if the remote workspace is not in their data.
//
// The lets the `DELETE /session/:id` endpoint through and we've
// made sure that it will run without an instance
if (url.pathname.match(/\/session\/[^/]+$/) && c.req.method === "DELETE") {
return next()
}
return new Response(`Workspace not found: ${workspaceID}`, {
status: 500,
headers: {
@ -98,6 +141,13 @@ export function WorkspaceRouterMiddleware(upgrade: UpgradeWebSocket): Middleware
const adaptor = await getAdaptor(workspace.projectID, workspace.type)
const target = await adaptor.target(workspace)
log.info("workspace route resolved", {
workspaceID,
workspace_type: workspace.type,
target_type: target.type,
request: url.toString(),
})
if (target.type === "local") {
return WorkspaceContext.provide({
workspaceID: WorkspaceID.make(workspaceID),
@ -118,18 +168,29 @@ export function WorkspaceRouterMiddleware(upgrade: UpgradeWebSocket): Middleware
return next()
}
const proxyURL = new URL(target.url)
proxyURL.pathname = `${proxyURL.pathname.replace(/\/$/, "")}${url.pathname}`
proxyURL.search = url.search
proxyURL.hash = url.hash
proxyURL.searchParams.delete("workspace")
log.info("workspace proxy forwarding", {
workspaceID,
request: url.toString(),
target: String(target.url),
proxy: proxyURL.toString(),
})
if (c.req.header("upgrade")?.toLowerCase() === "websocket") {
return ServerProxy.websocket(upgrade, target, c.req.raw, c.env)
return ServerProxy.websocket(upgrade, proxyURL, target.headers, c.req.raw, c.env)
}
const headers = new Headers(c.req.raw.headers)
headers.delete("x-opencode-workspace")
return ServerProxy.http(
target,
new Request(c.req.raw, {
headers,
}),
)
const req = new Request(c.req.raw, { headers })
console.log("workspace proxy curl", await curl(proxyURL, target.headers, req))
return ServerProxy.http(proxyURL, target.headers, req)
}
}

View file

@ -0,0 +1,118 @@
import z from "zod"
import { Hono } from "hono"
import { describeRoute, validator, resolver } from "hono-openapi"
import { SyncEvent } from "@/sync"
import { Database, asc, and, not, or, lte, eq } from "@/storage/db"
import { EventTable } from "@/sync/event.sql"
import { lazy } from "@/util/lazy"
import { Log } from "@/util/log"
import { errors } from "../error"
const ReplayEvent = z.object({
id: z.string(),
aggregateID: z.string(),
seq: z.number().int().min(0),
type: z.string(),
data: z.record(z.string(), z.unknown()),
})
const log = Log.create({ service: "server.sync" })
export const SyncRoutes = lazy(() =>
new Hono()
.post(
"/replay",
describeRoute({
summary: "Replay sync events",
description: "Validate and replay a complete sync event history.",
operationId: "global.sync-replay",
responses: {
200: {
description: "Replayed sync events",
content: {
"application/json": {
schema: resolver(
z.object({
sessionID: z.string(),
}),
),
},
},
},
...errors(400),
},
}),
validator(
"json",
z.object({
directory: z.string(),
events: z.array(ReplayEvent).min(1),
}),
),
async (c) => {
const body = c.req.valid("json")
const events = body.events
const source = events[0].aggregateID
log.info("sync replay requested", {
sessionID: source,
events: events.length,
first: events[0]?.seq,
last: events.at(-1)?.seq,
directory: body.directory,
})
SyncEvent.replayAll(events)
log.info("sync replay complete", {
sessionID: source,
events: events.length,
first: events[0]?.seq,
last: events.at(-1)?.seq,
})
return c.json({
sessionID: source,
})
},
)
.get(
"/history",
describeRoute({
summary: "List sync events",
description:
"List sync events for all aggregates. Keys are aggregate IDs the client already knows about, values are the last known sequence ID. Events with seq > value are returned for those aggregates. Aggregates not listed in the input get their full history.",
operationId: "global.sync-history.list",
responses: {
200: {
description: "Sync events",
content: {
"application/json": {
schema: resolver(
z.array(
z.object({
id: z.string(),
aggregate_id: z.string(),
seq: z.number(),
type: z.string(),
data: z.record(z.string(), z.unknown()),
}),
),
),
},
},
},
...errors(400),
},
}),
validator("json", z.record(z.string(), z.number().int().min(0))),
async (c) => {
const body = c.req.valid("json")
const exclude = Object.entries(body)
const where =
exclude.length > 0
? not(or(...exclude.map(([id, seq]) => and(eq(EventTable.aggregate_id, id), lte(EventTable.seq, seq))))!)
: undefined
const rows = Database.use((db) => db.select().from(EventTable).where(where).orderBy(asc(EventTable.seq)).all())
return c.json(rows)
},
),
)

View file

@ -6,6 +6,16 @@ import { Workspace } from "../../control-plane/workspace"
import { Instance } from "../../project/instance"
import { errors } from "../error"
import { lazy } from "../../util/lazy"
import { Log } from "@/util/log"
import { errorData } from "@/util/error"
const SessionRestoreBody = Workspace.sessionRestore.schema.omit({
workspaceID: true,
})
const SessionRestoreResult = z.object({
total: z.number().int().min(0),
})
const WorkspaceAdaptor = z.object({
type: z.string(),
@ -13,6 +23,8 @@ const WorkspaceAdaptor = z.object({
description: z.string(),
})
const log = Log.create({ service: "server.workspace" })
export const WorkspaceRoutes = lazy(() =>
new Hono()
.get(
@ -140,5 +152,59 @@ export const WorkspaceRoutes = lazy(() =>
const { id } = c.req.valid("param")
return c.json(await Workspace.remove(id))
},
)
.post(
"/:id/session-restore",
describeRoute({
summary: "Restore session into workspace",
description: "Replay a session's sync events into the target workspace in batches.",
operationId: "experimental.workspace.sessionRestore",
responses: {
200: {
description: "Session replay started",
content: {
"application/json": {
schema: resolver(SessionRestoreResult),
},
},
},
...errors(400),
},
}),
validator(
"param",
z.object({
id: Workspace.Info.shape.id,
}),
),
validator("json", SessionRestoreBody),
async (c) => {
const { id } = c.req.valid("param")
const body = c.req.valid("json")
log.info("session restore route requested", {
workspaceID: id,
sessionID: body.sessionID,
directory: Instance.directory,
})
try {
const result = await Workspace.sessionRestore({
workspaceID: id,
...body,
})
log.info("session restore route complete", {
workspaceID: id,
sessionID: body.sessionID,
total: result.total,
})
return c.json(result)
} catch (err) {
log.error("session restore route failed", {
workspaceID: id,
sessionID: body.sessionID,
error: errorData(err),
})
throw err
}
},
),
)

View file

@ -86,7 +86,7 @@ const zipped = compress()
export const CompressionMiddleware: MiddlewareHandler = (c, next) => {
const path = c.req.path
const method = c.req.method
if (path === "/event" || path === "/global/event" || path === "/global/sync-event") return next()
if (path === "/event" || path === "/global/event") return next()
if (method === "POST" && /\/session\/[^/]+\/(message|prompt_async)$/.test(path)) return next()
return zipped(c, next)
}

View file

@ -1,6 +1,6 @@
import type { Target } from "@/control-plane/types"
import { Hono } from "hono"
import type { UpgradeWebSocket } from "hono/ws"
import { Log } from "@/util/log"
const hop = new Set([
"connection",
@ -20,6 +20,7 @@ type Msg = string | ArrayBuffer | Uint8Array
function headers(req: Request, extra?: HeadersInit) {
const out = new Headers(req.headers)
for (const key of hop) out.delete(key)
out.delete("accept-encoding")
out.delete("x-opencode-directory")
out.delete("x-opencode-workspace")
if (!extra) return out
@ -98,31 +99,63 @@ const app = (upgrade: UpgradeWebSocket) =>
)
export namespace ServerProxy {
export function http(target: Extract<Target, { type: "remote" }>, req: Request) {
const log = Log.Default.clone().tag("service", "server-proxy")
export function http(url: string | URL, extra: HeadersInit | undefined, req: Request) {
console.log("proxy http request", {
method: req.method,
request: req.url,
url: String(url),
})
return fetch(
new Request(target.url, {
new Request(url, {
method: req.method,
headers: headers(req, target.headers),
headers: headers(req, extra),
body: req.method === "GET" || req.method === "HEAD" ? undefined : req.body,
redirect: "manual",
signal: req.signal,
}),
)
).then((res) => {
const next = new Headers(res.headers)
next.delete("content-encoding")
next.delete("content-length")
console.log("proxy http response", {
method: req.method,
request: req.url,
url: String(url),
status: res.status,
statusText: res.statusText,
})
return new Response(res.body, {
status: res.status,
statusText: res.statusText,
headers: next,
})
})
}
export function websocket(
upgrade: UpgradeWebSocket,
target: Extract<Target, { type: "remote" }>,
target: string | URL,
extra: HeadersInit | undefined,
req: Request,
env: unknown,
) {
const url = new URL(req.url)
url.pathname = "/__workspace_ws"
url.search = ""
const proxy = new URL(req.url)
proxy.pathname = "/__workspace_ws"
proxy.search = ""
const next = new Headers(req.headers)
next.set("x-opencode-proxy-url", socket(target.url))
next.set("x-opencode-proxy-url", socket(target))
for (const [key, value] of new Headers(extra).entries()) {
next.set(key, value)
}
log.info("proxy websocket", {
request: req.url,
target: String(target),
})
return app(upgrade).fetch(
new Request(url, {
new Request(proxy, {
method: req.method,
headers: next,
signal: req.signal,

View file

@ -199,6 +199,25 @@ export namespace SyncEvent {
process(def, event, { publish: !!options?.publish })
}
export function replayAll(events: SerializedEvent[], options?: { publish: boolean }) {
const source = events[0]?.aggregateID
if (!source) return
if (events.some((item) => item.aggregateID !== source)) {
throw new Error("Replay events must belong to the same session")
}
const start = events[0].seq
for (const [i, item] of events.entries()) {
const seq = start + i
if (item.seq !== seq) {
throw new Error(`Replay sequence mismatch at index ${i}: expected ${seq}, got ${item.seq}`)
}
}
for (const item of events) {
replay(item, options)
}
return source
}
export function run<Def extends Definition>(def: Def, data: Event<Def>["data"], options?: { publish?: boolean }) {
const agg = (data as Record<string, string>)[def.aggregate]
// This should never happen: we've enforced it via typescript in

View file

@ -0,0 +1,89 @@
import { describe, expect, mock, test } from "bun:test"
import { restoreWorkspaceSession } from "../../../src/cli/cmd/tui/component/dialog-workspace-create"
describe("restoreWorkspaceSession", () => {
test("refreshes workspace and session data after a successful restore", async () => {
const sessionRestore = mock(async () => ({ data: { total: 2 } }))
const syncWorkspace = mock(async () => {})
const refresh = mock(async () => {})
const clear = mock(() => {})
const show = mock(() => {})
await restoreWorkspaceSession({
dialog: { clear } as any,
sdk: {
client: {
experimental: {
workspace: {
sessionRestore,
},
},
},
} as any,
sync: {
session: {
refresh,
},
} as any,
project: {
workspace: {
sync: syncWorkspace,
},
} as any,
toast: { show } as any,
workspaceID: "wrk_1",
sessionID: "ses_1",
})
expect(sessionRestore).toHaveBeenCalledWith({ id: "wrk_1", sessionID: "ses_1" })
expect(syncWorkspace).toHaveBeenCalledTimes(1)
expect(refresh).toHaveBeenCalledTimes(1)
expect(show).toHaveBeenCalledWith({
message: "Session restored into the new workspace",
variant: "success",
})
expect(clear).toHaveBeenCalledTimes(1)
})
test("shows an error and keeps the dialog open when restore fails", async () => {
const sessionRestore = mock(async () => undefined)
const syncWorkspace = mock(async () => {})
const refresh = mock(async () => {})
const clear = mock(() => {})
const show = mock(() => {})
await restoreWorkspaceSession({
dialog: { clear } as any,
sdk: {
client: {
experimental: {
workspace: {
sessionRestore,
},
},
},
} as any,
sync: {
session: {
refresh,
},
} as any,
project: {
workspace: {
sync: syncWorkspace,
},
} as any,
toast: { show } as any,
workspaceID: "wrk_1",
sessionID: "ses_1",
})
expect(syncWorkspace).not.toHaveBeenCalled()
expect(refresh).not.toHaveBeenCalled()
expect(clear).not.toHaveBeenCalled()
expect(show).toHaveBeenCalledWith({
message: "Failed to restore session: no response",
variant: "error",
})
})
})

View file

@ -0,0 +1,268 @@
import { afterEach, beforeEach, describe, expect, mock, spyOn, test } from "bun:test"
import fs from "node:fs/promises"
import path from "node:path"
import { GlobalBus } from "../../src/bus/global"
import { registerAdaptor } from "../../src/control-plane/adaptors"
import type { WorkspaceAdaptor } from "../../src/control-plane/types"
import { Workspace } from "../../src/control-plane/workspace"
import { Flag } from "../../src/flag/flag"
import { ModelID, ProviderID } from "../../src/provider/schema"
import { Instance } from "../../src/project/instance"
import { Server } from "../../src/server/server"
import { Session } from "../../src/session"
import { MessageID, PartID, type SessionID } from "../../src/session/schema"
import { Database, asc, eq } from "../../src/storage/db"
import { SyncEvent } from "../../src/sync"
import { EventTable } from "../../src/sync/event.sql"
import { Log } from "../../src/util/log"
import { resetDatabase } from "../fixture/db"
import { tmpdir } from "../fixture/fixture"
Log.init({ print: false })
const original = Flag.OPENCODE_EXPERIMENTAL_WORKSPACES
beforeEach(() => {
Database.close()
// @ts-expect-error test override
Flag.OPENCODE_EXPERIMENTAL_WORKSPACES = true
})
afterEach(async () => {
mock.restore()
// @ts-expect-error test override
Flag.OPENCODE_EXPERIMENTAL_WORKSPACES = original
await resetDatabase()
})
async function user(sessionID: SessionID, text: string) {
const msg = await Session.updateMessage({
id: MessageID.ascending(),
role: "user",
sessionID,
agent: "build",
model: { providerID: ProviderID.make("test"), modelID: ModelID.make("test") },
time: { created: Date.now() },
})
await Session.updatePart({
id: PartID.ascending(),
sessionID,
messageID: msg.id,
type: "text",
text,
})
}
function remote(dir: string, url: string): WorkspaceAdaptor {
return {
name: "remote",
description: "remote",
configure(info) {
return {
...info,
directory: dir,
}
},
async create() {
await fs.mkdir(dir, { recursive: true })
},
async remove() {},
target() {
return {
type: "remote" as const,
url,
}
},
}
}
function local(dir: string): WorkspaceAdaptor {
return {
name: "local",
description: "local",
configure(info) {
return {
...info,
directory: dir,
}
},
async create() {
await fs.mkdir(dir, { recursive: true })
},
async remove() {},
target() {
return {
type: "local" as const,
directory: dir,
}
},
}
}
describe("workspace restore route", () => {
test("replays session events in batches of 10 and emits progress", async () => {
await using tmp = await tmpdir({ git: true })
const app = Server.Default().app
const dir = path.join(tmp.path, ".restore")
const seen: any[] = []
const posts: Array<{
path: string
body: { directory: string; events: Array<{ seq: number; aggregateID: string }> }
}> = []
const on = (evt: any) => seen.push(evt)
GlobalBus.on("event", on)
const raw = globalThis.fetch
const fetch = spyOn(globalThis, "fetch").mockImplementation(
Object.assign(
async (input: URL | RequestInfo, init?: BunFetchRequestInit | RequestInit) => {
const url = new URL(typeof input === "string" || input instanceof URL ? input : input.url)
posts.push({
path: url.pathname,
body: JSON.parse(String(init?.body)),
})
return Response.json({ sessionID: posts.at(-1)!.body.events[0].aggregateID })
},
{
preconnect: raw.preconnect?.bind(raw),
},
) as typeof globalThis.fetch,
)
try {
const setup = await Instance.provide({
directory: tmp.path,
fn: async () => {
registerAdaptor(Instance.project.id, "worktree", remote(dir, "https://workspace.test/base"))
const space = await Workspace.create({
type: "worktree",
branch: null,
extra: null,
projectID: Instance.project.id,
})
const session = await Session.create({})
for (let i = 0; i < 6; i++) {
await user(session.id, `msg ${i}`)
}
const rows = Database.use((db) =>
db
.select({ seq: EventTable.seq })
.from(EventTable)
.where(eq(EventTable.aggregate_id, session.id))
.orderBy(asc(EventTable.seq))
.all(),
)
return { space, session, rows }
},
})
expect(setup.rows).toHaveLength(13)
const res = await app.request(`/experimental/workspace/${setup.space.id}/session-restore`, {
method: "POST",
headers: {
"content-type": "application/json",
"x-opencode-directory": tmp.path,
},
body: JSON.stringify({
sessionID: setup.session.id,
}),
})
expect(fetch).toHaveBeenCalledTimes(2)
expect(res.status).toBe(200)
expect(await res.json()).toEqual({ total: 2 })
expect(posts).toHaveLength(2)
expect(posts[0]?.path).toBe("/base/sync/replay")
expect(posts[1]?.path).toBe("/base/sync/replay")
expect(posts[0]?.body.directory).toBe(dir)
expect(posts[1]?.body.directory).toBe(dir)
expect(posts[0]?.body.events).toHaveLength(10)
expect(posts[1]?.body.events).toHaveLength(4)
expect(posts.flatMap((item) => item.body.events.map((event) => event.seq))).toEqual([
...setup.rows.map((row) => row.seq),
setup.rows.at(-1)!.seq + 1,
])
expect(posts[1]?.body.events.at(-1)).toMatchObject({
aggregateID: setup.session.id,
seq: setup.rows.at(-1)!.seq + 1,
type: SyncEvent.versionedType(Session.Event.Updated.type, Session.Event.Updated.version),
data: {
sessionID: setup.session.id,
info: {
workspaceID: setup.space.id,
},
},
})
const restore = seen.filter(
(evt) => evt.workspace === setup.space.id && evt.payload.type === Workspace.Event.Restore.type,
)
expect(restore.map((evt) => evt.payload.properties.step)).toEqual([0, 1, 2])
expect(restore.map((evt) => evt.payload.properties.total)).toEqual([2, 2, 2])
expect(restore.map((evt) => evt.payload.properties.sessionID)).toEqual([
setup.session.id,
setup.session.id,
setup.session.id,
])
} finally {
GlobalBus.off("event", on)
}
})
test("replays locally without posting to a server", async () => {
await using tmp = await tmpdir({ git: true })
const app = Server.Default().app
const dir = path.join(tmp.path, ".restore-local")
const seen: any[] = []
const on = (evt: any) => seen.push(evt)
GlobalBus.on("event", on)
const fetch = spyOn(globalThis, "fetch")
const replayAll = spyOn(SyncEvent, "replayAll")
try {
const setup = await Instance.provide({
directory: tmp.path,
fn: async () => {
registerAdaptor(Instance.project.id, "local-restore", local(dir))
const space = await Workspace.create({
type: "local-restore",
branch: null,
extra: null,
projectID: Instance.project.id,
})
const session = await Session.create({})
for (let i = 0; i < 6; i++) {
await user(session.id, `msg ${i}`)
}
return { space, session }
},
})
const res = await app.request(`/experimental/workspace/${setup.space.id}/session-restore`, {
method: "POST",
headers: {
"content-type": "application/json",
"x-opencode-directory": tmp.path,
},
body: JSON.stringify({
sessionID: setup.session.id,
}),
})
expect(res.status).toBe(200)
expect(await res.json()).toEqual({ total: 2 })
expect(fetch).not.toHaveBeenCalled()
expect(replayAll).toHaveBeenCalledTimes(2)
expect((await Session.get(setup.session.id)).workspaceID).toBe(setup.space.id)
const restore = seen.filter(
(evt) => evt.workspace === setup.space.id && evt.payload.type === Workspace.Event.Restore.type,
)
expect(restore.map((evt) => evt.payload.properties.step)).toEqual([0, 1, 2])
} finally {
GlobalBus.off("event", on)
}
})
})

View file

@ -9,6 +9,7 @@ import { EventTable } from "../../src/sync/event.sql"
import { Identifier } from "../../src/id/id"
import { Flag } from "../../src/flag/flag"
import { initProjectors } from "../../src/server/projectors"
import { SyncRoutes } from "../../src/server/routes/sync"
const original = Flag.OPENCODE_EXPERIMENTAL_WORKSPACES
@ -187,5 +188,74 @@ describe("SyncEvent", () => {
).toThrow(/Unknown event type/)
}),
)
test(
"sync route accepts later chunks after the first batch",
withInstance(async () => {
const { Created } = setup()
const app = SyncRoutes()
const id = Identifier.descending("message")
const one = await app.request("/replay", {
method: "POST",
headers: {
"content-type": "application/json",
},
body: JSON.stringify({
directory: "/tmp/test",
events: [
{
id: "evt_1",
type: SyncEvent.versionedType(Created.type, Created.version),
seq: 0,
aggregateID: id,
data: { id, name: "first" },
},
{
id: "evt_2",
type: SyncEvent.versionedType(Created.type, Created.version),
seq: 1,
aggregateID: id,
data: { id, name: "second" },
},
],
}),
})
const two = await app.request("/replay", {
method: "POST",
headers: {
"content-type": "application/json",
},
body: JSON.stringify({
directory: "/tmp/test",
events: [
{
id: "evt_3",
type: SyncEvent.versionedType(Created.type, Created.version),
seq: 2,
aggregateID: id,
data: { id, name: "third" },
},
{
id: "evt_4",
type: SyncEvent.versionedType(Created.type, Created.version),
seq: 3,
aggregateID: id,
data: { id, name: "fourth" },
},
],
}),
})
expect(one.status).toBe(200)
expect(await one.json()).toEqual({ sessionID: id })
expect(two.status).toBe(200)
expect(await two.json()).toEqual({ sessionID: id })
const rows = Database.use((db) => db.select().from(EventTable).all())
expect(rows.map((row) => row.seq)).toEqual([0, 1, 2, 3])
}),
)
})
})

View file

@ -35,6 +35,8 @@ import type {
ExperimentalWorkspaceListResponses,
ExperimentalWorkspaceRemoveErrors,
ExperimentalWorkspaceRemoveResponses,
ExperimentalWorkspaceSessionRestoreErrors,
ExperimentalWorkspaceSessionRestoreResponses,
ExperimentalWorkspaceStatusResponses,
FileListResponses,
FilePartInput,
@ -51,6 +53,11 @@ import type {
GlobalDisposeResponses,
GlobalEventResponses,
GlobalHealthResponses,
GlobalSyncEventSubscribeResponses,
GlobalSyncHistoryListErrors,
GlobalSyncHistoryListResponses,
GlobalSyncReplayErrors,
GlobalSyncReplayResponses,
GlobalUpgradeErrors,
GlobalUpgradeResponses,
InstanceDisposeResponses,
@ -236,6 +243,20 @@ class HeyApiRegistry<T> {
}
}
export class SyncEvent extends HeyApiClient {
/**
* Subscribe to global sync events
*
* Get global sync events
*/
public subscribe<ThrowOnError extends boolean = false>(options?: Options<never, ThrowOnError>) {
return (options?.client ?? this.client).sse.get<GlobalSyncEventSubscribeResponses, unknown, ThrowOnError>({
url: "/global/sync-event",
...options,
})
}
}
export class Config extends HeyApiClient {
/**
* Get global configuration
@ -274,6 +295,38 @@ export class Config extends HeyApiClient {
}
}
export class SyncHistory extends HeyApiClient {
/**
* List sync events
*
* List sync events for all aggregates. Keys are aggregate IDs the client already knows about, values are the last known sequence ID. Events with seq > value are returned for those aggregates. Aggregates not listed in the input get their full history.
*/
public list<ThrowOnError extends boolean = false>(
parameters?: {
body?: {
[key: string]: number
}
},
options?: Options<never, ThrowOnError>,
) {
const params = buildClientParams([parameters], [{ args: [{ key: "body", map: "body" }] }])
return (options?.client ?? this.client).get<
GlobalSyncHistoryListResponses,
GlobalSyncHistoryListErrors,
ThrowOnError
>({
url: "/sync/history",
...options,
...params,
headers: {
"Content-Type": "application/json",
...options?.headers,
...params.headers,
},
})
}
}
export class Global extends HeyApiClient {
/**
* Get health
@ -335,10 +388,63 @@ export class Global extends HeyApiClient {
})
}
/**
* Replay sync events
*
* Validate and replay a complete sync event history.
*/
public syncReplay<ThrowOnError extends boolean = false>(
parameters?: {
directory?: string
events?: Array<{
id: string
aggregateID: string
seq: number
type: string
data: {
[key: string]: unknown
}
}>
},
options?: Options<never, ThrowOnError>,
) {
const params = buildClientParams(
[parameters],
[
{
args: [
{ in: "body", key: "directory" },
{ in: "body", key: "events" },
],
},
],
)
return (options?.client ?? this.client).post<GlobalSyncReplayResponses, GlobalSyncReplayErrors, ThrowOnError>({
url: "/sync/replay",
...options,
...params,
headers: {
"Content-Type": "application/json",
...options?.headers,
...params.headers,
},
})
}
private _syncEvent?: SyncEvent
get syncEvent(): SyncEvent {
return (this._syncEvent ??= new SyncEvent({ client: this.client }))
}
private _config?: Config
get config(): Config {
return (this._config ??= new Config({ client: this.client }))
}
private _syncHistory?: SyncHistory
get syncHistory(): SyncHistory {
return (this._syncHistory ??= new SyncHistory({ client: this.client }))
}
}
export class Auth extends HeyApiClient {
@ -1243,6 +1349,49 @@ export class Workspace extends HeyApiClient {
})
}
/**
* Restore session into workspace
*
* Replay a session's sync events into the target workspace in batches.
*/
public sessionRestore<ThrowOnError extends boolean = false>(
parameters: {
id: string
directory?: string
workspace?: string
sessionID?: string
},
options?: Options<never, ThrowOnError>,
) {
const params = buildClientParams(
[parameters],
[
{
args: [
{ in: "path", key: "id" },
{ in: "query", key: "directory" },
{ in: "query", key: "workspace" },
{ in: "body", key: "sessionID" },
],
},
],
)
return (options?.client ?? this.client).post<
ExperimentalWorkspaceSessionRestoreResponses,
ExperimentalWorkspaceSessionRestoreErrors,
ThrowOnError
>({
url: "/experimental/workspace/{id}/session-restore",
...options,
...params,
headers: {
"Content-Type": "application/json",
...options?.headers,
...params.headers,
},
})
}
private _adaptor?: Adaptor
get adaptor(): Adaptor {
return (this._adaptor ??= new Adaptor({ client: this.client }))
@ -1743,7 +1892,6 @@ export class Session2 extends HeyApiClient {
directory?: string
workspace?: string
title?: string
permission?: PermissionRuleset
time?: {
archived?: number
}
@ -1759,7 +1907,6 @@ export class Session2 extends HeyApiClient {
{ in: "query", key: "directory" },
{ in: "query", key: "workspace" },
{ in: "body", key: "title" },
{ in: "body", key: "permission" },
{ in: "body", key: "time" },
],
},

View file

@ -33,13 +33,6 @@ export type EventProjectUpdated = {
properties: Project
}
export type EventServerInstanceDisposed = {
type: "server.instance.disposed"
properties: {
directory: string
}
}
export type EventInstallationUpdated = {
type: "installation.updated"
properties: {
@ -54,6 +47,13 @@ export type EventInstallationUpdateAvailable = {
}
}
export type EventServerInstanceDisposed = {
type: "server.instance.disposed"
properties: {
directory: string
}
}
export type EventServerConnected = {
type: "server.connected"
properties: {
@ -68,21 +68,6 @@ export type EventGlobalDisposed = {
}
}
export type EventFileEdited = {
type: "file.edited"
properties: {
file: string
}
}
export type EventFileWatcherUpdated = {
type: "file.watcher.updated"
properties: {
file: string
event: "add" | "change" | "unlink"
}
}
export type EventLspClientDiagnostics = {
type: "lsp.client.diagnostics"
properties: {
@ -230,133 +215,25 @@ export type EventSessionError = {
}
}
export type QuestionOption = {
/**
* Display text (1-5 words, concise)
*/
label: string
/**
* Explanation of choice
*/
description: string
}
export type QuestionInfo = {
/**
* Complete question
*/
question: string
/**
* Very short label (max 30 chars)
*/
header: string
/**
* Available choices
*/
options: Array<QuestionOption>
/**
* Allow selecting multiple choices
*/
multiple?: boolean
/**
* Allow typing a custom answer (default: true)
*/
custom?: boolean
}
export type QuestionRequest = {
id: string
sessionID: string
/**
* Questions to ask
*/
questions: Array<QuestionInfo>
tool?: {
messageID: string
callID: string
export type EventFileEdited = {
type: "file.edited"
properties: {
file: string
}
}
export type EventQuestionAsked = {
type: "question.asked"
properties: QuestionRequest
}
export type QuestionAnswer = Array<string>
export type EventQuestionReplied = {
type: "question.replied"
export type EventFileWatcherUpdated = {
type: "file.watcher.updated"
properties: {
sessionID: string
requestID: string
answers: Array<QuestionAnswer>
file: string
event: "add" | "change" | "unlink"
}
}
export type EventQuestionRejected = {
type: "question.rejected"
export type EventVcsBranchUpdated = {
type: "vcs.branch.updated"
properties: {
sessionID: string
requestID: string
}
}
export type Todo = {
/**
* Brief description of the task
*/
content: string
/**
* Current status of the task: pending, in_progress, completed, cancelled
*/
status: string
/**
* Priority level of the task: high, medium, low
*/
priority: string
}
export type EventTodoUpdated = {
type: "todo.updated"
properties: {
sessionID: string
todos: Array<Todo>
}
}
export type SessionStatus =
| {
type: "idle"
}
| {
type: "retry"
attempt: number
message: string
next: number
}
| {
type: "busy"
}
export type EventSessionStatus = {
type: "session.status"
properties: {
sessionID: string
status: SessionStatus
}
}
export type EventSessionIdle = {
type: "session.idle"
properties: {
sessionID: string
}
}
export type EventSessionCompacted = {
type: "session.compacted"
properties: {
sessionID: string
branch?: string
}
}
@ -439,28 +316,169 @@ export type EventCommandExecuted = {
}
}
export type EventVcsBranchUpdated = {
type: "vcs.branch.updated"
properties: {
branch?: string
}
}
export type EventWorktreeReady = {
type: "worktree.ready"
export type EventWorkspaceReady = {
type: "workspace.ready"
properties: {
name: string
branch: string
}
}
export type EventWorktreeFailed = {
type: "worktree.failed"
export type EventWorkspaceFailed = {
type: "workspace.failed"
properties: {
message: string
}
}
export type EventWorkspaceRestore = {
type: "workspace.restore"
properties: {
workspaceID: string
sessionID: string
total: number
step: number
}
}
export type EventWorkspaceStatus = {
type: "workspace.status"
properties: {
workspaceID: string
status: "connected" | "connecting" | "disconnected" | "error"
error?: string
}
}
export type QuestionOption = {
/**
* Display text (1-5 words, concise)
*/
label: string
/**
* Explanation of choice
*/
description: string
}
export type QuestionInfo = {
/**
* Complete question
*/
question: string
/**
* Very short label (max 30 chars)
*/
header: string
/**
* Available choices
*/
options: Array<QuestionOption>
/**
* Allow selecting multiple choices
*/
multiple?: boolean
/**
* Allow typing a custom answer (default: true)
*/
custom?: boolean
}
export type QuestionRequest = {
id: string
sessionID: string
/**
* Questions to ask
*/
questions: Array<QuestionInfo>
tool?: {
messageID: string
callID: string
}
}
export type EventQuestionAsked = {
type: "question.asked"
properties: QuestionRequest
}
export type QuestionAnswer = Array<string>
export type EventQuestionReplied = {
type: "question.replied"
properties: {
sessionID: string
requestID: string
answers: Array<QuestionAnswer>
}
}
export type EventQuestionRejected = {
type: "question.rejected"
properties: {
sessionID: string
requestID: string
}
}
export type SessionStatus =
| {
type: "idle"
}
| {
type: "retry"
attempt: number
message: string
next: number
}
| {
type: "busy"
}
export type EventSessionStatus = {
type: "session.status"
properties: {
sessionID: string
status: SessionStatus
}
}
export type EventSessionIdle = {
type: "session.idle"
properties: {
sessionID: string
}
}
export type EventSessionCompacted = {
type: "session.compacted"
properties: {
sessionID: string
}
}
export type Todo = {
/**
* Brief description of the task
*/
content: string
/**
* Current status of the task: pending, in_progress, completed, cancelled
*/
status: string
/**
* Priority level of the task: high, medium, low
*/
priority: string
}
export type EventTodoUpdated = {
type: "todo.updated"
properties: {
sessionID: string
todos: Array<Todo>
}
}
export type Pty = {
id: string
title: string
@ -500,29 +518,21 @@ export type EventPtyDeleted = {
}
}
export type EventWorkspaceReady = {
type: "workspace.ready"
export type EventWorktreeReady = {
type: "worktree.ready"
properties: {
name: string
branch: string
}
}
export type EventWorkspaceFailed = {
type: "workspace.failed"
export type EventWorktreeFailed = {
type: "worktree.failed"
properties: {
message: string
}
}
export type EventWorkspaceStatus = {
type: "workspace.status"
properties: {
workspaceID: string
status: "connected" | "connecting" | "disconnected" | "error"
error?: string
}
}
export type OutputFormatText = {
type: "text"
}
@ -971,12 +981,65 @@ export type EventSessionDeleted = {
}
}
export type Event =
| EventProjectUpdated
| EventInstallationUpdated
| EventInstallationUpdateAvailable
| EventServerInstanceDisposed
| EventServerConnected
| EventGlobalDisposed
| EventLspClientDiagnostics
| EventLspUpdated
| EventMessagePartDelta
| EventPermissionAsked
| EventPermissionReplied
| EventSessionDiff
| EventSessionError
| EventFileEdited
| EventFileWatcherUpdated
| EventVcsBranchUpdated
| EventTuiPromptAppend
| EventTuiCommandExecute
| EventTuiToastShow
| EventTuiSessionSelect
| EventMcpToolsChanged
| EventMcpBrowserOpenFailed
| EventCommandExecuted
| EventWorkspaceReady
| EventWorkspaceFailed
| EventWorkspaceRestore
| EventWorkspaceStatus
| EventQuestionAsked
| EventQuestionReplied
| EventQuestionRejected
| EventSessionStatus
| EventSessionIdle
| EventSessionCompacted
| EventTodoUpdated
| EventPtyCreated
| EventPtyUpdated
| EventPtyExited
| EventPtyDeleted
| EventWorktreeReady
| EventWorktreeFailed
| EventMessageUpdated
| EventMessageRemoved
| EventMessagePartUpdated
| EventMessagePartRemoved
| EventSessionCreated
| EventSessionUpdated
| EventSessionDeleted
export type GlobalEvent = {
directory: string
project?: string
workspace?: string
payload: Event
}
export type SyncEventMessageUpdated = {
type: "sync"
name: "message.updated.1"
id: string
seq: number
aggregateID: "sessionID"
type: "message.updated.1"
aggregate: "sessionID"
data: {
sessionID: string
info: Message
@ -984,11 +1047,8 @@ export type SyncEventMessageUpdated = {
}
export type SyncEventMessageRemoved = {
type: "sync"
name: "message.removed.1"
id: string
seq: number
aggregateID: "sessionID"
type: "message.removed.1"
aggregate: "sessionID"
data: {
sessionID: string
messageID: string
@ -996,11 +1056,8 @@ export type SyncEventMessageRemoved = {
}
export type SyncEventMessagePartUpdated = {
type: "sync"
name: "message.part.updated.1"
id: string
seq: number
aggregateID: "sessionID"
type: "message.part.updated.1"
aggregate: "sessionID"
data: {
sessionID: string
part: Part
@ -1009,11 +1066,8 @@ export type SyncEventMessagePartUpdated = {
}
export type SyncEventMessagePartRemoved = {
type: "sync"
name: "message.part.removed.1"
id: string
seq: number
aggregateID: "sessionID"
type: "message.part.removed.1"
aggregate: "sessionID"
data: {
sessionID: string
messageID: string
@ -1022,11 +1076,8 @@ export type SyncEventMessagePartRemoved = {
}
export type SyncEventSessionCreated = {
type: "sync"
name: "session.created.1"
id: string
seq: number
aggregateID: "sessionID"
type: "session.created.1"
aggregate: "sessionID"
data: {
sessionID: string
info: Session
@ -1034,11 +1085,8 @@ export type SyncEventSessionCreated = {
}
export type SyncEventSessionUpdated = {
type: "sync"
name: "session.updated.1"
id: string
seq: number
aggregateID: "sessionID"
type: "session.updated.1"
aggregate: "sessionID"
data: {
sessionID: string
info: {
@ -1077,75 +1125,16 @@ export type SyncEventSessionUpdated = {
}
export type SyncEventSessionDeleted = {
type: "sync"
name: "session.deleted.1"
id: string
seq: number
aggregateID: "sessionID"
type: "session.deleted.1"
aggregate: "sessionID"
data: {
sessionID: string
info: Session
}
}
export type GlobalEvent = {
directory: string
project?: string
workspace?: string
payload:
| EventProjectUpdated
| EventServerInstanceDisposed
| EventInstallationUpdated
| EventInstallationUpdateAvailable
| EventServerConnected
| EventGlobalDisposed
| EventFileEdited
| EventFileWatcherUpdated
| EventLspClientDiagnostics
| EventLspUpdated
| EventMessagePartDelta
| EventPermissionAsked
| EventPermissionReplied
| EventSessionDiff
| EventSessionError
| EventQuestionAsked
| EventQuestionReplied
| EventQuestionRejected
| EventTodoUpdated
| EventSessionStatus
| EventSessionIdle
| EventSessionCompacted
| EventTuiPromptAppend
| EventTuiCommandExecute
| EventTuiToastShow
| EventTuiSessionSelect
| EventMcpToolsChanged
| EventMcpBrowserOpenFailed
| EventCommandExecuted
| EventVcsBranchUpdated
| EventWorktreeReady
| EventWorktreeFailed
| EventPtyCreated
| EventPtyUpdated
| EventPtyExited
| EventPtyDeleted
| EventWorkspaceReady
| EventWorkspaceFailed
| EventWorkspaceStatus
| EventMessageUpdated
| EventMessageRemoved
| EventMessagePartUpdated
| EventMessagePartRemoved
| EventSessionCreated
| EventSessionUpdated
| EventSessionDeleted
| SyncEventMessageUpdated
| SyncEventMessageRemoved
| SyncEventMessagePartUpdated
| SyncEventMessagePartRemoved
| SyncEventSessionCreated
| SyncEventSessionUpdated
| SyncEventSessionDeleted
export type SyncEvent = {
payload: SyncEvent
}
/**
@ -2004,54 +1993,6 @@ export type File = {
status: "added" | "deleted" | "modified"
}
export type Event =
| EventProjectUpdated
| EventServerInstanceDisposed
| EventInstallationUpdated
| EventInstallationUpdateAvailable
| EventServerConnected
| EventGlobalDisposed
| EventFileEdited
| EventFileWatcherUpdated
| EventLspClientDiagnostics
| EventLspUpdated
| EventMessagePartDelta
| EventPermissionAsked
| EventPermissionReplied
| EventSessionDiff
| EventSessionError
| EventQuestionAsked
| EventQuestionReplied
| EventQuestionRejected
| EventTodoUpdated
| EventSessionStatus
| EventSessionIdle
| EventSessionCompacted
| EventTuiPromptAppend
| EventTuiCommandExecute
| EventTuiToastShow
| EventTuiSessionSelect
| EventMcpToolsChanged
| EventMcpBrowserOpenFailed
| EventCommandExecuted
| EventVcsBranchUpdated
| EventWorktreeReady
| EventWorktreeFailed
| EventPtyCreated
| EventPtyUpdated
| EventPtyExited
| EventPtyDeleted
| EventWorkspaceReady
| EventWorkspaceFailed
| EventWorkspaceStatus
| EventMessageUpdated
| EventMessageRemoved
| EventMessagePartUpdated
| EventMessagePartRemoved
| EventSessionCreated
| EventSessionUpdated
| EventSessionDeleted
export type McpStatusConnected = {
status: "connected"
}
@ -2183,6 +2124,23 @@ export type GlobalEventResponses = {
export type GlobalEventResponse = GlobalEventResponses[keyof GlobalEventResponses]
export type GlobalSyncEventSubscribeData = {
body?: never
path?: never
query?: never
url: "/global/sync-event"
}
export type GlobalSyncEventSubscribeResponses = {
/**
* Event stream
*/
200: SyncEvent
}
export type GlobalSyncEventSubscribeResponse =
GlobalSyncEventSubscribeResponses[keyof GlobalSyncEventSubscribeResponses]
export type GlobalConfigGetData = {
body?: never
path?: never
@ -2275,6 +2233,79 @@ export type GlobalUpgradeResponses = {
export type GlobalUpgradeResponse = GlobalUpgradeResponses[keyof GlobalUpgradeResponses]
export type GlobalSyncReplayData = {
body?: {
directory: string
events: Array<{
id: string
aggregateID: string
seq: number
type: string
data: {
[key: string]: unknown
}
}>
}
path?: never
query?: never
url: "/sync/replay"
}
export type GlobalSyncReplayErrors = {
/**
* Bad request
*/
400: BadRequestError
}
export type GlobalSyncReplayError = GlobalSyncReplayErrors[keyof GlobalSyncReplayErrors]
export type GlobalSyncReplayResponses = {
/**
* Replayed sync events
*/
200: {
sessionID: string
}
}
export type GlobalSyncReplayResponse = GlobalSyncReplayResponses[keyof GlobalSyncReplayResponses]
export type GlobalSyncHistoryListData = {
body?: {
[key: string]: number
}
path?: never
query?: never
url: "/sync/history"
}
export type GlobalSyncHistoryListErrors = {
/**
* Bad request
*/
400: BadRequestError
}
export type GlobalSyncHistoryListError = GlobalSyncHistoryListErrors[keyof GlobalSyncHistoryListErrors]
export type GlobalSyncHistoryListResponses = {
/**
* Sync events
*/
200: Array<{
id: string
aggregate_id: string
seq: number
type: string
data: {
[key: string]: unknown
}
}>
}
export type GlobalSyncHistoryListResponse = GlobalSyncHistoryListResponses[keyof GlobalSyncHistoryListResponses]
export type AuthRemoveData = {
body?: never
path: {
@ -3000,6 +3031,42 @@ export type ExperimentalWorkspaceRemoveResponses = {
export type ExperimentalWorkspaceRemoveResponse =
ExperimentalWorkspaceRemoveResponses[keyof ExperimentalWorkspaceRemoveResponses]
export type ExperimentalWorkspaceSessionRestoreData = {
body?: {
sessionID: string
}
path: {
id: string
}
query?: {
directory?: string
workspace?: string
}
url: "/experimental/workspace/{id}/session-restore"
}
export type ExperimentalWorkspaceSessionRestoreErrors = {
/**
* Bad request
*/
400: BadRequestError
}
export type ExperimentalWorkspaceSessionRestoreError =
ExperimentalWorkspaceSessionRestoreErrors[keyof ExperimentalWorkspaceSessionRestoreErrors]
export type ExperimentalWorkspaceSessionRestoreResponses = {
/**
* Session replay started
*/
200: {
total: number
}
}
export type ExperimentalWorkspaceSessionRestoreResponse =
ExperimentalWorkspaceSessionRestoreResponses[keyof ExperimentalWorkspaceSessionRestoreResponses]
export type WorktreeRemoveData = {
body?: WorktreeRemoveInput
path?: never
@ -3343,7 +3410,6 @@ export type SessionGetResponse = SessionGetResponses[keyof SessionGetResponses]
export type SessionUpdateData = {
body?: {
title?: string
permission?: PermissionRuleset
time?: {
archived?: number
}