Add auto-sync of SDK sessions: directly read from OpenCode storage and import to Native mode
Some checks failed
Release Binaries / release (push) Has been cancelled
Some checks failed
Release Binaries / release (push) Has been cancelled
This commit is contained in:
@@ -26,6 +26,7 @@ import { registerAntigravityRoutes } from "./routes/antigravity"
|
|||||||
import { registerSkillsRoutes } from "./routes/skills"
|
import { registerSkillsRoutes } from "./routes/skills"
|
||||||
import { registerContextEngineRoutes } from "./routes/context-engine"
|
import { registerContextEngineRoutes } from "./routes/context-engine"
|
||||||
import { registerNativeSessionsRoutes } from "./routes/native-sessions"
|
import { registerNativeSessionsRoutes } from "./routes/native-sessions"
|
||||||
|
import { registerSdkSyncRoutes } from "./routes/sdk-sync"
|
||||||
import { initSessionManager } from "../storage/session-store"
|
import { initSessionManager } from "../storage/session-store"
|
||||||
import { ServerMeta } from "../api-types"
|
import { ServerMeta } from "../api-types"
|
||||||
import { InstanceStore } from "../storage/instance-store"
|
import { InstanceStore } from "../storage/instance-store"
|
||||||
@@ -144,6 +145,12 @@ export function createHttpServer(deps: HttpServerDeps) {
|
|||||||
eventBus: deps.eventBus,
|
eventBus: deps.eventBus,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Register SDK session sync routes (for auto-migration from OpenCode to Native)
|
||||||
|
registerSdkSyncRoutes(app, {
|
||||||
|
logger: deps.logger,
|
||||||
|
dataDir,
|
||||||
|
})
|
||||||
|
|
||||||
registerInstanceProxyRoutes(app, { workspaceManager: deps.workspaceManager, logger: proxyLogger })
|
registerInstanceProxyRoutes(app, { workspaceManager: deps.workspaceManager, logger: proxyLogger })
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
206
packages/server/src/server/routes/sdk-sync.ts
Normal file
206
packages/server/src/server/routes/sdk-sync.ts
Normal file
@@ -0,0 +1,206 @@
|
|||||||
|
/**
|
||||||
|
* SDK Session Sync - Reads sessions from OpenCode's storage and syncs to Native mode
|
||||||
|
*
|
||||||
|
* OpenCode stores sessions in:
|
||||||
|
* - Windows: %USERPROFILE%\.local\share\opencode\storage\session\{projectId}\
|
||||||
|
* - Linux/Mac: ~/.local/share/opencode/storage/session/{projectId}/
|
||||||
|
*
|
||||||
|
* The projectId is a hash of the workspace folder path.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { FastifyInstance } from "fastify"
|
||||||
|
import { readdir, readFile } from "fs/promises"
|
||||||
|
import { existsSync } from "fs"
|
||||||
|
import { join } from "path"
|
||||||
|
import { createHash } from "crypto"
|
||||||
|
import { homedir } from "os"
|
||||||
|
import { Logger } from "../../logger"
|
||||||
|
import { getSessionManager } from "../../storage/session-store"
|
||||||
|
|
||||||
|
interface SdkSyncRouteDeps {
|
||||||
|
logger: Logger
|
||||||
|
dataDir: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface OpenCodeSession {
|
||||||
|
id: string
|
||||||
|
version: string
|
||||||
|
projectID: string
|
||||||
|
directory: string
|
||||||
|
title: string
|
||||||
|
parentID?: string
|
||||||
|
time: {
|
||||||
|
created: number
|
||||||
|
updated: number
|
||||||
|
}
|
||||||
|
summary?: {
|
||||||
|
additions: number
|
||||||
|
deletions: number
|
||||||
|
files: number
|
||||||
|
}
|
||||||
|
share?: {
|
||||||
|
url: string
|
||||||
|
version: number
|
||||||
|
}
|
||||||
|
revert?: {
|
||||||
|
messageID: string
|
||||||
|
reason?: string
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the OpenCode storage directory
|
||||||
|
*/
|
||||||
|
function getOpenCodeStorageDir(): string {
|
||||||
|
const homeDir = homedir()
|
||||||
|
|
||||||
|
// Windows: %USERPROFILE%\.local\share\opencode
|
||||||
|
// Linux/Mac: ~/.local/share/opencode
|
||||||
|
return join(homeDir, ".local", "share", "opencode", "storage")
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate the project ID hash that OpenCode uses
|
||||||
|
* OpenCode uses a SHA1 hash of the folder path
|
||||||
|
*/
|
||||||
|
function generateProjectId(folderPath: string): string {
|
||||||
|
return createHash("sha1").update(folderPath).digest("hex")
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Read all sessions for a project from OpenCode's storage
|
||||||
|
*/
|
||||||
|
async function readOpenCodeSessions(folderPath: string, logger: Logger): Promise<OpenCodeSession[]> {
|
||||||
|
const storageDir = getOpenCodeStorageDir()
|
||||||
|
const projectId = generateProjectId(folderPath)
|
||||||
|
const sessionDir = join(storageDir, "session", projectId)
|
||||||
|
|
||||||
|
logger.info({ folderPath, projectId, sessionDir }, "Looking for OpenCode sessions")
|
||||||
|
|
||||||
|
if (!existsSync(sessionDir)) {
|
||||||
|
logger.info({ sessionDir }, "OpenCode session directory not found")
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
|
||||||
|
const sessions: OpenCodeSession[] = []
|
||||||
|
|
||||||
|
try {
|
||||||
|
const files = await readdir(sessionDir)
|
||||||
|
const sessionFiles = files.filter(f => f.startsWith("ses_") && f.endsWith(".json"))
|
||||||
|
|
||||||
|
logger.info({ count: sessionFiles.length }, "Found OpenCode session files")
|
||||||
|
|
||||||
|
for (const file of sessionFiles) {
|
||||||
|
try {
|
||||||
|
const filePath = join(sessionDir, file)
|
||||||
|
const content = await readFile(filePath, "utf-8")
|
||||||
|
const session = JSON.parse(content) as OpenCodeSession
|
||||||
|
sessions.push(session)
|
||||||
|
} catch (error) {
|
||||||
|
logger.warn({ file, error }, "Failed to read session file")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
logger.error({ error }, "Failed to read OpenCode sessions directory")
|
||||||
|
}
|
||||||
|
|
||||||
|
return sessions
|
||||||
|
}
|
||||||
|
|
||||||
|
export function registerSdkSyncRoutes(app: FastifyInstance, deps: SdkSyncRouteDeps) {
|
||||||
|
const logger = deps.logger.child({ component: "sdk-sync" })
|
||||||
|
const sessionManager = getSessionManager(deps.dataDir)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sync sessions from SDK (OpenCode) to Native mode
|
||||||
|
* This reads sessions directly from OpenCode's storage directory
|
||||||
|
*/
|
||||||
|
app.post<{
|
||||||
|
Params: { workspaceId: string }
|
||||||
|
Body: { folderPath: string }
|
||||||
|
}>("/api/native/workspaces/:workspaceId/sync-sdk", async (request, reply) => {
|
||||||
|
const { workspaceId } = request.params
|
||||||
|
const { folderPath } = request.body
|
||||||
|
|
||||||
|
if (!folderPath) {
|
||||||
|
return reply.status(400).send({ error: "Missing folderPath" })
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info({ workspaceId, folderPath }, "Starting SDK session sync")
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Read sessions from OpenCode's storage
|
||||||
|
const sdkSessions = await readOpenCodeSessions(folderPath, logger)
|
||||||
|
|
||||||
|
if (sdkSessions.length === 0) {
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
imported: 0,
|
||||||
|
skipped: 0,
|
||||||
|
message: "No SDK sessions found for this folder"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert OpenCode sessions to our format
|
||||||
|
const sessionsToImport = sdkSessions.map(s => ({
|
||||||
|
id: s.id,
|
||||||
|
title: s.title,
|
||||||
|
parentId: s.parentID || null,
|
||||||
|
createdAt: s.time.created,
|
||||||
|
updatedAt: s.time.updated,
|
||||||
|
// We don't have model/agent info in the SDK session format
|
||||||
|
// Those are stored in OpenCode's config, not session
|
||||||
|
}))
|
||||||
|
|
||||||
|
// Import to native session store
|
||||||
|
const result = await sessionManager.importSessions(workspaceId, sessionsToImport)
|
||||||
|
|
||||||
|
logger.info({ workspaceId, ...result }, "SDK session sync completed")
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
imported: result.imported,
|
||||||
|
skipped: result.skipped,
|
||||||
|
total: sdkSessions.length
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
logger.error({ error }, "SDK session sync failed")
|
||||||
|
return reply.status(500).send({
|
||||||
|
error: "Failed to sync SDK sessions",
|
||||||
|
details: error instanceof Error ? error.message : String(error)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if OpenCode sessions exist for a folder
|
||||||
|
*/
|
||||||
|
app.post<{
|
||||||
|
Body: { folderPath: string }
|
||||||
|
}>("/api/native/check-sdk-sessions", async (request, reply) => {
|
||||||
|
const { folderPath } = request.body
|
||||||
|
|
||||||
|
if (!folderPath) {
|
||||||
|
return reply.status(400).send({ error: "Missing folderPath" })
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const sdkSessions = await readOpenCodeSessions(folderPath, logger)
|
||||||
|
|
||||||
|
return {
|
||||||
|
found: sdkSessions.length > 0,
|
||||||
|
count: sdkSessions.length,
|
||||||
|
sessions: sdkSessions.map(s => ({
|
||||||
|
id: s.id,
|
||||||
|
title: s.title,
|
||||||
|
created: s.time.created
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
logger.error({ error }, "Failed to check SDK sessions")
|
||||||
|
return { found: false, count: 0, sessions: [] }
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
logger.info("SDK sync routes registered")
|
||||||
|
}
|
||||||
@@ -192,6 +192,43 @@ export const nativeSessionApi = {
|
|||||||
return response.json()
|
return response.json()
|
||||||
},
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sync sessions from SDK (OpenCode) to Native mode
|
||||||
|
* This reads sessions directly from OpenCode's storage
|
||||||
|
*/
|
||||||
|
async syncFromSdk(workspaceId: string, folderPath: string): Promise<{
|
||||||
|
success: boolean
|
||||||
|
imported: number
|
||||||
|
skipped: number
|
||||||
|
total?: number
|
||||||
|
message?: string
|
||||||
|
}> {
|
||||||
|
const response = await fetch(`${CODENOMAD_API_BASE}/api/native/workspaces/${encodeURIComponent(workspaceId)}/sync-sdk`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ folderPath })
|
||||||
|
})
|
||||||
|
if (!response.ok) throw new Error("Failed to sync SDK sessions")
|
||||||
|
return response.json()
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if SDK sessions exist for a folder
|
||||||
|
*/
|
||||||
|
async checkSdkSessions(folderPath: string): Promise<{
|
||||||
|
found: boolean
|
||||||
|
count: number
|
||||||
|
sessions: Array<{ id: string; title: string; created: number }>
|
||||||
|
}> {
|
||||||
|
const response = await fetch(`${CODENOMAD_API_BASE}/api/native/check-sdk-sessions`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ folderPath })
|
||||||
|
})
|
||||||
|
if (!response.ok) throw new Error("Failed to check SDK sessions")
|
||||||
|
return response.json()
|
||||||
|
},
|
||||||
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Send a prompt to the session and get a streaming response
|
* Send a prompt to the session and get a streaming response
|
||||||
|
|||||||
@@ -390,15 +390,28 @@ async function fetchSessions(instanceId: string): Promise<void> {
|
|||||||
let responseData: any[] = []
|
let responseData: any[] = []
|
||||||
|
|
||||||
if (isNative) {
|
if (isNative) {
|
||||||
// Auto-import cached SDK sessions on native mode startup
|
// Auto-sync SDK sessions from OpenCode's storage on native mode startup
|
||||||
if (needsMigration(instanceId)) {
|
if (needsMigration(instanceId)) {
|
||||||
try {
|
try {
|
||||||
const result = await autoImportCachedSessions(instanceId)
|
// First try to sync directly from OpenCode's storage (most reliable)
|
||||||
if (result.imported > 0) {
|
const folderPath = instance.folder
|
||||||
log.info({ instanceId, result }, "Auto-imported SDK sessions to native mode")
|
if (folderPath) {
|
||||||
|
log.info({ instanceId, folderPath }, "Syncing SDK sessions from OpenCode storage")
|
||||||
|
const syncResult = await nativeSessionApi.syncFromSdk(instanceId, folderPath)
|
||||||
|
if (syncResult.imported > 0) {
|
||||||
|
log.info({ instanceId, syncResult }, "Synced SDK sessions from OpenCode storage")
|
||||||
|
} else if (syncResult.message) {
|
||||||
|
log.info({ instanceId, message: syncResult.message }, "SDK sync info")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Also try the localStorage cache as fallback
|
||||||
|
const cacheResult = await autoImportCachedSessions(instanceId)
|
||||||
|
if (cacheResult.imported > 0) {
|
||||||
|
log.info({ instanceId, cacheResult }, "Auto-imported cached SDK sessions")
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
log.error({ instanceId, error }, "Failed to auto-import SDK sessions")
|
log.error({ instanceId, error }, "Failed to sync SDK sessions")
|
||||||
markMigrated(instanceId) // Mark as migrated to prevent repeated failures
|
markMigrated(instanceId) // Mark as migrated to prevent repeated failures
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user