@telepat/rilo 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (66) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +209 -0
  3. package/index.js +1 -0
  4. package/models/black-forest-labs__flux-2-pro.json +78 -0
  5. package/models/black-forest-labs__flux-schnell.json +95 -0
  6. package/models/bytedance__seedream-4.json +71 -0
  7. package/models/deepseek-ai__deepseek-v3.json +61 -0
  8. package/models/google__nano-banana-pro.json +92 -0
  9. package/models/google__veo-3.1-fast.json +93 -0
  10. package/models/google__veo-3.1.json +93 -0
  11. package/models/jaaari__kokoro-82m.json +86 -0
  12. package/models/kwaivgi__kling-v3-video.json +101 -0
  13. package/models/minimax__speech-02-turbo.json +141 -0
  14. package/models/pixverse__pixverse-v5.6.json +113 -0
  15. package/models/prunaai__z-image-turbo.json +107 -0
  16. package/models/resemble-ai__chatterbox-turbo.json +102 -0
  17. package/models/wan-video__wan-2.2-i2v-fast.json +139 -0
  18. package/package.json +67 -0
  19. package/src/api/firebaseFunction.js +46 -0
  20. package/src/api/middleware/auth.js +70 -0
  21. package/src/api/openapi/generateOpenApi.js +21 -0
  22. package/src/api/openapi/spec.js +831 -0
  23. package/src/api/routes/jobs.js +45 -0
  24. package/src/api/routes/projectAssets.js +63 -0
  25. package/src/api/routes/projects.js +647 -0
  26. package/src/api/routes/webhooks.js +13 -0
  27. package/src/api/server.js +88 -0
  28. package/src/backends/firebaseClient.js +57 -0
  29. package/src/backends/outputBackend.js +186 -0
  30. package/src/backends/projectMetadataBackend.js +550 -0
  31. package/src/cli/commands/openHome.js +70 -0
  32. package/src/cli/commands/settingsFlow.js +196 -0
  33. package/src/cli/index.js +192 -0
  34. package/src/config/env.js +158 -0
  35. package/src/config/keystore.js +175 -0
  36. package/src/config/models.js +281 -0
  37. package/src/config/settingsSchema.js +214 -0
  38. package/src/media/ffmpeg.js +144 -0
  39. package/src/media/files.js +77 -0
  40. package/src/media/subtitles.js +444 -0
  41. package/src/observability/apiTrace.js +17 -0
  42. package/src/observability/logger.js +7 -0
  43. package/src/observability/metrics.js +10 -0
  44. package/src/pipeline/inputSanitizer.js +6 -0
  45. package/src/pipeline/orchestrator.js +1669 -0
  46. package/src/policy/contentGuardrails.js +30 -0
  47. package/src/providers/predictions.js +188 -0
  48. package/src/providers/replicateClient.js +12 -0
  49. package/src/steps/alignSubtitles.js +156 -0
  50. package/src/steps/burnInSubtitles.js +22 -0
  51. package/src/steps/composeFinalVideo.js +57 -0
  52. package/src/steps/generateKeyframes.js +70 -0
  53. package/src/steps/generateVideoSegments.js +95 -0
  54. package/src/steps/generateVoiceover.js +128 -0
  55. package/src/steps/imageToVideoAdapters.js +100 -0
  56. package/src/steps/script.js +177 -0
  57. package/src/steps/textToImageAdapters.js +87 -0
  58. package/src/store/assetStore.js +5 -0
  59. package/src/store/jobStore.js +102 -0
  60. package/src/store/projectAnalyticsStore.js +625 -0
  61. package/src/store/projectStore.js +684 -0
  62. package/src/store/settingsStore.js +155 -0
  63. package/src/store/staleAssetStore.js +63 -0
  64. package/src/types/job.js +28 -0
  65. package/src/types/media.js +28 -0
  66. package/src/worker/processor.js +24 -0
