File size: 17,058 Bytes
c6ec6e8
 
 
 
 
 
 
 
 
 
 
 
 
2f2efaf
 
 
c6ec6e8
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2f2efaf
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
c6ec6e8
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
31549ba
2f2efaf
 
c6ec6e8
 
31549ba
2f2efaf
 
31549ba
 
2f2efaf
c6ec6e8
 
31549ba
 
c3e3658
 
 
 
03194b1
 
 
c3e3658
2f2efaf
 
c6ec6e8
 
 
 
 
 
 
 
 
 
2f2efaf
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
/**
 * src/managers/FirebaseManager.ts
 *
 * Singleton wrapper around the Firebase Admin SDK.
 * Responsibilities:
 *   - Initialize the Admin SDK once from env (service account JSON string)
 *   - Real-time Firestore listener for `titan_transpiler_jobs`
 *   - Atomic job claiming via Firestore transactions (prevents duplicate processing
 *     if multiple workers are ever running)
 *   - Firebase Storage upload / download helpers
 */

import admin from 'firebase-admin';
import axios from 'axios';
import fs from 'fs';
import { pipeline } from 'stream/promises';
import type { Firestore } from 'firebase-admin/firestore';
import { logger } from '../utils/logger';
import { ENV } from '../config/env';

// ─── Job Document Types ───────────────────────────────────────────────────────

export type JobStatus =
  | 'pending'
  | 'processing'
  | 'transpiling'
  | 'auditing'
  | 'completed'
  | 'failed';

export interface TranspilerJob {
  /** Firestore document ID */
  id: string;
  /** gs:// or https:// URL of the uploaded Website.zip in Firebase Storage */
  zipUrl: string;
  status: JobStatus;
  appName?: string;
  createdAt: admin.firestore.Timestamp;
  updatedAt?: admin.firestore.Timestamp;
  /** Signed download URL for the generated Flutter_Code.zip */
  outputUrl?: string;
  /** Human-readable error message when status === 'failed' */
  errorMessage?: string;
  /** 0–100 overall progress indicator */
  progress?: number;
  /** Per-file Dart confidence scores */
  confidenceScores?: Record<string, number>;
  /** Per-file audit pass/fail */
  auditResults?: Record<string, boolean>;
}

export interface JobUpdate {
  status?: JobStatus;
  outputUrl?: string;
  errorMessage?: string;
  progress?: number;
  confidenceScores?: Record<string, number>;
  auditResults?: Record<string, boolean>;
}

// ─── FirebaseManager (Singleton) ─────────────────────────────────────────────

export class FirebaseManager {
  private static instance: FirebaseManager | null = null;

  private readonly db: Firestore;
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
  private readonly bucket: any; // admin.storage().bucket() β€” type varies by firebase-admin version
  private unsubscribeListener: (() => void) | null = null;

  // ─── Initialization ──────────────────────────────────────────────────────

  private constructor() {
    // Guard: only call initializeApp once (important for hot-reload in dev)
    if (!admin.apps.length) {
      const serviceAccount = JSON.parse(ENV.FIREBASE_SERVICE_ACCOUNT) as admin.ServiceAccount;
      admin.initializeApp({
        credential: admin.credential.cert(serviceAccount),
        storageBucket: ENV.FIREBASE_STORAGE_BUCKET,
      });
    }

    this.db = admin.firestore();
    this.bucket = admin.storage().bucket();

    logger.info('FirebaseManager: Admin SDK initialized');
    logger.info(`FirebaseManager: bucket="${ENV.FIREBASE_STORAGE_BUCKET}"`);
  }

  static getInstance(): FirebaseManager {
    if (!FirebaseManager.instance) {
      FirebaseManager.instance = new FirebaseManager();
    }
    return FirebaseManager.instance;
  }

  // ─── Firestore Job Operations ────────────────────────────────────────────

  /**
   * Fetches a single job document by its Firestore document ID.
   * Returns null if the document does not exist.
   */
  async getJob(jobId: string): Promise<TranspilerJob | null> {
    const snap = await this.db
      .collection(ENV.FIRESTORE_JOBS_COLLECTION)
      .doc(jobId)
      .get();

    if (!snap.exists) return null;
    return { id: snap.id, ...(snap.data() as Omit<TranspilerJob, 'id'>) };
  }

