[Gemini Log Analyzer] Part 1: Backend Foundation (Models, Config, & Setup) (#5042)

This is Part 1 of a stacked PR series implementing the Gemini Log Analyzer feature: https://github.com/flutter/flutter/issues/185700

This PR establishes the required foundation:
- Adds `APP_DART_GEMINI_LOG_ANALYZER_KEY` secret config property to `Config`.
- Adds `log_analysis` and `build_id` fields to Firestore `PresubmitJob` model.
- Adds `build_id` to `PresubmitJobResponse` RPC model in `cocoon_common`.

### Stacked PR Series:
- **Part 1: Backend Foundation** (This PR)
- Part 2: Backend Core API
- Part 3: Frontend Dashboard UI
diff --git a/app_dart/lib/src/model/common/presubmit_completed_check.dart b/app_dart/lib/src/model/common/presubmit_completed_check.dart
index b510273..3251307 100644
--- a/app_dart/lib/src/model/common/presubmit_completed_check.dart
+++ b/app_dart/lib/src/model/common/presubmit_completed_check.dart
@@ -41,6 +41,7 @@
   final int? endTime;
   final String? summary;
   final int? buildNumber;
+  final int? buildId;
 
   const PresubmitCompletedJob({
     required this.name,
@@ -59,6 +60,7 @@
     this.endTime,
     this.summary,
     this.buildNumber,
+    this.buildId,
   });
 
   /// Creates a [PresubmitCompletedJob] from a GitHub [CheckRun].
@@ -81,6 +83,7 @@
       endTime: null,
       summary: null,
       buildNumber: null,
+      buildId: null,
     );
   }
 
@@ -107,6 +110,7 @@
       endTime: build.endTime.toDateTime().millisecondsSinceEpoch,
       summary: build.summaryMarkdown,
       buildNumber: build.number,
+      buildId: build.id.toInt(),
     );
   }
 
@@ -149,6 +153,7 @@
       endTime: endTime,
       summary: summary,
       buildNumber: buildNumber,
+      buildId: buildId,
     );
   }
 
diff --git a/app_dart/lib/src/model/common/presubmit_job_state.dart b/app_dart/lib/src/model/common/presubmit_job_state.dart
index 84582c7..b9756b1 100644
--- a/app_dart/lib/src/model/common/presubmit_job_state.dart
+++ b/app_dart/lib/src/model/common/presubmit_job_state.dart
@@ -20,6 +20,7 @@
   final int? endTime;
   final String? summary;
   final int? buildNumber;
+  final int? buildId;
 
   const PresubmitJobState({
     required this.jobName,
@@ -29,6 +30,7 @@
     this.endTime,
     this.summary,
     this.buildNumber,
+    this.buildId,
   });
 }
 
@@ -41,5 +43,6 @@
     endTime: endTime.toDateTime().millisecondsSinceEpoch,
     summary: summaryMarkdown,
     buildNumber: number,
+    buildId: id.toInt(),
   );
 }
diff --git a/app_dart/lib/src/model/firestore/presubmit_job.dart b/app_dart/lib/src/model/firestore/presubmit_job.dart
index 5c285fa..80fc5ea 100644
--- a/app_dart/lib/src/model/firestore/presubmit_job.dart
+++ b/app_dart/lib/src/model/firestore/presubmit_job.dart
@@ -104,12 +104,14 @@
   static const fieldSlug = 'slug';
   static const fieldJobName = 'job_name';
   static const fieldBuildNumber = 'build_number';
+  static const fieldBuildId = 'build_id';
   static const fieldStatus = 'status';
   static const fieldAttemptNumber = 'attempt_number';
   static const fieldCreationTime = 'creation_time';
   static const fieldStartTime = 'start_time';
   static const fieldEndTime = 'end_time';
   static const fieldSummary = 'summary';
