mirror of
https://fastgit.cc/https://github.com/anomalyco/opencode
synced 2026-05-05 08:10:25 +08:00
Compare commits
2 Commits
dev
...
jlongster/
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
098258817a | ||
|
|
81626affb1 |
@@ -0,0 +1 @@
|
||||
ALTER TABLE `event_sequence` ADD `owner_id` text;
|
||||
File diff suppressed because it is too large
Load Diff
@@ -776,9 +776,9 @@ const scenarios: Scenario[] = [
|
||||
}))
|
||||
.status(200),
|
||||
http
|
||||
.post("/experimental/workspace/{id}/session-restore", "experimental.workspace.sessionRestore")
|
||||
.post("/experimental/workspace/warp", "experimental.workspace.warp")
|
||||
.at((ctx) => ({
|
||||
path: route("/experimental/workspace/{id}/session-restore", { id: "wrk_httpapi_missing" }),
|
||||
path: "/experimental/workspace/warp",
|
||||
headers: ctx.headers(),
|
||||
body: {},
|
||||
}))
|
||||
|
||||
@@ -2,7 +2,7 @@ import { useDialog } from "@tui/ui/dialog"
|
||||
import { DialogSelect } from "@tui/ui/dialog-select"
|
||||
import { useRoute } from "@tui/context/route"
|
||||
import { useSync } from "@tui/context/sync"
|
||||
import { createMemo, createResource, createSignal, onMount } from "solid-js"
|
||||
import { createMemo, createResource, createSignal, onMount, type JSX } from "solid-js"
|
||||
import { Locale } from "@/util/locale"
|
||||
import { useProject } from "@tui/context/project"
|
||||
import { useKeybind } from "../context/keybind"
|
||||
@@ -10,15 +10,13 @@ import { useTheme } from "../context/theme"
|
||||
import { useSDK } from "../context/sdk"
|
||||
import { Flag } from "@opencode-ai/core/flag/flag"
|
||||
import { DialogSessionRename } from "./dialog-session-rename"
|
||||
import { Keybind } from "@/util/keybind"
|
||||
import { createDebouncedSignal } from "../util/signal"
|
||||
import { useToast } from "../ui/toast"
|
||||
import { DialogWorkspaceCreate, openWorkspaceSession, restoreWorkspaceSession } from "./dialog-workspace-create"
|
||||
import { openWorkspaceSelect, type WorkspaceSelection, warpWorkspaceSession } from "./dialog-workspace-create"
|
||||
import { Spinner } from "./spinner"
|
||||
import { errorMessage } from "@/util/error"
|
||||
import { DialogSessionDeleteFailed } from "./dialog-session-delete-failed"
|
||||
|
||||
type WorkspaceStatus = "connected" | "connecting" | "disconnected" | "error"
|
||||
import { WorkspaceLabel } from "./workspace-label"
|
||||
|
||||
export function DialogSessionList() {
|
||||
const dialog = useDialog()
|
||||
@@ -44,26 +42,39 @@ export function DialogSessionList() {
|
||||
const currentSessionID = createMemo(() => (route.data.type === "session" ? route.data.sessionID : undefined))
|
||||
const sessions = createMemo(() => searchResults() ?? sync.data.session)
|
||||
|
||||
function createWorkspace() {
|
||||
dialog.replace(() => (
|
||||
<DialogWorkspaceCreate
|
||||
onSelect={(workspaceID) =>
|
||||
openWorkspaceSession({
|
||||
dialog,
|
||||
route,
|
||||
sdk,
|
||||
sync,
|
||||
toast,
|
||||
workspaceID,
|
||||
})
|
||||
}
|
||||
/>
|
||||
))
|
||||
}
|
||||
|
||||
function recover(session: NonNullable<ReturnType<typeof sessions>[number]>) {
|
||||
const workspace = project.workspace.get(session.workspaceID!)
|
||||
const list = () => dialog.replace(() => <DialogSessionList />)
|
||||
const warp = async (selection: WorkspaceSelection) => {
|
||||
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(() => (
|
||||
<DialogSessionDeleteFailed
|
||||
session={session.title}
|
||||
@@ -90,22 +101,15 @@ export function DialogSessionList() {
|
||||
return true
|
||||
}}
|
||||
onRestore={() => {
|
||||
dialog.replace(() => (
|
||||
<DialogWorkspaceCreate
|
||||
onSelect={(workspaceID) =>
|
||||
restoreWorkspaceSession({
|
||||
dialog,
|
||||
sdk,
|
||||
sync,
|
||||
project,
|
||||
toast,
|
||||
workspaceID,
|
||||
sessionID: session.id,
|
||||
done: list,
|
||||
})
|
||||
}
|
||||
/>
|
||||
))
|
||||
void openWorkspaceSelect({
|
||||
dialog,
|
||||
sdk,
|
||||
sync,
|
||||
toast,
|
||||
onSelect: (selection) => {
|
||||
void warp(selection)
|
||||
},
|
||||
})
|
||||
return false
|
||||
}}
|
||||
/>
|
||||
@@ -124,30 +128,17 @@ export function DialogSessionList() {
|
||||
.map((x) => {
|
||||
const workspace = x.workspaceID ? project.workspace.get(x.workspaceID) : undefined
|
||||
|
||||
let workspaceStatus: WorkspaceStatus | null = null
|
||||
if (x.workspaceID) {
|
||||
workspaceStatus = project.workspace.status(x.workspaceID) || "error"
|
||||
}
|
||||
|
||||
let footer = ""
|
||||
let footer: JSX.Element | string = ""
|
||||
if (Flag.OPENCODE_EXPERIMENTAL_WORKSPACES) {
|
||||
if (x.workspaceID) {
|
||||
let desc = "unknown"
|
||||
if (workspace) {
|
||||
desc = `${workspace.type}: ${workspace.name}`
|
||||
}
|
||||
|
||||
footer = (
|
||||
<>
|
||||
{desc}{" "}
|
||||
<span
|
||||
style={{
|
||||
fg: workspaceStatus === "connected" ? theme.success : theme.error,
|
||||
}}
|
||||
>
|
||||
●
|
||||
</span>
|
||||
</>
|
||||
footer = workspace ? (
|
||||
<WorkspaceLabel
|
||||
type={workspace.type}
|
||||
name={workspace.name}
|
||||
status={project.workspace.status(x.workspaceID) ?? "error"}
|
||||
/>
|
||||
) : (
|
||||
<WorkspaceLabel type="unknown" name={x.workspaceID} status="error" />
|
||||
)
|
||||
}
|
||||
} else {
|
||||
@@ -250,15 +241,6 @@ export function DialogSessionList() {
|
||||
dialog.replace(() => <DialogSessionRename session={option.value} />)
|
||||
},
|
||||
},
|
||||
{
|
||||
keybind: Keybind.parse("ctrl+w")[0],
|
||||
title: "new workspace",
|
||||
side: "right",
|
||||
disabled: !Flag.OPENCODE_EXPERIMENTAL_WORKSPACES,
|
||||
onTrigger: () => {
|
||||
createWorkspace()
|
||||
},
|
||||
},
|
||||
]}
|
||||
/>
|
||||
)
|
||||
|
||||
@@ -1,11 +1,9 @@
|
||||
import { createOpencodeClient } from "@opencode-ai/sdk/v2"
|
||||
import type { Workspace } from "@opencode-ai/sdk/v2"
|
||||
import { useDialog } from "@tui/ui/dialog"
|
||||
import { DialogSelect } from "@tui/ui/dialog-select"
|
||||
import { useRoute } from "@tui/context/route"
|
||||
import { DialogSelect, type DialogSelectOption } from "@tui/ui/dialog-select"
|
||||
import { useSync } from "@tui/context/sync"
|
||||
import { useProject } from "@tui/context/project"
|
||||
import { createMemo, createSignal, onMount } from "solid-js"
|
||||
import { setTimeout as sleep } from "node:timers/promises"
|
||||
import { errorMessage } from "@/util/error"
|
||||
import { useSDK } from "../context/sdk"
|
||||
import { useToast } from "../ui/toast"
|
||||
@@ -16,184 +14,217 @@ type Adapter = {
|
||||
description: string
|
||||
}
|
||||
|
||||
function scoped(sdk: ReturnType<typeof useSDK>, sync: ReturnType<typeof useSync>, workspaceID: string) {
|
||||
return createOpencodeClient({
|
||||
baseUrl: sdk.url,
|
||||
fetch: sdk.fetch,
|
||||
directory: sync.path.directory || sdk.directory,
|
||||
experimental_workspaceID: workspaceID,
|
||||
})
|
||||
}
|
||||
export type WorkspaceSelection =
|
||||
| {
|
||||
type: "none"
|
||||
}
|
||||
| {
|
||||
type: "new"
|
||||
workspaceType: string
|
||||
workspaceName: string
|
||||
}
|
||||
| {
|
||||
type: "existing"
|
||||
workspaceID: string
|
||||
workspaceType: string
|
||||
workspaceName: string
|
||||
}
|
||||
|
||||
export async function openWorkspaceSession(input: {
|
||||
dialog: ReturnType<typeof useDialog>
|
||||
route: ReturnType<typeof useRoute>
|
||||
type WorkspaceSelectValue = WorkspaceSelection | { type: "existing-list" }
|
||||
type ExistingWorkspaceSelectValue = { workspace: Workspace }
|
||||
|
||||
async function loadWorkspaceAdapters(input: {
|
||||
sdk: ReturnType<typeof useSDK>
|
||||
sync: ReturnType<typeof useSync>
|
||||
toast: ReturnType<typeof useToast>
|
||||
workspaceID: string
|
||||
}) {
|
||||
const client = scoped(input.sdk, input.sync, input.workspaceID)
|
||||
|
||||
while (true) {
|
||||
const result = await client.session.create({ workspace: input.workspaceID }).catch(() => undefined)
|
||||
if (!result) {
|
||||
input.toast.show({
|
||||
message: "Failed to create workspace session",
|
||||
variant: "error",
|
||||
})
|
||||
return
|
||||
}
|
||||
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
|
||||
}
|
||||
const dir = input.sync.path.directory || input.sdk.directory
|
||||
const url = new URL("/experimental/workspace/adapter", input.sdk.url)
|
||||
if (dir) url.searchParams.set("directory", dir)
|
||||
const res = await input.sdk
|
||||
.fetch(url)
|
||||
.then((x) => x.json() as Promise<Adapter[]>)
|
||||
.catch(() => undefined)
|
||||
if (res) return res
|
||||
input.toast.show({
|
||||
message: "Failed to load workspace adapters",
|
||||
variant: "error",
|
||||
})
|
||||
}
|
||||
|
||||
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>
|
||||
sdk: ReturnType<typeof useSDK>
|
||||
sync: ReturnType<typeof useSync>
|
||||
project: ReturnType<typeof useProject>
|
||||
toast: ReturnType<typeof useToast>
|
||||
workspaceID: string
|
||||
workspaceID: string | null
|
||||
sessionID: string
|
||||
done?: () => void
|
||||
}) {
|
||||
showSuccessToast?: boolean
|
||||
}): Promise<boolean> {
|
||||
const result = await input.sdk.client.experimental.workspace
|
||||
.sessionRestore({ id: input.workspaceID, sessionID: input.sessionID })
|
||||
.warp({
|
||||
id: input.workspaceID,
|
||||
sessionID: input.sessionID,
|
||||
})
|
||||
.catch(() => undefined)
|
||||
if (!result?.data) {
|
||||
if (!result || result.error) {
|
||||
input.toast.show({
|
||||
message: `Failed to restore session: ${errorMessage(result?.error ?? "no response")}`,
|
||||
message: `Failed to warp session: ${errorMessage(result?.error ?? "no response")}`,
|
||||
variant: "error",
|
||||
})
|
||||
return
|
||||
return false
|
||||
}
|
||||
|
||||
input.project.workspace.set(input.workspaceID)
|
||||
|
||||
await input.sync.bootstrap({ fatal: false }).catch(() => undefined)
|
||||
|
||||
await Promise.all([input.project.workspace.sync(), input.sync.session.sync(input.sessionID)])
|
||||
await Promise.all([input.project.workspace.sync(), input.sync.session.refresh()])
|
||||
|
||||
if (input.showSuccessToast !== false) {
|
||||
input.toast.show({ message: "Session warped", variant: "success" })
|
||||
}
|
||||
|
||||
input.toast.show({
|
||||
message: "Session restored into the new workspace",
|
||||
variant: "success",
|
||||
})
|
||||
input.done?.()
|
||||
if (input.done) return
|
||||
if (input.done) return true
|
||||
input.dialog.clear()
|
||||
return true
|
||||
}
|
||||
|
||||
export function DialogWorkspaceCreate(props: { onSelect: (workspaceID: string) => Promise<void> | void }) {
|
||||
export function DialogWorkspaceSelect(props: {
|
||||
adapters?: Adapter[]
|
||||
onSelect: (selection: WorkspaceSelection) => Promise<void> | void
|
||||
}) {
|
||||
const dialog = useDialog()
|
||||
const sync = useSync()
|
||||
const project = useProject()
|
||||
const sync = useSync()
|
||||
const sdk = useSDK()
|
||||
const toast = useToast()
|
||||
const [creating, setCreating] = createSignal<string>()
|
||||
const [adapters, setAdapters] = createSignal<Adapter[]>()
|
||||
const [adapters, setAdapters] = createSignal<Adapter[] | undefined>(props.adapters)
|
||||
|
||||
onMount(() => {
|
||||
dialog.setSize("medium")
|
||||
void (async () => {
|
||||
const dir = sync.path.directory || sdk.directory
|
||||
const url = new URL("/experimental/workspace/adapter", sdk.url)
|
||||
if (dir) url.searchParams.set("directory", dir)
|
||||
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
|
||||
}
|
||||
if (adapters()) return
|
||||
const res = await loadWorkspaceAdapters({ sdk, sync, toast })
|
||||
if (!res) return
|
||||
setAdapters(res)
|
||||
})()
|
||||
})
|
||||
|
||||
const options = createMemo(() => {
|
||||
const type = creating()
|
||||
if (type) {
|
||||
return [
|
||||
{
|
||||
title: `Creating ${type} workspace...`,
|
||||
value: "creating" as const,
|
||||
description: "This can take a while for remote environments",
|
||||
},
|
||||
]
|
||||
}
|
||||
const options = createMemo<DialogSelectOption<WorkspaceSelectValue>[]>(() => {
|
||||
const list = adapters()
|
||||
if (!list) {
|
||||
return [
|
||||
{
|
||||
title: "Loading workspaces...",
|
||||
value: "loading" as const,
|
||||
description: "Fetching available workspace adapters",
|
||||
if (!list) return []
|
||||
const recent = sync.data.session
|
||||
.toSorted((a, b) => b.time.updated - a.time.updated)
|
||||
.flatMap((session) => (session.workspaceID ? [session.workspaceID] : []))
|
||||
.filter((workspaceID, index, list) => list.indexOf(workspaceID) === index)
|
||||
.slice(0, 3)
|
||||
.flatMap((workspaceID) => {
|
||||
const workspace = project.workspace.get(workspaceID)
|
||||
return workspace ? [workspace] : []
|
||||
})
|
||||
return [
|
||||
...list.map((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,
|
||||
},
|
||||
]
|
||||
}
|
||||
return list.map((item) => ({
|
||||
title: item.name,
|
||||
value: item.type,
|
||||
description: item.description,
|
||||
}))
|
||||
category: "Choose workspace",
|
||||
})),
|
||||
{
|
||||
title: "View all workspaces",
|
||||
value: { type: "existing-list" as const },
|
||||
description: "Choose from all workspaces",
|
||||
category: "Choose workspace",
|
||||
},
|
||||
]
|
||||
})
|
||||
|
||||
const create = async (type: string) => {
|
||||
if (creating()) return
|
||||
setCreating(type)
|
||||
|
||||
const result = await sdk.client.experimental.workspace.create({ type, branch: null }).catch(() => {
|
||||
toast.show({
|
||||
message: "Creating workspace failed",
|
||||
variant: "error",
|
||||
})
|
||||
return undefined
|
||||
})
|
||||
|
||||
const workspace = result?.data
|
||||
if (!workspace) {
|
||||
setCreating(undefined)
|
||||
toast.show({
|
||||
message: `Failed to create workspace: ${errorMessage(result?.error ?? "no response")}`,
|
||||
variant: "error",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
await project.workspace.sync()
|
||||
await props.onSelect(workspace.id)
|
||||
setCreating(undefined)
|
||||
}
|
||||
|
||||
if (!adapters()) return null
|
||||
return (
|
||||
<DialogSelect
|
||||
title={creating() ? "Creating Workspace" : "New Workspace"}
|
||||
<DialogSelect<WorkspaceSelectValue>
|
||||
title="Warp"
|
||||
skipFilter={true}
|
||||
renderFilter={false}
|
||||
options={options()}
|
||||
onSelect={(option) => {
|
||||
if (option.value === "creating" || option.value === "loading") return
|
||||
void create(option.value)
|
||||
if (!option.value) return
|
||||
if (option.value.type === "none") {
|
||||
void props.onSelect(option.value)
|
||||
return
|
||||
}
|
||||
if (option.value.type === "new") {
|
||||
void props.onSelect(option.value)
|
||||
return
|
||||
}
|
||||
if (option.value.type === "existing") {
|
||||
void props.onSelect(option.value)
|
||||
return
|
||||
}
|
||||
|
||||
dialog.replace(() => <DialogExistingWorkspaceSelect onSelect={props.onSelect} />)
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DialogExistingWorkspaceSelect(props: { onSelect: (selection: WorkspaceSelection) => Promise<void> | void }) {
|
||||
const project = useProject()
|
||||
|
||||
const options = createMemo<DialogSelectOption<ExistingWorkspaceSelectValue>[]>(() =>
|
||||
project.workspace
|
||||
.list()
|
||||
.filter((workspace) => project.workspace.status(workspace.id) === "connected")
|
||||
.map((workspace: Workspace) => ({
|
||||
title: workspace.name,
|
||||
description: `(${workspace.type})`,
|
||||
value: { workspace },
|
||||
})),
|
||||
)
|
||||
|
||||
return (
|
||||
<DialogSelect<ExistingWorkspaceSelectValue>
|
||||
title="Existing Workspace"
|
||||
options={options()}
|
||||
onSelect={(option) => {
|
||||
void props.onSelect({
|
||||
type: "existing",
|
||||
workspaceID: option.value.workspace.id,
|
||||
workspaceType: option.value.workspace.type,
|
||||
workspaceName: option.value.workspace.name,
|
||||
})
|
||||
}}
|
||||
/>
|
||||
)
|
||||
|
||||
@@ -7,6 +7,7 @@ import { Filesystem } from "@/util/filesystem"
|
||||
import { useLocal } from "@tui/context/local"
|
||||
import { tint, useTheme } from "@tui/context/theme"
|
||||
import { EmptyBorder, SplitBorder } from "@tui/component/border"
|
||||
import { Spinner } from "@tui/component/spinner"
|
||||
import { useSDK } from "@tui/context/sdk"
|
||||
import { useRoute } from "@tui/context/route"
|
||||
import { useProject } from "@tui/context/project"
|
||||
@@ -41,13 +42,16 @@ import { useKV } from "../../context/kv"
|
||||
import { createFadeIn } from "../../util/signal"
|
||||
import { useTextareaKeybindings } from "../textarea-keybindings"
|
||||
import { DialogSkill } from "../dialog-skill"
|
||||
import { DialogWorkspaceCreate, restoreWorkspaceSession } from "../dialog-workspace-create"
|
||||
import { openWorkspaceSelect, warpWorkspaceSession, type WorkspaceSelection } from "../dialog-workspace-create"
|
||||
import { DialogWorkspaceUnavailable } from "../dialog-workspace-unavailable"
|
||||
import { useArgs } from "@tui/context/args"
|
||||
import { Flag } from "@opencode-ai/core/flag/flag"
|
||||
import { WorkspaceLabel, type WorkspaceStatus } from "../workspace-label"
|
||||
|
||||
export type PromptProps = {
|
||||
sessionID?: string
|
||||
workspaceID?: string
|
||||
onWorkspaceCreatingChange?: (creating: boolean) => void
|
||||
visible?: boolean
|
||||
disabled?: boolean
|
||||
onSubmit?: () => void
|
||||
@@ -173,9 +177,94 @@ export function Prompt(props: PromptProps) {
|
||||
const [editorContextHover, setEditorContextHover] = createSignal(false)
|
||||
let lastSubmittedEditorSelectionKey: string | undefined
|
||||
const [auto, setAuto] = createSignal<AutocompleteRef>()
|
||||
const [workspaceSelection, setWorkspaceSelection] = createSignal<WorkspaceSelection>()
|
||||
const [workspaceCreating, setWorkspaceCreating] = createSignal(false)
|
||||
const [workspaceCreatingDots, setWorkspaceCreatingDots] = createSignal(3)
|
||||
const [warpNotice, setWarpNotice] = createSignal<string>()
|
||||
const currentProviderLabel = createMemo(() => local.model.parsed().provider)
|
||||
const hasRightContent = createMemo(() => Boolean(props.right))
|
||||
|
||||
function selectWorkspace(selection: WorkspaceSelection | undefined) {
|
||||
setWorkspaceSelection(selection)
|
||||
}
|
||||
|
||||
function setCreatingWorkspace(creating: boolean) {
|
||||
setWorkspaceCreating(creating)
|
||||
props.onWorkspaceCreatingChange?.(creating)
|
||||
}
|
||||
|
||||
function showWarpNotice(name: string) {
|
||||
setWarpNotice(`Warped to ${name}`)
|
||||
setTimeout(() => setWarpNotice(undefined), 4000)
|
||||
}
|
||||
|
||||
async function createWorkspace(selection: Extract<WorkspaceSelection, { type: "new" }>) {
|
||||
setCreatingWorkspace(true)
|
||||
const result = await sdk.client.experimental.workspace
|
||||
.create({ type: selection.workspaceType, branch: null })
|
||||
.catch(() => undefined)
|
||||
if (result == undefined || result.error || !result.data) {
|
||||
selectWorkspace(undefined)
|
||||
setCreatingWorkspace(false)
|
||||
toast.show({
|
||||
message: "Creating workspace failed",
|
||||
variant: "error",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
await project.workspace.sync()
|
||||
const workspace = result.data
|
||||
selectWorkspace({
|
||||
type: "existing",
|
||||
workspaceID: workspace.id,
|
||||
workspaceType: workspace.type,
|
||||
workspaceName: workspace.name,
|
||||
})
|
||||
setCreatingWorkspace(false)
|
||||
return workspace
|
||||
}
|
||||
|
||||
async function warpSession(selection: WorkspaceSelection) {
|
||||
if (!props.sessionID) {
|
||||
selectWorkspace(selection)
|
||||
dialog.clear()
|
||||
if (selection.type === "new") void createWorkspace(selection)
|
||||
return
|
||||
}
|
||||
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,
|
||||
showSuccessToast: false,
|
||||
})
|
||||
if (warped) showWarpNotice(workspace.name)
|
||||
}
|
||||
|
||||
createEffect(() => {
|
||||
if (!workspaceCreating()) {
|
||||
setWorkspaceCreatingDots(3)
|
||||
return
|
||||
}
|
||||
const timer = setInterval(() => setWorkspaceCreatingDots((dots) => (dots % 3) + 1), 1000)
|
||||
onCleanup(() => clearInterval(timer))
|
||||
})
|
||||
|
||||
function promptModelWarning() {
|
||||
toast.show({
|
||||
variant: "warning",
|
||||
@@ -213,6 +302,7 @@ export function Prompt(props: PromptProps) {
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
if (!input || input.isDestroyed) return
|
||||
if (props.disabled) input.cursorColor = theme.backgroundElement
|
||||
if (!props.disabled) input.cursorColor = theme.text
|
||||
})
|
||||
@@ -489,6 +579,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 +810,8 @@ export function Prompt(props: PromptProps) {
|
||||
])
|
||||
|
||||
async function submit() {
|
||||
setWarpNotice(undefined)
|
||||
|
||||
// IME: double-defer may fire before onContentChange flushes the last
|
||||
// composed character (e.g. Korean hangul) to the store, so read
|
||||
// plainText directly and sync before any downstream reads.
|
||||
@@ -707,6 +820,7 @@ export function Prompt(props: PromptProps) {
|
||||
syncExtmarksWithPromptParts()
|
||||
}
|
||||
if (props.disabled) return false
|
||||
if (workspaceCreating()) return false
|
||||
if (autocomplete?.visible) return false
|
||||
if (!store.prompt.input) return false
|
||||
const agent = local.agent.current()
|
||||
@@ -729,21 +843,16 @@ export function Prompt(props: PromptProps) {
|
||||
dialog.replace(() => (
|
||||
<DialogWorkspaceUnavailable
|
||||
onRestore={() => {
|
||||
dialog.replace(() => (
|
||||
<DialogWorkspaceCreate
|
||||
onSelect={(nextWorkspaceID) =>
|
||||
restoreWorkspaceSession({
|
||||
dialog,
|
||||
sdk,
|
||||
sync,
|
||||
project,
|
||||
toast,
|
||||
workspaceID: nextWorkspaceID,
|
||||
sessionID: props.sessionID!,
|
||||
})
|
||||
}
|
||||
/>
|
||||
))
|
||||
void openWorkspaceSelect({
|
||||
dialog,
|
||||
sdk,
|
||||
sync,
|
||||
toast,
|
||||
onSelect: (selection) => {
|
||||
void warpSession(selection)
|
||||
},
|
||||
})
|
||||
return false
|
||||
}}
|
||||
/>
|
||||
))
|
||||
@@ -753,6 +862,14 @@ export function Prompt(props: PromptProps) {
|
||||
const variant = local.model.variant.current()
|
||||
let sessionID = props.sessionID
|
||||
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({
|
||||
workspace: props.workspaceID,
|
||||
agent: agent.name,
|
||||
@@ -1025,6 +1142,29 @@ export function Prompt(props: PromptProps) {
|
||||
return `Ask anything... "${list()[store.placeholder % list().length]}"`
|
||||
})
|
||||
|
||||
const workspaceLabel = createMemo<
|
||||
| { type: "new"; workspaceType: string }
|
||||
| { type: "existing"; workspaceType: string; workspaceName: string; status?: WorkspaceStatus }
|
||||
| undefined
|
||||
>(() => {
|
||||
const selected = 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 agent = local.agent.current()
|
||||
const color = agent ? local.agent.color(agent.name) : theme.border
|
||||
@@ -1281,7 +1421,7 @@ export function Prompt(props: PromptProps) {
|
||||
}}
|
||||
onMouseDown={(r: MouseEvent) => r.target?.focus()}
|
||||
focusedBackgroundColor={theme.backgroundElement}
|
||||
cursorColor={theme.text}
|
||||
cursorColor={props.disabled ? theme.backgroundElement : theme.text}
|
||||
syntaxStyle={syntax()}
|
||||
/>
|
||||
<box flexDirection="row" flexShrink={0} paddingTop={1} gap={1} justifyContent="space-between">
|
||||
@@ -1351,86 +1491,124 @@ export function Prompt(props: PromptProps) {
|
||||
/>
|
||||
</box>
|
||||
<box width="100%" flexDirection="row" justifyContent="space-between">
|
||||
<Show when={status().type !== "idle"} fallback={props.hint ?? <text />}>
|
||||
<box
|
||||
flexDirection="row"
|
||||
gap={1}
|
||||
flexGrow={1}
|
||||
justifyContent={status().type === "retry" ? "space-between" : "flex-start"}
|
||||
>
|
||||
<box flexShrink={0} flexDirection="row" gap={1}>
|
||||
<box marginLeft={1}>
|
||||
<Show when={kv.get("animations_enabled", true)} fallback={<text fg={theme.textMuted}>[⋯]</text>}>
|
||||
<spinner color={spinnerDef().color} frames={spinnerDef().frames} interval={40} />
|
||||
</Show>
|
||||
</box>
|
||||
<box flexDirection="row" gap={1} flexShrink={0}>
|
||||
{(() => {
|
||||
const retry = createMemo(() => {
|
||||
const s = status()
|
||||
if (s.type !== "retry") return
|
||||
return s
|
||||
})
|
||||
const message = createMemo(() => {
|
||||
const r = retry()
|
||||
if (!r) return
|
||||
if (r.message.includes("exceeded your current quota") && r.message.includes("gemini"))
|
||||
return "gemini is way too hot right now"
|
||||
if (r.message.length > 80) return r.message.slice(0, 80) + "..."
|
||||
return r.message
|
||||
})
|
||||
const isTruncated = createMemo(() => {
|
||||
const r = retry()
|
||||
if (!r) return false
|
||||
return r.message.length > 120
|
||||
})
|
||||
const [seconds, setSeconds] = createSignal(0)
|
||||
onMount(() => {
|
||||
const timer = setInterval(() => {
|
||||
const next = retry()?.next
|
||||
if (next) setSeconds(Math.round((next - Date.now()) / 1000))
|
||||
}, 1000)
|
||||
|
||||
onCleanup(() => {
|
||||
clearInterval(timer)
|
||||
<Switch>
|
||||
<Match when={status().type !== "idle"}>
|
||||
<box
|
||||
flexDirection="row"
|
||||
gap={1}
|
||||
flexGrow={1}
|
||||
justifyContent={status().type === "retry" ? "space-between" : "flex-start"}
|
||||
>
|
||||
<box flexShrink={0} flexDirection="row" gap={1}>
|
||||
<box marginLeft={1}>
|
||||
<Show when={kv.get("animations_enabled", true)} fallback={<text fg={theme.textMuted}>[⋯]</text>}>
|
||||
<spinner color={spinnerDef().color} frames={spinnerDef().frames} interval={40} />
|
||||
</Show>
|
||||
</box>
|
||||
<box flexDirection="row" gap={1} flexShrink={0}>
|
||||
{(() => {
|
||||
const retry = createMemo(() => {
|
||||
const s = status()
|
||||
if (s.type !== "retry") return
|
||||
return s
|
||||
})
|
||||
})
|
||||
const handleMessageClick = () => {
|
||||
const r = retry()
|
||||
if (!r) return
|
||||
if (isTruncated()) {
|
||||
void DialogAlert.show(dialog, "Retry Error", r.message)
|
||||
const message = createMemo(() => {
|
||||
const r = retry()
|
||||
if (!r) return
|
||||
if (r.message.includes("exceeded your current quota") && r.message.includes("gemini"))
|
||||
return "gemini is way too hot right now"
|
||||
if (r.message.length > 80) return r.message.slice(0, 80) + "..."
|
||||
return r.message
|
||||
})
|
||||
const isTruncated = createMemo(() => {
|
||||
const r = retry()
|
||||
if (!r) return false
|
||||
return r.message.length > 120
|
||||
})
|
||||
const [seconds, setSeconds] = createSignal(0)
|
||||
onMount(() => {
|
||||
const timer = setInterval(() => {
|
||||
const next = retry()?.next
|
||||
if (next) setSeconds(Math.round((next - Date.now()) / 1000))
|
||||
}, 1000)
|
||||
|
||||
onCleanup(() => {
|
||||
clearInterval(timer)
|
||||
})
|
||||
})
|
||||
const handleMessageClick = () => {
|
||||
const r = retry()
|
||||
if (!r) return
|
||||
if (isTruncated()) {
|
||||
void DialogAlert.show(dialog, "Retry Error", r.message)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const retryText = () => {
|
||||
const r = retry()
|
||||
if (!r) return ""
|
||||
const baseMessage = message()
|
||||
const truncatedHint = isTruncated() ? " (click to expand)" : ""
|
||||
const duration = formatDuration(seconds())
|
||||
const retryInfo = ` [retrying ${duration ? `in ${duration} ` : ""}attempt #${r.attempt}]`
|
||||
return baseMessage + truncatedHint + retryInfo
|
||||
}
|
||||
const retryText = () => {
|
||||
const r = retry()
|
||||
if (!r) return ""
|
||||
const baseMessage = message()
|
||||
const truncatedHint = isTruncated() ? " (click to expand)" : ""
|
||||
const duration = formatDuration(seconds())
|
||||
const retryInfo = ` [retrying ${duration ? `in ${duration} ` : ""}attempt #${r.attempt}]`
|
||||
return baseMessage + truncatedHint + retryInfo
|
||||
}
|
||||
|
||||
return (
|
||||
<Show when={retry()}>
|
||||
<box onMouseUp={handleMessageClick}>
|
||||
<text fg={theme.error}>{retryText()}</text>
|
||||
</box>
|
||||
</Show>
|
||||
)
|
||||
})()}
|
||||
return (
|
||||
<Show when={retry()}>
|
||||
<box onMouseUp={handleMessageClick}>
|
||||
<text fg={theme.error}>{retryText()}</text>
|
||||
</box>
|
||||
</Show>
|
||||
)
|
||||
})()}
|
||||
</box>
|
||||
</box>
|
||||
<text fg={store.interrupt > 0 ? theme.primary : theme.text}>
|
||||
esc{" "}
|
||||
<span style={{ fg: store.interrupt > 0 ? theme.primary : theme.textMuted }}>
|
||||
{store.interrupt > 0 ? "again to interrupt" : "interrupt"}
|
||||
</span>
|
||||
</text>
|
||||
</box>
|
||||
<text fg={store.interrupt > 0 ? theme.primary : theme.text}>
|
||||
esc{" "}
|
||||
<span style={{ fg: store.interrupt > 0 ? theme.primary : theme.textMuted }}>
|
||||
{store.interrupt > 0 ? "again to interrupt" : "interrupt"}
|
||||
</span>
|
||||
</text>
|
||||
</box>
|
||||
</Show>
|
||||
</Match>
|
||||
<Match when={warpNotice()}>
|
||||
{(notice) => (
|
||||
<box paddingLeft={3}>
|
||||
<text fg={theme.accent}>{notice()}</text>
|
||||
</box>
|
||||
)}
|
||||
</Match>
|
||||
<Match when={workspaceLabel()}>
|
||||
{(workspace) => (
|
||||
<box paddingLeft={3} flexDirection="row" gap={1}>
|
||||
<Show when={workspaceCreating()}>
|
||||
<Spinner color={theme.accent} />
|
||||
</Show>
|
||||
<text fg={workspaceCreating() ? theme.accent : theme.text}>
|
||||
{(() => {
|
||||
const item = workspace()
|
||||
if (item.type === "new") {
|
||||
if (workspaceCreating())
|
||||
return `Creating ${item.workspaceType}${".".repeat(workspaceCreatingDots())}`
|
||||
return (
|
||||
<>
|
||||
Workspace <span style={{ fg: theme.textMuted }}>(new {item.workspaceType})</span>
|
||||
</>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<>
|
||||
Workspace <span style={{ fg: theme.textMuted }}>{item.workspaceName}</span>
|
||||
</>
|
||||
)
|
||||
})()}
|
||||
</text>
|
||||
</box>
|
||||
)}
|
||||
</Match>
|
||||
<Match when={true}>{props.hint ?? <text />}</Match>
|
||||
</Switch>
|
||||
<Show when={status().type !== "retry"}>
|
||||
<box gap={2} flexDirection="row">
|
||||
<Show when={editorFileLabelDisplay()}>
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import { InstallationChannel, InstallationVersion } from "@opencode-ai/core/inst
|
||||
import { TuiPluginRuntime } from "@/cli/cmd/tui/plugin/runtime"
|
||||
|
||||
import { getScrollAcceleration } from "../../util/scroll"
|
||||
import { WorkspaceLabel } from "../../component/workspace-label"
|
||||
|
||||
export function Sidebar(props: { sessionID: string; overlay?: boolean }) {
|
||||
const project = useProject()
|
||||
@@ -14,17 +15,10 @@ export function Sidebar(props: { sessionID: string; overlay?: boolean }) {
|
||||
const { theme } = useTheme()
|
||||
const tuiConfig = useTuiConfig()
|
||||
const session = createMemo(() => sync.session.get(props.sessionID))
|
||||
const workspaceStatus = () => {
|
||||
const workspace = () => {
|
||||
const workspaceID = session()?.workspaceID
|
||||
if (!workspaceID) return "error"
|
||||
return project.workspace.status(workspaceID) ?? "error"
|
||||
}
|
||||
const workspaceLabel = () => {
|
||||
const workspaceID = session()?.workspaceID
|
||||
if (!workspaceID) return "unknown"
|
||||
const info = project.workspace.get(workspaceID)
|
||||
if (!info) return "unknown"
|
||||
return `${info.type}: ${info.name}`
|
||||
if (!workspaceID) return
|
||||
return project.workspace.get(workspaceID)
|
||||
}
|
||||
const scrollAcceleration = createMemo(() => getScrollAcceleration(tuiConfig))
|
||||
|
||||
@@ -67,8 +61,19 @@ export function Sidebar(props: { sessionID: string; overlay?: boolean }) {
|
||||
</Show>
|
||||
<Show when={session()!.workspaceID}>
|
||||
<text fg={theme.textMuted}>
|
||||
<span style={{ fg: workspaceStatus() === "connected" ? theme.success : theme.error }}>●</span>{" "}
|
||||
{workspaceLabel()}
|
||||
<Show
|
||||
when={workspace()}
|
||||
fallback={<WorkspaceLabel type="unknown" name={session()!.workspaceID!} status="error" icon />}
|
||||
>
|
||||
{(item) => (
|
||||
<WorkspaceLabel
|
||||
type={item().type}
|
||||
name={item().name}
|
||||
status={project.workspace.status(item().id) ?? "error"}
|
||||
icon
|
||||
/>
|
||||
)}
|
||||
</Show>
|
||||
</text>
|
||||
</Show>
|
||||
<Show when={session()!.share?.url}>
|
||||
|
||||
@@ -23,6 +23,7 @@ export interface DialogSelectProps<T> {
|
||||
onFilter?: (query: string) => void
|
||||
onSelect?: (option: DialogSelectOption<T>) => void
|
||||
skipFilter?: boolean
|
||||
renderFilter?: boolean
|
||||
keybind?: {
|
||||
keybind?: Keybind.Info
|
||||
title: string
|
||||
@@ -81,7 +82,7 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
|
||||
let input: InputRenderable
|
||||
|
||||
const filtered = createMemo(() => {
|
||||
if (props.skipFilter) return props.options.filter((x) => x.disabled !== true)
|
||||
if (props.skipFilter || props.renderFilter === false) return props.options.filter((x) => x.disabled !== true)
|
||||
const needle = store.filter.toLowerCase()
|
||||
const options = pipe(
|
||||
props.options,
|
||||
@@ -250,30 +251,32 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
|
||||
esc
|
||||
</text>
|
||||
</box>
|
||||
<box paddingTop={1}>
|
||||
<input
|
||||
onInput={(e) => {
|
||||
batch(() => {
|
||||
setStore("filter", e)
|
||||
props.onFilter?.(e)
|
||||
})
|
||||
}}
|
||||
focusedBackgroundColor={theme.backgroundPanel}
|
||||
cursorColor={theme.primary}
|
||||
focusedTextColor={theme.textMuted}
|
||||
ref={(r) => {
|
||||
input = r
|
||||
input.traits = { status: "FILTER" }
|
||||
setTimeout(() => {
|
||||
if (!input) return
|
||||
if (input.isDestroyed) return
|
||||
input.focus()
|
||||
}, 1)
|
||||
}}
|
||||
placeholder={props.placeholder ?? "Search"}
|
||||
placeholderColor={theme.textMuted}
|
||||
/>
|
||||
</box>
|
||||
<Show when={props.renderFilter !== false}>
|
||||
<box paddingTop={1}>
|
||||
<input
|
||||
onInput={(e) => {
|
||||
batch(() => {
|
||||
setStore("filter", e)
|
||||
props.onFilter?.(e)
|
||||
})
|
||||
}}
|
||||
focusedBackgroundColor={theme.backgroundPanel}
|
||||
cursorColor={theme.primary}
|
||||
focusedTextColor={theme.textMuted}
|
||||
ref={(r) => {
|
||||
input = r
|
||||
input.traits = { status: "FILTER" }
|
||||
setTimeout(() => {
|
||||
if (!input) return
|
||||
if (input.isDestroyed) return
|
||||
input.focus()
|
||||
}, 1)
|
||||
}}
|
||||
placeholder={props.placeholder ?? "Search"}
|
||||
placeholderColor={theme.textMuted}
|
||||
/>
|
||||
</box>
|
||||
</Show>
|
||||
</box>
|
||||
<Show
|
||||
when={grouped().length > 0}
|
||||
|
||||
@@ -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 { Database } from "@/storage/db"
|
||||
import { asc } from "drizzle-orm"
|
||||
import { eq } from "drizzle-orm"
|
||||
import { inArray } from "drizzle-orm"
|
||||
import { Project } from "@/project/project"
|
||||
import { Instance } from "@/project/instance"
|
||||
import { BusEvent } from "@/bus/bus-event"
|
||||
import { GlobalBus } from "@/bus/global"
|
||||
import { Auth } from "@/auth"
|
||||
@@ -20,6 +21,7 @@ import { getAdapter } from "./adapters"
|
||||
import { type WorkspaceInfo, WorkspaceInfo as WorkspaceInfoSchema } from "./types"
|
||||
import { WorkspaceID } from "./schema"
|
||||
import { Session } from "@/session/session"
|
||||
import { SessionPrompt } from "@/session/prompt"
|
||||
import { SessionTable } from "@/session/session.sql"
|
||||
import { SessionID } from "@/session/schema"
|
||||
import { errorData } from "@/util/error"
|
||||
@@ -38,13 +40,6 @@ export const ConnectionStatus = Schema.Struct({
|
||||
})
|
||||
export type ConnectionStatus = Schema.Schema.Type<typeof ConnectionStatus>
|
||||
|
||||
const Restore = Schema.Struct({
|
||||
workspaceID: WorkspaceID,
|
||||
sessionID: SessionID,
|
||||
total: NonNegativeInt,
|
||||
step: NonNegativeInt,
|
||||
})
|
||||
|
||||
export const Event = {
|
||||
Ready: BusEvent.define(
|
||||
"workspace.ready",
|
||||
@@ -58,7 +53,6 @@ export const Event = {
|
||||
message: Schema.String,
|
||||
}),
|
||||
),
|
||||
Restore: BusEvent.define("workspace.restore", Restore),
|
||||
Status: BusEvent.define("workspace.status", ConnectionStatus),
|
||||
}
|
||||
|
||||
@@ -84,15 +78,15 @@ export const CreateInput = Schema.Struct({
|
||||
type: Info.fields.type,
|
||||
branch: Info.fields.branch,
|
||||
projectID: ProjectID,
|
||||
extra: Info.fields.extra,
|
||||
extra: Schema.optional(Info.fields.extra),
|
||||
}).pipe(withStatics((s) => ({ zod: effectZod(s), zodObject: zodObject(s) })))
|
||||
export type CreateInput = Schema.Schema.Type<typeof CreateInput>
|
||||
|
||||
export const SessionRestoreInput = Schema.Struct({
|
||||
workspaceID: WorkspaceID,
|
||||
export const SessionWarpInput = Schema.Struct({
|
||||
workspaceID: Schema.NullOr(WorkspaceID),
|
||||
sessionID: SessionID,
|
||||
}).pipe(withStatics((s) => ({ zod: effectZod(s), zodObject: zodObject(s) })))
|
||||
export type SessionRestoreInput = Schema.Schema.Type<typeof SessionRestoreInput>
|
||||
export type SessionWarpInput = Schema.Schema.Type<typeof SessionWarpInput>
|
||||
|
||||
export class SyncHttpError extends Schema.TaggedErrorClass<SyncHttpError>()("WorkspaceSyncHttpError", {
|
||||
message: Schema.String,
|
||||
@@ -116,8 +110,8 @@ export class SessionEventsNotFoundError extends Schema.TaggedErrorClass<SessionE
|
||||
},
|
||||
) {}
|
||||
|
||||
export class SessionRestoreHttpError extends Schema.TaggedErrorClass<SessionRestoreHttpError>()(
|
||||
"WorkspaceSessionRestoreHttpError",
|
||||
export class SessionWarpHttpError extends Schema.TaggedErrorClass<SessionWarpHttpError>()(
|
||||
"WorkspaceSessionWarpHttpError",
|
||||
{
|
||||
message: Schema.String,
|
||||
workspaceID: WorkspaceID,
|
||||
@@ -138,17 +132,17 @@ export class SyncAbortedError extends Schema.TaggedErrorClass<SyncAbortedError>(
|
||||
}) {}
|
||||
|
||||
type CreateError = Auth.AuthError
|
||||
type SessionRestoreError =
|
||||
type SessionWarpError =
|
||||
| WorkspaceNotFoundError
|
||||
| SessionEventsNotFoundError
|
||||
| SessionRestoreHttpError
|
||||
| SessionWarpHttpError
|
||||
| HttpClientError.HttpClientError
|
||||
type WaitForSyncError = SyncTimeoutError | SyncAbortedError
|
||||
type SyncLoopError = SyncHttpError | HttpClientError.HttpClientError
|
||||
|
||||
export interface Interface {
|
||||
readonly create: (input: CreateInput) => Effect.Effect<Info, CreateError>
|
||||
readonly sessionRestore: (input: SessionRestoreInput) => Effect.Effect<{ total: number }, SessionRestoreError>
|
||||
readonly sessionWarp: (input: SessionWarpInput) => Effect.Effect<void, SessionWarpError>
|
||||
readonly list: (project: Project.Info) => Effect.Effect<Info[]>
|
||||
readonly get: (id: WorkspaceID) => Effect.Effect<Info | undefined>
|
||||
readonly remove: (id: WorkspaceID) => Effect.Effect<Info | undefined>
|
||||
@@ -169,6 +163,7 @@ export const layer = Layer.effect(
|
||||
Effect.gen(function* () {
|
||||
const auth = yield* Auth.Service
|
||||
const session = yield* Session.Service
|
||||
const prompt = yield* SessionPrompt.Service
|
||||
const http = yield* HttpClient.HttpClient
|
||||
const sync = yield* SyncEvent.Service
|
||||
const connections = new Map<WorkspaceID, ConnectionStatus>()
|
||||
@@ -461,7 +456,7 @@ export const layer = Layer.effect(
|
||||
const id = WorkspaceID.ascending(input.id)
|
||||
const adapter = getAdapter(input.projectID, input.type)
|
||||
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 = {
|
||||
@@ -518,29 +513,93 @@ export const layer = Layer.effect(
|
||||
return info
|
||||
})
|
||||
|
||||
const sessionRestore = Effect.fn("Workspace.sessionRestore")(function* (input: SessionRestoreInput) {
|
||||
const sessionWarp = Effect.fn("Workspace.sessionWarp")(function* (input: SessionWarpInput) {
|
||||
return yield* Effect.gen(function* () {
|
||||
log.info("session restore requested", {
|
||||
yield* prompt.cancel(input.sessionID)
|
||||
|
||||
log.info("session warp requested", {
|
||||
workspaceID: input.workspaceID,
|
||||
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),
|
||||
})
|
||||
}),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
// "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)
|
||||
return yield* new WorkspaceNotFoundError({
|
||||
message: `Workspace not found: ${input.workspaceID}`,
|
||||
workspaceID: input.workspaceID,
|
||||
message: `Workspace not found: ${workspaceID}`,
|
||||
workspaceID,
|
||||
})
|
||||
|
||||
const adapter = getAdapter(space.projectID, space.type)
|
||||
const target = yield* EffectBridge.fromPromise(() => adapter.target(space))
|
||||
|
||||
yield* sync.run(Session.Event.Updated, {
|
||||
sessionID: input.sessionID,
|
||||
info: {
|
||||
if (target.type === "local") {
|
||||
yield* sync.run(Session.Event.Updated, {
|
||||
sessionID: input.sessionID,
|
||||
info: {
|
||||
workspaceID: input.workspaceID,
|
||||
},
|
||||
})
|
||||
|
||||
log.info("session warp complete", {
|
||||
workspaceID: input.workspaceID,
|
||||
},
|
||||
})
|
||||
sessionID: input.sessionID,
|
||||
target: target.directory,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
const rows = yield* db((db) =>
|
||||
db
|
||||
@@ -562,130 +621,95 @@ export const layer = Layer.effect(
|
||||
sessionID: input.sessionID,
|
||||
})
|
||||
|
||||
const size = 10
|
||||
// TODO: look into using effect APIs to process this in chunks
|
||||
const sets = Array.from({ length: Math.ceil(rows.length / size) }, (_, i) =>
|
||||
rows.slice(i * size, (i + 1) * size),
|
||||
)
|
||||
const total = sets.length
|
||||
const batches = Iterable.chunksOf(rows, 10)
|
||||
const total = Iterable.size(batches)
|
||||
|
||||
log.info("session restore prepared", {
|
||||
log.info("session warp prepared", {
|
||||
workspaceID: input.workspaceID,
|
||||
sessionID: input.sessionID,
|
||||
workspaceType: space.type,
|
||||
directory: space.directory,
|
||||
target: target.type === "remote" ? String(route(target.url, "/sync/replay")) : target.directory,
|
||||
target: String(route(target.url, "/sync/replay")),
|
||||
events: rows.length,
|
||||
batches: total,
|
||||
first: rows[0]?.seq,
|
||||
last: rows.at(-1)?.seq,
|
||||
})
|
||||
|
||||
yield* Effect.sync(() =>
|
||||
GlobalBus.emit("event", {
|
||||
directory: "global",
|
||||
workspace: input.workspaceID,
|
||||
payload: {
|
||||
type: Event.Restore.type,
|
||||
properties: {
|
||||
workspaceID: input.workspaceID,
|
||||
sessionID: input.sessionID,
|
||||
total,
|
||||
step: 0,
|
||||
},
|
||||
},
|
||||
}),
|
||||
)
|
||||
|
||||
for (const [i, events] of sets.entries()) {
|
||||
log.info("session restore batch starting", {
|
||||
workspaceID: input.workspaceID,
|
||||
sessionID: input.sessionID,
|
||||
step: i + 1,
|
||||
total,
|
||||
events: events.length,
|
||||
first: events[0]?.seq,
|
||||
last: events.at(-1)?.seq,
|
||||
target: target.type === "remote" ? String(route(target.url, "/sync/replay")) : target.directory,
|
||||
})
|
||||
|
||||
if (target.type === "local") {
|
||||
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,
|
||||
yield* Effect.forEach(
|
||||
batches,
|
||||
(events, i) =>
|
||||
Effect.gen(function* () {
|
||||
const response = yield* http.execute(
|
||||
HttpClientRequest.post(route(target.url, "/sync/replay"), {
|
||||
headers: new Headers(target.headers),
|
||||
body: HttpBody.jsonUnsafe({
|
||||
directory: space.directory ?? "",
|
||||
events,
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
)
|
||||
)
|
||||
|
||||
if (res.status < 200 || res.status >= 300) {
|
||||
const body = yield* res.text
|
||||
log.error("session restore batch failed", {
|
||||
if (response.status < 200 || response.status >= 300) {
|
||||
const body = yield* response.text
|
||||
log.error("session warp batch failed", {
|
||||
workspaceID: input.workspaceID,
|
||||
sessionID: input.sessionID,
|
||||
step: i + 1,
|
||||
total,
|
||||
status: response.status,
|
||||
body,
|
||||
})
|
||||
return yield* new SessionWarpHttpError({
|
||||
message: `Failed to warp session ${input.sessionID} into workspace ${workspaceID}: HTTP ${response.status} ${body}`,
|
||||
workspaceID,
|
||||
sessionID: input.sessionID,
|
||||
status: response.status,
|
||||
body,
|
||||
})
|
||||
}
|
||||
|
||||
log.info("session warp batch posted", {
|
||||
workspaceID: input.workspaceID,
|
||||
sessionID: input.sessionID,
|
||||
step: i + 1,
|
||||
total,
|
||||
status: res.status,
|
||||
body,
|
||||
status: response.status,
|
||||
})
|
||||
return yield* new SessionRestoreHttpError({
|
||||
message: `Failed to replay session ${input.sessionID} into workspace ${input.workspaceID}: HTTP ${res.status} ${body}`,
|
||||
workspaceID: input.workspaceID,
|
||||
sessionID: input.sessionID,
|
||||
status: res.status,
|
||||
body,
|
||||
})
|
||||
}
|
||||
|
||||
log.info("session restore batch posted", {
|
||||
workspaceID: input.workspaceID,
|
||||
sessionID: input.sessionID,
|
||||
step: i + 1,
|
||||
total,
|
||||
status: res.status,
|
||||
})
|
||||
}
|
||||
|
||||
yield* Effect.sync(() =>
|
||||
GlobalBus.emit("event", {
|
||||
directory: "global",
|
||||
workspace: input.workspaceID,
|
||||
payload: {
|
||||
type: Event.Restore.type,
|
||||
properties: {
|
||||
workspaceID: input.workspaceID,
|
||||
sessionID: input.sessionID,
|
||||
total,
|
||||
step: i + 1,
|
||||
},
|
||||
},
|
||||
}),
|
||||
)
|
||||
{ discard: true },
|
||||
)
|
||||
|
||||
const response = yield* http.execute(
|
||||
HttpClientRequest.post(route(target.url, "/sync/steal"), {
|
||||
headers: new Headers(target.headers),
|
||||
body: HttpBody.jsonUnsafe({ sessionID: input.sessionID }),
|
||||
}),
|
||||
)
|
||||
if (response.status < 200 || response.status >= 300) {
|
||||
const body = yield* response.text
|
||||
log.error("session warp steal failed", {
|
||||
workspaceID: input.workspaceID,
|
||||
sessionID: input.sessionID,
|
||||
status: response.status,
|
||||
body,
|
||||
})
|
||||
return yield* new SessionWarpHttpError({
|
||||
message: `Failed to steal session ${input.sessionID} into workspace ${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,
|
||||
sessionID: input.sessionID,
|
||||
batches: total,
|
||||
})
|
||||
|
||||
return { total }
|
||||
}).pipe(
|
||||
Effect.tapError((err) =>
|
||||
Effect.sync(() =>
|
||||
log.error("session restore failed", {
|
||||
log.error("session warp failed", {
|
||||
workspaceID: input.workspaceID,
|
||||
sessionID: input.sessionID,
|
||||
error: errorData(err),
|
||||
@@ -814,7 +838,7 @@ export const layer = Layer.effect(
|
||||
|
||||
return Service.of({
|
||||
create,
|
||||
sessionRestore,
|
||||
sessionWarp,
|
||||
list,
|
||||
get,
|
||||
remove,
|
||||
@@ -830,6 +854,7 @@ export const defaultLayer = layer.pipe(
|
||||
Layer.provide(Auth.defaultLayer),
|
||||
Layer.provide(Session.defaultLayer),
|
||||
Layer.provide(SyncEvent.defaultLayer),
|
||||
Layer.provide(SessionPrompt.defaultLayer),
|
||||
Layer.provide(FetchHttpClient.layer),
|
||||
)
|
||||
|
||||
|
||||
@@ -10,10 +10,6 @@ import { zodObject } from "@/util/effect-zod"
|
||||
import { Instance } from "@/project/instance"
|
||||
import { errors } from "../../error"
|
||||
import { lazy } from "@/util/lazy"
|
||||
import * as Log from "@opencode-ai/core/util/log"
|
||||
import { errorData } from "@/util/error"
|
||||
|
||||
const log = Log.create({ service: "server.workspace" })
|
||||
|
||||
export const WorkspaceRoutes = lazy(() =>
|
||||
new Hono()
|
||||
@@ -151,60 +147,36 @@ export const WorkspaceRoutes = lazy(() =>
|
||||
},
|
||||
)
|
||||
.post(
|
||||
"/:id/session-restore",
|
||||
"/warp",
|
||||
describeRoute({
|
||||
summary: "Restore session into workspace",
|
||||
description: "Replay a session's sync events into the target workspace in batches.",
|
||||
operationId: "experimental.workspace.sessionRestore",
|
||||
summary: "Warp session into workspace",
|
||||
description: "Move a session's sync history into the target workspace, or detach it to the local project.",
|
||||
operationId: "experimental.workspace.warp",
|
||||
responses: {
|
||||
200: {
|
||||
description: "Session replay started",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(
|
||||
z.object({
|
||||
total: z.number().int().min(0),
|
||||
}),
|
||||
),
|
||||
},
|
||||
},
|
||||
204: {
|
||||
description: "Session warped",
|
||||
},
|
||||
...errors(400),
|
||||
},
|
||||
}),
|
||||
validator("param", z.object({ id: zodObject(Workspace.Info).shape.id })),
|
||||
validator("json", Workspace.SessionRestoreInput.zodObject.omit({ workspaceID: true })),
|
||||
validator(
|
||||
"json",
|
||||
z.object({
|
||||
id: zodObject(Workspace.Info).shape.id.nullable(),
|
||||
sessionID: Workspace.SessionWarpInput.zodObject.shape.sessionID,
|
||||
}),
|
||||
),
|
||||
async (c) => {
|
||||
const { id } = c.req.valid("param")
|
||||
const body = c.req.valid("json") as Omit<Workspace.SessionRestoreInput, "workspaceID">
|
||||
log.info("session restore route requested", {
|
||||
workspaceID: id,
|
||||
sessionID: body.sessionID,
|
||||
directory: Instance.directory,
|
||||
})
|
||||
try {
|
||||
const result = await AppRuntime.runPromise(
|
||||
Workspace.Service.use((svc) =>
|
||||
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
|
||||
}
|
||||
const body = c.req.valid("json")
|
||||
await AppRuntime.runPromise(
|
||||
Workspace.Service.use((workspace) =>
|
||||
workspace.sessionWarp({
|
||||
workspaceID: body.id,
|
||||
sessionID: body.sessionID,
|
||||
}),
|
||||
),
|
||||
)
|
||||
return c.body(null, 204)
|
||||
},
|
||||
),
|
||||
)
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { NonNegativeInt } from "@/util/schema"
|
||||
import { SessionID } from "@/session/schema"
|
||||
import { Schema } from "effect"
|
||||
import { HttpApi, HttpApiEndpoint, HttpApiError, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
|
||||
import { Authorization } from "../middleware/authorization"
|
||||
@@ -21,6 +22,9 @@ export const ReplayPayload = Schema.Struct({
|
||||
export const ReplayResponse = Schema.Struct({
|
||||
sessionID: Schema.String,
|
||||
})
|
||||
export const SessionPayload = Schema.Struct({
|
||||
sessionID: SessionID,
|
||||
})
|
||||
export const HistoryPayload = Schema.Record(Schema.String, NonNegativeInt)
|
||||
export const HistoryEvent = Schema.Struct({
|
||||
id: Schema.String,
|
||||
@@ -33,6 +37,7 @@ export const HistoryEvent = Schema.Struct({
|
||||
export const SyncPaths = {
|
||||
start: `${root}/start`,
|
||||
replay: `${root}/replay`,
|
||||
steal: `${root}/steal`,
|
||||
history: `${root}/history`,
|
||||
} as const
|
||||
|
||||
@@ -60,6 +65,17 @@ export const SyncApi = HttpApi.make("sync")
|
||||
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, {
|
||||
payload: HistoryPayload,
|
||||
success: described(Schema.Array(HistoryEvent), "Sync events"),
|
||||
|
||||
@@ -1,21 +1,17 @@
|
||||
import { Workspace } from "@/control-plane/workspace"
|
||||
import { WorkspaceAdapterEntry } from "@/control-plane/types"
|
||||
import { NonNegativeInt } from "@/util/schema"
|
||||
import { Schema, Struct } from "effect"
|
||||
import { HttpApi, HttpApiEndpoint, HttpApiError, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
|
||||
import { HttpApi, HttpApiEndpoint, HttpApiError, HttpApiGroup, HttpApiSchema, OpenApi } from "effect/unstable/httpapi"
|
||||
import { Authorization } from "../middleware/authorization"
|
||||
import { InstanceContextMiddleware } from "../middleware/instance-context"
|
||||
import { WorkspaceRoutingMiddleware } from "../middleware/workspace-routing"
|
||||
import { described } from "./metadata"
|
||||
|
||||
const root = "/experimental/workspace"
|
||||
export const CreatePayload = Schema.Struct({
|
||||
...Struct.omit(Workspace.CreateInput.fields, ["projectID", "extra"]),
|
||||
extra: Schema.optional(Workspace.CreateInput.fields.extra),
|
||||
})
|
||||
export const SessionRestorePayload = Schema.Struct(Struct.omit(Workspace.SessionRestoreInput.fields, ["workspaceID"]))
|
||||
export const SessionRestoreResponse = Schema.Struct({
|
||||
total: NonNegativeInt,
|
||||
export const CreatePayload = Schema.Struct(Struct.omit(Workspace.CreateInput.fields, ["projectID"]))
|
||||
export const WarpPayload = Schema.Struct({
|
||||
id: Schema.NullOr(Workspace.Info.fields.id),
|
||||
sessionID: Workspace.SessionWarpInput.fields.sessionID,
|
||||
})
|
||||
|
||||
export const WorkspacePaths = {
|
||||
@@ -23,7 +19,7 @@ export const WorkspacePaths = {
|
||||
list: root,
|
||||
status: `${root}/status`,
|
||||
remove: `${root}/:id`,
|
||||
sessionRestore: `${root}/:id/session-restore`,
|
||||
warp: `${root}/warp`,
|
||||
} as const
|
||||
|
||||
export const WorkspaceApi = HttpApi.make("workspace")
|
||||
@@ -79,16 +75,15 @@ export const WorkspaceApi = HttpApi.make("workspace")
|
||||
description: "Remove an existing workspace.",
|
||||
}),
|
||||
),
|
||||
HttpApiEndpoint.post("sessionRestore", WorkspacePaths.sessionRestore, {
|
||||
params: { id: Workspace.Info.fields.id },
|
||||
payload: SessionRestorePayload,
|
||||
success: described(SessionRestoreResponse, "Session replay started"),
|
||||
HttpApiEndpoint.post("warp", WorkspacePaths.warp, {
|
||||
payload: WarpPayload,
|
||||
success: described(HttpApiSchema.NoContent, "Session warped"),
|
||||
error: HttpApiError.BadRequest,
|
||||
}).annotateMerge(
|
||||
OpenApi.annotations({
|
||||
identifier: "experimental.workspace.sessionRestore",
|
||||
summary: "Restore session into workspace",
|
||||
description: "Replay a session's sync events into the target workspace in batches.",
|
||||
identifier: "experimental.workspace.warp",
|
||||
summary: "Warp session into workspace",
|
||||
description: "Move a session's sync history into the target workspace, or detach it to the local project.",
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Workspace } from "@/control-plane/workspace"
|
||||
import * as InstanceState from "@/effect/instance-state"
|
||||
import { Session } from "@/session/session"
|
||||
import { Database } from "@/storage/db"
|
||||
import { SyncEvent } from "@/sync"
|
||||
import { EventTable } from "@/sync/event.sql"
|
||||
@@ -12,7 +13,7 @@ import { or } from "drizzle-orm"
|
||||
import { Effect, Scope } from "effect"
|
||||
import { HttpApiBuilder } from "effect/unstable/httpapi"
|
||||
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"
|
||||
|
||||
const log = Log.create({ service: "server.sync" })
|
||||
@@ -56,6 +57,25 @@ export const syncHandlers = HttpApiBuilder.group(InstanceHttpApi, "sync", (handl
|
||||
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 exclude = Object.entries(ctx.payload)
|
||||
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)
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -4,7 +4,7 @@ import * as InstanceState from "@/effect/instance-state"
|
||||
import { Effect } from "effect"
|
||||
import { HttpApiBuilder, HttpApiError } from "effect/unstable/httpapi"
|
||||
import { InstanceHttpApi } from "../api"
|
||||
import { CreatePayload, SessionRestorePayload } from "../groups/workspace"
|
||||
import { CreatePayload, WarpPayload } from "../groups/workspace"
|
||||
|
||||
export const workspaceHandlers = HttpApiBuilder.group(InstanceHttpApi, "workspace", (handlers) =>
|
||||
Effect.gen(function* () {
|
||||
@@ -39,13 +39,10 @@ export const workspaceHandlers = HttpApiBuilder.group(InstanceHttpApi, "workspac
|
||||
return yield* workspace.remove(ctx.params.id)
|
||||
})
|
||||
|
||||
const sessionRestore = Effect.fn("WorkspaceHttpApi.sessionRestore")(function* (ctx: {
|
||||
params: { id: Workspace.Info["id"] }
|
||||
payload: typeof SessionRestorePayload.Type
|
||||
}) {
|
||||
return yield* workspace
|
||||
.sessionRestore({
|
||||
workspaceID: ctx.params.id,
|
||||
const warp = Effect.fn("WorkspaceHttpApi.warp")(function* (ctx: { payload: typeof WarpPayload.Type }) {
|
||||
yield* workspace
|
||||
.sessionWarp({
|
||||
workspaceID: ctx.payload.id,
|
||||
sessionID: ctx.payload.sessionID,
|
||||
})
|
||||
.pipe(Effect.mapError(() => new HttpApiError.BadRequest({})))
|
||||
@@ -57,6 +54,6 @@ export const workspaceHandlers = HttpApiBuilder.group(InstanceHttpApi, "workspac
|
||||
.handle("create", create)
|
||||
.handle("status", status)
|
||||
.handle("remove", remove)
|
||||
.handle("sessionRestore", sessionRestore)
|
||||
.handle("warp", warp)
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -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.status, (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
|
||||
|
||||
@@ -16,6 +16,9 @@ import { Workspace } from "@/control-plane/workspace"
|
||||
import { AppRuntime } from "@/effect/app-runtime"
|
||||
import { Instance } from "@/project/instance"
|
||||
import { errors } from "../../error"
|
||||
import { Session } from "@/session/session"
|
||||
import { WorkspaceContext } from "@/control-plane/workspace-context"
|
||||
import { SessionID } from "@/session/schema"
|
||||
|
||||
const ReplayEvent = z.object({
|
||||
id: z.string(),
|
||||
@@ -24,6 +27,9 @@ const ReplayEvent = z.object({
|
||||
type: z.string(),
|
||||
data: z.record(z.string(), z.unknown()),
|
||||
})
|
||||
const SessionPayload = z.object({
|
||||
sessionID: SessionID.zod,
|
||||
})
|
||||
|
||||
const log = Log.create({ service: "server.sync" })
|
||||
|
||||
@@ -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(
|
||||
"/history",
|
||||
describeRoute({
|
||||
|
||||
@@ -3,6 +3,7 @@ import { sqliteTable, text, integer } from "drizzle-orm/sqlite-core"
|
||||
export const EventSequenceTable = sqliteTable("event_sequence", {
|
||||
aggregate_id: text().notNull().primaryKey(),
|
||||
seq: integer().notNull(),
|
||||
owner_id: text(),
|
||||
})
|
||||
|
||||
export const EventTable = sqliteTable("event", {
|
||||
|
||||
@@ -59,8 +59,11 @@ export interface Interface {
|
||||
data: Event<Def>["data"],
|
||||
options?: { publish?: boolean },
|
||||
) => Effect.Effect<void>
|
||||
readonly replay: (event: SerializedEvent, options?: { publish: boolean }) => Effect.Effect<void>
|
||||
readonly replayAll: (events: SerializedEvent[], options?: { publish: boolean }) => Effect.Effect<string | undefined>
|
||||
readonly replay: (event: SerializedEvent, options?: { publish: boolean; ownerID?: string }) => Effect.Effect<void>
|
||||
readonly replayAll: (
|
||||
events: SerializedEvent[],
|
||||
options?: { publish: boolean; ownerID?: string },
|
||||
) => Effect.Effect<string | undefined>
|
||||
readonly remove: (aggregateID: string) => Effect.Effect<void>
|
||||
}
|
||||
|
||||
@@ -76,7 +79,7 @@ export const layer = Layer.effect(Service)(
|
||||
|
||||
const row = Database.use((db) =>
|
||||
db
|
||||
.select({ seq: EventSequenceTable.seq })
|
||||
.select({ seq: EventSequenceTable.seq, ownerID: EventSequenceTable.owner_id })
|
||||
.from(EventSequenceTable)
|
||||
.where(eq(EventSequenceTable.aggregate_id, event.aggregateID))
|
||||
.get(),
|
||||
@@ -85,6 +88,10 @@ export const layer = Layer.effect(Service)(
|
||||
const latest = row?.seq ?? -1
|
||||
if (event.seq <= latest) return
|
||||
|
||||
if (row?.ownerID && row.ownerID !== options?.ownerID) {
|
||||
return
|
||||
}
|
||||
|
||||
const expected = latest + 1
|
||||
if (event.seq !== expected) {
|
||||
throw new Error(
|
||||
@@ -99,7 +106,7 @@ export const layer = Layer.effect(Service)(
|
||||
workspace: yield* InstanceState.workspaceID,
|
||||
}
|
||||
: 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) {
|
||||
@@ -263,7 +270,7 @@ export function project<Def extends Definition>(
|
||||
function process<Def extends Definition>(
|
||||
def: Def,
|
||||
event: Event<Def>,
|
||||
options: { publish: boolean; context?: PublishContext },
|
||||
options: { publish: boolean; context?: PublishContext; ownerID?: string },
|
||||
) {
|
||||
if (projectors == null) {
|
||||
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}`)
|
||||
}
|
||||
|
||||
// idempotent: need to ignore any events already logged
|
||||
|
||||
Database.transaction((tx) => {
|
||||
projector(tx, event.data, event)
|
||||
|
||||
@@ -284,6 +289,7 @@ function process<Def extends Definition>(
|
||||
.values({
|
||||
aggregate_id: event.aggregateID,
|
||||
seq: event.seq,
|
||||
owner_id: options?.ownerID,
|
||||
})
|
||||
.onConflictDoUpdate({
|
||||
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))
|
||||
}
|
||||
|
||||
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))
|
||||
}
|
||||
|
||||
@@ -348,6 +354,16 @@ export function remove(aggregateID: string) {
|
||||
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() {
|
||||
return registry
|
||||
.entries()
|
||||
|
||||
@@ -6,7 +6,7 @@ import { setTimeout as delay } from "node:timers/promises"
|
||||
import { NodeHttpServer } from "@effect/platform-node"
|
||||
import { Effect, Layer } from "effect"
|
||||
import { HttpServer, HttpServerRequest, HttpServerResponse } from "effect/unstable/http"
|
||||
import { asc, eq } from "drizzle-orm"
|
||||
import { eq } from "drizzle-orm"
|
||||
import * as Log from "@opencode-ai/core/util/log"
|
||||
import { Flag } from "@opencode-ai/core/flag/flag"
|
||||
import { GlobalBus, type GlobalEvent } from "@/bus/global"
|
||||
@@ -16,11 +16,10 @@ import { ProjectTable } from "@/project/project.sql"
|
||||
import { Instance } from "@/project/instance"
|
||||
import { WithInstance } from "../../src/project/with-instance"
|
||||
import { Session as SessionNs } from "@/session/session"
|
||||
import { SessionID, MessageID, PartID } from "@/session/schema"
|
||||
import { SessionID } from "@/session/schema"
|
||||
import { SessionTable } from "@/session/session.sql"
|
||||
import { ModelID, ProviderID } from "@/provider/schema"
|
||||
import { SyncEvent } from "@/sync"
|
||||
import { EventSequenceTable, EventTable } from "@/sync/event.sql"
|
||||
import { EventSequenceTable } from "@/sync/event.sql"
|
||||
import { resetDatabase } from "../fixture/db"
|
||||
import { disposeAllInstances, provideTmpdirInstance, tmpdir } from "../fixture/fixture"
|
||||
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 createWorkspace = (input: WorkspaceOld.CreateInput) =>
|
||||
runWorkspace(WorkspaceOld.Service.use((workspace) => workspace.create(input)))
|
||||
const restoreWorkspaceSession = (input: WorkspaceOld.SessionRestoreInput) =>
|
||||
runWorkspace(WorkspaceOld.Service.use((workspace) => workspace.sessionRestore(input)))
|
||||
const warpWorkspaceSession = (input: WorkspaceOld.SessionWarpInput) =>
|
||||
runWorkspace(WorkspaceOld.Service.use((workspace) => workspace.sessionWarp(input)))
|
||||
const listWorkspaces = (project: Parameters<WorkspaceOld.Interface["list"]>[0]) =>
|
||||
runWorkspace(WorkspaceOld.Service.use((workspace) => workspace.list(project)))
|
||||
const getWorkspace = (id: WorkspaceID) => runWorkspace(WorkspaceOld.Service.use((workspace) => workspace.get(id)))
|
||||
@@ -317,48 +316,24 @@ function sessionSequence(sessionID: SessionID) {
|
||||
)?.seq
|
||||
}
|
||||
|
||||
function eventRows(sessionID: SessionID) {
|
||||
function sessionSequenceOwner(sessionID: SessionID) {
|
||||
return Database.use((db) =>
|
||||
db
|
||||
.select({ seq: EventTable.seq, type: EventTable.type, data: EventTable.data })
|
||||
.from(EventTable)
|
||||
.where(eq(EventTable.aggregate_id, sessionID))
|
||||
.orderBy(asc(EventTable.seq))
|
||||
.all(),
|
||||
)
|
||||
.select({ ownerID: EventSequenceTable.owner_id })
|
||||
.from(EventSequenceTable)
|
||||
.where(eq(EventSequenceTable.aggregate_id, sessionID))
|
||||
.get(),
|
||||
)?.ownerID
|
||||
}
|
||||
|
||||
function sessionUpdatedType() {
|
||||
return SyncEvent.versionedType(SessionNs.Event.Updated.type, SessionNs.Event.Updated.version)
|
||||
}
|
||||
|
||||
function replaceSessionEvents(sessionID: SessionID, count: number) {
|
||||
Database.use((db) => {
|
||||
db.delete(EventSequenceTable).where(eq(EventSequenceTable.aggregate_id, sessionID)).run()
|
||||
if (count === 0) return
|
||||
|
||||
db.insert(EventSequenceTable)
|
||||
.values({ aggregate_id: sessionID, seq: count - 1 })
|
||||
.run()
|
||||
db.insert(EventTable)
|
||||
.values(
|
||||
Array.from({ length: count }, (_, i) => ({
|
||||
id: `evt_${unique(`manual-${i}`)}`,
|
||||
aggregate_id: sessionID,
|
||||
seq: i,
|
||||
type: sessionUpdatedType(),
|
||||
data: { sessionID, info: { title: `manual ${i}` } },
|
||||
})),
|
||||
)
|
||||
.run()
|
||||
})
|
||||
}
|
||||
|
||||
describe("workspace-old schemas and exports", () => {
|
||||
test("keeps the historical event type names", () => {
|
||||
expect(WorkspaceOld.Event.Ready.type).toBe("workspace.ready")
|
||||
expect(WorkspaceOld.Event.Failed.type).toBe("workspace.failed")
|
||||
expect(WorkspaceOld.Event.Restore.type).toBe("workspace.restore")
|
||||
expect(WorkspaceOld.Event.Status.type).toBe("workspace.status")
|
||||
})
|
||||
|
||||
@@ -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, 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", () => {
|
||||
@@ -651,6 +615,144 @@ describe("workspace-old CRUD", () => {
|
||||
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", () => {
|
||||
@@ -1215,313 +1317,3 @@ describe("workspace-old waitForSync", () => {
|
||||
})
|
||||
}, 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 },
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -175,15 +175,12 @@ describe("workspace HttpApi", () => {
|
||||
expect(workspace).toMatchObject({ type: "local-test", name: "local-test" })
|
||||
|
||||
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",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ sessionID: session.id }),
|
||||
})
|
||||
expect(restored.status).toBe(200)
|
||||
expect((yield* Effect.promise(() => restored.json())) as { total: number }).toMatchObject({
|
||||
total: expect.any(Number),
|
||||
body: JSON.stringify({ id: workspace.id, sessionID: session.id }),
|
||||
})
|
||||
expect(warped.status).toBe(204)
|
||||
|
||||
const removed = yield* request(WorkspacePaths.remove.replace(":id", workspace.id), dir, { method: "DELETE" })
|
||||
expect(removed.status).toBe(200)
|
||||
@@ -205,7 +202,7 @@ describe("workspace HttpApi", () => {
|
||||
const created = yield* request(WorkspacePaths.list, dir, {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ type: "local-test", branch: null }),
|
||||
body: JSON.stringify({ type: "local-test", branch: null, extra: null }),
|
||||
})
|
||||
|
||||
expect(created.status).toBe(200)
|
||||
@@ -225,7 +222,7 @@ describe("workspace HttpApi", () => {
|
||||
const created = yield* request(WorkspacePaths.list, dir, {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ type: "worktree", branch: null }),
|
||||
body: JSON.stringify({ type: "worktree", branch: null, extra: null }),
|
||||
})
|
||||
|
||||
const body = yield* Effect.promise(() => created.text())
|
||||
|
||||
@@ -5,7 +5,7 @@ import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
|
||||
import { Bus } from "../../src/bus"
|
||||
import { SyncEvent } from "../../src/sync"
|
||||
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 { Flag } from "@opencode-ai/core/flag/flag"
|
||||
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" })
|
||||
}),
|
||||
),
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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 type {
|
||||
AgentPartInput,
|
||||
AppAgentsErrors,
|
||||
AppAgentsResponses,
|
||||
AppLogErrors,
|
||||
AppLogResponses,
|
||||
AppSkillsErrors,
|
||||
AppSkillsResponses,
|
||||
Auth as Auth3,
|
||||
AuthRemoveErrors,
|
||||
AuthRemoveResponses,
|
||||
AuthSetErrors,
|
||||
AuthSetResponses,
|
||||
CommandListErrors,
|
||||
CommandListResponses,
|
||||
Config as Config3,
|
||||
ConfigGetErrors,
|
||||
ConfigGetResponses,
|
||||
ConfigProvidersErrors,
|
||||
ConfigProvidersResponses,
|
||||
ConfigUpdateErrors,
|
||||
ConfigUpdateResponses,
|
||||
EventSubscribeErrors,
|
||||
EventSubscribeResponses,
|
||||
EventTuiCommandExecute2,
|
||||
EventTuiPromptAppend2,
|
||||
EventTuiSessionSelect2,
|
||||
EventTuiToastShow2,
|
||||
ExperimentalConsoleGetErrors,
|
||||
ExperimentalConsoleGetResponses,
|
||||
ExperimentalConsoleListOrgsErrors,
|
||||
ExperimentalConsoleListOrgsResponses,
|
||||
ExperimentalConsoleSwitchOrgResponses,
|
||||
ExperimentalResourceListErrors,
|
||||
ExperimentalResourceListResponses,
|
||||
ExperimentalSessionListErrors,
|
||||
ExperimentalSessionListResponses,
|
||||
ExperimentalWorkspaceAdapterListErrors,
|
||||
ExperimentalWorkspaceAdapterListResponses,
|
||||
ExperimentalWorkspaceCreateErrors,
|
||||
ExperimentalWorkspaceCreateResponses,
|
||||
ExperimentalWorkspaceListErrors,
|
||||
ExperimentalWorkspaceListResponses,
|
||||
ExperimentalWorkspaceRemoveErrors,
|
||||
ExperimentalWorkspaceRemoveResponses,
|
||||
ExperimentalWorkspaceSessionRestoreErrors,
|
||||
ExperimentalWorkspaceSessionRestoreResponses,
|
||||
ExperimentalWorkspaceStatusErrors,
|
||||
ExperimentalWorkspaceStatusResponses,
|
||||
ExperimentalWorkspaceWarpErrors,
|
||||
ExperimentalWorkspaceWarpResponses,
|
||||
FileListErrors,
|
||||
FileListResponses,
|
||||
FilePartInput,
|
||||
FilePartSource,
|
||||
FileReadErrors,
|
||||
FileReadResponses,
|
||||
FileStatusErrors,
|
||||
FileStatusResponses,
|
||||
FindFilesErrors,
|
||||
FindFilesResponses,
|
||||
FindSymbolsErrors,
|
||||
FindSymbolsResponses,
|
||||
FindTextErrors,
|
||||
FindTextResponses,
|
||||
FormatterStatusErrors,
|
||||
FormatterStatusResponses,
|
||||
GlobalConfigGetErrors,
|
||||
GlobalConfigGetResponses,
|
||||
GlobalConfigUpdateErrors,
|
||||
GlobalConfigUpdateResponses,
|
||||
GlobalDisposeErrors,
|
||||
GlobalDisposeResponses,
|
||||
GlobalEventErrors,
|
||||
GlobalEventResponses,
|
||||
GlobalHealthErrors,
|
||||
GlobalHealthResponses,
|
||||
GlobalUpgradeErrors,
|
||||
GlobalUpgradeResponses,
|
||||
InstanceDisposeErrors,
|
||||
InstanceDisposeResponses,
|
||||
LspStatusErrors,
|
||||
LspStatusResponses,
|
||||
McpAddErrors,
|
||||
McpAddResponses,
|
||||
@@ -67,10 +93,13 @@ import type {
|
||||
McpAuthRemoveResponses,
|
||||
McpAuthStartErrors,
|
||||
McpAuthStartResponses,
|
||||
McpConnectErrors,
|
||||
McpConnectResponses,
|
||||
McpDisconnectErrors,
|
||||
McpDisconnectResponses,
|
||||
McpLocalConfig,
|
||||
McpRemoteConfig,
|
||||
McpStatusErrors,
|
||||
McpStatusResponses,
|
||||
OutputFormat,
|
||||
Part as Part2,
|
||||
@@ -78,20 +107,27 @@ import type {
|
||||
PartDeleteResponses,
|
||||
PartUpdateErrors,
|
||||
PartUpdateResponses,
|
||||
PathGetErrors,
|
||||
PathGetResponses,
|
||||
PermissionListErrors,
|
||||
PermissionListResponses,
|
||||
PermissionReplyErrors,
|
||||
PermissionReplyResponses,
|
||||
PermissionRespondErrors,
|
||||
PermissionRespondResponses,
|
||||
PermissionRuleset,
|
||||
ProjectCurrentErrors,
|
||||
ProjectCurrentResponses,
|
||||
ProjectInitGitErrors,
|
||||
ProjectInitGitResponses,
|
||||
ProjectListErrors,
|
||||
ProjectListResponses,
|
||||
ProjectUpdateErrors,
|
||||
ProjectUpdateResponses,
|
||||
Prompt,
|
||||
ProviderAuthErrors,
|
||||
ProviderAuthResponses,
|
||||
ProviderListErrors,
|
||||
ProviderListResponses,
|
||||
ProviderOauthAuthorizeErrors,
|
||||
ProviderOauthAuthorizeResponses,
|
||||
@@ -105,13 +141,16 @@ import type {
|
||||
PtyCreateResponses,
|
||||
PtyGetErrors,
|
||||
PtyGetResponses,
|
||||
PtyListErrors,
|
||||
PtyListResponses,
|
||||
PtyRemoveErrors,
|
||||
PtyRemoveResponses,
|
||||
PtyShellsErrors,
|
||||
PtyShellsResponses,
|
||||
PtyUpdateErrors,
|
||||
PtyUpdateResponses,
|
||||
QuestionAnswer,
|
||||
QuestionListErrors,
|
||||
QuestionListResponses,
|
||||
QuestionRejectErrors,
|
||||
QuestionRejectResponses,
|
||||
@@ -130,12 +169,15 @@ import type {
|
||||
SessionDeleteMessageResponses,
|
||||
SessionDeleteResponses,
|
||||
SessionDelivery,
|
||||
SessionDiffErrors,
|
||||
SessionDiffResponses,
|
||||
SessionForkErrors,
|
||||
SessionForkResponses,
|
||||
SessionGetErrors,
|
||||
SessionGetResponses,
|
||||
SessionInitErrors,
|
||||
SessionInitResponses,
|
||||
SessionListErrors,
|
||||
SessionListResponses,
|
||||
SessionMessageErrors,
|
||||
SessionMessageResponses,
|
||||
@@ -168,7 +210,10 @@ import type {
|
||||
SyncHistoryListResponses,
|
||||
SyncReplayErrors,
|
||||
SyncReplayResponses,
|
||||
SyncStartErrors,
|
||||
SyncStartResponses,
|
||||
SyncStealErrors,
|
||||
SyncStealResponses,
|
||||
TextPartInput,
|
||||
ToolIdsErrors,
|
||||
ToolIdsResponses,
|
||||
@@ -176,34 +221,50 @@ import type {
|
||||
ToolListResponses,
|
||||
TuiAppendPromptErrors,
|
||||
TuiAppendPromptResponses,
|
||||
TuiClearPromptErrors,
|
||||
TuiClearPromptResponses,
|
||||
TuiControlNextErrors,
|
||||
TuiControlNextResponses,
|
||||
TuiControlResponseErrors,
|
||||
TuiControlResponseResponses,
|
||||
TuiExecuteCommandErrors,
|
||||
TuiExecuteCommandResponses,
|
||||
TuiOpenHelpErrors,
|
||||
TuiOpenHelpResponses,
|
||||
TuiOpenModelsErrors,
|
||||
TuiOpenModelsResponses,
|
||||
TuiOpenSessionsErrors,
|
||||
TuiOpenSessionsResponses,
|
||||
TuiOpenThemesErrors,
|
||||
TuiOpenThemesResponses,
|
||||
TuiPublishErrors,
|
||||
TuiPublishResponses,
|
||||
TuiSelectSessionErrors,
|
||||
TuiSelectSessionResponses,
|
||||
TuiShowToastErrors,
|
||||
TuiShowToastResponses,
|
||||
TuiSubmitPromptErrors,
|
||||
TuiSubmitPromptResponses,
|
||||
V2SessionCompactErrors,
|
||||
V2SessionCompactResponses,
|
||||
V2SessionContextErrors,
|
||||
V2SessionContextResponses,
|
||||
V2SessionListErrors,
|
||||
V2SessionListResponses,
|
||||
V2SessionMessagesErrors,
|
||||
V2SessionMessagesResponses,
|
||||
V2SessionPromptErrors,
|
||||
V2SessionPromptResponses,
|
||||
V2SessionWaitErrors,
|
||||
V2SessionWaitResponses,
|
||||
VcsDiffErrors,
|
||||
VcsDiffResponses,
|
||||
VcsGetErrors,
|
||||
VcsGetResponses,
|
||||
WorktreeCreateErrors,
|
||||
WorktreeCreateInput,
|
||||
WorktreeCreateResponses,
|
||||
WorktreeListErrors,
|
||||
WorktreeListResponses,
|
||||
WorktreeRemoveErrors,
|
||||
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",
|
||||
...options,
|
||||
...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",
|
||||
...options,
|
||||
...params,
|
||||
@@ -426,7 +487,7 @@ export class Config extends HeyApiClient {
|
||||
* Retrieve the current global OpenCode configuration settings and preferences.
|
||||
*/
|
||||
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",
|
||||
...options,
|
||||
})
|
||||
@@ -464,7 +525,7 @@ export class Global extends HeyApiClient {
|
||||
* Get health information about the OpenCode server.
|
||||
*/
|
||||
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",
|
||||
...options,
|
||||
})
|
||||
@@ -476,7 +537,7 @@ export class Global extends HeyApiClient {
|
||||
* Subscribe to global events from the OpenCode system using server-sent events.
|
||||
*/
|
||||
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",
|
||||
...options,
|
||||
})
|
||||
@@ -488,7 +549,7 @@ export class Global extends HeyApiClient {
|
||||
* Clean up and dispose all OpenCode instances, releasing all resources.
|
||||
*/
|
||||
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",
|
||||
...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",
|
||||
...options,
|
||||
...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",
|
||||
...options,
|
||||
...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",
|
||||
...options,
|
||||
...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",
|
||||
...options,
|
||||
...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",
|
||||
...options,
|
||||
...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",
|
||||
...options,
|
||||
...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",
|
||||
...options,
|
||||
...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",
|
||||
...options,
|
||||
...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",
|
||||
...options,
|
||||
...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",
|
||||
...options,
|
||||
...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>(
|
||||
parameters: {
|
||||
id: string
|
||||
public warp<ThrowOnError extends boolean = false>(
|
||||
parameters?: {
|
||||
directory?: string
|
||||
workspace?: string
|
||||
id?: string | null
|
||||
sessionID?: string
|
||||
},
|
||||
options?: Options<never, ThrowOnError>,
|
||||
@@ -1027,20 +1116,20 @@ export class Workspace extends HeyApiClient {
|
||||
[
|
||||
{
|
||||
args: [
|
||||
{ in: "path", key: "id" },
|
||||
{ in: "query", key: "directory" },
|
||||
{ in: "query", key: "workspace" },
|
||||
{ in: "body", key: "id" },
|
||||
{ in: "body", key: "sessionID" },
|
||||
],
|
||||
},
|
||||
],
|
||||
)
|
||||
return (options?.client ?? this.client).post<
|
||||
ExperimentalWorkspaceSessionRestoreResponses,
|
||||
ExperimentalWorkspaceSessionRestoreErrors,
|
||||
ExperimentalWorkspaceWarpResponses,
|
||||
ExperimentalWorkspaceWarpErrors,
|
||||
ThrowOnError
|
||||
>({
|
||||
url: "/experimental/workspace/{id}/session-restore",
|
||||
url: "/experimental/workspace/warp",
|
||||
...options,
|
||||
...params,
|
||||
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",
|
||||
...options,
|
||||
...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",
|
||||
...options,
|
||||
...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",
|
||||
...options,
|
||||
...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",
|
||||
...options,
|
||||
...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",
|
||||
...options,
|
||||
...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",
|
||||
...options,
|
||||
...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",
|
||||
...options,
|
||||
...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",
|
||||
...options,
|
||||
...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",
|
||||
...options,
|
||||
...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",
|
||||
...options,
|
||||
...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",
|
||||
...options,
|
||||
...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",
|
||||
...options,
|
||||
...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",
|
||||
...options,
|
||||
...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",
|
||||
...options,
|
||||
...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",
|
||||
...options,
|
||||
...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",
|
||||
...options,
|
||||
...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",
|
||||
...options,
|
||||
...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",
|
||||
...options,
|
||||
...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",
|
||||
...options,
|
||||
...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",
|
||||
...options,
|
||||
...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",
|
||||
...options,
|
||||
...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",
|
||||
...options,
|
||||
...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",
|
||||
...options,
|
||||
...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",
|
||||
...options,
|
||||
...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",
|
||||
...options,
|
||||
...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",
|
||||
...options,
|
||||
...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",
|
||||
...options,
|
||||
...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",
|
||||
...options,
|
||||
...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",
|
||||
...options,
|
||||
...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",
|
||||
...options,
|
||||
...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
|
||||
get history(): History {
|
||||
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",
|
||||
...options,
|
||||
...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",
|
||||
...options,
|
||||
...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",
|
||||
...options,
|
||||
...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",
|
||||
...options,
|
||||
...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",
|
||||
...options,
|
||||
...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",
|
||||
...options,
|
||||
...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",
|
||||
...options,
|
||||
...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",
|
||||
...options,
|
||||
...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",
|
||||
...options,
|
||||
...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",
|
||||
...options,
|
||||
...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",
|
||||
...options,
|
||||
...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",
|
||||
...options,
|
||||
...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",
|
||||
...options,
|
||||
...params,
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user