perceptkit 0.2.7__tar.gz → 0.3.0__tar.gz

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.
Files changed (114) hide show
  1. {perceptkit-0.2.7 → perceptkit-0.3.0}/CHANGELOG.md +70 -0
  2. {perceptkit-0.2.7 → perceptkit-0.3.0}/PKG-INFO +1 -1
  3. {perceptkit-0.2.7 → perceptkit-0.3.0}/pyproject.toml +1 -1
  4. {perceptkit-0.2.7 → perceptkit-0.3.0}/src/perceptkit/__init__.py +5 -0
  5. {perceptkit-0.2.7 → perceptkit-0.3.0}/src/perceptkit/conformance/memory.py +22 -5
  6. {perceptkit-0.2.7 → perceptkit-0.3.0}/src/perceptkit/conformance/suite.py +7 -7
  7. {perceptkit-0.2.7 → perceptkit-0.3.0}/src/perceptkit/contracts/records.py +11 -2
  8. {perceptkit-0.2.7 → perceptkit-0.3.0}/src/perceptkit/kit.py +18 -0
  9. {perceptkit-0.2.7 → perceptkit-0.3.0}/src/perceptkit/ports/storage.py +21 -0
  10. perceptkit-0.3.0/src/perceptkit/processing/source_sync.py +299 -0
  11. {perceptkit-0.2.7 → perceptkit-0.3.0}/src/perceptkit/retention.py +62 -9
  12. {perceptkit-0.2.7 → perceptkit-0.3.0}/tests/test_export.py +2 -2
  13. {perceptkit-0.2.7 → perceptkit-0.3.0}/tests/test_queries.py +2 -2
  14. {perceptkit-0.2.7 → perceptkit-0.3.0}/tests/test_recurrence.py +1 -1
  15. {perceptkit-0.2.7 → perceptkit-0.3.0}/tests/test_regressions.py +2 -2
  16. perceptkit-0.3.0/tests/test_retention.py +132 -0
  17. perceptkit-0.3.0/tests/test_retention_single_truth.py +80 -0
  18. {perceptkit-0.2.7 → perceptkit-0.3.0}/tests/test_source_mirror.py +4 -4
  19. perceptkit-0.3.0/tests/test_source_sync.py +385 -0
  20. {perceptkit-0.2.7 → perceptkit-0.3.0}/uv.lock +1 -1
  21. perceptkit-0.2.7/tests/test_retention.py +0 -97
  22. {perceptkit-0.2.7 → perceptkit-0.3.0}/.github/workflows/ci.yml +0 -0
  23. {perceptkit-0.2.7 → perceptkit-0.3.0}/.github/workflows/release.yml +0 -0
  24. {perceptkit-0.2.7 → perceptkit-0.3.0}/.gitignore +0 -0
  25. {perceptkit-0.2.7 → perceptkit-0.3.0}/LICENSE +0 -0
  26. {perceptkit-0.2.7 → perceptkit-0.3.0}/NOTES-packaging.md +0 -0
  27. {perceptkit-0.2.7 → perceptkit-0.3.0}/NOTES-quickstart.md +0 -0
  28. {perceptkit-0.2.7 → perceptkit-0.3.0}/README.md +0 -0
  29. {perceptkit-0.2.7 → perceptkit-0.3.0}/docs/PerceptKit-/344/272/247/345/223/201/347/233/256/346/240/207/344/270/216/345/275/223/345/211/215/345/256/236/347/216/260/345/267/256/350/267/235/345/217/215/351/246/210.md" +0 -0
  30. {perceptkit-0.2.7 → perceptkit-0.3.0}/docs/PerceptKit-/346/204/237/347/237/245/345/255/227/346/256/265/344/270/216/345/255/230/345/202/250-/345/267/245/347/250/213/345/257/271/351/275/220/350/241/245/345/205/205/350/256/250/350/256/272.md" +0 -0
  31. {perceptkit-0.2.7 → perceptkit-0.3.0}/docs/reference-storage-mapping.md +0 -0
  32. {perceptkit-0.2.7 → perceptkit-0.3.0}/examples/end_to_end.py +0 -0
  33. {perceptkit-0.2.7 → perceptkit-0.3.0}/examples/ios_adapter.py +0 -0
  34. {perceptkit-0.2.7 → perceptkit-0.3.0}/examples/quickstart.py +0 -0
  35. {perceptkit-0.2.7 → perceptkit-0.3.0}/src/perceptkit/algorithms/__init__.py +0 -0
  36. {perceptkit-0.2.7 → perceptkit-0.3.0}/src/perceptkit/algorithms/attribution.py +0 -0
  37. {perceptkit-0.2.7 → perceptkit-0.3.0}/src/perceptkit/algorithms/glance.py +0 -0
  38. {perceptkit-0.2.7 → perceptkit-0.3.0}/src/perceptkit/algorithms/history.py +0 -0
  39. {perceptkit-0.2.7 → perceptkit-0.3.0}/src/perceptkit/algorithms/identity.py +0 -0
  40. {perceptkit-0.2.7 → perceptkit-0.3.0}/src/perceptkit/algorithms/observation.py +0 -0
  41. {perceptkit-0.2.7 → perceptkit-0.3.0}/src/perceptkit/algorithms/streaks.py +0 -0
  42. {perceptkit-0.2.7 → perceptkit-0.3.0}/src/perceptkit/algorithms/trend_models.py +0 -0
  43. {perceptkit-0.2.7 → perceptkit-0.3.0}/src/perceptkit/algorithms/wake.py +0 -0
  44. {perceptkit-0.2.7 → perceptkit-0.3.0}/src/perceptkit/catalog.py +0 -0
  45. {perceptkit-0.2.7 → perceptkit-0.3.0}/src/perceptkit/conformance/__init__.py +0 -0
  46. {perceptkit-0.2.7 → perceptkit-0.3.0}/src/perceptkit/conformance/report.py +0 -0
  47. {perceptkit-0.2.7 → perceptkit-0.3.0}/src/perceptkit/conformance/wake.py +0 -0
  48. {perceptkit-0.2.7 → perceptkit-0.3.0}/src/perceptkit/contracts/__init__.py +0 -0
  49. {perceptkit-0.2.7 → perceptkit-0.3.0}/src/perceptkit/contracts/_time.py +0 -0
  50. {perceptkit-0.2.7 → perceptkit-0.3.0}/src/perceptkit/contracts/availability.py +0 -0
  51. {perceptkit-0.2.7 → perceptkit-0.3.0}/src/perceptkit/contracts/context.py +0 -0
  52. {perceptkit-0.2.7 → perceptkit-0.3.0}/src/perceptkit/contracts/delivery.py +0 -0
  53. {perceptkit-0.2.7 → perceptkit-0.3.0}/src/perceptkit/contracts/errors.py +0 -0
  54. {perceptkit-0.2.7 → perceptkit-0.3.0}/src/perceptkit/contracts/event.py +0 -0
  55. {perceptkit-0.2.7 → perceptkit-0.3.0}/src/perceptkit/contracts/observation.py +0 -0
  56. {perceptkit-0.2.7 → perceptkit-0.3.0}/src/perceptkit/contracts/receipt.py +0 -0
  57. {perceptkit-0.2.7 → perceptkit-0.3.0}/src/perceptkit/contracts/report.py +0 -0
  58. {perceptkit-0.2.7 → perceptkit-0.3.0}/src/perceptkit/contracts/versioning.py +0 -0
  59. {perceptkit-0.2.7 → perceptkit-0.3.0}/src/perceptkit/fields.py +0 -0
  60. {perceptkit-0.2.7 → perceptkit-0.3.0}/src/perceptkit/manifest/__init__.py +0 -0
  61. {perceptkit-0.2.7 → perceptkit-0.3.0}/src/perceptkit/manifest/checks.py +0 -0
  62. {perceptkit-0.2.7 → perceptkit-0.3.0}/src/perceptkit/manifest/mapping.py +0 -0
  63. {perceptkit-0.2.7 → perceptkit-0.3.0}/src/perceptkit/manifest/minimal.py +0 -0
  64. {perceptkit-0.2.7 → perceptkit-0.3.0}/src/perceptkit/manifest/types.py +0 -0
  65. {perceptkit-0.2.7 → perceptkit-0.3.0}/src/perceptkit/manifest/units.py +0 -0
  66. {perceptkit-0.2.7 → perceptkit-0.3.0}/src/perceptkit/ports/__init__.py +0 -0
  67. {perceptkit-0.2.7 → perceptkit-0.3.0}/src/perceptkit/ports/wake.py +0 -0
  68. {perceptkit-0.2.7 → perceptkit-0.3.0}/src/perceptkit/processing/__init__.py +0 -0
  69. {perceptkit-0.2.7 → perceptkit-0.3.0}/src/perceptkit/processing/aggregate.py +0 -0
  70. {perceptkit-0.2.7 → perceptkit-0.3.0}/src/perceptkit/processing/dispatch.py +0 -0
  71. {perceptkit-0.2.7 → perceptkit-0.3.0}/src/perceptkit/processing/normalize.py +0 -0
  72. {perceptkit-0.2.7 → perceptkit-0.3.0}/src/perceptkit/processing/pipeline.py +0 -0
  73. {perceptkit-0.2.7 → perceptkit-0.3.0}/src/perceptkit/processing/recompute.py +0 -0
  74. {perceptkit-0.2.7 → perceptkit-0.3.0}/src/perceptkit/processing/recurrence.py +0 -0
  75. {perceptkit-0.2.7 → perceptkit-0.3.0}/src/perceptkit/processing/scheduled.py +0 -0
  76. {perceptkit-0.2.7 → perceptkit-0.3.0}/src/perceptkit/prompts.py +0 -0
  77. {perceptkit-0.2.7 → perceptkit-0.3.0}/src/perceptkit/queries/__init__.py +0 -0
  78. {perceptkit-0.2.7 → perceptkit-0.3.0}/src/perceptkit/queries/api.py +0 -0
  79. {perceptkit-0.2.7 → perceptkit-0.3.0}/src/perceptkit/rules/__init__.py +0 -0
  80. {perceptkit-0.2.7 → perceptkit-0.3.0}/src/perceptkit/rules/engine.py +0 -0
  81. {perceptkit-0.2.7 → perceptkit-0.3.0}/src/perceptkit/rules/evaluators.py +0 -0
  82. {perceptkit-0.2.7 → perceptkit-0.3.0}/src/perceptkit/rules/types.py +0 -0
  83. {perceptkit-0.2.7 → perceptkit-0.3.0}/tests/fixtures/README.md +0 -0
  84. {perceptkit-0.2.7 → perceptkit-0.3.0}/tests/fixtures/ios_snapshot_no_data.json +0 -0
  85. {perceptkit-0.2.7 → perceptkit-0.3.0}/tests/fixtures/ios_snapshot_normal.json +0 -0
  86. {perceptkit-0.2.7 → perceptkit-0.3.0}/tests/fixtures/ios_snapshot_unauthorized.json +0 -0
  87. {perceptkit-0.2.7 → perceptkit-0.3.0}/tests/test_attribution.py +0 -0
  88. {perceptkit-0.2.7 → perceptkit-0.3.0}/tests/test_catalog.py +0 -0
  89. {perceptkit-0.2.7 → perceptkit-0.3.0}/tests/test_conformance.py +0 -0
  90. {perceptkit-0.2.7 → perceptkit-0.3.0}/tests/test_conformance_wake_report.py +0 -0
  91. {perceptkit-0.2.7 → perceptkit-0.3.0}/tests/test_contracts.py +0 -0
  92. {perceptkit-0.2.7 → perceptkit-0.3.0}/tests/test_delivery_and_records.py +0 -0
  93. {perceptkit-0.2.7 → perceptkit-0.3.0}/tests/test_docs_match_code.py +0 -0
  94. {perceptkit-0.2.7 → perceptkit-0.3.0}/tests/test_edge_cases.py +0 -0
  95. {perceptkit-0.2.7 → perceptkit-0.3.0}/tests/test_end_to_end.py +0 -0
  96. {perceptkit-0.2.7 → perceptkit-0.3.0}/tests/test_event_envelope.py +0 -0
  97. {perceptkit-0.2.7 → perceptkit-0.3.0}/tests/test_examples.py +0 -0
  98. {perceptkit-0.2.7 → perceptkit-0.3.0}/tests/test_identity.py +0 -0
  99. {perceptkit-0.2.7 → perceptkit-0.3.0}/tests/test_ios_fixture.py +0 -0
  100. {perceptkit-0.2.7 → perceptkit-0.3.0}/tests/test_isolation.py +0 -0
  101. {perceptkit-0.2.7 → perceptkit-0.3.0}/tests/test_manifest.py +0 -0
  102. {perceptkit-0.2.7 → perceptkit-0.3.0}/tests/test_no_host_leakage.py +0 -0
  103. {perceptkit-0.2.7 → perceptkit-0.3.0}/tests/test_observation.py +0 -0
  104. {perceptkit-0.2.7 → perceptkit-0.3.0}/tests/test_pipeline.py +0 -0
  105. {perceptkit-0.2.7 → perceptkit-0.3.0}/tests/test_projection.py +0 -0
  106. {perceptkit-0.2.7 → perceptkit-0.3.0}/tests/test_purity.py +0 -0
  107. {perceptkit-0.2.7 → perceptkit-0.3.0}/tests/test_recompute.py +0 -0
  108. {perceptkit-0.2.7 → perceptkit-0.3.0}/tests/test_retention_entry.py +0 -0
  109. {perceptkit-0.2.7 → perceptkit-0.3.0}/tests/test_revision_recompute.py +0 -0
  110. {perceptkit-0.2.7 → perceptkit-0.3.0}/tests/test_rules.py +0 -0
  111. {perceptkit-0.2.7 → perceptkit-0.3.0}/tests/test_scheduled.py +0 -0
  112. {perceptkit-0.2.7 → perceptkit-0.3.0}/tests/test_streaks.py +0 -0
  113. {perceptkit-0.2.7 → perceptkit-0.3.0}/tests/test_trend_models.py +0 -0
  114. {perceptkit-0.2.7 → perceptkit-0.3.0}/tests/test_wake.py +0 -0
