@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,2583 @@
1
+ /**
2
+ * The merge runner: one queue, every trigger, three modes (6.47.0, WS4).
3
+ *
4
+ * WHAT THIS OWNS. Deciding what the engine's scores MEAN, writing that decision down, and
5
+ * being able to take it back. The engine (WS3) is pure and knows nothing about files; the
6
+ * library (WS2) writes records but decides nothing. This is the layer in between, and it is
7
+ * the only writer of `.actions.json`, `.suggestions.json`, `.tombstones.json`,
8
+ * `.engine-status.json` and the decision files.
9
+ *
10
+ * ONE RUNNER, FIVE TRIGGERS. An import page sealing, a G2 recording finalizing (two paths),
11
+ * an orphan recovering, and a 30 s tick that fires a full pass every six hours. All of them
12
+ * enqueue onto one promise chain. Two engine passes racing would score the same backlog
13
+ * twice and write two actions for one merge, and with a per-action cap they would each spend
14
+ * the whole cap.
15
+ *
16
+ * IT NEVER BLOCKS LIVE CAPTURE (principle 7). The engine runs on a worker thread, and the
17
+ * runner defers entirely while any meeting capture is active. Scoring a backlog is CPU-bound
18
+ * over every capture against every transcript; on the main thread, during a meeting, that
19
+ * competes with chunk writes and transcription.
20
+ *
21
+ * THREE MODES, ONE DECISION PROCEDURE:
22
+ *
23
+ * imports the server writes derived records into its own imports root
24
+ * advise the server writes NOTHING outside the imports root; the auto tier becomes
25
+ * would-merge items for a person to look at
26
+ * apply the server writes a decision file and the COS pipeline splices it into the
27
+ * operations tree, additively, archiving what it replaces
28
+ *
29
+ * D14, THE ADVISORY FIRST RUN. On every Mac, recordings that finished BEFORE the engine
30
+ * arrived become suggestions rather than automatic merges. The tier rules were measured only
31
+ * on recordings that already had a merge, so the backlog is precisely the population nobody
32
+ * has measured. Automatic behaviour starts from the moment a person could have seen it.
33
+ *
34
+ * EVERY AUTOMATIC ACTION IS REVERSIBLE (principle 2), and a Revert leaves a tombstone, so
35
+ * the next run cannot immediately redo what was just undone.
36
+ */
37
+
38
+ import { createHash } from 'node:crypto'
39
+ import { closeSync, cpSync, existsSync, mkdirSync, openSync, readFileSync, readSync, readdirSync, statSync, unlinkSync } from 'node:fs'
40
+ import { basename, dirname, join, relative, resolve } from 'node:path'
41
+ import { discoverMeetingDomains, resolveCosOperationsDir } from './cos-operations-meetings.js'
42
+ import {
43
+ DECISION_SCHEMA,
44
+ DecisionError,
45
+ MERGE_RESULT_PREFIX,
46
+ PIPELINE_EXIT_DECISION_INVALID,
47
+ PIPELINE_EXIT_LOCK_BUSY,
48
+ type MergeDecision,
49
+ type MergeDecisionInput,
50
+ type MergePipelineResult,
51
+ deleteDecision,
52
+ isMergePipelineResult,
53
+ listDecisionIds,
54
+ readDecision,
55
+ sha256OfFile,
56
+ writeDecision,
57
+ writeDecisionResult,
58
+ } from './meeting-decisions.js'
59
+ import {
60
+ type ImportedMeetingLibrary,
61
+ getImportedMeetingLibrary,
62
+ importHash,
63
+ importRecordId,
64
+ importsRoot,
65
+ mergedHash,
66
+ pieceHash,
67
+ } from './imported-meeting-library.js'
68
+ import {
69
+ acquireMaintenanceWork,
70
+ maintenanceAdmissionsOpen,
71
+ maintenanceLifecycle,
72
+ type MaintenanceWorkLease,
73
+ } from './maintenance-lifecycle.js'
74
+ import {
75
+ type ActionDirection,
76
+ type ActionMode,
77
+ type ActionsStoreFile,
78
+ type CanonicalInputs,
79
+ type ClockBandStats,
80
+ type EngineRunSummary,
81
+ type HashCacheEntry,
82
+ type MergeActionRecord,
83
+ type MergeSuggestionRecord,
84
+ type PipelineFailureDiagnostics,
85
+ MAX_HASH_CACHE_ENTRIES,
86
+ MeetingActionsStore,
87
+ actionIdFor,
88
+ directionOf,
89
+ getMeetingActionsStore,
90
+ inputPairs,
91
+ isTombstoned,
92
+ isWaitingState,
93
+ pendingStateFor,
94
+ suggestionIdFor,
95
+ } from './meeting-actions-store.js'
96
+ import {
97
+ type MeetingEngineMacClass,
98
+ type MeetingEngineMode,
99
+ type MeetingEnginePipelineMode,
100
+ meetingEngineIsPipelineMac,
101
+ meetingEngineMode,
102
+ meetingEngineModeDetail,
103
+ writeMergeModeFile,
104
+ } from './meeting-engine-mode.js'
105
+ import type { FirefliesMeetingInput, FirefliesSentenceInput, G2RecordingInput } from './meeting-engine/evidence.js'
106
+ import type { MergeGroup, PairingResult } from './meeting-engine/pairing.js'
107
+ import type { SplitPlan } from './meeting-engine/split.js'
108
+ import { fingerprintKey, renderPipelinePatch, type DerivedInputFingerprint } from './meeting-engine/render.js'
109
+ import { type EngineRequest, type EngineResult, type EngineScoreResult, runEngineInWorker } from './meeting-engine/worker.js'
110
+ import { getMeetingStore } from './meeting-store.js'
111
+ import { type SuggestionWithSides, withSuggestionSides } from './meeting-suggestion-sides.js'
112
+ import { onCorrectionApplied } from './meeting-corrections.js'
113
+ import {
114
+ type PipelineAttempt,
115
+ parseResultLine,
116
+ pipelinePath,
117
+ runPipelineCommand,
118
+ PIPELINE_DEFAULT_TIMEOUT_MS,
119
+ } from './pipeline-runner.js'
120
+
121
+ /**
122
+ * How many automatic actions one run may take.
123
+ *
124
+ * COUNTED INSIDE THE RUNNER, not by the caller, because five triggers call it and a cap a
125
+ * caller applies is a cap the other four do not. It exists so the first pass over a backlog
126
+ * of hundreds is something a person can look at and revert, not a wall of changes.
127
+ */
128
+ export const MAX_AUTO_ACTIONS_PER_RUN = 25
129
+
130
+ export const ENGINE_TICK_MS = 30_000
131
+ export const ENGINE_DUE_INTERVAL_MS = 6 * 60 * 60_000
132
+
133
+ /** A pipeline child that could not take the sync lock is retried after this. */
134
+ export const PIPELINE_LOCK_RETRY_MS = 2 * 60_000
135
+
136
+ /** One retry, then the action stays failed for a person to decide about. */
137
+ export const PIPELINE_MAX_ATTEMPTS = 2
138
+
139
+ /** `--merge-engine-status` is asked at most this often. */
140
+ export const PIPELINE_STATUS_CACHE_MS = 5 * 60_000
141
+
142
+ /** Engine work above this many inputs is refused rather than run; nothing hangs silently. */
143
+ export const ENGINE_MAX_INPUTS = 5_000
144
+
145
+ export class ActionRefusedError extends Error {
146
+ constructor(readonly status: number, readonly code: string, message: string) {
147
+ super(message)
148
+ this.name = 'ActionRefusedError'
149
+ }
150
+ }
151
+
152
+ /** What a G2 recording's files say about itself, beyond what the engine scores. */
153
+ export interface G2InputMeta {
154
+ /** Operations-relative `.g2-chunks.json`, in advise and apply mode. */
155
+ sidecarRelPath?: string
156
+ /** Operations-relative standalone scribe. */
157
+ scribeRelPath?: string
158
+ /** Absolute paths, for reading and for imports-mode reverts. */
159
+ sidecarPath?: string
160
+ scribePath?: string
161
+ sha256?: string
162
+ /** The pipeline already merged this capture into that scribe. */
163
+ blendedInto?: string
164
+ claimedParent?: string
165
+ finalizedAtMs?: number
166
+ }
167
+
168
+ export interface FirefliesInputMeta {
169
+ scribeRelPath?: string
170
+ sidecarRelPath?: string
171
+ scribePath?: string
172
+ sha256?: string
173
+ /** The scribe carries `<!-- g2-transcript-blended -->`. */
174
+ blendedMarker?: boolean
175
+ /** ...and a `<!-- merge-action: -->` marker, meaning THIS engine did it. */
176
+ mergeActionId?: string
177
+ }
178
+
179
+ export interface EngineInputs {
180
+ g2: G2RecordingInput[]
181
+ fireflies: FirefliesMeetingInput[]
182
+ g2Meta: Record<string, G2InputMeta>
183
+ firefliesMeta: Record<string, FirefliesInputMeta>
184
+ /** Inputs the collector refused, by reason code. Optional so a test fixture stays small. */
185
+ skipped?: SkipCounts
186
+ }
187
+
188
+ export interface RunOutcome extends EngineRunSummary {
189
+ actions: string[]
190
+ suggestions: string[]
191
+ }
192
+
193
+ export interface MergeRunnerDeps {
194
+ store?: MeetingActionsStore
195
+ library?: ImportedMeetingLibrary
196
+ mode?: () => MeetingEngineMode
197
+ isPipelineMac?: () => boolean
198
+ collectInputs?: (mode: MeetingEngineMode) => EngineInputs | Promise<EngineInputs>
199
+ runEngine?: (request: EngineRequest) => Promise<EngineResult>
200
+ acquireLease?: () => MaintenanceWorkLease
201
+ admissionsOpen?: () => boolean
202
+ /** True while a meeting is being captured. The runner defers entirely. */
203
+ captureActive?: () => boolean
204
+ /** Spawn one pipeline command. Null when this Mac has no pipeline. */
205
+ spawnPipeline?: ((args: readonly string[], options: { actionId?: string }) => Promise<PipelineAttempt>) | null
206
+ now?: () => number
207
+ log?: (line: string) => void
208
+ }
209
+
210
+ // ── Reading the world ─────────────────────────────────────────────────────────
211
+
212
+ const MONTH_PATTERN = /^\d{4}-(0[1-9]|1[0-2])$/
213
+ /** iCloud conflict copies ("x 2.g2-chunks.json"). Never an input. */
214
+ const ICLOUD_DUPLICATE = / \d+(\.[A-Za-z0-9-]+)*\.json$/
215
+ const MAX_SIDECAR_BYTES = 64 * 1024 * 1024
216
+
217
+ /**
218
+ * The only `.fireflies.json` shape this engine scores.
219
+ *
220
+ * `sidecar_version` exists because the first sidecars the pipeline wrote were built AFTER
221
+ * `_clip_superset_bleed` rewrote `source_data['sentences']` in place, so they describe a
222
+ * recording with a whole real meeting cut out of it. `clipped: true` says so outright.
223
+ * Scoring either one pairs a capture against a transcript that is missing the very minutes
224
+ * the capture covers, which reads as "no evidence" and silently loses the merge.
225
+ */
226
+ export const FIREFLIES_SIDECAR_VERSION = 1
227
+
228
+ /**
229
+ * Cap on a scribe read for its markers.
230
+ *
231
+ * `scribeMarkers` was the one unbounded read left in the collector: a whole operations scribe
232
+ * into a string, per Fireflies meeting, per pass, to look for two comment markers. The
233
+ * library's own markdown cap is 9 MiB, so nothing this engine wrote can reach this; what can
234
+ * is a file that is no longer a scribe.
235
+ */
236
+ export const MAX_SCRIBE_BYTES = 10 * 1024 * 1024
237
+
238
+ function readJsonFile(path: string): Record<string, unknown> | null {
239
+ try {
240
+ if (statSync(path).size > MAX_SIDECAR_BYTES) return null
241
+ const parsed = JSON.parse(readFileSync(path, 'utf8')) as unknown
242
+ return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? (parsed as Record<string, unknown>) : null
243
+ } catch {
244
+ return null
245
+ }
246
+ }
247
+
248
+ /** Hashes a file, or answers from a cache when nothing about the file has moved. */
249
+ export type FileHasher = (path: string) => string | undefined
250
+
251
+ /** Counted refusals, so an input the engine never scored is never merely absent. */
252
+ export type SkipCounts = Record<string, number>
253
+
254
+ function countSkip(counts: SkipCounts, reason: string): void {
255
+ counts[reason] = (counts[reason] ?? 0) + 1
256
+ }
257
+
258
+ function numberOr(value: unknown, fallback: number): number {
259
+ return typeof value === 'number' && Number.isFinite(value) ? value : fallback
260
+ }
261
+
262
+ /**
263
+ * Sentences in the shape the engine reads, from either sidecar.
264
+ *
265
+ * Two shapes exist and both are real: the server's own `.import.json` stores the client's
266
+ * camelCase normalization, while the pipeline's `.fireflies.json` stores the vendor's raw
267
+ * snake_case rows. Accepting both here is what lets one engine score both modes.
268
+ */
269
+ export function toEngineSentences(rows: unknown): FirefliesSentenceInput[] {
270
+ if (!Array.isArray(rows)) return []
271
+ return rows.map(raw => {
272
+ const row = (raw && typeof raw === 'object' ? raw : {}) as Record<string, unknown>
273
+ return {
274
+ text: typeof row.text === 'string' ? row.text : '',
275
+ speaker_name: typeof row.speaker_name === 'string'
276
+ ? row.speaker_name
277
+ : typeof row.speakerName === 'string' ? row.speakerName : '',
278
+ start_time: numberOr(row.start_time, numberOr(row.startTime, 0)),
279
+ end_time: numberOr(row.end_time, numberOr(row.endTime, 0)),
280
+ }
281
+ })
282
+ }
283
+
284
+ function g2FromSidecar(doc: Record<string, unknown>, sessionId: string): G2RecordingInput | null {
285
+ const startMs = numberOr(doc.startTime, Number.NaN)
286
+ const durationMs = numberOr(doc.durationMs, Number.NaN)
287
+ if (!Number.isFinite(startMs) || !Number.isFinite(durationMs)) return null
288
+ return {
289
+ sessionId,
290
+ startMs,
291
+ durationMs,
292
+ chunks: Array.isArray(doc.chunks) ? (doc.chunks as G2RecordingInput['chunks']) : [],
293
+ batchSegments: Array.isArray(doc.batchSegments) ? (doc.batchSegments as G2RecordingInput['batchSegments']) : [],
294
+ correctionRevision: typeof doc.correctionRevision === 'number' ? doc.correctionRevision : undefined,
295
+ batchApplied: typeof doc.batchApplied === 'boolean' ? doc.batchApplied : undefined,
296
+ title: typeof doc.title === 'string' ? doc.title : undefined,
297
+ domain: typeof doc.domain === 'string' ? doc.domain : undefined,
298
+ finalizedAtMs: startMs + Math.max(0, durationMs),
299
+ }
300
+ }
301
+
302
+ /**
303
+ * `<!-- g2-transcript-blended -->` and friends, from a scribe.
304
+ *
305
+ * BOUNDED, like `readJsonFile`. A file over `MAX_SCRIBE_BYTES` is not read at all and is
306
+ * reported as `oversized`, which the collector turns into a counted refusal rather than a
307
+ * silent `blended: false`. Answering "not blended" for a file nobody read would be worse
308
+ * than refusing it: it is the answer that makes the engine propose merging it.
309
+ */
310
+ export function scribeMarkers(path: string): { blended: boolean; mergeActionId?: string; oversized?: boolean } {
311
+ try {
312
+ if (statSync(path).size > MAX_SCRIBE_BYTES) return { blended: false, oversized: true }
313
+ const text = readFileSync(path, 'utf8')
314
+ const mergeAction = text.match(/<!--\s*merge-action:\s*(a_[0-9a-f]{16})\s*-->/)
315
+ return {
316
+ blended: text.includes('<!-- g2-transcript-blended -->'),
317
+ ...(mergeAction ? { mergeActionId: mergeAction[1] } : {}),
318
+ }
319
+ } catch {
320
+ return { blended: false }
321
+ }
322
+ }
323
+
324
+ /** Every G2 recording and imported Fireflies meeting this Mac holds, in imports mode. */
325
+ function collectImportsModeInputs(library: ImportedMeetingLibrary, hash: FileHasher): EngineInputs {
326
+ const inputs: EngineInputs & { skipped: SkipCounts } = { g2: [], fireflies: [], g2Meta: {}, firefliesMeta: {}, skipped: {} }
327
+ const store = getMeetingStore()
328
+ let months: string[] = []
329
+ try {
330
+ months = readdirSync(store.root).filter(name => MONTH_PATTERN.test(name)).sort().reverse()
331
+ } catch {
332
+ months = []
333
+ }
334
+ for (const month of months) {
335
+ const monthDir = join(store.root, month)
336
+ let names: string[] = []
337
+ try { names = readdirSync(monthDir) } catch { continue }
338
+ for (const name of names) {
339
+ if (!name.endsWith('.g2-chunks.json') || ICLOUD_DUPLICATE.test(name)) continue
340
+ const path = join(monthDir, name)
341
+ const doc = readJsonFile(path)
342
+ const sessionId = typeof doc?.sessionId === 'string' ? doc.sessionId : null
343
+ if (!doc || !sessionId) continue
344
+ const recording = g2FromSidecar(doc, sessionId)
345
+ if (!recording) continue
346
+ const scribePath = path.replace(/\.g2-chunks\.json$/, '.md')
347
+ inputs.g2.push(recording)
348
+ inputs.g2Meta[sessionId] = {
349
+ sidecarPath: path,
350
+ ...(existsSync(scribePath) ? { scribePath } : {}),
351
+ sha256: hash(path),
352
+ finalizedAtMs: recording.finalizedAtMs,
353
+ ...(typeof doc.blended_into === 'string' ? { blendedInto: doc.blended_into } : {}),
354
+ ...(Array.isArray(doc.blended_into) && typeof doc.blended_into[0] === 'string' ? { blendedInto: doc.blended_into[0] as string } : {}),
355
+ ...(typeof doc.claimed_parent === 'string' ? { claimedParent: doc.claimed_parent } : {}),
356
+ }
357
+ }
358
+ }
359
+
360
+ for (const row of library.list()) {
361
+ if (row.librarySource !== 'imported') continue
362
+ const sidecar = library.readSidecar(row.month, row.filename) as Record<string, unknown> | null
363
+ const firefliesId = typeof sidecar?.firefliesId === 'string' ? sidecar.firefliesId : null
364
+ if (!sidecar || !firefliesId) { countSkip(inputs.skipped, 'import_sidecar_unreadable'); continue }
365
+ inputs.fireflies.push({
366
+ id: firefliesId,
367
+ startMs: numberOr(sidecar.dateMs, 0),
368
+ durationS: numberOr(sidecar.durationSeconds, 0),
369
+ sentences: toEngineSentences(sidecar.sentences),
370
+ title: typeof sidecar.title === 'string' ? sidecar.title : undefined,
371
+ participants: Array.isArray(sidecar.participants) ? (sidecar.participants as string[]) : [],
372
+ })
373
+ inputs.firefliesMeta[firefliesId] = {}
374
+ }
375
+ return inputs
376
+ }
377
+
378
+ /** The operations tree, in advise and apply mode. Read-only in both. */
379
+ function collectOperationsInputs(hash: FileHasher): EngineInputs {
380
+ const inputs: EngineInputs & { skipped: SkipCounts } = { g2: [], fireflies: [], g2Meta: {}, firefliesMeta: {}, skipped: {} }
381
+ const operationsDir = resolveCosOperationsDir()
382
+ if (!operationsDir) return inputs
383
+
384
+ for (const domain of discoverMeetingDomains(operationsDir)) {
385
+ const base = join(operationsDir, domain, 'meetings')
386
+ let months: string[] = []
387
+ try { months = readdirSync(base).filter(name => MONTH_PATTERN.test(name)).sort().reverse() } catch { continue }
388
+ for (const month of months) {
389
+ const monthDir = join(base, month)
390
+ let names: string[] = []
391
+ try { names = readdirSync(monthDir) } catch { continue }
392
+ for (const name of names) {
393
+ if (ICLOUD_DUPLICATE.test(name)) continue
394
+ const path = join(monthDir, name)
395
+
396
+ if (name.endsWith('.g2-chunks.json')) {
397
+ const doc = readJsonFile(path)
398
+ const sessionId = typeof doc?.sessionId === 'string' ? doc.sessionId : null
399
+ if (!doc || !sessionId || inputs.g2Meta[sessionId]) continue
400
+ const recording = g2FromSidecar(doc, sessionId)
401
+ if (!recording) continue
402
+ const scribePath = path.replace(/\.g2-chunks\.json$/, '.md')
403
+ inputs.g2.push(recording)
404
+ const blendedInto = Array.isArray(doc.blended_into)
405
+ ? (typeof doc.blended_into[0] === 'string' ? doc.blended_into[0] as string : undefined)
406
+ : (typeof doc.blended_into === 'string' ? doc.blended_into : undefined)
407
+ inputs.g2Meta[sessionId] = {
408
+ sidecarPath: path,
409
+ sidecarRelPath: relative(operationsDir, path),
410
+ ...(existsSync(scribePath) ? { scribePath, scribeRelPath: relative(operationsDir, scribePath) } : {}),
411
+ sha256: hash(path),
412
+ finalizedAtMs: recording.finalizedAtMs,
413
+ ...(blendedInto ? { blendedInto } : {}),
414
+ ...(typeof doc.claimed_parent === 'string' ? { claimedParent: doc.claimed_parent } : {}),
415
+ }
416
+ continue
417
+ }
418
+
419
+ if (name.endsWith('.fireflies.json')) {
420
+ const doc = readJsonFile(path)
421
+ const firefliesId = typeof doc?.id === 'string' ? doc.id : null
422
+ if (!doc || !firefliesId) { countSkip(inputs.skipped, 'fireflies_sidecar_unreadable'); continue }
423
+ if (inputs.firefliesMeta[firefliesId]) continue
424
+ // A sidecar written before the raw-snapshot fix holds the CLIPPED sentences: the
425
+ // pipeline's de-bleed rewrites `source_data['sentences']` in place, so a sidecar
426
+ // built after it describes a recording with a whole real meeting cut out. Scoring
427
+ // one pairs a capture against a transcript missing the minutes that capture
428
+ // covers, which reads as "no evidence" and loses the merge without saying so.
429
+ if (doc.sidecar_version !== FIREFLIES_SIDECAR_VERSION) {
430
+ countSkip(inputs.skipped, 'fireflies_sidecar_version')
431
+ continue
432
+ }
433
+ if (doc.clipped === true) {
434
+ countSkip(inputs.skipped, 'fireflies_sidecar_clipped')
435
+ continue
436
+ }
437
+ const scribePath = path.replace(/\.fireflies\.json$/, '.md')
438
+ const hasScribe = existsSync(scribePath)
439
+ const markers = hasScribe ? scribeMarkers(scribePath) : { blended: false, oversized: false }
440
+ if (markers.oversized) {
441
+ // Nobody read this file, so nobody may say it is unblended. Refusing it keeps a
442
+ // merge that already happened from being proposed a second time.
443
+ countSkip(inputs.skipped, 'fireflies_scribe_oversized')
444
+ continue
445
+ }
446
+ const durationMinutes = numberOr(doc.duration, 0)
447
+ inputs.fireflies.push({
448
+ id: firefliesId,
449
+ startMs: numberOr(doc.date, 0),
450
+ durationS: durationMinutes > 0 ? durationMinutes * 60 : 0,
451
+ sentences: toEngineSentences(doc.sentences),
452
+ title: typeof doc.title === 'string' ? doc.title : basename(scribePath, '.md'),
453
+ participants: Array.isArray(doc.participants) ? (doc.participants as string[]) : [],
454
+ })
455
+ inputs.firefliesMeta[firefliesId] = {
456
+ sidecarRelPath: relative(operationsDir, path),
457
+ ...(hasScribe
458
+ ? { scribePath, scribeRelPath: relative(operationsDir, scribePath), sha256: hash(scribePath) }
459
+ : {}),
460
+ blendedMarker: markers.blended,
461
+ ...(markers.mergeActionId ? { mergeActionId: markers.mergeActionId } : {}),
462
+ }
463
+ }
464
+ }
465
+ }
466
+ }
467
+ return inputs
468
+ }
469
+
470
+ export function collectEngineInputs(
471
+ mode: MeetingEngineMode,
472
+ library: ImportedMeetingLibrary,
473
+ hash: FileHasher = path => sha256OfFile(path) ?? undefined,
474
+ ): EngineInputs {
475
+ return mode === 'imports' ? collectImportsModeInputs(library, hash) : collectOperationsInputs(hash)
476
+ }
477
+
478
+ // ── Knowing whether anything changed, without reading anything ────────────────
479
+ //
480
+ // PRINCIPLE 7, THE HALF THAT IS NOT THE WORKER THREAD. The engine scores on a worker, but
481
+ // COLLECTING its inputs happened on the main thread and hashed every sidecar it found: on
482
+ // this Mac, 817 MB of `.g2-chunks.json` per pass. A pass fires on every G2 finalization,
483
+ // every import page, every orphan recovery and every six hours, and most of them have
484
+ // nothing new to look at.
485
+ //
486
+ // Two cheap answers, in order. First: has ANY input file moved since the last collected
487
+ // pass? That is a readdir plus a stat per file, no reads at all, and when the answer is no
488
+ // the pass does nothing but drive whatever the pipeline still owes. Second, when something
489
+ // did move: only the files whose mtime or size changed are re-read, because a sha256 is a
490
+ // pure function of bytes and the bytes are what mtime and size describe.
491
+
492
+ /** The directories a pass would read inputs from, without opening any of them. */
493
+ function inputDirectories(mode: MeetingEngineMode, library: ImportedMeetingLibrary): string[] {
494
+ const directories: string[] = []
495
+ if (mode === 'imports') {
496
+ const store = getMeetingStore()
497
+ for (const root of [store.root, library.root]) {
498
+ try {
499
+ for (const month of readdirSync(root)) {
500
+ if (MONTH_PATTERN.test(month)) directories.push(join(root, month))
501
+ }
502
+ } catch { /* a root that is not there holds no inputs */ }
503
+ }
504
+ return directories
505
+ }
506
+ const operationsDir = resolveCosOperationsDir()
507
+ if (!operationsDir) return directories
508
+ for (const domain of discoverMeetingDomains(operationsDir)) {
509
+ const base = join(operationsDir, domain, 'meetings')
510
+ try {
511
+ for (const month of readdirSync(base)) {
512
+ if (MONTH_PATTERN.test(month)) directories.push(join(base, month))
513
+ }
514
+ } catch { /* a domain with no meetings dir holds no inputs */ }
515
+ }
516
+ return directories
517
+ }
518
+
519
+ /** True for the filenames a collector would open. */
520
+ function isEngineInputName(name: string): boolean {
521
+ if (ICLOUD_DUPLICATE.test(name)) return false
522
+ return name.endsWith('.g2-chunks.json') || name.endsWith('.fireflies.json') || name.endsWith('.import.json')
523
+ }
524
+
525
+ /**
526
+ * The newest input mtime and how many inputs there are, from stats alone.
527
+ *
528
+ * COUNT AS WELL AS MTIME, because a DELETED file leaves every surviving mtime where it was.
529
+ * Together they move for every change that can alter what a pass would decide.
530
+ */
531
+ export function scanInputStamp(
532
+ mode: MeetingEngineMode,
533
+ library: ImportedMeetingLibrary,
534
+ ): { newestMtimeMs: number; count: number } {
535
+ let newestMtimeMs = 0
536
+ let count = 0
537
+ for (const directory of inputDirectories(mode, library)) {
538
+ let names: string[]
539
+ try { names = readdirSync(directory) } catch { continue }
540
+ for (const name of names) {
541
+ if (!isEngineInputName(name)) continue
542
+ try {
543
+ const stat = statSync(join(directory, name))
544
+ count += 1
545
+ if (stat.mtimeMs > newestMtimeMs) newestMtimeMs = stat.mtimeMs
546
+ } catch { /* vanished between readdir and stat; the next pass sees it */ }
547
+ }
548
+ }
549
+ return { newestMtimeMs, count }
550
+ }
551
+
552
+ // ── The runner ────────────────────────────────────────────────────────────────
553
+
554
+ function fingerprintsFor(
555
+ inputs: CanonicalInputs,
556
+ g2: Map<string, G2RecordingInput>,
557
+ fireflies: Map<string, FirefliesMeetingInput>,
558
+ g2Meta: Record<string, G2InputMeta>,
559
+ firefliesMeta: Record<string, FirefliesInputMeta>,
560
+ ): string {
561
+ const rows: DerivedInputFingerprint[] = [
562
+ ...inputs.sessionIds.map(id => ({
563
+ kind: 'g2' as const,
564
+ id,
565
+ sha256: g2Meta[id]?.sha256 ?? null,
566
+ correctionRevision: g2.get(id)?.correctionRevision,
567
+ batchApplied: g2.get(id)?.batchApplied,
568
+ })),
569
+ ...inputs.firefliesIds.map(id => ({
570
+ kind: 'fireflies' as const,
571
+ id,
572
+ sha256: firefliesMeta[id]?.sha256 ?? null,
573
+ })),
574
+ ]
575
+ // Sentence count moves when a transcript is re-fetched even if no file hash exists,
576
+ // so it is folded in for the imports-mode case where there is no scribe to hash.
577
+ const counts = inputs.firefliesIds.map(id => `${id}:${fireflies.get(id)?.sentences.length ?? 0}`).sort().join(',')
578
+ return createHash('sha256').update(`${fingerprintKey(rows)}|${counts}`).digest('hex')
579
+ }
580
+
581
+ function sortedInputs(sessionIds: string[], firefliesIds: string[]): CanonicalInputs {
582
+ return { sessionIds: [...sessionIds].sort(), firefliesIds: [...firefliesIds].sort() }
583
+ }
584
+
585
+ /** The hash a Revert preview hands back, and the applying call has to hand in. */
586
+ export function revertPreviewHash(action: MergeActionRecord, currentSha256: Array<string | null>): string {
587
+ return createHash('sha256')
588
+ .update(JSON.stringify({
589
+ id: action.id,
590
+ state: action.state,
591
+ mode: action.mode,
592
+ outputs: action.outputs.map(output => output.path),
593
+ recorded: action.outputSha256,
594
+ current: currentSha256,
595
+ }))
596
+ .digest('hex')
597
+ }
598
+
599
+ export interface RevertPreview {
600
+ actionId: string
601
+ previewHash: string
602
+ state: MergeActionRecord['state']
603
+ mode: ActionMode
604
+ outputs: string[]
605
+ /** Outputs whose bytes changed since the action wrote them. Copied aside, not lost. */
606
+ editedOutputs: string[]
607
+ missingOutputs: string[]
608
+ }
609
+
610
+ export class MeetingMergeRunner {
611
+ private readonly store: MeetingActionsStore
612
+ private readonly library: ImportedMeetingLibrary
613
+ private readonly mode: () => MeetingEngineMode
614
+ private readonly modeDetailFn: (() => ReturnType<typeof meetingEngineModeDetail>) | null
615
+ private readonly isPipelineMac: () => boolean
616
+ private readonly collect: (mode: MeetingEngineMode) => EngineInputs | Promise<EngineInputs>
617
+ /** True when the caller supplies inputs, which makes a filesystem scan meaningless. */
618
+ private readonly collectInjected: boolean
619
+ private readonly engine: (request: EngineRequest) => Promise<EngineResult>
620
+ private readonly acquireLease: () => MaintenanceWorkLease
621
+ private readonly admissionsOpen: () => boolean
622
+ private readonly captureActive: () => boolean
623
+ private readonly spawnPipeline: ((args: readonly string[], options: { actionId?: string }) => Promise<PipelineAttempt>) | null
624
+ private readonly now: () => number
625
+ private readonly log: (line: string) => void
626
+
627
+ /** THE queue. Every trigger lands here; nothing runs two passes at once. */
628
+ private chain: Promise<unknown> = Promise.resolve()
629
+ private inFlight = 0
630
+ private lastDueRunAt = 0
631
+
632
+ /** sha256 by absolute path, loaded from `.engine-status.json` on the first collected pass. */
633
+ private readonly hashCache = new Map<string, HashCacheEntry>()
634
+ private hashCacheLoaded = false
635
+ /** Paths this pass actually looked at. The cache is rewritten to exactly these. */
636
+ private hashCacheSeen = new Set<string>()
637
+ /** The scan stamp to record IF this pass finishes collecting; dropped if it does not. */
638
+ private pendingInputScan: { newestMtimeMs: number; count: number; mode: MeetingEngineMode } | null = null
639
+ /** Whether this pass actually read inputs, so a bailed pass does not wipe the cache. */
640
+ private collectedThisPass = false
641
+ /** One pass must collect even though nothing moved: a person asked for a retry. */
642
+ private forceNextCollection = false
643
+ /** Operations paths per Fireflies id, remembered from the last collected pass. */
644
+ private firefliesPaths: Record<string, { sidecarRelPath?: string; scribeRelPath?: string }> | null = null
645
+ /** A pass live meeting work turned away, owed to the first tick that finds the way clear. */
646
+ private deferredTrigger: string | null = null
647
+
648
+ constructor(deps: MergeRunnerDeps = {}) {
649
+ this.store = deps.store ?? getMeetingActionsStore()
650
+ this.library = deps.library ?? getImportedMeetingLibrary()
651
+ this.mode = deps.mode ?? meetingEngineMode
652
+ // Only the REAL predicate can report a Mac-class change, because only it reads the
653
+ // file and the probe. A test that injects a mode gets a detail derived from that mode,
654
+ // never a claim about a machine it is not describing.
655
+ this.modeDetailFn = deps.mode ? null : meetingEngineModeDetail
656
+ this.isPipelineMac = deps.isPipelineMac ?? meetingEngineIsPipelineMac
657
+ // The change-scan and the hash cache both describe the FILESYSTEM the real collector
658
+ // reads. A caller that supplies its own inputs is describing something else entirely, so
659
+ // for it the scan would answer "nothing changed" about files it never looks at and every
660
+ // pass after the first would do nothing.
661
+ this.collectInjected = deps.collectInputs != null
662
+ this.collect = deps.collectInputs
663
+ ?? ((mode: MeetingEngineMode) => collectEngineInputs(mode, this.library, path => this.hashFor(path)))
664
+ this.engine = deps.runEngine ?? (request => runEngineInWorker(request))
665
+ this.acquireLease = deps.acquireLease ?? (() => acquireMaintenanceWork('meeting_merge'))
666
+ this.admissionsOpen = deps.admissionsOpen ?? maintenanceAdmissionsOpen
667
+ // PRINCIPLE 7, WIRED. This defaulted to `() => false`, which made "the engine never
668
+ // blocks live capture" a claim no code enforced: the production constructor passed no
669
+ // deps at all, so the only thing that ever deferred was a test. `recording_chunk` is the
670
+ // lease every live chunk write takes, and `meeting_save` the one a capture's own save
671
+ // holds, so between them they are the window where the CPU belongs to the meeting.
672
+ this.captureActive = deps.captureActive ?? defaultCaptureActive
673
+ this.spawnPipeline = deps.spawnPipeline === undefined ? defaultPipelineSpawn : deps.spawnPipeline
674
+ this.now = deps.now ?? (() => Date.now())
675
+ this.log = deps.log ?? ((line: string) => console.log(`[meeting-merge] ${line}`))
676
+ }
677
+
678
+ // ── Hashing, once per changed file ──────────────────────────────────────────
679
+
680
+ private loadHashCache(store: ActionsStoreFile): void {
681
+ if (this.hashCacheLoaded) return
682
+ this.hashCacheLoaded = true
683
+ for (const [path, entry] of Object.entries(store.status.hashCache ?? {})) {
684
+ if (entry && typeof entry.sha256 === 'string' && typeof entry.mtimeMs === 'number' && typeof entry.size === 'number') {
685
+ this.hashCache.set(path, entry)
686
+ }
687
+ }
688
+ }
689
+
690
+ /**
691
+ * This file's sha256, read only when the file itself has moved.
692
+ *
693
+ * mtime AND size, not mtime alone: a same-size rewrite within one filesystem timestamp tick
694
+ * is exactly what an atomic replace of a sidecar looks like, and size is the cheap second
695
+ * opinion. Both come from the `statSync` this function has to do anyway.
696
+ */
697
+ private hashFor(path: string): string | undefined {
698
+ let stat
699
+ try { stat = statSync(path) } catch { return undefined }
700
+ this.hashCacheSeen.add(path)
701
+ const cached = this.hashCache.get(path)
702
+ if (cached && cached.mtimeMs === stat.mtimeMs && cached.size === stat.size) return cached.sha256
703
+ const sha256 = sha256OfFile(path)
704
+ if (!sha256) return undefined
705
+ this.hashCache.set(path, { sha256, mtimeMs: stat.mtimeMs, size: stat.size })
706
+ return sha256
707
+ }
708
+
709
+ /** What the cache should be persisted as, pruned to what this pass saw. */
710
+ private hashCacheSnapshot(): Record<string, HashCacheEntry> {
711
+ const out: Record<string, HashCacheEntry> = {}
712
+ let kept = 0
713
+ for (const path of this.hashCacheSeen) {
714
+ const entry = this.hashCache.get(path)
715
+ if (!entry) continue
716
+ if (kept >= MAX_HASH_CACHE_ENTRIES) break
717
+ out[path] = entry
718
+ kept += 1
719
+ }
720
+ return out
721
+ }
722
+
723
+ busy(): boolean {
724
+ return this.inFlight > 0
725
+ }
726
+
727
+ /**
728
+ * Every person-initiated mutation refuses during a committed drain.
729
+ *
730
+ * These routes are `lifecycleOwned` in `index.ts` — they do NOT take the global request
731
+ * lease, because in apply mode one of them holds a 75 s pipeline spawn and the global
732
+ * lease would push a drain past COS Control's 90 s timeout. That exemption is exactly why
733
+ * the refusal has to live here: without it, a mutation admitted during a drain would be
734
+ * silently deferred and answer as though it had happened.
735
+ */
736
+ private assertAdmissions(): void {
737
+ if (!this.admissionsOpen()) {
738
+ throw new ActionRefusedError(
739
+ 409,
740
+ 'maintenance_drain_active',
741
+ 'The server is finishing a maintenance operation. Try again shortly.',
742
+ )
743
+ }
744
+ }
745
+
746
+ /** Resolves when everything queued has finished. */
747
+ idle(): Promise<unknown> {
748
+ return this.chain.then(() => this.store.idle())
749
+ }
750
+
751
+ private enqueue<T>(work: () => Promise<T>): Promise<T> {
752
+ this.inFlight += 1
753
+ const result = this.chain.then(work, work).finally(() => { this.inFlight -= 1 })
754
+ this.chain = result.then(() => undefined, () => undefined)
755
+ return result
756
+ }
757
+
758
+ /** Every trigger calls this. Failures are logged, never thrown at the trigger. */
759
+ trigger(reason: string): void {
760
+ void this.run(reason).catch(error => this.log(`run (${reason}) failed: ${describe(error)}`))
761
+ }
762
+
763
+ run(trigger: string): Promise<RunOutcome> {
764
+ return this.enqueue(() => this.execute(trigger))
765
+ }
766
+
767
+ /** The 30 s tick: drive waiting pipeline work, and run a full pass every six hours. */
768
+ tick(): { fired: boolean; reason?: string } {
769
+ if (!this.admissionsOpen()) return { fired: false, reason: 'admissions_closed' }
770
+ if (this.busy()) return { fired: false, reason: 'run_in_progress' }
771
+ const now = this.now()
772
+ // BOTH waiting states. `revert_pending` was missing here and in `drivePending`, so an
773
+ // Undo deferred by the sync lock or by a drain sat untouched until the server restarted:
774
+ // the action stayed `revert_pending`, `setMode` refused because something was in flight,
775
+ // and Control showed no way out of it.
776
+ const hasDue = this.store.read().actions.some(action =>
777
+ isWaitingState(action.state) && (action.nextAt ?? 0) <= now)
778
+ if (hasDue) {
779
+ this.trigger('pending_retry')
780
+ return { fired: true, reason: 'pending_retry' }
781
+ }
782
+ // A pass live meeting work turned away (QA round 2). While that work still runs, firing it
783
+ // would only defer again, and so would a due run, so both wait. Once the work is done the
784
+ // owed pass runs under its own trigger, rather than six hours later.
785
+ if (this.deferredTrigger) {
786
+ if (this.captureActive()) return { fired: false, reason: 'capture_active' }
787
+ const owed = this.deferredTrigger
788
+ this.deferredTrigger = null
789
+ this.trigger(owed)
790
+ return { fired: true, reason: 'deferred_pass' }
791
+ }
792
+ if (now - this.lastDueRunAt < ENGINE_DUE_INTERVAL_MS) return { fired: false, reason: 'not_due' }
793
+ this.lastDueRunAt = now
794
+ this.trigger('tick')
795
+ return { fired: true, reason: 'due' }
796
+ }
797
+
798
+ /**
799
+ * A speaker mutation landed on a G2 session. Re-derive, do not re-decide.
800
+ *
801
+ * A relabel changes the NAMES on a merged record, not whether the merge was right, so this
802
+ * must never take a new action or surface a new suggestion. It re-runs the render for the
803
+ * actions that already hold this session and rewrites them only if the bytes differ.
804
+ */
805
+ rederive(sessionId: string): Promise<{ rewritten: number }> {
806
+ return this.enqueue(async () => {
807
+ const affected = this.store.read().actions.filter(action =>
808
+ action.state === 'applied' && action.mode === 'imports' && action.inputs.sessionIds.includes(sessionId))
809
+ if (affected.length === 0) return { rewritten: 0 }
810
+ const outcome = await this.execute('rederive', { rederiveOnly: affected.map(action => action.id) })
811
+ return { rewritten: outcome.auto }
812
+ })
813
+ }
814
+
815
+ // ── The pass ────────────────────────────────────────────────────────────────
816
+
817
+ private async execute(trigger: string, options: { rederiveOnly?: string[] } = {}): Promise<RunOutcome> {
818
+ const mode = this.mode()
819
+ const startedAt = this.now()
820
+ this.hashCacheSeen = new Set<string>()
821
+ this.pendingInputScan = null
822
+ this.collectedThisPass = false
823
+ const summary: RunOutcome = {
824
+ at: new Date(startedAt).toISOString(),
825
+ mode,
826
+ trigger,
827
+ scanned: 0,
828
+ auto: 0,
829
+ suggested: 0,
830
+ wouldMerge: 0,
831
+ none: 0,
832
+ alreadyMerged: 0,
833
+ deferredByCap: 0,
834
+ errors: 0,
835
+ actions: [],
836
+ suggestions: [],
837
+ }
838
+
839
+ if (!this.admissionsOpen()) return await this.finishRun({ ...summary, skippedReason: 'maintenance_deferred' })
840
+ // Principle 7. Live meeting work owns the CPU; the backlog can wait 30 seconds. The pass is
841
+ // REMEMBERED, so the tick runs it once that work is done.
842
+ if (this.captureActive()) {
843
+ this.deferredTrigger = trigger
844
+ return await this.finishRun({ ...summary, skippedReason: 'capture_active' })
845
+ }
846
+ // This pass is running now, and every pass is a full pass, so nothing is owed any more.
847
+ this.deferredTrigger = null
848
+
849
+ const before = this.store.read()
850
+ this.loadHashCache(before)
851
+
852
+ // Waiting pipeline work is driven whatever else this pass decides, INCLUDING on a pass
853
+ // that bails: an apply or an Undo the pipeline still owes must not be held hostage to
854
+ // whether any input file happens to have changed.
855
+ const drivePending = before.actions.filter(action =>
856
+ isWaitingState(action.state) && (action.nextAt ?? 0) <= startedAt)
857
+
858
+ // Nothing moved since the last collected pass, so there is nothing to collect. This is
859
+ // the common case on every trigger and it costs one stat per input file.
860
+ const forced = this.forceNextCollection
861
+ this.forceNextCollection = false
862
+ if (!options.rederiveOnly && !this.collectInjected) {
863
+ const stamp = scanInputStamp(mode, this.library)
864
+ const last = before.status.lastInputScan
865
+ if (!forced && last && last.mode === mode && last.newestMtimeMs === stamp.newestMtimeMs && last.count === stamp.count) {
866
+ for (const action of drivePending) {
867
+ const driven = await this.driveWaitingAction(action.id)
868
+ if (!driven.ok) summary.errors += 1
869
+ }
870
+ return await this.finishRun({ ...summary, skippedReason: 'inputs_unchanged' })
871
+ }
872
+ this.pendingInputScan = { ...stamp, mode }
873
+ }
874
+
875
+ let inputs: EngineInputs
876
+ try {
877
+ inputs = await this.collect(mode)
878
+ } catch (error) {
879
+ this.log(`input collection failed: ${describe(error)}`)
880
+ this.pendingInputScan = null
881
+ return await this.finishRun({ ...summary, errors: 1, skippedReason: 'inputs_unreadable' })
882
+ }
883
+ this.collectedThisPass = true
884
+ this.rememberFirefliesPaths(inputs)
885
+ if (inputs.skipped && Object.keys(inputs.skipped).length > 0) summary.inputsSkipped = inputs.skipped
886
+ if (inputs.g2.length + inputs.fireflies.length > ENGINE_MAX_INPUTS) {
887
+ return await this.finishRun({ ...summary, skippedReason: 'too_many_inputs' })
888
+ }
889
+ // Anything the pipeline already merged is adopted once and never scored again
890
+ // (v3 blocker 5). Without this the engine re-proposes every merge a person already has.
891
+ const adopted = await this.adoptLegacy(inputs, before)
892
+ summary.alreadyMerged = adopted.size
893
+
894
+ const g2ById = new Map(inputs.g2.map(row => [row.sessionId, row]))
895
+ const firefliesById = new Map(inputs.fireflies.map(row => [row.id, row]))
896
+
897
+ const scoreable = {
898
+ g2: inputs.g2.filter(row => !adopted.has(`g2:${row.sessionId}`)),
899
+ fireflies: inputs.fireflies.filter(row => !adopted.has(`ff:${row.id}`)),
900
+ }
901
+ summary.scanned = scoreable.g2.length
902
+
903
+ let score: EngineScoreResult | null = null
904
+ if (!options.rederiveOnly && scoreable.g2.length > 0 && scoreable.fireflies.length > 0) {
905
+ try {
906
+ const result = await this.engine({ kind: 'score', g2: scoreable.g2, fireflies: scoreable.fireflies })
907
+ if (result.kind !== 'score') throw new Error(`engine returned ${result.kind}`)
908
+ score = result
909
+ } catch (error) {
910
+ this.log(`engine failed: ${describe(error)}`)
911
+ summary.errors += 1
912
+ }
913
+ }
914
+
915
+ const boundary = this.advisoryBoundary(mode, before)
916
+ let autoTaken = 0
917
+
918
+ if (score) {
919
+ summary.none = score.tierCounts.none
920
+ summary.clockBand = clockBandStatsOf(score.pairings, g2ById, firefliesById)
921
+
922
+ for (const group of score.groups) {
923
+ const canonical = sortedInputs(group.sessionIds, [group.primaryFirefliesId, ...group.alternateFirefliesIds])
924
+ const fingerprints = fingerprintsFor(canonical, g2ById, firefliesById, inputs.g2Meta, inputs.firefliesMeta)
925
+ const decision = this.classify(canonical, fingerprints, mode, boundary, inputs, before)
926
+ if (decision === 'skip') continue
927
+ if (decision === 'suggest' || decision === 'would_merge') {
928
+ const id = await this.upsertSuggestion({
929
+ kind: decision === 'would_merge' ? 'would_merge' : 'merge',
930
+ inputs: canonical,
931
+ fingerprints,
932
+ evidence: { K1: group.k1, K2: group.k2 },
933
+ })
934
+ if (id) {
935
+ summary.suggestions.push(id)
936
+ if (decision === 'would_merge') summary.wouldMerge += 1
937
+ else summary.suggested += 1
938
+ }
939
+ continue
940
+ }
941
+ if (autoTaken >= MAX_AUTO_ACTIONS_PER_RUN) { summary.deferredByCap += 1; continue }
942
+ autoTaken += 1
943
+ const taken = await this.takeMergeAction({
944
+ canonical,
945
+ fingerprints,
946
+ tier: 'auto',
947
+ mode,
948
+ group,
949
+ inputs,
950
+ g2ById,
951
+ firefliesById,
952
+ })
953
+ if (taken.ok) { summary.auto += 1; summary.actions.push(taken.id) } else summary.errors += 1
954
+ }
955
+
956
+ for (const suggestion of score.suggestions) {
957
+ const canonical = sortedInputs(suggestion.sessionIds, suggestion.firefliesIds)
958
+ if (isTombstoned(before.tombstones, canonical)) continue
959
+ const fingerprints = fingerprintsFor(canonical, g2ById, firefliesById, inputs.g2Meta, inputs.firefliesMeta)
960
+ const id = await this.upsertSuggestion({ kind: 'merge', inputs: canonical, fingerprints, evidence: suggestion.evidence })
961
+ if (id) { summary.suggestions.push(id); summary.suggested += 1 }
962
+ }
963
+
964
+ for (const plan of score.splits) {
965
+ const canonical = sortedInputs([], [plan.firefliesId])
966
+ if (isTombstoned(before.tombstones, canonical)) continue
967
+ const fingerprints = fingerprintsFor(canonical, g2ById, firefliesById, inputs.g2Meta, inputs.firefliesMeta)
968
+ const decision = this.classifySplit(plan.firefliesId, canonical, mode, boundary, firefliesById, before)
969
+ if (decision === 'skip') continue
970
+ if (decision === 'auto') {
971
+ if (autoTaken >= MAX_AUTO_ACTIONS_PER_RUN) { summary.deferredByCap += 1; continue }
972
+ autoTaken += 1
973
+ const taken = await this.takeSplitAction({ canonical, fingerprints, tier: 'auto', plan, inputs, g2ById, firefliesById })
974
+ if (taken.ok) { summary.auto += 1; summary.actions.push(taken.id) } else summary.errors += 1
975
+ continue
976
+ }
977
+ const id = await this.upsertSuggestion({
978
+ kind: 'split',
979
+ inputs: canonical,
980
+ fingerprints,
981
+ evidence: { K1: 0, K2: 0, spans: plan.pieces.map(piece => ({ startS: piece.startS, endS: piece.endS })) },
982
+ })
983
+ if (id) { summary.suggestions.push(id); summary.suggested += 1 }
984
+ }
985
+ }
986
+
987
+ // Accepted-in-advise items become real actions once the mode is apply (WS4 step 7).
988
+ if (mode === 'apply') {
989
+ for (const suggestion of before.suggestions) {
990
+ if (suggestion.state !== 'confirmed' || suggestion.kind !== 'would_merge') continue
991
+ if (autoTaken >= MAX_AUTO_ACTIONS_PER_RUN) { summary.deferredByCap += 1; continue }
992
+ autoTaken += 1
993
+ const taken = await this.takeMergeAction({
994
+ canonical: suggestion.inputs,
995
+ fingerprints: suggestion.fingerprints,
996
+ tier: 'accepted_suggestion',
997
+ mode,
998
+ inputs,
999
+ g2ById,
1000
+ firefliesById,
1001
+ })
1002
+ if (taken.ok) {
1003
+ summary.auto += 1
1004
+ summary.actions.push(taken.id)
1005
+ await this.markSuggestion(suggestion.id, 'accepted')
1006
+ } else summary.errors += 1
1007
+ }
1008
+ }
1009
+
1010
+ for (const action of drivePending) {
1011
+ const driven = await this.driveWaitingAction(action.id)
1012
+ if (!driven.ok) summary.errors += 1
1013
+ }
1014
+
1015
+ return await this.finishRun(summary)
1016
+ }
1017
+
1018
+ /**
1019
+ * The D14 boundary: recordings that finished before this only ever become suggestions.
1020
+ *
1021
+ * In imports mode it is when the engine arrived; in apply mode it is when a person chose
1022
+ * apply, because that is the first moment they had seen the advise report.
1023
+ */
1024
+ private advisoryBoundary(mode: MeetingEngineMode, store: ActionsStoreFile): number {
1025
+ if (mode === 'apply') return store.status.applyModeSince ?? store.status.engineInstalledAt ?? this.now()
1026
+ return store.status.engineInstalledAt ?? this.now()
1027
+ }
1028
+
1029
+ private classify(
1030
+ canonical: CanonicalInputs,
1031
+ fingerprints: string,
1032
+ mode: MeetingEngineMode,
1033
+ boundary: number,
1034
+ inputs: EngineInputs,
1035
+ store: ActionsStoreFile,
1036
+ ): 'auto' | 'suggest' | 'would_merge' | 'skip' {
1037
+ if (isTombstoned(store.tombstones, canonical)) return 'skip'
1038
+ const id = actionIdFor('merge', canonical)
1039
+ const existing = store.actions.find(action => action.id === id)
1040
+ // `applied` is already covered by the line above; a second check for it was dead code.
1041
+ if (existing && existing.state !== 'failed' && existing.state !== 'reverted') return 'skip'
1042
+ const suggestion = store.suggestions.find(row => row.id === suggestionIdFor('merge', canonical)
1043
+ || row.id === suggestionIdFor('would_merge', canonical))
1044
+ if (suggestion?.state === 'dismissed') return 'skip'
1045
+
1046
+ // Advise mode NEVER applies. The auto tier becomes something to look at.
1047
+ if (mode === 'advise') return 'would_merge'
1048
+
1049
+ const tooOld = canonical.sessionIds.some(sessionId => (inputs.g2Meta[sessionId]?.finalizedAtMs ?? 0) < boundary)
1050
+ return tooOld ? 'suggest' : 'auto'
1051
+ }
1052
+
1053
+ /**
1054
+ * What to do with a split plan.
1055
+ *
1056
+ * SPLITS ARE AN IMPORTS-MODE ACTION ONLY. A split writes NEW records, one per piece; in
1057
+ * apply mode the pipeline's job is to splice a patch into a scribe that already exists,
1058
+ * and there is no additive, revertible way to turn one operations scribe into three. In
1059
+ * advise and apply the plan therefore stays a suggestion for a person to look at, which is
1060
+ * also what the copy, the changelog and the contract now say.
1061
+ *
1062
+ * D14 applies here as it does to merges: a recording that finished before the engine
1063
+ * arrived is one nobody has measured, so it is only ever offered.
1064
+ */
1065
+ private classifySplit(
1066
+ firefliesId: string,
1067
+ canonical: CanonicalInputs,
1068
+ mode: MeetingEngineMode,
1069
+ boundary: number,
1070
+ firefliesById: Map<string, FirefliesMeetingInput>,
1071
+ store: ActionsStoreFile,
1072
+ ): 'auto' | 'suggest' | 'skip' {
1073
+ const id = actionIdFor('split', canonical)
1074
+ const existing = store.actions.find(action => action.id === id)
1075
+ if (existing && existing.state !== 'failed' && existing.state !== 'reverted') return 'skip'
1076
+ const suggestion = store.suggestions.find(row => row.id === suggestionIdFor('split', canonical))
1077
+ if (suggestion?.state === 'dismissed' || suggestion?.state === 'accepted') return 'skip'
1078
+ if (mode !== 'imports') return 'suggest'
1079
+ const source = firefliesById.get(firefliesId)
1080
+ if (!source) return 'skip'
1081
+ const finishedAtMs = source.startMs + Math.max(0, source.durationS) * 1000
1082
+ return finishedAtMs < boundary ? 'suggest' : 'auto'
1083
+ }
1084
+
1085
+ /**
1086
+ * Record, exactly once, every merge the pipeline already made.
1087
+ *
1088
+ * Two tells, both written by the old blend path: a G2 sidecar stamped `blended_into` or
1089
+ * `claimed_parent`, and a Fireflies scribe carrying the blended marker without one of this
1090
+ * engine's own `merge-action` markers. Either makes a `legacy_applied` action, which
1091
+ * nothing ever merges, suggests or reverts.
1092
+ */
1093
+ private async adoptLegacy(inputs: EngineInputs, store: ActionsStoreFile): Promise<Set<string>> {
1094
+ const adopted = new Set<string>()
1095
+ const newRows: MergeActionRecord[] = []
1096
+ const known = new Set(store.actions.map(action => action.id))
1097
+
1098
+ for (const [sessionId, meta] of Object.entries(inputs.g2Meta)) {
1099
+ const parent = meta.blendedInto ?? meta.claimedParent
1100
+ if (!parent) continue
1101
+ adopted.add(`g2:${sessionId}`)
1102
+ const canonical = sortedInputs([sessionId], [])
1103
+ const id = actionIdFor('legacy', canonical)
1104
+ if (known.has(id)) continue
1105
+ known.add(id)
1106
+ newRows.push({
1107
+ id,
1108
+ kind: 'merge',
1109
+ tier: 'legacy_applied',
1110
+ inputs: canonical,
1111
+ fingerprints: '',
1112
+ outputs: [{ path: parent }],
1113
+ outputSha256: [],
1114
+ state: 'applied',
1115
+ mode: 'apply',
1116
+ at: new Date(this.now()).toISOString(),
1117
+ legacyParentPath: parent,
1118
+ })
1119
+ }
1120
+
1121
+ for (const [firefliesId, meta] of Object.entries(inputs.firefliesMeta)) {
1122
+ if (!meta.blendedMarker || meta.mergeActionId) continue
1123
+ adopted.add(`ff:${firefliesId}`)
1124
+ const canonical = sortedInputs([], [firefliesId])
1125
+ const id = actionIdFor('legacy', canonical)
1126
+ if (known.has(id)) continue
1127
+ known.add(id)
1128
+ newRows.push({
1129
+ id,
1130
+ kind: 'merge',
1131
+ tier: 'legacy_applied',
1132
+ inputs: canonical,
1133
+ fingerprints: '',
1134
+ outputs: meta.scribeRelPath ? [{ path: meta.scribeRelPath }] : [],
1135
+ outputSha256: [],
1136
+ state: 'applied',
1137
+ mode: 'apply',
1138
+ at: new Date(this.now()).toISOString(),
1139
+ ...(meta.scribeRelPath ? { legacyParentPath: meta.scribeRelPath } : {}),
1140
+ })
1141
+ }
1142
+
1143
+ if (newRows.length > 0) {
1144
+ await this.store.update(current => { current.actions.push(...newRows) })
1145
+ }
1146
+ return adopted
1147
+ }
1148
+
1149
+ private async upsertSuggestion(input: {
1150
+ kind: MergeSuggestionRecord['kind']
1151
+ inputs: CanonicalInputs
1152
+ fingerprints: string
1153
+ evidence: MergeSuggestionRecord['evidence']
1154
+ }): Promise<string | null> {
1155
+ const id = suggestionIdFor(input.kind, input.inputs)
1156
+ return await this.store.update(store => {
1157
+ const existing = store.suggestions.find(row => row.id === id)
1158
+ // A dismissed input set never reopens, and a decided one is not re-asked.
1159
+ if (existing && existing.state !== 'open') return null
1160
+ if (existing) {
1161
+ existing.fingerprints = input.fingerprints
1162
+ existing.evidence = input.evidence
1163
+ return id
1164
+ }
1165
+ store.suggestions.push({
1166
+ id,
1167
+ kind: input.kind,
1168
+ inputs: input.inputs,
1169
+ fingerprints: input.fingerprints,
1170
+ evidence: input.evidence,
1171
+ state: 'open',
1172
+ at: new Date(this.now()).toISOString(),
1173
+ })
1174
+ return id
1175
+ })
1176
+ }
1177
+
1178
+ private async markSuggestion(id: string, state: MergeSuggestionRecord['state']): Promise<void> {
1179
+ await this.store.update(store => {
1180
+ const row = store.suggestions.find(item => item.id === id)
1181
+ if (!row) return
1182
+ row.state = state
1183
+ row.decidedAt = new Date(this.now()).toISOString()
1184
+ })
1185
+ }
1186
+
1187
+ // ── Taking one action ───────────────────────────────────────────────────────
1188
+
1189
+ private async takeMergeAction(input: {
1190
+ canonical: CanonicalInputs
1191
+ fingerprints: string
1192
+ tier: 'auto' | 'accepted_suggestion'
1193
+ mode: MeetingEngineMode
1194
+ group?: MergeGroup
1195
+ inputs: EngineInputs
1196
+ g2ById: Map<string, G2RecordingInput>
1197
+ firefliesById: Map<string, FirefliesMeetingInput>
1198
+ }): Promise<{ ok: boolean; id: string }> {
1199
+ const id = actionIdFor('merge', input.canonical)
1200
+ const primaryId = input.group?.primaryFirefliesId ?? input.canonical.firefliesIds[0]
1201
+ const primary = primaryId ? input.firefliesById.get(primaryId) : undefined
1202
+ const captures = input.canonical.sessionIds
1203
+ .map(sessionId => input.g2ById.get(sessionId))
1204
+ .filter((row): row is G2RecordingInput => row != null)
1205
+ .sort((a, b) => a.startMs - b.startMs)
1206
+ if (!primary || captures.length === 0) return { ok: false, id }
1207
+ const alternates = (input.group?.alternateFirefliesIds ?? input.canonical.firefliesIds.filter(ffId => ffId !== primaryId))
1208
+ .map(ffId => input.firefliesById.get(ffId))
1209
+ .filter((row): row is FirefliesMeetingInput => row != null)
1210
+
1211
+ let lease: MaintenanceWorkLease
1212
+ try {
1213
+ lease = this.acquireLease()
1214
+ } catch {
1215
+ // A drain committed between the engine and the write. Nothing has happened yet;
1216
+ // the next run repeats this decision.
1217
+ this.log(`action ${id} deferred: maintenance_drain_active`)
1218
+ return { ok: false, id }
1219
+ }
1220
+
1221
+ try {
1222
+ if (input.mode === 'apply') {
1223
+ return { ok: await this.writeApplyDecision({ ...input, id, primary, alternates, captures }), id }
1224
+ }
1225
+ return { ok: await this.writeImportsRecord({ ...input, id, primary, alternates, captures }), id }
1226
+ } catch (error) {
1227
+ this.log(`action ${id} failed: ${describe(error)}`)
1228
+ await this.failAction(id, describe(error), input.canonical, input.fingerprints, input.mode)
1229
+ return { ok: false, id }
1230
+ } finally {
1231
+ lease.release()
1232
+ }
1233
+ }
1234
+
1235
+ /**
1236
+ * Cut one long recording into the meetings it holds, one record per piece.
1237
+ *
1238
+ * IMPORTS MODE ONLY, and additive like every other action: the original import stays on
1239
+ * disk and the pieces supersede it in the list, so Revert is deleting what was added.
1240
+ *
1241
+ * ALL OR NOTHING. A half-written split is a recording that appears twice, once whole and
1242
+ * once in fragments, so a piece that fails to derive takes the whole action down and
1243
+ * removes whatever was already written. There is no partial split state to explain.
1244
+ */
1245
+ /** The engine's own plan for this one recording, re-made from what is on disk now. */
1246
+ private async splitPlanFor(source: FirefliesMeetingInput, g2: readonly G2RecordingInput[]): Promise<SplitPlan | null> {
1247
+ const result = await this.engine({ kind: 'score', g2: [...g2], fireflies: [source] })
1248
+ if (result.kind !== 'score') return null
1249
+ return result.splits.find(plan => plan.firefliesId === source.id && plan.split) ?? null
1250
+ }
1251
+
1252
+ private async takeSplitAction(input: {
1253
+ canonical: CanonicalInputs
1254
+ fingerprints: string
1255
+ tier: 'auto' | 'accepted_suggestion'
1256
+ plan: SplitPlan
1257
+ inputs: EngineInputs
1258
+ g2ById: Map<string, G2RecordingInput>
1259
+ firefliesById: Map<string, FirefliesMeetingInput>
1260
+ }): Promise<{ ok: boolean; id: string }> {
1261
+ const id = actionIdFor('split', input.canonical)
1262
+ const source = input.firefliesById.get(input.plan.firefliesId)
1263
+ if (!source || input.plan.pieces.length < 2) return { ok: false, id }
1264
+
1265
+ let lease: MaintenanceWorkLease
1266
+ try {
1267
+ lease = this.acquireLease()
1268
+ } catch {
1269
+ this.log(`split ${id} deferred: maintenance_drain_active`)
1270
+ return { ok: false, id }
1271
+ }
1272
+
1273
+ const sourceRecordId = importRecordId('fireflies', importHash(source.id))
1274
+ const written: Array<{ path: string; recordId: string; sidecarPath?: string; markdown: string; pieceIndex: number }> = []
1275
+ try {
1276
+ for (const piece of input.plan.pieces) {
1277
+ const captures = piece.sessionIds
1278
+ .map(sessionId => input.g2ById.get(sessionId))
1279
+ .filter((row): row is G2RecordingInput => row != null)
1280
+ const derived = await this.engine({
1281
+ kind: 'derive_piece',
1282
+ input: { actionId: id, tier: input.tier, source, piece, pieceCount: input.plan.pieces.length, captures },
1283
+ })
1284
+ if (derived.kind !== 'derive') throw new Error('engine_result_kind')
1285
+ if (!derived.result.ok) throw new Error(derived.result.error)
1286
+ const record = derived.result.record
1287
+ const result = this.library.writeRecord({
1288
+ kind: 'piece',
1289
+ hash: pieceHash(sourceRecordId, piece.index),
1290
+ dateMs: source.startMs + piece.startS * 1000,
1291
+ markdown: record.markdown,
1292
+ sidecar: record.sidecar,
1293
+ })
1294
+ written.push({
1295
+ path: result.filepath,
1296
+ recordId: importRecordId('piece', pieceHash(sourceRecordId, piece.index)),
1297
+ sidecarPath: result.sidecarPath,
1298
+ markdown: record.markdown,
1299
+ pieceIndex: piece.index,
1300
+ })
1301
+ }
1302
+ } catch (error) {
1303
+ for (const piece of written) {
1304
+ for (const path of [piece.path, piece.sidecarPath]) {
1305
+ if (path) { try { unlinkSync(path) } catch { /* nothing written is the state we wanted */ } }
1306
+ }
1307
+ }
1308
+ lease.release()
1309
+ this.log(`split ${id} failed: ${describe(error)}`)
1310
+ await this.failAction(id, describe(error), input.canonical, input.fingerprints, 'imports', { kind: 'split' })
1311
+ return { ok: false, id }
1312
+ }
1313
+ lease.release()
1314
+
1315
+ await this.store.update(store => {
1316
+ upsertAction(store, {
1317
+ id,
1318
+ kind: 'split',
1319
+ tier: input.tier,
1320
+ inputs: input.canonical,
1321
+ fingerprints: input.fingerprints,
1322
+ outputs: written.map(piece => ({ path: piece.path, recordId: piece.recordId, sidecarPath: piece.sidecarPath })),
1323
+ outputSha256: written.map(piece => sha256OfText(piece.markdown)),
1324
+ state: 'applied',
1325
+ mode: 'imports',
1326
+ direction: 'apply',
1327
+ at: new Date(this.now()).toISOString(),
1328
+ })
1329
+ })
1330
+ return { ok: true, id }
1331
+ }
1332
+
1333
+ /** imports mode: render a derived record and write it through the library. */
1334
+ private async writeImportsRecord(input: {
1335
+ id: string
1336
+ canonical: CanonicalInputs
1337
+ fingerprints: string
1338
+ tier: 'auto' | 'accepted_suggestion'
1339
+ group?: MergeGroup
1340
+ primary: FirefliesMeetingInput
1341
+ alternates: FirefliesMeetingInput[]
1342
+ captures: G2RecordingInput[]
1343
+ }): Promise<boolean> {
1344
+ const derived = await this.engine({
1345
+ kind: 'derive_merge',
1346
+ input: {
1347
+ actionId: input.id,
1348
+ tier: input.tier,
1349
+ primary: input.primary,
1350
+ alternates: input.alternates,
1351
+ captures: input.captures,
1352
+ // The GROUP's real K, not zeros. The derived sidecar's `evidence` is the only record
1353
+ // of why a merge happened, and a sidecar that says K1 = K2 = 0 says a merge with no
1354
+ // evidence behind it took place automatically.
1355
+ evidence: { k1: input.group?.k1 ?? 0, k2: input.group?.k2 ?? 0 },
1356
+ ...(coarseOffsets(input.group) ? { coarseOffsetMsBySession: coarseOffsets(input.group)! } : {}),
1357
+ },
1358
+ })
1359
+ if (derived.kind !== 'derive') {
1360
+ await this.failAction(input.id, 'engine_result_kind', input.canonical, input.fingerprints, 'imports')
1361
+ return false
1362
+ }
1363
+ if (!derived.result.ok) {
1364
+ await this.failAction(input.id, derived.result.error, input.canonical, input.fingerprints, 'imports')
1365
+ return false
1366
+ }
1367
+ const record = derived.result.record
1368
+ const hash = mergedHash(input.primary.id, input.captures.map(capture => capture.sessionId))
1369
+ const written = this.library.writeRecord({
1370
+ kind: 'merged',
1371
+ hash,
1372
+ dateMs: input.primary.startMs,
1373
+ markdown: record.markdown,
1374
+ sidecar: record.sidecar,
1375
+ })
1376
+ await this.store.update(store => {
1377
+ upsertAction(store, {
1378
+ id: input.id,
1379
+ kind: 'merge',
1380
+ tier: input.tier,
1381
+ inputs: input.canonical,
1382
+ fingerprints: input.fingerprints,
1383
+ outputs: [{ path: written.filepath, recordId: importRecordId('merged', hash), sidecarPath: written.sidecarPath }],
1384
+ outputSha256: [sha256OfText(record.markdown)],
1385
+ state: 'applied',
1386
+ mode: 'imports',
1387
+ at: new Date(this.now()).toISOString(),
1388
+ })
1389
+ })
1390
+ return true
1391
+ }
1392
+
1393
+ /** apply mode: write the decision file, record the action pending, then drive it. */
1394
+ private async writeApplyDecision(input: {
1395
+ id: string
1396
+ canonical: CanonicalInputs
1397
+ fingerprints: string
1398
+ tier: 'auto' | 'accepted_suggestion'
1399
+ group?: MergeGroup
1400
+ primary: FirefliesMeetingInput
1401
+ alternates: FirefliesMeetingInput[]
1402
+ captures: G2RecordingInput[]
1403
+ inputs: EngineInputs
1404
+ }): Promise<boolean> {
1405
+ const firefliesMeta = input.inputs.firefliesMeta[input.primary.id]
1406
+ if (!firefliesMeta?.scribeRelPath || !firefliesMeta.sidecarRelPath || !firefliesMeta.sha256) {
1407
+ await this.failAction(input.id, 'fireflies_scribe_missing', input.canonical, input.fingerprints, 'apply')
1408
+ return false
1409
+ }
1410
+ const sidecarRelPathBySession: Record<string, string> = {}
1411
+ const decisionInputs: MergeDecisionInput[] = []
1412
+ for (const capture of input.captures) {
1413
+ const meta = input.inputs.g2Meta[capture.sessionId]
1414
+ if (!meta?.sidecarRelPath || !meta.sha256) {
1415
+ await this.failAction(input.id, 'g2_sidecar_missing', input.canonical, input.fingerprints, 'apply')
1416
+ return false
1417
+ }
1418
+ sidecarRelPathBySession[capture.sessionId] = meta.sidecarRelPath
1419
+ decisionInputs.push({
1420
+ kind: 'g2',
1421
+ sessionId: capture.sessionId,
1422
+ sidecarRelPath: meta.sidecarRelPath,
1423
+ ...(meta.scribeRelPath ? { scribeRelPath: meta.scribeRelPath } : {}),
1424
+ sha256: meta.sha256,
1425
+ })
1426
+ }
1427
+ decisionInputs.push({
1428
+ kind: 'fireflies',
1429
+ firefliesId: input.primary.id,
1430
+ scribeRelPath: firefliesMeta.scribeRelPath,
1431
+ sidecarRelPath: firefliesMeta.sidecarRelPath,
1432
+ sha256: firefliesMeta.sha256,
1433
+ })
1434
+
1435
+ const patch = renderPipelinePatch({
1436
+ actionId: input.id,
1437
+ tier: input.tier,
1438
+ primary: input.primary,
1439
+ alternates: input.alternates,
1440
+ captures: input.captures,
1441
+ evidence: { k1: input.group?.k1 ?? 0, k2: input.group?.k2 ?? 0 },
1442
+ ...(coarseOffsets(input.group) ? { coarseOffsetMsBySession: coarseOffsets(input.group)! } : {}),
1443
+ sidecarRelPathBySession,
1444
+ })
1445
+ const decision: MergeDecision = {
1446
+ schema: DECISION_SCHEMA,
1447
+ actionId: input.id,
1448
+ kind: 'merge',
1449
+ inputs: decisionInputs,
1450
+ patch,
1451
+ speakerMap: patch.speakerMap,
1452
+ retire: input.captures
1453
+ .map(capture => input.inputs.g2Meta[capture.sessionId]?.scribeRelPath)
1454
+ .filter((path): path is string => typeof path === 'string'),
1455
+ }
1456
+ try {
1457
+ writeDecision(decision, this.library.root)
1458
+ } catch (error) {
1459
+ // A patch that adds nothing would apply cleanly, retire the capture and leave the
1460
+ // scribe exactly as it was. The refusal is recorded on the action so a person sees
1461
+ // why this one did not happen rather than seeing it quietly not happen.
1462
+ const code = error instanceof DecisionError ? error.code : describe(error)
1463
+ await this.failAction(input.id, code, input.canonical, input.fingerprints, 'apply')
1464
+ return false
1465
+ }
1466
+
1467
+ await this.store.update(store => {
1468
+ upsertAction(store, {
1469
+ id: input.id,
1470
+ kind: 'merge',
1471
+ tier: input.tier,
1472
+ inputs: input.canonical,
1473
+ fingerprints: input.fingerprints,
1474
+ outputs: [{ path: firefliesMeta.scribeRelPath! }],
1475
+ outputSha256: [],
1476
+ state: 'pending',
1477
+ mode: 'apply',
1478
+ // Written down HERE, once, rather than inferred from the state later. A failed
1479
+ // apply and a failed revert both come back to a waiting state, and only this field
1480
+ // says which command the next drive should spawn.
1481
+ direction: 'apply',
1482
+ at: new Date(this.now()).toISOString(),
1483
+ attempts: 0,
1484
+ })
1485
+ })
1486
+ const driven = await this.drivePipelineAction(input.id)
1487
+ return driven.ok
1488
+ }
1489
+
1490
+ // ── The pipeline ────────────────────────────────────────────────────────────
1491
+
1492
+ /**
1493
+ * Drive one waiting action, whatever kind of Mac wrote it.
1494
+ *
1495
+ * An `imports`-mode action can also be left waiting — a Revert that claimed the action and
1496
+ * then lost the process before it finished deleting. It has no pipeline to spawn, so it is
1497
+ * finished here rather than handed to a spawn that would answer `no_pipeline` forever.
1498
+ */
1499
+ async driveWaitingAction(actionId: string): Promise<{ ok: boolean; state: MergeActionRecord['state']; reason?: string }> {
1500
+ const action = this.store.read().actions.find(row => row.id === actionId)
1501
+ if (!action) return { ok: false, state: 'failed', reason: 'action_not_found' }
1502
+ if (!isWaitingState(action.state)) return { ok: true, state: action.state }
1503
+ if (action.mode === 'imports') {
1504
+ if (directionOf(action) !== 'revert') return { ok: true, state: action.state }
1505
+ await this.revertImportsRecord(action)
1506
+ return { ok: true, state: 'reverted' }
1507
+ }
1508
+ return await this.drivePipelineAction(actionId)
1509
+ }
1510
+
1511
+ /**
1512
+ * Spawn the pipeline for one waiting action and record what it reported.
1513
+ *
1514
+ * NO SERVER LOCK AROUND THE SPAWN (v3 blocker 1). The child takes the pipeline's own lock
1515
+ * with no retries. Exit 3 means it could not, which is normal while the hourly sync runs:
1516
+ * the action stays waiting and comes back in two minutes. Anything else is a real
1517
+ * outcome, and every failure names a step so a person can see where it stopped.
1518
+ *
1519
+ * THE DIRECTION COMES FROM THE ROW, NOT THE STATE. A failed revert is reset to a waiting
1520
+ * state so it can be retried; reading the direction back out of that state turned the
1521
+ * retry of an Undo into a Redo.
1522
+ */
1523
+ async drivePipelineAction(actionId: string): Promise<{ ok: boolean; state: MergeActionRecord['state']; reason?: string }> {
1524
+ const spawn = this.spawnPipeline
1525
+ if (!spawn) return { ok: false, state: 'pending', reason: 'no_pipeline' }
1526
+ const action = this.store.read().actions.find(row => row.id === actionId)
1527
+ if (!action) return { ok: false, state: 'failed', reason: 'action_not_found' }
1528
+ if (!isWaitingState(action.state)) return { ok: true, state: action.state }
1529
+ const direction = directionOf(action)
1530
+ const waiting = pendingStateFor(direction)
1531
+
1532
+ let lease: MaintenanceWorkLease
1533
+ try {
1534
+ lease = this.acquireLease()
1535
+ } catch {
1536
+ await this.deferAction(actionId, 'maintenance_drain_active')
1537
+ return { ok: false, state: waiting, reason: 'maintenance_drain_active' }
1538
+ }
1539
+
1540
+ let attempt: PipelineAttempt
1541
+ try {
1542
+ attempt = await spawn(
1543
+ direction === 'revert' ? ['--revert-merge-decision', actionId] : ['--apply-merge-decision', actionId],
1544
+ { actionId },
1545
+ )
1546
+ } catch (error) {
1547
+ lease.release()
1548
+ await this.failAction(actionId, `spawn_failed: ${describe(error)}`, undefined, undefined, undefined, {
1549
+ diagnostics: { code: null, signal: null, timedOut: false, elapsedMs: 0, spawnError: describe(error) },
1550
+ })
1551
+ return { ok: false, state: 'failed', reason: 'spawn_failed' }
1552
+ }
1553
+ lease.release()
1554
+
1555
+ if (attempt.code === PIPELINE_EXIT_LOCK_BUSY) {
1556
+ await this.deferAction(actionId, 'sync_lock_busy')
1557
+ return { ok: false, state: waiting, reason: 'sync_lock_busy' }
1558
+ }
1559
+
1560
+ const diagnostics = diagnosticsOf(attempt)
1561
+ const parsed = parseResultLine<MergePipelineResult>(attempt.resultLines, isMergePipelineResult)
1562
+ if (!parsed.ok) {
1563
+ // Exit 4 is terminal whatever it printed: the decision does not match the disk, and a
1564
+ // retry cannot make it match.
1565
+ const terminal = attempt.code === PIPELINE_EXIT_DECISION_INVALID
1566
+ const reason = terminal ? 'decision_invalid' : `result_unreadable:${parsed.reason}`
1567
+ await this.failAction(actionId, reason, undefined, undefined, undefined, { retryable: !terminal, diagnostics })
1568
+ return { ok: false, state: 'failed', reason }
1569
+ }
1570
+
1571
+ const result = parsed.value
1572
+ writeDecisionResult(actionId, result, this.library.root)
1573
+
1574
+ if (result.status === 'applied' || result.status === 'reverted') {
1575
+ await this.store.update(store => {
1576
+ const row = store.actions.find(item => item.id === actionId)
1577
+ if (!row) return
1578
+ row.state = result.status === 'applied' ? 'applied' : 'reverted'
1579
+ row.outputs = result.outputs.map(output => ({ path: output.path }))
1580
+ row.outputSha256 = result.outputs.map(output => output.sha256)
1581
+ delete row.error
1582
+ delete row.nextAt
1583
+ delete row.diagnostics
1584
+ })
1585
+ if (result.status === 'reverted') {
1586
+ await this.tombstoneAction(actionId)
1587
+ deleteDecision(actionId, this.library.root)
1588
+ }
1589
+ return { ok: true, state: result.status === 'applied' ? 'applied' : 'reverted' }
1590
+ }
1591
+
1592
+ const reason = result.error_code ?? result.status
1593
+ await this.failAction(actionId, result.step ? `${reason} at ${result.step}` : reason, undefined, undefined, undefined, {
1594
+ retryable: true,
1595
+ diagnostics,
1596
+ })
1597
+ return { ok: false, state: 'failed', reason }
1598
+ }
1599
+
1600
+ private async deferAction(actionId: string, reason: string): Promise<void> {
1601
+ await this.store.update(store => {
1602
+ const row = store.actions.find(item => item.id === actionId)
1603
+ if (!row) return
1604
+ row.nextAt = this.now() + PIPELINE_LOCK_RETRY_MS
1605
+ row.error = reason
1606
+ })
1607
+ }
1608
+
1609
+ private async failAction(
1610
+ actionId: string,
1611
+ error: string,
1612
+ canonical?: CanonicalInputs,
1613
+ fingerprints?: string,
1614
+ mode?: MeetingEngineMode,
1615
+ options: { retryable?: boolean; kind?: 'merge' | 'split'; diagnostics?: PipelineFailureDiagnostics } = {},
1616
+ ): Promise<void> {
1617
+ await this.store.update(store => {
1618
+ const row = store.actions.find(item => item.id === actionId)
1619
+ if (!row) {
1620
+ if (!canonical) return
1621
+ store.actions.push({
1622
+ id: actionId,
1623
+ kind: options.kind ?? 'merge',
1624
+ tier: 'auto',
1625
+ inputs: canonical,
1626
+ fingerprints: fingerprints ?? '',
1627
+ outputs: [],
1628
+ outputSha256: [],
1629
+ state: 'failed',
1630
+ mode: mode === 'apply' ? 'apply' : 'imports',
1631
+ direction: 'apply',
1632
+ error,
1633
+ ...(options.diagnostics ? { diagnostics: options.diagnostics } : {}),
1634
+ at: new Date(this.now()).toISOString(),
1635
+ })
1636
+ return
1637
+ }
1638
+ const attempts = (row.attempts ?? 0) + 1
1639
+ row.attempts = attempts
1640
+ row.error = error
1641
+ if (options.diagnostics) row.diagnostics = options.diagnostics
1642
+ // Retried once, then it stays failed until a person acts. A pipeline that keeps
1643
+ // failing on one decision would otherwise burn a spawn every thirty seconds.
1644
+ // A retryable REVERT comes back as a revert: `pendingStateFor` is the only thing
1645
+ // standing between "try the Undo again" and "do the merge a second time".
1646
+ row.state = options.retryable && attempts < PIPELINE_MAX_ATTEMPTS ? pendingStateFor(directionOf(row)) : 'failed'
1647
+ if (isWaitingState(row.state)) row.nextAt = this.now() + PIPELINE_LOCK_RETRY_MS
1648
+ else delete row.nextAt
1649
+ })
1650
+ }
1651
+
1652
+ private async finishRun(summary: RunOutcome): Promise<RunOutcome> {
1653
+ const { actions, suggestions, ...run } = summary
1654
+ const collected = this.collectedThisPass
1655
+ const scan = this.pendingInputScan
1656
+ const hashCache = collected ? this.hashCacheSnapshot() : null
1657
+ await this.store.update(store => {
1658
+ store.status.engineInstalledAt ??= this.now()
1659
+ store.status.lastRun = run
1660
+ // Recorded only for a pass that actually READ the inputs. A pass that bailed saw
1661
+ // nothing, and writing its (empty) view would throw away the cache that made it
1662
+ // cheap, so the next pass would read every sidecar again.
1663
+ if (collected && scan) store.status.lastInputScan = scan
1664
+ if (collected && hashCache) store.status.hashCache = hashCache
1665
+ if (!store.status.firstRun && !run.skippedReason) {
1666
+ store.status.firstRun = {
1667
+ startedAt: run.at,
1668
+ completedAt: new Date(this.now()).toISOString(),
1669
+ mode: run.mode,
1670
+ scanned: run.scanned,
1671
+ auto: run.auto,
1672
+ wouldMerge: run.wouldMerge,
1673
+ suggested: run.suggested,
1674
+ none: run.none,
1675
+ alreadyMerged: run.alreadyMerged,
1676
+ deferredByCap: run.deferredByCap,
1677
+ errors: run.errors,
1678
+ }
1679
+ }
1680
+ })
1681
+ return summary
1682
+ }
1683
+
1684
+ // ── Suggestions ─────────────────────────────────────────────────────────────
1685
+
1686
+ /**
1687
+ * Suggestions, each with both sides resolved to something a person can read.
1688
+ *
1689
+ * SERVER-SIDE, because only the server can do it on the Mac where it matters. A suggestion
1690
+ * holds canonical ids; on a pipeline Mac the Fireflies side is a scribe in the operations
1691
+ * tree whose path no client knows. The last collected pass already learned that mapping,
1692
+ * so it is remembered here rather than re-scanned per request.
1693
+ */
1694
+ listSuggestions(state?: string): SuggestionWithSides[] {
1695
+ const rows = this.store.read().suggestions
1696
+ const filtered = state && state !== 'all' ? rows.filter(row => row.state === state) : rows
1697
+ return withSuggestionSides(filtered, {
1698
+ library: this.library,
1699
+ firefliesScribeRelPaths: this.firefliesPathsForSides(),
1700
+ })
1701
+ }
1702
+
1703
+ /**
1704
+ * Operations paths per Fireflies id, from the last collected pass or a fresh scan.
1705
+ *
1706
+ * A list request must not cost a full collection, and after any pass in advise or apply
1707
+ * mode the map is already in hand. It is only rebuilt when this process has not run a pass
1708
+ * yet, which is the first request after a restart.
1709
+ */
1710
+ private firefliesPathsForSides(): Record<string, { sidecarRelPath?: string; scribeRelPath?: string }> {
1711
+ if (this.firefliesPaths) return this.firefliesPaths
1712
+ const mode = this.mode()
1713
+ if (mode === 'imports') return {}
1714
+ try {
1715
+ this.rememberFirefliesPaths(collectEngineInputs(mode, this.library, () => undefined))
1716
+ } catch {
1717
+ return {}
1718
+ }
1719
+ return this.firefliesPaths ?? {}
1720
+ }
1721
+
1722
+ private rememberFirefliesPaths(inputs: EngineInputs): void {
1723
+ const paths: Record<string, { sidecarRelPath?: string; scribeRelPath?: string }> = {}
1724
+ for (const [id, meta] of Object.entries(inputs.firefliesMeta)) {
1725
+ if (!meta.sidecarRelPath && !meta.scribeRelPath) continue
1726
+ paths[id] = {
1727
+ ...(meta.sidecarRelPath ? { sidecarRelPath: meta.sidecarRelPath } : {}),
1728
+ ...(meta.scribeRelPath ? { scribeRelPath: meta.scribeRelPath } : {}),
1729
+ }
1730
+ }
1731
+ this.firefliesPaths = paths
1732
+ }
1733
+
1734
+ listActions(limit = 100): MergeActionRecord[] {
1735
+ const rows = this.store.read().actions
1736
+ return rows.slice(-Math.max(1, Math.min(limit, rows.length))).reverse()
1737
+ }
1738
+
1739
+ /** One action by id, for a surface that holds a link to it rather than a page of rows. */
1740
+ getAction(actionId: string): MergeActionRecord {
1741
+ const row = this.store.read().actions.find(action => action.id === actionId)
1742
+ if (!row) throw new ActionRefusedError(404, 'action_not_found', 'That action is gone.')
1743
+ return row
1744
+ }
1745
+
1746
+ /**
1747
+ * Put a failed action back in the queue, in the direction it was already going.
1748
+ *
1749
+ * WHY A ROUTE AND NOT JUST THE NEXT PASS. A merge that failed twice was terminal: nothing
1750
+ * re-drove it, the comment on the boot sweep said otherwise, and Control's Retry button
1751
+ * only reloaded the list. Two failures on one decision is exactly the case where a person
1752
+ * has just fixed the thing that was wrong — a missing file, a full disk, a pipeline that
1753
+ * was mid-upgrade — and the only way to say so was to restart the server.
1754
+ *
1755
+ * A RETRY RESETS THE ATTEMPT BUDGET. The bounded automatic retry exists so a broken
1756
+ * decision cannot burn a spawn every thirty seconds; a person asking for one is not that.
1757
+ */
1758
+ async retryAction(actionId: string): Promise<{ ok: boolean; state: MergeActionRecord['state']; direction: ActionDirection }> {
1759
+ // ASYNC so every refusal is a REJECTED PROMISE. A method that returns a promise and
1760
+ // sometimes throws synchronously needs two error paths at every call site, and the one
1761
+ // a caller forgets turns a 409 into a 500.
1762
+ this.assertAdmissions()
1763
+ if (this.busy()) {
1764
+ throw new ActionRefusedError(409, 'run_in_progress', 'COS is working on meetings right now. Try again shortly.')
1765
+ }
1766
+ const current = this.getAction(actionId)
1767
+ if (isWaitingState(current.state)) {
1768
+ throw new ActionRefusedError(409, 'apply_in_flight', 'That one is already queued. Give it a moment.')
1769
+ }
1770
+ if (current.state !== 'failed') {
1771
+ throw new ActionRefusedError(409, 'action_not_failed', 'Only a failed action can be retried.')
1772
+ }
1773
+ return this.enqueue(async () => {
1774
+ const direction = directionOf(current)
1775
+ // TWO DIFFERENT RETRIES, because the two modes fail at different layers.
1776
+ //
1777
+ // An APPLY-mode action failed in the pipeline, so its retry is another spawn: it goes
1778
+ // back to its own waiting state and the next pass drives it.
1779
+ //
1780
+ // An IMPORTS-mode action failed in the DERIVE, so there is no child to spawn and
1781
+ // nothing to re-drive. Its retry is the engine taking the decision again, and
1782
+ // `classify` already retakes a `failed` row — so the row STAYS failed. Moving it to
1783
+ // `pending` would have stranded it forever: nothing drives a pending imports action,
1784
+ // and `classify` skips every state except `failed` and `reverted`.
1785
+ const state: MergeActionRecord['state'] = current.mode === 'apply' ? pendingStateFor(direction) : 'failed'
1786
+ await this.store.update(store => {
1787
+ const row = store.actions.find(item => item.id === actionId)
1788
+ if (!row || row.state !== 'failed') return
1789
+ row.state = state
1790
+ row.direction = direction
1791
+ row.attempts = 0
1792
+ if (isWaitingState(state)) row.nextAt = this.now()
1793
+ else delete row.nextAt
1794
+ delete row.error
1795
+ delete row.diagnostics
1796
+ })
1797
+ // The next pass would otherwise BAIL: nothing on disk has moved since the last one, and
1798
+ // a person asking for a retry is the one case where that is not a reason to do nothing.
1799
+ this.forceNextCollection = true
1800
+ // Fire and forget, like every other trigger: the route answers now, the pass does it.
1801
+ this.trigger('manual_retry')
1802
+ return { ok: true, state, direction }
1803
+ })
1804
+ }
1805
+
1806
+ /**
1807
+ * The evidence a HUMAN-accepted merge carries (QA round 2, blocker 1).
1808
+ *
1809
+ * WHAT WAS WRONG. The accept path called `takeMergeAction` with no `group`, so the derived
1810
+ * sidecar recorded K1 = K2 = 0, a merge with no evidence behind it, and alignment had no
1811
+ * pairing offset to search around. It fell back to the difference between the two clocks,
1812
+ * which on a capture whose clock is minutes off puts the alignment band beside the real
1813
+ * anchors: the capture attaches unaligned and no speaker is relabelled, silently.
1814
+ *
1815
+ * WHERE EACH HALF COMES FROM.
1816
+ * - K1 and K2 from the suggestion's STORED evidence: what the person was shown when they
1817
+ * agreed, from a full scoring pass. Re-scoring against only this suggestion's meetings
1818
+ * would report K2 = 0 for every one-meeting suggestion, because K2 is the best OTHER
1819
+ * meeting and there is no other.
1820
+ * - Offsets from RE-SCORING this suggestion's own captures against its own meetings. An
1821
+ * offset is the dominant bin of the phrases one capture shares with one meeting, so it does
1822
+ * not depend on the other candidates. The fingerprint check before this proved these are
1823
+ * the bytes that were suggested.
1824
+ *
1825
+ * A scoring failure does not block a merge a person asked for: it proceeds with the stored
1826
+ * evidence and no offsets, which is exactly the old alignment, and logs why.
1827
+ */
1828
+ private async groupForAcceptedMerge(
1829
+ suggestion: MergeSuggestionRecord,
1830
+ g2ById: Map<string, G2RecordingInput>,
1831
+ firefliesById: Map<string, FirefliesMeetingInput>,
1832
+ ): Promise<MergeGroup | undefined> {
1833
+ const canonical = suggestion.inputs
1834
+ const captures = canonical.sessionIds
1835
+ .map(sessionId => g2ById.get(sessionId))
1836
+ .filter((row): row is G2RecordingInput => row != null)
1837
+ .sort((a, b) => a.startMs - b.startMs)
1838
+ const meetings = canonical.firefliesIds
1839
+ .map(ffId => firefliesById.get(ffId))
1840
+ .filter((row): row is FirefliesMeetingInput => row != null)
1841
+ if (captures.length === 0 || meetings.length === 0) return undefined
1842
+
1843
+ let pairings: PairingResult[] = []
1844
+ try {
1845
+ const scored = await this.engine({ kind: 'score', g2: captures, fireflies: meetings })
1846
+ if (scored.kind === 'score') pairings = scored.pairings
1847
+ } catch (error) {
1848
+ this.log(`accepted merge ${suggestion.id}: offsets unavailable: ${describe(error)}`)
1849
+ }
1850
+
1851
+ // The primary is the meeting these captures actually paired with, when they name one of
1852
+ // this suggestion's own; otherwise the suggestion's first, which is what accept used before.
1853
+ const votes = new Map<string, number>()
1854
+ for (const pairing of pairings) {
1855
+ const primary = pairing.primaryFirefliesId
1856
+ if (primary && canonical.firefliesIds.includes(primary)) votes.set(primary, (votes.get(primary) ?? 0) + 1)
1857
+ }
1858
+ const primaryFirefliesId = [...votes.entries()]
1859
+ .sort((a, b) => b[1] - a[1] || (a[0] < b[0] ? -1 : a[0] > b[0] ? 1 : 0))[0]?.[0]
1860
+ ?? canonical.firefliesIds[0]
1861
+ if (!primaryFirefliesId) return undefined
1862
+
1863
+ const offsetMsBySession: Record<string, number | null> = {}
1864
+ for (const capture of captures) {
1865
+ const pairing = pairings.find(row => row.sessionId === capture.sessionId)
1866
+ offsetMsBySession[capture.sessionId] = pairing && pairing.primaryFirefliesId === primaryFirefliesId ? pairing.offsetMs : null
1867
+ }
1868
+ return {
1869
+ primaryFirefliesId,
1870
+ alternateFirefliesIds: canonical.firefliesIds.filter(ffId => ffId !== primaryFirefliesId).sort(),
1871
+ sessionIds: captures.map(capture => capture.sessionId),
1872
+ k1: suggestion.evidence.K1,
1873
+ k2: suggestion.evidence.K2,
1874
+ offsetMsBySession,
1875
+ }
1876
+ }
1877
+
1878
+ /** Imports mode: turn a suggestion into a real merge, or a real split. */
1879
+ acceptSuggestion(id: string): Promise<{ ok: boolean; actionId: string }> {
1880
+ this.assertAdmissions()
1881
+ return this.enqueue(async () => {
1882
+ const mode = this.mode()
1883
+ if (mode === 'advise') {
1884
+ throw new ActionRefusedError(409, 'advise_mode', 'COS does not change your pipeline files in this version.')
1885
+ }
1886
+ const suggestion = this.store.read().suggestions.find(row => row.id === id)
1887
+ if (!suggestion) throw new ActionRefusedError(404, 'suggestion_not_found', 'That suggestion is gone.')
1888
+ if (suggestion.state === 'dismissed') {
1889
+ throw new ActionRefusedError(409, 'suggestion_dismissed', 'That suggestion was dismissed.')
1890
+ }
1891
+ const kind = suggestion.kind === 'split' ? 'split' : 'merge'
1892
+ // A split writes NEW records, one per piece. In apply mode the pipeline splices a
1893
+ // patch into a scribe that already exists, and there is no additive, revertible way
1894
+ // to turn one operations scribe into three, so the honest answer is a refusal rather
1895
+ // than a 200 that did nothing.
1896
+ if (kind === 'split' && mode === 'apply') {
1897
+ throw new ActionRefusedError(
1898
+ 409,
1899
+ 'split_not_supported_in_apply_mode',
1900
+ 'COS can suggest splitting this recording, but only your pipeline can split a meeting it already filed.',
1901
+ )
1902
+ }
1903
+ if (suggestion.state === 'accepted') {
1904
+ return { ok: true, actionId: actionIdFor(kind, suggestion.inputs) }
1905
+ }
1906
+ const inputs = await this.collect(mode)
1907
+ const g2ById = new Map(inputs.g2.map(row => [row.sessionId, row]))
1908
+ const firefliesById = new Map(inputs.fireflies.map(row => [row.id, row]))
1909
+ const fingerprints = fingerprintsFor(suggestion.inputs, g2ById, firefliesById, inputs.g2Meta, inputs.firefliesMeta)
1910
+ // A suggestion shown against one version of the files must not be applied to another:
1911
+ // the person agreed to what they were shown.
1912
+ if (fingerprints !== suggestion.fingerprints) {
1913
+ throw new ActionRefusedError(409, 'suggestion_stale', 'These meetings changed since this was suggested. Look again.')
1914
+ }
1915
+ if (kind === 'split') {
1916
+ const firefliesId = suggestion.inputs.firefliesIds[0]
1917
+ const source = firefliesId ? firefliesById.get(firefliesId) : undefined
1918
+ if (!source) throw new ActionRefusedError(409, 'suggestion_stale', 'That recording is no longer here. Look again.')
1919
+ // Re-planned from the CURRENT recording rather than replayed from the suggestion's
1920
+ // stored spans: the fingerprint proves the bytes are the same, and the plan is the
1921
+ // engine's to make.
1922
+ const plan = await this.splitPlanFor(source, inputs.g2)
1923
+ if (!plan) throw new ActionRefusedError(409, 'split_no_longer_planned', 'COS no longer sees more than one meeting in that recording.')
1924
+ const taken = await this.takeSplitAction({
1925
+ canonical: suggestion.inputs,
1926
+ fingerprints,
1927
+ tier: 'accepted_suggestion',
1928
+ plan,
1929
+ inputs,
1930
+ g2ById,
1931
+ firefliesById,
1932
+ })
1933
+ if (taken.ok) await this.markSuggestion(id, 'accepted')
1934
+ return { ok: taken.ok, actionId: taken.id }
1935
+ }
1936
+ const group = await this.groupForAcceptedMerge(suggestion, g2ById, firefliesById)
1937
+ const taken = await this.takeMergeAction({
1938
+ canonical: suggestion.inputs,
1939
+ fingerprints,
1940
+ tier: 'accepted_suggestion',
1941
+ mode,
1942
+ ...(group ? { group } : {}),
1943
+ inputs,
1944
+ g2ById,
1945
+ firefliesById,
1946
+ })
1947
+ if (taken.ok) await this.markSuggestion(id, 'accepted')
1948
+ return { ok: taken.ok, actionId: taken.id }
1949
+ })
1950
+ }
1951
+
1952
+ /** Advise mode: record the answer, change nothing. */
1953
+ confirmSuggestion(id: string): Promise<{ ok: boolean }> {
1954
+ this.assertAdmissions()
1955
+ return this.enqueue(async () => {
1956
+ const suggestion = this.store.read().suggestions.find(row => row.id === id)
1957
+ if (!suggestion) throw new ActionRefusedError(404, 'suggestion_not_found', 'That suggestion is gone.')
1958
+ if (suggestion.state === 'dismissed') {
1959
+ throw new ActionRefusedError(409, 'suggestion_dismissed', 'That suggestion was dismissed.')
1960
+ }
1961
+ if (suggestion.state === 'open') await this.markSuggestion(id, 'confirmed')
1962
+ return { ok: true }
1963
+ })
1964
+ }
1965
+
1966
+ /** Never the same two meetings again. */
1967
+ dismissSuggestion(id: string): Promise<{ ok: boolean }> {
1968
+ this.assertAdmissions()
1969
+ return this.enqueue(async () => {
1970
+ const suggestion = this.store.read().suggestions.find(row => row.id === id)
1971
+ if (!suggestion) throw new ActionRefusedError(404, 'suggestion_not_found', 'That suggestion is gone.')
1972
+ await this.store.update(store => {
1973
+ const row = store.suggestions.find(item => item.id === id)
1974
+ if (row) {
1975
+ row.state = 'dismissed'
1976
+ row.decidedAt = new Date(this.now()).toISOString()
1977
+ }
1978
+ addTombstones(store, suggestion.inputs, id, new Date(this.now()).toISOString())
1979
+ })
1980
+ return { ok: true }
1981
+ })
1982
+ }
1983
+
1984
+ // ── Revert ──────────────────────────────────────────────────────────────────
1985
+
1986
+ previewRevert(actionId: string): RevertPreview {
1987
+ const action = this.store.read().actions.find(row => row.id === actionId)
1988
+ if (!action) throw new ActionRefusedError(404, 'action_not_found', 'That action is gone.')
1989
+ if (action.tier === 'legacy_applied') {
1990
+ throw new ActionRefusedError(409, 'legacy_action', 'Your pipeline made this merge, so COS does not undo it.')
1991
+ }
1992
+ const current = action.outputs.map(output => sha256OfFile(output.path))
1993
+ return {
1994
+ actionId,
1995
+ previewHash: revertPreviewHash(action, current),
1996
+ state: action.state,
1997
+ mode: action.mode,
1998
+ outputs: action.outputs.map(output => output.path),
1999
+ editedOutputs: action.outputs
2000
+ .filter((output, index) => current[index] != null && action.outputSha256[index] != null && current[index] !== action.outputSha256[index])
2001
+ .map(output => output.path),
2002
+ missingOutputs: action.outputs.filter((_, index) => current[index] == null).map(output => output.path),
2003
+ }
2004
+ }
2005
+
2006
+ /**
2007
+ * Undo one action.
2008
+ *
2009
+ * COMPARE AND SET. `applied` becomes `revert_pending` inside the mutex, so a double click
2010
+ * is a 409 rather than two reverts of one thing, and a second call after it finished is a
2011
+ * 200 rather than an error about an action that is already in the state you wanted.
2012
+ *
2013
+ * THE PREVIEW HASH IS NOT CEREMONY. It covers the outputs' CURRENT bytes, so an edit that
2014
+ * landed between the preview and the confirm invalidates the confirm rather than silently
2015
+ * deleting the edit.
2016
+ */
2017
+ revert(actionId: string, options: { dryRun?: boolean; previewHash?: string } = {}): Promise<RevertPreview | { ok: true; state: MergeActionRecord['state']; alreadyReverted?: boolean }> {
2018
+ if (!options.dryRun) this.assertAdmissions()
2019
+ return this.enqueue(async () => {
2020
+ const preview = this.previewRevert(actionId)
2021
+ if (options.dryRun) return preview
2022
+ if (preview.state === 'reverted') return { ok: true as const, state: 'reverted' as const, alreadyReverted: true }
2023
+ if (preview.state === 'revert_pending') {
2024
+ throw new ActionRefusedError(409, 'revert_in_progress', 'That undo is already running.')
2025
+ }
2026
+ if (preview.state !== 'applied') {
2027
+ throw new ActionRefusedError(409, 'action_not_revertible', 'Only an applied action can be undone.')
2028
+ }
2029
+ if (!options.previewHash) {
2030
+ throw new ActionRefusedError(400, 'preview_required', 'Ask for the preview first, then send its previewHash.')
2031
+ }
2032
+ if (options.previewHash !== preview.previewHash) {
2033
+ throw new ActionRefusedError(409, 'revert_stale', 'These files changed since the preview. Look again.')
2034
+ }
2035
+
2036
+ const claimed = await this.store.update(store => {
2037
+ const row = store.actions.find(item => item.id === actionId)
2038
+ if (!row || row.state !== 'applied') return false
2039
+ row.state = 'revert_pending'
2040
+ // The intent, written down. Every later drive of this action reads it, including
2041
+ // the one after a `partial` result reset the row to a waiting state.
2042
+ row.direction = 'revert'
2043
+ row.attempts = 0
2044
+ delete row.error
2045
+ return true
2046
+ })
2047
+ if (!claimed) throw new ActionRefusedError(409, 'revert_in_progress', 'That undo is already running.')
2048
+
2049
+ const action = this.store.read().actions.find(row => row.id === actionId)!
2050
+ if (action.mode === 'apply') {
2051
+ const driven = await this.drivePipelineAction(actionId)
2052
+ return { ok: driven.ok as true, state: driven.state }
2053
+ }
2054
+ await this.revertImportsRecord(action)
2055
+ return { ok: true as const, state: 'reverted' as const }
2056
+ })
2057
+ }
2058
+
2059
+ /**
2060
+ * imports mode Revert: delete what was added, keep anything a person changed.
2061
+ *
2062
+ * A derived record whose bytes no longer match what the action wrote has been edited, and
2063
+ * deleting it would destroy that edit with no trace. It is copied into
2064
+ * `.reverted/<actionId>/` first. The sources were never touched, so removing the record
2065
+ * restores the rows it superseded.
2066
+ */
2067
+ private async revertImportsRecord(action: MergeActionRecord): Promise<void> {
2068
+ for (const [index, output] of action.outputs.entries()) {
2069
+ const current = sha256OfFile(output.path)
2070
+ if (current && action.outputSha256[index] && current !== action.outputSha256[index]) {
2071
+ const keepDir = join(this.store.revertedDir(), action.id)
2072
+ try {
2073
+ mkdirSync(keepDir, { recursive: true, mode: 0o700 })
2074
+ cpSync(output.path, join(keepDir, basename(output.path)))
2075
+ if (output.sidecarPath && existsSync(output.sidecarPath)) {
2076
+ cpSync(output.sidecarPath, join(keepDir, basename(output.sidecarPath)))
2077
+ }
2078
+ } catch (error) {
2079
+ this.log(`could not keep the edited record for ${action.id}: ${describe(error)}`)
2080
+ }
2081
+ }
2082
+ for (const path of [output.path, output.sidecarPath]) {
2083
+ if (!path) continue
2084
+ try { unlinkSync(path) } catch { /* already gone is the state we wanted */ }
2085
+ }
2086
+ }
2087
+ await this.store.update(store => {
2088
+ const row = store.actions.find(item => item.id === action.id)
2089
+ if (row) {
2090
+ row.state = 'reverted'
2091
+ delete row.error
2092
+ }
2093
+ addTombstones(store, action.inputs, action.id, new Date(this.now()).toISOString())
2094
+ })
2095
+ }
2096
+
2097
+ private async tombstoneAction(actionId: string): Promise<void> {
2098
+ await this.store.update(store => {
2099
+ const row = store.actions.find(item => item.id === actionId)
2100
+ if (!row) return
2101
+ addTombstones(store, row.inputs, actionId, new Date(this.now()).toISOString())
2102
+ })
2103
+ }
2104
+
2105
+ /** Every applied action, newest first, under the same preview rule as one. */
2106
+ revertAll(options: { dryRun?: boolean; previewHash?: string } = {}): Promise<{
2107
+ previewHash: string
2108
+ actions: string[]
2109
+ reverted?: string[]
2110
+ failed?: Array<{ id: string; reason: string }>
2111
+ }> {
2112
+ if (!options.dryRun) this.assertAdmissions()
2113
+ return this.enqueue(async () => {
2114
+ const applied = this.store.read().actions
2115
+ .filter(row => row.state === 'applied' && row.tier !== 'legacy_applied')
2116
+ .reverse()
2117
+ const previews = applied.map(row => this.previewRevert(row.id))
2118
+ const previewHash = createHash('sha256').update(previews.map(row => row.previewHash).join('|')).digest('hex')
2119
+ if (options.dryRun) return { previewHash, actions: applied.map(row => row.id) }
2120
+ if (!options.previewHash) {
2121
+ throw new ActionRefusedError(400, 'preview_required', 'Ask for the preview first, then send its previewHash.')
2122
+ }
2123
+ if (options.previewHash !== previewHash) {
2124
+ throw new ActionRefusedError(409, 'revert_stale', 'Something changed since the preview. Look again.')
2125
+ }
2126
+ const reverted: string[] = []
2127
+ const failed: Array<{ id: string; reason: string }> = []
2128
+ for (const preview of previews) {
2129
+ try {
2130
+ const claimed = await this.store.update(store => {
2131
+ const row = store.actions.find(item => item.id === preview.actionId)
2132
+ if (!row || row.state !== 'applied') return false
2133
+ row.state = 'revert_pending'
2134
+ row.direction = 'revert'
2135
+ row.attempts = 0
2136
+ delete row.error
2137
+ return true
2138
+ })
2139
+ if (!claimed) { failed.push({ id: preview.actionId, reason: 'not_applied' }); continue }
2140
+ const action = this.store.read().actions.find(row => row.id === preview.actionId)!
2141
+ if (action.mode === 'apply') {
2142
+ const driven = await this.drivePipelineAction(action.id)
2143
+ if (driven.ok) reverted.push(action.id)
2144
+ else failed.push({ id: action.id, reason: driven.reason ?? 'revert_failed' })
2145
+ } else {
2146
+ await this.revertImportsRecord(action)
2147
+ reverted.push(action.id)
2148
+ }
2149
+ } catch (error) {
2150
+ failed.push({ id: preview.actionId, reason: describe(error) })
2151
+ }
2152
+ }
2153
+ return { previewHash, actions: applied.map(row => row.id), reverted, failed }
2154
+ })
2155
+ }
2156
+
2157
+ // ── Status and mode ─────────────────────────────────────────────────────────
2158
+
2159
+ /** The mode plus what it was decided from, with a safe shape when a mode is injected. */
2160
+ private modeDetail(): ReturnType<typeof meetingEngineModeDetail> {
2161
+ if (this.modeDetailFn) return this.modeDetailFn()
2162
+ const mode = this.mode()
2163
+ return {
2164
+ mode,
2165
+ observedMacClass: mode === 'imports' ? 'standalone' : 'pipeline',
2166
+ macClassChanged: false,
2167
+ }
2168
+ }
2169
+
2170
+ async status(): Promise<{
2171
+ mode: MeetingEngineMode
2172
+ isPipelineMac: boolean
2173
+ pipelineSees: { mode: string; active: boolean; appliedActions: number } | null
2174
+ mismatch: boolean
2175
+ /** Advise, with merges the pipeline still honours. Expected after a rollback, not a bug. */
2176
+ mergesRemainApplied: boolean
2177
+ macClass?: { observed: MeetingEngineMacClass; recorded?: MeetingEngineMacClass; changed: boolean }
2178
+ lastRun?: EngineRunSummary
2179
+ firstRun?: ActionsStoreFile['status']['firstRun']
2180
+ counts: {
2181
+ /** Every applied action revert-all would undo: any tier but `legacy_applied`. */
2182
+ applied: number
2183
+ auto: number
2184
+ suggested: number
2185
+ none: number
2186
+ reverted: number
2187
+ pending: number
2188
+ revertPending: number
2189
+ failed: number
2190
+ }
2191
+ engineInstalledAt?: number
2192
+ running: boolean
2193
+ }> {
2194
+ const detail = this.modeDetail()
2195
+ const mode = detail.mode
2196
+ const store = this.store.read()
2197
+ const pipelineSees = await this.pipelineStatus(store)
2198
+ const counts = {
2199
+ // EVERY applied action a person can undo, any tier (QA round 2, blocker 5). Control's
2200
+ // Undo-all count added OPEN suggestions to `auto`, so a Mac with suggestions and nothing
2201
+ // merged offered "Undo all merges" and then previewed "Undo 0 merges". `legacy_applied`
2202
+ // is out for the reason revert-all leaves it out: the pipeline made those merges.
2203
+ applied: store.actions.filter(row => row.state === 'applied' && row.tier !== 'legacy_applied').length,
2204
+ auto: store.actions.filter(row => row.tier === 'auto' && row.state === 'applied').length,
2205
+ suggested: store.suggestions.filter(row => row.state === 'open').length,
2206
+ none: store.status.lastRun?.none ?? 0,
2207
+ reverted: store.actions.filter(row => row.state === 'reverted').length,
2208
+ pending: store.actions.filter(row => row.state === 'pending').length,
2209
+ // Its own count. An Undo that cannot finish is the state `setMode` refuses on and the
2210
+ // one Control has to be able to name, and rolling it into `pending` hid it.
2211
+ revertPending: store.actions.filter(row => row.state === 'revert_pending').length,
2212
+ failed: store.actions.filter(row => row.state === 'failed').length,
2213
+ }
2214
+ // ADVISE PLUS ACTIVE IS NOT A DISAGREEMENT. `merge_engine_active()` stays true while any
2215
+ // applied action exists, precisely so the old blend path does not restart over merged
2216
+ // scribes after the mode is rolled back. That is the documented rollback state, and
2217
+ // reporting it as a mismatch told a person their two halves disagreed when they agreed.
2218
+ const mergesRemainApplied = mode === 'advise'
2219
+ && pipelineSees != null
2220
+ && pipelineSees.active
2221
+ && pipelineSees.mode !== 'apply'
2222
+ // Only when there IS something applied (QA round 2, blocker 4). An actions file the
2223
+ // pipeline cannot read prints `advise / active / 0`: the gate held on over nothing
2224
+ // readable. Reading that as the benign rollback state reassured a person whose merge
2225
+ // record was gone. It now reports as a mismatch, which Control shows as an alarm.
2226
+ && pipelineSees.appliedActions > 0
2227
+ const mismatch = pipelineSees != null
2228
+ && !mergesRemainApplied
2229
+ && ((mode === 'apply') !== (pipelineSees.mode === 'apply' || pipelineSees.active))
2230
+ return {
2231
+ mode,
2232
+ isPipelineMac: mode !== 'imports',
2233
+ pipelineSees,
2234
+ mismatch,
2235
+ mergesRemainApplied,
2236
+ macClass: {
2237
+ observed: detail.observedMacClass,
2238
+ ...(detail.recordedMacClass ? { recorded: detail.recordedMacClass } : {}),
2239
+ changed: detail.macClassChanged,
2240
+ },
2241
+ ...(store.status.lastRun ? { lastRun: store.status.lastRun } : {}),
2242
+ ...(store.status.firstRun ? { firstRun: store.status.firstRun } : {}),
2243
+ counts,
2244
+ ...(store.status.engineInstalledAt ? { engineInstalledAt: store.status.engineInstalledAt } : {}),
2245
+ running: this.busy(),
2246
+ }
2247
+ }
2248
+
2249
+ /** `--merge-engine-status`, at most once every five minutes, null when it cannot be read. */
2250
+ private async pipelineStatus(store: ActionsStoreFile): Promise<{ mode: string; active: boolean; appliedActions: number } | null> {
2251
+ const spawn = this.spawnPipeline
2252
+ if (!spawn) return null
2253
+ const seenAt = store.status.pipelineSeenAt ?? 0
2254
+ if (this.now() - seenAt < PIPELINE_STATUS_CACHE_MS) return store.status.pipelineSees ?? null
2255
+ let value: { mode: string; active: boolean; appliedActions: number } | null = null
2256
+ try {
2257
+ const attempt = await spawn([PIPELINE_STATUS_ARG], {})
2258
+ if (attempt.code === 0) {
2259
+ const line = attempt.stdout.split(/\r?\n/).map(row => row.trim()).filter(Boolean).pop() ?? ''
2260
+ const parsed = JSON.parse(line) as Record<string, unknown>
2261
+ if (typeof parsed?.mode === 'string') {
2262
+ value = {
2263
+ mode: parsed.mode,
2264
+ active: parsed.active === true,
2265
+ appliedActions: typeof parsed.applied_actions === 'number' ? parsed.applied_actions : 0,
2266
+ }
2267
+ }
2268
+ }
2269
+ } catch {
2270
+ value = null
2271
+ }
2272
+ await this.store.update(current => {
2273
+ current.status.pipelineSees = value
2274
+ current.status.pipelineSeenAt = this.now()
2275
+ })
2276
+ return value
2277
+ }
2278
+
2279
+ /**
2280
+ * Switch a pipeline Mac between advise and apply.
2281
+ *
2282
+ * REFUSED WHILE ANYTHING IS IN FLIGHT. Changing the mode under a run means the decisions
2283
+ * already taken belong to one mode and the writes land under the rules of another.
2284
+ */
2285
+ setMode(mode: MeetingEnginePipelineMode): Promise<{ mode: MeetingEnginePipelineMode }> {
2286
+ this.assertAdmissions()
2287
+ if (!this.isPipelineMac()) {
2288
+ throw new ActionRefusedError(409, 'not_a_pipeline_mac', 'This Mac has no COS pipeline, so there is no mode to choose.')
2289
+ }
2290
+ if (mode !== 'advise' && mode !== 'apply') {
2291
+ throw new ActionRefusedError(400, 'invalid_mode', 'Mode must be advise or apply.')
2292
+ }
2293
+ if (this.busy()) {
2294
+ throw new ActionRefusedError(409, 'run_in_progress', 'COS is working on meetings right now. Try again shortly.')
2295
+ }
2296
+ if (this.store.read().actions.some(row => row.state === 'pending' || row.state === 'revert_pending')) {
2297
+ throw new ActionRefusedError(409, 'apply_in_flight', 'A merge is still finishing. Try again shortly.')
2298
+ }
2299
+ writeMergeModeFile(mode, { now: this.now })
2300
+ // D14 in apply mode counts from HERE, not from install: a person has now seen the
2301
+ // advise report, so what happens after this moment is what they opted into.
2302
+ void this.store.update(store => {
2303
+ if (mode === 'apply') store.status.applyModeSince = this.now()
2304
+ })
2305
+ return Promise.resolve({ mode })
2306
+ }
2307
+ }
2308
+
2309
+ // ── Helpers ───────────────────────────────────────────────────────────────────
2310
+
2311
+ function describe(error: unknown): string {
2312
+ return error instanceof Error ? error.message : String(error)
2313
+ }
2314
+
2315
+ /**
2316
+ * Whether live meeting work owns the CPU right now.
2317
+ *
2318
+ * Five lease kinds, all work a person is waiting on: `recording_chunk` (every live chunk
2319
+ * write), `meeting_save` (a capture's own save), `meeting_batch_finalization` (batch
2320
+ * transcription of a finished capture), `orphan_recovery` (a stranded capture being rebuilt)
2321
+ * and `one_shot_transcription`. The last three were missing (QA round 2), and each is
2322
+ * transcription or rebuild work a backlog score competes with for the same cores.
2323
+ *
2324
+ * IT GATES PASS START ONLY. A pass already running is not interrupted.
2325
+ *
2326
+ * THE FINALIZATION TRIGGER IS NOT LOST. `g2_finalized` fires from INSIDE the finalization
2327
+ * lease (`routes/meeting.ts`), so its pass now defers. The runner remembers it and the next
2328
+ * 30 s tick that finds none of these leases held runs it (`tick()`), not the six-hourly run.
2329
+ */
2330
+ export const CAPTURE_LEASE_KINDS = [
2331
+ 'recording_chunk',
2332
+ 'meeting_save',
2333
+ 'meeting_batch_finalization',
2334
+ 'orphan_recovery',
2335
+ 'one_shot_transcription',
2336
+ ] as const
2337
+
2338
+ function defaultCaptureActive(): boolean {
2339
+ const byKind = maintenanceLifecycle.snapshot().activeByKind as Record<string, number>
2340
+ return CAPTURE_LEASE_KINDS.some(kind => (byKind[kind] ?? 0) > 0)
2341
+ }
2342
+
2343
+ /** The alignment offsets pairing measured, or undefined when it measured none. */
2344
+ function coarseOffsets(group: MergeGroup | undefined): Record<string, number> | null {
2345
+ if (!group) return null
2346
+ const offsets: Record<string, number> = {}
2347
+ for (const [sessionId, offsetMs] of Object.entries(group.offsetMsBySession)) {
2348
+ if (typeof offsetMs === 'number' && Number.isFinite(offsetMs)) offsets[sessionId] = offsetMs
2349
+ }
2350
+ return Object.keys(offsets).length > 0 ? offsets : null
2351
+ }
2352
+
2353
+ /** The trimmed stderr tail a diagnostics row keeps. Enough for a traceback's last frames. */
2354
+ export const DIAGNOSTIC_STDERR_CHARS = 2_000
2355
+
2356
+ /** `COS_MERGE_DECISION_INVALID=<code>`, which the pipeline prints before exiting 4. */
2357
+ const DECISION_INVALID_LINE = /^COS_MERGE_DECISION_INVALID=.*$/m
2358
+
2359
+ /**
2360
+ * What a failed child did, from the attempt alone.
2361
+ *
2362
+ * `Command failed` with an empty stderr is three different bugs — a non-zero exit, a timeout
2363
+ * kill, and a failed fork — and a row that records only the message cannot tell them apart
2364
+ * afterwards. Every one of them is a separate field here.
2365
+ */
2366
+ function diagnosticsOf(attempt: PipelineAttempt): PipelineFailureDiagnostics {
2367
+ const stderr = attempt.stderr.trim()
2368
+ const decisionInvalid = attempt.stderr.match(DECISION_INVALID_LINE)?.[0]
2369
+ ?? attempt.stdout.match(DECISION_INVALID_LINE)?.[0]
2370
+ return {
2371
+ code: attempt.code,
2372
+ signal: attempt.signal,
2373
+ timedOut: attempt.timedOut,
2374
+ elapsedMs: attempt.elapsedMs,
2375
+ ...(stderr ? { stderr: stderr.slice(-DIAGNOSTIC_STDERR_CHARS) } : {}),
2376
+ ...(decisionInvalid ? { decisionInvalid } : {}),
2377
+ ...(attempt.spawnError ? { spawnError: attempt.spawnError } : {}),
2378
+ }
2379
+ }
2380
+
2381
+ function median(values: readonly number[]): number {
2382
+ if (values.length === 0) return 0
2383
+ const sorted = [...values].sort((a, b) => a - b)
2384
+ const middle = Math.floor(sorted.length / 2)
2385
+ return sorted.length % 2 === 0 ? (sorted[middle - 1] + sorted[middle]) / 2 : sorted[middle]
2386
+ }
2387
+
2388
+ /**
2389
+ * What the clock band did this run.
2390
+ *
2391
+ * `PAIRING_CLOCK_BAND_S` was chosen from 198 scored recordings on one Mac and nothing made
2392
+ * its effect observable anywhere else. These five numbers do: `candidatesZeroed` rising is
2393
+ * the band refusing real matches, and the winner skews say how much of the band this Mac's
2394
+ * clocks actually use.
2395
+ */
2396
+ function clockBandStatsOf(
2397
+ pairings: readonly PairingResult[],
2398
+ g2ById: Map<string, G2RecordingInput>,
2399
+ firefliesById: Map<string, FirefliesMeetingInput>,
2400
+ ): ClockBandStats {
2401
+ let candidates = 0
2402
+ let anchorsDropped = 0
2403
+ let candidatesZeroed = 0
2404
+ const winnerSkews: number[] = []
2405
+ for (const pairing of pairings) {
2406
+ for (const candidate of pairing.candidates) {
2407
+ if (candidate.anchorsOutsideClockBand <= 0) continue
2408
+ candidates += 1
2409
+ anchorsDropped += candidate.anchorsOutsideClockBand
2410
+ if (candidate.k === 0) candidatesZeroed += 1
2411
+ }
2412
+ if (!pairing.primaryFirefliesId) continue
2413
+ const recording = g2ById.get(pairing.sessionId)
2414
+ const meeting = firefliesById.get(pairing.primaryFirefliesId)
2415
+ if (!recording || !meeting) continue
2416
+ winnerSkews.push(Math.abs(recording.startMs - meeting.startMs) / 1000)
2417
+ }
2418
+ return {
2419
+ candidates,
2420
+ anchorsDropped,
2421
+ candidatesZeroed,
2422
+ maxWinnerSkewS: winnerSkews.length > 0 ? Math.round(Math.max(...winnerSkews)) : 0,
2423
+ medianWinnerSkewS: Math.round(median(winnerSkews)),
2424
+ }
2425
+ }
2426
+
2427
+ function sha256OfText(value: string): string {
2428
+ return createHash('sha256').update(value, 'utf8').digest('hex')
2429
+ }
2430
+
2431
+ function upsertAction(store: ActionsStoreFile, record: MergeActionRecord): void {
2432
+ const index = store.actions.findIndex(row => row.id === record.id)
2433
+ if (index >= 0) store.actions[index] = { ...store.actions[index], ...record }
2434
+ else store.actions.push(record)
2435
+ }
2436
+
2437
+ function addTombstones(store: ActionsStoreFile, inputs: CanonicalInputs, source: string, at: string): void {
2438
+ const known = new Set(store.tombstones.map(row => [...row.pair].sort().join('|')))
2439
+ for (const pair of inputPairs(inputs)) {
2440
+ const key = [...pair].sort().join('|')
2441
+ if (known.has(key)) continue
2442
+ known.add(key)
2443
+ store.tombstones.push({ pair, at, source })
2444
+ }
2445
+ }
2446
+
2447
+ /** `--merge-engine-status` asks what the PIPELINE sees, so it must not be told what to see. */
2448
+ export const PIPELINE_STATUS_ARG = '--merge-engine-status'
2449
+
2450
+ /** The real spawn. Null on a Mac with no pipeline, which is how imports mode gets no child. */
2451
+ function defaultPipelineSpawn(args: readonly string[], options: { actionId?: string }): Promise<PipelineAttempt> {
2452
+ const scriptsDir = process.env.COS_SCRIPTS_DIR?.trim()
2453
+ if (!scriptsDir) return Promise.reject(new Error('COS_SCRIPTS_DIR is not set'))
2454
+ const dir = resolve(scriptsDir)
2455
+ const script = join(dir, 'sync_meetings.py')
2456
+ const pythonBin = process.env.COS_PYTHON_BIN?.trim() || resolve(dir, 'venv/bin/python3')
2457
+ if (!existsSync(script) || !existsSync(pythonBin)) {
2458
+ return Promise.reject(new Error('COS pipeline is not installed on this Mac'))
2459
+ }
2460
+ // THE STATUS PROBE RUNS WITH THE INHERITED ENVIRONMENT. Injecting the server's own
2461
+ // COS_DATA_DIR made the child resolve the SERVER's mode file, so `pipelineSees` was the
2462
+ // server reading itself back and `mismatch` could not be true however far the two halves
2463
+ // had actually drifted. The pipeline's own processes — the hourly sync, the watcher —
2464
+ // resolve the data dir the way this child now does, which is the thing being compared.
2465
+ const isStatusProbe = args[0] === PIPELINE_STATUS_ARG
2466
+ return runPipelineCommand({
2467
+ pythonBin,
2468
+ script,
2469
+ args,
2470
+ cwd: dir,
2471
+ env: {
2472
+ ...process.env,
2473
+ PYTHONUNBUFFERED: '1',
2474
+ PATH: pipelinePath(),
2475
+ // Explicit, not inherited, for real WORK: the pipeline resolves the mode file and the
2476
+ // decision from these two, and a child that guessed either would act on the wrong
2477
+ // Mac's state.
2478
+ ...(isStatusProbe ? {} : { COS_DATA_DIR: dirname(importsRoot()) }),
2479
+ ...(options.actionId ? { COS_MERGE_DECISION_FILE: join(importsRoot(), 'decisions', `${options.actionId}.json`) } : {}),
2480
+ },
2481
+ resultPrefix: MERGE_RESULT_PREFIX,
2482
+ timeoutMs: PIPELINE_DEFAULT_TIMEOUT_MS,
2483
+ })
2484
+ }
2485
+
2486
+ // ── Process-wide runner, scheduler and triggers ───────────────────────────────
2487
+
2488
+ let runner: MeetingMergeRunner | null = null
2489
+ let tickTimer: ReturnType<typeof setInterval> | null = null
2490
+ let correctionHookOff: (() => void) | null = null
2491
+
2492
+ export function getMeetingMergeRunner(): MeetingMergeRunner {
2493
+ // Built on first use, never at import time: the constructor touches the data home.
2494
+ runner ??= new MeetingMergeRunner()
2495
+ return runner
2496
+ }
2497
+
2498
+ /** Test seam. */
2499
+ export function resetMeetingMergeRunner(): void {
2500
+ runner = null
2501
+ }
2502
+
2503
+ /**
2504
+ * The trigger every caller uses. Safe everywhere: it refuses in the wrong mode, defers under
2505
+ * a drain or a live capture, and never throws at its caller.
2506
+ */
2507
+ export function triggerMeetingMergeRun(reason: string): void {
2508
+ try {
2509
+ getMeetingMergeRunner().trigger(reason)
2510
+ } catch (error) {
2511
+ console.warn(`[meeting-merge] trigger (${reason}) unavailable:`, error)
2512
+ }
2513
+ }
2514
+
2515
+ /** A speaker mutation landed. Re-derive the records that hold this session. */
2516
+ export function triggerMeetingMergeRederive(sessionId: string): void {
2517
+ try {
2518
+ void getMeetingMergeRunner().rederive(sessionId).catch(() => { /* logged by the runner */ })
2519
+ } catch (error) {
2520
+ console.warn('[meeting-merge] re-derive unavailable:', error)
2521
+ }
2522
+ }
2523
+
2524
+ export function startMeetingMergeScheduler(): void {
2525
+ if (tickTimer) return
2526
+ tickTimer = setInterval(() => {
2527
+ try {
2528
+ getMeetingMergeRunner().tick()
2529
+ } catch (error) {
2530
+ console.error('[meeting-merge] tick failed:', error)
2531
+ }
2532
+ }, ENGINE_TICK_MS)
2533
+ tickTimer.unref?.()
2534
+ // ONE central hook, in the ledger every speaker mutation writes to, rather than in each
2535
+ // of the three route handlers: a fourth mutation route would otherwise silently not
2536
+ // re-derive. `intent` and `failed` rows are not outcomes and are ignored.
2537
+ correctionHookOff ??= onCorrectionApplied((sessionId, row) => {
2538
+ if (row.phase === 'intent' || row.phase === 'failed') return
2539
+ triggerMeetingMergeRederive(sessionId)
2540
+ })
2541
+ }
2542
+
2543
+ export function stopMeetingMergeScheduler(): void {
2544
+ if (tickTimer) clearInterval(tickTimer)
2545
+ tickTimer = null
2546
+ correctionHookOff?.()
2547
+ correctionHookOff = null
2548
+ }
2549
+
2550
+ /**
2551
+ * On boot, after the imports sweep: drive whatever a previous process left waiting.
2552
+ *
2553
+ * WAITING, NOT FAILED. A `failed` action is one that already used its automatic retry, and
2554
+ * re-driving it on every restart is how a broken decision spawns a child every time the
2555
+ * server comes up. A person retries it through `POST /api/meeting-actions/:id/retry`, which
2556
+ * puts it back in the correct direction's waiting state and this sweep's own queue.
2557
+ */
2558
+ export async function redrivePendingMergeActions(): Promise<number> {
2559
+ const active = getMeetingMergeRunner()
2560
+ const waiting = active.listActions(Number.MAX_SAFE_INTEGER).filter(row => isWaitingState(row.state))
2561
+ for (const row of waiting) {
2562
+ try { await active.driveWaitingAction(row.id) } catch { /* the tick retries */ }
2563
+ }
2564
+ return waiting.length
2565
+ }
2566
+
2567
+ /**
2568
+ * Decision files with no action behind them.
2569
+ *
2570
+ * A decision is written BEFORE its action row, so a crash in that window leaves a file
2571
+ * holding transcript text that nothing will ever read or delete. The sweep runs on boot,
2572
+ * beside the library's own, and only removes ids no action claims.
2573
+ */
2574
+ export function sweepOrphanDecisions(): number {
2575
+ const known = new Set(getMeetingMergeRunner().listActions(Number.MAX_SAFE_INTEGER).map(row => row.id))
2576
+ let removed = 0
2577
+ for (const actionId of listDecisionIds(importsRoot())) {
2578
+ if (known.has(actionId)) continue
2579
+ deleteDecision(actionId, importsRoot())
2580
+ removed += 1
2581
+ }
2582
+ return removed
2583
+ }