@gotcos/glasses-server 6.94.1 → 6.94.3
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 +26 -0
- package/README.md +27 -0
- package/package.json +2 -1
- package/server/index.ts +6 -0
- package/server/lib/decision-provider.ts +4 -2
- package/server/lib/held-naming-batches.ts +8 -7
- package/server/lib/held-voice-groups.ts +5 -4
- package/server/lib/keyed-sessions.ts +21 -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/mac-voice-assignment.ts +2 -1
- package/server/lib/meeting-corrections.ts +2 -1
- package/server/lib/meeting-relabel-enrolment.ts +2 -1
- package/server/lib/meeting-relabel.ts +17 -10
- package/server/lib/meeting-scribe-content.ts +8 -1
- package/server/lib/meeting-speaker-review.ts +46 -7
- package/server/lib/nemotron-final.ts +64 -14
- package/server/lib/session-find-ask.ts +281 -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-trainer.ts +2 -1
- package/server/lib/voice-directory.ts +7 -2
- package/server/lib/voice-profile-store.ts +24 -14
- package/server/routes/decision-provider.ts +4 -1
- package/server/routes/jev.ts +5 -1
- package/server/routes/meeting.ts +43 -15
- package/server/routes/session-find.ts +73 -0
- package/server/routes/voice.ts +25 -18
- package/server/scripts/mac-speaker-repair.ts +324 -0
|
@@ -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
|
+
}
|