Skip to content

Commit 8925b74

Browse files
author
Krzysztof Dziedzic
committed
test: set up itk nightly runs
1 parent 94ad594 commit 8925b74

7 files changed

Lines changed: 479 additions & 88 deletions

File tree

.github/workflows/itk.yaml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,4 +31,4 @@ jobs:
3131
run: bash run_itk.sh
3232
working-directory: itk
3333
env:
34-
A2A_SAMPLES_REVISION: itk-v.021-alpha
34+
A2A_SAMPLES_REVISION: itk-v.023-alpha

.github/workflows/nightly.yaml

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
name: Nightly ITK
2+
3+
on:
4+
schedule:
5+
- cron: '0 2 * * *' # 2:00 AM UTC daily
6+
workflow_dispatch: # Allow manual execution
7+
8+
permissions:
9+
contents: write
10+
11+
jobs:
12+
nightly:
13+
name: Nightly ITK Run
14+
runs-on: ubuntu-latest
15+
16+
steps:
17+
- name: Checkout code
18+
uses: actions/checkout@v6
19+
20+
- name: Install uv
21+
uses: astral-sh/setup-uv@v7
22+
23+
- name: Run Nightly ITK Tests
24+
run: bash run_itk.sh
25+
working-directory: itk
26+
env:
27+
A2A_SAMPLES_REVISION: itk-v.023-alpha
28+
ITK_NIGHTLY_RUN: "True"
29+
30+
- name: Upload Results to Rolling Release
31+
uses: softprops/action-gh-release@v2
32+
with:
33+
tag_name: "nightly-metrics"
34+
prerelease: true
35+
files: |
36+
itk/itk_python.json
37+
env:
38+
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

itk/main.py

