Compare commits

...

11 Commits

Author SHA1 Message Date
Brendan Allan
f6a3615f59 fix(console): remove Cloudflare cache config from download fetch (#25804) 2026-05-05 10:15:00 +08:00
James Long
edd480f56b fix(tui): fix type error for calling workspace.warp (#25801) 2026-05-04 22:06:33 -04:00
Luke Parker
2740d398fa devex: Enable Electron MCP servers with DevTools debug port (#25795) 2026-05-05 11:37:18 +10:00
opencode-agent[bot]
f33b17e8ac chore: generate 2026-05-05 01:29:49 +00:00
James Long
22a4a9df8b feat(core): session warping (#25768) 2026-05-04 21:28:38 -04:00
Brendan Allan
84afd2bef8 update: normalize download asset names to match new naming convention (#25796) 2026-05-05 09:19:13 +08:00
Luke Parker
ca2411d332 Run UI unit tests in CI (#25792) 2026-05-05 11:05:53 +10:00
opencode
6b852774e1 sync release versions for v1.14.35 2026-05-05 01:01:47 +00:00
opencode-agent[bot]
f14784d531 chore: generate 2026-05-05 00:35:18 +00:00
Luke Parker
6a5e329427 fix(vcs): preserve batched patch boundaries (#25787) 2026-05-05 00:34:06 +00:00
opencode
4b65b1e053 sync release versions for v1.14.34 2026-05-04 23:26:02 +00:00
51 changed files with 2836 additions and 1096 deletions

View File

@@ -29,7 +29,7 @@
},
"packages/app": {
"name": "@opencode-ai/app",
"version": "1.14.33",
"version": "1.14.35",
"dependencies": {
"@kobalte/core": "catalog:",
"@opencode-ai/core": "workspace:*",
@@ -85,7 +85,7 @@
},
"packages/console/app": {
"name": "@opencode-ai/console-app",
"version": "1.14.33",
"version": "1.14.35",
"dependencies": {
"@cloudflare/vite-plugin": "1.15.2",
"@ibm/plex": "6.4.1",
@@ -119,7 +119,7 @@
},
"packages/console/core": {
"name": "@opencode-ai/console-core",
"version": "1.14.33",
"version": "1.14.35",
"dependencies": {
"@aws-sdk/client-sts": "3.782.0",
"@jsx-email/render": "1.1.1",
@@ -146,7 +146,7 @@
},
"packages/console/function": {
"name": "@opencode-ai/console-function",
"version": "1.14.33",
"version": "1.14.35",
"dependencies": {
"@ai-sdk/anthropic": "3.0.64",
"@ai-sdk/openai": "3.0.48",
@@ -170,7 +170,7 @@
},
"packages/console/mail": {
"name": "@opencode-ai/console-mail",
"version": "1.14.33",
"version": "1.14.35",
"dependencies": {
"@jsx-email/all": "2.2.3",
"@jsx-email/cli": "1.4.3",
@@ -194,7 +194,7 @@
},
"packages/core": {
"name": "@opencode-ai/core",
"version": "1.14.33",
"version": "1.14.35",
"bin": {
"opencode": "./bin/opencode",
},
@@ -228,7 +228,7 @@
},
"packages/desktop": {
"name": "@opencode-ai/desktop",
"version": "1.14.33",
"version": "1.14.35",
"dependencies": {
"@opencode-ai/app": "workspace:*",
"@opencode-ai/ui": "workspace:*",
@@ -263,7 +263,7 @@
},
"packages/desktop-electron": {
"name": "@opencode-ai/desktop-electron",
"version": "1.14.33",
"version": "1.14.35",
"dependencies": {
"drizzle-orm": "catalog:",
"effect": "catalog:",
@@ -309,7 +309,7 @@
},
"packages/enterprise": {
"name": "@opencode-ai/enterprise",
"version": "1.14.33",
"version": "1.14.35",
"dependencies": {
"@opencode-ai/core": "workspace:*",
"@opencode-ai/ui": "workspace:*",
@@ -338,7 +338,7 @@
},
"packages/function": {
"name": "@opencode-ai/function",
"version": "1.14.33",
"version": "1.14.35",
"dependencies": {
"@octokit/auth-app": "8.0.1",
"@octokit/rest": "catalog:",
@@ -354,7 +354,7 @@
},
"packages/opencode": {
"name": "opencode",
"version": "1.14.33",
"version": "1.14.35",
"bin": {
"opencode": "./bin/opencode",
},
@@ -496,7 +496,7 @@
},
"packages/plugin": {
"name": "@opencode-ai/plugin",
"version": "1.14.33",
"version": "1.14.35",
"dependencies": {
"@opencode-ai/sdk": "workspace:*",
"effect": "catalog:",
@@ -531,7 +531,7 @@
},
"packages/sdk/js": {
"name": "@opencode-ai/sdk",
"version": "1.14.33",
"version": "1.14.35",
"dependencies": {
"cross-spawn": "catalog:",
},
@@ -546,7 +546,7 @@
},
"packages/slack": {
"name": "@opencode-ai/slack",
"version": "1.14.33",
"version": "1.14.35",
"dependencies": {
"@opencode-ai/sdk": "workspace:*",
"@slack/bolt": "^3.17.1",
@@ -581,7 +581,7 @@
},
"packages/ui": {
"name": "@opencode-ai/ui",
"version": "1.14.33",
"version": "1.14.35",
"dependencies": {
"@kobalte/core": "catalog:",
"@opencode-ai/core": "workspace:*",
@@ -630,7 +630,7 @@
},
"packages/web": {
"name": "@opencode-ai/web",
"version": "1.14.33",
"version": "1.14.35",
"dependencies": {
"@astrojs/cloudflare": "12.6.3",
"@astrojs/markdown-remark": "6.3.1",

View File

@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/app",
"version": "1.14.33",
"version": "1.14.35",
"description": "",
"type": "module",
"exports": {

View File

@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/console-app",
"version": "1.14.33",
"version": "1.14.35",
"type": "module",
"license": "MIT",
"scripts": {

View File

@@ -2,11 +2,11 @@ import type { APIEvent } from "@solidjs/start"
import type { DownloadPlatform } from "../types"
const prodAssetNames: Record<string, string> = {
"darwin-aarch64-dmg": "opencode-desktop-darwin-aarch64.dmg",
"darwin-x64-dmg": "opencode-desktop-darwin-x64.dmg",
"windows-x64-nsis": "opencode-desktop-windows-x64.exe",
"darwin-aarch64-dmg": "opencode-desktop-mac-arm64.dmg",
"darwin-x64-dmg": "opencode-desktop-mac-x64.dmg",
"windows-x64-nsis": "opencode-desktop-win-x64.exe",
"linux-x64-deb": "opencode-desktop-linux-amd64.deb",
"linux-x64-appimage": "opencode-desktop-linux-amd64.AppImage",
"linux-x64-appimage": "opencode-desktop-linux-x86_64.AppImage",
"linux-x64-rpm": "opencode-desktop-linux-x86_64.rpm",
} satisfies Record<DownloadPlatform, string>
@@ -32,13 +32,6 @@ export async function GET({ params: { platform, channel } }: APIEvent) {
const resp = await fetch(
`https://github.com/anomalyco/${channel === "stable" ? "opencode" : "opencode-beta"}/releases/latest/download/${assetName}`,
{
cf: {
// in case gh releases has rate limits
cacheTtl: 60 * 5,
cacheEverything: true,
},
} as any,
)
const downloadName = downloadNames[platform]

View File

@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode-ai/console-core",
"version": "1.14.33",
"version": "1.14.35",
"private": true,
"type": "module",
"license": "MIT",

View File

@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/console-function",
"version": "1.14.33",
"version": "1.14.35",
"$schema": "https://json.schemastore.org/package.json",
"private": true,
"type": "module",

View File

@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/console-mail",
"version": "1.14.33",
"version": "1.14.35",
"dependencies": {
"@jsx-email/all": "2.2.3",
"@jsx-email/cli": "1.4.3",

View File

@@ -1,6 +1,6 @@
{
"$schema": "https://json.schemastore.org/package.json",
"version": "1.14.33",
"version": "1.14.35",
"name": "@opencode-ai/core",
"type": "module",
"license": "MIT",

View File

@@ -1,7 +1,7 @@
{
"name": "@opencode-ai/desktop-electron",
"private": true,
"version": "1.14.33",
"version": "1.14.35",
"type": "module",
"license": "MIT",
"homepage": "https://opencode.ai",

View File

@@ -74,6 +74,7 @@ setupApp()
function setupApp() {
ensureLoopbackNoProxy()
app.commandLine.appendSwitch("proxy-bypass-list", "<-loopback>")
if (!app.isPackaged) app.commandLine.appendSwitch("remote-debugging-port", "9222")
if (!app.requestSingleInstanceLock()) {
app.quit()

View File

@@ -1,7 +1,7 @@
{
"name": "@opencode-ai/desktop",
"private": true,
"version": "1.14.33",
"version": "1.14.35",
"type": "module",
"license": "MIT",
"scripts": {

View File

@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/enterprise",
"version": "1.14.33",
"version": "1.14.35",
"private": true,
"type": "module",
"license": "MIT",

View File

@@ -1,7 +1,7 @@
id = "opencode"
name = "OpenCode"
description = "The open source coding agent."
version = "1.14.33"
version = "1.14.35"
schema_version = 1
authors = ["Anomaly"]
repository = "https://github.com/anomalyco/opencode"
@@ -11,26 +11,26 @@ name = "OpenCode"
icon = "./icons/opencode.svg"
[agent_servers.opencode.targets.darwin-aarch64]
archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.33/opencode-darwin-arm64.zip"
archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.35/opencode-darwin-arm64.zip"
cmd = "./opencode"
args = ["acp"]
[agent_servers.opencode.targets.darwin-x86_64]
archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.33/opencode-darwin-x64.zip"
archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.35/opencode-darwin-x64.zip"
cmd = "./opencode"
args = ["acp"]
[agent_servers.opencode.targets.linux-aarch64]
archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.33/opencode-linux-arm64.tar.gz"
archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.35/opencode-linux-arm64.tar.gz"
cmd = "./opencode"
args = ["acp"]
[agent_servers.opencode.targets.linux-x86_64]
archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.33/opencode-linux-x64.tar.gz"
archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.35/opencode-linux-x64.tar.gz"
cmd = "./opencode"
args = ["acp"]
[agent_servers.opencode.targets.windows-x86_64]
archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.33/opencode-windows-x64.zip"
archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.35/opencode-windows-x64.zip"
cmd = "./opencode.exe"
args = ["acp"]

View File

@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/function",
"version": "1.14.33",
"version": "1.14.35",
"$schema": "https://json.schemastore.org/package.json",
"private": true,
"type": "module",

View File

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

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,6 @@
{
"$schema": "https://json.schemastore.org/package.json",
"version": "1.14.33",
"version": "1.14.35",
"name": "opencode",
"type": "module",
"license": "MIT",

View File

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

View File

@@ -2,7 +2,7 @@ import { useDialog } from "@tui/ui/dialog"
import { DialogSelect } from "@tui/ui/dialog-select"
import { useRoute } from "@tui/context/route"
import { useSync } from "@tui/context/sync"
import { createMemo, createResource, createSignal, onMount } from "solid-js"
import { createMemo, createResource, createSignal, onMount, type JSX } from "solid-js"
import { Locale } from "@/util/locale"
import { useProject } from "@tui/context/project"
import { useKeybind } from "../context/keybind"
@@ -10,15 +10,13 @@ import { useTheme } from "../context/theme"
import { useSDK } from "../context/sdk"
import { Flag } from "@opencode-ai/core/flag/flag"
import { DialogSessionRename } from "./dialog-session-rename"
import { Keybind } from "@/util/keybind"
import { createDebouncedSignal } from "../util/signal"
import { useToast } from "../ui/toast"
import { DialogWorkspaceCreate, openWorkspaceSession, restoreWorkspaceSession } from "./dialog-workspace-create"
import { openWorkspaceSelect, type WorkspaceSelection, warpWorkspaceSession } from "./dialog-workspace-create"
import { Spinner } from "./spinner"
import { errorMessage } from "@/util/error"
import { DialogSessionDeleteFailed } from "./dialog-session-delete-failed"
type WorkspaceStatus = "connected" | "connecting" | "disconnected" | "error"
import { WorkspaceLabel } from "./workspace-label"
export function DialogSessionList() {
const dialog = useDialog()
@@ -44,26 +42,39 @@ export function DialogSessionList() {
const currentSessionID = createMemo(() => (route.data.type === "session" ? route.data.sessionID : undefined))
const sessions = createMemo(() => searchResults() ?? sync.data.session)
function createWorkspace() {
dialog.replace(() => (
<DialogWorkspaceCreate
onSelect={(workspaceID) =>
openWorkspaceSession({
dialog,
route,
sdk,
sync,
toast,
workspaceID,
})
}
/>
))
}
function recover(session: NonNullable<ReturnType<typeof sessions>[number]>) {
const workspace = project.workspace.get(session.workspaceID!)
const list = () => dialog.replace(() => <DialogSessionList />)
const warp = async (selection: WorkspaceSelection) => {
const workspaceID = await (async () => {
if (selection.type === "none") return null
if (selection.type === "existing") return selection.workspaceID
const result = await sdk.client.experimental.workspace
.create({ type: selection.workspaceType, branch: null })
.catch(() => undefined)
const workspace = result?.data
if (!workspace) {
toast.show({
message: `Failed to create workspace: ${errorMessage(result?.error ?? "no response")}`,
variant: "error",
})
return
}
await project.workspace.sync()
return workspace.id
})()
if (workspaceID === undefined) return
await warpWorkspaceSession({
dialog,
sdk,
sync,
project,
toast,
workspaceID,
sessionID: session.id,
done: list,
})
}
dialog.replace(() => (
<DialogSessionDeleteFailed
session={session.title}
@@ -90,22 +101,15 @@ export function DialogSessionList() {
return true
}}
onRestore={() => {
dialog.replace(() => (
<DialogWorkspaceCreate
onSelect={(workspaceID) =>
restoreWorkspaceSession({
dialog,
sdk,
sync,
project,
toast,
workspaceID,
sessionID: session.id,
done: list,
})
}
/>
))
void openWorkspaceSelect({
dialog,
sdk,
sync,
toast,
onSelect: (selection) => {
void warp(selection)
},
})
return false
}}
/>
@@ -124,30 +128,17 @@ export function DialogSessionList() {
.map((x) => {
const workspace = x.workspaceID ? project.workspace.get(x.workspaceID) : undefined
let workspaceStatus: WorkspaceStatus | null = null
if (x.workspaceID) {
workspaceStatus = project.workspace.status(x.workspaceID) || "error"
}
let footer = ""
let footer: JSX.Element | string = ""
if (Flag.OPENCODE_EXPERIMENTAL_WORKSPACES) {
if (x.workspaceID) {
let desc = "unknown"
if (workspace) {
desc = `${workspace.type}: ${workspace.name}`
}
footer = (
<>
{desc}{" "}
<span
style={{
fg: workspaceStatus === "connected" ? theme.success : theme.error,
}}
>
</span>
</>
footer = workspace ? (
<WorkspaceLabel
type={workspace.type}
name={workspace.name}
status={project.workspace.status(x.workspaceID) ?? "error"}
/>
) : (
<WorkspaceLabel type="unknown" name={x.workspaceID} status="error" />
)
}
} else {
@@ -250,15 +241,6 @@ export function DialogSessionList() {
dialog.replace(() => <DialogSessionRename session={option.value} />)
},
},
{
keybind: Keybind.parse("ctrl+w")[0],
title: "new workspace",
side: "right",
disabled: !Flag.OPENCODE_EXPERIMENTAL_WORKSPACES,
onTrigger: () => {
createWorkspace()
},
},
]}
/>
)

View File

@@ -1,11 +1,9 @@
import { createOpencodeClient } from "@opencode-ai/sdk/v2"
import type { Workspace } from "@opencode-ai/sdk/v2"
import { useDialog } from "@tui/ui/dialog"
import { DialogSelect } from "@tui/ui/dialog-select"
import { useRoute } from "@tui/context/route"
import { DialogSelect, type DialogSelectOption } from "@tui/ui/dialog-select"
import { useSync } from "@tui/context/sync"
import { useProject } from "@tui/context/project"
import { createMemo, createSignal, onMount } from "solid-js"
import { setTimeout as sleep } from "node:timers/promises"
import { errorMessage } from "@/util/error"
import { useSDK } from "../context/sdk"
import { useToast } from "../ui/toast"
@@ -16,184 +14,212 @@ type Adapter = {
description: string
}
function scoped(sdk: ReturnType<typeof useSDK>, sync: ReturnType<typeof useSync>, workspaceID: string) {
return createOpencodeClient({
baseUrl: sdk.url,
fetch: sdk.fetch,
directory: sync.path.directory || sdk.directory,
experimental_workspaceID: workspaceID,
})
}
export type WorkspaceSelection =
| {
type: "none"
}
| {
type: "new"
workspaceType: string
workspaceName: string
}
| {
type: "existing"
workspaceID: string
workspaceType: string
workspaceName: string
}
export async function openWorkspaceSession(input: {
dialog: ReturnType<typeof useDialog>
route: ReturnType<typeof useRoute>
type WorkspaceSelectValue = WorkspaceSelection | { type: "existing-list" }
type ExistingWorkspaceSelectValue = { workspace: Workspace }
async function loadWorkspaceAdapters(input: {
sdk: ReturnType<typeof useSDK>
sync: ReturnType<typeof useSync>
toast: ReturnType<typeof useToast>
workspaceID: string
}) {
const client = scoped(input.sdk, input.sync, input.workspaceID)
while (true) {
const result = await client.session.create({ workspace: input.workspaceID }).catch(() => undefined)
if (!result) {
input.toast.show({
message: "Failed to create workspace session",
variant: "error",
})
return
}
if (result.response?.status && result.response.status >= 500 && result.response.status < 600) {
await sleep(1000)
continue
}
if (!result.data) {
input.toast.show({
message: "Failed to create workspace session",
variant: "error",
})
return
}
input.route.navigate({
type: "session",
sessionID: result.data.id,
})
input.dialog.clear()
return
}
const dir = input.sync.path.directory || input.sdk.directory
const url = new URL("/experimental/workspace/adapter", input.sdk.url)
if (dir) url.searchParams.set("directory", dir)
const res = await input.sdk
.fetch(url)
.then((x) => x.json() as Promise<Adapter[]>)
.catch(() => undefined)
if (res) return res
input.toast.show({
message: "Failed to load workspace adapters",
variant: "error",
})
}
export async function restoreWorkspaceSession(input: {
export async function openWorkspaceSelect(input: {
dialog: ReturnType<typeof useDialog>
sdk: ReturnType<typeof useSDK>
sync: ReturnType<typeof useSync>
toast: ReturnType<typeof useToast>
onSelect: (selection: WorkspaceSelection) => Promise<void> | void
}) {
input.dialog.clear()
const adapters = await loadWorkspaceAdapters(input)
if (!adapters) return
input.dialog.replace(() => <DialogWorkspaceSelect adapters={adapters} onSelect={input.onSelect} />)
}
export async function warpWorkspaceSession(input: {
dialog: ReturnType<typeof useDialog>
sdk: ReturnType<typeof useSDK>
sync: ReturnType<typeof useSync>
project: ReturnType<typeof useProject>
toast: ReturnType<typeof useToast>
workspaceID: string
workspaceID: string | null
sessionID: string
done?: () => void
}) {
}): Promise<boolean> {
const result = await input.sdk.client.experimental.workspace
.sessionRestore({ id: input.workspaceID, sessionID: input.sessionID })
.warp({
id: input.workspaceID ?? undefined,
sessionID: input.sessionID,
})
.catch(() => undefined)
if (!result?.data) {
input.toast.show({
message: `Failed to restore session: ${errorMessage(result?.error ?? "no response")}`,
message: `Failed to warp session: ${errorMessage(result?.error ?? "no response")}`,
variant: "error",
})
return
return false
}
input.project.workspace.set(input.workspaceID)
await input.sync.bootstrap({ fatal: false }).catch(() => undefined)
await Promise.all([input.project.workspace.sync(), input.sync.session.sync(input.sessionID)])
await Promise.all([input.project.workspace.sync(), input.sync.session.refresh()])
input.toast.show({
message: "Session restored into the new workspace",
variant: "success",
})
input.done?.()
if (input.done) return
if (input.done) return true
input.dialog.clear()
return true
}
export function DialogWorkspaceCreate(props: { onSelect: (workspaceID: string) => Promise<void> | void }) {
export function DialogWorkspaceSelect(props: {
adapters?: Adapter[]
onSelect: (selection: WorkspaceSelection) => Promise<void> | void
}) {
const dialog = useDialog()
const sync = useSync()
const project = useProject()
const sync = useSync()
const sdk = useSDK()
const toast = useToast()
const [creating, setCreating] = createSignal<string>()
const [adapters, setAdapters] = createSignal<Adapter[]>()
const [adapters, setAdapters] = createSignal<Adapter[] | undefined>(props.adapters)
onMount(() => {
dialog.setSize("medium")
void (async () => {
const dir = sync.path.directory || sdk.directory
const url = new URL("/experimental/workspace/adapter", sdk.url)
if (dir) url.searchParams.set("directory", dir)
const res = await sdk
.fetch(url)
.then((x) => x.json() as Promise<Adapter[]>)
.catch(() => undefined)
if (!res) {
toast.show({
message: "Failed to load workspace adapters",
variant: "error",
})
return
}
if (adapters()) return
const res = await loadWorkspaceAdapters({ sdk, sync, toast })
if (!res) return
setAdapters(res)
})()
})
const options = createMemo(() => {
const type = creating()
if (type) {
return [
{
title: `Creating ${type} workspace...`,
value: "creating" as const,
description: "This can take a while for remote environments",
},
]
}
const options = createMemo<DialogSelectOption<WorkspaceSelectValue>[]>(() => {
const list = adapters()
if (!list) {
return [
{
title: "Loading workspaces...",
value: "loading" as const,
description: "Fetching available workspace adapters",
if (!list) return []
const recent = sync.data.session
.toSorted((a, b) => b.time.updated - a.time.updated)
.flatMap((session) => (session.workspaceID ? [session.workspaceID] : []))
.filter((workspaceID, index, list) => list.indexOf(workspaceID) === index)
.slice(0, 3)
.flatMap((workspaceID) => {
const workspace = project.workspace.get(workspaceID)
return workspace ? [workspace] : []
})
return [
...list.map((adapter) => ({
title: adapter.name,
value: { type: "new" as const, workspaceType: adapter.type, workspaceName: adapter.name },
description: adapter.description,
category: "New workspace",
})),
{
title: "None",
value: { type: "none" as const },
description: "Use the local project",
category: "Choose workspace",
},
...recent.map((workspace: Workspace) => ({
title: workspace.name,
description: `(${workspace.type})`,
value: {
type: "existing" as const,
workspaceID: workspace.id,
workspaceType: workspace.type,
workspaceName: workspace.name,
},
]
}
return list.map((item) => ({
title: item.name,
value: item.type,
description: item.description,
}))
category: "Choose workspace",
})),
{
title: "View all workspaces",
value: { type: "existing-list" as const },
description: "Choose from all workspaces",
category: "Choose workspace",
},
]
})
const create = async (type: string) => {
if (creating()) return
setCreating(type)
const result = await sdk.client.experimental.workspace.create({ type, branch: null }).catch(() => {
toast.show({
message: "Creating workspace failed",
variant: "error",
})
return undefined
})
const workspace = result?.data
if (!workspace) {
setCreating(undefined)
toast.show({
message: `Failed to create workspace: ${errorMessage(result?.error ?? "no response")}`,
variant: "error",
})
return
}
await project.workspace.sync()
await props.onSelect(workspace.id)
setCreating(undefined)
}
if (!adapters()) return null
return (
<DialogSelect
title={creating() ? "Creating Workspace" : "New Workspace"}
<DialogSelect<WorkspaceSelectValue>
title="Warp"
skipFilter={true}
renderFilter={false}
options={options()}
onSelect={(option) => {
if (option.value === "creating" || option.value === "loading") return
void create(option.value)
if (!option.value) return
if (option.value.type === "none") {
void props.onSelect(option.value)
return
}
if (option.value.type === "new") {
void props.onSelect(option.value)
return
}
if (option.value.type === "existing") {
void props.onSelect(option.value)
return
}
dialog.replace(() => <DialogExistingWorkspaceSelect onSelect={props.onSelect} />)
}}
/>
)
}
function DialogExistingWorkspaceSelect(props: { onSelect: (selection: WorkspaceSelection) => Promise<void> | void }) {
const project = useProject()
const options = createMemo<DialogSelectOption<ExistingWorkspaceSelectValue>[]>(() =>
project.workspace
.list()
.filter((workspace) => project.workspace.status(workspace.id) === "connected")
.map((workspace: Workspace) => ({
title: workspace.name,
description: `(${workspace.type})`,
value: { workspace },
})),
)
return (
<DialogSelect<ExistingWorkspaceSelectValue>
title="Existing Workspace"
options={options()}
onSelect={(option) => {
void props.onSelect({
type: "existing",
workspaceID: option.value.workspace.id,
workspaceType: option.value.workspace.type,
workspaceName: option.value.workspace.name,
})
}}
/>
)

View File

@@ -7,6 +7,7 @@ import { Filesystem } from "@/util/filesystem"
import { useLocal } from "@tui/context/local"
import { tint, useTheme } from "@tui/context/theme"
import { EmptyBorder, SplitBorder } from "@tui/component/border"
import { Spinner } from "@tui/component/spinner"
import { useSDK } from "@tui/context/sdk"
import { useRoute } from "@tui/context/route"
import { useProject } from "@tui/context/project"
@@ -41,9 +42,11 @@ import { useKV } from "../../context/kv"
import { createFadeIn } from "../../util/signal"
import { useTextareaKeybindings } from "../textarea-keybindings"
import { DialogSkill } from "../dialog-skill"
import { DialogWorkspaceCreate, restoreWorkspaceSession } from "../dialog-workspace-create"
import { openWorkspaceSelect, warpWorkspaceSession, type WorkspaceSelection } from "../dialog-workspace-create"
import { DialogWorkspaceUnavailable } from "../dialog-workspace-unavailable"
import { useArgs } from "@tui/context/args"
import { Flag } from "@opencode-ai/core/flag/flag"
import { WorkspaceLabel, type WorkspaceStatus } from "../workspace-label"
export type PromptProps = {
sessionID?: string
@@ -173,9 +176,92 @@ export function Prompt(props: PromptProps) {
const [editorContextHover, setEditorContextHover] = createSignal(false)
let lastSubmittedEditorSelectionKey: string | undefined
const [auto, setAuto] = createSignal<AutocompleteRef>()
const [workspaceSelection, setWorkspaceSelection] = createSignal<WorkspaceSelection>()
const [workspaceCreating, setWorkspaceCreating] = createSignal(false)
const [workspaceCreatingDots, setWorkspaceCreatingDots] = createSignal(3)
const [warpNotice, setWarpNotice] = createSignal<string>()
const currentProviderLabel = createMemo(() => local.model.parsed().provider)
const hasRightContent = createMemo(() => Boolean(props.right))
function selectWorkspace(selection: WorkspaceSelection | undefined) {
setWorkspaceSelection(selection)
}
function setCreatingWorkspace(creating: boolean) {
setWorkspaceCreating(creating)
}
function showWarpNotice(name: string) {
setWarpNotice(`Warped to ${name}`)
setTimeout(() => setWarpNotice(undefined), 4000)
}
async function createWorkspace(selection: Extract<WorkspaceSelection, { type: "new" }>) {
setCreatingWorkspace(true)
const result = await sdk.client.experimental.workspace
.create({ type: selection.workspaceType, branch: null })
.catch(() => undefined)
if (result == undefined || result.error || !result.data) {
selectWorkspace(undefined)
setCreatingWorkspace(false)
toast.show({
message: "Creating workspace failed",
variant: "error",
})
return
}
await project.workspace.sync()
const workspace = result.data
selectWorkspace({
type: "existing",
workspaceID: workspace.id,
workspaceType: workspace.type,
workspaceName: workspace.name,
})
setCreatingWorkspace(false)
return workspace
}
async function warpSession(selection: WorkspaceSelection) {
if (!props.sessionID) {
selectWorkspace(selection)
dialog.clear()
if (selection.type === "new") void createWorkspace(selection)
return
}
selectWorkspace(selection)
dialog.clear()
const workspace =
selection.type === "none"
? { id: null, name: "local project" }
: selection.type === "existing"
? { id: selection.workspaceID, name: selection.workspaceName }
: await createWorkspace(selection)
if (!workspace) return
const warped = await warpWorkspaceSession({
dialog,
sdk,
sync,
project,
toast,
workspaceID: workspace.id,
sessionID: props.sessionID,
})
if (warped) showWarpNotice(workspace.name)
}
createEffect(() => {
if (!workspaceCreating()) {
setWorkspaceCreatingDots(3)
return
}
const timer = setInterval(() => setWorkspaceCreatingDots((dots) => (dots % 3) + 1), 1000)
onCleanup(() => clearInterval(timer))
})
function promptModelWarning() {
toast.show({
variant: "warning",
@@ -213,6 +299,7 @@ export function Prompt(props: PromptProps) {
})
createEffect(() => {
if (!input || input.isDestroyed) return
if (props.disabled) input.cursorColor = theme.backgroundElement
if (!props.disabled) input.cursorColor = theme.text
})
@@ -489,6 +576,27 @@ export function Prompt(props: PromptProps) {
))
},
},
{
title: "Warp",
description: "Change the workspace for the session",
value: "workspace.set",
category: "Session",
enabled: Flag.OPENCODE_EXPERIMENTAL_WORKSPACES,
slash: {
name: "warp",
},
onSelect: (dialog) => {
void openWorkspaceSelect({
dialog,
sdk,
sync,
toast,
onSelect: (selection) => {
void warpSession(selection)
},
})
},
},
]
})
@@ -699,6 +807,8 @@ export function Prompt(props: PromptProps) {
])
async function submit() {
setWarpNotice(undefined)
// IME: double-defer may fire before onContentChange flushes the last
// composed character (e.g. Korean hangul) to the store, so read
// plainText directly and sync before any downstream reads.
@@ -707,6 +817,7 @@ export function Prompt(props: PromptProps) {
syncExtmarksWithPromptParts()
}
if (props.disabled) return false
if (workspaceCreating()) return false
if (autocomplete?.visible) return false
if (!store.prompt.input) return false
const agent = local.agent.current()
@@ -729,21 +840,16 @@ export function Prompt(props: PromptProps) {
dialog.replace(() => (
<DialogWorkspaceUnavailable
onRestore={() => {
dialog.replace(() => (
<DialogWorkspaceCreate
onSelect={(nextWorkspaceID) =>
restoreWorkspaceSession({
dialog,
sdk,
sync,
project,
toast,
workspaceID: nextWorkspaceID,
sessionID: props.sessionID!,
})
}
/>
))
void openWorkspaceSelect({
dialog,
sdk,
sync,
toast,
onSelect: (selection) => {
void warpSession(selection)
},
})
return false
}}
/>
))
@@ -753,6 +859,14 @@ export function Prompt(props: PromptProps) {
const variant = local.model.variant.current()
let sessionID = props.sessionID
if (sessionID == null) {
const workspace = workspaceSelection()
const workspaceID = iife(() => {
if (!workspace) return undefined
if (workspace.type === "none") return undefined
if (workspace.type === "existing") return workspace.workspaceID
return undefined
})
const res = await sdk.client.session.create({
workspace: props.workspaceID,
agent: agent.name,
@@ -1025,6 +1139,29 @@ export function Prompt(props: PromptProps) {
return `Ask anything... "${list()[store.placeholder % list().length]}"`
})
const workspaceLabel = createMemo<
| { type: "new"; workspaceType: string }
| { type: "existing"; workspaceType: string; workspaceName: string; status?: WorkspaceStatus }
| undefined
>(() => {
const selected = workspaceSelection()
if (!selected) return
if (selected.type === "none") return
if (props.sessionID && !workspaceCreating()) return
if (selected.type === "new") {
return {
type: "new",
workspaceType: selected.workspaceType,
}
}
return {
type: "existing",
workspaceType: selected.workspaceType,
workspaceName: selected.workspaceName,
status: selected.type === "existing" ? "connected" : undefined,
}
})
const spinnerDef = createMemo(() => {
const agent = local.agent.current()
const color = agent ? local.agent.color(agent.name) : theme.border
@@ -1281,7 +1418,7 @@ export function Prompt(props: PromptProps) {
}}
onMouseDown={(r: MouseEvent) => r.target?.focus()}
focusedBackgroundColor={theme.backgroundElement}
cursorColor={theme.text}
cursorColor={props.disabled ? theme.backgroundElement : theme.text}
syntaxStyle={syntax()}
/>
<box flexDirection="row" flexShrink={0} paddingTop={1} gap={1} justifyContent="space-between">
@@ -1351,86 +1488,124 @@ export function Prompt(props: PromptProps) {
/>
</box>
<box width="100%" flexDirection="row" justifyContent="space-between">
<Show when={status().type !== "idle"} fallback={props.hint ?? <text />}>
<box
flexDirection="row"
gap={1}
flexGrow={1}
justifyContent={status().type === "retry" ? "space-between" : "flex-start"}
>
<box flexShrink={0} flexDirection="row" gap={1}>
<box marginLeft={1}>
<Show when={kv.get("animations_enabled", true)} fallback={<text fg={theme.textMuted}>[]</text>}>
<spinner color={spinnerDef().color} frames={spinnerDef().frames} interval={40} />
</Show>
</box>
<box flexDirection="row" gap={1} flexShrink={0}>
{(() => {
const retry = createMemo(() => {
const s = status()
if (s.type !== "retry") return
return s
})
const message = createMemo(() => {
const r = retry()
if (!r) return
if (r.message.includes("exceeded your current quota") && r.message.includes("gemini"))
return "gemini is way too hot right now"
if (r.message.length > 80) return r.message.slice(0, 80) + "..."
return r.message
})
const isTruncated = createMemo(() => {
const r = retry()
if (!r) return false
return r.message.length > 120
})
const [seconds, setSeconds] = createSignal(0)
onMount(() => {
const timer = setInterval(() => {
const next = retry()?.next
if (next) setSeconds(Math.round((next - Date.now()) / 1000))
}, 1000)
onCleanup(() => {
clearInterval(timer)
<Switch>
<Match when={status().type !== "idle"}>
<box
flexDirection="row"
gap={1}
flexGrow={1}
justifyContent={status().type === "retry" ? "space-between" : "flex-start"}
>
<box flexShrink={0} flexDirection="row" gap={1}>
<box marginLeft={1}>
<Show when={kv.get("animations_enabled", true)} fallback={<text fg={theme.textMuted}>[]</text>}>
<spinner color={spinnerDef().color} frames={spinnerDef().frames} interval={40} />
</Show>
</box>
<box flexDirection="row" gap={1} flexShrink={0}>
{(() => {
const retry = createMemo(() => {
const s = status()
if (s.type !== "retry") return
return s
})
})
const handleMessageClick = () => {
const r = retry()
if (!r) return
if (isTruncated()) {
void DialogAlert.show(dialog, "Retry Error", r.message)
const message = createMemo(() => {
const r = retry()
if (!r) return
if (r.message.includes("exceeded your current quota") && r.message.includes("gemini"))
return "gemini is way too hot right now"
if (r.message.length > 80) return r.message.slice(0, 80) + "..."
return r.message
})
const isTruncated = createMemo(() => {
const r = retry()
if (!r) return false
return r.message.length > 120
})
const [seconds, setSeconds] = createSignal(0)
onMount(() => {
const timer = setInterval(() => {
const next = retry()?.next
if (next) setSeconds(Math.round((next - Date.now()) / 1000))
}, 1000)
onCleanup(() => {
clearInterval(timer)
})
})
const handleMessageClick = () => {
const r = retry()
if (!r) return
if (isTruncated()) {
void DialogAlert.show(dialog, "Retry Error", r.message)
}
}
}
const retryText = () => {
const r = retry()
if (!r) return ""
const baseMessage = message()
const truncatedHint = isTruncated() ? " (click to expand)" : ""
const duration = formatDuration(seconds())
const retryInfo = ` [retrying ${duration ? `in ${duration} ` : ""}attempt #${r.attempt}]`
return baseMessage + truncatedHint + retryInfo
}
const retryText = () => {
const r = retry()
if (!r) return ""
const baseMessage = message()
const truncatedHint = isTruncated() ? " (click to expand)" : ""
const duration = formatDuration(seconds())
const retryInfo = ` [retrying ${duration ? `in ${duration} ` : ""}attempt #${r.attempt}]`
return baseMessage + truncatedHint + retryInfo
}
return (
<Show when={retry()}>
<box onMouseUp={handleMessageClick}>
<text fg={theme.error}>{retryText()}</text>
</box>
</Show>
)
})()}
return (
<Show when={retry()}>
<box onMouseUp={handleMessageClick}>
<text fg={theme.error}>{retryText()}</text>
</box>
</Show>
)
})()}
</box>
</box>
<text fg={store.interrupt > 0 ? theme.primary : theme.text}>
esc{" "}
<span style={{ fg: store.interrupt > 0 ? theme.primary : theme.textMuted }}>
{store.interrupt > 0 ? "again to interrupt" : "interrupt"}
</span>
</text>
</box>
<text fg={store.interrupt > 0 ? theme.primary : theme.text}>
esc{" "}
<span style={{ fg: store.interrupt > 0 ? theme.primary : theme.textMuted }}>
{store.interrupt > 0 ? "again to interrupt" : "interrupt"}
</span>
</text>
</box>
</Show>
</Match>
<Match when={warpNotice()}>
{(notice) => (
<box paddingLeft={3}>
<text fg={theme.accent}>{notice()}</text>
</box>
)}
</Match>
<Match when={workspaceLabel()}>
{(workspace) => (
<box paddingLeft={3} flexDirection="row" gap={1}>
<Show when={workspaceCreating()}>
<Spinner color={theme.accent} />
</Show>
<text fg={workspaceCreating() ? theme.accent : theme.text}>
{(() => {
const item = workspace()
if (item.type === "new") {
if (workspaceCreating())
return `Creating ${item.workspaceType}${".".repeat(workspaceCreatingDots())}`
return (
<>
Workspace <span style={{ fg: theme.textMuted }}>(new {item.workspaceType})</span>
</>
)
}
return (
<>
Workspace <span style={{ fg: theme.textMuted }}>{item.workspaceName}</span>
</>
)
})()}
</text>
</box>
)}
</Match>
<Match when={true}>{props.hint ?? <text />}</Match>
</Switch>
<Show when={status().type !== "retry"}>
<box gap={2} flexDirection="row">
<Show when={editorFileLabelDisplay()}>

View File

@@ -0,0 +1,19 @@
import { useTheme } from "@tui/context/theme"
export type WorkspaceStatus = "connected" | "connecting" | "disconnected" | "error"
export function WorkspaceLabel(props: { type: string; name: string; status?: WorkspaceStatus; icon?: boolean }) {
const { theme } = useTheme()
const color = () => {
if (props.status === "connected") return theme.success
if (props.status === "error") return theme.error
return theme.textMuted
}
return (
<>
{props.icon ? <span style={{ fg: color() }}> </span> : undefined}
<span style={{ fg: theme.text }}>{props.name}</span> <span style={{ fg: theme.textMuted }}>({props.type})</span>
</>
)
}

View File

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

View File

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

View File

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

View File

@@ -85,7 +85,9 @@ const fileFromPatchChunk = (chunk: string) => {
}
const splitGitPatch = (patch: Git.Patch) => {
const starts = [...patch.text.matchAll(/^diff --git /gm)].map((match) => match.index)
const starts = [...patch.text.matchAll(/(?:^|\n)diff --git /g)].map((match) =>
match[0].startsWith("\n") ? match.index + 1 : match.index,
)
const chunks = starts.map((start, index) => patch.text.slice(start, starts[index + 1] ?? patch.text.length))
if (!patch.truncated) return chunks
return chunks.slice(0, -1)

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -16,6 +16,9 @@ import { Workspace } from "@/control-plane/workspace"
import { AppRuntime } from "@/effect/app-runtime"
import { Instance } from "@/project/instance"
import { errors } from "../../error"
import { Session } from "@/session/session"
import { WorkspaceContext } from "@/control-plane/workspace-context"
import { SessionID } from "@/session/schema"
const ReplayEvent = z.object({
id: z.string(),
@@ -24,6 +27,9 @@ const ReplayEvent = z.object({
type: z.string(),
data: z.record(z.string(), z.unknown()),
})
const SessionPayload = z.object({
sessionID: SessionID.zod,
})
const log = Log.create({ service: "server.sync" })
@@ -108,6 +114,47 @@ export const SyncRoutes = lazy(() =>
})
},
)
.post(
"/steal",
describeRoute({
summary: "Steal session into workspace",
description: "Update a session to belong to the current workspace through the sync event system.",
operationId: "sync.steal",
responses: {
200: {
description: "Session stolen into workspace",
content: {
"application/json": {
schema: resolver(SessionPayload),
},
},
},
...errors(400),
},
}),
validator("json", SessionPayload),
async (c) => {
const body = c.req.valid("json")
const workspaceID = WorkspaceContext.workspaceID
if (!workspaceID) throw new Error("Cannot steal session without workspace context")
SyncEvent.run(Session.Event.Updated, {
sessionID: body.sessionID,
info: {
workspaceID,
},
})
log.info("sync session stolen", {
sessionID: body.sessionID,
workspaceID,
})
return c.json({
sessionID: body.sessionID,
})
},
)
.post(
"/history",
describeRoute({

View File

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

View File

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

View File

@@ -6,7 +6,7 @@ import { setTimeout as delay } from "node:timers/promises"
import { NodeHttpServer } from "@effect/platform-node"
import { Effect, Layer } from "effect"
import { HttpServer, HttpServerRequest, HttpServerResponse } from "effect/unstable/http"
import { asc, eq } from "drizzle-orm"
import { eq } from "drizzle-orm"
import * as Log from "@opencode-ai/core/util/log"
import { Flag } from "@opencode-ai/core/flag/flag"
import { GlobalBus, type GlobalEvent } from "@/bus/global"
@@ -16,11 +16,10 @@ import { ProjectTable } from "@/project/project.sql"
import { Instance } from "@/project/instance"
import { WithInstance } from "../../src/project/with-instance"
import { Session as SessionNs } from "@/session/session"
import { SessionID, MessageID, PartID } from "@/session/schema"
import { SessionID } from "@/session/schema"
import { SessionTable } from "@/session/session.sql"
import { ModelID, ProviderID } from "@/provider/schema"
import { SyncEvent } from "@/sync"
import { EventSequenceTable, EventTable } from "@/sync/event.sql"
import { EventSequenceTable } from "@/sync/event.sql"
import { resetDatabase } from "../fixture/db"
import { disposeAllInstances, provideTmpdirInstance, tmpdir } from "../fixture/fixture"
import { testEffect } from "../lib/effect"
@@ -111,8 +110,8 @@ async function withInstance<T>(fn: (dir: string) => T | Promise<T>) {
const runWorkspace = <A, E>(effect: Effect.Effect<A, E, WorkspaceOld.Service>) => AppRuntime.runPromise(effect)
const createWorkspace = (input: WorkspaceOld.CreateInput) =>
runWorkspace(WorkspaceOld.Service.use((workspace) => workspace.create(input)))
const restoreWorkspaceSession = (input: WorkspaceOld.SessionRestoreInput) =>
runWorkspace(WorkspaceOld.Service.use((workspace) => workspace.sessionRestore(input)))
const warpWorkspaceSession = (input: WorkspaceOld.SessionWarpInput) =>
runWorkspace(WorkspaceOld.Service.use((workspace) => workspace.sessionWarp(input)))
const listWorkspaces = (project: Parameters<WorkspaceOld.Interface["list"]>[0]) =>
runWorkspace(WorkspaceOld.Service.use((workspace) => workspace.list(project)))
const getWorkspace = (id: WorkspaceID) => runWorkspace(WorkspaceOld.Service.use((workspace) => workspace.get(id)))
@@ -317,48 +316,24 @@ function sessionSequence(sessionID: SessionID) {
)?.seq
}
function eventRows(sessionID: SessionID) {
function sessionSequenceOwner(sessionID: SessionID) {
return Database.use((db) =>
db
.select({ seq: EventTable.seq, type: EventTable.type, data: EventTable.data })
.from(EventTable)
.where(eq(EventTable.aggregate_id, sessionID))
.orderBy(asc(EventTable.seq))
.all(),
)
.select({ ownerID: EventSequenceTable.owner_id })
.from(EventSequenceTable)
.where(eq(EventSequenceTable.aggregate_id, sessionID))
.get(),
)?.ownerID
}
function sessionUpdatedType() {
return SyncEvent.versionedType(SessionNs.Event.Updated.type, SessionNs.Event.Updated.version)
}
function replaceSessionEvents(sessionID: SessionID, count: number) {
Database.use((db) => {
db.delete(EventSequenceTable).where(eq(EventSequenceTable.aggregate_id, sessionID)).run()
if (count === 0) return
db.insert(EventSequenceTable)
.values({ aggregate_id: sessionID, seq: count - 1 })
.run()
db.insert(EventTable)
.values(
Array.from({ length: count }, (_, i) => ({
id: `evt_${unique(`manual-${i}`)}`,
aggregate_id: sessionID,
seq: i,
type: sessionUpdatedType(),
data: { sessionID, info: { title: `manual ${i}` } },
})),
)
.run()
})
}
describe("workspace-old schemas and exports", () => {
test("keeps the historical event type names", () => {
expect(WorkspaceOld.Event.Ready.type).toBe("workspace.ready")
expect(WorkspaceOld.Event.Failed.type).toBe("workspace.failed")
expect(WorkspaceOld.Event.Restore.type).toBe("workspace.restore")
expect(WorkspaceOld.Event.Status.type).toBe("workspace.status")
})
@@ -375,17 +350,6 @@ describe("workspace-old schemas and exports", () => {
expect(() => WorkspaceOld.CreateInput.zod.parse({ ...input, id: "bad" })).toThrow()
expect(() => WorkspaceOld.CreateInput.zod.parse({ ...input, branch: 1 })).toThrow()
})
test("validates session restore input", () => {
const input = {
workspaceID: WorkspaceID.ascending("wrk_schema_restore"),
sessionID: SessionID.descending("ses_schema_restore"),
}
expect(WorkspaceOld.SessionRestoreInput.zod.parse(input)).toEqual(input)
expect(() => WorkspaceOld.SessionRestoreInput.zod.parse({ ...input, workspaceID: "bad" })).toThrow()
expect(() => WorkspaceOld.SessionRestoreInput.zod.parse({ ...input, sessionID: "bad" })).toThrow()
})
})
describe("workspace-old CRUD", () => {
@@ -651,6 +615,144 @@ describe("workspace-old CRUD", () => {
expect(await getWorkspace(info.id)).toBeUndefined()
})
})
test("sessionWarp moves a session into a local workspace and claims ownership", async () => {
await withInstance(async (dir) => {
const previousType = unique("warp-prev-local")
const targetType = unique("warp-target-local")
const previous = workspaceInfo(Instance.project.id, previousType)
const target = workspaceInfo(Instance.project.id, targetType)
insertWorkspace(previous)
insertWorkspace(target)
registerAdapter(Instance.project.id, previousType, localAdapter(path.join(dir, "warp-prev-local")).adapter)
registerAdapter(Instance.project.id, targetType, localAdapter(path.join(dir, "warp-target-local")).adapter)
const session = await AppRuntime.runPromise(SessionNs.Service.use((svc) => svc.create({})))
attachSessionToWorkspace(session.id, previous.id)
await warpWorkspaceSession({ workspaceID: target.id, sessionID: session.id })
expect(
Database.use((db) =>
db
.select({ workspaceID: SessionTable.workspace_id })
.from(SessionTable)
.where(eq(SessionTable.id, session.id))
.get(),
)?.workspaceID,
).toBe(target.id)
expect(sessionSequenceOwner(session.id)).toBe(target.id)
})
})
test("sessionWarp detaches a session to the local project and claims project ownership", async () => {
await withInstance(async (dir) => {
const previousType = unique("warp-detach-local")
const previous = workspaceInfo(Instance.project.id, previousType)
insertWorkspace(previous)
registerAdapter(Instance.project.id, previousType, localAdapter(path.join(dir, "warp-detach-local")).adapter)
const session = await AppRuntime.runPromise(SessionNs.Service.use((svc) => svc.create({})))
attachSessionToWorkspace(session.id, previous.id)
await warpWorkspaceSession({ workspaceID: null, sessionID: session.id })
expect(
Database.use((db) =>
db
.select({ workspaceID: SessionTable.workspace_id })
.from(SessionTable)
.where(eq(SessionTable.id, session.id))
.get(),
)?.workspaceID,
).toBeNull()
expect(sessionSequenceOwner(session.id)).toBe(Instance.project.id)
})
})
it.live("sessionWarp syncs previous remote history, replays it, steals, and claims the sequence", () => {
const calls: FetchCall[] = []
let historySessionID: SessionID | undefined
let historyNextSeq = 0
return Effect.gen(function* () {
yield* HttpServer.serveEffect()(
Effect.gen(function* () {
const req = yield* HttpServerRequest.HttpServerRequest
const bodyText = yield* req.text
const call = {
url: new URL(req.url, "http://localhost"),
method: req.method,
headers: new Headers(req.headers),
bodyText,
json: bodyText ? JSON.parse(bodyText) : undefined,
}
calls.push(call)
if (call.url.pathname === "/warp-source/sync/history") {
return yield* HttpServerResponse.json([
{
id: `evt_${unique("warp-source-history")}`,
aggregate_id: historySessionID!,
seq: historyNextSeq,
type: sessionUpdatedType(),
data: { sessionID: historySessionID!, info: { title: "from source history" } },
},
])
}
if (call.url.pathname === "/warp-target/sync/replay")
return yield* HttpServerResponse.json({ sessionID: "ok" })
if (call.url.pathname === "/warp-target/sync/steal")
return yield* HttpServerResponse.json({ sessionID: "ok" })
return HttpServerResponse.text("unexpected", { status: 500 })
}),
)
const url = yield* serverUrl()
yield* provideTmpdirInstance(
() =>
Effect.gen(function* () {
const workspace = yield* WorkspaceOld.Service
const sessionSvc = yield* SessionNs.Service
const previousType = unique("warp-remote-source")
const targetType = unique("warp-remote-target")
const previous = workspaceInfo(Instance.project.id, previousType)
const target = workspaceInfo(Instance.project.id, targetType, { directory: "remote-target-dir" })
insertWorkspace(previous)
insertWorkspace(target)
registerAdapter(Instance.project.id, previousType, remoteAdapter(`${url}/warp-source`).adapter)
registerAdapter(Instance.project.id, targetType, remoteAdapter(`${url}/warp-target`).adapter)
const session = yield* sessionSvc.create({})
attachSessionToWorkspace(session.id, previous.id)
historySessionID = session.id
historyNextSeq = (sessionSequence(session.id) ?? -1) + 1
yield* workspace.sessionWarp({ workspaceID: target.id, sessionID: session.id })
expect(calls.map((call) => `${call.method} ${call.url.pathname}`)).toEqual([
"POST /warp-source/sync/history",
"POST /warp-target/sync/replay",
"POST /warp-target/sync/steal",
])
expect(calls[0].json).toEqual({ [session.id]: historyNextSeq - 1 })
expect(calls[1].json).toMatchObject({
directory: "remote-target-dir",
events: [
{
aggregateID: session.id,
seq: 0,
type: SyncEvent.versionedType(SessionNs.Event.Created.type, SessionNs.Event.Created.version),
},
{
aggregateID: session.id,
seq: historyNextSeq,
type: sessionUpdatedType(),
},
],
})
expect(calls[2].json).toEqual({ sessionID: session.id })
expect((yield* sessionSvc.get(session.id)).title).toBe("from source history")
expect(sessionSequenceOwner(session.id)).toBe(target.id)
}),
{ git: true },
)
})
})
})
describe("workspace-old sync state", () => {
@@ -1215,313 +1317,3 @@ describe("workspace-old waitForSync", () => {
})
}, 7000)
})
describe("workspace-old sessionRestore", () => {
test("throws when the workspace is missing", async () => {
await withInstance(async () => {
await expect(
restoreWorkspaceSession({
workspaceID: WorkspaceID.ascending("wrk_restore_missing"),
sessionID: SessionID.descending("ses_restore_missing_workspace"),
}),
).rejects.toThrow("Workspace not found: wrk_restore_missing")
})
})
test("throws when switching a missing session fails", async () => {
await withInstance(async (dir) => {
const type = unique("restore-missing-session")
const info = workspaceInfo(Instance.project.id, type, { directory: dir })
insertWorkspace(info)
registerAdapter(Instance.project.id, type, localAdapter(dir).adapter)
await expect(
restoreWorkspaceSession({ workspaceID: info.id, sessionID: SessionID.descending("ses_missing_restore") }),
).rejects.toThrow("NotFoundError")
await removeWorkspace(info.id)
})
})
it.live("posts remote replay batches of 10, emits progress, and includes the workspace update event", () => {
const replay: FetchCall[] = []
return Effect.gen(function* () {
yield* HttpServer.serveEffect()(
Effect.gen(function* () {
const req = yield* HttpServerRequest.HttpServerRequest
const bodyText = yield* req.text
const call = {
url: new URL(req.url, "http://localhost"),
method: req.method,
headers: new Headers(req.headers),
bodyText,
json: bodyText ? JSON.parse(bodyText) : undefined,
}
if (call.url.pathname === "/restore/sync/replay") {
replay.push(call)
return HttpServerResponse.fromWeb(Response.json({ ok: true }))
}
return HttpServerResponse.text("unexpected", { status: 500 })
}),
)
const url = yield* serverUrl()
yield* provideTmpdirInstance(
(dir) =>
Effect.gen(function* () {
const workspace = yield* WorkspaceOld.Service
const sessionSvc = yield* SessionNs.Service
const captured = captureGlobalEvents()
try {
const type = unique("restore-remote")
const info = workspaceInfo(Instance.project.id, type, { directory: dir })
insertWorkspace(info)
registerAdapter(
Instance.project.id,
type,
remoteAdapter(`${url}/restore/?ignored=1#hash`, {
directory: dir,
headers: { authorization: "Bearer restore" },
}).adapter,
)
const session = yield* sessionSvc.create({ title: "restore remote" })
replaceSessionEvents(session.id, 24)
const result = yield* workspace.sessionRestore({ workspaceID: info.id, sessionID: session.id })
expect(result).toEqual({ total: 3 })
expect(replay).toHaveLength(3)
expect(replay.map((call) => call.url.pathname + call.url.search + call.url.hash)).toEqual([
"/restore/sync/replay",
"/restore/sync/replay",
"/restore/sync/replay",
])
expect(replay.every((call) => call.headers.get("authorization") === "Bearer restore")).toBe(true)
expect(replay.every((call) => call.headers.get("content-type") === "application/json")).toBe(true)
expect(replay.map((call) => (call.json as { events: unknown[] }).events.length)).toEqual([10, 10, 5])
expect(replay.map((call) => (call.json as { directory: string }).directory)).toEqual([dir, dir, dir])
expect(
replay.flatMap((call) =>
(call.json as { events: Array<{ seq: number }> }).events.map((event) => event.seq),
),
).toEqual(Array.from({ length: 25 }, (_, i) => i))
expect(
(replay[2].json as { events: Array<{ seq: number; type: string; data: unknown }> }).events.at(-1),
).toMatchObject({
seq: 24,
type: sessionUpdatedType(),
data: { sessionID: session.id, info: { workspaceID: info.id } },
})
expect((yield* sessionSvc.get(session.id)).workspaceID).toBe(info.id)
expect(
captured.events
.filter(
(event) => event.workspace === info.id && event.payload.type === WorkspaceOld.Event.Restore.type,
)
.map((event) => event.payload.properties.step),
).toEqual([0, 1, 2, 3])
yield* workspace.remove(info.id)
} finally {
captured.dispose()
}
}),
{ git: true },
)
})
})
it.live("remote restore sends an empty directory string when the workspace directory is null", () => {
const replay: FetchCall[] = []
return Effect.gen(function* () {
yield* HttpServer.serveEffect()(
Effect.gen(function* () {
const req = yield* HttpServerRequest.HttpServerRequest
const bodyText = yield* req.text
replay.push({
url: new URL(req.url, "http://localhost"),
method: req.method,
headers: new Headers(req.headers),
bodyText,
json: bodyText ? JSON.parse(bodyText) : undefined,
})
return HttpServerResponse.fromWeb(Response.json({ ok: true }))
}),
)
const url = yield* serverUrl()
yield* provideTmpdirInstance(
() =>
Effect.gen(function* () {
const workspace = yield* WorkspaceOld.Service
const sessionSvc = yield* SessionNs.Service
const type = unique("restore-null-dir")
const info = workspaceInfo(Instance.project.id, type, { directory: null })
insertWorkspace(info)
registerAdapter(Instance.project.id, type, remoteAdapter(`${url}/null-dir`, { directory: null }).adapter)
const session = yield* sessionSvc.create({ title: "null dir" })
replaceSessionEvents(session.id, 0)
expect(yield* workspace.sessionRestore({ workspaceID: info.id, sessionID: session.id })).toEqual({
total: 1,
})
expect((replay[0].json as { directory: string }).directory).toBe("")
expect((replay[0].json as { events: unknown[] }).events).toHaveLength(1)
yield* workspace.remove(info.id)
}),
{ git: true },
)
})
})
it.live("remote restore failures include status and body and do not emit completed batch progress", () => {
const replay: FetchCall[] = []
return Effect.gen(function* () {
yield* HttpServer.serveEffect()(
Effect.gen(function* () {
const req = yield* HttpServerRequest.HttpServerRequest
const bodyText = yield* req.text
replay.push({
url: new URL(req.url, "http://localhost"),
method: req.method,
headers: new Headers(req.headers),
bodyText,
json: bodyText ? JSON.parse(bodyText) : undefined,
})
return HttpServerResponse.text("replay failed", { status: 503 })
}),
)
const url = yield* serverUrl()
yield* provideTmpdirInstance(
(dir) =>
Effect.gen(function* () {
const workspace = yield* WorkspaceOld.Service
const sessionSvc = yield* SessionNs.Service
const captured = captureGlobalEvents()
try {
const type = unique("restore-remote-fail")
const info = workspaceInfo(Instance.project.id, type, { directory: dir })
insertWorkspace(info)
registerAdapter(Instance.project.id, type, remoteAdapter(`${url}/fail`, { directory: dir }).adapter)
const session = yield* sessionSvc.create({ title: "restore fail" })
replaceSessionEvents(session.id, 11)
const error = yield* Effect.flip(
workspace.sessionRestore({ workspaceID: info.id, sessionID: session.id }),
)
expect((error as Error).message).toContain(
`Failed to replay session ${session.id} into workspace ${info.id}: HTTP 503 replay failed`,
)
expect(replay).toHaveLength(1)
expect(
captured.events
.filter(
(event) => event.workspace === info.id && event.payload.type === WorkspaceOld.Event.Restore.type,
)
.map((event) => event.payload.properties.step),
).toEqual([0])
yield* workspace.remove(info.id)
} finally {
captured.dispose()
}
}),
{ git: true },
)
})
})
it.live("local restore replays batches and emits progress", () =>
provideTmpdirInstance(
(dir) =>
Effect.gen(function* () {
const workspace = yield* WorkspaceOld.Service
const sessionSvc = yield* SessionNs.Service
const captured = captureGlobalEvents()
try {
const type = unique("restore-local")
const info = workspaceInfo(Instance.project.id, type, { directory: dir })
insertWorkspace(info)
registerAdapter(Instance.project.id, type, localAdapter(dir).adapter)
const session = yield* sessionSvc.create({ title: "restore local" })
replaceSessionEvents(session.id, 20)
expect(yield* workspace.sessionRestore({ workspaceID: info.id, sessionID: session.id })).toEqual({
total: 3,
})
expect((yield* sessionSvc.get(session.id)).workspaceID).toBe(info.id)
expect(eventRows(session.id).map((row) => row.seq)).toEqual(Array.from({ length: 21 }, (_, i) => i))
expect(
captured.events
.filter(
(event) => event.workspace === info.id && event.payload.type === WorkspaceOld.Event.Restore.type,
)
.map((event) => event.payload.properties.step),
).toEqual([0, 1, 2, 3])
yield* workspace.remove(info.id)
} finally {
captured.dispose()
}
}),
{ git: true },
),
)
it.live("session restore includes real message and part events in sequence order", () => {
const replay: FetchCall[] = []
return Effect.gen(function* () {
yield* HttpServer.serveEffect()(
Effect.gen(function* () {
const req = yield* HttpServerRequest.HttpServerRequest
const bodyText = yield* req.text
replay.push({
url: new URL(req.url, "http://localhost"),
method: req.method,
headers: new Headers(req.headers),
bodyText,
json: bodyText ? JSON.parse(bodyText) : undefined,
})
return HttpServerResponse.fromWeb(Response.json({ ok: true }))
}),
)
const url = yield* serverUrl()
yield* provideTmpdirInstance(
(dir) =>
Effect.gen(function* () {
const workspace = yield* WorkspaceOld.Service
const sessionSvc = yield* SessionNs.Service
const type = unique("restore-real-events")
const info = workspaceInfo(Instance.project.id, type, { directory: dir })
insertWorkspace(info)
registerAdapter(Instance.project.id, type, remoteAdapter(`${url}/real`, { directory: dir }).adapter)
const session = yield* sessionSvc.create({ title: "real events" })
for (let i = 0; i < 3; i++) {
const msg = yield* sessionSvc.updateMessage({
id: MessageID.ascending(),
role: "user",
sessionID: session.id,
agent: "build",
model: { providerID: ProviderID.make("test"), modelID: ModelID.make("test") },
time: { created: Date.now() },
})
yield* sessionSvc.updatePart({
id: PartID.ascending(),
sessionID: session.id,
messageID: msg.id,
type: "text",
text: `message ${i}`,
})
}
const before = eventRows(session.id)
expect(yield* workspace.sessionRestore({ workspaceID: info.id, sessionID: session.id })).toEqual({
total: 1,
})
const posted = (replay[0].json as { events: Array<{ seq: number; type: string }> }).events
expect(posted.map((event) => event.seq)).toEqual([...before.map((row) => row.seq), before.at(-1)!.seq + 1])
expect(posted.map((event) => event.type).slice(0, -1)).toEqual(before.map((row) => row.type))
expect(posted.at(-1)?.type).toBe(sessionUpdatedType())
yield* workspace.remove(info.id)
}),
{ git: true },
)
})
})
})

View File

@@ -1,5 +1,6 @@
import { $ } from "bun"
import { afterEach, describe, expect, test } from "bun:test"
import { parsePatch } from "diff"
import { Effect } from "effect"
import fs from "fs/promises"
import path from "path"
@@ -288,6 +289,28 @@ describe("Vcs diff", () => {
})
})
test("diff('git') keeps carriage returns inside patch hunks", async () => {
await using tmp = await tmpdir({ git: true })
await fs.writeFile(path.join(tmp.path, "file.txt"), "keep\nsame\rdiff --git inside\ndelete\n", "utf-8")
await $`git add .`.cwd(tmp.path).quiet()
await $`git commit --no-gpg-sign -m "add file"`.cwd(tmp.path).quiet()
await fs.writeFile(path.join(tmp.path, "file.txt"), "keep\nadd\nsame\rdiff --git inside\n", "utf-8")
await withVcsOnly(tmp.path, async () => {
const diff = await AppRuntime.runPromise(
Effect.gen(function* () {
const vcs = yield* Vcs.Service
return yield* vcs.diff("git")
}),
)
const file = diff.find((item) => item.file === "file.txt")
expect(file?.patch).toContain(" same\rdiff --git inside")
expect(file?.patch).toContain("-delete")
expect(() => parsePatch(file?.patch ?? "")).not.toThrow()
})
}, 20_000)
test("diff('branch') returns changes against default branch", async () => {
await using tmp = await tmpdir({ git: true })
await $`git branch -M main`.cwd(tmp.path).quiet()

View File

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

View File

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

View File

@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode-ai/plugin",
"version": "1.14.33",
"version": "1.14.35",
"type": "module",
"license": "MIT",
"scripts": {

View File

@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode-ai/sdk",
"version": "1.14.33",
"version": "1.14.35",
"type": "module",
"license": "MIT",
"scripts": {

View File

@@ -35,9 +35,9 @@ import type {
ExperimentalWorkspaceListResponses,
ExperimentalWorkspaceRemoveErrors,
ExperimentalWorkspaceRemoveResponses,
ExperimentalWorkspaceSessionRestoreErrors,
ExperimentalWorkspaceSessionRestoreResponses,
ExperimentalWorkspaceStatusResponses,
ExperimentalWorkspaceWarpErrors,
ExperimentalWorkspaceWarpResponses,
FileListResponses,
FilePartInput,
FilePartSource,
@@ -169,6 +169,8 @@ import type {
SyncReplayErrors,
SyncReplayResponses,
SyncStartResponses,
SyncStealErrors,
SyncStealResponses,
TextPartInput,
ToolIdsErrors,
ToolIdsResponses,
@@ -1009,15 +1011,15 @@ export class Workspace extends HeyApiClient {
}
/**
* Restore session into workspace
* Warp session into workspace
*
* Replay a session's sync events into the target workspace in batches.
* Move a session's sync history into the target workspace, or detach it to the local project.
*/
public sessionRestore<ThrowOnError extends boolean = false>(
parameters: {
id: string
public warp<ThrowOnError extends boolean = false>(
parameters?: {
directory?: string
workspace?: string
id?: string
sessionID?: string
},
options?: Options<never, ThrowOnError>,
@@ -1027,20 +1029,20 @@ export class Workspace extends HeyApiClient {
[
{
args: [
{ in: "path", key: "id" },
{ in: "query", key: "directory" },
{ in: "query", key: "workspace" },
{ in: "body", key: "id" },
{ in: "body", key: "sessionID" },
],
},
],
)
return (options?.client ?? this.client).post<
ExperimentalWorkspaceSessionRestoreResponses,
ExperimentalWorkspaceSessionRestoreErrors,
ExperimentalWorkspaceWarpResponses,
ExperimentalWorkspaceWarpErrors,
ThrowOnError
>({
url: "/experimental/workspace/{id}/session-restore",
url: "/experimental/workspace/warp",
...options,
...params,
headers: {
@@ -3956,6 +3958,43 @@ export class Sync extends HeyApiClient {
})
}
/**
* Steal session into workspace
*
* Update a session to belong to the current workspace through the sync event system.
*/
public steal<ThrowOnError extends boolean = false>(
parameters?: {
directory?: string
workspace?: string
sessionID?: string
},
options?: Options<never, ThrowOnError>,
) {
const params = buildClientParams(
[parameters],
[
{
args: [
{ in: "query", key: "directory" },
{ in: "query", key: "workspace" },
{ in: "body", key: "sessionID" },
],
},
],
)
return (options?.client ?? this.client).post<SyncStealResponses, SyncStealErrors, ThrowOnError>({
url: "/sync/steal",
...options,
...params,
headers: {
"Content-Type": "application/json",
...options?.headers,
...params.headers,
},
})
}
private _history?: History
get history(): History {
return (this._history ??= new History({ client: this.client }))

View File

@@ -35,7 +35,6 @@ export type Event =
| EventVcsBranchUpdated
| EventWorkspaceReady
| EventWorkspaceFailed
| EventWorkspaceRestore
| EventWorkspaceStatus
| EventWorktreeReady
| EventWorktreeFailed
@@ -801,7 +800,6 @@ export type GlobalEvent = {
| EventVcsBranchUpdated
| EventWorkspaceReady
| EventWorkspaceFailed
| EventWorkspaceRestore
| EventWorkspaceStatus
| EventWorktreeReady
| EventWorktreeFailed
@@ -2478,17 +2476,6 @@ export type EventWorkspaceFailed = {
}
}
export type EventWorkspaceRestore = {
id: string
type: "workspace.restore"
properties: {
workspaceID: string
sessionID: string
total: number
step: number
}
}
export type EventWorkspaceStatus = {
id: string
type: "workspace.status"
@@ -6023,6 +6010,38 @@ export type 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 = {
body?: {
[key: string]: number
@@ -6644,41 +6663,37 @@ export type ExperimentalWorkspaceRemoveResponses = {
export type ExperimentalWorkspaceRemoveResponse =
ExperimentalWorkspaceRemoveResponses[keyof ExperimentalWorkspaceRemoveResponses]
export type ExperimentalWorkspaceSessionRestoreData = {
export type ExperimentalWorkspaceWarpData = {
body?: {
id: string
sessionID: string
}
path: {
id: string
}
path?: never
query?: {
directory?: string
workspace?: string
}
url: "/experimental/workspace/{id}/session-restore"
url: "/experimental/workspace/warp"
}
export type ExperimentalWorkspaceSessionRestoreErrors = {
export type ExperimentalWorkspaceWarpErrors = {
/**
* Bad request
*/
400: BadRequestError
}
export type ExperimentalWorkspaceSessionRestoreError =
ExperimentalWorkspaceSessionRestoreErrors[keyof ExperimentalWorkspaceSessionRestoreErrors]
export type ExperimentalWorkspaceWarpError = ExperimentalWorkspaceWarpErrors[keyof ExperimentalWorkspaceWarpErrors]
export type ExperimentalWorkspaceSessionRestoreResponses = {
export type ExperimentalWorkspaceWarpResponses = {
/**
* Session replay started
* Session warped
*/
200: {
total: number
}
204: void
}
export type ExperimentalWorkspaceSessionRestoreResponse =
ExperimentalWorkspaceSessionRestoreResponses[keyof ExperimentalWorkspaceSessionRestoreResponses]
export type ExperimentalWorkspaceWarpResponse =
ExperimentalWorkspaceWarpResponses[keyof ExperimentalWorkspaceWarpResponses]
export type PtyConnectData = {
body?: never

View File

@@ -6785,6 +6785,84 @@
]
}
},
"/sync/steal": {
"post": {
"tags": ["sync"],
"operationId": "sync.steal",
"parameters": [
{
"name": "directory",
"in": "query",
"required": false,
"schema": {
"type": "string"
}
},
{
"name": "workspace",
"in": "query",
"required": false,
"schema": {
"type": "string"
}
}
],
"responses": {
"200": {
"description": "Session stolen into workspace",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"sessionID": {
"type": "string"
}
},
"required": ["sessionID"],
"additionalProperties": false,
"description": "Session stolen into workspace"
}
}
}
},
"400": {
"description": "Bad request",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/BadRequestError"
}
}
}
}
},
"description": "Update a session to belong to the current workspace through the sync event system.",
"summary": "Steal session into workspace",
"requestBody": {
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"sessionID": {
"type": "string"
}
},
"required": ["sessionID"],
"additionalProperties": false
}
}
}
},
"x-codeSamples": [
{
"lang": "js",
"source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.sync.steal({\n ...\n})"
}
]
}
},
"/sync/history": {
"post": {
"tags": ["sync"],
@@ -8281,10 +8359,10 @@
]
}
},
"/experimental/workspace/{id}/session-restore": {
"/experimental/workspace/warp": {
"post": {
"tags": ["workspace"],
"operationId": "experimental.workspace.sessionRestore",
"operationId": "experimental.workspace.warp",
"parameters": [
{
"name": "directory",
@@ -8301,36 +8379,11 @@
"schema": {
"type": "string"
}
},
{
"name": "id",
"in": "path",
"schema": {
"type": "string",
"pattern": "^wrk.*"
},
"required": true
}
],
"responses": {
"200": {
"description": "Session replay started",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"total": {
"type": "integer",
"minimum": 0
}
},
"required": ["total"],
"additionalProperties": false,
"description": "Session replay started"
}
}
}
"204": {
"description": "Session warped"
},
"400": {
"description": "Bad request",
@@ -8343,19 +8396,22 @@
}
}
},
"description": "Replay a session's sync events into the target workspace in batches.",
"summary": "Restore session into workspace",
"description": "Move a session's sync history into the target workspace, or detach it to the local project.",
"summary": "Warp session into workspace",
"requestBody": {
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"id": {
"type": "string"
},
"sessionID": {
"type": "string"
}
},
"required": ["sessionID"],
"required": ["id", "sessionID"],
"additionalProperties": false
}
}
@@ -8364,7 +8420,7 @@
"x-codeSamples": [
{
"lang": "js",
"source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.experimental.workspace.sessionRestore({\n ...\n})"
"source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.experimental.workspace.warp({\n ...\n})"
}
]
}
@@ -8538,9 +8594,6 @@
{
"$ref": "#/components/schemas/EventWorkspaceFailed"
},
{
"$ref": "#/components/schemas/EventWorkspaceRestore"
},
{
"$ref": "#/components/schemas/EventWorkspaceStatus"
},
@@ -10737,9 +10790,6 @@
{
"$ref": "#/components/schemas/EventWorkspaceFailed"
},
{
"$ref": "#/components/schemas/EventWorkspaceRestore"
},
{
"$ref": "#/components/schemas/EventWorkspaceStatus"
},
@@ -15793,41 +15843,6 @@
"required": ["id", "type", "properties"],
"additionalProperties": false
},
"EventWorkspaceRestore": {
"type": "object",
"properties": {
"id": {
"type": "string"
},
"type": {
"type": "string",
"enum": ["workspace.restore"]
},
"properties": {
"type": "object",
"properties": {
"workspaceID": {
"type": "string"
},
"sessionID": {
"type": "string"
},
"total": {
"type": "integer",
"minimum": 0
},
"step": {
"type": "integer",
"minimum": 0
}
},
"required": ["workspaceID", "sessionID", "total", "step"],
"additionalProperties": false
}
},
"required": ["id", "type", "properties"],
"additionalProperties": false
},
"EventWorkspaceStatus": {
"type": "object",
"properties": {

View File

@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/slack",
"version": "1.14.33",
"version": "1.14.35",
"type": "module",
"license": "MIT",
"scripts": {

View File

@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/ui",
"version": "1.14.33",
"version": "1.14.35",
"type": "module",
"license": "MIT",
"exports": {
@@ -25,6 +25,8 @@
},
"scripts": {
"typecheck": "tsgo --noEmit",
"test": "bun test src",
"test:ci": "mkdir -p .artifacts/unit && bun test src --reporter=junit --reporter-outfile=.artifacts/unit/junit.xml",
"dev": "vite",
"generate:tailwind": "bun run script/tailwind.ts"
},

View File

@@ -19,6 +19,21 @@ describe("session diff", () => {
expect(text(view, "additions")).toBe("one\nthree\n")
})
test("keeps missing final newlines from unified patches", () => {
const diff = {
file: "a.ts",
patch:
"Index: a.ts\n===================================================================\n--- a.ts\t\n+++ a.ts\t\n@@ -1,2 +1,2 @@\n one\n-two\n\\ No newline at end of file\n+three\n\\ No newline at end of file\n",
additions: 1,
deletions: 1,
status: "modified" as const,
}
const view = normalize(diff)
expect(text(view, "deletions")).toBe("one\ntwo")
expect(text(view, "additions")).toBe("one\nthree")
})
test("converts legacy content into a patch", () => {
const diff = {
file: "a.ts",
@@ -34,4 +49,20 @@ describe("session diff", () => {
expect(text(view, "deletions")).toBe("one\n")
expect(text(view, "additions")).toBe("two\n")
})
test("ignores malformed persisted patches", () => {
const diff = {
file: "a.ts",
patch:
"diff --git a/a.ts b/a.ts\nindex ff4ceb2..65a1de0 100644\n--- a/a.ts\n+++ b/a.ts\n@@ -1,3 +1,3 @@\n keep\n+add\n same\r",
additions: 1,
deletions: 1,
status: "modified" as const,
}
const view = normalize(diff)
expect(view.patch).toBe(diff.patch)
expect(text(view, "deletions")).toBe("")
expect(text(view, "additions")).toBe("")
})
})

View File

@@ -27,26 +27,49 @@ const cache = new Map<string, FileDiffMetadata>()
function patch(diff: ReviewDiff) {
if (typeof diff.patch === "string") {
const [patch] = parsePatch(diff.patch)
try {
const [patch] = parsePatch(diff.patch)
const beforeLines: Array<{ text: string; newline: boolean }> = []
const afterLines: Array<{ text: string; newline: boolean }> = []
let previous: "-" | "+" | " " | undefined
const beforeLines = []
const afterLines = []
for (const hunk of patch.hunks) {
for (const line of hunk.lines) {
if (line.startsWith("\\")) {
if (previous === "-" || previous === " ") {
const before = beforeLines.at(-1)
if (before) before.newline = false
}
if (previous === "+" || previous === " ") {
const after = afterLines.at(-1)
if (after) after.newline = false
}
continue
}
for (const hunk of patch.hunks) {
for (const line of hunk.lines) {
if (line.startsWith("-")) {
beforeLines.push(line.slice(1))
} else if (line.startsWith("+")) {
afterLines.push(line.slice(1))
} else {
// context line (starts with ' ')
beforeLines.push(line.slice(1))
afterLines.push(line.slice(1))
if (line.startsWith("-")) {
beforeLines.push({ text: line.slice(1), newline: true })
previous = "-"
} else if (line.startsWith("+")) {
afterLines.push({ text: line.slice(1), newline: true })
previous = "+"
} else {
// context line (starts with ' ')
beforeLines.push({ text: line.slice(1), newline: true })
afterLines.push({ text: line.slice(1), newline: true })
previous = " "
}
}
}
}
return { before: beforeLines.join("\n"), after: afterLines.join("\n"), patch: diff.patch }
return {
before: beforeLines.map((line) => line.text + (line.newline ? "\n" : "")).join(""),
after: afterLines.map((line) => line.text + (line.newline ? "\n" : "")).join(""),
patch: diff.patch,
}
} catch {
return { before: "", after: "", patch: diff.patch }
}
}
return {
before: "before" in diff && typeof diff.before === "string" ? diff.before : "",

View File

@@ -2,7 +2,7 @@
"name": "@opencode-ai/web",
"type": "module",
"license": "MIT",
"version": "1.14.33",
"version": "1.14.35",
"scripts": {
"dev": "astro dev",
"dev:remote": "VITE_API_URL=https://api.opencode.ai astro dev",

View File

@@ -2,7 +2,7 @@
"name": "opencode",
"displayName": "opencode",
"description": "opencode for VS Code",
"version": "1.14.33",
"version": "1.14.35",
"publisher": "sst-dev",
"repository": {
"type": "git",

View File

@@ -26,6 +26,15 @@
"dependsOn": ["^build"],
"outputs": [".artifacts/unit/junit.xml"],
"passThroughEnv": ["*"]
},
"@opencode-ai/ui#test": {
"dependsOn": ["^build"],
"outputs": []
},
"@opencode-ai/ui#test:ci": {
"dependsOn": ["^build"],
"outputs": [".artifacts/unit/junit.xml"],
"passThroughEnv": ["*"]
}
}
}