From 895468cd5fbdc73e512e19847041be471a83167f Mon Sep 17 00:00:00 2001 From: Victor Gutierrez Calderon Date: Mon, 23 Feb 2026 11:36:57 +1100 Subject: [PATCH 1/3] remove ExportData field and dead file write from OpenCode WriteSession replace sql queries with opencode session delete --- cmd/entire/cli/agent/opencode/cli_commands.go | 23 ++++++ cmd/entire/cli/agent/opencode/opencode.go | 39 ++-------- cmd/entire/cli/agent/opencode/sqlite.go | 78 ------------------- cmd/entire/cli/agent/session.go | 6 -- cmd/entire/cli/resume.go | 4 - cmd/entire/cli/rewind.go | 9 --- .../cli/strategy/manual_commit_rewind.go | 1 - 7 files changed, 30 insertions(+), 130 deletions(-) delete mode 100644 cmd/entire/cli/agent/opencode/sqlite.go diff --git a/cmd/entire/cli/agent/opencode/cli_commands.go b/cmd/entire/cli/agent/opencode/cli_commands.go index 233ad672b..fe54b78e7 100644 --- a/cmd/entire/cli/agent/opencode/cli_commands.go +++ b/cmd/entire/cli/agent/opencode/cli_commands.go @@ -5,6 +5,7 @@ import ( "errors" "fmt" "os/exec" + "strings" "time" ) @@ -34,6 +35,28 @@ func runOpenCodeExport(sessionID string) ([]byte, error) { return output, nil } +// runOpenCodeSessionDelete runs `opencode session delete ` to remove +// a session from OpenCode's database. Returns nil on success or if the session +// doesn't exist (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) + if output, err := cmd.CombinedOutput(); err != nil { + if ctx.Err() == context.DeadlineExceeded { + return fmt.Errorf("opencode session delete timed out after %s", openCodeCommandTimeout) + } + // "Session not found" means the session doesn't exist — nothing to delete. + if strings.Contains(strings.ToLower(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. diff --git a/cmd/entire/cli/agent/opencode/opencode.go b/cmd/entire/cli/agent/opencode/opencode.go index 2798e08bd..35db8b331 100644 --- a/cmd/entire/cli/agent/opencode/opencode.go +++ b/cmd/entire/cli/agent/opencode/opencode.go @@ -200,7 +200,6 @@ func (a *OpenCodeAgent) ReadSession(input *agent.HookInput) (*agent.AgentSession SessionID: input.SessionID, SessionRef: input.SessionRef, NativeData: data, - ExportData: data, // Export JSON is both native and export format ModifiedFiles: modifiedFiles, }, nil } @@ -209,36 +208,13 @@ func (a *OpenCodeAgent) WriteSession(session *agent.AgentSession) error { if session == nil { return errors.New("nil session") } - if session.SessionRef == "" { - return errors.New("no session ref to write to") - } if len(session.NativeData) == 0 { return errors.New("no session data to write") } - // 1. Write export JSON file (for Entire's internal checkpoint use) - dir := filepath.Dir(session.SessionRef) - //nolint:gosec // G301: Session directory needs standard permissions - if err := os.MkdirAll(dir, 0o755); err != nil { - return fmt.Errorf("failed to create session directory: %w", err) - } - if err := os.WriteFile(session.SessionRef, session.NativeData, 0o600); err != nil { - return fmt.Errorf("failed to write session data: %w", err) - } - - // 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 import (graceful degradation) - } - - 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) - } - - return nil + // Import the session into OpenCode's database. + // This enables `opencode -s ` for both resume and rewind. + return a.importSessionIntoOpenCode(session.SessionID, session.NativeData) } // importSessionIntoOpenCode writes the export JSON to a temp file and runs @@ -246,15 +222,14 @@ func (a *OpenCodeAgent) WriteSession(session *agent.AgentSession) error { // 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 existing messages first so reimport replaces them cleanly. + // Delete existing 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). - // Uses direct SQLite delete since OpenCode CLI has no session delete command. - if err := deleteMessagesFromSQLite(sessionID); err != nil { - // Non-fatal: DB might not exist yet (first session), or sqlite3 not installed. + if err := runOpenCodeSessionDelete(sessionID); err != nil { + // Non-fatal: session might not exist yet (first session). // Import will still work for new sessions; only rewind of existing sessions // would have stale messages. - fmt.Fprintf(os.Stderr, "warning: could not clear existing messages: %v\n", err) + fmt.Fprintf(os.Stderr, "warning: could not delete existing session: %v\n", 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 018188ebd..000000000 --- a/cmd/entire/cli/agent/opencode/sqlite.go +++ /dev/null @@ -1,78 +0,0 @@ -package opencode - -import ( - "context" - "fmt" - "os" - "os/exec" - "path/filepath" - "time" -) - -// 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 -} - -// 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 -} - -// 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) -} diff --git a/cmd/entire/cli/agent/session.go b/cmd/entire/cli/agent/session.go index 88ad4b13f..a058210aa 100644 --- a/cmd/entire/cli/agent/session.go +++ b/cmd/entire/cli/agent/session.go @@ -26,12 +26,6 @@ type AgentSession struct { // - Aider: Markdown content NativeData []byte - // ExportData holds the session in the agent's native export/import format. - // Used by agents whose primary storage isn't file-based (e.g., OpenCode uses SQLite). - // At resume/rewind time, this data is imported back into the agent's storage. - // Optional — nil for agents where NativeData is sufficient (Claude, Gemini). - ExportData []byte - // Computed fields - populated by the agent when reading ModifiedFiles []string NewFiles []string diff --git a/cmd/entire/cli/resume.go b/cmd/entire/cli/resume.go index cbc2d2b00..7456fdf8f 100644 --- a/cmd/entire/cli/resume.go +++ b/cmd/entire/cli/resume.go @@ -537,16 +537,12 @@ func resumeSingleSession(ctx context.Context, ag agent.Agent, sessionID string, return fmt.Errorf("failed to create session directory: %w", err) } - // Create an AgentSession with the native data. - // The transcript IS the export data — for agents that need import (OpenCode), - // WriteSession uses ExportData; for others (Claude, Gemini), it's ignored. agentSession := &agent.AgentSession{ SessionID: sessionID, AgentName: ag.Name(), RepoPath: repoRoot, SessionRef: sessionLogPath, NativeData: logContent, - ExportData: logContent, } // Write the session using the agent's WriteSession method diff --git a/cmd/entire/cli/rewind.go b/cmd/entire/cli/rewind.go index 782da0a05..ce89bd9d6 100644 --- a/cmd/entire/cli/rewind.go +++ b/cmd/entire/cli/rewind.go @@ -680,10 +680,6 @@ func restoreSessionTranscriptFromStrategy(cpID id.CheckpointID, sessionID string sessionID = returnedSessionID } - // Use WriteSession which handles both file writing and native storage import - // (e.g., SQLite for OpenCode). The transcript IS the export data — for agents - // that need import (OpenCode), WriteSession uses ExportData; for others - // (Claude, Gemini), WriteSession ignores it and just writes NativeData. sessionFile, err := resolveTranscriptPath(sessionID, agent) if err != nil { return "", err @@ -696,7 +692,6 @@ func restoreSessionTranscriptFromStrategy(cpID id.CheckpointID, sessionID string AgentName: agent.Name(), SessionRef: sessionFile, NativeData: content, - ExportData: content, } if err := agent.WriteSession(agentSession); err != nil { return "", fmt.Errorf("failed to write session: %w", err) @@ -726,9 +721,6 @@ func restoreSessionTranscriptFromShadow(commitHash, metadataDir, sessionID strin return "", fmt.Errorf("failed to get transcript from shadow branch: %w", err) } - // Use WriteSession which handles both file writing and native storage import. - // The transcript IS the export data — for agents that need import (OpenCode), - // WriteSession uses ExportData; for others (Claude, Gemini), it's ignored. sessionFile, err := resolveTranscriptPath(sessionID, agent) if err != nil { return "", err @@ -741,7 +733,6 @@ func restoreSessionTranscriptFromShadow(commitHash, metadataDir, sessionID strin AgentName: agent.Name(), SessionRef: sessionFile, NativeData: content, - ExportData: content, } if err := agent.WriteSession(agentSession); err != nil { return "", fmt.Errorf("failed to write session: %w", err) diff --git a/cmd/entire/cli/strategy/manual_commit_rewind.go b/cmd/entire/cli/strategy/manual_commit_rewind.go index 5d6305fa8..1c78a7396 100644 --- a/cmd/entire/cli/strategy/manual_commit_rewind.go +++ b/cmd/entire/cli/strategy/manual_commit_rewind.go @@ -685,7 +685,6 @@ func (s *ManualCommitStrategy) RestoreLogsOnly(point RewindPoint, force bool) ([ RepoPath: repoRoot, SessionRef: sessionFile, NativeData: content.Transcript, - ExportData: content.Transcript, } if writeErr := sessionAgent.WriteSession(agentSession); writeErr != nil { if totalSessions > 1 { From 86ad3560e7873a92efa9d568d0e21b4a00aaf1b1 Mon Sep 17 00:00:00 2001 From: Victor Gutierrez Calderon Date: Mon, 23 Feb 2026 11:52:07 +1100 Subject: [PATCH 2/3] fix OpenCode WriteSession test to not require opencode CLI Replace file-write test (which tested removed behavior) with validation tests that don't need opencode installed on CI runners. Co-Authored-By: Claude Opus 4.6 Entire-Checkpoint: d91f42f3046e --- cmd/entire/cli/integration_test/agent_test.go | 34 +++---------------- 1 file changed, 5 insertions(+), 29 deletions(-) diff --git a/cmd/entire/cli/integration_test/agent_test.go b/cmd/entire/cli/integration_test/agent_test.go index 75f1d2f55..359c54573 100644 --- a/cmd/entire/cli/integration_test/agent_test.go +++ b/cmd/entire/cli/integration_test/agent_test.go @@ -1036,40 +1036,16 @@ func TestOpenCodeSessionOperations(t *testing.T) { } }) - t.Run("WriteSession writes NativeData to file", func(t *testing.T) { + t.Run("WriteSession validates input", func(t *testing.T) { t.Parallel() - env := NewTestEnv(t) - env.InitRepo() ag, _ := agent.Get("opencode") - // First read a session - srcPath := filepath.Join(env.RepoDir, "src.json") - srcContent := `{"info": {"id": "test"}, "messages": [{"info": {"id": "msg-1", "role": "user", "time": {"created": 1708300000}}, "parts": [{"type": "text", "text": "hello"}]}]}` - if err := os.WriteFile(srcPath, []byte(srcContent), 0o644); err != nil { - t.Fatalf("failed to write source: %v", err) - } - - session, _ := ag.ReadSession(&agent.HookInput{ - SessionID: "test", - SessionRef: srcPath, - }) - - // Write to a new location - dstPath := filepath.Join(env.RepoDir, "dst.json") - session.SessionRef = dstPath - - if err := ag.WriteSession(session); err != nil { - t.Fatalf("WriteSession() error = %v", err) - } - - // Verify file was written - data, err := os.ReadFile(dstPath) - if err != nil { - t.Fatalf("failed to read destination: %v", err) + if err := ag.WriteSession(nil); err == nil { + t.Error("WriteSession(nil) should error") } - if string(data) != srcContent { - t.Errorf("written content = %q, want %q", string(data), srcContent) + if err := ag.WriteSession(&agent.AgentSession{}); err == nil { + t.Error("WriteSession with empty NativeData should error") } }) } From a32d307b9478bf555069f24dbfee6ae65bd88368 Mon Sep 17 00:00:00 2001 From: Victor Gutierrez Calderon Date: Mon, 23 Feb 2026 12:05:15 +1100 Subject: [PATCH 3/3] use logging.Warn instead of fmt.Fprintf(os.Stderr) in opencode agent Follow repo convention of using cli/logging for operational diagnostics so messages go to .entire/logs/ instead of intermixing with user output. Co-Authored-By: Claude Opus 4.6 Entire-Checkpoint: 7a7d20d47c1f --- cmd/entire/cli/agent/opencode/opencode.go | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/cmd/entire/cli/agent/opencode/opencode.go b/cmd/entire/cli/agent/opencode/opencode.go index 35db8b331..e999766ac 100644 --- a/cmd/entire/cli/agent/opencode/opencode.go +++ b/cmd/entire/cli/agent/opencode/opencode.go @@ -2,15 +2,18 @@ package opencode import ( + "context" "encoding/json" "errors" "fmt" + "log/slog" "os" "path/filepath" "regexp" "strings" "github.com/entireio/cli/cmd/entire/cli/agent" + "github.com/entireio/cli/cmd/entire/cli/logging" "github.com/entireio/cli/cmd/entire/cli/paths" ) @@ -229,7 +232,10 @@ func (a *OpenCodeAgent) importSessionIntoOpenCode(sessionID string, exportData [ // Non-fatal: session might not exist yet (first session). // Import will still work for new sessions; only rewind of existing sessions // would have stale messages. - fmt.Fprintf(os.Stderr, "warning: could not delete existing session: %v\n", err) + logging.Warn(context.Background(), "could not delete existing opencode session", + slog.String("session_id", sessionID), + slog.String("error", err.Error()), + ) } // Write export JSON to a temp file for opencode import