+  static const fieldLogAnalysis = 'log_analysis';
 
   static AppDocumentId<PresubmitJob> documentIdFor({
     required RepositorySlug slug,
@@ -168,9 +170,11 @@
     required int attemptNumber,
     required int creationTime,
     int? buildNumber,
+    int? buildId,
     int? startTime,
     int? endTime,
     String? summary,
+    String? logAnalysis,
   }) {
     return PresubmitJob._(
       {
@@ -178,12 +182,14 @@
         fieldCheckRunId: checkRunId.toValue(),
         fieldJobName: jobName.toValue(),
         fieldBuildNumber: ?buildNumber?.toValue(),
+        fieldBuildId: ?buildId?.toValue(),
         fieldStatus: status.value.toValue(),
         fieldAttemptNumber: attemptNumber.toValue(),
         fieldCreationTime: creationTime.toValue(),
         fieldStartTime: ?startTime?.toValue(),
         fieldEndTime: ?endTime?.toValue(),
         fieldSummary: ?summary?.toValue(),
+        fieldLogAnalysis: ?logAnalysis?.toValue(),
       },
       name: documentNameFor(
         slug: slug,
@@ -213,9 +219,11 @@
       creationTime: creationTime,
       status: TaskStatus.waitingForBackfill,
       buildNumber: null,
+      buildId: null,
       startTime: null,
       endTime: null,
       summary: null,
+      logAnalysis: null,
     );
   }
 
@@ -240,6 +248,9 @@
   int? get buildNumber => fields[fieldBuildNumber] != null
       ? int.parse(fields[fieldBuildNumber]!.integerValue!)
       : null;
+  int? get buildId => fields[fieldBuildId] != null
+      ? int.parse(fields[fieldBuildId]!.integerValue!)
+      : null;
   int? get startTime => fields[fieldStartTime] != null
       ? int.parse(fields[fieldStartTime]!.integerValue!)
       : null;
@@ -247,6 +258,7 @@
       ? int.parse(fields[fieldEndTime]!.integerValue!)
       : null;
   String? get summary => fields[fieldSummary]?.stringValue;
+  String? get logAnalysis => fields[fieldLogAnalysis]?.stringValue;
 
   TaskStatus get status {
     final rawValue = fields[fieldStatus]!.stringValue!;
@@ -273,6 +285,14 @@
     }
   }
 
+  set buildId(int? buildId) {
+    if (buildId == null) {
+      fields.remove(fieldBuildId);
+    } else {
+      fields[fieldBuildId] = buildId.toValue();
+    }
+  }
+
   set summary(String? summary) {
     if (summary == null) {
       fields.remove(fieldSummary);
@@ -281,8 +301,17 @@
     }
   }
 
+  set logAnalysis(String? logAnalysis) {
+    if (logAnalysis == null) {
+      fields.remove(fieldLogAnalysis);
+    } else {
+      fields[fieldLogAnalysis] = logAnalysis.toValue();
+    }
+  }
+
   void updateFromBuild(bbv2.Build build) {
     fields[fieldBuildNumber] = build.number.toValue();
+    fields[fieldBuildId] = Value(integerValue: build.id.toString());
     fields[fieldCreationTime] = build.createTime
         .toDateTime()
         .millisecondsSinceEpoch
diff --git a/app_dart/lib/src/request_handlers/get_presubmit_jobs.dart b/app_dart/lib/src/request_handlers/get_presubmit_jobs.dart
index b770dc8..bb16a8e 100644
--- a/app_dart/lib/src/request_handlers/get_presubmit_jobs.dart
+++ b/app_dart/lib/src/request_handlers/get_presubmit_jobs.dart
@@ -100,6 +100,7 @@
           status: job.status,
           summary: job.summary,
           buildNumber: job.buildNumber,
+          buildId: job.buildId,
         ),
     ];
 
diff --git a/app_dart/lib/src/service/config.dart b/app_dart/lib/src/service/config.dart
index bf76c4d..24e8396 100644
--- a/app_dart/lib/src/service/config.dart
+++ b/app_dart/lib/src/service/config.dart
@@ -259,6 +259,9 @@
   Future<String> get discordTreeStatusWebhookUrl =>
       _getSingleValue('TREE_STATUS_DISCORD_WEBHOOK_URL');
 
+  Future<String> get geminiLogAnalyzerKey =>
+      _getSingleValue('APP_DART_GEMINI_LOG_ANALYZER_KEY');
+
   String get wrongBaseBranchPullRequestMessage =>
       'This pull request was opened against a branch other than '
       '_{{default_branch}}_. Since Flutter pull requests should not '
