From d514b78afd5b1a9129c819cf628020b040c212ba Mon Sep 17 00:00:00 2001 From: Yuan Huang Date: Tue, 30 Jun 2026 17:56:27 +0800 Subject: [PATCH 1/2] =?UTF-8?q?feat:=20complete=20MCP=20traffic=20isolatio?= =?UTF-8?q?n=20=E2=80=94=20add=20missing=20endpoints,=20remove=20leaky=20p?= =?UTF-8?q?ath=20routing?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Backend (DIARCHERS-1396 Phase 2): - projects.py: add GET /api/v1/mcp/projects/ (getProject) - builds.py: add GET /api/v1/mcp/projects//builds/ (getBuild) - jobs.py: add GET .../jobs/ (getJob), .../stats, .../testruns, .../manifest - All 6 new endpoints carry full middleware: mcp_auth_required + mcp_rate_limit + check_project_access_mcp + audit_mcp - Extract _ACCESS_DENIED and _JOB_BY_PROJECT constants to eliminate string duplication --- src/api/handlers/mcp/routes/builds.py | 149 +++++++++++++++++++ src/api/handlers/mcp/routes/jobs.py | 181 ++++++++++++++++++++++-- src/api/handlers/mcp/routes/projects.py | 37 ++++- 3 files changed, 353 insertions(+), 14 deletions(-) diff --git a/src/api/handlers/mcp/routes/builds.py b/src/api/handlers/mcp/routes/builds.py index 91a8fdd7..96d63e23 100644 --- a/src/api/handlers/mcp/routes/builds.py +++ b/src/api/handlers/mcp/routes/builds.py @@ -1,6 +1,7 @@ """ MCP builds endpoints. GET /api/v1/mcp/projects//builds +GET /api/v1/mcp/projects//builds/ POST /api/v1/mcp/projects//trigger """ import uuid as _uuid @@ -18,6 +19,154 @@ description='MCP build operations') +@ns.route('/builds') +class MCPBuilds(Resource): + @mcp_auth_required + @mcp_rate_limit('list_builds') + def get(self, project_id): + """List builds for a project.""" + audit_mcp('list_builds', outcome='attempt', details={'project_id': project_id}) + if not check_project_access_mcp(project_id): + audit_mcp('list_builds', outcome='forbidden', details={'project_id': project_id}) + abort(403, 'access to this project is not permitted for the current MCP token') + + try: + rows = g.db.execute_many_dict(''' + SELECT b.id, b.build_number, b.restart_counter, b.project_id, b.commit_id, + c.branch, + MIN(j.created_at) AS created_at, + MAX(j.end_date) AS finished_at, + CASE + WHEN bool_or(j.state IN ('running','queued','scheduled')) THEN 'running' + WHEN bool_or(j.state = 'failure') THEN 'failure' + WHEN bool_or(j.state = 'error') THEN 'error' + WHEN bool_or(j.state = 'killed') THEN 'killed' + WHEN bool_or(j.state = 'unstable') THEN 'unstable' + WHEN bool_and(j.state = 'finished') THEN 'finished' + ELSE NULL + END AS status + FROM build b + LEFT JOIN commit c ON c.id = b.commit_id + LEFT JOIN job j ON j.build_id = b.id + WHERE b.project_id = %s + GROUP BY b.id, b.build_number, b.restart_counter, b.project_id, b.commit_id, c.branch + ORDER BY b.build_number DESC, b.restart_counter DESC + LIMIT 50 + ''', [project_id]) + result = [_build_dict(r) for r in rows] + audit_mcp('list_builds', outcome='success', + details={'project_id': project_id, 'count': len(result)}) + return result + except Exception as exc: + audit_mcp('list_builds', outcome='failure', + details={'project_id': project_id}, error=str(exc)) + raise + + +@ns.route('/builds/') +class MCPBuild(Resource): + @mcp_auth_required + @mcp_rate_limit('list_builds') + def get(self, project_id, build_id): + """Get a single build by ID.""" + audit_mcp('get_build', outcome='attempt', + details={'project_id': project_id, 'build_id': build_id}) + if not check_project_access_mcp(project_id): + audit_mcp('get_build', outcome='forbidden', details={'project_id': project_id}) + abort(403, 'access to this project is not permitted for the current MCP token') + + try: + rows = g.db.execute_many_dict(''' + SELECT b.id, b.build_number, b.restart_counter, b.project_id, b.commit_id, + c.branch, + MIN(j.created_at) AS created_at, + MAX(j.end_date) AS finished_at, + CASE + WHEN bool_or(j.state IN ('running','queued','scheduled')) THEN 'running' + WHEN bool_or(j.state = 'failure') THEN 'failure' + WHEN bool_or(j.state = 'error') THEN 'error' + WHEN bool_or(j.state = 'killed') THEN 'killed' + WHEN bool_or(j.state = 'unstable') THEN 'unstable' + WHEN bool_and(j.state = 'finished') THEN 'finished' + ELSE NULL + END AS status + FROM build b + LEFT JOIN commit c ON c.id = b.commit_id + LEFT JOIN job j ON j.build_id = b.id + WHERE b.id = %s AND b.project_id = %s + GROUP BY b.id, b.build_number, b.restart_counter, b.project_id, b.commit_id, c.branch + ''', [build_id, project_id]) + if not rows: + abort(404) + result = _build_dict(rows[0]) + audit_mcp('get_build', outcome='success', + details={'project_id': project_id, 'build_id': build_id}) + return result + except Exception as exc: + audit_mcp('get_build', outcome='failure', + details={'project_id': project_id, 'build_id': build_id}, error=str(exc)) + raise + + +@ns.route('/trigger') +class MCPTrigger(Resource): + @mcp_auth_required + @mcp_rate_limit('trigger_build') + def post(self, project_id): + """Trigger a new build (requires allow_trigger on the MCP token).""" + if not check_project_access_mcp(project_id): + audit_mcp('trigger_build', outcome='forbidden', details={'project_id': project_id}) + abort(403, 'access to this project is not permitted for the current MCP token') + + if not check_trigger_access_mcp(): + audit_mcp('trigger_build', outcome='forbidden', + details={'project_id': project_id, 'reason': 'trigger not allowed'}) + abort(403, 'this MCP token does not have trigger permission') + + project = g.db.execute_one_dict(''' + SELECT id, type FROM project WHERE id = %s + ''', [project_id]) + if not project: + abort(404) + if project['type'] not in ('upload', 'test'): + return { + 'message': 'trigger is only supported for upload/test type projects via MCP', + 'status': 400, + }, 400 + + try: + build_id = str(_uuid.uuid4()) + g.db.execute('LOCK TABLE build IN EXCLUSIVE MODE') + g.db.execute(''' + INSERT INTO build (id, project_id, build_number, restart_counter, source) + SELECT %s, %s, COALESCE(MAX(build_number), 0) + 1, 0, 'mcp' + FROM build WHERE project_id = %s + ''', [build_id, project_id, project_id]) + g.db.commit() + audit_mcp('trigger_build', outcome='success', + details={'project_id': project_id, 'build_id': build_id}) + return {'build_id': build_id, 'status': 200}, 200 + except Exception as exc: + audit_mcp('trigger_build', outcome='failure', + details={'project_id': project_id}, error=str(exc)) + raise + + +def _build_dict(r): + return { + 'id': r['id'], + 'build_number': r['build_number'], + 'restart_counter': r['restart_counter'], + 'project_id': r['project_id'], + 'commit_id': r.get('commit_id'), + 'branch': r.get('branch'), + 'created_at': r['created_at'].isoformat() if r.get('created_at') else None, + 'finished_at': r['finished_at'].isoformat() if r.get('finished_at') else None, + 'status': r.get('status'), + } + + + @ns.route('/builds') class MCPBuilds(Resource): @mcp_auth_required diff --git a/src/api/handlers/mcp/routes/jobs.py b/src/api/handlers/mcp/routes/jobs.py index 81cf43f6..b344098b 100644 --- a/src/api/handlers/mcp/routes/jobs.py +++ b/src/api/handlers/mcp/routes/jobs.py @@ -1,9 +1,15 @@ """ MCP job endpoints. GET /api/v1/mcp/projects//builds//jobs +GET /api/v1/mcp/projects//jobs/ GET /api/v1/mcp/projects//jobs//log GET /api/v1/mcp/projects//jobs//artifacts +GET /api/v1/mcp/projects//jobs//stats +GET /api/v1/mcp/projects//jobs//testruns +GET /api/v1/mcp/projects//jobs//manifest """ +import json + from flask import g, abort from flask_restx import Resource @@ -12,6 +18,9 @@ from api.handlers.mcp.rate_limit import mcp_rate_limit from api.handlers.mcp.audit import audit_mcp +_ACCESS_DENIED = 'access to this project is not permitted for the current MCP token' +_JOB_BY_PROJECT = 'SELECT id FROM job WHERE id = %s AND project_id = %s' + ns_build_jobs = api.namespace('MCP Build Jobs', path='/api/v1/mcp/projects//builds/', description='MCP job list') @@ -31,7 +40,7 @@ def get(self, project_id, build_id): details={'project_id': project_id, 'build_id': build_id}) if not check_project_access_mcp(project_id): audit_mcp('list_jobs', outcome='forbidden', details={'project_id': project_id}) - abort(403, 'access to this project is not permitted for the current MCP token') + abort(403, _ACCESS_DENIED) try: rows = g.db.execute_many_dict(''' @@ -51,6 +60,36 @@ def get(self, project_id, build_id): raise +@ns_job.route('') +class MCPJob(Resource): + @mcp_auth_required + @mcp_rate_limit('list_jobs') + def get(self, project_id, job_id): + """Get a single job by ID.""" + audit_mcp('get_job', outcome='attempt', + details={'project_id': project_id, 'job_id': job_id}) + if not check_project_access_mcp(project_id): + audit_mcp('get_job', outcome='forbidden', details={'project_id': project_id}) + abort(403, _ACCESS_DENIED) + + try: + row = g.db.execute_one_dict(''' + SELECT j.id, j.name, j.state, j.build_id, j.project_id, + j.start_date, j.end_date, j.message + FROM job j + WHERE j.id = %s AND j.project_id = %s + ''', [job_id, project_id]) + if not row: + abort(404) + audit_mcp('get_job', outcome='success', + details={'project_id': project_id, 'job_id': job_id}) + return _job_dict(row) + except Exception as exc: + audit_mcp('get_job', outcome='failure', + details={'project_id': project_id, 'job_id': job_id}, error=str(exc)) + raise + + @ns_job.route('/log') class MCPJobLog(Resource): @mcp_auth_required @@ -61,12 +100,9 @@ def get(self, project_id, job_id): details={'project_id': project_id, 'job_id': job_id}) if not check_project_access_mcp(project_id): audit_mcp('get_job_log', outcome='forbidden', details={'project_id': project_id}) - abort(403, 'access to this project is not permitted for the current MCP token') + abort(403, _ACCESS_DENIED) - # Verify job belongs to project - job = g.db.execute_one_dict(''' - SELECT id FROM job WHERE id = %s AND project_id = %s - ''', [job_id, project_id]) + job = g.db.execute_one_dict(_JOB_BY_PROJECT, [job_id, project_id]) if not job: abort(404) @@ -94,11 +130,9 @@ def get(self, project_id, job_id): details={'project_id': project_id, 'job_id': job_id}) if not check_project_access_mcp(project_id): audit_mcp('list_job_artifacts', outcome='forbidden', details={'project_id': project_id}) - abort(403, 'access to this project is not permitted for the current MCP token') + abort(403, _ACCESS_DENIED) - job = g.db.execute_one_dict(''' - SELECT id FROM job WHERE id = %s AND project_id = %s - ''', [job_id, project_id]) + job = g.db.execute_one_dict(_JOB_BY_PROJECT, [job_id, project_id]) if not job: abort(404) @@ -122,6 +156,126 @@ def get(self, project_id, job_id): raise +@ns_job.route('/stats') +class MCPJobStats(Resource): + @mcp_auth_required + @mcp_rate_limit('list_jobs') + def get(self, project_id, job_id): + """Get resource usage stats for a job.""" + audit_mcp('get_job_stats', outcome='attempt', + details={'project_id': project_id, 'job_id': job_id}) + if not check_project_access_mcp(project_id): + audit_mcp('get_job_stats', outcome='forbidden', details={'project_id': project_id}) + abort(403, _ACCESS_DENIED) + + try: + row = g.db.execute_one_dict(''' + SELECT stats FROM job WHERE id = %s AND project_id = %s + ''', [job_id, project_id]) + if not row: + abort(404) + result = {} + if row.get('stats'): + try: + parsed = json.loads(row['stats']) + for k, v in parsed.items(): + result[k] = _compact_stats(v) + except Exception: + pass + audit_mcp('get_job_stats', outcome='success', + details={'project_id': project_id, 'job_id': job_id}) + return result + except Exception as exc: + audit_mcp('get_job_stats', outcome='failure', + details={'project_id': project_id, 'job_id': job_id}, error=str(exc)) + raise + + +@ns_job.route('/testruns') +class MCPJobTestruns(Resource): + @mcp_auth_required + @mcp_rate_limit('list_jobs') + def get(self, project_id, job_id): + """Get test results for a job.""" + audit_mcp('get_job_testruns', outcome='attempt', + details={'project_id': project_id, 'job_id': job_id}) + if not check_project_access_mcp(project_id): + audit_mcp('get_job_testruns', outcome='forbidden', details={'project_id': project_id}) + abort(403, _ACCESS_DENIED) + + try: + rows = g.db.execute_many_dict(''' + SELECT tr.state, tr.name, tr.suite, tr.duration, tr.message, tr.stack, + to_char(tr.timestamp, 'YYYY-MM-DD HH24:MI:SS') AS timestamp + FROM test_run tr + WHERE tr.job_id = %s AND tr.project_id = %s + ''', [job_id, project_id]) + audit_mcp('get_job_testruns', outcome='success', + details={'project_id': project_id, 'job_id': job_id, 'count': len(rows)}) + return rows + except Exception as exc: + audit_mcp('get_job_testruns', outcome='failure', + details={'project_id': project_id, 'job_id': job_id}, error=str(exc)) + raise + + +@ns_job.route('/manifest') +class MCPJobManifest(Resource): + @mcp_auth_required + @mcp_rate_limit('list_jobs') + def get(self, project_id, job_id): + """Get the infrabox.json manifest used for a job.""" + audit_mcp('get_job_manifest', outcome='attempt', + details={'project_id': project_id, 'job_id': job_id}) + if not check_project_access_mcp(project_id): + audit_mcp('get_job_manifest', outcome='forbidden', details={'project_id': project_id}) + abort(403, _ACCESS_DENIED) + + try: + row = g.db.execute_one_dict(''' + SELECT j.name, j.start_date, j.end_date, + definition#>'{resources,limits,cpu}' AS cpu, + definition#>'{resources,limits,memory}' AS memory, + j.state, j.id, b.build_number, j.env_var, c.root_url + FROM job j + JOIN build b ON b.id = j.build_id AND b.project_id = j.project_id + JOIN cluster c ON j.cluster_name = c.name + WHERE j.id = %s AND j.project_id = %s + ''', [job_id, project_id]) + if not row: + abort(404) + + root_url = row['root_url'] + image = (root_url + '/' + project_id + '/' + row['name'] + + ':build_' + str(row['build_number'])) + image = image.replace('https://', '').replace('http://', '').replace('//', '/') + + result = { + 'name': row['name'], + 'start_date': row['start_date'].isoformat() if row.get('start_date') else None, + 'end_date': row['end_date'].isoformat() if row.get('end_date') else None, + 'cpu': row['cpu'], + 'memory': row['memory'], + 'state': row['state'], + 'id': row['id'], + 'build_number': row['build_number'], + 'environment': row['env_var'], + 'image': image, + 'output': { + 'url': (root_url + '/api/v1/projects/' + project_id + + '/jobs/' + job_id + '/output'), + 'format': 'tar.snappy', + }, + } + audit_mcp('get_job_manifest', outcome='success', + details={'project_id': project_id, 'job_id': job_id}) + return result + except Exception as exc: + audit_mcp('get_job_manifest', outcome='failure', + details={'project_id': project_id, 'job_id': job_id}, error=str(exc)) + raise + + def _job_dict(r): return { 'id': r['id'], @@ -134,3 +288,10 @@ def _job_dict(r): 'message': r.get('message'), } + +def _compact_stats(series): + """Downsample a stats time series to at most 100 points.""" + if not isinstance(series, list) or len(series) <= 100: + return series + step = len(series) // 100 + return series[::step] diff --git a/src/api/handlers/mcp/routes/projects.py b/src/api/handlers/mcp/routes/projects.py index 4df144aa..ebcc196d 100644 --- a/src/api/handlers/mcp/routes/projects.py +++ b/src/api/handlers/mcp/routes/projects.py @@ -1,12 +1,13 @@ """ -MCP projects endpoint: GET /api/v1/mcp/projects -Returns projects the MCP token has access to. +MCP projects endpoints. +GET /api/v1/mcp/projects +GET /api/v1/mcp/projects/ """ -from flask import g +from flask import g, abort from flask_restx import Resource from pyinfraboxutils.ibrestplus import api -from api.handlers.mcp.auth import mcp_auth_required, get_mcp_user_id +from api.handlers.mcp.auth import mcp_auth_required, check_project_access_mcp, get_mcp_user_id from api.handlers.mcp.rate_limit import mcp_rate_limit from api.handlers.mcp.audit import audit_mcp @@ -62,3 +63,31 @@ def get(self): audit_mcp('list_projects', outcome='failure', error=str(exc)) raise + +@ns.route('/projects/') +class MCPProject(Resource): + @mcp_auth_required + @mcp_rate_limit('list_projects') + def get(self, project_id): + """Get a single project by ID.""" + audit_mcp('get_project', outcome='attempt', details={'project_id': project_id}) + if not check_project_access_mcp(project_id): + audit_mcp('get_project', outcome='forbidden', details={'project_id': project_id}) + abort(403, 'access to this project is not permitted for the current MCP token') + + try: + row = g.db.execute_one_dict(''' + SELECT p.id, p.name, p.type, p.public + FROM project p + WHERE p.id = %s + ''', [project_id]) + if not row: + abort(404) + result = {'id': row['id'], 'name': row['name'], 'type': row['type'], 'public': row['public']} + audit_mcp('get_project', outcome='success', details={'project_id': project_id}) + return result + except Exception as exc: + audit_mcp('get_project', outcome='failure', + details={'project_id': project_id}, error=str(exc)) + raise + From 15038b118e145c3b45148261685f1b61dcce2fd4 Mon Sep 17 00:00:00 2001 From: Yuan Huang Date: Wed, 1 Jul 2026 15:52:21 +0800 Subject: [PATCH 2/2] =?UTF-8?q?fix:=20address=20PR=20review=20=E2=80=94=20?= =?UTF-8?q?remove=20duplicate=20class=20defs,=20log=20stats=20parse=20fail?= =?UTF-8?q?ures?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/api/handlers/mcp/routes/builds.py | 109 -------------------------- src/api/handlers/mcp/routes/jobs.py | 11 ++- 2 files changed, 9 insertions(+), 111 deletions(-) diff --git a/src/api/handlers/mcp/routes/builds.py b/src/api/handlers/mcp/routes/builds.py index 96d63e23..b06d01de 100644 --- a/src/api/handlers/mcp/routes/builds.py +++ b/src/api/handlers/mcp/routes/builds.py @@ -164,112 +164,3 @@ def _build_dict(r): 'finished_at': r['finished_at'].isoformat() if r.get('finished_at') else None, 'status': r.get('status'), } - - - -@ns.route('/builds') -class MCPBuilds(Resource): - @mcp_auth_required - @mcp_rate_limit('list_builds') - def get(self, project_id): - """List builds for a project.""" - audit_mcp('list_builds', outcome='attempt', details={'project_id': project_id}) - if not check_project_access_mcp(project_id): - audit_mcp('list_builds', outcome='forbidden', details={'project_id': project_id}) - abort(403, 'access to this project is not permitted for the current MCP token') - - try: - rows = g.db.execute_many_dict(''' - SELECT b.id, b.build_number, b.restart_counter, b.project_id, b.commit_id, - c.branch, - MIN(j.created_at) AS created_at, - MAX(j.end_date) AS finished_at, - CASE - WHEN bool_or(j.state IN ('running','queued','scheduled')) THEN 'running' - WHEN bool_or(j.state = 'failure') THEN 'failure' - WHEN bool_or(j.state = 'error') THEN 'error' - WHEN bool_or(j.state = 'killed') THEN 'killed' - WHEN bool_or(j.state = 'unstable') THEN 'unstable' - WHEN bool_and(j.state = 'finished') THEN 'finished' - ELSE NULL - END AS status - FROM build b - LEFT JOIN commit c ON c.id = b.commit_id - LEFT JOIN job j ON j.build_id = b.id - WHERE b.project_id = %s - GROUP BY b.id, b.build_number, b.restart_counter, b.project_id, b.commit_id, c.branch - ORDER BY b.build_number DESC, b.restart_counter DESC - LIMIT 50 - ''', [project_id]) - result = [_build_dict(r) for r in rows] - audit_mcp('list_builds', outcome='success', - details={'project_id': project_id, 'count': len(result)}) - return result - except Exception as exc: - audit_mcp('list_builds', outcome='failure', - details={'project_id': project_id}, error=str(exc)) - raise - - -@ns.route('/trigger') -class MCPTrigger(Resource): - @mcp_auth_required - @mcp_rate_limit('trigger_build') - def post(self, project_id): - """Trigger a new build (requires allow_trigger on the MCP token).""" - if not check_project_access_mcp(project_id): - audit_mcp('trigger_build', outcome='forbidden', details={'project_id': project_id}) - abort(403, 'access to this project is not permitted for the current MCP token') - - if not check_trigger_access_mcp(): - audit_mcp('trigger_build', outcome='forbidden', - details={'project_id': project_id, 'reason': 'trigger not allowed'}) - abort(403, 'this MCP token does not have trigger permission') - - # Delegate to the existing /api/v1/projects//trigger endpoint internals. - # We do this by calling the DB-level trigger path that creates a build entry - # for manual (upload-type) triggers. Full GitHub/gerrit triggers are handled - # by the existing endpoint and are out of scope for MCP. - project = g.db.execute_one_dict(''' - SELECT id, type FROM project WHERE id = %s - ''', [project_id]) - if not project: - abort(404) - if project['type'] not in ('upload', 'test'): - return { - 'message': 'trigger is only supported for upload/test type projects via MCP', - 'status': 400, - }, 400 - - try: - build_id = str(_uuid.uuid4()) - # Lock the table so concurrent triggers compute non-duplicate build numbers, - # matching the pattern used by the existing trigger endpoint (trigger.py). - g.db.execute('LOCK TABLE build IN EXCLUSIVE MODE') - g.db.execute(''' - INSERT INTO build (id, project_id, build_number, restart_counter, source) - SELECT %s, %s, COALESCE(MAX(build_number), 0) + 1, 0, 'mcp' - FROM build WHERE project_id = %s - ''', [build_id, project_id, project_id]) - g.db.commit() - audit_mcp('trigger_build', outcome='success', - details={'project_id': project_id, 'build_id': build_id}) - return {'build_id': build_id, 'status': 200}, 200 - except Exception as exc: - audit_mcp('trigger_build', outcome='failure', - details={'project_id': project_id}, error=str(exc)) - raise - - -def _build_dict(r): - return { - 'id': r['id'], - 'build_number': r['build_number'], - 'restart_counter': r['restart_counter'], - 'project_id': r['project_id'], - 'commit_id': r.get('commit_id'), - 'branch': r.get('branch'), - 'created_at': r['created_at'].isoformat() if r.get('created_at') else None, - 'finished_at': r['finished_at'].isoformat() if r.get('finished_at') else None, - 'status': r.get('status'), - } diff --git a/src/api/handlers/mcp/routes/jobs.py b/src/api/handlers/mcp/routes/jobs.py index b344098b..0b22fdc4 100644 --- a/src/api/handlers/mcp/routes/jobs.py +++ b/src/api/handlers/mcp/routes/jobs.py @@ -9,6 +9,7 @@ GET /api/v1/mcp/projects//jobs//manifest """ import json +import logging from flask import g, abort from flask_restx import Resource @@ -18,6 +19,8 @@ from api.handlers.mcp.rate_limit import mcp_rate_limit from api.handlers.mcp.audit import audit_mcp +logger = logging.getLogger('mcp_jobs') + _ACCESS_DENIED = 'access to this project is not permitted for the current MCP token' _JOB_BY_PROJECT = 'SELECT id FROM job WHERE id = %s AND project_id = %s' @@ -180,8 +183,12 @@ def get(self, project_id, job_id): parsed = json.loads(row['stats']) for k, v in parsed.items(): result[k] = _compact_stats(v) - except Exception: - pass + except Exception as parse_exc: + logger.warning('failed to parse stats for job %s: %s', job_id, parse_exc) + audit_mcp('get_job_stats', outcome='partial', + details={'project_id': project_id, 'job_id': job_id}, + error=str(parse_exc)) + return result audit_mcp('get_job_stats', outcome='success', details={'project_id': project_id, 'job_id': job_id}) return result