zooid 0.13.0 → 0.14.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.
@@ -1,5 +1,5 @@
1
1
  import { readFileSync } from 'node:fs'
2
- import { mkdir, unlink } from 'node:fs/promises'
2
+ import { mkdir } from 'node:fs/promises'
3
3
  import { tmpdir } from 'node:os'
4
4
  import { dirname, isAbsolute, join, resolve } from 'node:path'
5
5
  import type { AddressInfo } from 'node:net'
@@ -26,16 +26,19 @@ import {
26
26
  type PublisherHandle,
27
27
  type SyncLoop,
28
28
  } from '@zooid/transport-matrix'
29
- import {
30
- SpawnRegistry,
31
- startDaemonSocketServer,
32
- type DaemonSocketHandle,
33
- } from '@zooid/context-mcp'
34
- import { buildAcpRegistry } from '../build-registry.js'
29
+ import { SpawnRegistry, startAgentSocketServers, type AgentSocketsHandle } from '@zooid/context-mcp'
30
+ import { buildAcpRegistry, contextEligibleAgents } from '../build-registry.js'
31
+ import { installPiExtension, resolvePiAgentDir } from '../pi-extension-install.js'
32
+ import { resolvePiExtensionBundle } from '@zooid/pi-extension'
35
33
  import { prepullImages } from '../prepull-images.js'
36
34
  import { mountPushGateway } from '../push-gateway/index.js'
37
35
  import { makeSyncCursorStore } from './sync-cursors.js'
36
+ import { makeTaskJournal } from './task-journal.js'
38
37
  import { shouldBindHttpListener } from './pull-wiring.js'
38
+ import { startTriggerScheduler, validateCron } from './trigger-scheduler.js'
39
+ import { mountWebhookRoutes, WEBHOOK_ROUTE_PREFIX } from './webhook-routes.js'
40
+ import { loadCustomVerifiers } from './load-custom-verifiers.js'
41
+ import { joinTriggerRooms } from './trigger-rooms.js'
39
42
 
40
43
  export interface StartDaemonOpts {
41
44
  configPath?: string
@@ -103,44 +106,73 @@ function closeAsync(server: ServerType): Promise<void> {
103
106
  })
104
107
  }
105
108
 
