use crate::collect::model_from_json;
use crate::core::event::{Event, EventKind, EventSource, SessionRecord, SessionStatus};
use anyhow::{Context, Result};
use rusqlite::Connection;
use serde_json::Value;
use std::path::{Path, PathBuf};
const AGENT: &str = "goose";
fn goose_session_db_paths(home: &Path) -> Vec<PathBuf> {
let mut v = vec![
home.join("Library/Application Support/Block/goose/data/sessions/sessions.db"),
home.join(".local/share/goose/sessions/sessions.db"),
];
if let Ok(root) = std::env::var("GOOSE_PATH_ROOT") {
let b = PathBuf::from(root);
v.insert(0, b.join("data/sessions/sessions.db"));
}
v
}
fn canonical(p: &Path) -> PathBuf {
std::fs::canonicalize(p).unwrap_or_else(|_| p.to_path_buf())
}
fn paths_equal(a: &Path, b: &Path) -> bool {
canonical(a) == canonical(b)
}
fn is_goose_schema(conn: &Connection) -> Result<bool> {
let n: i32 = conn.query_row(
"SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name IN ('sessions','messages')",
[],
|row| row.get(0),
)?;
Ok(n >= 2)
}
fn goose_model_from_row(
model_config_json: Option<String>,
provider: Option<String>,
) -> Option<String> {
if let Some(ref s) = model_config_json
&& let Ok(v) = serde_json::from_str::<Value>(s)
&& let Some(m) = v
.get("model")
.or_else(|| v.get("id"))
.and_then(|x| x.as_str())
{
return Some(m.to_string());
}
provider
}
fn events_from_goose_content(
session_id: &str,
seq: u64,
ts_ms: u64,
tokens: Option<i64>,
content_json: &str,
) -> Result<Vec<Event>> {
let v: Value = serde_json::from_str(content_json.trim()).context("goose content_json")?;
let blocks: Vec<Value> = if let Some(arr) = v.as_array() {
arr.clone()
} else if let Some(arr) = v.get("content").and_then(|c| c.as_array()) {
arr.clone()
} else {
return Ok(vec![]);
};
let mut out = Vec::new();
let mut s = seq;
let tk = tokens.map(|t| t.max(0) as u32);
for block in blocks {
let Some(typ) = block.get("type").and_then(|t| t.as_str()) else {
continue;
};
match typ {
"toolRequest" | "frontendToolRequest" => {
let id = block
.get("id")
.and_then(|x| x.as_str())
.unwrap_or("")
.to_string();
let tool_name = block
.get("toolCall")
.and_then(|tc| tc.get("Ok"))
.and_then(|ok| ok.get("name"))
.and_then(|n| n.as_str())
.or_else(|| {
block
.get("toolCall")
.and_then(|tc| tc.get("name"))
.and_then(|n| n.as_str())
})
.unwrap_or("")
.to_string();
out.push(Event {
session_id: session_id.to_string(),
seq: s,
ts_ms,
ts_exact: true,
kind: EventKind::ToolCall,
source: EventSource::Tail,
tool: Some(tool_name),
tool_call_id: Some(id),
tokens_in: None,
tokens_out: tk,
reasoning_tokens: None,
cost_usd_e6: None,
payload: block,
});
s += 1;
}
"toolResponse" => {
let id = block
.get("id")
.and_then(|x| x.as_str())
.unwrap_or("")
.to_string();
out.push(Event {
session_id: session_id.to_string(),
seq: s,
ts_ms,
ts_exact: true,
kind: EventKind::ToolResult,
source: EventSource::Tail,
tool: None,
tool_call_id: Some(id),
tokens_in: None,
tokens_out: tk,
reasoning_tokens: None,
cost_usd_e6: None,
payload: block,
});
s += 1;
}
_ => {}
}
}
Ok(out)
}
fn sessions_select_sql(conn: &Connection) -> &'static str {
let Ok(mut stmt) = conn.prepare("PRAGMA table_info(sessions)") else {
return "SELECT id, working_dir FROM sessions";
};
let Ok(rows) = stmt.query_map([], |row| row.get::<_, String>(1)) else {
return "SELECT id, working_dir FROM sessions";
};
let mut cols = Vec::new();
for c in rows.flatten() {
cols.push(c);
}
let has = |n: &str| cols.iter().any(|c| c == n);
if has("model_config_json")
&& has("provider_name")
&& has("input_tokens")
&& has("output_tokens")
{
"SELECT id, working_dir, model_config_json, provider_name, input_tokens, output_tokens FROM sessions"
} else {
"SELECT id, working_dir FROM sessions"
}
}
pub fn scan_goose_sqlite(
db_path: &Path,
workspace: &Path,
) -> Result<Vec<(SessionRecord, Vec<Event>)>> {
let ws_canon = canonical(workspace);
let conn = Connection::open(db_path).with_context(|| format!("open {}", db_path.display()))?;
if !is_goose_schema(&conn)? {
return Ok(vec![]);
}
let sql = sessions_select_sql(&conn);
let mut stmt = conn
.prepare(sql)
.with_context(|| format!("goose sessions query ({sql})"))?;
let mut sessions_out = Vec::new();
let mut rows = stmt.query([])?;
while let Some(row) = rows.next()? {
let id: String = row.get(0)?;
let working_dir: String = row.get(1)?;
let (model_json, provider, in_tok, out_tok) = if sql.contains("model_config_json") {
(
row.get::<_, Option<String>>(2).ok().flatten(),
row.get::<_, Option<String>>(3).ok().flatten(),
row.get::<_, Option<i64>>(4).ok().flatten(),
row.get::<_, Option<i64>>(5).ok().flatten(),
)
} else {
(None, None, None, None)
};
let wd = PathBuf::from(&working_dir);
if !paths_equal(&wd, &ws_canon) {
continue;
}
let model = goose_model_from_row(model_json, provider);
let mut msg_stmt = conn.prepare(
"SELECT content_json, created_timestamp, tokens FROM messages WHERE session_id = ?1 ORDER BY id",
)?;
let msg_rows = msg_stmt.query_map([&id], |row| {
Ok((
row.get::<_, String>(0)?,
row.get::<_, i64>(1)?,
row.get::<_, Option<i64>>(2)?,
))
})?;
let mut events = Vec::new();
let mut seq: u64 = 0;
for mr in msg_rows {
let (content_json, created_ts, tokens) = mr?;
let ts_ms = (created_ts.max(0) as u64).saturating_mul(1000);
let chunk = events_from_goose_content(&id, seq, ts_ms, tokens, &content_json)?;
let n = chunk.len() as u64;
events.extend(chunk);
seq += n.max(1);
}
let started_at_ms = events.first().map(|e| e.ts_ms).unwrap_or_else(|| {
std::fs::metadata(db_path)
.ok()
.and_then(|m| m.modified().ok())
.and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
.map(|d| d.as_millis() as u64)
.unwrap_or(0)
});
let record = SessionRecord {
id: id.clone(),
agent: AGENT.to_string(),
model,
workspace: workspace.to_string_lossy().to_string(),
started_at_ms,
ended_at_ms: None,
status: SessionStatus::Done,
trace_path: db_path.to_string_lossy().to_string(),
start_commit: None,
end_commit: None,
branch: None,
dirty_start: None,
dirty_end: None,
repo_binding_source: None,
};
if let (Some(ti), Some(to)) = (in_tok, out_tok)
&& (ti > 0 || to > 0)
&& let Some(last) = events.last_mut()
{
last.tokens_in = Some(ti.max(0) as u32);
last.tokens_out = Some(to.max(0) as u32);
}
sessions_out.push((record, events));
}
Ok(sessions_out)
}
pub fn scan_goose_legacy_jsonl_dir(
sessions_dir: &Path,
workspace: &Path,
) -> Result<Vec<(SessionRecord, Vec<Event>)>> {
let ws_canon = canonical(workspace);
let mut out = Vec::new();
if !sessions_dir.is_dir() {
return Ok(out);
}
for entry in std::fs::read_dir(sessions_dir)? {
let entry = entry?;
let path = entry.path();
if path.extension().map(|e| e != "jsonl").unwrap_or(true) {
continue;
}
let text = match std::fs::read_to_string(&path) {
Ok(t) => t,
Err(_) => continue,
};
let session_id = path
.file_stem()
.and_then(|s| s.to_str())
.unwrap_or("goose-legacy")
.to_string();
let mut events = Vec::new();
let mut seq: u64 = 0;
let mut model: Option<String> = None;
let mut matches_ws = false;
for line in text.lines() {
let line = line.trim();
if line.is_empty() {
continue;
}
let Ok(v) = serde_json::from_str::<Value>(line) else {
continue;
};
if let Some(wd) = v
.get("working_dir")
.or_else(|| v.get("workingDir"))
.and_then(|x| x.as_str())
&& paths_equal(Path::new(wd), &ws_canon)
{
matches_ws = true;
}
if model.is_none() {
model = model_from_json::from_value(&v);
}
if let Some(created) = v.get("created").and_then(|c| c.as_i64()) {
let ts_ms = (created.max(0) as u64).saturating_mul(1000);
if let Some(content) = v.get("content") {
let s = content.to_string();
if let Ok(chunk) = events_from_goose_content(&session_id, seq, ts_ms, None, &s)
{
let n = chunk.len() as u64;
events.extend(chunk);
seq += n.max(1);
}
}
}
}
if !matches_ws && !events.is_empty() {
continue;
}
if events.is_empty() {
continue;
}
out.push((
SessionRecord {
id: session_id,
agent: AGENT.to_string(),
model,
workspace: workspace.to_string_lossy().to_string(),
started_at_ms: events.first().map(|e| e.ts_ms).unwrap_or(0),
ended_at_ms: None,
status: SessionStatus::Done,
trace_path: path.to_string_lossy().to_string(),
start_commit: None,
end_commit: None,
branch: None,
dirty_start: None,
dirty_end: None,
repo_binding_source: None,
},
events,
));
}
Ok(out)
}
pub fn scan_goose_workspace(
home: &Path,
workspace: &Path,
) -> Result<Vec<(SessionRecord, Vec<Event>)>> {
let mut all = Vec::new();
for db in goose_session_db_paths(home) {
if db.is_file() {
all.extend(scan_goose_sqlite(&db, workspace)?);
}
}
for db in goose_session_db_paths(home) {
let parent = db.parent().unwrap_or_else(|| Path::new("."));
if parent.is_dir() {
all.extend(scan_goose_legacy_jsonl_dir(parent, workspace)?);
}
}
Ok(all)
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
#[test]
fn goose_sqlite_roundtrip_fixture() {
let dir = TempDir::new().unwrap();
let ws = dir.path().join("proj");
std::fs::create_dir_all(&ws).unwrap();
let ws_canon = std::fs::canonicalize(&ws).unwrap();
let db_path = dir.path().join("sessions.db");
let conn = Connection::open(&db_path).unwrap();
conn.execute_batch(
r"
CREATE TABLE schema_version (version INTEGER PRIMARY KEY);
INSERT INTO schema_version (version) VALUES (7);
CREATE TABLE sessions (
id TEXT PRIMARY KEY,
name TEXT NOT NULL DEFAULT '',
description TEXT NOT NULL DEFAULT '',
working_dir TEXT NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
model_config_json TEXT,
provider_name TEXT,
input_tokens INTEGER,
output_tokens INTEGER,
total_tokens INTEGER
);
CREATE TABLE messages (
id INTEGER PRIMARY KEY AUTOINCREMENT,
message_id TEXT,
session_id TEXT NOT NULL,
role TEXT NOT NULL,
content_json TEXT NOT NULL,
created_timestamp INTEGER NOT NULL,
tokens INTEGER,
metadata_json TEXT
);
",
)
.unwrap();
conn.execute(
"INSERT INTO sessions (id, working_dir, model_config_json, provider_name) VALUES (?1, ?2, ?3, ?4)",
rusqlite::params![
"sess-goose-1",
ws_canon.to_string_lossy().as_ref(),
r#"{"model":"gpt-4o-fixture"}"#,
"openai"
],
)
.unwrap();
let tool_req = r#"[{"type":"toolRequest","id":"t1","toolCall":{"Ok":{"name":"shell","arguments":{}}}}]"#;
conn.execute(
"INSERT INTO messages (message_id, session_id, role, content_json, created_timestamp) VALUES ('m1', 'sess-goose-1', 'assistant', ?1, 1700000000)",
[tool_req],
)
.unwrap();
let sessions = scan_goose_sqlite(&db_path, &ws_canon).unwrap();
assert_eq!(sessions.len(), 1);
let (rec, evs) = &sessions[0];
assert_eq!(rec.agent, "goose");
assert_eq!(rec.model.as_deref(), Some("gpt-4o-fixture"));
assert!(!evs.is_empty());
assert_eq!(evs[0].kind, EventKind::ToolCall);
assert_eq!(evs[0].tool.as_deref(), Some("shell"));
}
#[test]
fn goose_skips_other_workspace() {
let dir = TempDir::new().unwrap();
let ws_a = dir.path().join("a");
let ws_b = dir.path().join("b");
std::fs::create_dir_all(&ws_a).unwrap();
std::fs::create_dir_all(&ws_b).unwrap();
let canon_b = std::fs::canonicalize(&ws_b).unwrap();
let db_path = dir.path().join("sessions.db");
let conn = Connection::open(&db_path).unwrap();
conn.execute_batch(
r"
CREATE TABLE sessions (id TEXT PRIMARY KEY, working_dir TEXT NOT NULL);
CREATE TABLE messages (id INTEGER PRIMARY KEY AUTOINCREMENT, session_id TEXT, role TEXT, content_json TEXT NOT NULL, created_timestamp INTEGER NOT NULL);
",
)
.unwrap();
conn.execute(
"INSERT INTO sessions (id, working_dir) VALUES ('x', ?1)",
[canon_b.to_string_lossy().as_ref()],
)
.unwrap();
let sessions = scan_goose_sqlite(&db_path, &ws_a).unwrap();
assert!(sessions.is_empty());
}
}