@zooid/transport-matrix 0.8.0 → 0.9.1

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zooid/transport-matrix",
3
- "version": "0.8.0",
3
+ "version": "0.9.1",
4
4
  "description": "Matrix Application Service transport for zooid. Routes inbound Matrix messages to ACP agents and posts replies plus approval custom events back to threads.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -28,8 +28,8 @@
28
28
  "marked": "^18.0.4",
29
29
  "sanitize-html": "^2.17.4",
30
30
  "yaml": "^2.5.0",
31
- "@zooid/acp-client": "^0.8.0",
32
- "@zooid/core": "^0.8.0"
31
+ "@zooid/acp-client": "^0.9.1",
32
+ "@zooid/core": "^0.9.1"
33
33
  },
34
34
  "devDependencies": {
35
35
  "@hono/node-server": "^1.13.0",
@@ -248,8 +248,14 @@ describe('BotPool.bootstrap workforce-space attachment', () => {
248
248
  ],
249
249
  )
250
250
  await pool.bootstrap({ spaceRoomId: '!space:zoon.local', asUserId: '@zooid:zoon.local' })
251
+ // Created BY the AS sender bot (room owner/admin), never by the agent —
252
+ // agents join as plain members, never room admins.
251
253
  expect(createRoom).toHaveBeenCalledWith(
252
- expect.objectContaining({ roomAliasName: 'design', restrictedToSpaceId: '!space:zoon.local' }),
254
+ expect.objectContaining({
255
+ roomAliasName: 'design',
256
+ restrictedToSpaceId: '!space:zoon.local',
257
+ senderUserId: '@zooid:zoon.local',
258
+ }),
253
259
  )
254
260
  })
255
261
 
package/src/bot-pool.ts CHANGED
@@ -48,7 +48,7 @@ export class BotPool {
48
48
  console.warn(`[matrix] setDisplayName(${a.userId}) failed: ${(err as Error).message}`)
49
49
  }
50
50
  // Make each agent a member of the workforce space. That covers two
51
- // things at once: the eco.zoon.workforce roster is now backed by
51
+ // things at once: the dev.zooid.workforce roster is now backed by
52
52
  // actual space membership (so the Zoon client's member autocomplete
53
53
  // works across rooms), and every restricted child room's allow rule
54
54
  // is satisfied without per-room invites.
