@gotcos/glasses-server 6.43.4 → 6.44.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 +19 -0
- package/package.json +4 -2
- package/server/index.ts +2 -0
- package/server/lib/claude-bridge.ts +30 -12
- package/server/lib/maintenance-lifecycle.ts +6 -0
- package/server/lib/model-router.ts +4 -0
- package/server/lib/morning-brief-config.ts +22 -1
- package/server/lib/morning-brief-prompt.ts +16 -2
- package/server/lib/morning-brief-runtime.ts +32 -1
- package/server/lib/morning-brief-schedule.ts +19 -2
- package/server/lib/morning-brief-scheduler.ts +49 -1
- package/server/lib/python-bridge.ts +18 -4
- package/server/lib/query-job-coordinator.ts +8 -2
- package/server/lib/query-job-runtime.ts +9 -2
- package/server/lib/query-job-store.ts +5 -0
- package/server/lib/query-job-types.ts +31 -1
- package/server/lib/task-dispatcher.ts +642 -0
- package/server/lib/task-store.ts +652 -0
- package/server/routes/health.ts +8 -1
- package/server/routes/query-jobs.ts +1 -1
- package/server/routes/tasks.ts +152 -0
|
@@ -0,0 +1,652 @@
|
|
|
1
|
+
import { join } from 'node:path'
|
|
2
|
+
import { durableAtomicWriteFileSync, loadJsonOrQuarantine } from './atomic-fs.js'
|
|
3
|
+
import { dataPath } from './data-dir.js'
|
|
4
|
+
import {
|
|
5
|
+
TASK_CATCH_UP_LIMITS,
|
|
6
|
+
ensurePrivateDir,
|
|
7
|
+
loadMorningBriefConfig,
|
|
8
|
+
morningBriefPaths,
|
|
9
|
+
type MorningBriefConfig,
|
|
10
|
+
} from './morning-brief-config.js'
|
|
11
|
+
import { localClock, shiftDay, taskInstant } from './morning-brief-schedule.js'
|
|
12
|
+
import { callPython, pythonBridgeAvailable } from './python-bridge.js'
|
|
13
|
+
import { isClientJobId } from './query-job-types.js'
|
|
14
|
+
import { DEFAULT_MODEL, isClaudeModel } from '../../shared/model-preference.js'
|
|
15
|
+
|
|
16
|
+
export const TASK_DISPATCH_LIMITS = Object.freeze({
|
|
17
|
+
runsPerDay: 3,
|
|
18
|
+
submitAttempts: 3,
|
|
19
|
+
submitSpacingMs: 2 * 60_000,
|
|
20
|
+
runSpacingMs: 10 * 60_000,
|
|
21
|
+
perTick: 2,
|
|
22
|
+
reconcilePerTick: 2,
|
|
23
|
+
capPerDay: 20,
|
|
24
|
+
retainedRuns: 200,
|
|
25
|
+
})
|
|
26
|
+
export const TASK_DISPATCH_WALL_MS = 25_000
|
|
27
|
+
export const TASK_LEASE_CEILING_MS = 2 * TASK_DISPATCH_WALL_MS
|
|
28
|
+
export const TASK_RECONCILE_WALL_MS = 40_000
|
|
29
|
+
export const TASK_BRIDGE_TIMEOUT_MS = 12_000
|
|
30
|
+
export const TASK_JOB_LOST_MS = 6 * 60 * 60_000
|
|
31
|
+
export const TASK_LOCK_RETRY = Object.freeze({ attempts: 3, spacingMs: 300 })
|
|
32
|
+
export const TASK_TODAY_PURGE_HORIZON_DAYS = 7
|
|
33
|
+
export const FULL_DOMAINS = ['quilt', 'personal', 'hermit_crabs', 'sprocket_rocket'] as const
|
|
34
|
+
export type TaskDomain = (typeof FULL_DOMAINS)[number]
|
|
35
|
+
export type TaskRunStatus = 'dispatching' | 'running' | 'done' | 'superseded' | 'failed' | 'orphaned'
|
|
36
|
+
export const TASK_RUN_TERMINAL = new Set<TaskRunStatus>(['done', 'superseded', 'failed', 'orphaned'])
|
|
37
|
+
export type TaskColumn = 'done' | 'running' | 'today' | 'carried' | 'scheduled' | 'inbox'
|
|
38
|
+
|
|
39
|
+
export interface TaskStorePaths {
|
|
40
|
+
runs: string
|
|
41
|
+
cap: string
|
|
42
|
+
capturesSeen: string
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export interface TaskRun {
|
|
46
|
+
id: string
|
|
47
|
+
identity: string
|
|
48
|
+
taskId: string
|
|
49
|
+
ref: string
|
|
50
|
+
domain: TaskDomain
|
|
51
|
+
line: string
|
|
52
|
+
scheduledFor?: string
|
|
53
|
+
day: string
|
|
54
|
+
attempt: number
|
|
55
|
+
trigger: 'manual' | 'scheduled'
|
|
56
|
+
firedAt: string
|
|
57
|
+
clientJobId: string
|
|
58
|
+
generation: 1
|
|
59
|
+
sessionId: string
|
|
60
|
+
messageEra?: string
|
|
61
|
+
globalMsgNum?: number
|
|
62
|
+
status: TaskRunStatus
|
|
63
|
+
jobId?: string
|
|
64
|
+
submitAttempts: number
|
|
65
|
+
retryAfter?: number
|
|
66
|
+
lastKnownStatus?: string
|
|
67
|
+
completedAt?: string
|
|
68
|
+
error?: { code: string; message: string }
|
|
69
|
+
catchUp?: boolean
|
|
70
|
+
model: string
|
|
71
|
+
effort?: string
|
|
72
|
+
cursorExecutionMode?: string
|
|
73
|
+
activityToolMode: 'status'
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export interface BridgeTaskRow {
|
|
77
|
+
ref: string
|
|
78
|
+
id: string
|
|
79
|
+
domain: string
|
|
80
|
+
description: string
|
|
81
|
+
priority: string
|
|
82
|
+
is_checked: boolean
|
|
83
|
+
archived: boolean
|
|
84
|
+
line_number: number
|
|
85
|
+
source: string | null
|
|
86
|
+
owner: string | null
|
|
87
|
+
delegated: boolean
|
|
88
|
+
needs_review: boolean
|
|
89
|
+
thread: null
|
|
90
|
+
run_at: string | null
|
|
91
|
+
agent_state: 'running' | 'done' | 'failed' | null
|
|
92
|
+
agent_no: number | null
|
|
93
|
+
section: string
|
|
94
|
+
section_day: string | null
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export interface TaskFlags {
|
|
98
|
+
due: boolean
|
|
99
|
+
missed: boolean
|
|
100
|
+
failed: boolean
|
|
101
|
+
late: boolean
|
|
102
|
+
carriedOver: boolean
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export interface TaskBoardRow {
|
|
106
|
+
id: string
|
|
107
|
+
ref: string
|
|
108
|
+
domain: string
|
|
109
|
+
title: string
|
|
110
|
+
column: TaskColumn
|
|
111
|
+
priority: string
|
|
112
|
+
runAt?: string
|
|
113
|
+
section: string
|
|
114
|
+
sectionDay?: string
|
|
115
|
+
due: boolean
|
|
116
|
+
missed: boolean
|
|
117
|
+
failed: boolean
|
|
118
|
+
late: boolean
|
|
119
|
+
carriedOver: boolean
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
export class TaskBridgeError extends Error {
|
|
123
|
+
constructor(readonly code: string, message: string) {
|
|
124
|
+
super(message)
|
|
125
|
+
this.name = 'TaskBridgeError'
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
export class TaskRunError extends Error {
|
|
130
|
+
constructor(readonly status: number, readonly code: string, message: string) {
|
|
131
|
+
super(message)
|
|
132
|
+
this.name = 'TaskRunError'
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
export function taskStorePaths(root?: string): TaskStorePaths {
|
|
137
|
+
const base = root ?? dataPath('tasks')
|
|
138
|
+
return {
|
|
139
|
+
runs: join(base, 'runs.json'),
|
|
140
|
+
cap: join(base, 'tasks-dispatch-cap.json'),
|
|
141
|
+
capturesSeen: join(base, 'captures-seen.json'),
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
export function tasksGate(): 'ready' | 'disabled' {
|
|
146
|
+
return pythonBridgeAvailable() ? 'ready' : 'disabled'
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
export function taskDispatchModel(config: MorningBriefConfig): string {
|
|
150
|
+
return config.model && isClaudeModel(config.model) ? config.model : DEFAULT_MODEL
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function isTaskRunRecord(value: unknown): value is TaskRun {
|
|
154
|
+
if (!value || typeof value !== 'object') return false
|
|
155
|
+
const rec = value as Record<string, unknown>
|
|
156
|
+
return typeof rec.id === 'string' && typeof rec.taskId === 'string' && typeof rec.status === 'string'
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
export function loadTaskLedger(paths: TaskStorePaths): TaskRun[] {
|
|
160
|
+
const loaded = loadJsonOrQuarantine<unknown>(paths.runs)
|
|
161
|
+
if (loaded.status !== 'ok' || !loaded.data || typeof loaded.data !== 'object') return []
|
|
162
|
+
const raw = loaded.data as { runs?: unknown }
|
|
163
|
+
if (!Array.isArray(raw.runs)) return []
|
|
164
|
+
return raw.runs.filter(isTaskRunRecord)
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
export function saveTaskLedger(paths: TaskStorePaths, runs: TaskRun[]): void {
|
|
168
|
+
const kept: TaskRun[] = []
|
|
169
|
+
const terminals: TaskRun[] = []
|
|
170
|
+
for (const run of runs) {
|
|
171
|
+
if (TASK_RUN_TERMINAL.has(run.status)) terminals.push(run)
|
|
172
|
+
else kept.push(run)
|
|
173
|
+
}
|
|
174
|
+
terminals.sort((a, b) => a.firedAt.localeCompare(b.firedAt))
|
|
175
|
+
const overflow = Math.max(0, terminals.length - TASK_DISPATCH_LIMITS.retainedRuns)
|
|
176
|
+
const retained = [...kept, ...terminals.slice(overflow)]
|
|
177
|
+
ensurePrivateDir(paths.runs)
|
|
178
|
+
durableAtomicWriteFileSync(paths.runs, `${JSON.stringify({ v: 1, runs: retained }, null, 2)}\n`, { mode: 0o600 })
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
let serializeChain: Promise<unknown> = Promise.resolve()
|
|
182
|
+
let serializeDepth = 0
|
|
183
|
+
|
|
184
|
+
export function serializeTaskWork<T>(fn: () => Promise<T> | T): Promise<T> {
|
|
185
|
+
if (serializeDepth > 0) {
|
|
186
|
+
return Promise.reject(new Error('nested serializeTaskWork'))
|
|
187
|
+
}
|
|
188
|
+
const run = serializeChain.then(async () => {
|
|
189
|
+
serializeDepth += 1
|
|
190
|
+
try {
|
|
191
|
+
return await fn()
|
|
192
|
+
} finally {
|
|
193
|
+
serializeDepth -= 1
|
|
194
|
+
}
|
|
195
|
+
})
|
|
196
|
+
serializeChain = run.then(() => undefined, () => undefined)
|
|
197
|
+
return run
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
export function parseRunAt(value: string | null | undefined): { day: string; minutes: number } | null {
|
|
201
|
+
if (!value) return null
|
|
202
|
+
const match = /^(\d{4}-\d{2}-\d{2}) (\d{2}):(\d{2})$/.exec(value)
|
|
203
|
+
if (!match) return null
|
|
204
|
+
return { day: match[1], minutes: Number(match[2]) * 60 + Number(match[3]) }
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
export function hasAgentMarker(row: Pick<BridgeTaskRow, 'agent_state'>): boolean {
|
|
208
|
+
return row.agent_state === 'running' || row.agent_state === 'done' || row.agent_state === 'failed'
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
export function beyondCatchUp(
|
|
212
|
+
runAt: { day: string; minutes: number } | null,
|
|
213
|
+
nowMs: number,
|
|
214
|
+
tz: string,
|
|
215
|
+
taskCatchUpMinutes: number = TASK_CATCH_UP_LIMITS.defaultMinutes,
|
|
216
|
+
): boolean {
|
|
217
|
+
if (!runAt) return false
|
|
218
|
+
return nowMs - taskInstant(runAt.day, runAt.minutes, tz) > taskCatchUpMinutes * 60_000
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
export function isCatchUpDue(
|
|
222
|
+
row: Pick<BridgeTaskRow, 'run_at' | 'agent_state'>,
|
|
223
|
+
nowMs: number,
|
|
224
|
+
tz: string,
|
|
225
|
+
taskCatchUpMinutes: number = TASK_CATCH_UP_LIMITS.defaultMinutes,
|
|
226
|
+
): boolean {
|
|
227
|
+
const runAt = parseRunAt(row.run_at)
|
|
228
|
+
if (!runAt) return false
|
|
229
|
+
const runAtInstant = taskInstant(runAt.day, runAt.minutes, tz)
|
|
230
|
+
return runAtInstant <= nowMs && !beyondCatchUp(runAt, nowMs, tz, taskCatchUpMinutes) && !hasAgentMarker(row)
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
export function latestLedgerRow(runs: readonly TaskRun[], taskId: string, day: string): TaskRun | undefined {
|
|
234
|
+
const inDay = runs.filter(run => run.taskId === taskId && run.day === day)
|
|
235
|
+
const live = inDay.find(run => run.status === 'dispatching' || run.status === 'running')
|
|
236
|
+
if (live) return live
|
|
237
|
+
return [...inDay].sort((a, b) => b.firedAt.localeCompare(a.firedAt))[0]
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
export function column(
|
|
241
|
+
row: BridgeTaskRow,
|
|
242
|
+
ledgerRow: TaskRun | undefined,
|
|
243
|
+
day: string,
|
|
244
|
+
nowMs: number,
|
|
245
|
+
tz: string,
|
|
246
|
+
taskCatchUpMinutes: number = TASK_CATCH_UP_LIMITS.defaultMinutes,
|
|
247
|
+
): TaskColumn | null {
|
|
248
|
+
if (row.archived) return null
|
|
249
|
+
if (row.delegated) return null
|
|
250
|
+
if (row.is_checked) return 'done'
|
|
251
|
+
if (row.agent_state === 'done') return 'done'
|
|
252
|
+
if (row.agent_state === 'running' || ledgerRow?.status === 'dispatching' || ledgerRow?.status === 'running') {
|
|
253
|
+
return 'running'
|
|
254
|
+
}
|
|
255
|
+
const runAt = parseRunAt(row.run_at)
|
|
256
|
+
if (runAt && runAt.day > day) return 'scheduled'
|
|
257
|
+
const skipSection = hasAgentMarker(row) || beyondCatchUp(runAt, nowMs, tz, taskCatchUpMinutes)
|
|
258
|
+
if (!skipSection && row.section === 'today' && row.section_day === day) return 'today'
|
|
259
|
+
if (!skipSection && row.section === 'today' && row.section_day && row.section_day < day) return 'carried'
|
|
260
|
+
if (!skipSection && row.section === 'today' && row.section_day && row.section_day > day) return 'carried'
|
|
261
|
+
if (isCatchUpDue(row, nowMs, tz, taskCatchUpMinutes)) return 'today'
|
|
262
|
+
return 'inbox'
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
export function flags(
|
|
266
|
+
row: BridgeTaskRow,
|
|
267
|
+
ledgerRow: TaskRun | undefined,
|
|
268
|
+
day: string,
|
|
269
|
+
nowMs: number,
|
|
270
|
+
tz: string,
|
|
271
|
+
taskCatchUpMinutes: number = TASK_CATCH_UP_LIMITS.defaultMinutes,
|
|
272
|
+
): TaskFlags {
|
|
273
|
+
const runAt = parseRunAt(row.run_at)
|
|
274
|
+
const liveInDay = ledgerRow?.day === day && (ledgerRow.status === 'dispatching' || ledgerRow.status === 'running')
|
|
275
|
+
const missed = beyondCatchUp(runAt, nowMs, tz, taskCatchUpMinutes) && !hasAgentMarker(row) && !liveInDay
|
|
276
|
+
const failed = row.agent_state === 'failed'
|
|
277
|
+
const runAtInstant = runAt ? taskInstant(runAt.day, runAt.minutes, tz) : null
|
|
278
|
+
const due = !!runAt && runAtInstant! <= nowMs && !hasAgentMarker(row) && !missed && !beyondCatchUp(runAt, nowMs, tz, taskCatchUpMinutes)
|
|
279
|
+
return {
|
|
280
|
+
due,
|
|
281
|
+
missed,
|
|
282
|
+
failed,
|
|
283
|
+
late: ledgerRow?.catchUp === true,
|
|
284
|
+
carriedOver: row.section === 'today' && !!row.section_day && row.section_day !== day,
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
function briefContext(nowMs = Date.now()) {
|
|
289
|
+
const { config } = loadMorningBriefConfig(morningBriefPaths(), new Date(nowMs))
|
|
290
|
+
const clock = localClock(nowMs, config.timezone)
|
|
291
|
+
return { config, clock, nowMs }
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
function asBridgeError(payload: unknown): TaskBridgeError | null {
|
|
295
|
+
if (!payload || typeof payload !== 'object') return null
|
|
296
|
+
const error = (payload as { error?: unknown }).error
|
|
297
|
+
if (!error || typeof error !== 'object') return null
|
|
298
|
+
const rec = error as { code?: unknown; message?: unknown }
|
|
299
|
+
if (typeof rec.code !== 'string') return null
|
|
300
|
+
return new TaskBridgeError(rec.code, typeof rec.message === 'string' ? rec.message : rec.code)
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
async function bridge(args: string[], input?: string): Promise<unknown> {
|
|
304
|
+
const payload = await callPython(args, TASK_BRIDGE_TIMEOUT_MS, input)
|
|
305
|
+
const error = asBridgeError(payload)
|
|
306
|
+
if (error) throw error
|
|
307
|
+
return payload
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
async function withLockRetry<T>(fn: () => Promise<T>): Promise<T> {
|
|
311
|
+
let last: unknown
|
|
312
|
+
for (let attempt = 0; attempt < TASK_LOCK_RETRY.attempts; attempt++) {
|
|
313
|
+
try {
|
|
314
|
+
return await fn()
|
|
315
|
+
} catch (error) {
|
|
316
|
+
last = error
|
|
317
|
+
if (!(error instanceof TaskBridgeError) || error.code !== 'task_file_locked') throw error
|
|
318
|
+
if (attempt + 1 < TASK_LOCK_RETRY.attempts) {
|
|
319
|
+
await new Promise(resolve => setTimeout(resolve, TASK_LOCK_RETRY.spacingMs))
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
throw last
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
export async function loadDomainRows(domain: TaskDomain, day: string): Promise<BridgeTaskRow[]> {
|
|
327
|
+
const payload = await bridge(['task-rows', domain, '--day', day])
|
|
328
|
+
return Array.isArray(payload) ? payload as BridgeTaskRow[] : []
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
export async function loadAllRows(day: string): Promise<BridgeTaskRow[]> {
|
|
332
|
+
const groups = await Promise.all(FULL_DOMAINS.map(domain => loadDomainRows(domain, day)))
|
|
333
|
+
return groups.flat()
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
/** Board and lens titles come from a raw tasks.md line, which carries markdown
|
|
337
|
+
* emphasis and is usually far longer than a row. Slicing the raw line cut words
|
|
338
|
+
* and left unbalanced `**` on 119 of 201 live rows, so a row read
|
|
339
|
+
* "**Provide data migration process docs by ind". Strip the markup first (which
|
|
340
|
+
* buys back the four characters the asterisks were spending), then cut on a word
|
|
341
|
+
* boundary. The cap is exported so tests pin the real value rather than a copy. */
|
|
342
|
+
export const TASK_TITLE_MAX = 44
|
|
343
|
+
|
|
344
|
+
export function taskTitle(line: string): string {
|
|
345
|
+
const plain = line
|
|
346
|
+
.replace(/`([^`]*)`/g, '$1')
|
|
347
|
+
.replace(/\[([^\]]*)\]\([^)]*\)/g, '$1')
|
|
348
|
+
// Underscore emphasis must be anchored: a bare `_` between word characters is
|
|
349
|
+
// an identifier (cos_python, hermit_crabs), not markup, and stripping it
|
|
350
|
+
// corrupts the row. Asterisks need no such care, so one catch-all below clears
|
|
351
|
+
// them whether the pair is balanced or not — and after a slice, it often is not.
|
|
352
|
+
.replace(/(^|[\s(])_([^_]+)_(?=[\s).,;:!?]|$)/g, '$1$2')
|
|
353
|
+
.replace(/\*+/g, '')
|
|
354
|
+
.replace(/\s+/g, ' ')
|
|
355
|
+
.trim()
|
|
356
|
+
if (plain.length <= TASK_TITLE_MAX) return plain
|
|
357
|
+
const cut = plain.slice(0, TASK_TITLE_MAX - 1)
|
|
358
|
+
const space = cut.lastIndexOf(' ')
|
|
359
|
+
// Only honour a word boundary that is not so early it throws the title away.
|
|
360
|
+
const body = space > TASK_TITLE_MAX * 0.6 ? cut.slice(0, space) : cut
|
|
361
|
+
return `${body.replace(/[\s\u2014\u2013,;:.-]+$/, '')}\u2026`
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
export function projectRow(
|
|
365
|
+
row: BridgeTaskRow,
|
|
366
|
+
ledger: readonly TaskRun[],
|
|
367
|
+
day: string,
|
|
368
|
+
nowMs: number,
|
|
369
|
+
tz: string,
|
|
370
|
+
taskCatchUpMinutes: number,
|
|
371
|
+
): TaskBoardRow | null {
|
|
372
|
+
const ledgerRow = latestLedgerRow(ledger, row.id, day)
|
|
373
|
+
const col = column(row, ledgerRow, day, nowMs, tz, taskCatchUpMinutes)
|
|
374
|
+
if (!col) return null
|
|
375
|
+
const mark = flags(row, ledgerRow, day, nowMs, tz, taskCatchUpMinutes)
|
|
376
|
+
const runAt = parseRunAt(row.run_at)
|
|
377
|
+
return {
|
|
378
|
+
id: row.id,
|
|
379
|
+
ref: row.ref,
|
|
380
|
+
domain: row.domain,
|
|
381
|
+
title: taskTitle(row.description),
|
|
382
|
+
column: col,
|
|
383
|
+
priority: row.priority,
|
|
384
|
+
...(runAt ? { runAt: new Date(taskInstant(runAt.day, runAt.minutes, tz)).toISOString() } : {}),
|
|
385
|
+
section: row.section,
|
|
386
|
+
...(row.section_day ? { sectionDay: row.section_day } : {}),
|
|
387
|
+
...mark,
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
export async function listBoard(columnFilter?: string, nowMs = Date.now()): Promise<TaskBoardRow[]> {
|
|
392
|
+
if (!pythonBridgeAvailable()) {
|
|
393
|
+
throw new TaskRunError(503, 'cos_pipeline_not_configured', 'COS pipeline is not configured.')
|
|
394
|
+
}
|
|
395
|
+
const { config, clock } = briefContext(nowMs)
|
|
396
|
+
const [rows, ledger] = await Promise.all([
|
|
397
|
+
loadAllRows(clock.day),
|
|
398
|
+
Promise.resolve(loadTaskLedger(taskStorePaths())),
|
|
399
|
+
])
|
|
400
|
+
return rows
|
|
401
|
+
.map(row => projectRow(row, ledger, clock.day, nowMs, config.timezone, config.taskCatchUpMinutes))
|
|
402
|
+
.filter((row): row is TaskBoardRow => !!row && (!columnFilter || row.column === columnFilter))
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
export function workBadgeCount(rows: readonly TaskBoardRow[]): number {
|
|
406
|
+
const ids = new Set<string>()
|
|
407
|
+
for (const row of rows) {
|
|
408
|
+
if (row.column === 'today' || row.column === 'carried' || row.missed || row.failed) ids.add(row.id)
|
|
409
|
+
}
|
|
410
|
+
return ids.size
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
export async function captureTask(body: {
|
|
414
|
+
domain: string
|
|
415
|
+
text: string
|
|
416
|
+
section: string
|
|
417
|
+
runAt?: string
|
|
418
|
+
captureId?: string
|
|
419
|
+
}, nowMs = Date.now()): Promise<{ ok: true; replayed?: boolean; fell_to_inbox?: boolean; section?: string }> {
|
|
420
|
+
if (!pythonBridgeAvailable()) throw new TaskRunError(503, 'cos_pipeline_not_configured', 'COS pipeline is not configured.')
|
|
421
|
+
if (!body.captureId) throw new TaskRunError(400, 'capture_id_required', 'captureId is required.')
|
|
422
|
+
if (!isClientJobId(body.captureId)) throw new TaskRunError(422, 'invalid_capture_id', 'captureId must be a UUID v4.')
|
|
423
|
+
if (!FULL_DOMAINS.includes(body.domain as TaskDomain)) {
|
|
424
|
+
throw new TaskRunError(400, 'invalid_domain', 'full domain required')
|
|
425
|
+
}
|
|
426
|
+
const paths = taskStorePaths()
|
|
427
|
+
const seen = loadCapturesSeen(paths)
|
|
428
|
+
if (seen.ids.includes(body.captureId)) return { ok: true, replayed: true }
|
|
429
|
+
const { config, clock } = briefContext(nowMs)
|
|
430
|
+
const args = ['task-capture', body.domain, '--section', body.section]
|
|
431
|
+
if (body.runAt) args.push('--run-at', body.runAt)
|
|
432
|
+
if (body.section.startsWith('today:')) {
|
|
433
|
+
args.push('--purge-before', shiftDay(clock.day, -TASK_TODAY_PURGE_HORIZON_DAYS))
|
|
434
|
+
}
|
|
435
|
+
const payload = await withLockRetry(() => bridge(args, body.text)) as { ok?: boolean; fell_to_inbox?: boolean; section?: string }
|
|
436
|
+
rememberCapture(paths, body.captureId, nowMs)
|
|
437
|
+
return { ok: true, fell_to_inbox: payload.fell_to_inbox, section: payload.section }
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
export async function setTaskRunAt(domain: string, id: string, runAt: string | null, nowMs = Date.now()): Promise<void> {
|
|
441
|
+
const { clock } = briefContext(nowMs)
|
|
442
|
+
const row = await findTaskRow(domain, id, clock.day)
|
|
443
|
+
if (!row) throw new TaskBridgeError('task_not_found', `no task ${id} in ${domain}`)
|
|
444
|
+
if (row.agent_state === 'running' || liveLedgerFor(id, clock.day)) {
|
|
445
|
+
throw new TaskRunError(409, 'task_running', 'A run is already in flight for this task.')
|
|
446
|
+
}
|
|
447
|
+
const args = ['task-set-run-at', domain, id, runAt ?? '--clear']
|
|
448
|
+
await withLockRetry(() => bridge(args))
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
export async function moveTask(domain: string, id: string, section: string, nowMs = Date.now()): Promise<void> {
|
|
452
|
+
const { clock } = briefContext(nowMs)
|
|
453
|
+
const args = ['task-move', domain, id, '--section', section]
|
|
454
|
+
if (section.startsWith('today:')) {
|
|
455
|
+
args.push('--purge-before', shiftDay(clock.day, -TASK_TODAY_PURGE_HORIZON_DAYS))
|
|
456
|
+
}
|
|
457
|
+
await withLockRetry(() => bridge(args))
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
export async function checkTask(opts: {
|
|
461
|
+
domain: string
|
|
462
|
+
id?: string
|
|
463
|
+
text?: string
|
|
464
|
+
checked: boolean
|
|
465
|
+
}): Promise<void> {
|
|
466
|
+
const args = opts.id
|
|
467
|
+
? ['task-check', opts.domain, opts.id]
|
|
468
|
+
: ['task-check', opts.domain, '--text', opts.text ?? '']
|
|
469
|
+
if (!opts.checked) args.push('--uncheck')
|
|
470
|
+
await withLockRetry(() => bridge(args))
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
interface CapturesSeen {
|
|
474
|
+
ids: string[]
|
|
475
|
+
at: Record<string, number>
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
function loadCapturesSeen(paths: TaskStorePaths): CapturesSeen {
|
|
479
|
+
const loaded = loadJsonOrQuarantine<unknown>(paths.capturesSeen)
|
|
480
|
+
if (loaded.status !== 'ok' || !loaded.data || typeof loaded.data !== 'object') return { ids: [], at: {} }
|
|
481
|
+
const raw = loaded.data as CapturesSeen
|
|
482
|
+
return { ids: Array.isArray(raw.ids) ? raw.ids : [], at: raw.at && typeof raw.at === 'object' ? raw.at : {} }
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
function rememberCapture(paths: TaskStorePaths, id: string, nowMs: number): void {
|
|
486
|
+
const seen = loadCapturesSeen(paths)
|
|
487
|
+
const cutoff = nowMs - 24 * 60 * 60_000
|
|
488
|
+
const nextIds = [...seen.ids.filter(existing => (seen.at[existing] ?? 0) >= cutoff), id].slice(-500)
|
|
489
|
+
const at: Record<string, number> = {}
|
|
490
|
+
for (const existing of nextIds) at[existing] = existing === id ? nowMs : seen.at[existing] ?? nowMs
|
|
491
|
+
ensurePrivateDir(paths.capturesSeen)
|
|
492
|
+
durableAtomicWriteFileSync(paths.capturesSeen, `${JSON.stringify({ ids: nextIds, at }, null, 2)}\n`, { mode: 0o600 })
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
export function loadDispatchCap(paths = taskStorePaths()): number {
|
|
496
|
+
const loaded = loadJsonOrQuarantine<unknown>(paths.cap)
|
|
497
|
+
if (loaded.status !== 'ok' || !loaded.data || typeof loaded.data !== 'object') return TASK_DISPATCH_LIMITS.capPerDay
|
|
498
|
+
const cap = Number((loaded.data as { capPerDay?: unknown }).capPerDay)
|
|
499
|
+
return Number.isSafeInteger(cap) && cap >= 0 ? cap : TASK_DISPATCH_LIMITS.capPerDay
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
export function saveDispatchCap(capPerDay: number, paths = taskStorePaths()): void {
|
|
503
|
+
if (!Number.isSafeInteger(capPerDay) || capPerDay < 0) {
|
|
504
|
+
throw new TaskRunError(400, 'invalid_cap', 'capPerDay must be a non-negative integer.')
|
|
505
|
+
}
|
|
506
|
+
ensurePrivateDir(paths.cap)
|
|
507
|
+
durableAtomicWriteFileSync(paths.cap, `${JSON.stringify({ capPerDay }, null, 2)}\n`, { mode: 0o600 })
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
export interface TaskRunView {
|
|
511
|
+
id: string
|
|
512
|
+
kind: 'task'
|
|
513
|
+
trigger: TaskRun['trigger']
|
|
514
|
+
status: string
|
|
515
|
+
firedAt: string
|
|
516
|
+
completedAt?: string
|
|
517
|
+
globalMsgNum?: number
|
|
518
|
+
title: string
|
|
519
|
+
taskId: string
|
|
520
|
+
jobId?: string
|
|
521
|
+
clientJobId: string
|
|
522
|
+
generation: 1
|
|
523
|
+
messageEra?: string
|
|
524
|
+
sessionId: string
|
|
525
|
+
error?: { code: string; message: string }
|
|
526
|
+
catchUp?: boolean
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
export function projectTaskRun(
|
|
530
|
+
run: TaskRun,
|
|
531
|
+
snapshot?: { status: string; completedAt?: string } | null,
|
|
532
|
+
): TaskRunView {
|
|
533
|
+
const title = taskTitle(run.line)
|
|
534
|
+
const base = {
|
|
535
|
+
id: run.id,
|
|
536
|
+
kind: 'task' as const,
|
|
537
|
+
trigger: run.trigger,
|
|
538
|
+
firedAt: run.firedAt,
|
|
539
|
+
...(run.completedAt ? { completedAt: run.completedAt } : {}),
|
|
540
|
+
...(run.globalMsgNum != null ? { globalMsgNum: run.globalMsgNum } : {}),
|
|
541
|
+
title,
|
|
542
|
+
taskId: run.taskId,
|
|
543
|
+
...(run.jobId ? { jobId: run.jobId } : {}),
|
|
544
|
+
clientJobId: run.clientJobId,
|
|
545
|
+
generation: 1 as const,
|
|
546
|
+
...(run.messageEra ? { messageEra: run.messageEra } : {}),
|
|
547
|
+
sessionId: run.sessionId,
|
|
548
|
+
...(run.catchUp ? { catchUp: true } : {}),
|
|
549
|
+
}
|
|
550
|
+
switch (run.status) {
|
|
551
|
+
case 'dispatching':
|
|
552
|
+
return { ...base, status: 'submitting' }
|
|
553
|
+
case 'running':
|
|
554
|
+
return {
|
|
555
|
+
...base,
|
|
556
|
+
status: snapshot?.status ?? 'running',
|
|
557
|
+
...(snapshot?.completedAt ? { completedAt: snapshot.completedAt } : {}),
|
|
558
|
+
}
|
|
559
|
+
case 'done':
|
|
560
|
+
return { ...base, status: 'completed' }
|
|
561
|
+
case 'superseded':
|
|
562
|
+
return {
|
|
563
|
+
...base,
|
|
564
|
+
status: 'canceled',
|
|
565
|
+
error: { code: 'superseded', message: 'Rescheduled before it ran' },
|
|
566
|
+
}
|
|
567
|
+
case 'failed':
|
|
568
|
+
return {
|
|
569
|
+
...base,
|
|
570
|
+
status: 'failed',
|
|
571
|
+
error: run.error ?? { code: 'failed', message: `Task ${run.status} on the Mac` },
|
|
572
|
+
}
|
|
573
|
+
case 'orphaned':
|
|
574
|
+
return {
|
|
575
|
+
...base,
|
|
576
|
+
status: 'canceled',
|
|
577
|
+
error: { code: 'orphaned', message: run.error?.message ?? 'Task orphaned on the Mac' },
|
|
578
|
+
}
|
|
579
|
+
}
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
export function listTaskRuns(limit = 20, paths = taskStorePaths()): TaskRun[] {
|
|
583
|
+
return loadTaskLedger(paths).sort((a, b) => b.firedAt.localeCompare(a.firedAt)).slice(0, limit)
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
export async function listProjectedTaskRuns(
|
|
587
|
+
getSnapshot: (jobId: string) => Promise<{ status: string; completedAt?: string } | undefined>,
|
|
588
|
+
limit = 20,
|
|
589
|
+
paths = taskStorePaths(),
|
|
590
|
+
): Promise<TaskRunView[]> {
|
|
591
|
+
const runs = listTaskRuns(limit, paths)
|
|
592
|
+
return Promise.all(runs.map(async run => {
|
|
593
|
+
const snapshot = run.status === 'running' && run.jobId
|
|
594
|
+
? await getSnapshot(run.jobId).catch(() => undefined)
|
|
595
|
+
: undefined
|
|
596
|
+
return projectTaskRun(run, snapshot)
|
|
597
|
+
}))
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
export function composeTaskDigest(rows: readonly TaskBoardRow[]): string {
|
|
601
|
+
const today = rows.filter(row => row.column === 'today' || row.column === 'carried').length
|
|
602
|
+
const running = rows.filter(row => row.column === 'running').length
|
|
603
|
+
const scheduled = rows.filter(row => row.column === 'scheduled').length
|
|
604
|
+
const inbox = rows.filter(row => row.column === 'inbox').length
|
|
605
|
+
const missed = rows.filter(row => row.missed).length
|
|
606
|
+
const failed = rows.filter(row => row.failed).length
|
|
607
|
+
const clamp = (n: number) => (n > 99 ? '99+' : String(n))
|
|
608
|
+
const lines = [`TASKS Today ${clamp(today)} · Run ${clamp(running)} · Sched ${clamp(scheduled)} · Inbox ${clamp(inbox)}`]
|
|
609
|
+
if (missed > 0 || failed > 0) lines.push(`Missed ${clamp(missed)} · Failed ${clamp(failed)}`)
|
|
610
|
+
return lines.join('\n')
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
export function composeTaskDispatchPrompt(line: string, day: string, tz: string): string {
|
|
614
|
+
return [
|
|
615
|
+
`Scheduled task for ${day} (${tz}).`,
|
|
616
|
+
'',
|
|
617
|
+
'Do this work from what is already on this Mac. Read-only: do not send messages, edit files, or change calendar events.',
|
|
618
|
+
'',
|
|
619
|
+
`Task: ${line}`,
|
|
620
|
+
'',
|
|
621
|
+
'Reply with a short status the wearer can read on glasses.',
|
|
622
|
+
].join('\n')
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
export function liveTaskReservations(era: string, nowMs = Date.now(), paths = taskStorePaths()) {
|
|
626
|
+
const floor = nowMs - 24 * 60 * 60_000
|
|
627
|
+
const out: Array<{ globalMsgNum: number; messageEra: string; owner: string }> = []
|
|
628
|
+
for (const run of loadTaskLedger(paths)) {
|
|
629
|
+
if (typeof run.globalMsgNum !== 'number') continue
|
|
630
|
+
if (era !== run.messageEra) continue
|
|
631
|
+
const fired = Date.parse(run.firedAt)
|
|
632
|
+
if (!Number.isFinite(fired) || fired < floor) continue
|
|
633
|
+
out.push({ globalMsgNum: run.globalMsgNum, messageEra: run.messageEra ?? era, owner: `task:${run.taskId}` })
|
|
634
|
+
}
|
|
635
|
+
return out
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
export async function setTaskMarker(domain: string, id: string, marker: string | null): Promise<void> {
|
|
639
|
+
const args = ['task-set-marker', domain, id, marker ?? '--clear']
|
|
640
|
+
await withLockRetry(() => bridge(args))
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
export async function findTaskRow(domain: string, id: string, day: string): Promise<BridgeTaskRow | undefined> {
|
|
644
|
+
const rows = await loadDomainRows(domain as TaskDomain, day)
|
|
645
|
+
return rows.find(row => row.id === id)
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
export function liveLedgerFor(taskId: string, day: string, paths = taskStorePaths()): TaskRun | undefined {
|
|
649
|
+
return loadTaskLedger(paths).find(run =>
|
|
650
|
+
run.taskId === taskId && run.day === day && (run.status === 'dispatching' || run.status === 'running'),
|
|
651
|
+
)
|
|
652
|
+
}
|
package/server/routes/health.ts
CHANGED
|
@@ -3,7 +3,7 @@ import { isWorthRecovering } from '../lib/quarantine-auto-recover.js'
|
|
|
3
3
|
import { claudeSessionsEnabled } from './claude-sessions.js'
|
|
4
4
|
import { statSync } from 'node:fs'
|
|
5
5
|
import { resolve } from 'node:path'
|
|
6
|
-
import { COS_SCRIPTS_DIR, COS_MODE } from '../lib/python-bridge.js'
|
|
6
|
+
import { COS_SCRIPTS_DIR, COS_MODE, pythonBridgeAvailable } from '../lib/python-bridge.js'
|
|
7
7
|
import { serverMetrics } from '../lib/server-metrics.js'
|
|
8
8
|
import { getServerInstanceId } from '../lib/server-instance-id.js'
|
|
9
9
|
import { localFirstMeetingsCapability } from '../lib/local-first-meetings-contract.js'
|
|
@@ -102,6 +102,7 @@ function durableQueryJobStatus() {
|
|
|
102
102
|
subscribers: runtime.store.subscribers,
|
|
103
103
|
malformedRows: runtime.store.malformedRows,
|
|
104
104
|
originDropped: runtime.store.originDropped,
|
|
105
|
+
originStripped: runtime.store.originStripped,
|
|
105
106
|
fingerprintMismatches: runtime.store.fingerprintMismatches,
|
|
106
107
|
evictedHydratedJobs: runtime.store.evictedHydratedJobs,
|
|
107
108
|
journalFailures: runtime.store.journalFailures,
|
|
@@ -423,6 +424,9 @@ healthRouter.get('/health', async (_req, res) => {
|
|
|
423
424
|
},
|
|
424
425
|
...(localFirstMeetings ? { localFirstMeetings } : {}),
|
|
425
426
|
...(morningBrief ? { morningBrief } : {}),
|
|
427
|
+
tasks: {
|
|
428
|
+
gate: pythonBridgeAvailable() ? 'ready' : 'disabled',
|
|
429
|
+
},
|
|
426
430
|
},
|
|
427
431
|
// /api/health is intentionally unauthenticated for setup diagnostics.
|
|
428
432
|
// Publish capability only; job counts, retention identities, subscriber
|
|
@@ -555,6 +559,9 @@ healthRouter.get('/models', async (req, res) => {
|
|
|
555
559
|
},
|
|
556
560
|
},
|
|
557
561
|
...(localFirstMeetings ? { localFirstMeetings } : {}),
|
|
562
|
+
tasks: {
|
|
563
|
+
gate: pythonBridgeAvailable() ? 'ready' : 'disabled',
|
|
564
|
+
},
|
|
558
565
|
},
|
|
559
566
|
})
|
|
560
567
|
})
|
|
@@ -270,7 +270,7 @@ export function createQueryJobsRouter(
|
|
|
270
270
|
try {
|
|
271
271
|
const jobId = validJobId(req.params.jobId)
|
|
272
272
|
const generation = requiredGeneration(req.body?.generation)
|
|
273
|
-
const job = await coordinator.cancel(jobId, generation)
|
|
273
|
+
const { job } = await coordinator.cancel(jobId, generation)
|
|
274
274
|
return res.json({ job })
|
|
275
275
|
} catch (error) {
|
|
276
276
|
const wire = wireError(error)
|