@gotcos/glasses-server 6.12.7 → 6.13.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 +7 -0
- package/README.md +10 -0
- package/bin/cli.cjs +12 -0
- package/bin/managed-server.cjs +28 -0
- package/managed-runtime-contract.json +23 -0
- package/package.json +5 -2
- package/server/index.ts +92 -35
- package/server/lib/launch-dir.ts +13 -7
- package/server/lib/maintenance-lifecycle.ts +735 -0
- package/server/lib/managed-runtime.ts +44 -0
- package/server/lib/query-job-coordinator.ts +36 -4
- package/server/lib/query-job-runtime.ts +38 -26
- package/server/routes/health.ts +15 -12
- package/server/routes/maintenance.ts +160 -0
- package/server/routes/meeting.ts +25 -8
- package/server/routes/openai-compat.ts +82 -36
- package/server/routes/prompt-drafts.ts +51 -8
- package/server/routes/query.ts +52 -24
- package/server/routes/transcribe-stream.ts +49 -3
- package/server/routes/transcribe.ts +14 -0
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
export const MANAGED_RUNTIME_CONTRACT_VERSION = 2
|
|
2
|
+
|
|
3
|
+
export interface ManagedRuntimeCapability {
|
|
4
|
+
status: boolean
|
|
5
|
+
restartWhisper: boolean
|
|
6
|
+
restartServer: boolean
|
|
7
|
+
maintenanceDrain: boolean
|
|
8
|
+
lifecycleProof: boolean
|
|
9
|
+
managed: boolean
|
|
10
|
+
contractVersion: number
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function isManagedRuntime(): boolean {
|
|
14
|
+
return process.env.COS_MANAGED === '1'
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function managedRuntimeCapability(): ManagedRuntimeCapability {
|
|
18
|
+
const managed = isManagedRuntime()
|
|
19
|
+
return {
|
|
20
|
+
status: managed,
|
|
21
|
+
// Whisper lifecycle is private to the local controller. It is never
|
|
22
|
+
// exposed as a network-reachable mutation capability.
|
|
23
|
+
restartWhisper: false,
|
|
24
|
+
// Server restart is performed by the trusted local helper through launchd,
|
|
25
|
+
// never by an HTTP endpoint. This flag tells clients that managed recovery
|
|
26
|
+
// exists without widening the network attack surface.
|
|
27
|
+
restartServer: managed,
|
|
28
|
+
maintenanceDrain: managed,
|
|
29
|
+
lifecycleProof: managed,
|
|
30
|
+
managed,
|
|
31
|
+
contractVersion: MANAGED_RUNTIME_CONTRACT_VERSION,
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function managedServerVersion(): string | null {
|
|
36
|
+
const value = process.env.COS_SERVER_VERSION?.trim()
|
|
37
|
+
return value || null
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Deployment generation expected by the trusted local controller. */
|
|
41
|
+
export function getServerGenerationId(): string | null {
|
|
42
|
+
const explicit = process.env.COS_SERVER_GENERATION_ID?.trim()
|
|
43
|
+
return explicit && /^[A-Za-z0-9._:-]{1,160}$/.test(explicit) ? explicit : null
|
|
44
|
+
}
|
|
@@ -16,6 +16,7 @@ import {
|
|
|
16
16
|
type QueryJobSnapshot,
|
|
17
17
|
type QueryJobStoreHealth,
|
|
18
18
|
} from './query-job-types.js'
|
|
19
|
+
import type { MaintenanceWorkLease } from './maintenance-lifecycle.js'
|
|
19
20
|
|
|
20
21
|
const CLIENT_JOB_ID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i
|
|
21
22
|
|
|
@@ -68,6 +69,9 @@ export interface QueryJobCoordinatorOptions {
|
|
|
68
69
|
/** Idempotent projection of a terminal journal record into canonical
|
|
69
70
|
* conversation state. The journal remains authoritative if this fails. */
|
|
70
71
|
projectTerminal?: (job: QueryJobSnapshot, request: QueryJobRequest) => void | Promise<void>
|
|
72
|
+
/** Acquired synchronously before serialized admission and retained through
|
|
73
|
+
* the provider terminal, so maintenance proof covers queued transitions. */
|
|
74
|
+
acquireMaintenanceWork?: () => MaintenanceWorkLease
|
|
71
75
|
}
|
|
72
76
|
|
|
73
77
|
interface ActiveRun {
|
|
@@ -76,6 +80,7 @@ interface ActiveRun {
|
|
|
76
80
|
request: QueryJobRequest
|
|
77
81
|
controller: AbortController
|
|
78
82
|
release?: () => void
|
|
83
|
+
maintenanceLease?: MaintenanceWorkLease
|
|
79
84
|
released: boolean
|
|
80
85
|
callbackTail: Promise<void>
|
|
81
86
|
partialText: string
|
|
@@ -114,6 +119,7 @@ export class QueryJobCoordinator {
|
|
|
114
119
|
private readonly partialFlushChars: number
|
|
115
120
|
private readonly providerTimeoutMs: number
|
|
116
121
|
private readonly active = new Map<string, ActiveRun>()
|
|
122
|
+
private readonly admittedMaintenance = new Map<string, MaintenanceWorkLease>()
|
|
117
123
|
private admissionTail: Promise<void> = Promise.resolve()
|
|
118
124
|
private shuttingDown = false
|
|
119
125
|
private callbackPersistenceFailures = 0
|
|
@@ -148,6 +154,7 @@ export class QueryJobCoordinator {
|
|
|
148
154
|
* two simultaneous retries without a sessionId cannot allocate two sessions
|
|
149
155
|
* and conflict solely because the client had not learned the first one. */
|
|
150
156
|
submit(raw: unknown): Promise<QueryJobAdmissionResult> {
|
|
157
|
+
const maintenanceLease = this.options.acquireMaintenanceWork?.()
|
|
151
158
|
let resolve!: (value: QueryJobAdmissionResult) => void
|
|
152
159
|
let reject!: (reason?: unknown) => void
|
|
153
160
|
const result = new Promise<QueryJobAdmissionResult>((res, rej) => {
|
|
@@ -160,8 +167,14 @@ export class QueryJobCoordinator {
|
|
|
160
167
|
const normalized = await this.assignSession(raw)
|
|
161
168
|
const admission = await this.store.admit(normalized)
|
|
162
169
|
resolve(admission)
|
|
163
|
-
if (admission.created)
|
|
170
|
+
if (admission.created) {
|
|
171
|
+
if (maintenanceLease) this.admittedMaintenance.set(admission.job.jobId, maintenanceLease)
|
|
172
|
+
queueMicrotask(() => { void this.execute(admission.job.jobId) })
|
|
173
|
+
} else {
|
|
174
|
+
maintenanceLease?.release()
|
|
175
|
+
}
|
|
164
176
|
} catch (error) {
|
|
177
|
+
maintenanceLease?.release()
|
|
165
178
|
reject(error)
|
|
166
179
|
}
|
|
167
180
|
})
|
|
@@ -186,14 +199,30 @@ export class QueryJobCoordinator {
|
|
|
186
199
|
}
|
|
187
200
|
|
|
188
201
|
private async execute(jobId: string): Promise<void> {
|
|
202
|
+
const maintenanceLease = this.admittedMaintenance.get(jobId)
|
|
203
|
+
this.admittedMaintenance.delete(jobId)
|
|
189
204
|
const starting = await this.store.markStarting(jobId).catch(() => null)
|
|
190
|
-
if (!starting?.applied)
|
|
191
|
-
|
|
205
|
+
if (!starting?.applied) {
|
|
206
|
+
maintenanceLease?.release()
|
|
207
|
+
return
|
|
208
|
+
}
|
|
209
|
+
maintenanceLease?.setPhase('active')
|
|
210
|
+
let execution
|
|
211
|
+
try {
|
|
212
|
+
execution = await this.store.getExecution(jobId)
|
|
213
|
+
} catch {
|
|
214
|
+
maintenanceLease?.release()
|
|
215
|
+
return
|
|
216
|
+
}
|
|
192
217
|
let release: (() => void) | undefined
|
|
193
218
|
try {
|
|
194
219
|
release = await this.options.acquireSessionLock?.(execution.request.sessionId)
|
|
195
220
|
} catch (error) {
|
|
196
|
-
|
|
221
|
+
try {
|
|
222
|
+
await this.store.fail(jobId, error)
|
|
223
|
+
} finally {
|
|
224
|
+
maintenanceLease?.release()
|
|
225
|
+
}
|
|
197
226
|
return
|
|
198
227
|
}
|
|
199
228
|
|
|
@@ -207,6 +236,7 @@ export class QueryJobCoordinator {
|
|
|
207
236
|
request: execution.request,
|
|
208
237
|
controller: new AbortController(),
|
|
209
238
|
release,
|
|
239
|
+
maintenanceLease,
|
|
210
240
|
released: false,
|
|
211
241
|
callbackTail: Promise.resolve(),
|
|
212
242
|
partialText: '',
|
|
@@ -536,6 +566,7 @@ export class QueryJobCoordinator {
|
|
|
536
566
|
if (!active.released) {
|
|
537
567
|
active.released = true
|
|
538
568
|
active.release?.()
|
|
569
|
+
active.maintenanceLease?.release()
|
|
539
570
|
}
|
|
540
571
|
}
|
|
541
572
|
|
|
@@ -548,6 +579,7 @@ export class QueryJobCoordinator {
|
|
|
548
579
|
if (!active.released) {
|
|
549
580
|
active.released = true
|
|
550
581
|
active.release?.()
|
|
582
|
+
active.maintenanceLease?.release()
|
|
551
583
|
}
|
|
552
584
|
}
|
|
553
585
|
|
|
@@ -26,6 +26,7 @@ import {
|
|
|
26
26
|
type QueryJobRequest,
|
|
27
27
|
type QueryJobSnapshot,
|
|
28
28
|
} from './query-job-types.js'
|
|
29
|
+
import { acquireMaintenanceWork } from './maintenance-lifecycle.js'
|
|
29
30
|
|
|
30
31
|
const TOOL_STATUS_MESSAGES: Record<string, string> = {
|
|
31
32
|
WebSearch: 'Searching web...',
|
|
@@ -161,6 +162,12 @@ const runner: QueryJobRunner = async ({ jobId, turnId, request, signal, callback
|
|
|
161
162
|
resolvedAttachments.refs,
|
|
162
163
|
metadata?.outputAttachments,
|
|
163
164
|
)
|
|
165
|
+
// Acquire before the durable terminal callback can release the main
|
|
166
|
+
// query lease. Attachment association is a post-terminal write and
|
|
167
|
+
// must not create a zero-count maintenance proof gap.
|
|
168
|
+
const attachmentLease = resolvedAttachments.ids.length > 0
|
|
169
|
+
? acquireMaintenanceWork('query_attachment_write', { allowDuringDrain: true })
|
|
170
|
+
: undefined
|
|
164
171
|
const linkage = {
|
|
165
172
|
provider: providerFor(model),
|
|
166
173
|
resolvedModel: model,
|
|
@@ -171,34 +178,38 @@ const runner: QueryJobRunner = async ({ jobId, turnId, request, signal, callback
|
|
|
171
178
|
} as const
|
|
172
179
|
// Publish compatibility completion only after the durable terminal is
|
|
173
180
|
// fsynced. Display subscribers can disappear without owning this job.
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
181
|
+
try {
|
|
182
|
+
const terminalOwned = await callbacks.onDone({
|
|
183
|
+
text: fullText,
|
|
184
|
+
attachments,
|
|
185
|
+
outputImageStats: metadata?.outputImageStats,
|
|
186
|
+
...linkage,
|
|
187
|
+
})
|
|
188
|
+
if (!terminalOwned) return
|
|
189
|
+
if (resolvedAttachments.ids.length > 0) {
|
|
190
|
+
await getMediaStore().associate(resolvedAttachments.ids, {
|
|
191
|
+
sessionId: request.sessionId,
|
|
192
|
+
...(request.globalMsgNum ? { globalMsgNum: request.globalMsgNum } : {}),
|
|
193
|
+
}).catch(error => console.error('[query-jobs] attachment association failed:', error))
|
|
194
|
+
}
|
|
195
|
+
const { outputAttachments: _outputAttachments, ...runMetadata } = metadata ?? {}
|
|
196
|
+
emitDisplay({ type: 'done', data: {
|
|
197
|
+
jobId,
|
|
198
|
+
clientJobId: request.clientJobId,
|
|
199
|
+
generation: request.generation,
|
|
200
|
+
turnId,
|
|
201
|
+
messageEra: request.messageEra,
|
|
202
|
+
globalMsgNum: request.globalMsgNum,
|
|
203
|
+
text: fullText,
|
|
183
204
|
sessionId: request.sessionId,
|
|
184
|
-
|
|
185
|
-
|
|
205
|
+
model,
|
|
206
|
+
cliSessionId,
|
|
207
|
+
...runMetadata,
|
|
208
|
+
...(attachments.length > 0 ? { attachments } : {}),
|
|
209
|
+
} })
|
|
210
|
+
} finally {
|
|
211
|
+
attachmentLease?.release()
|
|
186
212
|
}
|
|
187
|
-
const { outputAttachments: _outputAttachments, ...runMetadata } = metadata ?? {}
|
|
188
|
-
emitDisplay({ type: 'done', data: {
|
|
189
|
-
jobId,
|
|
190
|
-
clientJobId: request.clientJobId,
|
|
191
|
-
generation: request.generation,
|
|
192
|
-
turnId,
|
|
193
|
-
messageEra: request.messageEra,
|
|
194
|
-
globalMsgNum: request.globalMsgNum,
|
|
195
|
-
text: fullText,
|
|
196
|
-
sessionId: request.sessionId,
|
|
197
|
-
model,
|
|
198
|
-
cliSessionId,
|
|
199
|
-
...runMetadata,
|
|
200
|
-
...(attachments.length > 0 ? { attachments } : {}),
|
|
201
|
-
} })
|
|
202
213
|
},
|
|
203
214
|
onError: async error => {
|
|
204
215
|
const terminalOwned = await callbacks.onError(error)
|
|
@@ -239,6 +250,7 @@ export const queryJobStore = new QueryJobStore({
|
|
|
239
250
|
export const queryJobCoordinator = new QueryJobCoordinator(queryJobStore, runner, {
|
|
240
251
|
projectTerminal: projectPublicConversationTerminal,
|
|
241
252
|
acquireSessionLock: acquireModelSessionRunLock,
|
|
253
|
+
acquireMaintenanceWork: () => acquireMaintenanceWork('durable_query', { phase: 'queued' }),
|
|
242
254
|
})
|
|
243
255
|
|
|
244
256
|
export function initQueryJobRuntime() {
|
package/server/routes/health.ts
CHANGED
|
@@ -21,6 +21,9 @@ import { durableQueryJobsCapability } from '../lib/query-job-feature.js'
|
|
|
21
21
|
import { getQueryJobRuntimeHealth } from '../lib/query-job-runtime.js'
|
|
22
22
|
import { getTranscriptionPolicySnapshot } from '../lib/transcription-policy.js'
|
|
23
23
|
import { CLI_DEBUG_CAPABILITY } from '../lib/cli-debug-view.js'
|
|
24
|
+
import { managedRuntimeCapability, managedServerVersion } from '../lib/managed-runtime.js'
|
|
25
|
+
import { getServerGenerationId } from '../lib/managed-runtime.js'
|
|
26
|
+
import { maintenanceLifecycle } from '../lib/maintenance-lifecycle.js'
|
|
24
27
|
|
|
25
28
|
export const healthRouter = Router()
|
|
26
29
|
|
|
@@ -145,12 +148,8 @@ healthRouter.get('/health', async (_req, res) => {
|
|
|
145
148
|
const durableJobs = durableQueryJobStatus()
|
|
146
149
|
const localFirstMeetings = localFirstMeetingsCapability(getServerInstanceId())
|
|
147
150
|
const transcription = getTranscriptionPolicySnapshot()
|
|
148
|
-
const recovery =
|
|
149
|
-
|
|
150
|
-
restartWhisper: false,
|
|
151
|
-
restartServer: false,
|
|
152
|
-
managed: false,
|
|
153
|
-
}
|
|
151
|
+
const recovery = managedRuntimeCapability()
|
|
152
|
+
const maintenance = maintenanceLifecycle.snapshot()
|
|
154
153
|
const features = {
|
|
155
154
|
claude: claudeAvailable,
|
|
156
155
|
codex: codexAvailable,
|
|
@@ -180,6 +179,10 @@ healthRouter.get('/health', async (_req, res) => {
|
|
|
180
179
|
const codex_models = getCodexModelCatalogSnapshot()
|
|
181
180
|
res.json({
|
|
182
181
|
...checks,
|
|
182
|
+
server_version: managedServerVersion(),
|
|
183
|
+
server_instance_id: getServerInstanceId(),
|
|
184
|
+
boot_id: serverMetrics.bootId,
|
|
185
|
+
generation_id: getServerGenerationId(),
|
|
183
186
|
features,
|
|
184
187
|
voice,
|
|
185
188
|
whisper_health,
|
|
@@ -188,6 +191,11 @@ healthRouter.get('/health', async (_req, res) => {
|
|
|
188
191
|
capabilities: {
|
|
189
192
|
transcription,
|
|
190
193
|
recovery,
|
|
194
|
+
maintenance: {
|
|
195
|
+
state: maintenance.state,
|
|
196
|
+
admissionsOpen: maintenance.admissionsOpen,
|
|
197
|
+
carriedAcrossBoot: maintenance.operation?.carriedAcrossBoot ?? false,
|
|
198
|
+
},
|
|
191
199
|
cliDebug: CLI_DEBUG_CAPABILITY,
|
|
192
200
|
...(localFirstMeetings ? { localFirstMeetings } : {}),
|
|
193
201
|
},
|
|
@@ -220,12 +228,7 @@ healthRouter.get('/models', async (req, res) => {
|
|
|
220
228
|
},
|
|
221
229
|
transcription,
|
|
222
230
|
cliDebug: CLI_DEBUG_CAPABILITY,
|
|
223
|
-
recovery:
|
|
224
|
-
status: false,
|
|
225
|
-
restartWhisper: false,
|
|
226
|
-
restartServer: false,
|
|
227
|
-
managed: false,
|
|
228
|
-
},
|
|
231
|
+
recovery: managedRuntimeCapability(),
|
|
229
232
|
...(localFirstMeetings ? { localFirstMeetings } : {}),
|
|
230
233
|
},
|
|
231
234
|
})
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
import { Router, type Request, type Response } from 'express'
|
|
2
|
+
import {
|
|
3
|
+
MaintenanceLifecycleError,
|
|
4
|
+
maintenanceErrorPayload,
|
|
5
|
+
maintenanceLifecycle,
|
|
6
|
+
type MaintenanceDrainRequest,
|
|
7
|
+
type MaintenanceOperationCredentials,
|
|
8
|
+
type MaintenanceOperationIdentity,
|
|
9
|
+
type MaintenanceOperationKind,
|
|
10
|
+
type MaintenanceOperationScope,
|
|
11
|
+
type MaintenancePostcondition,
|
|
12
|
+
} from '../lib/maintenance-lifecycle.js'
|
|
13
|
+
import { managedRuntimeCapability, managedServerVersion, getServerGenerationId } from '../lib/managed-runtime.js'
|
|
14
|
+
import { getQueryJobRuntimeHealth } from '../lib/query-job-runtime.js'
|
|
15
|
+
import { serverMetrics } from '../lib/server-metrics.js'
|
|
16
|
+
import { getServerInstanceId } from '../lib/server-instance-id.js'
|
|
17
|
+
import { getWhisperHealth } from '../lib/whisper-local.js'
|
|
18
|
+
import { getActiveTranscriptionSessionCount } from './transcribe-stream.js'
|
|
19
|
+
|
|
20
|
+
export const maintenanceRouter = Router()
|
|
21
|
+
|
|
22
|
+
function isLoopback(req: Request): boolean {
|
|
23
|
+
const address = req.socket.remoteAddress ?? ''
|
|
24
|
+
return address === '::1' || address === '127.0.0.1' || address.startsWith('127.')
|
|
25
|
+
|| address.startsWith('::ffff:127.')
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function bodyRecord(body: unknown): Record<string, unknown> {
|
|
29
|
+
return body && typeof body === 'object' ? body as Record<string, unknown> : {}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function operationIdentity(body: unknown): MaintenanceOperationIdentity {
|
|
33
|
+
const value = bodyRecord(body)
|
|
34
|
+
return {
|
|
35
|
+
serverInstanceId: typeof value.serverInstanceId === 'string' ? value.serverInstanceId : '',
|
|
36
|
+
bootId: typeof value.bootId === 'string' ? value.bootId : '',
|
|
37
|
+
generationId: typeof value.generationId === 'string' ? value.generationId : '',
|
|
38
|
+
operationId: typeof value.operationId === 'string' ? value.operationId : '',
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function drainRequest(body: unknown): MaintenanceDrainRequest {
|
|
43
|
+
const value = bodyRecord(body)
|
|
44
|
+
return {
|
|
45
|
+
...operationIdentity(value),
|
|
46
|
+
operationKind: value.operationKind as MaintenanceOperationKind,
|
|
47
|
+
scope: value.scope as MaintenanceOperationScope,
|
|
48
|
+
postcondition: value.postcondition as MaintenancePostcondition,
|
|
49
|
+
nonceSha256: typeof value.nonceSha256 === 'string' ? value.nonceSha256 : '',
|
|
50
|
+
authorizedSuccessorGenerations: Array.isArray(value.authorizedSuccessorGenerations)
|
|
51
|
+
? value.authorizedSuccessorGenerations.filter((item): item is string => typeof item === 'string')
|
|
52
|
+
: [],
|
|
53
|
+
...(typeof value.ttlMs === 'number' ? { ttlMs: value.ttlMs } : {}),
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function operationCredentials(req: Request): MaintenanceOperationCredentials {
|
|
58
|
+
return {
|
|
59
|
+
leaseId: typeof req.headers['x-cos-maintenance-lease'] === 'string'
|
|
60
|
+
? req.headers['x-cos-maintenance-lease']
|
|
61
|
+
: undefined,
|
|
62
|
+
operationId: typeof req.headers['x-cos-maintenance-operation'] === 'string'
|
|
63
|
+
? req.headers['x-cos-maintenance-operation']
|
|
64
|
+
: undefined,
|
|
65
|
+
nonce: typeof req.headers['x-cos-maintenance-nonce'] === 'string'
|
|
66
|
+
? req.headers['x-cos-maintenance-nonce']
|
|
67
|
+
: undefined,
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function statusSnapshot(credentials: MaintenanceOperationCredentials = {}) {
|
|
72
|
+
const jobs = getQueryJobRuntimeHealth()
|
|
73
|
+
const activeTranscriptionSessions = getActiveTranscriptionSessionCount()
|
|
74
|
+
const managed = managedRuntimeCapability()
|
|
75
|
+
const tracked = maintenanceLifecycle.snapshot(credentials, {
|
|
76
|
+
recording_session: activeTranscriptionSessions,
|
|
77
|
+
})
|
|
78
|
+
const untrackedDurableRuns = Math.max(0, jobs.activeRuns - (tracked.activeByKind.durable_query ?? 0))
|
|
79
|
+
const lifecycle = untrackedDurableRuns > 0
|
|
80
|
+
? maintenanceLifecycle.snapshot(credentials, {
|
|
81
|
+
durable_query_runtime: untrackedDurableRuns,
|
|
82
|
+
recording_session: activeTranscriptionSessions,
|
|
83
|
+
})
|
|
84
|
+
: tracked
|
|
85
|
+
return {
|
|
86
|
+
contractVersion: managed.contractVersion,
|
|
87
|
+
managed: managed.managed,
|
|
88
|
+
serverVersion: managedServerVersion(),
|
|
89
|
+
generationId: getServerGenerationId(),
|
|
90
|
+
serverInstanceId: getServerInstanceId(),
|
|
91
|
+
bootId: serverMetrics.bootId,
|
|
92
|
+
activeJobs: jobs.activeRuns,
|
|
93
|
+
activeTranscriptionSessions,
|
|
94
|
+
shuttingDown: jobs.shuttingDown,
|
|
95
|
+
durableStoreState: jobs.store.state,
|
|
96
|
+
lifecycle,
|
|
97
|
+
safeToRestart: lifecycle.safeToRestart && !jobs.shuttingDown,
|
|
98
|
+
whisper: getWhisperHealth(),
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function sendLifecycleError(res: Response, error: unknown) {
|
|
103
|
+
if (error instanceof MaintenanceLifecycleError) {
|
|
104
|
+
if (error.retryAfterSeconds != null) res.setHeader('Retry-After', String(error.retryAfterSeconds))
|
|
105
|
+
res.status(error.status).json(maintenanceErrorPayload(error))
|
|
106
|
+
return
|
|
107
|
+
}
|
|
108
|
+
res.status(500).json({ error: 'maintenance_internal_error', retryable: false })
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function requireLoopback(req: Request, res: Response): boolean {
|
|
112
|
+
if (isLoopback(req)) return true
|
|
113
|
+
res.status(403).json({ error: 'loopback_required', retryable: false })
|
|
114
|
+
return false
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
maintenanceRouter.get('/maintenance/status', (req, res) => {
|
|
118
|
+
res.json(statusSnapshot(operationCredentials(req)))
|
|
119
|
+
})
|
|
120
|
+
|
|
121
|
+
maintenanceRouter.post('/maintenance/drain', (req, res) => {
|
|
122
|
+
if (!requireLoopback(req, res)) return
|
|
123
|
+
try {
|
|
124
|
+
const leaseId = maintenanceLifecycle.beginDrain(drainRequest(req.body))
|
|
125
|
+
res.json({ leaseId, ...statusSnapshot() })
|
|
126
|
+
} catch (error) {
|
|
127
|
+
sendLifecycleError(res, error)
|
|
128
|
+
}
|
|
129
|
+
})
|
|
130
|
+
|
|
131
|
+
maintenanceRouter.post('/maintenance/drain/adopt', (req, res) => {
|
|
132
|
+
if (!requireLoopback(req, res)) return
|
|
133
|
+
const credentials = operationCredentials(req)
|
|
134
|
+
try {
|
|
135
|
+
maintenanceLifecycle.adoptDrain(operationIdentity(req.body), credentials)
|
|
136
|
+
res.json({ adopted: true, ...statusSnapshot(credentials) })
|
|
137
|
+
} catch (error) {
|
|
138
|
+
sendLifecycleError(res, error)
|
|
139
|
+
}
|
|
140
|
+
})
|
|
141
|
+
|
|
142
|
+
maintenanceRouter.post('/maintenance/drain/release', (req, res) => {
|
|
143
|
+
if (!requireLoopback(req, res)) return
|
|
144
|
+
try {
|
|
145
|
+
maintenanceLifecycle.releaseDrain(operationIdentity(req.body), operationCredentials(req))
|
|
146
|
+
res.json({ released: true, ...statusSnapshot() })
|
|
147
|
+
} catch (error) {
|
|
148
|
+
sendLifecycleError(res, error)
|
|
149
|
+
}
|
|
150
|
+
})
|
|
151
|
+
|
|
152
|
+
maintenanceRouter.post('/maintenance/drain/cancel', (req, res) => {
|
|
153
|
+
if (!requireLoopback(req, res)) return
|
|
154
|
+
try {
|
|
155
|
+
maintenanceLifecycle.cancelDrain(operationIdentity(req.body), operationCredentials(req))
|
|
156
|
+
res.json({ canceled: true, ...statusSnapshot() })
|
|
157
|
+
} catch (error) {
|
|
158
|
+
sendLifecycleError(res, error)
|
|
159
|
+
}
|
|
160
|
+
})
|
package/server/routes/meeting.ts
CHANGED
|
@@ -40,6 +40,7 @@ import {
|
|
|
40
40
|
type TranscriptGapReport,
|
|
41
41
|
} from './transcribe-stream.js'
|
|
42
42
|
import { getServerInstanceId } from '../lib/server-instance-id.js'
|
|
43
|
+
import { acquireMaintenanceWork, type MaintenanceWorkLease } from '../lib/maintenance-lifecycle.js'
|
|
43
44
|
|
|
44
45
|
interface MeetingSessionSource {
|
|
45
46
|
getTranscript(sessionId: string): string | null
|
|
@@ -177,6 +178,7 @@ export function createMeetingRouter(deps: MeetingRouteDependencies = {}): Router
|
|
|
177
178
|
|
|
178
179
|
router.post('/meeting/save', async (req, res) => {
|
|
179
180
|
let lockedSessionId: string | null = null
|
|
181
|
+
let maintenanceLease: MaintenanceWorkLease | undefined
|
|
180
182
|
try {
|
|
181
183
|
const body = req.body as Record<string, unknown> | undefined
|
|
182
184
|
const sessionId = typeof body?.sessionId === 'string' ? body.sessionId : ''
|
|
@@ -219,6 +221,10 @@ export function createMeetingRouter(deps: MeetingRouteDependencies = {}): Router
|
|
|
219
221
|
res.status(409).json({ error: 'Meeting save already in progress', reason: 'save_in_progress' })
|
|
220
222
|
return
|
|
221
223
|
}
|
|
224
|
+
// Saving/finalizing an already-recording session is a drain
|
|
225
|
+
// continuation. It must remain admitted so existing audio can reach a
|
|
226
|
+
// durable terminal while all genuinely new work is closed.
|
|
227
|
+
maintenanceLease = acquireMaintenanceWork('meeting_save', { allowDuringDrain: true })
|
|
222
228
|
savingSessions.add(sessionId)
|
|
223
229
|
lockedSessionId = sessionId
|
|
224
230
|
|
|
@@ -300,20 +306,30 @@ export function createMeetingRouter(deps: MeetingRouteDependencies = {}): Router
|
|
|
300
306
|
res.json(publicSaveResponse(saved))
|
|
301
307
|
|
|
302
308
|
if (audioWritesReady && pendingAudioDir && chunkEntries.length > 0) {
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
309
|
+
// Acquire before the foreground save lease can leave scope: there is
|
|
310
|
+
// no zero-count proof gap between pending-audio handoff and the queued
|
|
311
|
+
// background finalizer.
|
|
312
|
+
const batchLease = acquireMaintenanceWork('meeting_batch_finalization', {
|
|
313
|
+
allowDuringDrain: true,
|
|
314
|
+
phase: 'queued',
|
|
315
|
+
})
|
|
316
|
+
const task = Promise.resolve().then(() => {
|
|
317
|
+
batchLease.setPhase('active')
|
|
318
|
+
return finalizeBatch({
|
|
319
|
+
audioDir: pendingAudioDir,
|
|
320
|
+
entries: chunkEntries.map(entry => ({ ...entry, chunk: { ...entry.chunk } })),
|
|
321
|
+
streamingWordCount: countWords(transcript),
|
|
322
|
+
meetingPath: saved.filepath,
|
|
323
|
+
sidecarPath: saved.sidecarPath,
|
|
324
|
+
runBatch,
|
|
325
|
+
})
|
|
310
326
|
}).catch(error => {
|
|
311
327
|
// Raw audio deliberately remains for the existing two-hour cleanup.
|
|
312
328
|
console.error(
|
|
313
329
|
`[meeting/save] Batch finalization failed for ${sessionId}: `
|
|
314
330
|
+ `${error instanceof Error ? error.message : String(error)}`,
|
|
315
331
|
)
|
|
316
|
-
})
|
|
332
|
+
}).finally(() => batchLease.release())
|
|
317
333
|
scheduleBackground(task)
|
|
318
334
|
}
|
|
319
335
|
} catch (error) {
|
|
@@ -324,6 +340,7 @@ export function createMeetingRouter(deps: MeetingRouteDependencies = {}): Router
|
|
|
324
340
|
console.error('[meeting/save] Finalization failed:', error)
|
|
325
341
|
res.status(500).json({ error: 'Meeting save failed', reason: 'meeting_save_error' })
|
|
326
342
|
} finally {
|
|
343
|
+
maintenanceLease?.release()
|
|
327
344
|
if (lockedSessionId) savingSessions.delete(lockedSessionId)
|
|
328
345
|
}
|
|
329
346
|
})
|