@huynle/hub 0.8.2 → 0.8.4
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 +118 -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 +16 -3
- 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 +161 -25
- package/src/central/spoke-ws.ts +2 -0
- package/src/index.ts +28 -0
- package/src/spoke/session-context.ts +28 -8
- package/src/spoke/spoke.ts +153 -10
- package/src/spoke/tracker.ts +6 -1
- package/src/status.ts +101 -19
- package/web/dist/assets/{ArtifactPage-R0ZTpHca.js → ArtifactPage-Cb1DcrQf.js} +1 -1
- package/web/dist/assets/{ArtifactPane-C9KrBna8.js → ArtifactPane-XTnwXl-Y.js} +1 -1
- package/web/dist/assets/{ArtifactWorkbench-sP2mZccC.js → ArtifactWorkbench-C37tZZ3e.js} +1 -1
- package/web/dist/assets/{Library-BXbJvTUS.js → Library-CZSSHvOJ.js} +2 -2
- package/web/dist/assets/ProfilesDestination-dZa3vioh.js +1 -0
- package/web/dist/assets/{TeamsPage-Dgxt8UI3.js → TeamsPage-DJt-Kk84.js} +1 -1
- package/web/dist/assets/Watchtower-BXaUaXO1.js +2 -0
- package/web/dist/assets/index-DVSkeAvn.css +1 -0
- package/web/dist/assets/index-DifgVuKS.js +34 -0
- package/web/dist/assets/{live-OgQWPrSY.js → live-D-RZbeUi.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-DILxLFEq.js} +1 -1
- package/web/dist/assets/renderer3d-Co5yCKMg.css +1 -0
- package/web/dist/assets/{renderer3d-B648jKGO.js → renderer3d-PvN9Qbt2.js} +4 -56
- package/web/dist/assets/{safe-area-CNLyboIj.js → safe-area-BS6ii3M7.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,232 @@
|
|
|
1
|
+
// REC-01 recording event schema (docs/plans/2026-11-05-voice-recording-contract.md, Event schema).
|
|
2
|
+
// The console kinds and exactly their fields: a batch with an unknown kind, an unknown field (also inside nested
|
|
3
|
+
// objects), a wrong type or a missing required field is refused whole, naming the event's seq. Every ✂ text field,
|
|
4
|
+
// from either source, is scrubbed (redactCredentials) and then cut at its limit before it is stored.
|
|
5
|
+
import { type ConsoleEvent, type ConsoleEventKind, type HubEventKind, RECORDING_LIMITS, type RecordingEvent } from "../../protocol"
|
|
6
|
+
import { redactCredentials } from "../profiles/content"
|
|
7
|
+
|
|
8
|
+
/** A field's shape. `text` fields are the ✂ ones: scrubbed and cut at `max`, never refused for length. */
|
|
9
|
+
export type FieldSpec =
|
|
10
|
+
| { readonly t: "int" | "num" | "bool" | "true" }
|
|
11
|
+
| { readonly t: "str"; readonly max: number }
|
|
12
|
+
| { readonly t: "text"; readonly max: number }
|
|
13
|
+
| { readonly t: "enum"; readonly values: readonly string[] }
|
|
14
|
+
| { readonly t: "array"; readonly of: FieldSpec; readonly max: number }
|
|
15
|
+
| { readonly t: "object"; readonly fields: Fields }
|
|
16
|
+
|
|
17
|
+
/** Field name → spec; a name ending in `?` is optional. */
|
|
18
|
+
export type Fields = Readonly<Record<string, FieldSpec>>
|
|
19
|
+
|
|
20
|
+
const int: FieldSpec = { t: "int" }
|
|
21
|
+
const num: FieldSpec = { t: "num" }
|
|
22
|
+
const bool: FieldSpec = { t: "bool" }
|
|
23
|
+
const yes: FieldSpec = { t: "true" }
|
|
24
|
+
/** IDs and labels the console echoes (message IDs, inbox IDs, Decision IDs, option IDs). */
|
|
25
|
+
const id: FieldSpec = { t: "str", max: 300 }
|
|
26
|
+
const label: FieldSpec = { t: "str", max: 60 }
|
|
27
|
+
const text = (max: number): FieldSpec => ({ t: "text", max })
|
|
28
|
+
const oneOf = (...values: string[]): FieldSpec => ({ t: "enum", values })
|
|
29
|
+
const list = (of: FieldSpec, max: number): FieldSpec => ({ t: "array", of, max })
|
|
30
|
+
const object = (fields: Fields): FieldSpec => ({ t: "object", fields })
|
|
31
|
+
|
|
32
|
+
const L = RECORDING_LIMITS
|
|
33
|
+
const ref = object({ messageID: id, ordinal: int, "seq?": int, i: int })
|
|
34
|
+
const AUDIO_RESULTS = ["stored", "skipped-cap", "failed", "off"]
|
|
35
|
+
|
|
36
|
+
/** The console kinds (REC-02 emits exactly these fields). */
|
|
37
|
+
export const CONSOLE_FIELDS: Readonly<Record<ConsoleEventKind, Fields>> = {
|
|
38
|
+
state: { state: label },
|
|
39
|
+
detector: { mode: oneOf("silero", "energy"), "avgMs?": num },
|
|
40
|
+
"speech.start": { bargeIn: bool },
|
|
41
|
+
"speech.end": { segment: int, durationMs: num, speechMs: num, forced: bool },
|
|
42
|
+
"segment.closed": { segment: int, bytes: int, durationMs: num, speechMs: num, forced: bool },
|
|
43
|
+
"stt.sent": { segment: int, bytes: int },
|
|
44
|
+
// `text` is optional: a failed request has none (failed names why).
|
|
45
|
+
"stt.received": { segment: int, "text?": text(L.textChars), "junk?": yes, "empty?": yes, "failed?": text(L.messageChars), "audio?": oneOf(...AUDIO_RESULTS) },
|
|
46
|
+
"utterance.held": { text: text(L.utteranceChars) },
|
|
47
|
+
"utterance.merged": { segments: list(int, 1000), pauseMs: num },
|
|
48
|
+
"utterance.sent": { text: text(L.utteranceChars), segments: list(int, 1000), reason: oneOf("finished", "hold", "max", "command", "typed"), holdMs: num },
|
|
49
|
+
"utterance.accepted": { inboxID: id },
|
|
50
|
+
"utterance.failed": { "status?": int, message: text(L.messageChars) },
|
|
51
|
+
"utterance.command": { command: oneOf("pause", "interrupt"), text: text(L.textChars) },
|
|
52
|
+
heard: { inboxID: id },
|
|
53
|
+
// The contract's `seq?` (the say's seq) collides with the envelope's `seq`: a flat event carries only the latter.
|
|
54
|
+
"say.received": { messageID: id, ordinal: int, chars: int, sentences: int, muted: bool },
|
|
55
|
+
"tts.sentence.queued": { ref, text: text(L.textChars) },
|
|
56
|
+
"tts.request": { ref },
|
|
57
|
+
"tts.first.byte": { ref },
|
|
58
|
+
"tts.sentence.played": { ref },
|
|
59
|
+
"tts.sentence.ended": { ref, playedMs: num, "durationMs?": num, completed: bool },
|
|
60
|
+
"barge.in": {
|
|
61
|
+
cause: oneOf("speech", "typed", "command", "hush", "pause", "stop"),
|
|
62
|
+
"playing?": object({ ref, text: text(L.textChars), playedMs: num, "durationMs?": num }),
|
|
63
|
+
spoken: object({ text: text(L.replyChars), estimated: bool }),
|
|
64
|
+
unplayed: list(object({ ref, text: text(L.textChars) }), 20),
|
|
65
|
+
mutedMessages: list(id, 20),
|
|
66
|
+
},
|
|
67
|
+
"cue.played": { cue: oneOf("earcon", "phrase"), "name?": label, "text?": text(L.textChars) },
|
|
68
|
+
mic: { live: bool, "reason?": label },
|
|
69
|
+
control: { action: oneOf("pause", "resume", "hush", "background", "cues"), "on?": bool },
|
|
70
|
+
"turn.timing": { "speechEnd?": num, "transcript?": num, "accepted?": num, "firstSay?": num, "firstByte?": num, firstAudio: num },
|
|
71
|
+
"decision.voice": { decisionID: id, option: id, outcome: oneOf("answered", "refused", "failed") },
|
|
72
|
+
"clock.sync": { t0: num, t1: num, hubAt: num },
|
|
73
|
+
error: { where: oneOf("stt", "tts", "send", "mic", "upload", "audio"), message: text(L.messageChars) },
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** The ✂ fields of the hub kinds (the hub writes the rest itself: only text needs scrubbing and cutting). */
|
|
77
|
+
const HUB_TEXT: Partial<Record<HubEventKind, Readonly<Record<string, number>>>> = {
|
|
78
|
+
"stt.result": { text: L.textChars },
|
|
79
|
+
"utterance.received": { text: L.utteranceChars },
|
|
80
|
+
"say.sent": { text: L.textChars },
|
|
81
|
+
"reply.full": { text: L.replyChars },
|
|
82
|
+
tool: { summary: L.textChars },
|
|
83
|
+
"wake.arrived": { text: L.textChars },
|
|
84
|
+
"decision.opened": { text: L.textChars },
|
|
85
|
+
error: { message: L.messageChars },
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
class Invalid extends Error {}
|
|
89
|
+
|
|
90
|
+
const isRecord = (v: unknown): v is Record<string, unknown> => typeof v === "object" && v !== null && !Array.isArray(v)
|
|
91
|
+
|
|
92
|
+
function checkValue(spec: FieldSpec, value: unknown, where: string): void {
|
|
93
|
+
const fail = (what: string): never => {
|
|
94
|
+
throw new Invalid(`${where} must be ${what}`)
|
|
95
|
+
}
|
|
96
|
+
switch (spec.t) {
|
|
97
|
+
case "int":
|
|
98
|
+
if (!Number.isSafeInteger(value)) fail("an integer")
|
|
99
|
+
return
|
|
100
|
+
case "num":
|
|
101
|
+
if (typeof value !== "number" || !Number.isFinite(value)) fail("a finite number")
|
|
102
|
+
return
|
|
103
|
+
case "bool":
|
|
104
|
+
if (typeof value !== "boolean") fail("true or false")
|
|
105
|
+
return
|
|
106
|
+
case "true":
|
|
107
|
+
if (value !== true) fail("true")
|
|
108
|
+
return
|
|
109
|
+
case "str":
|
|
110
|
+
if (typeof value !== "string" || value.length > spec.max) fail(`text of at most ${spec.max} characters`)
|
|
111
|
+
return
|
|
112
|
+
case "text":
|
|
113
|
+
if (typeof value !== "string") fail("text")
|
|
114
|
+
return
|
|
115
|
+
case "enum":
|
|
116
|
+
if (typeof value !== "string" || !spec.values.includes(value)) fail(`one of ${spec.values.join(", ")}`)
|
|
117
|
+
return
|
|
118
|
+
case "array":
|
|
119
|
+
if (!Array.isArray(value) || value.length > spec.max) fail(`a list of at most ${spec.max}`)
|
|
120
|
+
;(value as unknown[]).forEach((item, i) => checkValue(spec.of, item, `${where}[${i}]`))
|
|
121
|
+
return
|
|
122
|
+
case "object":
|
|
123
|
+
if (!isRecord(value)) fail("an object")
|
|
124
|
+
checkFields(spec.fields, value as Record<string, unknown>, `${where}.`)
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function checkFields(fields: Fields, value: Record<string, unknown>, prefix: string, skip: ReadonlySet<string> = new Set()): void {
|
|
129
|
+
const known = new Map(Object.entries(fields).map(([name, spec]) => [name.replace(/\?$/, ""), { spec, optional: name.endsWith("?") }]))
|
|
130
|
+
for (const key of Object.keys(value)) {
|
|
131
|
+
if (skip.has(key)) continue
|
|
132
|
+
if (!known.has(key)) throw new Invalid(`unknown field ${prefix}${/^[A-Za-z0-9_.-]{1,40}$/.test(key) ? key : "?"}`)
|
|
133
|
+
}
|
|
134
|
+
for (const [name, { spec, optional }] of known) {
|
|
135
|
+
if (value[name] === undefined) {
|
|
136
|
+
if (optional) continue
|
|
137
|
+
throw new Invalid(`${prefix}${name} is required`)
|
|
138
|
+
}
|
|
139
|
+
checkValue(spec, value[name], `${prefix}${name}`)
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
const ENVELOPE = new Set(["seq", "t", "kind"])
|
|
144
|
+
|
|
145
|
+
export type BatchCheck =
|
|
146
|
+
| { readonly ok: true; readonly events: readonly ConsoleEvent[] }
|
|
147
|
+
| { readonly ok: false; readonly problem: string; readonly seq?: number; readonly limit?: true }
|
|
148
|
+
|
|
149
|
+
/** Checks a console batch (`events` of POST …/events): count, envelopes, kinds and their fields. */
|
|
150
|
+
export function checkConsoleBatch(events: unknown): BatchCheck {
|
|
151
|
+
if (!Array.isArray(events) || events.length === 0) return { ok: false, problem: "Expected {events: [ConsoleEvent, …]} with at least one event" }
|
|
152
|
+
if (events.length > L.eventsPerBatch) return { ok: false, limit: true, problem: `A batch holds at most ${L.eventsPerBatch} events` }
|
|
153
|
+
let previous: number | undefined
|
|
154
|
+
for (const event of events) {
|
|
155
|
+
const seq = isRecord(event) && Number.isSafeInteger(event.seq) ? (event.seq as number) : undefined
|
|
156
|
+
try {
|
|
157
|
+
if (!isRecord(event)) throw new Invalid("an event must be an object")
|
|
158
|
+
if (seq === undefined || seq < 1) throw new Invalid("seq must be an integer from 1")
|
|
159
|
+
if (previous !== undefined && seq !== previous + 1) throw new Invalid("seq must increase by 1 within a batch")
|
|
160
|
+
if (typeof event.t !== "number" || !Number.isFinite(event.t) || event.t < 0) throw new Invalid("t must be a finite number ≥ 0")
|
|
161
|
+
const fields = typeof event.kind === "string" && Object.hasOwn(CONSOLE_FIELDS, event.kind) ? CONSOLE_FIELDS[event.kind as ConsoleEventKind] : undefined
|
|
162
|
+
if (!fields) throw new Invalid("unknown kind")
|
|
163
|
+
checkFields(fields, event, "", ENVELOPE)
|
|
164
|
+
} catch (cause) {
|
|
165
|
+
if (!(cause instanceof Invalid)) throw cause
|
|
166
|
+
return { ok: false, problem: cause.message, ...(seq !== undefined ? { seq } : {}) }
|
|
167
|
+
}
|
|
168
|
+
previous = seq
|
|
169
|
+
}
|
|
170
|
+
return { ok: true, events: events as ConsoleEvent[] }
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/** Scrubs and cuts the text fields of a value by its spec; records the kinds removed and whether anything was cut. */
|
|
174
|
+
function scrubValue(spec: FieldSpec, value: unknown, marks: Marks): unknown {
|
|
175
|
+
if (spec.t === "text" && typeof value === "string") return scrubText(value, spec.max, marks)
|
|
176
|
+
if (spec.t === "array" && Array.isArray(value)) return value.map((item) => scrubValue(spec.of, item, marks))
|
|
177
|
+
if (spec.t === "object" && isRecord(value)) return scrubFields(spec.fields, value, marks)
|
|
178
|
+
return value
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
function scrubFields(fields: Fields, value: Record<string, unknown>, marks: Marks): Record<string, unknown> {
|
|
182
|
+
const out: Record<string, unknown> = {}
|
|
183
|
+
const specs = new Map(Object.entries(fields).map(([name, spec]) => [name.replace(/\?$/, ""), spec]))
|
|
184
|
+
for (const [key, item] of Object.entries(value)) {
|
|
185
|
+
const spec = specs.get(key)
|
|
186
|
+
out[key] = spec ? scrubValue(spec, item, marks) : item
|
|
187
|
+
}
|
|
188
|
+
return out
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
interface Marks {
|
|
192
|
+
kinds: string[]
|
|
193
|
+
truncated: boolean
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/** Redacts a text, then cuts it at `max` characters (redacting first: a cut must never leave half a secret). */
|
|
197
|
+
export function scrubText(value: string, max: number, marks: Marks): string {
|
|
198
|
+
const { text: clean, kinds } = redactCredentials(value)
|
|
199
|
+
for (const kind of kinds) if (!marks.kinds.includes(kind)) marks.kinds.push(kind)
|
|
200
|
+
if (clean.length <= max) return clean
|
|
201
|
+
marks.truncated = true
|
|
202
|
+
return clean.slice(0, max)
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
const withMarks = (record: Record<string, unknown>, marks: Marks): RecordingEvent =>
|
|
206
|
+
({ ...record, ...(marks.kinds.length ? { redacted: marks.kinds } : {}), ...(marks.truncated ? { truncated: true as const } : {}) }) as RecordingEvent
|
|
207
|
+
|
|
208
|
+
/** A checked console event as stored: the envelope, the receive time, scrubbed and cut fields. */
|
|
209
|
+
export function consoleRecord(event: ConsoleEvent, at: number): RecordingEvent {
|
|
210
|
+
const marks: Marks = { kinds: [], truncated: false }
|
|
211
|
+
const { seq, t, kind, ...rest } = event
|
|
212
|
+
const fields = scrubFields(CONSOLE_FIELDS[kind], rest, marks)
|
|
213
|
+
return withMarks({ v: 1, src: "console", seq, t, at, kind, ...fields }, marks)
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
// Not `seq`: hub events have no envelope seq, and say.sent carries the say's own.
|
|
217
|
+
const HUB_ENVELOPE = ["v", "src", "id", "at", "kind", "ts", "redacted", "truncated"]
|
|
218
|
+
|
|
219
|
+
/** A hub event as stored: `id` and `at`, its text fields scrubbed and cut. */
|
|
220
|
+
export function hubRecord(kind: HubEventKind, fields: Record<string, unknown>, meta: { readonly id: string; readonly at: number }): RecordingEvent {
|
|
221
|
+
const marks: Marks = { kinds: [], truncated: false }
|
|
222
|
+
const limits = HUB_TEXT[kind] ?? {}
|
|
223
|
+
const out: Record<string, unknown> = {}
|
|
224
|
+
for (const [key, value] of Object.entries(fields)) {
|
|
225
|
+
if (value === undefined) continue
|
|
226
|
+
const max = limits[key]
|
|
227
|
+
out[key] = max !== undefined && typeof value === "string" ? scrubText(value, max, marks) : value
|
|
228
|
+
}
|
|
229
|
+
// The envelope always wins over a field of the same name.
|
|
230
|
+
for (const key of HUB_ENVELOPE) delete out[key]
|
|
231
|
+
return withMarks({ v: 1, src: "hub", id: meta.id, at: meta.at, kind, ...out }, marks)
|
|
232
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
// REC-01 recording setting (contract: Setting and indicator): per user, off by default, version-checked by the
|
|
2
|
+
// caller's expectedRevision. StateStore collection `voice-recording-settings`, key `settings`.
|
|
3
|
+
import type { RecordingSettings } from "../../protocol"
|
|
4
|
+
import type { Clock, StateStore } from "../backend/types"
|
|
5
|
+
|
|
6
|
+
export const SETTINGS_COLLECTION = "voice-recording-settings"
|
|
7
|
+
const KEY = "settings"
|
|
8
|
+
export const DEFAULT_SETTINGS: RecordingSettings = { enabled: false, revision: 0 }
|
|
9
|
+
|
|
10
|
+
export type SettingsWrite = { readonly ok: true; readonly settings: RecordingSettings; readonly changed: boolean } | { readonly ok: false; readonly currentRevision: number }
|
|
11
|
+
|
|
12
|
+
export class RecordingSettingsStore {
|
|
13
|
+
constructor(
|
|
14
|
+
private readonly state: StateStore,
|
|
15
|
+
private readonly now: Clock,
|
|
16
|
+
) {}
|
|
17
|
+
|
|
18
|
+
async get(userID: string): Promise<RecordingSettings> {
|
|
19
|
+
return (await this.state.get<RecordingSettings>(userID, SETTINGS_COLLECTION, KEY))?.value ?? DEFAULT_SETTINGS
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** Sets `enabled` when the stored revision is `expectedRevision`; a stale one answers the current revision. */
|
|
23
|
+
async set(userID: string, enabled: boolean, expectedRevision: number): Promise<SettingsWrite> {
|
|
24
|
+
for (let attempt = 0; attempt < 8; attempt++) {
|
|
25
|
+
const stored = await this.state.get<RecordingSettings>(userID, SETTINGS_COLLECTION, KEY)
|
|
26
|
+
const current = stored?.value ?? DEFAULT_SETTINGS
|
|
27
|
+
if (current.revision !== expectedRevision) return { ok: false, currentRevision: current.revision }
|
|
28
|
+
const next: RecordingSettings = { enabled, revision: current.revision + 1, updatedAt: this.now() }
|
|
29
|
+
const written = await this.state.put(userID, SETTINGS_COLLECTION, KEY, next, { ifVersion: stored?.version ?? 0 })
|
|
30
|
+
if (written.ok) return { ok: true, settings: next, changed: current.enabled !== enabled }
|
|
31
|
+
}
|
|
32
|
+
const current = await this.get(userID)
|
|
33
|
+
return { ok: false, currentRevision: current.revision }
|
|
34
|
+
}
|
|
35
|
+
}
|
|
@@ -0,0 +1,316 @@
|
|
|
1
|
+
// REC-01 recording storage (contract: Recordings → Storage). Small records in the StateStore under the real user
|
|
2
|
+
// ID (`voice-recordings`, every update CAS), one index collection per recording (`voice-recording-index:<id>`:
|
|
3
|
+
// console pages c…, hub pages h…, audio segments a…), and the bytes in the BlobStore under the separate tenant
|
|
4
|
+
// `rec~<userID>` (artifact recovery walks only real user IDs, so it never sweeps them; usage() of the tenant is
|
|
5
|
+
// exactly the recording usage). Deletion is real: blobs (unless another recording of the user indexes the hash,
|
|
6
|
+
// with touchedBefore = the deletion start), then the index, then the record.
|
|
7
|
+
import { randomBytes } from "node:crypto"
|
|
8
|
+
import {
|
|
9
|
+
RECORDING_LIMITS,
|
|
10
|
+
RECORDING_MEDIA_TYPES,
|
|
11
|
+
type BlobRef,
|
|
12
|
+
type RecordingAnchor,
|
|
13
|
+
type RecordingAudioResult,
|
|
14
|
+
type RecordingClient,
|
|
15
|
+
type RecordingEndReason,
|
|
16
|
+
type RecordingSummary,
|
|
17
|
+
} from "../../protocol"
|
|
18
|
+
import type { BlobBody, BlobStore } from "../artifacts/blob-store"
|
|
19
|
+
import type { Clock, Entry, StateStore, Versioned } from "../backend/types"
|
|
20
|
+
|
|
21
|
+
export const RECORDINGS = "voice-recordings"
|
|
22
|
+
/** Unindexed rec~ blobs younger than this are left to their writer (a write in flight); older ones are swept. */
|
|
23
|
+
export const ORPHAN_GRACE_MS = 60 * 60_000
|
|
24
|
+
export const indexCollection = (recordingID: string) => `voice-recording-index:${recordingID}`
|
|
25
|
+
/**
|
|
26
|
+
* Recordings being or recently deleted (key: the ID, value {at}): the sweep empties their index collections again
|
|
27
|
+
* (a write racing the deletion, another pod's late page), since a StateStore cannot list collections.
|
|
28
|
+
*/
|
|
29
|
+
export const TOMBSTONES = "voice-recording-tombstones"
|
|
30
|
+
/** How long a tombstone is kept after its index was found empty. */
|
|
31
|
+
export const TOMBSTONE_MS = 24 * 60 * 60_000
|
|
32
|
+
/** The BlobStore tenant of a user's recordings (user IDs never contain `~`). */
|
|
33
|
+
export const blobTenant = (userID: string) => `rec~${userID}`
|
|
34
|
+
|
|
35
|
+
/** Speech provider facts at start (no keys, no base URLs). */
|
|
36
|
+
export interface SpeechFacts {
|
|
37
|
+
readonly stt?: { readonly provider?: string; readonly model?: string }
|
|
38
|
+
readonly tts?: { readonly provider?: string; readonly model?: string; readonly voice?: string }
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** A recording as stored: the summary plus internal fields (kept small: ≤ 4 KiB). */
|
|
42
|
+
export interface RecordingRecord extends RecordingSummary, SpeechFacts {
|
|
43
|
+
readonly clientID: string
|
|
44
|
+
readonly client: RecordingClient
|
|
45
|
+
readonly anchor: RecordingAnchor
|
|
46
|
+
/** Highest contiguous console seq stored. */
|
|
47
|
+
readonly consoleThrough: number
|
|
48
|
+
readonly consoleGaps: number
|
|
49
|
+
readonly hubPages: number
|
|
50
|
+
readonly pages: number
|
|
51
|
+
readonly eventBytes: number
|
|
52
|
+
readonly lastEventAt: number
|
|
53
|
+
/** `<what>:<scope>` caps already reported (cap.reached once each). */
|
|
54
|
+
readonly capNoted: readonly string[]
|
|
55
|
+
readonly skipped: { readonly cap: number; readonly failed: number }
|
|
56
|
+
/** Appends are still accepted until then (the 10 s grace after a hub-initiated conversation_off end). */
|
|
57
|
+
readonly graceUntil?: number
|
|
58
|
+
readonly deletingAt?: number
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export interface ConsolePageEntry {
|
|
62
|
+
readonly hash: string
|
|
63
|
+
readonly bytes: number
|
|
64
|
+
readonly first: number
|
|
65
|
+
readonly last: number
|
|
66
|
+
readonly at: number
|
|
67
|
+
}
|
|
68
|
+
export interface HubPageEntry {
|
|
69
|
+
readonly hash: string
|
|
70
|
+
readonly bytes: number
|
|
71
|
+
readonly count: number
|
|
72
|
+
readonly at: number
|
|
73
|
+
}
|
|
74
|
+
export interface AudioEntry {
|
|
75
|
+
readonly hash: string
|
|
76
|
+
readonly bytes: number
|
|
77
|
+
readonly durationMs?: number
|
|
78
|
+
readonly at: number
|
|
79
|
+
readonly via: "transcribe" | "put"
|
|
80
|
+
/** (USER CHANGE) The segment's transcript matched a secret pattern: its text was scrubbed, the audio kept. */
|
|
81
|
+
readonly redacted?: true
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export const consoleKey = (firstSeq: number) => `c${String(firstSeq).padStart(10, "0")}`
|
|
85
|
+
export const hubKey = (podTag: string, n: number) => `h${podTag}-${String(n).padStart(8, "0")}`
|
|
86
|
+
export const audioKey = (n: number) => `a${String(n).padStart(6, "0")}`
|
|
87
|
+
/** Segment numbers that fit the 6-digit index key and file name. */
|
|
88
|
+
export const isSegmentNumber = (n: number) => Number.isSafeInteger(n) && n >= 0 && n <= 999_999
|
|
89
|
+
|
|
90
|
+
const BASE32 = "abcdefghijklmnopqrstuvwxyz234567"
|
|
91
|
+
/** "rec_" + 20 base32 characters (100 random bits). */
|
|
92
|
+
export function newRecordingID(): string {
|
|
93
|
+
const bytes = randomBytes(20)
|
|
94
|
+
let out = ""
|
|
95
|
+
for (const b of bytes) out += BASE32[b & 31]
|
|
96
|
+
return `rec_${out}`
|
|
97
|
+
}
|
|
98
|
+
export const isRecordingID = (id: string) => /^rec_[a-z2-7]{20}$/.test(id)
|
|
99
|
+
|
|
100
|
+
/** The public part of a record. */
|
|
101
|
+
export function summaryOf(r: RecordingRecord): RecordingSummary {
|
|
102
|
+
return {
|
|
103
|
+
id: r.id,
|
|
104
|
+
kind: r.kind,
|
|
105
|
+
sessionID: r.sessionID,
|
|
106
|
+
...(r.sessionTitle !== undefined ? { sessionTitle: r.sessionTitle } : {}),
|
|
107
|
+
deviceID: r.deviceID,
|
|
108
|
+
...(r.deviceName !== undefined ? { deviceName: r.deviceName } : {}),
|
|
109
|
+
state: r.state,
|
|
110
|
+
startedAt: r.startedAt,
|
|
111
|
+
...(r.endedAt !== undefined ? { endedAt: r.endedAt } : {}),
|
|
112
|
+
...(r.endReason !== undefined ? { endReason: r.endReason } : {}),
|
|
113
|
+
expiresAt: r.expiresAt,
|
|
114
|
+
bytes: r.bytes,
|
|
115
|
+
segments: r.segments,
|
|
116
|
+
events: r.events,
|
|
117
|
+
audio: r.audio,
|
|
118
|
+
redactions: r.redactions,
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
export class RecordingStore {
|
|
123
|
+
constructor(
|
|
124
|
+
readonly state: StateStore,
|
|
125
|
+
readonly blobs: BlobStore,
|
|
126
|
+
private readonly now: Clock,
|
|
127
|
+
) {}
|
|
128
|
+
|
|
129
|
+
async get(userID: string, id: string): Promise<Versioned<RecordingRecord> | undefined> {
|
|
130
|
+
if (!isRecordingID(id)) return undefined
|
|
131
|
+
return this.state.get<RecordingRecord>(userID, RECORDINGS, id)
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** The record when it is the user's and not being deleted (every route's view). */
|
|
135
|
+
async visible(userID: string, id: string): Promise<RecordingRecord | undefined> {
|
|
136
|
+
const got = await this.get(userID, id)
|
|
137
|
+
return got && got.value.state !== "deleting" ? got.value : undefined
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
async list(userID: string): Promise<RecordingRecord[]> {
|
|
141
|
+
return (await this.state.list<RecordingRecord>(userID, RECORDINGS)).map((e) => e.value)
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
async create(userID: string, record: RecordingRecord): Promise<boolean> {
|
|
145
|
+
return (await this.state.put(userID, RECORDINGS, record.id, record, { ifVersion: 0 })).ok
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Read-modify-write with CAS, retried on a lost race. `change` answers the next record, or undefined to leave it
|
|
150
|
+
* (the current one is answered). Missing → undefined.
|
|
151
|
+
*/
|
|
152
|
+
async update(userID: string, id: string, change: (current: RecordingRecord) => RecordingRecord | undefined): Promise<{ record: RecordingRecord; changed: boolean } | undefined> {
|
|
153
|
+
for (let attempt = 0; attempt < 32; attempt++) {
|
|
154
|
+
const got = await this.get(userID, id)
|
|
155
|
+
if (!got) return undefined
|
|
156
|
+
const next = change(got.value)
|
|
157
|
+
if (!next) return { record: got.value, changed: false }
|
|
158
|
+
if ((await this.state.put(userID, RECORDINGS, id, next, { ifVersion: got.version })).ok) return { record: next, changed: true }
|
|
159
|
+
}
|
|
160
|
+
throw new Error("recording record kept changing")
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
async index<T = unknown>(userID: string, id: string): Promise<Entry<T>[]> {
|
|
164
|
+
return this.state.list<T>(userID, indexCollection(id))
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
async indexEntry<T = unknown>(userID: string, id: string, key: string): Promise<T | undefined> {
|
|
168
|
+
return (await this.state.get<T>(userID, indexCollection(id), key))?.value
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/** Writes an index key once (ifVersion 0); false when it already exists. */
|
|
172
|
+
async putIndex(userID: string, id: string, key: string, value: unknown): Promise<boolean> {
|
|
173
|
+
return (await this.state.put(userID, indexCollection(id), key, value, { ifVersion: 0 })).ok
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
async putBlob(userID: string, body: BlobBody, mediaType: (typeof RECORDING_MEDIA_TYPES)[number], maxBytes: number, quotaBytes: number): Promise<BlobRef> {
|
|
177
|
+
return this.blobs.put(blobTenant(userID), body, mediaType, { maxBytes, quotaBytes, mediaTypes: RECORDING_MEDIA_TYPES })
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
async usage(userID: string): Promise<number> {
|
|
181
|
+
return (await this.blobs.usage(blobTenant(userID))).bytes
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/** Every hash another recording of the user indexes (deletion keeps those). */
|
|
185
|
+
private async otherHashes(userID: string, except: ReadonlySet<string>): Promise<Set<string>> {
|
|
186
|
+
const hashes = new Set<string>()
|
|
187
|
+
for (const record of await this.list(userID)) {
|
|
188
|
+
if (except.has(record.id)) continue
|
|
189
|
+
for (const entry of await this.index<{ hash?: string }>(userID, record.id)) if (typeof entry.value.hash === "string") hashes.add(entry.value.hash)
|
|
190
|
+
}
|
|
191
|
+
return hashes
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/** Step 1 helper: remembers the deletion (the sweep re-checks the index collection later). */
|
|
195
|
+
async tombstone(userID: string, id: string, at: number): Promise<void> {
|
|
196
|
+
await this.state.put(userID, TOMBSTONES, id, { at })
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* After a write into a recording (an index key `key` naming `hash`): when the recording is gone or being deleted,
|
|
201
|
+
* the write is undone (a deletion that ran meanwhile did not see it). Answers whether it was undone.
|
|
202
|
+
*/
|
|
203
|
+
async undoIfDeleted(userID: string, id: string, key: string, hash: string): Promise<boolean> {
|
|
204
|
+
const got = await this.get(userID, id)
|
|
205
|
+
if (got && got.value.state !== "deleting") return false
|
|
206
|
+
const entry = await this.indexEntry<{ hash?: string }>(userID, id, key)
|
|
207
|
+
if (entry?.hash === hash) await this.state.delete(userID, indexCollection(id), key)
|
|
208
|
+
await this.dropIfUnindexed(userID, hash, this.now())
|
|
209
|
+
return true
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/**
|
|
213
|
+
* The orphan pass of the sweep: tombstoned recordings whose record is gone get their index emptied again; rec~ blobs
|
|
214
|
+
* no recording indexes and untouched for ORPHAN_GRACE_MS are deleted. Answers how many items it removed.
|
|
215
|
+
*/
|
|
216
|
+
async sweepOrphans(userID: string): Promise<number> {
|
|
217
|
+
const now = this.now()
|
|
218
|
+
let removed = 0
|
|
219
|
+
for (const tomb of await this.state.list<{ at?: number }>(userID, TOMBSTONES)) {
|
|
220
|
+
if (await this.get(userID, tomb.key)) continue // still deleting: finishDelete runs (or the sweep resumes it)
|
|
221
|
+
const entries = await this.index(userID, tomb.key)
|
|
222
|
+
for (const entry of entries) {
|
|
223
|
+
await this.state.delete(userID, indexCollection(tomb.key), entry.key)
|
|
224
|
+
removed++
|
|
225
|
+
}
|
|
226
|
+
if (!entries.length && now - (tomb.value.at ?? 0) >= TOMBSTONE_MS) await this.state.delete(userID, TOMBSTONES, tomb.key)
|
|
227
|
+
}
|
|
228
|
+
const indexed = await this.otherHashes(userID, new Set())
|
|
229
|
+
let cursor: string | undefined
|
|
230
|
+
const tenant = blobTenant(userID)
|
|
231
|
+
for (;;) {
|
|
232
|
+
const page = await this.blobs.list(tenant, { limit: 200, ...(cursor ? { cursor } : {}) })
|
|
233
|
+
for (const blob of page.items) {
|
|
234
|
+
if (indexed.has(blob.hash) || now - blob.touchedAt < ORPHAN_GRACE_MS) continue
|
|
235
|
+
if (await this.blobs.delete(tenant, blob.hash, { touchedBefore: now - ORPHAN_GRACE_MS + 1 })) removed++
|
|
236
|
+
}
|
|
237
|
+
if (!page.cursor) break
|
|
238
|
+
cursor = page.cursor
|
|
239
|
+
}
|
|
240
|
+
return removed
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/** Steps 2–4 of a deletion (the record is already `deleting`): blobs, index, record. */
|
|
244
|
+
async finishDelete(userID: string, id: string): Promise<void> {
|
|
245
|
+
const got = await this.get(userID, id)
|
|
246
|
+
if (!got) return
|
|
247
|
+
const deletingAt = got.value.deletingAt ?? this.now()
|
|
248
|
+
const entries = await this.index<{ hash?: string }>(userID, id)
|
|
249
|
+
const keep = await this.otherHashes(userID, new Set([id]))
|
|
250
|
+
for (const hash of new Set(entries.map((e) => e.value.hash).filter((h): h is string => typeof h === "string"))) {
|
|
251
|
+
if (keep.has(hash)) continue
|
|
252
|
+
await this.blobs.delete(blobTenant(userID), hash, { touchedBefore: deletingAt + 1 })
|
|
253
|
+
}
|
|
254
|
+
for (const entry of entries) await this.state.delete(userID, indexCollection(id), entry.key)
|
|
255
|
+
await this.state.delete(userID, RECORDINGS, id)
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/** Discards a blob nothing indexes (a lost first-write race). */
|
|
259
|
+
async dropIfUnindexed(userID: string, hash: string, at: number): Promise<void> {
|
|
260
|
+
if ((await this.otherHashes(userID, new Set())).has(hash)) return
|
|
261
|
+
await this.blobs.delete(blobTenant(userID), hash, { touchedBefore: at + 1 })
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
export interface StartFacts {
|
|
266
|
+
readonly id: string
|
|
267
|
+
readonly kind: RecordingSummary["kind"]
|
|
268
|
+
readonly sessionID: string
|
|
269
|
+
readonly sessionTitle?: string
|
|
270
|
+
readonly deviceID: string
|
|
271
|
+
readonly deviceName?: string
|
|
272
|
+
readonly clientID: string
|
|
273
|
+
readonly client: RecordingClient
|
|
274
|
+
readonly anchor: RecordingAnchor
|
|
275
|
+
readonly audio: boolean
|
|
276
|
+
readonly at: number
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
export function newRecord(facts: StartFacts, speech: SpeechFacts): RecordingRecord {
|
|
280
|
+
return {
|
|
281
|
+
id: facts.id,
|
|
282
|
+
kind: facts.kind,
|
|
283
|
+
sessionID: facts.sessionID,
|
|
284
|
+
...(facts.sessionTitle ? { sessionTitle: facts.sessionTitle } : {}),
|
|
285
|
+
deviceID: facts.deviceID,
|
|
286
|
+
...(facts.deviceName ? { deviceName: facts.deviceName } : {}),
|
|
287
|
+
state: "active",
|
|
288
|
+
startedAt: facts.at,
|
|
289
|
+
expiresAt: facts.at + RECORDING_LIMITS.retentionMs,
|
|
290
|
+
bytes: 0,
|
|
291
|
+
segments: 0,
|
|
292
|
+
events: 0,
|
|
293
|
+
audio: facts.audio,
|
|
294
|
+
redactions: 0,
|
|
295
|
+
clientID: facts.clientID,
|
|
296
|
+
client: facts.client,
|
|
297
|
+
anchor: facts.anchor,
|
|
298
|
+
consoleThrough: 0,
|
|
299
|
+
consoleGaps: 0,
|
|
300
|
+
hubPages: 0,
|
|
301
|
+
pages: 0,
|
|
302
|
+
eventBytes: 0,
|
|
303
|
+
lastEventAt: facts.at,
|
|
304
|
+
capNoted: [],
|
|
305
|
+
skipped: { cap: 0, failed: 0 },
|
|
306
|
+
...speech,
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
/** The record ended (unchanged when it already is). */
|
|
311
|
+
export function ended(record: RecordingRecord, reason: RecordingEndReason, at: number, graceUntil?: number): RecordingRecord | undefined {
|
|
312
|
+
if (record.state !== "active") return undefined
|
|
313
|
+
return { ...record, state: "ended", endedAt: at, endReason: reason, ...(graceUntil !== undefined ? { graceUntil } : {}) }
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
export type { RecordingAudioResult }
|