feat(core): copy file changes when warping (#26190)
This commit is contained in:
@@ -12,7 +12,11 @@ import { Flag } from "@opencode-ai/core/flag/flag"
|
|||||||
import { DialogSessionRename } from "./dialog-session-rename"
|
import { DialogSessionRename } from "./dialog-session-rename"
|
||||||
import { createDebouncedSignal } from "../util/signal"
|
import { createDebouncedSignal } from "../util/signal"
|
||||||
import { useToast } from "../ui/toast"
|
import { useToast } from "../ui/toast"
|
||||||
import { openWorkspaceSelect, type WorkspaceSelection, warpWorkspaceSession } 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"
|
||||||
@@ -70,8 +74,10 @@ export function DialogSessionList() {
|
|||||||
sync,
|
sync,
|
||||||
project,
|
project,
|
||||||
toast,
|
toast,
|
||||||
|
sourceWorkspaceID: session.workspaceID,
|
||||||
workspaceID,
|
workspaceID,
|
||||||
sessionID: session.id,
|
sessionID: session.id,
|
||||||
|
copyChanges: false,
|
||||||
done: list,
|
done: list,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,10 +3,13 @@ import { useDialog } from "@tui/ui/dialog"
|
|||||||
import { DialogSelect, type DialogSelectOption } from "@tui/ui/dialog-select"
|
import { DialogSelect, type DialogSelectOption } from "@tui/ui/dialog-select"
|
||||||
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 { useRoute } from "@tui/context/route"
|
||||||
import { createMemo, createSignal, onMount } from "solid-js"
|
import { createMemo, createSignal, onMount } from "solid-js"
|
||||||
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"
|
||||||
|
import { DialogAlert } from "../ui/dialog-alert"
|
||||||
|
import { DialogWorkspaceFileChanges } from "./dialog-workspace-file-changes"
|
||||||
|
|
||||||
type Adapter = {
|
type Adapter = {
|
||||||
type: string
|
type: string
|
||||||
@@ -38,6 +41,7 @@ export function recentConnectedWorkspaces<WorkspaceInfo extends { id: string }>(
|
|||||||
get: (workspaceID: string) => WorkspaceInfo | undefined
|
get: (workspaceID: string) => WorkspaceInfo | undefined
|
||||||
status: (workspaceID: string) => string | undefined
|
status: (workspaceID: string) => string | undefined
|
||||||
limit?: number
|
limit?: number
|
||||||
|
omitWorkspaceID?: string
|
||||||
}) {
|
}) {
|
||||||
const workspaces = input.sessions
|
const workspaces = input.sessions
|
||||||
.toSorted((a, b) => b.time.updated - a.time.updated)
|
.toSorted((a, b) => b.time.updated - a.time.updated)
|
||||||
@@ -45,6 +49,7 @@ export function recentConnectedWorkspaces<WorkspaceInfo extends { id: string }>(
|
|||||||
const workspace = session.workspaceID ? input.get(session.workspaceID) : undefined
|
const workspace = session.workspaceID ? input.get(session.workspaceID) : undefined
|
||||||
return workspace && input.status(workspace.id) === "connected" ? [workspace] : []
|
return workspace && input.status(workspace.id) === "connected" ? [workspace] : []
|
||||||
})
|
})
|
||||||
|
.filter((workspace) => workspace.id !== input.omitWorkspaceID)
|
||||||
.filter((workspace, index, list) => list.findIndex((item) => item.id === workspace.id) === index)
|
.filter((workspace, index, list) => list.findIndex((item) => item.id === workspace.id) === index)
|
||||||
const recent = workspaces.slice(0, input.limit ?? 3)
|
const recent = workspaces.slice(0, input.limit ?? 3)
|
||||||
|
|
||||||
@@ -93,17 +98,29 @@ export async function warpWorkspaceSession(input: {
|
|||||||
sync: ReturnType<typeof useSync>
|
sync: ReturnType<typeof useSync>
|
||||||
project: ReturnType<typeof useProject>
|
project: ReturnType<typeof useProject>
|
||||||
toast: ReturnType<typeof useToast>
|
toast: ReturnType<typeof useToast>
|
||||||
|
sourceWorkspaceID?: string
|
||||||
workspaceID: string | null
|
workspaceID: string | null
|
||||||
sessionID: string
|
sessionID: string
|
||||||
|
copyChanges: boolean
|
||||||
done?: () => void
|
done?: () => void
|
||||||
}): Promise<boolean> {
|
}): Promise<boolean> {
|
||||||
const result = await input.sdk.client.experimental.workspace
|
const result = await input.sdk.client.experimental.workspace
|
||||||
.warp({
|
.warp({
|
||||||
id: input.workspaceID,
|
id: input.workspaceID,
|
||||||
sessionID: input.sessionID,
|
sessionID: input.sessionID,
|
||||||
|
copyChanges: input.copyChanges,
|
||||||
})
|
})
|
||||||
.catch(() => undefined)
|
.catch(() => undefined)
|
||||||
if (!result?.data) {
|
if (!result?.data) {
|
||||||
|
if (result?.error?.name === "VcsApplyError") {
|
||||||
|
await DialogAlert.show(
|
||||||
|
input.dialog,
|
||||||
|
"Unable to Warp Session",
|
||||||
|
"Unable to apply file changes to this workspace. It has existing changes that conflict or is based off a different branch. Session has not been warped.",
|
||||||
|
)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
input.toast.show({
|
input.toast.show({
|
||||||
message: `Failed to warp session: ${errorMessage(result?.error ?? "no response")}`,
|
message: `Failed to warp session: ${errorMessage(result?.error ?? "no response")}`,
|
||||||
variant: "error",
|
variant: "error",
|
||||||
@@ -143,16 +160,29 @@ export async function warpWorkspaceSession(input: {
|
|||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function confirmWorkspaceFileChanges(input: {
|
||||||
|
dialog: ReturnType<typeof useDialog>
|
||||||
|
sdk: ReturnType<typeof useSDK>
|
||||||
|
sourceWorkspaceID?: string
|
||||||
|
}) {
|
||||||
|
const status = await input.sdk.client.vcs.status({ workspace: input.sourceWorkspaceID }).catch(() => undefined)
|
||||||
|
const fileChangeChoice = status?.data?.length ? await DialogWorkspaceFileChanges.show(input.dialog, status.data) : "no"
|
||||||
|
if (!fileChangeChoice) return
|
||||||
|
return fileChangeChoice === "yes"
|
||||||
|
}
|
||||||
|
|
||||||
export function DialogWorkspaceSelect(props: {
|
export function DialogWorkspaceSelect(props: {
|
||||||
adapters?: Adapter[]
|
adapters?: Adapter[]
|
||||||
onSelect: (selection: WorkspaceSelection) => Promise<void> | void
|
onSelect: (selection: WorkspaceSelection) => Promise<void> | void
|
||||||
}) {
|
}) {
|
||||||
const dialog = useDialog()
|
const dialog = useDialog()
|
||||||
const project = useProject()
|
const project = useProject()
|
||||||
|
const route = useRoute()
|
||||||
const sync = useSync()
|
const sync = useSync()
|
||||||
const sdk = useSDK()
|
const sdk = useSDK()
|
||||||
const toast = useToast()
|
const toast = useToast()
|
||||||
const [adapters, setAdapters] = createSignal<Adapter[] | undefined>(props.adapters)
|
const [adapters, setAdapters] = createSignal<Adapter[] | undefined>(props.adapters)
|
||||||
|
const omittedWorkspaceID = createMemo(() => (route.data.type === "session" ? project.workspace.current() : undefined))
|
||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
dialog.setSize("medium")
|
dialog.setSize("medium")
|
||||||
@@ -171,6 +201,7 @@ export function DialogWorkspaceSelect(props: {
|
|||||||
sessions: sync.data.session,
|
sessions: sync.data.session,
|
||||||
get: project.workspace.get,
|
get: project.workspace.get,
|
||||||
status: project.workspace.status,
|
status: project.workspace.status,
|
||||||
|
omitWorkspaceID: omittedWorkspaceID(),
|
||||||
})
|
})
|
||||||
return [
|
return [
|
||||||
...list.map((adapter) => ({
|
...list.map((adapter) => ({
|
||||||
@@ -231,19 +262,23 @@ export function DialogWorkspaceSelect(props: {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
dialog.replace(() => <DialogExistingWorkspaceSelect onSelect={props.onSelect} />)
|
dialog.replace(() => <DialogExistingWorkspaceSelect omitWorkspaceID={omittedWorkspaceID()} onSelect={props.onSelect} />)
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function DialogExistingWorkspaceSelect(props: { onSelect: (selection: WorkspaceSelection) => Promise<void> | void }) {
|
function DialogExistingWorkspaceSelect(props: {
|
||||||
|
omitWorkspaceID?: string
|
||||||
|
onSelect: (selection: WorkspaceSelection) => Promise<void> | void
|
||||||
|
}) {
|
||||||
const project = useProject()
|
const project = useProject()
|
||||||
|
|
||||||
const options = createMemo<DialogSelectOption<ExistingWorkspaceSelectValue>[]>(() =>
|
const options = createMemo<DialogSelectOption<ExistingWorkspaceSelectValue>[]>(() =>
|
||||||
project.workspace
|
project.workspace
|
||||||
.list()
|
.list()
|
||||||
.filter((workspace) => project.workspace.status(workspace.id) === "connected")
|
.filter((workspace) => project.workspace.status(workspace.id) === "connected")
|
||||||
|
.filter((workspace) => workspace.id !== props.omitWorkspaceID)
|
||||||
.map((workspace: Workspace) => ({
|
.map((workspace: Workspace) => ({
|
||||||
title: workspace.name,
|
title: workspace.name,
|
||||||
description: `(${workspace.type})`,
|
description: `(${workspace.type})`,
|
||||||
|
|||||||
@@ -0,0 +1,138 @@
|
|||||||
|
import { TextAttributes } from "@opentui/core"
|
||||||
|
import { useKeyboard } from "@opentui/solid"
|
||||||
|
import type { VcsFileStatus } from "@opencode-ai/sdk/v2"
|
||||||
|
import { createMemo, For } from "solid-js"
|
||||||
|
import { createStore } from "solid-js/store"
|
||||||
|
import { Locale } from "@/util/locale"
|
||||||
|
import { useTheme } from "../context/theme"
|
||||||
|
import { useTuiConfig } from "../context/tui-config"
|
||||||
|
import { useDialog, type DialogContext } from "../ui/dialog"
|
||||||
|
import { getScrollAcceleration } from "../util/scroll"
|
||||||
|
|
||||||
|
const options = ["no", "yes"] as const
|
||||||
|
|
||||||
|
export type WorkspaceFileChangesChoice = (typeof options)[number]
|
||||||
|
|
||||||
|
function statusLabel(status: VcsFileStatus["status"]) {
|
||||||
|
if (status === "added") return "A"
|
||||||
|
if (status === "deleted") return "D"
|
||||||
|
return "M"
|
||||||
|
}
|
||||||
|
|
||||||
|
function changeCountWidth(file: VcsFileStatus) {
|
||||||
|
// The "plus 2" is for spaces
|
||||||
|
return `${file.additions ? `+${file.additions}` : ""}${file.deletions ? ` -${file.deletions}` : ""}`.length + 2
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DialogWorkspaceFileChanges(props: {
|
||||||
|
files: VcsFileStatus[]
|
||||||
|
onSelect: (choice: WorkspaceFileChangesChoice) => void
|
||||||
|
}) {
|
||||||
|
const dialog = useDialog()
|
||||||
|
const { theme } = useTheme()
|
||||||
|
const tuiConfig = useTuiConfig()
|
||||||
|
const scrollAcceleration = createMemo(() => getScrollAcceleration(tuiConfig))
|
||||||
|
const [store, setStore] = createStore({ active: "yes" as WorkspaceFileChangesChoice })
|
||||||
|
const height = createMemo(() => Math.min(props.files.length, 8))
|
||||||
|
const fileNameWidth = createMemo(() => 48 - Math.max(Math.max(7, ...props.files.map(changeCountWidth)) - 7, 0))
|
||||||
|
|
||||||
|
function confirm() {
|
||||||
|
props.onSelect(store.active)
|
||||||
|
dialog.clear()
|
||||||
|
}
|
||||||
|
|
||||||
|
useKeyboard((evt) => {
|
||||||
|
if (evt.name === "return") {
|
||||||
|
evt.preventDefault()
|
||||||
|
evt.stopPropagation()
|
||||||
|
confirm()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (evt.name === "left") {
|
||||||
|
evt.preventDefault()
|
||||||
|
evt.stopPropagation()
|
||||||
|
const index = options.indexOf(store.active)
|
||||||
|
setStore("active", options[Math.max(index - 1, 0)])
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (evt.name === "right") {
|
||||||
|
evt.preventDefault()
|
||||||
|
evt.stopPropagation()
|
||||||
|
const index = options.indexOf(store.active)
|
||||||
|
setStore("active", options[Math.min(index + 1, options.length - 1)])
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return (
|
||||||
|
<box gap={1}>
|
||||||
|
<box flexDirection="row" justifyContent="space-between" paddingLeft={2} paddingRight={2}>
|
||||||
|
<text attributes={TextAttributes.BOLD} fg={theme.text}>
|
||||||
|
File Changes Found
|
||||||
|
</text>
|
||||||
|
<text fg={theme.textMuted} onMouseUp={() => dialog.clear()}>
|
||||||
|
esc
|
||||||
|
</text>
|
||||||
|
</box>
|
||||||
|
<scrollbox
|
||||||
|
height={height()}
|
||||||
|
backgroundColor={theme.backgroundElement}
|
||||||
|
scrollbarOptions={{ visible: false }}
|
||||||
|
scrollAcceleration={scrollAcceleration()}
|
||||||
|
>
|
||||||
|
<For each={props.files}>
|
||||||
|
{(item) => (
|
||||||
|
<box flexDirection="row" justifyContent="space-between" paddingLeft={2} paddingRight={2}>
|
||||||
|
<box flexDirection="row" minWidth={0} flexShrink={1}>
|
||||||
|
<box width={2} flexShrink={0}>
|
||||||
|
<text fg={theme.textMuted}>{statusLabel(item.status)}</text>
|
||||||
|
</box>
|
||||||
|
<text fg={theme.textMuted} wrapMode="none">
|
||||||
|
{Locale.truncateLeft(item.file, fileNameWidth())}
|
||||||
|
</text>
|
||||||
|
</box>
|
||||||
|
<box flexDirection="row" gap={1} minWidth={7} flexShrink={0} justifyContent="flex-end">
|
||||||
|
<text>
|
||||||
|
{" "}
|
||||||
|
{item.additions ? <span style={{ fg: theme.diffAdded }}>+{item.additions}</span> : null}
|
||||||
|
{item.deletions ? <span style={{ fg: theme.diffRemoved }}> -{item.deletions}</span> : null}
|
||||||
|
</text>
|
||||||
|
</box>
|
||||||
|
</box>
|
||||||
|
)}
|
||||||
|
</For>
|
||||||
|
</scrollbox>
|
||||||
|
<box paddingLeft={2} paddingRight={2}>
|
||||||
|
<text fg={theme.textMuted} wrapMode="word">
|
||||||
|
Do you want to apply these changes after warping?
|
||||||
|
</text>
|
||||||
|
</box>
|
||||||
|
<box flexDirection="row" justifyContent="flex-end" paddingLeft={2} paddingRight={2} paddingBottom={1}>
|
||||||
|
<For each={options}>
|
||||||
|
{(item) => (
|
||||||
|
<box
|
||||||
|
paddingLeft={2}
|
||||||
|
paddingRight={2}
|
||||||
|
backgroundColor={item === store.active ? theme.primary : undefined}
|
||||||
|
onMouseUp={() => {
|
||||||
|
setStore("active", item)
|
||||||
|
props.onSelect(item)
|
||||||
|
dialog.clear()
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<text fg={item === store.active ? theme.selectedListItemText : theme.textMuted}>{item}</text>
|
||||||
|
</box>
|
||||||
|
)}
|
||||||
|
</For>
|
||||||
|
</box>
|
||||||
|
</box>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
DialogWorkspaceFileChanges.show = (dialog: DialogContext, files: VcsFileStatus[]) => {
|
||||||
|
return new Promise<WorkspaceFileChangesChoice | undefined>((resolve) => {
|
||||||
|
dialog.replace(
|
||||||
|
() => <DialogWorkspaceFileChanges files={files} onSelect={resolve} />,
|
||||||
|
() => resolve(undefined),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -42,7 +42,12 @@ 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 { openWorkspaceSelect, warpWorkspaceSession, type WorkspaceSelection } from "../dialog-workspace-create"
|
import {
|
||||||
|
confirmWorkspaceFileChanges,
|
||||||
|
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 { Flag } from "@opencode-ai/core/flag/flag"
|
||||||
@@ -230,6 +235,9 @@ export function Prompt(props: PromptProps) {
|
|||||||
if (selection.type === "new") void createWorkspace(selection)
|
if (selection.type === "new") void createWorkspace(selection)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
const sourceWorkspaceID = project.workspace.current()
|
||||||
|
const copyChanges = await confirmWorkspaceFileChanges({ dialog, sdk, sourceWorkspaceID })
|
||||||
|
if (copyChanges === undefined) return
|
||||||
selectWorkspace(selection)
|
selectWorkspace(selection)
|
||||||
dialog.clear()
|
dialog.clear()
|
||||||
|
|
||||||
@@ -247,8 +255,10 @@ export function Prompt(props: PromptProps) {
|
|||||||
sync,
|
sync,
|
||||||
project,
|
project,
|
||||||
toast,
|
toast,
|
||||||
|
sourceWorkspaceID,
|
||||||
workspaceID: workspace.id,
|
workspaceID: workspace.id,
|
||||||
sessionID: props.sessionID,
|
sessionID: props.sessionID,
|
||||||
|
copyChanges,
|
||||||
})
|
})
|
||||||
if (warped) showWarpNotice(workspace.name)
|
if (warped) showWarpNotice(workspace.name)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ import { ProjectID } from "@/project/schema"
|
|||||||
import { Slug } from "@opencode-ai/core/util/slug"
|
import { Slug } from "@opencode-ai/core/util/slug"
|
||||||
import { WorkspaceTable } from "./workspace.sql"
|
import { WorkspaceTable } from "./workspace.sql"
|
||||||
import { getAdapter } from "./adapters"
|
import { getAdapter } from "./adapters"
|
||||||
import { type WorkspaceInfo, WorkspaceInfo as WorkspaceInfoSchema } from "./types"
|
import { type Target, 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 { SessionPrompt } from "@/session/prompt"
|
||||||
@@ -31,6 +31,9 @@ import { WorkspaceContext } from "./workspace-context"
|
|||||||
import { EffectBridge } from "@/effect/bridge"
|
import { EffectBridge } from "@/effect/bridge"
|
||||||
import { 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"
|
||||||
|
import { Vcs } from "@/project/vcs"
|
||||||
|
import { InstanceStore } from "@/project/instance-store"
|
||||||
|
import { InstanceBootstrap } from "@/project/bootstrap"
|
||||||
|
|
||||||
export const Info = WorkspaceInfoSchema
|
export const Info = WorkspaceInfoSchema
|
||||||
export type Info = WorkspaceInfo
|
export type Info = WorkspaceInfo
|
||||||
@@ -86,6 +89,7 @@ export type CreateInput = Schema.Schema.Type<typeof CreateInput>
|
|||||||
export const SessionWarpInput = Schema.Struct({
|
export const SessionWarpInput = Schema.Struct({
|
||||||
workspaceID: Schema.NullOr(WorkspaceID),
|
workspaceID: Schema.NullOr(WorkspaceID),
|
||||||
sessionID: SessionID,
|
sessionID: SessionID,
|
||||||
|
copyChanges: Schema.optional(Schema.Boolean),
|
||||||
}).pipe(withStatics((s) => ({ zod: effectZod(s), zodObject: zodObject(s) })))
|
}).pipe(withStatics((s) => ({ zod: effectZod(s), zodObject: zodObject(s) })))
|
||||||
export type SessionWarpInput = Schema.Schema.Type<typeof SessionWarpInput>
|
export type SessionWarpInput = Schema.Schema.Type<typeof SessionWarpInput>
|
||||||
|
|
||||||
@@ -137,6 +141,7 @@ type SessionWarpError =
|
|||||||
| WorkspaceNotFoundError
|
| WorkspaceNotFoundError
|
||||||
| SessionEventsNotFoundError
|
| SessionEventsNotFoundError
|
||||||
| SessionWarpHttpError
|
| SessionWarpHttpError
|
||||||
|
| Vcs.PatchApplyError
|
||||||
| HttpClientError.HttpClientError
|
| HttpClientError.HttpClientError
|
||||||
type WaitForSyncError = SyncTimeoutError | SyncAbortedError
|
type WaitForSyncError = SyncTimeoutError | SyncAbortedError
|
||||||
type SyncLoopError = SyncHttpError | HttpClientError.HttpClientError
|
type SyncLoopError = SyncHttpError | HttpClientError.HttpClientError
|
||||||
@@ -167,6 +172,7 @@ export const layer = Layer.effect(
|
|||||||
const prompt = yield* SessionPrompt.Service
|
const prompt = yield* SessionPrompt.Service
|
||||||
const http = yield* HttpClient.HttpClient
|
const http = yield* HttpClient.HttpClient
|
||||||
const sync = yield* SyncEvent.Service
|
const sync = yield* SyncEvent.Service
|
||||||
|
const vcs = yield* Vcs.Service
|
||||||
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>()
|
||||||
|
|
||||||
@@ -255,6 +261,66 @@ export const layer = Layer.effect(
|
|||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const runInWorkspace = <A, E, R>(input: {
|
||||||
|
workspaceID?: WorkspaceID
|
||||||
|
local: () => Effect.Effect<A, E, R>
|
||||||
|
remote: (input: {
|
||||||
|
workspace: Info
|
||||||
|
target: Extract<Target, { type: "remote" }>
|
||||||
|
}) => HttpClientRequest.HttpClientRequest
|
||||||
|
fallback: A
|
||||||
|
response?: "json" | "text"
|
||||||
|
}) =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
if (!input.workspaceID) return yield* input.local()
|
||||||
|
|
||||||
|
const workspace = yield* get(input.workspaceID)
|
||||||
|
if (!workspace) return input.fallback
|
||||||
|
|
||||||
|
const adapter = getAdapter(workspace.projectID, workspace.type)
|
||||||
|
const target = yield* EffectBridge.fromPromise(() => adapter.target(workspace))
|
||||||
|
|
||||||
|
if (target.type === "local") {
|
||||||
|
const store = yield* InstanceStore.Service
|
||||||
|
return yield* store.provide({ directory: target.directory }, input.local())
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = yield* http.execute(input.remote({ workspace, target })).pipe(
|
||||||
|
Effect.catch((error) =>
|
||||||
|
Effect.sync(() => {
|
||||||
|
log.warn("workspace target request failed", {
|
||||||
|
workspaceID: workspace.id,
|
||||||
|
error: errorData(error),
|
||||||
|
})
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
if (!response) return input.fallback
|
||||||
|
if (response.status < 200 || response.status >= 300) {
|
||||||
|
const body = yield* response.text.pipe(Effect.catch(() => Effect.succeed("")))
|
||||||
|
log.warn("workspace target request failed", {
|
||||||
|
workspaceID: workspace.id,
|
||||||
|
status: response.status,
|
||||||
|
body,
|
||||||
|
})
|
||||||
|
return input.fallback
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = input.response === "text" ? response.text : response.json
|
||||||
|
return yield* body.pipe(
|
||||||
|
Effect.map((result) => result as A),
|
||||||
|
Effect.catch((error) =>
|
||||||
|
Effect.sync(() => {
|
||||||
|
log.warn("workspace target response decode failed", {
|
||||||
|
workspaceID: workspace.id,
|
||||||
|
error: errorData(error),
|
||||||
|
})
|
||||||
|
return input.fallback
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
const syncHistory = Effect.fn("Workspace.syncHistory")(function* (
|
const syncHistory = Effect.fn("Workspace.syncHistory")(function* (
|
||||||
space: Info,
|
space: Info,
|
||||||
url: URL | string,
|
url: URL | string,
|
||||||
@@ -557,6 +623,36 @@ export const layer = Layer.effect(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const sourcePatch =
|
||||||
|
input.copyChanges && current?.workspaceID
|
||||||
|
? yield* runInWorkspace({
|
||||||
|
workspaceID: current?.workspaceID ?? undefined,
|
||||||
|
local: () => vcs.diffRaw(),
|
||||||
|
remote: ({ target }) =>
|
||||||
|
HttpClientRequest.get(route(target.url, "/vcs/diff/raw"), {
|
||||||
|
headers: new Headers(target.headers),
|
||||||
|
}),
|
||||||
|
fallback: "",
|
||||||
|
response: "text",
|
||||||
|
}).pipe(Effect.provide(InstanceStore.defaultLayer.pipe(Layer.provide(InstanceBootstrap.defaultLayer))))
|
||||||
|
: ""
|
||||||
|
|
||||||
|
if (sourcePatch) {
|
||||||
|
// Attempt to apply the file changes to the new workspace.
|
||||||
|
// We intentionally do first so if it fails we don't warp
|
||||||
|
// the session.
|
||||||
|
yield* runInWorkspace({
|
||||||
|
workspaceID: input.workspaceID ?? undefined,
|
||||||
|
local: () => vcs.apply({ patch: sourcePatch }),
|
||||||
|
remote: ({ target }) =>
|
||||||
|
HttpClientRequest.post(route(target.url, "/vcs/apply"), {
|
||||||
|
headers: new Headers(target.headers),
|
||||||
|
body: HttpBody.jsonUnsafe({ patch: sourcePatch }),
|
||||||
|
}),
|
||||||
|
fallback: { applied: false },
|
||||||
|
}).pipe(Effect.provide(InstanceStore.defaultLayer.pipe(Layer.provide(InstanceBootstrap.defaultLayer))))
|
||||||
|
}
|
||||||
|
|
||||||
if (input.workspaceID === null) {
|
if (input.workspaceID === null) {
|
||||||
yield* Effect.sync(() =>
|
yield* Effect.sync(() =>
|
||||||
SyncEvent.run(Session.Event.Updated, {
|
SyncEvent.run(Session.Event.Updated, {
|
||||||
@@ -866,6 +962,8 @@ export const defaultLayer = layer.pipe(
|
|||||||
Layer.provide(Session.defaultLayer),
|
Layer.provide(Session.defaultLayer),
|
||||||
Layer.provide(SyncEvent.defaultLayer),
|
Layer.provide(SyncEvent.defaultLayer),
|
||||||
Layer.provide(SessionPrompt.defaultLayer),
|
Layer.provide(SessionPrompt.defaultLayer),
|
||||||
|
Layer.provide(Project.defaultLayer),
|
||||||
|
Layer.provide(Vcs.defaultLayer),
|
||||||
Layer.provide(FetchHttpClient.layer),
|
Layer.provide(FetchHttpClient.layer),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -68,6 +68,7 @@ export interface Options {
|
|||||||
readonly cwd: string
|
readonly cwd: string
|
||||||
readonly env?: Record<string, string>
|
readonly env?: Record<string, string>
|
||||||
readonly maxOutputBytes?: number
|
readonly maxOutputBytes?: number
|
||||||
|
readonly stdin?: ChildProcess.CommandInput
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Interface {
|
export interface Interface {
|
||||||
@@ -85,6 +86,7 @@ export interface Interface {
|
|||||||
readonly patchAll: (cwd: string, ref: string, options?: PatchOptions) => Effect.Effect<Patch>
|
readonly patchAll: (cwd: string, ref: string, options?: PatchOptions) => Effect.Effect<Patch>
|
||||||
readonly patchUntracked: (cwd: string, file: string, options?: PatchOptions) => Effect.Effect<Patch>
|
readonly patchUntracked: (cwd: string, file: string, options?: PatchOptions) => Effect.Effect<Patch>
|
||||||
readonly statUntracked: (cwd: string, file: string) => Effect.Effect<Stat | undefined>
|
readonly statUntracked: (cwd: string, file: string) => Effect.Effect<Stat | undefined>
|
||||||
|
readonly applyPatch: (cwd: string, patch: string) => Effect.Effect<Result>
|
||||||
}
|
}
|
||||||
|
|
||||||
const kind = (code: string): Kind => {
|
const kind = (code: string): Kind => {
|
||||||
@@ -101,6 +103,8 @@ export const layer = Layer.effect(
|
|||||||
Service,
|
Service,
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const spawner = yield* ChildProcessSpawner.ChildProcessSpawner
|
const spawner = yield* ChildProcessSpawner.ChildProcessSpawner
|
||||||
|
const encoder = new TextEncoder()
|
||||||
|
const stdin = (text: string) => Stream.make(encoder.encode(text))
|
||||||
|
|
||||||
const run = Effect.fn("Git.run")(
|
const run = Effect.fn("Git.run")(
|
||||||
function* (args: string[], opts: Options) {
|
function* (args: string[], opts: Options) {
|
||||||
@@ -108,7 +112,7 @@ export const layer = Layer.effect(
|
|||||||
cwd: opts.cwd,
|
cwd: opts.cwd,
|
||||||
env: opts.env,
|
env: opts.env,
|
||||||
extendEnv: true,
|
extendEnv: true,
|
||||||
stdin: "ignore",
|
stdin: opts.stdin ?? "ignore",
|
||||||
stdout: "pipe",
|
stdout: "pipe",
|
||||||
stderr: "pipe",
|
stderr: "pipe",
|
||||||
})
|
})
|
||||||
@@ -316,9 +320,13 @@ export const layer = Layer.effect(
|
|||||||
cwd,
|
cwd,
|
||||||
maxOutputBytes: 4096,
|
maxOutputBytes: 4096,
|
||||||
})
|
})
|
||||||
|
|
||||||
if (result.truncated) return
|
if (result.truncated) return
|
||||||
const parts = result.text().split("\t")
|
const text = result.text()
|
||||||
|
|
||||||
|
const parts = text.split("\t")
|
||||||
if (parts.length < 2) return
|
if (parts.length < 2) return
|
||||||
|
|
||||||
const additions = parts[0] === "-" ? 0 : Number.parseInt(parts[0] || "0", 10)
|
const additions = parts[0] === "-" ? 0 : Number.parseInt(parts[0] || "0", 10)
|
||||||
const deletions = parts[1] === "-" ? 0 : Number.parseInt(parts[1] || "0", 10)
|
const deletions = parts[1] === "-" ? 0 : Number.parseInt(parts[1] || "0", 10)
|
||||||
return {
|
return {
|
||||||
@@ -328,6 +336,10 @@ export const layer = Layer.effect(
|
|||||||
} satisfies Stat
|
} satisfies Stat
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const applyPatch = Effect.fn("Git.applyPatch")(function* (cwd: string, patch: string) {
|
||||||
|
return yield* run(["apply", "-"], { cwd, stdin: stdin(patch) })
|
||||||
|
})
|
||||||
|
|
||||||
return Service.of({
|
return Service.of({
|
||||||
run,
|
run,
|
||||||
branch,
|
branch,
|
||||||
@@ -343,6 +355,7 @@ export const layer = Layer.effect(
|
|||||||
patchAll,
|
patchAll,
|
||||||
patchUntracked,
|
patchUntracked,
|
||||||
statUntracked,
|
statUntracked,
|
||||||
|
applyPatch,
|
||||||
})
|
})
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import { InstanceState } from "@/effect/instance-state"
|
|||||||
import { FileWatcher } from "@/file/watcher"
|
import { FileWatcher } from "@/file/watcher"
|
||||||
import { Git } from "@/git"
|
import { Git } from "@/git"
|
||||||
import * as Log from "@opencode-ai/core/util/log"
|
import * as Log from "@opencode-ai/core/util/log"
|
||||||
import { zod } from "@/util/effect-zod"
|
import { zod, zodObject } from "@/util/effect-zod"
|
||||||
import { NonNegativeInt, withStatics } from "@/util/schema"
|
import { NonNegativeInt, withStatics } from "@/util/schema"
|
||||||
|
|
||||||
const log = Log.create({ service: "vcs" })
|
const log = Log.create({ service: "vcs" })
|
||||||
@@ -239,11 +239,39 @@ export const FileDiff = Schema.Struct({
|
|||||||
.pipe(withStatics((s) => ({ zod: zod(s) })))
|
.pipe(withStatics((s) => ({ zod: zod(s) })))
|
||||||
export type FileDiff = Schema.Schema.Type<typeof FileDiff>
|
export type FileDiff = Schema.Schema.Type<typeof FileDiff>
|
||||||
|
|
||||||
|
export const FileStatus = Schema.Struct({
|
||||||
|
file: Schema.String,
|
||||||
|
additions: NonNegativeInt,
|
||||||
|
deletions: NonNegativeInt,
|
||||||
|
status: Schema.Literals(["added", "deleted", "modified"]),
|
||||||
|
})
|
||||||
|
.annotate({ identifier: "VcsFileStatus" })
|
||||||
|
.pipe(withStatics((s) => ({ zod: zod(s) })))
|
||||||
|
export type FileStatus = Schema.Schema.Type<typeof FileStatus>
|
||||||
|
|
||||||
|
export const ApplyInput = Schema.Struct({
|
||||||
|
patch: Schema.String,
|
||||||
|
}).pipe(withStatics((s) => ({ zod: zod(s), zodObject: zodObject(s) })))
|
||||||
|
export type ApplyInput = Schema.Schema.Type<typeof ApplyInput>
|
||||||
|
|
||||||
|
export const ApplyResult = Schema.Struct({
|
||||||
|
applied: Schema.Boolean,
|
||||||
|
}).pipe(withStatics((s) => ({ zod: zod(s) })))
|
||||||
|
export type ApplyResult = Schema.Schema.Type<typeof ApplyResult>
|
||||||
|
|
||||||
|
export class PatchApplyError extends Schema.TaggedErrorClass<PatchApplyError>()("VcsPatchApplyError", {
|
||||||
|
message: Schema.String,
|
||||||
|
reason: Schema.Literals(["non-git", "not-clean"]),
|
||||||
|
}) {}
|
||||||
|
|
||||||
export interface Interface {
|
export interface Interface {
|
||||||
readonly init: () => Effect.Effect<void>
|
readonly init: () => Effect.Effect<void>
|
||||||
readonly branch: () => Effect.Effect<string | undefined>
|
readonly branch: () => Effect.Effect<string | undefined>
|
||||||
readonly defaultBranch: () => Effect.Effect<string | undefined>
|
readonly defaultBranch: () => Effect.Effect<string | undefined>
|
||||||
|
readonly status: () => Effect.Effect<FileStatus[]>
|
||||||
readonly diff: (mode: Mode) => Effect.Effect<FileDiff[]>
|
readonly diff: (mode: Mode) => Effect.Effect<FileDiff[]>
|
||||||
|
readonly diffRaw: () => Effect.Effect<string>
|
||||||
|
readonly apply: (input: ApplyInput) => Effect.Effect<ApplyResult, PatchApplyError>
|
||||||
}
|
}
|
||||||
|
|
||||||
interface State {
|
interface State {
|
||||||
@@ -304,6 +332,31 @@ export const layer: Layer.Layer<Service, never, Git.Service | Bus.Service> = Lay
|
|||||||
defaultBranch: Effect.fn("Vcs.defaultBranch")(function* () {
|
defaultBranch: Effect.fn("Vcs.defaultBranch")(function* () {
|
||||||
return yield* InstanceState.use(state, (x) => x.root?.name)
|
return yield* InstanceState.use(state, (x) => x.root?.name)
|
||||||
}),
|
}),
|
||||||
|
status: Effect.fn("Vcs.status")(function* () {
|
||||||
|
const ctx = yield* InstanceState.context
|
||||||
|
if (ctx.project.vcs !== "git") return []
|
||||||
|
const ref = (yield* git.hasHead(ctx.directory)) ? "HEAD" : undefined
|
||||||
|
const [list, stats] = yield* Effect.all(
|
||||||
|
[git.status(ctx.directory), ref ? git.stats(ctx.directory, ref) : Effect.succeed([])],
|
||||||
|
{ concurrency: 2 },
|
||||||
|
)
|
||||||
|
const map = nums(stats)
|
||||||
|
return yield* Effect.forEach(
|
||||||
|
list.toSorted((a, b) => a.file.localeCompare(b.file)),
|
||||||
|
(item) =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const stat =
|
||||||
|
map.get(item.file) ??
|
||||||
|
(item.status === "added" ? yield* git.statUntracked(ctx.worktree, item.file) : undefined)
|
||||||
|
return {
|
||||||
|
file: item.file,
|
||||||
|
additions: stat?.additions ?? 0,
|
||||||
|
deletions: stat?.deletions ?? 0,
|
||||||
|
status: item.status,
|
||||||
|
} satisfies FileStatus
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}),
|
||||||
diff: Effect.fn("Vcs.diff")(function* (mode: Mode) {
|
diff: Effect.fn("Vcs.diff")(function* (mode: Mode) {
|
||||||
const value = yield* InstanceState.get(state)
|
const value = yield* InstanceState.get(state)
|
||||||
const ctx = yield* InstanceState.context
|
const ctx = yield* InstanceState.context
|
||||||
@@ -318,6 +371,36 @@ export const layer: Layer.Layer<Service, never, Git.Service | Bus.Service> = Lay
|
|||||||
if (!ref) return []
|
if (!ref) return []
|
||||||
return yield* diffAgainstRef(git, ctx.directory, ref)
|
return yield* diffAgainstRef(git, ctx.directory, ref)
|
||||||
}),
|
}),
|
||||||
|
diffRaw: Effect.fn("Vcs.diffRaw")(function* () {
|
||||||
|
const ctx = yield* InstanceState.context
|
||||||
|
if (ctx.project.vcs !== "git") return ""
|
||||||
|
const [hasHead, status] = yield* Effect.all([git.hasHead(ctx.directory), git.status(ctx.directory)], {
|
||||||
|
concurrency: 2,
|
||||||
|
})
|
||||||
|
const tracked = hasHead ? (yield* git.patchAll(ctx.directory, "HEAD")).text : ""
|
||||||
|
const untracked = yield* Effect.forEach(
|
||||||
|
status.filter((item) => item.code === "??"),
|
||||||
|
(item) => git.patchUntracked(ctx.directory, item.file).pipe(Effect.map((patch) => patch.text)),
|
||||||
|
)
|
||||||
|
return [tracked, ...untracked].filter(Boolean).join("\n")
|
||||||
|
}),
|
||||||
|
apply: Effect.fn("Vcs.apply")(function* (input: ApplyInput) {
|
||||||
|
const ctx = yield* InstanceState.context
|
||||||
|
if (ctx.project.vcs !== "git") {
|
||||||
|
return yield* new PatchApplyError({
|
||||||
|
message: "Patch can't be applied because the project is not git-based",
|
||||||
|
reason: "non-git",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
const applied = yield* git.applyPatch(ctx.directory, input.patch)
|
||||||
|
if (applied.exitCode !== 0) {
|
||||||
|
return yield* new PatchApplyError({
|
||||||
|
message: "Patch can't be applied",
|
||||||
|
reason: "not-clean",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return { applied: true }
|
||||||
|
}),
|
||||||
})
|
})
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import { AppRuntime } from "@/effect/app-runtime"
|
|||||||
import { WorkspaceAdapterEntry } from "@/control-plane/types"
|
import { WorkspaceAdapterEntry } from "@/control-plane/types"
|
||||||
import { zodObject } from "@/util/effect-zod"
|
import { zodObject } from "@/util/effect-zod"
|
||||||
import { Instance } from "@/project/instance"
|
import { Instance } from "@/project/instance"
|
||||||
|
import { Vcs } from "@/project/vcs"
|
||||||
import { errors } from "../../error"
|
import { errors } from "../../error"
|
||||||
import { lazy } from "@/util/lazy"
|
import { lazy } from "@/util/lazy"
|
||||||
|
|
||||||
@@ -164,19 +165,47 @@ export const WorkspaceRoutes = lazy(() =>
|
|||||||
z.object({
|
z.object({
|
||||||
id: zodObject(Workspace.Info).shape.id.nullable(),
|
id: zodObject(Workspace.Info).shape.id.nullable(),
|
||||||
sessionID: Workspace.SessionWarpInput.zodObject.shape.sessionID,
|
sessionID: Workspace.SessionWarpInput.zodObject.shape.sessionID,
|
||||||
|
copyChanges: z.boolean().optional(),
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
async (c) => {
|
async (c) => {
|
||||||
const body = c.req.valid("json")
|
const body = c.req.valid("json")
|
||||||
await AppRuntime.runPromise(
|
return AppRuntime.runPromise(
|
||||||
Workspace.Service.use((workspace) =>
|
Workspace.Service.use((workspace) =>
|
||||||
workspace.sessionWarp({
|
workspace.sessionWarp({
|
||||||
workspaceID: body.id,
|
workspaceID: body.id,
|
||||||
sessionID: body.sessionID,
|
sessionID: body.sessionID,
|
||||||
|
copyChanges: body.copyChanges,
|
||||||
|
}),
|
||||||
|
).pipe(
|
||||||
|
Effect.match({
|
||||||
|
onFailure: (error) => {
|
||||||
|
if (error instanceof Vcs.PatchApplyError) {
|
||||||
|
return c.json(
|
||||||
|
{
|
||||||
|
name: "VcsApplyError",
|
||||||
|
data: {
|
||||||
|
message: error.message,
|
||||||
|
reason: error.reason,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
400,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return c.json(
|
||||||
|
{
|
||||||
|
name: "WorkspaceWarpError",
|
||||||
|
data: {
|
||||||
|
message: error.message,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
400,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
onSuccess: () => c.body(null, 204),
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
return c.body(null, 204)
|
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { LSP } from "@/lsp/lsp"
|
|||||||
import { Vcs } from "@/project/vcs"
|
import { Vcs } from "@/project/vcs"
|
||||||
import { Skill } from "@/skill"
|
import { Skill } from "@/skill"
|
||||||
import { Schema } from "effect"
|
import { Schema } from "effect"
|
||||||
import { HttpApi, HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
|
import { HttpApi, HttpApiEndpoint, 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"
|
||||||
@@ -23,11 +23,25 @@ export const VcsDiffQuery = Schema.Struct({
|
|||||||
mode: Vcs.Mode,
|
mode: Vcs.Mode,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
export class ApiVcsApplyError extends Schema.ErrorClass<ApiVcsApplyError>("VcsApplyError")(
|
||||||
|
{
|
||||||
|
name: Schema.Literal("VcsApplyError"),
|
||||||
|
data: Schema.Struct({
|
||||||
|
message: Schema.String,
|
||||||
|
reason: Schema.Literals(["non-git", "not-clean"]),
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
{ httpApiStatus: 400 },
|
||||||
|
) {}
|
||||||
|
|
||||||
export const InstancePaths = {
|
export const InstancePaths = {
|
||||||
dispose: "/instance/dispose",
|
dispose: "/instance/dispose",
|
||||||
path: "/path",
|
path: "/path",
|
||||||
vcs: "/vcs",
|
vcs: "/vcs",
|
||||||
|
vcsStatus: "/vcs/status",
|
||||||
vcsDiff: "/vcs/diff",
|
vcsDiff: "/vcs/diff",
|
||||||
|
vcsDiffRaw: "/vcs/diff/raw",
|
||||||
|
vcsApply: "/vcs/apply",
|
||||||
command: "/command",
|
command: "/command",
|
||||||
agent: "/agent",
|
agent: "/agent",
|
||||||
skill: "/skill",
|
skill: "/skill",
|
||||||
@@ -68,6 +82,15 @@ export const InstanceApi = HttpApi.make("instance")
|
|||||||
"Retrieve version control system (VCS) information for the current project, such as git branch.",
|
"Retrieve version control system (VCS) information for the current project, such as git branch.",
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
|
HttpApiEndpoint.get("vcsStatus", InstancePaths.vcsStatus, {
|
||||||
|
success: described(Schema.Array(Vcs.FileStatus), "VCS status"),
|
||||||
|
}).annotateMerge(
|
||||||
|
OpenApi.annotations({
|
||||||
|
identifier: "vcs.status",
|
||||||
|
summary: "Get VCS status",
|
||||||
|
description: "Retrieve changed files in the current working tree without patches.",
|
||||||
|
}),
|
||||||
|
),
|
||||||
HttpApiEndpoint.get("vcsDiff", InstancePaths.vcsDiff, {
|
HttpApiEndpoint.get("vcsDiff", InstancePaths.vcsDiff, {
|
||||||
query: VcsDiffQuery,
|
query: VcsDiffQuery,
|
||||||
success: described(Schema.Array(Vcs.FileDiff), "VCS diff"),
|
success: described(Schema.Array(Vcs.FileDiff), "VCS diff"),
|
||||||
@@ -78,6 +101,29 @@ export const InstanceApi = HttpApi.make("instance")
|
|||||||
description: "Retrieve the current git diff for the working tree or against the default branch.",
|
description: "Retrieve the current git diff for the working tree or against the default branch.",
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
|
HttpApiEndpoint.get("vcsDiffRaw", InstancePaths.vcsDiffRaw, {
|
||||||
|
success: described(
|
||||||
|
Schema.String.pipe(HttpApiSchema.asText({ contentType: "text/x-diff; charset=utf-8" })),
|
||||||
|
"Raw VCS diff",
|
||||||
|
),
|
||||||
|
}).annotateMerge(
|
||||||
|
OpenApi.annotations({
|
||||||
|
identifier: "vcs.diff.raw",
|
||||||
|
summary: "Get raw VCS diff",
|
||||||
|
description: "Retrieve a raw patch for current uncommitted changes.",
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
HttpApiEndpoint.post("vcsApply", InstancePaths.vcsApply, {
|
||||||
|
payload: Vcs.ApplyInput,
|
||||||
|
success: described(Vcs.ApplyResult, "VCS patch applied"),
|
||||||
|
error: ApiVcsApplyError,
|
||||||
|
}).annotateMerge(
|
||||||
|
OpenApi.annotations({
|
||||||
|
identifier: "vcs.apply",
|
||||||
|
summary: "Apply VCS patch",
|
||||||
|
description: "Apply a raw patch to the current working tree.",
|
||||||
|
}),
|
||||||
|
),
|
||||||
HttpApiEndpoint.get("command", InstancePaths.command, {
|
HttpApiEndpoint.get("command", InstancePaths.command, {
|
||||||
success: described(Schema.Array(Command.Info), "List of commands"),
|
success: described(Schema.Array(Command.Info), "List of commands"),
|
||||||
}).annotateMerge(
|
}).annotateMerge(
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { Workspace } from "@/control-plane/workspace"
|
|||||||
import { WorkspaceAdapterEntry } from "@/control-plane/types"
|
import { WorkspaceAdapterEntry } from "@/control-plane/types"
|
||||||
import { Schema, Struct } from "effect"
|
import { Schema, Struct } from "effect"
|
||||||
import { HttpApi, HttpApiEndpoint, HttpApiError, HttpApiGroup, HttpApiSchema, OpenApi } from "effect/unstable/httpapi"
|
import { HttpApi, HttpApiEndpoint, HttpApiError, HttpApiGroup, HttpApiSchema, OpenApi } from "effect/unstable/httpapi"
|
||||||
|
import { ApiVcsApplyError } from "./instance"
|
||||||
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"
|
||||||
@@ -12,8 +13,19 @@ export const CreatePayload = Schema.Struct(Struct.omit(Workspace.CreateInput.fie
|
|||||||
export const WarpPayload = Schema.Struct({
|
export const WarpPayload = Schema.Struct({
|
||||||
id: Schema.NullOr(Workspace.Info.fields.id),
|
id: Schema.NullOr(Workspace.Info.fields.id),
|
||||||
sessionID: Workspace.SessionWarpInput.fields.sessionID,
|
sessionID: Workspace.SessionWarpInput.fields.sessionID,
|
||||||
|
copyChanges: Workspace.SessionWarpInput.fields.copyChanges,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
export class ApiWorkspaceWarpError extends Schema.ErrorClass<ApiWorkspaceWarpError>("WorkspaceWarpError")(
|
||||||
|
{
|
||||||
|
name: Schema.Literal("WorkspaceWarpError"),
|
||||||
|
data: Schema.Struct({
|
||||||
|
message: Schema.String,
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
{ httpApiStatus: 400 },
|
||||||
|
) {}
|
||||||
|
|
||||||
export const WorkspacePaths = {
|
export const WorkspacePaths = {
|
||||||
adapters: `${root}/adapter`,
|
adapters: `${root}/adapter`,
|
||||||
list: root,
|
list: root,
|
||||||
@@ -78,7 +90,7 @@ export const WorkspaceApi = HttpApi.make("workspace")
|
|||||||
HttpApiEndpoint.post("warp", WorkspacePaths.warp, {
|
HttpApiEndpoint.post("warp", WorkspacePaths.warp, {
|
||||||
payload: WarpPayload,
|
payload: WarpPayload,
|
||||||
success: described(HttpApiSchema.NoContent, "Session warped"),
|
success: described(HttpApiSchema.NoContent, "Session warped"),
|
||||||
error: HttpApiError.BadRequest,
|
error: [ApiWorkspaceWarpError, ApiVcsApplyError],
|
||||||
}).annotateMerge(
|
}).annotateMerge(
|
||||||
OpenApi.annotations({
|
OpenApi.annotations({
|
||||||
identifier: "experimental.workspace.warp",
|
identifier: "experimental.workspace.warp",
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import { Skill } from "@/skill"
|
|||||||
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 { ApiVcsApplyError } from "../groups/instance"
|
||||||
import { markInstanceForDisposal } from "../lifecycle"
|
import { markInstanceForDisposal } from "../lifecycle"
|
||||||
|
|
||||||
export const instanceHandlers = HttpApiBuilder.group(InstanceHttpApi, "instance", (handlers) =>
|
export const instanceHandlers = HttpApiBuilder.group(InstanceHttpApi, "instance", (handlers) =>
|
||||||
@@ -41,10 +42,33 @@ export const instanceHandlers = HttpApiBuilder.group(InstanceHttpApi, "instance"
|
|||||||
return { branch, default_branch }
|
return { branch, default_branch }
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const getVcsStatus = Effect.fn("InstanceHttpApi.vcsStatus")(function* () {
|
||||||
|
return yield* vcs.status()
|
||||||
|
})
|
||||||
|
|
||||||
const getVcsDiff = Effect.fn("InstanceHttpApi.vcsDiff")(function* (ctx: { query: { mode: Vcs.Mode } }) {
|
const getVcsDiff = Effect.fn("InstanceHttpApi.vcsDiff")(function* (ctx: { query: { mode: Vcs.Mode } }) {
|
||||||
return yield* vcs.diff(ctx.query.mode)
|
return yield* vcs.diff(ctx.query.mode)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const getVcsDiffRaw = Effect.fn("InstanceHttpApi.vcsDiffRaw")(function* () {
|
||||||
|
return yield* vcs.diffRaw()
|
||||||
|
})
|
||||||
|
|
||||||
|
const applyVcs = Effect.fn("InstanceHttpApi.vcsApply")(function* (ctx: { payload: Vcs.ApplyInput }) {
|
||||||
|
return yield* vcs.apply(ctx.payload).pipe(
|
||||||
|
Effect.mapError(
|
||||||
|
(error) =>
|
||||||
|
new ApiVcsApplyError({
|
||||||
|
name: "VcsApplyError",
|
||||||
|
data: {
|
||||||
|
message: error.message,
|
||||||
|
reason: error.reason,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
const getCommand = Effect.fn("InstanceHttpApi.command")(function* () {
|
const getCommand = Effect.fn("InstanceHttpApi.command")(function* () {
|
||||||
return yield* command.list()
|
return yield* command.list()
|
||||||
})
|
})
|
||||||
@@ -69,7 +93,10 @@ export const instanceHandlers = HttpApiBuilder.group(InstanceHttpApi, "instance"
|
|||||||
.handle("dispose", dispose)
|
.handle("dispose", dispose)
|
||||||
.handle("path", getPath)
|
.handle("path", getPath)
|
||||||
.handle("vcs", getVcs)
|
.handle("vcs", getVcs)
|
||||||
|
.handle("vcsStatus", getVcsStatus)
|
||||||
.handle("vcsDiff", getVcsDiff)
|
.handle("vcsDiff", getVcsDiff)
|
||||||
|
.handle("vcsDiffRaw", getVcsDiffRaw)
|
||||||
|
.handle("vcsApply", applyVcs)
|
||||||
.handle("command", getCommand)
|
.handle("command", getCommand)
|
||||||
.handle("agent", getAgent)
|
.handle("agent", getAgent)
|
||||||
.handle("skill", getSkill)
|
.handle("skill", getSkill)
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
import { listAdapters } from "@/control-plane/adapters"
|
import { listAdapters } from "@/control-plane/adapters"
|
||||||
import { Workspace } from "@/control-plane/workspace"
|
import { Workspace } from "@/control-plane/workspace"
|
||||||
import * as InstanceState from "@/effect/instance-state"
|
import * as InstanceState from "@/effect/instance-state"
|
||||||
|
import { Vcs } from "@/project/vcs"
|
||||||
import { Effect } from "effect"
|
import { Effect } from "effect"
|
||||||
import { HttpApiBuilder, HttpApiError } from "effect/unstable/httpapi"
|
import { HttpApiBuilder, HttpApiError } from "effect/unstable/httpapi"
|
||||||
import { InstanceHttpApi } from "../api"
|
import { InstanceHttpApi } from "../api"
|
||||||
import { CreatePayload, WarpPayload } from "../groups/workspace"
|
import { ApiVcsApplyError } from "../groups/instance"
|
||||||
|
import { ApiWorkspaceWarpError, 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* () {
|
||||||
@@ -44,8 +46,27 @@ export const workspaceHandlers = HttpApiBuilder.group(InstanceHttpApi, "workspac
|
|||||||
.sessionWarp({
|
.sessionWarp({
|
||||||
workspaceID: ctx.payload.id,
|
workspaceID: ctx.payload.id,
|
||||||
sessionID: ctx.payload.sessionID,
|
sessionID: ctx.payload.sessionID,
|
||||||
|
copyChanges: ctx.payload.copyChanges,
|
||||||
})
|
})
|
||||||
.pipe(Effect.mapError(() => new HttpApiError.BadRequest({})))
|
.pipe(
|
||||||
|
Effect.mapError((error) => {
|
||||||
|
if (error instanceof Vcs.PatchApplyError) {
|
||||||
|
return new ApiVcsApplyError({
|
||||||
|
name: "VcsApplyError",
|
||||||
|
data: {
|
||||||
|
message: error.message,
|
||||||
|
reason: error.reason,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return new ApiWorkspaceWarpError({
|
||||||
|
name: "WorkspaceWarpError",
|
||||||
|
data: {
|
||||||
|
message: error.message,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}),
|
||||||
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
return handlers
|
return handlers
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ import { ProviderRoutes } from "./provider"
|
|||||||
import { EventRoutes } from "./event"
|
import { EventRoutes } from "./event"
|
||||||
import { SyncRoutes } from "./sync"
|
import { SyncRoutes } from "./sync"
|
||||||
import { InstanceMiddleware } from "./middleware"
|
import { InstanceMiddleware } from "./middleware"
|
||||||
import { jsonRequest } from "./trace"
|
import { jsonRequest, runRequest } from "./trace"
|
||||||
import { ExperimentalHttpApiServer } from "./httpapi/server"
|
import { ExperimentalHttpApiServer } from "./httpapi/server"
|
||||||
import { EventPaths } from "./httpapi/event"
|
import { EventPaths } from "./httpapi/event"
|
||||||
import { ExperimentalPaths } from "./httpapi/groups/experimental"
|
import { ExperimentalPaths } from "./httpapi/groups/experimental"
|
||||||
@@ -40,6 +40,7 @@ import { SyncPaths } from "./httpapi/groups/sync"
|
|||||||
import { TuiPaths } from "./httpapi/groups/tui"
|
import { TuiPaths } from "./httpapi/groups/tui"
|
||||||
import { WorkspacePaths } from "./httpapi/groups/workspace"
|
import { WorkspacePaths } from "./httpapi/groups/workspace"
|
||||||
import type { CorsOptions } from "@/server/cors"
|
import type { CorsOptions } from "@/server/cors"
|
||||||
|
import { errors } from "@/server/error"
|
||||||
|
|
||||||
export const InstanceRoutes = (upgrade: UpgradeWebSocket, opts?: CorsOptions): Hono => {
|
export const InstanceRoutes = (upgrade: UpgradeWebSocket, opts?: CorsOptions): Hono => {
|
||||||
const app = new Hono()
|
const app = new Hono()
|
||||||
@@ -86,7 +87,10 @@ export const InstanceRoutes = (upgrade: UpgradeWebSocket, opts?: CorsOptions): H
|
|||||||
app.get(InstancePaths.path, (c) => handler(c.req.raw, context))
|
app.get(InstancePaths.path, (c) => handler(c.req.raw, context))
|
||||||
app.post(InstancePaths.dispose, (c) => handler(c.req.raw, context))
|
app.post(InstancePaths.dispose, (c) => handler(c.req.raw, context))
|
||||||
app.get(InstancePaths.vcs, (c) => handler(c.req.raw, context))
|
app.get(InstancePaths.vcs, (c) => handler(c.req.raw, context))
|
||||||
|
app.get(InstancePaths.vcsStatus, (c) => handler(c.req.raw, context))
|
||||||
app.get(InstancePaths.vcsDiff, (c) => handler(c.req.raw, context))
|
app.get(InstancePaths.vcsDiff, (c) => handler(c.req.raw, context))
|
||||||
|
app.get(InstancePaths.vcsDiffRaw, (c) => handler(c.req.raw, context))
|
||||||
|
app.post(InstancePaths.vcsApply, (c) => handler(c.req.raw, context))
|
||||||
app.get(InstancePaths.command, (c) => handler(c.req.raw, context))
|
app.get(InstancePaths.command, (c) => handler(c.req.raw, context))
|
||||||
app.get(InstancePaths.agent, (c) => handler(c.req.raw, context))
|
app.get(InstancePaths.agent, (c) => handler(c.req.raw, context))
|
||||||
app.get(InstancePaths.skill, (c) => handler(c.req.raw, context))
|
app.get(InstancePaths.skill, (c) => handler(c.req.raw, context))
|
||||||
@@ -288,6 +292,98 @@ export const InstanceRoutes = (upgrade: UpgradeWebSocket, opts?: CorsOptions): H
|
|||||||
return yield* vcs.diff(c.req.valid("query").mode)
|
return yield* vcs.diff(c.req.valid("query").mode)
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
.get(
|
||||||
|
"/vcs/status",
|
||||||
|
describeRoute({
|
||||||
|
summary: "Get VCS status",
|
||||||
|
description: "Retrieve changed files in the current working tree without patches.",
|
||||||
|
operationId: "vcs.status",
|
||||||
|
responses: {
|
||||||
|
200: {
|
||||||
|
description: "VCS status",
|
||||||
|
content: {
|
||||||
|
"application/json": {
|
||||||
|
schema: resolver(Vcs.FileStatus.zod.array()),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
async (c) =>
|
||||||
|
jsonRequest("InstanceRoutes.vcs.status", c, function* () {
|
||||||
|
const vcs = yield* Vcs.Service
|
||||||
|
return yield* vcs.status()
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.get(
|
||||||
|
"/vcs/diff/raw",
|
||||||
|
describeRoute({
|
||||||
|
summary: "Get raw VCS diff",
|
||||||
|
description: "Retrieve a raw patch for current uncommitted changes.",
|
||||||
|
operationId: "vcs.diff.raw",
|
||||||
|
responses: {
|
||||||
|
200: {
|
||||||
|
description: "Raw VCS diff",
|
||||||
|
content: {
|
||||||
|
"text/x-diff": {
|
||||||
|
schema: resolver(z.string()),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
async (c) => {
|
||||||
|
const patch = await runRequest(
|
||||||
|
"InstanceRoutes.vcs.diffRaw",
|
||||||
|
c,
|
||||||
|
Vcs.Service.use((vcs) => vcs.diffRaw()),
|
||||||
|
)
|
||||||
|
return c.text(patch, 200, { "content-type": "text/x-diff; charset=utf-8" })
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.post(
|
||||||
|
"/vcs/apply",
|
||||||
|
describeRoute({
|
||||||
|
summary: "Apply VCS patch",
|
||||||
|
description: "Apply a raw patch to the current working tree.",
|
||||||
|
operationId: "vcs.apply",
|
||||||
|
responses: {
|
||||||
|
200: {
|
||||||
|
description: "VCS patch applied",
|
||||||
|
content: {
|
||||||
|
"application/json": {
|
||||||
|
schema: resolver(Vcs.ApplyResult.zod),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
...errors(400),
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
validator("json", Vcs.ApplyInput.zodObject),
|
||||||
|
async (c) => {
|
||||||
|
const result = await runRequest(
|
||||||
|
"InstanceRoutes.vcs.apply",
|
||||||
|
c,
|
||||||
|
Vcs.Service.use((vcs) => vcs.apply(c.req.valid("json") as Vcs.ApplyInput)).pipe(
|
||||||
|
Effect.match({
|
||||||
|
onFailure: (error) => ({ ok: false as const, error }),
|
||||||
|
onSuccess: (value) => ({ ok: true as const, value }),
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
if (result.ok) return c.json(result.value)
|
||||||
|
return c.json(
|
||||||
|
{
|
||||||
|
name: "VcsApplyError",
|
||||||
|
data: {
|
||||||
|
message: result.error.message,
|
||||||
|
reason: result.error.reason,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
400,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
)
|
||||||
.get(
|
.get(
|
||||||
"/command",
|
"/command",
|
||||||
describeRoute({
|
describeRoute({
|
||||||
|
|||||||
@@ -63,6 +63,11 @@ export function truncate(str: string, len: number): string {
|
|||||||
return str.slice(0, len - 1) + "…"
|
return str.slice(0, len - 1) + "…"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function truncateLeft(str: string, len: number): string {
|
||||||
|
if (str.length <= len) return str
|
||||||
|
return "…" + str.slice(-(len - 1))
|
||||||
|
}
|
||||||
|
|
||||||
export function truncateMiddle(str: string, maxLength: number = 35): string {
|
export function truncateMiddle(str: string, maxLength: number = 35): string {
|
||||||
if (str.length <= maxLength) return str
|
if (str.length <= maxLength) return str
|
||||||
|
|
||||||
|
|||||||
@@ -35,4 +35,29 @@ describe("recentConnectedWorkspaces", () => {
|
|||||||
|
|
||||||
expect(recent.map((workspace) => workspace.id)).toEqual(["wrk_a", "wrk_d", "wrk_e"])
|
expect(recent.map((workspace) => workspace.id)).toEqual(["wrk_a", "wrk_d", "wrk_e"])
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test("omits the active workspace before limiting recent workspaces", () => {
|
||||||
|
const workspaces = [
|
||||||
|
{ id: "wrk_a", name: "alpha" },
|
||||||
|
{ id: "wrk_b", name: "beta" },
|
||||||
|
{ id: "wrk_c", name: "gamma" },
|
||||||
|
{ id: "wrk_d", name: "delta" },
|
||||||
|
]
|
||||||
|
|
||||||
|
const { recent, hasMore } = recentConnectedWorkspaces({
|
||||||
|
sessions: [
|
||||||
|
{ workspaceID: "wrk_a", time: { updated: 400 } },
|
||||||
|
{ workspaceID: "wrk_b", time: { updated: 300 } },
|
||||||
|
{ workspaceID: "wrk_c", time: { updated: 200 } },
|
||||||
|
{ workspaceID: "wrk_d", time: { updated: 100 } },
|
||||||
|
],
|
||||||
|
get: (workspaceID) => workspaces.find((workspace) => workspace.id === workspaceID),
|
||||||
|
status: () => "connected",
|
||||||
|
limit: 3,
|
||||||
|
omitWorkspaceID: "wrk_a",
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(recent.map((workspace) => workspace.id)).toEqual(["wrk_b", "wrk_c", "wrk_d"])
|
||||||
|
expect(hasMore).toBe(false)
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test"
|
import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test"
|
||||||
|
import { $ } from "bun"
|
||||||
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"
|
||||||
@@ -29,12 +30,17 @@ import { WorkspaceTable } from "../../src/control-plane/workspace.sql"
|
|||||||
import type { Target, WorkspaceAdapter, WorkspaceInfo } from "../../src/control-plane/types"
|
import type { Target, WorkspaceAdapter, WorkspaceInfo } from "../../src/control-plane/types"
|
||||||
import * as WorkspaceOld from "../../src/control-plane/workspace"
|
import * as WorkspaceOld from "../../src/control-plane/workspace"
|
||||||
import { AppRuntime } from "@/effect/app-runtime"
|
import { AppRuntime } from "@/effect/app-runtime"
|
||||||
|
import { InstanceStore } from "@/project/instance-store"
|
||||||
|
import { InstanceBootstrap } from "@/project/bootstrap"
|
||||||
|
|
||||||
void Log.init({ print: false })
|
void Log.init({ print: false })
|
||||||
|
|
||||||
const testServerLayer = Layer.mergeAll(
|
const testServerLayer = Layer.mergeAll(
|
||||||
NodeHttpServer.layer(Http.createServer, { host: "127.0.0.1", port: 0 }),
|
NodeHttpServer.layer(Http.createServer, { host: "127.0.0.1", port: 0 }),
|
||||||
WorkspaceOld.defaultLayer,
|
WorkspaceOld.defaultLayer.pipe(
|
||||||
|
Layer.provide(InstanceStore.defaultLayer),
|
||||||
|
Layer.provide(InstanceBootstrap.defaultLayer),
|
||||||
|
),
|
||||||
SessionNs.defaultLayer,
|
SessionNs.defaultLayer,
|
||||||
)
|
)
|
||||||
const it = testEffect(testServerLayer)
|
const it = testEffect(testServerLayer)
|
||||||
@@ -107,6 +113,18 @@ async function withInstance<T>(fn: (dir: string) => T | Promise<T>) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function initGitRepo(dir: string) {
|
||||||
|
await fs.mkdir(dir, { recursive: true })
|
||||||
|
await $`git init`.cwd(dir).quiet()
|
||||||
|
await $`git config core.fsmonitor false`.cwd(dir).quiet()
|
||||||
|
await $`git config commit.gpgsign false`.cwd(dir).quiet()
|
||||||
|
await $`git config user.email "test@opencode.test"`.cwd(dir).quiet()
|
||||||
|
await $`git config user.name "Test"`.cwd(dir).quiet()
|
||||||
|
await fs.writeFile(path.join(dir, "tracked.txt"), "base\n")
|
||||||
|
await $`git add tracked.txt`.cwd(dir).quiet()
|
||||||
|
await $`git commit -m "base"`.cwd(dir).quiet()
|
||||||
|
}
|
||||||
|
|
||||||
const runWorkspace = <A, E>(effect: Effect.Effect<A, E, WorkspaceOld.Service>) => AppRuntime.runPromise(effect)
|
const runWorkspace = <A, E>(effect: Effect.Effect<A, E, WorkspaceOld.Service>) => AppRuntime.runPromise(effect)
|
||||||
const createWorkspace = (input: WorkspaceOld.CreateInput) =>
|
const createWorkspace = (input: WorkspaceOld.CreateInput) =>
|
||||||
runWorkspace(WorkspaceOld.Service.use((workspace) => workspace.create(input)))
|
runWorkspace(WorkspaceOld.Service.use((workspace) => workspace.create(input)))
|
||||||
@@ -644,6 +662,33 @@ describe("workspace-old CRUD", () => {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test("sessionWarp applies source workspace patch to local target workspace", async () => {
|
||||||
|
await withInstance(async (dir) => {
|
||||||
|
const previousType = unique("warp-patch-prev-local")
|
||||||
|
const targetType = unique("warp-patch-target-local")
|
||||||
|
const previousDir = path.join(dir, "warp-patch-prev-local")
|
||||||
|
const targetDir = path.join(dir, "warp-patch-target-local")
|
||||||
|
await initGitRepo(previousDir)
|
||||||
|
await initGitRepo(targetDir)
|
||||||
|
await fs.writeFile(path.join(previousDir, "tracked.txt"), "changed\n")
|
||||||
|
await fs.writeFile(path.join(previousDir, "new.txt"), "new\n")
|
||||||
|
|
||||||
|
const previous = workspaceInfo(Instance.project.id, previousType)
|
||||||
|
const target = workspaceInfo(Instance.project.id, targetType)
|
||||||
|
insertWorkspace(previous)
|
||||||
|
insertWorkspace(target)
|
||||||
|
registerAdapter(Instance.project.id, previousType, localAdapter(previousDir, { createDir: false }).adapter)
|
||||||
|
registerAdapter(Instance.project.id, targetType, localAdapter(targetDir, { createDir: false }).adapter)
|
||||||
|
const session = await AppRuntime.runPromise(SessionNs.Service.use((svc) => svc.create({})))
|
||||||
|
attachSessionToWorkspace(session.id, previous.id)
|
||||||
|
|
||||||
|
await warpWorkspaceSession({ workspaceID: target.id, sessionID: session.id, copyChanges: true })
|
||||||
|
|
||||||
|
expect(await fs.readFile(path.join(targetDir, "tracked.txt"), "utf8")).toBe("changed\n")
|
||||||
|
expect(await fs.readFile(path.join(targetDir, "new.txt"), "utf8")).toBe("new\n")
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
test("sessionWarp detaches a session to the local project and claims project ownership", async () => {
|
test("sessionWarp detaches a session to the local project and claims project ownership", async () => {
|
||||||
await withInstance(async (dir) => {
|
await withInstance(async (dir) => {
|
||||||
const previousType = unique("warp-detach-local")
|
const previousType = unique("warp-detach-local")
|
||||||
@@ -696,10 +741,12 @@ describe("workspace-old CRUD", () => {
|
|||||||
},
|
},
|
||||||
])
|
])
|
||||||
}
|
}
|
||||||
|
if (call.url.pathname === "/warp-source/vcs/diff/raw") return HttpServerResponse.text("remote patch")
|
||||||
if (call.url.pathname === "/warp-target/sync/replay")
|
if (call.url.pathname === "/warp-target/sync/replay")
|
||||||
return yield* HttpServerResponse.json({ sessionID: "ok" })
|
return yield* HttpServerResponse.json({ sessionID: "ok" })
|
||||||
if (call.url.pathname === "/warp-target/sync/steal")
|
if (call.url.pathname === "/warp-target/sync/steal")
|
||||||
return yield* HttpServerResponse.json({ sessionID: "ok" })
|
return yield* HttpServerResponse.json({ sessionID: "ok" })
|
||||||
|
if (call.url.pathname === "/warp-target/vcs/apply") return yield* HttpServerResponse.json({ applied: true })
|
||||||
return HttpServerResponse.text("unexpected", { status: 500 })
|
return HttpServerResponse.text("unexpected", { status: 500 })
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
@@ -722,15 +769,18 @@ describe("workspace-old CRUD", () => {
|
|||||||
historySessionID = session.id
|
historySessionID = session.id
|
||||||
historyNextSeq = (sessionSequence(session.id) ?? -1) + 1
|
historyNextSeq = (sessionSequence(session.id) ?? -1) + 1
|
||||||
|
|
||||||
yield* workspace.sessionWarp({ workspaceID: target.id, sessionID: session.id })
|
yield* workspace.sessionWarp({ workspaceID: target.id, sessionID: session.id, copyChanges: true })
|
||||||
|
|
||||||
expect(calls.map((call) => `${call.method} ${call.url.pathname}`)).toEqual([
|
expect(calls.map((call) => `${call.method} ${call.url.pathname}`)).toEqual([
|
||||||
"POST /warp-source/sync/history",
|
"POST /warp-source/sync/history",
|
||||||
|
"GET /warp-source/vcs/diff/raw",
|
||||||
|
"POST /warp-target/vcs/apply",
|
||||||
"POST /warp-target/sync/replay",
|
"POST /warp-target/sync/replay",
|
||||||
"POST /warp-target/sync/steal",
|
"POST /warp-target/sync/steal",
|
||||||
])
|
])
|
||||||
expect(calls[0].json).toEqual({ [session.id]: historyNextSeq - 1 })
|
expect(calls[0].json).toEqual({ [session.id]: historyNextSeq - 1 })
|
||||||
expect(calls[1].json).toMatchObject({
|
expect(calls[2].json).toEqual({ patch: "remote patch" })
|
||||||
|
expect(calls[3].json).toMatchObject({
|
||||||
directory: "remote-target-dir",
|
directory: "remote-target-dir",
|
||||||
events: [
|
events: [
|
||||||
{
|
{
|
||||||
@@ -745,7 +795,7 @@ describe("workspace-old CRUD", () => {
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
expect(calls[2].json).toEqual({ sessionID: session.id })
|
expect(calls[4].json).toEqual({ sessionID: session.id })
|
||||||
expect((yield* sessionSvc.get(session.id)).title).toBe("from source history")
|
expect((yield* sessionSvc.get(session.id)).title).toBe("from source history")
|
||||||
expect(sessionSequenceOwner(session.id)).toBe(target.id)
|
expect(sessionSequenceOwner(session.id)).toBe(target.id)
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -12,8 +12,14 @@ process.env.OPENCODE_DISABLE_DEFAULT_PLUGINS = "1"
|
|||||||
const { Flag } = await import("@opencode-ai/core/flag/flag")
|
const { Flag } = await import("@opencode-ai/core/flag/flag")
|
||||||
const { Plugin } = await import("../../src/plugin/index")
|
const { Plugin } = await import("../../src/plugin/index")
|
||||||
const { Workspace } = await import("../../src/control-plane/workspace")
|
const { Workspace } = await import("../../src/control-plane/workspace")
|
||||||
|
const { InstanceBootstrap } = await import("../../src/project/bootstrap")
|
||||||
const { Instance } = await import("../../src/project/instance")
|
const { Instance } = await import("../../src/project/instance")
|
||||||
const it = testEffect(Layer.mergeAll(Plugin.defaultLayer, Workspace.defaultLayer, CrossSpawnSpawner.defaultLayer))
|
const { InstanceStore } = await import("../../src/project/instance-store")
|
||||||
|
const workspaceLayer = Workspace.defaultLayer.pipe(
|
||||||
|
Layer.provide(InstanceStore.defaultLayer),
|
||||||
|
Layer.provide(InstanceBootstrap.defaultLayer),
|
||||||
|
)
|
||||||
|
const it = testEffect(Layer.mergeAll(Plugin.defaultLayer, workspaceLayer, CrossSpawnSpawner.defaultLayer))
|
||||||
|
|
||||||
const experimental = Flag.OPENCODE_EXPERIMENTAL_WORKSPACES
|
const experimental = Flag.OPENCODE_EXPERIMENTAL_WORKSPACES
|
||||||
|
|
||||||
|
|||||||
@@ -10,8 +10,10 @@ import { registerAdapter } from "../../src/control-plane/adapters"
|
|||||||
import type { WorkspaceAdapter } from "../../src/control-plane/types"
|
import type { WorkspaceAdapter } from "../../src/control-plane/types"
|
||||||
import { Workspace } from "../../src/control-plane/workspace"
|
import { Workspace } from "../../src/control-plane/workspace"
|
||||||
import { InstanceRef, WorkspaceRef } from "../../src/effect/instance-ref"
|
import { InstanceRef, WorkspaceRef } from "../../src/effect/instance-ref"
|
||||||
|
import { InstanceBootstrap } from "../../src/project/bootstrap"
|
||||||
import { Instance } from "../../src/project/instance"
|
import { Instance } from "../../src/project/instance"
|
||||||
import { InstanceLayer } from "../../src/project/instance-layer"
|
import { InstanceLayer } from "../../src/project/instance-layer"
|
||||||
|
import { InstanceStore } from "../../src/project/instance-store"
|
||||||
import { Project } from "../../src/project/project"
|
import { Project } from "../../src/project/project"
|
||||||
import { disposeMiddleware, markInstanceForDisposal } from "../../src/server/routes/instance/httpapi/lifecycle"
|
import { disposeMiddleware, markInstanceForDisposal } from "../../src/server/routes/instance/httpapi/lifecycle"
|
||||||
import { instanceRouterMiddleware } from "../../src/server/routes/instance/httpapi/middleware/instance-context"
|
import { instanceRouterMiddleware } from "../../src/server/routes/instance/httpapi/middleware/instance-context"
|
||||||
@@ -36,6 +38,11 @@ const testStateLayer = Layer.effectDiscard(
|
|||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const workspaceLayer = Workspace.defaultLayer.pipe(
|
||||||
|
Layer.provide(InstanceStore.defaultLayer),
|
||||||
|
Layer.provide(InstanceBootstrap.defaultLayer),
|
||||||
|
)
|
||||||
|
|
||||||
const it = testEffect(
|
const it = testEffect(
|
||||||
Layer.mergeAll(
|
Layer.mergeAll(
|
||||||
testStateLayer,
|
testStateLayer,
|
||||||
@@ -43,7 +50,7 @@ const it = testEffect(
|
|||||||
NodeServices.layer,
|
NodeServices.layer,
|
||||||
InstanceLayer.layer,
|
InstanceLayer.layer,
|
||||||
Project.defaultLayer,
|
Project.defaultLayer,
|
||||||
Workspace.defaultLayer,
|
workspaceLayer,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { afterEach, describe, expect } from "bun:test"
|
import { afterEach, describe, expect } from "bun:test"
|
||||||
import { mkdir } from "node:fs/promises"
|
import { mkdir } from "node:fs/promises"
|
||||||
import path from "node:path"
|
import path from "node:path"
|
||||||
import { Effect } from "effect"
|
import { Effect, Layer } from "effect"
|
||||||
import { Flag } from "@opencode-ai/core/flag/flag"
|
import { Flag } from "@opencode-ai/core/flag/flag"
|
||||||
import { registerAdapter } from "../../src/control-plane/adapters"
|
import { registerAdapter } from "../../src/control-plane/adapters"
|
||||||
import type { WorkspaceAdapter } from "../../src/control-plane/types"
|
import type { WorkspaceAdapter } from "../../src/control-plane/types"
|
||||||
@@ -9,6 +9,8 @@ import { Workspace } from "../../src/control-plane/workspace"
|
|||||||
import { PermissionID } from "../../src/permission/schema"
|
import { PermissionID } from "../../src/permission/schema"
|
||||||
import { ModelID, ProviderID } from "../../src/provider/schema"
|
import { ModelID, ProviderID } from "../../src/provider/schema"
|
||||||
import { WithInstance } from "../../src/project/with-instance"
|
import { WithInstance } from "../../src/project/with-instance"
|
||||||
|
import { InstanceBootstrap } from "../../src/project/bootstrap"
|
||||||
|
import { InstanceStore } from "../../src/project/instance-store"
|
||||||
import { Project } from "../../src/project/project"
|
import { Project } from "../../src/project/project"
|
||||||
import { Server } from "../../src/server/server"
|
import { Server } from "../../src/server/server"
|
||||||
import { SessionPaths } from "../../src/server/routes/instance/httpapi/groups/session"
|
import { SessionPaths } from "../../src/server/routes/instance/httpapi/groups/session"
|
||||||
@@ -30,6 +32,10 @@ void Log.init({ print: false })
|
|||||||
|
|
||||||
const original = Flag.OPENCODE_EXPERIMENTAL_HTTPAPI
|
const original = Flag.OPENCODE_EXPERIMENTAL_HTTPAPI
|
||||||
const originalWorkspaces = Flag.OPENCODE_EXPERIMENTAL_WORKSPACES
|
const originalWorkspaces = Flag.OPENCODE_EXPERIMENTAL_WORKSPACES
|
||||||
|
const workspaceLayer = Workspace.defaultLayer.pipe(
|
||||||
|
Layer.provide(InstanceStore.defaultLayer),
|
||||||
|
Layer.provide(InstanceBootstrap.defaultLayer),
|
||||||
|
)
|
||||||
|
|
||||||
function app(experimental = true) {
|
function app(experimental = true) {
|
||||||
Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = experimental
|
Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = experimental
|
||||||
@@ -106,7 +112,7 @@ const createLocalWorkspace = (input: { projectID: Project.Info["id"]; type: stri
|
|||||||
extra: null,
|
extra: null,
|
||||||
projectID: input.projectID,
|
projectID: input.projectID,
|
||||||
}),
|
}),
|
||||||
).pipe(Effect.provide(Workspace.defaultLayer))
|
).pipe(Effect.provide(workspaceLayer))
|
||||||
})
|
})
|
||||||
|
|
||||||
function request(path: string, init?: RequestInit) {
|
function request(path: string, init?: RequestInit) {
|
||||||
|
|||||||
@@ -20,6 +20,8 @@ import { WorkspaceID } from "../../src/control-plane/schema"
|
|||||||
import type { WorkspaceAdapter } from "../../src/control-plane/types"
|
import type { WorkspaceAdapter } from "../../src/control-plane/types"
|
||||||
import { Workspace } from "../../src/control-plane/workspace"
|
import { Workspace } from "../../src/control-plane/workspace"
|
||||||
import { WorkspaceTable } from "../../src/control-plane/workspace.sql"
|
import { WorkspaceTable } from "../../src/control-plane/workspace.sql"
|
||||||
|
import { InstanceBootstrap } from "../../src/project/bootstrap"
|
||||||
|
import { InstanceStore } from "../../src/project/instance-store"
|
||||||
import { Project } from "../../src/project/project"
|
import { Project } from "../../src/project/project"
|
||||||
import { WorkspacePaths } from "../../src/server/routes/instance/httpapi/groups/workspace"
|
import { WorkspacePaths } from "../../src/server/routes/instance/httpapi/groups/workspace"
|
||||||
import {
|
import {
|
||||||
@@ -45,13 +47,18 @@ const testStateLayer = Layer.effectDiscard(
|
|||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const workspaceLayer = Workspace.defaultLayer.pipe(
|
||||||
|
Layer.provide(InstanceStore.defaultLayer),
|
||||||
|
Layer.provide(InstanceBootstrap.defaultLayer),
|
||||||
|
)
|
||||||
|
|
||||||
const it = testEffect(
|
const it = testEffect(
|
||||||
Layer.mergeAll(
|
Layer.mergeAll(
|
||||||
testStateLayer,
|
testStateLayer,
|
||||||
NodeHttpServer.layerTest,
|
NodeHttpServer.layerTest,
|
||||||
NodeServices.layer,
|
NodeServices.layer,
|
||||||
Project.defaultLayer,
|
Project.defaultLayer,
|
||||||
Workspace.defaultLayer,
|
workspaceLayer,
|
||||||
Socket.layerWebSocketConstructorGlobal,
|
Socket.layerWebSocketConstructorGlobal,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -14,6 +14,8 @@ import { Server } from "../../src/server/server"
|
|||||||
import { resetDatabase } from "../fixture/db"
|
import { resetDatabase } from "../fixture/db"
|
||||||
import { disposeAllInstances, provideInstance, tmpdirScoped } from "../fixture/fixture"
|
import { disposeAllInstances, provideInstance, tmpdirScoped } from "../fixture/fixture"
|
||||||
import { Instance } from "../../src/project/instance"
|
import { Instance } from "../../src/project/instance"
|
||||||
|
import { InstanceBootstrap } from "../../src/project/bootstrap"
|
||||||
|
import { InstanceStore } from "../../src/project/instance-store"
|
||||||
import { Project } from "../../src/project/project"
|
import { Project } from "../../src/project/project"
|
||||||
import { InstancePaths } from "../../src/server/routes/instance/httpapi/groups/instance"
|
import { InstancePaths } from "../../src/server/routes/instance/httpapi/groups/instance"
|
||||||
import { WorkspaceRef } from "../../src/effect/instance-ref"
|
import { WorkspaceRef } from "../../src/effect/instance-ref"
|
||||||
@@ -23,9 +25,11 @@ void Log.init({ print: false })
|
|||||||
|
|
||||||
const originalWorkspaces = Flag.OPENCODE_EXPERIMENTAL_WORKSPACES
|
const originalWorkspaces = Flag.OPENCODE_EXPERIMENTAL_WORKSPACES
|
||||||
const originalHttpApi = Flag.OPENCODE_EXPERIMENTAL_HTTPAPI
|
const originalHttpApi = Flag.OPENCODE_EXPERIMENTAL_HTTPAPI
|
||||||
const it = testEffect(
|
const workspaceLayer = Workspace.defaultLayer.pipe(
|
||||||
Layer.mergeAll(NodeServices.layer, Project.defaultLayer, Session.defaultLayer, Workspace.defaultLayer),
|
Layer.provide(InstanceStore.defaultLayer),
|
||||||
|
Layer.provide(InstanceBootstrap.defaultLayer),
|
||||||
)
|
)
|
||||||
|
const it = testEffect(Layer.mergeAll(NodeServices.layer, Project.defaultLayer, Session.defaultLayer, workspaceLayer))
|
||||||
|
|
||||||
function request(path: string, directory: string, init: RequestInit = {}, httpApi = true) {
|
function request(path: string, directory: string, init: RequestInit = {}, httpApi = true) {
|
||||||
return Effect.promise(() => {
|
return Effect.promise(() => {
|
||||||
|
|||||||
@@ -202,8 +202,12 @@ import type {
|
|||||||
V2SessionMessagesResponses,
|
V2SessionMessagesResponses,
|
||||||
V2SessionPromptResponses,
|
V2SessionPromptResponses,
|
||||||
V2SessionWaitResponses,
|
V2SessionWaitResponses,
|
||||||
|
VcsApplyErrors,
|
||||||
|
VcsApplyResponses,
|
||||||
|
VcsDiffRawResponses,
|
||||||
VcsDiffResponses,
|
VcsDiffResponses,
|
||||||
VcsGetResponses,
|
VcsGetResponses,
|
||||||
|
VcsStatusResponses,
|
||||||
WorktreeCreateErrors,
|
WorktreeCreateErrors,
|
||||||
WorktreeCreateInput,
|
WorktreeCreateInput,
|
||||||
WorktreeCreateResponses,
|
WorktreeCreateResponses,
|
||||||
@@ -1022,6 +1026,7 @@ export class Workspace extends HeyApiClient {
|
|||||||
workspace?: string
|
workspace?: string
|
||||||
id?: string | null
|
id?: string | null
|
||||||
sessionID?: string
|
sessionID?: string
|
||||||
|
copyChanges?: boolean
|
||||||
},
|
},
|
||||||
options?: Options<never, ThrowOnError>,
|
options?: Options<never, ThrowOnError>,
|
||||||
) {
|
) {
|
||||||
@@ -1034,6 +1039,7 @@ export class Workspace extends HeyApiClient {
|
|||||||
{ in: "query", key: "workspace" },
|
{ in: "query", key: "workspace" },
|
||||||
{ in: "body", key: "id" },
|
{ in: "body", key: "id" },
|
||||||
{ in: "body", key: "sessionID" },
|
{ in: "body", key: "sessionID" },
|
||||||
|
{ in: "body", key: "copyChanges" },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
@@ -1555,6 +1561,38 @@ export class Path extends HeyApiClient {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export class Diff extends HeyApiClient {
|
||||||
|
/**
|
||||||
|
* Get raw VCS diff
|
||||||
|
*
|
||||||
|
* Retrieve a raw patch for current uncommitted changes.
|
||||||
|
*/
|
||||||
|
public raw<ThrowOnError extends boolean = false>(
|
||||||
|
parameters?: {
|
||||||
|
directory?: string
|
||||||
|
workspace?: string
|
||||||
|
},
|
||||||
|
options?: Options<never, ThrowOnError>,
|
||||||
|
) {
|
||||||
|
const params = buildClientParams(
|
||||||
|
[parameters],
|
||||||
|
[
|
||||||
|
{
|
||||||
|
args: [
|
||||||
|
{ in: "query", key: "directory" },
|
||||||
|
{ in: "query", key: "workspace" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
)
|
||||||
|
return (options?.client ?? this.client).get<VcsDiffRawResponses, unknown, ThrowOnError>({
|
||||||
|
url: "/vcs/diff/raw",
|
||||||
|
...options,
|
||||||
|
...params,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export class Vcs extends HeyApiClient {
|
export class Vcs extends HeyApiClient {
|
||||||
/**
|
/**
|
||||||
* Get VCS info
|
* Get VCS info
|
||||||
@@ -1586,6 +1624,36 @@ export class Vcs extends HeyApiClient {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get VCS status
|
||||||
|
*
|
||||||
|
* Retrieve changed files in the current working tree without patches.
|
||||||
|
*/
|
||||||
|
public status<ThrowOnError extends boolean = false>(
|
||||||
|
parameters?: {
|
||||||
|
directory?: string
|
||||||
|
workspace?: string
|
||||||
|
},
|
||||||
|
options?: Options<never, ThrowOnError>,
|
||||||
|
) {
|
||||||
|
const params = buildClientParams(
|
||||||
|
[parameters],
|
||||||
|
[
|
||||||
|
{
|
||||||
|
args: [
|
||||||
|
{ in: "query", key: "directory" },
|
||||||
|
{ in: "query", key: "workspace" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
)
|
||||||
|
return (options?.client ?? this.client).get<VcsStatusResponses, unknown, ThrowOnError>({
|
||||||
|
url: "/vcs/status",
|
||||||
|
...options,
|
||||||
|
...params,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get VCS diff
|
* Get VCS diff
|
||||||
*
|
*
|
||||||
@@ -1617,6 +1685,48 @@ export class Vcs extends HeyApiClient {
|
|||||||
...params,
|
...params,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Apply VCS patch
|
||||||
|
*
|
||||||
|
* Apply a raw patch to the current working tree.
|
||||||
|
*/
|
||||||
|
public apply<ThrowOnError extends boolean = false>(
|
||||||
|
parameters?: {
|
||||||
|
directory?: string
|
||||||
|
workspace?: string
|
||||||
|
patch?: string
|
||||||
|
},
|
||||||
|
options?: Options<never, ThrowOnError>,
|
||||||
|
) {
|
||||||
|
const params = buildClientParams(
|
||||||
|
[parameters],
|
||||||
|
[
|
||||||
|
{
|
||||||
|
args: [
|
||||||
|
{ in: "query", key: "directory" },
|
||||||
|
{ in: "query", key: "workspace" },
|
||||||
|
{ in: "body", key: "patch" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
)
|
||||||
|
return (options?.client ?? this.client).post<VcsApplyResponses, VcsApplyErrors, ThrowOnError>({
|
||||||
|
url: "/vcs/apply",
|
||||||
|
...options,
|
||||||
|
...params,
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
...options?.headers,
|
||||||
|
...params.headers,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
private _diff?: Diff
|
||||||
|
get diff2(): Diff {
|
||||||
|
return (this._diff ??= new Diff({ client: this.client }))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export class Command extends HeyApiClient {
|
export class Command extends HeyApiClient {
|
||||||
|
|||||||
@@ -1474,6 +1474,13 @@ export type VcsInfo = {
|
|||||||
default_branch?: string
|
default_branch?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type VcsFileStatus = {
|
||||||
|
file: string
|
||||||
|
additions: number
|
||||||
|
deletions: number
|
||||||
|
status: "added" | "deleted" | "modified"
|
||||||
|
}
|
||||||
|
|
||||||
export type VcsFileDiff = {
|
export type VcsFileDiff = {
|
||||||
file: string
|
file: string
|
||||||
patch: string
|
patch: string
|
||||||
@@ -1482,6 +1489,14 @@ export type VcsFileDiff = {
|
|||||||
status?: "added" | "deleted" | "modified"
|
status?: "added" | "deleted" | "modified"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type VcsApplyError = {
|
||||||
|
name: "VcsApplyError"
|
||||||
|
data: {
|
||||||
|
message: string
|
||||||
|
reason: "non-git" | "not-clean"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export type Command = {
|
export type Command = {
|
||||||
name: string
|
name: string
|
||||||
description?: string
|
description?: string
|
||||||
@@ -1736,6 +1751,13 @@ export type Workspace = {
|
|||||||
projectID: string
|
projectID: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type WorkspaceWarpError = {
|
||||||
|
name: "WorkspaceWarpError"
|
||||||
|
data: {
|
||||||
|
message: string
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export type SyncEventMessageUpdated = {
|
export type SyncEventMessageUpdated = {
|
||||||
type: "sync"
|
type: "sync"
|
||||||
name: "message.updated.1"
|
name: "message.updated.1"
|
||||||
@@ -4020,6 +4042,25 @@ export type VcsGetResponses = {
|
|||||||
|
|
||||||
export type VcsGetResponse = VcsGetResponses[keyof VcsGetResponses]
|
export type VcsGetResponse = VcsGetResponses[keyof VcsGetResponses]
|
||||||
|
|
||||||
|
export type VcsStatusData = {
|
||||||
|
body?: never
|
||||||
|
path?: never
|
||||||
|
query?: {
|
||||||
|
directory?: string
|
||||||
|
workspace?: string
|
||||||
|
}
|
||||||
|
url: "/vcs/status"
|
||||||
|
}
|
||||||
|
|
||||||
|
export type VcsStatusResponses = {
|
||||||
|
/**
|
||||||
|
* VCS status
|
||||||
|
*/
|
||||||
|
200: Array<VcsFileStatus>
|
||||||
|
}
|
||||||
|
|
||||||
|
export type VcsStatusResponse = VcsStatusResponses[keyof VcsStatusResponses]
|
||||||
|
|
||||||
export type VcsDiffData = {
|
export type VcsDiffData = {
|
||||||
body?: never
|
body?: never
|
||||||
path?: never
|
path?: never
|
||||||
@@ -4040,6 +4081,57 @@ export type VcsDiffResponses = {
|
|||||||
|
|
||||||
export type VcsDiffResponse = VcsDiffResponses[keyof VcsDiffResponses]
|
export type VcsDiffResponse = VcsDiffResponses[keyof VcsDiffResponses]
|
||||||
|
|
||||||
|
export type VcsDiffRawData = {
|
||||||
|
body?: never
|
||||||
|
path?: never
|
||||||
|
query?: {
|
||||||
|
directory?: string
|
||||||
|
workspace?: string
|
||||||
|
}
|
||||||
|
url: "/vcs/diff/raw"
|
||||||
|
}
|
||||||
|
|
||||||
|
export type VcsDiffRawResponses = {
|
||||||
|
/**
|
||||||
|
* Raw VCS diff
|
||||||
|
*/
|
||||||
|
200: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type VcsDiffRawResponse = VcsDiffRawResponses[keyof VcsDiffRawResponses]
|
||||||
|
|
||||||
|
export type VcsApplyData = {
|
||||||
|
body?: {
|
||||||
|
patch: string
|
||||||
|
}
|
||||||
|
path?: never
|
||||||
|
query?: {
|
||||||
|
directory?: string
|
||||||
|
workspace?: string
|
||||||
|
}
|
||||||
|
url: "/vcs/apply"
|
||||||
|
}
|
||||||
|
|
||||||
|
export type VcsApplyErrors = {
|
||||||
|
/**
|
||||||
|
* VcsApplyError
|
||||||
|
*/
|
||||||
|
400: VcsApplyError
|
||||||
|
}
|
||||||
|
|
||||||
|
export type VcsApplyError2 = VcsApplyErrors[keyof VcsApplyErrors]
|
||||||
|
|
||||||
|
export type VcsApplyResponses = {
|
||||||
|
/**
|
||||||
|
* VCS patch applied
|
||||||
|
*/
|
||||||
|
200: {
|
||||||
|
applied: boolean
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export type VcsApplyResponse = VcsApplyResponses[keyof VcsApplyResponses]
|
||||||
|
|
||||||
export type CommandListData = {
|
export type CommandListData = {
|
||||||
body?: never
|
body?: never
|
||||||
path?: never
|
path?: never
|
||||||
@@ -6667,6 +6759,7 @@ export type ExperimentalWorkspaceWarpData = {
|
|||||||
body?: {
|
body?: {
|
||||||
id: string | null
|
id: string | null
|
||||||
sessionID: string
|
sessionID: string
|
||||||
|
copyChanges?: boolean
|
||||||
}
|
}
|
||||||
path?: never
|
path?: never
|
||||||
query?: {
|
query?: {
|
||||||
@@ -6678,9 +6771,9 @@ export type ExperimentalWorkspaceWarpData = {
|
|||||||
|
|
||||||
export type ExperimentalWorkspaceWarpErrors = {
|
export type ExperimentalWorkspaceWarpErrors = {
|
||||||
/**
|
/**
|
||||||
* Bad request
|
* WorkspaceWarpError | VcsApplyError
|
||||||
*/
|
*/
|
||||||
400: BadRequestError
|
400: WorkspaceWarpError | VcsApplyError
|
||||||
}
|
}
|
||||||
|
|
||||||
export type ExperimentalWorkspaceWarpError = ExperimentalWorkspaceWarpErrors[keyof ExperimentalWorkspaceWarpErrors]
|
export type ExperimentalWorkspaceWarpError = ExperimentalWorkspaceWarpErrors[keyof ExperimentalWorkspaceWarpErrors]
|
||||||
|
|||||||
Reference in New Issue
Block a user