Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
132 changes: 132 additions & 0 deletions packages/opencode/src/backup/index.ts
Original file line number Diff line number Diff line change
@@ -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<Schema.Schema.Type<typeof Payload>>
const decodePayload = Schema.decodeUnknownSync(Payload)

export interface Interface {
readonly list: (projectID: ProjectID) => Effect.Effect<Session.Info[]>
readonly exportSession: (sessionID: SessionID, projectID: ProjectID) => Effect.Effect<Payload, InstanceType<typeof NotFoundError>>
readonly importSession: (payload: Payload, projectID: ProjectID) => Effect.Effect<SessionID>
}

export class Service extends Context.Service<Service, Interface>()("@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 "."
2 changes: 2 additions & 0 deletions packages/opencode/src/effect/app-runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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,
Expand Down
101 changes: 101 additions & 0 deletions packages/opencode/src/server/routes/instance/backup.ts
Original file line number Diff line number Diff line change
@@ -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)),
),
})
},
),
)
2 changes: 2 additions & 0 deletions packages/opencode/src/server/routes/instance/httpapi/api.ts
Original file line number Diff line number Diff line change
@@ -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"
Expand Down Expand Up @@ -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)
Expand Down
Original file line number Diff line number Diff line change
@@ -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.",
}),
)
Original file line number Diff line number Diff line change
@@ -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)
}),
)
Loading
Loading