@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.
- package/CHANGELOG.md +93 -0
- package/docs/work-orchestrator.md +184 -0
- package/package.json +6 -1
- package/server/index.ts +19 -0
- package/server/lib/claude-bridge.ts +10 -1
- package/server/lib/codex-bridge.ts +1 -1
- package/server/lib/cursor-bridge.ts +1 -1
- package/server/lib/direct-bridge.ts +1 -1
- package/server/lib/morning-brief-schedule.ts +13 -7
- package/server/lib/query-job-coordinator.ts +5 -1
- package/server/lib/query-job-runtime.ts +3 -0
- package/server/lib/routines-config.ts +421 -0
- package/server/lib/routines-registry.ts +151 -0
- package/server/lib/routines-runtime.ts +64 -0
- package/server/lib/routines-scheduler.ts +509 -0
- package/server/lib/work-chat.ts +76 -9
- package/server/lib/work-lens-rounds.ts +445 -0
- package/server/lib/work-orchestrator/discovery.ts +99 -0
- package/server/lib/work-orchestrator/input.ts +109 -0
- package/server/lib/work-orchestrator/pickup.ts +85 -0
- package/server/lib/work-orchestrator/plan-log.ts +38 -0
- package/server/lib/work-orchestrator/redact.ts +41 -0
- package/server/lib/work-orchestrator/runtime.ts +74 -0
- package/server/lib/work-orchestrator/session-log.ts +114 -0
- package/server/lib/work-orchestrator/snapshot.ts +87 -0
- package/server/lib/work-orchestrator/tag-index.ts +272 -0
- package/server/lib/work-orchestrator/tags.ts +52 -0
- package/server/lib/work-orchestrator/usage-events.ts +30 -0
- package/server/lib/work-orchestrator/usage.ts +341 -0
- package/server/routes/routines.ts +101 -0
- package/server/routes/work-chat.ts +54 -11
- package/server/routes/work-lens-rounds.ts +179 -0
- package/server/routes/work-orchestrator.ts +71 -0
- 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
|
+
}
|