spooling 0.2.9__tar.gz → 0.2.11__tar.gz
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-0.2.9/spooling.egg-info → spooling-0.2.11}/PKG-INFO +24 -1
- {spooling-0.2.9 → spooling-0.2.11}/README.md +23 -0
- {spooling-0.2.9 → spooling-0.2.11}/pyproject.toml +1 -1
- {spooling-0.2.9 → spooling-0.2.11}/spooling/cli.py +201 -0
- {spooling-0.2.9 → spooling-0.2.11}/spooling/cloud.py +189 -24
- spooling-0.2.11/spooling/insights.py +253 -0
- {spooling-0.2.9 → spooling-0.2.11/spooling.egg-info}/PKG-INFO +24 -1
- {spooling-0.2.9 → spooling-0.2.11}/spooling.egg-info/SOURCES.txt +3 -0
- spooling-0.2.11/tests/test_cloud_errors.py +219 -0
- spooling-0.2.11/tests/test_insights.py +218 -0
- {spooling-0.2.9 → spooling-0.2.11}/LICENSE +0 -0
- {spooling-0.2.9 → spooling-0.2.11}/setup.cfg +0 -0
- {spooling-0.2.9 → spooling-0.2.11}/spooling/__init__.py +0 -0
- {spooling-0.2.9 → spooling-0.2.11}/spooling/agent.py +0 -0
- {spooling-0.2.9 → spooling-0.2.11}/spooling/classifiers.py +0 -0
- {spooling-0.2.9 → spooling-0.2.11}/spooling/config.py +0 -0
- {spooling-0.2.9 → spooling-0.2.11}/spooling/connectors/__init__.py +0 -0
- {spooling-0.2.9 → spooling-0.2.11}/spooling/connectors/base.py +0 -0
- {spooling-0.2.9 → spooling-0.2.11}/spooling/connectors/bigquery.py +0 -0
- {spooling-0.2.9 → spooling-0.2.11}/spooling/connectors/clickhouse.py +0 -0
- {spooling-0.2.9 → spooling-0.2.11}/spooling/connectors/factory.py +0 -0
- {spooling-0.2.9 → spooling-0.2.11}/spooling/connectors/mongodb.py +0 -0
- {spooling-0.2.9 → spooling-0.2.11}/spooling/connectors/mysql.py +0 -0
- {spooling-0.2.9 → spooling-0.2.11}/spooling/connectors/postgresql.py +0 -0
- {spooling-0.2.9 → spooling-0.2.11}/spooling/connectors/rest/__init__.py +0 -0
- {spooling-0.2.9 → spooling-0.2.11}/spooling/connectors/rest/base.py +0 -0
- {spooling-0.2.9 → spooling-0.2.11}/spooling/connectors/rest/shopify_connectors.py +0 -0
- {spooling-0.2.9 → spooling-0.2.11}/spooling/connectors/snowflake.py +0 -0
- {spooling-0.2.9 → spooling-0.2.11}/spooling/connectors/types.py +0 -0
- {spooling-0.2.9 → spooling-0.2.11}/spooling/db.py +0 -0
- {spooling-0.2.9 → spooling-0.2.11}/spooling/embeddings.py +0 -0
- {spooling-0.2.9 → spooling-0.2.11}/spooling/evals.py +0 -0
- {spooling-0.2.9 → spooling-0.2.11}/spooling/experiments.py +0 -0
- {spooling-0.2.9 → spooling-0.2.11}/spooling/ingest.py +0 -0
- {spooling-0.2.9 → spooling-0.2.11}/spooling/mcp_server.py +0 -0
- {spooling-0.2.9 → spooling-0.2.11}/spooling/optimize.py +0 -0
- {spooling-0.2.9 → spooling-0.2.11}/spooling/parser.py +0 -0
- {spooling-0.2.9 → spooling-0.2.11}/spooling/pricing.py +0 -0
- {spooling-0.2.9 → spooling-0.2.11}/spooling/providers/__init__.py +0 -0
- {spooling-0.2.9 → spooling-0.2.11}/spooling/providers/antigravity.py +0 -0
- {spooling-0.2.9 → spooling-0.2.11}/spooling/providers/base.py +0 -0
- {spooling-0.2.9 → spooling-0.2.11}/spooling/providers/claude_code.py +0 -0
- {spooling-0.2.9 → spooling-0.2.11}/spooling/providers/codex.py +0 -0
- {spooling-0.2.9 → spooling-0.2.11}/spooling/providers/copilot.py +0 -0
- {spooling-0.2.9 → spooling-0.2.11}/spooling/providers/cortex_code.py +0 -0
- {spooling-0.2.9 → spooling-0.2.11}/spooling/providers/cursor.py +0 -0
- {spooling-0.2.9 → spooling-0.2.11}/spooling/providers/gemini.py +0 -0
- {spooling-0.2.9 → spooling-0.2.11}/spooling/providers/github.py +0 -0
- {spooling-0.2.9 → spooling-0.2.11}/spooling/providers/gitlab.py +0 -0
- {spooling-0.2.9 → spooling-0.2.11}/spooling/providers/kiro.py +0 -0
- {spooling-0.2.9 → spooling-0.2.11}/spooling/providers/opencode.py +0 -0
- {spooling-0.2.9 → spooling-0.2.11}/spooling/providers/session_file.py +0 -0
- {spooling-0.2.9 → spooling-0.2.11}/spooling/providers/windsurf.py +0 -0
- {spooling-0.2.9 → spooling-0.2.11}/spooling/redact.py +0 -0
- {spooling-0.2.9 → spooling-0.2.11}/spooling/remote_otel.py +0 -0
- {spooling-0.2.9 → spooling-0.2.11}/spooling/schema.py +0 -0
- {spooling-0.2.9 → spooling-0.2.11}/spooling/sdk.py +0 -0
- {spooling-0.2.9 → spooling-0.2.11}/spooling/search.py +0 -0
- {spooling-0.2.9 → spooling-0.2.11}/spooling/server.py +0 -0
- {spooling-0.2.9 → spooling-0.2.11}/spooling/sessions.py +0 -0
- {spooling-0.2.9 → spooling-0.2.11}/spooling/stats.py +0 -0
- {spooling-0.2.9 → spooling-0.2.11}/spooling/subscription_pricing.py +0 -0
- {spooling-0.2.9 → spooling-0.2.11}/spooling/tracing.py +0 -0
- {spooling-0.2.9 → spooling-0.2.11}/spooling/tunnel.py +0 -0
- {spooling-0.2.9 → spooling-0.2.11}/spooling/tunnel_auth.py +0 -0
- {spooling-0.2.9 → spooling-0.2.11}/spooling/watcher.py +0 -0
- {spooling-0.2.9 → spooling-0.2.11}/spooling.egg-info/dependency_links.txt +0 -0
- {spooling-0.2.9 → spooling-0.2.11}/spooling.egg-info/entry_points.txt +0 -0
- {spooling-0.2.9 → spooling-0.2.11}/spooling.egg-info/requires.txt +0 -0
- {spooling-0.2.9 → spooling-0.2.11}/spooling.egg-info/top_level.txt +0 -0
- {spooling-0.2.9 → spooling-0.2.11}/tests/test_claude_code_provider.py +0 -0
- {spooling-0.2.9 → spooling-0.2.11}/tests/test_mcp_auth_middleware.py +0 -0
- {spooling-0.2.9 → spooling-0.2.11}/tests/test_session_ids.py +0 -0
- {spooling-0.2.9 → spooling-0.2.11}/tests/test_storage_optimizations.py +0 -0
- {spooling-0.2.9 → spooling-0.2.11}/tests/test_tunnel_auth.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: spooling
|
|
3
|
-
Version: 0.2.
|
|
3
|
+
Version: 0.2.11
|
|
4
4
|
Summary: Local session tracker and semantic search for AI coding assistants
|
|
5
5
|
Author: Parsed Analytics, Inc.
|
|
6
6
|
License: MIT
|
|
@@ -155,6 +155,29 @@ spooling stats --week # Weekly breakdown
|
|
|
155
155
|
spooling stats --days 30 # Last 30 days
|
|
156
156
|
```
|
|
157
157
|
|
|
158
|
+
### `spooling tools`, `spooling agents`, `spooling traces`
|
|
159
|
+
|
|
160
|
+
See which tools and agents your sessions use. These read the spans stored by `spooling sync`, and use the same agent/tool names as the web app (an agent is its subagent type, falling back to its span name).
|
|
161
|
+
|
|
162
|
+
```bash
|
|
163
|
+
spooling tools # Top tools: uses, traces, errors, avg time
|
|
164
|
+
spooling tools --agent explore # Tools called by the "explore" agent
|
|
165
|
+
spooling tools --days 7 --provider claude-code
|
|
166
|
+
|
|
167
|
+
spooling agents # Agents: runs, traces, tokens, cost
|
|
168
|
+
spooling agents --tool Bash # Agents whose runs called Bash
|
|
169
|
+
|
|
170
|
+
spooling traces # Latest traces, with the agents each ran
|
|
171
|
+
spooling traces ses_10dd # Search title, project, session id, trace id or agent name
|
|
172
|
+
spooling traces --agent explore --tool Bash --days 30
|
|
173
|
+
```
|
|
174
|
+
|
|
175
|
+
Common options: `--days N`, `--project TEXT`, `--provider/-p NAME`, `--limit/-n N`, and `--json` for scripting. `--agent` and `--tool` can be repeated (matches any).
|
|
176
|
+
|
|
177
|
+
`tools --agent` and `agents --tool` look inside an agent's own runs. `traces --agent/--tool` matches whole traces, like the Traces page.
|
|
178
|
+
|
|
179
|
+
`spooling tools --cloud` and `spooling agents --cloud` show workspace-wide totals from Spooling Cloud. The cloud endpoints only support `--limit`, so combining `--cloud` with other filters is an error. Agent data is only recorded for providers that expose subagents (for example Claude Code's `Task` tool), so `spooling agents` can be empty for other providers.
|
|
180
|
+
|
|
158
181
|
### `spooling search <query>`
|
|
159
182
|
|
|
160
183
|
Semantic search across all your session history using natural language. Each result shows the session title **and its session id**, so you can open it with `spooling session show`.
|
|
@@ -119,6 +119,29 @@ spooling stats --week # Weekly breakdown
|
|
|
119
119
|
spooling stats --days 30 # Last 30 days
|
|
120
120
|
```
|
|
121
121
|
|
|
122
|
+
### `spooling tools`, `spooling agents`, `spooling traces`
|
|
123
|
+
|
|
124
|
+
See which tools and agents your sessions use. These read the spans stored by `spooling sync`, and use the same agent/tool names as the web app (an agent is its subagent type, falling back to its span name).
|
|
125
|
+
|
|
126
|
+
```bash
|
|
127
|
+
spooling tools # Top tools: uses, traces, errors, avg time
|
|
128
|
+
spooling tools --agent explore # Tools called by the "explore" agent
|
|
129
|
+
spooling tools --days 7 --provider claude-code
|
|
130
|
+
|
|
131
|
+
spooling agents # Agents: runs, traces, tokens, cost
|
|
132
|
+
spooling agents --tool Bash # Agents whose runs called Bash
|
|
133
|
+
|
|
134
|
+
spooling traces # Latest traces, with the agents each ran
|
|
135
|
+
spooling traces ses_10dd # Search title, project, session id, trace id or agent name
|
|
136
|
+
spooling traces --agent explore --tool Bash --days 30
|
|
137
|
+
```
|
|
138
|
+
|
|
139
|
+
Common options: `--days N`, `--project TEXT`, `--provider/-p NAME`, `--limit/-n N`, and `--json` for scripting. `--agent` and `--tool` can be repeated (matches any).
|
|
140
|
+
|
|
141
|
+
`tools --agent` and `agents --tool` look inside an agent's own runs. `traces --agent/--tool` matches whole traces, like the Traces page.
|
|
142
|
+
|
|
143
|
+
`spooling tools --cloud` and `spooling agents --cloud` show workspace-wide totals from Spooling Cloud. The cloud endpoints only support `--limit`, so combining `--cloud` with other filters is an error. Agent data is only recorded for providers that expose subagents (for example Claude Code's `Task` tool), so `spooling agents` can be empty for other providers.
|
|
144
|
+
|
|
122
145
|
### `spooling search <query>`
|
|
123
146
|
|
|
124
147
|
Semantic search across all your session history using natural language. Each result shows the session title **and its session id**, so you can open it with `spooling session show`.
|
|
@@ -1339,6 +1339,207 @@ def stats_providers():
|
|
|
1339
1339
|
console.print(table)
|
|
1340
1340
|
|
|
1341
1341
|
|
|
1342
|
+
# ---------------------------------------------------------------------------
|
|
1343
|
+
# Tools / agents / traces insights
|
|
1344
|
+
# ---------------------------------------------------------------------------
|
|
1345
|
+
|
|
1346
|
+
def _fmt_ms(ms) -> str:
|
|
1347
|
+
if ms is None:
|
|
1348
|
+
return "-"
|
|
1349
|
+
if ms < 1000:
|
|
1350
|
+
return f"{ms:.0f}ms"
|
|
1351
|
+
if ms < 60_000:
|
|
1352
|
+
return f"{ms / 1000:.1f}s"
|
|
1353
|
+
return f"{ms / 60_000:.1f}m"
|
|
1354
|
+
|
|
1355
|
+
|
|
1356
|
+
def _fmt_num(n) -> str:
|
|
1357
|
+
n = int(n or 0)
|
|
1358
|
+
if n >= 1_000_000:
|
|
1359
|
+
return f"{n / 1_000_000:.1f}M"
|
|
1360
|
+
if n >= 10_000:
|
|
1361
|
+
return f"{n / 1000:.1f}K"
|
|
1362
|
+
return f"{n:,}"
|
|
1363
|
+
|
|
1364
|
+
|
|
1365
|
+
def _emit_json(data) -> None:
|
|
1366
|
+
import json
|
|
1367
|
+
click.echo(json.dumps(data, indent=2, default=str))
|
|
1368
|
+
|
|
1369
|
+
|
|
1370
|
+
def _reject_cloud_filters(**filters) -> None:
|
|
1371
|
+
used = [f"--{k}" for k, v in filters.items() if v]
|
|
1372
|
+
if used:
|
|
1373
|
+
raise click.UsageError(
|
|
1374
|
+
f"{', '.join(used)} can't be combined with --cloud "
|
|
1375
|
+
"(the cloud endpoint only supports --limit). Drop --cloud to filter local data."
|
|
1376
|
+
)
|
|
1377
|
+
|
|
1378
|
+
|
|
1379
|
+
_FILTER_OPTS = [
|
|
1380
|
+
click.option("--days", type=int, default=None, help="Only the last N days"),
|
|
1381
|
+
click.option("--project", default=None, help="Project name contains this text"),
|
|
1382
|
+
click.option("--provider", "-p", default=None, help="Only this provider (claude-code, opencode, ...)"),
|
|
1383
|
+
click.option("--limit", "-n", type=int, default=20, show_default=True, help="Max rows"),
|
|
1384
|
+
click.option("--json", "as_json", is_flag=True, help="Print JSON instead of a table"),
|
|
1385
|
+
]
|
|
1386
|
+
|
|
1387
|
+
|
|
1388
|
+
def _filter_opts(fn):
|
|
1389
|
+
for opt in reversed(_FILTER_OPTS):
|
|
1390
|
+
fn = opt(fn)
|
|
1391
|
+
return fn
|
|
1392
|
+
|
|
1393
|
+
|
|
1394
|
+
@cli.command("tools")
|
|
1395
|
+
@click.option("--agent", "agents", multiple=True, help="Only tools called by this agent (repeatable)")
|
|
1396
|
+
@click.option("--cloud", "cloud_mode", is_flag=True, help="Use the cloud workspace instead of local data")
|
|
1397
|
+
@_filter_opts
|
|
1398
|
+
def tools_cmd(agents, cloud_mode, days, project, provider, limit, as_json):
|
|
1399
|
+
"""Show which tools are used, how often, and how often they fail.
|
|
1400
|
+
|
|
1401
|
+
\b
|
|
1402
|
+
spooling tools top tools across all traces
|
|
1403
|
+
spooling tools --agent explore tools called by the 'explore' agent
|
|
1404
|
+
spooling tools --days 7 --json
|
|
1405
|
+
spooling tools --cloud workspace-wide, from Spooling Cloud
|
|
1406
|
+
"""
|
|
1407
|
+
if cloud_mode:
|
|
1408
|
+
_reject_cloud_filters(agent=agents, days=days, project=project, provider=provider)
|
|
1409
|
+
from spooling.cloud import cloud_tools
|
|
1410
|
+
rows = cloud_tools(limit)
|
|
1411
|
+
if as_json:
|
|
1412
|
+
return _emit_json(rows)
|
|
1413
|
+
table = Table(title="Tools (cloud)", show_lines=False)
|
|
1414
|
+
table.add_column("Tool", style="cyan")
|
|
1415
|
+
table.add_column("Uses", justify="right")
|
|
1416
|
+
table.add_column("Sessions", justify="right")
|
|
1417
|
+
table.add_column("Errors", justify="right")
|
|
1418
|
+
for r in rows:
|
|
1419
|
+
table.add_row(r["tool_name"], _fmt_num(r["uses"]), _fmt_num(r["sessions"]),
|
|
1420
|
+
f"[red]{r['errors']}[/red]" if r["errors"] else "0")
|
|
1421
|
+
console.print(table if rows else "[yellow]No tool data in the cloud workspace yet.[/yellow]")
|
|
1422
|
+
return
|
|
1423
|
+
|
|
1424
|
+
from spooling.insights import tool_stats
|
|
1425
|
+
rows = tool_stats(days=days, project=project, provider=provider, agents=list(agents) or None, limit=limit)
|
|
1426
|
+
if as_json:
|
|
1427
|
+
return _emit_json(rows)
|
|
1428
|
+
if not rows:
|
|
1429
|
+
console.print("[yellow]No tool data found. Run 'spooling sync' first"
|
|
1430
|
+
+ (" (agent filters need traces that contain agent spans)." if agents else ".") + "[/yellow]")
|
|
1431
|
+
return
|
|
1432
|
+
table = Table(title="Tools" + (f" called by {', '.join(agents)}" if agents else ""), show_lines=False)
|
|
1433
|
+
table.add_column("Tool", style="cyan")
|
|
1434
|
+
table.add_column("Uses", justify="right")
|
|
1435
|
+
table.add_column("Traces", justify="right")
|
|
1436
|
+
table.add_column("Errors", justify="right")
|
|
1437
|
+
table.add_column("Avg time", justify="right", style="dim")
|
|
1438
|
+
for r in rows:
|
|
1439
|
+
table.add_row(r["name"], _fmt_num(r["uses"]), _fmt_num(r["traces"]),
|
|
1440
|
+
f"[red]{r['errors']}[/red]" if r["errors"] else "0", _fmt_ms(r["avg_ms"]))
|
|
1441
|
+
console.print(table)
|
|
1442
|
+
|
|
1443
|
+
|
|
1444
|
+
@cli.command("agents")
|
|
1445
|
+
@click.option("--tool", "tools", multiple=True, help="Only agents that called this tool (repeatable)")
|
|
1446
|
+
@click.option("--cloud", "cloud_mode", is_flag=True, help="Use the cloud workspace instead of local data")
|
|
1447
|
+
@_filter_opts
|
|
1448
|
+
def agents_cmd(tools, cloud_mode, days, project, provider, limit, as_json):
|
|
1449
|
+
"""Show which agents (subagent types) ran, how often, and what they cost.
|
|
1450
|
+
|
|
1451
|
+
\b
|
|
1452
|
+
spooling agents agents across all traces
|
|
1453
|
+
spooling agents --tool Bash agents whose runs called Bash
|
|
1454
|
+
spooling agents --days 30 --json
|
|
1455
|
+
spooling agents --cloud workspace-wide, from Spooling Cloud
|
|
1456
|
+
"""
|
|
1457
|
+
if cloud_mode:
|
|
1458
|
+
_reject_cloud_filters(tool=tools, days=days, project=project, provider=provider)
|
|
1459
|
+
from spooling.cloud import cloud_agents
|
|
1460
|
+
rows = cloud_agents()[:limit]
|
|
1461
|
+
if as_json:
|
|
1462
|
+
return _emit_json(rows)
|
|
1463
|
+
table = Table(title="Agents (cloud)", show_lines=False)
|
|
1464
|
+
table.add_column("Agent", style="cyan")
|
|
1465
|
+
table.add_column("Runs", justify="right")
|
|
1466
|
+
for r in rows:
|
|
1467
|
+
table.add_row(r["agent_type"], _fmt_num(r["uses"]))
|
|
1468
|
+
console.print(table if rows else "[yellow]No agent data in the cloud workspace yet.[/yellow]")
|
|
1469
|
+
return
|
|
1470
|
+
|
|
1471
|
+
from spooling.insights import agent_stats
|
|
1472
|
+
rows = agent_stats(days=days, project=project, provider=provider, tools=list(tools) or None, limit=limit)
|
|
1473
|
+
if as_json:
|
|
1474
|
+
return _emit_json(rows)
|
|
1475
|
+
if not rows:
|
|
1476
|
+
console.print(
|
|
1477
|
+
"[yellow]No agent spans found locally.[/yellow]\n"
|
|
1478
|
+
"[dim]Agent runs are recorded for providers that expose subagents (e.g. Claude Code's Task tool). "
|
|
1479
|
+
"Run 'spooling sync' after using one, or try 'spooling agents --cloud'.[/dim]"
|
|
1480
|
+
)
|
|
1481
|
+
return
|
|
1482
|
+
table = Table(title="Agents" + (f" that called {', '.join(tools)}" if tools else ""), show_lines=False)
|
|
1483
|
+
table.add_column("Agent", style="cyan")
|
|
1484
|
+
table.add_column("Runs", justify="right")
|
|
1485
|
+
table.add_column("Traces", justify="right")
|
|
1486
|
+
table.add_column("Tokens", justify="right")
|
|
1487
|
+
table.add_column("Est. Cost", justify="right")
|
|
1488
|
+
table.add_column("Avg time", justify="right", style="dim")
|
|
1489
|
+
for r in rows:
|
|
1490
|
+
table.add_row(r["name"], _fmt_num(r["runs"]), _fmt_num(r["traces"]), _fmt_num(r["tokens"]),
|
|
1491
|
+
f"${r['cost']:.2f}", _fmt_ms(r["avg_ms"]))
|
|
1492
|
+
console.print(table)
|
|
1493
|
+
|
|
1494
|
+
|
|
1495
|
+
@cli.command("traces")
|
|
1496
|
+
@click.argument("query", required=False)
|
|
1497
|
+
@click.option("--agent", "agents", multiple=True, help="Only traces that ran this agent (repeatable)")
|
|
1498
|
+
@click.option("--tool", "tools", multiple=True, help="Only traces that used this tool (repeatable)")
|
|
1499
|
+
@click.option("--session", default=None, help="Session id (or unique prefix)")
|
|
1500
|
+
@_filter_opts
|
|
1501
|
+
def traces_cmd(query, agents, tools, session, days, project, provider, limit, as_json):
|
|
1502
|
+
"""List traces, optionally filtered by agent, tool, or free text.
|
|
1503
|
+
|
|
1504
|
+
QUERY matches title, project, session id, trace id or an agent name.
|
|
1505
|
+
|
|
1506
|
+
\b
|
|
1507
|
+
spooling traces latest traces
|
|
1508
|
+
spooling traces ses_10dd find by session id
|
|
1509
|
+
spooling traces --agent explore traces that ran the explore agent
|
|
1510
|
+
spooling traces --tool Bash --days 7 traces that used Bash this week
|
|
1511
|
+
"""
|
|
1512
|
+
from spooling.insights import list_traces
|
|
1513
|
+
rows = list_traces(query=query, agents=list(agents) or None, tools=list(tools) or None,
|
|
1514
|
+
session=session, days=days, project=project, provider=provider, limit=limit)
|
|
1515
|
+
if as_json:
|
|
1516
|
+
return _emit_json(rows)
|
|
1517
|
+
if not rows:
|
|
1518
|
+
console.print("[yellow]No traces match. Traces are created by 'spooling sync'.[/yellow]")
|
|
1519
|
+
return
|
|
1520
|
+
table = Table(title=f"Traces ({len(rows)})", show_lines=False, pad_edge=False)
|
|
1521
|
+
table.add_column("Date", style="dim", no_wrap=True)
|
|
1522
|
+
table.add_column("Title", style="cyan", max_width=22, overflow="ellipsis", no_wrap=True)
|
|
1523
|
+
table.add_column("Session", style="dim", no_wrap=True)
|
|
1524
|
+
table.add_column("Agents", max_width=12, overflow="ellipsis", no_wrap=True)
|
|
1525
|
+
table.add_column("Spans", justify="right", no_wrap=True)
|
|
1526
|
+
table.add_column("Tools", justify="right", no_wrap=True)
|
|
1527
|
+
table.add_column("Cost", justify="right", no_wrap=True)
|
|
1528
|
+
for r in rows:
|
|
1529
|
+
ag = r["agents"]
|
|
1530
|
+
ag_txt = ", ".join(ag[:1]) + (f" +{len(ag) - 1}" if len(ag) > 1 else "") if ag else "-"
|
|
1531
|
+
table.add_row(
|
|
1532
|
+
str(r["started_at"] or "")[5:10],
|
|
1533
|
+
r["title"] or "(untitled)",
|
|
1534
|
+
(r["session_id"] or "")[:10],
|
|
1535
|
+
ag_txt,
|
|
1536
|
+
_fmt_num(r["span_count"]),
|
|
1537
|
+
_fmt_num(r["tool_count"]),
|
|
1538
|
+
f"${float(r['total_cost_usd'] or 0):.2f}",
|
|
1539
|
+
)
|
|
1540
|
+
console.print(table)
|
|
1541
|
+
|
|
1542
|
+
|
|
1342
1543
|
# ---------------------------------------------------------------------------
|
|
1343
1544
|
# Token management
|
|
1344
1545
|
# ---------------------------------------------------------------------------
|
|
@@ -4,13 +4,17 @@ import json
|
|
|
4
4
|
import os
|
|
5
5
|
import re
|
|
6
6
|
import signal
|
|
7
|
+
import socket
|
|
8
|
+
import ssl
|
|
7
9
|
import time
|
|
8
10
|
from datetime import datetime, timedelta, timezone
|
|
9
11
|
from pathlib import Path
|
|
12
|
+
from urllib.parse import urlparse
|
|
10
13
|
|
|
11
14
|
import click
|
|
12
15
|
import httpx
|
|
13
16
|
from rich.console import Console
|
|
17
|
+
from rich.markup import escape
|
|
14
18
|
|
|
15
19
|
from spooling.db import get_connection
|
|
16
20
|
from spooling.redact import redact_messages, redact_traces
|
|
@@ -70,6 +74,143 @@ def _api_base() -> str:
|
|
|
70
74
|
return cfg.get("api_url") or os.environ.get("SPOOLING_CLOUD_URL") or DEFAULT_API
|
|
71
75
|
|
|
72
76
|
|
|
77
|
+
# --- Friendly network errors -------------------------------------------------
|
|
78
|
+
#
|
|
79
|
+
# Raw exceptions like "[Errno 8] nodename nor servname provided, or not known"
|
|
80
|
+
# tell a user nothing. Everything that talks to the cloud funnels its errors
|
|
81
|
+
# through _explain_error / _explain_http so they get a plain-English cause and
|
|
82
|
+
# the next step.
|
|
83
|
+
|
|
84
|
+
_SELF_HOST_HINT = (
|
|
85
|
+
"Using a self-hosted or staging server? Point the CLI at it with\n"
|
|
86
|
+
" spooling cloud login --key sk_... --api-url https://your-host\n"
|
|
87
|
+
" or set SPOOLING_CLOUD_URL."
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _host_port(base: str) -> tuple[str, int]:
|
|
92
|
+
try:
|
|
93
|
+
u = urlparse(base)
|
|
94
|
+
host = u.hostname or base
|
|
95
|
+
port = u.port or (80 if u.scheme == "http" else 443)
|
|
96
|
+
except ValueError: # e.g. port out of range in a hand-typed URL
|
|
97
|
+
return base, 443
|
|
98
|
+
return host, port
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def _exc_chain(e: BaseException):
|
|
102
|
+
seen = set()
|
|
103
|
+
while e is not None and id(e) not in seen:
|
|
104
|
+
seen.add(id(e))
|
|
105
|
+
yield e
|
|
106
|
+
e = e.__cause__ or e.__context__
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
_DNS_MARKERS = (
|
|
110
|
+
"nodename nor servname", # macOS
|
|
111
|
+
"name or service not known", # Linux (glibc)
|
|
112
|
+
"temporary failure in name resolution",
|
|
113
|
+
"no address associated with hostname",
|
|
114
|
+
"getaddrinfo failed", # Windows
|
|
115
|
+
"no such host",
|
|
116
|
+
)
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def _is_dns_error(e: BaseException) -> bool:
|
|
120
|
+
for x in _exc_chain(e):
|
|
121
|
+
if isinstance(x, socket.gaierror):
|
|
122
|
+
return True
|
|
123
|
+
if any(m in str(x).lower() for m in _DNS_MARKERS):
|
|
124
|
+
return True
|
|
125
|
+
return False
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def _explain_error(e: BaseException, base: str) -> str:
|
|
129
|
+
"""Return a plain-English explanation (rich markup) for a failed request.
|
|
130
|
+
|
|
131
|
+
Never raises: it runs inside error handlers, where a second exception would
|
|
132
|
+
hide the original problem.
|
|
133
|
+
"""
|
|
134
|
+
try:
|
|
135
|
+
return _explain_error_impl(e, base)
|
|
136
|
+
except Exception: # noqa: BLE001
|
|
137
|
+
return escape(str(e) or type(e).__name__)
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def _explain_error_impl(e: BaseException, base: str) -> str:
|
|
141
|
+
host, port = _host_port(base)
|
|
142
|
+
chain = list(_exc_chain(e))
|
|
143
|
+
if _is_dns_error(e):
|
|
144
|
+
return (
|
|
145
|
+
f"Can't reach Spooling Cloud at {escape(base)}\n"
|
|
146
|
+
f" The hostname '{escape(host)}' doesn't resolve (DNS lookup failed).\n"
|
|
147
|
+
f" Either you're offline, or that server doesn't exist (yet).\n"
|
|
148
|
+
f" - Offline or on a VPN? Check your connection and try again.\n"
|
|
149
|
+
f" - {_SELF_HOST_HINT}"
|
|
150
|
+
)
|
|
151
|
+
if any(isinstance(x, ConnectionRefusedError) for x in chain):
|
|
152
|
+
return (
|
|
153
|
+
f"Connection refused by {escape(host)}:{port}\n"
|
|
154
|
+
f" The host is reachable but nothing is listening. Is the sync server running?\n"
|
|
155
|
+
f" - {_SELF_HOST_HINT}"
|
|
156
|
+
)
|
|
157
|
+
if isinstance(e, httpx.TimeoutException) or any(isinstance(x, (TimeoutError, socket.timeout)) for x in chain):
|
|
158
|
+
return (
|
|
159
|
+
f"Timed out talking to {escape(base)}\n"
|
|
160
|
+
f" The server may be starting up or overloaded. Try again in a moment."
|
|
161
|
+
)
|
|
162
|
+
if any(isinstance(x, ssl.SSLError) for x in chain):
|
|
163
|
+
return (
|
|
164
|
+
f"TLS/SSL error connecting to {escape(host)}\n"
|
|
165
|
+
f" {escape(str(e))}\n"
|
|
166
|
+
f" Check the server's certificate, and that the URL scheme (http/https) is right."
|
|
167
|
+
)
|
|
168
|
+
if isinstance(e, httpx.ConnectError):
|
|
169
|
+
return f"Can't connect to {escape(base)}: {escape(str(e) or type(e).__name__)}"
|
|
170
|
+
return escape(str(e) or type(e).__name__)
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def _explain_http(e: "httpx.HTTPStatusError") -> str:
|
|
174
|
+
code = e.response.status_code
|
|
175
|
+
body = escape(e.response.text[:200])
|
|
176
|
+
if code == 401:
|
|
177
|
+
return (
|
|
178
|
+
f"HTTP 401: the server rejected your API key.\n"
|
|
179
|
+
f" Create a new key in the admin app (Settings > API keys), then run\n"
|
|
180
|
+
f" spooling cloud login --key sk_...\n"
|
|
181
|
+
f" {body}"
|
|
182
|
+
)
|
|
183
|
+
if code == 403:
|
|
184
|
+
return f"HTTP 403: this API key isn't allowed to do that (missing scope or no workspace).\n {body}"
|
|
185
|
+
if code == 429:
|
|
186
|
+
retry = e.response.headers.get("retry-after")
|
|
187
|
+
return f"HTTP 429: rate limited{f', retry in {retry}s' if retry else ''}. Slow down or try again shortly."
|
|
188
|
+
if code >= 500:
|
|
189
|
+
return f"HTTP {code}: the server hit an error. This is not a problem with your data; try again shortly.\n {body}"
|
|
190
|
+
return f"HTTP {code}: {body}"
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def _fail(message: str) -> "None":
|
|
194
|
+
"""Print an error and exit non-zero so scripts and cron can detect it."""
|
|
195
|
+
console.print(f"[red]{message}[/red]")
|
|
196
|
+
raise click.exceptions.Exit(1)
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def _preflight(base: str) -> None:
|
|
200
|
+
"""Fail fast, before any sync/redaction work, if the server can't be resolved.
|
|
201
|
+
|
|
202
|
+
Only checks DNS (no request is sent, so it works with any server version).
|
|
203
|
+
Connection-level problems are still reported by the request itself.
|
|
204
|
+
"""
|
|
205
|
+
host, port = _host_port(base)
|
|
206
|
+
try:
|
|
207
|
+
socket.getaddrinfo(host, port, type=socket.SOCK_STREAM)
|
|
208
|
+
except OSError as e:
|
|
209
|
+
console.print(f"[red]{_explain_error(e, base)}[/red]")
|
|
210
|
+
console.print("[dim]Nothing was sent. Your local sessions are untouched.[/dim]")
|
|
211
|
+
raise click.exceptions.Exit(1)
|
|
212
|
+
|
|
213
|
+
|
|
73
214
|
@click.group()
|
|
74
215
|
def cloud():
|
|
75
216
|
"""Spooling Cloud: push local sessions to api.spooling.ai."""
|
|
@@ -93,8 +234,10 @@ def cloud_login(key: str, api_url: str | None):
|
|
|
93
234
|
stats = r.json()
|
|
94
235
|
console.print(f"[green]Logged in to {base}[/green]")
|
|
95
236
|
console.print(f" sessions in cloud: [bold]{stats.get('sessions', 0)}[/bold]")
|
|
237
|
+
except httpx.HTTPStatusError as e:
|
|
238
|
+
console.print(f"[red]Saved key, but the server rejected the check:\n {_explain_http(e)}[/red]")
|
|
96
239
|
except Exception as e:
|
|
97
|
-
console.print(f"[red]Saved key, but
|
|
240
|
+
console.print(f"[red]Saved key, but couldn't verify it.\n {_explain_error(e, base)}[/red]")
|
|
98
241
|
|
|
99
242
|
|
|
100
243
|
@cloud.command("status")
|
|
@@ -114,8 +257,10 @@ def cloud_status():
|
|
|
114
257
|
console.print(f" messages: [bold]{s.get('messages', 0)}[/bold]")
|
|
115
258
|
console.print(f" providers: [bold]{s.get('providers', 0)}[/bold]")
|
|
116
259
|
console.print(f" cost: [bold]${s.get('cost', 0):.2f}[/bold]")
|
|
260
|
+
except httpx.HTTPStatusError as e:
|
|
261
|
+
_fail(f"{_explain_http(e)}")
|
|
117
262
|
except Exception as e:
|
|
118
|
-
|
|
263
|
+
_fail(f"{_explain_error(e, base)}")
|
|
119
264
|
|
|
120
265
|
|
|
121
266
|
@cloud.command("logout")
|
|
@@ -395,14 +540,9 @@ def _push_trace_batches(
|
|
|
395
540
|
else:
|
|
396
541
|
log(f" pushed {acc} trace(s), {spans} span(s)")
|
|
397
542
|
except httpx.HTTPStatusError as e:
|
|
398
|
-
return (
|
|
399
|
-
accepted,
|
|
400
|
-
rejected,
|
|
401
|
-
spans_total,
|
|
402
|
-
f"HTTP {e.response.status_code}: {e.response.text[:200]}",
|
|
403
|
-
)
|
|
543
|
+
return accepted, rejected, spans_total, _explain_http(e)
|
|
404
544
|
except Exception as e:
|
|
405
|
-
return accepted, rejected, spans_total,
|
|
545
|
+
return accepted, rejected, spans_total, _explain_error(e, base)
|
|
406
546
|
return accepted, rejected, spans_total, None
|
|
407
547
|
|
|
408
548
|
|
|
@@ -448,9 +588,9 @@ def _push_batches(
|
|
|
448
588
|
else:
|
|
449
589
|
log(f" pushed {accepted} sessions")
|
|
450
590
|
except httpx.HTTPStatusError as e:
|
|
451
|
-
return total, rejected,
|
|
591
|
+
return total, rejected, _explain_http(e)
|
|
452
592
|
except Exception as e:
|
|
453
|
-
return total, rejected,
|
|
593
|
+
return total, rejected, _explain_error(e, base)
|
|
454
594
|
return total, rejected, None
|
|
455
595
|
|
|
456
596
|
|
|
@@ -559,6 +699,8 @@ def push(limit: int, batch: int, project: str | None, cwd_substr: str | None, ti
|
|
|
559
699
|
return
|
|
560
700
|
headers = _auth_headers()
|
|
561
701
|
base = _api_base()
|
|
702
|
+
if not dry_run:
|
|
703
|
+
_preflight(base) # fail fast, before syncing and redacting, if the server can't be reached
|
|
562
704
|
if do_sync:
|
|
563
705
|
_sync_local()
|
|
564
706
|
sessions = _collect_sessions(
|
|
@@ -607,8 +749,7 @@ def push(limit: int, batch: int, project: str | None, cwd_substr: str | None, ti
|
|
|
607
749
|
return
|
|
608
750
|
total, rejected, err = _push_batches(sessions, batch, base, headers, console.print, copy=copy)
|
|
609
751
|
if err:
|
|
610
|
-
|
|
611
|
-
return
|
|
752
|
+
_fail(err)
|
|
612
753
|
if rejected:
|
|
613
754
|
console.print(
|
|
614
755
|
f"[green]Done.[/green] {total} sessions synced to {base} "
|
|
@@ -649,8 +790,7 @@ def push(limit: int, batch: int, project: str | None, cwd_substr: str | None, ti
|
|
|
649
790
|
traces, trace_batch_size, base, headers, console.print,
|
|
650
791
|
)
|
|
651
792
|
if t_err:
|
|
652
|
-
|
|
653
|
-
return
|
|
793
|
+
_fail(f"Sessions were pushed, but the trace/span upload failed:\n {t_err}")
|
|
654
794
|
msg = f"[green]Done.[/green] {t_acc} trace(s) and {t_spans} span(s) synced"
|
|
655
795
|
if t_rej:
|
|
656
796
|
msg += f" ([yellow]{t_rej} trace(s) rejected[/yellow])"
|
|
@@ -715,7 +855,7 @@ def cloud_watch(interval: int, limit: int, batch: int, lookback: int, no_redact:
|
|
|
715
855
|
console.print(f" [dim]Redacted {redaction_count} secret(s) before push[/dim]")
|
|
716
856
|
total, rejected, err = _push_batches(sessions, batch, base, headers, console.print)
|
|
717
857
|
if err:
|
|
718
|
-
console.print(f"[red]{err}[/red] (will retry next cycle)")
|
|
858
|
+
console.print(f"[red]{err.splitlines()[0]}[/red] (will retry next cycle)")
|
|
719
859
|
else:
|
|
720
860
|
# Advance watermark to the cycle start; the lookback window catches
|
|
721
861
|
# sessions whose started_at slid backwards or whose messages were
|
|
@@ -790,6 +930,7 @@ def cloud_delete(project: str | None, cwd_substr: str | None, session_id: str |
|
|
|
790
930
|
|
|
791
931
|
headers = _auth_headers()
|
|
792
932
|
base = _api_base()
|
|
933
|
+
_preflight(base)
|
|
793
934
|
params = {"dry_run": "true" if dry_run else "false"}
|
|
794
935
|
if project:
|
|
795
936
|
params["project"] = project
|
|
@@ -810,10 +951,10 @@ def cloud_delete(project: str | None, cwd_substr: str | None, session_id: str |
|
|
|
810
951
|
r.raise_for_status()
|
|
811
952
|
data = r.json()
|
|
812
953
|
except httpx.HTTPStatusError as e:
|
|
813
|
-
console.print(f"[red]
|
|
954
|
+
console.print(f"[red]{_explain_http(e)}[/red]")
|
|
814
955
|
return
|
|
815
956
|
except Exception as e:
|
|
816
|
-
console.print(f"[red]{e}[/red]")
|
|
957
|
+
console.print(f"[red]{_explain_error(e, base)}[/red]")
|
|
817
958
|
return
|
|
818
959
|
|
|
819
960
|
matched = data.get("matched", 0)
|
|
@@ -928,9 +1069,9 @@ def _pull_sessions(
|
|
|
928
1069
|
r = client.get(f"{base}/v1/sessions", headers=headers, params=params)
|
|
929
1070
|
r.raise_for_status()
|
|
930
1071
|
except httpx.HTTPStatusError as e:
|
|
931
|
-
return 0, 0,
|
|
1072
|
+
return 0, 0, _explain_http(e)
|
|
932
1073
|
except Exception as e:
|
|
933
|
-
return 0, 0,
|
|
1074
|
+
return 0, 0, _explain_error(e, base)
|
|
934
1075
|
|
|
935
1076
|
data = r.json()
|
|
936
1077
|
cloud_sessions = data.get("sessions", [])
|
|
@@ -979,6 +1120,7 @@ def pull(limit: int, batch: int, dry_run: bool):
|
|
|
979
1120
|
"""
|
|
980
1121
|
headers = _auth_headers()
|
|
981
1122
|
base = _api_base()
|
|
1123
|
+
_preflight(base)
|
|
982
1124
|
|
|
983
1125
|
cfg = _load_config()
|
|
984
1126
|
last = cfg.get("last_pull_at")
|
|
@@ -996,10 +1138,10 @@ def pull(limit: int, batch: int, dry_run: bool):
|
|
|
996
1138
|
r = httpx.get(f"{base}/v1/sessions", headers=headers, params=params, timeout=30)
|
|
997
1139
|
r.raise_for_status()
|
|
998
1140
|
except httpx.HTTPStatusError as e:
|
|
999
|
-
console.print(f"[red]
|
|
1141
|
+
console.print(f"[red]{_explain_http(e)}[/red]")
|
|
1000
1142
|
return
|
|
1001
1143
|
except Exception as e:
|
|
1002
|
-
console.print(f"[red]{e}[/red]")
|
|
1144
|
+
console.print(f"[red]{_explain_error(e, base)}[/red]")
|
|
1003
1145
|
return
|
|
1004
1146
|
|
|
1005
1147
|
data = r.json()
|
|
@@ -1070,6 +1212,7 @@ def sync(limit: int, batch: int, project: str | None, cwd_substr: str | None, ti
|
|
|
1070
1212
|
"""
|
|
1071
1213
|
headers = _auth_headers()
|
|
1072
1214
|
base = _api_base()
|
|
1215
|
+
_preflight(base)
|
|
1073
1216
|
|
|
1074
1217
|
# Phase 1: Push local → cloud
|
|
1075
1218
|
console.print("[bold]Phase 1: Push local → cloud[/bold]")
|
|
@@ -1115,10 +1258,10 @@ def sync(limit: int, batch: int, project: str | None, cwd_substr: str | None, ti
|
|
|
1115
1258
|
r = httpx.get(f"{base}/v1/sessions", headers=headers, params=params, timeout=30)
|
|
1116
1259
|
r.raise_for_status()
|
|
1117
1260
|
except httpx.HTTPStatusError as e:
|
|
1118
|
-
console.print(f" [red]
|
|
1261
|
+
console.print(f" [red]{_explain_http(e)}[/red]")
|
|
1119
1262
|
return
|
|
1120
1263
|
except Exception as e:
|
|
1121
|
-
console.print(f" [red]{e}[/red]")
|
|
1264
|
+
console.print(f" [red]{_explain_error(e, base)}[/red]")
|
|
1122
1265
|
return
|
|
1123
1266
|
|
|
1124
1267
|
data = r.json()
|
|
@@ -1189,3 +1332,25 @@ def cloud_stats() -> dict | None:
|
|
|
1189
1332
|
return r.json()
|
|
1190
1333
|
except Exception:
|
|
1191
1334
|
return None
|
|
1335
|
+
|
|
1336
|
+
|
|
1337
|
+
def _cloud_get(path: str, params: dict | None = None):
|
|
1338
|
+
"""GET a Spooling Cloud endpoint; on any failure print a plain-English reason and exit 1."""
|
|
1339
|
+
headers = _auth_headers()
|
|
1340
|
+
base = _api_base()
|
|
1341
|
+
try:
|
|
1342
|
+
r = httpx.get(f"{base}{path}", headers=headers, params=params, timeout=15)
|
|
1343
|
+
r.raise_for_status()
|
|
1344
|
+
return r.json()
|
|
1345
|
+
except Exception as e: # noqa: BLE001
|
|
1346
|
+
_fail(_explain_error(e, base))
|
|
1347
|
+
|
|
1348
|
+
|
|
1349
|
+
def cloud_tools(limit: int = 20) -> list[dict]:
|
|
1350
|
+
"""Per-tool usage across the cloud workspace (GET /v1/tools)."""
|
|
1351
|
+
return _cloud_get("/v1/tools", {"limit": limit})
|
|
1352
|
+
|
|
1353
|
+
|
|
1354
|
+
def cloud_agents() -> list[dict]:
|
|
1355
|
+
"""Per-agent-type usage across the cloud workspace (GET /v1/stats/agent-subtypes)."""
|
|
1356
|
+
return _cloud_get("/v1/stats/agent-subtypes").get("agent_subtypes", [])
|