feat(core): session warping (#25768)
This commit is contained in:
parent
84afd2bef8
commit
22a4a9df8b
25 changed files with 4029 additions and 1092 deletions
|
|
@ -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),
|
.status(200),
|
||||||
http
|
http
|
||||||
.post("/experimental/workspace/{id}/session-restore", "experimental.workspace.sessionRestore")
|
.post("/experimental/workspace/warp", "experimental.workspace.warp")
|
||||||
.at((ctx) => ({
|
.at((ctx) => ({
|
||||||
path: route("/experimental/workspace/{id}/session-restore", { id: "wrk_httpapi_missing" }),
|
path: "/experimental/workspace/warp",
|
||||||
headers: ctx.headers(),
|
headers: ctx.headers(),
|
||||||
body: {},
|
body: {},
|
||||||
}))
|
}))
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@ import { useDialog } from "@tui/ui/dialog"
|
||||||
import { DialogSelect } from "@tui/ui/dialog-select"
|
import { DialogSelect } from "@tui/ui/dialog-select"
|
||||||
import { useRoute } from "@tui/context/route"
|
import { useRoute } from "@tui/context/route"
|
||||||
import { useSync } from "@tui/context/sync"
|
import { useSync } from "@tui/context/sync"
|
||||||
import { createMemo, createResource, createSignal, onMount } from "solid-js"
|
import { createMemo, createResource, createSignal, onMount, type JSX } from "solid-js"
|
||||||
import { Locale } from "@/util/locale"
|
import { Locale } from "@/util/locale"
|
||||||
import { useProject } from "@tui/context/project"
|
import { useProject } from "@tui/context/project"
|
||||||
import { useKeybind } from "../context/keybind"
|
import { useKeybind } from "../context/keybind"
|
||||||
|
|
@ -10,15 +10,13 @@ import { useTheme } from "../context/theme"
|
||||||
import { useSDK } from "../context/sdk"
|
import { useSDK } from "../context/sdk"
|
||||||
import { Flag } from "@opencode-ai/core/flag/flag"
|
import { Flag } from "@opencode-ai/core/flag/flag"
|
||||||
import { DialogSessionRename } from "./dialog-session-rename"
|
import { DialogSessionRename } from "./dialog-session-rename"
|
||||||
import { Keybind } from "@/util/keybind"
|
|
||||||
import { createDebouncedSignal } from "../util/signal"
|
import { createDebouncedSignal } from "../util/signal"
|
||||||
import { useToast } from "../ui/toast"
|
import { useToast } from "../ui/toast"
|
||||||
import { DialogWorkspaceCreate, openWorkspaceSession, restoreWorkspaceSession } from "./dialog-workspace-create"
|
import { openWorkspaceSelect, type WorkspaceSelection, warpWorkspaceSession } from "./dialog-workspace-create"
|
||||||
import { Spinner } from "./spinner"
|
import { Spinner } from "./spinner"
|
||||||
import { errorMessage } from "@/util/error"
|
import { errorMessage } from "@/util/error"
|
||||||
import { DialogSessionDeleteFailed } from "./dialog-session-delete-failed"
|
import { DialogSessionDeleteFailed } from "./dialog-session-delete-failed"
|
||||||
|
import { WorkspaceLabel } from "./workspace-label"
|
||||||
type WorkspaceStatus = "connected" | "connecting" | "disconnected" | "error"
|
|
||||||
|
|
||||||
export function DialogSessionList() {
|
export function DialogSessionList() {
|
||||||
const dialog = useDialog()
|
const dialog = useDialog()
|
||||||
|
|
@ -44,26 +42,39 @@ export function DialogSessionList() {
|
||||||
const currentSessionID = createMemo(() => (route.data.type === "session" ? route.data.sessionID : undefined))
|
const currentSessionID = createMemo(() => (route.data.type === "session" ? route.data.sessionID : undefined))
|
||||||
const sessions = createMemo(() => searchResults() ?? sync.data.session)
|
const sessions = createMemo(() => searchResults() ?? sync.data.session)
|
||||||
|
|
||||||
function createWorkspace() {
|
|
||||||
dialog.replace(() => (
|
|
||||||
<DialogWorkspaceCreate
|
|
||||||
onSelect={(workspaceID) =>
|
|
||||||
openWorkspaceSession({
|
|
||||||
dialog,
|
|
||||||
route,
|
|
||||||
sdk,
|
|
||||||
sync,
|
|
||||||
toast,
|
|
||||||
workspaceID,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
))
|
|
||||||
}
|
|
||||||
|
|
||||||
function recover(session: NonNullable<ReturnType<typeof sessions>[number]>) {
|
function recover(session: NonNullable<ReturnType<typeof sessions>[number]>) {
|
||||||
const workspace = project.workspace.get(session.workspaceID!)
|
const workspace = project.workspace.get(session.workspaceID!)
|
||||||
const list = () => dialog.replace(() => <DialogSessionList />)
|
const list = () => dialog.replace(() => <DialogSessionList />)
|
||||||
|
const warp = async (selection: WorkspaceSelection) => {
|
||||||
|
const workspaceID = await (async () => {
|
||||||
|
if (selection.type === "none") return null
|
||||||
|
if (selection.type === "existing") return selection.workspaceID
|
||||||
|
const result = await sdk.client.experimental.workspace
|
||||||
|
.create({ type: selection.workspaceType, branch: null })
|
||||||
|
.catch(() => undefined)
|
||||||
|
const workspace = result?.data
|
||||||
|
if (!workspace) {
|
||||||
|
toast.show({
|
||||||
|
message: `Failed to create workspace: ${errorMessage(result?.error ?? "no response")}`,
|
||||||
|
variant: "error",
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
await project.workspace.sync()
|
||||||
|
return workspace.id
|
||||||
|
})()
|
||||||
|
if (workspaceID === undefined) return
|
||||||
|
await warpWorkspaceSession({
|
||||||
|
dialog,
|
||||||
|
sdk,
|
||||||
|
sync,
|
||||||
|
project,
|
||||||
|
toast,
|
||||||
|
workspaceID,
|
||||||
|
sessionID: session.id,
|
||||||
|
done: list,
|
||||||
|
})
|
||||||
|
}
|
||||||
dialog.replace(() => (
|
dialog.replace(() => (
|
||||||
<DialogSessionDeleteFailed
|
<DialogSessionDeleteFailed
|
||||||
session={session.title}
|
session={session.title}
|
||||||
|
|
@ -90,22 +101,15 @@ export function DialogSessionList() {
|
||||||
return true
|
return true
|
||||||
}}
|
}}
|
||||||
onRestore={() => {
|
onRestore={() => {
|
||||||
dialog.replace(() => (
|
void openWorkspaceSelect({
|
||||||
<DialogWorkspaceCreate
|
dialog,
|
||||||
onSelect={(workspaceID) =>
|
sdk,
|
||||||
restoreWorkspaceSession({
|
sync,
|
||||||
dialog,
|
toast,
|
||||||
sdk,
|
onSelect: (selection) => {
|
||||||
sync,
|
void warp(selection)
|
||||||
project,
|
},
|
||||||
toast,
|
})
|
||||||
workspaceID,
|
|
||||||
sessionID: session.id,
|
|
||||||
done: list,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
))
|
|
||||||
return false
|
return false
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
@ -124,30 +128,17 @@ export function DialogSessionList() {
|
||||||
.map((x) => {
|
.map((x) => {
|
||||||
const workspace = x.workspaceID ? project.workspace.get(x.workspaceID) : undefined
|
const workspace = x.workspaceID ? project.workspace.get(x.workspaceID) : undefined
|
||||||
|
|
||||||
let workspaceStatus: WorkspaceStatus | null = null
|
let footer: JSX.Element | string = ""
|
||||||
if (x.workspaceID) {
|
|
||||||
workspaceStatus = project.workspace.status(x.workspaceID) || "error"
|
|
||||||
}
|
|
||||||
|
|
||||||
let footer = ""
|
|
||||||
if (Flag.OPENCODE_EXPERIMENTAL_WORKSPACES) {
|
if (Flag.OPENCODE_EXPERIMENTAL_WORKSPACES) {
|
||||||
if (x.workspaceID) {
|
if (x.workspaceID) {
|
||||||
let desc = "unknown"
|
footer = workspace ? (
|
||||||
if (workspace) {
|
<WorkspaceLabel
|
||||||
desc = `${workspace.type}: ${workspace.name}`
|
type={workspace.type}
|
||||||
}
|
name={workspace.name}
|
||||||
|
status={project.workspace.status(x.workspaceID) ?? "error"}
|
||||||
footer = (
|
/>
|
||||||
<>
|
) : (
|
||||||
{desc}{" "}
|
<WorkspaceLabel type="unknown" name={x.workspaceID} status="error" />
|
||||||
<span
|
|
||||||
style={{
|
|
||||||
fg: workspaceStatus === "connected" ? theme.success : theme.error,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
●
|
|
||||||
</span>
|
|
||||||
</>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -250,15 +241,6 @@ export function DialogSessionList() {
|
||||||
dialog.replace(() => <DialogSessionRename session={option.value} />)
|
dialog.replace(() => <DialogSessionRename session={option.value} />)
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
|
||||||
keybind: Keybind.parse("ctrl+w")[0],
|
|
||||||
title: "new workspace",
|
|
||||||
side: "right",
|
|
||||||
disabled: !Flag.OPENCODE_EXPERIMENTAL_WORKSPACES,
|
|
||||||
onTrigger: () => {
|
|
||||||
createWorkspace()
|
|
||||||
},
|
|
||||||
},
|
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,9 @@
|
||||||
import { createOpencodeClient } from "@opencode-ai/sdk/v2"
|
import type { Workspace } from "@opencode-ai/sdk/v2"
|
||||||
import { useDialog } from "@tui/ui/dialog"
|
import { useDialog } from "@tui/ui/dialog"
|
||||||
import { DialogSelect } from "@tui/ui/dialog-select"
|
import { DialogSelect, type DialogSelectOption } from "@tui/ui/dialog-select"
|
||||||
import { useRoute } from "@tui/context/route"
|
|
||||||
import { useSync } from "@tui/context/sync"
|
import { useSync } from "@tui/context/sync"
|
||||||
import { useProject } from "@tui/context/project"
|
import { useProject } from "@tui/context/project"
|
||||||
import { createMemo, createSignal, onMount } from "solid-js"
|
import { createMemo, createSignal, onMount } from "solid-js"
|
||||||
import { setTimeout as sleep } from "node:timers/promises"
|
|
||||||
import { errorMessage } from "@/util/error"
|
import { errorMessage } from "@/util/error"
|
||||||
import { useSDK } from "../context/sdk"
|
import { useSDK } from "../context/sdk"
|
||||||
import { useToast } from "../ui/toast"
|
import { useToast } from "../ui/toast"
|
||||||
|
|
@ -16,184 +14,212 @@ type Adapter = {
|
||||||
description: string
|
description: string
|
||||||
}
|
}
|
||||||
|
|
||||||
function scoped(sdk: ReturnType<typeof useSDK>, sync: ReturnType<typeof useSync>, workspaceID: string) {
|
export type WorkspaceSelection =
|
||||||
return createOpencodeClient({
|
| {
|
||||||
baseUrl: sdk.url,
|
type: "none"
|
||||||
fetch: sdk.fetch,
|
}
|
||||||
directory: sync.path.directory || sdk.directory,
|
| {
|
||||||
experimental_workspaceID: workspaceID,
|
type: "new"
|
||||||
})
|
workspaceType: string
|
||||||
}
|
workspaceName: string
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
type: "existing"
|
||||||
|
workspaceID: string
|
||||||
|
workspaceType: string
|
||||||
|
workspaceName: string
|
||||||
|
}
|
||||||
|
|
||||||
export async function openWorkspaceSession(input: {
|
type WorkspaceSelectValue = WorkspaceSelection | { type: "existing-list" }
|
||||||
dialog: ReturnType<typeof useDialog>
|
type ExistingWorkspaceSelectValue = { workspace: Workspace }
|
||||||
route: ReturnType<typeof useRoute>
|
|
||||||
|
async function loadWorkspaceAdapters(input: {
|
||||||
sdk: ReturnType<typeof useSDK>
|
sdk: ReturnType<typeof useSDK>
|
||||||
sync: ReturnType<typeof useSync>
|
sync: ReturnType<typeof useSync>
|
||||||
toast: ReturnType<typeof useToast>
|
toast: ReturnType<typeof useToast>
|
||||||
workspaceID: string
|
|
||||||
}) {
|
}) {
|
||||||
const client = scoped(input.sdk, input.sync, input.workspaceID)
|
const dir = input.sync.path.directory || input.sdk.directory
|
||||||
|
const url = new URL("/experimental/workspace/adapter", input.sdk.url)
|
||||||
while (true) {
|
if (dir) url.searchParams.set("directory", dir)
|
||||||
const result = await client.session.create({ workspace: input.workspaceID }).catch(() => undefined)
|
const res = await input.sdk
|
||||||
if (!result) {
|
.fetch(url)
|
||||||
input.toast.show({
|
.then((x) => x.json() as Promise<Adapter[]>)
|
||||||
message: "Failed to create workspace session",
|
.catch(() => undefined)
|
||||||
variant: "error",
|
if (res) return res
|
||||||
})
|
input.toast.show({
|
||||||
return
|
message: "Failed to load workspace adapters",
|
||||||
}
|
variant: "error",
|
||||||
if (result.response?.status && result.response.status >= 500 && result.response.status < 600) {
|
})
|
||||||
await sleep(1000)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if (!result.data) {
|
|
||||||
input.toast.show({
|
|
||||||
message: "Failed to create workspace session",
|
|
||||||
variant: "error",
|
|
||||||
})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
input.route.navigate({
|
|
||||||
type: "session",
|
|
||||||
sessionID: result.data.id,
|
|
||||||
})
|
|
||||||
input.dialog.clear()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function restoreWorkspaceSession(input: {
|
export async function openWorkspaceSelect(input: {
|
||||||
|
dialog: ReturnType<typeof useDialog>
|
||||||
|
sdk: ReturnType<typeof useSDK>
|
||||||
|
sync: ReturnType<typeof useSync>
|
||||||
|
toast: ReturnType<typeof useToast>
|
||||||
|
onSelect: (selection: WorkspaceSelection) => Promise<void> | void
|
||||||
|
}) {
|
||||||
|
input.dialog.clear()
|
||||||
|
const adapters = await loadWorkspaceAdapters(input)
|
||||||
|
if (!adapters) return
|
||||||
|
input.dialog.replace(() => <DialogWorkspaceSelect adapters={adapters} onSelect={input.onSelect} />)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function warpWorkspaceSession(input: {
|
||||||
dialog: ReturnType<typeof useDialog>
|
dialog: ReturnType<typeof useDialog>
|
||||||
sdk: ReturnType<typeof useSDK>
|
sdk: ReturnType<typeof useSDK>
|
||||||
sync: ReturnType<typeof useSync>
|
sync: ReturnType<typeof useSync>
|
||||||
project: ReturnType<typeof useProject>
|
project: ReturnType<typeof useProject>
|
||||||
toast: ReturnType<typeof useToast>
|
toast: ReturnType<typeof useToast>
|
||||||
workspaceID: string
|
workspaceID: string | null
|
||||||
sessionID: string
|
sessionID: string
|
||||||
done?: () => void
|
done?: () => void
|
||||||
}) {
|
}): Promise<boolean> {
|
||||||
const result = await input.sdk.client.experimental.workspace
|
const result = await input.sdk.client.experimental.workspace
|
||||||
.sessionRestore({ id: input.workspaceID, sessionID: input.sessionID })
|
.warp({
|
||||||
|
id: input.workspaceID,
|
||||||
|
sessionID: input.sessionID,
|
||||||
|
})
|
||||||
.catch(() => undefined)
|
.catch(() => undefined)
|
||||||
if (!result?.data) {
|
if (!result?.data) {
|
||||||
input.toast.show({
|
input.toast.show({
|
||||||
message: `Failed to restore session: ${errorMessage(result?.error ?? "no response")}`,
|
message: `Failed to warp session: ${errorMessage(result?.error ?? "no response")}`,
|
||||||
variant: "error",
|
variant: "error",
|
||||||
})
|
})
|
||||||
return
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
input.project.workspace.set(input.workspaceID)
|
input.project.workspace.set(input.workspaceID)
|
||||||
|
|
||||||
await input.sync.bootstrap({ fatal: false }).catch(() => undefined)
|
await input.sync.bootstrap({ fatal: false }).catch(() => undefined)
|
||||||
|
|
||||||
await Promise.all([input.project.workspace.sync(), input.sync.session.sync(input.sessionID)])
|
await Promise.all([input.project.workspace.sync(), input.sync.session.refresh()])
|
||||||
|
|
||||||
input.toast.show({
|
|
||||||
message: "Session restored into the new workspace",
|
|
||||||
variant: "success",
|
|
||||||
})
|
|
||||||
input.done?.()
|
input.done?.()
|
||||||
if (input.done) return
|
if (input.done) return true
|
||||||
input.dialog.clear()
|
input.dialog.clear()
|
||||||
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
export function DialogWorkspaceCreate(props: { onSelect: (workspaceID: string) => Promise<void> | void }) {
|
export function DialogWorkspaceSelect(props: {
|
||||||
|
adapters?: Adapter[]
|
||||||
|
onSelect: (selection: WorkspaceSelection) => Promise<void> | void
|
||||||
|
}) {
|
||||||
const dialog = useDialog()
|
const dialog = useDialog()
|
||||||
const sync = useSync()
|
|
||||||
const project = useProject()
|
const project = useProject()
|
||||||
|
const sync = useSync()
|
||||||
const sdk = useSDK()
|
const sdk = useSDK()
|
||||||
const toast = useToast()
|
const toast = useToast()
|
||||||
const [creating, setCreating] = createSignal<string>()
|
const [adapters, setAdapters] = createSignal<Adapter[] | undefined>(props.adapters)
|
||||||
const [adapters, setAdapters] = createSignal<Adapter[]>()
|
|
||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
dialog.setSize("medium")
|
dialog.setSize("medium")
|
||||||
void (async () => {
|
void (async () => {
|
||||||
const dir = sync.path.directory || sdk.directory
|
if (adapters()) return
|
||||||
const url = new URL("/experimental/workspace/adapter", sdk.url)
|
const res = await loadWorkspaceAdapters({ sdk, sync, toast })
|
||||||
if (dir) url.searchParams.set("directory", dir)
|
if (!res) return
|
||||||
const res = await sdk
|
|
||||||
.fetch(url)
|
|
||||||
.then((x) => x.json() as Promise<Adapter[]>)
|
|
||||||
.catch(() => undefined)
|
|
||||||
if (!res) {
|
|
||||||
toast.show({
|
|
||||||
message: "Failed to load workspace adapters",
|
|
||||||
variant: "error",
|
|
||||||
})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
setAdapters(res)
|
setAdapters(res)
|
||||||
})()
|
})()
|
||||||
})
|
})
|
||||||
|
|
||||||
const options = createMemo(() => {
|
const options = createMemo<DialogSelectOption<WorkspaceSelectValue>[]>(() => {
|
||||||
const type = creating()
|
|
||||||
if (type) {
|
|
||||||
return [
|
|
||||||
{
|
|
||||||
title: `Creating ${type} workspace...`,
|
|
||||||
value: "creating" as const,
|
|
||||||
description: "This can take a while for remote environments",
|
|
||||||
},
|
|
||||||
]
|
|
||||||
}
|
|
||||||
const list = adapters()
|
const list = adapters()
|
||||||
if (!list) {
|
if (!list) return []
|
||||||
return [
|
const recent = sync.data.session
|
||||||
{
|
.toSorted((a, b) => b.time.updated - a.time.updated)
|
||||||
title: "Loading workspaces...",
|
.flatMap((session) => (session.workspaceID ? [session.workspaceID] : []))
|
||||||
value: "loading" as const,
|
.filter((workspaceID, index, list) => list.indexOf(workspaceID) === index)
|
||||||
description: "Fetching available workspace adapters",
|
.slice(0, 3)
|
||||||
|
.flatMap((workspaceID) => {
|
||||||
|
const workspace = project.workspace.get(workspaceID)
|
||||||
|
return workspace ? [workspace] : []
|
||||||
|
})
|
||||||
|
return [
|
||||||
|
...list.map((adapter) => ({
|
||||||
|
title: adapter.name,
|
||||||
|
value: { type: "new" as const, workspaceType: adapter.type, workspaceName: adapter.name },
|
||||||
|
description: adapter.description,
|
||||||
|
category: "New workspace",
|
||||||
|
})),
|
||||||
|
{
|
||||||
|
title: "None",
|
||||||
|
value: { type: "none" as const },
|
||||||
|
description: "Use the local project",
|
||||||
|
category: "Choose workspace",
|
||||||
|
},
|
||||||
|
...recent.map((workspace: Workspace) => ({
|
||||||
|
title: workspace.name,
|
||||||
|
description: `(${workspace.type})`,
|
||||||
|
value: {
|
||||||
|
type: "existing" as const,
|
||||||
|
workspaceID: workspace.id,
|
||||||
|
workspaceType: workspace.type,
|
||||||
|
workspaceName: workspace.name,
|
||||||
},
|
},
|
||||||
]
|
category: "Choose workspace",
|
||||||
}
|
})),
|
||||||
return list.map((item) => ({
|
{
|
||||||
title: item.name,
|
title: "View all workspaces",
|
||||||
value: item.type,
|
value: { type: "existing-list" as const },
|
||||||
description: item.description,
|
description: "Choose from all workspaces",
|
||||||
}))
|
category: "Choose workspace",
|
||||||
|
},
|
||||||
|
]
|
||||||
})
|
})
|
||||||
|
|
||||||
const create = async (type: string) => {
|
if (!adapters()) return null
|
||||||
if (creating()) return
|
|
||||||
setCreating(type)
|
|
||||||
|
|
||||||
const result = await sdk.client.experimental.workspace.create({ type, branch: null }).catch(() => {
|
|
||||||
toast.show({
|
|
||||||
message: "Creating workspace failed",
|
|
||||||
variant: "error",
|
|
||||||
})
|
|
||||||
return undefined
|
|
||||||
})
|
|
||||||
|
|
||||||
const workspace = result?.data
|
|
||||||
if (!workspace) {
|
|
||||||
setCreating(undefined)
|
|
||||||
toast.show({
|
|
||||||
message: `Failed to create workspace: ${errorMessage(result?.error ?? "no response")}`,
|
|
||||||
variant: "error",
|
|
||||||
})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
await project.workspace.sync()
|
|
||||||
await props.onSelect(workspace.id)
|
|
||||||
setCreating(undefined)
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<DialogSelect
|
<DialogSelect<WorkspaceSelectValue>
|
||||||
title={creating() ? "Creating Workspace" : "New Workspace"}
|
title="Warp"
|
||||||
skipFilter={true}
|
skipFilter={true}
|
||||||
|
renderFilter={false}
|
||||||
options={options()}
|
options={options()}
|
||||||
onSelect={(option) => {
|
onSelect={(option) => {
|
||||||
if (option.value === "creating" || option.value === "loading") return
|
if (!option.value) return
|
||||||
void create(option.value)
|
if (option.value.type === "none") {
|
||||||
|
void props.onSelect(option.value)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (option.value.type === "new") {
|
||||||
|
void props.onSelect(option.value)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (option.value.type === "existing") {
|
||||||
|
void props.onSelect(option.value)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
dialog.replace(() => <DialogExistingWorkspaceSelect onSelect={props.onSelect} />)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DialogExistingWorkspaceSelect(props: { onSelect: (selection: WorkspaceSelection) => Promise<void> | void }) {
|
||||||
|
const project = useProject()
|
||||||
|
|
||||||
|
const options = createMemo<DialogSelectOption<ExistingWorkspaceSelectValue>[]>(() =>
|
||||||
|
project.workspace
|
||||||
|
.list()
|
||||||
|
.filter((workspace) => project.workspace.status(workspace.id) === "connected")
|
||||||
|
.map((workspace: Workspace) => ({
|
||||||
|
title: workspace.name,
|
||||||
|
description: `(${workspace.type})`,
|
||||||
|
value: { workspace },
|
||||||
|
})),
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DialogSelect<ExistingWorkspaceSelectValue>
|
||||||
|
title="Existing Workspace"
|
||||||
|
options={options()}
|
||||||
|
onSelect={(option) => {
|
||||||
|
void props.onSelect({
|
||||||
|
type: "existing",
|
||||||
|
workspaceID: option.value.workspace.id,
|
||||||
|
workspaceType: option.value.workspace.type,
|
||||||
|
workspaceName: option.value.workspace.name,
|
||||||
|
})
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@ import { Filesystem } from "@/util/filesystem"
|
||||||
import { useLocal } from "@tui/context/local"
|
import { useLocal } from "@tui/context/local"
|
||||||
import { tint, useTheme } from "@tui/context/theme"
|
import { tint, useTheme } from "@tui/context/theme"
|
||||||
import { EmptyBorder, SplitBorder } from "@tui/component/border"
|
import { EmptyBorder, SplitBorder } from "@tui/component/border"
|
||||||
|
import { Spinner } from "@tui/component/spinner"
|
||||||
import { useSDK } from "@tui/context/sdk"
|
import { useSDK } from "@tui/context/sdk"
|
||||||
import { useRoute } from "@tui/context/route"
|
import { useRoute } from "@tui/context/route"
|
||||||
import { useProject } from "@tui/context/project"
|
import { useProject } from "@tui/context/project"
|
||||||
|
|
@ -41,9 +42,11 @@ import { useKV } from "../../context/kv"
|
||||||
import { createFadeIn } from "../../util/signal"
|
import { createFadeIn } from "../../util/signal"
|
||||||
import { useTextareaKeybindings } from "../textarea-keybindings"
|
import { useTextareaKeybindings } from "../textarea-keybindings"
|
||||||
import { DialogSkill } from "../dialog-skill"
|
import { DialogSkill } from "../dialog-skill"
|
||||||
import { DialogWorkspaceCreate, restoreWorkspaceSession } from "../dialog-workspace-create"
|
import { openWorkspaceSelect, warpWorkspaceSession, type WorkspaceSelection } from "../dialog-workspace-create"
|
||||||
import { DialogWorkspaceUnavailable } from "../dialog-workspace-unavailable"
|
import { DialogWorkspaceUnavailable } from "../dialog-workspace-unavailable"
|
||||||
import { useArgs } from "@tui/context/args"
|
import { useArgs } from "@tui/context/args"
|
||||||
|
import { Flag } from "@opencode-ai/core/flag/flag"
|
||||||
|
import { WorkspaceLabel, type WorkspaceStatus } from "../workspace-label"
|
||||||
|
|
||||||
export type PromptProps = {
|
export type PromptProps = {
|
||||||
sessionID?: string
|
sessionID?: string
|
||||||
|
|
@ -173,9 +176,92 @@ export function Prompt(props: PromptProps) {
|
||||||
const [editorContextHover, setEditorContextHover] = createSignal(false)
|
const [editorContextHover, setEditorContextHover] = createSignal(false)
|
||||||
let lastSubmittedEditorSelectionKey: string | undefined
|
let lastSubmittedEditorSelectionKey: string | undefined
|
||||||
const [auto, setAuto] = createSignal<AutocompleteRef>()
|
const [auto, setAuto] = createSignal<AutocompleteRef>()
|
||||||
|
const [workspaceSelection, setWorkspaceSelection] = createSignal<WorkspaceSelection>()
|
||||||
|
const [workspaceCreating, setWorkspaceCreating] = createSignal(false)
|
||||||
|
const [workspaceCreatingDots, setWorkspaceCreatingDots] = createSignal(3)
|
||||||
|
const [warpNotice, setWarpNotice] = createSignal<string>()
|
||||||
const currentProviderLabel = createMemo(() => local.model.parsed().provider)
|
const currentProviderLabel = createMemo(() => local.model.parsed().provider)
|
||||||
const hasRightContent = createMemo(() => Boolean(props.right))
|
const hasRightContent = createMemo(() => Boolean(props.right))
|
||||||
|
|
||||||
|
function selectWorkspace(selection: WorkspaceSelection | undefined) {
|
||||||
|
setWorkspaceSelection(selection)
|
||||||
|
}
|
||||||
|
|
||||||
|
function setCreatingWorkspace(creating: boolean) {
|
||||||
|
setWorkspaceCreating(creating)
|
||||||
|
}
|
||||||
|
|
||||||
|
function showWarpNotice(name: string) {
|
||||||
|
setWarpNotice(`Warped to ${name}`)
|
||||||
|
setTimeout(() => setWarpNotice(undefined), 4000)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createWorkspace(selection: Extract<WorkspaceSelection, { type: "new" }>) {
|
||||||
|
setCreatingWorkspace(true)
|
||||||
|
const result = await sdk.client.experimental.workspace
|
||||||
|
.create({ type: selection.workspaceType, branch: null })
|
||||||
|
.catch(() => undefined)
|
||||||
|
if (result == undefined || result.error || !result.data) {
|
||||||
|
selectWorkspace(undefined)
|
||||||
|
setCreatingWorkspace(false)
|
||||||
|
toast.show({
|
||||||
|
message: "Creating workspace failed",
|
||||||
|
variant: "error",
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
await project.workspace.sync()
|
||||||
|
const workspace = result.data
|
||||||
|
selectWorkspace({
|
||||||
|
type: "existing",
|
||||||
|
workspaceID: workspace.id,
|
||||||
|
workspaceType: workspace.type,
|
||||||
|
workspaceName: workspace.name,
|
||||||
|
})
|
||||||
|
setCreatingWorkspace(false)
|
||||||
|
return workspace
|
||||||
|
}
|
||||||
|
|
||||||
|
async function warpSession(selection: WorkspaceSelection) {
|
||||||
|
if (!props.sessionID) {
|
||||||
|
selectWorkspace(selection)
|
||||||
|
dialog.clear()
|
||||||
|
if (selection.type === "new") void createWorkspace(selection)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
selectWorkspace(selection)
|
||||||
|
dialog.clear()
|
||||||
|
|
||||||
|
const workspace =
|
||||||
|
selection.type === "none"
|
||||||
|
? { id: null, name: "local project" }
|
||||||
|
: selection.type === "existing"
|
||||||
|
? { id: selection.workspaceID, name: selection.workspaceName }
|
||||||
|
: await createWorkspace(selection)
|
||||||
|
if (!workspace) return
|
||||||
|
|
||||||
|
const warped = await warpWorkspaceSession({
|
||||||
|
dialog,
|
||||||
|
sdk,
|
||||||
|
sync,
|
||||||
|
project,
|
||||||
|
toast,
|
||||||
|
workspaceID: workspace.id,
|
||||||
|
sessionID: props.sessionID,
|
||||||
|
})
|
||||||
|
if (warped) showWarpNotice(workspace.name)
|
||||||
|
}
|
||||||
|
|
||||||
|
createEffect(() => {
|
||||||
|
if (!workspaceCreating()) {
|
||||||
|
setWorkspaceCreatingDots(3)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const timer = setInterval(() => setWorkspaceCreatingDots((dots) => (dots % 3) + 1), 1000)
|
||||||
|
onCleanup(() => clearInterval(timer))
|
||||||
|
})
|
||||||
|
|
||||||
function promptModelWarning() {
|
function promptModelWarning() {
|
||||||
toast.show({
|
toast.show({
|
||||||
variant: "warning",
|
variant: "warning",
|
||||||
|
|
@ -213,6 +299,7 @@ export function Prompt(props: PromptProps) {
|
||||||
})
|
})
|
||||||
|
|
||||||
createEffect(() => {
|
createEffect(() => {
|
||||||
|
if (!input || input.isDestroyed) return
|
||||||
if (props.disabled) input.cursorColor = theme.backgroundElement
|
if (props.disabled) input.cursorColor = theme.backgroundElement
|
||||||
if (!props.disabled) input.cursorColor = theme.text
|
if (!props.disabled) input.cursorColor = theme.text
|
||||||
})
|
})
|
||||||
|
|
@ -489,6 +576,27 @@ export function Prompt(props: PromptProps) {
|
||||||
))
|
))
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
title: "Warp",
|
||||||
|
description: "Change the workspace for the session",
|
||||||
|
value: "workspace.set",
|
||||||
|
category: "Session",
|
||||||
|
enabled: Flag.OPENCODE_EXPERIMENTAL_WORKSPACES,
|
||||||
|
slash: {
|
||||||
|
name: "warp",
|
||||||
|
},
|
||||||
|
onSelect: (dialog) => {
|
||||||
|
void openWorkspaceSelect({
|
||||||
|
dialog,
|
||||||
|
sdk,
|
||||||
|
sync,
|
||||||
|
toast,
|
||||||
|
onSelect: (selection) => {
|
||||||
|
void warpSession(selection)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
},
|
||||||
|
},
|
||||||
]
|
]
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
@ -699,6 +807,8 @@ export function Prompt(props: PromptProps) {
|
||||||
])
|
])
|
||||||
|
|
||||||
async function submit() {
|
async function submit() {
|
||||||
|
setWarpNotice(undefined)
|
||||||
|
|
||||||
// IME: double-defer may fire before onContentChange flushes the last
|
// IME: double-defer may fire before onContentChange flushes the last
|
||||||
// composed character (e.g. Korean hangul) to the store, so read
|
// composed character (e.g. Korean hangul) to the store, so read
|
||||||
// plainText directly and sync before any downstream reads.
|
// plainText directly and sync before any downstream reads.
|
||||||
|
|
@ -707,6 +817,7 @@ export function Prompt(props: PromptProps) {
|
||||||
syncExtmarksWithPromptParts()
|
syncExtmarksWithPromptParts()
|
||||||
}
|
}
|
||||||
if (props.disabled) return false
|
if (props.disabled) return false
|
||||||
|
if (workspaceCreating()) return false
|
||||||
if (autocomplete?.visible) return false
|
if (autocomplete?.visible) return false
|
||||||
if (!store.prompt.input) return false
|
if (!store.prompt.input) return false
|
||||||
const agent = local.agent.current()
|
const agent = local.agent.current()
|
||||||
|
|
@ -729,21 +840,16 @@ export function Prompt(props: PromptProps) {
|
||||||
dialog.replace(() => (
|
dialog.replace(() => (
|
||||||
<DialogWorkspaceUnavailable
|
<DialogWorkspaceUnavailable
|
||||||
onRestore={() => {
|
onRestore={() => {
|
||||||
dialog.replace(() => (
|
void openWorkspaceSelect({
|
||||||
<DialogWorkspaceCreate
|
dialog,
|
||||||
onSelect={(nextWorkspaceID) =>
|
sdk,
|
||||||
restoreWorkspaceSession({
|
sync,
|
||||||
dialog,
|
toast,
|
||||||
sdk,
|
onSelect: (selection) => {
|
||||||
sync,
|
void warpSession(selection)
|
||||||
project,
|
},
|
||||||
toast,
|
})
|
||||||
workspaceID: nextWorkspaceID,
|
return false
|
||||||
sessionID: props.sessionID!,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
))
|
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
))
|
))
|
||||||
|
|
@ -753,6 +859,14 @@ export function Prompt(props: PromptProps) {
|
||||||
const variant = local.model.variant.current()
|
const variant = local.model.variant.current()
|
||||||
let sessionID = props.sessionID
|
let sessionID = props.sessionID
|
||||||
if (sessionID == null) {
|
if (sessionID == null) {
|
||||||
|
const workspace = workspaceSelection()
|
||||||
|
const workspaceID = iife(() => {
|
||||||
|
if (!workspace) return undefined
|
||||||
|
if (workspace.type === "none") return undefined
|
||||||
|
if (workspace.type === "existing") return workspace.workspaceID
|
||||||
|
return undefined
|
||||||
|
})
|
||||||
|
|
||||||
const res = await sdk.client.session.create({
|
const res = await sdk.client.session.create({
|
||||||
workspace: props.workspaceID,
|
workspace: props.workspaceID,
|
||||||
agent: agent.name,
|
agent: agent.name,
|
||||||
|
|
@ -1025,6 +1139,29 @@ export function Prompt(props: PromptProps) {
|
||||||
return `Ask anything... "${list()[store.placeholder % list().length]}"`
|
return `Ask anything... "${list()[store.placeholder % list().length]}"`
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const workspaceLabel = createMemo<
|
||||||
|
| { type: "new"; workspaceType: string }
|
||||||
|
| { type: "existing"; workspaceType: string; workspaceName: string; status?: WorkspaceStatus }
|
||||||
|
| undefined
|
||||||
|
>(() => {
|
||||||
|
const selected = workspaceSelection()
|
||||||
|
if (!selected) return
|
||||||
|
if (selected.type === "none") return
|
||||||
|
if (props.sessionID && !workspaceCreating()) return
|
||||||
|
if (selected.type === "new") {
|
||||||
|
return {
|
||||||
|
type: "new",
|
||||||
|
workspaceType: selected.workspaceType,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
type: "existing",
|
||||||
|
workspaceType: selected.workspaceType,
|
||||||
|
workspaceName: selected.workspaceName,
|
||||||
|
status: selected.type === "existing" ? "connected" : undefined,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
const spinnerDef = createMemo(() => {
|
const spinnerDef = createMemo(() => {
|
||||||
const agent = local.agent.current()
|
const agent = local.agent.current()
|
||||||
const color = agent ? local.agent.color(agent.name) : theme.border
|
const color = agent ? local.agent.color(agent.name) : theme.border
|
||||||
|
|
@ -1281,7 +1418,7 @@ export function Prompt(props: PromptProps) {
|
||||||
}}
|
}}
|
||||||
onMouseDown={(r: MouseEvent) => r.target?.focus()}
|
onMouseDown={(r: MouseEvent) => r.target?.focus()}
|
||||||
focusedBackgroundColor={theme.backgroundElement}
|
focusedBackgroundColor={theme.backgroundElement}
|
||||||
cursorColor={theme.text}
|
cursorColor={props.disabled ? theme.backgroundElement : theme.text}
|
||||||
syntaxStyle={syntax()}
|
syntaxStyle={syntax()}
|
||||||
/>
|
/>
|
||||||
<box flexDirection="row" flexShrink={0} paddingTop={1} gap={1} justifyContent="space-between">
|
<box flexDirection="row" flexShrink={0} paddingTop={1} gap={1} justifyContent="space-between">
|
||||||
|
|
@ -1351,86 +1488,124 @@ export function Prompt(props: PromptProps) {
|
||||||
/>
|
/>
|
||||||
</box>
|
</box>
|
||||||
<box width="100%" flexDirection="row" justifyContent="space-between">
|
<box width="100%" flexDirection="row" justifyContent="space-between">
|
||||||
<Show when={status().type !== "idle"} fallback={props.hint ?? <text />}>
|
<Switch>
|
||||||
<box
|
<Match when={status().type !== "idle"}>
|
||||||
flexDirection="row"
|
<box
|
||||||
gap={1}
|
flexDirection="row"
|
||||||
flexGrow={1}
|
gap={1}
|
||||||
justifyContent={status().type === "retry" ? "space-between" : "flex-start"}
|
flexGrow={1}
|
||||||
>
|
justifyContent={status().type === "retry" ? "space-between" : "flex-start"}
|
||||||
<box flexShrink={0} flexDirection="row" gap={1}>
|
>
|
||||||
<box marginLeft={1}>
|
<box flexShrink={0} flexDirection="row" gap={1}>
|
||||||
<Show when={kv.get("animations_enabled", true)} fallback={<text fg={theme.textMuted}>[⋯]</text>}>
|
<box marginLeft={1}>
|
||||||
<spinner color={spinnerDef().color} frames={spinnerDef().frames} interval={40} />
|
<Show when={kv.get("animations_enabled", true)} fallback={<text fg={theme.textMuted}>[⋯]</text>}>
|
||||||
</Show>
|
<spinner color={spinnerDef().color} frames={spinnerDef().frames} interval={40} />
|
||||||
</box>
|
</Show>
|
||||||
<box flexDirection="row" gap={1} flexShrink={0}>
|
</box>
|
||||||
{(() => {
|
<box flexDirection="row" gap={1} flexShrink={0}>
|
||||||
const retry = createMemo(() => {
|
{(() => {
|
||||||
const s = status()
|
const retry = createMemo(() => {
|
||||||
if (s.type !== "retry") return
|
const s = status()
|
||||||
return s
|
if (s.type !== "retry") return
|
||||||
})
|
return s
|
||||||
const message = createMemo(() => {
|
|
||||||
const r = retry()
|
|
||||||
if (!r) return
|
|
||||||
if (r.message.includes("exceeded your current quota") && r.message.includes("gemini"))
|
|
||||||
return "gemini is way too hot right now"
|
|
||||||
if (r.message.length > 80) return r.message.slice(0, 80) + "..."
|
|
||||||
return r.message
|
|
||||||
})
|
|
||||||
const isTruncated = createMemo(() => {
|
|
||||||
const r = retry()
|
|
||||||
if (!r) return false
|
|
||||||
return r.message.length > 120
|
|
||||||
})
|
|
||||||
const [seconds, setSeconds] = createSignal(0)
|
|
||||||
onMount(() => {
|
|
||||||
const timer = setInterval(() => {
|
|
||||||
const next = retry()?.next
|
|
||||||
if (next) setSeconds(Math.round((next - Date.now()) / 1000))
|
|
||||||
}, 1000)
|
|
||||||
|
|
||||||
onCleanup(() => {
|
|
||||||
clearInterval(timer)
|
|
||||||
})
|
})
|
||||||
})
|
const message = createMemo(() => {
|
||||||
const handleMessageClick = () => {
|
const r = retry()
|
||||||
const r = retry()
|
if (!r) return
|
||||||
if (!r) return
|
if (r.message.includes("exceeded your current quota") && r.message.includes("gemini"))
|
||||||
if (isTruncated()) {
|
return "gemini is way too hot right now"
|
||||||
void DialogAlert.show(dialog, "Retry Error", r.message)
|
if (r.message.length > 80) return r.message.slice(0, 80) + "..."
|
||||||
|
return r.message
|
||||||
|
})
|
||||||
|
const isTruncated = createMemo(() => {
|
||||||
|
const r = retry()
|
||||||
|
if (!r) return false
|
||||||
|
return r.message.length > 120
|
||||||
|
})
|
||||||
|
const [seconds, setSeconds] = createSignal(0)
|
||||||
|
onMount(() => {
|
||||||
|
const timer = setInterval(() => {
|
||||||
|
const next = retry()?.next
|
||||||
|
if (next) setSeconds(Math.round((next - Date.now()) / 1000))
|
||||||
|
}, 1000)
|
||||||
|
|
||||||
|
onCleanup(() => {
|
||||||
|
clearInterval(timer)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
const handleMessageClick = () => {
|
||||||
|
const r = retry()
|
||||||
|
if (!r) return
|
||||||
|
if (isTruncated()) {
|
||||||
|
void DialogAlert.show(dialog, "Retry Error", r.message)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
const retryText = () => {
|
const retryText = () => {
|
||||||
const r = retry()
|
const r = retry()
|
||||||
if (!r) return ""
|
if (!r) return ""
|
||||||
const baseMessage = message()
|
const baseMessage = message()
|
||||||
const truncatedHint = isTruncated() ? " (click to expand)" : ""
|
const truncatedHint = isTruncated() ? " (click to expand)" : ""
|
||||||
const duration = formatDuration(seconds())
|
const duration = formatDuration(seconds())
|
||||||
const retryInfo = ` [retrying ${duration ? `in ${duration} ` : ""}attempt #${r.attempt}]`
|
const retryInfo = ` [retrying ${duration ? `in ${duration} ` : ""}attempt #${r.attempt}]`
|
||||||
return baseMessage + truncatedHint + retryInfo
|
return baseMessage + truncatedHint + retryInfo
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Show when={retry()}>
|
<Show when={retry()}>
|
||||||
<box onMouseUp={handleMessageClick}>
|
<box onMouseUp={handleMessageClick}>
|
||||||
<text fg={theme.error}>{retryText()}</text>
|
<text fg={theme.error}>{retryText()}</text>
|
||||||
</box>
|
</box>
|
||||||
</Show>
|
</Show>
|
||||||
)
|
)
|
||||||
})()}
|
})()}
|
||||||
|
</box>
|
||||||
</box>
|
</box>
|
||||||
|
<text fg={store.interrupt > 0 ? theme.primary : theme.text}>
|
||||||
|
esc{" "}
|
||||||
|
<span style={{ fg: store.interrupt > 0 ? theme.primary : theme.textMuted }}>
|
||||||
|
{store.interrupt > 0 ? "again to interrupt" : "interrupt"}
|
||||||
|
</span>
|
||||||
|
</text>
|
||||||
</box>
|
</box>
|
||||||
<text fg={store.interrupt > 0 ? theme.primary : theme.text}>
|
</Match>
|
||||||
esc{" "}
|
<Match when={warpNotice()}>
|
||||||
<span style={{ fg: store.interrupt > 0 ? theme.primary : theme.textMuted }}>
|
{(notice) => (
|
||||||
{store.interrupt > 0 ? "again to interrupt" : "interrupt"}
|
<box paddingLeft={3}>
|
||||||
</span>
|
<text fg={theme.accent}>{notice()}</text>
|
||||||
</text>
|
</box>
|
||||||
</box>
|
)}
|
||||||
</Show>
|
</Match>
|
||||||
|
<Match when={workspaceLabel()}>
|
||||||
|
{(workspace) => (
|
||||||
|
<box paddingLeft={3} flexDirection="row" gap={1}>
|
||||||
|
<Show when={workspaceCreating()}>
|
||||||
|
<Spinner color={theme.accent} />
|
||||||
|
</Show>
|
||||||
|
<text fg={workspaceCreating() ? theme.accent : theme.text}>
|
||||||
|
{(() => {
|
||||||
|
const item = workspace()
|
||||||
|
if (item.type === "new") {
|
||||||
|
if (workspaceCreating())
|
||||||
|
return `Creating ${item.workspaceType}${".".repeat(workspaceCreatingDots())}`
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
Workspace <span style={{ fg: theme.textMuted }}>(new {item.workspaceType})</span>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
Workspace <span style={{ fg: theme.textMuted }}>{item.workspaceName}</span>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
})()}
|
||||||
|
</text>
|
||||||
|
</box>
|
||||||
|
)}
|
||||||
|
</Match>
|
||||||
|
<Match when={true}>{props.hint ?? <text />}</Match>
|
||||||
|
</Switch>
|
||||||
<Show when={status().type !== "retry"}>
|
<Show when={status().type !== "retry"}>
|
||||||
<box gap={2} flexDirection="row">
|
<box gap={2} flexDirection="row">
|
||||||
<Show when={editorFileLabelDisplay()}>
|
<Show when={editorFileLabelDisplay()}>
|
||||||
|
|
|
||||||
|
|
@ -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 { TuiPluginRuntime } from "@/cli/cmd/tui/plugin/runtime"
|
||||||
|
|
||||||
import { getScrollAcceleration } from "../../util/scroll"
|
import { getScrollAcceleration } from "../../util/scroll"
|
||||||
|
import { WorkspaceLabel } from "../../component/workspace-label"
|
||||||
|
|
||||||
export function Sidebar(props: { sessionID: string; overlay?: boolean }) {
|
export function Sidebar(props: { sessionID: string; overlay?: boolean }) {
|
||||||
const project = useProject()
|
const project = useProject()
|
||||||
|
|
@ -14,17 +15,10 @@ export function Sidebar(props: { sessionID: string; overlay?: boolean }) {
|
||||||
const { theme } = useTheme()
|
const { theme } = useTheme()
|
||||||
const tuiConfig = useTuiConfig()
|
const tuiConfig = useTuiConfig()
|
||||||
const session = createMemo(() => sync.session.get(props.sessionID))
|
const session = createMemo(() => sync.session.get(props.sessionID))
|
||||||
const workspaceStatus = () => {
|
const workspace = () => {
|
||||||
const workspaceID = session()?.workspaceID
|
const workspaceID = session()?.workspaceID
|
||||||
if (!workspaceID) return "error"
|
if (!workspaceID) return
|
||||||
return project.workspace.status(workspaceID) ?? "error"
|
return project.workspace.get(workspaceID)
|
||||||
}
|
|
||||||
const workspaceLabel = () => {
|
|
||||||
const workspaceID = session()?.workspaceID
|
|
||||||
if (!workspaceID) return "unknown"
|
|
||||||
const info = project.workspace.get(workspaceID)
|
|
||||||
if (!info) return "unknown"
|
|
||||||
return `${info.type}: ${info.name}`
|
|
||||||
}
|
}
|
||||||
const scrollAcceleration = createMemo(() => getScrollAcceleration(tuiConfig))
|
const scrollAcceleration = createMemo(() => getScrollAcceleration(tuiConfig))
|
||||||
|
|
||||||
|
|
@ -67,8 +61,19 @@ export function Sidebar(props: { sessionID: string; overlay?: boolean }) {
|
||||||
</Show>
|
</Show>
|
||||||
<Show when={session()!.workspaceID}>
|
<Show when={session()!.workspaceID}>
|
||||||
<text fg={theme.textMuted}>
|
<text fg={theme.textMuted}>
|
||||||
<span style={{ fg: workspaceStatus() === "connected" ? theme.success : theme.error }}>●</span>{" "}
|
<Show
|
||||||
{workspaceLabel()}
|
when={workspace()}
|
||||||
|
fallback={<WorkspaceLabel type="unknown" name={session()!.workspaceID!} status="error" icon />}
|
||||||
|
>
|
||||||
|
{(item) => (
|
||||||
|
<WorkspaceLabel
|
||||||
|
type={item().type}
|
||||||
|
name={item().name}
|
||||||
|
status={project.workspace.status(item().id) ?? "error"}
|
||||||
|
icon
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Show>
|
||||||
</text>
|
</text>
|
||||||
</Show>
|
</Show>
|
||||||
<Show when={session()!.share?.url}>
|
<Show when={session()!.share?.url}>
|
||||||
|
|
|
||||||
|
|
@ -23,6 +23,7 @@ export interface DialogSelectProps<T> {
|
||||||
onFilter?: (query: string) => void
|
onFilter?: (query: string) => void
|
||||||
onSelect?: (option: DialogSelectOption<T>) => void
|
onSelect?: (option: DialogSelectOption<T>) => void
|
||||||
skipFilter?: boolean
|
skipFilter?: boolean
|
||||||
|
renderFilter?: boolean
|
||||||
keybind?: {
|
keybind?: {
|
||||||
keybind?: Keybind.Info
|
keybind?: Keybind.Info
|
||||||
title: string
|
title: string
|
||||||
|
|
@ -81,7 +82,7 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
|
||||||
let input: InputRenderable
|
let input: InputRenderable
|
||||||
|
|
||||||
const filtered = createMemo(() => {
|
const filtered = createMemo(() => {
|
||||||
if (props.skipFilter) return props.options.filter((x) => x.disabled !== true)
|
if (props.skipFilter || props.renderFilter === false) return props.options.filter((x) => x.disabled !== true)
|
||||||
const needle = store.filter.toLowerCase()
|
const needle = store.filter.toLowerCase()
|
||||||
const options = pipe(
|
const options = pipe(
|
||||||
props.options,
|
props.options,
|
||||||
|
|
@ -250,30 +251,32 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
|
||||||
esc
|
esc
|
||||||
</text>
|
</text>
|
||||||
</box>
|
</box>
|
||||||
<box paddingTop={1}>
|
<Show when={props.renderFilter !== false}>
|
||||||
<input
|
<box paddingTop={1}>
|
||||||
onInput={(e) => {
|
<input
|
||||||
batch(() => {
|
onInput={(e) => {
|
||||||
setStore("filter", e)
|
batch(() => {
|
||||||
props.onFilter?.(e)
|
setStore("filter", e)
|
||||||
})
|
props.onFilter?.(e)
|
||||||
}}
|
})
|
||||||
focusedBackgroundColor={theme.backgroundPanel}
|
}}
|
||||||
cursorColor={theme.primary}
|
focusedBackgroundColor={theme.backgroundPanel}
|
||||||
focusedTextColor={theme.textMuted}
|
cursorColor={theme.primary}
|
||||||
ref={(r) => {
|
focusedTextColor={theme.textMuted}
|
||||||
input = r
|
ref={(r) => {
|
||||||
input.traits = { status: "FILTER" }
|
input = r
|
||||||
setTimeout(() => {
|
input.traits = { status: "FILTER" }
|
||||||
if (!input) return
|
setTimeout(() => {
|
||||||
if (input.isDestroyed) return
|
if (!input) return
|
||||||
input.focus()
|
if (input.isDestroyed) return
|
||||||
}, 1)
|
input.focus()
|
||||||
}}
|
}, 1)
|
||||||
placeholder={props.placeholder ?? "Search"}
|
}}
|
||||||
placeholderColor={theme.textMuted}
|
placeholder={props.placeholder ?? "Search"}
|
||||||
/>
|
placeholderColor={theme.textMuted}
|
||||||
</box>
|
/>
|
||||||
|
</box>
|
||||||
|
</Show>
|
||||||
</box>
|
</box>
|
||||||
<Show
|
<Show
|
||||||
when={grouped().length > 0}
|
when={grouped().length > 0}
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,11 @@
|
||||||
import { Context, Effect, FiberMap, Layer, Schema, Stream } from "effect"
|
import { Context, Effect, FiberMap, Iterable, Layer, Schema, Stream } from "effect"
|
||||||
import { FetchHttpClient, HttpBody, HttpClient, HttpClientError, HttpClientRequest } from "effect/unstable/http"
|
import { FetchHttpClient, HttpBody, HttpClient, HttpClientError, HttpClientRequest } from "effect/unstable/http"
|
||||||
import { Database } from "@/storage/db"
|
import { Database } from "@/storage/db"
|
||||||
import { asc } from "drizzle-orm"
|
import { asc } from "drizzle-orm"
|
||||||
import { eq } from "drizzle-orm"
|
import { eq } from "drizzle-orm"
|
||||||
import { inArray } from "drizzle-orm"
|
import { inArray } from "drizzle-orm"
|
||||||
import { Project } from "@/project/project"
|
import { Project } from "@/project/project"
|
||||||
|
import { Instance } from "@/project/instance"
|
||||||
import { BusEvent } from "@/bus/bus-event"
|
import { BusEvent } from "@/bus/bus-event"
|
||||||
import { GlobalBus } from "@/bus/global"
|
import { GlobalBus } from "@/bus/global"
|
||||||
import { Auth } from "@/auth"
|
import { Auth } from "@/auth"
|
||||||
|
|
@ -20,6 +21,7 @@ import { getAdapter } from "./adapters"
|
||||||
import { type WorkspaceInfo, WorkspaceInfo as WorkspaceInfoSchema } from "./types"
|
import { type WorkspaceInfo, WorkspaceInfo as WorkspaceInfoSchema } from "./types"
|
||||||
import { WorkspaceID } from "./schema"
|
import { WorkspaceID } from "./schema"
|
||||||
import { Session } from "@/session/session"
|
import { Session } from "@/session/session"
|
||||||
|
import { SessionPrompt } from "@/session/prompt"
|
||||||
import { SessionTable } from "@/session/session.sql"
|
import { SessionTable } from "@/session/session.sql"
|
||||||
import { SessionID } from "@/session/schema"
|
import { SessionID } from "@/session/schema"
|
||||||
import { errorData } from "@/util/error"
|
import { errorData } from "@/util/error"
|
||||||
|
|
@ -38,13 +40,6 @@ export const ConnectionStatus = Schema.Struct({
|
||||||
})
|
})
|
||||||
export type ConnectionStatus = Schema.Schema.Type<typeof ConnectionStatus>
|
export type ConnectionStatus = Schema.Schema.Type<typeof ConnectionStatus>
|
||||||
|
|
||||||
const Restore = Schema.Struct({
|
|
||||||
workspaceID: WorkspaceID,
|
|
||||||
sessionID: SessionID,
|
|
||||||
total: NonNegativeInt,
|
|
||||||
step: NonNegativeInt,
|
|
||||||
})
|
|
||||||
|
|
||||||
export const Event = {
|
export const Event = {
|
||||||
Ready: BusEvent.define(
|
Ready: BusEvent.define(
|
||||||
"workspace.ready",
|
"workspace.ready",
|
||||||
|
|
@ -58,7 +53,6 @@ export const Event = {
|
||||||
message: Schema.String,
|
message: Schema.String,
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
Restore: BusEvent.define("workspace.restore", Restore),
|
|
||||||
Status: BusEvent.define("workspace.status", ConnectionStatus),
|
Status: BusEvent.define("workspace.status", ConnectionStatus),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -84,15 +78,15 @@ export const CreateInput = Schema.Struct({
|
||||||
type: Info.fields.type,
|
type: Info.fields.type,
|
||||||
branch: Info.fields.branch,
|
branch: Info.fields.branch,
|
||||||
projectID: ProjectID,
|
projectID: ProjectID,
|
||||||
extra: Info.fields.extra,
|
extra: Schema.optional(Info.fields.extra),
|
||||||
}).pipe(withStatics((s) => ({ zod: effectZod(s), zodObject: zodObject(s) })))
|
}).pipe(withStatics((s) => ({ zod: effectZod(s), zodObject: zodObject(s) })))
|
||||||
export type CreateInput = Schema.Schema.Type<typeof CreateInput>
|
export type CreateInput = Schema.Schema.Type<typeof CreateInput>
|
||||||
|
|
||||||
export const SessionRestoreInput = Schema.Struct({
|
export const SessionWarpInput = Schema.Struct({
|
||||||
workspaceID: WorkspaceID,
|
workspaceID: Schema.NullOr(WorkspaceID),
|
||||||
sessionID: SessionID,
|
sessionID: SessionID,
|
||||||
}).pipe(withStatics((s) => ({ zod: effectZod(s), zodObject: zodObject(s) })))
|
}).pipe(withStatics((s) => ({ zod: effectZod(s), zodObject: zodObject(s) })))
|
||||||
export type SessionRestoreInput = Schema.Schema.Type<typeof SessionRestoreInput>
|
export type SessionWarpInput = Schema.Schema.Type<typeof SessionWarpInput>
|
||||||
|
|
||||||
export class SyncHttpError extends Schema.TaggedErrorClass<SyncHttpError>()("WorkspaceSyncHttpError", {
|
export class SyncHttpError extends Schema.TaggedErrorClass<SyncHttpError>()("WorkspaceSyncHttpError", {
|
||||||
message: Schema.String,
|
message: Schema.String,
|
||||||
|
|
@ -116,8 +110,8 @@ export class SessionEventsNotFoundError extends Schema.TaggedErrorClass<SessionE
|
||||||
},
|
},
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
export class SessionRestoreHttpError extends Schema.TaggedErrorClass<SessionRestoreHttpError>()(
|
export class SessionWarpHttpError extends Schema.TaggedErrorClass<SessionWarpHttpError>()(
|
||||||
"WorkspaceSessionRestoreHttpError",
|
"WorkspaceSessionWarpHttpError",
|
||||||
{
|
{
|
||||||
message: Schema.String,
|
message: Schema.String,
|
||||||
workspaceID: WorkspaceID,
|
workspaceID: WorkspaceID,
|
||||||
|
|
@ -138,17 +132,17 @@ export class SyncAbortedError extends Schema.TaggedErrorClass<SyncAbortedError>(
|
||||||
}) {}
|
}) {}
|
||||||
|
|
||||||
type CreateError = Auth.AuthError
|
type CreateError = Auth.AuthError
|
||||||
type SessionRestoreError =
|
type SessionWarpError =
|
||||||
| WorkspaceNotFoundError
|
| WorkspaceNotFoundError
|
||||||
| SessionEventsNotFoundError
|
| SessionEventsNotFoundError
|
||||||
| SessionRestoreHttpError
|
| SessionWarpHttpError
|
||||||
| HttpClientError.HttpClientError
|
| HttpClientError.HttpClientError
|
||||||
type WaitForSyncError = SyncTimeoutError | SyncAbortedError
|
type WaitForSyncError = SyncTimeoutError | SyncAbortedError
|
||||||
type SyncLoopError = SyncHttpError | HttpClientError.HttpClientError
|
type SyncLoopError = SyncHttpError | HttpClientError.HttpClientError
|
||||||
|
|
||||||
export interface Interface {
|
export interface Interface {
|
||||||
readonly create: (input: CreateInput) => Effect.Effect<Info, CreateError>
|
readonly create: (input: CreateInput) => Effect.Effect<Info, CreateError>
|
||||||
readonly sessionRestore: (input: SessionRestoreInput) => Effect.Effect<{ total: number }, SessionRestoreError>
|
readonly sessionWarp: (input: SessionWarpInput) => Effect.Effect<void, SessionWarpError>
|
||||||
readonly list: (project: Project.Info) => Effect.Effect<Info[]>
|
readonly list: (project: Project.Info) => Effect.Effect<Info[]>
|
||||||
readonly get: (id: WorkspaceID) => Effect.Effect<Info | undefined>
|
readonly get: (id: WorkspaceID) => Effect.Effect<Info | undefined>
|
||||||
readonly remove: (id: WorkspaceID) => Effect.Effect<Info | undefined>
|
readonly remove: (id: WorkspaceID) => Effect.Effect<Info | undefined>
|
||||||
|
|
@ -169,6 +163,7 @@ export const layer = Layer.effect(
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const auth = yield* Auth.Service
|
const auth = yield* Auth.Service
|
||||||
const session = yield* Session.Service
|
const session = yield* Session.Service
|
||||||
|
const prompt = yield* SessionPrompt.Service
|
||||||
const http = yield* HttpClient.HttpClient
|
const http = yield* HttpClient.HttpClient
|
||||||
const sync = yield* SyncEvent.Service
|
const sync = yield* SyncEvent.Service
|
||||||
const connections = new Map<WorkspaceID, ConnectionStatus>()
|
const connections = new Map<WorkspaceID, ConnectionStatus>()
|
||||||
|
|
@ -461,7 +456,7 @@ export const layer = Layer.effect(
|
||||||
const id = WorkspaceID.ascending(input.id)
|
const id = WorkspaceID.ascending(input.id)
|
||||||
const adapter = getAdapter(input.projectID, input.type)
|
const adapter = getAdapter(input.projectID, input.type)
|
||||||
const config = yield* EffectBridge.fromPromise(() =>
|
const config = yield* EffectBridge.fromPromise(() =>
|
||||||
adapter.configure({ ...input, id, name: Slug.create(), directory: null }),
|
adapter.configure({ ...input, id, name: Slug.create(), directory: null, extra: input.extra ?? null }),
|
||||||
)
|
)
|
||||||
|
|
||||||
const info: Info = {
|
const info: Info = {
|
||||||
|
|
@ -518,29 +513,93 @@ export const layer = Layer.effect(
|
||||||
return info
|
return info
|
||||||
})
|
})
|
||||||
|
|
||||||
const sessionRestore = Effect.fn("Workspace.sessionRestore")(function* (input: SessionRestoreInput) {
|
const sessionWarp = Effect.fn("Workspace.sessionWarp")(function* (input: SessionWarpInput) {
|
||||||
return yield* Effect.gen(function* () {
|
return yield* Effect.gen(function* () {
|
||||||
log.info("session restore requested", {
|
log.info("session warp requested", {
|
||||||
workspaceID: input.workspaceID,
|
workspaceID: input.workspaceID,
|
||||||
sessionID: input.sessionID,
|
sessionID: input.sessionID,
|
||||||
})
|
})
|
||||||
|
|
||||||
const space = yield* get(input.workspaceID)
|
const current = yield* db((db) =>
|
||||||
|
db
|
||||||
|
.select({ workspaceID: SessionTable.workspace_id })
|
||||||
|
.from(SessionTable)
|
||||||
|
.where(eq(SessionTable.id, input.sessionID))
|
||||||
|
.get(),
|
||||||
|
)
|
||||||
|
|
||||||
|
if (current?.workspaceID) {
|
||||||
|
const previous = yield* get(current.workspaceID)
|
||||||
|
if (previous) {
|
||||||
|
const adapter = getAdapter(previous.projectID, previous.type)
|
||||||
|
const target = yield* EffectBridge.fromPromise(() => adapter.target(previous))
|
||||||
|
|
||||||
|
if (target.type === "remote") {
|
||||||
|
yield* syncHistory(previous, target.url, target.headers).pipe(
|
||||||
|
Effect.catch((error) =>
|
||||||
|
Effect.sync(() => {
|
||||||
|
log.warn("session warp final source sync failed", {
|
||||||
|
workspaceID: previous.id,
|
||||||
|
sessionID: input.sessionID,
|
||||||
|
error: errorData(error),
|
||||||
|
})
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
yield* prompt.cancel(input.sessionID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// "claim" this session so any future events coming from
|
||||||
|
// the old workspace are ignored
|
||||||
|
SyncEvent.claim(input.sessionID, input.workspaceID ?? Instance.project.id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (input.workspaceID === null) {
|
||||||
|
yield* Effect.sync(() =>
|
||||||
|
SyncEvent.run(Session.Event.Updated, {
|
||||||
|
sessionID: input.sessionID,
|
||||||
|
info: {
|
||||||
|
workspaceID: null,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
log.info("session warp complete", {
|
||||||
|
workspaceID: input.workspaceID,
|
||||||
|
sessionID: input.sessionID,
|
||||||
|
target: "local",
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const workspaceID = input.workspaceID
|
||||||
|
const space = yield* get(workspaceID)
|
||||||
if (!space)
|
if (!space)
|
||||||
return yield* new WorkspaceNotFoundError({
|
return yield* new WorkspaceNotFoundError({
|
||||||
message: `Workspace not found: ${input.workspaceID}`,
|
message: `Workspace not found: ${workspaceID}`,
|
||||||
workspaceID: input.workspaceID,
|
workspaceID,
|
||||||
})
|
})
|
||||||
|
|
||||||
const adapter = getAdapter(space.projectID, space.type)
|
const adapter = getAdapter(space.projectID, space.type)
|
||||||
const target = yield* EffectBridge.fromPromise(() => adapter.target(space))
|
const target = yield* EffectBridge.fromPromise(() => adapter.target(space))
|
||||||
|
|
||||||
yield* sync.run(Session.Event.Updated, {
|
if (target.type === "local") {
|
||||||
sessionID: input.sessionID,
|
yield* sync.run(Session.Event.Updated, {
|
||||||
info: {
|
sessionID: input.sessionID,
|
||||||
|
info: {
|
||||||
|
workspaceID: input.workspaceID,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
log.info("session warp complete", {
|
||||||
workspaceID: input.workspaceID,
|
workspaceID: input.workspaceID,
|
||||||
},
|
sessionID: input.sessionID,
|
||||||
})
|
target: target.directory,
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
const rows = yield* db((db) =>
|
const rows = yield* db((db) =>
|
||||||
db
|
db
|
||||||
|
|
@ -562,130 +621,95 @@ export const layer = Layer.effect(
|
||||||
sessionID: input.sessionID,
|
sessionID: input.sessionID,
|
||||||
})
|
})
|
||||||
|
|
||||||
const size = 10
|
const batches = Iterable.chunksOf(rows, 10)
|
||||||
// TODO: look into using effect APIs to process this in chunks
|
const total = Iterable.size(batches)
|
||||||
const sets = Array.from({ length: Math.ceil(rows.length / size) }, (_, i) =>
|
|
||||||
rows.slice(i * size, (i + 1) * size),
|
|
||||||
)
|
|
||||||
const total = sets.length
|
|
||||||
|
|
||||||
log.info("session restore prepared", {
|
log.info("session warp prepared", {
|
||||||
workspaceID: input.workspaceID,
|
workspaceID: input.workspaceID,
|
||||||
sessionID: input.sessionID,
|
sessionID: input.sessionID,
|
||||||
workspaceType: space.type,
|
target: String(route(target.url, "/sync/replay")),
|
||||||
directory: space.directory,
|
|
||||||
target: target.type === "remote" ? String(route(target.url, "/sync/replay")) : target.directory,
|
|
||||||
events: rows.length,
|
events: rows.length,
|
||||||
batches: total,
|
batches: total,
|
||||||
first: rows[0]?.seq,
|
first: rows[0]?.seq,
|
||||||
last: rows.at(-1)?.seq,
|
last: rows.at(-1)?.seq,
|
||||||
})
|
})
|
||||||
|
|
||||||
yield* Effect.sync(() =>
|
yield* Effect.forEach(
|
||||||
GlobalBus.emit("event", {
|
batches,
|
||||||
directory: "global",
|
(events, i) =>
|
||||||
workspace: input.workspaceID,
|
Effect.gen(function* () {
|
||||||
payload: {
|
const response = yield* http.execute(
|
||||||
type: Event.Restore.type,
|
HttpClientRequest.post(route(target.url, "/sync/replay"), {
|
||||||
properties: {
|
headers: new Headers(target.headers),
|
||||||
workspaceID: input.workspaceID,
|
body: HttpBody.jsonUnsafe({
|
||||||
sessionID: input.sessionID,
|
directory: space.directory ?? "",
|
||||||
total,
|
events,
|
||||||
step: 0,
|
}),
|
||||||
},
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
|
|
||||||
for (const [i, events] of sets.entries()) {
|
|
||||||
log.info("session restore batch starting", {
|
|
||||||
workspaceID: input.workspaceID,
|
|
||||||
sessionID: input.sessionID,
|
|
||||||
step: i + 1,
|
|
||||||
total,
|
|
||||||
events: events.length,
|
|
||||||
first: events[0]?.seq,
|
|
||||||
last: events.at(-1)?.seq,
|
|
||||||
target: target.type === "remote" ? String(route(target.url, "/sync/replay")) : target.directory,
|
|
||||||
})
|
|
||||||
|
|
||||||
if (target.type === "local") {
|
|
||||||
yield* sync.replayAll(events)
|
|
||||||
log.info("session restore batch replayed locally", {
|
|
||||||
workspaceID: input.workspaceID,
|
|
||||||
sessionID: input.sessionID,
|
|
||||||
step: i + 1,
|
|
||||||
total,
|
|
||||||
events: events.length,
|
|
||||||
})
|
|
||||||
} else {
|
|
||||||
const url = route(target.url, "/sync/replay")
|
|
||||||
const res = yield* http.execute(
|
|
||||||
HttpClientRequest.post(url, {
|
|
||||||
headers: new Headers(target.headers),
|
|
||||||
body: HttpBody.jsonUnsafe({
|
|
||||||
directory: space.directory ?? "",
|
|
||||||
events,
|
|
||||||
}),
|
}),
|
||||||
}),
|
)
|
||||||
)
|
|
||||||
|
|
||||||
if (res.status < 200 || res.status >= 300) {
|
if (response.status < 200 || response.status >= 300) {
|
||||||
const body = yield* res.text
|
const body = yield* response.text
|
||||||
log.error("session restore batch failed", {
|
log.error("session warp batch failed", {
|
||||||
|
workspaceID: input.workspaceID,
|
||||||
|
sessionID: input.sessionID,
|
||||||
|
step: i + 1,
|
||||||
|
total,
|
||||||
|
status: response.status,
|
||||||
|
body,
|
||||||
|
})
|
||||||
|
return yield* new SessionWarpHttpError({
|
||||||
|
message: `Failed to warp session ${input.sessionID} into workspace ${workspaceID}: HTTP ${response.status} ${body}`,
|
||||||
|
workspaceID,
|
||||||
|
sessionID: input.sessionID,
|
||||||
|
status: response.status,
|
||||||
|
body,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
log.info("session warp batch posted", {
|
||||||
workspaceID: input.workspaceID,
|
workspaceID: input.workspaceID,
|
||||||
sessionID: input.sessionID,
|
sessionID: input.sessionID,
|
||||||
step: i + 1,
|
step: i + 1,
|
||||||
total,
|
total,
|
||||||
status: res.status,
|
status: response.status,
|
||||||
body,
|
|
||||||
})
|
})
|
||||||
return yield* new SessionRestoreHttpError({
|
|
||||||
message: `Failed to replay session ${input.sessionID} into workspace ${input.workspaceID}: HTTP ${res.status} ${body}`,
|
|
||||||
workspaceID: input.workspaceID,
|
|
||||||
sessionID: input.sessionID,
|
|
||||||
status: res.status,
|
|
||||||
body,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
log.info("session restore batch posted", {
|
|
||||||
workspaceID: input.workspaceID,
|
|
||||||
sessionID: input.sessionID,
|
|
||||||
step: i + 1,
|
|
||||||
total,
|
|
||||||
status: res.status,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
yield* Effect.sync(() =>
|
|
||||||
GlobalBus.emit("event", {
|
|
||||||
directory: "global",
|
|
||||||
workspace: input.workspaceID,
|
|
||||||
payload: {
|
|
||||||
type: Event.Restore.type,
|
|
||||||
properties: {
|
|
||||||
workspaceID: input.workspaceID,
|
|
||||||
sessionID: input.sessionID,
|
|
||||||
total,
|
|
||||||
step: i + 1,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}),
|
}),
|
||||||
)
|
{ discard: true },
|
||||||
|
)
|
||||||
|
|
||||||
|
const response = yield* http.execute(
|
||||||
|
HttpClientRequest.post(route(target.url, "/sync/steal"), {
|
||||||
|
headers: new Headers(target.headers),
|
||||||
|
body: HttpBody.jsonUnsafe({ sessionID: input.sessionID }),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
if (response.status < 200 || response.status >= 300) {
|
||||||
|
const body = yield* response.text
|
||||||
|
log.error("session warp steal failed", {
|
||||||
|
workspaceID: input.workspaceID,
|
||||||
|
sessionID: input.sessionID,
|
||||||
|
status: response.status,
|
||||||
|
body,
|
||||||
|
})
|
||||||
|
return yield* new SessionWarpHttpError({
|
||||||
|
message: `Failed to steal session ${input.sessionID} into workspace ${workspaceID}: HTTP ${response.status} ${body}`,
|
||||||
|
workspaceID,
|
||||||
|
sessionID: input.sessionID,
|
||||||
|
status: response.status,
|
||||||
|
body,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
log.info("session restore complete", {
|
log.info("session warp complete", {
|
||||||
workspaceID: input.workspaceID,
|
workspaceID: input.workspaceID,
|
||||||
sessionID: input.sessionID,
|
sessionID: input.sessionID,
|
||||||
batches: total,
|
batches: total,
|
||||||
})
|
})
|
||||||
|
|
||||||
return { total }
|
|
||||||
}).pipe(
|
}).pipe(
|
||||||
Effect.tapError((err) =>
|
Effect.tapError((err) =>
|
||||||
Effect.sync(() =>
|
Effect.sync(() =>
|
||||||
log.error("session restore failed", {
|
log.error("session warp failed", {
|
||||||
workspaceID: input.workspaceID,
|
workspaceID: input.workspaceID,
|
||||||
sessionID: input.sessionID,
|
sessionID: input.sessionID,
|
||||||
error: errorData(err),
|
error: errorData(err),
|
||||||
|
|
@ -814,7 +838,7 @@ export const layer = Layer.effect(
|
||||||
|
|
||||||
return Service.of({
|
return Service.of({
|
||||||
create,
|
create,
|
||||||
sessionRestore,
|
sessionWarp,
|
||||||
list,
|
list,
|
||||||
get,
|
get,
|
||||||
remove,
|
remove,
|
||||||
|
|
@ -830,6 +854,7 @@ export const defaultLayer = layer.pipe(
|
||||||
Layer.provide(Auth.defaultLayer),
|
Layer.provide(Auth.defaultLayer),
|
||||||
Layer.provide(Session.defaultLayer),
|
Layer.provide(Session.defaultLayer),
|
||||||
Layer.provide(SyncEvent.defaultLayer),
|
Layer.provide(SyncEvent.defaultLayer),
|
||||||
|
Layer.provide(SessionPrompt.defaultLayer),
|
||||||
Layer.provide(FetchHttpClient.layer),
|
Layer.provide(FetchHttpClient.layer),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -10,10 +10,6 @@ import { zodObject } from "@/util/effect-zod"
|
||||||
import { Instance } from "@/project/instance"
|
import { Instance } from "@/project/instance"
|
||||||
import { errors } from "../../error"
|
import { errors } from "../../error"
|
||||||
import { lazy } from "@/util/lazy"
|
import { lazy } from "@/util/lazy"
|
||||||
import * as Log from "@opencode-ai/core/util/log"
|
|
||||||
import { errorData } from "@/util/error"
|
|
||||||
|
|
||||||
const log = Log.create({ service: "server.workspace" })
|
|
||||||
|
|
||||||
export const WorkspaceRoutes = lazy(() =>
|
export const WorkspaceRoutes = lazy(() =>
|
||||||
new Hono()
|
new Hono()
|
||||||
|
|
@ -151,60 +147,36 @@ export const WorkspaceRoutes = lazy(() =>
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
.post(
|
.post(
|
||||||
"/:id/session-restore",
|
"/warp",
|
||||||
describeRoute({
|
describeRoute({
|
||||||
summary: "Restore session into workspace",
|
summary: "Warp session into workspace",
|
||||||
description: "Replay a session's sync events into the target workspace in batches.",
|
description: "Move a session's sync history into the target workspace, or detach it to the local project.",
|
||||||
operationId: "experimental.workspace.sessionRestore",
|
operationId: "experimental.workspace.warp",
|
||||||
responses: {
|
responses: {
|
||||||
200: {
|
204: {
|
||||||
description: "Session replay started",
|
description: "Session warped",
|
||||||
content: {
|
|
||||||
"application/json": {
|
|
||||||
schema: resolver(
|
|
||||||
z.object({
|
|
||||||
total: z.number().int().min(0),
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
...errors(400),
|
...errors(400),
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
validator("param", z.object({ id: zodObject(Workspace.Info).shape.id })),
|
validator(
|
||||||
validator("json", Workspace.SessionRestoreInput.zodObject.omit({ workspaceID: true })),
|
"json",
|
||||||
|
z.object({
|
||||||
|
id: zodObject(Workspace.Info).shape.id.nullable(),
|
||||||
|
sessionID: Workspace.SessionWarpInput.zodObject.shape.sessionID,
|
||||||
|
}),
|
||||||
|
),
|
||||||
async (c) => {
|
async (c) => {
|
||||||
const { id } = c.req.valid("param")
|
const body = c.req.valid("json")
|
||||||
const body = c.req.valid("json") as Omit<Workspace.SessionRestoreInput, "workspaceID">
|
await AppRuntime.runPromise(
|
||||||
log.info("session restore route requested", {
|
Workspace.Service.use((workspace) =>
|
||||||
workspaceID: id,
|
workspace.sessionWarp({
|
||||||
sessionID: body.sessionID,
|
workspaceID: body.id,
|
||||||
directory: Instance.directory,
|
sessionID: body.sessionID,
|
||||||
})
|
}),
|
||||||
try {
|
),
|
||||||
const result = await AppRuntime.runPromise(
|
)
|
||||||
Workspace.Service.use((svc) =>
|
return c.body(null, 204)
|
||||||
svc.sessionRestore({
|
|
||||||
workspaceID: id,
|
|
||||||
...body,
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
log.info("session restore route complete", {
|
|
||||||
workspaceID: id,
|
|
||||||
sessionID: body.sessionID,
|
|
||||||
total: result.total,
|
|
||||||
})
|
|
||||||
return c.json(result)
|
|
||||||
} catch (err) {
|
|
||||||
log.error("session restore route failed", {
|
|
||||||
workspaceID: id,
|
|
||||||
sessionID: body.sessionID,
|
|
||||||
error: errorData(err),
|
|
||||||
})
|
|
||||||
throw err
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import { NonNegativeInt } from "@/util/schema"
|
import { NonNegativeInt } from "@/util/schema"
|
||||||
|
import { SessionID } from "@/session/schema"
|
||||||
import { Schema } from "effect"
|
import { Schema } from "effect"
|
||||||
import { HttpApi, HttpApiEndpoint, HttpApiError, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
|
import { HttpApi, HttpApiEndpoint, HttpApiError, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
|
||||||
import { Authorization } from "../middleware/authorization"
|
import { Authorization } from "../middleware/authorization"
|
||||||
|
|
@ -21,6 +22,9 @@ export const ReplayPayload = Schema.Struct({
|
||||||
export const ReplayResponse = Schema.Struct({
|
export const ReplayResponse = Schema.Struct({
|
||||||
sessionID: Schema.String,
|
sessionID: Schema.String,
|
||||||
})
|
})
|
||||||
|
export const SessionPayload = Schema.Struct({
|
||||||
|
sessionID: SessionID,
|
||||||
|
})
|
||||||
export const HistoryPayload = Schema.Record(Schema.String, NonNegativeInt)
|
export const HistoryPayload = Schema.Record(Schema.String, NonNegativeInt)
|
||||||
export const HistoryEvent = Schema.Struct({
|
export const HistoryEvent = Schema.Struct({
|
||||||
id: Schema.String,
|
id: Schema.String,
|
||||||
|
|
@ -33,6 +37,7 @@ export const HistoryEvent = Schema.Struct({
|
||||||
export const SyncPaths = {
|
export const SyncPaths = {
|
||||||
start: `${root}/start`,
|
start: `${root}/start`,
|
||||||
replay: `${root}/replay`,
|
replay: `${root}/replay`,
|
||||||
|
steal: `${root}/steal`,
|
||||||
history: `${root}/history`,
|
history: `${root}/history`,
|
||||||
} as const
|
} as const
|
||||||
|
|
||||||
|
|
@ -60,6 +65,17 @@ export const SyncApi = HttpApi.make("sync")
|
||||||
description: "Validate and replay a complete sync event history.",
|
description: "Validate and replay a complete sync event history.",
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
|
HttpApiEndpoint.post("steal", SyncPaths.steal, {
|
||||||
|
payload: SessionPayload,
|
||||||
|
success: described(SessionPayload, "Session stolen into workspace"),
|
||||||
|
error: HttpApiError.BadRequest,
|
||||||
|
}).annotateMerge(
|
||||||
|
OpenApi.annotations({
|
||||||
|
identifier: "sync.steal",
|
||||||
|
summary: "Steal session into workspace",
|
||||||
|
description: "Update a session to belong to the current workspace through the sync event system.",
|
||||||
|
}),
|
||||||
|
),
|
||||||
HttpApiEndpoint.post("history", SyncPaths.history, {
|
HttpApiEndpoint.post("history", SyncPaths.history, {
|
||||||
payload: HistoryPayload,
|
payload: HistoryPayload,
|
||||||
success: described(Schema.Array(HistoryEvent), "Sync events"),
|
success: described(Schema.Array(HistoryEvent), "Sync events"),
|
||||||
|
|
|
||||||
|
|
@ -1,21 +1,17 @@
|
||||||
import { Workspace } from "@/control-plane/workspace"
|
import { Workspace } from "@/control-plane/workspace"
|
||||||
import { WorkspaceAdapterEntry } from "@/control-plane/types"
|
import { WorkspaceAdapterEntry } from "@/control-plane/types"
|
||||||
import { NonNegativeInt } from "@/util/schema"
|
|
||||||
import { Schema, Struct } from "effect"
|
import { Schema, Struct } from "effect"
|
||||||
import { HttpApi, HttpApiEndpoint, HttpApiError, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
|
import { HttpApi, HttpApiEndpoint, HttpApiError, HttpApiGroup, HttpApiSchema, OpenApi } from "effect/unstable/httpapi"
|
||||||
import { Authorization } from "../middleware/authorization"
|
import { Authorization } from "../middleware/authorization"
|
||||||
import { InstanceContextMiddleware } from "../middleware/instance-context"
|
import { InstanceContextMiddleware } from "../middleware/instance-context"
|
||||||
import { WorkspaceRoutingMiddleware } from "../middleware/workspace-routing"
|
import { WorkspaceRoutingMiddleware } from "../middleware/workspace-routing"
|
||||||
import { described } from "./metadata"
|
import { described } from "./metadata"
|
||||||
|
|
||||||
const root = "/experimental/workspace"
|
const root = "/experimental/workspace"
|
||||||
export const CreatePayload = Schema.Struct({
|
export const CreatePayload = Schema.Struct(Struct.omit(Workspace.CreateInput.fields, ["projectID"]))
|
||||||
...Struct.omit(Workspace.CreateInput.fields, ["projectID", "extra"]),
|
export const WarpPayload = Schema.Struct({
|
||||||
extra: Schema.optional(Workspace.CreateInput.fields.extra),
|
id: Schema.NullOr(Workspace.Info.fields.id),
|
||||||
})
|
sessionID: Workspace.SessionWarpInput.fields.sessionID,
|
||||||
export const SessionRestorePayload = Schema.Struct(Struct.omit(Workspace.SessionRestoreInput.fields, ["workspaceID"]))
|
|
||||||
export const SessionRestoreResponse = Schema.Struct({
|
|
||||||
total: NonNegativeInt,
|
|
||||||
})
|
})
|
||||||
|
|
||||||
export const WorkspacePaths = {
|
export const WorkspacePaths = {
|
||||||
|
|
@ -23,7 +19,7 @@ export const WorkspacePaths = {
|
||||||
list: root,
|
list: root,
|
||||||
status: `${root}/status`,
|
status: `${root}/status`,
|
||||||
remove: `${root}/:id`,
|
remove: `${root}/:id`,
|
||||||
sessionRestore: `${root}/:id/session-restore`,
|
warp: `${root}/warp`,
|
||||||
} as const
|
} as const
|
||||||
|
|
||||||
export const WorkspaceApi = HttpApi.make("workspace")
|
export const WorkspaceApi = HttpApi.make("workspace")
|
||||||
|
|
@ -79,16 +75,15 @@ export const WorkspaceApi = HttpApi.make("workspace")
|
||||||
description: "Remove an existing workspace.",
|
description: "Remove an existing workspace.",
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
HttpApiEndpoint.post("sessionRestore", WorkspacePaths.sessionRestore, {
|
HttpApiEndpoint.post("warp", WorkspacePaths.warp, {
|
||||||
params: { id: Workspace.Info.fields.id },
|
payload: WarpPayload,
|
||||||
payload: SessionRestorePayload,
|
success: described(HttpApiSchema.NoContent, "Session warped"),
|
||||||
success: described(SessionRestoreResponse, "Session replay started"),
|
|
||||||
error: HttpApiError.BadRequest,
|
error: HttpApiError.BadRequest,
|
||||||
}).annotateMerge(
|
}).annotateMerge(
|
||||||
OpenApi.annotations({
|
OpenApi.annotations({
|
||||||
identifier: "experimental.workspace.sessionRestore",
|
identifier: "experimental.workspace.warp",
|
||||||
summary: "Restore session into workspace",
|
summary: "Warp session into workspace",
|
||||||
description: "Replay a session's sync events into the target workspace in batches.",
|
description: "Move a session's sync history into the target workspace, or detach it to the local project.",
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import { Workspace } from "@/control-plane/workspace"
|
import { Workspace } from "@/control-plane/workspace"
|
||||||
import * as InstanceState from "@/effect/instance-state"
|
import * as InstanceState from "@/effect/instance-state"
|
||||||
|
import { Session } from "@/session/session"
|
||||||
import { Database } from "@/storage/db"
|
import { Database } from "@/storage/db"
|
||||||
import { SyncEvent } from "@/sync"
|
import { SyncEvent } from "@/sync"
|
||||||
import { EventTable } from "@/sync/event.sql"
|
import { EventTable } from "@/sync/event.sql"
|
||||||
|
|
@ -12,7 +13,7 @@ import { or } from "drizzle-orm"
|
||||||
import { Effect, Scope } from "effect"
|
import { Effect, Scope } from "effect"
|
||||||
import { HttpApiBuilder } from "effect/unstable/httpapi"
|
import { HttpApiBuilder } from "effect/unstable/httpapi"
|
||||||
import { InstanceHttpApi } from "../api"
|
import { InstanceHttpApi } from "../api"
|
||||||
import { HistoryPayload, ReplayPayload } from "../groups/sync"
|
import { HistoryPayload, ReplayPayload, SessionPayload } from "../groups/sync"
|
||||||
import * as Log from "@opencode-ai/core/util/log"
|
import * as Log from "@opencode-ai/core/util/log"
|
||||||
|
|
||||||
const log = Log.create({ service: "server.sync" })
|
const log = Log.create({ service: "server.sync" })
|
||||||
|
|
@ -56,6 +57,25 @@ export const syncHandlers = HttpApiBuilder.group(InstanceHttpApi, "sync", (handl
|
||||||
return { sessionID: source }
|
return { sessionID: source }
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const steal = Effect.fn("SyncHttpApi.steal")(function* (ctx: { payload: typeof SessionPayload.Type }) {
|
||||||
|
const workspaceID = yield* InstanceState.workspaceID
|
||||||
|
if (!workspaceID) throw new Error("Cannot steal session without workspace context")
|
||||||
|
|
||||||
|
yield* sync.run(Session.Event.Updated, {
|
||||||
|
sessionID: ctx.payload.sessionID,
|
||||||
|
info: {
|
||||||
|
workspaceID,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
log.info("sync session stolen", {
|
||||||
|
sessionID: ctx.payload.sessionID,
|
||||||
|
workspaceID,
|
||||||
|
})
|
||||||
|
|
||||||
|
return { sessionID: ctx.payload.sessionID }
|
||||||
|
})
|
||||||
|
|
||||||
const history = Effect.fn("SyncHttpApi.history")(function* (ctx: { payload: typeof HistoryPayload.Type }) {
|
const history = Effect.fn("SyncHttpApi.history")(function* (ctx: { payload: typeof HistoryPayload.Type }) {
|
||||||
const exclude = Object.entries(ctx.payload)
|
const exclude = Object.entries(ctx.payload)
|
||||||
return Database.use((db) =>
|
return Database.use((db) =>
|
||||||
|
|
@ -72,6 +92,6 @@ export const syncHandlers = HttpApiBuilder.group(InstanceHttpApi, "sync", (handl
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
return handlers.handle("start", start).handle("replay", replay).handle("history", history)
|
return handlers.handle("start", start).handle("replay", replay).handle("steal", steal).handle("history", history)
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@ import * as InstanceState from "@/effect/instance-state"
|
||||||
import { Effect } from "effect"
|
import { Effect } from "effect"
|
||||||
import { HttpApiBuilder, HttpApiError } from "effect/unstable/httpapi"
|
import { HttpApiBuilder, HttpApiError } from "effect/unstable/httpapi"
|
||||||
import { InstanceHttpApi } from "../api"
|
import { InstanceHttpApi } from "../api"
|
||||||
import { CreatePayload, SessionRestorePayload } from "../groups/workspace"
|
import { CreatePayload, WarpPayload } from "../groups/workspace"
|
||||||
|
|
||||||
export const workspaceHandlers = HttpApiBuilder.group(InstanceHttpApi, "workspace", (handlers) =>
|
export const workspaceHandlers = HttpApiBuilder.group(InstanceHttpApi, "workspace", (handlers) =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
|
|
@ -39,13 +39,10 @@ export const workspaceHandlers = HttpApiBuilder.group(InstanceHttpApi, "workspac
|
||||||
return yield* workspace.remove(ctx.params.id)
|
return yield* workspace.remove(ctx.params.id)
|
||||||
})
|
})
|
||||||
|
|
||||||
const sessionRestore = Effect.fn("WorkspaceHttpApi.sessionRestore")(function* (ctx: {
|
const warp = Effect.fn("WorkspaceHttpApi.warp")(function* (ctx: { payload: typeof WarpPayload.Type }) {
|
||||||
params: { id: Workspace.Info["id"] }
|
yield* workspace
|
||||||
payload: typeof SessionRestorePayload.Type
|
.sessionWarp({
|
||||||
}) {
|
workspaceID: ctx.payload.id,
|
||||||
return yield* workspace
|
|
||||||
.sessionRestore({
|
|
||||||
workspaceID: ctx.params.id,
|
|
||||||
sessionID: ctx.payload.sessionID,
|
sessionID: ctx.payload.sessionID,
|
||||||
})
|
})
|
||||||
.pipe(Effect.mapError(() => new HttpApiError.BadRequest({})))
|
.pipe(Effect.mapError(() => new HttpApiError.BadRequest({})))
|
||||||
|
|
@ -57,6 +54,6 @@ export const workspaceHandlers = HttpApiBuilder.group(InstanceHttpApi, "workspac
|
||||||
.handle("create", create)
|
.handle("create", create)
|
||||||
.handle("status", status)
|
.handle("status", status)
|
||||||
.handle("remove", remove)
|
.handle("remove", remove)
|
||||||
.handle("sessionRestore", sessionRestore)
|
.handle("warp", warp)
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -155,7 +155,7 @@ export const InstanceRoutes = (upgrade: UpgradeWebSocket, opts?: CorsOptions): H
|
||||||
app.get(WorkspacePaths.list, (c) => handler(c.req.raw, context))
|
app.get(WorkspacePaths.list, (c) => handler(c.req.raw, context))
|
||||||
app.get(WorkspacePaths.status, (c) => handler(c.req.raw, context))
|
app.get(WorkspacePaths.status, (c) => handler(c.req.raw, context))
|
||||||
app.delete(WorkspacePaths.remove, (c) => handler(c.req.raw, context))
|
app.delete(WorkspacePaths.remove, (c) => handler(c.req.raw, context))
|
||||||
app.post(WorkspacePaths.sessionRestore, (c) => handler(c.req.raw, context))
|
app.post(WorkspacePaths.warp, (c) => handler(c.req.raw, context))
|
||||||
}
|
}
|
||||||
|
|
||||||
return app
|
return app
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,9 @@ import { Workspace } from "@/control-plane/workspace"
|
||||||
import { AppRuntime } from "@/effect/app-runtime"
|
import { AppRuntime } from "@/effect/app-runtime"
|
||||||
import { Instance } from "@/project/instance"
|
import { Instance } from "@/project/instance"
|
||||||
import { errors } from "../../error"
|
import { errors } from "../../error"
|
||||||
|
import { Session } from "@/session/session"
|
||||||
|
import { WorkspaceContext } from "@/control-plane/workspace-context"
|
||||||
|
import { SessionID } from "@/session/schema"
|
||||||
|
|
||||||
const ReplayEvent = z.object({
|
const ReplayEvent = z.object({
|
||||||
id: z.string(),
|
id: z.string(),
|
||||||
|
|
@ -24,6 +27,9 @@ const ReplayEvent = z.object({
|
||||||
type: z.string(),
|
type: z.string(),
|
||||||
data: z.record(z.string(), z.unknown()),
|
data: z.record(z.string(), z.unknown()),
|
||||||
})
|
})
|
||||||
|
const SessionPayload = z.object({
|
||||||
|
sessionID: SessionID.zod,
|
||||||
|
})
|
||||||
|
|
||||||
const log = Log.create({ service: "server.sync" })
|
const log = Log.create({ service: "server.sync" })
|
||||||
|
|
||||||
|
|
@ -108,6 +114,47 @@ export const SyncRoutes = lazy(() =>
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
.post(
|
||||||
|
"/steal",
|
||||||
|
describeRoute({
|
||||||
|
summary: "Steal session into workspace",
|
||||||
|
description: "Update a session to belong to the current workspace through the sync event system.",
|
||||||
|
operationId: "sync.steal",
|
||||||
|
responses: {
|
||||||
|
200: {
|
||||||
|
description: "Session stolen into workspace",
|
||||||
|
content: {
|
||||||
|
"application/json": {
|
||||||
|
schema: resolver(SessionPayload),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
...errors(400),
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
validator("json", SessionPayload),
|
||||||
|
async (c) => {
|
||||||
|
const body = c.req.valid("json")
|
||||||
|
const workspaceID = WorkspaceContext.workspaceID
|
||||||
|
if (!workspaceID) throw new Error("Cannot steal session without workspace context")
|
||||||
|
|
||||||
|
SyncEvent.run(Session.Event.Updated, {
|
||||||
|
sessionID: body.sessionID,
|
||||||
|
info: {
|
||||||
|
workspaceID,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
log.info("sync session stolen", {
|
||||||
|
sessionID: body.sessionID,
|
||||||
|
workspaceID,
|
||||||
|
})
|
||||||
|
|
||||||
|
return c.json({
|
||||||
|
sessionID: body.sessionID,
|
||||||
|
})
|
||||||
|
},
|
||||||
|
)
|
||||||
.post(
|
.post(
|
||||||
"/history",
|
"/history",
|
||||||
describeRoute({
|
describeRoute({
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ import { sqliteTable, text, integer } from "drizzle-orm/sqlite-core"
|
||||||
export const EventSequenceTable = sqliteTable("event_sequence", {
|
export const EventSequenceTable = sqliteTable("event_sequence", {
|
||||||
aggregate_id: text().notNull().primaryKey(),
|
aggregate_id: text().notNull().primaryKey(),
|
||||||
seq: integer().notNull(),
|
seq: integer().notNull(),
|
||||||
|
owner_id: text(),
|
||||||
})
|
})
|
||||||
|
|
||||||
export const EventTable = sqliteTable("event", {
|
export const EventTable = sqliteTable("event", {
|
||||||
|
|
|
||||||
|
|
@ -59,8 +59,11 @@ export interface Interface {
|
||||||
data: Event<Def>["data"],
|
data: Event<Def>["data"],
|
||||||
options?: { publish?: boolean },
|
options?: { publish?: boolean },
|
||||||
) => Effect.Effect<void>
|
) => Effect.Effect<void>
|
||||||
readonly replay: (event: SerializedEvent, options?: { publish: boolean }) => Effect.Effect<void>
|
readonly replay: (event: SerializedEvent, options?: { publish: boolean; ownerID?: string }) => Effect.Effect<void>
|
||||||
readonly replayAll: (events: SerializedEvent[], options?: { publish: boolean }) => Effect.Effect<string | undefined>
|
readonly replayAll: (
|
||||||
|
events: SerializedEvent[],
|
||||||
|
options?: { publish: boolean; ownerID?: string },
|
||||||
|
) => Effect.Effect<string | undefined>
|
||||||
readonly remove: (aggregateID: string) => Effect.Effect<void>
|
readonly remove: (aggregateID: string) => Effect.Effect<void>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -76,7 +79,7 @@ export const layer = Layer.effect(Service)(
|
||||||
|
|
||||||
const row = Database.use((db) =>
|
const row = Database.use((db) =>
|
||||||
db
|
db
|
||||||
.select({ seq: EventSequenceTable.seq })
|
.select({ seq: EventSequenceTable.seq, ownerID: EventSequenceTable.owner_id })
|
||||||
.from(EventSequenceTable)
|
.from(EventSequenceTable)
|
||||||
.where(eq(EventSequenceTable.aggregate_id, event.aggregateID))
|
.where(eq(EventSequenceTable.aggregate_id, event.aggregateID))
|
||||||
.get(),
|
.get(),
|
||||||
|
|
@ -85,6 +88,10 @@ export const layer = Layer.effect(Service)(
|
||||||
const latest = row?.seq ?? -1
|
const latest = row?.seq ?? -1
|
||||||
if (event.seq <= latest) return
|
if (event.seq <= latest) return
|
||||||
|
|
||||||
|
if (row?.ownerID && row.ownerID !== options?.ownerID) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
const expected = latest + 1
|
const expected = latest + 1
|
||||||
if (event.seq !== expected) {
|
if (event.seq !== expected) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
|
|
@ -99,7 +106,7 @@ export const layer = Layer.effect(Service)(
|
||||||
workspace: yield* InstanceState.workspaceID,
|
workspace: yield* InstanceState.workspaceID,
|
||||||
}
|
}
|
||||||
: undefined
|
: undefined
|
||||||
process(def, event, { publish, context })
|
process(def, event, { publish, context, ownerID: options?.ownerID })
|
||||||
})
|
})
|
||||||
|
|
||||||
const replayAll: Interface["replayAll"] = Effect.fn("SyncEvent.replayAll")(function* (events, options) {
|
const replayAll: Interface["replayAll"] = Effect.fn("SyncEvent.replayAll")(function* (events, options) {
|
||||||
|
|
@ -263,7 +270,7 @@ export function project<Def extends Definition>(
|
||||||
function process<Def extends Definition>(
|
function process<Def extends Definition>(
|
||||||
def: Def,
|
def: Def,
|
||||||
event: Event<Def>,
|
event: Event<Def>,
|
||||||
options: { publish: boolean; context?: PublishContext },
|
options: { publish: boolean; context?: PublishContext; ownerID?: string },
|
||||||
) {
|
) {
|
||||||
if (projectors == null) {
|
if (projectors == null) {
|
||||||
throw new Error("No projectors available. Call `SyncEvent.init` to install projectors")
|
throw new Error("No projectors available. Call `SyncEvent.init` to install projectors")
|
||||||
|
|
@ -274,8 +281,6 @@ function process<Def extends Definition>(
|
||||||
throw new Error(`Projector not found for event: ${def.type}`)
|
throw new Error(`Projector not found for event: ${def.type}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
// idempotent: need to ignore any events already logged
|
|
||||||
|
|
||||||
Database.transaction((tx) => {
|
Database.transaction((tx) => {
|
||||||
projector(tx, event.data, event)
|
projector(tx, event.data, event)
|
||||||
|
|
||||||
|
|
@ -284,6 +289,7 @@ function process<Def extends Definition>(
|
||||||
.values({
|
.values({
|
||||||
aggregate_id: event.aggregateID,
|
aggregate_id: event.aggregateID,
|
||||||
seq: event.seq,
|
seq: event.seq,
|
||||||
|
owner_id: options?.ownerID,
|
||||||
})
|
})
|
||||||
.onConflictDoUpdate({
|
.onConflictDoUpdate({
|
||||||
target: EventSequenceTable.aggregate_id,
|
target: EventSequenceTable.aggregate_id,
|
||||||
|
|
@ -332,11 +338,11 @@ function process<Def extends Definition>(
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
export function replay(event: SerializedEvent, options?: { publish: boolean }) {
|
export function replay(event: SerializedEvent, options?: { publish: boolean; ownerID?: string }) {
|
||||||
return runtime.runSync((sync) => sync.replay(event, options))
|
return runtime.runSync((sync) => sync.replay(event, options))
|
||||||
}
|
}
|
||||||
|
|
||||||
export function replayAll(events: SerializedEvent[], options?: { publish: boolean }) {
|
export function replayAll(events: SerializedEvent[], options?: { publish: boolean; ownerID?: string }) {
|
||||||
return runtime.runSync((sync) => sync.replayAll(events, options))
|
return runtime.runSync((sync) => sync.replayAll(events, options))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -348,6 +354,16 @@ export function remove(aggregateID: string) {
|
||||||
return runtime.runSync((sync) => sync.remove(aggregateID))
|
return runtime.runSync((sync) => sync.remove(aggregateID))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function claim(aggregateID: string, ownerID: string) {
|
||||||
|
Database.use((db) =>
|
||||||
|
db
|
||||||
|
.update(EventSequenceTable)
|
||||||
|
.set({ owner_id: ownerID })
|
||||||
|
.where(eq(EventSequenceTable.aggregate_id, aggregateID))
|
||||||
|
.run(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
export function payloads() {
|
export function payloads() {
|
||||||
return registry
|
return registry
|
||||||
.entries()
|
.entries()
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,7 @@ import { setTimeout as delay } from "node:timers/promises"
|
||||||
import { NodeHttpServer } from "@effect/platform-node"
|
import { NodeHttpServer } from "@effect/platform-node"
|
||||||
import { Effect, Layer } from "effect"
|
import { Effect, Layer } from "effect"
|
||||||
import { HttpServer, HttpServerRequest, HttpServerResponse } from "effect/unstable/http"
|
import { HttpServer, HttpServerRequest, HttpServerResponse } from "effect/unstable/http"
|
||||||
import { asc, eq } from "drizzle-orm"
|
import { eq } from "drizzle-orm"
|
||||||
import * as Log from "@opencode-ai/core/util/log"
|
import * as Log from "@opencode-ai/core/util/log"
|
||||||
import { Flag } from "@opencode-ai/core/flag/flag"
|
import { Flag } from "@opencode-ai/core/flag/flag"
|
||||||
import { GlobalBus, type GlobalEvent } from "@/bus/global"
|
import { GlobalBus, type GlobalEvent } from "@/bus/global"
|
||||||
|
|
@ -16,11 +16,10 @@ import { ProjectTable } from "@/project/project.sql"
|
||||||
import { Instance } from "@/project/instance"
|
import { Instance } from "@/project/instance"
|
||||||
import { WithInstance } from "../../src/project/with-instance"
|
import { WithInstance } from "../../src/project/with-instance"
|
||||||
import { Session as SessionNs } from "@/session/session"
|
import { Session as SessionNs } from "@/session/session"
|
||||||
import { SessionID, MessageID, PartID } from "@/session/schema"
|
import { SessionID } from "@/session/schema"
|
||||||
import { SessionTable } from "@/session/session.sql"
|
import { SessionTable } from "@/session/session.sql"
|
||||||
import { ModelID, ProviderID } from "@/provider/schema"
|
|
||||||
import { SyncEvent } from "@/sync"
|
import { SyncEvent } from "@/sync"
|
||||||
import { EventSequenceTable, EventTable } from "@/sync/event.sql"
|
import { EventSequenceTable } from "@/sync/event.sql"
|
||||||
import { resetDatabase } from "../fixture/db"
|
import { resetDatabase } from "../fixture/db"
|
||||||
import { disposeAllInstances, provideTmpdirInstance, tmpdir } from "../fixture/fixture"
|
import { disposeAllInstances, provideTmpdirInstance, tmpdir } from "../fixture/fixture"
|
||||||
import { testEffect } from "../lib/effect"
|
import { testEffect } from "../lib/effect"
|
||||||
|
|
@ -111,8 +110,8 @@ async function withInstance<T>(fn: (dir: string) => T | Promise<T>) {
|
||||||
const runWorkspace = <A, E>(effect: Effect.Effect<A, E, WorkspaceOld.Service>) => AppRuntime.runPromise(effect)
|
const runWorkspace = <A, E>(effect: Effect.Effect<A, E, WorkspaceOld.Service>) => AppRuntime.runPromise(effect)
|
||||||
const createWorkspace = (input: WorkspaceOld.CreateInput) =>
|
const createWorkspace = (input: WorkspaceOld.CreateInput) =>
|
||||||
runWorkspace(WorkspaceOld.Service.use((workspace) => workspace.create(input)))
|
runWorkspace(WorkspaceOld.Service.use((workspace) => workspace.create(input)))
|
||||||
const restoreWorkspaceSession = (input: WorkspaceOld.SessionRestoreInput) =>
|
const warpWorkspaceSession = (input: WorkspaceOld.SessionWarpInput) =>
|
||||||
runWorkspace(WorkspaceOld.Service.use((workspace) => workspace.sessionRestore(input)))
|
runWorkspace(WorkspaceOld.Service.use((workspace) => workspace.sessionWarp(input)))
|
||||||
const listWorkspaces = (project: Parameters<WorkspaceOld.Interface["list"]>[0]) =>
|
const listWorkspaces = (project: Parameters<WorkspaceOld.Interface["list"]>[0]) =>
|
||||||
runWorkspace(WorkspaceOld.Service.use((workspace) => workspace.list(project)))
|
runWorkspace(WorkspaceOld.Service.use((workspace) => workspace.list(project)))
|
||||||
const getWorkspace = (id: WorkspaceID) => runWorkspace(WorkspaceOld.Service.use((workspace) => workspace.get(id)))
|
const getWorkspace = (id: WorkspaceID) => runWorkspace(WorkspaceOld.Service.use((workspace) => workspace.get(id)))
|
||||||
|
|
@ -317,48 +316,24 @@ function sessionSequence(sessionID: SessionID) {
|
||||||
)?.seq
|
)?.seq
|
||||||
}
|
}
|
||||||
|
|
||||||
function eventRows(sessionID: SessionID) {
|
function sessionSequenceOwner(sessionID: SessionID) {
|
||||||
return Database.use((db) =>
|
return Database.use((db) =>
|
||||||
db
|
db
|
||||||
.select({ seq: EventTable.seq, type: EventTable.type, data: EventTable.data })
|
.select({ ownerID: EventSequenceTable.owner_id })
|
||||||
.from(EventTable)
|
.from(EventSequenceTable)
|
||||||
.where(eq(EventTable.aggregate_id, sessionID))
|
.where(eq(EventSequenceTable.aggregate_id, sessionID))
|
||||||
.orderBy(asc(EventTable.seq))
|
.get(),
|
||||||
.all(),
|
)?.ownerID
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function sessionUpdatedType() {
|
function sessionUpdatedType() {
|
||||||
return SyncEvent.versionedType(SessionNs.Event.Updated.type, SessionNs.Event.Updated.version)
|
return SyncEvent.versionedType(SessionNs.Event.Updated.type, SessionNs.Event.Updated.version)
|
||||||
}
|
}
|
||||||
|
|
||||||
function replaceSessionEvents(sessionID: SessionID, count: number) {
|
|
||||||
Database.use((db) => {
|
|
||||||
db.delete(EventSequenceTable).where(eq(EventSequenceTable.aggregate_id, sessionID)).run()
|
|
||||||
if (count === 0) return
|
|
||||||
|
|
||||||
db.insert(EventSequenceTable)
|
|
||||||
.values({ aggregate_id: sessionID, seq: count - 1 })
|
|
||||||
.run()
|
|
||||||
db.insert(EventTable)
|
|
||||||
.values(
|
|
||||||
Array.from({ length: count }, (_, i) => ({
|
|
||||||
id: `evt_${unique(`manual-${i}`)}`,
|
|
||||||
aggregate_id: sessionID,
|
|
||||||
seq: i,
|
|
||||||
type: sessionUpdatedType(),
|
|
||||||
data: { sessionID, info: { title: `manual ${i}` } },
|
|
||||||
})),
|
|
||||||
)
|
|
||||||
.run()
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
describe("workspace-old schemas and exports", () => {
|
describe("workspace-old schemas and exports", () => {
|
||||||
test("keeps the historical event type names", () => {
|
test("keeps the historical event type names", () => {
|
||||||
expect(WorkspaceOld.Event.Ready.type).toBe("workspace.ready")
|
expect(WorkspaceOld.Event.Ready.type).toBe("workspace.ready")
|
||||||
expect(WorkspaceOld.Event.Failed.type).toBe("workspace.failed")
|
expect(WorkspaceOld.Event.Failed.type).toBe("workspace.failed")
|
||||||
expect(WorkspaceOld.Event.Restore.type).toBe("workspace.restore")
|
|
||||||
expect(WorkspaceOld.Event.Status.type).toBe("workspace.status")
|
expect(WorkspaceOld.Event.Status.type).toBe("workspace.status")
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
@ -375,17 +350,6 @@ describe("workspace-old schemas and exports", () => {
|
||||||
expect(() => WorkspaceOld.CreateInput.zod.parse({ ...input, id: "bad" })).toThrow()
|
expect(() => WorkspaceOld.CreateInput.zod.parse({ ...input, id: "bad" })).toThrow()
|
||||||
expect(() => WorkspaceOld.CreateInput.zod.parse({ ...input, branch: 1 })).toThrow()
|
expect(() => WorkspaceOld.CreateInput.zod.parse({ ...input, branch: 1 })).toThrow()
|
||||||
})
|
})
|
||||||
|
|
||||||
test("validates session restore input", () => {
|
|
||||||
const input = {
|
|
||||||
workspaceID: WorkspaceID.ascending("wrk_schema_restore"),
|
|
||||||
sessionID: SessionID.descending("ses_schema_restore"),
|
|
||||||
}
|
|
||||||
|
|
||||||
expect(WorkspaceOld.SessionRestoreInput.zod.parse(input)).toEqual(input)
|
|
||||||
expect(() => WorkspaceOld.SessionRestoreInput.zod.parse({ ...input, workspaceID: "bad" })).toThrow()
|
|
||||||
expect(() => WorkspaceOld.SessionRestoreInput.zod.parse({ ...input, sessionID: "bad" })).toThrow()
|
|
||||||
})
|
|
||||||
})
|
})
|
||||||
|
|
||||||
describe("workspace-old CRUD", () => {
|
describe("workspace-old CRUD", () => {
|
||||||
|
|
@ -651,6 +615,144 @@ describe("workspace-old CRUD", () => {
|
||||||
expect(await getWorkspace(info.id)).toBeUndefined()
|
expect(await getWorkspace(info.id)).toBeUndefined()
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test("sessionWarp moves a session into a local workspace and claims ownership", async () => {
|
||||||
|
await withInstance(async (dir) => {
|
||||||
|
const previousType = unique("warp-prev-local")
|
||||||
|
const targetType = unique("warp-target-local")
|
||||||
|
const previous = workspaceInfo(Instance.project.id, previousType)
|
||||||
|
const target = workspaceInfo(Instance.project.id, targetType)
|
||||||
|
insertWorkspace(previous)
|
||||||
|
insertWorkspace(target)
|
||||||
|
registerAdapter(Instance.project.id, previousType, localAdapter(path.join(dir, "warp-prev-local")).adapter)
|
||||||
|
registerAdapter(Instance.project.id, targetType, localAdapter(path.join(dir, "warp-target-local")).adapter)
|
||||||
|
const session = await AppRuntime.runPromise(SessionNs.Service.use((svc) => svc.create({})))
|
||||||
|
attachSessionToWorkspace(session.id, previous.id)
|
||||||
|
|
||||||
|
await warpWorkspaceSession({ workspaceID: target.id, sessionID: session.id })
|
||||||
|
|
||||||
|
expect(
|
||||||
|
Database.use((db) =>
|
||||||
|
db
|
||||||
|
.select({ workspaceID: SessionTable.workspace_id })
|
||||||
|
.from(SessionTable)
|
||||||
|
.where(eq(SessionTable.id, session.id))
|
||||||
|
.get(),
|
||||||
|
)?.workspaceID,
|
||||||
|
).toBe(target.id)
|
||||||
|
expect(sessionSequenceOwner(session.id)).toBe(target.id)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test("sessionWarp detaches a session to the local project and claims project ownership", async () => {
|
||||||
|
await withInstance(async (dir) => {
|
||||||
|
const previousType = unique("warp-detach-local")
|
||||||
|
const previous = workspaceInfo(Instance.project.id, previousType)
|
||||||
|
insertWorkspace(previous)
|
||||||
|
registerAdapter(Instance.project.id, previousType, localAdapter(path.join(dir, "warp-detach-local")).adapter)
|
||||||
|
const session = await AppRuntime.runPromise(SessionNs.Service.use((svc) => svc.create({})))
|
||||||
|
attachSessionToWorkspace(session.id, previous.id)
|
||||||
|
|
||||||
|
await warpWorkspaceSession({ workspaceID: null, sessionID: session.id })
|
||||||
|
|
||||||
|
expect(
|
||||||
|
Database.use((db) =>
|
||||||
|
db
|
||||||
|
.select({ workspaceID: SessionTable.workspace_id })
|
||||||
|
.from(SessionTable)
|
||||||
|
.where(eq(SessionTable.id, session.id))
|
||||||
|
.get(),
|
||||||
|
)?.workspaceID,
|
||||||
|
).toBeNull()
|
||||||
|
expect(sessionSequenceOwner(session.id)).toBe(Instance.project.id)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it.live("sessionWarp syncs previous remote history, replays it, steals, and claims the sequence", () => {
|
||||||
|
const calls: FetchCall[] = []
|
||||||
|
let historySessionID: SessionID | undefined
|
||||||
|
let historyNextSeq = 0
|
||||||
|
return Effect.gen(function* () {
|
||||||
|
yield* HttpServer.serveEffect()(
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const req = yield* HttpServerRequest.HttpServerRequest
|
||||||
|
const bodyText = yield* req.text
|
||||||
|
const call = {
|
||||||
|
url: new URL(req.url, "http://localhost"),
|
||||||
|
method: req.method,
|
||||||
|
headers: new Headers(req.headers),
|
||||||
|
bodyText,
|
||||||
|
json: bodyText ? JSON.parse(bodyText) : undefined,
|
||||||
|
}
|
||||||
|
calls.push(call)
|
||||||
|
if (call.url.pathname === "/warp-source/sync/history") {
|
||||||
|
return yield* HttpServerResponse.json([
|
||||||
|
{
|
||||||
|
id: `evt_${unique("warp-source-history")}`,
|
||||||
|
aggregate_id: historySessionID!,
|
||||||
|
seq: historyNextSeq,
|
||||||
|
type: sessionUpdatedType(),
|
||||||
|
data: { sessionID: historySessionID!, info: { title: "from source history" } },
|
||||||
|
},
|
||||||
|
])
|
||||||
|
}
|
||||||
|
if (call.url.pathname === "/warp-target/sync/replay")
|
||||||
|
return yield* HttpServerResponse.json({ sessionID: "ok" })
|
||||||
|
if (call.url.pathname === "/warp-target/sync/steal")
|
||||||
|
return yield* HttpServerResponse.json({ sessionID: "ok" })
|
||||||
|
return HttpServerResponse.text("unexpected", { status: 500 })
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
const url = yield* serverUrl()
|
||||||
|
yield* provideTmpdirInstance(
|
||||||
|
() =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const workspace = yield* WorkspaceOld.Service
|
||||||
|
const sessionSvc = yield* SessionNs.Service
|
||||||
|
const previousType = unique("warp-remote-source")
|
||||||
|
const targetType = unique("warp-remote-target")
|
||||||
|
const previous = workspaceInfo(Instance.project.id, previousType)
|
||||||
|
const target = workspaceInfo(Instance.project.id, targetType, { directory: "remote-target-dir" })
|
||||||
|
insertWorkspace(previous)
|
||||||
|
insertWorkspace(target)
|
||||||
|
registerAdapter(Instance.project.id, previousType, remoteAdapter(`${url}/warp-source`).adapter)
|
||||||
|
registerAdapter(Instance.project.id, targetType, remoteAdapter(`${url}/warp-target`).adapter)
|
||||||
|
const session = yield* sessionSvc.create({})
|
||||||
|
attachSessionToWorkspace(session.id, previous.id)
|
||||||
|
historySessionID = session.id
|
||||||
|
historyNextSeq = (sessionSequence(session.id) ?? -1) + 1
|
||||||
|
|
||||||
|
yield* workspace.sessionWarp({ workspaceID: target.id, sessionID: session.id })
|
||||||
|
|
||||||
|
expect(calls.map((call) => `${call.method} ${call.url.pathname}`)).toEqual([
|
||||||
|
"POST /warp-source/sync/history",
|
||||||
|
"POST /warp-target/sync/replay",
|
||||||
|
"POST /warp-target/sync/steal",
|
||||||
|
])
|
||||||
|
expect(calls[0].json).toEqual({ [session.id]: historyNextSeq - 1 })
|
||||||
|
expect(calls[1].json).toMatchObject({
|
||||||
|
directory: "remote-target-dir",
|
||||||
|
events: [
|
||||||
|
{
|
||||||
|
aggregateID: session.id,
|
||||||
|
seq: 0,
|
||||||
|
type: SyncEvent.versionedType(SessionNs.Event.Created.type, SessionNs.Event.Created.version),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
aggregateID: session.id,
|
||||||
|
seq: historyNextSeq,
|
||||||
|
type: sessionUpdatedType(),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
})
|
||||||
|
expect(calls[2].json).toEqual({ sessionID: session.id })
|
||||||
|
expect((yield* sessionSvc.get(session.id)).title).toBe("from source history")
|
||||||
|
expect(sessionSequenceOwner(session.id)).toBe(target.id)
|
||||||
|
}),
|
||||||
|
{ git: true },
|
||||||
|
)
|
||||||
|
})
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
describe("workspace-old sync state", () => {
|
describe("workspace-old sync state", () => {
|
||||||
|
|
@ -1215,313 +1317,3 @@ describe("workspace-old waitForSync", () => {
|
||||||
})
|
})
|
||||||
}, 7000)
|
}, 7000)
|
||||||
})
|
})
|
||||||
|
|
||||||
describe("workspace-old sessionRestore", () => {
|
|
||||||
test("throws when the workspace is missing", async () => {
|
|
||||||
await withInstance(async () => {
|
|
||||||
await expect(
|
|
||||||
restoreWorkspaceSession({
|
|
||||||
workspaceID: WorkspaceID.ascending("wrk_restore_missing"),
|
|
||||||
sessionID: SessionID.descending("ses_restore_missing_workspace"),
|
|
||||||
}),
|
|
||||||
).rejects.toThrow("Workspace not found: wrk_restore_missing")
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
test("throws when switching a missing session fails", async () => {
|
|
||||||
await withInstance(async (dir) => {
|
|
||||||
const type = unique("restore-missing-session")
|
|
||||||
const info = workspaceInfo(Instance.project.id, type, { directory: dir })
|
|
||||||
insertWorkspace(info)
|
|
||||||
registerAdapter(Instance.project.id, type, localAdapter(dir).adapter)
|
|
||||||
|
|
||||||
await expect(
|
|
||||||
restoreWorkspaceSession({ workspaceID: info.id, sessionID: SessionID.descending("ses_missing_restore") }),
|
|
||||||
).rejects.toThrow("NotFoundError")
|
|
||||||
await removeWorkspace(info.id)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
it.live("posts remote replay batches of 10, emits progress, and includes the workspace update event", () => {
|
|
||||||
const replay: FetchCall[] = []
|
|
||||||
return Effect.gen(function* () {
|
|
||||||
yield* HttpServer.serveEffect()(
|
|
||||||
Effect.gen(function* () {
|
|
||||||
const req = yield* HttpServerRequest.HttpServerRequest
|
|
||||||
const bodyText = yield* req.text
|
|
||||||
const call = {
|
|
||||||
url: new URL(req.url, "http://localhost"),
|
|
||||||
method: req.method,
|
|
||||||
headers: new Headers(req.headers),
|
|
||||||
bodyText,
|
|
||||||
json: bodyText ? JSON.parse(bodyText) : undefined,
|
|
||||||
}
|
|
||||||
if (call.url.pathname === "/restore/sync/replay") {
|
|
||||||
replay.push(call)
|
|
||||||
return HttpServerResponse.fromWeb(Response.json({ ok: true }))
|
|
||||||
}
|
|
||||||
return HttpServerResponse.text("unexpected", { status: 500 })
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
const url = yield* serverUrl()
|
|
||||||
yield* provideTmpdirInstance(
|
|
||||||
(dir) =>
|
|
||||||
Effect.gen(function* () {
|
|
||||||
const workspace = yield* WorkspaceOld.Service
|
|
||||||
const sessionSvc = yield* SessionNs.Service
|
|
||||||
const captured = captureGlobalEvents()
|
|
||||||
try {
|
|
||||||
const type = unique("restore-remote")
|
|
||||||
const info = workspaceInfo(Instance.project.id, type, { directory: dir })
|
|
||||||
insertWorkspace(info)
|
|
||||||
registerAdapter(
|
|
||||||
Instance.project.id,
|
|
||||||
type,
|
|
||||||
remoteAdapter(`${url}/restore/?ignored=1#hash`, {
|
|
||||||
directory: dir,
|
|
||||||
headers: { authorization: "Bearer restore" },
|
|
||||||
}).adapter,
|
|
||||||
)
|
|
||||||
const session = yield* sessionSvc.create({ title: "restore remote" })
|
|
||||||
replaceSessionEvents(session.id, 24)
|
|
||||||
|
|
||||||
const result = yield* workspace.sessionRestore({ workspaceID: info.id, sessionID: session.id })
|
|
||||||
|
|
||||||
expect(result).toEqual({ total: 3 })
|
|
||||||
expect(replay).toHaveLength(3)
|
|
||||||
expect(replay.map((call) => call.url.pathname + call.url.search + call.url.hash)).toEqual([
|
|
||||||
"/restore/sync/replay",
|
|
||||||
"/restore/sync/replay",
|
|
||||||
"/restore/sync/replay",
|
|
||||||
])
|
|
||||||
expect(replay.every((call) => call.headers.get("authorization") === "Bearer restore")).toBe(true)
|
|
||||||
expect(replay.every((call) => call.headers.get("content-type") === "application/json")).toBe(true)
|
|
||||||
expect(replay.map((call) => (call.json as { events: unknown[] }).events.length)).toEqual([10, 10, 5])
|
|
||||||
expect(replay.map((call) => (call.json as { directory: string }).directory)).toEqual([dir, dir, dir])
|
|
||||||
expect(
|
|
||||||
replay.flatMap((call) =>
|
|
||||||
(call.json as { events: Array<{ seq: number }> }).events.map((event) => event.seq),
|
|
||||||
),
|
|
||||||
).toEqual(Array.from({ length: 25 }, (_, i) => i))
|
|
||||||
expect(
|
|
||||||
(replay[2].json as { events: Array<{ seq: number; type: string; data: unknown }> }).events.at(-1),
|
|
||||||
).toMatchObject({
|
|
||||||
seq: 24,
|
|
||||||
type: sessionUpdatedType(),
|
|
||||||
data: { sessionID: session.id, info: { workspaceID: info.id } },
|
|
||||||
})
|
|
||||||
expect((yield* sessionSvc.get(session.id)).workspaceID).toBe(info.id)
|
|
||||||
expect(
|
|
||||||
captured.events
|
|
||||||
.filter(
|
|
||||||
(event) => event.workspace === info.id && event.payload.type === WorkspaceOld.Event.Restore.type,
|
|
||||||
)
|
|
||||||
.map((event) => event.payload.properties.step),
|
|
||||||
).toEqual([0, 1, 2, 3])
|
|
||||||
yield* workspace.remove(info.id)
|
|
||||||
} finally {
|
|
||||||
captured.dispose()
|
|
||||||
}
|
|
||||||
}),
|
|
||||||
{ git: true },
|
|
||||||
)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
it.live("remote restore sends an empty directory string when the workspace directory is null", () => {
|
|
||||||
const replay: FetchCall[] = []
|
|
||||||
return Effect.gen(function* () {
|
|
||||||
yield* HttpServer.serveEffect()(
|
|
||||||
Effect.gen(function* () {
|
|
||||||
const req = yield* HttpServerRequest.HttpServerRequest
|
|
||||||
const bodyText = yield* req.text
|
|
||||||
replay.push({
|
|
||||||
url: new URL(req.url, "http://localhost"),
|
|
||||||
method: req.method,
|
|
||||||
headers: new Headers(req.headers),
|
|
||||||
bodyText,
|
|
||||||
json: bodyText ? JSON.parse(bodyText) : undefined,
|
|
||||||
})
|
|
||||||
return HttpServerResponse.fromWeb(Response.json({ ok: true }))
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
const url = yield* serverUrl()
|
|
||||||
yield* provideTmpdirInstance(
|
|
||||||
() =>
|
|
||||||
Effect.gen(function* () {
|
|
||||||
const workspace = yield* WorkspaceOld.Service
|
|
||||||
const sessionSvc = yield* SessionNs.Service
|
|
||||||
const type = unique("restore-null-dir")
|
|
||||||
const info = workspaceInfo(Instance.project.id, type, { directory: null })
|
|
||||||
insertWorkspace(info)
|
|
||||||
registerAdapter(Instance.project.id, type, remoteAdapter(`${url}/null-dir`, { directory: null }).adapter)
|
|
||||||
const session = yield* sessionSvc.create({ title: "null dir" })
|
|
||||||
replaceSessionEvents(session.id, 0)
|
|
||||||
|
|
||||||
expect(yield* workspace.sessionRestore({ workspaceID: info.id, sessionID: session.id })).toEqual({
|
|
||||||
total: 1,
|
|
||||||
})
|
|
||||||
expect((replay[0].json as { directory: string }).directory).toBe("")
|
|
||||||
expect((replay[0].json as { events: unknown[] }).events).toHaveLength(1)
|
|
||||||
yield* workspace.remove(info.id)
|
|
||||||
}),
|
|
||||||
{ git: true },
|
|
||||||
)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
it.live("remote restore failures include status and body and do not emit completed batch progress", () => {
|
|
||||||
const replay: FetchCall[] = []
|
|
||||||
return Effect.gen(function* () {
|
|
||||||
yield* HttpServer.serveEffect()(
|
|
||||||
Effect.gen(function* () {
|
|
||||||
const req = yield* HttpServerRequest.HttpServerRequest
|
|
||||||
const bodyText = yield* req.text
|
|
||||||
replay.push({
|
|
||||||
url: new URL(req.url, "http://localhost"),
|
|
||||||
method: req.method,
|
|
||||||
headers: new Headers(req.headers),
|
|
||||||
bodyText,
|
|
||||||
json: bodyText ? JSON.parse(bodyText) : undefined,
|
|
||||||
})
|
|
||||||
return HttpServerResponse.text("replay failed", { status: 503 })
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
const url = yield* serverUrl()
|
|
||||||
yield* provideTmpdirInstance(
|
|
||||||
(dir) =>
|
|
||||||
Effect.gen(function* () {
|
|
||||||
const workspace = yield* WorkspaceOld.Service
|
|
||||||
const sessionSvc = yield* SessionNs.Service
|
|
||||||
const captured = captureGlobalEvents()
|
|
||||||
try {
|
|
||||||
const type = unique("restore-remote-fail")
|
|
||||||
const info = workspaceInfo(Instance.project.id, type, { directory: dir })
|
|
||||||
insertWorkspace(info)
|
|
||||||
registerAdapter(Instance.project.id, type, remoteAdapter(`${url}/fail`, { directory: dir }).adapter)
|
|
||||||
const session = yield* sessionSvc.create({ title: "restore fail" })
|
|
||||||
replaceSessionEvents(session.id, 11)
|
|
||||||
|
|
||||||
const error = yield* Effect.flip(
|
|
||||||
workspace.sessionRestore({ workspaceID: info.id, sessionID: session.id }),
|
|
||||||
)
|
|
||||||
expect((error as Error).message).toContain(
|
|
||||||
`Failed to replay session ${session.id} into workspace ${info.id}: HTTP 503 replay failed`,
|
|
||||||
)
|
|
||||||
|
|
||||||
expect(replay).toHaveLength(1)
|
|
||||||
expect(
|
|
||||||
captured.events
|
|
||||||
.filter(
|
|
||||||
(event) => event.workspace === info.id && event.payload.type === WorkspaceOld.Event.Restore.type,
|
|
||||||
)
|
|
||||||
.map((event) => event.payload.properties.step),
|
|
||||||
).toEqual([0])
|
|
||||||
yield* workspace.remove(info.id)
|
|
||||||
} finally {
|
|
||||||
captured.dispose()
|
|
||||||
}
|
|
||||||
}),
|
|
||||||
{ git: true },
|
|
||||||
)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
it.live("local restore replays batches and emits progress", () =>
|
|
||||||
provideTmpdirInstance(
|
|
||||||
(dir) =>
|
|
||||||
Effect.gen(function* () {
|
|
||||||
const workspace = yield* WorkspaceOld.Service
|
|
||||||
const sessionSvc = yield* SessionNs.Service
|
|
||||||
const captured = captureGlobalEvents()
|
|
||||||
try {
|
|
||||||
const type = unique("restore-local")
|
|
||||||
const info = workspaceInfo(Instance.project.id, type, { directory: dir })
|
|
||||||
insertWorkspace(info)
|
|
||||||
registerAdapter(Instance.project.id, type, localAdapter(dir).adapter)
|
|
||||||
const session = yield* sessionSvc.create({ title: "restore local" })
|
|
||||||
replaceSessionEvents(session.id, 20)
|
|
||||||
|
|
||||||
expect(yield* workspace.sessionRestore({ workspaceID: info.id, sessionID: session.id })).toEqual({
|
|
||||||
total: 3,
|
|
||||||
})
|
|
||||||
expect((yield* sessionSvc.get(session.id)).workspaceID).toBe(info.id)
|
|
||||||
expect(eventRows(session.id).map((row) => row.seq)).toEqual(Array.from({ length: 21 }, (_, i) => i))
|
|
||||||
expect(
|
|
||||||
captured.events
|
|
||||||
.filter(
|
|
||||||
(event) => event.workspace === info.id && event.payload.type === WorkspaceOld.Event.Restore.type,
|
|
||||||
)
|
|
||||||
.map((event) => event.payload.properties.step),
|
|
||||||
).toEqual([0, 1, 2, 3])
|
|
||||||
yield* workspace.remove(info.id)
|
|
||||||
} finally {
|
|
||||||
captured.dispose()
|
|
||||||
}
|
|
||||||
}),
|
|
||||||
{ git: true },
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
it.live("session restore includes real message and part events in sequence order", () => {
|
|
||||||
const replay: FetchCall[] = []
|
|
||||||
return Effect.gen(function* () {
|
|
||||||
yield* HttpServer.serveEffect()(
|
|
||||||
Effect.gen(function* () {
|
|
||||||
const req = yield* HttpServerRequest.HttpServerRequest
|
|
||||||
const bodyText = yield* req.text
|
|
||||||
replay.push({
|
|
||||||
url: new URL(req.url, "http://localhost"),
|
|
||||||
method: req.method,
|
|
||||||
headers: new Headers(req.headers),
|
|
||||||
bodyText,
|
|
||||||
json: bodyText ? JSON.parse(bodyText) : undefined,
|
|
||||||
})
|
|
||||||
return HttpServerResponse.fromWeb(Response.json({ ok: true }))
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
const url = yield* serverUrl()
|
|
||||||
yield* provideTmpdirInstance(
|
|
||||||
(dir) =>
|
|
||||||
Effect.gen(function* () {
|
|
||||||
const workspace = yield* WorkspaceOld.Service
|
|
||||||
const sessionSvc = yield* SessionNs.Service
|
|
||||||
const type = unique("restore-real-events")
|
|
||||||
const info = workspaceInfo(Instance.project.id, type, { directory: dir })
|
|
||||||
insertWorkspace(info)
|
|
||||||
registerAdapter(Instance.project.id, type, remoteAdapter(`${url}/real`, { directory: dir }).adapter)
|
|
||||||
const session = yield* sessionSvc.create({ title: "real events" })
|
|
||||||
for (let i = 0; i < 3; i++) {
|
|
||||||
const msg = yield* sessionSvc.updateMessage({
|
|
||||||
id: MessageID.ascending(),
|
|
||||||
role: "user",
|
|
||||||
sessionID: session.id,
|
|
||||||
agent: "build",
|
|
||||||
model: { providerID: ProviderID.make("test"), modelID: ModelID.make("test") },
|
|
||||||
time: { created: Date.now() },
|
|
||||||
})
|
|
||||||
yield* sessionSvc.updatePart({
|
|
||||||
id: PartID.ascending(),
|
|
||||||
sessionID: session.id,
|
|
||||||
messageID: msg.id,
|
|
||||||
type: "text",
|
|
||||||
text: `message ${i}`,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
const before = eventRows(session.id)
|
|
||||||
|
|
||||||
expect(yield* workspace.sessionRestore({ workspaceID: info.id, sessionID: session.id })).toEqual({
|
|
||||||
total: 1,
|
|
||||||
})
|
|
||||||
|
|
||||||
const posted = (replay[0].json as { events: Array<{ seq: number; type: string }> }).events
|
|
||||||
expect(posted.map((event) => event.seq)).toEqual([...before.map((row) => row.seq), before.at(-1)!.seq + 1])
|
|
||||||
expect(posted.map((event) => event.type).slice(0, -1)).toEqual(before.map((row) => row.type))
|
|
||||||
expect(posted.at(-1)?.type).toBe(sessionUpdatedType())
|
|
||||||
yield* workspace.remove(info.id)
|
|
||||||
}),
|
|
||||||
{ git: true },
|
|
||||||
)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
|
||||||
|
|
@ -168,22 +168,19 @@ describe("workspace HttpApi", () => {
|
||||||
const created = yield* request(WorkspacePaths.list, dir, {
|
const created = yield* request(WorkspacePaths.list, dir, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "content-type": "application/json" },
|
headers: { "content-type": "application/json" },
|
||||||
body: JSON.stringify({ type: "local-test", branch: null, extra: null }),
|
body: JSON.stringify({ type: "local-test", branch: null }),
|
||||||
})
|
})
|
||||||
expect(created.status).toBe(200)
|
expect(created.status).toBe(200)
|
||||||
const workspace = (yield* Effect.promise(() => created.json())) as Workspace.Info
|
const workspace = (yield* Effect.promise(() => created.json())) as Workspace.Info
|
||||||
expect(workspace).toMatchObject({ type: "local-test", name: "local-test" })
|
expect(workspace).toMatchObject({ type: "local-test", name: "local-test" })
|
||||||
|
|
||||||
const session = yield* Session.Service.use((svc) => svc.create({})).pipe(provideInstance(dir))
|
const session = yield* Session.Service.use((svc) => svc.create({})).pipe(provideInstance(dir))
|
||||||
const restored = yield* request(WorkspacePaths.sessionRestore.replace(":id", workspace.id), dir, {
|
const warped = yield* request(WorkspacePaths.warp, dir, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "content-type": "application/json" },
|
headers: { "content-type": "application/json" },
|
||||||
body: JSON.stringify({ sessionID: session.id }),
|
body: JSON.stringify({ id: workspace.id, sessionID: session.id }),
|
||||||
})
|
|
||||||
expect(restored.status).toBe(200)
|
|
||||||
expect((yield* Effect.promise(() => restored.json())) as { total: number }).toMatchObject({
|
|
||||||
total: expect.any(Number),
|
|
||||||
})
|
})
|
||||||
|
expect(warped.status).toBe(204)
|
||||||
|
|
||||||
const removed = yield* request(WorkspacePaths.remove.replace(":id", workspace.id), dir, { method: "DELETE" })
|
const removed = yield* request(WorkspacePaths.remove.replace(":id", workspace.id), dir, { method: "DELETE" })
|
||||||
expect(removed.status).toBe(200)
|
expect(removed.status).toBe(200)
|
||||||
|
|
@ -212,7 +209,6 @@ describe("workspace HttpApi", () => {
|
||||||
expect((yield* Effect.promise(() => created.json())) as Workspace.Info).toMatchObject({
|
expect((yield* Effect.promise(() => created.json())) as Workspace.Info).toMatchObject({
|
||||||
type: "local-test",
|
type: "local-test",
|
||||||
name: "local-test",
|
name: "local-test",
|
||||||
extra: null,
|
|
||||||
})
|
})
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
@ -257,7 +253,6 @@ describe("workspace HttpApi", () => {
|
||||||
expect((yield* Effect.promise(() => created.json())) as Workspace.Info).toMatchObject({
|
expect((yield* Effect.promise(() => created.json())) as Workspace.Info).toMatchObject({
|
||||||
type: "local-test",
|
type: "local-test",
|
||||||
name: "local-test",
|
name: "local-test",
|
||||||
extra: null,
|
|
||||||
})
|
})
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
@ -272,7 +267,7 @@ describe("workspace HttpApi", () => {
|
||||||
const created = yield* request(WorkspacePaths.list, dir, {
|
const created = yield* request(WorkspacePaths.list, dir, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "content-type": "application/json" },
|
headers: { "content-type": "application/json" },
|
||||||
body: JSON.stringify({ type: "local-target", branch: null, extra: null }),
|
body: JSON.stringify({ type: "local-target", branch: null }),
|
||||||
})
|
})
|
||||||
const workspace = (yield* Effect.promise(() => created.json())) as Workspace.Info
|
const workspace = (yield* Effect.promise(() => created.json())) as Workspace.Info
|
||||||
|
|
||||||
|
|
@ -327,7 +322,7 @@ describe("workspace HttpApi", () => {
|
||||||
const created = yield* request(WorkspacePaths.list, dir, {
|
const created = yield* request(WorkspacePaths.list, dir, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "content-type": "application/json" },
|
headers: { "content-type": "application/json" },
|
||||||
body: JSON.stringify({ type: "remote-target", branch: null, extra: null }),
|
body: JSON.stringify({ type: "remote-target", branch: null }),
|
||||||
})
|
})
|
||||||
const workspace = (yield* Effect.promise(() => created.json())) as Workspace.Info
|
const workspace = (yield* Effect.promise(() => created.json())) as Workspace.Info
|
||||||
|
|
||||||
|
|
@ -394,7 +389,7 @@ describe("workspace HttpApi", () => {
|
||||||
const created = yield* request(WorkspacePaths.list, dir, {
|
const created = yield* request(WorkspacePaths.list, dir, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "content-type": "application/json" },
|
headers: { "content-type": "application/json" },
|
||||||
body: JSON.stringify({ type: "remote-session-target", branch: null, extra: null }),
|
body: JSON.stringify({ type: "remote-session-target", branch: null }),
|
||||||
})
|
})
|
||||||
const workspace = (yield* Effect.promise(() => created.json())) as Workspace.Info
|
const workspace = (yield* Effect.promise(() => created.json())) as Workspace.Info
|
||||||
const session = yield* Session.Service.use((svc) => svc.create()).pipe(
|
const session = yield* Session.Service.use((svc) => svc.create()).pipe(
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,7 @@ import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
|
||||||
import { Bus } from "../../src/bus"
|
import { Bus } from "../../src/bus"
|
||||||
import { SyncEvent } from "../../src/sync"
|
import { SyncEvent } from "../../src/sync"
|
||||||
import { Database } from "@/storage/db"
|
import { Database } from "@/storage/db"
|
||||||
import { EventTable } from "../../src/sync/event.sql"
|
import { EventSequenceTable, EventTable } from "../../src/sync/event.sql"
|
||||||
import { MessageID } from "../../src/session/schema"
|
import { MessageID } from "../../src/session/schema"
|
||||||
import { Flag } from "@opencode-ai/core/flag/flag"
|
import { Flag } from "@opencode-ai/core/flag/flag"
|
||||||
import { initProjectors } from "../../src/server/projectors"
|
import { initProjectors } from "../../src/server/projectors"
|
||||||
|
|
@ -252,5 +252,76 @@ describe("SyncEvent", () => {
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
it.live(
|
||||||
|
"claims unowned event sequence on replay with ownerID",
|
||||||
|
provideTmpdirInstance(() =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const { Created } = setup()
|
||||||
|
const id = MessageID.ascending()
|
||||||
|
|
||||||
|
yield* SyncEvent.use.replay(
|
||||||
|
{
|
||||||
|
id: "evt_1",
|
||||||
|
type: SyncEvent.versionedType(Created.type, Created.version),
|
||||||
|
seq: 0,
|
||||||
|
aggregateID: id,
|
||||||
|
data: { id, name: "owned" },
|
||||||
|
},
|
||||||
|
{ publish: false, ownerID: "owner-1" },
|
||||||
|
)
|
||||||
|
|
||||||
|
const row = Database.use((db) =>
|
||||||
|
db
|
||||||
|
.select({ seq: EventSequenceTable.seq, ownerID: EventSequenceTable.owner_id })
|
||||||
|
.from(EventSequenceTable)
|
||||||
|
.get(),
|
||||||
|
)
|
||||||
|
expect(row).toEqual({ seq: 0, ownerID: "owner-1" })
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
it.live(
|
||||||
|
"ignores replay from a different owner after sequence is claimed",
|
||||||
|
provideTmpdirInstance(() =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const { Created } = setup()
|
||||||
|
const id = MessageID.ascending()
|
||||||
|
|
||||||
|
yield* SyncEvent.use.replay(
|
||||||
|
{
|
||||||
|
id: "evt_1",
|
||||||
|
type: SyncEvent.versionedType(Created.type, Created.version),
|
||||||
|
seq: 0,
|
||||||
|
aggregateID: id,
|
||||||
|
data: { id, name: "first" },
|
||||||
|
},
|
||||||
|
{ publish: false, ownerID: "owner-1" },
|
||||||
|
)
|
||||||
|
yield* SyncEvent.use.replay(
|
||||||
|
{
|
||||||
|
id: "evt_2",
|
||||||
|
type: SyncEvent.versionedType(Created.type, Created.version),
|
||||||
|
seq: 1,
|
||||||
|
aggregateID: id,
|
||||||
|
data: { id, name: "ignored" },
|
||||||
|
},
|
||||||
|
{ publish: false, ownerID: "owner-2" },
|
||||||
|
)
|
||||||
|
|
||||||
|
const events = Database.use((db) => db.select().from(EventTable).all())
|
||||||
|
const sequence = Database.use((db) =>
|
||||||
|
db
|
||||||
|
.select({ seq: EventSequenceTable.seq, ownerID: EventSequenceTable.owner_id })
|
||||||
|
.from(EventSequenceTable)
|
||||||
|
.get(),
|
||||||
|
)
|
||||||
|
expect(events).toHaveLength(1)
|
||||||
|
expect(events[0].id).toBe("evt_1")
|
||||||
|
expect(sequence).toEqual({ seq: 0, ownerID: "owner-1" })
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -4,58 +4,84 @@ import { client } from "./client.gen.js"
|
||||||
import { buildClientParams, type Client, type Options as Options2, type TDataShape } from "./client/index.js"
|
import { buildClientParams, type Client, type Options as Options2, type TDataShape } from "./client/index.js"
|
||||||
import type {
|
import type {
|
||||||
AgentPartInput,
|
AgentPartInput,
|
||||||
|
AppAgentsErrors,
|
||||||
AppAgentsResponses,
|
AppAgentsResponses,
|
||||||
AppLogErrors,
|
AppLogErrors,
|
||||||
AppLogResponses,
|
AppLogResponses,
|
||||||
|
AppSkillsErrors,
|
||||||
AppSkillsResponses,
|
AppSkillsResponses,
|
||||||
Auth as Auth3,
|
Auth as Auth3,
|
||||||
AuthRemoveErrors,
|
AuthRemoveErrors,
|
||||||
AuthRemoveResponses,
|
AuthRemoveResponses,
|
||||||
AuthSetErrors,
|
AuthSetErrors,
|
||||||
AuthSetResponses,
|
AuthSetResponses,
|
||||||
|
CommandListErrors,
|
||||||
CommandListResponses,
|
CommandListResponses,
|
||||||
Config as Config3,
|
Config as Config3,
|
||||||
|
ConfigGetErrors,
|
||||||
ConfigGetResponses,
|
ConfigGetResponses,
|
||||||
|
ConfigProvidersErrors,
|
||||||
ConfigProvidersResponses,
|
ConfigProvidersResponses,
|
||||||
ConfigUpdateErrors,
|
ConfigUpdateErrors,
|
||||||
ConfigUpdateResponses,
|
ConfigUpdateResponses,
|
||||||
|
EventSubscribeErrors,
|
||||||
EventSubscribeResponses,
|
EventSubscribeResponses,
|
||||||
EventTuiCommandExecute2,
|
EventTuiCommandExecute2,
|
||||||
EventTuiPromptAppend2,
|
EventTuiPromptAppend2,
|
||||||
EventTuiSessionSelect2,
|
EventTuiSessionSelect2,
|
||||||
EventTuiToastShow2,
|
EventTuiToastShow2,
|
||||||
|
ExperimentalConsoleGetErrors,
|
||||||
ExperimentalConsoleGetResponses,
|
ExperimentalConsoleGetResponses,
|
||||||
|
ExperimentalConsoleListOrgsErrors,
|
||||||
ExperimentalConsoleListOrgsResponses,
|
ExperimentalConsoleListOrgsResponses,
|
||||||
ExperimentalConsoleSwitchOrgResponses,
|
ExperimentalConsoleSwitchOrgResponses,
|
||||||
|
ExperimentalResourceListErrors,
|
||||||
ExperimentalResourceListResponses,
|
ExperimentalResourceListResponses,
|
||||||
|
ExperimentalSessionListErrors,
|
||||||
ExperimentalSessionListResponses,
|
ExperimentalSessionListResponses,
|
||||||
|
ExperimentalWorkspaceAdapterListErrors,
|
||||||
ExperimentalWorkspaceAdapterListResponses,
|
ExperimentalWorkspaceAdapterListResponses,
|
||||||
ExperimentalWorkspaceCreateErrors,
|
ExperimentalWorkspaceCreateErrors,
|
||||||
ExperimentalWorkspaceCreateResponses,
|
ExperimentalWorkspaceCreateResponses,
|
||||||
|
ExperimentalWorkspaceListErrors,
|
||||||
ExperimentalWorkspaceListResponses,
|
ExperimentalWorkspaceListResponses,
|
||||||
ExperimentalWorkspaceRemoveErrors,
|
ExperimentalWorkspaceRemoveErrors,
|
||||||
ExperimentalWorkspaceRemoveResponses,
|
ExperimentalWorkspaceRemoveResponses,
|
||||||
ExperimentalWorkspaceSessionRestoreErrors,
|
ExperimentalWorkspaceStatusErrors,
|
||||||
ExperimentalWorkspaceSessionRestoreResponses,
|
|
||||||
ExperimentalWorkspaceStatusResponses,
|
ExperimentalWorkspaceStatusResponses,
|
||||||
|
ExperimentalWorkspaceWarpErrors,
|
||||||
|
ExperimentalWorkspaceWarpResponses,
|
||||||
|
FileListErrors,
|
||||||
FileListResponses,
|
FileListResponses,
|
||||||
FilePartInput,
|
FilePartInput,
|
||||||
FilePartSource,
|
FilePartSource,
|
||||||
|
FileReadErrors,
|
||||||
FileReadResponses,
|
FileReadResponses,
|
||||||
|
FileStatusErrors,
|
||||||
FileStatusResponses,
|
FileStatusResponses,
|
||||||
|
FindFilesErrors,
|
||||||
FindFilesResponses,
|
FindFilesResponses,
|
||||||
|
FindSymbolsErrors,
|
||||||
FindSymbolsResponses,
|
FindSymbolsResponses,
|
||||||
|
FindTextErrors,
|
||||||
FindTextResponses,
|
FindTextResponses,
|
||||||
|
FormatterStatusErrors,
|
||||||
FormatterStatusResponses,
|
FormatterStatusResponses,
|
||||||
|
GlobalConfigGetErrors,
|
||||||
GlobalConfigGetResponses,
|
GlobalConfigGetResponses,
|
||||||
GlobalConfigUpdateErrors,
|
GlobalConfigUpdateErrors,
|
||||||
GlobalConfigUpdateResponses,
|
GlobalConfigUpdateResponses,
|
||||||
|
GlobalDisposeErrors,
|
||||||
GlobalDisposeResponses,
|
GlobalDisposeResponses,
|
||||||
|
GlobalEventErrors,
|
||||||
GlobalEventResponses,
|
GlobalEventResponses,
|
||||||
|
GlobalHealthErrors,
|
||||||
GlobalHealthResponses,
|
GlobalHealthResponses,
|
||||||
GlobalUpgradeErrors,
|
GlobalUpgradeErrors,
|
||||||
GlobalUpgradeResponses,
|
GlobalUpgradeResponses,
|
||||||
|
InstanceDisposeErrors,
|
||||||
InstanceDisposeResponses,
|
InstanceDisposeResponses,
|
||||||
|
LspStatusErrors,
|
||||||
LspStatusResponses,
|
LspStatusResponses,
|
||||||
McpAddErrors,
|
McpAddErrors,
|
||||||
McpAddResponses,
|
McpAddResponses,
|
||||||
|
|
@ -67,10 +93,13 @@ import type {
|
||||||
McpAuthRemoveResponses,
|
McpAuthRemoveResponses,
|
||||||
McpAuthStartErrors,
|
McpAuthStartErrors,
|
||||||
McpAuthStartResponses,
|
McpAuthStartResponses,
|
||||||
|
McpConnectErrors,
|
||||||
McpConnectResponses,
|
McpConnectResponses,
|
||||||
|
McpDisconnectErrors,
|
||||||
McpDisconnectResponses,
|
McpDisconnectResponses,
|
||||||
McpLocalConfig,
|
McpLocalConfig,
|
||||||
McpRemoteConfig,
|
McpRemoteConfig,
|
||||||
|
McpStatusErrors,
|
||||||
McpStatusResponses,
|
McpStatusResponses,
|
||||||
OutputFormat,
|
OutputFormat,
|
||||||
Part as Part2,
|
Part as Part2,
|
||||||
|
|
@ -78,20 +107,27 @@ import type {
|
||||||
PartDeleteResponses,
|
PartDeleteResponses,
|
||||||
PartUpdateErrors,
|
PartUpdateErrors,
|
||||||
PartUpdateResponses,
|
PartUpdateResponses,
|
||||||
|
PathGetErrors,
|
||||||
PathGetResponses,
|
PathGetResponses,
|
||||||
|
PermissionListErrors,
|
||||||
PermissionListResponses,
|
PermissionListResponses,
|
||||||
PermissionReplyErrors,
|
PermissionReplyErrors,
|
||||||
PermissionReplyResponses,
|
PermissionReplyResponses,
|
||||||
PermissionRespondErrors,
|
PermissionRespondErrors,
|
||||||
PermissionRespondResponses,
|
PermissionRespondResponses,
|
||||||
PermissionRuleset,
|
PermissionRuleset,
|
||||||
|
ProjectCurrentErrors,
|
||||||
ProjectCurrentResponses,
|
ProjectCurrentResponses,
|
||||||
|
ProjectInitGitErrors,
|
||||||
ProjectInitGitResponses,
|
ProjectInitGitResponses,
|
||||||
|
ProjectListErrors,
|
||||||
ProjectListResponses,
|
ProjectListResponses,
|
||||||
ProjectUpdateErrors,
|
ProjectUpdateErrors,
|
||||||
ProjectUpdateResponses,
|
ProjectUpdateResponses,
|
||||||
Prompt,
|
Prompt,
|
||||||
|
ProviderAuthErrors,
|
||||||
ProviderAuthResponses,
|
ProviderAuthResponses,
|
||||||
|
ProviderListErrors,
|
||||||
ProviderListResponses,
|
ProviderListResponses,
|
||||||
ProviderOauthAuthorizeErrors,
|
ProviderOauthAuthorizeErrors,
|
||||||
ProviderOauthAuthorizeResponses,
|
ProviderOauthAuthorizeResponses,
|
||||||
|
|
@ -105,13 +141,16 @@ import type {
|
||||||
PtyCreateResponses,
|
PtyCreateResponses,
|
||||||
PtyGetErrors,
|
PtyGetErrors,
|
||||||
PtyGetResponses,
|
PtyGetResponses,
|
||||||
|
PtyListErrors,
|
||||||
PtyListResponses,
|
PtyListResponses,
|
||||||
PtyRemoveErrors,
|
PtyRemoveErrors,
|
||||||
PtyRemoveResponses,
|
PtyRemoveResponses,
|
||||||
|
PtyShellsErrors,
|
||||||
PtyShellsResponses,
|
PtyShellsResponses,
|
||||||
PtyUpdateErrors,
|
PtyUpdateErrors,
|
||||||
PtyUpdateResponses,
|
PtyUpdateResponses,
|
||||||
QuestionAnswer,
|
QuestionAnswer,
|
||||||
|
QuestionListErrors,
|
||||||
QuestionListResponses,
|
QuestionListResponses,
|
||||||
QuestionRejectErrors,
|
QuestionRejectErrors,
|
||||||
QuestionRejectResponses,
|
QuestionRejectResponses,
|
||||||
|
|
@ -130,12 +169,15 @@ import type {
|
||||||
SessionDeleteMessageResponses,
|
SessionDeleteMessageResponses,
|
||||||
SessionDeleteResponses,
|
SessionDeleteResponses,
|
||||||
SessionDelivery,
|
SessionDelivery,
|
||||||
|
SessionDiffErrors,
|
||||||
SessionDiffResponses,
|
SessionDiffResponses,
|
||||||
|
SessionForkErrors,
|
||||||
SessionForkResponses,
|
SessionForkResponses,
|
||||||
SessionGetErrors,
|
SessionGetErrors,
|
||||||
SessionGetResponses,
|
SessionGetResponses,
|
||||||
SessionInitErrors,
|
SessionInitErrors,
|
||||||
SessionInitResponses,
|
SessionInitResponses,
|
||||||
|
SessionListErrors,
|
||||||
SessionListResponses,
|
SessionListResponses,
|
||||||
SessionMessageErrors,
|
SessionMessageErrors,
|
||||||
SessionMessageResponses,
|
SessionMessageResponses,
|
||||||
|
|
@ -168,7 +210,10 @@ import type {
|
||||||
SyncHistoryListResponses,
|
SyncHistoryListResponses,
|
||||||
SyncReplayErrors,
|
SyncReplayErrors,
|
||||||
SyncReplayResponses,
|
SyncReplayResponses,
|
||||||
|
SyncStartErrors,
|
||||||
SyncStartResponses,
|
SyncStartResponses,
|
||||||
|
SyncStealErrors,
|
||||||
|
SyncStealResponses,
|
||||||
TextPartInput,
|
TextPartInput,
|
||||||
ToolIdsErrors,
|
ToolIdsErrors,
|
||||||
ToolIdsResponses,
|
ToolIdsResponses,
|
||||||
|
|
@ -176,34 +221,50 @@ import type {
|
||||||
ToolListResponses,
|
ToolListResponses,
|
||||||
TuiAppendPromptErrors,
|
TuiAppendPromptErrors,
|
||||||
TuiAppendPromptResponses,
|
TuiAppendPromptResponses,
|
||||||
|
TuiClearPromptErrors,
|
||||||
TuiClearPromptResponses,
|
TuiClearPromptResponses,
|
||||||
|
TuiControlNextErrors,
|
||||||
TuiControlNextResponses,
|
TuiControlNextResponses,
|
||||||
|
TuiControlResponseErrors,
|
||||||
TuiControlResponseResponses,
|
TuiControlResponseResponses,
|
||||||
TuiExecuteCommandErrors,
|
TuiExecuteCommandErrors,
|
||||||
TuiExecuteCommandResponses,
|
TuiExecuteCommandResponses,
|
||||||
|
TuiOpenHelpErrors,
|
||||||
TuiOpenHelpResponses,
|
TuiOpenHelpResponses,
|
||||||
|
TuiOpenModelsErrors,
|
||||||
TuiOpenModelsResponses,
|
TuiOpenModelsResponses,
|
||||||
|
TuiOpenSessionsErrors,
|
||||||
TuiOpenSessionsResponses,
|
TuiOpenSessionsResponses,
|
||||||
|
TuiOpenThemesErrors,
|
||||||
TuiOpenThemesResponses,
|
TuiOpenThemesResponses,
|
||||||
TuiPublishErrors,
|
TuiPublishErrors,
|
||||||
TuiPublishResponses,
|
TuiPublishResponses,
|
||||||
TuiSelectSessionErrors,
|
TuiSelectSessionErrors,
|
||||||
TuiSelectSessionResponses,
|
TuiSelectSessionResponses,
|
||||||
|
TuiShowToastErrors,
|
||||||
TuiShowToastResponses,
|
TuiShowToastResponses,
|
||||||
|
TuiSubmitPromptErrors,
|
||||||
TuiSubmitPromptResponses,
|
TuiSubmitPromptResponses,
|
||||||
|
V2SessionCompactErrors,
|
||||||
V2SessionCompactResponses,
|
V2SessionCompactResponses,
|
||||||
|
V2SessionContextErrors,
|
||||||
V2SessionContextResponses,
|
V2SessionContextResponses,
|
||||||
V2SessionListErrors,
|
V2SessionListErrors,
|
||||||
V2SessionListResponses,
|
V2SessionListResponses,
|
||||||
V2SessionMessagesErrors,
|
V2SessionMessagesErrors,
|
||||||
V2SessionMessagesResponses,
|
V2SessionMessagesResponses,
|
||||||
|
V2SessionPromptErrors,
|
||||||
V2SessionPromptResponses,
|
V2SessionPromptResponses,
|
||||||
|
V2SessionWaitErrors,
|
||||||
V2SessionWaitResponses,
|
V2SessionWaitResponses,
|
||||||
|
VcsDiffErrors,
|
||||||
VcsDiffResponses,
|
VcsDiffResponses,
|
||||||
|
VcsGetErrors,
|
||||||
VcsGetResponses,
|
VcsGetResponses,
|
||||||
WorktreeCreateErrors,
|
WorktreeCreateErrors,
|
||||||
WorktreeCreateInput,
|
WorktreeCreateInput,
|
||||||
WorktreeCreateResponses,
|
WorktreeCreateResponses,
|
||||||
|
WorktreeListErrors,
|
||||||
WorktreeListResponses,
|
WorktreeListResponses,
|
||||||
WorktreeRemoveErrors,
|
WorktreeRemoveErrors,
|
||||||
WorktreeRemoveInput,
|
WorktreeRemoveInput,
|
||||||
|
|
@ -381,7 +442,7 @@ export class App extends HeyApiClient {
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
return (options?.client ?? this.client).get<AppAgentsResponses, unknown, ThrowOnError>({
|
return (options?.client ?? this.client).get<AppAgentsResponses, AppAgentsErrors, ThrowOnError>({
|
||||||
url: "/agent",
|
url: "/agent",
|
||||||
...options,
|
...options,
|
||||||
...params,
|
...params,
|
||||||
|
|
@ -411,7 +472,7 @@ export class App extends HeyApiClient {
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
return (options?.client ?? this.client).get<AppSkillsResponses, unknown, ThrowOnError>({
|
return (options?.client ?? this.client).get<AppSkillsResponses, AppSkillsErrors, ThrowOnError>({
|
||||||
url: "/skill",
|
url: "/skill",
|
||||||
...options,
|
...options,
|
||||||
...params,
|
...params,
|
||||||
|
|
@ -426,7 +487,7 @@ export class Config extends HeyApiClient {
|
||||||
* Retrieve the current global OpenCode configuration settings and preferences.
|
* Retrieve the current global OpenCode configuration settings and preferences.
|
||||||
*/
|
*/
|
||||||
public get<ThrowOnError extends boolean = false>(options?: Options<never, ThrowOnError>) {
|
public get<ThrowOnError extends boolean = false>(options?: Options<never, ThrowOnError>) {
|
||||||
return (options?.client ?? this.client).get<GlobalConfigGetResponses, unknown, ThrowOnError>({
|
return (options?.client ?? this.client).get<GlobalConfigGetResponses, GlobalConfigGetErrors, ThrowOnError>({
|
||||||
url: "/global/config",
|
url: "/global/config",
|
||||||
...options,
|
...options,
|
||||||
})
|
})
|
||||||
|
|
@ -464,7 +525,7 @@ export class Global extends HeyApiClient {
|
||||||
* Get health information about the OpenCode server.
|
* Get health information about the OpenCode server.
|
||||||
*/
|
*/
|
||||||
public health<ThrowOnError extends boolean = false>(options?: Options<never, ThrowOnError>) {
|
public health<ThrowOnError extends boolean = false>(options?: Options<never, ThrowOnError>) {
|
||||||
return (options?.client ?? this.client).get<GlobalHealthResponses, unknown, ThrowOnError>({
|
return (options?.client ?? this.client).get<GlobalHealthResponses, GlobalHealthErrors, ThrowOnError>({
|
||||||
url: "/global/health",
|
url: "/global/health",
|
||||||
...options,
|
...options,
|
||||||
})
|
})
|
||||||
|
|
@ -476,7 +537,7 @@ export class Global extends HeyApiClient {
|
||||||
* Subscribe to global events from the OpenCode system using server-sent events.
|
* Subscribe to global events from the OpenCode system using server-sent events.
|
||||||
*/
|
*/
|
||||||
public event<ThrowOnError extends boolean = false>(options?: Options<never, ThrowOnError>) {
|
public event<ThrowOnError extends boolean = false>(options?: Options<never, ThrowOnError>) {
|
||||||
return (options?.client ?? this.client).sse.get<GlobalEventResponses, unknown, ThrowOnError>({
|
return (options?.client ?? this.client).sse.get<GlobalEventResponses, GlobalEventErrors, ThrowOnError>({
|
||||||
url: "/global/event",
|
url: "/global/event",
|
||||||
...options,
|
...options,
|
||||||
})
|
})
|
||||||
|
|
@ -488,7 +549,7 @@ export class Global extends HeyApiClient {
|
||||||
* Clean up and dispose all OpenCode instances, releasing all resources.
|
* Clean up and dispose all OpenCode instances, releasing all resources.
|
||||||
*/
|
*/
|
||||||
public dispose<ThrowOnError extends boolean = false>(options?: Options<never, ThrowOnError>) {
|
public dispose<ThrowOnError extends boolean = false>(options?: Options<never, ThrowOnError>) {
|
||||||
return (options?.client ?? this.client).post<GlobalDisposeResponses, unknown, ThrowOnError>({
|
return (options?.client ?? this.client).post<GlobalDisposeResponses, GlobalDisposeErrors, ThrowOnError>({
|
||||||
url: "/global/dispose",
|
url: "/global/dispose",
|
||||||
...options,
|
...options,
|
||||||
})
|
})
|
||||||
|
|
@ -548,7 +609,7 @@ export class Event extends HeyApiClient {
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
return (options?.client ?? this.client).sse.get<EventSubscribeResponses, unknown, ThrowOnError>({
|
return (options?.client ?? this.client).sse.get<EventSubscribeResponses, EventSubscribeErrors, ThrowOnError>({
|
||||||
url: "/event",
|
url: "/event",
|
||||||
...options,
|
...options,
|
||||||
...params,
|
...params,
|
||||||
|
|
@ -580,7 +641,7 @@ export class Config2 extends HeyApiClient {
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
return (options?.client ?? this.client).get<ConfigGetResponses, unknown, ThrowOnError>({
|
return (options?.client ?? this.client).get<ConfigGetResponses, ConfigGetErrors, ThrowOnError>({
|
||||||
url: "/config",
|
url: "/config",
|
||||||
...options,
|
...options,
|
||||||
...params,
|
...params,
|
||||||
|
|
@ -647,7 +708,7 @@ export class Config2 extends HeyApiClient {
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
return (options?.client ?? this.client).get<ConfigProvidersResponses, unknown, ThrowOnError>({
|
return (options?.client ?? this.client).get<ConfigProvidersResponses, ConfigProvidersErrors, ThrowOnError>({
|
||||||
url: "/config/providers",
|
url: "/config/providers",
|
||||||
...options,
|
...options,
|
||||||
...params,
|
...params,
|
||||||
|
|
@ -679,7 +740,11 @@ export class Console extends HeyApiClient {
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
return (options?.client ?? this.client).get<ExperimentalConsoleGetResponses, unknown, ThrowOnError>({
|
return (options?.client ?? this.client).get<
|
||||||
|
ExperimentalConsoleGetResponses,
|
||||||
|
ExperimentalConsoleGetErrors,
|
||||||
|
ThrowOnError
|
||||||
|
>({
|
||||||
url: "/experimental/console",
|
url: "/experimental/console",
|
||||||
...options,
|
...options,
|
||||||
...params,
|
...params,
|
||||||
|
|
@ -709,7 +774,11 @@ export class Console extends HeyApiClient {
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
return (options?.client ?? this.client).get<ExperimentalConsoleListOrgsResponses, unknown, ThrowOnError>({
|
return (options?.client ?? this.client).get<
|
||||||
|
ExperimentalConsoleListOrgsResponses,
|
||||||
|
ExperimentalConsoleListOrgsErrors,
|
||||||
|
ThrowOnError
|
||||||
|
>({
|
||||||
url: "/experimental/console/orgs",
|
url: "/experimental/console/orgs",
|
||||||
...options,
|
...options,
|
||||||
...params,
|
...params,
|
||||||
|
|
@ -792,7 +861,11 @@ export class Session extends HeyApiClient {
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
return (options?.client ?? this.client).get<ExperimentalSessionListResponses, unknown, ThrowOnError>({
|
return (options?.client ?? this.client).get<
|
||||||
|
ExperimentalSessionListResponses,
|
||||||
|
ExperimentalSessionListErrors,
|
||||||
|
ThrowOnError
|
||||||
|
>({
|
||||||
url: "/experimental/session",
|
url: "/experimental/session",
|
||||||
...options,
|
...options,
|
||||||
...params,
|
...params,
|
||||||
|
|
@ -824,7 +897,11 @@ export class Resource extends HeyApiClient {
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
return (options?.client ?? this.client).get<ExperimentalResourceListResponses, unknown, ThrowOnError>({
|
return (options?.client ?? this.client).get<
|
||||||
|
ExperimentalResourceListResponses,
|
||||||
|
ExperimentalResourceListErrors,
|
||||||
|
ThrowOnError
|
||||||
|
>({
|
||||||
url: "/experimental/resource",
|
url: "/experimental/resource",
|
||||||
...options,
|
...options,
|
||||||
...params,
|
...params,
|
||||||
|
|
@ -856,7 +933,11 @@ export class Adapter extends HeyApiClient {
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
return (options?.client ?? this.client).get<ExperimentalWorkspaceAdapterListResponses, unknown, ThrowOnError>({
|
return (options?.client ?? this.client).get<
|
||||||
|
ExperimentalWorkspaceAdapterListResponses,
|
||||||
|
ExperimentalWorkspaceAdapterListErrors,
|
||||||
|
ThrowOnError
|
||||||
|
>({
|
||||||
url: "/experimental/workspace/adapter",
|
url: "/experimental/workspace/adapter",
|
||||||
...options,
|
...options,
|
||||||
...params,
|
...params,
|
||||||
|
|
@ -888,7 +969,11 @@ export class Workspace extends HeyApiClient {
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
return (options?.client ?? this.client).get<ExperimentalWorkspaceListResponses, unknown, ThrowOnError>({
|
return (options?.client ?? this.client).get<
|
||||||
|
ExperimentalWorkspaceListResponses,
|
||||||
|
ExperimentalWorkspaceListErrors,
|
||||||
|
ThrowOnError
|
||||||
|
>({
|
||||||
url: "/experimental/workspace",
|
url: "/experimental/workspace",
|
||||||
...options,
|
...options,
|
||||||
...params,
|
...params,
|
||||||
|
|
@ -965,7 +1050,11 @@ export class Workspace extends HeyApiClient {
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
return (options?.client ?? this.client).get<ExperimentalWorkspaceStatusResponses, unknown, ThrowOnError>({
|
return (options?.client ?? this.client).get<
|
||||||
|
ExperimentalWorkspaceStatusResponses,
|
||||||
|
ExperimentalWorkspaceStatusErrors,
|
||||||
|
ThrowOnError
|
||||||
|
>({
|
||||||
url: "/experimental/workspace/status",
|
url: "/experimental/workspace/status",
|
||||||
...options,
|
...options,
|
||||||
...params,
|
...params,
|
||||||
|
|
@ -1009,15 +1098,15 @@ export class Workspace extends HeyApiClient {
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Restore session into workspace
|
* Warp session into workspace
|
||||||
*
|
*
|
||||||
* Replay a session's sync events into the target workspace in batches.
|
* Move a session's sync history into the target workspace, or detach it to the local project.
|
||||||
*/
|
*/
|
||||||
public sessionRestore<ThrowOnError extends boolean = false>(
|
public warp<ThrowOnError extends boolean = false>(
|
||||||
parameters: {
|
parameters?: {
|
||||||
id: string
|
|
||||||
directory?: string
|
directory?: string
|
||||||
workspace?: string
|
workspace?: string
|
||||||
|
id?: string | null
|
||||||
sessionID?: string
|
sessionID?: string
|
||||||
},
|
},
|
||||||
options?: Options<never, ThrowOnError>,
|
options?: Options<never, ThrowOnError>,
|
||||||
|
|
@ -1027,20 +1116,20 @@ export class Workspace extends HeyApiClient {
|
||||||
[
|
[
|
||||||
{
|
{
|
||||||
args: [
|
args: [
|
||||||
{ in: "path", key: "id" },
|
|
||||||
{ in: "query", key: "directory" },
|
{ in: "query", key: "directory" },
|
||||||
{ in: "query", key: "workspace" },
|
{ in: "query", key: "workspace" },
|
||||||
|
{ in: "body", key: "id" },
|
||||||
{ in: "body", key: "sessionID" },
|
{ in: "body", key: "sessionID" },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
return (options?.client ?? this.client).post<
|
return (options?.client ?? this.client).post<
|
||||||
ExperimentalWorkspaceSessionRestoreResponses,
|
ExperimentalWorkspaceWarpResponses,
|
||||||
ExperimentalWorkspaceSessionRestoreErrors,
|
ExperimentalWorkspaceWarpErrors,
|
||||||
ThrowOnError
|
ThrowOnError
|
||||||
>({
|
>({
|
||||||
url: "/experimental/workspace/{id}/session-restore",
|
url: "/experimental/workspace/warp",
|
||||||
...options,
|
...options,
|
||||||
...params,
|
...params,
|
||||||
headers: {
|
headers: {
|
||||||
|
|
@ -1206,7 +1295,7 @@ export class Worktree extends HeyApiClient {
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
return (options?.client ?? this.client).get<WorktreeListResponses, unknown, ThrowOnError>({
|
return (options?.client ?? this.client).get<WorktreeListResponses, WorktreeListErrors, ThrowOnError>({
|
||||||
url: "/experimental/worktree",
|
url: "/experimental/worktree",
|
||||||
...options,
|
...options,
|
||||||
...params,
|
...params,
|
||||||
|
|
@ -1314,7 +1403,7 @@ export class Find extends HeyApiClient {
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
return (options?.client ?? this.client).get<FindTextResponses, unknown, ThrowOnError>({
|
return (options?.client ?? this.client).get<FindTextResponses, FindTextErrors, ThrowOnError>({
|
||||||
url: "/find",
|
url: "/find",
|
||||||
...options,
|
...options,
|
||||||
...params,
|
...params,
|
||||||
|
|
@ -1352,7 +1441,7 @@ export class Find extends HeyApiClient {
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
return (options?.client ?? this.client).get<FindFilesResponses, unknown, ThrowOnError>({
|
return (options?.client ?? this.client).get<FindFilesResponses, FindFilesErrors, ThrowOnError>({
|
||||||
url: "/find/file",
|
url: "/find/file",
|
||||||
...options,
|
...options,
|
||||||
...params,
|
...params,
|
||||||
|
|
@ -1384,7 +1473,7 @@ export class Find extends HeyApiClient {
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
return (options?.client ?? this.client).get<FindSymbolsResponses, unknown, ThrowOnError>({
|
return (options?.client ?? this.client).get<FindSymbolsResponses, FindSymbolsErrors, ThrowOnError>({
|
||||||
url: "/find/symbol",
|
url: "/find/symbol",
|
||||||
...options,
|
...options,
|
||||||
...params,
|
...params,
|
||||||
|
|
@ -1418,7 +1507,7 @@ export class File extends HeyApiClient {
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
return (options?.client ?? this.client).get<FileListResponses, unknown, ThrowOnError>({
|
return (options?.client ?? this.client).get<FileListResponses, FileListErrors, ThrowOnError>({
|
||||||
url: "/file",
|
url: "/file",
|
||||||
...options,
|
...options,
|
||||||
...params,
|
...params,
|
||||||
|
|
@ -1450,7 +1539,7 @@ export class File extends HeyApiClient {
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
return (options?.client ?? this.client).get<FileReadResponses, unknown, ThrowOnError>({
|
return (options?.client ?? this.client).get<FileReadResponses, FileReadErrors, ThrowOnError>({
|
||||||
url: "/file/content",
|
url: "/file/content",
|
||||||
...options,
|
...options,
|
||||||
...params,
|
...params,
|
||||||
|
|
@ -1480,7 +1569,7 @@ export class File extends HeyApiClient {
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
return (options?.client ?? this.client).get<FileStatusResponses, unknown, ThrowOnError>({
|
return (options?.client ?? this.client).get<FileStatusResponses, FileStatusErrors, ThrowOnError>({
|
||||||
url: "/file/status",
|
url: "/file/status",
|
||||||
...options,
|
...options,
|
||||||
...params,
|
...params,
|
||||||
|
|
@ -1512,7 +1601,7 @@ export class Instance extends HeyApiClient {
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
return (options?.client ?? this.client).post<InstanceDisposeResponses, unknown, ThrowOnError>({
|
return (options?.client ?? this.client).post<InstanceDisposeResponses, InstanceDisposeErrors, ThrowOnError>({
|
||||||
url: "/instance/dispose",
|
url: "/instance/dispose",
|
||||||
...options,
|
...options,
|
||||||
...params,
|
...params,
|
||||||
|
|
@ -1544,7 +1633,7 @@ export class Path extends HeyApiClient {
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
return (options?.client ?? this.client).get<PathGetResponses, unknown, ThrowOnError>({
|
return (options?.client ?? this.client).get<PathGetResponses, PathGetErrors, ThrowOnError>({
|
||||||
url: "/path",
|
url: "/path",
|
||||||
...options,
|
...options,
|
||||||
...params,
|
...params,
|
||||||
|
|
@ -1576,7 +1665,7 @@ export class Vcs extends HeyApiClient {
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
return (options?.client ?? this.client).get<VcsGetResponses, unknown, ThrowOnError>({
|
return (options?.client ?? this.client).get<VcsGetResponses, VcsGetErrors, ThrowOnError>({
|
||||||
url: "/vcs",
|
url: "/vcs",
|
||||||
...options,
|
...options,
|
||||||
...params,
|
...params,
|
||||||
|
|
@ -1608,7 +1697,7 @@ export class Vcs extends HeyApiClient {
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
return (options?.client ?? this.client).get<VcsDiffResponses, unknown, ThrowOnError>({
|
return (options?.client ?? this.client).get<VcsDiffResponses, VcsDiffErrors, ThrowOnError>({
|
||||||
url: "/vcs/diff",
|
url: "/vcs/diff",
|
||||||
...options,
|
...options,
|
||||||
...params,
|
...params,
|
||||||
|
|
@ -1640,7 +1729,7 @@ export class Command extends HeyApiClient {
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
return (options?.client ?? this.client).get<CommandListResponses, unknown, ThrowOnError>({
|
return (options?.client ?? this.client).get<CommandListResponses, CommandListErrors, ThrowOnError>({
|
||||||
url: "/command",
|
url: "/command",
|
||||||
...options,
|
...options,
|
||||||
...params,
|
...params,
|
||||||
|
|
@ -1672,7 +1761,7 @@ export class Lsp extends HeyApiClient {
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
return (options?.client ?? this.client).get<LspStatusResponses, unknown, ThrowOnError>({
|
return (options?.client ?? this.client).get<LspStatusResponses, LspStatusErrors, ThrowOnError>({
|
||||||
url: "/lsp",
|
url: "/lsp",
|
||||||
...options,
|
...options,
|
||||||
...params,
|
...params,
|
||||||
|
|
@ -1704,7 +1793,7 @@ export class Formatter extends HeyApiClient {
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
return (options?.client ?? this.client).get<FormatterStatusResponses, unknown, ThrowOnError>({
|
return (options?.client ?? this.client).get<FormatterStatusResponses, FormatterStatusErrors, ThrowOnError>({
|
||||||
url: "/formatter",
|
url: "/formatter",
|
||||||
...options,
|
...options,
|
||||||
...params,
|
...params,
|
||||||
|
|
@ -1875,7 +1964,7 @@ export class Mcp extends HeyApiClient {
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
return (options?.client ?? this.client).get<McpStatusResponses, unknown, ThrowOnError>({
|
return (options?.client ?? this.client).get<McpStatusResponses, McpStatusErrors, ThrowOnError>({
|
||||||
url: "/mcp",
|
url: "/mcp",
|
||||||
...options,
|
...options,
|
||||||
...params,
|
...params,
|
||||||
|
|
@ -1944,7 +2033,7 @@ export class Mcp extends HeyApiClient {
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
return (options?.client ?? this.client).post<McpConnectResponses, unknown, ThrowOnError>({
|
return (options?.client ?? this.client).post<McpConnectResponses, McpConnectErrors, ThrowOnError>({
|
||||||
url: "/mcp/{name}/connect",
|
url: "/mcp/{name}/connect",
|
||||||
...options,
|
...options,
|
||||||
...params,
|
...params,
|
||||||
|
|
@ -1974,7 +2063,7 @@ export class Mcp extends HeyApiClient {
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
return (options?.client ?? this.client).post<McpDisconnectResponses, unknown, ThrowOnError>({
|
return (options?.client ?? this.client).post<McpDisconnectResponses, McpDisconnectErrors, ThrowOnError>({
|
||||||
url: "/mcp/{name}/disconnect",
|
url: "/mcp/{name}/disconnect",
|
||||||
...options,
|
...options,
|
||||||
...params,
|
...params,
|
||||||
|
|
@ -2011,7 +2100,7 @@ export class Project extends HeyApiClient {
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
return (options?.client ?? this.client).get<ProjectListResponses, unknown, ThrowOnError>({
|
return (options?.client ?? this.client).get<ProjectListResponses, ProjectListErrors, ThrowOnError>({
|
||||||
url: "/project",
|
url: "/project",
|
||||||
...options,
|
...options,
|
||||||
...params,
|
...params,
|
||||||
|
|
@ -2041,7 +2130,7 @@ export class Project extends HeyApiClient {
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
return (options?.client ?? this.client).get<ProjectCurrentResponses, unknown, ThrowOnError>({
|
return (options?.client ?? this.client).get<ProjectCurrentResponses, ProjectCurrentErrors, ThrowOnError>({
|
||||||
url: "/project/current",
|
url: "/project/current",
|
||||||
...options,
|
...options,
|
||||||
...params,
|
...params,
|
||||||
|
|
@ -2071,7 +2160,7 @@ export class Project extends HeyApiClient {
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
return (options?.client ?? this.client).post<ProjectInitGitResponses, unknown, ThrowOnError>({
|
return (options?.client ?? this.client).post<ProjectInitGitResponses, ProjectInitGitErrors, ThrowOnError>({
|
||||||
url: "/project/git/init",
|
url: "/project/git/init",
|
||||||
...options,
|
...options,
|
||||||
...params,
|
...params,
|
||||||
|
|
@ -2155,7 +2244,7 @@ export class Pty extends HeyApiClient {
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
return (options?.client ?? this.client).get<PtyShellsResponses, unknown, ThrowOnError>({
|
return (options?.client ?? this.client).get<PtyShellsResponses, PtyShellsErrors, ThrowOnError>({
|
||||||
url: "/pty/shells",
|
url: "/pty/shells",
|
||||||
...options,
|
...options,
|
||||||
...params,
|
...params,
|
||||||
|
|
@ -2185,7 +2274,7 @@ export class Pty extends HeyApiClient {
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
return (options?.client ?? this.client).get<PtyListResponses, unknown, ThrowOnError>({
|
return (options?.client ?? this.client).get<PtyListResponses, PtyListErrors, ThrowOnError>({
|
||||||
url: "/pty",
|
url: "/pty",
|
||||||
...options,
|
...options,
|
||||||
...params,
|
...params,
|
||||||
|
|
@ -2436,7 +2525,7 @@ export class Question extends HeyApiClient {
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
return (options?.client ?? this.client).get<QuestionListResponses, unknown, ThrowOnError>({
|
return (options?.client ?? this.client).get<QuestionListResponses, QuestionListErrors, ThrowOnError>({
|
||||||
url: "/question",
|
url: "/question",
|
||||||
...options,
|
...options,
|
||||||
...params,
|
...params,
|
||||||
|
|
@ -2539,7 +2628,7 @@ export class Permission extends HeyApiClient {
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
return (options?.client ?? this.client).get<PermissionListResponses, unknown, ThrowOnError>({
|
return (options?.client ?? this.client).get<PermissionListResponses, PermissionListErrors, ThrowOnError>({
|
||||||
url: "/permission",
|
url: "/permission",
|
||||||
...options,
|
...options,
|
||||||
...params,
|
...params,
|
||||||
|
|
@ -2749,7 +2838,7 @@ export class Provider extends HeyApiClient {
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
return (options?.client ?? this.client).get<ProviderListResponses, unknown, ThrowOnError>({
|
return (options?.client ?? this.client).get<ProviderListResponses, ProviderListErrors, ThrowOnError>({
|
||||||
url: "/provider",
|
url: "/provider",
|
||||||
...options,
|
...options,
|
||||||
...params,
|
...params,
|
||||||
|
|
@ -2779,7 +2868,7 @@ export class Provider extends HeyApiClient {
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
return (options?.client ?? this.client).get<ProviderAuthResponses, unknown, ThrowOnError>({
|
return (options?.client ?? this.client).get<ProviderAuthResponses, ProviderAuthErrors, ThrowOnError>({
|
||||||
url: "/provider/auth",
|
url: "/provider/auth",
|
||||||
...options,
|
...options,
|
||||||
...params,
|
...params,
|
||||||
|
|
@ -2828,7 +2917,7 @@ export class Session2 extends HeyApiClient {
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
return (options?.client ?? this.client).get<SessionListResponses, unknown, ThrowOnError>({
|
return (options?.client ?? this.client).get<SessionListResponses, SessionListErrors, ThrowOnError>({
|
||||||
url: "/session",
|
url: "/session",
|
||||||
...options,
|
...options,
|
||||||
...params,
|
...params,
|
||||||
|
|
@ -3116,7 +3205,7 @@ export class Session2 extends HeyApiClient {
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
return (options?.client ?? this.client).get<SessionDiffResponses, unknown, ThrowOnError>({
|
return (options?.client ?? this.client).get<SessionDiffResponses, SessionDiffErrors, ThrowOnError>({
|
||||||
url: "/session/{sessionID}/diff",
|
url: "/session/{sessionID}/diff",
|
||||||
...options,
|
...options,
|
||||||
...params,
|
...params,
|
||||||
|
|
@ -3318,7 +3407,7 @@ export class Session2 extends HeyApiClient {
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
return (options?.client ?? this.client).post<SessionForkResponses, unknown, ThrowOnError>({
|
return (options?.client ?? this.client).post<SessionForkResponses, SessionForkErrors, ThrowOnError>({
|
||||||
url: "/session/{sessionID}/fork",
|
url: "/session/{sessionID}/fork",
|
||||||
...options,
|
...options,
|
||||||
...params,
|
...params,
|
||||||
|
|
@ -3894,7 +3983,7 @@ export class Sync extends HeyApiClient {
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
return (options?.client ?? this.client).post<SyncStartResponses, unknown, ThrowOnError>({
|
return (options?.client ?? this.client).post<SyncStartResponses, SyncStartErrors, ThrowOnError>({
|
||||||
url: "/sync/start",
|
url: "/sync/start",
|
||||||
...options,
|
...options,
|
||||||
...params,
|
...params,
|
||||||
|
|
@ -3956,6 +4045,43 @@ export class Sync extends HeyApiClient {
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Steal session into workspace
|
||||||
|
*
|
||||||
|
* Update a session to belong to the current workspace through the sync event system.
|
||||||
|
*/
|
||||||
|
public steal<ThrowOnError extends boolean = false>(
|
||||||
|
parameters?: {
|
||||||
|
directory?: string
|
||||||
|
workspace?: string
|
||||||
|
sessionID?: string
|
||||||
|
},
|
||||||
|
options?: Options<never, ThrowOnError>,
|
||||||
|
) {
|
||||||
|
const params = buildClientParams(
|
||||||
|
[parameters],
|
||||||
|
[
|
||||||
|
{
|
||||||
|
args: [
|
||||||
|
{ in: "query", key: "directory" },
|
||||||
|
{ in: "query", key: "workspace" },
|
||||||
|
{ in: "body", key: "sessionID" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
)
|
||||||
|
return (options?.client ?? this.client).post<SyncStealResponses, SyncStealErrors, ThrowOnError>({
|
||||||
|
url: "/sync/steal",
|
||||||
|
...options,
|
||||||
|
...params,
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
...options?.headers,
|
||||||
|
...params.headers,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
private _history?: History
|
private _history?: History
|
||||||
get history(): History {
|
get history(): History {
|
||||||
return (this._history ??= new History({ client: this.client }))
|
return (this._history ??= new History({ client: this.client }))
|
||||||
|
|
@ -4022,7 +4148,7 @@ export class Session3 extends HeyApiClient {
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
return (options?.client ?? this.client).post<V2SessionPromptResponses, unknown, ThrowOnError>({
|
return (options?.client ?? this.client).post<V2SessionPromptResponses, V2SessionPromptErrors, ThrowOnError>({
|
||||||
url: "/api/session/{sessionID}/prompt",
|
url: "/api/session/{sessionID}/prompt",
|
||||||
...options,
|
...options,
|
||||||
...params,
|
...params,
|
||||||
|
|
@ -4059,7 +4185,7 @@ export class Session3 extends HeyApiClient {
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
return (options?.client ?? this.client).post<V2SessionCompactResponses, unknown, ThrowOnError>({
|
return (options?.client ?? this.client).post<V2SessionCompactResponses, V2SessionCompactErrors, ThrowOnError>({
|
||||||
url: "/api/session/{sessionID}/compact",
|
url: "/api/session/{sessionID}/compact",
|
||||||
...options,
|
...options,
|
||||||
...params,
|
...params,
|
||||||
|
|
@ -4091,7 +4217,7 @@ export class Session3 extends HeyApiClient {
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
return (options?.client ?? this.client).post<V2SessionWaitResponses, unknown, ThrowOnError>({
|
return (options?.client ?? this.client).post<V2SessionWaitResponses, V2SessionWaitErrors, ThrowOnError>({
|
||||||
url: "/api/session/{sessionID}/wait",
|
url: "/api/session/{sessionID}/wait",
|
||||||
...options,
|
...options,
|
||||||
...params,
|
...params,
|
||||||
|
|
@ -4123,7 +4249,7 @@ export class Session3 extends HeyApiClient {
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
return (options?.client ?? this.client).get<V2SessionContextResponses, unknown, ThrowOnError>({
|
return (options?.client ?? this.client).get<V2SessionContextResponses, V2SessionContextErrors, ThrowOnError>({
|
||||||
url: "/api/session/{sessionID}/context",
|
url: "/api/session/{sessionID}/context",
|
||||||
...options,
|
...options,
|
||||||
...params,
|
...params,
|
||||||
|
|
@ -4194,7 +4320,7 @@ export class Control extends HeyApiClient {
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
return (options?.client ?? this.client).get<TuiControlNextResponses, unknown, ThrowOnError>({
|
return (options?.client ?? this.client).get<TuiControlNextResponses, TuiControlNextErrors, ThrowOnError>({
|
||||||
url: "/tui/control/next",
|
url: "/tui/control/next",
|
||||||
...options,
|
...options,
|
||||||
...params,
|
...params,
|
||||||
|
|
@ -4226,7 +4352,7 @@ export class Control extends HeyApiClient {
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
return (options?.client ?? this.client).post<TuiControlResponseResponses, unknown, ThrowOnError>({
|
return (options?.client ?? this.client).post<TuiControlResponseResponses, TuiControlResponseErrors, ThrowOnError>({
|
||||||
url: "/tui/control/response",
|
url: "/tui/control/response",
|
||||||
...options,
|
...options,
|
||||||
...params,
|
...params,
|
||||||
|
|
@ -4300,7 +4426,7 @@ export class Tui extends HeyApiClient {
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
return (options?.client ?? this.client).post<TuiOpenHelpResponses, unknown, ThrowOnError>({
|
return (options?.client ?? this.client).post<TuiOpenHelpResponses, TuiOpenHelpErrors, ThrowOnError>({
|
||||||
url: "/tui/open-help",
|
url: "/tui/open-help",
|
||||||
...options,
|
...options,
|
||||||
...params,
|
...params,
|
||||||
|
|
@ -4330,7 +4456,7 @@ export class Tui extends HeyApiClient {
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
return (options?.client ?? this.client).post<TuiOpenSessionsResponses, unknown, ThrowOnError>({
|
return (options?.client ?? this.client).post<TuiOpenSessionsResponses, TuiOpenSessionsErrors, ThrowOnError>({
|
||||||
url: "/tui/open-sessions",
|
url: "/tui/open-sessions",
|
||||||
...options,
|
...options,
|
||||||
...params,
|
...params,
|
||||||
|
|
@ -4360,7 +4486,7 @@ export class Tui extends HeyApiClient {
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
return (options?.client ?? this.client).post<TuiOpenThemesResponses, unknown, ThrowOnError>({
|
return (options?.client ?? this.client).post<TuiOpenThemesResponses, TuiOpenThemesErrors, ThrowOnError>({
|
||||||
url: "/tui/open-themes",
|
url: "/tui/open-themes",
|
||||||
...options,
|
...options,
|
||||||
...params,
|
...params,
|
||||||
|
|
@ -4390,7 +4516,7 @@ export class Tui extends HeyApiClient {
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
return (options?.client ?? this.client).post<TuiOpenModelsResponses, unknown, ThrowOnError>({
|
return (options?.client ?? this.client).post<TuiOpenModelsResponses, TuiOpenModelsErrors, ThrowOnError>({
|
||||||
url: "/tui/open-models",
|
url: "/tui/open-models",
|
||||||
...options,
|
...options,
|
||||||
...params,
|
...params,
|
||||||
|
|
@ -4420,7 +4546,7 @@ export class Tui extends HeyApiClient {
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
return (options?.client ?? this.client).post<TuiSubmitPromptResponses, unknown, ThrowOnError>({
|
return (options?.client ?? this.client).post<TuiSubmitPromptResponses, TuiSubmitPromptErrors, ThrowOnError>({
|
||||||
url: "/tui/submit-prompt",
|
url: "/tui/submit-prompt",
|
||||||
...options,
|
...options,
|
||||||
...params,
|
...params,
|
||||||
|
|
@ -4450,7 +4576,7 @@ export class Tui extends HeyApiClient {
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
return (options?.client ?? this.client).post<TuiClearPromptResponses, unknown, ThrowOnError>({
|
return (options?.client ?? this.client).post<TuiClearPromptResponses, TuiClearPromptErrors, ThrowOnError>({
|
||||||
url: "/tui/clear-prompt",
|
url: "/tui/clear-prompt",
|
||||||
...options,
|
...options,
|
||||||
...params,
|
...params,
|
||||||
|
|
@ -4525,7 +4651,7 @@ export class Tui extends HeyApiClient {
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
return (options?.client ?? this.client).post<TuiShowToastResponses, unknown, ThrowOnError>({
|
return (options?.client ?? this.client).post<TuiShowToastResponses, TuiShowToastErrors, ThrowOnError>({
|
||||||
url: "/tui/show-toast",
|
url: "/tui/show-toast",
|
||||||
...options,
|
...options,
|
||||||
...params,
|
...params,
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
Loading…
Add table
Add a link
Reference in a new issue