@@ -0,0 +1,625 @@
1
+ import fs from 'node:fs/promises';
2
+ import path from 'node:path';
3
+ import crypto from 'node:crypto';
4
+ import { ensureDir, writeJson } from '../media/files.js';
5
+ import { getProjectDir } from './projectStore.js';
6
+ import { MODEL_METADATA, MODEL_PRICING } from '../config/models.js';
7
+
8
+ const RUNS_DIR = 'analytics/runs';
9
+
10
+ const STAGE_ORDER = [
11
+ 'script',
12
+ 'voiceover',
13
+ 'keyframes',
14
+ 'segments',
15
+ 'compose'
16
+ ];
17
+
18
+ function stageSkeleton() {
19
+ return {
20
+ status: 'pending',
21
+ executed: false,
22
+ reused: false,
23
+ startedAt: null,
24
+ completedAt: null,
25
+ durationMs: 0,
26
+ predictionCount: 0,
27
+ tokenUsage: {
28
+ input: null,
29
+ output: null,
30
+ total: null
31
+ },
32
+ costUsd: null,
33
+ details: null,
34
+ error: null,
35
+ predictions: []
36
+ };
37
+ }
38
+
39
+ function numberOrNull(value) {
40
+ return Number.isFinite(value) ? value : null;
41
+ }
42
+
43
+ function resolveAnalyticsStageName(stage) {
44
+ if (stage === 'shots') {
45
+ return 'script';
46
+ }
47
+ if (stage === 'keyframe') {
48
+ return 'keyframes';
49
+ }
50
+ if (stage === 'segment') {
51
+ return 'segments';
52
+ }
53
+ return stage;
54
+ }
55
+
56
+ function estimateCostFromPricingRules(modelId, input = {}, entry = {}) {
57
+ const metadata = MODEL_METADATA[modelId] || {};
58
+ const pricingRules = metadata.pricingRules || {};
59
+ const tiers = Array.isArray(pricingRules.tiers) ? pricingRules.tiers : [];
60
+
61
+ if (pricingRules.basis === 'output_image_count') {
62
+ const usdPerImage = Number.parseFloat(pricingRules.usdPerImage);
63
+ if (!Number.isFinite(usdPerImage)) {
64
+ return null;
65
+ }
66
+
67
+ const outputCount = Array.isArray(entry.output) && entry.output.length > 0 ? entry.output.length : 1;
68
+ return usdPerImage * outputCount;
69
+ }
70
+
71
+ if (pricingRules.basis === 'output_image_resolution') {
72
+ const resolution = String(input.resolution || '').trim();
73
+ if (!resolution) {
74
+ return null;
75
+ }
76
+
77
+ const tier = tiers.find(
78
+ (candidate) => String(candidate.resolution || '').toLowerCase() === resolution.toLowerCase()
79
+ );
80
+ if (!tier) {
81
+ return null;
82
+ }
83
+
84
+ const usdPerImage = Number.parseFloat(tier.usdPerImage);
85
+ if (!Number.isFinite(usdPerImage)) {
86
+ return null;
87
+ }
88
+
89
+ const outputCount = Array.isArray(entry.output) && entry.output.length > 0 ? entry.output.length : 1;
90
+ return usdPerImage * outputCount;
91
+ }
92
+
93
+ if (tiers.length === 0) {
94
+ return null;
95
+ }
96
+
97
+ if (pricingRules.basis === 'output_video') {
98
+ const durationSecRaw = Number.parseFloat(input.duration);
99
+ const durationSec = Number.isFinite(durationSecRaw) ? durationSecRaw : 5;
100
+
101
+ const quality = String(input.quality || '').toLowerCase();
102
+ if (quality) {
103
+ const tier = tiers.find((candidate) => String(candidate.quality || '').toLowerCase() === quality);
104
+ if (tier) {
105
+ const usdPerSecond = Number.parseFloat(tier.usdPerSecond);
106
+ if (Number.isFinite(usdPerSecond) && Number.isFinite(durationSec)) {
107
+ return usdPerSecond * durationSec;
108
+ }
109
+ }
110
+ }
111
+
112
+ const mode = String(input.mode || '').toLowerCase();
113
+ if (mode) {
114
+ const generateAudio = Boolean(input.generate_audio);
115
+ let tier = tiers.find((candidate) =>
116
+ String(candidate.mode || '').toLowerCase() === mode
117
+ && Boolean(candidate.generateAudio) === generateAudio
118
+ );
119
+
120
+ if (!tier) {
121
+ tier = tiers.find((candidate) => String(candidate.mode || '').toLowerCase() === mode);
122
+ }
123
+
124
+ if (tier) {
125
+ const usdPerSecond = Number.parseFloat(tier.usdPerSecond);
126
+ if (Number.isFinite(usdPerSecond) && Number.isFinite(durationSec)) {
127
+ return usdPerSecond * durationSec;
128
+ }
129
+ }
130
+ }
131
+
132
+ const generateAudio = Boolean(input.generate_audio || input.generate_audio_switch);
133
+ const audioTier = tiers.find(
134
+ (candidate) => Object.prototype.hasOwnProperty.call(candidate, 'generateAudio')
135
+ && Boolean(candidate.generateAudio) === generateAudio
136
+ && Number.isFinite(Number.parseFloat(candidate.usdPerSecond))
137
+ );
138
+ if (audioTier) {
139
+ return Number.parseFloat(audioTier.usdPerSecond) * durationSec;
140
+ }
141
+
142
+ const resolution = String(input.resolution || '').toLowerCase();
143
+ const variant = input.interpolate_output ? 'interpolate' : 'base';
144
+
145
+ let tier = tiers.find(
146
+ (candidate) =>
147
+ String(candidate.resolution || '').toLowerCase() === resolution &&
148
+ String(candidate.variant || '').toLowerCase() === variant
149
+ );
150
+
151
+ if (!tier) {
152
+ tier = tiers.find((candidate) => String(candidate.resolution || '').toLowerCase() === resolution);
153
+ }
154
+
155
+ if (!tier) {
156
+ tier = tiers.find((candidate) => String(candidate.variant || '').toLowerCase() === variant);
157
+ }
158
+
159
+ /* c8 ignore next 3 */
160
+ if (!tier) {
161
+ return null;
162
+ }
163
+
164
+ const usdPerVideo = Number.parseFloat(tier.usdPerVideo);
165
+ return Number.isFinite(usdPerVideo) ? usdPerVideo : null;
166
+ }
167
+
168
+ if (pricingRules.basis === 'output_image_megapixels') {
169
+ const width = Number.parseFloat(input.width);
170
+ const height = Number.parseFloat(input.height);
171
+ if (!Number.isFinite(width) || !Number.isFinite(height) || width <= 0 || height <= 0) {
172
+ return null;
173
+ }
174
+
175
+ const megapixels = (width * height) / 1_000_000;
176
+ const sortedTiers = [...tiers].sort((a, b) => Number.parseFloat(a.maxMegapixels) - Number.parseFloat(b.maxMegapixels));
177
+ let tier = sortedTiers.find((candidate) => {
178
+ const maxMegapixels = Number.parseFloat(candidate.maxMegapixels);
179
+ return Number.isFinite(maxMegapixels) && megapixels <= maxMegapixels;
180
+ });
181
+
182
+ if (!tier) {
183
+ tier = sortedTiers[sortedTiers.length - 1] || null;
184
+ }
185
+
186
+ if (!tier) {
187
+ return null;
188
+ }
189
+
190
+ const usdPerImage = Number.parseFloat(tier.usdPerImage);
191
+ if (!Number.isFinite(usdPerImage)) {
192
+ return null;
193
+ }
194
+
195
+ const outputCount = Array.isArray(entry.output) && entry.output.length > 0 ? entry.output.length : 1;
196
+ return usdPerImage * outputCount;
197
+ }
198
+
199
+ return null;
200
+ }
201
+
202
+ async function pathExists(targetPath) {
203
+ try {
204
+ await fs.access(targetPath);
205
+ return true;
206
+ } catch {
207
+ return false;
208
+ }
209
+ }
210
+
211
+ function getRunsDir(project) {
212
+ return path.join(getProjectDir(project), RUNS_DIR);
213
+ }
214
+
215
+ function getRunFilePath(project, runId) {
216
+ return path.join(getRunsDir(project), `${runId}.json`);
217
+ }
218
+
219
+ export function createRunId() {
220
+ return crypto.randomUUID();
221
+ }
222
+
223
+ export function createRunRecord({ runId, project, jobId, forceRestart }) {
224
+ const now = new Date().toISOString();
225
+ const stages = {};
226
+ for (const stage of STAGE_ORDER) {
227
+ stages[stage] = stageSkeleton();
228
+ }
229
+
230
+ return {
231
+ runId,
232
+ project,
233
+ jobId,
234
+ forceRestart,
235
+ status: 'running',
236
+ error: null,
237
+ invokedAt: now,
238
+ completedAt: null,
239
+ totalDurationMs: 0,
240
+ stageOrder: STAGE_ORDER,
241
+ stages,
242
+ totals: {
243
+ predictionCount: 0,
244
+ tokenUsage: {
245
+ input: null,
246
+ output: null,
247
+ total: null
248
+ },
249
+ costUsd: null
250
+ }
251
+ };
252
+ }
253
+
254
+ export function markStageStarted(runRecord, stage, details = null) {
255
+ const next = structuredClone(runRecord);
256
+ const current = next.stages[stage] || stageSkeleton();
257
+ if (!current.startedAt) {
258
+ current.startedAt = new Date().toISOString();
259
+ }
260
+ current.status = 'running';
261
+ current.details = details || current.details;
262
+ next.stages[stage] = current;
263
+ return next;
264
+ }
265
+
266
+ export function markStageFinished(runRecord, stage, payload = {}) {
267
+ const next = structuredClone(runRecord);
268
+ const current = next.stages[stage] || stageSkeleton();
269
+ const completedAt = new Date().toISOString();
270
+ const startedMs = current.startedAt ? Date.parse(current.startedAt) : NaN;
271
+ const completedMs = Date.parse(completedAt);
272
+
273
+ current.completedAt = completedAt;
274
+ current.durationMs = Number.isFinite(startedMs) ? Math.max(0, completedMs - startedMs) : 0;
275
+ current.status = payload.status || 'succeeded';
276
+ current.executed = Boolean(payload.executed);
277
+ current.reused = Boolean(payload.reused);
278
+ current.error = payload.error || null;
279
+ current.details = payload.details || current.details;
280
+ next.stages[stage] = current;
281
+ return next;
282
+ }
283
+
284
+ export function markStageReused(runRecord, stage, details = null) {
285
+ const next = structuredClone(runRecord);
286
+ const current = next.stages[stage] || stageSkeleton();
287
+ const now = new Date().toISOString();
288
+ current.status = 'reused';
289
+ current.executed = false;
290
+ current.reused = true;
291
+ current.startedAt = current.startedAt || now;
292
+ current.completedAt = now;
293
+ current.durationMs = current.durationMs || 0;
294
+ current.details = details || current.details;
295
+ next.stages[stage] = current;
296
+ return next;
297
+ }
298
+
299
+ function normalizePrediction(entry) {
300
+ const prediction = entry.prediction || {};
301
+ const metrics = prediction.metrics || {};
302
+ const trace = entry.trace || {};
303
+ const input = entry.input || {};
304
+
305
+ const inputTokens = numberOrNull(
306
+ metrics.input_token_count ?? metrics.input_tokens ?? metrics.prompt_tokens
307
+ );
308
+ const outputTokens = numberOrNull(
309
+ metrics.output_token_count ?? metrics.output_tokens ?? metrics.completion_tokens
310
+ );
311
+ const totalTokens = numberOrNull(
312
+ metrics.total_tokens ?? metrics.token_count ?? (Number.isFinite(inputTokens) && Number.isFinite(outputTokens) ? inputTokens + outputTokens : NaN)
313
+ );
314
+
315
+ const predictTimeSec = numberOrNull(metrics.predict_time ?? metrics.predict_time_seconds);
316
+ const totalTimeSec = numberOrNull(metrics.total_time ?? metrics.total_time_seconds);
317
+
318
+ const modelPricing = MODEL_PRICING[entry.model] || {};
319
+ const rulesCost = estimateCostFromPricingRules(entry.model, input, entry);
320
+ const runtimeCost = Number.isFinite(modelPricing.usdPerSecond) && Number.isFinite(predictTimeSec)
321
+ ? modelPricing.usdPerSecond * predictTimeSec
322
+ : null;
323
+
324
+ const tokenInputCost = Number.isFinite(modelPricing.usdPer1kInputTokens) && Number.isFinite(inputTokens)
325
+ ? (inputTokens / 1000) * modelPricing.usdPer1kInputTokens
326
+ : null;
327
+
328
+ const tokenOutputCost = Number.isFinite(modelPricing.usdPer1kOutputTokens) && Number.isFinite(outputTokens)
329
+ ? (outputTokens / 1000) * modelPricing.usdPer1kOutputTokens
330
+ : null;
331
+
332
+ let tokenCost = null;
333
+ if (Number.isFinite(tokenInputCost) || Number.isFinite(tokenOutputCost)) {
334
+ tokenCost = (Number.isFinite(tokenInputCost) ? tokenInputCost : 0)
335
+ + (Number.isFinite(tokenOutputCost) ? tokenOutputCost : 0);
336
+ }
337
+
338
+ const costUsd = Number.isFinite(rulesCost)
339
+ ? rulesCost
340
+ : Number.isFinite(runtimeCost)
341
+ ? runtimeCost
342
+ : tokenCost;
343
+
344
+ let costSource = 'unavailable';
345
+ if (Number.isFinite(rulesCost)) {
346
+ costSource = 'pricing_rules';
347
+ } else if (Number.isFinite(runtimeCost) || Number.isFinite(tokenCost)) {
348
+ costSource = 'model_pricing_table';
349
+ }
350
+
351
+ return {
352
+ predictionId: entry.predictionId || prediction.id || null,
353
+ model: entry.model || null,
354
+ status: entry.status || prediction.status || null,
355
+ stage: resolveAnalyticsStageName(trace.step || 'unknown'),
356
+ index: Number.isInteger(trace.index) ? trace.index : null,
357
+ createdAt: prediction.createdAt || null,
358
+ startedAt: prediction.startedAt || null,
359
+ completedAt: prediction.completedAt || null,
360
+ predictTimeSec,
361
+ totalTimeSec,
362
+ tokenUsage: {
363
+ input: inputTokens,
364
+ output: outputTokens,
365
+ total: totalTokens
366
+ },
367
+ costUsd,
368
+ costSource
369
+ };
370
+ }
371
+
372
+ function aggregateStagePredictions(stageRecord, predictions) {
373
+ const next = structuredClone(stageRecord);
374
+ next.predictions = predictions;
375
+ next.predictionCount = predictions.length;
376
+
377
+ let stageInput = 0;
378
+ let stageOutput = 0;
379
+ let stageTotal = 0;
380
+ let hasInput = false;
381
+ let hasOutput = false;
382
+ let hasTotal = false;
383
+ let stageCost = 0;
384
+ let hasCost = false;
385
+
386
+ for (const prediction of predictions) {
387
+ if (Number.isFinite(prediction.tokenUsage.input)) {
388
+ hasInput = true;
389
+ stageInput += prediction.tokenUsage.input;
390
+ }
391
+ if (Number.isFinite(prediction.tokenUsage.output)) {
392
+ hasOutput = true;
393
+ stageOutput += prediction.tokenUsage.output;
394
+ }
395
+ if (Number.isFinite(prediction.tokenUsage.total)) {
396
+ hasTotal = true;
397
+ stageTotal += prediction.tokenUsage.total;
398
+ }
399
+ if (Number.isFinite(prediction.costUsd)) {
400
+ hasCost = true;
401
+ stageCost += prediction.costUsd;
402
+ }
403
+ }
404
+
405
+ next.tokenUsage = {
406
+ input: hasInput ? stageInput : null,
407
+ output: hasOutput ? stageOutput : null,
408
+ total: hasTotal ? stageTotal : null
409
+ };
410
+ next.costUsd = hasCost ? stageCost : null;
411
+
412
+ return next;
413
+ }
414
+
415
+ async function readJsonLines(filePath) {
416
+ if (!(await pathExists(filePath))) {
417
+ return [];
418
+ }
419
+
420
+ const raw = await fs.readFile(filePath, 'utf8');
421
+ return raw
422
+ .split('\n')
423
+ .map((line) => line.trim())
424
+ .filter(Boolean)
425
+ .map((line) => {
426
+ try {
427
+ return JSON.parse(line);
428
+ } catch {
429
+ /* c8 ignore next */
430
+ return null;
431
+ }
432
+ })
433
+ .filter(Boolean);
434
+ }
435
+
436
+ export async function collectRunPredictions(projectDir, runId) {
437
+ const tracePath = path.join(projectDir, 'assets', 'debug', 'api-requests.jsonl');
438
+ const entries = await readJsonLines(tracePath);
439
+ return entries
440
+ .filter(
441
+ (entry) =>
442
+ (entry.type === 'request_succeeded' || entry.type === 'request_failed') &&
443
+ entry.trace?.runId === runId
444
+ )
445
+ .map((entry) => normalizePrediction(entry));
446
+ }
447
+
448
+ export function finalizeRunRecord(runRecord, predictions, outcome = {}) {
449
+ const next = structuredClone(runRecord);
450
+ const byStage = new Map();
451
+
452
+ for (const prediction of predictions) {
453
+ const stage = prediction.stage || 'unknown';
454
+ const stagePredictions = byStage.get(stage) || [];
455
+ stagePredictions.push(prediction);
456
+ byStage.set(stage, stagePredictions);
457
+ }
458
+
459
+ for (const stage of STAGE_ORDER) {
460
+ const existing = next.stages[stage] || stageSkeleton();
461
+ next.stages[stage] = aggregateStagePredictions(existing, byStage.get(stage) || []);
462
+ }
463
+
464
+ const invokedMs = Date.parse(next.invokedAt);
465
+ const completedAt = new Date().toISOString();
466
+ const completedMs = Date.parse(completedAt);
467
+
468
+ let totalPredictionCount = 0;
469
+ let totalCost = 0;
470
+ let totalInputTokens = 0;
471
+ let totalOutputTokens = 0;
472
+ let totalTokens = 0;
473
+ let hasCost = false;
474
+ let hasInput = false;
475
+ let hasOutput = false;
476
+ let hasTotal = false;
477
+
478
+ for (const stage of STAGE_ORDER) {
479
+ const stageEntry = next.stages[stage];
480
+ totalPredictionCount += stageEntry.predictionCount || 0;
481
+ if (Number.isFinite(stageEntry.costUsd)) {
482
+ hasCost = true;
483
+ totalCost += stageEntry.costUsd;
484
+ }
485
+ if (Number.isFinite(stageEntry.tokenUsage.input)) {
486
+ hasInput = true;
487
+ totalInputTokens += stageEntry.tokenUsage.input;
488
+ }
489
+ if (Number.isFinite(stageEntry.tokenUsage.output)) {
490
+ hasOutput = true;
491
+ totalOutputTokens += stageEntry.tokenUsage.output;
492
+ }
493
+ if (Number.isFinite(stageEntry.tokenUsage.total)) {
494
+ hasTotal = true;
495
+ totalTokens += stageEntry.tokenUsage.total;
496
+ }
497
+ }
498
+
499
+ next.status = outcome.status || 'completed';
500
+ next.error = outcome.error || null;
501
+ next.completedAt = completedAt;
502
+ next.totalDurationMs = Number.isFinite(invokedMs) ? Math.max(0, completedMs - invokedMs) : 0;
503
+ next.totals = {
504
+ predictionCount: totalPredictionCount,
505
+ tokenUsage: {
506
+ input: hasInput ? totalInputTokens : null,
507
+ output: hasOutput ? totalOutputTokens : null,
508
+ total: hasTotal ? totalTokens : null
509
+ },
510
+ costUsd: hasCost ? totalCost : null
511
+ };
512
+
513
+ return next;
514
+ }
515
+
516
+ export async function writeRunRecord(project, runRecord) {
517
+ const runsDir = getRunsDir(project);
518
+ await ensureDir(runsDir);
519
+ await writeJson(getRunFilePath(project, runRecord.runId), runRecord);
520
+ }
521
+
522
+ export async function readRunRecord(project, runId) {
523
+ const filePath = getRunFilePath(project, runId);
524
+ if (!(await pathExists(filePath))) {
525
+ return null;
526
+ }
527
+
528
+ const raw = await fs.readFile(filePath, 'utf8');
529
+ return JSON.parse(raw);
530
+ }
531
+
532
+ export async function listRunRecords(project) {
533
+ const runsDir = getRunsDir(project);
534
+ if (!(await pathExists(runsDir))) {
535
+ return [];
536
+ }
537
+
538
+ const entries = await fs.readdir(runsDir, { withFileTypes: true });
539
+ const files = entries.filter((entry) => entry.isFile() && entry.name.endsWith('.json'));
540
+
541
+ const runs = [];
542
+ for (const file of files) {
543
+ try {
544
+ const raw = await fs.readFile(path.join(runsDir, file.name), 'utf8');
545
+ runs.push(JSON.parse(raw));
546
+ } catch {
547
+ continue;
548
+ }
549
+ }
550
+
551
+ return runs.sort((a, b) => String(b.invokedAt || '').localeCompare(String(a.invokedAt || '')));
552
+ }
553
+
554
+ export function summarizeRun(runRecord) {
555
+ const stages = {};
556
+ for (const stage of STAGE_ORDER) {
557
+ const source = runRecord.stages?.[stage] || stageSkeleton();
558
+ stages[stage] = {
559
+ status: source.status,
560
+ executed: source.executed,
561
+ reused: source.reused,
562
+ durationMs: source.durationMs,
563
+ predictionCount: source.predictionCount,
564
+ tokenUsage: source.tokenUsage,
565
+ costUsd: source.costUsd
566
+ };
567
+ }
568
+
569
+ return {
570
+ runId: runRecord.runId,
571
+ jobId: runRecord.jobId,
572
+ project: runRecord.project,
573
+ status: runRecord.status,
574
+ error: runRecord.error,
575
+ invokedAt: runRecord.invokedAt,
576
+ completedAt: runRecord.completedAt,
577
+ totalDurationMs: runRecord.totalDurationMs,
578
+ forceRestart: runRecord.forceRestart,
579
+ totals: runRecord.totals,
580
+ stages
581
+ };
582
+ }
583
+
584
+ export function summarizeProjectAnalytics(runs) {
585
+ const summaries = runs.map((run) => summarizeRun(run));
586
+
587
+ let completedRuns = 0;
588
+ let failedRuns = 0;
589
+ let totalDurationMs = 0;
590
+ let totalPredictions = 0;
591
+ let totalCostUsd = 0;
592
+ let hasCost = false;
593
+
594
+ for (const run of summaries) {
595
+ if (run.status === 'completed') {
596
+ completedRuns += 1;
597
+ }
598
+ if (run.status === 'failed') {
599
+ failedRuns += 1;
600
+ }
601
+
602
+ if (Number.isFinite(run.totalDurationMs)) {
603
+ totalDurationMs += run.totalDurationMs;
604
+ }
605
+
606
+ totalPredictions += run.totals?.predictionCount || 0;
607
+ if (Number.isFinite(run.totals?.costUsd)) {
608
+ hasCost = true;
609
+ totalCostUsd += run.totals.costUsd;
610
+ }
611
+ }
612
+
613
+ const averageDurationMs = summaries.length > 0 ? Math.round(totalDurationMs / summaries.length) : 0;
614
+
615
+ return {
616
+ totalRuns: summaries.length,
617
+ completedRuns,
618
+ failedRuns,
619
+ totalDurationMs,
620
+ averageDurationMs,
621
+ totalPredictions,
622
+ totalCostUsd: hasCost ? totalCostUsd : null,
623
+ lastRun: summaries[0] || null
624
+ };
625
+ }