Skip to content

Commit 4eb451a

Browse files
EfeDurmaz16claude
andcommitted
feat(dashboard+sdk): add retention SDK, event stream, debugger, and retention pages
- Python SDK: retention preview/enforce, schema version/migrate endpoints + CLI - Dashboard: Event Stream page with live feed simulation and type filters - Dashboard: Time-Travel Debugger with bisect UI and causal graph SVG - Dashboard: Retention & Maintenance with policy editor and preview results - Sidebar: added Events, Debugger, Retention nav items Co-Authored-By: Claude Opus 4.6 <[email protected]>
1 parent aa89b4c commit 4eb451a

9 files changed

Lines changed: 3010 additions & 0 deletions

File tree

python/agit/_stubs.py

Lines changed: 212 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -632,6 +632,218 @@ def get_causal_graph(self, head_hash: str | None = None, depth: int = 50) -> dic
632632

633633
return {"nodes": nodes, "edges": edges}
634634

635+
def get_retention_preview(self, policy_dict: dict[str, Any]) -> dict[str, Any]:
636+
"""Analyze objects/commits and return what would be deleted under policy."""
637+
max_age_secs = policy_dict.get("max_age_secs")
638+
max_commits = policy_dict.get("max_commits")
639+
keep_branches = policy_dict.get("keep_branches", ["main"])
640+
max_log_age_secs = policy_dict.get("max_log_age_secs")
641+
max_log_entries = policy_dict.get("max_log_entries")
642+
643+
now = time.time()
644+
all_commits = self.log(limit=10000)
645+
646+
# Determine which commits would be expired
647+
commits_expired = 0
648+
commits_retained = 0
649+
for i, c in enumerate(all_commits):
650+
expired = False
651+
if max_age_secs is not None:
652+
try:
653+
ts = time.mktime(time.strptime(c.timestamp, "%Y-%m-%dT%H:%M:%SZ"))
654+
if (now - ts) > max_age_secs:
655+
expired = True
656+
except (ValueError, OverflowError):
657+
pass
658+
if max_commits is not None and i >= max_commits:
659+
expired = True
660+
if expired:
661+
commits_expired += 1
662+
else:
663+
commits_retained += 1
664+
665+
objects_before = len(self._objects)
666+
667+
# Estimate objects that would be deleted (2 per expired commit: tree + commit)
668+
objects_deleted = commits_expired * 2
669+
670+
# Estimate log entries that would be pruned
671+
logs_pruned = 0
672+
with self._lock:
673+
total_logs = len(self._audit)
674+
if max_log_entries is not None and total_logs > max_log_entries:
675+
logs_pruned = total_logs - max_log_entries
676+
if max_log_age_secs is not None:
677+
for entry in list(self._audit):
678+
try:
679+
ts = time.mktime(time.strptime(entry["timestamp"], "%Y-%m-%dT%H:%M:%SZ"))
680+
if (now - ts) > max_log_age_secs:
681+
logs_pruned += 1
682+
except (ValueError, OverflowError):
683+
pass
684+
685+
return {
686+
"commits_expired": commits_expired,
687+
"commits_retained": commits_retained,
688+
"objects_deleted": objects_deleted,
689+
"logs_pruned": logs_pruned,
690+
"objects_before": objects_before,
691+
"objects_after": max(0, objects_before - objects_deleted),
692+
}
693+
694+
def enforce_retention(self, policy_dict: dict[str, Any]) -> dict[str, Any]:
695+
"""Actually delete expired objects and prune logs based on policy."""
696+
max_age_secs = policy_dict.get("max_age_secs")
697+
max_commits = policy_dict.get("max_commits")
698+
keep_branches = policy_dict.get("keep_branches", ["main"])
699+
max_log_age_secs = policy_dict.get("max_log_age_secs")
700+
max_log_entries = policy_dict.get("max_log_entries")
701+
702+
now = time.time()
703+
objects_before = len(self._objects)
704+
all_commits = self.log(limit=10000)
705+
706+
# Find protected commit hashes (branch tips for kept branches)
707+
protected_hashes: set[str] = set()
708+
with self._lock:
709+
for br in keep_branches:
710+
if br in self._branches:
711+
protected_hashes.add(self._branches[br])
712+
713+
commits_expired = 0
714+
commits_retained = 0
715+
hashes_to_remove: set[str] = set()
716+
717+
for i, c in enumerate(all_commits):
718+
if c.hash in protected_hashes:
719+
commits_retained += 1
720+
continue
721+
expired = False
722+
if max_age_secs is not None:
723+
try:
724+
ts = time.mktime(time.strptime(c.timestamp, "%Y-%m-%dT%H:%M:%SZ"))
725+
if (now - ts) > max_age_secs:
726+
expired = True
727+
except (ValueError, OverflowError):
728+
pass
729+
if max_commits is not None and i >= max_commits:
730+
expired = True
731+
if expired:
732+
commits_expired += 1
733+
hashes_to_remove.add(c.hash)
734+
if c.tree_hash:
735+
hashes_to_remove.add(c.tree_hash)
736+
else:
737+
commits_retained += 1
738+
739+
# Delete expired objects
740+
objects_deleted = 0
741+
for h in hashes_to_remove:
742+
if h in self._objects:
743+
with self._lock:
744+
self._objects.pop(h, None)
745+
if self._db_path:
746+
con = sqlite3.connect(self._db_path)
747+
con.execute("DELETE FROM objects WHERE hash=?", (h,))
748+
con.commit()
749+
con.close()
750+
objects_deleted += 1
751+
752+
# Prune logs
753+
logs_pruned = 0
754+
with self._lock:
755+
original_logs = list(self._audit)
756+
757+
kept_logs = list(original_logs)
758+
if max_log_age_secs is not None:
759+
kept_logs_new = []
760+
for entry in kept_logs:
761+
try:
762+
ts = time.mktime(time.strptime(entry["timestamp"], "%Y-%m-%dT%H:%M:%SZ"))
763+
if (now - ts) <= max_log_age_secs:
764+
kept_logs_new.append(entry)
765+
else:
766+
logs_pruned += 1
767+
except (ValueError, OverflowError):
768+
kept_logs_new.append(entry)
769+
kept_logs = kept_logs_new
770+
771+
if max_log_entries is not None and len(kept_logs) > max_log_entries:
772+
excess = len(kept_logs) - max_log_entries
773+
logs_pruned += excess
774+
kept_logs = kept_logs[-max_log_entries:]
775+
776+
with self._lock:
777+
self._audit = kept_logs
778+
779+
objects_after = len(self._objects)
780+
return {
781+
"commits_expired": commits_expired,
782+
"commits_retained": commits_retained,
783+
"objects_deleted": objects_deleted,
784+
"logs_pruned": logs_pruned,
785+
"objects_before": objects_before,
786+
"objects_after": objects_after,
787+
}
788+
789+
def get_schema_version(self) -> int:
790+
"""Return current schema version from schema_version table."""
791+
if self._db_path is None:
792+
return 1
793+
con = sqlite3.connect(self._db_path)
794+
try:
795+
row = con.execute("SELECT version FROM schema_version ORDER BY version DESC LIMIT 1").fetchone()
796+
if row:
797+
return int(row[0])
798+
return 1
799+
except sqlite3.OperationalError:
800+
# Table does not exist yet; version 1 is baseline
801+
return 1
802+
finally:
803+
con.close()
804+
805+
def apply_migrations(self) -> dict[str, Any]:
806+
"""Apply pending schema migrations. Returns migration result."""
807+
current_version = self.get_schema_version()
808+
809+
if self._db_path is None:
810+
return {"from_version": current_version, "to_version": current_version, "migrations_applied": 0}
811+
812+
con = sqlite3.connect(self._db_path)
813+
try:
814+
# Ensure schema_version table exists
815+
con.execute(
816+
"CREATE TABLE IF NOT EXISTS schema_version (version INTEGER PRIMARY KEY, applied_at TEXT)"
817+
)
818+
con.commit()
819+
820+
# Define available migrations (version -> DDL)
821+
migrations: list[tuple[int, str]] = [
822+
(2, "CREATE TABLE IF NOT EXISTS branches (name TEXT PRIMARY KEY, commit_hash TEXT, created_at TEXT)"),
823+
(3, "CREATE INDEX IF NOT EXISTS idx_audit_ts ON audit (ts)"),
824+
]
825+
826+
applied = 0
827+
target_version = current_version
828+
for version, ddl in migrations:
829+
if version > current_version:
830+
con.execute(ddl)
831+
con.execute(
832+
"INSERT OR REPLACE INTO schema_version VALUES (?, ?)",
833+
(version, time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())),
834+
)
835+
con.commit()
836+
target_version = version
837+
applied += 1
838+
839+
return {
840+
"from_version": current_version,
841+
"to_version": target_version,
842+
"migrations_applied": applied,
843+
}
844+
finally:
845+
con.close()
846+
635847
# --- Internal ---
636848

