@gotcos/glasses-server 6.42.0 → 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 +117 -1
- package/README.md +23 -0
- package/SECURITY.md +32 -0
- package/package.json +4 -2
- package/server/index.ts +14 -3
- package/server/lib/api-auth.ts +5 -2
- package/server/lib/display-bus.ts +7 -2
- package/server/lib/display-ticket.ts +28 -8
- 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/display.ts +43 -11
- package/server/routes/health.ts +32 -3
- 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/display.ts
CHANGED
|
@@ -11,7 +11,7 @@ import {
|
|
|
11
11
|
type DisplayEvent,
|
|
12
12
|
type PublishedDisplayEvent,
|
|
13
13
|
} from '../lib/display-bus.js'
|
|
14
|
-
import {
|
|
14
|
+
import { type DisplayTicketVerdict, explainDisplayTicket } from '../lib/display-ticket.js'
|
|
15
15
|
import { timingSafeTokenEqual } from '../lib/token-auth.js'
|
|
16
16
|
|
|
17
17
|
export const displayRouter = Router()
|
|
@@ -93,7 +93,13 @@ const TICKETLESS_PROJECTIONS: {
|
|
|
93
93
|
* Never returns the input event unchanged — the projection is always applied.
|
|
94
94
|
*/
|
|
95
95
|
function projectForTicketless(event: PublishedDisplayEvent): PublishedDisplayEvent | null {
|
|
96
|
-
|
|
96
|
+
// hasOwn, not a bare index: the map is an object literal and inherits
|
|
97
|
+
// Object.prototype, so a type of "constructor" would resolve to `Object` — a
|
|
98
|
+
// truthy identity function — and pass the event through whole. Unreachable via
|
|
99
|
+
// the typed union today; the allowlist must not depend on that staying true.
|
|
100
|
+
const project = Object.hasOwn(TICKETLESS_PROJECTIONS, event.type)
|
|
101
|
+
? TICKETLESS_PROJECTIONS[event.type]
|
|
102
|
+
: undefined
|
|
97
103
|
if (!project) return null
|
|
98
104
|
return { ...event, data: project(event.data) }
|
|
99
105
|
}
|
|
@@ -134,25 +140,37 @@ const TICKETLESS_LOG_INTERVAL_MS = 60_000
|
|
|
134
140
|
let ticketlessConnects = 0
|
|
135
141
|
let ticketlessRejectedTickets = 0
|
|
136
142
|
let ticketlessLoggedAt = 0
|
|
143
|
+
// Per-reason so the log can tell "a client needs to re-mint" (expired — expected
|
|
144
|
+
// after every native EventSource retry on a stale URL) from "someone is holding
|
|
145
|
+
// a ticket this token never signed" (bad-signature — a rotation, or a probe).
|
|
146
|
+
const ticketlessByReason: Record<Exclude<DisplayTicketVerdict, 'ok'> | 'none', number> = {
|
|
147
|
+
none: 0, malformed: 0, expired: 0, 'bad-signature': 0,
|
|
148
|
+
}
|
|
137
149
|
|
|
138
|
-
function noteTicketlessConnect(
|
|
150
|
+
function noteTicketlessConnect(reason: Exclude<DisplayTicketVerdict, 'ok'> | 'none'): void {
|
|
139
151
|
ticketlessConnects++
|
|
140
|
-
if (
|
|
152
|
+
if (reason !== 'none') ticketlessRejectedTickets++
|
|
153
|
+
ticketlessByReason[reason]++
|
|
141
154
|
const now = Date.now()
|
|
142
155
|
if (ticketlessLoggedAt !== 0 && now - ticketlessLoggedAt < TICKETLESS_LOG_INTERVAL_MS) return
|
|
143
156
|
ticketlessLoggedAt = now
|
|
157
|
+
const { expired, 'bad-signature': bad, malformed, none } = ticketlessByReason
|
|
144
158
|
console.warn(
|
|
145
159
|
`[display-bus] ${ticketlessConnects} ticketless subscriber(s)`
|
|
146
|
-
+ ` (${ticketlessRejectedTickets} with a rejected ticket
|
|
160
|
+
+ ` (${ticketlessRejectedTickets} with a rejected ticket:`
|
|
161
|
+
+ ` ${expired} expired, ${bad} bad-signature, ${malformed} malformed; ${none} bare)`
|
|
162
|
+
+ ` — content withheld, lifecycle only`,
|
|
147
163
|
)
|
|
148
164
|
ticketlessConnects = 0
|
|
149
165
|
ticketlessRejectedTickets = 0
|
|
166
|
+
for (const k of Object.keys(ticketlessByReason) as Array<keyof typeof ticketlessByReason>) ticketlessByReason[k] = 0
|
|
150
167
|
}
|
|
151
168
|
|
|
152
169
|
export function __resetDisplayStreamLogForTests(): void {
|
|
153
170
|
ticketlessConnects = 0
|
|
154
171
|
ticketlessRejectedTickets = 0
|
|
155
172
|
ticketlessLoggedAt = 0
|
|
173
|
+
for (const k of Object.keys(ticketlessByReason) as Array<keyof typeof ticketlessByReason>) ticketlessByReason[k] = 0
|
|
156
174
|
}
|
|
157
175
|
|
|
158
176
|
/**
|
|
@@ -213,7 +231,15 @@ function serveDisplayStream(req: Request, res: Response, authorized: boolean): v
|
|
|
213
231
|
const watermark = getDisplayWatermark()
|
|
214
232
|
res.write(`event: ready\ndata: ${JSON.stringify({ ...watermark, contentAuthorized: authorized })}\n\n`)
|
|
215
233
|
|
|
216
|
-
|
|
234
|
+
// Gap detection is needed for every subscriber; MATERIALISING the up-to-200
|
|
235
|
+
// event buffer is only needed for one we will actually write it to. A stale
|
|
236
|
+
// ticketless install retrying every 3s was filtering the whole buffer each
|
|
237
|
+
// time and discarding it — and so was every authorized `probe=1` connect,
|
|
238
|
+
// whose write is skipped below. The term here must match that `else if`.
|
|
239
|
+
const materialize = authorized && req.query.probe !== '1'
|
|
240
|
+
const replay = replayDisplayEvents(
|
|
241
|
+
cursorBootId, Number.isFinite(cursorEventId) ? cursorEventId : 0, { materialize },
|
|
242
|
+
)
|
|
217
243
|
if (replay.gap) {
|
|
218
244
|
// Ticketless-VISIBLE on purpose. The payload is transport metadata only —
|
|
219
245
|
// reason, the cursor the client itself sent, the watermark already in `ready`,
|
|
@@ -226,11 +252,17 @@ function serveDisplayStream(req: Request, res: Response, authorized: boolean): v
|
|
|
226
252
|
watermark,
|
|
227
253
|
oldestEventId: replay.oldestEventId,
|
|
228
254
|
})}\n\n`)
|
|
229
|
-
} else if (authorized) {
|
|
255
|
+
} else if (authorized && req.query.probe !== '1') {
|
|
230
256
|
// The replay buffer holds up to REPLAY_BUFFER_SIZE past events, so serving it
|
|
231
257
|
// to a ticketless subscriber would be a retroactive transcript dump — a larger
|
|
232
258
|
// disclosure than the live subscription. Skipped entirely rather than filtered,
|
|
233
259
|
// so no future event type can leak through a per-item test here.
|
|
260
|
+
//
|
|
261
|
+
// `probe=1` is the client's connection probe: it opens this stream ONLY to read
|
|
262
|
+
// the `ready` watermark and then aborts. It sends the token, so it is
|
|
263
|
+
// authorized — and was being handed the full buffer on every reconnect and
|
|
264
|
+
// throwing it away (1,164 "Replayed 200" lines in one day's log). A probe is
|
|
265
|
+
// never a consumer; it gets the handshake and nothing else.
|
|
234
266
|
for (const event of replay.events) writeEvent(res, event)
|
|
235
267
|
if (replay.events.length > 0) {
|
|
236
268
|
console.log(`[display-bus] Replayed ${replay.events.length} publish-owned events after ${cursorEventId}`)
|
|
@@ -265,7 +297,7 @@ function serveDisplayStream(req: Request, res: Response, authorized: boolean): v
|
|
|
265
297
|
// is withheld above unless the caller sent a valid token header.
|
|
266
298
|
displayRouter.get('/display-stream', (req, res) => {
|
|
267
299
|
const authorized = headerAuthorized(req)
|
|
268
|
-
if (!authorized) noteTicketlessConnect(
|
|
300
|
+
if (!authorized) noteTicketlessConnect('none')
|
|
269
301
|
serveDisplayStream(req, res, authorized)
|
|
270
302
|
})
|
|
271
303
|
|
|
@@ -276,9 +308,9 @@ displayRouter.get('/display-stream', (req, res) => {
|
|
|
276
308
|
// is degraded from `contentAuthorized:false` in the `ready` frame, and re-mints.
|
|
277
309
|
displayRouter.get('/display-stream/:ticket', (req, res) => {
|
|
278
310
|
const apiToken = process.env.COS_API_TOKEN ?? ''
|
|
279
|
-
const
|
|
280
|
-
const authorized =
|
|
281
|
-
if (!authorized) noteTicketlessConnect(
|
|
311
|
+
const verdict = explainDisplayTicket(apiToken, req.params.ticket)
|
|
312
|
+
const authorized = verdict === 'ok' || headerAuthorized(req)
|
|
313
|
+
if (!authorized) noteTicketlessConnect(verdict)
|
|
282
314
|
serveDisplayStream(req, res, authorized)
|
|
283
315
|
})
|
|
284
316
|
|
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
|
|
@@ -482,9 +502,18 @@ healthRouter.get('/models', async (req, res) => {
|
|
|
482
502
|
// outlive one.)
|
|
483
503
|
//
|
|
484
504
|
// So the CLIENT carries the re-mint obligation: fetch /api/models and reconnect
|
|
485
|
-
// when a display-stream `ready` frame reports `contentAuthorized: false`,
|
|
486
|
-
//
|
|
487
|
-
//
|
|
505
|
+
// when a display-stream `ready` frame reports `contentAuthorized: false`,
|
|
506
|
+
// retrying if the fetch 503s. Nothing on the server can re-mint on the
|
|
507
|
+
// client's behalf.
|
|
508
|
+
//
|
|
509
|
+
// AUTHORIZATION IS DECIDED ONCE PER SOCKET, at connect. A ticket that was
|
|
510
|
+
// valid when the EventSource opened keeps that socket authorized for its whole
|
|
511
|
+
// life — QA on 2026-09-01 held an 8-second ticket open for 32s and content
|
|
512
|
+
// kept flowing. That is deliberate: the shipped client re-mints only on
|
|
513
|
+
// RECONNECT, never on a timer, so a per-event re-check would blank every
|
|
514
|
+
// 6.8.441 lens fifteen minutes into a meeting. The TTL therefore bounds how
|
|
515
|
+
// long a LEAKED URL can open a new socket, not how long an open socket lives.
|
|
516
|
+
// Changing that is a breaking change that needs a client version gate first.
|
|
488
517
|
...(apiToken ? { displayStreamTicket: mintDisplayTicket(apiToken) } : {}),
|
|
489
518
|
capabilities: {
|
|
490
519
|
durableQueryJobs: {
|
|
@@ -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
|
+
}
|