Compare commits

...

3 Commits

Author SHA1 Message Date
James Long
7089f72e76 fixes 2026-05-01 18:23:32 -04:00
James Long
26512178c7 warp 2026-05-01 17:53:39 -04:00
James Long
38129f3663 first pass 2026-05-01 15:22:38 -04:00
18 changed files with 970 additions and 944 deletions

View File

@@ -1,6 +1,7 @@
{
"$schema": "https://opencode.ai/config.json",
"provider": {},
"plugin": ["../packages/opencode/src/control-plane/dev/debug-workspace-plugin.ts"],
"permission": {
"edit": {
"packages/opencode/migration/*": "deny",

View File

@@ -2,7 +2,7 @@ import { useDialog } from "@tui/ui/dialog"
import { DialogSelect } from "@tui/ui/dialog-select"
import { useRoute } from "@tui/context/route"
import { useSync } from "@tui/context/sync"
import { createMemo, createResource, createSignal, onMount } from "solid-js"
import { createMemo, createResource, createSignal, onMount, type JSX } from "solid-js"
import { Locale } from "@/util/locale"
import { useProject } from "@tui/context/project"
import { useKeybind } from "../context/keybind"
@@ -10,15 +10,13 @@ import { useTheme } from "../context/theme"
import { useSDK } from "../context/sdk"
import { Flag } from "@opencode-ai/core/flag/flag"
import { DialogSessionRename } from "./dialog-session-rename"
import { Keybind } from "@/util/keybind"
import { createDebouncedSignal } from "../util/signal"
import { useToast } from "../ui/toast"
import { DialogWorkspaceCreate, openWorkspaceSession, restoreWorkspaceSession } from "./dialog-workspace-create"
import { DialogWorkspaceSelect, type WorkspaceSelection, warpWorkspaceSession } 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"
import { WorkspaceLabel } from "./workspace-label"
export function DialogSessionList() {
const dialog = useDialog()
@@ -44,26 +42,39 @@ export function DialogSessionList() {
const currentSessionID = createMemo(() => (route.data.type === "session" ? route.data.sessionID : undefined))
const sessions = createMemo(() => searchResults() ?? sync.data.session)
function createWorkspace() {
dialog.replace(() => (
<DialogWorkspaceCreate
onSelect={(workspaceID) =>
openWorkspaceSession({
dialog,
route,
sdk,
sync,
toast,
workspaceID,
})
}
/>
))
}
function recover(session: NonNullable<ReturnType<typeof sessions>[number]>) {
const workspace = project.workspace.get(session.workspaceID!)
const list = () => dialog.replace(() => <DialogSessionList />)
const warp = async (selection: WorkspaceSelection) => {
if (selection.type === "none") return
const workspaceID = await (async () => {
if (selection.type === "existing") return selection.workspaceID
const result = await sdk.client.experimental.workspace
.create({ type: selection.workspaceType, branch: null })
.catch(() => undefined)
const workspace = result?.data
if (!workspace) {
toast.show({
message: `Failed to create workspace: ${errorMessage(result?.error ?? "no response")}`,
variant: "error",
})
return
}
await project.workspace.sync()
return workspace.id
})()
if (!workspaceID) return
await warpWorkspaceSession({
dialog,
sdk,
sync,
project,
toast,
workspaceID,
sessionID: session.id,
done: list,
})
}
dialog.replace(() => (
<DialogSessionDeleteFailed
session={session.title}
@@ -91,19 +102,10 @@ export function DialogSessionList() {
}}
onRestore={() => {
dialog.replace(() => (
<DialogWorkspaceCreate
onSelect={(workspaceID) =>
restoreWorkspaceSession({
dialog,
sdk,
sync,
project,
toast,
workspaceID,
sessionID: session.id,
done: list,
})
}
<DialogWorkspaceSelect
onSelect={(selection) => {
void warp(selection)
}}
/>
))
return false
@@ -124,30 +126,17 @@ export function DialogSessionList() {
.map((x) => {
const workspace = x.workspaceID ? project.workspace.get(x.workspaceID) : undefined
let workspaceStatus: WorkspaceStatus | null = null
if (x.workspaceID) {
workspaceStatus = project.workspace.status(x.workspaceID) || "error"
}
let footer = ""
let footer: JSX.Element | string = ""
if (Flag.OPENCODE_EXPERIMENTAL_WORKSPACES) {
if (x.workspaceID) {
let desc = "unknown"
if (workspace) {
desc = `${workspace.type}: ${workspace.name}`
}
footer = (
<>
{desc}{" "}
<span
style={{
fg: workspaceStatus === "connected" ? theme.success : theme.error,
}}
>
</span>
</>
footer = workspace ? (
<WorkspaceLabel
type={workspace.type}
name={workspace.name}
status={project.workspace.status(x.workspaceID) ?? "error"}
/>
) : (
<WorkspaceLabel type="unknown" name={x.workspaceID} status="error" />
)
}
} else {
@@ -250,15 +239,6 @@ export function DialogSessionList() {
dialog.replace(() => <DialogSessionRename session={option.value} />)
},
},
{
keybind: Keybind.parse("ctrl+w")[0],
title: "new workspace",
side: "right",
disabled: !Flag.OPENCODE_EXPERIMENTAL_WORKSPACES,
onTrigger: () => {
createWorkspace()
},
},
]}
/>
)

View File

@@ -1,11 +1,9 @@
import { createOpencodeClient } from "@opencode-ai/sdk/v2"
import type { Workspace } from "@opencode-ai/sdk/v2"
import { useDialog } from "@tui/ui/dialog"
import { DialogSelect } from "@tui/ui/dialog-select"
import { useRoute } from "@tui/context/route"
import { DialogSelect, type DialogSelectOption } from "@tui/ui/dialog-select"
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 { errorMessage } from "@/util/error"
import { useSDK } from "../context/sdk"
import { useToast } from "../ui/toast"
@@ -16,56 +14,26 @@ type Adaptor = {
description: string
}
function scoped(sdk: ReturnType<typeof useSDK>, sync: ReturnType<typeof useSync>, workspaceID: string) {
return createOpencodeClient({
baseUrl: sdk.url,
fetch: sdk.fetch,
directory: sync.path.directory || sdk.directory,
experimental_workspaceID: workspaceID,
})
}
export async function openWorkspaceSession(input: {
dialog: ReturnType<typeof useDialog>
route: ReturnType<typeof useRoute>
sdk: ReturnType<typeof useSDK>
sync: ReturnType<typeof useSync>
toast: ReturnType<typeof useToast>
workspaceID: string
}) {
const client = scoped(input.sdk, input.sync, input.workspaceID)
while (true) {
const result = await client.session.create({ workspace: input.workspaceID }).catch(() => undefined)
if (!result) {
input.toast.show({
message: "Failed to create workspace session",
variant: "error",
})
return
export type WorkspaceSelection =
| {
type: "none"
}
if (result.response?.status && result.response.status >= 500 && result.response.status < 600) {
await sleep(1000)
continue
| {
type: "new"
workspaceType: string
workspaceName: string
}
if (!result.data) {
input.toast.show({
message: "Failed to create workspace session",
variant: "error",
})
return
| {
type: "existing"
workspaceID: string
workspaceType: string
workspaceName: string
}
input.route.navigate({
type: "session",
sessionID: result.data.id,
})
input.dialog.clear()
return
}
}
type WorkspaceSelectValue = WorkspaceSelection | { type: "existing-list" } | { type: "loading" }
type ExistingWorkspaceSelectValue = { workspace: Workspace }
export async function restoreWorkspaceSession(input: {
export async function warpWorkspaceSession(input: {
dialog: ReturnType<typeof useDialog>
sdk: ReturnType<typeof useSDK>
sync: ReturnType<typeof useSync>
@@ -74,40 +42,49 @@ export async function restoreWorkspaceSession(input: {
workspaceID: string
sessionID: string
done?: () => void
}) {
showSuccessToast?: boolean
}): Promise<boolean> {
const result = await input.sdk.client.experimental.workspace
.sessionRestore({ id: input.workspaceID, sessionID: input.sessionID })
.warp({
id: input.workspaceID,
sessionID: input.sessionID,
})
.catch(() => undefined)
if (!result?.data) {
if (!result || result.error) {
input.toast.show({
message: `Failed to restore session: ${errorMessage(result?.error ?? "no response")}`,
message: `Failed to warp session: ${errorMessage(result?.error ?? "no response")}`,
variant: "error",
})
return
return false
}
input.project.workspace.set(input.workspaceID)
await input.sync.bootstrap({ fatal: false }).catch(() => undefined)
await Promise.all([input.project.workspace.sync(), input.sync.session.sync(input.sessionID)])
await Promise.all([input.project.workspace.sync(), input.sync.session.refresh()])
input.toast.show({
message: "Session restored into the new workspace",
variant: "success",
})
if (input.showSuccessToast !== false) {
input.toast.show({
message: "Session warped into the new workspace",
variant: "success",
})
}
input.done?.()
if (input.done) return
if (input.done) return true
input.dialog.clear()
return true
}
export function DialogWorkspaceCreate(props: { onSelect: (workspaceID: string) => Promise<void> | void }) {
export function DialogWorkspaceSelect(props: {
current?: WorkspaceSelection
onSelect: (selection: WorkspaceSelection) => Promise<void> | void
}) {
const dialog = useDialog()
const sync = useSync()
const project = useProject()
const sync = useSync()
const sdk = useSDK()
const toast = useToast()
const [creating, setCreating] = createSignal<string>()
const [adaptors, setAdaptors] = createSignal<Adaptor[]>()
onMount(() => {
@@ -131,69 +108,113 @@ export function DialogWorkspaceCreate(props: { onSelect: (workspaceID: string) =
})()
})
const options = createMemo(() => {
const type = creating()
if (type) {
return [
{
title: `Creating ${type} workspace...`,
value: "creating" as const,
description: "This can take a while for remote environments",
},
]
}
const options = createMemo<DialogSelectOption<WorkspaceSelectValue>[]>(() => {
const list = adaptors()
if (!list) {
return [
{
title: "Loading workspaces...",
value: "loading" as const,
value: { type: "loading" as const },
description: "Fetching available workspace adaptors",
category: "New workspace",
},
]
}
return list.map((item) => ({
title: item.name,
value: item.type,
description: item.description,
}))
const recent = sync.data.session
.toSorted((a, b) => b.time.updated - a.time.updated)
.flatMap((session) => (session.workspaceID ? [session.workspaceID] : []))
.filter((workspaceID, index, list) => list.indexOf(workspaceID) === index)
.slice(0, 3)
.flatMap((workspaceID) => {
const workspace = project.workspace.get(workspaceID)
return workspace ? [workspace] : []
})
return [
...list.map((adaptor) => ({
title: adaptor.name,
value: { type: "new" as const, workspaceType: adaptor.type, workspaceName: adaptor.name },
description: adaptor.description,
category: "New workspace",
})),
{
title: "None",
value: { type: "none" as const },
description: "Use the local project",
category: "Choose workspace",
},
...recent.map((workspace: Workspace) => ({
title: workspace.name,
description: `(${workspace.type})`,
value: {
type: "existing" as const,
workspaceID: workspace.id,
workspaceType: workspace.type,
workspaceName: workspace.name,
},
category: "Choose workspace",
})),
{
title: "View all workspaces",
value: { type: "existing-list" as const },
description: "Choose from all workspaces",
category: "Choose workspace",
},
]
})
const create = async (type: string) => {
if (creating()) return
setCreating(type)
const result = await sdk.client.experimental.workspace.create({ type, branch: null }).catch(() => {
toast.show({
message: "Creating workspace failed",
variant: "error",
})
return undefined
})
const workspace = result?.data
if (!workspace) {
setCreating(undefined)
toast.show({
message: `Failed to create workspace: ${errorMessage(result?.error ?? "no response")}`,
variant: "error",
})
return
}
await project.workspace.sync()
await props.onSelect(workspace.id)
setCreating(undefined)
}
return (
<DialogSelect
title={creating() ? "Creating Workspace" : "New Workspace"}
<DialogSelect<WorkspaceSelectValue>
title="Warp"
skipFilter={true}
renderFilter={false}
options={options()}
current={props.current}
onSelect={(option) => {
if (option.value === "creating" || option.value === "loading") return
void create(option.value)
if (!option.value) return
if (option.value.type === "none") {
void props.onSelect(option.value)
return
}
if (option.value.type === "new") {
void props.onSelect(option.value)
return
}
if (option.value.type === "existing") {
void props.onSelect(option.value)
return
}
dialog.replace(() => <DialogExistingWorkspaceSelect onSelect={props.onSelect} />)
}}
/>
)
}
function DialogExistingWorkspaceSelect(props: { onSelect: (selection: WorkspaceSelection) => Promise<void> | void }) {
const project = useProject()
const options = createMemo<DialogSelectOption<ExistingWorkspaceSelectValue>[]>(() =>
project.workspace
.list()
.filter((workspace) => project.workspace.status(workspace.id) === "connected")
.map((workspace: Workspace) => ({
title: workspace.name,
description: `(${workspace.type})`,
value: { workspace },
})),
)
return (
<DialogSelect<ExistingWorkspaceSelectValue>
title="Existing Workspace"
options={options()}
onSelect={(option) => {
void props.onSelect({
type: "existing",
workspaceID: option.value.workspace.id,
workspaceType: option.value.workspace.type,
workspaceName: option.value.workspace.name,
})
}}
/>
)

View File

@@ -7,6 +7,7 @@ import { Filesystem } from "@/util/filesystem"
import { useLocal } from "@tui/context/local"
import { tint, useTheme } from "@tui/context/theme"
import { EmptyBorder, SplitBorder } from "@tui/component/border"
import { Spinner } from "@tui/component/spinner"
import { useSDK } from "@tui/context/sdk"
import { useRoute } from "@tui/context/route"
import { useProject } from "@tui/context/project"
@@ -40,13 +41,18 @@ import { useKV } from "../../context/kv"
import { createFadeIn } from "../../util/signal"
import { useTextareaKeybindings } from "../textarea-keybindings"
import { DialogSkill } from "../dialog-skill"
import { DialogWorkspaceCreate, restoreWorkspaceSession } from "../dialog-workspace-create"
import { DialogWorkspaceSelect, warpWorkspaceSession, type WorkspaceSelection } from "../dialog-workspace-create"
import { DialogWorkspaceUnavailable } from "../dialog-workspace-unavailable"
import { useArgs } from "@tui/context/args"
import { Flag } from "@opencode-ai/core/flag/flag"
import { WorkspaceLabel, type WorkspaceStatus } from "../workspace-label"
export type PromptProps = {
sessionID?: string
workspaceID?: string
workspaceSelection?: WorkspaceSelection
onWorkspaceSelectionChange?: (selection: WorkspaceSelection | undefined) => void
onWorkspaceCreatingChange?: (creating: boolean) => void
visible?: boolean
disabled?: boolean
onSubmit?: () => void
@@ -149,8 +155,98 @@ export function Prompt(props: PromptProps) {
})
let lastSubmittedEditorSelectionKey: string | undefined
const [auto, setAuto] = createSignal<AutocompleteRef>()
const [workspaceSelection, setWorkspaceSelection] = createSignal<WorkspaceSelection>()
const [workspaceCreating, setWorkspaceCreating] = createSignal(false)
const [workspaceCreatingDots, setWorkspaceCreatingDots] = createSignal(3)
const [warpNotice, setWarpNotice] = createSignal<string>()
const currentProviderLabel = createMemo(() => local.model.parsed().provider)
const hasRightContent = createMemo(() => Boolean(props.right))
const selectedWorkspace = () => props.workspaceSelection ?? workspaceSelection()
function selectWorkspace(selection: WorkspaceSelection | undefined) {
setWorkspaceSelection(selection)
props.onWorkspaceSelectionChange?.(selection)
}
function setCreatingWorkspace(creating: boolean) {
setWorkspaceCreating(creating)
props.onWorkspaceCreatingChange?.(creating)
}
function showWarpNotice(name: string) {
setWarpNotice(`Warped to ${name}`)
setTimeout(() => setWarpNotice(undefined), 4000)
}
async function createWorkspace(selection: Extract<WorkspaceSelection, { type: "new" }>) {
setCreatingWorkspace(true)
const result = await sdk.client.experimental.workspace
.create({ type: selection.workspaceType, branch: null })
.catch(() => undefined)
if (result == undefined || result.error || !result.data) {
selectWorkspace(undefined)
setCreatingWorkspace(false)
toast.show({
message: "Creating workspace failed",
variant: "error",
})
return
}
await project.workspace.sync()
const workspace = result.data
selectWorkspace({
type: "existing",
workspaceID: workspace.id,
workspaceType: workspace.type,
workspaceName: workspace.name,
})
setCreatingWorkspace(false)
return workspace
}
async function warpSession(selection: WorkspaceSelection) {
if (!props.sessionID) {
selectWorkspace(selection)
dialog.clear()
if (selection.type === "new") void createWorkspace(selection)
return
}
if (selection.type === "none") {
dialog.clear()
return
}
selectWorkspace(selection)
dialog.clear()
const workspace =
selection.type === "existing"
? { id: selection.workspaceID, name: selection.workspaceName }
: await createWorkspace(selection)
if (!workspace) return
const warped = await warpWorkspaceSession({
dialog,
sdk,
sync,
project,
toast,
workspaceID: workspace.id,
sessionID: props.sessionID,
showSuccessToast: false,
})
if (warped) showWarpNotice(workspace.name)
}
createEffect(() => {
if (!workspaceCreating()) {
setWorkspaceCreatingDots(3)
return
}
const timer = setInterval(() => setWorkspaceCreatingDots((dots) => (dots % 3) + 1), 1000)
onCleanup(() => clearInterval(timer))
})
function promptModelWarning() {
toast.show({
@@ -184,6 +280,7 @@ export function Prompt(props: PromptProps) {
})
createEffect(() => {
if (!input || input.isDestroyed) return
if (props.disabled) input.cursorColor = theme.backgroundElement
if (!props.disabled) input.cursorColor = theme.text
})
@@ -450,6 +547,26 @@ export function Prompt(props: PromptProps) {
))
},
},
{
title: "Warp",
description: "Change the workspace for the session",
value: "workspace.set",
category: "Session",
enabled: Flag.OPENCODE_EXPERIMENTAL_WORKSPACES,
slash: {
name: "warp",
},
onSelect: (dialog) => {
dialog.replace(() => (
<DialogWorkspaceSelect
current={selectedWorkspace()}
onSelect={(selection) => {
void warpSession(selection)
}}
/>
))
},
},
]
})
@@ -533,7 +650,7 @@ export function Prompt(props: PromptProps) {
})
function restoreExtmarksFromParts(parts: PromptInfo["parts"]) {
input.extmarks.clear()
if (!input.isDestroyed) input.extmarks.clear()
setStore("extmarkToPartIndex", new Map())
parts.forEach((part, partIndex) => {
@@ -666,6 +783,7 @@ export function Prompt(props: PromptProps) {
])
async function submit() {
setWarpNotice(undefined)
// IME: double-defer may fire before onContentChange flushes the last
// composed character (e.g. Korean hangul) to the store, so read
// plainText directly and sync before any downstream reads.
@@ -674,6 +792,7 @@ export function Prompt(props: PromptProps) {
syncExtmarksWithPromptParts()
}
if (props.disabled) return false
if (workspaceCreating()) return false
if (autocomplete?.visible) return false
if (!store.prompt.input) return false
const agent = local.agent.current()
@@ -697,44 +816,20 @@ export function Prompt(props: PromptProps) {
<DialogWorkspaceUnavailable
onRestore={() => {
dialog.replace(() => (
<DialogWorkspaceCreate
onSelect={(nextWorkspaceID) =>
restoreWorkspaceSession({
dialog,
sdk,
sync,
project,
toast,
workspaceID: nextWorkspaceID,
sessionID: props.sessionID!,
})
}
<DialogWorkspaceSelect
current={selectedWorkspace()}
onSelect={(selection) => {
void warpSession(selection)
}}
/>
))
return false
}}
/>
))
return false
}
let sessionID = props.sessionID
if (sessionID == null) {
const res = await sdk.client.session.create({ workspace: props.workspaceID })
if (res.error) {
console.log("Creating a session failed:", res.error)
toast.show({
message: "Creating a session failed. Open console for more details.",
variant: "error",
})
return true
}
sessionID = res.data.id
}
const messageID = MessageID.ascending()
let inputText = store.prompt.input
@@ -759,6 +854,7 @@ export function Prompt(props: PromptProps) {
// Capture mode before it gets reset
const currentMode = store.mode
const submittedPrompt = unwrap(store.prompt)
const variant = local.model.variant.current()
const editorSelection = fileContextEnabled() ? editor.selection() : undefined
const editorSelectionKey = editorSelection ? getEditorSelectionKey(editorSelection) : undefined
@@ -794,6 +890,33 @@ export function Prompt(props: PromptProps) {
]
: []
let sessionID = props.sessionID
if (sessionID == null) {
const workspace = selectedWorkspace()
const workspaceID = iife(() => {
if (!workspace) return undefined
if (workspace.type === "none") return undefined
if (workspace.type === "existing") return workspace.workspaceID
return undefined
})
const res = await sdk.client.session.create({ workspace: workspaceID })
if (res.error) {
setCreatingWorkspace(false)
console.log("Creating a session failed:", res.error)
toast.show({
message: "Creating a session failed. Open console for more details.",
variant: "error",
})
return true
}
sessionID = res.data.id
}
if (store.mode === "shell") {
void sdk.client.session.shell({
sessionID,
@@ -858,7 +981,7 @@ export function Prompt(props: PromptProps) {
lastSubmittedEditorSelectionKey = editorSelectionKey
}
history.append({
...store.prompt,
...submittedPrompt,
mode: currentMode,
})
input.extmarks.clear()
@@ -877,7 +1000,7 @@ export function Prompt(props: PromptProps) {
sessionID,
})
}, 50)
input.clear()
if (!input.isDestroyed) input.clear()
return true
}
const exit = useExit()
@@ -998,6 +1121,29 @@ export function Prompt(props: PromptProps) {
return `Ask anything... "${list()[store.placeholder % list().length]}"`
})
const workspaceLabel = createMemo<
| { type: "new"; workspaceType: string }
| { type: "existing"; workspaceType: string; workspaceName: string; status?: WorkspaceStatus }
| undefined
>(() => {
const selected = selectedWorkspace()
if (!selected) return
if (selected.type === "none") return
if (props.sessionID && !workspaceCreating()) return
if (selected.type === "new") {
return {
type: "new",
workspaceType: selected.workspaceType,
}
}
return {
type: "existing",
workspaceType: selected.workspaceType,
workspaceName: selected.workspaceName,
status: selected.type === "existing" ? "connected" : undefined,
}
})
const spinnerDef = createMemo(() => {
const agent = local.agent.current()
const color = agent ? local.agent.color(agent.name) : theme.border
@@ -1254,7 +1400,7 @@ export function Prompt(props: PromptProps) {
}}
onMouseDown={(r: MouseEvent) => r.target?.focus()}
focusedBackgroundColor={theme.backgroundElement}
cursorColor={theme.text}
cursorColor={props.disabled ? theme.backgroundElement : theme.text}
syntaxStyle={syntax()}
/>
<box flexDirection="row" flexShrink={0} paddingTop={1} gap={1} justifyContent="space-between">
@@ -1324,86 +1470,126 @@ export function Prompt(props: PromptProps) {
/>
</box>
<box width="100%" flexDirection="row" justifyContent="space-between">
<Show when={status().type !== "idle"} fallback={props.hint ?? <text />}>
<box
flexDirection="row"
gap={1}
flexGrow={1}
justifyContent={status().type === "retry" ? "space-between" : "flex-start"}
>
<box flexShrink={0} flexDirection="row" gap={1}>
<box marginLeft={1}>
<Show when={kv.get("animations_enabled", true)} fallback={<text fg={theme.textMuted}>[]</text>}>
<spinner color={spinnerDef().color} frames={spinnerDef().frames} interval={40} />
</Show>
</box>
<box flexDirection="row" gap={1} flexShrink={0}>
{(() => {
const retry = createMemo(() => {
const s = status()
if (s.type !== "retry") return
return s
})
const message = createMemo(() => {
const r = retry()
if (!r) return
if (r.message.includes("exceeded your current quota") && r.message.includes("gemini"))
return "gemini is way too hot right now"
if (r.message.length > 80) return r.message.slice(0, 80) + "..."
return r.message
})
const isTruncated = createMemo(() => {
const r = retry()
if (!r) return false
return r.message.length > 120
})
const [seconds, setSeconds] = createSignal(0)
onMount(() => {
const timer = setInterval(() => {
const next = retry()?.next
if (next) setSeconds(Math.round((next - Date.now()) / 1000))
}, 1000)
onCleanup(() => {
clearInterval(timer)
})
})
const handleMessageClick = () => {
const r = retry()
if (!r) return
if (isTruncated()) {
void DialogAlert.show(dialog, "Retry Error", r.message)
}
}
const retryText = () => {
const r = retry()
if (!r) return ""
const baseMessage = message()
const truncatedHint = isTruncated() ? " (click to expand)" : ""
const duration = formatDuration(seconds())
const retryInfo = ` [retrying ${duration ? `in ${duration} ` : ""}attempt #${r.attempt}]`
return baseMessage + truncatedHint + retryInfo
}
return (
<Show when={retry()}>
<box onMouseUp={handleMessageClick}>
<text fg={theme.error}>{retryText()}</text>
</box>
<Switch>
<Match when={status().type !== "idle"}>
<Show when={true}>
<box
flexDirection="row"
gap={1}
flexGrow={1}
justifyContent={status().type === "retry" ? "space-between" : "flex-start"}
>
<box flexShrink={0} flexDirection="row" gap={1}>
<box marginLeft={1}>
<Show when={kv.get("animations_enabled", true)} fallback={<text fg={theme.textMuted}>[]</text>}>
<spinner color={spinnerDef().color} frames={spinnerDef().frames} interval={40} />
</Show>
)
})()}
</box>
<box flexDirection="row" gap={1} flexShrink={0}>
{(() => {
const retry = createMemo(() => {
const s = status()
if (s.type !== "retry") return
return s
})
const message = createMemo(() => {
const r = retry()
if (!r) return
if (r.message.includes("exceeded your current quota") && r.message.includes("gemini"))
return "gemini is way too hot right now"
if (r.message.length > 80) return r.message.slice(0, 80) + "..."
return r.message
})
const isTruncated = createMemo(() => {
const r = retry()
if (!r) return false
return r.message.length > 120
})
const [seconds, setSeconds] = createSignal(0)
onMount(() => {
const timer = setInterval(() => {
const next = retry()?.next
if (next) setSeconds(Math.round((next - Date.now()) / 1000))
}, 1000)
onCleanup(() => {
clearInterval(timer)
})
})
const handleMessageClick = () => {
const r = retry()
if (!r) return
if (isTruncated()) {
void DialogAlert.show(dialog, "Retry Error", r.message)
}
}
const retryText = () => {
const r = retry()
if (!r) return ""
const baseMessage = message()
const truncatedHint = isTruncated() ? " (click to expand)" : ""
const duration = formatDuration(seconds())
const retryInfo = ` [retrying ${duration ? `in ${duration} ` : ""}attempt #${r.attempt}]`
return baseMessage + truncatedHint + retryInfo
}
return (
<Show when={retry()}>
<box onMouseUp={handleMessageClick}>
<text fg={theme.error}>{retryText()}</text>
</box>
</Show>
)
})()}
</box>
</box>
<text fg={store.interrupt > 0 ? theme.primary : theme.text}>
esc{" "}
<span style={{ fg: store.interrupt > 0 ? theme.primary : theme.textMuted }}>
{store.interrupt > 0 ? "again to interrupt" : "interrupt"}
</span>
</text>
</box>
</box>
<text fg={store.interrupt > 0 ? theme.primary : theme.text}>
esc{" "}
<span style={{ fg: store.interrupt > 0 ? theme.primary : theme.textMuted }}>
{store.interrupt > 0 ? "again to interrupt" : "interrupt"}
</span>
</text>
</box>
</Show>
</Show>
</Match>
<Match when={warpNotice()}>
{(notice) => (
<box paddingLeft={3}>
<text fg={theme.accent}>{notice()}</text>
</box>
)}
</Match>
<Match when={workspaceLabel()}>
{(workspace) => (
<box paddingLeft={3} flexDirection="row" gap={1}>
<Show when={workspaceCreating()}>
<Spinner color={theme.accent} />
</Show>
<text fg={workspaceCreating() ? theme.accent : theme.text}>
{(() => {
const item = workspace()
if (item.type === "new") {
if (workspaceCreating())
return `Creating ${item.workspaceType}${".".repeat(workspaceCreatingDots())}`
return (
<>
Workspace <span style={{ fg: theme.textMuted }}>(new {item.workspaceType})</span>
</>
)
}
return (
<>
Workspace <span style={{ fg: theme.textMuted }}>{item.workspaceName}</span>
</>
)
})()}
</text>
</box>
)}
</Match>
<Match when={true}>{props.hint ?? <text />}</Match>
</Switch>
<Show when={status().type !== "retry"}>
<box gap={2} flexDirection="row">
<Show when={editorFileLabelDisplay()}>{(file) => <text fg={theme.secondary}>{file()}</text>}</Show>

View File

@@ -0,0 +1,19 @@
import { useTheme } from "@tui/context/theme"
export type WorkspaceStatus = "connected" | "connecting" | "disconnected" | "error"
export function WorkspaceLabel(props: { type: string; name: string; status?: WorkspaceStatus; icon?: boolean }) {
const { theme } = useTheme()
const color = () => {
if (props.status === "connected") return theme.success
if (props.status === "error") return theme.error
return theme.textMuted
}
return (
<>
{props.icon ? <span style={{ fg: color() }}> </span> : undefined}
<span style={{ fg: theme.text }}>{props.name}</span> <span style={{ fg: theme.textMuted }}>({props.type})</span>
</>
)
}

View File

@@ -9,6 +9,7 @@ import { useRouteData } from "@tui/context/route"
import { usePromptRef } from "../context/prompt"
import { useLocal } from "../context/local"
import { TuiPluginRuntime } from "@/cli/cmd/tui/plugin/runtime"
import type { WorkspaceSelection } from "../component/dialog-workspace-create"
let once = false
const placeholder = {
@@ -22,10 +23,28 @@ export function Home() {
const route = useRouteData("home")
const promptRef = usePromptRef()
const [ref, setRef] = createSignal<PromptRef | undefined>()
const [workspaceSelection, setWorkspaceSelection] = createSignal<WorkspaceSelection>()
const args = useArgs()
const local = useLocal()
let sent = false
const currentWorkspaceSelection = (): WorkspaceSelection | undefined => {
const workspaceID = project.workspace.current()
if (!workspaceID) return { type: "none" }
const workspace = project.workspace.get(workspaceID)
return {
type: "existing",
workspaceID,
workspaceType: workspace?.type ?? "unknown",
workspaceName: workspace?.name ?? workspaceID,
}
}
createEffect(() => {
if (workspaceSelection()) return
setWorkspaceSelection(currentWorkspaceSelection())
})
const bind = (r: PromptRef | undefined) => {
setRef(r)
promptRef.set(r)
@@ -73,6 +92,8 @@ export function Home() {
<Prompt
ref={bind}
workspaceID={project.workspace.current()}
workspaceSelection={workspaceSelection()}
onWorkspaceSelectionChange={setWorkspaceSelection}
right={<TuiPluginRuntime.Slot name="home_prompt_right" workspace_id={project.workspace.current()} />}
placeholders={placeholder}
/>

View File

@@ -7,6 +7,7 @@ import { InstallationChannel, InstallationVersion } from "@opencode-ai/core/inst
import { TuiPluginRuntime } from "@/cli/cmd/tui/plugin/runtime"
import { getScrollAcceleration } from "../../util/scroll"
import { WorkspaceLabel } from "../../component/workspace-label"
export function Sidebar(props: { sessionID: string; overlay?: boolean }) {
const project = useProject()
@@ -14,17 +15,10 @@ export function Sidebar(props: { sessionID: string; overlay?: boolean }) {
const { theme } = useTheme()
const tuiConfig = useTuiConfig()
const session = createMemo(() => sync.session.get(props.sessionID))
const workspaceStatus = () => {
const workspace = () => {
const workspaceID = session()?.workspaceID
if (!workspaceID) return "error"
return project.workspace.status(workspaceID) ?? "error"
}
const workspaceLabel = () => {
const workspaceID = session()?.workspaceID
if (!workspaceID) return "unknown"
const info = project.workspace.get(workspaceID)
if (!info) return "unknown"
return `${info.type}: ${info.name}`
if (!workspaceID) return
return project.workspace.get(workspaceID)
}
const scrollAcceleration = createMemo(() => getScrollAcceleration(tuiConfig))
@@ -67,8 +61,19 @@ export function Sidebar(props: { sessionID: string; overlay?: boolean }) {
</Show>
<Show when={session()!.workspaceID}>
<text fg={theme.textMuted}>
<span style={{ fg: workspaceStatus() === "connected" ? theme.success : theme.error }}></span>{" "}
{workspaceLabel()}
<Show
when={workspace()}
fallback={<WorkspaceLabel type="unknown" name={session()!.workspaceID!} status="error" icon />}
>
{(item) => (
<WorkspaceLabel
type={item().type}
name={item().name}
status={project.workspace.status(item().id) ?? "error"}
icon
/>
)}
</Show>
</text>
</Show>
<Show when={session()!.share?.url}>

View File

@@ -23,6 +23,7 @@ export interface DialogSelectProps<T> {
onFilter?: (query: string) => void
onSelect?: (option: DialogSelectOption<T>) => void
skipFilter?: boolean
renderFilter?: boolean
keybind?: {
keybind?: Keybind.Info
title: string
@@ -35,6 +36,7 @@ export interface DialogSelectProps<T> {
export interface DialogSelectOption<T = any> {
title: string
titleView?: JSX.Element
value: T
description?: string
footer?: JSX.Element | string
@@ -81,7 +83,7 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
let input: InputRenderable
const filtered = createMemo(() => {
if (props.skipFilter) return props.options.filter((x) => x.disabled !== true)
if (props.skipFilter || props.renderFilter === false) return props.options.filter((x) => x.disabled !== true)
const needle = store.filter.toLowerCase()
const options = pipe(
props.options,
@@ -250,30 +252,32 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
esc
</text>
</box>
<box paddingTop={1}>
<input
onInput={(e) => {
batch(() => {
setStore("filter", e)
props.onFilter?.(e)
})
}}
focusedBackgroundColor={theme.backgroundPanel}
cursorColor={theme.primary}
focusedTextColor={theme.textMuted}
ref={(r) => {
input = r
input.traits = { status: "FILTER" }
setTimeout(() => {
if (!input) return
if (input.isDestroyed) return
input.focus()
}, 1)
}}
placeholder={props.placeholder ?? "Search"}
placeholderColor={theme.textMuted}
/>
</box>
<Show when={props.renderFilter !== false}>
<box paddingTop={1}>
<input
onInput={(e) => {
batch(() => {
setStore("filter", e)
props.onFilter?.(e)
})
}}
focusedBackgroundColor={theme.backgroundPanel}
cursorColor={theme.primary}
focusedTextColor={theme.textMuted}
ref={(r) => {
input = r
input.traits = { status: "FILTER" }
setTimeout(() => {
if (!input) return
if (input.isDestroyed) return
input.focus()
}, 1)
}}
placeholder={props.placeholder ?? "Search"}
placeholderColor={theme.textMuted}
/>
</box>
</Show>
</box>
<Show
when={grouped().length > 0}
@@ -347,6 +351,7 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
</Show>
<Option
title={option.title}
titleView={option.titleView}
footer={flatten() ? (option.category ?? option.footer) : option.footer}
description={option.description !== category ? option.description : undefined}
active={active()}
@@ -403,6 +408,7 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
function Option(props: {
title: string
titleView?: JSX.Element
description?: string
active?: boolean
current?: boolean
@@ -433,7 +439,9 @@ function Option(props: {
wrapMode="none"
paddingLeft={3}
>
{Locale.truncate(props.title, 61)}
<Show when={props.titleView} fallback={Locale.truncate(props.title, 61)}>
{(titleView) => titleView()}
</Show>
<Show when={props.description}>
<span style={{ fg: props.active ? fg : theme.textMuted }}> {props.description}</span>
</Show>

View File

@@ -1,4 +1,4 @@
import { Context, Effect, FiberMap, Layer, Schema, Stream } from "effect"
import { Context, Effect, FiberMap, Iterable, Layer, Schema, Stream } from "effect"
import { FetchHttpClient, HttpBody, HttpClient, HttpClientError, HttpClientRequest } from "effect/unstable/http"
import { fn } from "@/util/fn"
import { Database } from "@/storage/db"
@@ -27,7 +27,7 @@ import { errorData } from "@/util/error"
import { makeRuntime } from "@/effect/run-service"
import { waitEvent } from "./util"
import { WorkspaceContext } from "./workspace-context"
import { NonNegativeInt, withStatics } from "@/util/schema"
import { withStatics } from "@/util/schema"
import { zod as effectZod, zodObject } from "@/util/effect-zod"
export const Info = WorkspaceInfoSchema
@@ -39,13 +39,6 @@ export const ConnectionStatus = Schema.Struct({
})
export type ConnectionStatus = Schema.Schema.Type<typeof ConnectionStatus>
const Restore = Schema.Struct({
workspaceID: WorkspaceID,
sessionID: SessionID,
total: NonNegativeInt,
step: NonNegativeInt,
})
export const Event = {
Ready: BusEvent.define(
"workspace.ready",
@@ -59,7 +52,6 @@ export const Event = {
message: Schema.String,
}),
),
Restore: BusEvent.define("workspace.restore", Restore),
Status: BusEvent.define("workspace.status", ConnectionStatus),
}
@@ -89,11 +81,11 @@ export const CreateInput = Schema.Struct({
}).pipe(withStatics((s) => ({ zod: effectZod(s), zodObject: zodObject(s) })))
export type CreateInput = Schema.Schema.Type<typeof CreateInput>
export const SessionRestoreInput = Schema.Struct({
export const SessionWarpInput = Schema.Struct({
workspaceID: WorkspaceID,
sessionID: SessionID,
}).pipe(withStatics((s) => ({ zod: effectZod(s), zodObject: zodObject(s) })))
export type SessionRestoreInput = Schema.Schema.Type<typeof SessionRestoreInput>
export type SessionWarpInput = Schema.Schema.Type<typeof SessionWarpInput>
export class SyncHttpError extends Schema.TaggedErrorClass<SyncHttpError>()("WorkspaceSyncHttpError", {
message: Schema.String,
@@ -117,8 +109,8 @@ export class SessionEventsNotFoundError extends Schema.TaggedErrorClass<SessionE
},
) {}
export class SessionRestoreHttpError extends Schema.TaggedErrorClass<SessionRestoreHttpError>()(
"WorkspaceSessionRestoreHttpError",
export class SessionWarpHttpError extends Schema.TaggedErrorClass<SessionWarpHttpError>()(
"WorkspaceSessionWarpHttpError",
{
message: Schema.String,
workspaceID: WorkspaceID,
@@ -139,17 +131,17 @@ export class SyncAbortedError extends Schema.TaggedErrorClass<SyncAbortedError>(
}) {}
type CreateError = Auth.AuthError
type SessionRestoreError =
type SessionWarpError =
| WorkspaceNotFoundError
| SessionEventsNotFoundError
| SessionRestoreHttpError
| SessionWarpHttpError
| HttpClientError.HttpClientError
type WaitForSyncError = SyncTimeoutError | SyncAbortedError
type SyncLoopError = SyncHttpError | HttpClientError.HttpClientError
export interface Interface {
readonly create: (input: CreateInput) => Effect.Effect<Info, CreateError>
readonly sessionRestore: (input: SessionRestoreInput) => Effect.Effect<{ total: number }, SessionRestoreError>
readonly sessionWarp: (input: SessionWarpInput) => Effect.Effect<void, SessionWarpError>
readonly list: (project: Project.Info) => Effect.Effect<Info[]>
readonly get: (id: WorkspaceID) => Effect.Effect<Info | undefined>
readonly remove: (id: WorkspaceID) => Effect.Effect<Info | undefined>
@@ -501,9 +493,9 @@ export const layer = Layer.effect(
return info
})
const sessionRestore = Effect.fn("Workspace.sessionRestore")(function* (input: SessionRestoreInput) {
const sessionWarp = Effect.fn("Workspace.sessionWarp")(function* (input: SessionWarpInput) {
return yield* Effect.gen(function* () {
log.info("session restore requested", {
log.info("session warp requested", {
workspaceID: input.workspaceID,
sessionID: input.sessionID,
})
@@ -515,17 +507,76 @@ export const layer = Layer.effect(
workspaceID: input.workspaceID,
})
const current = yield* db((db) =>
db
.select({ workspaceID: SessionTable.workspace_id })
.from(SessionTable)
.where(eq(SessionTable.id, input.sessionID))
.get(),
)
if (current?.workspaceID) {
const previous = yield* get(current.workspaceID)
if (previous) {
yield* Effect.gen(function* () {
const adaptor = getAdaptor(previous.projectID, previous.type)
const target = yield* Effect.promise(() => Promise.resolve(adaptor.target(previous)))
if (target.type === "local") return
const response = yield* http.execute(
HttpClientRequest.post(route(target.url, "/sync/erase"), {
headers: new Headers(target.headers),
body: HttpBody.jsonUnsafe({ sessionID: input.sessionID }),
}),
)
// TODO: if this fails, we need to mark this workspace
// as "orphaned" meaning we abandoned it and never want
// to talk to it again
if (response.status < 200 || response.status >= 300) {
const body = yield* response.text
log.warn("session warp erase failed", {
workspaceID: previous.id,
sessionID: input.sessionID,
status: response.status,
body,
})
}
}).pipe(
Effect.catch((error) =>
Effect.sync(() => {
log.warn("session warp erase unavailable", {
workspaceID: previous.id,
sessionID: input.sessionID,
error,
})
}),
),
)
}
}
const adaptor = getAdaptor(space.projectID, space.type)
const target = yield* Effect.promise(() => Promise.resolve(adaptor.target(space)))
yield* Effect.sync(() =>
SyncEvent.run(Session.Event.Updated, {
if (target.type === "local") {
yield* Effect.sync(() =>
SyncEvent.run(Session.Event.Updated, {
sessionID: input.sessionID,
info: {
workspaceID: input.workspaceID,
},
}),
)
log.info("session warp complete", {
workspaceID: input.workspaceID,
sessionID: input.sessionID,
info: {
workspaceID: input.workspaceID,
},
}),
)
target: target.directory,
})
return
}
const rows = yield* db((db) =>
db
@@ -547,130 +598,95 @@ export const layer = Layer.effect(
sessionID: input.sessionID,
})
const size = 10
// TODO: look into using effect APIs to process this in chunks
const sets = Array.from({ length: Math.ceil(rows.length / size) }, (_, i) =>
rows.slice(i * size, (i + 1) * size),
)
const total = sets.length
const batches = Iterable.chunksOf(rows, 10)
const total = Iterable.size(batches)
log.info("session restore prepared", {
log.info("session warp 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,
target: String(route(target.url, "/sync/replay")),
events: rows.length,
batches: total,
first: rows[0]?.seq,
last: rows.at(-1)?.seq,
})
yield* Effect.sync(() =>
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 res = yield* http.execute(
HttpClientRequest.post(url, {
headers: new Headers(target.headers),
body: HttpBody.jsonUnsafe({
directory: space.directory ?? "",
events,
yield* Effect.forEach(
batches,
(events, i) =>
Effect.gen(function* () {
const response = yield* http.execute(
HttpClientRequest.post(route(target.url, "/sync/replay"), {
headers: new Headers(target.headers),
body: HttpBody.jsonUnsafe({
directory: space.directory ?? "",
events,
}),
}),
}),
)
)
if (res.status < 200 || res.status >= 300) {
const body = yield* res.text
log.error("session restore batch failed", {
if (response.status < 200 || response.status >= 300) {
const body = yield* response.text
log.error("session warp batch failed", {
workspaceID: input.workspaceID,
sessionID: input.sessionID,
step: i + 1,
total,
status: response.status,
body,
})
return yield* new SessionWarpHttpError({
message: `Failed to warp session ${input.sessionID} into workspace ${input.workspaceID}: HTTP ${response.status} ${body}`,
workspaceID: input.workspaceID,
sessionID: input.sessionID,
status: response.status,
body,
})
}
log.info("session warp batch posted", {
workspaceID: input.workspaceID,
sessionID: input.sessionID,
step: i + 1,
total,
status: res.status,
body,
status: response.status,
})
return yield* new SessionRestoreHttpError({
message: `Failed to replay session ${input.sessionID} into workspace ${input.workspaceID}: HTTP ${res.status} ${body}`,
workspaceID: input.workspaceID,
sessionID: input.sessionID,
status: res.status,
body,
})
}
log.info("session restore batch posted", {
workspaceID: input.workspaceID,
sessionID: input.sessionID,
step: i + 1,
total,
status: res.status,
})
}
yield* Effect.sync(() =>
GlobalBus.emit("event", {
directory: "global",
workspace: input.workspaceID,
payload: {
type: Event.Restore.type,
properties: {
workspaceID: input.workspaceID,
sessionID: input.sessionID,
total,
step: i + 1,
},
},
}),
)
{ discard: true },
)
const response = yield* http.execute(
HttpClientRequest.post(route(target.url, "/sync/steal"), {
headers: new Headers(target.headers),
body: HttpBody.jsonUnsafe({ sessionID: input.sessionID }),
}),
)
if (response.status < 200 || response.status >= 300) {
const body = yield* response.text
log.error("session warp steal failed", {
workspaceID: input.workspaceID,
sessionID: input.sessionID,
status: response.status,
body,
})
return yield* new SessionWarpHttpError({
message: `Failed to steal session ${input.sessionID} into workspace ${input.workspaceID}: HTTP ${response.status} ${body}`,
workspaceID: input.workspaceID,
sessionID: input.sessionID,
status: response.status,
body,
})
}
log.info("session restore complete", {
log.info("session warp complete", {
workspaceID: input.workspaceID,
sessionID: input.sessionID,
batches: total,
})
return { total }
}).pipe(
Effect.tapError((err) =>
Effect.sync(() =>
log.error("session restore failed", {
log.error("session warp failed", {
workspaceID: input.workspaceID,
sessionID: input.sessionID,
error: errorData(err),
@@ -799,7 +815,7 @@ export const layer = Layer.effect(
return Service.of({
create,
sessionRestore,
sessionWarp,
list,
get,
remove,
@@ -861,7 +877,7 @@ const { runPromise, runSync } = makeRuntime(Service, defaultLayer)
export const create = fn(CreateInput.zod, (input) => runPromise((svc) => svc.create(input)))
export const sessionRestore = fn(SessionRestoreInput.zod, (input) => runPromise((svc) => svc.sessionRestore(input)))
export const sessionWarp = fn(SessionWarpInput.zod, (input) => runPromise((svc) => svc.sessionWarp(input)))
export function list(project: Project.Info) {
return Database.use((db) =>

View File

@@ -11,7 +11,7 @@ export type Selection = {
export type Attributes = ReturnType<typeof attributes>
export function select(): Selection {
if (Flag.OPENCODE_EXPERIMENTAL_HTTPAPI) return { backend: "effect-httpapi", reason: "env" }
// if (Flag.OPENCODE_EXPERIMENTAL_HTTPAPI) return { backend: "effect-httpapi", reason: "env" }
return { backend: "hono", reason: "stable" }
}

View File

@@ -8,10 +8,6 @@ import { zodObject } from "@/util/effect-zod"
import { Instance } from "@/project/instance"
import { errors } from "../../error"
import { lazy } from "@/util/lazy"
import * as Log from "@opencode-ai/core/util/log"
import { errorData } from "@/util/error"
const log = Log.create({ service: "server.workspace" })
export const WorkspaceRoutes = lazy(() =>
new Hono()
@@ -142,56 +138,28 @@ export const WorkspaceRoutes = lazy(() =>
},
)
.post(
"/:id/session-restore",
"/:id/warp",
describeRoute({
summary: "Restore session into workspace",
description: "Replay a session's sync events into the target workspace in batches.",
operationId: "experimental.workspace.sessionRestore",
summary: "Warp session into workspace",
description: "Move a session's sync history into the target workspace.",
operationId: "experimental.workspace.warp",
responses: {
200: {
description: "Session replay started",
content: {
"application/json": {
schema: resolver(
z.object({
total: z.number().int().min(0),
}),
),
},
},
204: {
description: "Session warped",
},
...errors(400),
},
}),
validator("param", z.object({ id: zodObject(Workspace.Info).shape.id })),
validator("json", Workspace.SessionRestoreInput.zodObject.omit({ workspaceID: true })),
validator("json", Workspace.SessionWarpInput.zodObject.omit({ workspaceID: true })),
async (c) => {
const { id } = c.req.valid("param")
const body = c.req.valid("json") as Omit<Workspace.SessionRestoreInput, "workspaceID">
log.info("session restore route requested", {
const body = c.req.valid("json") as Omit<Workspace.SessionWarpInput, "workspaceID">
await Workspace.sessionWarp({
workspaceID: id,
sessionID: body.sessionID,
directory: Instance.directory,
...body,
})
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
}
return c.body(null, 204)
},
),
)

View File

@@ -1,6 +1,5 @@
import { Workspace } from "@/control-plane/workspace"
import { WorkspaceAdaptorEntry } from "@/control-plane/types"
import { NonNegativeInt } from "@/util/schema"
import { Schema, Struct } from "effect"
import { HttpApi, HttpApiEndpoint, HttpApiError, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
import { Authorization } from "../middleware/authorization"
@@ -10,17 +9,12 @@ import { described } from "./metadata"
const root = "/experimental/workspace"
export const CreatePayload = Schema.Struct(Struct.omit(Workspace.CreateInput.fields, ["projectID"]))
export const SessionRestorePayload = Schema.Struct(Struct.omit(Workspace.SessionRestoreInput.fields, ["workspaceID"]))
export const SessionRestoreResponse = Schema.Struct({
total: NonNegativeInt,
})
export const WorkspacePaths = {
adaptors: `${root}/adaptor`,
list: root,
status: `${root}/status`,
remove: `${root}/:id`,
sessionRestore: `${root}/:id/session-restore`,
} as const
export const WorkspaceApi = HttpApi.make("workspace")
@@ -76,18 +70,6 @@ export const WorkspaceApi = HttpApi.make("workspace")
description: "Remove an existing workspace.",
}),
),
HttpApiEndpoint.post("sessionRestore", WorkspacePaths.sessionRestore, {
params: { id: Workspace.Info.fields.id },
payload: SessionRestorePayload,
success: described(SessionRestoreResponse, "Session replay started"),
error: HttpApiError.BadRequest,
}).annotateMerge(
OpenApi.annotations({
identifier: "experimental.workspace.sessionRestore",
summary: "Restore session into workspace",
description: "Replay a session's sync events into the target workspace in batches.",
}),
),
)
.annotateMerge(OpenApi.annotations({ title: "workspace", description: "Experimental HttpApi workspace routes." }))
.middleware(InstanceContextMiddleware)

View File

@@ -5,7 +5,7 @@ import { Instance } from "@/project/instance"
import { Effect } from "effect"
import { HttpApiBuilder } from "effect/unstable/httpapi"
import { InstanceHttpApi } from "../api"
import { CreatePayload, SessionRestorePayload } from "../groups/workspace"
import { CreatePayload } from "../groups/workspace"
export const workspaceHandlers = HttpApiBuilder.group(InstanceHttpApi, "workspace", (handlers) =>
Effect.gen(function* () {
@@ -40,27 +40,11 @@ export const workspaceHandlers = HttpApiBuilder.group(InstanceHttpApi, "workspac
return yield* Effect.promise(() => Instance.restore(instance, () => Workspace.remove(ctx.params.id)))
})
const sessionRestore = Effect.fn("WorkspaceHttpApi.sessionRestore")(function* (ctx: {
params: { id: Workspace.Info["id"] }
payload: typeof SessionRestorePayload.Type
}) {
const instance = yield* InstanceState.context
return yield* Effect.promise(() =>
Instance.restore(instance, () =>
Workspace.sessionRestore({
workspaceID: ctx.params.id,
sessionID: ctx.payload.sessionID,
}),
),
)
})
return handlers
.handle("adaptors", adaptors)
.handle("list", list)
.handle("create", create)
.handle("status", status)
.handle("remove", remove)
.handle("sessionRestore", sessionRestore)
}),
)

View File

@@ -9,12 +9,15 @@ import { not } from "drizzle-orm"
import { or } from "drizzle-orm"
import { lte } from "drizzle-orm"
import { eq } from "drizzle-orm"
import { EventTable } from "@/sync/event.sql"
import { EventSequenceTable, EventTable } from "@/sync/event.sql"
import { lazy } from "@/util/lazy"
import * as Log from "@opencode-ai/core/util/log"
import { startWorkspaceSyncing } from "@/control-plane/workspace"
import { Instance } from "@/project/instance"
import { errors } from "../../error"
import { Session } from "@/session/session"
import { WorkspaceContext } from "@/control-plane/workspace-context"
import { SessionID } from "@/session/schema"
const ReplayEvent = z.object({
id: z.string(),
@@ -23,6 +26,9 @@ const ReplayEvent = z.object({
type: z.string(),
data: z.record(z.string(), z.unknown()),
})
const SessionPayload = z.object({
sessionID: SessionID.zod,
})
const log = Log.create({ service: "server.sync" })
@@ -105,6 +111,82 @@ export const SyncRoutes = lazy(() =>
})
},
)
.post(
"/erase",
describeRoute({
summary: "Erase session sync events",
description: "Erase all locally stored sync events for a session aggregate.",
operationId: "sync.erase",
responses: {
200: {
description: "Erased session sync events",
content: {
"application/json": {
schema: resolver(SessionPayload),
},
},
},
...errors(400),
},
}),
validator("json", SessionPayload),
async (c) => {
const body = c.req.valid("json")
Database.transaction((tx) => {
tx.delete(EventTable).where(eq(EventTable.aggregate_id, body.sessionID)).run()
tx.delete(EventSequenceTable).where(eq(EventSequenceTable.aggregate_id, body.sessionID)).run()
})
log.info("sync events erased", {
sessionID: body.sessionID,
})
return c.json({
sessionID: body.sessionID,
})
},
)
.post(
"/steal",
describeRoute({
summary: "Steal session into workspace",
description: "Update a session to belong to the current workspace through the sync event system.",
operationId: "sync.steal",
responses: {
200: {
description: "Session stolen into workspace",
content: {
"application/json": {
schema: resolver(SessionPayload),
},
},
},
...errors(400),
},
}),
validator("json", SessionPayload),
async (c) => {
const body = c.req.valid("json")
const workspaceID = WorkspaceContext.workspaceID
if (!workspaceID) throw new Error("Cannot steal session without workspace context")
SyncEvent.run(Session.Event.Updated, {
sessionID: body.sessionID,
info: {
workspaceID,
},
})
log.info("sync session stolen", {
sessionID: body.sessionID,
workspaceID,
})
return c.json({
sessionID: body.sessionID,
})
},
)
.post(
"/history",
describeRoute({

View File

@@ -1,4 +1,4 @@
import { afterEach, beforeEach, describe, expect, mock, spyOn, test } from "bun:test"
import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test"
import fs from "node:fs/promises"
import Http from "node:http"
import path from "node:path"
@@ -6,7 +6,7 @@ import { setTimeout as delay } from "node:timers/promises"
import { NodeHttpServer } from "@effect/platform-node"
import { Effect, Layer } from "effect"
import { HttpServer, HttpServerRequest, HttpServerResponse } from "effect/unstable/http"
import { asc, eq } from "drizzle-orm"
import { eq } from "drizzle-orm"
import * as Log from "@opencode-ai/core/util/log"
import { Flag } from "@opencode-ai/core/flag/flag"
import { GlobalBus, type GlobalEvent } from "@/bus/global"
@@ -15,11 +15,10 @@ import { ProjectID } from "@/project/schema"
import { ProjectTable } from "@/project/project.sql"
import { Instance } from "@/project/instance"
import { Session as SessionNs } from "@/session/session"
import { SessionID, MessageID, PartID } from "@/session/schema"
import { SessionID } from "@/session/schema"
import { SessionTable } from "@/session/session.sql"
import { ModelID, ProviderID } from "@/provider/schema"
import { SyncEvent } from "@/sync"
import { EventSequenceTable, EventTable } from "@/sync/event.sql"
import { EventSequenceTable } from "@/sync/event.sql"
import { resetDatabase } from "../fixture/db"
import { provideTmpdirInstance, tmpdir } from "../fixture/fixture"
import { testEffect } from "../lib/effect"
@@ -298,48 +297,14 @@ function sessionSequence(sessionID: SessionID) {
)?.seq
}
function eventRows(sessionID: SessionID) {
return Database.use((db) =>
db
.select({ seq: EventTable.seq, type: EventTable.type, data: EventTable.data })
.from(EventTable)
.where(eq(EventTable.aggregate_id, sessionID))
.orderBy(asc(EventTable.seq))
.all(),
)
}
function sessionUpdatedType() {
return SyncEvent.versionedType(SessionNs.Event.Updated.type, SessionNs.Event.Updated.version)
}
function replaceSessionEvents(sessionID: SessionID, count: number) {
Database.use((db) => {
db.delete(EventSequenceTable).where(eq(EventSequenceTable.aggregate_id, sessionID)).run()
if (count === 0) return
db.insert(EventSequenceTable)
.values({ aggregate_id: sessionID, seq: count - 1 })
.run()
db.insert(EventTable)
.values(
Array.from({ length: count }, (_, i) => ({
id: `evt_${unique(`manual-${i}`)}`,
aggregate_id: sessionID,
seq: i,
type: sessionUpdatedType(),
data: { sessionID, info: { title: `manual ${i}` } },
})),
)
.run()
})
}
describe("workspace-old schemas and exports", () => {
test("keeps the historical event type names", () => {
expect(WorkspaceOld.Event.Ready.type).toBe("workspace.ready")
expect(WorkspaceOld.Event.Failed.type).toBe("workspace.failed")
expect(WorkspaceOld.Event.Restore.type).toBe("workspace.restore")
expect(WorkspaceOld.Event.Status.type).toBe("workspace.status")
})
@@ -357,16 +322,6 @@ describe("workspace-old schemas and exports", () => {
expect(() => WorkspaceOld.CreateInput.zod.parse({ ...input, branch: 1 })).toThrow()
})
test("validates session restore input", () => {
const input = {
workspaceID: WorkspaceID.ascending("wrk_schema_restore"),
sessionID: SessionID.descending("ses_schema_restore"),
}
expect(WorkspaceOld.SessionRestoreInput.zod.parse(input)).toEqual(input)
expect(() => WorkspaceOld.SessionRestoreInput.zod.parse({ ...input, workspaceID: "bad" })).toThrow()
expect(() => WorkspaceOld.SessionRestoreInput.zod.parse({ ...input, sessionID: "bad" })).toThrow()
})
})
describe("workspace-old CRUD", () => {
@@ -1190,320 +1145,3 @@ describe("workspace-old waitForSync", () => {
})
}, 7000)
})
describe("workspace-old sessionRestore", () => {
test("throws when the workspace is missing", async () => {
await withInstance(async () => {
await expect(
WorkspaceOld.sessionRestore({
workspaceID: WorkspaceID.ascending("wrk_restore_missing"),
sessionID: SessionID.descending("ses_restore_missing_workspace"),
}),
).rejects.toThrow("Workspace not found: wrk_restore_missing")
})
})
test("throws when switching a missing session fails", async () => {
await withInstance(async (dir) => {
const type = unique("restore-missing-session")
const info = workspaceInfo(Instance.project.id, type, { directory: dir })
insertWorkspace(info)
registerAdaptor(Instance.project.id, type, localAdaptor(dir).adaptor)
await expect(
WorkspaceOld.sessionRestore({ workspaceID: info.id, sessionID: SessionID.descending("ses_missing_restore") }),
).rejects.toThrow("NotFoundError")
await WorkspaceOld.remove(info.id)
})
})
it.live("posts remote replay batches of 10, emits progress, and includes the workspace update event", () => {
const replay: FetchCall[] = []
return Effect.gen(function* () {
yield* HttpServer.serveEffect()(
Effect.gen(function* () {
const req = yield* HttpServerRequest.HttpServerRequest
const bodyText = yield* req.text
const call = {
url: new URL(req.url, "http://localhost"),
method: req.method,
headers: new Headers(req.headers),
bodyText,
json: bodyText ? JSON.parse(bodyText) : undefined,
}
if (call.url.pathname === "/restore/sync/replay") {
replay.push(call)
return HttpServerResponse.fromWeb(Response.json({ ok: true }))
}
return HttpServerResponse.text("unexpected", { status: 500 })
}),
)
const url = yield* serverUrl()
yield* provideTmpdirInstance(
(dir) =>
Effect.gen(function* () {
const workspace = yield* WorkspaceOld.Service
const sessionSvc = yield* SessionNs.Service
const captured = captureGlobalEvents()
try {
const type = unique("restore-remote")
const info = workspaceInfo(Instance.project.id, type, { directory: dir })
insertWorkspace(info)
registerAdaptor(
Instance.project.id,
type,
remoteAdaptor(`${url}/restore/?ignored=1#hash`, {
directory: dir,
headers: { authorization: "Bearer restore" },
}).adaptor,
)
const session = yield* sessionSvc.create({ title: "restore remote" })
replaceSessionEvents(session.id, 24)
const result = yield* workspace.sessionRestore({ workspaceID: info.id, sessionID: session.id })
expect(result).toEqual({ total: 3 })
expect(replay).toHaveLength(3)
expect(replay.map((call) => call.url.pathname + call.url.search + call.url.hash)).toEqual([
"/restore/sync/replay",
"/restore/sync/replay",
"/restore/sync/replay",
])
expect(replay.every((call) => call.headers.get("authorization") === "Bearer restore")).toBe(true)
expect(replay.every((call) => call.headers.get("content-type") === "application/json")).toBe(true)
expect(replay.map((call) => (call.json as { events: unknown[] }).events.length)).toEqual([10, 10, 5])
expect(replay.map((call) => (call.json as { directory: string }).directory)).toEqual([dir, dir, dir])
expect(
replay.flatMap((call) =>
(call.json as { events: Array<{ seq: number }> }).events.map((event) => event.seq),
),
).toEqual(Array.from({ length: 25 }, (_, i) => i))
expect(
(replay[2].json as { events: Array<{ seq: number; type: string; data: unknown }> }).events.at(-1),
).toMatchObject({
seq: 24,
type: sessionUpdatedType(),
data: { sessionID: session.id, info: { workspaceID: info.id } },
})
expect((yield* sessionSvc.get(session.id)).workspaceID).toBe(info.id)
expect(
captured.events
.filter(
(event) => event.workspace === info.id && event.payload.type === WorkspaceOld.Event.Restore.type,
)
.map((event) => event.payload.properties.step),
).toEqual([0, 1, 2, 3])
yield* workspace.remove(info.id)
} finally {
captured.dispose()
}
}),
{ git: true },
)
})
})
it.live("remote restore sends an empty directory string when the workspace directory is null", () => {
const replay: FetchCall[] = []
return Effect.gen(function* () {
yield* HttpServer.serveEffect()(
Effect.gen(function* () {
const req = yield* HttpServerRequest.HttpServerRequest
const bodyText = yield* req.text
replay.push({
url: new URL(req.url, "http://localhost"),
method: req.method,
headers: new Headers(req.headers),
bodyText,
json: bodyText ? JSON.parse(bodyText) : undefined,
})
return HttpServerResponse.fromWeb(Response.json({ ok: true }))
}),
)
const url = yield* serverUrl()
yield* provideTmpdirInstance(
() =>
Effect.gen(function* () {
const workspace = yield* WorkspaceOld.Service
const sessionSvc = yield* SessionNs.Service
const type = unique("restore-null-dir")
const info = workspaceInfo(Instance.project.id, type, { directory: null })
insertWorkspace(info)
registerAdaptor(Instance.project.id, type, remoteAdaptor(`${url}/null-dir`, { directory: null }).adaptor)
const session = yield* sessionSvc.create({ title: "null dir" })
replaceSessionEvents(session.id, 0)
expect(yield* workspace.sessionRestore({ workspaceID: info.id, sessionID: session.id })).toEqual({
total: 1,
})
expect((replay[0].json as { directory: string }).directory).toBe("")
expect((replay[0].json as { events: unknown[] }).events).toHaveLength(1)
yield* workspace.remove(info.id)
}),
{ git: true },
)
})
})
it.live("remote restore failures include status and body and do not emit completed batch progress", () => {
const replay: FetchCall[] = []
return Effect.gen(function* () {
yield* HttpServer.serveEffect()(
Effect.gen(function* () {
const req = yield* HttpServerRequest.HttpServerRequest
const bodyText = yield* req.text
replay.push({
url: new URL(req.url, "http://localhost"),
method: req.method,
headers: new Headers(req.headers),
bodyText,
json: bodyText ? JSON.parse(bodyText) : undefined,
})
return HttpServerResponse.text("replay failed", { status: 503 })
}),
)
const url = yield* serverUrl()
yield* provideTmpdirInstance(
(dir) =>
Effect.gen(function* () {
const workspace = yield* WorkspaceOld.Service
const sessionSvc = yield* SessionNs.Service
const captured = captureGlobalEvents()
try {
const type = unique("restore-remote-fail")
const info = workspaceInfo(Instance.project.id, type, { directory: dir })
insertWorkspace(info)
registerAdaptor(Instance.project.id, type, remoteAdaptor(`${url}/fail`, { directory: dir }).adaptor)
const session = yield* sessionSvc.create({ title: "restore fail" })
replaceSessionEvents(session.id, 11)
const error = yield* Effect.flip(
workspace.sessionRestore({ workspaceID: info.id, sessionID: session.id }),
)
expect((error as Error).message).toContain(
`Failed to replay session ${session.id} into workspace ${info.id}: HTTP 503 replay failed`,
)
expect(replay).toHaveLength(1)
expect(
captured.events
.filter(
(event) => event.workspace === info.id && event.payload.type === WorkspaceOld.Event.Restore.type,
)
.map((event) => event.payload.properties.step),
).toEqual([0])
yield* workspace.remove(info.id)
} finally {
captured.dispose()
}
}),
{ git: true },
)
})
})
test("local restore replays batches without fetch and emits progress", async () => {
await withInstance(async (dir) => {
const captured = captureGlobalEvents()
let fetchCallCount = 0
const replayAll = spyOn(SyncEvent, "replayAll")
try {
using server = Bun.serve({
port: 0,
fetch() {
fetchCallCount++
return Response.json({ ok: true })
},
})
const type = unique("restore-local")
const info = workspaceInfo(Instance.project.id, type, { directory: dir })
insertWorkspace(info)
registerAdaptor(Instance.project.id, type, localAdaptor(dir).adaptor)
const session = await AppRuntime.runPromise(
SessionNs.Service.use((svc) => svc.create({ title: "restore local" })),
)
replaceSessionEvents(session.id, 20)
expect(await WorkspaceOld.sessionRestore({ workspaceID: info.id, sessionID: session.id })).toEqual({ total: 3 })
expect(fetchCallCount).toBe(0)
expect(replayAll).toHaveBeenCalledTimes(3)
expect(replayAll.mock.calls.map((call) => call[0].length)).toEqual([10, 10, 1])
expect((await AppRuntime.runPromise(SessionNs.Service.use((svc) => svc.get(session.id)))).workspaceID).toBe(
info.id,
)
expect(eventRows(session.id).map((row) => row.seq)).toEqual(Array.from({ length: 21 }, (_, i) => i))
expect(
captured.events
.filter((event) => event.workspace === info.id && event.payload.type === WorkspaceOld.Event.Restore.type)
.map((event) => event.payload.properties.step),
).toEqual([0, 1, 2, 3])
await WorkspaceOld.remove(info.id)
} finally {
captured.dispose()
}
})
})
it.live("session restore includes real message and part events in sequence order", () => {
const replay: FetchCall[] = []
return Effect.gen(function* () {
yield* HttpServer.serveEffect()(
Effect.gen(function* () {
const req = yield* HttpServerRequest.HttpServerRequest
const bodyText = yield* req.text
replay.push({
url: new URL(req.url, "http://localhost"),
method: req.method,
headers: new Headers(req.headers),
bodyText,
json: bodyText ? JSON.parse(bodyText) : undefined,
})
return HttpServerResponse.fromWeb(Response.json({ ok: true }))
}),
)
const url = yield* serverUrl()
yield* provideTmpdirInstance(
(dir) =>
Effect.gen(function* () {
const workspace = yield* WorkspaceOld.Service
const sessionSvc = yield* SessionNs.Service
const type = unique("restore-real-events")
const info = workspaceInfo(Instance.project.id, type, { directory: dir })
insertWorkspace(info)
registerAdaptor(Instance.project.id, type, remoteAdaptor(`${url}/real`, { directory: dir }).adaptor)
const session = yield* sessionSvc.create({ title: "real events" })
for (let i = 0; i < 3; i++) {
const msg = yield* sessionSvc.updateMessage({
id: MessageID.ascending(),
role: "user",
sessionID: session.id,
agent: "build",
model: { providerID: ProviderID.make("test"), modelID: ModelID.make("test") },
time: { created: Date.now() },
})
yield* sessionSvc.updatePart({
id: PartID.ascending(),
sessionID: session.id,
messageID: msg.id,
type: "text",
text: `message ${i}`,
})
}
const before = eventRows(session.id)
expect(yield* workspace.sessionRestore({ workspaceID: info.id, sessionID: session.id })).toEqual({
total: 1,
})
const posted = (replay[0].json as { events: Array<{ seq: number; type: string }> }).events
expect(posted.map((event) => event.seq)).toEqual([...before.map((row) => row.seq), before.at(-1)!.seq + 1])
expect(posted.map((event) => event.type).slice(0, -1)).toEqual(before.map((row) => row.type))
expect(posted.at(-1)?.type).toBe(sessionUpdatedType())
yield* workspace.remove(info.id)
}),
{ git: true },
)
})
})
})

View File

@@ -179,18 +179,6 @@ describe("workspace HttpApi", () => {
const workspace = (await created.json()) as Workspace.Info
expect(workspace).toMatchObject({ type: "local-test", name: "local-test" })
const session = await Instance.provide({
directory: tmp.path,
fn: async () => runSession(Session.Service.use((svc) => svc.create({}))),
})
const restored = await request(WorkspacePaths.sessionRestore.replace(":id", workspace.id), tmp.path, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ sessionID: session.id }),
})
expect(restored.status).toBe(200)
expect((await restored.json()) as { total: number }).toMatchObject({ total: expect.any(Number) })
const removed = await request(WorkspacePaths.remove.replace(":id", workspace.id), tmp.path, { method: "DELETE" })
expect(removed.status).toBe(200)
expect(await removed.json()).toMatchObject({ id: workspace.id })

View File

@@ -35,9 +35,9 @@ import type {
ExperimentalWorkspaceListResponses,
ExperimentalWorkspaceRemoveErrors,
ExperimentalWorkspaceRemoveResponses,
ExperimentalWorkspaceSessionRestoreErrors,
ExperimentalWorkspaceSessionRestoreResponses,
ExperimentalWorkspaceStatusResponses,
ExperimentalWorkspaceWarpErrors,
ExperimentalWorkspaceWarpResponses,
FileListResponses,
FilePartInput,
FilePartSource,
@@ -160,11 +160,15 @@ import type {
SessionUpdateErrors,
SessionUpdateResponses,
SubtaskPartInput,
SyncEraseErrors,
SyncEraseResponses,
SyncHistoryListErrors,
SyncHistoryListResponses,
SyncReplayErrors,
SyncReplayResponses,
SyncStartResponses,
SyncStealErrors,
SyncStealResponses,
TextPartInput,
ToolIdsErrors,
ToolIdsResponses,
@@ -689,11 +693,11 @@ export class Workspace extends HeyApiClient {
}
/**
* Restore session into workspace
* Warp session into workspace
*
* Replay a session's sync events into the target workspace in batches.
* Move a session's sync history into the target workspace.
*/
public sessionRestore<ThrowOnError extends boolean = false>(
public warp<ThrowOnError extends boolean = false>(
parameters: {
id: string
directory?: string
@@ -716,11 +720,11 @@ export class Workspace extends HeyApiClient {
],
)
return (options?.client ?? this.client).post<
ExperimentalWorkspaceSessionRestoreResponses,
ExperimentalWorkspaceSessionRestoreErrors,
ExperimentalWorkspaceWarpResponses,
ExperimentalWorkspaceWarpErrors,
ThrowOnError
>({
url: "/experimental/workspace/{id}/session-restore",
url: "/experimental/workspace/{id}/warp",
...options,
...params,
headers: {
@@ -3173,6 +3177,80 @@ export class Sync extends HeyApiClient {
})
}
/**
* Erase session sync events
*
* Erase all locally stored sync events for a session aggregate.
*/
public erase<ThrowOnError extends boolean = false>(
parameters?: {
directory?: string
workspace?: string
sessionID?: string
},
options?: Options<never, ThrowOnError>,
) {
const params = buildClientParams(
[parameters],
[
{
args: [
{ in: "query", key: "directory" },
{ in: "query", key: "workspace" },
{ in: "body", key: "sessionID" },
],
},
],
)
return (options?.client ?? this.client).post<SyncEraseResponses, SyncEraseErrors, ThrowOnError>({
url: "/sync/erase",
...options,
...params,
headers: {
"Content-Type": "application/json",
...options?.headers,
...params.headers,
},
})
}
/**
* Steal session into workspace
*
* Update a session to belong to the current workspace through the sync event system.
*/
public steal<ThrowOnError extends boolean = false>(
parameters?: {
directory?: string
workspace?: string
sessionID?: string
},
options?: Options<never, ThrowOnError>,
) {
const params = buildClientParams(
[parameters],
[
{
args: [
{ in: "query", key: "directory" },
{ in: "query", key: "workspace" },
{ in: "body", key: "sessionID" },
],
},
],
)
return (options?.client ?? this.client).post<SyncStealResponses, SyncStealErrors, ThrowOnError>({
url: "/sync/steal",
...options,
...params,
headers: {
"Content-Type": "application/json",
...options?.headers,
...params.headers,
},
})
}
private _history?: History
get history(): History {
return (this._history ??= new History({ client: this.client }))

View File

@@ -466,16 +466,6 @@ export type EventWorkspaceFailed = {
}
}
export type EventWorkspaceRestore = {
type: "workspace.restore"
properties: {
workspaceID: string
sessionID: string
total: number
step: number
}
}
export type EventWorkspaceStatus = {
type: "workspace.status"
properties: {
@@ -1143,7 +1133,6 @@ export type GlobalEvent = {
| EventVcsBranchUpdated
| EventWorkspaceReady
| EventWorkspaceFailed
| EventWorkspaceRestore
| EventWorkspaceStatus
| EventWorktreeReady
| EventWorktreeFailed
@@ -2086,7 +2075,6 @@ export type Event =
| EventVcsBranchUpdated
| EventWorkspaceReady
| EventWorkspaceFailed
| EventWorkspaceRestore
| EventWorkspaceStatus
| EventWorktreeReady
| EventWorktreeFailed
@@ -2564,7 +2552,7 @@ export type ExperimentalWorkspaceRemoveResponses = {
export type ExperimentalWorkspaceRemoveResponse =
ExperimentalWorkspaceRemoveResponses[keyof ExperimentalWorkspaceRemoveResponses]
export type ExperimentalWorkspaceSessionRestoreData = {
export type ExperimentalWorkspaceWarpData = {
body?: {
sessionID: string
}
@@ -2575,30 +2563,27 @@ export type ExperimentalWorkspaceSessionRestoreData = {
directory?: string
workspace?: string
}
url: "/experimental/workspace/{id}/session-restore"
url: "/experimental/workspace/{id}/warp"
}
export type ExperimentalWorkspaceSessionRestoreErrors = {
export type ExperimentalWorkspaceWarpErrors = {
/**
* Bad request
*/
400: BadRequestError
}
export type ExperimentalWorkspaceSessionRestoreError =
ExperimentalWorkspaceSessionRestoreErrors[keyof ExperimentalWorkspaceSessionRestoreErrors]
export type ExperimentalWorkspaceWarpError = ExperimentalWorkspaceWarpErrors[keyof ExperimentalWorkspaceWarpErrors]
export type ExperimentalWorkspaceSessionRestoreResponses = {
export type ExperimentalWorkspaceWarpResponses = {
/**
* Session replay started
* Session warped
*/
200: {
total: number
}
204: void
}
export type ExperimentalWorkspaceSessionRestoreResponse =
ExperimentalWorkspaceSessionRestoreResponses[keyof ExperimentalWorkspaceSessionRestoreResponses]
export type ExperimentalWorkspaceWarpResponse =
ExperimentalWorkspaceWarpResponses[keyof ExperimentalWorkspaceWarpResponses]
export type ProjectListData = {
body?: never
@@ -4626,6 +4611,70 @@ export type SyncReplayResponses = {
export type SyncReplayResponse = SyncReplayResponses[keyof SyncReplayResponses]
export type SyncEraseData = {
body?: {
sessionID: string
}
path?: never
query?: {
directory?: string
workspace?: string
}
url: "/sync/erase"
}
export type SyncEraseErrors = {
/**
* Bad request
*/
400: BadRequestError
}
export type SyncEraseError = SyncEraseErrors[keyof SyncEraseErrors]
export type SyncEraseResponses = {
/**
* Erased session sync events
*/
200: {
sessionID: string
}
}
export type SyncEraseResponse = SyncEraseResponses[keyof SyncEraseResponses]
export type SyncStealData = {
body?: {
sessionID: string
}
path?: never
query?: {
directory?: string
workspace?: string
}
url: "/sync/steal"
}
export type SyncStealErrors = {
/**
* Bad request
*/
400: BadRequestError
}
export type SyncStealError = SyncStealErrors[keyof SyncStealErrors]
export type SyncStealResponses = {
/**
* Session stolen into workspace
*/
200: {
sessionID: string
}
}
export type SyncStealResponse = SyncStealResponses[keyof SyncStealResponses]
export type SyncHistoryListData = {
body?: {
[key: string]: number