feat: implement manual SDK session sync and fix UI crash
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:
14
.gitignore
vendored
14
.gitignore
vendored
@@ -41,7 +41,7 @@ install.log
|
|||||||
# ===================== OS Generated Files ===============
|
# ===================== OS Generated Files ===============
|
||||||
Thumbs.db
|
Thumbs.db
|
||||||
ehthumbs.db
|
ehthumbs.db
|
||||||
Desktop.ini
|
44: Desktop.ini
|
||||||
|
|
||||||
# ===================== Temporary Files ==================
|
# ===================== Temporary Files ==================
|
||||||
*.tmp
|
*.tmp
|
||||||
@@ -100,4 +100,14 @@ packages/server/dist/
|
|||||||
# ===================== Backup Files =====================
|
# ===================== Backup Files =====================
|
||||||
*.backup
|
*.backup
|
||||||
*_backup*
|
*_backup*
|
||||||
_backup_original/
|
_backup_original/
|
||||||
|
|
||||||
|
# ===================== NomadArch Specific Data ============
|
||||||
|
.codenomad-data/
|
||||||
|
**/logs/
|
||||||
|
**/.codenomad-data/
|
||||||
|
sdk-sync-debug.log
|
||||||
|
**/sessions.json
|
||||||
|
**/messages.json
|
||||||
|
**/workspaces.json
|
||||||
|
*.json.bak
|
||||||
@@ -9,10 +9,9 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { FastifyInstance } from "fastify"
|
import { FastifyInstance } from "fastify"
|
||||||
import { readdir, readFile } from "fs/promises"
|
import { readdir, readFile, appendFile } from "fs/promises"
|
||||||
import { existsSync } from "fs"
|
import { existsSync } from "fs"
|
||||||
import { join } from "path"
|
import { join } from "path"
|
||||||
import { createHash } from "crypto"
|
|
||||||
import { homedir } from "os"
|
import { homedir } from "os"
|
||||||
import { Logger } from "../../logger"
|
import { Logger } from "../../logger"
|
||||||
import { getSessionManager } from "../../storage/session-store"
|
import { getSessionManager } from "../../storage/session-store"
|
||||||
@@ -59,54 +58,92 @@ function getOpenCodeStorageDir(): string {
|
|||||||
return join(homeDir, ".local", "share", "opencode", "storage")
|
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
|
* Read all sessions for a project from OpenCode's storage
|
||||||
*/
|
*/
|
||||||
async function readOpenCodeSessions(folderPath: string, logger: Logger): Promise<OpenCodeSession[]> {
|
async function readOpenCodeSessions(folderPath: string, logger: Logger): Promise<OpenCodeSession[]> {
|
||||||
const storageDir = getOpenCodeStorageDir()
|
const storageDir = getOpenCodeStorageDir()
|
||||||
const projectId = generateProjectId(folderPath)
|
const sessionBaseDir = join(storageDir, "session")
|
||||||
const sessionDir = join(storageDir, "session", projectId)
|
const debugLogPath = join(process.cwd(), "sdk-sync-debug.log")
|
||||||
|
|
||||||
logger.info({ folderPath, projectId, sessionDir }, "Looking for OpenCode sessions")
|
const logDebug = async (msg: string, obj?: any) => {
|
||||||
|
const line = `[${new Date().toISOString()}] ${msg}${obj ? ' ' + JSON.stringify(obj) : ''}\n`
|
||||||
|
await appendFile(debugLogPath, line).catch(() => { })
|
||||||
|
logger.info(obj || {}, msg)
|
||||||
|
}
|
||||||
|
|
||||||
if (!existsSync(sessionDir)) {
|
// Normalize target folder path for comparison
|
||||||
logger.info({ sessionDir }, "OpenCode session directory not found")
|
const targetPath = folderPath.replace(/\\/g, '/').toLowerCase().trim()
|
||||||
|
|
||||||
|
await logDebug("Starting SDK session search", { folderPath, targetPath, sessionBaseDir })
|
||||||
|
|
||||||
|
if (!existsSync(sessionBaseDir)) {
|
||||||
|
await logDebug("OpenCode session base directory not found", { sessionBaseDir })
|
||||||
return []
|
return []
|
||||||
}
|
}
|
||||||
|
|
||||||
const sessions: OpenCodeSession[] = []
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const files = await readdir(sessionDir)
|
const projectDirs = await readdir(sessionBaseDir, { withFileTypes: true })
|
||||||
const sessionFiles = files.filter(f => f.startsWith("ses_") && f.endsWith(".json"))
|
const dirs = projectDirs.filter(d => d.isDirectory()).map(d => d.name)
|
||||||
|
|
||||||
logger.info({ count: sessionFiles.length }, "Found OpenCode session files")
|
await logDebug("Scanning project directories", { count: dirs.length })
|
||||||
|
|
||||||
|
for (const projectId of dirs) {
|
||||||
|
const sessionDir = join(sessionBaseDir, projectId)
|
||||||
|
|
||||||
for (const file of sessionFiles) {
|
|
||||||
try {
|
try {
|
||||||
const filePath = join(sessionDir, file)
|
const files = await readdir(sessionDir)
|
||||||
const content = await readFile(filePath, "utf-8")
|
const firstSessionFile = files.find(f => f.startsWith("ses_") && f.endsWith(".json"))
|
||||||
const session = JSON.parse(content) as OpenCodeSession
|
|
||||||
sessions.push(session)
|
if (firstSessionFile) {
|
||||||
} catch (error) {
|
const content = await readFile(join(sessionDir, firstSessionFile), "utf-8")
|
||||||
logger.warn({ file, error }, "Failed to read session file")
|
const sessionData = JSON.parse(content) as OpenCodeSession
|
||||||
|
|
||||||
|
if (!sessionData.directory) {
|
||||||
|
await logDebug("Session file missing directory field", { projectId, firstSessionFile })
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
const sessionPath = sessionData.directory.replace(/\\/g, '/').toLowerCase().trim()
|
||||||
|
|
||||||
|
if (sessionPath === targetPath) {
|
||||||
|
await logDebug("MATCH FOUND!", { projectId, sessionPath })
|
||||||
|
|
||||||
|
// This is the correct directory, read all sessions
|
||||||
|
const sessions: OpenCodeSession[] = [sessionData]
|
||||||
|
const otherFiles = files.filter(f => f !== firstSessionFile && f.startsWith("ses_") && f.endsWith(".json"))
|
||||||
|
|
||||||
|
for (const file of otherFiles) {
|
||||||
|
try {
|
||||||
|
const fileContent = await readFile(join(sessionDir, file), "utf-8")
|
||||||
|
sessions.push(JSON.parse(fileContent) as OpenCodeSession)
|
||||||
|
} catch (e) {
|
||||||
|
logger.warn({ file, error: e }, "Failed to read session file")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await logDebug("Read sessions count", { count: sessions.length })
|
||||||
|
return sessions
|
||||||
|
} else {
|
||||||
|
// Just log a few mismatches to avoid bloating
|
||||||
|
// await logDebug("Mismatch", { sessionPath, targetPath })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
await logDebug("Error scanning project directory", { projectId, error: String(e) })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error({ error }, "Failed to read OpenCode sessions directory")
|
await logDebug("Failed to scan OpenCode sessions directory", { error: String(error) })
|
||||||
}
|
}
|
||||||
|
|
||||||
return sessions
|
await logDebug("No sessions found after scan")
|
||||||
|
return []
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
export function registerSdkSyncRoutes(app: FastifyInstance, deps: SdkSyncRouteDeps) {
|
export function registerSdkSyncRoutes(app: FastifyInstance, deps: SdkSyncRouteDeps) {
|
||||||
const logger = deps.logger.child({ component: "sdk-sync" })
|
const logger = deps.logger.child({ component: "sdk-sync" })
|
||||||
const sessionManager = getSessionManager(deps.dataDir)
|
const sessionManager = getSessionManager(deps.dataDir)
|
||||||
|
|||||||
@@ -7,6 +7,8 @@ import {
|
|||||||
Settings,
|
Settings,
|
||||||
Plug,
|
Plug,
|
||||||
Sparkles,
|
Sparkles,
|
||||||
|
RefreshCw,
|
||||||
|
Download,
|
||||||
ChevronRight,
|
ChevronRight,
|
||||||
ChevronDown,
|
ChevronDown,
|
||||||
Folder,
|
Folder,
|
||||||
@@ -21,6 +23,7 @@ import InstanceServiceStatus from "../instance-service-status"
|
|||||||
import McpManager from "../mcp-manager"
|
import McpManager from "../mcp-manager"
|
||||||
import { catalog, catalogLoading, catalogError, loadCatalog } from "../../stores/skills"
|
import { catalog, catalogLoading, catalogError, loadCatalog } from "../../stores/skills"
|
||||||
import { getSessionSkills, setSessionSkills } from "../../stores/session-state"
|
import { getSessionSkills, setSessionSkills } from "../../stores/session-state"
|
||||||
|
import { syncSessionsFromSdk } from "../../stores/session-api"
|
||||||
|
|
||||||
export interface FileNode {
|
export interface FileNode {
|
||||||
name: string
|
name: string
|
||||||
@@ -132,6 +135,7 @@ export const Sidebar: Component<SidebarProps> = (props) => {
|
|||||||
const [rootFiles, setRootFiles] = createSignal<FileNode[]>([])
|
const [rootFiles, setRootFiles] = createSignal<FileNode[]>([])
|
||||||
const [lastRequestedTab, setLastRequestedTab] = createSignal<string | null>(null)
|
const [lastRequestedTab, setLastRequestedTab] = createSignal<string | null>(null)
|
||||||
const [searchQuery, setSearchQuery] = createSignal("")
|
const [searchQuery, setSearchQuery] = createSignal("")
|
||||||
|
const [syncing, setSyncing] = createSignal(false)
|
||||||
const [searchResults, setSearchResults] = createSignal<FileNode[]>([])
|
const [searchResults, setSearchResults] = createSignal<FileNode[]>([])
|
||||||
const [searchLoading, setSearchLoading] = createSignal(false)
|
const [searchLoading, setSearchLoading] = createSignal(false)
|
||||||
const [gitStatus, setGitStatus] = createSignal<{
|
const [gitStatus, setGitStatus] = createSignal<{
|
||||||
@@ -322,6 +326,25 @@ export const Sidebar: Component<SidebarProps> = (props) => {
|
|||||||
</Show>
|
</Show>
|
||||||
<Show when={activeTab() === "sessions"}>
|
<Show when={activeTab() === "sessions"}>
|
||||||
<div class="flex flex-col gap-1">
|
<div class="flex flex-col gap-1">
|
||||||
|
<div class="px-2 mb-2">
|
||||||
|
<button
|
||||||
|
onClick={async () => {
|
||||||
|
setSyncing(true)
|
||||||
|
try {
|
||||||
|
await syncSessionsFromSdk(props.instanceId)
|
||||||
|
} finally {
|
||||||
|
setSyncing(false)
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
disabled={syncing()}
|
||||||
|
class="w-full flex items-center justify-center gap-2 px-3 py-2 text-xs font-semibold uppercase tracking-wide rounded-md bg-blue-500/10 text-blue-400 border border-blue-500/20 hover:bg-blue-500/20 disabled:opacity-50 transition-all"
|
||||||
|
>
|
||||||
|
<Show when={syncing()} fallback={<Download size={14} />}>
|
||||||
|
<RefreshCw size={14} class="animate-spin" />
|
||||||
|
</Show>
|
||||||
|
{syncing() ? "Syncing..." : "Sync SDK Sessions"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
<For each={props.sessions}>
|
<For each={props.sessions}>
|
||||||
{(session) => (
|
{(session) => (
|
||||||
<div
|
<div
|
||||||
@@ -479,8 +502,8 @@ export const Sidebar: Component<SidebarProps> = (props) => {
|
|||||||
type="button"
|
type="button"
|
||||||
onClick={() => toggleSkillSelection(skill.id)}
|
onClick={() => toggleSkillSelection(skill.id)}
|
||||||
class={`w-full text-left px-3 py-2 rounded-md border transition-colors ${isSelected()
|
class={`w-full text-left px-3 py-2 rounded-md border transition-colors ${isSelected()
|
||||||
? "border-blue-500/60 bg-blue-500/10 text-blue-200"
|
? "border-blue-500/60 bg-blue-500/10 text-blue-200"
|
||||||
: "border-white/10 bg-white/5 text-zinc-300 hover:text-white"
|
: "border-white/10 bg-white/5 text-zinc-300 hover:text-white"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<div class="text-xs font-semibold">{skill.name}</div>
|
<div class="text-xs font-semibold">{skill.name}</div>
|
||||||
|
|||||||
@@ -1153,6 +1153,27 @@ async function loadMessages(instanceId: string, sessionId: string, force = false
|
|||||||
updateSessionInfo(instanceId, sessionId)
|
updateSessionInfo(instanceId, sessionId)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function syncSessionsFromSdk(instanceId: string): Promise<void> {
|
||||||
|
const instance = instances().get(instanceId)
|
||||||
|
if (!instance) throw new Error("Instance not ready")
|
||||||
|
|
||||||
|
const folderPath = instance.folder
|
||||||
|
if (!folderPath) throw new Error("No folder path for instance")
|
||||||
|
|
||||||
|
log.info({ instanceId, folderPath }, "Manual SDK sync requested")
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await nativeSessionApi.syncFromSdk(instanceId, folderPath)
|
||||||
|
log.info({ instanceId, result }, "Manual SDK sync result")
|
||||||
|
|
||||||
|
// Refresh sessions after sync
|
||||||
|
await fetchSessions(instanceId)
|
||||||
|
} catch (error) {
|
||||||
|
log.error({ instanceId, error }, "Manual SDK sync failed")
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export {
|
export {
|
||||||
createSession,
|
createSession,
|
||||||
deleteSession,
|
deleteSession,
|
||||||
@@ -1160,6 +1181,7 @@ export {
|
|||||||
fetchProviders,
|
fetchProviders,
|
||||||
|
|
||||||
fetchSessions,
|
fetchSessions,
|
||||||
|
syncSessionsFromSdk,
|
||||||
forkSession,
|
forkSession,
|
||||||
loadMessages,
|
loadMessages,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ import {
|
|||||||
setActiveParentSession,
|
setActiveParentSession,
|
||||||
setActiveSession,
|
setActiveSession,
|
||||||
setSessionDraftPrompt,
|
setSessionDraftPrompt,
|
||||||
} from "./session-state"
|
} from "./session-state"
|
||||||
|
|
||||||
import { getDefaultModel } from "./session-models"
|
import { getDefaultModel } from "./session-models"
|
||||||
import {
|
import {
|
||||||
@@ -35,6 +35,7 @@ import {
|
|||||||
fetchAgents,
|
fetchAgents,
|
||||||
fetchProviders,
|
fetchProviders,
|
||||||
fetchSessions,
|
fetchSessions,
|
||||||
|
syncSessionsFromSdk,
|
||||||
forkSession,
|
forkSession,
|
||||||
loadMessages,
|
loadMessages,
|
||||||
} from "./session-api"
|
} from "./session-api"
|
||||||
@@ -88,6 +89,7 @@ export {
|
|||||||
fetchAgents,
|
fetchAgents,
|
||||||
fetchProviders,
|
fetchProviders,
|
||||||
fetchSessions,
|
fetchSessions,
|
||||||
|
syncSessionsFromSdk,
|
||||||
forkSession,
|
forkSession,
|
||||||
getActiveParentSession,
|
getActiveParentSession,
|
||||||
getActiveSession,
|
getActiveSession,
|
||||||
|
|||||||
Reference in New Issue
Block a user