@gotcos/glasses-server 6.46.0 → 6.47.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 (39) hide show
  1. package/CHANGELOG.md +21 -0
  2. package/package.json +6 -2
  3. package/server/index.ts +76 -0
  4. package/server/lib/cos-operations-meetings.ts +99 -8
  5. package/server/lib/fireflies-client.ts +862 -0
  6. package/server/lib/fireflies-key.ts +182 -0
  7. package/server/lib/g2-ops-handoff.ts +15 -1
  8. package/server/lib/imported-library-rows.ts +616 -0
  9. package/server/lib/imported-meeting-library.ts +608 -0
  10. package/server/lib/maintenance-lifecycle.ts +14 -0
  11. package/server/lib/meeting-actions-store.ts +478 -0
  12. package/server/lib/meeting-actions.ts +2583 -0
  13. package/server/lib/meeting-corrections.ts +32 -1
  14. package/server/lib/meeting-decisions.ts +223 -0
  15. package/server/lib/meeting-engine/align.ts +167 -0
  16. package/server/lib/meeting-engine/attribute.ts +265 -0
  17. package/server/lib/meeting-engine/evidence.ts +428 -0
  18. package/server/lib/meeting-engine/pairing.ts +327 -0
  19. package/server/lib/meeting-engine/render.ts +694 -0
  20. package/server/lib/meeting-engine/split.ts +242 -0
  21. package/server/lib/meeting-engine/worker.ts +238 -0
  22. package/server/lib/meeting-engine-mode.ts +197 -0
  23. package/server/lib/meeting-file-guards.ts +141 -0
  24. package/server/lib/meeting-import.ts +763 -0
  25. package/server/lib/meeting-library-search.ts +146 -11
  26. package/server/lib/meeting-parse.ts +184 -0
  27. package/server/lib/meeting-store.ts +108 -275
  28. package/server/lib/meeting-suggestion-sides.ts +242 -0
  29. package/server/lib/morning-brief-runtime.ts +20 -8
  30. package/server/lib/pipeline-runner.ts +227 -0
  31. package/server/lib/voice-evidence-guard.ts +87 -0
  32. package/server/routes/fireflies-key.ts +102 -0
  33. package/server/routes/meeting-actions.ts +82 -0
  34. package/server/routes/meeting-engine.ts +52 -0
  35. package/server/routes/meeting-import.ts +67 -0
  36. package/server/routes/meeting-suggestions.ts +66 -0
  37. package/server/routes/meeting.ts +117 -10
  38. package/server/routes/meetings.ts +205 -37
  39. package/server/routes/voice.ts +18 -0
