spooling 0.1.1__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.
- spooling/__init__.py +2 -0
- spooling/agent.py +213 -0
- spooling/classifiers.py +147 -0
- spooling/cli.py +522 -0
- spooling/cloud.py +768 -0
- spooling/config.py +44 -0
- spooling/db.py +21 -0
- spooling/embeddings.py +60 -0
- spooling/evals.py +611 -0
- spooling/experiments.py +407 -0
- spooling/ingest.py +496 -0
- spooling/mcp_server.py +312 -0
- spooling/parser.py +614 -0
- spooling/pricing.py +307 -0
- spooling/providers/__init__.py +46 -0
- spooling/providers/antigravity.py +312 -0
- spooling/providers/base.py +166 -0
- spooling/providers/codex.py +230 -0
- spooling/providers/copilot.py +294 -0
- spooling/providers/cortex_code.py +234 -0
- spooling/providers/cursor.py +307 -0
- spooling/providers/gemini.py +476 -0
- spooling/providers/github.py +241 -0
- spooling/providers/gitlab.py +186 -0
- spooling/providers/kiro.py +240 -0
- spooling/providers/opencode.py +282 -0
- spooling/providers/session_file.py +36 -0
- spooling/providers/windsurf.py +355 -0
- spooling/redact.py +284 -0
- spooling/remote_otel.py +257 -0
- spooling/sdk.py +364 -0
- spooling/search.py +68 -0
- spooling/server.py +1291 -0
- spooling/stats.py +180 -0
- spooling/subscription_pricing.py +131 -0
- spooling/tracing.py +451 -0
- spooling/watcher.py +125 -0
- spooling-0.1.1.dist-info/METADATA +28 -0
- spooling-0.1.1.dist-info/RECORD +43 -0
- spooling-0.1.1.dist-info/WHEEL +5 -0
- spooling-0.1.1.dist-info/entry_points.txt +2 -0
- spooling-0.1.1.dist-info/licenses/LICENSE +21 -0
- spooling-0.1.1.dist-info/top_level.txt +1 -0
spooling/cloud.py
ADDED
|
@@ -0,0 +1,768 @@
|
|
|
1
|
+
"""Spooling Cloud: push local sessions up to api.spooling.ai."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import os
|
|
5
|
+
import re
|
|
6
|
+
import signal
|
|
7
|
+
import time
|
|
8
|
+
from datetime import datetime, timedelta, timezone
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
import click
|
|
12
|
+
import httpx
|
|
13
|
+
from rich.console import Console
|
|
14
|
+
|
|
15
|
+
from spooling.db import get_connection
|
|
16
|
+
from spooling.redact import redact_messages, redact_traces
|
|
17
|
+
|
|
18
|
+
console = Console()
|
|
19
|
+
|
|
20
|
+
DEFAULT_API = "https://api.spooling.ai"
|
|
21
|
+
|
|
22
|
+
_MAX_TITLE = 4096
|
|
23
|
+
_MAX_SPAN_NAME = 512
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _clean_str(raw: str | None, max_len: int) -> str | None:
|
|
27
|
+
if not raw:
|
|
28
|
+
return raw
|
|
29
|
+
# Strip HTML tags (values occasionally capture raw HTML from page content)
|
|
30
|
+
cleaned = re.sub(r"<[^>]+>", " ", raw)
|
|
31
|
+
cleaned = " ".join(cleaned.split())
|
|
32
|
+
return cleaned[:max_len] or None
|
|
33
|
+
CONFIG_PATH = Path.home() / ".config" / "spooling" / "cloud.json"
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _load_config() -> dict:
|
|
37
|
+
if not CONFIG_PATH.exists():
|
|
38
|
+
return {}
|
|
39
|
+
try:
|
|
40
|
+
return json.loads(CONFIG_PATH.read_text())
|
|
41
|
+
except Exception:
|
|
42
|
+
return {}
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _save_config(cfg: dict) -> None:
|
|
46
|
+
CONFIG_PATH.parent.mkdir(parents=True, exist_ok=True)
|
|
47
|
+
CONFIG_PATH.write_text(json.dumps(cfg, indent=2))
|
|
48
|
+
os.chmod(CONFIG_PATH, 0o600)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _auth_headers() -> dict:
|
|
52
|
+
cfg = _load_config()
|
|
53
|
+
key = cfg.get("api_key") or os.environ.get("SPOOLING_CLOUD_API_KEY")
|
|
54
|
+
if not key:
|
|
55
|
+
raise click.ClickException("Not logged in. Run `spooling cloud login --key sk_...` first.")
|
|
56
|
+
return {"Authorization": f"Bearer {key}"}
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _api_base() -> str:
|
|
60
|
+
cfg = _load_config()
|
|
61
|
+
return cfg.get("api_url") or os.environ.get("SPOOLING_CLOUD_URL") or DEFAULT_API
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
@click.group()
|
|
65
|
+
def cloud():
|
|
66
|
+
"""Spooling Cloud: push local sessions to api.spooling.ai."""
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
@cloud.command("login")
|
|
70
|
+
@click.option("--key", required=True, help="API key minted at app.spooling.ai/settings/api-keys")
|
|
71
|
+
@click.option("--api-url", default=None, help=f"Override API base (default {DEFAULT_API})")
|
|
72
|
+
def cloud_login(key: str, api_url: str | None):
|
|
73
|
+
"""Store a Spooling Cloud API key in ~/.config/spooling/cloud.json."""
|
|
74
|
+
cfg = _load_config()
|
|
75
|
+
cfg["api_key"] = key.strip()
|
|
76
|
+
if api_url:
|
|
77
|
+
cfg["api_url"] = api_url.rstrip("/")
|
|
78
|
+
_save_config(cfg)
|
|
79
|
+
|
|
80
|
+
base = cfg.get("api_url") or DEFAULT_API
|
|
81
|
+
try:
|
|
82
|
+
r = httpx.get(f"{base}/v1/stats", headers={"Authorization": f"Bearer {cfg['api_key']}"}, timeout=10)
|
|
83
|
+
r.raise_for_status()
|
|
84
|
+
stats = r.json()
|
|
85
|
+
console.print(f"[green]Logged in to {base}[/green]")
|
|
86
|
+
console.print(f" sessions in cloud: [bold]{stats.get('sessions', 0)}[/bold]")
|
|
87
|
+
except Exception as e:
|
|
88
|
+
console.print(f"[red]Saved key, but /v1/stats check failed: {e}[/red]")
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
@cloud.command("status")
|
|
92
|
+
def cloud_status():
|
|
93
|
+
"""Show current login + cloud stats."""
|
|
94
|
+
cfg = _load_config()
|
|
95
|
+
if not cfg.get("api_key"):
|
|
96
|
+
console.print("[yellow]Not logged in.[/yellow] Run `spooling cloud login --key sk_...`.")
|
|
97
|
+
return
|
|
98
|
+
base = _api_base()
|
|
99
|
+
try:
|
|
100
|
+
r = httpx.get(f"{base}/v1/stats", headers=_auth_headers(), timeout=10)
|
|
101
|
+
r.raise_for_status()
|
|
102
|
+
s = r.json()
|
|
103
|
+
console.print(f"API: [cyan]{base}[/cyan]")
|
|
104
|
+
console.print(f" sessions: [bold]{s.get('sessions', 0)}[/bold]")
|
|
105
|
+
console.print(f" messages: [bold]{s.get('messages', 0)}[/bold]")
|
|
106
|
+
console.print(f" providers: [bold]{s.get('providers', 0)}[/bold]")
|
|
107
|
+
console.print(f" cost: [bold]${s.get('cost', 0):.2f}[/bold]")
|
|
108
|
+
except Exception as e:
|
|
109
|
+
console.print(f"[red]Error: {e}[/red]")
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
@cloud.command("logout")
|
|
113
|
+
def cloud_logout():
|
|
114
|
+
"""Remove the stored API key."""
|
|
115
|
+
cfg = _load_config()
|
|
116
|
+
cfg.pop("api_key", None)
|
|
117
|
+
_save_config(cfg)
|
|
118
|
+
console.print("[green]Logged out.[/green]")
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def _collect_sessions(
|
|
122
|
+
limit: int,
|
|
123
|
+
since: datetime | None,
|
|
124
|
+
project: str | None = None,
|
|
125
|
+
cwd_substr: str | None = None,
|
|
126
|
+
title_substr: str | None = None,
|
|
127
|
+
) -> list[dict]:
|
|
128
|
+
"""Read up to `limit` sessions newer than `since` from the local DB.
|
|
129
|
+
|
|
130
|
+
Filters (all case-insensitive, all optional):
|
|
131
|
+
``project`` session's project column equals this exactly
|
|
132
|
+
``cwd_substr`` session's cwd contains this substring
|
|
133
|
+
``title_substr`` session's title contains this substring (useful when
|
|
134
|
+
the agent was launched from the home dir so the cwd
|
|
135
|
+
is generic but the project name is in the title)
|
|
136
|
+
"""
|
|
137
|
+
conn = get_connection()
|
|
138
|
+
try:
|
|
139
|
+
clauses: list[str] = []
|
|
140
|
+
params: list = []
|
|
141
|
+
if since is not None:
|
|
142
|
+
clauses.append("(started_at IS NULL OR started_at >= %s)")
|
|
143
|
+
params.append(since)
|
|
144
|
+
if project is not None:
|
|
145
|
+
clauses.append("LOWER(project) = LOWER(%s)")
|
|
146
|
+
params.append(project)
|
|
147
|
+
if cwd_substr is not None:
|
|
148
|
+
clauses.append("cwd ILIKE %s")
|
|
149
|
+
params.append(f"%{cwd_substr}%")
|
|
150
|
+
if title_substr is not None:
|
|
151
|
+
clauses.append("title ILIKE %s")
|
|
152
|
+
params.append(f"%{title_substr}%")
|
|
153
|
+
where = f"WHERE {' AND '.join(clauses)}" if clauses else ""
|
|
154
|
+
params.append(limit)
|
|
155
|
+
rows = conn.execute(
|
|
156
|
+
f"""SELECT id, provider_id, project, title, cwd, started_at, ended_at,
|
|
157
|
+
message_count, tool_call_count,
|
|
158
|
+
estimated_input_tokens, estimated_output_tokens, estimated_cost_usd,
|
|
159
|
+
model
|
|
160
|
+
FROM sessions
|
|
161
|
+
{where}
|
|
162
|
+
ORDER BY started_at DESC NULLS LAST
|
|
163
|
+
LIMIT %s""",
|
|
164
|
+
tuple(params),
|
|
165
|
+
).fetchall()
|
|
166
|
+
sessions = []
|
|
167
|
+
for r in rows:
|
|
168
|
+
sid = r["id"]
|
|
169
|
+
msgs = conn.execute(
|
|
170
|
+
"""SELECT role, content, timestamp,
|
|
171
|
+
ROW_NUMBER() OVER (ORDER BY timestamp NULLS LAST, id) - 1 AS seq
|
|
172
|
+
FROM messages WHERE session_id = %s
|
|
173
|
+
ORDER BY timestamp NULLS LAST, id""",
|
|
174
|
+
(sid,),
|
|
175
|
+
).fetchall()
|
|
176
|
+
sessions.append({
|
|
177
|
+
"id": sid,
|
|
178
|
+
"provider_id": r["provider_id"],
|
|
179
|
+
"project": r["project"],
|
|
180
|
+
"title": _clean_str(r["title"], _MAX_TITLE),
|
|
181
|
+
"cwd": r["cwd"],
|
|
182
|
+
"started_at": r["started_at"].isoformat() if r["started_at"] else None,
|
|
183
|
+
"ended_at": r["ended_at"].isoformat() if r["ended_at"] else None,
|
|
184
|
+
"message_count": r["message_count"] or 0,
|
|
185
|
+
"tool_call_count": r["tool_call_count"] or 0,
|
|
186
|
+
"input_tokens": r["estimated_input_tokens"] or 0,
|
|
187
|
+
"output_tokens": r["estimated_output_tokens"] or 0,
|
|
188
|
+
"estimated_cost_usd": float(r["estimated_cost_usd"] or 0),
|
|
189
|
+
"model": r["model"],
|
|
190
|
+
"messages": [
|
|
191
|
+
{
|
|
192
|
+
"role": m["role"],
|
|
193
|
+
"content": (m["content"] or "")[:20000],
|
|
194
|
+
"sequence": int(m["seq"]),
|
|
195
|
+
"timestamp": m["timestamp"].isoformat() if m["timestamp"] else None,
|
|
196
|
+
}
|
|
197
|
+
for m in msgs
|
|
198
|
+
],
|
|
199
|
+
})
|
|
200
|
+
return sessions
|
|
201
|
+
finally:
|
|
202
|
+
conn.close()
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def _collect_traces(session_ids: list[str]) -> list[dict]:
|
|
206
|
+
"""Pull traces + spans + span_events for a list of session_ids.
|
|
207
|
+
|
|
208
|
+
Each trace becomes a JSON-serializable dict ready for the
|
|
209
|
+
/v1/traces/batch endpoint. Spans are sorted by sequence so the
|
|
210
|
+
server's parent-before-child invariant holds, and span_events
|
|
211
|
+
ride along on each span.
|
|
212
|
+
"""
|
|
213
|
+
if not session_ids:
|
|
214
|
+
return []
|
|
215
|
+
conn = get_connection()
|
|
216
|
+
try:
|
|
217
|
+
traces = conn.execute(
|
|
218
|
+
"""SELECT id, session_id, provider_id, project, title,
|
|
219
|
+
started_at, ended_at, duration_ms,
|
|
220
|
+
span_count, agent_count, tool_count, llm_count, error_count,
|
|
221
|
+
total_input_tokens, total_output_tokens,
|
|
222
|
+
total_cache_read_tokens, total_cache_write_tokens,
|
|
223
|
+
total_cost_usd, cwd, git_branch, model,
|
|
224
|
+
vendor_count, top_vendors, attrs
|
|
225
|
+
FROM traces WHERE session_id = ANY(%s)""",
|
|
226
|
+
(session_ids,),
|
|
227
|
+
).fetchall()
|
|
228
|
+
|
|
229
|
+
out: list[dict] = []
|
|
230
|
+
for t in traces:
|
|
231
|
+
tid = t["id"]
|
|
232
|
+
spans = conn.execute(
|
|
233
|
+
"""SELECT id, parent_id, kind, name, status,
|
|
234
|
+
started_at, ended_at, duration_ms, depth, sequence,
|
|
235
|
+
input_tokens, output_tokens, cache_read_tokens, cache_write_tokens,
|
|
236
|
+
cost_usd, model, tool_name, tool_input, tool_output, tool_is_error,
|
|
237
|
+
agent_type, agent_prompt, vendor, category, attrs
|
|
238
|
+
FROM spans WHERE trace_id = %s ORDER BY sequence""",
|
|
239
|
+
(tid,),
|
|
240
|
+
).fetchall()
|
|
241
|
+
|
|
242
|
+
spans_payload: list[dict] = []
|
|
243
|
+
for s in spans:
|
|
244
|
+
events = conn.execute(
|
|
245
|
+
"""SELECT name, timestamp, attrs
|
|
246
|
+
FROM span_events WHERE span_id = %s ORDER BY id""",
|
|
247
|
+
(s["id"],),
|
|
248
|
+
).fetchall()
|
|
249
|
+
spans_payload.append({
|
|
250
|
+
"id": s["id"],
|
|
251
|
+
"parent_id": s["parent_id"],
|
|
252
|
+
"kind": s["kind"],
|
|
253
|
+
"name": _clean_str(s["name"], _MAX_SPAN_NAME),
|
|
254
|
+
"status": s["status"] or "ok",
|
|
255
|
+
"started_at": s["started_at"].isoformat() if s["started_at"] else None,
|
|
256
|
+
"ended_at": s["ended_at"].isoformat() if s["ended_at"] else None,
|
|
257
|
+
"duration_ms": int(s["duration_ms"] or 0),
|
|
258
|
+
"depth": int(s["depth"] or 0),
|
|
259
|
+
"sequence": int(s["sequence"] or 0),
|
|
260
|
+
"input_tokens": int(s["input_tokens"] or 0),
|
|
261
|
+
"output_tokens": int(s["output_tokens"] or 0),
|
|
262
|
+
"cache_read_tokens": int(s["cache_read_tokens"] or 0),
|
|
263
|
+
"cache_write_tokens": int(s["cache_write_tokens"] or 0),
|
|
264
|
+
"cost_usd": float(s["cost_usd"] or 0),
|
|
265
|
+
"model": s["model"],
|
|
266
|
+
"tool_name": s["tool_name"],
|
|
267
|
+
"tool_input": s["tool_input"],
|
|
268
|
+
"tool_output": (s["tool_output"] or "")[:200000] if s["tool_output"] else None,
|
|
269
|
+
"tool_is_error": bool(s["tool_is_error"]),
|
|
270
|
+
"agent_type": s["agent_type"],
|
|
271
|
+
"agent_prompt": (s["agent_prompt"] or "")[:200000] if s["agent_prompt"] else None,
|
|
272
|
+
"vendor": s["vendor"],
|
|
273
|
+
"category": s["category"],
|
|
274
|
+
"attrs": s["attrs"] or {},
|
|
275
|
+
"events": [
|
|
276
|
+
{
|
|
277
|
+
"name": e["name"],
|
|
278
|
+
"timestamp": e["timestamp"].isoformat() if e["timestamp"] else None,
|
|
279
|
+
"attrs": e["attrs"] or {},
|
|
280
|
+
}
|
|
281
|
+
for e in events
|
|
282
|
+
],
|
|
283
|
+
})
|
|
284
|
+
|
|
285
|
+
out.append({
|
|
286
|
+
"id": tid,
|
|
287
|
+
"session_id": t["session_id"],
|
|
288
|
+
"provider_id": t["provider_id"],
|
|
289
|
+
"project": t["project"],
|
|
290
|
+
"title": _clean_str(t["title"], _MAX_TITLE),
|
|
291
|
+
"started_at": t["started_at"].isoformat() if t["started_at"] else None,
|
|
292
|
+
"ended_at": t["ended_at"].isoformat() if t["ended_at"] else None,
|
|
293
|
+
"duration_ms": int(t["duration_ms"] or 0),
|
|
294
|
+
"span_count": int(t["span_count"] or 0),
|
|
295
|
+
"agent_count": int(t["agent_count"] or 0),
|
|
296
|
+
"tool_count": int(t["tool_count"] or 0),
|
|
297
|
+
"llm_count": int(t["llm_count"] or 0),
|
|
298
|
+
"error_count": int(t["error_count"] or 0),
|
|
299
|
+
"total_input_tokens": int(t["total_input_tokens"] or 0),
|
|
300
|
+
"total_output_tokens": int(t["total_output_tokens"] or 0),
|
|
301
|
+
"total_cache_read_tokens": int(t["total_cache_read_tokens"] or 0),
|
|
302
|
+
"total_cache_write_tokens": int(t["total_cache_write_tokens"] or 0),
|
|
303
|
+
"total_cost_usd": float(t["total_cost_usd"] or 0),
|
|
304
|
+
"cwd": t["cwd"],
|
|
305
|
+
"git_branch": t["git_branch"],
|
|
306
|
+
"model": t["model"],
|
|
307
|
+
"vendor_count": int(t["vendor_count"] or 0),
|
|
308
|
+
"top_vendors": t["top_vendors"] or [],
|
|
309
|
+
"attrs": t["attrs"] or {},
|
|
310
|
+
"spans": spans_payload,
|
|
311
|
+
})
|
|
312
|
+
return out
|
|
313
|
+
finally:
|
|
314
|
+
conn.close()
|
|
315
|
+
|
|
316
|
+
|
|
317
|
+
def _push_trace_batches(
|
|
318
|
+
traces: list[dict],
|
|
319
|
+
batch_size: int,
|
|
320
|
+
base: str,
|
|
321
|
+
headers: dict,
|
|
322
|
+
log,
|
|
323
|
+
) -> tuple[int, int, int, str | None]:
|
|
324
|
+
"""POST traces+spans+events to /v1/traces/batch in chunks.
|
|
325
|
+
|
|
326
|
+
Returns ``(accepted, rejected, spans_inserted, error)``. The
|
|
327
|
+
cloud rejects traces whose session_id isn't in this workspace,
|
|
328
|
+
so push sessions FIRST and only push traces for those that
|
|
329
|
+
landed.
|
|
330
|
+
"""
|
|
331
|
+
if not traces:
|
|
332
|
+
return 0, 0, 0, None
|
|
333
|
+
accepted = 0
|
|
334
|
+
rejected = 0
|
|
335
|
+
spans_total = 0
|
|
336
|
+
with httpx.Client(timeout=180) as client:
|
|
337
|
+
for i in range(0, len(traces), batch_size):
|
|
338
|
+
chunk = traces[i:i + batch_size]
|
|
339
|
+
try:
|
|
340
|
+
r = client.post(
|
|
341
|
+
f"{base}/v1/traces/batch",
|
|
342
|
+
headers=headers,
|
|
343
|
+
json={"traces": chunk},
|
|
344
|
+
)
|
|
345
|
+
if r.status_code == 404:
|
|
346
|
+
return (
|
|
347
|
+
accepted,
|
|
348
|
+
rejected,
|
|
349
|
+
spans_total,
|
|
350
|
+
"Cloud doesn't support `/v1/traces/batch` yet (server is older than 2026-04-30). Ask your admin to redeploy.",
|
|
351
|
+
)
|
|
352
|
+
r.raise_for_status()
|
|
353
|
+
data = r.json()
|
|
354
|
+
acc = int(data.get("accepted", 0))
|
|
355
|
+
rej = int(data.get("rejected", 0))
|
|
356
|
+
spans = int(data.get("spans_inserted", 0))
|
|
357
|
+
accepted += acc
|
|
358
|
+
rejected += rej
|
|
359
|
+
spans_total += spans
|
|
360
|
+
if rej:
|
|
361
|
+
log(
|
|
362
|
+
f" pushed {acc} trace(s), {spans} span(s) "
|
|
363
|
+
f"[yellow]rejected {rej}[/yellow]"
|
|
364
|
+
)
|
|
365
|
+
else:
|
|
366
|
+
log(f" pushed {acc} trace(s), {spans} span(s)")
|
|
367
|
+
except httpx.HTTPStatusError as e:
|
|
368
|
+
return (
|
|
369
|
+
accepted,
|
|
370
|
+
rejected,
|
|
371
|
+
spans_total,
|
|
372
|
+
f"HTTP {e.response.status_code}: {e.response.text[:200]}",
|
|
373
|
+
)
|
|
374
|
+
except Exception as e:
|
|
375
|
+
return accepted, rejected, spans_total, str(e)
|
|
376
|
+
return accepted, rejected, spans_total, None
|
|
377
|
+
|
|
378
|
+
|
|
379
|
+
def _push_batches(
|
|
380
|
+
sessions: list[dict],
|
|
381
|
+
batch: int,
|
|
382
|
+
base: str,
|
|
383
|
+
headers: dict,
|
|
384
|
+
log,
|
|
385
|
+
copy: bool = False,
|
|
386
|
+
) -> tuple[int, int, str | None]:
|
|
387
|
+
"""POST sessions to /v1/sessions/batch in chunks.
|
|
388
|
+
|
|
389
|
+
Returns ``(accepted, rejected, error)``. The cloud rejects sessions
|
|
390
|
+
whose IDs are already owned by another workspace; the CLI surfaces
|
|
391
|
+
that count so the user can react (use ``--copy`` to share into the
|
|
392
|
+
new workspace as fresh rows, or ``spooling cloud delete`` to free the
|
|
393
|
+
IDs in the source workspace).
|
|
394
|
+
|
|
395
|
+
When ``copy=True`` the server rewrites session IDs deterministically
|
|
396
|
+
so the same source session can live in multiple workspaces.
|
|
397
|
+
"""
|
|
398
|
+
if not sessions:
|
|
399
|
+
return 0, 0, None
|
|
400
|
+
total = 0
|
|
401
|
+
rejected = 0
|
|
402
|
+
with httpx.Client(timeout=60) as client:
|
|
403
|
+
for i in range(0, len(sessions), batch):
|
|
404
|
+
chunk = sessions[i:i + batch]
|
|
405
|
+
try:
|
|
406
|
+
payload: dict = {"sessions": chunk}
|
|
407
|
+
if copy:
|
|
408
|
+
payload["copy"] = True
|
|
409
|
+
r = client.post(f"{base}/v1/sessions/batch", headers=headers, json=payload)
|
|
410
|
+
r.raise_for_status()
|
|
411
|
+
data = r.json()
|
|
412
|
+
accepted = data.get("accepted", 0)
|
|
413
|
+
rej = data.get("rejected", 0)
|
|
414
|
+
total += accepted
|
|
415
|
+
rejected += rej
|
|
416
|
+
if rej:
|
|
417
|
+
log(f" pushed {accepted} sessions [yellow]rejected {rej}[/yellow]")
|
|
418
|
+
else:
|
|
419
|
+
log(f" pushed {accepted} sessions")
|
|
420
|
+
except httpx.HTTPStatusError as e:
|
|
421
|
+
return total, rejected, f"HTTP {e.response.status_code}: {e.response.text[:200]}"
|
|
422
|
+
except Exception as e:
|
|
423
|
+
return total, rejected, str(e)
|
|
424
|
+
return total, rejected, None
|
|
425
|
+
|
|
426
|
+
|
|
427
|
+
@click.command()
|
|
428
|
+
@click.option("--limit", default=100, help="Max sessions to push per run")
|
|
429
|
+
@click.option("--batch", default=20, help="Sessions per request")
|
|
430
|
+
@click.option(
|
|
431
|
+
"--project",
|
|
432
|
+
default=None,
|
|
433
|
+
help="Only push sessions whose project name matches exactly. Pair with `spooling stats --by project` to discover names.",
|
|
434
|
+
)
|
|
435
|
+
@click.option(
|
|
436
|
+
"--cwd",
|
|
437
|
+
"cwd_substr",
|
|
438
|
+
default=None,
|
|
439
|
+
help="Only push sessions whose working directory contains this substring (case-insensitive).",
|
|
440
|
+
)
|
|
441
|
+
@click.option(
|
|
442
|
+
"--title",
|
|
443
|
+
"title_substr",
|
|
444
|
+
default=None,
|
|
445
|
+
help="Only push sessions whose title contains this substring (case-insensitive). Useful when the agent was launched from the home dir so all sessions share a generic cwd, but the project name is in the title (e.g. `--title islet`).",
|
|
446
|
+
)
|
|
447
|
+
@click.option(
|
|
448
|
+
"--dry-run",
|
|
449
|
+
is_flag=True,
|
|
450
|
+
help="Show which sessions would be pushed and exit. No network call.",
|
|
451
|
+
)
|
|
452
|
+
@click.option(
|
|
453
|
+
"--copy",
|
|
454
|
+
is_flag=True,
|
|
455
|
+
help="Share sessions you've already pushed elsewhere. The cloud writes them as fresh rows in this workspace under deterministically rewritten IDs, so the same source session can live in multiple workspaces. Idempotent on repeat.",
|
|
456
|
+
)
|
|
457
|
+
@click.option(
|
|
458
|
+
"--with-spans",
|
|
459
|
+
"with_spans",
|
|
460
|
+
is_flag=True,
|
|
461
|
+
help="Also push trace + span data (per-LLM-call usage, model, cost, tool boundaries, agent boundaries) for each session. Required for the cloud admin GUI's Traces page to show meaningful data. Not yet compatible with --copy.",
|
|
462
|
+
)
|
|
463
|
+
@click.option(
|
|
464
|
+
"--no-redact",
|
|
465
|
+
"no_redact",
|
|
466
|
+
is_flag=True,
|
|
467
|
+
help="Skip the client-side secret redactor. By default spool scrubs secrets (Snowflake/AWS/GitHub/OpenAI/Anthropic/Stripe tokens, PEM private keys, KEY=VALUE lines whose key looks sensitive) before pushing. Use this only if you're sure the content is clean and you need raw values.",
|
|
468
|
+
)
|
|
469
|
+
def push(limit: int, batch: int, project: str | None, cwd_substr: str | None, title_substr: str | None, dry_run: bool, copy: bool, with_spans: bool, no_redact: bool):
|
|
470
|
+
"""Push local sessions up to Spooling Cloud.
|
|
471
|
+
|
|
472
|
+
Without filters, this pushes the most recent ``--limit`` sessions from
|
|
473
|
+
every project on this laptop into the workspace your CLI is logged
|
|
474
|
+
into. Pass ``--project``, ``--cwd``, or ``--title`` to scope which
|
|
475
|
+
sessions go up. Filters combine with AND.
|
|
476
|
+
|
|
477
|
+
Two scoping patterns:
|
|
478
|
+
|
|
479
|
+
\b
|
|
480
|
+
1. Filtered push from one workspace — clean per-workspace separation:
|
|
481
|
+
spooling cloud login --key sk_<team-key>
|
|
482
|
+
spooling push --cwd toebox
|
|
483
|
+
|
|
484
|
+
\b
|
|
485
|
+
2. Copy share — same sessions live in multiple workspaces:
|
|
486
|
+
spooling cloud login --key sk_<team-key>
|
|
487
|
+
spooling push --cwd toebox --copy
|
|
488
|
+
|
|
489
|
+
\b
|
|
490
|
+
3. When the cwd is generic (e.g. agent launched from ~):
|
|
491
|
+
spooling push --title islet --copy
|
|
492
|
+
"""
|
|
493
|
+
if with_spans and copy:
|
|
494
|
+
console.print(
|
|
495
|
+
"[red]--with-spans is not compatible with --copy yet. "
|
|
496
|
+
"Trace IDs reference span IDs which reference parent span IDs; "
|
|
497
|
+
"remapping all of those for copy mode is a separate change. "
|
|
498
|
+
"Run sessions with --copy first, then re-run without --copy "
|
|
499
|
+
"in the destination workspace to push spans.[/red]"
|
|
500
|
+
)
|
|
501
|
+
return
|
|
502
|
+
headers = _auth_headers()
|
|
503
|
+
base = _api_base()
|
|
504
|
+
sessions = _collect_sessions(
|
|
505
|
+
limit=limit,
|
|
506
|
+
since=None,
|
|
507
|
+
project=project,
|
|
508
|
+
cwd_substr=cwd_substr,
|
|
509
|
+
title_substr=title_substr,
|
|
510
|
+
)
|
|
511
|
+
if not sessions:
|
|
512
|
+
console.print("[yellow]No local sessions match.[/yellow]")
|
|
513
|
+
return
|
|
514
|
+
|
|
515
|
+
# Client-side secret redaction. Default-on; opt-out with --no-redact.
|
|
516
|
+
if not no_redact:
|
|
517
|
+
total_redactions = 0
|
|
518
|
+
sessions_with_hits = 0
|
|
519
|
+
for s in sessions:
|
|
520
|
+
_, n = redact_messages(s.get("messages") or [])
|
|
521
|
+
if n:
|
|
522
|
+
total_redactions += n
|
|
523
|
+
sessions_with_hits += 1
|
|
524
|
+
if total_redactions:
|
|
525
|
+
console.print(
|
|
526
|
+
f"[dim]Redacted {total_redactions} secret(s) across "
|
|
527
|
+
f"{sessions_with_hits} session(s) before push. "
|
|
528
|
+
f"Use [bold]--no-redact[/bold] to disable.[/dim]"
|
|
529
|
+
)
|
|
530
|
+
|
|
531
|
+
if dry_run:
|
|
532
|
+
mode = "copy" if copy else "push"
|
|
533
|
+
if with_spans:
|
|
534
|
+
mode = f"{mode} + spans"
|
|
535
|
+
console.print(f"[dim]Dry run ({mode}): {len(sessions)} session(s) would land in {base}[/dim]")
|
|
536
|
+
for s in sessions[:20]:
|
|
537
|
+
title = (s.get("title") or "(untitled)")[:60]
|
|
538
|
+
console.print(f" [cyan]{s.get('project') or '-'}[/cyan] {title}")
|
|
539
|
+
if len(sessions) > 20:
|
|
540
|
+
console.print(f" ... and {len(sessions) - 20} more")
|
|
541
|
+
if with_spans:
|
|
542
|
+
traces = _collect_traces([s["id"] for s in sessions])
|
|
543
|
+
span_count = sum(len(t.get("spans") or []) for t in traces)
|
|
544
|
+
console.print(
|
|
545
|
+
f"[dim] + {len(traces)} trace(s) with {span_count} span(s) ready[/dim]"
|
|
546
|
+
)
|
|
547
|
+
return
|
|
548
|
+
total, rejected, err = _push_batches(sessions, batch, base, headers, console.print, copy=copy)
|
|
549
|
+
if err:
|
|
550
|
+
console.print(f"[red]{err}[/red]")
|
|
551
|
+
return
|
|
552
|
+
if rejected:
|
|
553
|
+
console.print(
|
|
554
|
+
f"[green]Done.[/green] {total} sessions synced to {base} "
|
|
555
|
+
f"([yellow]{rejected} rejected — IDs already owned by another workspace[/yellow])"
|
|
556
|
+
)
|
|
557
|
+
console.print(
|
|
558
|
+
"[dim]To share these sessions into this workspace too, "
|
|
559
|
+
"re-run with `--copy`. To move them instead (delete from the "
|
|
560
|
+
"other workspace), see `spooling cloud delete --help`.[/dim]"
|
|
561
|
+
)
|
|
562
|
+
else:
|
|
563
|
+
console.print(f"[green]Done.[/green] {total} sessions synced to {base}")
|
|
564
|
+
|
|
565
|
+
if with_spans and total > 0:
|
|
566
|
+
# Push traces only for sessions that landed. Cloud will reject
|
|
567
|
+
# any whose session isn't in this workspace anyway, but pre-
|
|
568
|
+
# filtering keeps the request payload smaller.
|
|
569
|
+
accepted_ids = [s["id"] for s in sessions]
|
|
570
|
+
traces = _collect_traces(accepted_ids)
|
|
571
|
+
if traces and not no_redact:
|
|
572
|
+
_, span_redactions = redact_traces(traces)
|
|
573
|
+
if span_redactions:
|
|
574
|
+
console.print(
|
|
575
|
+
f"[dim]Redacted {span_redactions} secret(s) inside spans "
|
|
576
|
+
f"(tool_input/tool_output/agent_prompt/attrs) before push.[/dim]"
|
|
577
|
+
)
|
|
578
|
+
if not traces:
|
|
579
|
+
console.print(
|
|
580
|
+
"[dim]No traces found for those sessions (likely the "
|
|
581
|
+
"session_id has no rows in the local traces table — "
|
|
582
|
+
"re-run `spooling sync` to regenerate).[/dim]"
|
|
583
|
+
)
|
|
584
|
+
return
|
|
585
|
+
# Smaller per-batch fanout because each trace can carry many spans.
|
|
586
|
+
trace_batch_size = max(1, min(20, batch // 2 or 5))
|
|
587
|
+
console.print(f"[dim]Pushing {len(traces)} trace(s) with spans to {base}…[/dim]")
|
|
588
|
+
t_acc, t_rej, t_spans, t_err = _push_trace_batches(
|
|
589
|
+
traces, trace_batch_size, base, headers, console.print,
|
|
590
|
+
)
|
|
591
|
+
if t_err:
|
|
592
|
+
console.print(f"[yellow]Trace push failed: {t_err}[/yellow]")
|
|
593
|
+
return
|
|
594
|
+
msg = f"[green]Done.[/green] {t_acc} trace(s) and {t_spans} span(s) synced"
|
|
595
|
+
if t_rej:
|
|
596
|
+
msg += f" ([yellow]{t_rej} trace(s) rejected[/yellow])"
|
|
597
|
+
console.print(msg)
|
|
598
|
+
|
|
599
|
+
|
|
600
|
+
@cloud.command("watch")
|
|
601
|
+
@click.option("--interval", default=60, show_default=True, help="Seconds between push cycles")
|
|
602
|
+
@click.option("--limit", default=1000, show_default=True, help="Max sessions per cycle")
|
|
603
|
+
@click.option("--batch", default=20, show_default=True, help="Sessions per request")
|
|
604
|
+
@click.option("--lookback", default=10, show_default=True, help="Minutes to overlap on each cycle to catch updated sessions")
|
|
605
|
+
@click.option(
|
|
606
|
+
"--no-redact",
|
|
607
|
+
"no_redact",
|
|
608
|
+
is_flag=True,
|
|
609
|
+
help="Skip the client-side secret redactor (see `spooling push --help`).",
|
|
610
|
+
)
|
|
611
|
+
def cloud_watch(interval: int, limit: int, batch: int, lookback: int, no_redact: bool):
|
|
612
|
+
"""Continuously push new local sessions to Spooling Cloud (Ctrl+C to stop)."""
|
|
613
|
+
headers = _auth_headers()
|
|
614
|
+
base = _api_base()
|
|
615
|
+
|
|
616
|
+
cfg = _load_config()
|
|
617
|
+
last = cfg.get("last_push_at")
|
|
618
|
+
watermark: datetime | None = datetime.fromisoformat(last) if last else None
|
|
619
|
+
|
|
620
|
+
stop = {"flag": False}
|
|
621
|
+
def _handle(_sig, _frm):
|
|
622
|
+
stop["flag"] = True
|
|
623
|
+
console.print("\n[yellow]Stopping after current cycle…[/yellow]")
|
|
624
|
+
signal.signal(signal.SIGINT, _handle)
|
|
625
|
+
signal.signal(signal.SIGTERM, _handle)
|
|
626
|
+
|
|
627
|
+
console.print(f"[cyan]Watching local sessions → {base}[/cyan]")
|
|
628
|
+
console.print(f" interval: {interval}s · lookback: {lookback}m · starting watermark: {watermark or 'none (full first push)'}")
|
|
629
|
+
|
|
630
|
+
while not stop["flag"]:
|
|
631
|
+
cycle_started = datetime.now(timezone.utc)
|
|
632
|
+
# Re-read each cycle so a manual `spooling cloud login` change is picked up.
|
|
633
|
+
headers = _auth_headers()
|
|
634
|
+
since = (watermark - timedelta(minutes=lookback)) if watermark else None
|
|
635
|
+
|
|
636
|
+
try:
|
|
637
|
+
sessions = _collect_sessions(limit=limit, since=since)
|
|
638
|
+
except Exception as e:
|
|
639
|
+
console.print(f"[red]DB error: {e}[/red]")
|
|
640
|
+
sessions = []
|
|
641
|
+
|
|
642
|
+
if sessions:
|
|
643
|
+
ts = cycle_started.strftime("%H:%M:%S")
|
|
644
|
+
console.print(f"[dim]{ts}[/dim] {len(sessions)} candidate session(s) since {since or 'beginning'}")
|
|
645
|
+
if not no_redact:
|
|
646
|
+
redaction_count = 0
|
|
647
|
+
for s in sessions:
|
|
648
|
+
_, n = redact_messages(s.get("messages") or [])
|
|
649
|
+
redaction_count += n
|
|
650
|
+
if redaction_count:
|
|
651
|
+
console.print(f" [dim]Redacted {redaction_count} secret(s) before push[/dim]")
|
|
652
|
+
total, rejected, err = _push_batches(sessions, batch, base, headers, console.print)
|
|
653
|
+
if err:
|
|
654
|
+
console.print(f"[red]{err}[/red] (will retry next cycle)")
|
|
655
|
+
else:
|
|
656
|
+
# Advance watermark to the cycle start; the lookback window catches
|
|
657
|
+
# sessions whose started_at slid backwards or whose messages were
|
|
658
|
+
# appended after the original started_at.
|
|
659
|
+
watermark = cycle_started
|
|
660
|
+
cfg = _load_config()
|
|
661
|
+
cfg["last_push_at"] = watermark.isoformat()
|
|
662
|
+
_save_config(cfg)
|
|
663
|
+
rej_note = f" · [yellow]{rejected} rejected (cross-workspace)[/yellow]" if rejected else ""
|
|
664
|
+
console.print(f" [green]✓[/green] {total} accepted{rej_note} · watermark → {watermark.strftime('%H:%M:%S')}")
|
|
665
|
+
# else: silent — no new work this cycle.
|
|
666
|
+
|
|
667
|
+
# Sleep in 1s slices so Ctrl+C is responsive even with long intervals.
|
|
668
|
+
slept = 0
|
|
669
|
+
while slept < interval and not stop["flag"]:
|
|
670
|
+
time.sleep(1)
|
|
671
|
+
slept += 1
|
|
672
|
+
|
|
673
|
+
console.print("[green]Stopped.[/green]")
|
|
674
|
+
|
|
675
|
+
|
|
676
|
+
@cloud.command("delete")
|
|
677
|
+
@click.option(
|
|
678
|
+
"--project",
|
|
679
|
+
default=None,
|
|
680
|
+
help="Delete cloud sessions whose project name matches exactly.",
|
|
681
|
+
)
|
|
682
|
+
@click.option(
|
|
683
|
+
"--cwd",
|
|
684
|
+
"cwd_substr",
|
|
685
|
+
default=None,
|
|
686
|
+
help="Delete cloud sessions whose working directory contains this substring.",
|
|
687
|
+
)
|
|
688
|
+
@click.option(
|
|
689
|
+
"--session-id",
|
|
690
|
+
"session_id",
|
|
691
|
+
default=None,
|
|
692
|
+
help="Delete a single cloud session by its id. Use when project/cwd filters can't isolate it (e.g. seed data with project=null).",
|
|
693
|
+
)
|
|
694
|
+
@click.option(
|
|
695
|
+
"--all",
|
|
696
|
+
"delete_all",
|
|
697
|
+
is_flag=True,
|
|
698
|
+
help="Delete every session in the workspace this key authenticates to. Requires --yes.",
|
|
699
|
+
)
|
|
700
|
+
@click.option(
|
|
701
|
+
"--dry-run",
|
|
702
|
+
is_flag=True,
|
|
703
|
+
help="Print which sessions would be deleted and exit. No network mutation.",
|
|
704
|
+
)
|
|
705
|
+
@click.option(
|
|
706
|
+
"--yes",
|
|
707
|
+
is_flag=True,
|
|
708
|
+
help="Skip the confirmation prompt. Required with --all.",
|
|
709
|
+
)
|
|
710
|
+
def cloud_delete(project: str | None, cwd_substr: str | None, session_id: str | None, delete_all: bool, dry_run: bool, yes: bool):
|
|
711
|
+
"""Delete cloud sessions in the workspace this key authenticates to.
|
|
712
|
+
|
|
713
|
+
Use this when a session ID is owned by the wrong workspace (typically
|
|
714
|
+
after pushing personal sessions and then trying to push them again to
|
|
715
|
+
a team workspace). Delete from the wrong workspace, then `spooling push`
|
|
716
|
+
under the right workspace's key.
|
|
717
|
+
|
|
718
|
+
Filters scope what gets deleted. Without filters, --all is required.
|
|
719
|
+
"""
|
|
720
|
+
if not (project or cwd_substr or session_id or delete_all):
|
|
721
|
+
console.print("[red]Pass --project, --cwd, --session-id, or --all.[/red]")
|
|
722
|
+
return
|
|
723
|
+
if delete_all and not yes:
|
|
724
|
+
console.print("[red]--all is destructive. Re-run with --yes to confirm.[/red]")
|
|
725
|
+
return
|
|
726
|
+
|
|
727
|
+
headers = _auth_headers()
|
|
728
|
+
base = _api_base()
|
|
729
|
+
params = {"dry_run": "true" if dry_run else "false"}
|
|
730
|
+
if project:
|
|
731
|
+
params["project"] = project
|
|
732
|
+
if cwd_substr:
|
|
733
|
+
params["cwd_substr"] = cwd_substr
|
|
734
|
+
if session_id:
|
|
735
|
+
params["id"] = session_id
|
|
736
|
+
|
|
737
|
+
try:
|
|
738
|
+
with httpx.Client(timeout=60) as client:
|
|
739
|
+
r = client.delete(f"{base}/v1/sessions", headers=headers, params=params)
|
|
740
|
+
if r.status_code == 404:
|
|
741
|
+
console.print(
|
|
742
|
+
"[red]This Spooling Cloud doesn't support `spooling cloud delete` yet "
|
|
743
|
+
"(server is older than 2026-04-27). Ask your admin to redeploy.[/red]"
|
|
744
|
+
)
|
|
745
|
+
return
|
|
746
|
+
r.raise_for_status()
|
|
747
|
+
data = r.json()
|
|
748
|
+
except httpx.HTTPStatusError as e:
|
|
749
|
+
console.print(f"[red]HTTP {e.response.status_code}: {e.response.text[:200]}[/red]")
|
|
750
|
+
return
|
|
751
|
+
except Exception as e:
|
|
752
|
+
console.print(f"[red]{e}[/red]")
|
|
753
|
+
return
|
|
754
|
+
|
|
755
|
+
matched = data.get("matched", 0)
|
|
756
|
+
deleted = data.get("deleted", 0)
|
|
757
|
+
sessions = data.get("sessions") or []
|
|
758
|
+
|
|
759
|
+
if dry_run:
|
|
760
|
+
console.print(f"[dim]Dry run: {matched} session(s) would be deleted from {base}[/dim]")
|
|
761
|
+
for s in sessions[:20]:
|
|
762
|
+
title = (s.get("title") or "(untitled)")[:60]
|
|
763
|
+
console.print(f" [cyan]{s.get('project') or '-'}[/cyan] {title}")
|
|
764
|
+
if matched > 20:
|
|
765
|
+
console.print(f" ... and {matched - 20} more")
|
|
766
|
+
return
|
|
767
|
+
|
|
768
|
+
console.print(f"[green]Deleted[/green] {deleted} session(s) from {base}")
|