feat(core): session warping (#25768)

This commit is contained in:
James Long 2026-05-04 21:28:38 -04:00 committed by GitHub
commit 22a4a9df8b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
25 changed files with 4029 additions and 1092 deletions

View file

@ -0,0 +1 @@
ALTER TABLE `event_sequence` ADD `owner_id` text;

File diff suppressed because it is too large Load diff

View file

@ -776,9 +776,9 @@ const scenarios: Scenario[] = [
})) }))
.status(200), .status(200),
http http
.post("/experimental/workspace/{id}/session-restore", "experimental.workspace.sessionRestore") .post("/experimental/workspace/warp", "experimental.workspace.warp")
.at((ctx) => ({ .at((ctx) => ({
path: route("/experimental/workspace/{id}/session-restore", { id: "wrk_httpapi_missing" }), path: "/experimental/workspace/warp",
headers: ctx.headers(), headers: ctx.headers(),
body: {}, body: {},
})) }))

View file

@ -2,7 +2,7 @@ import { useDialog } from "@tui/ui/dialog"
import { DialogSelect } from "@tui/ui/dialog-select" import { DialogSelect } from "@tui/ui/dialog-select"
import { useRoute } from "@tui/context/route" import { useRoute } from "@tui/context/route"
import { useSync } from "@tui/context/sync" 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 { Locale } from "@/util/locale"
import { useProject } from "@tui/context/project" import { useProject } from "@tui/context/project"
import { useKeybind } from "../context/keybind" import { useKeybind } from "../context/keybind"
@ -10,15 +10,13 @@ import { useTheme } from "../context/theme"
import { useSDK } from "../context/sdk" import { useSDK } from "../context/sdk"
import { Flag } from "@opencode-ai/core/flag/flag" import { Flag } from "@opencode-ai/core/flag/flag"
import { DialogSessionRename } from "./dialog-session-rename" import { DialogSessionRename } from "./dialog-session-rename"
import { Keybind } from "@/util/keybind"
import { createDebouncedSignal } from "../util/signal" import { createDebouncedSignal } from "../util/signal"
import { useToast } from "../ui/toast" import { useToast } from "../ui/toast"
import { DialogWorkspaceCreate, openWorkspaceSession, restoreWorkspaceSession } from "./dialog-workspace-create" import { openWorkspaceSelect, type WorkspaceSelection, warpWorkspaceSession } from "./dialog-workspace-create"
import { Spinner } from "./spinner" import { Spinner } from "./spinner"
import { errorMessage } from "@/util/error" import { errorMessage } from "@/util/error"
import { DialogSessionDeleteFailed } from "./dialog-session-delete-failed" import { DialogSessionDeleteFailed } from "./dialog-session-delete-failed"
import { WorkspaceLabel } from "./workspace-label"
type WorkspaceStatus = "connected" | "connecting" | "disconnected" | "error"
export function DialogSessionList() { export function DialogSessionList() {
const dialog = useDialog() const dialog = useDialog()
@ -44,26 +42,39 @@ export function DialogSessionList() {
const currentSessionID = createMemo(() => (route.data.type === "session" ? route.data.sessionID : undefined)) const currentSessionID = createMemo(() => (route.data.type === "session" ? route.data.sessionID : undefined))
const sessions = createMemo(() => searchResults() ?? sync.data.session) 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]>) { function recover(session: NonNullable<ReturnType<typeof sessions>[number]>) {
const workspace = project.workspace.get(session.workspaceID!) const workspace = project.workspace.get(session.workspaceID!)
const list = () => dialog.replace(() => <DialogSessionList />) const list = () => dialog.replace(() => <DialogSessionList />)
const warp = async (selection: WorkspaceSelection) => {
const workspaceID = await (async () => {
if (selection.type === "none") return null
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 === undefined) return
await warpWorkspaceSession({
dialog,
sdk,
sync,
project,
toast,
workspaceID,
sessionID: session.id,
done: list,
})
}
dialog.replace(() => ( dialog.replace(() => (
<DialogSessionDeleteFailed <DialogSessionDeleteFailed
session={session.title} session={session.title}
@ -90,22 +101,15 @@ export function DialogSessionList() {
return true return true
}} }}
onRestore={() => { onRestore={() => {
dialog.replace(() => ( void openWorkspaceSelect({
<DialogWorkspaceCreate dialog,
onSelect={(workspaceID) => sdk,
restoreWorkspaceSession({ sync,
dialog, toast,
sdk, onSelect: (selection) => {
sync, void warp(selection)
project, },
toast, })
workspaceID,
sessionID: session.id,
done: list,
})
}
/>
))
return false return false
}} }}
/> />
@ -124,30 +128,17 @@ export function DialogSessionList() {
.map((x) => { .map((x) => {
const workspace = x.workspaceID ? project.workspace.get(x.workspaceID) : undefined const workspace = x.workspaceID ? project.workspace.get(x.workspaceID) : undefined
let workspaceStatus: WorkspaceStatus | null = null let footer: JSX.Element | string = ""
if (x.workspaceID) {
workspaceStatus = project.workspace.status(x.workspaceID) || "error"
}
let footer = ""
if (Flag.OPENCODE_EXPERIMENTAL_WORKSPACES) { if (Flag.OPENCODE_EXPERIMENTAL_WORKSPACES) {
if (x.workspaceID) { if (x.workspaceID) {
let desc = "unknown" footer = workspace ? (
if (workspace) { <WorkspaceLabel
desc = `${workspace.type}: ${workspace.name}` type={workspace.type}
} name={workspace.name}
status={project.workspace.status(x.workspaceID) ?? "error"}
footer = ( />
<> ) : (
{desc}{" "} <WorkspaceLabel type="unknown" name={x.workspaceID} status="error" />
<span
style={{
fg: workspaceStatus === "connected" ? theme.success : theme.error,
}}
>
</span>
</>
) )
} }
} else { } else {
@ -250,15 +241,6 @@ export function DialogSessionList() {
dialog.replace(() => <DialogSessionRename session={option.value} />) 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 { useDialog } from "@tui/ui/dialog"
import { DialogSelect } from "@tui/ui/dialog-select" import { DialogSelect, type DialogSelectOption } from "@tui/ui/dialog-select"
import { useRoute } from "@tui/context/route"
import { useSync } from "@tui/context/sync" import { useSync } from "@tui/context/sync"
import { useProject } from "@tui/context/project" import { useProject } from "@tui/context/project"
import { createMemo, createSignal, onMount } from "solid-js" import { createMemo, createSignal, onMount } from "solid-js"
import { setTimeout as sleep } from "node:timers/promises"
import { errorMessage } from "@/util/error" import { errorMessage } from "@/util/error"
import { useSDK } from "../context/sdk" import { useSDK } from "../context/sdk"
import { useToast } from "../ui/toast" import { useToast } from "../ui/toast"
@ -16,184 +14,212 @@ type Adapter = {
description: string description: string
} }
function scoped(sdk: ReturnType<typeof useSDK>, sync: ReturnType<typeof useSync>, workspaceID: string) { export type WorkspaceSelection =
return createOpencodeClient({ | {
baseUrl: sdk.url, type: "none"
fetch: sdk.fetch, }
directory: sync.path.directory || sdk.directory, | {
experimental_workspaceID: workspaceID, type: "new"
}) workspaceType: string
} workspaceName: string
}
| {
type: "existing"
workspaceID: string
workspaceType: string
workspaceName: string
}
export async function openWorkspaceSession(input: { type WorkspaceSelectValue = WorkspaceSelection | { type: "existing-list" }
dialog: ReturnType<typeof useDialog> type ExistingWorkspaceSelectValue = { workspace: Workspace }
route: ReturnType<typeof useRoute>
async function loadWorkspaceAdapters(input: {
sdk: ReturnType<typeof useSDK> sdk: ReturnType<typeof useSDK>
sync: ReturnType<typeof useSync> sync: ReturnType<typeof useSync>
toast: ReturnType<typeof useToast> toast: ReturnType<typeof useToast>
workspaceID: string
}) { }) {
const client = scoped(input.sdk, input.sync, input.workspaceID) const dir = input.sync.path.directory || input.sdk.directory
const url = new URL("/experimental/workspace/adapter", input.sdk.url)
while (true) { if (dir) url.searchParams.set("directory", dir)
const result = await client.session.create({ workspace: input.workspaceID }).catch(() => undefined) const res = await input.sdk
if (!result) { .fetch(url)
input.toast.show({ .then((x) => x.json() as Promise<Adapter[]>)
message: "Failed to create workspace session", .catch(() => undefined)
variant: "error", if (res) return res
}) input.toast.show({
return message: "Failed to load workspace adapters",
} variant: "error",
if (result.response?.status && result.response.status >= 500 && result.response.status < 600) { })
await sleep(1000)
continue
}
if (!result.data) {
input.toast.show({
message: "Failed to create workspace session",
variant: "error",
})
return
}
input.route.navigate({
type: "session",
sessionID: result.data.id,
})
input.dialog.clear()
return
}
} }
export async function restoreWorkspaceSession(input: { export async function openWorkspaceSelect(input: {
dialog: ReturnType<typeof useDialog>
sdk: ReturnType<typeof useSDK>
sync: ReturnType<typeof useSync>
toast: ReturnType<typeof useToast>
onSelect: (selection: WorkspaceSelection) => Promise<void> | void
}) {
input.dialog.clear()
const adapters = await loadWorkspaceAdapters(input)
if (!adapters) return
input.dialog.replace(() => <DialogWorkspaceSelect adapters={adapters} onSelect={input.onSelect} />)
}
export async function warpWorkspaceSession(input: {
dialog: ReturnType<typeof useDialog> dialog: ReturnType<typeof useDialog>
sdk: ReturnType<typeof useSDK> sdk: ReturnType<typeof useSDK>
sync: ReturnType<typeof useSync> sync: ReturnType<typeof useSync>
project: ReturnType<typeof useProject> project: ReturnType<typeof useProject>
toast: ReturnType<typeof useToast> toast: ReturnType<typeof useToast>
workspaceID: string workspaceID: string | null
sessionID: string sessionID: string
done?: () => void done?: () => void
}) { }): Promise<boolean> {
const result = await input.sdk.client.experimental.workspace const result = await input.sdk.client.experimental.workspace
.sessionRestore({ id: input.workspaceID, sessionID: input.sessionID }) .warp({
id: input.workspaceID,
sessionID: input.sessionID,
})
.catch(() => undefined) .catch(() => undefined)
if (!result?.data) { if (!result?.data) {
input.toast.show({ 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", variant: "error",
}) })
return return false
} }
input.project.workspace.set(input.workspaceID) input.project.workspace.set(input.workspaceID)
await input.sync.bootstrap({ fatal: false }).catch(() => undefined) 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",
})
input.done?.() input.done?.()
if (input.done) return if (input.done) return true
input.dialog.clear() input.dialog.clear()
return true
} }
export function DialogWorkspaceCreate(props: { onSelect: (workspaceID: string) => Promise<void> | void }) { export function DialogWorkspaceSelect(props: {
adapters?: Adapter[]
onSelect: (selection: WorkspaceSelection) => Promise<void> | void
}) {
const dialog = useDialog() const dialog = useDialog()
const sync = useSync()
const project = useProject() const project = useProject()
const sync = useSync()
const sdk = useSDK() const sdk = useSDK()
const toast = useToast() const toast = useToast()
const [creating, setCreating] = createSignal<string>() const [adapters, setAdapters] = createSignal<Adapter[] | undefined>(props.adapters)
const [adapters, setAdapters] = createSignal<Adapter[]>()
onMount(() => { onMount(() => {
dialog.setSize("medium") dialog.setSize("medium")
void (async () => { void (async () => {
const dir = sync.path.directory || sdk.directory if (adapters()) return
const url = new URL("/experimental/workspace/adapter", sdk.url) const res = await loadWorkspaceAdapters({ sdk, sync, toast })
if (dir) url.searchParams.set("directory", dir) if (!res) return
const res = await sdk
.fetch(url)
.then((x) => x.json() as Promise<Adapter[]>)
.catch(() => undefined)
if (!res) {
toast.show({
message: "Failed to load workspace adapters",
variant: "error",
})
return
}
setAdapters(res) setAdapters(res)
})() })()
}) })
const options = createMemo(() => { const options = createMemo<DialogSelectOption<WorkspaceSelectValue>[]>(() => {
const type = creating()
if (type) {
return [
{
title: `Creating ${type} workspace...`,
value: "creating" as const,
description: "This can take a while for remote environments",
},
]
}
const list = adapters() const list = adapters()
if (!list) { if (!list) return []
return [ const recent = sync.data.session
{ .toSorted((a, b) => b.time.updated - a.time.updated)
title: "Loading workspaces...", .flatMap((session) => (session.workspaceID ? [session.workspaceID] : []))
value: "loading" as const, .filter((workspaceID, index, list) => list.indexOf(workspaceID) === index)
description: "Fetching available workspace adapters", .slice(0, 3)
.flatMap((workspaceID) => {
const workspace = project.workspace.get(workspaceID)
return workspace ? [workspace] : []
})
return [
...list.map((adapter) => ({
title: adapter.name,
value: { type: "new" as const, workspaceType: adapter.type, workspaceName: adapter.name },
description: adapter.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",
} })),
return list.map((item) => ({ {
title: item.name, title: "View all workspaces",
value: item.type, value: { type: "existing-list" as const },
description: item.description, description: "Choose from all workspaces",
})) category: "Choose workspace",
},
]
}) })
const create = async (type: string) => { if (!adapters()) return null
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 ( return (
<DialogSelect <DialogSelect<WorkspaceSelectValue>
title={creating() ? "Creating Workspace" : "New Workspace"} title="Warp"
skipFilter={true} skipFilter={true}
renderFilter={false}
options={options()} options={options()}
onSelect={(option) => { onSelect={(option) => {
if (option.value === "creating" || option.value === "loading") return if (!option.value) return
void create(option.value) 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 { useLocal } from "@tui/context/local"
import { tint, useTheme } from "@tui/context/theme" import { tint, useTheme } from "@tui/context/theme"
import { EmptyBorder, SplitBorder } from "@tui/component/border" import { EmptyBorder, SplitBorder } from "@tui/component/border"
import { Spinner } from "@tui/component/spinner"
import { useSDK } from "@tui/context/sdk" import { useSDK } from "@tui/context/sdk"
import { useRoute } from "@tui/context/route" import { useRoute } from "@tui/context/route"
import { useProject } from "@tui/context/project" import { useProject } from "@tui/context/project"
@ -41,9 +42,11 @@ import { useKV } from "../../context/kv"
import { createFadeIn } from "../../util/signal" import { createFadeIn } from "../../util/signal"
import { useTextareaKeybindings } from "../textarea-keybindings" import { useTextareaKeybindings } from "../textarea-keybindings"
import { DialogSkill } from "../dialog-skill" import { DialogSkill } from "../dialog-skill"
import { DialogWorkspaceCreate, restoreWorkspaceSession } from "../dialog-workspace-create" import { openWorkspaceSelect, warpWorkspaceSession, type WorkspaceSelection } from "../dialog-workspace-create"
import { DialogWorkspaceUnavailable } from "../dialog-workspace-unavailable" import { DialogWorkspaceUnavailable } from "../dialog-workspace-unavailable"
import { useArgs } from "@tui/context/args" import { useArgs } from "@tui/context/args"
import { Flag } from "@opencode-ai/core/flag/flag"
import { WorkspaceLabel, type WorkspaceStatus } from "../workspace-label"
export type PromptProps = { export type PromptProps = {
sessionID?: string sessionID?: string
@ -173,9 +176,92 @@ export function Prompt(props: PromptProps) {
const [editorContextHover, setEditorContextHover] = createSignal(false) const [editorContextHover, setEditorContextHover] = createSignal(false)
let lastSubmittedEditorSelectionKey: string | undefined let lastSubmittedEditorSelectionKey: string | undefined
const [auto, setAuto] = createSignal<AutocompleteRef>() 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 currentProviderLabel = createMemo(() => local.model.parsed().provider)
const hasRightContent = createMemo(() => Boolean(props.right)) const hasRightContent = createMemo(() => Boolean(props.right))
function selectWorkspace(selection: WorkspaceSelection | undefined) {
setWorkspaceSelection(selection)
}
function setCreatingWorkspace(creating: boolean) {
setWorkspaceCreating(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
}
selectWorkspace(selection)
dialog.clear()
const workspace =
selection.type === "none"
? { id: null, name: "local project" }
: 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,
})
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() { function promptModelWarning() {
toast.show({ toast.show({
variant: "warning", variant: "warning",
@ -213,6 +299,7 @@ export function Prompt(props: PromptProps) {
}) })
createEffect(() => { createEffect(() => {
if (!input || input.isDestroyed) return
if (props.disabled) input.cursorColor = theme.backgroundElement if (props.disabled) input.cursorColor = theme.backgroundElement
if (!props.disabled) input.cursorColor = theme.text if (!props.disabled) input.cursorColor = theme.text
}) })
@ -489,6 +576,27 @@ 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) => {
void openWorkspaceSelect({
dialog,
sdk,
sync,
toast,
onSelect: (selection) => {
void warpSession(selection)
},
})
},
},
] ]
}) })
@ -699,6 +807,8 @@ export function Prompt(props: PromptProps) {
]) ])
async function submit() { async function submit() {
setWarpNotice(undefined)
// IME: double-defer may fire before onContentChange flushes the last // IME: double-defer may fire before onContentChange flushes the last
// composed character (e.g. Korean hangul) to the store, so read // composed character (e.g. Korean hangul) to the store, so read
// plainText directly and sync before any downstream reads. // plainText directly and sync before any downstream reads.
@ -707,6 +817,7 @@ export function Prompt(props: PromptProps) {
syncExtmarksWithPromptParts() syncExtmarksWithPromptParts()
} }
if (props.disabled) return false if (props.disabled) return false
if (workspaceCreating()) return false
if (autocomplete?.visible) return false if (autocomplete?.visible) return false
if (!store.prompt.input) return false if (!store.prompt.input) return false
const agent = local.agent.current() const agent = local.agent.current()
@ -729,21 +840,16 @@ export function Prompt(props: PromptProps) {
dialog.replace(() => ( dialog.replace(() => (
<DialogWorkspaceUnavailable <DialogWorkspaceUnavailable
onRestore={() => { onRestore={() => {
dialog.replace(() => ( void openWorkspaceSelect({
<DialogWorkspaceCreate dialog,
onSelect={(nextWorkspaceID) => sdk,
restoreWorkspaceSession({ sync,
dialog, toast,
sdk, onSelect: (selection) => {
sync, void warpSession(selection)
project, },
toast, })
workspaceID: nextWorkspaceID, return false
sessionID: props.sessionID!,
})
}
/>
))
}} }}
/> />
)) ))
@ -753,6 +859,14 @@ export function Prompt(props: PromptProps) {
const variant = local.model.variant.current() const variant = local.model.variant.current()
let sessionID = props.sessionID let sessionID = props.sessionID
if (sessionID == null) { if (sessionID == null) {
const workspace = workspaceSelection()
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({ const res = await sdk.client.session.create({
workspace: props.workspaceID, workspace: props.workspaceID,
agent: agent.name, agent: agent.name,
@ -1025,6 +1139,29 @@ export function Prompt(props: PromptProps) {
return `Ask anything... "${list()[store.placeholder % list().length]}"` 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 = workspaceSelection()
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 spinnerDef = createMemo(() => {
const agent = local.agent.current() const agent = local.agent.current()
const color = agent ? local.agent.color(agent.name) : theme.border const color = agent ? local.agent.color(agent.name) : theme.border
@ -1281,7 +1418,7 @@ export function Prompt(props: PromptProps) {
}} }}
onMouseDown={(r: MouseEvent) => r.target?.focus()} onMouseDown={(r: MouseEvent) => r.target?.focus()}
focusedBackgroundColor={theme.backgroundElement} focusedBackgroundColor={theme.backgroundElement}
cursorColor={theme.text} cursorColor={props.disabled ? theme.backgroundElement : theme.text}
syntaxStyle={syntax()} syntaxStyle={syntax()}
/> />
<box flexDirection="row" flexShrink={0} paddingTop={1} gap={1} justifyContent="space-between"> <box flexDirection="row" flexShrink={0} paddingTop={1} gap={1} justifyContent="space-between">
@ -1351,86 +1488,124 @@ export function Prompt(props: PromptProps) {
/> />
</box> </box>
<box width="100%" flexDirection="row" justifyContent="space-between"> <box width="100%" flexDirection="row" justifyContent="space-between">
<Show when={status().type !== "idle"} fallback={props.hint ?? <text />}> <Switch>
<box <Match when={status().type !== "idle"}>
flexDirection="row" <box
gap={1} flexDirection="row"
flexGrow={1} gap={1}
justifyContent={status().type === "retry" ? "space-between" : "flex-start"} flexGrow={1}
> justifyContent={status().type === "retry" ? "space-between" : "flex-start"}
<box flexShrink={0} flexDirection="row" gap={1}> >
<box marginLeft={1}> <box flexShrink={0} flexDirection="row" gap={1}>
<Show when={kv.get("animations_enabled", true)} fallback={<text fg={theme.textMuted}>[]</text>}> <box marginLeft={1}>
<spinner color={spinnerDef().color} frames={spinnerDef().frames} interval={40} /> <Show when={kv.get("animations_enabled", true)} fallback={<text fg={theme.textMuted}>[]</text>}>
</Show> <spinner color={spinnerDef().color} frames={spinnerDef().frames} interval={40} />
</box> </Show>
<box flexDirection="row" gap={1} flexShrink={0}> </box>
{(() => { <box flexDirection="row" gap={1} flexShrink={0}>
const retry = createMemo(() => { {(() => {
const s = status() const retry = createMemo(() => {
if (s.type !== "retry") return const s = status()
return s 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 message = createMemo(() => {
const handleMessageClick = () => { const r = retry()
const r = retry() if (!r) return
if (!r) return if (r.message.includes("exceeded your current quota") && r.message.includes("gemini"))
if (isTruncated()) { return "gemini is way too hot right now"
void DialogAlert.show(dialog, "Retry Error", r.message) 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 retryText = () => {
const r = retry() const r = retry()
if (!r) return "" if (!r) return ""
const baseMessage = message() const baseMessage = message()
const truncatedHint = isTruncated() ? " (click to expand)" : "" const truncatedHint = isTruncated() ? " (click to expand)" : ""
const duration = formatDuration(seconds()) const duration = formatDuration(seconds())
const retryInfo = ` [retrying ${duration ? `in ${duration} ` : ""}attempt #${r.attempt}]` const retryInfo = ` [retrying ${duration ? `in ${duration} ` : ""}attempt #${r.attempt}]`
return baseMessage + truncatedHint + retryInfo return baseMessage + truncatedHint + retryInfo
} }
return ( return (
<Show when={retry()}> <Show when={retry()}>
<box onMouseUp={handleMessageClick}> <box onMouseUp={handleMessageClick}>
<text fg={theme.error}>{retryText()}</text> <text fg={theme.error}>{retryText()}</text>
</box> </box>
</Show> </Show>
) )
})()} })()}
</box>
</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}> </Match>
esc{" "} <Match when={warpNotice()}>
<span style={{ fg: store.interrupt > 0 ? theme.primary : theme.textMuted }}> {(notice) => (
{store.interrupt > 0 ? "again to interrupt" : "interrupt"} <box paddingLeft={3}>
</span> <text fg={theme.accent}>{notice()}</text>
</text> </box>
</box> )}
</Show> </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"}> <Show when={status().type !== "retry"}>
<box gap={2} flexDirection="row"> <box gap={2} flexDirection="row">
<Show when={editorFileLabelDisplay()}> <Show when={editorFileLabelDisplay()}>

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

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

View file

@ -23,6 +23,7 @@ export interface DialogSelectProps<T> {
onFilter?: (query: string) => void onFilter?: (query: string) => void
onSelect?: (option: DialogSelectOption<T>) => void onSelect?: (option: DialogSelectOption<T>) => void
skipFilter?: boolean skipFilter?: boolean
renderFilter?: boolean
keybind?: { keybind?: {
keybind?: Keybind.Info keybind?: Keybind.Info
title: string title: string
@ -81,7 +82,7 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
let input: InputRenderable let input: InputRenderable
const filtered = createMemo(() => { 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 needle = store.filter.toLowerCase()
const options = pipe( const options = pipe(
props.options, props.options,
@ -250,30 +251,32 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
esc esc
</text> </text>
</box> </box>
<box paddingTop={1}> <Show when={props.renderFilter !== false}>
<input <box paddingTop={1}>
onInput={(e) => { <input
batch(() => { onInput={(e) => {
setStore("filter", e) batch(() => {
props.onFilter?.(e) setStore("filter", e)
}) props.onFilter?.(e)
}} })
focusedBackgroundColor={theme.backgroundPanel} }}
cursorColor={theme.primary} focusedBackgroundColor={theme.backgroundPanel}
focusedTextColor={theme.textMuted} cursorColor={theme.primary}
ref={(r) => { focusedTextColor={theme.textMuted}
input = r ref={(r) => {
input.traits = { status: "FILTER" } input = r
setTimeout(() => { input.traits = { status: "FILTER" }
if (!input) return setTimeout(() => {
if (input.isDestroyed) return if (!input) return
input.focus() if (input.isDestroyed) return
}, 1) input.focus()
}} }, 1)
placeholder={props.placeholder ?? "Search"} }}
placeholderColor={theme.textMuted} placeholder={props.placeholder ?? "Search"}
/> placeholderColor={theme.textMuted}
</box> />
</box>
</Show>
</box> </box>
<Show <Show
when={grouped().length > 0} when={grouped().length > 0}

View file

@ -1,10 +1,11 @@
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 { FetchHttpClient, HttpBody, HttpClient, HttpClientError, HttpClientRequest } from "effect/unstable/http"
import { Database } from "@/storage/db" import { Database } from "@/storage/db"
import { asc } from "drizzle-orm" import { asc } from "drizzle-orm"
import { eq } from "drizzle-orm" import { eq } from "drizzle-orm"
import { inArray } from "drizzle-orm" import { inArray } from "drizzle-orm"
import { Project } from "@/project/project" import { Project } from "@/project/project"
import { Instance } from "@/project/instance"
import { BusEvent } from "@/bus/bus-event" import { BusEvent } from "@/bus/bus-event"
import { GlobalBus } from "@/bus/global" import { GlobalBus } from "@/bus/global"
import { Auth } from "@/auth" import { Auth } from "@/auth"
@ -20,6 +21,7 @@ import { getAdapter } from "./adapters"
import { type WorkspaceInfo, WorkspaceInfo as WorkspaceInfoSchema } from "./types" import { type WorkspaceInfo, WorkspaceInfo as WorkspaceInfoSchema } from "./types"
import { WorkspaceID } from "./schema" import { WorkspaceID } from "./schema"
import { Session } from "@/session/session" import { Session } from "@/session/session"
import { SessionPrompt } from "@/session/prompt"
import { SessionTable } from "@/session/session.sql" import { SessionTable } from "@/session/session.sql"
import { SessionID } from "@/session/schema" import { SessionID } from "@/session/schema"
import { errorData } from "@/util/error" import { errorData } from "@/util/error"
@ -38,13 +40,6 @@ export const ConnectionStatus = Schema.Struct({
}) })
export type ConnectionStatus = Schema.Schema.Type<typeof ConnectionStatus> export type ConnectionStatus = Schema.Schema.Type<typeof ConnectionStatus>
const Restore = Schema.Struct({
workspaceID: WorkspaceID,
sessionID: SessionID,
total: NonNegativeInt,
step: NonNegativeInt,
})
export const Event = { export const Event = {
Ready: BusEvent.define( Ready: BusEvent.define(
"workspace.ready", "workspace.ready",
@ -58,7 +53,6 @@ export const Event = {
message: Schema.String, message: Schema.String,
}), }),
), ),
Restore: BusEvent.define("workspace.restore", Restore),
Status: BusEvent.define("workspace.status", ConnectionStatus), Status: BusEvent.define("workspace.status", ConnectionStatus),
} }
@ -84,15 +78,15 @@ export const CreateInput = Schema.Struct({
type: Info.fields.type, type: Info.fields.type,
branch: Info.fields.branch, branch: Info.fields.branch,
projectID: ProjectID, projectID: ProjectID,
extra: Info.fields.extra, extra: Schema.optional(Info.fields.extra),
}).pipe(withStatics((s) => ({ zod: effectZod(s), zodObject: zodObject(s) }))) }).pipe(withStatics((s) => ({ zod: effectZod(s), zodObject: zodObject(s) })))
export type CreateInput = Schema.Schema.Type<typeof CreateInput> export type CreateInput = Schema.Schema.Type<typeof CreateInput>
export const SessionRestoreInput = Schema.Struct({ export const SessionWarpInput = Schema.Struct({
workspaceID: WorkspaceID, workspaceID: Schema.NullOr(WorkspaceID),
sessionID: SessionID, sessionID: SessionID,
}).pipe(withStatics((s) => ({ zod: effectZod(s), zodObject: zodObject(s) }))) }).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", { export class SyncHttpError extends Schema.TaggedErrorClass<SyncHttpError>()("WorkspaceSyncHttpError", {
message: Schema.String, message: Schema.String,
@ -116,8 +110,8 @@ export class SessionEventsNotFoundError extends Schema.TaggedErrorClass<SessionE
}, },
) {} ) {}
export class SessionRestoreHttpError extends Schema.TaggedErrorClass<SessionRestoreHttpError>()( export class SessionWarpHttpError extends Schema.TaggedErrorClass<SessionWarpHttpError>()(
"WorkspaceSessionRestoreHttpError", "WorkspaceSessionWarpHttpError",
{ {
message: Schema.String, message: Schema.String,
workspaceID: WorkspaceID, workspaceID: WorkspaceID,
@ -138,17 +132,17 @@ export class SyncAbortedError extends Schema.TaggedErrorClass<SyncAbortedError>(
}) {} }) {}
type CreateError = Auth.AuthError type CreateError = Auth.AuthError
type SessionRestoreError = type SessionWarpError =
| WorkspaceNotFoundError | WorkspaceNotFoundError
| SessionEventsNotFoundError | SessionEventsNotFoundError
| SessionRestoreHttpError | SessionWarpHttpError
| HttpClientError.HttpClientError | HttpClientError.HttpClientError
type WaitForSyncError = SyncTimeoutError | SyncAbortedError type WaitForSyncError = SyncTimeoutError | SyncAbortedError
type SyncLoopError = SyncHttpError | HttpClientError.HttpClientError type SyncLoopError = SyncHttpError | HttpClientError.HttpClientError
export interface Interface { export interface Interface {
readonly create: (input: CreateInput) => Effect.Effect<Info, CreateError> 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 list: (project: Project.Info) => Effect.Effect<Info[]>
readonly get: (id: WorkspaceID) => Effect.Effect<Info | undefined> readonly get: (id: WorkspaceID) => Effect.Effect<Info | undefined>
readonly remove: (id: WorkspaceID) => Effect.Effect<Info | undefined> readonly remove: (id: WorkspaceID) => Effect.Effect<Info | undefined>
@ -169,6 +163,7 @@ export const layer = Layer.effect(
Effect.gen(function* () { Effect.gen(function* () {
const auth = yield* Auth.Service const auth = yield* Auth.Service
const session = yield* Session.Service const session = yield* Session.Service
const prompt = yield* SessionPrompt.Service
const http = yield* HttpClient.HttpClient const http = yield* HttpClient.HttpClient
const sync = yield* SyncEvent.Service const sync = yield* SyncEvent.Service
const connections = new Map<WorkspaceID, ConnectionStatus>() const connections = new Map<WorkspaceID, ConnectionStatus>()
@ -461,7 +456,7 @@ export const layer = Layer.effect(
const id = WorkspaceID.ascending(input.id) const id = WorkspaceID.ascending(input.id)
const adapter = getAdapter(input.projectID, input.type) const adapter = getAdapter(input.projectID, input.type)
const config = yield* EffectBridge.fromPromise(() => const config = yield* EffectBridge.fromPromise(() =>
adapter.configure({ ...input, id, name: Slug.create(), directory: null }), adapter.configure({ ...input, id, name: Slug.create(), directory: null, extra: input.extra ?? null }),
) )
const info: Info = { const info: Info = {
@ -518,29 +513,93 @@ export const layer = Layer.effect(
return info 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* () { return yield* Effect.gen(function* () {
log.info("session restore requested", { log.info("session warp requested", {
workspaceID: input.workspaceID, workspaceID: input.workspaceID,
sessionID: input.sessionID, sessionID: input.sessionID,
}) })
const space = yield* get(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) {
const adapter = getAdapter(previous.projectID, previous.type)
const target = yield* EffectBridge.fromPromise(() => adapter.target(previous))
if (target.type === "remote") {
yield* syncHistory(previous, target.url, target.headers).pipe(
Effect.catch((error) =>
Effect.sync(() => {
log.warn("session warp final source sync failed", {
workspaceID: previous.id,
sessionID: input.sessionID,
error: errorData(error),
})
}),
),
)
} else {
yield* prompt.cancel(input.sessionID)
}
// "claim" this session so any future events coming from
// the old workspace are ignored
SyncEvent.claim(input.sessionID, input.workspaceID ?? Instance.project.id)
}
}
if (input.workspaceID === null) {
yield* Effect.sync(() =>
SyncEvent.run(Session.Event.Updated, {
sessionID: input.sessionID,
info: {
workspaceID: null,
},
}),
)
log.info("session warp complete", {
workspaceID: input.workspaceID,
sessionID: input.sessionID,
target: "local",
})
return
}
const workspaceID = input.workspaceID
const space = yield* get(workspaceID)
if (!space) if (!space)
return yield* new WorkspaceNotFoundError({ return yield* new WorkspaceNotFoundError({
message: `Workspace not found: ${input.workspaceID}`, message: `Workspace not found: ${workspaceID}`,
workspaceID: input.workspaceID, workspaceID,
}) })
const adapter = getAdapter(space.projectID, space.type) const adapter = getAdapter(space.projectID, space.type)
const target = yield* EffectBridge.fromPromise(() => adapter.target(space)) const target = yield* EffectBridge.fromPromise(() => adapter.target(space))
yield* sync.run(Session.Event.Updated, { if (target.type === "local") {
sessionID: input.sessionID, yield* sync.run(Session.Event.Updated, {
info: { sessionID: input.sessionID,
info: {
workspaceID: input.workspaceID,
},
})
log.info("session warp complete", {
workspaceID: input.workspaceID, workspaceID: input.workspaceID,
}, sessionID: input.sessionID,
}) target: target.directory,
})
return
}
const rows = yield* db((db) => const rows = yield* db((db) =>
db db
@ -562,130 +621,95 @@ export const layer = Layer.effect(
sessionID: input.sessionID, sessionID: input.sessionID,
}) })
const size = 10 const batches = Iterable.chunksOf(rows, 10)
// TODO: look into using effect APIs to process this in chunks const total = Iterable.size(batches)
const sets = Array.from({ length: Math.ceil(rows.length / size) }, (_, i) =>
rows.slice(i * size, (i + 1) * size),
)
const total = sets.length
log.info("session restore prepared", { log.info("session warp prepared", {
workspaceID: input.workspaceID, workspaceID: input.workspaceID,
sessionID: input.sessionID, sessionID: input.sessionID,
workspaceType: space.type, target: String(route(target.url, "/sync/replay")),
directory: space.directory,
target: target.type === "remote" ? String(route(target.url, "/sync/replay")) : target.directory,
events: rows.length, events: rows.length,
batches: total, batches: total,
first: rows[0]?.seq, first: rows[0]?.seq,
last: rows.at(-1)?.seq, last: rows.at(-1)?.seq,
}) })
yield* Effect.sync(() => yield* Effect.forEach(
GlobalBus.emit("event", { batches,
directory: "global", (events, i) =>
workspace: input.workspaceID, Effect.gen(function* () {
payload: { const response = yield* http.execute(
type: Event.Restore.type, HttpClientRequest.post(route(target.url, "/sync/replay"), {
properties: { headers: new Headers(target.headers),
workspaceID: input.workspaceID, body: HttpBody.jsonUnsafe({
sessionID: input.sessionID, directory: space.directory ?? "",
total, events,
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") {
yield* sync.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,
}), }),
}), )
)
if (res.status < 200 || res.status >= 300) { if (response.status < 200 || response.status >= 300) {
const body = yield* res.text const body = yield* response.text
log.error("session restore batch failed", { 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 ${workspaceID}: HTTP ${response.status} ${body}`,
workspaceID,
sessionID: input.sessionID,
status: response.status,
body,
})
}
log.info("session warp batch posted", {
workspaceID: input.workspaceID, workspaceID: input.workspaceID,
sessionID: input.sessionID, sessionID: input.sessionID,
step: i + 1, step: i + 1,
total, total,
status: res.status, status: response.status,
body,
}) })
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 ${workspaceID}: HTTP ${response.status} ${body}`,
workspaceID,
sessionID: input.sessionID,
status: response.status,
body,
})
} }
log.info("session restore complete", { log.info("session warp complete", {
workspaceID: input.workspaceID, workspaceID: input.workspaceID,
sessionID: input.sessionID, sessionID: input.sessionID,
batches: total, batches: total,
}) })
return { total }
}).pipe( }).pipe(
Effect.tapError((err) => Effect.tapError((err) =>
Effect.sync(() => Effect.sync(() =>
log.error("session restore failed", { log.error("session warp failed", {
workspaceID: input.workspaceID, workspaceID: input.workspaceID,
sessionID: input.sessionID, sessionID: input.sessionID,
error: errorData(err), error: errorData(err),
@ -814,7 +838,7 @@ export const layer = Layer.effect(
return Service.of({ return Service.of({
create, create,
sessionRestore, sessionWarp,
list, list,
get, get,
remove, remove,
@ -830,6 +854,7 @@ export const defaultLayer = layer.pipe(
Layer.provide(Auth.defaultLayer), Layer.provide(Auth.defaultLayer),
Layer.provide(Session.defaultLayer), Layer.provide(Session.defaultLayer),
Layer.provide(SyncEvent.defaultLayer), Layer.provide(SyncEvent.defaultLayer),
Layer.provide(SessionPrompt.defaultLayer),
Layer.provide(FetchHttpClient.layer), Layer.provide(FetchHttpClient.layer),
) )

View file

@ -10,10 +10,6 @@ import { zodObject } from "@/util/effect-zod"
import { Instance } from "@/project/instance" import { Instance } from "@/project/instance"
import { errors } from "../../error" import { errors } from "../../error"
import { lazy } from "@/util/lazy" 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(() => export const WorkspaceRoutes = lazy(() =>
new Hono() new Hono()
@ -151,60 +147,36 @@ export const WorkspaceRoutes = lazy(() =>
}, },
) )
.post( .post(
"/:id/session-restore", "/warp",
describeRoute({ describeRoute({
summary: "Restore session into workspace", summary: "Warp session into workspace",
description: "Replay a session's sync events into the target workspace in batches.", description: "Move a session's sync history into the target workspace, or detach it to the local project.",
operationId: "experimental.workspace.sessionRestore", operationId: "experimental.workspace.warp",
responses: { responses: {
200: { 204: {
description: "Session replay started", description: "Session warped",
content: {
"application/json": {
schema: resolver(
z.object({
total: z.number().int().min(0),
}),
),
},
},
}, },
...errors(400), ...errors(400),
}, },
}), }),
validator("param", z.object({ id: zodObject(Workspace.Info).shape.id })), validator(
validator("json", Workspace.SessionRestoreInput.zodObject.omit({ workspaceID: true })), "json",
z.object({
id: zodObject(Workspace.Info).shape.id.nullable(),
sessionID: Workspace.SessionWarpInput.zodObject.shape.sessionID,
}),
),
async (c) => { async (c) => {
const { id } = c.req.valid("param") const body = c.req.valid("json")
const body = c.req.valid("json") as Omit<Workspace.SessionRestoreInput, "workspaceID"> await AppRuntime.runPromise(
log.info("session restore route requested", { Workspace.Service.use((workspace) =>
workspaceID: id, workspace.sessionWarp({
sessionID: body.sessionID, workspaceID: body.id,
directory: Instance.directory, sessionID: body.sessionID,
}) }),
try { ),
const result = await AppRuntime.runPromise( )
Workspace.Service.use((svc) => return c.body(null, 204)
svc.sessionRestore({
workspaceID: id,
...body,
}),
),
)
log.info("session restore route complete", {
workspaceID: id,
sessionID: body.sessionID,
total: result.total,
})
return c.json(result)
} catch (err) {
log.error("session restore route failed", {
workspaceID: id,
sessionID: body.sessionID,
error: errorData(err),
})
throw err
}
}, },
), ),
) )

View file

@ -1,4 +1,5 @@
import { NonNegativeInt } from "@/util/schema" import { NonNegativeInt } from "@/util/schema"
import { SessionID } from "@/session/schema"
import { Schema } from "effect" import { Schema } from "effect"
import { HttpApi, HttpApiEndpoint, HttpApiError, HttpApiGroup, OpenApi } from "effect/unstable/httpapi" import { HttpApi, HttpApiEndpoint, HttpApiError, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
import { Authorization } from "../middleware/authorization" import { Authorization } from "../middleware/authorization"
@ -21,6 +22,9 @@ export const ReplayPayload = Schema.Struct({
export const ReplayResponse = Schema.Struct({ export const ReplayResponse = Schema.Struct({
sessionID: Schema.String, sessionID: Schema.String,
}) })
export const SessionPayload = Schema.Struct({
sessionID: SessionID,
})
export const HistoryPayload = Schema.Record(Schema.String, NonNegativeInt) export const HistoryPayload = Schema.Record(Schema.String, NonNegativeInt)
export const HistoryEvent = Schema.Struct({ export const HistoryEvent = Schema.Struct({
id: Schema.String, id: Schema.String,
@ -33,6 +37,7 @@ export const HistoryEvent = Schema.Struct({
export const SyncPaths = { export const SyncPaths = {
start: `${root}/start`, start: `${root}/start`,
replay: `${root}/replay`, replay: `${root}/replay`,
steal: `${root}/steal`,
history: `${root}/history`, history: `${root}/history`,
} as const } as const
@ -60,6 +65,17 @@ export const SyncApi = HttpApi.make("sync")
description: "Validate and replay a complete sync event history.", description: "Validate and replay a complete sync event history.",
}), }),
), ),
HttpApiEndpoint.post("steal", SyncPaths.steal, {
payload: SessionPayload,
success: described(SessionPayload, "Session stolen into workspace"),
error: HttpApiError.BadRequest,
}).annotateMerge(
OpenApi.annotations({
identifier: "sync.steal",
summary: "Steal session into workspace",
description: "Update a session to belong to the current workspace through the sync event system.",
}),
),
HttpApiEndpoint.post("history", SyncPaths.history, { HttpApiEndpoint.post("history", SyncPaths.history, {
payload: HistoryPayload, payload: HistoryPayload,
success: described(Schema.Array(HistoryEvent), "Sync events"), success: described(Schema.Array(HistoryEvent), "Sync events"),

View file

@ -1,21 +1,17 @@
import { Workspace } from "@/control-plane/workspace" import { Workspace } from "@/control-plane/workspace"
import { WorkspaceAdapterEntry } from "@/control-plane/types" import { WorkspaceAdapterEntry } from "@/control-plane/types"
import { NonNegativeInt } from "@/util/schema"
import { Schema, Struct } from "effect" import { Schema, Struct } from "effect"
import { HttpApi, HttpApiEndpoint, HttpApiError, HttpApiGroup, OpenApi } from "effect/unstable/httpapi" import { HttpApi, HttpApiEndpoint, HttpApiError, HttpApiGroup, HttpApiSchema, OpenApi } from "effect/unstable/httpapi"
import { Authorization } from "../middleware/authorization" import { Authorization } from "../middleware/authorization"
import { InstanceContextMiddleware } from "../middleware/instance-context" import { InstanceContextMiddleware } from "../middleware/instance-context"
import { WorkspaceRoutingMiddleware } from "../middleware/workspace-routing" import { WorkspaceRoutingMiddleware } from "../middleware/workspace-routing"
import { described } from "./metadata" import { described } from "./metadata"
const root = "/experimental/workspace" const root = "/experimental/workspace"
export const CreatePayload = Schema.Struct({ export const CreatePayload = Schema.Struct(Struct.omit(Workspace.CreateInput.fields, ["projectID"]))
...Struct.omit(Workspace.CreateInput.fields, ["projectID", "extra"]), export const WarpPayload = Schema.Struct({
extra: Schema.optional(Workspace.CreateInput.fields.extra), id: Schema.NullOr(Workspace.Info.fields.id),
}) sessionID: Workspace.SessionWarpInput.fields.sessionID,
export const SessionRestorePayload = Schema.Struct(Struct.omit(Workspace.SessionRestoreInput.fields, ["workspaceID"]))
export const SessionRestoreResponse = Schema.Struct({
total: NonNegativeInt,
}) })
export const WorkspacePaths = { export const WorkspacePaths = {
@ -23,7 +19,7 @@ export const WorkspacePaths = {
list: root, list: root,
status: `${root}/status`, status: `${root}/status`,
remove: `${root}/:id`, remove: `${root}/:id`,
sessionRestore: `${root}/:id/session-restore`, warp: `${root}/warp`,
} as const } as const
export const WorkspaceApi = HttpApi.make("workspace") export const WorkspaceApi = HttpApi.make("workspace")
@ -79,16 +75,15 @@ export const WorkspaceApi = HttpApi.make("workspace")
description: "Remove an existing workspace.", description: "Remove an existing workspace.",
}), }),
), ),
HttpApiEndpoint.post("sessionRestore", WorkspacePaths.sessionRestore, { HttpApiEndpoint.post("warp", WorkspacePaths.warp, {
params: { id: Workspace.Info.fields.id }, payload: WarpPayload,
payload: SessionRestorePayload, success: described(HttpApiSchema.NoContent, "Session warped"),
success: described(SessionRestoreResponse, "Session replay started"),
error: HttpApiError.BadRequest, error: HttpApiError.BadRequest,
}).annotateMerge( }).annotateMerge(
OpenApi.annotations({ OpenApi.annotations({
identifier: "experimental.workspace.sessionRestore", identifier: "experimental.workspace.warp",
summary: "Restore session into workspace", summary: "Warp session into workspace",
description: "Replay a session's sync events into the target workspace in batches.", description: "Move a session's sync history into the target workspace, or detach it to the local project.",
}), }),
), ),
) )

View file

@ -1,5 +1,6 @@
import { Workspace } from "@/control-plane/workspace" import { Workspace } from "@/control-plane/workspace"
import * as InstanceState from "@/effect/instance-state" import * as InstanceState from "@/effect/instance-state"
import { Session } from "@/session/session"
import { Database } from "@/storage/db" import { Database } from "@/storage/db"
import { SyncEvent } from "@/sync" import { SyncEvent } from "@/sync"
import { EventTable } from "@/sync/event.sql" import { EventTable } from "@/sync/event.sql"
@ -12,7 +13,7 @@ import { or } from "drizzle-orm"
import { Effect, Scope } from "effect" import { Effect, Scope } from "effect"
import { HttpApiBuilder } from "effect/unstable/httpapi" import { HttpApiBuilder } from "effect/unstable/httpapi"
import { InstanceHttpApi } from "../api" import { InstanceHttpApi } from "../api"
import { HistoryPayload, ReplayPayload } from "../groups/sync" import { HistoryPayload, ReplayPayload, SessionPayload } from "../groups/sync"
import * as Log from "@opencode-ai/core/util/log" import * as Log from "@opencode-ai/core/util/log"
const log = Log.create({ service: "server.sync" }) const log = Log.create({ service: "server.sync" })
@ -56,6 +57,25 @@ export const syncHandlers = HttpApiBuilder.group(InstanceHttpApi, "sync", (handl
return { sessionID: source } return { sessionID: source }
}) })
const steal = Effect.fn("SyncHttpApi.steal")(function* (ctx: { payload: typeof SessionPayload.Type }) {
const workspaceID = yield* InstanceState.workspaceID
if (!workspaceID) throw new Error("Cannot steal session without workspace context")
yield* sync.run(Session.Event.Updated, {
sessionID: ctx.payload.sessionID,
info: {
workspaceID,
},
})
log.info("sync session stolen", {
sessionID: ctx.payload.sessionID,
workspaceID,
})
return { sessionID: ctx.payload.sessionID }
})
const history = Effect.fn("SyncHttpApi.history")(function* (ctx: { payload: typeof HistoryPayload.Type }) { const history = Effect.fn("SyncHttpApi.history")(function* (ctx: { payload: typeof HistoryPayload.Type }) {
const exclude = Object.entries(ctx.payload) const exclude = Object.entries(ctx.payload)
return Database.use((db) => return Database.use((db) =>
@ -72,6 +92,6 @@ export const syncHandlers = HttpApiBuilder.group(InstanceHttpApi, "sync", (handl
) )
}) })
return handlers.handle("start", start).handle("replay", replay).handle("history", history) return handlers.handle("start", start).handle("replay", replay).handle("steal", steal).handle("history", history)
}), }),
) )

View file

@ -4,7 +4,7 @@ import * as InstanceState from "@/effect/instance-state"
import { Effect } from "effect" import { Effect } from "effect"
import { HttpApiBuilder, HttpApiError } from "effect/unstable/httpapi" import { HttpApiBuilder, HttpApiError } from "effect/unstable/httpapi"
import { InstanceHttpApi } from "../api" import { InstanceHttpApi } from "../api"
import { CreatePayload, SessionRestorePayload } from "../groups/workspace" import { CreatePayload, WarpPayload } from "../groups/workspace"
export const workspaceHandlers = HttpApiBuilder.group(InstanceHttpApi, "workspace", (handlers) => export const workspaceHandlers = HttpApiBuilder.group(InstanceHttpApi, "workspace", (handlers) =>
Effect.gen(function* () { Effect.gen(function* () {
@ -39,13 +39,10 @@ export const workspaceHandlers = HttpApiBuilder.group(InstanceHttpApi, "workspac
return yield* workspace.remove(ctx.params.id) return yield* workspace.remove(ctx.params.id)
}) })
const sessionRestore = Effect.fn("WorkspaceHttpApi.sessionRestore")(function* (ctx: { const warp = Effect.fn("WorkspaceHttpApi.warp")(function* (ctx: { payload: typeof WarpPayload.Type }) {
params: { id: Workspace.Info["id"] } yield* workspace
payload: typeof SessionRestorePayload.Type .sessionWarp({
}) { workspaceID: ctx.payload.id,
return yield* workspace
.sessionRestore({
workspaceID: ctx.params.id,
sessionID: ctx.payload.sessionID, sessionID: ctx.payload.sessionID,
}) })
.pipe(Effect.mapError(() => new HttpApiError.BadRequest({}))) .pipe(Effect.mapError(() => new HttpApiError.BadRequest({})))
@ -57,6 +54,6 @@ export const workspaceHandlers = HttpApiBuilder.group(InstanceHttpApi, "workspac
.handle("create", create) .handle("create", create)
.handle("status", status) .handle("status", status)
.handle("remove", remove) .handle("remove", remove)
.handle("sessionRestore", sessionRestore) .handle("warp", warp)
}), }),
) )

View file

@ -155,7 +155,7 @@ export const InstanceRoutes = (upgrade: UpgradeWebSocket, opts?: CorsOptions): H
app.get(WorkspacePaths.list, (c) => handler(c.req.raw, context)) app.get(WorkspacePaths.list, (c) => handler(c.req.raw, context))
app.get(WorkspacePaths.status, (c) => handler(c.req.raw, context)) app.get(WorkspacePaths.status, (c) => handler(c.req.raw, context))
app.delete(WorkspacePaths.remove, (c) => handler(c.req.raw, context)) app.delete(WorkspacePaths.remove, (c) => handler(c.req.raw, context))
app.post(WorkspacePaths.sessionRestore, (c) => handler(c.req.raw, context)) app.post(WorkspacePaths.warp, (c) => handler(c.req.raw, context))
} }
return app return app

View file

@ -16,6 +16,9 @@ import { Workspace } from "@/control-plane/workspace"
import { AppRuntime } from "@/effect/app-runtime" import { AppRuntime } from "@/effect/app-runtime"
import { Instance } from "@/project/instance" import { Instance } from "@/project/instance"
import { errors } from "../../error" 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({ const ReplayEvent = z.object({
id: z.string(), id: z.string(),
@ -24,6 +27,9 @@ const ReplayEvent = z.object({
type: z.string(), type: z.string(),
data: z.record(z.string(), z.unknown()), data: z.record(z.string(), z.unknown()),
}) })
const SessionPayload = z.object({
sessionID: SessionID.zod,
})
const log = Log.create({ service: "server.sync" }) const log = Log.create({ service: "server.sync" })
@ -108,6 +114,47 @@ export const SyncRoutes = lazy(() =>
}) })
}, },
) )
.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( .post(
"/history", "/history",
describeRoute({ describeRoute({

View file

@ -3,6 +3,7 @@ import { sqliteTable, text, integer } from "drizzle-orm/sqlite-core"
export const EventSequenceTable = sqliteTable("event_sequence", { export const EventSequenceTable = sqliteTable("event_sequence", {
aggregate_id: text().notNull().primaryKey(), aggregate_id: text().notNull().primaryKey(),
seq: integer().notNull(), seq: integer().notNull(),
owner_id: text(),
}) })
export const EventTable = sqliteTable("event", { export const EventTable = sqliteTable("event", {

View file

@ -59,8 +59,11 @@ export interface Interface {
data: Event<Def>["data"], data: Event<Def>["data"],
options?: { publish?: boolean }, options?: { publish?: boolean },
) => Effect.Effect<void> ) => Effect.Effect<void>
readonly replay: (event: SerializedEvent, options?: { publish: boolean }) => Effect.Effect<void> readonly replay: (event: SerializedEvent, options?: { publish: boolean; ownerID?: string }) => Effect.Effect<void>
readonly replayAll: (events: SerializedEvent[], options?: { publish: boolean }) => Effect.Effect<string | undefined> readonly replayAll: (
events: SerializedEvent[],
options?: { publish: boolean; ownerID?: string },
) => Effect.Effect<string | undefined>
readonly remove: (aggregateID: string) => Effect.Effect<void> readonly remove: (aggregateID: string) => Effect.Effect<void>
} }
@ -76,7 +79,7 @@ export const layer = Layer.effect(Service)(
const row = Database.use((db) => const row = Database.use((db) =>
db db
.select({ seq: EventSequenceTable.seq }) .select({ seq: EventSequenceTable.seq, ownerID: EventSequenceTable.owner_id })
.from(EventSequenceTable) .from(EventSequenceTable)
.where(eq(EventSequenceTable.aggregate_id, event.aggregateID)) .where(eq(EventSequenceTable.aggregate_id, event.aggregateID))
.get(), .get(),
@ -85,6 +88,10 @@ export const layer = Layer.effect(Service)(
const latest = row?.seq ?? -1 const latest = row?.seq ?? -1
if (event.seq <= latest) return if (event.seq <= latest) return
if (row?.ownerID && row.ownerID !== options?.ownerID) {
return
}
const expected = latest + 1 const expected = latest + 1
if (event.seq !== expected) { if (event.seq !== expected) {
throw new Error( throw new Error(
@ -99,7 +106,7 @@ export const layer = Layer.effect(Service)(
workspace: yield* InstanceState.workspaceID, workspace: yield* InstanceState.workspaceID,
} }
: undefined : undefined
process(def, event, { publish, context }) process(def, event, { publish, context, ownerID: options?.ownerID })
}) })
const replayAll: Interface["replayAll"] = Effect.fn("SyncEvent.replayAll")(function* (events, options) { const replayAll: Interface["replayAll"] = Effect.fn("SyncEvent.replayAll")(function* (events, options) {
@ -263,7 +270,7 @@ export function project<Def extends Definition>(
function process<Def extends Definition>( function process<Def extends Definition>(
def: Def, def: Def,
event: Event<Def>, event: Event<Def>,
options: { publish: boolean; context?: PublishContext }, options: { publish: boolean; context?: PublishContext; ownerID?: string },
) { ) {
if (projectors == null) { if (projectors == null) {
throw new Error("No projectors available. Call `SyncEvent.init` to install projectors") throw new Error("No projectors available. Call `SyncEvent.init` to install projectors")
@ -274,8 +281,6 @@ function process<Def extends Definition>(
throw new Error(`Projector not found for event: ${def.type}`) throw new Error(`Projector not found for event: ${def.type}`)
} }
// idempotent: need to ignore any events already logged
Database.transaction((tx) => { Database.transaction((tx) => {
projector(tx, event.data, event) projector(tx, event.data, event)
@ -284,6 +289,7 @@ function process<Def extends Definition>(
.values({ .values({
aggregate_id: event.aggregateID, aggregate_id: event.aggregateID,
seq: event.seq, seq: event.seq,
owner_id: options?.ownerID,
}) })
.onConflictDoUpdate({ .onConflictDoUpdate({
target: EventSequenceTable.aggregate_id, target: EventSequenceTable.aggregate_id,
@ -332,11 +338,11 @@ function process<Def extends Definition>(
}) })
} }
export function replay(event: SerializedEvent, options?: { publish: boolean }) { export function replay(event: SerializedEvent, options?: { publish: boolean; ownerID?: string }) {
return runtime.runSync((sync) => sync.replay(event, options)) return runtime.runSync((sync) => sync.replay(event, options))
} }
export function replayAll(events: SerializedEvent[], options?: { publish: boolean }) { export function replayAll(events: SerializedEvent[], options?: { publish: boolean; ownerID?: string }) {
return runtime.runSync((sync) => sync.replayAll(events, options)) return runtime.runSync((sync) => sync.replayAll(events, options))
} }
@ -348,6 +354,16 @@ export function remove(aggregateID: string) {
return runtime.runSync((sync) => sync.remove(aggregateID)) return runtime.runSync((sync) => sync.remove(aggregateID))
} }
export function claim(aggregateID: string, ownerID: string) {
Database.use((db) =>
db
.update(EventSequenceTable)
.set({ owner_id: ownerID })
.where(eq(EventSequenceTable.aggregate_id, aggregateID))
.run(),
)
}
export function payloads() { export function payloads() {
return registry return registry
.entries() .entries()

View file

@ -6,7 +6,7 @@ import { setTimeout as delay } from "node:timers/promises"
import { NodeHttpServer } from "@effect/platform-node" import { NodeHttpServer } from "@effect/platform-node"
import { Effect, Layer } from "effect" import { Effect, Layer } from "effect"
import { HttpServer, HttpServerRequest, HttpServerResponse } from "effect/unstable/http" 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 * as Log from "@opencode-ai/core/util/log"
import { Flag } from "@opencode-ai/core/flag/flag" import { Flag } from "@opencode-ai/core/flag/flag"
import { GlobalBus, type GlobalEvent } from "@/bus/global" import { GlobalBus, type GlobalEvent } from "@/bus/global"
@ -16,11 +16,10 @@ import { ProjectTable } from "@/project/project.sql"
import { Instance } from "@/project/instance" import { Instance } from "@/project/instance"
import { WithInstance } from "../../src/project/with-instance" import { WithInstance } from "../../src/project/with-instance"
import { Session as SessionNs } from "@/session/session" 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 { SessionTable } from "@/session/session.sql"
import { ModelID, ProviderID } from "@/provider/schema"
import { SyncEvent } from "@/sync" import { SyncEvent } from "@/sync"
import { EventSequenceTable, EventTable } from "@/sync/event.sql" import { EventSequenceTable } from "@/sync/event.sql"
import { resetDatabase } from "../fixture/db" import { resetDatabase } from "../fixture/db"
import { disposeAllInstances, provideTmpdirInstance, tmpdir } from "../fixture/fixture" import { disposeAllInstances, provideTmpdirInstance, tmpdir } from "../fixture/fixture"
import { testEffect } from "../lib/effect" import { testEffect } from "../lib/effect"
@ -111,8 +110,8 @@ async function withInstance<T>(fn: (dir: string) => T | Promise<T>) {
const runWorkspace = <A, E>(effect: Effect.Effect<A, E, WorkspaceOld.Service>) => AppRuntime.runPromise(effect) const runWorkspace = <A, E>(effect: Effect.Effect<A, E, WorkspaceOld.Service>) => AppRuntime.runPromise(effect)
const createWorkspace = (input: WorkspaceOld.CreateInput) => const createWorkspace = (input: WorkspaceOld.CreateInput) =>
runWorkspace(WorkspaceOld.Service.use((workspace) => workspace.create(input))) runWorkspace(WorkspaceOld.Service.use((workspace) => workspace.create(input)))
const restoreWorkspaceSession = (input: WorkspaceOld.SessionRestoreInput) => const warpWorkspaceSession = (input: WorkspaceOld.SessionWarpInput) =>
runWorkspace(WorkspaceOld.Service.use((workspace) => workspace.sessionRestore(input))) runWorkspace(WorkspaceOld.Service.use((workspace) => workspace.sessionWarp(input)))
const listWorkspaces = (project: Parameters<WorkspaceOld.Interface["list"]>[0]) => const listWorkspaces = (project: Parameters<WorkspaceOld.Interface["list"]>[0]) =>
runWorkspace(WorkspaceOld.Service.use((workspace) => workspace.list(project))) runWorkspace(WorkspaceOld.Service.use((workspace) => workspace.list(project)))
const getWorkspace = (id: WorkspaceID) => runWorkspace(WorkspaceOld.Service.use((workspace) => workspace.get(id))) const getWorkspace = (id: WorkspaceID) => runWorkspace(WorkspaceOld.Service.use((workspace) => workspace.get(id)))
@ -317,48 +316,24 @@ function sessionSequence(sessionID: SessionID) {
)?.seq )?.seq
} }
function eventRows(sessionID: SessionID) { function sessionSequenceOwner(sessionID: SessionID) {
return Database.use((db) => return Database.use((db) =>
db db
.select({ seq: EventTable.seq, type: EventTable.type, data: EventTable.data }) .select({ ownerID: EventSequenceTable.owner_id })
.from(EventTable) .from(EventSequenceTable)
.where(eq(EventTable.aggregate_id, sessionID)) .where(eq(EventSequenceTable.aggregate_id, sessionID))
.orderBy(asc(EventTable.seq)) .get(),
.all(), )?.ownerID
)
} }
function sessionUpdatedType() { function sessionUpdatedType() {
return SyncEvent.versionedType(SessionNs.Event.Updated.type, SessionNs.Event.Updated.version) 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", () => { describe("workspace-old schemas and exports", () => {
test("keeps the historical event type names", () => { test("keeps the historical event type names", () => {
expect(WorkspaceOld.Event.Ready.type).toBe("workspace.ready") expect(WorkspaceOld.Event.Ready.type).toBe("workspace.ready")
expect(WorkspaceOld.Event.Failed.type).toBe("workspace.failed") expect(WorkspaceOld.Event.Failed.type).toBe("workspace.failed")
expect(WorkspaceOld.Event.Restore.type).toBe("workspace.restore")
expect(WorkspaceOld.Event.Status.type).toBe("workspace.status") expect(WorkspaceOld.Event.Status.type).toBe("workspace.status")
}) })
@ -375,17 +350,6 @@ describe("workspace-old schemas and exports", () => {
expect(() => WorkspaceOld.CreateInput.zod.parse({ ...input, id: "bad" })).toThrow() expect(() => WorkspaceOld.CreateInput.zod.parse({ ...input, id: "bad" })).toThrow()
expect(() => WorkspaceOld.CreateInput.zod.parse({ ...input, branch: 1 })).toThrow() 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", () => { describe("workspace-old CRUD", () => {
@ -651,6 +615,144 @@ describe("workspace-old CRUD", () => {
expect(await getWorkspace(info.id)).toBeUndefined() expect(await getWorkspace(info.id)).toBeUndefined()
}) })
}) })
test("sessionWarp moves a session into a local workspace and claims ownership", async () => {
await withInstance(async (dir) => {
const previousType = unique("warp-prev-local")
const targetType = unique("warp-target-local")
const previous = workspaceInfo(Instance.project.id, previousType)
const target = workspaceInfo(Instance.project.id, targetType)
insertWorkspace(previous)
insertWorkspace(target)
registerAdapter(Instance.project.id, previousType, localAdapter(path.join(dir, "warp-prev-local")).adapter)
registerAdapter(Instance.project.id, targetType, localAdapter(path.join(dir, "warp-target-local")).adapter)
const session = await AppRuntime.runPromise(SessionNs.Service.use((svc) => svc.create({})))
attachSessionToWorkspace(session.id, previous.id)
await warpWorkspaceSession({ workspaceID: target.id, sessionID: session.id })
expect(
Database.use((db) =>
db
.select({ workspaceID: SessionTable.workspace_id })
.from(SessionTable)
.where(eq(SessionTable.id, session.id))
.get(),
)?.workspaceID,
).toBe(target.id)
expect(sessionSequenceOwner(session.id)).toBe(target.id)
})
})
test("sessionWarp detaches a session to the local project and claims project ownership", async () => {
await withInstance(async (dir) => {
const previousType = unique("warp-detach-local")
const previous = workspaceInfo(Instance.project.id, previousType)
insertWorkspace(previous)
registerAdapter(Instance.project.id, previousType, localAdapter(path.join(dir, "warp-detach-local")).adapter)
const session = await AppRuntime.runPromise(SessionNs.Service.use((svc) => svc.create({})))
attachSessionToWorkspace(session.id, previous.id)
await warpWorkspaceSession({ workspaceID: null, sessionID: session.id })
expect(
Database.use((db) =>
db
.select({ workspaceID: SessionTable.workspace_id })
.from(SessionTable)
.where(eq(SessionTable.id, session.id))
.get(),
)?.workspaceID,
).toBeNull()
expect(sessionSequenceOwner(session.id)).toBe(Instance.project.id)
})
})
it.live("sessionWarp syncs previous remote history, replays it, steals, and claims the sequence", () => {
const calls: FetchCall[] = []
let historySessionID: SessionID | undefined
let historyNextSeq = 0
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,
}
calls.push(call)
if (call.url.pathname === "/warp-source/sync/history") {
return yield* HttpServerResponse.json([
{
id: `evt_${unique("warp-source-history")}`,
aggregate_id: historySessionID!,
seq: historyNextSeq,
type: sessionUpdatedType(),
data: { sessionID: historySessionID!, info: { title: "from source history" } },
},
])
}
if (call.url.pathname === "/warp-target/sync/replay")
return yield* HttpServerResponse.json({ sessionID: "ok" })
if (call.url.pathname === "/warp-target/sync/steal")
return yield* HttpServerResponse.json({ sessionID: "ok" })
return HttpServerResponse.text("unexpected", { status: 500 })
}),
)
const url = yield* serverUrl()
yield* provideTmpdirInstance(
() =>
Effect.gen(function* () {
const workspace = yield* WorkspaceOld.Service
const sessionSvc = yield* SessionNs.Service
const previousType = unique("warp-remote-source")
const targetType = unique("warp-remote-target")
const previous = workspaceInfo(Instance.project.id, previousType)
const target = workspaceInfo(Instance.project.id, targetType, { directory: "remote-target-dir" })
insertWorkspace(previous)
insertWorkspace(target)
registerAdapter(Instance.project.id, previousType, remoteAdapter(`${url}/warp-source`).adapter)
registerAdapter(Instance.project.id, targetType, remoteAdapter(`${url}/warp-target`).adapter)
const session = yield* sessionSvc.create({})
attachSessionToWorkspace(session.id, previous.id)
historySessionID = session.id
historyNextSeq = (sessionSequence(session.id) ?? -1) + 1
yield* workspace.sessionWarp({ workspaceID: target.id, sessionID: session.id })
expect(calls.map((call) => `${call.method} ${call.url.pathname}`)).toEqual([
"POST /warp-source/sync/history",
"POST /warp-target/sync/replay",
"POST /warp-target/sync/steal",
])
expect(calls[0].json).toEqual({ [session.id]: historyNextSeq - 1 })
expect(calls[1].json).toMatchObject({
directory: "remote-target-dir",
events: [
{
aggregateID: session.id,
seq: 0,
type: SyncEvent.versionedType(SessionNs.Event.Created.type, SessionNs.Event.Created.version),
},
{
aggregateID: session.id,
seq: historyNextSeq,
type: sessionUpdatedType(),
},
],
})
expect(calls[2].json).toEqual({ sessionID: session.id })
expect((yield* sessionSvc.get(session.id)).title).toBe("from source history")
expect(sessionSequenceOwner(session.id)).toBe(target.id)
}),
{ git: true },
)
})
})
}) })
describe("workspace-old sync state", () => { describe("workspace-old sync state", () => {
@ -1215,313 +1317,3 @@ describe("workspace-old waitForSync", () => {
}) })
}, 7000) }, 7000)
}) })
describe("workspace-old sessionRestore", () => {
test("throws when the workspace is missing", async () => {
await withInstance(async () => {
await expect(
restoreWorkspaceSession({
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)
registerAdapter(Instance.project.id, type, localAdapter(dir).adapter)
await expect(
restoreWorkspaceSession({ workspaceID: info.id, sessionID: SessionID.descending("ses_missing_restore") }),
).rejects.toThrow("NotFoundError")
await removeWorkspace(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)
registerAdapter(
Instance.project.id,
type,
remoteAdapter(`${url}/restore/?ignored=1#hash`, {
directory: dir,
headers: { authorization: "Bearer restore" },
}).adapter,
)
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)
registerAdapter(Instance.project.id, type, remoteAdapter(`${url}/null-dir`, { directory: null }).adapter)
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)
registerAdapter(Instance.project.id, type, remoteAdapter(`${url}/fail`, { directory: dir }).adapter)
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 },
)
})
})
it.live("local restore replays batches and emits progress", () =>
provideTmpdirInstance(
(dir) =>
Effect.gen(function* () {
const workspace = yield* WorkspaceOld.Service
const sessionSvc = yield* SessionNs.Service
const captured = captureGlobalEvents()
try {
const type = unique("restore-local")
const info = workspaceInfo(Instance.project.id, type, { directory: dir })
insertWorkspace(info)
registerAdapter(Instance.project.id, type, localAdapter(dir).adapter)
const session = yield* sessionSvc.create({ title: "restore local" })
replaceSessionEvents(session.id, 20)
expect(yield* workspace.sessionRestore({ workspaceID: info.id, sessionID: session.id })).toEqual({
total: 3,
})
expect((yield* sessionSvc.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])
yield* workspace.remove(info.id)
} finally {
captured.dispose()
}
}),
{ git: true },
),
)
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)
registerAdapter(Instance.project.id, type, remoteAdapter(`${url}/real`, { directory: dir }).adapter)
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

@ -168,22 +168,19 @@ describe("workspace HttpApi", () => {
const created = yield* request(WorkspacePaths.list, dir, { const created = yield* request(WorkspacePaths.list, dir, {
method: "POST", method: "POST",
headers: { "content-type": "application/json" }, headers: { "content-type": "application/json" },
body: JSON.stringify({ type: "local-test", branch: null, extra: null }), body: JSON.stringify({ type: "local-test", branch: null }),
}) })
expect(created.status).toBe(200) expect(created.status).toBe(200)
const workspace = (yield* Effect.promise(() => created.json())) as Workspace.Info const workspace = (yield* Effect.promise(() => created.json())) as Workspace.Info
expect(workspace).toMatchObject({ type: "local-test", name: "local-test" }) expect(workspace).toMatchObject({ type: "local-test", name: "local-test" })
const session = yield* Session.Service.use((svc) => svc.create({})).pipe(provideInstance(dir)) const session = yield* Session.Service.use((svc) => svc.create({})).pipe(provideInstance(dir))
const restored = yield* request(WorkspacePaths.sessionRestore.replace(":id", workspace.id), dir, { const warped = yield* request(WorkspacePaths.warp, dir, {
method: "POST", method: "POST",
headers: { "content-type": "application/json" }, headers: { "content-type": "application/json" },
body: JSON.stringify({ sessionID: session.id }), body: JSON.stringify({ id: workspace.id, sessionID: session.id }),
})
expect(restored.status).toBe(200)
expect((yield* Effect.promise(() => restored.json())) as { total: number }).toMatchObject({
total: expect.any(Number),
}) })
expect(warped.status).toBe(204)
const removed = yield* request(WorkspacePaths.remove.replace(":id", workspace.id), dir, { method: "DELETE" }) const removed = yield* request(WorkspacePaths.remove.replace(":id", workspace.id), dir, { method: "DELETE" })
expect(removed.status).toBe(200) expect(removed.status).toBe(200)
@ -212,7 +209,6 @@ describe("workspace HttpApi", () => {
expect((yield* Effect.promise(() => created.json())) as Workspace.Info).toMatchObject({ expect((yield* Effect.promise(() => created.json())) as Workspace.Info).toMatchObject({
type: "local-test", type: "local-test",
name: "local-test", name: "local-test",
extra: null,
}) })
}), }),
) )
@ -257,7 +253,6 @@ describe("workspace HttpApi", () => {
expect((yield* Effect.promise(() => created.json())) as Workspace.Info).toMatchObject({ expect((yield* Effect.promise(() => created.json())) as Workspace.Info).toMatchObject({
type: "local-test", type: "local-test",
name: "local-test", name: "local-test",
extra: null,
}) })
}), }),
) )
@ -272,7 +267,7 @@ describe("workspace HttpApi", () => {
const created = yield* request(WorkspacePaths.list, dir, { const created = yield* request(WorkspacePaths.list, dir, {
method: "POST", method: "POST",
headers: { "content-type": "application/json" }, headers: { "content-type": "application/json" },
body: JSON.stringify({ type: "local-target", branch: null, extra: null }), body: JSON.stringify({ type: "local-target", branch: null }),
}) })
const workspace = (yield* Effect.promise(() => created.json())) as Workspace.Info const workspace = (yield* Effect.promise(() => created.json())) as Workspace.Info
@ -327,7 +322,7 @@ describe("workspace HttpApi", () => {
const created = yield* request(WorkspacePaths.list, dir, { const created = yield* request(WorkspacePaths.list, dir, {
method: "POST", method: "POST",
headers: { "content-type": "application/json" }, headers: { "content-type": "application/json" },
body: JSON.stringify({ type: "remote-target", branch: null, extra: null }), body: JSON.stringify({ type: "remote-target", branch: null }),
}) })
const workspace = (yield* Effect.promise(() => created.json())) as Workspace.Info const workspace = (yield* Effect.promise(() => created.json())) as Workspace.Info
@ -394,7 +389,7 @@ describe("workspace HttpApi", () => {
const created = yield* request(WorkspacePaths.list, dir, { const created = yield* request(WorkspacePaths.list, dir, {
method: "POST", method: "POST",
headers: { "content-type": "application/json" }, headers: { "content-type": "application/json" },
body: JSON.stringify({ type: "remote-session-target", branch: null, extra: null }), body: JSON.stringify({ type: "remote-session-target", branch: null }),
}) })
const workspace = (yield* Effect.promise(() => created.json())) as Workspace.Info const workspace = (yield* Effect.promise(() => created.json())) as Workspace.Info
const session = yield* Session.Service.use((svc) => svc.create()).pipe( const session = yield* Session.Service.use((svc) => svc.create()).pipe(

View file

@ -5,7 +5,7 @@ import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
import { Bus } from "../../src/bus" import { Bus } from "../../src/bus"
import { SyncEvent } from "../../src/sync" import { SyncEvent } from "../../src/sync"
import { Database } from "@/storage/db" import { Database } from "@/storage/db"
import { EventTable } from "../../src/sync/event.sql" import { EventSequenceTable, EventTable } from "../../src/sync/event.sql"
import { MessageID } from "../../src/session/schema" import { MessageID } from "../../src/session/schema"
import { Flag } from "@opencode-ai/core/flag/flag" import { Flag } from "@opencode-ai/core/flag/flag"
import { initProjectors } from "../../src/server/projectors" import { initProjectors } from "../../src/server/projectors"
@ -252,5 +252,76 @@ describe("SyncEvent", () => {
}), }),
), ),
) )
it.live(
"claims unowned event sequence on replay with ownerID",
provideTmpdirInstance(() =>
Effect.gen(function* () {
const { Created } = setup()
const id = MessageID.ascending()
yield* SyncEvent.use.replay(
{
id: "evt_1",
type: SyncEvent.versionedType(Created.type, Created.version),
seq: 0,
aggregateID: id,
data: { id, name: "owned" },
},
{ publish: false, ownerID: "owner-1" },
)
const row = Database.use((db) =>
db
.select({ seq: EventSequenceTable.seq, ownerID: EventSequenceTable.owner_id })
.from(EventSequenceTable)
.get(),
)
expect(row).toEqual({ seq: 0, ownerID: "owner-1" })
}),
),
)
it.live(
"ignores replay from a different owner after sequence is claimed",
provideTmpdirInstance(() =>
Effect.gen(function* () {
const { Created } = setup()
const id = MessageID.ascending()
yield* SyncEvent.use.replay(
{
id: "evt_1",
type: SyncEvent.versionedType(Created.type, Created.version),
seq: 0,
aggregateID: id,
data: { id, name: "first" },
},
{ publish: false, ownerID: "owner-1" },
)
yield* SyncEvent.use.replay(
{
id: "evt_2",
type: SyncEvent.versionedType(Created.type, Created.version),
seq: 1,
aggregateID: id,
data: { id, name: "ignored" },
},
{ publish: false, ownerID: "owner-2" },
)
const events = Database.use((db) => db.select().from(EventTable).all())
const sequence = Database.use((db) =>
db
.select({ seq: EventSequenceTable.seq, ownerID: EventSequenceTable.owner_id })
.from(EventSequenceTable)
.get(),
)
expect(events).toHaveLength(1)
expect(events[0].id).toBe("evt_1")
expect(sequence).toEqual({ seq: 0, ownerID: "owner-1" })
}),
),
)
}) })
}) })

View file

@ -4,58 +4,84 @@ import { client } from "./client.gen.js"
import { buildClientParams, type Client, type Options as Options2, type TDataShape } from "./client/index.js" import { buildClientParams, type Client, type Options as Options2, type TDataShape } from "./client/index.js"
import type { import type {
AgentPartInput, AgentPartInput,
AppAgentsErrors,
AppAgentsResponses, AppAgentsResponses,
AppLogErrors, AppLogErrors,
AppLogResponses, AppLogResponses,
AppSkillsErrors,
AppSkillsResponses, AppSkillsResponses,
Auth as Auth3, Auth as Auth3,
AuthRemoveErrors, AuthRemoveErrors,
AuthRemoveResponses, AuthRemoveResponses,
AuthSetErrors, AuthSetErrors,
AuthSetResponses, AuthSetResponses,
CommandListErrors,
CommandListResponses, CommandListResponses,
Config as Config3, Config as Config3,
ConfigGetErrors,
ConfigGetResponses, ConfigGetResponses,
ConfigProvidersErrors,
ConfigProvidersResponses, ConfigProvidersResponses,
ConfigUpdateErrors, ConfigUpdateErrors,
ConfigUpdateResponses, ConfigUpdateResponses,
EventSubscribeErrors,
EventSubscribeResponses, EventSubscribeResponses,
EventTuiCommandExecute2, EventTuiCommandExecute2,
EventTuiPromptAppend2, EventTuiPromptAppend2,
EventTuiSessionSelect2, EventTuiSessionSelect2,
EventTuiToastShow2, EventTuiToastShow2,
ExperimentalConsoleGetErrors,
ExperimentalConsoleGetResponses, ExperimentalConsoleGetResponses,
ExperimentalConsoleListOrgsErrors,
ExperimentalConsoleListOrgsResponses, ExperimentalConsoleListOrgsResponses,
ExperimentalConsoleSwitchOrgResponses, ExperimentalConsoleSwitchOrgResponses,
ExperimentalResourceListErrors,
ExperimentalResourceListResponses, ExperimentalResourceListResponses,
ExperimentalSessionListErrors,
ExperimentalSessionListResponses, ExperimentalSessionListResponses,
ExperimentalWorkspaceAdapterListErrors,
ExperimentalWorkspaceAdapterListResponses, ExperimentalWorkspaceAdapterListResponses,
ExperimentalWorkspaceCreateErrors, ExperimentalWorkspaceCreateErrors,
ExperimentalWorkspaceCreateResponses, ExperimentalWorkspaceCreateResponses,
ExperimentalWorkspaceListErrors,
ExperimentalWorkspaceListResponses, ExperimentalWorkspaceListResponses,
ExperimentalWorkspaceRemoveErrors, ExperimentalWorkspaceRemoveErrors,
ExperimentalWorkspaceRemoveResponses, ExperimentalWorkspaceRemoveResponses,
ExperimentalWorkspaceSessionRestoreErrors, ExperimentalWorkspaceStatusErrors,
ExperimentalWorkspaceSessionRestoreResponses,
ExperimentalWorkspaceStatusResponses, ExperimentalWorkspaceStatusResponses,
ExperimentalWorkspaceWarpErrors,
ExperimentalWorkspaceWarpResponses,
FileListErrors,
FileListResponses, FileListResponses,
FilePartInput, FilePartInput,
FilePartSource, FilePartSource,
FileReadErrors,
FileReadResponses, FileReadResponses,
FileStatusErrors,
FileStatusResponses, FileStatusResponses,
FindFilesErrors,
FindFilesResponses, FindFilesResponses,
FindSymbolsErrors,
FindSymbolsResponses, FindSymbolsResponses,
FindTextErrors,
FindTextResponses, FindTextResponses,
FormatterStatusErrors,
FormatterStatusResponses, FormatterStatusResponses,
GlobalConfigGetErrors,
GlobalConfigGetResponses, GlobalConfigGetResponses,
GlobalConfigUpdateErrors, GlobalConfigUpdateErrors,
GlobalConfigUpdateResponses, GlobalConfigUpdateResponses,
GlobalDisposeErrors,
GlobalDisposeResponses, GlobalDisposeResponses,
GlobalEventErrors,
GlobalEventResponses, GlobalEventResponses,
GlobalHealthErrors,
GlobalHealthResponses, GlobalHealthResponses,
GlobalUpgradeErrors, GlobalUpgradeErrors,
GlobalUpgradeResponses, GlobalUpgradeResponses,
InstanceDisposeErrors,
InstanceDisposeResponses, InstanceDisposeResponses,
LspStatusErrors,
LspStatusResponses, LspStatusResponses,
McpAddErrors, McpAddErrors,
McpAddResponses, McpAddResponses,
@ -67,10 +93,13 @@ import type {
McpAuthRemoveResponses, McpAuthRemoveResponses,
McpAuthStartErrors, McpAuthStartErrors,
McpAuthStartResponses, McpAuthStartResponses,
McpConnectErrors,
McpConnectResponses, McpConnectResponses,
McpDisconnectErrors,
McpDisconnectResponses, McpDisconnectResponses,
McpLocalConfig, McpLocalConfig,
McpRemoteConfig, McpRemoteConfig,
McpStatusErrors,
McpStatusResponses, McpStatusResponses,
OutputFormat, OutputFormat,
Part as Part2, Part as Part2,
@ -78,20 +107,27 @@ import type {
PartDeleteResponses, PartDeleteResponses,
PartUpdateErrors, PartUpdateErrors,
PartUpdateResponses, PartUpdateResponses,
PathGetErrors,
PathGetResponses, PathGetResponses,
PermissionListErrors,
PermissionListResponses, PermissionListResponses,
PermissionReplyErrors, PermissionReplyErrors,
PermissionReplyResponses, PermissionReplyResponses,
PermissionRespondErrors, PermissionRespondErrors,
PermissionRespondResponses, PermissionRespondResponses,
PermissionRuleset, PermissionRuleset,
ProjectCurrentErrors,
ProjectCurrentResponses, ProjectCurrentResponses,
ProjectInitGitErrors,
ProjectInitGitResponses, ProjectInitGitResponses,
ProjectListErrors,
ProjectListResponses, ProjectListResponses,
ProjectUpdateErrors, ProjectUpdateErrors,
ProjectUpdateResponses, ProjectUpdateResponses,
Prompt, Prompt,
ProviderAuthErrors,
ProviderAuthResponses, ProviderAuthResponses,
ProviderListErrors,
ProviderListResponses, ProviderListResponses,
ProviderOauthAuthorizeErrors, ProviderOauthAuthorizeErrors,
ProviderOauthAuthorizeResponses, ProviderOauthAuthorizeResponses,
@ -105,13 +141,16 @@ import type {
PtyCreateResponses, PtyCreateResponses,
PtyGetErrors, PtyGetErrors,
PtyGetResponses, PtyGetResponses,
PtyListErrors,
PtyListResponses, PtyListResponses,
PtyRemoveErrors, PtyRemoveErrors,
PtyRemoveResponses, PtyRemoveResponses,
PtyShellsErrors,
PtyShellsResponses, PtyShellsResponses,
PtyUpdateErrors, PtyUpdateErrors,
PtyUpdateResponses, PtyUpdateResponses,
QuestionAnswer, QuestionAnswer,
QuestionListErrors,
QuestionListResponses, QuestionListResponses,
QuestionRejectErrors, QuestionRejectErrors,
QuestionRejectResponses, QuestionRejectResponses,
@ -130,12 +169,15 @@ import type {
SessionDeleteMessageResponses, SessionDeleteMessageResponses,
SessionDeleteResponses, SessionDeleteResponses,
SessionDelivery, SessionDelivery,
SessionDiffErrors,
SessionDiffResponses, SessionDiffResponses,
SessionForkErrors,
SessionForkResponses, SessionForkResponses,
SessionGetErrors, SessionGetErrors,
SessionGetResponses, SessionGetResponses,
SessionInitErrors, SessionInitErrors,
SessionInitResponses, SessionInitResponses,
SessionListErrors,
SessionListResponses, SessionListResponses,
SessionMessageErrors, SessionMessageErrors,
SessionMessageResponses, SessionMessageResponses,
@ -168,7 +210,10 @@ import type {
SyncHistoryListResponses, SyncHistoryListResponses,
SyncReplayErrors, SyncReplayErrors,
SyncReplayResponses, SyncReplayResponses,
SyncStartErrors,
SyncStartResponses, SyncStartResponses,
SyncStealErrors,
SyncStealResponses,
TextPartInput, TextPartInput,
ToolIdsErrors, ToolIdsErrors,
ToolIdsResponses, ToolIdsResponses,
@ -176,34 +221,50 @@ import type {
ToolListResponses, ToolListResponses,
TuiAppendPromptErrors, TuiAppendPromptErrors,
TuiAppendPromptResponses, TuiAppendPromptResponses,
TuiClearPromptErrors,
TuiClearPromptResponses, TuiClearPromptResponses,
TuiControlNextErrors,
TuiControlNextResponses, TuiControlNextResponses,
TuiControlResponseErrors,
TuiControlResponseResponses, TuiControlResponseResponses,
TuiExecuteCommandErrors, TuiExecuteCommandErrors,
TuiExecuteCommandResponses, TuiExecuteCommandResponses,
TuiOpenHelpErrors,
TuiOpenHelpResponses, TuiOpenHelpResponses,
TuiOpenModelsErrors,
TuiOpenModelsResponses, TuiOpenModelsResponses,
TuiOpenSessionsErrors,
TuiOpenSessionsResponses, TuiOpenSessionsResponses,
TuiOpenThemesErrors,
TuiOpenThemesResponses, TuiOpenThemesResponses,
TuiPublishErrors, TuiPublishErrors,
TuiPublishResponses, TuiPublishResponses,
TuiSelectSessionErrors, TuiSelectSessionErrors,
TuiSelectSessionResponses, TuiSelectSessionResponses,
TuiShowToastErrors,
TuiShowToastResponses, TuiShowToastResponses,
TuiSubmitPromptErrors,
TuiSubmitPromptResponses, TuiSubmitPromptResponses,
V2SessionCompactErrors,
V2SessionCompactResponses, V2SessionCompactResponses,
V2SessionContextErrors,
V2SessionContextResponses, V2SessionContextResponses,
V2SessionListErrors, V2SessionListErrors,
V2SessionListResponses, V2SessionListResponses,
V2SessionMessagesErrors, V2SessionMessagesErrors,
V2SessionMessagesResponses, V2SessionMessagesResponses,
V2SessionPromptErrors,
V2SessionPromptResponses, V2SessionPromptResponses,
V2SessionWaitErrors,
V2SessionWaitResponses, V2SessionWaitResponses,
VcsDiffErrors,
VcsDiffResponses, VcsDiffResponses,
VcsGetErrors,
VcsGetResponses, VcsGetResponses,
WorktreeCreateErrors, WorktreeCreateErrors,
WorktreeCreateInput, WorktreeCreateInput,
WorktreeCreateResponses, WorktreeCreateResponses,
WorktreeListErrors,
WorktreeListResponses, WorktreeListResponses,
WorktreeRemoveErrors, WorktreeRemoveErrors,
WorktreeRemoveInput, WorktreeRemoveInput,
@ -381,7 +442,7 @@ export class App extends HeyApiClient {
}, },
], ],
) )
return (options?.client ?? this.client).get<AppAgentsResponses, unknown, ThrowOnError>({ return (options?.client ?? this.client).get<AppAgentsResponses, AppAgentsErrors, ThrowOnError>({
url: "/agent", url: "/agent",
...options, ...options,
...params, ...params,
@ -411,7 +472,7 @@ export class App extends HeyApiClient {
}, },
], ],
) )
return (options?.client ?? this.client).get<AppSkillsResponses, unknown, ThrowOnError>({ return (options?.client ?? this.client).get<AppSkillsResponses, AppSkillsErrors, ThrowOnError>({
url: "/skill", url: "/skill",
...options, ...options,
...params, ...params,
@ -426,7 +487,7 @@ export class Config extends HeyApiClient {
* Retrieve the current global OpenCode configuration settings and preferences. * Retrieve the current global OpenCode configuration settings and preferences.
*/ */
public get<ThrowOnError extends boolean = false>(options?: Options<never, ThrowOnError>) { public get<ThrowOnError extends boolean = false>(options?: Options<never, ThrowOnError>) {
return (options?.client ?? this.client).get<GlobalConfigGetResponses, unknown, ThrowOnError>({ return (options?.client ?? this.client).get<GlobalConfigGetResponses, GlobalConfigGetErrors, ThrowOnError>({
url: "/global/config", url: "/global/config",
...options, ...options,
}) })
@ -464,7 +525,7 @@ export class Global extends HeyApiClient {
* Get health information about the OpenCode server. * Get health information about the OpenCode server.
*/ */
public health<ThrowOnError extends boolean = false>(options?: Options<never, ThrowOnError>) { public health<ThrowOnError extends boolean = false>(options?: Options<never, ThrowOnError>) {
return (options?.client ?? this.client).get<GlobalHealthResponses, unknown, ThrowOnError>({ return (options?.client ?? this.client).get<GlobalHealthResponses, GlobalHealthErrors, ThrowOnError>({
url: "/global/health", url: "/global/health",
...options, ...options,
}) })
@ -476,7 +537,7 @@ export class Global extends HeyApiClient {
* Subscribe to global events from the OpenCode system using server-sent events. * Subscribe to global events from the OpenCode system using server-sent events.
*/ */
public event<ThrowOnError extends boolean = false>(options?: Options<never, ThrowOnError>) { public event<ThrowOnError extends boolean = false>(options?: Options<never, ThrowOnError>) {
return (options?.client ?? this.client).sse.get<GlobalEventResponses, unknown, ThrowOnError>({ return (options?.client ?? this.client).sse.get<GlobalEventResponses, GlobalEventErrors, ThrowOnError>({
url: "/global/event", url: "/global/event",
...options, ...options,
}) })
@ -488,7 +549,7 @@ export class Global extends HeyApiClient {
* Clean up and dispose all OpenCode instances, releasing all resources. * Clean up and dispose all OpenCode instances, releasing all resources.
*/ */
public dispose<ThrowOnError extends boolean = false>(options?: Options<never, ThrowOnError>) { public dispose<ThrowOnError extends boolean = false>(options?: Options<never, ThrowOnError>) {
return (options?.client ?? this.client).post<GlobalDisposeResponses, unknown, ThrowOnError>({ return (options?.client ?? this.client).post<GlobalDisposeResponses, GlobalDisposeErrors, ThrowOnError>({
url: "/global/dispose", url: "/global/dispose",
...options, ...options,
}) })
@ -548,7 +609,7 @@ export class Event extends HeyApiClient {
}, },
], ],
) )
return (options?.client ?? this.client).sse.get<EventSubscribeResponses, unknown, ThrowOnError>({ return (options?.client ?? this.client).sse.get<EventSubscribeResponses, EventSubscribeErrors, ThrowOnError>({
url: "/event", url: "/event",
...options, ...options,
...params, ...params,
@ -580,7 +641,7 @@ export class Config2 extends HeyApiClient {
}, },
], ],
) )
return (options?.client ?? this.client).get<ConfigGetResponses, unknown, ThrowOnError>({ return (options?.client ?? this.client).get<ConfigGetResponses, ConfigGetErrors, ThrowOnError>({
url: "/config", url: "/config",
...options, ...options,
...params, ...params,
@ -647,7 +708,7 @@ export class Config2 extends HeyApiClient {
}, },
], ],
) )
return (options?.client ?? this.client).get<ConfigProvidersResponses, unknown, ThrowOnError>({ return (options?.client ?? this.client).get<ConfigProvidersResponses, ConfigProvidersErrors, ThrowOnError>({
url: "/config/providers", url: "/config/providers",
...options, ...options,
...params, ...params,
@ -679,7 +740,11 @@ export class Console extends HeyApiClient {
}, },
], ],
) )
return (options?.client ?? this.client).get<ExperimentalConsoleGetResponses, unknown, ThrowOnError>({ return (options?.client ?? this.client).get<
ExperimentalConsoleGetResponses,
ExperimentalConsoleGetErrors,
ThrowOnError
>({
url: "/experimental/console", url: "/experimental/console",
...options, ...options,
...params, ...params,
@ -709,7 +774,11 @@ export class Console extends HeyApiClient {
}, },
], ],
) )
return (options?.client ?? this.client).get<ExperimentalConsoleListOrgsResponses, unknown, ThrowOnError>({ return (options?.client ?? this.client).get<
ExperimentalConsoleListOrgsResponses,
ExperimentalConsoleListOrgsErrors,
ThrowOnError
>({
url: "/experimental/console/orgs", url: "/experimental/console/orgs",
...options, ...options,
...params, ...params,
@ -792,7 +861,11 @@ export class Session extends HeyApiClient {
}, },
], ],
) )
return (options?.client ?? this.client).get<ExperimentalSessionListResponses, unknown, ThrowOnError>({ return (options?.client ?? this.client).get<
ExperimentalSessionListResponses,
ExperimentalSessionListErrors,
ThrowOnError
>({
url: "/experimental/session", url: "/experimental/session",
...options, ...options,
...params, ...params,
@ -824,7 +897,11 @@ export class Resource extends HeyApiClient {
}, },
], ],
) )
return (options?.client ?? this.client).get<ExperimentalResourceListResponses, unknown, ThrowOnError>({ return (options?.client ?? this.client).get<
ExperimentalResourceListResponses,
ExperimentalResourceListErrors,
ThrowOnError
>({
url: "/experimental/resource", url: "/experimental/resource",
...options, ...options,
...params, ...params,
@ -856,7 +933,11 @@ export class Adapter extends HeyApiClient {
}, },
], ],
) )
return (options?.client ?? this.client).get<ExperimentalWorkspaceAdapterListResponses, unknown, ThrowOnError>({ return (options?.client ?? this.client).get<
ExperimentalWorkspaceAdapterListResponses,
ExperimentalWorkspaceAdapterListErrors,
ThrowOnError
>({
url: "/experimental/workspace/adapter", url: "/experimental/workspace/adapter",
...options, ...options,
...params, ...params,
@ -888,7 +969,11 @@ export class Workspace extends HeyApiClient {
}, },
], ],
) )
return (options?.client ?? this.client).get<ExperimentalWorkspaceListResponses, unknown, ThrowOnError>({ return (options?.client ?? this.client).get<
ExperimentalWorkspaceListResponses,
ExperimentalWorkspaceListErrors,
ThrowOnError
>({
url: "/experimental/workspace", url: "/experimental/workspace",
...options, ...options,
...params, ...params,
@ -965,7 +1050,11 @@ export class Workspace extends HeyApiClient {
}, },
], ],
) )
return (options?.client ?? this.client).get<ExperimentalWorkspaceStatusResponses, unknown, ThrowOnError>({ return (options?.client ?? this.client).get<
ExperimentalWorkspaceStatusResponses,
ExperimentalWorkspaceStatusErrors,
ThrowOnError
>({
url: "/experimental/workspace/status", url: "/experimental/workspace/status",
...options, ...options,
...params, ...params,
@ -1009,15 +1098,15 @@ 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, or detach it to the local project.
*/ */
public sessionRestore<ThrowOnError extends boolean = false>( public warp<ThrowOnError extends boolean = false>(
parameters: { parameters?: {
id: string
directory?: string directory?: string
workspace?: string workspace?: string
id?: string | null
sessionID?: string sessionID?: string
}, },
options?: Options<never, ThrowOnError>, options?: Options<never, ThrowOnError>,
@ -1027,20 +1116,20 @@ export class Workspace extends HeyApiClient {
[ [
{ {
args: [ args: [
{ in: "path", key: "id" },
{ in: "query", key: "directory" }, { in: "query", key: "directory" },
{ in: "query", key: "workspace" }, { in: "query", key: "workspace" },
{ in: "body", key: "id" },
{ in: "body", key: "sessionID" }, { in: "body", key: "sessionID" },
], ],
}, },
], ],
) )
return (options?.client ?? this.client).post< return (options?.client ?? this.client).post<
ExperimentalWorkspaceSessionRestoreResponses, ExperimentalWorkspaceWarpResponses,
ExperimentalWorkspaceSessionRestoreErrors, ExperimentalWorkspaceWarpErrors,
ThrowOnError ThrowOnError
>({ >({
url: "/experimental/workspace/{id}/session-restore", url: "/experimental/workspace/warp",
...options, ...options,
...params, ...params,
headers: { headers: {
@ -1206,7 +1295,7 @@ export class Worktree extends HeyApiClient {
}, },
], ],
) )
return (options?.client ?? this.client).get<WorktreeListResponses, unknown, ThrowOnError>({ return (options?.client ?? this.client).get<WorktreeListResponses, WorktreeListErrors, ThrowOnError>({
url: "/experimental/worktree", url: "/experimental/worktree",
...options, ...options,
...params, ...params,
@ -1314,7 +1403,7 @@ export class Find extends HeyApiClient {
}, },
], ],
) )
return (options?.client ?? this.client).get<FindTextResponses, unknown, ThrowOnError>({ return (options?.client ?? this.client).get<FindTextResponses, FindTextErrors, ThrowOnError>({
url: "/find", url: "/find",
...options, ...options,
...params, ...params,
@ -1352,7 +1441,7 @@ export class Find extends HeyApiClient {
}, },
], ],
) )
return (options?.client ?? this.client).get<FindFilesResponses, unknown, ThrowOnError>({ return (options?.client ?? this.client).get<FindFilesResponses, FindFilesErrors, ThrowOnError>({
url: "/find/file", url: "/find/file",
...options, ...options,
...params, ...params,
@ -1384,7 +1473,7 @@ export class Find extends HeyApiClient {
}, },
], ],
) )
return (options?.client ?? this.client).get<FindSymbolsResponses, unknown, ThrowOnError>({ return (options?.client ?? this.client).get<FindSymbolsResponses, FindSymbolsErrors, ThrowOnError>({
url: "/find/symbol", url: "/find/symbol",
...options, ...options,
...params, ...params,
@ -1418,7 +1507,7 @@ export class File extends HeyApiClient {
}, },
], ],
) )
return (options?.client ?? this.client).get<FileListResponses, unknown, ThrowOnError>({ return (options?.client ?? this.client).get<FileListResponses, FileListErrors, ThrowOnError>({
url: "/file", url: "/file",
...options, ...options,
...params, ...params,
@ -1450,7 +1539,7 @@ export class File extends HeyApiClient {
}, },
], ],
) )
return (options?.client ?? this.client).get<FileReadResponses, unknown, ThrowOnError>({ return (options?.client ?? this.client).get<FileReadResponses, FileReadErrors, ThrowOnError>({
url: "/file/content", url: "/file/content",
...options, ...options,
...params, ...params,
@ -1480,7 +1569,7 @@ export class File extends HeyApiClient {
}, },
], ],
) )
return (options?.client ?? this.client).get<FileStatusResponses, unknown, ThrowOnError>({ return (options?.client ?? this.client).get<FileStatusResponses, FileStatusErrors, ThrowOnError>({
url: "/file/status", url: "/file/status",
...options, ...options,
...params, ...params,
@ -1512,7 +1601,7 @@ export class Instance extends HeyApiClient {
}, },
], ],
) )
return (options?.client ?? this.client).post<InstanceDisposeResponses, unknown, ThrowOnError>({ return (options?.client ?? this.client).post<InstanceDisposeResponses, InstanceDisposeErrors, ThrowOnError>({
url: "/instance/dispose", url: "/instance/dispose",
...options, ...options,
...params, ...params,
@ -1544,7 +1633,7 @@ export class Path extends HeyApiClient {
}, },
], ],
) )
return (options?.client ?? this.client).get<PathGetResponses, unknown, ThrowOnError>({ return (options?.client ?? this.client).get<PathGetResponses, PathGetErrors, ThrowOnError>({
url: "/path", url: "/path",
...options, ...options,
...params, ...params,
@ -1576,7 +1665,7 @@ export class Vcs extends HeyApiClient {
}, },
], ],
) )
return (options?.client ?? this.client).get<VcsGetResponses, unknown, ThrowOnError>({ return (options?.client ?? this.client).get<VcsGetResponses, VcsGetErrors, ThrowOnError>({
url: "/vcs", url: "/vcs",
...options, ...options,
...params, ...params,
@ -1608,7 +1697,7 @@ export class Vcs extends HeyApiClient {
}, },
], ],
) )
return (options?.client ?? this.client).get<VcsDiffResponses, unknown, ThrowOnError>({ return (options?.client ?? this.client).get<VcsDiffResponses, VcsDiffErrors, ThrowOnError>({
url: "/vcs/diff", url: "/vcs/diff",
...options, ...options,
...params, ...params,
@ -1640,7 +1729,7 @@ export class Command extends HeyApiClient {
}, },
], ],
) )
return (options?.client ?? this.client).get<CommandListResponses, unknown, ThrowOnError>({ return (options?.client ?? this.client).get<CommandListResponses, CommandListErrors, ThrowOnError>({
url: "/command", url: "/command",
...options, ...options,
...params, ...params,
@ -1672,7 +1761,7 @@ export class Lsp extends HeyApiClient {
}, },
], ],
) )
return (options?.client ?? this.client).get<LspStatusResponses, unknown, ThrowOnError>({ return (options?.client ?? this.client).get<LspStatusResponses, LspStatusErrors, ThrowOnError>({
url: "/lsp", url: "/lsp",
...options, ...options,
...params, ...params,
@ -1704,7 +1793,7 @@ export class Formatter extends HeyApiClient {
}, },
], ],
) )
return (options?.client ?? this.client).get<FormatterStatusResponses, unknown, ThrowOnError>({ return (options?.client ?? this.client).get<FormatterStatusResponses, FormatterStatusErrors, ThrowOnError>({
url: "/formatter", url: "/formatter",
...options, ...options,
...params, ...params,
@ -1875,7 +1964,7 @@ export class Mcp extends HeyApiClient {
}, },
], ],
) )
return (options?.client ?? this.client).get<McpStatusResponses, unknown, ThrowOnError>({ return (options?.client ?? this.client).get<McpStatusResponses, McpStatusErrors, ThrowOnError>({
url: "/mcp", url: "/mcp",
...options, ...options,
...params, ...params,
@ -1944,7 +2033,7 @@ export class Mcp extends HeyApiClient {
}, },
], ],
) )
return (options?.client ?? this.client).post<McpConnectResponses, unknown, ThrowOnError>({ return (options?.client ?? this.client).post<McpConnectResponses, McpConnectErrors, ThrowOnError>({
url: "/mcp/{name}/connect", url: "/mcp/{name}/connect",
...options, ...options,
...params, ...params,
@ -1974,7 +2063,7 @@ export class Mcp extends HeyApiClient {
}, },
], ],
) )
return (options?.client ?? this.client).post<McpDisconnectResponses, unknown, ThrowOnError>({ return (options?.client ?? this.client).post<McpDisconnectResponses, McpDisconnectErrors, ThrowOnError>({
url: "/mcp/{name}/disconnect", url: "/mcp/{name}/disconnect",
...options, ...options,
...params, ...params,
@ -2011,7 +2100,7 @@ export class Project extends HeyApiClient {
}, },
], ],
) )
return (options?.client ?? this.client).get<ProjectListResponses, unknown, ThrowOnError>({ return (options?.client ?? this.client).get<ProjectListResponses, ProjectListErrors, ThrowOnError>({
url: "/project", url: "/project",
...options, ...options,
...params, ...params,
@ -2041,7 +2130,7 @@ export class Project extends HeyApiClient {
}, },
], ],
) )
return (options?.client ?? this.client).get<ProjectCurrentResponses, unknown, ThrowOnError>({ return (options?.client ?? this.client).get<ProjectCurrentResponses, ProjectCurrentErrors, ThrowOnError>({
url: "/project/current", url: "/project/current",
...options, ...options,
...params, ...params,
@ -2071,7 +2160,7 @@ export class Project extends HeyApiClient {
}, },
], ],
) )
return (options?.client ?? this.client).post<ProjectInitGitResponses, unknown, ThrowOnError>({ return (options?.client ?? this.client).post<ProjectInitGitResponses, ProjectInitGitErrors, ThrowOnError>({
url: "/project/git/init", url: "/project/git/init",
...options, ...options,
...params, ...params,
@ -2155,7 +2244,7 @@ export class Pty extends HeyApiClient {
}, },
], ],
) )
return (options?.client ?? this.client).get<PtyShellsResponses, unknown, ThrowOnError>({ return (options?.client ?? this.client).get<PtyShellsResponses, PtyShellsErrors, ThrowOnError>({
url: "/pty/shells", url: "/pty/shells",
...options, ...options,
...params, ...params,
@ -2185,7 +2274,7 @@ export class Pty extends HeyApiClient {
}, },
], ],
) )
return (options?.client ?? this.client).get<PtyListResponses, unknown, ThrowOnError>({ return (options?.client ?? this.client).get<PtyListResponses, PtyListErrors, ThrowOnError>({
url: "/pty", url: "/pty",
...options, ...options,
...params, ...params,
@ -2436,7 +2525,7 @@ export class Question extends HeyApiClient {
}, },
], ],
) )
return (options?.client ?? this.client).get<QuestionListResponses, unknown, ThrowOnError>({ return (options?.client ?? this.client).get<QuestionListResponses, QuestionListErrors, ThrowOnError>({
url: "/question", url: "/question",
...options, ...options,
...params, ...params,
@ -2539,7 +2628,7 @@ export class Permission extends HeyApiClient {
}, },
], ],
) )
return (options?.client ?? this.client).get<PermissionListResponses, unknown, ThrowOnError>({ return (options?.client ?? this.client).get<PermissionListResponses, PermissionListErrors, ThrowOnError>({
url: "/permission", url: "/permission",
...options, ...options,
...params, ...params,
@ -2749,7 +2838,7 @@ export class Provider extends HeyApiClient {
}, },
], ],
) )
return (options?.client ?? this.client).get<ProviderListResponses, unknown, ThrowOnError>({ return (options?.client ?? this.client).get<ProviderListResponses, ProviderListErrors, ThrowOnError>({
url: "/provider", url: "/provider",
...options, ...options,
...params, ...params,
@ -2779,7 +2868,7 @@ export class Provider extends HeyApiClient {
}, },
], ],
) )
return (options?.client ?? this.client).get<ProviderAuthResponses, unknown, ThrowOnError>({ return (options?.client ?? this.client).get<ProviderAuthResponses, ProviderAuthErrors, ThrowOnError>({
url: "/provider/auth", url: "/provider/auth",
...options, ...options,
...params, ...params,
@ -2828,7 +2917,7 @@ export class Session2 extends HeyApiClient {
}, },
], ],
) )
return (options?.client ?? this.client).get<SessionListResponses, unknown, ThrowOnError>({ return (options?.client ?? this.client).get<SessionListResponses, SessionListErrors, ThrowOnError>({
url: "/session", url: "/session",
...options, ...options,
...params, ...params,
@ -3116,7 +3205,7 @@ export class Session2 extends HeyApiClient {
}, },
], ],
) )
return (options?.client ?? this.client).get<SessionDiffResponses, unknown, ThrowOnError>({ return (options?.client ?? this.client).get<SessionDiffResponses, SessionDiffErrors, ThrowOnError>({
url: "/session/{sessionID}/diff", url: "/session/{sessionID}/diff",
...options, ...options,
...params, ...params,
@ -3318,7 +3407,7 @@ export class Session2 extends HeyApiClient {
}, },
], ],
) )
return (options?.client ?? this.client).post<SessionForkResponses, unknown, ThrowOnError>({ return (options?.client ?? this.client).post<SessionForkResponses, SessionForkErrors, ThrowOnError>({
url: "/session/{sessionID}/fork", url: "/session/{sessionID}/fork",
...options, ...options,
...params, ...params,
@ -3894,7 +3983,7 @@ export class Sync extends HeyApiClient {
}, },
], ],
) )
return (options?.client ?? this.client).post<SyncStartResponses, unknown, ThrowOnError>({ return (options?.client ?? this.client).post<SyncStartResponses, SyncStartErrors, ThrowOnError>({
url: "/sync/start", url: "/sync/start",
...options, ...options,
...params, ...params,
@ -3956,6 +4045,43 @@ export class Sync extends HeyApiClient {
}) })
} }
/**
* 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 private _history?: History
get history(): History { get history(): History {
return (this._history ??= new History({ client: this.client })) return (this._history ??= new History({ client: this.client }))
@ -4022,7 +4148,7 @@ export class Session3 extends HeyApiClient {
}, },
], ],
) )
return (options?.client ?? this.client).post<V2SessionPromptResponses, unknown, ThrowOnError>({ return (options?.client ?? this.client).post<V2SessionPromptResponses, V2SessionPromptErrors, ThrowOnError>({
url: "/api/session/{sessionID}/prompt", url: "/api/session/{sessionID}/prompt",
...options, ...options,
...params, ...params,
@ -4059,7 +4185,7 @@ export class Session3 extends HeyApiClient {
}, },
], ],
) )
return (options?.client ?? this.client).post<V2SessionCompactResponses, unknown, ThrowOnError>({ return (options?.client ?? this.client).post<V2SessionCompactResponses, V2SessionCompactErrors, ThrowOnError>({
url: "/api/session/{sessionID}/compact", url: "/api/session/{sessionID}/compact",
...options, ...options,
...params, ...params,
@ -4091,7 +4217,7 @@ export class Session3 extends HeyApiClient {
}, },
], ],
) )
return (options?.client ?? this.client).post<V2SessionWaitResponses, unknown, ThrowOnError>({ return (options?.client ?? this.client).post<V2SessionWaitResponses, V2SessionWaitErrors, ThrowOnError>({
url: "/api/session/{sessionID}/wait", url: "/api/session/{sessionID}/wait",
...options, ...options,
...params, ...params,
@ -4123,7 +4249,7 @@ export class Session3 extends HeyApiClient {
}, },
], ],
) )
return (options?.client ?? this.client).get<V2SessionContextResponses, unknown, ThrowOnError>({ return (options?.client ?? this.client).get<V2SessionContextResponses, V2SessionContextErrors, ThrowOnError>({
url: "/api/session/{sessionID}/context", url: "/api/session/{sessionID}/context",
...options, ...options,
...params, ...params,
@ -4194,7 +4320,7 @@ export class Control extends HeyApiClient {
}, },
], ],
) )
return (options?.client ?? this.client).get<TuiControlNextResponses, unknown, ThrowOnError>({ return (options?.client ?? this.client).get<TuiControlNextResponses, TuiControlNextErrors, ThrowOnError>({
url: "/tui/control/next", url: "/tui/control/next",
...options, ...options,
...params, ...params,
@ -4226,7 +4352,7 @@ export class Control extends HeyApiClient {
}, },
], ],
) )
return (options?.client ?? this.client).post<TuiControlResponseResponses, unknown, ThrowOnError>({ return (options?.client ?? this.client).post<TuiControlResponseResponses, TuiControlResponseErrors, ThrowOnError>({
url: "/tui/control/response", url: "/tui/control/response",
...options, ...options,
...params, ...params,
@ -4300,7 +4426,7 @@ export class Tui extends HeyApiClient {
}, },
], ],
) )
return (options?.client ?? this.client).post<TuiOpenHelpResponses, unknown, ThrowOnError>({ return (options?.client ?? this.client).post<TuiOpenHelpResponses, TuiOpenHelpErrors, ThrowOnError>({
url: "/tui/open-help", url: "/tui/open-help",
...options, ...options,
...params, ...params,
@ -4330,7 +4456,7 @@ export class Tui extends HeyApiClient {
}, },
], ],
) )
return (options?.client ?? this.client).post<TuiOpenSessionsResponses, unknown, ThrowOnError>({ return (options?.client ?? this.client).post<TuiOpenSessionsResponses, TuiOpenSessionsErrors, ThrowOnError>({
url: "/tui/open-sessions", url: "/tui/open-sessions",
...options, ...options,
...params, ...params,
@ -4360,7 +4486,7 @@ export class Tui extends HeyApiClient {
}, },
], ],
) )
return (options?.client ?? this.client).post<TuiOpenThemesResponses, unknown, ThrowOnError>({ return (options?.client ?? this.client).post<TuiOpenThemesResponses, TuiOpenThemesErrors, ThrowOnError>({
url: "/tui/open-themes", url: "/tui/open-themes",
...options, ...options,
...params, ...params,
@ -4390,7 +4516,7 @@ export class Tui extends HeyApiClient {
}, },
], ],
) )
return (options?.client ?? this.client).post<TuiOpenModelsResponses, unknown, ThrowOnError>({ return (options?.client ?? this.client).post<TuiOpenModelsResponses, TuiOpenModelsErrors, ThrowOnError>({
url: "/tui/open-models", url: "/tui/open-models",
...options, ...options,
...params, ...params,
@ -4420,7 +4546,7 @@ export class Tui extends HeyApiClient {
}, },
], ],
) )
return (options?.client ?? this.client).post<TuiSubmitPromptResponses, unknown, ThrowOnError>({ return (options?.client ?? this.client).post<TuiSubmitPromptResponses, TuiSubmitPromptErrors, ThrowOnError>({
url: "/tui/submit-prompt", url: "/tui/submit-prompt",
...options, ...options,
...params, ...params,
@ -4450,7 +4576,7 @@ export class Tui extends HeyApiClient {
}, },
], ],
) )
return (options?.client ?? this.client).post<TuiClearPromptResponses, unknown, ThrowOnError>({ return (options?.client ?? this.client).post<TuiClearPromptResponses, TuiClearPromptErrors, ThrowOnError>({
url: "/tui/clear-prompt", url: "/tui/clear-prompt",
...options, ...options,
...params, ...params,
@ -4525,7 +4651,7 @@ export class Tui extends HeyApiClient {
}, },
], ],
) )
return (options?.client ?? this.client).post<TuiShowToastResponses, unknown, ThrowOnError>({ return (options?.client ?? this.client).post<TuiShowToastResponses, TuiShowToastErrors, ThrowOnError>({
url: "/tui/show-toast", url: "/tui/show-toast",
...options, ...options,
...params, ...params,

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff