@gotcos/glasses-server 6.80.0 → 6.81.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 +12 -0
- package/package.json +2 -1
- package/server/index.ts +3 -1
- package/server/lib/meeting-autonomous-finalize.ts +267 -0
- package/server/lib/meeting-stop-intents.ts +277 -0
- package/server/routes/meeting.ts +326 -47
- package/server/routes/transcribe-stream.ts +48 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,15 @@
|
|
|
1
|
+
## 6.81.0 - The Mac saves a stopped meeting on its own once it holds every chunk
|
|
2
|
+
|
|
3
|
+
Miles, 2026-10-20: "if the application isn't in the foreground, it can fail to save the meetings". Both G2 meetings that day saved only once the phone came back on screen: the phone's Stop needs IndexedDB, and iOS suspends IndexedDB while the companion is hidden (11:19 qbbgyq: Stop 12:43, `stop_blocked`, "Load failed", saved 12:47 by a new WebView; 14:07 mip9qb: Stop 15:13:50, saved 15:14:37 only after the phone was visible). The full map is `docs/meeting-autonomous-save.md`.
|
|
4
|
+
|
|
5
|
+
- **Client matrix.** Server only. Nothing changes until the companion sends the new Stop (a companion release, not in this one). Older companions never call the new route and save exactly as before.
|
|
6
|
+
- **The phone's Stop, durable.** `POST /api/meeting/sessions/:id/stop` `{finalChunkIndex, serverInstanceId}`: one small fetch a hidden phone can still send. The server pin is required. It is written to `data/meeting-stop-intents.json` (0600, atomic) before the 202, so a restart keeps it. A repeat is idempotent; a higher index raises it, a lower one never lowers it. A Stop behind what the Mac already holds is refused 409 `final_index_behind_server`. It never closes the session and never refuses a chunk.
|
|
7
|
+
- **The Mac saves only a complete capture.** Once every chunk 0..finalChunkIndex has arrived and finished ASR, and 45 s have passed since the Stop and since the last chunk (so a phone on screen saves first), the Mac runs the same save the phone's `POST /meeting/save` runs: one lock, one idempotent receipt. A gap means audio exists only on the phone, so the Mac waits and the late chunks land in the SAME live session. There is no timeout that saves with a gap: a gap that never fills is left to the existing 4 h stranded sweep, unchanged. A chunk past the Stop revokes it (the meeting is still recording). Checked every 15 s while a Stop waits, only while admissions are open and session recovery has run.
|
|
8
|
+
- **The phone coming back.** Status answers `saved` with the receipt; `POST /meeting/save` replays it; and a chunk the phone re-sends because its answer was lost while hidden is answered from the saved sidecar instead of 410, so the phone no longer reads that state as a collision. This replay is only for meetings the Mac saved itself; every other closed session still answers 410.
|
|
9
|
+
- **Telemetry.** `[meeting/auto-save]` lines and `data/meeting-autonomous-save.jsonl`: `stop_received`, `waiting` (once per change, with missing count and sample), `finalized` (chunks, canonical, gaps, waited ms), `phone_saved` (or `client_saved` for an unpinned save such as the stranded sweep's), `phone_confirmed` (how and how long after), `late_replay`, `late_chunk_refused`, `revoked`, `stood_down`, `finalize_failed`. The status route carries `stopIntent` additively. `recording_stop` carries `savedBy: "server"` for the Mac's own saves.
|
|
10
|
+
- **Unchanged.** Transcription engines, the G2 matching thresholds, the restart and maintenance gate semantics, the stranded sweeper, and the save route's answers (its body moved into one shared `performSave`; the mutation gate anchor `cx-6533-save-nothing-to-save` follows it).
|
|
11
|
+
- **Checks.** Full suite 8,462 passed, 0 failed, 3 skipped (557 files); `tsc --noEmit` clean. `server/scripts/autonomous-save-mutation-gate.mjs` (not packaged): green baseline (63 tests in 4 files), 42 of 42 mutants killed, each by a named test. The moved `cx-6533-save-nothing-to-save` anchor re-runs killed. Journeys: phone hidden after Stop (the Mac saves alone), phone back late with missing chunks (merged into one meeting), phone and Mac racing (one meeting), against the real chunk and save routes. Needs the companion release to send the Stop before it does anything on a real phone.
|
|
12
|
+
|
|
1
13
|
## 6.80.0 - Answer with your own key (opt in, off by default)
|
|
2
14
|
|
|
3
15
|
You can now give COS your own provider key (OpenRouter, Anthropic, OpenAI, or a compatible endpoint) and pick a key profile for a thread, so that thread answers on the key instead of your Claude or ChatGPT plan. **This is opt in. Without a key, nothing changes:** with `COS_KEY_LANES` unset or `0`, or with lanes on but no key profile saved, the server answers, spawns and writes exactly as 6.79.1 did. Turning it on and saving keys happens in COS Control on this Mac.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@gotcos/glasses-server",
|
|
3
|
-
"version": "6.
|
|
3
|
+
"version": "6.81.0",
|
|
4
4
|
"description": "COS Glasses: self-hosted AI heads-up-display server for Even G2 smart glasses, powered by Claude Code, Codex, Cursor Agent CLI, or local Ollama",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -54,6 +54,7 @@
|
|
|
54
54
|
"!server/scripts/work-search-mutation-gate.mjs",
|
|
55
55
|
"!server/scripts/fact-check-mutation-gate.mjs",
|
|
56
56
|
"!server/scripts/stale-session-mutation-gate.mjs",
|
|
57
|
+
"!server/scripts/autonomous-save-mutation-gate.mjs",
|
|
57
58
|
"!server/scripts/stt-mutation-gate.mjs",
|
|
58
59
|
"!server/scripts/dictation-stt-mutation-gate.mjs",
|
|
59
60
|
"!server/scripts/pairing-mutation-gate.mjs",
|
package/server/index.ts
CHANGED
|
@@ -74,7 +74,7 @@ import { nativeHead, realNativeHeadDeps, registerClaudeTranscriptHint } from './
|
|
|
74
74
|
import { threadOccupancy, holderActivity } from './lib/thread-occupancy.js'
|
|
75
75
|
import { displayRouter } from './routes/display.js'
|
|
76
76
|
import { transcribeStreamRouter, getActiveTranscriptionSessionCount } from './routes/transcribe-stream.js'
|
|
77
|
-
import { meetingRouter, resumeMeetingFinalizationJobs, scheduleTranscriptCleanupSweep, transcriptCleanupRouteDeps } from './routes/meeting.js'
|
|
77
|
+
import { meetingRouter, resumeAutonomousMeetingSaves, resumeMeetingFinalizationJobs, scheduleTranscriptCleanupSweep, transcriptCleanupRouteDeps } from './routes/meeting.js'
|
|
78
78
|
import { createTranscriptCleanupRouter } from './routes/transcript-cleanup.js'
|
|
79
79
|
import { meetingsRouter } from './routes/meetings.js'
|
|
80
80
|
import { openaiCompatRouter } from './routes/openai-compat.js'
|
|
@@ -1473,6 +1473,8 @@ listenRequiredServers(listeners).then(() => {
|
|
|
1473
1473
|
// Resume already-saved meetings whose post-response HQ/operations work
|
|
1474
1474
|
// was interrupted by a prior server update or process exit.
|
|
1475
1475
|
resumeMeetingFinalizationJobs()
|
|
1476
|
+
// 6.81.0: Stops the phone sent before the last exit; the server saves them once complete.
|
|
1477
|
+
resumeAutonomousMeetingSaves()
|
|
1476
1478
|
// 6.77.0: transcript cleanup data whose meeting is gone, and screen text past retention.
|
|
1477
1479
|
scheduleTranscriptCleanupSweep()
|
|
1478
1480
|
|
|
@@ -0,0 +1,267 @@
|
|
|
1
|
+
// The server finishes a stopped G2 meeting on its own (6.81.0).
|
|
2
|
+
//
|
|
3
|
+
// Miles, 2026-10-20: "if the application isn't in the foreground, it can fail to save the
|
|
4
|
+
// meetings". Once the phone has told the server its meeting stopped (lib/meeting-stop-
|
|
5
|
+
// intents.ts) and the server holds every chunk it captured, nothing is left that needs the
|
|
6
|
+
// phone, so the server runs the same save the phone would have run. The phone, when it
|
|
7
|
+
// comes back on screen, finds a saved meeting and adopts the receipt.
|
|
8
|
+
//
|
|
9
|
+
// THE ONE RULE: NEVER SAVE A CAPTURE THAT IS NOT COMPLETE. The server saves on its own only
|
|
10
|
+
// when its ledger holds every index 0..N (N = the phone's last captured index) and every one
|
|
11
|
+
// of them has finished ASR. A gap means audio exists only on the phone, so the server waits
|
|
12
|
+
// and the session stays live: the phone's late upload lands in the SAME session (the merge)
|
|
13
|
+
// and the next check saves. There is no shorter timeout that saves with the gap, because a
|
|
14
|
+
// save closes the session, and a chunk that arrives after the close is refused (410) and the
|
|
15
|
+
// phone reads "server closed, local audio pending" as a collision it cannot resolve: the
|
|
16
|
+
// meeting splits or the tail is lost. A gap that never fills is handed to the existing
|
|
17
|
+
// stranded sweeper at its existing 4 h horizon, exactly as before this release. Waiting
|
|
18
|
+
// costs only time; saving early costs audio.
|
|
19
|
+
//
|
|
20
|
+
// Why a settle delay as well: a phone that is on screen saves within a few seconds of Stop.
|
|
21
|
+
// The server stands back for AUTONOMOUS_FINALIZE_SETTLE_MS after the Stop and after the last
|
|
22
|
+
// chunk arrived, so a healthy phone's own save is the normal path and the server's is the
|
|
23
|
+
// fallback. Both racing is safe anyway (one save lock, an idempotent save route), but not
|
|
24
|
+
// racing is quieter.
|
|
25
|
+
|
|
26
|
+
import type { StopIntent } from './meeting-stop-intents.js'
|
|
27
|
+
|
|
28
|
+
/** Quiet time after the Stop and after the last chunk before the server saves on its own. */
|
|
29
|
+
export const AUTONOMOUS_FINALIZE_SETTLE_MS = 45_000
|
|
30
|
+
/** How often a waiting Stop is checked again. Cheap: an in-memory ledger read. */
|
|
31
|
+
export const AUTONOMOUS_FINALIZE_POLL_MS = 15_000
|
|
32
|
+
/** Missing indices kept in a log line. */
|
|
33
|
+
const MISSING_SAMPLE = 8
|
|
34
|
+
|
|
35
|
+
export interface SessionLedger {
|
|
36
|
+
state: 'active' | 'closed' | 'missing'
|
|
37
|
+
received: readonly number[]
|
|
38
|
+
asrCompleted: readonly number[]
|
|
39
|
+
maxChunkIndex: number
|
|
40
|
+
/** ms since epoch of the last chunk arrival, or null. */
|
|
41
|
+
lastActivityAt: number | null
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export type AutonomousWaitReason = 'settling' | 'missing_chunks' | 'asr_pending' | 'session_unknown' | 'saving' | 'not_ready'
|
|
45
|
+
export type AutonomousStandDownReason = 'already_saved' | 'closed_unsaved' | 'nothing_captured' | 'server_instance_mismatch'
|
|
46
|
+
|
|
47
|
+
export type AutonomousDecision =
|
|
48
|
+
| { action: 'finalize'; chunks: number }
|
|
49
|
+
| { action: 'wait'; reason: AutonomousWaitReason; missing: number[]; missingCount: number; retryInMs: number }
|
|
50
|
+
| { action: 'revoke'; reason: 'chunk_beyond_final'; maxChunkIndex: number }
|
|
51
|
+
| { action: 'stand_down'; reason: AutonomousStandDownReason }
|
|
52
|
+
|
|
53
|
+
function missingFrom(finalIndex: number, have: readonly number[]): { sample: number[]; count: number } {
|
|
54
|
+
const present = new Set(have)
|
|
55
|
+
const sample: number[] = []
|
|
56
|
+
let count = 0
|
|
57
|
+
for (let index = 0; index <= finalIndex; index++) {
|
|
58
|
+
if (present.has(index)) continue
|
|
59
|
+
count++
|
|
60
|
+
if (sample.length < MISSING_SAMPLE) sample.push(index)
|
|
61
|
+
}
|
|
62
|
+
return { sample, count }
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* What to do with a waiting Stop right now. Pure: every input is passed in.
|
|
67
|
+
*
|
|
68
|
+
* Order matters and each step is pinned by a test: a saved meeting is never saved again, a
|
|
69
|
+
* chunk past the Stop means the meeting is still recording (revoke, never save), and only a
|
|
70
|
+
* complete, fully transcribed, quiet capture is saved.
|
|
71
|
+
*/
|
|
72
|
+
export function decideAutonomousFinalize(input: {
|
|
73
|
+
intent: Pick<StopIntent, 'finalChunkIndex' | 'receivedAt'>
|
|
74
|
+
ledger: SessionLedger
|
|
75
|
+
now: number
|
|
76
|
+
alreadySaved: boolean
|
|
77
|
+
saving: boolean
|
|
78
|
+
}): AutonomousDecision {
|
|
79
|
+
const { intent, ledger, now } = input
|
|
80
|
+
if (input.alreadySaved) return { action: 'stand_down', reason: 'already_saved' }
|
|
81
|
+
if (input.saving) return { action: 'wait', reason: 'saving', missing: [], missingCount: 0, retryInMs: AUTONOMOUS_FINALIZE_POLL_MS }
|
|
82
|
+
// A session closed WITHOUT a save receipt (expired, quarantined) is the stranded and
|
|
83
|
+
// quarantine paths' to finish. Saving here would race them.
|
|
84
|
+
if (ledger.state === 'closed') return { action: 'stand_down', reason: 'closed_unsaved' }
|
|
85
|
+
if (intent.finalChunkIndex < 0) return { action: 'stand_down', reason: 'nothing_captured' }
|
|
86
|
+
// The server holds audio the phone says it never captured: the phone resumed after this
|
|
87
|
+
// Stop, or the Stop is stale. Either way the meeting may still be recording.
|
|
88
|
+
if (ledger.maxChunkIndex > intent.finalChunkIndex) {
|
|
89
|
+
return { action: 'revoke', reason: 'chunk_beyond_final', maxChunkIndex: ledger.maxChunkIndex }
|
|
90
|
+
}
|
|
91
|
+
// An offline capture: the Stop arrived before any chunk did. Wait for the upload.
|
|
92
|
+
if (ledger.state === 'missing') {
|
|
93
|
+
return { action: 'wait', reason: 'session_unknown', missing: [], missingCount: intent.finalChunkIndex + 1, retryInMs: AUTONOMOUS_FINALIZE_POLL_MS }
|
|
94
|
+
}
|
|
95
|
+
const absent = missingFrom(intent.finalChunkIndex, ledger.received)
|
|
96
|
+
if (absent.count > 0) {
|
|
97
|
+
return { action: 'wait', reason: 'missing_chunks', missing: absent.sample, missingCount: absent.count, retryInMs: AUTONOMOUS_FINALIZE_POLL_MS }
|
|
98
|
+
}
|
|
99
|
+
const pending = missingFrom(intent.finalChunkIndex, ledger.asrCompleted)
|
|
100
|
+
if (pending.count > 0) {
|
|
101
|
+
return { action: 'wait', reason: 'asr_pending', missing: pending.sample, missingCount: pending.count, retryInMs: AUTONOMOUS_FINALIZE_POLL_MS }
|
|
102
|
+
}
|
|
103
|
+
const quietSince = Math.max(intent.receivedAt, ledger.lastActivityAt ?? 0)
|
|
104
|
+
const settleLeft = quietSince + AUTONOMOUS_FINALIZE_SETTLE_MS - now
|
|
105
|
+
if (settleLeft > 0) {
|
|
106
|
+
return { action: 'wait', reason: 'settling', missing: [], missingCount: 0, retryInMs: Math.min(settleLeft, AUTONOMOUS_FINALIZE_POLL_MS) }
|
|
107
|
+
}
|
|
108
|
+
return { action: 'finalize', chunks: intent.finalChunkIndex + 1 }
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** Expand `[start, end]` ranges (the status route's shape) into indices. */
|
|
112
|
+
export function indicesFromRanges(ranges: ReadonlyArray<readonly [number, number]>): number[] {
|
|
113
|
+
const out: number[] = []
|
|
114
|
+
for (const [start, end] of ranges) for (let index = start; index <= end; index++) out.push(index)
|
|
115
|
+
return out
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
export type AutonomousSaveOutcome =
|
|
119
|
+
| { ok: true; filename: string; sidecarPath: string; chunkCount: number; canonicalCount: number; missingIndices: number[] }
|
|
120
|
+
| { ok: false; status: number; reason: string }
|
|
121
|
+
|
|
122
|
+
export interface AutonomousFinalizerDeps {
|
|
123
|
+
waiting(): StopIntent[]
|
|
124
|
+
get(sessionId: string): StopIntent | null
|
|
125
|
+
update(sessionId: string, patch: Partial<StopIntent>): void
|
|
126
|
+
ledger(sessionId: string): SessionLedger
|
|
127
|
+
alreadySaved(sessionId: string): boolean
|
|
128
|
+
saving(sessionId: string): boolean
|
|
129
|
+
save(intent: StopIntent): Promise<AutonomousSaveOutcome>
|
|
130
|
+
/** Maintenance admissions open AND durable session recovery has run. */
|
|
131
|
+
ready(): boolean
|
|
132
|
+
now(): number
|
|
133
|
+
/** Returns a cancel function. Production timers are unref'd so they never hold the process. */
|
|
134
|
+
schedule(fn: () => void, ms: number): () => void
|
|
135
|
+
log(event: string, fields: Record<string, unknown>): void
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
export interface AutonomousFinalizer {
|
|
139
|
+
/** Check one session now (a Stop arrived, a boot). Never throws. */
|
|
140
|
+
check(sessionId: string, trigger: string): Promise<AutonomousDecision | null>
|
|
141
|
+
/** Check every waiting Stop, then re-arm while any remain. */
|
|
142
|
+
tick(trigger?: string): Promise<void>
|
|
143
|
+
/** Make sure a tick is scheduled. */
|
|
144
|
+
arm(ms?: number): void
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
export function createAutonomousFinalizer(deps: AutonomousFinalizerDeps): AutonomousFinalizer {
|
|
148
|
+
const inFlight = new Set<string>()
|
|
149
|
+
let cancel: (() => void) | null = null
|
|
150
|
+
let armedFor = Infinity
|
|
151
|
+
|
|
152
|
+
const arm = (ms = AUTONOMOUS_FINALIZE_POLL_MS) => {
|
|
153
|
+
const due = deps.now() + Math.max(0, ms)
|
|
154
|
+
if (cancel && armedFor <= due) return
|
|
155
|
+
cancel?.()
|
|
156
|
+
armedFor = due
|
|
157
|
+
cancel = deps.schedule(() => {
|
|
158
|
+
cancel = null
|
|
159
|
+
armedFor = Infinity
|
|
160
|
+
void finalizer.tick('poll')
|
|
161
|
+
}, Math.max(0, ms))
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
const check = async (sessionId: string, trigger: string): Promise<AutonomousDecision | null> => {
|
|
165
|
+
if (inFlight.has(sessionId)) return null
|
|
166
|
+
const intent = deps.get(sessionId)
|
|
167
|
+
if (!intent || intent.state !== 'waiting') return null
|
|
168
|
+
// A boot behind a closed maintenance gate has not adopted the persisted sessions yet,
|
|
169
|
+
// so "the server holds no chunks" is not evidence of anything. Wait, never decide.
|
|
170
|
+
if (!deps.ready()) {
|
|
171
|
+
return { action: 'wait', reason: 'not_ready', missing: [], missingCount: 0, retryInMs: AUTONOMOUS_FINALIZE_POLL_MS }
|
|
172
|
+
}
|
|
173
|
+
inFlight.add(sessionId)
|
|
174
|
+
try {
|
|
175
|
+
const now = deps.now()
|
|
176
|
+
const decision = decideAutonomousFinalize({
|
|
177
|
+
intent,
|
|
178
|
+
ledger: deps.ledger(sessionId),
|
|
179
|
+
now,
|
|
180
|
+
alreadySaved: deps.alreadySaved(sessionId),
|
|
181
|
+
saving: deps.saving(sessionId),
|
|
182
|
+
})
|
|
183
|
+
if (decision.action === 'stand_down') {
|
|
184
|
+
// A save the phone (or anyone) already made is the phone's save, not ours.
|
|
185
|
+
deps.update(sessionId, decision.reason === 'already_saved'
|
|
186
|
+
? { state: 'saved', savedBy: intent.savedBy ?? 'phone', savedAt: intent.savedAt ?? now }
|
|
187
|
+
: { state: 'stood_down', reason: decision.reason })
|
|
188
|
+
deps.log('stood_down', { sessionId, reason: decision.reason, trigger, waitedMs: now - intent.receivedAt })
|
|
189
|
+
return decision
|
|
190
|
+
}
|
|
191
|
+
if (decision.action === 'revoke') {
|
|
192
|
+
deps.update(sessionId, { state: 'revoked', reason: decision.reason })
|
|
193
|
+
deps.log('revoked', { sessionId, reason: decision.reason, finalChunkIndex: intent.finalChunkIndex, maxChunkIndex: decision.maxChunkIndex, trigger })
|
|
194
|
+
return decision
|
|
195
|
+
}
|
|
196
|
+
if (decision.action === 'wait') {
|
|
197
|
+
const last = intent.lastWait
|
|
198
|
+
// One line per change of state, not one per poll.
|
|
199
|
+
if (!last || last.reason !== decision.reason || last.missingCount !== decision.missingCount) {
|
|
200
|
+
deps.update(sessionId, { lastWait: { reason: decision.reason, missingCount: decision.missingCount, missingSample: decision.missing, at: now } })
|
|
201
|
+
deps.log('waiting', { sessionId, reason: decision.reason, missingCount: decision.missingCount, missingSample: decision.missing, finalChunkIndex: intent.finalChunkIndex, trigger })
|
|
202
|
+
}
|
|
203
|
+
return decision
|
|
204
|
+
}
|
|
205
|
+
const outcome = await deps.save(intent)
|
|
206
|
+
const at = deps.now()
|
|
207
|
+
if (outcome.ok) {
|
|
208
|
+
deps.update(sessionId, {
|
|
209
|
+
state: 'saved', savedBy: 'server', savedAt: at,
|
|
210
|
+
filename: outcome.filename, sidecarPath: outcome.sidecarPath,
|
|
211
|
+
chunkCount: outcome.chunkCount, canonicalCount: outcome.canonicalCount, lastWait: null,
|
|
212
|
+
})
|
|
213
|
+
deps.log('finalized', {
|
|
214
|
+
sessionId, reason: 'stop_received_complete', trigger,
|
|
215
|
+
finalChunkIndex: intent.finalChunkIndex, chunks: outcome.chunkCount, canonical: outcome.canonicalCount,
|
|
216
|
+
gaps: outcome.missingIndices.length, filename: outcome.filename,
|
|
217
|
+
waitedMs: at - intent.receivedAt, phoneConfirmed: false,
|
|
218
|
+
})
|
|
219
|
+
return decision
|
|
220
|
+
}
|
|
221
|
+
// The phone saved between the decision and the lock: its save, not ours.
|
|
222
|
+
if (outcome.reason === 'already_saved') {
|
|
223
|
+
deps.update(sessionId, { state: 'saved', savedBy: 'phone', savedAt: at, lastWait: null })
|
|
224
|
+
deps.log('stood_down', { sessionId, reason: 'already_saved', trigger, waitedMs: at - intent.receivedAt })
|
|
225
|
+
return { action: 'stand_down', reason: 'already_saved' }
|
|
226
|
+
}
|
|
227
|
+
// The phone pinned another server instance: this Mac is not where it meant to save.
|
|
228
|
+
if (outcome.reason === 'server_instance_mismatch') {
|
|
229
|
+
deps.update(sessionId, { state: 'stood_down', reason: outcome.reason })
|
|
230
|
+
deps.log('stood_down', { sessionId, reason: outcome.reason, trigger })
|
|
231
|
+
return { action: 'stand_down', reason: 'server_instance_mismatch' }
|
|
232
|
+
}
|
|
233
|
+
// The phone's own save holds the lock: it wins, and the next check reads its receipt.
|
|
234
|
+
if (outcome.status === 409 && outcome.reason === 'save_in_progress') {
|
|
235
|
+
return { action: 'wait', reason: 'saving', missing: [], missingCount: 0, retryInMs: AUTONOMOUS_FINALIZE_POLL_MS }
|
|
236
|
+
}
|
|
237
|
+
// Nothing to save (every chunk was silence): the phone's own Stop says so on the lens.
|
|
238
|
+
if (outcome.status === 404) {
|
|
239
|
+
deps.update(sessionId, { state: 'stood_down', reason: outcome.reason })
|
|
240
|
+
deps.log('stood_down', { sessionId, reason: outcome.reason, trigger })
|
|
241
|
+
return { action: 'stand_down', reason: 'nothing_captured' }
|
|
242
|
+
}
|
|
243
|
+
deps.log('finalize_failed', { sessionId, status: outcome.status, reason: outcome.reason, trigger })
|
|
244
|
+
return { action: 'wait', reason: 'saving', missing: [], missingCount: 0, retryInMs: AUTONOMOUS_FINALIZE_POLL_MS }
|
|
245
|
+
} catch (error) {
|
|
246
|
+
deps.log('finalize_failed', { sessionId, status: 500, reason: error instanceof Error ? error.message.slice(0, 200) : 'error', trigger })
|
|
247
|
+
return null
|
|
248
|
+
} finally {
|
|
249
|
+
inFlight.delete(sessionId)
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
const finalizer: AutonomousFinalizer = {
|
|
254
|
+
check,
|
|
255
|
+
async tick(trigger = 'poll') {
|
|
256
|
+
let soonest = Infinity
|
|
257
|
+
for (const intent of deps.waiting()) {
|
|
258
|
+
const decision = await check(intent.sessionId, trigger)
|
|
259
|
+
if (decision?.action === 'wait') soonest = Math.min(soonest, decision.retryInMs)
|
|
260
|
+
else if (decision === null && deps.get(intent.sessionId)?.state === 'waiting') soonest = Math.min(soonest, AUTONOMOUS_FINALIZE_POLL_MS)
|
|
261
|
+
}
|
|
262
|
+
if (Number.isFinite(soonest)) arm(soonest)
|
|
263
|
+
},
|
|
264
|
+
arm,
|
|
265
|
+
}
|
|
266
|
+
return finalizer
|
|
267
|
+
}
|
|
@@ -0,0 +1,277 @@
|
|
|
1
|
+
// The phone's Stop, as the server hears it (6.81.0).
|
|
2
|
+
//
|
|
3
|
+
// WHY THIS EXISTS. Until 6.81.0 the server learned that a G2 meeting had ended only from
|
|
4
|
+
// POST /api/meeting/save, and the phone sends that save only after its own finalize has
|
|
5
|
+
// read IndexedDB, reconciled the server's ledger and drained every chunk. iOS suspends
|
|
6
|
+
// IndexedDB while the companion is off screen, so a Stop pressed with the phone in a pocket
|
|
7
|
+
// sat unsaved until the app came back on screen (2026-10-20: 11:19 meeting qbbgyq, Stop at
|
|
8
|
+
// 12:43, `stop_blocked` and `foreground_reconcile_failed` "Load failed", saved only by a new
|
|
9
|
+
// WebView at 12:47; 14:07 meeting mip9qb, Stop at 15:13:50, saved at 15:14:37 only once
|
|
10
|
+
// the phone was visible). A fetch works while hidden; IndexedDB does not.
|
|
11
|
+
//
|
|
12
|
+
// WHAT A STOP INTENT IS. One small POST the phone can send the moment Stop is pressed,
|
|
13
|
+
// before any IndexedDB work: "this meeting has ended, and the last chunk I captured is
|
|
14
|
+
// index N". The server records it durably and from then on can finish the save itself
|
|
15
|
+
// once it holds every chunk 0..N (lib/meeting-autonomous-finalize.ts decides when).
|
|
16
|
+
//
|
|
17
|
+
// WHAT IT IS NOT. It never closes the session, never refuses a chunk, and never saves by
|
|
18
|
+
// itself. Chunks keep arriving into the same live session after it (that is the merge),
|
|
19
|
+
// and a chunk past N cancels it, because a meeting that is still recording must never be
|
|
20
|
+
// finalized.
|
|
21
|
+
|
|
22
|
+
import { appendFileSync, existsSync, mkdirSync, readFileSync, renameSync, statSync } from 'node:fs'
|
|
23
|
+
import { dirname } from 'node:path'
|
|
24
|
+
import { durableAtomicWriteFileSync } from './atomic-fs.js'
|
|
25
|
+
import { dataPath } from './data-dir.js'
|
|
26
|
+
import { LOCAL_FIRST_MEETING_IDLE_RETENTION_MS } from './local-first-meetings-contract.js'
|
|
27
|
+
|
|
28
|
+
export const STOP_INTENTS_FILE_NAME = 'meeting-stop-intents.json'
|
|
29
|
+
export const AUTONOMOUS_SAVE_LOG_NAME = 'meeting-autonomous-save.jsonl'
|
|
30
|
+
|
|
31
|
+
/** A finished intent stays readable this long, so a late phone can still be answered and logged. */
|
|
32
|
+
export const STOP_INTENT_TERMINAL_RETAIN_MS = 24 * 60 * 60_000
|
|
33
|
+
/**
|
|
34
|
+
* A waiting intent outlives the session it describes by one retention horizon. By then the
|
|
35
|
+
* stranded sweeper (4 h idle) has promoted or quarantined the session and owns it.
|
|
36
|
+
*/
|
|
37
|
+
export const STOP_INTENT_WAITING_RETAIN_MS = 2 * LOCAL_FIRST_MEETING_IDLE_RETENTION_MS
|
|
38
|
+
|
|
39
|
+
export type StopIntentState = 'waiting' | 'saved' | 'revoked' | 'stood_down'
|
|
40
|
+
/** `other`: an unpinned save (the stranded sweep's own POST, a manual curl). */
|
|
41
|
+
export type StopIntentSavedBy = 'server' | 'phone' | 'other'
|
|
42
|
+
|
|
43
|
+
export interface StopIntentWait {
|
|
44
|
+
reason: string
|
|
45
|
+
missingCount: number
|
|
46
|
+
/** The first few missing indices, for the log. Never the whole list. */
|
|
47
|
+
missingSample: number[]
|
|
48
|
+
at: number
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export interface StopIntent {
|
|
52
|
+
sessionId: string
|
|
53
|
+
/** The highest chunk index the phone captured. -1 = it captured nothing. */
|
|
54
|
+
finalChunkIndex: number
|
|
55
|
+
source: string
|
|
56
|
+
title?: string
|
|
57
|
+
domain?: string
|
|
58
|
+
serverInstanceId: string
|
|
59
|
+
/** Server clock, first receipt. The phone's clock is never read. */
|
|
60
|
+
receivedAt: number
|
|
61
|
+
updatedAt: number
|
|
62
|
+
/** How many times the phone sent it (a hidden phone retries). */
|
|
63
|
+
posts: number
|
|
64
|
+
state: StopIntentState
|
|
65
|
+
/** Why it is revoked or stood down. */
|
|
66
|
+
reason?: string
|
|
67
|
+
savedBy?: StopIntentSavedBy
|
|
68
|
+
savedAt?: number
|
|
69
|
+
filename?: string
|
|
70
|
+
sidecarPath?: string
|
|
71
|
+
chunkCount?: number
|
|
72
|
+
canonicalCount?: number
|
|
73
|
+
lastWait?: StopIntentWait | null
|
|
74
|
+
/** Set when the phone first reports back after the server saved on its own. */
|
|
75
|
+
phoneConfirmedAt?: number
|
|
76
|
+
phoneConfirmedBy?: string
|
|
77
|
+
/** Chunks the phone re-sent after the save that the saved meeting already held. */
|
|
78
|
+
lateReplays?: number
|
|
79
|
+
/** Chunks the phone sent after the save that the saved meeting did NOT hold. Should stay 0. */
|
|
80
|
+
lateRefused?: number
|
|
81
|
+
/** The state of an earlier Stop for this session that this one replaced. */
|
|
82
|
+
replaced?: StopIntentState
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export type RecordStopIntentResult =
|
|
86
|
+
| { ok: true; intent: StopIntent; created: boolean; raised: boolean }
|
|
87
|
+
| { ok: false; reason: 'terminal'; intent: StopIntent }
|
|
88
|
+
|
|
89
|
+
export interface StopIntentStore {
|
|
90
|
+
readonly path: string
|
|
91
|
+
get(sessionId: string): StopIntent | null
|
|
92
|
+
/** Waiting intents, oldest first. */
|
|
93
|
+
waiting(): StopIntent[]
|
|
94
|
+
/**
|
|
95
|
+
* Record or refresh the phone's Stop. Re-posting is idempotent; a higher final index
|
|
96
|
+
* raises it (never lowers it: the server must never finalize before the highest index the
|
|
97
|
+
* phone ever claimed has arrived).
|
|
98
|
+
*/
|
|
99
|
+
record(input: { sessionId: string; finalChunkIndex: number; source: string; title?: string; domain?: string; serverInstanceId: string }): RecordStopIntentResult
|
|
100
|
+
update(sessionId: string, patch: Partial<StopIntent>): StopIntent | null
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
104
|
+
return Boolean(value) && typeof value === 'object' && !Array.isArray(value)
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function readIntents(path: string): Record<string, StopIntent> {
|
|
108
|
+
if (!existsSync(path)) return {}
|
|
109
|
+
try {
|
|
110
|
+
const parsed = JSON.parse(readFileSync(path, 'utf-8')) as unknown
|
|
111
|
+
if (!isRecord(parsed) || !isRecord(parsed.intents)) return {}
|
|
112
|
+
const out: Record<string, StopIntent> = {}
|
|
113
|
+
for (const [id, raw] of Object.entries(parsed.intents)) {
|
|
114
|
+
if (!/^[A-Za-z0-9:_-]{3,96}$/.test(id) || !isRecord(raw)) continue
|
|
115
|
+
const intent = raw as unknown as StopIntent
|
|
116
|
+
if (intent.sessionId !== id) continue
|
|
117
|
+
if (!Number.isInteger(intent.finalChunkIndex) || intent.finalChunkIndex < -1) continue
|
|
118
|
+
if (!Number.isFinite(intent.receivedAt) || !Number.isFinite(intent.updatedAt)) continue
|
|
119
|
+
if (!['waiting', 'saved', 'revoked', 'stood_down'].includes(intent.state)) continue
|
|
120
|
+
out[id] = intent
|
|
121
|
+
}
|
|
122
|
+
return out
|
|
123
|
+
} catch {
|
|
124
|
+
// A corrupt ledger is set aside, never silently trusted or deleted.
|
|
125
|
+
try { renameSync(path, `${path}.corrupt.${Date.now()}`) } catch {}
|
|
126
|
+
return {}
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
export function createStopIntentStore(options: { path?: string; now?: () => number } = {}): StopIntentStore {
|
|
131
|
+
const path = options.path ?? dataPath(STOP_INTENTS_FILE_NAME)
|
|
132
|
+
const now = options.now ?? Date.now
|
|
133
|
+
let cache: Record<string, StopIntent> | null = null
|
|
134
|
+
|
|
135
|
+
const load = (): Record<string, StopIntent> => {
|
|
136
|
+
if (!cache) cache = readIntents(path)
|
|
137
|
+
return cache
|
|
138
|
+
}
|
|
139
|
+
const prune = (intents: Record<string, StopIntent>, at: number) => {
|
|
140
|
+
for (const [id, intent] of Object.entries(intents)) {
|
|
141
|
+
const retain = intent.state === 'waiting' ? STOP_INTENT_WAITING_RETAIN_MS : STOP_INTENT_TERMINAL_RETAIN_MS
|
|
142
|
+
if (at - intent.updatedAt > retain) delete intents[id]
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
// Persisted before the caller answers, so a 2xx means a restart still knows the Stop.
|
|
146
|
+
const persist = (intents: Record<string, StopIntent>) => {
|
|
147
|
+
mkdirSync(dirname(path), { recursive: true, mode: 0o700 })
|
|
148
|
+
durableAtomicWriteFileSync(path, JSON.stringify({ schemaVersion: 1, intents }, null, 2), { mode: 0o600 })
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
return {
|
|
152
|
+
path,
|
|
153
|
+
get(sessionId) {
|
|
154
|
+
const intent = load()[sessionId]
|
|
155
|
+
return intent ? { ...intent } : null
|
|
156
|
+
},
|
|
157
|
+
waiting() {
|
|
158
|
+
return Object.values(load())
|
|
159
|
+
.filter(intent => intent.state === 'waiting')
|
|
160
|
+
.sort((a, b) => a.receivedAt - b.receivedAt)
|
|
161
|
+
.map(intent => ({ ...intent }))
|
|
162
|
+
},
|
|
163
|
+
record(input) {
|
|
164
|
+
const at = now()
|
|
165
|
+
const intents = load()
|
|
166
|
+
prune(intents, at)
|
|
167
|
+
const prior = intents[input.sessionId]
|
|
168
|
+
// A saved Stop is final. A revoked or stood-down one is history: the phone resumed and
|
|
169
|
+
// stopped again, so this is a new Stop and replaces it.
|
|
170
|
+
if (prior && prior.state === 'saved') return { ok: false, reason: 'terminal', intent: { ...prior } }
|
|
171
|
+
const existing = prior?.state === 'waiting' ? prior : undefined
|
|
172
|
+
if (existing) {
|
|
173
|
+
const raised = input.finalChunkIndex > existing.finalChunkIndex
|
|
174
|
+
const next: StopIntent = {
|
|
175
|
+
...existing,
|
|
176
|
+
finalChunkIndex: Math.max(existing.finalChunkIndex, input.finalChunkIndex),
|
|
177
|
+
title: input.title ?? existing.title,
|
|
178
|
+
domain: input.domain ?? existing.domain,
|
|
179
|
+
updatedAt: at,
|
|
180
|
+
posts: existing.posts + 1,
|
|
181
|
+
}
|
|
182
|
+
intents[input.sessionId] = next
|
|
183
|
+
persist(intents)
|
|
184
|
+
return { ok: true, intent: { ...next }, created: false, raised }
|
|
185
|
+
}
|
|
186
|
+
const intent: StopIntent = {
|
|
187
|
+
sessionId: input.sessionId,
|
|
188
|
+
finalChunkIndex: input.finalChunkIndex,
|
|
189
|
+
source: input.source,
|
|
190
|
+
...(input.title ? { title: input.title } : {}),
|
|
191
|
+
...(input.domain ? { domain: input.domain } : {}),
|
|
192
|
+
serverInstanceId: input.serverInstanceId,
|
|
193
|
+
receivedAt: at,
|
|
194
|
+
updatedAt: at,
|
|
195
|
+
posts: 1,
|
|
196
|
+
state: 'waiting',
|
|
197
|
+
lastWait: null,
|
|
198
|
+
...(prior ? { replaced: prior.state } : {}),
|
|
199
|
+
}
|
|
200
|
+
intents[input.sessionId] = intent
|
|
201
|
+
persist(intents)
|
|
202
|
+
return { ok: true, intent: { ...intent }, created: true, raised: false }
|
|
203
|
+
},
|
|
204
|
+
update(sessionId, patch) {
|
|
205
|
+
const intents = load()
|
|
206
|
+
const existing = intents[sessionId]
|
|
207
|
+
if (!existing) return null
|
|
208
|
+
const next: StopIntent = { ...existing, ...patch, sessionId, updatedAt: now() }
|
|
209
|
+
intents[sessionId] = next
|
|
210
|
+
try { persist(intents) } catch (error) {
|
|
211
|
+
console.error(`[meeting/auto-save] Could not persist the stop intent for ${sessionId}: ${error instanceof Error ? error.message : error}`)
|
|
212
|
+
}
|
|
213
|
+
return { ...next }
|
|
214
|
+
},
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
let defaultStore: StopIntentStore | null = null
|
|
219
|
+
/** The process-wide store, created on first use (never at import: tests import this module). */
|
|
220
|
+
export function getStopIntentStore(): StopIntentStore {
|
|
221
|
+
if (!defaultStore) defaultStore = createStopIntentStore()
|
|
222
|
+
return defaultStore
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/** One JSON line per autonomous-save event, beside the server log line that says the same. */
|
|
226
|
+
export function appendAutonomousSaveLog(event: string, fields: Record<string, unknown>, path = dataPath(AUTONOMOUS_SAVE_LOG_NAME)): void {
|
|
227
|
+
try {
|
|
228
|
+
mkdirSync(dirname(path), { recursive: true, mode: 0o700 })
|
|
229
|
+
appendFileSync(path, `${JSON.stringify({ at: new Date().toISOString(), event, ...fields })}\n`, { mode: 0o600 })
|
|
230
|
+
} catch { /* the console line is the fallback record */ }
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
export interface SavedChunkText {
|
|
234
|
+
text: string
|
|
235
|
+
speaker: string
|
|
236
|
+
elapsed: number
|
|
237
|
+
[key: string]: unknown
|
|
238
|
+
}
|
|
239
|
+
export type SavedChunkReplay = { kind: 'canonical'; chunk: SavedChunkText } | { kind: 'empty' } | null
|
|
240
|
+
|
|
241
|
+
let sidecarCache: { path: string; mtimeMs: number; entries: Map<number, SavedChunkText> } | null = null
|
|
242
|
+
|
|
243
|
+
/**
|
|
244
|
+
* What a meeting the server saved on its own holds at one chunk index, for answering a
|
|
245
|
+
* phone that re-sends that chunk after the save (its upload response was lost while it was
|
|
246
|
+
* hidden). `canonical`: the saved text. `empty`: the chunk arrived and was silence. `null`:
|
|
247
|
+
* the saved meeting never held this index, so the replay is refused as before (410).
|
|
248
|
+
*
|
|
249
|
+
* Read from the saved sidecar, not from memory, so it survives a restart. One sidecar is
|
|
250
|
+
* cached at a time: replays arrive in a burst for one meeting.
|
|
251
|
+
*/
|
|
252
|
+
export function savedChunkReplay(sidecarPath: string, chunkIndex: number, asrCompleted: readonly number[]): SavedChunkReplay {
|
|
253
|
+
let entries: Map<number, SavedChunkText>
|
|
254
|
+
try {
|
|
255
|
+
const mtimeMs = statSync(sidecarPath).mtimeMs
|
|
256
|
+
if (sidecarCache && sidecarCache.path === sidecarPath && sidecarCache.mtimeMs === mtimeMs) {
|
|
257
|
+
entries = sidecarCache.entries
|
|
258
|
+
} else {
|
|
259
|
+
const sidecar = JSON.parse(readFileSync(sidecarPath, 'utf-8')) as { chunkEntries?: unknown }
|
|
260
|
+
entries = new Map()
|
|
261
|
+
for (const entry of Array.isArray(sidecar.chunkEntries) ? sidecar.chunkEntries : []) {
|
|
262
|
+
const candidate = entry as { chunkIndex?: unknown; chunk?: unknown }
|
|
263
|
+
const chunk = candidate?.chunk as Partial<SavedChunkText> | undefined
|
|
264
|
+
if (!Number.isInteger(candidate?.chunkIndex) || !chunk) continue
|
|
265
|
+
if (typeof chunk.text !== 'string' || !chunk.text || typeof chunk.speaker !== 'string') continue
|
|
266
|
+
if (typeof chunk.elapsed !== 'number' || !Number.isFinite(chunk.elapsed)) continue
|
|
267
|
+
entries.set(candidate.chunkIndex as number, chunk as SavedChunkText)
|
|
268
|
+
}
|
|
269
|
+
sidecarCache = { path: sidecarPath, mtimeMs, entries }
|
|
270
|
+
}
|
|
271
|
+
} catch {
|
|
272
|
+
return null
|
|
273
|
+
}
|
|
274
|
+
const chunk = entries.get(chunkIndex)
|
|
275
|
+
if (chunk) return { kind: 'canonical', chunk }
|
|
276
|
+
return asrCompleted.includes(chunkIndex) ? { kind: 'empty' } : null
|
|
277
|
+
}
|
package/server/routes/meeting.ts
CHANGED
|
@@ -11,6 +11,16 @@ import { createHash } from 'node:crypto'
|
|
|
11
11
|
import { Router } from 'express'
|
|
12
12
|
import { emitDisplay } from '../lib/display-bus.js'
|
|
13
13
|
import { createStopRequestStore, isStopRequestRefusal, STOP_REQUEST_CLAIM_WINDOW_MS, type StopRequest, type StopRequestStore } from '../lib/meeting-stop-requests.js'
|
|
14
|
+
import { appendAutonomousSaveLog, getStopIntentStore, type StopIntent, type StopIntentStore } from '../lib/meeting-stop-intents.js'
|
|
15
|
+
import {
|
|
16
|
+
AUTONOMOUS_FINALIZE_POLL_MS,
|
|
17
|
+
AUTONOMOUS_FINALIZE_SETTLE_MS,
|
|
18
|
+
createAutonomousFinalizer,
|
|
19
|
+
indicesFromRanges,
|
|
20
|
+
type AutonomousFinalizer,
|
|
21
|
+
type AutonomousSaveOutcome,
|
|
22
|
+
type SessionLedger,
|
|
23
|
+
} from '../lib/meeting-autonomous-finalize.js'
|
|
14
24
|
import { cleanTranscriptLines } from '../lib/hallucination-filter.js'
|
|
15
25
|
import { durableAtomicWriteFileSync } from '../lib/atomic-fs.js'
|
|
16
26
|
import { appendCorrection, appliedCorrections, pendingCorrections } from '../lib/meeting-corrections.js'
|
|
@@ -231,10 +241,25 @@ interface MeetingSessionSource {
|
|
|
231
241
|
delete(sessionId: string, options?: { preserveAudio?: boolean }): void
|
|
232
242
|
/** 6.53.3: live, closed within its retention, or unknown (`getMeetingSessionStatus`). */
|
|
233
243
|
getState?(sessionId: string): 'active' | 'closed' | 'missing'
|
|
244
|
+
/** 6.81.0: the received/ASR ledger the server's own save of a stopped meeting reads. */
|
|
245
|
+
getLedger?(sessionId: string): SessionLedger
|
|
234
246
|
}
|
|
235
247
|
|
|
236
248
|
const defaultSessionState = (sessionId: string): 'active' | 'closed' | 'missing' => getMeetingSessionStatus(sessionId).state
|
|
237
249
|
|
|
250
|
+
/** 6.81.0: the live ledger as the autonomous finalizer reads it (the status route's own numbers). */
|
|
251
|
+
function defaultSessionLedger(sessionId: string): SessionLedger {
|
|
252
|
+
const status = getMeetingSessionStatus(sessionId)
|
|
253
|
+
const lastActivityAt = status.lastActivityAt ? Date.parse(status.lastActivityAt) : NaN
|
|
254
|
+
return {
|
|
255
|
+
state: status.state,
|
|
256
|
+
received: indicesFromRanges(status.receivedRanges),
|
|
257
|
+
asrCompleted: indicesFromRanges(status.asrCompletedRanges),
|
|
258
|
+
maxChunkIndex: status.maxChunkIndex,
|
|
259
|
+
lastActivityAt: Number.isFinite(lastActivityAt) ? lastActivityAt : null,
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
|
|
238
263
|
export interface MeetingRouteDependencies {
|
|
239
264
|
store?: MeetingStore
|
|
240
265
|
sessions?: MeetingSessionSource
|
|
@@ -252,6 +277,16 @@ export interface MeetingRouteDependencies {
|
|
|
252
277
|
stopRequests?: StopRequestStore
|
|
253
278
|
/** 6.77.0: the post-save transcript cleanup (lib/transcript-cleanup.ts). */
|
|
254
279
|
transcriptCleanup?: TranscriptCleanupRunner
|
|
280
|
+
/** 6.81.0: the phone's Stop, durable (lib/meeting-stop-intents.ts). */
|
|
281
|
+
stopIntents?: StopIntentStore
|
|
282
|
+
/** 6.81.0: the server's own save of a stopped meeting. Tests inject the clock, timer and gate. */
|
|
283
|
+
autonomousFinalize?: {
|
|
284
|
+
now?: () => number
|
|
285
|
+
schedule?: (fn: () => void, ms: number) => () => void
|
|
286
|
+
ready?: () => boolean
|
|
287
|
+
log?: (event: string, fields: Record<string, unknown>) => void
|
|
288
|
+
onCreate?: (finalizer: AutonomousFinalizer) => void
|
|
289
|
+
}
|
|
255
290
|
}
|
|
256
291
|
|
|
257
292
|
const activeFinalizationJobs = new Set<string>()
|
|
@@ -584,6 +619,21 @@ function publicDisposition(disposition: CaptureDisposition | null): Record<strin
|
|
|
584
619
|
}
|
|
585
620
|
}
|
|
586
621
|
|
|
622
|
+
/** 6.81.0: the Stop the server holds, as the status and stop routes show it. Never a path. */
|
|
623
|
+
function publicStopIntent(intent: StopIntent): Record<string, unknown> {
|
|
624
|
+
return {
|
|
625
|
+
finalChunkIndex: intent.finalChunkIndex,
|
|
626
|
+
state: intent.state,
|
|
627
|
+
receivedAt: new Date(intent.receivedAt).toISOString(),
|
|
628
|
+
savedBy: intent.savedBy ?? null,
|
|
629
|
+
savedAt: intent.savedAt ? new Date(intent.savedAt).toISOString() : null,
|
|
630
|
+
reason: intent.reason ?? null,
|
|
631
|
+
waitingFor: intent.state === 'waiting' && intent.lastWait
|
|
632
|
+
? { reason: intent.lastWait.reason, missingCount: intent.lastWait.missingCount, missingSample: intent.lastWait.missingSample }
|
|
633
|
+
: null,
|
|
634
|
+
}
|
|
635
|
+
}
|
|
636
|
+
|
|
587
637
|
function suppliedServerPin(
|
|
588
638
|
req: { get: (name: string) => string | undefined },
|
|
589
639
|
fallback: unknown,
|
|
@@ -608,6 +658,83 @@ export function createMeetingRouter(deps: MeetingRouteDependencies = {}): Router
|
|
|
608
658
|
const stopRequests = deps.stopRequests ?? createStopRequestStore()
|
|
609
659
|
setNamingSessionLiveness(id => sessions.getStartTime(id) !== null || savingSessions.has(id) || activeFinalizationJobs.has(id))
|
|
610
660
|
|
|
661
|
+
// ── 6.81.0: the server finishes a stopped meeting on its own ─────────────────────
|
|
662
|
+
// lib/meeting-stop-intents.ts says what a Stop intent is; lib/meeting-autonomous-
|
|
663
|
+
// finalize.ts says when the server may save (only a complete capture, never early).
|
|
664
|
+
const stopIntents = deps.stopIntents ?? getStopIntentStore()
|
|
665
|
+
const autoDeps = deps.autonomousFinalize ?? {}
|
|
666
|
+
const autoNow = autoDeps.now ?? Date.now
|
|
667
|
+
const ledgerFor = (sessionId: string): SessionLedger => (sessions.getLedger ?? defaultSessionLedger)(sessionId)
|
|
668
|
+
const autoLog = autoDeps.log ?? ((event: string, fields: Record<string, unknown>) => {
|
|
669
|
+
const line = `[meeting/auto-save] ${event} ${String(fields.sessionId ?? '')} ${JSON.stringify(fields)}`
|
|
670
|
+
if (event === 'finalize_failed' || event === 'revoked' || event === 'late_chunk_refused') console.warn(line)
|
|
671
|
+
else console.log(line)
|
|
672
|
+
appendAutonomousSaveLog(event, fields)
|
|
673
|
+
})
|
|
674
|
+
|
|
675
|
+
/** Someone saved a meeting the phone had told us about before the server did: theirs, not ours. */
|
|
676
|
+
const notePhoneSavedFirst = (sessionId: string, savedBy: 'phone' | 'other') => {
|
|
677
|
+
const intent = stopIntents.get(sessionId)
|
|
678
|
+
if (!intent || intent.state !== 'waiting') return
|
|
679
|
+
const at = autoNow()
|
|
680
|
+
stopIntents.update(sessionId, { state: 'saved', savedBy, savedAt: at, lastWait: null })
|
|
681
|
+
autoLog(savedBy === 'phone' ? 'phone_saved' : 'client_saved', { sessionId, waitedMs: at - intent.receivedAt, finalChunkIndex: intent.finalChunkIndex })
|
|
682
|
+
}
|
|
683
|
+
/** The phone came back after the server saved on its own. Logged once, with how it showed up. */
|
|
684
|
+
const notePhoneAfterAutonomousSave = (sessionId: string, via: string) => {
|
|
685
|
+
const intent = stopIntents.get(sessionId)
|
|
686
|
+
if (!intent || intent.state !== 'saved' || intent.savedBy !== 'server' || intent.phoneConfirmedAt) return
|
|
687
|
+
const at = autoNow()
|
|
688
|
+
stopIntents.update(sessionId, { phoneConfirmedAt: at, phoneConfirmedBy: via })
|
|
689
|
+
autoLog('phone_confirmed', { sessionId, via, sinceSaveMs: at - (intent.savedAt ?? at), lateReplays: intent.lateReplays ?? 0, lateRefused: intent.lateRefused ?? 0 })
|
|
690
|
+
}
|
|
691
|
+
|
|
692
|
+
const autonomousSave = async (intent: StopIntent): Promise<AutonomousSaveOutcome> => {
|
|
693
|
+
// The phone pinned one server instance. A different one (a reinstall) is not its Mac.
|
|
694
|
+
if (intent.serverInstanceId !== getServerInstanceId()) return { ok: false, status: 409, reason: 'server_instance_mismatch' }
|
|
695
|
+
const entries = sessions.getChunkEntries(intent.sessionId) ?? []
|
|
696
|
+
const canonicalCount = entries.filter(entry => entry?.chunk?.text).length
|
|
697
|
+
const integrity = sessions.getIntegrity(intent.sessionId)
|
|
698
|
+
let status = 0
|
|
699
|
+
let payload: Record<string, unknown> = {}
|
|
700
|
+
const saved = await performSave(
|
|
701
|
+
intent.sessionId,
|
|
702
|
+
{ ...(intent.title ? { title: intent.title } : {}), ...(intent.domain ? { domain: intent.domain } : {}) },
|
|
703
|
+
(code, body) => { status = code; payload = body },
|
|
704
|
+
'server',
|
|
705
|
+
)
|
|
706
|
+
if (!saved || status !== 200) return { ok: false, status, reason: String(payload.reason ?? 'meeting_save_error') }
|
|
707
|
+
// Someone saved it between the decision and the lock: theirs, not ours.
|
|
708
|
+
if (payload.replayed === true) return { ok: false, status: 200, reason: 'already_saved' }
|
|
709
|
+
return {
|
|
710
|
+
ok: true,
|
|
711
|
+
filename: saved.filename,
|
|
712
|
+
sidecarPath: saved.sidecarPath,
|
|
713
|
+
chunkCount: intent.finalChunkIndex + 1,
|
|
714
|
+
canonicalCount,
|
|
715
|
+
missingIndices: integrity?.missingIndices ?? [],
|
|
716
|
+
}
|
|
717
|
+
}
|
|
718
|
+
|
|
719
|
+
const autonomousFinalizer = createAutonomousFinalizer({
|
|
720
|
+
waiting: () => stopIntents.waiting(),
|
|
721
|
+
get: sessionId => stopIntents.get(sessionId),
|
|
722
|
+
update: (sessionId, patch) => { stopIntents.update(sessionId, patch) },
|
|
723
|
+
ledger: ledgerFor,
|
|
724
|
+
alreadySaved: sessionId => store.findBySessionId(sessionId) !== null,
|
|
725
|
+
saving: sessionId => savingSessions.has(sessionId),
|
|
726
|
+
save: autonomousSave,
|
|
727
|
+
ready: autoDeps.ready ?? (() => maintenanceAdmissionsOpen() && durableSessionRecoveryHasRun()),
|
|
728
|
+
now: autoNow,
|
|
729
|
+
schedule: autoDeps.schedule ?? ((fn, ms) => {
|
|
730
|
+
const timer = setTimeout(fn, ms)
|
|
731
|
+
timer.unref?.()
|
|
732
|
+
return () => clearTimeout(timer)
|
|
733
|
+
}),
|
|
734
|
+
log: autoLog,
|
|
735
|
+
})
|
|
736
|
+
autoDeps.onCreate?.(autonomousFinalizer)
|
|
737
|
+
|
|
611
738
|
let recoveryPendingLogged = false
|
|
612
739
|
router.get('/meeting/sessions/:sessionId/status', (req, res) => {
|
|
613
740
|
const sessionId = String(req.params.sessionId ?? '')
|
|
@@ -651,6 +778,9 @@ export function createMeetingRouter(deps: MeetingRouteDependencies = {}): Router
|
|
|
651
778
|
// A saved meeting or a live session is the truth for itself; the quarantine is
|
|
652
779
|
// read only when neither is.
|
|
653
780
|
const disposition = state === 'saved' || state === 'active' ? null : readCaptureDisposition(sessionId)
|
|
781
|
+
// 6.81.0: a pinned status read is the phone; one after the server saved on its own is its return.
|
|
782
|
+
if (saved && pin) notePhoneAfterAutonomousSave(sessionId, 'status')
|
|
783
|
+
const intent = stopIntents.get(sessionId)
|
|
654
784
|
res.set('Cache-Control', 'private, no-store')
|
|
655
785
|
res.json({
|
|
656
786
|
sessionId,
|
|
@@ -670,6 +800,8 @@ export function createMeetingRouter(deps: MeetingRouteDependencies = {}): Router
|
|
|
670
800
|
// (null otherwise). Older clients ignore both.
|
|
671
801
|
outcome: captureOutcome(state, disposition),
|
|
672
802
|
disposition: publicDisposition(disposition),
|
|
803
|
+
// 6.81.0, additive: the Stop the server holds for this meeting, if any. Older clients ignore it.
|
|
804
|
+
stopIntent: intent ? publicStopIntent(intent) : null,
|
|
673
805
|
})
|
|
674
806
|
})
|
|
675
807
|
|
|
@@ -781,43 +913,124 @@ export function createMeetingRouter(deps: MeetingRouteDependencies = {}): Router
|
|
|
781
913
|
res.json(stopRequestBody(request, savingSessions.has(sessionId) ? { finalizing: true } : {}))
|
|
782
914
|
})
|
|
783
915
|
|
|
784
|
-
|
|
916
|
+
// ── 6.81.0: the phone's Stop ──────────────────────────────────────────────────────
|
|
917
|
+
// POST /meeting/sessions/:id/stop {finalChunkIndex, serverInstanceId}. One small fetch the
|
|
918
|
+
// phone can send the moment Stop is pressed, before any IndexedDB work, so a phone that is
|
|
919
|
+
// off screen still tells the Mac. It never closes the session and never refuses a chunk:
|
|
920
|
+
// the server saves on its own only once it holds every chunk 0..finalChunkIndex.
|
|
921
|
+
router.post('/meeting/sessions/:sessionId/stop', (req, res) => {
|
|
922
|
+
const sessionId = String(req.params.sessionId ?? '')
|
|
923
|
+
if (!/^[A-Za-z0-9:_-]{3,96}$/.test(sessionId)) {
|
|
924
|
+
res.status(400).json({ error: 'Invalid sessionId', reason: 'invalid_session_id' })
|
|
925
|
+
return
|
|
926
|
+
}
|
|
927
|
+
if (MAC_CAPTURE_ID.test(sessionId)) {
|
|
928
|
+
res.status(400).json({ error: 'A Mac recording stops from Record on Mac', reason: 'mac_capture' })
|
|
929
|
+
return
|
|
930
|
+
}
|
|
931
|
+
const body = req.body as Record<string, unknown> | undefined
|
|
932
|
+
const finalChunkIndex = body?.finalChunkIndex
|
|
933
|
+
if (!Number.isInteger(finalChunkIndex) || (finalChunkIndex as number) < -1 || (finalChunkIndex as number) > 1_000_000) {
|
|
934
|
+
res.status(400).json({ error: 'finalChunkIndex must be a whole number, -1 or more', reason: 'invalid_final_chunk_index' })
|
|
935
|
+
return
|
|
936
|
+
}
|
|
937
|
+
if ((body?.title !== undefined && typeof body.title !== 'string') || (body?.domain !== undefined && typeof body.domain !== 'string')) {
|
|
938
|
+
res.status(400).json({ error: 'title and domain must be strings', reason: 'invalid_metadata' })
|
|
939
|
+
return
|
|
940
|
+
}
|
|
941
|
+
const serverInstanceId = getServerInstanceId()
|
|
942
|
+
if (!serverInstanceId) {
|
|
943
|
+
res.status(503).json({ error: 'Server identity unavailable', reason: 'server_identity_unavailable' })
|
|
944
|
+
return
|
|
945
|
+
}
|
|
946
|
+
// Required, not optional: only a phone pinned to THIS Mac may hand it a Stop.
|
|
947
|
+
const pin = suppliedServerPin(req, body?.serverInstanceId)
|
|
948
|
+
if (!pin) {
|
|
949
|
+
res.status(400).json({ error: 'Server identity pin required', reason: 'server_identity_pin_required' })
|
|
950
|
+
return
|
|
951
|
+
}
|
|
952
|
+
if (pin !== serverInstanceId) {
|
|
953
|
+
res.status(409).json({ error: 'Server identity mismatch', reason: 'server_instance_mismatch' })
|
|
954
|
+
return
|
|
955
|
+
}
|
|
956
|
+
res.set('Cache-Control', 'private, no-store')
|
|
957
|
+
const saved = store.findBySessionId(sessionId)
|
|
958
|
+
if (saved) {
|
|
959
|
+
notePhoneAfterAutonomousSave(sessionId, 'stop_retry')
|
|
960
|
+
res.json({ sessionId, state: 'saved', saveReceipt: publicSaveResponse(saved, true) })
|
|
961
|
+
return
|
|
962
|
+
}
|
|
963
|
+
const ready = (autoDeps.ready ?? (() => maintenanceAdmissionsOpen() && durableSessionRecoveryHasRun()))()
|
|
964
|
+
// Before recovery has run the ledger is empty by construction, so it proves nothing:
|
|
965
|
+
// record the Stop and let the finalizer judge it once the gate opens.
|
|
966
|
+
const ledger = ready ? ledgerFor(sessionId) : null
|
|
967
|
+
if (ledger?.state === 'closed') {
|
|
968
|
+
res.status(409).json({ error: 'This meeting is closed without a save', reason: 'not_live', state: 'closed' })
|
|
969
|
+
return
|
|
970
|
+
}
|
|
971
|
+
if (ledger && ledger.maxChunkIndex > (finalChunkIndex as number)) {
|
|
972
|
+
// The Mac holds audio past the phone's last chunk. Never take that Stop.
|
|
973
|
+
res.status(409).json({ error: 'The Mac holds chunks past finalChunkIndex', reason: 'final_index_behind_server', maxChunkIndex: ledger.maxChunkIndex })
|
|
974
|
+
return
|
|
975
|
+
}
|
|
976
|
+
const source = typeof body?.source === 'string' && /^[a-z][a-z0-9_-]{0,31}$/.test(body.source) ? body.source : 'companion'
|
|
977
|
+
let result: ReturnType<StopIntentStore['record']>
|
|
978
|
+
try {
|
|
979
|
+
result = stopIntents.record({
|
|
980
|
+
sessionId,
|
|
981
|
+
finalChunkIndex: finalChunkIndex as number,
|
|
982
|
+
source,
|
|
983
|
+
...(typeof body?.title === 'string' && body.title.trim() ? { title: body.title.slice(0, 160) } : {}),
|
|
984
|
+
...(typeof body?.domain === 'string' && body.domain.trim() ? { domain: body.domain } : {}),
|
|
985
|
+
serverInstanceId,
|
|
986
|
+
})
|
|
987
|
+
} catch (error) {
|
|
988
|
+
// Not durable means not received: the phone must keep its own Stop path.
|
|
989
|
+
console.error(`[meeting/auto-save] Could not record the Stop for ${sessionId}: ${error instanceof Error ? error.message : error}`)
|
|
990
|
+
res.status(503).json({ error: 'Stop could not be recorded; retry', reason: 'stop_persistence_failed', retryable: true })
|
|
991
|
+
return
|
|
992
|
+
}
|
|
993
|
+
if (!result.ok) {
|
|
994
|
+
res.json({ sessionId, state: 'saved', stopIntent: publicStopIntent(result.intent) })
|
|
995
|
+
return
|
|
996
|
+
}
|
|
997
|
+
const intent = result.intent
|
|
998
|
+
if (result.created || result.raised) {
|
|
999
|
+
autoLog(result.created ? 'stop_received' : 'stop_raised', {
|
|
1000
|
+
sessionId, source, finalChunkIndex: intent.finalChunkIndex, posts: intent.posts,
|
|
1001
|
+
received: ledger?.received.length ?? null, replaced: intent.replaced ?? null,
|
|
1002
|
+
})
|
|
1003
|
+
}
|
|
1004
|
+
void autonomousFinalizer.tick('stop_received')
|
|
1005
|
+
res.status(202).json({
|
|
1006
|
+
sessionId,
|
|
1007
|
+
state: 'stop_received',
|
|
1008
|
+
finalChunkIndex: intent.finalChunkIndex,
|
|
1009
|
+
receivedAt: new Date(intent.receivedAt).toISOString(),
|
|
1010
|
+
posts: intent.posts,
|
|
1011
|
+
autonomousSave: { settleMs: AUTONOMOUS_FINALIZE_SETTLE_MS, pollMs: AUTONOMOUS_FINALIZE_POLL_MS },
|
|
1012
|
+
})
|
|
1013
|
+
})
|
|
1014
|
+
|
|
1015
|
+
/**
|
|
1016
|
+
* The save itself, shared by the phone's POST /meeting/save and the server's own save of a
|
|
1017
|
+
* stopped meeting (6.81.0). One body so the two can never drift: the same lock, the same
|
|
1018
|
+
* idempotent receipt, the same durable sidecar-then-job order, and the same maintenance
|
|
1019
|
+
* lease order (the finalization job's lease is taken before the save lease is released).
|
|
1020
|
+
*
|
|
1021
|
+
* `respond` is called exactly once, before any background work is scheduled, as the route
|
|
1022
|
+
* always did. `origin` changes only bookkeeping: who is recorded as having saved, and the
|
|
1023
|
+
* phone-confirmed line when the phone replays a save the server already made.
|
|
1024
|
+
*/
|
|
1025
|
+
async function performSave(
|
|
1026
|
+
sessionId: string,
|
|
1027
|
+
body: Record<string, unknown> | undefined,
|
|
1028
|
+
respond: (status: number, payload: Record<string, unknown>, noStore?: boolean) => void,
|
|
1029
|
+
origin: 'phone' | 'client' | 'server',
|
|
1030
|
+
): Promise<SavedMeeting | null> {
|
|
785
1031
|
let lockedSessionId: string | null = null
|
|
786
1032
|
let maintenanceLease: MaintenanceWorkLease | undefined
|
|
787
1033
|
try {
|
|
788
|
-
const body = req.body as Record<string, unknown> | undefined
|
|
789
|
-
const sessionId = typeof body?.sessionId === 'string' ? body.sessionId : ''
|
|
790
|
-
if (!sessionId) {
|
|
791
|
-
res.status(400).json({ error: 'sessionId required', reason: 'missing_session_id' })
|
|
792
|
-
return
|
|
793
|
-
}
|
|
794
|
-
if (!/^[A-Za-z0-9:_-]{3,96}$/.test(sessionId)) {
|
|
795
|
-
res.status(400).json({ error: 'Invalid sessionId', reason: 'invalid_session_id' })
|
|
796
|
-
return
|
|
797
|
-
}
|
|
798
|
-
if (body?.title !== undefined && typeof body.title !== 'string') {
|
|
799
|
-
res.status(400).json({ error: 'Invalid title', reason: 'invalid_title' })
|
|
800
|
-
return
|
|
801
|
-
}
|
|
802
|
-
if (body?.domain !== undefined && typeof body.domain !== 'string') {
|
|
803
|
-
res.status(400).json({ error: 'Invalid domain', reason: 'invalid_domain' })
|
|
804
|
-
return
|
|
805
|
-
}
|
|
806
|
-
const serverInstanceId = getServerInstanceId()
|
|
807
|
-
if (!serverInstanceId) {
|
|
808
|
-
res.status(503).json({ error: 'Server identity unavailable', reason: 'server_identity_unavailable' })
|
|
809
|
-
return
|
|
810
|
-
}
|
|
811
|
-
const pin = suppliedServerPin(req, body?.serverInstanceId)
|
|
812
|
-
if (MAC_CAPTURE_ID.test(sessionId) && !pin) {
|
|
813
|
-
res.status(400).json({ error: 'Server identity pin required', reason: 'server_identity_pin_required' })
|
|
814
|
-
return
|
|
815
|
-
}
|
|
816
|
-
if (pin && pin !== serverInstanceId) {
|
|
817
|
-
res.status(409).json({ error: 'Server identity mismatch', reason: 'server_instance_mismatch' })
|
|
818
|
-
return
|
|
819
|
-
}
|
|
820
|
-
|
|
821
1034
|
// A response can be lost after both durable files were committed. Find
|
|
822
1035
|
// the sidecar by session ID so client retry/restart is idempotent.
|
|
823
1036
|
const alreadySaved = store.findBySessionId(sessionId)
|
|
@@ -833,16 +1046,16 @@ export function createMeetingRouter(deps: MeetingRouteDependencies = {}): Router
|
|
|
833
1046
|
sessions,
|
|
834
1047
|
finalDiarize,
|
|
835
1048
|
})
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
return
|
|
1049
|
+
if (origin === 'phone') notePhoneAfterAutonomousSave(sessionId, 'save_replay')
|
|
1050
|
+
respond(200, publicSaveResponse(alreadySaved, true), true)
|
|
1051
|
+
return alreadySaved
|
|
839
1052
|
}
|
|
840
1053
|
if (MAC_CAPTURE_ID.test(sessionId)) {
|
|
841
1054
|
requireMacRecordingForSave(sessionId, body?.expectedChunkCount)
|
|
842
1055
|
}
|
|
843
1056
|
if (savingSessions.has(sessionId)) {
|
|
844
|
-
|
|
845
|
-
return
|
|
1057
|
+
respond(409, { error: 'Meeting save already in progress', reason: 'save_in_progress' })
|
|
1058
|
+
return null
|
|
846
1059
|
}
|
|
847
1060
|
// Saving/finalizing an already-recording session is a drain
|
|
848
1061
|
// continuation. It must remain admitted so existing audio can reach a
|
|
@@ -858,10 +1071,10 @@ export function createMeetingRouter(deps: MeetingRouteDependencies = {}): Router
|
|
|
858
1071
|
// lets a client stop asking ("nothing to save") instead of retrying a 404 all day.
|
|
859
1072
|
// Still 404, so every existing client reads it exactly as before.
|
|
860
1073
|
const known = (sessions.getState ?? defaultSessionState)(sessionId)
|
|
861
|
-
|
|
1074
|
+
respond(404, known === 'active' || known === 'closed'
|
|
862
1075
|
? { error: `Nothing to save for session ${sessionId}`, reason: 'nothing_to_save', state: known }
|
|
863
1076
|
: { error: `No transcript found for session ${sessionId}`, reason: 'session_not_found' })
|
|
864
|
-
return
|
|
1077
|
+
return null
|
|
865
1078
|
}
|
|
866
1079
|
|
|
867
1080
|
const chunks = sessions.getChunks(sessionId) ?? []
|
|
@@ -913,6 +1126,10 @@ export function createMeetingRouter(deps: MeetingRouteDependencies = {}): Router
|
|
|
913
1126
|
finalizationRequired,
|
|
914
1127
|
claimPending,
|
|
915
1128
|
})
|
|
1129
|
+
// 6.81.0: who saved. A pinned save is the phone's; an unpinned one (the stranded sweep's
|
|
1130
|
+
// own POST, a manual curl) is someone else's. Either closes a waiting Stop. The server's
|
|
1131
|
+
// own save is recorded by its finalizer.
|
|
1132
|
+
if (origin !== 'server') notePhoneSavedFirst(sessionId, origin === 'phone' ? 'phone' : 'other')
|
|
916
1133
|
|
|
917
1134
|
// Two-phase replay intent: the canonical sidecar above is the recovery
|
|
918
1135
|
// source of truth, and this indexed job is its fast execution ledger.
|
|
@@ -965,6 +1182,8 @@ export function createMeetingRouter(deps: MeetingRouteDependencies = {}): Router
|
|
|
965
1182
|
filename: saved.filename,
|
|
966
1183
|
durationMin: saved.durationMin,
|
|
967
1184
|
domain: saved.domain,
|
|
1185
|
+
// 6.81.0, additive: the server saved this on its own after the phone's Stop.
|
|
1186
|
+
...(origin === 'server' ? { savedBy: 'server' } : {}),
|
|
968
1187
|
},
|
|
969
1188
|
})
|
|
970
1189
|
} catch (error) {
|
|
@@ -994,8 +1213,7 @@ export function createMeetingRouter(deps: MeetingRouteDependencies = {}): Router
|
|
|
994
1213
|
finalizationJob = null
|
|
995
1214
|
}
|
|
996
1215
|
|
|
997
|
-
|
|
998
|
-
res.json(publicSaveResponse(saved))
|
|
1216
|
+
respond(200, publicSaveResponse(saved), true)
|
|
999
1217
|
|
|
1000
1218
|
if (finalizationJob) {
|
|
1001
1219
|
scheduleFinalizationJob(finalizationJob, {
|
|
@@ -1007,17 +1225,68 @@ export function createMeetingRouter(deps: MeetingRouteDependencies = {}): Router
|
|
|
1007
1225
|
transcriptCleanup,
|
|
1008
1226
|
})
|
|
1009
1227
|
}
|
|
1228
|
+
return saved
|
|
1010
1229
|
} catch (error) {
|
|
1011
1230
|
if (error instanceof MeetingStoreError) {
|
|
1012
|
-
|
|
1013
|
-
return
|
|
1231
|
+
respond(error.status, { error: error.message, reason: error.code })
|
|
1232
|
+
return null
|
|
1014
1233
|
}
|
|
1015
1234
|
console.error('[meeting/save] Finalization failed:', error)
|
|
1016
|
-
|
|
1235
|
+
respond(500, { error: 'Meeting save failed', reason: 'meeting_save_error' })
|
|
1236
|
+
return null
|
|
1017
1237
|
} finally {
|
|
1018
1238
|
maintenanceLease?.release()
|
|
1019
1239
|
if (lockedSessionId) savingSessions.delete(lockedSessionId)
|
|
1020
1240
|
}
|
|
1241
|
+
}
|
|
1242
|
+
|
|
1243
|
+
router.post('/meeting/save', async (req, res) => {
|
|
1244
|
+
let responded = false
|
|
1245
|
+
const respond = (status: number, payload: Record<string, unknown>, noStore = false) => {
|
|
1246
|
+
if (responded) return
|
|
1247
|
+
responded = true
|
|
1248
|
+
if (noStore) res.set('Cache-Control', 'private, no-store')
|
|
1249
|
+
res.status(status).json(payload)
|
|
1250
|
+
}
|
|
1251
|
+
try {
|
|
1252
|
+
const body = req.body as Record<string, unknown> | undefined
|
|
1253
|
+
const sessionId = typeof body?.sessionId === 'string' ? body.sessionId : ''
|
|
1254
|
+
if (!sessionId) {
|
|
1255
|
+
res.status(400).json({ error: 'sessionId required', reason: 'missing_session_id' })
|
|
1256
|
+
return
|
|
1257
|
+
}
|
|
1258
|
+
if (!/^[A-Za-z0-9:_-]{3,96}$/.test(sessionId)) {
|
|
1259
|
+
res.status(400).json({ error: 'Invalid sessionId', reason: 'invalid_session_id' })
|
|
1260
|
+
return
|
|
1261
|
+
}
|
|
1262
|
+
if (body?.title !== undefined && typeof body.title !== 'string') {
|
|
1263
|
+
res.status(400).json({ error: 'Invalid title', reason: 'invalid_title' })
|
|
1264
|
+
return
|
|
1265
|
+
}
|
|
1266
|
+
if (body?.domain !== undefined && typeof body.domain !== 'string') {
|
|
1267
|
+
res.status(400).json({ error: 'Invalid domain', reason: 'invalid_domain' })
|
|
1268
|
+
return
|
|
1269
|
+
}
|
|
1270
|
+
const serverInstanceId = getServerInstanceId()
|
|
1271
|
+
if (!serverInstanceId) {
|
|
1272
|
+
res.status(503).json({ error: 'Server identity unavailable', reason: 'server_identity_unavailable' })
|
|
1273
|
+
return
|
|
1274
|
+
}
|
|
1275
|
+
const pin = suppliedServerPin(req, body?.serverInstanceId)
|
|
1276
|
+
if (MAC_CAPTURE_ID.test(sessionId) && !pin) {
|
|
1277
|
+
res.status(400).json({ error: 'Server identity pin required', reason: 'server_identity_pin_required' })
|
|
1278
|
+
return
|
|
1279
|
+
}
|
|
1280
|
+
if (pin && pin !== serverInstanceId) {
|
|
1281
|
+
res.status(409).json({ error: 'Server identity mismatch', reason: 'server_instance_mismatch' })
|
|
1282
|
+
return
|
|
1283
|
+
}
|
|
1284
|
+
// Only the phone pins its saves (the local-first contract); the stranded sweep does not.
|
|
1285
|
+
await performSave(sessionId, body, respond, pin ? 'phone' : 'client')
|
|
1286
|
+
} catch (error) {
|
|
1287
|
+
console.error('[meeting/save] Finalization failed:', error)
|
|
1288
|
+
respond(500, { error: 'Meeting save failed', reason: 'meeting_save_error' })
|
|
1289
|
+
}
|
|
1021
1290
|
})
|
|
1022
1291
|
|
|
1023
1292
|
// ── Record identity for the speaker surfaces (6.47.0) ─────────────────
|
|
@@ -2669,7 +2938,17 @@ async function finalizeBatch(options: {
|
|
|
2669
2938
|
}
|
|
2670
2939
|
}
|
|
2671
2940
|
|
|
2672
|
-
|
|
2941
|
+
let defaultAutonomousFinalizer: AutonomousFinalizer | null = null
|
|
2942
|
+
export const meetingRouter = createMeetingRouter({ autonomousFinalize: { onCreate: finalizer => { defaultAutonomousFinalizer = finalizer } } })
|
|
2943
|
+
|
|
2944
|
+
/**
|
|
2945
|
+
* 6.81.0: at boot (once admissions open), pick up every Stop the server was holding when it
|
|
2946
|
+
* last exited and check it again. A Stop is durable, so a restart between the Stop and the
|
|
2947
|
+
* last chunk loses nothing: the finalizer waits for recovery, then for the chunks.
|
|
2948
|
+
*/
|
|
2949
|
+
export function resumeAutonomousMeetingSaves(): void {
|
|
2950
|
+
void defaultAutonomousFinalizer?.tick('boot')
|
|
2951
|
+
}
|
|
2673
2952
|
|
|
2674
2953
|
/** 6.77.0: what the transcript cleanup routes need from the meeting layer. */
|
|
2675
2954
|
export function transcriptCleanupRouteDeps(store: MeetingStore = getMeetingStore()): TranscriptCleanupRouteDeps {
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { MacParakeetUnavailableError, transcribeMacParakeet } from '../lib/mac-recording-asr.js'
|
|
2
2
|
import { getMeetingStore } from '../lib/meeting-store.js'
|
|
3
|
+
import { appendAutonomousSaveLog, getStopIntentStore, savedChunkReplay, type StopIntent } from '../lib/meeting-stop-intents.js'
|
|
3
4
|
import { MAC_AUDIO, MAC_CAPTURE_ID, persistMacChunkReceipt, macError, registerMacRecording, requireMacRecording, readMacRecording, validateMacWav } from '../lib/mac-recording.js'
|
|
4
5
|
import { AUTO_ENROLL_THRESHOLD } from '../lib/voiceprint-floor.js'
|
|
5
6
|
import { readSpeakerPolicy, policyLabel, type SpeakerMode } from '../lib/meeting-speaker-policy.js'
|
|
@@ -2164,6 +2165,47 @@ interface StreamCandidateInput {
|
|
|
2164
2165
|
mode?: string
|
|
2165
2166
|
}
|
|
2166
2167
|
|
|
2168
|
+
/**
|
|
2169
|
+
* 6.81.0: the phone re-sends a chunk after the server saved its meeting on its own.
|
|
2170
|
+
*
|
|
2171
|
+
* WHY. The server saves a stopped meeting itself only once it holds every chunk, so any chunk
|
|
2172
|
+
* the phone still lists as unconfirmed is one whose upload reached the Mac and whose answer
|
|
2173
|
+
* was lost while the phone was off screen. Before 6.81.0 such a replay was refused 410 and the
|
|
2174
|
+
* phone, holding a closed meeting with "unconfirmed" audio, called it a collision and kept the
|
|
2175
|
+
* audio forever. The saved meeting already holds that chunk, so it answers with the saved
|
|
2176
|
+
* text, exactly as a live session answers a replayed index, and the phone adopts the receipt.
|
|
2177
|
+
*
|
|
2178
|
+
* ONLY for meetings the SERVER saved after a Stop: every other closed session answers 410 as
|
|
2179
|
+
* before. A chunk the saved meeting never held is still refused, and is logged loudly, because
|
|
2180
|
+
* the finalizer's rule (never save an incomplete capture) says it cannot happen.
|
|
2181
|
+
*/
|
|
2182
|
+
function replayAfterAutonomousSave(sessionId: string, chunkIndex: number): StreamChunkCompletionResponse | null {
|
|
2183
|
+
let intent: StopIntent | null
|
|
2184
|
+
try { intent = getStopIntentStore().get(sessionId) } catch { return null }
|
|
2185
|
+
if (!intent || intent.state !== 'saved' || intent.savedBy !== 'server' || !intent.sidecarPath) return null
|
|
2186
|
+
const closed = closedSessionRecords.get(sessionId)
|
|
2187
|
+
const replay = savedChunkReplay(intent.sidecarPath, chunkIndex, closed?.asrCompletedIndices ?? [])
|
|
2188
|
+
const store = getStopIntentStore()
|
|
2189
|
+
if (!replay) {
|
|
2190
|
+
store.update(sessionId, { lateRefused: (intent.lateRefused ?? 0) + 1 })
|
|
2191
|
+
const fields = { sessionId, chunkIndex, finalChunkIndex: intent.finalChunkIndex, lateRefused: (intent.lateRefused ?? 0) + 1 }
|
|
2192
|
+
console.warn(`[meeting/auto-save] late_chunk_refused ${sessionId} ${JSON.stringify(fields)}`)
|
|
2193
|
+
appendAutonomousSaveLog('late_chunk_refused', fields)
|
|
2194
|
+
return null
|
|
2195
|
+
}
|
|
2196
|
+
const at = Date.now()
|
|
2197
|
+
store.update(sessionId, {
|
|
2198
|
+
lateReplays: (intent.lateReplays ?? 0) + 1,
|
|
2199
|
+
...(intent.phoneConfirmedAt ? {} : { phoneConfirmedAt: at, phoneConfirmedBy: 'chunk_replay' }),
|
|
2200
|
+
})
|
|
2201
|
+
const fields = { sessionId, chunkIndex, kind: replay.kind, lateReplays: (intent.lateReplays ?? 0) + 1, sinceSaveMs: at - (intent.savedAt ?? at) }
|
|
2202
|
+
console.log(`[meeting/auto-save] late_replay ${sessionId} ${JSON.stringify(fields)}`)
|
|
2203
|
+
appendAutonomousSaveLog('late_replay', fields)
|
|
2204
|
+
if (!intent.phoneConfirmedAt) appendAutonomousSaveLog('phone_confirmed', { sessionId, via: 'chunk_replay', sinceSaveMs: at - (intent.savedAt ?? at) })
|
|
2205
|
+
if (replay.kind === 'canonical') return canonicalChunkResponse(replay.chunk as unknown as TranscriptChunk, sessionId, chunkIndex)
|
|
2206
|
+
return emptyChunkResponse({ text: '', speaker: 'Ext', elapsed: 0, canonical: false }, sessionId, chunkIndex)
|
|
2207
|
+
}
|
|
2208
|
+
|
|
2167
2209
|
async function processStreamChunk(opts: {
|
|
2168
2210
|
sessionId: string
|
|
2169
2211
|
chunkIndex: number
|
|
@@ -2182,7 +2224,12 @@ async function processStreamChunk(opts: {
|
|
|
2182
2224
|
const tReq = performance.now()
|
|
2183
2225
|
validateSessionId(sessionId)
|
|
2184
2226
|
validateChunkIndex(chunkIndex)
|
|
2185
|
-
if (isSessionDeleted(sessionId))
|
|
2227
|
+
if (isSessionDeleted(sessionId)) {
|
|
2228
|
+
// 6.81.0: a meeting the server saved on its own answers a chunk it already holds.
|
|
2229
|
+
const replay = replayAfterAutonomousSave(sessionId, chunkIndex)
|
|
2230
|
+
if (replay) return replay
|
|
2231
|
+
throw makeHttpError(410, 'session is closed', 'session_closed')
|
|
2232
|
+
}
|
|
2186
2233
|
if (audioBuffer.length < 100) throw makeHttpError(400, 'audio too short', 'audio_too_short')
|
|
2187
2234
|
|
|
2188
2235
|
const audioSha256 = sha256Hex(audioBuffer)
|