Skip to main content

sessionwiki/
account_link.rs

1//! Optional swapdex integration: attribute each session to the ACCOUNT profile
2//! that was active when it started, by reading swapdex's switch timeline
3//! (read-only). No swapdex on the machine -> no events -> no badges, silently.
4//!
5//! The join mirrors swapdex's own `sessions` attribution: a session belongs to
6//! the last activating event (`use`, `restore`, or `serve`) for its tool with ts
7//! <= session start, unless a later `serve-off` cleared that state. A session
8//! that predates every event stays unattributed (None) - a missing badge, never
9//! a guess.
10
11use serde_json::Value;
12use std::path::PathBuf;
13
14pub struct SwitchEvent {
15    pub ts: i64,
16    pub tool: String,
17    pub account: String,
18    /// What swapdex recorded: `use`/`restore` move where new sessions start,
19    /// `serve` hands turns to an account without moving them, while `serve-off`
20    /// explicitly clears that attribution until a later activating event.
21    pub action: String,
22}
23
24/// swapdex's timeline location (same `dirs::data_dir` convention swapdex
25/// uses). `SESSIONWIKI_SWAPDEX_TIMELINE` overrides for tests.
26fn timeline_path() -> Option<PathBuf> {
27    if let Some(p) = std::env::var_os("SESSIONWIKI_SWAPDEX_TIMELINE") {
28        return Some(p.into());
29    }
30    Some(dirs::data_dir()?.join("swapdex").join("timeline.jsonl"))
31}
32
33/// Parse the timeline defensively: keep activating events and the explicit
34/// `serve-off` boundary, and skip malformed lines.
35///
36/// `serve` counts. It was dropped here as "not a switch", which was true of the
37/// event and false of the question: on a machine where switching goes through
38/// swapdex's proxy, `serve` is the ONLY record of which account was live, so
39/// every claude-code session on this one was badged with nothing while 190
40/// serves named three accounts, and codex sessions carried a `use` from months
41/// before the account actually changed.
42///
43/// The actions are listed rather than "anything swapdex writes": this reads
44/// another program's file, and an action it adds later need not mean an account
45/// went live.
46pub fn load_events() -> Vec<SwitchEvent> {
47    let Some(path) = timeline_path() else {
48        return Vec::new();
49    };
50    // The bound was claimed and not taken: the whole file was read into memory
51    // and only the PARSE was capped at 4000 lines. swapdex trims the timeline to
52    // ~1000 events, but its size is another program's business, and a hand-edited
53    // or runaway file would be read whole on every query. 1 MB is about eight
54    // thousand of these lines, so the cap is far above what the producer writes
55    // and far below what would hurt.
56    let Ok(text) = crate::util::read_tail(&path, 1024 * 1024) else {
57        return Vec::new();
58    };
59    parse_events(&text)
60}
61
62/// The parsing half, separated from the read so which lines survive it can be
63/// tested without a file or an environment variable.
64fn parse_events(text: &str) -> Vec<SwitchEvent> {
65    let mut out = Vec::new();
66    for line in text.lines() {
67        let Ok(v) = serde_json::from_str::<Value>(line) else {
68            continue;
69        };
70        // A line written before swapdex recorded actions carries no field, and
71        // those were all switches. A present value of another JSON type is
72        // malformed, not legacy.
73        let action = match v.get("action") {
74            None => "use",
75            Some(Value::String(action)) => action,
76            Some(_) => continue,
77        };
78        if !matches!(action, "use" | "restore" | "serve" | "serve-off") {
79            continue;
80        }
81        if let (Some(ts), Some(tool), Some(account)) =
82            (v["ts"].as_i64(), v["tool"].as_str(), v["account"].as_str())
83        {
84            let account: String = account.chars().filter(|c| !c.is_control()).collect();
85            // Active-account events need a real badge. `serve-off` deliberately
86            // carries an empty account in swapdex's cross-repo protocol; keep
87            // it as a boundary rather than dropping or treating it as a badge.
88            if action != "serve-off" && account.is_empty() {
89                continue;
90            }
91            out.push(SwitchEvent {
92                ts,
93                tool: tool.to_string(),
94                action: action.to_string(),
95                // Strip control chars at the source: every consumer (CLI
96                // badge, web, JSON) then gets a terminal-safe name.
97                account,
98            });
99        }
100    }
101    out
102}
103
104/// The profile active when a session of `tool` started: the newest event for
105/// that tool at or before it, whatever kind. Equal timestamps use append order,
106/// matching the timeline's event-log semantics. `serve-off` returns None and
107/// does not fall through to an older account. None also covers no prior event or
108/// no swapdex timeline, so a missing badge is still never a guess.
109pub fn account_for(
110    events: &[SwitchEvent],
111    tool: &str,
112    started_rfc3339: Option<&str>,
113) -> Option<String> {
114    let started = chrono::DateTime::parse_from_rfc3339(started_rfc3339?)
115        .ok()?
116        .timestamp();
117    events
118        .iter()
119        .enumerate()
120        .filter(|(_, e)| e.tool == tool && e.ts <= started)
121        .max_by_key(|(order, e)| (e.ts, *order))
122        .and_then(|(_, e)| match e.action.as_str() {
123            "use" | "restore" | "serve" => Some(e.account.clone()),
124            "serve-off" => None,
125            _ => None,
126        })
127}
128
129/// Fill `account` on freshly queried rows. One timeline read per query call;
130/// zero work when no swapdex timeline exists.
131pub fn annotate<'a, I: IntoIterator<Item = &'a mut crate::index::SessionRow>>(rows: I) {
132    let events = load_events();
133    if events.is_empty() {
134        return;
135    }
136    for r in rows {
137        r.account = account_for(&events, &r.tool, r.started.as_deref());
138    }
139}
140
141#[cfg(test)]
142mod tests {
143    use super::*;
144
145    fn ev(ts: i64, tool: &str, account: &str) -> SwitchEvent {
146        SwitchEvent {
147            ts,
148            tool: tool.into(),
149            account: account.into(),
150            action: "use".into(),
151        }
152    }
153
154    fn at(ts: i64) -> String {
155        chrono::DateTime::from_timestamp(ts, 0)
156            .unwrap()
157            .to_rfc3339()
158    }
159
160    fn serve(ts: i64, tool: &str, account: &str) -> SwitchEvent {
161        SwitchEvent {
162            action: "serve".into(),
163            ..ev(ts, tool, account)
164        }
165    }
166
167    #[test]
168    fn the_parser_keeps_serve_lines() {
169        // Real lines from the timeline this was found on.
170        let text = concat!(
171            r#"{"ts":1787186784,"tool":"claude-code","account":"rnd","action":"serve"}"#,
172            "\n",
173            r#"{"ts":1784504808,"tool":"codex","account":"codex","action":"use"}"#,
174            "\n",
175            r#"{"ts":1788488191,"tool":"codex","account":"work","action":"serve","by":"swapdex"}"#,
176            "\n",
177            r#"{"ts":1,"tool":"codex","account":"legacy"}"#,
178            "\n",
179            r#"{"ts":3,"tool":"codex","account":"","action":"serve-off"}"#,
180            "\n",
181            r#"not json"#,
182            "\n",
183            r#"{"ts":2,"tool":"codex","account":"nope","action":"something-new"}"#,
184            "\n",
185            r#"{"ts":4,"tool":"codex","account":"typed-null","action":null}"#,
186        );
187        let events = parse_events(text);
188        let kept: Vec<(&str, &str)> = events
189            .iter()
190            .map(|e| (e.action.as_str(), e.account.as_str()))
191            .collect();
192        assert_eq!(
193            kept,
194            vec![
195                ("serve", "rnd"),
196                ("use", "codex"),
197                ("serve", "work"),
198                ("use", "legacy"),
199                ("serve-off", ""),
200            ],
201            "serve boundaries and legacy uses are kept; unknown actions are not"
202        );
203    }
204
205    #[test]
206    fn serve_off_clears_attribution_until_a_later_activation() {
207        let off = SwitchEvent {
208            ts: 200,
209            tool: "codex".into(),
210            account: String::new(),
211            action: "serve-off".into(),
212        };
213        let events = vec![serve(100, "codex", "payer"), off];
214        assert_eq!(
215            account_for(&events, "codex", Some(&at(250))),
216            None,
217            "serve-off is a state boundary, not an empty account badge"
218        );
219
220        for action in ["use", "restore", "serve"] {
221            let mut resumed = events
222                .iter()
223                .map(|e| SwitchEvent {
224                    ts: e.ts,
225                    tool: e.tool.clone(),
226                    account: e.account.clone(),
227                    action: e.action.clone(),
228                })
229                .collect::<Vec<_>>();
230            resumed.push(SwitchEvent {
231                ts: 300,
232                tool: "codex".into(),
233                account: "home".into(),
234                action: action.into(),
235            });
236            assert_eq!(
237                account_for(&resumed, "codex", Some(&at(350))).as_deref(),
238                Some("home"),
239                "a later {action} restores attribution"
240            );
241        }
242    }
243
244    #[test]
245    fn equal_timestamps_use_append_order() {
246        let events = parse_events(concat!(
247            r#"{"ts":100,"tool":"codex","account":"payer","action":"serve"}"#,
248            "\n",
249            r#"{"ts":100,"tool":"codex","account":"","action":"serve-off"}"#,
250            "\n",
251            r#"{"ts":100,"tool":"codex","account":"home","action":"use"}"#,
252        ));
253        assert_eq!(
254            account_for(&events[..2], "codex", Some(&at(100))),
255            None,
256            "the later appended serve-off wins a timestamp tie"
257        );
258        assert_eq!(
259            account_for(&events, "codex", Some(&at(100))).as_deref(),
260            Some("home"),
261            "a still-later use at the same timestamp wins"
262        );
263    }
264
265    #[test]
266    fn a_serve_is_evidence_of_who_was_active() {
267        // The machine this was found on: claude-code has never been `use`d -
268        // switching goes through swapdex's proxy, which writes `serve` - and
269        // codex's last `use` is months older than its last serve.
270        let only_serves = vec![serve(200, "claude-code", "kong")];
271        assert_eq!(
272            account_for(&only_serves, "claude-code", Some(&at(300))).as_deref(),
273            Some("kong"),
274            "190 serves naming three accounts is not 'no information'"
275        );
276
277        let stale_use = vec![ev(100, "codex", "codex"), serve(200, "codex", "work")];
278        assert_eq!(
279            account_for(&stale_use, "codex", Some(&at(300))).as_deref(),
280            Some("work"),
281            "the newest evidence wins, not the oldest kind of event"
282        );
283        // Before any event for that tool, still no badge rather than a guess.
284        assert_eq!(account_for(&stale_use, "codex", Some(&at(50))), None);
285    }
286
287    #[test]
288    fn attributes_to_the_last_switch_before_start() {
289        let events = vec![
290            ev(100, "codex", "work"),
291            ev(200, "codex", "personal"),
292            ev(150, "claude-code", "work"),
293        ];
294        // codex session started at t=250 -> personal (the t=200 switch).
295        let started = chrono::DateTime::from_timestamp(250, 0)
296            .unwrap()
297            .to_rfc3339();
298        assert_eq!(
299            account_for(&events, "codex", Some(&started)).as_deref(),
300            Some("personal")
301        );
302        // claude session at t=250 -> work (its own tool's events only).
303        assert_eq!(
304            account_for(&events, "claude-code", Some(&started)).as_deref(),
305            Some("work")
306        );
307        // A session that predates every switch stays unattributed.
308        let early = chrono::DateTime::from_timestamp(50, 0)
309            .unwrap()
310            .to_rfc3339();
311        assert_eq!(account_for(&events, "codex", Some(&early)), None);
312        // Unknown start time -> None, never a guess.
313        assert_eq!(account_for(&events, "codex", None), None);
314    }
315}