@huynle/hub 0.10.1 → 0.11.1
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 +36 -8
- package/package.json +1 -1
- package/packages/claude-code/package.json +1 -1
- package/packages/claude-code/src/sessions.ts +4 -1
- package/packages/cli/dist/hub-cli.js +851 -466
- package/packages/cli/package.json +1 -1
- package/packages/cli/src/cli.ts +12 -7
- package/packages/cli/src/decisions.ts +52 -4
- package/packages/cli/src/format.ts +9 -0
- package/packages/cli/src/grants-mcp.ts +65 -0
- package/packages/cli/src/mcp-operations.ts +49 -0
- package/packages/cli/src/mcp.ts +26 -4
- package/packages/cli/src/subscriptions-mcp.ts +76 -0
- package/packages/cli/src/team-intake-mcp.ts +2 -1
- package/packages/cli/src/teams-mcp.ts +9 -3
- package/packages/codex/package.json +1 -1
- package/packages/codex/src/sessions.ts +14 -3
- package/packages/node/package.json +1 -1
- package/packages/preset/package.json +1 -1
- package/packages/protocol/package.json +1 -1
- package/packages/protocol/schema/api/v1/_root.json +119 -0
- package/packages/protocol/schema/api/v1/account.json +419 -0
- package/packages/protocol/schema/api/v1/decisions.json +635 -0
- package/packages/protocol/schema/api/v1/discovery.json +110 -0
- package/packages/protocol/schema/api/v1/events.json +679 -0
- package/packages/protocol/schema/api/v1/session-actions.json +660 -0
- package/packages/protocol/schema/api/v1/sessions.json +2055 -0
- package/packages/protocol/schema/api/v1/speech.json +492 -0
- package/packages/protocol/schema/api/v1/unspecified.json +282 -0
- package/packages/protocol/schema/app-delivery/v1.schema.json +67 -5
- package/packages/protocol/schema/app-front/v1.schema.json +7 -3
- package/packages/protocol/src/api-v1.ts +8 -0
- package/packages/protocol/src/index.ts +2 -0
- package/packages/protocol/src/teams.ts +16 -0
- package/packages/protocol/src/voice-recording.ts +39 -2
- package/packages/runtime-openshell/image/entry.sh +3 -1
- package/packages/runtime-openshell/image/start.sh +3 -1
- 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/sdk/src/app-front.ts +2 -2
- package/packages/sdk/src/ask-fields.ts +76 -0
- package/packages/sdk/src/client.ts +24 -2
- package/packages/sdk/src/envelope.ts +3 -2
- package/packages/sdk/src/index.ts +1 -0
- package/packages/sdk/src/team-intake.ts +6 -0
- package/packages/sdk/src/teams.ts +4 -0
- package/packages/sdk/src/types.ts +36 -4
- package/packages/sdk/src/wake.ts +9 -4
- package/packages/spoke-sdk/package.json +1 -1
- package/src/central/agent/agent-limits.ts +185 -0
- package/src/central/agent/app-decisions.ts +6 -5
- package/src/central/agent/decisions.ts +136 -9
- package/src/central/agent/delivery.ts +164 -22
- package/src/central/agent/errors.ts +2 -1
- package/src/central/agent/events.ts +20 -2
- package/src/central/agent/grant-routes.ts +14 -1
- package/src/central/agent/grants.ts +23 -0
- package/src/central/agent/launch-engine.ts +9 -6
- package/src/central/agent/message-limits.ts +6 -0
- package/src/central/agent/message-routes.ts +33 -6
- package/src/central/agent/messages.ts +7 -0
- package/src/central/agent/placement.ts +4 -2
- package/src/central/agent/read-routes.ts +5 -0
- package/src/central/agent/routes.ts +13 -0
- package/src/central/agent/subscriptions.ts +15 -4
- package/src/central/api-v1.ts +115 -0
- package/src/central/artifacts/store.ts +4 -2
- package/src/central/concierge/board-results.ts +2 -1
- package/src/central/concierge/notifier.ts +4 -2
- package/src/central/config.ts +24 -3
- package/src/central/health/classify.ts +209 -0
- package/src/central/http.ts +380 -36
- package/src/central/recording/export.ts +57 -3
- package/src/central/recording/index.ts +236 -17
- package/src/central/recording/ingest.ts +11 -2
- package/src/central/recording/routes.ts +61 -7
- package/src/central/recording/schema.ts +68 -3
- package/src/central/recording/settings.ts +19 -5
- package/src/central/recording/store.ts +19 -0
- package/src/central/recording/taps.ts +45 -1
- package/src/central/registry.ts +34 -3
- package/src/central/speech/openai.ts +4 -4
- package/src/central/speech/openrouter.ts +4 -4
- package/src/central/speech/provider.ts +32 -1
- package/src/central/speech/service.ts +6 -5
- package/src/central/teams/budgets.ts +144 -0
- package/src/central/teams/hop-stalls.ts +80 -0
- package/src/central/teams/intake.ts +38 -6
- package/src/central/teams/involvement.ts +95 -0
- package/src/central/teams/item-decisions.ts +60 -0
- package/src/central/teams/item-eligibility.ts +35 -0
- package/src/central/teams/item-stalls.ts +87 -0
- package/src/central/teams/loops.ts +40 -0
- package/src/central/teams/policy.ts +10 -0
- package/src/central/teams/progress.ts +154 -0
- package/src/central/teams/routes.ts +2 -0
- package/src/central/teams/seats.ts +3 -3
- package/src/central/teams/staffing.ts +5 -0
- package/src/central/teams/store.ts +20 -1
- package/src/central/teams/validation.ts +16 -2
- package/src/central/workspaces/coordinator.ts +3 -1
- package/src/spoke/hub-tool.ts +26 -11
- package/web/dist/assets/{ArtifactPage-CsPjam8D.js → ArtifactPage-TbFJi59n.js} +1 -1
- package/web/dist/assets/{ArtifactPane-B9weO8L6.js → ArtifactPane-7D-M-Ich.js} +1 -1
- package/web/dist/assets/{ArtifactWorkbench-DzKhMs3l.js → ArtifactWorkbench-DlCLEjut.js} +1 -1
- package/web/dist/assets/{Diagram-CuRIffka.js → Diagram-PvOPetOI.js} +1 -1
- package/web/dist/assets/{Library-rv5DGtrm.js → Library-Cdi83pOV.js} +2 -2
- package/web/dist/assets/{ProfilesDestination-DUuQ9jmd.js → ProfilesDestination-cryC7P6j.js} +1 -1
- package/web/dist/assets/TeamsPage-B4xrlGhp.js +1 -0
- package/web/dist/assets/Watchtower-60B1JX_J.js +2 -0
- package/web/dist/assets/{artifacts-But87Jfo.js → artifacts-DlQEYmDl.js} +1 -1
- package/web/dist/assets/index-DA0gKIzv.js +33 -0
- package/web/dist/assets/{index-rW6LKQj1.css → index-Dyq-jBeE.css} +1 -1
- package/web/dist/assets/live-B2IfKm37.js +5 -0
- package/web/dist/assets/{live-BUofsQpz.css → live-De-kd3eW.css} +1 -1
- package/web/dist/assets/{pane-layout-BW0RdX5a.js → pane-layout-BAuu_pA3.js} +1 -1
- package/web/dist/assets/{renderer2d-DyANQ1WS.js → renderer2d-CsZnF3_o.js} +1 -1
- package/web/dist/assets/{renderer3d-vYYKFAJq.js → renderer3d-BIMA0wFn.js} +2 -2
- package/web/dist/assets/{safe-area-B2Bk__FK.js → safe-area-dtrnw9NF.js} +1 -1
- package/web/dist/assets/{silero-worker-e802lB5T.js → silero-worker-CC2etKS3.js} +1 -1
- package/web/dist/assets/{source-C-De8L3s.js → source-GYayxUxS.js} +1 -1
- package/web/dist/assets/{toast-CXTwTvDw.js → toast-q818mjUF.js} +1 -1
- package/web/dist/index.html +5 -5
- package/web/dist/assets/TeamsPage-D_17MkvN.js +0 -1
- package/web/dist/assets/Watchtower-DlrNOJ7X.js +0 -2
- package/web/dist/assets/index-bX8B6NLo.js +0 -33
- package/web/dist/assets/live-BGLA-peO.js +0 -5
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
// entries are reached (the root manifest of "all" reads them once more beforehand).
|
|
6
6
|
import { RECORDING_AUDIO_TYPE, RECORDING_FORMAT, RECORDING_FORMAT_VERSION, type RecordingEvent } from "../../protocol"
|
|
7
7
|
import { clockAlignment, type ClockSample, type ClockSummary } from "./clock"
|
|
8
|
-
import { type AudioEntry, blobTenant, type RecordingRecord, type RecordingStore } from "./store"
|
|
8
|
+
import { type AudioEntry, blobTenant, type ExtraEntry, isPageKey, type RecordingRecord, type RecordingStore } from "./store"
|
|
9
9
|
import type { ZipEntry } from "./zip"
|
|
10
10
|
|
|
11
11
|
export interface ExportOptions {
|
|
@@ -31,6 +31,8 @@ export interface SegmentManifest {
|
|
|
31
31
|
const decoder = new TextDecoder()
|
|
32
32
|
const encoder = new TextEncoder()
|
|
33
33
|
const audioFile = (n: number) => `audio/${String(n).padStart(6, "0")}.wav`
|
|
34
|
+
const clipFile = (n: number) => `clips/${String(n).padStart(6, "0")}.wav`
|
|
35
|
+
const replyFile = (n: number) => `replies/${String(n).padStart(6, "0")}.mp3`
|
|
34
36
|
|
|
35
37
|
/** A hub event ID "<podTag>.<n>" as a sort key. */
|
|
36
38
|
const idKey = (id: unknown): [string, number] => {
|
|
@@ -52,6 +54,9 @@ interface Loaded {
|
|
|
52
54
|
readonly events: RecordingEvent[]
|
|
53
55
|
readonly clock: ClockSummary
|
|
54
56
|
readonly audio: Map<number, AudioEntry>
|
|
57
|
+
/** Developer mode: clips (k…) and reply audio (r…) by number. */
|
|
58
|
+
readonly clips: Map<number, ExtraEntry>
|
|
59
|
+
readonly replies: Map<number, ExtraEntry>
|
|
55
60
|
}
|
|
56
61
|
|
|
57
62
|
/** Every stored event of a recording (pages that are missing or unreadable are skipped), aligned and sorted. */
|
|
@@ -59,11 +64,16 @@ async function load(store: RecordingStore, userID: string, record: RecordingReco
|
|
|
59
64
|
const index = await store.index<Record<string, unknown>>(userID, record.id)
|
|
60
65
|
const raw: RecordingEvent[] = []
|
|
61
66
|
const audio = new Map<number, AudioEntry>()
|
|
67
|
+
const clips = new Map<number, ExtraEntry>()
|
|
68
|
+
const replies = new Map<number, ExtraEntry>()
|
|
62
69
|
for (const entry of index) {
|
|
63
70
|
if (entry.key.startsWith("a")) {
|
|
64
71
|
audio.set(Number(entry.key.slice(1)), entry.value as unknown as AudioEntry)
|
|
65
72
|
continue
|
|
66
73
|
}
|
|
74
|
+
if (entry.key.startsWith("k")) clips.set(Number(entry.key.slice(1)), entry.value as unknown as ExtraEntry)
|
|
75
|
+
if (entry.key.startsWith("r")) replies.set(Number(entry.key.slice(1)), entry.value as unknown as ExtraEntry)
|
|
76
|
+
if (!isPageKey(entry.key)) continue
|
|
67
77
|
const hash = entry.value.hash
|
|
68
78
|
if (typeof hash !== "string") continue
|
|
69
79
|
const blob = await store.blobs.get(blobTenant(userID), hash).catch(() => undefined)
|
|
@@ -82,7 +92,36 @@ async function load(store: RecordingStore, userID: string, record: RecordingReco
|
|
|
82
92
|
const clock = clockAlignment(samples, record.anchor)
|
|
83
93
|
const events = raw.map((e) => ({ ...e, ts: e.src === "console" ? clock.ts(e.t ?? 0) : (e.at ?? 0) }) as RecordingEvent)
|
|
84
94
|
events.sort(compare)
|
|
85
|
-
return { events, clock: clock.summary, audio }
|
|
95
|
+
return { events, clock: clock.summary, audio, clips, replies }
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const sha = (hash: string) => hash.replace(/^sha256:/, "")
|
|
99
|
+
|
|
100
|
+
/** Developer mode: clips[] joined with their audio.clip events (what each belongs to), replies[] with their ref. */
|
|
101
|
+
function extras(loaded: Loaded): { clips: Record<string, unknown>[]; replies: Record<string, unknown>[] } {
|
|
102
|
+
const clipEvents = new Map<number, RecordingEvent>()
|
|
103
|
+
for (const e of loaded.events) if (e.src === "console" && e.kind === "audio.clip" && typeof e.clip === "number") clipEvents.set(e.clip, e)
|
|
104
|
+
const sentences = new Map<string, string>()
|
|
105
|
+
for (const e of loaded.events) {
|
|
106
|
+
const ref = e.ref as ExtraEntry["ref"]
|
|
107
|
+
if (e.src === "console" && e.kind === "tts.sentence.queued" && ref && typeof e.text === "string") sentences.set(`${ref.messageID}:${ref.ordinal}:${ref.seq ?? ""}:${ref.i}`, e.text)
|
|
108
|
+
}
|
|
109
|
+
const clips = [...loaded.clips].sort((a, b) => a[0] - b[0]).map(([n, entry]) => {
|
|
110
|
+
const e = clipEvents.get(n)
|
|
111
|
+
return {
|
|
112
|
+
n,
|
|
113
|
+
file: clipFile(n),
|
|
114
|
+
bytes: entry.bytes,
|
|
115
|
+
sha256: sha(entry.hash),
|
|
116
|
+
...(e ? { for: e.for, preMs: e.preMs, postMs: e.postMs, ts: e.ts } : {}),
|
|
117
|
+
}
|
|
118
|
+
})
|
|
119
|
+
const replies = [...loaded.replies].sort((a, b) => a[0] - b[0]).map(([n, entry]) => {
|
|
120
|
+
const ref = entry.ref
|
|
121
|
+
const text = ref ? sentences.get(`${ref.messageID}:${ref.ordinal}:${ref.seq ?? ""}:${ref.i}`) : undefined
|
|
122
|
+
return { n, file: replyFile(n), bytes: entry.bytes, sha256: sha(entry.hash), ...(ref ? { ref } : {}), ...(text !== undefined ? { text } : {}), at: entry.at }
|
|
123
|
+
})
|
|
124
|
+
return { clips, replies }
|
|
86
125
|
}
|
|
87
126
|
|
|
88
127
|
const num = (v: unknown) => (typeof v === "number" && Number.isFinite(v) ? v : undefined)
|
|
@@ -176,7 +215,15 @@ function manifestEntry(record: RecordingRecord, loaded: Loaded): Record<string,
|
|
|
176
215
|
clock: loaded.clock,
|
|
177
216
|
audio: { format: RECORDING_AUDIO_TYPE, sampleRate: 16000, channels: 1, bitsPerSample: 16 },
|
|
178
217
|
segments: list,
|
|
179
|
-
|
|
218
|
+
...(record.developer || loaded.clips.size || loaded.replies.size ? { developer: record.developer === true, ...extras(loaded) } : {}),
|
|
219
|
+
counts: {
|
|
220
|
+
events: loaded.events.length,
|
|
221
|
+
consoleGaps: record.consoleGaps,
|
|
222
|
+
redactions: record.redactions,
|
|
223
|
+
segmentsRedacted: redacted,
|
|
224
|
+
segmentsSkipped: skipped,
|
|
225
|
+
...(loaded.clips.size || loaded.replies.size || record.developer ? { clips: loaded.clips.size, replies: loaded.replies.size } : {}),
|
|
226
|
+
},
|
|
180
227
|
}
|
|
181
228
|
}
|
|
182
229
|
|
|
@@ -200,6 +247,13 @@ async function* recordingEntries(store: RecordingStore, userID: string, record:
|
|
|
200
247
|
if (!blob) continue
|
|
201
248
|
yield { name: `${prefix}${audioFile(n)}`, body: async () => blob.body }
|
|
202
249
|
}
|
|
250
|
+
for (const [files, name] of [[loaded.clips, clipFile], [loaded.replies, replyFile]] as const) {
|
|
251
|
+
for (const [n, extra] of [...files].sort((a, b) => a[0] - b[0])) {
|
|
252
|
+
const blob = await store.blobs.get(blobTenant(userID), extra.hash).catch(() => undefined)
|
|
253
|
+
if (!blob) continue
|
|
254
|
+
yield { name: `${prefix}${name(n)}`, body: async () => blob.body }
|
|
255
|
+
}
|
|
256
|
+
}
|
|
203
257
|
}
|
|
204
258
|
|
|
205
259
|
/** The zip entries of one recording (at the root) or of several (root manifest + one folder each). */
|
|
@@ -8,8 +8,11 @@
|
|
|
8
8
|
// only while a recording is active for that session, only into the session owner's recordings, and never change
|
|
9
9
|
// what the voice path answers (failures are logged without content or IDs).
|
|
10
10
|
import { randomBytes } from "node:crypto"
|
|
11
|
+
import type { TranscribeStats } from "../speech/provider"
|
|
11
12
|
import {
|
|
13
|
+
DEV_LIMITS,
|
|
12
14
|
RECORDING_AUDIO_TYPE,
|
|
15
|
+
RECORDING_REPLY_TYPE,
|
|
13
16
|
RECORDING_HEADER,
|
|
14
17
|
RECORDING_LIMITS,
|
|
15
18
|
RECORDING_REF_HEADER,
|
|
@@ -29,10 +32,13 @@ import { redactCredentials } from "../profiles/content"
|
|
|
29
32
|
import type { ConciergeCallFields } from "./concierge-taps"
|
|
30
33
|
import { HubEventWriter, ingestConsole, PAGE_QUOTA_BYTES } from "./ingest"
|
|
31
34
|
import { RecordingRetention } from "./retention"
|
|
32
|
-
import { RecordingSettingsStore } from "./settings"
|
|
35
|
+
import { RecordingSettingsStore, type SettingsChange } from "./settings"
|
|
33
36
|
import {
|
|
34
37
|
type AudioEntry,
|
|
35
38
|
audioKey,
|
|
39
|
+
clipKey,
|
|
40
|
+
type ExtraEntry,
|
|
41
|
+
replyKey,
|
|
36
42
|
ended,
|
|
37
43
|
isSegmentNumber,
|
|
38
44
|
newRecord,
|
|
@@ -42,7 +48,7 @@ import {
|
|
|
42
48
|
type SpeechFacts,
|
|
43
49
|
summaryOf,
|
|
44
50
|
} from "./store"
|
|
45
|
-
import { parseTtsRef, RegistryTaps } from "./taps"
|
|
51
|
+
import { parseTtsRef, RegistryTaps, type TurnTimings } from "./taps"
|
|
46
52
|
|
|
47
53
|
const L = RECORDING_LIMITS
|
|
48
54
|
export const RECORDINGS_TOPIC = "recordings.changed"
|
|
@@ -82,20 +88,57 @@ export interface RecordingServiceOptions {
|
|
|
82
88
|
readonly log?: (message: string) => void
|
|
83
89
|
readonly sweepMs?: number
|
|
84
90
|
readonly hubFlushMs?: number
|
|
91
|
+
/** Developer mode's limits (tests shrink them). */
|
|
92
|
+
readonly devLimits?: Partial<DevLimits>
|
|
93
|
+
/** This pod's name for request lines (Developer mode). */
|
|
94
|
+
readonly pod?: string
|
|
95
|
+
/**
|
|
96
|
+
* (Developer mode) What ran a session's turn: the model and its provider, the agent, the context size (token
|
|
97
|
+
* counts only). Asked only when a developer recording of the session is active; never text.
|
|
98
|
+
*/
|
|
99
|
+
readonly turnFacts?: (userID: string, sessionID: string) => Promise<TurnFacts>
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export interface TurnFacts {
|
|
103
|
+
readonly provider?: string
|
|
104
|
+
readonly model?: string
|
|
105
|
+
readonly variant?: string
|
|
106
|
+
readonly agent?: string
|
|
107
|
+
readonly contextTokens?: number
|
|
108
|
+
readonly contextLimit?: number
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** How long a turn detail waits for the session's facts before it is recorded without them. */
|
|
112
|
+
export const TURN_FACTS_MS = 3000
|
|
113
|
+
|
|
114
|
+
export type DevLimits = { -readonly [K in keyof typeof DEV_LIMITS]: number }
|
|
115
|
+
|
|
116
|
+
/** One voice request of the user (Developer mode's request lines): what route, its status and time. Never a body. */
|
|
117
|
+
export interface RequestLine {
|
|
118
|
+
readonly route: "transcribe" | "speak" | "events"
|
|
119
|
+
readonly method: string
|
|
120
|
+
readonly status: number
|
|
121
|
+
readonly ms: number
|
|
85
122
|
}
|
|
86
123
|
|
|
124
|
+
export type ExtraOutcome = { readonly kind: "stored"; readonly entry: ExtraEntry; readonly n: number; readonly fresh: boolean } | { readonly kind: "cap" } | { readonly kind: "gone" } | { readonly kind: "off" } | { readonly kind: "failed"; readonly cause: unknown }
|
|
125
|
+
|
|
87
126
|
interface ActiveRef {
|
|
88
127
|
readonly id: string
|
|
89
128
|
readonly sessionID: string
|
|
90
129
|
readonly kind: RecordingKind
|
|
91
130
|
readonly deviceID: string
|
|
131
|
+
readonly developer?: true
|
|
92
132
|
}
|
|
93
133
|
|
|
94
134
|
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
135
|
|
|
96
136
|
export interface TranscribeTap {
|
|
97
|
-
/**
|
|
98
|
-
|
|
137
|
+
/**
|
|
138
|
+
* The answer's x-hub-recording-audio after STT answered (the write of a small body is done by then). `stats`: the
|
|
139
|
+
* provider's HTTP status, response bytes and time to headers, recorded in stt.result when known.
|
|
140
|
+
*/
|
|
141
|
+
finish(text: string, providerMs: number, stats?: TranscribeStats): Promise<RecordingAudioResult>
|
|
99
142
|
}
|
|
100
143
|
|
|
101
144
|
export interface SpeakTap {
|
|
@@ -118,9 +161,11 @@ export class RecordingService {
|
|
|
118
161
|
private readonly background = new Set<Promise<unknown>>()
|
|
119
162
|
private readonly opened = new Set<string>()
|
|
120
163
|
private unsubscribe?: () => Promise<void>
|
|
164
|
+
readonly devLimits: DevLimits
|
|
121
165
|
|
|
122
166
|
constructor(private readonly options: RecordingServiceOptions) {
|
|
123
167
|
this.now = options.now ?? Date.now
|
|
168
|
+
this.devLimits = { ...DEV_LIMITS, ...options.devLimits }
|
|
124
169
|
// Unique per process: a restarted pod never reuses another's page keys.
|
|
125
170
|
this.podTag = `${(options.podID ?? "hub").replace(/[^A-Za-z0-9]/g, "").slice(0, 12) || "hub"}${randomBytes(3).toString("hex")}`
|
|
126
171
|
this.store = new RecordingStore(options.state, options.blobs, this.now)
|
|
@@ -139,7 +184,14 @@ export class RecordingService {
|
|
|
139
184
|
})().catch(() => undefined),
|
|
140
185
|
})
|
|
141
186
|
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(
|
|
187
|
+
this.registryTap = new RegistryTaps(
|
|
188
|
+
{
|
|
189
|
+
hub: (userID, sessionID, kind, fields) => this.hub(userID, sessionID, kind, fields),
|
|
190
|
+
mayRecord: (userID) => this.mayRecord(userID),
|
|
191
|
+
turnDetail: (userID, sessionID, detail) => void this.track(this.turnDetail(userID, sessionID, detail)).catch((cause) => this.log(`[hub] recording turn detail failed: ${(cause as Error).message}`)),
|
|
192
|
+
},
|
|
193
|
+
this.now,
|
|
194
|
+
)
|
|
143
195
|
if (options.bus) {
|
|
144
196
|
options.bus
|
|
145
197
|
.subscribe(RECORDINGS_TOPIC, (message) => {
|
|
@@ -168,7 +220,7 @@ export class RecordingService {
|
|
|
168
220
|
private async activeOf(userID: string): Promise<ActiveRef[]> {
|
|
169
221
|
const cached = this.active.get(userID)
|
|
170
222
|
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 }))
|
|
223
|
+
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, ...(r.developer ? { developer: true as const } : {}) }))
|
|
172
224
|
this.active.set(userID, { at: this.now(), refs })
|
|
173
225
|
if (this.active.size > 10_000) this.active.delete(this.active.keys().next().value as string)
|
|
174
226
|
return refs
|
|
@@ -214,6 +266,29 @@ export class RecordingService {
|
|
|
214
266
|
return run
|
|
215
267
|
}
|
|
216
268
|
|
|
269
|
+
/**
|
|
270
|
+
* (Developer mode) turn.detail into the session's active developer recordings: the turn's timings and what ran it
|
|
271
|
+
* (asked of the session only then, at most TURN_FACTS_MS). Only the whitelisted facts: never text.
|
|
272
|
+
*/
|
|
273
|
+
private async turnDetail(userID: string, sessionID: string, detail: TurnTimings): Promise<void> {
|
|
274
|
+
if (!this.mayRecord(userID)) return
|
|
275
|
+
const refs = (await this.activeOf(userID)).filter((r) => r.sessionID === sessionID && r.developer)
|
|
276
|
+
if (!refs.length) return
|
|
277
|
+
let facts: TurnFacts = {}
|
|
278
|
+
if (this.options.turnFacts) {
|
|
279
|
+
const timeout = new Promise<TurnFacts>((resolve) => setTimeout(() => resolve({}), TURN_FACTS_MS).unref?.())
|
|
280
|
+
facts = await Promise.race([this.options.turnFacts(userID, sessionID).catch(() => ({})), timeout])
|
|
281
|
+
}
|
|
282
|
+
const at = this.now()
|
|
283
|
+
const label = (v: unknown) => (typeof v === "string" && v ? v.slice(0, 120) : undefined)
|
|
284
|
+
const count = (v: unknown) => (typeof v === "number" && Number.isFinite(v) && v >= 0 ? Math.round(v) : undefined)
|
|
285
|
+
const picked = Object.fromEntries(
|
|
286
|
+
Object.entries({ provider: label(facts.provider), model: label(facts.model), variant: label(facts.variant), agent: label(facts.agent), contextTokens: count(facts.contextTokens), contextLimit: count(facts.contextLimit) }).filter(([, v]) => v !== undefined),
|
|
287
|
+
)
|
|
288
|
+
const tools = detail.tools.map((t) => ({ callID: t.callID.slice(0, 120), ...(t.tool ? { tool: t.tool } : {}), startMs: t.startMs, ...(t.endMs !== undefined ? { endMs: t.endMs } : {}), state: t.state }))
|
|
289
|
+
for (const ref of refs) this.writer.add(userID, ref.id, "turn.detail", { outcome: detail.outcome, ms: detail.ms, ...(detail.firstTokenMs !== undefined ? { firstTokenMs: detail.firstTokenMs } : {}), ...picked, tools }, at)
|
|
290
|
+
}
|
|
291
|
+
|
|
217
292
|
/** Writes every buffered hub event now. */
|
|
218
293
|
async flush(): Promise<void> {
|
|
219
294
|
await Promise.all([...this.tails.values()])
|
|
@@ -239,13 +314,28 @@ export class RecordingService {
|
|
|
239
314
|
return { bytes, recordings: records.filter((r) => r.state !== "deleting").length, capBytes: L.userBytes }
|
|
240
315
|
}
|
|
241
316
|
|
|
242
|
-
|
|
243
|
-
|
|
317
|
+
/** The setting: recording and Developer mode (developer implies enabled; see RecordingSettingsStore.set). */
|
|
318
|
+
async setSettings(userID: string, deviceID: string, change: SettingsChange, expectedRevision: number): Promise<{ ok: true; settings: RecordingSettings } | { ok: false; currentRevision: number }> {
|
|
319
|
+
const written = await this.settings.set(userID, change, expectedRevision)
|
|
244
320
|
if (!written.ok) return written
|
|
321
|
+
const { enabled } = written.settings
|
|
322
|
+
const developer = written.settings.developer === true
|
|
245
323
|
this.options.notify(userID, { type: "recording", settings: written.settings }, deviceID)
|
|
246
|
-
if (written.changed) {
|
|
247
|
-
|
|
248
|
-
|
|
324
|
+
if (written.changed) await this.options.audit({ actor: userID, action: enabled ? "recording.enable" : "recording.disable" })
|
|
325
|
+
if (written.developerChanged) await this.options.audit({ actor: userID, action: developer ? "recording.developer.enable" : "recording.developer.disable" })
|
|
326
|
+
if (written.changed && !enabled) for (const r of await this.store.list(userID)) if (r.state === "active") await this.endByHub(userID, r.id, "disabled")
|
|
327
|
+
// Developer mode off: the running recordings go on without their extras from now on (on: from the next start).
|
|
328
|
+
if (written.developerChanged && !developer && enabled) {
|
|
329
|
+
for (const r of await this.store.list(userID)) {
|
|
330
|
+
if (r.state !== "active" || !r.developer) continue
|
|
331
|
+
await this.store.update(userID, r.id, (current) => {
|
|
332
|
+
if (!current.developer) return undefined
|
|
333
|
+
const { developer: _off, ...rest } = current
|
|
334
|
+
return rest as RecordingRecord
|
|
335
|
+
})
|
|
336
|
+
this.changed(userID, r.sessionID)
|
|
337
|
+
this.options.notify(userID, { type: "recording", recordingID: r.id, developer: false })
|
|
338
|
+
}
|
|
249
339
|
}
|
|
250
340
|
return { ok: true, settings: written.settings }
|
|
251
341
|
}
|
|
@@ -271,7 +361,8 @@ export class RecordingService {
|
|
|
271
361
|
}
|
|
272
362
|
|
|
273
363
|
async start(userID: string, device: { id: string; name?: string }, input: StartInput): Promise<StartResult> {
|
|
274
|
-
|
|
364
|
+
const setting = await this.settings.get(userID)
|
|
365
|
+
if (!setting.enabled) return { kind: "off" }
|
|
275
366
|
if (!this.options.owns(userID, input.sessionID)) return { kind: "not_found" }
|
|
276
367
|
const records = await this.store.list(userID)
|
|
277
368
|
const same = records.find((r) => r.clientID === input.clientID && r.deviceID === device.id && r.state !== "deleting")
|
|
@@ -298,6 +389,7 @@ export class RecordingService {
|
|
|
298
389
|
anchor: input.anchor ?? { perf: 0, epoch: at },
|
|
299
390
|
audio,
|
|
300
391
|
at,
|
|
392
|
+
developer: setting.developer === true,
|
|
301
393
|
},
|
|
302
394
|
speech,
|
|
303
395
|
)
|
|
@@ -445,7 +537,7 @@ export class RecordingService {
|
|
|
445
537
|
this.writer.add(userID, id, "stt.request", { segment: n, bytes: audio.length, format }, at)
|
|
446
538
|
const model = live.stt?.model
|
|
447
539
|
return {
|
|
448
|
-
finish: async (text, providerMs) => {
|
|
540
|
+
finish: async (text, providerMs, stats) => {
|
|
449
541
|
// (USER CHANGE vs the contract text) A transcript matching a secret pattern: the text is scrubbed (the
|
|
450
542
|
// hub record of stt.result is), the audio kept, and the segment marked redacted.
|
|
451
543
|
const redacted = redactCredentials(text).kinds.length > 0
|
|
@@ -460,7 +552,7 @@ export class RecordingService {
|
|
|
460
552
|
} catch (cause) {
|
|
461
553
|
result = this.audioResult(userID, id, n, { kind: "failed", cause })
|
|
462
554
|
}
|
|
463
|
-
this.writer.add(userID, id, "stt.result", { segment: n, text, providerMs, ...(model ? { model } : {}), audio: result })
|
|
555
|
+
this.writer.add(userID, id, "stt.result", { segment: n, text, providerMs, ...(model ? { model } : {}), audio: result, ...providerStats(stats) })
|
|
464
556
|
return result
|
|
465
557
|
},
|
|
466
558
|
}
|
|
@@ -482,19 +574,34 @@ export class RecordingService {
|
|
|
482
574
|
return "failed"
|
|
483
575
|
}
|
|
484
576
|
|
|
485
|
-
/**
|
|
577
|
+
/**
|
|
578
|
+
* /api/speak with x-hub-recording (and x-hub-tts-ref): tts.synth timing. In a developer recording (with a ref) the
|
|
579
|
+
* reply audio is also kept, as the client received it (copied while streaming, no second upload).
|
|
580
|
+
*/
|
|
486
581
|
async speakTap(userID: string, deviceID: string, headers: Headers, chars: number): Promise<SpeakTap | undefined> {
|
|
487
582
|
const id = headers.get(RECORDING_HEADER)
|
|
488
583
|
if (id === null) return undefined
|
|
489
584
|
const record = await this.store.visible(userID, id).catch(() => undefined)
|
|
490
585
|
if (!record || record.state !== "active" || record.deviceID !== deviceID) return undefined
|
|
491
586
|
const ref = parseTtsRef(headers.get(RECORDING_REF_HEADER))
|
|
587
|
+
const keep = record.developer === true && ref !== undefined
|
|
492
588
|
const started = this.now()
|
|
493
|
-
const
|
|
589
|
+
const chunks: Uint8Array[] = []
|
|
590
|
+
let kept = 0
|
|
591
|
+
const collect = (chunk: Uint8Array) => {
|
|
592
|
+
if (!keep || kept > this.devLimits.replyBytes) return
|
|
593
|
+
kept += chunk.length
|
|
594
|
+
if (kept <= this.devLimits.replyBytes) chunks.push(chunk)
|
|
595
|
+
}
|
|
596
|
+
const done = (firstByteMs: number | undefined) => {
|
|
597
|
+
this.writer.add(userID, id, "tts.synth", { ...(ref ? { ref } : {}), chars, ...(firstByteMs !== undefined ? { firstByteMs } : {}), ms: this.now() - started })
|
|
598
|
+
if (keep) void this.track(this.keepReply(userID, id, ref!, kept > this.devLimits.replyBytes ? undefined : chunks))
|
|
599
|
+
}
|
|
494
600
|
return {
|
|
495
601
|
wrap: <T extends Uint8Array | ReadableStream<Uint8Array>>(audio: T): T => {
|
|
496
602
|
if (audio instanceof Uint8Array) {
|
|
497
603
|
const ms = this.now() - started
|
|
604
|
+
collect(audio)
|
|
498
605
|
done(ms)
|
|
499
606
|
return audio
|
|
500
607
|
}
|
|
@@ -503,6 +610,7 @@ export class RecordingService {
|
|
|
503
610
|
new TransformStream<Uint8Array, Uint8Array>({
|
|
504
611
|
transform: (chunk, controller) => {
|
|
505
612
|
first ??= this.now() - started
|
|
613
|
+
collect(chunk)
|
|
506
614
|
controller.enqueue(chunk)
|
|
507
615
|
},
|
|
508
616
|
flush: () => done(first),
|
|
@@ -513,6 +621,96 @@ export class RecordingService {
|
|
|
513
621
|
}
|
|
514
622
|
}
|
|
515
623
|
|
|
624
|
+
/** A reply sentence's audio (Developer mode): stored under the next reply number; reply.audio says what happened. */
|
|
625
|
+
private async keepReply(userID: string, id: string, ref: NonNullable<ExtraEntry["ref"]>, chunks: Uint8Array[] | undefined): Promise<void> {
|
|
626
|
+
const bytes = chunks ? concat(chunks) : undefined
|
|
627
|
+
const record = await this.store.visible(userID, id)
|
|
628
|
+
if (!record) return
|
|
629
|
+
const outcome: ExtraOutcome = bytes?.length ? await this.storeExtra(userID, record, "reply", undefined, bytes, ref) : { kind: "cap" }
|
|
630
|
+
const audio = outcome.kind === "stored" ? "stored" : outcome.kind === "cap" ? "skipped-cap" : outcome.kind === "failed" ? "failed" : "off"
|
|
631
|
+
this.writer.add(userID, id, "reply.audio", { ref, ...(outcome.kind === "stored" ? { n: outcome.n } : {}), bytes: bytes?.length ?? 0, audio })
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
/**
|
|
635
|
+
* (Developer mode) Stores an audio clip (`n` from the console) or a reply's audio (the next number). Only for a
|
|
636
|
+
* developer recording; the extras' audio stops first: never past (cap − headroom) of the user's or the recording's
|
|
637
|
+
* cap, so segments keep the rest (cap.reached {what: "extras"} once).
|
|
638
|
+
*/
|
|
639
|
+
async storeExtra(userID: string, record: RecordingRecord, what: "clip" | "reply", n: number | undefined, audio: Uint8Array, ref?: ExtraEntry["ref"]): Promise<ExtraOutcome> {
|
|
640
|
+
const D = this.devLimits
|
|
641
|
+
if (!record.developer) return { kind: "off" }
|
|
642
|
+
if (what === "clip" && n !== undefined) {
|
|
643
|
+
const existing = await this.store.indexEntry<ExtraEntry>(userID, record.id, clipKey(n))
|
|
644
|
+
if (existing) return { kind: "stored", entry: existing, n, fresh: false }
|
|
645
|
+
}
|
|
646
|
+
if ((await this.store.usage(userID)) + audio.length > L.userBytes - D.userHeadroomBytes) {
|
|
647
|
+
await this.extrasCap(userID, record, "user", L.userBytes - D.userHeadroomBytes)
|
|
648
|
+
return { kind: "cap" }
|
|
649
|
+
}
|
|
650
|
+
// Reserve the bytes (and a reply's number) in the record, so parallel writes never pass the limits.
|
|
651
|
+
let verdict = "gone" as "ok" | "over" | "gone" | "off"
|
|
652
|
+
let number = n ?? 0
|
|
653
|
+
await this.store.update(userID, record.id, (current) => {
|
|
654
|
+
if (current.state === "deleting") return void (verdict = "gone")
|
|
655
|
+
if (!current.developer) return void (verdict = "off")
|
|
656
|
+
const count = what === "clip" ? (current.clips ?? 0) : (current.replies ?? 0)
|
|
657
|
+
const max = what === "clip" ? D.clipsPerRecording : D.repliesPerRecording
|
|
658
|
+
if (count >= max || current.bytes + audio.length > L.recordingBytes - D.recordingHeadroomBytes) return void (verdict = "over")
|
|
659
|
+
verdict = "ok"
|
|
660
|
+
if (what === "reply") number = count + 1
|
|
661
|
+
return { ...current, bytes: current.bytes + audio.length, ...(what === "clip" ? { clips: count + 1 } : { replies: count + 1 }) }
|
|
662
|
+
})
|
|
663
|
+
if (verdict === "gone" || verdict === "off") return { kind: verdict }
|
|
664
|
+
if (verdict === "over") {
|
|
665
|
+
await this.extrasCap(userID, record, "recording", L.recordingBytes - D.recordingHeadroomBytes)
|
|
666
|
+
return { kind: "cap" }
|
|
667
|
+
}
|
|
668
|
+
const release = () =>
|
|
669
|
+
this.store.update(userID, record.id, (current) =>
|
|
670
|
+
current.state === "deleting" ? undefined : { ...current, bytes: Math.max(0, current.bytes - audio.length), ...(what === "clip" ? { clips: Math.max(0, (current.clips ?? 1) - 1) } : {}) },
|
|
671
|
+
)
|
|
672
|
+
let blob
|
|
673
|
+
try {
|
|
674
|
+
blob = await this.store.putBlob(userID, audio, what === "clip" ? RECORDING_AUDIO_TYPE : RECORDING_REPLY_TYPE, what === "clip" ? D.clipBytes : D.replyBytes, L.userBytes - D.userHeadroomBytes)
|
|
675
|
+
} catch (cause) {
|
|
676
|
+
await release()
|
|
677
|
+
if (cause instanceof BlobQuotaError) {
|
|
678
|
+
await this.extrasCap(userID, record, "user", L.userBytes - D.userHeadroomBytes)
|
|
679
|
+
return { kind: "cap" }
|
|
680
|
+
}
|
|
681
|
+
return { kind: "failed", cause }
|
|
682
|
+
}
|
|
683
|
+
const at = this.now()
|
|
684
|
+
const key = what === "clip" ? clipKey(number) : replyKey(number)
|
|
685
|
+
const entry: ExtraEntry = { hash: blob.hash, bytes: blob.bytes, at, ...(ref ? { ref } : {}) }
|
|
686
|
+
if (!(await this.store.putIndex(userID, record.id, key, entry))) {
|
|
687
|
+
await release()
|
|
688
|
+
const first = await this.store.indexEntry<ExtraEntry>(userID, record.id, key)
|
|
689
|
+
if (first?.hash !== blob.hash) await this.store.dropIfUnindexed(userID, blob.hash, at)
|
|
690
|
+
return first ? { kind: "stored", entry: first, n: number, fresh: false } : { kind: "failed", cause: new Error("extra index lost") }
|
|
691
|
+
}
|
|
692
|
+
if (await this.store.undoIfDeleted(userID, record.id, key, blob.hash)) return { kind: "gone" }
|
|
693
|
+
return { kind: "stored", entry, n: number, fresh: true }
|
|
694
|
+
}
|
|
695
|
+
|
|
696
|
+
/** cap.reached {what: "extras"} once per scope; audio (segments) goes on. */
|
|
697
|
+
private async extrasCap(userID: string, record: RecordingRecord, scope: "user" | "recording", limit: number): Promise<void> {
|
|
698
|
+
const key = `extras:${scope}`
|
|
699
|
+
const result = await this.store.update(userID, record.id, (current) => (current.capNoted.includes(key) ? undefined : { ...current, capNoted: [...current.capNoted, key] }))
|
|
700
|
+
if (result?.changed) this.writer.add(userID, record.id, "cap.reached", { what: "extras", scope, limit })
|
|
701
|
+
}
|
|
702
|
+
|
|
703
|
+
/**
|
|
704
|
+
* (Developer mode) A request line for one of the user's voice requests naming a recording (transcribe, speak:
|
|
705
|
+
* x-hub-recording; events: the path): only into an active developer recording of that device. Never a body.
|
|
706
|
+
*/
|
|
707
|
+
async requestLine(userID: string, deviceID: string, recordingID: string | null | undefined, line: RequestLine): Promise<void> {
|
|
708
|
+
if (!recordingID || !this.mayRecord(userID)) return
|
|
709
|
+
const record = await this.store.visible(userID, recordingID).catch(() => undefined)
|
|
710
|
+
if (!record?.developer || record.deviceID !== deviceID || (record.state !== "active" && !(record.graceUntil !== undefined && this.now() <= record.graceUntil))) return
|
|
711
|
+
this.writer.add(userID, record.id, "request", { route: line.route, method: line.method, status: line.status, ms: Math.round(line.ms), pod: this.options.pod ?? "hub" })
|
|
712
|
+
}
|
|
713
|
+
|
|
516
714
|
/** POST …/conversation: the event, and (off) the session's recordings end with a grace for the last batches. */
|
|
517
715
|
async conversationChanged(userID: string, sessionID: string, on: boolean): Promise<void> {
|
|
518
716
|
await this.hubNow(userID, sessionID, "conversation", { on })
|
|
@@ -537,7 +735,8 @@ export class RecordingService {
|
|
|
537
735
|
if (this.opened.size > 2000) this.opened.delete(this.opened.values().next().value as string)
|
|
538
736
|
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
737
|
} else if (decision.state === "answered" && decision.answer?.answeredBy === "user") {
|
|
540
|
-
|
|
738
|
+
const picks = decision.answer.picks && Object.entries(decision.answer.picks).map(([group, option]) => ({ group, option }))
|
|
739
|
+
this.hub(userID, sessionID, "decision.answered", { decisionID: decision.id, ...(decision.answer.option !== undefined ? { option: decision.answer.option } : {}), ...(picks ? { picks } : {}), via: decision.answer.via ?? "tap" })
|
|
541
740
|
}
|
|
542
741
|
}
|
|
543
742
|
|
|
@@ -656,3 +855,23 @@ export type StartResult =
|
|
|
656
855
|
| { readonly kind: "existing" | "created"; readonly record: RecordingRecord }
|
|
657
856
|
|
|
658
857
|
export { summaryOf }
|
|
858
|
+
|
|
859
|
+
const concat = (chunks: readonly Uint8Array[]): Uint8Array => {
|
|
860
|
+
const out = new Uint8Array(chunks.reduce((n, c) => n + c.length, 0))
|
|
861
|
+
let at = 0
|
|
862
|
+
for (const c of chunks) {
|
|
863
|
+
out.set(c, at)
|
|
864
|
+
at += c.length
|
|
865
|
+
}
|
|
866
|
+
return out
|
|
867
|
+
}
|
|
868
|
+
|
|
869
|
+
/** stt.result's provider fields (capture.v2): the upstream HTTP status, response bytes and time to headers, when known. */
|
|
870
|
+
function providerStats(stats: TranscribeStats | undefined): Record<string, number> {
|
|
871
|
+
const out: Record<string, number> = {}
|
|
872
|
+
const finite = (x: unknown): x is number => typeof x === "number" && Number.isFinite(x)
|
|
873
|
+
if (finite(stats?.status)) out.httpStatus = stats.status
|
|
874
|
+
if (finite(stats?.bytes)) out.responseBytes = stats.bytes
|
|
875
|
+
if (finite(stats?.headersMs)) out.headersMs = Math.round(stats.headersMs)
|
|
876
|
+
return out
|
|
877
|
+
}
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
// ifVersion 0 (an existing key is a page stored before a crash: treated as stored), then the record CAS-updated.
|
|
4
4
|
// Hub events: buffered per pod and recording, written as pages of up to hubFlushEvents every hubFlushMs (and at
|
|
5
5
|
// end and drain) under h<podTag>-<n>; a pod that dies loses at most its last hubFlushMs of hub events.
|
|
6
|
-
import { type ConsoleEvent, type HubEventKind, RECORDING_LIMITS, type RecordingEvent } from "../../protocol"
|
|
6
|
+
import { type ConsoleEvent, DEV_CONSOLE_KINDS, type HubEventKind, RECORDING_LIMITS, type RecordingEvent } from "../../protocol"
|
|
7
7
|
import { BlobQuotaError } from "../artifacts/blob-store"
|
|
8
8
|
import type { Clock, Timers } from "../backend/types"
|
|
9
9
|
import { consoleRecord, hubRecord } from "./schema"
|
|
@@ -15,6 +15,7 @@ export const PAGE_QUOTA_BYTES = L.userBytes + L.activePerUser * L.eventBytesPerR
|
|
|
15
15
|
/** Room past PAGE_QUOTA_BYTES for the end markers of a recording whose user filled the page quota. */
|
|
16
16
|
export const CONTROL_HEADROOM_BYTES = 1024 * 1024
|
|
17
17
|
const CONTROL_KINDS = new Set<string>(["cap.reached", "recording.ended", "error"])
|
|
18
|
+
const DEV_KINDS: ReadonlySet<string> = new Set(DEV_CONSOLE_KINDS)
|
|
18
19
|
/** A page is the batch plus the envelope the hub adds (≈ 50 bytes per event). */
|
|
19
20
|
const PAGE_MAX_BYTES = L.batchBytes + L.eventsPerBatch * 128
|
|
20
21
|
|
|
@@ -33,7 +34,15 @@ export async function ingestConsole(store: RecordingStore, userID: string, recor
|
|
|
33
34
|
const fresh = events.filter((e) => e.seq > record.consoleThrough)
|
|
34
35
|
if (!fresh.length) return { kind: "stored", record }
|
|
35
36
|
const at = now()
|
|
36
|
-
|
|
37
|
+
// Developer mode's kinds only in a developer recording (a console that has not seen it turned off yet): dropped
|
|
38
|
+
// here, their seqs still count as stored (no gap).
|
|
39
|
+
const kept = record.developer ? fresh : fresh.filter((e) => !DEV_KINDS.has(e.kind))
|
|
40
|
+
if (!kept.length) {
|
|
41
|
+
const last = fresh.at(-1)!.seq
|
|
42
|
+
const updated = await store.update(userID, record.id, (current) => (current.state === "deleting" || current.consoleThrough >= last ? undefined : { ...current, consoleThrough: last, consoleGaps: current.consoleGaps + Math.max(0, fresh[0]!.seq - (current.consoleThrough + 1)), lastEventAt: at }))
|
|
43
|
+
return updated ? { kind: "stored", record: updated.record } : { kind: "gone" }
|
|
44
|
+
}
|
|
45
|
+
const records = kept.map((e) => consoleRecord(e, at))
|
|
37
46
|
const page = ndjson(records)
|
|
38
47
|
if (record.events + records.length > L.eventsPerRecording || record.eventBytes + page.length > L.eventBytesPerRecording) return { kind: "limit" }
|
|
39
48
|
const first = fresh[0]!.seq
|