@gotcos/glasses-server 6.81.0 → 6.82.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.
@@ -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
+ }