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/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}")