@@ -1,5 +1,75 @@
1
1
  # 变更记录
2
2
 
3
+ ## 0.3.0 — 2026-09-03
4
+
5
+ 外部审查(2026-09-03)里**不需要产品拍板的那批契约漏洞**。其中三条是审查者
6
+ 自己复现的,一条是我们没发现的双重真相。
7
+
8
+ ### ⚠️ 破坏性变更
9
+
10
+ - **``CalendarEventMirror`` / ``ReminderItemMirror`` 新增必填字段 ``source``。**
11
+ 它是唯一身份的一部分,不是标签 —— 少了它,一次 ``source="ios"`` 的全量同步
12
+ 会把概念上属于 Google 的日程一起删掉(快照收尾删的是"这轮没见到的",
13
+ 而另一个来源的条目当然没在这轮里)。用户会发现自己另一个日历账户的日程
14
+ 凭空消失,且不可逆。
15
+ 走 ``sync_source_mirror`` 的调用方不用改:kit 会用这一批声明的 ``source``
16
+ 盖上去,和 ``sync_id`` 同一个道理。
17
+ - **``StoragePort`` 新增 ``delete_source_items``**(见下)。
18
+
19
+ ### 修复
20
+
21
+ - **增量同步执行来源明确的删除(§8.1)。** 早先把增量定义成"一条都不许删",
22
+ 防住了"拿局部列表当全量",但同时堵死了另一条:来源的 change feed 明确
23
+ 传来一条删除时,那是**确定的事实**,不是推断。结果是用户在手机上删掉的
24
+ 日程,在 agent 眼里永远还在,还会一直出现在"接下来有什么安排"里。
25
+ ``SyncBatch.deleted_item_ids`` + ``StoragePort.delete_source_items``;
26
+ 和全量收尾的删除**分开计数**,否则分不清某次异常删除是范围判断出错
27
+ 还是来源真的删了。
28
+ - **来源进入镜像唯一身份(§8.2)。** 见上面的破坏性变更。
29
+ - **``collection_kind`` 与条目类型交叉校验(§8.3)。**
30
+ ``collection_kind="reminders"`` 配一批日历条目原本会被照单全收:日历表
31
+ 被写进去了,而**提醒的游标往前推进了** —— 数据和游标从此互相矛盾,
32
+ 下一轮增量提醒同步会以为上一轮成功了,那段提醒永远补不回来。
33
+ - **镜像条目的 subject 一律用可信上下文覆盖(§8.4)。** 条目是宿主从来源
34
+ 数据翻译出来的,它自带的 ``subject_id`` 最好的情况是冗余、最坏的情况是
35
+ 把 A 的日程写进 B 的花园。可信 subject 只有一个来源:``IngestContext``。
36
+ - **``retention_days()`` / ``stores_history()`` 改从 manifest 查(§9)。**
37
+ 它们原本读本模块顶上那张旧表,和 manifest **七条全对不上**:
38
+ ``focus_state`` 抛 KeyError(manifest 说 365)、``audio_route`` 返回 90
39
+ (manifest 说 7)。接入方和工程 AI 调公开 API 会拿到错的结论,而且没有
40
+ 任何地方报错 —— 两个真相各自自洽,只是不一样。
41
+ 旧名(``focus`` / ``playback`` / ``location_signal``)作为显式的、
42
+ 受测试的别名保留;那张旧表降级成历史记录,不再是任何查询的依据。
43
+
44
+ 🔴 ``retention_days()`` 对不进历史表的信号**仍然抛 ``KeyError``,
45
+ 不返回 ``None``** —— 这条早先的决策改用 manifest 之后理由更硬了:
46
+ 旧词表里 ``None`` 是「永久保存」,静默返回 ``None`` 会让照旧词表理解的
47
+ 调用方把「根本不存历史」读成「永久保留」,两个意思正好相反。
48
+
49
+ ## 0.2.8 — 2026-09-02
50
+
51
+ - **来源镜像的同步终于有了和 ``ingest()`` 对等的入口(P0-2)。**
52
+ 写入侧原本只有四个原语,于是每个宿主自己拼「收数据 → upsert → 写同步状态
53
+ → 全量按范围删 → 处理失败」这一串。而这条路上**每个坑错了都不报错,
54
+ 且大多不可逆**:
55
+
56
+ 增量当全量删 用户发现自己去年的日程凭空消失了,系统一切正常
57
+ 失败还推进游标 那段数据永远不会再被同步一次,也没人记得它缺过
58
+ 失败还删条目 来源临时不可达被当成"来源侧删光了"
59
+ 删了但没提交 镜像少一批、状态说成功,下轮增量不会补
60
+ 没有覆盖范围就删 "全量"是相对某个范围说的,没范围就是删全部
61
+ 条目没打这轮的 id 刚写进去的被自己的快照收尾删掉,一次"成功"的
62
+ 全量同步把镜像清空了(写这个模块时真踩到的)
63
+
64
+ ``SyncBatch`` 的字段就是审查里列的那份契约(source / collection_kind /
65
+ sync_id / snapshot_kind / coverage_start / coverage_end / cursor / items /
66
+ attempted_at / completed_at / error_code)。
67
+ ``PerceptionKit.sync_source_mirror(batch, context=...)`` 编排 upsert、
68
+ 范围内删除和同步状态,三个动作在同一个事务里。
69
+
70
+ **不解析来源格式** —— 苹果日历、Google、Exchange 的条目长得完全不一样,
71
+ 翻译成标准镜像记录仍然是宿主的活。
72
+
3
73
  ## 0.2.7 — 2026-09-02
