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.
- package/README.md +6 -1
- package/dist/bin.js +674 -171
- package/dist/bin.js.map +1 -1
- package/dist/{chunk-YZ4IO5MR.js → chunk-R5S26T7B.js} +20523 -507
- package/dist/chunk-R5S26T7B.js.map +1 -0
- package/dist/index.d.ts +3 -3
- package/dist/index.js +1 -1
- package/package.json +11 -9
- package/src/build-registry.context.test.ts +3 -3
- package/src/build-registry.ts +67 -24
- package/src/daemon/delivery-cache.test.ts +34 -0
- package/src/daemon/delivery-cache.ts +36 -0
- package/src/daemon/load-custom-verifiers.ts +43 -0
- package/src/daemon/start-daemon.ts +162 -38
- package/src/daemon/task-journal.test.ts +19 -0
- package/src/daemon/task-journal.ts +32 -0
- package/src/daemon/trigger-rooms.test.ts +60 -0
- package/src/daemon/trigger-rooms.ts +0 -0
- package/src/daemon/trigger-runner.test.ts +71 -0
- package/src/daemon/trigger-runner.ts +41 -0
- package/src/daemon/trigger-scheduler.ts +55 -0
- package/src/daemon/webhook-routes.test.ts +74 -0
- package/src/daemon/webhook-routes.ts +202 -0
- package/src/daemon/webhook-verify.test.ts +155 -0
- package/src/daemon/webhook-verify.ts +155 -0
- package/src/pi-extension-install.test.ts +65 -0
- package/src/pi-extension-install.ts +56 -0
- package/dist/chunk-YZ4IO5MR.js.map +0 -1
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { readFileSync } from 'node:fs'
|
|
2
|
-
import { mkdir
|
|
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
|
-
|
|
31
|
-
|
|
32
|
-
|
|
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
|
|
117
|
-
? join(opts.agentsDir, '..', 'run'
|
|
118
|
-
: join(tmpdir(), `zooid-context-${process.pid}
|
|
119
|
-
await mkdir(
|
|
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
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
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
|
|
137
|
-
|
|
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
|
-
|
|
156
|
-
|
|
157
|
-
|
|
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
|
|
213
|
-
|
|
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({
|
|
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(
|
|
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({
|
|
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
|
-
|
|
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
|
+
})
|