zooid 0.12.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.
Files changed (48) hide show
  1. package/README.md +8 -1
  2. package/dist/bin.js +923 -184
  3. package/dist/bin.js.map +1 -1
  4. package/dist/{chunk-3Q4BPAZD.js → chunk-R5S26T7B.js} +20576 -514
  5. package/dist/chunk-R5S26T7B.js.map +1 -0
  6. package/dist/index.d.ts +3 -3
  7. package/dist/index.js +1 -1
  8. package/package.json +13 -9
  9. package/src/bin.test.ts +63 -0
  10. package/src/bin.ts +52 -3
  11. package/src/bootstrap/configs.test.ts +7 -0
  12. package/src/bootstrap/configs.ts +14 -0
  13. package/src/build-registry.context.test.ts +3 -3
  14. package/src/build-registry.ts +67 -24
  15. package/src/commands/dev.ts +37 -7
  16. package/src/commands/status.test.ts +15 -0
  17. package/src/commands/status.ts +27 -2
  18. package/src/daemon/delivery-cache.test.ts +34 -0
  19. package/src/daemon/delivery-cache.ts +36 -0
  20. package/src/daemon/load-custom-verifiers.ts +43 -0
  21. package/src/daemon/start-daemon.ts +177 -39
  22. package/src/daemon/task-journal.test.ts +19 -0
  23. package/src/daemon/task-journal.ts +32 -0
  24. package/src/daemon/trigger-rooms.test.ts +60 -0
  25. package/src/daemon/trigger-rooms.ts +0 -0
  26. package/src/daemon/trigger-runner.test.ts +71 -0
  27. package/src/daemon/trigger-runner.ts +41 -0
  28. package/src/daemon/trigger-scheduler.ts +55 -0
  29. package/src/daemon/webhook-routes.test.ts +74 -0
  30. package/src/daemon/webhook-routes.ts +202 -0
  31. package/src/daemon/webhook-verify.test.ts +155 -0
  32. package/src/daemon/webhook-verify.ts +155 -0
  33. package/src/pi-extension-install.test.ts +65 -0
  34. package/src/pi-extension-install.ts +56 -0
  35. package/src/push-gateway/gateway.test.ts +150 -0
  36. package/src/push-gateway/gateway.ts +78 -0
  37. package/src/push-gateway/index.ts +17 -0
  38. package/src/push-gateway/payload.test.ts +88 -0
  39. package/src/push-gateway/payload.ts +39 -0
  40. package/src/push-gateway/types.ts +37 -0
  41. package/src/push-gateway/vapid.test.ts +45 -0
  42. package/src/push-gateway/vapid.ts +34 -0
  43. package/src/services/tuwunel.ts +21 -2
  44. package/src/version.test.ts +67 -0
  45. package/src/version.ts +30 -0
  46. package/src/web/static.test.ts +22 -0
  47. package/src/web/static.ts +9 -1
  48. package/dist/chunk-3Q4BPAZD.js.map +0 -1
