diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index dcc5cbeb..dcc9ba70 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -21,6 +21,11 @@ jobs: api/package-lock.json worker/package-lock.json + - uses: subosito/flutter-action@v2 + with: + channel: stable + cache: true + - run: ./scripts/lint.sh build: @@ -62,6 +67,11 @@ jobs: api/package-lock.json worker/package-lock.json + - uses: subosito/flutter-action@v2 + with: + channel: stable + cache: true + - run: ./scripts/test.sh release-scripts: diff --git a/.jules/forge.md b/.jules/forge.md index e5788bbd..4dc154ae 100644 --- a/.jules/forge.md +++ b/.jules/forge.md @@ -6,3 +6,4 @@ - **[2026-06-15]**: Removed obsolete Python/`uv` tooling from `AGENTS.md` as the project is now fully Node.js. Identified a gap where `worker` build checks were missing from `.github/workflows/ci.yml`. Added a `worker` job to `ci.yml` to run `npm ci` and `npm run build`, ensuring type safety and parity with `AGENTS.md` intent. - **[2026-04-08]**: Updated `google-github-actions/auth` from v2 to v3 in `provision-worker.yml` to prevent deprecation issues. Extracted inline YAML scripts for Doppler secrets, Forge version resolution, worker notifications, and worker host config into reusable `scripts/` to enforce execution parity and Kickstart principles. Refined `AGENTS.md` to expose CI parity scripts (`./scripts/test.sh`, etc.) alongside targeted commands. - **[2026-04-11]**: Updated `google-github-actions/auth` from v2 to v3 in `rollout-safety-net.yml` to prevent deprecation issues. Extracted inline YAML scripts for rollout safety net polling, setting image names, checking docker tags, and extracting GCP project IDs into reusable `scripts/` to enforce execution parity and Kickstart verification principles. Refined `scripts/run-analysis.js` to mitigate command injection risks by removing `shell: true` from `child_process.spawn`. +- **[2026-07-08]**: Discovered Dart SDK tool-version conflict causing CI parity failure (Flutter stable provides 3.11.0, pubspec required ^3.11.5). Calibrated `worker_flutter/pubspec.yaml` constraint to `>=3.11.0 <4.0.0` and integrated `subosito/flutter-action@v2` into `ci.yml` `lint` and `test` jobs to match local scripts. Updated `AGENTS.md` and verification scripts to include `flutter analyze` and `flutter test`. diff --git a/AGENTS.md b/AGENTS.md index fa0850de..b983ced0 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -7,11 +7,13 @@ ## Tooling - **Node.js**: 20+ (managed by `npm`) +- **Flutter**: Stable channel (managed by `flutter`) - **Docker**: Required for simulation engine -- **Package Manager**: `npm` (Node) +- **Package Manager**: `npm` (Node), `pub` (Flutter) ## Setup - **Install Node Dependencies**: `npm run install:all` +- **Install Flutter Dependencies**: `cd worker_flutter && flutter pub get` - **Start Dev Server**: `npm run dev` ## Testing & Verification @@ -25,6 +27,8 @@ - **API Unit Test**: `cd api && npm run test:unit` - **API Ingestion Test**: `cd api && npm run test:ingestion` - **Worker Unit Test**: `cd worker && npm run test:unit` +- **Worker Flutter Lint**: `cd worker_flutter && flutter analyze --no-fatal-infos` +- **Worker Flutter Test**: `cd worker_flutter && flutter test` - **Worker Build**: `cd worker && npm run build` - **Worker Install**: `cd worker && npm install` - **Worker Dev**: `cd worker && npm run dev` @@ -38,3 +42,4 @@ - `frontend/`: React app - `api/`: Next.js API, ingestion, and analysis - `worker/`: Node.js + Docker simulation runner +- `worker_flutter/`: Cross-platform desktop worker for Magic Bracket Simulator diff --git a/scripts/lint.sh b/scripts/lint.sh index 528e316c..af4c5369 100755 --- a/scripts/lint.sh +++ b/scripts/lint.sh @@ -15,3 +15,7 @@ cd frontend && npm ci && npm run lint && cd .. # API echo "=== API lint ===" cd api && npm ci && npm run lint && cd .. + +# Worker Flutter +echo "=== Worker Flutter lint ===" +cd worker_flutter && flutter pub get && flutter analyze --no-fatal-infos && cd .. diff --git a/scripts/test.sh b/scripts/test.sh index 31b58634..41399ecc 100755 --- a/scripts/test.sh +++ b/scripts/test.sh @@ -22,3 +22,7 @@ npm run test:ingestion && cd .. # Worker tests echo "=== Worker unit tests ===" cd worker && npm ci && npm run test:unit && cd .. + +# Worker Flutter tests +echo "=== Worker Flutter tests ===" +cd worker_flutter && flutter pub get && flutter test && cd .. diff --git a/worker_flutter/lib/api_client.dart b/worker_flutter/lib/api_client.dart index ca6e2364..4f9f9fb4 100644 --- a/worker_flutter/lib/api_client.dart +++ b/worker_flutter/lib/api_client.dart @@ -3,7 +3,6 @@ import 'dart:io'; import 'package:firebase_app_check/firebase_app_check.dart'; import 'package:firebase_auth/firebase_auth.dart'; -import 'package:flutter/foundation.dart'; import 'package:http/http.dart' as http; import 'package:worker_flutter/main.dart'; diff --git a/worker_flutter/lib/installer/install_progress_app.dart b/worker_flutter/lib/installer/install_progress_app.dart index e4503cc1..133d6fe1 100644 --- a/worker_flutter/lib/installer/install_progress_app.dart +++ b/worker_flutter/lib/installer/install_progress_app.dart @@ -69,7 +69,7 @@ class _InstallScreenState extends State<_InstallScreen> { e, st, category: TelemetryCategory.installer, - extra: {'stage': _last.stage, 'message': _last.message}, + contexts: {'stage': _last.stage, 'message': _last.message}, ); } } diff --git a/worker_flutter/lib/main.dart b/worker_flutter/lib/main.dart index 08d7cc87..5f1b4162 100644 --- a/worker_flutter/lib/main.dart +++ b/worker_flutter/lib/main.dart @@ -104,7 +104,7 @@ Future _appMain() async { e, st, category: TelemetryCategory.firebaseInit, - extra: {'projectId': fbOpts.projectId}, + contexts: {'projectId': fbOpts.projectId}, ); } } @@ -404,7 +404,7 @@ Future _activateAppCheck() async { e, st, category: TelemetryCategory.boot, - extra: {'phase': 'appcheck_activate'}, + contexts: {'phase': 'appcheck_activate'}, ); } } diff --git a/worker_flutter/lib/offline/db/app_db.dart b/worker_flutter/lib/offline/db/app_db.dart index aa736d60..284d50f5 100644 --- a/worker_flutter/lib/offline/db/app_db.dart +++ b/worker_flutter/lib/offline/db/app_db.dart @@ -79,7 +79,7 @@ class AppDb extends _$AppDb { /// test suite with `NativeDatabase.memory()` for hermetic, fast /// integration tests that exercise the same SQL the app does at /// runtime — but without touching disk. - AppDb.forTesting(QueryExecutor e) : super(e); + AppDb.forTesting(super.e); @override int get schemaVersion => 2; diff --git a/worker_flutter/lib/offline/deck_source.dart b/worker_flutter/lib/offline/deck_source.dart index 6f221bac..4059e38b 100644 --- a/worker_flutter/lib/offline/deck_source.dart +++ b/worker_flutter/lib/offline/deck_source.dart @@ -6,7 +6,7 @@ import 'package:path/path.dart' as p; /// One bundled-precon deck. The `.dck` content lives either in the /// Flutter asset bundle (preferred — ships inside the app, available /// instantly on first launch) or, for users with a separately- -/// installed Forge tree, in `/res/Decks/Commander/*.dck`. +/// installed Forge tree, in `<forgePath>/res/Decks/Commander/*.dck`. class PreconDeck { PreconDeck({ required this.displayName, @@ -38,7 +38,7 @@ class PreconDeck { } /// Loads precons from the Flutter asset bundle PLUS any extra .dck -/// files the user may have dropped into `/res/Decks/ +/// files the user may have dropped into `<forgePath>/res/Decks/ /// Commander/`. The bundled set is the floor — offline mode works /// from a fresh install without a Forge download. Future> loadBundledPrecons(String forgePath) async { @@ -104,7 +104,7 @@ Future> loadBundledPrecons(String forgePath) async { return out; } -/// Write a bundled precon to `destDir/` and return the full +/// Write a bundled precon to `destDir/<filename>` and return the full /// path Forge will read. No-op when the deck is already a real file. Future materializePrecon(PreconDeck deck, String destDir) async { if (!deck.isBundled) return deck.path; diff --git a/worker_flutter/lib/sims/deck_picker_section.dart b/worker_flutter/lib/sims/deck_picker_section.dart index 04246fd0..a9ae4a87 100644 --- a/worker_flutter/lib/sims/deck_picker_section.dart +++ b/worker_flutter/lib/sims/deck_picker_section.dart @@ -40,11 +40,13 @@ class DeckPickerSection extends StatefulWidget { } class _DeckPickerSectionState extends State { + // ignore: deprecated_member_use late final ExpansionTileController _ctrl; @override void initState() { super.initState(); + // ignore: deprecated_member_use _ctrl = ExpansionTileController(); } diff --git a/worker_flutter/lib/telemetry.dart b/worker_flutter/lib/telemetry.dart index 146c2c48..5795917d 100644 --- a/worker_flutter/lib/telemetry.dart +++ b/worker_flutter/lib/telemetry.dart @@ -62,7 +62,7 @@ class Telemetry { StackTrace? stack, { required TelemetryCategory category, Map? tags, - Map? extra, + Map? contexts, }) async { await Sentry.captureException( error, @@ -72,10 +72,8 @@ class Telemetry { if (tags != null) { tags.forEach(scope.setTag); } - if (extra != null) { - // ignore: deprecated_member_use — `setExtra` is the documented - // 8.x API. Sentry's 9.x release moves to structured contexts. - extra.forEach(scope.setExtra); + if (contexts != null) { + contexts.forEach((key, value) => scope.setContexts(key, value)); } }, ); @@ -111,18 +109,24 @@ SentryEvent? scrubPii(SentryEvent event, {Hint? hint}) { ? event : event.copyWith(user: SentryUser(id: '[redacted]')); - // ignore: deprecated_member_use — see comment in Telemetry.captureError. - final extra = cleaned.extra; - if (extra != null) { - for (final key in extra.keys.toList()) { - if (_piiKeys.contains(key)) { - extra[key] = '[redacted]'; - continue; - } - final value = extra[key]; - if (value is String && _emailRegex.hasMatch(value)) { - extra[key] = value.replaceAll(_emailRegex, '[redacted-email]'); + final contexts = cleaned.contexts; + for (final key in contexts.keys.toList()) { + if (_piiKeys.contains(key)) { + contexts[key] = '[redacted]'; + continue; + } + final value = contexts[key]; + if (value is Map) { + for (final nestedKey in value.keys.toList()) { + final nestedValue = value[nestedKey]; + if (nestedValue is String && _emailRegex.hasMatch(nestedValue)) { + value[nestedKey] = nestedValue.replaceAll(_emailRegex, '[redacted-email]'); + } else if (_piiKeys.contains(nestedKey)) { + value[nestedKey] = '[redacted]'; + } } + } else if (value is String && _emailRegex.hasMatch(value)) { + contexts[key] = value.replaceAll(_emailRegex, '[redacted-email]'); } } diff --git a/worker_flutter/lib/worker/worker_engine.dart b/worker_flutter/lib/worker/worker_engine.dart index f3530055..e9e50985 100644 --- a/worker_flutter/lib/worker/worker_engine.dart +++ b/worker_flutter/lib/worker/worker_engine.dart @@ -130,7 +130,7 @@ class WorkerEngine { e, st, category: TelemetryCategory.engineRuntime, - extra: {'phase': 'lease_writer_start'}, + contexts: {'phase': 'lease_writer_start'}, ); } @@ -167,7 +167,7 @@ class WorkerEngine { e, st, category: TelemetryCategory.engineRuntime, - extra: {'phase': 'pending_listener_setup'}, + contexts: {'phase': 'pending_listener_setup'}, ); } } @@ -305,7 +305,7 @@ class WorkerEngine { e, st, category: TelemetryCategory.engineRuntime, - extra: {'phase': 'sim_run', 'jobId': sim.jobId, 'simId': sim.simId}, + contexts: {'phase': 'sim_run', 'jobId': sim.jobId, 'simId': sim.simId}, ); await _claimer.reportTerminal( sim: sim, diff --git a/worker_flutter/pubspec.lock b/worker_flutter/pubspec.lock index b7c9feee..d21e63a0 100644 --- a/worker_flutter/pubspec.lock +++ b/worker_flutter/pubspec.lock @@ -692,10 +692,10 @@ packages: dependency: transitive description: name: matcher - sha256: dc0b7dc7651697ea4ff3e69ef44b0407ea32c487a39fff6a4004fa585e901861 + sha256: "12956d0ad8390bbcc63ca2e1469c0619946ccb52809807067a7020d57e647aa6" url: "https://pub.dev" source: hosted - version: "0.12.19" + version: "0.12.18" material_color_utilities: dependency: transitive description: @@ -1193,10 +1193,10 @@ packages: dependency: transitive description: name: test_api - sha256: "8161c84903fd860b26bfdefb7963b3f0b68fee7adea0f59ef805ecca346f0c7a" + sha256: "93167629bfc610f71560ab9312acdda4959de4df6fac7492c89ff0d3886f6636" url: "https://pub.dev" source: hosted - version: "0.7.10" + version: "0.7.9" tray_manager: dependency: "direct main" description: @@ -1374,5 +1374,5 @@ packages: source: hosted version: "3.1.3" sdks: - dart: ">=3.11.5 <4.0.0" + dart: ">=3.11.0 <4.0.0" flutter: ">=3.38.4" diff --git a/worker_flutter/pubspec.yaml b/worker_flutter/pubspec.yaml index 5fb626dc..08b2bf83 100644 --- a/worker_flutter/pubspec.yaml +++ b/worker_flutter/pubspec.yaml @@ -10,7 +10,7 @@ publish_to: 'none' version: 0.2.0+1 environment: - sdk: ^3.11.5 + sdk: '>=3.11.0 <4.0.0' dependencies: flutter: diff --git a/worker_flutter/test/offline/resume_test.dart b/worker_flutter/test/offline/resume_test.dart index 7a57724e..ab976908 100644 --- a/worker_flutter/test/offline/resume_test.dart +++ b/worker_flutter/test/offline/resume_test.dart @@ -1,4 +1,3 @@ -import 'dart:async'; import 'dart:io'; import 'package:drift/drift.dart' show Value; diff --git a/worker_flutter/test/telemetry_test.dart b/worker_flutter/test/telemetry_test.dart index 8157e195..a65e02b5 100644 --- a/worker_flutter/test/telemetry_test.dart +++ b/worker_flutter/test/telemetry_test.dart @@ -18,39 +18,42 @@ void main() { group('scrubPii', () { test('redacts email-shaped values in event extras', () { + final contexts = Contexts(); + contexts['app_data'] = { + 'note': 'user contacted me at someone@example.com about it', + 'okField': 'no pii here', + }; final event = SentryEvent(message: const SentryMessage('hi')).copyWith( - extra: { - 'note': 'user contacted me at someone@example.com about it', - 'okField': 'no pii here', - }, + contexts: contexts, ); final scrubbed = scrubPii(event); expect(scrubbed, isNotNull); - expect(scrubbed!.extra!['note'], contains('[redacted-email]')); - expect(scrubbed.extra!['note'], isNot(contains('someone@example.com'))); - expect(scrubbed.extra!['okField'], 'no pii here'); + final appData = scrubbed!.contexts['app_data'] as Map; + expect(appData['note'], contains('[redacted-email]')); + expect(appData['note'], isNot(contains('someone@example.com'))); + expect(appData['okField'], 'no pii here'); }); test('redacts known PII keys', () { + final contexts = Contexts(); + contexts['email'] = 'a@b.com'; + contexts['uid'] = 'xyz'; + contexts['displayName'] = 'Tyler'; + contexts['user_id'] = '42'; + contexts['safe'] = 'keep me'; final event = SentryEvent(message: const SentryMessage('hi')).copyWith( - extra: { - 'email': 'a@b.com', - 'uid': 'xyz', - 'displayName': 'Tyler', - 'user_id': '42', - 'safe': 'keep me', - }, + contexts: contexts, ); final scrubbed = scrubPii(event); - expect(scrubbed!.extra!['email'], '[redacted]'); - expect(scrubbed.extra!['uid'], '[redacted]'); - expect(scrubbed.extra!['displayName'], '[redacted]'); - expect(scrubbed.extra!['user_id'], '[redacted]'); - expect(scrubbed.extra!['safe'], 'keep me'); + expect(scrubbed!.contexts['email'], '[redacted]'); + expect(scrubbed.contexts['uid'], '[redacted]'); + expect(scrubbed.contexts['displayName'], '[redacted]'); + expect(scrubbed.contexts['user_id'], '[redacted]'); + expect(scrubbed.contexts['safe'], 'keep me'); }); test('replaces user with redacted placeholder', () { diff --git a/worker_flutter/test/worker/log_uploader_test.dart b/worker_flutter/test/worker/log_uploader_test.dart index 1f584393..ad1208d9 100644 --- a/worker_flutter/test/worker/log_uploader_test.dart +++ b/worker_flutter/test/worker/log_uploader_test.dart @@ -1,4 +1,3 @@ -import 'dart:async'; import 'dart:convert'; import 'package:flutter_test/flutter_test.dart';