diff --git a/app_dart/lib/src/service/firestore/unified_check_run.dart b/app_dart/lib/src/service/firestore/unified_check_run.dart
index 05454aa..fea62cc 100644
--- a/app_dart/lib/src/service/firestore/unified_check_run.dart
+++ b/app_dart/lib/src/service/firestore/unified_check_run.dart
@@ -584,6 +584,7 @@
       } else if (state.status == TaskStatus.inProgress) {
         presubmitJob.startTime = state.startTime!;
         presubmitJob.buildNumber = state.buildNumber;
+        presubmitJob.buildId = state.buildId;
         // If the job is not completed, update the status.
         if (!status.isComplete) {
           status = state.status;
@@ -622,6 +623,7 @@
           presubmitJob.endTime = state.endTime!;
           presubmitJob.summary = state.summary;
           presubmitJob.buildNumber = state.buildNumber;
+          presubmitJob.buildId = state.buildId;
         } else {
           status = state.status;
           valid = true;
diff --git a/app_dart/test/model/common/presubmit_check_state_test.dart b/app_dart/test/model/common/presubmit_check_state_test.dart
index 9285c07..ea337af 100644
--- a/app_dart/test/model/common/presubmit_check_state_test.dart
+++ b/app_dart/test/model/common/presubmit_check_state_test.dart
@@ -14,6 +14,7 @@
   group('PresubmitJobState', () {
     test('BuildToPresubmitJobState extension maps build number', () {
       final build = bbv2.Build(
+        id: Int64(67890),
         builder: bbv2.BuilderID(builder: 'linux_test'),
         status: bbv2.Status.SUCCESS,
         number: 12345,
@@ -27,6 +28,7 @@
       expect(state.jobName, 'linux_test');
       expect(state.status, TaskStatus.succeeded);
       expect(state.buildNumber, 12345);
+      expect(state.buildId, 67890);
     });
   });
 }
diff --git a/app_dart/test/model/common/presubmit_completed_check_test.dart b/app_dart/test/model/common/presubmit_completed_check_test.dart
index ad9a33a..f5d53a2 100644
--- a/app_dart/test/model/common/presubmit_completed_check_test.dart
+++ b/app_dart/test/model/common/presubmit_completed_check_test.dart
@@ -11,6 +11,7 @@
 import 'package:cocoon_service/src/model/github/checks.dart' as cocoon_checks;
 import 'package:cocoon_service/src/service/config.dart';
 import 'package:cocoon_service/src/service/luci_build_service/user_data.dart';
+import 'package:fixnum/fixnum.dart';
 import 'package:github/github.dart';
 import 'package:test/test.dart';
 
@@ -45,6 +46,7 @@
 
     test('fromBuild creates correct unified check', () {
       final build = Build(
+        id: Int64(98765),
         builder: BuilderID(builder: 'test_builder'),
         status: Status.SUCCESS,
       );
@@ -74,10 +76,12 @@
       expect(check.isUnifiedCheckRun, true);
       expect(check.checkRun.name, Config.kFlutterPresubmitsName);
       expect(check.buildNumber, 0);
+      expect(check.buildId, 98765);
     });
 
     test('fromBuild creates correct legacy check', () {
       final build = Build(
+        id: Int64(98765),
         builder: BuilderID(builder: 'test_builder'),
         status: Status.SUCCESS,
         number: 1234,
@@ -108,6 +112,7 @@
       expect(check.isUnifiedCheckRun, false);
       expect(check.checkRun.name, 'test_builder');
       expect(check.buildNumber, 1234);
+      expect(check.buildId, 98765);
     });
   });
 }
diff --git a/app_dart/test/model/firestore/presubmit_check_test.dart b/app_dart/test/model/firestore/presubmit_check_test.dart
index 9431764..0515161 100644
--- a/app_dart/test/model/firestore/presubmit_check_test.dart
+++ b/app_dart/test/model/firestore/presubmit_check_test.dart
@@ -100,6 +100,7 @@
       expect(check.attemptNumber, 1);
       expect(check.status, TaskStatus.waitingForBackfill);
       expect(check.buildNumber, isNull);
+      expect(check.buildId, isNull);
       expect(check.startTime, isNull);
       expect(check.endTime, isNull);
       expect(check.summary, isNull);
@@ -131,6 +132,7 @@
         attemptNumber: 1,
         creationTime: 1000,
         buildNumber: 456,
