diff --git a/packages/opencode/src/backup/index.ts b/packages/opencode/src/backup/index.ts new file mode 100644 index 000000000000..7aacb4409339 --- /dev/null +++ b/packages/opencode/src/backup/index.ts @@ -0,0 +1,132 @@ +import { Database } from "@/storage/db" +import { MessageV2 } from "@/session/message-v2" +import { Session } from "@/session/session" +import { MessageTable, PartTable, SessionTable, TodoTable } from "@/session/session.sql" +import { MessageID, PartID, SessionID } from "@/session/schema" +import { Todo } from "@/session/todo" +import { NotFoundError } from "@/storage/storage" +import { ProjectID } from "@/project/schema" +import { zod, zodObject } from "@/util/effect-zod" +import { withStatics } from "@/util/schema" +import { desc, eq } from "drizzle-orm" +import { Effect, Layer, Context, Schema, Types } from "effect" + +export const Payload = Schema.Struct({ + info: Session.Info, + messages: Schema.Array(MessageV2.WithParts), + todos: Schema.Array(Todo.Info), +}).pipe(withStatics((s) => ({ zod: zod(s), zodObject: zodObject(s) }))) +export type Payload = Types.DeepMutable> +const decodePayload = Schema.decodeUnknownSync(Payload) + +export interface Interface { + readonly list: (projectID: ProjectID) => Effect.Effect + readonly exportSession: (sessionID: SessionID, projectID: ProjectID) => Effect.Effect> + readonly importSession: (payload: Payload, projectID: ProjectID) => Effect.Effect +} + +export class Service extends Context.Service()("@opencode/Backup") {} + +function messageValue(message: MessageV2.WithParts, sessionID: SessionID) { + const id = MessageID.ascending() + const { id: _id, sessionID: _sessionID, ...data } = message.info + return { + id, + session_id: sessionID, + time_created: message.info.time?.created ?? Date.now(), + data, + } +} + +function partValue(part: MessageV2.Part, sessionID: SessionID, messageID: MessageID) { + const { id: _id, sessionID: _sessionID, messageID: _messageID, ...data } = part + return { + id: PartID.ascending(), + message_id: messageID, + session_id: sessionID, + data, + } +} + +export const apply = Effect.fn("Backup.apply")(function* (payload: Payload, projectID: ProjectID) { + const sessionID = SessionID.descending() + const info: Session.Info = { + ...payload.info, + id: sessionID, + projectID, + // Workspace identity is environment-local and should not be restored from backups. + workspaceID: undefined, + } + const messages = payload.messages.map((message) => ({ + message, + row: messageValue(message, info.id), + })) + const parts = messages.flatMap(({ message, row }) => message.parts.map((part) => partValue(part, info.id, row.id))) + const todos = payload.todos.map((todo, position) => ({ + session_id: info.id, + content: todo.content, + status: todo.status, + priority: todo.priority, + position, + })) + + yield* Effect.sync(() => + Database.transaction((db) => { + const session = Session.toRow(info) + db.insert(SessionTable).values(session).run() + if (messages.length) db.insert(MessageTable).values(messages.map((item) => item.row)).run() + if (parts.length) db.insert(PartTable).values(parts).run() + if (todos.length) db.insert(TodoTable).values(todos).run() + }), + ) + return info.id +}) + +export const layer = Layer.effect( + Service, + Effect.gen(function* () { + const session = yield* Session.Service + const todo = yield* Todo.Service + + const list: Interface["list"] = Effect.fn("Backup.list")(function* (projectID) { + return yield* Effect.sync(() => + Database.use((db) => + db + .select() + .from(SessionTable) + .where(eq(SessionTable.project_id, projectID)) + .orderBy(desc(SessionTable.time_updated)) + .all() + .map(Session.fromRow), + ), + ) + }) + + const exportSession: Interface["exportSession"] = Effect.fn("Backup.exportSession")(function* (sessionID, projectID) { + const info = yield* session.get(sessionID) + if (info.projectID !== projectID) throw new NotFoundError({ message: `Session not found: ${sessionID}` }) + return { + info, + messages: yield* session.messages({ sessionID }), + todos: yield* todo.get(sessionID), + } + }) + + const importSession: Interface["importSession"] = Effect.fn("Backup.importSession")(function* (payload, projectID) { + return yield* apply(structuredClone(decodePayload(payload)) as Payload, projectID) + }) + + return Service.of({ + list, + exportSession, + importSession, + }) + }), +) + +export const defaultLayer = layer.pipe( + Layer.provide(Session.defaultLayer), + Layer.provide(Todo.defaultLayer), +) + +export * as Backup from "." diff --git a/packages/opencode/src/effect/app-runtime.ts b/packages/opencode/src/effect/app-runtime.ts index 76ed26d302f5..03d7809b1195 100644 --- a/packages/opencode/src/effect/app-runtime.ts +++ b/packages/opencode/src/effect/app-runtime.ts @@ -3,6 +3,7 @@ import { attach } from "./run-service" import * as Observability from "@opencode-ai/core/effect/observability" import { AppFileSystem } from "@opencode-ai/core/filesystem" +import { Backup } from "@/backup" import { Bus } from "@/bus" import { Auth } from "@/auth" import { Account } from "@/account/account" @@ -57,6 +58,7 @@ import { memoMap } from "@opencode-ai/core/effect/memo-map" export const AppLayer = Layer.mergeAll( Npm.defaultLayer, AppFileSystem.defaultLayer, + Backup.defaultLayer, Bus.defaultLayer, Auth.defaultLayer, Account.defaultLayer, diff --git a/packages/opencode/src/server/routes/instance/backup.ts b/packages/opencode/src/server/routes/instance/backup.ts new file mode 100644 index 000000000000..a96f2194cb69 --- /dev/null +++ b/packages/opencode/src/server/routes/instance/backup.ts @@ -0,0 +1,101 @@ +import { Hono } from "hono" +import { describeRoute, resolver, validator } from "hono-openapi" +import z from "zod" +import { Backup } from "@/backup" +import { AppRuntime } from "@/effect/app-runtime" +import { Instance } from "@/project/instance" +import { SessionID } from "@/session/schema" +import { Session } from "@/session/session" +import { errors } from "../../error" +import { lazy } from "@/util/lazy" + +const SessionPayload = z.object({ + sessionID: SessionID.zod, +}) + +const ImportPayload = z.object({ + payload: Backup.Payload.zod, +}) + +const SessionResponse = z.object({ + sessionID: SessionID.zod, +}) + +export const BackupRoutes = lazy(() => + new Hono() + .post( + "/list", + describeRoute({ + summary: "List backup sessions", + description: "List all local sessions available for backup export.", + operationId: "backup.list", + responses: { + 200: { + description: "Sessions", + content: { + "application/json": { + schema: resolver(z.array(Session.Info.zod)), + }, + }, + }, + }, + }), + async (c) => { + return c.json(await AppRuntime.runPromise(Backup.Service.use((backup) => backup.list(Instance.project.id)))) + }, + ) + .post( + "/export", + describeRoute({ + summary: "Export session backup", + description: "Return the full JSON backup payload for a single session.", + operationId: "backup.export", + responses: { + 200: { + description: "Backup payload", + content: { + "application/json": { + schema: resolver(Backup.Payload.zod), + }, + }, + }, + ...errors(400, 404), + }, + }), + validator("json", SessionPayload), + async (c) => { + const body = c.req.valid("json") + return c.json( + await AppRuntime.runPromise(Backup.Service.use((backup) => backup.exportSession(body.sessionID, Instance.project.id))), + ) + }, + ) + .post( + "/import", + describeRoute({ + summary: "Import session backup", + description: "Restore a single session from a JSON backup payload.", + operationId: "backup.import", + responses: { + 200: { + description: "Imported session", + content: { + "application/json": { + schema: resolver(SessionResponse), + }, + }, + }, + ...errors(400), + }, + }), + validator("json", ImportPayload), + async (c) => { + const body = c.req.valid("json") + return c.json({ + sessionID: await AppRuntime.runPromise( + Backup.Service.use((backup) => backup.importSession(structuredClone(body.payload) as Backup.Payload, Instance.project.id)), + ), + }) + }, + ), +) diff --git a/packages/opencode/src/server/routes/instance/httpapi/api.ts b/packages/opencode/src/server/routes/instance/httpapi/api.ts index 1cf1584e3eea..c1b575f77ffc 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/api.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/api.ts @@ -1,5 +1,6 @@ import { Schema } from "effect" import { HttpApi } from "effect/unstable/httpapi" +import { BackupApi } from "./groups/backup" import { BusEvent } from "@/bus/bus-event" import { SyncEvent } from "@/sync" import { ConfigApi } from "./groups/config" @@ -29,6 +30,7 @@ const SyncEventSchemas = SyncEvent.effectPayloads() export const RootHttpApi = HttpApi.make("opencode-root").addHttpApi(ControlApi).addHttpApi(GlobalApi) export const InstanceHttpApi = HttpApi.make("opencode-instance") + .addHttpApi(BackupApi) .addHttpApi(ConfigApi) .addHttpApi(ExperimentalApi) .addHttpApi(FileApi) diff --git a/packages/opencode/src/server/routes/instance/httpapi/groups/backup.ts b/packages/opencode/src/server/routes/instance/httpapi/groups/backup.ts new file mode 100644 index 000000000000..f8e83d3bece3 --- /dev/null +++ b/packages/opencode/src/server/routes/instance/httpapi/groups/backup.ts @@ -0,0 +1,76 @@ +import { Backup } from "@/backup" +import { Session } from "@/session/session" +import { SessionID } from "@/session/schema" +import { Schema } from "effect" +import { HttpApi, HttpApiEndpoint, HttpApiError, HttpApiGroup, 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" + +export const SessionPayload = Schema.Struct({ + sessionID: SessionID, +}) + +export const ImportPayload = Schema.Struct({ + payload: Backup.Payload, +}) + +export const SessionResponse = Schema.Struct({ + sessionID: SessionID, +}) + +export const BackupPaths = { + list: "/backup/list", + export: "/backup/export", + import: "/backup/import", +} as const + +export const BackupApi = HttpApi.make("backup") + .add( + HttpApiGroup.make("backup") + .add( + HttpApiEndpoint.post("list", BackupPaths.list, { + success: described(Schema.Array(Session.Info), "Sessions"), + }).annotateMerge( + OpenApi.annotations({ + identifier: "backup.list", + summary: "List backup sessions", + description: "List all local sessions available for backup export.", + }), + ), + HttpApiEndpoint.post("export", BackupPaths.export, { + payload: SessionPayload, + success: described(Backup.Payload, "Backup payload"), + error: [HttpApiError.BadRequest, HttpApiError.NotFound], + }).annotateMerge( + OpenApi.annotations({ + identifier: "backup.export", + summary: "Export session backup", + description: "Return the full JSON backup payload for a single session.", + }), + ), + HttpApiEndpoint.post("import", BackupPaths.import, { + payload: ImportPayload, + success: described(SessionResponse, "Imported session"), + error: HttpApiError.BadRequest, + }).annotateMerge( + OpenApi.annotations({ + identifier: "backup.import", + summary: "Import session backup", + description: "Restore a single session from a JSON backup payload.", + }), + ), + ) + .annotateMerge(OpenApi.annotations({ title: "backup", description: "Experimental HttpApi backup routes." })) + .middleware(InstanceContextMiddleware) + .middleware(WorkspaceRoutingMiddleware) + .middleware(Authorization), + ) + .annotateMerge( + OpenApi.annotations({ + title: "opencode experimental HttpApi", + version: "0.0.1", + description: "Experimental HttpApi surface for selected instance routes.", + }), + ) diff --git a/packages/opencode/src/server/routes/instance/httpapi/handlers/backup.ts b/packages/opencode/src/server/routes/instance/httpapi/handlers/backup.ts new file mode 100644 index 000000000000..b719029d4b21 --- /dev/null +++ b/packages/opencode/src/server/routes/instance/httpapi/handlers/backup.ts @@ -0,0 +1,39 @@ +import { Backup } from "@/backup" +import * as InstanceState from "@/effect/instance-state" +import { NotFoundError } from "@/storage/storage" +import { Effect } from "effect" +import { HttpApiBuilder, HttpApiError } from "effect/unstable/httpapi" +import { InstanceHttpApi } from "../api" +import { ImportPayload, SessionPayload } from "../groups/backup" + +export const backupHandlers = HttpApiBuilder.group(InstanceHttpApi, "backup", (handlers) => + Effect.gen(function* () { + const backup = yield* Backup.Service + + const list = Effect.fn("BackupHttpApi.list")(function* () { + return yield* backup.list((yield* InstanceState.context).project.id) + }) + + const exportSession = Effect.fn("BackupHttpApi.export")(function* (ctx: { payload: typeof SessionPayload.Type }) { + return yield* backup + .exportSession(ctx.payload.sessionID, (yield* InstanceState.context).project.id) + .pipe( + Effect.catchIf(NotFoundError.isInstance, () => Effect.fail(new HttpApiError.NotFound({}))), + Effect.mapError(() => new HttpApiError.BadRequest({})), + ) + }) + + const importSession = Effect.fn("BackupHttpApi.import")(function* (ctx: { payload: typeof ImportPayload.Type }) { + return { + sessionID: yield* backup + .importSession( + structuredClone(ctx.payload.payload) as Backup.Payload, + (yield* InstanceState.context).project.id, + ) + .pipe(Effect.mapError(() => new HttpApiError.BadRequest({}))), + } + }) + + return handlers.handle("list", list).handle("export", exportSession).handle("import", importSession) + }), +) diff --git a/packages/opencode/src/server/routes/instance/httpapi/server.ts b/packages/opencode/src/server/routes/instance/httpapi/server.ts index ef966036a94f..ea4e0496094a 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/server.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/server.ts @@ -2,6 +2,7 @@ import { Context, Effect, Layer } from "effect" import { HttpApiBuilder } from "effect/unstable/httpapi" import { FetchHttpClient, HttpClient, HttpMiddleware, HttpRouter, HttpServer } from "effect/unstable/http" import * as Socket from "effect/unstable/socket/Socket" +import { Backup } from "@/backup" import { AppFileSystem } from "@opencode-ai/core/filesystem" import { Account } from "@/account/account" import { Agent } from "@/agent/agent" @@ -51,6 +52,7 @@ import { ServerAuth } from "@/server/auth" import { InstanceHttpApi, RootHttpApi } from "./api" import { authorizationLayer, authorizationRouterMiddleware } from "./middleware/authorization" import { EventApi, eventHandlers } from "./event" +import { backupHandlers } from "./handlers/backup" import { configHandlers } from "./handlers/config" import { controlHandlers } from "./handlers/control" import { experimentalHandlers } from "./handlers/experimental" @@ -107,6 +109,7 @@ const eventApiRoutes = HttpApiBuilder.layer(EventApi).pipe( ) const instanceApiRoutes = HttpApiBuilder.layer(InstanceHttpApi).pipe( Layer.provide([ + backupHandlers, configHandlers, experimentalHandlers, fileHandlers, @@ -151,6 +154,7 @@ export function createRoutes(corsOptions?: CorsOptions) { Account.defaultLayer, Agent.defaultLayer, Auth.defaultLayer, + Backup.defaultLayer, Command.defaultLayer, Config.defaultLayer, File.defaultLayer, diff --git a/packages/opencode/src/server/routes/instance/index.ts b/packages/opencode/src/server/routes/instance/index.ts index 71662dea903d..73d44dac292f 100644 --- a/packages/opencode/src/server/routes/instance/index.ts +++ b/packages/opencode/src/server/routes/instance/index.ts @@ -24,9 +24,9 @@ import { FileRoutes } from "./file" import { ConfigRoutes } from "./config" import { ExperimentalRoutes } from "./experimental" import { ProviderRoutes } from "./provider" +import { BackupRoutes } from "./backup" import { EventRoutes } from "./event" import { SyncRoutes } from "./sync" -import { InstanceMiddleware } from "./middleware" import { jsonRequest } from "./trace" import { ExperimentalHttpApiServer } from "./httpapi/server" import { EventPaths } from "./httpapi/event" @@ -35,6 +35,7 @@ import { FilePaths } from "./httpapi/groups/file" import { InstancePaths } from "./httpapi/groups/instance" import { McpPaths } from "./httpapi/groups/mcp" import { PtyPaths } from "./httpapi/groups/pty" +import { BackupPaths } from "./httpapi/groups/backup" import { SessionPaths } from "./httpapi/groups/session" import { SyncPaths } from "./httpapi/groups/sync" import { TuiPaths } from "./httpapi/groups/tui" @@ -100,6 +101,9 @@ export const InstanceRoutes = (upgrade: UpgradeWebSocket, opts?: CorsOptions): H app.delete(McpPaths.auth, (c) => handler(c.req.raw, context)) app.post(McpPaths.connect, (c) => handler(c.req.raw, context)) app.post(McpPaths.disconnect, (c) => handler(c.req.raw, context)) + app.post(BackupPaths.list, (c) => handler(c.req.raw, context)) + app.post(BackupPaths.export, (c) => handler(c.req.raw, context)) + app.post(BackupPaths.import, (c) => handler(c.req.raw, context)) app.post(SyncPaths.start, (c) => handler(c.req.raw, context)) app.post(SyncPaths.replay, (c) => handler(c.req.raw, context)) app.post(SyncPaths.history, (c) => handler(c.req.raw, context)) @@ -167,6 +171,7 @@ export const InstanceRoutes = (upgrade: UpgradeWebSocket, opts?: CorsOptions): H .route("/permission", PermissionRoutes()) .route("/question", QuestionRoutes()) .route("/provider", ProviderRoutes()) + .route("/backup", BackupRoutes()) .route("/sync", SyncRoutes()) .route("/", FileRoutes()) .route("/", EventRoutes()) diff --git a/packages/opencode/test/server/httpapi-backup.test.ts b/packages/opencode/test/server/httpapi-backup.test.ts new file mode 100644 index 000000000000..b6b1038cd128 --- /dev/null +++ b/packages/opencode/test/server/httpapi-backup.test.ts @@ -0,0 +1,202 @@ +import { afterEach, describe, expect } from "bun:test" +import { NodeServices } from "@effect/platform-node" +import { Effect, Layer } from "effect" +import { eq } from "drizzle-orm" +import * as Log from "@opencode-ai/core/util/log" +import { Flag } from "@opencode-ai/core/flag/flag" +import { Todo } from "../../src/session/todo" +import { Session } from "../../src/session/session" +import { MessageID, PartID, SessionID } from "../../src/session/schema" +import { Database } from "../../src/storage/db" +import { MessageTable, PartTable, SessionTable, TodoTable } from "../../src/session/session.sql" +import { BackupPaths } from "../../src/server/routes/instance/httpapi/groups/backup" +import { Server } from "../../src/server/server" +import { disposeAllInstances, provideInstance, tmpdirScoped } from "../fixture/fixture" +import { resetDatabase } from "../fixture/db" +import { testEffect } from "../lib/effect" + +Log.init({ print: false }) + +const originalHttpApi = Flag.OPENCODE_EXPERIMENTAL_HTTPAPI +const it = testEffect(Layer.mergeAll(NodeServices.layer, Session.defaultLayer, Todo.defaultLayer)) + +function request(path: string, directory: string, init: RequestInit = {}, httpApi = true) { + return Effect.promise(() => { + Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = httpApi + const headers = new Headers(init.headers) + headers.set("x-opencode-directory", directory) + return Promise.resolve(Server.Default().app.request(path, { ...init, headers })) + }) +} + +function body(value: unknown) { + return { + method: "POST", + headers: { + "content-type": "application/json", + }, + body: JSON.stringify(value), + } satisfies RequestInit +} + +function messageData(role: "user" | "assistant", time: number) { + return { + role, + agent: "default", + model: { + providerID: "openai", + modelID: "gpt-4", + }, + time: { + created: time, + }, + } +} + +function partData(text: string) { + return { + type: "text" as const, + text, + } +} + +afterEach(async () => { + Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = originalHttpApi + await disposeAllInstances() + await resetDatabase() +}) + +describe("backup routes", () => { + for (const httpApi of [false, true]) { + const label = httpApi ? "HttpApi" : "Hono" + + it.live(`lists, exports, and imports sessions via ${label}`, () => + Effect.gen(function* () { + const dir = yield* tmpdirScoped({ git: true }) + const sessionInfo = yield* Session.Service.use((session) => session.create({ title: "Backup session" })).pipe( + provideInstance(dir), + ) + const time = Date.now() + + yield* Effect.sync(() => + Database.transaction((db) => { + db.insert(MessageTable) + .values([ + { + id: MessageID.make("msg_backup_test"), + session_id: sessionInfo.id, + time_created: time, + data: messageData("user", time), + }, + ]) + .run() + db.insert(PartTable) + .values([ + { + id: PartID.make("prt_backup_test"), + message_id: MessageID.make("msg_backup_test"), + session_id: sessionInfo.id, + data: partData("hello from backup"), + }, + ]) + .run() + }), + ) + + yield* Todo.Service.use((todo) => + todo.update({ + sessionID: sessionInfo.id, + todos: [ + { + content: "ship backup", + status: "pending", + priority: "high", + }, + ], + }), + ).pipe(provideInstance(dir)) + + const listed = yield* request(BackupPaths.list, dir, { method: "POST" }, httpApi) + expect(listed.status).toBe(200) + expect((yield* Effect.promise(() => listed.json())) as Session.Info[]).toMatchObject([ + { + id: sessionInfo.id, + title: "Backup session", + }, + ]) + + const exported = yield* request(BackupPaths.export, dir, body({ sessionID: sessionInfo.id }), httpApi) + expect(exported.status).toBe(200) + const payload = yield* Effect.promise(() => exported.json()) + expect((payload as { info: Session.Info }).info.id).toBe(sessionInfo.id) + + yield* Effect.sync(() => + Database.transaction((db) => { + db.update(SessionTable).set({ title: "stale" }).where(eq(SessionTable.id, sessionInfo.id)).run() + db.delete(PartTable).where(eq(PartTable.session_id, sessionInfo.id)).run() + db.delete(MessageTable).where(eq(MessageTable.session_id, sessionInfo.id)).run() + db.delete(TodoTable).where(eq(TodoTable.session_id, sessionInfo.id)).run() + db.insert(MessageTable) + .values([ + { + id: MessageID.make("msg_stale"), + session_id: sessionInfo.id, + time_created: time + 1, + data: messageData("assistant", time + 1), + }, + ]) + .run() + db.insert(PartTable) + .values([ + { + id: PartID.make("prt_stale"), + message_id: MessageID.make("msg_stale"), + session_id: sessionInfo.id, + data: partData("stale"), + }, + ]) + .run() + db.insert(TodoTable) + .values({ + session_id: sessionInfo.id, + content: "stale", + status: "cancelled", + priority: "low", + position: 0, + }) + .run() + }), + ) + + const imported = yield* request(BackupPaths.import, dir, body({ payload }), httpApi) + expect(imported.status).toBe(200) + const importedBody = (yield* Effect.promise(() => imported.json())) as { sessionID: string } + expect(importedBody.sessionID).not.toBe(sessionInfo.id) + const importedID = SessionID.make(importedBody.sessionID) + + const restoredSession = yield* Session.Service.use((session) => session.get(importedID)).pipe(provideInstance(dir)) + const restoredMessages = yield* Session.Service.use((session) => + session.messages({ sessionID: importedID }), + ).pipe(provideInstance(dir)) + const restoredTodos = yield* Todo.Service.use((todo) => todo.get(importedID)).pipe(provideInstance(dir)) + + expect(restoredSession.title).toBe("Backup session") + expect(restoredMessages).toHaveLength(1) + expect(restoredMessages[0]?.parts).toMatchObject([ + { + type: "text", + text: "hello from backup", + }, + ]) + expect(restoredTodos).toEqual([ + { + content: "ship backup", + status: "pending", + priority: "high", + }, + ]) + expect((yield* Session.Service.use((session) => session.get(sessionInfo.id)).pipe(provideInstance(dir))).title).toBe("stale") + }), + ) + } +}) diff --git a/packages/sdk/js/src/v2/gen/sdk.gen.ts b/packages/sdk/js/src/v2/gen/sdk.gen.ts index ffc0970c0eba..e640bfef9d8c 100644 --- a/packages/sdk/js/src/v2/gen/sdk.gen.ts +++ b/packages/sdk/js/src/v2/gen/sdk.gen.ts @@ -13,6 +13,11 @@ import type { AuthRemoveResponses, AuthSetErrors, AuthSetResponses, + BackupExportErrors, + BackupExportResponses, + BackupImportErrors, + BackupImportResponses, + BackupListResponses, CommandListResponses, Config as Config3, ConfigGetResponses, @@ -72,6 +77,7 @@ import type { McpLocalConfig, McpRemoteConfig, McpStatusResponses, + Message, OutputFormat, Part as Part2, PartDeleteErrors, @@ -117,6 +123,7 @@ import type { QuestionRejectResponses, QuestionReplyErrors, QuestionReplyResponses, + Session as Session4, SessionAbortErrors, SessionAbortResponses, SessionChildrenErrors, @@ -172,6 +179,7 @@ import type { SyncStealErrors, SyncStealResponses, TextPartInput, + Todo, ToolIdsErrors, ToolIdsResponses, ToolListErrors, @@ -558,6 +566,119 @@ export class Event extends HeyApiClient { } } +export class Backup extends HeyApiClient { + /** + * List backup sessions + * + * List all local sessions available for backup export. + */ + public list( + parameters?: { + directory?: string + workspace?: string + }, + options?: Options, + ) { + const params = buildClientParams( + [parameters], + [ + { + args: [ + { in: "query", key: "directory" }, + { in: "query", key: "workspace" }, + ], + }, + ], + ) + return (options?.client ?? this.client).post({ + url: "/backup/list", + ...options, + ...params, + }) + } + + /** + * Export session backup + * + * Return the full JSON backup payload for a single session. + */ + public export( + parameters?: { + directory?: string + workspace?: string + sessionID?: string + }, + options?: Options, + ) { + const params = buildClientParams( + [parameters], + [ + { + args: [ + { in: "query", key: "directory" }, + { in: "query", key: "workspace" }, + { in: "body", key: "sessionID" }, + ], + }, + ], + ) + return (options?.client ?? this.client).post({ + url: "/backup/export", + ...options, + ...params, + headers: { + "Content-Type": "application/json", + ...options?.headers, + ...params.headers, + }, + }) + } + + /** + * Import session backup + * + * Restore a single session from a JSON backup payload. + */ + public import( + parameters?: { + directory?: string + workspace?: string + payload?: { + info: Session4 + messages: Array<{ + info: Message + parts: Array + }> + todos: Array + } + }, + options?: Options, + ) { + const params = buildClientParams( + [parameters], + [ + { + args: [ + { in: "query", key: "directory" }, + { in: "query", key: "workspace" }, + { in: "body", key: "payload" }, + ], + }, + ], + ) + return (options?.client ?? this.client).post({ + url: "/backup/import", + ...options, + ...params, + headers: { + "Content-Type": "application/json", + ...options?.headers, + ...params.headers, + }, + }) + } +} + export class Config2 extends HeyApiClient { /** * Get configuration @@ -4684,6 +4805,11 @@ export class OpencodeClient extends HeyApiClient { return (this._event ??= new Event({ client: this.client })) } + private _backup?: Backup + get backup(): Backup { + return (this._backup ??= new Backup({ client: this.client })) + } + private _config?: Config2 get config(): Config2 { return (this._config ??= new Config2({ client: this.client })) diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts index 7734ca53ebc2..c177fb9c2b92 100644 --- a/packages/sdk/js/src/v2/gen/types.gen.ts +++ b/packages/sdk/js/src/v2/gen/types.gen.ts @@ -6,8 +6,6 @@ export type ClientOptions = { export type Event = | EventServerInstanceDisposed - | EventFileEdited - | EventFileWatcherUpdated | EventLspClientDiagnostics | EventLspUpdated | EventMessagePartDelta @@ -15,12 +13,14 @@ export type Event = | EventPermissionReplied | EventSessionDiff | EventSessionError + | EventTodoUpdated + | EventFileEdited + | EventFileWatcherUpdated | EventInstallationUpdated | EventInstallationUpdateAvailable | EventQuestionAsked | EventQuestionReplied | EventQuestionRejected - | EventTodoUpdated | EventSessionStatus | EventSessionIdle | EventSessionCompacted @@ -187,6 +187,21 @@ export type ApiError = { } } +export type Todo = { + /** + * Brief description of the task + */ + content: string + /** + * Current status of the task: pending, in_progress, completed, cancelled + */ + status: string + /** + * Priority level of the task: high, medium, low + */ + priority: string +} + export type QuestionOption = { /** * Display text (1-5 words, concise) @@ -243,21 +258,6 @@ export type QuestionRejected = { requestID: string } -export type Todo = { - /** - * Brief description of the task - */ - content: string - /** - * Current status of the task: pending, in_progress, completed, cancelled - */ - status: string - /** - * Priority level of the task: high, medium, low - */ - priority: string -} - export type SessionStatus = | { type: "idle" @@ -771,8 +771,6 @@ export type GlobalEvent = { workspace?: string payload: | EventServerInstanceDisposed - | EventFileEdited - | EventFileWatcherUpdated | EventLspClientDiagnostics | EventLspUpdated | EventMessagePartDelta @@ -780,12 +778,14 @@ export type GlobalEvent = { | EventPermissionReplied | EventSessionDiff | EventSessionError + | EventTodoUpdated + | EventFileEdited + | EventFileWatcherUpdated | EventInstallationUpdated | EventInstallationUpdateAvailable | EventQuestionAsked | EventQuestionReplied | EventQuestionRejected - | EventTodoUpdated | EventSessionStatus | EventSessionIdle | EventSessionCompacted @@ -2259,23 +2259,6 @@ export type EventServerInstanceDisposed = { } } -export type EventFileEdited = { - id: string - type: "file.edited" - properties: { - file: string - } -} - -export type EventFileWatcherUpdated = { - id: string - type: "file.watcher.updated" - properties: { - file: string - event: "add" | "change" | "unlink" - } -} - export type EventLspClientDiagnostics = { id: string type: "lsp.client.diagnostics" @@ -2346,6 +2329,32 @@ export type EventSessionError = { } } +export type EventTodoUpdated = { + id: string + type: "todo.updated" + properties: { + sessionID: string + todos: Array + } +} + +export type EventFileEdited = { + id: string + type: "file.edited" + properties: { + file: string + } +} + +export type EventFileWatcherUpdated = { + id: string + type: "file.watcher.updated" + properties: { + file: string + event: "add" | "change" | "unlink" + } +} + export type EventInstallationUpdated = { id: string type: "installation.updated" @@ -2380,15 +2389,6 @@ export type EventQuestionRejected = { properties: QuestionRejected } -export type EventTodoUpdated = { - id: string - type: "todo.updated" - properties: { - sessionID: string - todos: Array - } -} - export type EventSessionStatus = { id: string type: "session.status" @@ -3475,6 +3475,105 @@ export type EventSubscribeResponses = { export type EventSubscribeResponse = EventSubscribeResponses[keyof EventSubscribeResponses] +export type BackupListData = { + body?: never + path?: never + query?: { + directory?: string + workspace?: string + } + url: "/backup/list" +} + +export type BackupListResponses = { + /** + * Sessions + */ + 200: Array +} + +export type BackupListResponse = BackupListResponses[keyof BackupListResponses] + +export type BackupExportData = { + body?: { + sessionID: string + } + path?: never + query?: { + directory?: string + workspace?: string + } + url: "/backup/export" +} + +export type BackupExportErrors = { + /** + * Bad request + */ + 400: BadRequestError + /** + * Not found + */ + 404: NotFoundError +} + +export type BackupExportError = BackupExportErrors[keyof BackupExportErrors] + +export type BackupExportResponses = { + /** + * Backup payload + */ + 200: { + info: Session + messages: Array<{ + info: Message + parts: Array + }> + todos: Array + } +} + +export type BackupExportResponse = BackupExportResponses[keyof BackupExportResponses] + +export type BackupImportData = { + body?: { + payload: { + info: Session + messages: Array<{ + info: Message + parts: Array + }> + todos: Array + } + } + path?: never + query?: { + directory?: string + workspace?: string + } + url: "/backup/import" +} + +export type BackupImportErrors = { + /** + * Bad request + */ + 400: BadRequestError +} + +export type BackupImportError = BackupImportErrors[keyof BackupImportErrors] + +export type BackupImportResponses = { + /** + * Imported session + */ + 200: { + sessionID: string + } +} + +export type BackupImportResponse = BackupImportResponses[keyof BackupImportResponses] + export type ConfigGetData = { body?: never path?: never