agentdir-cli 0.7.5__py3-none-any.whl
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.
- agentdir/__init__.py +3 -0
- agentdir/__main__.py +6 -0
- agentdir/actors.py +50 -0
- agentdir/artifacts.py +56 -0
- agentdir/audit.py +339 -0
- agentdir/capture.py +187 -0
- agentdir/cli.py +1900 -0
- agentdir/context.py +654 -0
- agentdir/control.py +729 -0
- agentdir/daemon.py +253 -0
- agentdir/doctor.py +115 -0
- agentdir/envelope.py +147 -0
- agentdir/events.py +72 -0
- agentdir/federation.py +530 -0
- agentdir/git.py +45 -0
- agentdir/gitignore.py +106 -0
- agentdir/hooks.py +215 -0
- agentdir/index.py +362 -0
- agentdir/mailbox.py +84 -0
- agentdir/memory.py +1274 -0
- agentdir/query.py +65 -0
- agentdir/redaction.py +42 -0
- agentdir/rendering.py +89 -0
- agentdir/replay.py +31 -0
- agentdir/retention.py +319 -0
- agentdir/review.py +288 -0
- agentdir/secrets.py +158 -0
- agentdir/sessions.py +171 -0
- agentdir/skills.py +685 -0
- agentdir/store.py +270 -0
- agentdir/upgrade.py +252 -0
- agentdir_cli-0.7.5.dist-info/METADATA +393 -0
- agentdir_cli-0.7.5.dist-info/RECORD +37 -0
- agentdir_cli-0.7.5.dist-info/WHEEL +5 -0
- agentdir_cli-0.7.5.dist-info/entry_points.txt +2 -0
- agentdir_cli-0.7.5.dist-info/licenses/LICENSE +21 -0
- agentdir_cli-0.7.5.dist-info/top_level.txt +1 -0
agentdir/query.py
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
from .index import connect_index
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def query_messages(
|
|
10
|
+
root: str | Path,
|
|
11
|
+
*,
|
|
12
|
+
session_id: str | None = None,
|
|
13
|
+
event_type: str | None = None,
|
|
14
|
+
actor: str | None = None,
|
|
15
|
+
task_id: str | None = None,
|
|
16
|
+
tool: str | None = None,
|
|
17
|
+
git_head: str | None = None,
|
|
18
|
+
workspace: str | None = None,
|
|
19
|
+
text: str | None = None,
|
|
20
|
+
since: str | None = None,
|
|
21
|
+
until: str | None = None,
|
|
22
|
+
limit: int = 100,
|
|
23
|
+
) -> list[dict[str, Any]]:
|
|
24
|
+
clauses: list[str] = []
|
|
25
|
+
params: list[Any] = []
|
|
26
|
+
if session_id:
|
|
27
|
+
clauses.append("session_id = ?")
|
|
28
|
+
params.append(session_id)
|
|
29
|
+
if event_type:
|
|
30
|
+
clauses.append("event_type = ?")
|
|
31
|
+
params.append(event_type)
|
|
32
|
+
if actor:
|
|
33
|
+
clauses.append("(from_actor like ? or to_actor like ?)")
|
|
34
|
+
params.extend([f"{actor}%", f"{actor}%"])
|
|
35
|
+
if task_id:
|
|
36
|
+
clauses.append("task_id = ?")
|
|
37
|
+
params.append(task_id)
|
|
38
|
+
if tool:
|
|
39
|
+
clauses.append("tool = ?")
|
|
40
|
+
params.append(tool)
|
|
41
|
+
if git_head:
|
|
42
|
+
clauses.append("git_head = ?")
|
|
43
|
+
params.append(git_head)
|
|
44
|
+
if workspace:
|
|
45
|
+
clauses.append("workspace = ?")
|
|
46
|
+
params.append(workspace)
|
|
47
|
+
if since:
|
|
48
|
+
clauses.append("coalesce(date_utc, indexed_at) >= ?")
|
|
49
|
+
params.append(since)
|
|
50
|
+
if until:
|
|
51
|
+
clauses.append("coalesce(date_utc, indexed_at) <= ?")
|
|
52
|
+
params.append(until)
|
|
53
|
+
if text:
|
|
54
|
+
clauses.append("(subject like ? or body_text like ? or message_id like ?)")
|
|
55
|
+
like = f"%{text}%"
|
|
56
|
+
params.extend([like, like, like])
|
|
57
|
+
|
|
58
|
+
sql = "select * from messages"
|
|
59
|
+
if clauses:
|
|
60
|
+
sql += " where " + " and ".join(clauses)
|
|
61
|
+
sql += " order by coalesce(date_utc, indexed_at), coalesce(created_ns, 0), file_path, id limit ?"
|
|
62
|
+
params.append(limit)
|
|
63
|
+
|
|
64
|
+
with connect_index(root) as conn:
|
|
65
|
+
return [dict(row) for row in conn.execute(sql, params).fetchall()]
|
agentdir/redaction.py
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
SECRET_PATTERNS = [
|
|
8
|
+
("github-token", re.compile(r"gh[opsu]_[A-Za-z0-9_]{20,}")),
|
|
9
|
+
("aws-access-key", re.compile(r"AKIA[0-9A-Z]{16}")),
|
|
10
|
+
(
|
|
11
|
+
"key-value-secret",
|
|
12
|
+
re.compile(r"(?i)(api[_-]?key|secret|token|password)\s*[:=]\s*['\"]?[A-Za-z0-9_./+=-]{12,}"),
|
|
13
|
+
),
|
|
14
|
+
("private-key", re.compile(r"-----BEGIN [A-Z ]*PRIVATE KEY-----")),
|
|
15
|
+
]
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@dataclass(frozen=True)
|
|
19
|
+
class RedactionResult:
|
|
20
|
+
text: str
|
|
21
|
+
replacements: int
|
|
22
|
+
labels: tuple[str, ...] = ()
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def redact_text(text: str) -> RedactionResult:
|
|
26
|
+
replacements = 0
|
|
27
|
+
redacted = text
|
|
28
|
+
labels: list[str] = []
|
|
29
|
+
for label, pattern in SECRET_PATTERNS:
|
|
30
|
+
redacted, count = pattern.subn(f"<redacted:{label}>", redacted)
|
|
31
|
+
replacements += count
|
|
32
|
+
if count:
|
|
33
|
+
labels.append(label)
|
|
34
|
+
return RedactionResult(text=redacted, replacements=replacements, labels=tuple(labels))
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def looks_secret_bearing(text: str) -> bool:
|
|
38
|
+
return any(pattern.search(text) for _label, pattern in SECRET_PATTERNS)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def secret_labels(text: str) -> tuple[str, ...]:
|
|
42
|
+
return tuple(label for label, pattern in SECRET_PATTERNS if pattern.search(text))
|
agentdir/rendering.py
ADDED
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def rich_status(status: dict[str, Any]) -> str | None:
|
|
7
|
+
rich = _rich()
|
|
8
|
+
if rich is None:
|
|
9
|
+
return None
|
|
10
|
+
Console, Table = rich
|
|
11
|
+
table = Table(title="AgentDir Status", show_header=True, header_style="bold")
|
|
12
|
+
table.add_column("Area")
|
|
13
|
+
table.add_column("Key")
|
|
14
|
+
table.add_column("Value")
|
|
15
|
+
root = status["root"]
|
|
16
|
+
session = status["session"]
|
|
17
|
+
context = status["context"]
|
|
18
|
+
memory = status["memory"]
|
|
19
|
+
federation = status["federation"]
|
|
20
|
+
health = status["health"]
|
|
21
|
+
table.add_row("root", "path", root["path"])
|
|
22
|
+
table.add_row("root", "scope", root["scope"])
|
|
23
|
+
table.add_row("root", "version", str(root.get("version") or ""))
|
|
24
|
+
table.add_row("session", "active", _bool(session["active"]))
|
|
25
|
+
table.add_row("session", "current", session["current"]["session_id"] if session.get("current") else "")
|
|
26
|
+
table.add_row("context", "latest_pack", context["latest_pack"]["pack_id"] if context.get("latest_pack") else "")
|
|
27
|
+
table.add_row("evidence", "count", str(status["evidence"]["count"]))
|
|
28
|
+
table.add_row("memory", "documents", str(memory.get("memory_documents", 0)))
|
|
29
|
+
table.add_row("memory", "passages", str(memory.get("passages", 0)))
|
|
30
|
+
table.add_row("federation", "registered_roots", str(federation["registered_roots"]))
|
|
31
|
+
table.add_row("federation", "stale_roots", str(federation["stale_roots"]))
|
|
32
|
+
table.add_row("health", "doctor_ok", _bool(health["ok"]))
|
|
33
|
+
return _export(table, Console)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def rich_root_diagnostics(rows: list[dict[str, Any]]) -> str | None:
|
|
37
|
+
rich = _rich()
|
|
38
|
+
if rich is None:
|
|
39
|
+
return None
|
|
40
|
+
Console, Table = rich
|
|
41
|
+
table = Table(title="AgentDir Root Diagnostics", show_header=True, header_style="bold")
|
|
42
|
+
for column in ("Root", "Name", "Status", "Freshness", "Path", "Details"):
|
|
43
|
+
table.add_column(column)
|
|
44
|
+
for row in rows:
|
|
45
|
+
details = "; ".join([*row.get("errors", []), *row.get("warnings", [])])
|
|
46
|
+
table.add_row(
|
|
47
|
+
row["root_id"],
|
|
48
|
+
row["name"],
|
|
49
|
+
"ok" if row["ok"] else "error",
|
|
50
|
+
"stale" if row.get("stale") else "fresh",
|
|
51
|
+
row["root_path"],
|
|
52
|
+
details,
|
|
53
|
+
)
|
|
54
|
+
return _export(table, Console)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def rich_doctor(report: dict[str, Any]) -> str | None:
|
|
58
|
+
rich = _rich()
|
|
59
|
+
if rich is None:
|
|
60
|
+
return None
|
|
61
|
+
Console, Table = rich
|
|
62
|
+
table = Table(title="AgentDir Doctor", show_header=True, header_style="bold")
|
|
63
|
+
table.add_column("Level")
|
|
64
|
+
table.add_column("Message")
|
|
65
|
+
table.add_row("ok", _bool(report["ok"]))
|
|
66
|
+
for warning in report.get("warnings", []):
|
|
67
|
+
table.add_row("warning", warning)
|
|
68
|
+
for error in report.get("errors", []):
|
|
69
|
+
table.add_row("error", error)
|
|
70
|
+
return _export(table, Console)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _rich() -> tuple[Any, Any] | None:
|
|
74
|
+
try:
|
|
75
|
+
from rich.console import Console
|
|
76
|
+
from rich.table import Table
|
|
77
|
+
except ImportError:
|
|
78
|
+
return None
|
|
79
|
+
return Console, Table
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _export(renderable: Any, Console: Any) -> str:
|
|
83
|
+
console = Console(record=True, force_terminal=False, color_system=None, width=120)
|
|
84
|
+
console.print(renderable)
|
|
85
|
+
return console.export_text(clear=True)
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _bool(value: bool) -> str:
|
|
89
|
+
return "true" if value else "false"
|
agentdir/replay.py
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import sqlite3
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
from .index import rebuild_index
|
|
7
|
+
from .query import query_messages
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def replay_session(root: str | Path, session_id: str) -> list[str]:
|
|
11
|
+
try:
|
|
12
|
+
rows = query_messages(root, session_id=session_id, limit=10_000)
|
|
13
|
+
except sqlite3.DatabaseError as exc:
|
|
14
|
+
if not _looks_like_missing_index(exc):
|
|
15
|
+
raise
|
|
16
|
+
rebuild_index(root)
|
|
17
|
+
rows = query_messages(root, session_id=session_id, limit=10_000)
|
|
18
|
+
lines: list[str] = []
|
|
19
|
+
for row in rows:
|
|
20
|
+
date = row.get("date_header") or row.get("indexed_at") or "unknown-date"
|
|
21
|
+
event_type = row.get("event_type") or "unknown"
|
|
22
|
+
subject = row.get("subject") or ""
|
|
23
|
+
body = (row.get("body_text") or "").strip().replace("\n", "\\n")
|
|
24
|
+
file_path = row.get("file_path") or ""
|
|
25
|
+
lines.append(f"{date} {event_type} {subject} {body} [{file_path}]")
|
|
26
|
+
return lines
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _looks_like_missing_index(exc: sqlite3.DatabaseError) -> bool:
|
|
30
|
+
message = str(exc).lower()
|
|
31
|
+
return "no such table" in message or "file is not a database" in message or "database disk image is malformed" in message
|
agentdir/retention.py
ADDED
|
@@ -0,0 +1,319 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import shutil
|
|
4
|
+
import time
|
|
5
|
+
from dataclasses import asdict, dataclass
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
from .index import rebuild_index
|
|
9
|
+
from .mailbox import fsync_directory
|
|
10
|
+
from .sessions import read_current_session
|
|
11
|
+
from .store import AgentDirError, init_root, validate_id
|
|
12
|
+
|
|
13
|
+
SECONDS_PER_DAY = 24 * 60 * 60
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@dataclass(frozen=True)
|
|
17
|
+
class RetentionCandidate:
|
|
18
|
+
session_id: str
|
|
19
|
+
store: str
|
|
20
|
+
path: str
|
|
21
|
+
event_count: int
|
|
22
|
+
latest_event_at: float | None
|
|
23
|
+
active: bool = False
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@dataclass(frozen=True)
|
|
27
|
+
class RetentionResult:
|
|
28
|
+
action: str
|
|
29
|
+
dry_run: bool
|
|
30
|
+
selected: list[RetentionCandidate]
|
|
31
|
+
protected: list[RetentionCandidate]
|
|
32
|
+
changed: list[str]
|
|
33
|
+
rebuilt_index: bool = False
|
|
34
|
+
|
|
35
|
+
def as_dict(self) -> dict[str, object]:
|
|
36
|
+
return {
|
|
37
|
+
"action": self.action,
|
|
38
|
+
"dry_run": self.dry_run,
|
|
39
|
+
"selected": [asdict(candidate) for candidate in self.selected],
|
|
40
|
+
"protected": [asdict(candidate) for candidate in self.protected],
|
|
41
|
+
"changed": self.changed,
|
|
42
|
+
"rebuilt_index": self.rebuilt_index,
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def archive_sessions(
|
|
47
|
+
root: str | Path,
|
|
48
|
+
*,
|
|
49
|
+
sessions: list[str] | None = None,
|
|
50
|
+
older_than_days: int | None = None,
|
|
51
|
+
keep_recent: int | None = None,
|
|
52
|
+
apply: bool = False,
|
|
53
|
+
) -> RetentionResult:
|
|
54
|
+
paths = init_root(root)
|
|
55
|
+
_validate_selectors(
|
|
56
|
+
sessions=sessions,
|
|
57
|
+
older_than_days=older_than_days,
|
|
58
|
+
keep_recent=keep_recent,
|
|
59
|
+
command="archive",
|
|
60
|
+
)
|
|
61
|
+
active_id = _active_session_id(paths.root)
|
|
62
|
+
candidates = _list_sessions(paths.sessions, store="sessions", active_session_id=active_id)
|
|
63
|
+
selected, protected = _select_candidates(
|
|
64
|
+
candidates,
|
|
65
|
+
sessions=sessions,
|
|
66
|
+
older_than_days=older_than_days,
|
|
67
|
+
keep_recent=keep_recent,
|
|
68
|
+
)
|
|
69
|
+
_raise_on_exact_active("archive", sessions, protected)
|
|
70
|
+
|
|
71
|
+
changed: list[str] = []
|
|
72
|
+
if apply:
|
|
73
|
+
target_root = paths.archives / "sessions"
|
|
74
|
+
target_root.mkdir(parents=True, exist_ok=True)
|
|
75
|
+
targets: list[tuple[RetentionCandidate, Path]] = []
|
|
76
|
+
for candidate in selected:
|
|
77
|
+
target = target_root / candidate.session_id
|
|
78
|
+
if target.exists():
|
|
79
|
+
raise AgentDirError(f"archive target already exists: {target}")
|
|
80
|
+
targets.append((candidate, target))
|
|
81
|
+
for candidate, target in targets:
|
|
82
|
+
source = Path(candidate.path)
|
|
83
|
+
source.rename(target)
|
|
84
|
+
fsync_directory(paths.sessions)
|
|
85
|
+
fsync_directory(target_root)
|
|
86
|
+
changed.append(f"{candidate.session_id}:sessions->archives")
|
|
87
|
+
rebuilt = bool(changed)
|
|
88
|
+
if rebuilt:
|
|
89
|
+
rebuild_index(paths.root)
|
|
90
|
+
else:
|
|
91
|
+
rebuilt = False
|
|
92
|
+
|
|
93
|
+
return RetentionResult(
|
|
94
|
+
action="archive",
|
|
95
|
+
dry_run=not apply,
|
|
96
|
+
selected=selected,
|
|
97
|
+
protected=protected,
|
|
98
|
+
changed=changed,
|
|
99
|
+
rebuilt_index=rebuilt,
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def prune_sessions(
|
|
104
|
+
root: str | Path,
|
|
105
|
+
*,
|
|
106
|
+
sessions: list[str] | None = None,
|
|
107
|
+
older_than_days: int | None = None,
|
|
108
|
+
keep_recent: int | None = None,
|
|
109
|
+
include_live_sessions: bool = False,
|
|
110
|
+
apply: bool = False,
|
|
111
|
+
) -> RetentionResult:
|
|
112
|
+
paths = init_root(root)
|
|
113
|
+
_validate_selectors(
|
|
114
|
+
sessions=sessions,
|
|
115
|
+
older_than_days=older_than_days,
|
|
116
|
+
keep_recent=keep_recent,
|
|
117
|
+
command="prune",
|
|
118
|
+
)
|
|
119
|
+
active_id = _active_session_id(paths.root)
|
|
120
|
+
candidates = _list_sessions(
|
|
121
|
+
paths.archives / "sessions",
|
|
122
|
+
store="archives",
|
|
123
|
+
active_session_id=active_id,
|
|
124
|
+
)
|
|
125
|
+
if include_live_sessions:
|
|
126
|
+
candidates.extend(
|
|
127
|
+
_list_sessions(paths.sessions, store="sessions", active_session_id=active_id)
|
|
128
|
+
)
|
|
129
|
+
selected, protected = _select_candidates(
|
|
130
|
+
candidates,
|
|
131
|
+
sessions=sessions,
|
|
132
|
+
older_than_days=older_than_days,
|
|
133
|
+
keep_recent=keep_recent,
|
|
134
|
+
)
|
|
135
|
+
_raise_on_exact_active("prune", sessions, protected)
|
|
136
|
+
|
|
137
|
+
changed: list[str] = []
|
|
138
|
+
live_changed = False
|
|
139
|
+
if apply:
|
|
140
|
+
for candidate in selected:
|
|
141
|
+
source = Path(candidate.path)
|
|
142
|
+
shutil.rmtree(source)
|
|
143
|
+
fsync_directory(source.parent)
|
|
144
|
+
changed.append(f"{candidate.session_id}:{candidate.store}->deleted")
|
|
145
|
+
live_changed = live_changed or candidate.store == "sessions"
|
|
146
|
+
if live_changed:
|
|
147
|
+
rebuild_index(paths.root)
|
|
148
|
+
|
|
149
|
+
return RetentionResult(
|
|
150
|
+
action="prune",
|
|
151
|
+
dry_run=not apply,
|
|
152
|
+
selected=selected,
|
|
153
|
+
protected=protected,
|
|
154
|
+
changed=changed,
|
|
155
|
+
rebuilt_index=apply and live_changed,
|
|
156
|
+
)
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def format_retention_result(result: RetentionResult) -> str:
|
|
160
|
+
mode = "dry-run" if result.dry_run else "applied"
|
|
161
|
+
lines = [
|
|
162
|
+
f"action={result.action}",
|
|
163
|
+
f"mode={mode}",
|
|
164
|
+
f"selected={len(result.selected)}",
|
|
165
|
+
f"protected={len(result.protected)}",
|
|
166
|
+
f"changed={len(result.changed)}",
|
|
167
|
+
]
|
|
168
|
+
for candidate in result.selected:
|
|
169
|
+
lines.append(
|
|
170
|
+
f"selected: {candidate.session_id} store={candidate.store} events={candidate.event_count}"
|
|
171
|
+
)
|
|
172
|
+
for candidate in result.protected:
|
|
173
|
+
lines.append(f"protected: {candidate.session_id} store={candidate.store} active=true")
|
|
174
|
+
for changed in result.changed:
|
|
175
|
+
lines.append(f"changed: {changed}")
|
|
176
|
+
if result.rebuilt_index:
|
|
177
|
+
lines.append("rebuilt_index=true")
|
|
178
|
+
if result.dry_run and result.selected:
|
|
179
|
+
lines.append("rerun with --apply to perform this change")
|
|
180
|
+
return "\n".join(lines)
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def _validate_selectors(
|
|
184
|
+
*,
|
|
185
|
+
sessions: list[str] | None,
|
|
186
|
+
older_than_days: int | None,
|
|
187
|
+
keep_recent: int | None,
|
|
188
|
+
command: str,
|
|
189
|
+
) -> None:
|
|
190
|
+
if not sessions and older_than_days is None and keep_recent is None:
|
|
191
|
+
raise AgentDirError(
|
|
192
|
+
f"{command} requires --session, --older-than-days, or --keep-recent"
|
|
193
|
+
)
|
|
194
|
+
if sessions and (older_than_days is not None or keep_recent is not None):
|
|
195
|
+
raise AgentDirError("Use --session or age/count filters, not both")
|
|
196
|
+
for session_id in sessions or []:
|
|
197
|
+
validate_id(session_id, "session id")
|
|
198
|
+
if older_than_days is not None and older_than_days < 0:
|
|
199
|
+
raise AgentDirError("--older-than-days must be non-negative")
|
|
200
|
+
if keep_recent is not None and keep_recent < 0:
|
|
201
|
+
raise AgentDirError("--keep-recent must be non-negative")
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def _active_session_id(root: Path) -> str | None:
|
|
205
|
+
try:
|
|
206
|
+
state = read_current_session(root)
|
|
207
|
+
except Exception:
|
|
208
|
+
return None
|
|
209
|
+
if state and state.status == "active":
|
|
210
|
+
return state.session_id
|
|
211
|
+
return None
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def _list_sessions(
|
|
215
|
+
directory: Path,
|
|
216
|
+
*,
|
|
217
|
+
store: str,
|
|
218
|
+
active_session_id: str | None,
|
|
219
|
+
) -> list[RetentionCandidate]:
|
|
220
|
+
if not directory.is_dir():
|
|
221
|
+
return []
|
|
222
|
+
candidates: list[RetentionCandidate] = []
|
|
223
|
+
for session_dir in sorted(directory.iterdir()):
|
|
224
|
+
if not session_dir.is_dir() or not (session_dir / "Maildir").is_dir():
|
|
225
|
+
continue
|
|
226
|
+
event_paths = [
|
|
227
|
+
path
|
|
228
|
+
for state in ("new", "cur")
|
|
229
|
+
for path in (session_dir / "Maildir" / state).glob("*")
|
|
230
|
+
if path.is_file() and not path.name.startswith(".")
|
|
231
|
+
]
|
|
232
|
+
latest_event_at = max((path.stat().st_mtime for path in event_paths), default=None)
|
|
233
|
+
if latest_event_at is None:
|
|
234
|
+
latest_event_at = session_dir.stat().st_mtime
|
|
235
|
+
candidates.append(
|
|
236
|
+
RetentionCandidate(
|
|
237
|
+
session_id=session_dir.name,
|
|
238
|
+
store=store,
|
|
239
|
+
path=str(session_dir),
|
|
240
|
+
event_count=len(event_paths),
|
|
241
|
+
latest_event_at=latest_event_at,
|
|
242
|
+
active=session_dir.name == active_session_id and store == "sessions",
|
|
243
|
+
)
|
|
244
|
+
)
|
|
245
|
+
return candidates
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
def _select_candidates(
|
|
249
|
+
candidates: list[RetentionCandidate],
|
|
250
|
+
*,
|
|
251
|
+
sessions: list[str] | None,
|
|
252
|
+
older_than_days: int | None,
|
|
253
|
+
keep_recent: int | None,
|
|
254
|
+
) -> tuple[list[RetentionCandidate], list[RetentionCandidate]]:
|
|
255
|
+
selected = _select_unprotected_candidates(
|
|
256
|
+
candidates,
|
|
257
|
+
sessions=sessions,
|
|
258
|
+
older_than_days=older_than_days,
|
|
259
|
+
keep_recent=keep_recent,
|
|
260
|
+
)
|
|
261
|
+
protected = [candidate for candidate in selected if candidate.active]
|
|
262
|
+
return [candidate for candidate in selected if not candidate.active], protected
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
def _select_unprotected_candidates(
|
|
266
|
+
candidates: list[RetentionCandidate],
|
|
267
|
+
*,
|
|
268
|
+
sessions: list[str] | None,
|
|
269
|
+
older_than_days: int | None,
|
|
270
|
+
keep_recent: int | None,
|
|
271
|
+
) -> list[RetentionCandidate]:
|
|
272
|
+
if sessions:
|
|
273
|
+
requested = set(sessions)
|
|
274
|
+
selected = [candidate for candidate in candidates if candidate.session_id in requested]
|
|
275
|
+
found = {candidate.session_id for candidate in selected}
|
|
276
|
+
missing = sorted(requested - found)
|
|
277
|
+
if missing:
|
|
278
|
+
raise AgentDirError(f"unknown session id(s): {', '.join(missing)}")
|
|
279
|
+
return selected
|
|
280
|
+
|
|
281
|
+
selected = list(candidates)
|
|
282
|
+
if older_than_days is not None:
|
|
283
|
+
cutoff = time.time() - (older_than_days * SECONDS_PER_DAY)
|
|
284
|
+
selected = [
|
|
285
|
+
candidate
|
|
286
|
+
for candidate in selected
|
|
287
|
+
if (candidate.latest_event_at or 0) <= cutoff
|
|
288
|
+
]
|
|
289
|
+
if keep_recent is not None:
|
|
290
|
+
ordered = sorted(
|
|
291
|
+
candidates,
|
|
292
|
+
key=lambda candidate: (
|
|
293
|
+
candidate.latest_event_at or 0,
|
|
294
|
+
candidate.store,
|
|
295
|
+
candidate.session_id,
|
|
296
|
+
),
|
|
297
|
+
reverse=True,
|
|
298
|
+
)
|
|
299
|
+
prune_keys = {
|
|
300
|
+
(candidate.store, candidate.session_id)
|
|
301
|
+
for candidate in ordered[keep_recent:]
|
|
302
|
+
}
|
|
303
|
+
selected = [
|
|
304
|
+
candidate
|
|
305
|
+
for candidate in selected
|
|
306
|
+
if (candidate.store, candidate.session_id) in prune_keys
|
|
307
|
+
]
|
|
308
|
+
return selected
|
|
309
|
+
|
|
310
|
+
|
|
311
|
+
def _raise_on_exact_active(
|
|
312
|
+
command: str,
|
|
313
|
+
sessions: list[str] | None,
|
|
314
|
+
protected: list[RetentionCandidate],
|
|
315
|
+
) -> None:
|
|
316
|
+
if not sessions or not protected:
|
|
317
|
+
return
|
|
318
|
+
ids = ", ".join(candidate.session_id for candidate in protected)
|
|
319
|
+
raise AgentDirError(f"refusing to {command} active session(s): {ids}")
|