@@ -0,0 +1,34 @@
1
+ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
2
+ import { DeliveryCache } from './delivery-cache.js'
3
+
4
+ describe('DeliveryCache', () => {
5
+ beforeEach(() => vi.useFakeTimers())
6
+ afterEach(() => vi.useRealTimers())
7
+
8
+ it('accepts an id once and rejects the replay', () => {
9
+ const c = new DeliveryCache(60_000)
10
+ expect(c.seen('abc')).toBe(false)
11
+ expect(c.seen('abc')).toBe(true)
12
+ })
13
+
14
+ it('treats ids from different triggers as distinct', () => {
15
+ const c = new DeliveryCache(60_000)
16
+ expect(c.seen('t1:abc')).toBe(false)
17
+ expect(c.seen('t2:abc')).toBe(false)
18
+ })
19
+
20
+ it('forgets an id after the TTL, so the cache cannot grow without bound', () => {
21
+ const c = new DeliveryCache(60_000)
22
+ c.seen('abc')
23
+ vi.advanceTimersByTime(61_000)
24
+ expect(c.seen('abc')).toBe(false)
25
+ })
26
+
27
+ it('evicts expired entries rather than retaining them forever', () => {
28
+ const c = new DeliveryCache(1_000)
29
+ for (let i = 0; i < 1000; i++) c.seen(`id-${i}`)
30
+ vi.advanceTimersByTime(2_000)
31
+ c.seen('trigger-eviction')
32
+ expect(c.size).toBeLessThan(10)
33
+ })
34
+ })
@@ -0,0 +1,36 @@
1
+ /**
2
+ * TTL-bounded replay defence for webhook delivery ids. GitHub sends no
3
+ * timestamp, so freshness-window rejection (used for Stripe/Slack in
4
+ * `webhook-verify.ts`) is not available — dedupe on delivery id instead.
5
+ * Keys are namespaced by trigger name (e.g. `t1:abc`) so two triggers never
6
+ * collide on the same upstream id.
7
+ */
8
+ export class DeliveryCache {
9
+ private readonly ttlMs: number
10
+ private readonly expiryById = new Map<string, number>()
11
+
12
+ constructor(ttlMs: number) {
13
+ this.ttlMs = ttlMs
14
+ }
15
+
16
+ get size(): number {
17
+ return this.expiryById.size
18
+ }
19
+
20
+ /** Returns true if `id` was already seen (and still within its TTL). */
21
+ seen(id: string): boolean {
22
+ this.evictExpired()
23
+ const now = Date.now()
24
+ const expiry = this.expiryById.get(id)
25
+ if (expiry !== undefined && expiry > now) return true
26
+ this.expiryById.set(id, now + this.ttlMs)
27
+ return false
28
+ }
29
+
30
+ private evictExpired(): void {
31
+ const now = Date.now()
32
+ for (const [id, expiry] of this.expiryById) {
33
+ if (expiry <= now) this.expiryById.delete(id)
34
+ }
35
+ }
36
+ }
@@ -0,0 +1,43 @@
1
+ import { pathToFileURL } from 'node:url'
2
+ import type { TriggerConfig } from '@zooid/core'
3
+ import type { CustomVerifier } from './webhook-verify.js'
4
+
5
+ /**
6
+ * Import the `verify:` module of every `provider: custom` trigger, keyed by
7
+ * trigger name.
8
+ *
9
+ * Loaded once at daemon start rather than per delivery: a bad path or a
10
+ * module that exports the wrong thing is a configuration error, and it
11
+ * should surface when the operator starts the daemon, not silently as a 401
12
+ * the first time the service fires. Config has already resolved the path to
13
+ * an absolute one against the zooid.yaml directory.
14
+ */
15
+ export async function loadCustomVerifiers(
16
+ triggers: Record<string, TriggerConfig>,
17
+ ): Promise<Record<string, CustomVerifier>> {
18
+ const out: Record<string, CustomVerifier> = {}
19
+ for (const [name, trigger] of Object.entries(triggers)) {
20
+ const path = trigger.webhook?.provider === 'custom' ? trigger.webhook.verify : undefined
21
+ if (!path) continue
22
+
23
+ let mod: Record<string, unknown>
24
+ try {
25
+ mod = (await import(pathToFileURL(path).href)) as Record<string, unknown>
26
+ } catch (err) {
27
+ throw new Error(
28
+ `triggers.${name}.webhook.verify: cannot load ${path} — ${(err as Error).message}`,
29
+ )
30
+ }
31
+ // `export default` is the documented shape; a named `verify` export is
32
+ // accepted so a module can hold more than one provider's verifier.
33
+ const fn = mod.default ?? mod.verify
34
+ if (typeof fn !== 'function') {
35
+ throw new Error(
36
+ `triggers.${name}.webhook.verify: ${path} must export a function as \`default\` ` +
37
+ `(or as \`verify\`), got ${typeof fn}`,
38
+ )
39
+ }
40
+ out[name] = fn as CustomVerifier
41
+ }
42
+ return out
43
+ }
@@ -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,15 +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'
34
+ import { mountPushGateway } from '../push-gateway/index.js'
36
35
  import { makeSyncCursorStore } from './sync-cursors.js'
36
+ import { makeTaskJournal } from './task-journal.js'
37
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'
38
42
 
39
43
  export interface StartDaemonOpts {
40
44
  configPath?: string
@@ -79,6 +83,8 @@ export interface StartDaemonOpts {
79
83
  export interface DaemonHandle {
80
84
  port: number
81
85
  agentNames: string[]
86
+ /** VAPID public key for web push, when the gateway bound (appservice mode with a data dir). */
87
+ vapidPublicKey?: string
82
88
  stop(): Promise<void>
83
89
  whenStopped: Promise<void>
84
90
  }
@@ -100,44 +106,73 @@ function closeAsync(server: ServerType): Promise<void> {
100
106
  })
101
107
  }
