@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.
- package/CHANGELOG.md +103 -0
- package/README.md +30 -0
- package/package.json +1 -1
- package/server/index.ts +10 -0
- package/server/lib/message-reservations.ts +59 -0
- package/server/lib/morning-brief-config.ts +546 -0
- package/server/lib/morning-brief-coverage.ts +376 -0
- package/server/lib/morning-brief-prompt.ts +263 -0
- package/server/lib/morning-brief-runtime.ts +203 -0
- package/server/lib/morning-brief-schedule.ts +158 -0
- package/server/lib/morning-brief-scheduler.ts +412 -0
- package/server/lib/query-job-coordinator.ts +6 -0
- package/server/lib/query-job-runtime.ts +9 -0
- package/server/lib/query-job-store.ts +28 -0
- package/server/routes/health.ts +20 -0
- package/server/routes/message-ref.ts +6 -1
- package/server/routes/morning-brief.ts +94 -0
|
@@ -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))
|
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
|
|
@@ -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
|
-
|
|
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
|
+
}
|