@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.
- package/CHANGELOG.md +38 -0
- package/docs/work-intake.md +2 -0
- package/docs/work-task-runtime.md +11 -0
- package/package.json +2 -1
- package/server/index.ts +7 -0
- package/server/lib/cos-operations-meetings.ts +175 -0
- package/server/lib/jev.ts +5 -3
- package/server/lib/meeting-context-keys.ts +135 -0
- package/server/lib/meeting-store.ts +22 -0
- package/server/lib/task-dates.ts +369 -0
- package/server/lib/task-store.ts +30 -3
- package/server/lib/work-search.ts +226 -0
- package/server/routes/jev.ts +6 -1
- package/server/routes/meetings.ts +57 -6
- package/server/routes/tasks.ts +3 -1
- package/server/routes/work-board.ts +7 -2
- package/server/routes/work-search.ts +72 -0
package/server/routes/jev.ts
CHANGED
|
@@ -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),
|
|
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(
|
|
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(
|
|
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
|
}
|
package/server/routes/tasks.ts
CHANGED
|
@@ -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
|
-
|
|
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([
|
|
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
|
+
}
|