@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,735 @@
|
|
|
1
|
+
import { createHash, randomUUID, timingSafeEqual } from 'node:crypto'
|
|
2
|
+
import {
|
|
3
|
+
chmodSync,
|
|
4
|
+
closeSync,
|
|
5
|
+
existsSync,
|
|
6
|
+
fsyncSync,
|
|
7
|
+
lstatSync,
|
|
8
|
+
mkdirSync,
|
|
9
|
+
openSync,
|
|
10
|
+
readFileSync,
|
|
11
|
+
renameSync,
|
|
12
|
+
rmSync,
|
|
13
|
+
writeFileSync,
|
|
14
|
+
} from 'node:fs'
|
|
15
|
+
import { homedir } from 'node:os'
|
|
16
|
+
import { dirname, join } from 'node:path'
|
|
17
|
+
import { getServerGenerationId, isManagedRuntime } from './managed-runtime.js'
|
|
18
|
+
import { serverMetrics } from './server-metrics.js'
|
|
19
|
+
import { getServerInstanceId } from './server-instance-id.js'
|
|
20
|
+
|
|
21
|
+
const GATE_VERSION = 2
|
|
22
|
+
const DEFAULT_LEASE_MS = 5 * 60_000
|
|
23
|
+
const MIN_LEASE_MS = 30_000
|
|
24
|
+
const MAX_LEASE_MS = 15 * 60_000
|
|
25
|
+
const ID_RE = /^[A-Za-z0-9._:-]{1,160}$/
|
|
26
|
+
const NONCE_RE = /^[A-Za-z0-9_-]{32,172}$/
|
|
27
|
+
const SHA256_RE = /^[0-9a-f]{64}$/
|
|
28
|
+
const DEFAULT_GATE_PATH = join(
|
|
29
|
+
homedir(),
|
|
30
|
+
'Library',
|
|
31
|
+
'Application Support',
|
|
32
|
+
'COS Glasses',
|
|
33
|
+
'control',
|
|
34
|
+
'maintenance-gate.json',
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
export type MaintenanceWorkKind =
|
|
38
|
+
| 'api_mutation'
|
|
39
|
+
| 'durable_query'
|
|
40
|
+
| 'legacy_query'
|
|
41
|
+
| 'openai_query'
|
|
42
|
+
| 'query_attachment_write'
|
|
43
|
+
| 'one_shot_transcription'
|
|
44
|
+
| 'recording_chunk'
|
|
45
|
+
| 'meeting_save'
|
|
46
|
+
| 'meeting_batch_finalization'
|
|
47
|
+
| 'prompt_draft_write'
|
|
48
|
+
| 'prompt_draft_warm'
|
|
49
|
+
| 'prompt_draft_finalize'
|
|
50
|
+
|
|
51
|
+
export type MaintenanceWorkPhase = 'queued' | 'active'
|
|
52
|
+
export type MaintenanceOperationScope = 'same_boot' | 'cross_boot'
|
|
53
|
+
export type MaintenanceOperationKind =
|
|
54
|
+
| 'same_boot_maintenance'
|
|
55
|
+
| 'server_restart'
|
|
56
|
+
| 'server_update'
|
|
57
|
+
| 'server_rollback'
|
|
58
|
+
| 'server_stop'
|
|
59
|
+
export type MaintenancePostcondition = 'same_boot_idle' | 'authorized_successor_adopted'
|
|
60
|
+
|
|
61
|
+
interface AdoptedSuccessor {
|
|
62
|
+
serverInstanceId: string
|
|
63
|
+
bootId: string
|
|
64
|
+
generationId: string
|
|
65
|
+
adoptedAt: string
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
interface DurableDrainGateV2 {
|
|
69
|
+
version: typeof GATE_VERSION
|
|
70
|
+
leaseId: string
|
|
71
|
+
operationId: string
|
|
72
|
+
operationKind: MaintenanceOperationKind
|
|
73
|
+
scope: MaintenanceOperationScope
|
|
74
|
+
postcondition: MaintenancePostcondition
|
|
75
|
+
nonceSha256: string
|
|
76
|
+
authorizedSuccessorGenerations: string[]
|
|
77
|
+
serverInstanceId: string
|
|
78
|
+
sourceBootId: string
|
|
79
|
+
sourceGenerationId: string
|
|
80
|
+
startedAt: string
|
|
81
|
+
expiresAt: string | null
|
|
82
|
+
adoptedSuccessor?: AdoptedSuccessor
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
type BlockedGateReason = 'legacy_v1' | 'unknown_schema' | 'invalid_schema' | 'corrupt_json'
|
|
86
|
+
|
|
87
|
+
interface WorkEntry {
|
|
88
|
+
kind: MaintenanceWorkKind
|
|
89
|
+
phase: MaintenanceWorkPhase
|
|
90
|
+
startedAtMs: number
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
interface MaintenanceLifecycleOptions {
|
|
94
|
+
path?: string
|
|
95
|
+
now?: () => number
|
|
96
|
+
bootId?: () => string
|
|
97
|
+
serverInstanceId?: () => string | null
|
|
98
|
+
generationId?: () => string | null
|
|
99
|
+
managed?: () => boolean
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export interface MaintenanceDrainRequest {
|
|
103
|
+
serverInstanceId: string
|
|
104
|
+
bootId: string
|
|
105
|
+
generationId: string
|
|
106
|
+
operationId: string
|
|
107
|
+
operationKind: MaintenanceOperationKind
|
|
108
|
+
scope: MaintenanceOperationScope
|
|
109
|
+
postcondition: MaintenancePostcondition
|
|
110
|
+
nonceSha256: string
|
|
111
|
+
authorizedSuccessorGenerations: string[]
|
|
112
|
+
ttlMs?: number
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
export interface MaintenanceOperationIdentity {
|
|
116
|
+
serverInstanceId: string
|
|
117
|
+
bootId: string
|
|
118
|
+
generationId: string
|
|
119
|
+
operationId: string
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
export interface MaintenanceOperationCredentials {
|
|
123
|
+
leaseId?: string
|
|
124
|
+
operationId?: string
|
|
125
|
+
nonce?: string
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
export interface MaintenanceWorkLease {
|
|
129
|
+
readonly id: string
|
|
130
|
+
setPhase(phase: MaintenanceWorkPhase): void
|
|
131
|
+
release(): void
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
export class MaintenanceLifecycleError extends Error {
|
|
135
|
+
readonly status: number
|
|
136
|
+
readonly retryable: boolean
|
|
137
|
+
readonly retryAfterSeconds?: number
|
|
138
|
+
|
|
139
|
+
constructor(
|
|
140
|
+
readonly code: string,
|
|
141
|
+
message: string,
|
|
142
|
+
options: { status?: number; retryable?: boolean; retryAfterSeconds?: number } = {},
|
|
143
|
+
) {
|
|
144
|
+
super(message)
|
|
145
|
+
this.name = 'MaintenanceLifecycleError'
|
|
146
|
+
this.status = options.status ?? 409
|
|
147
|
+
this.retryable = options.retryable ?? false
|
|
148
|
+
this.retryAfterSeconds = options.retryAfterSeconds
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function isStringId(value: unknown): value is string {
|
|
153
|
+
return typeof value === 'string' && ID_RE.test(value)
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function isOperationKind(value: unknown): value is MaintenanceOperationKind {
|
|
157
|
+
return value === 'same_boot_maintenance'
|
|
158
|
+
|| value === 'server_restart'
|
|
159
|
+
|| value === 'server_update'
|
|
160
|
+
|| value === 'server_rollback'
|
|
161
|
+
|| value === 'server_stop'
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function isScope(value: unknown): value is MaintenanceOperationScope {
|
|
165
|
+
return value === 'same_boot' || value === 'cross_boot'
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function isPostcondition(value: unknown): value is MaintenancePostcondition {
|
|
169
|
+
return value === 'same_boot_idle' || value === 'authorized_successor_adopted'
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
function parseAdoptedSuccessor(value: unknown): AdoptedSuccessor | undefined | null {
|
|
173
|
+
if (value == null) return undefined
|
|
174
|
+
if (!value || typeof value !== 'object') return null
|
|
175
|
+
const item = value as Record<string, unknown>
|
|
176
|
+
if (!isStringId(item.serverInstanceId)
|
|
177
|
+
|| !isStringId(item.bootId)
|
|
178
|
+
|| !isStringId(item.generationId)
|
|
179
|
+
|| typeof item.adoptedAt !== 'string'
|
|
180
|
+
|| !Number.isFinite(Date.parse(item.adoptedAt))) return null
|
|
181
|
+
return item as unknown as AdoptedSuccessor
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
function parseGateV2(value: unknown): DurableDrainGateV2 | null {
|
|
185
|
+
if (!value || typeof value !== 'object') return null
|
|
186
|
+
const gate = value as Record<string, unknown>
|
|
187
|
+
const adoptedSuccessor = parseAdoptedSuccessor(gate.adoptedSuccessor)
|
|
188
|
+
const successors = Array.isArray(gate.authorizedSuccessorGenerations)
|
|
189
|
+
? gate.authorizedSuccessorGenerations
|
|
190
|
+
: []
|
|
191
|
+
if (gate.version !== GATE_VERSION
|
|
192
|
+
|| !isStringId(gate.leaseId)
|
|
193
|
+
|| !isStringId(gate.operationId)
|
|
194
|
+
|| !isOperationKind(gate.operationKind)
|
|
195
|
+
|| !isScope(gate.scope)
|
|
196
|
+
|| !isPostcondition(gate.postcondition)
|
|
197
|
+
|| typeof gate.nonceSha256 !== 'string'
|
|
198
|
+
|| !SHA256_RE.test(gate.nonceSha256)
|
|
199
|
+
|| successors.length < 1
|
|
200
|
+
|| successors.length > 8
|
|
201
|
+
|| successors.some(value => !isStringId(value))
|
|
202
|
+
|| new Set(successors).size !== successors.length
|
|
203
|
+
|| !isStringId(gate.serverInstanceId)
|
|
204
|
+
|| !isStringId(gate.sourceBootId)
|
|
205
|
+
|| !isStringId(gate.sourceGenerationId)
|
|
206
|
+
|| typeof gate.startedAt !== 'string'
|
|
207
|
+
|| !Number.isFinite(Date.parse(gate.startedAt))
|
|
208
|
+
|| adoptedSuccessor === null) return null
|
|
209
|
+
|
|
210
|
+
if (gate.scope === 'same_boot') {
|
|
211
|
+
if (gate.operationKind !== 'same_boot_maintenance'
|
|
212
|
+
|| gate.postcondition !== 'same_boot_idle'
|
|
213
|
+
|| typeof gate.expiresAt !== 'string'
|
|
214
|
+
|| !Number.isFinite(Date.parse(gate.expiresAt))) return null
|
|
215
|
+
} else if (gate.operationKind === 'same_boot_maintenance'
|
|
216
|
+
|| gate.postcondition !== 'authorized_successor_adopted'
|
|
217
|
+
|| gate.expiresAt !== null) return null
|
|
218
|
+
|
|
219
|
+
return {
|
|
220
|
+
...(gate as unknown as DurableDrainGateV2),
|
|
221
|
+
authorizedSuccessorGenerations: [...successors] as string[],
|
|
222
|
+
...(adoptedSuccessor ? { adoptedSuccessor } : {}),
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
function syncDirectory(path: string): void {
|
|
227
|
+
const fd = openSync(path, 'r')
|
|
228
|
+
try { fsyncSync(fd) } finally { closeSync(fd) }
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
function assertOwnedSafeDirectory(path: string): void {
|
|
232
|
+
const stat = lstatSync(path)
|
|
233
|
+
const uid = typeof process.getuid === 'function' ? process.getuid() : stat.uid
|
|
234
|
+
if (!stat.isDirectory() || stat.isSymbolicLink() || stat.uid !== uid || (stat.mode & 0o022) !== 0) {
|
|
235
|
+
throw new Error(`Unsafe maintenance state directory: ${path}`)
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
function ensureOwnedSafeDirectory(path: string): void {
|
|
240
|
+
mkdirSync(path, { recursive: true, mode: 0o700 })
|
|
241
|
+
assertOwnedSafeDirectory(path)
|
|
242
|
+
chmodSync(path, 0o700)
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
function assertOwnedSafeGateFile(path: string): void {
|
|
246
|
+
const stat = lstatSync(path)
|
|
247
|
+
const uid = typeof process.getuid === 'function' ? process.getuid() : stat.uid
|
|
248
|
+
if (!stat.isFile() || stat.isSymbolicLink() || stat.uid !== uid || (stat.mode & 0o077) !== 0) {
|
|
249
|
+
throw new Error(`Unsafe maintenance gate file: ${path}`)
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
function persistGate(path: string, gate: DurableDrainGateV2): void {
|
|
254
|
+
const directory = dirname(path)
|
|
255
|
+
ensureOwnedSafeDirectory(directory)
|
|
256
|
+
const temp = `${path}.tmp-${process.pid}-${randomUUID()}`
|
|
257
|
+
let fd: number | null = null
|
|
258
|
+
try {
|
|
259
|
+
fd = openSync(temp, 'wx', 0o600)
|
|
260
|
+
writeFileSync(fd, `${JSON.stringify(gate)}\n`, 'utf8')
|
|
261
|
+
fsyncSync(fd)
|
|
262
|
+
closeSync(fd)
|
|
263
|
+
fd = null
|
|
264
|
+
renameSync(temp, path)
|
|
265
|
+
chmodSync(path, 0o600)
|
|
266
|
+
assertOwnedSafeGateFile(path)
|
|
267
|
+
syncDirectory(directory)
|
|
268
|
+
} catch (error) {
|
|
269
|
+
if (fd != null) closeSync(fd)
|
|
270
|
+
rmSync(temp, { force: true })
|
|
271
|
+
throw error
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
function removeGate(path: string): void {
|
|
276
|
+
const directory = dirname(path)
|
|
277
|
+
if (!existsSync(directory)) return
|
|
278
|
+
assertOwnedSafeDirectory(directory)
|
|
279
|
+
if (existsSync(path)) {
|
|
280
|
+
assertOwnedSafeGateFile(path)
|
|
281
|
+
rmSync(path)
|
|
282
|
+
}
|
|
283
|
+
syncDirectory(directory)
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
function hashNonce(nonce: string): string {
|
|
287
|
+
return createHash('sha256').update(nonce, 'utf8').digest('hex')
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
function secureDigestEqual(left: string, right: string): boolean {
|
|
291
|
+
if (!SHA256_RE.test(left) || !SHA256_RE.test(right)) return false
|
|
292
|
+
return timingSafeEqual(Buffer.from(left, 'hex'), Buffer.from(right, 'hex'))
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
/**
|
|
296
|
+
* Process-wide admission gate and work ledger. Version-2 cross-boot gates are
|
|
297
|
+
* committed operations, not expiring leases: they remain closed until an
|
|
298
|
+
* explicitly authorized successor adopts the operation and proves the
|
|
299
|
+
* controller-held nonce. Unknown/legacy state is decoded into a typed blocked
|
|
300
|
+
* state instead of being deleted or interpreted optimistically.
|
|
301
|
+
*/
|
|
302
|
+
export class MaintenanceLifecycle {
|
|
303
|
+
private readonly path: string
|
|
304
|
+
private readonly now: () => number
|
|
305
|
+
private readonly currentBootId: () => string
|
|
306
|
+
private readonly currentServerInstanceId: () => string | null
|
|
307
|
+
private readonly currentGenerationId: () => string | null
|
|
308
|
+
private readonly managed: () => boolean
|
|
309
|
+
private gate: DurableDrainGateV2 | null = null
|
|
310
|
+
private blockedGateReason: BlockedGateReason | null = null
|
|
311
|
+
private blockedGateVersion: number | null = null
|
|
312
|
+
private readonly work = new Map<string, WorkEntry>()
|
|
313
|
+
|
|
314
|
+
constructor(options: MaintenanceLifecycleOptions = {}) {
|
|
315
|
+
this.path = options.path ?? process.env.COS_MAINTENANCE_GATE_PATH?.trim() ?? DEFAULT_GATE_PATH
|
|
316
|
+
this.now = options.now ?? (() => Date.now())
|
|
317
|
+
this.currentBootId = options.bootId ?? (() => serverMetrics.bootId)
|
|
318
|
+
this.currentServerInstanceId = options.serverInstanceId ?? getServerInstanceId
|
|
319
|
+
this.currentGenerationId = options.generationId ?? getServerGenerationId
|
|
320
|
+
this.managed = options.managed ?? isManagedRuntime
|
|
321
|
+
this.loadGateSync()
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
private loadGateSync(): void {
|
|
325
|
+
let value: unknown
|
|
326
|
+
try {
|
|
327
|
+
assertOwnedSafeDirectory(dirname(this.path))
|
|
328
|
+
assertOwnedSafeGateFile(this.path)
|
|
329
|
+
value = JSON.parse(readFileSync(this.path, 'utf8'))
|
|
330
|
+
} catch (error) {
|
|
331
|
+
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') this.blockedGateReason = 'corrupt_json'
|
|
332
|
+
return
|
|
333
|
+
}
|
|
334
|
+
const version = value && typeof value === 'object'
|
|
335
|
+
? Number((value as Record<string, unknown>).version)
|
|
336
|
+
: Number.NaN
|
|
337
|
+
this.blockedGateVersion = Number.isInteger(version) ? version : null
|
|
338
|
+
if (version === GATE_VERSION) {
|
|
339
|
+
const parsed = parseGateV2(value)
|
|
340
|
+
if (parsed) this.gate = parsed
|
|
341
|
+
else this.blockedGateReason = 'invalid_schema'
|
|
342
|
+
} else if (version === 1) {
|
|
343
|
+
this.blockedGateReason = 'legacy_v1'
|
|
344
|
+
} else {
|
|
345
|
+
this.blockedGateReason = 'unknown_schema'
|
|
346
|
+
}
|
|
347
|
+
this.expireSameBootGateIfPermitted()
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
private expireSameBootGateIfPermitted(): void {
|
|
351
|
+
if (!this.gate
|
|
352
|
+
|| this.gate.scope !== 'same_boot'
|
|
353
|
+
|| this.gate.sourceBootId !== this.currentBootId()
|
|
354
|
+
|| !this.gate.expiresAt
|
|
355
|
+
|| Date.parse(this.gate.expiresAt) > this.now()) return
|
|
356
|
+
try {
|
|
357
|
+
removeGate(this.path)
|
|
358
|
+
this.gate = null
|
|
359
|
+
} catch {
|
|
360
|
+
this.blockedGateReason = 'invalid_schema'
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
private credentialsMatch(credentials: MaintenanceOperationCredentials): {
|
|
365
|
+
leaseMatches: boolean
|
|
366
|
+
operationMatches: boolean
|
|
367
|
+
nonceMatches: boolean
|
|
368
|
+
} {
|
|
369
|
+
const leaseMatches = Boolean(this.gate && credentials.leaseId === this.gate.leaseId)
|
|
370
|
+
const operationMatches = Boolean(this.gate && credentials.operationId === this.gate.operationId)
|
|
371
|
+
const nonceDigest = credentials.nonce && NONCE_RE.test(credentials.nonce)
|
|
372
|
+
? hashNonce(credentials.nonce)
|
|
373
|
+
: ''
|
|
374
|
+
const nonceMatches = Boolean(this.gate && secureDigestEqual(nonceDigest, this.gate.nonceSha256))
|
|
375
|
+
return { leaseMatches, operationMatches, nonceMatches }
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
private requireValidGate(): DurableDrainGateV2 {
|
|
379
|
+
this.expireSameBootGateIfPermitted()
|
|
380
|
+
if (this.blockedGateReason || !this.gate) {
|
|
381
|
+
throw new MaintenanceLifecycleError(
|
|
382
|
+
'maintenance_gate_unavailable',
|
|
383
|
+
'A valid maintenance operation gate is not available.',
|
|
384
|
+
{ status: 503 },
|
|
385
|
+
)
|
|
386
|
+
}
|
|
387
|
+
return this.gate
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
private requireCredentials(credentials: MaintenanceOperationCredentials): DurableDrainGateV2 {
|
|
391
|
+
const gate = this.requireValidGate()
|
|
392
|
+
const proof = this.credentialsMatch(credentials)
|
|
393
|
+
if (!proof.leaseMatches || !proof.operationMatches || !proof.nonceMatches) {
|
|
394
|
+
throw new MaintenanceLifecycleError(
|
|
395
|
+
'maintenance_operation_proof_invalid',
|
|
396
|
+
'Maintenance operation credentials are not valid.',
|
|
397
|
+
)
|
|
398
|
+
}
|
|
399
|
+
return gate
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
acquire(
|
|
403
|
+
kind: MaintenanceWorkKind,
|
|
404
|
+
options: { allowDuringDrain?: boolean; phase?: MaintenanceWorkPhase } = {},
|
|
405
|
+
): MaintenanceWorkLease {
|
|
406
|
+
this.expireSameBootGateIfPermitted()
|
|
407
|
+
// Unknown, corrupt, or unsafe durable state is never a continuation gate.
|
|
408
|
+
// Only a valid decoded gate may allow already-admitted work to finish.
|
|
409
|
+
if (this.blockedGateReason || (this.gate && !options.allowDuringDrain)) {
|
|
410
|
+
const retryAfterSeconds = this.gate?.scope === 'same_boot' && this.gate.expiresAt
|
|
411
|
+
&& this.gate.sourceBootId === this.currentBootId()
|
|
412
|
+
? Math.max(1, Math.ceil((Date.parse(this.gate.expiresAt) - this.now()) / 1_000))
|
|
413
|
+
: undefined
|
|
414
|
+
throw new MaintenanceLifecycleError(
|
|
415
|
+
'maintenance_drain_active',
|
|
416
|
+
'Server is closed for a committed maintenance operation.',
|
|
417
|
+
{ status: 503, retryable: true, retryAfterSeconds },
|
|
418
|
+
)
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
const id = randomUUID()
|
|
422
|
+
this.work.set(id, { kind, phase: options.phase ?? 'active', startedAtMs: this.now() })
|
|
423
|
+
let released = false
|
|
424
|
+
return {
|
|
425
|
+
id,
|
|
426
|
+
setPhase: (phase) => {
|
|
427
|
+
if (released) return
|
|
428
|
+
const entry = this.work.get(id)
|
|
429
|
+
if (entry) entry.phase = phase
|
|
430
|
+
},
|
|
431
|
+
release: () => {
|
|
432
|
+
if (released) return
|
|
433
|
+
released = true
|
|
434
|
+
this.work.delete(id)
|
|
435
|
+
},
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
beginDrain(request: MaintenanceDrainRequest): string {
|
|
440
|
+
this.expireSameBootGateIfPermitted()
|
|
441
|
+
if (!this.managed()) {
|
|
442
|
+
throw new MaintenanceLifecycleError('server_not_managed', 'Server is not managed.')
|
|
443
|
+
}
|
|
444
|
+
if (this.blockedGateReason) {
|
|
445
|
+
throw new MaintenanceLifecycleError(
|
|
446
|
+
'maintenance_gate_blocked',
|
|
447
|
+
'Existing durable maintenance state requires local repair.',
|
|
448
|
+
{ status: 503 },
|
|
449
|
+
)
|
|
450
|
+
}
|
|
451
|
+
if (this.gate) {
|
|
452
|
+
throw new MaintenanceLifecycleError(
|
|
453
|
+
'maintenance_operation_already_active',
|
|
454
|
+
'A maintenance operation is already active.',
|
|
455
|
+
{ retryable: true },
|
|
456
|
+
)
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
const current = {
|
|
460
|
+
serverInstanceId: this.currentServerInstanceId(),
|
|
461
|
+
bootId: this.currentBootId(),
|
|
462
|
+
generationId: this.currentGenerationId(),
|
|
463
|
+
}
|
|
464
|
+
const successors = request.authorizedSuccessorGenerations
|
|
465
|
+
if (!current.serverInstanceId || !current.generationId
|
|
466
|
+
|| request.serverInstanceId !== current.serverInstanceId
|
|
467
|
+
|| request.bootId !== current.bootId
|
|
468
|
+
|| request.generationId !== current.generationId) {
|
|
469
|
+
throw new MaintenanceLifecycleError(
|
|
470
|
+
'server_identity_mismatch',
|
|
471
|
+
'Maintenance request does not match the running server identity.',
|
|
472
|
+
)
|
|
473
|
+
}
|
|
474
|
+
if (!isStringId(request.operationId)
|
|
475
|
+
|| !isOperationKind(request.operationKind)
|
|
476
|
+
|| !isScope(request.scope)
|
|
477
|
+
|| !isPostcondition(request.postcondition)
|
|
478
|
+
|| !SHA256_RE.test(request.nonceSha256)
|
|
479
|
+
|| !Array.isArray(successors)
|
|
480
|
+
|| successors.length < 1
|
|
481
|
+
|| successors.length > 8
|
|
482
|
+
|| successors.some(value => !isStringId(value))
|
|
483
|
+
|| new Set(successors).size !== successors.length) {
|
|
484
|
+
throw new MaintenanceLifecycleError('invalid_maintenance_operation', 'Maintenance operation is invalid.', { status: 400 })
|
|
485
|
+
}
|
|
486
|
+
if ((request.scope === 'same_boot'
|
|
487
|
+
&& (request.operationKind !== 'same_boot_maintenance'
|
|
488
|
+
|| request.postcondition !== 'same_boot_idle'
|
|
489
|
+
|| successors.length !== 1
|
|
490
|
+
|| successors[0] !== current.generationId))
|
|
491
|
+
|| (request.scope === 'cross_boot'
|
|
492
|
+
&& (request.operationKind === 'same_boot_maintenance'
|
|
493
|
+
|| request.postcondition !== 'authorized_successor_adopted'))) {
|
|
494
|
+
throw new MaintenanceLifecycleError('invalid_maintenance_operation_contract', 'Maintenance operation scope and postcondition do not agree.', { status: 400 })
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
const started = this.now()
|
|
498
|
+
const ttlMs = request.scope === 'same_boot'
|
|
499
|
+
? Math.min(MAX_LEASE_MS, Math.max(MIN_LEASE_MS, request.ttlMs ?? DEFAULT_LEASE_MS))
|
|
500
|
+
: null
|
|
501
|
+
const gate: DurableDrainGateV2 = {
|
|
502
|
+
version: GATE_VERSION,
|
|
503
|
+
leaseId: randomUUID(),
|
|
504
|
+
operationId: request.operationId,
|
|
505
|
+
operationKind: request.operationKind,
|
|
506
|
+
scope: request.scope,
|
|
507
|
+
postcondition: request.postcondition,
|
|
508
|
+
nonceSha256: request.nonceSha256,
|
|
509
|
+
authorizedSuccessorGenerations: [...successors],
|
|
510
|
+
serverInstanceId: current.serverInstanceId,
|
|
511
|
+
sourceBootId: current.bootId,
|
|
512
|
+
sourceGenerationId: current.generationId,
|
|
513
|
+
startedAt: new Date(started).toISOString(),
|
|
514
|
+
expiresAt: ttlMs == null ? null : new Date(started + ttlMs).toISOString(),
|
|
515
|
+
}
|
|
516
|
+
try {
|
|
517
|
+
persistGate(this.path, gate)
|
|
518
|
+
} catch {
|
|
519
|
+
throw new MaintenanceLifecycleError(
|
|
520
|
+
'maintenance_gate_persist_failed',
|
|
521
|
+
'Could not durably commit the maintenance operation.',
|
|
522
|
+
{ status: 503, retryable: true },
|
|
523
|
+
)
|
|
524
|
+
}
|
|
525
|
+
this.gate = gate
|
|
526
|
+
return gate.leaseId
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
adoptDrain(identity: MaintenanceOperationIdentity, credentials: MaintenanceOperationCredentials): void {
|
|
530
|
+
const gate = this.requireCredentials(credentials)
|
|
531
|
+
const current = {
|
|
532
|
+
serverInstanceId: this.currentServerInstanceId(),
|
|
533
|
+
bootId: this.currentBootId(),
|
|
534
|
+
generationId: this.currentGenerationId(),
|
|
535
|
+
}
|
|
536
|
+
if (gate.scope !== 'cross_boot'
|
|
537
|
+
|| identity.operationId !== gate.operationId
|
|
538
|
+
|| !current.serverInstanceId
|
|
539
|
+
|| !current.generationId
|
|
540
|
+
|| identity.serverInstanceId !== current.serverInstanceId
|
|
541
|
+
|| identity.bootId !== current.bootId
|
|
542
|
+
|| identity.generationId !== current.generationId
|
|
543
|
+
|| current.serverInstanceId !== gate.serverInstanceId
|
|
544
|
+
|| current.bootId === gate.sourceBootId
|
|
545
|
+
|| !gate.authorizedSuccessorGenerations.includes(current.generationId)) {
|
|
546
|
+
throw new MaintenanceLifecycleError(
|
|
547
|
+
'maintenance_successor_unauthorized',
|
|
548
|
+
'Running server is not an authorized successor for this operation.',
|
|
549
|
+
)
|
|
550
|
+
}
|
|
551
|
+
const next: DurableDrainGateV2 = {
|
|
552
|
+
...gate,
|
|
553
|
+
adoptedSuccessor: {
|
|
554
|
+
serverInstanceId: current.serverInstanceId,
|
|
555
|
+
bootId: current.bootId,
|
|
556
|
+
generationId: current.generationId,
|
|
557
|
+
adoptedAt: new Date(this.now()).toISOString(),
|
|
558
|
+
},
|
|
559
|
+
}
|
|
560
|
+
try {
|
|
561
|
+
persistGate(this.path, next)
|
|
562
|
+
} catch {
|
|
563
|
+
throw new MaintenanceLifecycleError(
|
|
564
|
+
'maintenance_adoption_persist_failed',
|
|
565
|
+
'Could not durably adopt the maintenance operation.',
|
|
566
|
+
{ status: 503, retryable: true },
|
|
567
|
+
)
|
|
568
|
+
}
|
|
569
|
+
this.gate = next
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
releaseDrain(identity: MaintenanceOperationIdentity, credentials: MaintenanceOperationCredentials): void {
|
|
573
|
+
const gate = this.requireCredentials(credentials)
|
|
574
|
+
const current = {
|
|
575
|
+
serverInstanceId: this.currentServerInstanceId(),
|
|
576
|
+
bootId: this.currentBootId(),
|
|
577
|
+
generationId: this.currentGenerationId(),
|
|
578
|
+
}
|
|
579
|
+
const identityMatches = Boolean(current.serverInstanceId && current.generationId
|
|
580
|
+
&& identity.operationId === gate.operationId
|
|
581
|
+
&& identity.serverInstanceId === current.serverInstanceId
|
|
582
|
+
&& identity.bootId === current.bootId
|
|
583
|
+
&& identity.generationId === current.generationId
|
|
584
|
+
&& current.serverInstanceId === gate.serverInstanceId
|
|
585
|
+
&& gate.authorizedSuccessorGenerations.includes(current.generationId))
|
|
586
|
+
const postconditionSatisfied = gate.scope === 'same_boot'
|
|
587
|
+
? gate.postcondition === 'same_boot_idle' && current.bootId === gate.sourceBootId
|
|
588
|
+
: gate.postcondition === 'authorized_successor_adopted'
|
|
589
|
+
&& gate.adoptedSuccessor?.serverInstanceId === current.serverInstanceId
|
|
590
|
+
&& gate.adoptedSuccessor?.bootId === current.bootId
|
|
591
|
+
&& gate.adoptedSuccessor?.generationId === current.generationId
|
|
592
|
+
if (!identityMatches || !postconditionSatisfied) {
|
|
593
|
+
throw new MaintenanceLifecycleError(
|
|
594
|
+
'maintenance_release_postcondition_failed',
|
|
595
|
+
'Maintenance operation release postcondition is not satisfied.',
|
|
596
|
+
)
|
|
597
|
+
}
|
|
598
|
+
try {
|
|
599
|
+
removeGate(this.path)
|
|
600
|
+
} catch {
|
|
601
|
+
throw new MaintenanceLifecycleError(
|
|
602
|
+
'maintenance_gate_release_failed',
|
|
603
|
+
'Could not durably clear the maintenance operation.',
|
|
604
|
+
{ status: 503, retryable: true },
|
|
605
|
+
)
|
|
606
|
+
}
|
|
607
|
+
this.gate = null
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
cancelDrain(identity: MaintenanceOperationIdentity, credentials: MaintenanceOperationCredentials): void {
|
|
611
|
+
const gate = this.requireCredentials(credentials)
|
|
612
|
+
if (identity.operationId !== gate.operationId
|
|
613
|
+
|| identity.serverInstanceId !== gate.serverInstanceId
|
|
614
|
+
|| identity.bootId !== gate.sourceBootId
|
|
615
|
+
|| identity.generationId !== gate.sourceGenerationId
|
|
616
|
+
|| this.currentServerInstanceId() !== gate.serverInstanceId
|
|
617
|
+
|| this.currentBootId() !== gate.sourceBootId
|
|
618
|
+
|| this.currentGenerationId() !== gate.sourceGenerationId) {
|
|
619
|
+
throw new MaintenanceLifecycleError(
|
|
620
|
+
'maintenance_cancel_identity_mismatch',
|
|
621
|
+
'Only the source boot may cancel this maintenance operation.',
|
|
622
|
+
)
|
|
623
|
+
}
|
|
624
|
+
try {
|
|
625
|
+
removeGate(this.path)
|
|
626
|
+
} catch {
|
|
627
|
+
throw new MaintenanceLifecycleError(
|
|
628
|
+
'maintenance_gate_cancel_failed',
|
|
629
|
+
'Could not durably cancel the maintenance operation.',
|
|
630
|
+
{ status: 503, retryable: true },
|
|
631
|
+
)
|
|
632
|
+
}
|
|
633
|
+
this.gate = null
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
snapshot(credentials: MaintenanceOperationCredentials = {}, extraActiveByKind: Record<string, number> = {}) {
|
|
637
|
+
this.expireSameBootGateIfPermitted()
|
|
638
|
+
const activeByKind: Record<string, number> = {}
|
|
639
|
+
let queuedTransitions = 0
|
|
640
|
+
let oldestStartedAtMs: number | null = null
|
|
641
|
+
for (const entry of this.work.values()) {
|
|
642
|
+
activeByKind[entry.kind] = (activeByKind[entry.kind] ?? 0) + 1
|
|
643
|
+
if (entry.phase === 'queued') queuedTransitions++
|
|
644
|
+
oldestStartedAtMs = oldestStartedAtMs == null
|
|
645
|
+
? entry.startedAtMs
|
|
646
|
+
: Math.min(oldestStartedAtMs, entry.startedAtMs)
|
|
647
|
+
}
|
|
648
|
+
for (const [kind, rawCount] of Object.entries(extraActiveByKind)) {
|
|
649
|
+
const count = Number.isFinite(rawCount) ? Math.max(0, Math.floor(rawCount)) : 0
|
|
650
|
+
if (count > 0) activeByKind[kind] = (activeByKind[kind] ?? 0) + count
|
|
651
|
+
}
|
|
652
|
+
const activeTotal = Object.values(activeByKind).reduce((sum, count) => sum + count, 0)
|
|
653
|
+
const currentBootId = this.currentBootId()
|
|
654
|
+
const currentGenerationId = this.currentGenerationId()
|
|
655
|
+
const currentServerInstanceId = this.currentServerInstanceId()
|
|
656
|
+
const credentialProof = this.credentialsMatch(credentials)
|
|
657
|
+
const sourceIdentityMatches = Boolean(this.gate
|
|
658
|
+
&& this.gate.serverInstanceId === currentServerInstanceId
|
|
659
|
+
&& this.gate.sourceBootId === currentBootId
|
|
660
|
+
&& this.gate.sourceGenerationId === currentGenerationId)
|
|
661
|
+
const candidateAdopted = Boolean(this.gate?.adoptedSuccessor)
|
|
662
|
+
const candidateIdentityMatches = Boolean(this.gate?.adoptedSuccessor
|
|
663
|
+
&& this.gate.adoptedSuccessor.serverInstanceId === currentServerInstanceId
|
|
664
|
+
&& this.gate.adoptedSuccessor.bootId === currentBootId
|
|
665
|
+
&& this.gate.adoptedSuccessor.generationId === currentGenerationId)
|
|
666
|
+
const proofValid = credentialProof.leaseMatches
|
|
667
|
+
&& credentialProof.operationMatches
|
|
668
|
+
&& credentialProof.nonceMatches
|
|
669
|
+
&& sourceIdentityMatches
|
|
670
|
+
&& !this.blockedGateReason
|
|
671
|
+
return {
|
|
672
|
+
state: this.blockedGateReason ? `blocked_${this.blockedGateReason}` : this.gate ? 'draining' : 'accepting',
|
|
673
|
+
admissionsOpen: !this.gate && !this.blockedGateReason,
|
|
674
|
+
blockedGate: this.blockedGateReason ? {
|
|
675
|
+
reason: this.blockedGateReason,
|
|
676
|
+
version: this.blockedGateVersion,
|
|
677
|
+
} : null,
|
|
678
|
+
activeTotal,
|
|
679
|
+
activeByKind,
|
|
680
|
+
queuedTransitions,
|
|
681
|
+
idle: activeTotal === 0,
|
|
682
|
+
oldestWorkStartedAt: oldestStartedAtMs == null ? null : new Date(oldestStartedAtMs).toISOString(),
|
|
683
|
+
operation: this.gate ? {
|
|
684
|
+
version: this.gate.version,
|
|
685
|
+
operationId: this.gate.operationId,
|
|
686
|
+
operationKind: this.gate.operationKind,
|
|
687
|
+
scope: this.gate.scope,
|
|
688
|
+
postcondition: this.gate.postcondition,
|
|
689
|
+
nonceSha256: this.gate.nonceSha256,
|
|
690
|
+
authorizedSuccessorGenerations: [...this.gate.authorizedSuccessorGenerations],
|
|
691
|
+
serverInstanceId: this.gate.serverInstanceId,
|
|
692
|
+
sourceBootId: this.gate.sourceBootId,
|
|
693
|
+
sourceGenerationId: this.gate.sourceGenerationId,
|
|
694
|
+
startedAt: this.gate.startedAt,
|
|
695
|
+
expiresAt: this.gate.expiresAt,
|
|
696
|
+
carriedAcrossBoot: this.gate.sourceBootId !== currentBootId,
|
|
697
|
+
adoptedSuccessor: this.gate.adoptedSuccessor ?? null,
|
|
698
|
+
} : null,
|
|
699
|
+
restartProof: {
|
|
700
|
+
valid: proofValid,
|
|
701
|
+
...credentialProof,
|
|
702
|
+
sourceIdentityMatches,
|
|
703
|
+
candidateAdopted,
|
|
704
|
+
candidateIdentityMatches,
|
|
705
|
+
serverInstanceId: currentServerInstanceId,
|
|
706
|
+
bootId: currentBootId,
|
|
707
|
+
generationId: currentGenerationId,
|
|
708
|
+
},
|
|
709
|
+
safeToRestart: this.managed() && proofValid && activeTotal === 0,
|
|
710
|
+
}
|
|
711
|
+
}
|
|
712
|
+
}
|
|
713
|
+
|
|
714
|
+
export const maintenanceLifecycle = new MaintenanceLifecycle()
|
|
715
|
+
|
|
716
|
+
export function acquireMaintenanceWork(
|
|
717
|
+
kind: MaintenanceWorkKind,
|
|
718
|
+
options?: { allowDuringDrain?: boolean; phase?: MaintenanceWorkPhase },
|
|
719
|
+
): MaintenanceWorkLease {
|
|
720
|
+
return maintenanceLifecycle.acquire(kind, options)
|
|
721
|
+
}
|
|
722
|
+
|
|
723
|
+
/** Read-only boot/background-worker admission check. */
|
|
724
|
+
export function maintenanceAdmissionsOpen(): boolean {
|
|
725
|
+
return maintenanceLifecycle.snapshot().admissionsOpen
|
|
726
|
+
}
|
|
727
|
+
|
|
728
|
+
export function maintenanceErrorPayload(error: MaintenanceLifecycleError) {
|
|
729
|
+
return {
|
|
730
|
+
error: error.code,
|
|
731
|
+
message: error.message,
|
|
732
|
+
retryable: error.retryable,
|
|
733
|
+
...(error.retryAfterSeconds != null ? { retryAfterSeconds: error.retryAfterSeconds } : {}),
|
|
734
|
+
}
|
|
735
|
+
}
|