@gotcos/glasses-server 6.94.1 → 6.95.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 +24 -0
- package/README.md +75 -0
- package/package.json +2 -1
- package/server/index.ts +13 -1
- package/server/lib/mac-speaker-machine-evidence.ts +111 -0
- package/server/lib/mac-speaker-profile-extractor.ts +41 -0
- package/server/lib/mac-speaker-profile-finalizer.ts +257 -0
- package/server/lib/mac-speaker-protection.ts +22 -0
- package/server/lib/mac-speaker-reconcile.ts +300 -0
- package/server/lib/mac-speaker-repair-projection.ts +301 -0
- package/server/lib/mac-speaker-repair-render.ts +105 -0
- package/server/lib/mac-speaker-windows.ts +239 -0
- package/server/lib/meeting-scribe-content.ts +8 -1
- package/server/lib/meeting-speaker-review.ts +39 -1
- package/server/lib/nemotron-final.ts +64 -14
- package/server/lib/speaker-shadow-decide.ts +466 -0
- package/server/lib/speaker-shadow-models.ts +150 -0
- package/server/lib/speaker-shadow-numeric.ts +143 -0
- package/server/lib/speaker-shadow-report.ts +113 -0
- package/server/lib/speaker-shadow-timeline.ts +210 -0
- package/server/lib/speaker-shadow-worker.ts +277 -0
- package/server/lib/speaker-shadow.ts +727 -0
- package/server/lib/voice-directory.ts +5 -1
- package/server/routes/health.ts +5 -0
- package/server/routes/meeting.ts +36 -5
- package/server/routes/speaker-shadow.ts +79 -0
- package/server/scripts/mac-speaker-repair.ts +324 -0
|
@@ -13,6 +13,7 @@
|
|
|
13
13
|
import { lstat, readFile, readdir, realpath } from 'node:fs/promises'
|
|
14
14
|
import { basename, dirname, join } from 'node:path'
|
|
15
15
|
import { setImmediate as yieldToEventLoop } from 'node:timers/promises'
|
|
16
|
+
import { readMacSpeakerMachineEvidence } from './mac-speaker-machine-evidence.js'
|
|
16
17
|
import { dataPath } from './data-dir.js'
|
|
17
18
|
import { confirmedLabels, confirmedChunks } from './meeting-corrections.js'
|
|
18
19
|
import { resolveCosOperationsDir, resolveMeetingLibrary } from './cos-operations-meetings.js'
|
|
@@ -294,11 +295,14 @@ export async function buildVoiceDirectorySnapshot(): Promise<VoiceDirectorySnaps
|
|
|
294
295
|
storedTitle,
|
|
295
296
|
)
|
|
296
297
|
|
|
298
|
+
const machineEvidence = readMacSpeakerMachineEvidence(record)
|
|
297
299
|
const review = reviewMeetingSpeakers(chunks as ReviewChunk[], {
|
|
298
300
|
owner,
|
|
299
301
|
phrasesPerVoice: 1,
|
|
300
302
|
confirmed: confirmedLabels(sessionId),
|
|
301
|
-
|
|
303
|
+
confirmedChunks: confirmedChunks(sessionId),
|
|
304
|
+
machineQualifiedPositions: machineEvidence.positions,
|
|
305
|
+
machineEvidenceStatus: machineEvidence.status,
|
|
302
306
|
durationMs: typeof record.durationMs === 'number' ? record.durationMs : undefined,
|
|
303
307
|
batchSegments: Array.isArray(record.batchSegments)
|
|
304
308
|
? record.batchSegments as SpeakerWordSegment[]
|
package/server/routes/health.ts
CHANGED
|
@@ -16,6 +16,7 @@ import { localFirstMeetingsCapability } from '../lib/local-first-meetings-contra
|
|
|
16
16
|
import { isSileroAvailable } from '../lib/vad-silero.js'
|
|
17
17
|
import { profileProvenanceSummary, speakerModelState, speakerReadiness } from '../lib/speaker-embeddings.js'
|
|
18
18
|
import { nemotronLive } from '../lib/nemotron-live.js'
|
|
19
|
+
import { speakerShadowHealthFields } from '../lib/speaker-shadow.js'
|
|
19
20
|
import { finalPassSnapshot } from '../lib/nemotron-final.js'
|
|
20
21
|
import { chunkEmbeddingStoreStats } from '../lib/chunk-embedding-store.js'
|
|
21
22
|
import { mintDisplayTicket, DISPLAY_TICKET_TTL_SECONDS } from '../lib/display-ticket.js'
|
|
@@ -579,6 +580,10 @@ healthRouter.get('/health', async (_req, res) => {
|
|
|
579
580
|
// count only (health is public); the items and their Retry are behind the token at
|
|
580
581
|
// GET /api/maintenance/status.
|
|
581
582
|
crash_loop_parking: crashLoopParkingHealth(),
|
|
583
|
+
// 6.95.0: speaker merge shadow mode, only while it is on (absent when off). Codes and counts
|
|
584
|
+
// only (health is public): the proposals and the last error's text are behind the token at
|
|
585
|
+
// /api/speaker-shadow/*.
|
|
586
|
+
...speakerShadowHealthFields(),
|
|
582
587
|
// Key lanes (plan v4 3.1): `details.keyLanes` only while enabled. Absent, never null, when off.
|
|
583
588
|
...keyLanesHealthFields(),
|
|
584
589
|
})
|
package/server/routes/meeting.ts
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { runQualifiedMacProfilePass } from '../lib/mac-speaker-profile-finalizer.js'
|
|
2
|
+
import { readMacSpeakerMachineEvidence } from '../lib/mac-speaker-machine-evidence.js'
|
|
1
3
|
import { dataPath } from '../lib/data-dir.js'
|
|
2
4
|
import { PHONE_CAPTURE_ID, PHONE_SOURCE_TYPE, assertPhoneProvenance, readPhoneRecording, requirePhoneRecording, closePhoneRecording } from '../lib/phone-recording.js'
|
|
3
5
|
import { MAC_CAPTURE_ID, readMacRecording, requireMacRecording, macChunkReceipt, closeMacRecording } from '../lib/mac-recording.js'
|
|
@@ -201,6 +203,7 @@ import { enrichStandaloneMeeting } from '../lib/meeting-summary-persistence.js'
|
|
|
201
203
|
import { bootResumeGate, type BootResumeGate } from '../lib/boot-resume.js'
|
|
202
204
|
import { recordFinalPassSkip, runNemotronFinalPass, type FinalPassOptions, type FinalPassOutcome } from '../lib/nemotron-final.js'
|
|
203
205
|
import { runTranscriptCleanup, type CleanupOutcome } from '../lib/transcript-cleanup.js'
|
|
206
|
+
import { notifySpeakerShadowFinalized } from '../lib/speaker-shadow.js'
|
|
204
207
|
import { sweepTranscriptCleanupData } from '../lib/transcript-cleanup-screen.js'
|
|
205
208
|
import type { TranscriptCleanupRouteDeps } from './transcript-cleanup.js'
|
|
206
209
|
|
|
@@ -309,6 +312,7 @@ export interface MeetingRouteDependencies {
|
|
|
309
312
|
emit?: typeof emitDisplay
|
|
310
313
|
finalizationJobs?: MeetingFinalizationJobStore
|
|
311
314
|
finalDiarize?: FinalDiarize
|
|
315
|
+
qualifiedProfilePass?: typeof runQualifiedMacProfilePass
|
|
312
316
|
/** Control's stop requests for live G2 meetings (lib/meeting-stop-requests.ts). */
|
|
313
317
|
stopRequests?: StopRequestStore
|
|
314
318
|
/** 6.77.0: the post-save transcript cleanup (lib/transcript-cleanup.ts). */
|
|
@@ -334,6 +338,7 @@ interface FinalizationRuntime {
|
|
|
334
338
|
scheduleBackground: (task: Promise<void>) => void
|
|
335
339
|
sessions?: MeetingSessionSource
|
|
336
340
|
finalDiarize?: FinalDiarize
|
|
341
|
+
qualifiedProfilePass?: typeof runQualifiedMacProfilePass
|
|
337
342
|
transcriptCleanup?: TranscriptCleanupRunner
|
|
338
343
|
}
|
|
339
344
|
|
|
@@ -367,7 +372,7 @@ function validReplayEntries(raw: unknown[]): IndexedTranscriptChunk[] | null {
|
|
|
367
372
|
return entries.length > 0 ? entries : null
|
|
368
373
|
}
|
|
369
374
|
|
|
370
|
-
function scheduleFinalizationJob(job: MeetingFinalizationJob, runtime: FinalizationRuntime): void {
|
|
375
|
+
export function scheduleFinalizationJob(job: MeetingFinalizationJob, runtime: FinalizationRuntime): void {
|
|
371
376
|
const key = `${runtime.finalizationJobs.root}:${job.sessionId}`
|
|
372
377
|
if (activeFinalizationJobs.has(key)) return
|
|
373
378
|
activeFinalizationJobs.add(key)
|
|
@@ -452,8 +457,12 @@ function scheduleFinalizationJob(job: MeetingFinalizationJob, runtime: Finalizat
|
|
|
452
457
|
sessionId: current.sessionId,
|
|
453
458
|
runBatch: runtime.runBatch,
|
|
454
459
|
finalDiarize: runtime.finalDiarize ?? runNemotronFinalPass,
|
|
460
|
+
qualifiedProfilePass: runtime.qualifiedProfilePass,
|
|
455
461
|
})
|
|
456
462
|
} else {
|
|
463
|
+
// A torn profile transaction must block filing even if pending audio
|
|
464
|
+
// vanished. Missing audio never waives journal/proof recovery gates.
|
|
465
|
+
if (MAC_CAPTURE_ID.test(current.sessionId)) await (runtime.qualifiedProfilePass ?? runQualifiedMacProfilePass)({ meetingPath: current.meetingPath, sidecarPath: current.sidecarPath, audioDir: current.audioDir ?? '', resumeOnly: true })
|
|
457
466
|
console.warn(`[meeting/save] Pending HQ audio missing for ${current.sessionId}; preserving streaming canonical`)
|
|
458
467
|
}
|
|
459
468
|
current = runtime.finalizationJobs.save({
|
|
@@ -483,6 +492,9 @@ function scheduleFinalizationJob(job: MeetingFinalizationJob, runtime: Finalizat
|
|
|
483
492
|
await enrichStandaloneMeeting(current.meetingPath, jobStartedAt)
|
|
484
493
|
}
|
|
485
494
|
markCanonicalFinalizationState(current.sidecarPath, 'complete', false)
|
|
495
|
+
// 6.95.0: speaker merge shadow mode (off unless COS_SPEAKER_SHADOW=1). Log only and
|
|
496
|
+
// fire-and-forget: it queues, defers under live work, and never throws back here.
|
|
497
|
+
if (MAC_CAPTURE_ID.test(current.sessionId)) notifySpeakerShadowFinalized({ sessionId: current.sessionId, sidecarPath: current.sidecarPath })
|
|
486
498
|
// The capture is finished and its files are durable, so the merge engine may score it.
|
|
487
499
|
// Fire-and-forget by contract: the runner queues, defers under a drain or a live
|
|
488
500
|
// capture, and never throws back at the finalization path.
|
|
@@ -720,6 +732,7 @@ export function createMeetingRouter(deps: MeetingRouteDependencies = {}): Router
|
|
|
720
732
|
const emit = deps.emit ?? emitDisplay
|
|
721
733
|
const finalizationJobs = deps.finalizationJobs ?? new MeetingFinalizationJobStore()
|
|
722
734
|
const finalDiarize = deps.finalDiarize ?? runNemotronFinalPass
|
|
735
|
+
const qualifiedProfilePass = deps.qualifiedProfilePass ?? runQualifiedMacProfilePass
|
|
723
736
|
const transcriptCleanup = deps.transcriptCleanup
|
|
724
737
|
const router = Router()
|
|
725
738
|
const captureActive = deps.captureActive ?? (() => getTranscriptionSessionLiveness().live > 0)
|
|
@@ -1206,6 +1219,7 @@ export function createMeetingRouter(deps: MeetingRouteDependencies = {}): Router
|
|
|
1206
1219
|
scheduleBackground,
|
|
1207
1220
|
sessions,
|
|
1208
1221
|
finalDiarize,
|
|
1222
|
+
qualifiedProfilePass,
|
|
1209
1223
|
})
|
|
1210
1224
|
if (origin === 'phone') notePhoneAfterAutonomousSave(sessionId, 'save_replay')
|
|
1211
1225
|
respond(200, publicSaveResponse(alreadySaved, true), true)
|
|
@@ -1403,6 +1417,7 @@ export function createMeetingRouter(deps: MeetingRouteDependencies = {}): Router
|
|
|
1403
1417
|
scheduleBackground,
|
|
1404
1418
|
sessions,
|
|
1405
1419
|
finalDiarize,
|
|
1420
|
+
qualifiedProfilePass,
|
|
1406
1421
|
transcriptCleanup,
|
|
1407
1422
|
})
|
|
1408
1423
|
}
|
|
@@ -1659,9 +1674,10 @@ export function createMeetingRouter(deps: MeetingRouteDependencies = {}): Router
|
|
|
1659
1674
|
const filename = operations?.filename ?? direct?.filename ?? saved!.filename
|
|
1660
1675
|
|
|
1661
1676
|
let chunks: unknown
|
|
1677
|
+
let sidecar: Record<string, unknown>
|
|
1662
1678
|
try {
|
|
1663
|
-
|
|
1664
|
-
chunks = Array.isArray(
|
|
1679
|
+
sidecar = JSON.parse(readFileSync(sidecarPath, 'utf-8')) as Record<string, unknown>
|
|
1680
|
+
chunks = Array.isArray(sidecar) ? sidecar : sidecar.chunks
|
|
1665
1681
|
} catch {
|
|
1666
1682
|
// Defensive: both lookups above already read this sidecar to match the
|
|
1667
1683
|
// session, so a corrupt file 404s and never reaches here. This covers the
|
|
@@ -1678,8 +1694,8 @@ export function createMeetingRouter(deps: MeetingRouteDependencies = {}): Router
|
|
|
1678
1694
|
|
|
1679
1695
|
// Raw capture indices, so a phrase can address its own audio. Position in
|
|
1680
1696
|
// `chunks` is NOT the WAV number — see attachRawChunkIndices.
|
|
1681
|
-
const sidecar = (JSON.parse(readFileSync(sidecarPath, 'utf-8')) ?? {}) as Record<string, unknown>
|
|
1682
1697
|
const withIndices = attachRawChunkIndices(chunks as ReviewChunk[], sidecar.chunkEntries)
|
|
1698
|
+
const machineEvidence = readMacSpeakerMachineEvidence(sidecar)
|
|
1683
1699
|
const review = reviewMeetingSpeakers(withIndices, {
|
|
1684
1700
|
owner: getOwnerSpeakerLabel(),
|
|
1685
1701
|
// Labels a human already vouched for in this meeting. Without this the
|
|
@@ -1687,6 +1703,8 @@ export function createMeetingRouter(deps: MeetingRouteDependencies = {}): Router
|
|
|
1687
1703
|
// confirms the same voice forever.
|
|
1688
1704
|
confirmed: confirmedLabels(sessionId),
|
|
1689
1705
|
confirmedChunks: confirmedChunks(sessionId),
|
|
1706
|
+
machineQualifiedPositions: machineEvidence.positions,
|
|
1707
|
+
machineEvidenceStatus: machineEvidence.status,
|
|
1690
1708
|
phrasesPerVoice: Math.max(1, Math.min(6, Number(req.query.phrases) || 3)),
|
|
1691
1709
|
// The sidecar's own durationMs is the meeting's true end. Deriving it from
|
|
1692
1710
|
// max(elapsed) uses the START of the last chunk, which made the final
|
|
@@ -1791,10 +1809,13 @@ export function createMeetingRouter(deps: MeetingRouteDependencies = {}): Router
|
|
|
1791
1809
|
}
|
|
1792
1810
|
const chunks = rawChunks as ReviewChunk[]
|
|
1793
1811
|
|
|
1812
|
+
const machineEvidence = readMacSpeakerMachineEvidence(sidecar)
|
|
1794
1813
|
const review = reviewMeetingSpeakers(attachRawChunkIndices(chunks, sidecar.chunkEntries), {
|
|
1795
1814
|
owner: getOwnerSpeakerLabel(),
|
|
1796
1815
|
confirmed: confirmedLabels(sessionId),
|
|
1797
1816
|
confirmedChunks: confirmedChunks(sessionId),
|
|
1817
|
+
machineQualifiedPositions: machineEvidence.positions,
|
|
1818
|
+
machineEvidenceStatus: machineEvidence.status,
|
|
1798
1819
|
// This route renders no phrases; the default of 3 per voice computed and
|
|
1799
1820
|
// discarded 48 transcript excerpts on a 16-voice meeting.
|
|
1800
1821
|
phrasesPerVoice: 1,
|
|
@@ -1818,6 +1839,8 @@ export function createMeetingRouter(deps: MeetingRouteDependencies = {}): Router
|
|
|
1818
1839
|
// "voice matching" for the wearer exemption and for names a human typed.
|
|
1819
1840
|
isOwner: v.isOwner,
|
|
1820
1841
|
confirmedByHuman: v.confirmedByHuman,
|
|
1842
|
+
machineQualified: v.machineQualified,
|
|
1843
|
+
machineQualifiedSegments: v.machineQualifiedSegments,
|
|
1821
1844
|
}))
|
|
1822
1845
|
|
|
1823
1846
|
// A missing .md is not fatal — the review and the sidecar still describe the
|
|
@@ -3152,7 +3175,7 @@ function synthesizeEntriesFromChunkWavs(audioDir: string): RecoveredCapture {
|
|
|
3152
3175
|
return { entries, startTime, durationMs }
|
|
3153
3176
|
}
|
|
3154
3177
|
|
|
3155
|
-
async function finalizeBatch(options: {
|
|
3178
|
+
export async function finalizeBatch(options: {
|
|
3156
3179
|
audioDir: string
|
|
3157
3180
|
entries: IndexedTranscriptChunk[]
|
|
3158
3181
|
streamingWordCount: number
|
|
@@ -3161,7 +3184,11 @@ async function finalizeBatch(options: {
|
|
|
3161
3184
|
sessionId?: string
|
|
3162
3185
|
runBatch: NonNullable<MeetingRouteDependencies['runBatch']>
|
|
3163
3186
|
finalDiarize?: FinalDiarize
|
|
3187
|
+
qualifiedProfilePass?: typeof runQualifiedMacProfilePass
|
|
3164
3188
|
}): Promise<void> {
|
|
3189
|
+
const qualifiedProfilePass = options.qualifiedProfilePass ?? runQualifiedMacProfilePass
|
|
3190
|
+
const mac = MAC_CAPTURE_ID.test(options.sessionId ?? '')
|
|
3191
|
+
if (mac && (await qualifiedProfilePass({ ...options, resumeOnly: true })).status === 'applied') return
|
|
3165
3192
|
const result = await options.runBatch(
|
|
3166
3193
|
options.audioDir,
|
|
3167
3194
|
options.entries,
|
|
@@ -3226,6 +3253,10 @@ async function finalizeBatch(options: {
|
|
|
3226
3253
|
}
|
|
3227
3254
|
}
|
|
3228
3255
|
|
|
3256
|
+
// Exact qualified profile evidence runs before raw deletion and filing.
|
|
3257
|
+
// Prepared-transaction failures propagate: mixed generations cannot be filed.
|
|
3258
|
+
if (mac && metadataPersisted) await qualifiedProfilePass(options)
|
|
3259
|
+
|
|
3229
3260
|
if (canDeletePendingBatchAudio(transcriptApplied, metadataPersisted)) {
|
|
3230
3261
|
rmSync(options.audioDir, { recursive: true, force: true })
|
|
3231
3262
|
} else {
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
// Speaker merge shadow mode (6.95.0). Token-protected by the /api middleware. READ ONLY.
|
|
2
|
+
//
|
|
3
|
+
// GET /api/speaker-shadow/proposals the logged proposal records, newest first
|
|
4
|
+
// ?limit=1..100 (default 10) ?sessionId=mac_<uuid>
|
|
5
|
+
// GET /api/speaker-shadow/status the switch, queue, daily cap, breaker, last error and
|
|
6
|
+
// setup readiness (models, CLI, sandbox)
|
|
7
|
+
// GET /api/speaker-shadow/settings { enabled, source } source: env | setting | default
|
|
8
|
+
// POST /api/speaker-shadow/settings {"enabled": true|false}: this Mac's saved switch (no
|
|
9
|
+
// restart); 409 env_forced when COS_SPEAKER_SHADOW pins it
|
|
10
|
+
//
|
|
11
|
+
// The settings POST is the only write, and it writes only <data>/speaker-shadow/settings.json.
|
|
12
|
+
// Nothing on these routes changes a meeting, a label, a name or a profile. A 404 means a
|
|
13
|
+
// server older than 6.95.0.
|
|
14
|
+
|
|
15
|
+
import { Router } from 'express'
|
|
16
|
+
import { getSpeakerShadowRunner, readSpeakerShadowRecords, resolveSpeakerShadowSwitch, speakerShadowEnvSwitch, writeSpeakerShadowSetting } from '../lib/speaker-shadow.js'
|
|
17
|
+
import { speakerShadowRoot } from '../lib/speaker-shadow-models.js'
|
|
18
|
+
|
|
19
|
+
export interface SpeakerShadowRouterDeps {
|
|
20
|
+
root?: () => string
|
|
21
|
+
env?: NodeJS.ProcessEnv
|
|
22
|
+
snapshot?: () => ReturnType<ReturnType<typeof getSpeakerShadowRunner>['snapshot']>
|
|
23
|
+
/** Called after the setting turns shadow mode on (the runner picks up anything still queued). */
|
|
24
|
+
onEnabled?: () => void
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const SESSION_ID = /^mac_[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/
|
|
28
|
+
|
|
29
|
+
export function createSpeakerShadowRouter(deps: SpeakerShadowRouterDeps = {}): Router {
|
|
30
|
+
const router = Router()
|
|
31
|
+
const root = deps.root ?? speakerShadowRoot
|
|
32
|
+
const snapshot = deps.snapshot ?? (() => getSpeakerShadowRunner().snapshot({ detail: true }))
|
|
33
|
+
const env = deps.env ?? process.env
|
|
34
|
+
const onEnabled = deps.onEnabled ?? (() => getSpeakerShadowRunner().resume())
|
|
35
|
+
|
|
36
|
+
router.get('/speaker-shadow/proposals', async (req, res) => {
|
|
37
|
+
const rawLimit = Number(req.query.limit ?? 10)
|
|
38
|
+
if (!Number.isInteger(rawLimit) || rawLimit < 1 || rawLimit > 100) { res.status(400).json({ error: 'limit must be an integer from 1 to 100' }); return }
|
|
39
|
+
const sessionId = typeof req.query.sessionId === 'string' ? req.query.sessionId : undefined
|
|
40
|
+
if (sessionId !== undefined && !SESSION_ID.test(sessionId)) { res.status(400).json({ error: 'sessionId must be a mac_<uuid> session id' }); return }
|
|
41
|
+
try {
|
|
42
|
+
const records = await readSpeakerShadowRecords(root(), { limit: rawLimit, sessionId })
|
|
43
|
+
res.json({ logOnly: true, shadow: snapshot(), count: records.length, records })
|
|
44
|
+
} catch (error) {
|
|
45
|
+
res.status(500).json({ error: `could not read the shadow log: ${error instanceof Error ? error.message : String(error)}` })
|
|
46
|
+
}
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
router.get('/speaker-shadow/status', (_req, res) => {
|
|
50
|
+
res.json({ logOnly: true, ...snapshot() })
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
router.get('/speaker-shadow/settings', (_req, res) => {
|
|
54
|
+
res.set('Cache-Control', 'private, no-store')
|
|
55
|
+
res.json(resolveSpeakerShadowSwitch(env, root()))
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
router.post('/speaker-shadow/settings', (req, res) => {
|
|
59
|
+
res.set('Cache-Control', 'private, no-store')
|
|
60
|
+
const body = req.body as { enabled?: unknown } | undefined
|
|
61
|
+
if (!body || typeof body !== 'object' || Array.isArray(body) || typeof body.enabled !== 'boolean' || Object.keys(body).some(k => k !== 'enabled')) {
|
|
62
|
+
res.status(400).json({ error: 'invalid_request', expected: '{"enabled": true|false}' })
|
|
63
|
+
return
|
|
64
|
+
}
|
|
65
|
+
const pinned = speakerShadowEnvSwitch(env)
|
|
66
|
+
if (pinned !== null && pinned !== body.enabled) { res.status(409).json({ error: 'env_forced', ...resolveSpeakerShadowSwitch(env, root()) }); return }
|
|
67
|
+
try {
|
|
68
|
+
writeSpeakerShadowSetting(body.enabled, root())
|
|
69
|
+
} catch {
|
|
70
|
+
res.status(500).json({ error: 'settings_write_failed' })
|
|
71
|
+
return
|
|
72
|
+
}
|
|
73
|
+
const view = resolveSpeakerShadowSwitch(env, root())
|
|
74
|
+
if (view.enabled) { try { onEnabled() } catch { /* the next finished meeting starts it */ } }
|
|
75
|
+
res.json(view)
|
|
76
|
+
})
|
|
77
|
+
|
|
78
|
+
return router
|
|
79
|
+
}
|
|
@@ -0,0 +1,324 @@
|
|
|
1
|
+
#!/usr/bin/env tsx
|
|
2
|
+
import { boundWords, renderMarkdown, renderBoundSpeakerLabels } from '../lib/mac-speaker-repair-render.js'
|
|
3
|
+
export { renderBoundSpeakerLabels, renderSparseBoundSpeakerLabels, renderTruncatedBoundSpeakerLabels } from '../lib/mac-speaker-repair-render.js'
|
|
4
|
+
/** Offline-only, labels-only repair transaction. No server/data-home discovery.
|
|
5
|
+
* Default preview writes nothing. --prepare saves private durable before/after
|
|
6
|
+
* images; --apply/--undo can modify only explicit marker-certified offline copies.
|
|
7
|
+
* Live installation requires the deploying session's own sync/capture coordination.
|
|
8
|
+
*/
|
|
9
|
+
import { createHash } from 'node:crypto'
|
|
10
|
+
import { closeSync, constants, existsSync, fsyncSync, lstatSync, mkdirSync, openSync, readFileSync, realpathSync, unlinkSync, writeFileSync } from 'node:fs'
|
|
11
|
+
import { dirname, isAbsolute, join, relative, resolve } from 'node:path'
|
|
12
|
+
import { pathToFileURL } from 'node:url'
|
|
13
|
+
import { durableAtomicWriteFileSync } from '../lib/atomic-fs.js'
|
|
14
|
+
import { hashMacSpeakerMachineBinding, projectMacSpeakerRepair, type MacSpeakerMachineEvidence, type MacSpeakerRepairPlan, type MacSpeakerRepairReceipt } from '../lib/mac-speaker-repair-projection.js'
|
|
15
|
+
|
|
16
|
+
type Doc = Record<string, any>
|
|
17
|
+
export interface RepairFileSnapshot { path: string; sha256: string | null }
|
|
18
|
+
export interface MacRepairManifest {
|
|
19
|
+
version: 1
|
|
20
|
+
id: string
|
|
21
|
+
at: string
|
|
22
|
+
evidenceHash: string
|
|
23
|
+
offlineRoot: string
|
|
24
|
+
/** Read-only immutable evidence, including frozen profile/model versions. */
|
|
25
|
+
preconditions: RepairFileSnapshot[]
|
|
26
|
+
audioFiles: Array<{ rawChunkIndex: number; path: string; sha256: string }>
|
|
27
|
+
copies: Array<{
|
|
28
|
+
copy: 'local' | 'operations'
|
|
29
|
+
sidecar: RepairFileSnapshot
|
|
30
|
+
markdown: RepairFileSnapshot
|
|
31
|
+
ledger: RepairFileSnapshot
|
|
32
|
+
plan: MacSpeakerRepairPlan
|
|
33
|
+
}>
|
|
34
|
+
}
|
|
35
|
+
interface PreparedFile {
|
|
36
|
+
path: string
|
|
37
|
+
beforeHash: string
|
|
38
|
+
afterHash: string
|
|
39
|
+
beforeImage: string
|
|
40
|
+
afterImage: string
|
|
41
|
+
role: 'sidecar' | 'markdown'
|
|
42
|
+
copy: 'local' | 'operations'
|
|
43
|
+
}
|
|
44
|
+
interface TransactionPayload {
|
|
45
|
+
version: 1
|
|
46
|
+
id: string
|
|
47
|
+
at: string
|
|
48
|
+
evidenceHash: string
|
|
49
|
+
offlineRoot: string
|
|
50
|
+
preconditions: RepairFileSnapshot[]
|
|
51
|
+
files: PreparedFile[]
|
|
52
|
+
receipts: MacSpeakerRepairReceipt[]
|
|
53
|
+
}
|
|
54
|
+
export interface MacRepairTransaction extends TransactionPayload {
|
|
55
|
+
payloadHash: string
|
|
56
|
+
state: 'prepared' | 'applying' | 'applied' | 'undoing' | 'reverted' | 'partial'
|
|
57
|
+
completed: string[]
|
|
58
|
+
error?: string
|
|
59
|
+
}
|
|
60
|
+
export interface MacRepairPreview {
|
|
61
|
+
transaction: MacRepairTransaction
|
|
62
|
+
images: Array<{ name: string; bytes: string }>
|
|
63
|
+
summary: { copies: number; chunks: number; labelPatches: number; offlineOnly: true }
|
|
64
|
+
}
|
|
65
|
+
const sha = (data: string | Buffer) => createHash('sha256').update(data).digest('hex')
|
|
66
|
+
const digest = (value: unknown) => sha(JSON.stringify(value))
|
|
67
|
+
const HASH = /^[a-f0-9]{64}$/
|
|
68
|
+
const MARKER = '.mac-speaker-repair-offline.json'
|
|
69
|
+
function refuse(message: string): never { throw new Error(`Mac offline repair: ${message}`) }
|
|
70
|
+
function absolute(path: string) { if (typeof path !== 'string' || !isAbsolute(path)) refuse('all paths must be explicit and absolute'); return resolve(path) }
|
|
71
|
+
function safeRead(path: string): Buffer {
|
|
72
|
+
absolute(path)
|
|
73
|
+
const stat = lstatSync(path)
|
|
74
|
+
if (!stat.isFile() || stat.isSymbolicLink() || stat.nlink !== 1 || realpathSync(path) !== resolve(path)) refuse('symlink, hardlink or nonregular source refused')
|
|
75
|
+
return readFileSync(path)
|
|
76
|
+
}
|
|
77
|
+
function snapshot(path: string): string | null { return existsSync(path) ? sha(safeRead(path)) : null }
|
|
78
|
+
function checkSnapshot(file: RepairFileSnapshot) {
|
|
79
|
+
absolute(file.path)
|
|
80
|
+
if (file.sha256 !== null && !HASH.test(file.sha256)) refuse('invalid source hash')
|
|
81
|
+
if (snapshot(file.path) !== file.sha256) refuse('source/profile/ledger drift')
|
|
82
|
+
}
|
|
83
|
+
function inside(root: string, path: string) {
|
|
84
|
+
const rel = relative(root, absolute(path))
|
|
85
|
+
if (!rel || rel.startsWith('..') || isAbsolute(rel)) refuse('write target must be strictly inside offline root')
|
|
86
|
+
}
|
|
87
|
+
function offlineRoot(path: string): string {
|
|
88
|
+
const root = absolute(path)
|
|
89
|
+
if (realpathSync(root) !== root || lstatSync(root).isSymbolicLink()) refuse('offline root must not contain symlinks')
|
|
90
|
+
// An explicit marker is an offline-copy assertion, never a server maintenance
|
|
91
|
+
// authorization. Also reject recognizable production data locations.
|
|
92
|
+
if (root.split('/').includes('.cos-glasses') || /\/MU-Chief-Staff\/operations(?:\/|$)/.test(root)) refuse('production data path is not an offline root')
|
|
93
|
+
const marker = JSON.parse(safeRead(join(root, MARKER)).toString('utf8'))
|
|
94
|
+
if (marker.kind !== 'cos-mac-speaker-repair-offline' || marker.version !== 1) refuse('offline-copy marker required')
|
|
95
|
+
return root
|
|
96
|
+
}
|
|
97
|
+
/** Only WAV metadata + hash verification; never playback/ASR/native extraction. */
|
|
98
|
+
function pcmSamples(bytes: Buffer): { count: number; pcm: Buffer } {
|
|
99
|
+
if (bytes.length < 44 || bytes.toString('ascii', 0, 4) !== 'RIFF' || bytes.toString('ascii', 8, 12) !== 'WAVE' || bytes.readUInt32LE(4) + 8 !== bytes.length) refuse('invalid WAV container')
|
|
100
|
+
let format = false, samples: number | null = null, pcm: Buffer | null = null
|
|
101
|
+
for (let offset = 12; offset + 8 <= bytes.length;) {
|
|
102
|
+
const tag = bytes.toString('ascii', offset, offset + 4), size = bytes.readUInt32LE(offset + 4), start = offset + 8
|
|
103
|
+
if (start + size > bytes.length) refuse('truncated WAV')
|
|
104
|
+
if (tag === 'fmt ') {
|
|
105
|
+
if (size < 16 || bytes.readUInt16LE(start) !== 1 || bytes.readUInt16LE(start + 2) !== 1 || bytes.readUInt32LE(start + 4) !== 16000 || bytes.readUInt16LE(start + 12) !== 2 || bytes.readUInt16LE(start + 14) !== 16) refuse('expected mono 16kHz PCM16 WAV')
|
|
106
|
+
format = true
|
|
107
|
+
}
|
|
108
|
+
if (tag === 'data') { if (samples !== null || size % 2) refuse('ambiguous PCM data'); samples = size / 2; pcm = bytes.subarray(start, start + size) }
|
|
109
|
+
offset = start + size + (size % 2)
|
|
110
|
+
}
|
|
111
|
+
if (!format || !samples) refuse('missing WAV PCM')
|
|
112
|
+
return { count: samples, pcm: pcm! }
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function protectedRaw(doc: Doc, ledger: RepairFileSnapshot): number[] {
|
|
116
|
+
checkSnapshot(ledger)
|
|
117
|
+
if (ledger.sha256 === null) return []
|
|
118
|
+
const rows = safeRead(ledger.path).toString('utf8').split('\n').filter(line => line.trim()).map(line => JSON.parse(line))
|
|
119
|
+
const byId = new Map<string, Doc[]>()
|
|
120
|
+
for (const row of rows) {
|
|
121
|
+
if (!row.id || !['intent', 'applied', 'failed', 'confirmed', 'confirmed-chunks', 'reverted'].includes(row.phase)) refuse('unreadable correction ledger')
|
|
122
|
+
byId.set(row.id, [...(byId.get(row.id) ?? []), row])
|
|
123
|
+
}
|
|
124
|
+
const raw = doc.chunkEntries.filter((e: Doc) => e.chunk?.text).map((e: Doc) => e.chunkIndex)
|
|
125
|
+
const protectedIndexes = new Set<number>()
|
|
126
|
+
for (const history of byId.values()) {
|
|
127
|
+
const last = history.at(-1)!
|
|
128
|
+
if (last.phase === 'intent') refuse('incomplete prior correction intent')
|
|
129
|
+
if (last.phase === 'reverted' || last.phase === 'failed') continue
|
|
130
|
+
for (const row of history.filter(r => r.phase !== 'intent')) {
|
|
131
|
+
if (!Array.isArray(row.chunks) || row.chunks.some((position: number) => !Number.isInteger(position) || raw[position] === undefined)) refuse('ambiguous correction ledger positions')
|
|
132
|
+
if (!row.chunks.length) for (const index of raw) protectedIndexes.add(index)
|
|
133
|
+
else for (const position of row.chunks) protectedIndexes.add(raw[position])
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
return [...protectedIndexes]
|
|
137
|
+
}
|
|
138
|
+
function revision(value: any): number { if (value === undefined) return 0; if (!Number.isSafeInteger(value) || value < 0) refuse('invalid lifecycle/correction revision'); return value }
|
|
139
|
+
export function previewMacSpeakerRepair(manifest: MacRepairManifest): MacRepairPreview {
|
|
140
|
+
if (manifest.version !== 1 || !/^[A-Za-z0-9_-]{3,100}$/.test(manifest.id) || !HASH.test(manifest.evidenceHash) || !Number.isFinite(Date.parse(manifest.at))) refuse('invalid manifest identity')
|
|
141
|
+
const root = offlineRoot(manifest.offlineRoot)
|
|
142
|
+
if (!Array.isArray(manifest.copies) || manifest.copies.length !== 2 || new Set(manifest.copies.map(copy => copy.copy)).size !== 2) refuse('explicit local and operations copies required')
|
|
143
|
+
if (!Array.isArray(manifest.preconditions) || !manifest.preconditions.length || !Array.isArray(manifest.audioFiles) || !manifest.audioFiles.length) refuse('frozen evidence and raw audio required')
|
|
144
|
+
if (!manifest.preconditions.some(file=>file.sha256===manifest.evidenceHash)) refuse('qualified evidence bytes must be a frozen precondition')
|
|
145
|
+
manifest.preconditions.forEach(checkSnapshot)
|
|
146
|
+
const pcm = new Map<number, Buffer>()
|
|
147
|
+
const samples: Record<string, number> = {}, audioPreconditions: RepairFileSnapshot[] = []
|
|
148
|
+
for (const audio of manifest.audioFiles) {
|
|
149
|
+
if (!Number.isInteger(audio.rawChunkIndex) || audio.rawChunkIndex < 0 || samples[String(audio.rawChunkIndex)] !== undefined || !HASH.test(audio.sha256)) refuse('invalid audio evidence binding')
|
|
150
|
+
const bytes = safeRead(audio.path)
|
|
151
|
+
if (sha(bytes) !== audio.sha256) refuse('raw audio drift')
|
|
152
|
+
const metadata = pcmSamples(bytes)
|
|
153
|
+
samples[String(audio.rawChunkIndex)] = metadata.count
|
|
154
|
+
pcm.set(audio.rawChunkIndex, metadata.pcm)
|
|
155
|
+
audioPreconditions.push({ path: audio.path, sha256: audio.sha256 })
|
|
156
|
+
}
|
|
157
|
+
const files: PreparedFile[] = [], images: MacRepairPreview['images'] = [], receipts: MacSpeakerRepairReceipt[] = []
|
|
158
|
+
const seen = new Set<string>(); let session: string | undefined, agreement: string | undefined
|
|
159
|
+
for (const [ci, copy] of manifest.copies.entries()) {
|
|
160
|
+
for (const file of [copy.sidecar, copy.markdown]) {
|
|
161
|
+
inside(root, file.path); if (seen.has(resolve(file.path))) refuse('duplicate target file'); seen.add(resolve(file.path)); checkSnapshot(file)
|
|
162
|
+
}
|
|
163
|
+
if (copy.sidecar.sha256 === null || copy.markdown.sha256 === null) refuse('source copies must exist')
|
|
164
|
+
const beforeSidecar = safeRead(copy.sidecar.path).toString('utf8'), beforeMarkdown = safeRead(copy.markdown.path).toString('utf8'), before = JSON.parse(beforeSidecar)
|
|
165
|
+
if (session && session !== before.sessionId) refuse('copy capture IDs disagree'); session = before.sessionId
|
|
166
|
+
const copyAgreement = digest({ assignments: copy.plan.assignments, samples: copy.plan.rawAudioSampleCounts, chunks: before.chunks.map((chunk: Doc) => [chunk.text, chunk.speaker, chunk.elapsed]), raw: before.chunkEntries.map((entry: Doc) => entry.chunkIndex) })
|
|
167
|
+
if (agreement && agreement !== copyAgreement) refuse('local and operations projection mappings disagree'); agreement = copyAgreement
|
|
168
|
+
if (copy.plan.id !== manifest.id || copy.plan.evidenceHash !== manifest.evidenceHash) refuse('copy plan identity disagrees')
|
|
169
|
+
for (const [raw, count] of Object.entries(copy.plan.rawAudioSampleCounts)) {
|
|
170
|
+
if (samples[raw] !== count) refuse('raw sample count not supported by WAV evidence')
|
|
171
|
+
const sourceChunk = before.chunkEntries.find((entry: Doc) => entry.chunkIndex === Number(raw))?.chunk
|
|
172
|
+
const verified = manifest.audioFiles.find(file => file.rawChunkIndex === Number(raw))
|
|
173
|
+
if (sourceChunk?.audioSha256 && sourceChunk.audioSha256 !== verified?.sha256) refuse('sidecar raw audio hash disagrees with verified WAV')
|
|
174
|
+
}
|
|
175
|
+
for (const assignment of copy.plan.assignments) {
|
|
176
|
+
const proof = assignment.targetEvidence
|
|
177
|
+
if (!proof) continue
|
|
178
|
+
const profileFile = manifest.preconditions.find(file => file.sha256 === proof.profileSha256)
|
|
179
|
+
if (!profileFile || !manifest.preconditions.some(file => file.sha256 === proof.modelSha256)) refuse('profile/model proof files are not frozen preconditions')
|
|
180
|
+
const profile = JSON.parse(safeRead(profileFile.path).toString('utf8'))
|
|
181
|
+
if (!Array.isArray(profile.profiles) || profile.profiles.filter((row: Doc) => row.name === proof.profileName).length !== 1) refuse('profile evidence name is missing or ambiguous')
|
|
182
|
+
for (const window of proof.windows) {
|
|
183
|
+
if (window.rawChunks.some(raw => !pcm.has(raw.rawChunkIndex)) || sha(Buffer.concat(window.rawChunks.map(raw => pcm.get(raw.rawChunkIndex)!))) !== window.pcmSha256) refuse('concatenated profile PCM proof disagrees')
|
|
184
|
+
for (const raw of window.rawChunks) {
|
|
185
|
+
if (!manifest.audioFiles.some(file => file.rawChunkIndex === raw.rawChunkIndex && file.sha256 === raw.audioSha256) || samples[String(raw.rawChunkIndex)] !== raw.sampleCount) refuse('profile evidence raw audio is not verified')
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
const plan = { ...copy.plan, protectedRawChunkIndices: [...new Set([...copy.plan.protectedRawChunkIndices, ...protectedRaw(before, copy.ledger)])] }
|
|
190
|
+
const projected = projectMacSpeakerRepair(before, plan), after = projected.doc, sequence = boundWords(before, after)
|
|
191
|
+
after.batchTranscript = renderBoundSpeakerLabels(before.batchTranscript, sequence)
|
|
192
|
+
const afterMarkdown = renderMarkdown(beforeMarkdown, sequence)
|
|
193
|
+
after.correctionRevision = revision(before.correctionRevision) + 1
|
|
194
|
+
after.lifecycleRevision = revision(before.lifecycleRevision) + 1
|
|
195
|
+
after.lifecycleUpdatedAt = manifest.at
|
|
196
|
+
after.labelsNewerThanGraph = true
|
|
197
|
+
after.proseStale = true
|
|
198
|
+
const rawChunkMap = Object.fromEntries(plan.assignments.map(assignment => [assignment.rawChunkIndex, assignment.to]))
|
|
199
|
+
after.speakerRepair = { version: 1, id: manifest.id, evidenceHash: manifest.evidenceHash, at: manifest.at, requiresSyncRefresh: true, rawChunkMap }
|
|
200
|
+
after.diarization = { engine: 'mac-speaker-repair-v1', status: 'applied', mode: 'labels-only', labels: plan.assignments.every(assignment => /^Voice (?:[1-9]|[1-9]\d)$/.test(assignment.to)) ? 'unnamed' : 'profile-matched', appliedAt: manifest.at, outputSha256: sha(after.batchTranscript), rawChunkMap }
|
|
201
|
+
const machineEvidence: MacSpeakerMachineEvidence = {
|
|
202
|
+
version: 1, correctionRevision: after.correctionRevision,
|
|
203
|
+
documentBinding: hashMacSpeakerMachineBinding(after),
|
|
204
|
+
receipt: projected.receipt, rawAudioSampleCounts: structuredClone(plan.rawAudioSampleCounts),
|
|
205
|
+
}
|
|
206
|
+
after.speakerRepair.machineEvidence = machineEvidence
|
|
207
|
+
// The receipt records aggregate model/profile confidence. It neither changes
|
|
208
|
+
// capture similarities nor fabricates ledger confirmations or enrollment.
|
|
209
|
+
// Original diarization/output maps remain byte-exact in the before-image.
|
|
210
|
+
const afterSidecar = JSON.stringify(after, null, 2) + '\n'
|
|
211
|
+
receipts.push(projected.receipt)
|
|
212
|
+
for (const [role, path, beforeBytes, afterBytes] of [['sidecar', copy.sidecar.path, beforeSidecar, afterSidecar], ['markdown', copy.markdown.path, beforeMarkdown, afterMarkdown]] as const) {
|
|
213
|
+
const stem = `${ci}-${role}`, beforeImage = `${stem}.before`, afterImage = `${stem}.after`
|
|
214
|
+
images.push({ name: beforeImage, bytes: beforeBytes }, { name: afterImage, bytes: afterBytes })
|
|
215
|
+
files.push({ path, beforeHash: sha(beforeBytes), afterHash: sha(afterBytes), beforeImage, afterImage, role, copy: copy.copy })
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
const payload: TransactionPayload = { version: 1, id: manifest.id, at: manifest.at, evidenceHash: manifest.evidenceHash, offlineRoot: root, preconditions: [...manifest.preconditions, ...audioPreconditions, ...manifest.copies.map(copy => copy.ledger)], files, receipts }
|
|
219
|
+
return { transaction: { ...payload, payloadHash: digest(payload), state: 'prepared', completed: [] }, images, summary: { copies: 2, chunks: receipts[0].rawChunkIndices.length, labelPatches: receipts.reduce((n, receipt) => n + receipt.patches.length, 0), offlineOnly: true } }
|
|
220
|
+
}
|
|
221
|
+
function writePrivate(path: string, bytes: string) { durableAtomicWriteFileSync(path, bytes, { mode: 0o600 }) }
|
|
222
|
+
function saveTransaction(path: string, tx: MacRepairTransaction) { writePrivate(path, JSON.stringify(tx, null, 2) + '\n') }
|
|
223
|
+
function lockRoot(root: string, resume = false): () => void {
|
|
224
|
+
const path = join(root, '.mac-speaker-repair.lock')
|
|
225
|
+
if (resume && existsSync(path)) {
|
|
226
|
+
const prior = JSON.parse(safeRead(path).toString('utf8'))
|
|
227
|
+
if (!Number.isSafeInteger(prior.pid) || prior.pid <= 0) refuse('invalid lock owner; manual review required')
|
|
228
|
+
try { process.kill(prior.pid, 0); refuse('offline repair lock owner is still alive') }
|
|
229
|
+
catch (error) { if ((error as NodeJS.ErrnoException).code !== 'ESRCH') throw error }
|
|
230
|
+
unlinkSync(path)
|
|
231
|
+
}
|
|
232
|
+
const fd = openSync(path, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW, 0o600)
|
|
233
|
+
try { writeFileSync(fd, JSON.stringify({ pid: process.pid })); fsyncSync(fd) } finally { closeSync(fd) }
|
|
234
|
+
return () => { unlinkSync(path) }
|
|
235
|
+
}
|
|
236
|
+
export function prepareMacSpeakerRepair(manifest: MacRepairManifest, transactionDir: string): MacRepairPreview {
|
|
237
|
+
const root = offlineRoot(manifest.offlineRoot); inside(root, transactionDir)
|
|
238
|
+
if (existsSync(transactionDir)) refuse('transaction directory already exists')
|
|
239
|
+
const parent = dirname(absolute(transactionDir)); if (realpathSync(parent) !== parent) refuse('transaction parent symlink refused')
|
|
240
|
+
const release = lockRoot(root)
|
|
241
|
+
try {
|
|
242
|
+
const preview = previewMacSpeakerRepair(manifest)
|
|
243
|
+
mkdirSync(transactionDir, { mode: 0o700 })
|
|
244
|
+
const parentFd = openSync(root, constants.O_RDONLY); try { fsyncSync(parentFd) } finally { closeSync(parentFd) }
|
|
245
|
+
for (const image of preview.images) writePrivate(join(transactionDir, image.name), image.bytes)
|
|
246
|
+
saveTransaction(join(transactionDir, 'transaction.json'), preview.transaction)
|
|
247
|
+
return preview
|
|
248
|
+
} finally { release() }
|
|
249
|
+
}
|
|
250
|
+
function payloadOf(tx: MacRepairTransaction): TransactionPayload {
|
|
251
|
+
const { payloadHash: _, state: __, completed: ___, error: ____, ...payload } = tx
|
|
252
|
+
return payload
|
|
253
|
+
}
|
|
254
|
+
export function executeMacSpeakerRepair(transactionDir: string, direction: 'apply' | 'undo', options: { resume?: boolean; beforeWrite?: (index: number) => void } = {}): MacRepairTransaction {
|
|
255
|
+
const transactionPath = join(absolute(transactionDir), 'transaction.json')
|
|
256
|
+
const tx = JSON.parse(safeRead(transactionPath).toString('utf8')) as MacRepairTransaction
|
|
257
|
+
if (tx.version !== 1 || digest(payloadOf(tx)) !== tx.payloadHash) refuse('transaction integrity mismatch')
|
|
258
|
+
const root = offlineRoot(tx.offlineRoot); inside(root, transactionDir)
|
|
259
|
+
if (realpathSync(transactionDir) !== resolve(transactionDir)) refuse('transaction directory symlink refused')
|
|
260
|
+
const release = lockRoot(root, options.resume)
|
|
261
|
+
try {
|
|
262
|
+
tx.preconditions.forEach(checkSnapshot)
|
|
263
|
+
const forward = direction === 'apply', wanted = forward ? 'applied' : 'reverted'
|
|
264
|
+
if ((tx.state === 'partial' || tx.state === 'applying' || tx.state === 'undoing') && !options.resume && forward) refuse('incomplete prior intent; explicit --resume or --undo required')
|
|
265
|
+
const writes: Array<{ file: PreparedFile; bytes: string; changed: boolean }> = []
|
|
266
|
+
for (const file of tx.files) {
|
|
267
|
+
inside(root, file.path)
|
|
268
|
+
for (const image of [file.beforeImage, file.afterImage]) if (image !== image.split('/').at(-1) || image.includes('..')) refuse('unsafe image path')
|
|
269
|
+
const before = safeRead(join(transactionDir, file.beforeImage)), after = safeRead(join(transactionDir, file.afterImage))
|
|
270
|
+
if (sha(before) !== file.beforeHash || sha(after) !== file.afterHash) refuse('before/after-image drift')
|
|
271
|
+
const current = snapshot(file.path), desired = forward ? file.afterHash : file.beforeHash, prior = forward ? file.beforeHash : file.afterHash
|
|
272
|
+
if (current !== desired && current !== prior) refuse('copy drift; restore would overwrite a later edit')
|
|
273
|
+
if (!options.resume && tx.state === 'prepared' && forward && current !== prior) refuse('copy changed before first apply')
|
|
274
|
+
const currentDoc = file.role === 'sidecar' ? JSON.parse(safeRead(file.path).toString('utf8')) : null
|
|
275
|
+
if (currentDoc && (currentDoc.finalizationState !== 'complete' || currentDoc.claimPending === true || currentDoc.active === true || currentDoc.isActive === true)) refuse('capture is active or no longer finalized')
|
|
276
|
+
writes.push({ file, bytes: (forward ? after : before).toString('utf8'), changed: current !== desired })
|
|
277
|
+
}
|
|
278
|
+
if (tx.state === wanted && writes.every(write => !write.changed)) return tx
|
|
279
|
+
tx.state = forward ? 'applying' : 'undoing'; tx.completed = []; delete tx.error
|
|
280
|
+
saveTransaction(transactionPath, tx) // durable intent before any copy mutation
|
|
281
|
+
try {
|
|
282
|
+
for (const [index, write] of writes.entries()) {
|
|
283
|
+
options.beforeWrite?.(index)
|
|
284
|
+
tx.preconditions.forEach(checkSnapshot)
|
|
285
|
+
// Recheck all remaining copies immediately before each publish. The
|
|
286
|
+
// offline lock excludes this tool; hash CAS also detects external edits.
|
|
287
|
+
for (const completed of writes.slice(0, index)) if (snapshot(completed.file.path) !== (forward ? completed.file.afterHash : completed.file.beforeHash)) refuse('completed copy drift after publish')
|
|
288
|
+
for (const pending of writes.slice(index)) {
|
|
289
|
+
const actual = snapshot(pending.file.path)
|
|
290
|
+
const expected = pending.changed ? (forward ? pending.file.beforeHash : pending.file.afterHash) : (forward ? pending.file.afterHash : pending.file.beforeHash)
|
|
291
|
+
if (actual !== expected) refuse('copy changed immediately before write')
|
|
292
|
+
}
|
|
293
|
+
if (write.changed) writePrivate(write.file.path, write.bytes)
|
|
294
|
+
if (snapshot(write.file.path) !== (forward ? write.file.afterHash : write.file.beforeHash)) refuse('post-write verification failed')
|
|
295
|
+
tx.completed.push(write.file.path); saveTransaction(transactionPath, tx)
|
|
296
|
+
}
|
|
297
|
+
for (const write of writes) if (snapshot(write.file.path) !== (forward ? write.file.afterHash : write.file.beforeHash)) refuse('final copy postimage drift')
|
|
298
|
+
tx.state = wanted; saveTransaction(transactionPath, tx)
|
|
299
|
+
} catch (error) {
|
|
300
|
+
tx.state = 'partial'; tx.error = error instanceof Error ? error.message : String(error); saveTransaction(transactionPath, tx); throw error
|
|
301
|
+
}
|
|
302
|
+
return tx
|
|
303
|
+
} finally { release() }
|
|
304
|
+
}
|
|
305
|
+
export function macSpeakerRepairMain(args = process.argv.slice(2)): void {
|
|
306
|
+
const known = new Set(['--manifest', '--transaction', '--prepare', '--apply', '--undo', '--resume'])
|
|
307
|
+
for (let i = 0; i < args.length; i++) { if (!known.has(args[i])) refuse('unknown command option'); if (args[i] === '--manifest' || args[i] === '--transaction') { if (!args[++i]) refuse('missing option value') } }
|
|
308
|
+
const value = (flag: string) => { const index = args.indexOf(flag); return index < 0 ? undefined : args[index + 1] }
|
|
309
|
+
const transactionDir = value('--transaction'), apply = args.includes('--apply'), undo = args.includes('--undo'), prepare = args.includes('--prepare')
|
|
310
|
+
if (Number(apply) + Number(undo) + Number(prepare) > 1) refuse('choose one of --prepare, --apply or --undo')
|
|
311
|
+
if (apply || undo) {
|
|
312
|
+
if (!transactionDir) refuse('--transaction required')
|
|
313
|
+
const tx = executeMacSpeakerRepair(transactionDir, undo ? 'undo' : 'apply', { resume: args.includes('--resume') })
|
|
314
|
+
console.log(JSON.stringify({ id: tx.id, state: tx.state, offlineOnly: true })); return
|
|
315
|
+
}
|
|
316
|
+
const manifestPath = value('--manifest'); if (!manifestPath) refuse('--manifest required for preview or prepare')
|
|
317
|
+
const manifest = JSON.parse(safeRead(manifestPath).toString('utf8')) as MacRepairManifest
|
|
318
|
+
if (prepare && !transactionDir) refuse('--transaction required for prepare')
|
|
319
|
+
const preview = prepare ? prepareMacSpeakerRepair(manifest, transactionDir!) : previewMacSpeakerRepair(manifest)
|
|
320
|
+
console.log(JSON.stringify({ id: manifest.id, mode: prepare ? 'prepared' : 'dry-run', ...preview.summary }))
|
|
321
|
+
}
|
|
322
|
+
if (process.argv[1] && import.meta.url === pathToFileURL(resolve(process.argv[1])).href) {
|
|
323
|
+
try { macSpeakerRepairMain() } catch (error) { console.error(error instanceof Error ? error.message : String(error)); process.exitCode = 1 }
|
|
324
|
+
}
|