Compare commits
11 commits
dev
...
jlongster/
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bce1cb7b07 | ||
|
|
2bb9166546 | ||
|
|
71406ae27d | ||
|
|
b7b57a0172 | ||
|
|
d8d7b432ae | ||
|
|
c86fc1a756 | ||
|
|
e1f49c2cbe | ||
|
|
1a2b1837e8 | ||
|
|
7089f72e76 | ||
|
|
26512178c7 | ||
|
|
38129f3663 |
24 changed files with 2797 additions and 957 deletions
|
|
@ -1,6 +1,7 @@
|
||||||
{
|
{
|
||||||
"$schema": "https://opencode.ai/config.json",
|
"$schema": "https://opencode.ai/config.json",
|
||||||
"provider": {},
|
"provider": {},
|
||||||
|
"plugin": ["../packages/opencode/src/control-plane/dev/debug-workspace-plugin.ts"],
|
||||||
"permission": {
|
"permission": {
|
||||||
"edit": {
|
"edit": {
|
||||||
"packages/opencode/migration/*": "deny",
|
"packages/opencode/migration/*": "deny",
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1 @@
|
||||||
|
ALTER TABLE `event_sequence` ADD `owner_id` text;
|
||||||
File diff suppressed because it is too large
Load diff
|
|
@ -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,51 @@ 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) => {
|
||||||
|
if (selection.type === "none") {
|
||||||
|
await warpWorkspaceSession({
|
||||||
|
dialog,
|
||||||
|
sdk,
|
||||||
|
sync,
|
||||||
|
project,
|
||||||
|
toast,
|
||||||
|
workspaceID: null,
|
||||||
|
sessionID: session.id,
|
||||||
|
done: list,
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const workspaceID = await (async () => {
|
||||||
|
if (selection.type === "existing") return selection.workspaceID
|
||||||
|
const result = await sdk.client.experimental.workspace
|
||||||
|
.create({ type: selection.workspaceType, branch: null })
|
||||||
|
.catch(() => undefined)
|
||||||
|
const workspace = result?.data
|
||||||
|
if (!workspace) {
|
||||||
|
toast.show({
|
||||||
|
message: `Failed to create workspace: ${errorMessage(result?.error ?? "no response")}`,
|
||||||
|
variant: "error",
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
await project.workspace.sync()
|
||||||
|
return workspace.id
|
||||||
|
})()
|
||||||
|
if (!workspaceID) return
|
||||||
|
await warpWorkspaceSession({
|
||||||
|
dialog,
|
||||||
|
sdk,
|
||||||
|
sync,
|
||||||
|
project,
|
||||||
|
toast,
|
||||||
|
workspaceID,
|
||||||
|
sessionID: session.id,
|
||||||
|
done: list,
|
||||||
|
})
|
||||||
|
}
|
||||||
dialog.replace(() => (
|
dialog.replace(() => (
|
||||||
<DialogSessionDeleteFailed
|
<DialogSessionDeleteFailed
|
||||||
session={session.title}
|
session={session.title}
|
||||||
|
|
@ -90,22 +113,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 +140,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 +253,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,223 @@ type Adaptor = {
|
||||||
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 loadWorkspaceAdaptors(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/adaptor", 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<Adaptor[]>)
|
||||||
message: "Failed to create workspace session",
|
.catch(() => undefined)
|
||||||
variant: "error",
|
if (res) return res
|
||||||
})
|
input.toast.show({
|
||||||
return
|
message: "Failed to load workspace adaptors",
|
||||||
}
|
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 adaptors = await loadWorkspaceAdaptors(input)
|
||||||
|
if (!adaptors) return
|
||||||
|
input.dialog.replace(() => <DialogWorkspaceSelect adaptors={adaptors} 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
|
||||||
}) {
|
showSuccessToast?: boolean
|
||||||
const result = await input.sdk.client.experimental.workspace
|
}): Promise<boolean> {
|
||||||
.sessionRestore({ id: input.workspaceID, sessionID: input.sessionID })
|
const result = await (input.workspaceID === null
|
||||||
.catch(() => undefined)
|
? input.sdk.client.experimental.workspace.detach({
|
||||||
if (!result?.data) {
|
workspaceID: null,
|
||||||
|
sessionID: input.sessionID,
|
||||||
|
})
|
||||||
|
: input.sdk.client.experimental.workspace.warp({
|
||||||
|
id: input.workspaceID,
|
||||||
|
sessionID: input.sessionID,
|
||||||
|
})
|
||||||
|
).catch(() => undefined)
|
||||||
|
if (!result || result.error) {
|
||||||
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({
|
if (input.showSuccessToast !== false) {
|
||||||
message: "Session restored into the new workspace",
|
input.toast.show({
|
||||||
variant: "success",
|
message: input.workspaceID === null ? "Session moved to the local project" : "Session warped 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: {
|
||||||
|
adaptors?: Adaptor[]
|
||||||
|
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 [adaptors, setAdaptors] = createSignal<Adaptor[] | undefined>(props.adaptors)
|
||||||
const [adaptors, setAdaptors] = createSignal<Adaptor[]>()
|
|
||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
dialog.setSize("medium")
|
dialog.setSize("medium")
|
||||||
void (async () => {
|
void (async () => {
|
||||||
const dir = sync.path.directory || sdk.directory
|
if (adaptors()) return
|
||||||
const url = new URL("/experimental/workspace/adaptor", sdk.url)
|
const res = await loadWorkspaceAdaptors({ 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<Adaptor[]>)
|
|
||||||
.catch(() => undefined)
|
|
||||||
if (!res) {
|
|
||||||
toast.show({
|
|
||||||
message: "Failed to load workspace adaptors",
|
|
||||||
variant: "error",
|
|
||||||
})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
setAdaptors(res)
|
setAdaptors(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 = adaptors()
|
const list = adaptors()
|
||||||
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 adaptors",
|
.slice(0, 3)
|
||||||
|
.flatMap((workspaceID) => {
|
||||||
|
const workspace = project.workspace.get(workspaceID)
|
||||||
|
return workspace ? [workspace] : []
|
||||||
|
})
|
||||||
|
return [
|
||||||
|
...list.map((adaptor) => ({
|
||||||
|
title: adaptor.name,
|
||||||
|
value: { type: "new" as const, workspaceType: adaptor.type, workspaceName: adaptor.name },
|
||||||
|
description: adaptor.description,
|
||||||
|
category: "New workspace",
|
||||||
|
})),
|
||||||
|
{
|
||||||
|
title: "None",
|
||||||
|
value: { type: "none" as const },
|
||||||
|
description: "Use the local project",
|
||||||
|
category: "Choose workspace",
|
||||||
|
},
|
||||||
|
...recent.map((workspace: Workspace) => ({
|
||||||
|
title: workspace.name,
|
||||||
|
description: `(${workspace.type})`,
|
||||||
|
value: {
|
||||||
|
type: "existing" as const,
|
||||||
|
workspaceID: workspace.id,
|
||||||
|
workspaceType: workspace.type,
|
||||||
|
workspaceName: workspace.name,
|
||||||
},
|
},
|
||||||
]
|
category: "Choose workspace",
|
||||||
}
|
})),
|
||||||
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 (!adaptors()) 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"
|
||||||
|
|
@ -40,13 +41,16 @@ 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
|
||||||
workspaceID?: string
|
workspaceID?: string
|
||||||
|
onWorkspaceCreatingChange?: (creating: boolean) => void
|
||||||
visible?: boolean
|
visible?: boolean
|
||||||
disabled?: boolean
|
disabled?: boolean
|
||||||
onSubmit?: () => void
|
onSubmit?: () => void
|
||||||
|
|
@ -149,9 +153,94 @@ export function Prompt(props: PromptProps) {
|
||||||
})
|
})
|
||||||
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)
|
||||||
|
props.onWorkspaceCreatingChange?.(creating)
|
||||||
|
}
|
||||||
|
|
||||||
|
function showWarpNotice(name: string) {
|
||||||
|
setWarpNotice(`Warped to ${name}`)
|
||||||
|
setTimeout(() => setWarpNotice(undefined), 4000)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createWorkspace(selection: Extract<WorkspaceSelection, { type: "new" }>) {
|
||||||
|
setCreatingWorkspace(true)
|
||||||
|
const result = await sdk.client.experimental.workspace
|
||||||
|
.create({ type: selection.workspaceType, branch: null })
|
||||||
|
.catch(() => undefined)
|
||||||
|
if (result == undefined || result.error || !result.data) {
|
||||||
|
selectWorkspace(undefined)
|
||||||
|
setCreatingWorkspace(false)
|
||||||
|
toast.show({
|
||||||
|
message: "Creating workspace failed",
|
||||||
|
variant: "error",
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
await project.workspace.sync()
|
||||||
|
const workspace = result.data
|
||||||
|
selectWorkspace({
|
||||||
|
type: "existing",
|
||||||
|
workspaceID: workspace.id,
|
||||||
|
workspaceType: workspace.type,
|
||||||
|
workspaceName: workspace.name,
|
||||||
|
})
|
||||||
|
setCreatingWorkspace(false)
|
||||||
|
return workspace
|
||||||
|
}
|
||||||
|
|
||||||
|
async function warpSession(selection: WorkspaceSelection) {
|
||||||
|
if (!props.sessionID) {
|
||||||
|
selectWorkspace(selection)
|
||||||
|
dialog.clear()
|
||||||
|
if (selection.type === "new") void createWorkspace(selection)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
selectWorkspace(selection)
|
||||||
|
dialog.clear()
|
||||||
|
|
||||||
|
const workspace =
|
||||||
|
selection.type === "none"
|
||||||
|
? { id: null, name: "local project" }
|
||||||
|
: selection.type === "existing"
|
||||||
|
? { id: selection.workspaceID, name: selection.workspaceName }
|
||||||
|
: await createWorkspace(selection)
|
||||||
|
if (!workspace) return
|
||||||
|
|
||||||
|
const warped = await warpWorkspaceSession({
|
||||||
|
dialog,
|
||||||
|
sdk,
|
||||||
|
sync,
|
||||||
|
project,
|
||||||
|
toast,
|
||||||
|
workspaceID: workspace.id,
|
||||||
|
sessionID: props.sessionID,
|
||||||
|
showSuccessToast: false,
|
||||||
|
})
|
||||||
|
if (warped) showWarpNotice(workspace.name)
|
||||||
|
}
|
||||||
|
|
||||||
|
createEffect(() => {
|
||||||
|
if (!workspaceCreating()) {
|
||||||
|
setWorkspaceCreatingDots(3)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const timer = setInterval(() => setWorkspaceCreatingDots((dots) => (dots % 3) + 1), 1000)
|
||||||
|
onCleanup(() => clearInterval(timer))
|
||||||
|
})
|
||||||
|
|
||||||
function promptModelWarning() {
|
function promptModelWarning() {
|
||||||
toast.show({
|
toast.show({
|
||||||
variant: "warning",
|
variant: "warning",
|
||||||
|
|
@ -184,6 +273,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
|
||||||
})
|
})
|
||||||
|
|
@ -450,6 +540,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)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
},
|
||||||
|
},
|
||||||
]
|
]
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
@ -533,7 +644,7 @@ export function Prompt(props: PromptProps) {
|
||||||
})
|
})
|
||||||
|
|
||||||
function restoreExtmarksFromParts(parts: PromptInfo["parts"]) {
|
function restoreExtmarksFromParts(parts: PromptInfo["parts"]) {
|
||||||
input.extmarks.clear()
|
if (!input.isDestroyed) input.extmarks.clear()
|
||||||
setStore("extmarkToPartIndex", new Map())
|
setStore("extmarkToPartIndex", new Map())
|
||||||
|
|
||||||
parts.forEach((part, partIndex) => {
|
parts.forEach((part, partIndex) => {
|
||||||
|
|
@ -666,6 +777,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.
|
||||||
|
|
@ -674,6 +787,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()
|
||||||
|
|
@ -696,45 +810,22 @@ 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!,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
))
|
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
))
|
))
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
let sessionID = props.sessionID
|
|
||||||
if (sessionID == null) {
|
|
||||||
const res = await sdk.client.session.create({ workspace: props.workspaceID })
|
|
||||||
|
|
||||||
if (res.error) {
|
|
||||||
console.log("Creating a session failed:", res.error)
|
|
||||||
|
|
||||||
toast.show({
|
|
||||||
message: "Creating a session failed. Open console for more details.",
|
|
||||||
variant: "error",
|
|
||||||
})
|
|
||||||
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
sessionID = res.data.id
|
|
||||||
}
|
|
||||||
|
|
||||||
const messageID = MessageID.ascending()
|
const messageID = MessageID.ascending()
|
||||||
let inputText = store.prompt.input
|
let inputText = store.prompt.input
|
||||||
|
|
||||||
|
|
@ -759,6 +850,7 @@ export function Prompt(props: PromptProps) {
|
||||||
|
|
||||||
// Capture mode before it gets reset
|
// Capture mode before it gets reset
|
||||||
const currentMode = store.mode
|
const currentMode = store.mode
|
||||||
|
const submittedPrompt = unwrap(store.prompt)
|
||||||
const variant = local.model.variant.current()
|
const variant = local.model.variant.current()
|
||||||
const editorSelection = fileContextEnabled() ? editor.selection() : undefined
|
const editorSelection = fileContextEnabled() ? editor.selection() : undefined
|
||||||
const editorSelectionKey = editorSelection ? getEditorSelectionKey(editorSelection) : undefined
|
const editorSelectionKey = editorSelection ? getEditorSelectionKey(editorSelection) : undefined
|
||||||
|
|
@ -794,6 +886,33 @@ export function Prompt(props: PromptProps) {
|
||||||
]
|
]
|
||||||
: []
|
: []
|
||||||
|
|
||||||
|
let sessionID = props.sessionID
|
||||||
|
if (sessionID == null) {
|
||||||
|
const workspace = workspaceSelection()
|
||||||
|
const workspaceID = iife(() => {
|
||||||
|
if (!workspace) return undefined
|
||||||
|
if (workspace.type === "none") return undefined
|
||||||
|
if (workspace.type === "existing") return workspace.workspaceID
|
||||||
|
return undefined
|
||||||
|
})
|
||||||
|
|
||||||
|
const res = await sdk.client.session.create({ workspace: workspaceID })
|
||||||
|
|
||||||
|
if (res.error) {
|
||||||
|
setCreatingWorkspace(false)
|
||||||
|
console.log("Creating a session failed:", res.error)
|
||||||
|
|
||||||
|
toast.show({
|
||||||
|
message: "Creating a session failed. Open console for more details.",
|
||||||
|
variant: "error",
|
||||||
|
})
|
||||||
|
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
sessionID = res.data.id
|
||||||
|
}
|
||||||
|
|
||||||
if (store.mode === "shell") {
|
if (store.mode === "shell") {
|
||||||
void sdk.client.session.shell({
|
void sdk.client.session.shell({
|
||||||
sessionID,
|
sessionID,
|
||||||
|
|
@ -858,7 +977,7 @@ export function Prompt(props: PromptProps) {
|
||||||
lastSubmittedEditorSelectionKey = editorSelectionKey
|
lastSubmittedEditorSelectionKey = editorSelectionKey
|
||||||
}
|
}
|
||||||
history.append({
|
history.append({
|
||||||
...store.prompt,
|
...submittedPrompt,
|
||||||
mode: currentMode,
|
mode: currentMode,
|
||||||
})
|
})
|
||||||
input.extmarks.clear()
|
input.extmarks.clear()
|
||||||
|
|
@ -877,7 +996,7 @@ export function Prompt(props: PromptProps) {
|
||||||
sessionID,
|
sessionID,
|
||||||
})
|
})
|
||||||
}, 50)
|
}, 50)
|
||||||
input.clear()
|
if (!input.isDestroyed) input.clear()
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
const exit = useExit()
|
const exit = useExit()
|
||||||
|
|
@ -998,6 +1117,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
|
||||||
|
|
@ -1254,7 +1396,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">
|
||||||
|
|
@ -1324,86 +1466,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()}>{(file) => <text fg={theme.secondary}>{file()}</text>}</Show>
|
<Show when={editorFileLabelDisplay()}>{(file) => <text fg={theme.secondary}>{file()}</text>}</Show>
|
||||||
|
|
|
||||||
|
|
@ -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,4 +1,4 @@
|
||||||
import { Context, Effect, FiberMap, Layer, Schema, Stream } from "effect"
|
import { Context, Effect, FiberMap, Iterable, Layer, Schema, Stream } from "effect"
|
||||||
import { FetchHttpClient, HttpBody, HttpClient, HttpClientError, HttpClientRequest } from "effect/unstable/http"
|
import { FetchHttpClient, HttpBody, HttpClient, HttpClientError, HttpClientRequest } from "effect/unstable/http"
|
||||||
import { fn } from "@/util/fn"
|
import { fn } from "@/util/fn"
|
||||||
import { Database } from "@/storage/db"
|
import { Database } from "@/storage/db"
|
||||||
|
|
@ -6,6 +6,7 @@ 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"
|
||||||
|
|
@ -21,13 +22,14 @@ import { getAdaptor } from "./adaptors"
|
||||||
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"
|
||||||
import { makeRuntime } from "@/effect/run-service"
|
import { makeRuntime } from "@/effect/run-service"
|
||||||
import { waitEvent } from "./util"
|
import { waitEvent } from "./util"
|
||||||
import { WorkspaceContext } from "./workspace-context"
|
import { WorkspaceContext } from "./workspace-context"
|
||||||
import { NonNegativeInt, withStatics } from "@/util/schema"
|
import { withStatics } from "@/util/schema"
|
||||||
import { zod as effectZod, zodObject } from "@/util/effect-zod"
|
import { zod as effectZod, zodObject } from "@/util/effect-zod"
|
||||||
|
|
||||||
export const Info = WorkspaceInfoSchema
|
export const Info = WorkspaceInfoSchema
|
||||||
|
|
@ -39,13 +41,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",
|
||||||
|
|
@ -59,7 +54,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),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -89,11 +83,11 @@ export const CreateInput = Schema.Struct({
|
||||||
}).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,
|
||||||
|
|
@ -117,8 +111,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,
|
||||||
|
|
@ -139,17 +133,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>
|
||||||
|
|
@ -170,6 +164,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 connections = new Map<WorkspaceID, ConnectionStatus>()
|
const connections = new Map<WorkspaceID, ConnectionStatus>()
|
||||||
const syncFibers = yield* FiberMap.make<WorkspaceID, void, SyncLoopError>()
|
const syncFibers = yield* FiberMap.make<WorkspaceID, void, SyncLoopError>()
|
||||||
|
|
@ -501,31 +496,95 @@ 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", {
|
yield* prompt.cancel(input.sessionID)
|
||||||
|
|
||||||
|
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 adaptor = getAdaptor(previous.projectID, previous.type)
|
||||||
|
const target = yield* Effect.promise(() => Promise.resolve(adaptor.target(previous)))
|
||||||
|
|
||||||
|
if (target.type === "remote") {
|
||||||
|
yield* syncHistory(previous, target.url, target.headers).pipe(
|
||||||
|
Effect.catch((error) =>
|
||||||
|
Effect.sync(() => {
|
||||||
|
log.warn("session warp final source sync failed", {
|
||||||
|
workspaceID: previous.id,
|
||||||
|
sessionID: input.sessionID,
|
||||||
|
error: errorData(error),
|
||||||
|
})
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// "claim" this session so any future events coming from
|
||||||
|
// the old workspace are ignored
|
||||||
|
SyncEvent.claim(input.sessionID, input.workspaceID ?? Instance.project.id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (input.workspaceID === null) {
|
||||||
|
yield* Effect.sync(() =>
|
||||||
|
SyncEvent.run(Session.Event.Updated, {
|
||||||
|
sessionID: input.sessionID,
|
||||||
|
info: {
|
||||||
|
workspaceID: null,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
log.info("session warp complete", {
|
||||||
|
workspaceID: input.workspaceID,
|
||||||
|
sessionID: input.sessionID,
|
||||||
|
target: "local",
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const workspaceID = input.workspaceID
|
||||||
|
const space = yield* get(workspaceID)
|
||||||
if (!space)
|
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 adaptor = getAdaptor(space.projectID, space.type)
|
const adaptor = getAdaptor(space.projectID, space.type)
|
||||||
const target = yield* Effect.promise(() => Promise.resolve(adaptor.target(space)))
|
const target = yield* Effect.promise(() => Promise.resolve(adaptor.target(space)))
|
||||||
|
|
||||||
yield* Effect.sync(() =>
|
if (target.type === "local") {
|
||||||
SyncEvent.run(Session.Event.Updated, {
|
yield* Effect.sync(() =>
|
||||||
|
SyncEvent.run(Session.Event.Updated, {
|
||||||
|
sessionID: input.sessionID,
|
||||||
|
info: {
|
||||||
|
workspaceID: input.workspaceID,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
log.info("session warp complete", {
|
||||||
|
workspaceID: input.workspaceID,
|
||||||
sessionID: input.sessionID,
|
sessionID: input.sessionID,
|
||||||
info: {
|
target: target.directory,
|
||||||
workspaceID: input.workspaceID,
|
})
|
||||||
},
|
return
|
||||||
}),
|
}
|
||||||
)
|
|
||||||
|
|
||||||
const rows = yield* db((db) =>
|
const rows = yield* db((db) =>
|
||||||
db
|
db
|
||||||
|
|
@ -547,130 +606,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") {
|
|
||||||
SyncEvent.replayAll(events)
|
|
||||||
log.info("session restore batch replayed locally", {
|
|
||||||
workspaceID: input.workspaceID,
|
|
||||||
sessionID: input.sessionID,
|
|
||||||
step: i + 1,
|
|
||||||
total,
|
|
||||||
events: events.length,
|
|
||||||
})
|
|
||||||
} else {
|
|
||||||
const url = route(target.url, "/sync/replay")
|
|
||||||
const res = yield* http.execute(
|
|
||||||
HttpClientRequest.post(url, {
|
|
||||||
headers: new Headers(target.headers),
|
|
||||||
body: HttpBody.jsonUnsafe({
|
|
||||||
directory: space.directory ?? "",
|
|
||||||
events,
|
|
||||||
}),
|
}),
|
||||||
}),
|
)
|
||||||
)
|
|
||||||
|
|
||||||
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),
|
||||||
|
|
@ -799,7 +823,7 @@ export const layer = Layer.effect(
|
||||||
|
|
||||||
return Service.of({
|
return Service.of({
|
||||||
create,
|
create,
|
||||||
sessionRestore,
|
sessionWarp,
|
||||||
list,
|
list,
|
||||||
get,
|
get,
|
||||||
remove,
|
remove,
|
||||||
|
|
@ -814,6 +838,7 @@ export const layer = Layer.effect(
|
||||||
export const defaultLayer = layer.pipe(
|
export const defaultLayer = layer.pipe(
|
||||||
Layer.provide(Auth.defaultLayer),
|
Layer.provide(Auth.defaultLayer),
|
||||||
Layer.provide(Session.defaultLayer),
|
Layer.provide(Session.defaultLayer),
|
||||||
|
Layer.provide(SessionPrompt.defaultLayer),
|
||||||
Layer.provide(FetchHttpClient.layer),
|
Layer.provide(FetchHttpClient.layer),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -861,7 +886,7 @@ const { runPromise, runSync } = makeRuntime(Service, defaultLayer)
|
||||||
|
|
||||||
export const create = fn(CreateInput.zod, (input) => runPromise((svc) => svc.create(input)))
|
export const create = fn(CreateInput.zod, (input) => runPromise((svc) => svc.create(input)))
|
||||||
|
|
||||||
export const sessionRestore = fn(SessionRestoreInput.zod, (input) => runPromise((svc) => svc.sessionRestore(input)))
|
export const sessionWarp = fn(SessionWarpInput.zod, (input) => runPromise((svc) => svc.sessionWarp(input)))
|
||||||
|
|
||||||
export function list(project: Project.Info) {
|
export function list(project: Project.Info) {
|
||||||
return Database.use((db) =>
|
return Database.use((db) =>
|
||||||
|
|
|
||||||
|
|
@ -8,10 +8,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()
|
||||||
|
|
@ -142,56 +138,47 @@ 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.detach",
|
||||||
responses: {
|
responses: {
|
||||||
200: {
|
204: {
|
||||||
description: "Session replay started",
|
description: "Session warped",
|
||||||
content: {
|
},
|
||||||
"application/json": {
|
...errors(400),
|
||||||
schema: resolver(
|
},
|
||||||
z.object({
|
}),
|
||||||
total: z.number().int().min(0),
|
validator("json", Workspace.SessionWarpInput.zodObject),
|
||||||
}),
|
async (c) => {
|
||||||
),
|
await Workspace.sessionWarp(c.req.valid("json") as Workspace.SessionWarpInput)
|
||||||
},
|
return c.body(null, 204)
|
||||||
},
|
},
|
||||||
|
)
|
||||||
|
.post(
|
||||||
|
"/:id/warp",
|
||||||
|
describeRoute({
|
||||||
|
summary: "Warp session into workspace",
|
||||||
|
description: "Move a session's sync history into the target workspace.",
|
||||||
|
operationId: "experimental.workspace.warp",
|
||||||
|
responses: {
|
||||||
|
204: {
|
||||||
|
description: "Session warped",
|
||||||
},
|
},
|
||||||
...errors(400),
|
...errors(400),
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
validator("param", z.object({ id: zodObject(Workspace.Info).shape.id })),
|
validator("param", z.object({ id: zodObject(Workspace.Info).shape.id })),
|
||||||
validator("json", Workspace.SessionRestoreInput.zodObject.omit({ workspaceID: true })),
|
validator("json", Workspace.SessionWarpInput.zodObject.omit({ workspaceID: true })),
|
||||||
async (c) => {
|
async (c) => {
|
||||||
const { id } = c.req.valid("param")
|
const { id } = c.req.valid("param")
|
||||||
const body = c.req.valid("json") as Omit<Workspace.SessionRestoreInput, "workspaceID">
|
const body = c.req.valid("json") as Omit<Workspace.SessionWarpInput, "workspaceID">
|
||||||
log.info("session restore route requested", {
|
await Workspace.sessionWarp({
|
||||||
workspaceID: id,
|
workspaceID: id,
|
||||||
sessionID: body.sessionID,
|
...body,
|
||||||
directory: Instance.directory,
|
|
||||||
})
|
})
|
||||||
try {
|
return c.body(null, 204)
|
||||||
const result = await Workspace.sessionRestore({
|
|
||||||
workspaceID: id,
|
|
||||||
...body,
|
|
||||||
})
|
|
||||||
log.info("session restore route complete", {
|
|
||||||
workspaceID: id,
|
|
||||||
sessionID: body.sessionID,
|
|
||||||
total: result.total,
|
|
||||||
})
|
|
||||||
return c.json(result)
|
|
||||||
} catch (err) {
|
|
||||||
log.error("session restore route failed", {
|
|
||||||
workspaceID: id,
|
|
||||||
sessionID: body.sessionID,
|
|
||||||
error: errorData(err),
|
|
||||||
})
|
|
||||||
throw err
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -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,8 +1,7 @@
|
||||||
import { Workspace } from "@/control-plane/workspace"
|
import { Workspace } from "@/control-plane/workspace"
|
||||||
import { WorkspaceAdaptorEntry } from "@/control-plane/types"
|
import { WorkspaceAdaptorEntry } 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"
|
||||||
|
|
@ -10,17 +9,15 @@ import { described } from "./metadata"
|
||||||
|
|
||||||
const root = "/experimental/workspace"
|
const root = "/experimental/workspace"
|
||||||
export const CreatePayload = Schema.Struct(Struct.omit(Workspace.CreateInput.fields, ["projectID"]))
|
export const CreatePayload = Schema.Struct(Struct.omit(Workspace.CreateInput.fields, ["projectID"]))
|
||||||
export const SessionRestorePayload = Schema.Struct(Struct.omit(Workspace.SessionRestoreInput.fields, ["workspaceID"]))
|
export const WarpPayload = Schema.Struct(Struct.omit(Workspace.SessionWarpInput.fields, ["workspaceID"]))
|
||||||
export const SessionRestoreResponse = Schema.Struct({
|
|
||||||
total: NonNegativeInt,
|
|
||||||
})
|
|
||||||
|
|
||||||
export const WorkspacePaths = {
|
export const WorkspacePaths = {
|
||||||
adaptors: `${root}/adaptor`,
|
adaptors: `${root}/adaptor`,
|
||||||
list: root,
|
list: root,
|
||||||
status: `${root}/status`,
|
status: `${root}/status`,
|
||||||
remove: `${root}/:id`,
|
remove: `${root}/:id`,
|
||||||
sessionRestore: `${root}/:id/session-restore`,
|
warpLocal: `${root}/warp`,
|
||||||
|
warp: `${root}/:id/warp`,
|
||||||
} as const
|
} as const
|
||||||
|
|
||||||
export const WorkspaceApi = HttpApi.make("workspace")
|
export const WorkspaceApi = HttpApi.make("workspace")
|
||||||
|
|
@ -76,16 +73,27 @@ export const WorkspaceApi = HttpApi.make("workspace")
|
||||||
description: "Remove an existing workspace.",
|
description: "Remove an existing workspace.",
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
HttpApiEndpoint.post("sessionRestore", WorkspacePaths.sessionRestore, {
|
HttpApiEndpoint.post("warpLocal", WorkspacePaths.warpLocal, {
|
||||||
params: { id: Workspace.Info.fields.id },
|
payload: Workspace.SessionWarpInput,
|
||||||
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.detach",
|
||||||
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.",
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
HttpApiEndpoint.post("warp", WorkspacePaths.warp, {
|
||||||
|
params: { id: Workspace.Info.fields.id },
|
||||||
|
payload: WarpPayload,
|
||||||
|
success: described(HttpApiSchema.NoContent, "Session warped"),
|
||||||
|
error: HttpApiError.BadRequest,
|
||||||
|
}).annotateMerge(
|
||||||
|
OpenApi.annotations({
|
||||||
|
identifier: "experimental.workspace.warp",
|
||||||
|
summary: "Warp session into workspace",
|
||||||
|
description: "Move a session's sync history into the target workspace.",
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,8 @@
|
||||||
import { startWorkspaceSyncing } from "@/control-plane/workspace"
|
import { startWorkspaceSyncing } from "@/control-plane/workspace"
|
||||||
|
import { WorkspaceContext } from "@/control-plane/workspace-context"
|
||||||
import * as InstanceState from "@/effect/instance-state"
|
import * as InstanceState from "@/effect/instance-state"
|
||||||
|
import { Instance } from "@/project/instance"
|
||||||
|
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 +15,7 @@ import { or } from "drizzle-orm"
|
||||||
import { Effect } from "effect"
|
import { Effect } 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"
|
||||||
|
|
||||||
export const syncHandlers = HttpApiBuilder.group(InstanceHttpApi, "sync", (handlers) =>
|
export const syncHandlers = HttpApiBuilder.group(InstanceHttpApi, "sync", (handlers) =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
|
|
@ -33,6 +36,29 @@ export const syncHandlers = HttpApiBuilder.group(InstanceHttpApi, "sync", (handl
|
||||||
return { sessionID: events[0].aggregateID }
|
return { sessionID: events[0].aggregateID }
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const steal = Effect.fn("SyncHttpApi.steal")(function* (ctx: { payload: typeof SessionPayload.Type }) {
|
||||||
|
const instance = yield* InstanceState.context
|
||||||
|
const workspaceID = yield* InstanceState.workspaceID
|
||||||
|
if (!workspaceID) throw new Error("Cannot steal session without workspace context")
|
||||||
|
|
||||||
|
yield* Effect.sync(() =>
|
||||||
|
WorkspaceContext.provide({
|
||||||
|
workspaceID,
|
||||||
|
fn: () =>
|
||||||
|
Instance.restore(instance, () =>
|
||||||
|
SyncEvent.run(Session.Event.Updated, {
|
||||||
|
sessionID: ctx.payload.sessionID,
|
||||||
|
info: {
|
||||||
|
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) =>
|
||||||
|
|
@ -49,6 +75,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)
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -3,9 +3,9 @@ import { Workspace } from "@/control-plane/workspace"
|
||||||
import * as InstanceState from "@/effect/instance-state"
|
import * as InstanceState from "@/effect/instance-state"
|
||||||
import { Instance } from "@/project/instance"
|
import { Instance } from "@/project/instance"
|
||||||
import { Effect } from "effect"
|
import { Effect } from "effect"
|
||||||
import { HttpApiBuilder } from "effect/unstable/httpapi"
|
import { HttpApiBuilder, HttpApiSchema } 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* () {
|
||||||
|
|
@ -40,19 +40,28 @@ export const workspaceHandlers = HttpApiBuilder.group(InstanceHttpApi, "workspac
|
||||||
return yield* Effect.promise(() => Instance.restore(instance, () => Workspace.remove(ctx.params.id)))
|
return yield* Effect.promise(() => Instance.restore(instance, () => Workspace.remove(ctx.params.id)))
|
||||||
})
|
})
|
||||||
|
|
||||||
const sessionRestore = Effect.fn("WorkspaceHttpApi.sessionRestore")(function* (ctx: {
|
const warp = Effect.fn("WorkspaceHttpApi.warp")(function* (ctx: {
|
||||||
params: { id: Workspace.Info["id"] }
|
params: { id: Workspace.Info["id"] }
|
||||||
payload: typeof SessionRestorePayload.Type
|
payload: typeof WarpPayload.Type
|
||||||
}) {
|
}) {
|
||||||
const instance = yield* InstanceState.context
|
const instance = yield* InstanceState.context
|
||||||
return yield* Effect.promise(() =>
|
yield* Effect.promise(() =>
|
||||||
Instance.restore(instance, () =>
|
Instance.restore(instance, () =>
|
||||||
Workspace.sessionRestore({
|
Workspace.sessionWarp({
|
||||||
workspaceID: ctx.params.id,
|
workspaceID: ctx.params.id,
|
||||||
sessionID: ctx.payload.sessionID,
|
...ctx.payload,
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
return HttpApiSchema.NoContent.make()
|
||||||
|
})
|
||||||
|
|
||||||
|
const warpLocal = Effect.fn("WorkspaceHttpApi.warpLocal")(function* (ctx: {
|
||||||
|
payload: typeof Workspace.SessionWarpInput.Type
|
||||||
|
}) {
|
||||||
|
const instance = yield* InstanceState.context
|
||||||
|
yield* Effect.promise(() => Instance.restore(instance, () => Workspace.sessionWarp(ctx.payload)))
|
||||||
|
return HttpApiSchema.NoContent.make()
|
||||||
})
|
})
|
||||||
|
|
||||||
return handlers
|
return handlers
|
||||||
|
|
@ -61,6 +70,7 @@ 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("warpLocal", warpLocal)
|
||||||
|
.handle("warp", warp)
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,9 @@ import * as Log from "@opencode-ai/core/util/log"
|
||||||
import { startWorkspaceSyncing } from "@/control-plane/workspace"
|
import { startWorkspaceSyncing } from "@/control-plane/workspace"
|
||||||
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(),
|
||||||
|
|
@ -23,6 +26,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" })
|
||||||
|
|
||||||
|
|
@ -105,6 +111,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", {
|
||||||
|
|
|
||||||
|
|
@ -121,7 +121,7 @@ export function project<Def extends Definition>(
|
||||||
return [def, func as ProjectorFunc]
|
return [def, func as ProjectorFunc]
|
||||||
}
|
}
|
||||||
|
|
||||||
function process<Def extends Definition>(def: Def, event: Event<Def>, options: { publish: boolean }) {
|
function process<Def extends Definition>(def: Def, event: Event<Def>, options: { publish: boolean; 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")
|
||||||
}
|
}
|
||||||
|
|
@ -131,8 +131,6 @@ function process<Def extends Definition>(def: Def, event: Event<Def>, options: {
|
||||||
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)
|
projector(tx, event.data)
|
||||||
|
|
||||||
|
|
@ -141,6 +139,7 @@ function process<Def extends Definition>(def: Def, event: Event<Def>, options: {
|
||||||
.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,
|
||||||
|
|
@ -185,7 +184,7 @@ function process<Def extends Definition>(def: Def, event: Event<Def>, options: {
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
export function replay(event: SerializedEvent, options?: { publish: boolean }) {
|
export function replay(event: SerializedEvent, options?: { publish: boolean; ownerID?: string }) {
|
||||||
const def = registry.get(event.type)
|
const def = registry.get(event.type)
|
||||||
if (!def) {
|
if (!def) {
|
||||||
throw new Error(`Unknown event type: ${event.type}`)
|
throw new Error(`Unknown event type: ${event.type}`)
|
||||||
|
|
@ -193,7 +192,7 @@ export function replay(event: SerializedEvent, options?: { publish: boolean }) {
|
||||||
|
|
||||||
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(),
|
||||||
|
|
@ -204,12 +203,16 @@ export function replay(event: SerializedEvent, options?: { publish: boolean }) {
|
||||||
return
|
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(`Sequence mismatch for aggregate "${event.aggregateID}": expected ${expected}, got ${event.seq}`)
|
throw new Error(`Sequence mismatch for aggregate "${event.aggregateID}": expected ${expected}, got ${event.seq}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
process(def, event, { publish: !!options?.publish })
|
process(def, event, { publish: !!options?.publish, ownerID: options?.ownerID ?? row?.ownerID ?? undefined })
|
||||||
}
|
}
|
||||||
|
|
||||||
export function replayAll(events: SerializedEvent[], options?: { publish: boolean }) {
|
export function replayAll(events: SerializedEvent[], options?: { publish: boolean }) {
|
||||||
|
|
@ -274,6 +277,16 @@ export function remove(aggregateID: string) {
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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()
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { afterEach, beforeEach, describe, expect, mock, spyOn, test } from "bun:test"
|
import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test"
|
||||||
import fs from "node:fs/promises"
|
import fs from "node:fs/promises"
|
||||||
import Http from "node:http"
|
import Http from "node:http"
|
||||||
import path from "node:path"
|
import path from "node:path"
|
||||||
|
|
@ -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"
|
||||||
|
|
@ -15,11 +15,10 @@ import { ProjectID } from "@/project/schema"
|
||||||
import { ProjectTable } from "@/project/project.sql"
|
import { ProjectTable } from "@/project/project.sql"
|
||||||
import { Instance } from "@/project/instance"
|
import { Instance } from "@/project/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 { provideTmpdirInstance, tmpdir } from "../fixture/fixture"
|
import { provideTmpdirInstance, tmpdir } from "../fixture/fixture"
|
||||||
import { testEffect } from "../lib/effect"
|
import { testEffect } from "../lib/effect"
|
||||||
|
|
@ -298,48 +297,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")
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
@ -356,17 +331,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", () => {
|
||||||
|
|
@ -632,6 +596,134 @@ describe("workspace-old CRUD", () => {
|
||||||
expect(await WorkspaceOld.get(info.id)).toBeUndefined()
|
expect(await WorkspaceOld.get(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)
|
||||||
|
registerAdaptor(Instance.project.id, previousType, localAdaptor(path.join(dir, "warp-prev-local")).adaptor)
|
||||||
|
registerAdaptor(Instance.project.id, targetType, localAdaptor(path.join(dir, "warp-target-local")).adaptor)
|
||||||
|
const session = await AppRuntime.runPromise(SessionNs.Service.use((svc) => svc.create({})))
|
||||||
|
attachSessionToWorkspace(session.id, previous.id)
|
||||||
|
|
||||||
|
await WorkspaceOld.sessionWarp({ 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)
|
||||||
|
registerAdaptor(Instance.project.id, previousType, localAdaptor(path.join(dir, "warp-detach-local")).adaptor)
|
||||||
|
const session = await AppRuntime.runPromise(SessionNs.Service.use((svc) => svc.create({})))
|
||||||
|
attachSessionToWorkspace(session.id, previous.id)
|
||||||
|
|
||||||
|
await WorkspaceOld.sessionWarp({ 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)
|
||||||
|
registerAdaptor(Instance.project.id, previousType, remoteAdaptor(`${url}/warp-source`).adaptor)
|
||||||
|
registerAdaptor(Instance.project.id, targetType, remoteAdaptor(`${url}/warp-target`).adaptor)
|
||||||
|
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", () => {
|
||||||
|
|
@ -1190,320 +1282,3 @@ describe("workspace-old waitForSync", () => {
|
||||||
})
|
})
|
||||||
}, 7000)
|
}, 7000)
|
||||||
})
|
})
|
||||||
|
|
||||||
describe("workspace-old sessionRestore", () => {
|
|
||||||
test("throws when the workspace is missing", async () => {
|
|
||||||
await withInstance(async () => {
|
|
||||||
await expect(
|
|
||||||
WorkspaceOld.sessionRestore({
|
|
||||||
workspaceID: WorkspaceID.ascending("wrk_restore_missing"),
|
|
||||||
sessionID: SessionID.descending("ses_restore_missing_workspace"),
|
|
||||||
}),
|
|
||||||
).rejects.toThrow("Workspace not found: wrk_restore_missing")
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
test("throws when switching a missing session fails", async () => {
|
|
||||||
await withInstance(async (dir) => {
|
|
||||||
const type = unique("restore-missing-session")
|
|
||||||
const info = workspaceInfo(Instance.project.id, type, { directory: dir })
|
|
||||||
insertWorkspace(info)
|
|
||||||
registerAdaptor(Instance.project.id, type, localAdaptor(dir).adaptor)
|
|
||||||
|
|
||||||
await expect(
|
|
||||||
WorkspaceOld.sessionRestore({ workspaceID: info.id, sessionID: SessionID.descending("ses_missing_restore") }),
|
|
||||||
).rejects.toThrow("NotFoundError")
|
|
||||||
await WorkspaceOld.remove(info.id)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
it.live("posts remote replay batches of 10, emits progress, and includes the workspace update event", () => {
|
|
||||||
const replay: FetchCall[] = []
|
|
||||||
return Effect.gen(function* () {
|
|
||||||
yield* HttpServer.serveEffect()(
|
|
||||||
Effect.gen(function* () {
|
|
||||||
const req = yield* HttpServerRequest.HttpServerRequest
|
|
||||||
const bodyText = yield* req.text
|
|
||||||
const call = {
|
|
||||||
url: new URL(req.url, "http://localhost"),
|
|
||||||
method: req.method,
|
|
||||||
headers: new Headers(req.headers),
|
|
||||||
bodyText,
|
|
||||||
json: bodyText ? JSON.parse(bodyText) : undefined,
|
|
||||||
}
|
|
||||||
if (call.url.pathname === "/restore/sync/replay") {
|
|
||||||
replay.push(call)
|
|
||||||
return HttpServerResponse.fromWeb(Response.json({ ok: true }))
|
|
||||||
}
|
|
||||||
return HttpServerResponse.text("unexpected", { status: 500 })
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
const url = yield* serverUrl()
|
|
||||||
yield* provideTmpdirInstance(
|
|
||||||
(dir) =>
|
|
||||||
Effect.gen(function* () {
|
|
||||||
const workspace = yield* WorkspaceOld.Service
|
|
||||||
const sessionSvc = yield* SessionNs.Service
|
|
||||||
const captured = captureGlobalEvents()
|
|
||||||
try {
|
|
||||||
const type = unique("restore-remote")
|
|
||||||
const info = workspaceInfo(Instance.project.id, type, { directory: dir })
|
|
||||||
insertWorkspace(info)
|
|
||||||
registerAdaptor(
|
|
||||||
Instance.project.id,
|
|
||||||
type,
|
|
||||||
remoteAdaptor(`${url}/restore/?ignored=1#hash`, {
|
|
||||||
directory: dir,
|
|
||||||
headers: { authorization: "Bearer restore" },
|
|
||||||
}).adaptor,
|
|
||||||
)
|
|
||||||
const session = yield* sessionSvc.create({ title: "restore remote" })
|
|
||||||
replaceSessionEvents(session.id, 24)
|
|
||||||
|
|
||||||
const result = yield* workspace.sessionRestore({ workspaceID: info.id, sessionID: session.id })
|
|
||||||
|
|
||||||
expect(result).toEqual({ total: 3 })
|
|
||||||
expect(replay).toHaveLength(3)
|
|
||||||
expect(replay.map((call) => call.url.pathname + call.url.search + call.url.hash)).toEqual([
|
|
||||||
"/restore/sync/replay",
|
|
||||||
"/restore/sync/replay",
|
|
||||||
"/restore/sync/replay",
|
|
||||||
])
|
|
||||||
expect(replay.every((call) => call.headers.get("authorization") === "Bearer restore")).toBe(true)
|
|
||||||
expect(replay.every((call) => call.headers.get("content-type") === "application/json")).toBe(true)
|
|
||||||
expect(replay.map((call) => (call.json as { events: unknown[] }).events.length)).toEqual([10, 10, 5])
|
|
||||||
expect(replay.map((call) => (call.json as { directory: string }).directory)).toEqual([dir, dir, dir])
|
|
||||||
expect(
|
|
||||||
replay.flatMap((call) =>
|
|
||||||
(call.json as { events: Array<{ seq: number }> }).events.map((event) => event.seq),
|
|
||||||
),
|
|
||||||
).toEqual(Array.from({ length: 25 }, (_, i) => i))
|
|
||||||
expect(
|
|
||||||
(replay[2].json as { events: Array<{ seq: number; type: string; data: unknown }> }).events.at(-1),
|
|
||||||
).toMatchObject({
|
|
||||||
seq: 24,
|
|
||||||
type: sessionUpdatedType(),
|
|
||||||
data: { sessionID: session.id, info: { workspaceID: info.id } },
|
|
||||||
})
|
|
||||||
expect((yield* sessionSvc.get(session.id)).workspaceID).toBe(info.id)
|
|
||||||
expect(
|
|
||||||
captured.events
|
|
||||||
.filter(
|
|
||||||
(event) => event.workspace === info.id && event.payload.type === WorkspaceOld.Event.Restore.type,
|
|
||||||
)
|
|
||||||
.map((event) => event.payload.properties.step),
|
|
||||||
).toEqual([0, 1, 2, 3])
|
|
||||||
yield* workspace.remove(info.id)
|
|
||||||
} finally {
|
|
||||||
captured.dispose()
|
|
||||||
}
|
|
||||||
}),
|
|
||||||
{ git: true },
|
|
||||||
)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
it.live("remote restore sends an empty directory string when the workspace directory is null", () => {
|
|
||||||
const replay: FetchCall[] = []
|
|
||||||
return Effect.gen(function* () {
|
|
||||||
yield* HttpServer.serveEffect()(
|
|
||||||
Effect.gen(function* () {
|
|
||||||
const req = yield* HttpServerRequest.HttpServerRequest
|
|
||||||
const bodyText = yield* req.text
|
|
||||||
replay.push({
|
|
||||||
url: new URL(req.url, "http://localhost"),
|
|
||||||
method: req.method,
|
|
||||||
headers: new Headers(req.headers),
|
|
||||||
bodyText,
|
|
||||||
json: bodyText ? JSON.parse(bodyText) : undefined,
|
|
||||||
})
|
|
||||||
return HttpServerResponse.fromWeb(Response.json({ ok: true }))
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
const url = yield* serverUrl()
|
|
||||||
yield* provideTmpdirInstance(
|
|
||||||
() =>
|
|
||||||
Effect.gen(function* () {
|
|
||||||
const workspace = yield* WorkspaceOld.Service
|
|
||||||
const sessionSvc = yield* SessionNs.Service
|
|
||||||
const type = unique("restore-null-dir")
|
|
||||||
const info = workspaceInfo(Instance.project.id, type, { directory: null })
|
|
||||||
insertWorkspace(info)
|
|
||||||
registerAdaptor(Instance.project.id, type, remoteAdaptor(`${url}/null-dir`, { directory: null }).adaptor)
|
|
||||||
const session = yield* sessionSvc.create({ title: "null dir" })
|
|
||||||
replaceSessionEvents(session.id, 0)
|
|
||||||
|
|
||||||
expect(yield* workspace.sessionRestore({ workspaceID: info.id, sessionID: session.id })).toEqual({
|
|
||||||
total: 1,
|
|
||||||
})
|
|
||||||
expect((replay[0].json as { directory: string }).directory).toBe("")
|
|
||||||
expect((replay[0].json as { events: unknown[] }).events).toHaveLength(1)
|
|
||||||
yield* workspace.remove(info.id)
|
|
||||||
}),
|
|
||||||
{ git: true },
|
|
||||||
)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
it.live("remote restore failures include status and body and do not emit completed batch progress", () => {
|
|
||||||
const replay: FetchCall[] = []
|
|
||||||
return Effect.gen(function* () {
|
|
||||||
yield* HttpServer.serveEffect()(
|
|
||||||
Effect.gen(function* () {
|
|
||||||
const req = yield* HttpServerRequest.HttpServerRequest
|
|
||||||
const bodyText = yield* req.text
|
|
||||||
replay.push({
|
|
||||||
url: new URL(req.url, "http://localhost"),
|
|
||||||
method: req.method,
|
|
||||||
headers: new Headers(req.headers),
|
|
||||||
bodyText,
|
|
||||||
json: bodyText ? JSON.parse(bodyText) : undefined,
|
|
||||||
})
|
|
||||||
return HttpServerResponse.text("replay failed", { status: 503 })
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
const url = yield* serverUrl()
|
|
||||||
yield* provideTmpdirInstance(
|
|
||||||
(dir) =>
|
|
||||||
Effect.gen(function* () {
|
|
||||||
const workspace = yield* WorkspaceOld.Service
|
|
||||||
const sessionSvc = yield* SessionNs.Service
|
|
||||||
const captured = captureGlobalEvents()
|
|
||||||
try {
|
|
||||||
const type = unique("restore-remote-fail")
|
|
||||||
const info = workspaceInfo(Instance.project.id, type, { directory: dir })
|
|
||||||
insertWorkspace(info)
|
|
||||||
registerAdaptor(Instance.project.id, type, remoteAdaptor(`${url}/fail`, { directory: dir }).adaptor)
|
|
||||||
const session = yield* sessionSvc.create({ title: "restore fail" })
|
|
||||||
replaceSessionEvents(session.id, 11)
|
|
||||||
|
|
||||||
const error = yield* Effect.flip(
|
|
||||||
workspace.sessionRestore({ workspaceID: info.id, sessionID: session.id }),
|
|
||||||
)
|
|
||||||
expect((error as Error).message).toContain(
|
|
||||||
`Failed to replay session ${session.id} into workspace ${info.id}: HTTP 503 replay failed`,
|
|
||||||
)
|
|
||||||
|
|
||||||
expect(replay).toHaveLength(1)
|
|
||||||
expect(
|
|
||||||
captured.events
|
|
||||||
.filter(
|
|
||||||
(event) => event.workspace === info.id && event.payload.type === WorkspaceOld.Event.Restore.type,
|
|
||||||
)
|
|
||||||
.map((event) => event.payload.properties.step),
|
|
||||||
).toEqual([0])
|
|
||||||
yield* workspace.remove(info.id)
|
|
||||||
} finally {
|
|
||||||
captured.dispose()
|
|
||||||
}
|
|
||||||
}),
|
|
||||||
{ git: true },
|
|
||||||
)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
test("local restore replays batches without fetch and emits progress", async () => {
|
|
||||||
await withInstance(async (dir) => {
|
|
||||||
const captured = captureGlobalEvents()
|
|
||||||
let fetchCallCount = 0
|
|
||||||
const replayAll = spyOn(SyncEvent, "replayAll")
|
|
||||||
try {
|
|
||||||
using server = Bun.serve({
|
|
||||||
port: 0,
|
|
||||||
fetch() {
|
|
||||||
fetchCallCount++
|
|
||||||
return Response.json({ ok: true })
|
|
||||||
},
|
|
||||||
})
|
|
||||||
const type = unique("restore-local")
|
|
||||||
const info = workspaceInfo(Instance.project.id, type, { directory: dir })
|
|
||||||
insertWorkspace(info)
|
|
||||||
registerAdaptor(Instance.project.id, type, localAdaptor(dir).adaptor)
|
|
||||||
const session = await AppRuntime.runPromise(
|
|
||||||
SessionNs.Service.use((svc) => svc.create({ title: "restore local" })),
|
|
||||||
)
|
|
||||||
replaceSessionEvents(session.id, 20)
|
|
||||||
|
|
||||||
expect(await WorkspaceOld.sessionRestore({ workspaceID: info.id, sessionID: session.id })).toEqual({ total: 3 })
|
|
||||||
|
|
||||||
expect(fetchCallCount).toBe(0)
|
|
||||||
expect(replayAll).toHaveBeenCalledTimes(3)
|
|
||||||
expect(replayAll.mock.calls.map((call) => call[0].length)).toEqual([10, 10, 1])
|
|
||||||
expect((await AppRuntime.runPromise(SessionNs.Service.use((svc) => svc.get(session.id)))).workspaceID).toBe(
|
|
||||||
info.id,
|
|
||||||
)
|
|
||||||
expect(eventRows(session.id).map((row) => row.seq)).toEqual(Array.from({ length: 21 }, (_, i) => i))
|
|
||||||
expect(
|
|
||||||
captured.events
|
|
||||||
.filter((event) => event.workspace === info.id && event.payload.type === WorkspaceOld.Event.Restore.type)
|
|
||||||
.map((event) => event.payload.properties.step),
|
|
||||||
).toEqual([0, 1, 2, 3])
|
|
||||||
await WorkspaceOld.remove(info.id)
|
|
||||||
} finally {
|
|
||||||
captured.dispose()
|
|
||||||
}
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
it.live("session restore includes real message and part events in sequence order", () => {
|
|
||||||
const replay: FetchCall[] = []
|
|
||||||
return Effect.gen(function* () {
|
|
||||||
yield* HttpServer.serveEffect()(
|
|
||||||
Effect.gen(function* () {
|
|
||||||
const req = yield* HttpServerRequest.HttpServerRequest
|
|
||||||
const bodyText = yield* req.text
|
|
||||||
replay.push({
|
|
||||||
url: new URL(req.url, "http://localhost"),
|
|
||||||
method: req.method,
|
|
||||||
headers: new Headers(req.headers),
|
|
||||||
bodyText,
|
|
||||||
json: bodyText ? JSON.parse(bodyText) : undefined,
|
|
||||||
})
|
|
||||||
return HttpServerResponse.fromWeb(Response.json({ ok: true }))
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
const url = yield* serverUrl()
|
|
||||||
yield* provideTmpdirInstance(
|
|
||||||
(dir) =>
|
|
||||||
Effect.gen(function* () {
|
|
||||||
const workspace = yield* WorkspaceOld.Service
|
|
||||||
const sessionSvc = yield* SessionNs.Service
|
|
||||||
const type = unique("restore-real-events")
|
|
||||||
const info = workspaceInfo(Instance.project.id, type, { directory: dir })
|
|
||||||
insertWorkspace(info)
|
|
||||||
registerAdaptor(Instance.project.id, type, remoteAdaptor(`${url}/real`, { directory: dir }).adaptor)
|
|
||||||
const session = yield* sessionSvc.create({ title: "real events" })
|
|
||||||
for (let i = 0; i < 3; i++) {
|
|
||||||
const msg = yield* sessionSvc.updateMessage({
|
|
||||||
id: MessageID.ascending(),
|
|
||||||
role: "user",
|
|
||||||
sessionID: session.id,
|
|
||||||
agent: "build",
|
|
||||||
model: { providerID: ProviderID.make("test"), modelID: ModelID.make("test") },
|
|
||||||
time: { created: Date.now() },
|
|
||||||
})
|
|
||||||
yield* sessionSvc.updatePart({
|
|
||||||
id: PartID.ascending(),
|
|
||||||
sessionID: session.id,
|
|
||||||
messageID: msg.id,
|
|
||||||
type: "text",
|
|
||||||
text: `message ${i}`,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
const before = eventRows(session.id)
|
|
||||||
|
|
||||||
expect(yield* workspace.sessionRestore({ workspaceID: info.id, sessionID: session.id })).toEqual({
|
|
||||||
total: 1,
|
|
||||||
})
|
|
||||||
|
|
||||||
const posted = (replay[0].json as { events: Array<{ seq: number; type: string }> }).events
|
|
||||||
expect(posted.map((event) => event.seq)).toEqual([...before.map((row) => row.seq), before.at(-1)!.seq + 1])
|
|
||||||
expect(posted.map((event) => event.type).slice(0, -1)).toEqual(before.map((row) => row.type))
|
|
||||||
expect(posted.at(-1)?.type).toBe(sessionUpdatedType())
|
|
||||||
yield* workspace.remove(info.id)
|
|
||||||
}),
|
|
||||||
{ git: true },
|
|
||||||
)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
|
||||||
|
|
@ -179,18 +179,6 @@ describe("workspace HttpApi", () => {
|
||||||
const workspace = (await created.json()) as Workspace.Info
|
const workspace = (await 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 = await Instance.provide({
|
|
||||||
directory: tmp.path,
|
|
||||||
fn: async () => runSession(Session.Service.use((svc) => svc.create({}))),
|
|
||||||
})
|
|
||||||
const restored = await request(WorkspacePaths.sessionRestore.replace(":id", workspace.id), tmp.path, {
|
|
||||||
method: "POST",
|
|
||||||
headers: { "content-type": "application/json" },
|
|
||||||
body: JSON.stringify({ sessionID: session.id }),
|
|
||||||
})
|
|
||||||
expect(restored.status).toBe(200)
|
|
||||||
expect((await restored.json()) as { total: number }).toMatchObject({ total: expect.any(Number) })
|
|
||||||
|
|
||||||
const removed = await request(WorkspacePaths.remove.replace(":id", workspace.id), tmp.path, { method: "DELETE" })
|
const removed = await request(WorkspacePaths.remove.replace(":id", workspace.id), tmp.path, { method: "DELETE" })
|
||||||
expect(removed.status).toBe(200)
|
expect(removed.status).toBe(200)
|
||||||
expect(await removed.json()).toMatchObject({ id: workspace.id })
|
expect(await removed.json()).toMatchObject({ id: workspace.id })
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,7 @@ import { Bus } from "../../src/bus"
|
||||||
import { Instance } from "../../src/project/instance"
|
import { Instance } from "../../src/project/instance"
|
||||||
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 { Identifier } from "../../src/id/id"
|
import { Identifier } from "../../src/id/id"
|
||||||
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"
|
||||||
|
|
@ -233,5 +233,72 @@ describe("SyncEvent", () => {
|
||||||
expect(rows.map((row) => row.seq)).toEqual([0, 1, 2, 3])
|
expect(rows.map((row) => row.seq)).toEqual([0, 1, 2, 3])
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
test(
|
||||||
|
"claims unowned event sequence on replay with ownerID",
|
||||||
|
withInstance(() => {
|
||||||
|
const { Created } = setup()
|
||||||
|
const id = Identifier.descending("message")
|
||||||
|
|
||||||
|
SyncEvent.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" })
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
test(
|
||||||
|
"ignores replay from a different owner after sequence is claimed",
|
||||||
|
withInstance(() => {
|
||||||
|
const { Created } = setup()
|
||||||
|
const id = Identifier.descending("message")
|
||||||
|
|
||||||
|
SyncEvent.replay(
|
||||||
|
{
|
||||||
|
id: "evt_1",
|
||||||
|
type: SyncEvent.versionedType(Created.type, Created.version),
|
||||||
|
seq: 0,
|
||||||
|
aggregateID: id,
|
||||||
|
data: { id, name: "first" },
|
||||||
|
},
|
||||||
|
{ publish: false, ownerID: "owner-1" },
|
||||||
|
)
|
||||||
|
SyncEvent.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" })
|
||||||
|
}),
|
||||||
|
)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -32,12 +32,14 @@ import type {
|
||||||
ExperimentalWorkspaceAdaptorListResponses,
|
ExperimentalWorkspaceAdaptorListResponses,
|
||||||
ExperimentalWorkspaceCreateErrors,
|
ExperimentalWorkspaceCreateErrors,
|
||||||
ExperimentalWorkspaceCreateResponses,
|
ExperimentalWorkspaceCreateResponses,
|
||||||
|
ExperimentalWorkspaceDetachErrors,
|
||||||
|
ExperimentalWorkspaceDetachResponses,
|
||||||
ExperimentalWorkspaceListResponses,
|
ExperimentalWorkspaceListResponses,
|
||||||
ExperimentalWorkspaceRemoveErrors,
|
ExperimentalWorkspaceRemoveErrors,
|
||||||
ExperimentalWorkspaceRemoveResponses,
|
ExperimentalWorkspaceRemoveResponses,
|
||||||
ExperimentalWorkspaceSessionRestoreErrors,
|
|
||||||
ExperimentalWorkspaceSessionRestoreResponses,
|
|
||||||
ExperimentalWorkspaceStatusResponses,
|
ExperimentalWorkspaceStatusResponses,
|
||||||
|
ExperimentalWorkspaceWarpErrors,
|
||||||
|
ExperimentalWorkspaceWarpResponses,
|
||||||
FileListResponses,
|
FileListResponses,
|
||||||
FilePartInput,
|
FilePartInput,
|
||||||
FilePartSource,
|
FilePartSource,
|
||||||
|
|
@ -165,6 +167,8 @@ import type {
|
||||||
SyncReplayErrors,
|
SyncReplayErrors,
|
||||||
SyncReplayResponses,
|
SyncReplayResponses,
|
||||||
SyncStartResponses,
|
SyncStartResponses,
|
||||||
|
SyncStealErrors,
|
||||||
|
SyncStealResponses,
|
||||||
TextPartInput,
|
TextPartInput,
|
||||||
ToolIdsErrors,
|
ToolIdsErrors,
|
||||||
ToolIdsResponses,
|
ToolIdsResponses,
|
||||||
|
|
@ -652,6 +656,49 @@ export class Workspace extends HeyApiClient {
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Warp session into workspace
|
||||||
|
*
|
||||||
|
* Move a session's sync history into the target workspace, or detach it to the local project.
|
||||||
|
*/
|
||||||
|
public detach<ThrowOnError extends boolean = false>(
|
||||||
|
parameters?: {
|
||||||
|
directory?: string
|
||||||
|
workspace?: string
|
||||||
|
workspaceID?: string | null
|
||||||
|
sessionID?: string
|
||||||
|
},
|
||||||
|
options?: Options<never, ThrowOnError>,
|
||||||
|
) {
|
||||||
|
const params = buildClientParams(
|
||||||
|
[parameters],
|
||||||
|
[
|
||||||
|
{
|
||||||
|
args: [
|
||||||
|
{ in: "query", key: "directory" },
|
||||||
|
{ in: "query", key: "workspace" },
|
||||||
|
{ in: "body", key: "workspaceID" },
|
||||||
|
{ in: "body", key: "sessionID" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
)
|
||||||
|
return (options?.client ?? this.client).post<
|
||||||
|
ExperimentalWorkspaceDetachResponses,
|
||||||
|
ExperimentalWorkspaceDetachErrors,
|
||||||
|
ThrowOnError
|
||||||
|
>({
|
||||||
|
url: "/experimental/workspace/warp",
|
||||||
|
...options,
|
||||||
|
...params,
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
...options?.headers,
|
||||||
|
...params.headers,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Remove workspace
|
* Remove workspace
|
||||||
*
|
*
|
||||||
|
|
@ -689,11 +736,11 @@ export class Workspace extends HeyApiClient {
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Restore session into workspace
|
* Warp session into workspace
|
||||||
*
|
*
|
||||||
* Replay a session's sync events into the target workspace in batches.
|
* Move a session's sync history into the target workspace.
|
||||||
*/
|
*/
|
||||||
public sessionRestore<ThrowOnError extends boolean = false>(
|
public warp<ThrowOnError extends boolean = false>(
|
||||||
parameters: {
|
parameters: {
|
||||||
id: string
|
id: string
|
||||||
directory?: string
|
directory?: string
|
||||||
|
|
@ -716,11 +763,11 @@ export class Workspace extends HeyApiClient {
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
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/{id}/warp",
|
||||||
...options,
|
...options,
|
||||||
...params,
|
...params,
|
||||||
headers: {
|
headers: {
|
||||||
|
|
@ -3173,6 +3220,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 }))
|
||||||
|
|
|
||||||
|
|
@ -466,16 +466,6 @@ export type EventWorkspaceFailed = {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export type EventWorkspaceRestore = {
|
|
||||||
type: "workspace.restore"
|
|
||||||
properties: {
|
|
||||||
workspaceID: string
|
|
||||||
sessionID: string
|
|
||||||
total: number
|
|
||||||
step: number
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export type EventWorkspaceStatus = {
|
export type EventWorkspaceStatus = {
|
||||||
type: "workspace.status"
|
type: "workspace.status"
|
||||||
properties: {
|
properties: {
|
||||||
|
|
@ -1143,7 +1133,6 @@ export type GlobalEvent = {
|
||||||
| EventVcsBranchUpdated
|
| EventVcsBranchUpdated
|
||||||
| EventWorkspaceReady
|
| EventWorkspaceReady
|
||||||
| EventWorkspaceFailed
|
| EventWorkspaceFailed
|
||||||
| EventWorkspaceRestore
|
|
||||||
| EventWorkspaceStatus
|
| EventWorkspaceStatus
|
||||||
| EventWorktreeReady
|
| EventWorktreeReady
|
||||||
| EventWorktreeFailed
|
| EventWorktreeFailed
|
||||||
|
|
@ -2086,7 +2075,6 @@ export type Event =
|
||||||
| EventVcsBranchUpdated
|
| EventVcsBranchUpdated
|
||||||
| EventWorkspaceReady
|
| EventWorkspaceReady
|
||||||
| EventWorkspaceFailed
|
| EventWorkspaceFailed
|
||||||
| EventWorkspaceRestore
|
|
||||||
| EventWorkspaceStatus
|
| EventWorkspaceStatus
|
||||||
| EventWorktreeReady
|
| EventWorktreeReady
|
||||||
| EventWorktreeFailed
|
| EventWorktreeFailed
|
||||||
|
|
@ -2532,6 +2520,39 @@ export type ExperimentalWorkspaceStatusResponses = {
|
||||||
export type ExperimentalWorkspaceStatusResponse =
|
export type ExperimentalWorkspaceStatusResponse =
|
||||||
ExperimentalWorkspaceStatusResponses[keyof ExperimentalWorkspaceStatusResponses]
|
ExperimentalWorkspaceStatusResponses[keyof ExperimentalWorkspaceStatusResponses]
|
||||||
|
|
||||||
|
export type ExperimentalWorkspaceDetachData = {
|
||||||
|
body?: {
|
||||||
|
workspaceID: string | null
|
||||||
|
sessionID: string
|
||||||
|
}
|
||||||
|
path?: never
|
||||||
|
query?: {
|
||||||
|
directory?: string
|
||||||
|
workspace?: string
|
||||||
|
}
|
||||||
|
url: "/experimental/workspace/warp"
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ExperimentalWorkspaceDetachErrors = {
|
||||||
|
/**
|
||||||
|
* Bad request
|
||||||
|
*/
|
||||||
|
400: BadRequestError
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ExperimentalWorkspaceDetachError =
|
||||||
|
ExperimentalWorkspaceDetachErrors[keyof ExperimentalWorkspaceDetachErrors]
|
||||||
|
|
||||||
|
export type ExperimentalWorkspaceDetachResponses = {
|
||||||
|
/**
|
||||||
|
* Session warped
|
||||||
|
*/
|
||||||
|
204: void
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ExperimentalWorkspaceDetachResponse =
|
||||||
|
ExperimentalWorkspaceDetachResponses[keyof ExperimentalWorkspaceDetachResponses]
|
||||||
|
|
||||||
export type ExperimentalWorkspaceRemoveData = {
|
export type ExperimentalWorkspaceRemoveData = {
|
||||||
body?: never
|
body?: never
|
||||||
path: {
|
path: {
|
||||||
|
|
@ -2564,7 +2585,7 @@ export type ExperimentalWorkspaceRemoveResponses = {
|
||||||
export type ExperimentalWorkspaceRemoveResponse =
|
export type ExperimentalWorkspaceRemoveResponse =
|
||||||
ExperimentalWorkspaceRemoveResponses[keyof ExperimentalWorkspaceRemoveResponses]
|
ExperimentalWorkspaceRemoveResponses[keyof ExperimentalWorkspaceRemoveResponses]
|
||||||
|
|
||||||
export type ExperimentalWorkspaceSessionRestoreData = {
|
export type ExperimentalWorkspaceWarpData = {
|
||||||
body?: {
|
body?: {
|
||||||
sessionID: string
|
sessionID: string
|
||||||
}
|
}
|
||||||
|
|
@ -2575,30 +2596,27 @@ export type ExperimentalWorkspaceSessionRestoreData = {
|
||||||
directory?: string
|
directory?: string
|
||||||
workspace?: string
|
workspace?: string
|
||||||
}
|
}
|
||||||
url: "/experimental/workspace/{id}/session-restore"
|
url: "/experimental/workspace/{id}/warp"
|
||||||
}
|
}
|
||||||
|
|
||||||
export type ExperimentalWorkspaceSessionRestoreErrors = {
|
export type ExperimentalWorkspaceWarpErrors = {
|
||||||
/**
|
/**
|
||||||
* Bad request
|
* Bad request
|
||||||
*/
|
*/
|
||||||
400: BadRequestError
|
400: BadRequestError
|
||||||
}
|
}
|
||||||
|
|
||||||
export type ExperimentalWorkspaceSessionRestoreError =
|
export type ExperimentalWorkspaceWarpError = ExperimentalWorkspaceWarpErrors[keyof ExperimentalWorkspaceWarpErrors]
|
||||||
ExperimentalWorkspaceSessionRestoreErrors[keyof ExperimentalWorkspaceSessionRestoreErrors]
|
|
||||||
|
|
||||||
export type ExperimentalWorkspaceSessionRestoreResponses = {
|
export type ExperimentalWorkspaceWarpResponses = {
|
||||||
/**
|
/**
|
||||||
* Session replay started
|
* Session warped
|
||||||
*/
|
*/
|
||||||
200: {
|
204: void
|
||||||
total: number
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export type ExperimentalWorkspaceSessionRestoreResponse =
|
export type ExperimentalWorkspaceWarpResponse =
|
||||||
ExperimentalWorkspaceSessionRestoreResponses[keyof ExperimentalWorkspaceSessionRestoreResponses]
|
ExperimentalWorkspaceWarpResponses[keyof ExperimentalWorkspaceWarpResponses]
|
||||||
|
|
||||||
export type ProjectListData = {
|
export type ProjectListData = {
|
||||||
body?: never
|
body?: never
|
||||||
|
|
@ -4626,6 +4644,38 @@ export type SyncReplayResponses = {
|
||||||
|
|
||||||
export type SyncReplayResponse = SyncReplayResponses[keyof SyncReplayResponses]
|
export type SyncReplayResponse = SyncReplayResponses[keyof SyncReplayResponses]
|
||||||
|
|
||||||
|
export type SyncStealData = {
|
||||||
|
body?: {
|
||||||
|
sessionID: string
|
||||||
|
}
|
||||||
|
path?: never
|
||||||
|
query?: {
|
||||||
|
directory?: string
|
||||||
|
workspace?: string
|
||||||
|
}
|
||||||
|
url: "/sync/steal"
|
||||||
|
}
|
||||||
|
|
||||||
|
export type SyncStealErrors = {
|
||||||
|
/**
|
||||||
|
* Bad request
|
||||||
|
*/
|
||||||
|
400: BadRequestError
|
||||||
|
}
|
||||||
|
|
||||||
|
export type SyncStealError = SyncStealErrors[keyof SyncStealErrors]
|
||||||
|
|
||||||
|
export type SyncStealResponses = {
|
||||||
|
/**
|
||||||
|
* Session stolen into workspace
|
||||||
|
*/
|
||||||
|
200: {
|
||||||
|
sessionID: string
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export type SyncStealResponse = SyncStealResponses[keyof SyncStealResponses]
|
||||||
|
|
||||||
export type SyncHistoryListData = {
|
export type SyncHistoryListData = {
|
||||||
body?: {
|
body?: {
|
||||||
[key: string]: number
|
[key: string]: number
|
||||||
|
|
|
||||||
|
|
@ -5448,6 +5448,80 @@
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"/sync/steal": {
|
||||||
|
"post": {
|
||||||
|
"operationId": "sync.steal",
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"in": "query",
|
||||||
|
"name": "directory",
|
||||||
|
"schema": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"in": "query",
|
||||||
|
"name": "workspace",
|
||||||
|
"schema": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"summary": "Steal session into workspace",
|
||||||
|
"description": "Update a session to belong to the current workspace through the sync event system.",
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "Session stolen into workspace",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"sessionID": {
|
||||||
|
"type": "string",
|
||||||
|
"pattern": "^ses.*"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": ["sessionID"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"400": {
|
||||||
|
"description": "Bad request",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/BadRequestError"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"requestBody": {
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"sessionID": {
|
||||||
|
"type": "string",
|
||||||
|
"pattern": "^ses.*"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": ["sessionID"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"x-codeSamples": [
|
||||||
|
{
|
||||||
|
"lang": "js",
|
||||||
|
"source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.sync.steal({\n ...\n})"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
"/sync/history": {
|
"/sync/history": {
|
||||||
"post": {
|
"post": {
|
||||||
"operationId": "sync.history.list",
|
"operationId": "sync.history.list",
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue