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