109
+ function localpart(userId: string): string {
110
+ const m = /^@([^:]+):/.exec(userId)
111
+ if (!m) throw new Error(`bad user id: ${userId}`)
112
+ return m[1]!
113
+ }
114
+
106
115
  export async function startDaemon(opts: StartDaemonOpts = {}): Promise<DaemonHandle> {
107
116
  const cwd = opts.cwd ?? process.cwd()
108
117
  const found = opts.configPath ? { path: opts.configPath } : findConfigFile(cwd)
109
118
  if (!found) throw new Error('zooid.yaml is required')
110
119
  const configDir = dirname(found.path)
111
- const base = loadZooidConfig(readFileSync(found.path, 'utf8'), { configDir })
120
+ const base = loadZooidConfig(readFileSync(found.path, 'utf8'), { configDir, validateCron })
112
121
  const config = mergeCliFlags(base, opts.cliFlags ?? {})
113
122
 
114
123
  const approvals = new ApprovalCorrelator()
115
124
 
116
- const daemonSockPath = opts.agentsDir
117
- ? join(opts.agentsDir, '..', 'run', 'context.sock')
118
- : join(tmpdir(), `zooid-context-${process.pid}.sock`)
119
- await mkdir(dirname(daemonSockPath), { recursive: true }).catch(() => {})
125
+ const runDir = opts.agentsDir
126
+ ? join(opts.agentsDir, '..', 'run')
127
+ : join(tmpdir(), `zooid-context-${process.pid}`)
128
+ await mkdir(runDir, { recursive: true }).catch(() => {})
120
129
  const contextSpawnRegistry = new SpawnRegistry()
121
- let contextSocket: DaemonSocketHandle | null = null
122
- try {
123
- contextSocket = await startDaemonSocketServer({
124
- sockPath: daemonSockPath,
125
- registry: contextSpawnRegistry,
126
- })
127
- } catch (err) {
128
- console.warn('[context] daemon socket startup failed; zooid-context MCP disabled:', err)
129
- }
130
+ const contextSockets: AgentSocketsHandle = await startAgentSocketServers({
131
+ runDir,
132
+ registry: contextSpawnRegistry,
133
+ agentNames: contextEligibleAgents(config),
134
+ })
130
135
 
131
136
  const dataDir = opts.agentsDir ? dirname(opts.agentsDir) : undefined
132
137
  const registry = buildAcpRegistry(config, {
133
138
  approvals,
134
139
  onTap: opts.onTap,
135
140
  agentsDir: opts.agentsDir,
136
- contextSpawnRegistry: contextSocket ? contextSpawnRegistry : undefined,
137
- daemonSockPath: contextSocket ? daemonSockPath : undefined,
141
+ contextSpawnRegistry,
142
+ daemonSockPaths: contextSockets.paths,
138
143
  configDir,
139
144
  dataDir,
140
145
  daemonHome: process.env.HOME,
141
146
  })
142
147
  const agentNames = Object.keys(config.agents)
143
148
 
149
+ const piAgents = agentNames.filter(
150
+ (name) => (config.agents[name].acp as { preset?: string } | undefined)?.preset === 'pi',
151
+ )
152
+ if (piAgents.length > 0) {
153
+ const bundlePath = resolvePiExtensionBundle()
154
+ // PI_CODING_AGENT_DIR is normally relative, so each agent gets its own
155
+ // extensions dir; an absolute value (or none) collapses them into one.
156
+ const installed = new Set<string>()
157
+ for (const name of piAgents) {
158
+ const { dir, scope } = resolvePiAgentDir({
159
+ agentWorkdir: resolve(configDir, config.agents[name].workdir),
160
+ daemonHome: process.env.HOME ?? '',
161
+ env: process.env,
162
+ })
163
+ if (installed.has(dir)) continue
164
+ installed.add(dir)
165
+ const result = installPiExtension({
166
+ agentDir: dir,
167
+ bundlePath,
168
+ createMissing: scope === 'project',
169
+ })
170
+ const where = result.target ? ` extension=${result.target}` : ''
171
+ const why = result.reason ? ` reason=${result.reason}` : ''
172
+ console.log(`[pi] agent=${name}${where} status=${result.status}${why}`)
173
+ }
174
+ }
175
+
144
176
  if (config.runtime !== 'local') {
145
177
  await prepullImages(registry, {
146
178
  engine: config.runtime === 'podman' ? 'podman' : 'docker',
@@ -151,16 +183,14 @@ export async function startDaemon(opts: StartDaemonOpts = {}): Promise<DaemonHan
151
183
  })
152
184
  }
153
185
 
154
- console.log(
155
- `[context] socket=${daemonSockPath} ` +
156
- `status=${contextSocket ? 'listening' : 'disabled'} ` +
157
- `agents={${agentNames
158
- .map((n) => `${n}:${registry.hasContextSpawn(n) ? 'yes' : 'no'}`)
159
- .join(', ')}}`,
160
- )
186
+ console.log(`[context] runDir=${runDir}`)
187
+ for (const name of agentNames) {
188
+ console.log(`[context] agent=${name} socket=${contextSockets.paths[name] ?? '(disabled)'}`)
189
+ }
161
190
 
162
191
  let server: ServerType | null = null
163
192
  let syncLoops: SyncLoop[] | undefined
193
+ let triggers: { stop(): Promise<void> } | undefined
164
194
  let stopped = false
165
195
  let resolveStopped!: () => void
166
196
  const whenStopped = new Promise<void>((r) => {
@@ -209,8 +239,11 @@ export async function startDaemon(opts: StartDaemonOpts = {}): Promise<DaemonHan
209
239
  // `:` is the homeserver's server_name. Fall back to the homeserver URL's
210
240
  // host if the namespace shape is unexpected.
211
241
  const serverName =
212
- matrix.transport.user_namespace.split(':').slice(1).join(':').replace(/\\?\)?$/, '') ||
213
- new URL(matrix.transport.homeserver).hostname
242
+ matrix.transport.user_namespace
243
+ .split(':')
244
+ .slice(1)
245
+ .join(':')
246
+ .replace(/\\?\)?$/, '') || new URL(matrix.transport.homeserver).hostname
214
247
  const asUserId = `@${matrix.transport.sender_localpart}:${serverName}`
215
248
  // Pull mode's loadSince/saveSince are keyed by MXID; the cursor store is
216
249
  // keyed by agent name. Translate via the bindings we just built.
@@ -224,6 +257,7 @@ export async function startDaemon(opts: StartDaemonOpts = {}): Promise<DaemonHan
224
257
  adminUserId: opts.adminUserId,
225
258
  botUserId: asUserId,
226
259
  media: mediaClient,
260
+ taskJournal: dataDir ? makeTaskJournal(dataDir) : undefined,
227
261
  mode,
228
262
  loadSince: (uid) => {
229
263
  const name = nameByUserId.get(uid)
@@ -234,6 +268,26 @@ export async function startDaemon(opts: StartDaemonOpts = {}): Promise<DaemonHan
234
268
  if (name && cursors) cursors.saveSince(name, since)
235
269
  },
236
270
  })
