diff --git a/mod_api/routes/system.py b/mod_api/routes/system.py index da0d3d6f4..b8206e151 100644 --- a/mod_api/routes/system.py +++ b/mod_api/routes/system.py @@ -4,6 +4,7 @@ GET /system/health Health check (unauthenticated) GET /system/queue Queue status — active + queued runs GET /runs/{id}/artifacts Run artifacts from GCS + local +GET /runs/{id}/console-diff Diagnostic stdout vs previous run GET /system/about Versions this platform runs against GET /system/maintenance Maintenance state per platform PATCH /system/maintenance/{platform} Pause or resume a platform @@ -20,11 +21,13 @@ system:write, so a token can be narrowed further than the role allows. """ +import difflib import os +import tempfile from datetime import datetime, timezone from flask import g, jsonify, request -from sqlalchemy import text +from sqlalchemy import func, text from sqlalchemy.exc import IntegrityError from sqlalchemy.orm import joinedload @@ -39,8 +42,10 @@ from mod_api.schemas.system import (BlockedUserCreateSchema, ForbiddenExtensionCreateSchema, MaintenanceUpdateSchema) +from mod_api.services.diff_service import compute_log_diff, read_log_lines from mod_api.services.status import batch_get_run_data, is_dummy_row -from mod_api.services.storage import (get_log_file_path, +from mod_api.services.storage import (get_combined_stdout_path, + get_log_file_path, get_test_results_base_path, resolve_artifact) from mod_api.utils import paginated_response, safe_resolve, single_response @@ -370,6 +375,148 @@ def list_artifacts(run_id, limit=50, offset=0): return paginated_response(paged, total, limit, offset) +MAX_CONSOLE_DIFF_BYTES = 10 * 1024 * 1024 +UNIFIED_DIFF_LINE_CAP = 10000 + + +def _unlink_if_temp(path): + """Delete GCS downloads from get_combined_stdout_path, not local repo copies.""" + if not path: + return + name = os.path.basename(path) + if not (name.startswith('console-') and name.endswith('.log')): + return + try: + temp_root = os.path.realpath(tempfile.gettempdir()) + real = os.path.realpath(path) + if real == temp_root or real.startswith(temp_root + os.sep): + os.unlink(real) + except OSError: + pass + + +def _previous_completed_run(test): + """Most recent completed run on the same platform with a lower id.""" + latest = g.db.query( + TestProgress.test_id, + func.max(TestProgress.id).label('max_id'), + ).group_by(TestProgress.test_id).subquery() + return ( + g.db.query(Test) + .join(latest, latest.c.test_id == Test.id) + .join(TestProgress, TestProgress.id == latest.c.max_id) + .filter( + Test.platform == test.platform, + Test.id < test.id, + TestProgress.status == TestStatus.completed, + ) + .order_by(Test.id.desc()) + .first() + ) + + +@mod_api.route('/runs//console-diff', methods=['GET']) +@require_scope(Scope.RESULTS_READ) +@validate_path_id('run_id') +def get_console_diff(run_id): + """ + Diagnostic unified/structured diff of combined VM stdout vs another run. + + Default comparison is the previous completed run on the same platform. + This does not change CI pass/fail or goldens — stdout is a whole-run log. + """ + test = Test.query.filter(Test.id == run_id).first() + if test is None: + return make_error_response( + 'not_found', f'Run {run_id} not found.', http_status=404) + + format_type = request.args.get('format', 'unified') + if format_type not in ('unified', 'structured'): + return make_error_response( + 'validation_error', + 'format must be unified or structured.', + http_status=400) + + against_raw = request.args.get('against') + if against_raw is None or against_raw == '': + against_run = _previous_completed_run(test) + if against_run is None: + return make_error_response( + 'not_found', + 'No previous completed run on this platform to compare against.', + http_status=404) + else: + try: + against_id = int(against_raw) + except (TypeError, ValueError): + return make_error_response( + 'validation_error', 'against must be an integer run id.', + http_status=400) + if against_id == run_id: + return make_error_response( + 'validation_error', 'against must be a different run.', + http_status=400) + against_run = Test.query.filter(Test.id == against_id).first() + if against_run is None: + return make_error_response( + 'not_found', f'Run {against_id} not found.', http_status=404) + + current_path = get_combined_stdout_path(run_id) + previous_path = get_combined_stdout_path(against_run.id) + try: + if current_path is None: + return make_error_response( + 'not_found', + f'combined_stdout.log for run {run_id} is not in storage.', + http_status=404) + if previous_path is None: + return make_error_response( + 'not_found', + f'combined_stdout.log for run {against_run.id} is not in storage.', + http_status=404) + + ids = { + 'run_id': run_id, + 'against_run_id': against_run.id, + 'kind': 'console', + } + + try: + if format_type == 'unified': + previous_lines = read_log_lines( + previous_path, MAX_CONSOLE_DIFF_BYTES) + current_lines = read_log_lines( + current_path, MAX_CONSOLE_DIFF_BYTES) + differ = list(difflib.unified_diff( + previous_lines, + current_lines, + fromfile=f'run {against_run.id}', + tofile=f'run {run_id}', + lineterm='', + )) + if len(differ) > UNIFIED_DIFF_LINE_CAP: + differ = differ[:UNIFIED_DIFF_LINE_CAP] + differ.append('... Diff truncated due to length ...') + return single_response({ + **ids, + 'format': 'unified', + 'content': '\n'.join(differ), + }) + + context_lines = request.args.get('context_lines', 3, type=int) + diff_result = compute_log_diff( + previous_path, current_path, context_lines=context_lines) + diff_result.update(ids) + diff_result['format'] = 'structured' + return single_response(diff_result) + except ValueError as exc: + return make_error_response( + 'unprocessable', str(exc), http_status=422) + finally: + _unlink_if_temp(current_path) + _unlink_if_temp(previous_path) + + @mod_api.route('/system/about', methods=['GET']) @require_scope(Scope.SYSTEM_READ) def system_about(): diff --git a/mod_api/services/diff_service.py b/mod_api/services/diff_service.py index f527c57ba..cf2e76683 100644 --- a/mod_api/services/diff_service.py +++ b/mod_api/services/diff_service.py @@ -218,3 +218,67 @@ def file_sha256(file_path: str) -> Optional[str]: return sha.hexdigest() except (OSError, IOError): return None + + +def read_log_lines(file_path: str, max_size_bytes: int = 10 * 1024 * 1024) -> List[str]: + """Read a console log for diagnostic diffs (not TestResults-bound).""" + file_size = os.path.getsize(file_path) + if file_size > max_size_bytes: + raise ValueError( + f"File too large ({file_size} bytes > {max_size_bytes} limit)") + try: + with open(file_path, encoding='utf8') as f: + return [line.rstrip('\n\r') for line in f.readlines()] + except UnicodeDecodeError: + with open(file_path, encoding='cp1252', errors='replace') as f: + return [line.rstrip('\n\r') for line in f.readlines()] + + +def compute_log_diff( + previous_path: str, + current_path: str, + context_lines: int = 3, + max_hunks: int = 500, +) -> Dict[str, Any]: + """Structured diff of two combined_stdout logs. Does not affect CI verdicts.""" + context_lines = max(1, min(context_lines, 50)) + + if not os.path.isfile(previous_path): + return { + 'status': 'missing_expected', + 'summary': {'added_lines': 0, 'removed_lines': 0, 'changed_hunks': 0}, + 'hunks': [], + } + + if not os.path.isfile(current_path): + return { + 'status': 'missing_actual', + 'summary': {'added_lines': 0, 'removed_lines': 0, 'changed_hunks': 0}, + 'hunks': [], + } + + previous_lines = read_log_lines(previous_path) + current_lines = read_log_lines(current_path) + + if previous_lines == current_lines: + return { + 'status': 'identical', + 'summary': {'added_lines': 0, 'removed_lines': 0, 'changed_hunks': 0}, + 'hunks': [], + } + + hunks = _compute_hunks(previous_lines, current_lines, context_lines, max_hunks) + added = sum( + 1 for h in hunks for line in h['lines'] if line['kind'] == 'added') + removed = sum( + 1 for h in hunks for line in h['lines'] if line['kind'] == 'removed') + + return { + 'status': 'different', + 'summary': { + 'added_lines': added, + 'removed_lines': removed, + 'changed_hunks': len(hunks), + }, + 'hunks': hunks, + } diff --git a/mod_api/services/storage.py b/mod_api/services/storage.py index f9d56533e..79544e29e 100644 --- a/mod_api/services/storage.py +++ b/mod_api/services/storage.py @@ -8,6 +8,7 @@ import logging import os +import tempfile from datetime import timedelta from typing import Optional, Tuple @@ -75,3 +76,49 @@ def get_test_results_base_path() -> str: """Return the base directory where TestResults files are stored.""" from run import config return os.path.join(config.get('SAMPLE_REPOSITORY', ''), 'TestResults') + + +def combined_stdout_relative_path(run_id: int) -> str: + """GCS / SAMPLE_REPOSITORY path of a run's combined VM stdout log.""" + return f'test_artifacts/{int(run_id)}/combined_stdout.log' + + +def get_combined_stdout_path(run_id: int) -> Optional[str]: + """ + Absolute path to combined_stdout.log for this run, or None. + + Prefers the local SAMPLE_REPOSITORY copy. If that is missing and GCS + has the blob, download it to a temp file (caller must delete it). + """ + from run import config, storage_client_bucket + + relative = combined_stdout_relative_path(run_id) + sample_repo = config.get('SAMPLE_REPOSITORY', '') + if not sample_repo: + local_path = '' + local_exists = False + else: + local_path = os.path.join(sample_repo, relative) + real_base = os.path.realpath(sample_repo) + real_path = os.path.realpath(local_path) + if not (real_path.startswith(real_base + os.sep) or real_path == real_base): + return None + local_exists = os.path.isfile(local_path) + + if local_exists: + return local_path + + if not storage_client_bucket: + return None + + try: + blob = storage_client_bucket.blob(relative) + if not blob.exists(): + return None + fd, tmp_path = tempfile.mkstemp(prefix=f'console-{run_id}-', suffix='.log') + os.close(fd) + blob.download_to_filename(tmp_path) + return tmp_path + except Exception as e: + logger.warning(f"Failed to fetch combined_stdout for run {run_id}: {e}") + return None diff --git a/openapi-ci-api.yaml b/openapi-ci-api.yaml index b34311d30..4a6059166 100644 --- a/openapi-ci-api.yaml +++ b/openapi-ci-api.yaml @@ -3592,6 +3592,83 @@ paths: default: $ref: "#/components/responses/Error" + /runs/{run_id}/console-diff: + get: + tags: [System] + summary: Diagnostic diff of combined stdout vs another run + operationId: getConsoleDiff + description: > + Compare this run's combined_stdout.log to another run's copy. + Default `against` is the previous completed run on the same platform. + This is a diagnostic view only and does not change CI pass/fail. + security: + - bearerAuth: [] + x-required-scope: results:read + parameters: + - $ref: "#/components/parameters/RunId" + - name: against + in: query + description: Run id to compare against. Defaults to previous completed same-platform run. + schema: + type: integer + minimum: 1 + - name: format + in: query + schema: + type: string + enum: [unified, structured] + default: unified + - name: context_lines + in: query + schema: + type: integer + minimum: 1 + maximum: 50 + default: 3 + responses: + "200": + description: Diagnostic console log diff + content: + application/json: + schema: + type: object + properties: + run_id: + type: integer + against_run_id: + type: integer + kind: + type: string + enum: [console] + format: + type: string + enum: [unified, structured] + content: + type: string + status: + type: string + summary: + type: object + hunks: + type: array + items: + type: object + "400": + $ref: "#/components/responses/BadRequest" + "401": + $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/Forbidden" + "404": + $ref: "#/components/responses/NotFound" + "422": + $ref: "#/components/responses/UnprocessableEntity" + "429": + $ref: "#/components/responses/RateLimited" + default: + $ref: "#/components/responses/Error" + + # # COMPONENTS # diff --git a/scripts/verify_schemathesis.py b/scripts/verify_schemathesis.py index c40339c5a..5303ea5e2 100644 --- a/scripts/verify_schemathesis.py +++ b/scripts/verify_schemathesis.py @@ -452,6 +452,18 @@ def test_artifacts_list(case, auth_token): _call_safe(case) +_console_diff_schema = _full_schema.include( + path="/runs/{run_id}/console-diff", method="GET" +) + + +@_console_diff_schema.parametrize() +def test_console_diff(case, auth_token): + """GET /runs/{run_id}/console-diff — fuzz diagnostic stdout diffs.""" + _set_auth(case, auth_token) + _call_safe(case) + + # --- Errors & Logs ------------------------------------------------- _errors_schema = _full_schema.include( diff --git a/tests/api/test_routes_system.py b/tests/api/test_routes_system.py index 66c86af73..43d8d5a86 100644 --- a/tests/api/test_routes_system.py +++ b/tests/api/test_routes_system.py @@ -10,7 +10,8 @@ from mod_ci.models import BlockedUsers, MaintenanceMode from mod_regression.models import RegressionTestOutput from mod_sample.models import ForbiddenExtension -from mod_test.models import Fork, Test, TestPlatform, TestResultFile, TestType +from mod_test.models import (Fork, Test, TestPlatform, TestProgress, + TestResultFile, TestStatus, TestType) from tests.api.base import ApiTestCase @@ -376,3 +377,131 @@ def test_allow_extension_not_forbidden(self): res = self.client.delete('/api/v1/system/forbidden-extensions/mkv', headers=self._admin()) self.assertEqual(res.status_code, 404) + + +class TestConsoleDiff(ApiTestCase): + """Diagnostic GET /runs/{id}/console-diff. Does not change CI verdicts.""" + + def setUp(self): + super().setUp() + self.test_dir = tempfile.TemporaryDirectory() + self.dir_path = self.test_dir.name + self.setup_run_data('con') + self.prev = self.test_obj + self.curr = Test(TestPlatform.linux, TestType.commit, + self.fork.id, 'master', 'commit_b') + g.db.add(self.curr) + g.db.commit() + g.db.add(TestProgress(self.prev.id, TestStatus.completed, 'done')) + g.db.add(TestProgress(self.curr.id, TestStatus.completed, 'done')) + g.db.commit() + _rate_limit_store.clear() + + def tearDown(self): + self.test_dir.cleanup() + super().tearDown() + + def _token(self, name='con_tok', scopes=None): + return self.get_token( + 'con_user@local.com', 'userpass123', name, + scopes=scopes or ['results:read']) + + def _write_stdout(self, run_id, content): + folder = os.path.join(self.dir_path, 'test_artifacts', str(run_id)) + os.makedirs(folder, exist_ok=True) + path = os.path.join(folder, 'combined_stdout.log') + with open(path, 'w', encoding='utf-8') as handle: + handle.write(content) + return path + + def _get(self, run_id, token, query=''): + with patch('run.storage_client_bucket', None), patch.dict( + 'run.config', {'SAMPLE_REPOSITORY': self.dir_path}): + return self.client.get( + f'/api/v1/runs/{run_id}/console-diff{query}', + headers={'Authorization': f'Bearer {token}'}) + + def test_unified_diff_against_previous_completed(self): + self._write_stdout(self.prev.id, 'line1\nold\n') + self._write_stdout(self.curr.id, 'line1\nnew\n') + res = self._get(self.curr.id, self._token()) + self.assertEqual(res.status_code, 200) + self.assertEqual(res.json['kind'], 'console') + self.assertEqual(res.json['format'], 'unified') + self.assertEqual(res.json['run_id'], self.curr.id) + self.assertEqual(res.json['against_run_id'], self.prev.id) + self.assertIn('-old', res.json['content']) + self.assertIn('+new', res.json['content']) + + def test_identical_logs(self): + self._write_stdout(self.prev.id, 'same\n') + self._write_stdout(self.curr.id, 'same\n') + res = self._get(self.curr.id, self._token('id'), '?format=structured') + self.assertEqual(res.status_code, 200) + self.assertEqual(res.json['status'], 'identical') + self.assertEqual(res.json['summary']['changed_hunks'], 0) + + def test_explicit_against(self): + other = Test(TestPlatform.linux, TestType.commit, + self.fork.id, 'master', 'commit_c') + g.db.add(other) + g.db.commit() + self._write_stdout(other.id, 'aaa\n') + self._write_stdout(self.curr.id, 'bbb\n') + res = self._get( + self.curr.id, self._token('ex'), f'?against={other.id}') + self.assertEqual(res.status_code, 200) + self.assertEqual(res.json['against_run_id'], other.id) + self.assertIn('-aaa', res.json['content']) + + def test_no_previous_completed_run(self): + lone = Test(TestPlatform.windows, TestType.commit, + self.fork.id, 'master', 'win_only') + g.db.add(lone) + g.db.commit() + g.db.add(TestProgress(lone.id, TestStatus.completed, 'done')) + g.db.commit() + self._write_stdout(lone.id, 'win\n') + res = self._get(lone.id, self._token('lone')) + self.assertEqual(res.status_code, 404) + + def test_missing_stdout(self): + res = self._get(self.curr.id, self._token('miss')) + self.assertEqual(res.status_code, 404) + + def test_invalid_format(self): + self._write_stdout(self.prev.id, 'a\n') + self._write_stdout(self.curr.id, 'b\n') + res = self._get(self.curr.id, self._token('fmt'), '?format=html') + self.assertEqual(res.status_code, 400) + + def test_against_self_rejected(self): + res = self._get( + self.curr.id, self._token('self'), f'?against={self.curr.id}') + self.assertEqual(res.status_code, 400) + + def test_against_not_an_int(self): + res = self._get(self.curr.id, self._token('nan'), '?against=nope') + self.assertEqual(res.status_code, 400) + + def test_requires_results_read(self): + token = self.get_token( + 'con_user@local.com', 'userpass123', 'noscope', + scopes=['runs:read']) + res = self._get(self.curr.id, token) + self.assertEqual(res.status_code, 403) + + def test_run_not_found(self): + res = self._get(999999, self._token('nf')) + self.assertEqual(res.status_code, 404) + + def test_file_too_large(self): + self._write_stdout(self.prev.id, 'ok\n') + huge = os.path.join( + self.dir_path, 'test_artifacts', str(self.curr.id)) + os.makedirs(huge, exist_ok=True) + path = os.path.join(huge, 'combined_stdout.log') + with open(path, 'wb') as handle: + handle.write(b'x' * (10 * 1024 * 1024 + 1)) + res = self._get(self.curr.id, self._token('big')) + self.assertEqual(res.status_code, 422) diff --git a/tests/api/test_services_diff_service.py b/tests/api/test_services_diff_service.py index 4beb34daf..934639d24 100644 --- a/tests/api/test_services_diff_service.py +++ b/tests/api/test_services_diff_service.py @@ -2,7 +2,8 @@ import tempfile from mod_api.services.diff_service import (_compute_hunks, compute_diff, - file_sha256, read_lines) + compute_log_diff, file_sha256, + read_lines, read_log_lines) from tests.api.base import ApiTestCase @@ -124,3 +125,18 @@ def test_file_sha256(self): self.assertIsNone(file_sha256( os.path.join(self.dir_path, "nonexistent.txt"))) + + def test_compute_log_diff_outside_test_results(self): + path1 = self.create_file("console_a.log", "a\nb\n") + path2 = self.create_file("console_b.log", "a\nc\n") + diff = compute_log_diff(path1, path2) + self.assertEqual(diff["status"], "different") + self.assertEqual(diff["summary"]["added_lines"], 1) + self.assertEqual(diff["summary"]["removed_lines"], 1) + + def test_read_log_lines_too_large(self): + path = os.path.join(self.dir_path, "huge.log") + with open(path, "wb") as handle: + handle.write(b"y" * 50) + with self.assertRaises(ValueError): + read_log_lines(path, max_size_bytes=10) diff --git a/web/src/components/DiffDrawer.tsx b/web/src/components/DiffDrawer.tsx index a148aab58..4f15a1646 100644 --- a/web/src/components/DiffDrawer.tsx +++ b/web/src/components/DiffDrawer.tsx @@ -3,11 +3,12 @@ import { Download, FileText, PlayCircle } from "lucide-react"; import { useEffect, useMemo, useRef, useState } from "react"; import { AnimatedSheet, SheetDescription, SheetTitle } from "@/components/ui/sheet"; -import { fetchDiff, outputText, sampleFile } from "@/lib/api"; +import { fetchConsoleDiff, fetchDiff, outputText, sampleFile } from "@/lib/api"; import { clock, cuesToVtt, parseCues, type Cue } from "@/lib/cues"; import { cn } from "@/lib/utils"; -export interface DiffTarget { +export interface OutputDiffTarget { + kind?: "output"; runId: number; sampleId: number; regressionId: number; @@ -16,6 +17,18 @@ export interface DiffTarget { sampleName: string; } +export interface ConsoleDiffTarget { + kind: "console"; + runId: number; + against?: number; +} + +export type DiffTarget = OutputDiffTarget | ConsoleDiffTarget; + +function isConsole(target: DiffTarget): target is ConsoleDiffTarget { + return target.kind === "console"; +} + type View = "diff" | "playback"; /** MediaError.MEDIA_ERR_SRC_NOT_SUPPORTED — nothing here can decode the file. */ @@ -49,27 +62,35 @@ export function DiffDrawer({
- Test #{target.regressionId} + {isConsole(target) ? "Combined stdout" : `Test #${target.regressionId}`} - {target.command} · {target.sampleName} · run {target.runId} + {isConsole(target) + ? `run ${target.runId} vs previous completed run on this platform` + : `${target.command} · ${target.sampleName} · run ${target.runId}`} -
- setView("diff")} - /> - setView("playback")} - /> -
+ {!isConsole(target) && ( +
+ setView("diff")} + /> + setView("playback")} + /> +
+ )}
- {view === "diff" ? : } + {isConsole(target) || view === "diff" ? ( + + ) : ( + + )}
)} @@ -108,11 +129,19 @@ function numbered(lines: string[]): { id: string; line: string }[] { } function DiffPane({ target }: Readonly<{ target: DiffTarget }>) { + const consoleTarget = isConsole(target); const { data, isLoading, isError, error } = useQuery({ - queryKey: ["diff", target.runId, target.regressionId, target.outputId], + queryKey: consoleTarget + ? ["console-diff", target.runId, target.against ?? null] + : ["diff", target.runId, target.regressionId, target.outputId], staleTime: Infinity, retry: false, - queryFn: () => fetchDiff(target), + queryFn: () => { + if (isConsole(target)) { + return fetchConsoleDiff(target.runId, target.against); + } + return fetchDiff(target); + }, }); return ( @@ -120,8 +149,10 @@ function DiffPane({ target }: Readonly<{ target: DiffTarget }>) { {isLoading && } {isError && ( - {error instanceof Error ? error.message : "Could not load diff"} — the output - file for this result isn't present in storage. + {error instanceof Error ? error.message : "Could not load diff"} + {isConsole(target) + ? " — combined stdout may be missing for this run or the previous one." + : " — the output file for this result isn't present in storage."} )} {data && ( @@ -162,7 +193,7 @@ function DiffPane({ target }: Readonly<{ target: DiffTarget }>) { * worked. When it cannot play, the two tracks are still worth reading side by * side, and the sample is one click away for a local player. */ -function PlaybackPane({ target }: Readonly<{ target: DiffTarget }>) { +function PlaybackPane({ target }: Readonly<{ target: OutputDiffTarget }>) { const video = useRef(null); const [at, setAt] = useState(0); // The element's own error code, kept rather than a boolean: a container no @@ -246,7 +277,7 @@ function PlaybackPane({ target }: Readonly<{ target: DiffTarget }>) { ); } -function useOutput(target: DiffTarget, which: "expected" | "actual") { +function useOutput(target: OutputDiffTarget, which: "expected" | "actual") { return useQuery({ queryKey: ["output-text", target.runId, target.regressionId, target.outputId, which], staleTime: Infinity, diff --git a/web/src/lib/api.ts b/web/src/lib/api.ts index 7e126111f..f643ca413 100644 --- a/web/src/lib/api.ts +++ b/web/src/lib/api.ts @@ -855,6 +855,18 @@ export async function fetchDiff(args: { ); } +/** Diagnostic unified diff of combined VM stdout vs another run. */ +export async function fetchConsoleDiff( + runId: number, + against?: number, +): Promise<{ content: string; against_run_id: number }> { + const qs = new URLSearchParams({ format: "unified" }); + if (against != null) qs.set("against", String(against)); + return apiGet<{ content: string; against_run_id: number }>( + `/runs/${runId}/console-diff?${qs}`, + ); +} + /** * One side of a failing comparison as text. * diff --git a/web/src/pages/RunDetail.tsx b/web/src/pages/RunDetail.tsx index cef52881f..5981f6f3a 100644 --- a/web/src/pages/RunDetail.tsx +++ b/web/src/pages/RunDetail.tsx @@ -502,7 +502,15 @@ function ArtifactsSection({ Run files
{runFiles.map((a) => ( - + setDiff({ kind: "console", runId }) + : undefined + } + /> ))}
@@ -644,7 +652,10 @@ function LogDownload({ runId, filename }: Readonly<{ runId: number; filename: st ); } -function RunFileCard({ a }: Readonly<{ a: RunArtifact }>) { +function RunFileCard({ + a, + onConsoleDiff, +}: Readonly<{ a: RunArtifact; onConsoleDiff?: () => void }>) { const Icon = RUN_FILE_ICON[a.type] ?? FileText; const viaLogEndpoint = a.type === "build_log" && a.storage_status === "ok" && !httpsUrl(a.download_url); @@ -664,11 +675,21 @@ function RunFileCard({ a }: Readonly<{ a: RunArtifact }>) { {(a.size_bytes / 1024).toFixed(0)} KB )} - {viaLogEndpoint ? ( - - ) : ( - - )} +
+ {onConsoleDiff && ( + + )} + {viaLogEndpoint ? ( + + ) : ( + + )} +
); }