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.
- package/README.md +8 -1
- package/dist/bin.js +923 -184
- package/dist/bin.js.map +1 -1
- package/dist/{chunk-3Q4BPAZD.js → chunk-R5S26T7B.js} +20576 -514
- package/dist/chunk-R5S26T7B.js.map +1 -0
- package/dist/index.d.ts +3 -3
- package/dist/index.js +1 -1
- package/package.json +13 -9
- package/src/bin.test.ts +63 -0
- package/src/bin.ts +52 -3
- package/src/bootstrap/configs.test.ts +7 -0
- package/src/bootstrap/configs.ts +14 -0
- package/src/build-registry.context.test.ts +3 -3
- package/src/build-registry.ts +67 -24
- package/src/commands/dev.ts +37 -7
- package/src/commands/status.test.ts +15 -0
- package/src/commands/status.ts +27 -2
- 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 +177 -39
- 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/src/push-gateway/gateway.test.ts +150 -0
- package/src/push-gateway/gateway.ts +78 -0
- package/src/push-gateway/index.ts +17 -0
- package/src/push-gateway/payload.test.ts +88 -0
- package/src/push-gateway/payload.ts +39 -0
- package/src/push-gateway/types.ts +37 -0
- package/src/push-gateway/vapid.test.ts +45 -0
- package/src/push-gateway/vapid.ts +34 -0
- package/src/services/tuwunel.ts +21 -2
- package/src/version.test.ts +67 -0
- package/src/version.ts +30 -0
- package/src/web/static.test.ts +22 -0
- package/src/web/static.ts +9 -1
- 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
|
|
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
|
-
|
|
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'
|
|
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
|
|
114
|
-
? join(opts.agentsDir, '..', 'run'
|
|
115
|
-
: join(tmpdir(), `zooid-context-${process.pid}
|
|
116
|
-
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(() => {})
|
|
117
129
|
const contextSpawnRegistry = new SpawnRegistry()
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
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
|
|
134
|
-
|
|
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
|
-
|
|
153
|
-
|
|
154
|
-
|
|
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
|
|
209
|
-
|
|
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({
|
|
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(
|
|
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({
|
|
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
|
-
|
|
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
|
+
})
|