271
+ contextSpawnRegistry.setTaskActions(transport.taskActions)
272
+
273
+ // Declared here, assigned after the listener is up. The closures below
274
+ // read it at call time, so they can be built before the space exists.
275
+ let spaceRoomId: string | undefined
276
+ const agentUserIds = Object.fromEntries(bindings.map((b) => [b.name, b.userId]))
277
+ const resolveRoom = async (r: string): Promise<string | null> =>
278
+ r.startsWith('!') ? r : await client.resolveAlias(r)
279
+ const ensureBot = async (mxid: string, roomId: string): Promise<void> => {
280
+ await client.registerBot(localpart(mxid)).catch(() => {})
281
+ // Agent rooms are created restricted to workforce-space members
282
+ // (BotPool.bootstrap): a trigger bot needs space membership too,
283
+ // or its join to the target room 403s.
284
+ if (spaceRoomId) {
285
+ await client.invite({ roomId: spaceRoomId, asUserId, targetUserId: mxid }).catch(() => {})
286
+ await client.joinRoom(spaceRoomId, mxid).catch(() => {})
287
+ }
288
+ await client.joinRoom(roomId, mxid)
289
+ }
290
+
237
291
  if (shouldBindHttpListener(mode)) {
238
292
  const requestedPort = matrix.transport.port ?? 9000
239
293
  // The gateway rides the appservice listener, not webStatic: webStatic
@@ -246,10 +300,42 @@ export async function startDaemon(opts: StartDaemonOpts = {}): Promise<DaemonHan
246
300
  subject: `https://${serverName}`,
247
301
  }).publicKey
248
302
  }
303
+ // Webhook ingress rides this same listener, alongside the push gateway.
304
+ // A separate port would buy no isolation — same process — and the AS
305
+ // transaction endpoint is already internet-reachable under [ZOD063].
306
+ // What matters is that each route authenticates its own callers:
307
+ // hs_token for transactions, HMAC over the raw body for webhooks.
308
+ //
309
+ // MUST be mounted before serve(): Hono builds its route matcher on the
310
+ // first request and then refuses new routes ("matcher is already
311
+ // built"), and the AS transaction endpoint starts taking pushes during
312
+ // bootstrap below.
313
+ const webhookTriggers = Object.entries(config.triggers).filter(([, t]) => t.webhook)
314
+ if (webhookTriggers.length > 0) {
315
+ // Imported before serving so a bad `verify:` path fails the daemon
316
+ // start, not the first delivery.
317
+ const customVerifiers = await loadCustomVerifiers(config.triggers)
318
+ mountWebhookRoutes(transport.app, {
319
+ triggers: config.triggers,
320
+ customVerifiers,
321
+ agentUserIds,
322
+ resolveRoom,
323
+ ensureBot,
324
+ sendMessage: (m) => client.sendMessage(m),
325
+ })
326
+ for (const [name] of webhookTriggers) {
327
+ console.log(`[webhook] POST ${WEBHOOK_ROUTE_PREFIX}/${name}`)
328
+ }
329
+ }
330
+
249
331
  // Bind 0.0.0.0 explicitly — @hono/node-server defaults to IPv6-only on
250
332
  // macOS, which Docker's NAT bridge can't reach when Tuwunel pushes AS
