@gotcos/glasses-server 6.95.0 → 6.98.1
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 +166 -0
- package/README.md +4 -0
- package/managed-runtime-contract.json +13 -0
- package/package.json +3 -1
- package/server/index.ts +12 -0
- package/server/lib/attached-provider-adapter.ts +35 -1
- package/server/lib/claude-bridge.ts +47 -0
- package/server/lib/codex-bridge.ts +11 -1
- package/server/lib/codex-rollout-watermark.ts +42 -0
- package/server/lib/cursor-bridge.ts +2 -1
- package/server/lib/decision-provider.ts +4 -2
- package/server/lib/dictation-clean-models.ts +2 -0
- package/server/lib/dictation-clean.ts +2 -2
- package/server/lib/dictation-polish-pipeline.ts +22 -2
- package/server/lib/dictation-prewarm.ts +16 -0
- package/server/lib/direct-bridge.ts +14 -2
- package/server/lib/direct-sse.ts +6 -1
- package/server/lib/display-bus.ts +1 -1
- package/server/lib/fork-thread.ts +32 -1
- package/server/lib/held-naming-batches.ts +44 -7
- package/server/lib/held-voice-groups.ts +5 -4
- package/server/lib/job-audit-caller.ts +20 -0
- package/server/lib/keyed-sessions.ts +21 -1
- package/server/lib/lens-gist-engines.ts +17 -5
- package/server/lib/live-cos-ask.ts +29 -6
- package/server/lib/live-cue-verdicts.ts +43 -8
- package/server/lib/live-cues-engine.ts +4 -1
- package/server/lib/live-cues-filter.ts +50 -0
- package/server/lib/live-cues-general-prompts.ts +137 -0
- package/server/lib/live-cues-general-topic.ts +245 -0
- package/server/lib/live-cues-general.ts +715 -0
- package/server/lib/live-cues-v2-data.ts +763 -0
- package/server/lib/live-cues-v2-grounded.ts +262 -0
- package/server/lib/live-cues-v2-judge-b.ts +59 -0
- package/server/lib/live-cues-v2-names.ts +49 -0
- package/server/lib/live-cues-v2-notes.ts +8 -4
- package/server/lib/live-cues-v2-open-items.ts +132 -0
- package/server/lib/live-cues-v2-pacing.ts +45 -0
- package/server/lib/live-cues-v2-prompts-kinds.ts +9 -4
- package/server/lib/live-cues-v2-settings.ts +89 -10
- package/server/lib/live-cues-v2.ts +572 -47
- package/server/lib/mac-recording-asr.ts +6 -3
- package/server/lib/mac-speaker-call-human.ts +371 -0
- package/server/lib/mac-speaker-call-interval-human.ts +499 -0
- package/server/lib/mac-speaker-call-interval-projection.ts +233 -0
- package/server/lib/mac-speaker-call-original-view.ts +328 -0
- package/server/lib/mac-speaker-call-producer-worker.ts +615 -0
- package/server/lib/mac-speaker-call-producer.ts +539 -0
- package/server/lib/mac-speaker-call-store.ts +264 -0
- package/server/lib/mac-speaker-call-view.ts +373 -0
- package/server/lib/mac-speaker-live-compute.ts +72 -0
- package/server/lib/mac-speaker-live-memory.ts +414 -0
- package/server/lib/mac-speaker-live-projection.ts +126 -0
- package/server/lib/mac-speaker-live-save.ts +90 -0
- package/server/lib/mac-speaker-live-store.ts +71 -0
- package/server/lib/mac-speaker-live-worker.ts +58 -0
- package/server/lib/mac-speaker-live.ts +175 -0
- package/server/lib/mac-speaker-profile-extractor.ts +15 -10
- package/server/lib/mac-speaker-shadow-repair.ts +307 -0
- package/server/lib/mac-voice-assignment.ts +46 -27
- package/server/lib/mac-voice-display.ts +195 -0
- package/server/lib/mac-voice-naming-batch.ts +1067 -0
- package/server/lib/mac-voice-suggestions.ts +548 -0
- package/server/lib/maintenance-lifecycle.ts +2 -0
- package/server/lib/media-store.ts +2 -1
- package/server/lib/meeting-ask-pasted.ts +135 -0
- package/server/lib/meeting-ask-review.ts +8 -1
- package/server/lib/meeting-ask-runner.ts +4 -1
- package/server/lib/meeting-ask-runtime.ts +189 -17
- package/server/lib/meeting-ask-store.ts +2 -0
- package/server/lib/meeting-ask-web.ts +284 -13
- package/server/lib/meeting-batch-progress.ts +51 -5
- package/server/lib/meeting-batch-transcribe.ts +35 -2
- package/server/lib/meeting-corrections.ts +2 -1
- package/server/lib/meeting-fact-check.ts +3 -0
- package/server/lib/meeting-finalization-jobs.ts +69 -2
- package/server/lib/meeting-public-topic.ts +6 -1
- package/server/lib/meeting-relabel-enrolment.ts +2 -1
- package/server/lib/meeting-relabel.ts +17 -10
- package/server/lib/meeting-speaker-review.ts +187 -21
- package/server/lib/meeting-store.ts +6 -1
- package/server/lib/parakeet.ts +51 -5
- package/server/lib/pending-batch-retention.ts +58 -0
- package/server/lib/private-reference.ts +38 -0
- package/server/lib/provider-usage.ts +561 -0
- package/server/lib/public-https-url.ts +32 -0
- package/server/lib/query-job-runtime.ts +3 -0
- package/server/lib/rich-media-safety.ts +23 -2
- package/server/lib/session-find-ask.ts +288 -0
- package/server/lib/session-find-collect.ts +715 -0
- package/server/lib/session-find-redact.ts +254 -0
- package/server/lib/session-find.ts +345 -0
- package/server/lib/speaker-calibration-log.ts +3 -2
- package/server/lib/speaker-embeddings.ts +46 -35
- package/server/lib/speaker-name-nfc.ts +37 -0
- package/server/lib/speaker-rename-fanout.ts +7 -6
- package/server/lib/speaker-shadow-decide.ts +59 -22
- package/server/lib/speaker-shadow-pair-source.ts +91 -0
- package/server/lib/speaker-shadow-paired.ts +268 -0
- package/server/lib/speaker-shadow-report.ts +2 -2
- package/server/lib/speaker-shadow-worker.ts +50 -15
- package/server/lib/speaker-shadow.ts +80 -19
- package/server/lib/speaker-trainer.ts +2 -1
- package/server/lib/token-audit.ts +36 -6
- package/server/lib/transcript-cleanup.ts +3 -2
- package/server/lib/voice-directory.ts +10 -5
- package/server/lib/voice-profile-store.ts +24 -14
- package/server/lib/web-lookup-ledger.ts +151 -0
- package/server/lib/work-review-runtime.ts +2 -0
- package/server/lib/work-search.ts +3 -1
- package/server/lib/xlsx-text.ts +277 -0
- package/server/routes/decision-provider.ts +4 -1
- package/server/routes/jev.ts +5 -1
- package/server/routes/live-cues.ts +36 -14
- package/server/routes/meeting.ts +515 -61
- package/server/routes/session-find.ts +73 -0
- package/server/routes/transcribe-stream.ts +114 -33
- package/server/routes/voice.ts +28 -18
- package/server/scripts/live-cues-v3-mutation-gate.mjs +311 -0
- package/server/scripts/mac-voice-assignment-6930-mutation-gate.mjs +5 -5
- package/shared/live-cues-v2.ts +38 -5
- package/shared/media-attachment.ts +2 -0
- package/shared/meeting-ask.ts +11 -0
|
@@ -0,0 +1,1067 @@
|
|
|
1
|
+
// 6.96.3: name several of a saved Mac call's voices at once, with a real batch Undo.
|
|
2
|
+
//
|
|
3
|
+
// WHY. Suggest names finds voices worth naming; a person reviews the list and confirms. A wrong batch would write
|
|
4
|
+
// wrong samples into the same profiles the G2 uses, so the preview and a full Undo are part of the feature.
|
|
5
|
+
//
|
|
6
|
+
// ONE WRITE PATH. Each label goes through what POST /meeting/:sessionId/relabel does for one voice: relabelSidecarJson
|
|
7
|
+
// and relabelMeetingMarkdown over the same copy of the meeting, the same intent/applied ledger rows, then
|
|
8
|
+
// planMacVoiceAssignment and commitMacVoiceAssignment with no `force`. renameMeetingSpeaker is deliberately NOT used:
|
|
9
|
+
// on real Mac calls its position-based markdown edit resolves almost no turns, because the markdown is not an
|
|
10
|
+
// index-parallel rendering of the chunks, so `[Voice N]:` would stay in the transcript while the sidecar said a name.
|
|
11
|
+
//
|
|
12
|
+
// ALL OR NOTHING BEFORE THE FIRST WRITE, THEN STOP AT THE FIRST REFUSAL. Every item must be a current high-confidence
|
|
13
|
+
// suggestion for this session and revision. The flag is trusted as given: `score` is rounded, so it is never
|
|
14
|
+
// recomputed here. A restart loses suggestions on purpose, and the answer is to click Suggest names again. The check is
|
|
15
|
+
// repeated immediately before each write, because a profile can change during a batch. Suggestions read null once the
|
|
16
|
+
// store changes, which this batch's own first commit also does, so from then on the store's stamp and the meeting's
|
|
17
|
+
// revision, both taken right after that commit, stand in for it: any write since then that is not ours stops the batch
|
|
18
|
+
// as `suggestions_stale`.
|
|
19
|
+
//
|
|
20
|
+
// JOURNAL. Intent is durable before anything is touched and the outcome after. A process that dies leaves `running`,
|
|
21
|
+
// which reads as `interrupted` once no live batch owns it, and Undo works out what landed by inspection. Refusals that
|
|
22
|
+
// wrote nothing leave no journal. It holds no transcript words: turn positions and hashes are enough for an exact
|
|
23
|
+
// restore, and only an attendee section a step edited is copied, names only. Journals last 30 days, then Undo is gone.
|
|
24
|
+
//
|
|
25
|
+
// WHICH SAMPLES. The profile store has no sample ids, so a sample is its source plus a sha256 of its vector, taken by
|
|
26
|
+
// diffing the profile around the one synchronous commit. `retractEmbeddingsBySource` matches on source alone, and the
|
|
27
|
+
// broad session retraction would also remove earlier single namings from this call, which are a person's decisions.
|
|
28
|
+
// So Undo finds the exact indices, in the named profile and then in any other a merge moved them to, and passes a
|
|
29
|
+
// predicate that walks them in the store's index order and can only match the source recorded at that index. A sample
|
|
30
|
+
// no profile holds is reported: `partial`, reason `samples_not_found`, the voice listed there as well as under `reverted`.
|
|
31
|
+
//
|
|
32
|
+
// EVICTED SAMPLES ARE RESTORED WHEN THAT IS SAFE. A Mac-assigned sample only ever evicts another Mac-assigned one, and
|
|
33
|
+
// one label can replace a person's whole Mac allowance, so losing them on Undo would cost earlier decisions. Vectors go
|
|
34
|
+
// back through the normal enrolment path, skipping its near-duplicate check, only if nothing would be displaced, the
|
|
35
|
+
// profile still exists and that name has not since been moved or taken back in the sample's own meeting, which is what
|
|
36
|
+
// stops a sample of an undone batch coming back. Anything else is counted as not restored. A restored sample becomes the
|
|
37
|
+
// newest Mac-assigned one, so eviction order is not preserved. A vector leaves the journal once it is restored or can
|
|
38
|
+
// never be. One with no room yet, or whose enrolment failed, stays until a later Undo of that name retries it or the
|
|
39
|
+
// journal expires, so two overlapping batches end at the same samples in either Undo order.
|
|
40
|
+
// forgetMacVoiceNamingPerson scrubs vectors when a person is deleted, including from a batch still in memory.
|
|
41
|
+
//
|
|
42
|
+
// LATER HUMAN ACTIONS WIN. A label a person changed afterwards is left alone and the batch's samples for it are still
|
|
43
|
+
// retracted, because that change says the batch was wrong. A label a person CONFIRMED afterwards keeps its samples too.
|
|
44
|
+
//
|
|
45
|
+
// THE LEDGER AFTER AN UNDO. `reverted` goes on the applied row's id, and the undo's own intent closes `failed`. That is
|
|
46
|
+
// the only shape mac-speaker-shadow-repair's reader accepts, and the ledger is append-only, so anything else would
|
|
47
|
+
// block that capture's call view for good.
|
|
48
|
+
//
|
|
49
|
+
// DRAINS. Planning is read-only and holds no lease. Each write section holds its own `api_mutation` lease and the
|
|
50
|
+
// meeting sync lock. isMacVoiceNamingBatchPost is what index.ts needs in order to let the route answer its own refusal.
|
|
51
|
+
|
|
52
|
+
import { nfc, sameSpeakerName } from './speaker-name-nfc.js'
|
|
53
|
+
import { createHash, randomUUID } from 'node:crypto'
|
|
54
|
+
import { mkdirSync, readFileSync, readdirSync, statSync, unlinkSync } from 'node:fs'
|
|
55
|
+
import { dirname, join } from 'node:path'
|
|
56
|
+
import type { Router } from 'express'
|
|
57
|
+
import { durableAtomicWriteFileSync } from './atomic-fs.js'
|
|
58
|
+
import { cosOperationsMeetingsConfigured, findCosOperationsMeetingBySessionId, findDirectLibraryMeetingBySessionId } from './cos-operations-meetings.js'
|
|
59
|
+
import { dataPath } from './data-dir.js'
|
|
60
|
+
import { MAC_ASSIGN_SOURCE, isMacAssign, macAssignCapFor } from './embedding-eviction.js'
|
|
61
|
+
import { MAC_CAPTURE_ID } from './mac-recording.js'
|
|
62
|
+
import { commitMacVoiceAssignment, planMacVoiceAssignment, type MacAssignInput, type MacAssignPlan, type MacAssignReport } from './mac-voice-assignment.js'
|
|
63
|
+
import { isVoiceLabel } from './mac-voice-labels.js'
|
|
64
|
+
import { currentVoiceSuggestions, neverSuggested, voiceSuggestionRecordingLease, type VoiceSuggestion } from './mac-voice-suggestions.js'
|
|
65
|
+
import { acquireMaintenanceWork, maintenanceAdmissionsOpen } from './maintenance-lifecycle.js'
|
|
66
|
+
import { appendCorrection, appliedCorrections, pendingCorrections, readCorrections, type CorrectionRow } from './meeting-corrections.js'
|
|
67
|
+
import { relabelMeetingMarkdown, relabelSidecarJson } from './meeting-relabel.js'
|
|
68
|
+
import { acquireMeetingSyncLock } from './meeting-sync-lock.js'
|
|
69
|
+
import { getOwnerName, getOwnerSpeakerLabel } from './profile.js'
|
|
70
|
+
import { MAX_EMBEDDINGS_PER_SPEAKER, enrollEmbedding, readVoiceProfiles, retractEmbeddingsBySource } from './speaker-embeddings.js'
|
|
71
|
+
import { assertVoiceEvidenceSource } from './voice-evidence-guard.js'
|
|
72
|
+
|
|
73
|
+
export const MAC_VOICE_NAMING_BATCH_DIR = 'mac-voice-naming-batches'
|
|
74
|
+
const SESSION_ID = /^[A-Za-z0-9:_-]{3,96}$/
|
|
75
|
+
const BATCH_ID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/
|
|
76
|
+
const MAX_ITEMS = 99
|
|
77
|
+
/** How long a batch can be undone. After this its journal, with any voiceprints and attendee copies in it, is deleted. */
|
|
78
|
+
export const MAC_VOICE_NAMING_RETENTION_MS = 30 * 24 * 60 * 60 * 1000
|
|
79
|
+
const FILE_MODE = { mode: 0o600 }
|
|
80
|
+
/** Reasons a retry cannot change: the person's action stands. */
|
|
81
|
+
const PERMANENT_LEFT = new Set(['label_changed_by_person', 'confirmed_by_person'])
|
|
82
|
+
|
|
83
|
+
export interface NamingBatchItem { labels: string[]; name: string }
|
|
84
|
+
export interface NamingBatchRequest { sourceRevision: string; items: NamingBatchItem[]; confirm?: boolean }
|
|
85
|
+
export interface NamedEntry { labels: string[]; name: string; samplesAdded: number }
|
|
86
|
+
export interface NotNamedEntry { labels: string[]; name: string; reason: string }
|
|
87
|
+
export interface NamingBatchResult {
|
|
88
|
+
batchId: string
|
|
89
|
+
state: 'applied' | 'partial' | 'refused'
|
|
90
|
+
reason?: string
|
|
91
|
+
named: NamedEntry[]
|
|
92
|
+
notNamed: NotNamedEntry[]
|
|
93
|
+
sourceRevision: string
|
|
94
|
+
undoAvailable: boolean
|
|
95
|
+
dryRun?: true
|
|
96
|
+
}
|
|
97
|
+
export interface NamingBatchUndoResult {
|
|
98
|
+
state: 'undone' | 'partial' | 'refused'
|
|
99
|
+
reason?: string
|
|
100
|
+
reverted: Array<{ labels: string[]; name: string }>
|
|
101
|
+
left: Array<{ labels: string[]; name: string; reason: string }>
|
|
102
|
+
samplesRemoved: number
|
|
103
|
+
/** Samples this batch had evicted that went back, and those that could not. */
|
|
104
|
+
evictedRestored: number
|
|
105
|
+
evictedNotRestored: number
|
|
106
|
+
sourceRevision: string
|
|
107
|
+
dryRun?: true
|
|
108
|
+
}
|
|
109
|
+
export type NamingBatchListState = 'running' | 'applied' | 'partial' | 'interrupted' | 'undone' | 'undo_partial' | 'unreadable'
|
|
110
|
+
export interface NamingBatchListEntry { batchId: string; at: string; state: NamingBatchListState; named: NamedEntry[]; undoAvailable: boolean }
|
|
111
|
+
|
|
112
|
+
interface SampleId { source: string; hash: string }
|
|
113
|
+
interface SampleCopy extends SampleId { embedding: number[] }
|
|
114
|
+
interface StepUndo {
|
|
115
|
+
state: 'reverted' | 'left'
|
|
116
|
+
reason?: string
|
|
117
|
+
samplesRemoved: number
|
|
118
|
+
/** Samples this step added that no profile holds any more. */
|
|
119
|
+
samplesNotFound: number
|
|
120
|
+
evictedRestored: number
|
|
121
|
+
/** Evicted samples that can never come back: the person is gone, or the name moved in their meeting. */
|
|
122
|
+
evictedDropped: number
|
|
123
|
+
/** An interrupted step that had changed nothing: done, and not reported as reverted. */
|
|
124
|
+
nothingApplied?: boolean
|
|
125
|
+
}
|
|
126
|
+
interface Step {
|
|
127
|
+
itemIndex: number
|
|
128
|
+
label: string
|
|
129
|
+
name: string
|
|
130
|
+
/** `intent` left on disk means the process died inside this step. `refused` wrote nothing. */
|
|
131
|
+
state: 'intent' | 'named' | 'failed' | 'refused'
|
|
132
|
+
at: string
|
|
133
|
+
correctionId: string
|
|
134
|
+
changed: number[]
|
|
135
|
+
/** Null when this step did not rewrite the markdown. */
|
|
136
|
+
markdownBeforeHash: string | null
|
|
137
|
+
attendeeEdits: number
|
|
138
|
+
/** The section's body, only when this step edited it. Names, never words. */
|
|
139
|
+
attendeesBefore: string | null
|
|
140
|
+
attendeesAfterHash: string | null
|
|
141
|
+
turnCount: number
|
|
142
|
+
/** Which `[Name]:` turns, by order in the transcript, this step rewrote. */
|
|
143
|
+
turns: number[]
|
|
144
|
+
source: string
|
|
145
|
+
planned: string[]
|
|
146
|
+
before: string[]
|
|
147
|
+
/** Candidates for eviction, kept only until the commit reports what really left. */
|
|
148
|
+
macBefore?: SampleCopy[]
|
|
149
|
+
added: SampleId[]
|
|
150
|
+
evicted: SampleCopy[]
|
|
151
|
+
/** Set once Undo has worked out `added` and `evicted` for a step that died before its journal said. */
|
|
152
|
+
effectsKnown?: boolean
|
|
153
|
+
retractStarted?: boolean
|
|
154
|
+
evictedForgotten?: number
|
|
155
|
+
samplesAdded: number
|
|
156
|
+
undo?: StepUndo
|
|
157
|
+
}
|
|
158
|
+
interface Journal {
|
|
159
|
+
version: 1
|
|
160
|
+
batchId: string
|
|
161
|
+
sessionId: string
|
|
162
|
+
at: string
|
|
163
|
+
state: 'running' | 'applied' | 'partial' | 'undone' | 'undo_partial'
|
|
164
|
+
sourceRevisionBefore: string
|
|
165
|
+
items: NamingBatchItem[]
|
|
166
|
+
steps: Step[]
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
type Refusal = { status: number; body: Record<string, unknown> } | null
|
|
170
|
+
|
|
171
|
+
export interface NamingBatchDeps {
|
|
172
|
+
store: { findBySessionId(sessionId: string): { sidecarPath: string; filepath: string } | null }
|
|
173
|
+
captureActive: () => boolean
|
|
174
|
+
reviewRevision: (meetingPath: string, sidecarPath: string) => string
|
|
175
|
+
callViewMutationRefusal: (sessionId: string) => Refusal
|
|
176
|
+
finalizingRefusal: (sessionId: string) => Refusal
|
|
177
|
+
/** Everything below defaults to the real thing; tests override single seams. */
|
|
178
|
+
suggestions?: (sessionId: string) => { sourceRevision: string; suggestions: VoiceSuggestion[] } | null
|
|
179
|
+
recordingLease?: () => boolean
|
|
180
|
+
admissionsOpen?: () => boolean
|
|
181
|
+
acquireLock?: () => Promise<{ release: () => void }>
|
|
182
|
+
acquireWork?: () => { release: () => void }
|
|
183
|
+
plan?: (input: MacAssignInput) => Promise<MacAssignPlan>
|
|
184
|
+
commit?: (sessionId: string, plan: MacAssignPlan) => MacAssignReport
|
|
185
|
+
log?: (line: string) => void
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
// ── Samples ──────────────────────────────────────────────────────────────────
|
|
189
|
+
|
|
190
|
+
const sha256 = (text: string): string => createHash('sha256').update(text).digest('hex')
|
|
191
|
+
/** Stored rows are the float32 values as JSON doubles, so this hash survives the trip through the store. */
|
|
192
|
+
const vectorHash = (vector: ArrayLike<number>): string => createHash('sha256').update(Buffer.from(Float32Array.from(vector).buffer)).digest('hex')
|
|
193
|
+
const sampleKey = (sample: SampleId): string => `${sample.source}#${sample.hash}`
|
|
194
|
+
|
|
195
|
+
function profileSamples(name: string): SampleCopy[] | null {
|
|
196
|
+
const profile = readVoiceProfiles().profiles.find(p => sameSpeakerName(p.name, name))
|
|
197
|
+
return profile ? profile.embeddings.map((embedding, i) => ({ source: profile.sources?.[i] ?? 'unknown', hash: vectorHash(embedding), embedding })) : null
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/** `a` minus `b` as multisets, keeping `a`'s order. */
|
|
201
|
+
function subtractSamples<T extends SampleId>(a: T[], b: string[]): T[] {
|
|
202
|
+
const left = new Map<string, number>()
|
|
203
|
+
for (const key of b) left.set(key, (left.get(key) ?? 0) + 1)
|
|
204
|
+
return a.filter(sample => {
|
|
205
|
+
const key = sampleKey(sample)
|
|
206
|
+
const count = left.get(key) ?? 0
|
|
207
|
+
if (count === 0) return true
|
|
208
|
+
left.set(key, count - 1)
|
|
209
|
+
return false
|
|
210
|
+
})
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* Where these identities are now, by profile and index: the named profile first, then every other one, because a later
|
|
215
|
+
* merge moves samples with their source intact. `notFound` counts what no profile holds.
|
|
216
|
+
*/
|
|
217
|
+
function locateSamples(name: string, ids: SampleId[]): { found: Array<{ owner: string; samples: SampleCopy[]; drop: Set<number> }>; notFound: number } {
|
|
218
|
+
const wanted = new Map<string, number>()
|
|
219
|
+
for (const id of ids) wanted.set(sampleKey(id), (wanted.get(sampleKey(id)) ?? 0) + 1)
|
|
220
|
+
let notFound = ids.length
|
|
221
|
+
const found: Array<{ owner: string; samples: SampleCopy[]; drop: Set<number> }> = []
|
|
222
|
+
if (ids.length === 0) return { found, notFound }
|
|
223
|
+
const owners = [name, ...readVoiceProfiles().profiles.map(p => p.name).filter(owner => owner !== name)]
|
|
224
|
+
for (const owner of owners) {
|
|
225
|
+
if (notFound === 0) break
|
|
226
|
+
const samples = profileSamples(owner) ?? []
|
|
227
|
+
const drop = new Set<number>()
|
|
228
|
+
samples.forEach((sample, i) => {
|
|
229
|
+
const count = wanted.get(sampleKey(sample)) ?? 0
|
|
230
|
+
if (count === 0) return
|
|
231
|
+
drop.add(i)
|
|
232
|
+
wanted.set(sampleKey(sample), count - 1)
|
|
233
|
+
notFound--
|
|
234
|
+
})
|
|
235
|
+
if (drop.size > 0) found.push({ owner, samples, drop })
|
|
236
|
+
}
|
|
237
|
+
return { found, notFound }
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/** Exactly these identities, wherever they are now. Runs without awaiting, so the indices cannot go stale. */
|
|
241
|
+
function retractSamples(name: string, ids: SampleId[]): { removed: number; notFound: number } {
|
|
242
|
+
const { found, notFound } = locateSamples(name, ids)
|
|
243
|
+
let removed = 0
|
|
244
|
+
for (const { owner, samples, drop } of found) {
|
|
245
|
+
let index = 0
|
|
246
|
+
const out = retractEmbeddingsBySource(owner, source => {
|
|
247
|
+
const at = index++
|
|
248
|
+
return drop.has(at) && samples[at].source === source
|
|
249
|
+
})
|
|
250
|
+
removed += out.removed
|
|
251
|
+
if (out.removed !== drop.size) throw new Error(`removed ${out.removed} of ${drop.size} samples from one profile`)
|
|
252
|
+
}
|
|
253
|
+
return { removed, notFound }
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/** True when `enrollEmbedding` would make room by dropping something. Mirrors its makeRoomForSample. */
|
|
257
|
+
function wouldDisplace(samples: SampleCopy[]): boolean {
|
|
258
|
+
const macCount = samples.filter(s => isMacAssign(s.source)).length
|
|
259
|
+
return samples.length >= MAX_EMBEDDINGS_PER_SPEAKER || macCount >= macAssignCapFor(samples.length, macCount)
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
/**
|
|
263
|
+
* Has that name been taken back or moved in the meeting this sample came from, since this step? A `reverted` row counts:
|
|
264
|
+
* a sample from a batch that was undone after it was evicted must not come back. `own` is this journal's corrections,
|
|
265
|
+
* whose rows say nothing about a person.
|
|
266
|
+
*/
|
|
267
|
+
function nameMovedInItsMeeting(sample: SampleId, step: Step, own: Set<string>): boolean {
|
|
268
|
+
// A Mac-assigned sample only ever evicts another Mac-assigned one, so every evicted source names its meeting.
|
|
269
|
+
const session = sample.source.slice(MAC_ASSIGN_SOURCE.length + 1)
|
|
270
|
+
const later = (row: CorrectionRow) => !own.has(row.id) && row.from === step.name && row.at >= step.at
|
|
271
|
+
return appliedCorrections(session).some(later) || readCorrections(session).rows.some(row => row.phase === 'reverted' && later(row))
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
/** One pass. `keep` is what another pass could still restore: no room yet, or enrolment failed. `dropped` is final. */
|
|
275
|
+
function restoreEvicted(step: Step, evicted: SampleCopy[], own: Set<string>): { restored: number; dropped: number; keep: SampleCopy[]; enrolFailed: boolean } {
|
|
276
|
+
const out = { restored: 0, dropped: 0, keep: [] as SampleCopy[], enrolFailed: false }
|
|
277
|
+
for (const sample of evicted) {
|
|
278
|
+
const samples = profileSamples(step.name)
|
|
279
|
+
if (samples?.some(s => sampleKey(s) === sampleKey(sample))) continue
|
|
280
|
+
if (!samples || nameMovedInItsMeeting(sample, step, own)) { out.dropped++; continue }
|
|
281
|
+
if (wouldDisplace(samples)) { out.keep.push(sample); continue }
|
|
282
|
+
if (enrollEmbedding(step.name, Float32Array.from(sample.embedding), sample.source, true).success) out.restored++
|
|
283
|
+
else { out.keep.push(sample); out.enrolFailed = true }
|
|
284
|
+
}
|
|
285
|
+
return out
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
/** What this step did to the profile. A step that died inside its commit is worked out from what is there now. */
|
|
289
|
+
function sampleEffects(step: Step): { added: SampleId[]; evicted: SampleCopy[] } {
|
|
290
|
+
if (step.state === 'named' || step.effectsKnown) return { added: step.added, evicted: step.evicted }
|
|
291
|
+
const current = profileSamples(step.name) ?? []
|
|
292
|
+
const planned = new Set(step.planned)
|
|
293
|
+
const added = subtractSamples(current.filter(s => s.source === step.source && planned.has(s.hash)), step.before)
|
|
294
|
+
// No added sample means the commit never ran, so a missing sample left for some other reason.
|
|
295
|
+
const evicted = added.length > 0 ? subtractSamples(step.macBefore ?? [], current.map(sampleKey)) : []
|
|
296
|
+
return { added: added.map(({ source, hash }) => ({ source, hash })), evicted }
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
// ── Journal ──────────────────────────────────────────────────────────────────
|
|
300
|
+
|
|
301
|
+
const sessionFolder = (sessionId: string): string => dataPath(MAC_VOICE_NAMING_BATCH_DIR, sessionId.replace(/:/g, '_'))
|
|
302
|
+
const journalPath = (sessionId: string, batchId: string): string => join(sessionFolder(sessionId), `${batchId}.json`)
|
|
303
|
+
|
|
304
|
+
/** Names deleted in this process, and when. A batch still in memory must not write their voiceprints back. */
|
|
305
|
+
const forgotten = new Map<string, string>()
|
|
306
|
+
|
|
307
|
+
/** Drops what a person's deletion must not leave behind. Returns the number of voiceprints dropped. */
|
|
308
|
+
function scrubForName(step: Step, name: string): number {
|
|
309
|
+
if (step.attendeesBefore && nfc(step.attendeesBefore).includes(nfc(name))) step.attendeesBefore = null
|
|
310
|
+
if (!sameSpeakerName(step.name, name)) return 0
|
|
311
|
+
const vectors = step.evicted.length + (step.macBefore?.length ?? 0)
|
|
312
|
+
step.evictedForgotten = (step.evictedForgotten ?? 0) + step.evicted.length
|
|
313
|
+
step.evicted = []
|
|
314
|
+
delete step.macBefore
|
|
315
|
+
return vectors
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
function writeJournal(journal: Journal): void {
|
|
319
|
+
for (const step of journal.steps) {
|
|
320
|
+
for (const [name, at] of forgotten) if (step.at <= at) scrubForName(step, name)
|
|
321
|
+
}
|
|
322
|
+
const path = journalPath(journal.sessionId, journal.batchId)
|
|
323
|
+
mkdirSync(dirname(path), { recursive: true, mode: 0o700 })
|
|
324
|
+
durableAtomicWriteFileSync(path, `${JSON.stringify(journal)}\n`, FILE_MODE)
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
function isExpired(path: string, now = Date.now()): boolean {
|
|
328
|
+
try { return now - statSync(path).mtimeMs > MAC_VOICE_NAMING_RETENTION_MS } catch { return false }
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
/** `missing` covers a journal past retention: it no longer exists as far as anyone is told. */
|
|
332
|
+
function loadJournal(sessionId: string, batchId: string): Journal | 'missing' | 'unreadable' {
|
|
333
|
+
const path = journalPath(sessionId, batchId)
|
|
334
|
+
if (isExpired(path)) return 'missing'
|
|
335
|
+
let text: string
|
|
336
|
+
try { text = readFileSync(path, 'utf-8') } catch { return 'missing' }
|
|
337
|
+
try {
|
|
338
|
+
const parsed = JSON.parse(text) as Journal
|
|
339
|
+
return parsed?.version === 1 && parsed.sessionId === sessionId && parsed.batchId === batchId && Array.isArray(parsed.steps) ? parsed : 'unreadable'
|
|
340
|
+
} catch { return 'unreadable' }
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
/** Every journal on disk, as paths. */
|
|
344
|
+
function journalFiles(): Array<{ session: string; batchId: string; path: string }> {
|
|
345
|
+
const out: Array<{ session: string; batchId: string; path: string }> = []
|
|
346
|
+
let folders: string[] = []
|
|
347
|
+
try { folders = readdirSync(dataPath(MAC_VOICE_NAMING_BATCH_DIR)) } catch { return out }
|
|
348
|
+
for (const session of folders) {
|
|
349
|
+
let files: string[] = []
|
|
350
|
+
try { files = readdirSync(dataPath(MAC_VOICE_NAMING_BATCH_DIR, session)) } catch { continue }
|
|
351
|
+
for (const file of files) {
|
|
352
|
+
const batchId = file.replace(/\.json$/, '')
|
|
353
|
+
if (BATCH_ID.test(batchId)) out.push({ session, batchId, path: dataPath(MAC_VOICE_NAMING_BATCH_DIR, session, file) })
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
return out
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
/** Undo is offered for the retention window, and the voiceprints and attendee copies go with the journal after it. */
|
|
360
|
+
function pruneExpiredJournals(): void {
|
|
361
|
+
for (const { path } of journalFiles()) {
|
|
362
|
+
if (isExpired(path)) { try { unlinkSync(path) } catch { /* the next pass tries again */ } }
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
/** Person deleted: drop the voiceprints journals hold for them. Their count stays, reported as not restored. */
|
|
367
|
+
export function forgetMacVoiceNamingPerson(name: string): { batches: number; vectors: number } {
|
|
368
|
+
name = nfc(name)
|
|
369
|
+
forgotten.set(name, new Date().toISOString())
|
|
370
|
+
const out = { batches: 0, vectors: 0 }
|
|
371
|
+
for (const { path } of journalFiles()) {
|
|
372
|
+
let journal: Journal
|
|
373
|
+
try { journal = JSON.parse(readFileSync(path, 'utf-8')) as Journal } catch { continue }
|
|
374
|
+
const before = JSON.stringify(journal)
|
|
375
|
+
let vectors = 0
|
|
376
|
+
for (const step of journal.steps ?? []) vectors += scrubForName(step, name)
|
|
377
|
+
if (JSON.stringify(journal) === before) continue
|
|
378
|
+
writeJournal(journal)
|
|
379
|
+
out.batches++
|
|
380
|
+
out.vectors += vectors
|
|
381
|
+
}
|
|
382
|
+
return out
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
// ── Markdown ─────────────────────────────────────────────────────────────────
|
|
386
|
+
|
|
387
|
+
const TURN = /^\[[^\]\r\n]+\]:/
|
|
388
|
+
|
|
389
|
+
/** Line numbers of `[Name]:` turns in the Transcript section, which is what relabelMeetingMarkdown rewrites. */
|
|
390
|
+
function transcriptTurnLines(lines: string[]): number[] {
|
|
391
|
+
const out: number[] = []
|
|
392
|
+
let inside = false
|
|
393
|
+
for (let i = 0; i < lines.length; i++) {
|
|
394
|
+
if (/^##\s+/.test(lines[i])) inside = /^##\s+Transcript\s*$/.test(lines[i])
|
|
395
|
+
else if (inside && TURN.test(lines[i])) out.push(i)
|
|
396
|
+
}
|
|
397
|
+
return out
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
/** The Attendees section's body, as relabelMeetingMarkdown bounds it. Null when there is none. */
|
|
401
|
+
function attendeeRange(md: string): { start: number; end: number } | null {
|
|
402
|
+
const heading = /^##\s+Attendees\s*$/m.exec(md)
|
|
403
|
+
if (!heading) return null
|
|
404
|
+
const start = heading.index + heading[0].length
|
|
405
|
+
const next = /^##\s+/m.exec(md.slice(start))
|
|
406
|
+
return { start, end: next ? start + next.index : md.length }
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
/**
|
|
410
|
+
* The markdown with this step's edits taken back, or null when that cannot be done honestly. The journal keeps no words:
|
|
411
|
+
* turn order finds the turns and the attendee section is names only, which restores the exact bytes when nothing else
|
|
412
|
+
* has changed. Turn order and not turn text, because undoSpeakerMarkdown refuses a turn whose words repeat, and "Yeah." repeats on
|
|
413
|
+
* every real call.
|
|
414
|
+
*/
|
|
415
|
+
function revertedMarkdown(current: string, step: Step): string | null {
|
|
416
|
+
if (step.markdownBeforeHash === null) return current
|
|
417
|
+
if (sha256(current) === step.markdownBeforeHash) return current
|
|
418
|
+
const lines = current.split('\n')
|
|
419
|
+
const turns = transcriptTurnLines(lines)
|
|
420
|
+
if (turns.length !== step.turnCount) return null
|
|
421
|
+
const from = `[${step.name}]:`
|
|
422
|
+
const to = `[${step.label}]:`
|
|
423
|
+
for (const ordinal of step.turns) {
|
|
424
|
+
const line = lines[turns[ordinal]]
|
|
425
|
+
if (line.startsWith(to)) continue
|
|
426
|
+
if (!line.startsWith(from)) return null
|
|
427
|
+
lines[turns[ordinal]] = to + line.slice(from.length)
|
|
428
|
+
}
|
|
429
|
+
const next = lines.join('\n')
|
|
430
|
+
if (step.attendeeEdits === 0) return next
|
|
431
|
+
const range = attendeeRange(next)
|
|
432
|
+
if (!range || step.attendeesBefore === null || sha256(next.slice(range.start, range.end)) !== step.attendeesAfterHash) return null
|
|
433
|
+
return next.slice(0, range.start) + step.attendeesBefore + next.slice(range.end)
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
// ── The batch ────────────────────────────────────────────────────────────────
|
|
437
|
+
|
|
438
|
+
const sameLabels = (a: readonly string[], b: readonly string[]): boolean => a.length === b.length && [...a].sort().join('\n') === [...b].sort().join('\n')
|
|
439
|
+
const readText = (path: string): string | null => { try { return readFileSync(path, 'utf-8') } catch { return null } }
|
|
440
|
+
const yieldToLoop = (): Promise<void> => new Promise(resolve => { setImmediate(resolve) })
|
|
441
|
+
/** Every store write is an atomic rename, so any write by anyone changes this. */
|
|
442
|
+
function profileStoreStamp(): string {
|
|
443
|
+
try { const info = statSync(dataPath('voice-profiles.json')); return `${info.ino}:${info.size}:${info.mtimeMs}` } catch { return 'missing' }
|
|
444
|
+
}
|
|
445
|
+
const itemIsSuggested = (current: { suggestions: VoiceSuggestion[] }, item: NamingBatchItem): boolean => current.suggestions.find(s => sameSpeakerName(s.name, item.name) && sameLabels(s.labels, item.labels))?.highConfidence === true
|
|
446
|
+
|
|
447
|
+
/** One batch or Undo per session at a time, in this process. The value is the journal it is writing, once it has one. */
|
|
448
|
+
const active = new Map<string, string | null>()
|
|
449
|
+
|
|
450
|
+
function isOwnerName(name: string): boolean {
|
|
451
|
+
let label: string | undefined
|
|
452
|
+
let owner: string | undefined
|
|
453
|
+
try { label = getOwnerSpeakerLabel() } catch { /* MU is still refused */ }
|
|
454
|
+
try { owner = getOwnerName() } catch { /* MU is still refused */ }
|
|
455
|
+
return neverSuggested(name, label) || neverSuggested(name, owner)
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
function namedEntries(journal: Journal): NamedEntry[] {
|
|
459
|
+
const byItem = new Map<number, NamedEntry>()
|
|
460
|
+
for (const step of journal.steps) {
|
|
461
|
+
if (step.state !== 'named') continue
|
|
462
|
+
const entry = byItem.get(step.itemIndex) ?? { labels: [], name: step.name, samplesAdded: 0 }
|
|
463
|
+
entry.labels.push(step.label)
|
|
464
|
+
entry.samplesAdded += step.samplesAdded
|
|
465
|
+
byItem.set(step.itemIndex, entry)
|
|
466
|
+
}
|
|
467
|
+
return [...byItem.values()]
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
function journalUndoAvailable(journal: Journal): boolean {
|
|
471
|
+
if (active.get(journal.sessionId) === journal.batchId) return false
|
|
472
|
+
return journal.steps.some(step => step.state !== 'refused' && step.undo?.state !== 'reverted' && !(step.undo?.state === 'left' && PERMANENT_LEFT.has(step.undo.reason ?? '')))
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
function listState(journal: Journal): NamingBatchListState {
|
|
476
|
+
return journal.state === 'running' && active.get(journal.sessionId) !== journal.batchId ? 'interrupted' : journal.state
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
/** The attendee section before and the hash after, when a step edited it. */
|
|
480
|
+
function attendeeCopies(before: string | null, after: string | null): { attendeesBefore: string | null; attendeesAfterHash: string | null } {
|
|
481
|
+
const beforeRange = before === null ? null : attendeeRange(before)
|
|
482
|
+
const afterRange = after === null ? null : attendeeRange(after)
|
|
483
|
+
if (before === null || after === null || !beforeRange || !afterRange) return { attendeesBefore: null, attendeesAfterHash: null }
|
|
484
|
+
return { attendeesBefore: before.slice(beforeRange.start, beforeRange.end), attendeesAfterHash: sha256(after.slice(afterRange.start, afterRange.end)) }
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
export interface MacVoiceNamingBatches {
|
|
488
|
+
run(sessionId: string, request: NamingBatchRequest): Promise<NamingBatchResult>
|
|
489
|
+
undo(sessionId: string, batchId: string, confirm: boolean): Promise<NamingBatchUndoResult>
|
|
490
|
+
list(sessionId: string): { batches: NamingBatchListEntry[] }
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
export function createMacVoiceNamingBatches(deps: NamingBatchDeps): MacVoiceNamingBatches {
|
|
494
|
+
const suggestions = deps.suggestions ?? currentVoiceSuggestions
|
|
495
|
+
const recordingLease = deps.recordingLease ?? (() => voiceSuggestionRecordingLease())
|
|
496
|
+
const admissionsOpen = deps.admissionsOpen ?? maintenanceAdmissionsOpen
|
|
497
|
+
const acquireLock = deps.acquireLock ?? acquireMeetingSyncLock
|
|
498
|
+
const acquireWork = deps.acquireWork ?? (() => acquireMaintenanceWork('api_mutation'))
|
|
499
|
+
const plan = deps.plan ?? planMacVoiceAssignment
|
|
500
|
+
const commit = deps.commit ?? commitMacVoiceAssignment
|
|
501
|
+
const log = deps.log ?? ((line: string) => console.log(`[mac-naming-batch] ${line}`))
|
|
502
|
+
|
|
503
|
+
/** The same copy of the meeting relabel writes: operations first, read-only libraries refused. */
|
|
504
|
+
function resolveMeeting(sessionId: string): { refused: string } | { sidecarPath: string; meetingPath: string } {
|
|
505
|
+
if (assertVoiceEvidenceSource([sessionId]) || !MAC_CAPTURE_ID.test(sessionId)) return { refused: 'not_saved_mac_capture' }
|
|
506
|
+
const operations = cosOperationsMeetingsConfigured() ? findCosOperationsMeetingBySessionId(sessionId) : null
|
|
507
|
+
if (operations?.mergedScribeConflict?.length) return { refused: 'record_source_mismatch' }
|
|
508
|
+
if (operations) return { sidecarPath: operations.sidecarPath, meetingPath: operations.meetingPath }
|
|
509
|
+
if (findDirectLibraryMeetingBySessionId(sessionId)) return { refused: 'direct_library_read_only' }
|
|
510
|
+
const saved = deps.store.findBySessionId(sessionId)
|
|
511
|
+
return saved ? { sidecarPath: saved.sidecarPath, meetingPath: saved.filepath } : { refused: 'not_saved_mac_capture' }
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
function blockReason(sessionId: string): string | null {
|
|
515
|
+
if (deps.captureActive() || recordingLease()) return 'recording_active'
|
|
516
|
+
if (!admissionsOpen()) return 'maintenance'
|
|
517
|
+
if (deps.finalizingRefusal(sessionId)) return 'meeting_finalizing'
|
|
518
|
+
const view = deps.callViewMutationRefusal(sessionId)
|
|
519
|
+
return view ? String(view.body.reason ?? 'derived_call_view') : null
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
/** Whole-batch checks. Writes nothing and awaits nothing. */
|
|
523
|
+
function preflight(sessionId: string, request: NamingBatchRequest, meeting: { sidecarPath: string; meetingPath: string }, revision: string): { reason: string; item?: number } | { sidecar: string } {
|
|
524
|
+
const blocked = blockReason(sessionId)
|
|
525
|
+
if (blocked) return { reason: blocked }
|
|
526
|
+
if (pendingCorrections(sessionId).length > 0) return { reason: 'correction_pending' }
|
|
527
|
+
const seen = new Set<string>()
|
|
528
|
+
for (const [item, { labels, name }] of request.items.entries()) {
|
|
529
|
+
if (isOwnerName(name)) return { reason: 'owner_refused', item }
|
|
530
|
+
for (const label of labels) {
|
|
531
|
+
if (!isVoiceLabel(label)) return { reason: 'label_not_anonymous', item }
|
|
532
|
+
if (seen.has(label)) return { reason: 'duplicate_label', item }
|
|
533
|
+
seen.add(label)
|
|
534
|
+
}
|
|
535
|
+
}
|
|
536
|
+
const current = suggestions(sessionId)
|
|
537
|
+
if (!current || current.sourceRevision !== request.sourceRevision || current.sourceRevision !== revision) return { reason: 'suggestions_stale' }
|
|
538
|
+
for (const [index, item] of request.items.entries()) {
|
|
539
|
+
if (!itemIsSuggested(current, item)) return { reason: 'not_high_confidence', item: index }
|
|
540
|
+
}
|
|
541
|
+
const sidecar = readText(meeting.sidecarPath)
|
|
542
|
+
if (sidecar === null) return { reason: 'sidecar_unreadable' }
|
|
543
|
+
let carried: Set<unknown>
|
|
544
|
+
try {
|
|
545
|
+
const rows = (JSON.parse(sidecar) as { chunks?: unknown }).chunks
|
|
546
|
+
if (!Array.isArray(rows)) return { reason: 'sidecar_unreadable' }
|
|
547
|
+
carried = new Set(rows.map(row => (row as { speaker?: unknown } | null)?.speaker))
|
|
548
|
+
} catch { return { reason: 'sidecar_unreadable' } }
|
|
549
|
+
for (const [item, { labels }] of request.items.entries()) {
|
|
550
|
+
if (labels.some(label => !carried.has(label))) return { reason: 'label_not_anonymous', item }
|
|
551
|
+
}
|
|
552
|
+
return { sidecar }
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
async function run(sessionId: string, request: NamingBatchRequest): Promise<NamingBatchResult> {
|
|
556
|
+
const confirm = request.confirm === true
|
|
557
|
+
const base = (reason: string, notNamed: NotNamedEntry[], sourceRevision: string): NamingBatchResult => ({
|
|
558
|
+
batchId: '', state: 'refused', reason, named: [], notNamed, sourceRevision, undoAvailable: false, ...(confirm ? {} : { dryRun: true as const }),
|
|
559
|
+
})
|
|
560
|
+
const allRefused = (reason: string, item?: number): NotNamedEntry[] => request.items.map((entry, i) => ({ labels: entry.labels, name: entry.name, reason: item === undefined || item === i ? reason : 'not_attempted' }))
|
|
561
|
+
const meeting = resolveMeeting(sessionId)
|
|
562
|
+
if ('refused' in meeting) return base(meeting.refused, allRefused(meeting.refused), '')
|
|
563
|
+
const { sidecarPath, meetingPath } = meeting
|
|
564
|
+
const revision = (): string => deps.reviewRevision(meetingPath, sidecarPath)
|
|
565
|
+
const before = revision()
|
|
566
|
+
if (active.has(sessionId)) return base('batch_running', allRefused('batch_running'), before)
|
|
567
|
+
const checked = preflight(sessionId, request, meeting, before)
|
|
568
|
+
if ('reason' in checked) return base(checked.reason, allRefused(checked.reason, checked.item), before)
|
|
569
|
+
|
|
570
|
+
if (confirm) pruneExpiredJournals()
|
|
571
|
+
active.set(sessionId, null)
|
|
572
|
+
const batchId = randomUUID()
|
|
573
|
+
let journal = null as Journal | null
|
|
574
|
+
let expected = { sidecar: checked.sidecar, markdown: readText(meetingPath) }
|
|
575
|
+
/** What this batch left, after its last commit. Null until it has written anything. */
|
|
576
|
+
let own = null as { store: string; revision: string } | null
|
|
577
|
+
const wouldName = new Map<number, NamedEntry>()
|
|
578
|
+
const moved = (): boolean => readText(sidecarPath) !== expected.sidecar || readText(meetingPath) !== expected.markdown
|
|
579
|
+
|
|
580
|
+
/** One label through the normal path. `named` is true when the label landed, even if the samples then failed. */
|
|
581
|
+
const nameLabel = async (itemIndex: number, item: NamingBatchItem, label: string): Promise<{ named: boolean; stop: string | null }> => {
|
|
582
|
+
const { name } = item
|
|
583
|
+
const refuse = (stop: string) => ({ named: false, stop })
|
|
584
|
+
const blocked = blockReason(sessionId)
|
|
585
|
+
if (blocked) return refuse(blocked)
|
|
586
|
+
const rename = relabelSidecarJson(expected.sidecar, label, name, [])
|
|
587
|
+
if (!rename.ok) return refuse('label_not_anonymous')
|
|
588
|
+
const ownerLabel = getOwnerSpeakerLabel()
|
|
589
|
+
const assignment = await plan({ sessionId, from: label, to: name, changed: rename.value.changed, sidecar: JSON.parse(expected.sidecar) as Record<string, unknown>, ownerLabel })
|
|
590
|
+
if (assignment.report.skipped !== null) return refuse(assignment.report.skipped)
|
|
591
|
+
if (assignment.report.gates.length > 0) return refuse(assignment.report.gates[0])
|
|
592
|
+
if (!confirm) {
|
|
593
|
+
const entry = wouldName.get(itemIndex) ?? { labels: [], name, samplesAdded: 0 }
|
|
594
|
+
entry.labels.push(label)
|
|
595
|
+
entry.samplesAdded += assignment.report.enrolled
|
|
596
|
+
wouldName.set(itemIndex, entry)
|
|
597
|
+
return { named: true, stop: null }
|
|
598
|
+
}
|
|
599
|
+
let work: { release: () => void }
|
|
600
|
+
try { work = acquireWork() } catch { return refuse('maintenance') }
|
|
601
|
+
let lock: { release: () => void } | undefined
|
|
602
|
+
try {
|
|
603
|
+
try { lock = await acquireLock() } catch { return refuse('sync_running') }
|
|
604
|
+
// The plan yielded and the lock waited, so anything may have started or moved by now.
|
|
605
|
+
const blockedAtLock = blockReason(sessionId)
|
|
606
|
+
if (blockedAtLock) return refuse(blockedAtLock)
|
|
607
|
+
if (pendingCorrections(sessionId).length > 0) return refuse('correction_pending')
|
|
608
|
+
if (moved()) return refuse('meeting_changed')
|
|
609
|
+
if (own === null) {
|
|
610
|
+
const current = suggestions(sessionId)
|
|
611
|
+
if (!current || current.sourceRevision !== revision() || !itemIsSuggested(current, item)) return refuse('suggestions_stale')
|
|
612
|
+
} else if (profileStoreStamp() !== own.store || revision() !== own.revision) return refuse('suggestions_stale')
|
|
613
|
+
// From here to the release nothing awaits, so the profile diff below is exactly the commit.
|
|
614
|
+
const md = expected.markdown === null ? null : relabelMeetingMarkdown(expected.markdown, label, name)
|
|
615
|
+
const edit = md?.ok ? md.value : null
|
|
616
|
+
const rewrites = !!edit && (edit.attendees > 0 || edit.transcript > 0)
|
|
617
|
+
const lines = expected.markdown?.split('\n') ?? []
|
|
618
|
+
const turnLines = transcriptTurnLines(lines)
|
|
619
|
+
const at = new Date().toISOString()
|
|
620
|
+
const correctionId = `${sessionId}:${label}>${name}:${Date.now().toString(36)}`
|
|
621
|
+
const samplesBefore = profileSamples(name) ?? []
|
|
622
|
+
const source = `${MAC_ASSIGN_SOURCE}:${sessionId}`
|
|
623
|
+
const step: Step = {
|
|
624
|
+
itemIndex, label, name, state: 'intent', at, correctionId, changed: rename.value.changed,
|
|
625
|
+
markdownBeforeHash: rewrites && expected.markdown !== null ? sha256(expected.markdown) : null,
|
|
626
|
+
attendeeEdits: edit?.attendees ?? 0,
|
|
627
|
+
...attendeeCopies(expected.markdown, edit?.attendees ? edit.markdown : null),
|
|
628
|
+
turnCount: turnLines.length,
|
|
629
|
+
turns: turnLines.flatMap((line, ordinal) => (lines[line].startsWith(`[${label}]:`) ? [ordinal] : [])),
|
|
630
|
+
source,
|
|
631
|
+
planned: assignment.selected.map(vectorHash),
|
|
632
|
+
before: samplesBefore.map(sampleKey),
|
|
633
|
+
macBefore: samplesBefore.filter(sample => isMacAssign(sample.source)),
|
|
634
|
+
added: [], evicted: [], samplesAdded: 0,
|
|
635
|
+
}
|
|
636
|
+
const opened: Journal = journal ?? { version: 1, batchId, sessionId, at, state: 'running', sourceRevisionBefore: before, items: request.items, steps: [] }
|
|
637
|
+
opened.steps.push(step)
|
|
638
|
+
active.set(sessionId, batchId)
|
|
639
|
+
try { writeJournal(opened) } catch (error) {
|
|
640
|
+
opened.steps.pop()
|
|
641
|
+
log(`journal not writable, stopping ${batchId}: ${String(error)}`)
|
|
642
|
+
return refuse('journal_unwritable')
|
|
643
|
+
}
|
|
644
|
+
journal = opened
|
|
645
|
+
const row: CorrectionRow = { id: correctionId, phase: 'intent', at, from: label, to: name, chunks: rename.value.changed, scope: 'meeting', source: `mac-naming-batch:${batchId}` }
|
|
646
|
+
if (!appendCorrection(sessionId, row)) {
|
|
647
|
+
step.state = 'refused'
|
|
648
|
+
writeJournal(opened)
|
|
649
|
+
return refuse('ledger_unwritable')
|
|
650
|
+
}
|
|
651
|
+
try {
|
|
652
|
+
durableAtomicWriteFileSync(sidecarPath, rename.value.json, FILE_MODE)
|
|
653
|
+
if (rewrites && edit) durableAtomicWriteFileSync(meetingPath, edit.markdown, FILE_MODE)
|
|
654
|
+
} catch (error) {
|
|
655
|
+
appendCorrection(sessionId, { ...row, phase: 'failed', at: new Date().toISOString(), error: String(error) })
|
|
656
|
+
step.state = 'failed'
|
|
657
|
+
writeJournal(opened)
|
|
658
|
+
return refuse('write_failed')
|
|
659
|
+
}
|
|
660
|
+
appendCorrection(sessionId, { ...row, phase: 'applied', at: new Date().toISOString(), surfaces: { sidecar: rename.value.changed.length, attendees: edit?.attendees ?? 0, transcript: edit?.transcript ?? 0 }, proseStale: edit?.proseStale ?? false })
|
|
661
|
+
expected = { sidecar: rename.value.json, markdown: rewrites && edit ? edit.markdown : expected.markdown }
|
|
662
|
+
const report = commit(sessionId, assignment)
|
|
663
|
+
own = { store: profileStoreStamp(), revision: revision() }
|
|
664
|
+
const samplesAfter = profileSamples(name) ?? []
|
|
665
|
+
step.added = subtractSamples(samplesAfter, samplesBefore.map(sampleKey)).map(({ source: s, hash }) => ({ source: s, hash }))
|
|
666
|
+
step.evicted = subtractSamples(samplesBefore, samplesAfter.map(sampleKey))
|
|
667
|
+
step.samplesAdded = step.added.length
|
|
668
|
+
step.state = 'named'
|
|
669
|
+
delete step.macBefore
|
|
670
|
+
writeJournal(opened)
|
|
671
|
+
return { named: true, stop: report.skipped }
|
|
672
|
+
} finally {
|
|
673
|
+
lock?.release()
|
|
674
|
+
work.release()
|
|
675
|
+
}
|
|
676
|
+
}
|
|
677
|
+
|
|
678
|
+
const notNamedByItem = new Map<string, NotNamedEntry>()
|
|
679
|
+
const noteNotNamed = (itemIndex: number, label: string, name: string, reason: string): void => {
|
|
680
|
+
const key = `${itemIndex}|${reason}`
|
|
681
|
+
const entry = notNamedByItem.get(key) ?? { labels: [], name, reason }
|
|
682
|
+
entry.labels.push(label)
|
|
683
|
+
notNamedByItem.set(key, entry)
|
|
684
|
+
}
|
|
685
|
+
let stop: string | null = null
|
|
686
|
+
try {
|
|
687
|
+
for (const [itemIndex, item] of request.items.entries()) {
|
|
688
|
+
for (const label of item.labels) {
|
|
689
|
+
if (stop) { noteNotNamed(itemIndex, label, item.name, 'not_attempted'); continue }
|
|
690
|
+
const outcome = await nameLabel(itemIndex, item, label)
|
|
691
|
+
stop = outcome.stop
|
|
692
|
+
if (!outcome.named) noteNotNamed(itemIndex, label, item.name, outcome.stop ?? 'not_named')
|
|
693
|
+
}
|
|
694
|
+
}
|
|
695
|
+
if (journal) {
|
|
696
|
+
const finished: Journal = journal
|
|
697
|
+
finished.state = stop ? 'partial' : 'applied'
|
|
698
|
+
writeJournal(finished)
|
|
699
|
+
}
|
|
700
|
+
} catch (error) {
|
|
701
|
+
// Same outcome as a process dying here: nothing more is written, the journal stays `running`, and it reads
|
|
702
|
+
// `interrupted` once this batch lets go of the session.
|
|
703
|
+
log(`interrupted ${batchId}: ${error instanceof Error ? error.message : String(error)}`)
|
|
704
|
+
stop = 'interrupted'
|
|
705
|
+
} finally {
|
|
706
|
+
active.delete(sessionId)
|
|
707
|
+
}
|
|
708
|
+
const landed = journal
|
|
709
|
+
const named = landed ? namedEntries(landed) : [...wouldName.values()]
|
|
710
|
+
// A label that never reached a verdict because the batch died mid-item stays unnamed like the rest.
|
|
711
|
+
if (stop === 'interrupted') {
|
|
712
|
+
const done = new Set(named.flatMap(entry => entry.labels))
|
|
713
|
+
const listed = new Set([...notNamedByItem.values()].flatMap(entry => entry.labels))
|
|
714
|
+
for (const [itemIndex, item] of request.items.entries()) for (const label of item.labels) if (!done.has(label) && !listed.has(label)) noteNotNamed(itemIndex, label, item.name, 'interrupted')
|
|
715
|
+
}
|
|
716
|
+
const notNamed = [...notNamedByItem.values()]
|
|
717
|
+
return {
|
|
718
|
+
batchId: landed ? batchId : '',
|
|
719
|
+
state: named.length === 0 && !landed?.steps.some(step => step.state !== 'refused') ? 'refused' : stop ? 'partial' : 'applied',
|
|
720
|
+
...(stop ? { reason: stop } : {}),
|
|
721
|
+
named,
|
|
722
|
+
notNamed,
|
|
723
|
+
sourceRevision: revision(),
|
|
724
|
+
undoAvailable: !!landed && journalUndoAvailable(landed),
|
|
725
|
+
...(confirm ? {} : { dryRun: true as const }),
|
|
726
|
+
}
|
|
727
|
+
}
|
|
728
|
+
|
|
729
|
+
// ── Undo ───────────────────────────────────────────────────────────────────
|
|
730
|
+
|
|
731
|
+
type Verdict = 'confirmed_by_person' | 'label_changed_by_person' | 'applied' | 'not_applied'
|
|
732
|
+
|
|
733
|
+
function verdictFor(step: Step, own: Set<string>, rows: CorrectionRow[], doc: Record<string, unknown>): Verdict {
|
|
734
|
+
const revoked = new Set(rows.filter(r => r.phase === 'reverted').map(r => r.batchId ?? r.id))
|
|
735
|
+
const later = rows.filter(r => !own.has(r.id) && !revoked.has(r.batchId ?? r.id) && r.at >= step.at)
|
|
736
|
+
const overlaps = (r: CorrectionRow) => r.chunks.some(i => step.changed.includes(i))
|
|
737
|
+
if (later.some(r => (r.phase === 'confirmed' || r.phase === 'confirmed-chunks') && sameSpeakerName(r.to, step.name) && (r.chunks.length === 0 || overlaps(r)))) return 'confirmed_by_person'
|
|
738
|
+
if (later.some(r => r.phase === 'applied' && overlaps(r))) return 'label_changed_by_person'
|
|
739
|
+
const chunks = Array.isArray(doc.chunks) ? doc.chunks as Array<{ speaker?: unknown } | null> : []
|
|
740
|
+
const speakers = step.changed.map(i => chunks[i]?.speaker)
|
|
741
|
+
if (speakers.every(speaker => speaker === step.name)) return 'applied'
|
|
742
|
+
return speakers.every(speaker => speaker === step.label) ? 'not_applied' : 'label_changed_by_person'
|
|
743
|
+
}
|
|
744
|
+
|
|
745
|
+
/** Puts one step's label back: markdown, then sidecar, so a crash can never leave the sidecar reverted alone. */
|
|
746
|
+
function revertLabel(sessionId: string, batchId: string, step: Step, meeting: { sidecarPath: string; meetingPath: string }, sidecar: string): string | null {
|
|
747
|
+
const markdown = readText(meeting.meetingPath)
|
|
748
|
+
const nextMarkdown = markdown === null ? null : revertedMarkdown(markdown, step)
|
|
749
|
+
if (step.markdownBeforeHash !== null && nextMarkdown === null) return 'transcript_changed'
|
|
750
|
+
const inverse = relabelSidecarJson(sidecar, step.name, step.label, step.changed)
|
|
751
|
+
if (!inverse.ok) return 'label_changed_by_person'
|
|
752
|
+
const row: CorrectionRow = { id: `${step.correctionId}:undo:${Date.now().toString(36)}`, phase: 'intent', at: new Date().toISOString(), from: step.name, to: step.label, chunks: step.changed, scope: 'meeting', source: `mac-naming-batch-undo:${batchId}` }
|
|
753
|
+
if (!appendCorrection(sessionId, row)) return 'ledger_unwritable'
|
|
754
|
+
try {
|
|
755
|
+
if (nextMarkdown !== null && nextMarkdown !== markdown) durableAtomicWriteFileSync(meeting.meetingPath, nextMarkdown, FILE_MODE)
|
|
756
|
+
durableAtomicWriteFileSync(meeting.sidecarPath, inverse.value.json, FILE_MODE)
|
|
757
|
+
} catch (error) {
|
|
758
|
+
appendCorrection(sessionId, { ...row, phase: 'failed', at: new Date().toISOString(), error: String(error) })
|
|
759
|
+
return 'write_failed'
|
|
760
|
+
}
|
|
761
|
+
return null
|
|
762
|
+
}
|
|
763
|
+
|
|
764
|
+
/**
|
|
765
|
+
* The ledger's half of a revert. `reverted` goes on the applied row's own id, whenever one still counts, so a retry
|
|
766
|
+
* after a crash finishes the job: that id is what appliedCorrections and the repair reader key on, and the undo's own
|
|
767
|
+
* intent closes `failed` because an intent may only be followed by applied or failed. Null when all of it landed.
|
|
768
|
+
*/
|
|
769
|
+
function recordRevert(sessionId: string, batchId: string, step: Step): string | null {
|
|
770
|
+
if (!appliedCorrections(sessionId).some(row => row.id === step.correctionId)) return null
|
|
771
|
+
const row: CorrectionRow = { id: step.correctionId, phase: 'reverted', at: new Date().toISOString(), from: step.name, to: step.label, chunks: step.changed, scope: 'meeting', source: `mac-naming-batch-undo:${batchId}` }
|
|
772
|
+
return appendCorrection(sessionId, row) ? null : 'ledger_unwritable'
|
|
773
|
+
}
|
|
774
|
+
|
|
775
|
+
function closeOwnIntents(sessionId: string, step: Step): string | null {
|
|
776
|
+
let reason: string | null = null
|
|
777
|
+
for (const open of pendingCorrections(sessionId)) {
|
|
778
|
+
if (open.id !== step.correctionId && !open.id.startsWith(`${step.correctionId}:undo:`)) continue
|
|
779
|
+
if (!appendCorrection(sessionId, { ...open, phase: 'failed', at: new Date().toISOString(), error: 'closed by batch undo' })) reason = 'ledger_unwritable'
|
|
780
|
+
}
|
|
781
|
+
return reason
|
|
782
|
+
}
|
|
783
|
+
|
|
784
|
+
function undoStep(journal: Journal, step: Step, meeting: { sidecarPath: string; meetingPath: string }): StepUndo {
|
|
785
|
+
const { sessionId, batchId } = journal
|
|
786
|
+
const own = new Set(journal.steps.map(s => s.correctionId))
|
|
787
|
+
const prior = step.undo
|
|
788
|
+
const totals = (state: StepUndo['state'], extra: { removed?: number; restored?: number; dropped?: number; notFound?: number; reason?: string | null; nothingApplied?: boolean } = {}): StepUndo => ({
|
|
789
|
+
state,
|
|
790
|
+
...(extra.reason ? { reason: extra.reason } : {}),
|
|
791
|
+
samplesRemoved: (prior?.samplesRemoved ?? 0) + (extra.removed ?? 0),
|
|
792
|
+
samplesNotFound: (prior?.samplesNotFound ?? 0) + (extra.notFound ?? 0),
|
|
793
|
+
evictedRestored: (prior?.evictedRestored ?? 0) + (extra.restored ?? 0),
|
|
794
|
+
evictedDropped: (prior?.evictedDropped ?? 0) + (extra.dropped ?? 0),
|
|
795
|
+
...(extra.nothingApplied ? { nothingApplied: true } : {}),
|
|
796
|
+
})
|
|
797
|
+
const sidecar = readText(meeting.sidecarPath)
|
|
798
|
+
let doc: Record<string, unknown>
|
|
799
|
+
try { doc = JSON.parse(sidecar ?? '') as Record<string, unknown> } catch { return totals('left', { reason: 'sidecar_unreadable' }) }
|
|
800
|
+
const verdict = verdictFor(step, own, readCorrections(sessionId).rows, doc)
|
|
801
|
+
if (verdict === 'confirmed_by_person') {
|
|
802
|
+
// The name and its samples stand, so what they evicted stays out. Nothing is kept for a restore that cannot happen.
|
|
803
|
+
const dropped = sampleEffects(step).evicted.length
|
|
804
|
+
step.added = []
|
|
805
|
+
step.evicted = []
|
|
806
|
+
step.effectsKnown = true
|
|
807
|
+
delete step.macBefore
|
|
808
|
+
step.attendeesBefore = null
|
|
809
|
+
const closing = closeOwnIntents(sessionId, step)
|
|
810
|
+
return totals('left', { reason: closing ?? verdict, dropped })
|
|
811
|
+
}
|
|
812
|
+
let reason: string | null = verdict === 'label_changed_by_person' ? verdict : null
|
|
813
|
+
if (verdict === 'applied') reason = revertLabel(sessionId, batchId, step, meeting, sidecar ?? '')
|
|
814
|
+
if (reason === null) reason = recordRevert(sessionId, batchId, step)
|
|
815
|
+
reason = closeOwnIntents(sessionId, step) ?? reason
|
|
816
|
+
let removed = 0
|
|
817
|
+
let notFound = 0
|
|
818
|
+
let restored = 0
|
|
819
|
+
let dropped = 0
|
|
820
|
+
try {
|
|
821
|
+
const effects = sampleEffects(step)
|
|
822
|
+
step.effectsKnown = true
|
|
823
|
+
step.evicted = effects.evicted
|
|
824
|
+
step.added = effects.added
|
|
825
|
+
// Durable before the store changes: a retry after a crash here must not report its own retraction as missing.
|
|
826
|
+
const retrying = step.retractStarted === true
|
|
827
|
+
step.retractStarted = true
|
|
828
|
+
writeJournal(journal)
|
|
829
|
+
const out = retractSamples(step.name, step.added)
|
|
830
|
+
removed = out.removed
|
|
831
|
+
notFound = retrying ? 0 : out.notFound
|
|
832
|
+
step.added = []
|
|
833
|
+
delete step.macBefore
|
|
834
|
+
const back = restoreEvicted(step, step.evicted, own)
|
|
835
|
+
step.evicted = back.keep
|
|
836
|
+
restored = back.restored
|
|
837
|
+
dropped = back.dropped
|
|
838
|
+
if (back.enrolFailed) reason = 'profile_store_unavailable'
|
|
839
|
+
} catch (error) {
|
|
840
|
+
log(`samples not reverted for ${step.label} in ${batchId}: ${String(error)}`)
|
|
841
|
+
reason = 'profile_store_unavailable'
|
|
842
|
+
}
|
|
843
|
+
if (!reason) step.attendeesBefore = null
|
|
844
|
+
return totals(reason ? 'left' : 'reverted', { removed, notFound, restored, dropped, reason, nothingApplied: verdict === 'not_applied' && step.state !== 'named' && removed === 0 })
|
|
845
|
+
}
|
|
846
|
+
|
|
847
|
+
/** A sample with no room when its batch was undone may have room now that a later batch is undone too. */
|
|
848
|
+
function retryKeptRestores(): void {
|
|
849
|
+
for (const { session, batchId } of journalFiles()) {
|
|
850
|
+
const journal = loadJournal(session, batchId)
|
|
851
|
+
if (typeof journal === 'string') continue
|
|
852
|
+
const own = new Set(journal.steps.map(s => s.correctionId))
|
|
853
|
+
let changed = false
|
|
854
|
+
for (const step of journal.steps) {
|
|
855
|
+
if (!step.undo || step.evicted.length === 0) continue
|
|
856
|
+
const back = restoreEvicted(step, step.evicted, own)
|
|
857
|
+
step.evicted = back.keep
|
|
858
|
+
step.undo.evictedRestored += back.restored
|
|
859
|
+
step.undo.evictedDropped += back.dropped
|
|
860
|
+
changed = changed || back.restored + back.dropped > 0
|
|
861
|
+
}
|
|
862
|
+
if (changed) writeJournal(journal)
|
|
863
|
+
}
|
|
864
|
+
}
|
|
865
|
+
|
|
866
|
+
function undoResult(journal: Journal, transient: Map<string, string>, revision: string, dryRun: boolean, wouldRemove = 0, wouldRestore = 0, verdicts?: Map<string, Verdict>, wouldMissNames?: Map<string, number>): NamingBatchUndoResult {
|
|
867
|
+
const reverted = new Map<number, { labels: string[]; name: string }>()
|
|
868
|
+
const left = new Map<string, { labels: string[]; name: string; reason: string }>()
|
|
869
|
+
let samplesRemoved = wouldRemove
|
|
870
|
+
let evictedRestored = wouldRestore
|
|
871
|
+
let evictedNotRestored = 0
|
|
872
|
+
const leave = (step: Step, reason: string): void => {
|
|
873
|
+
const key = `${step.itemIndex}|${reason}`
|
|
874
|
+
const entry = left.get(key) ?? { labels: [], name: step.name, reason }
|
|
875
|
+
entry.labels.push(step.label)
|
|
876
|
+
left.set(key, entry)
|
|
877
|
+
}
|
|
878
|
+
for (const step of journal.steps) {
|
|
879
|
+
if (step.state === 'refused') continue
|
|
880
|
+
samplesRemoved += step.undo?.samplesRemoved ?? 0
|
|
881
|
+
evictedRestored += step.undo?.evictedRestored ?? 0
|
|
882
|
+
if (step.undo) evictedNotRestored += step.undo.evictedDropped + step.evicted.length + (step.evictedForgotten ?? 0)
|
|
883
|
+
const verdict = verdicts?.get(step.correctionId)
|
|
884
|
+
const reason = transient.get(step.correctionId)
|
|
885
|
+
?? (verdict === 'confirmed_by_person' || verdict === 'label_changed_by_person' ? verdict : undefined)
|
|
886
|
+
?? (step.undo?.state === 'left' ? step.undo.reason ?? 'not_reverted' : undefined)
|
|
887
|
+
if (reason) leave(step, reason)
|
|
888
|
+
else if (!step.undo?.nothingApplied && !(verdict === 'not_applied' && step.state !== 'named')) {
|
|
889
|
+
const entry = reverted.get(step.itemIndex) ?? { labels: [], name: step.name }
|
|
890
|
+
entry.labels.push(step.label)
|
|
891
|
+
reverted.set(step.itemIndex, entry)
|
|
892
|
+
}
|
|
893
|
+
// The label may be back and a sample still unaccounted for: a voice can be in both lists, and the answer is partial.
|
|
894
|
+
if ((step.undo?.samplesNotFound ?? 0) + (wouldMissNames?.get(step.correctionId) ?? 0) > 0) leave(step, 'samples_not_found')
|
|
895
|
+
}
|
|
896
|
+
return {
|
|
897
|
+
state: left.size > 0 ? 'partial' : 'undone',
|
|
898
|
+
reverted: [...reverted.values()],
|
|
899
|
+
left: [...left.values()],
|
|
900
|
+
samplesRemoved,
|
|
901
|
+
evictedRestored,
|
|
902
|
+
evictedNotRestored,
|
|
903
|
+
sourceRevision: revision,
|
|
904
|
+
...(dryRun ? { dryRun: true as const } : {}),
|
|
905
|
+
}
|
|
906
|
+
}
|
|
907
|
+
|
|
908
|
+
async function undo(sessionId: string, batchId: string, confirm: boolean): Promise<NamingBatchUndoResult> {
|
|
909
|
+
const refusal = (reason: string, sourceRevision: string): NamingBatchUndoResult => ({
|
|
910
|
+
state: 'refused', reason, reverted: [], left: [], samplesRemoved: 0, evictedRestored: 0, evictedNotRestored: 0, sourceRevision, ...(confirm ? {} : { dryRun: true as const }),
|
|
911
|
+
})
|
|
912
|
+
const meeting = resolveMeeting(sessionId)
|
|
913
|
+
if ('refused' in meeting) return refusal(meeting.refused, '')
|
|
914
|
+
const revision = (): string => deps.reviewRevision(meeting.meetingPath, meeting.sidecarPath)
|
|
915
|
+
const journal = loadJournal(sessionId, batchId)
|
|
916
|
+
if (journal === 'missing') return refusal('batch_not_found', revision())
|
|
917
|
+
if (journal === 'unreadable') return refusal('journal_unreadable', revision())
|
|
918
|
+
if (active.has(sessionId)) return refusal('batch_running', revision())
|
|
919
|
+
// Already done: answer the same thing again and touch nothing, whatever else is going on.
|
|
920
|
+
if (journal.state === 'undone') return undoResult(journal, new Map(), revision(), !confirm)
|
|
921
|
+
const blocked = blockReason(sessionId)
|
|
922
|
+
if (blocked) return refusal(blocked, revision())
|
|
923
|
+
const own = journal.steps.map(step => step.correctionId)
|
|
924
|
+
if (pendingCorrections(sessionId).some(open => !own.some(id => open.id === id || open.id.startsWith(`${id}:undo:`)))) return refusal('correction_pending', revision())
|
|
925
|
+
const steps = journal.steps.filter(step => step.state !== 'refused' && step.undo?.state !== 'reverted').reverse()
|
|
926
|
+
|
|
927
|
+
if (!confirm) {
|
|
928
|
+
let doc: Record<string, unknown>
|
|
929
|
+
try { doc = JSON.parse(readText(meeting.sidecarPath) ?? '') as Record<string, unknown> } catch { return refusal('sidecar_unreadable', revision()) }
|
|
930
|
+
const rows = readCorrections(sessionId).rows
|
|
931
|
+
const verdicts = new Map<string, Verdict>()
|
|
932
|
+
const misses = new Map<string, number>()
|
|
933
|
+
let wouldRemove = 0
|
|
934
|
+
let wouldRestore = 0
|
|
935
|
+
for (const step of steps) {
|
|
936
|
+
const verdict = verdictFor(step, new Set(own), rows, doc)
|
|
937
|
+
verdicts.set(step.correctionId, verdict)
|
|
938
|
+
if (verdict === 'confirmed_by_person') continue
|
|
939
|
+
const effects = sampleEffects(step)
|
|
940
|
+
const located = locateSamples(step.name, effects.added)
|
|
941
|
+
wouldRemove += effects.added.length - located.notFound
|
|
942
|
+
misses.set(step.correctionId, located.notFound)
|
|
943
|
+
wouldRestore += subtractSamples(effects.evicted, (profileSamples(step.name) ?? []).map(sampleKey)).length
|
|
944
|
+
}
|
|
945
|
+
return undoResult(journal, new Map(), revision(), true, wouldRemove, wouldRestore, verdicts, misses)
|
|
946
|
+
}
|
|
947
|
+
|
|
948
|
+
active.set(sessionId, batchId)
|
|
949
|
+
const transient = new Map<string, string>()
|
|
950
|
+
try {
|
|
951
|
+
for (const step of steps) {
|
|
952
|
+
await yieldToLoop()
|
|
953
|
+
let work: { release: () => void }
|
|
954
|
+
try { work = acquireWork() } catch { transient.set(step.correctionId, 'maintenance'); continue }
|
|
955
|
+
let lock: { release: () => void } | undefined
|
|
956
|
+
try {
|
|
957
|
+
try { lock = await acquireLock() } catch { transient.set(step.correctionId, 'sync_running'); continue }
|
|
958
|
+
// A recording may have started while this waited for the lock. Nothing awaits between here and the journal.
|
|
959
|
+
const stopAtLock = blockReason(sessionId)
|
|
960
|
+
if (stopAtLock) { transient.set(step.correctionId, stopAtLock); continue }
|
|
961
|
+
step.undo = undoStep(journal, step, meeting)
|
|
962
|
+
writeJournal(journal)
|
|
963
|
+
} finally {
|
|
964
|
+
lock?.release()
|
|
965
|
+
work.release()
|
|
966
|
+
}
|
|
967
|
+
}
|
|
968
|
+
const clean = transient.size === 0 && journal.steps.every(step => step.state === 'refused' || (step.undo?.state === 'reverted' && !step.undo.samplesNotFound))
|
|
969
|
+
journal.state = clean ? 'undone' : 'undo_partial'
|
|
970
|
+
writeJournal(journal)
|
|
971
|
+
// Best effort and bounded by the same checks as a write. A failure here must not turn a finished Undo into an error.
|
|
972
|
+
if (!blockReason(sessionId)) {
|
|
973
|
+
let work: { release: () => void } | undefined
|
|
974
|
+
try {
|
|
975
|
+
work = acquireWork()
|
|
976
|
+
retryKeptRestores()
|
|
977
|
+
} catch (error) {
|
|
978
|
+
log(`kept samples not retried after ${batchId}: ${String(error)}`)
|
|
979
|
+
} finally {
|
|
980
|
+
work?.release()
|
|
981
|
+
}
|
|
982
|
+
}
|
|
983
|
+
} finally {
|
|
984
|
+
active.delete(sessionId)
|
|
985
|
+
}
|
|
986
|
+
const reread = loadJournal(sessionId, batchId)
|
|
987
|
+
return undoResult(typeof reread === 'string' ? journal : reread, transient, revision(), false)
|
|
988
|
+
}
|
|
989
|
+
|
|
990
|
+
function list(sessionId: string): { batches: NamingBatchListEntry[] } {
|
|
991
|
+
let files: string[] = []
|
|
992
|
+
try { files = readdirSync(sessionFolder(sessionId)).filter(file => BATCH_ID.test(file.replace(/\.json$/, ''))) } catch { return { batches: [] } }
|
|
993
|
+
const batches = files.flatMap((file): NamingBatchListEntry[] => {
|
|
994
|
+
const batchId = file.replace(/\.json$/, '')
|
|
995
|
+
const journal = loadJournal(sessionId, batchId)
|
|
996
|
+
if (journal === 'missing') return []
|
|
997
|
+
return [typeof journal === 'string'
|
|
998
|
+
? { batchId, at: '', state: 'unreadable', named: [], undoAvailable: false }
|
|
999
|
+
: { batchId, at: journal.at, state: listState(journal), named: namedEntries(journal), undoAvailable: journalUndoAvailable(journal) }]
|
|
1000
|
+
})
|
|
1001
|
+
return { batches: batches.sort((a, b) => b.at.localeCompare(a.at)) }
|
|
1002
|
+
}
|
|
1003
|
+
|
|
1004
|
+
return { run, undo, list }
|
|
1005
|
+
}
|
|
1006
|
+
|
|
1007
|
+
// ── Routes ───────────────────────────────────────────────────────────────────
|
|
1008
|
+
|
|
1009
|
+
/** For index.ts's `lifecycleOwned`: these own their lease per write and refuse on their own during a drain. */
|
|
1010
|
+
export function isMacVoiceNamingBatchPost(path: string): boolean {
|
|
1011
|
+
return /^\/meeting\/[A-Za-z0-9:_-]{3,96}\/voice-naming-batch(?:\/[0-9a-f-]{36}\/undo)?$/.test(path)
|
|
1012
|
+
}
|
|
1013
|
+
|
|
1014
|
+
function parseItems(value: unknown): NamingBatchItem[] | null {
|
|
1015
|
+
if (!Array.isArray(value) || value.length === 0 || value.length > MAX_ITEMS) return null
|
|
1016
|
+
const items: NamingBatchItem[] = []
|
|
1017
|
+
for (const raw of value as Array<{ labels?: unknown; name?: unknown } | null>) {
|
|
1018
|
+
const labels = raw?.labels
|
|
1019
|
+
const name = raw?.name
|
|
1020
|
+
if (typeof name !== 'string' || name.length === 0 || name.length > 120) return null
|
|
1021
|
+
if (!Array.isArray(labels) || labels.length === 0 || labels.length > MAX_ITEMS || labels.some(label => typeof label !== 'string')) return null
|
|
1022
|
+
items.push({ labels: labels as string[], name: nfc(name) })
|
|
1023
|
+
}
|
|
1024
|
+
return items
|
|
1025
|
+
}
|
|
1026
|
+
|
|
1027
|
+
export function registerMacVoiceNamingBatchRoutes(router: Router, deps: NamingBatchDeps): void {
|
|
1028
|
+
const batches = createMacVoiceNamingBatches(deps)
|
|
1029
|
+
// Also at startup, so voiceprints do not outlive retention on a Mac that names nothing more.
|
|
1030
|
+
pruneExpiredJournals()
|
|
1031
|
+
const invalidSession = (sessionId: string): boolean => !SESSION_ID.test(sessionId)
|
|
1032
|
+
|
|
1033
|
+
router.get('/meeting/:sessionId/voice-naming-batch', (req, res) => {
|
|
1034
|
+
const sessionId = String(req.params.sessionId ?? '')
|
|
1035
|
+
if (invalidSession(sessionId)) { res.status(400).json({ error: 'Invalid sessionId', reason: 'invalid_session_id' }); return }
|
|
1036
|
+
res.set('Cache-Control', 'private, no-store')
|
|
1037
|
+
res.json(batches.list(sessionId))
|
|
1038
|
+
})
|
|
1039
|
+
|
|
1040
|
+
router.post('/meeting/:sessionId/voice-naming-batch', async (req, res) => {
|
|
1041
|
+
const sessionId = String(req.params.sessionId ?? '')
|
|
1042
|
+
res.set('Cache-Control', 'private, no-store')
|
|
1043
|
+
if (invalidSession(sessionId)) { res.status(400).json({ error: 'Invalid sessionId', reason: 'invalid_session_id' }); return }
|
|
1044
|
+
const items = parseItems(req.body?.items)
|
|
1045
|
+
if (!items || typeof req.body?.sourceRevision !== 'string') { res.status(400).json({ error: 'sourceRevision and items are required', reason: 'invalid_items' }); return }
|
|
1046
|
+
try {
|
|
1047
|
+
res.json(await batches.run(sessionId, { sourceRevision: req.body.sourceRevision, items, confirm: req.body?.confirm === true }))
|
|
1048
|
+
} catch (error) {
|
|
1049
|
+
console.error('[mac-naming-batch] refused:', error)
|
|
1050
|
+
res.status(500).json({ error: 'The batch could not run', reason: 'internal_error' })
|
|
1051
|
+
}
|
|
1052
|
+
})
|
|
1053
|
+
|
|
1054
|
+
router.post('/meeting/:sessionId/voice-naming-batch/:batchId/undo', async (req, res) => {
|
|
1055
|
+
const sessionId = String(req.params.sessionId ?? '')
|
|
1056
|
+
const batchId = String(req.params.batchId ?? '')
|
|
1057
|
+
res.set('Cache-Control', 'private, no-store')
|
|
1058
|
+
if (invalidSession(sessionId)) { res.status(400).json({ error: 'Invalid sessionId', reason: 'invalid_session_id' }); return }
|
|
1059
|
+
if (!BATCH_ID.test(batchId)) { res.status(400).json({ error: 'Invalid batchId', reason: 'invalid_batch_id' }); return }
|
|
1060
|
+
try {
|
|
1061
|
+
res.json(await batches.undo(sessionId, batchId, req.body?.confirm === true))
|
|
1062
|
+
} catch (error) {
|
|
1063
|
+
console.error('[mac-naming-batch] undo failed:', error)
|
|
1064
|
+
res.status(500).json({ error: 'The Undo could not finish. Ask again; it is safe to repeat', reason: 'internal_error' })
|
|
1065
|
+
}
|
|
1066
|
+
})
|
|
1067
|
+
}
|