diff --git a/cmd/entire/cli/agent/opencode/cli_commands.go b/cmd/entire/cli/agent/opencode/cli_commands.go new file mode 100644 index 000000000..e12ac7ad4 --- /dev/null +++ b/cmd/entire/cli/agent/opencode/cli_commands.go @@ -0,0 +1,52 @@ +package opencode + +import ( + "context" + "fmt" + "os/exec" + "strings" + "time" +) + +// openCodeCommandTimeout is the maximum time to wait for opencode CLI commands. +const openCodeCommandTimeout = 30 * time.Second + +// runOpenCodeSessionDelete runs `opencode session delete ` to remove +// a session from OpenCode's database. Treats "Session not found" as success +// (nothing to delete). +func runOpenCodeSessionDelete(sessionID string) error { + ctx, cancel := context.WithTimeout(context.Background(), openCodeCommandTimeout) + defer cancel() + + cmd := exec.CommandContext(ctx, "opencode", "session", "delete", sessionID) + output, err := cmd.CombinedOutput() + if err != nil { + if ctx.Err() == context.DeadlineExceeded { + return fmt.Errorf("opencode session delete timed out after %s", openCodeCommandTimeout) + } + // Treat "Session not found" as success — nothing to delete. + if strings.Contains(string(output), "Session not found") { + return nil + } + return fmt.Errorf("opencode session delete failed: %w (output: %s)", err, string(output)) + } + return nil +} + +// runOpenCodeImport runs `opencode import ` to import a session into +// OpenCode's database. The import preserves the original session ID +// from the export file. +func runOpenCodeImport(exportFilePath string) error { + ctx, cancel := context.WithTimeout(context.Background(), openCodeCommandTimeout) + defer cancel() + + cmd := exec.CommandContext(ctx, "opencode", "import", exportFilePath) + if output, err := cmd.CombinedOutput(); err != nil { + if ctx.Err() == context.DeadlineExceeded { + return fmt.Errorf("opencode import timed out after %s", openCodeCommandTimeout) + } + return fmt.Errorf("opencode import failed: %w (output: %s)", err, string(output)) + } + + return nil +} diff --git a/cmd/entire/cli/agent/opencode/opencode.go b/cmd/entire/cli/agent/opencode/opencode.go index de9f0de72..e1b169eaf 100644 --- a/cmd/entire/cli/agent/opencode/opencode.go +++ b/cmd/entire/cli/agent/opencode/opencode.go @@ -144,14 +144,14 @@ func (a *OpenCodeAgent) WriteSession(session *agent.AgentSession) error { return fmt.Errorf("failed to write session data: %w", err) } - // 2. If we have export data, import the session into OpenCode's SQLite. + // 2. If we have export data, import the session into OpenCode. // This enables `opencode -s ` for both resume and rewind. if len(session.ExportData) == 0 { - return nil // No export data — skip SQLite import (graceful degradation) + return nil // No export data — skip import (graceful degradation) } - if err := a.importSessionIntoSQLite(session.SessionID, session.ExportData); err != nil { - // Non-fatal: SQLite import is best-effort. The JSONL file is written, + if err := a.importSessionIntoOpenCode(session.SessionID, session.ExportData); err != nil { + // Non-fatal: import is best-effort. The JSONL file is written, // and the user can always run `opencode import ` manually. fmt.Fprintf(os.Stderr, "warning: could not import session into OpenCode: %v\n", err) } @@ -159,18 +159,17 @@ func (a *OpenCodeAgent) WriteSession(session *agent.AgentSession) error { return nil } -// importSessionIntoSQLite writes the export JSON to a temp file and runs -// `opencode import` to restore the session into OpenCode's SQLite database. -// For rewind (session already exists), messages are deleted first so the -// reimport replaces them with the checkpoint-state messages. -func (a *OpenCodeAgent) importSessionIntoSQLite(sessionID string, exportData []byte) error { - // If the session already exists in SQLite, delete its messages first. +// importSessionIntoOpenCode writes the export JSON to a temp file and runs +// `opencode import` to restore the session into OpenCode's database. +// For rewind (session already exists), the session is deleted first so the +// reimport replaces it with the checkpoint-state messages. +func (a *OpenCodeAgent) importSessionIntoOpenCode(sessionID string, exportData []byte) error { + // Delete the session first so reimport replaces it cleanly. // opencode import uses ON CONFLICT DO NOTHING, so existing messages // would be skipped without this step (breaking rewind). - if sessionExistsInSQLite(sessionID) { - if err := deleteMessagesFromSQLite(sessionID); err != nil { - return fmt.Errorf("failed to clear existing messages: %w", err) - } + // runOpenCodeSessionDelete treats "not found" as success. + if err := runOpenCodeSessionDelete(sessionID); err != nil { + return fmt.Errorf("failed to delete existing session: %w", err) } // Write export JSON to a temp file for opencode import diff --git a/cmd/entire/cli/agent/opencode/sqlite.go b/cmd/entire/cli/agent/opencode/sqlite.go deleted file mode 100644 index 0388e9dac..000000000 --- a/cmd/entire/cli/agent/opencode/sqlite.go +++ /dev/null @@ -1,110 +0,0 @@ -package opencode - -import ( - "context" - "fmt" - "os" - "os/exec" - "path/filepath" - "time" -) - -// openCodeImportTimeout is the maximum time to wait for `opencode import` to complete. -const openCodeImportTimeout = 30 * time.Second - -// getOpenCodeDBPath returns the path to OpenCode's SQLite database. -// OpenCode always uses ~/.local/share/opencode/opencode.db (XDG default) -// regardless of platform — it does NOT use ~/Library/Application Support on macOS. -// -// XDG_DATA_HOME overrides the default on all platforms. -func getOpenCodeDBPath() (string, error) { - dataDir := os.Getenv("XDG_DATA_HOME") - if dataDir == "" { - home, err := os.UserHomeDir() - if err != nil { - return "", fmt.Errorf("failed to get home directory: %w", err) - } - dataDir = filepath.Join(home, ".local", "share") - } - return filepath.Join(dataDir, "opencode", "opencode.db"), nil -} - -// runSQLiteQuery executes a SQL query against OpenCode's SQLite database. -// Returns the combined stdout/stderr output. -func runSQLiteQuery(query string, timeout time.Duration) ([]byte, error) { - dbPath, err := getOpenCodeDBPath() - if err != nil { - return nil, fmt.Errorf("failed to get OpenCode DB path: %w", err) - } - if _, err := os.Stat(dbPath); os.IsNotExist(err) { - return nil, fmt.Errorf("OpenCode database not found: %w", err) - } - - ctx, cancel := context.WithTimeout(context.Background(), timeout) - defer cancel() - - //nolint:gosec // G204: query is constructed from sanitized inputs (escapeSQLiteString) - cmd := exec.CommandContext(ctx, "sqlite3", dbPath, query) - output, err := cmd.CombinedOutput() - if err != nil { - return output, fmt.Errorf("sqlite3 query failed: %w", err) - } - return output, nil -} - -// sessionExistsInSQLite checks whether a session with the given ID exists -// in OpenCode's SQLite database. -func sessionExistsInSQLite(sessionID string) bool { - query := fmt.Sprintf("SELECT count(*) FROM session WHERE id = '%s';", escapeSQLiteString(sessionID)) - output, err := runSQLiteQuery(query, 5*time.Second) - if err != nil { - return false - } - return len(output) > 0 && output[0] != '0' -} - -// deleteMessagesFromSQLite removes all messages (and cascading parts) for a session. -// This is used before reimporting a session during rewind so that `opencode import` -// can insert the checkpoint-state messages (import uses ON CONFLICT DO NOTHING). -func deleteMessagesFromSQLite(sessionID string) error { - // Enable foreign keys so CASCADE deletes work (parts are deleted with messages). - query := fmt.Sprintf( - "PRAGMA foreign_keys = ON; DELETE FROM message WHERE session_id = '%s';", - escapeSQLiteString(sessionID), - ) - if output, err := runSQLiteQuery(query, 5*time.Second); err != nil { - return fmt.Errorf("failed to delete messages from OpenCode DB: %w (output: %s)", err, string(output)) - } - return nil -} - -// runOpenCodeImport runs `opencode import ` to import a session into -// OpenCode's SQLite database. The import preserves the original session ID -// from the export file. -func runOpenCodeImport(exportFilePath string) error { - ctx, cancel := context.WithTimeout(context.Background(), openCodeImportTimeout) - defer cancel() - - cmd := exec.CommandContext(ctx, "opencode", "import", exportFilePath) - if output, err := cmd.CombinedOutput(); err != nil { - if ctx.Err() == context.DeadlineExceeded { - return fmt.Errorf("opencode import timed out after %s", openCodeImportTimeout) - } - return fmt.Errorf("opencode import failed: %w (output: %s)", err, string(output)) - } - - return nil -} - -// escapeSQLiteString escapes single quotes in a string for safe use in SQLite queries. -func escapeSQLiteString(s string) string { - result := make([]byte, 0, len(s)) - for i := range len(s) { - if s[i] == '\'' { - result = append(result, '\'', '\'') - } else { - result = append(result, s[i]) - } - } - return string(result) -}