diff --git a/app.js b/app.js index be2abeb..d0ecabd 100644 --- a/app.js +++ b/app.js @@ -38,6 +38,10 @@ import { partitionProvisionedCodeFiles, } from "./src/flutterFlowCodeFileProvisioning.js"; import { buildReviewPresentation } from "./src/reviewPresentation.js"; +import { + generateDeployRunId, + queryRunStatusWithinBudget, +} from "./src/runStatus.js"; import { expectedWidgetClassFromFileName, findUnbalancedBracketError, @@ -3254,6 +3258,15 @@ async function provisionMissingCodeFiles( return { remoteFiles, syncFileMap: fileMap, unverified: [], approximate: [] }; } + // The client generates the run id so that, if this response is ever lost, + // a 404 on GET /runStatus/ is proof the request never reached the + // runner (nothing written, retry safe) rather than an ambiguous "unknown". + const runId = generateDeployRunId(); + + // True once the run-status lookup has decided this attempt's real outcome, + // so the late-arriving provision stream cannot double-credit its writes. + let runStatusResolved = false; + // The runner compiles these against the project's own package versions // before it writes anything, so an API the generated code invented - a named // argument the package never declared - stops the deploy instead of landing @@ -3310,6 +3323,7 @@ async function provisionMissingCodeFiles( projectId: apiClient.projectId, baseUrl: apiClient.baseUrl, commitMessage, + runId, customClasses: missingCodeFiles, verification, stream: true, @@ -3413,6 +3427,9 @@ async function provisionMissingCodeFiles( // retires them outright. provisionWork?.then( (late) => { + // When the run-status lookup already decided this attempt's real + // outcome, the late stream must not double-credit its writes. + if (runStatusResolved) return; if (late?.finalResultReceived && late.success) { adjustProjectSourceDirty(apiClient, missingCodeFiles, { uncertain: -1, @@ -3429,6 +3446,49 @@ async function provisionMissingCodeFiles( }, () => {}, ); + // The record is the authority here: ask the runner what actually + // happened instead of re-reading the project and guessing. Only when the + // answer is definitive does it replace the estimate; a 404, a 5xx or an + // unrecognised body (an older runner) falls through to the reconcile. + let statusOutcome = { definitive: null }; + try { + statusOutcome = await queryRunStatusWithinBudget({ + provisionEndpoint: FLUTTERFLOW_CLASS_PROVISION_ENDPOINT, + runId, + apiKey: apiClient.apiKey, + }); + } catch { + // The status query must fail closed: any unexpected throw from it (a + // browser abort surfacing unusually, a half-updated bundle) falls back + // to the existing reconcile instead of fabricating a new failure. + statusOutcome = { definitive: null }; + } + if (statusOutcome.definitive === "success") { + // The runner confirms every class it was asked to write landed, so the + // provision succeeds and step 3 (the pubspec push) runs as usual — this + // is a known success, not an estimate. + runStatusResolved = true; + adjustProjectSourceDirty(apiClient, missingCodeFiles, { + uncertain: -1, + verified: 1, + }); + return { + remoteFiles, + syncFileMap: excludeProvisionedCodeFiles(fileMap, missingCodeFiles), + unverified, + approximate, + provisionSucceeded: true, + }; + } + if (statusOutcome.definitive === "failed") { + // A provable pre-write refusal: nothing could have been written, so it + // is a real failure, not an unknown outcome. + runStatusResolved = true; + throw new Error( + statusOutcome.error || "The deploy failed before writing anything.", + ); + } + const reconciled = await reconcileUnconfirmedProvision( apiClient, missingCodeFiles, diff --git a/cloud-run/ffai-runner/bin/server.dart b/cloud-run/ffai-runner/bin/server.dart index be4b67a..b774885 100644 --- a/cloud-run/ffai-runner/bin/server.dart +++ b/cloud-run/ffai-runner/bin/server.dart @@ -3,6 +3,8 @@ import 'dart:convert'; import 'dart:io'; import 'package:ccc_ffai_runner/request_validation.dart'; +import 'package:ccc_ffai_runner/run_record.dart'; +import 'package:ccc_ffai_runner/sha256.dart'; // The lowest flutterflow_cli the vendored FlutterFlow AI snapshot accepts. The // image pins FLUTTERFLOW_CLI_VERSION to this (see Dockerfile); it must be kept @@ -35,6 +37,19 @@ Future _handle(HttpRequest request) async { return; } + // GET /runStatus/ answers what happened to a deploy whose response + // was lost. The id in the path is the one the client itself generated, so + // a 404 is a proof the request never reached the runner (nothing was + // written, a retry is safe), never an ambiguous "unknown". The caller must + // present the same API key the run started with; see fetchRunStatus. + final statusPath = RegExp( + r'^/runStatus/([A-Za-z0-9_-]+)$', + ).firstMatch(request.uri.path); + if (request.method == 'GET' && statusPath != null) { + await _handleRunStatus(request, statusPath.group(1)!); + return; + } + if (request.method != 'POST' || request.uri.path != '/deployCustomClasses') { await channel.result(HttpStatus.notFound, { @@ -54,6 +69,7 @@ Future _handle(HttpRequest request) async { String projectId; String baseUrl; String commitMessage; + String runId; bool dryRun; List classes; VerificationRequest? verification; @@ -73,6 +89,7 @@ Future _handle(HttpRequest request) async { maxLength: 300, required: false, ); + runId = stringField(payload, 'runId', maxLength: 120, required: false); dryRun = payload['dryRun'] == true; classes = _normalizeClasses(payload['customClasses']); verification = normalizeVerification(payload['verification']); @@ -86,19 +103,64 @@ Future _handle(HttpRequest request) async { return; } + // The client generates the run id so a 404 on /runStatus proves the + // request never arrived. It becomes a document id in Firestore, so it is + // constrained to characters that are safe there (letters, digits, - and _). + if (runId.isNotEmpty && !RegExp(r'^[A-Za-z0-9_-]+$').hasMatch(runId)) { + await channel.result(HttpStatus.badRequest, { + 'success': false, + 'error': 'Invalid runId.', + 'preWrite': true, + }); + return; + } + + // A run record lets a client whose connection dropped or whose wait expired + // ask what actually happened instead of re-reading the project and + // guessing. Older clients send no runId; for them nothing is recorded and + // the deploy behaves exactly as before. + late RunRecorder recorder; + final recordRun = runId.isNotEmpty; + if (recordRun) { + recorder = RunRecorder( + FirestoreRunStore( + project: + Platform.environment['FIRESTORE_PROJECT'] ?? + defaultFirestoreProject, + ), + runId: runId, + projectId: projectId, + keyHash: sha256Hex(utf8.encode(apiKey)), + classNames: classes.map((entry) => entry.className).toList(), + ); + } + // Only stream for callers that asked for it, so older clients keep getting // the single JSON response they parse. if (payload['stream'] == true) { channel.beginStream(); } - channel.phase('connected', 'Connected to the FlutterFlow deploy runner.'); + _phase(channel, recordRun ? recorder : null, 'connected', + 'Connected to the FlutterFlow deploy runner.'); final workspace = Directory( Platform.environment['FFAI_WORKSPACE'] ?? '/workspace/custom_code_connect', ); - final initResult = await _ensureWorkspace(workspace, apiKey, channel); + final initResult = await _ensureWorkspace( + workspace, + apiKey, + channel, + recordRun ? recorder : null, + ); if (initResult != null) { + if (recordRun) { + await recorder.recordFailed( + initResult.timedOut + ? 'Preparing the FlutterFlow AI workspace timed out.' + : 'FlutterFlow AI workspace initialization failed.', + ); + } await channel.result(HttpStatus.badGateway, { 'success': false, 'error': @@ -118,8 +180,18 @@ Future _handle(HttpRequest request) async { // accepts a call to a named argument the package never declared - exactly // the class of error that otherwise lands in the project and breaks every // custom widget or action importing it. - final analysis = await _verifyCustomCode(workspace, verification, channel); + final analysis = await _verifyCustomCode( + workspace, + verification, + channel, + recordRun ? recorder : null, + ); if (analysis != null && analysis.hasErrors) { + if (recordRun) { + await recorder.recordFailed( + 'The generated custom code does not compile.', + ); + } await channel.result(HttpStatus.unprocessableEntity, { 'success': false, 'error': @@ -157,7 +229,9 @@ Future _handle(HttpRequest request) async { args.addAll(['--commit-message', commitMessage]); } - channel.phase( + _phase( + channel, + recordRun ? recorder : null, 'deploy_start', classes.length == 1 ? 'Deploying ${classes.single.className} to FlutterFlow...' @@ -169,10 +243,14 @@ Future _handle(HttpRequest request) async { workingDirectory: workspace.path, apiKey: apiKey, channel: channel, + recorder: recordRun ? recorder : null, timeout: _deployTimeout(classes.length), ); if (result.timedOut) { + if (recordRun) { + await recorder.recordFailed('FlutterFlow AI DSL deploy timed out.'); + } await channel.result(HttpStatus.gatewayTimeout, { 'success': false, 'error': 'FlutterFlow AI DSL deploy timed out.', @@ -184,6 +262,9 @@ Future _handle(HttpRequest request) async { } if (result.exitCode != 0) { + if (recordRun) { + await recorder.recordFailed('FlutterFlow AI DSL deploy failed.'); + } await channel.result(HttpStatus.badGateway, { 'success': false, 'error': 'FlutterFlow AI DSL deploy failed.', @@ -194,18 +275,25 @@ Future _handle(HttpRequest request) async { return; } + final deployed = classes + .map( + (entry) => { + 'artifactId': entry.artifactId, + 'className': entry.className, + }, + ) + .toList(); + + // The terminal write is awaited (bounded and error-swallowed in the + // recorder) so a client whose response was lost can learn the run finished. + if (recordRun) { + await recorder.recordDone(deployed, dryRun); + } + await channel.result(HttpStatus.ok, { 'success': true, 'message': 'Custom classes upserted through FlutterFlow AI DSL.', - 'deployed': - classes - .map( - (entry) => { - 'artifactId': entry.artifactId, - 'className': entry.className, - }, - ) - .toList(), + 'deployed': deployed, 'dryRun': dryRun, 'verified': analysis?.verifiedFiles ?? const [], 'verificationSkipped': analysis?.skippedReason, @@ -213,6 +301,9 @@ Future _handle(HttpRequest request) async { } catch (error, stackTrace) { stderr.writeln(error); stderr.writeln(stackTrace); + // Never allow a run-record write that follows the catch to take down the + // response - it is best effort, and no runId is parsed for a request that + // threw before it (request body/format errors). await channel.result(HttpStatus.internalServerError, { 'success': false, 'error': '$error', @@ -224,20 +315,35 @@ Future _handle(HttpRequest request) async { } } +/// Reports a phase to the streaming channel and, when a run is being recorded, +/// persists it to the run record so a disconnected client can later ask what +/// happened. +void _phase( + _ResponseChannel channel, + RunRecorder? recorder, + String id, + String message, +) { + channel.phase(id, message); + recorder?.recordPhase(id, message); +} + Future<_RunOutcome?> _ensureWorkspace( Directory workspace, String apiKey, _ResponseChannel channel, + RunRecorder? recorder, ) async { final packageConfig = File( '${workspace.path}/.dart_tool/package_config.json', ); if (packageConfig.existsSync()) { - channel.phase('workspace_ready', 'Build environment is ready.'); + _phase(channel, recorder, 'workspace_ready', 'Build environment is ready.'); return null; } - channel.phase('workspace_init', 'Preparing the FlutterFlow AI workspace...'); + _phase(channel, recorder, 'workspace_init', + 'Preparing the FlutterFlow AI workspace...'); final parent = workspace.parent; await parent.create(recursive: true); final workspaceName = workspace.path.split(Platform.pathSeparator).last; @@ -252,13 +358,14 @@ Future<_RunOutcome?> _ensureWorkspace( workingDirectory: parent.path, apiKey: apiKey, channel: channel, + recorder: recorder, ); if (result.timedOut || result.exitCode != 0) { return result; } - channel.phase('workspace_ready', 'Build environment is ready.'); + _phase(channel, recorder, 'workspace_ready', 'Build environment is ready.'); return null; } @@ -283,6 +390,7 @@ Future<_RunOutcome> _runFlutterFlow( required String workingDirectory, required String apiKey, required _ResponseChannel channel, + RunRecorder? recorder, Duration timeout = const Duration(minutes: 5), }) async { final process = await Process.start( @@ -315,7 +423,7 @@ Future<_RunOutcome> _runFlutterFlow( // Markers are progress reporting, not output worth showing in an error. final marker = _readPhaseMarker(line); if (marker != null) { - channel.phase(marker.phase, marker.message); + _phase(channel, recorder, marker.phase, marker.message); return; } @@ -459,8 +567,10 @@ void _writeCorsHeaders(HttpResponse response) { final allowedOrigin = Platform.environment['ALLOWED_ORIGIN'] ?? '*'; response.headers ..set('Access-Control-Allow-Origin', allowedOrigin) - ..set('Access-Control-Allow-Methods', 'POST, OPTIONS') - ..set('Access-Control-Allow-Headers', 'Content-Type') + ..set('Access-Control-Allow-Methods', 'POST, GET, OPTIONS') + // x-ff-api-key lets the browser present the deploy key on GET /runStatus so + // a run id is never a bearer capability on its own. + ..set('Access-Control-Allow-Headers', 'Content-Type, x-ff-api-key') ..set('Vary', 'Origin'); } @@ -505,6 +615,45 @@ Future _writeJsonBody(HttpResponse response, Object body) async { } catch (_) {} } +/// Serves `GET /runStatus/`: looks up the run record and enforces that +/// the caller presents the same API key the run started with. +Future _handleRunStatus(HttpRequest request, String runId) async { + final store = FirestoreRunStore( + project: + Platform.environment['FIRESTORE_PROJECT'] ?? defaultFirestoreProject, + ); + final lookup = await fetchRunStatus( + runId: runId, + presentedKey: _apiKeyFromRequest(request), + store: store, + ); + await _writeJsonResponse(request.response, lookup.statusCode, lookup.body); +} + +/// The API key the caller must present to read a run. Read from a header +/// (preferred, to keep it out of URLs and access logs) with a query-parameter +/// fallback for callers that cannot set a custom header. +String? _apiKeyFromRequest(HttpRequest request) { + final header = request.headers.value('x-ff-api-key')?.trim(); + if (header != null && header.isNotEmpty) return header; + final query = request.uri.queryParameters['key']?.trim(); + if (query != null && query.isNotEmpty) return query; + return null; +} + +Future _writeJsonResponse( + HttpResponse response, + int statusCode, + Object body, +) async { + response.statusCode = statusCode; + response.headers.contentType = ContentType.json; + response.write(jsonEncode(body)); + try { + await response.close(); + } catch (_) {} +} + Future> _readJson(HttpRequest request) async { final raw = await utf8.decoder.bind(request).join(); final decoded = jsonDecode(raw); @@ -566,6 +715,7 @@ Future<_AnalysisOutcome?> _verifyCustomCode( Directory workspace, VerificationRequest? verification, _ResponseChannel channel, + RunRecorder? recorder, ) async { if (verification == null) return null; if (verification.unavailableReason != null) { @@ -573,7 +723,7 @@ Future<_AnalysisOutcome?> _verifyCustomCode( } if (verification.sources.isEmpty) return null; - channel.phase('verifying', 'Compiling your custom code...'); + _phase(channel, recorder, 'verifying', 'Compiling your custom code...'); final packageDir = Directory('${workspace.parent.path}/custom_code_analysis'); final libDir = Directory('${packageDir.path}/lib'); diff --git a/cloud-run/ffai-runner/lib/run_record.dart b/cloud-run/ffai-runner/lib/run_record.dart new file mode 100644 index 0000000..2e34704 --- /dev/null +++ b/cloud-run/ffai-runner/lib/run_record.dart @@ -0,0 +1,419 @@ +/// The deploy-run record: a per-request record the runner writes as a deploy +/// progresses, and the status endpoint that reads it back. +/// +/// Why it exists: a deploy can outlive the browser's wait bound, and until now +/// the only way the browser could learn what happened was to re-read the whole +/// project and guess. A record that tracks the run's phases, terminal outcome, +/// per-class results and error string lets a client whose response was lost +/// ask the runner directly. +/// +/// The record lives in Firestore in a fixed project (default `low-code-connect`) +/// because that project exists, its Firestore is enabled, and the runner's +/// service account already holds `roles/editor` there - so no IAM change and no +/// runtime pub dependency is needed. It is reached through the Firestore REST +/// API with an OAuth token borrowed from the GCE metadata server, all over +/// `dart:io`. +/// +/// Two invariants are enforced from here because they are the point of the +/// feature: +/// - The API key is never stored - only its SHA-256 digest is. The status +/// endpoint requires the caller to present the same key and compares +/// digests, so a run id alone is never a bearer capability over +/// `--allow-unauthenticated`. +/// - Writing the record can never fail a deploy. Every Firestore call is +/// bounded and its failure is logged, not propagated. The status endpoint, +/// by contrast, must answer its caller, so a read failure surfaces as a +/// temporary-unavailable error the browser treats as "unknown" and falls +/// back to reconciling by re-reading the project. +library; + +import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; + +import 'package:ccc_ffai_runner/sha256.dart'; + +/// The GCP project that owns the Firestore database holding run records. The +/// runner's service account holds roles/editor here, so no separate grant is +/// needed; overridden by FIRESTORE_PROJECT if the deployment ever relocates. +const defaultFirestoreProject = 'low-code-connect'; + +/// The Firestore collection (a sub-path of `databases/(default)/documents`) +/// that holds deploy-run records, keyed by the client-generated run id. +const runStatusCollection = 'deploy_runs'; + +/// Cap on a single record write. Writing must never hold up or fail a deploy, +/// so even the terminal write - the one a waiting client depends on - is +/// bounded to this and its failure swallowed. +const runRecordWriteTimeout = Duration(seconds: 2); + +/// An HTTP response from the backing store, kept opaque so the real transport +/// (dart:io) and a test double share one shape. +class FirestoreHttpResponse { + const FirestoreHttpResponse(this.status, this.body); + + final int status; + final String body; +} + +/// The transport the store talks to Firestore through. Injectable so tests can +/// exercise the store without a network or a metadata server. +typedef FirestoreTransport = + Future Function( + String method, + Uri uri, { + Map? headers, + String? body, + }); + +/// A Firestore lookup/update that did not succeed. Message is for the log +/// stream only; it is never shown to the caller of a deploy. +class RunStoreException implements Exception { + RunStoreException(this.message); + + final String message; + + @override + String toString() => 'RunStoreException: $message'; +} + +/// A dependency-free Firestore client scoped to one document path (the run +/// records), using the REST API and an OAuth token from the GCE metadata +/// server over dart:io. Both the token source and the HTTP transport are +/// injectable so the store is testable in isolation. +class FirestoreRunStore { + FirestoreRunStore({ + String project = defaultFirestoreProject, + String collection = runStatusCollection, + FirestoreTransport? transport, + Future Function()? fetchAccessToken, + }) : _project = project, + _collection = collection, + _transport = transport, + _fetchAccessToken = fetchAccessToken; + + final String _project; + final String _collection; + final FirestoreTransport? _transport; + final Future Function()? _fetchAccessToken; + + String? _cachedToken; + DateTime? _tokenExpiry; + + // Every field the runner writes, listed so the create-or-update PATCH + // targets exactly this set and never clobbers fields another writer owns. + static const _fieldKeys = [ + 'runId', + 'projectId', + 'keyHash', + 'classNames', + 'status', + 'phase', + 'message', + 'error', + 'deployed', + 'dryRun', + 'updatedAt', + ]; + + Uri _documentUri(String docId) => Uri.parse( + 'https://firestore.googleapis.com/v1/projects/$_project' + '/databases/(default)/documents/$_collection/$docId', + ); + + Uri _writeUri(String docId) => Uri.parse( + '${_documentUri(docId)}?' + '${_fieldKeys.map((k) => 'updateMask.fieldPaths=$k').join('&')}', + ); + + /// Writes a document, creating it if absent and otherwise updating only the + /// fields the runner owns. Throws `RunStoreException` on any non-2xx. + Future put(String docId, Map fields) async { + final token = await _accessToken(); + final response = await _send( + 'PATCH', + _writeUri(docId), + headers: {'Authorization': 'Bearer $token'}, + body: jsonEncode({'fields': encodeFields(fields)}), + ); + if (response.status < 200 || response.status >= 300) { + throw RunStoreException('write returned HTTP ${response.status}'); + } + } + + /// Reads a document, or returns null when it does not exist. + Future?> get(String docId) async { + final token = await _accessToken(); + final response = await _send( + 'GET', + _documentUri(docId), + headers: {'Authorization': 'Bearer $token'}, + ); + if (response.status == 404) return null; + if (response.status < 200 || response.status >= 300) { + throw RunStoreException('read returned HTTP ${response.status}'); + } + final decoded = jsonDecode(response.body); + if (decoded is! Map) { + throw RunStoreException('unexpected Firestore response shape'); + } + return decodeFields(decoded['fields'] as Map? ?? const {}); + } + + Future _accessToken() async { + final now = DateTime.now(); + if (_cachedToken != null && + _tokenExpiry != null && + now.isBefore(_tokenExpiry!)) { + return _cachedToken!; + } + // The real token source is the GCE metadata server; tests inject their own + // so no metadata round trip happens in the test suite. + final token = await (_fetchAccessToken ?? _metadataToken)(); + // Refresh comfortably ahead of the hour-long token lifetime. + _cachedToken = token; + _tokenExpiry = now.add(const Duration(minutes: 50)); + return token; + } + + static Future _metadataToken() async { + final client = HttpClient(); + try { + final request = await client.getUrl( + Uri.parse( + 'http://metadata.google.internal/computeMetadata/v1/instance/' + 'service-accounts/default/token', + ), + ); + request.headers.set('Metadata-Flavor', 'Google'); + final response = await request.close(); + final body = await utf8.decoder.bind(response).join(); + final decoded = jsonDecode(body); + final token = decoded is Map ? decoded['access_token'] : null; + if (token is! String || token.isEmpty) { + throw RunStoreException('metadata server returned no access token'); + } + return token; + } finally { + client.close(); + } + } + + Future _send( + String method, + Uri uri, { + Map? headers, + String? body, + }) { + if (_transport != null) { + return _transport(method, uri, headers: headers, body: body); + } + return _dartIoSend(method, uri, headers: headers, body: body); + } + + static Future _dartIoSend( + String method, + Uri uri, { + Map? headers, + String? body, + }) async { + final client = HttpClient(); + try { + final request = await client.openUrl(method, uri); + headers?.forEach((key, value) => request.headers.set(key, value)); + if (body != null) request.write(body); + final response = await request.close(); + final text = await utf8.decoder.bind(response).join(); + return FirestoreHttpResponse(response.statusCode, text); + } finally { + client.close(); + } + } +} + +/// Encodes a flat/typed object map into Firestore REST `fields` values. +Map encodeFields(Map fields) { + return { + for (final entry in fields.entries) entry.key: _toFirestoreValue(entry.value), + }; +} + +/// Decodes a Firestore REST `fields` map back into plain Dart values. +Map decodeFields(Map fields) { + final out = {}; + fields.forEach((key, value) => out['$key'] = _fromFirestoreValue(value)); + return out; +} + +Object? _toFirestoreValue(Object? value) { + if (value == null) return {'nullValue': null}; + if (value is bool) return {'booleanValue': value}; + if (value is String) return {'stringValue': value}; + if (value is int) return {'integerValue': '$value'}; + if (value is List) { + return { + 'arrayValue': { + 'values': [for (final item in value) _toFirestoreValue(item)], + }, + }; + } + if (value is Map) { + return {'mapValue': {'fields': encodeFields(Map.from(value))}}; + } + return {'nullValue': null}; +} + +Object? _fromFirestoreValue(Object? value) { + if (value is! Map) return null; + if (value.containsKey('stringValue')) return value['stringValue']; + if (value.containsKey('booleanValue')) return value['booleanValue']; + if (value.containsKey('integerValue')) { + return int.tryParse('${value['integerValue']}'); + } + if (value.containsKey('doubleValue')) return value['doubleValue']; + final array = value['arrayValue']; + if (array is Map) { + final values = array['values'] as List? ?? const []; + return [for (final item in values) _fromFirestoreValue(item)]; + } + final map = value['mapValue']; + if (map is Map) { + return decodeFields(map['fields'] as Map? ?? const {}); + } + return null; +} + +/// Records a deploy run's progress to Firestore. Idempotent by run id: every +/// write targets the same document, so a phase update overwrites the previous +/// one rather than appending. +class RunRecorder { + RunRecorder( + this._store, { + required this.runId, + required this.projectId, + required this.keyHash, + required List classNames, + }) : _classNames = List.unmodifiable(classNames); + + final FirestoreRunStore _store; + final String runId; + final String projectId; + final String keyHash; + final List _classNames; + String _phase = 'connected'; + + /// Records a non-terminal progress update. Fire-and-forget: never awaited, + /// never able to stall or fail the deploy. + void recordPhase(String phase, String message) { + _phase = phase; + _write({'status': 'running', 'phase': phase, 'message': message}); + } + + /// Records a successful terminal state. Awaited (bounded and swallowed + /// internally) so a client that lost the response can reliably learn the run + /// finished before its request returns. + Future recordDone(List> deployed, bool dryRun) { + _phase = 'done'; + return _write( + {'status': 'done', 'deployed': deployed, 'dryRun': dryRun}, + ); + } + + /// Records a terminal failure. The phase at failure is kept so the client can + /// tell a refusal that preceded any write from a failure that may have + /// followed classes already uploaded. + Future recordFailed(String error) { + return _write({'status': 'failed', 'error': error}); + } + + Future _write(Map patch) async { + final fields = { + 'runId': runId, + 'projectId': projectId, + 'keyHash': keyHash, + 'classNames': _classNames, + 'phase': _phase, + 'updatedAt': DateTime.now().toUtc().toIso8601String(), + ...patch, + }; + try { + await _store.put(runId, fields).timeout(runRecordWriteTimeout); + } catch (error) { + // The record is advisory: Firestore being slow, unreachable or + // misconfigured must degrade to the pre-record behaviour, not turn a + // good deploy into an error. + stderr.writeln('[run record] failed to persist $runId: $error'); + } + } +} + +/// The terminal endpoint's answer to a status read. +class RunStatusLookup { + const RunStatusLookup(this.statusCode, this.body); + + final int statusCode; + final Map body; +} + +/// Looks up a run by its client-generated id for `GET /runStatus/`. +/// +/// A run id alone is deliberately not a capability: the caller must present +/// the same API key the run started with, compared by SHA-256 digest, so +/// anyone reading another project's class names still has to know its key. +/// +/// Semantics the browser relies on: +/// - 404 for an id it generated itself proves the request never reached the +/// runner, so nothing was written and a retry is safe. +/// - 401/403 when the key is absent or does not match. +/// - 503 when Firestore itself cannot be read (the browser treats that as +/// "unknown" and falls back to re-reading the project). +/// - 200 with the record when found and authenticated. +Future fetchRunStatus({ + required String runId, + required String? presentedKey, + required FirestoreRunStore store, +}) async { + if (presentedKey == null || presentedKey.isEmpty) { + return const RunStatusLookup(401, { + 'success': false, + 'error': 'An API key is required to read a run.', + }); + } + + final Map? document; + try { + document = await store.get(runId); + } catch (error) { + stderr.writeln('[run status] lookup failed for $runId: $error'); + return const RunStatusLookup(503, { + 'success': false, + 'error': 'Run status is temporarily unavailable.', + }); + } + if (document == null) { + return const RunStatusLookup(404, { + 'success': false, + 'error': 'Unknown run.', + }); + } + + if ('${document['keyHash']}' != sha256Hex(utf8.encode(presentedKey))) { + return const RunStatusLookup(403, { + 'success': false, + 'error': 'The API key does not match this run.', + }); + } + + return RunStatusLookup(200, { + 'runId': document['runId'], + 'projectId': document['projectId'], + 'status': document['status'], + 'phase': document['phase'], + 'message': document['message'], + 'error': document['error'], + 'deployed': document['deployed'], + 'dryRun': document['dryRun'] == true, + 'updatedAt': document['updatedAt'], + 'classNames': document['classNames'], + }); +} diff --git a/cloud-run/ffai-runner/lib/sha256.dart b/cloud-run/ffai-runner/lib/sha256.dart new file mode 100644 index 0000000..d500498 --- /dev/null +++ b/cloud-run/ffai-runner/lib/sha256.dart @@ -0,0 +1,111 @@ +/// SHA-256 as a dependence-free pure-Dart implementation. +/// +/// The runner keeps zero runtime dependencies so its image build stays fast, +/// and that constraint is exactly why this exists: the deploy-run record must +/// authenticate status reads without ever storing the callers' API key, so it +/// stores a digest of the key instead. Rather than pull in a pub package (the +/// one thing this codebase deliberately avoids at runtime), the digest is +/// computed here and pinned by tests against the standard FIPS-180-4 vectors. +/// +/// The digest is used only as a capability the caller proves it knows the key +/// for, compared for equality in the status handler - it is not a MAC and is +/// never used to transit or key anything, so there is no secret material to +/// protect from timing side channels. +library; + +/// Returns the SHA-256 digest of [bytes] as a lowercase hex string. +String sha256Hex(List bytes) { + final state = [ + 0x6a09e667, 0xbb67ae85, 0x3c6ef372, 0xa54ff53a, + 0x510e527f, 0x9b05688c, 0x1f83d9ab, 0x5be0cd19, + ]; + + final messageLengthBits = bytes.length * 8; + // Padding: 0x80, zeros to a 56-byte residue, then the 64-bit length. + final paddedLength = ((bytes.length + 1 + 8 + 63) ~/ 64) * 64; + final padded = List.filled(paddedLength, 0); + for (var i = 0; i < bytes.length; i++) { + padded[i] = bytes[i]; + } + padded[bytes.length] = 0x80; + final lengthHigh = (messageLengthBits >>> 32) & 0xFFFFFFFF; + final lengthLow = messageLengthBits & 0xFFFFFFFF; + padded[paddedLength - 8] = (lengthHigh >>> 24) & 0xFF; + padded[paddedLength - 7] = (lengthHigh >>> 16) & 0xFF; + padded[paddedLength - 6] = (lengthHigh >>> 8) & 0xFF; + padded[paddedLength - 5] = lengthHigh & 0xFF; + padded[paddedLength - 4] = (lengthLow >>> 24) & 0xFF; + padded[paddedLength - 3] = (lengthLow >>> 16) & 0xFF; + padded[paddedLength - 2] = (lengthLow >>> 8) & 0xFF; + padded[paddedLength - 1] = lengthLow & 0xFF; + + final w = List.filled(64, 0); + for (var blockStart = 0; blockStart < paddedLength; blockStart += 64) { + for (var i = 0; i < 16; i++) { + final base = blockStart + i * 4; + w[i] = (((padded[base] << 24) | + (padded[base + 1] << 16) | + (padded[base + 2] << 8) | + padded[base + 3]) & + 0xFFFFFFFF); + } + _compress(state, w); + } + + return state + .map((word) => word.toRadixString(16).padLeft(8, '0')) + .join(); +} + +void _compress(List h, List w) { + for (var i = 16; i < 64; i++) { + final s0 = _rotr(w[i - 15], 7) ^ _rotr(w[i - 15], 18) ^ (w[i - 15] >>> 3); + final s1 = _rotr(w[i - 2], 17) ^ _rotr(w[i - 2], 19) ^ (w[i - 2] >>> 10); + w[i] = (w[i - 16] + s0 + w[i - 7] + s1) & 0xFFFFFFFF; + } + + var a = h[0], b = h[1], c = h[2], d = h[3]; + var e = h[4], f = h[5], g = h[6], gh = h[7]; + + for (var i = 0; i < 64; i++) { + final bigS1 = _rotr(e, 6) ^ _rotr(e, 11) ^ _rotr(e, 25); + final ch = (e & f) ^ ((~e) & g); + final temp1 = (gh + bigS1 + ch + _roundConstants[i] + w[i]) & 0xFFFFFFFF; + final bigS0 = _rotr(a, 2) ^ _rotr(a, 13) ^ _rotr(a, 22); + final maj = (a & b) ^ (a & c) ^ (b & c); + final temp2 = (bigS0 + maj) & 0xFFFFFFFF; + + gh = g; + g = f; + f = e; + e = (d + temp1) & 0xFFFFFFFF; + d = c; + c = b; + b = a; + a = (temp1 + temp2) & 0xFFFFFFFF; + } + + final tv = [a, b, c, d, e, f, g, gh]; + for (var i = 0; i < 8; i++) { + h[i] = (h[i] + tv[i]) & 0xFFFFFFFF; + } +} + +int _rotr(int value, int bits) => + ((value >>> bits) | (value << (32 - bits))) & 0xFFFFFFFF; + +// The first 32 bits of the fractional parts of the cube roots of the first 64 +// primes. Inlined as data rather than generated so the loop can index it. +const List _roundConstants = [ + 0x428a2f98, 0x71374491, 0xb5c0fbcf, 0xe9b5dba5, 0x3956c25b, 0x59f111f1, + 0x923f82a4, 0xab1c5ed5, 0xd807aa98, 0x12835b01, 0x243185be, 0x550c7dc3, + 0x72be5d74, 0x80deb1fe, 0x9bdc06a7, 0xc19bf174, 0xe49b69c1, 0xefbe4786, + 0x0fc19dc6, 0x240ca1cc, 0x2de92c6f, 0x4a7484aa, 0x5cb0a9dc, 0x76f988da, + 0x983e5152, 0xa831c66d, 0xb00327c8, 0xbf597fc7, 0xc6e00bf3, 0xd5a79147, + 0x06ca6351, 0x14292967, 0x27b70a85, 0x2e1b2138, 0x4d2c6dfc, 0x53380d13, + 0x650a7354, 0x766a0abb, 0x81c2c92e, 0x92722c85, 0xa2bfe8a1, 0xa81a664b, + 0xc24b8b70, 0xc76c51a3, 0xd192e819, 0xd6990624, 0xf40e3585, 0x106aa070, + 0x19a4c116, 0x1e376c08, 0x2748774c, 0x34b0bcb5, 0x391c0cb3, 0x4ed8aa4a, + 0x5b9cca4f, 0x682e6ff3, 0x748f82ee, 0x78a5636f, 0x84c87814, 0x8cc70208, + 0x90befffa, 0xa4506ceb, 0xbef9a3f7, 0xc67178f2, +]; diff --git a/cloud-run/ffai-runner/test/run_record_test.dart b/cloud-run/ffai-runner/test/run_record_test.dart new file mode 100644 index 0000000..684c6eb --- /dev/null +++ b/cloud-run/ffai-runner/test/run_record_test.dart @@ -0,0 +1,170 @@ +import 'dart:convert'; + +import 'package:ccc_ffai_runner/run_record.dart'; +import 'package:ccc_ffai_runner/sha256.dart'; +import 'package:test/test.dart'; + +String _keyHash(String key) => sha256Hex(utf8.encode(key)); + +/// Builds a store whose transport is a canned responder and whose token is +/// injected, so no metadata server or network is touched. +FirestoreRunStore _store( + Future Function( + String method, + Uri uri, { + Map? headers, + String? body, + }) transport, +) { + return FirestoreRunStore( + transport: transport, + fetchAccessToken: () async => 'test-token', + ); +} + +Future _jsonResponse(Object body) async => + FirestoreHttpResponse(200, jsonEncode(body)); + +void main() { + final key = 'ff-key-123'; + final goodDoc = { + 'runId': 'dr_abc', + 'projectId': 'ff-proj', + 'keyHash': _keyHash(key), + 'classNames': ['GaugeWidget'], + 'status': 'done', + 'phase': 'done', + 'message': 'done', + 'error': null, + 'deployed': [ + {'className': 'GaugeWidget', 'artifactId': 'g'}, + ], + 'dryRun': false, + }; + + group('fetchRunStatus auth and identity', () { + test('rejects a missing key before ever querying the store', () async { + final store = _store((method, uri, {headers, body}) async { + fail('store must not be queried without a key'); + }); + final lookup = await fetchRunStatus( + runId: 'dr_abc', + presentedKey: null, + store: store, + ); + expect(lookup.statusCode, 401); + }); + + test('404 for an unknown run id a client generated itself', () async { + final store = _store( + (method, uri, {headers, body}) async => + const FirestoreHttpResponse(404, ''), + ); + final lookup = await fetchRunStatus( + runId: 'dr_never-sent', + presentedKey: key, + store: store, + ); + expect(lookup.statusCode, 404); + }); + + test('refuses a wrong key (hash mismatch)', () async { + final store = _store( + (method, uri, {headers, body}) async => _jsonResponse({ + 'fields': { + for (final f in {'keyHash': _keyHash(key)}.entries) + f.key: {'stringValue': f.value}, + }, + }), + ); + final lookup = await fetchRunStatus( + runId: 'dr_abc', + presentedKey: 'some-other-key', + store: store, + ); + expect(lookup.statusCode, 403); + }); + + test('returns the record for the matching key', () async { + final store = _store( + (method, uri, {headers, body}) async => + _jsonResponse({'fields': encodeFields(goodDoc)}), + ); + final lookup = await fetchRunStatus( + runId: 'dr_abc', + presentedKey: key, + store: store, + ); + expect(lookup.statusCode, 200); + expect(lookup.body['status'], 'done'); + expect(lookup.body['projectId'], 'ff-proj'); + expect(lookup.body['deployed'], isA()); + // The stored digest is never returned to the caller. + expect(lookup.body.containsKey('keyHash'), isFalse); + }); + + test('503 when Firestore cannot be read', () async { + final store = _store( + (method, uri, {headers, body}) async => + const FirestoreHttpResponse(500, 'server error'), + ); + final lookup = await fetchRunStatus( + runId: 'dr_abc', + presentedKey: key, + store: store, + ); + expect(lookup.statusCode, 503); + }); + }); + + group('RunRecorder', () { + test('never throws even when Firestore rejects every write', () async { + final store = _store( + (method, uri, {headers, body}) async => + const FirestoreHttpResponse(500, 'denied'), + ); + final recorder = RunRecorder( + store, + runId: 'dr_abc', + projectId: 'ff-proj', + keyHash: _keyHash('key'), + classNames: const ['GaugeWidget'], + ); + // Intermediate progress is fire-and-forget. + recorder.recordPhase('verifying', 'Compiling...'); + // The terminal write is awaited but must not throw or hang past its cap. + await expectLater( + recorder.recordDone(const [ + {'className': 'GaugeWidget', 'artifactId': 'g'}, + ], false), + completes, + ); + await expectLater(recorder.recordFailed('something broke'), completes); + }); + }); + + group('Firestore field codec', () { + test('round-trips the record shape', () { + final original = { + 'runId': 'dr_abc', + 'status': 'done', + 'dryRun': false, + 'deployed': [ + {'className': 'GaugeWidget', 'artifactId': 'g'}, + ], + 'classNames': ['GaugeWidget'], + 'error': null, + }; + final decoded = decodeFields(encodeFields(original)); + expect(decoded, original); + }); + + test('store.get returns null for a 404', () async { + final store = _store( + (method, uri, {headers, body}) async => + const FirestoreHttpResponse(404, 'not found'), + ); + expect(await store.get('missing'), isNull); + }); + }); +} diff --git a/cloud-run/ffai-runner/test/sha256_test.dart b/cloud-run/ffai-runner/test/sha256_test.dart new file mode 100644 index 0000000..7c3ac0d --- /dev/null +++ b/cloud-run/ffai-runner/test/sha256_test.dart @@ -0,0 +1,34 @@ +import 'dart:convert'; + +import 'package:ccc_ffai_runner/sha256.dart'; +import 'package:test/test.dart'; + +void main() { + // The first two are the canonical FIPS-180-4 examples; the rest exercise the + // padding-length path (a message that ends exactly at a block boundary, and + // messages that span two blocks). Expected values verified with `shasum`. + final vectors = <(String, String)>[ + ('', 'e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855'), + ('abc', 'ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad'), + ( + 'abcdbcdecdefdefgefghfghighijhijkijkljklmklmnlmnomnopnopq', + '248d6a61d20638b8e5c026930c3e6039a33ce45964ff2167f6ecedd419db06c1', + ), + ('${'a' * 64}', 'ffe054fe7ae0cb6dc65c3af9b61d5209f439851db43d0ba5997337df154668eb'), + ('${'a' * 1000}', '41edece42d63e8d9bf515a9ba6932e1c20cbc9f5a5d134645adb5db1b9737ea3'), + ]; + + for (final (input, expected) in vectors) { + test('sha256Hex(${input.length} bytes)', () { + expect(sha256Hex(utf8.encode(input)), expected); + }); + } + + test('is deterministic and distinct for a close pair', () { + final a = sha256Hex(utf8.encode('secret-key-abc')); + final b = sha256Hex(utf8.encode('secret-key-abc')); + final c = sha256Hex(utf8.encode('secret-key-abd')); + expect(a, b); + expect(a, isNot(c)); + }); +} diff --git a/src/runStatus.js b/src/runStatus.js new file mode 100644 index 0000000..eae1bbf --- /dev/null +++ b/src/runStatus.js @@ -0,0 +1,227 @@ +/** + * Client side of the deploy-run record. + * + * When a custom-class provisioning request outlives the browser's wait bound or + * drops its connection, the only way to learn what happened used to be to + * re-read the whole project and guess. The runner now keeps a per-run record + * and answers `GET /runStatus/`: the client generates the run id and + * sends it with the deploy request, and on a lost outcome asks the runner + * directly. A 404 for an id the client itself generated is proof the request + * never reached the runner, so nothing was written and a retry is safe; a + * definitive `done` or provably pre-write `failed` answer replaces the guess. + * + * Everything an older runner predates stays a fallback: a 404, a 5xx, or an + * unrecognised body resolves to "unknown", and the caller re-reads the project + * exactly as before. + */ + +/** The request header that carries the API key to GET /runStatus. */ +export const RUN_STATUS_KEY_HEADER = "x-ff-api-key"; + +/** Prefix marking an id as client-generated (a runner could never mint these). */ +const RUN_ID_PREFIX = "dr_"; + +/** Allowed: letters, digits, `-` and `_`, up to 120 chars (matches the runner). */ +const RUN_ID_PATTERN = /^[A-Za-z0-9_-]{1,120}$/; + +/** + * Generates a client-side run id. A random UUID (prefixed so it can never be + * confused with a runner-minted id) with a small fallback for environments + * without `crypto.randomUUID`. + * @returns {string} + */ +export function generateDeployRunId() { + const cryptoObj = globalThis.crypto; + if (cryptoObj && typeof cryptoObj.randomUUID === "function") { + return `${RUN_ID_PREFIX}${cryptoObj.randomUUID()}`; + } + return `${RUN_ID_PREFIX}${Date.now().toString(36)}${Math.random() + .toString(36) + .slice(2, 10)}`; +} + +/** + * Whether a run id is well-formed enough to place in a URL path and pass to + * the runner. Everything else is treated as absent. + * @param {string} id + * @returns {boolean} + */ +export function isValidRunId(id) { + return typeof id === "string" && RUN_ID_PATTERN.test(id); +} + +/** + * The status endpoint lives on the same runner as the provision endpoint, so + * it is derived from it rather than configured separately. The provision + * endpoint must end in /deployCustomClasses; handle the unusual case of a + * differently-shaped override by falling back to it unchanged. + * @param {string} provisionEndpoint + * @param {string} runId + * @returns {string} + */ +export function runStatusUrl(provisionEndpoint, runId) { + const base = String(provisionEndpoint || "").replace( + /\/deployCustomClasses$/, + "", + ); + return `${base}/runStatus/${encodeURIComponent(runId)}`; +} + +/** + * Normalises a run-status response body into the shape the outcome mapper + * reads, or null when it is not a recognisable record (an older runner, or a + * malformed body - both must act as "unknown"). + * @param {Object} body - Parsed JSON from the runner + * @returns {Object|null} + */ +export function readRunStatus(body) { + if (!body || typeof body !== "object") return null; + const { status } = body; + if (status !== "running" && status !== "done" && status !== "failed") { + return null; + } + return { + status, + phase: typeof body.phase === "string" ? body.phase : "", + message: typeof body.message === "string" ? body.message : "", + error: typeof body.error === "string" ? body.error : null, + deployed: Array.isArray(body.deployed) ? body.deployed : [], + dryRun: body.dryRun === true, + }; +} + +/** + * The runner phases that provably precede any write to FlutterFlow, so a + * failure recorded at one of them is a definitive refusal. A failure at or + * after `deploy_start` may have landed classes and stays "unknown" - the + * caller must re-read the project to know what happened. + */ +export const PRE_WRITE_RUN_PHASES = new Set([ + "connected", + "workspace_init", + "workspace_ready", + "verifying", +]); + +/** + * Maps a run-status record to a decision the deploy flow can act on. + * + * Returns `{ definitive: "success", deployed }` only when the runner recorded + * a clean finish; `{ definitive: "failed", error }` only for a failure that + * provably preceded any write; and `{ definitive: null }` for a run still in + * progress or a failure whose write state is unknown - in both cases the caller + * falls back to reconciling by re-reading the project. + * @param {Object|null} record - From `readRunStatus` + * @returns {{definitive: (string|null), deployed?: Array, error?: string}} + */ +export function runStatusOutcome(record) { + if (!record || !record.status) return { definitive: null }; + if (record.status === "done") { + return { + definitive: "success", + deployed: Array.isArray(record.deployed) ? record.deployed : [], + }; + } + if (record.status === "failed" && PRE_WRITE_RUN_PHASES.has(record.phase)) { + return { + definitive: "failed", + error: + record.error || "The deploy failed before writing anything to FlutterFlow.", + }; + } + return { definitive: null }; +} + +/** + * Fetches the run-status record once. Any non-2xx (404 for a never-arrived or + * older-runner id, 5xx from an unreachable backend) or a fetch that throws or + * times out resolves to null - "unknown" - never an exception. + * @param {Object} opts + * @param {string} opts.provisionEndpoint + * @param {string} opts.runId + * @param {string} opts.apiKey + * @param {Function} [opts.fetchImpl] - Injectable for tests + * @param {number} [opts.timeoutMs] + * @returns {Promise} + */ +export async function fetchRunStatus({ + provisionEndpoint, + runId, + apiKey, + fetchImpl, + timeoutMs = 8000, +}) { + if (!isValidRunId(runId)) return null; + const doFetch = fetchImpl || globalThis.fetch; + if (!doFetch) return null; + + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), timeoutMs); + try { + const response = await doFetch( + runStatusUrl(provisionEndpoint, runId), + { + method: "GET", + headers: { [RUN_STATUS_KEY_HEADER]: String(apiKey || "") }, + signal: controller.signal, + }, + ); + if (!response.ok) return null; + const body = await response.json(); + return readRunStatus(body); + } catch { + return null; + } finally { + clearTimeout(timer); + } +} + +/** + * Polls the run-status endpoint under a hard budget until it yields a + * definitive outcome, or the budget runs out. Used when a provision whose + * response was lost may still be finishing on the runner - this is the bounded + * ask that replaces the guess when the record is definitive. + * @param {Object} opts - As `fetchRunStatus`, plus a budget + * @param {number} [opts.budgetMs] + * @param {number} [opts.intervalMs] + * @returns {Promise<{definitive: (string|null), deployed?: Array, error?: string}>} + */ +export async function queryRunStatusWithinBudget({ + provisionEndpoint, + runId, + apiKey, + fetchImpl, + budgetMs = 12000, + intervalMs = 2000, +}) { + const deadline = Date.now() + budgetMs; + let nullPolls = 0; + while (Date.now() < deadline) { + const record = await fetchRunStatus({ + provisionEndpoint, + runId, + apiKey, + fetchImpl, + }); + if (record) { + const outcome = runStatusOutcome(record); + if (outcome.definitive) return outcome; + // A recognised but still-running record is worth waiting on: the runner + // reached back, so its answer is on its way. + nullPolls = 0; + } else { + // A 404 (never arrived, or an older runner with no endpoint) or an + // unreachable endpoint will not resolve within the budget. A single + // transient miss is retried once, then the question is left to the caller + // instead of burning the budget polling an endpoint that cannot answer. + nullPolls += 1; + if (nullPolls >= 2) return { definitive: null }; + } + const remaining = deadline - Date.now(); + if (remaining <= 0) break; + await new Promise((resolve) => + setTimeout(resolve, Math.min(intervalMs, remaining)), + ); + } + return { definitive: null }; +} diff --git a/src/runStatus.test.js b/src/runStatus.test.js new file mode 100644 index 0000000..87fe660 --- /dev/null +++ b/src/runStatus.test.js @@ -0,0 +1,186 @@ +import { describe, it } from "node:test"; +import assert from "node:assert/strict"; +import { + generateDeployRunId, + isValidRunId, + runStatusUrl, + readRunStatus, + runStatusOutcome, + fetchRunStatus, + queryRunStatusWithinBudget, + PRE_WRITE_RUN_PHASES, + RUN_STATUS_KEY_HEADER, +} from "./runStatus.js"; + +describe("runStatus — id plumbing", () => { + it("generates a valid, prefixed run id", () => { + const id = generateDeployRunId(); + assert.ok(id.startsWith("dr_")); + assert.ok(isValidRunId(id)); + }); + + it("rejects invalid and over-long ids", () => { + assert.equal(isValidRunId(""), false); + assert.equal(isValidRunId("has a space"), false); + assert.equal(isValidRunId("a".repeat(121)), false); + assert.equal(isValidRunId(null), false); + }); + + it("derives the status url from the provision endpoint", () => { + assert.equal( + runStatusUrl("https://runner.example/deployCustomClasses", "dr_123"), + "https://runner.example/runStatus/dr_123", + ); + assert.equal( + runStatusUrl("/api/ffai-runner/deployCustomClasses", "dr_123"), + "/api/ffai-runner/runStatus/dr_123", + ); + }); +}); + +describe("runStatus — response-to-outcome mapping", () => { + it("accepts only recognisable records", () => { + assert.equal(readRunStatus({ status: "bogus" }), null); + assert.equal(readRunStatus("not an object"), null); + assert.equal(readRunStatus(null), null); + }); + + it("maps a done record to a definitive success with deployed classes", () => { + const outcome = runStatusOutcome( + readRunStatus({ + status: "done", + deployed: [{ className: "GaugeWidget", artifactId: "g" }], + }), + ); + assert.deepEqual(outcome, { + definitive: "success", + deployed: [{ className: "GaugeWidget", artifactId: "g" }], + }); + }); + + it("maps a pre-write failure to a definitive failure", () => { + const outcome = runStatusOutcome( + readRunStatus({ + status: "failed", + phase: "verifying", + error: "does not compile", + }), + ); + assert.deepEqual(outcome, { + definitive: "failed", + error: "does not compile", + }); + }); + + it("leaves a running record unknown", () => { + const outcome = runStatusOutcome( + readRunStatus({ status: "running", phase: "uploading" }), + ); + assert.equal(outcome.definitive, null); + }); + + it("leaves a post-write failure unknown so the caller reconciles", () => { + assert.ok(PRE_WRITE_RUN_PHASES.has("verifying")); + assert.ok(!PRE_WRITE_RUN_PHASES.has("deploy_start")); + const outcome = runStatusOutcome( + readRunStatus({ status: "failed", phase: "uploading" }), + ); + assert.equal(outcome.definitive, null); + }); +}); + +describe("runStatus — client fallback when the endpoint is absent or 404s", () => { + // A never-arrived request is a 404 the client can distinguish from "unknown": + // it generated the id itself, so not finding it proves nothing was written. + it("resolves a 404 to unknown, never a failure", async () => { + const fetchImpl = async () => ({ ok: false, status: 404 }); + const outcome = await queryRunStatusWithinBudget({ + provisionEndpoint: "https://runner.example/deployCustomClasses", + runId: "dr_never-sent", + apiKey: "key", + fetchImpl, + budgetMs: 50, + intervalMs: 10, + }); + assert.equal(outcome.definitive, null); + }); + + it("sends the api key in the run-status header", async () => { + let sentHeader = null; + let sentUrl = null; + const fetchImpl = async (url, opts) => { + sentUrl = url; + sentHeader = opts.headers[RUN_STATUS_KEY_HEADER]; + return { ok: true, json: async () => ({ status: "done", deployed: [] }) }; + }; + const outcome = await queryRunStatusWithinBudget({ + provisionEndpoint: "https://runner.example/deployCustomClasses", + runId: "dr_123", + apiKey: "the-key", + fetchImpl, + budgetMs: 50, + }); + assert.equal(sentUrl, "https://runner.example/runStatus/dr_123"); + assert.equal(sentHeader, "the-key"); + assert.equal(outcome.definitive, "success"); + }); + + it("returns a definitive success even if the first poll sees it running", async () => { + let calls = 0; + const fetchImpl = async () => { + calls += 1; + if (calls === 1) { + return { ok: true, json: async () => ({ status: "running", phase: "uploading" }) }; + } + return { ok: true, json: async () => ({ status: "done", deployed: [] }) }; + }; + const outcome = await queryRunStatusWithinBudget({ + provisionEndpoint: "https://runner.example/deployCustomClasses", + runId: "dr_123", + apiKey: "key", + fetchImpl, + budgetMs: 500, + intervalMs: 10, + }); + assert.equal(outcome.definitive, "success"); + }); + + it("ignores a malformed body (older runner) and falls back to unknown", async () => { + const fetchImpl = async () => ({ ok: true, json: async () => ({ event: "heartbeat" }) }); + const outcome = await queryRunStatusWithinBudget({ + provisionEndpoint: "https://runner.example/deployCustomClasses", + runId: "dr_123", + apiKey: "key", + fetchImpl, + budgetMs: 50, + intervalMs: 10, + }); + assert.equal(outcome.definitive, null); + }); + + it("does not throw the app when a network error lands mid-poll", async () => { + const fetchImpl = async () => { + throw new TypeError("fetch failed"); + }; + const outcome = await queryRunStatusWithinBudget({ + provisionEndpoint: "https://runner.example/deployCustomClasses", + runId: "dr_123", + apiKey: "key", + fetchImpl, + budgetMs: 50, + intervalMs: 10, + }); + assert.equal(outcome.definitive, null); + }); + + it("returns 404-unknown via fetchRunStatus for a never-arrived id", async () => { + const fetchImpl = async () => ({ ok: false, status: 404 }); + const record = await fetchRunStatus({ + provisionEndpoint: "https://runner.example/deployCustomClasses", + runId: "dr_never", + apiKey: "key", + fetchImpl, + }); + assert.equal(record, null); + }); +});