agentic-ci 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- agentic_ci/__init__.py +0 -0
- agentic_ci/cli.py +91 -0
- agentic_ci/extract.py +114 -0
- agentic_ci/otel_collector.py +104 -0
- agentic_ci/otel_summary.py +139 -0
- agentic_ci/runner.py +190 -0
- agentic_ci/setup.py +91 -0
- agentic_ci/stream.py +302 -0
- agentic_ci-0.1.0.dist-info/METADATA +114 -0
- agentic_ci-0.1.0.dist-info/RECORD +14 -0
- agentic_ci-0.1.0.dist-info/WHEEL +5 -0
- agentic_ci-0.1.0.dist-info/entry_points.txt +2 -0
- agentic_ci-0.1.0.dist-info/licenses/LICENSE +201 -0
- agentic_ci-0.1.0.dist-info/top_level.txt +1 -0
agentic_ci/__init__.py
ADDED
|
File without changes
|
agentic_ci/cli.py
ADDED
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
"""CLI entry point for agentic-ci."""
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import sys
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def main():
|
|
8
|
+
parser = argparse.ArgumentParser(
|
|
9
|
+
prog="agentic-ci",
|
|
10
|
+
description="Tooling for running AI coding agents in CI/CD environments",
|
|
11
|
+
)
|
|
12
|
+
sub = parser.add_subparsers(dest="command")
|
|
13
|
+
|
|
14
|
+
# setup
|
|
15
|
+
p_setup = sub.add_parser("setup", help="Bootstrap CI container")
|
|
16
|
+
p_setup.add_argument("--workspace", default=None)
|
|
17
|
+
p_setup.add_argument("--user", default="claude-ci")
|
|
18
|
+
|
|
19
|
+
# run
|
|
20
|
+
p_run = sub.add_parser("run", help="Run Claude Code with telemetry")
|
|
21
|
+
p_run.add_argument("prompt")
|
|
22
|
+
p_run.add_argument("workdir", nargs="?", default=".")
|
|
23
|
+
p_run.add_argument("--model", default=None)
|
|
24
|
+
|
|
25
|
+
# stream
|
|
26
|
+
p_stream = sub.add_parser("stream", help="Parse stream-json from stdin")
|
|
27
|
+
p_stream.add_argument("--wrap", type=int, default=0, metavar="COLS")
|
|
28
|
+
p_stream.add_argument("--no-color", action="store_true")
|
|
29
|
+
p_stream.add_argument("--claude-pid", type=int, default=0)
|
|
30
|
+
|
|
31
|
+
# otel-collect
|
|
32
|
+
sub.add_parser("otel-collect", help="Start OTLP metrics collector")
|
|
33
|
+
|
|
34
|
+
# otel-summary
|
|
35
|
+
p_summary = sub.add_parser("otel-summary", help="Print token/cost summary")
|
|
36
|
+
p_summary.add_argument("log_file", nargs="?", default="/tmp/claude-otel.jsonl")
|
|
37
|
+
|
|
38
|
+
# extract
|
|
39
|
+
p_extract = sub.add_parser("extract", help="Extract JSON result from stream output")
|
|
40
|
+
p_extract.add_argument("stream_file")
|
|
41
|
+
p_extract.add_argument("output_dir")
|
|
42
|
+
|
|
43
|
+
args = parser.parse_args()
|
|
44
|
+
|
|
45
|
+
if args.command == "setup":
|
|
46
|
+
from agentic_ci.setup import setup
|
|
47
|
+
setup(workspace=args.workspace, user=args.user)
|
|
48
|
+
|
|
49
|
+
elif args.command == "run":
|
|
50
|
+
from agentic_ci.runner import run
|
|
51
|
+
sys.exit(run(args.prompt, args.workdir, model=args.model))
|
|
52
|
+
|
|
53
|
+
elif args.command == "stream":
|
|
54
|
+
from agentic_ci.stream import StreamProcessor
|
|
55
|
+
processor = StreamProcessor(
|
|
56
|
+
color=not args.no_color,
|
|
57
|
+
wrap=args.wrap,
|
|
58
|
+
claude_pid=args.claude_pid,
|
|
59
|
+
)
|
|
60
|
+
processor.process(sys.stdin)
|
|
61
|
+
print()
|
|
62
|
+
|
|
63
|
+
elif args.command == "otel-collect":
|
|
64
|
+
from agentic_ci.otel_collector import main as collector_main
|
|
65
|
+
collector_main()
|
|
66
|
+
|
|
67
|
+
elif args.command == "otel-summary":
|
|
68
|
+
from agentic_ci.otel_summary import print_summary
|
|
69
|
+
print_summary(args.log_file)
|
|
70
|
+
|
|
71
|
+
elif args.command == "extract":
|
|
72
|
+
from agentic_ci.extract import extract_text_from_stream, parse_result, write_outputs
|
|
73
|
+
import json
|
|
74
|
+
text = extract_text_from_stream(args.stream_file)
|
|
75
|
+
if not text:
|
|
76
|
+
print("Error: no text content found", file=sys.stderr)
|
|
77
|
+
sys.exit(1)
|
|
78
|
+
try:
|
|
79
|
+
result = parse_result(text)
|
|
80
|
+
except json.JSONDecodeError as e:
|
|
81
|
+
print(f"Error: could not parse JSON: {e}", file=sys.stderr)
|
|
82
|
+
sys.exit(1)
|
|
83
|
+
write_outputs(result, args.output_dir)
|
|
84
|
+
|
|
85
|
+
else:
|
|
86
|
+
parser.print_help()
|
|
87
|
+
sys.exit(1)
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
if __name__ == "__main__":
|
|
91
|
+
main()
|
agentic_ci/extract.py
ADDED
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
"""Extract structured JSON result from Claude's stream-json output.
|
|
2
|
+
|
|
3
|
+
Reads the stream-json capture file, extracts the final text content,
|
|
4
|
+
parses it as JSON, and writes individual fields to an output directory.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import json
|
|
8
|
+
import os
|
|
9
|
+
import re
|
|
10
|
+
import sys
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def extract_text_from_stream(stream_file):
|
|
14
|
+
"""Reassemble the full text content from stream-json deltas."""
|
|
15
|
+
text = ""
|
|
16
|
+
with open(stream_file) as f:
|
|
17
|
+
for line in f:
|
|
18
|
+
line = line.strip()
|
|
19
|
+
if not line:
|
|
20
|
+
continue
|
|
21
|
+
try:
|
|
22
|
+
msg = json.loads(line)
|
|
23
|
+
except (json.JSONDecodeError, ValueError):
|
|
24
|
+
continue
|
|
25
|
+
|
|
26
|
+
if msg.get("type") != "stream_event":
|
|
27
|
+
continue
|
|
28
|
+
|
|
29
|
+
event = msg.get("event", {})
|
|
30
|
+
if event.get("type") == "content_block_delta":
|
|
31
|
+
delta = event.get("delta", {})
|
|
32
|
+
if delta.get("type") == "text_delta":
|
|
33
|
+
text += delta.get("text", "")
|
|
34
|
+
|
|
35
|
+
return text
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def parse_result(text):
|
|
39
|
+
"""Parse the JSON result from Claude's text output.
|
|
40
|
+
|
|
41
|
+
Claude may output explanatory text before/after the JSON, and may
|
|
42
|
+
wrap the JSON in markdown code fences.
|
|
43
|
+
"""
|
|
44
|
+
fence_match = re.search(r"```(?:json)?\s*\n(.*?)\n\s*```", text, re.DOTALL)
|
|
45
|
+
if fence_match:
|
|
46
|
+
try:
|
|
47
|
+
return json.loads(fence_match.group(1).strip())
|
|
48
|
+
except json.JSONDecodeError:
|
|
49
|
+
pass
|
|
50
|
+
|
|
51
|
+
brace_start = text.find("{")
|
|
52
|
+
if brace_start >= 0:
|
|
53
|
+
depth = 0
|
|
54
|
+
for i in range(brace_start, len(text)):
|
|
55
|
+
if text[i] == "{":
|
|
56
|
+
depth += 1
|
|
57
|
+
elif text[i] == "}":
|
|
58
|
+
depth -= 1
|
|
59
|
+
if depth == 0:
|
|
60
|
+
try:
|
|
61
|
+
return json.loads(text[brace_start:i + 1])
|
|
62
|
+
except json.JSONDecodeError:
|
|
63
|
+
pass
|
|
64
|
+
break
|
|
65
|
+
|
|
66
|
+
return json.loads(text.strip())
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def write_outputs(result, output_dir):
|
|
70
|
+
"""Write structured fields to individual files in output_dir."""
|
|
71
|
+
os.makedirs(output_dir, exist_ok=True)
|
|
72
|
+
|
|
73
|
+
fields = {
|
|
74
|
+
"recommendation": str(result.get("recommendation", "unknown")),
|
|
75
|
+
"confidence": str(result.get("confidence", 0.0)),
|
|
76
|
+
"severity": str(result.get("severity", "unknown")),
|
|
77
|
+
"manipulation_detected": str(result.get("manipulation_detected", False)).lower(),
|
|
78
|
+
"analysis": str(result.get("analysis", "")),
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
for field, value in fields.items():
|
|
82
|
+
with open(os.path.join(output_dir, field), "w") as f:
|
|
83
|
+
f.write(value)
|
|
84
|
+
|
|
85
|
+
with open(os.path.join(output_dir, "result.json"), "w") as f:
|
|
86
|
+
json.dump(result, f, indent=2)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def main(args=None):
|
|
90
|
+
import argparse
|
|
91
|
+
|
|
92
|
+
parser = argparse.ArgumentParser(description="Extract JSON result from Claude stream output")
|
|
93
|
+
parser.add_argument("stream_file", help="Path to stream-json capture file")
|
|
94
|
+
parser.add_argument("output_dir", help="Directory to write extracted fields")
|
|
95
|
+
parsed = parser.parse_args(args)
|
|
96
|
+
|
|
97
|
+
text = extract_text_from_stream(parsed.stream_file)
|
|
98
|
+
if not text:
|
|
99
|
+
print("Error: no text content found in stream output", file=sys.stderr)
|
|
100
|
+
sys.exit(1)
|
|
101
|
+
|
|
102
|
+
try:
|
|
103
|
+
result = parse_result(text)
|
|
104
|
+
except json.JSONDecodeError as e:
|
|
105
|
+
print(f"Error: could not parse JSON from Claude output: {e}", file=sys.stderr)
|
|
106
|
+
print(f"Raw text:\n{text[:500]}", file=sys.stderr)
|
|
107
|
+
sys.exit(1)
|
|
108
|
+
|
|
109
|
+
write_outputs(result, parsed.output_dir)
|
|
110
|
+
print(f"Result extracted to {parsed.output_dir}/", file=sys.stderr)
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
if __name__ == "__main__":
|
|
114
|
+
main()
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
"""Lightweight OTLP HTTP/JSON receiver that writes payloads to a log file.
|
|
2
|
+
|
|
3
|
+
Listens on localhost and accepts OTLP HTTP/JSON exports for metrics
|
|
4
|
+
and logs, writing them to a structured log file for later analysis.
|
|
5
|
+
|
|
6
|
+
Also tracks token usage over time and writes a rate file for live
|
|
7
|
+
tokens/sec display.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
import json
|
|
11
|
+
import os
|
|
12
|
+
import signal
|
|
13
|
+
import sys
|
|
14
|
+
import time
|
|
15
|
+
from datetime import datetime, timezone
|
|
16
|
+
from http.server import HTTPServer, BaseHTTPRequestHandler
|
|
17
|
+
|
|
18
|
+
_token_samples = []
|
|
19
|
+
_WINDOW_SECS = 60
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class OTLPHandler(BaseHTTPRequestHandler):
|
|
23
|
+
def do_POST(self):
|
|
24
|
+
length = int(self.headers.get("Content-Length", 0))
|
|
25
|
+
body = self.rfile.read(length) if length else b""
|
|
26
|
+
try:
|
|
27
|
+
payload = json.loads(body) if body else {}
|
|
28
|
+
except json.JSONDecodeError:
|
|
29
|
+
payload = {"raw": body.decode("utf-8", errors="replace")}
|
|
30
|
+
|
|
31
|
+
record = {
|
|
32
|
+
"ts": datetime.now(timezone.utc).isoformat(),
|
|
33
|
+
"path": self.path,
|
|
34
|
+
"payload": payload,
|
|
35
|
+
}
|
|
36
|
+
log_file = os.environ.get("OTEL_LOG_FILE", "/tmp/claude-otel.jsonl")
|
|
37
|
+
with open(log_file, "a") as f:
|
|
38
|
+
f.write(json.dumps(record) + "\n")
|
|
39
|
+
|
|
40
|
+
if "/v1/metrics" in self.path:
|
|
41
|
+
_update_token_rate(payload)
|
|
42
|
+
|
|
43
|
+
self.send_response(200)
|
|
44
|
+
self.send_header("Content-Type", "application/json")
|
|
45
|
+
self.end_headers()
|
|
46
|
+
self.wfile.write(b'{"partialSuccess":{}}')
|
|
47
|
+
|
|
48
|
+
def log_message(self, format, *args):
|
|
49
|
+
pass
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _update_token_rate(payload):
|
|
53
|
+
global _token_samples
|
|
54
|
+
now = time.monotonic()
|
|
55
|
+
total = 0
|
|
56
|
+
for rm in payload.get("resourceMetrics", []):
|
|
57
|
+
for sm in rm.get("scopeMetrics", []):
|
|
58
|
+
for metric in sm.get("metrics", []):
|
|
59
|
+
if metric.get("name") == "claude_code.token.usage":
|
|
60
|
+
data = metric.get("sum", metric.get("gauge", {}))
|
|
61
|
+
for dp in data.get("dataPoints", []):
|
|
62
|
+
total += dp.get("asDouble", dp.get("asInt", 0))
|
|
63
|
+
if total <= 0:
|
|
64
|
+
return
|
|
65
|
+
|
|
66
|
+
_token_samples.append((now, total))
|
|
67
|
+
cutoff = now - _WINDOW_SECS
|
|
68
|
+
_token_samples = [(t, v) for t, v in _token_samples if t >= cutoff]
|
|
69
|
+
|
|
70
|
+
rate = 0.0
|
|
71
|
+
if len(_token_samples) >= 2:
|
|
72
|
+
dt = _token_samples[-1][0] - _token_samples[0][0]
|
|
73
|
+
dv = _token_samples[-1][1] - _token_samples[0][1]
|
|
74
|
+
if dt > 0:
|
|
75
|
+
rate = dv / dt
|
|
76
|
+
|
|
77
|
+
rate_file = os.environ.get("OTEL_RATE_FILE", "/tmp/claude-otel-rate.json")
|
|
78
|
+
tmp = rate_file + ".tmp"
|
|
79
|
+
with open(tmp, "w") as f:
|
|
80
|
+
json.dump({"total": total, "rate": rate, "ts": time.time()}, f)
|
|
81
|
+
os.replace(tmp, rate_file)
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def main(args=None):
|
|
85
|
+
port = int(os.environ.get("OTEL_COLLECTOR_PORT", "4318"))
|
|
86
|
+
server = HTTPServer(("127.0.0.1", port), OTLPHandler)
|
|
87
|
+
actual_port = server.server_address[1]
|
|
88
|
+
port_file = os.environ.get("OTEL_PORT_FILE")
|
|
89
|
+
if port_file:
|
|
90
|
+
with open(port_file, "w") as f:
|
|
91
|
+
f.write(str(actual_port))
|
|
92
|
+
signal.signal(signal.SIGTERM, lambda *_: sys.exit(0))
|
|
93
|
+
log_file = os.environ.get("OTEL_LOG_FILE", "/tmp/claude-otel.jsonl")
|
|
94
|
+
print(f"OTLP collector listening on 127.0.0.1:{actual_port}, writing to {log_file}",
|
|
95
|
+
file=sys.stderr)
|
|
96
|
+
try:
|
|
97
|
+
server.serve_forever()
|
|
98
|
+
except KeyboardInterrupt:
|
|
99
|
+
pass
|
|
100
|
+
server.server_close()
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
if __name__ == "__main__":
|
|
104
|
+
main()
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
"""Parse OTLP JSONL log and print a human-readable token/cost summary."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import sys
|
|
5
|
+
from collections import defaultdict
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def parse_metrics(records):
|
|
9
|
+
token_totals = defaultdict(float)
|
|
10
|
+
cost_totals = defaultdict(float)
|
|
11
|
+
api_requests = []
|
|
12
|
+
active_time = defaultdict(float)
|
|
13
|
+
|
|
14
|
+
for rec in records:
|
|
15
|
+
path = rec.get("path", "")
|
|
16
|
+
payload = rec.get("payload", {})
|
|
17
|
+
|
|
18
|
+
if "/v1/metrics" in path:
|
|
19
|
+
for rm in payload.get("resourceMetrics", []):
|
|
20
|
+
for sm in rm.get("scopeMetrics", []):
|
|
21
|
+
for metric in sm.get("metrics", []):
|
|
22
|
+
name = metric.get("name", "")
|
|
23
|
+
data = metric.get("sum", metric.get("gauge", metric.get("histogram", {})))
|
|
24
|
+
for dp in data.get("dataPoints", []):
|
|
25
|
+
attrs = {
|
|
26
|
+
a["key"]: a["value"].get(
|
|
27
|
+
"stringValue",
|
|
28
|
+
a["value"].get("intValue", a["value"].get("doubleValue")),
|
|
29
|
+
)
|
|
30
|
+
for a in dp.get("attributes", [])
|
|
31
|
+
}
|
|
32
|
+
value = dp.get("asDouble", dp.get("asInt", 0))
|
|
33
|
+
|
|
34
|
+
if name == "claude_code.token.usage":
|
|
35
|
+
model = attrs.get("model", "unknown")
|
|
36
|
+
token_type = attrs.get("type", "unknown")
|
|
37
|
+
token_totals[(model, token_type)] += value
|
|
38
|
+
elif name == "claude_code.cost.usage":
|
|
39
|
+
model = attrs.get("model", "unknown")
|
|
40
|
+
cost_totals[model] += value
|
|
41
|
+
elif name == "claude_code.active_time.total":
|
|
42
|
+
time_type = attrs.get("type", "unknown")
|
|
43
|
+
active_time[time_type] += value
|
|
44
|
+
|
|
45
|
+
elif "/v1/logs" in path:
|
|
46
|
+
for rl in payload.get("resourceLogs", []):
|
|
47
|
+
for sl in rl.get("scopeLogs", []):
|
|
48
|
+
for lr in sl.get("logRecords", []):
|
|
49
|
+
event_name = ""
|
|
50
|
+
event_attrs = {}
|
|
51
|
+
for a in lr.get("attributes", []):
|
|
52
|
+
key = a["key"]
|
|
53
|
+
val = a["value"]
|
|
54
|
+
v = val.get("stringValue", val.get("intValue", val.get("doubleValue")))
|
|
55
|
+
event_attrs[key] = v
|
|
56
|
+
if key == "event.name":
|
|
57
|
+
event_name = v
|
|
58
|
+
if event_name == "claude_code.api_request":
|
|
59
|
+
api_requests.append(event_attrs)
|
|
60
|
+
|
|
61
|
+
return token_totals, cost_totals, api_requests, active_time
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def print_summary(log_file):
|
|
65
|
+
records = []
|
|
66
|
+
try:
|
|
67
|
+
with open(log_file) as f:
|
|
68
|
+
for line in f:
|
|
69
|
+
line = line.strip()
|
|
70
|
+
if line:
|
|
71
|
+
records.append(json.loads(line))
|
|
72
|
+
except FileNotFoundError:
|
|
73
|
+
print("No OTEL data collected (log file not found).")
|
|
74
|
+
return
|
|
75
|
+
except json.JSONDecodeError as e:
|
|
76
|
+
print(f"Error parsing OTEL log: {e}")
|
|
77
|
+
return
|
|
78
|
+
|
|
79
|
+
if not records:
|
|
80
|
+
print("No OTEL data collected.")
|
|
81
|
+
return
|
|
82
|
+
|
|
83
|
+
token_totals, cost_totals, api_requests, active_time = parse_metrics(records)
|
|
84
|
+
|
|
85
|
+
print("=" * 60)
|
|
86
|
+
print(" CLAUDE TOKEN & COST SUMMARY (OpenTelemetry)")
|
|
87
|
+
print("=" * 60)
|
|
88
|
+
|
|
89
|
+
if token_totals:
|
|
90
|
+
models = sorted(set(m for m, _ in token_totals.keys()))
|
|
91
|
+
for model in models:
|
|
92
|
+
print(f"\n Model: {model}")
|
|
93
|
+
print(f" {'Token Type':<20} {'Count':>12}")
|
|
94
|
+
print(f" {'-'*20} {'-'*12}")
|
|
95
|
+
model_tokens = {t: c for (m, t), c in token_totals.items() if m == model}
|
|
96
|
+
for token_type in ["input", "cacheRead", "cacheCreation", "output"]:
|
|
97
|
+
if token_type in model_tokens:
|
|
98
|
+
print(f" {token_type:<20} {model_tokens[token_type]:>12,.0f}")
|
|
99
|
+
total = sum(model_tokens.values())
|
|
100
|
+
print(f" {'TOTAL':<20} {total:>12,.0f}")
|
|
101
|
+
|
|
102
|
+
if cost_totals:
|
|
103
|
+
print(f"\n {'Model':<30} {'Cost (USD)':>12}")
|
|
104
|
+
print(f" {'-'*30} {'-'*12}")
|
|
105
|
+
grand_total = 0.0
|
|
106
|
+
for model in sorted(cost_totals.keys()):
|
|
107
|
+
cost = cost_totals[model]
|
|
108
|
+
grand_total += cost
|
|
109
|
+
print(f" {model:<30} ${cost:>11.4f}")
|
|
110
|
+
if len(cost_totals) > 1:
|
|
111
|
+
print(f" {'TOTAL':<30} ${grand_total:>11.4f}")
|
|
112
|
+
|
|
113
|
+
if active_time:
|
|
114
|
+
print(f"\n Active Time:")
|
|
115
|
+
for time_type, seconds in sorted(active_time.items()):
|
|
116
|
+
mins, secs = divmod(int(seconds), 60)
|
|
117
|
+
print(f" {time_type}: {mins}m {secs}s")
|
|
118
|
+
|
|
119
|
+
if api_requests:
|
|
120
|
+
print(f"\n API Requests: {len(api_requests)}")
|
|
121
|
+
total_duration = sum(float(r.get("duration_ms", 0)) for r in api_requests)
|
|
122
|
+
if total_duration:
|
|
123
|
+
print(f" Total API time: {total_duration/1000:.1f}s")
|
|
124
|
+
|
|
125
|
+
print("=" * 60)
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def main(args=None):
|
|
129
|
+
import argparse
|
|
130
|
+
|
|
131
|
+
parser = argparse.ArgumentParser(description="Print Claude OTEL token/cost summary")
|
|
132
|
+
parser.add_argument("log_file", nargs="?", default="/tmp/claude-otel.jsonl",
|
|
133
|
+
help="Path to OTEL JSONL log file")
|
|
134
|
+
parsed = parser.parse_args(args)
|
|
135
|
+
print_summary(parsed.log_file)
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
if __name__ == "__main__":
|
|
139
|
+
main()
|
agentic_ci/runner.py
ADDED
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
"""Run Claude Code in CI with streaming output and telemetry.
|
|
2
|
+
|
|
3
|
+
Starts an OTEL collector, runs Claude with stream-json output,
|
|
4
|
+
displays human-readable progress, and prints a token/cost summary.
|
|
5
|
+
|
|
6
|
+
When run as root, re-execs itself as a non-root user.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import os
|
|
10
|
+
import signal
|
|
11
|
+
import subprocess
|
|
12
|
+
import sys
|
|
13
|
+
import time
|
|
14
|
+
|
|
15
|
+
from agentic_ci.otel_collector import main as collector_main
|
|
16
|
+
from agentic_ci.otel_summary import print_summary
|
|
17
|
+
from agentic_ci.stream import StreamProcessor
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def run(prompt, workdir=".", model=None, user="claude-ci"):
|
|
21
|
+
"""Run Claude Code with telemetry and streaming output.
|
|
22
|
+
|
|
23
|
+
Returns the exit code (0 for success).
|
|
24
|
+
"""
|
|
25
|
+
if os.getuid() == 0:
|
|
26
|
+
os.execvp("runuser", [
|
|
27
|
+
"runuser", "-u", user, "--",
|
|
28
|
+
sys.executable, "-m", "agentic_ci.runner",
|
|
29
|
+
prompt, workdir,
|
|
30
|
+
*(["--model", model] if model else []),
|
|
31
|
+
])
|
|
32
|
+
|
|
33
|
+
if model is None:
|
|
34
|
+
model = os.environ.get("CLAUDE_MODEL", "claude-opus-4-6")
|
|
35
|
+
|
|
36
|
+
os.environ["PATH"] = os.path.expanduser("~/.local/bin") + ":" + os.environ.get("PATH", "")
|
|
37
|
+
|
|
38
|
+
print("--- Preflight checks ---", flush=True)
|
|
39
|
+
subprocess.run(["claude", "--version"], check=True)
|
|
40
|
+
|
|
41
|
+
os.chdir(workdir)
|
|
42
|
+
|
|
43
|
+
workspace = os.environ.get("WORKSPACE_DIR")
|
|
44
|
+
if workspace:
|
|
45
|
+
run_tmp = os.path.join(workspace, "_run")
|
|
46
|
+
else:
|
|
47
|
+
import tempfile
|
|
48
|
+
run_tmp = tempfile.mkdtemp(prefix="agentic-ci-run.")
|
|
49
|
+
os.makedirs(run_tmp, exist_ok=True)
|
|
50
|
+
|
|
51
|
+
otel_log = os.path.join(run_tmp, "claude-otel.jsonl")
|
|
52
|
+
otel_rate = os.path.join(run_tmp, "claude-otel-rate.json")
|
|
53
|
+
otel_port_file = os.path.join(run_tmp, "otel-port")
|
|
54
|
+
stderr_log = os.path.join(run_tmp, "claude-stderr.log")
|
|
55
|
+
stream_capture = os.environ.get(
|
|
56
|
+
"STREAM_CAPTURE_FILE",
|
|
57
|
+
os.path.join(run_tmp, "claude-stream-capture.jsonl"),
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
for f in [otel_log, otel_port_file]:
|
|
61
|
+
try:
|
|
62
|
+
os.unlink(f)
|
|
63
|
+
except FileNotFoundError:
|
|
64
|
+
pass
|
|
65
|
+
|
|
66
|
+
# Start OTEL collector with OS-assigned port
|
|
67
|
+
collector_env = {
|
|
68
|
+
**os.environ,
|
|
69
|
+
"OTEL_LOG_FILE": otel_log,
|
|
70
|
+
"OTEL_RATE_FILE": otel_rate,
|
|
71
|
+
"OTEL_COLLECTOR_PORT": "0",
|
|
72
|
+
"OTEL_PORT_FILE": otel_port_file,
|
|
73
|
+
}
|
|
74
|
+
collector_proc = subprocess.Popen(
|
|
75
|
+
[sys.executable, "-m", "agentic_ci.otel_collector"],
|
|
76
|
+
env=collector_env,
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
# Wait for collector to write its port
|
|
80
|
+
for _ in range(50):
|
|
81
|
+
if os.path.exists(otel_port_file):
|
|
82
|
+
break
|
|
83
|
+
time.sleep(0.1)
|
|
84
|
+
else:
|
|
85
|
+
print("Error: OTEL collector did not write port file", file=sys.stderr)
|
|
86
|
+
collector_proc.kill()
|
|
87
|
+
return 1
|
|
88
|
+
|
|
89
|
+
with open(otel_port_file) as f:
|
|
90
|
+
otel_port = f.read().strip()
|
|
91
|
+
print(f"--- OTEL collector started (pid {collector_proc.pid}, port {otel_port}) ---",
|
|
92
|
+
flush=True)
|
|
93
|
+
|
|
94
|
+
# Configure Claude to export OTEL data
|
|
95
|
+
os.environ["CLAUDE_CODE_ENABLE_TELEMETRY"] = "1"
|
|
96
|
+
os.environ["OTEL_METRICS_EXPORTER"] = "otlp"
|
|
97
|
+
os.environ["OTEL_LOGS_EXPORTER"] = "otlp"
|
|
98
|
+
os.environ["OTEL_EXPORTER_OTLP_PROTOCOL"] = "http/json"
|
|
99
|
+
os.environ["OTEL_EXPORTER_OTLP_ENDPOINT"] = f"http://127.0.0.1:{otel_port}"
|
|
100
|
+
os.environ["OTEL_METRIC_EXPORT_INTERVAL"] = "10000"
|
|
101
|
+
os.environ["OTEL_RATE_FILE"] = otel_rate
|
|
102
|
+
|
|
103
|
+
# Run Claude
|
|
104
|
+
with open(stderr_log, "w") as stderr_f, open(stream_capture, "w") as capture_f:
|
|
105
|
+
claude_proc = subprocess.Popen(
|
|
106
|
+
[
|
|
107
|
+
"claude", "-p", prompt,
|
|
108
|
+
"--model", model,
|
|
109
|
+
"--dangerously-skip-permissions",
|
|
110
|
+
"--output-format", "stream-json",
|
|
111
|
+
"--include-partial-messages",
|
|
112
|
+
"--verbose",
|
|
113
|
+
],
|
|
114
|
+
stdout=subprocess.PIPE,
|
|
115
|
+
stderr=stderr_f,
|
|
116
|
+
)
|
|
117
|
+
|
|
118
|
+
processor = StreamProcessor(claude_pid=claude_proc.pid)
|
|
119
|
+
stream_complete = False
|
|
120
|
+
|
|
121
|
+
for line in claude_proc.stdout:
|
|
122
|
+
text = line.decode("utf-8", errors="replace")
|
|
123
|
+
capture_f.write(text)
|
|
124
|
+
capture_f.flush()
|
|
125
|
+
if processor.process_line(text):
|
|
126
|
+
stream_complete = True
|
|
127
|
+
break
|
|
128
|
+
|
|
129
|
+
# Ensure Claude is terminated
|
|
130
|
+
try:
|
|
131
|
+
claude_proc.kill()
|
|
132
|
+
except OSError:
|
|
133
|
+
pass
|
|
134
|
+
claude_proc.wait()
|
|
135
|
+
rc = claude_proc.returncode
|
|
136
|
+
|
|
137
|
+
# SIGTERM produces 143 (128+15). Treat as success when stream detected completion.
|
|
138
|
+
if stream_complete and rc != 0:
|
|
139
|
+
print(f"--- stream processor detected run complete (claude rc={rc}), treating as success ---",
|
|
140
|
+
flush=True)
|
|
141
|
+
rc = 0
|
|
142
|
+
|
|
143
|
+
# Wait for Claude's final OTEL flush
|
|
144
|
+
time.sleep(7)
|
|
145
|
+
|
|
146
|
+
# Stop OTEL collector
|
|
147
|
+
collector_proc.terminate()
|
|
148
|
+
try:
|
|
149
|
+
collector_proc.wait(timeout=5)
|
|
150
|
+
except subprocess.TimeoutExpired:
|
|
151
|
+
collector_proc.kill()
|
|
152
|
+
collector_proc.wait()
|
|
153
|
+
|
|
154
|
+
print(f"--- Claude exit code: {rc} ---", flush=True)
|
|
155
|
+
print("--- stderr log ---", flush=True)
|
|
156
|
+
with open(stderr_log) as f:
|
|
157
|
+
sys.stderr.write(f.read())
|
|
158
|
+
|
|
159
|
+
print("\n--- OTEL Token/Cost Summary ---", flush=True)
|
|
160
|
+
print_summary(otel_log)
|
|
161
|
+
|
|
162
|
+
# Copy artifacts for CI upload
|
|
163
|
+
artifact_dir = os.environ.get("GITHUB_WORKSPACE") or os.environ.get("CI_PROJECT_DIR")
|
|
164
|
+
if artifact_dir:
|
|
165
|
+
import shutil
|
|
166
|
+
for src in [otel_log, stderr_log]:
|
|
167
|
+
try:
|
|
168
|
+
shutil.copy2(src, artifact_dir)
|
|
169
|
+
except (OSError, FileNotFoundError):
|
|
170
|
+
pass
|
|
171
|
+
|
|
172
|
+
return rc
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def main(args=None):
|
|
176
|
+
import argparse
|
|
177
|
+
|
|
178
|
+
parser = argparse.ArgumentParser(description="Run Claude Code in CI with telemetry")
|
|
179
|
+
parser.add_argument("prompt", help="Prompt to send to Claude")
|
|
180
|
+
parser.add_argument("workdir", nargs="?", default=".",
|
|
181
|
+
help="Working directory (default: .)")
|
|
182
|
+
parser.add_argument("--model", default=None,
|
|
183
|
+
help="Claude model (default: $CLAUDE_MODEL or claude-opus-4-6)")
|
|
184
|
+
parsed = parser.parse_args(args)
|
|
185
|
+
|
|
186
|
+
sys.exit(run(parsed.prompt, parsed.workdir, model=parsed.model))
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
if __name__ == "__main__":
|
|
190
|
+
main()
|