@gotcos/glasses-server 6.42.1 → 6.43.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.
@@ -0,0 +1,412 @@
1
+ // Morning brief — the scheduler.
2
+ //
3
+ // One 30-second tick, unref'd so it never keeps a shutting-down process alive.
4
+ // Each tick asks the pure decision function whether the scheduled slot has
5
+ // arrived for the configured zone, then fires by submitting a durable query
6
+ // job to the same coordinator that every phone-originated prompt uses. From
7
+ // that point on the brief is an ordinary job: it survives the phone being
8
+ // asleep, it is projected into the conversation store with a message number
9
+ // when it completes, and the companion's history hydration picks it up like
10
+ // any other reply.
11
+ //
12
+ // HOW IT STOPS (the question every new provider caller must answer):
13
+ // - one scheduled fire per local calendar day, remembered in a ledger that is
14
+ // written BEFORE the job is admitted;
15
+ // - a failed admission retries at most `scheduledAttemptsPerDay` times, two
16
+ // minutes apart, then the day is given up;
17
+ // - a crash between the ledger write and the admission is RESUMED by client
18
+ // identity (deterministic per day), never re-run;
19
+ // - "Run now" is capped per day and refused while a brief is already running;
20
+ // - the whole thing is inert when durable jobs are off (COS Control's
21
+ // "Background jobs" switch) or maintenance admissions are closed.
22
+
23
+ import { createHash, randomUUID } from 'node:crypto'
24
+ import {
25
+ MORNING_BRIEF_LIMITS,
26
+ applyMorningBriefPatch,
27
+ loadMorningBriefConfig,
28
+ loadMorningBriefLedger,
29
+ saveMorningBriefConfig,
30
+ saveMorningBriefLedger,
31
+ type MorningBriefConfig,
32
+ type MorningBriefLedger,
33
+ type MorningBriefRun,
34
+ type MorningBriefStorePaths,
35
+ type MorningBriefTrigger,
36
+ serverTimezone,
37
+ } from './morning-brief-config.js'
38
+ import { composeMorningBriefPrompt } from './morning-brief-prompt.js'
39
+ import {
40
+ briefSections,
41
+ sectionOutcomes,
42
+ type MorningBriefCoverage,
43
+ type MorningBriefCoverageService,
44
+ type MorningBriefSectionOutcome,
45
+ } from './morning-brief-coverage.js'
46
+ import type { MessageReservation } from './message-reservations.js'
47
+ import { decideScheduledFire, localClock, nextScheduledFire } from './morning-brief-schedule.js'
48
+ import type { QueryJobSnapshot } from './query-job-types.js'
49
+ import { isTerminalQueryJobStatus } from './query-job-types.js'
50
+
51
+ export interface MorningBriefSubmission {
52
+ job: Pick<QueryJobSnapshot, 'jobId' | 'clientJobId' | 'generation' | 'status' | 'sessionId'>
53
+ created: boolean
54
+ }
55
+
56
+ export interface MorningBriefSchedulerDeps {
57
+ paths: MorningBriefStorePaths
58
+ submit: (request: Record<string, unknown>) => Promise<MorningBriefSubmission>
59
+ findByClientGeneration: (clientJobId: string, generation: number) => Promise<QueryJobSnapshot | undefined>
60
+ getSnapshot: (jobId: string) => Promise<QueryJobSnapshot>
61
+ createSession: () => string
62
+ currentMessageEra: () => string
63
+ /** Highest stamped number in the active era across live sessions and archives. */
64
+ currentMessageMax: () => number
65
+ ownerName: () => string
66
+ durableJobsEnabled: () => boolean
67
+ admissionsOpen: () => boolean
68
+ /** Per-source reach (counts behind each source). Absent on a bare harness. */
69
+ coverage?: MorningBriefCoverageService
70
+ now?: () => number
71
+ tickMs?: number
72
+ log?: (line: string) => void
73
+ }
74
+
75
+ export class MorningBriefRunError extends Error {
76
+ constructor(readonly status: number, readonly code: string, message: string) {
77
+ super(message)
78
+ this.name = 'MorningBriefRunError'
79
+ }
80
+ }
81
+
82
+ export interface MorningBriefRunView extends Omit<MorningBriefRun, 'sections'> {
83
+ status: string
84
+ completedAt?: string
85
+ error?: { code: string; message: string }
86
+ /** First ~120 chars of the answer, for a list row. Never the whole brief. */
87
+ preview?: string
88
+ /** Which asked-for sections the answer opened, once the run is terminal. */
89
+ sections?: MorningBriefSectionOutcome[]
90
+ }
91
+
92
+ export interface MorningBriefStatus {
93
+ protocolVersion: 1
94
+ enabled: boolean
95
+ time: string
96
+ timezone: string
97
+ serverTimezone: string
98
+ days: number[]
99
+ nextRunAt: string | null
100
+ lastRun: MorningBriefRunView | null
101
+ /** Why the scheduler would not fire right now, for a status line. */
102
+ gate: 'ready' | 'durable_jobs_off' | 'admissions_closed' | 'disabled'
103
+ }
104
+
105
+ export type TickResult =
106
+ | { fired: true; run: MorningBriefRun }
107
+ | { fired: false; reason: string }
108
+
109
+ /** Deterministic v4-shaped client id for one local day, so a retry after a
110
+ * crashed submission admits as the SAME job. The store dedupes on it. */
111
+ export function scheduledClientJobId(day: string, timezone: string): string {
112
+ const digest = createHash('sha256').update(`morning-brief|${timezone}|${day}`).digest()
113
+ const bytes = Buffer.from(digest.subarray(0, 16))
114
+ bytes[6] = (bytes[6] & 0x0f) | 0x40
115
+ bytes[8] = (bytes[8] & 0x3f) | 0x80
116
+ const hex = bytes.toString('hex')
117
+ return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`
118
+ }
119
+
120
+ function errorParts(error: unknown): { code: string; message: string } {
121
+ const code = typeof (error as { code?: unknown })?.code === 'string' ? (error as { code: string }).code : 'submit_failed'
122
+ const message = error instanceof Error ? error.message : String(error)
123
+ return { code: code.slice(0, 64), message: message.slice(0, 300) }
124
+ }
125
+
126
+ export class MorningBriefScheduler {
127
+ private config: MorningBriefConfig
128
+ private ledger: MorningBriefLedger
129
+ private timer: ReturnType<typeof setInterval> | null = null
130
+ private tickInFlight: Promise<TickResult> | null = null
131
+ private readonly now: () => number
132
+ private readonly log: (line: string) => void
133
+ readonly quarantinedConfig?: string
134
+
135
+ constructor(private readonly deps: MorningBriefSchedulerDeps) {
136
+ this.now = deps.now ?? Date.now
137
+ this.log = deps.log ?? ((line) => console.log(`[morning-brief] ${line}`))
138
+ const loaded = loadMorningBriefConfig(deps.paths, new Date(this.now()))
139
+ this.config = loaded.config
140
+ this.quarantinedConfig = loaded.quarantinedAs
141
+ if (loaded.fresh) {
142
+ // Persist the defaults so every surface reads one file from the start
143
+ // and so `updatedAt` is a real first-seen time.
144
+ try { saveMorningBriefConfig(deps.paths, this.config) } catch (error) {
145
+ this.log(`could not persist default config: ${(error as Error).message}`)
146
+ }
147
+ }
148
+ this.ledger = loadMorningBriefLedger(deps.paths)
149
+ }
150
+
151
+ start(): void {
152
+ if (this.timer) return
153
+ const tickMs = Math.max(1_000, this.deps.tickMs ?? 30_000)
154
+ this.timer = setInterval(() => { void this.tick() }, tickMs)
155
+ this.timer.unref?.()
156
+ this.log(`scheduled ${this.config.enabled ? `daily at ${this.config.time} ${this.config.timezone}` : 'off'} · next ${nextScheduledFire(this.config, this.now()) ?? 'none'}`)
157
+ }
158
+
159
+ stop(): void {
160
+ if (this.timer) clearInterval(this.timer)
161
+ this.timer = null
162
+ }
163
+
164
+ getConfig(): MorningBriefConfig {
165
+ return structuredClone(this.config)
166
+ }
167
+
168
+ updateConfig(patch: unknown): MorningBriefConfig {
169
+ const next = applyMorningBriefPatch(this.config, patch, new Date(this.now()))
170
+ saveMorningBriefConfig(this.deps.paths, next)
171
+ this.config = next
172
+ this.log(`config updated · ${next.enabled ? `daily at ${next.time} ${next.timezone}` : 'off'} · next ${nextScheduledFire(next, this.now()) ?? 'none'}`)
173
+ return structuredClone(next)
174
+ }
175
+
176
+ previewPrompt(trigger: MorningBriefTrigger = 'scheduled'): string {
177
+ const clock = localClock(this.now(), this.config.timezone)
178
+ return composeMorningBriefPrompt({ config: this.config, day: clock.day, ownerName: this.deps.ownerName(), trigger })
179
+ }
180
+
181
+ /** One scheduler pass. Serialised: a slow submission never overlaps the next tick. */
182
+ tick(): Promise<TickResult> {
183
+ if (this.tickInFlight) return this.tickInFlight
184
+ this.tickInFlight = this.runTick().finally(() => { this.tickInFlight = null })
185
+ return this.tickInFlight
186
+ }
187
+
188
+ private async runTick(): Promise<TickResult> {
189
+ if (!this.deps.durableJobsEnabled()) return { fired: false, reason: 'durable_jobs_off' }
190
+ if (!this.deps.admissionsOpen()) return { fired: false, reason: 'admissions_closed' }
191
+ const decision = decideScheduledFire(this.config, this.ledger.runs, this.now())
192
+ if (!decision.fire) return { fired: false, reason: decision.reason }
193
+ const run = await this.fire('scheduled', decision.day, decision.attempt, decision.resume)
194
+ return { fired: true, run }
195
+ }
196
+
197
+ /** "Run now" from a settings surface. Bounded per day; refused while a brief is live. */
198
+ async runNow(): Promise<MorningBriefRun> {
199
+ if (!this.deps.durableJobsEnabled()) {
200
+ throw new MorningBriefRunError(409, 'durable_jobs_off', 'Turn on Background jobs in COS Control to run the brief.')
201
+ }
202
+ if (!this.deps.admissionsOpen()) {
203
+ throw new MorningBriefRunError(503, 'admissions_closed', 'The server is in maintenance. Try again in a moment.')
204
+ }
205
+ const clock = localClock(this.now(), this.config.timezone)
206
+ const today = this.ledger.runs.filter(run => run.day === clock.day)
207
+ for (const run of today) {
208
+ if (!run.jobId) continue
209
+ const snapshot = await this.deps.getSnapshot(run.jobId).catch(() => undefined)
210
+ if (snapshot && !isTerminalQueryJobStatus(snapshot.status)) {
211
+ throw new MorningBriefRunError(409, 'brief_in_progress', 'A brief is already running. It will land in the inbox when it finishes.')
212
+ }
213
+ }
214
+ const manual = today.filter(run => run.trigger === 'manual' && run.jobId).length
215
+ if (manual >= MORNING_BRIEF_LIMITS.manualRunsPerDay) {
216
+ throw new MorningBriefRunError(429, 'manual_runs_exhausted', `Run now is limited to ${MORNING_BRIEF_LIMITS.manualRunsPerDay} briefs a day.`)
217
+ }
218
+ const run = await this.fire('manual', clock.day, manual + 1)
219
+ if (run.submitError) {
220
+ throw new MorningBriefRunError(503, run.submitError.code, run.submitError.message)
221
+ }
222
+ return run
223
+ }
224
+
225
+ private async fire(trigger: MorningBriefTrigger, day: string, attempt: number, resume?: MorningBriefRun): Promise<MorningBriefRun> {
226
+ const clientJobId = trigger === 'scheduled'
227
+ ? scheduledClientJobId(day, this.config.timezone)
228
+ : randomUUID()
229
+
230
+ // Resume: a ledger row with no job id. Adopt an admission the coordinator
231
+ // may already hold for this identity before considering a fresh submit.
232
+ if (resume) {
233
+ const existing = await this.deps.findByClientGeneration(clientJobId, 1).catch(() => undefined)
234
+ if (existing) {
235
+ const adopted: MorningBriefRun = { ...resume, jobId: existing.jobId, lastKnownStatus: existing.status }
236
+ this.replaceRun(adopted)
237
+ this.log(`adopted ${trigger} brief for ${day} as job ${existing.jobId}`)
238
+ return adopted
239
+ }
240
+ }
241
+
242
+ const sessionId = resume?.sessionId ?? this.deps.createSession()
243
+ const messageEra = this.deps.currentMessageEra()
244
+ const globalMsgNum = resume?.globalMsgNum ?? this.deps.currentMessageMax() + 1
245
+ const run: MorningBriefRun = {
246
+ id: resume?.id ?? randomUUID(),
247
+ day,
248
+ trigger,
249
+ attempt,
250
+ firedAt: new Date(this.now()).toISOString(),
251
+ clientJobId,
252
+ generation: 1,
253
+ sessionId,
254
+ messageEra,
255
+ globalMsgNum,
256
+ sections: resume?.sections ?? briefSections(this.config, day),
257
+ }
258
+ // Ledger first. A crash after this line is a resume, not a second brief.
259
+ this.replaceRun(run)
260
+
261
+ const prompt = composeMorningBriefPrompt({ config: this.config, day, ownerName: this.deps.ownerName(), trigger })
262
+ try {
263
+ const admission = await this.deps.submit({
264
+ clientJobId,
265
+ generation: 1,
266
+ query: prompt,
267
+ sessionId,
268
+ messageEra,
269
+ globalMsgNum,
270
+ ...(this.config.model ? { model: this.config.model } : {}),
271
+ ...(this.config.effort ? { effort: this.config.effort } : {}),
272
+ activityToolMode: 'status',
273
+ attachmentIds: [],
274
+ attachmentRefs: [],
275
+ })
276
+ const accepted: MorningBriefRun = { ...run, jobId: admission.job.jobId, lastKnownStatus: admission.job.status }
277
+ this.replaceRun(accepted)
278
+ this.log(`${trigger} brief for ${day} admitted as job ${admission.job.jobId} (#${globalMsgNum}${admission.created ? '' : ', already held'})`)
279
+ return accepted
280
+ } catch (error) {
281
+ const failed: MorningBriefRun = { ...run, submitError: errorParts(error) }
282
+ this.replaceRun(failed)
283
+ this.log(`${trigger} brief for ${day} attempt ${attempt} failed to submit: ${failed.submitError!.code}`)
284
+ return failed
285
+ }
286
+ }
287
+
288
+ private replaceRun(run: MorningBriefRun): void {
289
+ const index = this.ledger.runs.findIndex(existing => existing.id === run.id)
290
+ if (index >= 0) this.ledger.runs[index] = run
291
+ else this.ledger.runs.push(run)
292
+ if (this.ledger.runs.length > MORNING_BRIEF_LIMITS.retainedRuns) {
293
+ this.ledger.runs = this.ledger.runs.slice(-MORNING_BRIEF_LIMITS.retainedRuns)
294
+ }
295
+ try {
296
+ saveMorningBriefLedger(this.deps.paths, this.ledger)
297
+ } catch (error) {
298
+ this.log(`ledger write failed: ${(error as Error).message}`)
299
+ }
300
+ }
301
+
302
+ /** Ledger rows newest first, each with the job's live status folded in. */
303
+ async listRuns(limit = 14): Promise<MorningBriefRunView[]> {
304
+ const rows = this.ledger.runs.slice(-Math.max(1, Math.min(limit, MORNING_BRIEF_LIMITS.retainedRuns))).reverse()
305
+ const views: MorningBriefRunView[] = []
306
+ for (const run of rows) {
307
+ const pending = run.sections?.length ? run.sections.map(section => ({ ...section, state: 'pending' as const })) : undefined
308
+ if (!run.jobId) {
309
+ views.push({ ...run, sections: pending, status: run.submitError ? 'submit_failed' : 'submitting', ...(run.submitError ? { error: run.submitError } : {}) })
310
+ continue
311
+ }
312
+ const snapshot = await this.deps.getSnapshot(run.jobId).catch(() => undefined)
313
+ if (!snapshot) {
314
+ views.push({ ...run, sections: pending, status: run.lastKnownStatus ?? 'unknown' })
315
+ continue
316
+ }
317
+ if (snapshot.status !== run.lastKnownStatus && isTerminalQueryJobStatus(snapshot.status)) {
318
+ this.replaceRun({ ...run, lastKnownStatus: snapshot.status })
319
+ }
320
+ const answer = snapshot.response ?? snapshot.partialText ?? ''
321
+ const sections = run.sections?.length && snapshot.status === 'completed'
322
+ ? sectionOutcomes(run.sections, snapshot.response ?? '')
323
+ : pending
324
+ views.push({
325
+ ...run,
326
+ sections,
327
+ status: snapshot.status,
328
+ ...(snapshot.completedAt ? { completedAt: snapshot.completedAt } : {}),
329
+ ...(snapshot.error ? { error: { code: snapshot.error.code, message: snapshot.error.message } } : {}),
330
+ ...(answer ? { preview: answer.replace(/\s+/g, ' ').trim().slice(0, 120) } : {}),
331
+ })
332
+ }
333
+ return views
334
+ }
335
+
336
+ /** Per-source reach for the settings surfaces. `null` when the harness has
337
+ * no probes. `force` re-runs the probes instead of serving the cache. */
338
+ async coverage(force = false): Promise<MorningBriefCoverage | null> {
339
+ if (!this.deps.coverage) return null
340
+ try {
341
+ return await this.deps.coverage.describe(this.config, force)
342
+ } catch (error) {
343
+ this.log(`coverage failed: ${(error as Error).message}`)
344
+ return null
345
+ }
346
+ }
347
+
348
+ /**
349
+ * Numbers this scheduler has minted for runs that are not terminal yet: the
350
+ * ledger row exists BEFORE the job does, and the job's own reservation
351
+ * (query-job-store identities) ends the moment its terminal projection
352
+ * writes the exchange. Bounded to a day so a crashed row cannot pin the
353
+ * counter forever; the resume path reuses the same number anyway.
354
+ */
355
+ liveReservations(era: string): MessageReservation[] {
356
+ const floor = this.now() - 24 * 60 * 60_000
357
+ const out: MessageReservation[] = []
358
+ for (const run of this.ledger.runs) {
359
+ if (typeof run.globalMsgNum !== 'number' || run.submitError) continue
360
+ if (run.lastKnownStatus && isTerminalQueryJobStatus(run.lastKnownStatus as never)) continue
361
+ const fired = Date.parse(run.firedAt)
362
+ if (!Number.isFinite(fired) || fired < floor) continue
363
+ if (era !== run.messageEra) continue
364
+ out.push({ globalMsgNum: run.globalMsgNum, messageEra: run.messageEra, owner: `brief:${run.day}` })
365
+ }
366
+ return out
367
+ }
368
+
369
+ async status(): Promise<MorningBriefStatus> {
370
+ const [lastRun] = await this.listRuns(1)
371
+ const gate: MorningBriefStatus['gate'] = !this.deps.durableJobsEnabled()
372
+ ? 'durable_jobs_off'
373
+ : !this.deps.admissionsOpen()
374
+ ? 'admissions_closed'
375
+ : !this.config.enabled ? 'disabled' : 'ready'
376
+ return {
377
+ protocolVersion: 1,
378
+ enabled: this.config.enabled,
379
+ time: this.config.time,
380
+ timezone: this.config.timezone,
381
+ serverTimezone: serverTimezone(),
382
+ days: [...this.config.days],
383
+ nextRunAt: nextScheduledFire(this.config, this.now()),
384
+ lastRun: lastRun ?? null,
385
+ gate,
386
+ }
387
+ }
388
+
389
+ /** Public-health shape: no prompt, no session ids, no job ids. */
390
+ async capability(): Promise<{
391
+ protocolVersion: 1
392
+ enabled: boolean
393
+ time: string
394
+ timezone: string
395
+ nextRunAt: string | null
396
+ lastRunAt: string | null
397
+ lastRunStatus: string | null
398
+ gate: MorningBriefStatus['gate']
399
+ }> {
400
+ const status = await this.status()
401
+ return {
402
+ protocolVersion: 1,
403
+ enabled: status.enabled,
404
+ time: status.time,
405
+ timezone: status.timezone,
406
+ nextRunAt: status.nextRunAt,
407
+ lastRunAt: status.lastRun?.firedAt ?? null,
408
+ lastRunStatus: status.lastRun?.status ?? null,
409
+ gate: status.gate,
410
+ }
411
+ }
412
+ }
@@ -596,6 +596,12 @@ export class QueryJobCoordinator {
596
596
  }