4
74
 
5
75
  - **跳过原因带上稳定的 `code`(`SkippedSignal`)。** 0.2.6 的清理计划里,
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: perceptkit
3
- Version: 0.2.7
3
+ Version: 0.3.0
4
4
  Summary: 从设备信号判断:有没有发生值得留意的事、值不值得叫醒一次 agent、以及该怎么把此刻的状况讲给它听。不采集数据、不选数据库、不调模型 —— 存储由宿主实现 StoragePort,编排在包内。
5
5
  License:
6
6
  Apache License
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "perceptkit"
7
- version = "0.2.7"
7
+ version = "0.3.0"
8
8
  description = "从设备信号判断:有没有发生值得留意的事、值不值得叫醒一次 agent、以及该怎么把此刻的状况讲给它听。不采集数据、不选数据库、不调模型 —— 存储由宿主实现 StoragePort,编排在包内。"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.10"
@@ -33,6 +33,9 @@ from .algorithms.attribution import attribute_episode, attribute_instant, split_
33
33
  from .catalog import CAPABILITIES, SIGNALS
34
34
  from .contracts import IngestContext, Observation, PerceptionEvent, ReportEnvelope, WakeReceipt
35
35
  from .kit import PerceptionKit
36
+ from .processing.source_sync import (
37
+ FULL, INCREMENTAL, SyncBatch, SyncContractError, SyncOutcome,
38
+ )
36
39
  from .fields import AGENT_PERCEPTION_SIGNALS, project_signal
