@gotcos/glasses-server 6.43.3 → 6.44.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.
@@ -79,7 +79,9 @@ sessionsRouter.post('/sessions/:id/context-break', (req, res) => {
79
79
  res.json({ ok: true })
80
80
  })
81
81
 
82
- // GET /api/sessions/:id/messages — client-compatible format (paired Q&A)
82
+ // GET /api/sessions/:id/messages — client-compatible format (paired Q&A).
83
+ // Deliberately carries no origin/originId (6.43.4): this is the legacy paired
84
+ // shape. The labelled views are today/all-messages and the day archive.
83
85
  sessionsRouter.get('/sessions/:id/messages', (req, res) => {
84
86
  const exchanges = getHistory(req.params.id)
85
87
  if (exchanges.length === 0) {
@@ -315,6 +317,8 @@ sessionsRouter.get('/sessions/today/all-messages', (_req, res) => {
315
317
  globalMsgNum?: number
316
318
  messageEra?: string
317
319
  modelPreference?: string
320
+ origin?: 'routine' | 'task'
321
+ originId?: string
318
322
  attachments?: MediaAttachmentRef[]
319
323
  }> = []
320
324
  const liveSessions = getActiveSessions()
@@ -331,6 +335,10 @@ sessionsRouter.get('/sessions/today/all-messages', (_req, res) => {
331
335
  const messageEra = ex.messageEra ?? next.messageEra
332
336
  const attachments = turnAttachments(session.id, globalMsgNum, messageEra, activeEra, ex.attachments, next.attachments)
333
337
  const modelPreference = resolveExchangePairModel(ex, next, session.modelPreference)
338
+ // Both halves of a pair are stamped together by the terminal
339
+ // projection; pick BOTH fields from ONE half so a mid-run insert
340
+ // (stamped later) can never pair one half's kind with the other's id.
341
+ const stamped = ex.origin ? ex : next
334
342
  liveMessages.push({
335
343
  query: ex.content,
336
344
  text: next.content,
@@ -341,6 +349,7 @@ sessionsRouter.get('/sessions/today/all-messages', (_req, res) => {
341
349
  ...(globalMsgNum != null ? { no: globalMsgNum, globalMsgNum } : {}),
342
350
  ...(messageEra ? { messageEra } : {}),
343
351
  ...(modelPreference ? { modelPreference } : {}),
352
+ ...(stamped.origin ? { origin: stamped.origin, ...(stamped.originId ? { originId: stamped.originId } : {}) } : {}),
344
353
  ...(attachments.length > 0 ? { attachments } : {}),
345
354
  })
346
355
  i++
@@ -0,0 +1,152 @@
1
+ import { Router } from 'express'
2
+ import { queryJobCoordinator } from '../lib/query-job-runtime.js'
3
+ import { runTaskNow } from '../lib/task-dispatcher.js'
4
+ import {
5
+ TaskBridgeError,
6
+ TaskRunError,
7
+ captureTask,
8
+ checkTask,
9
+ listBoard,
10
+ listProjectedTaskRuns,
11
+ loadDispatchCap,
12
+ moveTask,
13
+ saveDispatchCap,
14
+ setTaskRunAt,
15
+ tasksGate,
16
+ workBadgeCount,
17
+ } from '../lib/task-store.js'
18
+
19
+ export const tasksRouter = Router()
20
+
21
+ function sendError(res: import('express').Response, error: unknown): void {
22
+ if (error instanceof TaskRunError) {
23
+ if (error.code === 'task_file_locked') res.setHeader('Retry-After', '2')
24
+ res.status(error.status).json({ error: { code: error.code, message: error.message } })
25
+ return
26
+ }
27
+ if (error instanceof TaskBridgeError) {
28
+ const status = error.code === 'task_file_locked' ? 503
29
+ : error.code === 'task_not_found' || error.code === 'ambiguous_text' ? (error.code === 'ambiguous_text' ? 409 : 404)
30
+ : error.code === 'cos_pipeline_not_configured' ? 503
31
+ : 400
32
+ if (error.code === 'task_file_locked') res.setHeader('Retry-After', '2')
33
+ res.status(status).json({ error: { code: error.code, message: error.message } })
34
+ return
35
+ }
36
+ console.error('[tasks]', error)
37
+ res.status(500).json({ error: { code: 'task_store_failed', message: 'Task store failed.' } })
38
+ }
39
+
40
+ function requireDomain(value: unknown): string {
41
+ if (typeof value !== 'string' || !value) throw new TaskRunError(400, 'invalid_domain', 'domain is required')
42
+ return value
43
+ }
44
+
45
+ tasksRouter.get('/tasks/runs', async (req, res) => {
46
+ if (tasksGate() === 'disabled') {
47
+ return res.status(503).json({ error: { code: 'cos_pipeline_not_configured', message: 'COS pipeline is not configured.' }, gate: 'disabled' })
48
+ }
49
+ const limit = Number.parseInt(String(req.query.limit ?? '20'), 10)
50
+ const runs = await listProjectedTaskRuns(
51
+ jobId => queryJobCoordinator.getSnapshot(jobId).catch(() => undefined),
52
+ Number.isFinite(limit) ? limit : 20,
53
+ )
54
+ res.json({ runs })
55
+ })
56
+
57
+ tasksRouter.get('/tasks/next', async (_req, res) => {
58
+ try {
59
+ const rows = await listBoard('today')
60
+ res.json({ task: rows[0] ?? null })
61
+ } catch (error) {
62
+ sendError(res, error)
63
+ }
64
+ })
65
+
66
+ tasksRouter.post('/tasks/capture', async (req, res) => {
67
+ try {
68
+ const body = req.body ?? {}
69
+ const result = await captureTask({
70
+ domain: requireDomain(body.domain),
71
+ text: String(body.text ?? ''),
72
+ section: String(body.section ?? 'inbox'),
73
+ runAt: typeof body.runAt === 'string' ? body.runAt : undefined,
74
+ captureId: typeof body.captureId === 'string' ? body.captureId : undefined,
75
+ })
76
+ res.status(result.replayed ? 200 : 200).json(result)
77
+ } catch (error) {
78
+ sendError(res, error)
79
+ }
80
+ })
81
+
82
+ tasksRouter.patch('/tasks/by-text', async (req, res) => {
83
+ try {
84
+ const body = req.body ?? {}
85
+ if (typeof body.domain !== 'string' || !body.domain) {
86
+ throw new TaskRunError(400, 'invalid_domain', 'domain is required')
87
+ }
88
+ if (typeof body.text !== 'string' || !body.text) {
89
+ throw new TaskRunError(400, 'text_required', 'text is required')
90
+ }
91
+ await checkTask({
92
+ domain: body.domain,
93
+ text: body.text,
94
+ checked: body.checked !== false,
95
+ })
96
+ res.json({ ok: true })
97
+ } catch (error) {
98
+ sendError(res, error)
99
+ }
100
+ })
101
+
102
+ tasksRouter.patch('/tasks/dispatch-cap', (req, res) => {
103
+ try {
104
+ const cap = Number((req.body ?? {}).capPerDay)
105
+ saveDispatchCap(cap)
106
+ res.json({ capPerDay: loadDispatchCap() })
107
+ } catch (error) {
108
+ sendError(res, error)
109
+ }
110
+ })
111
+
112
+ tasksRouter.get('/tasks', async (req, res) => {
113
+ try {
114
+ const column = typeof req.query.column === 'string' ? req.query.column : undefined
115
+ const rows = await listBoard(column)
116
+ res.json({ tasks: rows, workBadge: workBadgeCount(rows), gate: tasksGate() })
117
+ } catch (error) {
118
+ sendError(res, error)
119
+ }
120
+ })
121
+
122
+ tasksRouter.patch('/tasks/:id', async (req, res) => {
123
+ try {
124
+ const body = req.body ?? {}
125
+ const domain = requireDomain(body.domain)
126
+ if ('runAt' in body) {
127
+ await setTaskRunAt(domain, req.params.id, body.runAt == null || body.runAt === '' ? null : String(body.runAt))
128
+ } else if ('section' in body) {
129
+ await moveTask(domain, req.params.id, String(body.section))
130
+ } else if ('checked' in body) {
131
+ await checkTask({ domain, id: req.params.id, checked: body.checked === true })
132
+ } else {
133
+ throw new TaskRunError(400, 'invalid_patch', 'Expected runAt, section, or checked.')
134
+ }
135
+ res.json({ ok: true })
136
+ } catch (error) {
137
+ sendError(res, error)
138
+ }
139
+ })
140
+
141
+ tasksRouter.post('/tasks/:id/run', async (req, res) => {
142
+ try {
143
+ const domain = requireDomain((req.body ?? {}).domain)
144
+ const result = await runTaskNow(req.params.id, domain)
145
+ res.status(202).json(result)
146
+ } catch (error) {
147
+ if (error instanceof TaskRunError && error.code === 'dispatch_slots_busy') {
148
+ res.setHeader('Retry-After', '30')
149
+ }
150
+ sendError(res, error)
151
+ }
152
+ })