Lines changed: 39 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -100,9 +100,22 @@ def extract_instruction(
100100
continue
101101
else:
102102
return inst
103+
103104
return None
104105

105106

107+
def _get_text_from_part(part: Any) -> str | None:
108+
"""Safely extracts text string from a Part object supporting protobuf, pydantic, and raw dict."""
109+
if not part:
110+
return None
111+
if hasattr(part, 'HasField') and part.HasField('text'):
112+
return part.text
113+
root = getattr(part, 'root', part)
114+
if isinstance(root, dict):
115+
return root.get('text')
116+
return getattr(root, 'text', None)
117+
118+
106119
def _extract_text_from_event(event: Any) -> list[str]:
107120
"""Extracts text parts from an event's message."""
108121
if isinstance(event, tuple):
@@ -128,7 +141,7 @@ def _extract_text_from_event(event: Any) -> list[str]:
128141
return results
129142

130143

131-
async def _handle_call_agent_with_resubscribe(
144+
async def _handle_call_agent_with_resubscribe( # noqa: PLR0912, PLR0915
132145
client: Client, request: SendMessageRequest
133146
) -> list[str]:
134147
"""Handles the send-disconnect-resubscribe flow."""
@@ -154,9 +167,28 @@ async def _handle_call_agent_with_resubscribe(
154167
finished = False
155168
async for event in resub_agen:
156169
logger.info('Event after re-subscribe: %s', event)
157-
if hasattr(event, 'HasField') and event.HasField('task'):
170+
if isinstance(event, Task):
171+
task_obj = event
172+
elif hasattr(event, 'HasField') and event.HasField('task'):
158173
task_obj = event.task
159174

175+
if task_obj and hasattr(task_obj, 'history'):
176+
for msg in task_obj.history:
177+
if str(msg.role) == '2' or 'agent' in str(msg.role).lower():
178+
for part in msg.parts:
179+
text = _get_text_from_part(part)
180+
if text and 'task-finished' in text:
181+
logger.info(
182+
'Found task-finished in history, breaking loop!'
183+
)
184+
results.append(text.replace('task-finished', ''))
185+
finished = True
186+
break
187+
if finished:
188+
break
189+
if finished:
190+
break
191+
160192
extracted_text = _extract_text_from_event(event)
161193
for text in extracted_text:
162194
processed_text = text.replace('task-finished', '')
@@ -173,12 +205,11 @@ async def _handle_call_agent_with_resubscribe(
173205
for msg in task_obj.history:
174206
# Check stringified role to support protobuf enums (2 for ROLE_AGENT in v0.3 and v1.0)
175207
# as well as string descriptors from dict/JSON forms.
176-
if str(msg.role) in {'2', 'ROLE_AGENT', 'agent'}:
177-
results.extend(
178-
part.text.replace('task-finished', '')
179-
for part in msg.parts
180-
if part.text
181-
)
208+
if str(msg.role) == '2' or 'agent' in str(msg.role).lower():
209+
for part in msg.parts:
210+
text = _get_text_from_part(part)
211+
if text:
212+
results.append(text.replace('task-finished', ''))
182213

183214
if not finished:
184215
logger.info('Canceling task %s after retrieval.', task_id)

itk/process_results.py

Lines changed: 211 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,211 @@
1+
#!/usr/bin/env python3
2+
"""ITK Compatibility Metrics Processor.
3+
4+
Compiles test outcomes from raw JSON results, retrieves and aggregates historical
5+
runs from GitHub Release assets, and outputs the updated historical metrics log.
6+
"""
7+
8+
import datetime
9+
import json
10+
import logging
11+
import os
12+
import pathlib
13+
import sys
14+
import urllib.error
15+
import urllib.request
16+
17+
18+
# --- CONSTANTS ---
19+
RESULTS_FILE = 'raw_results.json'
20+
HISTORY_OUTPUT_FILE = 'itk_python.json'
21+
HISTORY_URL = 'https://github.com/a2aproject/a2a-python/releases/download/nightly-metrics/itk_python.json'
22+
DEFAULT_HISTORY_LIMIT = 1
23+
24+
HTTP_STATUS_OK = 200
25+
HTTP_STATUS_NOT_FOUND = 404
26+
27+
# Configure logging to match standard ITK formatting
28+
logging.basicConfig(
29+
level=logging.INFO,
30+
)
31+
logger = logging.getLogger(__name__)
32+
33+
34+
def load_raw_results(filepath: str) -> dict:
35+
"""Loads the raw compatibility results from raw_results.json."""
36+
path = pathlib.Path(filepath)
37+
if not path.exists():
38+
logger.error('Results file %s not found.', filepath)
39+
raise SystemExit(1)
40+
41+
try:
42+
with path.open() as f:
43+
return json.load(f)
44+
except (OSError, json.JSONDecodeError):
45+
logger.exception('Error loading results JSON')
46+
raise SystemExit(1) from None
47+
48+
49+
def fetch_existing_history(url: str) -> list:
50+
"""Fetches the existing compatibility history from the GitHub release asset.
51+
52+
If the asset does not exist (HTTP 404), a fresh empty history list is returned.
53+
For all other network or server errors, the script exits with a non-zero status
54+
to prevent overwriting and losing historical metrics.
55+
"""
56+
try:
57+
req = urllib.request.Request( # noqa: S310
58+
url, headers={'User-Agent': 'Mozilla/5.0'}
59+
)
60+
with urllib.request.urlopen(req, timeout=15) as response: # noqa: S310
61+
if response.status == HTTP_STATUS_OK:
62+
history = json.loads(response.read().decode('utf-8'))
63+
logger.info(
64+
'Successfully retrieved history. Current entries: %d',
65+
len(history),
66+
)
67+
return history
68+
logger.error(
69+
'Unexpected HTTP status when downloading existing history: %d',
70+
response.status,
71+
)
72+
raise SystemExit(1) # noqa: TRY301
73+
except urllib.error.HTTPError as e:
74+
if e.code == HTTP_STATUS_NOT_FOUND:
75+
logger.warning(
76+
'No existing history found (HTTP %d). Initializing fresh history.',
77+
e.code,
78+
)
79+
return []
80+
logger.exception(
81+
'HTTP error downloading existing history: %d. Aborting to preserve metrics.',
82+
e.code,
83+
)
84+
raise SystemExit(1) from None
85+
except Exception:
86+
logger.exception(
87+
'Failed to download existing history. Aborting to preserve metrics.'
88+
)
89+
raise SystemExit(1) from None
90+
91+
92+
def load_scenarios(filepath: str) -> list:
93+
"""Loads the list of tests from the scenarios.json definitions."""
94+
path = pathlib.Path(filepath)
95+
if not path.exists():
96+
logger.error('Scenarios file %s not found.', filepath)
97+
raise SystemExit(1)
98+
99+
try:
100+
with path.open() as f:
101+
data = json.load(f)
102+
return data['tests']
103+
except (OSError, json.JSONDecodeError, KeyError):
104+
logger.exception('Failed to load scenarios.json definitions')
105+
raise SystemExit(1) from None
106+
107+
108+
def save_history(filepath: str, history: list) -> None:
109+
"""Saves the updated history back to disk as a release asset candidate."""
110+
path = pathlib.Path(filepath)
111+
try:
112+
with path.open('w') as f:
113+
json.dump(history, f, indent=2)
114+
logger.info(
115+
'Successfully compiled and wrote nightly history to: %s',
116+
filepath,
117+
)
118+
except (OSError, TypeError):
119+
logger.exception('Error writing history file')
120+
sys.exit(1)
121+
122+
123+
def main() -> None:
124+
"""Orchestrates nightly ITK metrics processing and compiles rolling history."""
125+
# 1. Load raw compatibility results
126+
data = load_raw_results(RESULTS_FILE)
127+
all_passed = data.get('all_passed', False)
128+
results = data.get('results', {})
129+
130+
# 2. Fetch existing history from rolling release
131+
history = fetch_existing_history(HISTORY_URL)
132+
133+
# 3. Load scenarios list for base metadata
134+
scenarios_file = (
135+
'scenarios_full.json'
136+
if os.environ.get('ITK_NIGHTLY_RUN') == 'True'
137+
else 'scenarios.json'
138+
)
139+
base_scenarios = load_scenarios(scenarios_file)
140+
# Merge definitions with current outcomes dynamically
141+
compiled_scenarios = []
142+
for name, details in results.items():
143+
# Extract the parent scenario name cleanly by splitting on the subtest suffix
144+
parent_name = name.split('-sub-')[0]
145+
146+
# Find the matching base scenario with an EXACT match!
147+
matched_base = None
148+
for base in base_scenarios:
149+
if parent_name == base['name']:
150+
matched_base = base
151+
break
152+
153+
if not matched_base:
154+
logger.warning(
155+
'No matching base scenario found for result key: %s', name
156+
)
157+
continue
158+
159+
# Build the metadata-rich scenario record
160+
passed = False
161+
sdks = matched_base.get('sdks', [])
162+
edges = matched_base.get('edges')
163+
164+
if isinstance(details, dict):
165+
passed = details.get('passed', False)
166+
sdks = details.get('sdks', sdks)
167+
edges = details.get('edges', edges)
168+
elif isinstance(details, bool):
169+
passed = details
170+
171+
record = {
172+
'name': name,
173+
'sdks': sdks,
174+
'edges': edges,
175+
'protocols': matched_base.get('protocols'),
176+
'behavior': matched_base.get('behavior'),
177+
'traversal': matched_base.get('traversal', 'euler'),
178+
'passed': passed,
179+
}
180+
if 'streaming' in matched_base:
181+
record['streaming'] = matched_base['streaming']
182+
if 'build_subtests' in matched_base:
183+
record['build_subtests'] = matched_base['build_subtests']
184+
185+
compiled_scenarios.append(record)
186+
187+
# 4. Compile new run metadata
188+
new_run = {
189+
'timestamp': datetime.datetime.now(datetime.timezone.utc).isoformat(),
190+
'commit_sha': os.environ.get('GITHUB_SHA', 'local-dev'),
191+
'github_run_id': os.environ.get('GITHUB_RUN_ID', '0'),
192+
'all_passed': all_passed,
193+
'scenarios': compiled_scenarios,
194+
}
195+
196+
# 5. Merge and Prune rolling window
197+
history.append(new_run)
198+
history_limit = int(
199+
os.environ.get('ITK_HISTORY_LIMIT', str(DEFAULT_HISTORY_LIMIT))
200+
)
201+
if len(history) > history_limit:
202+
history = history[-history_limit:]
203+
logger.info('Pruned history to last %d entries.', history_limit)
204+
205+
# 6. Save candidates back to disk
206+
save_history(HISTORY_OUTPUT_FILE, history)
207+
sys.exit(0)
208+
209+
210+
if __name__ == '__main__':
211+
main()

0 commit comments

Comments
 (0)