37
40
  from .algorithms.glance import build_perception_glance
38
41
  from .algorithms.history import is_historized
@@ -53,6 +56,8 @@ from .algorithms.wake import is_wake_worthy_signal, is_significant_change, shoul
53
56
  __all__ = [
54
57
  # 接入口
55
58
  "PerceptionKit",
59
+ "SyncBatch", "SyncOutcome", "SyncContractError",
60
+ "FULL", "INCREMENTAL",
56
61
  "ReportEnvelope", "Observation", "PerceptionEvent", "WakeReceipt", "IngestContext",
57
62
  "contracts", "manifest", "ports", "processing", "rules",
58
63
  # 算法
@@ -196,14 +196,16 @@ class InMemoryStorage:
196
196
  self.sync_state[(state.subject_id, state.source, state.collection_kind)] = state
197
197
 
198
198
  def upsert_calendar_events(self, *, subject_id, events) -> None:
199
+ # 键里带 source —— 少了它,两个来源系统里碰巧同 id 的日程会互相覆盖,
200
+ # 而全量同步还会把另一个来源的条目当成"这轮没见到"删掉。
199
201
  for e in events:
200
- self.calendar[(subject_id, e.source_account_id, e.source_calendar_id,
201
- e.source_event_id)] = e
202
+ self.calendar[(subject_id, e.source, e.source_account_id,
203
+ e.source_calendar_id, e.source_event_id)] = e
202
204
 
203
205
  def upsert_reminders(self, *, subject_id, items) -> None:
204
206
  for r in items:
205
- self.reminders[(subject_id, r.source_account_id, r.source_list_id,
206
- r.source_reminder_id)] = r
207
+ self.reminders[(subject_id, r.source, r.source_account_id,
208
+ r.source_list_id, r.source_reminder_id)] = r
207
209
 
208
210
  def list_calendar_events(self, *, subject_id, start=None, end=None,
209
211
  limit=50, offset=0):
@@ -235,6 +237,17 @@ class InMemoryStorage:
235
237
  i.source_reminder_id))
236
238
  return keep[offset:offset + limit]
237
239
 
240
+ def delete_source_items(self, *, subject_id, source, collection_kind,
241
+ source_item_ids) -> int:
242
+ store = self.calendar if collection_kind == "calendar" else self.reminders
243
+ wanted = set(source_item_ids)
244
+ # key = (subject, source, account, collection, item_id) —— 最后一位是 id。
245
+ doomed = [k for k in store
246
+ if k[0] == subject_id and k[1] == source and k[-1] in wanted]
247
+ for k in doomed:
248
+ del store[k]
249
+ return len(doomed)
250
+
238
251
  def apply_source_snapshot(self, *, subject_id, source, collection_kind, sync_id,
239
252
  coverage_start, coverage_end, snapshot_kind) -> int:
240
253
  # 增量同步没有资格删任何东西 —— 它只知道"变了什么",不知道"还剩什么"。
@@ -243,7 +256,11 @@ class InMemoryStorage:
243
256
  store = self.calendar if collection_kind == "calendar" else self.reminders
244
257
  doomed = []
245
258
  for key, item in store.items():
246
- if key[0] != subject_id or item.last_seen_sync_id == sync_id:
259
+ # 🔴 必须同时限定 subject **和 source**。只按 subject 删的话,
260
+ # 一次 source="ios" 的全量同步会把 Google 日历的条目一起删掉 ——
261
+ # 它们当然没出现在这一轮 ios 的批次里。
262
+ if (key[0] != subject_id or item.source != source
263
+ or item.last_seen_sync_id == sync_id):
247
264
  continue
248
265
  # 🔴 只删【能证明落在覆盖范围内】的。拿局部窗口去删窗口外的数据,
249
266
  # 会让用户发现自己去年的日程凭空消失,而且不可逆。
@@ -228,12 +228,12 @@ def _g8_partial_sync_does_not_delete_outside_its_window(new: StorageFactory) ->
228
228
  problems: list[str] = []
