@swarmclawai/swarmclaw 1.0.0 → 1.0.2
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/bin/doctor-cmd.js +149 -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 +37 -6
- 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 +8 -2
- package/package.json +9 -8
- 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/cli/binary.test.js +57 -0
- package/src/cli/index.js +13 -1
- package/src/lib/server/data-dir.test.ts +35 -0
- 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/cli/binary.test.js
CHANGED
|
@@ -119,6 +119,51 @@ test('binary server help exits successfully', () => {
|
|
|
119
119
|
assert.match(result.stdout, /Usage: swarmclaw server/i)
|
|
120
120
|
})
|
|
121
121
|
|
|
122
|
+
test('binary run alias routes to server help', () => {
|
|
123
|
+
const result = runBinary(['run', '--help'])
|
|
124
|
+
assert.equal(result.status, 0, result.stderr)
|
|
125
|
+
assert.match(result.stdout, /Usage: swarmclaw server/i)
|
|
126
|
+
})
|
|
127
|
+
|
|
128
|
+
test('binary help command shows root help', () => {
|
|
129
|
+
const result = runBinary(['help'])
|
|
130
|
+
assert.equal(result.status, 0, result.stderr)
|
|
131
|
+
assert.match(result.stdout, /SwarmClaw CLI/i)
|
|
132
|
+
assert.match(result.stdout, /swarmclaw help \[command\]/i)
|
|
133
|
+
})
|
|
134
|
+
|
|
135
|
+
test('binary help command shows command help for run alias', () => {
|
|
136
|
+
const result = runBinary(['help', 'run'])
|
|
137
|
+
assert.equal(result.status, 0, result.stderr)
|
|
138
|
+
assert.match(result.stdout, /Usage: swarmclaw server/i)
|
|
139
|
+
})
|
|
140
|
+
|
|
141
|
+
test('binary help command shows command help for doctor alias', () => {
|
|
142
|
+
const result = runBinary(['help', 'doctor'])
|
|
143
|
+
assert.equal(result.status, 0, result.stderr)
|
|
144
|
+
assert.match(result.stdout, /Usage: swarmclaw doctor/i)
|
|
145
|
+
})
|
|
146
|
+
|
|
147
|
+
test('binary status alias routes to local server status', () => {
|
|
148
|
+
const homeDir = fs.mkdtempSync(path.join(os.tmpdir(), 'swarmclaw-binary-status-'))
|
|
149
|
+
const result = runBinary(['status'], {
|
|
150
|
+
env: {
|
|
151
|
+
SWARMCLAW_HOME: homeDir,
|
|
152
|
+
},
|
|
153
|
+
})
|
|
154
|
+
|
|
155
|
+
assert.equal(result.status, 0, result.stderr)
|
|
156
|
+
assert.match(result.stdout, /Server: not running/i)
|
|
157
|
+
|
|
158
|
+
fs.rmSync(homeDir, { recursive: true, force: true })
|
|
159
|
+
})
|
|
160
|
+
|
|
161
|
+
test('binary doctor help exits successfully', () => {
|
|
162
|
+
const result = runBinary(['doctor', '--help'])
|
|
163
|
+
assert.equal(result.status, 0, result.stderr)
|
|
164
|
+
assert.match(result.stdout, /Usage: swarmclaw doctor/i)
|
|
165
|
+
})
|
|
166
|
+
|
|
122
167
|
test('binary update help exits successfully', () => {
|
|
123
168
|
const result = runBinary(['update', '--help'])
|
|
124
169
|
assert.equal(result.status, 0, result.stderr)
|
|
@@ -131,6 +176,18 @@ test('binary version output matches package version', () => {
|
|
|
131
176
|
assert.equal(result.stdout.trim(), `${PACKAGE_JSON.name} ${PACKAGE_JSON.version}`)
|
|
132
177
|
})
|
|
133
178
|
|
|
179
|
+
test('binary bare version alias output matches package version', () => {
|
|
180
|
+
const result = runBinary(['version'])
|
|
181
|
+
assert.equal(result.status, 0, result.stderr)
|
|
182
|
+
assert.equal(result.stdout.trim(), `${PACKAGE_JSON.name} ${PACKAGE_JSON.version}`)
|
|
183
|
+
})
|
|
184
|
+
|
|
185
|
+
test('binary -v alias output matches package version', () => {
|
|
186
|
+
const result = runBinary(['-v'])
|
|
187
|
+
assert.equal(result.status, 0, result.stderr)
|
|
188
|
+
assert.equal(result.stdout.trim(), `${PACKAGE_JSON.name} ${PACKAGE_JSON.version}`)
|
|
189
|
+
})
|
|
190
|
+
|
|
134
191
|
test('legacy TS launcher falls back to tsx import when strip-types is unavailable', () => {
|
|
135
192
|
const cliPath = path.join(APP_ROOT, 'src', 'cli', 'index.ts')
|
|
136
193
|
const args = buildLegacyTsCliArgs(cliPath, ['runs', 'list'], {
|
package/src/cli/index.js
CHANGED
|
@@ -1183,6 +1183,9 @@ function renderGeneralHelp() {
|
|
|
1183
1183
|
'SwarmClaw CLI',
|
|
1184
1184
|
'',
|
|
1185
1185
|
'Usage:',
|
|
1186
|
+
' swarmclaw',
|
|
1187
|
+
' swarmclaw help [command]',
|
|
1188
|
+
' swarmclaw run|start|stop|status|doctor|update|version',
|
|
1186
1189
|
' swarmclaw <group> <command> [args] [options]',
|
|
1187
1190
|
'',
|
|
1188
1191
|
'Global options:',
|
|
@@ -1199,6 +1202,15 @@ function renderGeneralHelp() {
|
|
|
1199
1202
|
' --help Show help',
|
|
1200
1203
|
' --version Show package version',
|
|
1201
1204
|
'',
|
|
1205
|
+
'Top-level commands:',
|
|
1206
|
+
' run, start Start the SwarmClaw server',
|
|
1207
|
+
' stop Stop the detached SwarmClaw server',
|
|
1208
|
+
' status Show local server status',
|
|
1209
|
+
' doctor Show local install/build diagnostics',
|
|
1210
|
+
' help Show root or command help',
|
|
1211
|
+
' update Update this SwarmClaw installation',
|
|
1212
|
+
' version Show package version',
|
|
1213
|
+
'',
|
|
1202
1214
|
'Groups:',
|
|
1203
1215
|
]
|
|
1204
1216
|
|
|
@@ -1210,7 +1222,7 @@ function renderGeneralHelp() {
|
|
|
1210
1222
|
}
|
|
1211
1223
|
}
|
|
1212
1224
|
|
|
1213
|
-
lines.push('', 'Use "swarmclaw <group> --help" for
|
|
1225
|
+
lines.push('', 'Use "swarmclaw help <command>" or "swarmclaw <group> --help" for more detail.')
|
|
1214
1226
|
return lines.join('\n')
|
|
1215
1227
|
}
|
|
1216
1228
|
|
|
@@ -90,4 +90,39 @@ describe('data-dir resolution', () => {
|
|
|
90
90
|
fs.rmSync(tempDir, { recursive: true, force: true })
|
|
91
91
|
}
|
|
92
92
|
})
|
|
93
|
+
|
|
94
|
+
it('derives runtime directories from SWARMCLAW_HOME when set', () => {
|
|
95
|
+
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'swarmclaw-data-dir-home-'))
|
|
96
|
+
const fakeHome = path.join(tempDir, 'home')
|
|
97
|
+
const swarmclawHome = path.join(tempDir, 'project', '.swarmclaw')
|
|
98
|
+
|
|
99
|
+
try {
|
|
100
|
+
const env = { ...process.env, HOME: fakeHome, SWARMCLAW_HOME: swarmclawHome }
|
|
101
|
+
delete (env as any).DATA_DIR
|
|
102
|
+
delete (env as any).WORKSPACE_DIR
|
|
103
|
+
delete (env as any).BROWSER_PROFILES_DIR
|
|
104
|
+
|
|
105
|
+
const result = spawnSync(process.execPath, ['--import', 'tsx', '--input-type=module', '--eval', `
|
|
106
|
+
const modNs = await import('./src/lib/server/data-dir')
|
|
107
|
+
const mod = modNs.default || modNs['module.exports'] || modNs
|
|
108
|
+
console.log(JSON.stringify({
|
|
109
|
+
dataDir: mod.DATA_DIR,
|
|
110
|
+
workspaceDir: mod.WORKSPACE_DIR,
|
|
111
|
+
browserProfilesDir: mod.BROWSER_PROFILES_DIR,
|
|
112
|
+
}))
|
|
113
|
+
`], {
|
|
114
|
+
cwd: repoRoot,
|
|
115
|
+
env,
|
|
116
|
+
encoding: 'utf-8',
|
|
117
|
+
})
|
|
118
|
+
|
|
119
|
+
assert.equal(result.status, 0, result.stderr || result.stdout || 'subprocess failed')
|
|
120
|
+
const payload = extractLastJson(result.stdout || '')
|
|
121
|
+
assert.equal(payload.dataDir, path.join(swarmclawHome, 'data'))
|
|
122
|
+
assert.equal(payload.workspaceDir, path.join(swarmclawHome, 'workspace'))
|
|
123
|
+
assert.equal(payload.browserProfilesDir, path.join(swarmclawHome, 'browser-profiles'))
|
|
124
|
+
} finally {
|
|
125
|
+
fs.rmSync(tempDir, { recursive: true, force: true })
|
|
126
|
+
}
|
|
127
|
+
})
|
|
93
128
|
})
|