@gotcos/glasses-server 6.94.4 → 6.98.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 +147 -0
- package/README.md +52 -0
- package/managed-runtime-contract.json +13 -0
- package/package.json +3 -1
- package/server/index.ts +19 -1
- 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/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 +36 -0
- package/server/lib/job-audit-caller.ts +20 -0
- 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 +453 -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 +570 -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 +44 -26
- 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-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-speaker-review.ts +182 -17
- 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/speaker-shadow-decide.ts +503 -0
- package/server/lib/speaker-shadow-models.ts +150 -0
- package/server/lib/speaker-shadow-numeric.ts +143 -0
- 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 +113 -0
- package/server/lib/speaker-shadow-timeline.ts +210 -0
- package/server/lib/speaker-shadow-worker.ts +312 -0
- package/server/lib/speaker-shadow.ts +788 -0
- package/server/lib/token-audit.ts +36 -6
- package/server/lib/transcript-cleanup.ts +3 -2
- package/server/lib/voice-directory.ts +8 -4
- 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/health.ts +5 -0
- package/server/routes/live-cues.ts +36 -14
- package/server/routes/meeting.ts +506 -49
- package/server/routes/speaker-shadow.ts +79 -0
- package/server/routes/transcribe-stream.ts +114 -33
- package/server/routes/voice.ts +3 -0
- 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
|
@@ -9,11 +9,14 @@ export class MacParakeetUnavailableError extends Error {
|
|
|
9
9
|
readonly status = 503
|
|
10
10
|
readonly retryable = true
|
|
11
11
|
readonly model = 'parakeet-ultra'
|
|
12
|
-
readonly reason: `mac_parakeet_${ParakeetFailure}` | `phone_parakeet_${ParakeetFailure}`
|
|
12
|
+
readonly reason: `mac_parakeet_${ParakeetFailure | 'internal'}` | `phone_parakeet_${ParakeetFailure | 'internal'}`
|
|
13
13
|
|
|
14
14
|
constructor(cause: unknown, source: 'Mac' | 'iPhone' = 'Mac') {
|
|
15
|
-
|
|
16
|
-
|
|
15
|
+
// Anything but a ParakeetError never reached the CLI. Calling it cli_failed sent a stuck
|
|
16
|
+
// meeting's diagnosis to a tool that had exited 0 on every window (2026-10-31).
|
|
17
|
+
const reason = cause instanceof ParakeetError ? cause.reason : 'internal'
|
|
18
|
+
const diagnostic = cause instanceof ParakeetError && cause.reason === 'cli_failed' ? `; ${cause.message}` : ''
|
|
19
|
+
super(`Selected Parakeet Ultra could not transcribe this ${source} chunk (${reason})${diagnostic}; audio is preserved for retry`)
|
|
17
20
|
this.name = 'MacParakeetUnavailableError'
|
|
18
21
|
this.reason = `${source === 'Mac' ? 'mac' : 'phone'}_parakeet_${reason}`
|
|
19
22
|
}
|
|
@@ -0,0 +1,371 @@
|
|
|
1
|
+
/** Explicit human annotations over an immutable automatic BASE. These are not
|
|
2
|
+
* acoustic assertions or enrollment. The authenticated route owns authorization;
|
|
3
|
+
* hashes bind the reviewed source and ledger, not the identity of a human. */
|
|
4
|
+
import { createHash, randomUUID } from 'node:crypto'
|
|
5
|
+
import { closeSync, constants, existsSync, fstatSync, fsyncSync, lstatSync, mkdirSync, openSync, readSync, realpathSync, renameSync, unlinkSync, writeFileSync, type Stats } from 'node:fs'
|
|
6
|
+
import { dirname } from 'node:path'
|
|
7
|
+
import { dataPath } from './data-dir.js'
|
|
8
|
+
import { checkSpeakerName } from './speaker-name.js'
|
|
9
|
+
import { hasMacSpeakerProtectedRows, isMacSpeakerHumanProtected } from './mac-speaker-protection.js'
|
|
10
|
+
import { boundWords, renderMarkdown } from './mac-speaker-repair-render.js'
|
|
11
|
+
import type { MacSpeakerCallPacket, MacSpeakerCallView } from './mac-speaker-call-view.js'
|
|
12
|
+
|
|
13
|
+
type Doc = Record<string, any>
|
|
14
|
+
type Path = Array<string | number>
|
|
15
|
+
const VOICE = /^Voice (?:[1-9]|[1-9]\d)$/
|
|
16
|
+
const SHA = /^[a-f0-9]{64}$/
|
|
17
|
+
const PREFIX = 'mac-speaker-call-human-v1'
|
|
18
|
+
const hash = (value: string) => createHash('sha256').update(value).digest('hex')
|
|
19
|
+
const digest = (value: unknown) => hash(JSON.stringify(value))
|
|
20
|
+
const fail = (reason: string): never => { throw new Error(`Mac call human: ${reason}`) }
|
|
21
|
+
const equal = (a: unknown, b: unknown) => JSON.stringify(a) === JSON.stringify(b)
|
|
22
|
+
/** Human annotations require a person's name, never a machine placeholder.
|
|
23
|
+
* Keep this local: the shared profile-name checker has other consumers. */
|
|
24
|
+
export function isMacCallHumanName(value: unknown): value is string {
|
|
25
|
+
return typeof value === 'string' && value === value.trim() && checkSpeakerName(value).ok
|
|
26
|
+
&& !/^(?:Ext|Unknown|Unidentified(?:\s+\d+)?|Speaker(?:\s*\d+)?|MU)$/i.test(value)
|
|
27
|
+
}
|
|
28
|
+
interface Member { rawChunkIndex: number; position: number; before: string; text: string; elapsedMs: number; baseBefore?: string }
|
|
29
|
+
interface Field { path: Path; before: string; rawChunkIndices: number[] }
|
|
30
|
+
export interface MacCallHumanScope {
|
|
31
|
+
kind: 'automatic' | 'human'
|
|
32
|
+
reviewBasis: 'accepted-acoustic-group' | 'automatic-label-only' | 'human-authored'
|
|
33
|
+
identityId: string; displayLabel: string; scopeSHA256: string
|
|
34
|
+
members: Member[]; fields: Field[]
|
|
35
|
+
baseSHA256: string; markdownSHA256: string; ledgerSHA256: string; annotationSHA256: string | null
|
|
36
|
+
packetSHA256: string | null; generation: string; packetRevision: number
|
|
37
|
+
}
|
|
38
|
+
// One exact-input entry, capped independently of the per-scope field bound.
|
|
39
|
+
// Frozen outputs cannot be poisoned by a caller retaining a returned reference.
|
|
40
|
+
let scopeCache: { key: string; scopes: MacCallHumanScope[] } | null = null
|
|
41
|
+
function cacheScopes(key: string, scopes: MacCallHumanScope[]): MacCallHumanScope[] {
|
|
42
|
+
let fields = 0
|
|
43
|
+
for (const scope of scopes) {
|
|
44
|
+
fields += scope.fields.length
|
|
45
|
+
for (const member of scope.members) Object.freeze(member)
|
|
46
|
+
for (const field of scope.fields) { Object.freeze(field.path); Object.freeze(field.rawChunkIndices); Object.freeze(field) }
|
|
47
|
+
Object.freeze(scope.members); Object.freeze(scope.fields); Object.freeze(scope)
|
|
48
|
+
}
|
|
49
|
+
Object.freeze(scopes)
|
|
50
|
+
scopeCache = fields <= 250000 ? { key, scopes } : null
|
|
51
|
+
return scopes
|
|
52
|
+
}
|
|
53
|
+
interface Action {
|
|
54
|
+
id: string; at: string; identityId: string; to: string; scopeSHA256: string
|
|
55
|
+
members: Member[]; fields: Field[]; fieldScopeSHA256: string; ledgerStart: number; ledgerEnd: number; ledgerBeforeSHA256: string; ledgerAfterSHA256: string
|
|
56
|
+
}
|
|
57
|
+
interface Annotation {
|
|
58
|
+
version: 1; policy: typeof PREFIX; captureId: string; revision: number
|
|
59
|
+
baseSHA256: string; ledgerSHA256: string; actions: Action[]; annotationSHA256: string
|
|
60
|
+
}
|
|
61
|
+
export interface MacCallHumanFiles { sidecarPath: string; meetingPath: string }
|
|
62
|
+
export function macCallHumanPaths(files: MacCallHumanFiles, captureId: string) {
|
|
63
|
+
if (!files.sidecarPath.endsWith('.g2-chunks.json') || !/^mac_[A-Za-z0-9_-]{1,100}$/.test(captureId)) fail('capture paths')
|
|
64
|
+
const stem = files.sidecarPath.replace(/\.g2-chunks\.json$/, '')
|
|
65
|
+
return { annotation: `${stem}.speaker-call-human.json`, packet: `${stem}.speaker-call.json`, journal: `${stem}.speaker-call-human-transaction.json`,
|
|
66
|
+
ledger: dataPath('meeting-corrections', `${captureId}.jsonl`), lock: `${dirname(files.sidecarPath)}/.${captureId}.speaker-call.lock` }
|
|
67
|
+
}
|
|
68
|
+
function at(doc: Doc, path: Path): any {
|
|
69
|
+
if (!Array.isArray(path) || !path.length || path.length > 8 || path.some(p => typeof p !== 'number' && typeof p !== 'string' || ['__proto__', 'prototype', 'constructor'].includes(String(p)))) fail('field path')
|
|
70
|
+
let row = doc
|
|
71
|
+
for (const part of path.slice(0, -1)) { if (!row || typeof row !== 'object' || !Object.hasOwn(row, part)) fail('field missing'); row = row[part] }
|
|
72
|
+
if (!row || typeof row !== 'object' || !Object.hasOwn(row, path.at(-1)!)) fail('field missing')
|
|
73
|
+
return row
|
|
74
|
+
}
|
|
75
|
+
function mapped(doc: Doc): Member[] {
|
|
76
|
+
if (!Array.isArray(doc.chunks) || !Array.isArray(doc.chunkEntries) || doc.chunkEntries.length > 6000) fail('source bounds')
|
|
77
|
+
const result: Member[] = []
|
|
78
|
+
for (const [raw, e] of doc.chunkEntries.entries()) {
|
|
79
|
+
if (e.chunkIndex !== raw || !e.chunk || typeof e.chunk.text !== 'string') fail('raw source')
|
|
80
|
+
if (!e.chunk.text.trim()) continue
|
|
81
|
+
const position = result.length, root = doc.chunks[position]
|
|
82
|
+
if (!root || ['text', 'elapsed', 'audioSha256', 'audioDurationMs'].some(k => !equal(root[k], e.chunk[k]))) fail('source join')
|
|
83
|
+
result.push({ rawChunkIndex: raw, position, before: root.speaker, text: root.text.slice(0, 600), elapsedMs: root.elapsed })
|
|
84
|
+
}
|
|
85
|
+
if (result.length !== doc.chunks.length) fail('source join')
|
|
86
|
+
return result
|
|
87
|
+
}
|
|
88
|
+
function seal(body: Omit<Annotation, 'annotationSHA256'>): Annotation { return { ...body, annotationSHA256: digest(body) } }
|
|
89
|
+
function selectedFields(scope: MacCallHumanScope, selected: Set<number>): Field[] {
|
|
90
|
+
if (scope.kind === 'human' && scope.fields.some(f => f.rawChunkIndices.some(raw => selected.has(raw)) && !f.rawChunkIndices.every(raw => selected.has(raw)))) fail('selection cuts a previously human-confirmed word')
|
|
91
|
+
return scope.fields.filter(f => f.rawChunkIndices.every(raw => selected.has(raw)))
|
|
92
|
+
}
|
|
93
|
+
/** Preview the same exact label renderer used after publication. A transcript
|
|
94
|
+
* mismatch refuses the action before any human intent or annotation is written. */
|
|
95
|
+
export function previewMacCallHumanAnnotation(baseJSON: string, markdown: string, view: MacSpeakerCallView, scope: MacCallHumanScope, selectedRawChunkIndices: number[], to: string): { members: Member[]; fields: number } {
|
|
96
|
+
if (!isMacCallHumanName(to) || !selectedRawChunkIndices.length || new Set(selectedRawChunkIndices).size !== selectedRawChunkIndices.length) fail('explicit name and source selection required')
|
|
97
|
+
const selected = new Set(selectedRawChunkIndices), members = scope.members.filter(m => selected.has(m.rawChunkIndex)), doc = structuredClone(view.doc)
|
|
98
|
+
if (members.length !== selected.size) fail('selection outside reviewed group')
|
|
99
|
+
const fields = selectedFields(scope, selected)
|
|
100
|
+
for (const field of fields) at(doc, field.path)[field.path.at(-1)!] = to
|
|
101
|
+
renderMarkdown(markdown, boundWords(JSON.parse(baseJSON), doc))
|
|
102
|
+
return { members, fields: fields.length }
|
|
103
|
+
}
|
|
104
|
+
function validateAnnotation(value: unknown, baseJSON: string, ledger: string): Annotation {
|
|
105
|
+
const a = value as Annotation, base = JSON.parse(baseJSON), map = mapped(base), byRaw = new Map(map.map(m => [m.rawChunkIndex, m]))
|
|
106
|
+
if (!a || a.version !== 1 || a.policy !== PREFIX || a.captureId !== base.sessionId || a.baseSHA256 !== hash(baseJSON) || a.ledgerSHA256 !== hash(ledger) || !Number.isSafeInteger(a.revision) || a.revision < 1 || !Array.isArray(a.actions) || a.actions.length !== a.revision || a.actions.length > 6000) fail('annotation source binding')
|
|
107
|
+
const { annotationSHA256, ...body } = a
|
|
108
|
+
if (!SHA.test(annotationSHA256) || annotationSHA256 !== digest(body)) fail('annotation integrity')
|
|
109
|
+
let previousEnd = 0, fieldCount = 0
|
|
110
|
+
const used = new Map<string, string>(), selected = new Map<number, string>()
|
|
111
|
+
for (const action of a.actions) {
|
|
112
|
+
if (!action || typeof action.id !== 'string' || !action.id || !isMacCallHumanName(action.to) || !SHA.test(action.scopeSHA256) || !Array.isArray(action.members) || !action.members.length || action.members.length > 6000 || new Set(action.members.map(m => m.rawChunkIndex)).size !== action.members.length || !Array.isArray(action.fields) || !action.fields.length || (fieldCount += action.fields.length) > 40000 || !Number.isSafeInteger(action.ledgerStart) || !Number.isSafeInteger(action.ledgerEnd) || action.ledgerStart < previousEnd || action.ledgerEnd <= action.ledgerStart || action.ledgerEnd > ledger.length || action.ledgerBeforeSHA256 !== hash(ledger.slice(0, action.ledgerStart)) || action.ledgerAfterSHA256 !== hash(ledger.slice(0, action.ledgerEnd))) fail('annotation action')
|
|
113
|
+
previousEnd = action.ledgerEnd
|
|
114
|
+
const rows = ledger.slice(action.ledgerStart, action.ledgerEnd).trim().split('\n').map(line => JSON.parse(line))
|
|
115
|
+
const groups = new Map<string, number[]>()
|
|
116
|
+
for (const member of action.members) {
|
|
117
|
+
const actual = byRaw.get(member.rawChunkIndex), c = base.chunkEntries[member.rawChunkIndex]?.chunk, root = base.chunks[member.position]
|
|
118
|
+
const baseBefore = member.baseBefore ?? member.before
|
|
119
|
+
const expected = { ...member }; delete expected.baseBefore; expected.before = baseBefore
|
|
120
|
+
if (!actual || !equal(actual, expected) || !VOICE.test(baseBefore) || c.speaker !== baseBefore || hasMacSpeakerProtectedRows(c) || hasMacSpeakerProtectedRows(root)
|
|
121
|
+
|| (member.baseBefore === undefined ? selected.has(member.rawChunkIndex) : selected.get(member.rawChunkIndex) !== member.before)) fail('human member source')
|
|
122
|
+
const positions = groups.get(member.before) ?? []; positions.push(member.position); groups.set(member.before, positions)
|
|
123
|
+
}
|
|
124
|
+
if (action.fieldScopeSHA256 !== digest({ identityId: action.identityId, members: action.members, fields: action.fields })) fail('human reviewed field scope')
|
|
125
|
+
const expectedRows = [...groups].flatMap(([from, chunks], index) => ['intent', 'applied', 'confirmed-chunks'].map(phase => ({ id: `${action.id}:${index}`, batchId: action.id, phase, at: action.at, from, to: action.to, chunks, scope: 'meeting', source: PREFIX, fieldScopeSHA256: action.fieldScopeSHA256 })))
|
|
126
|
+
if (!equal(rows, expectedRows)) fail('human ledger evidence')
|
|
127
|
+
const actionRaw = new Set(action.members.map(m => m.rawChunkIndex))
|
|
128
|
+
for (const field of action.fields) {
|
|
129
|
+
const key = JSON.stringify(field.path), row = at(base, field.path), last = field.path.at(-1)!
|
|
130
|
+
if (last !== 'speaker' || !VOICE.test(field.before) || row[last] !== field.before || !Array.isArray(field.rawChunkIndices) || !field.rawChunkIndices.length || field.rawChunkIndices.some(raw => !actionRaw.has(raw)) || isMacSpeakerHumanProtected(row)
|
|
131
|
+
|| used.has(key) && field.rawChunkIndices.some(raw => selected.get(raw) !== used.get(key))) fail('human field source')
|
|
132
|
+
const raw = field.rawChunkIndices[0], member = byRaw.get(raw)!
|
|
133
|
+
if (field.path[0] === 'chunks') { if (field.rawChunkIndices.length !== 1 || field.path[1] !== member.position || ![3, 5].includes(field.path.length) || field.path.length === 5 && !['segments', 'finalSegments', 'words'].includes(String(field.path[2]))) fail('human compacted field') }
|
|
134
|
+
else if (field.path[0] === 'chunkEntries') { if (field.rawChunkIndices.length !== 1 || field.path[1] !== raw || field.path[2] !== 'chunk' || ![4, 6].includes(field.path.length) || field.path.length === 6 && !['segments', 'finalSegments', 'words'].includes(String(field.path[3]))) fail('human raw field') }
|
|
135
|
+
else if (field.path[0] !== 'batchSegments' || field.path.length !== 5 || field.path[2] !== 'speakerWords') fail('human field scope')
|
|
136
|
+
used.set(key, action.to)
|
|
137
|
+
}
|
|
138
|
+
// A scoped confirmation is never permitted to claim an unmodified root.
|
|
139
|
+
for (const member of action.members) if (!action.fields.some(f => equal(f.path, ['chunks', member.position, 'speaker']))) fail('human root field missing')
|
|
140
|
+
for (const member of action.members) selected.set(member.rawChunkIndex, action.to)
|
|
141
|
+
}
|
|
142
|
+
requireHumanLedgerCoverage(ledger, a)
|
|
143
|
+
return a
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/** A capture ledger is shared by its copies, but an annotation is bound to one
|
|
147
|
+
* immutable BASE. Never append from a copy that is missing earlier decisions.
|
|
148
|
+
* Only validated action ranges cover these rows; duplicated ids outside their
|
|
149
|
+
* original range cannot confer coverage. Replication is a separate operation. */
|
|
150
|
+
function requireHumanLedgerCoverage(ledger: string, annotation: Annotation | null): void {
|
|
151
|
+
let offset = 0, index = 0
|
|
152
|
+
const actions = annotation?.actions ?? [], lines = ledger.split('\n')
|
|
153
|
+
if (lines.length > 20001) fail('human ledger row budget')
|
|
154
|
+
for (const line of lines) {
|
|
155
|
+
while (index < actions.length && actions[index].ledgerEnd <= offset) index++
|
|
156
|
+
if (line.trim()) {
|
|
157
|
+
const row = JSON.parse(line)
|
|
158
|
+
if (row?.source === PREFIX) {
|
|
159
|
+
const action = actions[index]
|
|
160
|
+
if (!action || offset < action.ledgerStart || offset + line.length > action.ledgerEnd) fail('copy is missing prior human decisions')
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
offset += line.length + 1
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/** Select exact current automatic members. No display-label expansion and no
|
|
168
|
+
* independent nested identity: the b18 source/root agreement remains mandatory. */
|
|
169
|
+
export function macCallHumanScopes(input: { baseJSON: string; markdown: string; ledgerJSONL: string; annotationJSON: string | null; packet: MacSpeakerCallPacket | null; view: MacSpeakerCallView }): MacCallHumanScope[] {
|
|
170
|
+
const { packet, view } = input
|
|
171
|
+
const source = { baseSHA256: hash(input.baseJSON), markdownSHA256: hash(input.markdown), ledgerSHA256: hash(input.ledgerJSONL), annotationSHA256: input.annotationJSON === null ? null : hash(input.annotationJSON) }
|
|
172
|
+
const key = digest({ source, packet: digest(packet), status: view.metadata.status, viewPacket: view.metadata.packetSHA256 })
|
|
173
|
+
if (scopeCache?.key === key) return scopeCache.scopes
|
|
174
|
+
const base = JSON.parse(input.baseJSON), map = mapped(base)
|
|
175
|
+
const result: MacCallHumanScope[] = []
|
|
176
|
+
const finish = () => cacheScopes(key, result)
|
|
177
|
+
const annotation = input.annotationJSON ? validateAnnotation(JSON.parse(input.annotationJSON), input.baseJSON, input.ledgerJSONL) : null
|
|
178
|
+
if (!annotation) requireHumanLedgerCoverage(input.ledgerJSONL, null)
|
|
179
|
+
const protectedRaw = new Set<number>()
|
|
180
|
+
if (annotation) {
|
|
181
|
+
const latestMembers = new Map<number, { member: Member; to: string }>(), latestFields = new Map<string, { field: Field; to: string }>()
|
|
182
|
+
for (const action of annotation.actions) {
|
|
183
|
+
for (const member of action.members) { latestMembers.set(member.rawChunkIndex, { member, to: action.to }); protectedRaw.add(member.rawChunkIndex) }
|
|
184
|
+
for (const field of action.fields) latestFields.set(JSON.stringify(field.path), { field, to: action.to })
|
|
185
|
+
}
|
|
186
|
+
for (const name of new Set([...latestMembers.values()].map(m => m.to))) {
|
|
187
|
+
const members = [...latestMembers.values()].filter(m => m.to === name).map(({ member }) => ({ ...member, before: name, baseBefore: member.baseBefore ?? member.before }))
|
|
188
|
+
const raw = new Set(members.map(m => m.rawChunkIndex)), fields = [...latestFields.values()].filter(f => f.to === name && f.field.rawChunkIndices.every(r => raw.has(r))).map(f => f.field)
|
|
189
|
+
const body = { kind: 'human' as const, reviewBasis: 'human-authored' as const, identityId: `human_${digest({ base: source.baseSHA256, name }).slice(0, 40)}`, displayLabel: name, members, fields, ...source, packetSHA256: null, generation: annotation.annotationSHA256, packetRevision: annotation.revision }
|
|
190
|
+
result.push({ ...body, scopeSHA256: digest(body) })
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
if (!packet) return finish()
|
|
194
|
+
const { packetSHA256, ...packetBody } = packet
|
|
195
|
+
if (packetSHA256 !== digest(packetBody)) return finish()
|
|
196
|
+
if (view.metadata.status !== 'applied' || view.metadata.packetSHA256 !== packet.packetSHA256 || packet.admission !== 'qualified' || packet.projectionPolicy !== 'anonymous-estimate' || packet.baseSHA256 !== source.baseSHA256 || packet.ledgerSHA256 !== source.ledgerSHA256) return finish()
|
|
197
|
+
for (const raw of packet.aliasProtectedRawChunkIndices) protectedRaw.add(raw)
|
|
198
|
+
const acousticVoices = new Set(packet.groups.flatMap(g => g.memberVoices))
|
|
199
|
+
// A human may also review an unresolved automatic label. This is NOT an
|
|
200
|
+
// acoustic group/alias: it only presents explicit eligible rows for selection.
|
|
201
|
+
const singletonVoices = [...new Set(map.filter(m => VOICE.test(m.before) && !acousticVoices.has(m.before)).map(m => m.before))]
|
|
202
|
+
const groups = [
|
|
203
|
+
...packet.groups.map(group => ({ group, displayLabel: packet.displayLabels[group.identityId], reviewBasis: 'accepted-acoustic-group' as const })),
|
|
204
|
+
...singletonVoices.map(voice => ({ group: { identityId: `review_${digest({ base: source.baseSHA256, voice }).slice(0, 40)}`, memberVoices: [voice] }, displayLabel: voice, reviewBasis: 'automatic-label-only' as const })),
|
|
205
|
+
]
|
|
206
|
+
const owned = groups.map(({ group, displayLabel, reviewBasis }) => {
|
|
207
|
+
const voices = new Set(group.memberVoices), fields: Field[] = [], members = map.filter(m => !protectedRaw.has(m.rawChunkIndex) && voices.has(m.before) && voices.has(base.chunkEntries[m.rawChunkIndex].chunk.speaker) && base.chunks[m.position].speaker === base.chunkEntries[m.rawChunkIndex].chunk.speaker && !hasMacSpeakerProtectedRows(base.chunks[m.position]) && !hasMacSpeakerProtectedRows(base.chunkEntries[m.rawChunkIndex].chunk))
|
|
208
|
+
const memberRaw = new Set(members.map(m => m.rawChunkIndex))
|
|
209
|
+
for (const member of members) for (const [path, row] of [[['chunks', member.position], base.chunks[member.position]], [['chunkEntries', member.rawChunkIndex, 'chunk'], base.chunkEntries[member.rawChunkIndex].chunk]] as Array<[Path, Doc]>) {
|
|
210
|
+
fields.push({ path: [...path, 'speaker'], before: row.speaker, rawChunkIndices: [member.rawChunkIndex] })
|
|
211
|
+
for (const key of ['segments', 'finalSegments', 'words']) for (const [i, nested] of (row[key] ?? []).entries()) if (voices.has(nested.speaker) && !isMacSpeakerHumanProtected(nested)) fields.push({ path: [...path, key, i, 'speaker'], before: nested.speaker, rawChunkIndices: [member.rawChunkIndex] })
|
|
212
|
+
}
|
|
213
|
+
return { group, displayLabel, reviewBasis, members, memberRaw, fields }
|
|
214
|
+
}).filter(group => group.members.length > 0)
|
|
215
|
+
const byVoice = new Map(owned.flatMap(group => group.group.memberVoices.map(voice => [voice, group] as const)))
|
|
216
|
+
// Bind each word once in its exact concatenated-RAW clock. ASR words need
|
|
217
|
+
// not be sorted, so locate the first intersected source with binary search.
|
|
218
|
+
if (owned.length) for (const [si, segment] of (base.batchSegments ?? []).entries()) {
|
|
219
|
+
const clock: Array<{ raw: number; lo: number; hi: number }> = []; let offset = 0
|
|
220
|
+
for (let raw = segment.startChunkIdx; raw <= segment.endChunkIdx; raw++) { const r = packet.rawBindings[raw]; if (!r) fail('batch binding'); const n = r.endSample - r.startSample; clock.push({ raw, lo: offset, hi: offset + n }); offset += n }
|
|
221
|
+
for (const [wi, word] of (segment.speakerWords ?? []).entries()) {
|
|
222
|
+
const group = byVoice.get(word.speaker)
|
|
223
|
+
if (!group || isMacSpeakerHumanProtected(word)) continue
|
|
224
|
+
const lo = Math.round(word.start * 16000), hi = Math.round(word.end * 16000)
|
|
225
|
+
if (!Number.isSafeInteger(lo) || !Number.isSafeInteger(hi) || lo < 0 || hi <= lo || hi > offset) continue
|
|
226
|
+
let left = 0, right = clock.length
|
|
227
|
+
while (left < right) { const mid = (left + right) >>> 1; if (clock[mid].hi <= lo) left = mid + 1; else right = mid }
|
|
228
|
+
const raw: number[] = []
|
|
229
|
+
for (let i = left; i < clock.length && clock[i].lo < hi; i++) raw.push(clock[i].raw)
|
|
230
|
+
if (raw.length && raw.every(r => group.memberRaw.has(r))) group.fields.push({ path: ['batchSegments', si, 'speakerWords', wi, 'speaker'], before: word.speaker, rawChunkIndices: raw })
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
for (const { group, displayLabel, reviewBasis, members, fields } of owned) {
|
|
234
|
+
const body = { kind: 'automatic' as const, reviewBasis, identityId: group.identityId, displayLabel, members, fields, ...source, packetSHA256: packet.packetSHA256, generation: packet.generation, packetRevision: packet.revision }
|
|
235
|
+
if (fields.length > 40000) fail('field budget')
|
|
236
|
+
result.push({ ...body, scopeSHA256: digest(body) })
|
|
237
|
+
}
|
|
238
|
+
return finish()
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
export function applyMacCallHumanAnnotation(baseJSON: string, ledger: string, annotationJSON: string | null, view: MacSpeakerCallView): MacSpeakerCallView {
|
|
242
|
+
if (annotationJSON === null) return view
|
|
243
|
+
const annotation = validateAnnotation(JSON.parse(annotationJSON), baseJSON, ledger), doc = structuredClone(view.doc)
|
|
244
|
+
const changedRaw = new Set<number>(), changedWords = new Set<string>(), activeNames = new Map<string, string>()
|
|
245
|
+
for (const action of annotation.actions) {
|
|
246
|
+
action.members.forEach(m => changedRaw.add(m.rawChunkIndex))
|
|
247
|
+
for (const field of action.fields) { const row = at(doc, field.path); row[field.path.at(-1)!] = action.to; activeNames.set(JSON.stringify(field.path), action.to); if (field.path[0] === 'batchSegments') changedWords.add(JSON.stringify(field.path)) }
|
|
248
|
+
}
|
|
249
|
+
doc.speakers = [...new Set([...(doc.speakers ?? []), ...activeNames.values()])]
|
|
250
|
+
return { ...view, doc, metadata: { ...view.metadata, status: 'applied', reason: 'source-bound-human-annotation', changedChunks: view.metadata.changedChunks + changedRaw.size, changedWords: view.metadata.changedWords + changedWords.size, humanAnnotation: { revision: annotation.revision, annotationSHA256: annotation.annotationSHA256, members: changedRaw.size } } }
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
interface Read { text: string; hash: string; stamp: string }
|
|
254
|
+
const stamp = (s: Stats) => [s.dev, s.ino, s.mode, s.size, s.mtimeMs, s.ctimeMs].join(':')
|
|
255
|
+
export function readMacCallHumanFile(path: string, max: number, missing = false): Read | null {
|
|
256
|
+
let fd: number
|
|
257
|
+
try { fd = openSync(path, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK) } catch (e) { if (missing && (e as NodeJS.ErrnoException).code === 'ENOENT') return null; throw e }
|
|
258
|
+
try {
|
|
259
|
+
const before = fstatSync(fd); if (!before.isFile() || before.size > max) fail('unsafe file')
|
|
260
|
+
const bytes = Buffer.alloc(before.size + 1); let n = 0
|
|
261
|
+
while (n < bytes.length) { const got = readSync(fd, bytes, n, bytes.length - n, null); if (!got) break; n += got }
|
|
262
|
+
const after = fstatSync(fd), current = lstatSync(path)
|
|
263
|
+
if (n !== before.size || stamp(before) !== stamp(after) || stamp(after) !== stamp(current) || current.isSymbolicLink()) fail('file read drift')
|
|
264
|
+
const contents = bytes.subarray(0, n), text = contents.toString('utf8')
|
|
265
|
+
if (!Buffer.from(text, 'utf8').equals(contents)) fail('non-UTF8 source')
|
|
266
|
+
return { text, hash: hash(text), stamp: stamp(after) }
|
|
267
|
+
} finally { closeSync(fd) }
|
|
268
|
+
}
|
|
269
|
+
function directory(path: string) { const dir = dirname(path); if (realpathSync(dir) !== dir || !lstatSync(dir).isDirectory()) fail('canonical parent required'); return dir }
|
|
270
|
+
function syncDir(path: string) { const fd = openSync(directory(path), constants.O_RDONLY | constants.O_DIRECTORY | constants.O_NOFOLLOW | constants.O_NONBLOCK); try { fsyncSync(fd) } finally { closeSync(fd) } }
|
|
271
|
+
function atomic(path: string, text: string, before: () => void) {
|
|
272
|
+
directory(path); const temp = `${path}.${randomUUID()}.tmp`
|
|
273
|
+
const fd = openSync(temp, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW | constants.O_NONBLOCK, 0o600)
|
|
274
|
+
try { writeFileSync(fd, text); fsyncSync(fd) } finally { closeSync(fd) }
|
|
275
|
+
try { before(); renameSync(temp, path); syncDir(path) } finally { if (existsSync(temp)) unlinkSync(temp) }
|
|
276
|
+
}
|
|
277
|
+
function acquire(path: string, journal: string, recover: boolean) {
|
|
278
|
+
const old = readMacCallHumanFile(path, 4096, true)
|
|
279
|
+
if (old) {
|
|
280
|
+
if (!recover) fail('capture locked')
|
|
281
|
+
const owner = JSON.parse(old.text)
|
|
282
|
+
if (!Number.isSafeInteger(owner.pid) || owner.pid <= 0 || owner.kind !== PREFIX || owner.directory !== journal) fail('foreign lock')
|
|
283
|
+
try { process.kill(owner.pid, 0); fail('capture lock owner alive') } catch (e) { if ((e as NodeJS.ErrnoException).code !== 'ESRCH') throw e }
|
|
284
|
+
if (readMacCallHumanFile(path, 4096)!.hash !== old.hash) fail('lock drift')
|
|
285
|
+
unlinkSync(path); syncDir(path)
|
|
286
|
+
}
|
|
287
|
+
directory(path); const text = JSON.stringify({ pid: process.pid, token: randomUUID(), kind: PREFIX, directory: journal }), fd = openSync(path, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW | constants.O_NONBLOCK, 0o600)
|
|
288
|
+
try { writeFileSync(fd, text); fsyncSync(fd) } finally { closeSync(fd) }
|
|
289
|
+
syncDir(path)
|
|
290
|
+
const assertOwned = () => { if (readMacCallHumanFile(path, 4096, true)?.hash !== hash(text)) fail('lock drift') }
|
|
291
|
+
return { assertOwned, release: () => { assertOwned(); unlinkSync(path); syncDir(path) } }
|
|
292
|
+
}
|
|
293
|
+
interface Pin { path: string; hash: string | null; maximum: number }
|
|
294
|
+
interface Journal { version: 1; policy: typeof PREFIX; captureId: string; phase: 'prepared' | 'complete'; pins: Pin[]; images: Array<{ path: string; before: string | null; after: string }>; journalSHA256: string }
|
|
295
|
+
function sealJournal(body: Omit<Journal, 'journalSHA256'>): Journal { return { ...body, journalSHA256: digest(body) } }
|
|
296
|
+
function runJournal(files: MacCallHumanFiles, captureId: string, journal: Journal, expectedJournal: string, afterWrite?: (index: number) => void, assertOwned: () => void = () => {}) {
|
|
297
|
+
const paths = macCallHumanPaths(files, captureId), { journalSHA256, ...body } = journal
|
|
298
|
+
if (journal.version !== 1 || journal.policy !== PREFIX || journal.captureId !== captureId || journalSHA256 !== digest(body) || journal.images.length !== 2 || journal.images[0].path !== paths.ledger || journal.images[1].path !== paths.annotation || !['prepared', 'complete'].includes(journal.phase) || !equal(journal.pins.map(p => p.path), [files.sidecarPath, files.meetingPath, paths.packet])) fail('journal scope')
|
|
299
|
+
if (!equal(journal.pins.map(p => p.maximum), [32 * 1024 * 1024, 16 * 1024 * 1024, 16 * 1024 * 1024]) || journal.pins.some(p => p.hash !== null && !SHA.test(p.hash)) || journal.images.some(image => (image.before !== null && typeof image.before !== 'string') || typeof image.after !== 'string' || Buffer.byteLength(image.before ?? '') > 16 * 1024 * 1024 || Buffer.byteLength(image.after) > 16 * 1024 * 1024)) fail('journal bounds')
|
|
300
|
+
const fence = () => {
|
|
301
|
+
assertOwned()
|
|
302
|
+
if (readMacCallHumanFile(paths.journal, 64 * 1024 * 1024)!.text !== expectedJournal) fail('journal changed')
|
|
303
|
+
for (const pin of journal.pins) if ((readMacCallHumanFile(pin.path, pin.maximum, pin.hash === null)?.hash ?? null) !== pin.hash) fail('source changed')
|
|
304
|
+
return journal.images.map(image => { const actual = readMacCallHumanFile(image.path, 16 * 1024 * 1024, true)?.text ?? null; if (actual !== image.before && actual !== image.after) fail('later human edit'); return actual })
|
|
305
|
+
}
|
|
306
|
+
if (journal.phase === 'complete') {
|
|
307
|
+
assertOwned()
|
|
308
|
+
if (readMacCallHumanFile(paths.journal, 64 * 1024 * 1024)!.text !== expectedJournal || journal.images.some(image => readMacCallHumanFile(image.path, 16 * 1024 * 1024, true)?.text !== image.after)) fail('completed annotation changed')
|
|
309
|
+
return // Archival human decision: refreshed automatic packets are allowed.
|
|
310
|
+
}
|
|
311
|
+
let current = fence()
|
|
312
|
+
for (const [index, image] of journal.images.entries()) {
|
|
313
|
+
fence()
|
|
314
|
+
if (current[index] !== image.after) atomic(image.path, image.after, () => { const checked = fence(); for (let i = 0; i < index; i++) if (checked[i] !== journal.images[i].after) fail('earlier publication changed') })
|
|
315
|
+
afterWrite?.(index)
|
|
316
|
+
current = fence()
|
|
317
|
+
for (let i = 0; i <= index; i++) if (current[i] !== journal.images[i].after) fail('publication changed')
|
|
318
|
+
}
|
|
319
|
+
const completed = `${JSON.stringify(sealJournal({ ...body, phase: 'complete' }))}\n`
|
|
320
|
+
atomic(paths.journal, completed, () => { const checked = fence(); if (checked.some((v, i) => v !== journal.images[i].after)) fail('postimage changed') })
|
|
321
|
+
if (readMacCallHumanFile(paths.journal, 64 * 1024 * 1024)!.text !== completed) fail('journal readback changed')
|
|
322
|
+
}
|
|
323
|
+
export function recoverMacCallHumanAnnotation(files: MacCallHumanFiles, captureId: string, afterWrite?: (index: number) => void): void {
|
|
324
|
+
const paths = macCallHumanPaths(files, captureId), journal = readMacCallHumanFile(paths.journal, 64 * 1024 * 1024, true)
|
|
325
|
+
if (!journal) return
|
|
326
|
+
const guard = acquire(paths.lock, paths.journal, true)
|
|
327
|
+
try { runJournal(files, captureId, JSON.parse(journal.text), journal.text, afterWrite, guard.assertOwned) } finally { guard.release() }
|
|
328
|
+
}
|
|
329
|
+
export function commitMacCallHumanAnnotation(input: { files: MacCallHumanFiles; captureId: string; scope: MacCallHumanScope; selectedRawChunkIndices: number[]; to: string; afterWrite?: (index: number) => void }): { correctionId: string; members: number; fields: number } {
|
|
330
|
+
if (!isMacCallHumanName(input.to) || !Array.isArray(input.selectedRawChunkIndices) || !input.selectedRawChunkIndices.length || input.selectedRawChunkIndices.length > 6000 || new Set(input.selectedRawChunkIndices).size !== input.selectedRawChunkIndices.length) fail('explicit name and source selection required')
|
|
331
|
+
const paths = macCallHumanPaths(input.files, input.captureId)
|
|
332
|
+
mkdirSync(dirname(paths.ledger), { recursive: true, mode: 0o700 }); directory(paths.ledger)
|
|
333
|
+
const guard = acquire(paths.lock, paths.journal, false)
|
|
334
|
+
try {
|
|
335
|
+
const base = readMacCallHumanFile(input.files.sidecarPath, 32 * 1024 * 1024)!, md = readMacCallHumanFile(input.files.meetingPath, 16 * 1024 * 1024)!, ledger = readMacCallHumanFile(paths.ledger, 8 * 1024 * 1024, true), packet = readMacCallHumanFile(paths.packet, 16 * 1024 * 1024, true), prior = readMacCallHumanFile(paths.annotation, 16 * 1024 * 1024, true)
|
|
336
|
+
const scope = input.scope, { scopeSHA256, ...scopeBody } = scope
|
|
337
|
+
const parsedPacket = scope.kind === 'automatic' && packet ? JSON.parse(packet.text) as MacSpeakerCallPacket : null
|
|
338
|
+
const { packetSHA256, ...packetBody } = parsedPacket ?? { packetSHA256: null }
|
|
339
|
+
if (scopeSHA256 !== digest(scopeBody) || base.hash !== scope.baseSHA256 || md.hash !== scope.markdownSHA256 || hash(ledger?.text ?? '') !== scope.ledgerSHA256 || prior?.hash !== (scope.annotationSHA256 ?? undefined) || !['automatic', 'human'].includes(scope.kind) || scope.kind === 'automatic' && (packetSHA256 !== scope.packetSHA256 || packetSHA256 !== digest(packetBody) || parsedPacket?.generation !== scope.generation || parsedPacket?.revision !== scope.packetRevision)) fail('reviewed source changed')
|
|
340
|
+
const existing = prior ? validateAnnotation(JSON.parse(prior.text), base.text, ledger?.text ?? '') : null
|
|
341
|
+
if (!existing) requireHumanLedgerCoverage(ledger?.text ?? '', null)
|
|
342
|
+
if (scope.kind === 'human' && (!existing || existing.annotationSHA256 !== scope.generation || existing.revision !== scope.packetRevision)) fail('reviewed human annotation changed')
|
|
343
|
+
const selected = new Set(input.selectedRawChunkIndices), members = scope.members.filter(m => selected.has(m.rawChunkIndex))
|
|
344
|
+
if (members.length !== selected.size) fail('selection outside reviewed group')
|
|
345
|
+
const fields = selectedFields(scope, selected)
|
|
346
|
+
const fieldScopeSHA256 = digest({ identityId: scope.identityId, members, fields })
|
|
347
|
+
const id = `${PREFIX}:${randomUUID()}`, atTime = new Date().toISOString(), groups = new Map<string, number[]>()
|
|
348
|
+
for (const m of members) { const positions = groups.get(m.before) ?? []; positions.push(m.position); groups.set(m.before, positions) }
|
|
349
|
+
const rows = [...groups].flatMap(([from, chunks], index) => ['intent', 'applied', 'confirmed-chunks'].map(phase => ({ id: `${id}:${index}`, batchId: id, phase, at: atTime, from, to: input.to, chunks, scope: 'meeting', source: PREFIX, fieldScopeSHA256 })))
|
|
350
|
+
const beforeLedger = ledger?.text ?? '', afterLedger = beforeLedger + (beforeLedger && !beforeLedger.endsWith('\n') ? '\n' : '') + rows.map(r => JSON.stringify(r)).join('\n') + '\n'
|
|
351
|
+
const action: Action = { id, at: atTime, identityId: scope.identityId, to: input.to, scopeSHA256, members, fields, fieldScopeSHA256, ledgerStart: beforeLedger.length, ledgerEnd: afterLedger.length, ledgerBeforeSHA256: hash(beforeLedger), ledgerAfterSHA256: hash(afterLedger) }
|
|
352
|
+
const annotation = seal({ version: 1, policy: PREFIX, captureId: input.captureId, revision: (existing?.revision ?? 0) + 1, baseSHA256: base.hash, ledgerSHA256: hash(afterLedger), actions: [...(existing?.actions ?? []), action] }), afterAnnotation = `${JSON.stringify(annotation)}\n`
|
|
353
|
+
validateAnnotation(annotation, base.text, afterLedger)
|
|
354
|
+
const humanDoc = structuredClone(JSON.parse(base.text))
|
|
355
|
+
for (const a of annotation.actions) for (const field of a.fields) at(humanDoc, field.path)[field.path.at(-1)!] = a.to
|
|
356
|
+
renderMarkdown(md.text, boundWords(JSON.parse(base.text), humanDoc))
|
|
357
|
+
if (Buffer.byteLength(afterLedger) > 8 * 1024 * 1024 || Buffer.byteLength(afterAnnotation) > 16 * 1024 * 1024) fail('annotation byte budget')
|
|
358
|
+
const oldJournal = readMacCallHumanFile(paths.journal, 64 * 1024 * 1024, true)
|
|
359
|
+
if (oldJournal && JSON.parse(oldJournal.text).phase !== 'complete') fail('annotation recovery required')
|
|
360
|
+
const pins = [{ path: input.files.sidecarPath, hash: base.hash, maximum: 32 * 1024 * 1024 }, { path: input.files.meetingPath, hash: md.hash, maximum: 16 * 1024 * 1024 }, { path: paths.packet, hash: packet?.hash ?? null, maximum: 16 * 1024 * 1024 }]
|
|
361
|
+
const journal = sealJournal({ version: 1, policy: PREFIX, captureId: input.captureId, phase: 'prepared', pins, images: [{ path: paths.ledger, before: ledger?.text ?? null, after: afterLedger }, { path: paths.annotation, before: prior?.text ?? null, after: afterAnnotation }] }), journalText = `${JSON.stringify(journal)}\n`
|
|
362
|
+
atomic(paths.journal, journalText, () => {
|
|
363
|
+
guard.assertOwned()
|
|
364
|
+
for (const pin of pins) if ((readMacCallHumanFile(pin.path, pin.maximum, pin.hash === null)?.hash ?? null) !== pin.hash) fail('source changed')
|
|
365
|
+
if ((readMacCallHumanFile(paths.ledger, 8 * 1024 * 1024, true)?.text ?? null) !== (ledger?.text ?? null) || (readMacCallHumanFile(paths.annotation, 16 * 1024 * 1024, true)?.text ?? null) !== (prior?.text ?? null) || (readMacCallHumanFile(paths.journal, 64 * 1024 * 1024, true)?.text ?? null) !== (oldJournal?.text ?? null)) fail('publication precondition changed')
|
|
366
|
+
})
|
|
367
|
+
if (readMacCallHumanFile(paths.journal, 64 * 1024 * 1024)!.text !== journalText) fail('journal readback changed')
|
|
368
|
+
runJournal(input.files, input.captureId, journal, journalText, input.afterWrite, guard.assertOwned)
|
|
369
|
+
return { correctionId: id, members: members.length, fields: fields.length }
|
|
370
|
+
} finally { guard.release() }
|
|
371
|
+
}
|