@gotcos/glasses-server 6.81.0 → 6.83.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.
Files changed (34) hide show
  1. package/CHANGELOG.md +93 -0
  2. package/docs/work-orchestrator.md +184 -0
  3. package/package.json +6 -1
  4. package/server/index.ts +19 -0
  5. package/server/lib/claude-bridge.ts +10 -1
  6. package/server/lib/codex-bridge.ts +1 -1
  7. package/server/lib/cursor-bridge.ts +1 -1
  8. package/server/lib/direct-bridge.ts +1 -1
  9. package/server/lib/morning-brief-schedule.ts +13 -7
  10. package/server/lib/query-job-coordinator.ts +5 -1
  11. package/server/lib/query-job-runtime.ts +3 -0
  12. package/server/lib/routines-config.ts +421 -0
  13. package/server/lib/routines-registry.ts +151 -0
  14. package/server/lib/routines-runtime.ts +64 -0
  15. package/server/lib/routines-scheduler.ts +509 -0
  16. package/server/lib/work-chat.ts +76 -9
  17. package/server/lib/work-lens-rounds.ts +445 -0
  18. package/server/lib/work-orchestrator/discovery.ts +99 -0
  19. package/server/lib/work-orchestrator/input.ts +109 -0
  20. package/server/lib/work-orchestrator/pickup.ts +85 -0
  21. package/server/lib/work-orchestrator/plan-log.ts +38 -0
  22. package/server/lib/work-orchestrator/redact.ts +41 -0
  23. package/server/lib/work-orchestrator/runtime.ts +74 -0
  24. package/server/lib/work-orchestrator/session-log.ts +114 -0
  25. package/server/lib/work-orchestrator/snapshot.ts +87 -0
  26. package/server/lib/work-orchestrator/tag-index.ts +272 -0
  27. package/server/lib/work-orchestrator/tags.ts +52 -0
  28. package/server/lib/work-orchestrator/usage-events.ts +30 -0
  29. package/server/lib/work-orchestrator/usage.ts +341 -0
  30. package/server/routes/routines.ts +101 -0
  31. package/server/routes/work-chat.ts +54 -11
  32. package/server/routes/work-lens-rounds.ts +179 -0
  33. package/server/routes/work-orchestrator.ts +71 -0
  34. package/server/scripts/work-lens-rounds-mutation-gate.mjs +151 -0
