@gotcos/glasses-server 6.42.1 → 6.43.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 +69 -0
- package/README.md +23 -0
- package/package.json +1 -1
- package/server/index.ts +10 -0
- package/server/lib/morning-brief-config.ts +544 -0
- package/server/lib/morning-brief-prompt.ts +263 -0
- package/server/lib/morning-brief-runtime.ts +58 -0
- package/server/lib/morning-brief-schedule.ts +158 -0
- package/server/lib/morning-brief-scheduler.ts +361 -0
- package/server/routes/health.ts +20 -0
- package/server/routes/morning-brief.ts +80 -0
|
@@ -0,0 +1,361 @@
|
|
|
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 { decideScheduledFire, localClock, nextScheduledFire } from './morning-brief-schedule.js'
|
|
40
|
+
import type { QueryJobSnapshot } from './query-job-types.js'
|
|
41
|
+
import { isTerminalQueryJobStatus } from './query-job-types.js'
|
|
42
|
+
|
|
43
|
+
export interface MorningBriefSubmission {
|
|
44
|
+
job: Pick<QueryJobSnapshot, 'jobId' | 'clientJobId' | 'generation' | 'status' | 'sessionId'>
|
|
45
|
+
created: boolean
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export interface MorningBriefSchedulerDeps {
|
|
49
|
+
paths: MorningBriefStorePaths
|
|
50
|
+
submit: (request: Record<string, unknown>) => Promise<MorningBriefSubmission>
|
|
51
|
+
findByClientGeneration: (clientJobId: string, generation: number) => Promise<QueryJobSnapshot | undefined>
|
|
52
|
+
getSnapshot: (jobId: string) => Promise<QueryJobSnapshot>
|
|
53
|
+
createSession: () => string
|
|
54
|
+
currentMessageEra: () => string
|
|
55
|
+
/** Highest stamped number in the active era across live sessions and archives. */
|
|
56
|
+
currentMessageMax: () => number
|
|
57
|
+
ownerName: () => string
|
|
58
|
+
durableJobsEnabled: () => boolean
|
|
59
|
+
admissionsOpen: () => boolean
|
|
60
|
+
now?: () => number
|
|
61
|
+
tickMs?: number
|
|
62
|
+
log?: (line: string) => void
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export class MorningBriefRunError extends Error {
|
|
66
|
+
constructor(readonly status: number, readonly code: string, message: string) {
|
|
67
|
+
super(message)
|
|
68
|
+
this.name = 'MorningBriefRunError'
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export interface MorningBriefRunView extends MorningBriefRun {
|
|
73
|
+
status: string
|
|
74
|
+
completedAt?: string
|
|
75
|
+
error?: { code: string; message: string }
|
|
76
|
+
/** First ~120 chars of the answer, for a list row. Never the whole brief. */
|
|
77
|
+
preview?: string
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export interface MorningBriefStatus {
|
|
81
|
+
protocolVersion: 1
|
|
82
|
+
enabled: boolean
|
|
83
|
+
time: string
|
|
84
|
+
timezone: string
|
|
85
|
+
serverTimezone: string
|
|
86
|
+
days: number[]
|
|
87
|
+
nextRunAt: string | null
|
|
88
|
+
lastRun: MorningBriefRunView | null
|
|
89
|
+
/** Why the scheduler would not fire right now, for a status line. */
|
|
90
|
+
gate: 'ready' | 'durable_jobs_off' | 'admissions_closed' | 'disabled'
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export type TickResult =
|
|
94
|
+
| { fired: true; run: MorningBriefRun }
|
|
95
|
+
| { fired: false; reason: string }
|
|
96
|
+
|
|
97
|
+
/** Deterministic v4-shaped client id for one local day, so a retry after a
|
|
98
|
+
* crashed submission admits as the SAME job. The store dedupes on it. */
|
|
99
|
+
export function scheduledClientJobId(day: string, timezone: string): string {
|
|
100
|
+
const digest = createHash('sha256').update(`morning-brief|${timezone}|${day}`).digest()
|
|
101
|
+
const bytes = Buffer.from(digest.subarray(0, 16))
|
|
102
|
+
bytes[6] = (bytes[6] & 0x0f) | 0x40
|
|
103
|
+
bytes[8] = (bytes[8] & 0x3f) | 0x80
|
|
104
|
+
const hex = bytes.toString('hex')
|
|
105
|
+
return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function errorParts(error: unknown): { code: string; message: string } {
|
|
109
|
+
const code = typeof (error as { code?: unknown })?.code === 'string' ? (error as { code: string }).code : 'submit_failed'
|
|
110
|
+
const message = error instanceof Error ? error.message : String(error)
|
|
111
|
+
return { code: code.slice(0, 64), message: message.slice(0, 300) }
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export class MorningBriefScheduler {
|
|
115
|
+
private config: MorningBriefConfig
|
|
116
|
+
private ledger: MorningBriefLedger
|
|
117
|
+
private timer: ReturnType<typeof setInterval> | null = null
|
|
118
|
+
private tickInFlight: Promise<TickResult> | null = null
|
|
119
|
+
private readonly now: () => number
|
|
120
|
+
private readonly log: (line: string) => void
|
|
121
|
+
readonly quarantinedConfig?: string
|
|
122
|
+
|
|
123
|
+
constructor(private readonly deps: MorningBriefSchedulerDeps) {
|
|
124
|
+
this.now = deps.now ?? Date.now
|
|
125
|
+
this.log = deps.log ?? ((line) => console.log(`[morning-brief] ${line}`))
|
|
126
|
+
const loaded = loadMorningBriefConfig(deps.paths, new Date(this.now()))
|
|
127
|
+
this.config = loaded.config
|
|
128
|
+
this.quarantinedConfig = loaded.quarantinedAs
|
|
129
|
+
if (loaded.fresh) {
|
|
130
|
+
// Persist the defaults so every surface reads one file from the start
|
|
131
|
+
// and so `updatedAt` is a real first-seen time.
|
|
132
|
+
try { saveMorningBriefConfig(deps.paths, this.config) } catch (error) {
|
|
133
|
+
this.log(`could not persist default config: ${(error as Error).message}`)
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
this.ledger = loadMorningBriefLedger(deps.paths)
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
start(): void {
|
|
140
|
+
if (this.timer) return
|
|
141
|
+
const tickMs = Math.max(1_000, this.deps.tickMs ?? 30_000)
|
|
142
|
+
this.timer = setInterval(() => { void this.tick() }, tickMs)
|
|
143
|
+
this.timer.unref?.()
|
|
144
|
+
this.log(`scheduled ${this.config.enabled ? `daily at ${this.config.time} ${this.config.timezone}` : 'off'} · next ${nextScheduledFire(this.config, this.now()) ?? 'none'}`)
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
stop(): void {
|
|
148
|
+
if (this.timer) clearInterval(this.timer)
|
|
149
|
+
this.timer = null
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
getConfig(): MorningBriefConfig {
|
|
153
|
+
return structuredClone(this.config)
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
updateConfig(patch: unknown): MorningBriefConfig {
|
|
157
|
+
const next = applyMorningBriefPatch(this.config, patch, new Date(this.now()))
|
|
158
|
+
saveMorningBriefConfig(this.deps.paths, next)
|
|
159
|
+
this.config = next
|
|
160
|
+
this.log(`config updated · ${next.enabled ? `daily at ${next.time} ${next.timezone}` : 'off'} · next ${nextScheduledFire(next, this.now()) ?? 'none'}`)
|
|
161
|
+
return structuredClone(next)
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
previewPrompt(trigger: MorningBriefTrigger = 'scheduled'): string {
|
|
165
|
+
const clock = localClock(this.now(), this.config.timezone)
|
|
166
|
+
return composeMorningBriefPrompt({ config: this.config, day: clock.day, ownerName: this.deps.ownerName(), trigger })
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/** One scheduler pass. Serialised: a slow submission never overlaps the next tick. */
|
|
170
|
+
tick(): Promise<TickResult> {
|
|
171
|
+
if (this.tickInFlight) return this.tickInFlight
|
|
172
|
+
this.tickInFlight = this.runTick().finally(() => { this.tickInFlight = null })
|
|
173
|
+
return this.tickInFlight
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
private async runTick(): Promise<TickResult> {
|
|
177
|
+
if (!this.deps.durableJobsEnabled()) return { fired: false, reason: 'durable_jobs_off' }
|
|
178
|
+
if (!this.deps.admissionsOpen()) return { fired: false, reason: 'admissions_closed' }
|
|
179
|
+
const decision = decideScheduledFire(this.config, this.ledger.runs, this.now())
|
|
180
|
+
if (!decision.fire) return { fired: false, reason: decision.reason }
|
|
181
|
+
const run = await this.fire('scheduled', decision.day, decision.attempt, decision.resume)
|
|
182
|
+
return { fired: true, run }
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/** "Run now" from a settings surface. Bounded per day; refused while a brief is live. */
|
|
186
|
+
async runNow(): Promise<MorningBriefRun> {
|
|
187
|
+
if (!this.deps.durableJobsEnabled()) {
|
|
188
|
+
throw new MorningBriefRunError(409, 'durable_jobs_off', 'Turn on Background jobs in COS Control to run the brief.')
|
|
189
|
+
}
|
|
190
|
+
if (!this.deps.admissionsOpen()) {
|
|
191
|
+
throw new MorningBriefRunError(503, 'admissions_closed', 'The server is in maintenance. Try again in a moment.')
|
|
192
|
+
}
|
|
193
|
+
const clock = localClock(this.now(), this.config.timezone)
|
|
194
|
+
const today = this.ledger.runs.filter(run => run.day === clock.day)
|
|
195
|
+
for (const run of today) {
|
|
196
|
+
if (!run.jobId) continue
|
|
197
|
+
const snapshot = await this.deps.getSnapshot(run.jobId).catch(() => undefined)
|
|
198
|
+
if (snapshot && !isTerminalQueryJobStatus(snapshot.status)) {
|
|
199
|
+
throw new MorningBriefRunError(409, 'brief_in_progress', 'A brief is already running. It will land in the inbox when it finishes.')
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
const manual = today.filter(run => run.trigger === 'manual' && run.jobId).length
|
|
203
|
+
if (manual >= MORNING_BRIEF_LIMITS.manualRunsPerDay) {
|
|
204
|
+
throw new MorningBriefRunError(429, 'manual_runs_exhausted', `Run now is limited to ${MORNING_BRIEF_LIMITS.manualRunsPerDay} briefs a day.`)
|
|
205
|
+
}
|
|
206
|
+
const run = await this.fire('manual', clock.day, manual + 1)
|
|
207
|
+
if (run.submitError) {
|
|
208
|
+
throw new MorningBriefRunError(503, run.submitError.code, run.submitError.message)
|
|
209
|
+
}
|
|
210
|
+
return run
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
private async fire(trigger: MorningBriefTrigger, day: string, attempt: number, resume?: MorningBriefRun): Promise<MorningBriefRun> {
|
|
214
|
+
const clientJobId = trigger === 'scheduled'
|
|
215
|
+
? scheduledClientJobId(day, this.config.timezone)
|
|
216
|
+
: randomUUID()
|
|
217
|
+
|
|
218
|
+
// Resume: a ledger row with no job id. Adopt an admission the coordinator
|
|
219
|
+
// may already hold for this identity before considering a fresh submit.
|
|
220
|
+
if (resume) {
|
|
221
|
+
const existing = await this.deps.findByClientGeneration(clientJobId, 1).catch(() => undefined)
|
|
222
|
+
if (existing) {
|
|
223
|
+
const adopted: MorningBriefRun = { ...resume, jobId: existing.jobId, lastKnownStatus: existing.status }
|
|
224
|
+
this.replaceRun(adopted)
|
|
225
|
+
this.log(`adopted ${trigger} brief for ${day} as job ${existing.jobId}`)
|
|
226
|
+
return adopted
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
const sessionId = resume?.sessionId ?? this.deps.createSession()
|
|
231
|
+
const messageEra = this.deps.currentMessageEra()
|
|
232
|
+
const globalMsgNum = resume?.globalMsgNum ?? this.deps.currentMessageMax() + 1
|
|
233
|
+
const run: MorningBriefRun = {
|
|
234
|
+
id: resume?.id ?? randomUUID(),
|
|
235
|
+
day,
|
|
236
|
+
trigger,
|
|
237
|
+
attempt,
|
|
238
|
+
firedAt: new Date(this.now()).toISOString(),
|
|
239
|
+
clientJobId,
|
|
240
|
+
generation: 1,
|
|
241
|
+
sessionId,
|
|
242
|
+
messageEra,
|
|
243
|
+
globalMsgNum,
|
|
244
|
+
}
|
|
245
|
+
// Ledger first. A crash after this line is a resume, not a second brief.
|
|
246
|
+
this.replaceRun(run)
|
|
247
|
+
|
|
248
|
+
const prompt = composeMorningBriefPrompt({ config: this.config, day, ownerName: this.deps.ownerName(), trigger })
|
|
249
|
+
try {
|
|
250
|
+
const admission = await this.deps.submit({
|
|
251
|
+
clientJobId,
|
|
252
|
+
generation: 1,
|
|
253
|
+
query: prompt,
|
|
254
|
+
sessionId,
|
|
255
|
+
messageEra,
|
|
256
|
+
globalMsgNum,
|
|
257
|
+
...(this.config.model ? { model: this.config.model } : {}),
|
|
258
|
+
...(this.config.effort ? { effort: this.config.effort } : {}),
|
|
259
|
+
activityToolMode: 'status',
|
|
260
|
+
attachmentIds: [],
|
|
261
|
+
attachmentRefs: [],
|
|
262
|
+
})
|
|
263
|
+
const accepted: MorningBriefRun = { ...run, jobId: admission.job.jobId, lastKnownStatus: admission.job.status }
|
|
264
|
+
this.replaceRun(accepted)
|
|
265
|
+
this.log(`${trigger} brief for ${day} admitted as job ${admission.job.jobId} (#${globalMsgNum}${admission.created ? '' : ', already held'})`)
|
|
266
|
+
return accepted
|
|
267
|
+
} catch (error) {
|
|
268
|
+
const failed: MorningBriefRun = { ...run, submitError: errorParts(error) }
|
|
269
|
+
this.replaceRun(failed)
|
|
270
|
+
this.log(`${trigger} brief for ${day} attempt ${attempt} failed to submit: ${failed.submitError!.code}`)
|
|
271
|
+
return failed
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
private replaceRun(run: MorningBriefRun): void {
|
|
276
|
+
const index = this.ledger.runs.findIndex(existing => existing.id === run.id)
|
|
277
|
+
if (index >= 0) this.ledger.runs[index] = run
|
|
278
|
+
else this.ledger.runs.push(run)
|
|
279
|
+
if (this.ledger.runs.length > MORNING_BRIEF_LIMITS.retainedRuns) {
|
|
280
|
+
this.ledger.runs = this.ledger.runs.slice(-MORNING_BRIEF_LIMITS.retainedRuns)
|
|
281
|
+
}
|
|
282
|
+
try {
|
|
283
|
+
saveMorningBriefLedger(this.deps.paths, this.ledger)
|
|
284
|
+
} catch (error) {
|
|
285
|
+
this.log(`ledger write failed: ${(error as Error).message}`)
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
/** Ledger rows newest first, each with the job's live status folded in. */
|
|
290
|
+
async listRuns(limit = 14): Promise<MorningBriefRunView[]> {
|
|
291
|
+
const rows = this.ledger.runs.slice(-Math.max(1, Math.min(limit, MORNING_BRIEF_LIMITS.retainedRuns))).reverse()
|
|
292
|
+
const views: MorningBriefRunView[] = []
|
|
293
|
+
for (const run of rows) {
|
|
294
|
+
if (!run.jobId) {
|
|
295
|
+
views.push({ ...run, status: run.submitError ? 'submit_failed' : 'submitting', ...(run.submitError ? { error: run.submitError } : {}) })
|
|
296
|
+
continue
|
|
297
|
+
}
|
|
298
|
+
const snapshot = await this.deps.getSnapshot(run.jobId).catch(() => undefined)
|
|
299
|
+
if (!snapshot) {
|
|
300
|
+
views.push({ ...run, status: run.lastKnownStatus ?? 'unknown' })
|
|
301
|
+
continue
|
|
302
|
+
}
|
|
303
|
+
if (snapshot.status !== run.lastKnownStatus && isTerminalQueryJobStatus(snapshot.status)) {
|
|
304
|
+
this.replaceRun({ ...run, lastKnownStatus: snapshot.status })
|
|
305
|
+
}
|
|
306
|
+
const answer = snapshot.response ?? snapshot.partialText ?? ''
|
|
307
|
+
views.push({
|
|
308
|
+
...run,
|
|
309
|
+
status: snapshot.status,
|
|
310
|
+
...(snapshot.completedAt ? { completedAt: snapshot.completedAt } : {}),
|
|
311
|
+
...(snapshot.error ? { error: { code: snapshot.error.code, message: snapshot.error.message } } : {}),
|
|
312
|
+
...(answer ? { preview: answer.replace(/\s+/g, ' ').trim().slice(0, 120) } : {}),
|
|
313
|
+
})
|
|
314
|
+
}
|
|
315
|
+
return views
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
async status(): Promise<MorningBriefStatus> {
|
|
319
|
+
const [lastRun] = await this.listRuns(1)
|
|
320
|
+
const gate: MorningBriefStatus['gate'] = !this.deps.durableJobsEnabled()
|
|
321
|
+
? 'durable_jobs_off'
|
|
322
|
+
: !this.deps.admissionsOpen()
|
|
323
|
+
? 'admissions_closed'
|
|
324
|
+
: !this.config.enabled ? 'disabled' : 'ready'
|
|
325
|
+
return {
|
|
326
|
+
protocolVersion: 1,
|
|
327
|
+
enabled: this.config.enabled,
|
|
328
|
+
time: this.config.time,
|
|
329
|
+
timezone: this.config.timezone,
|
|
330
|
+
serverTimezone: serverTimezone(),
|
|
331
|
+
days: [...this.config.days],
|
|
332
|
+
nextRunAt: nextScheduledFire(this.config, this.now()),
|
|
333
|
+
lastRun: lastRun ?? null,
|
|
334
|
+
gate,
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
/** Public-health shape: no prompt, no session ids, no job ids. */
|
|
339
|
+
async capability(): Promise<{
|
|
340
|
+
protocolVersion: 1
|
|
341
|
+
enabled: boolean
|
|
342
|
+
time: string
|
|
343
|
+
timezone: string
|
|
344
|
+
nextRunAt: string | null
|
|
345
|
+
lastRunAt: string | null
|
|
346
|
+
lastRunStatus: string | null
|
|
347
|
+
gate: MorningBriefStatus['gate']
|
|
348
|
+
}> {
|
|
349
|
+
const status = await this.status()
|
|
350
|
+
return {
|
|
351
|
+
protocolVersion: 1,
|
|
352
|
+
enabled: status.enabled,
|
|
353
|
+
time: status.time,
|
|
354
|
+
timezone: status.timezone,
|
|
355
|
+
nextRunAt: status.nextRunAt,
|
|
356
|
+
lastRunAt: status.lastRun?.firedAt ?? null,
|
|
357
|
+
lastRunStatus: status.lastRun?.status ?? null,
|
|
358
|
+
gate: status.gate,
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
}
|
package/server/routes/health.ts
CHANGED
|
@@ -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
|
|
@@ -0,0 +1,80 @@
|
|
|
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 and message numbers
|
|
7
|
+
// GET /api/morning-brief/preview the exact prompt today's brief would send
|
|
8
|
+
//
|
|
9
|
+
// Authenticated by the global /api middleware like every other settings route.
|
|
10
|
+
// Nothing here returns a brief's full text: the answer lives in the conversation
|
|
11
|
+
// store and the phone reads it through the same history route as any reply.
|
|
12
|
+
|
|
13
|
+
import { Router } from 'express'
|
|
14
|
+
import { MorningBriefConfigError, describeMorningBriefSources } from '../lib/morning-brief-config.js'
|
|
15
|
+
import { MorningBriefRunError, type MorningBriefScheduler } from '../lib/morning-brief-scheduler.js'
|
|
16
|
+
|
|
17
|
+
export function createMorningBriefRouter(scheduler: () => MorningBriefScheduler): Router {
|
|
18
|
+
const router = Router()
|
|
19
|
+
|
|
20
|
+
const describe = async () => {
|
|
21
|
+
const instance = scheduler()
|
|
22
|
+
return {
|
|
23
|
+
config: instance.getConfig(),
|
|
24
|
+
sources: describeMorningBriefSources(),
|
|
25
|
+
status: await instance.status(),
|
|
26
|
+
runs: await instance.listRuns(7),
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
router.get('/morning-brief', async (_req, res) => {
|
|
31
|
+
try {
|
|
32
|
+
res.json(await describe())
|
|
33
|
+
} catch (error) {
|
|
34
|
+
console.error('[morning-brief] describe failed:', error)
|
|
35
|
+
res.status(500).json({ error: { code: 'morning_brief_unavailable', message: 'Could not read the morning brief settings.' } })
|
|
36
|
+
}
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
router.put('/morning-brief', async (req, res) => {
|
|
40
|
+
try {
|
|
41
|
+
scheduler().updateConfig(req.body)
|
|
42
|
+
res.json(await describe())
|
|
43
|
+
} catch (error) {
|
|
44
|
+
if (error instanceof MorningBriefConfigError) {
|
|
45
|
+
return res.status(400).json({ error: { code: error.code, message: error.message } })
|
|
46
|
+
}
|
|
47
|
+
console.error('[morning-brief] update failed:', error)
|
|
48
|
+
res.status(500).json({ error: { code: 'morning_brief_save_failed', message: 'The settings could not be saved.' } })
|
|
49
|
+
}
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
router.post('/morning-brief/run', async (_req, res) => {
|
|
53
|
+
try {
|
|
54
|
+
const run = await scheduler().runNow()
|
|
55
|
+
res.status(202).json({ run })
|
|
56
|
+
} catch (error) {
|
|
57
|
+
if (error instanceof MorningBriefRunError) {
|
|
58
|
+
return res.status(error.status).json({ error: { code: error.code, message: error.message } })
|
|
59
|
+
}
|
|
60
|
+
console.error('[morning-brief] run now failed:', error)
|
|
61
|
+
res.status(500).json({ error: { code: 'morning_brief_run_failed', message: 'The brief could not be started.' } })
|
|
62
|
+
}
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
router.get('/morning-brief/runs', async (req, res) => {
|
|
66
|
+
const limit = Number.parseInt(String(req.query.limit ?? '14'), 10)
|
|
67
|
+
try {
|
|
68
|
+
res.json({ runs: await scheduler().listRuns(Number.isFinite(limit) ? limit : 14) })
|
|
69
|
+
} catch (error) {
|
|
70
|
+
console.error('[morning-brief] runs failed:', error)
|
|
71
|
+
res.status(500).json({ error: { code: 'morning_brief_unavailable', message: 'Could not read the morning brief runs.' } })
|
|
72
|
+
}
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
router.get('/morning-brief/preview', (_req, res) => {
|
|
76
|
+
res.type('text/plain').send(scheduler().previewPrompt('scheduled'))
|
|
77
|
+
})
|
|
78
|
+
|
|
79
|
+
return router
|
|
80
|
+
}
|