  /**
   * Partially updates a job document. Always stamps `updatedAt`.
   */
  async updateJob(jobId: string, update: JobUpdate): Promise<void> {
    const payload: Record<string, unknown> = {
      ...update,
      updatedAt: admin.firestore.Timestamp.now(),
    };

    await this.db
      .collection(ENV.FIRESTORE_JOBS_COLLECTION)
      .doc(jobId)
      .update(payload);

    logger.info(
      `FirebaseManager: job ${jobId} updated` +
      (update.status ? ` β†’ status="${update.status}"` : '') +
      (update.progress !== undefined ? ` progress=${update.progress}%` : '')
    );
  }

  /**
   * Starts a Firestore real-time listener on the jobs collection.
   * When a document is added with status === "pending", it:
   *   1. Runs a Firestore transaction to atomically claim the job
   *      (sets status β†’ "processing"), preventing races.
   *   2. Calls `onNewJob(job)` asynchronously.
   *
   * @returns An unsubscribe function that stops the listener.
   */
  listenForPendingJobs(
    onNewJob: (job: TranspilerJob) => Promise<void>
  ): () => void {
    const col = this.db.collection(ENV.FIRESTORE_JOBS_COLLECTION);
    const query = col
      .where('status', '==', 'pending')
      .orderBy('createdAt', 'asc');

    const unsubscribe = query.onSnapshot(
      async (snapshot) => {
        for (const change of snapshot.docChanges()) {
          // We only care about documents that just became 'pending'
          if (change.type !== 'added') continue;

          const docRef = change.doc.ref;
          const rawData = change.doc.data() as Omit<TranspilerJob, 'id'>;
          const job: TranspilerJob = { id: change.doc.id, ...rawData };

          logger.info(`FirebaseManager: new pending job β†’ id=${job.id}`);

          // ── Atomic claim via transaction ─────────────────────────────────
          let claimed = false;
          try {
            await this.db.runTransaction(async (tx) => {
              const freshSnap = await tx.get(docRef);
              if (!freshSnap.exists) throw new Error('Job document disappeared');

              const freshStatus = (freshSnap.data() as Omit<TranspilerJob, 'id'>).status;
              if (freshStatus !== 'pending') {
                // Another worker claimed it first β€” abort silently
                throw new Error(`Job ${job.id} already claimed (status="${freshStatus}")`);
              }

              tx.update(docRef, {
                status: 'processing',
                updatedAt: admin.firestore.Timestamp.now(),
              });
            });
            claimed = true;
          } catch (txErr) {
            logger.warn(`FirebaseManager: job ${job.id} claim failed β€” ${String(txErr)}`);
          }

          if (!claimed) continue;

          // ── Dispatch to orchestrator (non-blocking) ──────────────────────
          onNewJob(job).catch((err: unknown) => {
            logger.error(`FirebaseManager: uncaught error in onNewJob for ${job.id}`, err);
          });
        }
      },
      (err) => {
        logger.error('FirebaseManager: Firestore listener error', err);
      }
    );

    this.unsubscribeListener = unsubscribe;
    logger.info(
      `FirebaseManager: listening on collection "${ENV.FIRESTORE_JOBS_COLLECTION}"`
    );
    return unsubscribe;
  }

  /** Stops the active Firestore listener, if any. */
  stopListening(): void {
    this.unsubscribeListener?.();
    this.unsubscribeListener = null;
    logger.info('FirebaseManager: listener stopped');
  }

  // ─── Queue Operations (titan_builder_queue) ──────────────────────────────
  //
  // These methods target titan_builder_queue exclusively.
  // No existing method above is touched or modified.

  /**
   * Writes a partial update to a document in `titan_builder_queue`.
   * Uses set({ merge: true }) so it is safe even if the doc is brand-new,
   * and always stamps updatedAt.
   */
  async updateQueueDoc(docId: string, update: QueueDocUpdate): Promise<void> {
    const payload: Record<string, unknown> = {
      ...update,
      updatedAt: admin.firestore.Timestamp.now(),
    };
    await this.db
      .collection(QUEUE_COLLECTION)
      .doc(docId)
      .set(payload, { merge: true });

    logger.info(
      `FirebaseManager[queue]: ${docId} updated` +
      (update.status ? ` β†’ "${update.status}"` : '')
    );
  }

