@gotcos/glasses-server 6.46.1 → 6.48.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 +25 -0
- package/README.md +24 -0
- package/bin/cli.cjs +22 -0
- package/bin/hooks/cos-session-hook +43 -0
- package/managed-runtime-contract.json +7 -1
- package/package.json +8 -2
- package/server/index.ts +85 -0
- package/server/lib/claude-hooks-installer.ts +403 -0
- package/server/lib/claude-session-registry.ts +25 -0
- package/server/lib/cos-operations-meetings.ts +99 -8
- package/server/lib/fireflies-client.ts +862 -0
- package/server/lib/fireflies-key.ts +182 -0
- package/server/lib/imported-library-rows.ts +616 -0
- package/server/lib/imported-meeting-library.ts +608 -0
- package/server/lib/maintenance-lifecycle.ts +14 -0
- package/server/lib/meeting-actions-store.ts +478 -0
- package/server/lib/meeting-actions.ts +2583 -0
- package/server/lib/meeting-corrections.ts +32 -1
- package/server/lib/meeting-decisions.ts +223 -0
- package/server/lib/meeting-engine/align.ts +167 -0
- package/server/lib/meeting-engine/attribute.ts +265 -0
- package/server/lib/meeting-engine/evidence.ts +428 -0
- package/server/lib/meeting-engine/pairing.ts +327 -0
- package/server/lib/meeting-engine/render.ts +694 -0
- package/server/lib/meeting-engine/split.ts +242 -0
- package/server/lib/meeting-engine/worker.ts +238 -0
- package/server/lib/meeting-engine-mode.ts +197 -0
- package/server/lib/meeting-file-guards.ts +141 -0
- package/server/lib/meeting-import.ts +763 -0
- package/server/lib/meeting-library-search.ts +146 -11
- package/server/lib/meeting-parse.ts +184 -0
- package/server/lib/meeting-store.ts +108 -275
- package/server/lib/meeting-suggestion-sides.ts +242 -0
- package/server/lib/morning-brief-runtime.ts +20 -8
- package/server/lib/pipeline-runner.ts +227 -0
- package/server/lib/session-hook-events.ts +200 -0
- package/server/lib/session-hook-ledger.ts +129 -0
- package/server/lib/session-hook-spool.ts +264 -0
- package/server/lib/session-hooks-runtime.ts +229 -0
- package/server/lib/session-signal-store.ts +361 -0
- package/server/lib/session-state-derive.ts +211 -0
- package/server/lib/voice-evidence-guard.ts +87 -0
- package/server/routes/agent-sessions.ts +56 -6
- package/server/routes/claude-sessions.ts +32 -5
- package/server/routes/fireflies-key.ts +102 -0
- package/server/routes/health.ts +2 -0
- package/server/routes/meeting-actions.ts +82 -0
- package/server/routes/meeting-engine.ts +52 -0
- package/server/routes/meeting-import.ts +67 -0
- package/server/routes/meeting-suggestions.ts +66 -0
- package/server/routes/meeting.ts +117 -10
- package/server/routes/meetings.ts +177 -50
- package/server/routes/session-hooks.ts +70 -0
- package/server/routes/voice.ts +18 -0
- package/server/scripts/hooks-cli.ts +48 -0
- package/server/lib/__fixtures__/query-jobs-6.43.3/2099-01-01.jsonl +0 -2
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
// What the engine did, and how to undo it (6.47.0, WS4).
|
|
2
|
+
//
|
|
3
|
+
// GET /api/meeting-actions?limit=
|
|
4
|
+
// GET /api/meeting-actions/:id one action
|
|
5
|
+
// POST /api/meeting-actions/:id/revert { dryRun } or { previewHash }
|
|
6
|
+
// POST /api/meeting-actions/:id/retry a failed action, in its own direction
|
|
7
|
+
// POST /api/meeting-actions/revert-all { dryRun } or { previewHash }
|
|
8
|
+
//
|
|
9
|
+
// TWO CALLS, ALWAYS. A revert asks for the preview first and gets a `previewHash` covering
|
|
10
|
+
// the outputs' CURRENT bytes; sending it back is what proves the person is undoing the thing
|
|
11
|
+
// they were shown. This follows the held-groups enroll gate in `voice.ts`, and for the same
|
|
12
|
+
// reason: the blast radius belongs in the preview, not in an apology afterwards.
|
|
13
|
+
//
|
|
14
|
+
// RETRY EXISTS BECAUSE FAILURE WAS TERMINAL. An action that used its one automatic retry
|
|
15
|
+
// stayed failed until the server restarted, and even a restart only re-drove WAITING rows.
|
|
16
|
+
// Control's Retry button reloaded the list and nothing else. This is the route it needed.
|
|
17
|
+
//
|
|
18
|
+
// `revert-all` is declared BEFORE `/:id/revert`. Express matches in order, and without that
|
|
19
|
+
// ordering `revert-all` is read as an action whose id is the literal string "revert-all".
|
|
20
|
+
|
|
21
|
+
import { Router } from 'express'
|
|
22
|
+
import { getMeetingMergeRunner, type MeetingMergeRunner } from '../lib/meeting-actions.js'
|
|
23
|
+
import { actionRefusal } from './meeting-suggestions.js'
|
|
24
|
+
|
|
25
|
+
export interface MeetingActionsRouterDeps {
|
|
26
|
+
runner: () => MeetingMergeRunner
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function createMeetingActionsRouter(deps: MeetingActionsRouterDeps): Router {
|
|
30
|
+
const router = Router()
|
|
31
|
+
|
|
32
|
+
router.get('/meeting-actions', (req, res) => {
|
|
33
|
+
try {
|
|
34
|
+
const raw = Number(req.query.limit)
|
|
35
|
+
const limit = Number.isFinite(raw) && raw > 0 ? Math.min(Math.trunc(raw), 1_000) : 100
|
|
36
|
+
res.set('Cache-Control', 'private, no-store')
|
|
37
|
+
res.json({ actions: deps.runner().listActions(limit) })
|
|
38
|
+
} catch (error) {
|
|
39
|
+
actionRefusal(error, res, 'meeting_actions_unavailable', 'The action log could not be read.')
|
|
40
|
+
}
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
router.get('/meeting-actions/:id', (req, res) => {
|
|
44
|
+
try {
|
|
45
|
+
res.set('Cache-Control', 'private, no-store')
|
|
46
|
+
res.json({ action: deps.runner().getAction(String(req.params.id)) })
|
|
47
|
+
} catch (error) {
|
|
48
|
+
actionRefusal(error, res, 'meeting_action_unavailable', 'That action could not be read.')
|
|
49
|
+
}
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
router.post('/meeting-actions/revert-all', (req, res) => {
|
|
53
|
+
deps.runner().revertAll({ dryRun: req.body?.dryRun === true, previewHash: asHash(req.body?.previewHash) })
|
|
54
|
+
.then(result => res.json(result))
|
|
55
|
+
.catch(error => actionRefusal(error, res, 'meeting_actions_revert_all_failed', 'Those merges could not be undone.'))
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
router.post('/meeting-actions/:id/revert', (req, res) => {
|
|
59
|
+
deps.runner().revert(String(req.params.id), {
|
|
60
|
+
dryRun: req.body?.dryRun === true,
|
|
61
|
+
previewHash: asHash(req.body?.previewHash),
|
|
62
|
+
})
|
|
63
|
+
.then(result => res.json(result))
|
|
64
|
+
.catch(error => actionRefusal(error, res, 'meeting_action_revert_failed', 'That merge could not be undone.'))
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
router.post('/meeting-actions/:id/retry', (req, res) => {
|
|
68
|
+
// Every refusal arrives as a rejection: `retryAction` is async, so a drain, a run in
|
|
69
|
+
// flight and a wrong state all land in the same handler as a real failure.
|
|
70
|
+
deps.runner().retryAction(String(req.params.id))
|
|
71
|
+
.then(result => res.json(result))
|
|
72
|
+
.catch(error => actionRefusal(error, res, 'meeting_action_retry_failed', 'That one could not be tried again.'))
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
return router
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function asHash(value: unknown): string | undefined {
|
|
79
|
+
return typeof value === 'string' && /^[0-9a-f]{64}$/.test(value) ? value : undefined
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export const meetingActionsRouter = createMeetingActionsRouter({ runner: getMeetingMergeRunner })
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
// The engine's own state, and the one switch a person may throw (6.47.0, WS4).
|
|
2
|
+
//
|
|
3
|
+
// GET /api/meeting-engine/status mode, what the pipeline sees, mismatch, runs, counts
|
|
4
|
+
// POST /api/meeting-engine/mode { mode: 'advise' | 'apply' }
|
|
5
|
+
//
|
|
6
|
+
// `pipelineSees` IS THE POINT OF THE STATUS ROUTE. Four processes read the mode file, and
|
|
7
|
+
// the one failure a person cannot see for themselves is the server acting on `apply` while
|
|
8
|
+
// the pipeline on the same Mac still believes it owns the blend. The server asks
|
|
9
|
+
// `sync_meetings.py --merge-engine-status` (at most once every five minutes) and reports
|
|
10
|
+
// both answers plus whether they disagree. When the pipeline cannot be asked, the field is
|
|
11
|
+
// null and `mismatch` is false: "not known" is not "disagrees".
|
|
12
|
+
//
|
|
13
|
+
// THE MODE ROUTE REFUSES ON A MAC WITH NO PIPELINE. There is nothing to choose there: the
|
|
14
|
+
// server is in imports mode and does the importing itself.
|
|
15
|
+
|
|
16
|
+
import { Router } from 'express'
|
|
17
|
+
import { getMeetingMergeRunner, type MeetingMergeRunner } from '../lib/meeting-actions.js'
|
|
18
|
+
import type { MeetingEnginePipelineMode } from '../lib/meeting-engine-mode.js'
|
|
19
|
+
import { actionRefusal } from './meeting-suggestions.js'
|
|
20
|
+
|
|
21
|
+
export interface MeetingEngineRouterDeps {
|
|
22
|
+
runner: () => MeetingMergeRunner
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function createMeetingEngineRouter(deps: MeetingEngineRouterDeps): Router {
|
|
26
|
+
const router = Router()
|
|
27
|
+
|
|
28
|
+
router.get('/meeting-engine/status', (_req, res) => {
|
|
29
|
+
deps.runner().status()
|
|
30
|
+
.then(status => {
|
|
31
|
+
res.set('Cache-Control', 'private, no-store')
|
|
32
|
+
res.json(status)
|
|
33
|
+
})
|
|
34
|
+
.catch(error => actionRefusal(error, res, 'meeting_engine_status_unavailable', 'The engine status could not be read.'))
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
router.post('/meeting-engine/mode', (req, res) => {
|
|
38
|
+
try {
|
|
39
|
+
// `setMode` refuses synchronously (wrong Mac, bad value, work in flight) and only then
|
|
40
|
+
// returns a promise, so both paths have to be caught or a refusal becomes a 500.
|
|
41
|
+
deps.runner().setMode(req.body?.mode as MeetingEnginePipelineMode)
|
|
42
|
+
.then(result => res.json(result))
|
|
43
|
+
.catch(error => actionRefusal(error, res, 'meeting_engine_mode_failed', 'The mode could not be changed.'))
|
|
44
|
+
} catch (error) {
|
|
45
|
+
actionRefusal(error, res, 'meeting_engine_mode_failed', 'The mode could not be changed.')
|
|
46
|
+
}
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
return router
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export const meetingEngineRouter = createMeetingEngineRouter({ runner: getMeetingMergeRunner })
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
// Meeting import endpoints.
|
|
2
|
+
//
|
|
3
|
+
// POST /api/meeting-import/fireflies/run { windowDays } -> 202 { runId }
|
|
4
|
+
// GET /api/meeting-import/fireflies/status mode, state, counts, budget
|
|
5
|
+
// POST /api/meeting-import/fireflies/settings { keepImporting, planCap }
|
|
6
|
+
//
|
|
7
|
+
// The run route answers as soon as the run is admitted. A backfill takes
|
|
8
|
+
// minutes and holds a maintenance lease per page, so an HTTP request that
|
|
9
|
+
// waited for it would sit far past COS Control's drain timeout; status is how a
|
|
10
|
+
// surface follows it.
|
|
11
|
+
//
|
|
12
|
+
// Every refusal is a code a surface can render: operations_pipeline_owns_fireflies
|
|
13
|
+
// (this Mac's pipeline owns Fireflies), import_in_progress, fireflies_key_missing,
|
|
14
|
+
// invalid_key, invalid_window, maintenance_drain_active.
|
|
15
|
+
|
|
16
|
+
import { Router } from 'express'
|
|
17
|
+
import { ImportRefusedError, getFirefliesImporter, type FirefliesImporter } from '../lib/meeting-import.js'
|
|
18
|
+
|
|
19
|
+
export interface MeetingImportRouterDeps {
|
|
20
|
+
importer: () => FirefliesImporter
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function createMeetingImportRouter(deps: MeetingImportRouterDeps): Router {
|
|
24
|
+
const router = Router()
|
|
25
|
+
|
|
26
|
+
const refusal = (error: unknown, res: import('express').Response, fallback: string): void => {
|
|
27
|
+
if (error instanceof ImportRefusedError) {
|
|
28
|
+
res.status(error.status).json({ error: { code: error.code, message: error.message } })
|
|
29
|
+
return
|
|
30
|
+
}
|
|
31
|
+
console.error(`[meeting-import] ${fallback}:`, error)
|
|
32
|
+
res.status(500).json({ error: { code: fallback, message: 'The import could not be started.' } })
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
router.post('/meeting-import/fireflies/run', (req, res) => {
|
|
36
|
+
try {
|
|
37
|
+
const started = deps.importer().run({ windowDays: req.body?.windowDays })
|
|
38
|
+
res.status(202).json({ accepted: true, runId: started.runId, status: deps.importer().status() })
|
|
39
|
+
} catch (error) {
|
|
40
|
+
refusal(error, res, 'meeting_import_run_failed')
|
|
41
|
+
}
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
router.get('/meeting-import/fireflies/status', (_req, res) => {
|
|
45
|
+
try {
|
|
46
|
+
res.json(deps.importer().status())
|
|
47
|
+
} catch (error) {
|
|
48
|
+
console.error('[meeting-import] status failed:', error)
|
|
49
|
+
res.status(500).json({ error: { code: 'meeting_import_unavailable', message: 'The import status could not be read.' } })
|
|
50
|
+
}
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
router.post('/meeting-import/fireflies/settings', (req, res) => {
|
|
54
|
+
try {
|
|
55
|
+
res.json(deps.importer().settings({
|
|
56
|
+
keepImporting: req.body?.keepImporting,
|
|
57
|
+
planCap: req.body?.planCap,
|
|
58
|
+
}))
|
|
59
|
+
} catch (error) {
|
|
60
|
+
refusal(error, res, 'meeting_import_settings_failed')
|
|
61
|
+
}
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
return router
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export const meetingImportRouter = createMeetingImportRouter({ importer: getFirefliesImporter })
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
// Suggestions: what the engine wants a person to decide (6.47.0, WS4).
|
|
2
|
+
//
|
|
3
|
+
// GET /api/meeting-suggestions?state=open|confirmed|accepted|dismissed|all
|
|
4
|
+
// POST /api/meeting-suggestions/:id/accept imports mode: make the merge
|
|
5
|
+
// POST /api/meeting-suggestions/:id/confirm advise mode: record the answer only
|
|
6
|
+
// POST /api/meeting-suggestions/:id/dismiss never these two meetings again
|
|
7
|
+
//
|
|
8
|
+
// ACCEPT AND CONFIRM ARE DIFFERENT VERBS BECAUSE THEY MEAN DIFFERENT THINGS. In imports
|
|
9
|
+
// mode "yes" writes a record. In advise mode the server may not write into the operations
|
|
10
|
+
// tree at all, so "yes" is remembered and acted on later, if and when a person switches to
|
|
11
|
+
// apply. One verb doing both would make the advise answer look like it did something.
|
|
12
|
+
//
|
|
13
|
+
// Every refusal is a code a surface renders: suggestion_not_found, suggestion_stale,
|
|
14
|
+
// suggestion_dismissed, advise_mode, maintenance_drain_active.
|
|
15
|
+
|
|
16
|
+
import { Router, type Response } from 'express'
|
|
17
|
+
import { ActionRefusedError, getMeetingMergeRunner, type MeetingMergeRunner } from '../lib/meeting-actions.js'
|
|
18
|
+
|
|
19
|
+
export interface MeetingSuggestionsRouterDeps {
|
|
20
|
+
runner: () => MeetingMergeRunner
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function actionRefusal(error: unknown, res: Response, fallback: string, message: string): void {
|
|
24
|
+
if (error instanceof ActionRefusedError) {
|
|
25
|
+
res.status(error.status).json({ error: { code: error.code, message: error.message } })
|
|
26
|
+
return
|
|
27
|
+
}
|
|
28
|
+
console.error(`[meeting-suggestions] ${fallback}:`, error)
|
|
29
|
+
res.status(500).json({ error: { code: fallback, message } })
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function createMeetingSuggestionsRouter(deps: MeetingSuggestionsRouterDeps): Router {
|
|
33
|
+
const router = Router()
|
|
34
|
+
|
|
35
|
+
router.get('/meeting-suggestions', (req, res) => {
|
|
36
|
+
try {
|
|
37
|
+
const state = typeof req.query.state === 'string' ? req.query.state : undefined
|
|
38
|
+
res.set('Cache-Control', 'private, no-store')
|
|
39
|
+
res.json({ suggestions: deps.runner().listSuggestions(state) })
|
|
40
|
+
} catch (error) {
|
|
41
|
+
actionRefusal(error, res, 'meeting_suggestions_unavailable', 'The suggestions could not be read.')
|
|
42
|
+
}
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
router.post('/meeting-suggestions/:id/accept', (req, res) => {
|
|
46
|
+
deps.runner().acceptSuggestion(String(req.params.id))
|
|
47
|
+
.then(result => res.json(result))
|
|
48
|
+
.catch(error => actionRefusal(error, res, 'meeting_suggestion_accept_failed', 'That merge could not be made.'))
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
router.post('/meeting-suggestions/:id/confirm', (req, res) => {
|
|
52
|
+
deps.runner().confirmSuggestion(String(req.params.id))
|
|
53
|
+
.then(result => res.json(result))
|
|
54
|
+
.catch(error => actionRefusal(error, res, 'meeting_suggestion_confirm_failed', 'That answer could not be saved.'))
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
router.post('/meeting-suggestions/:id/dismiss', (req, res) => {
|
|
58
|
+
deps.runner().dismissSuggestion(String(req.params.id))
|
|
59
|
+
.then(result => res.json(result))
|
|
60
|
+
.catch(error => actionRefusal(error, res, 'meeting_suggestion_dismiss_failed', 'That answer could not be saved.'))
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
return router
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export const meetingSuggestionsRouter = createMeetingSuggestionsRouter({ runner: getMeetingMergeRunner })
|
package/server/routes/meeting.ts
CHANGED
|
@@ -10,6 +10,7 @@ import { emitDisplay } from '../lib/display-bus.js'
|
|
|
10
10
|
import { cleanTranscriptLines } from '../lib/hallucination-filter.js'
|
|
11
11
|
import { durableAtomicWriteFileSync } from '../lib/atomic-fs.js'
|
|
12
12
|
import { appendCorrection, appliedCorrections, pendingCorrections } from '../lib/meeting-corrections.js'
|
|
13
|
+
import { triggerMeetingMergeRun } from '../lib/meeting-actions.js'
|
|
13
14
|
import { isSampleFromSession, untraceableSampleCount } from '../lib/training-audio-provenance.js'
|
|
14
15
|
import { sendAudioFile } from '../lib/send-audio.js'
|
|
15
16
|
import { adaptivePlaybackAudio } from '../lib/adaptive-playback-audio.js'
|
|
@@ -116,11 +117,14 @@ import {
|
|
|
116
117
|
} from './transcribe-stream.js'
|
|
117
118
|
import { getServerInstanceId } from '../lib/server-instance-id.js'
|
|
118
119
|
import {
|
|
120
|
+
type MeetingLibraryRecord,
|
|
119
121
|
cosOperationsMeetingsConfigured,
|
|
120
122
|
findDirectLibraryMeetingBySessionId,
|
|
121
123
|
findCosOperationsMeetingBySessionId,
|
|
122
124
|
resolveCosOperationsDir,
|
|
123
125
|
} from '../lib/cos-operations-meetings.js'
|
|
126
|
+
import { derivedSourcesFor, findDerivedRecord } from '../lib/imported-library-rows.js'
|
|
127
|
+
import { assertVoiceEvidenceSource } from '../lib/voice-evidence-guard.js'
|
|
124
128
|
import { domainForMeeting, resolveDomains } from '../lib/domains.js'
|
|
125
129
|
import { getOwnerSpeakerLabel } from '../lib/profile.js'
|
|
126
130
|
import {
|
|
@@ -329,6 +333,10 @@ function scheduleFinalizationJob(job: MeetingFinalizationJob, runtime: Finalizat
|
|
|
329
333
|
await enrichStandaloneMeeting(current.meetingPath, jobStartedAt)
|
|
330
334
|
}
|
|
331
335
|
markCanonicalFinalizationState(current.sidecarPath, 'complete', false)
|
|
336
|
+
// The capture is finished and its files are durable, so the merge engine may score it.
|
|
337
|
+
// Fire-and-forget by contract: the runner queues, defers under a drain or a live
|
|
338
|
+
// capture, and never throws back at the finalization path.
|
|
339
|
+
triggerMeetingMergeRun('g2_finalized')
|
|
332
340
|
runtime.finalizationJobs.remove(current.sessionId)
|
|
333
341
|
finalizationRetryCounts.delete(key)
|
|
334
342
|
}).catch(error => {
|
|
@@ -686,6 +694,7 @@ export function createMeetingRouter(deps: MeetingRouteDependencies = {}): Router
|
|
|
686
694
|
markCanonicalFinalizationState(saved.sidecarPath, finalizationJob.phase, claimPending)
|
|
687
695
|
} else if (finalizationJob) {
|
|
688
696
|
markCanonicalFinalizationState(saved.sidecarPath, 'complete', false)
|
|
697
|
+
triggerMeetingMergeRun('g2_finalized')
|
|
689
698
|
finalizationJobs.remove(sessionId)
|
|
690
699
|
finalizationJob = null
|
|
691
700
|
}
|
|
@@ -714,6 +723,71 @@ export function createMeetingRouter(deps: MeetingRouteDependencies = {}): Router
|
|
|
714
723
|
}
|
|
715
724
|
})
|
|
716
725
|
|
|
726
|
+
// ── Record identity for the speaker surfaces (6.47.0) ─────────────────
|
|
727
|
+
//
|
|
728
|
+
// One resolver, because the speakers and content routes each carried their own
|
|
729
|
+
// copy of the same three literals and the two had already drifted apart once.
|
|
730
|
+
// `source` and `mutable` always describe the CAPTURE — the file that holds the
|
|
731
|
+
// chunks and the one a correction would rewrite — even when the caller reached
|
|
732
|
+
// it through a merged record's row. `blendedRecordId` is what says "you opened
|
|
733
|
+
// the merged row", so Control can label the panel without pretending the merge
|
|
734
|
+
// is the thing being edited.
|
|
735
|
+
function speakerRecordIdentity(
|
|
736
|
+
sessionId: string,
|
|
737
|
+
operations: { domain: string; month: string; filename: string } | null,
|
|
738
|
+
direct: MeetingLibraryRecord | null,
|
|
739
|
+
blendedRecordId?: string,
|
|
740
|
+
): {
|
|
741
|
+
source: 'cos_operations' | 'direct_library' | 'standalone_recordings'
|
|
742
|
+
recordId: string
|
|
743
|
+
mutable: boolean
|
|
744
|
+
blendedRecordId?: string
|
|
745
|
+
} {
|
|
746
|
+
return {
|
|
747
|
+
source: operations ? 'cos_operations' : direct ? 'direct_library' : 'standalone_recordings',
|
|
748
|
+
recordId: operations
|
|
749
|
+
? `ops:${operations.domain}:${operations.month}:${operations.filename}`
|
|
750
|
+
: direct?.recordId ?? `standalone:${sessionId}`,
|
|
751
|
+
mutable: direct == null,
|
|
752
|
+
...(blendedRecordId ? { blendedRecordId } : {}),
|
|
753
|
+
}
|
|
754
|
+
}
|
|
755
|
+
|
|
756
|
+
/**
|
|
757
|
+
* `?recordId=blended:<h16>` on a read: the caller opened the merged row.
|
|
758
|
+
*
|
|
759
|
+
* Confirmed against the record's OWN inputs rather than trusted, so a wrong or
|
|
760
|
+
* stale id is simply ignored instead of labelling an unrelated capture as part
|
|
761
|
+
* of a merge. Any capture the merge holds is accepted, not only the earliest,
|
|
762
|
+
* because a merge of two captures has two ways in and both are legitimate.
|
|
763
|
+
*/
|
|
764
|
+
function blendedRecordFor(value: unknown, sessionId: string): string | undefined {
|
|
765
|
+
if (typeof value !== 'string' || !value.startsWith('blended:')) return undefined
|
|
766
|
+
const record = findDerivedRecord(value)
|
|
767
|
+
if (!record || record.kind !== 'merge') return undefined
|
|
768
|
+
return record.g2SessionIds.includes(sessionId) ? record.recordId : undefined
|
|
769
|
+
}
|
|
770
|
+
|
|
771
|
+
/**
|
|
772
|
+
* Refuse a mutation aimed at an imported or derived record.
|
|
773
|
+
*
|
|
774
|
+
* A derived record is a FUNCTION of its inputs: the next re-derive rewrites it,
|
|
775
|
+
* so an edit here would be silently discarded. The refusal names the capture to
|
|
776
|
+
* correct instead, which is the whole point of answering 409 rather than 404.
|
|
777
|
+
*/
|
|
778
|
+
function refuseDerivedMutation(recordId: unknown): { status: number; body: Record<string, unknown> } | null {
|
|
779
|
+
const refusal = assertVoiceEvidenceSource([recordId])
|
|
780
|
+
if (!refusal) return null
|
|
781
|
+
const record = typeof recordId === 'string' ? findDerivedRecord(recordId) : null
|
|
782
|
+
const sourceRecordId = record
|
|
783
|
+
? derivedSourcesFor(record, store).find(source => source.kind === 'g2')?.recordId
|
|
784
|
+
: undefined
|
|
785
|
+
return {
|
|
786
|
+
status: refusal.status,
|
|
787
|
+
body: { ...refusal.body, mutable: false, ...(sourceRecordId ? { sourceRecordId } : {}) },
|
|
788
|
+
}
|
|
789
|
+
}
|
|
790
|
+
|
|
717
791
|
// ── Speaker review (6.21.12) ──────────────────────────────────────────
|
|
718
792
|
// Backs COS Control's naming panel. Read-only: it reports what a saved
|
|
719
793
|
// meeting's sidecar already contains and never writes. Naming, merging, and
|
|
@@ -728,6 +802,15 @@ export function createMeetingRouter(deps: MeetingRouteDependencies = {}): Router
|
|
|
728
802
|
res.status(400).json({ error: 'Invalid sessionId', reason: 'invalid_session_id' })
|
|
729
803
|
return
|
|
730
804
|
}
|
|
805
|
+
// BEFORE the lookup. The sessionId pattern allows colons, so `blended:<h16>`
|
|
806
|
+
// and `imported:fireflies:<h16>` are shaped like sessions and would otherwise
|
|
807
|
+
// fall through to a store scan that answers 404 — the wrong answer, because
|
|
808
|
+
// the record exists and simply is not a capture.
|
|
809
|
+
const identityRefusal = assertVoiceEvidenceSource([sessionId])
|
|
810
|
+
if (identityRefusal) {
|
|
811
|
+
res.status(identityRefusal.status).json(identityRefusal.body)
|
|
812
|
+
return
|
|
813
|
+
}
|
|
731
814
|
// Prefer the COS operations copy when configured. The same session exists in
|
|
732
815
|
// both trees under different names — the standalone store keeps the raw
|
|
733
816
|
// capture name, operations holds the titled copy — and the meetings LIST
|
|
@@ -796,11 +879,7 @@ export function createMeetingRouter(deps: MeetingRouteDependencies = {}): Router
|
|
|
796
879
|
title,
|
|
797
880
|
domain,
|
|
798
881
|
filename,
|
|
799
|
-
|
|
800
|
-
recordId: operations
|
|
801
|
-
? `ops:${operations.domain}:${operations.month}:${operations.filename}`
|
|
802
|
-
: direct?.recordId ?? `standalone:${sessionId}`,
|
|
803
|
-
mutable: direct == null,
|
|
882
|
+
...speakerRecordIdentity(sessionId, operations, direct, blendedRecordFor(req.query.recordId, sessionId)),
|
|
804
883
|
...(saved ? { durationMin: saved.durationMin } : {}),
|
|
805
884
|
...review,
|
|
806
885
|
})
|
|
@@ -839,6 +918,11 @@ export function createMeetingRouter(deps: MeetingRouteDependencies = {}): Router
|
|
|
839
918
|
res.status(400).json({ error: 'Invalid sessionId', reason: 'invalid_session_id' })
|
|
840
919
|
return
|
|
841
920
|
}
|
|
921
|
+
const identityRefusal = assertVoiceEvidenceSource([sessionId])
|
|
922
|
+
if (identityRefusal) {
|
|
923
|
+
res.status(identityRefusal.status).json(identityRefusal.body)
|
|
924
|
+
return
|
|
925
|
+
}
|
|
842
926
|
const operations = cosOperationsMeetingsConfigured()
|
|
843
927
|
? findCosOperationsMeetingBySessionId(sessionId)
|
|
844
928
|
: null
|
|
@@ -975,11 +1059,7 @@ export function createMeetingRouter(deps: MeetingRouteDependencies = {}): Router
|
|
|
975
1059
|
// no write-up yet.
|
|
976
1060
|
capturedChars: clip.capturedChars,
|
|
977
1061
|
domain: clip.domain,
|
|
978
|
-
|
|
979
|
-
recordId: operations
|
|
980
|
-
? `ops:${operations.domain}:${operations.month}:${operations.filename}`
|
|
981
|
-
: direct?.recordId ?? `standalone:${sessionId}`,
|
|
982
|
-
mutable: direct == null,
|
|
1062
|
+
...speakerRecordIdentity(sessionId, operations, direct, blendedRecordFor(req.query.recordId, sessionId)),
|
|
983
1063
|
// So the panel can warn above the write-up, not just the clipboard.
|
|
984
1064
|
removedNames: clip.removed,
|
|
985
1065
|
coverage,
|
|
@@ -1033,6 +1113,14 @@ export function createMeetingRouter(deps: MeetingRouteDependencies = {}): Router
|
|
|
1033
1113
|
res.status(400).json({ error: 'speaker is required', reason: 'invalid_label' })
|
|
1034
1114
|
return
|
|
1035
1115
|
}
|
|
1116
|
+
// Enrolment writes to the voice store, so the same two gates as the other
|
|
1117
|
+
// corrections: the session must be a capture, and the record aimed at must
|
|
1118
|
+
// not be a derived one.
|
|
1119
|
+
const evidenceRefusal = assertVoiceEvidenceSource([sessionId]) ?? refuseDerivedMutation(req.body?.recordId)
|
|
1120
|
+
if (evidenceRefusal) {
|
|
1121
|
+
res.status(evidenceRefusal.status).json(evidenceRefusal.body)
|
|
1122
|
+
return
|
|
1123
|
+
}
|
|
1036
1124
|
|
|
1037
1125
|
const operations = cosOperationsMeetingsConfigured()
|
|
1038
1126
|
? findCosOperationsMeetingBySessionId(sessionId)
|
|
@@ -1091,6 +1179,14 @@ export function createMeetingRouter(deps: MeetingRouteDependencies = {}): Router
|
|
|
1091
1179
|
res.status(400).json({ error: 'Invalid sessionId', reason: 'invalid_session_id' })
|
|
1092
1180
|
return
|
|
1093
1181
|
}
|
|
1182
|
+
// A merged G2 session is NOT refused here: it is a real capture with real
|
|
1183
|
+
// audio, and relabelling it is how its profile gets better. Only an imported
|
|
1184
|
+
// or derived IDENTITY is refused.
|
|
1185
|
+
const evidenceRefusal = assertVoiceEvidenceSource([sessionId]) ?? refuseDerivedMutation(req.body?.recordId)
|
|
1186
|
+
if (evidenceRefusal) {
|
|
1187
|
+
res.status(evidenceRefusal.status).json(evidenceRefusal.body)
|
|
1188
|
+
return
|
|
1189
|
+
}
|
|
1094
1190
|
|
|
1095
1191
|
const from = typeof req.body?.from === 'string' ? req.body.from : ''
|
|
1096
1192
|
const to = typeof req.body?.to === 'string' ? req.body.to : ''
|
|
@@ -1329,6 +1425,11 @@ export function createMeetingRouter(deps: MeetingRouteDependencies = {}): Router
|
|
|
1329
1425
|
res.status(400).json({ error: 'Invalid sessionId', reason: 'invalid_session_id' })
|
|
1330
1426
|
return
|
|
1331
1427
|
}
|
|
1428
|
+
const evidenceRefusal = assertVoiceEvidenceSource([sessionId]) ?? refuseDerivedMutation(req.body?.recordId)
|
|
1429
|
+
if (evidenceRefusal) {
|
|
1430
|
+
res.status(evidenceRefusal.status).json(evidenceRefusal.body)
|
|
1431
|
+
return
|
|
1432
|
+
}
|
|
1332
1433
|
const label = typeof req.body?.label === 'string' ? req.body.label : ''
|
|
1333
1434
|
const bad = invalidLabelReason(label)
|
|
1334
1435
|
if (bad) {
|
|
@@ -1415,6 +1516,11 @@ export function createMeetingRouter(deps: MeetingRouteDependencies = {}): Router
|
|
|
1415
1516
|
res.status(400).json({ error: 'Invalid sessionId', reason: 'invalid_session_id' })
|
|
1416
1517
|
return
|
|
1417
1518
|
}
|
|
1519
|
+
const evidenceRefusal = assertVoiceEvidenceSource([sessionId]) ?? refuseDerivedMutation(req.body?.recordId)
|
|
1520
|
+
if (evidenceRefusal) {
|
|
1521
|
+
res.status(evidenceRefusal.status).json(evidenceRefusal.body)
|
|
1522
|
+
return
|
|
1523
|
+
}
|
|
1418
1524
|
const from = typeof req.body?.from === 'string' ? req.body.from : ''
|
|
1419
1525
|
const bad = invalidLabelReason(from)
|
|
1420
1526
|
if (bad) {
|
|
@@ -1992,6 +2098,7 @@ export function createMeetingRouter(deps: MeetingRouteDependencies = {}): Router
|
|
|
1992
2098
|
// Control surfaces and warns on before committing a drain.
|
|
1993
2099
|
await enrichStandaloneMeeting(saved.filepath, Date.now())
|
|
1994
2100
|
}
|
|
2101
|
+
triggerMeetingMergeRun('orphan_recovered')
|
|
1995
2102
|
}).catch(error => {
|
|
1996
2103
|
// The quarantined audio is untouched on failure — retry stays possible
|
|
1997
2104
|
// until the retention clock clears it.
|