@swarmclawai/swarmclaw 0.5.1 → 0.5.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/LICENSE +21 -0
- package/README.md +2 -2
- package/package.json +2 -1
- package/public/screenshots/agents.png +0 -0
- package/public/screenshots/dashboard.png +0 -0
- package/public/screenshots/providers.png +0 -0
- package/public/screenshots/tasks.png +0 -0
- package/src/app/api/activity/route.ts +30 -0
- package/src/app/api/agents/[id]/route.ts +3 -1
- package/src/app/api/agents/route.ts +2 -1
- package/src/app/api/connectors/[id]/route.ts +4 -1
- package/src/app/api/openclaw/approvals/route.ts +20 -0
- package/src/app/api/tasks/[id]/route.ts +37 -1
- package/src/app/api/tasks/route.ts +7 -1
- package/src/app/api/usage/route.ts +74 -22
- package/src/app/api/webhooks/[id]/route.ts +62 -22
- package/src/cli/index.js +7 -0
- package/src/cli/spec.js +6 -0
- package/src/components/activity/activity-feed.tsx +91 -0
- package/src/components/chat/exec-approval-card.tsx +6 -3
- package/src/components/layout/app-layout.tsx +21 -7
- package/src/components/tasks/task-board.tsx +40 -2
- package/src/components/tasks/task-card.tsx +40 -2
- package/src/components/tasks/task-sheet.tsx +147 -1
- package/src/components/usage/metrics-dashboard.tsx +278 -0
- package/src/hooks/use-page-active.ts +21 -0
- package/src/hooks/use-ws.ts +13 -1
- package/src/lib/fetch-dedup.ts +20 -0
- package/src/lib/optimistic.ts +25 -0
- package/src/lib/server/connectors/manager.ts +18 -0
- package/src/lib/server/daemon-state.ts +205 -20
- package/src/lib/server/queue.ts +16 -0
- package/src/lib/server/storage.ts +34 -0
- package/src/lib/view-routes.ts +1 -0
- package/src/lib/ws-client.ts +2 -1
- package/src/stores/use-app-store.ts +48 -1
- package/src/stores/use-approval-store.ts +21 -7
- package/src/types/index.ts +40 -1
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 SwarmClaw Contributors
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -81,7 +81,7 @@ curl -fsSL https://raw.githubusercontent.com/swarmclawai/swarmclaw/main/install.
|
|
|
81
81
|
```
|
|
82
82
|
|
|
83
83
|
The installer resolves the latest stable release tag and installs that version by default.
|
|
84
|
-
To pin a version: `SWARMCLAW_VERSION=v0.5.
|
|
84
|
+
To pin a version: `SWARMCLAW_VERSION=v0.5.2 curl ... | bash`
|
|
85
85
|
|
|
86
86
|
Or run locally from the repo (friendly for non-technical users):
|
|
87
87
|
|
|
@@ -600,4 +600,4 @@ swarmclaw memory maintenance
|
|
|
600
600
|
|
|
601
601
|
## License
|
|
602
602
|
|
|
603
|
-
MIT
|
|
603
|
+
[MIT](./LICENSE)
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@swarmclawai/swarmclaw",
|
|
3
|
-
"version": "0.5.
|
|
3
|
+
"version": "0.5.2",
|
|
4
4
|
"description": "Self-hosted AI agent orchestration dashboard — manage LLM providers, orchestrate agent swarms, schedule tasks, and bridge agents to chat platforms.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
@@ -84,6 +84,7 @@
|
|
|
84
84
|
"react-dom": "19.2.3",
|
|
85
85
|
"react-icons": "^5.5.0",
|
|
86
86
|
"react-markdown": "^10.1.0",
|
|
87
|
+
"recharts": "^3.7.0",
|
|
87
88
|
"rehype-highlight": "^7.0.2",
|
|
88
89
|
"remark-gfm": "^4.0.1",
|
|
89
90
|
"sonner": "^2.0.7",
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { NextResponse } from 'next/server'
|
|
2
|
+
import { loadActivity } from '@/lib/server/storage'
|
|
3
|
+
export const dynamic = 'force-dynamic'
|
|
4
|
+
|
|
5
|
+
export async function GET(req: Request) {
|
|
6
|
+
const { searchParams } = new URL(req.url)
|
|
7
|
+
const entityType = searchParams.get('entityType')
|
|
8
|
+
const entityId = searchParams.get('entityId')
|
|
9
|
+
const action = searchParams.get('action')
|
|
10
|
+
const since = searchParams.get('since')
|
|
11
|
+
const limit = Math.min(200, Math.max(1, Number(searchParams.get('limit')) || 50))
|
|
12
|
+
|
|
13
|
+
const all = loadActivity()
|
|
14
|
+
let entries = Object.values(all) as Array<Record<string, unknown>>
|
|
15
|
+
|
|
16
|
+
if (entityType) entries = entries.filter((e) => e.entityType === entityType)
|
|
17
|
+
if (entityId) entries = entries.filter((e) => e.entityId === entityId)
|
|
18
|
+
if (action) entries = entries.filter((e) => e.action === action)
|
|
19
|
+
if (since) {
|
|
20
|
+
const sinceMs = Number(since)
|
|
21
|
+
if (Number.isFinite(sinceMs)) {
|
|
22
|
+
entries = entries.filter((e) => typeof e.timestamp === 'number' && e.timestamp >= sinceMs)
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
entries.sort((a, b) => (b.timestamp as number) - (a.timestamp as number))
|
|
27
|
+
entries = entries.slice(0, limit)
|
|
28
|
+
|
|
29
|
+
return NextResponse.json(entries)
|
|
30
|
+
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { NextResponse } from 'next/server'
|
|
2
|
-
import { loadAgents, saveAgents, loadSessions, saveSessions } from '@/lib/server/storage'
|
|
2
|
+
import { loadAgents, saveAgents, loadSessions, saveSessions, logActivity } from '@/lib/server/storage'
|
|
3
3
|
import { normalizeProviderEndpoint } from '@/lib/openclaw-endpoint'
|
|
4
4
|
import { mutateItem, notFound, type CollectionOps } from '@/lib/server/collection-helpers'
|
|
5
5
|
|
|
@@ -22,6 +22,7 @@ export async function PUT(req: Request, { params }: { params: Promise<{ id: stri
|
|
|
22
22
|
return agent
|
|
23
23
|
})
|
|
24
24
|
if (!result) return notFound()
|
|
25
|
+
logActivity({ entityType: 'agent', entityId: id, action: 'updated', actor: 'user', summary: `Agent updated: "${result.name}"` })
|
|
25
26
|
return NextResponse.json(result)
|
|
26
27
|
}
|
|
27
28
|
|
|
@@ -33,6 +34,7 @@ export async function DELETE(_req: Request, { params }: { params: Promise<{ id:
|
|
|
33
34
|
return agent
|
|
34
35
|
})
|
|
35
36
|
if (!result) return notFound()
|
|
37
|
+
logActivity({ entityType: 'agent', entityId: id, action: 'deleted', actor: 'user', summary: `Agent trashed: "${result.name}"` })
|
|
36
38
|
|
|
37
39
|
// Detach sessions from the trashed agent
|
|
38
40
|
const sessions = loadSessions()
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { NextResponse } from 'next/server'
|
|
2
2
|
import { genId } from '@/lib/id'
|
|
3
|
-
import { loadAgents, saveAgents } from '@/lib/server/storage'
|
|
3
|
+
import { loadAgents, saveAgents, logActivity } from '@/lib/server/storage'
|
|
4
4
|
import { normalizeProviderEndpoint } from '@/lib/openclaw-endpoint'
|
|
5
5
|
import { notify } from '@/lib/server/ws-hub'
|
|
6
6
|
export const dynamic = 'force-dynamic'
|
|
@@ -33,6 +33,7 @@ export async function POST(req: Request) {
|
|
|
33
33
|
updatedAt: now,
|
|
34
34
|
}
|
|
35
35
|
saveAgents(agents)
|
|
36
|
+
logActivity({ entityType: 'agent', entityId: id, action: 'created', actor: 'user', summary: `Agent created: "${agents[id].name}"` })
|
|
36
37
|
notify('agents')
|
|
37
38
|
return NextResponse.json(agents[id])
|
|
38
39
|
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { NextResponse } from 'next/server'
|
|
2
|
-
import { loadConnectors, saveConnectors } from '@/lib/server/storage'
|
|
2
|
+
import { loadConnectors, saveConnectors, logActivity } from '@/lib/server/storage'
|
|
3
3
|
import { notify } from '@/lib/server/ws-hub'
|
|
4
4
|
import { notFound } from '@/lib/server/collection-helpers'
|
|
5
5
|
|
|
@@ -39,10 +39,13 @@ export async function PUT(req: Request, { params }: { params: Promise<{ id: stri
|
|
|
39
39
|
const manager = await import('@/lib/server/connectors/manager')
|
|
40
40
|
if (body.action === 'start') {
|
|
41
41
|
await manager.startConnector(id)
|
|
42
|
+
logActivity({ entityType: 'connector', entityId: id, action: 'started', actor: 'user', summary: `Connector started: "${connector.name}"` })
|
|
42
43
|
} else if (body.action === 'stop') {
|
|
43
44
|
await manager.stopConnector(id)
|
|
45
|
+
logActivity({ entityType: 'connector', entityId: id, action: 'stopped', actor: 'user', summary: `Connector stopped: "${connector.name}"` })
|
|
44
46
|
} else {
|
|
45
47
|
await manager.repairConnector(id)
|
|
48
|
+
logActivity({ entityType: 'connector', entityId: id, action: 'started', actor: 'user', summary: `Connector repaired: "${connector.name}"` })
|
|
46
49
|
}
|
|
47
50
|
} catch (err: unknown) {
|
|
48
51
|
// Re-read to get the error state saved by startConnector
|
|
@@ -17,6 +17,19 @@ export async function GET() {
|
|
|
17
17
|
}
|
|
18
18
|
}
|
|
19
19
|
|
|
20
|
+
/* ── Conflict-detection: track recently resolved approval IDs in-process ── */
|
|
21
|
+
const resolvedKey = '__swarmclaw_resolved_approvals__'
|
|
22
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
23
|
+
const resolved: Map<string, number> = (globalThis as any)[resolvedKey] ?? ((globalThis as any)[resolvedKey] = new Map<string, number>())
|
|
24
|
+
const RESOLVED_TTL_MS = 5 * 60 * 1000
|
|
25
|
+
|
|
26
|
+
function pruneResolved() {
|
|
27
|
+
const cutoff = Date.now() - RESOLVED_TTL_MS
|
|
28
|
+
for (const [k, ts] of resolved) {
|
|
29
|
+
if (ts < cutoff) resolved.delete(k)
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
20
33
|
/** POST { id, decision } — resolve an execution approval */
|
|
21
34
|
export async function POST(req: Request) {
|
|
22
35
|
const body = await req.json()
|
|
@@ -31,6 +44,12 @@ export async function POST(req: Request) {
|
|
|
31
44
|
return NextResponse.json({ error: 'Invalid decision' }, { status: 400 })
|
|
32
45
|
}
|
|
33
46
|
|
|
47
|
+
// Conflict detection — prevent duplicate resolution
|
|
48
|
+
pruneResolved()
|
|
49
|
+
if (resolved.has(id)) {
|
|
50
|
+
return NextResponse.json({ error: 'Already resolved' }, { status: 409 })
|
|
51
|
+
}
|
|
52
|
+
|
|
34
53
|
const gw = await ensureGatewayConnected()
|
|
35
54
|
if (!gw) {
|
|
36
55
|
return NextResponse.json({ error: 'OpenClaw gateway not connected' }, { status: 503 })
|
|
@@ -38,6 +57,7 @@ export async function POST(req: Request) {
|
|
|
38
57
|
|
|
39
58
|
try {
|
|
40
59
|
await gw.rpc('exec.approvals.resolve', { id, decision })
|
|
60
|
+
resolved.set(id, Date.now())
|
|
41
61
|
return NextResponse.json({ ok: true })
|
|
42
62
|
} catch (err: unknown) {
|
|
43
63
|
const message = err instanceof Error ? err.message : String(err)
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { genId } from '@/lib/id'
|
|
2
2
|
import { NextResponse } from 'next/server'
|
|
3
|
-
import { loadTasks, saveTasks } from '@/lib/server/storage'
|
|
3
|
+
import { loadTasks, saveTasks, logActivity } from '@/lib/server/storage'
|
|
4
4
|
import { notFound } from '@/lib/server/collection-helpers'
|
|
5
5
|
import { disableSessionHeartbeat, enqueueTask, validateCompletedTasksQueue } from '@/lib/server/queue'
|
|
6
6
|
import { ensureTaskCompletionReport } from '@/lib/server/task-reports'
|
|
@@ -65,6 +65,7 @@ export async function PUT(req: Request, { params }: { params: Promise<{ id: stri
|
|
|
65
65
|
}
|
|
66
66
|
|
|
67
67
|
saveTasks(tasks)
|
|
68
|
+
logActivity({ entityType: 'task', entityId: id, action: 'updated', actor: 'user', summary: `Task updated: "${tasks[id].title}" (${prevStatus} → ${tasks[id].status})` })
|
|
68
69
|
if (prevStatus !== tasks[id].status) {
|
|
69
70
|
pushMainLoopEventToMainSessions({
|
|
70
71
|
type: 'task_status_changed',
|
|
@@ -77,6 +78,40 @@ export async function PUT(req: Request, { params }: { params: Promise<{ id: stri
|
|
|
77
78
|
disableSessionHeartbeat(tasks[id].sessionId)
|
|
78
79
|
}
|
|
79
80
|
|
|
81
|
+
// Dependency check: cannot queue a task if any blocker is incomplete
|
|
82
|
+
if (tasks[id].status === 'queued') {
|
|
83
|
+
const blockers = Array.isArray(tasks[id].blockedBy) ? tasks[id].blockedBy : []
|
|
84
|
+
const incompleteBlocker = blockers.find((bid: string) => tasks[bid] && tasks[bid].status !== 'completed')
|
|
85
|
+
if (incompleteBlocker) {
|
|
86
|
+
// Revert status change and reject
|
|
87
|
+
tasks[id].status = prevStatus
|
|
88
|
+
tasks[id].updatedAt = Date.now()
|
|
89
|
+
saveTasks(tasks)
|
|
90
|
+
return NextResponse.json(
|
|
91
|
+
{ error: 'Cannot queue: blocked by incomplete tasks', blockedBy: incompleteBlocker },
|
|
92
|
+
{ status: 409 },
|
|
93
|
+
)
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// When a task is completed, auto-unblock dependent tasks
|
|
98
|
+
if (tasks[id].status === 'completed') {
|
|
99
|
+
const blockedIds = Array.isArray(tasks[id].blocks) ? tasks[id].blocks as string[] : []
|
|
100
|
+
for (const blockedId of blockedIds) {
|
|
101
|
+
const blocked = tasks[blockedId]
|
|
102
|
+
if (!blocked) continue
|
|
103
|
+
const deps = Array.isArray(blocked.blockedBy) ? blocked.blockedBy as string[] : []
|
|
104
|
+
const allDone = deps.every((depId: string) => tasks[depId]?.status === 'completed')
|
|
105
|
+
if (allDone && (blocked.status === 'backlog' || blocked.status === 'todo')) {
|
|
106
|
+
blocked.status = 'queued'
|
|
107
|
+
blocked.queuedAt = Date.now()
|
|
108
|
+
blocked.updatedAt = Date.now()
|
|
109
|
+
saveTasks(tasks)
|
|
110
|
+
enqueueTask(blockedId)
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
80
115
|
// If status changed to 'queued', enqueue it
|
|
81
116
|
if (prevStatus !== 'queued' && tasks[id].status === 'queued') {
|
|
82
117
|
enqueueTask(id)
|
|
@@ -96,6 +131,7 @@ export async function DELETE(_req: Request, { params }: { params: Promise<{ id:
|
|
|
96
131
|
tasks[id].archivedAt = Date.now()
|
|
97
132
|
tasks[id].updatedAt = Date.now()
|
|
98
133
|
saveTasks(tasks)
|
|
134
|
+
logActivity({ entityType: 'task', entityId: id, action: 'deleted', actor: 'user', summary: `Task archived: "${tasks[id].title}"` })
|
|
99
135
|
pushMainLoopEventToMainSessions({
|
|
100
136
|
type: 'task_archived',
|
|
101
137
|
text: `Task archived: "${tasks[id].title}" (${id}).`,
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { NextResponse } from 'next/server'
|
|
2
2
|
import { genId } from '@/lib/id'
|
|
3
|
-
import { loadTasks, saveTasks, loadSettings } from '@/lib/server/storage'
|
|
3
|
+
import { loadTasks, saveTasks, loadSettings, logActivity } from '@/lib/server/storage'
|
|
4
4
|
import { enqueueTask, validateCompletedTasksQueue } from '@/lib/server/queue'
|
|
5
5
|
import { ensureTaskCompletionReport } from '@/lib/server/task-reports'
|
|
6
6
|
import { formatValidationFailure, validateTaskCompletion } from '@/lib/server/task-validation'
|
|
@@ -88,6 +88,11 @@ export async function POST(req: Request) {
|
|
|
88
88
|
retryScheduledAt: null,
|
|
89
89
|
deadLetteredAt: null,
|
|
90
90
|
checkpoint: null,
|
|
91
|
+
blockedBy: Array.isArray(body.blockedBy) ? body.blockedBy.filter((s: unknown) => typeof s === 'string') : [],
|
|
92
|
+
blocks: Array.isArray(body.blocks) ? body.blocks.filter((s: unknown) => typeof s === 'string') : [],
|
|
93
|
+
tags: Array.isArray(body.tags) ? body.tags.filter((s: unknown) => typeof s === 'string') : [],
|
|
94
|
+
dueAt: typeof body.dueAt === 'number' ? body.dueAt : null,
|
|
95
|
+
customFields: body.customFields && typeof body.customFields === 'object' ? body.customFields : undefined,
|
|
91
96
|
}
|
|
92
97
|
|
|
93
98
|
if (tasks[id].status === 'completed') {
|
|
@@ -106,6 +111,7 @@ export async function POST(req: Request) {
|
|
|
106
111
|
}
|
|
107
112
|
|
|
108
113
|
saveTasks(tasks)
|
|
114
|
+
logActivity({ entityType: 'task', entityId: id, action: 'created', actor: 'user', summary: `Task created: "${tasks[id].title}"` })
|
|
109
115
|
pushMainLoopEventToMainSessions({
|
|
110
116
|
type: 'task_created',
|
|
111
117
|
text: `Task created: "${tasks[id].title}" (${id}) with status ${tasks[id].status}.`,
|
|
@@ -1,37 +1,89 @@
|
|
|
1
1
|
import { NextResponse } from 'next/server'
|
|
2
2
|
import { loadUsage } from '@/lib/server/storage'
|
|
3
|
+
import type { UsageRecord } from '@/types'
|
|
3
4
|
export const dynamic = 'force-dynamic'
|
|
4
5
|
|
|
6
|
+
type Range = '24h' | '7d' | '30d'
|
|
5
7
|
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
8
|
+
const RANGE_MS: Record<Range, number> = {
|
|
9
|
+
'24h': 24 * 60 * 60 * 1000,
|
|
10
|
+
'7d': 7 * 24 * 60 * 60 * 1000,
|
|
11
|
+
'30d': 30 * 24 * 60 * 60 * 1000,
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function bucketKey(ts: number, range: Range): string {
|
|
15
|
+
const d = new Date(ts)
|
|
16
|
+
if (range === '24h') {
|
|
17
|
+
// hourly buckets: "2026-03-01T14"
|
|
18
|
+
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}T${String(d.getHours()).padStart(2, '0')}`
|
|
19
|
+
}
|
|
20
|
+
// daily buckets: "2026-03-01"
|
|
21
|
+
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export async function GET(req: Request) {
|
|
25
|
+
const { searchParams } = new URL(req.url)
|
|
26
|
+
const rangeParam = searchParams.get('range') ?? '24h'
|
|
27
|
+
const range: Range = rangeParam === '7d' || rangeParam === '30d' ? rangeParam : '24h'
|
|
28
|
+
|
|
29
|
+
const now = Date.now()
|
|
30
|
+
const cutoff = now - RANGE_MS[range]
|
|
31
|
+
|
|
32
|
+
const usage = loadUsage() as Record<string, UsageRecord[]>
|
|
33
|
+
|
|
34
|
+
// Flatten and filter by time range
|
|
35
|
+
const records: UsageRecord[] = []
|
|
36
|
+
for (const sessionRecords of Object.values(usage)) {
|
|
37
|
+
for (const r of sessionRecords) {
|
|
38
|
+
if ((r.timestamp || 0) >= cutoff) {
|
|
39
|
+
records.push(r)
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// Compute summaries
|
|
9
45
|
let totalTokens = 0
|
|
10
46
|
let totalCost = 0
|
|
11
|
-
const
|
|
12
|
-
const byProvider: Record<string, { tokens: number; cost: number
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
47
|
+
const byAgent: Record<string, { tokens: number; cost: number }> = {}
|
|
48
|
+
const byProvider: Record<string, { tokens: number; cost: number }> = {}
|
|
49
|
+
const bucketMap: Record<string, { tokens: number; cost: number }> = {}
|
|
50
|
+
|
|
51
|
+
for (const r of records) {
|
|
52
|
+
const tokens = r.totalTokens || 0
|
|
53
|
+
const cost = r.estimatedCost || 0
|
|
54
|
+
totalTokens += tokens
|
|
55
|
+
totalCost += cost
|
|
56
|
+
|
|
57
|
+
// by provider
|
|
58
|
+
const prov = r.provider || 'unknown'
|
|
59
|
+
if (!byProvider[prov]) byProvider[prov] = { tokens: 0, cost: 0 }
|
|
60
|
+
byProvider[prov].tokens += tokens
|
|
61
|
+
byProvider[prov].cost += cost
|
|
62
|
+
|
|
63
|
+
// by agent (using sessionId as proxy — agents map to sessions)
|
|
64
|
+
const agentKey = r.sessionId || 'unknown'
|
|
65
|
+
if (!byAgent[agentKey]) byAgent[agentKey] = { tokens: 0, cost: 0 }
|
|
66
|
+
byAgent[agentKey].tokens += tokens
|
|
67
|
+
byAgent[agentKey].cost += cost
|
|
68
|
+
|
|
69
|
+
// time series bucketing
|
|
70
|
+
const bk = bucketKey(r.timestamp || now, range)
|
|
71
|
+
if (!bucketMap[bk]) bucketMap[bk] = { tokens: 0, cost: 0 }
|
|
72
|
+
bucketMap[bk].tokens += tokens
|
|
73
|
+
bucketMap[bk].cost += cost
|
|
28
74
|
}
|
|
29
75
|
|
|
76
|
+
// Sort time series
|
|
77
|
+
const timeSeries = Object.entries(bucketMap)
|
|
78
|
+
.sort(([a], [b]) => a.localeCompare(b))
|
|
79
|
+
.map(([bucket, data]) => ({ bucket, tokens: data.tokens, cost: Math.round(data.cost * 10000) / 10000 }))
|
|
80
|
+
|
|
30
81
|
return NextResponse.json({
|
|
82
|
+
records,
|
|
31
83
|
totalTokens,
|
|
32
84
|
totalCost: Math.round(totalCost * 10000) / 10000,
|
|
33
|
-
|
|
85
|
+
byAgent,
|
|
34
86
|
byProvider,
|
|
35
|
-
|
|
87
|
+
timeSeries,
|
|
36
88
|
})
|
|
37
89
|
}
|
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
import { genId } from '@/lib/id'
|
|
2
2
|
import { NextResponse } from 'next/server'
|
|
3
|
-
import { loadAgents, loadSessions, loadWebhooks, saveSessions, saveWebhooks, appendWebhookLog } from '@/lib/server/storage'
|
|
3
|
+
import { loadAgents, loadSessions, loadWebhooks, saveSessions, saveWebhooks, appendWebhookLog, upsertWebhookRetry } from '@/lib/server/storage'
|
|
4
4
|
import { WORKSPACE_DIR } from '@/lib/server/data-dir'
|
|
5
5
|
import { enqueueSessionRun } from '@/lib/server/session-run-manager'
|
|
6
6
|
import { mutateItem, deleteItem, notFound, type CollectionOps } from '@/lib/server/collection-helpers'
|
|
7
|
+
import type { WebhookRetryEntry } from '@/types'
|
|
7
8
|
|
|
8
9
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
9
10
|
const ops: CollectionOps<any> = { load: loadWebhooks, save: saveWebhooks }
|
|
@@ -129,7 +130,10 @@ export async function POST(req: Request, { params }: { params: Promise<{ id: str
|
|
|
129
130
|
|
|
130
131
|
const sessions = loadSessions()
|
|
131
132
|
const sessionName = `webhook:${id}`
|
|
132
|
-
let session = Object.values(sessions).find((s:
|
|
133
|
+
let session = Object.values(sessions).find((s: unknown) => {
|
|
134
|
+
const rec = s as Record<string, unknown>
|
|
135
|
+
return rec.name === sessionName && rec.agentId === agent.id
|
|
136
|
+
}) as Record<string, unknown> | undefined
|
|
133
137
|
if (!session) {
|
|
134
138
|
const sessionId = genId()
|
|
135
139
|
const now = Date.now()
|
|
@@ -160,10 +164,11 @@ export async function POST(req: Request, { params }: { params: Promise<{ id: str
|
|
|
160
164
|
heartbeatEnabled: agent.heartbeatEnabled ?? true,
|
|
161
165
|
heartbeatIntervalSec: agent.heartbeatIntervalSec ?? null,
|
|
162
166
|
}
|
|
163
|
-
sessions[session.id] = session
|
|
167
|
+
sessions[session.id as string] = session
|
|
164
168
|
saveSessions(sessions)
|
|
165
169
|
}
|
|
166
170
|
|
|
171
|
+
const sid = session.id as string
|
|
167
172
|
const payloadPreview = (rawBody || '').slice(0, 12_000)
|
|
168
173
|
const prompt = [
|
|
169
174
|
'Webhook event received.',
|
|
@@ -179,25 +184,60 @@ export async function POST(req: Request, { params }: { params: Promise<{ id: str
|
|
|
179
184
|
'Handle this event now. If this requires notifying the user, use configured connector tools.',
|
|
180
185
|
].join('\n')
|
|
181
186
|
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
187
|
+
try {
|
|
188
|
+
const run = enqueueSessionRun({
|
|
189
|
+
sessionId: sid,
|
|
190
|
+
message: prompt,
|
|
191
|
+
source: 'webhook',
|
|
192
|
+
internal: false,
|
|
193
|
+
mode: 'followup',
|
|
194
|
+
})
|
|
189
195
|
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
196
|
+
appendWebhookLog(genId(8), {
|
|
197
|
+
id: genId(8), webhookId: id, event: incomingEvent,
|
|
198
|
+
payload: (rawBody || '').slice(0, 2000), status: 'success',
|
|
199
|
+
sessionId: sid, runId: run.runId, timestamp: Date.now(),
|
|
200
|
+
})
|
|
195
201
|
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
202
|
+
return NextResponse.json({
|
|
203
|
+
ok: true,
|
|
204
|
+
webhookId: id,
|
|
205
|
+
event: incomingEvent,
|
|
206
|
+
sessionId: sid,
|
|
207
|
+
runId: run.runId,
|
|
208
|
+
})
|
|
209
|
+
} catch (err: unknown) {
|
|
210
|
+
const errorMsg = err instanceof Error ? err.message : String(err)
|
|
211
|
+
|
|
212
|
+
// Enqueue for retry with exponential backoff
|
|
213
|
+
const retryId = genId()
|
|
214
|
+
const now = Date.now()
|
|
215
|
+
const retryEntry: WebhookRetryEntry = {
|
|
216
|
+
id: retryId,
|
|
217
|
+
webhookId: id,
|
|
218
|
+
event: incomingEvent,
|
|
219
|
+
payload: (rawBody || '').slice(0, 12_000),
|
|
220
|
+
attempts: 1,
|
|
221
|
+
maxAttempts: 3,
|
|
222
|
+
nextRetryAt: now + 30_000,
|
|
223
|
+
deadLettered: false,
|
|
224
|
+
createdAt: now,
|
|
225
|
+
}
|
|
226
|
+
upsertWebhookRetry(retryId, retryEntry)
|
|
227
|
+
|
|
228
|
+
appendWebhookLog(genId(8), {
|
|
229
|
+
id: genId(8), webhookId: id, event: incomingEvent,
|
|
230
|
+
payload: (rawBody || '').slice(0, 2000), status: 'error',
|
|
231
|
+
error: `Dispatch failed, queued for retry: ${errorMsg}`, timestamp: Date.now(),
|
|
232
|
+
})
|
|
233
|
+
|
|
234
|
+
return NextResponse.json({
|
|
235
|
+
ok: true,
|
|
236
|
+
webhookId: id,
|
|
237
|
+
event: incomingEvent,
|
|
238
|
+
retryQueued: true,
|
|
239
|
+
retryId,
|
|
240
|
+
error: errorMsg,
|
|
241
|
+
})
|
|
242
|
+
}
|
|
203
243
|
}
|
package/src/cli/index.js
CHANGED
|
@@ -23,6 +23,13 @@ const COMMAND_GROUPS = [
|
|
|
23
23
|
cmd('thread', 'POST', '/agents/:id/thread', 'Get or create agent thread session'),
|
|
24
24
|
],
|
|
25
25
|
},
|
|
26
|
+
{
|
|
27
|
+
name: 'activity',
|
|
28
|
+
description: 'Query activity feed events',
|
|
29
|
+
commands: [
|
|
30
|
+
cmd('list', 'GET', '/activity', 'List activity events (use --query limit=50, --query entityType=task, --query action=updated)'),
|
|
31
|
+
],
|
|
32
|
+
},
|
|
26
33
|
{
|
|
27
34
|
name: 'auth',
|
|
28
35
|
description: 'Access key auth helpers',
|
package/src/cli/spec.js
CHANGED
|
@@ -12,6 +12,12 @@ const COMMAND_GROUPS = {
|
|
|
12
12
|
purge: { description: 'Permanently delete a trashed agent', method: 'DELETE', path: '/agents/trash' },
|
|
13
13
|
},
|
|
14
14
|
},
|
|
15
|
+
activity: {
|
|
16
|
+
description: 'Query activity feed events',
|
|
17
|
+
commands: {
|
|
18
|
+
list: { description: 'List activity events (supports --query limit=50,entityType=task,action=updated)', method: 'GET', path: '/activity' },
|
|
19
|
+
},
|
|
20
|
+
},
|
|
15
21
|
auth: {
|
|
16
22
|
description: 'Access-key auth checks',
|
|
17
23
|
commands: {
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import { useEffect, useState } from 'react'
|
|
4
|
+
import { useAppStore } from '@/stores/use-app-store'
|
|
5
|
+
import { useWs } from '@/hooks/use-ws'
|
|
6
|
+
import type { ActivityEntry } from '@/types'
|
|
7
|
+
|
|
8
|
+
const ENTITY_ICONS: Record<string, string> = {
|
|
9
|
+
agent: 'A', task: 'T', connector: 'C', session: 'S', webhook: 'W', schedule: 'R',
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
const ACTION_COLORS: Record<string, string> = {
|
|
13
|
+
created: 'bg-emerald-500/15 text-emerald-400',
|
|
14
|
+
updated: 'bg-blue-500/15 text-blue-400',
|
|
15
|
+
deleted: 'bg-red-500/15 text-red-400',
|
|
16
|
+
started: 'bg-green-500/15 text-green-400',
|
|
17
|
+
stopped: 'bg-gray-500/15 text-gray-400',
|
|
18
|
+
queued: 'bg-amber-500/15 text-amber-400',
|
|
19
|
+
completed: 'bg-emerald-500/15 text-emerald-400',
|
|
20
|
+
failed: 'bg-red-500/15 text-red-400',
|
|
21
|
+
approved: 'bg-green-500/15 text-green-400',
|
|
22
|
+
rejected: 'bg-red-500/15 text-red-400',
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function timeAgo(ts: number) {
|
|
26
|
+
const diff = Date.now() - ts
|
|
27
|
+
if (diff < 60_000) return 'just now'
|
|
28
|
+
if (diff < 3600_000) return `${Math.floor(diff / 60_000)}m ago`
|
|
29
|
+
if (diff < 86400_000) return `${Math.floor(diff / 3600_000)}h ago`
|
|
30
|
+
return `${Math.floor(diff / 86400_000)}d ago`
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const ENTITY_TYPES = ['', 'agent', 'task', 'connector', 'session', 'webhook', 'schedule'] as const
|
|
34
|
+
|
|
35
|
+
export function ActivityFeed() {
|
|
36
|
+
const entries = useAppStore((s) => s.activityEntries)
|
|
37
|
+
const loadActivity = useAppStore((s) => s.loadActivity)
|
|
38
|
+
const [filterType, setFilterType] = useState('')
|
|
39
|
+
|
|
40
|
+
useEffect(() => { loadActivity({ entityType: filterType || undefined, limit: 100 }) }, [filterType])
|
|
41
|
+
useWs('activity', () => loadActivity({ entityType: filterType || undefined, limit: 100 }), 10_000)
|
|
42
|
+
|
|
43
|
+
return (
|
|
44
|
+
<div className="flex-1 flex flex-col h-full overflow-hidden">
|
|
45
|
+
<div className="flex items-center justify-between px-8 pt-6 pb-4 shrink-0">
|
|
46
|
+
<div>
|
|
47
|
+
<h1 className="font-display text-[28px] font-800 tracking-[-0.03em]">Activity</h1>
|
|
48
|
+
<p className="text-[13px] text-text-3 mt-1">Audit trail of all entity mutations</p>
|
|
49
|
+
</div>
|
|
50
|
+
<select
|
|
51
|
+
value={filterType}
|
|
52
|
+
onChange={(e) => setFilterType(e.target.value)}
|
|
53
|
+
className="px-3 py-2 rounded-[10px] text-[13px] font-600 cursor-pointer transition-all border bg-transparent border-white/[0.06] text-text-3 hover:bg-white/[0.03] appearance-none"
|
|
54
|
+
style={{ fontFamily: 'inherit', minWidth: 130 }}
|
|
55
|
+
>
|
|
56
|
+
<option value="">All Types</option>
|
|
57
|
+
{ENTITY_TYPES.filter(Boolean).map((t) => (
|
|
58
|
+
<option key={t} value={t}>{t.charAt(0).toUpperCase() + t.slice(1)}s</option>
|
|
59
|
+
))}
|
|
60
|
+
</select>
|
|
61
|
+
</div>
|
|
62
|
+
|
|
63
|
+
<div className="flex-1 overflow-y-auto px-8 pb-6">
|
|
64
|
+
{entries.length === 0 ? (
|
|
65
|
+
<div className="text-center text-text-3 text-[14px] mt-16">No activity yet</div>
|
|
66
|
+
) : (
|
|
67
|
+
<div className="space-y-1">
|
|
68
|
+
{entries.map((entry: ActivityEntry) => (
|
|
69
|
+
<div key={entry.id} className="flex items-start gap-3 py-3 border-b border-white/[0.04]">
|
|
70
|
+
<div className="w-8 h-8 rounded-[8px] bg-surface-2 flex items-center justify-center text-[12px] font-700 text-text-3 shrink-0">
|
|
71
|
+
{ENTITY_ICONS[entry.entityType] || '?'}
|
|
72
|
+
</div>
|
|
73
|
+
<div className="flex-1 min-w-0">
|
|
74
|
+
<div className="flex items-center gap-2 mb-1">
|
|
75
|
+
<span className={`px-1.5 py-0.5 rounded-[5px] text-[10px] font-600 ${ACTION_COLORS[entry.action] || 'bg-white/[0.06] text-text-3'}`}>
|
|
76
|
+
{entry.action}
|
|
77
|
+
</span>
|
|
78
|
+
<span className="text-[10px] text-text-3/50 font-mono">{entry.entityType}</span>
|
|
79
|
+
<span className="text-[10px] text-text-3/40">{entry.actor}</span>
|
|
80
|
+
</div>
|
|
81
|
+
<p className="text-[13px] text-text-2 leading-[1.4] truncate">{entry.summary}</p>
|
|
82
|
+
</div>
|
|
83
|
+
<span className="text-[11px] text-text-3/50 shrink-0 pt-1">{timeAgo(entry.timestamp)}</span>
|
|
84
|
+
</div>
|
|
85
|
+
))}
|
|
86
|
+
</div>
|
|
87
|
+
)}
|
|
88
|
+
</div>
|
|
89
|
+
</div>
|
|
90
|
+
)
|
|
91
|
+
}
|