@gotcos/glasses-server 6.76.1 → 6.78.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/.env.example +40 -0
- package/CHANGELOG.md +56 -0
- package/README.md +12 -1
- package/managed-runtime-contract.json +17 -1
- package/package.json +10 -1
- package/server/index.ts +98 -10
- package/server/lib/agent-session-binding-registry.ts +623 -9
- package/server/lib/boot-resume.ts +552 -0
- package/server/lib/claude-bridge.ts +152 -3
- package/server/lib/claude-cli-support.ts +39 -0
- package/server/lib/claude-steer.ts +259 -0
- package/server/lib/cos-bridge-notes.ts +73 -0
- package/server/lib/decision-provider.ts +9 -0
- package/server/lib/env-switch.ts +17 -0
- package/server/lib/fuzzy-correct.ts +77 -49
- package/server/lib/live-cos-ask.ts +41 -7
- package/server/lib/live-cues-v2-notes.ts +152 -0
- package/server/lib/live-cues-v2-settings.ts +63 -0
- package/server/lib/live-cues-v2.ts +198 -38
- package/server/lib/managed-restart.ts +8 -0
- package/server/lib/provider-actions.ts +18 -0
- package/server/lib/query-job-coordinator.ts +300 -0
- package/server/lib/query-job-feature.ts +39 -0
- package/server/lib/query-job-resume.ts +269 -0
- package/server/lib/query-job-runtime.ts +23 -3
- package/server/lib/query-job-store.ts +516 -7
- package/server/lib/query-job-types.ts +210 -1
- package/server/lib/queue-turn-evidence.ts +213 -0
- package/server/lib/thread-turn-queue-deliver.ts +6 -1
- package/server/lib/thread-turn-queue-store.ts +13 -4
- package/server/lib/thread-turn-queue.ts +151 -1
- package/server/lib/transcript-cleanup-context.ts +234 -0
- package/server/lib/transcript-cleanup-ledger.ts +304 -0
- package/server/lib/transcript-cleanup-model.ts +217 -0
- package/server/lib/transcript-cleanup-screen.ts +249 -0
- package/server/lib/transcript-cleanup-spans.ts +358 -0
- package/server/lib/transcript-cleanup-state.ts +201 -0
- package/server/lib/transcript-cleanup-suggestions.ts +126 -0
- package/server/lib/transcript-cleanup-text.ts +200 -0
- package/server/lib/transcript-cleanup.ts +427 -0
- package/server/routes/agent-session-bindings.ts +169 -11
- package/server/routes/glossary.ts +38 -19
- package/server/routes/health.ts +29 -1
- package/server/routes/live-cues.ts +32 -3
- package/server/routes/maintenance.ts +34 -0
- package/server/routes/meeting.ts +92 -9
- package/server/routes/query-jobs.ts +107 -13
- package/server/routes/recovery.ts +4 -0
- package/server/routes/thread-turn-queue.ts +371 -10
- package/server/routes/transcribe.ts +3 -0
- package/server/routes/transcript-cleanup.ts +237 -0
- package/shared/live-cues-v2.ts +9 -4
package/server/routes/meeting.ts
CHANGED
|
@@ -181,7 +181,14 @@ import {
|
|
|
181
181
|
type MeetingFinalizationJob,
|
|
182
182
|
} from '../lib/meeting-finalization-jobs.js'
|
|
183
183
|
import { enrichStandaloneMeeting } from '../lib/meeting-summary-persistence.js'
|
|
184
|
+
import { bootResumeGate, type BootResumeGate } from '../lib/boot-resume.js'
|
|
184
185
|
import { recordFinalPassSkip, runNemotronFinalPass, type FinalPassOptions, type FinalPassOutcome } from '../lib/nemotron-final.js'
|
|
186
|
+
import { runTranscriptCleanup, type CleanupOutcome } from '../lib/transcript-cleanup.js'
|
|
187
|
+
import { sweepTranscriptCleanupData } from '../lib/transcript-cleanup-screen.js'
|
|
188
|
+
import type { TranscriptCleanupRouteDeps } from './transcript-cleanup.js'
|
|
189
|
+
|
|
190
|
+
/** 6.77.0: the post-save transcript cleanup. Injectable so tests never reach a model. */
|
|
191
|
+
export type TranscriptCleanupRunner = (input: { sessionId: string; meetingPath: string; sidecarPath: string }) => Promise<CleanupOutcome>
|
|
185
192
|
|
|
186
193
|
/** 6.61.0: the save-time Nemotron pass. Injectable so tests never spawn the CLI. */
|
|
187
194
|
export type FinalDiarize = (options: Pick<FinalPassOptions, 'meetingPath' | 'sidecarPath' | 'audioDir'>) => Promise<FinalPassOutcome>
|
|
@@ -243,6 +250,8 @@ export interface MeetingRouteDependencies {
|
|
|
243
250
|
finalDiarize?: FinalDiarize
|
|
244
251
|
/** Control's stop requests for live G2 meetings (lib/meeting-stop-requests.ts). */
|
|
245
252
|
stopRequests?: StopRequestStore
|
|
253
|
+
/** 6.77.0: the post-save transcript cleanup (lib/transcript-cleanup.ts). */
|
|
254
|
+
transcriptCleanup?: TranscriptCleanupRunner
|
|
246
255
|
}
|
|
247
256
|
|
|
248
257
|
const activeFinalizationJobs = new Set<string>()
|
|
@@ -254,6 +263,21 @@ interface FinalizationRuntime {
|
|
|
254
263
|
scheduleBackground: (task: Promise<void>) => void
|
|
255
264
|
sessions?: MeetingSessionSource
|
|
256
265
|
finalDiarize?: FinalDiarize
|
|
266
|
+
transcriptCleanup?: TranscriptCleanupRunner
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
/**
|
|
270
|
+
* The cleanup as finalization runs it: a meeting whose session is somehow live again is
|
|
271
|
+
* skipped, and nothing it does can fail the finalization (it never throws; this catch is
|
|
272
|
+
* the last line of defence should that ever change).
|
|
273
|
+
*/
|
|
274
|
+
async function runCleanupStage(runner: TranscriptCleanupRunner | undefined, input: { sessionId: string; meetingPath: string; sidecarPath: string }, sessions?: MeetingSessionSource): Promise<void> {
|
|
275
|
+
const run = runner ?? (cleanupInput => runTranscriptCleanup(cleanupInput, { sessionLive: id => (sessions?.getState ?? defaultSessionState)(id) === 'active' }))
|
|
276
|
+
try {
|
|
277
|
+
await run(input)
|
|
278
|
+
} catch (error) {
|
|
279
|
+
console.warn(`[meeting/save] transcript cleanup threw; the saved transcript is unchanged: ${error instanceof Error ? error.name : 'error'}`)
|
|
280
|
+
}
|
|
257
281
|
}
|
|
258
282
|
|
|
259
283
|
function validReplayEntries(raw: unknown[]): IndexedTranscriptChunk[] | null {
|
|
@@ -289,6 +313,7 @@ function scheduleFinalizationJob(job: MeetingFinalizationJob, runtime: Finalizat
|
|
|
289
313
|
let current = runtime.finalizationJobs.get(job.sessionId) ?? job
|
|
290
314
|
if (canonicalFinalizationIsComplete(current)) {
|
|
291
315
|
runtime.finalizationJobs.remove(current.sessionId)
|
|
316
|
+
bootResumeGate().forget('meeting_finalization', current.sessionId)
|
|
292
317
|
return
|
|
293
318
|
}
|
|
294
319
|
if (current.phase === 'capture_pending') {
|
|
@@ -372,6 +397,11 @@ function scheduleFinalizationJob(job: MeetingFinalizationJob, runtime: Finalizat
|
|
|
372
397
|
markCanonicalFinalizationState(current.sidecarPath, 'ops_pending', current.claimPending)
|
|
373
398
|
}
|
|
374
399
|
|
|
400
|
+
// 6.77.0: the transcript is final here (HQ text and the Nemotron relabel are written),
|
|
401
|
+
// and nothing downstream has read it yet: COS operations imports, and the standalone
|
|
402
|
+
// summary describes, the cleaned text. Off unless Miles turns it on.
|
|
403
|
+
await runCleanupStage(runtime.transcriptCleanup, { sessionId: current.sessionId, meetingPath: current.meetingPath, sidecarPath: current.sidecarPath }, runtime.sessions)
|
|
404
|
+
|
|
375
405
|
if (cosOpsPipelineConfigured() && !MAC_CAPTURE_ID.test(current.sessionId)) {
|
|
376
406
|
await handoffMeetingToOperations(current.meetingPath)
|
|
377
407
|
} else {
|
|
@@ -387,6 +417,8 @@ function scheduleFinalizationJob(job: MeetingFinalizationJob, runtime: Finalizat
|
|
|
387
417
|
// capture, and never throws back at the finalization path.
|
|
388
418
|
if (!MAC_CAPTURE_ID.test(current.sessionId)) triggerMeetingMergeRun('g2_finalized')
|
|
389
419
|
runtime.finalizationJobs.remove(current.sessionId)
|
|
420
|
+
// 6.78.0: finished, so its crash-loop count (lib/boot-resume.ts) is done.
|
|
421
|
+
bootResumeGate().forget('meeting_finalization', current.sessionId)
|
|
390
422
|
finalizationRetryCounts.delete(key)
|
|
391
423
|
}).catch(error => {
|
|
392
424
|
const current = runtime.finalizationJobs.get(job.sessionId) ?? job
|
|
@@ -429,16 +461,28 @@ function scheduleFinalizationJob(job: MeetingFinalizationJob, runtime: Finalizat
|
|
|
429
461
|
}
|
|
430
462
|
|
|
431
463
|
/** Boot hook: replay post-save HQ/operations work whose response already
|
|
432
|
-
* succeeded before a prior process exited. Safe to call more than once.
|
|
433
|
-
|
|
434
|
-
|
|
464
|
+
* succeeded before a prior process exited. Safe to call more than once.
|
|
465
|
+
* 6.78.0: `sessionId` resumes one job (the maintenance Retry); the rest are seams for tests. */
|
|
466
|
+
export function resumeMeetingFinalizationJobs(options: {
|
|
467
|
+
sessionId?: string
|
|
468
|
+
gate?: BootResumeGate
|
|
469
|
+
finalizationJobs?: MeetingFinalizationJobStore
|
|
470
|
+
schedule?: (job: MeetingFinalizationJob) => void
|
|
471
|
+
} = {}): void {
|
|
472
|
+
const finalizationJobs = options.finalizationJobs ?? new MeetingFinalizationJobStore()
|
|
435
473
|
finalizationJobs.reconcileCanonicalSidecars()
|
|
474
|
+
const gate = options.gate ?? bootResumeGate()
|
|
475
|
+
const schedule = options.schedule ?? ((job: MeetingFinalizationJob) => scheduleFinalizationJob(job, {
|
|
476
|
+
finalizationJobs,
|
|
477
|
+
runBatch: runMeetingBatchPipeline,
|
|
478
|
+
scheduleBackground: task => { void task },
|
|
479
|
+
}))
|
|
436
480
|
for (const job of finalizationJobs.list()) {
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
481
|
+
if (options.sessionId !== undefined && job.sessionId !== options.sessionId) continue
|
|
482
|
+
// 6.78.0: counted (durably) BEFORE it runs, so a job that kills the process is parked
|
|
483
|
+
// after RESTART_LIMIT crashed boots. A parked job is skipped and stays in its store.
|
|
484
|
+
if (!gate.admit('meeting_finalization', job.sessionId, job.phase)) continue
|
|
485
|
+
schedule(job)
|
|
442
486
|
}
|
|
443
487
|
}
|
|
444
488
|
|
|
@@ -558,6 +602,7 @@ export function createMeetingRouter(deps: MeetingRouteDependencies = {}): Router
|
|
|
558
602
|
const emit = deps.emit ?? emitDisplay
|
|
559
603
|
const finalizationJobs = deps.finalizationJobs ?? new MeetingFinalizationJobStore()
|
|
560
604
|
const finalDiarize = deps.finalDiarize ?? runNemotronFinalPass
|
|
605
|
+
const transcriptCleanup = deps.transcriptCleanup
|
|
561
606
|
const router = Router()
|
|
562
607
|
const savingSessions = new Set<string>()
|
|
563
608
|
const stopRequests = deps.stopRequests ?? createStopRequestStore()
|
|
@@ -779,7 +824,9 @@ export function createMeetingRouter(deps: MeetingRouteDependencies = {}): Router
|
|
|
779
824
|
if (alreadySaved) {
|
|
780
825
|
finalizationJobs.reconcileCanonicalSidecars()
|
|
781
826
|
const pendingJob = finalizationJobs.get(sessionId)
|
|
782
|
-
|
|
827
|
+
// 6.78.0: a replayed save never re-runs a job parked for crash-looping the server.
|
|
828
|
+
if (pendingJob && !bootResumeGate().isParked('meeting_finalization', sessionId)) scheduleFinalizationJob(pendingJob, {
|
|
829
|
+
transcriptCleanup,
|
|
783
830
|
finalizationJobs,
|
|
784
831
|
runBatch,
|
|
785
832
|
scheduleBackground,
|
|
@@ -943,6 +990,7 @@ export function createMeetingRouter(deps: MeetingRouteDependencies = {}): Router
|
|
|
943
990
|
markCanonicalFinalizationState(saved.sidecarPath, 'complete', false)
|
|
944
991
|
if (!readMacRecording(sessionId)) triggerMeetingMergeRun('g2_finalized')
|
|
945
992
|
finalizationJobs.remove(sessionId)
|
|
993
|
+
bootResumeGate().forget('meeting_finalization', sessionId)
|
|
946
994
|
finalizationJob = null
|
|
947
995
|
}
|
|
948
996
|
|
|
@@ -956,6 +1004,7 @@ export function createMeetingRouter(deps: MeetingRouteDependencies = {}): Router
|
|
|
956
1004
|
scheduleBackground,
|
|
957
1005
|
sessions,
|
|
958
1006
|
finalDiarize,
|
|
1007
|
+
transcriptCleanup,
|
|
959
1008
|
})
|
|
960
1009
|
}
|
|
961
1010
|
} catch (error) {
|
|
@@ -2438,6 +2487,7 @@ export function createMeetingRouter(deps: MeetingRouteDependencies = {}): Router
|
|
|
2438
2487
|
},
|
|
2439
2488
|
})
|
|
2440
2489
|
} catch { /* display is best-effort */ }
|
|
2490
|
+
await runCleanupStage(transcriptCleanup, { sessionId, meetingPath: saved.filepath, sidecarPath: saved.sidecarPath }, sessions)
|
|
2441
2491
|
if (cosOpsPipelineConfigured() && !macCapture) {
|
|
2442
2492
|
await handoffMeetingToOperations(saved.filepath)
|
|
2443
2493
|
} else {
|
|
@@ -2620,3 +2670,36 @@ async function finalizeBatch(options: {
|
|
|
2620
2670
|
}
|
|
2621
2671
|
|
|
2622
2672
|
export const meetingRouter = createMeetingRouter()
|
|
2673
|
+
|
|
2674
|
+
/** 6.77.0: what the transcript cleanup routes need from the meeting layer. */
|
|
2675
|
+
export function transcriptCleanupRouteDeps(store: MeetingStore = getMeetingStore()): TranscriptCleanupRouteDeps {
|
|
2676
|
+
return {
|
|
2677
|
+
sessionState: id => defaultSessionState(id),
|
|
2678
|
+
sessionStartedAt: id => getSessionStartTime(id),
|
|
2679
|
+
findMeeting: id => {
|
|
2680
|
+
const meeting = store.findBySessionId(id)
|
|
2681
|
+
return meeting ? { meetingPath: meeting.filepath, sidecarPath: meeting.sidecarPath } : null
|
|
2682
|
+
},
|
|
2683
|
+
}
|
|
2684
|
+
}
|
|
2685
|
+
|
|
2686
|
+
/**
|
|
2687
|
+
* Boot: delete cleanup data (screen context and ledgers) whose meeting is gone, and screen
|
|
2688
|
+
* text past its retention. Deferred and unref'd, so it never delays startup or a restart.
|
|
2689
|
+
*/
|
|
2690
|
+
export function scheduleTranscriptCleanupSweep(delayMs = 90_000): void {
|
|
2691
|
+
const timer = setTimeout(() => {
|
|
2692
|
+
try {
|
|
2693
|
+
const store = getMeetingStore()
|
|
2694
|
+
const jobs = new MeetingFinalizationJobStore()
|
|
2695
|
+
const result = sweepTranscriptCleanupData({
|
|
2696
|
+
now: Date.now(),
|
|
2697
|
+
known: id => defaultSessionState(id) !== 'missing' || Boolean(jobs.get(id)) || store.findBySessionId(id) !== null,
|
|
2698
|
+
})
|
|
2699
|
+
if (result.screenDeleted || result.ledgersDeleted) {
|
|
2700
|
+
console.log(`[transcript-cleanup] swept ${result.screenDeleted} screen and ${result.ledgersDeleted} ledger files`)
|
|
2701
|
+
}
|
|
2702
|
+
} catch { /* the next boot sweeps again */ }
|
|
2703
|
+
}, delayMs)
|
|
2704
|
+
timer.unref()
|
|
2705
|
+
}
|
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
import { Router, type Request, type Response } from 'express'
|
|
2
|
-
import { durableQueryJobsEnabled } from '../lib/query-job-feature.js'
|
|
2
|
+
import { durableQueryJobsEnabled, messagesSteerEnabled } from '../lib/query-job-feature.js'
|
|
3
3
|
import {
|
|
4
4
|
QueryJobCoordinator,
|
|
5
5
|
QueryJobCoordinatorError,
|
|
6
|
+
QueryJobSteerLimitError,
|
|
7
|
+
QueryJobSteerUnknownError,
|
|
6
8
|
queryJobErrorCode,
|
|
7
9
|
} from '../lib/query-job-coordinator.js'
|
|
8
10
|
import {
|
|
@@ -15,13 +17,16 @@ import {
|
|
|
15
17
|
QueryJobNotTerminalError,
|
|
16
18
|
QueryJobPersistenceError,
|
|
17
19
|
QueryJobProviderOrphanFenceError,
|
|
20
|
+
QueryJobSteerConflictError,
|
|
18
21
|
QueryJobStoreError,
|
|
22
|
+
type QueryJobSteerFrame,
|
|
19
23
|
type QueryJobTrailFrame,
|
|
20
24
|
} from '../lib/query-job-store.js'
|
|
21
25
|
import {
|
|
22
26
|
isTerminalQueryJobStatus,
|
|
23
27
|
normalizeQueryJobError,
|
|
24
28
|
parsePositiveInteger,
|
|
29
|
+
steerReadState,
|
|
25
30
|
QueryJobValidationError,
|
|
26
31
|
type QueryJobEvent,
|
|
27
32
|
type QueryJobSnapshot,
|
|
@@ -33,6 +38,8 @@ export interface QueryJobsRouterOptions {
|
|
|
33
38
|
enabled?: () => boolean
|
|
34
39
|
heartbeatMs?: number
|
|
35
40
|
prepareAdmission?: (raw: unknown) => unknown | Promise<unknown>
|
|
41
|
+
/** 6.78.0 Messages steer. Defaults to COS_MESSAGES_STEER (off). */
|
|
42
|
+
steerEnabled?: () => boolean
|
|
36
43
|
}
|
|
37
44
|
|
|
38
45
|
interface WireError {
|
|
@@ -104,13 +111,50 @@ function wantsTrail(req: Request): boolean {
|
|
|
104
111
|
return req.query.trail === '1'
|
|
105
112
|
}
|
|
106
113
|
|
|
107
|
-
function forClient(job: QueryJobSnapshot, trail: boolean): QueryJobSnapshot {
|
|
114
|
+
function forClient(job: QueryJobSnapshot, trail: boolean, steer = false): QueryJobSnapshot {
|
|
115
|
+
return steerView(trailView(job, trail), steer)
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function trailView(job: QueryJobSnapshot, trail: boolean): QueryJobSnapshot {
|
|
108
119
|
if (trail || !('trail' in job)) return job
|
|
109
120
|
const { trail: _trail, ...rest } = job
|
|
110
121
|
return rest as QueryJobSnapshot
|
|
111
122
|
}
|
|
112
123
|
|
|
113
|
-
|
|
124
|
+
/**
|
|
125
|
+
* 6.78.0: STEERS ARE OPT-IN, like the trail. Without `?steer=1` a snapshot carries no `steers`
|
|
126
|
+
* (exactly the 6.77.0 shape); with it, each steer reads as a client should see it now
|
|
127
|
+
* (`not_applied` for one an ended job did not get to apply).
|
|
128
|
+
*/
|
|
129
|
+
function wantsSteer(req: Request): boolean {
|
|
130
|
+
return req.query.steer === '1'
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function steerView(job: QueryJobSnapshot, steer: boolean): QueryJobSnapshot {
|
|
134
|
+
if (!('steers' in job)) return job
|
|
135
|
+
const { steers, ...rest } = job
|
|
136
|
+
if (!steer || !steers) return rest as QueryJobSnapshot
|
|
137
|
+
return { ...rest, steers: steers.map(item => ({ ...item, state: steerReadState(item.state, job.status) })) } as QueryJobSnapshot
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/** A steer frame on the wire (contract messages-steer.json `sse_frame`): no `id:` line, no eventSeq. */
|
|
141
|
+
function writeSteer(res: Response, frame: Pick<QueryJobSteerFrame, 'steerId' | 'state' | 'at'>): void {
|
|
142
|
+
res.write(`event: steer\ndata: ${JSON.stringify({ steerId: frame.steerId, state: frame.state, at: frame.at })}\n\n`)
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/** The steer routes' error bodies (contract messages-steer.json `errors`). */
|
|
146
|
+
function steerWireError(error: unknown): { status: number; body: WireError } {
|
|
147
|
+
if (error instanceof QueryJobSteerConflictError) return { status: 409, body: { error: { code: error.code, message: error.message } } }
|
|
148
|
+
if (error instanceof QueryJobSteerLimitError) return { status: 429, body: { error: { code: error.code, message: error.message, retryable: false } } }
|
|
149
|
+
if (error instanceof QueryJobSteerUnknownError) return { status: 404, body: { error: { code: error.code, message: error.message } } }
|
|
150
|
+
if (error instanceof QueryJobNotFoundError) return { status: 404, body: { error: { code: 'query_job_not_found', message: 'Query job not found' } } }
|
|
151
|
+
if (error instanceof QueryJobGenerationMismatchError) return { status: 409, body: { error: { code: 'query_job_generation_mismatch', message: 'Generation mismatch' } } }
|
|
152
|
+
return wireError(error)
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
const STEER_DISABLED: WireError = { error: { code: 'steer_disabled', message: 'Steering is off on this Mac.' } }
|
|
156
|
+
|
|
157
|
+
function snapshotEvent(job: QueryJobSnapshot, reason: string, trail: boolean, steer = false): Record<string, unknown> {
|
|
114
158
|
return {
|
|
115
159
|
type: 'snapshot',
|
|
116
160
|
eventSeq: job.eventSeq,
|
|
@@ -119,7 +163,7 @@ function snapshotEvent(job: QueryJobSnapshot, reason: string, trail: boolean): R
|
|
|
119
163
|
generation: job.generation,
|
|
120
164
|
status: job.status,
|
|
121
165
|
at: job.updatedAt,
|
|
122
|
-
data: { reason, job: forClient(job, trail) },
|
|
166
|
+
data: { reason, job: forClient(job, trail, steer) },
|
|
123
167
|
}
|
|
124
168
|
}
|
|
125
169
|
|
|
@@ -129,6 +173,9 @@ export function createQueryJobsRouter(
|
|
|
129
173
|
): Router {
|
|
130
174
|
const router = Router()
|
|
131
175
|
const enabled = options.enabled ?? durableQueryJobsEnabled
|
|
176
|
+
const steerEnabled = options.steerEnabled ?? messagesSteerEnabled
|
|
177
|
+
// Steers reach a client only when it asks AND the switch is on.
|
|
178
|
+
const steerRead = (req: Request) => wantsSteer(req) && steerEnabled()
|
|
132
179
|
const heartbeatMs = Math.max(1_000, options.heartbeatMs ?? 15_000)
|
|
133
180
|
|
|
134
181
|
router.post('/query-jobs', async (req, res) => {
|
|
@@ -143,7 +190,7 @@ export function createQueryJobsRouter(
|
|
|
143
190
|
try {
|
|
144
191
|
const prepared = options.prepareAdmission ? await options.prepareAdmission(req.body) : req.body
|
|
145
192
|
const admission = await coordinator.submit(prepared)
|
|
146
|
-
return res.status(202).json({ job: forClient(admission.job, wantsTrail(req)) })
|
|
193
|
+
return res.status(202).json({ job: forClient(admission.job, wantsTrail(req), steerRead(req)) })
|
|
147
194
|
} catch (error) {
|
|
148
195
|
const wire = wireError(error)
|
|
149
196
|
return res.status(wire.status).json(wire.body)
|
|
@@ -159,19 +206,32 @@ export function createQueryJobsRouter(
|
|
|
159
206
|
const generation = requiredGeneration(req.query.generation)
|
|
160
207
|
const job = await coordinator.getByClientGeneration(clientJobId, generation)
|
|
161
208
|
if (!job) throw new QueryJobNotFoundError(`${clientJobId}:${generation}`)
|
|
162
|
-
return res.json({ job: forClient(job, wantsTrail(req)) })
|
|
209
|
+
return res.json({ job: forClient(job, wantsTrail(req), steerRead(req)) })
|
|
163
210
|
} catch (error) {
|
|
164
211
|
const wire = wireError(error)
|
|
165
212
|
return res.status(wire.status).json(wire.body)
|
|
166
213
|
}
|
|
167
214
|
})
|
|
168
215
|
|
|
216
|
+
// 6.78.0: a steer by its id and lineage, from the newest generation that holds it. Ahead of
|
|
217
|
+
// the dynamic job routes, like by-client.
|
|
218
|
+
router.get('/query-jobs/steers/:steerId', async (req, res) => {
|
|
219
|
+
if (!enabled() || !steerEnabled()) return res.status(404).json(STEER_DISABLED)
|
|
220
|
+
try {
|
|
221
|
+
const clientJobId = validClientJobId(req.query.clientJobId)
|
|
222
|
+
return res.json(await coordinator.steerStatus(clientJobId, req.params.steerId))
|
|
223
|
+
} catch (error) {
|
|
224
|
+
const wire = steerWireError(error)
|
|
225
|
+
return res.status(wire.status).json(wire.body)
|
|
226
|
+
}
|
|
227
|
+
})
|
|
228
|
+
|
|
169
229
|
router.get('/query-jobs/:jobId', async (req, res) => {
|
|
170
230
|
try {
|
|
171
231
|
const jobId = validJobId(req.params.jobId)
|
|
172
232
|
const generation = requiredGeneration(req.query.generation)
|
|
173
233
|
const job = await coordinator.getSnapshot(jobId, generation)
|
|
174
|
-
return res.json({ job: forClient(job, wantsTrail(req)) })
|
|
234
|
+
return res.json({ job: forClient(job, wantsTrail(req), steerRead(req)) })
|
|
175
235
|
} catch (error) {
|
|
176
236
|
const wire = wireError(error)
|
|
177
237
|
return res.status(wire.status).json(wire.body)
|
|
@@ -188,6 +248,9 @@ export function createQueryJobsRouter(
|
|
|
188
248
|
const pendingLive: QueryJobEvent[] = []
|
|
189
249
|
const pendingTrail: QueryJobTrailFrame[] = []
|
|
190
250
|
const trail = wantsTrail(req)
|
|
251
|
+
// 6.78.0: `?steer=1` adds `steer` frames; without it the stream is the 6.77.0 one.
|
|
252
|
+
const steer = steerRead(req)
|
|
253
|
+
const pendingSteer: QueryJobSteerFrame[] = []
|
|
191
254
|
const writeTrail = (frame: QueryJobTrailFrame) => {
|
|
192
255
|
if (closed || frame.trailSeq <= sentTrailSeq) return
|
|
193
256
|
sentTrailSeq = frame.trailSeq
|
|
@@ -243,8 +306,22 @@ export function createQueryJobsRouter(
|
|
|
243
306
|
writeSse(res, event.type, event as unknown as Record<string, unknown>)
|
|
244
307
|
if (isTerminalQueryJobStatus(event.status)) finish()
|
|
245
308
|
}
|
|
309
|
+
const steerSubscription = steer
|
|
310
|
+
? {
|
|
311
|
+
listener: (frame: QueryJobSteerFrame) => {
|
|
312
|
+
if (closed) return
|
|
313
|
+
if (!streamReady) {
|
|
314
|
+
pendingSteer.push(frame)
|
|
315
|
+
return
|
|
316
|
+
}
|
|
317
|
+
writeSteer(res, frame)
|
|
318
|
+
},
|
|
319
|
+
}
|
|
320
|
+
: undefined
|
|
246
321
|
// Without `?trail=1` the call is exactly the 6.51.0 one.
|
|
247
|
-
const subscription =
|
|
322
|
+
const subscription = steerSubscription
|
|
323
|
+
? await coordinator.subscribe(jobId, generation, after, onEvent, trailSubscription, steerSubscription)
|
|
324
|
+
: trailSubscription
|
|
248
325
|
? await coordinator.subscribe(jobId, generation, after, onEvent, trailSubscription)
|
|
249
326
|
: await coordinator.subscribe(jobId, generation, after, onEvent)
|
|
250
327
|
unsubscribe = subscription.unsubscribe
|
|
@@ -263,7 +340,10 @@ export function createQueryJobsRouter(
|
|
|
263
340
|
res.flushHeaders()
|
|
264
341
|
res.write(': keepalive\n\n')
|
|
265
342
|
|
|
266
|
-
const { replay, trailReplay } = subscription
|
|
343
|
+
const { replay, trailReplay, steerReplay } = subscription
|
|
344
|
+
// 6.78.0: every steer as it reads now, before any job event (a state only moves forward,
|
|
345
|
+
// so a client that saw some of these simply sees them again).
|
|
346
|
+
if (steer && steerReplay) for (const item of steerReplay) writeSteer(res, item)
|
|
267
347
|
// The trail first: every entry precedes the terminal event that may end this stream.
|
|
268
348
|
if (trail && trailReplay) {
|
|
269
349
|
if (trailReplay.gap) {
|
|
@@ -289,7 +369,7 @@ export function createQueryJobsRouter(
|
|
|
289
369
|
}
|
|
290
370
|
}
|
|
291
371
|
if (replay.gap) {
|
|
292
|
-
writeSse(res, 'snapshot', snapshotEvent(replay.snapshot, replay.reason ?? 'replay_gap', trail))
|
|
372
|
+
writeSse(res, 'snapshot', snapshotEvent(replay.snapshot, replay.reason ?? 'replay_gap', trail, steer))
|
|
293
373
|
sentSeq = replay.snapshot.eventSeq
|
|
294
374
|
} else {
|
|
295
375
|
for (const event of replay.events) {
|
|
@@ -301,7 +381,7 @@ export function createQueryJobsRouter(
|
|
|
301
381
|
|
|
302
382
|
const replayTerminal = replay.events.some(event => isTerminalQueryJobStatus(event.status))
|
|
303
383
|
if (isTerminalQueryJobStatus(replay.snapshot.status) && !replayTerminal && !replay.gap) {
|
|
304
|
-
writeSse(res, 'snapshot', snapshotEvent(replay.snapshot, 'terminal_snapshot', trail))
|
|
384
|
+
writeSse(res, 'snapshot', snapshotEvent(replay.snapshot, 'terminal_snapshot', trail, steer))
|
|
305
385
|
sentSeq = replay.snapshot.eventSeq
|
|
306
386
|
}
|
|
307
387
|
if (isTerminalQueryJobStatus(replay.snapshot.status)) {
|
|
@@ -312,6 +392,8 @@ export function createQueryJobsRouter(
|
|
|
312
392
|
streamReady = true
|
|
313
393
|
for (const frame of pendingTrail) writeTrail(frame)
|
|
314
394
|
pendingTrail.length = 0
|
|
395
|
+
for (const frame of pendingSteer) writeSteer(res, frame)
|
|
396
|
+
pendingSteer.length = 0
|
|
315
397
|
for (const event of pendingLive) {
|
|
316
398
|
if (closed || event.eventSeq <= sentSeq) continue
|
|
317
399
|
sentSeq = event.eventSeq
|
|
@@ -344,7 +426,7 @@ export function createQueryJobsRouter(
|
|
|
344
426
|
const jobId = validJobId(req.params.jobId)
|
|
345
427
|
const generation = requiredGeneration(req.body?.generation)
|
|
346
428
|
const { job } = await coordinator.cancel(jobId, generation)
|
|
347
|
-
return res.json({ job: forClient(job, wantsTrail(req)) })
|
|
429
|
+
return res.json({ job: forClient(job, wantsTrail(req), steerRead(req)) })
|
|
348
430
|
} catch (error) {
|
|
349
431
|
const wire = wireError(error)
|
|
350
432
|
return res.status(wire.status).json(wire.body)
|
|
@@ -356,12 +438,24 @@ export function createQueryJobsRouter(
|
|
|
356
438
|
const jobId = validJobId(req.params.jobId)
|
|
357
439
|
const generation = requiredGeneration(req.body?.generation)
|
|
358
440
|
const job = await coordinator.acknowledge(jobId, generation)
|
|
359
|
-
return res.json({ job: forClient(job, wantsTrail(req)) })
|
|
441
|
+
return res.json({ job: forClient(job, wantsTrail(req), steerRead(req)) })
|
|
360
442
|
} catch (error) {
|
|
361
443
|
const wire = wireError(error)
|
|
362
444
|
return res.status(wire.status).json(wire.body)
|
|
363
445
|
}
|
|
364
446
|
})
|
|
365
447
|
|
|
448
|
+
// 6.78.0 Messages steer (contract messages-steer.json). 404 steer_disabled when off.
|
|
449
|
+
router.post('/query-jobs/:jobId/steer', async (req, res) => {
|
|
450
|
+
if (!enabled() || !steerEnabled()) return res.status(404).json(STEER_DISABLED)
|
|
451
|
+
try {
|
|
452
|
+
const jobId = validJobId(req.params.jobId)
|
|
453
|
+
return res.json(await coordinator.steer(jobId, req.body))
|
|
454
|
+
} catch (error) {
|
|
455
|
+
const wire = steerWireError(error)
|
|
456
|
+
return res.status(wire.status).json(wire.body)
|
|
457
|
+
}
|
|
458
|
+
})
|
|
459
|
+
|
|
366
460
|
return router
|
|
367
461
|
}
|
|
@@ -2,6 +2,7 @@ import { Router } from 'express'
|
|
|
2
2
|
import { readFileSync } from 'node:fs'
|
|
3
3
|
import { resolve } from 'node:path'
|
|
4
4
|
import { atomicWriteFileSync } from '../lib/atomic-fs.js'
|
|
5
|
+
import { bootResumeGate } from '../lib/boot-resume.js'
|
|
5
6
|
import { scheduleManagedServerRestart } from '../lib/managed-restart.js'
|
|
6
7
|
import { isManagedRuntime } from '../lib/managed-runtime.js'
|
|
7
8
|
import { acquireMaintenance, getRecoveryActivityStatus } from '../lib/recovery-activity.js'
|
|
@@ -72,6 +73,9 @@ recoveryRouter.post('/recovery/server/restart', (_req, res) => {
|
|
|
72
73
|
// SuccessfulExit:false, so a clean exit leaves the service STOPPED and the
|
|
73
74
|
// phone shows "Restart requested; reconnect is taking longer than expected."
|
|
74
75
|
scheduleManagedServerRestart({
|
|
76
|
+
// 6.78.0 (P7 S4): a requested restart is a clean exit, whichever way the process ends
|
|
77
|
+
// (SIGTERM from kickstart -k, or the 2 s exit(1) fallback): it never counts as a crash.
|
|
78
|
+
beforeRestart: () => bootResumeGate().markCleanShutdown(),
|
|
75
79
|
onError: (error) => {
|
|
76
80
|
gate.release()
|
|
77
81
|
console.error('[recovery] Failed to schedule managed server restart:', error)
|