@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,233 @@
|
|
|
1
|
+
/** Pure derived presentation geometry. This module neither admits identities nor
|
|
2
|
+
* authenticates native execution. Its caller must validate the bound recurrence
|
|
3
|
+
* state/source before preparing support and qualify the resulting packet offline. */
|
|
4
|
+
import { createHash } from 'node:crypto'
|
|
5
|
+
import type { MacSpeaker256Group, MacSpeaker256State } from './mac-speaker-live-memory.js'
|
|
6
|
+
import { hasMacSpeakerProtectedRows, isMacSpeakerHumanProtected } from './mac-speaker-protection.js'
|
|
7
|
+
import { pyRoundInt } from './speaker-shadow-numeric.js'
|
|
8
|
+
|
|
9
|
+
type Doc = Record<string, any>
|
|
10
|
+
type RawBinding = MacSpeaker256State['rawBindings'][number]
|
|
11
|
+
export type MacSpeakerCallIntervalMembership = 'accepted-only' | 'native-exclusive-members'
|
|
12
|
+
export interface MacSpeakerCallNativeInterval { startSample: number; endSample: number; nativeCluster: string }
|
|
13
|
+
export interface MacSpeakerCallSupportInterval {
|
|
14
|
+
identityId: string; rawChunkIndex: number; startSample: number; endSample: number
|
|
15
|
+
audioSHA256: string; pcmSHA256: string; evidenceIds: string[]
|
|
16
|
+
origin: 'accepted-interval' | 'native-exclusive-member'
|
|
17
|
+
}
|
|
18
|
+
export interface MacSpeakerCallIntervalSupport {
|
|
19
|
+
version: 1; membership: MacSpeakerCallIntervalMembership
|
|
20
|
+
sourceSHA256: string; nativeSHA256: string; timelinePCMSHA256: string; totalSamples: number
|
|
21
|
+
rawUsedSamples: number[]; nativeIntervals: MacSpeakerCallNativeInterval[]
|
|
22
|
+
intervals: MacSpeakerCallSupportInterval[]; supportSHA256: string
|
|
23
|
+
}
|
|
24
|
+
export interface MacSpeakerCallIntervalContext {
|
|
25
|
+
rawBindings: readonly RawBinding[]; groups: readonly MacSpeaker256Group[]
|
|
26
|
+
protectedRawChunkIndices: readonly number[]; displayLabels: Readonly<Record<string, string>>
|
|
27
|
+
}
|
|
28
|
+
/** Exact HQ words wholly supported by one accepted identity. These coordinates
|
|
29
|
+
* are issued by the projector, never supplied as a caller's naming selector. */
|
|
30
|
+
export interface MacSpeakerCallHQWordBinding {
|
|
31
|
+
batchIndex: number; wordIndex: number; identityId: string
|
|
32
|
+
startElapsedMs: number; endElapsedMs: number; rawChunkIndices: number[]
|
|
33
|
+
pieces: Array<{ rawChunkIndex: number; startSample: number; endSample: number; audioSHA256: string; pcmSHA256: string; originalRowSHA256: string }>
|
|
34
|
+
}
|
|
35
|
+
/** Source geometry for explicit human word review. It carries no acoustic
|
|
36
|
+
* identity or admission claim and includes named/unknown source labels. */
|
|
37
|
+
export type MacSpeakerCallHQWordSource = Omit<MacSpeakerCallHQWordBinding, 'identityId'>
|
|
38
|
+
const HASH = /^[a-f0-9]{64}$/
|
|
39
|
+
const GENERIC = /^(?:Voice [1-9]\d?|Ext|Unknown|Unidentified(?: \d+)?|Speaker \d+)$/
|
|
40
|
+
const digest = (v: unknown) => createHash('sha256').update(JSON.stringify(v)).digest('hex')
|
|
41
|
+
const fail = (reason: string): never => { throw new Error(`Mac call interval view: ${reason}`) }
|
|
42
|
+
const range = (lo: number, hi: number) => Number.isSafeInteger(lo) && Number.isSafeInteger(hi) && lo >= 0 && hi > lo
|
|
43
|
+
|
|
44
|
+
/** Sweep ALL native clusters, including withheld identities. A competing native
|
|
45
|
+
* cluster removes support even if it never passed recurrence admission. */
|
|
46
|
+
function exclusive(intervals: readonly MacSpeakerCallNativeInterval[]): MacSpeakerCallNativeInterval[] {
|
|
47
|
+
const events = new Map<number, Array<[string, number]>>()
|
|
48
|
+
for (const row of intervals) for (const [point, delta] of [[row.startSample, 1], [row.endSample, -1]]) {
|
|
49
|
+
const list = events.get(point) ?? []; list.push([row.nativeCluster, delta]); events.set(point, list)
|
|
50
|
+
}
|
|
51
|
+
const points = [...events.keys()].sort((a, b) => a - b), active = new Map<string, number>(), out: MacSpeakerCallNativeInterval[] = []
|
|
52
|
+
for (let i = 0; i + 1 < points.length; i++) {
|
|
53
|
+
const lo = points[i], hi = points[i + 1]
|
|
54
|
+
for (const [cluster, delta] of events.get(lo)!) { const count = (active.get(cluster) ?? 0) + delta; if (count) active.set(cluster, count); else active.delete(cluster) }
|
|
55
|
+
if (active.size !== 1) continue
|
|
56
|
+
const nativeCluster = active.keys().next().value!, prior = out.at(-1)
|
|
57
|
+
if (prior?.nativeCluster === nativeCluster && prior.endSample === lo) prior.endSample = hi
|
|
58
|
+
else out.push({ startSample: lo, endSample: hi, nativeCluster })
|
|
59
|
+
}
|
|
60
|
+
return out
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function expectedIntervals(context: MacSpeakerCallIntervalContext, support: Omit<MacSpeakerCallIntervalSupport, 'intervals' | 'supportSHA256'>): MacSpeakerCallSupportInterval[] {
|
|
64
|
+
const protectedRaw = new Set(context.protectedRawChunkIndices), admitted = new Map(context.groups.map(g => [g.nativeCluster, g]))
|
|
65
|
+
if (admitted.size !== context.groups.length) fail('duplicate admitted native cluster')
|
|
66
|
+
const native = exclusive(support.nativeIntervals), out: MacSpeakerCallSupportInterval[] = []
|
|
67
|
+
const add = (g: MacSpeaker256Group, r: RawBinding, startSample: number, endSample: number, origin: MacSpeakerCallSupportInterval['origin']) => {
|
|
68
|
+
if (protectedRaw.has(r.rawChunkIndex)) return
|
|
69
|
+
if (!range(startSample, endSample) || startSample < r.startSample || endSample > r.startSample + support.rawUsedSamples[r.rawChunkIndex]) fail('unsupported raw sample range')
|
|
70
|
+
out.push({ identityId: g.identityId, rawChunkIndex: r.rawChunkIndex, startSample, endSample, audioSHA256: r.audioSHA256, pcmSHA256: r.pcmSHA256, evidenceIds: g.witnesses.map(w => w.evidenceId), origin })
|
|
71
|
+
}
|
|
72
|
+
if (support.membership === 'accepted-only') {
|
|
73
|
+
for (const g of context.groups) for (const span of g.intervals) {
|
|
74
|
+
const r = context.rawBindings[span.rawChunkIndex]
|
|
75
|
+
if (!r || span.identityId !== g.identityId || span.audioSHA256 !== r.audioSHA256 || span.pcmSHA256 !== r.pcmSHA256) fail('accepted interval binding')
|
|
76
|
+
// Re-check exact native exclusivity instead of accepting a caller's span.
|
|
77
|
+
const cover = native.find(n => n.nativeCluster === g.nativeCluster && n.startSample <= span.startSample && n.endSample >= span.endSample)
|
|
78
|
+
if (!cover) fail('accepted interval outside exclusive native support')
|
|
79
|
+
add(g, r, span.startSample, span.endSample, 'accepted-interval')
|
|
80
|
+
}
|
|
81
|
+
} else {
|
|
82
|
+
for (const n of native) {
|
|
83
|
+
const g = admitted.get(n.nativeCluster); if (!g) continue
|
|
84
|
+
for (const r of context.rawBindings) {
|
|
85
|
+
const lo = Math.max(n.startSample, r.startSample), hi = Math.min(n.endSample, r.startSample + support.rawUsedSamples[r.rawChunkIndex])
|
|
86
|
+
if (hi > lo) add(g, r, lo, hi, 'native-exclusive-member')
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
return out.sort((a, b) => a.rawChunkIndex - b.rawChunkIndex || a.startSample - b.startSample || a.endSample - b.endSample || a.identityId.localeCompare(b.identityId))
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function checkSource(context: MacSpeakerCallIntervalContext, support: Omit<MacSpeakerCallIntervalSupport, 'intervals' | 'supportSHA256'>): void {
|
|
94
|
+
if (support.version !== 1 || !['accepted-only', 'native-exclusive-members'].includes(support.membership) || !HASH.test(support.sourceSHA256) || !HASH.test(support.nativeSHA256) || !HASH.test(support.timelinePCMSHA256) || !Number.isSafeInteger(support.totalSamples) || support.totalSamples <= 0 || support.totalSamples > 4 * 3600 * 16000) fail('support identity')
|
|
95
|
+
if (!Array.isArray(support.rawUsedSamples) || support.rawUsedSamples.length !== context.rawBindings.length || !Array.isArray(support.nativeIntervals) || support.nativeIntervals.length > 20000 || context.rawBindings.length > 6000 || context.groups.length > 99) fail('support bounds')
|
|
96
|
+
for (const [i, r] of context.rawBindings.entries()) if (r.rawChunkIndex !== i || !HASH.test(r.audioSHA256) || !HASH.test(r.pcmSHA256) || !HASH.test(r.originalRowSHA256) || !range(r.startSample, r.endSample) || r.endSample > support.totalSamples || !Number.isSafeInteger(support.rawUsedSamples[i]) || support.rawUsedSamples[i] <= 0 || support.rawUsedSamples[i] > r.endSample - r.startSample) fail('raw PCM binding')
|
|
97
|
+
if (new Set(context.protectedRawChunkIndices).size !== context.protectedRawChunkIndices.length || context.protectedRawChunkIndices.some(i => !Number.isInteger(i) || !context.rawBindings[i])) fail('protected source indices')
|
|
98
|
+
// The used prefix must be the actual reconstructed raw intersection. A packet
|
|
99
|
+
// cannot hide an overlapping raw tail by supplying a larger usable count.
|
|
100
|
+
const ordered = [...context.rawBindings].sort((a, b) => a.startSample - b.startSample || a.rawChunkIndex - b.rawChunkIndex)
|
|
101
|
+
if (!ordered.length || Math.max(...ordered.map(r => r.endSample)) !== support.totalSamples) fail('timeline sample bound')
|
|
102
|
+
for (const [i, r] of ordered.entries()) if (support.rawUsedSamples[r.rawChunkIndex] !== Math.min(r.endSample - r.startSample, i + 1 < ordered.length ? ordered[i + 1].startSample - r.startSample : Infinity)) fail('raw used-sample geometry')
|
|
103
|
+
for (const n of support.nativeIntervals) if (!range(n.startSample, n.endSample) || n.endSample > support.totalSamples || typeof n.nativeCluster !== 'string' || !n.nativeCluster || n.nativeCluster.length > 100) fail('native geometry')
|
|
104
|
+
const identities = new Set<string>(), labels = new Set<string>()
|
|
105
|
+
for (const g of context.groups) {
|
|
106
|
+
const label = context.displayLabels[g.identityId]
|
|
107
|
+
if (!/^fluid256_[a-f0-9]{64}$/.test(g.identityId) || identities.has(g.identityId) || typeof label !== 'string' || !/^Anonymous group [1-9]\d{0,5}$/.test(label) || labels.has(label)) fail('anonymous identity namespace')
|
|
108
|
+
if (!Array.isArray(g.witnesses) || g.witnesses.length !== 2 || g.witnesses.some(w => !HASH.test(w.evidenceId) || !range(w.startSample, w.endSample) || !w.rawChunkIndices.length || w.rawChunkIndices.length !== w.pcmSHA256s.length || w.pcmSHA256s.some(h => !HASH.test(h)))) fail('missing identity witnesses')
|
|
109
|
+
const [a, b] = g.witnesses
|
|
110
|
+
if (a.startSample < b.endSample && b.startSample < a.endSample || a.rawChunkIndices.some(i => b.rawChunkIndices.includes(i)) || a.pcmSHA256s.some(h => b.pcmSHA256s.includes(h))) fail('dependent identity witnesses')
|
|
111
|
+
identities.add(g.identityId); labels.add(label)
|
|
112
|
+
}
|
|
113
|
+
if (Object.keys(context.displayLabels).length !== identities.size) fail('display identity cardinality')
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** Only call after full-source/state validation in the trusted offline producer.
|
|
117
|
+
* Hashes bind the supplied source; they are not a native-authentication grant. */
|
|
118
|
+
export function prepareMacSpeakerCallIntervalSupport(context: MacSpeakerCallIntervalContext, source: Omit<MacSpeakerCallIntervalSupport, 'intervals' | 'supportSHA256'>): MacSpeakerCallIntervalSupport {
|
|
119
|
+
checkSource(context, source)
|
|
120
|
+
const body = { ...structuredClone(source), intervals: expectedIntervals(context, source) }
|
|
121
|
+
if (body.intervals.length > 40000) fail('interval bound')
|
|
122
|
+
return { ...body, supportSHA256: digest(body) }
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** Reconstruct support from the bound packet geometry and admitted groups. A
|
|
126
|
+
* missing, expanded, reidentified or rehashed arbitrary interval list refuses. */
|
|
127
|
+
export function validateMacSpeakerCallIntervalSupport(context: MacSpeakerCallIntervalContext, support: MacSpeakerCallIntervalSupport): void {
|
|
128
|
+
if (!support || !Array.isArray(support.intervals) || support.intervals.length > 40000) fail('missing support')
|
|
129
|
+
const { supportSHA256, ...body } = support
|
|
130
|
+
if (!HASH.test(supportSHA256) || digest(body) !== supportSHA256) fail('support hash')
|
|
131
|
+
checkSource(context, support)
|
|
132
|
+
if (digest(expectedIntervals(context, support)) !== digest(support.intervals)) fail('support reconstruction')
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** Build a closed-over validated projector, avoiding caller-made label maps or
|
|
136
|
+
* mutable projections after validation. The output is a clone; BASE is untouched. */
|
|
137
|
+
export function createMacSpeakerCallIntervalProjector(contextInput: MacSpeakerCallIntervalContext, supportInput: MacSpeakerCallIntervalSupport) {
|
|
138
|
+
validateMacSpeakerCallIntervalSupport(contextInput, supportInput)
|
|
139
|
+
const context = structuredClone(contextInput), support = structuredClone(supportInput), protectedRaw = new Set(context.protectedRawChunkIndices)
|
|
140
|
+
const labels = context.displayLabels, byRaw = new Map<number, MacSpeakerCallSupportInterval[]>()
|
|
141
|
+
for (const s of support.intervals) { const list = byRaw.get(s.rawChunkIndex) ?? []; list.push(s); byRaw.set(s.rawChunkIndex, list) }
|
|
142
|
+
const identityFor = (raw: number, lo: number, hi: number): string | null => {
|
|
143
|
+
const r = context.rawBindings[raw]
|
|
144
|
+
if (!r || protectedRaw.has(raw) || !range(lo, hi) || lo < r.startSample || hi > r.endSample) return null
|
|
145
|
+
let end = lo, identity: string | null = null
|
|
146
|
+
for (const s of byRaw.get(raw) ?? []) {
|
|
147
|
+
if (s.endSample <= lo || s.startSample >= hi) continue
|
|
148
|
+
if (identity && identity !== s.identityId || s.startSample > end) return null
|
|
149
|
+
identity = s.identityId; end = Math.max(end, s.endSample)
|
|
150
|
+
}
|
|
151
|
+
return end >= hi ? identity : null
|
|
152
|
+
}
|
|
153
|
+
const eligible = (r: Doc) => !!r && typeof r.speaker === 'string' && GENERIC.test(r.speaker) && !isMacSpeakerHumanProtected(r)
|
|
154
|
+
const samples = (start: unknown, end: unknown): [number, number] | null => {
|
|
155
|
+
if (typeof start !== 'number' || typeof end !== 'number' || !Number.isFinite(start) || !Number.isFinite(end) || start < 0 || end <= start) return null
|
|
156
|
+
if (!Number.isFinite(start * 16000) || !Number.isFinite(end * 16000)) return null
|
|
157
|
+
const lo = pyRoundInt(start * 16000), hi = pyRoundInt(end * 16000)
|
|
158
|
+
return range(lo, hi) ? [lo, hi] : null
|
|
159
|
+
}
|
|
160
|
+
// Presentation and human-review scopes must share this exact geometry. In
|
|
161
|
+
// particular a word crossing raw files cannot jump a gap or an overlap.
|
|
162
|
+
const hqSourceGeometry = (base: Doc): { usableHQWords: number; words: MacSpeakerCallHQWordSource[] } => {
|
|
163
|
+
let usableHQWords = 0
|
|
164
|
+
const words: MacSpeakerCallHQWordSource[] = []
|
|
165
|
+
for (const [batchIndex, batch] of (base.batchSegments ?? []).entries()) {
|
|
166
|
+
if (!Number.isInteger(batch.startChunkIdx) || !Number.isInteger(batch.endChunkIdx) || batch.startChunkIdx < 0 || batch.endChunkIdx < batch.startChunkIdx || batch.endChunkIdx >= context.rawBindings.length || !Array.isArray(batch.speakerWords)) fail('batch geometry')
|
|
167
|
+
const clock: Array<{ raw: number; lo: number; hi: number }> = []; let offset = 0
|
|
168
|
+
for (let raw = batch.startChunkIdx; raw <= batch.endChunkIdx; raw++) { const r = context.rawBindings[raw], n = r.endSample - r.startSample; clock.push({ raw, lo: offset, hi: offset + n }); offset += n }
|
|
169
|
+
for (const [wordIndex, word] of batch.speakerWords.entries()) {
|
|
170
|
+
if (typeof word?.word !== 'string' || !word.word.trim()) continue
|
|
171
|
+
const span = samples(word?.start, word?.end)
|
|
172
|
+
if (!span || span[1] > offset) continue
|
|
173
|
+
usableHQWords++
|
|
174
|
+
if (isMacSpeakerHumanProtected(word)) continue
|
|
175
|
+
const [lo, hi] = span
|
|
176
|
+
let left = 0, right = clock.length
|
|
177
|
+
while (left < right) { const mid = (left + right) >>> 1; if (clock[mid].hi <= lo) left = mid + 1; else right = mid }
|
|
178
|
+
let captureEnd: number | null = null, covered = 0, valid = true
|
|
179
|
+
const pieces: MacSpeakerCallHQWordBinding['pieces'] = []
|
|
180
|
+
for (let i = left; i < clock.length && clock[i].lo < hi; i++) {
|
|
181
|
+
const part = clock[i], r = context.rawBindings[part.raw], a = r.startSample + Math.max(0, lo - part.lo), b = r.startSample + Math.min(part.hi - part.lo, hi - part.lo)
|
|
182
|
+
if (protectedRaw.has(part.raw) || b > r.startSample + support.rawUsedSamples[part.raw] || captureEnd !== null && captureEnd !== a) { valid = false; break }
|
|
183
|
+
captureEnd = b; covered += b - a
|
|
184
|
+
pieces.push({ rawChunkIndex: part.raw, startSample: a, endSample: b, audioSHA256: r.audioSHA256, pcmSHA256: r.pcmSHA256, originalRowSHA256: r.originalRowSHA256 })
|
|
185
|
+
}
|
|
186
|
+
if (valid && pieces.length && covered === hi - lo) words.push({ batchIndex, wordIndex, startElapsedMs: pieces[0].startSample / 16, endElapsedMs: pieces.at(-1)!.endSample / 16, rawChunkIndices: pieces.map(p => p.rawChunkIndex), pieces })
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
return { usableHQWords, words }
|
|
190
|
+
}
|
|
191
|
+
const hqGeometry = (base: Doc): { usableHQWords: number; words: MacSpeakerCallHQWordBinding[] } => {
|
|
192
|
+
const source = hqSourceGeometry(base), words: MacSpeakerCallHQWordBinding[] = []
|
|
193
|
+
for (const word of source.words) {
|
|
194
|
+
if (!eligible(base.batchSegments[word.batchIndex].speakerWords[word.wordIndex])) continue
|
|
195
|
+
let identity: string | null = null, valid = true
|
|
196
|
+
for (const piece of word.pieces) {
|
|
197
|
+
const id = identityFor(piece.rawChunkIndex, piece.startSample, piece.endSample)
|
|
198
|
+
if (!id || identity && identity !== id) { valid = false; break }
|
|
199
|
+
identity = id
|
|
200
|
+
}
|
|
201
|
+
if (valid && identity) words.push({ ...word, identityId: identity })
|
|
202
|
+
}
|
|
203
|
+
return { usableHQWords: source.usableHQWords, words }
|
|
204
|
+
}
|
|
205
|
+
return {
|
|
206
|
+
identityFor,
|
|
207
|
+
hqWords(base: Doc): MacSpeakerCallHQWordBinding[] { return hqGeometry(base).words },
|
|
208
|
+
hqWordSources(base: Doc): MacSpeakerCallHQWordSource[] { return hqSourceGeometry(base).words },
|
|
209
|
+
project(base: Doc) {
|
|
210
|
+
const doc = structuredClone(base), changedRaw = new Set<number>()
|
|
211
|
+
let position = 0
|
|
212
|
+
for (const e of doc.chunkEntries) {
|
|
213
|
+
const raw = e.chunkIndex, c = e.chunk, r = context.rawBindings[raw]
|
|
214
|
+
if (typeof c.text !== 'string' || !c.text.trim()) continue
|
|
215
|
+
const root = doc.chunks[position++]
|
|
216
|
+
if (!root || !r || protectedRaw.has(raw) || hasMacSpeakerProtectedRows(c) || hasMacSpeakerProtectedRows(root)) continue
|
|
217
|
+
for (const row of [root, c]) {
|
|
218
|
+
const whole = identityFor(raw, r.startSample, r.endSample)
|
|
219
|
+
if (whole && eligible(row)) { row.speaker = labels[whole]; changedRaw.add(raw) }
|
|
220
|
+
for (const key of ['segments', 'finalSegments', 'words']) for (const nested of row[key] ?? []) {
|
|
221
|
+
if (!eligible(nested)) continue
|
|
222
|
+
const span = samples(key === 'words' ? nested.start : nested.startSec, key === 'words' ? nested.end : nested.endSec)
|
|
223
|
+
const identity = span ? identityFor(raw, r.startSample + span[0], r.startSample + span[1]) : null
|
|
224
|
+
if (identity) { nested.speaker = labels[identity]; changedRaw.add(raw) }
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
const hq = hqGeometry(base)
|
|
229
|
+
for (const word of hq.words) doc.batchSegments[word.batchIndex].speakerWords[word.wordIndex].speaker = labels[word.identityId]
|
|
230
|
+
return { doc, changedChunks: changedRaw.size, changedWords: hq.words.length, usableHQWords: hq.usableHQWords }
|
|
231
|
+
},
|
|
232
|
+
}
|
|
233
|
+
}
|
|
@@ -0,0 +1,328 @@
|
|
|
1
|
+
/** Explicit return to immutable BASE plus verified older human corrections.
|
|
2
|
+
* Only derived interval artifacts move. A durable archive precedes the prepared
|
|
3
|
+
* marker, which precedes every removal; recovery never invents a new intent. */
|
|
4
|
+
import { createHash, randomUUID } from 'node:crypto'
|
|
5
|
+
import { closeSync, constants, fsyncSync, linkSync, lstatSync, mkdirSync, openSync, realpathSync, renameSync, unlinkSync, writeFileSync } from 'node:fs'
|
|
6
|
+
import { dirname, join, resolve } from 'node:path'
|
|
7
|
+
import { applyMacCallHumanAnnotation, macCallHumanPaths, readMacCallHumanFile, type MacCallHumanFiles } from './mac-speaker-call-human.js'
|
|
8
|
+
import { applyMacIntervalHumanAnnotation, inspectMacIntervalHumanJournal, macIntervalHumanPaths } from './mac-speaker-call-interval-human.js'
|
|
9
|
+
import type { MacSpeakerCallPacket, MacSpeakerCallView } from './mac-speaker-call-view.js'
|
|
10
|
+
|
|
11
|
+
const POLICY = 'mac-speaker-call-original-view-v1'
|
|
12
|
+
const SHA = /^[a-f0-9]{64}$/
|
|
13
|
+
const MB = 1024 * 1024
|
|
14
|
+
const hash = (s: string) => createHash('sha256').update(s).digest('hex')
|
|
15
|
+
const digest = (v: unknown) => hash(JSON.stringify(v))
|
|
16
|
+
const equal = (a: unknown, b: unknown) => JSON.stringify(a) === JSON.stringify(b)
|
|
17
|
+
function fail(reason: string): never { throw Error(`Mac original view: ${reason}`) }
|
|
18
|
+
|
|
19
|
+
type SourceKey = 'base' | 'markdown' | 'ledger' | 'legacyAnnotation' | 'legacyJournal'
|
|
20
|
+
type ArtifactKey = 'packet' | 'intervalAnnotation' | 'intervalJournal' | 'state'
|
|
21
|
+
interface Pin<K extends string> { key: K; hash: string | null }
|
|
22
|
+
interface Intent {
|
|
23
|
+
version: 1; policy: typeof POLICY; captureId: string; scopeSHA256: string; archiveId: string
|
|
24
|
+
sourcePins: Pin<SourceKey>[]; artifacts: Pin<ArtifactKey>[]; previousTransactionSHA256: string | null
|
|
25
|
+
}
|
|
26
|
+
interface Journal extends Intent { phase: 'prepared' | 'complete'; journalSHA256: string }
|
|
27
|
+
export interface OriginalViewPreview {
|
|
28
|
+
scopeSHA256: string; archiveId: string; artifacts: ArtifactKey[]; activeActions: 0
|
|
29
|
+
}
|
|
30
|
+
export interface OriginalViewTransactionStatus {
|
|
31
|
+
status: 'absent' | 'prepared' | 'complete' | 'recovery-invalid'; archiveId?: string; reason?: string
|
|
32
|
+
}
|
|
33
|
+
export function originalViewPaths(files: MacCallHumanFiles, captureId: string) {
|
|
34
|
+
const interval = macIntervalHumanPaths(files, captureId), stem = files.sidecarPath.replace(/\.g2-chunks\.json$/, '')
|
|
35
|
+
return { journal: `${stem}.speaker-call-original-view-transaction.json`, archiveRoot: `${stem}.speaker-call-original-view-archive`, lock: interval.lock }
|
|
36
|
+
}
|
|
37
|
+
function specifications(files: MacCallHumanFiles, captureId: string) {
|
|
38
|
+
const interval = macIntervalHumanPaths(files, captureId), legacy = macCallHumanPaths(files, captureId)
|
|
39
|
+
return {
|
|
40
|
+
sources: [
|
|
41
|
+
{ key: 'base' as const, path: files.sidecarPath, maximum: 32 * MB, optional: false },
|
|
42
|
+
{ key: 'markdown' as const, path: files.meetingPath, maximum: 16 * MB, optional: true },
|
|
43
|
+
{ key: 'ledger' as const, path: legacy.ledger, maximum: 8 * MB, optional: true },
|
|
44
|
+
{ key: 'legacyAnnotation' as const, path: legacy.annotation, maximum: 16 * MB, optional: true },
|
|
45
|
+
{ key: 'legacyJournal' as const, path: legacy.journal, maximum: 64 * MB, optional: true },
|
|
46
|
+
],
|
|
47
|
+
artifacts: [
|
|
48
|
+
{ key: 'packet' as const, path: interval.packet, maximum: 16 * MB, archiveName: 'packet.json' },
|
|
49
|
+
{ key: 'intervalAnnotation' as const, path: interval.annotation, maximum: 16 * MB, archiveName: 'interval-human.json' },
|
|
50
|
+
{ key: 'intervalJournal' as const, path: interval.journal, maximum: 40 * MB, archiveName: 'interval-human-transaction.json' },
|
|
51
|
+
{ key: 'state' as const, path: files.sidecarPath.replace(/\.g2-chunks\.json$/, '.speaker-call-state.json'), maximum: 16 * MB, archiveName: 'state.json' },
|
|
52
|
+
],
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
function canonicalParent(path: string): string {
|
|
56
|
+
if (resolve(path) !== path) fail('absolute canonical path required')
|
|
57
|
+
const parent = dirname(path)
|
|
58
|
+
if (realpathSync(parent) !== parent || !lstatSync(parent).isDirectory()) fail('canonical parent required')
|
|
59
|
+
return parent
|
|
60
|
+
}
|
|
61
|
+
function syncParent(path: string) {
|
|
62
|
+
const fd = openSync(canonicalParent(path), constants.O_RDONLY | constants.O_DIRECTORY | constants.O_NOFOLLOW | constants.O_NONBLOCK)
|
|
63
|
+
try { fsyncSync(fd) } finally { closeSync(fd) }
|
|
64
|
+
}
|
|
65
|
+
function safeRead(path: string, maximum: number, optional = false) {
|
|
66
|
+
const result = readMacCallHumanFile(path, maximum, optional)
|
|
67
|
+
if (result) canonicalParent(path)
|
|
68
|
+
return result
|
|
69
|
+
}
|
|
70
|
+
function privateDirectory(path: string, create: boolean) {
|
|
71
|
+
canonicalParent(path)
|
|
72
|
+
if (create) { try { mkdirSync(path, { mode: 0o700 }); syncParent(path) } catch (e) { if ((e as NodeJS.ErrnoException).code !== 'EEXIST') throw e } }
|
|
73
|
+
const stat = lstatSync(path)
|
|
74
|
+
if (!stat.isDirectory() || stat.isSymbolicLink() || realpathSync(path) !== path || (stat.mode & 0o077) !== 0) fail('private canonical archive directory required')
|
|
75
|
+
}
|
|
76
|
+
function exclusiveCopy(path: string, text: string) {
|
|
77
|
+
canonicalParent(path)
|
|
78
|
+
const existing = safeRead(path, 80 * MB, true)
|
|
79
|
+
if (existing) { if (existing.text !== text) fail('archive collision'); return }
|
|
80
|
+
const temporary = `${path}.${randomUUID()}.tmp`
|
|
81
|
+
const fd = openSync(temporary, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW | constants.O_NONBLOCK, 0o600)
|
|
82
|
+
try { writeFileSync(fd, text); fsyncSync(fd) } finally { closeSync(fd) }
|
|
83
|
+
try { linkSync(temporary, path); syncParent(path) } finally { unlinkSync(temporary); syncParent(path) }
|
|
84
|
+
if (safeRead(path, 80 * MB)!.text !== text) fail('archive copy changed')
|
|
85
|
+
}
|
|
86
|
+
function atomic(path: string, text: string, before: () => void) {
|
|
87
|
+
canonicalParent(path)
|
|
88
|
+
const temporary = `${path}.${randomUUID()}.tmp`
|
|
89
|
+
const fd = openSync(temporary, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW | constants.O_NONBLOCK, 0o600)
|
|
90
|
+
try { writeFileSync(fd, text); fsyncSync(fd) } finally { closeSync(fd) }
|
|
91
|
+
try { before(); renameSync(temporary, path); syncParent(path) } finally { try { unlinkSync(temporary) } catch (e) { if ((e as NodeJS.ErrnoException).code !== 'ENOENT') throw e } }
|
|
92
|
+
}
|
|
93
|
+
function acquire(files: MacCallHumanFiles, captureId: string, recover: boolean) {
|
|
94
|
+
const paths = originalViewPaths(files, captureId), old = safeRead(paths.lock, 4096, true)
|
|
95
|
+
if (old) {
|
|
96
|
+
if (!recover) fail('capture locked')
|
|
97
|
+
const owner = JSON.parse(old.text)
|
|
98
|
+
if (owner.kind !== POLICY || owner.journal !== paths.journal || !Number.isSafeInteger(owner.pid) || owner.pid < 1) fail('foreign capture lock')
|
|
99
|
+
try { process.kill(owner.pid, 0); fail('capture lock owner alive') } catch (e) { if ((e as NodeJS.ErrnoException).code !== 'ESRCH') throw e }
|
|
100
|
+
if (safeRead(paths.lock, 4096)!.text !== old.text) fail('capture lock drift')
|
|
101
|
+
unlinkSync(paths.lock); syncParent(paths.lock)
|
|
102
|
+
}
|
|
103
|
+
canonicalParent(paths.lock)
|
|
104
|
+
const text = JSON.stringify({ kind: POLICY, journal: paths.journal, pid: process.pid, token: randomUUID() })
|
|
105
|
+
const fd = openSync(paths.lock, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW | constants.O_NONBLOCK, 0o600)
|
|
106
|
+
try { writeFileSync(fd, text); fsyncSync(fd) } finally { closeSync(fd) }
|
|
107
|
+
syncParent(paths.lock)
|
|
108
|
+
const assertOwned = () => { if (safeRead(paths.lock, 4096, true)?.text !== text) fail('capture lock drift') }
|
|
109
|
+
return { assertOwned, release: () => { assertOwned(); unlinkSync(paths.lock); syncParent(paths.lock) } }
|
|
110
|
+
}
|
|
111
|
+
function sealed(body: Omit<Journal, 'journalSHA256'>): Journal { return { ...body, journalSHA256: digest(body) } }
|
|
112
|
+
function checkedJournal(files: MacCallHumanFiles, captureId: string, value: unknown): Journal {
|
|
113
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)) fail('transaction integrity')
|
|
114
|
+
const journal = value as Journal, { journalSHA256, phase, ...intent } = journal
|
|
115
|
+
const { scopeSHA256, archiveId, ...scope } = intent
|
|
116
|
+
const specs = specifications(files, captureId)
|
|
117
|
+
if (journal.version !== 1 || journal.policy !== POLICY || journal.captureId !== captureId || !SHA.test(journalSHA256)
|
|
118
|
+
|| digest({ ...intent, phase }) !== journalSHA256 || !['prepared', 'complete'].includes(phase)
|
|
119
|
+
|| !SHA.test(scopeSHA256) || archiveId !== scopeSHA256 || digest(scope) !== scopeSHA256
|
|
120
|
+
|| !Array.isArray(journal.sourcePins) || !Array.isArray(journal.artifacts)
|
|
121
|
+
|| !equal(journal.sourcePins.map(p => p?.key), specs.sources.map(s => s.key))
|
|
122
|
+
|| !equal(journal.artifacts.map(p => p?.key), specs.artifacts.map(s => s.key))
|
|
123
|
+
|| [...journal.sourcePins, ...journal.artifacts].some(p => p.hash !== null && !SHA.test(p.hash))
|
|
124
|
+
|| journal.sourcePins[0].hash === null
|
|
125
|
+
|| journal.previousTransactionSHA256 !== null && !SHA.test(journal.previousTransactionSHA256)) fail('transaction integrity')
|
|
126
|
+
return journal
|
|
127
|
+
}
|
|
128
|
+
function archiveDirectory(files: MacCallHumanFiles, captureId: string, archiveId: string, create: boolean) {
|
|
129
|
+
if (!SHA.test(archiveId)) fail('archive identity')
|
|
130
|
+
const paths = originalViewPaths(files, captureId), directory = join(paths.archiveRoot, archiveId)
|
|
131
|
+
privateDirectory(paths.archiveRoot, create); privateDirectory(directory, create)
|
|
132
|
+
return directory
|
|
133
|
+
}
|
|
134
|
+
function checkSources(files: MacCallHumanFiles, captureId: string, pins: Pin<SourceKey>[]) {
|
|
135
|
+
for (const [index, spec] of specifications(files, captureId).sources.entries()) {
|
|
136
|
+
if ((safeRead(spec.path, spec.maximum, spec.optional)?.hash ?? null) !== pins[index].hash) fail('source changed')
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
/** Complete older journals are archival: their packet pin is intentionally not
|
|
140
|
+
* current. Their integrity and human/ledger postimages must still agree. */
|
|
141
|
+
function checkLegacyJournal(files: MacCallHumanFiles, captureId: string, text: string | null, ledger: string | null, annotation: string | null) {
|
|
142
|
+
if (text === null) return
|
|
143
|
+
const value = JSON.parse(text), { journalSHA256, ...body } = value ?? {}, legacy = macCallHumanPaths(files, captureId)
|
|
144
|
+
if (value?.version !== 1 || value.policy !== 'mac-speaker-call-human-v1' || value.captureId !== captureId || value.phase !== 'complete'
|
|
145
|
+
|| !SHA.test(journalSHA256) || journalSHA256 !== digest(body) || !Array.isArray(value.images) || !Array.isArray(value.pins)
|
|
146
|
+
|| !equal(value.pins.map((p: any) => p?.path), [files.sidecarPath, files.meetingPath, legacy.packet])
|
|
147
|
+
|| !equal(value.pins.map((p: any) => p?.maximum), [32 * MB, 16 * MB, 16 * MB])
|
|
148
|
+
|| value.pins.some((p: any) => p.hash !== null && !SHA.test(p.hash))
|
|
149
|
+
|| !equal(value.images.map((i: any) => i?.path), [legacy.ledger, legacy.annotation])
|
|
150
|
+
|| value.images.some((i: any) => i.before !== null && typeof i.before !== 'string' || typeof i.after !== 'string' || Buffer.byteLength(i.before ?? '') > 16 * MB || Buffer.byteLength(i.after) > 16 * MB)
|
|
151
|
+
|| value.images[0].after !== ledger || value.images[1].after !== annotation) fail('legacy human recovery required')
|
|
152
|
+
}
|
|
153
|
+
function validateInactive(files: MacCallHumanFiles, captureId: string, sources: Array<string | null>, artifacts: Array<string | null>) {
|
|
154
|
+
const [baseJSON, markdownSource, ledger, legacyAnnotation, legacyJournal] = sources
|
|
155
|
+
if (baseJSON === null) fail('missing immutable source')
|
|
156
|
+
const markdown = markdownSource ?? ''
|
|
157
|
+
const base = JSON.parse(baseJSON)
|
|
158
|
+
if (base?.sessionId !== captureId) fail('capture source mismatch')
|
|
159
|
+
checkLegacyJournal(files, captureId, legacyJournal, ledger, legacyAnnotation)
|
|
160
|
+
const baseline: MacSpeakerCallView = { doc: base, metadata: { status: 'base', reason: 'original-view', mode: 'strict', changedChunks: 0, changedWords: 0, groups: [], localization: 'estimated-ASR-with-native-support', speakerPurityVerified: false } }
|
|
161
|
+
const legacy = applyMacCallHumanAnnotation(baseJSON, ledger ?? '', legacyAnnotation, baseline)
|
|
162
|
+
let packet: MacSpeakerCallPacket | null = null
|
|
163
|
+
if (artifacts[0] !== null) { try { packet = JSON.parse(artifacts[0]) } catch { /* Preserve unusable automatic bytes in the archive too. */ } }
|
|
164
|
+
const view = applyMacIntervalHumanAnnotation({ baseJSON, markdown, ledgerJSONL: ledger ?? '', legacyAnnotationJSON: legacyAnnotation, annotationJSON: artifacts[1], packet, view: legacy })
|
|
165
|
+
if (view.metadata.intervalHumanAnnotation?.activeActions.length) fail('active interval human actions must be undone first')
|
|
166
|
+
if (artifacts[2] !== null) {
|
|
167
|
+
const value = JSON.parse(artifacts[2]), { journalSHA256, ...body } = value ?? {}, interval = macIntervalHumanPaths(files, captureId)
|
|
168
|
+
if (value?.version !== 1 || value.policy !== 'mac-speaker-call-interval-human-v1' || value.captureId !== captureId || value.phase !== 'complete'
|
|
169
|
+
|| !SHA.test(journalSHA256) || journalSHA256 !== digest(body) || value.after !== artifacts[1]
|
|
170
|
+
|| value.before !== null && typeof value.before !== 'string' || typeof value.after !== 'string'
|
|
171
|
+
|| Buffer.byteLength(value.before ?? '') > 16 * MB || Buffer.byteLength(value.after) > 16 * MB
|
|
172
|
+
|| !equal(value.pins?.map((p: any) => p.path), [files.sidecarPath, files.meetingPath, interval.ledger, interval.legacyAnnotation, interval.packet])
|
|
173
|
+
|| !equal(value.pins.map((p: any) => p.maximum), [32 * MB, 16 * MB, 8 * MB, 16 * MB, 16 * MB])
|
|
174
|
+
|| value.pins.some((p: any) => p.hash !== null && !SHA.test(p.hash))
|
|
175
|
+
|| !equal(value.pins.slice(0, 4).map((p: any) => p.hash), [hash(baseJSON), markdownSource === null ? null : hash(markdown), ledger === null ? null : hash(ledger), legacyAnnotation === null ? null : hash(legacyAnnotation)])) fail('interval human recovery required')
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
function snapshot(files: MacCallHumanFiles, captureId: string, ownedPreparation = false) {
|
|
179
|
+
const specs = specifications(files, captureId), paths = originalViewPaths(files, captureId)
|
|
180
|
+
canonicalParent(paths.journal)
|
|
181
|
+
const prior = safeRead(paths.journal, MB, true)
|
|
182
|
+
let status: OriginalViewTransactionStatus
|
|
183
|
+
if (ownedPreparation && prior === null) status = { status: 'absent' }
|
|
184
|
+
else if (ownedPreparation) {
|
|
185
|
+
const previous = checkedJournal(files, captureId, JSON.parse(prior!.text))
|
|
186
|
+
if (previous.phase === 'complete') { verifyArchive(files, captureId, previous); status = { status: 'complete' } }
|
|
187
|
+
else status = inspectOriginalViewTransaction(files, captureId)
|
|
188
|
+
} else status = inspectOriginalViewTransaction(files, captureId)
|
|
189
|
+
if (status.status === 'prepared' || status.status === 'recovery-invalid') fail('original view recovery required')
|
|
190
|
+
const sources = specs.sources.map(s => safeRead(s.path, s.maximum, s.optional))
|
|
191
|
+
const artifacts = specs.artifacts.map(s => safeRead(s.path, s.maximum, true))
|
|
192
|
+
const intervalJournal = inspectMacIntervalHumanJournal(files, captureId)
|
|
193
|
+
if (intervalJournal.status === 'prepared' || intervalJournal.status === 'recovery-invalid') fail('interval human recovery required')
|
|
194
|
+
validateInactive(files, captureId, sources.map(s => s?.text ?? null), artifacts.map(a => a?.text ?? null))
|
|
195
|
+
const body: Omit<Intent, 'scopeSHA256' | 'archiveId'> = { version: 1, policy: POLICY, captureId,
|
|
196
|
+
sourcePins: specs.sources.map((s, i) => ({ key: s.key, hash: sources[i]?.hash ?? null })),
|
|
197
|
+
artifacts: specs.artifacts.map((s, i) => ({ key: s.key, hash: artifacts[i]?.hash ?? null })), previousTransactionSHA256: prior?.hash ?? null }
|
|
198
|
+
const scopeSHA256 = digest(body), intent: Intent = { ...body, scopeSHA256, archiveId: scopeSHA256 }
|
|
199
|
+
checkSources(files, captureId, intent.sourcePins)
|
|
200
|
+
for (const [index, spec] of specs.artifacts.entries()) if ((safeRead(spec.path, spec.maximum, true)?.hash ?? null) !== intent.artifacts[index].hash) fail('artifact changed')
|
|
201
|
+
if ((safeRead(paths.journal, MB, true)?.hash ?? null) !== intent.previousTransactionSHA256) fail('transaction changed')
|
|
202
|
+
return { intent, sources, artifacts, prior }
|
|
203
|
+
}
|
|
204
|
+
export function previewOriginalViewTransaction(files: MacCallHumanFiles, captureId: string): OriginalViewPreview {
|
|
205
|
+
const { intent } = snapshot(files, captureId)
|
|
206
|
+
return { scopeSHA256: intent.scopeSHA256, archiveId: intent.archiveId, artifacts: intent.artifacts.filter(a => a.hash !== null).map(a => a.key), activeActions: 0 }
|
|
207
|
+
}
|
|
208
|
+
function verifyArchive(files: MacCallHumanFiles, captureId: string, journal: Journal) {
|
|
209
|
+
const archive = archiveDirectory(files, captureId, journal.archiveId, false), specs = specifications(files, captureId)
|
|
210
|
+
// Remove the old seal before resealing a phase; it is never part of the body.
|
|
211
|
+
const { journalSHA256: _, ...preparedBody } = journal
|
|
212
|
+
const intentText = `${JSON.stringify(sealed({ ...preparedBody, phase: 'prepared' }))}\n`
|
|
213
|
+
if (safeRead(join(archive, 'intent.json'), MB)!.text !== intentText) fail('archived intent changed')
|
|
214
|
+
const completion = safeRead(join(archive, 'complete.json'), MB, journal.phase !== 'complete')
|
|
215
|
+
if (completion && completion.text !== `${JSON.stringify(sealed({ ...preparedBody, phase: 'complete' }))}\n`) fail('archived completion changed')
|
|
216
|
+
const artifacts = specs.artifacts.map((s, i) => {
|
|
217
|
+
const read = safeRead(join(archive, s.archiveName), s.maximum, journal.artifacts[i].hash === null)
|
|
218
|
+
if ((read?.hash ?? null) !== journal.artifacts[i].hash) fail('archived artifact changed')
|
|
219
|
+
return read?.text ?? null
|
|
220
|
+
})
|
|
221
|
+
if ((safeRead(join(archive, 'previous-transaction.json'), MB, journal.previousTransactionSHA256 === null)?.hash ?? null) !== journal.previousTransactionSHA256) fail('archived prior transaction changed')
|
|
222
|
+
return artifacts
|
|
223
|
+
}
|
|
224
|
+
function verifyPrepared(files: MacCallHumanFiles, captureId: string, journal: Journal, expectedText: string) {
|
|
225
|
+
const paths = originalViewPaths(files, captureId), specs = specifications(files, captureId), artifacts = verifyArchive(files, captureId, journal)
|
|
226
|
+
checkSources(files, captureId, journal.sourcePins)
|
|
227
|
+
for (const [index, spec] of specs.artifacts.entries()) {
|
|
228
|
+
const current = safeRead(spec.path, spec.maximum, true)
|
|
229
|
+
if (current && current.hash !== journal.artifacts[index].hash) fail('later interval artifact changed')
|
|
230
|
+
}
|
|
231
|
+
const sources = specs.sources.map(s => safeRead(s.path, s.maximum, s.optional)?.text ?? null)
|
|
232
|
+
validateInactive(files, captureId, sources, artifacts)
|
|
233
|
+
checkSources(files, captureId, journal.sourcePins)
|
|
234
|
+
if (safeRead(paths.journal, MB)!.text !== expectedText) fail('transaction changed')
|
|
235
|
+
}
|
|
236
|
+
export function inspectOriginalViewTransaction(files: MacCallHumanFiles, captureId: string): OriginalViewTransactionStatus {
|
|
237
|
+
try {
|
|
238
|
+
const paths = originalViewPaths(files, captureId), stored = safeRead(paths.journal, MB, true)
|
|
239
|
+
if (!stored) {
|
|
240
|
+
const lock = safeRead(paths.lock, 4096, true)
|
|
241
|
+
if (lock) {
|
|
242
|
+
let owner: any
|
|
243
|
+
try { owner = JSON.parse(lock.text) } catch { /* Foreign lock handling belongs to its owner. */ }
|
|
244
|
+
if (owner?.kind === POLICY && owner.journal === paths.journal) return { status: 'recovery-invalid', reason: 'Original-view lock without a durable intent; explicit dead-owner recovery required' }
|
|
245
|
+
}
|
|
246
|
+
return { status: 'absent' }
|
|
247
|
+
}
|
|
248
|
+
const journal = checkedJournal(files, captureId, JSON.parse(stored.text))
|
|
249
|
+
if (journal.phase === 'prepared') verifyPrepared(files, captureId, journal, stored.text)
|
|
250
|
+
else {
|
|
251
|
+
verifyArchive(files, captureId, journal)
|
|
252
|
+
if (safeRead(paths.journal, MB)!.text !== stored.text) fail('transaction changed')
|
|
253
|
+
const lock = safeRead(paths.lock, 4096, true)
|
|
254
|
+
if (lock) {
|
|
255
|
+
let owner: any
|
|
256
|
+
try { owner = JSON.parse(lock.text) } catch { /* A different operation owns malformed/foreign locks. */ }
|
|
257
|
+
if (owner?.kind === POLICY && owner.journal === paths.journal) return { status: 'recovery-invalid', archiveId: journal.archiveId, reason: 'Original-view capture lock requires explicit owner recovery' }
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
// Completed receipts are historical. They deliberately do not pin current
|
|
261
|
+
// BASE, Markdown, ledger, legacy decisions, or newly generated packets.
|
|
262
|
+
return { status: journal.phase, archiveId: journal.archiveId }
|
|
263
|
+
} catch (error) { return { status: 'recovery-invalid', reason: error instanceof Error ? error.message : 'Invalid original-view transaction' } }
|
|
264
|
+
}
|
|
265
|
+
function run(files: MacCallHumanFiles, captureId: string, journal: Journal, expectedText: string, assertOwned: () => void, afterWrite?: (index: number) => void) {
|
|
266
|
+
const paths = originalViewPaths(files, captureId), specs = specifications(files, captureId)
|
|
267
|
+
const fence = () => { assertOwned(); verifyPrepared(files, captureId, journal, expectedText) }
|
|
268
|
+
if (journal.phase === 'complete') { assertOwned(); verifyArchive(files, captureId, journal); return }
|
|
269
|
+
fence()
|
|
270
|
+
for (const [index, spec] of specs.artifacts.entries()) {
|
|
271
|
+
fence()
|
|
272
|
+
if (safeRead(spec.path, spec.maximum, true)) { unlinkSync(spec.path); syncParent(spec.path) }
|
|
273
|
+
afterWrite?.(index)
|
|
274
|
+
fence()
|
|
275
|
+
for (const earlier of specs.artifacts.slice(0, index + 1)) if (safeRead(earlier.path, earlier.maximum, true)) fail('removed artifact reappeared')
|
|
276
|
+
}
|
|
277
|
+
const { journalSHA256: _, ...body } = journal, complete = `${JSON.stringify(sealed({ ...body, phase: 'complete' }))}\n`
|
|
278
|
+
fence()
|
|
279
|
+
for (const spec of specs.artifacts) if (safeRead(spec.path, spec.maximum, true)) fail('removed artifact reappeared')
|
|
280
|
+
exclusiveCopy(join(archiveDirectory(files, captureId, journal.archiveId, false), 'complete.json'), complete)
|
|
281
|
+
afterWrite?.(specs.artifacts.length)
|
|
282
|
+
atomic(paths.journal, complete, () => { fence(); for (const spec of specs.artifacts) if (safeRead(spec.path, spec.maximum, true)) fail('removed artifact reappeared') })
|
|
283
|
+
if (safeRead(paths.journal, MB)!.text !== complete) fail('completed transaction changed')
|
|
284
|
+
}
|
|
285
|
+
export function commitOriginalViewTransaction(input: { files: MacCallHumanFiles; captureId: string; expectedScopeSHA256: string; afterWrite?: (index: number) => void }): { archiveId: string; scopeSHA256: string } {
|
|
286
|
+
if (!SHA.test(input.expectedScopeSHA256)) fail('explicit reviewed scope required')
|
|
287
|
+
const guard = acquire(input.files, input.captureId, false)
|
|
288
|
+
try {
|
|
289
|
+
const state = snapshot(input.files, input.captureId, true)
|
|
290
|
+
if (state.intent.scopeSHA256 !== input.expectedScopeSHA256) fail('reviewed source changed')
|
|
291
|
+
const paths = originalViewPaths(input.files, input.captureId), archive = archiveDirectory(input.files, input.captureId, state.intent.archiveId, true)
|
|
292
|
+
const specs = specifications(input.files, input.captureId), journal = sealed({ ...state.intent, phase: 'prepared' }), text = `${JSON.stringify(journal)}\n`
|
|
293
|
+
state.artifacts.forEach((a, i) => { if (a) exclusiveCopy(join(archive, specs.artifacts[i].archiveName), a.text) })
|
|
294
|
+
if (state.prior) exclusiveCopy(join(archive, 'previous-transaction.json'), state.prior.text)
|
|
295
|
+
exclusiveCopy(join(archive, 'intent.json'), text)
|
|
296
|
+
verifyArchive(input.files, input.captureId, journal)
|
|
297
|
+
atomic(paths.journal, text, () => {
|
|
298
|
+
guard.assertOwned(); checkSources(input.files, input.captureId, state.intent.sourcePins)
|
|
299
|
+
for (const [i, spec] of specs.artifacts.entries()) if ((safeRead(spec.path, spec.maximum, true)?.hash ?? null) !== state.intent.artifacts[i].hash) fail('artifact changed')
|
|
300
|
+
if ((safeRead(paths.journal, MB, true)?.hash ?? null) !== state.intent.previousTransactionSHA256) fail('transaction changed')
|
|
301
|
+
})
|
|
302
|
+
input.afterWrite?.(-1) // The durable intent exists; no active artifact has moved.
|
|
303
|
+
run(input.files, input.captureId, journal, text, guard.assertOwned, input.afterWrite)
|
|
304
|
+
return { archiveId: state.intent.archiveId, scopeSHA256: state.intent.scopeSHA256 }
|
|
305
|
+
} finally { guard.release() }
|
|
306
|
+
}
|
|
307
|
+
export function recoverOriginalViewTransaction(files: MacCallHumanFiles, captureId: string, afterWrite?: (index: number) => void): void {
|
|
308
|
+
const paths = originalViewPaths(files, captureId), stored = safeRead(paths.journal, MB, true)
|
|
309
|
+
if (!stored) {
|
|
310
|
+
const lock = safeRead(paths.lock, 4096, true)
|
|
311
|
+
if (!lock) return
|
|
312
|
+
let owner: any
|
|
313
|
+
try { owner = JSON.parse(lock.text) } catch { return }
|
|
314
|
+
if (owner?.kind !== POLICY || owner.journal !== paths.journal) return
|
|
315
|
+
// A crash before the durable intent moved nothing. Only a proven dead lock
|
|
316
|
+
// owned by this operation may be cleared; no new intent is submitted.
|
|
317
|
+
const orphanGuard = acquire(files, captureId, true)
|
|
318
|
+
try { if (safeRead(paths.journal, MB, true)) fail('transaction appeared during orphan recovery') } finally { orphanGuard.release() }
|
|
319
|
+
return
|
|
320
|
+
}
|
|
321
|
+
const guard = acquire(files, captureId, true)
|
|
322
|
+
try {
|
|
323
|
+
if (safeRead(paths.journal, MB)!.text !== stored.text) fail('transaction changed')
|
|
324
|
+
const journal = checkedJournal(files, captureId, JSON.parse(stored.text))
|
|
325
|
+
if (journal.phase === 'complete') { verifyArchive(files, captureId, journal); return }
|
|
326
|
+
run(files, captureId, journal, stored.text, guard.assertOwned, afterWrite)
|
|
327
|
+
} finally { guard.release() }
|
|
328
|
+
}
|