@gotcos/glasses-server 6.21.6 → 6.21.9
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/.env.example +14 -0
- package/CHANGELOG.md +35 -0
- package/README.md +9 -0
- package/package.json +1 -1
- package/server/index.ts +5 -1
- package/server/lib/batch-transcript-quality.ts +1 -1
- package/server/lib/g2-enrichment-runner.ts +23 -6
- package/server/lib/g2-ops-handoff.ts +193 -32
- package/server/lib/meeting-batch-transcribe.ts +554 -10
- package/server/lib/meeting-finalization-jobs.ts +235 -0
- package/server/lib/meeting-store.ts +9 -0
- package/server/lib/whisper-local.ts +77 -6
- package/server/lib/whisper-preview.ts +34 -0
- package/server/routes/health.ts +15 -0
- package/server/routes/meeting.ts +268 -52
- package/server/routes/transcribe-stream.ts +154 -2
package/.env.example
CHANGED
|
@@ -153,6 +153,20 @@ BIND_HOST=0.0.0.0
|
|
|
153
153
|
# keeps working if the default ever flips to Metal-on.
|
|
154
154
|
# COS_BATCH_HQ_FORCE_CPU=1
|
|
155
155
|
|
|
156
|
+
# Private 6.21.8 canaries can precompute reusable Large-v3 checkpoints for
|
|
157
|
+
# sealed 30-second windows while a meeting is still running. Work is CPU-only,
|
|
158
|
+
# global-single-flight, preemptible, and never canonical until Stop/save accepts
|
|
159
|
+
# it. Balanced is hard-capped at 2 threads for fanless M1/M2 MacBook Airs; Max
|
|
160
|
+
# defaults to 6 and is still capped by available CPUs. Leave OFF for public use
|
|
161
|
+
# until the canary latency/thermal gate passes.
|
|
162
|
+
# COS_MEETING_PROGRESSIVE_HQ=1
|
|
163
|
+
# COS_MEETING_PROGRESSIVE_HQ_THREADS=2 # optional lower override; cannot raise the tier cap
|
|
164
|
+
|
|
165
|
+
# Early Sync writes stable meeting identity to the Operations pipeline before
|
|
166
|
+
# HQ finishes, then enriches that same artifact in place. It is I/O-only and
|
|
167
|
+
# deliberately independent from the progressive-HQ compute switch.
|
|
168
|
+
# COS_MEETING_EARLY_SYNC=1
|
|
169
|
+
|
|
156
170
|
# ── UNSAVED-CAPTURE QUARANTINE (6.19.0) ─────────────────────────────────
|
|
157
171
|
# Meeting audio whose save never landed is QUARANTINED, never deleted. It
|
|
158
172
|
# surfaces on /api/health (unsaved_captures) and, with COS Control 0.3.1+,
|
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,38 @@
|
|
|
1
|
+
## 6.21.9
|
|
2
|
+
|
|
3
|
+
- Prevent an unclosed or abandoned recording from monopolizing progressive HQ.
|
|
4
|
+
Each live session now receives one sealed window per FIFO turn, and sessions
|
|
5
|
+
idle for 45 seconds yield only disposable checkpoint compute while preserving
|
|
6
|
+
raw audio, recovery ledgers, completed checkpoints, and save behavior.
|
|
7
|
+
- Resume a yielded session automatically when a new canonical chunk arrives,
|
|
8
|
+
and expose `paused_idle` in the progressive health snapshot for diagnosis.
|
|
9
|
+
|
|
10
|
+
## 6.21.8
|
|
11
|
+
|
|
12
|
+
- Decouple durable G2 sync identity from post-meeting HQ so a saved meeting can
|
|
13
|
+
enter Operations immediately and be enriched in place when Large-v3 finishes.
|
|
14
|
+
- Add default-off progressive Large-v3 checkpoints for sealed 30-second meeting
|
|
15
|
+
windows. Stop reuses only cache entries whose audio, context, model, and session
|
|
16
|
+
identities still match; provisional text never becomes canonical on its own.
|
|
17
|
+
- Make progressive CPU admission tier-aware. Balanced is capped at two background
|
|
18
|
+
threads for fanless M1/M2-class Macs; Max defaults to six and remains capped by
|
|
19
|
+
available CPUs. Both stay global-single-flight, preemptible, and separately
|
|
20
|
+
kill-switched from Early Sync.
|
|
21
|
+
- Publish requested/effective tier, thread policy, sealed-window progress, early
|
|
22
|
+
sync outcomes, and durable finalization recovery through health for COS Control.
|
|
23
|
+
|
|
24
|
+
## 6.21.7
|
|
25
|
+
|
|
26
|
+
- Add a default-off, authenticated meeting-preview endpoint for private canaries.
|
|
27
|
+
It accepts bounded, server-pinned audio snapshots and returns disposable
|
|
28
|
+
Large-v3-Turbo text without creating or mutating meeting sessions.
|
|
29
|
+
- Keep canonical Large-v3 transcription, speaker attribution, recovery, save, HQ
|
|
30
|
+
polish, and indexing unchanged. Preview never falls back to the canonical worker
|
|
31
|
+
and drops under canonical Metal contention.
|
|
32
|
+
- Reject stale server pins and oversized bodies before inference, recheck
|
|
33
|
+
maintenance admission after slow uploads, and drop concurrent preview work rather
|
|
34
|
+
than building a latency queue. `COS_WHISPER_MEETING_PREVIEW=1` is required.
|
|
35
|
+
|
|
1
36
|
## 6.21.6
|
|
2
37
|
|
|
3
38
|
- Make server-owned durable query jobs the default so accepted replies keep
|
package/README.md
CHANGED
|
@@ -270,6 +270,15 @@ to Turbo rather than making transcription unavailable. COS Control is the
|
|
|
270
270
|
supported owner of the machine-wide tier; the per-lane environment variables
|
|
271
271
|
remain advanced overrides.
|
|
272
272
|
|
|
273
|
+
Server 6.21.8 adds a default-off meeting-completion canary. With
|
|
274
|
+
`COS_MEETING_PROGRESSIVE_HQ=1`, sealed meeting windows can be polished ahead of
|
|
275
|
+
Stop/save on a CPU-only, single-flight lane; finalization reuses only matching
|
|
276
|
+
audio/model/context checkpoints. Balanced is capped at two background threads
|
|
277
|
+
for fanless M1/M2 MacBook Airs, while Max defaults to six and remains capped by
|
|
278
|
+
available CPUs. `COS_MEETING_EARLY_SYNC=1` separately gives the Operations sync
|
|
279
|
+
pipeline a stable meeting identity before HQ completes. Either switch can be
|
|
280
|
+
disabled without changing canonical live transcription or raw meeting audio.
|
|
281
|
+
|
|
273
282
|
The first server start downloads the real-time turbo model. True HQ additionally
|
|
274
283
|
requires the full `ggml-large-v3.bin` model (about 3.1 GB):
|
|
275
284
|
|
package/package.json
CHANGED
package/server/index.ts
CHANGED
|
@@ -19,7 +19,7 @@ import { providerProofRouter } from './routes/provider-proof.js'
|
|
|
19
19
|
import { transcribeRouter } from './routes/transcribe.js'
|
|
20
20
|
import { displayRouter } from './routes/display.js'
|
|
21
21
|
import { transcribeStreamRouter } from './routes/transcribe-stream.js'
|
|
22
|
-
import { meetingRouter } from './routes/meeting.js'
|
|
22
|
+
import { meetingRouter, resumeMeetingFinalizationJobs } from './routes/meeting.js'
|
|
23
23
|
import { meetingsRouter } from './routes/meetings.js'
|
|
24
24
|
import { openaiCompatRouter } from './routes/openai-compat.js'
|
|
25
25
|
import { openaiKeyRouter } from './routes/openai-key.js'
|
|
@@ -439,6 +439,10 @@ listenRequiredServers(listeners).then(() => {
|
|
|
439
439
|
if (admittedRuntimeStarted) return
|
|
440
440
|
admittedRuntimeStarted = true
|
|
441
441
|
|
|
442
|
+
// Resume already-saved meetings whose post-response HQ/operations work
|
|
443
|
+
// was interrupted by a prior server update or process exit.
|
|
444
|
+
resumeMeetingFinalizationJobs()
|
|
445
|
+
|
|
442
446
|
void initQueryJobRuntime().then(health => {
|
|
443
447
|
if (process.env.COS_DURABLE_QUERY_JOBS !== '0') {
|
|
444
448
|
console.log(`[COS API] Durable query jobs: ${health.store.state} · ${health.store.retainedIdentities} retained`)
|
|
@@ -12,7 +12,7 @@ export interface BatchResult {
|
|
|
12
12
|
segment: BatchSegment
|
|
13
13
|
text: string
|
|
14
14
|
words: WhisperWord[]
|
|
15
|
-
speakerWords: Array<{ word: string; start: number; end: number; speaker: string }>
|
|
15
|
+
speakerWords: Array<{ word: string; start: number; end: number; speaker: string; similarity: number }>
|
|
16
16
|
}
|
|
17
17
|
|
|
18
18
|
export interface BatchTranscription {
|
|
@@ -5,7 +5,7 @@ import { isAbsolute } from 'node:path'
|
|
|
5
5
|
export const G2_RESULT_PREFIX = 'COS_G2_RESULT='
|
|
6
6
|
|
|
7
7
|
export interface G2EnrichmentOutcome {
|
|
8
|
-
status: 'enriched' | 'already-enriched' | 'blended'
|
|
8
|
+
status: 'claimed' | 'retained' | 'enriched' | 'already-enriched' | 'blended'
|
|
9
9
|
path: string
|
|
10
10
|
title: string
|
|
11
11
|
}
|
|
@@ -34,6 +34,8 @@ export interface G2EnrichmentOptions {
|
|
|
34
34
|
timeoutMs?: number
|
|
35
35
|
killGraceMs?: number
|
|
36
36
|
onAttempt?: (message: string) => void
|
|
37
|
+
claimOnly?: boolean
|
|
38
|
+
importLocal?: boolean
|
|
37
39
|
}
|
|
38
40
|
|
|
39
41
|
export interface G2EnrichmentDependencies {
|
|
@@ -57,7 +59,7 @@ export function parseG2EnrichmentOutcome(stdout: string): G2EnrichmentOutcome |
|
|
|
57
59
|
try {
|
|
58
60
|
const parsed = JSON.parse(line.slice(G2_RESULT_PREFIX.length)) as Partial<G2EnrichmentOutcome>
|
|
59
61
|
if (
|
|
60
|
-
(parsed.status === 'enriched' || parsed.status === 'already-enriched' || parsed.status === 'blended')
|
|
62
|
+
(parsed.status === 'claimed' || parsed.status === 'retained' || parsed.status === 'enriched' || parsed.status === 'already-enriched' || parsed.status === 'blended')
|
|
61
63
|
&& typeof parsed.path === 'string'
|
|
62
64
|
&& parsed.path.length > 0
|
|
63
65
|
&& typeof parsed.title === 'string'
|
|
@@ -72,8 +74,19 @@ export function parseG2EnrichmentOutcome(stdout: string): G2EnrichmentOutcome |
|
|
|
72
74
|
return null
|
|
73
75
|
}
|
|
74
76
|
|
|
75
|
-
export function buildExactG2SyncArgs(
|
|
76
|
-
|
|
77
|
+
export function buildExactG2SyncArgs(
|
|
78
|
+
syncScript: string,
|
|
79
|
+
meetingFile: string,
|
|
80
|
+
claimOnly = false,
|
|
81
|
+
importLocal = false,
|
|
82
|
+
): string[] {
|
|
83
|
+
return [
|
|
84
|
+
syncScript,
|
|
85
|
+
'--g2-only',
|
|
86
|
+
importLocal ? '--g2-import-file' : '--g2-file', meetingFile,
|
|
87
|
+
...(claimOnly ? ['--g2-claim-only'] : []),
|
|
88
|
+
'--quiet',
|
|
89
|
+
]
|
|
77
90
|
}
|
|
78
91
|
|
|
79
92
|
function signalChildTree(child: ReturnType<typeof spawn>, signal: NodeJS.Signals): boolean {
|
|
@@ -91,7 +104,12 @@ export async function spawnG2SyncAttempt(options: G2EnrichmentOptions): Promise<
|
|
|
91
104
|
return await new Promise(resolve => {
|
|
92
105
|
const child = spawn(
|
|
93
106
|
options.pythonBin,
|
|
94
|
-
buildExactG2SyncArgs(
|
|
107
|
+
buildExactG2SyncArgs(
|
|
108
|
+
options.syncScript,
|
|
109
|
+
options.meetingFile,
|
|
110
|
+
options.claimOnly,
|
|
111
|
+
options.importLocal,
|
|
112
|
+
),
|
|
95
113
|
{
|
|
96
114
|
cwd: options.scriptsDir,
|
|
97
115
|
stdio: ['ignore', 'pipe', 'pipe'],
|
|
@@ -176,4 +194,3 @@ export async function runG2EnrichmentWithRetry(
|
|
|
176
194
|
|
|
177
195
|
return { ok: false, attempts: retryDelaysMs.length, error: lastError }
|
|
178
196
|
}
|
|
179
|
-
|
|
@@ -10,16 +10,74 @@
|
|
|
10
10
|
// lets sync_meetings reclassify).
|
|
11
11
|
// 3. Run sync_meetings.py --g2-only --g2-file with the private-app retry helper.
|
|
12
12
|
|
|
13
|
-
import {
|
|
13
|
+
import { existsSync, mkdirSync, readFileSync } from 'node:fs'
|
|
14
14
|
import { basename, dirname, join } from 'node:path'
|
|
15
|
+
import { durableAtomicWriteFileSync } from './atomic-fs.js'
|
|
15
16
|
import { resolveCosOperationsDir } from './cos-operations-meetings.js'
|
|
16
17
|
import { runG2EnrichmentWithRetry } from './g2-enrichment-runner.js'
|
|
17
18
|
import { COS_SCRIPTS_DIR, PYTHON_BIN } from './python-bridge.js'
|
|
19
|
+
import { getServerInstanceId } from './server-instance-id.js'
|
|
18
20
|
|
|
19
21
|
const PENDING_SUMMARY = '*G2 recording — summary pending pipeline processing.*'
|
|
20
22
|
const DOMAIN_REVIEW_MARKER = '<!-- g2-needs-domain-review -->'
|
|
23
|
+
const HQ_PENDING_MARKER = '<!-- g2-hq-state: pending -->'
|
|
24
|
+
const OPERATIONS_OWNED_SIDECAR_FIELDS = new Set([
|
|
25
|
+
'blended_into',
|
|
26
|
+
'dedupEvidence',
|
|
27
|
+
'enrichmentState',
|
|
28
|
+
'finalPath',
|
|
29
|
+
'operationsPath',
|
|
30
|
+
])
|
|
21
31
|
|
|
22
|
-
export function
|
|
32
|
+
export function earlyMeetingSyncEnabled(): boolean {
|
|
33
|
+
return process.env.COS_MEETING_EARLY_SYNC === '1'
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
interface EarlySyncRuntimeState {
|
|
37
|
+
inFlight: boolean
|
|
38
|
+
pendingCount: number
|
|
39
|
+
lastOutcome: 'claimed' | 'finalized' | 'failed' | null
|
|
40
|
+
lastAt: string | null
|
|
41
|
+
lastError: string | null
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const earlySyncRuntime: EarlySyncRuntimeState = {
|
|
45
|
+
inFlight: false,
|
|
46
|
+
pendingCount: 0,
|
|
47
|
+
lastOutcome: null,
|
|
48
|
+
lastAt: null,
|
|
49
|
+
lastError: null,
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function getEarlyMeetingSyncSnapshot(): EarlySyncRuntimeState & {
|
|
53
|
+
requested: boolean
|
|
54
|
+
enabled: boolean
|
|
55
|
+
available: boolean
|
|
56
|
+
reason: string | null
|
|
57
|
+
} {
|
|
58
|
+
const requested = earlyMeetingSyncEnabled()
|
|
59
|
+
const scriptsDir = process.env.COS_SCRIPTS_DIR?.trim() || COS_SCRIPTS_DIR
|
|
60
|
+
const syncScript = scriptsDir ? join(scriptsDir, 'sync_meetings.py') : ''
|
|
61
|
+
const python = process.env.COS_PYTHON_BIN?.trim() || PYTHON_BIN
|
|
62
|
+
let reason: string | null = null
|
|
63
|
+
if (!scriptsDir) reason = 'operations_not_configured'
|
|
64
|
+
else if (!python || !existsSync(python)) reason = 'python_unavailable'
|
|
65
|
+
else if (!syncScript || !existsSync(syncScript)) reason = 'sync_script_missing'
|
|
66
|
+
else if (!syncScriptSupportsLockedImport(syncScript)) reason = 'sync_script_upgrade_required'
|
|
67
|
+
const available = reason === null
|
|
68
|
+
return { requested, enabled: requested && available, available, reason, ...earlySyncRuntime }
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export interface G2StageOptions {
|
|
72
|
+
phase?: 'claim' | 'final'
|
|
73
|
+
hqState?: 'queued' | 'running' | 'accepted' | 'rejected' | 'failed' | 'unavailable'
|
|
74
|
+
revision?: number
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export function patchRecordingForG2Pipeline(
|
|
78
|
+
markdown: string,
|
|
79
|
+
options: G2StageOptions = {},
|
|
80
|
+
): string {
|
|
23
81
|
let text = markdown.replace(/\r\n?/g, '\n')
|
|
24
82
|
if (!/\|\s*\*\*Source\*\*\s*\|\s*G2 Glasses\s*\|/i.test(text) && !text.includes('| G2 Glasses')) {
|
|
25
83
|
text = text.replace(
|
|
@@ -45,11 +103,71 @@ export function patchRecordingForG2Pipeline(markdown: string): string {
|
|
|
45
103
|
text = `${text.trimEnd()}\n\n${DOMAIN_REVIEW_MARKER}\n`
|
|
46
104
|
}
|
|
47
105
|
}
|
|
106
|
+
text = text.replace(/\n?<!-- g2-hq-state: pending -->\n?/g, '\n')
|
|
107
|
+
if (options.phase === 'claim') {
|
|
108
|
+
text = text.includes(DOMAIN_REVIEW_MARKER)
|
|
109
|
+
? text.replace(DOMAIN_REVIEW_MARKER, `${HQ_PENDING_MARKER}\n${DOMAIN_REVIEW_MARKER}`)
|
|
110
|
+
: `${text.trimEnd()}\n\n${HQ_PENDING_MARKER}\n`
|
|
111
|
+
}
|
|
48
112
|
return text
|
|
49
113
|
}
|
|
50
114
|
|
|
115
|
+
function parseSidecar(path: string): Record<string, unknown> | null {
|
|
116
|
+
if (!existsSync(path)) return null
|
|
117
|
+
try {
|
|
118
|
+
const parsed = JSON.parse(readFileSync(path, 'utf8')) as unknown
|
|
119
|
+
return parsed && typeof parsed === 'object' && !Array.isArray(parsed)
|
|
120
|
+
? parsed as Record<string, unknown>
|
|
121
|
+
: null
|
|
122
|
+
} catch {
|
|
123
|
+
return null
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** Merge server-owned capture truth while preserving operations-owned match state. */
|
|
128
|
+
export function mergeG2OperationsSidecar(
|
|
129
|
+
sourcePath: string,
|
|
130
|
+
destinationPath: string,
|
|
131
|
+
options: G2StageOptions = {},
|
|
132
|
+
): void {
|
|
133
|
+
const source = parseSidecar(sourcePath)
|
|
134
|
+
if (!source) return
|
|
135
|
+
const existing = parseSidecar(destinationPath) ?? {}
|
|
136
|
+
const sourceSession = typeof source.sessionId === 'string' ? source.sessionId : ''
|
|
137
|
+
const existingSession = typeof existing.sessionId === 'string' ? existing.sessionId : ''
|
|
138
|
+
if (sourceSession && existingSession && sourceSession !== existingSession) {
|
|
139
|
+
throw new Error(`Refusing G2 sidecar merge across sessions (${existingSession} != ${sourceSession})`)
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
const priorRevision = Number(existing.lifecycleRevision ?? 0)
|
|
143
|
+
const nextRevision = Math.max(Number(options.revision ?? 0), Number(source.lifecycleRevision ?? 0))
|
|
144
|
+
if (Number.isFinite(priorRevision) && Number.isFinite(nextRevision) && nextRevision < priorRevision) {
|
|
145
|
+
throw new Error(`Refusing regressing G2 sidecar revision ${nextRevision} < ${priorRevision}`)
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
const merged: Record<string, unknown> = { ...existing, ...source }
|
|
149
|
+
for (const field of OPERATIONS_OWNED_SIDECAR_FIELDS) {
|
|
150
|
+
if (Object.prototype.hasOwnProperty.call(existing, field)) merged[field] = existing[field]
|
|
151
|
+
}
|
|
152
|
+
merged.lifecycleRevision = Math.max(priorRevision || 0, nextRevision || 0)
|
|
153
|
+
const inferredFinalHqState = source.batchApplied === true
|
|
154
|
+
? 'accepted'
|
|
155
|
+
: source.batchQualityReport
|
|
156
|
+
? 'rejected'
|
|
157
|
+
: 'unavailable'
|
|
158
|
+
merged.hqState = options.hqState
|
|
159
|
+
?? (options.phase === 'claim' ? 'queued' : inferredFinalHqState)
|
|
160
|
+
merged.syncState = options.phase === 'claim' ? 'claimed' : 'finalized'
|
|
161
|
+
merged.serverInstanceId = getServerInstanceId()
|
|
162
|
+
merged.lifecycleUpdatedAt = new Date().toISOString()
|
|
163
|
+
durableAtomicWriteFileSync(destinationPath, JSON.stringify(merged, null, 2), { mode: 0o600 })
|
|
164
|
+
}
|
|
165
|
+
|
|
51
166
|
/** Stage local recording into operations/personal for exact enrichment. */
|
|
52
|
-
export function stageRecordingIntoOperations(
|
|
167
|
+
export function stageRecordingIntoOperations(
|
|
168
|
+
localMeetingPath: string,
|
|
169
|
+
options: G2StageOptions = { phase: 'final' },
|
|
170
|
+
): string | null {
|
|
53
171
|
const operationsDir = resolveCosOperationsDir()
|
|
54
172
|
if (!operationsDir) return null
|
|
55
173
|
if (!existsSync(localMeetingPath)) {
|
|
@@ -66,71 +184,114 @@ export function stageRecordingIntoOperations(localMeetingPath: string): string |
|
|
|
66
184
|
const destDir = join(operationsDir, 'personal', 'meetings', month)
|
|
67
185
|
mkdirSync(destDir, { recursive: true })
|
|
68
186
|
const destPath = join(destDir, basename(localMeetingPath))
|
|
69
|
-
const patched = patchRecordingForG2Pipeline(readFileSync(localMeetingPath, 'utf8'))
|
|
70
|
-
writeFileSync(destPath, patched, { encoding: 'utf8', mode: 0o600 })
|
|
187
|
+
const patched = patchRecordingForG2Pipeline(readFileSync(localMeetingPath, 'utf8'), options)
|
|
71
188
|
|
|
72
189
|
const stem = basename(localMeetingPath, '.md')
|
|
73
190
|
const localDir = dirname(localMeetingPath)
|
|
74
191
|
for (const companionName of [`${stem}.g2-chunks.json`, `${stem}.json`]) {
|
|
75
192
|
const companion = join(localDir, companionName)
|
|
76
193
|
if (existsSync(companion)) {
|
|
77
|
-
|
|
78
|
-
copyFileSync(companion, join(destDir, companionName))
|
|
79
|
-
} catch (error) {
|
|
80
|
-
console.warn(
|
|
81
|
-
`[g2-ops-handoff] Sidecar copy failed for ${companionName}: `
|
|
82
|
-
+ `${error instanceof Error ? error.message : String(error)}`,
|
|
83
|
-
)
|
|
84
|
-
}
|
|
194
|
+
mergeG2OperationsSidecar(companion, join(destDir, companionName), options)
|
|
85
195
|
}
|
|
86
196
|
}
|
|
197
|
+
// Markdown is the visible commit marker. Publish it only after every
|
|
198
|
+
// identity/revision sidecar has validated and committed.
|
|
199
|
+
durableAtomicWriteFileSync(destPath, patched, { mode: 0o600 })
|
|
87
200
|
return destPath
|
|
88
201
|
}
|
|
89
202
|
|
|
90
|
-
|
|
91
|
-
if (!COS_SCRIPTS_DIR
|
|
203
|
+
async function runOperationsSync(localMeetingPath: string, claimOnly: boolean): Promise<void> {
|
|
204
|
+
if (!COS_SCRIPTS_DIR) {
|
|
92
205
|
console.log('[meeting/save] Standalone mode — skipping G2 sync pipeline')
|
|
93
206
|
return
|
|
94
207
|
}
|
|
208
|
+
if (!PYTHON_BIN) throw new Error('COS operations configured but Python bridge is unavailable')
|
|
95
209
|
if (!existsSync(PYTHON_BIN)) {
|
|
96
|
-
|
|
97
|
-
return
|
|
98
|
-
}
|
|
99
|
-
|
|
100
|
-
const staged = stageRecordingIntoOperations(localMeetingPath)
|
|
101
|
-
if (!staged) {
|
|
102
|
-
console.warn('[meeting/save] COS operations dir unset/unavailable — G2 sync skipped')
|
|
103
|
-
return
|
|
210
|
+
throw new Error(`COS python missing at ${PYTHON_BIN}`)
|
|
104
211
|
}
|
|
105
212
|
|
|
106
213
|
const syncScript = join(COS_SCRIPTS_DIR, 'sync_meetings.py')
|
|
107
214
|
if (!existsSync(syncScript)) {
|
|
108
|
-
|
|
109
|
-
return
|
|
215
|
+
throw new Error(`sync_meetings.py missing at ${syncScript}`)
|
|
110
216
|
}
|
|
111
217
|
|
|
112
218
|
const spawnPath = process.env.PATH?.includes('/opt/homebrew/bin')
|
|
113
219
|
? process.env.PATH
|
|
114
220
|
: `/opt/homebrew/bin:${process.env.PATH || ''}`
|
|
115
221
|
|
|
222
|
+
const supportsLockedImport = syncScriptSupportsLockedImport(syncScript)
|
|
223
|
+
if (claimOnly && !supportsLockedImport) {
|
|
224
|
+
throw new Error('Early meeting sync requires a newer sync_meetings.py with --g2-import-file')
|
|
225
|
+
}
|
|
226
|
+
const meetingFile = supportsLockedImport
|
|
227
|
+
? localMeetingPath
|
|
228
|
+
: stageRecordingIntoOperations(localMeetingPath, { phase: 'final' })
|
|
229
|
+
if (!meetingFile) throw new Error('Unable to stage G2 recording into operations')
|
|
230
|
+
|
|
116
231
|
const enrichment = await runG2EnrichmentWithRetry({
|
|
117
232
|
pythonBin: PYTHON_BIN,
|
|
118
233
|
syncScript,
|
|
119
234
|
scriptsDir: COS_SCRIPTS_DIR,
|
|
120
|
-
meetingFile
|
|
121
|
-
env: {
|
|
122
|
-
|
|
235
|
+
meetingFile,
|
|
236
|
+
env: {
|
|
237
|
+
...process.env,
|
|
238
|
+
PYTHONUNBUFFERED: '1',
|
|
239
|
+
PATH: spawnPath,
|
|
240
|
+
COS_G2_IMPORT_ROOT: dirname(dirname(localMeetingPath)),
|
|
241
|
+
COS_SERVER_INSTANCE_ID: getServerInstanceId() ?? '',
|
|
242
|
+
},
|
|
243
|
+
claimOnly,
|
|
244
|
+
importLocal: supportsLockedImport,
|
|
245
|
+
retryDelaysMs: claimOnly ? [0] : undefined,
|
|
246
|
+
timeoutMs: claimOnly ? 30_000 : undefined,
|
|
247
|
+
onAttempt: message => console.log(`[meeting/save] G2 ${claimOnly ? 'claim' : 'exact sync'}: ${message}`),
|
|
123
248
|
})
|
|
124
249
|
|
|
125
250
|
if (enrichment.ok && enrichment.outcome) {
|
|
126
251
|
console.log(
|
|
127
|
-
`[meeting/save] G2 pipeline verified after ${enrichment.attempts} attempt(s): `
|
|
252
|
+
`[meeting/save] G2 ${claimOnly ? 'claim' : 'pipeline'} verified after ${enrichment.attempts} attempt(s): `
|
|
128
253
|
+ `${enrichment.outcome.title} → ${enrichment.outcome.path}`,
|
|
129
254
|
)
|
|
130
255
|
} else {
|
|
131
|
-
|
|
132
|
-
`
|
|
133
|
-
+ `${enrichment.error ?? 'unknown exact-file
|
|
256
|
+
throw new Error(
|
|
257
|
+
`G2 ${claimOnly ? 'claim' : 'pipeline'} failed after ${enrichment.attempts} attempt(s): `
|
|
258
|
+
+ `${enrichment.error ?? 'unknown exact-file sync failure'}`
|
|
134
259
|
)
|
|
135
260
|
}
|
|
136
261
|
}
|
|
262
|
+
|
|
263
|
+
function syncScriptSupportsLockedImport(syncScript: string): boolean {
|
|
264
|
+
try { return readFileSync(syncScript, 'utf8').includes('--g2-import-file') } catch { return false }
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
let claimQueueTail: Promise<void> = Promise.resolve()
|
|
268
|
+
|
|
269
|
+
export async function claimMeetingInOperations(localMeetingPath: string): Promise<void> {
|
|
270
|
+
if (!earlyMeetingSyncEnabled()) return
|
|
271
|
+
earlySyncRuntime.pendingCount += 1
|
|
272
|
+
const claim = claimQueueTail.then(async () => {
|
|
273
|
+
earlySyncRuntime.inFlight = true
|
|
274
|
+
try {
|
|
275
|
+
await runOperationsSync(localMeetingPath, true)
|
|
276
|
+
earlySyncRuntime.lastOutcome = 'claimed'
|
|
277
|
+
earlySyncRuntime.lastError = null
|
|
278
|
+
} catch (error) {
|
|
279
|
+
earlySyncRuntime.lastOutcome = 'failed'
|
|
280
|
+
earlySyncRuntime.lastError = error instanceof Error ? error.message : String(error)
|
|
281
|
+
throw error
|
|
282
|
+
} finally {
|
|
283
|
+
earlySyncRuntime.pendingCount -= 1
|
|
284
|
+
earlySyncRuntime.inFlight = earlySyncRuntime.pendingCount > 0
|
|
285
|
+
earlySyncRuntime.lastAt = new Date().toISOString()
|
|
286
|
+
}
|
|
287
|
+
})
|
|
288
|
+
claimQueueTail = claim.catch(() => undefined)
|
|
289
|
+
await claim
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
export async function handoffMeetingToOperations(localMeetingPath: string): Promise<void> {
|
|
293
|
+
await runOperationsSync(localMeetingPath, false)
|
|
294
|
+
earlySyncRuntime.lastOutcome = 'finalized'
|
|
295
|
+
earlySyncRuntime.lastError = null
|
|
296
|
+
earlySyncRuntime.lastAt = new Date().toISOString()
|
|
297
|
+
}
|