zooid 0.14.1 → 0.16.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 +1 -1
- package/dist/bin.js +93 -25
- package/dist/bin.js.map +1 -1
- package/dist/{chunk-R5S26T7B.js → chunk-HE7DVDJA.js} +1545 -816
- package/dist/chunk-HE7DVDJA.js.map +1 -0
- package/dist/index.d.ts +2 -1
- package/dist/index.js +1 -1
- package/package.json +10 -10
- package/src/build-registry.context.test.ts +1 -0
- package/src/build-registry.test.ts +1 -0
- package/src/build-registry.ts +3 -0
- package/src/build-registry.zod043.test.ts +1 -0
- package/src/build-registry.zod044.test.ts +1 -0
- package/src/commands/dev.ts +3 -0
- package/src/commands/start.ts +2 -0
- package/src/daemon/start-daemon.ts +6 -0
- package/src/daemon/trigger-runner.mxid.test.ts +145 -0
- package/src/daemon/trigger-runner.test.ts +3 -4
- package/src/daemon/trigger-runner.ts +45 -7
- package/src/daemon/trigger-scheduler.ts +12 -7
- package/src/daemon/webhook-routes.ts +3 -7
- package/src/observability/capture-agent.ts +35 -1
- package/src/observability/lifecycle-log.test.ts +48 -0
- package/src/observability/lifecycle-log.ts +29 -0
- package/src/observability/observability.integration.test.ts +22 -0
- package/src/web/static.test.ts +14 -0
- package/src/web/static.ts +3 -0
- package/dist/chunk-R5S26T7B.js.map +0 -1
package/dist/index.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { AcpRuntime, ApprovalCorrelator, TapEvent, ZooidConfig, AcpAgentRegistry } from '@zooid/core';
|
|
1
|
+
import { AcpRuntime, ApprovalCorrelator, TapEvent, SessionLifecycleEvent, ZooidConfig, AcpAgentRegistry } from '@zooid/core';
|
|
2
2
|
import { SpawnRegistry } from '@zooid/context-mcp';
|
|
3
3
|
|
|
4
4
|
interface BuildAcpRegistryOptions {
|
|
@@ -8,6 +8,7 @@ interface BuildAcpRegistryOptions {
|
|
|
8
8
|
approvals?: ApprovalCorrelator;
|
|
9
9
|
/** Observability tap forwarded to each AcpClient. */
|
|
10
10
|
onTap?: (agentName: string, event: TapEvent) => void;
|
|
11
|
+
onLifecycle?: (agentName: string, event: SessionLifecycleEvent) => void;
|
|
11
12
|
/**
|
|
12
13
|
* Per-agent state root (`<dataRoot>/agents/`). When set, each AcpClient
|
|
13
14
|
* persists its `(threadId → sessionId)` map under
|
package/dist/index.js
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "zooid",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.16.0",
|
|
4
4
|
"description": "An open-source, self-hostable chat app for collaborating with AI agents alongside your team. Any model, any CLI.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -25,7 +25,7 @@
|
|
|
25
25
|
"access": "public"
|
|
26
26
|
},
|
|
27
27
|
"zooid": {
|
|
28
|
-
"webVersion": "0.12.
|
|
28
|
+
"webVersion": "0.12.1"
|
|
29
29
|
},
|
|
30
30
|
"engines": {
|
|
31
31
|
"node": ">=22"
|
|
@@ -43,14 +43,14 @@
|
|
|
43
43
|
"tar": "^7.5.16",
|
|
44
44
|
"web-push": "^3.6.7",
|
|
45
45
|
"yaml": "^2.5.0",
|
|
46
|
-
"@zooid/
|
|
47
|
-
"@zooid/
|
|
48
|
-
"@zooid/
|
|
49
|
-
"@zooid/runtime-docker": "^0.
|
|
50
|
-
"@zooid/
|
|
51
|
-
"@zooid/
|
|
52
|
-
"@zooid/transport-
|
|
53
|
-
"@zooid/
|
|
46
|
+
"@zooid/acp-client": "^0.16.0",
|
|
47
|
+
"@zooid/context-mcp": "^0.16.0",
|
|
48
|
+
"@zooid/core": "^0.16.0",
|
|
49
|
+
"@zooid/runtime-docker": "^0.16.0",
|
|
50
|
+
"@zooid/pi-extension": "^0.13.3",
|
|
51
|
+
"@zooid/transport-http": "^0.16.0",
|
|
52
|
+
"@zooid/transport-matrix": "^0.16.0",
|
|
53
|
+
"@zooid/runtime-local": "^0.16.0"
|
|
54
54
|
},
|
|
55
55
|
"devDependencies": {
|
|
56
56
|
"@agentclientprotocol/sdk": "^0.21.0",
|
package/src/build-registry.ts
CHANGED
|
@@ -10,6 +10,7 @@ import {
|
|
|
10
10
|
type ContextSpawnFactory,
|
|
11
11
|
type MountConfig,
|
|
12
12
|
type TapEvent,
|
|
13
|
+
type SessionLifecycleEvent,
|
|
13
14
|
type ZooidConfig,
|
|
14
15
|
type TransportContextProvider,
|
|
15
16
|
} from '@zooid/core'
|
|
@@ -29,6 +30,7 @@ export interface BuildAcpRegistryOptions {
|
|
|
29
30
|
approvals?: ApprovalCorrelator
|
|
30
31
|
/** Observability tap forwarded to each AcpClient. */
|
|
31
32
|
onTap?: (agentName: string, event: TapEvent) => void
|
|
33
|
+
onLifecycle?: (agentName: string, event: SessionLifecycleEvent) => void
|
|
32
34
|
/**
|
|
33
35
|
* Per-agent state root (`<dataRoot>/agents/`). When set, each AcpClient
|
|
34
36
|
* persists its `(threadId → sessionId)` map under
|
|
@@ -314,6 +316,7 @@ export function buildAcpRegistry(
|
|
|
314
316
|
cwd: cwdByAgent,
|
|
315
317
|
approvals: opts.approvals,
|
|
316
318
|
onTap: opts.onTap,
|
|
319
|
+
onLifecycle: opts.onLifecycle,
|
|
317
320
|
agentsDir: opts.agentsDir,
|
|
318
321
|
contextSpawns,
|
|
319
322
|
onSessionEstablished: (agentName, sessionKey, sessionId) =>
|
package/src/commands/dev.ts
CHANGED
|
@@ -208,6 +208,7 @@ export async function runDev(flags: DevFlags): Promise<DevHandle> {
|
|
|
208
208
|
// the web client without needing an invite.
|
|
209
209
|
publicWorkforceSpace: true,
|
|
210
210
|
onTap: (agentName, event) => captures[agentName]?.onTap(event),
|
|
211
|
+
onLifecycle: (agentName, event) => captures[agentName]?.onLifecycle(event),
|
|
211
212
|
prepullLog: (line) => {
|
|
212
213
|
t.output = line.replace(/^\[zooid\]\s+/, '').trim()
|
|
213
214
|
},
|
|
@@ -251,6 +252,8 @@ export async function runDev(flags: DevFlags): Promise<DevHandle> {
|
|
|
251
252
|
const app = webStatic({
|
|
252
253
|
webRoot,
|
|
253
254
|
homeserverUrl: homeserver,
|
|
255
|
+
// Same default as the daemon's space provisioning (start-daemon.ts).
|
|
256
|
+
workforceSpace: matrix.transport.space ?? 'dev',
|
|
254
257
|
...(ctx.daemon?.vapidPublicKey
|
|
255
258
|
? {
|
|
256
259
|
// Tuwunel runs in a container; `localhost` here would
|
package/src/commands/start.ts
CHANGED
|
@@ -3,6 +3,7 @@ import { resolve } from 'node:path'
|
|
|
3
3
|
import type { CliFlags } from '@zooid/core'
|
|
4
4
|
import { resolveDataLayout } from '../bootstrap/data-layout.js'
|
|
5
5
|
import { startDaemon } from '../daemon/start-daemon.js'
|
|
6
|
+
import { logLifecycle } from '../observability/lifecycle-log.js'
|
|
6
7
|
|
|
7
8
|
export interface StartFlags extends CliFlags {
|
|
8
9
|
printToken?: boolean
|
|
@@ -21,6 +22,7 @@ export async function runStart(flags: StartFlags): Promise<void> {
|
|
|
21
22
|
cliFlags: flags,
|
|
22
23
|
installSignalHandlers: true,
|
|
23
24
|
agentsDir: layout.agentsDir,
|
|
25
|
+
onLifecycle: (agentName, event) => logLifecycle(agentName, event),
|
|
24
26
|
})
|
|
25
27
|
console.log(`zooid listening on http://localhost:${handle.port}`)
|
|
26
28
|
for (const name of handle.agentNames) console.log(` agent: ${name}`)
|
|
@@ -13,6 +13,7 @@ import {
|
|
|
13
13
|
mergeCliFlags,
|
|
14
14
|
type CliFlags,
|
|
15
15
|
type TapEvent,
|
|
16
|
+
type SessionLifecycleEvent,
|
|
16
17
|
} from '@zooid/core'
|
|
17
18
|
import { createApp } from '@zooid/transport-http'
|
|
18
19
|
import {
|
|
@@ -49,6 +50,7 @@ export interface StartDaemonOpts {
|
|
|
49
50
|
adminUserId?: string
|
|
50
51
|
/** Observability tap forwarded to each AcpClient. */
|
|
51
52
|
onTap?: (agentName: string, event: TapEvent) => void
|
|
53
|
+
onLifecycle?: (agentName: string, event: SessionLifecycleEvent) => void
|
|
52
54
|
/**
|
|
53
55
|
* Per-agent state root (`<dataRoot>/agents/`). Threaded into
|
|
54
56
|
* `buildAcpRegistry` so each AcpClient persists `sessionId`s across
|
|
@@ -137,6 +139,7 @@ export async function startDaemon(opts: StartDaemonOpts = {}): Promise<DaemonHan
|
|
|
137
139
|
const registry = buildAcpRegistry(config, {
|
|
138
140
|
approvals,
|
|
139
141
|
onTap: opts.onTap,
|
|
142
|
+
onLifecycle: opts.onLifecycle,
|
|
140
143
|
agentsDir: opts.agentsDir,
|
|
141
144
|
contextSpawnRegistry,
|
|
142
145
|
daemonSockPaths: contextSockets.paths,
|
|
@@ -322,6 +325,7 @@ export async function startDaemon(opts: StartDaemonOpts = {}): Promise<DaemonHan
|
|
|
322
325
|
resolveRoom,
|
|
323
326
|
ensureBot,
|
|
324
327
|
sendMessage: (m) => client.sendMessage(m),
|
|
328
|
+
getJoinedMembers: (roomId, asUserId) => client.getJoinedMembers(roomId, asUserId),
|
|
325
329
|
})
|
|
326
330
|
for (const [name] of webhookTriggers) {
|
|
327
331
|
console.log(`[webhook] POST ${WEBHOOK_ROUTE_PREFIX}/${name}`)
|
|
@@ -383,6 +387,7 @@ export async function startDaemon(opts: StartDaemonOpts = {}): Promise<DaemonHan
|
|
|
383
387
|
resolveRoom,
|
|
384
388
|
ensureBot,
|
|
385
389
|
sendMessage: (m) => client.sendMessage(m),
|
|
390
|
+
getJoinedMembers: (roomId, asUserId) => client.getJoinedMembers(roomId, asUserId),
|
|
386
391
|
})
|
|
387
392
|
const scheduled = Object.values(config.triggers).filter((t) => t.schedule).length
|
|
388
393
|
if (scheduled > 0) console.log(`[trigger] scheduled ${scheduled} trigger(s)`)
|
|
@@ -424,6 +429,7 @@ export async function startDaemon(opts: StartDaemonOpts = {}): Promise<DaemonHan
|
|
|
424
429
|
spaceRoomId,
|
|
425
430
|
asUserId,
|
|
426
431
|
getAgents: () => bindings,
|
|
432
|
+
stateKey: matrix.transport.workstation ?? '',
|
|
427
433
|
})
|
|
428
434
|
console.log(`[matrix] published dev.zooid.workforce (${bindings.length} agents)`)
|
|
429
435
|
void publisher
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
import { describe, it, expect, vi } from 'vitest'
|
|
2
|
+
import { fireTrigger } from './trigger-runner.js'
|
|
3
|
+
|
|
4
|
+
const deps = (mention: string, agentUserIds: Record<string, string>) => {
|
|
5
|
+
const sendMessage = vi.fn(async () => ({ event_id: '$1' }))
|
|
6
|
+
return {
|
|
7
|
+
sendMessage,
|
|
8
|
+
call: () =>
|
|
9
|
+
fireTrigger({
|
|
10
|
+
name: 'standup',
|
|
11
|
+
as: '@cloud.cron:zooid.zoon.eco',
|
|
12
|
+
message: { room: '#product:zooid.zoon.eco', mention, text: 'Morning standup.' },
|
|
13
|
+
agentUserIds,
|
|
14
|
+
resolveRoom: async () => '!room:zooid.zoon.eco',
|
|
15
|
+
ensureBot: async () => {},
|
|
16
|
+
sendMessage,
|
|
17
|
+
}),
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
describe('fireTrigger addressing', () => {
|
|
22
|
+
it('uses an MXID mention verbatim, with no local lookup', async () => {
|
|
23
|
+
const { call, sendMessage } = deps('@ori-macbook.cpo:zooid.zoon.eco', {})
|
|
24
|
+
await call()
|
|
25
|
+
expect(sendMessage.mock.calls[0]![0].content['m.mentions']).toEqual({
|
|
26
|
+
user_ids: ['@ori-macbook.cpo:zooid.zoon.eco'],
|
|
27
|
+
})
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
it('resolves a bare name through the local agent map', async () => {
|
|
31
|
+
const { call, sendMessage } = deps('scout', { scout: '@cloud.scout:zooid.zoon.eco' })
|
|
32
|
+
await call()
|
|
33
|
+
expect(sendMessage.mock.calls[0]![0].content['m.mentions']).toEqual({
|
|
34
|
+
user_ids: ['@cloud.scout:zooid.zoon.eco'],
|
|
35
|
+
})
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
it('stamps dev.zooid.trigger with the trigger name and firing time', async () => {
|
|
39
|
+
const { call, sendMessage } = deps('@ori-macbook.cpo:zooid.zoon.eco', {})
|
|
40
|
+
const before = Date.now()
|
|
41
|
+
await call()
|
|
42
|
+
const stamp = sendMessage.mock.calls[0]![0].content['dev.zooid.trigger'] as {
|
|
43
|
+
name: string
|
|
44
|
+
fired_at: number
|
|
45
|
+
ttl_ms?: number
|
|
46
|
+
}
|
|
47
|
+
expect(stamp.name).toBe('standup')
|
|
48
|
+
expect(stamp.fired_at).toBeGreaterThanOrEqual(before)
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
it('skips a bare name with no local binding rather than posting', async () => {
|
|
52
|
+
const { call, sendMessage } = deps('scout', {})
|
|
53
|
+
await call()
|
|
54
|
+
expect(sendMessage).not.toHaveBeenCalled()
|
|
55
|
+
})
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
describe('fireTrigger mention rendering', () => {
|
|
59
|
+
it('leads the body with the MXID so the room can see who was addressed', async () => {
|
|
60
|
+
const { call, sendMessage } = deps('@ori-macbook.cpo:zooid.zoon.eco', {})
|
|
61
|
+
await call()
|
|
62
|
+
expect(sendMessage.mock.calls[0]![0].content.body).toBe(
|
|
63
|
+
'@ori-macbook.cpo:zooid.zoon.eco Morning standup.',
|
|
64
|
+
)
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
it('sends no HTML — the client pills a bare MXID, and markup would need escaping', async () => {
|
|
68
|
+
const { call, sendMessage } = deps('@ori-macbook.cpo:zooid.zoon.eco', {})
|
|
69
|
+
await call()
|
|
70
|
+
const c = sendMessage.mock.calls[0]![0].content
|
|
71
|
+
expect(c.format).toBeUndefined()
|
|
72
|
+
expect(c.formatted_body).toBeUndefined()
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
it('warns when the addressed user is not in the room', async () => {
|
|
76
|
+
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
|
77
|
+
const sendMessage = vi.fn(async () => ({ event_id: '$1' }))
|
|
78
|
+
await fireTrigger({
|
|
79
|
+
name: 'standup',
|
|
80
|
+
as: '@cloud.cron:zooid.zoon.eco',
|
|
81
|
+
message: {
|
|
82
|
+
room: '#product:zooid.zoon.eco',
|
|
83
|
+
mention: '@ori-macbook.cpo:zooid.zoon.eco',
|
|
84
|
+
text: 'Morning standup.',
|
|
85
|
+
},
|
|
86
|
+
agentUserIds: {},
|
|
87
|
+
resolveRoom: async () => '!p:zooid.zoon.eco',
|
|
88
|
+
ensureBot: async () => {},
|
|
89
|
+
sendMessage,
|
|
90
|
+
getJoinedMembers: async () => ({ joined: { '@cloud.product:zooid.zoon.eco': {} } }),
|
|
91
|
+
})
|
|
92
|
+
// posted anyway — the agent may join before it expires
|
|
93
|
+
expect(sendMessage).toHaveBeenCalledTimes(1)
|
|
94
|
+
expect(warn).toHaveBeenCalledWith(expect.stringMatching(/not in .*#product/))
|
|
95
|
+
warn.mockRestore()
|
|
96
|
+
})
|
|
97
|
+
|
|
98
|
+
it('warns only once across repeated fires at the same absent target', async () => {
|
|
99
|
+
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
|
100
|
+
const getJoinedMembers = vi.fn(async () => ({ joined: {} }))
|
|
101
|
+
// A distinct trigger name from the previous test: `warnedTargets` is
|
|
102
|
+
// module-level (dedup lasts the daemon's process lifetime, per spec), so
|
|
103
|
+
// reusing "standup" here would find the key already warned and this
|
|
104
|
+
// test's own two calls would produce zero fresh warnings.
|
|
105
|
+
const fire = () =>
|
|
106
|
+
fireTrigger({
|
|
107
|
+
name: 'standup-repeat',
|
|
108
|
+
as: '@cloud.cron:zooid.zoon.eco',
|
|
109
|
+
message: {
|
|
110
|
+
room: '#product:zooid.zoon.eco',
|
|
111
|
+
mention: '@ori-macbook.cpo:zooid.zoon.eco',
|
|
112
|
+
text: 'Morning standup.',
|
|
113
|
+
},
|
|
114
|
+
agentUserIds: {},
|
|
115
|
+
resolveRoom: async () => '!p:zooid.zoon.eco',
|
|
116
|
+
ensureBot: async () => {},
|
|
117
|
+
sendMessage: async () => ({ event_id: '$1' }),
|
|
118
|
+
getJoinedMembers,
|
|
119
|
+
})
|
|
120
|
+
await fire()
|
|
121
|
+
await fire()
|
|
122
|
+
expect(warn).toHaveBeenCalledTimes(1)
|
|
123
|
+
warn.mockRestore()
|
|
124
|
+
})
|
|
125
|
+
|
|
126
|
+
it('leaves interpolated webhook text verbatim in the body', async () => {
|
|
127
|
+
const sendMessage = vi.fn(async () => ({ event_id: '$1' }))
|
|
128
|
+
await fireTrigger({
|
|
129
|
+
name: 'triage',
|
|
130
|
+
as: '@cloud.hook:zooid.zoon.eco',
|
|
131
|
+
message: {
|
|
132
|
+
room: '#product:zooid.zoon.eco',
|
|
133
|
+
mention: '@cloud.product:zooid.zoon.eco',
|
|
134
|
+
text: 'Triage "<img src=x> & more".',
|
|
135
|
+
},
|
|
136
|
+
agentUserIds: {},
|
|
137
|
+
resolveRoom: async () => '!r:zooid.zoon.eco',
|
|
138
|
+
ensureBot: async () => {},
|
|
139
|
+
sendMessage,
|
|
140
|
+
})
|
|
141
|
+
const c = sendMessage.mock.calls[0]![0].content
|
|
142
|
+
expect(c.body).toBe('@cloud.product:zooid.zoon.eco Triage "<img src=x> & more".')
|
|
143
|
+
expect(c.formatted_body).toBeUndefined()
|
|
144
|
+
})
|
|
145
|
+
})
|
|
@@ -12,7 +12,7 @@ const deps = (over: Record<string, unknown> = {}) => ({
|
|
|
12
12
|
name: 'image-currency',
|
|
13
13
|
as: '@cron:example.org',
|
|
14
14
|
message,
|
|
15
|
-
|
|
15
|
+
agentUserIds: { architect: '@architect:example.org' },
|
|
16
16
|
resolveRoom: vi.fn(async () => '!room:example.org'),
|
|
17
17
|
ensureBot: vi.fn(async () => {}),
|
|
18
18
|
sendMessage: vi.fn(async () => ({ event_id: '$1' })),
|
|
@@ -20,14 +20,14 @@ const deps = (over: Record<string, unknown> = {}) => ({
|
|
|
20
20
|
})
|
|
21
21
|
|
|
22
22
|
describe('fireTrigger', () => {
|
|
23
|
-
it('posts text
|
|
23
|
+
it('posts text as the trigger bot user, led by a visible mention', async () => {
|
|
24
24
|
const d = deps()
|
|
25
25
|
await fireTrigger(d as never)
|
|
26
26
|
expect(d.sendMessage).toHaveBeenCalledTimes(1)
|
|
27
27
|
const call = (d.sendMessage as ReturnType<typeof vi.fn>).mock.calls[0][0]
|
|
28
28
|
expect(call.roomId).toBe('!room:example.org')
|
|
29
29
|
expect(call.asUserId).toBe('@cron:example.org')
|
|
30
|
-
expect(call.content.body).toBe('Check the pinned agent CLI versions.')
|
|
30
|
+
expect(call.content.body).toBe('@architect:example.org Check the pinned agent CLI versions.')
|
|
31
31
|
expect(call.content.msgtype).toBe('m.text')
|
|
32
32
|
})
|
|
33
33
|
|
|
@@ -36,7 +36,6 @@ describe('fireTrigger', () => {
|
|
|
36
36
|
await fireTrigger(d as never)
|
|
37
37
|
const content = (d.sendMessage as ReturnType<typeof vi.fn>).mock.calls[0][0].content
|
|
38
38
|
expect(content['m.mentions']).toEqual({ user_ids: ['@architect:example.org'] })
|
|
39
|
-
expect(content.body).not.toContain('@architect')
|
|
40
39
|
})
|
|
41
40
|
|
|
42
41
|
it('posts at the top level, never into a thread — runTurn makes each firing a new root', async () => {
|
|
@@ -1,10 +1,12 @@
|
|
|
1
1
|
import type { TriggerMessage } from '@zooid/core'
|
|
2
|
+
import { buildMentionContent } from '@zooid/transport-matrix'
|
|
2
3
|
|
|
3
4
|
export interface FireTriggerDeps {
|
|
4
5
|
name: string
|
|
5
6
|
as: string
|
|
6
7
|
message: TriggerMessage
|
|
7
|
-
|
|
8
|
+
/** Local agent name → MXID. A `mention:` that is already an MXID bypasses this. */
|
|
9
|
+
agentUserIds: Record<string, string>
|
|
8
10
|
resolveRoom: (room: string) => Promise<string | null>
|
|
9
11
|
ensureBot: (asUserId: string, roomId: string) => Promise<void>
|
|
10
12
|
sendMessage: (input: {
|
|
@@ -12,11 +14,29 @@ export interface FireTriggerDeps {
|
|
|
12
14
|
asUserId: string
|
|
13
15
|
content: { msgtype: string; body: string; [k: string]: unknown }
|
|
14
16
|
}) => Promise<{ event_id: string }>
|
|
17
|
+
/**
|
|
18
|
+
* Optional: enables the [[ZOD081]] §Design 6 "nobody is listening" warning.
|
|
19
|
+
* Omitted in tests that don't care about it — the check is then skipped
|
|
20
|
+
* silently, never a hard requirement to fire.
|
|
21
|
+
*/
|
|
22
|
+
getJoinedMembers?: (roomId: string, asUserId: string) => Promise<{ joined: Record<string, unknown> }>
|
|
15
23
|
}
|
|
16
24
|
|
|
25
|
+
// Module-level so a daily trigger against an off laptop logs once per daemon
|
|
26
|
+
// run, not once a day. Keyed by trigger name + target so distinct triggers
|
|
27
|
+
// (or targets) each get their own first warning.
|
|
28
|
+
const warnedTargets = new Set<string>()
|
|
29
|
+
|
|
17
30
|
export async function fireTrigger(deps: FireTriggerDeps): Promise<void> {
|
|
18
|
-
const { name, as, message,
|
|
31
|
+
const { name, as, message, agentUserIds, resolveRoom, ensureBot, sendMessage, getJoinedMembers } = deps
|
|
19
32
|
try {
|
|
33
|
+
const target = message.mention.startsWith('@')
|
|
34
|
+
? message.mention
|
|
35
|
+
: agentUserIds[message.mention]
|
|
36
|
+
if (!target) {
|
|
37
|
+
console.warn(`[trigger:${name}] unknown agent "${message.mention}" — skipping`)
|
|
38
|
+
return
|
|
39
|
+
}
|
|
20
40
|
const roomId = await resolveRoom(message.room)
|
|
21
41
|
if (!roomId) {
|
|
22
42
|
console.warn(`[trigger:${name}] cannot resolve room ${message.room} — skipping`)
|
|
@@ -27,13 +47,31 @@ export async function fireTrigger(deps: FireTriggerDeps): Promise<void> {
|
|
|
27
47
|
roomId,
|
|
28
48
|
asUserId: as,
|
|
29
49
|
content: {
|
|
30
|
-
msgtype: 'm.text',
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
50
|
+
...buildMentionContent({ userId: target, text: message.text, msgtype: 'm.text' }),
|
|
51
|
+
'dev.zooid.trigger': {
|
|
52
|
+
name,
|
|
53
|
+
fired_at: Date.now(),
|
|
54
|
+
...(message.ttlMs !== undefined ? { ttl_ms: message.ttlMs } : {}),
|
|
55
|
+
},
|
|
35
56
|
},
|
|
36
57
|
})
|
|
58
|
+
|
|
59
|
+
// Post first, warn second: an absent target isn't a lost message —
|
|
60
|
+
// catch-up may still deliver it if that agent joins before it expires.
|
|
61
|
+
// Membership, not registration: router.ts routes only to agents joined
|
|
62
|
+
// to the room, so an agent that exists but isn't in the room misses the
|
|
63
|
+
// firing exactly as an unregistered one does.
|
|
64
|
+
if (getJoinedMembers) {
|
|
65
|
+
const { joined } = await getJoinedMembers(roomId, as)
|
|
66
|
+
const key = `${name}|${target}`
|
|
67
|
+
if (!(target in joined) && !warnedTargets.has(key)) {
|
|
68
|
+
warnedTargets.add(key)
|
|
69
|
+
console.warn(
|
|
70
|
+
`[trigger:${name}] ${target} is not in ${message.room} — nothing will handle this ` +
|
|
71
|
+
`unless that agent joins before the message expires`,
|
|
72
|
+
)
|
|
73
|
+
}
|
|
74
|
+
}
|
|
37
75
|
} catch (err) {
|
|
38
76
|
// Never throw: one bad firing must not take down the scheduler.
|
|
39
77
|
console.warn(`[trigger:${name}] failed:`, (err as Error).message)
|
|
@@ -8,6 +8,7 @@ export interface StartTriggerSchedulerDeps {
|
|
|
8
8
|
resolveRoom: FireTriggerDeps['resolveRoom']
|
|
9
9
|
ensureBot: FireTriggerDeps['ensureBot']
|
|
10
10
|
sendMessage: FireTriggerDeps['sendMessage']
|
|
11
|
+
getJoinedMembers?: FireTriggerDeps['getJoinedMembers']
|
|
11
12
|
}
|
|
12
13
|
|
|
13
14
|
export interface TriggerSchedulerHandle {
|
|
@@ -29,19 +30,23 @@ export function validateCron(name: string, expr: string): void {
|
|
|
29
30
|
}
|
|
30
31
|
|
|
31
32
|
export function startTriggerScheduler(deps: StartTriggerSchedulerDeps): TriggerSchedulerHandle {
|
|
32
|
-
const { triggers, agentUserIds, resolveRoom, ensureBot, sendMessage } = deps
|
|
33
|
+
const { triggers, agentUserIds, resolveRoom, ensureBot, sendMessage, getJoinedMembers } = deps
|
|
33
34
|
const jobs: Cron[] = []
|
|
34
35
|
|
|
35
36
|
for (const [name, trigger] of Object.entries(triggers)) {
|
|
36
37
|
if (!trigger.schedule) continue
|
|
37
38
|
const job = new Cron(trigger.schedule, () => {
|
|
38
39
|
for (const message of trigger.messages) {
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
40
|
+
void fireTrigger({
|
|
41
|
+
name,
|
|
42
|
+
as: trigger.as,
|
|
43
|
+
message,
|
|
44
|
+
agentUserIds,
|
|
45
|
+
resolveRoom,
|
|
46
|
+
ensureBot,
|
|
47
|
+
sendMessage,
|
|
48
|
+
getJoinedMembers,
|
|
49
|
+
})
|
|
45
50
|
}
|
|
46
51
|
})
|
|
47
52
|
jobs.push(job)
|
|
@@ -84,6 +84,7 @@ export interface WebhookDeps {
|
|
|
84
84
|
resolveRoom: FireTriggerDeps['resolveRoom']
|
|
85
85
|
ensureBot: FireTriggerDeps['ensureBot']
|
|
86
86
|
sendMessage: FireTriggerDeps['sendMessage']
|
|
87
|
+
getJoinedMembers?: FireTriggerDeps['getJoinedMembers']
|
|
87
88
|
}
|
|
88
89
|
|
|
89
90
|
/**
|
|
@@ -166,20 +167,15 @@ async function handleDelivery(
|
|
|
166
167
|
for (const message of trigger.messages) {
|
|
167
168
|
if (message.match !== undefined && !evaluateMatch(message.match, ctx)) continue
|
|
168
169
|
|
|
169
|
-
const agentUserId = deps.agentUserIds[message.mention]
|
|
170
|
-
if (!agentUserId) {
|
|
171
|
-
console.warn(`[webhook:${name}] unknown agent "${message.mention}" — skipping`)
|
|
172
|
-
continue
|
|
173
|
-
}
|
|
174
|
-
|
|
175
170
|
await fireTrigger({
|
|
176
171
|
name,
|
|
177
172
|
as: trigger.as,
|
|
178
173
|
message: { ...message, text: renderTemplate(message.text, ctx) },
|
|
179
|
-
|
|
174
|
+
agentUserIds: deps.agentUserIds,
|
|
180
175
|
resolveRoom: deps.resolveRoom,
|
|
181
176
|
ensureBot: deps.ensureBot,
|
|
182
177
|
sendMessage: deps.sendMessage,
|
|
178
|
+
getJoinedMembers: deps.getJoinedMembers,
|
|
183
179
|
})
|
|
184
180
|
}
|
|
185
181
|
} catch (err) {
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { TapEvent } from '@zooid/core'
|
|
1
|
+
import type { TapEvent, SessionLifecycleEvent } from '@zooid/core'
|
|
2
2
|
import type { LogPaths } from './paths.js'
|
|
3
3
|
import { JsonlSink, shouldCaptureUpdate, type Verbosity } from './file-sink.js'
|
|
4
4
|
|
|
@@ -19,13 +19,28 @@ export interface WireAgentCaptureOpts {
|
|
|
19
19
|
|
|
20
20
|
export interface AgentCapture {
|
|
21
21
|
onTap: (event: TapEvent) => void
|
|
22
|
+
onLifecycle: (event: SessionLifecycleEvent) => void
|
|
23
|
+
readLifecycleCounters: () => LifecycleCounters
|
|
22
24
|
close: () => Promise<void>
|
|
23
25
|
}
|
|
24
26
|
|
|
27
|
+
export interface LifecycleCounters {
|
|
28
|
+
closeAttempts: number
|
|
29
|
+
closeSuccesses: number
|
|
30
|
+
sessionsReclaimed: number
|
|
31
|
+
recoveries: Record<'cached' | 'resume' | 'load' | 'new', number>
|
|
32
|
+
}
|
|
33
|
+
|
|
25
34
|
export function wireAgentCapture(opts: WireAgentCaptureOpts): AgentCapture {
|
|
26
35
|
const sink = new JsonlSink(opts.paths.agentTap(opts.agent))
|
|
27
36
|
const now = opts.now ?? (() => new Date())
|
|
28
37
|
const pending: Promise<unknown>[] = []
|
|
38
|
+
const counters: LifecycleCounters = {
|
|
39
|
+
closeAttempts: 0,
|
|
40
|
+
closeSuccesses: 0,
|
|
41
|
+
sessionsReclaimed: 0,
|
|
42
|
+
recoveries: { cached: 0, resume: 0, load: 0, new: 0 },
|
|
43
|
+
}
|
|
29
44
|
|
|
30
45
|
const onTap = (event: TapEvent): void => {
|
|
31
46
|
if (event.kind === 'session_update') {
|
|
@@ -50,6 +65,25 @@ export function wireAgentCapture(opts: WireAgentCaptureOpts): AgentCapture {
|
|
|
50
65
|
|
|
51
66
|
return {
|
|
52
67
|
onTap,
|
|
68
|
+
onLifecycle: (event) => {
|
|
69
|
+
if (event.reason && event.outcome !== 'unsupported') counters.closeAttempts++
|
|
70
|
+
if (event.reason && event.outcome === 'closed') {
|
|
71
|
+
counters.closeSuccesses++
|
|
72
|
+
counters.sessionsReclaimed++
|
|
73
|
+
}
|
|
74
|
+
if (event.outcome === 'recovered' && event.recoveryMethod) counters.recoveries[event.recoveryMethod]++
|
|
75
|
+
pending.push(sink.write({
|
|
76
|
+
ts: now().toISOString(),
|
|
77
|
+
agent: opts.agent,
|
|
78
|
+
kind: 'session_lifecycle',
|
|
79
|
+
session_key: event.sessionKey,
|
|
80
|
+
session_id: event.sessionId,
|
|
81
|
+
reason: event.reason,
|
|
82
|
+
outcome: event.outcome,
|
|
83
|
+
recovery_method: event.recoveryMethod,
|
|
84
|
+
}))
|
|
85
|
+
},
|
|
86
|
+
readLifecycleCounters: () => ({ ...counters, recoveries: { ...counters.recoveries } }),
|
|
53
87
|
close: async () => {
|
|
54
88
|
await Promise.all(pending)
|
|
55
89
|
await sink.close()
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { describe, expect, it, vi } from 'vitest'
|
|
2
|
+
import type { SessionLifecycleEvent } from '@zooid/core'
|
|
3
|
+
import { formatLifecycleLine, logLifecycle } from './lifecycle-log.js'
|
|
4
|
+
|
|
5
|
+
const base = { agentId: 'architect', sessionKey: '$root', sessionId: 's-1' }
|
|
6
|
+
|
|
7
|
+
function sink() {
|
|
8
|
+
return { log: vi.fn(), error: vi.fn() }
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
describe('lifecycle log', () => {
|
|
12
|
+
it('formats closes with reason and outcome', () => {
|
|
13
|
+
expect(formatLifecycleLine('architect', { ...base, reason: 'idle', outcome: 'closed' })).toBe(
|
|
14
|
+
'[lifecycle] architect key=$root session=s-1 idle → closed',
|
|
15
|
+
)
|
|
16
|
+
expect(formatLifecycleLine('architect', { ...base, reason: 'clear', outcome: 'closed' })).toBe(
|
|
17
|
+
'[lifecycle] architect key=$root session=s-1 clear → closed',
|
|
18
|
+
)
|
|
19
|
+
})
|
|
20
|
+
|
|
21
|
+
it('formats recoveries with their method', () => {
|
|
22
|
+
expect(
|
|
23
|
+
formatLifecycleLine('architect', { ...base, outcome: 'recovered', recoveryMethod: 'resume' }),
|
|
24
|
+
).toBe('[lifecycle] architect key=$root session=s-1 recovered (resume)')
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
it('skips cached hits, which fire on every prompt to a live session', () => {
|
|
28
|
+
expect(
|
|
29
|
+
formatLifecycleLine('architect', { ...base, outcome: 'recovered', recoveryMethod: 'cached' }),
|
|
30
|
+
).toBeNull()
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
it('routes failed closes to stderr and the rest to stdout', () => {
|
|
34
|
+
const out = sink()
|
|
35
|
+
logLifecycle('architect', { ...base, reason: 'idle', outcome: 'failed' }, out)
|
|
36
|
+
logLifecycle('architect', { ...base, reason: 'idle', outcome: 'closed' }, out)
|
|
37
|
+
expect(out.error).toHaveBeenCalledWith('[lifecycle] architect key=$root session=s-1 idle → failed')
|
|
38
|
+
expect(out.log).toHaveBeenCalledTimes(1)
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
it('skips unsupported, which the ACP client already warns about', () => {
|
|
42
|
+
const out = sink()
|
|
43
|
+
const event: SessionLifecycleEvent = { ...base, reason: 'idle', outcome: 'unsupported' }
|
|
44
|
+
logLifecycle('architect', event, out)
|
|
45
|
+
expect(out.log).not.toHaveBeenCalled()
|
|
46
|
+
expect(out.error).not.toHaveBeenCalled()
|
|
47
|
+
})
|
|
48
|
+
})
|