From 862a92bcc0845cc22de5cb1a664f88c3dc48ff27 Mon Sep 17 00:00:00 2001 From: LUPENGHAN Date: Mon, 17 Aug 2026 18:31:57 +0800 Subject: [PATCH 1/5] feat(schedule): sync occurrence overrides to the client Closes #272 voice.command.result gains an occurrence_overrides field alongside schedule/schedules. Backend: CommandOutcome/CommandResult carry it through from ScheduleMutationResult.occurrence_overrides (already populated by _delete_recurring_range for scope=this_occurrence, but previously dropped before reaching the wire); schedule_tools.py's _mutation_result serializes each ScheduleOccurrenceOverrideSnapshot the same way _snapshot_for_client already does for schedules. Frontend: AppliedCommand carries the new field through from the WS message; LocalScheduleWriter.applyCommandResult now handles schedule and occurrence_overrides independently (a this_occurrence delete produces only an override, no new schedule snapshot) and writes each override via the already-existing, already-tested ScheduleLocalRepository.upsertOccurrenceOverride. Independent of the reminder-integration branch stack (#264-#271) -- the occurrence-override table and upsertOccurrenceOverride already exist on main; this only needed LocalScheduleWriter's plain-main shape, not the reminder stack's SqliteLocalScheduleReader wiring. --- .../timeflow/gateway/websocket/agent_ports.py | 5 + .../websocket/handlers/agent_result.py | 1 + .../gateway/websocket/messages/agent.py | 1 + backend/src/timeflow/intelligence/ports.py | 1 + .../timeflow/intelligence/realtime/agent.py | 1 + .../intelligence/realtime/schedule_tools.py | 8 ++ frontend/src/contracts/conversation.ts | 9 ++ .../AssistantContinuousConversationService.ts | 1 + .../AssistantConversationService.ts | 1 + .../data/local/LocalScheduleWriter.ts | 38 +++++-- .../assistant/domain/ConversationTurn.ts | 9 ++ .../integration/localScheduleWriter.test.ts | 107 ++++++++++++++++++ 12 files changed, 173 insertions(+), 9 deletions(-) create mode 100644 frontend/tests/integration/localScheduleWriter.test.ts diff --git a/backend/src/timeflow/gateway/websocket/agent_ports.py b/backend/src/timeflow/gateway/websocket/agent_ports.py index c47e7aef..d44812c3 100644 --- a/backend/src/timeflow/gateway/websocket/agent_ports.py +++ b/backend/src/timeflow/gateway/websocket/agent_ports.py @@ -153,6 +153,11 @@ def schedules(self) -> list[dict[str, Any]] | None: """Matches a query found, when the command was a query.""" ... + @property + def occurrence_overrides(self) -> list[dict[str, Any]] | None: + """Per-occurrence exceptions (cancel/replace) a recurring-schedule mutation produced.""" + ... + class AudioReplyInfo(Protocol): """Format and purpose of a spoken reply, announced before its audio.""" diff --git a/backend/src/timeflow/gateway/websocket/handlers/agent_result.py b/backend/src/timeflow/gateway/websocket/handlers/agent_result.py index 2bcfa5ff..6b669f61 100644 --- a/backend/src/timeflow/gateway/websocket/handlers/agent_result.py +++ b/backend/src/timeflow/gateway/websocket/handlers/agent_result.py @@ -91,6 +91,7 @@ async def deliver_result(self, result: CommandOutcome, stream: StreamIdentity) - status=result.status, schedule=result.schedule, schedules=result.schedules, + occurrence_overrides=result.occurrence_overrides, ), ) await self._send(stream.session_id, message.type, message.model_dump(exclude_none=True)) diff --git a/backend/src/timeflow/gateway/websocket/messages/agent.py b/backend/src/timeflow/gateway/websocket/messages/agent.py index c7ccd654..a2c706d4 100644 --- a/backend/src/timeflow/gateway/websocket/messages/agent.py +++ b/backend/src/timeflow/gateway/websocket/messages/agent.py @@ -29,6 +29,7 @@ class VoiceCommandResultPayload(BaseModel): status: str schedule: dict[str, Any] | None = None schedules: list[dict[str, Any]] | None = None + occurrence_overrides: list[dict[str, Any]] | None = None class VoiceCommandResult(BaseModel): diff --git a/backend/src/timeflow/intelligence/ports.py b/backend/src/timeflow/intelligence/ports.py index 18d3b734..149c18de 100644 --- a/backend/src/timeflow/intelligence/ports.py +++ b/backend/src/timeflow/intelligence/ports.py @@ -108,6 +108,7 @@ class CommandResult: status: str schedule: dict[str, Any] | None = None schedules: list[dict[str, Any]] | None = None + occurrence_overrides: list[dict[str, Any]] | None = None @dataclass(frozen=True, slots=True) diff --git a/backend/src/timeflow/intelligence/realtime/agent.py b/backend/src/timeflow/intelligence/realtime/agent.py index 132eb051..5f9cdcaf 100644 --- a/backend/src/timeflow/intelligence/realtime/agent.py +++ b/backend/src/timeflow/intelligence/realtime/agent.py @@ -399,6 +399,7 @@ async def tool_requested(self, call_id: str, name: str, arguments: dict[str, Any status=str(outcome["status"]), schedule=outcome.get("schedule"), schedules=outcome.get("schedules"), + occurrence_overrides=outcome.get("occurrence_overrides"), ), self._stream, ) diff --git a/backend/src/timeflow/intelligence/realtime/schedule_tools.py b/backend/src/timeflow/intelligence/realtime/schedule_tools.py index 0b2a7db0..d3374d00 100644 --- a/backend/src/timeflow/intelligence/realtime/schedule_tools.py +++ b/backend/src/timeflow/intelligence/realtime/schedule_tools.py @@ -463,6 +463,7 @@ def _business_error(error: ScheduleBusinessError) -> ToolResult: def _mutation_result(result: ScheduleMutationResult, operation: str, tz: ZoneInfo) -> ToolResult: """Convert a mutation result into model output and client outcome.""" snapshot = result.schedules[0] if result.schedules else None + overrides = [_override_for_client(o) for o in result.occurrence_overrides] return ToolResult( output=json.dumps( {"status": "applied", "schedule": _for_model_dict(snapshot, tz)}, ensure_ascii=False @@ -471,6 +472,7 @@ def _mutation_result(result: ScheduleMutationResult, operation: str, tz: ZoneInf "operation": operation, "status": "applied", "schedule": _snapshot_for_client(snapshot) if snapshot else None, + "occurrence_overrides": overrides or None, }, ) @@ -500,6 +502,12 @@ def _snapshot_for_client(snapshot: Any) -> dict[str, Any]: } +def _override_for_client(override: Any) -> dict[str, Any]: + """Convert ScheduleOccurrenceOverrideSnapshot to client dict, filtering out audit fields.""" + d = asdict(override) + return {k: _json_value(v) for k, v in d.items() if k not in {"created_at", "updated_at"}} + + def _local_text(instant: datetime | None, tz: ZoneInfo) -> str: """Render a stored instant as local wall-clock text, empty for a schedule without one.""" if instant is None: diff --git a/frontend/src/contracts/conversation.ts b/frontend/src/contracts/conversation.ts index 625039d7..09690f0c 100644 --- a/frontend/src/contracts/conversation.ts +++ b/frontend/src/contracts/conversation.ts @@ -64,6 +64,14 @@ export interface VoiceAsrCompletedMessage { /** 日程快照的形状由后端 `ScheduleSnapshot` 决定;前端只透传给本地落库,不在此处强约束字段。 */ export type ScheduleSnapshotPayload = Record; +export interface OccurrenceOverridePayload { + id: string; + schedule_id: string; + occurrence_start: string; + action: 'cancel' | 'replace'; + replacement_schedule_id: string | null; +} + export interface VoiceCommandResultMessage { type: 'voice.command.result'; message_id: string; @@ -74,6 +82,7 @@ export interface VoiceCommandResultMessage { status: string; schedule?: ScheduleSnapshotPayload; schedules?: ScheduleSnapshotPayload[]; + occurrence_overrides?: OccurrenceOverridePayload[]; }; } diff --git a/frontend/src/features/assistant/application/AssistantContinuousConversationService.ts b/frontend/src/features/assistant/application/AssistantContinuousConversationService.ts index 850b2351..8ea9beac 100644 --- a/frontend/src/features/assistant/application/AssistantContinuousConversationService.ts +++ b/frontend/src/features/assistant/application/AssistantContinuousConversationService.ts @@ -342,6 +342,7 @@ export class AssistantContinuousConversationService implements AssistantApplicat operation: message.payload.operation, schedule: message.payload.schedule, schedules: message.payload.schedules, + occurrence_overrides: message.payload.occurrence_overrides, status: message.payload.status, }; // 状态立刻回到 listening(麦克风还开着),不等写库;message.ack 必须等 diff --git a/frontend/src/features/assistant/application/AssistantConversationService.ts b/frontend/src/features/assistant/application/AssistantConversationService.ts index 3aa21790..7aacaf73 100644 --- a/frontend/src/features/assistant/application/AssistantConversationService.ts +++ b/frontend/src/features/assistant/application/AssistantConversationService.ts @@ -224,6 +224,7 @@ export class AssistantConversationService implements AssistantApplicationPort { operation: message.payload.operation, schedule: message.payload.schedule, schedules: message.payload.schedules, + occurrence_overrides: message.payload.occurrence_overrides, status: message.payload.status, }; // 状态立刻回到 idle,不等写库;message.ack 必须等写库成功才发(AGENTS.md §6)。 diff --git a/frontend/src/features/assistant/data/local/LocalScheduleWriter.ts b/frontend/src/features/assistant/data/local/LocalScheduleWriter.ts index b8b432ef..b24186ba 100644 --- a/frontend/src/features/assistant/data/local/LocalScheduleWriter.ts +++ b/frontend/src/features/assistant/data/local/LocalScheduleWriter.ts @@ -1,6 +1,10 @@ -import type { CloudScheduleRow, ScheduleLocalRepository } from '../../../schedule/data'; +import type { + CloudScheduleRow, + LocalScheduleOccurrenceOverrideRow, + ScheduleLocalRepository, +} from '../../../schedule/data'; import type { LocalScheduleWriterPort } from '../../application/interfaces/LocalScheduleWriterPort'; -import type { AppliedCommand } from '../../domain/ConversationTurn'; +import type { AppliedCommand, AppliedOccurrenceOverride } from '../../domain/ConversationTurn'; /** * `applyCloudSchedule` 是仓储里现成的 upsert,专门给"服务端权威数据落到本地"用的 @@ -16,20 +20,24 @@ import type { AppliedCommand } from '../../domain/ConversationTurn'; * `schedule` 字段存在就同步,这样天然排除 list_schedules(只有 `schedules` * 复数字段,没有 `schedule`)。 * - * 已知没覆盖的情况:重复日程"仅删除这一次"且不需要截断 RRULE 时 - * (service.py 的 _delete_recurring_range 里创建 occurrence_override 那条 - * 分支),后端只把 override 记到 ScheduleMutationResult.occurrence_overrides - * 里,`voice.command.result` 完全不下发这个字段——这种情况本地日历目前没法 - * 同步,得等后端把 override 数据也传下来。 + * 重复日程"仅删除这一次"(service.py 的 _delete_recurring_range 里创建 + * occurrence_override 那条分支)不产生新的 schedule 快照,只产生一条 + * override;`occurrence_overrides` 跟 `schedule` 各自独立处理,一条 + * command.result 里两者都可能有、也可能都没有。 */ export class LocalScheduleWriter implements LocalScheduleWriterPort { public constructor(private readonly repository: ScheduleLocalRepository) {} public async applyCommandResult(accountId: string, command: AppliedCommand): Promise { - if (command.status !== 'applied' || !command.schedule) { + if (command.status !== 'applied' || (!command.schedule && !command.occurrence_overrides)) { return; } - await this.repository.applyCloudSchedule(toCloudScheduleRow(accountId, command.schedule)); + if (command.schedule) { + await this.repository.applyCloudSchedule(toCloudScheduleRow(accountId, command.schedule)); + } + for (const override of command.occurrence_overrides ?? []) { + await this.repository.upsertOccurrenceOverride(accountId, toOverrideRow(override)); + } } } @@ -70,6 +78,18 @@ function toCloudScheduleRow(accountId: string, raw: Record): Cl }; } +function toOverrideRow( + override: AppliedOccurrenceOverride, +): LocalScheduleOccurrenceOverrideRow { + return { + id: override.id, + schedule_id: override.schedule_id, + occurrence_start: override.occurrence_start, + action: override.action, + replacement_schedule_id: override.replacement_schedule_id, + }; +} + function requireString(value: unknown, field: string): string { if (typeof value !== 'string' || value.length === 0) { throw new TypeError(`command.result.schedule.${field} must be a non-empty string`); diff --git a/frontend/src/features/assistant/domain/ConversationTurn.ts b/frontend/src/features/assistant/domain/ConversationTurn.ts index c6632573..774742dd 100644 --- a/frontend/src/features/assistant/domain/ConversationTurn.ts +++ b/frontend/src/features/assistant/domain/ConversationTurn.ts @@ -18,9 +18,18 @@ export type ConversationTurnState = | { phase: 'paused'; conversationId: string | null } | { phase: 'error'; message: string }; +export interface AppliedOccurrenceOverride { + id: string; + schedule_id: string; + occurrence_start: string; + action: 'cancel' | 'replace'; + replacement_schedule_id: string | null; +} + export interface AppliedCommand { operation: string; status: string; schedule?: Record; schedules?: Record[]; + occurrence_overrides?: AppliedOccurrenceOverride[]; } diff --git a/frontend/tests/integration/localScheduleWriter.test.ts b/frontend/tests/integration/localScheduleWriter.test.ts new file mode 100644 index 00000000..f8938f58 --- /dev/null +++ b/frontend/tests/integration/localScheduleWriter.test.ts @@ -0,0 +1,107 @@ +import initSqlJs, { type SqlJsStatic } from 'sql.js'; +import { afterEach, beforeAll, beforeEach, describe, expect, it } from 'vitest'; + +import { LocalScheduleWriter } from '../../src/features/assistant/data/local/LocalScheduleWriter'; +import type { AppliedCommand } from '../../src/features/assistant/domain/ConversationTurn'; +import { ScheduleLocalRepository } from '../../src/features/schedule/data'; +import { migrateScheduleDatabase } from '../../src/infrastructure/database/migrations'; +import { SqlJsExpoDatabase } from '../helpers/sqliteTestDatabase'; + +function appliedCommand(overrides: Partial = {}): AppliedCommand { + return { + operation: 'create_schedule', + status: 'applied', + schedule: { + id: 'schedule-a', + schedule_type: 'time', + schedule_kind: 'once', + title: 'Team sync', + is_all_day: false, + timezone: 'Asia/Shanghai', + start_time: '2026-08-12T07:00:00Z', + revision: 1, + }, + ...overrides, + }; +} + +describe('LocalScheduleWriter', () => { + let sql: SqlJsStatic; + let database: SqlJsExpoDatabase; + let repository: ScheduleLocalRepository; + + beforeAll(async () => { + sql = await initSqlJs(); + }); + + beforeEach(async () => { + database = new SqlJsExpoDatabase(new sql.Database()); + await migrateScheduleDatabase(database.asSQLiteDatabase()); + repository = new ScheduleLocalRepository(database.asSQLiteDatabase()); + }); + + afterEach(() => { + database.close(); + }); + + it('writes the schedule when the command carries one', async () => { + const writer = new LocalScheduleWriter(repository); + await writer.applyCommandResult('account-a', appliedCommand()); + + const stored = await repository.getSchedule('account-a', 'schedule-a'); + expect(stored?.title).toBe('Team sync'); + }); + + it('does not write when the command was not applied', async () => { + const writer = new LocalScheduleWriter(repository); + await writer.applyCommandResult('account-a', appliedCommand({ status: 'rejected' })); + + const stored = await repository.getSchedule('account-a', 'schedule-a'); + expect(stored).toBeNull(); + }); + + it('writes an occurrence override even when the command carries no schedule', async () => { + const writer = new LocalScheduleWriter(repository); + // 先建好日程,delete_this_occurrence 只回一条 override,不带 schedule 快照。 + await writer.applyCommandResult('account-a', appliedCommand()); + + await writer.applyCommandResult( + 'account-a', + appliedCommand({ + operation: 'delete_schedule', + schedule: undefined, + occurrence_overrides: [ + { + id: 'override-a', + schedule_id: 'schedule-a', + occurrence_start: '2026-08-19T07:00:00Z', + action: 'cancel', + replacement_schedule_id: null, + }, + ], + }), + ); + + const overrides = await repository.listOccurrenceOverrides('account-a', 'schedule-a'); + expect(overrides).toEqual([ + { + id: 'override-a', + schedule_id: 'schedule-a', + occurrence_start: '2026-08-19T07:00:00Z', + action: 'cancel', + replacement_schedule_id: null, + }, + ]); + }); + + it('does nothing when the command has neither a schedule nor overrides', async () => { + const writer = new LocalScheduleWriter(repository); + await writer.applyCommandResult( + 'account-a', + appliedCommand({ schedule: undefined, occurrence_overrides: undefined }), + ); + + const stored = await repository.getSchedule('account-a', 'schedule-a'); + expect(stored).toBeNull(); + }); +}); From cdf8d04cc9ba9749bb07f428e7f35a2729ccba43 Mon Sep 17 00:00:00 2001 From: LUPENGHAN Date: Mon, 17 Aug 2026 18:41:40 +0800 Subject: [PATCH 2/5] test(schedule): cover the this_occurrence override serialization path Prettier fix for LocalScheduleWriter.ts (npm run check caught it). Adds a realtime-toolbox test exercising a delete with scope=this_occurrence that produces an occurrence_override -- the only path that actually calls _override_for_client. Closes the 2-line patch-coverage gap Codecov flagged on the previous commit. --- .../realtime/test_realtime_toolbox.py | 44 +++++++++++++++++++ .../data/local/LocalScheduleWriter.ts | 4 +- 2 files changed, 45 insertions(+), 3 deletions(-) diff --git a/backend/tests/intelligence/realtime/test_realtime_toolbox.py b/backend/tests/intelligence/realtime/test_realtime_toolbox.py index 73bd11f4..3a947ac4 100644 --- a/backend/tests/intelligence/realtime/test_realtime_toolbox.py +++ b/backend/tests/intelligence/realtime/test_realtime_toolbox.py @@ -11,11 +11,13 @@ import pytest from timeflow.business.calendar import ( + OccurrenceOverrideAction, ScheduleAgentService, ScheduleBusinessError, ScheduleErrorCode, ScheduleKind, ScheduleMutationResult, + ScheduleOccurrenceOverrideSnapshot, ScheduleSearchResult, ScheduleSnapshot, ScheduleStatus, @@ -305,6 +307,48 @@ def test_a_delete_reaches_the_call_that_matches_the_kind( assert service.calls == [expected] +OVERRIDE = ScheduleOccurrenceOverrideSnapshot( + id="ovr_1", + schedule_id="sch_1", + occurrence_start=datetime(2026, 9, 8, 7, 0, tzinfo=UTC), + action=OccurrenceOverrideAction.CANCEL, + created_at=datetime(2026, 9, 7, 1, 0, tzinfo=UTC), + updated_at=datetime(2026, 9, 7, 1, 0, tzinfo=UTC), +) + + +class OverrideProducingService(RecordingService): + """A this_occurrence delete that cancels rather than replaces: no new schedule, one override.""" + + def delete_recurring_schedule(self, *, account_id: str, command: Any) -> ScheduleMutationResult: + self.calls.append("delete_recurring") + return ScheduleMutationResult(schedules=(), occurrence_overrides=(OVERRIDE,)) + + +def test_a_this_occurrence_delete_reports_the_override_it_produced() -> None: + result = run( + "schedule_delete", + { + "schedule_id": "sch_1", + "expected_revision": 1, + "schedule_kind": "recurring", + "scope": "this_occurrence", + }, + ToolBox("acc_test", OverrideProducingService()), + ) + assert result.outcome is not None + assert result.outcome["schedule"] is None + assert result.outcome["occurrence_overrides"] == [ + { + "id": "ovr_1", + "schedule_id": "sch_1", + "occurrence_start": "2026-09-08T07:00:00+00:00", + "action": "cancel", + "replacement_schedule_id": None, + } + ] + + def test_a_delete_with_nothing_left_to_report_still_says_it_applied() -> None: result = run( "schedule_delete", diff --git a/frontend/src/features/assistant/data/local/LocalScheduleWriter.ts b/frontend/src/features/assistant/data/local/LocalScheduleWriter.ts index b24186ba..89f23ccc 100644 --- a/frontend/src/features/assistant/data/local/LocalScheduleWriter.ts +++ b/frontend/src/features/assistant/data/local/LocalScheduleWriter.ts @@ -78,9 +78,7 @@ function toCloudScheduleRow(accountId: string, raw: Record): Cl }; } -function toOverrideRow( - override: AppliedOccurrenceOverride, -): LocalScheduleOccurrenceOverrideRow { +function toOverrideRow(override: AppliedOccurrenceOverride): LocalScheduleOccurrenceOverrideRow { return { id: override.id, schedule_id: override.schedule_id, From a669e0b5c020f183e13f1c2fabeec641750e227b Mon Sep 17 00:00:00 2001 From: LUPENGHAN Date: Mon, 17 Aug 2026 18:54:21 +0800 Subject: [PATCH 3/5] test(schedule): cover LocalScheduleWriter's missing-field rejection Closes the 1-line patch-coverage gap Codecov flagged: requireString's throw path had no test on this branch (the file existed on main with no dedicated test at all before this PR added one). --- frontend/tests/integration/localScheduleWriter.test.ts | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/frontend/tests/integration/localScheduleWriter.test.ts b/frontend/tests/integration/localScheduleWriter.test.ts index f8938f58..e3ac6e10 100644 --- a/frontend/tests/integration/localScheduleWriter.test.ts +++ b/frontend/tests/integration/localScheduleWriter.test.ts @@ -52,6 +52,15 @@ describe('LocalScheduleWriter', () => { expect(stored?.title).toBe('Team sync'); }); + it('rejects a schedule payload missing a required field', async () => { + const writer = new LocalScheduleWriter(repository); + const command = appliedCommand({ schedule: { id: 'schedule-a' } }); + + await expect(writer.applyCommandResult('account-a', command)).rejects.toThrow( + 'command.result.schedule.schedule_type must be a non-empty string', + ); + }); + it('does not write when the command was not applied', async () => { const writer = new LocalScheduleWriter(repository); await writer.applyCommandResult('account-a', appliedCommand({ status: 'rejected' })); From 80afb4db128bf7eac82373bd5d77c698c453b5f9 Mon Sep 17 00:00:00 2001 From: LUPENGHAN Date: Tue, 18 Aug 2026 16:49:37 +0800 Subject: [PATCH 4/5] fix(schedule): surface every schedule a mutation produced, not just the first _delete_recurring_range's this_occurrence path, when it hits an existing replace override, soft-deletes the replacement schedule and returns it alongside the untouched parent in ScheduleMutationResult.schedules. _mutation_result only ever forwarded schedules[0] to the client, so that soft-delete never reached voice.command.result and the client kept showing the stale replacement as active. CommandOutcome already had a schedules (plural) field - only list_schedules populated it. _mutation_result now fills it with every schedule the mutation touched; schedule (singular) stays as schedules[0] for backward compat with clients not yet updated. --- .../intelligence/realtime/schedule_tools.py | 2 ++ .../realtime/test_realtime_toolbox.py | 30 +++++++++++++++++++ 2 files changed, 32 insertions(+) diff --git a/backend/src/timeflow/intelligence/realtime/schedule_tools.py b/backend/src/timeflow/intelligence/realtime/schedule_tools.py index d3374d00..148c8559 100644 --- a/backend/src/timeflow/intelligence/realtime/schedule_tools.py +++ b/backend/src/timeflow/intelligence/realtime/schedule_tools.py @@ -463,6 +463,7 @@ def _business_error(error: ScheduleBusinessError) -> ToolResult: def _mutation_result(result: ScheduleMutationResult, operation: str, tz: ZoneInfo) -> ToolResult: """Convert a mutation result into model output and client outcome.""" snapshot = result.schedules[0] if result.schedules else None + schedules = [_snapshot_for_client(s) for s in result.schedules] overrides = [_override_for_client(o) for o in result.occurrence_overrides] return ToolResult( output=json.dumps( @@ -472,6 +473,7 @@ def _mutation_result(result: ScheduleMutationResult, operation: str, tz: ZoneInf "operation": operation, "status": "applied", "schedule": _snapshot_for_client(snapshot) if snapshot else None, + "schedules": schedules or None, "occurrence_overrides": overrides or None, }, ) diff --git a/backend/tests/intelligence/realtime/test_realtime_toolbox.py b/backend/tests/intelligence/realtime/test_realtime_toolbox.py index 3a947ac4..96493174 100644 --- a/backend/tests/intelligence/realtime/test_realtime_toolbox.py +++ b/backend/tests/intelligence/realtime/test_realtime_toolbox.py @@ -349,6 +349,36 @@ def test_a_this_occurrence_delete_reports_the_override_it_produced() -> None: ] +class MultiScheduleProducingService(RecordingService): + """A this_occurrence delete hitting an existing replace override: no new override, + but two schedules — the untouched parent and the soft-deleted replacement.""" + + def delete_recurring_schedule(self, *, account_id: str, command: Any) -> ScheduleMutationResult: + self.calls.append("delete_recurring") + replacement = replace(SNAPSHOT, id="sch_replacement", status=ScheduleStatus.DELETED) + return ScheduleMutationResult(schedules=(SNAPSHOT, replacement)) + + +def test_a_this_occurrence_delete_with_existing_replacement_reports_both_schedules() -> None: + result = run( + "schedule_delete", + { + "schedule_id": "sch_1", + "expected_revision": 1, + "schedule_kind": "recurring", + "scope": "this_occurrence", + }, + ToolBox("acc_test", MultiScheduleProducingService()), + ) + assert result.outcome is not None + # schedule(单数)保持只给第一条,向后兼容 + assert result.outcome["schedule"]["id"] == "sch_1" + # schedules(复数)必须两条都在,这是这次要修的东西——之前只有 schedules[0] 会下发, + # 软删除的 replacement 永远到不了客户端 + ids = [s["id"] for s in result.outcome["schedules"]] + assert ids == ["sch_1", "sch_replacement"] + + def test_a_delete_with_nothing_left_to_report_still_says_it_applied() -> None: result = run( "schedule_delete", From b1e20e1e341cec4ebc78c85bae1201aa3095a2d6 Mon Sep 17 00:00:00 2001 From: LUPENGHAN Date: Tue, 18 Aug 2026 16:49:47 +0800 Subject: [PATCH 5/5] fix(schedule): check write results and transact LocalScheduleWriter applyCommandResult ignored the boolean applyCloudSchedule()/ upsertOccurrenceOverride() return, so a failed write (missing parent schedule, account mismatch) still let the caller send message.ack status=applied - the server believed the command was persisted when it wasn't. Now every write's return value is checked and a false throws, and all writes for one command result run inside a single transaction via the repository's new withTransaction(). A command can produce multiple schedule and override writes that together represent one voice command landing; without the transaction a partial failure would leave state the server never actually had, and since the caller already skips the ack on any throw, that state would never get retried either. Also switches to consuming the schedules (plural) field the backend now populates for mutations, explicitly excluding list_schedules by operation instead of relying on the coincidence that query results never set schedule/occurrence_overrides. --- .../data/local/LocalScheduleWriter.ts | 53 +++++++++++++------ .../data/local/scheduleLocalRepository.ts | 14 +++++ .../integration/localScheduleWriter.test.ts | 53 +++++++++++++++++++ .../tests/scheduleStorageContracts.test-d.ts | 1 + 4 files changed, 105 insertions(+), 16 deletions(-) diff --git a/frontend/src/features/assistant/data/local/LocalScheduleWriter.ts b/frontend/src/features/assistant/data/local/LocalScheduleWriter.ts index 89f23ccc..9fa96c20 100644 --- a/frontend/src/features/assistant/data/local/LocalScheduleWriter.ts +++ b/frontend/src/features/assistant/data/local/LocalScheduleWriter.ts @@ -11,33 +11,54 @@ import type { AppliedCommand, AppliedOccurrenceOverride } from '../../domain/Con * (固定写 sync_status='synced'),voice.command.result 正好是这种场景——服务端 * 已经提交过了,这里只是把它同步进本地日历读服务能看到的地方。 * - * 不按 operation 名字判断该不该写:后端 create_schedule/update_schedule/ + * 用 `operation` 显式排除 list_schedules:后端 create_schedule/update_schedule/ * delete_schedule 三个操作共用同一条 `_mutation_result` 路径 - * (backend/src/timeflow/intelligence/realtime/schedule_tools.py:384), - * `schedule` 字段结构完全一样——删除是软删除,`status` 会变成 'deleted', + * (backend/src/timeflow/intelligence/realtime/schedule_tools.py:463), + * `list_schedules`(查询,见同文件 `_find`)复用同一个 `schedules` 复数字段填 + * outcome——不能靠"字段是否存在"这种隐式判断排除它,query 结果不是已提交的写入, + * 不能当成本地权威数据 upsert。删除是软删除,`status` 会变成 'deleted', * `applyCloudSchedule` 照样按 id upsert,本地这行的 status 也跟着变成 - * 'deleted',日历读服务只认 status==='active' 的行,自然就不显示了。只要 - * `schedule` 字段存在就同步,这样天然排除 list_schedules(只有 `schedules` - * 复数字段,没有 `schedule`)。 + * 'deleted',日历读服务只认 status==='active' 的行,自然就不显示了。 * - * 重复日程"仅删除这一次"(service.py 的 _delete_recurring_range 里创建 - * occurrence_override 那条分支)不产生新的 schedule 快照,只产生一条 - * override;`occurrence_overrides` 跟 `schedule` 各自独立处理,一条 - * command.result 里两者都可能有、也可能都没有。 + * 重复日程"仅删除这一次"命中已存在的 replace override 时(service.py 的 + * _delete_recurring_range),不产生新 override,而是让 `schedules` 里带上被 + * 连带软删的 replacement——`schedules` 复数字段是"这次命令落地的全部 schedule + * 快照",不是只服务查询场景;`occurrence_overrides` 跟 `schedules` 各自独立, + * 一条 command.result 里两者都可能有、也可能都没有。 + * + * 一条命令可能同时产生多条 schedule 写入和多条 override 写入,合起来才代表这条 + * 语音指令完整落地,所以全部包在一个事务里:任何一次写入失败(仓储返回 false, + * 比如父日程缺失、账号不匹配)都会抛错并回滚整个事务,调用方据此不发 + * message.ack,不会向服务端谎报已落库、也不会在本地留下半吊子状态。 */ export class LocalScheduleWriter implements LocalScheduleWriterPort { public constructor(private readonly repository: ScheduleLocalRepository) {} public async applyCommandResult(accountId: string, command: AppliedCommand): Promise { - if (command.status !== 'applied' || (!command.schedule && !command.occurrence_overrides)) { + if (command.status !== 'applied' || command.operation === 'list_schedules') { return; } - if (command.schedule) { - await this.repository.applyCloudSchedule(toCloudScheduleRow(accountId, command.schedule)); - } - for (const override of command.occurrence_overrides ?? []) { - await this.repository.upsertOccurrenceOverride(accountId, toOverrideRow(override)); + const schedules = command.schedules ?? (command.schedule ? [command.schedule] : []); + if (schedules.length === 0 && !command.occurrence_overrides?.length) { + return; } + await this.repository.withTransaction(async (repository) => { + for (const raw of schedules) { + const applied = await repository.applyCloudSchedule(toCloudScheduleRow(accountId, raw)); + if (!applied) { + throw new Error(`Could not apply cloud schedule ${String(raw.id)}`); + } + } + for (const override of command.occurrence_overrides ?? []) { + const applied = await repository.upsertOccurrenceOverride( + accountId, + toOverrideRow(override), + ); + if (!applied) { + throw new Error(`Could not apply occurrence override ${override.id}`); + } + } + }); } } diff --git a/frontend/src/features/schedule/data/local/scheduleLocalRepository.ts b/frontend/src/features/schedule/data/local/scheduleLocalRepository.ts index 7d189c2b..5d93c93f 100644 --- a/frontend/src/features/schedule/data/local/scheduleLocalRepository.ts +++ b/frontend/src/features/schedule/data/local/scheduleLocalRepository.ts @@ -89,6 +89,20 @@ export class ScheduleLocalRepository { ); } + /** + * 一批相关写入要么全部生效、要么全部不生效——调用方在 task 里用参数给的 repository + * (绑定在事务连接上)做写入,抛错会让整个事务回滚。 + */ + public async withTransaction( + task: (repository: ScheduleLocalRepository) => Promise, + ): Promise { + let result!: T; + await this.database.withExclusiveTransactionAsync(async (transaction) => { + result = await task(new ScheduleLocalRepository(transaction)); + }); + return result; + } + /** Apply cloud-owned fields while preserving existing device runtime state. */ public async applyCloudSchedule(row: CloudScheduleRow): Promise { const result = await this.database.runAsync( diff --git a/frontend/tests/integration/localScheduleWriter.test.ts b/frontend/tests/integration/localScheduleWriter.test.ts index e3ac6e10..4e07dd58 100644 --- a/frontend/tests/integration/localScheduleWriter.test.ts +++ b/frontend/tests/integration/localScheduleWriter.test.ts @@ -113,4 +113,57 @@ describe('LocalScheduleWriter', () => { const stored = await repository.getSchedule('account-a', 'schedule-a'); expect(stored).toBeNull(); }); + + it('writes every schedule when the command carries the plural field', async () => { + const writer = new LocalScheduleWriter(repository); + await writer.applyCommandResult( + 'account-a', + appliedCommand({ + schedule: undefined, + schedules: [ + appliedCommand().schedule!, + { ...appliedCommand().schedule!, id: 'schedule-b', title: 'Follow-up' }, + ], + }), + ); + + expect((await repository.getSchedule('account-a', 'schedule-a'))?.title).toBe('Team sync'); + expect((await repository.getSchedule('account-a', 'schedule-b'))?.title).toBe('Follow-up'); + }); + + it('does not write a list_schedules query result even if it carries schedules', async () => { + const writer = new LocalScheduleWriter(repository); + await writer.applyCommandResult( + 'account-a', + appliedCommand({ + operation: 'list_schedules', + schedule: undefined, + schedules: [appliedCommand().schedule!], + }), + ); + + expect(await repository.getSchedule('account-a', 'schedule-a')).toBeNull(); + }); + + it('rolls back the schedule write when a later override write fails', async () => { + const writer = new LocalScheduleWriter(repository); + const command = appliedCommand({ + occurrence_overrides: [ + { + id: 'override-a', + // upsertOccurrenceOverride 找不到这个 schedule_id 对应的本地日程,返回 false。 + schedule_id: 'schedule-does-not-exist', + occurrence_start: '2026-08-19T07:00:00Z', + action: 'cancel', + replacement_schedule_id: null, + }, + ], + }); + + await expect(writer.applyCommandResult('account-a', command)).rejects.toThrow( + 'Could not apply occurrence override override-a', + ); + // schedule 那一半本来会写成功,但事务应该把它也回滚掉,不留半吊子状态。 + expect(await repository.getSchedule('account-a', 'schedule-a')).toBeNull(); + }); }); diff --git a/frontend/tests/scheduleStorageContracts.test-d.ts b/frontend/tests/scheduleStorageContracts.test-d.ts index d4f8d042..ef288104 100644 --- a/frontend/tests/scheduleStorageContracts.test-d.ts +++ b/frontend/tests/scheduleStorageContracts.test-d.ts @@ -106,6 +106,7 @@ export type LocalRepositoryOperationsContract = Assert< keyof ScheduleLocalRepository, | 'getSchedule' | 'listSchedules' + | 'withTransaction' | 'applyCloudSchedule' | 'updateReminderRuntime' | 'purgeSchedule'