251
333
  // events back to host.docker.internal:<port>.
252
- server = serve({ fetch: transport.app.fetch, port: requestedPort, hostname: '0.0.0.0' })
334
+ server = serve({
335
+ fetch: transport.app.fetch,
336
+ port: requestedPort,
337
+ hostname: '0.0.0.0',
338
+ })
253
339
  port = await listenAsync(server)
254
340
  } else {
255
341
  // Pull (client) mode runs outbound /sync loops; no inbound listener.
@@ -257,7 +343,6 @@ export async function startDaemon(opts: StartDaemonOpts = {}): Promise<DaemonHan
257
343
  }
258
344
  const spaceLocalpart = matrix.transport.space ?? 'dev'
259
345
  const adminUserIds = opts.adminUserId ? [opts.adminUserId] : []
260
- let spaceRoomId: string | undefined
261
346
  try {
262
347
  spaceRoomId = await ensureWorkforceSpace({
263
348
  client,
@@ -268,7 +353,9 @@ export async function startDaemon(opts: StartDaemonOpts = {}): Promise<DaemonHan
268
353
  admins: adminUserIds,
269
354
  joinRule: opts.publicWorkforceSpace ? 'public' : 'invite',
270
355
  })
271
- console.log(`[matrix] ensured workforce space #${spaceLocalpart}:${serverName} → ${spaceRoomId}`)
356
+ console.log(
357
+ `[matrix] ensured workforce space #${spaceLocalpart}:${serverName} → ${spaceRoomId}`,
358
+ )
272
359
  } catch (err) {
273
360
  console.warn('[matrix] workforce space provisioning failed:', err)
274
361
  }
@@ -281,6 +368,35 @@ export async function startDaemon(opts: StartDaemonOpts = {}): Promise<DaemonHan
281
368
  // m.space.child while joining it.
282
369
  await transport.bootstrap({ spaceRoomId, asUserId, adminUserIds })
283
370
 
371
+ if (Object.keys(config.triggers).length > 0) {
372
+ // Join every trigger's rooms up front: a lazy join inside a webhook's
373
+ // 10s delivery window can 403 after the 202 already went out, silently
374
+ // losing the message.
375
+ await joinTriggerRooms({ triggers: config.triggers, resolveRoom, ensureBot })
376
+ console.log(
377
+ `[trigger] joined rooms for ${Object.keys(config.triggers).length} trigger(s)`,
378
+ )
379
+
380
+ triggers = startTriggerScheduler({
381
+ triggers: config.triggers,
382
+ agentUserIds,
383
+ resolveRoom,
384
+ ensureBot,
385
+ sendMessage: (m) => client.sendMessage(m),
386
+ })
387
+ const scheduled = Object.values(config.triggers).filter((t) => t.schedule).length
388
+ if (scheduled > 0) console.log(`[trigger] scheduled ${scheduled} trigger(s)`)
389
+ if (!shouldBindHttpListener(mode)) {
390
+ const webhooks = Object.values(config.triggers).filter((t) => t.webhook).length
391
+ if (webhooks > 0) {
392
+ console.warn(
393
+ `[webhook] ${webhooks} webhook trigger(s) configured, but pull mode binds no ` +
394
+ `inbound listener — these will never fire.`,
395
+ )
396
+ }
397
+ }
398
+ }
399
+
284
400
  // Pull mode: start the outbound /sync loops after bootstrap so the rooms
285
401
  // each agent syncs already exist. Loops long-poll until stop().
286
402
  syncLoops = transport.syncLoops
@@ -321,13 +437,22 @@ export async function startDaemon(opts: StartDaemonOpts = {}): Promise<DaemonHan
321
437
  const token = process.env.ZOOID_TOKEN
322
438
  if (!token) throw new Error('ZOOID_TOKEN is required for http transport')
323
439
  const app = createApp({ agents: registry, approvals, token })
324
- server = serve({ fetch: app.fetch, port: http.transport.port, hostname: '0.0.0.0' })
440
+ server = serve({
441
+ fetch: app.fetch,
442
+ port: http.transport.port,
443
+ hostname: '0.0.0.0',
444
+ })
325
445
  port = await listenAsync(server)
326
446
  }
327
447
 
