@swarmclawai/swarmclaw 0.9.9 → 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 +194 -0
- package/bin/install-root.test.js +121 -0
- package/bin/server-cmd.js +90 -111
- package/bin/swarmclaw.js +83 -3
- package/bin/update-cmd.js +33 -20
- package/bin/update-cmd.test.js +1 -36
- package/bin/worker-cmd.js +23 -17
- package/next.config.ts +2 -0
- package/package.json +11 -10
- 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/openclaw/dashboard-url/route.test.ts +166 -0
- package/src/app/api/openclaw/dashboard-url/route.ts +68 -0
- package/src/app/api/setup/check-provider/helpers.ts +28 -0
- package/src/app/api/setup/check-provider/route.test.ts +17 -1
- package/src/app/api/setup/check-provider/route.ts +29 -36
- 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 +14 -1
- package/src/cli/server-cmd.test.js +21 -20
- package/src/components/auth/setup-wizard/index.tsx +16 -0
- package/src/components/auth/setup-wizard/step-agents.tsx +34 -23
- package/src/components/auth/setup-wizard/step-connect.tsx +8 -0
- package/src/components/auth/setup-wizard/types.ts +2 -0
- package/src/components/auth/setup-wizard/utils.test.ts +79 -0
- package/src/components/chat/chat-header.tsx +45 -2
- package/src/lib/providers/openclaw-exports.test.ts +23 -0
- package/src/lib/providers/openclaw.ts +1 -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
- package/src/lib/setup-defaults.test.ts +42 -0
- package/src/lib/setup-defaults.ts +1 -1
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import { dedup } from '@/lib/shared-utils'
|
|
2
|
+
|
|
3
|
+
type GitHubIssueLabel = string | { name?: string | null }
|
|
4
|
+
|
|
5
|
+
export interface GitHubIssueRecord {
|
|
6
|
+
id: number | string
|
|
7
|
+
number: number
|
|
8
|
+
title: string
|
|
9
|
+
body?: string | null
|
|
10
|
+
state?: string | null
|
|
11
|
+
html_url?: string | null
|
|
12
|
+
labels?: GitHubIssueLabel[]
|
|
13
|
+
assignee?: { login?: string | null } | null
|
|
14
|
+
user?: { login?: string | null } | null
|
|
15
|
+
pull_request?: unknown
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface ParsedRepo {
|
|
19
|
+
owner: string
|
|
20
|
+
repo: string
|
|
21
|
+
fullName: string
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const BODY_CHAR_LIMIT = 12_000
|
|
25
|
+
|
|
26
|
+
function normalizeLabelName(label: GitHubIssueLabel): string {
|
|
27
|
+
if (typeof label === 'string') return label.trim()
|
|
28
|
+
return String(label?.name || '').trim()
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function normalizeTag(value: string): string {
|
|
32
|
+
return value.trim().replace(/\s+/g, ' ').slice(0, 60)
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function parseGitHubRepoInput(input: string): ParsedRepo | null {
|
|
36
|
+
const trimmed = input.trim().replace(/\.git$/i, '')
|
|
37
|
+
if (!trimmed) return null
|
|
38
|
+
|
|
39
|
+
if (/^https?:\/\//i.test(trimmed)) {
|
|
40
|
+
try {
|
|
41
|
+
const url = new URL(trimmed)
|
|
42
|
+
if (!/github\.com$/i.test(url.hostname)) return null
|
|
43
|
+
const parts = url.pathname.split('/').filter(Boolean)
|
|
44
|
+
if (parts.length < 2) return null
|
|
45
|
+
const owner = parts[0]
|
|
46
|
+
const repo = parts[1].replace(/\.git$/i, '')
|
|
47
|
+
if (!owner || !repo) return null
|
|
48
|
+
return { owner, repo, fullName: `${owner}/${repo}` }
|
|
49
|
+
} catch {
|
|
50
|
+
return null
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const compact = trimmed.replace(/^github\.com\//i, '')
|
|
55
|
+
const parts = compact.split('/').filter(Boolean)
|
|
56
|
+
if (parts.length < 2) return null
|
|
57
|
+
const owner = parts[0]
|
|
58
|
+
const repo = parts[1].replace(/\.git$/i, '')
|
|
59
|
+
if (!owner || !repo) return null
|
|
60
|
+
return { owner, repo, fullName: `${owner}/${repo}` }
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export function buildGitHubIssueTaskTitle(issue: GitHubIssueRecord, repoFullName: string): string {
|
|
64
|
+
const title = issue.title?.trim() || `Issue ${issue.number}`
|
|
65
|
+
return `[${repoFullName}#${issue.number}] ${title}`
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export function buildGitHubIssueTaskDescription(issue: GitHubIssueRecord, repoFullName: string): string {
|
|
69
|
+
const labels = (issue.labels || [])
|
|
70
|
+
.map(normalizeLabelName)
|
|
71
|
+
.filter(Boolean)
|
|
72
|
+
const header = [
|
|
73
|
+
`Imported from GitHub issue ${repoFullName}#${issue.number}`,
|
|
74
|
+
issue.html_url ? `URL: ${issue.html_url}` : '',
|
|
75
|
+
issue.state ? `State: ${issue.state}` : '',
|
|
76
|
+
labels.length > 0 ? `Labels: ${labels.join(', ')}` : '',
|
|
77
|
+
issue.assignee?.login ? `Assignee: ${issue.assignee.login}` : '',
|
|
78
|
+
issue.user?.login ? `Opened by: ${issue.user.login}` : '',
|
|
79
|
+
]
|
|
80
|
+
.filter(Boolean)
|
|
81
|
+
.join('\n')
|
|
82
|
+
|
|
83
|
+
const rawBody = String(issue.body || '').trim()
|
|
84
|
+
if (!rawBody) return header
|
|
85
|
+
|
|
86
|
+
const body = rawBody.length > BODY_CHAR_LIMIT
|
|
87
|
+
? `${rawBody.slice(0, BODY_CHAR_LIMIT).trimEnd()}\n\n[Truncated during import]`
|
|
88
|
+
: rawBody
|
|
89
|
+
|
|
90
|
+
return `${header}\n\n${body}`
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export function buildGitHubIssueTaskTags(issue: GitHubIssueRecord, repoFullName: string): string[] {
|
|
94
|
+
const raw = [
|
|
95
|
+
'github',
|
|
96
|
+
repoFullName,
|
|
97
|
+
...(issue.labels || []).map(normalizeLabelName),
|
|
98
|
+
]
|
|
99
|
+
return dedup(raw.map(normalizeTag).filter(Boolean)).slice(0, 8)
|
|
100
|
+
}
|
|
@@ -5,7 +5,7 @@ import {
|
|
|
5
5
|
buildGitHubIssueTaskTags,
|
|
6
6
|
buildGitHubIssueTaskTitle,
|
|
7
7
|
parseGitHubRepoInput,
|
|
8
|
-
} from './
|
|
8
|
+
} from './helpers'
|
|
9
9
|
|
|
10
10
|
test('parseGitHubRepoInput accepts repo slugs and GitHub URLs', () => {
|
|
11
11
|
assert.deepEqual(parseGitHubRepoInput('swarmclawai/swarmclaw'), {
|
|
@@ -6,10 +6,10 @@ import { formatZodError } from '@/lib/validation/schemas'
|
|
|
6
6
|
import { loadSettings, loadTasks, logActivity, upsertStoredItems } from '@/lib/server/storage'
|
|
7
7
|
import { notify } from '@/lib/server/ws-hub'
|
|
8
8
|
import type { BoardTask } from '@/types'
|
|
9
|
-
import {
|
|
9
|
+
import { parseGitHubRepoInput, buildGitHubIssueTaskTitle, buildGitHubIssueTaskDescription, buildGitHubIssueTaskTags } from './helpers'
|
|
10
|
+
import type { GitHubIssueRecord } from './helpers'
|
|
10
11
|
|
|
11
12
|
const MAX_IMPORT_LIMIT = 200
|
|
12
|
-
const BODY_CHAR_LIMIT = 12_000
|
|
13
13
|
|
|
14
14
|
const GitHubIssueImportSchema = z.object({
|
|
15
15
|
repo: z.string().trim().min(1, 'Repository is required'),
|
|
@@ -23,25 +23,6 @@ const GitHubIssueImportSchema = z.object({
|
|
|
23
23
|
|
|
24
24
|
type GitHubIssueLabel = string | { name?: string | null }
|
|
25
25
|
|
|
26
|
-
interface GitHubIssueRecord {
|
|
27
|
-
id: number | string
|
|
28
|
-
number: number
|
|
29
|
-
title: string
|
|
30
|
-
body?: string | null
|
|
31
|
-
state?: string | null
|
|
32
|
-
html_url?: string | null
|
|
33
|
-
labels?: GitHubIssueLabel[]
|
|
34
|
-
assignee?: { login?: string | null } | null
|
|
35
|
-
user?: { login?: string | null } | null
|
|
36
|
-
pull_request?: unknown
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
interface ParsedRepo {
|
|
40
|
-
owner: string
|
|
41
|
-
repo: string
|
|
42
|
-
fullName: string
|
|
43
|
-
}
|
|
44
|
-
|
|
45
26
|
function getGitHubToken(explicitToken: string): string {
|
|
46
27
|
return explicitToken.trim()
|
|
47
28
|
|| process.env.GITHUB_TOKEN
|
|
@@ -55,10 +36,6 @@ function normalizeLabelName(label: GitHubIssueLabel): string {
|
|
|
55
36
|
return String(label?.name || '').trim()
|
|
56
37
|
}
|
|
57
38
|
|
|
58
|
-
function normalizeTag(value: string): string {
|
|
59
|
-
return value.trim().replace(/\s+/g, ' ').slice(0, 60)
|
|
60
|
-
}
|
|
61
|
-
|
|
62
39
|
function toIssueSummary(issue: GitHubIssueRecord, taskId?: string) {
|
|
63
40
|
return {
|
|
64
41
|
taskId,
|
|
@@ -68,73 +45,6 @@ function toIssueSummary(issue: GitHubIssueRecord, taskId?: string) {
|
|
|
68
45
|
}
|
|
69
46
|
}
|
|
70
47
|
|
|
71
|
-
export function parseGitHubRepoInput(input: string): ParsedRepo | null {
|
|
72
|
-
const trimmed = input.trim().replace(/\.git$/i, '')
|
|
73
|
-
if (!trimmed) return null
|
|
74
|
-
|
|
75
|
-
if (/^https?:\/\//i.test(trimmed)) {
|
|
76
|
-
try {
|
|
77
|
-
const url = new URL(trimmed)
|
|
78
|
-
if (!/github\.com$/i.test(url.hostname)) return null
|
|
79
|
-
const parts = url.pathname.split('/').filter(Boolean)
|
|
80
|
-
if (parts.length < 2) return null
|
|
81
|
-
const owner = parts[0]
|
|
82
|
-
const repo = parts[1].replace(/\.git$/i, '')
|
|
83
|
-
if (!owner || !repo) return null
|
|
84
|
-
return { owner, repo, fullName: `${owner}/${repo}` }
|
|
85
|
-
} catch {
|
|
86
|
-
return null
|
|
87
|
-
}
|
|
88
|
-
}
|
|
89
|
-
|
|
90
|
-
const compact = trimmed.replace(/^github\.com\//i, '')
|
|
91
|
-
const parts = compact.split('/').filter(Boolean)
|
|
92
|
-
if (parts.length < 2) return null
|
|
93
|
-
const owner = parts[0]
|
|
94
|
-
const repo = parts[1].replace(/\.git$/i, '')
|
|
95
|
-
if (!owner || !repo) return null
|
|
96
|
-
return { owner, repo, fullName: `${owner}/${repo}` }
|
|
97
|
-
}
|
|
98
|
-
|
|
99
|
-
export function buildGitHubIssueTaskTitle(issue: GitHubIssueRecord, repoFullName: string): string {
|
|
100
|
-
const title = issue.title?.trim() || `Issue ${issue.number}`
|
|
101
|
-
return `[${repoFullName}#${issue.number}] ${title}`
|
|
102
|
-
}
|
|
103
|
-
|
|
104
|
-
export function buildGitHubIssueTaskDescription(issue: GitHubIssueRecord, repoFullName: string): string {
|
|
105
|
-
const labels = (issue.labels || [])
|
|
106
|
-
.map(normalizeLabelName)
|
|
107
|
-
.filter(Boolean)
|
|
108
|
-
const header = [
|
|
109
|
-
`Imported from GitHub issue ${repoFullName}#${issue.number}`,
|
|
110
|
-
issue.html_url ? `URL: ${issue.html_url}` : '',
|
|
111
|
-
issue.state ? `State: ${issue.state}` : '',
|
|
112
|
-
labels.length > 0 ? `Labels: ${labels.join(', ')}` : '',
|
|
113
|
-
issue.assignee?.login ? `Assignee: ${issue.assignee.login}` : '',
|
|
114
|
-
issue.user?.login ? `Opened by: ${issue.user.login}` : '',
|
|
115
|
-
]
|
|
116
|
-
.filter(Boolean)
|
|
117
|
-
.join('\n')
|
|
118
|
-
|
|
119
|
-
const rawBody = String(issue.body || '').trim()
|
|
120
|
-
if (!rawBody) return header
|
|
121
|
-
|
|
122
|
-
const body = rawBody.length > BODY_CHAR_LIMIT
|
|
123
|
-
? `${rawBody.slice(0, BODY_CHAR_LIMIT).trimEnd()}\n\n[Truncated during import]`
|
|
124
|
-
: rawBody
|
|
125
|
-
|
|
126
|
-
return `${header}\n\n${body}`
|
|
127
|
-
}
|
|
128
|
-
|
|
129
|
-
export function buildGitHubIssueTaskTags(issue: GitHubIssueRecord, repoFullName: string): string[] {
|
|
130
|
-
const raw = [
|
|
131
|
-
'github',
|
|
132
|
-
repoFullName,
|
|
133
|
-
...(issue.labels || []).map(normalizeLabelName),
|
|
134
|
-
]
|
|
135
|
-
return dedup(raw.map(normalizeTag).filter(Boolean)).slice(0, 8)
|
|
136
|
-
}
|
|
137
|
-
|
|
138
48
|
function findExistingImportedTask(
|
|
139
49
|
tasks: Record<string, BoardTask>,
|
|
140
50
|
repoFullName: string,
|
|
@@ -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
|
+
}
|