+        buildId: 789,
         startTime: 2000,
         endTime: 3000,
         summary: 'Success',
@@ -168,6 +170,7 @@
       expect(loadedCheck.attemptNumber, 1);
       expect(loadedCheck.creationTime, 1000);
       expect(loadedCheck.buildNumber, 456);
+      expect(loadedCheck.buildId, 789);
       expect(loadedCheck.startTime, 2000);
       expect(loadedCheck.endTime, 3000);
       expect(loadedCheck.summary, 'Success');
@@ -182,6 +185,7 @@
       );
 
       final build = bbv2.Build(
+        id: Int64(789),
         number: 456,
         createTime: bbv2.Timestamp(seconds: Int64(2000)),
         startTime: bbv2.Timestamp(seconds: Int64(2100)),
@@ -192,6 +196,7 @@
       check.updateFromBuild(build);
 
       expect(check.buildNumber, 456);
+      expect(check.buildId, 789);
       expect(check.creationTime, 2000000); // seconds to millis
       expect(check.startTime, 2100000);
       expect(check.endTime, 2200000);
@@ -232,5 +237,20 @@
       check.buildNumber = null;
       expect(check.buildNumber, isNull);
     });
+
+    test('buildId setter updates fields', () {
+      final check = PresubmitJob.init(
+        slug: slug,
+        jobName: 'linux',
+        checkRunId: 123,
+        creationTime: 1000,
+      );
+
+      check.buildId = 789;
+      expect(check.buildId, 789);
+
+      check.buildId = null;
+      expect(check.buildId, isNull);
+    });
   });
 }
diff --git a/app_dart/test/request_handlers/get_presubmit_checks_test.dart b/app_dart/test/request_handlers/get_presubmit_checks_test.dart
index 4206090..b9712c0 100644
--- a/app_dart/test/request_handlers/get_presubmit_checks_test.dart
+++ b/app_dart/test/request_handlers/get_presubmit_checks_test.dart
@@ -83,6 +83,7 @@
         endTime: 120,
         summary: 'all good',
         buildNumber: 456,
+        buildId: 98765,
       );
       await firestoreService.writeViaTransaction(
         documentsToWrites([job], exists: false),
@@ -100,6 +101,7 @@
       expect(jobs[0].jobName, 'linux');
       expect(jobs[0].status, TaskStatus.succeeded);
       expect(jobs[0].buildNumber, 456);
+      expect(jobs[0].buildId, 98765);
     });
 
     test('returns checks when found with owner and repo', () async {
diff --git a/app_dart/test/service/config_test.dart b/app_dart/test/service/config_test.dart
index 8fe7f3f..6763c83 100644
--- a/app_dart/test/service/config_test.dart
+++ b/app_dart/test/service/config_test.dart
@@ -55,6 +55,19 @@
     expect(githubToken, 'githubToken');
   });
 
+  test('geminiLogAnalyzerKey pulls from cache', () async {
+    const secretValue = 'my-gemini-key';
+    final cachedValue = Uint8List.fromList(secretValue.codeUnits);
+    await cacheService.set(
+      Config.configCacheName,
+      'APP_DART_GEMINI_LOG_ANALYZER_KEY',
+      cachedValue,
+    );
+
+    final key = await config.geminiLogAnalyzerKey;
+    expect(key, equals('my-gemini-key'));
+  });
+
   test('Returns the right flutter gold alert', () {
     expect(
       config.flutterGoldAlertConstant(RepositorySlug.full('flutter/flutter')),
diff --git a/app_dart/test/service/firestore/unified_check_run_test.dart b/app_dart/test/service/firestore/unified_check_run_test.dart
index d47b484..38bc618 100644
--- a/app_dart/test/service/firestore/unified_check_run_test.dart
+++ b/app_dart/test/service/firestore/unified_check_run_test.dart
@@ -176,6 +176,7 @@
           startTime: 2000,
           endTime: 3000,
           buildNumber: 456,
+          buildId: 98765,
         );
 
         final result = await UnifiedCheckRun.markConclusion(
@@ -200,6 +201,7 @@
         expect(checkDoc.status, TaskStatus.succeeded);
         expect(checkDoc.endTime, 3000);
         expect(checkDoc.buildNumber, 456);
+        expect(checkDoc.buildId, 98765);
       });
 
       test(
@@ -297,6 +299,7 @@
           attemptNumber: 1,
           startTime: 2000,
           buildNumber: 456,
+          buildId: 98765,
         );
 
         final result = await UnifiedCheckRun.markConclusion(
@@ -319,6 +322,7 @@
         expect(checkDoc.status, TaskStatus.inProgress);
         expect(checkDoc.startTime, 2000);
         expect(checkDoc.buildNumber, 456);
+        expect(checkDoc.buildId, 98765);
       });
     });
     group('reInitializeFailedChecks', () {
diff --git a/packages/cocoon_common/lib/src/rpc_model/presubmit_job_response.dart b/packages/cocoon_common/lib/src/rpc_model/presubmit_job_response.dart
index a9543c2..7f62882 100644
--- a/packages/cocoon_common/lib/src/rpc_model/presubmit_job_response.dart
+++ b/packages/cocoon_common/lib/src/rpc_model/presubmit_job_response.dart
@@ -28,6 +28,7 @@
     required this.status,
     this.summary,
     this.buildNumber,
+    this.buildId,
   });
 
   /// Creates a [PresubmitJobResponse] from [json] representation.