328
448
  const stop = async (): Promise<void> => {
329
449
  if (stopped) return whenStopped
330
450
  stopped = true
451
+ try {
452
+ await triggers?.stop()
453
+ } catch {
454
+ // swallow
455
+ }
331
456
  try {
332
457
  if (syncLoops) for (const loop of syncLoops) loop.stop()
333
458
  } catch {
@@ -344,8 +469,7 @@ export async function startDaemon(opts: StartDaemonOpts = {}): Promise<DaemonHan
344
469
  console.error('stopAll:', err)
345
470
  }
346
471
  try {
347
- if (contextSocket) await contextSocket.close()
348
- await unlink(daemonSockPath).catch(() => {})
472
+ await contextSockets.close()
349
473
  } catch {
350
474
  // swallow
351
475
  }
@@ -0,0 +1,19 @@
1
+ import { afterEach, describe, expect, it } from 'vitest'
2
+ import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'
3
+ import { tmpdir } from 'node:os'
4
+ import { join } from 'node:path'
5
+ import { makeTaskJournal } from './task-journal.js'
6
+
7
+ const dirs: string[] = []
8
+ afterEach(() => dirs.splice(0).forEach((dir) => rmSync(dir, { recursive: true, force: true })))
9
+ const makeDir = () => { const dir = mkdtempSync(join(tmpdir(), 'zooid-task-')); dirs.push(dir); return dir }
10
+ const row = { taskId: 't', attemptId: 't', roomId: '!r', assignee: 'a', notify: 'caller' as const, parent: { agent: 'p', threadRoot: '$p', sessionKey: '$p', generation: 0 }, phase: 'open' as const, threadRoot: '$r', runId: 'run' }
11
+ describe('makeTaskJournal', () => {
12
+ it('round-trips data and tolerates corruption', () => {
13
+ const dir = makeDir(); const journal = makeTaskJournal(dir)
14
+ expect(journal.load()).toEqual([])
15
+ journal.save([row]); expect(journal.load()).toEqual([row])
16
+ writeFileSync(join(dir, 'tasks.json'), '{broken')
17
+ expect(journal.load()).toEqual([])
18
+ })
19
+ })
@@ -0,0 +1,32 @@
1
+ import { mkdirSync, readFileSync, renameSync, unlinkSync, writeFileSync } from 'node:fs'
2
+ import { join } from 'node:path'
3
+ import type { PersistedTask, TaskJournal } from '@zooid/transport-matrix'
4
+
5
+ interface JournalFile { version: 1; tasks: PersistedTask[] }
6
+
7
+ /** Durable, small task-state journal stored alongside daemon state. */
8
+ export function makeTaskJournal(dataDir: string): TaskJournal {
9
+ const path = join(dataDir, 'tasks.json')
10
+ return {
11
+ load() {
12
+ try {
13
+ const value = JSON.parse(readFileSync(path, 'utf8')) as JournalFile
14
+ return value.version === 1 && Array.isArray(value.tasks) ? value.tasks : []
15
+ } catch (error) {
16
+ if ((error as NodeJS.ErrnoException).code !== 'ENOENT') console.warn('[tasks] journal unavailable; starting empty:', error)
17
+ return []
18
+ }
19
+ },
20
+ save(tasks) {
21
+ mkdirSync(dataDir, { recursive: true })
22
+ const temp = `${path}.tmp-${process.pid}`
23
+ try {
24
+ writeFileSync(temp, JSON.stringify({ version: 1, tasks } satisfies JournalFile, null, 2), 'utf8')
25
+ renameSync(temp, path)
26
+ } catch (error) {
27
+ console.warn('[tasks] journal write failed:', error)
28
+ try { unlinkSync(temp) } catch {}
29
+ }
30
+ },
31
+ }
32
+ }
@@ -0,0 +1,60 @@
1
+ import { describe, it, expect, vi } from 'vitest'
2
+ import { joinTriggerRooms } from './trigger-rooms.js'
3
+ import { loadZooidConfig } from '@zooid/core'
4
+
5
+ const yaml = `
6
+ runtime: local
7
+ transports:
8
+ matrix:
9
+ type: matrix
10
+ homeserver: http://localhost:8448
11
+ as_token: t
12
+ hs_token: h
13
+ user_namespace: '@.*:example.org'
14
+ agents:
15
+ product:
16
+ acp: { preset: opencode }
17
+ matrix: { rooms: ["#product:example.org"] }
18
+ triggers:
19
+ github:
20
+ webhook: { provider: github, secret: "s" }
21
+ as: "@hook:example.org"
22
+ messages:
23
+ - { room: "#product:example.org", mention: product, text: 'a' }
24
+ - { room: "#ops:example.org", mention: product, text: 'b' }
25
+ weekly:
26
+ schedule: "0 6 * * 1"
27
+ as: "@cron:example.org"
28
+ room: "#product:example.org"
29
+ mention: product
30
+ text: 'c'
31
+ `
32
+
33
+ describe('joinTriggerRooms', () => {
34
+ it('joins every declared room of every trigger, once per (bot, room)', async () => {
35
+ const ensureBot = vi.fn(async () => {})
36
+ await joinTriggerRooms({
37
+ triggers: loadZooidConfig(yaml).triggers,
38
+ resolveRoom: async (r: string) => r,
39
+ ensureBot,
40
+ })
41
+ expect(ensureBot.mock.calls.sort()).toEqual(
42
+ [
43
+ ['@cron:example.org', '#product:example.org'],
44
+ ['@hook:example.org', '#ops:example.org'],
45
+ ['@hook:example.org', '#product:example.org'],
46
+ ].sort(),
47
+ )
48
+ })
49
+
50
+ it('reports a room it cannot join instead of throwing, so one bad room does not stop the daemon', async () => {
51
+ const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
52
+ await joinTriggerRooms({
53
+ triggers: loadZooidConfig(yaml).triggers,
54
+ resolveRoom: async (r: string) => (r === '#ops:example.org' ? null : r),
55
+ ensureBot: async () => {},
56
+ })
57
+ expect(warn.mock.calls.flat().join(' ')).toMatch(/#ops:example\.org/)
58
+ warn.mockRestore()
59
+ })
60
+ })
Binary file
@@ -0,0 +1,71 @@
1
+ import { describe, it, expect, vi } from 'vitest'
2
+ import { fireTrigger } from './trigger-runner.js'
3
+ import type { TriggerMessage } from '@zooid/core'
4
+
5
+ const message: TriggerMessage = {
6
+ room: '#ops:example.org',
7
+ mention: 'architect',
8
+ text: 'Check the pinned agent CLI versions.',
9
+ }
10
+
11
+ const deps = (over: Record<string, unknown> = {}) => ({
12
+ name: 'image-currency',
13
+ as: '@cron:example.org',
14
+ message,
15
+ agentUserId: '@architect:example.org',
16
+ resolveRoom: vi.fn(async () => '!room:example.org'),
17
+ ensureBot: vi.fn(async () => {}),
18
+ sendMessage: vi.fn(async () => ({ event_id: '$1' })),
19
+ ...over,
20
+ })
21
+
22
+ describe('fireTrigger', () => {
23
+ it('posts text verbatim as the trigger bot user', async () => {
24
+ const d = deps()
25
+ await fireTrigger(d as never)
26
+ expect(d.sendMessage).toHaveBeenCalledTimes(1)
27
+ const call = (d.sendMessage as ReturnType<typeof vi.fn>).mock.calls[0][0]
28
+ expect(call.roomId).toBe('!room:example.org')
29
+ expect(call.asUserId).toBe('@cron:example.org')
30
+ expect(call.content.body).toBe('Check the pinned agent CLI versions.')
31
+ expect(call.content.msgtype).toBe('m.text')
32
+ })
33
+
34
+ it('sets m.mentions.user_ids structurally — never a templated @name (§Design 3)', async () => {
35
+ const d = deps()
36
+ await fireTrigger(d as never)
37
+ const content = (d.sendMessage as ReturnType<typeof vi.fn>).mock.calls[0][0].content
38
+ expect(content['m.mentions']).toEqual({ user_ids: ['@architect:example.org'] })
39
+ expect(content.body).not.toContain('@architect')
40
+ })
41
+
42
+ it('posts at the top level, never into a thread — runTurn makes each firing a new root', async () => {
43
+ const d = deps()
44
+ await fireTrigger(d as never)
45
+ expect((d.sendMessage as ReturnType<typeof vi.fn>).mock.calls[0][0].threadRoot).toBeUndefined()
46
+ })
47
+
48
+ it('registers and joins the bot user before posting', async () => {
49
+ const d = deps()
50
+ await fireTrigger(d as never)
51
+ expect(d.ensureBot).toHaveBeenCalledWith('@cron:example.org', '!room:example.org')
52
+ const ensure = (d.ensureBot as ReturnType<typeof vi.fn>).mock.invocationCallOrder[0]
53
+ const send = (d.sendMessage as ReturnType<typeof vi.fn>).mock.invocationCallOrder[0]
54
+ expect(ensure).toBeLessThan(send)
55
+ })
56
+
57
+ it('surfaces a send failure without throwing — one bad firing must not kill the scheduler', async () => {
58
+ const d = deps({
59
+ sendMessage: vi.fn(async () => {
60
+ throw new Error('matrix down')
61
+ }),
62
+ })
63
+ await expect(fireTrigger(d as never)).resolves.toBeUndefined()
64
+ })
65
+
66
+ it('does not post when the room cannot be resolved', async () => {
67
+ const d = deps({ resolveRoom: vi.fn(async () => null) })
68
+ await fireTrigger(d as never)
69
+ expect(d.sendMessage).not.toHaveBeenCalled()
70
+ })
71
+ })
@@ -0,0 +1,41 @@
1
+ import type { TriggerMessage } from '@zooid/core'
2
+
3
+ export interface FireTriggerDeps {
4
+ name: string
5
+ as: string
6
+ message: TriggerMessage
7
+ agentUserId: string
8
+ resolveRoom: (room: string) => Promise<string | null>
9
+ ensureBot: (asUserId: string, roomId: string) => Promise<void>
10
+ sendMessage: (input: {
11
+ roomId: string
12
+ asUserId: string
13
+ content: { msgtype: string; body: string; [k: string]: unknown }
14
+ }) => Promise<{ event_id: string }>
15
+ }
16
+
17
+ export async function fireTrigger(deps: FireTriggerDeps): Promise<void> {
18
+ const { name, as, message, agentUserId, resolveRoom, ensureBot, sendMessage } = deps
19
+ try {
20
+ const roomId = await resolveRoom(message.room)
21
+ if (!roomId) {
22
+ console.warn(`[trigger:${name}] cannot resolve room ${message.room} — skipping`)
23
+ return
24
+ }
25
+ await ensureBot(as, roomId)
26
+ await sendMessage({
27
+ roomId,
28
+ asUserId: as,
29
+ content: {
30
+ msgtype: 'm.text',
31
+ body: message.text,
32
+ // Structural mention: routes deterministically AND disarms the raw-body
33
+ // fallback in extractMentions, which only fires when nothing matched.
34
+ 'm.mentions': { user_ids: [agentUserId] },
35
+ },
36
+ })
37
+ } catch (err) {
38
+ // Never throw: one bad firing must not take down the scheduler.
39
+ console.warn(`[trigger:${name}] failed:`, (err as Error).message)
40
+ }
41
+ }
@@ -0,0 +1,55 @@
1
+ import { Cron } from 'croner'
2
+ import type { TriggerConfig } from '@zooid/core'
3
+ import { fireTrigger, type FireTriggerDeps } from './trigger-runner.js'
4
+
5
+ export interface StartTriggerSchedulerDeps {
6
+ triggers: Record<string, TriggerConfig>
7
+ agentUserIds: Record<string, string>
8
+ resolveRoom: FireTriggerDeps['resolveRoom']
9
+ ensureBot: FireTriggerDeps['ensureBot']
10
+ sendMessage: FireTriggerDeps['sendMessage']
11
+ }
12
+
13
+ export interface TriggerSchedulerHandle {
14
+ stop(): Promise<void>
15
+ }
16
+
17
+ /**
18
+ * Cron-expression validator backed by croner's own parser, injected into
19
+ * `loadZooidConfig({ validateCron })` so a malformed `schedule:` fails at
20
+ * config-load time with croner's real error, not a generic field-count check.
21
+ * `core` stays cron-dependency-free; only the daemon entrypoint needs this.
22
+ */
23
+ export function validateCron(name: string, expr: string): void {
24
+ try {
25
+ new Cron(expr, { paused: true }).stop()
26
+ } catch (err) {
27
+ throw new Error(`triggers.${name}.schedule: ${(err as Error).message}`)
28
+ }
29
+ }
30
+
31
+ export function startTriggerScheduler(deps: StartTriggerSchedulerDeps): TriggerSchedulerHandle {
32
+ const { triggers, agentUserIds, resolveRoom, ensureBot, sendMessage } = deps
33
+ const jobs: Cron[] = []
34
+
35
+ for (const [name, trigger] of Object.entries(triggers)) {
36
+ if (!trigger.schedule) continue
37
+ const job = new Cron(trigger.schedule, () => {
38
+ for (const message of trigger.messages) {
39
+ const agentUserId = agentUserIds[message.mention]
40
+ if (!agentUserId) {
41
+ console.warn(`[trigger:${name}] unknown agent "${message.mention}" — skipping`)
42
+ continue
43
+ }
44
+ void fireTrigger({ name, as: trigger.as, message, agentUserId, resolveRoom, ensureBot, sendMessage })
45
+ }
46
+ })
47
+ jobs.push(job)
48
+ }
49
+
50
+ return {
51
+ async stop(): Promise<void> {
52
+ for (const job of jobs) job.stop()
53
+ },
54
+ }
55
+ }
@@ -0,0 +1,74 @@
1
+ import { createHmac } from 'node:crypto'
2
+ import { describe, expect, it, vi } from 'vitest'
3
+ import { Hono } from 'hono'
4
+ import { loadZooidConfig } from '@zooid/core'
5
+ import { mountWebhookRoutes } from './webhook-routes.js'
6
+
7
+ const secret = 'route-secret'
8
+ const body = JSON.stringify({ action: 'opened' })
9
+ const signature = `sha256=${createHmac('sha256', secret).update(body).digest('hex')}`
10
+ const yaml = `
11
+ runtime: local
12
+ transports:
13
+ matrix:
14
+ type: matrix
15
+ homeserver: http://localhost:8448
16
+ as_token: t
17
+ hs_token: h
18
+ user_namespace: '@.*:example.org'
19
+ agents:
20
+ product:
21
+ acp: { preset: opencode }
22
+ matrix: { rooms: ['#product:example.org'] }
23
+ triggers:
24
+ triage:
25
+ webhook:
26
+ provider: github
27
+ secret: ${secret}
28
+ as: '@hook:example.org'
29
+ room: '!product:example.org'
30
+ mention: product
31
+ text: Triage.
32
+ `
33
+
34
+ function makeApp() {
35
+ const sent: Array<Record<string, unknown>> = []
36
+ const app = new Hono()
37
+ mountWebhookRoutes(app, {
38
+ triggers: loadZooidConfig(yaml).triggers,
39
+ agentUserIds: { product: '@product:example.org' },
40
+ resolveRoom: async (room) => room,
41
+ ensureBot: async () => {},
42
+ sendMessage: async (message: Record<string, unknown>) => {
43
+ sent.push(message)
44
+ return { event_id: '$event' }
45
+ },
46
+ })
47
+ return { app, sent }
48
+ }
49
+
50
+ function post(app: Hono, path: string) {
51
+ return app.request(path, {
52
+ method: 'POST',
53
+ body,
54
+ headers: {
55
+ 'x-hub-signature-256': signature,
56
+ 'x-github-event': 'issues',
57
+ 'x-github-delivery': crypto.randomUUID(),
58
+ },
59
+ })
60
+ }
61
+
62
+ describe('webhook route contract', () => {
63
+ it('accepts a signed delivery at /_zooid/webhooks/:name', async () => {
64
+ const { app, sent } = makeApp()
65
+ expect((await post(app, '/_zooid/webhooks/triage')).status).toBe(202)
66
+ await vi.waitFor(() => expect(sent).toHaveLength(1))
67
+ })
68
+
69
+ it('does not expose the retired /webhook/:name route', async () => {
70
+ const { app, sent } = makeApp()
71
+ expect((await post(app, '/webhook/triage')).status).toBe(404)
72
+ expect(sent).toHaveLength(0)
73
+ })
74
+ })