637849
def _append_audit(self, action: str, message: str, commit_hash: str | None) -> None:

python/agit/cli/app.py

Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -448,6 +448,111 @@ def causal_graph_cmd(
448448
_abort(str(exc))
449449

450450

451+
@app.command(name="retention-preview")
452+
def retention_preview(
453+
max_commits: Annotated[Optional[int], typer.Option("--max-commits", help="Max commits to keep")] = None,
454+
max_age: Annotated[Optional[int], typer.Option("--max-age", help="Max age in seconds")] = None,
455+
max_log_entries: Annotated[Optional[int], typer.Option("--max-log-entries", help="Max audit log entries to keep")] = None,
456+
repo: Annotated[str, typer.Option("--repo", "-r")] = _DEFAULT_REPO,
457+
agent: Annotated[str, typer.Option("--agent", "-a")] = _DEFAULT_AGENT,
458+
) -> None:
459+
"""Show what would be deleted under the given retention policy (dry run)."""
460+
try:
461+
eng = _engine(repo, agent)
462+
policy: dict = {}
463+
if max_commits is not None:
464+
policy["max_commits"] = max_commits
465+
if max_age is not None:
466+
policy["max_age_secs"] = max_age
467+
if max_log_entries is not None:
468+
policy["max_log_entries"] = max_log_entries
469+
result = eng.preview_retention(policy)
470+
table = Table(title="Retention Preview (dry run)", show_header=True)
471+
table.add_column("Metric", style="bold cyan")
472+
table.add_column("Value")
473+
table.add_row("Commits expired", str(result.get("commits_expired", 0)))
474+
table.add_row("Commits retained", str(result.get("commits_retained", 0)))
475+
table.add_row("Objects deleted", str(result.get("objects_deleted", 0)))
476+
table.add_row("Logs pruned", str(result.get("logs_pruned", 0)))
477+
table.add_row("Objects before", str(result.get("objects_before", 0)))
478+
table.add_row("Objects after", str(result.get("objects_after", 0)))
479+
console.print(table)
480+
except Exception as exc:
481+
_abort(str(exc))
482+
483+
484+
@app.command(name="retention-enforce")
485+
def retention_enforce(
486+
max_commits: Annotated[Optional[int], typer.Option("--max-commits", help="Max commits to keep")] = None,
487+
max_age: Annotated[Optional[int], typer.Option("--max-age", help="Max age in seconds")] = None,
488+
max_log_entries: Annotated[Optional[int], typer.Option("--max-log-entries", help="Max audit log entries to keep")] = None,
489+
repo: Annotated[str, typer.Option("--repo", "-r")] = _DEFAULT_REPO,
490+
agent: Annotated[str, typer.Option("--agent", "-a")] = _DEFAULT_AGENT,
491+
) -> None:
492+
"""Enforce the retention policy, deleting expired objects and pruning logs."""
493+
try:
494+
eng = _engine(repo, agent)
495+
policy: dict = {}
496+
if max_commits is not None:
497+
policy["max_commits"] = max_commits
498+
if max_age is not None:
499+
policy["max_age_secs"] = max_age
500+
if max_log_entries is not None:
501+
policy["max_log_entries"] = max_log_entries
502+
result = eng.enforce_retention(policy)
503+
_success(
504+
f"Retention enforced: {result.get('commits_expired', 0)} commits expired, "
505+
f"{result.get('objects_deleted', 0)} objects deleted, "
506+
f"{result.get('logs_pruned', 0)} log entries pruned"
507+
)
508+
table = Table(title="Retention Result", show_header=True)
509+
table.add_column("Metric", style="bold cyan")
510+
table.add_column("Value")
511+
table.add_row("Commits expired", str(result.get("commits_expired", 0)))
512+
table.add_row("Commits retained", str(result.get("commits_retained", 0)))
513+
table.add_row("Objects deleted", str(result.get("objects_deleted", 0)))
514+
table.add_row("Logs pruned", str(result.get("logs_pruned", 0)))
515+
table.add_row("Objects before", str(result.get("objects_before", 0)))
516+
table.add_row("Objects after", str(result.get("objects_after", 0)))
517+
console.print(table)
518+
except Exception as exc:
519+
_abort(str(exc))
520+
521+
522+
@app.command(name="schema-version")
523+
def schema_version(
524+
repo: Annotated[str, typer.Option("--repo", "-r")] = _DEFAULT_REPO,
525+
agent: Annotated[str, typer.Option("--agent", "-a")] = _DEFAULT_AGENT,
526+
) -> None:
527+
"""Print the current schema version."""
528+
try:
529+
eng = _engine(repo, agent)
530+
version = eng.get_schema_version()
531+
console.print(f"[bold cyan]Schema version:[/] {version}")
532+
except Exception as exc:
533+
_abort(str(exc))
534+
535+
536+
@app.command(name="schema-migrate")
537+
def schema_migrate(
538+
repo: Annotated[str, typer.Option("--repo", "-r")] = _DEFAULT_REPO,
539+
agent: Annotated[str, typer.Option("--agent", "-a")] = _DEFAULT_AGENT,
540+
) -> None:
541+
"""Apply pending schema migrations."""
542+
try:
543+
eng = _engine(repo, agent)
544+
result = eng.apply_migrations()
545+
from_v = result.get("from_version", 0)
546+
to_v = result.get("to_version", 0)
547+
applied = result.get("migrations_applied", 0)
548+
if applied == 0:
549+
console.print(f"[dim]Already at latest schema version ({to_v}). No migrations applied.[/]")
550+
else:
551+
_success(f"Applied {applied} migration(s): v{from_v} -> v{to_v}")
552+
except Exception as exc:
553+
_abort(str(exc))
554+
555+
451556
@app.command()
452557
def squash(
453558
branch_name: Annotated[str, typer.Argument(help="Branch to squash")],

python/agit/engine/executor.py

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -258,6 +258,44 @@ def get_causal_graph(self, head_hash: str | None = None, depth: int = 50) -> dic
258258
return self._repo.get_causal_graph(head_hash, depth)
259259
return {"nodes": [], "edges": []}
260260

261+
def preview_retention(self, policy: dict[str, Any]) -> dict[str, Any]:
262+
"""Preview what would be deleted under the given retention policy."""
263+
if hasattr(self._repo, "get_retention_preview"):
264+
return self._repo.get_retention_preview(policy)
265+
return {
266+
"commits_expired": 0,
267+
"commits_retained": 0,
268+
"objects_deleted": 0,
269+
"logs_pruned": 0,
270+
"objects_before": 0,
271+
"objects_after": 0,
272+
}
273+
274+
def enforce_retention(self, policy: dict[str, Any]) -> dict[str, Any]:
275+
"""Apply the retention policy, deleting expired objects and pruning logs."""
276+
if hasattr(self._repo, "enforce_retention"):
277+
return self._repo.enforce_retention(policy)
278+
return {
279+
"commits_expired": 0,
280+
"commits_retained": 0,
281+
"objects_deleted": 0,
282+
"logs_pruned": 0,
283+
"objects_before": 0,
284+
"objects_after": 0,
285+
}
286+
287+
def get_schema_version(self) -> int:
288+
"""Return the current schema version."""
289+
if hasattr(self._repo, "get_schema_version"):
290+
return self._repo.get_schema_version()
291+
return 1
292+
293+
def apply_migrations(self) -> dict[str, Any]:
294+
"""Apply pending schema migrations."""
295+
if hasattr(self._repo, "apply_migrations"):
296+
return self._repo.apply_migrations()
297+
return {"from_version": 1, "to_version": 1, "migrations_applied": 0}
298+
261299
# ------------------------------------------------------------------
262300
# Internal conversion helpers
263301
# ------------------------------------------------------------------

0 commit comments

Comments
 (0)