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

View File

@@ -23,6 +23,7 @@ import { ConfigRoutes } from "./config"
import { ExperimentalRoutes } from "./experimental"
import { ProviderRoutes } from "./provider"
import { EventRoutes } from "./event"
import { SyncRoutes } from "./sync"
import { WorkspaceRouterMiddleware } from "./middleware"
import { AppRuntime } from "@/effect/app-runtime"
@@ -37,6 +38,7 @@ export const InstanceRoutes = (upgrade: UpgradeWebSocket): Hono =>
.route("/permission", PermissionRoutes())
.route("/question", QuestionRoutes())
.route("/provider", ProviderRoutes())
.route("/sync", SyncRoutes())
.route("/", FileRoutes())
.route("/", EventRoutes())
.route("/mcp", McpRoutes())

View File

@@ -11,9 +11,12 @@ import { Session } from "@/session"
import { SessionID } from "@/session/schema"
import { WorkspaceContext } from "@/control-plane/workspace-context"
import { AppRuntime } from "@/effect/app-runtime"
import { Log } from "@/util/log"
type Rule = { method?: string; path: string; exact?: boolean; action: "local" | "forward" }
const OPENCODE_WORKSPACE = process.env.OPENCODE_WORKSPACE
const RULES: Array<Rule> = [
{ path: "/session/status", action: "forward" },
{ method: "GET", path: "/session", action: "local" },
@@ -46,6 +49,8 @@ async function getSessionWorkspace(url: URL) {
}
export function WorkspaceRouterMiddleware(upgrade: UpgradeWebSocket): MiddlewareHandler {
const log = Log.create({ service: "workspace-router" })
return async (c, next) => {
const raw = c.req.query("directory") || c.req.header("x-opencode-directory") || process.cwd()
const directory = Filesystem.resolve(
@@ -63,8 +68,22 @@ export function WorkspaceRouterMiddleware(upgrade: UpgradeWebSocket): Middleware
const sessionWorkspaceID = await getSessionWorkspace(url)
const workspaceID = sessionWorkspaceID || url.searchParams.get("workspace")
// If no workspace is provided we use the project
if (!workspaceID) {
if (!workspaceID || url.pathname.startsWith("/console") || OPENCODE_WORKSPACE) {
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({
directory,
init: () => AppRuntime.runPromise(InstanceBootstrap),
@@ -77,16 +96,6 @@ export function WorkspaceRouterMiddleware(upgrade: UpgradeWebSocket): Middleware
const workspace = await Workspace.get(WorkspaceID.make(workspaceID))
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}`, {
status: 500,
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 target = await adaptor.target(workspace)
@@ -112,24 +127,27 @@ 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 proxyURL = new URL(target.url)
proxyURL.pathname = `${proxyURL.pathname.replace(/\/$/, "")}${url.pathname}`
proxyURL.search = url.search
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") {
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)
headers.delete("x-opencode-workspace")
return ServerProxy.http(
target,
new Request(c.req.raw, {
headers,
}),
)
const req = new Request(c.req.raw, { headers })
return ServerProxy.http(proxyURL, target.headers, req)
}
}

View File

@@ -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)
},
),
)

View File

@@ -6,12 +6,10 @@ import { Workspace } from "../../control-plane/workspace"
import { Instance } from "../../project/instance"
import { errors } from "../error"
import { lazy } from "../../util/lazy"
import { Log } from "@/util/log"
import { errorData } from "@/util/error"
const WorkspaceAdaptor = z.object({
type: z.string(),
name: z.string(),
description: z.string(),
})
const log = Log.create({ service: "server.workspace" })
export const WorkspaceRoutes = lazy(() =>
new Hono()
@@ -26,7 +24,15 @@ export const WorkspaceRoutes = lazy(() =>
description: "Workspace adaptors",
content: {
"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")
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
}
},
),
)