@@ -0,0 +1,763 @@
1
+ // The Fireflies importer: vendor meetings into the imported library.
2
+ //
3
+ // REFUSED ON A PIPELINE MAC. `meetingEngineMode()` decides, and on a Mac whose
4
+ // COS pipeline already files Fireflies meetings this returns 409
5
+ // operations_pipeline_owns_fireflies and writes nothing at all.
6
+ //
7
+ // HOW IT STOPS:
8
+ // - one run at a time (a second gets 409 import_in_progress);
9
+ // - the client's daily budget, plus a poll ceiling of half of it;
10
+ // - a window (7, 30 or 90 days) and the vendor's newest-first order, so a
11
+ // pass ends at the first meeting older than the window;
12
+ // - a sticky invalid_key that refuses further runs until the key changes;
13
+ // - a maintenance lease per page, and a drain defers the page instead of
14
+ // fighting it.
15
+ //
16
+ // THE LEASE IS HELD OVER THE WRITE, NOT THE FETCH, and that is deliberate. A
17
+ // page fetch can legitimately take 30 s, and with vendor 5xx retries (5, 15 and
18
+ // 30 s) far longer. COS Control's drain timeout is 90 s (main.swift
19
+ // waitForRestartProof), so holding the lease across the network would turn a
20
+ // slow vendor into a failed Update Server. Nothing is written during the fetch,
21
+ // and the cursor is durable, so a drain that lands mid-fetch costs one repeated
22
+ // page and no data.
23
+ //
24
+ // COUNTS COME FROM A RE-LIST, never from a counter this code increments. After
25
+ // each write the library must return the hash, or the run stops with
26
+ // write_unlisted: a writer that believes it wrote and a library that cannot see
27
+ // the record is the one failure that would otherwise be silent.
28
+
29
+ import { createHash, randomUUID } from 'node:crypto'
30
+ import { readFileSync } from 'node:fs'
31
+ import { dirname } from 'node:path'
32
+ import { durableAtomicWriteFileSync } from './atomic-fs.js'
33
+ import { getFirefliesBudget, getFirefliesClient, getFirefliesKeyStore } from './fireflies-key.js'
34
+ import {
35
+ FirefliesClient,
36
+ type FirefliesBudget,
37
+ type FirefliesFailureState,
38
+ type FirefliesKeyCheck,
39
+ type FirefliesPlan,
40
+ type FirefliesSkipReason,
41
+ type FirefliesTranscriptRecord,
42
+ normalizeFirefliesTranscript,
43
+ } from './fireflies-client.js'
44
+ import {
45
+ DEFAULT_ORIGIN_DOMAIN,
46
+ type ImportedMeetingLibrary,
47
+ type ImportedRecordWrite,
48
+ actionItemLines,
49
+ getImportedMeetingLibrary,
50
+ importHash,
51
+ importRecordId,
52
+ importsRoot,
53
+ localDateParts,
54
+ renderImportMarkdown,
55
+ } from './imported-meeting-library.js'
56
+ import { acquireMaintenanceWork, maintenanceAdmissionsOpen, type MaintenanceWorkLease } from './maintenance-lifecycle.js'
57
+ import { meetingEngineMode, type MeetingEngineMode } from './meeting-engine-mode.js'
58
+ import { triggerMeetingMergeRun } from './meeting-actions.js'
59
+ import { securePrivateDirectory } from './secure-user-config.js'
60
+
61
+ export const IMPORT_WINDOW_DAYS = [7, 30, 90] as const
62
+ export const DEFAULT_IMPORT_WINDOW_DAYS = 30
63
+ export const IMPORT_POLL_TICK_MS = 30_000
64
+ export const IMPORT_POLL_INTERVAL_MS = 6 * 60 * 60_000
65
+ /** A poll may spend at most this share of the day's cap. */
66
+ export const IMPORT_POLL_BUDGET_SHARE = 0.5
67
+ export const STILL_PROCESSING_BACKOFF_MS = 60 * 60_000
68
+ export const STILL_PROCESSING_MAX_ATTEMPTS = 24
69
+ /**
70
+ * Hard bound on one run, in pages.
71
+ *
72
+ * Every other stop condition depends on the vendor behaving: the window ends a
73
+ * pass only if a meeting is older than it, and the end of the list only if a
74
+ * page comes back short. The daily cap bounds the capped plans, but a Business
75
+ * plan has NO daily cap, so a vendor that answers every page with a full one
76
+ * would page forever. 200 pages is 10,000 meetings, far past a 90-day backfill.
77
+ */
78
+ export const IMPORT_MAX_PAGES_PER_RUN = 200
79
+ export const IMPORT_LEDGER_FILENAME = '.fireflies-ledger.json'
80
+ /** Bound on remembered hashes, so a ledger cannot grow without limit. */
81
+ export const IMPORT_LEDGER_MAX_HASHES = 20_000
82
+ export const MS_PER_DAY = 24 * 60 * 60_000
83
+
84
+ export type ImportState =
85
+ | 'idle'
86
+ | 'running'
87
+ | 'ok'
88
+ | 'partial'
89
+ | 'invalid_key'
90
+ | 'rate_limited'
91
+ | 'vendor_down'
92
+ | 'unreachable'
93
+ | 'write_unlisted'
94
+ | 'refused_pipeline'
95
+
96
+ export type ImportSkipReason =
97
+ | FirefliesSkipReason
98
+ | 'never_transcribed'
99
+ | 'duration_unknown'
100
+ | 'response_too_large'
101
+
102
+ /** What each skip reason means for the meeting behind it. */
103
+ export const IMPORT_SKIP_KIND: Record<ImportSkipReason, 'terminal' | 'retryable' | 'imported' | 'alarm'> = {
104
+ missing_id: 'terminal',
105
+ missing_date: 'terminal',
106
+ date_string: 'terminal',
107
+ malformed_sentences: 'terminal',
108
+ still_processing: 'retryable',
109
+ response_too_large: 'retryable',
110
+ never_transcribed: 'terminal',
111
+ id_shape_changed: 'alarm',
112
+ duration_unknown: 'imported',
113
+ }
114
+
115
+ export interface ImportRunSummary {
116
+ runId: string
117
+ trigger: 'manual' | 'poll'
118
+ windowDays: number
119
+ startedAt: string
120
+ finishedAt?: string
121
+ state: ImportState
122
+ stopReason?: string
123
+ pages: number
124
+ calls: number
125
+ written: number
126
+ seen: number
127
+ failure?: { state: FirefliesFailureState; httpStatus?: number; retryAfterSeconds?: number; code?: string }
128
+ }
129
+
130
+ interface ImportLedger {
131
+ version: 1
132
+ window: { days: number }
133
+ cursor: number
134
+ vendorSeen: string[]
135
+ completed: string[]
136
+ skipped: Record<string, string[]>
137
+ retryable: Record<string, { reason: ImportSkipReason; attempts: number; nextAt: string }>
138
+ lastRun?: ImportRunSummary
139
+ state: ImportState
140
+ keepImporting: boolean
141
+ keyFingerprint?: string
142
+ lastPassCompletedAt?: string
143
+ /** Set by a rate limit. The poll waits for it. */
144
+ nextAttemptAt?: string
145
+ }
146
+
147
+ export class ImportRefusedError extends Error {
148
+ constructor(readonly status: number, readonly code: string, message: string) {
149
+ super(message)
150
+ this.name = 'ImportRefusedError'
151
+ }
152
+ }
153
+
154
+ function emptyLedger(): ImportLedger {
155
+ return {
156
+ version: 1,
157
+ window: { days: DEFAULT_IMPORT_WINDOW_DAYS },
158
+ cursor: 0,
159
+ vendorSeen: [],
160
+ completed: [],
161
+ skipped: {},
162
+ retryable: {},
163
+ state: 'idle',
164
+ // D7: importing stays on once a key is connected. The poll still refuses
165
+ // without a key, in advise mode, and while admissions are closed.
166
+ keepImporting: true,
167
+ }
168
+ }
169
+
170
+ function bounded(values: string[]): string[] {
171
+ return values.length <= IMPORT_LEDGER_MAX_HASHES ? values : values.slice(values.length - IMPORT_LEDGER_MAX_HASHES)
172
+ }
173
+
174
+ /** A stable key for a transcript too broken to have an identity of its own. */
175
+ function unusableKey(raw: unknown): string {
176
+ const item = (raw && typeof raw === 'object' ? raw : {}) as Record<string, unknown>
177
+ const shape = JSON.stringify({
178
+ id: typeof item.id === 'string' ? item.id : null,
179
+ date: typeof item.date === 'number' || typeof item.date === 'string' ? item.date : null,
180
+ title: typeof item.title === 'string' ? item.title : null,
181
+ })
182
+ return createHash('sha256').update(`fireflies-unusable:${shape}`).digest('hex').slice(0, 16)
183
+ }
184
+
185
+ /** Vendor record to the pair of files the library writes. */
186
+ export function buildFirefliesImport(
187
+ record: FirefliesTranscriptRecord,
188
+ options: { originDomain?: string; importedAt: string },
189
+ ): ImportedRecordWrite {
190
+ const hash = importHash(record.id)
191
+ const parts = localDateParts(record.dateMs)
192
+ const originDomain = options.originDomain ?? DEFAULT_ORIGIN_DOMAIN
193
+ const speakers = [...new Set(record.sentences.map(sentence => sentence.speakerName).filter(Boolean))]
194
+ const attendees = record.participants.length > 0 ? record.participants : speakers
195
+
196
+ const actionItems = actionItemLines(record.actionItems)
197
+ const markdown = renderImportMarkdown({
198
+ title: record.title,
199
+ dateMs: record.dateMs,
200
+ durationSeconds: record.durationSeconds,
201
+ attendees,
202
+ ...(record.overview ? { overview: record.overview } : {}),
203
+ ...(actionItems.length > 0 ? { actionItems } : {}),
204
+ sentences: record.sentences.map(sentence => ({
205
+ speakerName: sentence.speakerName,
206
+ text: sentence.text,
207
+ startTime: sentence.startTime,
208
+ })),
209
+ })
210
+
211
+ return {
212
+ kind: 'fireflies',
213
+ hash,
214
+ dateMs: record.dateMs,
215
+ markdown,
216
+ // Key order matters: the list reads `originDomain` out of the first 4 KiB
217
+ // rather than parsing a sidecar that can run to megabytes, so the small
218
+ // fields go first and `sentences` goes last.
219
+ sidecar: {
220
+ schemaVersion: 1,
221
+ kind: 'fireflies',
222
+ recordId: importRecordId('fireflies', hash),
223
+ originDomain,
224
+ source: 'fireflies',
225
+ firefliesId: record.id,
226
+ date: parts.date,
227
+ time: parts.time,
228
+ dateMs: record.dateMs,
229
+ durationSeconds: record.durationSeconds,
230
+ durationSource: record.durationSource,
231
+ // The engine never pairs a meeting whose length is unknown: an interval
232
+ // with no end overlaps everything.
233
+ pairable: record.durationSeconds != null,
234
+ organizerEmail: record.organizerEmail,
235
+ participants: record.participants,
236
+ speakers,
237
+ sentenceCount: record.sentences.length,
238
+ importedAt: options.importedAt,
239
+ title: record.title,
240
+ // The vendor's own summary, kept unedited beside the rendered record.
241
+ // The markdown collapses `##` and `**` to keep a sentence from forging a
242
+ // section; the sidecar is where the original survives that.
243
+ overview: record.overview,
244
+ actionItems: record.actionItems,
245
+ keywords: record.keywords,
246
+ sentences: record.sentences,
247
+ },
248
+ }
249
+ }
250
+
251
+ export interface FirefliesImporterDeps {
252
+ library: ImportedMeetingLibrary
253
+ client: FirefliesClient
254
+ budget: FirefliesBudget
255
+ key: () => string | null
256
+ ledgerPath?: string
257
+ mode?: () => MeetingEngineMode
258
+ acquireLease?: () => MaintenanceWorkLease
259
+ admissionsOpen?: () => boolean
260
+ now?: () => number
261
+ /** WS4 hooks the engine runner here when a page is sealed. */
262
+ onPageSealed?: (summary: { runId: string; written: string[] }) => void
263
+ log?: (line: string) => void
264
+ }
265
+
266
+ export interface ImportStatus {
267
+ mode: MeetingEngineMode
268
+ state: ImportState
269
+ running: boolean
270
+ keyConfigured: boolean
271
+ keepImporting: boolean
272
+ planCap: FirefliesPlan
273
+ windowDays: number
274
+ cursor: number
275
+ counts: {
276
+ imported: number
277
+ vendorSeen: number
278
+ retryable: number
279
+ skipped: Record<string, number>
280
+ }
281
+ skipKinds: Record<string, string>
282
+ budget: { day: string; calls: number; cap: number | null; remaining: number | null }
283
+ lastRun?: ImportRunSummary
284
+ nextAttemptAt?: string
285
+ lastPassCompletedAt?: string
286
+ }
287
+
288
+ export class FirefliesImporter {
289
+ private readonly deps: FirefliesImporterDeps
290
+ private readonly ledgerPath: string
291
+ private readonly now: () => number
292
+ private readonly mode: () => MeetingEngineMode
293
+ private readonly acquireLease: () => MaintenanceWorkLease
294
+ private readonly admissionsOpen: () => boolean
295
+ private readonly log: (line: string) => void
296
+ private ledger: ImportLedger
297
+ private active: Promise<ImportRunSummary> | null = null
298
+ private stopRequested = false
299
+ private startedAtMs: number
300
+
301
+ constructor(deps: FirefliesImporterDeps) {
302
+ this.deps = deps
303
+ this.ledgerPath = deps.ledgerPath ?? importsRoot(IMPORT_LEDGER_FILENAME)
304
+ this.now = deps.now ?? (() => Date.now())
305
+ this.mode = deps.mode ?? meetingEngineMode
306
+ this.acquireLease = deps.acquireLease ?? (() => acquireMaintenanceWork('meeting_import'))
307
+ this.admissionsOpen = deps.admissionsOpen ?? maintenanceAdmissionsOpen
308
+ this.log = deps.log ?? ((line: string) => console.log(`[meeting-import] ${line}`))
309
+ this.ledger = this.load()
310
+ this.startedAtMs = this.now()
311
+ }
312
+
313
+ private load(): ImportLedger {
314
+ let raw: string
315
+ try {
316
+ raw = readFileSync(this.ledgerPath, 'utf8')
317
+ } catch {
318
+ return emptyLedger()
319
+ }
320
+ try {
321
+ const parsed = JSON.parse(raw) as Partial<ImportLedger>
322
+ const base = emptyLedger()
323
+ return {
324
+ ...base,
325
+ ...parsed,
326
+ version: 1,
327
+ window: { days: typeof parsed.window?.days === 'number' ? parsed.window.days : base.window.days },
328
+ cursor: typeof parsed.cursor === 'number' && parsed.cursor >= 0 ? Math.trunc(parsed.cursor) : 0,
329
+ vendorSeen: Array.isArray(parsed.vendorSeen) ? parsed.vendorSeen.filter(v => typeof v === 'string') : [],
330
+ completed: Array.isArray(parsed.completed) ? parsed.completed.filter(v => typeof v === 'string') : [],
331
+ skipped: parsed.skipped && typeof parsed.skipped === 'object' ? parsed.skipped : {},
332
+ retryable: parsed.retryable && typeof parsed.retryable === 'object' ? parsed.retryable : {},
333
+ keepImporting: parsed.keepImporting !== false,
334
+ // A run that was in flight when the process died is not running now.
335
+ state: parsed.state === 'running' ? 'partial' : (parsed.state ?? 'idle'),
336
+ }
337
+ } catch {
338
+ return emptyLedger()
339
+ }
340
+ }
341
+
342
+ private save(): void {
343
+ this.ledger.vendorSeen = bounded(this.ledger.vendorSeen)
344
+ this.ledger.completed = bounded(this.ledger.completed)
345
+ // The ledger's OWN directory, not the default imports root: the path is
346
+ // injectable, and securing a directory the ledger does not live in would
347
+ // both miss the real one and touch a tree this instance was told to leave
348
+ // alone.
349
+ securePrivateDirectory(dirname(this.ledgerPath))
350
+ durableAtomicWriteFileSync(this.ledgerPath, JSON.stringify(this.ledger, null, 2), { mode: 0o600 })
351
+ }
352
+
353
+ private keyFingerprint(): string | undefined {
354
+ const key = this.deps.key()
355
+ return key ? FirefliesClient.fingerprint(key) : undefined
356
+ }
357
+
358
+ /** A key that is known bad stays refused until it changes. */
359
+ private stickyInvalidKey(): boolean {
360
+ if (this.ledger.state !== 'invalid_key') return false
361
+ const fingerprint = this.keyFingerprint()
362
+ return fingerprint != null && fingerprint === this.ledger.keyFingerprint
363
+ }
364
+
365
+ onKeyChanged(): void {
366
+ if (this.ledger.state === 'invalid_key') {
367
+ this.ledger.state = 'idle'
368
+ delete this.ledger.keyFingerprint
369
+ this.save()
370
+ }
371
+ }
372
+
373
+ onKeyChecked(check: FirefliesKeyCheck): void {
374
+ if (check.state === 'ok' && this.ledger.state === 'invalid_key') {
375
+ this.ledger.state = 'idle'
376
+ delete this.ledger.keyFingerprint
377
+ this.save()
378
+ }
379
+ if (check.state === 'invalid_key') {
380
+ this.ledger.state = 'invalid_key'
381
+ this.ledger.keyFingerprint = this.keyFingerprint()
382
+ this.save()
383
+ }
384
+ }
385
+
386
+ isRunning(): boolean {
387
+ return this.active !== null
388
+ }
389
+
390
+ whenIdle(): Promise<unknown> {
391
+ return this.active ?? Promise.resolve()
392
+ }
393
+
394
+ stop(): void {
395
+ this.stopRequested = true
396
+ }
397
+
398
+ settings(patch: { keepImporting?: unknown; planCap?: unknown }): ImportStatus {
399
+ this.refuseInAdviseMode()
400
+ if (patch.keepImporting !== undefined) {
401
+ if (typeof patch.keepImporting !== 'boolean') {
402
+ throw new ImportRefusedError(400, 'invalid_keep_importing', 'keepImporting must be true or false.')
403
+ }
404
+ this.ledger.keepImporting = patch.keepImporting
405
+ }
406
+ if (patch.planCap !== undefined) {
407
+ if (patch.planCap !== 'free' && patch.planCap !== 'pro' && patch.planCap !== 'business') {
408
+ throw new ImportRefusedError(400, 'invalid_plan_cap', 'planCap must be free, pro or business.')
409
+ }
410
+ this.deps.budget.setPlan(patch.planCap)
411
+ }
412
+ this.save()
413
+ return this.status()
414
+ }
415
+
416
+ private refuseInAdviseMode(): void {
417
+ // Anything that is not `imports` is a Mac whose own pipeline owns Fireflies.
418
+ // Written as "not imports" rather than "is advise" because 6.47.0 added a
419
+ // THIRD mode, `apply`, and a pipeline Mac in apply mode must refuse for
420
+ // exactly the same reason an advise one does.
421
+ if (this.mode() !== 'imports') {
422
+ throw new ImportRefusedError(
423
+ 409,
424
+ 'operations_pipeline_owns_fireflies',
425
+ 'Your COS pipeline already brings in Fireflies meetings, so the server does not import them here.',
426
+ )
427
+ }
428
+ }
429
+
430
+ status(): ImportStatus {
431
+ const mode = this.mode()
432
+ const budget = this.deps.budget.snapshot()
433
+ const skipped: Record<string, number> = {}
434
+ for (const [reason, hashes] of Object.entries(this.ledger.skipped)) skipped[reason] = hashes.length
435
+ return {
436
+ mode,
437
+ state: mode !== 'imports' ? 'refused_pipeline' : this.active ? 'running' : this.ledger.state,
438
+ running: this.active !== null,
439
+ keyConfigured: this.deps.key() != null,
440
+ keepImporting: this.ledger.keepImporting,
441
+ planCap: budget.plan,
442
+ windowDays: this.ledger.window.days,
443
+ cursor: this.ledger.cursor,
444
+ counts: {
445
+ // Derived from the library, never from a counter here. Scoped to the
446
+ // imported kind so a later merge or split record cannot inflate it.
447
+ imported: this.deps.library.listHashes({ kind: 'fireflies' }).size,
448
+ vendorSeen: this.ledger.vendorSeen.length,
449
+ retryable: Object.keys(this.ledger.retryable).length,
450
+ skipped,
451
+ },
452
+ skipKinds: IMPORT_SKIP_KIND,
453
+ budget: { day: budget.day, calls: budget.calls, cap: budget.cap, remaining: budget.remaining },
454
+ ...(this.ledger.lastRun ? { lastRun: this.ledger.lastRun } : {}),
455
+ ...(this.ledger.nextAttemptAt ? { nextAttemptAt: this.ledger.nextAttemptAt } : {}),
456
+ ...(this.ledger.lastPassCompletedAt ? { lastPassCompletedAt: this.ledger.lastPassCompletedAt } : {}),
457
+ }
458
+ }
459
+
460
+ /** Start a run. Returns as soon as it is admitted; the work continues. */
461
+ run(options: { windowDays?: number; trigger?: 'manual' | 'poll'; callCeiling?: number } = {}): { runId: string } {
462
+ this.refuseInAdviseMode()
463
+ if (this.active) {
464
+ throw new ImportRefusedError(409, 'import_in_progress', 'An import is already running.')
465
+ }
466
+ if (!this.deps.key()) {
467
+ throw new ImportRefusedError(409, 'fireflies_key_missing', 'Add your Fireflies API key first.')
468
+ }
469
+ if (this.stickyInvalidKey()) {
470
+ throw new ImportRefusedError(409, 'invalid_key', 'Fireflies refused this key. Enter a new one to try again.')
471
+ }
472
+ if (!this.admissionsOpen()) {
473
+ throw new ImportRefusedError(503, 'maintenance_drain_active', 'The server is finishing a maintenance operation. Try again shortly.')
474
+ }
475
+ const windowDays = normalizeWindowDays(options.windowDays)
476
+ const runId = randomUUID()
477
+ this.stopRequested = false
478
+ const run = this.execute(runId, windowDays, options.trigger ?? 'manual', options.callCeiling)
479
+ .finally(() => { this.active = null })
480
+ this.active = run
481
+ // A rejected background run must never become an unhandled rejection.
482
+ void run.catch(error => this.log(`run failed: ${error instanceof Error ? error.message : String(error)}`))
483
+ return { runId }
484
+ }
485
+
486
+ private async execute(
487
+ runId: string,
488
+ windowDays: number,
489
+ trigger: 'manual' | 'poll',
490
+ callCeiling?: number,
491
+ ): Promise<ImportRunSummary> {
492
+ const startedAt = new Date(this.now()).toISOString()
493
+ const summary: ImportRunSummary = {
494
+ runId, trigger, windowDays, startedAt, state: 'running', pages: 0, calls: 0, written: 0, seen: 0,
495
+ }
496
+ // A new window starts a new pass; the same window resumes where the last
497
+ // one stopped, which is what makes cap_exhausted recoverable rather than a
498
+ // restart from zero.
499
+ if (this.ledger.window.days !== windowDays) {
500
+ this.ledger.window = { days: windowDays }
501
+ this.ledger.cursor = 0
502
+ }
503
+ this.ledger.state = 'running'
504
+ this.ledger.lastRun = summary
505
+ this.save()
506
+
507
+ const windowStartMs = this.now() - windowDays * MS_PER_DAY
508
+ let stop: { state: ImportState; reason?: string } | null = null
509
+ let passComplete = false
510
+
511
+ while (!stop) {
512
+ if (summary.pages >= IMPORT_MAX_PAGES_PER_RUN) { stop = { state: 'partial', reason: 'page_cap' }; break }
513
+ if (this.stopRequested) { stop = { state: 'partial', reason: 'server_stopping' }; break }
514
+ if (!this.admissionsOpen()) { stop = { state: 'partial', reason: 'maintenance_deferred' }; break }
515
+ if (callCeiling != null && this.deps.budget.snapshot().calls >= callCeiling) {
516
+ stop = { state: 'partial', reason: 'poll_budget_share' }
517
+ break
518
+ }
519
+
520
+ const page = await this.deps.client.listPage({ skip: this.ledger.cursor })
521
+ summary.calls += page.calls
522
+ summary.pages += 1
523
+
524
+ let lease: MaintenanceWorkLease
525
+ try {
526
+ lease = this.acquireLease()
527
+ } catch {
528
+ // A drain during the fetch. Nothing was written, the cursor is durable,
529
+ // and the next run repeats this page.
530
+ stop = { state: 'partial', reason: 'maintenance_deferred' }
531
+ break
532
+ }
533
+
534
+ const written: string[] = []
535
+ try {
536
+ for (const entry of page.oversized) {
537
+ const hash = entry.id ? importHash(entry.id) : unusableKey({ id: null, title: `skip:${entry.skip}` })
538
+ this.markRetryable(hash, 'response_too_large')
539
+ }
540
+
541
+ let reachedWindowEdge = false
542
+ for (const raw of page.transcripts) {
543
+ summary.seen += 1
544
+ const normalized = normalizeFirefliesTranscript(raw)
545
+ if (!normalized.ok) {
546
+ this.recordSkip(raw, normalized.reason)
547
+ continue
548
+ }
549
+ const record = normalized.record
550
+ const hash = importHash(record.id)
551
+ if (!this.ledger.vendorSeen.includes(hash)) this.ledger.vendorSeen.push(hash)
552
+ if (record.dateMs < windowStartMs) { reachedWindowEdge = true; continue }
553
+
554
+ // A meeting that is retryable but arrived complete is imported NOW.
555
+ // The backoff exists to stop attempts piling up on a meeting with no
556
+ // transcript yet, and that is enforced where attempts are counted
557
+ // (markRetryable). Applying it here as well would hold a transcript
558
+ // the vendor has already handed over for up to an hour, for nothing.
559
+ // Found by the mutation gate: removing this line broke no test,
560
+ // because there is no case where skipping here is correct.
561
+ const result = this.deps.library.writeRecord(buildFirefliesImport(record, {
562
+ importedAt: new Date(this.now()).toISOString(),
563
+ }))
564
+ delete this.ledger.retryable[hash]
565
+ if (record.durationSeconds == null) this.recordSkipHash(hash, 'duration_unknown')
566
+ if (!this.ledger.completed.includes(hash)) this.ledger.completed.push(hash)
567
+ if (result.written) { summary.written += 1; written.push(hash) }
568
+
569
+ // The proof. A record the library cannot list is a record no surface
570
+ // will ever show, and continuing would bury that in a success count.
571
+ if (!this.deps.library.listHashes({ month: result.month }).has(hash)) {
572
+ stop = { state: 'write_unlisted', reason: 'write_unlisted' }
573
+ break
574
+ }
575
+ }
576
+
577
+ if (!stop) {
578
+ this.ledger.cursor = page.nextSkip
579
+ if (page.failure) {
580
+ stop = failureStop(page.failure.state, page.failure)
581
+ summary.failure = page.failure
582
+ if (page.failure.retryAfterSeconds != null) {
583
+ this.ledger.nextAttemptAt = new Date(this.now() + page.failure.retryAfterSeconds * 1_000).toISOString()
584
+ }
585
+ } else if (page.endOfList || reachedWindowEdge) {
586
+ passComplete = true
587
+ stop = { state: 'ok', reason: page.endOfList ? 'end_of_list' : 'window_edge' }
588
+ }
589
+ }
590
+ this.save()
591
+ } finally {
592
+ lease.release()
593
+ }
594
+
595
+ if (written.length > 0) this.deps.onPageSealed?.({ runId, written })
596
+ }
597
+
598
+ if (passComplete) {
599
+ this.ledger.cursor = 0
600
+ this.ledger.lastPassCompletedAt = new Date(this.now()).toISOString()
601
+ }
602
+ const pending = Object.keys(this.ledger.retryable).length
603
+ let state = stop?.state ?? 'partial'
604
+ if (state === 'ok' && pending > 0) state = 'partial'
605
+ if (state === 'invalid_key') this.ledger.keyFingerprint = this.keyFingerprint()
606
+
607
+ summary.state = state
608
+ summary.finishedAt = new Date(this.now()).toISOString()
609
+ if (stop?.reason) summary.stopReason = stop.reason
610
+ else if (state === 'partial' && pending > 0) summary.stopReason = 'retryable_pending'
611
+ this.ledger.state = state
612
+ this.ledger.lastRun = summary
613
+ this.save()
614
+ this.log(`run ${runId} ${state} (pages ${summary.pages}, calls ${summary.calls}, written ${summary.written})`)
615
+ return summary
616
+ }
617
+
618
+ /** Has this retryable meeting waited long enough to be tried again? */
619
+ private retryableDue(hash: string): boolean {
620
+ const entry = this.ledger.retryable[hash]
621
+ if (!entry) return true
622
+ const nextAt = Date.parse(entry.nextAt)
623
+ return !Number.isFinite(nextAt) || nextAt <= this.now()
624
+ }
625
+
626
+ private markRetryable(hash: string, reason: ImportSkipReason): void {
627
+ const entry = this.ledger.retryable[hash]
628
+ if (entry && !this.retryableDue(hash)) return
629
+ const attempts = (entry?.attempts ?? 0) + 1
630
+ if (attempts >= STILL_PROCESSING_MAX_ATTEMPTS) {
631
+ delete this.ledger.retryable[hash]
632
+ this.recordSkipHash(hash, 'never_transcribed')
633
+ return
634
+ }
635
+ this.ledger.retryable[hash] = {
636
+ reason,
637
+ attempts,
638
+ nextAt: new Date(this.now() + STILL_PROCESSING_BACKOFF_MS).toISOString(),
639
+ }
640
+ }
641
+
642
+ private recordSkip(raw: unknown, reason: FirefliesSkipReason): void {
643
+ const item = (raw && typeof raw === 'object' ? raw : {}) as Record<string, unknown>
644
+ const hash = typeof item.id === 'string' && item.id.trim() ? importHash(item.id.trim()) : unusableKey(raw)
645
+ if (reason === 'still_processing') {
646
+ this.markRetryable(hash, reason)
647
+ return
648
+ }
649
+ this.recordSkipHash(hash, reason)
650
+ }
651
+
652
+ private recordSkipHash(hash: string, reason: ImportSkipReason): void {
653
+ const list = this.ledger.skipped[reason] ?? []
654
+ if (!list.includes(hash)) list.push(hash)
655
+ this.ledger.skipped[reason] = bounded(list)
656
+ }
657
+
658
+ /** Should a poll fire right now, and if not, why not. */
659
+ pollDue(): { due: boolean; reason?: string } {
660
+ if (this.mode() !== 'imports') return { due: false, reason: 'refused_pipeline' }
661
+ if (!this.ledger.keepImporting) return { due: false, reason: 'keep_importing_off' }
662
+ if (this.active) return { due: false, reason: 'import_in_progress' }
663
+ if (!this.deps.key()) return { due: false, reason: 'fireflies_key_missing' }
664
+ if (this.stickyInvalidKey()) return { due: false, reason: 'invalid_key' }
665
+ if (!this.admissionsOpen()) return { due: false, reason: 'admissions_closed' }
666
+ const now = this.now()
667
+ if (this.ledger.nextAttemptAt) {
668
+ const at = Date.parse(this.ledger.nextAttemptAt)
669
+ if (Number.isFinite(at) && at > now) return { due: false, reason: 'rate_limited' }
670
+ }
671
+ const ceiling = this.pollCallCeiling()
672
+ if (ceiling != null && this.deps.budget.snapshot().calls >= ceiling) {
673
+ return { due: false, reason: 'poll_budget_share' }
674
+ }
675
+ const lastStartedAt = this.ledger.lastRun?.startedAt ? Date.parse(this.ledger.lastRun.startedAt) : Number.NaN
676
+ const since = Number.isFinite(lastStartedAt) ? lastStartedAt : this.startedAtMs
677
+ if (now - since < IMPORT_POLL_INTERVAL_MS) return { due: false, reason: 'not_due' }
678
+ return { due: true }
679
+ }
680
+
681
+ /** Half the day's cap, so a poll can never spend the budget a person wanted. */
682
+ private pollCallCeiling(): number | null {
683
+ const cap = this.deps.budget.snapshot().cap
684
+ return cap == null ? null : Math.floor(cap * IMPORT_POLL_BUDGET_SHARE)
685
+ }
686
+
687
+ tick(): { fired: boolean; reason?: string; runId?: string } {
688
+ const due = this.pollDue()
689
+ if (!due.due) return { fired: false, ...(due.reason ? { reason: due.reason } : {}) }
690
+ try {
691
+ const started = this.run({
692
+ windowDays: this.ledger.window.days,
693
+ trigger: 'poll',
694
+ ...(this.pollCallCeiling() != null ? { callCeiling: this.pollCallCeiling()! } : {}),
695
+ })
696
+ return { fired: true, runId: started.runId }
697
+ } catch (error) {
698
+ return { fired: false, reason: error instanceof ImportRefusedError ? error.code : 'run_failed' }
699
+ }
700
+ }
701
+ }
702
+
703
+ function failureStop(state: FirefliesFailureState, failure: { retryAfterSeconds?: number }): { state: ImportState; reason?: string } {
704
+ switch (state) {
705
+ case 'invalid_key': return { state: 'invalid_key', reason: 'invalid_key' }
706
+ case 'rate_limited': return { state: 'rate_limited', reason: 'rate_limited' }
707
+ case 'unreachable': return { state: 'unreachable', reason: 'unreachable' }
708
+ case 'cap_exhausted': return { state: 'partial', reason: 'cap_exhausted' }
709
+ // vendor_error has no state of its own in the contract every surface
710
+ // renders. It is reported as vendor_down with its code on the run, which is
711
+ // what a person can act on, rather than inventing an eleventh state.
712
+ case 'vendor_error':
713
+ case 'vendor_down':
714
+ default: return { state: 'vendor_down', reason: state }
715
+ }
716
+ }
717
+
718
+ export function normalizeWindowDays(value: unknown): number {
719
+ if (value === undefined || value === null) return DEFAULT_IMPORT_WINDOW_DAYS
720
+ const days = typeof value === 'number' ? value : Number(value)
721
+ if (!IMPORT_WINDOW_DAYS.includes(days as (typeof IMPORT_WINDOW_DAYS)[number])) {
722
+ throw new ImportRefusedError(400, 'invalid_window', `windowDays must be one of ${IMPORT_WINDOW_DAYS.join(', ')}.`)
723
+ }
724
+ return days
725
+ }
726
+
727
+ let importer: FirefliesImporter | null = null
728
+ let pollTimer: ReturnType<typeof setInterval> | null = null
729
+
730
+ export function getFirefliesImporter(): FirefliesImporter {
731
+ if (!importer) {
732
+ // Built on first use, never at import time: every getter below touches the
733
+ // data home, and a module that does that on import cannot be unit tested.
734
+ importer = new FirefliesImporter({
735
+ library: getImportedMeetingLibrary(),
736
+ client: getFirefliesClient(),
737
+ budget: getFirefliesBudget(),
738
+ key: () => getFirefliesKeyStore().key(),
739
+ // A page of imports is new evidence for the merge engine. The runner queues, defers
740
+ // under a drain or a live capture, and never throws back into the import loop.
741
+ onPageSealed: () => triggerMeetingMergeRun('import_page_sealed'),
742
+ })
743
+ }
744
+ return importer
745
+ }
746
+
747
+ export function startMeetingImportScheduler(): void {
748
+ if (pollTimer) return
749
+ pollTimer = setInterval(() => {
750
+ try {
751
+ getFirefliesImporter().tick()
752
+ } catch (error) {
753
+ console.error('[meeting-import] poll tick failed:', error)
754
+ }
755
+ }, IMPORT_POLL_TICK_MS)
756
+ pollTimer.unref?.()
757
+ }
758
+
759
+ export function stopMeetingImportScheduler(): void {
760
+ if (pollTimer) clearInterval(pollTimer)
761
+ pollTimer = null
762
+ importer?.stop()
763
+ }