@gotcos/glasses-server 6.77.0 → 6.78.1
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 +24 -0
- package/README.md +12 -1
- package/managed-runtime-contract.json +9 -1
- package/package.json +10 -1
- package/server/index.ts +96 -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/env-switch.ts +17 -0
- package/server/lib/managed-restart.ts +8 -0
- package/server/lib/native-head.ts +65 -6
- 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/routes/agent-session-bindings.ts +169 -11
- package/server/routes/health.ts +26 -1
- package/server/routes/maintenance.ts +34 -0
- package/server/routes/meeting.ts +27 -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/scripts/live-cues-v2-notes-mutation-gate.mjs +0 -170
- package/server/scripts/transcript-cleanup-mutation-gate.mjs +0 -219
package/server/routes/meeting.ts
CHANGED
|
@@ -181,6 +181,7 @@ 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'
|
|
185
186
|
import { runTranscriptCleanup, type CleanupOutcome } from '../lib/transcript-cleanup.js'
|
|
186
187
|
import { sweepTranscriptCleanupData } from '../lib/transcript-cleanup-screen.js'
|
|
@@ -312,6 +313,7 @@ function scheduleFinalizationJob(job: MeetingFinalizationJob, runtime: Finalizat
|
|
|
312
313
|
let current = runtime.finalizationJobs.get(job.sessionId) ?? job
|
|
313
314
|
if (canonicalFinalizationIsComplete(current)) {
|
|
314
315
|
runtime.finalizationJobs.remove(current.sessionId)
|
|
316
|
+
bootResumeGate().forget('meeting_finalization', current.sessionId)
|
|
315
317
|
return
|
|
316
318
|
}
|
|
317
319
|
if (current.phase === 'capture_pending') {
|
|
@@ -415,6 +417,8 @@ function scheduleFinalizationJob(job: MeetingFinalizationJob, runtime: Finalizat
|
|
|
415
417
|
// capture, and never throws back at the finalization path.
|
|
416
418
|
if (!MAC_CAPTURE_ID.test(current.sessionId)) triggerMeetingMergeRun('g2_finalized')
|
|
417
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)
|
|
418
422
|
finalizationRetryCounts.delete(key)
|
|
419
423
|
}).catch(error => {
|
|
420
424
|
const current = runtime.finalizationJobs.get(job.sessionId) ?? job
|
|
@@ -457,16 +461,28 @@ function scheduleFinalizationJob(job: MeetingFinalizationJob, runtime: Finalizat
|
|
|
457
461
|
}
|
|
458
462
|
|
|
459
463
|
/** Boot hook: replay post-save HQ/operations work whose response already
|
|
460
|
-
* succeeded before a prior process exited. Safe to call more than once.
|
|
461
|
-
|
|
462
|
-
|
|
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()
|
|
463
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
|
+
}))
|
|
464
480
|
for (const job of finalizationJobs.list()) {
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
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)
|
|
470
486
|
}
|
|
471
487
|
}
|
|
472
488
|
|
|
@@ -808,7 +824,8 @@ export function createMeetingRouter(deps: MeetingRouteDependencies = {}): Router
|
|
|
808
824
|
if (alreadySaved) {
|
|
809
825
|
finalizationJobs.reconcileCanonicalSidecars()
|
|
810
826
|
const pendingJob = finalizationJobs.get(sessionId)
|
|
811
|
-
|
|
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, {
|
|
812
829
|
transcriptCleanup,
|
|
813
830
|
finalizationJobs,
|
|
814
831
|
runBatch,
|
|
@@ -973,6 +990,7 @@ export function createMeetingRouter(deps: MeetingRouteDependencies = {}): Router
|
|
|
973
990
|
markCanonicalFinalizationState(saved.sidecarPath, 'complete', false)
|
|
974
991
|
if (!readMacRecording(sessionId)) triggerMeetingMergeRun('g2_finalized')
|
|
975
992
|
finalizationJobs.remove(sessionId)
|
|
993
|
+
bootResumeGate().forget('meeting_finalization', sessionId)
|
|
976
994
|
finalizationJob = null
|
|
977
995
|
}
|
|
978
996
|
|
|
@@ -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)
|