102
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
+
103
115
  export async function startDaemon(opts: StartDaemonOpts = {}): Promise<DaemonHandle> {
104
116
  const cwd = opts.cwd ?? process.cwd()
105
117
  const found = opts.configPath ? { path: opts.configPath } : findConfigFile(cwd)
106
118
  if (!found) throw new Error('zooid.yaml is required')
107
119
  const configDir = dirname(found.path)
108
- const base = loadZooidConfig(readFileSync(found.path, 'utf8'), { configDir })
120
+ const base = loadZooidConfig(readFileSync(found.path, 'utf8'), { configDir, validateCron })
109
121
  const config = mergeCliFlags(base, opts.cliFlags ?? {})
110
122
 
111
123
  const approvals = new ApprovalCorrelator()
112
124
 
113
- const daemonSockPath = opts.agentsDir
114
- ? join(opts.agentsDir, '..', 'run', 'context.sock')
115
- : join(tmpdir(), `zooid-context-${process.pid}.sock`)
116
- 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(() => {})
117
129
  const contextSpawnRegistry = new SpawnRegistry()
118
- let contextSocket: DaemonSocketHandle | null = null
119
- try {
120
- contextSocket = await startDaemonSocketServer({
121
- sockPath: daemonSockPath,
122
- registry: contextSpawnRegistry,
123
- })
124
- } catch (err) {
125
- console.warn('[context] daemon socket startup failed; zooid-context MCP disabled:', err)
126
- }
130
+ const contextSockets: AgentSocketsHandle = await startAgentSocketServers({
131
+ runDir,
132
+ registry: contextSpawnRegistry,
133
+ agentNames: contextEligibleAgents(config),
134
+ })
127
135
 
128
136
  const dataDir = opts.agentsDir ? dirname(opts.agentsDir) : undefined
129
137
  const registry = buildAcpRegistry(config, {
130
138
  approvals,
131
139
  onTap: opts.onTap,
132
140
  agentsDir: opts.agentsDir,
133
- contextSpawnRegistry: contextSocket ? contextSpawnRegistry : undefined,
134
- daemonSockPath: contextSocket ? daemonSockPath : undefined,
141
+ contextSpawnRegistry,
142
+ daemonSockPaths: contextSockets.paths,
135
143
  configDir,
136
144
  dataDir,
137
145
  daemonHome: process.env.HOME,
138
146
  })
139
147
  const agentNames = Object.keys(config.agents)
140
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
+
141
176
  if (config.runtime !== 'local') {
142
177
  await prepullImages(registry, {
143
178
  engine: config.runtime === 'podman' ? 'podman' : 'docker',
@@ -148,16 +183,14 @@ export async function startDaemon(opts: StartDaemonOpts = {}): Promise<DaemonHan
148
183
  })
149
184
  }
150
185
 
151
- console.log(
152
- `[context] socket=${daemonSockPath} ` +
153
- `status=${contextSocket ? 'listening' : 'disabled'} ` +
154
- `agents={${agentNames
155
- .map((n) => `${n}:${registry.hasContextSpawn(n) ? 'yes' : 'no'}`)
156
- .join(', ')}}`,
157
- )
186
+ console.log(`[context] runDir=${runDir}`)
187
+ for (const name of agentNames) {
188
+ console.log(`[context] agent=${name} socket=${contextSockets.paths[name] ?? '(disabled)'}`)
189
+ }
158
190
 
159
191
  let server: ServerType | null = null
160
192
  let syncLoops: SyncLoop[] | undefined
193
+ let triggers: { stop(): Promise<void> } | undefined
161
194
  let stopped = false
162
195
  let resolveStopped!: () => void
163
196
  const whenStopped = new Promise<void>((r) => {
@@ -166,6 +199,7 @@ export async function startDaemon(opts: StartDaemonOpts = {}): Promise<DaemonHan
166
199
 
167
200
  const matrix = findMatrixTransport(config)
168
201
  let port: number
202
+ let vapidPublicKey: string | undefined
169
203
 
170
204
  if (matrix) {
171
205
  const mode = matrix.transport.mode ?? 'appservice'
@@ -205,8 +239,11 @@ export async function startDaemon(opts: StartDaemonOpts = {}): Promise<DaemonHan
205
239
  // `:` is the homeserver's server_name. Fall back to the homeserver URL's
206
240
  // host if the namespace shape is unexpected.
207
241
  const serverName =
208
- matrix.transport.user_namespace.split(':').slice(1).join(':').replace(/\\?\)?$/, '') ||
209
- 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
210
247
  const asUserId = `@${matrix.transport.sender_localpart}:${serverName}`
211
248
  // Pull mode's loadSince/saveSince are keyed by MXID; the cursor store is
212
249
  // keyed by agent name. Translate via the bindings we just built.
@@ -220,6 +257,7 @@ export async function startDaemon(opts: StartDaemonOpts = {}): Promise<DaemonHan
220
257
  adminUserId: opts.adminUserId,
221
258
  botUserId: asUserId,
222
259
  media: mediaClient,
260
+ taskJournal: dataDir ? makeTaskJournal(dataDir) : undefined,
223
261
  mode,
224
262
  loadSince: (uid) => {
225
263
  const name = nameByUserId.get(uid)
@@ -230,12 +268,74 @@ export async function startDaemon(opts: StartDaemonOpts = {}): Promise<DaemonHan
230
268
  if (name && cursors) cursors.saveSince(name, since)
231
269
  },
232
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
+
233
291
  if (shouldBindHttpListener(mode)) {
234
292
  const requestedPort = matrix.transport.port ?? 9000
293
+ // The gateway rides the appservice listener, not webStatic: webStatic
294
+ // exists only under `zooid dev`, and on a deployed box Caddy serves the
295
+ // dist directly with the daemon out of the serving path. This is the
296
+ // one HTTP surface bound in both modes.
297
+ if (dataDir) {
298
+ vapidPublicKey = mountPushGateway(transport.app, {
299
+ dataDir,
300
+ subject: `https://${serverName}`,
301
+ }).publicKey
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
+
235
331
  // Bind 0.0.0.0 explicitly — @hono/node-server defaults to IPv6-only on
236
332
  // macOS, which Docker's NAT bridge can't reach when Tuwunel pushes AS
237
333
  // events back to host.docker.internal:<port>.
238
- 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
+ })
239
339
  port = await listenAsync(server)
240
340
  } else {
241
341
  // Pull (client) mode runs outbound /sync loops; no inbound listener.
@@ -243,7 +343,6 @@ export async function startDaemon(opts: StartDaemonOpts = {}): Promise<DaemonHan
243
343
  }
244
344
  const spaceLocalpart = matrix.transport.space ?? 'dev'
245
345
  const adminUserIds = opts.adminUserId ? [opts.adminUserId] : []
246
- let spaceRoomId: string | undefined
247
346
  try {
248
347
  spaceRoomId = await ensureWorkforceSpace({
249
348
  client,
@@ -254,7 +353,9 @@ export async function startDaemon(opts: StartDaemonOpts = {}): Promise<DaemonHan
254
353
  admins: adminUserIds,
255
354
  joinRule: opts.publicWorkforceSpace ? 'public' : 'invite',
256
355
  })
257
- console.log(`[matrix] ensured workforce space #${spaceLocalpart}:${serverName} → ${spaceRoomId}`)
356
+ console.log(
357
+ `[matrix] ensured workforce space #${spaceLocalpart}:${serverName} → ${spaceRoomId}`,
358
+ )
258
359
  } catch (err) {
259
360
  console.warn('[matrix] workforce space provisioning failed:', err)
260
361
  }
@@ -267,6 +368,35 @@ export async function startDaemon(opts: StartDaemonOpts = {}): Promise<DaemonHan
267
368
  // m.space.child while joining it.
268
369
  await transport.bootstrap({ spaceRoomId, asUserId, adminUserIds })
269
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
+
270
400
  // Pull mode: start the outbound /sync loops after bootstrap so the rooms
271
401
  // each agent syncs already exist. Loops long-poll until stop().
272
402
  syncLoops = transport.syncLoops
@@ -307,13 +437,22 @@ export async function startDaemon(opts: StartDaemonOpts = {}): Promise<DaemonHan
307
437
  const token = process.env.ZOOID_TOKEN
308
438
  if (!token) throw new Error('ZOOID_TOKEN is required for http transport')
309
439
  const app = createApp({ agents: registry, approvals, token })
310
- 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
+ })
311
445
  port = await listenAsync(server)
312
446
  }
313
447
 
314
448
  const stop = async (): Promise<void> => {
315
449
  if (stopped) return whenStopped
316
450
  stopped = true
451
+ try {
452
+ await triggers?.stop()
453
+ } catch {
454
+ // swallow
455
+ }
317
456
  try {
318
457
  if (syncLoops) for (const loop of syncLoops) loop.stop()
319
458
  } catch {
@@ -330,8 +469,7 @@ export async function startDaemon(opts: StartDaemonOpts = {}): Promise<DaemonHan
330
469
  console.error('stopAll:', err)
331
470
  }
332
471
  try {
333
- if (contextSocket) await contextSocket.close()
334
- await unlink(daemonSockPath).catch(() => {})
472
+ await contextSockets.close()
335
473
  } catch {
336
474
  // swallow
337
475
  }
@@ -347,5 +485,5 @@ export async function startDaemon(opts: StartDaemonOpts = {}): Promise<DaemonHan
347
485
  process.on('SIGTERM', () => handler('SIGTERM'))
348
486
  }
349
487
 
350
- return { port, agentNames, stop, whenStopped }
488
+ return { port, agentNames, vapidPublicKey, stop, whenStopped }
351
489
  }
@@ -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
+ })