@swarmclawai/swarmclaw 1.0.0 → 1.0.3
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 +15 -2
- package/bin/doctor-cmd.js +155 -0
- package/bin/doctor-cmd.test.js +50 -0
- package/bin/install-root.js +39 -0
- package/bin/install-root.test.js +60 -0
- package/bin/server-cmd.js +160 -38
- package/bin/swarmclaw.js +83 -3
- package/bin/update-cmd.js +1 -6
- package/bin/update-cmd.test.js +1 -36
- package/bin/worker-cmd.js +13 -6
- package/package.json +16 -15
- package/scripts/postinstall.mjs +17 -13
- package/src/app/api/gateways/[id]/health/route.ts +2 -32
- package/src/app/api/gateways/health-route.test.ts +1 -1
- package/src/app/api/setup/check-provider/helpers.ts +28 -0
- package/src/app/api/setup/check-provider/route.test.ts +1 -1
- package/src/app/api/setup/check-provider/route.ts +5 -32
- package/src/app/api/tasks/import/github/helpers.ts +100 -0
- package/src/app/api/tasks/import/github/route.test.ts +1 -1
- package/src/app/api/tasks/import/github/route.ts +2 -92
- package/src/app/api/webhooks/[id]/helpers.ts +253 -0
- package/src/app/api/webhooks/[id]/route.ts +2 -243
- package/src/app/api/webhooks/route.test.ts +4 -2
- package/src/app/usage/page.tsx +22 -12
- package/src/cli/binary.test.js +57 -0
- package/src/cli/index.js +13 -1
- package/src/cli/server-cmd.test.js +77 -0
- package/src/lib/server/data-dir.test.ts +38 -3
- package/src/lib/server/data-dir.ts +11 -0
- package/src/lib/server/openclaw/health.ts +30 -1
- package/src/lib/server/session-tools/file-send.test.ts +18 -2
- package/src/lib/server/session-tools/file.ts +11 -7
- package/src/lib/server/skills/skill-discovery.test.ts +34 -1
- package/src/lib/server/skills/skill-discovery.ts +9 -4
|
@@ -0,0 +1,253 @@
|
|
|
1
|
+
import { genId } from '@/lib/id'
|
|
2
|
+
import { NextResponse } from 'next/server'
|
|
3
|
+
import { loadAgents, loadSessions, saveSessions, loadWebhooks, appendWebhookLog, upsertWebhookRetry } from '@/lib/server/storage'
|
|
4
|
+
import { WORKSPACE_DIR } from '@/lib/server/data-dir'
|
|
5
|
+
import { enqueueSessionRun } from '@/lib/server/runtime/session-run-manager'
|
|
6
|
+
import { enqueueSystemEvent } from '@/lib/server/runtime/system-events'
|
|
7
|
+
import { requestHeartbeatNow } from '@/lib/server/runtime/heartbeat-wake'
|
|
8
|
+
import { notFound } from '@/lib/server/collection-helpers'
|
|
9
|
+
import type { WebhookRetryEntry } from '@/types'
|
|
10
|
+
import { triggerWebhookWatchJobs } from '@/lib/server/runtime/watch-jobs'
|
|
11
|
+
import { errorMessage } from '@/lib/shared-utils'
|
|
12
|
+
|
|
13
|
+
export type WebhookPostDeps = {
|
|
14
|
+
enqueueRun: typeof enqueueSessionRun
|
|
15
|
+
enqueueEvent: typeof enqueueSystemEvent
|
|
16
|
+
requestHeartbeat: typeof requestHeartbeatNow
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export const defaultWebhookPostDeps: WebhookPostDeps = {
|
|
20
|
+
enqueueRun: enqueueSessionRun,
|
|
21
|
+
enqueueEvent: enqueueSystemEvent,
|
|
22
|
+
requestHeartbeat: requestHeartbeatNow,
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function normalizeEvents(value: unknown): string[] {
|
|
26
|
+
if (!Array.isArray(value)) return []
|
|
27
|
+
return value
|
|
28
|
+
.filter((v): v is string => typeof v === 'string')
|
|
29
|
+
.map((v) => v.trim())
|
|
30
|
+
.filter(Boolean)
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function eventMatches(registered: string[], incoming: string): boolean {
|
|
34
|
+
if (registered.length === 0) return true
|
|
35
|
+
if (registered.includes('*')) return true
|
|
36
|
+
return registered.includes(incoming)
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export async function handleWebhookPost(
|
|
40
|
+
req: Request,
|
|
41
|
+
id: string,
|
|
42
|
+
deps: WebhookPostDeps = defaultWebhookPostDeps,
|
|
43
|
+
) {
|
|
44
|
+
const webhooks = loadWebhooks()
|
|
45
|
+
const webhook = webhooks[id]
|
|
46
|
+
if (!webhook) return notFound('Webhook not found')
|
|
47
|
+
if (webhook.isEnabled === false) {
|
|
48
|
+
appendWebhookLog(genId(8), {
|
|
49
|
+
id: genId(8), webhookId: id, event: 'unknown',
|
|
50
|
+
payload: '', status: 'error', error: 'Webhook is disabled', timestamp: Date.now(),
|
|
51
|
+
})
|
|
52
|
+
return NextResponse.json({ error: 'Webhook is disabled' }, { status: 409 })
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const { timingSafeEqual } = await import('node:crypto')
|
|
56
|
+
const secret = typeof webhook.secret === 'string' ? webhook.secret.trim() : ''
|
|
57
|
+
if (secret) {
|
|
58
|
+
const url = new URL(req.url)
|
|
59
|
+
const provided = req.headers.get('x-webhook-secret') || url.searchParams.get('secret') || ''
|
|
60
|
+
const secretBuf = Buffer.from(secret)
|
|
61
|
+
const providedBuf = Buffer.from(provided)
|
|
62
|
+
// timingSafeEqual requires equal lengths; compare against secretBuf if lengths differ
|
|
63
|
+
const compareBuf = providedBuf.length === secretBuf.length ? providedBuf : secretBuf
|
|
64
|
+
const isInvalid = providedBuf.length !== secretBuf.length || !timingSafeEqual(secretBuf, compareBuf)
|
|
65
|
+
if (isInvalid) {
|
|
66
|
+
appendWebhookLog(genId(8), {
|
|
67
|
+
id: genId(8), webhookId: id, event: 'unknown',
|
|
68
|
+
payload: '', status: 'error', error: 'Invalid webhook secret', timestamp: Date.now(),
|
|
69
|
+
})
|
|
70
|
+
return NextResponse.json({ error: 'Invalid webhook secret' }, { status: 401 })
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
let payload: unknown = null
|
|
75
|
+
let rawBody = ''
|
|
76
|
+
const contentType = req.headers.get('content-type') || ''
|
|
77
|
+
if (contentType.includes('application/json')) {
|
|
78
|
+
try {
|
|
79
|
+
payload = await req.json()
|
|
80
|
+
rawBody = JSON.stringify(payload)
|
|
81
|
+
} catch {
|
|
82
|
+
payload = {}
|
|
83
|
+
rawBody = '{}'
|
|
84
|
+
}
|
|
85
|
+
} else {
|
|
86
|
+
rawBody = await req.text()
|
|
87
|
+
try {
|
|
88
|
+
payload = JSON.parse(rawBody)
|
|
89
|
+
} catch {
|
|
90
|
+
payload = { raw: rawBody }
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const url = new URL(req.url)
|
|
95
|
+
const incomingEvent = String(
|
|
96
|
+
(payload as Record<string, unknown> | null)?.type
|
|
97
|
+
|| (payload as Record<string, unknown> | null)?.event
|
|
98
|
+
|| req.headers.get('x-event-type')
|
|
99
|
+
|| url.searchParams.get('event')
|
|
100
|
+
|| 'unknown',
|
|
101
|
+
)
|
|
102
|
+
const registeredEvents = normalizeEvents(webhook.events)
|
|
103
|
+
if (!eventMatches(registeredEvents, incomingEvent)) {
|
|
104
|
+
return NextResponse.json({
|
|
105
|
+
ok: true,
|
|
106
|
+
ignored: true,
|
|
107
|
+
reason: 'Event does not match webhook filters',
|
|
108
|
+
event: incomingEvent,
|
|
109
|
+
})
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
triggerWebhookWatchJobs({
|
|
113
|
+
webhookId: id,
|
|
114
|
+
event: incomingEvent,
|
|
115
|
+
payloadPreview: rawBody,
|
|
116
|
+
})
|
|
117
|
+
|
|
118
|
+
const agents = loadAgents()
|
|
119
|
+
const agent = webhook.agentId ? agents[webhook.agentId] : null
|
|
120
|
+
if (!agent) {
|
|
121
|
+
appendWebhookLog(genId(8), {
|
|
122
|
+
id: genId(8), webhookId: id, event: incomingEvent,
|
|
123
|
+
payload: (rawBody || '').slice(0, 2000), status: 'error', error: 'Webhook agent is not configured or missing', timestamp: Date.now(),
|
|
124
|
+
})
|
|
125
|
+
return NextResponse.json({ error: 'Webhook agent is not configured or missing' }, { status: 400 })
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
const sessions = loadSessions()
|
|
129
|
+
const sessionName = `webhook:${id}`
|
|
130
|
+
let session = Object.values(sessions).find((s: unknown) => {
|
|
131
|
+
const rec = s as Record<string, unknown>
|
|
132
|
+
return rec.name === sessionName && rec.agentId === agent.id
|
|
133
|
+
}) as Record<string, unknown> | undefined
|
|
134
|
+
if (!session) {
|
|
135
|
+
const sessionId = genId()
|
|
136
|
+
const now = Date.now()
|
|
137
|
+
session = {
|
|
138
|
+
id: sessionId,
|
|
139
|
+
name: sessionName,
|
|
140
|
+
cwd: WORKSPACE_DIR,
|
|
141
|
+
user: 'system',
|
|
142
|
+
provider: agent.provider || 'claude-cli',
|
|
143
|
+
model: agent.model || '',
|
|
144
|
+
credentialId: agent.credentialId || null,
|
|
145
|
+
apiEndpoint: agent.apiEndpoint || null,
|
|
146
|
+
claudeSessionId: null,
|
|
147
|
+
codexThreadId: null,
|
|
148
|
+
opencodeSessionId: null,
|
|
149
|
+
delegateResumeIds: {
|
|
150
|
+
claudeCode: null,
|
|
151
|
+
codex: null,
|
|
152
|
+
opencode: null,
|
|
153
|
+
},
|
|
154
|
+
messages: [],
|
|
155
|
+
createdAt: now,
|
|
156
|
+
lastActiveAt: now,
|
|
157
|
+
sessionType: 'human',
|
|
158
|
+
agentId: agent.id,
|
|
159
|
+
parentSessionId: null,
|
|
160
|
+
tools: agent.tools || [],
|
|
161
|
+
heartbeatEnabled: agent.heartbeatEnabled ?? false,
|
|
162
|
+
heartbeatIntervalSec: agent.heartbeatIntervalSec ?? null,
|
|
163
|
+
}
|
|
164
|
+
sessions[session.id as string] = session
|
|
165
|
+
saveSessions(sessions)
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
const sid = session.id as string
|
|
169
|
+
const payloadPreview = (rawBody || '').slice(0, 12_000)
|
|
170
|
+
const prompt = [
|
|
171
|
+
'Webhook event received.',
|
|
172
|
+
`Webhook ID: ${id}`,
|
|
173
|
+
`Webhook Name: ${webhook.name || id}`,
|
|
174
|
+
`Source: ${webhook.source || 'custom'}`,
|
|
175
|
+
`Event: ${incomingEvent}`,
|
|
176
|
+
`Received At: ${new Date().toISOString()}`,
|
|
177
|
+
'',
|
|
178
|
+
'Payload:',
|
|
179
|
+
payloadPreview || '(empty payload)',
|
|
180
|
+
'',
|
|
181
|
+
'Handle this event now. If this requires notifying the user, use configured connector tools.',
|
|
182
|
+
].join('\n')
|
|
183
|
+
|
|
184
|
+
try {
|
|
185
|
+
const run = deps.enqueueRun({
|
|
186
|
+
sessionId: sid,
|
|
187
|
+
message: prompt,
|
|
188
|
+
source: 'webhook',
|
|
189
|
+
internal: false,
|
|
190
|
+
mode: 'followup',
|
|
191
|
+
})
|
|
192
|
+
|
|
193
|
+
// Enqueue system event + heartbeat wake
|
|
194
|
+
deps.enqueueEvent(sid, `Webhook received: ${webhook.name || id} (${incomingEvent})`)
|
|
195
|
+
if (webhook.agentId) {
|
|
196
|
+
deps.requestHeartbeat({
|
|
197
|
+
agentId: webhook.agentId,
|
|
198
|
+
eventId: `webhook:${id}:${incomingEvent}:${Date.now()}`,
|
|
199
|
+
reason: 'webhook',
|
|
200
|
+
source: `webhook:${id}`,
|
|
201
|
+
resumeMessage: `Webhook received: ${webhook.name || id} (${incomingEvent})`,
|
|
202
|
+
detail: payloadPreview || '(empty payload)',
|
|
203
|
+
})
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
appendWebhookLog(genId(8), {
|
|
207
|
+
id: genId(8), webhookId: id, event: incomingEvent,
|
|
208
|
+
payload: (rawBody || '').slice(0, 2000), status: 'success',
|
|
209
|
+
sessionId: sid, runId: run.runId, timestamp: Date.now(),
|
|
210
|
+
})
|
|
211
|
+
|
|
212
|
+
return NextResponse.json({
|
|
213
|
+
ok: true,
|
|
214
|
+
webhookId: id,
|
|
215
|
+
event: incomingEvent,
|
|
216
|
+
sessionId: sid,
|
|
217
|
+
runId: run.runId,
|
|
218
|
+
})
|
|
219
|
+
} catch (err: unknown) {
|
|
220
|
+
const errorMsg = errorMessage(err)
|
|
221
|
+
|
|
222
|
+
// Enqueue for retry with exponential backoff
|
|
223
|
+
const retryId = genId()
|
|
224
|
+
const now = Date.now()
|
|
225
|
+
const retryEntry: WebhookRetryEntry = {
|
|
226
|
+
id: retryId,
|
|
227
|
+
webhookId: id,
|
|
228
|
+
event: incomingEvent,
|
|
229
|
+
payload: (rawBody || '').slice(0, 12_000),
|
|
230
|
+
attempts: 1,
|
|
231
|
+
maxAttempts: 3,
|
|
232
|
+
nextRetryAt: now + 30_000,
|
|
233
|
+
deadLettered: false,
|
|
234
|
+
createdAt: now,
|
|
235
|
+
}
|
|
236
|
+
upsertWebhookRetry(retryId, retryEntry)
|
|
237
|
+
|
|
238
|
+
appendWebhookLog(genId(8), {
|
|
239
|
+
id: genId(8), webhookId: id, event: incomingEvent,
|
|
240
|
+
payload: (rawBody || '').slice(0, 2000), status: 'error',
|
|
241
|
+
error: `Dispatch failed, queued for retry: ${errorMsg}`, timestamp: Date.now(),
|
|
242
|
+
})
|
|
243
|
+
|
|
244
|
+
return NextResponse.json({
|
|
245
|
+
ok: true,
|
|
246
|
+
webhookId: id,
|
|
247
|
+
event: incomingEvent,
|
|
248
|
+
retryQueued: true,
|
|
249
|
+
retryId,
|
|
250
|
+
error: errorMsg,
|
|
251
|
+
})
|
|
252
|
+
}
|
|
253
|
+
}
|
|
@@ -1,31 +1,11 @@
|
|
|
1
|
-
import { genId } from '@/lib/id'
|
|
2
|
-
import { timingSafeEqual } from 'node:crypto'
|
|
3
1
|
import { NextResponse } from 'next/server'
|
|
4
|
-
import {
|
|
5
|
-
import { WORKSPACE_DIR } from '@/lib/server/data-dir'
|
|
6
|
-
import { enqueueSessionRun } from '@/lib/server/runtime/session-run-manager'
|
|
7
|
-
import { enqueueSystemEvent } from '@/lib/server/runtime/system-events'
|
|
8
|
-
import { requestHeartbeatNow } from '@/lib/server/runtime/heartbeat-wake'
|
|
2
|
+
import { loadWebhooks, saveWebhooks } from '@/lib/server/storage'
|
|
9
3
|
import { mutateItem, deleteItem, notFound, type CollectionOps } from '@/lib/server/collection-helpers'
|
|
10
|
-
import
|
|
11
|
-
import { triggerWebhookWatchJobs } from '@/lib/server/runtime/watch-jobs'
|
|
12
|
-
import { errorMessage } from '@/lib/shared-utils'
|
|
4
|
+
import { handleWebhookPost } from './helpers'
|
|
13
5
|
|
|
14
6
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
15
7
|
const ops: CollectionOps<any> = { load: loadWebhooks, save: saveWebhooks }
|
|
16
8
|
|
|
17
|
-
type WebhookPostDeps = {
|
|
18
|
-
enqueueRun: typeof enqueueSessionRun
|
|
19
|
-
enqueueEvent: typeof enqueueSystemEvent
|
|
20
|
-
requestHeartbeat: typeof requestHeartbeatNow
|
|
21
|
-
}
|
|
22
|
-
|
|
23
|
-
const defaultWebhookPostDeps: WebhookPostDeps = {
|
|
24
|
-
enqueueRun: enqueueSessionRun,
|
|
25
|
-
enqueueEvent: enqueueSystemEvent,
|
|
26
|
-
requestHeartbeat: requestHeartbeatNow,
|
|
27
|
-
}
|
|
28
|
-
|
|
29
9
|
function normalizeEvents(value: unknown): string[] {
|
|
30
10
|
if (!Array.isArray(value)) return []
|
|
31
11
|
return value
|
|
@@ -34,12 +14,6 @@ function normalizeEvents(value: unknown): string[] {
|
|
|
34
14
|
.filter(Boolean)
|
|
35
15
|
}
|
|
36
16
|
|
|
37
|
-
function eventMatches(registered: string[], incoming: string): boolean {
|
|
38
|
-
if (registered.length === 0) return true
|
|
39
|
-
if (registered.includes('*')) return true
|
|
40
|
-
return registered.includes(incoming)
|
|
41
|
-
}
|
|
42
|
-
|
|
43
17
|
export async function GET(_req: Request, { params }: { params: Promise<{ id: string }> }) {
|
|
44
18
|
const { id } = await params
|
|
45
19
|
const webhooks = loadWebhooks()
|
|
@@ -71,221 +45,6 @@ export async function DELETE(_req: Request, { params }: { params: Promise<{ id:
|
|
|
71
45
|
return NextResponse.json({ ok: true })
|
|
72
46
|
}
|
|
73
47
|
|
|
74
|
-
export async function handleWebhookPost(
|
|
75
|
-
req: Request,
|
|
76
|
-
id: string,
|
|
77
|
-
deps: WebhookPostDeps = defaultWebhookPostDeps,
|
|
78
|
-
) {
|
|
79
|
-
const webhooks = loadWebhooks()
|
|
80
|
-
const webhook = webhooks[id]
|
|
81
|
-
if (!webhook) return notFound('Webhook not found')
|
|
82
|
-
if (webhook.isEnabled === false) {
|
|
83
|
-
appendWebhookLog(genId(8), {
|
|
84
|
-
id: genId(8), webhookId: id, event: 'unknown',
|
|
85
|
-
payload: '', status: 'error', error: 'Webhook is disabled', timestamp: Date.now(),
|
|
86
|
-
})
|
|
87
|
-
return NextResponse.json({ error: 'Webhook is disabled' }, { status: 409 })
|
|
88
|
-
}
|
|
89
|
-
|
|
90
|
-
const secret = typeof webhook.secret === 'string' ? webhook.secret.trim() : ''
|
|
91
|
-
if (secret) {
|
|
92
|
-
const url = new URL(req.url)
|
|
93
|
-
const provided = req.headers.get('x-webhook-secret') || url.searchParams.get('secret') || ''
|
|
94
|
-
const secretBuf = Buffer.from(secret)
|
|
95
|
-
const providedBuf = Buffer.from(provided)
|
|
96
|
-
// timingSafeEqual requires equal lengths; compare against secretBuf if lengths differ
|
|
97
|
-
const compareBuf = providedBuf.length === secretBuf.length ? providedBuf : secretBuf
|
|
98
|
-
const isInvalid = providedBuf.length !== secretBuf.length || !timingSafeEqual(secretBuf, compareBuf)
|
|
99
|
-
if (isInvalid) {
|
|
100
|
-
appendWebhookLog(genId(8), {
|
|
101
|
-
id: genId(8), webhookId: id, event: 'unknown',
|
|
102
|
-
payload: '', status: 'error', error: 'Invalid webhook secret', timestamp: Date.now(),
|
|
103
|
-
})
|
|
104
|
-
return NextResponse.json({ error: 'Invalid webhook secret' }, { status: 401 })
|
|
105
|
-
}
|
|
106
|
-
}
|
|
107
|
-
|
|
108
|
-
let payload: unknown = null
|
|
109
|
-
let rawBody = ''
|
|
110
|
-
const contentType = req.headers.get('content-type') || ''
|
|
111
|
-
if (contentType.includes('application/json')) {
|
|
112
|
-
try {
|
|
113
|
-
payload = await req.json()
|
|
114
|
-
rawBody = JSON.stringify(payload)
|
|
115
|
-
} catch {
|
|
116
|
-
payload = {}
|
|
117
|
-
rawBody = '{}'
|
|
118
|
-
}
|
|
119
|
-
} else {
|
|
120
|
-
rawBody = await req.text()
|
|
121
|
-
try {
|
|
122
|
-
payload = JSON.parse(rawBody)
|
|
123
|
-
} catch {
|
|
124
|
-
payload = { raw: rawBody }
|
|
125
|
-
}
|
|
126
|
-
}
|
|
127
|
-
|
|
128
|
-
const url = new URL(req.url)
|
|
129
|
-
const incomingEvent = String(
|
|
130
|
-
(payload as Record<string, unknown> | null)?.type
|
|
131
|
-
|| (payload as Record<string, unknown> | null)?.event
|
|
132
|
-
|| req.headers.get('x-event-type')
|
|
133
|
-
|| url.searchParams.get('event')
|
|
134
|
-
|| 'unknown',
|
|
135
|
-
)
|
|
136
|
-
const registeredEvents = normalizeEvents(webhook.events)
|
|
137
|
-
if (!eventMatches(registeredEvents, incomingEvent)) {
|
|
138
|
-
return NextResponse.json({
|
|
139
|
-
ok: true,
|
|
140
|
-
ignored: true,
|
|
141
|
-
reason: 'Event does not match webhook filters',
|
|
142
|
-
event: incomingEvent,
|
|
143
|
-
})
|
|
144
|
-
}
|
|
145
|
-
|
|
146
|
-
triggerWebhookWatchJobs({
|
|
147
|
-
webhookId: id,
|
|
148
|
-
event: incomingEvent,
|
|
149
|
-
payloadPreview: rawBody,
|
|
150
|
-
})
|
|
151
|
-
|
|
152
|
-
const agents = loadAgents()
|
|
153
|
-
const agent = webhook.agentId ? agents[webhook.agentId] : null
|
|
154
|
-
if (!agent) {
|
|
155
|
-
appendWebhookLog(genId(8), {
|
|
156
|
-
id: genId(8), webhookId: id, event: incomingEvent,
|
|
157
|
-
payload: (rawBody || '').slice(0, 2000), status: 'error', error: 'Webhook agent is not configured or missing', timestamp: Date.now(),
|
|
158
|
-
})
|
|
159
|
-
return NextResponse.json({ error: 'Webhook agent is not configured or missing' }, { status: 400 })
|
|
160
|
-
}
|
|
161
|
-
|
|
162
|
-
const sessions = loadSessions()
|
|
163
|
-
const sessionName = `webhook:${id}`
|
|
164
|
-
let session = Object.values(sessions).find((s: unknown) => {
|
|
165
|
-
const rec = s as Record<string, unknown>
|
|
166
|
-
return rec.name === sessionName && rec.agentId === agent.id
|
|
167
|
-
}) as Record<string, unknown> | undefined
|
|
168
|
-
if (!session) {
|
|
169
|
-
const sessionId = genId()
|
|
170
|
-
const now = Date.now()
|
|
171
|
-
session = {
|
|
172
|
-
id: sessionId,
|
|
173
|
-
name: sessionName,
|
|
174
|
-
cwd: WORKSPACE_DIR,
|
|
175
|
-
user: 'system',
|
|
176
|
-
provider: agent.provider || 'claude-cli',
|
|
177
|
-
model: agent.model || '',
|
|
178
|
-
credentialId: agent.credentialId || null,
|
|
179
|
-
apiEndpoint: agent.apiEndpoint || null,
|
|
180
|
-
claudeSessionId: null,
|
|
181
|
-
codexThreadId: null,
|
|
182
|
-
opencodeSessionId: null,
|
|
183
|
-
delegateResumeIds: {
|
|
184
|
-
claudeCode: null,
|
|
185
|
-
codex: null,
|
|
186
|
-
opencode: null,
|
|
187
|
-
},
|
|
188
|
-
messages: [],
|
|
189
|
-
createdAt: now,
|
|
190
|
-
lastActiveAt: now,
|
|
191
|
-
sessionType: 'human',
|
|
192
|
-
agentId: agent.id,
|
|
193
|
-
parentSessionId: null,
|
|
194
|
-
tools: agent.tools || [],
|
|
195
|
-
heartbeatEnabled: agent.heartbeatEnabled ?? false,
|
|
196
|
-
heartbeatIntervalSec: agent.heartbeatIntervalSec ?? null,
|
|
197
|
-
}
|
|
198
|
-
sessions[session.id as string] = session
|
|
199
|
-
saveSessions(sessions)
|
|
200
|
-
}
|
|
201
|
-
|
|
202
|
-
const sid = session.id as string
|
|
203
|
-
const payloadPreview = (rawBody || '').slice(0, 12_000)
|
|
204
|
-
const prompt = [
|
|
205
|
-
'Webhook event received.',
|
|
206
|
-
`Webhook ID: ${id}`,
|
|
207
|
-
`Webhook Name: ${webhook.name || id}`,
|
|
208
|
-
`Source: ${webhook.source || 'custom'}`,
|
|
209
|
-
`Event: ${incomingEvent}`,
|
|
210
|
-
`Received At: ${new Date().toISOString()}`,
|
|
211
|
-
'',
|
|
212
|
-
'Payload:',
|
|
213
|
-
payloadPreview || '(empty payload)',
|
|
214
|
-
'',
|
|
215
|
-
'Handle this event now. If this requires notifying the user, use configured connector tools.',
|
|
216
|
-
].join('\n')
|
|
217
|
-
|
|
218
|
-
try {
|
|
219
|
-
const run = deps.enqueueRun({
|
|
220
|
-
sessionId: sid,
|
|
221
|
-
message: prompt,
|
|
222
|
-
source: 'webhook',
|
|
223
|
-
internal: false,
|
|
224
|
-
mode: 'followup',
|
|
225
|
-
})
|
|
226
|
-
|
|
227
|
-
// Enqueue system event + heartbeat wake
|
|
228
|
-
deps.enqueueEvent(sid, `Webhook received: ${webhook.name || id} (${incomingEvent})`)
|
|
229
|
-
if (webhook.agentId) {
|
|
230
|
-
deps.requestHeartbeat({
|
|
231
|
-
agentId: webhook.agentId,
|
|
232
|
-
eventId: `webhook:${id}:${incomingEvent}:${Date.now()}`,
|
|
233
|
-
reason: 'webhook',
|
|
234
|
-
source: `webhook:${id}`,
|
|
235
|
-
resumeMessage: `Webhook received: ${webhook.name || id} (${incomingEvent})`,
|
|
236
|
-
detail: payloadPreview || '(empty payload)',
|
|
237
|
-
})
|
|
238
|
-
}
|
|
239
|
-
|
|
240
|
-
appendWebhookLog(genId(8), {
|
|
241
|
-
id: genId(8), webhookId: id, event: incomingEvent,
|
|
242
|
-
payload: (rawBody || '').slice(0, 2000), status: 'success',
|
|
243
|
-
sessionId: sid, runId: run.runId, timestamp: Date.now(),
|
|
244
|
-
})
|
|
245
|
-
|
|
246
|
-
return NextResponse.json({
|
|
247
|
-
ok: true,
|
|
248
|
-
webhookId: id,
|
|
249
|
-
event: incomingEvent,
|
|
250
|
-
sessionId: sid,
|
|
251
|
-
runId: run.runId,
|
|
252
|
-
})
|
|
253
|
-
} catch (err: unknown) {
|
|
254
|
-
const errorMsg = errorMessage(err)
|
|
255
|
-
|
|
256
|
-
// Enqueue for retry with exponential backoff
|
|
257
|
-
const retryId = genId()
|
|
258
|
-
const now = Date.now()
|
|
259
|
-
const retryEntry: WebhookRetryEntry = {
|
|
260
|
-
id: retryId,
|
|
261
|
-
webhookId: id,
|
|
262
|
-
event: incomingEvent,
|
|
263
|
-
payload: (rawBody || '').slice(0, 12_000),
|
|
264
|
-
attempts: 1,
|
|
265
|
-
maxAttempts: 3,
|
|
266
|
-
nextRetryAt: now + 30_000,
|
|
267
|
-
deadLettered: false,
|
|
268
|
-
createdAt: now,
|
|
269
|
-
}
|
|
270
|
-
upsertWebhookRetry(retryId, retryEntry)
|
|
271
|
-
|
|
272
|
-
appendWebhookLog(genId(8), {
|
|
273
|
-
id: genId(8), webhookId: id, event: incomingEvent,
|
|
274
|
-
payload: (rawBody || '').slice(0, 2000), status: 'error',
|
|
275
|
-
error: `Dispatch failed, queued for retry: ${errorMsg}`, timestamp: Date.now(),
|
|
276
|
-
})
|
|
277
|
-
|
|
278
|
-
return NextResponse.json({
|
|
279
|
-
ok: true,
|
|
280
|
-
webhookId: id,
|
|
281
|
-
event: incomingEvent,
|
|
282
|
-
retryQueued: true,
|
|
283
|
-
retryId,
|
|
284
|
-
error: errorMsg,
|
|
285
|
-
})
|
|
286
|
-
}
|
|
287
|
-
}
|
|
288
|
-
|
|
289
48
|
export async function POST(req: Request, { params }: { params: Promise<{ id: string }> }) {
|
|
290
49
|
const { id } = await params
|
|
291
50
|
return handleWebhookPost(req, id)
|
|
@@ -2,7 +2,7 @@ import assert from 'node:assert/strict'
|
|
|
2
2
|
import test, { afterEach } from 'node:test'
|
|
3
3
|
|
|
4
4
|
import { GET as getWebhookHistory } from './[id]/history/route'
|
|
5
|
-
import { handleWebhookPost } from './[id]/
|
|
5
|
+
import { handleWebhookPost } from './[id]/helpers'
|
|
6
6
|
import {
|
|
7
7
|
loadAgents,
|
|
8
8
|
loadSessions,
|
|
@@ -126,7 +126,9 @@ test('handleWebhookPost creates a session, records success history, and triggers
|
|
|
126
126
|
assert.match(String(calls.runs[0].message), /Event: build\.completed/)
|
|
127
127
|
|
|
128
128
|
assert.deepEqual(calls.events, [[sessionId, 'Webhook received: Webhook Smoke (build.completed)']])
|
|
129
|
-
assert.
|
|
129
|
+
assert.equal(calls.heartbeats.length, 1)
|
|
130
|
+
assert.equal(calls.heartbeats[0].agentId, 'agent-webhook-smoke')
|
|
131
|
+
assert.equal(calls.heartbeats[0].reason, 'webhook')
|
|
130
132
|
|
|
131
133
|
const logEntries = Object.values(loadWebhookLogs()) as Array<Record<string, unknown>>
|
|
132
134
|
const successEntry = logEntries.find((entry) => entry.webhookId === webhookId && entry.status === 'success')
|
package/src/app/usage/page.tsx
CHANGED
|
@@ -61,6 +61,16 @@ const CHART_COLORS = [
|
|
|
61
61
|
'#60A5FA', '#4ADE80',
|
|
62
62
|
]
|
|
63
63
|
|
|
64
|
+
function numericChartValue(value: unknown): number {
|
|
65
|
+
if (Array.isArray(value)) return numericChartValue(value[0])
|
|
66
|
+
if (typeof value === 'number') return Number.isFinite(value) ? value : 0
|
|
67
|
+
if (typeof value === 'string') {
|
|
68
|
+
const parsed = Number(value)
|
|
69
|
+
return Number.isFinite(parsed) ? parsed : 0
|
|
70
|
+
}
|
|
71
|
+
return 0
|
|
72
|
+
}
|
|
73
|
+
|
|
64
74
|
|
|
65
75
|
function formatBucketLabel(bucket: string, range: Range): string {
|
|
66
76
|
if (range === '24h') {
|
|
@@ -306,7 +316,7 @@ export default function UsagePage() {
|
|
|
306
316
|
<CartesianGrid strokeDasharray="3 3" stroke="rgba(255,255,255,0.06)" />
|
|
307
317
|
<XAxis dataKey="label" tick={{ fill: '#888', fontSize: 11 }} axisLine={false} tickLine={false} />
|
|
308
318
|
<YAxis tick={{ fill: '#888', fontSize: 11 }} axisLine={false} tickLine={false} tickFormatter={formatTokens} />
|
|
309
|
-
<Tooltip {...tooltipStyle} formatter={(value
|
|
319
|
+
<Tooltip {...tooltipStyle} formatter={(value) => [formatTokens(numericChartValue(value)), 'Tokens']} />
|
|
310
320
|
<Line type="monotone" dataKey="tokens" stroke="#818CF8" strokeWidth={2} dot={false} activeDot={{ r: 4, fill: '#818CF8' }} />
|
|
311
321
|
</LineChart>
|
|
312
322
|
</ResponsiveContainer>
|
|
@@ -324,8 +334,8 @@ export default function UsagePage() {
|
|
|
324
334
|
<BarChart data={providerData} margin={{ top: 5, right: 20, bottom: 5, left: 0 }}>
|
|
325
335
|
<CartesianGrid strokeDasharray="3 3" stroke="rgba(255,255,255,0.06)" />
|
|
326
336
|
<XAxis dataKey="name" tick={{ fill: '#888', fontSize: 11 }} axisLine={false} tickLine={false} />
|
|
327
|
-
<YAxis tick={{ fill: '#888', fontSize: 11 }} axisLine={false} tickLine={false} tickFormatter={(
|
|
328
|
-
<Tooltip {...tooltipStyle} formatter={(value
|
|
337
|
+
<YAxis tick={{ fill: '#888', fontSize: 11 }} axisLine={false} tickLine={false} tickFormatter={(value) => `$${numericChartValue(value)}`} />
|
|
338
|
+
<Tooltip {...tooltipStyle} formatter={(value) => [formatCost(numericChartValue(value)), 'Cost']} />
|
|
329
339
|
<Bar dataKey="cost" radius={[4, 4, 0, 0]}>
|
|
330
340
|
{providerData.map((_entry, i) => (
|
|
331
341
|
<Cell key={i} fill={CHART_COLORS[i % CHART_COLORS.length]} />
|
|
@@ -343,9 +353,9 @@ export default function UsagePage() {
|
|
|
343
353
|
<ResponsiveContainer width="100%" height={280}>
|
|
344
354
|
<BarChart data={agentData} layout="vertical" margin={{ top: 5, right: 20, bottom: 5, left: 0 }}>
|
|
345
355
|
<CartesianGrid strokeDasharray="3 3" stroke="rgba(255,255,255,0.06)" horizontal={false} />
|
|
346
|
-
<XAxis type="number" tick={{ fill: '#888', fontSize: 11 }} axisLine={false} tickLine={false} tickFormatter={(
|
|
356
|
+
<XAxis type="number" tick={{ fill: '#888', fontSize: 11 }} axisLine={false} tickLine={false} tickFormatter={(value) => `$${numericChartValue(value)}`} />
|
|
347
357
|
<YAxis type="category" dataKey="name" tick={{ fill: '#888', fontSize: 11 }} axisLine={false} tickLine={false} width={100} />
|
|
348
|
-
<Tooltip {...tooltipStyle} formatter={(value
|
|
358
|
+
<Tooltip {...tooltipStyle} formatter={(value) => [formatCost(numericChartValue(value)), 'Cost']} />
|
|
349
359
|
<Bar dataKey="cost" radius={[0, 4, 4, 0]}>
|
|
350
360
|
{agentData.map((_entry, i) => (
|
|
351
361
|
<Cell key={i} fill={CHART_COLORS[i % CHART_COLORS.length]} />
|
|
@@ -370,9 +380,9 @@ export default function UsagePage() {
|
|
|
370
380
|
<YAxis type="category" dataKey="name" tick={{ fill: '#888', fontSize: 11 }} axisLine={false} tickLine={false} width={120} />
|
|
371
381
|
<Tooltip
|
|
372
382
|
{...tooltipStyle}
|
|
373
|
-
formatter={(value
|
|
374
|
-
formatTokens(value
|
|
375
|
-
name === 'definitionTokens' ? 'Context (definitions)' : 'Invocations',
|
|
383
|
+
formatter={(value, name) => [
|
|
384
|
+
formatTokens(numericChartValue(value)),
|
|
385
|
+
String(name) === 'definitionTokens' ? 'Context (definitions)' : 'Invocations',
|
|
376
386
|
]}
|
|
377
387
|
/>
|
|
378
388
|
<Bar dataKey="definitionTokens" fill="#818CF8" radius={[0, 0, 0, 0]} stackId="a" name="definitionTokens" />
|
|
@@ -381,9 +391,9 @@ export default function UsagePage() {
|
|
|
381
391
|
verticalAlign="bottom"
|
|
382
392
|
iconType="circle"
|
|
383
393
|
iconSize={8}
|
|
384
|
-
formatter={(value
|
|
394
|
+
formatter={(value) => (
|
|
385
395
|
<span style={{ color: '#a0a0b0', fontSize: 11 }}>
|
|
386
|
-
{value === 'definitionTokens' ? 'Context (definitions)' : 'Invocations'}
|
|
396
|
+
{String(value) === 'definitionTokens' ? 'Context (definitions)' : 'Invocations'}
|
|
387
397
|
</span>
|
|
388
398
|
)}
|
|
389
399
|
/>
|
|
@@ -431,7 +441,7 @@ export default function UsagePage() {
|
|
|
431
441
|
<CartesianGrid strokeDasharray="3 3" stroke="rgba(255,255,255,0.06)" />
|
|
432
442
|
<XAxis dataKey="label" tick={{ fill: '#888', fontSize: 11 }} axisLine={false} tickLine={false} />
|
|
433
443
|
<YAxis tick={{ fill: '#888', fontSize: 11 }} axisLine={false} tickLine={false} allowDecimals={false} />
|
|
434
|
-
<Tooltip {...tooltipStyle} formatter={(value
|
|
444
|
+
<Tooltip {...tooltipStyle} formatter={(value) => [numericChartValue(value), 'Completed']} />
|
|
435
445
|
<Bar dataKey="count" fill="#34D399" radius={[4, 4, 0, 0]} />
|
|
436
446
|
</BarChart>
|
|
437
447
|
</ResponsiveContainer>
|
|
@@ -458,7 +468,7 @@ export default function UsagePage() {
|
|
|
458
468
|
<Bar dataKey="completed" fill="#34D399" radius={[4, 4, 0, 0]} stackId="a" name="Completed" />
|
|
459
469
|
<Bar dataKey="failed" fill="#F87171" radius={[4, 4, 0, 0]} stackId="a" name="Failed" />
|
|
460
470
|
<Legend verticalAlign="bottom" iconType="circle" iconSize={8}
|
|
461
|
-
formatter={(value
|
|
471
|
+
formatter={(value) => <span style={{ color: '#a0a0b0', fontSize: 11 }}>{String(value)}</span>} />
|
|
462
472
|
</BarChart>
|
|
463
473
|
</ResponsiveContainer>
|
|
464
474
|
) : (
|