@gotcos/glasses-server 6.82.0 → 6.84.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 +224 -0
- package/docs/work-orchestrator.md +184 -0
- package/managed-runtime-contract.json +2 -1
- package/package.json +8 -1
- package/server/index.ts +12 -0
- package/server/lib/claude-bridge.ts +4 -0
- package/server/lib/dictation-clean.ts +26 -3
- package/server/lib/dictation-format.ts +119 -0
- package/server/lib/dictation-polish-need.ts +4 -2
- package/server/lib/glossary-learn.ts +88 -10
- package/server/lib/hallucination-filter.ts +5 -0
- package/server/lib/meeting-batch-transcribe.ts +12 -4
- package/server/lib/profile.ts +3 -1
- package/server/lib/query-job-coordinator.ts +5 -1
- package/server/lib/unsaved-audio-quarantine.ts +36 -5
- package/server/lib/whisper-local.ts +31 -5
- package/server/lib/work-chat.ts +76 -9
- package/server/lib/work-lens-rounds.ts +445 -0
- package/server/lib/work-orchestrator/discovery.ts +99 -0
- package/server/lib/work-orchestrator/input.ts +109 -0
- package/server/lib/work-orchestrator/pickup.ts +85 -0
- package/server/lib/work-orchestrator/plan-log.ts +38 -0
- package/server/lib/work-orchestrator/redact.ts +41 -0
- package/server/lib/work-orchestrator/runtime.ts +74 -0
- package/server/lib/work-orchestrator/session-log.ts +114 -0
- package/server/lib/work-orchestrator/snapshot.ts +87 -0
- package/server/lib/work-orchestrator/tag-index.ts +272 -0
- package/server/lib/work-orchestrator/tags.ts +52 -0
- package/server/lib/work-orchestrator/usage-events.ts +30 -0
- package/server/lib/work-orchestrator/usage.ts +341 -0
- package/server/routes/glossary.ts +14 -2
- package/server/routes/meeting.ts +76 -1
- package/server/routes/prompt-drafts.ts +15 -2
- package/server/routes/transcribe-stream.ts +127 -10
- package/server/routes/work-chat.ts +54 -11
- package/server/routes/work-lens-rounds.ts +179 -0
- package/server/routes/work-orchestrator.ts +71 -0
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { MacParakeetUnavailableError, transcribeMacParakeet } from '../lib/mac-recording-asr.js'
|
|
2
2
|
import { getMeetingStore } from '../lib/meeting-store.js'
|
|
3
3
|
import { appendAutonomousSaveLog, getStopIntentStore, savedChunkReplay, type StopIntent } from '../lib/meeting-stop-intents.js'
|
|
4
|
-
import { MAC_AUDIO, MAC_CAPTURE_ID, persistMacChunkReceipt, macError, registerMacRecording, requireMacRecording, readMacRecording, validateMacWav } from '../lib/mac-recording.js'
|
|
4
|
+
import { MAC_AUDIO, MAC_CAPTURE_ID, closeMacRecording, persistMacChunkReceipt, macError, registerMacRecording, requireMacRecording, readMacRecording, validateMacWav } from '../lib/mac-recording.js'
|
|
5
5
|
import { AUTO_ENROLL_THRESHOLD } from '../lib/voiceprint-floor.js'
|
|
6
6
|
import { readSpeakerPolicy, policyLabel, type SpeakerMode } from '../lib/meeting-speaker-policy.js'
|
|
7
7
|
// POST /api/transcribe-stream — Streaming transcription for continuous meeting capture
|
|
@@ -15,7 +15,8 @@ import { chmodSync, readFileSync, writeFileSync, existsSync, lstatSync, mkdirSyn
|
|
|
15
15
|
import { writeFile } from 'node:fs/promises'
|
|
16
16
|
import { resolve } from 'node:path'
|
|
17
17
|
import { fileURLToPath } from 'node:url'
|
|
18
|
-
import { getVocabulary, getOwnerName, getOwnerSpeakerLabel } from '../lib/profile.js'
|
|
18
|
+
import { getVocabulary, getOwnerName, getOwnerSpeakerLabel, getWhisperCorrections } from '../lib/profile.js'
|
|
19
|
+
import { decoderContextWindow } from '../lib/glossary-learn.js'
|
|
19
20
|
import { getOpenAIKey } from '../lib/openai-key.js'
|
|
20
21
|
import { getTranscriptionPolicySnapshot, isOpenAIWhisperFallbackReady } from '../lib/transcription-policy.js'
|
|
21
22
|
import { STRANDED_STALE_MS } from '../lib/stranded-sessions.js'
|
|
@@ -81,12 +82,14 @@ import {
|
|
|
81
82
|
} from '../lib/even-hub-speaker-role.js'
|
|
82
83
|
import { archiveSessionAudio, runMeetingAudioRetention } from '../lib/meeting-audio-archive.js'
|
|
83
84
|
import {
|
|
85
|
+
DISCARDED_RETENTION_MS,
|
|
84
86
|
countChunkWavs,
|
|
85
87
|
listUnsavedCaptures,
|
|
86
88
|
purgeExpiredQuarantine,
|
|
87
89
|
quarantineSessionAudio,
|
|
88
90
|
readCaptureDisposition,
|
|
89
91
|
sweepOrphanedSessionAudio,
|
|
92
|
+
type QuarantineReceipt,
|
|
90
93
|
} from '../lib/unsaved-audio-quarantine.js'
|
|
91
94
|
import {
|
|
92
95
|
STALE_ACTIVE_SESSION_DIR_NAME,
|
|
@@ -1728,11 +1731,14 @@ export function getMeetingSessionStatus(sessionId: string, now = Date.now()): Me
|
|
|
1728
1731
|
}
|
|
1729
1732
|
}
|
|
1730
1733
|
|
|
1734
|
+
/** 6.83.2: a close that quarantines under its own reason, clock and terminal receipt (the Mac discard). */
|
|
1735
|
+
type CloseQuarantine = { reason: string; retentionMs?: number; receipt?: QuarantineReceipt }
|
|
1736
|
+
|
|
1731
1737
|
function closeTranscriptSession(
|
|
1732
1738
|
sessionId: string,
|
|
1733
1739
|
reason: ClosedTranscriptSession['reason'],
|
|
1734
|
-
options: { preserveAudio?: boolean } = {},
|
|
1735
|
-
):
|
|
1740
|
+
options: { preserveAudio?: boolean; quarantine?: CloseQuarantine } = {},
|
|
1741
|
+
): string | null {
|
|
1736
1742
|
// Progressive HQ is disposable cache state. Every terminal session path —
|
|
1737
1743
|
// save, expiry, quarantine, or abandonment — must release the single owner.
|
|
1738
1744
|
void stopProgressiveHqSession(sessionId)
|
|
@@ -1751,7 +1757,7 @@ function closeTranscriptSession(
|
|
|
1751
1757
|
maxChunkIndex,
|
|
1752
1758
|
reason,
|
|
1753
1759
|
})
|
|
1754
|
-
finishClosingTranscriptSession(sessionId, { ...options, closeReason: reason })
|
|
1760
|
+
return finishClosingTranscriptSession(sessionId, { ...options, closeReason: reason })
|
|
1755
1761
|
}
|
|
1756
1762
|
|
|
1757
1763
|
/** Delete session after save */
|
|
@@ -1759,10 +1765,11 @@ export function deleteSession(sessionId: string, options: { preserveAudio?: bool
|
|
|
1759
1765
|
closeTranscriptSession(sessionId, 'saved', options)
|
|
1760
1766
|
}
|
|
1761
1767
|
|
|
1768
|
+
/** Where a close put the session's chunk audio (the quarantine dir), or null when it moved none. */
|
|
1762
1769
|
function finishClosingTranscriptSession(
|
|
1763
1770
|
sessionId: string,
|
|
1764
|
-
options: { preserveAudio?: boolean; closeReason?: ClosedTranscriptSession['reason'] },
|
|
1765
|
-
):
|
|
1771
|
+
options: { preserveAudio?: boolean; closeReason?: ClosedTranscriptSession['reason']; quarantine?: CloseQuarantine },
|
|
1772
|
+
): string | null {
|
|
1766
1773
|
sessions.delete(sessionId)
|
|
1767
1774
|
sessionAudioBytes.delete(sessionId)
|
|
1768
1775
|
sessionAudioWrites.delete(sessionId)
|
|
@@ -1794,6 +1801,7 @@ function finishClosingTranscriptSession(
|
|
|
1794
1801
|
if (countChunkWavs(audioDir) > 0) {
|
|
1795
1802
|
const target = quarantineSessionAudio(audioDir, 'close_saved_residual_chunks')
|
|
1796
1803
|
if (target) console.warn(`[transcribe-stream] Saved-close left chunk audio behind; quarantined: ${sessionId} → ${target}`)
|
|
1804
|
+
return target
|
|
1797
1805
|
} else {
|
|
1798
1806
|
try { rmSync(audioDir, { recursive: true, force: true }) } catch {}
|
|
1799
1807
|
}
|
|
@@ -1801,13 +1809,114 @@ function finishClosingTranscriptSession(
|
|
|
1801
1809
|
// Any non-saved close (expired, error, …) with chunk audio still on disk
|
|
1802
1810
|
// is an unsaved capture. Quarantine it — never delete evidence (6.19.0).
|
|
1803
1811
|
if (countChunkWavs(audioDir) > 0) {
|
|
1804
|
-
const
|
|
1812
|
+
const quarantine = options.quarantine
|
|
1813
|
+
const target = quarantineSessionAudio(audioDir, quarantine?.reason ?? `close_${options.closeReason ?? 'unknown'}`, undefined,
|
|
1814
|
+
quarantine ? { retentionMs: quarantine.retentionMs, receipt: quarantine.receipt } : {})
|
|
1805
1815
|
if (target) console.warn(`[transcribe-stream] Quarantined unsaved session-audio on close: ${sessionId} → ${target}`)
|
|
1806
1816
|
else console.error(`[transcribe-stream] Quarantine move failed on close, source retained: ${sessionId}`)
|
|
1817
|
+
return target
|
|
1807
1818
|
} else {
|
|
1808
1819
|
try { rmSync(audioDir, { recursive: true, force: true }) } catch {}
|
|
1809
1820
|
}
|
|
1810
1821
|
}
|
|
1822
|
+
return null
|
|
1823
|
+
}
|
|
1824
|
+
|
|
1825
|
+
// ── 6.83.2: discard a stranded Mac capture that holds no sound ─────────────────────
|
|
1826
|
+
// Miles, 2026-10-21: Record on Mac captured a 21-minute Zoom call as 211 chunks of exact zeros (macOS returns
|
|
1827
|
+
// zeros, not an error, when system audio recording is not allowed). The save refused it (no_speech), and the
|
|
1828
|
+
// capture then sat in the stranded list with a Save that can never work. COS Control asks the server to let it go.
|
|
1829
|
+
//
|
|
1830
|
+
// What it may touch: a Mac capture (Control is its only uploader, so nothing else will send it a chunk) that is in
|
|
1831
|
+
// memory, holds NO canonical chunk (no transcribed text, so no speech), has finished ASR on every chunk it received
|
|
1832
|
+
// (an in-flight chunk could still become speech), is idle past EMPTY_SESSION_STALE_MS, and has no chunk upload in
|
|
1833
|
+
// progress. Anything else is refused and left exactly as it was. The audio is never deleted here: it moves into the
|
|
1834
|
+
// existing quarantine with a terminal no_speech receipt and a 24-hour clock (DISCARDED_RETENTION_MS).
|
|
1835
|
+
export type MacDiscardRefusal =
|
|
1836
|
+
| 'session_not_found' | 'not_mac_capture' | 'has_speech' | 'asr_in_progress' | 'not_idle' | 'chunk_in_progress'
|
|
1837
|
+
|
|
1838
|
+
export interface MacDiscardResult {
|
|
1839
|
+
ok: boolean
|
|
1840
|
+
reason?: MacDiscardRefusal
|
|
1841
|
+
sessionId: string
|
|
1842
|
+
/** The quarantine dir the audio moved into, or null when the capture held no chunk audio. */
|
|
1843
|
+
quarantined: string | null
|
|
1844
|
+
chunkFiles: number
|
|
1845
|
+
receivedCount: number
|
|
1846
|
+
retainedUntil: string | null
|
|
1847
|
+
}
|
|
1848
|
+
|
|
1849
|
+
export const MAC_DISCARD_IDLE_MS = EMPTY_SESSION_STALE_MS
|
|
1850
|
+
|
|
1851
|
+
/** Why a session may not be discarded, or null when it may. Pure over the session's ledger. */
|
|
1852
|
+
export function macDiscardRefusal(input: {
|
|
1853
|
+
sessionId: string
|
|
1854
|
+
present: boolean
|
|
1855
|
+
canonicalCount: number
|
|
1856
|
+
receivedIndices: number[]
|
|
1857
|
+
asrCompletedIndices: number[]
|
|
1858
|
+
silentForMs: number
|
|
1859
|
+
chunkInProgress: boolean
|
|
1860
|
+
}): MacDiscardRefusal | null {
|
|
1861
|
+
if (!MAC_CAPTURE_ID.test(input.sessionId)) return 'not_mac_capture'
|
|
1862
|
+
if (!input.present) return 'session_not_found'
|
|
1863
|
+
if (input.canonicalCount > 0) return 'has_speech'
|
|
1864
|
+
const completed = new Set(input.asrCompletedIndices)
|
|
1865
|
+
if (input.receivedIndices.some(index => !completed.has(index))) return 'asr_in_progress'
|
|
1866
|
+
if (input.chunkInProgress) return 'chunk_in_progress'
|
|
1867
|
+
if (input.silentForMs < MAC_DISCARD_IDLE_MS) return 'not_idle'
|
|
1868
|
+
return null
|
|
1869
|
+
}
|
|
1870
|
+
|
|
1871
|
+
export async function discardSilentMacCapture(sessionId: string, options: { now?: number; dryRun?: boolean } = {}): Promise<MacDiscardResult> {
|
|
1872
|
+
const now = options.now ?? Date.now()
|
|
1873
|
+
const refusalFor = () => {
|
|
1874
|
+
const session = sessions.get(sessionId)
|
|
1875
|
+
return {
|
|
1876
|
+
session,
|
|
1877
|
+
refusal: macDiscardRefusal({
|
|
1878
|
+
sessionId,
|
|
1879
|
+
present: !!session,
|
|
1880
|
+
canonicalCount: session ? canonicalIndices(session).length : 0,
|
|
1881
|
+
receivedIndices: session?.receivedIndices ?? [],
|
|
1882
|
+
asrCompletedIndices: session?.asrCompletedIndices ?? [],
|
|
1883
|
+
silentForMs: session ? now - session.lastActivityAt : 0,
|
|
1884
|
+
chunkInProgress: macChunkLocks.has(sessionId),
|
|
1885
|
+
}),
|
|
1886
|
+
}
|
|
1887
|
+
}
|
|
1888
|
+
const empty = (reason: MacDiscardRefusal, receivedCount = 0): MacDiscardResult =>
|
|
1889
|
+
({ ok: false, reason, sessionId, quarantined: null, chunkFiles: 0, receivedCount, retainedUntil: null })
|
|
1890
|
+
const first = refusalFor()
|
|
1891
|
+
if (first.refusal) return empty(first.refusal, first.session?.receivedIndices?.length ?? 0)
|
|
1892
|
+
if (options.dryRun) {
|
|
1893
|
+
const audioDir = getSessionAudioDirectory(sessionId)
|
|
1894
|
+
return { ok: true, sessionId, quarantined: null, chunkFiles: audioDir ? countChunkWavs(audioDir) : 0,
|
|
1895
|
+
receivedCount: first.session?.receivedIndices?.length ?? 0, retainedUntil: null }
|
|
1896
|
+
}
|
|
1897
|
+
// Every chunk write this session started reaches disk before its directory moves.
|
|
1898
|
+
await drainSessionAudioWrites(sessionId)
|
|
1899
|
+
// Ask again after the await: a chunk may have arrived meanwhile.
|
|
1900
|
+
const second = refusalFor()
|
|
1901
|
+
if (second.refusal) return empty(second.refusal, second.session?.receivedIndices?.length ?? 0)
|
|
1902
|
+
const receivedCount = second.session?.receivedIndices?.length ?? 0
|
|
1903
|
+
const quarantined = closeTranscriptSession(sessionId, 'closed', {
|
|
1904
|
+
quarantine: {
|
|
1905
|
+
reason: 'discarded_no_sound',
|
|
1906
|
+
retentionMs: DISCARDED_RETENTION_MS,
|
|
1907
|
+
receipt: { outcome: 'no_speech', discarded: true, note: 'Discarded from COS Control: no sound was captured. Audio retained until its 24-hour clock clears it.' },
|
|
1908
|
+
},
|
|
1909
|
+
})
|
|
1910
|
+
// A late chunk or a replayed start for this capture answers 410 from here on.
|
|
1911
|
+
closeMacRecording(sessionId)
|
|
1912
|
+
return {
|
|
1913
|
+
ok: true,
|
|
1914
|
+
sessionId,
|
|
1915
|
+
quarantined,
|
|
1916
|
+
chunkFiles: quarantined ? countChunkWavs(quarantined) : 0,
|
|
1917
|
+
receivedCount,
|
|
1918
|
+
retainedUntil: quarantined ? new Date(Date.now() + DISCARDED_RETENTION_MS).toISOString() : null,
|
|
1919
|
+
}
|
|
1811
1920
|
}
|
|
1812
1921
|
|
|
1813
1922
|
function isIosAsrCandidateEnabled(): boolean {
|
|
@@ -1946,7 +2055,13 @@ function recentWhisperContext(sessionId: string, session: TranscriptSession): st
|
|
|
1946
2055
|
.map(c => c.text)
|
|
1947
2056
|
.join(' ')
|
|
1948
2057
|
const cleanedContext = stripInlineHallucinations(recentText, sessionId)
|
|
1949
|
-
|
|
2058
|
+
// 6.83.1: correction-only forms ("/pricing") go back to speech BEFORE the 250-character cut.
|
|
2059
|
+
return decoderContextWindow(cleanedContext, decoderCorrections()) ?? ''
|
|
2060
|
+
}
|
|
2061
|
+
|
|
2062
|
+
/** The corrections map for the decoder window; a test double without it reads as none. */
|
|
2063
|
+
function decoderCorrections(): Record<string, string> {
|
|
2064
|
+
try { return getWhisperCorrections() } catch { return {} }
|
|
1950
2065
|
}
|
|
1951
2066
|
|
|
1952
2067
|
function isCrossChunkRepeat(session: TranscriptSession, text: string): boolean {
|
|
@@ -2610,7 +2725,9 @@ export function assertMacCaptureComplete(sessionId: string, expectedChunkCount:
|
|
|
2610
2725
|
|
|
2611
2726
|
// Manual Mac pilot: independent from iPhone candidate and cue rollout gates.
|
|
2612
2727
|
transcribeStreamRouter.get('/mac-recording/capabilities', (_req, res) => {
|
|
2613
|
-
|
|
2728
|
+
// 6.83.2: `discard` advertises POST /api/meeting/sessions/:id/discard for a stranded no-sound capture. Older
|
|
2729
|
+
// servers omit it, and COS Control then discards only its own local copy and says so.
|
|
2730
|
+
res.set('Cache-Control', 'private, no-store').json({ available: true, ...MAC_AUDIO, serverInstanceId: getServerInstanceId(), discard: true })
|
|
2614
2731
|
})
|
|
2615
2732
|
function requireMacPin(req: { get(name: string): string | undefined }): string {
|
|
2616
2733
|
const pin = req.get('X-COS-Server-Instance')?.trim()
|
|
@@ -1,33 +1,76 @@
|
|
|
1
1
|
import { Router } from 'express'
|
|
2
|
-
import { parseWorkChatInput, parseWorkChatAnswer, WORK_CHAT_SYSTEM, WORK_CHAT_PLANNERS, WORK_CHAT_EFFORTS, WORK_CHAT_DEFAULT_EFFORT,
|
|
3
|
-
WORK_CHAT_OUTPUT_TOKENS, WORK_CHAT_THINKING_TOKENS, WORK_CHAT_DEADLINE_MS, WORK_CHAT_MAX_ASSIGNMENTS, WORK_CHAT_MAX_ITEMS, WORK_CHAT_MAX_REFERENCES
|
|
2
|
+
import { parseWorkChatInput, parseWorkChatAnswer, WORK_CHAT_SYSTEM, WORK_CHAT_ORCHESTRATOR_RULES, WORK_CHAT_PLANNERS, WORK_CHAT_EFFORTS, WORK_CHAT_DEFAULT_EFFORT,
|
|
3
|
+
WORK_CHAT_OUTPUT_TOKENS, WORK_CHAT_ORCHESTRATOR_OUTPUT_TOKENS, WORK_CHAT_THINKING_TOKENS, WORK_CHAT_DEADLINE_MS, WORK_CHAT_MAX_ASSIGNMENTS, WORK_CHAT_MAX_ITEMS, WORK_CHAT_MAX_REFERENCES,
|
|
4
|
+
WORK_CHAT_BODY_BYTES, WORK_CHAT_ORCHESTRATOR_BODY_BYTES, type WorkChatInput, type WorkChatAnswer } from '../lib/work-chat.js'
|
|
4
5
|
import { runMeetingAskModel } from '../lib/meeting-ask-runner.js'
|
|
5
6
|
import { acquireMaintenanceWork, maintenanceAdmissionsOpen } from '../lib/maintenance-lifecycle.js'
|
|
6
|
-
|
|
7
|
+
import { appendPlanLog, type PlanLogRow } from '../lib/work-orchestrator/plan-log.js'
|
|
8
|
+
import { PLAN_LOG_PATH } from '../lib/work-orchestrator/runtime.js'
|
|
9
|
+
import { defaultLensRoundStore } from '../lib/work-lens-rounds.js'
|
|
10
|
+
|
|
11
|
+
/** 6.83.0: one plan's log row. Ids and enums only (plan-log.ts). */
|
|
12
|
+
export function planLogRow(input: WorkChatInput, outcome: PlanLogRow['outcome'], answer: WorkChatAnswer | null, ms: number, now = new Date()): PlanLogRow {
|
|
13
|
+
return {
|
|
14
|
+
at: now.toISOString(), workID: input.workID, mode: input.mode, planner: input.planner ?? 'sonnet', effort: input.effort ?? WORK_CHAT_DEFAULT_EFFORT,
|
|
15
|
+
orchestrator: input.orchestrator === true,
|
|
16
|
+
sessions: input.sessions.map(s => ({ id: s.id, automatic: s.automatic, ...(s.available !== undefined ? { available: s.available } : {}),
|
|
17
|
+
...(s.reason ? { reason: s.reason } : {}), ...(s.discoveredBy ? { discoveredBy: s.discoveredBy } : {}) })),
|
|
18
|
+
leftOut: (input.leftOut ?? []).map(l => ({ id: l.id, reason: l.reason })),
|
|
19
|
+
usageAgeSec: Object.fromEntries((input.usage ?? []).map(u => [u.provider, u.ageSec])),
|
|
20
|
+
usageState: Object.fromEntries((input.usage ?? []).map(u => [u.provider, u.state])),
|
|
21
|
+
assignments: (answer?.assignments ?? []).map(a => ({ mode: a.mode ?? 'continue', target: a.mode === 'newSession' ? `new:${a.provider}` : a.sessionID ?? '' })),
|
|
22
|
+
outcome, ms,
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const WORK_CHAT_DEPS = {run:runMeetingAskModel, admissions:maintenanceAdmissionsOpen, lease:()=>acquireMaintenanceWork('work_chat'),
|
|
27
|
+
planLog:(row: PlanLogRow)=>appendPlanLog(PLAN_LOG_PATH(), row), now:()=>Date.now(),
|
|
28
|
+
lensPlansControl:()=>defaultLensRoundStore().controlStatus()}
|
|
29
|
+
export function createWorkChatRouter(given: Partial<typeof WORK_CHAT_DEPS> = {}) {
|
|
30
|
+
// 6.83.0: a caller that passes only some deps (the tests pass run, admissions and lease) gets the rest by default.
|
|
31
|
+
const deps={...WORK_CHAT_DEPS,...given}
|
|
7
32
|
const router=Router(); let running=false
|
|
8
33
|
router.use('/work/chat', (_req,res,next)=>{res.set('Cache-Control','private, no-store');next()})
|
|
9
34
|
// 6.73.15: queueRounds means sessions may carry openWork, and the planner routes around sessions with earlier work open.
|
|
10
35
|
// references means the input may carry read-only reference sessions (never targets).
|
|
11
|
-
|
|
36
|
+
// 6.83.0: orchestrator 1 means a client may send the orchestrator input (POST /api/work/orchestrator/input) and get
|
|
37
|
+
// fork and new-session assignments back (docs/work-orchestrator.md).
|
|
38
|
+
// 6.83.0: lensPlans 1 means the lens may record a plan into the card's Work chat history and ask COS Control to send
|
|
39
|
+
// it (routes/work-lens-rounds.ts); lensPlansControl says whether a COS Control polled the lens inbox in the last 60 s.
|
|
40
|
+
router.get('/work/chat',(_req,res)=>res.json({protocolVersion:1,planner:'sonnet',planners:WORK_CHAT_PLANNERS,effort:WORK_CHAT_DEFAULT_EFFORT,efforts:WORK_CHAT_EFFORTS,automaticProviders:['claude','codex'],maxAssignments:WORK_CHAT_MAX_ASSIGNMENTS,contextItems:true,maxContextItems:WORK_CHAT_MAX_ITEMS,queueRounds:true,references:true,maxReferences:WORK_CHAT_MAX_REFERENCES,orchestrator:1,
|
|
41
|
+
lensPlans:1,lensPlansControl:deps.lensPlansControl()}))
|
|
12
42
|
router.post('/work/chat',async(req,res)=>{
|
|
13
|
-
let input
|
|
14
|
-
try {
|
|
43
|
+
let input: WorkChatInput
|
|
44
|
+
try {
|
|
45
|
+
const limit=req.body?.orchestrator===true ? WORK_CHAT_ORCHESTRATOR_BODY_BYTES : WORK_CHAT_BODY_BYTES
|
|
46
|
+
if (Buffer.byteLength(JSON.stringify(req.body))>limit) throw new Error(); input=parseWorkChatInput(req.body)
|
|
47
|
+
}
|
|
15
48
|
catch {return res.status(400).json({error:'invalid_work_chat'})}
|
|
16
49
|
if (running || !deps.admissions()) return res.status(409).json({error:'work_chat_busy'})
|
|
17
50
|
running=true
|
|
51
|
+
const started=deps.now()
|
|
18
52
|
let lease: ReturnType<typeof acquireMaintenanceWork> | undefined
|
|
53
|
+
let outcome: PlanLogRow['outcome']='unavailable', answer: WorkChatAnswer | null=null
|
|
19
54
|
const controller=new AbortController(); const abort=()=>controller.abort()
|
|
20
55
|
res.on('close',abort)
|
|
21
56
|
try {
|
|
22
57
|
lease=deps.lease()
|
|
23
58
|
// Planner and effort select how the model runs; they are not evidence, so they never reach the prompt.
|
|
24
59
|
const {planner='sonnet',effort=WORK_CHAT_DEFAULT_EFFORT,...prompt}=input
|
|
25
|
-
const
|
|
26
|
-
|
|
27
|
-
const
|
|
60
|
+
const orchestrator=input.orchestrator===true
|
|
61
|
+
const system=orchestrator ? WORK_CHAT_SYSTEM+'\n\n'+WORK_CHAT_ORCHESTRATOR_RULES : WORK_CHAT_SYSTEM
|
|
62
|
+
const out=await deps.run({engine:'claude',model:planner},JSON.stringify(prompt),system,controller.signal,undefined,{caller:'work_chat_'+input.mode},
|
|
63
|
+
{effort,maxOutputTokens:orchestrator ? WORK_CHAT_ORCHESTRATOR_OUTPUT_TOKENS : WORK_CHAT_OUTPUT_TOKENS,maxThinkingTokens:WORK_CHAT_THINKING_TOKENS[effort],deadlineMs:WORK_CHAT_DEADLINE_MS})
|
|
64
|
+
try { answer=parseWorkChatAnswer(out.text,input) } catch (e) { outcome='invalid_answer'; throw e }
|
|
65
|
+
outcome='planned'
|
|
28
66
|
if (!res.destroyed) return res.json({protocolVersion:1,...answer,model:out.model??`claude:${planner} (requested)`,effort})
|
|
29
|
-
|
|
30
|
-
|
|
67
|
+
outcome='aborted'
|
|
68
|
+
} catch {if (controller.signal.aborted && outcome==='unavailable') outcome='aborted'; if(!res.destroyed) return res.status(503).json({error:'work_chat_unavailable',message:'No assignment was dispatched. Your saved request can be retried.'})}
|
|
69
|
+
finally {
|
|
70
|
+
res.off('close',abort);lease?.release();running=false
|
|
71
|
+
// 6.83.0: every plan is logged, whatever its outcome (plan-log.ts); a log failure never changes the answer.
|
|
72
|
+
try { deps.planLog(planLogRow(input,outcome,answer,deps.now()-started)) } catch { /* observation only */ }
|
|
73
|
+
}
|
|
31
74
|
})
|
|
32
75
|
return router
|
|
33
76
|
}
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 6.83.0: lens plans in Work chat history, and "Send this plan" (docs/work-orchestrator.md, "Lens plans").
|
|
3
|
+
*
|
|
4
|
+
* Lens (over the network, token auth from the parent /api mount):
|
|
5
|
+
* POST /api/work/chat/lens-rounds record a plan the lens made (idempotent on clientRoundId)
|
|
6
|
+
* GET /api/work/chat/lens-rounds/:roundId the round's live state, never its prompts
|
|
7
|
+
* POST /api/work/chat/lens-rounds/:roundId/send ask COS Control to send it (two-step confirm, or card Auto-send)
|
|
8
|
+
* POST /api/work/chat/lens-rounds/:roundId/stop ask COS Control to Stop assignments
|
|
9
|
+
* COS Control (this Mac only):
|
|
10
|
+
* GET /api/work/chat/lens-inbox?since=<cursor>[&autoSend=1] rounds not yet in history, send and stop requests
|
|
11
|
+
* POST /api/work/chat/lens-rounds/:roundId/history { state: "in_history", autoSendCard? }
|
|
12
|
+
* POST /api/work/chat/lens-send/:sendRequestId/result { assignments: [{ id, state, receiptId?, detail?, sessionID? }] }
|
|
13
|
+
* POST /api/work/chat/lens-rounds/:roundId/mac-send the same shape, for COS Control's own Send of this round on the Mac
|
|
14
|
+
*
|
|
15
|
+
* Nothing here sends to a session: COS Control performs every send through its own Send path and reports back.
|
|
16
|
+
* Every route answers 503 `maintenance` while admissions are closed; writes hold the api_mutation lease themselves
|
|
17
|
+
* (index.ts leaves these paths to this router so the error code is the contract's).
|
|
18
|
+
*/
|
|
19
|
+
import { Router, type Request, type Response } from 'express'
|
|
20
|
+
import { LensRoundError, LensRoundStore, LENS_ROUND_BODY_BYTES, ROUND_ID, SEND_ID, cardSessionsOf, parseLensRound, parseResultBody,
|
|
21
|
+
parseSendBody, parseStopBody, roundView, defaultLensRoundStore } from '../lib/work-lens-rounds.js'
|
|
22
|
+
import type { ControlSnapshot } from '../lib/work-orchestrator/snapshot.js'
|
|
23
|
+
import { orchestratorSnapshots } from '../lib/work-orchestrator/runtime.js'
|
|
24
|
+
import { acquireMaintenanceWork, maintenanceAdmissionsOpen } from '../lib/maintenance-lifecycle.js'
|
|
25
|
+
import { isLoopbackRequest } from './work-handoff-requests.js'
|
|
26
|
+
import { listBoard, type TaskBoardRow } from '../lib/task-store.js'
|
|
27
|
+
import { createWorkBoardReader, type WorkBoardReader } from '../lib/work-board-reader.js'
|
|
28
|
+
|
|
29
|
+
export interface WorkLensRoundsDeps {
|
|
30
|
+
store: LensRoundStore
|
|
31
|
+
/** COS Control's Work snapshot (links and receipts), for the card's sessions. */
|
|
32
|
+
snapshot: () => ControlSnapshot | null
|
|
33
|
+
/** Whether the workID is a card the server knows: in COS Control's snapshot or on the Work board. Throws when unknowable. */
|
|
34
|
+
knownWork: (workID: string) => Promise<boolean>
|
|
35
|
+
admissions: () => boolean
|
|
36
|
+
lease: () => { release(): void }
|
|
37
|
+
isLocal: (req: Request) => boolean
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** A card is known when COS Control's snapshot names it, or the Work board (a bounded read, 2 s old at most) lists it. */
|
|
41
|
+
export function knownWorkFrom(snapshot: () => ControlSnapshot | null, board: WorkBoardReader<TaskBoardRow>) {
|
|
42
|
+
return async (workID: string): Promise<boolean> => {
|
|
43
|
+
const s = snapshot()
|
|
44
|
+
if (s && (s.links.some(l => l.workID === workID) || s.receipts.some(r => r.workID === workID))) return true
|
|
45
|
+
const rows = await board.read(2_000)
|
|
46
|
+
return rows.some(r => `task:${r.domain}:${r.workIdentity || r.id}` === workID)
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
let defaultBoard: WorkBoardReader<TaskBoardRow> | null = null
|
|
51
|
+
const DEFAULTS = (): WorkLensRoundsDeps => ({
|
|
52
|
+
store: defaultLensRoundStore(),
|
|
53
|
+
snapshot: () => orchestratorSnapshots().get(),
|
|
54
|
+
knownWork: knownWorkFrom(() => orchestratorSnapshots().get(), defaultBoard ??= createWorkBoardReader(() => listBoard())),
|
|
55
|
+
admissions: maintenanceAdmissionsOpen,
|
|
56
|
+
lease: () => acquireMaintenanceWork('api_mutation'),
|
|
57
|
+
isLocal: isLoopbackRequest,
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
const MAINTENANCE = { error: 'maintenance', message: 'The server is updating. The plan is kept on the lens; try again shortly.' }
|
|
61
|
+
|
|
62
|
+
export function createWorkLensRoundsRouter(given: Partial<WorkLensRoundsDeps> = {}) {
|
|
63
|
+
const deps: WorkLensRoundsDeps = { ...DEFAULTS(), ...given }
|
|
64
|
+
const router = Router()
|
|
65
|
+
router.use(['/work/chat/lens-rounds', '/work/chat/lens-inbox', '/work/chat/lens-send'], (_req, res, next) => {
|
|
66
|
+
res.set('Cache-Control', 'private, no-store')
|
|
67
|
+
if (!deps.admissions()) return res.status(503).json(MAINTENANCE)
|
|
68
|
+
next()
|
|
69
|
+
})
|
|
70
|
+
const fail = (res: Response, e: unknown, where: string) => {
|
|
71
|
+
if (e instanceof LensRoundError) {
|
|
72
|
+
if (e.status >= 500 || where !== 'record') console.warn(`[work-lens-rounds] ${where}: ${e.status} ${e.code}`)
|
|
73
|
+
return res.status(e.status).json({ error: e.code, message: e.message, ...e.extra })
|
|
74
|
+
}
|
|
75
|
+
console.error(`[work-lens-rounds] ${where} failed: ${e instanceof Error ? e.message : String(e)}`)
|
|
76
|
+
return res.status(503).json({ error: 'lens_rounds_unavailable', message: 'Lens plans are unavailable. Try again shortly.' })
|
|
77
|
+
}
|
|
78
|
+
/** A write holds the api_mutation lease for its own (sub-millisecond) body; a closed gate is `maintenance`. */
|
|
79
|
+
const write = <T>(res: Response, where: string, fn: () => Promise<T> | T) => {
|
|
80
|
+
let lease: { release(): void }
|
|
81
|
+
try { lease = deps.lease() } catch { return res.status(503).json(MAINTENANCE) }
|
|
82
|
+
return Promise.resolve().then(fn).catch(e => fail(res, e, where)).finally(() => lease.release())
|
|
83
|
+
}
|
|
84
|
+
const local = (req: Request) => { if (!deps.isLocal(req)) throw new LensRoundError('local_only', 403, 'Only COS Control on this Mac can do this.') }
|
|
85
|
+
const roundIdOf = (req: Request) => {
|
|
86
|
+
const id = String(req.params.roundId ?? '')
|
|
87
|
+
if (!ROUND_ID.test(id)) throw new LensRoundError('unknown_round', 404, 'This plan is not on the server any more.')
|
|
88
|
+
return id
|
|
89
|
+
}
|
|
90
|
+
const connected = () => deps.store.controlStatus().connected
|
|
91
|
+
|
|
92
|
+
router.post('/work/chat/lens-rounds', (req, res) => write(res, 'record', async () => {
|
|
93
|
+
if (Buffer.byteLength(JSON.stringify(req.body ?? null)) > LENS_ROUND_BODY_BYTES) {
|
|
94
|
+
return res.status(413).json({ error: 'lens_round_too_large', message: 'This plan is too large to save.' })
|
|
95
|
+
}
|
|
96
|
+
const input = parseLensRound(req.body)
|
|
97
|
+
// A repeat is answered from the store, even when the board has since changed or is unreachable.
|
|
98
|
+
const replay = deps.store.replay(input.clientRoundId)
|
|
99
|
+
if (!replay) {
|
|
100
|
+
let known: boolean
|
|
101
|
+
try { known = await deps.knownWork(input.workID) }
|
|
102
|
+
catch { throw new LensRoundError('work_board_unavailable', 503, 'The Work board is unavailable. The plan is kept on the lens; try again shortly.') }
|
|
103
|
+
if (!known) throw new LensRoundError('unknown_work', 404, 'This card is not on the Work board any more.')
|
|
104
|
+
}
|
|
105
|
+
const { round, created } = deps.store.record(input, cardSessionsOf(deps.snapshot(), input.workID))
|
|
106
|
+
if (created) console.log(`[work-lens-rounds] recorded ${round.roundId} work=${round.workID} assignments=${round.assignments.length}`)
|
|
107
|
+
return res.status(created ? 201 : 200).json({ roundId: round.roundId, workID: round.workID, state: roundView(round).state, control: { connected: connected() } })
|
|
108
|
+
}))
|
|
109
|
+
|
|
110
|
+
router.get('/work/chat/lens-rounds/:roundId', (req, res) => {
|
|
111
|
+
try {
|
|
112
|
+
const round = deps.store.find(roundIdOf(req))
|
|
113
|
+
if (!round) throw new LensRoundError('unknown_round', 404, 'This plan is not on the server any more.')
|
|
114
|
+
return res.json(roundView(round))
|
|
115
|
+
} catch (e) { return fail(res, e, 'status') }
|
|
116
|
+
})
|
|
117
|
+
|
|
118
|
+
router.post('/work/chat/lens-rounds/:roundId/send', (req, res) => write(res, 'send', () => {
|
|
119
|
+
const roundId = roundIdOf(req)
|
|
120
|
+
const body = parseSendBody(req.body)
|
|
121
|
+
const round = deps.store.find(roundId)
|
|
122
|
+
if (!round) throw new LensRoundError('unknown_round', 404, 'This plan is not on the server any more.')
|
|
123
|
+
const send = deps.store.requestSend(roundId, body, { cardSessions: cardSessionsOf(deps.snapshot(), round.workID), controlConnected: connected() })
|
|
124
|
+
console.log(`[work-lens-rounds] send requested ${send.id} round=${roundId} assignments=${send.assignmentIds.join(',')} confirm=${send.confirm ?? 'auto-send'}`)
|
|
125
|
+
return res.status(202).json({ sendRequestId: send.id, state: 'send_requested' })
|
|
126
|
+
}))
|
|
127
|
+
|
|
128
|
+
router.post('/work/chat/lens-rounds/:roundId/stop', (req, res) => write(res, 'stop', () => {
|
|
129
|
+
const roundId = roundIdOf(req)
|
|
130
|
+
const stop = deps.store.requestStop(roundId, parseStopBody(req.body), connected())
|
|
131
|
+
console.log(`[work-lens-rounds] stop requested ${stop.id} round=${roundId} assignments=${stop.assignmentIds.join(',')}`)
|
|
132
|
+
return res.status(202).json({ stopRequestId: stop.id, state: 'stop_requested' })
|
|
133
|
+
}))
|
|
134
|
+
|
|
135
|
+
// COS Control: polling this is what the capability reports as connected. Listing a send or a stop picks it up.
|
|
136
|
+
router.get('/work/chat/lens-inbox', (req, res) => {
|
|
137
|
+
try {
|
|
138
|
+
local(req)
|
|
139
|
+
const { since, autoSend } = req.query
|
|
140
|
+
if (Object.keys(req.query).some(k => k !== 'since' && k !== 'autoSend')
|
|
141
|
+
|| (since !== undefined && !(typeof since === 'string' && /^\d{1,12}$/.test(since)))
|
|
142
|
+
|| (autoSend !== undefined && autoSend !== '0' && autoSend !== '1')) {
|
|
143
|
+
throw new LensRoundError('invalid_lens_inbox', 400, 'since is a cursor and autoSend is 0 or 1.')
|
|
144
|
+
}
|
|
145
|
+
return res.json(deps.store.inbox(since === undefined ? 0 : Number(since), autoSend === '1'))
|
|
146
|
+
} catch (e) { return fail(res, e, 'inbox') }
|
|
147
|
+
})
|
|
148
|
+
|
|
149
|
+
router.post('/work/chat/lens-rounds/:roundId/history', (req, res) => write(res, 'history', () => {
|
|
150
|
+
local(req)
|
|
151
|
+
const roundId = roundIdOf(req)
|
|
152
|
+
const b = req.body
|
|
153
|
+
if (!b || typeof b !== 'object' || Array.isArray(b) || b.state !== 'in_history' || (b.autoSendCard !== undefined && typeof b.autoSendCard !== 'boolean')) {
|
|
154
|
+
throw new LensRoundError('invalid_lens_round', 400, 'History takes { state: "in_history" }.', { field: 'state' })
|
|
155
|
+
}
|
|
156
|
+
const round = deps.store.markInHistory(roundId, b.autoSendCard)
|
|
157
|
+
return res.json(roundView(round))
|
|
158
|
+
}))
|
|
159
|
+
|
|
160
|
+
// COS Control: it sent assignments of this round with its own Send on the Mac. Same assignments[] shape as a result.
|
|
161
|
+
router.post('/work/chat/lens-rounds/:roundId/mac-send', (req, res) => write(res, 'mac-send', () => {
|
|
162
|
+
local(req)
|
|
163
|
+
const round = deps.store.reportMac(roundIdOf(req), parseResultBody(req.body))
|
|
164
|
+
const view = roundView(round)
|
|
165
|
+
console.log(`[work-lens-rounds] mac send round=${round.roundId} state=${view.state} ${view.assignments.map(a => `${a.id}:${a.state}`).join(' ')}`)
|
|
166
|
+
return res.json(view)
|
|
167
|
+
}))
|
|
168
|
+
|
|
169
|
+
router.post('/work/chat/lens-send/:sendRequestId/result', (req, res) => write(res, 'result', () => {
|
|
170
|
+
local(req)
|
|
171
|
+
const id = String(req.params.sendRequestId ?? '')
|
|
172
|
+
if (!SEND_ID.test(id)) throw new LensRoundError('unknown_send', 404, 'No such send request.')
|
|
173
|
+
const round = deps.store.report(id, parseResultBody(req.body))
|
|
174
|
+
const view = roundView(round)
|
|
175
|
+
console.log(`[work-lens-rounds] result ${id} round=${round.roundId} state=${view.state} ${view.assignments.map(a => `${a.id}:${a.state}`).join(' ')}`)
|
|
176
|
+
return res.json(view)
|
|
177
|
+
}))
|
|
178
|
+
return router
|
|
179
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 6.83.0 Work orchestrator routes (docs/work-orchestrator.md). Read and plan inputs only: nothing here sends to a
|
|
3
|
+
* session, changes a link or a receipt, or calls a model.
|
|
4
|
+
*
|
|
5
|
+
* POST /api/work/orchestrator/input {workID, meetingWorkIDs?, snapshot?}
|
|
6
|
+
* Stores Control's snapshot when one is sent (Phase A: Control pushes, the server keeps the copy), then answers
|
|
7
|
+
* the card's planner rows, what was left out and why, and the usage snapshot.
|
|
8
|
+
* GET /api/work/orchestrator/usage[?refresh=1]
|
|
9
|
+
* The usage snapshot per provider, read on demand: Claude and Codex are re-read when their reading is older than
|
|
10
|
+
* 10 minutes (`refresh=1`: 2 minutes). No timer reads usage unless COS_WORK_USAGE_POLL=1.
|
|
11
|
+
*/
|
|
12
|
+
import { Router } from 'express'
|
|
13
|
+
import { parseSnapshot, type ControlSnapshot } from '../lib/work-orchestrator/snapshot.js'
|
|
14
|
+
import type { OrchestratorInput } from '../lib/work-orchestrator/input.js'
|
|
15
|
+
import type { UsageMonitor, UsageView } from '../lib/work-orchestrator/usage.js'
|
|
16
|
+
import { orchestratorInputFor, orchestratorSnapshots, orchestratorUsage } from '../lib/work-orchestrator/runtime.js'
|
|
17
|
+
|
|
18
|
+
const WORK_ID = /^task:[a-z_]+:[a-f0-9]{12}$/
|
|
19
|
+
const MEETING_ID = /^meeting:[^\u0000-\u001f]{1,250}$/
|
|
20
|
+
export const ORCHESTRATOR_BODY_BYTES = 4_000_000
|
|
21
|
+
|
|
22
|
+
export interface WorkOrchestratorDeps {
|
|
23
|
+
putSnapshot: (s: ControlSnapshot) => void
|
|
24
|
+
input: (workID: string, meetingWorkIDs: string[]) => Promise<OrchestratorInput>
|
|
25
|
+
usage: (refresh: boolean) => Promise<UsageView[]>
|
|
26
|
+
now: () => number
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** GET /usage reads on demand: a reading older than 10 minutes is re-read (`refresh=1`: 2 minutes). */
|
|
30
|
+
export const readUsage = (refresh: boolean, monitor: UsageMonitor = orchestratorUsage()): Promise<UsageView[]> => monitor.onDemand(refresh)
|
|
31
|
+
|
|
32
|
+
const DEFAULTS: WorkOrchestratorDeps = {
|
|
33
|
+
putSnapshot: s => orchestratorSnapshots().put(s),
|
|
34
|
+
input: orchestratorInputFor,
|
|
35
|
+
usage: refresh => readUsage(refresh),
|
|
36
|
+
now: () => Date.now(),
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function createWorkOrchestratorRouter(given: Partial<WorkOrchestratorDeps> = {}) {
|
|
40
|
+
const deps = { ...DEFAULTS, ...given }
|
|
41
|
+
const router = Router()
|
|
42
|
+
router.use('/work/orchestrator', (_req, res, next) => { res.set('Cache-Control', 'private, no-store'); next() })
|
|
43
|
+
router.post('/work/orchestrator/input', async (req, res) => {
|
|
44
|
+
const body = req.body
|
|
45
|
+
const meetings: unknown = body?.meetingWorkIDs ?? []
|
|
46
|
+
if (!body || typeof body !== 'object' || Object.keys(body).some(k => !['workID', 'meetingWorkIDs', 'snapshot'].includes(k))
|
|
47
|
+
|| typeof body.workID !== 'string' || !WORK_ID.test(body.workID)
|
|
48
|
+
|| !Array.isArray(meetings) || meetings.length > 20 || !meetings.every(m => typeof m === 'string' && MEETING_ID.test(m))) {
|
|
49
|
+
return res.status(400).json({ error: 'invalid_orchestrator_input' })
|
|
50
|
+
}
|
|
51
|
+
if ('snapshot' in body) {
|
|
52
|
+
if (Buffer.byteLength(JSON.stringify(body.snapshot)) > ORCHESTRATOR_BODY_BYTES) return res.status(413).json({ error: 'snapshot_too_large' })
|
|
53
|
+
let snapshot: ControlSnapshot
|
|
54
|
+
try { snapshot = parseSnapshot(body.snapshot, deps.now()) } catch (e) { return res.status(400).json({ error: 'invalid_snapshot', reason: (e as Error).message }) }
|
|
55
|
+
deps.putSnapshot(snapshot)
|
|
56
|
+
}
|
|
57
|
+
try {
|
|
58
|
+
const input = await deps.input(body.workID, meetings as string[])
|
|
59
|
+
console.log(`[work-orchestrator] input work=${body.workID} rows=${input.rows.length} discovered=${input.discovered} leftOut=${input.leftOut.length} snapshotAge=${input.snapshotAgeSec ?? '-'}s liveAge=${input.liveListAgeSec ?? '-'}s scan=${input.logScanComplete ? 'complete' : 'partial'}`)
|
|
60
|
+
return res.json(input)
|
|
61
|
+
} catch (e) {
|
|
62
|
+
console.warn(`[work-orchestrator] input failed for ${body.workID}: ${(e as Error).message}`)
|
|
63
|
+
return res.status(503).json({ error: 'orchestrator_input_unavailable' })
|
|
64
|
+
}
|
|
65
|
+
})
|
|
66
|
+
router.get('/work/orchestrator/usage', async (req, res) => {
|
|
67
|
+
try { return res.json({ protocolVersion: 1, now: new Date(deps.now()).toISOString(), usage: await deps.usage(req.query.refresh === '1') }) }
|
|
68
|
+
catch { return res.status(503).json({ error: 'usage_unavailable' }) }
|
|
69
|
+
})
|
|
70
|
+
return router
|
|
71
|
+
}
|