feat(core): sync routes, refactor proxy, session restore, and more syncing (#22518)

This commit is contained in:
James Long
2026-04-15 10:18:48 -04:00
committed by GitHub
parent 47af00b245
commit af20191d1c
11 changed files with 1133 additions and 64 deletions
+238 -22
View File
@@ -1,11 +1,13 @@
import z from "zod" import z from "zod"
import { setTimeout as sleep } from "node:timers/promises" import { setTimeout as sleep } from "node:timers/promises"
import { fn } from "@/util/fn" import { fn } from "@/util/fn"
import { Database, eq } from "@/storage/db" import { Database, asc, eq } from "@/storage/db"
import { Project } from "@/project/project" import { Project } from "@/project/project"
import { BusEvent } from "@/bus/bus-event" import { BusEvent } from "@/bus/bus-event"
import { GlobalBus } from "@/bus/global" import { GlobalBus } from "@/bus/global"
import { SyncEvent } from "@/sync" import { SyncEvent } from "@/sync"
import { EventTable } from "@/sync/event.sql"
import { Flag } from "@/flag/flag"
import { Log } from "@/util/log" import { Log } from "@/util/log"
import { Filesystem } from "@/util/filesystem" import { Filesystem } from "@/util/filesystem"
import { ProjectID } from "@/project/schema" import { ProjectID } from "@/project/schema"
@@ -15,6 +17,11 @@ import { getAdaptor } from "./adaptors"
import { WorkspaceInfo } from "./types" import { WorkspaceInfo } from "./types"
import { WorkspaceID } from "./schema" import { WorkspaceID } from "./schema"
import { parseSSE } from "./sse" import { parseSSE } from "./sse"
import { Session } from "@/session"
import { SessionTable } from "@/session/session.sql"
import { SessionID } from "@/session/schema"
import { errorData } from "@/util/error"
import { AppRuntime } from "@/effect/app-runtime"
export namespace Workspace { export namespace Workspace {
export const Info = WorkspaceInfo.meta({ export const Info = WorkspaceInfo.meta({
@@ -29,6 +36,13 @@ export namespace Workspace {
}) })
export type ConnectionStatus = z.infer<typeof ConnectionStatus> export type ConnectionStatus = z.infer<typeof ConnectionStatus>
const Restore = z.object({
workspaceID: WorkspaceID.zod,
sessionID: SessionID.zod,
total: z.number().int().min(0),
step: z.number().int().min(0),
})
export const Event = { export const Event = {
Ready: BusEvent.define( Ready: BusEvent.define(
"workspace.ready", "workspace.ready",
@@ -42,6 +56,7 @@ export namespace Workspace {
message: z.string(), message: z.string(),
}), }),
), ),
Restore: BusEvent.define("workspace.restore", Restore),
Status: BusEvent.define("workspace.status", ConnectionStatus), Status: BusEvent.define("workspace.status", ConnectionStatus),
} }
@@ -102,11 +117,170 @@ export namespace Workspace {
return info return info
}) })
const SessionRestoreInput = z.object({
workspaceID: WorkspaceID.zod,
sessionID: SessionID.zod,
})
export const sessionRestore = fn(SessionRestoreInput, async (input) => {
log.info("session restore requested", {
workspaceID: input.workspaceID,
sessionID: input.sessionID,
})
try {
const space = await get(input.workspaceID)
if (!space) throw new Error(`Workspace not found: ${input.workspaceID}`)
const adaptor = await getAdaptor(space.projectID, space.type)
const target = await adaptor.target(space)
// Need to switch the workspace of the session
SyncEvent.run(Session.Event.Updated, {
sessionID: input.sessionID,
info: {
workspaceID: input.workspaceID,
},
})
const rows = Database.use((db) =>
db
.select({
id: EventTable.id,
aggregateID: EventTable.aggregate_id,
seq: EventTable.seq,
type: EventTable.type,
data: EventTable.data,
})
.from(EventTable)
.where(eq(EventTable.aggregate_id, input.sessionID))
.orderBy(asc(EventTable.seq))
.all(),
)
if (rows.length === 0) throw new Error(`No events found for session: ${input.sessionID}`)
const all = rows
const size = 10
const sets = Array.from({ length: Math.ceil(all.length / size) }, (_, i) => all.slice(i * size, (i + 1) * size))
const total = sets.length
log.info("session restore 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,
events: all.length,
batches: total,
first: all[0]?.seq,
last: all.at(-1)?.seq,
})
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") {
SyncEvent.replayAll(events)
log.info("session restore batch replayed locally", {
workspaceID: input.workspaceID,
sessionID: input.sessionID,
step: i + 1,
total,
events: events.length,
})
} else {
const url = route(target.url, "/sync/replay")
const headers = new Headers(target.headers)
headers.set("content-type", "application/json")
const res = await fetch(url, {
method: "POST",
headers,
body: JSON.stringify({
directory: space.directory ?? "",
events,
}),
})
if (!res.ok) {
const body = await res.text()
log.error("session restore batch failed", {
workspaceID: input.workspaceID,
sessionID: input.sessionID,
step: i + 1,
total,
status: res.status,
body,
})
throw new Error(
`Failed to replay session ${input.sessionID} into workspace ${input.workspaceID}: HTTP ${res.status} ${body}`,
)
}
log.info("session restore batch posted", {
workspaceID: input.workspaceID,
sessionID: input.sessionID,
step: i + 1,
total,
status: res.status,
})
}
GlobalBus.emit("event", {
directory: "global",
workspace: input.workspaceID,
payload: {
type: Event.Restore.type,
properties: {
workspaceID: input.workspaceID,
sessionID: input.sessionID,
total,
step: i + 1,
},
},
})
}
log.info("session restore complete", {
workspaceID: input.workspaceID,
sessionID: input.sessionID,
batches: total,
})
return {
total,
}
} catch (err) {
log.error("session restore failed", {
workspaceID: input.workspaceID,
sessionID: input.sessionID,
error: errorData(err),
})
throw err
}
})
export function list(project: Project.Info) { export function list(project: Project.Info) {
const rows = Database.use((db) => const rows = Database.use((db) =>
db.select().from(WorkspaceTable).where(eq(WorkspaceTable.project_id, project.id)).all(), db.select().from(WorkspaceTable).where(eq(WorkspaceTable.project_id, project.id)).all(),
) )
const spaces = rows.map(fromRow).sort((a, b) => a.id.localeCompare(b.id)) const spaces = rows.map(fromRow).sort((a, b) => a.id.localeCompare(b.id))
for (const space of spaces) startSync(space) for (const space of spaces) startSync(space)
return spaces return spaces
} }
@@ -120,13 +294,25 @@ export namespace Workspace {
}) })
export const remove = fn(WorkspaceID.zod, async (id) => { export const remove = fn(WorkspaceID.zod, async (id) => {
const sessions = Database.use((db) =>
db.select({ id: SessionTable.id }).from(SessionTable).where(eq(SessionTable.workspace_id, id)).all(),
)
for (const session of sessions) {
await AppRuntime.runPromise(Session.Service.use((svc) => svc.remove(session.id)))
}
const row = Database.use((db) => db.select().from(WorkspaceTable).where(eq(WorkspaceTable.id, id)).get()) const row = Database.use((db) => db.select().from(WorkspaceTable).where(eq(WorkspaceTable.id, id)).get())
if (row) { if (row) {
stopSync(id) stopSync(id)
const info = fromRow(row) const info = fromRow(row)
const adaptor = await getAdaptor(info.projectID, row.type) try {
adaptor.remove(info) const adaptor = await getAdaptor(info.projectID, row.type)
await adaptor.remove(info)
} catch (err) {
log.error("adaptor not available when removing workspace", { type: row.type })
}
Database.use((db) => db.delete(WorkspaceTable).where(eq(WorkspaceTable.id, id)).run()) Database.use((db) => db.delete(WorkspaceTable).where(eq(WorkspaceTable.id, id)).run())
return info return info
} }
@@ -156,51 +342,81 @@ export namespace Workspace {
const log = Log.create({ service: "workspace-sync" }) const log = Log.create({ service: "workspace-sync" })
async function workspaceEventLoop(space: Info, signal: AbortSignal) { function route(url: string | URL, path: string) {
log.info("starting sync: " + space.id) const next = new URL(url)
next.pathname = `${next.pathname.replace(/\/$/, "")}${path}`
next.search = ""
next.hash = ""
return next
}
async function syncWorkspace(space: Info, signal: AbortSignal) {
while (!signal.aborted) { while (!signal.aborted) {
log.info("connecting to sync: " + space.id) log.info("connecting to global sync", { workspace: space.name })
setStatus(space.id, "connecting")
const adaptor = await getAdaptor(space.projectID, space.type) const adaptor = await getAdaptor(space.projectID, space.type)
const target = await adaptor.target(space) const target = await adaptor.target(space)
if (target.type === "local") return if (target.type === "local") return
const res = await fetch(target.url + "/sync/event", { method: "GET", signal }).catch((err: unknown) => { const res = await fetch(route(target.url, "/global/event"), {
setStatus(space.id, "error", String(err)) method: "GET",
headers: target.headers,
signal,
}).catch((err: unknown) => {
setStatus(space.id, "error")
log.info("failed to connect to global sync", {
workspace: space.name,
error: err,
})
return undefined return undefined
}) })
if (!res || !res.ok || !res.body) {
log.info("failed to connect to sync: " + res?.status)
setStatus(space.id, "error", res ? `HTTP ${res.status}` : "no response") if (!res || !res.ok || !res.body) {
log.info("failed to connect to global sync", { workspace: space.name })
setStatus(space.id, "error")
await sleep(1000) await sleep(1000)
continue continue
} }
setStatus(space.id, "connected")
await parseSSE(res.body, signal, (evt) => {
const event = evt as SyncEvent.SerializedEvent
log.info("global sync connected", { workspace: space.name })
setStatus(space.id, "connected")
await parseSSE(res.body, signal, (evt: any) => {
try { try {
if (!event.type.startsWith("server.")) { if (!("payload" in evt)) return
SyncEvent.replay(event)
if (evt.payload.type === "sync") {
// This name -> type is temporary
SyncEvent.replay({ ...evt.payload, type: evt.payload.name } as SyncEvent.SerializedEvent)
} }
GlobalBus.emit("event", {
directory: evt.directory,
project: evt.project,
workspace: space.id,
payload: evt.payload,
})
} catch (err) { } catch (err) {
log.warn("failed to replay sync event", { log.info("failed to replay global event", {
workspaceID: space.id, workspaceID: space.id,
error: err, error: err,
}) })
} }
}) })
log.info("disconnected from global sync: " + space.id)
setStatus(space.id, "disconnected") setStatus(space.id, "disconnected")
log.info("disconnected to sync: " + space.id)
await sleep(250) // TODO: Implement exponential backoff
await sleep(1000)
} }
} }
function startSync(space: Info) { function startSync(space: Info) {
if (!Flag.OPENCODE_EXPERIMENTAL_WORKSPACES) return
if (space.type === "worktree") { if (space.type === "worktree") {
void Filesystem.exists(space.directory!).then((exists) => { void Filesystem.exists(space.directory!).then((exists) => {
setStatus(space.id, exists ? "connected" : "error", exists ? undefined : "directory does not exist") setStatus(space.id, exists ? "connected" : "error", exists ? undefined : "directory does not exist")
@@ -213,9 +429,9 @@ export namespace Workspace {
aborts.set(space.id, abort) aborts.set(space.id, abort)
setStatus(space.id, "disconnected") setStatus(space.id, "disconnected")
void workspaceEventLoop(space, abort.signal).catch((error) => { void syncWorkspace(space, abort.signal).catch((error) => {
setStatus(space.id, "error", String(error)) setStatus(space.id, "error", String(error))
log.warn("workspace sync listener failed", { log.warn("workspace listener failed", {
workspaceID: space.id, workspaceID: space.id,
error, error,
}) })
@@ -23,6 +23,7 @@ import { ConfigRoutes } from "./config"
import { ExperimentalRoutes } from "./experimental" import { ExperimentalRoutes } from "./experimental"
import { ProviderRoutes } from "./provider" import { ProviderRoutes } from "./provider"
import { EventRoutes } from "./event" import { EventRoutes } from "./event"
import { SyncRoutes } from "./sync"
import { WorkspaceRouterMiddleware } from "./middleware" import { WorkspaceRouterMiddleware } from "./middleware"
import { AppRuntime } from "@/effect/app-runtime" import { AppRuntime } from "@/effect/app-runtime"
@@ -37,6 +38,7 @@ export const InstanceRoutes = (upgrade: UpgradeWebSocket): Hono =>
.route("/permission", PermissionRoutes()) .route("/permission", PermissionRoutes())
.route("/question", QuestionRoutes()) .route("/question", QuestionRoutes())
.route("/provider", ProviderRoutes()) .route("/provider", ProviderRoutes())
.route("/sync", SyncRoutes())
.route("/", FileRoutes()) .route("/", FileRoutes())
.route("/", EventRoutes()) .route("/", EventRoutes())
.route("/mcp", McpRoutes()) .route("/mcp", McpRoutes())
@@ -11,9 +11,12 @@ import { Session } from "@/session"
import { SessionID } from "@/session/schema" import { SessionID } from "@/session/schema"
import { WorkspaceContext } from "@/control-plane/workspace-context" import { WorkspaceContext } from "@/control-plane/workspace-context"
import { AppRuntime } from "@/effect/app-runtime" import { AppRuntime } from "@/effect/app-runtime"
import { Log } from "@/util/log"
type Rule = { method?: string; path: string; exact?: boolean; action: "local" | "forward" } type Rule = { method?: string; path: string; exact?: boolean; action: "local" | "forward" }
const OPENCODE_WORKSPACE = process.env.OPENCODE_WORKSPACE
const RULES: Array<Rule> = [ const RULES: Array<Rule> = [
{ path: "/session/status", action: "forward" }, { path: "/session/status", action: "forward" },
{ method: "GET", path: "/session", action: "local" }, { method: "GET", path: "/session", action: "local" },
@@ -46,6 +49,8 @@ async function getSessionWorkspace(url: URL) {
} }
export function WorkspaceRouterMiddleware(upgrade: UpgradeWebSocket): MiddlewareHandler { export function WorkspaceRouterMiddleware(upgrade: UpgradeWebSocket): MiddlewareHandler {
const log = Log.create({ service: "workspace-router" })
return async (c, next) => { return async (c, next) => {
const raw = c.req.query("directory") || c.req.header("x-opencode-directory") || process.cwd() const raw = c.req.query("directory") || c.req.header("x-opencode-directory") || process.cwd()
const directory = Filesystem.resolve( const directory = Filesystem.resolve(
@@ -63,8 +68,22 @@ export function WorkspaceRouterMiddleware(upgrade: UpgradeWebSocket): Middleware
const sessionWorkspaceID = await getSessionWorkspace(url) const sessionWorkspaceID = await getSessionWorkspace(url)
const workspaceID = sessionWorkspaceID || url.searchParams.get("workspace") const workspaceID = sessionWorkspaceID || url.searchParams.get("workspace")
// If no workspace is provided we use the project if (!workspaceID || url.pathname.startsWith("/console") || OPENCODE_WORKSPACE) {
if (!workspaceID) { if (OPENCODE_WORKSPACE) {
return WorkspaceContext.provide({
workspaceID: WorkspaceID.make(OPENCODE_WORKSPACE),
async fn() {
return Instance.provide({
directory,
init: () => AppRuntime.runPromise(InstanceBootstrap),
async fn() {
return next()
},
})
},
})
}
return Instance.provide({ return Instance.provide({
directory, directory,
init: () => AppRuntime.runPromise(InstanceBootstrap), init: () => AppRuntime.runPromise(InstanceBootstrap),
@@ -77,16 +96,6 @@ export function WorkspaceRouterMiddleware(upgrade: UpgradeWebSocket): Middleware
const workspace = await Workspace.get(WorkspaceID.make(workspaceID)) const workspace = await Workspace.get(WorkspaceID.make(workspaceID))
if (!workspace) { if (!workspace) {
// Special-case deleting a session in case user's data in a
// weird state. Allow them to forcefully delete a synced session
// even if the remote workspace is not in their data.
//
// The lets the `DELETE /session/:id` endpoint through and we've
// made sure that it will run without an instance
if (url.pathname.match(/\/session\/[^/]+$/) && c.req.method === "DELETE") {
return next()
}
return new Response(`Workspace not found: ${workspaceID}`, { return new Response(`Workspace not found: ${workspaceID}`, {
status: 500, status: 500,
headers: { headers: {
@@ -95,6 +104,12 @@ export function WorkspaceRouterMiddleware(upgrade: UpgradeWebSocket): Middleware
}) })
} }
if (local(c.req.method, url.pathname)) {
// No instance provided because we are serving cached data; there
// is no instance to work with
return next()
}
const adaptor = await getAdaptor(workspace.projectID, workspace.type) const adaptor = await getAdaptor(workspace.projectID, workspace.type)
const target = await adaptor.target(workspace) const target = await adaptor.target(workspace)
@@ -112,24 +127,27 @@ export function WorkspaceRouterMiddleware(upgrade: UpgradeWebSocket): Middleware
}) })
} }
if (local(c.req.method, url.pathname)) { const proxyURL = new URL(target.url)
// No instance provided because we are serving cached data; there proxyURL.pathname = `${proxyURL.pathname.replace(/\/$/, "")}${url.pathname}`
// is no instance to work with proxyURL.search = url.search
return next() proxyURL.hash = url.hash
} proxyURL.searchParams.delete("workspace")
log.info("workspace proxy forwarding", {
workspaceID,
request: url.toString(),
target: String(target.url),
proxy: proxyURL.toString(),
})
if (c.req.header("upgrade")?.toLowerCase() === "websocket") { if (c.req.header("upgrade")?.toLowerCase() === "websocket") {
return ServerProxy.websocket(upgrade, target, c.req.raw, c.env) return ServerProxy.websocket(upgrade, proxyURL, target.headers, c.req.raw, c.env)
} }
const headers = new Headers(c.req.raw.headers) const headers = new Headers(c.req.raw.headers)
headers.delete("x-opencode-workspace") headers.delete("x-opencode-workspace")
return ServerProxy.http( const req = new Request(c.req.raw, { headers })
target, return ServerProxy.http(proxyURL, target.headers, req)
new Request(c.req.raw, {
headers,
}),
)
} }
} }
@@ -0,0 +1,118 @@
import z from "zod"
import { Hono } from "hono"
import { describeRoute, validator, resolver } from "hono-openapi"
import { SyncEvent } from "@/sync"
import { Database, asc, and, not, or, lte, eq } from "@/storage/db"
import { EventTable } from "@/sync/event.sql"
import { lazy } from "@/util/lazy"
import { Log } from "@/util/log"
import { errors } from "../error"
const ReplayEvent = z.object({
id: z.string(),
aggregateID: z.string(),
seq: z.number().int().min(0),
type: z.string(),
data: z.record(z.string(), z.unknown()),
})
const log = Log.create({ service: "server.sync" })
export const SyncRoutes = lazy(() =>
new Hono()
.post(
"/replay",
describeRoute({
summary: "Replay sync events",
description: "Validate and replay a complete sync event history.",
operationId: "sync.replay",
responses: {
200: {
description: "Replayed sync events",
content: {
"application/json": {
schema: resolver(
z.object({
sessionID: z.string(),
}),
),
},
},
},
...errors(400),
},
}),
validator(
"json",
z.object({
directory: z.string(),
events: z.array(ReplayEvent).min(1),
}),
),
async (c) => {
const body = c.req.valid("json")
const events = body.events
const source = events[0].aggregateID
log.info("sync replay requested", {
sessionID: source,
events: events.length,
first: events[0]?.seq,
last: events.at(-1)?.seq,
directory: body.directory,
})
SyncEvent.replayAll(events)
log.info("sync replay complete", {
sessionID: source,
events: events.length,
first: events[0]?.seq,
last: events.at(-1)?.seq,
})
return c.json({
sessionID: source,
})
},
)
.get(
"/history",
describeRoute({
summary: "List sync events",
description:
"List sync events for all aggregates. Keys are aggregate IDs the client already knows about, values are the last known sequence ID. Events with seq > value are returned for those aggregates. Aggregates not listed in the input get their full history.",
operationId: "sync.history.list",
responses: {
200: {
description: "Sync events",
content: {
"application/json": {
schema: resolver(
z.array(
z.object({
id: z.string(),
aggregate_id: z.string(),
seq: z.number(),
type: z.string(),
data: z.record(z.string(), z.unknown()),
}),
),
),
},
},
},
...errors(400),
},
}),
validator("json", z.record(z.string(), z.number().int().min(0))),
async (c) => {
const body = c.req.valid("json")
const exclude = Object.entries(body)
const where =
exclude.length > 0
? not(or(...exclude.map(([id, seq]) => and(eq(EventTable.aggregate_id, id), lte(EventTable.seq, seq))))!)
: undefined
const rows = Database.use((db) => db.select().from(EventTable).where(where).orderBy(asc(EventTable.seq)).all())
return c.json(rows)
},
),
)
@@ -6,12 +6,10 @@ import { Workspace } from "../../control-plane/workspace"
import { Instance } from "../../project/instance" import { Instance } from "../../project/instance"
import { errors } from "../error" import { errors } from "../error"
import { lazy } from "../../util/lazy" import { lazy } from "../../util/lazy"
import { Log } from "@/util/log"
import { errorData } from "@/util/error"
const WorkspaceAdaptor = z.object({ const log = Log.create({ service: "server.workspace" })
type: z.string(),
name: z.string(),
description: z.string(),
})
export const WorkspaceRoutes = lazy(() => export const WorkspaceRoutes = lazy(() =>
new Hono() new Hono()
@@ -26,7 +24,15 @@ export const WorkspaceRoutes = lazy(() =>
description: "Workspace adaptors", description: "Workspace adaptors",
content: { content: {
"application/json": { "application/json": {
schema: resolver(z.array(WorkspaceAdaptor)), schema: resolver(
z.array(
z.object({
type: z.string(),
name: z.string(),
description: z.string(),
}),
),
),
}, },
}, },
}, },
@@ -140,5 +146,58 @@ export const WorkspaceRoutes = lazy(() =>
const { id } = c.req.valid("param") const { id } = c.req.valid("param")
return c.json(await Workspace.remove(id)) return c.json(await Workspace.remove(id))
}, },
)
.post(
"/:id/session-restore",
describeRoute({
summary: "Restore session into workspace",
description: "Replay a session's sync events into the target workspace in batches.",
operationId: "experimental.workspace.sessionRestore",
responses: {
200: {
description: "Session replay started",
content: {
"application/json": {
schema: resolver(
z.object({
total: z.number().int().min(0),
}),
),
},
},
},
...errors(400),
},
}),
validator("param", z.object({ id: Workspace.Info.shape.id })),
validator("json", Workspace.sessionRestore.schema.omit({ workspaceID: true })),
async (c) => {
const { id } = c.req.valid("param")
const body = c.req.valid("json")
log.info("session restore route requested", {
workspaceID: id,
sessionID: body.sessionID,
directory: Instance.directory,
})
try {
const result = await Workspace.sessionRestore({
workspaceID: id,
...body,
})
log.info("session restore route complete", {
workspaceID: id,
sessionID: body.sessionID,
total: result.total,
})
return c.json(result)
} catch (err) {
log.error("session restore route failed", {
workspaceID: id,
sessionID: body.sessionID,
error: errorData(err),
})
throw err
}
},
), ),
) )
+1 -1
View File
@@ -86,7 +86,7 @@ const zipped = compress()
export const CompressionMiddleware: MiddlewareHandler = (c, next) => { export const CompressionMiddleware: MiddlewareHandler = (c, next) => {
const path = c.req.path const path = c.req.path
const method = c.req.method const method = c.req.method
if (path === "/event" || path === "/global/event" || path === "/global/sync-event") return next() if (path === "/event" || path === "/global/event") return next()
if (method === "POST" && /\/session\/[^/]+\/(message|prompt_async)$/.test(path)) return next() if (method === "POST" && /\/session\/[^/]+\/(message|prompt_async)$/.test(path)) return next()
return zipped(c, next) return zipped(c, next)
} }
+44 -11
View File
@@ -1,6 +1,6 @@
import type { Target } from "@/control-plane/types"
import { Hono } from "hono" import { Hono } from "hono"
import type { UpgradeWebSocket } from "hono/ws" import type { UpgradeWebSocket } from "hono/ws"
import { Log } from "@/util/log"
const hop = new Set([ const hop = new Set([
"connection", "connection",
@@ -20,6 +20,7 @@ type Msg = string | ArrayBuffer | Uint8Array
function headers(req: Request, extra?: HeadersInit) { function headers(req: Request, extra?: HeadersInit) {
const out = new Headers(req.headers) const out = new Headers(req.headers)
for (const key of hop) out.delete(key) for (const key of hop) out.delete(key)
out.delete("accept-encoding")
out.delete("x-opencode-directory") out.delete("x-opencode-directory")
out.delete("x-opencode-workspace") out.delete("x-opencode-workspace")
if (!extra) return out if (!extra) return out
@@ -98,31 +99,63 @@ const app = (upgrade: UpgradeWebSocket) =>
) )
export namespace ServerProxy { export namespace ServerProxy {
export function http(target: Extract<Target, { type: "remote" }>, req: Request) { const log = Log.Default.clone().tag("service", "server-proxy")
export function http(url: string | URL, extra: HeadersInit | undefined, req: Request) {
console.log("proxy http request", {
method: req.method,
request: req.url,
url: String(url),
})
return fetch( return fetch(
new Request(target.url, { new Request(url, {
method: req.method, method: req.method,
headers: headers(req, target.headers), headers: headers(req, extra),
body: req.method === "GET" || req.method === "HEAD" ? undefined : req.body, body: req.method === "GET" || req.method === "HEAD" ? undefined : req.body,
redirect: "manual", redirect: "manual",
signal: req.signal, signal: req.signal,
}), }),
) ).then((res) => {
const next = new Headers(res.headers)
next.delete("content-encoding")
next.delete("content-length")
console.log("proxy http response", {
method: req.method,
request: req.url,
url: String(url),
status: res.status,
statusText: res.statusText,
})
return new Response(res.body, {
status: res.status,
statusText: res.statusText,
headers: next,
})
})
} }
export function websocket( export function websocket(
upgrade: UpgradeWebSocket, upgrade: UpgradeWebSocket,
target: Extract<Target, { type: "remote" }>, target: string | URL,
extra: HeadersInit | undefined,
req: Request, req: Request,
env: unknown, env: unknown,
) { ) {
const url = new URL(req.url) const proxy = new URL(req.url)
url.pathname = "/__workspace_ws" proxy.pathname = "/__workspace_ws"
url.search = "" proxy.search = ""
const next = new Headers(req.headers) const next = new Headers(req.headers)
next.set("x-opencode-proxy-url", socket(target.url)) next.set("x-opencode-proxy-url", socket(target))
for (const [key, value] of new Headers(extra).entries()) {
next.set(key, value)
}
log.info("proxy websocket", {
request: req.url,
target: String(target),
})
return app(upgrade).fetch( return app(upgrade).fetch(
new Request(url, { new Request(proxy, {
method: req.method, method: req.method,
headers: next, headers: next,
signal: req.signal, signal: req.signal,
+19
View File
@@ -199,6 +199,25 @@ export namespace SyncEvent {
process(def, event, { publish: !!options?.publish }) process(def, event, { publish: !!options?.publish })
} }
export function replayAll(events: SerializedEvent[], options?: { publish: boolean }) {
const source = events[0]?.aggregateID
if (!source) return
if (events.some((item) => item.aggregateID !== source)) {
throw new Error("Replay events must belong to the same session")
}
const start = events[0].seq
for (const [i, item] of events.entries()) {
const seq = start + i
if (item.seq !== seq) {
throw new Error(`Replay sequence mismatch at index ${i}: expected ${seq}, got ${item.seq}`)
}
}
for (const item of events) {
replay(item, options)
}
return source
}
export function run<Def extends Definition>(def: Def, data: Event<Def>["data"], options?: { publish?: boolean }) { export function run<Def extends Definition>(def: Def, data: Event<Def>["data"], options?: { publish?: boolean }) {
const agg = (data as Record<string, string>)[def.aggregate] const agg = (data as Record<string, string>)[def.aggregate]
// This should never happen: we've enforced it via typescript in // This should never happen: we've enforced it via typescript in
+157
View File
@@ -35,6 +35,8 @@ import type {
ExperimentalWorkspaceListResponses, ExperimentalWorkspaceListResponses,
ExperimentalWorkspaceRemoveErrors, ExperimentalWorkspaceRemoveErrors,
ExperimentalWorkspaceRemoveResponses, ExperimentalWorkspaceRemoveResponses,
ExperimentalWorkspaceSessionRestoreErrors,
ExperimentalWorkspaceSessionRestoreResponses,
ExperimentalWorkspaceStatusResponses, ExperimentalWorkspaceStatusResponses,
FileListResponses, FileListResponses,
FilePartInput, FilePartInput,
@@ -157,6 +159,10 @@ import type {
SessionUpdateErrors, SessionUpdateErrors,
SessionUpdateResponses, SessionUpdateResponses,
SubtaskPartInput, SubtaskPartInput,
SyncHistoryListErrors,
SyncHistoryListResponses,
SyncReplayErrors,
SyncReplayResponses,
TextPartInput, TextPartInput,
ToolIdsErrors, ToolIdsErrors,
ToolIdsResponses, ToolIdsResponses,
@@ -1243,6 +1249,49 @@ export class Workspace extends HeyApiClient {
}) })
} }
/**
* Restore session into workspace
*
* Replay a session's sync events into the target workspace in batches.
*/
public sessionRestore<ThrowOnError extends boolean = false>(
parameters: {
id: string
directory?: string
workspace?: string
sessionID?: string
},
options?: Options<never, ThrowOnError>,
) {
const params = buildClientParams(
[parameters],
[
{
args: [
{ in: "path", key: "id" },
{ in: "query", key: "directory" },
{ in: "query", key: "workspace" },
{ in: "body", key: "sessionID" },
],
},
],
)
return (options?.client ?? this.client).post<
ExperimentalWorkspaceSessionRestoreResponses,
ExperimentalWorkspaceSessionRestoreErrors,
ThrowOnError
>({
url: "/experimental/workspace/{id}/session-restore",
...options,
...params,
headers: {
"Content-Type": "application/json",
...options?.headers,
...params.headers,
},
})
}
private _adaptor?: Adaptor private _adaptor?: Adaptor
get adaptor(): Adaptor { get adaptor(): Adaptor {
return (this._adaptor ??= new Adaptor({ client: this.client })) return (this._adaptor ??= new Adaptor({ client: this.client }))
@@ -2961,6 +3010,109 @@ export class Provider extends HeyApiClient {
} }
} }
export class History extends HeyApiClient {
/**
* List sync events
*
* List sync events for all aggregates. Keys are aggregate IDs the client already knows about, values are the last known sequence ID. Events with seq > value are returned for those aggregates. Aggregates not listed in the input get their full history.
*/
public list<ThrowOnError extends boolean = false>(
parameters?: {
directory?: string
workspace?: string
body?: {
[key: string]: number
}
},
options?: Options<never, ThrowOnError>,
) {
const params = buildClientParams(
[parameters],
[
{
args: [
{ in: "query", key: "directory" },
{ in: "query", key: "workspace" },
{ key: "body", map: "body" },
],
},
],
)
return (options?.client ?? this.client).get<SyncHistoryListResponses, SyncHistoryListErrors, ThrowOnError>({
url: "/sync/history",
...options,
...params,
headers: {
"Content-Type": "application/json",
...options?.headers,
...params.headers,
},
})
}
}
export class Sync extends HeyApiClient {
/**
* Replay sync events
*
* Validate and replay a complete sync event history.
*/
public replay<ThrowOnError extends boolean = false>(
parameters?: {
query_directory?: string
workspace?: string
body_directory?: string
events?: Array<{
id: string
aggregateID: string
seq: number
type: string
data: {
[key: string]: unknown
}
}>
},
options?: Options<never, ThrowOnError>,
) {
const params = buildClientParams(
[parameters],
[
{
args: [
{
in: "query",
key: "query_directory",
map: "directory",
},
{ in: "query", key: "workspace" },
{
in: "body",
key: "body_directory",
map: "directory",
},
{ in: "body", key: "events" },
],
},
],
)
return (options?.client ?? this.client).post<SyncReplayResponses, SyncReplayErrors, ThrowOnError>({
url: "/sync/replay",
...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 }))
}
}
export class Find extends HeyApiClient { export class Find extends HeyApiClient {
/** /**
* Find text * Find text
@@ -4217,6 +4369,11 @@ export class OpencodeClient extends HeyApiClient {
return (this._provider ??= new Provider({ client: this.client })) return (this._provider ??= new Provider({ client: this.client }))
} }
private _sync?: Sync
get sync(): Sync {
return (this._sync ??= new Sync({ client: this.client }))
}
private _find?: Find private _find?: Find
get find(): Find { get find(): Find {
return (this._find ??= new Find({ client: this.client })) return (this._find ??= new Find({ client: this.client }))
+127
View File
@@ -520,6 +520,16 @@ export type EventWorkspaceFailed = {
} }
} }
export type EventWorkspaceRestore = {
type: "workspace.restore"
properties: {
workspaceID: string
sessionID: string
total: number
step: number
}
}
export type EventWorkspaceStatus = { export type EventWorkspaceStatus = {
type: "workspace.status" type: "workspace.status"
properties: { properties: {
@@ -1137,6 +1147,7 @@ export type GlobalEvent = {
| EventPtyDeleted | EventPtyDeleted
| EventWorkspaceReady | EventWorkspaceReady
| EventWorkspaceFailed | EventWorkspaceFailed
| EventWorkspaceRestore
| EventWorkspaceStatus | EventWorkspaceStatus
| EventMessageUpdated | EventMessageUpdated
| EventMessageRemoved | EventMessageRemoved
@@ -2049,6 +2060,7 @@ export type Event =
| EventPtyDeleted | EventPtyDeleted
| EventWorkspaceReady | EventWorkspaceReady
| EventWorkspaceFailed | EventWorkspaceFailed
| EventWorkspaceRestore
| EventWorkspaceStatus | EventWorkspaceStatus
| EventMessageUpdated | EventMessageUpdated
| EventMessageRemoved | EventMessageRemoved
@@ -3006,6 +3018,42 @@ export type ExperimentalWorkspaceRemoveResponses = {
export type ExperimentalWorkspaceRemoveResponse = export type ExperimentalWorkspaceRemoveResponse =
ExperimentalWorkspaceRemoveResponses[keyof ExperimentalWorkspaceRemoveResponses] ExperimentalWorkspaceRemoveResponses[keyof ExperimentalWorkspaceRemoveResponses]
export type ExperimentalWorkspaceSessionRestoreData = {
body?: {
sessionID: string
}
path: {
id: string
}
query?: {
directory?: string
workspace?: string
}
url: "/experimental/workspace/{id}/session-restore"
}
export type ExperimentalWorkspaceSessionRestoreErrors = {
/**
* Bad request
*/
400: BadRequestError
}
export type ExperimentalWorkspaceSessionRestoreError =
ExperimentalWorkspaceSessionRestoreErrors[keyof ExperimentalWorkspaceSessionRestoreErrors]
export type ExperimentalWorkspaceSessionRestoreResponses = {
/**
* Session replay started
*/
200: {
total: number
}
}
export type ExperimentalWorkspaceSessionRestoreResponse =
ExperimentalWorkspaceSessionRestoreResponses[keyof ExperimentalWorkspaceSessionRestoreResponses]
export type WorktreeRemoveData = { export type WorktreeRemoveData = {
body?: WorktreeRemoveInput body?: WorktreeRemoveInput
path?: never path?: never
@@ -4456,6 +4504,85 @@ export type ProviderOauthCallbackResponses = {
export type ProviderOauthCallbackResponse = ProviderOauthCallbackResponses[keyof ProviderOauthCallbackResponses] export type ProviderOauthCallbackResponse = ProviderOauthCallbackResponses[keyof ProviderOauthCallbackResponses]
export type SyncReplayData = {
body?: {
directory: string
events: Array<{
id: string
aggregateID: string
seq: number
type: string
data: {
[key: string]: unknown
}
}>
}
path?: never
query?: {
directory?: string
workspace?: string
}
url: "/sync/replay"
}
export type SyncReplayErrors = {
/**
* Bad request
*/
400: BadRequestError
}
export type SyncReplayError = SyncReplayErrors[keyof SyncReplayErrors]
export type SyncReplayResponses = {
/**
* Replayed sync events
*/
200: {
sessionID: string
}
}
export type SyncReplayResponse = SyncReplayResponses[keyof SyncReplayResponses]
export type SyncHistoryListData = {
body?: {
[key: string]: number
}
path?: never
query?: {
directory?: string
workspace?: string
}
url: "/sync/history"
}
export type SyncHistoryListErrors = {
/**
* Bad request
*/
400: BadRequestError
}
export type SyncHistoryListError = SyncHistoryListErrors[keyof SyncHistoryListErrors]
export type SyncHistoryListResponses = {
/**
* Sync events
*/
200: Array<{
id: string
aggregate_id: string
seq: number
type: string
data: {
[key: string]: unknown
}
}>
}
export type SyncHistoryListResponse = SyncHistoryListResponses[keyof SyncHistoryListResponses]
export type FindTextData = { export type FindTextData = {
body?: never body?: never
path?: never path?: never
+320
View File
@@ -1805,6 +1805,90 @@
] ]
} }
}, },
"/experimental/workspace/{id}/session-restore": {
"post": {
"operationId": "experimental.workspace.sessionRestore",
"parameters": [
{
"in": "query",
"name": "directory",
"schema": {
"type": "string"
}
},
{
"in": "query",
"name": "workspace",
"schema": {
"type": "string"
}
},
{
"in": "path",
"name": "id",
"schema": {
"type": "string",
"pattern": "^wrk.*"
},
"required": true
}
],
"summary": "Restore session into workspace",
"description": "Replay a session's sync events into the target workspace in batches.",
"responses": {
"200": {
"description": "Session replay started",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"total": {
"type": "integer",
"minimum": 0,
"maximum": 9007199254740991
}
},
"required": ["total"]
}
}
}
},
"400": {
"description": "Bad request",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/BadRequestError"
}
}
}
}
},
"requestBody": {
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"sessionID": {
"type": "string",
"pattern": "^ses.*"
}
},
"required": ["sessionID"]
}
}
}
},
"x-codeSamples": [
{
"lang": "js",
"source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.experimental.workspace.sessionRestore({\n ...\n})"
}
]
}
},
"/experimental/worktree": { "/experimental/worktree": {
"post": { "post": {
"operationId": "worktree.create", "operationId": "worktree.create",
@@ -5143,6 +5227,202 @@
] ]
} }
}, },
"/sync/replay": {
"post": {
"operationId": "sync.replay",
"parameters": [
{
"in": "query",
"name": "directory",
"schema": {
"type": "string"
}
},
{
"in": "query",
"name": "workspace",
"schema": {
"type": "string"
}
}
],
"summary": "Replay sync events",
"description": "Validate and replay a complete sync event history.",
"responses": {
"200": {
"description": "Replayed sync events",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"sessionID": {
"type": "string"
}
},
"required": ["sessionID"]
}
}
}
},
"400": {
"description": "Bad request",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/BadRequestError"
}
}
}
}
},
"requestBody": {
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"directory": {
"type": "string"
},
"events": {
"minItems": 1,
"type": "array",
"items": {
"type": "object",
"properties": {
"id": {
"type": "string"
},
"aggregateID": {
"type": "string"
},
"seq": {
"type": "integer",
"minimum": 0,
"maximum": 9007199254740991
},
"type": {
"type": "string"
},
"data": {
"type": "object",
"propertyNames": {
"type": "string"
},
"additionalProperties": {}
}
},
"required": ["id", "aggregateID", "seq", "type", "data"]
}
}
},
"required": ["directory", "events"]
}
}
}
},
"x-codeSamples": [
{
"lang": "js",
"source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.sync.replay({\n ...\n})"
}
]
}
},
"/sync/history": {
"get": {
"operationId": "sync.history.list",
"parameters": [
{
"in": "query",
"name": "directory",
"schema": {
"type": "string"
}
},
{
"in": "query",
"name": "workspace",
"schema": {
"type": "string"
}
}
],
"summary": "List sync events",
"description": "List sync events for all aggregates. Keys are aggregate IDs the client already knows about, values are the last known sequence ID. Events with seq > value are returned for those aggregates. Aggregates not listed in the input get their full history.",
"responses": {
"200": {
"description": "Sync events",
"content": {
"application/json": {
"schema": {
"type": "array",
"items": {
"type": "object",
"properties": {
"id": {
"type": "string"
},
"aggregate_id": {
"type": "string"
},
"seq": {
"type": "number"
},
"type": {
"type": "string"
},
"data": {
"type": "object",
"propertyNames": {
"type": "string"
},
"additionalProperties": {}
}
},
"required": ["id", "aggregate_id", "seq", "type", "data"]
}
}
}
}
},
"400": {
"description": "Bad request",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/BadRequestError"
}
}
}
}
},
"requestBody": {
"content": {
"application/json": {
"schema": {
"type": "object",
"propertyNames": {
"type": "string"
},
"additionalProperties": {
"type": "integer",
"minimum": 0,
"maximum": 9007199254740991
}
}
}
}
},
"x-codeSamples": [
{
"lang": "js",
"source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.sync.history.list({\n ...\n})"
}
]
}
},
"/find": { "/find": {
"get": { "get": {
"operationId": "find.text", "operationId": "find.text",
@@ -8514,6 +8794,40 @@
}, },
"required": ["type", "properties"] "required": ["type", "properties"]
}, },
"Event.workspace.restore": {
"type": "object",
"properties": {
"type": {
"type": "string",
"const": "workspace.restore"
},
"properties": {
"type": "object",
"properties": {
"workspaceID": {
"type": "string",
"pattern": "^wrk.*"
},
"sessionID": {
"type": "string",
"pattern": "^ses.*"
},
"total": {
"type": "integer",
"minimum": 0,
"maximum": 9007199254740991
},
"step": {
"type": "integer",
"minimum": 0,
"maximum": 9007199254740991
}
},
"required": ["workspaceID", "sessionID", "total", "step"]
}
},
"required": ["type", "properties"]
},
"Event.workspace.status": { "Event.workspace.status": {
"type": "object", "type": "object",
"properties": { "properties": {
@@ -10523,6 +10837,9 @@
{ {
"$ref": "#/components/schemas/Event.workspace.failed" "$ref": "#/components/schemas/Event.workspace.failed"
}, },
{
"$ref": "#/components/schemas/Event.workspace.restore"
},
{ {
"$ref": "#/components/schemas/Event.workspace.status" "$ref": "#/components/schemas/Event.workspace.status"
}, },
@@ -12780,6 +13097,9 @@
{ {
"$ref": "#/components/schemas/Event.workspace.failed" "$ref": "#/components/schemas/Event.workspace.failed"
}, },
{
"$ref": "#/components/schemas/Event.workspace.restore"
},
{ {
"$ref": "#/components/schemas/Event.workspace.status" "$ref": "#/components/schemas/Event.workspace.status"
}, },