@huynle/hub 0.8.2 → 0.8.3
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 +80 -0
- package/README.md +31 -0
- package/package.json +1 -1
- package/packages/claude-code/package.json +1 -1
- package/packages/cli/dist/hub-cli.js +35 -7
- package/packages/cli/package.json +1 -1
- package/packages/codex/package.json +1 -1
- package/packages/node/package.json +1 -1
- package/packages/preset/package.json +1 -1
- package/packages/protocol/package.json +1 -1
- package/packages/protocol/src/index.ts +9 -1
- package/packages/protocol/src/voice-recording.ts +107 -0
- package/packages/runtime-openshell/package.json +1 -1
- package/packages/runtime-sdk/package.json +1 -1
- package/packages/sdk/package.json +1 -1
- package/packages/spoke-sdk/package.json +1 -1
- package/src/central/agent/routes.ts +7 -1
- package/src/central/artifacts/blob-store.ts +6 -1
- package/src/central/artifacts/blob-support.ts +3 -1
- package/src/central/concierge/board-render.ts +34 -11
- package/src/central/concierge/index.ts +1 -1
- package/src/central/concierge/notifier.ts +2 -1
- package/src/central/config.ts +4 -0
- package/src/central/csp.ts +55 -0
- package/src/central/http.ts +85 -9
- package/src/central/profiles/content.ts +16 -0
- package/src/central/recording/clock.ts +76 -0
- package/src/central/recording/concierge-taps.ts +104 -0
- package/src/central/recording/export.ts +215 -0
- package/src/central/recording/index.ts +658 -0
- package/src/central/recording/ingest.ts +209 -0
- package/src/central/recording/retention.ts +71 -0
- package/src/central/recording/routes.ts +281 -0
- package/src/central/recording/schema.ts +232 -0
- package/src/central/recording/settings.ts +35 -0
- package/src/central/recording/store.ts +316 -0
- package/src/central/recording/taps.ts +105 -0
- package/src/central/recording/zip.ts +138 -0
- package/src/central/registry.ts +68 -3
- package/src/central/spoke-ws.ts +2 -0
- package/src/spoke/session-context.ts +28 -8
- package/src/spoke/spoke.ts +3 -0
- package/src/spoke/tracker.ts +6 -1
- package/src/status.ts +31 -5
- package/web/dist/assets/{ArtifactPage-R0ZTpHca.js → ArtifactPage-CSotAWob.js} +1 -1
- package/web/dist/assets/{ArtifactPane-C9KrBna8.js → ArtifactPane-DPHjiWce.js} +1 -1
- package/web/dist/assets/{ArtifactWorkbench-sP2mZccC.js → ArtifactWorkbench-CkmWnP6L.js} +1 -1
- package/web/dist/assets/{Library-BXbJvTUS.js → Library-DTLEyekn.js} +2 -2
- package/web/dist/assets/ProfilesDestination-GriuOpiZ.js +1 -0
- package/web/dist/assets/{TeamsPage-Dgxt8UI3.js → TeamsPage-C-5lYeeM.js} +1 -1
- package/web/dist/assets/Watchtower-DOlPsiIy.js +2 -0
- package/web/dist/assets/index-6mdQ7eUY.js +34 -0
- package/web/dist/assets/index-C5OmwA1y.css +1 -0
- package/web/dist/assets/{live-OgQWPrSY.js → live-CDWcyavI.js} +1 -1
- package/web/dist/assets/{pane-layout-DWXfDU-1.js → pane-layout-Csk-vPNe.js} +1 -1
- package/web/dist/assets/{renderer2d-DjEipyto.js → renderer2d-CNFdTDTr.js} +1 -1
- package/web/dist/assets/{renderer3d-B648jKGO.js → renderer3d-C45jPvYX.js} +4 -56
- package/web/dist/assets/renderer3d-Co5yCKMg.css +1 -0
- package/web/dist/assets/{safe-area-CNLyboIj.js → safe-area-sA9a8vYJ.js} +1 -1
- package/web/dist/assets/{source-CDU04kYw.js → source-BFLJQ43_.js} +1 -1
- package/web/dist/assets/{ui-Dh1-78Cl.js → ui-Qo2Pd9-O.js} +1 -1
- package/web/dist/index.html +5 -16
- package/web/dist/theme-boot.js +12 -0
- package/web/dist/assets/ProfilesDestination-D7XyLTJ7.js +0 -1
- package/web/dist/assets/Watchtower-DJHx6cWw.js +0 -2
- package/web/dist/assets/index-BabrNDB3.css +0 -1
- package/web/dist/assets/index-g4V9mfSr.js +0 -34
|
@@ -0,0 +1,658 @@
|
|
|
1
|
+
// REC-01 voice & Concierge recording mode, server half (docs/plans/2026-11-05-voice-recording-contract.md,
|
|
2
|
+
// docs/voice-recording.md). The RecordingService owns the setting, the recordings (records, index, blobs under the
|
|
3
|
+
// `rec~<userID>` tenant), console and hub events, audio, export, delete and the retention sweep. The hub wires its
|
|
4
|
+
// routes (user-only console routes), the voice route hooks, the registry and Concierge taps and the leader.
|
|
5
|
+
//
|
|
6
|
+
// Rules kept everywhere: a recording belongs to one user, one session and one device; every lookup is by
|
|
7
|
+
// (user.id, id) and anything else answers 404 Not found; appends need the recording's own device; hub taps record
|
|
8
|
+
// only while a recording is active for that session, only into the session owner's recordings, and never change
|
|
9
|
+
// what the voice path answers (failures are logged without content or IDs).
|
|
10
|
+
import { randomBytes } from "node:crypto"
|
|
11
|
+
import {
|
|
12
|
+
RECORDING_AUDIO_TYPE,
|
|
13
|
+
RECORDING_HEADER,
|
|
14
|
+
RECORDING_LIMITS,
|
|
15
|
+
RECORDING_REF_HEADER,
|
|
16
|
+
RECORDING_SEGMENT_HEADER,
|
|
17
|
+
type HubEventKind,
|
|
18
|
+
type RecordingAudioResult,
|
|
19
|
+
type RecordingEndReason,
|
|
20
|
+
type RecordingKind,
|
|
21
|
+
type RecordingSettings,
|
|
22
|
+
type RecordingUsage,
|
|
23
|
+
} from "../../protocol"
|
|
24
|
+
import { type BlobStore, BlobQuotaError } from "../artifacts/blob-store"
|
|
25
|
+
import type { Decision } from "../agent/decisions"
|
|
26
|
+
import { partyKey } from "../agent/envelope"
|
|
27
|
+
import type { Bus, Clock, StateStore } from "../backend/types"
|
|
28
|
+
import { redactCredentials } from "../profiles/content"
|
|
29
|
+
import type { ConciergeCallFields } from "./concierge-taps"
|
|
30
|
+
import { HubEventWriter, ingestConsole, PAGE_QUOTA_BYTES } from "./ingest"
|
|
31
|
+
import { RecordingRetention } from "./retention"
|
|
32
|
+
import { RecordingSettingsStore } from "./settings"
|
|
33
|
+
import {
|
|
34
|
+
type AudioEntry,
|
|
35
|
+
audioKey,
|
|
36
|
+
ended,
|
|
37
|
+
isSegmentNumber,
|
|
38
|
+
newRecord,
|
|
39
|
+
newRecordingID,
|
|
40
|
+
type RecordingRecord,
|
|
41
|
+
RecordingStore,
|
|
42
|
+
type SpeechFacts,
|
|
43
|
+
summaryOf,
|
|
44
|
+
} from "./store"
|
|
45
|
+
import { parseTtsRef, RegistryTaps } from "./taps"
|
|
46
|
+
|
|
47
|
+
const L = RECORDING_LIMITS
|
|
48
|
+
export const RECORDINGS_TOPIC = "recordings.changed"
|
|
49
|
+
/** How long the per-pod active index is trusted without a bus refresh. */
|
|
50
|
+
export const ACTIVE_TTL_MS = 30_000
|
|
51
|
+
/** The grace after a hub-initiated conversation_off end during which the console's last batches are accepted. */
|
|
52
|
+
export const END_GRACE_MS = 10_000
|
|
53
|
+
/** /api/transcribe bodies up to this size are stored before answering (so a failure can be retried with PUT). */
|
|
54
|
+
export const SYNC_AUDIO_BYTES = 512 * 1024
|
|
55
|
+
/** Exports per user at once. */
|
|
56
|
+
export const EXPORTS_PER_USER = 2
|
|
57
|
+
|
|
58
|
+
/** A console SSE message (IDs, state and counts only). */
|
|
59
|
+
export type RecordingMessage = { type: "recording"; [key: string]: unknown }
|
|
60
|
+
|
|
61
|
+
export interface RecordingServiceOptions {
|
|
62
|
+
readonly state: StateStore
|
|
63
|
+
readonly blobs: BlobStore
|
|
64
|
+
readonly bus?: Bus
|
|
65
|
+
readonly now?: Clock
|
|
66
|
+
/** The cluster pod (page keys and hub event IDs get a per-process tag after it). */
|
|
67
|
+
readonly podID?: string
|
|
68
|
+
readonly version?: string
|
|
69
|
+
/** The session is the user's (registry.owns). */
|
|
70
|
+
readonly owns: (userID: string, sessionID: string) => boolean
|
|
71
|
+
readonly sessionTitle: (sessionID: string) => string | undefined
|
|
72
|
+
/** The session is in conversation mode (an idle recording of it is not ended). */
|
|
73
|
+
readonly conversation: (sessionID: string) => boolean
|
|
74
|
+
readonly isConcierge: (userID: string, sessionID: string) => Promise<boolean>
|
|
75
|
+
/** STT/TTS provider facts of the user (no keys, no base URLs). */
|
|
76
|
+
readonly speech: (userID: string) => SpeechFacts
|
|
77
|
+
/** SSE to the user's devices (except one). */
|
|
78
|
+
readonly notify: (userID: string, message: RecordingMessage, exceptDevice?: string) => void
|
|
79
|
+
/** Hub audit (no content). */
|
|
80
|
+
readonly audit: (entry: { actor: string; action: string; target?: string; detail?: string }) => Promise<void>
|
|
81
|
+
readonly users: () => Promise<readonly string[]>
|
|
82
|
+
readonly log?: (message: string) => void
|
|
83
|
+
readonly sweepMs?: number
|
|
84
|
+
readonly hubFlushMs?: number
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
interface ActiveRef {
|
|
88
|
+
readonly id: string
|
|
89
|
+
readonly sessionID: string
|
|
90
|
+
readonly kind: RecordingKind
|
|
91
|
+
readonly deviceID: string
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export type AudioOutcome = { readonly kind: "stored"; readonly entry: AudioEntry; readonly fresh: boolean } | { readonly kind: "cap" } | { readonly kind: "gone" } | { readonly kind: "failed"; readonly cause: unknown }
|
|
95
|
+
|
|
96
|
+
export interface TranscribeTap {
|
|
97
|
+
/** The answer's x-hub-recording-audio after STT answered (the write of a small body is done by then). */
|
|
98
|
+
finish(text: string, providerMs: number): Promise<RecordingAudioResult>
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export interface SpeakTap {
|
|
102
|
+
/** Wraps the TTS answer: tts.synth {ref, chars, firstByteMs, ms} is recorded when it ends. */
|
|
103
|
+
wrap<T extends Uint8Array | ReadableStream<Uint8Array>>(audio: T): T
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export class RecordingService {
|
|
107
|
+
readonly store: RecordingStore
|
|
108
|
+
readonly settings: RecordingSettingsStore
|
|
109
|
+
readonly retention: RecordingRetention
|
|
110
|
+
/** The registry's recording tap (set on Registry.recordingTap). */
|
|
111
|
+
readonly registryTap: RegistryTaps
|
|
112
|
+
private readonly writer: HubEventWriter
|
|
113
|
+
private readonly now: Clock
|
|
114
|
+
private readonly podTag: string
|
|
115
|
+
private readonly active = new Map<string, { at: number; refs: ActiveRef[] }>()
|
|
116
|
+
private readonly tails = new Map<string, Promise<void>>()
|
|
117
|
+
private readonly exports = new Map<string, number>()
|
|
118
|
+
private readonly background = new Set<Promise<unknown>>()
|
|
119
|
+
private readonly opened = new Set<string>()
|
|
120
|
+
private unsubscribe?: () => Promise<void>
|
|
121
|
+
|
|
122
|
+
constructor(private readonly options: RecordingServiceOptions) {
|
|
123
|
+
this.now = options.now ?? Date.now
|
|
124
|
+
// Unique per process: a restarted pod never reuses another's page keys.
|
|
125
|
+
this.podTag = `${(options.podID ?? "hub").replace(/[^A-Za-z0-9]/g, "").slice(0, 12) || "hub"}${randomBytes(3).toString("hex")}`
|
|
126
|
+
this.store = new RecordingStore(options.state, options.blobs, this.now)
|
|
127
|
+
this.settings = new RecordingSettingsStore(options.state, this.now)
|
|
128
|
+
this.writer = new HubEventWriter({
|
|
129
|
+
store: this.store,
|
|
130
|
+
now: this.now,
|
|
131
|
+
podTag: this.podTag,
|
|
132
|
+
...(options.hubFlushMs !== undefined ? { flushMs: options.hubFlushMs } : {}),
|
|
133
|
+
failed: (cause) => this.log(`[hub] recording hub events not written: ${(cause as Error).message}`),
|
|
134
|
+
overLimit: (userID, id, scope) =>
|
|
135
|
+
void (async () => {
|
|
136
|
+
const record = await this.store.visible(userID, id)
|
|
137
|
+
if (record) await this.capReached(userID, record, "events", scope, scope === "user" ? PAGE_QUOTA_BYTES : L.eventsPerRecording)
|
|
138
|
+
await this.endByHub(userID, id, "limit")
|
|
139
|
+
})().catch(() => undefined),
|
|
140
|
+
})
|
|
141
|
+
this.retention = new RecordingRetention({ users: options.users, sweepUser: (userID) => this.sweepUser(userID), ...(options.sweepMs !== undefined ? { sweepMs: options.sweepMs } : {}), log: (m) => this.log(m) })
|
|
142
|
+
this.registryTap = new RegistryTaps({ hub: (userID, sessionID, kind, fields) => this.hub(userID, sessionID, kind, fields), mayRecord: (userID) => this.mayRecord(userID) })
|
|
143
|
+
if (options.bus) {
|
|
144
|
+
options.bus
|
|
145
|
+
.subscribe(RECORDINGS_TOPIC, (message) => {
|
|
146
|
+
const userID = (message as { userID?: unknown } | undefined)?.userID
|
|
147
|
+
if (typeof userID === "string") this.active.delete(userID)
|
|
148
|
+
})
|
|
149
|
+
.then((off) => (this.unsubscribe = off))
|
|
150
|
+
.catch((cause) => this.log(`[hub] recordings bus subscription failed: ${(cause as Error).message}`))
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
private log(message: string): void {
|
|
155
|
+
;(this.options.log ?? console.error)(message)
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/** Tracks a background promise (settled() waits for them). */
|
|
159
|
+
private track<T>(promise: Promise<T>): Promise<T> {
|
|
160
|
+
this.background.add(promise)
|
|
161
|
+
void promise.finally(() => this.background.delete(promise)).catch(() => undefined)
|
|
162
|
+
return promise
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// ---- the active index ------------------------------------------------------------------------------------
|
|
166
|
+
|
|
167
|
+
/** The user's active recordings (per pod, refreshed by the bus and after ACTIVE_TTL_MS). */
|
|
168
|
+
private async activeOf(userID: string): Promise<ActiveRef[]> {
|
|
169
|
+
const cached = this.active.get(userID)
|
|
170
|
+
if (cached && this.now() - cached.at < ACTIVE_TTL_MS) return cached.refs
|
|
171
|
+
const refs = (await this.store.list(userID)).filter((r) => r.state === "active").map((r) => ({ id: r.id, sessionID: r.sessionID, kind: r.kind, deviceID: r.deviceID }))
|
|
172
|
+
this.active.set(userID, { at: this.now(), refs })
|
|
173
|
+
if (this.active.size > 10_000) this.active.delete(this.active.keys().next().value as string)
|
|
174
|
+
return refs
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/** False only when the user surely has no active recording. */
|
|
178
|
+
mayRecord(userID: string): boolean {
|
|
179
|
+
const cached = this.active.get(userID)
|
|
180
|
+
return !(cached && this.now() - cached.at < ACTIVE_TTL_MS && cached.refs.length === 0)
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/** A recording of the user started or ended: this pod's index and every other pod's are refreshed. */
|
|
184
|
+
private changed(userID: string, sessionID: string): void {
|
|
185
|
+
this.active.delete(userID)
|
|
186
|
+
void this.options.bus?.publish(RECORDINGS_TOPIC, { userID, sessionID }).catch(() => undefined)
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// ---- hub events ------------------------------------------------------------------------------------------
|
|
190
|
+
|
|
191
|
+
/** Records a hub event into every active recording of (user, session), in call order per user. */
|
|
192
|
+
hub(userID: string, sessionID: string, kind: HubEventKind, fields: Record<string, unknown>, only?: RecordingKind): void {
|
|
193
|
+
void this.hubNow(userID, sessionID, kind, fields, only)
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/** hub(), resolving once the event is buffered. */
|
|
197
|
+
hubNow(userID: string, sessionID: string, kind: HubEventKind, fields: Record<string, unknown>, only?: RecordingKind): Promise<void> {
|
|
198
|
+
const at = this.now()
|
|
199
|
+
const add = (refs: readonly ActiveRef[]) => {
|
|
200
|
+
for (const ref of refs) if (ref.sessionID === sessionID && (only === undefined || ref.kind === only)) this.writer.add(userID, ref.id, kind, fields, at)
|
|
201
|
+
}
|
|
202
|
+
const cached = this.active.get(userID)
|
|
203
|
+
if (!this.tails.has(userID) && cached && this.now() - cached.at < ACTIVE_TTL_MS) {
|
|
204
|
+
add(cached.refs)
|
|
205
|
+
return Promise.resolve()
|
|
206
|
+
}
|
|
207
|
+
const run = (this.tails.get(userID) ?? Promise.resolve())
|
|
208
|
+
.then(async () => add(await this.activeOf(userID)))
|
|
209
|
+
.catch((cause) => this.log(`[hub] recording tap failed: ${(cause as Error).message}`))
|
|
210
|
+
this.tails.set(userID, run)
|
|
211
|
+
void run.finally(() => {
|
|
212
|
+
if (this.tails.get(userID) === run) this.tails.delete(userID)
|
|
213
|
+
})
|
|
214
|
+
return run
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/** Writes every buffered hub event now. */
|
|
218
|
+
async flush(): Promise<void> {
|
|
219
|
+
await Promise.all([...this.tails.values()])
|
|
220
|
+
await this.writer.flush()
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/** Waits for background work (large audio writes, delete-all) and flushes. */
|
|
224
|
+
async settled(): Promise<void> {
|
|
225
|
+
while (this.background.size) await Promise.allSettled([...this.background])
|
|
226
|
+
await this.flush()
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
async stop(): Promise<void> {
|
|
230
|
+
this.retention.stop()
|
|
231
|
+
await this.settled().catch(() => undefined)
|
|
232
|
+
await this.unsubscribe?.().catch(() => undefined)
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
// ---- settings --------------------------------------------------------------------------------------------
|
|
236
|
+
|
|
237
|
+
async usage(userID: string): Promise<RecordingUsage> {
|
|
238
|
+
const [bytes, records] = await Promise.all([this.store.usage(userID), this.store.list(userID)])
|
|
239
|
+
return { bytes, recordings: records.filter((r) => r.state !== "deleting").length, capBytes: L.userBytes }
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
async setEnabled(userID: string, deviceID: string, enabled: boolean, expectedRevision: number): Promise<{ ok: true; settings: RecordingSettings } | { ok: false; currentRevision: number }> {
|
|
243
|
+
const written = await this.settings.set(userID, enabled, expectedRevision)
|
|
244
|
+
if (!written.ok) return written
|
|
245
|
+
this.options.notify(userID, { type: "recording", settings: written.settings }, deviceID)
|
|
246
|
+
if (written.changed) {
|
|
247
|
+
await this.options.audit({ actor: userID, action: enabled ? "recording.enable" : "recording.disable" })
|
|
248
|
+
if (!enabled) for (const r of await this.store.list(userID)) if (r.state === "active") await this.endByHub(userID, r.id, "disabled")
|
|
249
|
+
}
|
|
250
|
+
return { ok: true, settings: written.settings }
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
// ---- start and end ---------------------------------------------------------------------------------------
|
|
254
|
+
|
|
255
|
+
/** Ends a recording (unchanged when it already ended): recording.ended, the pages written, SSE, the index refreshed. */
|
|
256
|
+
async end(userID: string, id: string, reason: RecordingEndReason, graceUntil?: number): Promise<RecordingRecord | undefined> {
|
|
257
|
+
const at = this.now()
|
|
258
|
+
const result = await this.store.update(userID, id, (current) => ended(current, reason, at, graceUntil))
|
|
259
|
+
if (!result) return undefined
|
|
260
|
+
if (result.changed) {
|
|
261
|
+
this.writer.add(userID, id, "recording.ended", { reason }, at)
|
|
262
|
+
await this.writer.flush(userID, id)
|
|
263
|
+
this.changed(userID, result.record.sessionID)
|
|
264
|
+
this.options.notify(userID, { type: "recording", recordingID: id, state: "ended", reason })
|
|
265
|
+
}
|
|
266
|
+
return result.record
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
private endByHub(userID: string, id: string, reason: RecordingEndReason, graceUntil?: number) {
|
|
270
|
+
return this.end(userID, id, reason, graceUntil)
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
async start(userID: string, device: { id: string; name?: string }, input: StartInput): Promise<StartResult> {
|
|
274
|
+
if (!(await this.settings.get(userID)).enabled) return { kind: "off" }
|
|
275
|
+
if (!this.options.owns(userID, input.sessionID)) return { kind: "not_found" }
|
|
276
|
+
const records = await this.store.list(userID)
|
|
277
|
+
const same = records.find((r) => r.clientID === input.clientID && r.deviceID === device.id && r.state !== "deleting")
|
|
278
|
+
if (same) return { kind: "existing", record: same }
|
|
279
|
+
const superseded = records.filter((r) => r.state === "active" && r.deviceID === device.id && r.sessionID === input.sessionID)
|
|
280
|
+
if (records.filter((r) => r.state === "active").length - superseded.length >= L.activePerUser) return { kind: "rate_limited" }
|
|
281
|
+
if (records.filter((r) => r.state !== "deleting").length >= L.recordingsStored) return { kind: "limit" }
|
|
282
|
+
for (const r of superseded) await this.end(userID, r.id, "superseded")
|
|
283
|
+
const at = this.now()
|
|
284
|
+
const kind: RecordingKind = (await this.options.isConcierge(userID, input.sessionID).catch(() => false)) ? "concierge" : "voice"
|
|
285
|
+
const audio = (await this.store.usage(userID)) < L.userBytes
|
|
286
|
+
const title = this.options.sessionTitle(input.sessionID)?.trim().slice(0, 80)
|
|
287
|
+
const speech = this.options.speech(userID)
|
|
288
|
+
const record = newRecord(
|
|
289
|
+
{
|
|
290
|
+
id: newRecordingID(),
|
|
291
|
+
kind,
|
|
292
|
+
sessionID: input.sessionID,
|
|
293
|
+
...(title ? { sessionTitle: title } : {}),
|
|
294
|
+
deviceID: device.id,
|
|
295
|
+
...(device.name ? { deviceName: device.name.slice(0, 80) } : {}),
|
|
296
|
+
clientID: input.clientID,
|
|
297
|
+
client: input.client ?? {},
|
|
298
|
+
anchor: input.anchor ?? { perf: 0, epoch: at },
|
|
299
|
+
audio,
|
|
300
|
+
at,
|
|
301
|
+
},
|
|
302
|
+
speech,
|
|
303
|
+
)
|
|
304
|
+
const created = audio ? record : { ...record, capNoted: ["audio:user"] }
|
|
305
|
+
if (!(await this.store.create(userID, created))) throw new Error("recording ID collision")
|
|
306
|
+
// `recordingKind`: the contract's `kind` field would collide with the event envelope's `kind`.
|
|
307
|
+
this.writer.add(userID, record.id, "recording.started", { recordingKind: kind, sessionID: input.sessionID, deviceID: device.id, ...(speech.stt ? { stt: speech.stt } : {}), ...(speech.tts ? { tts: speech.tts } : {}) }, at)
|
|
308
|
+
if (!audio) this.writer.add(userID, record.id, "cap.reached", { what: "audio", scope: "user", limit: L.userBytes }, at)
|
|
309
|
+
this.changed(userID, input.sessionID)
|
|
310
|
+
return { kind: "created", record: created }
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
// ---- appends ---------------------------------------------------------------------------------------------
|
|
314
|
+
|
|
315
|
+
/** The recording when this device may append to it now: active, or ended within its grace. */
|
|
316
|
+
async appendable(userID: string, deviceID: string, id: string): Promise<{ kind: "ok"; record: RecordingRecord } | { kind: "not_found" } | { kind: "ended"; reason: RecordingEndReason }> {
|
|
317
|
+
const record = await this.store.visible(userID, id)
|
|
318
|
+
if (!record || record.deviceID !== deviceID) return { kind: "not_found" }
|
|
319
|
+
const now = this.now()
|
|
320
|
+
if (record.state === "active" && now - record.startedAt >= L.maxDurationMs) {
|
|
321
|
+
const after = await this.end(userID, id, "max_duration")
|
|
322
|
+
return { kind: "ended", reason: after?.endReason ?? "max_duration" }
|
|
323
|
+
}
|
|
324
|
+
if (record.state === "active") return { kind: "ok", record }
|
|
325
|
+
if (record.graceUntil !== undefined && now <= record.graceUntil) return { kind: "ok", record }
|
|
326
|
+
return { kind: "ended", reason: record.endReason ?? "stopped" }
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
async appendConsole(userID: string, record: RecordingRecord, events: Parameters<typeof ingestConsole>[3]): Promise<{ kind: "ok"; through: number; audio: boolean } | { kind: "limit" } | { kind: "not_found" }> {
|
|
330
|
+
const ingested = await ingestConsole(this.store, userID, record, events, this.now)
|
|
331
|
+
if (ingested.kind === "gone") return { kind: "not_found" }
|
|
332
|
+
if (ingested.kind === "limit" || ingested.kind === "quota") {
|
|
333
|
+
if (ingested.kind === "quota") await this.capReached(userID, record, "events", "user", PAGE_QUOTA_BYTES)
|
|
334
|
+
else await this.capReached(userID, record, "events", "recording", L.eventsPerRecording)
|
|
335
|
+
await this.end(userID, record.id, "limit")
|
|
336
|
+
return { kind: "limit" }
|
|
337
|
+
}
|
|
338
|
+
return { kind: "ok", through: ingested.record.consoleThrough, audio: ingested.record.audio }
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
/** cap.reached once per (what, scope); an audio cap also turns the recording's audio off (SSE audio:false). */
|
|
342
|
+
private async capReached(userID: string, record: RecordingRecord, what: "audio" | "events", scope: "user" | "recording", limit: number): Promise<void> {
|
|
343
|
+
const key = `${what}:${scope}`
|
|
344
|
+
let note = false
|
|
345
|
+
let audioOff = false
|
|
346
|
+
const result = await this.store.update(userID, record.id, (current) => {
|
|
347
|
+
note = !current.capNoted.includes(key)
|
|
348
|
+
audioOff = what === "audio" && current.audio
|
|
349
|
+
if (!note && !audioOff) return undefined
|
|
350
|
+
return { ...current, capNoted: note ? [...current.capNoted, key] : current.capNoted, ...(what === "audio" ? { audio: false } : {}) }
|
|
351
|
+
})
|
|
352
|
+
if (!result?.changed) return
|
|
353
|
+
if (note) this.writer.add(userID, record.id, "cap.reached", { what, scope, limit })
|
|
354
|
+
if (audioOff) this.options.notify(userID, { type: "recording", recordingID: record.id, audio: false })
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
/** The user cap for a new segment of `bytes` (cap.reached / audio off when hit); the blob quota enforces it atomically. */
|
|
358
|
+
private async audioAllowed(userID: string, record: RecordingRecord, bytes: number): Promise<boolean> {
|
|
359
|
+
if (bytes > L.segmentBytes) return false
|
|
360
|
+
if (!record.audio) return false
|
|
361
|
+
if ((await this.store.usage(userID)) + bytes > L.userBytes) {
|
|
362
|
+
await this.capReached(userID, record, "audio", "user", L.userBytes)
|
|
363
|
+
return false
|
|
364
|
+
}
|
|
365
|
+
return true
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
/**
|
|
369
|
+
* Reserves a segment's bytes and count in the record (CAS), so parallel writes never pass recordingBytes or
|
|
370
|
+
* segmentsPerRecording: "ok", "cap" (cap.reached, audio off), or "gone" (deleted meanwhile).
|
|
371
|
+
*/
|
|
372
|
+
private async reserveAudio(userID: string, record: RecordingRecord, bytes: number): Promise<"ok" | "cap" | "gone"> {
|
|
373
|
+
let verdict = "gone" as "ok" | "cap" | "over" | "gone"
|
|
374
|
+
const at = this.now()
|
|
375
|
+
await this.store.update(userID, record.id, (current) => {
|
|
376
|
+
if (current.state === "deleting") return void (verdict = "gone")
|
|
377
|
+
if (!current.audio) return void (verdict = "cap")
|
|
378
|
+
if (current.segments >= L.segmentsPerRecording || current.bytes + bytes > L.recordingBytes) return void (verdict = "over")
|
|
379
|
+
verdict = "ok"
|
|
380
|
+
return { ...current, segments: current.segments + 1, bytes: current.bytes + bytes, lastEventAt: at }
|
|
381
|
+
})
|
|
382
|
+
if (verdict === "over") {
|
|
383
|
+
await this.capReached(userID, record, "audio", "recording", L.recordingBytes)
|
|
384
|
+
return "cap"
|
|
385
|
+
}
|
|
386
|
+
return verdict
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
/** Gives a reservation back (the write failed or lost the first-write race). */
|
|
390
|
+
private async releaseAudio(userID: string, id: string, bytes: number): Promise<void> {
|
|
391
|
+
await this.store.update(userID, id, (current) => (current.state === "deleting" ? undefined : { ...current, segments: Math.max(0, current.segments - 1), bytes: Math.max(0, current.bytes - bytes) }))
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
/** Stores a segment's WAV (first write wins); the caps are checked (and reserved) first. */
|
|
395
|
+
async storeAudio(userID: string, record: RecordingRecord, n: number, audio: Uint8Array, via: AudioEntry["via"], redacted = false): Promise<AudioOutcome> {
|
|
396
|
+
const existing = await this.store.indexEntry<AudioEntry>(userID, record.id, audioKey(n))
|
|
397
|
+
if (existing) return { kind: "stored", entry: existing, fresh: false }
|
|
398
|
+
if (!(await this.audioAllowed(userID, record, audio.length))) return { kind: "cap" }
|
|
399
|
+
const reserved = await this.reserveAudio(userID, record, audio.length)
|
|
400
|
+
if (reserved === "cap") return { kind: "cap" }
|
|
401
|
+
if (reserved === "gone") return { kind: "gone" }
|
|
402
|
+
let ref
|
|
403
|
+
try {
|
|
404
|
+
ref = await this.store.putBlob(userID, audio, RECORDING_AUDIO_TYPE, L.segmentBytes, L.userBytes)
|
|
405
|
+
} catch (cause) {
|
|
406
|
+
await this.releaseAudio(userID, record.id, audio.length)
|
|
407
|
+
if (cause instanceof BlobQuotaError) {
|
|
408
|
+
await this.capReached(userID, record, "audio", "user", L.userBytes)
|
|
409
|
+
return { kind: "cap" }
|
|
410
|
+
}
|
|
411
|
+
return { kind: "failed", cause }
|
|
412
|
+
}
|
|
413
|
+
const at = this.now()
|
|
414
|
+
const entry: AudioEntry = { hash: ref.hash, bytes: ref.bytes, at, via, ...(redacted ? { redacted: true as const } : {}) }
|
|
415
|
+
if (!(await this.store.putIndex(userID, record.id, audioKey(n), entry))) {
|
|
416
|
+
await this.releaseAudio(userID, record.id, audio.length)
|
|
417
|
+
const first = await this.store.indexEntry<AudioEntry>(userID, record.id, audioKey(n))
|
|
418
|
+
if (first?.hash !== ref.hash) await this.store.dropIfUnindexed(userID, ref.hash, at)
|
|
419
|
+
return first ? { kind: "stored", entry: first, fresh: false } : { kind: "failed", cause: new Error("segment index lost") }
|
|
420
|
+
}
|
|
421
|
+
// A deletion that ran meanwhile did not see this segment: undo it (delete is real).
|
|
422
|
+
if (await this.store.undoIfDeleted(userID, record.id, audioKey(n), ref.hash)) return { kind: "gone" }
|
|
423
|
+
return { kind: "stored", entry, fresh: true }
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
// ---- voice route taps ------------------------------------------------------------------------------------
|
|
427
|
+
|
|
428
|
+
/** /api/transcribe with the console's headers: undefined without them (dictation is unchanged). */
|
|
429
|
+
async transcribeTap(userID: string, deviceID: string, headers: Headers, audio: Uint8Array, format: string): Promise<TranscribeTap | undefined> {
|
|
430
|
+
const id = headers.get(RECORDING_HEADER)
|
|
431
|
+
if (id === null) return undefined
|
|
432
|
+
const off: TranscribeTap = { finish: async () => "off" }
|
|
433
|
+
const segmentText = headers.get(RECORDING_SEGMENT_HEADER) ?? ""
|
|
434
|
+
const n = /^\d{1,6}$/.test(segmentText) ? Number(segmentText) : -1
|
|
435
|
+
let record: RecordingRecord | undefined
|
|
436
|
+
try {
|
|
437
|
+
record = await this.store.visible(userID, id)
|
|
438
|
+
} catch {
|
|
439
|
+
return off
|
|
440
|
+
}
|
|
441
|
+
// Unknown, foreign, ended, another device's, or not a WAV/segment: the headers are ignored (no oracle).
|
|
442
|
+
if (!record || record.state !== "active" || record.deviceID !== deviceID || !isSegmentNumber(n) || format !== "wav") return off
|
|
443
|
+
const live = record
|
|
444
|
+
const at = this.now()
|
|
445
|
+
this.writer.add(userID, id, "stt.request", { segment: n, bytes: audio.length, format }, at)
|
|
446
|
+
const model = live.stt?.model
|
|
447
|
+
return {
|
|
448
|
+
finish: async (text, providerMs) => {
|
|
449
|
+
// (USER CHANGE vs the contract text) A transcript matching a secret pattern: the text is scrubbed (the
|
|
450
|
+
// hub record of stt.result is), the audio kept, and the segment marked redacted.
|
|
451
|
+
const redacted = redactCredentials(text).kinds.length > 0
|
|
452
|
+
let result: RecordingAudioResult
|
|
453
|
+
try {
|
|
454
|
+
if (!(await this.audioAllowedQuick(userID, live, audio.length))) result = "skipped-cap"
|
|
455
|
+
else if (audio.length <= SYNC_AUDIO_BYTES) result = this.audioResult(userID, id, n, await this.storeAudio(userID, live, n, audio, "transcribe", redacted))
|
|
456
|
+
else {
|
|
457
|
+
result = "stored"
|
|
458
|
+
void this.track(this.storeAudio(userID, live, n, audio, "transcribe", redacted).then((outcome) => this.audioResult(userID, id, n, outcome)))
|
|
459
|
+
}
|
|
460
|
+
} catch (cause) {
|
|
461
|
+
result = this.audioResult(userID, id, n, { kind: "failed", cause })
|
|
462
|
+
}
|
|
463
|
+
this.writer.add(userID, id, "stt.result", { segment: n, text, providerMs, ...(model ? { model } : {}), audio: result })
|
|
464
|
+
return result
|
|
465
|
+
},
|
|
466
|
+
}
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
/** The cap checks of a transcribe body (the store re-checks them). */
|
|
470
|
+
private async audioAllowedQuick(userID: string, record: RecordingRecord, bytes: number): Promise<boolean> {
|
|
471
|
+
const current = (await this.store.get(userID, record.id))?.value ?? record
|
|
472
|
+
return this.audioAllowed(userID, current, bytes)
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
private audioResult(userID: string, id: string, n: number, outcome: AudioOutcome): RecordingAudioResult {
|
|
476
|
+
if (outcome.kind === "stored") return "stored"
|
|
477
|
+
if (outcome.kind === "cap") return "skipped-cap"
|
|
478
|
+
// Deleted meanwhile: like an unknown recording.
|
|
479
|
+
if (outcome.kind === "gone") return "off"
|
|
480
|
+
this.log(`[hub] recording audio not stored: ${(outcome.cause as Error)?.message ?? "error"}`)
|
|
481
|
+
this.writer.add(userID, id, "error", { where: "audio", segment: n, message: (outcome.cause as Error)?.message ?? "write failed" })
|
|
482
|
+
return "failed"
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
/** /api/speak with x-hub-recording (and x-hub-tts-ref): tts.synth timing; the reply audio is never stored. */
|
|
486
|
+
async speakTap(userID: string, deviceID: string, headers: Headers, chars: number): Promise<SpeakTap | undefined> {
|
|
487
|
+
const id = headers.get(RECORDING_HEADER)
|
|
488
|
+
if (id === null) return undefined
|
|
489
|
+
const record = await this.store.visible(userID, id).catch(() => undefined)
|
|
490
|
+
if (!record || record.state !== "active" || record.deviceID !== deviceID) return undefined
|
|
491
|
+
const ref = parseTtsRef(headers.get(RECORDING_REF_HEADER))
|
|
492
|
+
const started = this.now()
|
|
493
|
+
const done = (firstByteMs: number | undefined) => this.writer.add(userID, id, "tts.synth", { ...(ref ? { ref } : {}), chars, ...(firstByteMs !== undefined ? { firstByteMs } : {}), ms: this.now() - started })
|
|
494
|
+
return {
|
|
495
|
+
wrap: <T extends Uint8Array | ReadableStream<Uint8Array>>(audio: T): T => {
|
|
496
|
+
if (audio instanceof Uint8Array) {
|
|
497
|
+
const ms = this.now() - started
|
|
498
|
+
done(ms)
|
|
499
|
+
return audio
|
|
500
|
+
}
|
|
501
|
+
let first: number | undefined
|
|
502
|
+
const timed = (audio as ReadableStream<Uint8Array>).pipeThrough(
|
|
503
|
+
new TransformStream<Uint8Array, Uint8Array>({
|
|
504
|
+
transform: (chunk, controller) => {
|
|
505
|
+
first ??= this.now() - started
|
|
506
|
+
controller.enqueue(chunk)
|
|
507
|
+
},
|
|
508
|
+
flush: () => done(first),
|
|
509
|
+
}),
|
|
510
|
+
)
|
|
511
|
+
return timed as T
|
|
512
|
+
},
|
|
513
|
+
}
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
/** POST …/conversation: the event, and (off) the session's recordings end with a grace for the last batches. */
|
|
517
|
+
async conversationChanged(userID: string, sessionID: string, on: boolean): Promise<void> {
|
|
518
|
+
await this.hubNow(userID, sessionID, "conversation", { on })
|
|
519
|
+
if (on) return
|
|
520
|
+
for (const ref of await this.activeOf(userID)) {
|
|
521
|
+
if (ref.sessionID === sessionID) await this.end(userID, ref.id, "conversation_off", this.now() + END_GRACE_MS)
|
|
522
|
+
}
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
/** A session was deleted: its recordings end. */
|
|
526
|
+
async sessionDeleted(userID: string, sessionID: string): Promise<void> {
|
|
527
|
+
for (const ref of await this.activeOf(userID)) if (ref.sessionID === sessionID) await this.end(userID, ref.id, "session_deleted")
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
/** Decisions: opened (asked by the recorded session) and answered (with via). */
|
|
531
|
+
decisionChanged(userID: string, decision: Decision): void {
|
|
532
|
+
const from = decision.from
|
|
533
|
+
if (from.kind !== "session") return
|
|
534
|
+
const sessionID = from.sessionID
|
|
535
|
+
if (decision.state === "open" && !this.opened.has(decision.id)) {
|
|
536
|
+
this.opened.add(decision.id)
|
|
537
|
+
if (this.opened.size > 2000) this.opened.delete(this.opened.values().next().value as string)
|
|
538
|
+
this.hub(userID, sessionID, "decision.opened", { decisionID: decision.id, from: partyKey(from), tier: decision.tier, urgency: decision.urgency, options: decision.options.map((o) => o.id), text: decision.question })
|
|
539
|
+
} else if (decision.state === "answered" && decision.answer?.answeredBy === "user") {
|
|
540
|
+
this.hub(userID, sessionID, "decision.answered", { decisionID: decision.id, ...(decision.answer.option !== undefined ? { option: decision.answer.option } : {}), via: decision.answer.via ?? "tap" })
|
|
541
|
+
}
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
/** concierge.tool: an agent call of a recorded Concierge session (no body, no response). */
|
|
545
|
+
conciergeCall(userID: string, sessionID: string, fields: ConciergeCallFields): void {
|
|
546
|
+
if (!this.mayRecord(userID)) return
|
|
547
|
+
this.hub(userID, sessionID, "concierge.tool", { ...fields }, "concierge")
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
/** wake.arrived: the notifier's send to the recorded Concierge session succeeded. */
|
|
551
|
+
wake(userID: string, sessionID: string, fields: Record<string, unknown>): void {
|
|
552
|
+
this.hub(userID, sessionID, "wake.arrived", fields, "concierge")
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
// ---- reads -----------------------------------------------------------------------------------------------
|
|
556
|
+
|
|
557
|
+
async visible(userID: string, id: string): Promise<RecordingRecord | undefined> {
|
|
558
|
+
return this.store.visible(userID, id)
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
async list(userID: string): Promise<RecordingRecord[]> {
|
|
562
|
+
return (await this.store.list(userID)).filter((r) => r.state !== "deleting").sort((a, b) => b.startedAt - a.startedAt || (a.id < b.id ? 1 : -1))
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
/** One export slot of the user, or undefined at EXPORTS_PER_USER; release it when the stream ends. */
|
|
566
|
+
exportSlot(userID: string): (() => void) | undefined {
|
|
567
|
+
const running = this.exports.get(userID) ?? 0
|
|
568
|
+
if (running >= EXPORTS_PER_USER) return undefined
|
|
569
|
+
this.exports.set(userID, running + 1)
|
|
570
|
+
let released = false
|
|
571
|
+
return () => {
|
|
572
|
+
if (released) return
|
|
573
|
+
released = true
|
|
574
|
+
const left = (this.exports.get(userID) ?? 1) - 1
|
|
575
|
+
if (left <= 0) this.exports.delete(userID)
|
|
576
|
+
else this.exports.set(userID, left)
|
|
577
|
+
}
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
// ---- delete ----------------------------------------------------------------------------------------------
|
|
581
|
+
|
|
582
|
+
/** Step 1: ends an active recording (stopped), then marks it deleting (invisible to every route from then on). */
|
|
583
|
+
private async markDeleting(userID: string, id: string): Promise<boolean> {
|
|
584
|
+
const record = await this.store.visible(userID, id)
|
|
585
|
+
if (!record) return false
|
|
586
|
+
if (record.state === "active") await this.end(userID, id, "stopped")
|
|
587
|
+
await this.writer.flush(userID, id)
|
|
588
|
+
const at = this.now()
|
|
589
|
+
await this.store.tombstone(userID, id, at)
|
|
590
|
+
const result = await this.store.update(userID, id, (current) => (current.state === "deleting" ? undefined : { ...current, state: "deleting", deletingAt: at }))
|
|
591
|
+
if (!result?.changed) return false
|
|
592
|
+
this.writer.discard(userID, id)
|
|
593
|
+
this.options.notify(userID, { type: "recording", recordingID: id, state: "deleted" })
|
|
594
|
+
return true
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
async deleteOne(userID: string, id: string): Promise<boolean> {
|
|
598
|
+
if (!(await this.markDeleting(userID, id))) return false
|
|
599
|
+
await this.store.finishDelete(userID, id)
|
|
600
|
+
await this.options.audit({ actor: userID, action: "recording.delete", target: id, detail: "1" })
|
|
601
|
+
return true
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
/** Every recording of the user: marked now, finished in the background (the sweep finishes what a crash left). */
|
|
605
|
+
async deleteAll(userID: string): Promise<number> {
|
|
606
|
+
const ids: string[] = []
|
|
607
|
+
for (const record of await this.store.list(userID)) if (record.state !== "deleting" && (await this.markDeleting(userID, record.id))) ids.push(record.id)
|
|
608
|
+
await this.options.audit({ actor: userID, action: "recording.delete", target: "all", detail: String(ids.length) })
|
|
609
|
+
void this.track(
|
|
610
|
+
(async () => {
|
|
611
|
+
for (const id of ids) await this.store.finishDelete(userID, id).catch((cause) => this.log(`[hub] recording delete not finished: ${(cause as Error).message}`))
|
|
612
|
+
})(),
|
|
613
|
+
)
|
|
614
|
+
return ids.length
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
// ---- retention -------------------------------------------------------------------------------------------
|
|
618
|
+
|
|
619
|
+
/** One user's sweep: finish deletions, delete expired, end idle and over-long active recordings. */
|
|
620
|
+
async sweepUser(userID: string): Promise<number> {
|
|
621
|
+
const now = this.now()
|
|
622
|
+
let expired = 0
|
|
623
|
+
for (const record of await this.store.list(userID)) {
|
|
624
|
+
if (record.state === "deleting") {
|
|
625
|
+
await this.store.finishDelete(userID, record.id)
|
|
626
|
+
continue
|
|
627
|
+
}
|
|
628
|
+
if (record.expiresAt <= now) {
|
|
629
|
+
if (record.state === "active") await this.end(userID, record.id, "stale")
|
|
630
|
+
if (await this.markDeleting(userID, record.id)) {
|
|
631
|
+
await this.store.finishDelete(userID, record.id)
|
|
632
|
+
expired++
|
|
633
|
+
}
|
|
634
|
+
continue
|
|
635
|
+
}
|
|
636
|
+
if (record.state !== "active") continue
|
|
637
|
+
if (now - record.startedAt >= L.maxDurationMs) await this.end(userID, record.id, "max_duration")
|
|
638
|
+
else if (now - record.lastEventAt >= L.idleEndMs && !this.options.conversation(record.sessionID)) await this.end(userID, record.id, "stale")
|
|
639
|
+
}
|
|
640
|
+
// Leftovers of writes that raced a deletion (index keys of gone recordings, unindexed blobs past the grace).
|
|
641
|
+
await this.store.sweepOrphans(userID)
|
|
642
|
+
if (expired) await this.options.audit({ actor: "system", action: "recording.purge", detail: `${expired} expired` })
|
|
643
|
+
return expired
|
|
644
|
+
}
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
export interface StartInput {
|
|
648
|
+
readonly sessionID: string
|
|
649
|
+
readonly clientID: string
|
|
650
|
+
readonly anchor?: RecordingRecord["anchor"]
|
|
651
|
+
readonly client?: RecordingRecord["client"]
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
export type StartResult =
|
|
655
|
+
| { readonly kind: "off" | "not_found" | "rate_limited" | "limit" }
|
|
656
|
+
| { readonly kind: "existing" | "created"; readonly record: RecordingRecord }
|
|
657
|
+
|
|
658
|
+
export { summaryOf }
|