229
229
  s = new()
230
230
  inside = CalendarEventMirror(
231
- subject_id="u1", source_account_id="a", source_calendar_id="c",
231
+ subject_id="u1", source="ios", source_account_id="a", source_calendar_id="c",
232
232
  source_event_id="e_in", event_fields={"start_at": T0},
233
233
  last_seen_sync_id="old",
234
234
  )
235
235
  outside = CalendarEventMirror(
236
- subject_id="u1", source_account_id="a", source_calendar_id="c",
236
+ subject_id="u1", source="ios", source_account_id="a", source_calendar_id="c",
237
237
  source_event_id="e_out", event_fields={"start_at": T0 - timedelta(days=400)},
238
238
  last_seen_sync_id="old",
239
239
  )
@@ -348,7 +348,7 @@ def _g11_both_source_mirrors_round_trip(new: StorageFactory) -> list[str]:
348
348
  problems: list[str] = []
349
349
  s = new()
350
350
  s.upsert_calendar_events(subject_id="u1", events=[CalendarEventMirror(
351
- subject_id="u1", source_account_id="a", source_calendar_id="c",
351
+ subject_id="u1", source="ios", source_account_id="a", source_calendar_id="c",
352
352
  source_event_id="e1", event_fields={"title": "站会", "start_at": T0},
353
353
  )])
354
354
  got = list(s.list_calendar_events(subject_id="u1", limit=10))
@@ -357,7 +357,7 @@ def _g11_both_source_mirrors_round_trip(new: StorageFactory) -> list[str]:
357
357
 
358
358
  s2 = new()
359
359
  s2.upsert_reminders(subject_id="u1", items=[ReminderItemMirror(
360
- subject_id="u1", source_account_id="a", source_list_id="l",
360
+ subject_id="u1", source="ios", source_account_id="a", source_list_id="l",
361
361
  source_reminder_id="r1", reminder_fields={"title": "买牛奶",
362
362
  "is_completed": False},
363
363
  )])
@@ -368,7 +368,7 @@ def _g11_both_source_mirrors_round_trip(new: StorageFactory) -> list[str]:
368
368
  )
369
369
  # 已完成的默认不出现,除非明说要。
370
370
  s2.upsert_reminders(subject_id="u1", items=[ReminderItemMirror(
371
- subject_id="u1", source_account_id="a", source_list_id="l",
371
+ subject_id="u1", source="ios", source_account_id="a", source_list_id="l",
372
372
  source_reminder_id="r2", reminder_fields={"title": "交房租",
373
373
  "is_completed": True},
374
374
  )])
