@zooid/transport-matrix 0.7.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.
@@ -0,0 +1,521 @@
1
+ import { Hono } from 'hono'
2
+ import { timingSafeEqual } from 'node:crypto'
3
+ import type { AcpRegistry, ApprovalCorrelator, RegisteredApproval } from '@zooid/core'
4
+ import type { AgentEvent } from '@zooid/acp-client'
5
+ import { MatrixClient } from './matrix-client.js'
6
+ import { BotPool } from './bot-pool.js'
7
+ import { route, type AgentBinding, type ThreadState } from './router.js'
8
+ import { stripMention, extractMentions } from './mentions.js'
9
+ import { toToolCallBody, toUpdateBody, toPlanBody } from './event-encoders.js'
10
+ import { toMatrixHtml } from './markdown-to-matrix-html.js'
11
+
12
+ export interface CreateMatrixTransportOptions {
13
+ agents: AcpRegistry
14
+ approvals: ApprovalCorrelator
15
+ client: MatrixClient
16
+ bindings: AgentBinding[]
17
+ hsToken: string
18
+ /** Admin Matrix user ID. When set, BotPool.bootstrap invites this user into rooms it creates. */
19
+ adminUserId?: string
20
+ }
21
+
22
+ interface SessionContext {
23
+ agent: AgentBinding
24
+ roomId: string
25
+ /** Always set — every session is thread-scoped via agent-promotion. */
26
+ threadRoot: string
27
+ }
28
+
29
+ interface MatrixEvent {
30
+ type?: string
31
+ event_id?: string
32
+ origin_server_ts?: number
33
+ room_id?: string
34
+ sender?: string
35
+ content?: Record<string, unknown> & {
36
+ msgtype?: string
37
+ body?: string
38
+ 'm.relates_to'?: { rel_type?: string; event_id?: string }
39
+ }
40
+ }
41
+
42
+ const STARTUP_GRACE_MS = 5_000
43
+ const SEEN_EVENT_CAP = 5_000
44
+
45
+ function inboundThreadRoot(evt: MatrixEvent): string | undefined {
46
+ const r = evt.content?.['m.relates_to']
47
+ return r?.rel_type === 'm.thread' && r.event_id ? r.event_id : undefined
48
+ }
49
+
50
+ export function createMatrixTransport(opts: CreateMatrixTransportOptions) {
51
+ const { agents, approvals, client, bindings, hsToken, adminUserId } = opts
52
+ const pool = new BotPool(client, bindings)
53
+ const sessions = new Map<string, SessionContext>()
54
+ const buffers = new Map<string, string>()
55
+ // Per-session promise tail so out-of-band events (tool_call, plan, etc.)
56
+ // serialize on the wire even though the ACP producer doesn't await us.
57
+ const sendQueue = new Map<string, Promise<void>>()
58
+ // Thread participation index: keyed by thread root event_id.
59
+ const threadStates = new Map<string, ThreadState>()
60
+ // Drop events older than this — Tuwunel may replay a backlog after the
61
+ // daemon was offline, and we don't want yesterday's "@docs hi" to fire now.
62
+ const cutoffTs = Date.now() - STARTUP_GRACE_MS
63
+ // Idempotency: appservice transactions are retried on 4xx/5xx/timeout, and
64
+ // the same event_id can arrive twice. Skip ones we've already taken.
65
+ const seenEventIds = new Set<string>()
66
+
67
+ agents.onEvent = async (name, event: AgentEvent) => {
68
+ const ctx = sessions.get(event.sessionId)
69
+ if (!ctx) {
70
+ console.warn(`[matrix:${name}] no session ctx for ${event.sessionId}`)
71
+ return
72
+ }
73
+
74
+ if (event.type === 'agent_message_chunk') {
75
+ const block = event.content as { type?: string; text?: string }
76
+ if (block.type === 'text' && typeof block.text === 'string') {
77
+ buffers.set(event.sessionId, (buffers.get(event.sessionId) ?? '') + block.text)
78
+ } else {
79
+ console.warn(`[matrix:${name}] dropped chunk block type=${block.type}`, block)
80
+ }
81
+ return
82
+ }
83
+
84
+ const eventType =
85
+ event.type === 'tool_call'
86
+ ? 'eco.zoon.tool_call'
87
+ : event.type === 'tool_call_update'
88
+ ? 'eco.zoon.tool_call_update'
89
+ : 'eco.zoon.plan'
90
+ const body =
91
+ event.type === 'tool_call'
92
+ ? toToolCallBody(event)
93
+ : event.type === 'tool_call_update'
94
+ ? toUpdateBody(event)
95
+ : toPlanBody(event)
96
+ body['m.relates_to'] = { rel_type: 'm.thread', event_id: ctx.threadRoot }
97
+ const tail = (sendQueue.get(event.sessionId) ?? Promise.resolve()).then(async () => {
98
+ try {
99
+ await client.sendCustomEvent({
100
+ roomId: ctx.roomId,
101
+ asUserId: ctx.agent.userId,
102
+ eventType,
103
+ content: body,
104
+ })
105
+ } catch (err) {
106
+ console.warn(`[matrix:${name}] sendCustomEvent(${eventType}) failed:`, err)
107
+ }
108
+ })
109
+ sendQueue.set(event.sessionId, tail)
110
+ await tail
111
+ }
112
+
113
+ agents.onApprovalRequest = async (name, req) => {
114
+ const handle = approvals.register(name, (req as { sessionId: string }).sessionId, req, {
115
+ timeoutMs: agents.getApprovalTimeoutMs(name),
116
+ })
117
+ return handle.decisionPromise
118
+ }
119
+
120
+ approvals.on('registered', (handle: RegisteredApproval) => {
121
+ const ctx = sessions.get(handle.sessionId)
122
+ if (!ctx) return
123
+ const content: Record<string, unknown> = {
124
+ approval_id: handle.approvalId,
125
+ session_id: handle.sessionId,
126
+ tool_call_id: handle.toolCallId,
127
+ options: handle.options,
128
+ }
129
+ content['m.relates_to'] = { rel_type: 'm.thread', event_id: ctx.threadRoot }
130
+ if (handle.toolKind !== undefined) content.tool_kind = handle.toolKind
131
+ if (handle.toolTitle !== undefined) content.tool_title = handle.toolTitle
132
+ if (handle.toolInput !== undefined) content.tool_input = handle.toolInput
133
+ void client.sendCustomEvent({
134
+ roomId: ctx.roomId,
135
+ asUserId: ctx.agent.userId,
136
+ eventType: 'eco.zoon.approval_request',
137
+ content,
138
+ })
139
+ })
140
+
141
+ const app = new Hono()
142
+
143
+ function authOk(authHeader: string | undefined): boolean {
144
+ const h = authHeader ?? ''
145
+ if (!h.startsWith('Bearer ')) return false
146
+ const got = h.slice(7)
147
+ if (got.length !== hsToken.length) return false
148
+ return timingSafeEqual(Buffer.from(got), Buffer.from(hsToken))
149
+ }
150
+
151
+ app.put('/_matrix/app/v1/transactions/:txnId', async (c) => {
152
+ if (!authOk(c.req.header('authorization'))) {
153
+ return c.json({ errcode: 'M_FORBIDDEN' }, 403)
154
+ }
155
+ const body = (await c.req.json().catch(() => ({}))) as { events?: MatrixEvent[] }
156
+ for (const evt of body.events ?? []) {
157
+ if (evt.event_id) {
158
+ if (seenEventIds.has(evt.event_id)) {
159
+ continue
160
+ }
161
+ seenEventIds.add(evt.event_id)
162
+ if (seenEventIds.size > SEEN_EVENT_CAP) {
163
+ const first = seenEventIds.values().next().value
164
+ if (first !== undefined) seenEventIds.delete(first)
165
+ }
166
+ }
167
+ if (
168
+ evt.origin_server_ts !== undefined &&
169
+ evt.origin_server_ts < cutoffTs &&
170
+ evt.type === 'm.room.message'
171
+ ) {
172
+ console.log(
173
+ `[matrix] dropping stale message event ${evt.event_id} ` +
174
+ `(ts=${evt.origin_server_ts}, daemon started at ${cutoffTs + STARTUP_GRACE_MS})`,
175
+ )
176
+ continue
177
+ }
178
+ if (evt.type === 'eco.zoon.session_reset') {
179
+ // Spec § /clear: room-scope reset is unsupported. Only thread-scoped
180
+ // resets carry a thread relation; drop bare room-level resets silently.
181
+ const relates = evt.content?.['m.relates_to'] as
182
+ | { rel_type?: string; event_id?: string }
183
+ | undefined
184
+ const threadRoot =
185
+ relates?.rel_type === 'm.thread' && relates.event_id ? relates.event_id : undefined
186
+ if (!threadRoot) {
187
+ console.log('[matrix] dropping eco.zoon.session_reset without thread relation')
188
+ continue
189
+ }
190
+ console.log(`[matrix] inbound eco.zoon.session_reset in ${evt.room_id} thread=${threadRoot}`)
191
+ for (const a of bindings) {
192
+ agents.endSession(a.name, threadRoot)
193
+ }
194
+ // NB: keep threadStates intact. Per ZOD039 § /clear, only the agent's
195
+ // session memory is wiped — thread-routing state (participants /
196
+ // root-mentions) must survive so the next bare reply still routes to
197
+ // the most-recently-posting agent under the same sessionKey.
198
+ continue
199
+ }
200
+ if (evt.type === 'eco.zoon.interrupt') {
201
+ const content = (evt.content ?? {}) as { session_id?: string; reason?: string }
202
+ // Thread-relation form (client-friendly): /interrupt in a thread sends
203
+ // an empty event with `m.relates_to: thread/<root>`. Cancel every
204
+ // session whose threadRoot matches.
205
+ const relates = evt.content?.['m.relates_to'] as
206
+ | { rel_type?: string; event_id?: string }
207
+ | undefined
208
+ const threadRoot =
209
+ relates?.rel_type === 'm.thread' && relates.event_id ? relates.event_id : undefined
210
+ if (threadRoot) {
211
+ const targets: Array<{ sessionId: string; agent: string }> = []
212
+ for (const [sessionId, ctx] of sessions) {
213
+ if (ctx.threadRoot === threadRoot) {
214
+ targets.push({ sessionId, agent: ctx.agent.name })
215
+ }
216
+ }
217
+ for (const t of targets) {
218
+ console.log(
219
+ `[matrix] interrupt session=${t.sessionId} agent=${t.agent} thread=${threadRoot}` +
220
+ (content.reason ? ` reason=${content.reason}` : ''),
221
+ )
222
+ await agents.cancelSession(t.agent, t.sessionId).catch((err) => {
223
+ console.error(`[matrix] cancelSession(${t.agent}, ${t.sessionId}) failed:`, err)
224
+ })
225
+ }
226
+ continue
227
+ }
228
+ // Legacy form: explicit session_id in content.
229
+ if (!content.session_id) {
230
+ console.warn(`[matrix] eco.zoon.interrupt missing session_id (event_id=${evt.event_id})`)
231
+ continue
232
+ }
233
+ const ctx = sessions.get(content.session_id)
234
+ if (!ctx) {
235
+ continue
236
+ }
237
+ console.log(
238
+ `[matrix] interrupt session=${content.session_id} agent=${ctx.agent.name}` +
239
+ (content.reason ? ` reason=${content.reason}` : ''),
240
+ )
241
+ await agents.cancelSession(ctx.agent.name, content.session_id).catch((err) => {
242
+ console.error(`[matrix] cancelSession(${ctx.agent.name}, ${content.session_id}) failed:`, err)
243
+ })
244
+ continue
245
+ }
246
+ if (evt.type === 'eco.zoon.approval_response') {
247
+ const content = (evt.content ?? {}) as {
248
+ approval_id?: string
249
+ session_id?: string
250
+ decision?: string
251
+ option_id?: string
252
+ }
253
+ if (!content.session_id || !content.approval_id || !content.decision) continue
254
+ const decision = content.option_id
255
+ ? { decision: content.decision, optionId: content.option_id }
256
+ : { decision: content.decision }
257
+ const ok = approvals.resolve(
258
+ content.session_id,
259
+ content.approval_id,
260
+ decision as never,
261
+ )
262
+ if (!ok) console.warn(`[matrix] unknown approval ${content.approval_id}`)
263
+ continue
264
+ }
265
+ logInbound(evt)
266
+ // Agent-promotion: top-level inbound event becomes the thread root.
267
+ // For in-thread messages the existing root is preserved.
268
+ const promotedRoot = inboundThreadRoot(evt) ?? evt.event_id
269
+ // Self-heal: if this is a thread reply but we have no in-memory state
270
+ // for the root (e.g. daemon was just restarted), reconstruct it by
271
+ // fetching the thread root + relations from the server.
272
+ const inboundRel = inboundThreadRoot(evt)
273
+ if (
274
+ evt.type === 'm.room.message' &&
275
+ inboundRel &&
276
+ !threadStates.has(inboundRel) &&
277
+ evt.room_id
278
+ ) {
279
+ try {
280
+ const rebuilt = await rebuildThreadState(client, evt.room_id, inboundRel, bindings)
281
+ threadStates.set(inboundRel, rebuilt)
282
+ console.log(
283
+ `[matrix] rebuilt threadState for ${inboundRel}: participants=${rebuilt.participants.join(',')} rootMentions=${rebuilt.rootMentions.join(',')}`,
284
+ )
285
+ } catch (err) {
286
+ console.warn(`[matrix] failed to rebuild threadState for ${inboundRel}:`, err)
287
+ }
288
+ }
289
+ const matches = route(evt, bindings, threadStates)
290
+ // Suppress the no-match warning for events sent by our own bots.
291
+ const senderIsBot = bindings.some((b) => b.userId === evt.sender)
292
+ if (evt.type === 'm.room.message' && matches.length === 0 && !senderIsBot) {
293
+ console.warn(
294
+ `[matrix] no agent matched message in ${evt.room_id} from ${evt.sender}` +
295
+ ` (bindings: ${bindings.map((b) => `${b.name}@${b.userId}[${b.trigger}]`).join(', ')})`,
296
+ )
297
+ }
298
+ // Seed thread state for any agent mentions in this event.
299
+ if (matches.length > 0 && promotedRoot) {
300
+ let st = threadStates.get(promotedRoot)
301
+ if (!st) {
302
+ st = { participants: [], rootMentions: [] }
303
+ threadStates.set(promotedRoot, st)
304
+ }
305
+ const msgMentions = new Set(extractMentions(evt as never))
306
+ for (const a of bindings) {
307
+ if (msgMentions.has(a.userId) && !st.rootMentions.includes(a.name)) {
308
+ st.rootMentions.push(a.name)
309
+ }
310
+ }
311
+ }
312
+ for (const a of matches) {
313
+ console.log(`[matrix] → ${a.name} (${a.userId})`)
314
+ void runTurn(a, evt)
315
+ .then(() => {
316
+ if (!promotedRoot) return
317
+ let st = threadStates.get(promotedRoot)
318
+ if (!st) {
319
+ st = { participants: [], rootMentions: [] }
320
+ threadStates.set(promotedRoot, st)
321
+ }
322
+ if (st.participants.at(-1) !== a.name) st.participants.push(a.name)
323
+ })
324
+ .catch((err) => {
325
+ console.error(`[matrix] runTurn failed for ${a.name}:`, err)
326
+ })
327
+ }
328
+ }
329
+ return c.json({})
330
+ })
331
+
332
+ app.get('/_matrix/app/v1/users/:userId', (c) => {
333
+ if (!authOk(c.req.header('authorization'))) {
334
+ return c.json({ errcode: 'M_FORBIDDEN' }, 403)
335
+ }
336
+ return c.json({})
337
+ })
338
+ app.get('/_matrix/app/v1/rooms/:alias', (c) => {
339
+ if (!authOk(c.req.header('authorization'))) {
340
+ return c.json({ errcode: 'M_FORBIDDEN' }, 403)
341
+ }
342
+ return c.json({ errcode: 'M_NOT_FOUND' }, 404)
343
+ })
344
+ app.post('/_matrix/app/v1/ping', (c) => {
345
+ if (!authOk(c.req.header('authorization'))) {
346
+ return c.json({ errcode: 'M_FORBIDDEN' }, 403)
347
+ }
348
+ return c.json({})
349
+ })
350
+ app.get('/healthz', (c) => c.text('ok'))
351
+
352
+ async function runTurn(agent: AgentBinding, evt: MatrixEvent): Promise<void> {
353
+ if (!evt.room_id || !evt.event_id) return
354
+ const inbound = inboundThreadRoot(evt)
355
+ // Agent-promotion: top-level inbound becomes a thread root via the agent's
356
+ // first reply. sessionKey is always a thread root id, never the room id.
357
+ const threadRoot = inbound ?? evt.event_id
358
+ const sessionKey = threadRoot
359
+ const sessionId = await agents.ensureSession(agent.name, sessionKey, evt.room_id)
360
+ sessions.set(sessionId, { agent, roomId: evt.room_id, threadRoot })
361
+ buffers.set(sessionId, '')
362
+
363
+ const roomId = evt.room_id
364
+ const TYPING_TTL_MS = 30_000
365
+ const TYPING_REFRESH_MS = 25_000
366
+ const safeTyping = (typing: boolean) =>
367
+ client
368
+ .setTyping({ roomId, asUserId: agent.userId, typing, timeoutMs: TYPING_TTL_MS })
369
+ .catch((err) => console.warn(`[matrix:${agent.name}] setTyping(${typing}) failed:`, err))
370
+ const safePresence = (presence: 'online' | 'unavailable' | 'offline') =>
371
+ client
372
+ .setPresence({ asUserId: agent.userId, presence })
373
+ .catch((err) =>
374
+ console.warn(`[matrix:${agent.name}] setPresence(${presence}) failed:`, err),
375
+ )
376
+
377
+ await safeTyping(true)
378
+ await safePresence('unavailable')
379
+ const refresh = setInterval(() => {
380
+ void safeTyping(true)
381
+ }, TYPING_REFRESH_MS)
382
+
383
+ try {
384
+ const rawBody = evt.content?.body ?? ''
385
+ const promptText = stripMention(rawBody, agent.userId)
386
+ await agents.prompt(agent.name, {
387
+ threadId: sessionKey,
388
+ channelId: evt.room_id,
389
+ content: [{ type: 'text', text: promptText }],
390
+ })
391
+ const text = buffers.get(sessionId) ?? ''
392
+ if (text.length > 0) {
393
+ const html = toMatrixHtml(text)
394
+ const content: { msgtype: string; body: string; [k: string]: unknown } = {
395
+ msgtype: 'm.text',
396
+ body: text,
397
+ }
398
+ // Only attach formatted_body when it adds rich-text the plain body
399
+ // can't carry. marked wraps plain prose in <p>…</p>; if that's all
400
+ // we'd add, skip — most clients render `body` better than a stripped
401
+ // re-encode.
402
+ if (html) {
403
+ const escapedPlain =
404
+ '<p>' +
405
+ text
406
+ .replace(/&/g, '&amp;')
407
+ .replace(/</g, '&lt;')
408
+ .replace(/>/g, '&gt;') +
409
+ '</p>'
410
+ const norm = (s: string) => s.replace(/\s+/g, ' ').trim()
411
+ if (norm(html) !== norm(escapedPlain)) {
412
+ content.format = 'org.matrix.custom.html'
413
+ content.formatted_body = html
414
+ }
415
+ }
416
+ await client.sendMessage({
417
+ roomId: evt.room_id,
418
+ asUserId: agent.userId,
419
+ content,
420
+ threadRoot, // every reply threads, full stop
421
+ })
422
+ } else {
423
+ console.warn(
424
+ `[matrix:${agent.name}] turn finished with empty buffer (session=${sessionId}); nothing sent to ${evt.room_id}`,
425
+ )
426
+ }
427
+ } finally {
428
+ clearInterval(refresh)
429
+ await safeTyping(false)
430
+ await safePresence('online')
431
+ buffers.delete(sessionId)
432
+ }
433
+ }
434
+
435
+ return {
436
+ app,
437
+ bootstrap: async (bootstrapOpts: { spaceRoomId?: string; asUserId?: string } = {}) => {
438
+ await pool.bootstrap({ adminUserId, ...bootstrapOpts })
439
+ await Promise.allSettled(
440
+ bindings.map((b) =>
441
+ client.setPresence({ asUserId: b.userId, presence: 'online' }).catch((err) => {
442
+ console.warn(`[matrix:${b.name}] initial setPresence(online) failed:`, err)
443
+ }),
444
+ ),
445
+ )
446
+ },
447
+ pool,
448
+ }
449
+ }
450
+
451
+ /**
452
+ * Reconstruct the in-memory ThreadState for a thread root by fetching the
453
+ * root event + its thread relations from the server. Used to recover the
454
+ * implicit-routing rule from ZOD039 § Implicit triggers in threads after a
455
+ * daemon restart wipes the in-memory cache.
456
+ */
457
+ export async function rebuildThreadState(
458
+ client: MatrixClient,
459
+ roomId: string,
460
+ rootEventId: string,
461
+ bindings: AgentBinding[],
462
+ ): Promise<ThreadState> {
463
+ const state: ThreadState = { participants: [], rootMentions: [] }
464
+ // Impersonate an agent that's actually a member of this room (AS reads
465
+ // require room membership). Falling through to the first binding would
466
+ // 403 if that agent never joined the target room.
467
+ const asUser = (bindings.find((b) => b.rooms.includes(roomId)) ?? bindings[0])?.userId
468
+ if (!asUser) return state
469
+
470
+ const root = await client.fetchEvent(roomId, rootEventId, asUser)
471
+ if (root) {
472
+ const rootMentions = new Set(extractMentions(root as never))
473
+ for (const a of bindings) {
474
+ if (rootMentions.has(a.userId) && !state.rootMentions.includes(a.name)) {
475
+ state.rootMentions.push(a.name)
476
+ }
477
+ }
478
+ }
479
+
480
+ const { chunk: thread } = await client.fetchThreadRelations({
481
+ roomId,
482
+ rootEventId,
483
+ asUserId: asUser,
484
+ })
485
+ // Also seed root-mentions from any subsequent agent @mentions in the thread.
486
+ for (const ev of thread) {
487
+ const mentions = new Set(extractMentions(ev as never))
488
+ for (const a of bindings) {
489
+ if (mentions.has(a.userId) && !state.rootMentions.includes(a.name)) {
490
+ state.rootMentions.push(a.name)
491
+ }
492
+ }
493
+ const sender = (ev as { sender?: string }).sender
494
+ const type = (ev as { type?: string }).type
495
+ if (type === 'm.room.message' && sender) {
496
+ const a = bindings.find((b) => b.userId === sender)
497
+ if (a && state.participants.at(-1) !== a.name) state.participants.push(a.name)
498
+ }
499
+ }
500
+ return state
501
+ }
502
+
503
+ function logInbound(evt: MatrixEvent): void {
504
+ const sender = evt.sender ?? '?'
505
+ const room = evt.room_id ?? '?'
506
+ const type = evt.type ?? '?'
507
+ if (type === 'm.room.message') {
508
+ const body = evt.content?.body ?? ''
509
+ const mentions = (evt.content?.['m.mentions'] as { user_ids?: string[] } | undefined)?.user_ids
510
+ const mentionsStr = mentions?.length ? ` mentions=${JSON.stringify(mentions)}` : ''
511
+ console.log(
512
+ `[matrix] inbound msg in ${room} from ${sender}${mentionsStr}: ${truncate(body, 200)}`,
513
+ )
514
+ } else {
515
+ console.log(`[matrix] inbound ${type} in ${room} from ${sender}`)
516
+ }
517
+ }
518
+
519
+ function truncate(s: string, n: number): string {
520
+ return s.length > n ? s.slice(0, n) + '…' : s
521
+ }
@@ -0,0 +1,76 @@
1
+ import { describe, expect, it, vi } from 'vitest'
2
+ import { MatrixClient } from './matrix-client.js'
3
+ import { buildWorkforceRoster, publishWorkforce } from './workforce-publisher.js'
4
+ import type { AgentBinding } from './router.js'
5
+
6
+ const agents: AgentBinding[] = [
7
+ { name: 'planner', userId: '@planner:zoon.local', rooms: ['!eng:zoon.local'], trigger: 'mention' },
8
+ { name: 'reviewer', userId: '@reviewer:zoon.local', rooms: ['!eng:zoon.local', '!review:zoon.local'], trigger: 'any' },
9
+ ]
10
+
11
+ describe('buildWorkforceRoster', () => {
12
+ it('emits version, agents list with user_id/name/rooms', () => {
13
+ const roster = buildWorkforceRoster(agents)
14
+ expect(roster).toEqual({
15
+ version: 1,
16
+ agents: [
17
+ { user_id: '@planner:zoon.local', name: 'planner', rooms: ['!eng:zoon.local'] },
18
+ { user_id: '@reviewer:zoon.local', name: 'reviewer', rooms: ['!eng:zoon.local', '!review:zoon.local'] },
19
+ ],
20
+ })
21
+ })
22
+
23
+ it('handles empty workforce', () => {
24
+ expect(buildWorkforceRoster([])).toEqual({ version: 1, agents: [] })
25
+ })
26
+ })
27
+
28
+ describe('publishWorkforce', () => {
29
+ it('PUTs eco.zoon.workforce state event on the configured space', async () => {
30
+ const fetch = vi.fn(async () => new Response('{}', { status: 200 }))
31
+ const client = new MatrixClient({
32
+ homeserver: 'https://hs.zoon.local',
33
+ asToken: 'as-tok',
34
+ fetch: fetch as unknown as typeof globalThis.fetch,
35
+ })
36
+
37
+ await publishWorkforce({
38
+ client,
39
+ spaceRoomId: '!space:zoon.local',
40
+ asUserId: '@zooid:zoon.local',
41
+ agents,
42
+ })
43
+
44
+ expect(fetch).toHaveBeenCalledTimes(1)
45
+ const [url, init] = fetch.mock.calls[0]!
46
+ expect(url).toBe(
47
+ 'https://hs.zoon.local/_matrix/client/v3/rooms/!space%3Azoon.local/state/eco.zoon.workforce/?user_id=%40zooid%3Azoon.local',
48
+ )
49
+ expect(init?.method).toBe('PUT')
50
+ expect(init?.headers).toMatchObject({ Authorization: 'Bearer as-tok' })
51
+ expect(JSON.parse(init?.body as string)).toEqual({
52
+ version: 1,
53
+ agents: [
54
+ { user_id: '@planner:zoon.local', name: 'planner', rooms: ['!eng:zoon.local'] },
55
+ { user_id: '@reviewer:zoon.local', name: 'reviewer', rooms: ['!eng:zoon.local', '!review:zoon.local'] },
56
+ ],
57
+ })
58
+ })
59
+
60
+ it('throws on non-2xx', async () => {
61
+ const fetch = vi.fn(async () => new Response('forbidden', { status: 403 }))
62
+ const client = new MatrixClient({
63
+ homeserver: 'https://hs.zoon.local',
64
+ asToken: 'as-tok',
65
+ fetch: fetch as unknown as typeof globalThis.fetch,
66
+ })
67
+ await expect(
68
+ publishWorkforce({
69
+ client,
70
+ spaceRoomId: '!space:zoon.local',
71
+ asUserId: '@zooid:zoon.local',
72
+ agents,
73
+ }),
74
+ ).rejects.toThrow(/403/)
75
+ })
76
+ })
@@ -0,0 +1,53 @@
1
+ import { MatrixClient } from './matrix-client.js'
2
+ import type { AgentBinding } from './router.js'
3
+
4
+ export interface WorkforceRoster {
5
+ version: 1
6
+ agents: { user_id: string; name: string; rooms: string[] }[]
7
+ }
8
+
9
+ export function buildWorkforceRoster(agents: AgentBinding[]): WorkforceRoster {
10
+ return {
11
+ version: 1,
12
+ agents: agents.map((a) => ({ user_id: a.userId, name: a.name, rooms: a.rooms })),
13
+ }
14
+ }
15
+
16
+ export interface PublishOpts {
17
+ client: MatrixClient
18
+ spaceRoomId: string
19
+ asUserId: string
20
+ agents: AgentBinding[]
21
+ }
22
+
23
+ export async function publishWorkforce(opts: PublishOpts): Promise<void> {
24
+ await opts.client.sendStateEvent({
25
+ roomId: opts.spaceRoomId,
26
+ asUserId: opts.asUserId,
27
+ eventType: 'eco.zoon.workforce',
28
+ stateKey: '',
29
+ content: buildWorkforceRoster(opts.agents) as unknown as Record<string, unknown>,
30
+ })
31
+ }
32
+
33
+ export interface PublisherHandle {
34
+ reload(): Promise<void>
35
+ stop(): Promise<void>
36
+ }
37
+
38
+ export interface StartOpts {
39
+ client: MatrixClient
40
+ spaceRoomId: string
41
+ asUserId: string
42
+ getAgents: () => AgentBinding[]
43
+ }
44
+
45
+ export async function startWorkforcePublisher(opts: StartOpts): Promise<PublisherHandle> {
46
+ await publishWorkforce({ ...opts, agents: opts.getAgents() })
47
+ return {
48
+ async reload() {
49
+ await publishWorkforce({ ...opts, agents: opts.getAgents() })
50
+ },
51
+ async stop() {},
52
+ }
53
+ }