@@ -63,6 +64,9 @@
   /// The LUCI build number.
   final int? buildNumber;
 
+  /// The LUCI build ID.
+  final int? buildId;
+
   @override
   Map<String, Object?> toJson() => _$PresubmitJobResponseToJson(this);
 }
diff --git a/packages/cocoon_common/lib/src/rpc_model/presubmit_job_response.g.dart b/packages/cocoon_common/lib/src/rpc_model/presubmit_job_response.g.dart
index c26621f..a904a92 100644
--- a/packages/cocoon_common/lib/src/rpc_model/presubmit_job_response.g.dart
+++ b/packages/cocoon_common/lib/src/rpc_model/presubmit_job_response.g.dart
@@ -27,6 +27,7 @@
       ),
       summary: $checkedConvert('summary', (v) => v as String?),
       buildNumber: $checkedConvert('build_number', (v) => (v as num?)?.toInt()),
+      buildId: $checkedConvert('build_id', (v) => (v as num?)?.toInt()),
     );
     return val;
   },
@@ -37,6 +38,7 @@
     'startTime': 'start_time',
     'endTime': 'end_time',
     'buildNumber': 'build_number',
+    'buildId': 'build_id',
   },
 );
 
@@ -51,6 +53,7 @@
   'status': instance.status,
   'summary': ?instance.summary,
   'build_number': ?instance.buildNumber,
+  'build_id': ?instance.buildId,
 };
 
 const _$TaskStatusEnumMap = {
diff --git a/packages/cocoon_integration_test/lib/src/fakes/fake_config.dart b/packages/cocoon_integration_test/lib/src/fakes/fake_config.dart
index 8213e39..a68e531 100644
--- a/packages/cocoon_integration_test/lib/src/fakes/fake_config.dart
+++ b/packages/cocoon_integration_test/lib/src/fakes/fake_config.dart
@@ -23,6 +23,7 @@
     this.maxFilesChangedForSkippingEnginePhaseValue,
     this.oauthClientIdValue,
     this.githubOAuthTokenValue,
+    this.geminiLogAnalyzerKeyValue,
     this.mergeConflictPullRequestMessageValue =
         'default mergeConflictPullRequestMessageValue',
     this.missingTestsPullRequestMessageValue =
@@ -66,6 +67,7 @@
   int? batchSizeValue;
   String? oauthClientIdValue;
   String? githubOAuthTokenValue;
+  String? geminiLogAnalyzerKeyValue;
   String mergeConflictPullRequestMessageValue;
   String missingTestsPullRequestMessageValue;
   String? wrongBaseBranchPullRequestMessageValue;
@@ -177,6 +179,10 @@
   Future<String> get githubOAuthToken async => githubOAuthTokenValue ?? 'token';
 
   @override
+  Future<String> get geminiLogAnalyzerKey async =>
+      geminiLogAnalyzerKeyValue ?? 'fake-gemini-key';
+
+  @override
   String get mergeConflictPullRequestMessage =>
       mergeConflictPullRequestMessageValue;