Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 13 additions & 3 deletions Frontend/client/src/lib/meeting-controls.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,11 +25,21 @@ test("Meeting controls are exposed only for recording and paused source states",
}
});

test("processing clocks freeze at endedAt while active states use the live clock", () => {
const now = Date.parse("2026-07-17T10:05:00.000Z");
test("stopped clocks retain the capture end even when processing resumes days later", () => {
const now = Date.parse("2026-07-19T22:00:00.000Z");
const endedAt = "2026-07-17T10:04:00.000Z";

for (const state of ["stopping", "finalizing", "analyzing"] as const) {
for (const state of [
"stopping",
"finalizing",
"analyzing",
"ready",
"capture_failed",
"finalization_failed",
"analysis_failed",
"interrupted",
"discarded",
] as const) {
assert.equal(meetingTimerNowMs(state, endedAt, now), Date.parse(endedAt));
}
assert.equal(meetingTimerNowMs("recording", endedAt, now), now);
Expand Down
4 changes: 2 additions & 2 deletions Frontend/client/src/lib/meeting-controls.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,9 +15,9 @@ export function meetingControlVisibility(state: MeetingState): MeetingControlVis
};
}

/** Freeze processing-state clocks at the durable capture end boundary. */
/** Freeze every stopped clock at the durable capture end boundary. */
export function meetingTimerNowMs(state: MeetingState, endedAt: string | null, nowMs: number): number {
if (!["stopping", "finalizing", "analyzing"].includes(state) || !endedAt) {
if (["starting", "recording", "paused"].includes(state) || !endedAt) {
return nowMs;
}
const endedAtMs = new Date(endedAt).getTime();
Expand Down
4 changes: 2 additions & 2 deletions Frontend/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion Frontend/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "scriber",
"version": "0.5.124",
"version": "0.5.125",
"type": "module",
"license": "MIT",
"engines": {
Expand Down
4 changes: 4 additions & 0 deletions docs/ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -431,6 +431,10 @@ bytes.
explicit `crash-recovery` gap. A process exit during `stopping` or
`finalizing` becomes `finalization_failed` instead, so the UI retries from
saved audio and never offers to append new capture to a stopped meeting.
`endedAt` records the capture end, never the processing completion time.
Successful Stop captures this boundary before recorder/provider draining;
finalization, analysis, retries, and recovery preserve it. Only an actual
transition back to recording clears it so the next Stop records a new end.
A completed Meeting in either `ready` or `analysis_failed` may be processed
again from retained evidence. Speaker-only refresh never calls an STT
provider: it verifies the persisted Opus playback asset and its SHA-256,
Expand Down
12 changes: 9 additions & 3 deletions src/data/meeting_store.py
Original file line number Diff line number Diff line change
Expand Up @@ -1427,6 +1427,7 @@ def transition(
error_message: str = "",
capture_metadata: dict[str, Any] | None = None,
analysis_model: str | None = None,
capture_ended_at: datetime | None = None,
) -> dict[str, Any]:
if new_state not in MEETING_STATES:
raise InvalidMeetingTransition(f"Unknown meeting state: {new_state}")
Expand All @@ -1435,9 +1436,14 @@ def transition(
raise InvalidMeetingTransition(f"Cannot transition {current['state']} to {new_state}.")
now = _utc_now()
started_at = now if new_state == "recording" and not current.get("startedAt") else current.get("startedAt")
ended_at = (
now if new_state in TERMINAL_STATES or new_state in {"stopping", "finalizing"} else current.get("endedAt")
)
# This is the capture boundary, not the processing completion time.
# Only actual capture resumption may reopen it; retries and recovery
# must retain the original end even when processing takes days.
ended_at = current.get("endedAt")
if new_state == "recording":
ended_at = None
elif ended_at is None and (new_state in TERMINAL_STATES or new_state in {"stopping", "finalizing"}):
ended_at = capture_ended_at.astimezone(UTC).isoformat().replace("+00:00", "Z") if capture_ended_at else now
metadata = capture_metadata if capture_metadata is not None else current.get("captureMetadata", {})
# A progress value belongs to exactly one processing run. Preserve the
# finalization -> analysis hand-off because it is one continuous
Expand Down
2 changes: 1 addition & 1 deletion src/version.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
import os
import re

__version__ = "0.5.124"
__version__ = "0.5.125"

_SEMVER_RE = re.compile(r"^\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?$")

Expand Down
6 changes: 6 additions & 0 deletions src/web_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -16776,6 +16776,10 @@ def finish(outcome: MeetingCaptureOutcome) -> MeetingCaptureOutcome:
)
)

# Both Stop and Pause stop native audio. Retain that boundary if
# subsequent recorder/provider cleanup turns Pause into a failure;
# an ordinary paused transition does not persist an end timestamp.
capture_ended_at = datetime.now(UTC)
native_payload = response.get("payload") if isinstance(response.get("payload"), dict) else {}
native_stop = _meeting_native_stop_snapshot(native_payload)
if native_stop:
Expand Down Expand Up @@ -16838,6 +16842,7 @@ def finish(outcome: MeetingCaptureOutcome) -> MeetingCaptureOutcome:
error_code=failure_code,
error_message=failure_message,
capture_metadata=capture_metadata,
capture_ended_at=capture_ended_at,
)
)
pending_cancel = pending_cancel or transition_cancel
Expand Down Expand Up @@ -16865,6 +16870,7 @@ def finish(outcome: MeetingCaptureOutcome) -> MeetingCaptureOutcome:
meeting_id,
target_state,
capture_metadata=capture_metadata,
capture_ended_at=capture_ended_at,
)
)
pending_cancel = pending_cancel or transition_cancel
Expand Down
70 changes: 70 additions & 0 deletions tests/data/test_meeting_store.py
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,76 @@ def create_request(**overrides):
return MeetingCreate(**values)


@pytest.mark.parametrize("paused", [False, True])
def test_capture_end_survives_processing_retries_recovery_and_reprocessing(store, monkeypatch, paused):
now = "2026-09-25T07:04:46Z"
monkeypatch.setattr("src.data.meeting_store._utc_now", lambda: now)
meeting_id = store.create(create_request())["id"]
store.transition(meeting_id, "recording")
if paused:
store.transition(meeting_id, "paused")
now = "2026-09-25T11:23:38Z"
stopped = store.transition(meeting_id, "stopping")
assert stopped["endedAt"] == now

now = "2026-09-27T18:58:09Z"
for state in ("stopping", "finalizing"):
assert store.transition(meeting_id, state)["endedAt"] == stopped["endedAt"]
assert store.recover_interrupted() == 1
assert store.get(meeting_id)["endedAt"] == stopped["endedAt"]
for state in (
"finalizing",
"finalization_failed",
"finalizing",
"analyzing",
"analysis_failed",
"analyzing",
"ready",
):
result = store.transition(meeting_id, state)
assert result["endedAt"] == stopped["endedAt"]
assert result["updatedAt"] == now
reprocessing = store.reserve_full_reprocess(
meeting_id, final_provider="soniox_async", analysis_model="test-model", voice_library_enabled=False
)
assert reprocessing["endedAt"] == stopped["endedAt"]
for state in ("ready", "analyzing", "ready", "discarded"):
assert store.transition(meeting_id, state)["endedAt"] == stopped["endedAt"]


def test_resumed_capture_reopens_end_but_keeps_original_start(store, monkeypatch):
now = "2026-09-25T07:00:00Z"
monkeypatch.setattr("src.data.meeting_store._utc_now", lambda: now)
meeting_id = store.create(create_request())["id"]
original = store.transition(meeting_id, "recording")
now = "2026-09-25T08:00:00Z"
assert store.recover_interrupted() == 1
assert store.get(meeting_id)["endedAt"] == now

now = "2026-09-25T09:00:00Z"
resumed = store.transition(meeting_id, "recording")
assert resumed["startedAt"] == original["startedAt"]
assert resumed["endedAt"] is None
now = "2026-09-25T11:00:00Z"
stopped = store.transition(meeting_id, "stopping")
assert stopped["endedAt"] == now
now = "2026-09-27T19:00:00Z"
assert store.transition(meeting_id, "finalizing")["endedAt"] == stopped["endedAt"]


@pytest.mark.parametrize("state", ["stopping", "capture_failed"])
def test_capture_end_uses_native_stop_time_before_delayed_cleanup(store, monkeypatch, state):
now = "2026-09-25T07:00:00Z"
monkeypatch.setattr("src.data.meeting_store._utc_now", lambda: now)
meeting_id = store.create(create_request())["id"]
store.transition(meeting_id, "recording")
native_end = datetime(2026, 9, 25, 11, 23, 38, tzinfo=UTC)
now = "2026-09-27T18:58:09Z"
stopped = store.transition(meeting_id, state, capture_ended_at=native_end)
assert stopped["endedAt"] == "2026-09-25T11:23:38Z"
assert store.transition(meeting_id, "finalizing")["endedAt"] == stopped["endedAt"]


def test_origin_is_first_class_without_fabricating_consent(store: MeetingStore):
captured = store.create(MeetingCreate(title="Private call"))
assert captured["origin"] == "captured"
Expand Down
63 changes: 63 additions & 0 deletions tests/test_meeting_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -2248,6 +2248,69 @@ async def release_audio(changed_controller, released_claim):
database._close_all_connections()


@pytest.mark.asyncio
@pytest.mark.parametrize("action", ["pause", "stop"])
@pytest.mark.parametrize("recorder_fails", [False, True])
async def test_stop_keeps_native_capture_end_through_delayed_provider_cleanup(
monkeypatch, tmp_path, action, recorder_fails
):
controller, store, meeting, recorder = _recording_meeting_control_controller(
monkeypatch, tmp_path, "stop-capture-end.db"
)
controller._persistent_audio_claim = _test_audio_claim("meeting", meeting["id"])
native_end = datetime(2026, 9, 25, 11, 23, 38, tzinfo=UTC)
clock = native_end

if recorder_fails:

def failed_recorder_stop(**_kwargs):
recorder.stop_count += 1
raise RuntimeError("Meeting audio reader did not stop before the timeout.")

recorder.stop = failed_recorder_stop
recorder.snapshot = lambda: {"microphone": {"chunks": 1, "errorCode": "reader_stop_timeout"}}

class Clock:
@staticmethod
def now(_tz):
return clock

class DelayedLiveTranscriber(FakeLiveTranscriber):
async def stop(self):
nonlocal clock
assert recorder.stop_count == 1
clock = datetime(2026, 9, 27, 18, 58, 9, tzinfo=UTC)
await super().stop()

async def release_audio(changed_controller, _claim):
changed_controller._persistent_audio_claim = None
return True

controller._meeting_live_transcribers[meeting["id"]] = DelayedLiveTranscriber()
monkeypatch.setattr(web_api, "datetime", Clock)
monkeypatch.setattr("src.data.meeting_store._utc_now", lambda: clock.isoformat().replace("+00:00", "Z"))
monkeypatch.setattr(
web_api, "call_shell_ipc", lambda *_args, **_kwargs: {"success": True, "payload": {"stopped": True}}
)
monkeypatch.setattr(web_api, "_release_persistent_audio", release_audio)
app = web_api.create_app(controller)
handler = _route_handler(app, "POST", f"/api/meetings/{{id}}/{action}")
try:
response = await handler(_DirectRequest(app, meeting_id=meeting["id"]))
assert response.status == (503 if recorder_fails else 202 if action == "stop" else 200)
result = json.loads(response.body)
if recorder_fails:
result = result["meeting"]
assert result["state"] == (
"capture_failed" if recorder_fails else "finalizing" if action == "stop" else "paused"
)
assert result["endedAt"] == ("2026-09-25T11:23:38Z" if action == "stop" or recorder_fails else None)
assert result["updatedAt"] == "2026-09-27T18:58:09Z"
assert store.get(meeting["id"])["endedAt"] == result["endedAt"]
finally:
database._close_all_connections()


@pytest.mark.asyncio
async def test_stop_reserves_finalizer_before_committing_finalizing(monkeypatch, tmp_path):
controller, store, meeting, recorder = _recording_meeting_control_controller(
Expand Down
Loading