@gotcos/glasses-server 6.63.1 → 6.65.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.
@@ -12,6 +12,7 @@ import { SessionRecommender, parseCandidates } from '../lib/session-recommendati
12
12
  import { CompletionChecker, readRepliesSince } from '../lib/work-completion.js'
13
13
  import { isSafeSessionId, type AgentProvider } from '../lib/agent-session-store.js'
14
14
  import { listBoard } from '../lib/task-store.js'
15
+ import { sharedWorkSearchJevClient } from '../lib/work-search.js'
15
16
  import { isSafeDomainName } from '../lib/domains.js'
16
17
 
17
18
  /** What the route reads from a meeting review (6.57.1): the server's own record, never client text. */
@@ -30,6 +31,8 @@ export interface JevRouteDependencies {
30
31
  /** 6.58.0: a session's replies since a time (or its newest reply) from this server's transcripts; null when the
31
32
  * session is not on this Mac. */
32
33
  replies: (provider: AgentProvider, sessionId: string, afterMs: number | null) => Promise<string | null>
34
+ /** 6.65.0: Work search keeps its own client and breaker; a newly saved key clears that breaker too. */
35
+ searchJev: Pick<JevClient, 'resetForNewKey'>
33
36
  }
34
37
 
35
38
  /** Work identity first: it survives a rename, while a new card's id could equal an old identity's text hash. */
@@ -40,7 +43,8 @@ function findTask<T extends { id: string; workIdentity?: string }>(rows: T[], id
40
43
  export function createJevRouter(overrides: Partial<JevRouteDependencies> = {}): Router {
41
44
  const jev = overrides.jev ?? sharedJevClient()
42
45
  const deps: JevRouteDependencies = { jev, recommender: new SessionRecommender(jev), list: listBoard, validate: validateJevKey, save: saveJevKey,
43
- completion: new CompletionChecker(jev), replies: (provider, sessionId, afterMs) => readRepliesSince(provider, sessionId, afterMs), ...overrides }
46
+ completion: new CompletionChecker(jev), replies: (provider, sessionId, afterMs) => readRepliesSince(provider, sessionId, afterMs),
47
+ searchJev: sharedWorkSearchJevClient(), ...overrides }
44
48
  const router = Router()
45
49
  router.use(['/jev-key', '/work-board/session-recommendation', '/work-board/completion-check'], (_req, res, next) => { res.set('Cache-Control', 'private, no-store'); next() })
46
50
 
@@ -61,6 +65,7 @@ export function createJevRouter(overrides: Partial<JevRouteDependencies> = {}):
61
65
  return res.status(500).json({ error: { code: 'jev_key_not_saved', message: 'The key was valid but could not be saved. Check the server data folder.' } })
62
66
  }
63
67
  deps.jev.resetForNewKey()
68
+ deps.searchJev.resetForNewKey()
64
69
  return res.json({ ok: true, ...deps.jev.status() })
65
70
  })
66
71
 
@@ -10,11 +10,13 @@ import {
10
10
  listDirectLibraryMeetingMonths,
11
11
  listCosOperationsMeetings,
12
12
  listCosOperationsMeetingMonths,
13
+ meetingContextForRecord,
13
14
  resolveMeetingLibrary,
14
15
  } from '../lib/cos-operations-meetings.js'
15
16
  import type { MeetingMeta, MeetingDetail } from '../lib/meeting-store.js'
16
17
  import { meetingListLimit } from '../lib/meeting-store.js'
17
18
  import { searchMeetingLibrary } from '../lib/meeting-library-search.js'
19
+ import { meetingContextFields, meetingContextKeys, readOnlyContext } from '../lib/meeting-context-keys.js'
18
20
  import { g2RecordingsReachOperations } from '../lib/g2-ops-handoff.js'
19
21
  import {
20
22
  type ImportedMeetingLibrary,
@@ -57,6 +59,19 @@ function withStandaloneIdentity(meeting: MeetingMeta): MeetingMeta {
57
59
  }
58
60
  }
59
61
 