@@ -0,0 +1,421 @@
1
+ // 6.82.0 Routines: any COS skill on a schedule (Miles, 2026-10-20, Q4: "Pick a skill, a time
2
+ // and days; each routine has its own cap and off switch").
3
+ //
4
+ // The morning brief stays exactly what it was (its own config file, scheduler and golden) and is
5
+ // listed as routine #1 by the registry. Everything here is the SECOND kind: a skill routine.
6
+ //
7
+ // HOW A SKILL ROUTINE STOPS (every new provider caller answers this):
8
+ // - its own off switch (`enabled`), and the master switch COS_ROUTINES=0 for all of them;
9
+ // - its own daily cap: admitted runs per local day, scheduled and Run now together;
10
+ // - a server-wide ceiling across every skill routine per day (ROUTINE_LIMITS.globalRunsPerDay);
11
+ // - a breaker: N consecutive failed runs open it for a cooldown, and nothing fires while it is open;
12
+ // - one scheduled slot per selected day, at most `scheduledAttemptsPerDay` submit attempts;
13
+ // - inert while durable jobs are off or maintenance admissions are closed (as the brief).
14
+ // It runs on a cheap model (sonnet) unless the routine names another, and never on a key lane.
15
+
16
+ import { createHash, randomBytes } from 'node:crypto'
17
+ import { durableAtomicWriteFileSync, loadJsonOrQuarantine } from './atomic-fs.js'
18
+ import { dataPath } from './data-dir.js'
19
+ import { ensurePrivateDir, isValidTimezone, serverTimezone } from './morning-brief-config.js'
20
+ import { KEY_PROFILE_REJECT_CODE, KEY_PROFILE_SCHEDULED_REJECT, keyProfileRejectApplies } from './key-profile-rejects.js'
21
+ import {
22
+ normalizeEffortPreference,
23
+ normalizeModelPreference,
24
+ type EffortPreference,
25
+ type ModelPreference,
26
+ } from '../../shared/model-preference.js'
27
+
28
+ export const ROUTINES_PROTOCOL_VERSION = 1 as const
29
+
30
+ export const ROUTINE_LIMITS = Object.freeze({
31
+ /** Skill routines one server holds (the morning brief is not counted). */
32
+ maxRoutines: 12,
33
+ nameChars: 60,
34
+ instructionChars: 1_000,
35
+ skillNameChars: 64,
36
+ dailyCapDefault: 2,
37
+ dailyCapMin: 1,
38
+ dailyCapMax: 10,
39
+ /** Every skill routine together, per local day of the server zone. */
40
+ globalRunsPerDay: 40,
41
+ breakerFailuresDefault: 3,
42
+ breakerFailuresMin: 1,
43
+ breakerFailuresMax: 10,
44
+ cooldownMinutesDefault: 6 * 60,
45
+ cooldownMinutesMin: 15,
46
+ cooldownMinutesMax: 7 * 24 * 60,
47
+ catchUpMinutesDefault: 3 * 60,
48
+ catchUpMinutesMax: 12 * 60,
49
+ /** Submit attempts for one scheduled slot before the day is given up (as the brief). */
50
+ scheduledAttemptsPerDay: 3,
51
+ attemptSpacingMs: 2 * 60_000,
52
+ retainedRuns: 240,
53
+ })
54
+
55
+ /** Unless a routine names another model, it runs here: the server's fast default, never Opus. */
56
+ export const ROUTINE_DEFAULT_MODEL: ModelPreference = 'sonnet'
57
+
58
+ const TIME_RE = /^([01]\d|2[0-3]):([0-5]\d)$/
59
+ const CONTROL_RE = /[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f]/g
60
+ /** A skill's loader identity (`good-morning`, `customer-marketing:intel`). No slash, no path. */
61
+ export const ROUTINE_SKILL_RE = /^[A-Za-z0-9][A-Za-z0-9_.:-]{0,63}$/
62
+ /** Routine ids double as the job origin id (QUERY_JOB_ORIGIN_ID_RE) and never equal `morning-brief`. */
63
+ export const ROUTINE_ID_RE = /^routine-[0-9a-f]{10}$/
64
+
65
+ export class RoutineConfigError extends Error {
66
+ constructor(readonly code: string, message = code, readonly status = 400) {
67
+ super(message)
68
+ this.name = 'RoutineConfigError'
69
+ }
70
+ }
71
+
72
+ export interface RoutineBreakerSettings {
73
+ /** Consecutive failed runs that open the breaker. */
74
+ failures: number
75
+ /** How long it stays open. */
76
+ cooldownMinutes: number
77
+ }
78
+
79
+ export interface SkillRoutine {
80
+ id: string
81
+ kind: 'skill'
82
+ name: string
83
+ /** Skill loader identity, without the slash. */
84
+ skill: string
85
+ /** Optional words added after the skill call. */
86
+ instruction: string
87
+ enabled: boolean
88
+ time: string
89
+ timezone: string
90
+ days: number[]
91
+ catchUpMinutes: number
92
+ /** Admitted runs per local day, scheduled and Run now together. */
93
+ dailyCap: number
94
+ model: ModelPreference
95
+ effort?: EffortPreference
96
+ breaker: RoutineBreakerSettings
97
+ createdAt: string
98
+ updatedAt: string
99
+ }
100
+
101
+ export interface RoutineBreakerState {
102
+ consecutiveFailures: number
103
+ /** Set while the breaker is open. Nothing fires before this instant. */
104
+ openUntil?: string
105
+ openedAt?: string
106
+ lastFailure?: { code: string; message: string; at: string }
107
+ }
108
+
109
+ export type RoutineTrigger = 'scheduled' | 'manual'
110
+
111
+ export interface RoutineRun {
112
+ id: string
113
+ routineId: string
114
+ day: string
115
+ trigger: RoutineTrigger
116
+ attempt: number
117
+ firedAt: string
118
+ clientJobId: string
119
+ generation: 1
120
+ sessionId: string
121
+ messageEra?: string
122
+ globalMsgNum?: number
123
+ jobId?: string
124
+ submitError?: { code: string; message: string }
125
+ lastKnownStatus?: string
126
+ /** Set once the run's result has been counted by the breaker (exactly once per run). */
127
+ outcome?: 'completed' | 'failed' | 'canceled'
128
+ }
129
+
130
+ export interface RoutinesFile {
131
+ v: 1
132
+ routines: SkillRoutine[]
133
+ breakers: Record<string, RoutineBreakerState>
134
+ }
135
+
136
+ export interface RoutinesLedger {
137
+ v: 1
138
+ runs: RoutineRun[]
139
+ }
140
+
141
+ export interface RoutineStorePaths {
142
+ routines: string
143
+ runs: string
144
+ }
145
+
146
+ export function routinePaths(root?: string): RoutineStorePaths {
147
+ const configured = root ?? process.env.COS_ROUTINES_DIR?.trim()
148
+ const base = configured ? configured : dataPath('routines')
149
+ return { routines: `${base}/routines.json`, runs: `${base}/runs.json` }
150
+ }
151
+
152
+ /** Master switch. Only an explicit `0`, `false`, `off` or `no` turns every skill routine off. */
153
+ export function routinesEnabledByEnv(env: NodeJS.ProcessEnv = process.env): boolean {
154
+ const raw = env.COS_ROUTINES?.trim().toLowerCase()
155
+ return !(raw === '0' || raw === 'false' || raw === 'off' || raw === 'no')
156
+ }
157
+
158
+ export function newRoutineId(): string {
159
+ return `routine-${randomBytes(5).toString('hex')}`
160
+ }
161
+
162
+ /** Deterministic client id for one routine's scheduled slot, so a retry after a crashed submit
163
+ * admits as the SAME job (the brief's rule, keyed by routine as well). */
164
+ export function routineScheduledClientJobId(routineId: string, day: string, timezone: string): string {
165
+ const digest = createHash('sha256').update(`routine|${routineId}|${timezone}|${day}`).digest()
166
+ const bytes = Buffer.from(digest.subarray(0, 16))
167
+ bytes[6] = (bytes[6] & 0x0f) | 0x40
168
+ bytes[8] = (bytes[8] & 0x3f) | 0x80
169
+ const hex = bytes.toString('hex')
170
+ return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`
171
+ }
172
+
173
+ function cleanText(value: unknown, maxChars: number): string {
174
+ if (typeof value !== 'string') return ''
175
+ return value.replace(CONTROL_RE, '').replace(/\r\n?/g, '\n').trim().slice(0, maxChars)
176
+ }
177
+
178
+ function intIn(raw: unknown, min: number, max: number, code: string, label: string): number {
179
+ const n = typeof raw === 'number' ? raw : Number(raw)
180
+ if (!Number.isSafeInteger(n) || n < min || n > max) throw new RoutineConfigError(code, `${label} must be ${min} to ${max}.`)
181
+ return n
182
+ }
183
+
184
+ function normalizeDays(raw: unknown): number[] {
185
+ if (!Array.isArray(raw)) throw new RoutineConfigError('invalid_days', 'days must be a list of weekday numbers, 0 (Sunday) to 6.')
186
+ return [...new Set(raw
187
+ .map(value => (typeof value === 'number' ? value : Number(value)))
188
+ .filter(value => Number.isInteger(value) && value >= 0 && value <= 6))]
189
+ .sort((a, b) => a - b)
190
+ }
191
+
192
+ export function normalizeSkillName(raw: unknown): string {
193
+ const name = typeof raw === 'string' ? raw.trim().replace(/^\//, '') : ''
194
+ if (!ROUTINE_SKILL_RE.test(name)) throw new RoutineConfigError('invalid_skill', 'Pick a skill from this COS workspace.')
195
+ return name
196
+ }
197
+
198
+ export interface RoutinePatchContext {
199
+ now: Date
200
+ /** True when the skill is in this workspace's catalog. A routine can only name one that is. */
201
+ skillExists: (name: string) => boolean
202
+ }
203
+
204
+ /**
205
+ * Create or edit a skill routine from an untrusted body. Every field is validated and an invalid
206
+ * one throws (a settings screen that shows Saved over a rejected time is the failure this guards).
207
+ * `current` absent means create: `skill` is required and everything else takes its default.
208
+ */
209
+ export function applyRoutinePatch(current: SkillRoutine | null, raw: unknown, ctx: RoutinePatchContext): SkillRoutine {
210
+ if (!raw || typeof raw !== 'object' || Array.isArray(raw)) throw new RoutineConfigError('invalid_patch', 'Expected a JSON object.')
211
+ const patch = raw as Record<string, unknown>
212
+ const at = ctx.now.toISOString()
213
+ const base: SkillRoutine = current
214
+ ? { ...current, days: [...current.days], breaker: { ...current.breaker } }
215
+ : {
216
+ id: newRoutineId(),
217
+ kind: 'skill',
218
+ name: '',
219
+ skill: '',
220
+ instruction: '',
221
+ enabled: true,
222
+ time: '08:00',
223
+ timezone: serverTimezone(),
224
+ days: [1, 2, 3, 4, 5],
225
+ catchUpMinutes: ROUTINE_LIMITS.catchUpMinutesDefault,
226
+ dailyCap: ROUTINE_LIMITS.dailyCapDefault,
227
+ model: ROUTINE_DEFAULT_MODEL,
228
+ breaker: { failures: ROUTINE_LIMITS.breakerFailuresDefault, cooldownMinutes: ROUTINE_LIMITS.cooldownMinutesDefault },
229
+ createdAt: at,
230
+ updatedAt: at,
231
+ }
232
+ if (!current && !('skill' in patch)) throw new RoutineConfigError('invalid_skill', 'Pick a skill from this COS workspace.')
233
+ if ('skill' in patch) {
234
+ const skill = normalizeSkillName(patch.skill)
235
+ if (!ctx.skillExists(skill)) throw new RoutineConfigError('unknown_skill', `/${skill} is not a skill in this COS workspace.`)
236
+ base.skill = skill
237
+ }
238
+ if ('name' in patch) {
239
+ if (patch.name != null && typeof patch.name !== 'string') throw new RoutineConfigError('invalid_name', 'name must be text.')
240
+ base.name = cleanText(patch.name, ROUTINE_LIMITS.nameChars)
241
+ }
242
+ if (!base.name) base.name = `/${base.skill}`
243
+ if ('instruction' in patch) {
244
+ if (patch.instruction != null && typeof patch.instruction !== 'string') throw new RoutineConfigError('invalid_instruction', 'instruction must be text.')
245
+ base.instruction = cleanText(patch.instruction, ROUTINE_LIMITS.instructionChars)
246
+ }
247
+ if ('enabled' in patch) {
248
+ if (typeof patch.enabled !== 'boolean') throw new RoutineConfigError('invalid_enabled', 'enabled must be true or false.')
249
+ base.enabled = patch.enabled
250
+ }
251
+ if ('time' in patch) {
252
+ const time = typeof patch.time === 'string' ? patch.time.trim() : ''
253
+ if (!TIME_RE.test(time)) throw new RoutineConfigError('invalid_time', 'time must be HH:MM in 24-hour form, e.g. 08:00.')
254
+ base.time = time
255
+ }
256
+ if ('timezone' in patch) {
257
+ if (!isValidTimezone(patch.timezone)) throw new RoutineConfigError('invalid_timezone', 'timezone must be an IANA zone such as America/Chicago.')
258
+ base.timezone = patch.timezone
259
+ }
260
+ if ('days' in patch) base.days = normalizeDays(patch.days)
261
+ if ('catchUpMinutes' in patch) {
262
+ base.catchUpMinutes = intIn(patch.catchUpMinutes, 0, ROUTINE_LIMITS.catchUpMinutesMax, 'invalid_catch_up', 'catchUpMinutes')
263
+ }
264
+ if ('dailyCap' in patch) {
265
+ base.dailyCap = intIn(patch.dailyCap, ROUTINE_LIMITS.dailyCapMin, ROUTINE_LIMITS.dailyCapMax, 'invalid_daily_cap', 'dailyCap')
266
+ }
267
+ if ('model' in patch) {
268
+ if (patch.model == null || patch.model === '') {
269
+ base.model = ROUTINE_DEFAULT_MODEL
270
+ } else {
271
+ const model = normalizeModelPreference(patch.model)
272
+ // A scheduled routine always runs on the subscription, never a key lane (Decision Q2).
273
+ if (!model && keyProfileRejectApplies(patch.model)) throw new RoutineConfigError(KEY_PROFILE_REJECT_CODE, KEY_PROFILE_SCHEDULED_REJECT)
274
+ if (!model) throw new RoutineConfigError('invalid_model', 'Unknown model preference.')
275
+ base.model = model
276
+ }
277
+ }
278
+ if ('effort' in patch) {
279
+ if (patch.effort == null || patch.effort === '') {
280
+ delete base.effort
281
+ } else {
282
+ const effort = normalizeEffortPreference(patch.effort)
283
+ if (!effort) throw new RoutineConfigError('invalid_effort', 'Unknown effort preference.')
284
+ base.effort = effort
285
+ }
286
+ }
287
+ if ('breaker' in patch) {
288
+ const b = patch.breaker
289
+ if (!b || typeof b !== 'object' || Array.isArray(b)) throw new RoutineConfigError('invalid_breaker', 'breaker must be an object.')
290
+ const rec = b as Record<string, unknown>
291
+ if ('failures' in rec) base.breaker.failures = intIn(rec.failures, ROUTINE_LIMITS.breakerFailuresMin, ROUTINE_LIMITS.breakerFailuresMax, 'invalid_breaker_failures', 'breaker.failures')
292
+ if ('cooldownMinutes' in rec) base.breaker.cooldownMinutes = intIn(rec.cooldownMinutes, ROUTINE_LIMITS.cooldownMinutesMin, ROUTINE_LIMITS.cooldownMinutesMax, 'invalid_breaker_cooldown', 'breaker.cooldownMinutes')
293
+ }
294
+ base.updatedAt = at
295
+ return base
296
+ }
297
+
298
+ /** Coerce a stored routine. A damaged field takes its default; a routine with no usable id or skill is dropped. */
299
+ export function coerceRoutine(raw: unknown): SkillRoutine | null {
300
+ if (!raw || typeof raw !== 'object') return null
301
+ const r = raw as Record<string, unknown>
302
+ if (typeof r.id !== 'string' || !ROUTINE_ID_RE.test(r.id)) return null
303
+ if (typeof r.skill !== 'string' || !ROUTINE_SKILL_RE.test(r.skill)) return null
304
+ const num = (value: unknown, min: number, max: number, fallback: number) => {
305
+ const n = Number(value)
306
+ return Number.isSafeInteger(n) && n >= min && n <= max ? n : fallback
307
+ }
308
+ const breaker = (r.breaker && typeof r.breaker === 'object' ? r.breaker : {}) as Record<string, unknown>
309
+ const model = normalizeModelPreference(r.model)
310
+ const effort = normalizeEffortPreference(r.effort)
311
+ const now = new Date().toISOString()
312
+ let days: number[]
313
+ try { days = normalizeDays(r.days) } catch { days = [1, 2, 3, 4, 5] }
314
+ return {
315
+ id: r.id,
316
+ kind: 'skill',
317
+ name: cleanText(r.name, ROUTINE_LIMITS.nameChars) || `/${r.skill}`,
318
+ skill: r.skill,
319
+ instruction: cleanText(r.instruction, ROUTINE_LIMITS.instructionChars),
320
+ enabled: typeof r.enabled === 'boolean' ? r.enabled : false,
321
+ time: typeof r.time === 'string' && TIME_RE.test(r.time) ? r.time : '08:00',
322
+ timezone: isValidTimezone(r.timezone) ? r.timezone : serverTimezone(),
323
+ days,
324
+ catchUpMinutes: num(r.catchUpMinutes, 0, ROUTINE_LIMITS.catchUpMinutesMax, ROUTINE_LIMITS.catchUpMinutesDefault),
325
+ dailyCap: num(r.dailyCap, ROUTINE_LIMITS.dailyCapMin, ROUTINE_LIMITS.dailyCapMax, ROUTINE_LIMITS.dailyCapDefault),
326
+ model: model ?? ROUTINE_DEFAULT_MODEL,
327
+ ...(effort ? { effort } : {}),
328
+ breaker: {
329
+ failures: num(breaker.failures, ROUTINE_LIMITS.breakerFailuresMin, ROUTINE_LIMITS.breakerFailuresMax, ROUTINE_LIMITS.breakerFailuresDefault),
330
+ cooldownMinutes: num(breaker.cooldownMinutes, ROUTINE_LIMITS.cooldownMinutesMin, ROUTINE_LIMITS.cooldownMinutesMax, ROUTINE_LIMITS.cooldownMinutesDefault),
331
+ },
332
+ createdAt: typeof r.createdAt === 'string' ? r.createdAt : now,
333
+ updatedAt: typeof r.updatedAt === 'string' ? r.updatedAt : now,
334
+ }
335
+ }
336
+
337
+ function coerceBreaker(raw: unknown): RoutineBreakerState {
338
+ const r = (raw && typeof raw === 'object' ? raw : {}) as Record<string, unknown>
339
+ const failures = Number(r.consecutiveFailures)
340
+ const state: RoutineBreakerState = { consecutiveFailures: Number.isSafeInteger(failures) && failures > 0 ? failures : 0 }
341
+ if (typeof r.openUntil === 'string' && Number.isFinite(Date.parse(r.openUntil))) state.openUntil = r.openUntil
342
+ if (typeof r.openedAt === 'string') state.openedAt = r.openedAt
343
+ const last = r.lastFailure as Record<string, unknown> | undefined
344
+ if (last && typeof last.code === 'string' && typeof last.at === 'string') {
345
+ state.lastFailure = { code: last.code.slice(0, 64), message: String(last.message ?? '').slice(0, 300), at: last.at }
346
+ }
347
+ return state
348
+ }
349
+
350
+ export function loadRoutinesFile(paths: RoutineStorePaths): { file: RoutinesFile; quarantinedAs?: string } {
351
+ const loaded = loadJsonOrQuarantine<unknown>(paths.routines)
352
+ if (loaded.status !== 'ok' || !loaded.data || typeof loaded.data !== 'object') {
353
+ return { file: { v: 1, routines: [], breakers: {} }, ...(loaded.status === 'corrupt' ? { quarantinedAs: loaded.quarantinedAs } : {}) }
354
+ }
355
+ const data = loaded.data as { routines?: unknown; breakers?: unknown }
356
+ const seen = new Set<string>()
357
+ const routines: SkillRoutine[] = []
358
+ for (const entry of Array.isArray(data.routines) ? data.routines : []) {
359
+ const routine = coerceRoutine(entry)
360
+ if (!routine || seen.has(routine.id)) continue
361
+ seen.add(routine.id)
362
+ routines.push(routine)
363
+ }
364
+ const breakers: Record<string, RoutineBreakerState> = {}
365
+ const rawBreakers = data.breakers && typeof data.breakers === 'object' ? data.breakers as Record<string, unknown> : {}
366
+ for (const id of seen) if (rawBreakers[id]) breakers[id] = coerceBreaker(rawBreakers[id])
367
+ return { file: { v: 1, routines: routines.slice(0, ROUTINE_LIMITS.maxRoutines), breakers } }
368
+ }
369
+
370
+ export function saveRoutinesFile(paths: RoutineStorePaths, file: RoutinesFile): void {
371
+ ensurePrivateDir(paths.routines)
372
+ durableAtomicWriteFileSync(paths.routines, `${JSON.stringify(file, null, 2)}\n`, { mode: 0o600 })
373
+ }
374
+
375
+ function isRoutineRun(value: unknown): value is RoutineRun {
376
+ if (!value || typeof value !== 'object') return false
377
+ const run = value as Record<string, unknown>
378
+ return typeof run.id === 'string'
379
+ && typeof run.routineId === 'string'
380
+ && typeof run.day === 'string'
381
+ && (run.trigger === 'scheduled' || run.trigger === 'manual')
382
+ && typeof run.firedAt === 'string'
383
+ && typeof run.clientJobId === 'string'
384
+ && typeof run.sessionId === 'string'
385
+ }
386
+
387
+ export function loadRoutinesLedger(paths: RoutineStorePaths): RoutinesLedger {
388
+ const loaded = loadJsonOrQuarantine<unknown>(paths.runs)
389
+ if (loaded.status !== 'ok' || !loaded.data || typeof loaded.data !== 'object') return { v: 1, runs: [] }
390
+ const raw = (loaded.data as { runs?: unknown }).runs
391
+ return { v: 1, runs: (Array.isArray(raw) ? raw : []).filter(isRoutineRun) }
392
+ }
393
+
394
+ export function saveRoutinesLedger(paths: RoutineStorePaths, ledger: RoutinesLedger): void {
395
+ ensurePrivateDir(paths.runs)
396
+ const runs = ledger.runs.slice(-ROUTINE_LIMITS.retainedRuns)
397
+ durableAtomicWriteFileSync(paths.runs, `${JSON.stringify({ v: 1, runs }, null, 2)}\n`, { mode: 0o600 })
398
+ }
399
+
400
+ /** The prompt a skill routine's durable job carries. Deterministic for (routine, day) so a crash
401
+ * resume re-admits the same job. Unattended: it never sends, publishes or deletes. */
402
+ export function composeSkillRoutinePrompt(routine: Pick<SkillRoutine, 'name' | 'skill' | 'instruction'>, day: string, ownerName: string, trigger: RoutineTrigger): string {
403
+ const who = ownerName.trim() || 'the owner'
404
+ const lines = [
405
+ `Scheduled COS routine "${routine.name}" for ${day} (${trigger === 'manual' ? 'Run now' : 'on schedule'}).`,
406
+ `Run the /${routine.skill} skill from this COS workspace and give ${who} its result.`,
407
+ ...(routine.instruction ? ['', `Also: ${routine.instruction}`] : []),
408
+ '',
409
+ 'Nobody is watching this run. Read and summarise freely, and write only the files this skill keeps on this Mac.',
410
+ 'Do not send email, Slack or any message, publish, deploy, push, delete data, or spend money. If the skill needs one of those, stop and say what it would do.',
411
+ 'If the skill is missing or a source it needs is unavailable, say so in one plain line instead of guessing.',
412
+ 'Answer in plain text with short lines, so it reads on the glasses.',
413
+ ]
414
+ return lines.join('\n')
415
+ }
416
+
417
+ /** 6.82.0: the token-audit caller for a job a routine started (`routine:<id>`), '' for every other
418
+ * job, whose callers stay exactly what they were. */
419
+ export function routineAuditCaller(request: { origin?: { kind: string; id: string } }): string {
420
+ return request.origin?.kind === 'routine' ? `routine:${request.origin.id}` : ''
421
+ }
@@ -0,0 +1,151 @@
1
+ // 6.82.0 Routines: one list for every routine on this server. Routine #1 is the morning brief,
2
+ // served by its own scheduler with its settings and behaviour unchanged (morning-brief-golden.test.ts);
3
+ // routine #2 onward are skill routines (routines-scheduler.ts). Every surface (the phone, COS Control,
4
+ // curl) lists, edits, pauses and runs both through /api/routines, and the morning brief keeps its
5
+ // own /api/morning-brief route for the surfaces that already use it.
6
+
7
+ import { MORNING_BRIEF_LIMITS, MorningBriefConfigError } from './morning-brief-config.js'
8
+ import { MORNING_BRIEF_ROUTINE_ID, MorningBriefRunError, type MorningBriefRunView, type MorningBriefScheduler } from './morning-brief-scheduler.js'
9
+ import { ROUTINE_DEFAULT_MODEL, ROUTINE_LIMITS, RoutineConfigError } from './routines-config.js'
10
+ import { RoutineRunError, type RoutineGate, type RoutineScheduler, type SkillRoutineView } from './routines-scheduler.js'
11
+
12
+ export interface MorningBriefRoutineView {
13
+ id: typeof MORNING_BRIEF_ROUTINE_ID
14
+ kind: 'morning-brief'
15
+ name: string
16
+ enabled: boolean
17
+ time: string
18
+ timezone: string
19
+ days: number[]
20
+ model: string | null
21
+ effort: string | null
22
+ nextRunAt: string | null
23
+ gate: RoutineGate
24
+ /** The brief's own stops, in the shape every routine row shows. */
25
+ limits: { scheduledPerDay: 1; runNowPerDay: number; submitAttemptsPerDay: number }
26
+ /** Sources on, in order, for the row's second line. Full settings stay on /api/morning-brief. */
27
+ sourcesOn: string[]
28
+ lastRun: MorningBriefRunView | null
29
+ updatedAt: string
30
+ }
31
+
32
+ export type RoutineListEntry = MorningBriefRoutineView | (SkillRoutineView & { model: string })
33
+
34
+ export interface RoutinesEnvelope {
35
+ protocolVersion: 1
36
+ /** COS_ROUTINES: off stops every skill routine (the brief has its own switch). */
37
+ masterSwitch: boolean
38
+ limits: { maxRoutines: number; dailyCapMax: number; globalRunsPerDay: number; defaultModel: string }
39
+ routines: RoutineListEntry[]
40
+ }
41
+
42
+ /** A body or a call the registry refuses, with the HTTP status the route sends. */
43
+ export class RoutineRegistryError extends Error {
44
+ constructor(readonly status: number, readonly code: string, message: string, readonly detail: Record<string, unknown> = {}) {
45
+ super(message)
46
+ this.name = 'RoutineRegistryError'
47
+ }
48
+ }
49
+
50
+ function mapError(error: unknown): never {
51
+ if (error instanceof RoutineRegistryError) throw error
52
+ if (error instanceof RoutineConfigError) throw new RoutineRegistryError(error.status, error.code, error.message)
53
+ if (error instanceof MorningBriefConfigError) throw new RoutineRegistryError(400, error.code, error.message)
54
+ if (error instanceof RoutineRunError) throw new RoutineRegistryError(error.status, error.code, error.message, error.detail)
55
+ if (error instanceof MorningBriefRunError) throw new RoutineRegistryError(error.status, error.code, error.message)
56
+ throw error
57
+ }
58
+
59
+ /** Fields a routine edit may carry for the brief. Anything else on the brief is refused, not ignored. */
60
+ const BRIEF_FIELDS = new Set(['enabled', 'time', 'timezone', 'days', 'model', 'effort', 'catchUpMinutes', 'sources', 'closingInstruction'])
61
+
62
+ export class RoutineRegistry {
63
+ constructor(private readonly brief: () => MorningBriefScheduler, private readonly skills: () => RoutineScheduler) {}
64
+
65
+ private async briefView(): Promise<MorningBriefRoutineView> {
66
+ const scheduler = this.brief()
67
+ const config = scheduler.getConfig()
68
+ const status = await scheduler.status()
69
+ const gate: RoutineGate = status.gate === 'durable_jobs_off' || status.gate === 'admissions_closed' || status.gate === 'disabled' ? status.gate : 'ready'
70
+ return {
71
+ id: MORNING_BRIEF_ROUTINE_ID,
72
+ kind: 'morning-brief',
73
+ name: 'Morning brief',
74
+ enabled: config.enabled,
75
+ time: config.time,
76
+ timezone: config.timezone,
77
+ days: [...config.days],
78
+ model: config.model ?? null,
79
+ effort: config.effort ?? null,
80
+ nextRunAt: status.nextRunAt,
81
+ gate,
82
+ limits: { scheduledPerDay: 1, runNowPerDay: MORNING_BRIEF_LIMITS.manualRunsPerDay, submitAttemptsPerDay: MORNING_BRIEF_LIMITS.scheduledAttemptsPerDay },
83
+ sourcesOn: config.sources.filter(source => source.enabled).map(source => source.id),
84
+ lastRun: status.lastRun,
85
+ updatedAt: config.updatedAt,
86
+ }
87
+ }
88
+
89
+ async list(): Promise<RoutinesEnvelope> {
90
+ const skills = this.skills()
91
+ return {
92
+ protocolVersion: 1,
93
+ masterSwitch: skills.masterSwitchOn(),
94
+ limits: { maxRoutines: ROUTINE_LIMITS.maxRoutines, dailyCapMax: ROUTINE_LIMITS.dailyCapMax, globalRunsPerDay: ROUTINE_LIMITS.globalRunsPerDay, defaultModel: ROUTINE_DEFAULT_MODEL },
95
+ routines: [await this.briefView(), ...await skills.views()],
96
+ }
97
+ }
98
+
99
+ async get(id: string): Promise<RoutineListEntry> {
100
+ try {
101
+ return id === MORNING_BRIEF_ROUTINE_ID ? await this.briefView() : await this.skills().get(id)
102
+ } catch (error) { return mapError(error) }
103
+ }
104
+
105
+ async create(body: unknown): Promise<RoutineListEntry> {
106
+ try {
107
+ const created = await this.skills().create(body)
108
+ return this.skills().get(created.id)
109
+ } catch (error) { return mapError(error) }
110
+ }
111
+
112
+ async update(id: string, body: unknown): Promise<RoutineListEntry> {
113
+ try {
114
+ if (id === MORNING_BRIEF_ROUTINE_ID) {
115
+ if (!body || typeof body !== 'object' || Array.isArray(body)) throw new RoutineRegistryError(400, 'invalid_patch', 'Expected a JSON object.')
116
+ const extra = Object.keys(body).filter(key => !BRIEF_FIELDS.has(key))
117
+ if (extra.length) throw new RoutineRegistryError(400, 'not_a_brief_field', `The morning brief does not take ${extra.join(', ')}. Its sources and options are on /api/morning-brief.`)
118
+ this.brief().updateConfig(body)
119
+ return this.briefView()
120
+ }
121
+ await this.skills().update(id, body)
122
+ return this.skills().get(id)
123
+ } catch (error) { return mapError(error) }
124
+ }
125
+
126
+ setEnabled(id: string, enabled: boolean): Promise<RoutineListEntry> {
127
+ return this.update(id, { enabled })
128
+ }
129
+
130
+ async remove(id: string): Promise<void> {
131
+ try {
132
+ if (id === MORNING_BRIEF_ROUTINE_ID) throw new RoutineRegistryError(409, 'cannot_delete_brief', 'The morning brief cannot be deleted. Turn it off instead.')
133
+ await this.skills().remove(id)
134
+ } catch (error) { return mapError(error) }
135
+ }
136
+
137
+ async runNow(id: string): Promise<Record<string, unknown>> {
138
+ try {
139
+ const run = id === MORNING_BRIEF_ROUTINE_ID ? await this.brief().runNow() : await this.skills().runNow(id)
140
+ return { ...run }
141
+ } catch (error) { return mapError(error) }
142
+ }
143
+
144
+ async runs(id: string | undefined, limit: number): Promise<unknown[]> {
145
+ try {
146
+ if (id === MORNING_BRIEF_ROUTINE_ID) return await this.brief().listRuns(limit)
147
+ if (id) await this.skills().get(id)
148
+ return await this.skills().listRuns(id, limit)
149
+ } catch (error) { return mapError(error) }
150
+ }
151
+ }
@@ -0,0 +1,64 @@
1
+ // 6.82.0 Routines: the production singletons, wired to the real coordinator.
2
+ // Mirrors morning-brief-runtime.ts; tests construct their own RoutineScheduler with fake deps.
3
+
4
+ import { createSession } from './conversation.js'
5
+ import { currentMessageEra } from './message-era.js'
6
+ import { getOwnerName } from './profile.js'
7
+ import { durableQueryJobsEnabled } from './query-job-feature.js'
8
+ import { queryJobCoordinator } from './query-job-runtime.js'
9
+ import { maintenanceAdmissionsOpen } from './maintenance-lifecycle.js'
10
+ import { registerMessageReservationSource } from './message-reservations.js'
11
+ import { currentMessageMax, getMorningBriefScheduler } from './morning-brief-runtime.js'
12
+ import { routinePaths } from './routines-config.js'
13
+ import { RoutineRegistry } from './routines-registry.js'
14
+ import { RoutineScheduler } from './routines-scheduler.js'
15
+ import { listWorkspaceSkills, type WorkspaceSkill } from './workspace-skills.js'
16
+
17
+ /** The skills a routine may run: this COS workspace's own (`.agents/skills`, `.claude/skills`). */
18
+ export function routineSkillCatalog(): WorkspaceSkill[] {
19
+ return listWorkspaceSkills().filter(skill => skill.where === 'workspace')
20
+ }
21
+
22
+ export function routineSkillExists(name: string): boolean {
23
+ return routineSkillCatalog().some(skill => skill.name === name)
24
+ }
25
+
26
+ let scheduler: RoutineScheduler | null = null
27
+ let registry: RoutineRegistry | null = null
28
+ let unregister: (() => void) | null = null
29
+
30
+ export function getRoutineScheduler(): RoutineScheduler {
31
+ if (!scheduler) {
32
+ const instance = new RoutineScheduler({
33
+ paths: routinePaths(),
34
+ submit: raw => queryJobCoordinator.submit(raw),
35
+ findByClientGeneration: (clientJobId, generation) => queryJobCoordinator.getByClientGeneration(clientJobId, generation),
36
+ getSnapshot: jobId => queryJobCoordinator.getSnapshot(jobId),
37
+ createSession,
38
+ currentMessageEra,
39
+ currentMessageMax,
40
+ ownerName: getOwnerName,
41
+ durableJobsEnabled: durableQueryJobsEnabled,
42
+ admissionsOpen: maintenanceAdmissionsOpen,
43
+ skillExists: routineSkillExists,
44
+ })
45
+ unregister = registerMessageReservationSource(() => instance.liveReservations(currentMessageEra()))
46
+ scheduler = instance
47
+ }
48
+ return scheduler
49
+ }
50
+
51
+ export function getRoutineRegistry(): RoutineRegistry {
52
+ if (!registry) registry = new RoutineRegistry(getMorningBriefScheduler, getRoutineScheduler)
53
+ return registry
54
+ }
55
+
56
+ export function startRoutineScheduler(): void {
57
+ getRoutineScheduler().start()
58
+ }
59
+
60
+ export function stopRoutineScheduler(): void {
61
+ scheduler?.stop()
62
+ unregister?.()
63
+ unregister = null
64
+ }