@swarmclawai/swarmclaw 0.5.1 → 0.5.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/LICENSE +21 -0
- package/README.md +6 -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/notifications/[id]/route.ts +27 -0
- package/src/app/api/notifications/route.ts +64 -0
- package/src/app/api/openclaw/approvals/route.ts +20 -0
- package/src/app/api/search/route.ts +105 -0
- package/src/app/api/tasks/[id]/route.ts +45 -1
- package/src/app/api/tasks/route.ts +7 -1
- package/src/app/api/usage/route.ts +118 -21
- package/src/app/api/webhooks/[id]/route.ts +62 -22
- package/src/cli/index.js +25 -0
- package/src/cli/spec.js +22 -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 +67 -7
- package/src/components/layout/mobile-header.tsx +2 -0
- package/src/components/schedules/schedule-card.tsx +2 -1
- package/src/components/shared/notification-center.tsx +185 -0
- package/src/components/shared/search-dialog.tsx +287 -0
- 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 +356 -0
- package/src/hooks/use-page-active.ts +21 -0
- package/src/hooks/use-ws.ts +13 -1
- package/src/lib/cron-human.ts +114 -0
- 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/create-notification.ts +30 -0
- package/src/lib/server/daemon-state.ts +213 -20
- package/src/lib/server/queue.ts +16 -0
- package/src/lib/server/storage.ts +61 -0
- package/src/lib/view-routes.ts +1 -0
- package/src/lib/ws-client.ts +2 -1
- package/src/proxy.ts +79 -2
- package/src/stores/use-app-store.ts +105 -1
- package/src/stores/use-approval-store.ts +21 -7
- package/src/types/index.ts +53 -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
|
@@ -20,6 +20,7 @@ Inspired by [OpenClaw](https://github.com/openclaw).
|
|
|
20
20
|
- Always use the access key authentication (generated on first run)
|
|
21
21
|
- Never expose port 3456 without a reverse proxy + TLS
|
|
22
22
|
- Review agent system prompts before giving them shell or browser tools
|
|
23
|
+
- Repeated failed access key attempts are rate-limited to slow brute-force attacks
|
|
23
24
|
|
|
24
25
|
## Features
|
|
25
26
|
|
|
@@ -40,6 +41,8 @@ Inspired by [OpenClaw](https://github.com/openclaw).
|
|
|
40
41
|
- **Session Run Queue** — Per-session queued runs with followup/steer/collect modes, collect coalescing for bursty inputs, and run-state APIs
|
|
41
42
|
- **Chat Iteration Workflow** — Edit-and-resend user turns, fork a new session from any message, bookmark key messages, use contextual follow-up suggestion chips, and auto-continue after tool access grants
|
|
42
43
|
- **Live Chat Telemetry** — Thinking/tool/responding stream phases, live main-loop status badges, connector activity presence, tone indicator, and optional sound notifications
|
|
44
|
+
- **Global Search Palette** — `Cmd/Ctrl+K` search across agents, tasks, sessions, schedules, webhooks, and skills from anywhere in the app
|
|
45
|
+
- **Notification Center** — Real-time in-app notifications for task/schedule/daemon events with unread tracking and quick actions
|
|
43
46
|
- **Preview-Rich Chat UI** — Side preview panel for tool outputs (image/browser/html/code), inline code/PDF previews for attachments, and image lightbox support
|
|
44
47
|
- **Voice Settings** — Per-instance ElevenLabs API key + voice ID for TTS replies, plus configurable speech recognition language for chat input
|
|
45
48
|
- **Chat Connectors** — Bridge agents to Discord, Slack, Telegram, WhatsApp, BlueBubbles (iMessage), Signal, Microsoft Teams, Google Chat, Matrix, and OpenClaw with media-aware inbound handling
|
|
@@ -48,6 +51,7 @@ Inspired by [OpenClaw](https://github.com/openclaw).
|
|
|
48
51
|
- **Context Management** — Auto-compaction of conversation history when approaching context limits, with manual `context_status` and `context_summarize` tools for agents
|
|
49
52
|
- **Memory** — Per-agent and per-session memory with hybrid FTS5 + vector embeddings search, relevance-based memory recall injected into runs, and periodic auto-journaling for durable execution context
|
|
50
53
|
- **Cost Tracking** — Per-message token counting and cost estimation displayed in the chat header
|
|
54
|
+
- **Provider Health Metrics** — Usage dashboard surfaces provider request volume, success rates, models used, and last-used timestamps
|
|
51
55
|
- **Model Failover** — Automatic key rotation on rate limits and auth errors with configurable fallback credentials
|
|
52
56
|
- **Plugin System** — Extend agent behavior with JS plugins (hooks: beforeAgentStart, afterAgentComplete, beforeToolExec, afterToolExec, onMessage)
|
|
53
57
|
- **Secrets Vault** — Encrypted storage for API keys and service tokens
|
|
@@ -81,7 +85,7 @@ curl -fsSL https://raw.githubusercontent.com/swarmclawai/swarmclaw/main/install.
|
|
|
81
85
|
```
|
|
82
86
|
|
|
83
87
|
The installer resolves the latest stable release tag and installs that version by default.
|
|
84
|
-
To pin a version: `SWARMCLAW_VERSION=v0.5.
|
|
88
|
+
To pin a version: `SWARMCLAW_VERSION=v0.5.3 curl ... | bash`
|
|
85
89
|
|
|
86
90
|
Or run locally from the repo (friendly for non-technical users):
|
|
87
91
|
|
|
@@ -600,4 +604,4 @@ swarmclaw memory maintenance
|
|
|
600
604
|
|
|
601
605
|
## License
|
|
602
606
|
|
|
603
|
-
MIT
|
|
607
|
+
[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.3",
|
|
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
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { NextResponse } from 'next/server'
|
|
2
|
+
import { loadNotifications, markNotificationRead, deleteNotification } from '@/lib/server/storage'
|
|
3
|
+
import { notify } from '@/lib/server/ws-hub'
|
|
4
|
+
|
|
5
|
+
export async function PUT(_req: Request, { params }: { params: Promise<{ id: string }> }) {
|
|
6
|
+
const { id } = await params
|
|
7
|
+
const all = loadNotifications()
|
|
8
|
+
if (!all[id]) {
|
|
9
|
+
return NextResponse.json({ error: 'Not found' }, { status: 404 })
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
markNotificationRead(id)
|
|
13
|
+
notify('notifications')
|
|
14
|
+
return NextResponse.json({ ok: true })
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export async function DELETE(_req: Request, { params }: { params: Promise<{ id: string }> }) {
|
|
18
|
+
const { id } = await params
|
|
19
|
+
const all = loadNotifications()
|
|
20
|
+
if (!all[id]) {
|
|
21
|
+
return NextResponse.json({ error: 'Not found' }, { status: 404 })
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
deleteNotification(id)
|
|
25
|
+
notify('notifications')
|
|
26
|
+
return NextResponse.json({ ok: true })
|
|
27
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { NextResponse } from 'next/server'
|
|
2
|
+
import { genId } from '@/lib/id'
|
|
3
|
+
import { loadNotifications, saveNotification, deleteNotification } from '@/lib/server/storage'
|
|
4
|
+
import { notify } from '@/lib/server/ws-hub'
|
|
5
|
+
import type { AppNotification } from '@/types'
|
|
6
|
+
export const dynamic = 'force-dynamic'
|
|
7
|
+
|
|
8
|
+
export async function GET(req: Request) {
|
|
9
|
+
const { searchParams } = new URL(req.url)
|
|
10
|
+
const unreadOnly = searchParams.get('unreadOnly') === 'true'
|
|
11
|
+
const limit = Math.min(200, Math.max(1, Number(searchParams.get('limit')) || 100))
|
|
12
|
+
|
|
13
|
+
const all = loadNotifications()
|
|
14
|
+
let entries = Object.values(all) as AppNotification[]
|
|
15
|
+
|
|
16
|
+
if (unreadOnly) {
|
|
17
|
+
entries = entries.filter((e) => !e.read)
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
entries.sort((a, b) => b.createdAt - a.createdAt)
|
|
21
|
+
entries = entries.slice(0, limit)
|
|
22
|
+
|
|
23
|
+
return NextResponse.json(entries)
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export async function POST(req: Request) {
|
|
27
|
+
const body = (await req.json()) as Record<string, unknown>
|
|
28
|
+
const id = genId()
|
|
29
|
+
const notification: AppNotification = {
|
|
30
|
+
id,
|
|
31
|
+
type: (['info', 'success', 'warning', 'error'].includes(body.type as string) ? body.type : 'info') as AppNotification['type'],
|
|
32
|
+
title: typeof body.title === 'string' ? body.title : 'Notification',
|
|
33
|
+
message: typeof body.message === 'string' ? body.message : undefined,
|
|
34
|
+
entityType: typeof body.entityType === 'string' ? body.entityType : undefined,
|
|
35
|
+
entityId: typeof body.entityId === 'string' ? body.entityId : undefined,
|
|
36
|
+
read: false,
|
|
37
|
+
createdAt: Date.now(),
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
saveNotification(id, notification)
|
|
41
|
+
notify('notifications')
|
|
42
|
+
return NextResponse.json(notification, { status: 201 })
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export async function DELETE(req: Request) {
|
|
46
|
+
const { searchParams } = new URL(req.url)
|
|
47
|
+
const specificId = searchParams.get('id')
|
|
48
|
+
|
|
49
|
+
if (specificId) {
|
|
50
|
+
deleteNotification(specificId)
|
|
51
|
+
} else {
|
|
52
|
+
// Clear all read notifications
|
|
53
|
+
const all = loadNotifications()
|
|
54
|
+
for (const raw of Object.values(all)) {
|
|
55
|
+
const entry = raw as AppNotification
|
|
56
|
+
if (entry.read) {
|
|
57
|
+
deleteNotification(entry.id)
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
notify('notifications')
|
|
63
|
+
return NextResponse.json({ ok: true })
|
|
64
|
+
}
|
|
@@ -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)
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import { NextResponse } from 'next/server'
|
|
2
|
+
import {
|
|
3
|
+
loadTasks,
|
|
4
|
+
loadAgents,
|
|
5
|
+
loadSessions,
|
|
6
|
+
loadSchedules,
|
|
7
|
+
loadWebhooks,
|
|
8
|
+
loadSkills,
|
|
9
|
+
} from '@/lib/server/storage'
|
|
10
|
+
|
|
11
|
+
interface SearchResult {
|
|
12
|
+
type: 'task' | 'agent' | 'session' | 'schedule' | 'webhook' | 'skill'
|
|
13
|
+
id: string
|
|
14
|
+
title: string
|
|
15
|
+
description?: string
|
|
16
|
+
status?: string
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const MAX_RESULTS = 20
|
|
20
|
+
|
|
21
|
+
function matches(haystack: string | undefined | null, needle: string): boolean {
|
|
22
|
+
if (!haystack) return false
|
|
23
|
+
return haystack.toLowerCase().includes(needle)
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function searchCollection(
|
|
27
|
+
collection: Record<string, Record<string, unknown>>,
|
|
28
|
+
type: SearchResult['type'],
|
|
29
|
+
needle: string,
|
|
30
|
+
titleKey: string,
|
|
31
|
+
descKey: string,
|
|
32
|
+
statusKey?: string,
|
|
33
|
+
): SearchResult[] {
|
|
34
|
+
const results: SearchResult[] = []
|
|
35
|
+
for (const [id, item] of Object.entries(collection)) {
|
|
36
|
+
const title = item[titleKey] as string | undefined
|
|
37
|
+
const desc = item[descKey] as string | undefined
|
|
38
|
+
const idStr = typeof item.id === 'string' ? item.id : id
|
|
39
|
+
if (matches(title, needle) || matches(desc, needle) || matches(idStr, needle)) {
|
|
40
|
+
results.push({
|
|
41
|
+
type,
|
|
42
|
+
id,
|
|
43
|
+
title: title || idStr || id,
|
|
44
|
+
description: desc ? desc.slice(0, 120) : undefined,
|
|
45
|
+
status: statusKey ? (item[statusKey] as string | undefined) : undefined,
|
|
46
|
+
})
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
return results
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export async function GET(req: Request) {
|
|
53
|
+
const { searchParams } = new URL(req.url)
|
|
54
|
+
const q = (searchParams.get('q') || '').trim().toLowerCase()
|
|
55
|
+
|
|
56
|
+
if (q.length < 2) {
|
|
57
|
+
return NextResponse.json({ results: [] })
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const tasks = loadTasks() as Record<string, Record<string, unknown>>
|
|
61
|
+
const agents = loadAgents() as Record<string, Record<string, unknown>>
|
|
62
|
+
const sessions = loadSessions() as Record<string, Record<string, unknown>>
|
|
63
|
+
const schedules = loadSchedules() as Record<string, Record<string, unknown>>
|
|
64
|
+
const webhooks = loadWebhooks() as Record<string, Record<string, unknown>>
|
|
65
|
+
const skills = loadSkills() as Record<string, Record<string, unknown>>
|
|
66
|
+
|
|
67
|
+
const buckets: SearchResult[][] = [
|
|
68
|
+
searchCollection(agents, 'agent', q, 'name', 'description'),
|
|
69
|
+
searchCollection(tasks, 'task', q, 'title', 'description', 'status'),
|
|
70
|
+
searchCollection(sessions, 'session', q, 'name', 'cwd'),
|
|
71
|
+
searchCollection(schedules, 'schedule', q, 'name', 'taskPrompt', 'status'),
|
|
72
|
+
searchCollection(webhooks, 'webhook', q, 'name', 'source'),
|
|
73
|
+
searchCollection(skills, 'skill', q, 'name', 'description'),
|
|
74
|
+
]
|
|
75
|
+
|
|
76
|
+
// Proportional allocation across types
|
|
77
|
+
const totalRaw = buckets.reduce((s, b) => s + b.length, 0)
|
|
78
|
+
if (totalRaw === 0) {
|
|
79
|
+
return NextResponse.json({ results: [] })
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const results: SearchResult[] = []
|
|
83
|
+
if (totalRaw <= MAX_RESULTS) {
|
|
84
|
+
for (const bucket of buckets) results.push(...bucket)
|
|
85
|
+
} else {
|
|
86
|
+
// Give each bucket a fair share, round-robin leftover slots
|
|
87
|
+
const perBucket = Math.floor(MAX_RESULTS / buckets.length)
|
|
88
|
+
let remaining = MAX_RESULTS
|
|
89
|
+
for (const bucket of buckets) {
|
|
90
|
+
const take = Math.min(bucket.length, perBucket)
|
|
91
|
+
results.push(...bucket.slice(0, take))
|
|
92
|
+
remaining -= take
|
|
93
|
+
}
|
|
94
|
+
// Fill remaining slots from buckets that had more
|
|
95
|
+
for (const bucket of buckets) {
|
|
96
|
+
if (remaining <= 0) break
|
|
97
|
+
const alreadyTaken = Math.min(bucket.length, perBucket)
|
|
98
|
+
const extra = bucket.slice(alreadyTaken, alreadyTaken + remaining)
|
|
99
|
+
results.push(...extra)
|
|
100
|
+
remaining -= extra.length
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
return NextResponse.json({ results })
|
|
105
|
+
}
|
|
@@ -1,12 +1,13 @@
|
|
|
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'
|
|
7
7
|
import { formatValidationFailure, validateTaskCompletion } from '@/lib/server/task-validation'
|
|
8
8
|
import { pushMainLoopEventToMainSessions } from '@/lib/server/main-agent-loop'
|
|
9
9
|
import { notify } from '@/lib/server/ws-hub'
|
|
10
|
+
import { createNotification } from '@/lib/server/create-notification'
|
|
10
11
|
|
|
11
12
|
export async function GET(_req: Request, { params }: { params: Promise<{ id: string }> }) {
|
|
12
13
|
// Keep completed queue integrity even if daemon is not running.
|
|
@@ -65,6 +66,7 @@ export async function PUT(req: Request, { params }: { params: Promise<{ id: stri
|
|
|
65
66
|
}
|
|
66
67
|
|
|
67
68
|
saveTasks(tasks)
|
|
69
|
+
logActivity({ entityType: 'task', entityId: id, action: 'updated', actor: 'user', summary: `Task updated: "${tasks[id].title}" (${prevStatus} → ${tasks[id].status})` })
|
|
68
70
|
if (prevStatus !== tasks[id].status) {
|
|
69
71
|
pushMainLoopEventToMainSessions({
|
|
70
72
|
type: 'task_status_changed',
|
|
@@ -75,6 +77,47 @@ export async function PUT(req: Request, { params }: { params: Promise<{ id: stri
|
|
|
75
77
|
// If task is manually transitioned to a terminal status, disable session heartbeat.
|
|
76
78
|
if (prevStatus !== tasks[id].status && (tasks[id].status === 'completed' || tasks[id].status === 'failed')) {
|
|
77
79
|
disableSessionHeartbeat(tasks[id].sessionId)
|
|
80
|
+
createNotification({
|
|
81
|
+
type: tasks[id].status === 'completed' ? 'success' : 'error',
|
|
82
|
+
title: `Task ${tasks[id].status}: "${tasks[id].title}"`,
|
|
83
|
+
message: tasks[id].status === 'failed' ? tasks[id].error?.slice(0, 200) : undefined,
|
|
84
|
+
entityType: 'task',
|
|
85
|
+
entityId: id,
|
|
86
|
+
})
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// Dependency check: cannot queue a task if any blocker is incomplete
|
|
90
|
+
if (tasks[id].status === 'queued') {
|
|
91
|
+
const blockers = Array.isArray(tasks[id].blockedBy) ? tasks[id].blockedBy : []
|
|
92
|
+
const incompleteBlocker = blockers.find((bid: string) => tasks[bid] && tasks[bid].status !== 'completed')
|
|
93
|
+
if (incompleteBlocker) {
|
|
94
|
+
// Revert status change and reject
|
|
95
|
+
tasks[id].status = prevStatus
|
|
96
|
+
tasks[id].updatedAt = Date.now()
|
|
97
|
+
saveTasks(tasks)
|
|
98
|
+
return NextResponse.json(
|
|
99
|
+
{ error: 'Cannot queue: blocked by incomplete tasks', blockedBy: incompleteBlocker },
|
|
100
|
+
{ status: 409 },
|
|
101
|
+
)
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// When a task is completed, auto-unblock dependent tasks
|
|
106
|
+
if (tasks[id].status === 'completed') {
|
|
107
|
+
const blockedIds = Array.isArray(tasks[id].blocks) ? tasks[id].blocks as string[] : []
|
|
108
|
+
for (const blockedId of blockedIds) {
|
|
109
|
+
const blocked = tasks[blockedId]
|
|
110
|
+
if (!blocked) continue
|
|
111
|
+
const deps = Array.isArray(blocked.blockedBy) ? blocked.blockedBy as string[] : []
|
|
112
|
+
const allDone = deps.every((depId: string) => tasks[depId]?.status === 'completed')
|
|
113
|
+
if (allDone && (blocked.status === 'backlog' || blocked.status === 'todo')) {
|
|
114
|
+
blocked.status = 'queued'
|
|
115
|
+
blocked.queuedAt = Date.now()
|
|
116
|
+
blocked.updatedAt = Date.now()
|
|
117
|
+
saveTasks(tasks)
|
|
118
|
+
enqueueTask(blockedId)
|
|
119
|
+
}
|
|
120
|
+
}
|
|
78
121
|
}
|
|
79
122
|
|
|
80
123
|
// If status changed to 'queued', enqueue it
|
|
@@ -96,6 +139,7 @@ export async function DELETE(_req: Request, { params }: { params: Promise<{ id:
|
|
|
96
139
|
tasks[id].archivedAt = Date.now()
|
|
97
140
|
tasks[id].updatedAt = Date.now()
|
|
98
141
|
saveTasks(tasks)
|
|
142
|
+
logActivity({ entityType: 'task', entityId: id, action: 'deleted', actor: 'user', summary: `Task archived: "${tasks[id].title}"` })
|
|
99
143
|
pushMainLoopEventToMainSessions({
|
|
100
144
|
type: 'task_archived',
|
|
101
145
|
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}.`,
|