62
+ /**
63
+ * 6.64.0 context keys for a row or detail the operations tree did not key. A standalone recording is keyed by its own
64
+ * session (it is a capture this server holds); a direct library, an import and a re-derived record are read-only here.
65
+ * A row the operations tree keyed already carries its fields and is left alone.
66
+ */
67
+ export function withMeetingContext<T extends MeetingMeta>(meeting: T, source: MeetingMeta['librarySource'] = meeting.librarySource): T {
68
+ if (meeting.contextKeys !== undefined) return meeting
69
+ if (source === 'standalone_recordings') {
70
+ return { ...meeting, ...meetingContextFields(meetingContextKeys({ g2SessionId: meeting.sessionId })) }
71
+ }
72
+ return { ...meeting, ...readOnlyContext() }
73
+ }
74
+
60
75
  function mergeMeetingSources(groups: MeetingMeta[][], limit: number, max = 50): MeetingMeta[] {
61
76
  const seenSessions = new Set<string>()
62
77
  const seenExact = new Set<string>()
@@ -187,6 +202,22 @@ export function resolveSavedMeetingDetail(
187
202
  return { ...detail, recordId: withStandaloneIdentity(detail).recordId }
188
203
  }
189
204
 
205
+ /** The detail the routes answer with: `resolveSavedMeetingDetail` plus its 6.64.0 context keys. */
206
+ function savedMeetingDetailWithContext(
207
+ descriptor: { domain: string; month: string; filename: string },
208
+ store: MeetingStore,
209
+ importsLibrary: ImportedMeetingLibrary,
210
+ ): MeetingDetail {
211
+ const detail = resolveSavedMeetingDetail(descriptor, store, importsLibrary)
212
+ // The store's detail carries no librarySource (a pinned contract); the identity above made its recordId `standalone:`.
213
+ const source = detail.librarySource ?? (detail.recordId?.startsWith('standalone:') ? 'standalone_recordings' : undefined)
214
+ if (source === 'standalone_recordings' && detail.contextKeys === undefined) {
215
+ const sessionId = detail.sessionId || store.storedSessionId(descriptor.month, detail.filename || descriptor.filename)
216
+ return { ...detail, ...meetingContextFields(meetingContextKeys({ g2SessionId: sessionId })) }
217
+ }
218
+ return withMeetingContext(detail, source)
219
+ }
220
+
190
221
  export function createMeetingsRouter(
191
222
  store: MeetingStore = getMeetingStore(),
192
223
  // Injectable so a test can drive a whole imports library without reaching the
@@ -255,7 +286,7 @@ export function createMeetingsRouter(
255
286
  ? supersededDayCounts(filters.month, 'direct', { domain, store, library: importsLibrary })
256
287
  : []
257
288
  res.json({
258
- meetings,
289
+ meetings: meetings.map(meeting => withMeetingContext(meeting)),
259
290
  months,
260
291
  days,
261
292
  source: operations.length > 0 || imported.present ? 'mixed_library' : 'direct_library',
@@ -288,7 +319,7 @@ export function createMeetingsRouter(
288
319
  // back above the row. A capped page pays for the scan instead.
289
320
  const listSawWholeMonth = rows.length < limit
290
321
  res.json({
291
- meetings,
322
+ meetings: meetings.map(meeting => withMeetingContext(meeting)),
292
323
  months: listCosOperationsMeetingMonths(domain),
293
324
  // The rows above already read every scribe in this month and told us which
294
325
  // sessions the merged ones hold. Handing that over is the difference between
@@ -329,7 +360,7 @@ export function createMeetingsRouter(
329
360
  ? supersededDayCounts(filters.month, 'multi_domain', { domain, store, library: importsLibrary, pipeline: false })
330
361
  : []
331
362
  res.json({
332
- meetings,
363
+ meetings: meetings.map(meeting => withMeetingContext(meeting)),
333
364
  months: uniqueSortedMonths([
334
365
  listCosOperationsMeetingMonths(domain),
335
366
  store.listMonths(),
@@ -359,7 +390,7 @@ export function createMeetingsRouter(
359
390
  imported.drop(imported.imports),
360
391
  ], limit, sourceLimit)
361
392
  res.json({
362
- meetings,
393
+ meetings: meetings.map(meeting => withMeetingContext(meeting)),
363
394
  months: uniqueSortedMonths([store.listMonths(), importedLibraryMonths(importsLibrary)]),
364
395
  days: filters.month ? supersededDayCounts(filters.month, 'standalone', { domain, store, library: importsLibrary }) : [],
365
396
  source: imported.present ? 'mixed_library' : 'standalone_recordings',
@@ -371,6 +402,26 @@ export function createMeetingsRouter(
371
402
  }
372
403
  })
373
404
 
405
+ // 6.64.0: the context keys behind a Work card's saved meeting links, by record id (at most 50 per call). Read-only and
406
+ // body-free: COS Control asks for every link a card carries, so a card finds the files dropped on its meetings
407
+ // whichever record id it was linked under (`meetingContextForRecord`).
408
+ router.get('/meetings/context-keys', (req, res) => {
409
+ const raw = req.query.recordId
410
+ const ids = (Array.isArray(raw) ? raw : raw === undefined ? [] : [raw]).filter((id): id is string => typeof id === 'string')
411
+ if (ids.length === 0 || ids.length > 50 || ids.some(id => id.length === 0 || id.length > 2048 || /[\u0000-\u001f]/.test(id))) {
412
+ res.status(400).json({ error: 'one to fifty recordId values are required', reason: 'invalid_record_ids' })
413
+ return
414
+ }
415
+ res.set('Cache-Control', 'private, no-store')
416
+ try {
417
+ const keys: Record<string, ReturnType<typeof meetingContextForRecord>> = {}
418
+ for (const id of [...new Set(ids)]) keys[id] = meetingContextForRecord(id)
419
+ res.json({ keys })
420
+ } catch (error) {
421
+ sendMeetingStoreError(res, error)
422
+ }
423
+ })
424
+
374
425
  // Literal path before /meetings/:domain/:month/:filename.
375
426
  router.get('/meetings/search', async (req, res) => {
376
427
  try {
@@ -406,7 +457,7 @@ export function createMeetingsRouter(
406
457
  }
407
458
  res.set('Cache-Control', 'private, no-store')
408
459
 
409
- res.json(resolveSavedMeetingDetail({ domain, month, filename }, store, importsLibrary))
460
+ res.json(savedMeetingDetailWithContext({ domain, month, filename }, store, importsLibrary))
410
461
  } catch (error) {
411
462
  sendMeetingStoreError(res, error)
412
463
  }
@@ -418,7 +469,7 @@ export function createMeetingsRouter(
418
469
  try {
419
470
  res.set('Cache-Control', 'private, no-store')
420
471
 
421
- res.json(resolveSavedMeetingDetail({ domain: String(req.params.domain), month: String(req.params.month), filename: String(req.params.filename) }, store, importsLibrary))
472
+ res.json(savedMeetingDetailWithContext({ domain: String(req.params.domain), month: String(req.params.month), filename: String(req.params.filename) }, store, importsLibrary))
422
473
  } catch (error) {
423
474
  sendMeetingStoreError(res, error)
424
475
  }
@@ -10,6 +10,7 @@ import {
10
10
  captureTask,
11
11
  checkTask,
12
12
  listBoard,
13
+ listBoardWithDates,
13
14
  listProjectedTaskRuns,
14
15
  loadDispatchCap,
15
16
  moveTask,
@@ -175,7 +176,8 @@ tasksRouter.put('/domains', (req, res) => {
175
176
  tasksRouter.get('/tasks', async (req, res) => {
176
177
  try {
177
178
  const column = typeof req.query.column === 'string' ? req.query.column : undefined
178
- const rows = await listBoard(column)
179
+ // 6.65.0: rows carry createdOn, createdFrom and lineChangedAt (null when unknown), as GET /api/work-board rows do.
180
+ const rows = await listBoardWithDates(column)
179
181
  res.json({ tasks: rows, workBadge: workBadgeCount(rows), gate: tasksGate() })
180
182
  } catch (error) {
181
183
  sendError(res, error)
@@ -1,7 +1,7 @@
1
1
  import { Router } from 'express'
2
2
  import { readWorkJournal, projectWorkActivity, projectOpenWork, savedDestinationFor, controlSnapshotRevision,
3
3
  type WorkActivityCapabilities, type WorkActivityResult } from '../lib/work-activity.js'
4
- import { listBoard, workBoardCapabilities, setTaskWorkStage, linkTaskMeeting, editWorkTask, TaskRunError, TaskBridgeError,
4
+ import { listBoard, listBoardWithDates, workBoardCapabilities, setTaskWorkStage, linkTaskMeeting, editWorkTask, TaskRunError, TaskBridgeError,
5
5
  WORK_STAGES, type WorkStage, type WorkMeetingRef, type TaskBoardRow } from '../lib/task-store.js'
6
6
  import { createWorkBoardReader, WORK_BOARD_READ_LIMITS, WorkBoardTimeoutError, type WorkBoardReader } from '../lib/work-board-reader.js'
7
7
  import type { MeetingDescriptor } from '../lib/work-review-store.js'
@@ -13,6 +13,10 @@ export interface WorkBoardDependencies {
13
13
  /** The native journal reader. It takes no request-derived argument: there is no request-supplied path. */
14
14
  journal: () => ReturnType<typeof readWorkJournal>
15
15
  list: typeof listBoard
16
+ /** 6.65.0: the rows GET /api/work-board serves, with createdOn, createdFrom and lineChangedAt (task-store.ts
17
+ * listBoardWithDates). COS Control reads this route for its Work board and reads /api/tasks only from an older
18
+ * server, so the dates must be here too. Defaults to the dated read unless a caller supplies its own `list`. */
19
+ listDated?: () => Promise<TaskBoardRow[]>
16
20
  capabilities: typeof workBoardCapabilities
17
21
  stage: typeof setTaskWorkStage
18
22
  link: typeof linkTaskMeeting
@@ -33,6 +37,7 @@ export const WORK_GLANCE_BOARD_AGE_MS = 10_000
33
37
  /** Auth is supplied by the parent /api mount. No provider execution occurs here. */
34
38
  export function createWorkBoardRouter(overrides: Partial<WorkBoardDependencies> = {}): Router {
35
39
  const deps = { ...defaults, ...overrides }, router = Router()
40
+ const listForBoard = overrides.listDated ?? (overrides.list ? deps.list : () => listBoardWithDates())
36
41
  const board = deps.readBoard ?? createWorkBoardReader(() => deps.list())
37
42
  router.use('/work-board', (_req, res, next) => { res.set('Cache-Control', 'private, no-store'); next() })
38
43
  const fail = (res: import('express').Response, e: unknown) => {
@@ -80,7 +85,7 @@ export function createWorkBoardRouter(overrides: Partial<WorkBoardDependencies>
80
85
  router.get('/work-board', async (_req, res) => {
81
86
  try {
82
87
  const read = board.begin()
83
- const [raw, capabilities] = await Promise.all([deps.list(), deps.capabilities()])
88
+ const [raw, capabilities] = await Promise.all([listForBoard(), deps.capabilities()])
84
89
  board.prime(raw, read)
85
90
  // 6.59.0: taskRevision is this task's own revision (COS Control's taskSnapshot), what a handoff request names.
86
91
  const tasks = raw.map(row => ({ ...row, workStage: row.checked ? 'complete' : row.workStage ?? (row.stage === 'active' ? 'draft' : row.stage === 'review' ? 'qa' : 'planned'), workIdentity: row.workIdentity || row.id, meetingRefs: row.meetingRefs ?? [], taskRevision: controlSnapshotRevision(row) }))
@@ -0,0 +1,72 @@
1
+ /**
2
+ * POST /api/work/search (6.65.0): which board card the user means, by meaning. Auth comes from the parent /api mount.
3
+ *
4
+ * Body `{ query, domain?, scope?, ids? }`. The cards come from the board read /api/tasks serves; the client sends a
5
+ * view (domain, scope) or exact card ids, never task text. A search that cannot run answers 200 with
6
+ * `{ available: false, reason, message }`, so Control keeps its word search. See lib/work-search.ts.
7
+ */
8
+ import { Router } from 'express'
9
+ import { listBoard } from '../lib/task-store.js'
10
+ import { isSafeDomainName } from '../lib/domains.js'
11
+ import { queryLength, sharedWorkSearchJevClient, selectCandidates, unavailable, workSearchEnabled, WorkSearcher, WORK_SEARCH_LIMITS,
12
+ WORK_SEARCH_SCOPES, type WorkSearchResult, type WorkSearchScope } from '../lib/work-search.js'
13
+
14
+ export interface WorkSearchRouteDependencies {
15
+ searcher: Pick<WorkSearcher, 'search'>
16
+ list: () => ReturnType<typeof listBoard>
17
+ enabled: () => boolean
18
+ }
19
+
20
+ const BODY_KEYS = new Set(['query', 'domain', 'scope', 'ids'])
21
+ const TASK_ID = /^[a-f0-9]{12}$/
22
+ /** One log line per degrade reason per 10 minutes: Control falls back quietly, so the server says why. */
23
+ export const WORK_SEARCH_LOG_EVERY_MS = 10 * 60_000
24
+
25
+ export function createWorkSearchRouter(overrides: Partial<WorkSearchRouteDependencies> = {}): Router {
26
+ const deps: WorkSearchRouteDependencies = { searcher: new WorkSearcher(sharedWorkSearchJevClient()), list: () => listBoard(),
27
+ enabled: () => workSearchEnabled(), ...overrides }
28
+ const router = Router()
29
+ const logged = new Map<string, number>()
30
+ const answer = (res: import('express').Response, result: WorkSearchResult) => {
31
+ if (!result.available) {
32
+ const at = Date.now(), last = logged.get(result.reason)
33
+ if (last === undefined || at - last >= WORK_SEARCH_LOG_EVERY_MS) {
34
+ logged.set(result.reason, at)
35
+ console.warn(`[work-search] meaning search unavailable: ${result.reason}`)
36
+ }
37
+ }
38
+ return res.json(result)
39
+ }
40
+ router.use('/work/search', (_req, res, next) => { res.set('Cache-Control', 'private, no-store'); next() })
41
+
42
+ router.post('/work/search', async (req, res) => {
43
+ const body = req.body
44
+ const bad = () => res.status(400).json({ error: { code: 'invalid_search_request',
45
+ message: `Send a query of ${WORK_SEARCH_LIMITS.queryMin} to ${WORK_SEARCH_LIMITS.queryMax} characters, and optionally a domain, a scope or card ids.` } })
46
+ if (!body || typeof body !== 'object' || Array.isArray(body) || Object.keys(body).some(k => !BODY_KEYS.has(k))) return bad()
47
+ const query = typeof body.query === 'string' ? body.query.trim() : ''
48
+ const length = queryLength(query)
49
+ if (length < WORK_SEARCH_LIMITS.queryMin || length > WORK_SEARCH_LIMITS.queryMax) return bad()
50
+ if (body.domain !== undefined && (typeof body.domain !== 'string' || !isSafeDomainName(body.domain))) return bad()
51
+ if (body.scope !== undefined && !(WORK_SEARCH_SCOPES as readonly unknown[]).includes(body.scope)) return bad()
52
+ if (body.ids !== undefined && (!Array.isArray(body.ids) || body.ids.some((id: unknown) => typeof id !== 'string' || !TASK_ID.test(id)))) return bad()
53
+ if (!deps.enabled()) return answer(res, unavailable('search_off'))
54
+ const ids: string[] | undefined = body.ids ? [...new Set<string>(body.ids)] : undefined
55
+ // Refused before reading the board: a Choice cannot hold them, and a cut list would silently miss cards.
56
+ if (ids && ids.length > WORK_SEARCH_LIMITS.maxCandidates) return answer(res, unavailable('too_many_candidates'))
57
+ let rows: Awaited<ReturnType<typeof listBoard>>
58
+ try { rows = await deps.list() } catch (e) {
59
+ console.error('[work-search] board read failed:', e instanceof Error ? e.message : e)
60
+ return res.status(503).json({ error: { code: 'work_board_unavailable', message: 'Work board is unavailable. Refresh before searching.' } })
61
+ }
62
+ const candidates = selectCandidates(rows, { domain: body.domain, scope: body.scope as WorkSearchScope | undefined, ids })
63
+ if (!candidates) return answer(res, unavailable('too_many_candidates'))
64
+ try {
65
+ return answer(res, await deps.searcher.search(query, candidates))
66
+ } catch (e) {
67
+ console.error('[work-search] search failed:', e instanceof Error ? e.message : e)
68
+ return answer(res, unavailable('jev_unavailable'))
69
+ }
70
+ })
71
+ return router
72
+ }