wip
This commit is contained in:
parent
b22add292c
commit
ba499fb405
28 changed files with 2830 additions and 406 deletions
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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 } }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
118
packages/opencode/src/server/instance/sync.ts
Normal file
118
packages/opencode/src/server/instance/sync.ts
Normal 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)
|
||||
},
|
||||
),
|
||||
)
|
||||
|
|
@ -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
|
||||
}
|
||||
},
|
||||
),
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
89
packages/opencode/test/cli/tui/workspace-restore.test.ts
Normal file
89
packages/opencode/test/cli/tui/workspace-restore.test.ts
Normal 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",
|
||||
})
|
||||
})
|
||||
})
|
||||
268
packages/opencode/test/server/workspace-restore.test.ts
Normal file
268
packages/opencode/test/server/workspace-restore.test.ts
Normal 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)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
|
@ -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])
|
||||
}),
|
||||
)
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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" },
|
||||
],
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue