@archipelagolab/lobi 1.0.0

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 (315) hide show
  1. package/CHANGELOG.md +164 -0
  2. package/ENDOFFILE +0 -0
  3. package/EOF +0 -0
  4. package/LICENSE +21 -0
  5. package/SPEC-SUPPORT.md +116 -0
  6. package/YAMLEND +0 -0
  7. package/api.ts +18 -0
  8. package/archipelagolab-lobi-1.0.0.tgz +0 -0
  9. package/auth-presence.ts +56 -0
  10. package/channel-plugin-api.ts +3 -0
  11. package/cli-metadata.ts +11 -0
  12. package/contract-api.ts +17 -0
  13. package/docs/CHECKLIST.md +83 -0
  14. package/docs/FORK_SDK_GUIDE.md +279 -0
  15. package/helper-api.ts +3 -0
  16. package/index.test.ts +61 -0
  17. package/index.ts +65 -0
  18. package/openclaw.plugin.json +23 -0
  19. package/package.json +52 -0
  20. package/plugin-entry.handlers.runtime.ts +1 -0
  21. package/runtime-api.ts +54 -0
  22. package/runtime-heavy-api.ts +1 -0
  23. package/scripts/migrate-to-lobi.sh +72 -0
  24. package/secret-contract-api.ts +5 -0
  25. package/setup-entry.ts +13 -0
  26. package/src/account-selection.test.ts +124 -0
  27. package/src/account-selection.ts +226 -0
  28. package/src/actions.account-propagation.test.ts +251 -0
  29. package/src/actions.test.ts +251 -0
  30. package/src/actions.ts +336 -0
  31. package/src/approval-auth.test.ts +23 -0
  32. package/src/approval-auth.ts +25 -0
  33. package/src/approval-handler.runtime.test.ts +46 -0
  34. package/src/approval-handler.runtime.ts +400 -0
  35. package/src/approval-ids.ts +6 -0
  36. package/src/approval-native.test.ts +329 -0
  37. package/src/approval-native.ts +336 -0
  38. package/src/approval-reactions.test.ts +107 -0
  39. package/src/approval-reactions.ts +158 -0
  40. package/src/auth-precedence.ts +61 -0
  41. package/src/channel-account-paths.ts +92 -0
  42. package/src/channel.account-paths.test.ts +102 -0
  43. package/src/channel.directory.test.ts +601 -0
  44. package/src/channel.resolve.test.ts +38 -0
  45. package/src/channel.runtime.ts +16 -0
  46. package/src/channel.setup.test.ts +269 -0
  47. package/src/channel.ts +570 -0
  48. package/src/cli-metadata.ts +19 -0
  49. package/src/cli.test.ts +1015 -0
  50. package/src/cli.ts +1198 -0
  51. package/src/config-adapter.ts +41 -0
  52. package/src/config-schema.test.ts +90 -0
  53. package/src/config-schema.ts +114 -0
  54. package/src/directory-live.test.ts +200 -0
  55. package/src/directory-live.ts +238 -0
  56. package/src/doctor-contract.ts +287 -0
  57. package/src/doctor.test.ts +440 -0
  58. package/src/doctor.ts +262 -0
  59. package/src/env-vars.ts +92 -0
  60. package/src/exec-approval-resolver.test.ts +68 -0
  61. package/src/exec-approval-resolver.ts +23 -0
  62. package/src/exec-approvals.test.ts +483 -0
  63. package/src/exec-approvals.ts +290 -0
  64. package/src/group-mentions.ts +41 -0
  65. package/src/legacy-crypto-inspector-availability.test.ts +81 -0
  66. package/src/legacy-crypto-inspector-availability.ts +60 -0
  67. package/src/legacy-crypto.test.ts +234 -0
  68. package/src/legacy-crypto.ts +549 -0
  69. package/src/legacy-state.test.ts +86 -0
  70. package/src/legacy-state.ts +156 -0
  71. package/src/matrix/account-config.ts +150 -0
  72. package/src/matrix/accounts.readiness.test.ts +27 -0
  73. package/src/matrix/accounts.test.ts +757 -0
  74. package/src/matrix/accounts.ts +194 -0
  75. package/src/matrix/actions/client.test.ts +215 -0
  76. package/src/matrix/actions/client.ts +31 -0
  77. package/src/matrix/actions/devices.test.ts +114 -0
  78. package/src/matrix/actions/devices.ts +34 -0
  79. package/src/matrix/actions/limits.test.ts +15 -0
  80. package/src/matrix/actions/limits.ts +6 -0
  81. package/src/matrix/actions/messages.test.ts +289 -0
  82. package/src/matrix/actions/messages.ts +123 -0
  83. package/src/matrix/actions/pins.test.ts +74 -0
  84. package/src/matrix/actions/pins.ts +64 -0
  85. package/src/matrix/actions/polls.test.ts +71 -0
  86. package/src/matrix/actions/polls.ts +109 -0
  87. package/src/matrix/actions/profile.test.ts +109 -0
  88. package/src/matrix/actions/profile.ts +37 -0
  89. package/src/matrix/actions/reactions.test.ts +135 -0
  90. package/src/matrix/actions/reactions.ts +59 -0
  91. package/src/matrix/actions/room.test.ts +79 -0
  92. package/src/matrix/actions/room.ts +71 -0
  93. package/src/matrix/actions/summary.test.ts +87 -0
  94. package/src/matrix/actions/summary.ts +88 -0
  95. package/src/matrix/actions/types.ts +82 -0
  96. package/src/matrix/actions/verification.test.ts +105 -0
  97. package/src/matrix/actions/verification.ts +237 -0
  98. package/src/matrix/actions.ts +37 -0
  99. package/src/matrix/active-client.ts +26 -0
  100. package/src/matrix/async-lock.ts +18 -0
  101. package/src/matrix/backup-health.ts +115 -0
  102. package/src/matrix/client/config-runtime-api.ts +14 -0
  103. package/src/matrix/client/config-secret-input.runtime.ts +1 -0
  104. package/src/matrix/client/config.ts +982 -0
  105. package/src/matrix/client/create-client.test.ts +115 -0
  106. package/src/matrix/client/create-client.ts +101 -0
  107. package/src/matrix/client/env-auth.ts +6 -0
  108. package/src/matrix/client/file-sync-store.test.ts +265 -0
  109. package/src/matrix/client/file-sync-store.ts +289 -0
  110. package/src/matrix/client/logging.ts +123 -0
  111. package/src/matrix/client/migration-snapshot.runtime.ts +1 -0
  112. package/src/matrix/client/private-network-host.ts +56 -0
  113. package/src/matrix/client/runtime.ts +4 -0
  114. package/src/matrix/client/shared.test.ts +344 -0
  115. package/src/matrix/client/shared.ts +306 -0
  116. package/src/matrix/client/storage.test.ts +634 -0
  117. package/src/matrix/client/storage.ts +544 -0
  118. package/src/matrix/client/types.ts +50 -0
  119. package/src/matrix/client-bootstrap.test.ts +84 -0
  120. package/src/matrix/client-bootstrap.ts +164 -0
  121. package/src/matrix/client-resolver.test-helpers.ts +147 -0
  122. package/src/matrix/client.test.ts +1521 -0
  123. package/src/matrix/client.ts +23 -0
  124. package/src/matrix/config-paths.ts +31 -0
  125. package/src/matrix/config-update.test.ts +237 -0
  126. package/src/matrix/config-update.ts +291 -0
  127. package/src/matrix/credentials-read.ts +206 -0
  128. package/src/matrix/credentials-write.runtime.ts +26 -0
  129. package/src/matrix/credentials.test.ts +501 -0
  130. package/src/matrix/credentials.ts +95 -0
  131. package/src/matrix/deps.test.ts +74 -0
  132. package/src/matrix/deps.ts +225 -0
  133. package/src/matrix/device-health.test.ts +45 -0
  134. package/src/matrix/device-health.ts +31 -0
  135. package/src/matrix/direct-management.test.ts +350 -0
  136. package/src/matrix/direct-management.ts +347 -0
  137. package/src/matrix/direct-room.test.ts +61 -0
  138. package/src/matrix/direct-room.ts +128 -0
  139. package/src/matrix/draft-stream.test.ts +406 -0
  140. package/src/matrix/draft-stream.ts +216 -0
  141. package/src/matrix/encryption-guidance.ts +27 -0
  142. package/src/matrix/errors.ts +21 -0
  143. package/src/matrix/format.test.ts +340 -0
  144. package/src/matrix/format.ts +428 -0
  145. package/src/matrix/legacy-crypto-inspector.ts +95 -0
  146. package/src/matrix/media-errors.ts +20 -0
  147. package/src/matrix/media-text.ts +169 -0
  148. package/src/matrix/monitor/access-state.test.ts +45 -0
  149. package/src/matrix/monitor/access-state.ts +77 -0
  150. package/src/matrix/monitor/ack-config.test.ts +57 -0
  151. package/src/matrix/monitor/ack-config.ts +26 -0
  152. package/src/matrix/monitor/allowlist.test.ts +45 -0
  153. package/src/matrix/monitor/allowlist.ts +94 -0
  154. package/src/matrix/monitor/auto-join.test.ts +203 -0
  155. package/src/matrix/monitor/auto-join.ts +86 -0
  156. package/src/matrix/monitor/config.test.ts +197 -0
  157. package/src/matrix/monitor/config.ts +303 -0
  158. package/src/matrix/monitor/context-summary.ts +43 -0
  159. package/src/matrix/monitor/direct.test.ts +529 -0
  160. package/src/matrix/monitor/direct.ts +270 -0
  161. package/src/matrix/monitor/events.test.ts +1524 -0
  162. package/src/matrix/monitor/events.ts +213 -0
  163. package/src/matrix/monitor/handler.body-for-agent.test.ts +396 -0
  164. package/src/matrix/monitor/handler.group-history.test.ts +648 -0
  165. package/src/matrix/monitor/handler.media-failure.test.ts +267 -0
  166. package/src/matrix/monitor/handler.test-helpers.ts +308 -0
  167. package/src/matrix/monitor/handler.test.ts +2952 -0
  168. package/src/matrix/monitor/handler.thread-root-media.test.ts +82 -0
  169. package/src/matrix/monitor/handler.ts +1679 -0
  170. package/src/matrix/monitor/inbound-dedupe.test.ts +146 -0
  171. package/src/matrix/monitor/inbound-dedupe.ts +267 -0
  172. package/src/matrix/monitor/index.test.ts +920 -0
  173. package/src/matrix/monitor/index.ts +434 -0
  174. package/src/matrix/monitor/legacy-crypto-restore.test.ts +206 -0
  175. package/src/matrix/monitor/legacy-crypto-restore.ts +139 -0
  176. package/src/matrix/monitor/location.ts +100 -0
  177. package/src/matrix/monitor/media.test.ts +159 -0
  178. package/src/matrix/monitor/media.ts +119 -0
  179. package/src/matrix/monitor/mentions.test.ts +289 -0
  180. package/src/matrix/monitor/mentions.ts +177 -0
  181. package/src/matrix/monitor/reaction-events.test.ts +326 -0
  182. package/src/matrix/monitor/reaction-events.ts +187 -0
  183. package/src/matrix/monitor/recent-invite.test.ts +92 -0
  184. package/src/matrix/monitor/recent-invite.ts +30 -0
  185. package/src/matrix/monitor/replies.test.ts +265 -0
  186. package/src/matrix/monitor/replies.ts +136 -0
  187. package/src/matrix/monitor/reply-context.test.ts +276 -0
  188. package/src/matrix/monitor/reply-context.ts +92 -0
  189. package/src/matrix/monitor/room-history.test.ts +258 -0
  190. package/src/matrix/monitor/room-history.ts +301 -0
  191. package/src/matrix/monitor/room-info.test.ts +201 -0
  192. package/src/matrix/monitor/room-info.ts +126 -0
  193. package/src/matrix/monitor/rooms.test.ts +121 -0
  194. package/src/matrix/monitor/rooms.ts +52 -0
  195. package/src/matrix/monitor/route.test.ts +255 -0
  196. package/src/matrix/monitor/route.ts +178 -0
  197. package/src/matrix/monitor/runtime-api.ts +31 -0
  198. package/src/matrix/monitor/startup-verification.test.ts +294 -0
  199. package/src/matrix/monitor/startup-verification.ts +237 -0
  200. package/src/matrix/monitor/startup.test.ts +257 -0
  201. package/src/matrix/monitor/startup.ts +218 -0
  202. package/src/matrix/monitor/status.ts +111 -0
  203. package/src/matrix/monitor/sync-lifecycle.test.ts +224 -0
  204. package/src/matrix/monitor/sync-lifecycle.ts +91 -0
  205. package/src/matrix/monitor/task-runner.ts +38 -0
  206. package/src/matrix/monitor/thread-context.test.ts +149 -0
  207. package/src/matrix/monitor/thread-context.ts +108 -0
  208. package/src/matrix/monitor/threads.test.ts +68 -0
  209. package/src/matrix/monitor/threads.ts +85 -0
  210. package/src/matrix/monitor/types.ts +30 -0
  211. package/src/matrix/monitor/verification-events.ts +627 -0
  212. package/src/matrix/monitor/verification-utils.test.ts +47 -0
  213. package/src/matrix/monitor/verification-utils.ts +46 -0
  214. package/src/matrix/outbound-media-runtime.ts +1 -0
  215. package/src/matrix/poll-summary.ts +110 -0
  216. package/src/matrix/poll-types.test.ts +205 -0
  217. package/src/matrix/poll-types.ts +433 -0
  218. package/src/matrix/probe.runtime.ts +4 -0
  219. package/src/matrix/probe.test.ts +154 -0
  220. package/src/matrix/probe.ts +96 -0
  221. package/src/matrix/profile.test.ts +154 -0
  222. package/src/matrix/profile.ts +184 -0
  223. package/src/matrix/reaction-common.test.ts +96 -0
  224. package/src/matrix/reaction-common.ts +147 -0
  225. package/src/matrix/sdk/crypto-bootstrap.test.ts +505 -0
  226. package/src/matrix/sdk/crypto-bootstrap.ts +341 -0
  227. package/src/matrix/sdk/crypto-facade.test.ts +197 -0
  228. package/src/matrix/sdk/crypto-facade.ts +207 -0
  229. package/src/matrix/sdk/crypto-node.runtime.test.ts +27 -0
  230. package/src/matrix/sdk/crypto-node.runtime.ts +9 -0
  231. package/src/matrix/sdk/crypto-runtime.ts +11 -0
  232. package/src/matrix/sdk/decrypt-bridge.ts +356 -0
  233. package/src/matrix/sdk/event-helpers.test.ts +60 -0
  234. package/src/matrix/sdk/event-helpers.ts +71 -0
  235. package/src/matrix/sdk/http-client.test.ts +134 -0
  236. package/src/matrix/sdk/http-client.ts +87 -0
  237. package/src/matrix/sdk/idb-persistence-lock.ts +51 -0
  238. package/src/matrix/sdk/idb-persistence.lock-order.test.ts +108 -0
  239. package/src/matrix/sdk/idb-persistence.test-helpers.ts +88 -0
  240. package/src/matrix/sdk/idb-persistence.test.ts +149 -0
  241. package/src/matrix/sdk/idb-persistence.ts +283 -0
  242. package/src/matrix/sdk/logger.test.ts +25 -0
  243. package/src/matrix/sdk/logger.ts +108 -0
  244. package/src/matrix/sdk/read-response-with-limit.ts +19 -0
  245. package/src/matrix/sdk/recovery-key-store.test.ts +385 -0
  246. package/src/matrix/sdk/recovery-key-store.ts +430 -0
  247. package/src/matrix/sdk/transport.test.ts +161 -0
  248. package/src/matrix/sdk/transport.ts +344 -0
  249. package/src/matrix/sdk/types.ts +236 -0
  250. package/src/matrix/sdk/verification-manager.test.ts +509 -0
  251. package/src/matrix/sdk/verification-manager.ts +694 -0
  252. package/src/matrix/sdk/verification-status.ts +23 -0
  253. package/src/matrix/sdk.test.ts +2568 -0
  254. package/src/matrix/sdk.ts +1789 -0
  255. package/src/matrix/send/client.test.ts +174 -0
  256. package/src/matrix/send/client.ts +90 -0
  257. package/src/matrix/send/formatting.ts +189 -0
  258. package/src/matrix/send/media.ts +244 -0
  259. package/src/matrix/send/targets.test.ts +254 -0
  260. package/src/matrix/send/targets.ts +104 -0
  261. package/src/matrix/send/types.ts +134 -0
  262. package/src/matrix/send.test.ts +958 -0
  263. package/src/matrix/send.ts +609 -0
  264. package/src/matrix/session-store-metadata.ts +108 -0
  265. package/src/matrix/startup-abort.ts +44 -0
  266. package/src/matrix/sync-state.ts +27 -0
  267. package/src/matrix/target-ids.ts +102 -0
  268. package/src/matrix/thread-bindings-shared.ts +201 -0
  269. package/src/matrix/thread-bindings.test.ts +673 -0
  270. package/src/matrix/thread-bindings.ts +577 -0
  271. package/src/matrix-migration.runtime.ts +9 -0
  272. package/src/migration-config.test.ts +228 -0
  273. package/src/migration-config.ts +243 -0
  274. package/src/migration-snapshot-backup.ts +117 -0
  275. package/src/migration-snapshot.test.ts +184 -0
  276. package/src/migration-snapshot.ts +55 -0
  277. package/src/onboarding.resolve.test.ts +55 -0
  278. package/src/onboarding.test-harness.ts +158 -0
  279. package/src/onboarding.test.ts +665 -0
  280. package/src/onboarding.ts +773 -0
  281. package/src/outbound.test.ts +173 -0
  282. package/src/outbound.ts +78 -0
  283. package/src/plugin-entry.runtime.js +159 -0
  284. package/src/plugin-entry.runtime.test.ts +108 -0
  285. package/src/plugin-entry.runtime.ts +68 -0
  286. package/src/profile-update.ts +68 -0
  287. package/src/record-shared.ts +3 -0
  288. package/src/resolve-targets.test.ts +178 -0
  289. package/src/resolve-targets.ts +175 -0
  290. package/src/resolver.ts +21 -0
  291. package/src/runtime-api.ts +144 -0
  292. package/src/runtime.ts +7 -0
  293. package/src/secret-contract.ts +174 -0
  294. package/src/session-route.test.ts +315 -0
  295. package/src/session-route.ts +113 -0
  296. package/src/setup-bootstrap.ts +94 -0
  297. package/src/setup-config.ts +222 -0
  298. package/src/setup-contract.ts +89 -0
  299. package/src/setup-core.test.ts +326 -0
  300. package/src/setup-core.ts +50 -0
  301. package/src/setup-surface.ts +4 -0
  302. package/src/startup-maintenance.test.ts +227 -0
  303. package/src/startup-maintenance.ts +114 -0
  304. package/src/storage-paths.ts +92 -0
  305. package/src/test-helpers.ts +42 -0
  306. package/src/test-mocks.ts +55 -0
  307. package/src/test-runtime.ts +72 -0
  308. package/src/test-support/monitor-route-test-support.ts +8 -0
  309. package/src/tool-actions.runtime.ts +1 -0
  310. package/src/tool-actions.test.ts +422 -0
  311. package/src/tool-actions.ts +498 -0
  312. package/src/types.ts +230 -0
  313. package/test-api.ts +2 -0
  314. package/thread-bindings-runtime.ts +4 -0
  315. package/tsconfig.json +16 -0
@@ -0,0 +1,224 @@
1
+ import { EventEmitter } from "node:events";
2
+ import { describe, expect, it, vi } from "vitest";
3
+ import { createMatrixMonitorStatusController } from "./status.js";
4
+ import { createMatrixMonitorSyncLifecycle } from "./sync-lifecycle.js";
5
+
6
+ function createClientEmitter() {
7
+ return new EventEmitter() as unknown as {
8
+ on: (event: string, listener: (...args: unknown[]) => void) => unknown;
9
+ off: (event: string, listener: (...args: unknown[]) => void) => unknown;
10
+ emit: (event: string, ...args: unknown[]) => boolean;
11
+ };
12
+ }
13
+
14
+ describe("createMatrixMonitorSyncLifecycle", () => {
15
+ it("rejects the channel wait on unexpected sync errors", async () => {
16
+ const client = createClientEmitter();
17
+ const setStatus = vi.fn();
18
+ const lifecycle = createMatrixMonitorSyncLifecycle({
19
+ client: client as never,
20
+ statusController: createMatrixMonitorStatusController({
21
+ accountId: "default",
22
+ statusSink: setStatus,
23
+ }),
24
+ });
25
+
26
+ const waitPromise = lifecycle.waitForFatalStop();
27
+ client.emit("sync.unexpected_error", new Error("sync exploded"));
28
+
29
+ await expect(waitPromise).rejects.toThrow("sync exploded");
30
+ expect(setStatus).toHaveBeenCalledWith(
31
+ expect.objectContaining({
32
+ accountId: "default",
33
+ healthState: "error",
34
+ lastError: "sync exploded",
35
+ }),
36
+ );
37
+ });
38
+
39
+ it("ignores STOPPED emitted during intentional shutdown", async () => {
40
+ const client = createClientEmitter();
41
+ const setStatus = vi.fn();
42
+ let stopping = false;
43
+ const lifecycle = createMatrixMonitorSyncLifecycle({
44
+ client: client as never,
45
+ statusController: createMatrixMonitorStatusController({
46
+ accountId: "default",
47
+ statusSink: setStatus,
48
+ }),
49
+ isStopping: () => stopping,
50
+ });
51
+
52
+ const waitPromise = lifecycle.waitForFatalStop();
53
+ stopping = true;
54
+ client.emit("sync.state", "STOPPED", "SYNCING", undefined);
55
+ lifecycle.dispose();
56
+
57
+ await expect(waitPromise).resolves.toBeUndefined();
58
+ expect(setStatus).toHaveBeenCalledWith(
59
+ expect.objectContaining({
60
+ accountId: "default",
61
+ healthState: "stopped",
62
+ }),
63
+ );
64
+ });
65
+
66
+ it("marks unexpected STOPPED sync as an error state", async () => {
67
+ const client = createClientEmitter();
68
+ const setStatus = vi.fn();
69
+ const lifecycle = createMatrixMonitorSyncLifecycle({
70
+ client: client as never,
71
+ statusController: createMatrixMonitorStatusController({
72
+ accountId: "default",
73
+ statusSink: setStatus,
74
+ }),
75
+ });
76
+
77
+ const waitPromise = lifecycle.waitForFatalStop();
78
+ client.emit("sync.state", "STOPPED", "SYNCING", undefined);
79
+
80
+ await expect(waitPromise).rejects.toThrow("Matrix sync stopped unexpectedly");
81
+ expect(setStatus).toHaveBeenCalledWith(
82
+ expect.objectContaining({
83
+ accountId: "default",
84
+ healthState: "error",
85
+ lastError: "Matrix sync stopped unexpectedly",
86
+ }),
87
+ );
88
+ });
89
+
90
+ it("ignores unexpected sync errors emitted during intentional shutdown", async () => {
91
+ const client = createClientEmitter();
92
+ const setStatus = vi.fn();
93
+ let stopping = false;
94
+ const lifecycle = createMatrixMonitorSyncLifecycle({
95
+ client: client as never,
96
+ statusController: createMatrixMonitorStatusController({
97
+ accountId: "default",
98
+ statusSink: setStatus,
99
+ }),
100
+ isStopping: () => stopping,
101
+ });
102
+
103
+ const waitPromise = lifecycle.waitForFatalStop();
104
+ stopping = true;
105
+ client.emit("sync.unexpected_error", new Error("shutdown noise"));
106
+ lifecycle.dispose();
107
+
108
+ await expect(waitPromise).resolves.toBeUndefined();
109
+ expect(setStatus).not.toHaveBeenCalledWith(
110
+ expect.objectContaining({
111
+ accountId: "default",
112
+ healthState: "error",
113
+ }),
114
+ );
115
+ });
116
+
117
+ it("ignores non-terminal sync states emitted during intentional shutdown", async () => {
118
+ const client = createClientEmitter();
119
+ const setStatus = vi.fn();
120
+ let stopping = false;
121
+ const statusController = createMatrixMonitorStatusController({
122
+ accountId: "default",
123
+ statusSink: setStatus,
124
+ });
125
+ const lifecycle = createMatrixMonitorSyncLifecycle({
126
+ client: client as never,
127
+ statusController,
128
+ isStopping: () => stopping,
129
+ });
130
+
131
+ const waitPromise = lifecycle.waitForFatalStop();
132
+ stopping = true;
133
+ client.emit("sync.state", "ERROR", "RECONNECTING", new Error("shutdown noise"));
134
+ lifecycle.dispose();
135
+ statusController.markStopped();
136
+
137
+ await expect(waitPromise).resolves.toBeUndefined();
138
+ expect(setStatus).toHaveBeenLastCalledWith(
139
+ expect.objectContaining({
140
+ accountId: "default",
141
+ healthState: "stopped",
142
+ lastError: null,
143
+ }),
144
+ );
145
+ });
146
+
147
+ it("does not downgrade a fatal error to stopped during shutdown", async () => {
148
+ const client = createClientEmitter();
149
+ const setStatus = vi.fn();
150
+ let stopping = false;
151
+ const statusController = createMatrixMonitorStatusController({
152
+ accountId: "default",
153
+ statusSink: setStatus,
154
+ });
155
+ const lifecycle = createMatrixMonitorSyncLifecycle({
156
+ client: client as never,
157
+ statusController,
158
+ isStopping: () => stopping,
159
+ });
160
+
161
+ const waitPromise = lifecycle.waitForFatalStop();
162
+ client.emit("sync.unexpected_error", new Error("sync exploded"));
163
+ await expect(waitPromise).rejects.toThrow("sync exploded");
164
+
165
+ stopping = true;
166
+ client.emit("sync.state", "STOPPED", "SYNCING", undefined);
167
+ lifecycle.dispose();
168
+ statusController.markStopped();
169
+
170
+ expect(setStatus).toHaveBeenLastCalledWith(
171
+ expect.objectContaining({
172
+ accountId: "default",
173
+ healthState: "error",
174
+ lastError: "sync exploded",
175
+ }),
176
+ );
177
+ });
178
+
179
+ it("ignores follow-up sync states after a fatal sync error", async () => {
180
+ const client = createClientEmitter();
181
+ const setStatus = vi.fn();
182
+ const lifecycle = createMatrixMonitorSyncLifecycle({
183
+ client: client as never,
184
+ statusController: createMatrixMonitorStatusController({
185
+ accountId: "default",
186
+ statusSink: setStatus,
187
+ }),
188
+ });
189
+
190
+ const waitPromise = lifecycle.waitForFatalStop();
191
+ client.emit("sync.unexpected_error", new Error("sync exploded"));
192
+ await expect(waitPromise).rejects.toThrow("sync exploded");
193
+
194
+ client.emit("sync.state", "RECONNECTING", "SYNCING", new Error("late reconnect"));
195
+ lifecycle.dispose();
196
+
197
+ expect(setStatus).toHaveBeenLastCalledWith(
198
+ expect.objectContaining({
199
+ accountId: "default",
200
+ healthState: "error",
201
+ lastError: "sync exploded",
202
+ }),
203
+ );
204
+ });
205
+
206
+ it("rejects a second concurrent fatal-stop waiter", async () => {
207
+ const client = createClientEmitter();
208
+ const lifecycle = createMatrixMonitorSyncLifecycle({
209
+ client: client as never,
210
+ statusController: createMatrixMonitorStatusController({
211
+ accountId: "default",
212
+ }),
213
+ });
214
+
215
+ const firstWait = lifecycle.waitForFatalStop();
216
+
217
+ await expect(lifecycle.waitForFatalStop()).rejects.toThrow(
218
+ "Matrix fatal-stop wait already in progress",
219
+ );
220
+
221
+ lifecycle.dispose();
222
+ await expect(firstWait).resolves.toBeUndefined();
223
+ });
224
+ });
@@ -0,0 +1,91 @@
1
+ import type { MatrixClient } from "../sdk.js";
2
+ import { isMatrixTerminalSyncState, type MatrixSyncState } from "../sync-state.js";
3
+ import type { MatrixMonitorStatusController } from "./status.js";
4
+
5
+ function formatSyncLifecycleError(state: MatrixSyncState, error?: unknown): Error {
6
+ if (error instanceof Error) {
7
+ return error;
8
+ }
9
+ const message = typeof error === "string" && error.trim() ? error.trim() : undefined;
10
+ if (state === "STOPPED") {
11
+ return new Error(message ?? "Matrix sync stopped unexpectedly");
12
+ }
13
+ if (state === "ERROR") {
14
+ return new Error(message ?? "Matrix sync entered ERROR unexpectedly");
15
+ }
16
+ return new Error(message ?? `Matrix sync entered ${state} unexpectedly`);
17
+ }
18
+
19
+ export function createMatrixMonitorSyncLifecycle(params: {
20
+ client: MatrixClient;
21
+ statusController: MatrixMonitorStatusController;
22
+ isStopping?: () => boolean;
23
+ }) {
24
+ let fatalError: Error | null = null;
25
+ let resolveFatalWait: (() => void) | null = null;
26
+ let rejectFatalWait: ((error: Error) => void) | null = null;
27
+
28
+ const settleFatal = (error: Error) => {
29
+ if (fatalError) {
30
+ return;
31
+ }
32
+ fatalError = error;
33
+ rejectFatalWait?.(error);
34
+ resolveFatalWait = null;
35
+ rejectFatalWait = null;
36
+ };
37
+
38
+ const onSyncState = (state: MatrixSyncState, _prevState: string | null, error?: unknown) => {
39
+ if (isMatrixTerminalSyncState(state) && !params.isStopping?.()) {
40
+ const fatalError = formatSyncLifecycleError(state, error);
41
+ params.statusController.noteUnexpectedError(fatalError);
42
+ settleFatal(fatalError);
43
+ return;
44
+ }
45
+ // Fatal sync failures are sticky for telemetry; later SDK state churn during
46
+ // cleanup or reconnect should not overwrite the first recorded error.
47
+ if (fatalError) {
48
+ return;
49
+ }
50
+ // Operator-initiated shutdown can still emit transient sync states before
51
+ // the final STOPPED. Ignore that churn so intentional stops do not look
52
+ // like runtime failures.
53
+ if (params.isStopping?.() && !isMatrixTerminalSyncState(state)) {
54
+ return;
55
+ }
56
+ params.statusController.noteSyncState(state, error);
57
+ };
58
+
59
+ const onUnexpectedError = (error: Error) => {
60
+ if (params.isStopping?.()) {
61
+ return;
62
+ }
63
+ params.statusController.noteUnexpectedError(error);
64
+ settleFatal(error);
65
+ };
66
+
67
+ params.client.on("sync.state", onSyncState);
68
+ params.client.on("sync.unexpected_error", onUnexpectedError);
69
+
70
+ return {
71
+ async waitForFatalStop(): Promise<void> {
72
+ if (fatalError) {
73
+ throw fatalError;
74
+ }
75
+ if (resolveFatalWait || rejectFatalWait) {
76
+ throw new Error("Matrix fatal-stop wait already in progress");
77
+ }
78
+ await new Promise<void>((resolve, reject) => {
79
+ resolveFatalWait = resolve;
80
+ rejectFatalWait = (error) => reject(error);
81
+ });
82
+ },
83
+ dispose() {
84
+ resolveFatalWait?.();
85
+ resolveFatalWait = null;
86
+ rejectFatalWait = null;
87
+ params.client.off("sync.state", onSyncState);
88
+ params.client.off("sync.unexpected_error", onUnexpectedError);
89
+ },
90
+ };
91
+ }
@@ -0,0 +1,38 @@
1
+ import type { RuntimeLogger } from "../../runtime-api.js";
2
+
3
+ export function createMatrixMonitorTaskRunner(params: {
4
+ logger: RuntimeLogger;
5
+ logVerboseMessage: (message: string) => void;
6
+ }) {
7
+ const inFlight = new Set<Promise<void>>();
8
+
9
+ const runDetachedTask = (label: string, task: () => Promise<void>): Promise<void> => {
10
+ let trackedTask!: Promise<void>;
11
+ trackedTask = Promise.resolve()
12
+ .then(task)
13
+ .catch((error) => {
14
+ const message = String(error);
15
+ params.logVerboseMessage(`matrix: ${label} failed (${message})`);
16
+ params.logger.warn("matrix background task failed", {
17
+ task: label,
18
+ error: message,
19
+ });
20
+ })
21
+ .finally(() => {
22
+ inFlight.delete(trackedTask);
23
+ });
24
+ inFlight.add(trackedTask);
25
+ return trackedTask;
26
+ };
27
+
28
+ const waitForIdle = async (): Promise<void> => {
29
+ while (inFlight.size > 0) {
30
+ await Promise.allSettled(Array.from(inFlight));
31
+ }
32
+ };
33
+
34
+ return {
35
+ runDetachedTask,
36
+ waitForIdle,
37
+ };
38
+ }
@@ -0,0 +1,149 @@
1
+ import { describe, expect, it, vi } from "vitest";
2
+ import {
3
+ createMatrixThreadContextResolver,
4
+ summarizeMatrixThreadStarterEvent,
5
+ } from "./thread-context.js";
6
+ import type { MatrixRawEvent } from "./types.js";
7
+
8
+ describe("matrix thread context", () => {
9
+ it("summarizes thread starter events from body text", () => {
10
+ expect(
11
+ summarizeMatrixThreadStarterEvent({
12
+ event_id: "$root",
13
+ sender: "@alice:example.org",
14
+ type: "m.room.message",
15
+ origin_server_ts: Date.now(),
16
+ content: {
17
+ msgtype: "m.text",
18
+ body: " Thread starter body ",
19
+ },
20
+ } as MatrixRawEvent),
21
+ ).toBe("Thread starter body");
22
+ });
23
+
24
+ it("marks media-only thread starter events instead of returning bare filenames", () => {
25
+ expect(
26
+ summarizeMatrixThreadStarterEvent({
27
+ event_id: "$root",
28
+ sender: "@alice:example.org",
29
+ type: "m.room.message",
30
+ origin_server_ts: Date.now(),
31
+ content: {
32
+ msgtype: "m.image",
33
+ body: "photo.jpg",
34
+ },
35
+ } as MatrixRawEvent),
36
+ ).toBe("[matrix image attachment]");
37
+ });
38
+
39
+ it("resolves and caches thread starter context", async () => {
40
+ const getEvent = vi.fn(async () => ({
41
+ event_id: "$root",
42
+ sender: "@alice:example.org",
43
+ type: "m.room.message",
44
+ origin_server_ts: Date.now(),
45
+ content: {
46
+ msgtype: "m.text",
47
+ body: "Root topic",
48
+ },
49
+ }));
50
+ const getMemberDisplayName = vi.fn(async () => "Alice");
51
+ const resolveThreadContext = createMatrixThreadContextResolver({
52
+ client: {
53
+ getEvent,
54
+ } as never,
55
+ getMemberDisplayName,
56
+ logVerboseMessage: () => {},
57
+ });
58
+
59
+ await expect(
60
+ resolveThreadContext({
61
+ roomId: "!room:example.org",
62
+ threadRootId: "$root",
63
+ }),
64
+ ).resolves.toEqual({
65
+ threadStarterBody: "Matrix thread root $root from Alice:\nRoot topic",
66
+ senderId: "@alice:example.org",
67
+ senderLabel: "Alice",
68
+ summary: "Root topic",
69
+ });
70
+
71
+ await resolveThreadContext({
72
+ roomId: "!room:example.org",
73
+ threadRootId: "$root",
74
+ });
75
+
76
+ expect(getEvent).toHaveBeenCalledTimes(1);
77
+ expect(getMemberDisplayName).toHaveBeenCalledTimes(1);
78
+ });
79
+
80
+ it("does not cache thread starter fetch failures", async () => {
81
+ const getEvent = vi
82
+ .fn()
83
+ .mockRejectedValueOnce(new Error("temporary failure"))
84
+ .mockResolvedValueOnce({
85
+ event_id: "$root",
86
+ sender: "@alice:example.org",
87
+ type: "m.room.message",
88
+ origin_server_ts: Date.now(),
89
+ content: {
90
+ msgtype: "m.text",
91
+ body: "Recovered topic",
92
+ },
93
+ });
94
+ const getMemberDisplayName = vi.fn(async () => "Alice");
95
+ const resolveThreadContext = createMatrixThreadContextResolver({
96
+ client: {
97
+ getEvent,
98
+ } as never,
99
+ getMemberDisplayName,
100
+ logVerboseMessage: () => {},
101
+ });
102
+
103
+ await expect(
104
+ resolveThreadContext({
105
+ roomId: "!room:example.org",
106
+ threadRootId: "$root",
107
+ }),
108
+ ).resolves.toEqual({
109
+ threadStarterBody: "Matrix thread root $root",
110
+ });
111
+
112
+ await expect(
113
+ resolveThreadContext({
114
+ roomId: "!room:example.org",
115
+ threadRootId: "$root",
116
+ }),
117
+ ).resolves.toEqual({
118
+ threadStarterBody: "Matrix thread root $root from Alice:\nRecovered topic",
119
+ senderId: "@alice:example.org",
120
+ senderLabel: "Alice",
121
+ summary: "Recovered topic",
122
+ });
123
+
124
+ expect(getEvent).toHaveBeenCalledTimes(2);
125
+ expect(getMemberDisplayName).toHaveBeenCalledTimes(1);
126
+ });
127
+
128
+ it("summarizes poll start thread roots from poll content", () => {
129
+ expect(
130
+ summarizeMatrixThreadStarterEvent({
131
+ event_id: "$root",
132
+ sender: "@alice:example.org",
133
+ type: "m.poll.start",
134
+ origin_server_ts: Date.now(),
135
+ content: {
136
+ "m.poll.start": {
137
+ question: { "m.text": "Lunch?" },
138
+ kind: "m.poll.disclosed",
139
+ max_selections: 1,
140
+ answers: [
141
+ { id: "a1", "m.text": "Pizza" },
142
+ { id: "a2", "m.text": "Sushi" },
143
+ ],
144
+ },
145
+ },
146
+ } as MatrixRawEvent),
147
+ ).toBe("[Poll]\nLunch?\n\n1. Pizza\n2. Sushi");
148
+ });
149
+ });
@@ -0,0 +1,108 @@
1
+ import type { MatrixClient } from "../sdk.js";
2
+ import { summarizeMatrixMessageContextEvent, trimMatrixMaybeString } from "./context-summary.js";
3
+ import type { MatrixRawEvent } from "./types.js";
4
+
5
+ const MAX_TRACKED_THREAD_STARTERS = 256;
6
+ const MAX_THREAD_STARTER_BODY_LENGTH = 500;
7
+
8
+ type MatrixThreadContext = {
9
+ threadStarterBody?: string;
10
+ senderId?: string;
11
+ senderLabel?: string;
12
+ summary?: string;
13
+ };
14
+
15
+ function truncateThreadStarterBody(value: string): string {
16
+ if (value.length <= MAX_THREAD_STARTER_BODY_LENGTH) {
17
+ return value;
18
+ }
19
+ return `${value.slice(0, MAX_THREAD_STARTER_BODY_LENGTH - 3)}...`;
20
+ }
21
+
22
+ export function summarizeMatrixThreadStarterEvent(event: MatrixRawEvent): string | undefined {
23
+ const body = summarizeMatrixMessageContextEvent(event);
24
+ if (body) {
25
+ return truncateThreadStarterBody(body);
26
+ }
27
+ const content = event.content as { msgtype?: unknown };
28
+ const msgtype = trimMatrixMaybeString(content.msgtype);
29
+ if (msgtype) {
30
+ return `Matrix ${msgtype} message`;
31
+ }
32
+ const eventType = trimMatrixMaybeString(event.type);
33
+ return eventType ? `Matrix ${eventType} event` : undefined;
34
+ }
35
+
36
+ function formatMatrixThreadStarterBody(params: {
37
+ threadRootId: string;
38
+ senderName?: string;
39
+ senderId?: string;
40
+ summary?: string;
41
+ }): string {
42
+ const senderLabel = params.senderName ?? params.senderId ?? "unknown sender";
43
+ const lines = [`Matrix thread root ${params.threadRootId} from ${senderLabel}:`];
44
+ if (params.summary) {
45
+ lines.push(params.summary);
46
+ }
47
+ return lines.join("\n");
48
+ }
49
+
50
+ export function createMatrixThreadContextResolver(params: {
51
+ client: MatrixClient;
52
+ getMemberDisplayName: (roomId: string, userId: string) => Promise<string>;
53
+ logVerboseMessage: (message: string) => void;
54
+ }) {
55
+ const cache = new Map<string, MatrixThreadContext>();
56
+
57
+ const remember = (key: string, value: MatrixThreadContext): MatrixThreadContext => {
58
+ cache.set(key, value);
59
+ if (cache.size > MAX_TRACKED_THREAD_STARTERS) {
60
+ const oldest = cache.keys().next().value;
61
+ if (typeof oldest === "string") {
62
+ cache.delete(oldest);
63
+ }
64
+ }
65
+ return value;
66
+ };
67
+
68
+ return async (input: { roomId: string; threadRootId: string }): Promise<MatrixThreadContext> => {
69
+ const cacheKey = `${input.roomId}:${input.threadRootId}`;
70
+ const cached = cache.get(cacheKey);
71
+ if (cached) {
72
+ return cached;
73
+ }
74
+
75
+ const rootEvent = await params.client
76
+ .getEvent(input.roomId, input.threadRootId)
77
+ .catch((err) => {
78
+ params.logVerboseMessage(
79
+ `matrix: failed resolving thread root room=${input.roomId} id=${input.threadRootId}: ${String(err)}`,
80
+ );
81
+ return null;
82
+ });
83
+ if (!rootEvent) {
84
+ return {
85
+ threadStarterBody: `Matrix thread root ${input.threadRootId}`,
86
+ };
87
+ }
88
+
89
+ const rawEvent = rootEvent as MatrixRawEvent;
90
+ const senderId = trimMatrixMaybeString(rawEvent.sender);
91
+ const senderName =
92
+ senderId &&
93
+ (await params.getMemberDisplayName(input.roomId, senderId).catch(() => undefined));
94
+ const senderLabel = senderName ?? senderId;
95
+ const summary = summarizeMatrixThreadStarterEvent(rawEvent);
96
+ return remember(cacheKey, {
97
+ threadStarterBody: formatMatrixThreadStarterBody({
98
+ threadRootId: input.threadRootId,
99
+ senderId,
100
+ senderName,
101
+ summary,
102
+ }),
103
+ senderId,
104
+ senderLabel,
105
+ summary,
106
+ });
107
+ };
108
+ }
@@ -0,0 +1,68 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { resolveMatrixThreadRouting } from "./threads.js";
3
+
4
+ describe("resolveMatrixThreadRouting", () => {
5
+ it("keeps sessions flat when threadReplies is off", () => {
6
+ expect(
7
+ resolveMatrixThreadRouting({
8
+ isDirectMessage: false,
9
+ threadReplies: "off",
10
+ messageId: "$reply1",
11
+ threadRootId: "$root",
12
+ }),
13
+ ).toEqual({
14
+ threadId: undefined,
15
+ });
16
+ });
17
+
18
+ it("uses the inbound thread root when replies arrive inside an existing thread", () => {
19
+ expect(
20
+ resolveMatrixThreadRouting({
21
+ isDirectMessage: false,
22
+ threadReplies: "inbound",
23
+ messageId: "$reply1",
24
+ threadRootId: "$root",
25
+ }),
26
+ ).toEqual({
27
+ threadId: "$root",
28
+ });
29
+ });
30
+
31
+ it("keeps top-level inbound messages flat when threadReplies is inbound", () => {
32
+ expect(
33
+ resolveMatrixThreadRouting({
34
+ isDirectMessage: false,
35
+ threadReplies: "inbound",
36
+ messageId: "$root",
37
+ }),
38
+ ).toEqual({
39
+ threadId: undefined,
40
+ });
41
+ });
42
+
43
+ it("uses the triggering message as the thread id when threadReplies is always", () => {
44
+ expect(
45
+ resolveMatrixThreadRouting({
46
+ isDirectMessage: false,
47
+ threadReplies: "always",
48
+ messageId: "$root",
49
+ }),
50
+ ).toEqual({
51
+ threadId: "$root",
52
+ });
53
+ });
54
+
55
+ it("lets dm.threadReplies override room threading behavior", () => {
56
+ expect(
57
+ resolveMatrixThreadRouting({
58
+ isDirectMessage: true,
59
+ threadReplies: "always",
60
+ dmThreadReplies: "off",
61
+ messageId: "$reply1",
62
+ threadRootId: "$root",
63
+ }),
64
+ ).toEqual({
65
+ threadId: undefined,
66
+ });
67
+ });
68
+ });