@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
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
// Speaker shadow mode (6.95.0): the whole-call timeline and the production segments.
|
|
2
|
+
//
|
|
3
|
+
// A port of round3/propose.py `build_timeline` and its segment loop. READ ONLY: it reads
|
|
4
|
+
// the finalized sidecar and the retained chunk WAVs and returns buffers; it never writes.
|
|
5
|
+
//
|
|
6
|
+
// Placement policy (the canary's): each original chunk starts at floor(elapsed_ms*16+.5),
|
|
7
|
+
// rows sort by (start, raw index), an original's tail that runs into the next one's start
|
|
8
|
+
// is trimmed, and gaps stay zero. Every original with an `audioSha256` must still match
|
|
9
|
+
// it, and each file is read twice with the second read required to hash the same as the
|
|
10
|
+
// first, which is what the tool's "rebuild twice, same PCM SHA" check proves.
|
|
11
|
+
|
|
12
|
+
import { createHash } from 'node:crypto'
|
|
13
|
+
import { readFile } from 'node:fs/promises'
|
|
14
|
+
import { basename } from 'node:path'
|
|
15
|
+
import { hasMacSpeakerProtectedRows } from './mac-speaker-protection.js'
|
|
16
|
+
import { SHADOW_SAMPLE_RATE, isShadowVoiceLabel, type ShadowSegment, type ShadowSegmentSource } from './speaker-shadow-decide.js'
|
|
17
|
+
import { PY_SPACE_CLASS, pyRoundInt } from './speaker-shadow-numeric.js'
|
|
18
|
+
|
|
19
|
+
/** A meeting longer than this is not reconstructed (the timeline buffer is 115 MB per hour). */
|
|
20
|
+
export const SHADOW_MAX_TIMELINE_SECONDS = 4 * 60 * 60
|
|
21
|
+
|
|
22
|
+
export class ShadowInputError extends Error {
|
|
23
|
+
constructor(readonly reason: 'bad_sidecar' | 'audio_missing' | 'audio_format' | 'audio_drift' | 'too_long', message: string) {
|
|
24
|
+
super(message)
|
|
25
|
+
this.name = 'ShadowInputError'
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export interface ShadowChunkEntry { chunkIndex: number; chunk: Record<string, unknown> }
|
|
30
|
+
export interface ShadowTimelineRow { rawChunkIndex: number; startSample: number; sampleCount: number; usedSampleCount: number }
|
|
31
|
+
export interface ShadowTimeline {
|
|
32
|
+
rows: ShadowTimelineRow[]
|
|
33
|
+
pcm: Buffer
|
|
34
|
+
total: number
|
|
35
|
+
verified: number
|
|
36
|
+
unhashed: number
|
|
37
|
+
unhashedWithText: number[]
|
|
38
|
+
pcmSHA256: string
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const sha256 = (data: Buffer) => createHash('sha256').update(data).digest('hex')
|
|
42
|
+
const PY_STRIP = new RegExp(`^${PY_SPACE_CLASS}+|${PY_SPACE_CLASS}+$`, 'gu')
|
|
43
|
+
|
|
44
|
+
/** Validated entries in sidecar order, or a bad_sidecar error. */
|
|
45
|
+
export function shadowEntries(sidecar: Record<string, unknown>): ShadowChunkEntry[] {
|
|
46
|
+
const raw = sidecar.chunkEntries
|
|
47
|
+
if (!Array.isArray(raw) || !raw.length) throw new ShadowInputError('bad_sidecar', 'the sidecar has no chunkEntries')
|
|
48
|
+
const seen = new Set<number>()
|
|
49
|
+
return raw.map(item => {
|
|
50
|
+
const e = item as { chunkIndex?: unknown; chunk?: unknown }
|
|
51
|
+
if (!e || typeof e !== 'object' || !Number.isInteger(e.chunkIndex) || (e.chunkIndex as number) < 0 || (e.chunkIndex as number) > 99_999) throw new ShadowInputError('bad_sidecar', 'a chunk entry has no valid chunkIndex')
|
|
52
|
+
if (!e.chunk || typeof e.chunk !== 'object' || Array.isArray(e.chunk)) throw new ShadowInputError('bad_sidecar', 'a chunk entry has no chunk')
|
|
53
|
+
const chunk = e.chunk as Record<string, unknown>
|
|
54
|
+
if (typeof chunk.elapsed !== 'number' || !Number.isFinite(chunk.elapsed)) throw new ShadowInputError('bad_sidecar', 'a chunk has no elapsed clock')
|
|
55
|
+
if (chunk.segments !== undefined && !Array.isArray(chunk.segments)) throw new ShadowInputError('bad_sidecar', 'a chunk has malformed segments')
|
|
56
|
+
if (seen.has(e.chunkIndex as number)) throw new ShadowInputError('bad_sidecar', 'duplicate chunkIndex')
|
|
57
|
+
seen.add(e.chunkIndex as number)
|
|
58
|
+
return { chunkIndex: e.chunkIndex as number, chunk }
|
|
59
|
+
})
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** propose.py labels_of: what a later correction would change. Hashed, so a run can refuse labels that moved after finalize. */
|
|
63
|
+
export function shadowLabelsHash(entries: readonly ShadowChunkEntry[]): string {
|
|
64
|
+
const projection = entries.map(e => [
|
|
65
|
+
e.chunkIndex,
|
|
66
|
+
e.chunk.speaker ?? null,
|
|
67
|
+
((e.chunk.segments as unknown[] | undefined) ?? []).map(s => {
|
|
68
|
+
const seg = (s ?? {}) as Record<string, unknown>
|
|
69
|
+
return [seg.speaker ?? null, seg.startSec ?? null, seg.endSec ?? null]
|
|
70
|
+
}),
|
|
71
|
+
])
|
|
72
|
+
return createHash('sha256').update(JSON.stringify(projection)).digest('hex')
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** Python `wave` read of a 16 kHz mono 16-bit PCM file: the declared frames and their bytes. */
|
|
76
|
+
export function readPcm16Wav(wav: Buffer): { sampleCount: number; pcm: Buffer } {
|
|
77
|
+
if (wav.length < 12 || wav.toString('ascii', 0, 4) !== 'RIFF' || wav.toString('ascii', 8, 12) !== 'WAVE') throw new ShadowInputError('audio_format', 'not a RIFF/WAVE file')
|
|
78
|
+
let offset = 12, fmtOk = false
|
|
79
|
+
while (offset + 8 <= wav.length) {
|
|
80
|
+
const id = wav.toString('ascii', offset, offset + 4)
|
|
81
|
+
const size = wav.readUInt32LE(offset + 4)
|
|
82
|
+
const body = offset + 8
|
|
83
|
+
if (id === 'fmt ') {
|
|
84
|
+
if (body + 16 > wav.length) throw new ShadowInputError('audio_format', 'truncated fmt chunk')
|
|
85
|
+
const format = wav.readUInt16LE(body), channels = wav.readUInt16LE(body + 2), rate = wav.readUInt32LE(body + 4), bits = wav.readUInt16LE(body + 14)
|
|
86
|
+
if (format !== 1 || channels !== 1 || rate !== SHADOW_SAMPLE_RATE || bits !== 16) throw new ShadowInputError('audio_format', `expected 16 kHz mono PCM16, got format ${format}, ${channels} ch, ${rate} Hz, ${bits} bit`)
|
|
87
|
+
fmtOk = true
|
|
88
|
+
} else if (id === 'data') {
|
|
89
|
+
if (!fmtOk) throw new ShadowInputError('audio_format', 'data before fmt')
|
|
90
|
+
const sampleCount = Math.floor(size / 2)
|
|
91
|
+
if (body + sampleCount * 2 > wav.length) throw new ShadowInputError('audio_format', 'truncated data chunk')
|
|
92
|
+
return { sampleCount, pcm: wav.subarray(body, body + sampleCount * 2) }
|
|
93
|
+
}
|
|
94
|
+
offset = body + size + (size % 2)
|
|
95
|
+
}
|
|
96
|
+
throw new ShadowInputError('audio_format', 'no data chunk')
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** The 44-byte header Python's wave writer emits, then the PCM. */
|
|
100
|
+
export function pcm16WavFile(pcm: Buffer): Buffer {
|
|
101
|
+
const header = Buffer.alloc(44)
|
|
102
|
+
header.write('RIFF', 0); header.writeUInt32LE(36 + pcm.length, 4); header.write('WAVE', 8); header.write('fmt ', 12)
|
|
103
|
+
header.writeUInt32LE(16, 16); header.writeUInt16LE(1, 20); header.writeUInt16LE(1, 22); header.writeUInt32LE(SHADOW_SAMPLE_RATE, 24)
|
|
104
|
+
header.writeUInt32LE(SHADOW_SAMPLE_RATE * 2, 28); header.writeUInt16LE(2, 32); header.writeUInt16LE(16, 34); header.write('data', 36); header.writeUInt32LE(pcm.length, 40)
|
|
105
|
+
return Buffer.concat([header, pcm])
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
async function readOriginal(path: string): Promise<Buffer> {
|
|
109
|
+
try { return await readFile(path) } catch (error) {
|
|
110
|
+
const code = (error as NodeJS.ErrnoException)?.code
|
|
111
|
+
throw new ShadowInputError(code === 'ENOENT' ? 'audio_missing' : 'audio_format', `original ${path.split('/').pop()}: ${code ?? 'unreadable'}`)
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** Every original verified, placed and copied into one PCM buffer. Async reads, one file at a time. */
|
|
116
|
+
export async function buildShadowTimeline(entries: readonly ShadowChunkEntry[], originalPath: (chunkIndex: number) => string): Promise<ShadowTimeline> {
|
|
117
|
+
const SR = SHADOW_SAMPLE_RATE
|
|
118
|
+
let verified = 0
|
|
119
|
+
const unhashed: number[] = [], unhashedWithText: number[] = []
|
|
120
|
+
const firstHash = new Map<number, string>()
|
|
121
|
+
const rows: Array<ShadowTimelineRow & { path: string }> = []
|
|
122
|
+
for (const e of entries) {
|
|
123
|
+
const path = originalPath(e.chunkIndex)
|
|
124
|
+
const bytes = await readOriginal(path)
|
|
125
|
+
const digest = sha256(bytes)
|
|
126
|
+
const want = e.chunk.audioSha256
|
|
127
|
+
if (typeof want === 'string' && want) {
|
|
128
|
+
if (digest !== want) throw new ShadowInputError('audio_drift', `original ${e.chunkIndex} no longer matches the sidecar audioSha256`)
|
|
129
|
+
verified++
|
|
130
|
+
} else {
|
|
131
|
+
unhashed.push(e.chunkIndex)
|
|
132
|
+
const text = typeof e.chunk.text === 'string' ? e.chunk.text : ''
|
|
133
|
+
if (text.replace(PY_STRIP, '')) unhashedWithText.push(e.chunkIndex)
|
|
134
|
+
}
|
|
135
|
+
const { sampleCount } = readPcm16Wav(bytes)
|
|
136
|
+
firstHash.set(e.chunkIndex, digest)
|
|
137
|
+
rows.push({ rawChunkIndex: e.chunkIndex, startSample: Math.floor((e.chunk.elapsed as number) * 16 + 0.5), sampleCount, usedSampleCount: 0, path })
|
|
138
|
+
}
|
|
139
|
+
rows.sort((a, b) => (a.startSample - b.startSample) || (a.rawChunkIndex - b.rawChunkIndex))
|
|
140
|
+
if (rows[0].startSample < 0) throw new ShadowInputError('bad_sidecar', 'a chunk starts before the recording')
|
|
141
|
+
const total = Math.max(...rows.map(x => x.startSample + x.sampleCount))
|
|
142
|
+
if (total > SHADOW_MAX_TIMELINE_SECONDS * SR) throw new ShadowInputError('too_long', `the meeting is longer than ${SHADOW_MAX_TIMELINE_SECONDS / 3600} h`)
|
|
143
|
+
const pcm = Buffer.alloc(total * 2)
|
|
144
|
+
for (let i = 0; i < rows.length; i++) {
|
|
145
|
+
const x = rows[i]
|
|
146
|
+
const bytes = await readOriginal(x.path)
|
|
147
|
+
if (sha256(bytes) !== firstHash.get(x.rawChunkIndex)) throw new ShadowInputError('audio_drift', `original ${x.rawChunkIndex} changed while the timeline was built`)
|
|
148
|
+
const raw = readPcm16Wav(bytes).pcm
|
|
149
|
+
const use = i + 1 < rows.length ? Math.min(x.sampleCount, Math.max(0, rows[i + 1].startSample - x.startSample)) : x.sampleCount
|
|
150
|
+
if (!(use > 0)) throw new ShadowInputError('bad_sidecar', `original ${x.rawChunkIndex} is fully shadowed by the next one`)
|
|
151
|
+
x.usedSampleCount = use
|
|
152
|
+
raw.copy(pcm, x.startSample * 2, 0, use * 2)
|
|
153
|
+
}
|
|
154
|
+
return {
|
|
155
|
+
rows: rows.map(({ path: _path, ...row }) => row),
|
|
156
|
+
pcm, total, verified, unhashed: unhashed.length, unhashedWithText, pcmSHA256: sha256(pcm),
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/** propose.py's segment loop: production labels on the timeline, plus where each came from. */
|
|
161
|
+
export function shadowProductionSegments(entries: readonly ShadowChunkEntry[], rows: readonly ShadowTimelineRow[], total: number): { segs: ShadowSegment[]; sources: ShadowSegmentSource[] } {
|
|
162
|
+
const SR = SHADOW_SAMPLE_RATE
|
|
163
|
+
const placed = new Map(rows.map(x => [x.rawChunkIndex, x]))
|
|
164
|
+
// Python's isinstance(x, (int, float)) is true for a JSON boolean, which counts as 0 or 1.
|
|
165
|
+
const num = (x: unknown): number | null => typeof x === 'number' ? x : typeof x === 'boolean' ? Number(x) : null
|
|
166
|
+
const segs: ShadowSegment[] = [], sources: ShadowSegmentSource[] = []
|
|
167
|
+
for (const e of entries) {
|
|
168
|
+
const x = placed.get(e.chunkIndex)!
|
|
169
|
+
const off = x.startSample, end = Math.min(total, off + x.usedSampleCount)
|
|
170
|
+
for (const raw of (e.chunk.segments as unknown[] | undefined) ?? []) {
|
|
171
|
+
const s = (raw && typeof raw === 'object' ? raw : {}) as Record<string, unknown>
|
|
172
|
+
const a = num(s.startSec), b = num(s.endSec), label = s.speaker
|
|
173
|
+
if (a === null || b === null || !label) continue
|
|
174
|
+
if (typeof label !== 'string') throw new ShadowInputError('bad_sidecar', 'a segment speaker is not a string')
|
|
175
|
+
const lo = off + pyRoundInt(a * SR), hi = Math.min(end, off + pyRoundInt(b * SR))
|
|
176
|
+
if (lo < hi) {
|
|
177
|
+
segs.push([lo, hi, label])
|
|
178
|
+
sources.push({ chunkIndex: e.chunkIndex, start: a, end: Math.min(b, x.usedSampleCount / SR), abs: lo / SR })
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
return { segs, sources }
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/** Voice labels that appear on any chunk a human touched. A hint in the record; it never changes a gate. */
|
|
186
|
+
export function shadowHumanProtectedVoices(entries: readonly ShadowChunkEntry[]): string[] {
|
|
187
|
+
const out = new Set<string>()
|
|
188
|
+
for (const e of entries) {
|
|
189
|
+
if (!hasMacSpeakerProtectedRows(e.chunk)) continue
|
|
190
|
+
const labels = [e.chunk.speaker, ...((e.chunk.segments as unknown[] | undefined) ?? []).map(s => (s as Record<string, unknown> | null)?.speaker)]
|
|
191
|
+
for (const label of labels) if (typeof label === 'string' && isShadowVoiceLabel(label)) out.add(label)
|
|
192
|
+
}
|
|
193
|
+
return [...out].sort()
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/** The meeting's title (the saved markdown's `# ` line), else the sidecar's file name. Read only. */
|
|
197
|
+
export async function readShadowMeetingTitle(sidecarPath: string): Promise<string> {
|
|
198
|
+
const fallback = basename(sidecarPath).replace(/\.g2-chunks\.json$/, '')
|
|
199
|
+
try {
|
|
200
|
+
const text = await readFile(sidecarPath.replace(/\.g2-chunks\.json$/, '.md'), 'utf8')
|
|
201
|
+
const line = text.split('\n', 40).find(l => l.startsWith('# '))
|
|
202
|
+
const title = line?.slice(2).trim()
|
|
203
|
+
return title ? title.slice(0, 160) : fallback
|
|
204
|
+
} catch { return fallback }
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/** The meeting day for the report folder: the sidecar name's YYYY-MM-DD prefix, else null. */
|
|
208
|
+
export function shadowMeetingDay(sidecarPath: string): string | null {
|
|
209
|
+
return /^(\d{4}-\d{2}-\d{2})/.exec(basename(sidecarPath))?.[1] ?? null
|
|
210
|
+
}
|
|
@@ -0,0 +1,312 @@
|
|
|
1
|
+
// Speaker shadow mode (6.95.0): one meeting's proposal run, in its own child process.
|
|
2
|
+
//
|
|
3
|
+
// The server never runs this on its event loop. speaker-shadow.ts starts it as a niced
|
|
4
|
+
// child (`node --import tsx/esm speaker-shadow-worker.ts`), sends one job on stdin, and
|
|
5
|
+
// reads exactly one JSON outcome line from stdout. The parent owns the wall clock and the
|
|
6
|
+
// RSS cap and kills the whole process group; this side also stops the CLI on its own
|
|
7
|
+
// deadline and exits if its parent goes away.
|
|
8
|
+
//
|
|
9
|
+
// Shadow remains READ ONLY, LOG ONLY: its temporary native outputs are removed.
|
|
10
|
+
// A separately default-off reviewed closed-call consumer may retain the SAME verified
|
|
11
|
+
// execution exports in its own data directory. It never edits BASE, names, profiles or
|
|
12
|
+
// corrections here; later derived-view publication has an independent queue/admission.
|
|
13
|
+
//
|
|
14
|
+
// The CLI is FluidAudio 0.17.4 `process --mode offline --overlapping-segments
|
|
15
|
+
// --export-embeddings`, the exact invocation of round3/propose.py, run through
|
|
16
|
+
// sandbox-exec with network denied (the Parakeet profile: writes only to the temp dir,
|
|
17
|
+
// the private HOME and the per-user temp dir NSTemporaryDirectory uses).
|
|
18
|
+
|
|
19
|
+
import { spawn, type ChildProcess, type SpawnOptions } from 'node:child_process'
|
|
20
|
+
import { createHash } from 'node:crypto'
|
|
21
|
+
import { createReadStream, existsSync, mkdtempSync, readdirSync, realpathSync, rmSync } from 'node:fs'
|
|
22
|
+
import { readFile, writeFile } from 'node:fs/promises'
|
|
23
|
+
import { join } from 'node:path'
|
|
24
|
+
import { pipeline } from 'node:stream/promises'
|
|
25
|
+
import { Transform } from 'node:stream'
|
|
26
|
+
import { fileURLToPath } from 'node:url'
|
|
27
|
+
import { darwinUserTempDir, parakeetChildEnv, parakeetSandboxProfile } from './parakeet.js'
|
|
28
|
+
import {
|
|
29
|
+
SHADOW_GATES, SHADOW_SAMPLE_RATE, buildShadowProposals, decide, nativeTurns,
|
|
30
|
+
type ShadowEmbeddingRow, type ShadowProposals,
|
|
31
|
+
} from './speaker-shadow-decide.js'
|
|
32
|
+
import { SHADOW_MODEL_REPO, SHADOW_MODEL_REVISION, prepareShadowHome, verifyShadowModels, type ShadowModelCheck } from './speaker-shadow-models.js'
|
|
33
|
+
import {
|
|
34
|
+
ShadowInputError, buildShadowTimeline, pcm16WavFile, readShadowMeetingTitle, shadowEntries, shadowHumanProtectedVoices,
|
|
35
|
+
shadowLabelsHash, shadowProductionSegments,
|
|
36
|
+
} from './speaker-shadow-timeline.js'
|
|
37
|
+
import { renderShadowReport } from './speaker-shadow-report.js'
|
|
38
|
+
import { pyRound } from './speaker-shadow-numeric.js'
|
|
39
|
+
import { findShadowPairSource } from './speaker-shadow-pair-source.js'
|
|
40
|
+
import { evaluatePairedSpeakerShadow, renderPairedSpeakerShadow, type PairedShadowReport } from './speaker-shadow-paired.js'
|
|
41
|
+
import { beginMacSpeakerCallNative,retainMacSpeakerCallNative,type MacSpeakerCallProducerRequest,type MacSpeakerCallNativeRef } from './mac-speaker-call-producer-worker.js'
|
|
42
|
+
|
|
43
|
+
export const SHADOW_TMP_PREFIX = 'cos-speaker-shadow-'
|
|
44
|
+
/** The CLI's flags, verbatim from round3/propose.py (via scripts/guard.py). */
|
|
45
|
+
export const SHADOW_CLI_FLAGS = ['--mode', 'offline', '--overlapping-segments'] as const
|
|
46
|
+
|
|
47
|
+
export interface ShadowJob {
|
|
48
|
+
sessionId: string
|
|
49
|
+
sidecarPath: string
|
|
50
|
+
/** The retained chunk WAV directory (`<data>/meeting-audio/<session>`). */
|
|
51
|
+
audioDir: string
|
|
52
|
+
/** The labels hash recorded when the meeting finalized; a different one now means a person edited it. */
|
|
53
|
+
expectedLabelsHash: string | null
|
|
54
|
+
cliPath: string
|
|
55
|
+
/** SHA-256 the parent verified for cliPath; re-checked here before the run. */
|
|
56
|
+
cliSha256: string
|
|
57
|
+
sandboxExec: string
|
|
58
|
+
modelsDir: string
|
|
59
|
+
homeDir: string
|
|
60
|
+
tmpRoot: string
|
|
61
|
+
cliTimeoutMs: number
|
|
62
|
+
/** Separate default-off consumer, admitted independently from log-only shadow. */
|
|
63
|
+
callProducer?: MacSpeakerCallProducerRequest
|
|
64
|
+
shadowLogRequested?: boolean
|
|
65
|
+
callProducerSwapStart?:number
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export type ShadowSkipReason =
|
|
69
|
+
| 'not_finalized' | 'labels_changed' | 'no_voices' | 'audio_missing' | 'audio_drift' | 'audio_format'
|
|
70
|
+
| 'bad_sidecar' | 'too_long' | 'models_missing' | 'models_mismatch' | 'cli_changed' | 'cli_lacks_flags'
|
|
71
|
+
export type ShadowFailReason = 'cli_timeout' | 'cli_failed' | 'cli_unsupported' | 'bad_cli_output' | 'sandbox_unavailable' | 'internal'
|
|
72
|
+
|
|
73
|
+
export interface ShadowRunFacts {
|
|
74
|
+
minutes: number
|
|
75
|
+
originals: number
|
|
76
|
+
verifiedAgainstSidecar: number
|
|
77
|
+
unhashedOriginals: number
|
|
78
|
+
unhashedWithText: number[]
|
|
79
|
+
timelinePCMSHA256: string
|
|
80
|
+
timelineWAVSHA256: string
|
|
81
|
+
labelsHash: string
|
|
82
|
+
clippedTurns: number
|
|
83
|
+
humanProtectedVoices: string[]
|
|
84
|
+
/**
|
|
85
|
+
* Empty `NSIRD_fluidaudiocli_*` OS temp dirs that appeared during the run and are still
|
|
86
|
+
* there. A cold CoreML cache leaves up to four (MPSGraph staging); warm runs leave none.
|
|
87
|
+
* Counted, never removed: the server cannot prove a dir in the shared temp folder is its own.
|
|
88
|
+
*/
|
|
89
|
+
osTempDirsLeft: number
|
|
90
|
+
timing: { rebuildMs: number; nativeMs: number; totalMs: number }
|
|
91
|
+
tool: { cliSha256: string; flags: string[]; modelRepo: string; modelRevision: string; modelBytes: number }
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export type ShadowJobOutcome =
|
|
95
|
+
| { status: 'proposed'; facts: ShadowRunFacts; proposals: ShadowProposals; paired?: PairedShadowReport & { pairSessionId?: string; pairSourceSha256?: string; discoveryReason: string }; report: { title: string; markdown: string };callProducerExport?:MacSpeakerCallNativeRef; callProducerFailure?:{stage:'retain';reason:string} }
|
|
96
|
+
| { status: 'skipped'; reason: ShadowSkipReason; detail: string }
|
|
97
|
+
| { status: 'failed'; reason: ShadowFailReason; detail: string }
|
|
98
|
+
|
|
99
|
+
type SpawnLike = (command: string, args: string[], options: SpawnOptions) => ChildProcess
|
|
100
|
+
|
|
101
|
+
export interface ShadowWorkerDeps {
|
|
102
|
+
spawn?: SpawnLike
|
|
103
|
+
now?: () => number
|
|
104
|
+
/** Aborts the CLI (the parent went away). */
|
|
105
|
+
signal?: AbortSignal
|
|
106
|
+
/** Tests only (the fake-CLI integration worker): real model bytes cannot be faked. */
|
|
107
|
+
verifyModels?: (dir: string) => Promise<ShadowModelCheck>
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
async function sha256File(path: string): Promise<string> {
|
|
111
|
+
const hash = createHash('sha256')
|
|
112
|
+
await pipeline(createReadStream(path), new Transform({ transform(chunk, _e, cb) { hash.update(chunk); cb(null) } }))
|
|
113
|
+
return hash.digest('hex')
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** The CLI flags shadow mode passes that are not in the binary's own strings. Read only. */
|
|
117
|
+
export async function cliMissingFlags(cliPath: string, flags: readonly string[] = ['--export-embeddings', '--overlapping-segments']): Promise<string[]> {
|
|
118
|
+
const bytes = await readFile(cliPath)
|
|
119
|
+
return flags.filter(flag => bytes.indexOf(flag) < 0)
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** Foundation's item-replacement dirs for this CLI, in the per-user temp folder. Read only. */
|
|
123
|
+
function nsirdDirs(): Set<string> {
|
|
124
|
+
const root = darwinUserTempDir()
|
|
125
|
+
if (!root) return new Set()
|
|
126
|
+
try { return new Set(readdirSync(join(root, 'TemporaryItems')).filter(name => name.startsWith('NSIRD_fluidaudiocli_'))) } catch { return new Set() }
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
const skip = (reason: ShadowSkipReason, detail: string): ShadowJobOutcome => ({ status: 'skipped', reason, detail })
|
|
130
|
+
const fail = (reason: ShadowFailReason, detail: string): ShadowJobOutcome => ({ status: 'failed', reason, detail: detail.slice(0, 300) })
|
|
131
|
+
|
|
132
|
+
function runCli(job: ShadowJob, wavPath: string, nativePath: string, embPath: string, work: string, deps: ShadowWorkerDeps): Promise<{ code: number | null; signal: NodeJS.Signals | null; stderr: string; timedOut: boolean;closed:boolean; error?: string }> {
|
|
133
|
+
const args = ['-p', parakeetSandboxProfile(work, job.homeDir), job.cliPath, 'process', wavPath, '--mode', 'offline', '--output', nativePath, '--overlapping-segments', '--export-embeddings', embPath]
|
|
134
|
+
return new Promise(resolvePromise => {
|
|
135
|
+
if(deps.signal?.aborted){resolvePromise({code:null,signal:null,stderr:'',timedOut:false,closed:false,error:'cancelled before native admission'});return}
|
|
136
|
+
const deadline=Date.now()+job.cliTimeoutMs
|
|
137
|
+
let child: ChildProcess
|
|
138
|
+
try {
|
|
139
|
+
child = (deps.spawn ?? (spawn as unknown as SpawnLike))(job.sandboxExec, args, { stdio: ['ignore', 'ignore', 'pipe'], env: {...parakeetChildEnv(job.homeDir, work),...(job.callProducer?{OMP_NUM_THREADS:'1',VECLIB_MAXIMUM_THREADS:'1',OPENBLAS_NUM_THREADS:'1'}:{})} })
|
|
140
|
+
} catch (error) {
|
|
141
|
+
resolvePromise({ code: null, signal: null, stderr: '', timedOut: false,closed:false, error: error instanceof Error ? error.message : String(error) })
|
|
142
|
+
return
|
|
143
|
+
}
|
|
144
|
+
let stderr = '', timedOut = false, settled = false, childError:string|undefined
|
|
145
|
+
const done = (value: Parameters<typeof resolvePromise>[0]) => { if (settled) return; settled = true; clearTimeout(timer); deps.signal?.removeEventListener('abort', onAbort); resolvePromise(value) }
|
|
146
|
+
const kill = () => { try { child.kill('SIGKILL') } catch { /* gone */ } }
|
|
147
|
+
// SIGKILL: CoreML work does not stop for TERM.
|
|
148
|
+
const timer = setTimeout(() => { timedOut = true; kill() }, job.cliTimeoutMs)
|
|
149
|
+
const onAbort = () => kill()
|
|
150
|
+
deps.signal?.addEventListener('abort', onAbort, { once: true })
|
|
151
|
+
child.stderr?.on('data', (d: Buffer) => { if (stderr.length < 65_536) stderr += d.toString() })
|
|
152
|
+
child.on('error', error => {childError=error.message;if(!child.pid)done({ code: null, signal: null, stderr, timedOut,closed:false, error: error.message });else kill()})
|
|
153
|
+
child.on('close', (code, signal) => done({ code, signal, stderr, timedOut:timedOut||Date.now()>deadline,closed:true,error:childError }))
|
|
154
|
+
})
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/** One meeting. Never throws: every path ends in an outcome, and the temp dir is always removed. */
|
|
158
|
+
export async function runShadowJob(job: ShadowJob, deps: ShadowWorkerDeps = {}): Promise<ShadowJobOutcome> {
|
|
159
|
+
const now = deps.now ?? Date.now
|
|
160
|
+
const started = now()
|
|
161
|
+
let work: string | null = null
|
|
162
|
+
try {
|
|
163
|
+
// 1. The finalized meeting, unchanged since it finalized.
|
|
164
|
+
let sidecar: Record<string, unknown>,sourceJSON:string
|
|
165
|
+
try { sourceJSON=await readFile(job.sidecarPath, 'utf8');sidecar = JSON.parse(sourceJSON) } catch { return skip('bad_sidecar', 'the sidecar is missing or unreadable') }
|
|
166
|
+
if (sidecar.sessionId !== job.sessionId) return skip('bad_sidecar', 'the sidecar belongs to another session')
|
|
167
|
+
if (sidecar.finalizationState !== 'complete') return skip('not_finalized', `finalizationState=${String(sidecar.finalizationState)}`)
|
|
168
|
+
const entries = shadowEntries(sidecar)
|
|
169
|
+
const labelsHash = shadowLabelsHash(entries)
|
|
170
|
+
if (job.expectedLabelsHash && labelsHash !== job.expectedLabelsHash) return skip('labels_changed', 'labels changed after the meeting finalized (a correction landed first)')
|
|
171
|
+
const missing = entries.filter(e => !existsSync(join(job.audioDir, `chunk_${String(e.chunkIndex).padStart(4, '0')}.wav`))).length
|
|
172
|
+
if (missing) return skip('audio_missing', `${missing} original chunk WAVs missing`)
|
|
173
|
+
|
|
174
|
+
// 2. Models and CLI, verified now rather than trusted from the scheduler's look.
|
|
175
|
+
const models = await (deps.verifyModels ?? verifyShadowModels)(job.modelsDir)
|
|
176
|
+
if (!models.ok) return skip(models.reason, models.detail)
|
|
177
|
+
if (!existsSync(job.cliPath) || await sha256File(job.cliPath) !== job.cliSha256) return skip('cli_changed', 'the FluidAudio CLI changed since it was verified')
|
|
178
|
+
// A build without these would fail every run and open the breaker; say so once instead.
|
|
179
|
+
const missingFlags = await cliMissingFlags(job.cliPath)
|
|
180
|
+
if (missingFlags.length) return skip('cli_lacks_flags', `the installed fluidaudiocli has no ${missingFlags.join(' or ')} (COS Control's signed helper has both)`)
|
|
181
|
+
if (!existsSync(job.sandboxExec)) return fail('sandbox_unavailable', 'sandbox-exec is missing, so the CLI cannot be proven offline and does not run')
|
|
182
|
+
|
|
183
|
+
// 3. The timeline, then the production segments on it.
|
|
184
|
+
const timeline = await buildShadowTimeline(entries, idx => join(job.audioDir, `chunk_${String(idx).padStart(4, '0')}.wav`))
|
|
185
|
+
const { segs, sources } = shadowProductionSegments(entries, timeline.rows, timeline.total)
|
|
186
|
+
if (!segs.length) return skip('no_voices', 'no production segments to compare')
|
|
187
|
+
const wav = pcm16WavFile(timeline.pcm)
|
|
188
|
+
const rebuildMs = now() - started
|
|
189
|
+
|
|
190
|
+
// The platform temp root may spell /private/var as /var. Pin the owned
|
|
191
|
+
// directory itself; retained-output readers keep their strict canonical path fence.
|
|
192
|
+
work = mkdtempSync(join(job.tmpRoot, SHADOW_TMP_PREFIX))
|
|
193
|
+
work = realpathSync(work)
|
|
194
|
+
const wavPath = join(work, 'timeline.wav'), nativePath = join(work, 'native.json'), embPath = join(work, 'embeddings.json')
|
|
195
|
+
await writeFile(wavPath, wav, { mode: 0o600, flag: 'wx' })
|
|
196
|
+
const timelineWAVSHA256 = createHash('sha256').update(wav).digest('hex')
|
|
197
|
+
prepareShadowHome(job.homeDir, models.dir)
|
|
198
|
+
let producerContext:ReturnType<typeof beginMacSpeakerCallNative>|null=null
|
|
199
|
+
if(job.callProducer){
|
|
200
|
+
try{producerContext=beginMacSpeakerCallNative(job.callProducer,{sourceJSON,audioDir:job.audioDir,cliPath:job.cliPath,modelsDir:models.dir,timeline:{rows:timeline.rows,total:timeline.total,pcmSHA256:timeline.pcmSHA256}})}
|
|
201
|
+
catch(error){if(job.shadowLogRequested===false)return skip('bad_sidecar',`independent consumer withheld: ${error instanceof Error?error.message:String(error)}`)}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
// 4. One offline diarization.
|
|
205
|
+
const nativeStarted = now()
|
|
206
|
+
const nsirdBefore = nsirdDirs()
|
|
207
|
+
const run = await runCli(job, wavPath, nativePath, embPath, work, deps)
|
|
208
|
+
const nativeMs = now() - nativeStarted
|
|
209
|
+
const osTempDirsLeft = [...nsirdDirs()].filter(name => !nsirdBefore.has(name)).length
|
|
210
|
+
if (run.timedOut) return fail('cli_timeout', `no result within ${job.cliTimeoutMs} ms`)
|
|
211
|
+
if (run.error) return fail('cli_failed', `spawn: ${run.error}`)
|
|
212
|
+
const tail = run.stderr.trim().split('\n').slice(-2).join(' | ')
|
|
213
|
+
if (/Unknown option|unexpected argument|Unrecognized/i.test(run.stderr)) return fail('cli_unsupported', `the CLI rejected an option: ${tail}`)
|
|
214
|
+
if (run.code !== 0) return fail('cli_failed', `exit ${run.code ?? run.signal}: ${tail}`)
|
|
215
|
+
|
|
216
|
+
let native: { segments?: unknown }, emb: unknown
|
|
217
|
+
try {
|
|
218
|
+
native = JSON.parse(await readFile(nativePath, 'utf8'))
|
|
219
|
+
emb = JSON.parse(await readFile(embPath, 'utf8'))
|
|
220
|
+
} catch (error) { return fail('bad_cli_output', `unreadable output: ${error instanceof Error ? error.message : String(error)}`) }
|
|
221
|
+
if (!Array.isArray(native.segments) || !Array.isArray(emb)) return fail('bad_cli_output', 'the CLI output has no segments or embeddings')
|
|
222
|
+
|
|
223
|
+
// 5. The gates, exactly as the research tool applies them.
|
|
224
|
+
let proposals: ShadowProposals, clipped: number
|
|
225
|
+
let paired: Extract<ShadowJobOutcome, { status: 'proposed' }>['paired']
|
|
226
|
+
try {
|
|
227
|
+
const turns = nativeTurns(native.segments as Array<{ speakerId: unknown; startTimeSeconds: unknown; endTimeSeconds: unknown }>, timeline.total / SHADOW_SAMPLE_RATE)
|
|
228
|
+
clipped = turns.clipped
|
|
229
|
+
for (const row of emb as ShadowEmbeddingRow[]) {
|
|
230
|
+
if (!row || typeof row.startTime !== 'number' || typeof row.endTime !== 'number' || !Array.isArray(row.embedding256) || row.embedding256.length !== 256) throw new Error('bad embedding row')
|
|
231
|
+
}
|
|
232
|
+
const D = decide(segs, timeline.total, turns.turns, emb as ShadowEmbeddingRow[], SHADOW_GATES.floorSeconds, SHADOW_GATES.majority)
|
|
233
|
+
proposals = buildShadowProposals(D, segs, sources, turns.turns)
|
|
234
|
+
// Pair scoring is advisory; an unavailable G2 must not suppress the acoustic report.
|
|
235
|
+
try {
|
|
236
|
+
const source = await findShadowPairSource(job.sidecarPath, sidecar, timeline.total / SHADOW_SAMPLE_RATE * 1000)
|
|
237
|
+
paired = { ...evaluatePairedSpeakerShadow({ macStartTime: sidecar.startTime as number, totalSamples: timeline.total,
|
|
238
|
+
segments: segs, regularTurns: turns.turns, acceptedGroups: proposals.accepted,
|
|
239
|
+
voiceEvidence: [...proposals.accepted.flatMap(group => group.voices), ...proposals.rejected],
|
|
240
|
+
g2: source.g2, alignment: { status: source.status } }),
|
|
241
|
+
pairSessionId: source.sessionId, pairSourceSha256: source.sourceSha256, discoveryReason: source.reason }
|
|
242
|
+
} catch {
|
|
243
|
+
paired = { ...evaluatePairedSpeakerShadow({ macStartTime: sidecar.startTime as number, totalSamples: timeline.total,
|
|
244
|
+
segments: segs, regularTurns: turns.turns, acceptedGroups: proposals.accepted, voiceEvidence: [],
|
|
245
|
+
alignment: { status: 'unpaired' } }), discoveryReason: 'pair discovery or scoring unavailable' }
|
|
246
|
+
}
|
|
247
|
+
} catch (error) { return fail('bad_cli_output', error instanceof Error ? error.message : String(error)) }
|
|
248
|
+
|
|
249
|
+
const humanProtectedVoices = shadowHumanProtectedVoices(entries)
|
|
250
|
+
const facts: ShadowRunFacts = {
|
|
251
|
+
minutes: pyRound(timeline.total / SHADOW_SAMPLE_RATE / 60, 1),
|
|
252
|
+
originals: entries.length,
|
|
253
|
+
verifiedAgainstSidecar: timeline.verified,
|
|
254
|
+
unhashedOriginals: timeline.unhashed,
|
|
255
|
+
unhashedWithText: timeline.unhashedWithText,
|
|
256
|
+
timelinePCMSHA256: timeline.pcmSHA256,
|
|
257
|
+
timelineWAVSHA256,
|
|
258
|
+
labelsHash,
|
|
259
|
+
clippedTurns: clipped,
|
|
260
|
+
humanProtectedVoices,
|
|
261
|
+
osTempDirsLeft,
|
|
262
|
+
timing: { rebuildMs, nativeMs, totalMs: now() - started },
|
|
263
|
+
tool: { cliSha256: job.cliSha256, flags: [...SHADOW_CLI_FLAGS, '--export-embeddings'], modelRepo: SHADOW_MODEL_REPO, modelRevision: SHADOW_MODEL_REVISION, modelBytes: models.bytes },
|
|
264
|
+
}
|
|
265
|
+
const title = await readShadowMeetingTitle(job.sidecarPath)
|
|
266
|
+
const chunkText = new Map(entries.map(e => [e.chunkIndex, typeof e.chunk.text === 'string' ? e.chunk.text : '']))
|
|
267
|
+
let markdown = renderShadowReport({ title, sessionId: job.sessionId, proposals, facts, audioDir: job.audioDir, chunkText })
|
|
268
|
+
if (paired) markdown += `\n${renderPairedSpeakerShadow(paired)}\nDiscovery: ${paired.discoveryReason}\n`
|
|
269
|
+
let callProducerExport:MacSpeakerCallNativeRef|undefined
|
|
270
|
+
let callProducerFailure:Extract<ShadowJobOutcome,{status:'proposed'}>['callProducerFailure']
|
|
271
|
+
if(producerContext){try{callProducerExport=retainMacSpeakerCallNative(producerContext,{nativePath,embeddingPath:embPath,exitCode:run.code,closed:run.closed,networkDenied:true})}catch(error){callProducerFailure={stage:'retain',reason:(error instanceof Error?error.message:'native export retention failed').slice(0,200)}}}
|
|
272
|
+
return { status: 'proposed', proposals, paired, facts, report: { title, markdown },...(callProducerExport?{callProducerExport}:{}),...(callProducerFailure?{callProducerFailure}:{}) }
|
|
273
|
+
} catch (error) {
|
|
274
|
+
if (error instanceof ShadowInputError) return skip(error.reason, error.message)
|
|
275
|
+
return fail('internal', error instanceof Error ? error.message : String(error))
|
|
276
|
+
} finally {
|
|
277
|
+
if (work) { try { rmSync(work, { recursive: true, force: true }) } catch { /* best effort; swept at boot */ } }
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
// ── Child process entry ──────────────────────────────────────────────────────
|
|
282
|
+
|
|
283
|
+
async function readStdin(limit = 64 * 1024): Promise<string> {
|
|
284
|
+
let text = ''
|
|
285
|
+
for await (const chunk of process.stdin) {
|
|
286
|
+
text += chunk.toString()
|
|
287
|
+
if (text.length > limit) throw new Error('job too large')
|
|
288
|
+
}
|
|
289
|
+
return text
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
/** The child's whole life: one job in on stdin, one outcome line out on stdout. */
|
|
293
|
+
export async function workerMain(deps: Omit<ShadowWorkerDeps, 'signal'> = {}): Promise<void> {
|
|
294
|
+
const controller = new AbortController()
|
|
295
|
+
const parent = process.ppid
|
|
296
|
+
// A parent that died (or was SIGKILLed with its group spared) must not leave this running.
|
|
297
|
+
const watch = setInterval(() => { if (process.ppid !== parent) { controller.abort(); process.exit(3) } }, 1_000)
|
|
298
|
+
watch.unref()
|
|
299
|
+
let outcome: ShadowJobOutcome
|
|
300
|
+
try {
|
|
301
|
+
const job = JSON.parse(await readStdin()) as ShadowJob
|
|
302
|
+
outcome = await runShadowJob(job, { ...deps, signal: controller.signal })
|
|
303
|
+
} catch (error) {
|
|
304
|
+
outcome = fail('internal', error instanceof Error ? error.message : String(error))
|
|
305
|
+
}
|
|
306
|
+
process.stdout.write(`${JSON.stringify(outcome)}\n`, () => process.exit(0))
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
function isEntry(): boolean {
|
|
310
|
+
try { return !!process.argv[1] && realpathSync(process.argv[1]) === realpathSync(fileURLToPath(import.meta.url)) } catch { return false }
|
|
311
|
+
}
|
|
312
|
+
if (isEntry()) void workerMain()
|