  /**
   * Creates a seed document in `titan_transpiler_jobs` via .set().
   *
   * WHY THIS EXISTS:
   *   updateJob() uses .update() which throws NOT_FOUND if the doc is absent.
   *   JobQueue calls this BEFORE LLMOrchestrator.processJob() so that the
   *   orchestrators internal updateJob() calls have a real document to land on.
   */
  async createTranspilerJobDoc(job: TranspilerJob): Promise<void> {
    const { id, ...data } = job;
    await this.db
      .collection(ENV.FIRESTORE_JOBS_COLLECTION)
      .doc(id)
      .set({
        ...data,
        createdAt: data.createdAt ?? admin.firestore.Timestamp.now(),
        updatedAt:  admin.firestore.Timestamp.now(),
      });
    logger.info(`FirebaseManager: transpiler job seeded β†’ id=${id}`);
  }

  /**
   * Downloads a Firebase Storage object entirely into a Node.js Buffer.
   *
   * Use-case: the build queue handler needs the reviewed Dart ZIP as bytes
   * to pass directly to GitHubActionsService.triggerBuild().
   *
   * Memory note: keep payloads under ~50 MB β€” fully buffered in RAM.
   */
  async downloadToBuffer(storageUrl: string): Promise<Buffer> {
    const storagePath = this.parseStoragePath(storageUrl);
    logger.info(`FirebaseManager: downloading to buffer β€” "${storagePath}"`);
    const [contents] = await this.bucket.file(storagePath).download();
    const buf = Buffer.isBuffer(contents) ? contents : Buffer.from(contents as Uint8Array);
    logger.info(`FirebaseManager: buffer ready β€” ${buf.length} bytes`);
    return buf;
  }


  // ─── Firebase Storage Operations ─────────────────────────────────────────

  /**
   * Downloads a file from Firebase Storage to a local path.
   * Accepts gs://, public storage URLs, or firebasestorage.googleapis.com URLs.
   */
  async downloadFile(storageUrl: string, localPath: string): Promise<void> {
    const storagePath = this.parseStoragePath(storageUrl);
    logger.info(`FirebaseManager: downloading "${storagePath}" β†’ "${localPath}"`);
    await this.bucket.file(storagePath).download({ destination: localPath });
    logger.info(`FirebaseManager: download complete`);
  }

  /**
   * Downloads a file from any HTTPS URL (HF Dataset, etc.) to a local path.
   * Used when zipUrl is an HF Dataset URL instead of a Firebase Storage URL.
   */
  async downloadFileFromUrl(url: string, localPath: string): Promise<void> {
    // If it's a Firebase Storage URL, use the old method
    if (url.startsWith('gs://') || url.includes('firebasestorage') || url.includes('storage.googleapis')) {
      return this.downloadFile(url, localPath);
    }
    // Otherwise download via HTTP (HF Dataset, file.io, etc.)
    logger.info(`FirebaseManager: downloading "${url}" β†’ "${localPath}"`);
    const response = await axios.get<NodeJS.ReadableStream>(url, {
      responseType: 'stream',
      timeout: 120_000,
    });
    const writer = fs.createWriteStream(localPath);
    await pipeline(response.data, writer);
    logger.info(`FirebaseManager: download complete`);
  }

  /**
   * Uploads a local file to Firebase Storage.
   * @returns A signed URL valid for 7 days.
   */
  async uploadFile(
    localPath: string,
    destinationPath: string,
    contentType = 'application/zip'
  ): Promise<string> {
    logger.info(`FirebaseManager: uploading "${localPath}" β†’ "${destinationPath}"`);

    await this.bucket.upload(localPath, {
      destination: destinationPath,
      metadata: { contentType },
    });

    return this.getSignedUrl(destinationPath);
  }

