spanforge 1.0.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- spanforge/__init__.py +815 -0
- spanforge/_ansi.py +93 -0
- spanforge/_batch_exporter.py +409 -0
- spanforge/_cli.py +2094 -0
- spanforge/_cli_audit.py +639 -0
- spanforge/_cli_compliance.py +711 -0
- spanforge/_cli_cost.py +243 -0
- spanforge/_cli_ops.py +791 -0
- spanforge/_cli_phase11.py +356 -0
- spanforge/_hooks.py +337 -0
- spanforge/_server.py +1708 -0
- spanforge/_span.py +1036 -0
- spanforge/_store.py +288 -0
- spanforge/_stream.py +664 -0
- spanforge/_trace.py +335 -0
- spanforge/_tracer.py +254 -0
- spanforge/actor.py +141 -0
- spanforge/alerts.py +469 -0
- spanforge/auto.py +464 -0
- spanforge/baseline.py +335 -0
- spanforge/cache.py +635 -0
- spanforge/compliance.py +325 -0
- spanforge/config.py +532 -0
- spanforge/consent.py +228 -0
- spanforge/consumer.py +377 -0
- spanforge/core/__init__.py +5 -0
- spanforge/core/compliance_mapping.py +1254 -0
- spanforge/cost.py +600 -0
- spanforge/debug.py +548 -0
- spanforge/deprecations.py +205 -0
- spanforge/drift.py +482 -0
- spanforge/egress.py +58 -0
- spanforge/eval.py +648 -0
- spanforge/event.py +1064 -0
- spanforge/exceptions.py +240 -0
- spanforge/explain.py +178 -0
- spanforge/export/__init__.py +69 -0
- spanforge/export/append_only.py +337 -0
- spanforge/export/cloud.py +357 -0
- spanforge/export/datadog.py +497 -0
- spanforge/export/grafana.py +320 -0
- spanforge/export/jsonl.py +195 -0
- spanforge/export/openinference.py +158 -0
- spanforge/export/otel_bridge.py +294 -0
- spanforge/export/otlp.py +811 -0
- spanforge/export/otlp_bridge.py +233 -0
- spanforge/export/redis_backend.py +282 -0
- spanforge/export/siem_schema.py +98 -0
- spanforge/export/siem_splunk.py +264 -0
- spanforge/export/siem_syslog.py +212 -0
- spanforge/export/webhook.py +299 -0
- spanforge/exporters/__init__.py +30 -0
- spanforge/exporters/console.py +271 -0
- spanforge/exporters/jsonl.py +144 -0
- spanforge/exporters/sqlite.py +142 -0
- spanforge/gate.py +1150 -0
- spanforge/governance.py +181 -0
- spanforge/hitl.py +295 -0
- spanforge/http.py +187 -0
- spanforge/inspect.py +427 -0
- spanforge/integrations/__init__.py +45 -0
- spanforge/integrations/_pricing.py +280 -0
- spanforge/integrations/anthropic.py +388 -0
- spanforge/integrations/azure_openai.py +133 -0
- spanforge/integrations/bedrock.py +292 -0
- spanforge/integrations/crewai.py +251 -0
- spanforge/integrations/gemini.py +351 -0
- spanforge/integrations/groq.py +442 -0
- spanforge/integrations/langchain.py +349 -0
- spanforge/integrations/langgraph.py +306 -0
- spanforge/integrations/llamaindex.py +373 -0
- spanforge/integrations/ollama.py +287 -0
- spanforge/integrations/openai.py +368 -0
- spanforge/integrations/together.py +483 -0
- spanforge/io.py +214 -0
- spanforge/lint.py +322 -0
- spanforge/metrics.py +417 -0
- spanforge/metrics_export.py +343 -0
- spanforge/migrate.py +402 -0
- spanforge/model_registry.py +278 -0
- spanforge/models.py +389 -0
- spanforge/namespaces/__init__.py +254 -0
- spanforge/namespaces/audit.py +256 -0
- spanforge/namespaces/cache.py +237 -0
- spanforge/namespaces/chain.py +77 -0
- spanforge/namespaces/confidence.py +72 -0
- spanforge/namespaces/consent.py +92 -0
- spanforge/namespaces/cost.py +179 -0
- spanforge/namespaces/decision.py +143 -0
- spanforge/namespaces/diff.py +157 -0
- spanforge/namespaces/drift.py +80 -0
- spanforge/namespaces/eval_.py +251 -0
- spanforge/namespaces/feedback.py +241 -0
- spanforge/namespaces/fence.py +193 -0
- spanforge/namespaces/guard.py +105 -0
- spanforge/namespaces/hitl.py +91 -0
- spanforge/namespaces/latency.py +72 -0
- spanforge/namespaces/prompt.py +190 -0
- spanforge/namespaces/redact.py +173 -0
- spanforge/namespaces/retrieval.py +379 -0
- spanforge/namespaces/runtime_governance.py +494 -0
- spanforge/namespaces/template.py +208 -0
- spanforge/namespaces/tool_call.py +77 -0
- spanforge/namespaces/trace.py +1029 -0
- spanforge/normalizer.py +171 -0
- spanforge/plugins.py +82 -0
- spanforge/presidio_backend.py +349 -0
- spanforge/processor.py +258 -0
- spanforge/prompt_registry.py +418 -0
- spanforge/py.typed +0 -0
- spanforge/redact.py +914 -0
- spanforge/regression.py +192 -0
- spanforge/runtime_policy.py +159 -0
- spanforge/sampling.py +511 -0
- spanforge/schema.py +183 -0
- spanforge/schemas/v1.0/schema.json +170 -0
- spanforge/schemas/v2.0/schema.json +536 -0
- spanforge/sdk/__init__.py +625 -0
- spanforge/sdk/_base.py +584 -0
- spanforge/sdk/_base.pyi +71 -0
- spanforge/sdk/_exceptions.py +1096 -0
- spanforge/sdk/_types.py +2184 -0
- spanforge/sdk/alert.py +1514 -0
- spanforge/sdk/alert.pyi +56 -0
- spanforge/sdk/audit.py +1196 -0
- spanforge/sdk/audit.pyi +67 -0
- spanforge/sdk/cec.py +1215 -0
- spanforge/sdk/cec.pyi +37 -0
- spanforge/sdk/config.py +641 -0
- spanforge/sdk/config.pyi +55 -0
- spanforge/sdk/enterprise.py +714 -0
- spanforge/sdk/enterprise.pyi +79 -0
- spanforge/sdk/explain.py +170 -0
- spanforge/sdk/fallback.py +432 -0
- spanforge/sdk/feedback.py +351 -0
- spanforge/sdk/gate.py +874 -0
- spanforge/sdk/gate.pyi +51 -0
- spanforge/sdk/identity.py +2114 -0
- spanforge/sdk/identity.pyi +47 -0
- spanforge/sdk/lineage.py +175 -0
- spanforge/sdk/observe.py +1065 -0
- spanforge/sdk/observe.pyi +50 -0
- spanforge/sdk/operator.py +338 -0
- spanforge/sdk/pii.py +1473 -0
- spanforge/sdk/pii.pyi +119 -0
- spanforge/sdk/pipelines.py +458 -0
- spanforge/sdk/pipelines.pyi +39 -0
- spanforge/sdk/policy.py +930 -0
- spanforge/sdk/rag.py +594 -0
- spanforge/sdk/rbac.py +280 -0
- spanforge/sdk/registry.py +430 -0
- spanforge/sdk/registry.pyi +46 -0
- spanforge/sdk/scope.py +279 -0
- spanforge/sdk/secrets.py +293 -0
- spanforge/sdk/secrets.pyi +25 -0
- spanforge/sdk/security.py +560 -0
- spanforge/sdk/security.pyi +57 -0
- spanforge/sdk/trust.py +472 -0
- spanforge/sdk/trust.pyi +41 -0
- spanforge/secrets.py +799 -0
- spanforge/signing.py +1179 -0
- spanforge/stats.py +100 -0
- spanforge/stream.py +560 -0
- spanforge/testing.py +378 -0
- spanforge/testing_mocks.py +1052 -0
- spanforge/trace.py +199 -0
- spanforge/types.py +696 -0
- spanforge/ulid.py +300 -0
- spanforge/validate.py +379 -0
- spanforge-1.0.0.dist-info/METADATA +1509 -0
- spanforge-1.0.0.dist-info/RECORD +174 -0
- spanforge-1.0.0.dist-info/WHEEL +4 -0
- spanforge-1.0.0.dist-info/entry_points.txt +5 -0
- spanforge-1.0.0.dist-info/licenses/LICENSE +128 -0
spanforge/_cli.py
ADDED
|
@@ -0,0 +1,2094 @@
|
|
|
1
|
+
"""Command-line interface for spanforge utilities.
|
|
2
|
+
|
|
3
|
+
This module provides the ``spanforge`` entry-point command. It is excluded
|
|
4
|
+
from coverage measurement because it is a thin integration shim over the
|
|
5
|
+
public library API — all business logic lives in tested library modules.
|
|
6
|
+
|
|
7
|
+
Entry-point (configured in pyproject.toml)::
|
|
8
|
+
|
|
9
|
+
spanforge = "spanforge._cli:main"
|
|
10
|
+
|
|
11
|
+
Sub-commands
|
|
12
|
+
------------
|
|
13
|
+
``spanforge check``
|
|
14
|
+
End-to-end health check: validates configuration, emits a test event,
|
|
15
|
+
and confirms the export pipeline is working. Exits 0 on success.
|
|
16
|
+
|
|
17
|
+
``spanforge check-compat <events.json>``
|
|
18
|
+
Load a JSON file containing a list of serialised events and run the
|
|
19
|
+
v1.0 compatibility checklist. Exits 0 on success, 1 on violations,
|
|
20
|
+
2 on usage/parse errors.
|
|
21
|
+
|
|
22
|
+
``spanforge list-deprecated``
|
|
23
|
+
Print all event types registered in the global deprecation registry.
|
|
24
|
+
|
|
25
|
+
``spanforge migration-roadmap [--json]``
|
|
26
|
+
Print the planned v1 → v2 migration roadmap from
|
|
27
|
+
:func:`~spanforge.migrate.v2_migration_roadmap`. Pass
|
|
28
|
+
``--json`` to emit JSON for machine consumption.
|
|
29
|
+
|
|
30
|
+
``spanforge check-consumers``
|
|
31
|
+
Assert that all globally registered consumers are compatible with the
|
|
32
|
+
installed schema version. Exits 0 on success, 1 on incompatibilities.
|
|
33
|
+
|
|
34
|
+
``spanforge validate <events.jsonl>``
|
|
35
|
+
Validate every event in a JSONL file against the published schema.
|
|
36
|
+
Exits 0 if all events are valid, 1 if any fail validation.
|
|
37
|
+
|
|
38
|
+
``spanforge audit-chain <events.jsonl>``
|
|
39
|
+
Verify the HMAC signing chain of events in a JSONL file. Reads the
|
|
40
|
+
signing key from the ``SPANFORGE_SIGNING_KEY`` environment variable.
|
|
41
|
+
Exits 0 if the chain is intact, 1 if tampering or gaps are found.
|
|
42
|
+
|
|
43
|
+
``spanforge inspect <event_id> <events.jsonl>``
|
|
44
|
+
Find a single event by ``event_id`` in a JSONL file and pretty-print
|
|
45
|
+
its JSON envelope to stdout. Exits 0 on success, 1 if not found.
|
|
46
|
+
|
|
47
|
+
``spanforge stats <events.jsonl>``
|
|
48
|
+
Print a summary table of events in a JSONL file: event counts by type,
|
|
49
|
+
total prompt/completion/total tokens, total cost, and timestamp range.
|
|
50
|
+
"""
|
|
51
|
+
|
|
52
|
+
from __future__ import annotations
|
|
53
|
+
|
|
54
|
+
import argparse
|
|
55
|
+
import contextlib
|
|
56
|
+
import json
|
|
57
|
+
import os
|
|
58
|
+
import sys
|
|
59
|
+
import threading
|
|
60
|
+
from pathlib import Path
|
|
61
|
+
from typing import Any, NoReturn
|
|
62
|
+
|
|
63
|
+
from spanforge._cli_audit import add_audit_subcommands, dispatch_audit_command
|
|
64
|
+
from spanforge._cli_compliance import add_compliance_subcommands, dispatch_compliance_command
|
|
65
|
+
from spanforge._cli_cost import add_cost_subcommands, dispatch_cost_command
|
|
66
|
+
from spanforge._cli_ops import add_ops_subcommands, dispatch_ops_command
|
|
67
|
+
from spanforge._cli_phase11 import add_phase11_subcommands, dispatch_phase11_command
|
|
68
|
+
|
|
69
|
+
_NO_EVENTS_MSG = "No events found in file."
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _cmd_check(_args: argparse.Namespace) -> int:
|
|
73
|
+
"""Implement the ``check`` sub-command — end-to-end health check."""
|
|
74
|
+
import traceback
|
|
75
|
+
|
|
76
|
+
print("spanforge health check")
|
|
77
|
+
print("=" * 40)
|
|
78
|
+
ok = True
|
|
79
|
+
|
|
80
|
+
# Step 1: Config
|
|
81
|
+
try:
|
|
82
|
+
from spanforge.config import get_config
|
|
83
|
+
|
|
84
|
+
cfg = get_config()
|
|
85
|
+
print(
|
|
86
|
+
f"[✓] Config loaded exporter={cfg.exporter!r} env={cfg.env!r} "
|
|
87
|
+
f"service={cfg.service_name!r}"
|
|
88
|
+
)
|
|
89
|
+
except Exception as exc:
|
|
90
|
+
print(f"[✗] Config failed: {exc}", file=sys.stderr)
|
|
91
|
+
return 1
|
|
92
|
+
|
|
93
|
+
# Step 2: Event creation
|
|
94
|
+
try:
|
|
95
|
+
from spanforge.event import Event
|
|
96
|
+
from spanforge.ulid import generate as gen_ulid
|
|
97
|
+
|
|
98
|
+
event = Event(
|
|
99
|
+
event_type="llm.trace.span.completed",
|
|
100
|
+
source=f"{cfg.service_name}@0.0.0",
|
|
101
|
+
payload={
|
|
102
|
+
"span_id": "0" * 16,
|
|
103
|
+
"trace_id": "0" * 32,
|
|
104
|
+
"span_name": "spanforge.health.check",
|
|
105
|
+
"operation": "chat",
|
|
106
|
+
"span_kind": "client",
|
|
107
|
+
"status": "ok",
|
|
108
|
+
"start_time_unix_nano": 0,
|
|
109
|
+
"end_time_unix_nano": 1_000_000,
|
|
110
|
+
"duration_ms": 1.0,
|
|
111
|
+
},
|
|
112
|
+
event_id=gen_ulid(),
|
|
113
|
+
)
|
|
114
|
+
print("[✓] Test event created")
|
|
115
|
+
except Exception as exc:
|
|
116
|
+
print(f"[✗] Event creation failed: {exc}", file=sys.stderr)
|
|
117
|
+
traceback.print_exc(file=sys.stderr)
|
|
118
|
+
return 1
|
|
119
|
+
|
|
120
|
+
# Step 3: Schema validation
|
|
121
|
+
try:
|
|
122
|
+
from spanforge.validate import validate_event
|
|
123
|
+
|
|
124
|
+
validate_event(event)
|
|
125
|
+
print("[✓] Schema validation passed")
|
|
126
|
+
except Exception as exc:
|
|
127
|
+
print(f"[✗] Schema validation failed: {exc}", file=sys.stderr)
|
|
128
|
+
ok = False
|
|
129
|
+
|
|
130
|
+
# Step 4: Export pipeline
|
|
131
|
+
try:
|
|
132
|
+
from spanforge._stream import _dispatch
|
|
133
|
+
|
|
134
|
+
_dispatch(event)
|
|
135
|
+
print("[✓] Export pipeline: event dispatched successfully")
|
|
136
|
+
except Exception as exc:
|
|
137
|
+
print(f"[✗] Export pipeline failed: {exc}", file=sys.stderr)
|
|
138
|
+
traceback.print_exc(file=sys.stderr)
|
|
139
|
+
ok = False
|
|
140
|
+
|
|
141
|
+
# Step 5: TraceStore recording (only if enabled)
|
|
142
|
+
if cfg.enable_trace_store:
|
|
143
|
+
try:
|
|
144
|
+
from spanforge._store import get_store
|
|
145
|
+
|
|
146
|
+
store = get_store()
|
|
147
|
+
events = store.get_trace("0" * 32)
|
|
148
|
+
if events is not None and len(events) >= 1:
|
|
149
|
+
print(f"[✓] TraceStore recorded {len(events)} event(s)")
|
|
150
|
+
else:
|
|
151
|
+
print("[✗] TraceStore: event not found after dispatch", file=sys.stderr)
|
|
152
|
+
ok = False
|
|
153
|
+
except Exception as exc:
|
|
154
|
+
print(f"[✗] TraceStore check failed: {exc}", file=sys.stderr)
|
|
155
|
+
ok = False
|
|
156
|
+
else:
|
|
157
|
+
print("[-] TraceStore: disabled (set SPANFORGE_ENABLE_TRACE_STORE=1 to enable)")
|
|
158
|
+
|
|
159
|
+
print("=" * 40)
|
|
160
|
+
if ok:
|
|
161
|
+
print("PASS — all checks passed.")
|
|
162
|
+
return 0
|
|
163
|
+
print("FAIL — one or more checks failed.", file=sys.stderr)
|
|
164
|
+
return 1
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def _cmd_check_compat(args: argparse.Namespace) -> int:
|
|
168
|
+
"""Implement the ``check-compat`` sub-command."""
|
|
169
|
+
from spanforge.compliance import test_compatibility
|
|
170
|
+
from spanforge.event import Event
|
|
171
|
+
|
|
172
|
+
path = Path(args.file)
|
|
173
|
+
if not path.exists():
|
|
174
|
+
print(f"error: file not found: {path}", file=sys.stderr)
|
|
175
|
+
return 2
|
|
176
|
+
|
|
177
|
+
try:
|
|
178
|
+
raw = json.loads(path.read_text(encoding="utf-8"))
|
|
179
|
+
except json.JSONDecodeError as exc:
|
|
180
|
+
print(f"error: invalid JSON in {path}: {exc}", file=sys.stderr)
|
|
181
|
+
return 2
|
|
182
|
+
|
|
183
|
+
if not isinstance(raw, list):
|
|
184
|
+
print("error: JSON file must contain a top-level array of events", file=sys.stderr)
|
|
185
|
+
return 2
|
|
186
|
+
|
|
187
|
+
from spanforge.exceptions import DeserializationError, SchemaValidationError
|
|
188
|
+
|
|
189
|
+
try:
|
|
190
|
+
events = [Event.from_dict(item) for item in raw]
|
|
191
|
+
except (DeserializationError, SchemaValidationError, KeyError, TypeError) as exc:
|
|
192
|
+
print(f"error: could not deserialise events: {exc}", file=sys.stderr)
|
|
193
|
+
return 2
|
|
194
|
+
|
|
195
|
+
result = test_compatibility(events)
|
|
196
|
+
|
|
197
|
+
if result.passed:
|
|
198
|
+
print(f"OK — {result.events_checked} event(s) passed all compatibility checks.")
|
|
199
|
+
return 0
|
|
200
|
+
|
|
201
|
+
print(
|
|
202
|
+
f"FAIL — {len(result.violations)} violation(s) found in {result.events_checked} event(s):\n"
|
|
203
|
+
)
|
|
204
|
+
for v in result.violations:
|
|
205
|
+
event_ref = f"[{v.event_id}] " if v.event_id else ""
|
|
206
|
+
print(f" {event_ref}{v.check_id} ({v.rule}): {v.detail}")
|
|
207
|
+
|
|
208
|
+
return 1
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def _cmd_list_deprecated(_args: argparse.Namespace) -> int:
|
|
212
|
+
"""Implement the ``list-deprecated`` sub-command."""
|
|
213
|
+
try:
|
|
214
|
+
from spanforge.deprecations import list_deprecated
|
|
215
|
+
|
|
216
|
+
notices = list_deprecated()
|
|
217
|
+
if not notices:
|
|
218
|
+
print("No deprecated event types registered.")
|
|
219
|
+
return 0
|
|
220
|
+
|
|
221
|
+
print(f"{'Event Type':<50} {'Since':<8} {'Sunset':<8} Replacement")
|
|
222
|
+
print("-" * 90)
|
|
223
|
+
for n in notices:
|
|
224
|
+
repl = n.replacement or "(no replacement)"
|
|
225
|
+
print(f"{n.event_type:<50} {n.since:<8} {n.sunset:<8} {repl}")
|
|
226
|
+
except Exception as exc:
|
|
227
|
+
print(f"error: {exc}", file=sys.stderr)
|
|
228
|
+
return 1
|
|
229
|
+
else:
|
|
230
|
+
return 0
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
def _cmd_migration_roadmap(args: argparse.Namespace) -> int:
|
|
234
|
+
"""Implement the ``migration-roadmap`` sub-command."""
|
|
235
|
+
try:
|
|
236
|
+
import spanforge.migrate as _migrate_mod
|
|
237
|
+
|
|
238
|
+
v2_migration_roadmap = getattr(_migrate_mod, "v2_migration_roadmap", None)
|
|
239
|
+
if v2_migration_roadmap is None:
|
|
240
|
+
print("No migration records found.")
|
|
241
|
+
return 0
|
|
242
|
+
except ImportError as exc:
|
|
243
|
+
print(f"error: {exc}", file=sys.stderr)
|
|
244
|
+
return 1
|
|
245
|
+
|
|
246
|
+
roadmap = v2_migration_roadmap()
|
|
247
|
+
if not roadmap:
|
|
248
|
+
print("No migration records found.")
|
|
249
|
+
return 0
|
|
250
|
+
|
|
251
|
+
if getattr(args, "json", False):
|
|
252
|
+
output = [
|
|
253
|
+
{
|
|
254
|
+
"event_type": r.event_type,
|
|
255
|
+
"since": r.since,
|
|
256
|
+
"sunset": r.sunset,
|
|
257
|
+
"sunset_policy": r.sunset_policy.value,
|
|
258
|
+
"replacement": r.replacement,
|
|
259
|
+
"migration_notes": r.migration_notes,
|
|
260
|
+
"field_renames": r.field_renames,
|
|
261
|
+
}
|
|
262
|
+
for r in roadmap
|
|
263
|
+
]
|
|
264
|
+
print(json.dumps(output, indent=2))
|
|
265
|
+
return 0
|
|
266
|
+
|
|
267
|
+
print(f"v1 → v2 Migration Roadmap ({len(roadmap)} changes)\n")
|
|
268
|
+
for r in roadmap:
|
|
269
|
+
arrow = f" → {r.replacement}" if r.replacement else " (removed)"
|
|
270
|
+
print(f" [{r.since}→{r.sunset}] {r.event_type}{arrow}")
|
|
271
|
+
if r.migration_notes:
|
|
272
|
+
import textwrap
|
|
273
|
+
|
|
274
|
+
wrapped = textwrap.fill(
|
|
275
|
+
r.migration_notes, width=72, initial_indent=" ", subsequent_indent=" "
|
|
276
|
+
)
|
|
277
|
+
print(wrapped)
|
|
278
|
+
if r.field_renames:
|
|
279
|
+
for old, new in r.field_renames.items():
|
|
280
|
+
print(f" field rename: {old!r} → {new!r}")
|
|
281
|
+
print()
|
|
282
|
+
return 0
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
def _cmd_check_consumers(_args: argparse.Namespace) -> int:
|
|
286
|
+
"""Implement the ``check-consumers`` sub-command."""
|
|
287
|
+
from spanforge.consumer import get_registry
|
|
288
|
+
|
|
289
|
+
registry = get_registry()
|
|
290
|
+
all_records = registry.all()
|
|
291
|
+
if not all_records:
|
|
292
|
+
print("No consumers registered.")
|
|
293
|
+
return 0
|
|
294
|
+
|
|
295
|
+
incompatible = registry.check_compatible()
|
|
296
|
+
if not incompatible:
|
|
297
|
+
print(f"OK — all {len(all_records)} consumer(s) are compatible.")
|
|
298
|
+
return 0
|
|
299
|
+
|
|
300
|
+
print(f"INCOMPATIBLE — {len(incompatible)} consumer(s) require a newer schema:\n")
|
|
301
|
+
for tool_name, version in incompatible:
|
|
302
|
+
print(f" {tool_name!r} requires schema v{version}")
|
|
303
|
+
return 1
|
|
304
|
+
|
|
305
|
+
|
|
306
|
+
def _read_jsonl_events(path: Path) -> list[tuple[int, Any]]:
|
|
307
|
+
"""Read a JSONL file and return a list of (lineno, Event | Exception) pairs."""
|
|
308
|
+
from spanforge.event import Event
|
|
309
|
+
from spanforge.exceptions import DeserializationError, SchemaValidationError
|
|
310
|
+
|
|
311
|
+
results: list[tuple[int, Any]] = []
|
|
312
|
+
for lineno, raw_line in enumerate(path.read_text(encoding="utf-8").splitlines(), 1):
|
|
313
|
+
line = raw_line.strip()
|
|
314
|
+
if not line:
|
|
315
|
+
continue
|
|
316
|
+
try:
|
|
317
|
+
obj = json.loads(line)
|
|
318
|
+
event = Event.from_dict(obj)
|
|
319
|
+
results.append((lineno, event))
|
|
320
|
+
except (
|
|
321
|
+
json.JSONDecodeError,
|
|
322
|
+
DeserializationError,
|
|
323
|
+
SchemaValidationError,
|
|
324
|
+
KeyError,
|
|
325
|
+
TypeError,
|
|
326
|
+
) as exc:
|
|
327
|
+
results.append((lineno, exc))
|
|
328
|
+
return results
|
|
329
|
+
|
|
330
|
+
|
|
331
|
+
def _cmd_validate(args: argparse.Namespace) -> int:
|
|
332
|
+
"""Implement the ``validate`` sub-command."""
|
|
333
|
+
from spanforge.exceptions import SchemaValidationError
|
|
334
|
+
from spanforge.validate import validate_event
|
|
335
|
+
|
|
336
|
+
path = Path(args.file)
|
|
337
|
+
if not path.exists():
|
|
338
|
+
print(f"error: file not found: {path}", file=sys.stderr)
|
|
339
|
+
return 2
|
|
340
|
+
|
|
341
|
+
rows = _read_jsonl_events(path)
|
|
342
|
+
if not rows:
|
|
343
|
+
print(_NO_EVENTS_MSG)
|
|
344
|
+
return 0
|
|
345
|
+
|
|
346
|
+
errors: list[tuple[int, str]] = []
|
|
347
|
+
for lineno, item in rows:
|
|
348
|
+
if isinstance(item, Exception):
|
|
349
|
+
errors.append((lineno, f"parse error: {item}"))
|
|
350
|
+
continue
|
|
351
|
+
try:
|
|
352
|
+
validate_event(item)
|
|
353
|
+
except SchemaValidationError as exc:
|
|
354
|
+
errors.append((lineno, str(exc)))
|
|
355
|
+
|
|
356
|
+
total = len(rows)
|
|
357
|
+
if not errors:
|
|
358
|
+
print(f"OK — {total} event(s) passed schema validation.")
|
|
359
|
+
return 0
|
|
360
|
+
|
|
361
|
+
print(f"FAIL — {len(errors)} of {total} event(s) failed validation:\n")
|
|
362
|
+
for lineno, msg in errors:
|
|
363
|
+
print(f" line {lineno}: {msg}")
|
|
364
|
+
return 1
|
|
365
|
+
|
|
366
|
+
|
|
367
|
+
|
|
368
|
+
|
|
369
|
+
def _cmd_inspect(args: argparse.Namespace) -> int:
|
|
370
|
+
"""Implement the ``inspect`` sub-command."""
|
|
371
|
+
path = Path(args.file)
|
|
372
|
+
if not path.exists():
|
|
373
|
+
print(f"error: file not found: {path}", file=sys.stderr)
|
|
374
|
+
return 2
|
|
375
|
+
|
|
376
|
+
rows = _read_jsonl_events(path)
|
|
377
|
+
target_id = args.event_id
|
|
378
|
+
|
|
379
|
+
for _lineno, item in rows:
|
|
380
|
+
if isinstance(item, Exception):
|
|
381
|
+
continue
|
|
382
|
+
if item.event_id == target_id:
|
|
383
|
+
print(json.dumps(item.to_dict(), indent=2))
|
|
384
|
+
return 0
|
|
385
|
+
|
|
386
|
+
print(f"error: event_id {target_id!r} not found in {path}", file=sys.stderr)
|
|
387
|
+
return 1
|
|
388
|
+
|
|
389
|
+
|
|
390
|
+
def _accumulate_stats(
|
|
391
|
+
rows: list[tuple[int, Any]],
|
|
392
|
+
) -> tuple[dict[str, int], int, int, int, float, list[str], int]:
|
|
393
|
+
"""Aggregate token/cost/type counters from parsed event rows."""
|
|
394
|
+
type_counts: dict[str, int] = {}
|
|
395
|
+
prompt_tokens = 0
|
|
396
|
+
completion_tokens = 0
|
|
397
|
+
total_tokens = 0
|
|
398
|
+
cost_usd = 0.0
|
|
399
|
+
timestamps: list[str] = []
|
|
400
|
+
parse_errors = 0
|
|
401
|
+
for _lineno, item in rows:
|
|
402
|
+
if isinstance(item, Exception):
|
|
403
|
+
parse_errors += 1
|
|
404
|
+
continue
|
|
405
|
+
event_type = str(item.event_type) if item.event_type else "(unknown)"
|
|
406
|
+
type_counts[event_type] = type_counts.get(event_type, 0) + 1
|
|
407
|
+
payload = item.payload or {}
|
|
408
|
+
prompt_tokens += int(payload.get("prompt_tokens") or 0)
|
|
409
|
+
completion_tokens += int(payload.get("completion_tokens") or 0)
|
|
410
|
+
total_tokens += int(payload.get("total_tokens") or 0)
|
|
411
|
+
cost_usd += float(payload.get("cost_usd") or 0.0)
|
|
412
|
+
if item.timestamp:
|
|
413
|
+
timestamps.append(item.timestamp)
|
|
414
|
+
return (
|
|
415
|
+
type_counts,
|
|
416
|
+
prompt_tokens,
|
|
417
|
+
completion_tokens,
|
|
418
|
+
total_tokens,
|
|
419
|
+
cost_usd,
|
|
420
|
+
timestamps,
|
|
421
|
+
parse_errors,
|
|
422
|
+
)
|
|
423
|
+
|
|
424
|
+
|
|
425
|
+
def _cmd_stats(args: argparse.Namespace) -> int:
|
|
426
|
+
"""Implement the ``stats`` sub-command."""
|
|
427
|
+
path = Path(args.file)
|
|
428
|
+
if not path.exists():
|
|
429
|
+
print(f"error: file not found: {path}", file=sys.stderr)
|
|
430
|
+
return 2
|
|
431
|
+
|
|
432
|
+
rows = _read_jsonl_events(path)
|
|
433
|
+
if not rows:
|
|
434
|
+
print(_NO_EVENTS_MSG)
|
|
435
|
+
return 0
|
|
436
|
+
|
|
437
|
+
(
|
|
438
|
+
type_counts,
|
|
439
|
+
prompt_tokens,
|
|
440
|
+
completion_tokens,
|
|
441
|
+
total_tokens,
|
|
442
|
+
cost_usd,
|
|
443
|
+
timestamps,
|
|
444
|
+
parse_errors,
|
|
445
|
+
) = _accumulate_stats(rows)
|
|
446
|
+
|
|
447
|
+
total_events = len(rows) - parse_errors
|
|
448
|
+
print(
|
|
449
|
+
f"Events: {total_events}"
|
|
450
|
+
+ (f" ({parse_errors} parse error(s) skipped)" if parse_errors else "")
|
|
451
|
+
)
|
|
452
|
+
print()
|
|
453
|
+
|
|
454
|
+
if type_counts:
|
|
455
|
+
print(f"{'Event Type':<55} {'Count':>7}")
|
|
456
|
+
print("-" * 65)
|
|
457
|
+
for et, cnt in sorted(type_counts.items(), key=lambda x: -x[1]):
|
|
458
|
+
print(f" {et:<53} {cnt:>7}")
|
|
459
|
+
print()
|
|
460
|
+
|
|
461
|
+
print(f"Prompt tokens: {prompt_tokens:>12,}")
|
|
462
|
+
print(f"Completion tokens: {completion_tokens:>12,}")
|
|
463
|
+
print(f"Total tokens: {total_tokens:>12,}")
|
|
464
|
+
print(f"Cost (USD): {cost_usd:>12.6f}")
|
|
465
|
+
print()
|
|
466
|
+
|
|
467
|
+
if timestamps:
|
|
468
|
+
ts_sorted = sorted(timestamps)
|
|
469
|
+
print(f"Earliest: {ts_sorted[0]}")
|
|
470
|
+
print(f"Latest: {ts_sorted[-1]}")
|
|
471
|
+
|
|
472
|
+
return 0
|
|
473
|
+
|
|
474
|
+
|
|
475
|
+
|
|
476
|
+
|
|
477
|
+
def _cmd_dev(args: argparse.Namespace) -> int:
|
|
478
|
+
"""Implement ``spanforge dev <action>``."""
|
|
479
|
+
from spanforge.core.dx import DevCLI
|
|
480
|
+
|
|
481
|
+
action = getattr(args, "dev_command", None)
|
|
482
|
+
if action is None:
|
|
483
|
+
print("error: specify a dev sub-command: start, stop, reset, logs, status", file=sys.stderr)
|
|
484
|
+
return 2
|
|
485
|
+
|
|
486
|
+
cli = DevCLI()
|
|
487
|
+
if action == "start":
|
|
488
|
+
service = getattr(args, "service", "spanforge-dev")
|
|
489
|
+
cli.start(service)
|
|
490
|
+
print(f"[✓] Dev environment started service={service!r}")
|
|
491
|
+
elif action == "stop":
|
|
492
|
+
cli.stop()
|
|
493
|
+
print("[✓] Dev environment stopped (no buffered spans)")
|
|
494
|
+
elif action == "reset":
|
|
495
|
+
cli.reset()
|
|
496
|
+
print("[✓] Dev environment reset")
|
|
497
|
+
elif action == "logs":
|
|
498
|
+
entries = cli.logs()
|
|
499
|
+
if not entries:
|
|
500
|
+
print("(no log entries for this session)")
|
|
501
|
+
else:
|
|
502
|
+
for line in entries:
|
|
503
|
+
print(line)
|
|
504
|
+
elif action == "status":
|
|
505
|
+
status = cli.status()
|
|
506
|
+
print(json.dumps(status, indent=2))
|
|
507
|
+
else:
|
|
508
|
+
print(f"error: unknown dev sub-command: {action!r}", file=sys.stderr)
|
|
509
|
+
return 2
|
|
510
|
+
return 0
|
|
511
|
+
|
|
512
|
+
|
|
513
|
+
def _cmd_module_create(args: argparse.Namespace) -> int:
|
|
514
|
+
"""Implement ``spanforge module create``."""
|
|
515
|
+
from spanforge.core.dx import ModuleCLI
|
|
516
|
+
|
|
517
|
+
base_dir = Path(getattr(args, "output_dir", ".") or ".")
|
|
518
|
+
cli = ModuleCLI()
|
|
519
|
+
try:
|
|
520
|
+
scaffolded = cli.scaffold(
|
|
521
|
+
module_name=args.name,
|
|
522
|
+
trust_level=getattr(args, "trust_level", "UNTRUSTED"),
|
|
523
|
+
author=getattr(args, "author", "unknown"),
|
|
524
|
+
base_dir=base_dir,
|
|
525
|
+
)
|
|
526
|
+
except Exception as exc:
|
|
527
|
+
print(f"error: scaffolding failed: {exc}", file=sys.stderr)
|
|
528
|
+
return 1
|
|
529
|
+
|
|
530
|
+
# Write generated files to disk
|
|
531
|
+
root = scaffolded.root_dir
|
|
532
|
+
root.mkdir(parents=True, exist_ok=True)
|
|
533
|
+
for rel_path, content in scaffolded.files.items():
|
|
534
|
+
file_path = root / rel_path
|
|
535
|
+
file_path.parent.mkdir(parents=True, exist_ok=True)
|
|
536
|
+
file_path.write_text(content, encoding="utf-8")
|
|
537
|
+
print(f"[✓] {file_path}")
|
|
538
|
+
|
|
539
|
+
print(f"\nModule {args.name!r} created at {root}")
|
|
540
|
+
return 0
|
|
541
|
+
|
|
542
|
+
|
|
543
|
+
def _cmd_serve(args: argparse.Namespace) -> int:
|
|
544
|
+
"""Implement ``spanforge serve`` — start a local trace viewer."""
|
|
545
|
+
import signal
|
|
546
|
+
|
|
547
|
+
from spanforge._server import TraceViewerServer
|
|
548
|
+
|
|
549
|
+
port: int = getattr(args, "port", 8888)
|
|
550
|
+
host: str = getattr(args, "host", "127.0.0.1")
|
|
551
|
+
jsonl_file: str | None = getattr(args, "file", None)
|
|
552
|
+
|
|
553
|
+
# Pre-load a JSONL file if provided.
|
|
554
|
+
if jsonl_file:
|
|
555
|
+
try:
|
|
556
|
+
import json
|
|
557
|
+
|
|
558
|
+
from spanforge._store import get_store
|
|
559
|
+
from spanforge.event import Event
|
|
560
|
+
|
|
561
|
+
store = get_store()
|
|
562
|
+
loaded = 0
|
|
563
|
+
with open(Path(jsonl_file), encoding="utf-8") as fh:
|
|
564
|
+
for line in fh:
|
|
565
|
+
line = line.strip()
|
|
566
|
+
if not line:
|
|
567
|
+
continue
|
|
568
|
+
raw = json.loads(line)
|
|
569
|
+
try:
|
|
570
|
+
evt = Event.from_dict(raw)
|
|
571
|
+
store.record(evt)
|
|
572
|
+
loaded += 1
|
|
573
|
+
except Exception as exc:
|
|
574
|
+
_ = exc
|
|
575
|
+
print(f"[spanforge] Loaded {loaded} events from {jsonl_file!r}")
|
|
576
|
+
except FileNotFoundError:
|
|
577
|
+
print(f"error: file not found: {jsonl_file!r}", file=sys.stderr)
|
|
578
|
+
return 1
|
|
579
|
+
except Exception as exc:
|
|
580
|
+
print(f"error: could not load file: {exc}", file=sys.stderr)
|
|
581
|
+
return 1
|
|
582
|
+
|
|
583
|
+
server = TraceViewerServer(port=port, host=host)
|
|
584
|
+
server.start()
|
|
585
|
+
print(f"[spanforge] Serving traces at http://{host}:{port}/traces")
|
|
586
|
+
print("[spanforge] Press Ctrl+C to stop.")
|
|
587
|
+
|
|
588
|
+
# Block until SIGINT / SIGTERM.
|
|
589
|
+
stop_event = threading.Event()
|
|
590
|
+
|
|
591
|
+
def _handle_signal(sig: int, frame: object) -> None:
|
|
592
|
+
stop_event.set()
|
|
593
|
+
|
|
594
|
+
signal.signal(signal.SIGINT, _handle_signal)
|
|
595
|
+
with contextlib.suppress(OSError, ValueError):
|
|
596
|
+
signal.signal(
|
|
597
|
+
signal.SIGTERM, _handle_signal
|
|
598
|
+
) # SIGTERM not available on Windows in some contexts
|
|
599
|
+
|
|
600
|
+
stop_event.wait()
|
|
601
|
+
server.stop()
|
|
602
|
+
return 0
|
|
603
|
+
|
|
604
|
+
|
|
605
|
+
# ---------------------------------------------------------------------------
|
|
606
|
+
# New Phase B sub-commands: init, quickstart, report, ui
|
|
607
|
+
# ---------------------------------------------------------------------------
|
|
608
|
+
|
|
609
|
+
_SPANFORGE_TOML_TEMPLATE = """\
|
|
610
|
+
# spanforge.toml — project-level spanforge configuration
|
|
611
|
+
# Generated by: spanforge init
|
|
612
|
+
# Reference: https://www.getspanforge.com/docs/configuration
|
|
613
|
+
|
|
614
|
+
[spanforge]
|
|
615
|
+
service_name = "{service_name}"
|
|
616
|
+
env = "development" # development | staging | production
|
|
617
|
+
exporter = "console" # console | jsonl | otlp | webhook | datadog | grafana_loki
|
|
618
|
+
|
|
619
|
+
# Uncomment to write events to a local JSONL file:
|
|
620
|
+
# endpoint = "events.jsonl"
|
|
621
|
+
|
|
622
|
+
# Uncomment to enable HMAC audit-chain signing:
|
|
623
|
+
# signing_key = "" # base64-encoded 32-byte key; set via SPANFORGE_SIGNING_KEY env var
|
|
624
|
+
|
|
625
|
+
# PII redaction — enabled by default in production:
|
|
626
|
+
[spanforge.redaction]
|
|
627
|
+
enabled = true
|
|
628
|
+
|
|
629
|
+
# Sampling:
|
|
630
|
+
[spanforge.sampling]
|
|
631
|
+
rate = 1.0 # 1.0 = emit all events; 0.1 = 10 % sample
|
|
632
|
+
always_sample_errors = true
|
|
633
|
+
"""
|
|
634
|
+
|
|
635
|
+
_EXAMPLE_PY_TEMPLATE = '''\
|
|
636
|
+
"""Example: tracing an LLM call with spanforge.
|
|
637
|
+
|
|
638
|
+
Run: python examples/trace_llm.py
|
|
639
|
+
"""
|
|
640
|
+
|
|
641
|
+
import spanforge
|
|
642
|
+
|
|
643
|
+
spanforge.configure(exporter="console", service_name="{service_name}")
|
|
644
|
+
|
|
645
|
+
with spanforge.span("call-llm") as span:
|
|
646
|
+
span.set_model(model="gpt-4o", system="openai")
|
|
647
|
+
# --- replace with your real LLM call ---
|
|
648
|
+
result = {{"role": "assistant", "content": "Hello, world!"}}
|
|
649
|
+
# ---------------------------------------
|
|
650
|
+
span.set_token_usage(input=10, output=8, total=18)
|
|
651
|
+
span.set_status("ok")
|
|
652
|
+
|
|
653
|
+
print("Event emitted. Check above output for the JSON envelope.")
|
|
654
|
+
'''
|
|
655
|
+
|
|
656
|
+
|
|
657
|
+
def _cmd_init(args: argparse.Namespace) -> int:
|
|
658
|
+
"""Implement the ``init`` sub-command — scaffold spanforge.toml in current dir."""
|
|
659
|
+
out_dir = Path(args.output_dir).resolve()
|
|
660
|
+
out_dir.mkdir(parents=True, exist_ok=True)
|
|
661
|
+
|
|
662
|
+
toml_path = out_dir / "spanforge.toml"
|
|
663
|
+
if toml_path.exists() and not args.force:
|
|
664
|
+
print(f"[!] {toml_path} already exists. Use --force to overwrite.", file=sys.stderr)
|
|
665
|
+
return 1
|
|
666
|
+
|
|
667
|
+
service_name = args.service_name or Path.cwd().name or "my-service"
|
|
668
|
+
toml_path.write_text(
|
|
669
|
+
_SPANFORGE_TOML_TEMPLATE.format(service_name=service_name), encoding="utf-8"
|
|
670
|
+
)
|
|
671
|
+
print(f"[OK] Created {toml_path}")
|
|
672
|
+
|
|
673
|
+
examples_dir = out_dir / "examples"
|
|
674
|
+
examples_dir.mkdir(exist_ok=True)
|
|
675
|
+
ex_path = examples_dir / "trace_llm.py"
|
|
676
|
+
if not ex_path.exists():
|
|
677
|
+
ex_path.write_text(_EXAMPLE_PY_TEMPLATE.format(service_name=service_name), encoding="utf-8")
|
|
678
|
+
print(f"[OK] Created {ex_path}")
|
|
679
|
+
|
|
680
|
+
print("\nNext steps:")
|
|
681
|
+
print(f" 1. Edit {toml_path} to configure your exporter.")
|
|
682
|
+
print(" 2. Run: python examples/trace_llm.py")
|
|
683
|
+
print(" 3. Run: spanforge check")
|
|
684
|
+
return 0
|
|
685
|
+
|
|
686
|
+
|
|
687
|
+
def _cmd_quickstart(_args: argparse.Namespace) -> int:
|
|
688
|
+
"""Implement the ``quickstart`` sub-command — interactive setup wizard."""
|
|
689
|
+
print("spanforge quickstart wizard")
|
|
690
|
+
print("=" * 40)
|
|
691
|
+
print("This wizard will configure spanforge for your project.\n")
|
|
692
|
+
|
|
693
|
+
try:
|
|
694
|
+
service_name = input("Service name [my-service]: ").strip() or "my-service"
|
|
695
|
+
env = (
|
|
696
|
+
input("Environment (development/staging/production) [development]: ").strip()
|
|
697
|
+
or "development"
|
|
698
|
+
)
|
|
699
|
+
exporter = input("Exporter (console/jsonl/otlp/datadog) [console]: ").strip() or "console"
|
|
700
|
+
endpoint = ""
|
|
701
|
+
if exporter == "jsonl":
|
|
702
|
+
endpoint = input("JSONL output path [events.jsonl]: ").strip() or "events.jsonl"
|
|
703
|
+
elif exporter in ("otlp", "datadog"):
|
|
704
|
+
endpoint = input("Endpoint URL: ").strip()
|
|
705
|
+
enable_signing = input("Enable HMAC signing? (y/N): ").strip().lower() in ("y", "yes")
|
|
706
|
+
except (KeyboardInterrupt, EOFError):
|
|
707
|
+
print("\nAborted.", file=sys.stderr)
|
|
708
|
+
return 1
|
|
709
|
+
|
|
710
|
+
lines = [
|
|
711
|
+
"# spanforge.toml — generated by spanforge quickstart",
|
|
712
|
+
"[spanforge]",
|
|
713
|
+
f'service_name = "{service_name}"',
|
|
714
|
+
f'env = "{env}"',
|
|
715
|
+
f'exporter = "{exporter}"',
|
|
716
|
+
]
|
|
717
|
+
if endpoint:
|
|
718
|
+
lines.append(f'endpoint = "{endpoint}"')
|
|
719
|
+
if enable_signing:
|
|
720
|
+
lines.append('# signing_key = "" # export SPANFORGE_SIGNING_KEY=<key>')
|
|
721
|
+
Path("spanforge.toml").write_text("\n".join(lines) + "\n", encoding="utf-8")
|
|
722
|
+
print("[OK] Wrote spanforge.toml")
|
|
723
|
+
|
|
724
|
+
print("\nRunning health check ...")
|
|
725
|
+
import importlib
|
|
726
|
+
|
|
727
|
+
try:
|
|
728
|
+
sf = importlib.import_module("spanforge")
|
|
729
|
+
sf.configure(exporter=exporter, service_name=service_name, env=env)
|
|
730
|
+
with sf.span("quickstart-test") as span:
|
|
731
|
+
span.set_status("ok")
|
|
732
|
+
print("[OK] Test event emitted successfully!")
|
|
733
|
+
except Exception as exc:
|
|
734
|
+
print(f"[!] Health check failed: {exc}", file=sys.stderr)
|
|
735
|
+
|
|
736
|
+
print("\nSetup complete. Run 'spanforge check' any time to verify your pipeline.")
|
|
737
|
+
return 0
|
|
738
|
+
|
|
739
|
+
|
|
740
|
+
def _cmd_report(args: argparse.Namespace) -> int:
|
|
741
|
+
"""Implement the ``report`` sub-command — generate a static HTML trace report."""
|
|
742
|
+
src = Path(args.file)
|
|
743
|
+
if not src.exists():
|
|
744
|
+
print(f"[x] File not found: {src}", file=sys.stderr)
|
|
745
|
+
return 1
|
|
746
|
+
|
|
747
|
+
out_path = Path(args.output)
|
|
748
|
+
events: list[dict[str, Any]] = []
|
|
749
|
+
with src.open(encoding="utf-8") as fh:
|
|
750
|
+
for lineno, line in enumerate(fh, 1):
|
|
751
|
+
line = line.strip()
|
|
752
|
+
if not line:
|
|
753
|
+
continue
|
|
754
|
+
try:
|
|
755
|
+
events.append(json.loads(line))
|
|
756
|
+
except json.JSONDecodeError as exc:
|
|
757
|
+
print(f"[!] Line {lineno}: {exc}", file=sys.stderr)
|
|
758
|
+
|
|
759
|
+
if not events:
|
|
760
|
+
print(_NO_EVENTS_MSG)
|
|
761
|
+
return 0
|
|
762
|
+
|
|
763
|
+
rows: list[str] = []
|
|
764
|
+
for ev in events:
|
|
765
|
+
ts = ev.get("timestamp", "")[:19]
|
|
766
|
+
ns = ev.get("namespace", "")
|
|
767
|
+
eid = ev.get("event_id", "")[:8]
|
|
768
|
+
svc = ev.get("service_name", "")
|
|
769
|
+
payload_str = json.dumps(ev.get("payload", {}), separators=(",", ":"))[:120]
|
|
770
|
+
rows.append(
|
|
771
|
+
f"<tr><td>{ts}</td><td><code>{ns}</code></td>"
|
|
772
|
+
f"<td><code>{eid}</code></td><td>{svc}</td>"
|
|
773
|
+
f"<td><pre style='margin:0;font-size:11px'>{payload_str}</pre></td></tr>"
|
|
774
|
+
)
|
|
775
|
+
|
|
776
|
+
html = (
|
|
777
|
+
"<!DOCTYPE html>\n<html lang='en'>\n<head>\n"
|
|
778
|
+
" <meta charset='utf-8'/>\n"
|
|
779
|
+
f" <title>spanforge report \u2014 {src.name}</title>\n"
|
|
780
|
+
" <style>\n"
|
|
781
|
+
" body{font-family:system-ui,sans-serif;padding:1rem 2rem}\n"
|
|
782
|
+
" h1{font-size:1.3rem;color:#333}\n"
|
|
783
|
+
" table{border-collapse:collapse;width:100%;font-size:13px}\n"
|
|
784
|
+
" th,td{border:1px solid #ddd;padding:6px 8px;text-align:left;vertical-align:top}\n"
|
|
785
|
+
" th{background:#f4f4f4}\n"
|
|
786
|
+
" tr:nth-child(even){background:#fafafa}\n"
|
|
787
|
+
" </style>\n</head>\n<body>\n"
|
|
788
|
+
" <h1>spanforge \u2014 Trace Report</h1>\n"
|
|
789
|
+
f" <p>Source: <code>{src}</code> | Events: <strong>{len(events)}</strong></p>\n"
|
|
790
|
+
" <table>\n <thead>\n"
|
|
791
|
+
" <tr><th>Timestamp</th><th>Namespace</th><th>Event ID</th>"
|
|
792
|
+
"<th>Service</th><th>Payload</th></tr>\n"
|
|
793
|
+
" </thead>\n <tbody>\n"
|
|
794
|
+
+ "".join(f" {r}\n" for r in rows)
|
|
795
|
+
+ " </tbody>\n </table>\n</body>\n</html>"
|
|
796
|
+
)
|
|
797
|
+
|
|
798
|
+
out_path.write_text(html, encoding="utf-8")
|
|
799
|
+
print(f"[OK] Report written to {out_path} ({len(events)} events)")
|
|
800
|
+
return 0
|
|
801
|
+
|
|
802
|
+
|
|
803
|
+
def _cmd_ui(args: argparse.Namespace) -> int:
|
|
804
|
+
"""Implement the ``ui`` sub-command — serve the interactive SPA trace viewer."""
|
|
805
|
+
import signal
|
|
806
|
+
import webbrowser
|
|
807
|
+
|
|
808
|
+
from spanforge._server import TraceViewerServer
|
|
809
|
+
|
|
810
|
+
port = args.port
|
|
811
|
+
|
|
812
|
+
if args.file:
|
|
813
|
+
src = Path(args.file)
|
|
814
|
+
if not src.exists():
|
|
815
|
+
print(f"[x] File not found: {src}", file=sys.stderr)
|
|
816
|
+
return 1
|
|
817
|
+
from spanforge._store import get_store
|
|
818
|
+
from spanforge.event import Event
|
|
819
|
+
|
|
820
|
+
store = get_store()
|
|
821
|
+
loaded = 0
|
|
822
|
+
with src.open(encoding="utf-8") as fh:
|
|
823
|
+
for line in fh:
|
|
824
|
+
line = line.strip()
|
|
825
|
+
if not line:
|
|
826
|
+
continue
|
|
827
|
+
try:
|
|
828
|
+
store.record(Event.from_dict(json.loads(line)))
|
|
829
|
+
loaded += 1
|
|
830
|
+
except Exception as exc:
|
|
831
|
+
_ = exc
|
|
832
|
+
print(f"[spanforge] Loaded {loaded} events from {str(src)!r}")
|
|
833
|
+
|
|
834
|
+
server = TraceViewerServer(port=port, host="127.0.0.1")
|
|
835
|
+
server.start()
|
|
836
|
+
url = f"http://127.0.0.1:{port}/"
|
|
837
|
+
print(f"[spanforge] Trace viewer running at {url}")
|
|
838
|
+
print("[spanforge] Press Ctrl+C to stop.")
|
|
839
|
+
|
|
840
|
+
if not args.no_browser:
|
|
841
|
+
webbrowser.open(url)
|
|
842
|
+
|
|
843
|
+
stop_evt = threading.Event()
|
|
844
|
+
|
|
845
|
+
def _handle_sig(*_: object) -> None:
|
|
846
|
+
stop_evt.set()
|
|
847
|
+
|
|
848
|
+
signal.signal(signal.SIGINT, _handle_sig)
|
|
849
|
+
if hasattr(signal, "SIGTERM"):
|
|
850
|
+
signal.signal(signal.SIGTERM, _handle_sig)
|
|
851
|
+
|
|
852
|
+
stop_evt.wait()
|
|
853
|
+
server.stop()
|
|
854
|
+
print("\n[spanforge] Stopped.")
|
|
855
|
+
return 0
|
|
856
|
+
|
|
857
|
+
|
|
858
|
+
|
|
859
|
+
|
|
860
|
+
def _cmd_scan(args: argparse.Namespace) -> int:
|
|
861
|
+
"""Implement ``spanforge scan`` — deep PII scan on a JSONL file."""
|
|
862
|
+
from spanforge.redact import scan_payload
|
|
863
|
+
|
|
864
|
+
path = Path(args.file)
|
|
865
|
+
if not path.exists():
|
|
866
|
+
print(f"error: file not found: {path}", file=sys.stderr)
|
|
867
|
+
return 2
|
|
868
|
+
|
|
869
|
+
rows = _read_jsonl_events(path)
|
|
870
|
+
if not rows:
|
|
871
|
+
print(_NO_EVENTS_MSG)
|
|
872
|
+
return 0
|
|
873
|
+
|
|
874
|
+
# GA-03-D: --types filter
|
|
875
|
+
type_filter: set[str] | None = None
|
|
876
|
+
raw_types = getattr(args, "types", None)
|
|
877
|
+
if raw_types:
|
|
878
|
+
type_filter = {t.strip().lower() for t in raw_types.split(",")}
|
|
879
|
+
|
|
880
|
+
all_hits: list[dict[str, str]] = []
|
|
881
|
+
total_scanned = 0
|
|
882
|
+
|
|
883
|
+
for idx, row in enumerate(rows):
|
|
884
|
+
if isinstance(row[1], Exception):
|
|
885
|
+
continue
|
|
886
|
+
event = row[1]
|
|
887
|
+
payload = getattr(event, "payload", None)
|
|
888
|
+
if not isinstance(payload, dict):
|
|
889
|
+
continue
|
|
890
|
+
result = scan_payload(payload)
|
|
891
|
+
total_scanned += result.scanned
|
|
892
|
+
for hit in result.hits:
|
|
893
|
+
if type_filter and hit.pii_type.lower() not in type_filter:
|
|
894
|
+
continue
|
|
895
|
+
all_hits.append(
|
|
896
|
+
{
|
|
897
|
+
"event_index": str(idx),
|
|
898
|
+
"event_id": getattr(event, "event_id", "unknown"),
|
|
899
|
+
"pii_type": hit.pii_type,
|
|
900
|
+
"path": hit.path,
|
|
901
|
+
"match_count": str(hit.match_count),
|
|
902
|
+
"sensitivity": hit.sensitivity,
|
|
903
|
+
}
|
|
904
|
+
)
|
|
905
|
+
|
|
906
|
+
fmt = getattr(args, "format", "text")
|
|
907
|
+
if fmt == "json":
|
|
908
|
+
import json as _json
|
|
909
|
+
|
|
910
|
+
print(
|
|
911
|
+
_json.dumps(
|
|
912
|
+
{
|
|
913
|
+
"file": str(path),
|
|
914
|
+
"events_scanned": len(rows),
|
|
915
|
+
"strings_scanned": total_scanned,
|
|
916
|
+
"pii_hits": len(all_hits),
|
|
917
|
+
"hits": all_hits,
|
|
918
|
+
},
|
|
919
|
+
indent=2,
|
|
920
|
+
)
|
|
921
|
+
)
|
|
922
|
+
else:
|
|
923
|
+
print(f"Scanned {len(rows)} events ({total_scanned} string values)")
|
|
924
|
+
if not all_hits:
|
|
925
|
+
print("[✓] No PII detected.")
|
|
926
|
+
else:
|
|
927
|
+
print(f"[!] Found {len(all_hits)} PII hit(s):\n")
|
|
928
|
+
for h in all_hits:
|
|
929
|
+
print(
|
|
930
|
+
f" event #{h['event_index']} ({h['event_id']}) "
|
|
931
|
+
f"{h['pii_type']:20s} path={h['path']} "
|
|
932
|
+
f"matches={h['match_count']} sensitivity={h['sensitivity']}"
|
|
933
|
+
)
|
|
934
|
+
|
|
935
|
+
# GA-03-D: --fail-on-match returns 1 on any hit
|
|
936
|
+
fail_on_match = getattr(args, "fail_on_match", False)
|
|
937
|
+
if fail_on_match and all_hits:
|
|
938
|
+
return 1
|
|
939
|
+
return 1 if all_hits else 0
|
|
940
|
+
|
|
941
|
+
|
|
942
|
+
def _cmd_migrate(args: argparse.Namespace) -> int:
|
|
943
|
+
"""Implement ``spanforge migrate`` — schema v1→v2 migration."""
|
|
944
|
+
from spanforge.migrate import migrate_file
|
|
945
|
+
|
|
946
|
+
path = Path(args.file)
|
|
947
|
+
if not path.exists():
|
|
948
|
+
print(f"error: file not found: {path}", file=sys.stderr)
|
|
949
|
+
return 2
|
|
950
|
+
|
|
951
|
+
output = getattr(args, "output", None)
|
|
952
|
+
target_version = getattr(args, "target_version", "2.0")
|
|
953
|
+
dry_run = getattr(args, "dry_run", False)
|
|
954
|
+
|
|
955
|
+
# GA-05-C: --sign reads SPANFORGE_SIGNING_KEY for chain re-signing
|
|
956
|
+
org_secret: str | None = None
|
|
957
|
+
if getattr(args, "sign", False):
|
|
958
|
+
org_secret = os.environ.get("SPANFORGE_SIGNING_KEY", "")
|
|
959
|
+
if not org_secret:
|
|
960
|
+
print("error: --sign requires SPANFORGE_SIGNING_KEY", file=sys.stderr)
|
|
961
|
+
return 2
|
|
962
|
+
|
|
963
|
+
stats = migrate_file(
|
|
964
|
+
path,
|
|
965
|
+
output=output,
|
|
966
|
+
org_secret=org_secret,
|
|
967
|
+
target_version=target_version,
|
|
968
|
+
dry_run=dry_run,
|
|
969
|
+
)
|
|
970
|
+
|
|
971
|
+
print(f"Total events: {stats.total}")
|
|
972
|
+
print(f"Migrated (v1→v2): {stats.migrated}")
|
|
973
|
+
print(f"Skipped (v2): {stats.skipped}")
|
|
974
|
+
print(f"Errors: {stats.errors}")
|
|
975
|
+
if stats.warnings:
|
|
976
|
+
print(f"Warnings: {len(stats.warnings)}")
|
|
977
|
+
for w in stats.warnings:
|
|
978
|
+
print(f" - {w}")
|
|
979
|
+
if stats.transformed_fields:
|
|
980
|
+
print("Transformations:")
|
|
981
|
+
for k, v in stats.transformed_fields.items():
|
|
982
|
+
print(f" {k}: {v}")
|
|
983
|
+
if dry_run:
|
|
984
|
+
print("(dry run — no files written)")
|
|
985
|
+
else:
|
|
986
|
+
print(f"Output: {stats.output_path}")
|
|
987
|
+
return 1 if stats.errors > 0 else 0
|
|
988
|
+
|
|
989
|
+
|
|
990
|
+
def _cmd_migrate_langsmith(args: argparse.Namespace) -> int:
|
|
991
|
+
"""Implement ``spanforge migrate-langsmith`` — LangSmith export import."""
|
|
992
|
+
import time as _time
|
|
993
|
+
|
|
994
|
+
from spanforge import EventType
|
|
995
|
+
from spanforge.ulid import generate as ulid_generate
|
|
996
|
+
|
|
997
|
+
path = Path(args.file)
|
|
998
|
+
if not path.exists():
|
|
999
|
+
print(f"error: file not found: {path}", file=sys.stderr)
|
|
1000
|
+
return 2
|
|
1001
|
+
|
|
1002
|
+
output = args.output or str(path).rsplit(".", 1)[0] + "_spanforge.jsonl"
|
|
1003
|
+
source = args.source
|
|
1004
|
+
|
|
1005
|
+
# Read LangSmith export (supports JSONL and JSON array)
|
|
1006
|
+
raw_runs: list[dict[str, Any]] = []
|
|
1007
|
+
content = path.read_text(encoding="utf-8")
|
|
1008
|
+
first_char = content.lstrip()[:1]
|
|
1009
|
+
if first_char == "[":
|
|
1010
|
+
# JSON array format
|
|
1011
|
+
try:
|
|
1012
|
+
raw_runs = json.loads(content)
|
|
1013
|
+
except json.JSONDecodeError as exc:
|
|
1014
|
+
print(f"error: invalid JSON: {exc}", file=sys.stderr)
|
|
1015
|
+
return 1
|
|
1016
|
+
else:
|
|
1017
|
+
# JSONL format
|
|
1018
|
+
for line in content.splitlines():
|
|
1019
|
+
line = line.strip()
|
|
1020
|
+
if line:
|
|
1021
|
+
try:
|
|
1022
|
+
raw_runs.append(json.loads(line))
|
|
1023
|
+
except json.JSONDecodeError:
|
|
1024
|
+
continue
|
|
1025
|
+
|
|
1026
|
+
if not raw_runs:
|
|
1027
|
+
print("error: no runs found in file", file=sys.stderr)
|
|
1028
|
+
return 1
|
|
1029
|
+
|
|
1030
|
+
events: list[dict[str, Any]] = []
|
|
1031
|
+
for run in raw_runs:
|
|
1032
|
+
# LangSmith run schema: id, name, run_type, inputs, outputs,
|
|
1033
|
+
# start_time, end_time, parent_run_id, trace_id, dotted_order,
|
|
1034
|
+
# total_tokens, prompt_tokens, completion_tokens, status, error
|
|
1035
|
+
run_type = run.get("run_type", "chain")
|
|
1036
|
+
run_name = run.get("name", "unknown")
|
|
1037
|
+
run_id = run.get("id", ulid_generate())
|
|
1038
|
+
|
|
1039
|
+
# Map LangSmith run_type → SpanForge EventType (F-27)
|
|
1040
|
+
if run_type == "llm":
|
|
1041
|
+
event_type = EventType.TRACE_SPAN_COMPLETED.value
|
|
1042
|
+
elif run_type in {"tool", "retriever"}:
|
|
1043
|
+
event_type = EventType.TOOL_CALL_COMPLETED.value
|
|
1044
|
+
elif run_type == "chain":
|
|
1045
|
+
event_type = EventType.CHAIN_COMPLETED.value
|
|
1046
|
+
else:
|
|
1047
|
+
event_type = EventType.TRACE_SPAN_COMPLETED.value
|
|
1048
|
+
|
|
1049
|
+
# Build payload
|
|
1050
|
+
payload: dict[str, Any] = {
|
|
1051
|
+
"span_name": run_name,
|
|
1052
|
+
"run_type": run_type,
|
|
1053
|
+
"status": run.get("status", "ok"),
|
|
1054
|
+
}
|
|
1055
|
+
|
|
1056
|
+
# Token usage
|
|
1057
|
+
total_tok = run.get("total_tokens") or 0
|
|
1058
|
+
prompt_tok = run.get("prompt_tokens") or 0
|
|
1059
|
+
completion_tok = run.get("completion_tokens") or 0
|
|
1060
|
+
if total_tok or prompt_tok or completion_tok:
|
|
1061
|
+
payload["token_usage"] = {
|
|
1062
|
+
"input_tokens": prompt_tok,
|
|
1063
|
+
"output_tokens": completion_tok,
|
|
1064
|
+
"total_tokens": total_tok or (prompt_tok + completion_tok),
|
|
1065
|
+
}
|
|
1066
|
+
|
|
1067
|
+
# Timing
|
|
1068
|
+
if run.get("start_time"):
|
|
1069
|
+
payload["start_time"] = run["start_time"]
|
|
1070
|
+
if run.get("end_time"):
|
|
1071
|
+
payload["end_time"] = run["end_time"]
|
|
1072
|
+
|
|
1073
|
+
# Inputs/outputs (sanitised — no raw content)
|
|
1074
|
+
if run.get("inputs"):
|
|
1075
|
+
payload["input_keys"] = (
|
|
1076
|
+
list(run["inputs"].keys()) if isinstance(run["inputs"], dict) else ["input"]
|
|
1077
|
+
)
|
|
1078
|
+
if run.get("outputs"):
|
|
1079
|
+
payload["output_keys"] = (
|
|
1080
|
+
list(run["outputs"].keys()) if isinstance(run["outputs"], dict) else ["output"]
|
|
1081
|
+
)
|
|
1082
|
+
|
|
1083
|
+
# Error info
|
|
1084
|
+
if run.get("error"):
|
|
1085
|
+
payload["error"] = str(run["error"])[:500]
|
|
1086
|
+
|
|
1087
|
+
# Build event
|
|
1088
|
+
trace_id = run.get("trace_id", run.get("session_id", ""))
|
|
1089
|
+
parent_id = run.get("parent_run_id", "")
|
|
1090
|
+
|
|
1091
|
+
event = {
|
|
1092
|
+
"event_id": ulid_generate(),
|
|
1093
|
+
"event_type": event_type,
|
|
1094
|
+
"source": source,
|
|
1095
|
+
"schema_version": "2.0",
|
|
1096
|
+
"timestamp": run.get("start_time") or _time.time(),
|
|
1097
|
+
"payload": payload,
|
|
1098
|
+
"tags": {
|
|
1099
|
+
"langsmith_run_id": str(run_id),
|
|
1100
|
+
"langsmith_trace_id": str(trace_id) if trace_id else "",
|
|
1101
|
+
"langsmith_parent_id": str(parent_id) if parent_id else "",
|
|
1102
|
+
},
|
|
1103
|
+
}
|
|
1104
|
+
events.append(event)
|
|
1105
|
+
|
|
1106
|
+
# Write output
|
|
1107
|
+
out_path = Path(output)
|
|
1108
|
+
with out_path.open("w", encoding="utf-8") as fh:
|
|
1109
|
+
for evt in events:
|
|
1110
|
+
fh.write(json.dumps(evt, default=str) + "\n")
|
|
1111
|
+
|
|
1112
|
+
print(f"[✓] Imported {len(events)} runs from LangSmith export")
|
|
1113
|
+
print(f" Source: {path}")
|
|
1114
|
+
print(f" Output: {out_path}")
|
|
1115
|
+
|
|
1116
|
+
# Summary by run_type
|
|
1117
|
+
type_counts: dict[str, int] = {}
|
|
1118
|
+
for run in raw_runs:
|
|
1119
|
+
rt = run.get("run_type", "unknown")
|
|
1120
|
+
type_counts[rt] = type_counts.get(rt, 0) + 1
|
|
1121
|
+
for rt, count in sorted(type_counts.items()):
|
|
1122
|
+
print(f" {rt}: {count}")
|
|
1123
|
+
|
|
1124
|
+
return 0
|
|
1125
|
+
|
|
1126
|
+
|
|
1127
|
+
|
|
1128
|
+
|
|
1129
|
+
# ---------------------------------------------------------------------------
|
|
1130
|
+
# T.R.U.S.T. Framework CLI handlers
|
|
1131
|
+
# ---------------------------------------------------------------------------
|
|
1132
|
+
|
|
1133
|
+
|
|
1134
|
+
def _cmd_consent(args: argparse.Namespace, parser: argparse.ArgumentParser) -> int:
|
|
1135
|
+
"""Handle ``spanforge consent`` subcommands."""
|
|
1136
|
+
from spanforge.consent import check_consent, grant_consent, revoke_consent
|
|
1137
|
+
|
|
1138
|
+
action = getattr(args, "consent_command", None)
|
|
1139
|
+
if action == "check":
|
|
1140
|
+
ok = check_consent(args.subject, args.scope)
|
|
1141
|
+
status = "GRANTED" if ok else "NOT GRANTED"
|
|
1142
|
+
print(f"consent({args.subject!r}, {args.scope!r}) = {status}")
|
|
1143
|
+
return 0 if ok else 1
|
|
1144
|
+
elif action == "grant":
|
|
1145
|
+
grant_consent(
|
|
1146
|
+
subject_id=args.subject,
|
|
1147
|
+
scope=args.scope,
|
|
1148
|
+
purpose=args.purpose,
|
|
1149
|
+
legal_basis=args.legal_basis,
|
|
1150
|
+
)
|
|
1151
|
+
print(f"[✓] Consent granted: subject={args.subject!r} scope={args.scope!r}")
|
|
1152
|
+
return 0
|
|
1153
|
+
elif action == "revoke":
|
|
1154
|
+
revoke_consent(subject_id=args.subject, scope=args.scope)
|
|
1155
|
+
print(f"[✓] Consent revoked: subject={args.subject!r} scope={args.scope!r}")
|
|
1156
|
+
return 0
|
|
1157
|
+
else:
|
|
1158
|
+
parser.print_help()
|
|
1159
|
+
return 2
|
|
1160
|
+
|
|
1161
|
+
|
|
1162
|
+
def _cmd_hitl(args: argparse.Namespace, parser: argparse.ArgumentParser) -> int:
|
|
1163
|
+
"""Handle ``spanforge hitl`` subcommands."""
|
|
1164
|
+
from spanforge.hitl import list_pending, review_item
|
|
1165
|
+
|
|
1166
|
+
action = getattr(args, "hitl_command", None)
|
|
1167
|
+
if action == "pending":
|
|
1168
|
+
items = list_pending()
|
|
1169
|
+
if not items:
|
|
1170
|
+
print("No pending HITL items.")
|
|
1171
|
+
else:
|
|
1172
|
+
for item in items:
|
|
1173
|
+
print(
|
|
1174
|
+
f" {item.decision_id} risk={item.risk_tier} "
|
|
1175
|
+
f"agent={item.agent_id} reason={item.reason}"
|
|
1176
|
+
)
|
|
1177
|
+
return 0
|
|
1178
|
+
elif action == "review":
|
|
1179
|
+
result = review_item(args.decision_id, args.reviewer, args.outcome)
|
|
1180
|
+
if result is None:
|
|
1181
|
+
print(f"[!] Decision {args.decision_id!r} not found in queue.")
|
|
1182
|
+
return 1
|
|
1183
|
+
print(f"[✓] Decision {args.decision_id!r} marked as {args.outcome} by {args.reviewer}")
|
|
1184
|
+
return 0
|
|
1185
|
+
else:
|
|
1186
|
+
parser.print_help()
|
|
1187
|
+
return 2
|
|
1188
|
+
|
|
1189
|
+
|
|
1190
|
+
def _cmd_model(args: argparse.Namespace, parser: argparse.ArgumentParser) -> int:
|
|
1191
|
+
"""Handle ``spanforge model`` subcommands."""
|
|
1192
|
+
from spanforge.model_registry import (
|
|
1193
|
+
deprecate_model,
|
|
1194
|
+
list_models,
|
|
1195
|
+
register_model,
|
|
1196
|
+
retire_model,
|
|
1197
|
+
)
|
|
1198
|
+
|
|
1199
|
+
action = getattr(args, "model_command", None)
|
|
1200
|
+
if action == "list":
|
|
1201
|
+
models = list_models()
|
|
1202
|
+
if not models:
|
|
1203
|
+
print("No models registered.")
|
|
1204
|
+
else:
|
|
1205
|
+
for m in models:
|
|
1206
|
+
print(
|
|
1207
|
+
f" {m.model_id} name={m.name!r} version={m.version} "
|
|
1208
|
+
f"status={m.status} risk={m.risk_tier} owner={m.owner}"
|
|
1209
|
+
)
|
|
1210
|
+
return 0
|
|
1211
|
+
elif action == "register":
|
|
1212
|
+
try:
|
|
1213
|
+
entry = register_model(
|
|
1214
|
+
model_id=args.model_id,
|
|
1215
|
+
name=args.name,
|
|
1216
|
+
version=args.version,
|
|
1217
|
+
risk_tier=args.risk_tier,
|
|
1218
|
+
owner=args.owner,
|
|
1219
|
+
purpose=args.purpose,
|
|
1220
|
+
)
|
|
1221
|
+
print(f"[✓] Model registered: {entry.model_id}")
|
|
1222
|
+
except ValueError as exc:
|
|
1223
|
+
print(f"[!] {exc}")
|
|
1224
|
+
return 1
|
|
1225
|
+
else:
|
|
1226
|
+
return 0
|
|
1227
|
+
elif action == "deprecate":
|
|
1228
|
+
try:
|
|
1229
|
+
entry = deprecate_model(args.model_id, reason=args.reason)
|
|
1230
|
+
print(f"[✓] Model deprecated: {entry.model_id}")
|
|
1231
|
+
except (KeyError, ValueError) as exc:
|
|
1232
|
+
print(f"[!] {exc}")
|
|
1233
|
+
return 1
|
|
1234
|
+
else:
|
|
1235
|
+
return 0
|
|
1236
|
+
elif action == "retire":
|
|
1237
|
+
try:
|
|
1238
|
+
entry = retire_model(args.model_id)
|
|
1239
|
+
print(f"[✓] Model retired: {entry.model_id}")
|
|
1240
|
+
except (KeyError, ValueError) as exc:
|
|
1241
|
+
print(f"[!] {exc}")
|
|
1242
|
+
return 1
|
|
1243
|
+
else:
|
|
1244
|
+
return 0
|
|
1245
|
+
else:
|
|
1246
|
+
parser.print_help()
|
|
1247
|
+
return 2
|
|
1248
|
+
|
|
1249
|
+
|
|
1250
|
+
def _cmd_explain(args: argparse.Namespace) -> int:
|
|
1251
|
+
"""Handle ``spanforge explain`` subcommand."""
|
|
1252
|
+
from spanforge.explain import generate_explanation
|
|
1253
|
+
|
|
1254
|
+
record = generate_explanation(
|
|
1255
|
+
trace_id=args.trace_id,
|
|
1256
|
+
agent_id=args.agent_id,
|
|
1257
|
+
decision_id=args.decision_id,
|
|
1258
|
+
factors=[],
|
|
1259
|
+
summary=args.summary,
|
|
1260
|
+
)
|
|
1261
|
+
print(record.to_text())
|
|
1262
|
+
return 0
|
|
1263
|
+
|
|
1264
|
+
|
|
1265
|
+
def _cmd_eval(args: argparse.Namespace, parser: argparse.ArgumentParser) -> int:
|
|
1266
|
+
"""Handle ``spanforge eval`` subcommands."""
|
|
1267
|
+
import json
|
|
1268
|
+
|
|
1269
|
+
action = getattr(args, "eval_command", None)
|
|
1270
|
+
|
|
1271
|
+
if action == "save":
|
|
1272
|
+
# Save examples to JSONL dataset file
|
|
1273
|
+
examples: list[dict[str, Any]] = []
|
|
1274
|
+
if getattr(args, "input", None):
|
|
1275
|
+
in_path = Path(args.input)
|
|
1276
|
+
if not in_path.exists():
|
|
1277
|
+
print(f"[!] File not found: {in_path}")
|
|
1278
|
+
return 1
|
|
1279
|
+
with in_path.open(encoding="utf-8") as fh:
|
|
1280
|
+
for line in fh:
|
|
1281
|
+
line = line.strip()
|
|
1282
|
+
if line:
|
|
1283
|
+
try:
|
|
1284
|
+
obj = json.loads(line)
|
|
1285
|
+
# Extract example-shaped data from events
|
|
1286
|
+
payload = obj.get("payload", obj)
|
|
1287
|
+
example: dict[str, Any] = {}
|
|
1288
|
+
if "output" in payload:
|
|
1289
|
+
example["output"] = payload["output"]
|
|
1290
|
+
elif "response" in payload:
|
|
1291
|
+
example["output"] = payload["response"]
|
|
1292
|
+
if "context" in payload:
|
|
1293
|
+
example["context"] = payload["context"]
|
|
1294
|
+
if "reference" in payload:
|
|
1295
|
+
example["reference"] = payload["reference"]
|
|
1296
|
+
if "input" in payload:
|
|
1297
|
+
example["input"] = payload["input"]
|
|
1298
|
+
elif "query" in payload:
|
|
1299
|
+
example["input"] = payload["query"]
|
|
1300
|
+
if "span_id" in obj:
|
|
1301
|
+
example["span_id"] = obj["span_id"]
|
|
1302
|
+
if "trace_id" in obj:
|
|
1303
|
+
example["trace_id"] = obj["trace_id"]
|
|
1304
|
+
if example:
|
|
1305
|
+
examples.append(example)
|
|
1306
|
+
except json.JSONDecodeError:
|
|
1307
|
+
continue
|
|
1308
|
+
else:
|
|
1309
|
+
print("[!] --input is required for 'eval save'")
|
|
1310
|
+
return 1
|
|
1311
|
+
|
|
1312
|
+
out_path = Path(args.output)
|
|
1313
|
+
with out_path.open("w", encoding="utf-8") as fh:
|
|
1314
|
+
for ex in examples:
|
|
1315
|
+
fh.write(json.dumps(ex, default=str) + "\n")
|
|
1316
|
+
print(f"[✓] Saved {len(examples)} examples to {out_path}")
|
|
1317
|
+
return 0
|
|
1318
|
+
|
|
1319
|
+
elif action == "run":
|
|
1320
|
+
from spanforge.eval import (
|
|
1321
|
+
EvalRunner,
|
|
1322
|
+
EvalScorer,
|
|
1323
|
+
FaithfulnessScorer,
|
|
1324
|
+
PIILeakageScorer,
|
|
1325
|
+
RefusalDetectionScorer,
|
|
1326
|
+
)
|
|
1327
|
+
|
|
1328
|
+
dataset_path = Path(args.file)
|
|
1329
|
+
if not dataset_path.exists():
|
|
1330
|
+
print(f"[!] File not found: {dataset_path}")
|
|
1331
|
+
return 1
|
|
1332
|
+
|
|
1333
|
+
dataset: list[dict[str, Any]] = []
|
|
1334
|
+
with dataset_path.open(encoding="utf-8") as fh:
|
|
1335
|
+
for line in fh:
|
|
1336
|
+
line = line.strip()
|
|
1337
|
+
if line:
|
|
1338
|
+
try:
|
|
1339
|
+
dataset.append(json.loads(line))
|
|
1340
|
+
except json.JSONDecodeError:
|
|
1341
|
+
continue
|
|
1342
|
+
|
|
1343
|
+
if not dataset:
|
|
1344
|
+
print("[!] No examples found in dataset file")
|
|
1345
|
+
return 1
|
|
1346
|
+
|
|
1347
|
+
# Build scorers from --scorers flag (or default to all)
|
|
1348
|
+
scorer_map: dict[str, type[EvalScorer]] = {
|
|
1349
|
+
"faithfulness": FaithfulnessScorer,
|
|
1350
|
+
"refusal": RefusalDetectionScorer,
|
|
1351
|
+
"pii_leakage": PIILeakageScorer,
|
|
1352
|
+
}
|
|
1353
|
+
requested = args.scorers.split(",") if args.scorers else list(scorer_map.keys())
|
|
1354
|
+
scorers: list[EvalScorer] = []
|
|
1355
|
+
for name in requested:
|
|
1356
|
+
name = name.strip()
|
|
1357
|
+
if name not in scorer_map:
|
|
1358
|
+
print(f"[!] Unknown scorer: {name} (available: {', '.join(scorer_map)})")
|
|
1359
|
+
return 1
|
|
1360
|
+
scorers.append(scorer_map[name]())
|
|
1361
|
+
|
|
1362
|
+
runner = EvalRunner(scorers=scorers, emit=False)
|
|
1363
|
+
report = runner.run(dataset)
|
|
1364
|
+
summary = report.summary()
|
|
1365
|
+
|
|
1366
|
+
fmt = getattr(args, "format", "text")
|
|
1367
|
+
if fmt == "json":
|
|
1368
|
+
result = {
|
|
1369
|
+
"examples": len(dataset),
|
|
1370
|
+
"scores": len(report.scores),
|
|
1371
|
+
"summary": summary,
|
|
1372
|
+
}
|
|
1373
|
+
print(json.dumps(result, indent=2, default=str))
|
|
1374
|
+
else:
|
|
1375
|
+
print(f"Dataset: {dataset_path} ({len(dataset)} examples)")
|
|
1376
|
+
print(f"{'Metric':<30} {'Mean':>10}")
|
|
1377
|
+
print("-" * 43)
|
|
1378
|
+
for metric, mean in sorted(summary.items()):
|
|
1379
|
+
print(f"{metric:<30} {mean:>10.4f}")
|
|
1380
|
+
print("-" * 43)
|
|
1381
|
+
print(f"Total scores: {len(report.scores)}")
|
|
1382
|
+
|
|
1383
|
+
return 0
|
|
1384
|
+
|
|
1385
|
+
else:
|
|
1386
|
+
parser.print_help()
|
|
1387
|
+
return 2
|
|
1388
|
+
|
|
1389
|
+
|
|
1390
|
+
# ---------------------------------------------------------------------------
|
|
1391
|
+
# secrets sub-command — SEC-040 secrets scanning
|
|
1392
|
+
# ---------------------------------------------------------------------------
|
|
1393
|
+
|
|
1394
|
+
|
|
1395
|
+
def _cmd_secrets(args: argparse.Namespace, secrets_parser: argparse.ArgumentParser) -> int:
|
|
1396
|
+
"""Implement the ``secrets`` sub-command group."""
|
|
1397
|
+
action = getattr(args, "secrets_command", None)
|
|
1398
|
+
if action == "scan":
|
|
1399
|
+
return _cmd_secrets_scan(args)
|
|
1400
|
+
secrets_parser.print_help()
|
|
1401
|
+
return 2
|
|
1402
|
+
|
|
1403
|
+
|
|
1404
|
+
def _cmd_secrets_scan(args: argparse.Namespace) -> int:
|
|
1405
|
+
"""Implement the ``secrets scan`` sub-command (SEC-040).
|
|
1406
|
+
|
|
1407
|
+
Reads a file (plain text, JSONL, or source code), scans every line for
|
|
1408
|
+
hard-coded secrets, and reports results.
|
|
1409
|
+
|
|
1410
|
+
Exit codes::
|
|
1411
|
+
|
|
1412
|
+
0 — no secrets detected above the confidence threshold.
|
|
1413
|
+
1 — secrets detected (CI gate mode).
|
|
1414
|
+
2 — usage or I/O error.
|
|
1415
|
+
"""
|
|
1416
|
+
from spanforge.secrets import SecretsScanner
|
|
1417
|
+
|
|
1418
|
+
file_path = Path(args.file)
|
|
1419
|
+
if not file_path.exists():
|
|
1420
|
+
print(f"error: file not found: {file_path}", file=sys.stderr)
|
|
1421
|
+
return 2
|
|
1422
|
+
|
|
1423
|
+
try:
|
|
1424
|
+
text = file_path.read_text(encoding="utf-8", errors="replace")
|
|
1425
|
+
except OSError as exc:
|
|
1426
|
+
print(f"error: could not read {file_path}: {exc}", file=sys.stderr)
|
|
1427
|
+
return 2
|
|
1428
|
+
|
|
1429
|
+
confidence = float(getattr(args, "confidence", 0.75))
|
|
1430
|
+
redact = getattr(args, "redact", False)
|
|
1431
|
+
fmt = getattr(args, "format", "text")
|
|
1432
|
+
|
|
1433
|
+
try:
|
|
1434
|
+
scanner = SecretsScanner(confidence_threshold=confidence)
|
|
1435
|
+
result = scanner.scan(text, confidence_threshold=confidence)
|
|
1436
|
+
except Exception as exc:
|
|
1437
|
+
print(f"error: scan failed: {exc}", file=sys.stderr)
|
|
1438
|
+
return 2
|
|
1439
|
+
|
|
1440
|
+
if fmt == "sarif":
|
|
1441
|
+
output = result.to_sarif(tool_name="spanforge-secrets")
|
|
1442
|
+
print(json.dumps(output, indent=2))
|
|
1443
|
+
return 1 if result.detected else 0
|
|
1444
|
+
|
|
1445
|
+
if fmt == "json":
|
|
1446
|
+
data = result.to_dict()
|
|
1447
|
+
if not redact:
|
|
1448
|
+
data.pop("redacted_text", None)
|
|
1449
|
+
print(json.dumps(data, indent=2))
|
|
1450
|
+
return 1 if result.detected else 0
|
|
1451
|
+
|
|
1452
|
+
# --- text output ---
|
|
1453
|
+
if not result.detected:
|
|
1454
|
+
print(f"[✓] No secrets detected in {file_path}")
|
|
1455
|
+
return 0
|
|
1456
|
+
|
|
1457
|
+
print(f"[✗] {len(result.hits)} secret(s) detected in {file_path}:")
|
|
1458
|
+
for hit in result.hits:
|
|
1459
|
+
block_marker = " [BLOCKED]" if hit.auto_blocked else ""
|
|
1460
|
+
print(
|
|
1461
|
+
f" [{hit.secret_type}] offset {hit.start}-{hit.end} "
|
|
1462
|
+
f"confidence={hit.confidence:.2f}{block_marker}"
|
|
1463
|
+
)
|
|
1464
|
+
if hit.vault_hint:
|
|
1465
|
+
print(f" Hint: {hit.vault_hint}")
|
|
1466
|
+
|
|
1467
|
+
if redact:
|
|
1468
|
+
print("\n--- Redacted output ---")
|
|
1469
|
+
print(result.redacted_text)
|
|
1470
|
+
|
|
1471
|
+
if result.auto_blocked:
|
|
1472
|
+
print(
|
|
1473
|
+
"\nAUTO-BLOCKED: Zero-tolerance or high-confidence secrets detected. "
|
|
1474
|
+
"Remove secrets and rotate credentials before committing.",
|
|
1475
|
+
file=sys.stderr,
|
|
1476
|
+
)
|
|
1477
|
+
|
|
1478
|
+
return 1
|
|
1479
|
+
|
|
1480
|
+
|
|
1481
|
+
|
|
1482
|
+
|
|
1483
|
+
|
|
1484
|
+
def main(argv: list[str] | None = None) -> NoReturn:
|
|
1485
|
+
"""Entry point for the ``spanforge`` CLI tool."""
|
|
1486
|
+
from spanforge import CONFORMANCE_PROFILE, __version__
|
|
1487
|
+
|
|
1488
|
+
parser = argparse.ArgumentParser(
|
|
1489
|
+
prog="spanforge",
|
|
1490
|
+
description="spanforge command-line utilities",
|
|
1491
|
+
)
|
|
1492
|
+
parser.add_argument(
|
|
1493
|
+
"-V",
|
|
1494
|
+
"--version",
|
|
1495
|
+
action="version",
|
|
1496
|
+
version=f"spanforge {__version__} [{CONFORMANCE_PROFILE}]",
|
|
1497
|
+
)
|
|
1498
|
+
sub = parser.add_subparsers(dest="command", metavar="<command>")
|
|
1499
|
+
|
|
1500
|
+
# check sub-command (health check)
|
|
1501
|
+
sub.add_parser(
|
|
1502
|
+
"check",
|
|
1503
|
+
help="End-to-end health check: validates config, emits a test event, confirms export pipeline",
|
|
1504
|
+
)
|
|
1505
|
+
|
|
1506
|
+
# check-compat sub-command
|
|
1507
|
+
compat_parser = sub.add_parser(
|
|
1508
|
+
"check-compat",
|
|
1509
|
+
help="Check a JSON file of events against the v1.0 compatibility checklist",
|
|
1510
|
+
)
|
|
1511
|
+
compat_parser.add_argument(
|
|
1512
|
+
"file",
|
|
1513
|
+
metavar="EVENTS_JSON",
|
|
1514
|
+
help="Path to a JSON file containing a list of serialised events",
|
|
1515
|
+
)
|
|
1516
|
+
|
|
1517
|
+
# list-deprecated sub-command
|
|
1518
|
+
sub.add_parser(
|
|
1519
|
+
"list-deprecated",
|
|
1520
|
+
help="Print all deprecated event types from the global deprecation registry",
|
|
1521
|
+
)
|
|
1522
|
+
|
|
1523
|
+
# migration-roadmap sub-command
|
|
1524
|
+
roadmap_parser = sub.add_parser(
|
|
1525
|
+
"migration-roadmap",
|
|
1526
|
+
help="Print the planned v1 → v2 migration roadmap",
|
|
1527
|
+
)
|
|
1528
|
+
roadmap_parser.add_argument(
|
|
1529
|
+
"--json",
|
|
1530
|
+
action="store_true",
|
|
1531
|
+
default=False,
|
|
1532
|
+
help="Emit JSON output for machine consumption",
|
|
1533
|
+
)
|
|
1534
|
+
|
|
1535
|
+
# check-consumers sub-command
|
|
1536
|
+
sub.add_parser(
|
|
1537
|
+
"check-consumers",
|
|
1538
|
+
help="Assert all registered consumers are compatible with the installed schema",
|
|
1539
|
+
)
|
|
1540
|
+
|
|
1541
|
+
# validate sub-command
|
|
1542
|
+
validate_parser = sub.add_parser(
|
|
1543
|
+
"validate",
|
|
1544
|
+
help="Validate every event in a JSONL file against the published schema",
|
|
1545
|
+
)
|
|
1546
|
+
validate_parser.add_argument(
|
|
1547
|
+
"file",
|
|
1548
|
+
metavar="EVENTS_JSONL",
|
|
1549
|
+
help="Path to a JSONL file (one event JSON per line)",
|
|
1550
|
+
)
|
|
1551
|
+
|
|
1552
|
+
audit_group_parser = add_audit_subcommands(sub)
|
|
1553
|
+
|
|
1554
|
+
# scan sub-command — GA-03 deep PII scanning
|
|
1555
|
+
scan_parser = sub.add_parser(
|
|
1556
|
+
"scan",
|
|
1557
|
+
help="Scan a JSONL file for PII using regex detectors",
|
|
1558
|
+
)
|
|
1559
|
+
scan_parser.add_argument(
|
|
1560
|
+
"file",
|
|
1561
|
+
metavar="FILE",
|
|
1562
|
+
help="Path to the JSONL file to scan",
|
|
1563
|
+
)
|
|
1564
|
+
scan_parser.add_argument(
|
|
1565
|
+
"--format",
|
|
1566
|
+
choices=["text", "json"],
|
|
1567
|
+
default="text",
|
|
1568
|
+
help="Output format (default: text)",
|
|
1569
|
+
)
|
|
1570
|
+
scan_parser.add_argument(
|
|
1571
|
+
"--types",
|
|
1572
|
+
default=None,
|
|
1573
|
+
help="Comma-separated PII types to filter (e.g. 'ssn,credit_card')",
|
|
1574
|
+
)
|
|
1575
|
+
scan_parser.add_argument(
|
|
1576
|
+
"--fail-on-match",
|
|
1577
|
+
dest="fail_on_match",
|
|
1578
|
+
action="store_true",
|
|
1579
|
+
default=False,
|
|
1580
|
+
help="Exit with code 1 if any PII is detected (CI gate mode)",
|
|
1581
|
+
)
|
|
1582
|
+
|
|
1583
|
+
# secrets sub-command group — SEC-040 secrets scanning
|
|
1584
|
+
secrets_parser = sub.add_parser(
|
|
1585
|
+
"secrets",
|
|
1586
|
+
help="Secrets scanning utilities (scan files for hard-coded credentials)",
|
|
1587
|
+
)
|
|
1588
|
+
secrets_sub = secrets_parser.add_subparsers(dest="secrets_command", metavar="<action>")
|
|
1589
|
+
|
|
1590
|
+
secrets_scan_parser = secrets_sub.add_parser(
|
|
1591
|
+
"scan",
|
|
1592
|
+
help="Scan a file for hard-coded secrets (API keys, tokens, private keys, etc.)",
|
|
1593
|
+
)
|
|
1594
|
+
secrets_scan_parser.add_argument(
|
|
1595
|
+
"file",
|
|
1596
|
+
metavar="FILE",
|
|
1597
|
+
help="Path to the file to scan (plain text, source code, or JSONL)",
|
|
1598
|
+
)
|
|
1599
|
+
secrets_scan_parser.add_argument(
|
|
1600
|
+
"--format",
|
|
1601
|
+
choices=["text", "json", "sarif"],
|
|
1602
|
+
default="text",
|
|
1603
|
+
help="Output format: text (human-readable), json, or sarif 2.1.0 (default: text)",
|
|
1604
|
+
)
|
|
1605
|
+
secrets_scan_parser.add_argument(
|
|
1606
|
+
"--redact",
|
|
1607
|
+
action="store_true",
|
|
1608
|
+
default=False,
|
|
1609
|
+
help="Include redacted text in text/json output",
|
|
1610
|
+
)
|
|
1611
|
+
secrets_scan_parser.add_argument(
|
|
1612
|
+
"--confidence",
|
|
1613
|
+
type=float,
|
|
1614
|
+
default=0.75,
|
|
1615
|
+
metavar="FLOAT",
|
|
1616
|
+
help="Minimum confidence threshold [0.0-1.0] to report a hit (default: 0.75)",
|
|
1617
|
+
)
|
|
1618
|
+
|
|
1619
|
+
# migrate sub-command — GA-05 schema migration
|
|
1620
|
+
migrate_parser = sub.add_parser(
|
|
1621
|
+
"migrate",
|
|
1622
|
+
help="Migrate a JSONL file from schema v1 to v2",
|
|
1623
|
+
)
|
|
1624
|
+
migrate_parser.add_argument(
|
|
1625
|
+
"file",
|
|
1626
|
+
metavar="FILE",
|
|
1627
|
+
help="Path to the JSONL file to migrate",
|
|
1628
|
+
)
|
|
1629
|
+
migrate_parser.add_argument(
|
|
1630
|
+
"--output",
|
|
1631
|
+
default=None,
|
|
1632
|
+
metavar="FILE",
|
|
1633
|
+
help="Output file (default: <input>_v2.jsonl)",
|
|
1634
|
+
)
|
|
1635
|
+
migrate_parser.add_argument(
|
|
1636
|
+
"--target-version",
|
|
1637
|
+
dest="target_version",
|
|
1638
|
+
default="2.0",
|
|
1639
|
+
help="Target schema version (default: 2.0)",
|
|
1640
|
+
)
|
|
1641
|
+
migrate_parser.add_argument(
|
|
1642
|
+
"--sign",
|
|
1643
|
+
action="store_true",
|
|
1644
|
+
default=False,
|
|
1645
|
+
help="Re-sign the migrated chain (reads SPANFORGE_SIGNING_KEY)",
|
|
1646
|
+
)
|
|
1647
|
+
migrate_parser.add_argument(
|
|
1648
|
+
"--dry-run",
|
|
1649
|
+
dest="dry_run",
|
|
1650
|
+
action="store_true",
|
|
1651
|
+
default=False,
|
|
1652
|
+
help="Preview migration without writing output",
|
|
1653
|
+
)
|
|
1654
|
+
|
|
1655
|
+
# migrate-langsmith sub-command — import LangSmith exports
|
|
1656
|
+
ls_migrate_parser = sub.add_parser(
|
|
1657
|
+
"migrate-langsmith",
|
|
1658
|
+
help="Import a LangSmith export file and convert to SpanForge events",
|
|
1659
|
+
)
|
|
1660
|
+
ls_migrate_parser.add_argument(
|
|
1661
|
+
"file",
|
|
1662
|
+
metavar="FILE",
|
|
1663
|
+
help="Path to the LangSmith export file (JSONL or JSON)",
|
|
1664
|
+
)
|
|
1665
|
+
ls_migrate_parser.add_argument(
|
|
1666
|
+
"--output",
|
|
1667
|
+
default=None,
|
|
1668
|
+
metavar="FILE",
|
|
1669
|
+
help="Output JSONL file (default: <input>_spanforge.jsonl)",
|
|
1670
|
+
)
|
|
1671
|
+
ls_migrate_parser.add_argument(
|
|
1672
|
+
"--source",
|
|
1673
|
+
default="langsmith-import",
|
|
1674
|
+
help="Source identifier for generated events (default: langsmith-import)",
|
|
1675
|
+
)
|
|
1676
|
+
|
|
1677
|
+
# inspect sub-command
|
|
1678
|
+
inspect_parser = sub.add_parser(
|
|
1679
|
+
"inspect",
|
|
1680
|
+
help="Pretty-print a single event by event_id from a JSONL file",
|
|
1681
|
+
)
|
|
1682
|
+
inspect_parser.add_argument(
|
|
1683
|
+
"event_id",
|
|
1684
|
+
metavar="EVENT_ID",
|
|
1685
|
+
help="The event_id to look up",
|
|
1686
|
+
)
|
|
1687
|
+
inspect_parser.add_argument(
|
|
1688
|
+
"file",
|
|
1689
|
+
metavar="EVENTS_JSONL",
|
|
1690
|
+
help="Path to a JSONL file to search",
|
|
1691
|
+
)
|
|
1692
|
+
|
|
1693
|
+
# stats sub-command
|
|
1694
|
+
stats_parser = sub.add_parser(
|
|
1695
|
+
"stats",
|
|
1696
|
+
help="Print a summary of events in a JSONL file (counts, tokens, cost, timestamps)",
|
|
1697
|
+
)
|
|
1698
|
+
stats_parser.add_argument(
|
|
1699
|
+
"file",
|
|
1700
|
+
metavar="EVENTS_JSONL",
|
|
1701
|
+
help="Path to a JSONL file",
|
|
1702
|
+
)
|
|
1703
|
+
|
|
1704
|
+
compliance_parser = add_compliance_subcommands(sub)
|
|
1705
|
+
|
|
1706
|
+
cost_parser = add_cost_subcommands(sub)
|
|
1707
|
+
|
|
1708
|
+
# dev command group
|
|
1709
|
+
dev_parser = sub.add_parser(
|
|
1710
|
+
"dev",
|
|
1711
|
+
help="Local development environment lifecycle",
|
|
1712
|
+
)
|
|
1713
|
+
dev_sub = dev_parser.add_subparsers(dest="dev_command", metavar="<action>")
|
|
1714
|
+
|
|
1715
|
+
dev_start_p = dev_sub.add_parser("start", help="Start the local dev environment")
|
|
1716
|
+
dev_start_p.add_argument(
|
|
1717
|
+
"service",
|
|
1718
|
+
nargs="?",
|
|
1719
|
+
default="spanforge-dev",
|
|
1720
|
+
help="Service name (default: spanforge-dev)",
|
|
1721
|
+
)
|
|
1722
|
+
dev_sub.add_parser("stop", help="Flush buffer and stop the local dev environment")
|
|
1723
|
+
dev_sub.add_parser("reset", help="Reset all in-memory dev state")
|
|
1724
|
+
dev_sub.add_parser("logs", help="Print accumulated dev log entries")
|
|
1725
|
+
dev_sub.add_parser("status", help="Print the current dev environment status as JSON")
|
|
1726
|
+
|
|
1727
|
+
# module command group
|
|
1728
|
+
module_parser = sub.add_parser(
|
|
1729
|
+
"module",
|
|
1730
|
+
help="SpanForge plugin module scaffolding",
|
|
1731
|
+
)
|
|
1732
|
+
module_sub = module_parser.add_subparsers(dest="module_command", metavar="<action>")
|
|
1733
|
+
|
|
1734
|
+
create_parser = module_sub.add_parser(
|
|
1735
|
+
"create",
|
|
1736
|
+
help="Scaffold a new SpanForge plugin module directory",
|
|
1737
|
+
)
|
|
1738
|
+
create_parser.add_argument(
|
|
1739
|
+
"name", metavar="MODULE_NAME", help="Python-package-safe module name"
|
|
1740
|
+
)
|
|
1741
|
+
create_parser.add_argument(
|
|
1742
|
+
"--trust-level",
|
|
1743
|
+
dest="trust_level",
|
|
1744
|
+
default="UNTRUSTED",
|
|
1745
|
+
metavar="LEVEL",
|
|
1746
|
+
help="Trust level: UNTRUSTED, COMMUNITY, VERIFIED, OFFICIAL (default: UNTRUSTED)",
|
|
1747
|
+
)
|
|
1748
|
+
create_parser.add_argument("--author", default="unknown", help="Author identifier")
|
|
1749
|
+
create_parser.add_argument(
|
|
1750
|
+
"--output-dir",
|
|
1751
|
+
dest="output_dir",
|
|
1752
|
+
default=".",
|
|
1753
|
+
metavar="DIR",
|
|
1754
|
+
help="Parent directory for the scaffolded module (default: .)",
|
|
1755
|
+
)
|
|
1756
|
+
|
|
1757
|
+
# serve subcommand — local trace viewer
|
|
1758
|
+
serve_parser = sub.add_parser(
|
|
1759
|
+
"serve",
|
|
1760
|
+
help="Start a local HTTP trace viewer at /traces (default port 8888)",
|
|
1761
|
+
)
|
|
1762
|
+
serve_parser.add_argument(
|
|
1763
|
+
"--port",
|
|
1764
|
+
type=int,
|
|
1765
|
+
default=8888,
|
|
1766
|
+
help="HTTP port to bind (default: 8888)",
|
|
1767
|
+
)
|
|
1768
|
+
serve_parser.add_argument(
|
|
1769
|
+
"--host",
|
|
1770
|
+
default="127.0.0.1",
|
|
1771
|
+
help="Interface to bind (default: 127.0.0.1)",
|
|
1772
|
+
)
|
|
1773
|
+
serve_parser.add_argument(
|
|
1774
|
+
"--file",
|
|
1775
|
+
dest="file",
|
|
1776
|
+
default=None,
|
|
1777
|
+
metavar="FILE",
|
|
1778
|
+
help="Optional JSONL file to pre-load into the trace store before serving",
|
|
1779
|
+
)
|
|
1780
|
+
|
|
1781
|
+
# init sub-command
|
|
1782
|
+
init_parser = sub.add_parser(
|
|
1783
|
+
"init",
|
|
1784
|
+
help="Scaffold a spanforge.toml config file in the current directory",
|
|
1785
|
+
)
|
|
1786
|
+
init_parser.add_argument(
|
|
1787
|
+
"--service-name",
|
|
1788
|
+
dest="service_name",
|
|
1789
|
+
default=None,
|
|
1790
|
+
help="Service name to embed in spanforge.toml (default: current directory name)",
|
|
1791
|
+
)
|
|
1792
|
+
init_parser.add_argument(
|
|
1793
|
+
"--output-dir",
|
|
1794
|
+
dest="output_dir",
|
|
1795
|
+
default=".",
|
|
1796
|
+
metavar="DIR",
|
|
1797
|
+
help="Directory to write files into (default: .)",
|
|
1798
|
+
)
|
|
1799
|
+
init_parser.add_argument(
|
|
1800
|
+
"--force",
|
|
1801
|
+
action="store_true",
|
|
1802
|
+
default=False,
|
|
1803
|
+
help="Overwrite existing spanforge.toml without prompting",
|
|
1804
|
+
)
|
|
1805
|
+
|
|
1806
|
+
# quickstart sub-command
|
|
1807
|
+
sub.add_parser(
|
|
1808
|
+
"quickstart",
|
|
1809
|
+
help="Interactive setup wizard: configure exporter, service name, and signing",
|
|
1810
|
+
)
|
|
1811
|
+
|
|
1812
|
+
# report sub-command
|
|
1813
|
+
report_parser = sub.add_parser(
|
|
1814
|
+
"report",
|
|
1815
|
+
help="Generate a static HTML trace report from a JSONL events file",
|
|
1816
|
+
)
|
|
1817
|
+
report_parser.add_argument(
|
|
1818
|
+
"file",
|
|
1819
|
+
metavar="EVENTS_JSONL",
|
|
1820
|
+
help="Path to the JSONL events file",
|
|
1821
|
+
)
|
|
1822
|
+
report_parser.add_argument(
|
|
1823
|
+
"--output",
|
|
1824
|
+
default="spanforge-report.html",
|
|
1825
|
+
metavar="HTML_FILE",
|
|
1826
|
+
help="Output HTML file path (default: spanforge-report.html)",
|
|
1827
|
+
)
|
|
1828
|
+
|
|
1829
|
+
# ui sub-command
|
|
1830
|
+
ui_parser = sub.add_parser(
|
|
1831
|
+
"ui",
|
|
1832
|
+
help="Open a local HTML trace viewer in your browser",
|
|
1833
|
+
)
|
|
1834
|
+
ui_parser.add_argument(
|
|
1835
|
+
"--file",
|
|
1836
|
+
dest="file",
|
|
1837
|
+
default=None,
|
|
1838
|
+
metavar="EVENTS_JSONL",
|
|
1839
|
+
help="JSONL file to render as a trace report",
|
|
1840
|
+
)
|
|
1841
|
+
ui_parser.add_argument(
|
|
1842
|
+
"--port",
|
|
1843
|
+
type=int,
|
|
1844
|
+
default=8889,
|
|
1845
|
+
help="HTTP port to bind (default: 8889)",
|
|
1846
|
+
)
|
|
1847
|
+
ui_parser.add_argument(
|
|
1848
|
+
"--no-browser",
|
|
1849
|
+
dest="no_browser",
|
|
1850
|
+
action="store_true",
|
|
1851
|
+
default=False,
|
|
1852
|
+
help="Do not automatically open the browser",
|
|
1853
|
+
)
|
|
1854
|
+
|
|
1855
|
+
# ---------------------------------------------------------------------------
|
|
1856
|
+
# T.R.U.S.T. Framework CLI commands
|
|
1857
|
+
# ---------------------------------------------------------------------------
|
|
1858
|
+
|
|
1859
|
+
# consent command group
|
|
1860
|
+
consent_parser = sub.add_parser(
|
|
1861
|
+
"consent",
|
|
1862
|
+
help="Consent boundary management",
|
|
1863
|
+
)
|
|
1864
|
+
consent_sub = consent_parser.add_subparsers(dest="consent_command", metavar="<action>")
|
|
1865
|
+
|
|
1866
|
+
consent_check_parser = consent_sub.add_parser(
|
|
1867
|
+
"check",
|
|
1868
|
+
help="Check if consent is granted for a given subject and scope",
|
|
1869
|
+
)
|
|
1870
|
+
consent_check_parser.add_argument("--subject", required=True, help="Subject ID")
|
|
1871
|
+
consent_check_parser.add_argument("--scope", required=True, help="Consent scope")
|
|
1872
|
+
|
|
1873
|
+
consent_grant_parser = consent_sub.add_parser(
|
|
1874
|
+
"grant",
|
|
1875
|
+
help="Grant consent for a subject/scope",
|
|
1876
|
+
)
|
|
1877
|
+
consent_grant_parser.add_argument("--subject", required=True, help="Subject ID")
|
|
1878
|
+
consent_grant_parser.add_argument("--scope", required=True, help="Consent scope")
|
|
1879
|
+
consent_grant_parser.add_argument(
|
|
1880
|
+
"--purpose", default="cli-grant", help="Purpose (default: cli-grant)"
|
|
1881
|
+
)
|
|
1882
|
+
consent_grant_parser.add_argument(
|
|
1883
|
+
"--legal-basis", dest="legal_basis", default="consent", help="Legal basis"
|
|
1884
|
+
)
|
|
1885
|
+
|
|
1886
|
+
consent_revoke_parser = consent_sub.add_parser(
|
|
1887
|
+
"revoke",
|
|
1888
|
+
help="Revoke consent for a subject/scope",
|
|
1889
|
+
)
|
|
1890
|
+
consent_revoke_parser.add_argument("--subject", required=True, help="Subject ID")
|
|
1891
|
+
consent_revoke_parser.add_argument("--scope", required=True, help="Consent scope")
|
|
1892
|
+
|
|
1893
|
+
# hitl command group
|
|
1894
|
+
hitl_parser = sub.add_parser(
|
|
1895
|
+
"hitl",
|
|
1896
|
+
help="Human-in-the-loop review queue",
|
|
1897
|
+
)
|
|
1898
|
+
hitl_sub = hitl_parser.add_subparsers(dest="hitl_command", metavar="<action>")
|
|
1899
|
+
|
|
1900
|
+
hitl_sub.add_parser(
|
|
1901
|
+
"pending",
|
|
1902
|
+
help="List all pending (queued) HITL items",
|
|
1903
|
+
)
|
|
1904
|
+
|
|
1905
|
+
hitl_review_parser = hitl_sub.add_parser(
|
|
1906
|
+
"review",
|
|
1907
|
+
help="Record a review decision for a pending item",
|
|
1908
|
+
)
|
|
1909
|
+
hitl_review_parser.add_argument("--id", dest="decision_id", required=True, help="Decision ID")
|
|
1910
|
+
hitl_review_parser.add_argument("--reviewer", required=True, help="Reviewer name")
|
|
1911
|
+
hitl_review_parser.add_argument(
|
|
1912
|
+
"--outcome",
|
|
1913
|
+
required=True,
|
|
1914
|
+
choices=["approved", "rejected"],
|
|
1915
|
+
help="Review outcome",
|
|
1916
|
+
)
|
|
1917
|
+
|
|
1918
|
+
# model command group
|
|
1919
|
+
model_parser = sub.add_parser(
|
|
1920
|
+
"model",
|
|
1921
|
+
help="Model registry management",
|
|
1922
|
+
)
|
|
1923
|
+
model_sub = model_parser.add_subparsers(dest="model_command", metavar="<action>")
|
|
1924
|
+
|
|
1925
|
+
model_sub.add_parser("list", help="List all registered models")
|
|
1926
|
+
|
|
1927
|
+
model_reg_parser = model_sub.add_parser(
|
|
1928
|
+
"register",
|
|
1929
|
+
help="Register a new model",
|
|
1930
|
+
)
|
|
1931
|
+
model_reg_parser.add_argument("--model-id", dest="model_id", required=True, help="Model ID")
|
|
1932
|
+
model_reg_parser.add_argument("--name", required=True, help="Model name")
|
|
1933
|
+
model_reg_parser.add_argument("--version", required=True, help="Model version")
|
|
1934
|
+
model_reg_parser.add_argument(
|
|
1935
|
+
"--risk-tier",
|
|
1936
|
+
dest="risk_tier",
|
|
1937
|
+
required=True,
|
|
1938
|
+
choices=["low", "medium", "high", "critical"],
|
|
1939
|
+
help="Risk tier",
|
|
1940
|
+
)
|
|
1941
|
+
model_reg_parser.add_argument("--owner", required=True, help="Owner")
|
|
1942
|
+
model_reg_parser.add_argument("--purpose", required=True, help="Purpose")
|
|
1943
|
+
|
|
1944
|
+
model_dep_parser = model_sub.add_parser(
|
|
1945
|
+
"deprecate",
|
|
1946
|
+
help="Deprecate a model",
|
|
1947
|
+
)
|
|
1948
|
+
model_dep_parser.add_argument("--model-id", dest="model_id", required=True, help="Model ID")
|
|
1949
|
+
model_dep_parser.add_argument("--reason", default="", help="Deprecation reason")
|
|
1950
|
+
|
|
1951
|
+
model_ret_parser = model_sub.add_parser(
|
|
1952
|
+
"retire",
|
|
1953
|
+
help="Retire a model",
|
|
1954
|
+
)
|
|
1955
|
+
model_ret_parser.add_argument("--model-id", dest="model_id", required=True, help="Model ID")
|
|
1956
|
+
|
|
1957
|
+
# explain command
|
|
1958
|
+
explain_parser = sub.add_parser(
|
|
1959
|
+
"explain",
|
|
1960
|
+
help="Generate an explainability record",
|
|
1961
|
+
)
|
|
1962
|
+
explain_parser.add_argument("--trace-id", dest="trace_id", required=True, help="Trace ID")
|
|
1963
|
+
explain_parser.add_argument("--agent-id", dest="agent_id", required=True, help="Agent ID")
|
|
1964
|
+
explain_parser.add_argument(
|
|
1965
|
+
"--decision-id", dest="decision_id", required=True, help="Decision ID"
|
|
1966
|
+
)
|
|
1967
|
+
explain_parser.add_argument("--summary", required=True, help="Human-readable summary")
|
|
1968
|
+
|
|
1969
|
+
# eval command group
|
|
1970
|
+
eval_parser = sub.add_parser(
|
|
1971
|
+
"eval",
|
|
1972
|
+
help="Evaluation dataset management and scorer execution",
|
|
1973
|
+
)
|
|
1974
|
+
eval_sub = eval_parser.add_subparsers(dest="eval_command", metavar="<action>")
|
|
1975
|
+
|
|
1976
|
+
eval_save_parser = eval_sub.add_parser(
|
|
1977
|
+
"save",
|
|
1978
|
+
help="Extract evaluation examples from a JSONL events file",
|
|
1979
|
+
)
|
|
1980
|
+
eval_save_parser.add_argument(
|
|
1981
|
+
"--input",
|
|
1982
|
+
required=True,
|
|
1983
|
+
metavar="JSONL",
|
|
1984
|
+
help="Path to a JSONL events file to extract examples from",
|
|
1985
|
+
)
|
|
1986
|
+
eval_save_parser.add_argument(
|
|
1987
|
+
"--output",
|
|
1988
|
+
default="eval_dataset.jsonl",
|
|
1989
|
+
metavar="FILE",
|
|
1990
|
+
help="Output JSONL file for evaluation examples (default: eval_dataset.jsonl)",
|
|
1991
|
+
)
|
|
1992
|
+
|
|
1993
|
+
eval_run_parser = eval_sub.add_parser(
|
|
1994
|
+
"run",
|
|
1995
|
+
help="Run evaluation scorers over a JSONL dataset",
|
|
1996
|
+
)
|
|
1997
|
+
eval_run_parser.add_argument(
|
|
1998
|
+
"--file",
|
|
1999
|
+
required=True,
|
|
2000
|
+
metavar="JSONL",
|
|
2001
|
+
help="Path to a JSONL evaluation dataset file",
|
|
2002
|
+
)
|
|
2003
|
+
eval_run_parser.add_argument(
|
|
2004
|
+
"--scorers",
|
|
2005
|
+
default=None,
|
|
2006
|
+
help="Comma-separated scorer names (default: all). Available: faithfulness, refusal, pii_leakage",
|
|
2007
|
+
)
|
|
2008
|
+
eval_run_parser.add_argument(
|
|
2009
|
+
"--format",
|
|
2010
|
+
choices=["text", "json"],
|
|
2011
|
+
default="text",
|
|
2012
|
+
help="Output format (default: text)",
|
|
2013
|
+
)
|
|
2014
|
+
|
|
2015
|
+
config_parser, trust_parser, gate_parser, operator_parser = add_ops_subcommands(sub)
|
|
2016
|
+
|
|
2017
|
+
enterprise_parser, security_parser = add_phase11_subcommands(sub)
|
|
2018
|
+
|
|
2019
|
+
|
|
2020
|
+
args = parser.parse_args(argv)
|
|
2021
|
+
|
|
2022
|
+
if args.command == "check":
|
|
2023
|
+
sys.exit(_cmd_check(args))
|
|
2024
|
+
elif args.command == "check-compat":
|
|
2025
|
+
sys.exit(_cmd_check_compat(args))
|
|
2026
|
+
elif args.command == "list-deprecated":
|
|
2027
|
+
sys.exit(_cmd_list_deprecated(args))
|
|
2028
|
+
elif args.command == "migration-roadmap":
|
|
2029
|
+
sys.exit(_cmd_migration_roadmap(args))
|
|
2030
|
+
elif args.command == "check-consumers":
|
|
2031
|
+
sys.exit(_cmd_check_consumers(args))
|
|
2032
|
+
elif args.command == "validate":
|
|
2033
|
+
sys.exit(_cmd_validate(args))
|
|
2034
|
+
elif args.command in {"audit-chain", "audit"}:
|
|
2035
|
+
sys.exit(
|
|
2036
|
+
dispatch_audit_command(
|
|
2037
|
+
args,
|
|
2038
|
+
audit_group_parser,
|
|
2039
|
+
_read_jsonl_events,
|
|
2040
|
+
_NO_EVENTS_MSG,
|
|
2041
|
+
)
|
|
2042
|
+
)
|
|
2043
|
+
elif args.command == "inspect":
|
|
2044
|
+
sys.exit(_cmd_inspect(args))
|
|
2045
|
+
elif args.command == "scan":
|
|
2046
|
+
sys.exit(_cmd_scan(args))
|
|
2047
|
+
elif args.command == "secrets":
|
|
2048
|
+
sys.exit(_cmd_secrets(args, secrets_parser))
|
|
2049
|
+
elif args.command == "migrate":
|
|
2050
|
+
sys.exit(_cmd_migrate(args))
|
|
2051
|
+
elif args.command == "migrate-langsmith":
|
|
2052
|
+
sys.exit(_cmd_migrate_langsmith(args))
|
|
2053
|
+
elif args.command == "stats":
|
|
2054
|
+
sys.exit(_cmd_stats(args))
|
|
2055
|
+
elif args.command == "compliance":
|
|
2056
|
+
sys.exit(dispatch_compliance_command(args, compliance_parser))
|
|
2057
|
+
elif args.command == "cost":
|
|
2058
|
+
sys.exit(dispatch_cost_command(args, cost_parser))
|
|
2059
|
+
elif args.command in {"config", "trust", "gate", "operator", "doctor"}:
|
|
2060
|
+
sys.exit(dispatch_ops_command(args, config_parser, trust_parser, gate_parser, operator_parser))
|
|
2061
|
+
elif args.command == "dev":
|
|
2062
|
+
sys.exit(_cmd_dev(args))
|
|
2063
|
+
elif args.command == "module":
|
|
2064
|
+
action = getattr(args, "module_command", None)
|
|
2065
|
+
if action == "create":
|
|
2066
|
+
sys.exit(_cmd_module_create(args))
|
|
2067
|
+
else:
|
|
2068
|
+
module_parser.print_help()
|
|
2069
|
+
sys.exit(2)
|
|
2070
|
+
elif args.command == "serve":
|
|
2071
|
+
sys.exit(_cmd_serve(args))
|
|
2072
|
+
elif args.command == "init":
|
|
2073
|
+
sys.exit(_cmd_init(args))
|
|
2074
|
+
elif args.command == "quickstart":
|
|
2075
|
+
sys.exit(_cmd_quickstart(args))
|
|
2076
|
+
elif args.command == "report":
|
|
2077
|
+
sys.exit(_cmd_report(args))
|
|
2078
|
+
elif args.command == "ui":
|
|
2079
|
+
sys.exit(_cmd_ui(args))
|
|
2080
|
+
elif args.command == "consent":
|
|
2081
|
+
sys.exit(_cmd_consent(args, consent_parser))
|
|
2082
|
+
elif args.command == "hitl":
|
|
2083
|
+
sys.exit(_cmd_hitl(args, hitl_parser))
|
|
2084
|
+
elif args.command == "model":
|
|
2085
|
+
sys.exit(_cmd_model(args, model_parser))
|
|
2086
|
+
elif args.command == "explain":
|
|
2087
|
+
sys.exit(_cmd_explain(args))
|
|
2088
|
+
elif args.command == "eval":
|
|
2089
|
+
sys.exit(_cmd_eval(args, eval_parser))
|
|
2090
|
+
elif args.command in {"enterprise", "security"}:
|
|
2091
|
+
sys.exit(dispatch_phase11_command(args, enterprise_parser, security_parser))
|
|
2092
|
+
else:
|
|
2093
|
+
parser.print_help()
|
|
2094
|
+
sys.exit(2)
|