@gotcos/glasses-server 6.46.1 → 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.
- package/CHANGELOG.md +13 -0
- package/package.json +6 -2
- package/server/index.ts +76 -0
- package/server/lib/cos-operations-meetings.ts +99 -8
- package/server/lib/fireflies-client.ts +862 -0
- package/server/lib/fireflies-key.ts +182 -0
- package/server/lib/imported-library-rows.ts +616 -0
- package/server/lib/imported-meeting-library.ts +608 -0
- package/server/lib/maintenance-lifecycle.ts +14 -0
- package/server/lib/meeting-actions-store.ts +478 -0
- package/server/lib/meeting-actions.ts +2583 -0
- package/server/lib/meeting-corrections.ts +32 -1
- package/server/lib/meeting-decisions.ts +223 -0
- package/server/lib/meeting-engine/align.ts +167 -0
- package/server/lib/meeting-engine/attribute.ts +265 -0
- package/server/lib/meeting-engine/evidence.ts +428 -0
- package/server/lib/meeting-engine/pairing.ts +327 -0
- package/server/lib/meeting-engine/render.ts +694 -0
- package/server/lib/meeting-engine/split.ts +242 -0
- package/server/lib/meeting-engine/worker.ts +238 -0
- package/server/lib/meeting-engine-mode.ts +197 -0
- package/server/lib/meeting-file-guards.ts +141 -0
- package/server/lib/meeting-import.ts +763 -0
- package/server/lib/meeting-library-search.ts +146 -11
- package/server/lib/meeting-parse.ts +184 -0
- package/server/lib/meeting-store.ts +108 -275
- package/server/lib/meeting-suggestion-sides.ts +242 -0
- package/server/lib/morning-brief-runtime.ts +20 -8
- package/server/lib/pipeline-runner.ts +227 -0
- package/server/lib/voice-evidence-guard.ts +87 -0
- package/server/routes/fireflies-key.ts +102 -0
- package/server/routes/meeting-actions.ts +82 -0
- package/server/routes/meeting-engine.ts +52 -0
- package/server/routes/meeting-import.ts +67 -0
- package/server/routes/meeting-suggestions.ts +66 -0
- package/server/routes/meeting.ts +117 -10
- package/server/routes/meetings.ts +177 -50
- 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
|
+
}
|