feat(meetings): bot ownership on dispatch, pause/resume, and a user-facing status - #12
Open
Rahulkaushik01 wants to merge 1 commit into
Open
Conversation
…acing status Groundwork for per-user meeting history. * Ownership: a bot dispatched with a member's team.day token is stamped with owner_user_id, so history can later be scoped to whoever created it. The token is OPTIONAL -- /bots is a public API and customers who send none keep working unchanged, with their bots left unowned. A token that IS sent must be valid: silently ignoring a bad one would hand the caller a bot that never appears in their history, which looks like data loss. * Pause/resume: pause stops audio uploads, so without an explicit signal the server cannot tell a paused session from a crashed one -- and guessing from timing would flag every thoughtful silence as a pause. The desktop now says so, reusing Attendee's existing PAUSED recording state. Both endpoints are idempotent, keep the session away from the idle reaper, and resume preserves started_at so the timeline (and every chunk's offset_ms) continues unbroken. * Status: maps the 19 internal bot states onto what a person actually needs -- recording, paused, finalizing, processing, done, partially_failed, deleted. It is a pure read of data we already store, so it cannot race the lifecycle. "Still transcribing" reuses the predicate the bot controller already uses (no transcription and no failure recorded), which is what stops a stuck clip from reporting "processing" forever. * Index: history is always "this user's meetings, newest first", so the index now carries the sort order. Measured on 6k rows, the owner-only index read every row a user owned and sorted them (1200 rows, 119 buffers) to return one page; the composite reads only the page (25 rows, 6 buffers) and stays flat as history grows. Migration 0091 builds the new index CONCURRENTLY *before* dropping the old one, so owner lookups are never left unindexed. Co-Authored-By: Claude <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Groundwork for per-user meeting history. Four independent gaps had to close before a
"show me my meetings" endpoint can exist and stay correct: bot meetings had no owner,
a paused session was indistinguishable from a crashed one, the internal bot states are
meaningless to an end user, and the history query would get slower with every recording.
Problem 1 — bot meetings had no owner
The problem. When a bot is dispatched, nothing recorded who dispatched it. The server
therefore could not answer "which meetings are mine?" for the bot half of a user's history.
(Local sessions already carry a verified owner from PR 4.)
Why not just use
metadata.app_user_id, which the desktop already sends? Because it iswritten by the client and never verified. The Attendee API key is a compile-time constant in
the desktop binary (
env!("ATTENDEE_API_KEY")), so anyone with the app can extract it and call/botsdirectly with anyapp_user_idthey like. Filtering history on that field would meanletting the caller choose whose meetings they receive. It is a claim, not proof. (It is also
inside a JSON column, so it would need its own functional index anyway.)
What this does. The dispatch endpoint reads the member's verified team.day token and stamps
Bot.owner_user_idfrom it.Three deliberate decisions:
/botsis a public, documented API used by other customers.Requiring the header would break every existing integration. No token → the bot is created
exactly as before and left unowned.
the caller a bot that never appears in their history — indistinguishable from data loss.
A bad token fails the request before anything is created, so there is no orphan bot.
UPDATE, not a read-modify-write, so it can never contend withthe row's optimistic-concurrency version.
Problem 2 — a paused session looked exactly like a crashed one
The problem. Pausing deliberately stops audio uploads. To the server that is
indistinguishable from the app dying, so the idle reaper could end a session while the user was
simply paused.
Why not infer it from timing? Because a long thoughtful silence looks identical to a pause.
Any threshold either ends real sessions or fails to detect real crashes. The desktop is the only
component that actually knows the user pressed pause, so it should say so.
What this does. Adds
POST /local_sessions/{id}/pauseand/resume, reusing Attendee'sexisting
RecordingStates.PAUSEDrather than inventing a state.RecordingManageralready returns early if the recording is in the targetstate, so a double-tap or a retry is harmless.
started_at— the session timeline continues unbroken, which is whatmakes every chunk's
offset_msline up after a pause instead of jumping.letting
RecordingManagerraise out of the view.The heartbeat endpoint moved into the same module, which also brought
local_session_api_views.pyfrom 247 down to 231 lines, back under the preferred limit.Problem 3 — internal states are meaningless, and a stuck clip said "processing" forever
The problem.
Bot.statehas 19 values describing pod plumbing (JOINING,WAITING_ROOM,POST_PROCESSING,FATAL_ERROR…). None of it means anything to someone lookingat their own recordings. Worse: if transcription exhausted its retries, nothing surfaced that,
so a session would report "processing" indefinitely.
What this does.
meeting_status.pymaps the internal states onto the seven a person caresabout:
recording · paused · finalizing · processing · done · partially_failed · deleted.Why a derived read rather than a new state or a watchdog job:
so it cannot race the lifecycle or introduce a state the state machine doesn't recognise.
FAILEDstate for local sessions was rejected: verified thatFATAL_ERRORisonly reachable from states
[2,3,4,5,6,8,11,12,13,14,15,16,100,101,102]—READYis notamong them — so a local session (which lives in
READYuntil it ends) can never legallyenter it. Failure therefore has to surface from
Recording.stateandUtterance.failure_data,which is exactly what this reads.
The "stuck forever" fix reuses the codebase's own definition of in-progress from
BotController—transcription IS NULL AND failure_data IS NULL. Every provider returns(transcription, failure_data)andprocess_utteranceretries 6 times with backoff, so when itfinally gives up the failure is recorded as data. A clip that has given up therefore flips the
meeting to
partially_failedinstead of hanging onprocessing.partially_failedrather thanfailedis deliberate: the rest of the transcript is stillperfectly usable, and calling the whole meeting failed would be misleading.
Problem 4 — history would get slower with every recording
The problem. History is always "this user's meetings, newest first". The previous index
covered only
owner_user_id, so it helped find the rows but not order them.Measured with
EXPLAIN (ANALYZE, BUFFERS)on 6,000 rows:The point is not the 4× today — it is that the old plan reads every row a user owns and sorts
them to return one page, so it degrades as someone records more. The composite index reads only
the requested page and stays flat at 10 meetings or 10,000.
Why replace rather than add:
owner_user_idis the leading column of the new index, so itserves everything the old one did. Keeping both would mean paying for two indexes on every write
for no benefit.
Why the migration is hand-written:
makemigrationsgenerated the operations in the wrongorder — dropping the old index before creating the new one, which leaves a window with no
index at all and performs a blocking build. This migration is
atomic = Falseand doesAddIndexConcurrentlyfirst, thenRemoveIndexConcurrently, so owner lookups are neverunindexed and writes are never locked out.
Changes made
bots/meeting_status.py(new)bots/local_session_control_views.py(new)bots/migrations/0091_replace_bot_owner_index.py(new)bots/team_day_user_auth.pyoptional_user_id()— absent is fine, invalid is notbots/bots_api_views.pybots/local_session_api_urls.pybots/models.pybots/local_session_api_views.pyversion.jsonfeattitle)Total: 270 changed lines, 1 commit.
Testing performed
Everything below was run against a real server over HTTP with a real team.day-signed token
and real audio, not mocks.
New behaviour — 20/20
60(a real team.day user id)NULL— existing API customers unaffectedPAUSEDin the DB; pausing twice → 200 (idempotent)IN_PROGRESS, audio still accepted afterwardsrecording → paused → recording → processing → doneEXPLAINconfirmsbot_owner_history_idxvia Index Only Scan, old index goneRegression — nothing existing broke
test_bots_api_views,test_bots_api_utils,test_bot_data_deletion,test_cleanup→ 114 tests OK (the first directly covers the endpoint modified here)
400s, cross-user isolation, 10 real audio chunks, replayed-chunk dropped, heartbeat from its
new home, stop →
ENDEDwith recordingCOMPLETE(notFAILED), idempotent double-stop,real transcription ("Hi, I am currently testing"), and
delete_data→DATA_DELETEDsimultaneous sessions → all accepted, duplicates not double-counted
Gates:
ruff check✅ ·ruff format --check✅ ·manage.py check✅ ·version.jsonbump simulated locally against the CI rule ✅Two defects my own testing caught: the backwards migration described above, and routes
silently 404-ing locally because the dev server runs
--noreload(a local-dev quirk only —gunicorn starts fresh on every rollout).
Coding-guidelines compliance
PR reduces
local_session_api_views.pyfrom 247 → 231.bots/models.pyis pre-existing at ~3,300 lines (over the 350 cap); this PR adds 10 linesbecause a Django index definition must live in the model. See the refactor note below.
the
RecordingManagerraise path is guarded rather than allowed to 500.from client-supplied input; a mismatch returns 404 so existence never leaks.
type(scope): description.Refactoring recommendation (§2 flag)
bots/models.pybreaches the file cap. I recommend not splitting it in this fork: thehistory shows ~3,810 commits from the upstream author versus a handful of ours, and
models.pyis the most-edited file upstream — restructuring it would turn every future upstream sync into a
permanent manual conflict. Our footprint there is deliberately minimal, with all genuinely new
logic in new, compliant files. If the split is wanted, the right move is to propose it upstream
and inherit it.
Risks / notes
CONCURRENTLY. If a concurrent build failspart-way, Postgres leaves an
INVALIDindex behind that must be dropped by hand beforere-running. Queries simply ignore an invalid index, so nothing breaks meanwhile — but whoever
runs the migration should confirm it succeeded.
the previous behaviour; only a present but invalid token is newly rejected.
before (a long pause with no heartbeat can still be reaped, as designed).
version.jsonis 1.50.0, one minor above this PR's base. Note the stack: if the earlierPRs' versions are bumped before this merges, this one needs re-bumping to stay one minor above
its base.
shared JWT signing secret.