  /**
   * Uploads a Buffer directly to Firebase Storage without touching disk.
   * @returns A signed URL valid for 7 days.
   */
  async uploadBuffer(
    buffer: Buffer,
    destinationPath: string,
    contentType = 'application/zip'
  ): Promise<string> {
    logger.info(`FirebaseManager: uploading buffer (${buffer.length} bytes) β†’ "${destinationPath}"`);
    const file = this.bucket.file(destinationPath);

    await new Promise<void>((resolve, reject) => {
      const stream = file.createWriteStream({ metadata: { contentType } });
      stream.on('error', reject);
      stream.on('finish', resolve);
      stream.end(buffer);
    });

    return this.getSignedUrl(destinationPath);
  }

  // ─── Private Helpers ─────────────────────────────────────────────────────

  /** Generates a 7-day signed download URL for a storage path. */
  private async getSignedUrl(storagePath: string): Promise<string> {
    const [url] = await this.bucket.file(storagePath).getSignedUrl({
      action: 'read',
      expires: Date.now() + 7 * 24 * 60 * 60 * 1000, // 7 days
    });
    return url;
  }

  /**
   * Normalises various Firebase Storage URL formats to a raw storage path.
   *
   * Handles:
   *   gs://bucket-name/path/to/file.zip
   *   https://storage.googleapis.com/bucket/path/to/file.zip
   *   https://firebasestorage.googleapis.com/v0/b/bucket/o/path%2Fto%2Ffile.zip?token=…
   */
  private parseStoragePath(url: string): string {
    // gs:// protocol
    if (url.startsWith('gs://')) {
      const withoutScheme = url.slice(5); // strip "gs://"
      const firstSlash = withoutScheme.indexOf('/');
      return firstSlash === -1 ? withoutScheme : withoutScheme.slice(firstSlash + 1);
    }

    // Public Google Storage CDN URL
    if (url.includes('storage.googleapis.com/')) {
      const u = new URL(url);
      // pathname = /bucket-name/path/to/file β€” drop first two segments
      const parts = u.pathname.split('/').filter(Boolean);
      return parts.slice(1).join('/');
    }

    // Firebase Storage REST URL
    if (url.includes('firebasestorage.googleapis.com')) {
      const u = new URL(url);
      const oSegment = u.pathname.split('/o/')[1];
      if (oSegment) return decodeURIComponent(oSegment.split('?')[0]);
    }

    // Fallback: treat as a raw storage path
    return url;
  }
}

// ═══════════════════════════════════════════════════════════════════════════
//  QUEUE TYPES  (added for titan_builder_queue β€” existing types unchanged)
// ═══════════════════════════════════════════════════════════════════════════

/** The Firestore collection that acts as the job message broker. */
export const QUEUE_COLLECTION = 'titan_builder_queue';

export type QueueStatus =
  | 'pending'
  | 'processing'
  | 'building'
  | 'completed'
  | 'failed';

export type QueueJobType = 'transpile' | 'build' | 'webview';

/**
 * Shape of a document in `titan_builder_queue`.
 * Clients (mobile app, web dashboard) write this; the worker reads it.
 */
export interface QueueDoc {
  id: string;
  type: QueueJobType;
  status: QueueStatus;

  // ── 'transpile' fields ─────────────────────────────────────────────────
  /** HF Dataset URL or HTTPS URL pointing to the ZIP */
  zipUrl?: string;
  appName?: string;

  // ── 'build' fields ─────────────────────────────────────────────────────
  /** Flutter ZIP as base64 string */
  zipBase64?: string;

  // ── 'webview' fields ───────────────────────────────────────────────────
  url?: string;
  iconUrl?: string;
  iconBase64?: string;
  iconMime?: string;
  enableCamera?: boolean;
  enableStorage?: boolean;
  enableLocation?: boolean;
  enableNotifications?: boolean;
  buildType?: string;
  manifestUrl?: string;
  templateType?: string;

  // ── Result fields (set by the worker) ──────────────────────────────────
  /** HF Dataset public download URL for the output ZIP */
  resultZipUrl?: string;
  errorMessage?: string;
  createdAt: admin.firestore.Timestamp;
  updatedAt?: admin.firestore.Timestamp;
}

export interface QueueDocUpdate {
  status?: QueueStatus;
  resultZipUrl?: string;
  errorMessage?: string;
}