agentpool-cli 0.1.0__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.
- agentpool/__init__.py +3 -0
- agentpool/agent_io.py +134 -0
- agentpool/artifacts.py +151 -0
- agentpool/cli.py +1199 -0
- agentpool/config.py +373 -0
- agentpool/docs/agentpool-skill.md +85 -0
- agentpool/docs/onboarding.md +169 -0
- agentpool/event_detection.py +150 -0
- agentpool/fixtures/__init__.py +1 -0
- agentpool/fixtures/fake_agents/__init__.py +1 -0
- agentpool/fixtures/fake_agents/fake_approval_agent.py +16 -0
- agentpool/fixtures/fake_agents/fake_common.py +44 -0
- agentpool/fixtures/fake_agents/fake_completed_agent.py +13 -0
- agentpool/fixtures/fake_agents/fake_idle_agent.py +16 -0
- agentpool/fixtures/fake_agents/fake_limit_agent.py +14 -0
- agentpool/fixtures/fake_agents/fake_patch_agent.py +17 -0
- agentpool/fixtures/fake_agents/fake_question_agent.py +16 -0
- agentpool/git_worktree.py +144 -0
- agentpool/mcp/__init__.py +1 -0
- agentpool/mcp/resources.py +64 -0
- agentpool/mcp/tools.py +259 -0
- agentpool/mcp_server.py +487 -0
- agentpool/models.py +310 -0
- agentpool/onboarding.py +1279 -0
- agentpool/policy.py +63 -0
- agentpool/provider_model_catalog.json +997 -0
- agentpool/providers/__init__.py +3 -0
- agentpool/providers/base.py +411 -0
- agentpool/providers/registry.py +139 -0
- agentpool/redaction.py +30 -0
- agentpool/runtimes/__init__.py +3 -0
- agentpool/runtimes/base.py +36 -0
- agentpool/runtimes/tmux.py +133 -0
- agentpool/session_manager.py +1061 -0
- agentpool/stats/__init__.py +6 -0
- agentpool/stats/card.py +74 -0
- agentpool/stats/compute.py +496 -0
- agentpool/stats/queries.py +138 -0
- agentpool/stats/render.py +103 -0
- agentpool/stats/window.py +85 -0
- agentpool/store.py +478 -0
- agentpool/usage/__init__.py +1 -0
- agentpool/usage/_common.py +223 -0
- agentpool/usage/ccusage.py +130 -0
- agentpool/usage/claude.py +23 -0
- agentpool/usage/codex.py +210 -0
- agentpool/usage/codexbar.py +186 -0
- agentpool/usage/combine.py +71 -0
- agentpool/usage/copilot.py +146 -0
- agentpool/usage/devin.py +265 -0
- agentpool/usage/parsers.py +41 -0
- agentpool/usage/probes.py +52 -0
- agentpool/usage/provider_parsers.py +276 -0
- agentpool/usage/summary.py +166 -0
- agentpool/utils.py +59 -0
- agentpool_cli-0.1.0.dist-info/METADATA +292 -0
- agentpool_cli-0.1.0.dist-info/RECORD +60 -0
- agentpool_cli-0.1.0.dist-info/WHEEL +4 -0
- agentpool_cli-0.1.0.dist-info/entry_points.txt +2 -0
- agentpool_cli-0.1.0.dist-info/licenses/LICENSE +21 -0
agentpool/store.py
ADDED
|
@@ -0,0 +1,478 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import sqlite3
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from agentpool.models import AgentSession, ArtifactRecord, CapacitySnapshot, FileLease, SessionState, TmuxSessionRef, ToolError
|
|
9
|
+
from agentpool.utils import utc_now_iso
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
SCHEMA = """
|
|
13
|
+
CREATE TABLE IF NOT EXISTS sessions (
|
|
14
|
+
id TEXT PRIMARY KEY,
|
|
15
|
+
provider_id TEXT NOT NULL,
|
|
16
|
+
model TEXT,
|
|
17
|
+
harness TEXT NOT NULL,
|
|
18
|
+
account TEXT,
|
|
19
|
+
role TEXT NOT NULL,
|
|
20
|
+
task TEXT NOT NULL,
|
|
21
|
+
repo_path TEXT NOT NULL,
|
|
22
|
+
worktree_path TEXT,
|
|
23
|
+
runtime TEXT NOT NULL,
|
|
24
|
+
state TEXT NOT NULL,
|
|
25
|
+
tmux_session TEXT,
|
|
26
|
+
tmux_window TEXT,
|
|
27
|
+
tmux_pane TEXT,
|
|
28
|
+
artifact_dir TEXT NOT NULL,
|
|
29
|
+
transcript_path TEXT NOT NULL,
|
|
30
|
+
events_path TEXT NOT NULL,
|
|
31
|
+
created_at TEXT NOT NULL,
|
|
32
|
+
updated_at TEXT NOT NULL,
|
|
33
|
+
ended_at TEXT,
|
|
34
|
+
metadata_json TEXT NOT NULL DEFAULT '{}'
|
|
35
|
+
);
|
|
36
|
+
|
|
37
|
+
CREATE TABLE IF NOT EXISTS events (
|
|
38
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
39
|
+
session_id TEXT NOT NULL,
|
|
40
|
+
ts TEXT NOT NULL,
|
|
41
|
+
event_type TEXT NOT NULL,
|
|
42
|
+
state TEXT,
|
|
43
|
+
screen_hash TEXT,
|
|
44
|
+
excerpt TEXT,
|
|
45
|
+
metadata_json TEXT NOT NULL DEFAULT '{}',
|
|
46
|
+
FOREIGN KEY(session_id) REFERENCES sessions(id)
|
|
47
|
+
);
|
|
48
|
+
|
|
49
|
+
CREATE TABLE IF NOT EXISTS usage_snapshots (
|
|
50
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
51
|
+
provider_id TEXT NOT NULL,
|
|
52
|
+
ts TEXT NOT NULL,
|
|
53
|
+
status TEXT NOT NULL,
|
|
54
|
+
confidence TEXT NOT NULL,
|
|
55
|
+
raw_json TEXT NOT NULL
|
|
56
|
+
);
|
|
57
|
+
|
|
58
|
+
CREATE TABLE IF NOT EXISTS artifacts (
|
|
59
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
60
|
+
session_id TEXT NOT NULL,
|
|
61
|
+
kind TEXT NOT NULL,
|
|
62
|
+
path TEXT NOT NULL,
|
|
63
|
+
sha256 TEXT,
|
|
64
|
+
metadata_json TEXT NOT NULL DEFAULT '{}',
|
|
65
|
+
created_at TEXT NOT NULL,
|
|
66
|
+
FOREIGN KEY(session_id) REFERENCES sessions(id)
|
|
67
|
+
);
|
|
68
|
+
|
|
69
|
+
CREATE TABLE IF NOT EXISTS file_leases (
|
|
70
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
71
|
+
session_id TEXT NOT NULL,
|
|
72
|
+
repo_path TEXT NOT NULL,
|
|
73
|
+
file_path TEXT NOT NULL,
|
|
74
|
+
mode TEXT NOT NULL,
|
|
75
|
+
expires_at TEXT,
|
|
76
|
+
created_at TEXT NOT NULL,
|
|
77
|
+
released_at TEXT,
|
|
78
|
+
metadata_json TEXT NOT NULL DEFAULT '{}'
|
|
79
|
+
);
|
|
80
|
+
|
|
81
|
+
CREATE INDEX IF NOT EXISTS idx_sessions_provider_state ON sessions(provider_id, state);
|
|
82
|
+
CREATE INDEX IF NOT EXISTS idx_sessions_created_at ON sessions(created_at);
|
|
83
|
+
CREATE INDEX IF NOT EXISTS idx_events_session_id ON events(session_id);
|
|
84
|
+
CREATE INDEX IF NOT EXISTS idx_events_ts ON events(ts);
|
|
85
|
+
CREATE INDEX IF NOT EXISTS idx_usage_snapshots_provider_id_desc ON usage_snapshots(provider_id, id DESC);
|
|
86
|
+
CREATE INDEX IF NOT EXISTS idx_file_leases_repo_file ON file_leases(repo_path, file_path);
|
|
87
|
+
"""
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
class Store:
|
|
91
|
+
def __init__(self, db_path: Path):
|
|
92
|
+
self.db_path = db_path
|
|
93
|
+
self.db_path.parent.mkdir(parents=True, exist_ok=True)
|
|
94
|
+
self.init_schema()
|
|
95
|
+
|
|
96
|
+
def connect(self) -> sqlite3.Connection:
|
|
97
|
+
conn = sqlite3.connect(self.db_path)
|
|
98
|
+
conn.row_factory = sqlite3.Row
|
|
99
|
+
return conn
|
|
100
|
+
|
|
101
|
+
def init_schema(self) -> None:
|
|
102
|
+
with self.connect() as conn:
|
|
103
|
+
conn.executescript(SCHEMA)
|
|
104
|
+
|
|
105
|
+
def save_session(self, session: AgentSession) -> None:
|
|
106
|
+
tmux = session.tmux
|
|
107
|
+
with self.connect() as conn:
|
|
108
|
+
conn.execute(
|
|
109
|
+
"""
|
|
110
|
+
INSERT INTO sessions (
|
|
111
|
+
id, provider_id, model, harness, account, role, task, repo_path,
|
|
112
|
+
worktree_path, runtime, state, tmux_session, tmux_window, tmux_pane,
|
|
113
|
+
artifact_dir, transcript_path, events_path, created_at, updated_at,
|
|
114
|
+
ended_at, metadata_json
|
|
115
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
116
|
+
ON CONFLICT(id) DO UPDATE SET
|
|
117
|
+
model=excluded.model,
|
|
118
|
+
account=excluded.account,
|
|
119
|
+
state=excluded.state,
|
|
120
|
+
worktree_path=excluded.worktree_path,
|
|
121
|
+
tmux_session=excluded.tmux_session,
|
|
122
|
+
tmux_window=excluded.tmux_window,
|
|
123
|
+
tmux_pane=excluded.tmux_pane,
|
|
124
|
+
updated_at=excluded.updated_at,
|
|
125
|
+
ended_at=excluded.ended_at,
|
|
126
|
+
metadata_json=excluded.metadata_json
|
|
127
|
+
""",
|
|
128
|
+
(
|
|
129
|
+
session.id,
|
|
130
|
+
session.provider_id,
|
|
131
|
+
session.model,
|
|
132
|
+
session.harness,
|
|
133
|
+
session.account,
|
|
134
|
+
session.role,
|
|
135
|
+
session.task,
|
|
136
|
+
session.repo_path,
|
|
137
|
+
session.worktree_path,
|
|
138
|
+
session.runtime.value if hasattr(session.runtime, "value") else session.runtime,
|
|
139
|
+
session.state.value if hasattr(session.state, "value") else session.state,
|
|
140
|
+
tmux.session_name if tmux else None,
|
|
141
|
+
tmux.window if tmux else None,
|
|
142
|
+
tmux.pane if tmux else None,
|
|
143
|
+
session.artifact_dir,
|
|
144
|
+
session.transcript_path,
|
|
145
|
+
session.events_path,
|
|
146
|
+
session.created_at.isoformat(),
|
|
147
|
+
session.updated_at.isoformat(),
|
|
148
|
+
session.ended_at.isoformat() if session.ended_at else None,
|
|
149
|
+
json.dumps(session.metadata),
|
|
150
|
+
),
|
|
151
|
+
)
|
|
152
|
+
|
|
153
|
+
def get_session(self, session_id: str) -> AgentSession | None:
|
|
154
|
+
with self.connect() as conn:
|
|
155
|
+
row = conn.execute("SELECT * FROM sessions WHERE id = ?", (session_id,)).fetchone()
|
|
156
|
+
return self._row_to_session(row) if row else None
|
|
157
|
+
|
|
158
|
+
def list_sessions(
|
|
159
|
+
self,
|
|
160
|
+
states: list[str] | None = None,
|
|
161
|
+
provider_id: str | None = None,
|
|
162
|
+
limit: int | None = None,
|
|
163
|
+
offset: int = 0,
|
|
164
|
+
) -> list[AgentSession]:
|
|
165
|
+
query, args = self._session_query(states, provider_id)
|
|
166
|
+
if limit is not None:
|
|
167
|
+
query = f"{query} LIMIT ? OFFSET ?"
|
|
168
|
+
args.extend([limit, offset])
|
|
169
|
+
with self.connect() as conn:
|
|
170
|
+
rows = conn.execute(query, args).fetchall()
|
|
171
|
+
return [self._row_to_session(row) for row in rows]
|
|
172
|
+
|
|
173
|
+
def count_sessions(self, states: list[str] | None = None, provider_id: str | None = None) -> int:
|
|
174
|
+
query, args = self._session_query(states, provider_id, select="COUNT(*)")
|
|
175
|
+
with self.connect() as conn:
|
|
176
|
+
return int(conn.execute(query, args).fetchone()[0])
|
|
177
|
+
|
|
178
|
+
def _session_query(
|
|
179
|
+
self,
|
|
180
|
+
states: list[str] | None = None,
|
|
181
|
+
provider_id: str | None = None,
|
|
182
|
+
select: str = "*",
|
|
183
|
+
) -> tuple[str, list[Any]]:
|
|
184
|
+
clauses: list[str] = []
|
|
185
|
+
args: list[Any] = []
|
|
186
|
+
if states:
|
|
187
|
+
clauses.append(f"state IN ({','.join('?' for _ in states)})")
|
|
188
|
+
args.extend(states)
|
|
189
|
+
if provider_id:
|
|
190
|
+
clauses.append("provider_id = ?")
|
|
191
|
+
args.append(provider_id)
|
|
192
|
+
where = f" WHERE {' AND '.join(clauses)}" if clauses else ""
|
|
193
|
+
order = "" if select != "*" else " ORDER BY created_at DESC"
|
|
194
|
+
return f"SELECT {select} FROM sessions{where}{order}", args
|
|
195
|
+
|
|
196
|
+
def update_session_state(self, session_id: str, state: SessionState, ended_at: str | None = None) -> None:
|
|
197
|
+
with self.connect() as conn:
|
|
198
|
+
conn.execute(
|
|
199
|
+
"UPDATE sessions SET state = ?, updated_at = ?, ended_at = COALESCE(?, ended_at) WHERE id = ?",
|
|
200
|
+
(state.value, utc_now_iso(), ended_at, session_id),
|
|
201
|
+
)
|
|
202
|
+
|
|
203
|
+
def append_event(
|
|
204
|
+
self,
|
|
205
|
+
session_id: str,
|
|
206
|
+
event_type: str,
|
|
207
|
+
state: str | None = None,
|
|
208
|
+
screen_hash: str | None = None,
|
|
209
|
+
excerpt: str | None = None,
|
|
210
|
+
metadata: dict[str, Any] | None = None,
|
|
211
|
+
) -> int:
|
|
212
|
+
with self.connect() as conn:
|
|
213
|
+
cursor = conn.execute(
|
|
214
|
+
"""
|
|
215
|
+
INSERT INTO events (session_id, ts, event_type, state, screen_hash, excerpt, metadata_json)
|
|
216
|
+
VALUES (?, ?, ?, ?, ?, ?, ?)
|
|
217
|
+
""",
|
|
218
|
+
(
|
|
219
|
+
session_id,
|
|
220
|
+
utc_now_iso(),
|
|
221
|
+
event_type,
|
|
222
|
+
state,
|
|
223
|
+
screen_hash,
|
|
224
|
+
excerpt,
|
|
225
|
+
json.dumps(metadata or {}),
|
|
226
|
+
),
|
|
227
|
+
)
|
|
228
|
+
return int(cursor.lastrowid)
|
|
229
|
+
|
|
230
|
+
def list_events(self, session_id: str) -> list[dict[str, Any]]:
|
|
231
|
+
with self.connect() as conn:
|
|
232
|
+
rows = conn.execute(
|
|
233
|
+
"SELECT * FROM events WHERE session_id = ? ORDER BY id ASC", (session_id,)
|
|
234
|
+
).fetchall()
|
|
235
|
+
return [
|
|
236
|
+
{
|
|
237
|
+
"id": row["id"],
|
|
238
|
+
"session_id": row["session_id"],
|
|
239
|
+
"ts": row["ts"],
|
|
240
|
+
"event_type": row["event_type"],
|
|
241
|
+
"state": row["state"],
|
|
242
|
+
"screen_hash": row["screen_hash"],
|
|
243
|
+
"excerpt": row["excerpt"],
|
|
244
|
+
"metadata": json.loads(row["metadata_json"]),
|
|
245
|
+
}
|
|
246
|
+
for row in rows
|
|
247
|
+
]
|
|
248
|
+
|
|
249
|
+
def save_usage_snapshot(self, snapshot: CapacitySnapshot) -> None:
|
|
250
|
+
with self.connect() as conn:
|
|
251
|
+
conn.execute(
|
|
252
|
+
"""
|
|
253
|
+
INSERT INTO usage_snapshots (provider_id, ts, status, confidence, raw_json)
|
|
254
|
+
VALUES (?, ?, ?, ?, ?)
|
|
255
|
+
""",
|
|
256
|
+
(
|
|
257
|
+
snapshot.provider_id,
|
|
258
|
+
snapshot.checked_at.isoformat(),
|
|
259
|
+
snapshot.status.value if hasattr(snapshot.status, "value") else snapshot.status,
|
|
260
|
+
snapshot.confidence.value if hasattr(snapshot.confidence, "value") else snapshot.confidence,
|
|
261
|
+
snapshot.model_dump_json(),
|
|
262
|
+
),
|
|
263
|
+
)
|
|
264
|
+
|
|
265
|
+
def latest_usage_snapshots(self, provider_id: str | None = None) -> list[CapacitySnapshot]:
|
|
266
|
+
clauses: list[str] = []
|
|
267
|
+
args: list[Any] = []
|
|
268
|
+
if provider_id:
|
|
269
|
+
clauses.append("provider_id = ?")
|
|
270
|
+
args.append(provider_id)
|
|
271
|
+
where = f" WHERE {' AND '.join(clauses)}" if clauses else ""
|
|
272
|
+
with self.connect() as conn:
|
|
273
|
+
rows = conn.execute(
|
|
274
|
+
f"""
|
|
275
|
+
SELECT u.*
|
|
276
|
+
FROM usage_snapshots u
|
|
277
|
+
JOIN (
|
|
278
|
+
SELECT provider_id, MAX(id) AS max_id
|
|
279
|
+
FROM usage_snapshots{where}
|
|
280
|
+
GROUP BY provider_id
|
|
281
|
+
) latest
|
|
282
|
+
ON u.provider_id = latest.provider_id AND u.id = latest.max_id
|
|
283
|
+
ORDER BY u.provider_id ASC
|
|
284
|
+
""",
|
|
285
|
+
args,
|
|
286
|
+
).fetchall()
|
|
287
|
+
return [CapacitySnapshot.model_validate_json(row["raw_json"]) for row in rows]
|
|
288
|
+
|
|
289
|
+
def save_artifact(self, session_id: str, artifact: ArtifactRecord) -> int:
|
|
290
|
+
with self.connect() as conn:
|
|
291
|
+
cursor = conn.execute(
|
|
292
|
+
"""
|
|
293
|
+
INSERT INTO artifacts (session_id, kind, path, sha256, metadata_json, created_at)
|
|
294
|
+
VALUES (?, ?, ?, ?, ?, ?)
|
|
295
|
+
""",
|
|
296
|
+
(
|
|
297
|
+
session_id,
|
|
298
|
+
artifact.kind,
|
|
299
|
+
artifact.path,
|
|
300
|
+
artifact.sha256,
|
|
301
|
+
json.dumps(artifact.metadata),
|
|
302
|
+
utc_now_iso(),
|
|
303
|
+
),
|
|
304
|
+
)
|
|
305
|
+
return int(cursor.lastrowid)
|
|
306
|
+
|
|
307
|
+
def list_artifacts(self, session_id: str) -> list[ArtifactRecord]:
|
|
308
|
+
with self.connect() as conn:
|
|
309
|
+
rows = conn.execute(
|
|
310
|
+
"SELECT * FROM artifacts WHERE session_id = ? ORDER BY id ASC", (session_id,)
|
|
311
|
+
).fetchall()
|
|
312
|
+
return [
|
|
313
|
+
ArtifactRecord(
|
|
314
|
+
kind=row["kind"],
|
|
315
|
+
path=row["path"],
|
|
316
|
+
sha256=row["sha256"],
|
|
317
|
+
metadata=json.loads(row["metadata_json"]),
|
|
318
|
+
)
|
|
319
|
+
for row in rows
|
|
320
|
+
]
|
|
321
|
+
|
|
322
|
+
def acquire_file_lease(
|
|
323
|
+
self,
|
|
324
|
+
session_id: str,
|
|
325
|
+
repo_path: str,
|
|
326
|
+
file_path: str,
|
|
327
|
+
mode: str = "write",
|
|
328
|
+
expires_at: str | None = None,
|
|
329
|
+
metadata: dict[str, Any] | None = None,
|
|
330
|
+
) -> FileLease:
|
|
331
|
+
now = utc_now_iso()
|
|
332
|
+
with self.connect() as conn:
|
|
333
|
+
existing = conn.execute(
|
|
334
|
+
"""
|
|
335
|
+
SELECT * FROM file_leases
|
|
336
|
+
WHERE repo_path = ?
|
|
337
|
+
AND file_path = ?
|
|
338
|
+
AND released_at IS NULL
|
|
339
|
+
AND (expires_at IS NULL OR expires_at > ?)
|
|
340
|
+
AND session_id = ?
|
|
341
|
+
ORDER BY id ASC
|
|
342
|
+
LIMIT 1
|
|
343
|
+
""",
|
|
344
|
+
(repo_path, file_path, now, session_id),
|
|
345
|
+
).fetchone()
|
|
346
|
+
if existing:
|
|
347
|
+
return self._row_to_lease(existing)
|
|
348
|
+
conflict = conn.execute(
|
|
349
|
+
"""
|
|
350
|
+
SELECT * FROM file_leases
|
|
351
|
+
WHERE repo_path = ?
|
|
352
|
+
AND file_path = ?
|
|
353
|
+
AND released_at IS NULL
|
|
354
|
+
AND (expires_at IS NULL OR expires_at > ?)
|
|
355
|
+
AND session_id != ?
|
|
356
|
+
ORDER BY id ASC
|
|
357
|
+
LIMIT 1
|
|
358
|
+
""",
|
|
359
|
+
(repo_path, file_path, now, session_id),
|
|
360
|
+
).fetchone()
|
|
361
|
+
if conflict:
|
|
362
|
+
lease = self._row_to_lease(conflict)
|
|
363
|
+
raise ToolError(
|
|
364
|
+
"LEASE_CONFLICT",
|
|
365
|
+
f"File is already leased by session {lease.session_id}.",
|
|
366
|
+
{"file_path": file_path, "lease": lease.model_dump(mode="json")},
|
|
367
|
+
)
|
|
368
|
+
cursor = conn.execute(
|
|
369
|
+
"""
|
|
370
|
+
INSERT INTO file_leases (
|
|
371
|
+
session_id, repo_path, file_path, mode, expires_at, created_at, released_at, metadata_json
|
|
372
|
+
) VALUES (?, ?, ?, ?, ?, ?, NULL, ?)
|
|
373
|
+
""",
|
|
374
|
+
(
|
|
375
|
+
session_id,
|
|
376
|
+
repo_path,
|
|
377
|
+
file_path,
|
|
378
|
+
mode,
|
|
379
|
+
expires_at,
|
|
380
|
+
now,
|
|
381
|
+
json.dumps(metadata or {}),
|
|
382
|
+
),
|
|
383
|
+
)
|
|
384
|
+
row = conn.execute("SELECT * FROM file_leases WHERE id = ?", (cursor.lastrowid,)).fetchone()
|
|
385
|
+
return self._row_to_lease(row)
|
|
386
|
+
|
|
387
|
+
def list_file_leases(
|
|
388
|
+
self,
|
|
389
|
+
session_id: str | None = None,
|
|
390
|
+
repo_path: str | None = None,
|
|
391
|
+
active_only: bool = True,
|
|
392
|
+
) -> list[FileLease]:
|
|
393
|
+
clauses: list[str] = []
|
|
394
|
+
args: list[Any] = []
|
|
395
|
+
if session_id:
|
|
396
|
+
clauses.append("session_id = ?")
|
|
397
|
+
args.append(session_id)
|
|
398
|
+
if repo_path:
|
|
399
|
+
clauses.append("repo_path = ?")
|
|
400
|
+
args.append(repo_path)
|
|
401
|
+
if active_only:
|
|
402
|
+
clauses.append("released_at IS NULL")
|
|
403
|
+
clauses.append("(expires_at IS NULL OR expires_at > ?)")
|
|
404
|
+
args.append(utc_now_iso())
|
|
405
|
+
where = f" WHERE {' AND '.join(clauses)}" if clauses else ""
|
|
406
|
+
with self.connect() as conn:
|
|
407
|
+
rows = conn.execute(f"SELECT * FROM file_leases{where} ORDER BY id ASC", args).fetchall()
|
|
408
|
+
return [self._row_to_lease(row) for row in rows]
|
|
409
|
+
|
|
410
|
+
def release_file_lease(
|
|
411
|
+
self,
|
|
412
|
+
lease_id: int | None = None,
|
|
413
|
+
session_id: str | None = None,
|
|
414
|
+
file_path: str | None = None,
|
|
415
|
+
) -> int:
|
|
416
|
+
clauses: list[str] = ["released_at IS NULL"]
|
|
417
|
+
args: list[Any] = []
|
|
418
|
+
if lease_id is not None:
|
|
419
|
+
clauses.append("id = ?")
|
|
420
|
+
args.append(lease_id)
|
|
421
|
+
if session_id:
|
|
422
|
+
clauses.append("session_id = ?")
|
|
423
|
+
args.append(session_id)
|
|
424
|
+
if file_path:
|
|
425
|
+
clauses.append("file_path = ?")
|
|
426
|
+
args.append(file_path)
|
|
427
|
+
if lease_id is None and not session_id:
|
|
428
|
+
raise ValueError("release_file_lease requires lease_id or session_id")
|
|
429
|
+
args.append(utc_now_iso())
|
|
430
|
+
with self.connect() as conn:
|
|
431
|
+
cursor = conn.execute(
|
|
432
|
+
f"UPDATE file_leases SET released_at = ? WHERE {' AND '.join(clauses)}",
|
|
433
|
+
[args[-1], *args[:-1]],
|
|
434
|
+
)
|
|
435
|
+
return cursor.rowcount
|
|
436
|
+
|
|
437
|
+
def _row_to_session(self, row: sqlite3.Row) -> AgentSession:
|
|
438
|
+
tmux = None
|
|
439
|
+
if row["tmux_session"]:
|
|
440
|
+
tmux = TmuxSessionRef(
|
|
441
|
+
session_name=row["tmux_session"],
|
|
442
|
+
window=row["tmux_window"] or "0",
|
|
443
|
+
pane=row["tmux_pane"] or "0",
|
|
444
|
+
)
|
|
445
|
+
return AgentSession(
|
|
446
|
+
id=row["id"],
|
|
447
|
+
provider_id=row["provider_id"],
|
|
448
|
+
model=row["model"],
|
|
449
|
+
harness=row["harness"],
|
|
450
|
+
account=row["account"],
|
|
451
|
+
role=row["role"],
|
|
452
|
+
task=row["task"],
|
|
453
|
+
repo_path=row["repo_path"],
|
|
454
|
+
worktree_path=row["worktree_path"],
|
|
455
|
+
runtime=row["runtime"],
|
|
456
|
+
state=row["state"],
|
|
457
|
+
created_at=row["created_at"],
|
|
458
|
+
updated_at=row["updated_at"],
|
|
459
|
+
ended_at=row["ended_at"],
|
|
460
|
+
tmux=tmux,
|
|
461
|
+
artifact_dir=row["artifact_dir"],
|
|
462
|
+
transcript_path=row["transcript_path"],
|
|
463
|
+
events_path=row["events_path"],
|
|
464
|
+
metadata=json.loads(row["metadata_json"]),
|
|
465
|
+
)
|
|
466
|
+
|
|
467
|
+
def _row_to_lease(self, row: sqlite3.Row) -> FileLease:
|
|
468
|
+
return FileLease(
|
|
469
|
+
id=row["id"],
|
|
470
|
+
session_id=row["session_id"],
|
|
471
|
+
repo_path=row["repo_path"],
|
|
472
|
+
file_path=row["file_path"],
|
|
473
|
+
mode=row["mode"],
|
|
474
|
+
expires_at=row["expires_at"],
|
|
475
|
+
created_at=row["created_at"],
|
|
476
|
+
released_at=row["released_at"],
|
|
477
|
+
metadata=json.loads(row["metadata_json"]),
|
|
478
|
+
)
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Usage snapshot helpers."""
|
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import os
|
|
5
|
+
import subprocess
|
|
6
|
+
import tempfile
|
|
7
|
+
import time
|
|
8
|
+
import urllib.error
|
|
9
|
+
import urllib.request
|
|
10
|
+
from collections.abc import Callable
|
|
11
|
+
from datetime import UTC, datetime
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from typing import Any
|
|
14
|
+
|
|
15
|
+
from agentpool.models import CapacitySnapshot, Confidence, TmuxSessionRef, UsageStatus, UsageWindow, UsageWindowKind
|
|
16
|
+
from agentpool.runtimes.tmux import TmuxRuntime
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class ProbeError(Exception):
|
|
20
|
+
pass
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def unavailable(provider_id: str, warning: str) -> CapacitySnapshot:
|
|
24
|
+
return CapacitySnapshot(
|
|
25
|
+
provider_id=provider_id,
|
|
26
|
+
status=UsageStatus.UNAVAILABLE,
|
|
27
|
+
confidence=Confidence.UNKNOWN,
|
|
28
|
+
warnings=[warning],
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def unknown(provider_id: str, warning: str, source: str) -> CapacitySnapshot:
|
|
33
|
+
return CapacitySnapshot(
|
|
34
|
+
provider_id=provider_id,
|
|
35
|
+
status=UsageStatus.UNKNOWN,
|
|
36
|
+
confidence=Confidence.UNKNOWN,
|
|
37
|
+
warnings=[warning],
|
|
38
|
+
raw={"source": source},
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _extract_json_payload(text: str) -> Any:
|
|
43
|
+
decoder = json.JSONDecoder()
|
|
44
|
+
errors: list[str] = []
|
|
45
|
+
for index, char in enumerate(text):
|
|
46
|
+
if char not in "{[":
|
|
47
|
+
continue
|
|
48
|
+
try:
|
|
49
|
+
payload, _ = decoder.raw_decode(text[index:])
|
|
50
|
+
return payload
|
|
51
|
+
except json.JSONDecodeError as exc:
|
|
52
|
+
errors.append(str(exc))
|
|
53
|
+
raise ProbeError("No JSON payload found." if not errors else f"No parseable JSON payload found: {errors[-1]}")
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _request_json(request: urllib.request.Request) -> dict[str, Any]:
|
|
57
|
+
try:
|
|
58
|
+
with urllib.request.urlopen(request, timeout=10) as response:
|
|
59
|
+
data = response.read()
|
|
60
|
+
except urllib.error.HTTPError as exc:
|
|
61
|
+
body = exc.read().decode("utf-8", errors="replace")[:500]
|
|
62
|
+
raise ProbeError(f"HTTP {exc.code}: {body}") from exc
|
|
63
|
+
except urllib.error.URLError as exc:
|
|
64
|
+
raise ProbeError(str(exc.reason)) from exc
|
|
65
|
+
try:
|
|
66
|
+
payload = json.loads(data.decode("utf-8"))
|
|
67
|
+
except json.JSONDecodeError as exc:
|
|
68
|
+
raise ProbeError(f"Invalid JSON response: {exc}") from exc
|
|
69
|
+
if not isinstance(payload, dict):
|
|
70
|
+
raise ProbeError("JSON response was not an object.")
|
|
71
|
+
return payload
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _tmux_slash_usage_probe(
|
|
75
|
+
provider_id: str,
|
|
76
|
+
command: list[str],
|
|
77
|
+
slash_command: str,
|
|
78
|
+
parser: Callable[[str, str], CapacitySnapshot | None],
|
|
79
|
+
source: str,
|
|
80
|
+
startup_delay: float,
|
|
81
|
+
timeout: float,
|
|
82
|
+
pre_keys: list[list[str]] | None = None,
|
|
83
|
+
extra_keys_after_match: list[list[str]] | None = None,
|
|
84
|
+
prefer_text: str | None = None,
|
|
85
|
+
) -> CapacitySnapshot:
|
|
86
|
+
runtime = TmuxRuntime()
|
|
87
|
+
session_name = f"agentpool-usage-{provider_id.replace('-', '')}-{os.getpid()}-{int(time.time() * 1000) % 100000}"
|
|
88
|
+
with tempfile.TemporaryDirectory(prefix="agentpool-usage-") as tmp:
|
|
89
|
+
ref: TmuxSessionRef | None = None
|
|
90
|
+
try:
|
|
91
|
+
ref = runtime.spawn(command, Path(tmp), {}, session_name)
|
|
92
|
+
time.sleep(startup_delay)
|
|
93
|
+
for keys in pre_keys or []:
|
|
94
|
+
runtime.send_keys(ref, keys)
|
|
95
|
+
time.sleep(0.5)
|
|
96
|
+
runtime.send_message(ref, slash_command, submit=True)
|
|
97
|
+
deadline = time.monotonic() + timeout
|
|
98
|
+
latest = ""
|
|
99
|
+
captures: list[str] = []
|
|
100
|
+
fallback_snapshot: CapacitySnapshot | None = None
|
|
101
|
+
while time.monotonic() < deadline:
|
|
102
|
+
latest = runtime.capture(ref, 260)
|
|
103
|
+
captures.append(latest)
|
|
104
|
+
joined = "\n".join(captures)
|
|
105
|
+
snapshot = parser(provider_id, joined)
|
|
106
|
+
if snapshot:
|
|
107
|
+
if prefer_text and prefer_text not in joined:
|
|
108
|
+
fallback_snapshot = snapshot
|
|
109
|
+
time.sleep(0.75)
|
|
110
|
+
continue
|
|
111
|
+
for keys in extra_keys_after_match or []:
|
|
112
|
+
time.sleep(1.0)
|
|
113
|
+
runtime.send_keys(ref, keys)
|
|
114
|
+
time.sleep(0.8)
|
|
115
|
+
captures.append(runtime.capture(ref, 260))
|
|
116
|
+
if extra_keys_after_match:
|
|
117
|
+
enriched = parser(provider_id, "\n".join(captures))
|
|
118
|
+
if enriched:
|
|
119
|
+
snapshot = enriched
|
|
120
|
+
snapshot.raw["source"] = source
|
|
121
|
+
return snapshot
|
|
122
|
+
time.sleep(0.75)
|
|
123
|
+
if fallback_snapshot:
|
|
124
|
+
fallback_snapshot.raw["source"] = source
|
|
125
|
+
fallback_snapshot.warnings.append(f"Returned fallback before seeing `{prefer_text}`.")
|
|
126
|
+
return fallback_snapshot
|
|
127
|
+
return unknown(
|
|
128
|
+
provider_id,
|
|
129
|
+
f"{slash_command} did not yield parseable usage within {int(timeout)}s.",
|
|
130
|
+
source=source,
|
|
131
|
+
)
|
|
132
|
+
except Exception as exc:
|
|
133
|
+
return unknown(provider_id, f"Interactive usage probe failed: {exc}", source=source)
|
|
134
|
+
finally:
|
|
135
|
+
if ref and runtime.exists(ref):
|
|
136
|
+
runtime.terminate(ref)
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def _duration_window_kind(duration_mins: int | None) -> UsageWindowKind:
|
|
140
|
+
if duration_mins == 300:
|
|
141
|
+
return UsageWindowKind.FIVE_HOUR
|
|
142
|
+
if duration_mins == 1440:
|
|
143
|
+
return UsageWindowKind.DAILY
|
|
144
|
+
if duration_mins == 10080:
|
|
145
|
+
return UsageWindowKind.WEEKLY
|
|
146
|
+
if duration_mins and 27 * 1440 <= duration_mins <= 32 * 1440:
|
|
147
|
+
return UsageWindowKind.MONTHLY
|
|
148
|
+
return UsageWindowKind.UNKNOWN
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def _status_from_windows(windows: list[UsageWindow]) -> UsageStatus:
|
|
152
|
+
remaining_values = [w.remaining_percent for w in windows if w.remaining_percent is not None]
|
|
153
|
+
if not remaining_values:
|
|
154
|
+
return UsageStatus.UNKNOWN
|
|
155
|
+
remaining = min(remaining_values)
|
|
156
|
+
if remaining <= 0:
|
|
157
|
+
return UsageStatus.LIMIT_REACHED
|
|
158
|
+
if remaining <= 15:
|
|
159
|
+
return UsageStatus.NEAR_LIMIT
|
|
160
|
+
return UsageStatus.AVAILABLE
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def _clamp_percent(value: float) -> float:
|
|
164
|
+
return max(0.0, min(100.0, float(value)))
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def _number(value: object) -> float | None:
|
|
168
|
+
if isinstance(value, bool):
|
|
169
|
+
return None
|
|
170
|
+
if isinstance(value, int | float):
|
|
171
|
+
return float(value)
|
|
172
|
+
if isinstance(value, str):
|
|
173
|
+
try:
|
|
174
|
+
return float(value)
|
|
175
|
+
except ValueError:
|
|
176
|
+
return None
|
|
177
|
+
return None
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def _int_number(value: object) -> int | None:
|
|
181
|
+
number = _number(value)
|
|
182
|
+
return int(number) if number is not None else None
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def _epoch_seconds(value: object) -> datetime | None:
|
|
186
|
+
seconds = _int_number(value)
|
|
187
|
+
if not seconds:
|
|
188
|
+
return None
|
|
189
|
+
return datetime.fromtimestamp(seconds, tz=UTC)
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def _parse_datetime(value: object) -> datetime | None:
|
|
193
|
+
if not isinstance(value, str) or not value:
|
|
194
|
+
return None
|
|
195
|
+
try:
|
|
196
|
+
return datetime.fromisoformat(value.replace("Z", "+00:00"))
|
|
197
|
+
except ValueError:
|
|
198
|
+
return None
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def _clean_optional_string(value: object) -> str | None:
|
|
202
|
+
if not isinstance(value, str):
|
|
203
|
+
return None
|
|
204
|
+
cleaned = value.strip()
|
|
205
|
+
return cleaned or None
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
def _terminate_process(proc: subprocess.Popen[str]) -> None:
|
|
209
|
+
if proc.poll() is None:
|
|
210
|
+
proc.terminate()
|
|
211
|
+
try:
|
|
212
|
+
proc.wait(timeout=1)
|
|
213
|
+
except subprocess.TimeoutExpired:
|
|
214
|
+
proc.kill()
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def _safe_read_pipe(pipe: Any) -> str:
|
|
218
|
+
if pipe is None:
|
|
219
|
+
return ""
|
|
220
|
+
try:
|
|
221
|
+
return pipe.read()[:1000]
|
|
222
|
+
except Exception:
|
|
223
|
+
return ""
|