@@ -447,7 +447,7 @@ def _g12_terminal_events_and_offsets_are_queryable(new: StorageFactory) -> list[
447
447
 
448
448
  s2 = new()
449
449
  s2.upsert_calendar_events(subject_id="u1", events=[CalendarEventMirror(
450
- subject_id="u1", source_account_id="a", source_calendar_id="c",
450
+ subject_id="u1", source="ios", source_account_id="a", source_calendar_id="c",
451
451
  source_event_id=f"e{i}",
452
452
  event_fields={"title": f"e{i}", "start_at": base + timedelta(minutes=i)},
453
453
  ) for i in range(4)])
@@ -460,7 +460,7 @@ def _g12_terminal_events_and_offsets_are_queryable(new: StorageFactory) -> list[
460
460
 
461
461
  s3 = new()
462
462
  s3.upsert_reminders(subject_id="u1", items=[ReminderItemMirror(
463
- subject_id="u1", source_account_id="a", source_list_id="l",
463
+ subject_id="u1", source="ios", source_account_id="a", source_list_id="l",
464
464
  source_reminder_id=f"r{i}",
465
465
  reminder_fields={"title": f"r{i}", "is_completed": False,
466
466
  "due_at": base + timedelta(minutes=i)},
@@ -218,11 +218,18 @@ class CalendarEventMirror:
218
218
  **镜像不是快照历史。** 存的是"来源现在有哪些条目",条目自己带着
219
219
  过去或未来的时间;不存"我们每次同步时看到了什么"。来源删除 → 本地删除。
220
220
 
221
- 唯一身份必须**包含来源账户和日历**:不同账户碰巧用同一个 event id
222
- 是完全可能的。
221
+ 唯一身份必须**包含来源系统、来源账户和日历**:不同账户碰巧用同一个
222
+ event id 是完全可能的,不同来源系统更是必然。
223
223
  """
224
224
 
225
225
  subject_id: str
226
+ #: 哪个来源系统(``ios`` / ``google`` / ``exchange`` …)。
227
+ #:
228
+ #: 🔴 **它是唯一身份的一部分,不是标签。** 少了它,一次
229
+ #: ``source="ios"`` 的全量同步会把概念上属于 Google 的日程一起删掉 ——
230
+ #: 快照收尾删的是「这轮没见到的」,而另一个来源的条目当然没在这轮里。
231
+ #: 用户发现自己另一个日历账户的日程凭空消失了,且不可逆。
232
+ source: str
226
233
  source_account_id: str
227
234
  source_calendar_id: str
228
235
  source_event_id: str
@@ -242,6 +249,8 @@ class ReminderItemMirror:
242
249
  """外部提醒里现在还存在的一条待办。"""
243
250
 
244
251
  subject_id: str
252
+ #: 哪个来源系统。理由同 :class:`CalendarEventMirror.source`。
253
+ source: str
245
254
  source_account_id: str
246
255
  source_list_id: str
247
256
  source_reminder_id: str
@@ -26,6 +26,7 @@ from .ports.wake import WakePort
26
26
  from .processing.dispatch import DispatchOutcome, drain
27
27
  from .processing.pipeline import AGGREGATION_VERSION, IngestOutcome, ingest_report
28
28
  from .processing.recompute import RecomputeOutcome, recompute_range
29
+ from .processing.source_sync import sync_source_mirror
29
30
  from .retention import plan_retention
30
31
  from .processing.scheduled import ScheduledOutcome, evaluate_absence, evaluate_daily
31
32
  from .queries import api as _queries
@@ -143,6 +144,23 @@ class PerceptionKit:
143
144
  now=now, allow_incomplete=allow_incomplete,
144
145
  )
145
146
 
147
+ def sync_source_mirror(self, batch, *, context: IngestContext):
148
+ """把一批日历/提醒的来源数据落进镜像,并推进同步状态。
149
+
150
+ 和 :meth:`ingest` 对等的那个入口 —— 来源镜像走的是完全不同的一条路
151
+ (它存「来源现在有哪些条目」,不是「我们每次看到了什么」),
152
+ 但同样有一串**错了不报错、而且大多不可逆**的规则:增量不许删、
153
+ 全量只在自己声明的范围内删、失败的批次什么都不动也不推进游标、
154
+ 三个动作必须在同一个事务里。
155
+
156
+ 见 ``processing.source_sync`` 的模块文档 —— 每条规则都配了它对应的
157
+ 那个故障长什么样。
158
+
159
+ **不解析来源格式。** 苹果日历、Google、Exchange 的条目长得完全不一样,
160
+ 翻译成标准镜像记录是宿主的活。
161
+ """
162
+ return sync_source_mirror(self.storage, batch, context=context)
163
+
146
164
  def run_retention(
147
165
  self, *, subject_id: str, now: datetime, dry_run: bool = True,
148
166
  ) -> dict[str, Any]:
@@ -215,6 +215,27 @@ class StoragePort(Protocol):
215
215
  """镜像里现在还存在的提醒事项。``offset`` 的要求同上。"""
216
216
  ...
217
217
 
218
+ def delete_source_items(
219
+ self, *, subject_id: str, source: str, collection_kind: str,
220
+ source_item_ids: Sequence[str],
221
+ ) -> int:
222
+ """删掉来源**明确说删了**的那几条,返回删了几条。
223
+
224
+ 和 :meth:`apply_source_snapshot` 是两件事,别合并:
225
+
226
+ 全量收尾 "覆盖范围内、这轮没见到的" —— 推断出来的,所以只有
227
+ 全量有资格,而且必须限定在声明的范围内
228
+ 这个方法 "来源说这条删了" —— 确定的事实,增量也必须执行
229
+
230
+ 没有这个方法的话,增量同步只能选:要么一条都不删(用户在手机上
231
+ 删掉的日程,在 agent 眼里永远还在,还会一直出现在"接下来有什么
232
+ 安排"里),要么拿局部列表当全量删(更糟,且不可逆)。
233
+
234
+ 🔴 ``source`` 是删除范围的一部分。少了它,一次 ``ios`` 的删除会
235
+ 命中另一个来源系统里碰巧同 id 的条目。
236
+ """
237
+ ...
238
+
218
239
  def apply_source_snapshot(
219
240
  self, *, subject_id: str, source: str, collection_kind: str,
220
241
  sync_id: str, coverage_start: datetime, coverage_end: datetime,
@@ -0,0 +1,299 @@
1
+ """来源镜像的同步编排 —— 日历和提醒走的那条路。
2
+
3
+ 写入侧原本只有四个原语(``upsert_calendar_events`` / ``upsert_reminders`` /
4
+ ``apply_source_snapshot`` / ``put_sync_state``),没有和 ``ingest()`` 对等的
5
+ 公开入口。于是每个宿主自己拼:
6
+
7
+ 收到来源数据 → 解析身份 → upsert → 写同步状态 → 全量时按范围删 → 处理失败
8
+
9
+ **这条路上的每个坑,错了都不报错,而且大多不可逆。** 那正是它该收进 kit 的理由:
10
+
11
+ 增量当全量删 拿一个局部窗口去删窗口外的数据 —— 用户发现自己去年的
12
+ 日程凭空消失了,而系统一切正常
13
+ 失败还推进游标 这一批没拿到,游标却往前走了 —— 那段数据**永远**不会
14
+ 再被同步一次,而且没有任何地方记得它缺过
15
+ 失败还删条目 来源临时不可达被当成"来源侧删光了"
16
+ 删了但没提交 镜像里少一批条目、同步状态却说这轮成功了;
17
+ 下一轮增量同步不会补,因为它以为上一轮是完整的
18
+ 没有覆盖范围就删 "全量"是相对于某个范围说的。没有范围的全量删除,
19
+ 删的是这个用户在这个来源下的**全部**条目
20
+
21
+ 所以这个模块只做一件事:把这几条规则变成一条路,而不是六个宿主各写一遍。
22
+
23
+ **它不解析来源格式。** 苹果日历、Google、Exchange 的条目长得完全不一样,
24
+ 翻译成 ``CalendarEventMirror`` / ``ReminderItemMirror`` 是宿主的活 ——
25
+ kit 只管拿到标准条目之后的顺序和边界。
26
+ """
27
+ from __future__ import annotations
28
+
29
+ from dataclasses import dataclass, field, replace
30
+ from datetime import datetime
31
+ from typing import Any, Sequence
32
+
33
+ from ..contracts.context import IngestContext
34
+ from ..contracts.records import (
35
+ CalendarEventMirror,
36
+ ReminderItemMirror,
37
+ SourceSyncState,
38
+ )
39
+ from ..ports.storage import StoragePort
40
+
41
+ #: 全量:这一批覆盖范围内的条目就是来源的全部,范围内没见到的可以删。
42
+ FULL = "full"
43
+ #: 增量:只带了变化的部分,**一条都不许删**。
44
+ INCREMENTAL = "incremental"
45
+
46
+ CALENDAR = "calendar"
47
+ REMINDERS = "reminders"
48
+
49
+ #: 集合种类 -> 它唯一接受的条目类型。
50
+ _ITEM_TYPE = {CALENDAR: CalendarEventMirror, REMINDERS: ReminderItemMirror}
51
+
52
+
53
+ class SyncContractError(ValueError):
54
+ """这一批的声明本身自相矛盾,处理它会造成不可逆的损失。
55
+
56
+ 刻意抛异常而不是返回一个"部分成功" —— 这几种情况下继续做的代价是删掉
57
+ 用户的真实数据,而那没有"部分"可言。
58
+ """
59
+
60
+
61
+ @dataclass
62
+ class SyncBatch:
63
+ """一批来源镜像数据,以及它自己声明的边界。
64
+
65
+ ``coverage_start`` / ``coverage_end`` 对 ``full`` 是**必填**:
66
+ "全量"永远是相对于某个范围说的,没有范围的全量删除删的是全部。
67
+ """
68
+
69
+ source: str
70
+ collection_kind: str
71
+ sync_id: str
72
+ snapshot_kind: str = INCREMENTAL
73
+ items: Sequence[Any] = field(default_factory=tuple)
74
+ coverage_start: datetime | None = None
75
+ coverage_end: datetime | None = None
76
+ cursor: str | None = None
77
+ attempted_at: datetime | None = None
78
+ completed_at: datetime | None = None
79
+ #: 来源**明确说**被删掉的条目身份。见 ``sync_source_mirror`` 的文档。
80
+ #:
81
+ #: 和「这一批里没出现」是两回事:没出现推断不出删除(增量只知道变了什么,
82
+ #: 不知道还剩什么),但来源的 change feed 明确传来一条删除时,
83
+ #: 那是确定的事实,必须执行 —— 否则用户在手机上删掉的日程,
84
+ #: 在 agent 眼里永远还在。
85
+ deleted_item_ids: Sequence[str] = field(default_factory=tuple)
86
+ #: 非空 = 这一批**没有成功拿到**。见 ``SyncOutcome`` 的文档。
87
+ error_code: str | None = None
88
+
89
+
90
+ @dataclass
91
+ class SyncOutcome:
92
+ """这一轮做了什么。``failed`` 时 ``upserted`` / ``deleted`` 一定是 0。"""
93
+
94
+ upserted: int = 0
95
+ #: 全量收尾按范围删掉的条数。
96
+ deleted: int = 0
97
+ #: 按来源明确的 tombstone 删掉的条数。和上面一个分开数 —— 一个是
98
+ #: "这轮没见到所以删",一个是"来源说删了",混在一起就分不清
99
+ #: 某次异常删除是范围判断出错还是来源真的删了。
100
+ tombstoned: int = 0
101
+ failed: bool = False
102
+ #: 记下来的错误码,原样来自这一批。
103
+ error_code: str | None = None
104
+ #: 游标推进到哪了。失败时**保持原值**,不是 None —— 置空等于让下一轮
105
+ #: 从头拉一遍,而那对一个只是临时不可达的来源是纯粹的浪费。
106
+ cursor: str | None = None
107
+
108
+
109
+ def _existing_cursor(storage: StoragePort, ctx: IngestContext,
110
+ batch: SyncBatch) -> tuple[str | None, datetime | None]:
111
+ prior = storage.get_sync_state(
112
+ subject_id=ctx.subject_id, source=batch.source,
113
+ collection_kind=batch.collection_kind,
114
+ )
115
+ if prior is None:
116
+ return None, None
117
+ return prior.sync_cursor, prior.last_successful_sync_at
118
+
119
+
120
+ def sync_source_mirror(
121
+ storage: StoragePort, batch: SyncBatch, *, context: IngestContext,
122
+ ) -> SyncOutcome:
123
+ """把一批来源镜像数据落进去,并把同步状态推到该有的位置。
124
+
125
+ 顺序是固定的,而且**整个包在一个事务里**:写条目、按范围删、写同步状态
126
+ 要么全都成立、要么全都不成立。分开提交的话,"镜像里少一批条目、
127
+ 同步状态却说这轮成功了"就会出现,而下一轮增量同步不会去补 ——
128
+ 它以为上一轮是完整的。
129
+ """
130
+ kind = batch.snapshot_kind or INCREMENTAL
131
+ if kind not in (FULL, INCREMENTAL):
132
+ raise SyncContractError(
133
+ f"snapshot_kind={kind!r}:只有 {FULL!r} 和 {INCREMENTAL!r} 两种。"
134
+ f"不认识的种类不能当成增量放过去 —— 万一它的本意是全量,"
135
+ f"该删的没删,镜像会一直留着来源已经删掉的条目"
136
+ )
137
+ if kind == FULL and batch.error_code is None and (
138
+ batch.coverage_start is None or batch.coverage_end is None):
139
+ raise SyncContractError(
140
+ "全量同步必须给 coverage_start 和 coverage_end。"
141
+ "「全量」永远是相对于某个范围说的 —— 没有范围的全量删除,"
142
+ "删的是这个用户在这个来源下的全部条目,且不可逆"
143
+ )
144
+ if (batch.coverage_start is not None and batch.coverage_end is not None
145
+ and batch.coverage_start > batch.coverage_end):
146
+ raise SyncContractError(
147
+ f"coverage_start({batch.coverage_start}) 晚于 "
148
+ f"coverage_end({batch.coverage_end}):这个范围是空的,"
149
+ f"照它删会删掉范围外的一切或者什么都不删,两种都不该猜"
150
+ )
151
+
152
+ prior_cursor, prior_ok_at = _existing_cursor(storage, context, batch)
153
+ now = batch.completed_at or batch.attempted_at or context.received_at
154
+
155
+ # ── 失败的一批:记下来,但**什么都不动** ──────────────────────────
156
+ #
157
+ # 不 upsert(这一批的内容不可信)、不删(来源临时不可达不等于来源侧删光了)、
158
+ # 不推进游标(推进了那段数据就永远不会被再同步一次,而且没有任何地方
159
+ # 记得它缺过)、不动 last_successful_sync_at(否则"日历数据已过期"
160
+ # 这个判断永远为假)。
161
+ if batch.error_code is not None:
162
+ with storage.transaction():
163
+ storage.put_sync_state(SourceSyncState(
164
+ subject_id=context.subject_id,
165
+ source=batch.source,
166
+ collection_kind=batch.collection_kind,
167
+ sync_cursor=prior_cursor,
168
+ coverage_start=batch.coverage_start,
169
+ coverage_end=batch.coverage_end,
170
+ snapshot_kind=kind,
171
+ last_attempted_at=batch.attempted_at or now,
172
+ last_successful_sync_at=prior_ok_at,
173
+ last_error_code=batch.error_code,
174
+ ))
175
+ return SyncOutcome(failed=True, error_code=batch.error_code,
176
+ cursor=prior_cursor)
177
+
178
+ with storage.transaction():
179
+ upserted = _upsert(storage, batch, context)
180
+ # 来源明确的删除,全量和增量都执行 —— 它不是推断出来的。
181
+ tombstoned = _apply_tombstones(storage, batch, context)
182
+ deleted = 0
183
+ if kind == FULL:
184
+ # 只有全量才有资格删,而且只在它自己声明的范围内。
185
+ deleted = int(storage.apply_source_snapshot(
186
+ subject_id=context.subject_id,
187
+ source=batch.source,
188
+ collection_kind=batch.collection_kind,
189
+ sync_id=batch.sync_id,
190
+ coverage_start=batch.coverage_start,
191
+ coverage_end=batch.coverage_end,
192
+ snapshot_kind=kind,
193
+ ) or 0)
194
+ storage.put_sync_state(SourceSyncState(
195
+ subject_id=context.subject_id,
196
+ source=batch.source,
197
+ collection_kind=batch.collection_kind,
198
+ sync_cursor=batch.cursor if batch.cursor is not None else prior_cursor,
199
+ coverage_start=batch.coverage_start,
200
+ coverage_end=batch.coverage_end,
201
+ snapshot_kind=kind,
202
+ last_attempted_at=batch.attempted_at or now,
203
+ last_successful_sync_at=now,
204
+ # 成功了就把上一次的错误清掉 —— 留着的话,一次早就恢复的
205
+ # 故障会一直挂在状态里,看的人分不清是历史还是现在。
206
+ last_error_code=None,
207
+ ))
208
+ return SyncOutcome(upserted=upserted, deleted=deleted,
209
+ tombstoned=tombstoned,
210
+ cursor=batch.cursor if batch.cursor is not None
211
+ else prior_cursor)
212
+
213
+
214
+ def _apply_tombstones(storage: StoragePort, batch: SyncBatch,
215
+ context: IngestContext) -> int:
216
+ """执行来源**明确传来**的删除。
217
+
218
+ 这和全量收尾的删除是两件事,别合并:
219
+
220
+ 全量收尾 "覆盖范围内、这轮没见到的" —— 推断出来的,所以只有全量
221
+ 有资格,而且必须限定在声明的范围内
222
+ tombstone "来源说这条删了" —— 确定的事实,增量也必须执行
223
+
224
+ 早先把增量定义成「一条都不许删」,防住了「拿局部列表当全量」,
225
+ 但同时也堵死了这条:用户在手机上删掉的日程,在 agent 眼里永远还在,
226
+ 而且它会一直出现在"接下来有什么安排"里。
227
+ """
228
+ ids = [str(i) for i in (batch.deleted_item_ids or ()) if str(i).strip()]
229
+ if not ids:
230
+ return 0
231
+ return int(storage.delete_source_items(
232
+ subject_id=context.subject_id, source=batch.source,
233
+ collection_kind=batch.collection_kind, source_item_ids=ids,
234
+ ) or 0)
235
+
236
+
237
+ def _upsert(storage: StoragePort, batch: SyncBatch,
238
+ context: IngestContext) -> int:
239
+ items = list(batch.items)
240
+ if not items:
241
+ return 0
242
+ # 🔴 **这一批的条目必须打上这一批的 sync_id。**
243
+ #
244
+ # 全量收尾删的是「覆盖范围内、这轮没见到的」,判据就是这个字段。
245
+ # 不打的话,刚写进去的条目在同一个事务里被自己的快照收尾删掉 ——
246
+ # 一次"成功"的全量同步,结果是镜像空了。
247
+ # 这个由 kit 打,不指望宿主记得:忘了不报错,只是数据没了。
248
+ # 这一批声明的 source 和 sync_id 由 kit 盖上去,不要求宿主在每个条目上
249
+ # 再写一遍。两者忘了都不报错,但后果不一样:
250
+ # sync_id 忘了 → 刚写进去的被自己的快照收尾删掉
251
+ # source 忘了 → 这批条目落在别的来源名下,下次那个来源的全量同步删掉它们
252
+ # 🔴 subject 一律用**可信上下文**的,不用条目自己带的那个。
253
+ #
254
+ # 条目是宿主从来源数据翻译出来的,它带的 subject_id 最好的情况是冗余、
255
+ # 最坏的情况是把 A 的日程写进 B 的花园。可信 subject 只有一个来源:
256
+ # IngestContext —— 那是宿主鉴权之后填的,不经过来源数据也不经过模型。
257
+ # 校验一致再拒绝也行,但覆盖更彻底:没有"该信哪个"这个问题存在。
258
+ items = [replace(i, subject_id=context.subject_id, source=batch.source,
259
+ last_seen_sync_id=batch.sync_id)
260
+ for i in items]
261
+ kinds = {type(i) for i in items}
262
+ if len(kinds) > 1:
263
+ # 混着来说明宿主那边的翻译出了问题。挑一部分写进去、剩下的丢掉,
264
+ # 会得到一份"成功了"的半份镜像。
265
+ raise SyncContractError(
266
+ f"一批里混了 {sorted(k.__name__ for k in kinds)}:"
267
+ f"一次同步只处理一种集合,混着来会写出一份看起来成功的半份镜像"
268
+ )
269
+ # 🔴 声明的集合种类必须和条目类型对上。
270
+ #
271
+ # 不校验的话,「collection_kind=reminders + 一批日历条目」会被照单全收:
272
+ # 日历表被写进去了,而**提醒的同步游标往前推进了** —— 数据和游标从此
273
+ # 互相矛盾,下一轮增量提醒同步会以为上一轮成功了,那段提醒永远补不回来。
274
+ expected = _ITEM_TYPE.get(batch.collection_kind)
275
+ if expected is None:
276
+ raise SyncContractError(
277
+ f"不认识的 collection_kind={batch.collection_kind!r}:"
278
+ f"只有 {CALENDAR!r} 和 {REMINDERS!r}。放过去的话,"
279
+ f"这批数据会落在一个没人读的地方,而同步状态说成功了"
280
+ )
281
+ actual = kinds.pop()
282
+ if actual is not expected:
283
+ raise SyncContractError(
284
+ f"collection_kind={batch.collection_kind!r} 声明的是 "
285
+ f"{expected.__name__},实际给的是 {actual.__name__}:"
286
+ f"照做会把数据写进一张表、把另一张表的游标往前推"
287
+ )
288
+ if expected is CalendarEventMirror:
289
+ storage.upsert_calendar_events(subject_id=context.subject_id,
290
+ events=items)
291
+ else:
292
+ storage.upsert_reminders(subject_id=context.subject_id, items=items)
293
+ return len(items)
294
+
295
+
296
+ __all__ = [
297
+ "CALENDAR", "REMINDERS", "FULL", "INCREMENTAL",
298
+ "SyncBatch", "SyncOutcome", "SyncContractError", "sync_source_mirror",
299
+ ]