@@ -82,7 +82,13 @@ export class BotPool {
82
82
  } else {
83
83
  const colon = room.indexOf(':')
84
84
  const aliasLocalpart = colon > 1 ? room.slice(1, colon) : room.slice(1)
85
- const sender = opts.adminUserId ?? a.userId
85
+ // Workstation-created rooms are owned by the AS sender bot (PL
86
+ // 100, already seeded in the power override below). Agents join
87
+ // as plain members and must never be room admins. The human
88
+ // operator is invited + seeded at PL 100, but can't be the
89
+ // creator under a workstation-scoped namespace — the AS token
90
+ // can't impersonate a non-namespaced human.
91
+ const sender = opts.asUserId ?? opts.adminUserId ?? a.userId
86
92
  const userPowerLevels = buildUserPowerLevels(
87
93
  opts.asUserId,
88
94
  opts.adminUserIds,
@@ -43,7 +43,7 @@ export class MatrixContextProvider implements TransportContextProvider {
43
43
 
44
44
  async getRoomHistory(channelId: string, hopts: HistoryOptions): Promise<HistoryPage> {
45
45
  // Server-side filter: only `m.room.message` events. Without this we'd
46
- // burn the page budget on reactions, `eco.zoon.*` custom events, typing
46
+ // burn the page budget on reactions, `dev.zooid.*` custom events, typing
47
47
  // notifications, etc., and routinely return empty pages with a stale
48
48
  // `has_more` cursor.
49
49
  const { chunk, end } = await this.opts.client.fetchRoomMessages({
@@ -9,6 +9,7 @@ import {
9
9
  toUpdateBody,
10
10
  toPlanBody,
11
11
  toErrorBody,
12
+ toAvailableCommandsBody,
12
13
  } from './event-encoders.js'
13
14
 
14
15
  describe('toToolCallBody', () => {
@@ -124,6 +125,27 @@ describe('toPlanBody', () => {
124
125
  })
125
126
  })
126
127
 
128
+ describe('toAvailableCommandsBody', () => {
129
+ it('encodes available_commands into the body ZNC021 decodes', () => {
130
+ expect(
131
+ toAvailableCommandsBody({
132
+ type: 'available_commands',
133
+ sessionId: 's-1',
134
+ commands: [
135
+ { name: 'plan', description: 'Switch to plan mode' },
136
+ { name: 'compact', description: 'Compact the context' },
137
+ ],
138
+ }),
139
+ ).toEqual({
140
+ session_id: 's-1',
141
+ available_commands: [
142
+ { name: 'plan', description: 'Switch to plan mode' },
143
+ { name: 'compact', description: 'Compact the context' },
144
+ ],
145
+ })
146
+ })
147
+ })
148
+
127
149
  describe('toErrorBody', () => {
128
150
  const threadRoot = '$root-event-id'
129
151
 
@@ -1,4 +1,5 @@
1
1
  import type {
2
+ AvailableCommandsEvent,
2
3
  PlanEvent,
3
4
  TapEvent,
4
5
  ToolCallEvent,
@@ -66,6 +67,18 @@ export function toPlanBody(evt: PlanEvent): Record<string, unknown> {
66
67
  }
67
68
  }
68
69
 
70
+ export function toAvailableCommandsBody(
71
+ evt: AvailableCommandsEvent,
72
+ ): Record<string, unknown> {
73
+ return {
74
+ session_id: evt.sessionId,
75
+ available_commands: evt.commands.map((c) => ({
76
+ name: c.name,
77
+ description: c.description,
78
+ })),
79
+ }
80
+ }
81
+
69
82
  const RECOVERY_URLS: Partial<Record<string, string>> = {
70
83
  auth_missing: 'https://zooid.dev/docs/guides/run-in-container#authentication-that-carries-over',
71
84
  auth_invalid: 'https://zooid.dev/docs/guides/run-in-container#authentication-that-carries-over',
@@ -0,0 +1,52 @@
1
+ import { describe, it, expect } from 'vitest'
2
+ import {
3
+ isValidWorkstation,
4
+ isValidAgentKey,
5
+ agentMxid,
6
+ splitAgentLocalpart,
7
+ workstationUserNamespace,
8
+ } from './identity.js'
9
+
10
+ describe('workstation / agent grammar', () => {
11
+ it('accepts dot-free lowercase-alnum-hyphen', () => {
12
+ expect(isValidWorkstation('laptop')).toBe(true)
13
+ expect(isValidWorkstation('ec2-prod')).toBe(true)
14
+ expect(isValidAgentKey('docs')).toBe(true)
15
+ })
16
+ it('rejects dots (the workstation/agent separator) and bad chars', () => {
17
+ expect(isValidWorkstation('lap.top')).toBe(false) // dot is the boundary, never inside
18
+ expect(isValidWorkstation('Laptop')).toBe(false) // strict ASCII lowercase
19
+ expect(isValidWorkstation('lap_top')).toBe(false)
20
+ expect(isValidWorkstation('')).toBe(false)
21
+ expect(isValidAgentKey('a.b')).toBe(false)
22
+ })
23
+ })
24
+
25
+ describe('agentMxid', () => {
26
+ it('builds @{workstation}.{agent}:server', () => {
27
+ expect(agentMxid('laptop', 'assistant', 'zoon.eco')).toBe('@laptop.assistant:zoon.eco')
28
+ })
29
+ it('throws on invalid parts', () => {
30
+ expect(() => agentMxid('lap.top', 'assistant', 'zoon.eco')).toThrow()
31
+ expect(() => agentMxid('laptop', 'a.b', 'zoon.eco')).toThrow()
32
+ })
33
+ })
34
+
35
+ describe('splitAgentLocalpart — split on FIRST dot', () => {
36
+ it('recovers (workstation, agent)', () => {
37
+ expect(splitAgentLocalpart('laptop.assistant')).toEqual({
38
+ workstation: 'laptop',
39
+ agent: 'assistant',
40
+ })
41
+ })
42
+ it('throws when there is no separator', () => {
43
+ expect(() => splitAgentLocalpart('zooid')).toThrow()
44
+ })
45
+ })
46
+
47
+ describe('workstationUserNamespace', () => {
48
+ it('emits an escaped-dot exclusive regex for the workstation', () => {
49
+ // The literal dot must be escaped so @{workstation}.* does not also match @{workstation}X*
50
+ expect(workstationUserNamespace('laptop', 'zoon.eco')).toBe('@laptop\\..*:zoon.eco')
51
+ })
52
+ })
@@ -0,0 +1,27 @@
1
+ // A workstation id (and an agent key) must be "slug-shaped": kebab-case,
2
+ // MXID/alias-safe. SLUG_RE names the *format*; the domain noun is workstation.
3
+ export const SLUG_RE = /^[a-z0-9-]+$/
4
+ export const AGENT_KEY_RE = /^[a-z0-9-]+$/
5
+
6
+ export const isValidWorkstation = (s: string): boolean => SLUG_RE.test(s)
7
+ export const isValidAgentKey = (s: string): boolean => AGENT_KEY_RE.test(s)
8
+
9
+ export function agentMxid(workstation: string, agent: string, serverName: string): string {
10
+ if (!isValidWorkstation(workstation))
11
+ throw new Error(`invalid workstation: ${JSON.stringify(workstation)}`)
12
+ if (!isValidAgentKey(agent)) throw new Error(`invalid agent key: ${JSON.stringify(agent)}`)
13
+ return `@${workstation}.${agent}:${serverName}`
14
+ }
15
+
16
+ export function splitAgentLocalpart(localpart: string): { workstation: string; agent: string } {
17
+ const i = localpart.indexOf('.') // first dot is the boundary; workstation/agent are dot-free
18
+ if (i <= 0 || i === localpart.length - 1)
19
+ throw new Error(`not a workstation.agent localpart: ${localpart}`)
20
+ return { workstation: localpart.slice(0, i), agent: localpart.slice(i + 1) }
21
+ }
22
+
23
+ export function workstationUserNamespace(workstation: string, serverName: string): string {
24
+ if (!isValidWorkstation(workstation))
25
+ throw new Error(`invalid workstation: ${JSON.stringify(workstation)}`)
26
+ return `@${workstation}\\..*:${serverName}` // escaped dot — exclusive to this workstation's agents
27
+ }
package/src/index.ts CHANGED
@@ -4,6 +4,15 @@ export { MatrixContextProvider } from './context-provider.js'
4
4
  export type { MatrixContextProviderOpts } from './context-provider.js'
5
5
  export { renderRegistration } from './registration.js'
6
6
  export type { MatrixTransportConfig } from './registration.js'
7
+ export {
8
+ isValidWorkstation,
9
+ isValidAgentKey,
10
+ agentMxid,
11
+ splitAgentLocalpart,
12
+ workstationUserNamespace,
13
+ SLUG_RE,
14
+ AGENT_KEY_RE,
15
+ } from './identity.js'
7
16
  export { extractMentions } from './mentions.js'
8
17
  export type { MaybeMessage } from './mentions.js'
9
18
  export { route, isMediaMsgtype, MEDIA_MSGTYPES } from './router.js'
@@ -11,6 +20,8 @@ export type { AgentBinding, RouteMatch } from './router.js'
11
20
  export { BotPool } from './bot-pool.js'
12
21
  export { createMatrixTransport } from './transport.js'
13
22
  export type { CreateMatrixTransportOptions, MediaClientLike } from './transport.js'
23
+ export { SyncLoop } from './sync-loop.js'
24
+ export type { SyncLoopOptions, SyncResponse, SyncClient } from './sync-loop.js'
14
25
  export { ensureDefaultChannel, ensureWorkforceSpace, serverNameFromMxid } from './space-provisioner.js'
15
26
  export type { EnsureDefaultChannelOpts, EnsureSpaceOpts } from './space-provisioner.js'
16
27
  export {
@@ -287,7 +287,7 @@ describe('MatrixClient', () => {
287
287
 
288
288
  it('sends a custom event type when content type is set', async () => {
289
289
  const fetch = fakeFetch(async ({ url, init }) => {
290
- expect(url).toMatch(/\/send\/eco\.zoon\.approval_request\//)
290
+ expect(url).toMatch(/\/send\/dev\.zooid\.approval_request\//)
291
291
  const body = JSON.parse(init.body as string)
292
292
  expect(body.approval_id).toBe('a1')
293
293
  return new Response(JSON.stringify({ event_id: '$x' }), { status: 200 })
@@ -300,7 +300,7 @@ describe('MatrixClient', () => {
300
300
  await client.sendCustomEvent({
301
301
  roomId: '!r:example.com',
302
302
  asUserId: '@architect:example.com',
303
- eventType: 'eco.zoon.approval_request',
303
+ eventType: 'dev.zooid.approval_request',
304
304
  content: { approval_id: 'a1', description: 'Run: git push' },
305
305
  })
306
306
  })
@@ -445,6 +445,62 @@ describe('MatrixClient.createRoom userPowerLevels', () => {
445
445
  })
446
446
  })
447
447
 
448
+ describe('MatrixClient.leaveRoom', () => {
449
+ it('leaves (rejects) a room as the impersonated user, with a reason', async () => {
450
+ const fetch = fakeFetch(async ({ url, init }) => {
451
+ expect(url).toBe(
452
+ 'https://hs.example.com/_matrix/client/v3/rooms/!r%3Aexample.com/leave' +
453
+ '?user_id=%40zooid%3Aexample.com',
454
+ )
455
+ expect(init.method).toBe('POST')
456
+ expect(JSON.parse(init.body as string)).toEqual({ reason: 'no thanks' })
457
+ return new Response('{}', { status: 200 })
458
+ })
459
+ const client = new MatrixClient({
460
+ homeserver: 'https://hs.example.com',
461
+ asToken: 'as-secret',
462
+ fetch: fetch as unknown as typeof globalThis.fetch,
463
+ })
464
+ await client.leaveRoom('!r:example.com', '@zooid:example.com', { reason: 'no thanks' })
465
+ })
466
+
467
+ it('sends an empty body when no reason is given', async () => {
468
+ const fetch = fakeFetch(async ({ init }) => {
469
+ expect(JSON.parse(init.body as string)).toEqual({})
470
+ return new Response('{}', { status: 200 })
471
+ })
472
+ const client = new MatrixClient({
473
+ homeserver: 'https://hs.example.com',
474
+ asToken: 'as-secret',
475
+ fetch: fetch as unknown as typeof globalThis.fetch,
476
+ })
477
+ await client.leaveRoom('!r:example.com', '@zooid:example.com')
478
+ })
479
+ })
480
+
481
+ describe('MatrixClient.sync (impersonated)', () => {
482
+ it('GETs /sync with ?user_id=, as_token bearer, since + timeout', async () => {
483
+ const fetch = vi.fn(async () =>
484
+ new Response(JSON.stringify({ next_batch: 's2', rooms: { join: {} } }), { status: 200 }),
485
+ )
486
+ const c = new MatrixClient({
487
+ homeserver: 'https://zoon.eco',
488
+ asToken: 'as-tok',
489
+ fetch: fetch as unknown as typeof globalThis.fetch,
490
+ })
491
+ const out = await c.sync({ asUserId: '@laptop.docs:zoon.eco', since: 's1', timeoutMs: 30000 })
492
+
493
+ expect(out.next_batch).toBe('s2')
494
+ const url = new URL(fetch.mock.calls[0]![0] as string)
495
+ expect(url.pathname).toBe('/_matrix/client/v3/sync')
496
+ expect(url.searchParams.get('user_id')).toBe('@laptop.docs:zoon.eco')
497
+ expect(url.searchParams.get('since')).toBe('s1')
498
+ expect(url.searchParams.get('timeout')).toBe('30000')
499
+ const headers = (fetch.mock.calls[0]![1] as RequestInit).headers as Record<string, string>
500
+ expect(headers.Authorization).toBe('Bearer as-tok')
501
+ })
502
+ })
503
+
448
504
  describe('MatrixClient.createRoom restricted', () => {
449
505
  it('injects a restricted join rule referencing the space when restrictedToSpaceId is set', async () => {
450
506
  const fetch = fakeFetch(async ({ url, init }) => {
@@ -222,6 +222,25 @@ export class MatrixClient {
222
222
  throw new Error(`invite(${opts.targetUserId}) failed: ${r.status}`)
223
223
  }
224
224
 
225
+ async leaveRoom(
226
+ roomId: string,
227
+ asUserId: string,
228
+ opts?: { reason?: string },
229
+ ): Promise<void> {
230
+ const url =
231
+ `${this.homeserver}/_matrix/client/v3/rooms/${encodeURIComponent(roomId)}/leave` +
232
+ `?user_id=${encodeURIComponent(asUserId)}`
233
+ const r = await this.fetch(url, {
234
+ method: 'POST',
235
+ headers: {
236
+ Authorization: `Bearer ${this.asToken}`,
237
+ 'content-type': 'application/json',
238
+ },
239
+ body: JSON.stringify(opts?.reason ? { reason: opts.reason } : {}),
240
+ })
241
+ if (!r.ok) throw new Error(`leaveRoom(${roomId}, ${asUserId}) failed: ${r.status}`)
242
+ }
243
+
225
244
  async joinRoom(roomIdOrAlias: string, asUserId: string): Promise<void> {
226
245
  const url =
227
246
  `${this.homeserver}/_matrix/client/v3/join/${encodeURIComponent(roomIdOrAlias)}` +
@@ -401,6 +420,38 @@ export class MatrixClient {
401
420
  return (await r.json()) as { joined: Record<string, { display_name?: string }> }
402
421
  }
403
422
 
423
+ async sync(opts: {
424
+ asUserId: string
425
+ since?: string | null
426
+ timeoutMs?: number
427
+ }): Promise<{
428
+ next_batch: string
429
+ rooms: {
430
+ join: Record<string, {
431
+ timeline: { events: Record<string, unknown>[]; prev_batch?: string; limited?: boolean }
432
+ }>
433
+ }
434
+ }> {
435
+ const params = new URLSearchParams({
436
+ user_id: opts.asUserId,
437
+ timeout: String(opts.timeoutMs ?? 30_000),
438
+ })
439
+ if (opts.since) params.set('since', opts.since)
440
+ const url = `${this.homeserver}/_matrix/client/v3/sync?${params.toString()}`
441
+ const r = await this.fetch(url, {
442
+ headers: { Authorization: `Bearer ${this.asToken}` },
443
+ })
444
+ if (!r.ok) throw new Error(`sync(${opts.asUserId}) failed: ${r.status}`)
445
+ return r.json() as Promise<{
446
+ next_batch: string
447
+ rooms: {
448
+ join: Record<string, {
449
+ timeline: { events: Record<string, unknown>[]; prev_batch?: string; limited?: boolean }
450
+ }>
451
+ }
452
+ }>
453
+ }
454
+
404
455
  async fetchRoomName(roomId: string, asUserId: string): Promise<string | null> {
405
456
  const url =
406
457
  `${this.homeserver}/_matrix/client/v3/rooms/${encodeURIComponent(roomId)}` +
@@ -1,4 +1,5 @@
1
1
  import { describe, it, expect } from 'vitest'
2
+ import { parse } from 'yaml'
2
3
  import { renderRegistration, type MatrixTransportConfig } from './registration.js'
3
4
 
4
5
  const baseConfig: MatrixTransportConfig = {
@@ -39,3 +40,22 @@ describe('renderRegistration', () => {
39
40
  expect(yaml).toContain('rate_limited: false')
40
41
  })
41
42
  })
43
+
44
+ it('renders a workstation-scoped EXCLUSIVE registration', () => {
45
+ const y = parse(
46
+ renderRegistration({
47
+ id: 'laptop',
48
+ url: 'http://10.0.1.5:9099',
49
+ homeserver: 'https://zoon.eco',
50
+ asToken: 'as-x',
51
+ hsToken: 'hs-x',
52
+ senderLocalpart: 'laptop',
53
+ userNamespace: '@laptop\\..*:zoon.eco',
54
+ exclusive: true,
55
+ }),
56
+ )
57
+ expect(y.sender_localpart).toBe('laptop')
58
+ expect(y.url).toBe('http://10.0.1.5:9099')
59
+ expect(y.namespaces.users[0]).toEqual({ exclusive: true, regex: '@laptop\\..*:zoon.eco' })
60
+ expect(y.namespaces.rooms).toEqual([]) // never fence by room id
61
+ })
@@ -0,0 +1,113 @@
1
+ import { describe, it, expect, vi } from 'vitest'
2
+ import { SyncLoop } from './sync-loop.js'
3
+
4
+ const evt = (id: string, roomId: string) => ({
5
+ type: 'm.room.message',
6
+ event_id: id,
7
+ sender: '@me:zoon.eco',
8
+ room_id: roomId,
9
+ content: { msgtype: 'm.text', body: 'hi' },
10
+ })
11
+
12
+ function fakeClient(pages: unknown[]) {
13
+ let i = 0
14
+ return {
15
+ sync: vi.fn(async () => pages[Math.min(i++, pages.length - 1)]),
16
+ }
17
+ }
18
+
19
+ describe('SyncLoop', () => {
20
+ it('dispatches each timeline event once and persists next_batch', async () => {
21
+ const store = new Map<string, string>()
22
+ const dispatched: string[] = []
23
+ const client = fakeClient([
24
+ { next_batch: 's1', rooms: { join: { '!r': { timeline: { events: [evt('a', '!r')] } } } } },
25
+ { next_batch: 's2', rooms: { join: { '!r': { timeline: { events: [evt('b', '!r')] } } } } },
26
+ { next_batch: 's2', rooms: { join: {} } },
27
+ ])
28
+ const loop = new SyncLoop({
29
+ client: client as never,
30
+ asUserId: '@laptop.docs:zoon.eco',
31
+ loadSince: () => store.get('docs') ?? null,
32
+ saveSince: (s) => store.set('docs', s),
33
+ onEvent: (e) => dispatched.push(e.event_id as string),
34
+ })
35
+ await loop.tick()
36
+ await loop.tick()
37
+ expect(dispatched).toEqual(['a', 'b'])
38
+ expect(store.get('docs')).toBe('s2')
39
+ })
40
+
41
+ it('resumes from the persisted since (no reprocessing across restart)', async () => {
42
+ const store = new Map<string, string>([['docs', 's-persisted']])
43
+ const client = fakeClient([{ next_batch: 's3', rooms: { join: {} } }])
44
+ const loop = new SyncLoop({
45
+ client: client as never,
46
+ asUserId: '@laptop.docs:zoon.eco',
47
+ loadSince: () => store.get('docs') ?? null,
48
+ saveSince: (s) => store.set('docs', s),
49
+ onEvent: () => {},
50
+ })
51
+ await loop.tick()
52
+ expect((client.sync as ReturnType<typeof vi.fn>).mock.calls[0][0].since).toBe('s-persisted')
53
+ })
54
+
55
+ it('tolerates an idle sync with no `rooms` key (real homeservers omit it)', async () => {
56
+ const store = new Map<string, string>()
57
+ const dispatched: string[] = []
58
+ // Real Tuwunel returns just { next_batch } on an idle incremental sync.
59
+ const client = fakeClient([{ next_batch: 's-idle' }])
60
+ const loop = new SyncLoop({
61
+ client: client as never,
62
+ asUserId: '@laptop.docs:zoon.eco',
63
+ loadSince: () => store.get('docs') ?? null,
64
+ saveSince: (s) => store.set('docs', s),
65
+ onEvent: (e) => dispatched.push(e.event_id as string),
66
+ })
67
+ await expect(loop.tick()).resolves.toBeUndefined()
68
+ expect(dispatched).toEqual([])
69
+ expect(store.get('docs')).toBe('s-idle') // cursor still advances
70
+ })
71
+
72
+ it('tolerates a joined room with no `timeline` (state/ephemeral-only update)', async () => {
73
+ const store = new Map<string, string>()
74
+ const dispatched: string[] = []
75
+ // A room can appear in rooms.join carrying only state — no timeline key.
76
+ const client = fakeClient([
77
+ { next_batch: 's-state', rooms: { join: { '!r': { state: { events: [] } } } } },
78
+ ])
79
+ const loop = new SyncLoop({
80
+ client: client as never,
81
+ asUserId: '@laptop.docs:zoon.eco',
82
+ loadSince: () => store.get('docs') ?? null,
83
+ saveSince: (s) => store.set('docs', s),
84
+ onEvent: (e) => dispatched.push(e.event_id as string),
85
+ })
86
+ await expect(loop.tick()).resolves.toBeUndefined()
87
+ expect(dispatched).toEqual([])
88
+ expect(store.get('docs')).toBe('s-state')
89
+ })
90
+
91
+ it('run() survives a throwing tick and keeps going (no daemon crash)', async () => {
92
+ let calls = 0
93
+ let loop!: SyncLoop
94
+ const client = {
95
+ sync: vi.fn(async () => {
96
+ calls++
97
+ if (calls === 1) throw new Error('transient /sync 502')
98
+ loop.stop() // recovered on the 2nd tick — end the loop deterministically
99
+ return { next_batch: 's-ok' }
100
+ }),
101
+ }
102
+ loop = new SyncLoop({
103
+ client: client as never,
104
+ asUserId: '@laptop.docs:zoon.eco',
105
+ loadSince: () => null,
106
+ saveSince: () => {},
107
+ onEvent: () => {},
108
+ retryDelayMs: 1,
109
+ })
110
+ await loop.run() // tick 1 throws → backoff → tick 2 recovers → stop → exits
111
+ expect(calls).toBe(2)
112
+ })
113
+ })
@@ -0,0 +1,74 @@
1
+ export interface SyncResponse {
2
+ next_batch: string
3
+ // Real homeservers omit `rooms` (and `rooms.join`) entirely on an idle
4
+ // incremental sync — both are optional.
5
+ rooms?: {
6
+ join?: Record<string, {
7
+ // A joined room may carry only state/ephemeral/account_data on a given
8
+ // sync, with no `timeline` (or a timeline with no `events`).
9
+ timeline?: {
10
+ events?: Record<string, unknown>[]
11
+ prev_batch?: string
12
+ limited?: boolean
13
+ }
14
+ }>
15
+ }
16
+ }
17
+
18
+ export interface SyncClient {
19
+ sync(opts: { asUserId: string; since: string | null; timeoutMs: number }): Promise<SyncResponse>
20
+ }
21
+
22
+ export interface SyncLoopOptions {
23
+ client: SyncClient
24
+ asUserId: string
25
+ loadSince: () => string | null
26
+ saveSince: (since: string) => void
27
+ onEvent: (evt: Record<string, unknown>) => void | Promise<void>
28
+ timeoutMs?: number
29
+ /** Backoff after a failed tick before retrying. Default 5000ms. */
30
+ retryDelayMs?: number
31
+ }
32
+
33
+ export class SyncLoop {
34
+ private readonly opts: SyncLoopOptions
35
+ private running = false
36
+
37
+ constructor(opts: SyncLoopOptions) {
38
+ this.opts = opts
39
+ }
40
+
41
+ async tick(): Promise<void> {
42
+ const since = this.opts.loadSince()
43
+ const res = await this.opts.client.sync({
44
+ asUserId: this.opts.asUserId,
45
+ since,
46
+ timeoutMs: this.opts.timeoutMs ?? 30_000,
47
+ })
48
+ for (const [roomId, roomState] of Object.entries(res.rooms?.join ?? {})) {
49
+ for (const baseEvt of roomState.timeline?.events ?? []) {
50
+ await this.opts.onEvent({ ...(baseEvt as Record<string, unknown>), room_id: roomId })
51
+ }
52
+ }
53
+ this.opts.saveSince(res.next_batch)
54
+ }
55
+
56
+ async run(): Promise<void> {
57
+ this.running = true
58
+ while (this.running) {
59
+ try {
60
+ await this.tick()
61
+ } catch (err) {
62
+ // A transient /sync failure (network blip, sleep/wake, 5xx) must not
63
+ // kill the loop — log and back off, then resume from the same `since`.
64
+ if (!this.running) break
65
+ console.warn(`[sync-loop] ${this.opts.asUserId} tick failed, retrying:`, err)
66
+ await new Promise((r) => setTimeout(r, this.opts.retryDelayMs ?? 5_000))
67
+ }
68
+ }
69
+ }
70
+
71
+ stop(): void {
72
+ this.running = false
73
+ }
74
+ }