feat(core): session warping (#25768)
This commit is contained in:
@@ -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)
|
||||
},
|
||||
),
|
||||
)
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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.",
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
@@ -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)
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -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)
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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({
|
||||
|
||||
Reference in New Issue
Block a user