597
597
  }
598
598
 
599
+ /** Message numbers held by admitted, not-yet-terminal jobs (see
600
+ * lib/message-reservations.ts). Sync: read straight off the store's identities. */
601
+ liveMessageReservations(): Array<{ jobId: string; sessionId: string; messageEra?: string; globalMsgNum: number }> {
602
+ return this.store.listLiveMessageReservations()
603
+ }
604
+
599
605
  getHealth(): QueryJobCoordinatorHealth {
600
606
  return {
601
607
  activeRuns: this.active.size,
@@ -33,6 +33,7 @@ import {
33
33
  type QueryJobSnapshot,
34
34
  } from './query-job-types.js'
35
35
  import { acquireMaintenanceWork } from './maintenance-lifecycle.js'
36
+ import { registerMessageReservationSource } from './message-reservations.js'
36
37
 
37
38
  const TOOL_STATUS_MESSAGES: Record<string, string> = {
38
39
  WebSearch: 'Searching web...',
@@ -338,6 +339,14 @@ export const queryJobCoordinator = new QueryJobCoordinator(queryJobStore, runner
338
339
  acquireMaintenanceWork: () => acquireMaintenanceWork('durable_query', { phase: 'queued' }),
339
340
  })
340
341
 
342
+ // A job's number is a live reservation from admission until its terminal
343
+ // projection; the counter must see it or the phone mints it again.
344
+ registerMessageReservationSource(() => queryJobCoordinator.liveMessageReservations().map(item => ({
345
+ globalMsgNum: item.globalMsgNum,
346
+ ...(item.messageEra ? { messageEra: item.messageEra } : {}),
347
+ owner: `job:${item.jobId}`,
348
+ })))
349
+
341
350
  export function initQueryJobRuntime() {
342
351
  if (!durableQueryJobsEnabled()) return Promise.resolve(queryJobCoordinator.getHealth())
343
352
  return queryJobCoordinator.init()
@@ -69,6 +69,11 @@ interface QueryJobIdentity {
69
69
  status: QueryJobStatus
70
70
  updatedAt: string
71
71
  orphanFenceUntil?: string
72
+ /** The number and era the client minted for this job, carried on the
73
+ * always-in-memory identity so a live reservation can be enumerated without
74
+ * hydrating the journal. See lib/message-reservations.ts. */
75
+ messageEra?: string
76
+ globalMsgNum?: number
72
77
  }
73
78
 
74
79
  export interface QueryJobMutationResult {
@@ -541,6 +546,8 @@ export class QueryJobStore {
541
546
  status: record.status,
542
547
  updatedAt: record.persistedAt,
543
548
  ...(snapshot.orphanFenceUntil ? { orphanFenceUntil: snapshot.orphanFenceUntil } : {}),
549
+ ...(typeof hydrated.request.messageEra === 'string' ? { messageEra: hydrated.request.messageEra } : {}),
550
+ ...(typeof hydrated.request.globalMsgNum === 'number' ? { globalMsgNum: hydrated.request.globalMsgNum } : {}),
544
551
  }
545
552
  this.identitiesByJobId.set(identity.jobId, identity)
546
553
  this.identitiesByKey.set(identityKey(identity.clientJobId, identity.generation), identity)
@@ -1018,6 +1025,27 @@ export class QueryJobStore {
1018
1025
  return out
1019
1026
  }
1020
1027
 
1028
+ /**
1029
+ * Numbers held by jobs that are admitted but not yet terminal. Synchronous
1030
+ * and in-memory on purpose: /api/message-counter answers on every phone
1031
+ * send, and identities are never evicted (only hydrated bodies are).
1032
+ * Before init the map is empty, so the answer is an honest nothing.
1033
+ */
1034
+ listLiveMessageReservations(): Array<{ jobId: string; sessionId: string; messageEra?: string; globalMsgNum: number }> {
1035
+ const out: Array<{ jobId: string; sessionId: string; messageEra?: string; globalMsgNum: number }> = []
1036
+ for (const identity of this.identitiesByJobId.values()) {
1037
+ if (isTerminalQueryJobStatus(identity.status)) continue
1038
+ if (typeof identity.globalMsgNum !== 'number') continue
1039
+ out.push({
1040
+ jobId: identity.jobId,
1041
+ sessionId: identity.sessionId,
1042
+ ...(identity.messageEra ? { messageEra: identity.messageEra } : {}),
1043
+ globalMsgNum: identity.globalMsgNum,
1044
+ })
1045
+ }
1046
+ return out
1047
+ }
1048
+
1021
1049
  async findByClientGeneration(clientJobId: string, generation: number): Promise<QueryJobSnapshot | undefined> {
1022
1050
  await this.ensureInitialized()
1023
1051
  const identity = this.identitiesByKey.get(identityKey(clientJobId.toLowerCase(), generation))
@@ -51,6 +51,7 @@ import { G2_LENS_VARIANT_CAPABILITY } from '../lib/media-store.js'
51
51
  import { MEDIA_CHUNKED_UPLOAD_ENABLED } from './media.js'
52
52
  import { durableQueryJobsCapability } from '../lib/query-job-feature.js'
53
53
  import { getQueryJobRuntimeHealth } from '../lib/query-job-runtime.js'
54
+ import { getMorningBriefScheduler } from '../lib/morning-brief-runtime.js'
54
55
  import { getTranscriptionPolicySnapshot } from '../lib/transcription-policy.js'
55
56
  import { CLI_DEBUG_CAPABILITY } from '../lib/cli-debug-view.js'
56
57
  import { managedRuntimeCapability, managedServerVersion } from '../lib/managed-runtime.js'
@@ -110,6 +111,14 @@ function durableQueryJobStatus() {
110
111
  }
111
112
  }
112
113
 
114
+ async function morningBriefCapabilityOrNull() {
115
+ try {
116
+ return await getMorningBriefScheduler().capability()
117
+ } catch {
118
+ return null
119
+ }
120
+ }
121
+
113
122
  healthRouter.get('/health', async (_req, res) => {
114
123
  const [, staticProbes] = await Promise.all([
115
124
  refreshLocalTtsHealth(),
@@ -199,6 +208,13 @@ healthRouter.get('/health', async (_req, res) => {
199
208
  // diverged, so a constant in both is guaranteed to drift. Absent means an
200
209
  // older server, and the client falls back to single-shot.
201
210
  const mediaLimits = await getMediaLimits({ chunkedUploadEnabled: MEDIA_CHUNKED_UPLOAD_ENABLED })
211
+ // Public on purpose: a settings screen decides whether to show the Morning
212
+ // brief card from health, before it holds a token. Times and gate only —
213
+ // no prompt, no session or job ids. Health must never 500 because of the
214
+ // brief, so BOTH a synchronous construction failure and a rejected status
215
+ // read collapse to "capability absent" (health-query-jobs.test mocks the
216
+ // conversation store to one export, which is exactly such a failure).
217
+ const morningBrief = await morningBriefCapabilityOrNull()
202
218
  const features = {
203
219
  claude: claudeAvailable,
204
220
  codex: codexAvailable,
@@ -216,6 +232,9 @@ healthRouter.get('/health', async (_req, res) => {
216
232
  g2LensVariant: G2_LENS_VARIANT_CAPABILITY,
217
233
  durableQueryJobs: durableJobs.enabled,
218
234
  durableQueryJobsProtocol: durableJobs.protocolVersion,
235
+ // Static: this build carries the scheduler. The live schedule is under
236
+ // capabilities.morningBrief and may be absent while the runtime boots.
237
+ morningBrief: true,
219
238
  localFirstMeetings: localFirstMeetings !== null,
220
239
  transcriptionPolicy: transcription.mode,
221
240
  liveCues: liveCues.available,
@@ -400,6 +419,7 @@ healthRouter.get('/health', async (_req, res) => {
400
419
  finalization: getMeetingFinalizationSnapshot(),
401
420
  },
402
421
  ...(localFirstMeetings ? { localFirstMeetings } : {}),
422
+ ...(morningBrief ? { morningBrief } : {}),
403
423
  },
404
424
  // /api/health is intentionally unauthenticated for setup diagnostics.
405
425
  // Publish capability only; job counts, retention identities, subscriber
@@ -26,6 +26,7 @@ import {
26
26
  exchangeBelongsToEra,
27
27
  } from '../lib/message-era.js'
28
28
  import { MessageEraResetError, resetLiveMessageEra } from '../lib/message-era-reset.js'
29
+ import { maxReservedGlobalMsgNum } from '../lib/message-reservations.js'
29
30
 
30
31
  // v6.3.0 — read archives from the SAME persistent location the archive-mirror
31
32
  // writes to (~/.cos-glasses/data/archive via dataPath), not a package-relative
@@ -248,5 +249,9 @@ messageRefRouter.get('/message-counter', (_req, res) => {
248
249
  if (typeof ex?.globalMsgNum === 'number' && ex.globalMsgNum > liveMax) liveMax = ex.globalMsgNum
249
250
  }
250
251
  }
251
- res.json({ max: Math.max(liveMax, maxGlobalMsgNumInDir(ARCHIVE_DIR, era)), era })
252
+ // 6.43.1 numbers minted for jobs that are admitted but not yet projected
253
+ // (a running morning brief, a desk session's durable job) are part of the
254
+ // ceiling. Without them the phone re-minted #74 on 2026-09-01.
255
+ const reservedMax = maxReservedGlobalMsgNum(era)
256
+ res.json({ max: Math.max(liveMax, maxGlobalMsgNumInDir(ARCHIVE_DIR, era), reservedMax), era })
252
257
  })
@@ -0,0 +1,94 @@
1
+ // Morning brief — the settings and status surface.
2
+ //
3
+ // GET /api/morning-brief config + source catalog + status + recent runs
4
+ // PUT /api/morning-brief patch the config (validated; 400 on a bad field)
5
+ // POST /api/morning-brief/run fire a brief now (202; 409 while one runs; 429 past the daily cap)
6
+ // GET /api/morning-brief/runs recent runs with live job status, message numbers, section outcomes
7
+ // GET /api/morning-brief/coverage what each source can reach right now (?refresh=1 re-probes)
8
+ // GET /api/morning-brief/preview the exact prompt today's brief would send
9
+ //
10
+ // Authenticated by the global /api middleware like every other settings route.
11
+ // Nothing here returns a brief's full text: the answer lives in the conversation
12
+ // store and the phone reads it through the same history route as any reply.
13
+
14
+ import { Router } from 'express'
15
+ import { MorningBriefConfigError, describeMorningBriefSources } from '../lib/morning-brief-config.js'
16
+ import { MorningBriefRunError, type MorningBriefScheduler } from '../lib/morning-brief-scheduler.js'
17
+
18
+ export function createMorningBriefRouter(scheduler: () => MorningBriefScheduler): Router {
19
+ const router = Router()
20
+
21
+ const describe = async () => {
22
+ const instance = scheduler()
23
+ return {
24
+ config: instance.getConfig(),
25
+ sources: describeMorningBriefSources(),
26
+ status: await instance.status(),
27
+ runs: await instance.listRuns(7),
28
+ coverage: await instance.coverage(),
29
+ }
30
+ }
31
+
32
+ router.get('/morning-brief', async (_req, res) => {
33
+ try {
34
+ res.json(await describe())
35
+ } catch (error) {
36
+ console.error('[morning-brief] describe failed:', error)
37
+ res.status(500).json({ error: { code: 'morning_brief_unavailable', message: 'Could not read the morning brief settings.' } })
38
+ }
39
+ })
40
+
41
+ router.put('/morning-brief', async (req, res) => {
42
+ try {
43
+ scheduler().updateConfig(req.body)
44
+ res.json(await describe())
45
+ } catch (error) {
46
+ if (error instanceof MorningBriefConfigError) {
47
+ return res.status(400).json({ error: { code: error.code, message: error.message } })
48
+ }
49
+ console.error('[morning-brief] update failed:', error)
50
+ res.status(500).json({ error: { code: 'morning_brief_save_failed', message: 'The settings could not be saved.' } })
51
+ }
52
+ })
53
+
54
+ router.post('/morning-brief/run', async (_req, res) => {
55
+ try {
56
+ const run = await scheduler().runNow()
57
+ res.status(202).json({ run })
58
+ } catch (error) {
59
+ if (error instanceof MorningBriefRunError) {
60
+ return res.status(error.status).json({ error: { code: error.code, message: error.message } })
61
+ }
62
+ console.error('[morning-brief] run now failed:', error)
63
+ res.status(500).json({ error: { code: 'morning_brief_run_failed', message: 'The brief could not be started.' } })
64
+ }
65
+ })
66
+
67
+ router.get('/morning-brief/runs', async (req, res) => {
68
+ const limit = Number.parseInt(String(req.query.limit ?? '14'), 10)
69
+ try {
70
+ res.json({ runs: await scheduler().listRuns(Number.isFinite(limit) ? limit : 14) })
71
+ } catch (error) {
72
+ console.error('[morning-brief] runs failed:', error)
73
+ res.status(500).json({ error: { code: 'morning_brief_unavailable', message: 'Could not read the morning brief runs.' } })
74
+ }
75
+ })
76
+
77
+ router.get('/morning-brief/coverage', async (req, res) => {
78
+ const refresh = req.query.refresh === '1' || req.query.refresh === 'true'
79
+ try {
80
+ const coverage = await scheduler().coverage(refresh)
81
+ if (!coverage) return res.status(503).json({ error: { code: 'coverage_unavailable', message: 'This server has no coverage probes.' } })
82
+ res.json({ coverage })
83
+ } catch (error) {
84
+ console.error('[morning-brief] coverage failed:', error)
85
+ res.status(500).json({ error: { code: 'coverage_failed', message: 'Could not read source coverage.' } })
86
+ }
87
+ })
88
+
89
+ router.get('/morning-brief/preview', (_req, res) => {
90
+ res.type('text/plain').send(scheduler().previewPrompt('scheduled'))
91
+ })
92
+
93
+ return router
94
+ }