sede 0.2.2__tar.gz → 0.2.3__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {sede-0.2.2/src/sede.egg-info → sede-0.2.3}/PKG-INFO +3 -3
- {sede-0.2.2 → sede-0.2.3}/README.md +2 -2
- {sede-0.2.2 → sede-0.2.3}/pyproject.toml +1 -1
- {sede-0.2.2 → sede-0.2.3}/src/sede/discovery.py +197 -71
- {sede-0.2.2 → sede-0.2.3/src/sede.egg-info}/PKG-INFO +3 -3
- {sede-0.2.2 → sede-0.2.3}/tests/test_discovery_unit.py +352 -12
- {sede-0.2.2 → sede-0.2.3}/LICENSE +0 -0
- {sede-0.2.2 → sede-0.2.3}/setup.cfg +0 -0
- {sede-0.2.2 → sede-0.2.3}/src/sede/__init__.py +0 -0
- {sede-0.2.2 → sede-0.2.3}/src/sede/__main__.py +0 -0
- {sede-0.2.2 → sede-0.2.3}/src/sede/cli.py +0 -0
- {sede-0.2.2 → sede-0.2.3}/src/sede/models.py +0 -0
- {sede-0.2.2 → sede-0.2.3}/src/sede.egg-info/SOURCES.txt +0 -0
- {sede-0.2.2 → sede-0.2.3}/src/sede.egg-info/dependency_links.txt +0 -0
- {sede-0.2.2 → sede-0.2.3}/src/sede.egg-info/entry_points.txt +0 -0
- {sede-0.2.2 → sede-0.2.3}/src/sede.egg-info/requires.txt +0 -0
- {sede-0.2.2 → sede-0.2.3}/src/sede.egg-info/top_level.txt +0 -0
- {sede-0.2.2 → sede-0.2.3}/tests/test_cli.py +0 -0
- {sede-0.2.2 → sede-0.2.3}/tests/test_cli_integration.py +0 -0
- {sede-0.2.2 → sede-0.2.3}/tests/test_discovery_integration.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: sede
|
|
3
|
-
Version: 0.2.
|
|
3
|
+
Version: 0.2.3
|
|
4
4
|
Summary: Session deleter for coding assistants
|
|
5
5
|
Author-email: Ilya Popov <ilypopv@gmail.com>
|
|
6
6
|
License: MIT
|
|
@@ -146,7 +146,7 @@ When no sessions are found for a provider, the same screen layout is shown inste
|
|
|
146
146
|
- Claude: deletes selected `.jsonl` session file, then removes parent project dir only if empty.
|
|
147
147
|
- Copilot: deletes the selected session directory recursively.
|
|
148
148
|
- Antigravity: deletes the selected conversation directory recursively.
|
|
149
|
-
- OpenCode: deletes selected session rows (`part`/`message`/`session` with `PRAGMA foreign_keys=ON`)
|
|
149
|
+
- OpenCode: deletes selected session rows from the shared `opencode.db` (`part`/`message`/`session` for v1, `session_v2` plus related rows for v2, with `PRAGMA foreign_keys=ON`); v2 child sessions are removed too, while the DB file and other sessions are untouched.
|
|
150
150
|
- Always review selected entries before confirming.
|
|
151
151
|
|
|
152
152
|
## Session Sources
|
|
@@ -154,4 +154,4 @@ When no sessions are found for a provider, the same screen layout is shown inste
|
|
|
154
154
|
- Claude: `~/.claude/projects/*/*.jsonl`
|
|
155
155
|
- Copilot: `~/.copilot/session-state/<session-id>/`
|
|
156
156
|
- Antigravity: `~/.gemini/antigravity-cli/brain/<session-id>/` and `~/.gemini/antigravity/brain/<session-id>/`
|
|
157
|
-
- OpenCode: `~/.local/share/opencode/opencode.db` (fallback `~/Library/Application Support/opencode/opencode.db`, `XDG_DATA_HOME` honoured; same XDG path for brew/non-brew) — only chat sessions are surfaced, caches (`~/.cache/opencode`, `~/.local/state/opencode`, snapshot/log) are excluded
|
|
157
|
+
- OpenCode: `~/.local/share/opencode/opencode.db` (fallback `~/Library/Application Support/opencode/opencode.db`, `XDG_DATA_HOME` honoured; same XDG path for brew/non-brew) — reads both v1 (`session`/`message`/`part`) and v2 (`session_v2`/`session_message`/`session_part`) tables; only chat sessions are surfaced, caches (`~/.cache/opencode`, `~/.local/state/opencode`, snapshot/log) are excluded
|
|
@@ -125,7 +125,7 @@ When no sessions are found for a provider, the same screen layout is shown inste
|
|
|
125
125
|
- Claude: deletes selected `.jsonl` session file, then removes parent project dir only if empty.
|
|
126
126
|
- Copilot: deletes the selected session directory recursively.
|
|
127
127
|
- Antigravity: deletes the selected conversation directory recursively.
|
|
128
|
-
- OpenCode: deletes selected session rows (`part`/`message`/`session` with `PRAGMA foreign_keys=ON`)
|
|
128
|
+
- OpenCode: deletes selected session rows from the shared `opencode.db` (`part`/`message`/`session` for v1, `session_v2` plus related rows for v2, with `PRAGMA foreign_keys=ON`); v2 child sessions are removed too, while the DB file and other sessions are untouched.
|
|
129
129
|
- Always review selected entries before confirming.
|
|
130
130
|
|
|
131
131
|
## Session Sources
|
|
@@ -133,4 +133,4 @@ When no sessions are found for a provider, the same screen layout is shown inste
|
|
|
133
133
|
- Claude: `~/.claude/projects/*/*.jsonl`
|
|
134
134
|
- Copilot: `~/.copilot/session-state/<session-id>/`
|
|
135
135
|
- Antigravity: `~/.gemini/antigravity-cli/brain/<session-id>/` and `~/.gemini/antigravity/brain/<session-id>/`
|
|
136
|
-
- OpenCode: `~/.local/share/opencode/opencode.db` (fallback `~/Library/Application Support/opencode/opencode.db`, `XDG_DATA_HOME` honoured; same XDG path for brew/non-brew) — only chat sessions are surfaced, caches (`~/.cache/opencode`, `~/.local/state/opencode`, snapshot/log) are excluded
|
|
136
|
+
- OpenCode: `~/.local/share/opencode/opencode.db` (fallback `~/Library/Application Support/opencode/opencode.db`, `XDG_DATA_HOME` honoured; same XDG path for brew/non-brew) — reads both v1 (`session`/`message`/`part`) and v2 (`session_v2`/`session_message`/`session_part`) tables; only chat sessions are surfaced, caches (`~/.cache/opencode`, `~/.local/state/opencode`, snapshot/log) are excluded
|
|
@@ -359,6 +359,71 @@ def _discover_antigravity_sessions() -> list[SessionRecord]:
|
|
|
359
359
|
return sorted(sessions, key=lambda s: s.updated_at, reverse=True)
|
|
360
360
|
|
|
361
361
|
|
|
362
|
+
_OPENCODE_SESSION_SCHEMAS = (
|
|
363
|
+
("session_v2", "session_message", "session_part"),
|
|
364
|
+
("session", "message", "part"),
|
|
365
|
+
)
|
|
366
|
+
|
|
367
|
+
_OPENCODE_V2_SESSION_TABLES = (
|
|
368
|
+
"blob_ref",
|
|
369
|
+
"instruction_entry",
|
|
370
|
+
"instruction_state",
|
|
371
|
+
"schedule",
|
|
372
|
+
"session_inbox",
|
|
373
|
+
"session_part",
|
|
374
|
+
"session_message",
|
|
375
|
+
)
|
|
376
|
+
|
|
377
|
+
|
|
378
|
+
def _opencode_table_names(cursor: sqlite3.Cursor) -> set[str]:
|
|
379
|
+
"""Returns the table names present in an OpenCode SQLite database.
|
|
380
|
+
|
|
381
|
+
Args:
|
|
382
|
+
cursor: Cursor connected to the OpenCode database.
|
|
383
|
+
|
|
384
|
+
Returns:
|
|
385
|
+
The set of table names available in the database.
|
|
386
|
+
"""
|
|
387
|
+
return {
|
|
388
|
+
row[0]
|
|
389
|
+
for row in cursor.execute("SELECT name FROM sqlite_master WHERE type='table'")
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
|
|
393
|
+
def _opencode_payload_size(
|
|
394
|
+
cursor: sqlite3.Cursor,
|
|
395
|
+
session_id: str,
|
|
396
|
+
message_table: str,
|
|
397
|
+
part_table: str,
|
|
398
|
+
fallback_size: int,
|
|
399
|
+
) -> int:
|
|
400
|
+
"""Estimates an OpenCode session size from stored payload columns.
|
|
401
|
+
|
|
402
|
+
Args:
|
|
403
|
+
cursor: Cursor connected to the OpenCode database.
|
|
404
|
+
session_id: Session identifier to measure.
|
|
405
|
+
message_table: Table holding message payloads for this schema.
|
|
406
|
+
part_table: Table holding message part payloads for this schema.
|
|
407
|
+
fallback_size: Size to use when no payload rows are found.
|
|
408
|
+
|
|
409
|
+
Returns:
|
|
410
|
+
The combined payload size in bytes, or ``fallback_size`` when both
|
|
411
|
+
payload tables contain no data for the session.
|
|
412
|
+
"""
|
|
413
|
+
size_bytes = 0
|
|
414
|
+
try:
|
|
415
|
+
for table in (message_table, part_table):
|
|
416
|
+
cursor.execute(
|
|
417
|
+
f"SELECT COALESCE(SUM(LENGTH(data)), 0) FROM {table} "
|
|
418
|
+
"WHERE session_id = ?",
|
|
419
|
+
(session_id,),
|
|
420
|
+
)
|
|
421
|
+
size_bytes += int(cursor.fetchone()[0] or 0)
|
|
422
|
+
except sqlite3.Error:
|
|
423
|
+
return 0
|
|
424
|
+
return size_bytes or fallback_size
|
|
425
|
+
|
|
426
|
+
|
|
362
427
|
def _opencode_db_paths() -> list[Path]:
|
|
363
428
|
"""Returns candidate paths for the OpenCode SQLite database.
|
|
364
429
|
|
|
@@ -416,10 +481,12 @@ def _opencode_cache_paths() -> list[tuple[str, Path, str]]:
|
|
|
416
481
|
def _discover_opencode_sessions() -> list[SessionRecord]:
|
|
417
482
|
"""Discovers OpenCode sessions from the SQLite database.
|
|
418
483
|
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
484
|
+
Reads both the legacy v1 ``session``/``message``/``part`` tables and the
|
|
485
|
+
v2 ``session_v2``/``session_message``/``session_part`` tables, so a single
|
|
486
|
+
database can surface sessions stored by either version. Sessions are read
|
|
487
|
+
from ``~/.local/share/opencode/opencode.db`` (and fallbacks). Only chat
|
|
488
|
+
sessions are surfaced — local caches such as ``~/.cache/opencode`` or
|
|
489
|
+
``~/.local/share/opencode/snapshot`` are intentionally excluded.
|
|
423
490
|
|
|
424
491
|
Returns:
|
|
425
492
|
Discovered OpenCode sessions sorted by last update time, newest
|
|
@@ -435,73 +502,66 @@ def _discover_opencode_sessions() -> list[SessionRecord]:
|
|
|
435
502
|
uri = f"file:{db_path}?mode=ro"
|
|
436
503
|
conn = sqlite3.connect(uri, uri=True)
|
|
437
504
|
cursor = conn.cursor()
|
|
438
|
-
|
|
439
|
-
cursor.execute("SELECT name FROM sqlite_master WHERE type='table' AND name='session'")
|
|
440
|
-
if cursor.fetchone() is None:
|
|
441
|
-
conn.close()
|
|
442
|
-
continue
|
|
505
|
+
tables = _opencode_table_names(cursor)
|
|
443
506
|
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
"SELECT s.id, s.project_id, s.directory, s.title, "
|
|
447
|
-
"s.time_created, s.time_updated, p.worktree "
|
|
448
|
-
"FROM session s LEFT JOIN project p ON s.project_id = p.id"
|
|
449
|
-
)
|
|
450
|
-
rows = cursor.fetchall()
|
|
451
|
-
|
|
452
|
-
# Pre-compute sizes per session when possible (message + part payloads).
|
|
453
|
-
for row in rows:
|
|
454
|
-
sid, pid, directory, title, t_created, t_updated, worktree = row
|
|
455
|
-
if sid in seen_ids:
|
|
507
|
+
for session_table, message_table, part_table in _OPENCODE_SESSION_SCHEMAS:
|
|
508
|
+
if session_table not in tables:
|
|
456
509
|
continue
|
|
457
|
-
seen_ids.add(sid)
|
|
458
|
-
project_path = directory or worktree or "Unknown project"
|
|
459
|
-
title_val = title or sid
|
|
460
|
-
# time columns are milliseconds since epoch
|
|
461
|
-
try:
|
|
462
|
-
ms = int(t_updated if t_updated is not None else t_created)
|
|
463
|
-
updated_at = datetime.fromtimestamp(ms / 1000, tz=timezone.utc)
|
|
464
|
-
except Exception:
|
|
465
|
-
try:
|
|
466
|
-
stat = db_path.stat()
|
|
467
|
-
updated_at = datetime.fromtimestamp(stat.st_mtime, tz=timezone.utc)
|
|
468
|
-
except OSError:
|
|
469
|
-
updated_at = datetime.now(timezone.utc)
|
|
470
510
|
|
|
471
|
-
#
|
|
472
|
-
|
|
511
|
+
# Left join project to resolve worktree when directory is empty.
|
|
512
|
+
cursor.execute(
|
|
513
|
+
"SELECT s.id, s.directory, s.title, "
|
|
514
|
+
"s.time_created, s.time_updated, p.worktree "
|
|
515
|
+
f"FROM {session_table} s "
|
|
516
|
+
"LEFT JOIN project p ON s.project_id = p.id"
|
|
517
|
+
)
|
|
518
|
+
rows = cursor.fetchall()
|
|
519
|
+
# Fallback to DB file size if a session has no payload rows.
|
|
473
520
|
try:
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
)
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
#
|
|
486
|
-
|
|
521
|
+
fallback_size = db_path.stat().st_size // max(1, len(rows))
|
|
522
|
+
except OSError:
|
|
523
|
+
fallback_size = 0
|
|
524
|
+
|
|
525
|
+
for row in rows:
|
|
526
|
+
sid, directory, title, t_created, t_updated, worktree = row
|
|
527
|
+
if sid in seen_ids:
|
|
528
|
+
continue
|
|
529
|
+
seen_ids.add(sid)
|
|
530
|
+
project_path = directory or worktree or "Unknown project"
|
|
531
|
+
title_val = title or sid
|
|
532
|
+
# time columns are milliseconds since epoch
|
|
533
|
+
try:
|
|
534
|
+
ms = int(t_updated if t_updated is not None else t_created)
|
|
535
|
+
updated_at = datetime.fromtimestamp(ms / 1000, tz=timezone.utc)
|
|
536
|
+
except Exception:
|
|
487
537
|
try:
|
|
488
|
-
|
|
538
|
+
stat = db_path.stat()
|
|
539
|
+
updated_at = datetime.fromtimestamp(
|
|
540
|
+
stat.st_mtime, tz=timezone.utc
|
|
541
|
+
)
|
|
489
542
|
except OSError:
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
543
|
+
updated_at = datetime.now(timezone.utc)
|
|
544
|
+
|
|
545
|
+
# Estimate size via sum of message/part data lengths.
|
|
546
|
+
size_bytes = _opencode_payload_size(
|
|
547
|
+
cursor,
|
|
548
|
+
sid,
|
|
549
|
+
message_table,
|
|
550
|
+
part_table,
|
|
551
|
+
fallback_size,
|
|
552
|
+
)
|
|
553
|
+
|
|
554
|
+
sessions.append(
|
|
555
|
+
SessionRecord(
|
|
556
|
+
provider="opencode",
|
|
557
|
+
session_id=sid,
|
|
558
|
+
title=_shorten(title_val, 70),
|
|
559
|
+
project_path=project_path,
|
|
560
|
+
size_bytes=size_bytes,
|
|
561
|
+
updated_at=updated_at,
|
|
562
|
+
storage_path=db_path,
|
|
563
|
+
)
|
|
503
564
|
)
|
|
504
|
-
)
|
|
505
565
|
conn.close()
|
|
506
566
|
except sqlite3.Error:
|
|
507
567
|
try:
|
|
@@ -513,6 +573,64 @@ def _discover_opencode_sessions() -> list[SessionRecord]:
|
|
|
513
573
|
return sorted(sessions, key=lambda s: s.updated_at, reverse=True)
|
|
514
574
|
|
|
515
575
|
|
|
576
|
+
def _delete_opencode_v2_session(
|
|
577
|
+
cursor: sqlite3.Cursor, session_id: str, tables: set[str]
|
|
578
|
+
) -> None:
|
|
579
|
+
"""Deletes an OpenCode v2 session, its child sessions, and related rows.
|
|
580
|
+
|
|
581
|
+
Mirrors OpenCode's own session deletion: child sessions are removed
|
|
582
|
+
recursively, durable events and jobs for the session are dropped, and
|
|
583
|
+
session-scoped rows are deleted explicitly before the ``session_v2`` row
|
|
584
|
+
itself. Optional tables are skipped when absent so databases from older
|
|
585
|
+
or newer schema versions keep working.
|
|
586
|
+
|
|
587
|
+
Args:
|
|
588
|
+
cursor: Cursor connected to the OpenCode database.
|
|
589
|
+
session_id: Identifier of the session to delete.
|
|
590
|
+
tables: Table names present in the database.
|
|
591
|
+
"""
|
|
592
|
+
if "session_v2" not in tables:
|
|
593
|
+
return
|
|
594
|
+
|
|
595
|
+
pending = [session_id]
|
|
596
|
+
seen: set[str] = set()
|
|
597
|
+
|
|
598
|
+
while pending:
|
|
599
|
+
current = pending.pop()
|
|
600
|
+
if current in seen:
|
|
601
|
+
continue
|
|
602
|
+
seen.add(current)
|
|
603
|
+
|
|
604
|
+
cursor.execute("SELECT id FROM session_v2 WHERE parent_id = ?", (current,))
|
|
605
|
+
pending.extend(row[0] for row in cursor.fetchall())
|
|
606
|
+
|
|
607
|
+
if "job" in tables:
|
|
608
|
+
cursor.execute(
|
|
609
|
+
"DELETE FROM job WHERE session_id = ? OR child_session_id = ?",
|
|
610
|
+
(current, current),
|
|
611
|
+
)
|
|
612
|
+
if "event" in tables:
|
|
613
|
+
cursor.execute("DELETE FROM event WHERE aggregate_id = ?", (current,))
|
|
614
|
+
if "event_sequence" in tables:
|
|
615
|
+
cursor.execute(
|
|
616
|
+
"DELETE FROM event_sequence WHERE aggregate_id = ?", (current,)
|
|
617
|
+
)
|
|
618
|
+
if "blob" in tables and "blob_ref" in tables:
|
|
619
|
+
cursor.execute(
|
|
620
|
+
"DELETE FROM blob WHERE hash IN ("
|
|
621
|
+
"SELECT hash FROM blob_ref WHERE session_id = ?"
|
|
622
|
+
") AND NOT EXISTS ("
|
|
623
|
+
"SELECT 1 FROM blob_ref AS ref WHERE ref.hash = blob.hash "
|
|
624
|
+
"AND ref.session_id != ?"
|
|
625
|
+
")",
|
|
626
|
+
(current, current),
|
|
627
|
+
)
|
|
628
|
+
for table in _OPENCODE_V2_SESSION_TABLES:
|
|
629
|
+
if table in tables:
|
|
630
|
+
cursor.execute(f"DELETE FROM {table} WHERE session_id = ?", (current,))
|
|
631
|
+
cursor.execute("DELETE FROM session_v2 WHERE id = ?", (current,))
|
|
632
|
+
|
|
633
|
+
|
|
516
634
|
def _delete_opencode_session(session_path: Path, session_id: str) -> None:
|
|
517
635
|
"""Deletes an OpenCode session.
|
|
518
636
|
|
|
@@ -520,10 +638,11 @@ def _delete_opencode_session(session_path: Path, session_id: str) -> None:
|
|
|
520
638
|
session_path: Path to the OpenCode database (``opencode.db``). For
|
|
521
639
|
legacy cache entries this may be a cache directory.
|
|
522
640
|
session_id: Session identifier. Cache entries prefixed with
|
|
523
|
-
``cache:`` are removed via filesystem deletion
|
|
524
|
-
are
|
|
525
|
-
``
|
|
526
|
-
|
|
641
|
+
``cache:`` are removed via filesystem deletion. Legacy v1
|
|
642
|
+
sessions are removed with explicit ``DELETE`` calls on
|
|
643
|
+
``part``/``message``/``session``; v2 sessions use the
|
|
644
|
+
``session_v2`` schema and also delete child sessions and their
|
|
645
|
+
related rows. A shared ``opencode.db`` file is never removed and
|
|
527
646
|
other sessions remain untouched.
|
|
528
647
|
"""
|
|
529
648
|
if session_id.startswith("cache:"):
|
|
@@ -538,9 +657,16 @@ def _delete_opencode_session(session_path: Path, session_id: str) -> None:
|
|
|
538
657
|
try:
|
|
539
658
|
conn.execute("PRAGMA foreign_keys = ON")
|
|
540
659
|
cursor = conn.cursor()
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
660
|
+
tables = _opencode_table_names(cursor)
|
|
661
|
+
|
|
662
|
+
if "session" in tables:
|
|
663
|
+
cursor.execute("DELETE FROM part WHERE session_id = ?", (session_id,))
|
|
664
|
+
cursor.execute("DELETE FROM message WHERE session_id = ?", (session_id,))
|
|
665
|
+
cursor.execute("DELETE FROM session WHERE id = ?", (session_id,))
|
|
666
|
+
|
|
667
|
+
if "session_v2" in tables:
|
|
668
|
+
_delete_opencode_v2_session(cursor, session_id, tables)
|
|
669
|
+
|
|
544
670
|
conn.commit()
|
|
545
671
|
finally:
|
|
546
672
|
conn.close()
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: sede
|
|
3
|
-
Version: 0.2.
|
|
3
|
+
Version: 0.2.3
|
|
4
4
|
Summary: Session deleter for coding assistants
|
|
5
5
|
Author-email: Ilya Popov <ilypopv@gmail.com>
|
|
6
6
|
License: MIT
|
|
@@ -146,7 +146,7 @@ When no sessions are found for a provider, the same screen layout is shown inste
|
|
|
146
146
|
- Claude: deletes selected `.jsonl` session file, then removes parent project dir only if empty.
|
|
147
147
|
- Copilot: deletes the selected session directory recursively.
|
|
148
148
|
- Antigravity: deletes the selected conversation directory recursively.
|
|
149
|
-
- OpenCode: deletes selected session rows (`part`/`message`/`session` with `PRAGMA foreign_keys=ON`)
|
|
149
|
+
- OpenCode: deletes selected session rows from the shared `opencode.db` (`part`/`message`/`session` for v1, `session_v2` plus related rows for v2, with `PRAGMA foreign_keys=ON`); v2 child sessions are removed too, while the DB file and other sessions are untouched.
|
|
150
150
|
- Always review selected entries before confirming.
|
|
151
151
|
|
|
152
152
|
## Session Sources
|
|
@@ -154,4 +154,4 @@ When no sessions are found for a provider, the same screen layout is shown inste
|
|
|
154
154
|
- Claude: `~/.claude/projects/*/*.jsonl`
|
|
155
155
|
- Copilot: `~/.copilot/session-state/<session-id>/`
|
|
156
156
|
- Antigravity: `~/.gemini/antigravity-cli/brain/<session-id>/` and `~/.gemini/antigravity/brain/<session-id>/`
|
|
157
|
-
- OpenCode: `~/.local/share/opencode/opencode.db` (fallback `~/Library/Application Support/opencode/opencode.db`, `XDG_DATA_HOME` honoured; same XDG path for brew/non-brew) — only chat sessions are surfaced, caches (`~/.cache/opencode`, `~/.local/state/opencode`, snapshot/log) are excluded
|
|
157
|
+
- OpenCode: `~/.local/share/opencode/opencode.db` (fallback `~/Library/Application Support/opencode/opencode.db`, `XDG_DATA_HOME` honoured; same XDG path for brew/non-brew) — reads both v1 (`session`/`message`/`part`) and v2 (`session_v2`/`session_message`/`session_part`) tables; only chat sessions are surfaced, caches (`~/.cache/opencode`, `~/.local/state/opencode`, snapshot/log) are excluded
|
|
@@ -204,8 +204,12 @@ def test_delete_antigravity_session_cleans_db_and_summaries(tmp_path: Path) -> N
|
|
|
204
204
|
|
|
205
205
|
summaries_db = app_dir / "conversation_summaries.db"
|
|
206
206
|
conn = sqlite3.connect(str(summaries_db))
|
|
207
|
-
conn.execute(
|
|
208
|
-
|
|
207
|
+
conn.execute(
|
|
208
|
+
"CREATE TABLE conversation_summaries (conversation_id TEXT PRIMARY KEY, title TEXT)"
|
|
209
|
+
)
|
|
210
|
+
conn.execute(
|
|
211
|
+
"INSERT INTO conversation_summaries VALUES ('session-123', 'My Session')"
|
|
212
|
+
)
|
|
209
213
|
conn.commit()
|
|
210
214
|
conn.close()
|
|
211
215
|
|
|
@@ -217,7 +221,9 @@ def test_delete_antigravity_session_cleans_db_and_summaries(tmp_path: Path) -> N
|
|
|
217
221
|
|
|
218
222
|
conn = sqlite3.connect(str(summaries_db))
|
|
219
223
|
cursor = conn.cursor()
|
|
220
|
-
cursor.execute(
|
|
224
|
+
cursor.execute(
|
|
225
|
+
"SELECT * FROM conversation_summaries WHERE conversation_id = 'session-123'"
|
|
226
|
+
)
|
|
221
227
|
assert cursor.fetchone() is None
|
|
222
228
|
conn.close()
|
|
223
229
|
|
|
@@ -329,12 +335,16 @@ def test_read_antigravity_metadata_extracts_tool_calls_cwd(tmp_path: Path) -> No
|
|
|
329
335
|
assert metadata.get("prompt") == "Build feature"
|
|
330
336
|
|
|
331
337
|
|
|
332
|
-
def test_discover_antigravity_sessions_handles_empty_root(
|
|
338
|
+
def test_discover_antigravity_sessions_handles_empty_root(
|
|
339
|
+
tmp_path: Path, monkeypatch
|
|
340
|
+
) -> None:
|
|
333
341
|
monkeypatch.setattr(discovery.Path, "home", staticmethod(lambda: tmp_path))
|
|
334
342
|
assert discovery._discover_antigravity_sessions() == []
|
|
335
343
|
|
|
336
344
|
|
|
337
|
-
def test_find_profile_targets_matches_direct_marker(
|
|
345
|
+
def test_find_profile_targets_matches_direct_marker(
|
|
346
|
+
tmp_path: Path, monkeypatch
|
|
347
|
+
) -> None:
|
|
338
348
|
monkeypatch.setattr(discovery.Path, "home", staticmethod(lambda: tmp_path))
|
|
339
349
|
target = tmp_path / ".copilot" / "session-state"
|
|
340
350
|
target.mkdir(parents=True)
|
|
@@ -344,7 +354,9 @@ def test_find_profile_targets_matches_direct_marker(tmp_path: Path, monkeypatch)
|
|
|
344
354
|
assert found == [target]
|
|
345
355
|
|
|
346
356
|
|
|
347
|
-
def test_find_profile_targets_finds_profile_subdirs(
|
|
357
|
+
def test_find_profile_targets_finds_profile_subdirs(
|
|
358
|
+
tmp_path: Path, monkeypatch
|
|
359
|
+
) -> None:
|
|
348
360
|
monkeypatch.setattr(discovery.Path, "home", staticmethod(lambda: tmp_path))
|
|
349
361
|
|
|
350
362
|
direct = tmp_path / ".copilot-profiles" / "az" / "session-state"
|
|
@@ -502,14 +514,18 @@ def test_delete_session_rejects_unknown_provider(tmp_path: Path) -> None:
|
|
|
502
514
|
discovery.delete_session(record)
|
|
503
515
|
|
|
504
516
|
|
|
505
|
-
def _create_opencode_db(
|
|
517
|
+
def _create_opencode_db(
|
|
518
|
+
db_path: Path, sessions: list[tuple[str, str, str, int]]
|
|
519
|
+
) -> None:
|
|
506
520
|
db_path.parent.mkdir(parents=True, exist_ok=True)
|
|
507
521
|
conn = __import__("sqlite3").connect(str(db_path))
|
|
508
522
|
conn.execute("CREATE TABLE project (id TEXT PRIMARY KEY, worktree TEXT)")
|
|
509
523
|
conn.execute(
|
|
510
524
|
"CREATE TABLE session (id TEXT PRIMARY KEY, project_id TEXT, directory TEXT, title TEXT, time_created INTEGER, time_updated INTEGER)"
|
|
511
525
|
)
|
|
512
|
-
conn.execute(
|
|
526
|
+
conn.execute(
|
|
527
|
+
"CREATE TABLE message (id TEXT PRIMARY KEY, session_id TEXT, data TEXT)"
|
|
528
|
+
)
|
|
513
529
|
conn.execute(
|
|
514
530
|
"CREATE TABLE part (id TEXT PRIMARY KEY, message_id TEXT, session_id TEXT, data TEXT)"
|
|
515
531
|
)
|
|
@@ -519,7 +535,9 @@ def _create_opencode_db(db_path: Path, sessions: list[tuple[str, str, str, int]]
|
|
|
519
535
|
"INSERT INTO session VALUES (?, ?, ?, ?, ?, ?)",
|
|
520
536
|
(sid, proj, f"/tmp/{proj}", title, ms - 1000, ms),
|
|
521
537
|
)
|
|
522
|
-
conn.execute(
|
|
538
|
+
conn.execute(
|
|
539
|
+
"INSERT INTO message VALUES (?, ?, ?)", (f"msg-{sid}", sid, "msgdata")
|
|
540
|
+
)
|
|
523
541
|
conn.execute(
|
|
524
542
|
"INSERT INTO part VALUES (?, ?, ?, ?)",
|
|
525
543
|
(f"prt-{sid}", f"msg-{sid}", sid, "partdata"),
|
|
@@ -528,6 +546,44 @@ def _create_opencode_db(db_path: Path, sessions: list[tuple[str, str, str, int]]
|
|
|
528
546
|
conn.close()
|
|
529
547
|
|
|
530
548
|
|
|
549
|
+
def _create_opencode_v2_db(
|
|
550
|
+
db_path: Path,
|
|
551
|
+
sessions: list[tuple[str, str, str, int]],
|
|
552
|
+
parents: dict[str, str] | None = None,
|
|
553
|
+
) -> None:
|
|
554
|
+
db_path.parent.mkdir(parents=True, exist_ok=True)
|
|
555
|
+
conn = __import__("sqlite3").connect(str(db_path))
|
|
556
|
+
conn.execute("CREATE TABLE project (id TEXT PRIMARY KEY, worktree TEXT)")
|
|
557
|
+
conn.execute(
|
|
558
|
+
"CREATE TABLE session_v2 (id TEXT PRIMARY KEY, project_id TEXT, parent_id TEXT, directory TEXT, title TEXT, time_created INTEGER, time_updated INTEGER)"
|
|
559
|
+
)
|
|
560
|
+
conn.execute(
|
|
561
|
+
"CREATE TABLE session_message (id TEXT PRIMARY KEY, session_id TEXT, data TEXT)"
|
|
562
|
+
)
|
|
563
|
+
conn.execute(
|
|
564
|
+
"CREATE TABLE session_part (message_id TEXT, session_id TEXT, ordinal INTEGER, data TEXT)"
|
|
565
|
+
)
|
|
566
|
+
parent_ids = parents or {}
|
|
567
|
+
for sid, proj, title, ms in sessions:
|
|
568
|
+
conn.execute(
|
|
569
|
+
"INSERT OR IGNORE INTO project VALUES (?, ?)", (proj, f"/tmp/{proj}")
|
|
570
|
+
)
|
|
571
|
+
conn.execute(
|
|
572
|
+
"INSERT INTO session_v2 VALUES (?, ?, ?, ?, ?, ?, ?)",
|
|
573
|
+
(sid, proj, parent_ids.get(sid), f"/tmp/{proj}", title, ms - 1000, ms),
|
|
574
|
+
)
|
|
575
|
+
conn.execute(
|
|
576
|
+
"INSERT INTO session_message VALUES (?, ?, ?)",
|
|
577
|
+
(f"msg-{sid}", sid, "msgdata"),
|
|
578
|
+
)
|
|
579
|
+
conn.execute(
|
|
580
|
+
"INSERT INTO session_part VALUES (?, ?, ?, ?)",
|
|
581
|
+
(f"msg-{sid}", sid, 0, "partdata"),
|
|
582
|
+
)
|
|
583
|
+
conn.commit()
|
|
584
|
+
conn.close()
|
|
585
|
+
|
|
586
|
+
|
|
531
587
|
def test_opencode_db_paths_respects_home(monkeypatch, tmp_path: Path) -> None:
|
|
532
588
|
monkeypatch.setattr(discovery.Path, "home", staticmethod(lambda: tmp_path))
|
|
533
589
|
paths = discovery._opencode_db_paths()
|
|
@@ -630,12 +686,16 @@ def test_delete_opencode_cache_removes_file(tmp_path: Path) -> None:
|
|
|
630
686
|
assert not cache_file.exists()
|
|
631
687
|
|
|
632
688
|
|
|
633
|
-
def test_discover_opencode_sessions_handles_missing_db(
|
|
689
|
+
def test_discover_opencode_sessions_handles_missing_db(
|
|
690
|
+
monkeypatch, tmp_path: Path
|
|
691
|
+
) -> None:
|
|
634
692
|
monkeypatch.setattr(discovery.Path, "home", staticmethod(lambda: tmp_path))
|
|
635
693
|
assert discovery._discover_opencode_sessions() == []
|
|
636
694
|
|
|
637
695
|
|
|
638
|
-
def test_discover_opencode_sessions_handles_corrupt_db(
|
|
696
|
+
def test_discover_opencode_sessions_handles_corrupt_db(
|
|
697
|
+
monkeypatch, tmp_path: Path
|
|
698
|
+
) -> None:
|
|
639
699
|
monkeypatch.setattr(discovery.Path, "home", staticmethod(lambda: tmp_path))
|
|
640
700
|
db_path = tmp_path / ".local" / "share" / "opencode" / "opencode.db"
|
|
641
701
|
db_path.parent.mkdir(parents=True)
|
|
@@ -677,7 +737,9 @@ def test_discover_opencode_sessions_handles_empty_title_and_missing_project(
|
|
|
677
737
|
conn.execute(
|
|
678
738
|
"CREATE TABLE session (id TEXT PRIMARY KEY, project_id TEXT, directory TEXT, title TEXT, time_created INTEGER, time_updated INTEGER)"
|
|
679
739
|
)
|
|
680
|
-
conn.execute(
|
|
740
|
+
conn.execute(
|
|
741
|
+
"CREATE TABLE message (id TEXT PRIMARY KEY, session_id TEXT, data TEXT)"
|
|
742
|
+
)
|
|
681
743
|
conn.execute(
|
|
682
744
|
"CREATE TABLE part (id TEXT PRIMARY KEY, message_id TEXT, session_id TEXT, data TEXT)"
|
|
683
745
|
)
|
|
@@ -735,3 +797,281 @@ def test_delete_opencode_session_missing_db_raises(tmp_path: Path) -> None:
|
|
|
735
797
|
)
|
|
736
798
|
with pytest.raises(FileNotFoundError):
|
|
737
799
|
discovery.delete_session(record)
|
|
800
|
+
|
|
801
|
+
|
|
802
|
+
def test_discover_opencode_sessions_reads_v1_and_v2_tables(
|
|
803
|
+
monkeypatch, tmp_path: Path
|
|
804
|
+
) -> None:
|
|
805
|
+
import sqlite3
|
|
806
|
+
|
|
807
|
+
monkeypatch.setattr(discovery.Path, "home", staticmethod(lambda: tmp_path))
|
|
808
|
+
db_path = tmp_path / ".local" / "share" / "opencode" / "opencode.db"
|
|
809
|
+
_create_opencode_v2_db(
|
|
810
|
+
db_path,
|
|
811
|
+
[("ses_v2", "proj_v2", "V2 Session", 1788600000000)],
|
|
812
|
+
)
|
|
813
|
+
|
|
814
|
+
conn = sqlite3.connect(str(db_path))
|
|
815
|
+
conn.execute(
|
|
816
|
+
"CREATE TABLE session (id TEXT PRIMARY KEY, project_id TEXT, directory TEXT, title TEXT, time_created INTEGER, time_updated INTEGER)"
|
|
817
|
+
)
|
|
818
|
+
conn.execute(
|
|
819
|
+
"CREATE TABLE message (id TEXT PRIMARY KEY, session_id TEXT, data TEXT)"
|
|
820
|
+
)
|
|
821
|
+
conn.execute(
|
|
822
|
+
"CREATE TABLE part (id TEXT PRIMARY KEY, message_id TEXT, session_id TEXT, data TEXT)"
|
|
823
|
+
)
|
|
824
|
+
conn.execute("INSERT INTO project VALUES ('proj_v1', '/tmp/proj_v1')")
|
|
825
|
+
conn.execute(
|
|
826
|
+
"INSERT INTO session VALUES ('ses_v1', 'proj_v1', '/tmp/proj_v1', 'V1 Session', ?, ?)",
|
|
827
|
+
(1788590000000, 1788590000000),
|
|
828
|
+
)
|
|
829
|
+
conn.execute("INSERT INTO message VALUES ('msg_v1', 'ses_v1', 'v1data')")
|
|
830
|
+
conn.execute("INSERT INTO part VALUES ('prt_v1', 'msg_v1', 'ses_v1', 'v1part')")
|
|
831
|
+
conn.commit()
|
|
832
|
+
conn.close()
|
|
833
|
+
|
|
834
|
+
sessions = discovery._discover_opencode_sessions()
|
|
835
|
+
|
|
836
|
+
assert [s.session_id for s in sessions] == ["ses_v2", "ses_v1"]
|
|
837
|
+
assert all(s.provider == "opencode" for s in sessions)
|
|
838
|
+
assert sessions[0].title == "V2 Session"
|
|
839
|
+
assert sessions[0].project_path == "/tmp/proj_v2"
|
|
840
|
+
assert sessions[0].size_bytes > 0
|
|
841
|
+
assert sessions[1].title == "V1 Session"
|
|
842
|
+
|
|
843
|
+
|
|
844
|
+
def test_discover_opencode_sessions_v2_uses_fallbacks(
|
|
845
|
+
monkeypatch, tmp_path: Path
|
|
846
|
+
) -> None:
|
|
847
|
+
import sqlite3
|
|
848
|
+
|
|
849
|
+
monkeypatch.setattr(discovery.Path, "home", staticmethod(lambda: tmp_path))
|
|
850
|
+
db_path = tmp_path / ".local" / "share" / "opencode" / "opencode.db"
|
|
851
|
+
_create_opencode_v2_db(
|
|
852
|
+
db_path,
|
|
853
|
+
[("ses_v2", "proj_v2", "V2 Session", 1788600000000)],
|
|
854
|
+
)
|
|
855
|
+
|
|
856
|
+
conn = sqlite3.connect(str(db_path))
|
|
857
|
+
conn.execute(
|
|
858
|
+
"INSERT INTO session_v2 VALUES ('ses_empty', 'missing', NULL, '', '', ?, ?)",
|
|
859
|
+
(1788590000000, 1788590000000),
|
|
860
|
+
)
|
|
861
|
+
conn.commit()
|
|
862
|
+
conn.close()
|
|
863
|
+
|
|
864
|
+
sessions = discovery._discover_opencode_sessions()
|
|
865
|
+
empty = next(s for s in sessions if s.session_id == "ses_empty")
|
|
866
|
+
|
|
867
|
+
assert empty.title == "ses_empty"
|
|
868
|
+
assert empty.project_path == "Unknown project"
|
|
869
|
+
assert empty.size_bytes > 0
|
|
870
|
+
|
|
871
|
+
|
|
872
|
+
def test_discover_opencode_sessions_v2_handles_missing_payload_tables(
|
|
873
|
+
monkeypatch, tmp_path: Path
|
|
874
|
+
) -> None:
|
|
875
|
+
import sqlite3
|
|
876
|
+
|
|
877
|
+
monkeypatch.setattr(discovery.Path, "home", staticmethod(lambda: tmp_path))
|
|
878
|
+
db_path = tmp_path / ".local" / "share" / "opencode" / "opencode.db"
|
|
879
|
+
db_path.parent.mkdir(parents=True)
|
|
880
|
+
conn = sqlite3.connect(str(db_path))
|
|
881
|
+
conn.execute("CREATE TABLE project (id TEXT PRIMARY KEY, worktree TEXT)")
|
|
882
|
+
conn.execute(
|
|
883
|
+
"CREATE TABLE session_v2 (id TEXT PRIMARY KEY, project_id TEXT, parent_id TEXT, directory TEXT, title TEXT, time_created INTEGER, time_updated INTEGER)"
|
|
884
|
+
)
|
|
885
|
+
conn.execute(
|
|
886
|
+
"INSERT INTO session_v2 VALUES ('ses_v2', 'proj_v2', NULL, '/tmp/proj_v2', 'V2 Session', 1788590000000, 1788590000000)"
|
|
887
|
+
)
|
|
888
|
+
conn.commit()
|
|
889
|
+
conn.close()
|
|
890
|
+
|
|
891
|
+
sessions = discovery._discover_opencode_sessions()
|
|
892
|
+
|
|
893
|
+
assert len(sessions) == 1
|
|
894
|
+
assert sessions[0].session_id == "ses_v2"
|
|
895
|
+
assert sessions[0].size_bytes == 0
|
|
896
|
+
|
|
897
|
+
|
|
898
|
+
def test_delete_opencode_v2_session_removes_children_and_related_rows(
|
|
899
|
+
tmp_path: Path,
|
|
900
|
+
) -> None:
|
|
901
|
+
import sqlite3
|
|
902
|
+
|
|
903
|
+
db_path = tmp_path / "opencode.db"
|
|
904
|
+
_create_opencode_v2_db(
|
|
905
|
+
db_path,
|
|
906
|
+
[
|
|
907
|
+
("ses_parent", "proj", "Parent", 1788590000000),
|
|
908
|
+
("ses_child", "proj", "Child", 1788590001000),
|
|
909
|
+
("ses_other", "proj", "Other", 1788590002000),
|
|
910
|
+
],
|
|
911
|
+
parents={"ses_child": "ses_parent"},
|
|
912
|
+
)
|
|
913
|
+
|
|
914
|
+
conn = sqlite3.connect(str(db_path))
|
|
915
|
+
conn.execute("CREATE TABLE instruction_state (session_id TEXT PRIMARY KEY)")
|
|
916
|
+
conn.execute("CREATE TABLE instruction_entry (session_id TEXT, key TEXT)")
|
|
917
|
+
conn.execute("CREATE TABLE blob_ref (session_id TEXT, message_id TEXT, hash TEXT)")
|
|
918
|
+
conn.execute("CREATE TABLE schedule (id TEXT PRIMARY KEY, session_id TEXT)")
|
|
919
|
+
conn.execute("CREATE TABLE session_inbox (id TEXT PRIMARY KEY, session_id TEXT)")
|
|
920
|
+
conn.execute(
|
|
921
|
+
"CREATE TABLE job (notification_id TEXT PRIMARY KEY, session_id TEXT, child_session_id TEXT)"
|
|
922
|
+
)
|
|
923
|
+
conn.execute(
|
|
924
|
+
"CREATE TABLE event_sequence (aggregate_id TEXT PRIMARY KEY, seq INTEGER)"
|
|
925
|
+
)
|
|
926
|
+
conn.execute("CREATE TABLE event (id TEXT PRIMARY KEY, aggregate_id TEXT)")
|
|
927
|
+
for sid in ("ses_parent", "ses_child", "ses_other"):
|
|
928
|
+
conn.execute("INSERT INTO instruction_state VALUES (?)", (sid,))
|
|
929
|
+
conn.execute("INSERT INTO instruction_entry VALUES (?, 'key')", (sid,))
|
|
930
|
+
conn.execute(
|
|
931
|
+
"INSERT INTO blob_ref VALUES (?, ?, ?)", (sid, f"msg-{sid}", "hash")
|
|
932
|
+
)
|
|
933
|
+
conn.execute("INSERT INTO schedule VALUES (?, ?)", (f"sch-{sid}", sid))
|
|
934
|
+
conn.execute("INSERT INTO session_inbox VALUES (?, ?)", (f"inbox-{sid}", sid))
|
|
935
|
+
conn.execute("INSERT INTO event_sequence VALUES (?, 1)", (sid,))
|
|
936
|
+
conn.execute("INSERT INTO event VALUES (?, ?)", (f"evt-{sid}", sid))
|
|
937
|
+
conn.execute("INSERT INTO job VALUES ('n1', 'ses_parent', NULL)")
|
|
938
|
+
conn.execute("INSERT INTO job VALUES ('n2', 'ses_other', 'ses_child')")
|
|
939
|
+
conn.execute("INSERT INTO job VALUES ('n3', 'ses_other', NULL)")
|
|
940
|
+
conn.commit()
|
|
941
|
+
conn.close()
|
|
942
|
+
|
|
943
|
+
record = SessionRecord(
|
|
944
|
+
provider="opencode",
|
|
945
|
+
session_id="ses_parent",
|
|
946
|
+
title="Parent",
|
|
947
|
+
project_path="/tmp/proj",
|
|
948
|
+
size_bytes=10,
|
|
949
|
+
updated_at=datetime.now(timezone.utc),
|
|
950
|
+
storage_path=db_path,
|
|
951
|
+
)
|
|
952
|
+
discovery.delete_session(record)
|
|
953
|
+
|
|
954
|
+
conn = sqlite3.connect(str(db_path))
|
|
955
|
+
cur = conn.cursor()
|
|
956
|
+
|
|
957
|
+
checks = {
|
|
958
|
+
"session_v2": "id",
|
|
959
|
+
"session_message": "session_id",
|
|
960
|
+
"session_part": "session_id",
|
|
961
|
+
"instruction_state": "session_id",
|
|
962
|
+
"instruction_entry": "session_id",
|
|
963
|
+
"blob_ref": "session_id",
|
|
964
|
+
"schedule": "session_id",
|
|
965
|
+
"session_inbox": "session_id",
|
|
966
|
+
"event_sequence": "aggregate_id",
|
|
967
|
+
"event": "aggregate_id",
|
|
968
|
+
}
|
|
969
|
+
for table, column in checks.items():
|
|
970
|
+
assert (
|
|
971
|
+
cur.execute(
|
|
972
|
+
f"SELECT COUNT(*) FROM {table} WHERE {column} IN (?, ?)",
|
|
973
|
+
("ses_parent", "ses_child"),
|
|
974
|
+
).fetchone()[0]
|
|
975
|
+
== 0
|
|
976
|
+
)
|
|
977
|
+
|
|
978
|
+
assert cur.execute("SELECT COUNT(*) FROM session_v2").fetchone()[0] == 1
|
|
979
|
+
assert cur.execute("SELECT id FROM session_v2").fetchone()[0] == "ses_other"
|
|
980
|
+
assert cur.execute("SELECT COUNT(*) FROM session_message").fetchone()[0] == 1
|
|
981
|
+
assert cur.execute("SELECT COUNT(*) FROM session_part").fetchone()[0] == 1
|
|
982
|
+
assert cur.execute("SELECT COUNT(*) FROM instruction_state").fetchone()[0] == 1
|
|
983
|
+
assert cur.execute("SELECT COUNT(*) FROM blob_ref").fetchone()[0] == 1
|
|
984
|
+
assert cur.execute("SELECT COUNT(*) FROM schedule").fetchone()[0] == 1
|
|
985
|
+
assert cur.execute("SELECT COUNT(*) FROM session_inbox").fetchone()[0] == 1
|
|
986
|
+
assert cur.execute("SELECT COUNT(*) FROM event_sequence").fetchone()[0] == 1
|
|
987
|
+
assert cur.execute("SELECT COUNT(*) FROM event").fetchone()[0] == 1
|
|
988
|
+
assert cur.execute("SELECT notification_id FROM job").fetchall() == [("n3",)]
|
|
989
|
+
conn.close()
|
|
990
|
+
|
|
991
|
+
|
|
992
|
+
def test_delete_opencode_v2_session_releases_unreferenced_blobs(
|
|
993
|
+
tmp_path: Path,
|
|
994
|
+
) -> None:
|
|
995
|
+
import sqlite3
|
|
996
|
+
|
|
997
|
+
db_path = tmp_path / "opencode.db"
|
|
998
|
+
_create_opencode_v2_db(
|
|
999
|
+
db_path,
|
|
1000
|
+
[
|
|
1001
|
+
("ses_parent", "proj", "Parent", 1788590000000),
|
|
1002
|
+
("ses_child", "proj", "Child", 1788590001000),
|
|
1003
|
+
("ses_other", "proj", "Other", 1788590002000),
|
|
1004
|
+
],
|
|
1005
|
+
parents={"ses_child": "ses_parent"},
|
|
1006
|
+
)
|
|
1007
|
+
|
|
1008
|
+
conn = sqlite3.connect(str(db_path))
|
|
1009
|
+
conn.execute("CREATE TABLE blob (hash TEXT PRIMARY KEY, data TEXT)")
|
|
1010
|
+
conn.execute("CREATE TABLE blob_ref (session_id TEXT, message_id TEXT, hash TEXT)")
|
|
1011
|
+
for hash_value in ("b_parent", "b_shared", "b_child_other", "b_other"):
|
|
1012
|
+
conn.execute("INSERT INTO blob VALUES (?, 'data')", (hash_value,))
|
|
1013
|
+
for sid, hash_value in (
|
|
1014
|
+
("ses_parent", "b_parent"),
|
|
1015
|
+
("ses_parent", "b_shared"),
|
|
1016
|
+
("ses_child", "b_shared"),
|
|
1017
|
+
("ses_child", "b_child_other"),
|
|
1018
|
+
("ses_other", "b_child_other"),
|
|
1019
|
+
("ses_other", "b_other"),
|
|
1020
|
+
):
|
|
1021
|
+
conn.execute(
|
|
1022
|
+
"INSERT INTO blob_ref VALUES (?, ?, ?)", (sid, f"msg-{sid}", hash_value)
|
|
1023
|
+
)
|
|
1024
|
+
conn.commit()
|
|
1025
|
+
conn.close()
|
|
1026
|
+
|
|
1027
|
+
record = SessionRecord(
|
|
1028
|
+
provider="opencode",
|
|
1029
|
+
session_id="ses_parent",
|
|
1030
|
+
title="Parent",
|
|
1031
|
+
project_path="/tmp/proj",
|
|
1032
|
+
size_bytes=10,
|
|
1033
|
+
updated_at=datetime.now(timezone.utc),
|
|
1034
|
+
storage_path=db_path,
|
|
1035
|
+
)
|
|
1036
|
+
discovery.delete_session(record)
|
|
1037
|
+
|
|
1038
|
+
conn = sqlite3.connect(str(db_path))
|
|
1039
|
+
cur = conn.cursor()
|
|
1040
|
+
assert cur.execute("SELECT hash FROM blob ORDER BY hash").fetchall() == [
|
|
1041
|
+
("b_child_other",),
|
|
1042
|
+
("b_other",),
|
|
1043
|
+
]
|
|
1044
|
+
assert cur.execute(
|
|
1045
|
+
"SELECT DISTINCT hash FROM blob_ref ORDER BY hash"
|
|
1046
|
+
).fetchall() == [
|
|
1047
|
+
("b_child_other",),
|
|
1048
|
+
("b_other",),
|
|
1049
|
+
]
|
|
1050
|
+
conn.close()
|
|
1051
|
+
|
|
1052
|
+
|
|
1053
|
+
def test_delete_opencode_v2_session_without_optional_tables(tmp_path: Path) -> None:
|
|
1054
|
+
import sqlite3
|
|
1055
|
+
|
|
1056
|
+
db_path = tmp_path / "opencode.db"
|
|
1057
|
+
_create_opencode_v2_db(
|
|
1058
|
+
db_path,
|
|
1059
|
+
[("ses_v2", "proj", "V2 Session", 1788590000000)],
|
|
1060
|
+
)
|
|
1061
|
+
|
|
1062
|
+
record = SessionRecord(
|
|
1063
|
+
provider="opencode",
|
|
1064
|
+
session_id="ses_v2",
|
|
1065
|
+
title="V2 Session",
|
|
1066
|
+
project_path="/tmp/proj",
|
|
1067
|
+
size_bytes=10,
|
|
1068
|
+
updated_at=datetime.now(timezone.utc),
|
|
1069
|
+
storage_path=db_path,
|
|
1070
|
+
)
|
|
1071
|
+
discovery.delete_session(record)
|
|
1072
|
+
|
|
1073
|
+
conn = sqlite3.connect(str(db_path))
|
|
1074
|
+
cur = conn.cursor()
|
|
1075
|
+
assert cur.execute("SELECT COUNT(*) FROM session_v2").fetchone()[0] == 0
|
|
1076
|
+
assert cur.execute("SELECT COUNT(*) FROM session_message").fetchone()[0] == 0
|
|
1077
|
+
conn.close()
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|