@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
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { loadQueue, loadSchedules, loadSessions, saveSessions, loadConnectors } from './storage'
|
|
1
|
+
import { loadQueue, loadSchedules, loadSessions, saveSessions, loadConnectors, saveConnectors, loadWebhookRetryQueue, upsertWebhookRetry, deleteWebhookRetry, loadWebhooks, loadAgents, appendWebhookLog } from './storage'
|
|
2
2
|
import { notify } from './ws-hub'
|
|
3
3
|
import { processNext, cleanupFinishedTaskSessions, validateCompletedTasksQueue, recoverStalledRunningTasks } from './queue'
|
|
4
4
|
import { startScheduler, stopScheduler } from './scheduler'
|
|
@@ -13,6 +13,10 @@ import {
|
|
|
13
13
|
} from './connectors/manager'
|
|
14
14
|
import { startHeartbeatService, stopHeartbeatService, getHeartbeatServiceStatus } from './heartbeat-service'
|
|
15
15
|
import { hasOpenClawAgents, ensureGatewayConnected, disconnectGateway, getGateway } from './openclaw-gateway'
|
|
16
|
+
import { enqueueSessionRun } from './session-run-manager'
|
|
17
|
+
import { WORKSPACE_DIR } from './data-dir'
|
|
18
|
+
import { genId } from '@/lib/id'
|
|
19
|
+
import type { WebhookRetryEntry } from '@/types'
|
|
16
20
|
|
|
17
21
|
const QUEUE_CHECK_INTERVAL = 30_000 // 30 seconds
|
|
18
22
|
const BROWSER_SWEEP_INTERVAL = 60_000 // 60 seconds
|
|
@@ -24,6 +28,7 @@ const STALE_AUTO_DISABLE_MULTIPLIER = 16 // auto-disable after much longer susta
|
|
|
24
28
|
const STALE_AUTO_DISABLE_MIN_MS = 45 * 60 * 1000 // never auto-disable before 45 minutes
|
|
25
29
|
const CONNECTOR_RESTART_BASE_MS = 30_000
|
|
26
30
|
const CONNECTOR_RESTART_MAX_MS = 15 * 60 * 1000
|
|
31
|
+
const MAX_WAKE_ATTEMPTS = 3
|
|
27
32
|
|
|
28
33
|
function parseBoolish(value: unknown, fallback: boolean): boolean {
|
|
29
34
|
if (typeof value === 'boolean') return value
|
|
@@ -70,16 +75,17 @@ const ds: {
|
|
|
70
75
|
healthIntervalId: ReturnType<typeof setInterval> | null
|
|
71
76
|
/** Session IDs we've already alerted as stale (alert-once semantics). */
|
|
72
77
|
staleSessionIds: Set<string>
|
|
73
|
-
connectorRestartState: Map<string, { lastAttemptAt: number; failCount: number }>
|
|
78
|
+
connectorRestartState: Map<string, { lastAttemptAt: number; failCount: number; wakeAttempts: number }>
|
|
74
79
|
manualStopRequested: boolean
|
|
75
80
|
running: boolean
|
|
76
81
|
lastProcessedAt: number | null
|
|
82
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
77
83
|
} = (globalThis as any)[gk] ?? ((globalThis as any)[gk] = {
|
|
78
84
|
queueIntervalId: null,
|
|
79
85
|
browserSweepId: null,
|
|
80
86
|
healthIntervalId: null,
|
|
81
87
|
staleSessionIds: new Set<string>(),
|
|
82
|
-
connectorRestartState: new Map<string, { lastAttemptAt: number; failCount: number }>(),
|
|
88
|
+
connectorRestartState: new Map<string, { lastAttemptAt: number; failCount: number; wakeAttempts: number }>(),
|
|
83
89
|
manualStopRequested: false,
|
|
84
90
|
running: false,
|
|
85
91
|
lastProcessedAt: null,
|
|
@@ -87,8 +93,9 @@ const ds: {
|
|
|
87
93
|
|
|
88
94
|
// Backfill fields for hot-reloaded daemon state objects from older code versions.
|
|
89
95
|
if (!ds.staleSessionIds) ds.staleSessionIds = new Set<string>()
|
|
90
|
-
if (!ds.connectorRestartState) ds.connectorRestartState = new Map<string, { lastAttemptAt: number; failCount: number }>()
|
|
96
|
+
if (!ds.connectorRestartState) ds.connectorRestartState = new Map<string, { lastAttemptAt: number; failCount: number; wakeAttempts: number }>()
|
|
91
97
|
// Migrate from old issueLastAlertAt map if present (HMR across code versions)
|
|
98
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
92
99
|
if ((ds as any).issueLastAlertAt) delete (ds as any).issueLastAlertAt
|
|
93
100
|
if (ds.healthIntervalId === undefined) ds.healthIntervalId = null
|
|
94
101
|
if (ds.manualStopRequested === undefined) ds.manualStopRequested = false
|
|
@@ -221,8 +228,8 @@ async function sendHealthAlert(text: string) {
|
|
|
221
228
|
|
|
222
229
|
async function runConnectorHealthChecks(now: number) {
|
|
223
230
|
const connectors = loadConnectors()
|
|
224
|
-
for (const connector of Object.values(connectors) as
|
|
225
|
-
if (!connector?.id) continue
|
|
231
|
+
for (const connector of Object.values(connectors) as Record<string, unknown>[]) {
|
|
232
|
+
if (!connector?.id || typeof connector.id !== 'string') continue
|
|
226
233
|
if (connector.isEnabled !== true) {
|
|
227
234
|
ds.connectorRestartState.delete(connector.id)
|
|
228
235
|
continue
|
|
@@ -234,7 +241,22 @@ async function runConnectorHealthChecks(now: number) {
|
|
|
234
241
|
continue
|
|
235
242
|
}
|
|
236
243
|
|
|
237
|
-
const current = ds.connectorRestartState.get(connector.id) || { lastAttemptAt: 0, failCount: 0 }
|
|
244
|
+
const current = ds.connectorRestartState.get(connector.id) || { lastAttemptAt: 0, failCount: 0, wakeAttempts: 0 }
|
|
245
|
+
// Backfill wakeAttempts for state objects created before this field existed
|
|
246
|
+
if (typeof current.wakeAttempts !== 'number') current.wakeAttempts = 0
|
|
247
|
+
|
|
248
|
+
// Cap wake attempts — stop retrying after MAX_WAKE_ATTEMPTS consecutive failures
|
|
249
|
+
if (current.wakeAttempts >= MAX_WAKE_ATTEMPTS) {
|
|
250
|
+
console.warn(`[health] Connector "${connector.name}" exceeded ${MAX_WAKE_ATTEMPTS} wake attempts — giving up`)
|
|
251
|
+
connector.status = 'error'
|
|
252
|
+
connector.lastError = `Auto-restart gave up after ${MAX_WAKE_ATTEMPTS} consecutive failures`
|
|
253
|
+
connector.updatedAt = Date.now()
|
|
254
|
+
connectors[connector.id] = connector
|
|
255
|
+
saveConnectors(connectors)
|
|
256
|
+
ds.connectorRestartState.delete(connector.id)
|
|
257
|
+
continue
|
|
258
|
+
}
|
|
259
|
+
|
|
238
260
|
const backoffMs = Math.min(
|
|
239
261
|
CONNECTOR_RESTART_MAX_MS,
|
|
240
262
|
CONNECTOR_RESTART_BASE_MS * (2 ** Math.min(6, current.failCount)),
|
|
@@ -247,10 +269,154 @@ async function runConnectorHealthChecks(now: number) {
|
|
|
247
269
|
await startConnector(connector.id)
|
|
248
270
|
ds.connectorRestartState.delete(connector.id)
|
|
249
271
|
await sendHealthAlert(`Connector "${connector.name}" (${connector.platform}) was down and has been auto-restarted.`)
|
|
250
|
-
} catch (err:
|
|
272
|
+
} catch (err: unknown) {
|
|
251
273
|
current.failCount += 1
|
|
274
|
+
current.wakeAttempts += 1
|
|
252
275
|
ds.connectorRestartState.set(connector.id, current)
|
|
253
|
-
|
|
276
|
+
const message = err instanceof Error ? err.message : String(err)
|
|
277
|
+
console.warn(`[health] Connector auto-restart failed for ${connector.name} (attempt ${current.wakeAttempts}/${MAX_WAKE_ATTEMPTS}): ${message}`)
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
async function processWebhookRetries() {
|
|
283
|
+
const retryQueue = loadWebhookRetryQueue()
|
|
284
|
+
const now = Date.now()
|
|
285
|
+
const dueEntries: WebhookRetryEntry[] = []
|
|
286
|
+
|
|
287
|
+
for (const raw of Object.values(retryQueue)) {
|
|
288
|
+
const entry = raw as WebhookRetryEntry
|
|
289
|
+
if (entry.deadLettered) continue
|
|
290
|
+
if (entry.nextRetryAt > now) continue
|
|
291
|
+
dueEntries.push(entry)
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
if (dueEntries.length === 0) return
|
|
295
|
+
|
|
296
|
+
const webhooks = loadWebhooks()
|
|
297
|
+
const agents = loadAgents()
|
|
298
|
+
const sessions = loadSessions()
|
|
299
|
+
|
|
300
|
+
for (const entry of dueEntries) {
|
|
301
|
+
const webhook = webhooks[entry.webhookId] as Record<string, unknown> | undefined
|
|
302
|
+
if (!webhook) {
|
|
303
|
+
// Webhook deleted — drop the retry
|
|
304
|
+
deleteWebhookRetry(entry.id)
|
|
305
|
+
continue
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
const agentId = typeof webhook.agentId === 'string' ? webhook.agentId : ''
|
|
309
|
+
const agent = agentId ? (agents[agentId] as Record<string, unknown> | undefined) : null
|
|
310
|
+
if (!agent) {
|
|
311
|
+
entry.deadLettered = true
|
|
312
|
+
upsertWebhookRetry(entry.id, entry)
|
|
313
|
+
console.warn(`[webhook-retry] Dead-lettered ${entry.id}: agent not found for webhook ${entry.webhookId}`)
|
|
314
|
+
continue
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
// Find or create a webhook session (same logic as the POST handler)
|
|
318
|
+
const sessionName = `webhook:${entry.webhookId}`
|
|
319
|
+
let session = Object.values(sessions).find(
|
|
320
|
+
(s: unknown) => {
|
|
321
|
+
const rec = s as Record<string, unknown>
|
|
322
|
+
return rec.name === sessionName && rec.agentId === agent.id
|
|
323
|
+
},
|
|
324
|
+
) as Record<string, unknown> | undefined
|
|
325
|
+
|
|
326
|
+
if (!session) {
|
|
327
|
+
const sessionId = genId()
|
|
328
|
+
const ts = Date.now()
|
|
329
|
+
session = {
|
|
330
|
+
id: sessionId,
|
|
331
|
+
name: sessionName,
|
|
332
|
+
cwd: WORKSPACE_DIR,
|
|
333
|
+
user: 'system',
|
|
334
|
+
provider: agent.provider || 'claude-cli',
|
|
335
|
+
model: agent.model || '',
|
|
336
|
+
credentialId: agent.credentialId || null,
|
|
337
|
+
apiEndpoint: agent.apiEndpoint || null,
|
|
338
|
+
claudeSessionId: null,
|
|
339
|
+
codexThreadId: null,
|
|
340
|
+
opencodeSessionId: null,
|
|
341
|
+
delegateResumeIds: { claudeCode: null, codex: null, opencode: null },
|
|
342
|
+
messages: [],
|
|
343
|
+
createdAt: ts,
|
|
344
|
+
lastActiveAt: ts,
|
|
345
|
+
sessionType: 'orchestrated',
|
|
346
|
+
agentId: agent.id,
|
|
347
|
+
parentSessionId: null,
|
|
348
|
+
tools: agent.tools || [],
|
|
349
|
+
heartbeatEnabled: (agent.heartbeatEnabled as boolean | undefined) ?? true,
|
|
350
|
+
heartbeatIntervalSec: (agent.heartbeatIntervalSec as number | null | undefined) ?? null,
|
|
351
|
+
}
|
|
352
|
+
sessions[session.id as string] = session
|
|
353
|
+
const { saveSessions: save } = await import('./storage')
|
|
354
|
+
save(sessions)
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
const payloadPreview = (entry.payload || '').slice(0, 12_000)
|
|
358
|
+
const prompt = [
|
|
359
|
+
'Webhook event received (retry).',
|
|
360
|
+
`Webhook ID: ${entry.webhookId}`,
|
|
361
|
+
`Webhook Name: ${(webhook.name as string) || entry.webhookId}`,
|
|
362
|
+
`Source: ${(webhook.source as string) || 'custom'}`,
|
|
363
|
+
`Event: ${entry.event}`,
|
|
364
|
+
`Retry attempt: ${entry.attempts}`,
|
|
365
|
+
`Original received at: ${new Date(entry.createdAt).toISOString()}`,
|
|
366
|
+
'',
|
|
367
|
+
'Payload:',
|
|
368
|
+
payloadPreview || '(empty payload)',
|
|
369
|
+
'',
|
|
370
|
+
'Handle this event now. If this requires notifying the user, use configured connector tools.',
|
|
371
|
+
].join('\n')
|
|
372
|
+
|
|
373
|
+
try {
|
|
374
|
+
const run = enqueueSessionRun({
|
|
375
|
+
sessionId: session.id as string,
|
|
376
|
+
message: prompt,
|
|
377
|
+
source: 'webhook',
|
|
378
|
+
internal: false,
|
|
379
|
+
mode: 'followup',
|
|
380
|
+
})
|
|
381
|
+
|
|
382
|
+
appendWebhookLog(genId(8), {
|
|
383
|
+
id: genId(8),
|
|
384
|
+
webhookId: entry.webhookId,
|
|
385
|
+
event: entry.event,
|
|
386
|
+
payload: (entry.payload || '').slice(0, 2000),
|
|
387
|
+
status: 'success',
|
|
388
|
+
sessionId: session.id,
|
|
389
|
+
runId: run.runId,
|
|
390
|
+
timestamp: Date.now(),
|
|
391
|
+
})
|
|
392
|
+
|
|
393
|
+
deleteWebhookRetry(entry.id)
|
|
394
|
+
console.log(`[webhook-retry] Successfully retried ${entry.id} for webhook ${entry.webhookId} (attempt ${entry.attempts})`)
|
|
395
|
+
} catch (err: unknown) {
|
|
396
|
+
const errorMsg = err instanceof Error ? err.message : String(err)
|
|
397
|
+
entry.attempts += 1
|
|
398
|
+
|
|
399
|
+
if (entry.attempts >= entry.maxAttempts) {
|
|
400
|
+
entry.deadLettered = true
|
|
401
|
+
upsertWebhookRetry(entry.id, entry)
|
|
402
|
+
console.warn(`[webhook-retry] Dead-lettered ${entry.id} after ${entry.attempts} attempts: ${errorMsg}`)
|
|
403
|
+
|
|
404
|
+
appendWebhookLog(genId(8), {
|
|
405
|
+
id: genId(8),
|
|
406
|
+
webhookId: entry.webhookId,
|
|
407
|
+
event: entry.event,
|
|
408
|
+
payload: (entry.payload || '').slice(0, 2000),
|
|
409
|
+
status: 'error',
|
|
410
|
+
error: `Dead-lettered after ${entry.attempts} attempts: ${errorMsg}`,
|
|
411
|
+
timestamp: Date.now(),
|
|
412
|
+
})
|
|
413
|
+
} else {
|
|
414
|
+
// Exponential backoff: 30s * 2^attempt + random jitter (0-5000ms)
|
|
415
|
+
const jitter = Math.floor(Math.random() * 5000)
|
|
416
|
+
entry.nextRetryAt = Date.now() + (30_000 * Math.pow(2, entry.attempts)) + jitter
|
|
417
|
+
upsertWebhookRetry(entry.id, entry)
|
|
418
|
+
console.warn(`[webhook-retry] Retry ${entry.id} failed (attempt ${entry.attempts}/${entry.maxAttempts}), next at ${new Date(entry.nextRetryAt).toISOString()}: ${errorMsg}`)
|
|
419
|
+
}
|
|
254
420
|
}
|
|
255
421
|
}
|
|
256
422
|
}
|
|
@@ -268,10 +434,12 @@ async function runHealthChecks() {
|
|
|
268
434
|
const currentlyStale = new Set<string>()
|
|
269
435
|
let sessionsDirty = false
|
|
270
436
|
|
|
271
|
-
for (const session of Object.values(sessions) as
|
|
272
|
-
if (!session?.id) continue
|
|
437
|
+
for (const session of Object.values(sessions) as Record<string, unknown>[]) {
|
|
438
|
+
if (!session?.id || typeof session.id !== 'string') continue
|
|
273
439
|
if (session.heartbeatEnabled !== true) continue
|
|
274
440
|
|
|
441
|
+
const sessionId = session.id
|
|
442
|
+
const sessionLabel = String(session.name || sessionId)
|
|
275
443
|
const intervalSec = parseHeartbeatIntervalSec(session.heartbeatIntervalSec, 120)
|
|
276
444
|
if (intervalSec <= 0) continue
|
|
277
445
|
const staleAfter = Math.max(intervalSec * STALE_MULTIPLIER * 1000, STALE_MIN_MS)
|
|
@@ -285,19 +453,19 @@ async function runHealthChecks() {
|
|
|
285
453
|
session.heartbeatEnabled = false
|
|
286
454
|
session.lastActiveAt = now
|
|
287
455
|
sessionsDirty = true
|
|
288
|
-
ds.staleSessionIds.delete(
|
|
456
|
+
ds.staleSessionIds.delete(sessionId)
|
|
289
457
|
await sendHealthAlert(
|
|
290
|
-
`Auto-disabled heartbeat for stale session "${
|
|
458
|
+
`Auto-disabled heartbeat for stale session "${sessionLabel}" after ${Math.round(staleForMs / 60_000)}m of inactivity.`,
|
|
291
459
|
)
|
|
292
460
|
continue
|
|
293
461
|
}
|
|
294
462
|
|
|
295
|
-
currentlyStale.add(
|
|
463
|
+
currentlyStale.add(sessionId)
|
|
296
464
|
// Only alert on transition from healthy → stale (once per stale episode)
|
|
297
|
-
if (!ds.staleSessionIds.has(
|
|
298
|
-
ds.staleSessionIds.add(
|
|
465
|
+
if (!ds.staleSessionIds.has(sessionId)) {
|
|
466
|
+
ds.staleSessionIds.add(sessionId)
|
|
299
467
|
await sendHealthAlert(
|
|
300
|
-
`Session "${
|
|
468
|
+
`Session "${sessionLabel}" heartbeat appears stale (last active ${(Math.round(staleForMs / 1000))}s ago, interval ${intervalSec}s).`,
|
|
301
469
|
)
|
|
302
470
|
}
|
|
303
471
|
}
|
|
@@ -313,6 +481,13 @@ async function runHealthChecks() {
|
|
|
313
481
|
if (sessionsDirty) saveSessions(sessions)
|
|
314
482
|
|
|
315
483
|
await runConnectorHealthChecks(now)
|
|
484
|
+
|
|
485
|
+
// Process webhook retry queue
|
|
486
|
+
try {
|
|
487
|
+
await processWebhookRetries()
|
|
488
|
+
} catch (err: unknown) {
|
|
489
|
+
console.error('[daemon] Webhook retry processing failed:', err instanceof Error ? err.message : String(err))
|
|
490
|
+
}
|
|
316
491
|
}
|
|
317
492
|
|
|
318
493
|
function startHealthMonitor() {
|
|
@@ -341,14 +516,20 @@ export function getDaemonStatus() {
|
|
|
341
516
|
|
|
342
517
|
// Find next scheduled task
|
|
343
518
|
let nextScheduled: number | null = null
|
|
344
|
-
for (const s of Object.values(schedules) as
|
|
519
|
+
for (const s of Object.values(schedules) as Record<string, unknown>[]) {
|
|
345
520
|
if (s.status === 'active' && s.nextRunAt) {
|
|
346
|
-
if (!nextScheduled || s.nextRunAt < nextScheduled) {
|
|
347
|
-
nextScheduled = s.nextRunAt
|
|
521
|
+
if (!nextScheduled || (s.nextRunAt as number) < nextScheduled) {
|
|
522
|
+
nextScheduled = s.nextRunAt as number
|
|
348
523
|
}
|
|
349
524
|
}
|
|
350
525
|
}
|
|
351
526
|
|
|
527
|
+
// Webhook retry queue stats
|
|
528
|
+
const retryQueue = loadWebhookRetryQueue()
|
|
529
|
+
const retryEntries = Object.values(retryQueue) as WebhookRetryEntry[]
|
|
530
|
+
const pendingRetries = retryEntries.filter(e => !e.deadLettered).length
|
|
531
|
+
const deadLettered = retryEntries.filter(e => e.deadLettered).length
|
|
532
|
+
|
|
352
533
|
return {
|
|
353
534
|
running: ds.running,
|
|
354
535
|
schedulerActive: ds.running,
|
|
@@ -364,5 +545,9 @@ export function getDaemonStatus() {
|
|
|
364
545
|
connectorsInBackoff: ds.connectorRestartState.size,
|
|
365
546
|
checkIntervalSec: Math.trunc(HEALTH_CHECK_INTERVAL / 1000),
|
|
366
547
|
},
|
|
548
|
+
webhookRetry: {
|
|
549
|
+
pendingRetries,
|
|
550
|
+
deadLettered,
|
|
551
|
+
},
|
|
367
552
|
}
|
|
368
553
|
}
|
package/src/lib/server/queue.ts
CHANGED
|
@@ -501,6 +501,22 @@ export async function processNext() {
|
|
|
501
501
|
continue
|
|
502
502
|
}
|
|
503
503
|
|
|
504
|
+
// Dependency guard: skip tasks whose blockers are not all completed
|
|
505
|
+
const blockers = Array.isArray(task.blockedBy) ? task.blockedBy as string[] : []
|
|
506
|
+
if (blockers.length > 0) {
|
|
507
|
+
const allBlockersDone = blockers.every((bid) => {
|
|
508
|
+
const blocker = tasks[bid] as BoardTask | undefined
|
|
509
|
+
return blocker?.status === 'completed'
|
|
510
|
+
})
|
|
511
|
+
if (!allBlockersDone) {
|
|
512
|
+
// Put it back in the queue and skip
|
|
513
|
+
pushQueueUnique(queue, taskId)
|
|
514
|
+
saveQueue(queue)
|
|
515
|
+
console.log(`[queue] Skipping task "${task.title}" (${taskId}) — blocked by incomplete dependencies`)
|
|
516
|
+
continue
|
|
517
|
+
}
|
|
518
|
+
}
|
|
519
|
+
|
|
504
520
|
const agents = loadAgents()
|
|
505
521
|
const agent = agents[task.agentId]
|
|
506
522
|
if (!agent) {
|
|
@@ -49,6 +49,8 @@ const COLLECTIONS = [
|
|
|
49
49
|
'mcp_servers',
|
|
50
50
|
'webhook_logs',
|
|
51
51
|
'projects',
|
|
52
|
+
'activity',
|
|
53
|
+
'webhook_retry_queue',
|
|
52
54
|
] as const
|
|
53
55
|
|
|
54
56
|
for (const table of COLLECTIONS) {
|
|
@@ -741,6 +743,38 @@ export function appendWebhookLog(id: string, entry: any) {
|
|
|
741
743
|
upsertCollectionItem('webhook_logs', id, entry)
|
|
742
744
|
}
|
|
743
745
|
|
|
746
|
+
// --- Activity / Audit Trail ---
|
|
747
|
+
export function loadActivity(): Record<string, unknown> {
|
|
748
|
+
return loadCollection('activity')
|
|
749
|
+
}
|
|
750
|
+
|
|
751
|
+
export function logActivity(entry: {
|
|
752
|
+
entityType: string
|
|
753
|
+
entityId: string
|
|
754
|
+
action: string
|
|
755
|
+
actor: string
|
|
756
|
+
actorId?: string
|
|
757
|
+
summary: string
|
|
758
|
+
detail?: Record<string, unknown>
|
|
759
|
+
}) {
|
|
760
|
+
const id = crypto.randomBytes(8).toString('hex')
|
|
761
|
+
const record = { id, ...entry, timestamp: Date.now() }
|
|
762
|
+
upsertCollectionItem('activity', id, record)
|
|
763
|
+
}
|
|
764
|
+
|
|
765
|
+
// --- Webhook Retry Queue ---
|
|
766
|
+
export function loadWebhookRetryQueue(): Record<string, unknown> {
|
|
767
|
+
return loadCollection('webhook_retry_queue')
|
|
768
|
+
}
|
|
769
|
+
|
|
770
|
+
export function upsertWebhookRetry(id: string, entry: unknown) {
|
|
771
|
+
upsertCollectionItem('webhook_retry_queue', id, entry)
|
|
772
|
+
}
|
|
773
|
+
|
|
774
|
+
export function deleteWebhookRetry(id: string) {
|
|
775
|
+
deleteCollectionItem('webhook_retry_queue', id)
|
|
776
|
+
}
|
|
777
|
+
|
|
744
778
|
export function getSessionMessages(sessionId: string): Message[] {
|
|
745
779
|
const stmt = db.prepare('SELECT data FROM sessions WHERE id = ?')
|
|
746
780
|
const row = stmt.get(sessionId) as { data: string } | undefined
|
package/src/lib/view-routes.ts
CHANGED
package/src/lib/ws-client.ts
CHANGED
|
@@ -31,11 +31,12 @@ function handleMessage(event: MessageEvent) {
|
|
|
31
31
|
|
|
32
32
|
function scheduleReconnect() {
|
|
33
33
|
if (reconnectTimer) return
|
|
34
|
+
const jitter = Math.random() * 2000
|
|
34
35
|
reconnectTimer = setTimeout(() => {
|
|
35
36
|
reconnectTimer = null
|
|
36
37
|
if (!accessKey) return
|
|
37
38
|
connect(accessKey)
|
|
38
|
-
}, reconnectDelay)
|
|
39
|
+
}, reconnectDelay + jitter)
|
|
39
40
|
reconnectDelay = Math.min(reconnectDelay * 2, MAX_RECONNECT_DELAY)
|
|
40
41
|
}
|
|
41
42
|
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
'use client'
|
|
2
2
|
|
|
3
3
|
import { create } from 'zustand'
|
|
4
|
-
import type { Sessions, Session, NetworkInfo, Directory, ProviderInfo, Credentials, Agent, Schedule, AppView, BoardTask, AppSettings, OrchestratorSecret, ProviderConfig, Skill, Connector, Webhook, McpServerConfig, PluginMeta, Project, FleetFilter } from '../types'
|
|
4
|
+
import type { Sessions, Session, NetworkInfo, Directory, ProviderInfo, Credentials, Agent, Schedule, AppView, BoardTask, AppSettings, OrchestratorSecret, ProviderConfig, Skill, Connector, Webhook, McpServerConfig, PluginMeta, Project, FleetFilter, ActivityEntry } from '../types'
|
|
5
5
|
import { fetchSessions, fetchDirs, fetchProviders, fetchCredentials } from '../lib/sessions'
|
|
6
6
|
import { fetchAgents } from '../lib/agents'
|
|
7
7
|
import { fetchSchedules } from '../lib/schedules'
|
|
@@ -89,6 +89,8 @@ interface AppState {
|
|
|
89
89
|
|
|
90
90
|
tasks: Record<string, BoardTask>
|
|
91
91
|
loadTasks: (includeArchived?: boolean) => Promise<void>
|
|
92
|
+
optimisticUpdateTask: (taskId: string, patch: Partial<BoardTask>) => Promise<boolean>
|
|
93
|
+
optimisticDeleteTask: (taskId: string) => Promise<boolean>
|
|
92
94
|
showArchivedTasks: boolean
|
|
93
95
|
setShowArchivedTasks: (show: boolean) => void
|
|
94
96
|
taskSheetOpen: boolean
|
|
@@ -178,6 +180,10 @@ interface AppState {
|
|
|
178
180
|
fleetFilter: FleetFilter
|
|
179
181
|
setFleetFilter: (filter: FleetFilter) => void
|
|
180
182
|
|
|
183
|
+
// Activity / Audit Trail
|
|
184
|
+
activityEntries: ActivityEntry[]
|
|
185
|
+
loadActivity: (filters?: { entityType?: string; limit?: number }) => Promise<void>
|
|
186
|
+
|
|
181
187
|
}
|
|
182
188
|
|
|
183
189
|
export const useAppStore = create<AppState>((set, get) => ({
|
|
@@ -394,6 +400,32 @@ export const useAppStore = create<AppState>((set, get) => ({
|
|
|
394
400
|
// ignore
|
|
395
401
|
}
|
|
396
402
|
},
|
|
403
|
+
optimisticUpdateTask: async (taskId, patch) => {
|
|
404
|
+
const prev = get().tasks[taskId]
|
|
405
|
+
if (!prev) return false
|
|
406
|
+
set({ tasks: { ...get().tasks, [taskId]: { ...prev, ...patch, updatedAt: Date.now() } } })
|
|
407
|
+
try {
|
|
408
|
+
await api('PUT', `/tasks/${taskId}`, patch)
|
|
409
|
+
return true
|
|
410
|
+
} catch {
|
|
411
|
+
set({ tasks: { ...get().tasks, [taskId]: prev } })
|
|
412
|
+
return false
|
|
413
|
+
}
|
|
414
|
+
},
|
|
415
|
+
optimisticDeleteTask: async (taskId) => {
|
|
416
|
+
const prev = get().tasks[taskId]
|
|
417
|
+
if (!prev) return false
|
|
418
|
+
const next = { ...get().tasks }
|
|
419
|
+
delete next[taskId]
|
|
420
|
+
set({ tasks: next })
|
|
421
|
+
try {
|
|
422
|
+
await api('DELETE', `/tasks/${taskId}`)
|
|
423
|
+
return true
|
|
424
|
+
} catch {
|
|
425
|
+
set({ tasks: { ...get().tasks, [taskId]: prev } })
|
|
426
|
+
return false
|
|
427
|
+
}
|
|
428
|
+
},
|
|
397
429
|
showArchivedTasks: false,
|
|
398
430
|
setShowArchivedTasks: (show) => {
|
|
399
431
|
set({ showArchivedTasks: show })
|
|
@@ -544,4 +576,19 @@ export const useAppStore = create<AppState>((set, get) => ({
|
|
|
544
576
|
fleetFilter: 'all',
|
|
545
577
|
setFleetFilter: (filter) => set({ fleetFilter: filter }),
|
|
546
578
|
|
|
579
|
+
// Activity / Audit Trail
|
|
580
|
+
activityEntries: [],
|
|
581
|
+
loadActivity: async (filters) => {
|
|
582
|
+
try {
|
|
583
|
+
const params = new URLSearchParams()
|
|
584
|
+
if (filters?.entityType) params.set('entityType', filters.entityType)
|
|
585
|
+
if (filters?.limit) params.set('limit', String(filters.limit))
|
|
586
|
+
const qs = params.toString()
|
|
587
|
+
const entries = await api<ActivityEntry[]>('GET', `/activity${qs ? `?${qs}` : ''}`)
|
|
588
|
+
set({ activityEntries: entries })
|
|
589
|
+
} catch {
|
|
590
|
+
// ignore
|
|
591
|
+
}
|
|
592
|
+
},
|
|
593
|
+
|
|
547
594
|
}))
|
|
@@ -6,6 +6,7 @@ import { api } from '@/lib/api-client'
|
|
|
6
6
|
|
|
7
7
|
interface ApprovalState {
|
|
8
8
|
approvals: Record<string, PendingExecApproval>
|
|
9
|
+
resolvedIds: Set<string>
|
|
9
10
|
addApproval: (approval: PendingExecApproval) => void
|
|
10
11
|
removeApproval: (id: string) => void
|
|
11
12
|
resolveApproval: (id: string, decision: ExecApprovalDecision) => Promise<void>
|
|
@@ -15,6 +16,7 @@ interface ApprovalState {
|
|
|
15
16
|
|
|
16
17
|
export const useApprovalStore = create<ApprovalState>((set) => ({
|
|
17
18
|
approvals: {},
|
|
19
|
+
resolvedIds: new Set<string>(),
|
|
18
20
|
|
|
19
21
|
addApproval: (approval) => {
|
|
20
22
|
set((s) => ({ approvals: { ...s.approvals, [approval.id]: approval } }))
|
|
@@ -46,11 +48,23 @@ export const useApprovalStore = create<ApprovalState>((set) => ({
|
|
|
46
48
|
})
|
|
47
49
|
} catch (err: unknown) {
|
|
48
50
|
const message = err instanceof Error ? err.message : String(err)
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
51
|
+
const isConflict = message.includes('409') || message.includes('Already resolved')
|
|
52
|
+
if (isConflict) {
|
|
53
|
+
// Another session already resolved this — treat as success
|
|
54
|
+
set((s) => {
|
|
55
|
+
const next = { ...s.approvals }
|
|
56
|
+
delete next[id]
|
|
57
|
+
const nextResolved = new Set(s.resolvedIds)
|
|
58
|
+
nextResolved.add(id)
|
|
59
|
+
return { approvals: next, resolvedIds: nextResolved }
|
|
60
|
+
})
|
|
61
|
+
} else {
|
|
62
|
+
set((s) => {
|
|
63
|
+
const approval = s.approvals[id]
|
|
64
|
+
if (!approval) return s
|
|
65
|
+
return { approvals: { ...s.approvals, [id]: { ...approval, resolving: false, error: message } } }
|
|
66
|
+
})
|
|
67
|
+
}
|
|
54
68
|
}
|
|
55
69
|
},
|
|
56
70
|
|
|
@@ -59,7 +73,7 @@ export const useApprovalStore = create<ApprovalState>((set) => ({
|
|
|
59
73
|
set((s) => {
|
|
60
74
|
const next: Record<string, PendingExecApproval> = {}
|
|
61
75
|
for (const [id, a] of Object.entries(s.approvals)) {
|
|
62
|
-
if (a.expiresAtMs > now) next[id] = a
|
|
76
|
+
if (a.expiresAtMs > now && !s.resolvedIds.has(id)) next[id] = a
|
|
63
77
|
}
|
|
64
78
|
return { approvals: next }
|
|
65
79
|
})
|
|
@@ -70,7 +84,7 @@ export const useApprovalStore = create<ApprovalState>((set) => ({
|
|
|
70
84
|
const result = await api<PendingExecApproval[]>('GET', '/openclaw/approvals')
|
|
71
85
|
const approvals: Record<string, PendingExecApproval> = {}
|
|
72
86
|
for (const a of result) approvals[a.id] = a
|
|
73
|
-
set({ approvals })
|
|
87
|
+
set({ approvals, resolvedIds: new Set<string>() })
|
|
74
88
|
} catch {
|
|
75
89
|
// ignore — gateway may be offline
|
|
76
90
|
}
|
package/src/types/index.ts
CHANGED
|
@@ -336,7 +336,35 @@ export interface MemoryEntry {
|
|
|
336
336
|
}
|
|
337
337
|
|
|
338
338
|
export type SessionType = 'human' | 'orchestrated'
|
|
339
|
-
export type AppView = 'agents' | 'schedules' | 'memory' | 'tasks' | 'secrets' | 'providers' | 'skills' | 'connectors' | 'webhooks' | 'mcp_servers' | 'knowledge' | 'plugins' | 'usage' | 'runs' | 'logs' | 'settings' | 'projects'
|
|
339
|
+
export type AppView = 'agents' | 'schedules' | 'memory' | 'tasks' | 'secrets' | 'providers' | 'skills' | 'connectors' | 'webhooks' | 'mcp_servers' | 'knowledge' | 'plugins' | 'usage' | 'runs' | 'logs' | 'settings' | 'projects' | 'activity'
|
|
340
|
+
|
|
341
|
+
// --- Activity / Audit Trail ---
|
|
342
|
+
|
|
343
|
+
export interface ActivityEntry {
|
|
344
|
+
id: string
|
|
345
|
+
entityType: 'agent' | 'task' | 'connector' | 'session' | 'webhook' | 'schedule'
|
|
346
|
+
entityId: string
|
|
347
|
+
action: 'created' | 'updated' | 'deleted' | 'started' | 'stopped' | 'queued' | 'completed' | 'failed' | 'approved' | 'rejected'
|
|
348
|
+
actor: 'user' | 'agent' | 'system' | 'daemon'
|
|
349
|
+
actorId?: string
|
|
350
|
+
summary: string
|
|
351
|
+
detail?: Record<string, unknown>
|
|
352
|
+
timestamp: number
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
// --- Webhook Retry Queue ---
|
|
356
|
+
|
|
357
|
+
export interface WebhookRetryEntry {
|
|
358
|
+
id: string
|
|
359
|
+
webhookId: string
|
|
360
|
+
event: string
|
|
361
|
+
payload: string
|
|
362
|
+
attempts: number
|
|
363
|
+
maxAttempts: number
|
|
364
|
+
nextRetryAt: number
|
|
365
|
+
deadLettered: boolean
|
|
366
|
+
createdAt: number
|
|
367
|
+
}
|
|
340
368
|
|
|
341
369
|
export interface Project {
|
|
342
370
|
id: string
|
|
@@ -461,6 +489,8 @@ export interface AppSettings {
|
|
|
461
489
|
// Web search provider
|
|
462
490
|
webSearchProvider?: 'duckduckgo' | 'google' | 'bing' | 'searxng' | 'tavily' | 'brave'
|
|
463
491
|
searxngUrl?: string
|
|
492
|
+
// Task custom field definitions
|
|
493
|
+
taskCustomFieldDefs?: Array<{ key: string; label: string; type: 'text' | 'number' | 'select'; options?: string[] }>
|
|
464
494
|
// OpenClaw sync settings
|
|
465
495
|
openclawWorkspacePath?: string | null
|
|
466
496
|
openclawAutoSyncMemory?: boolean
|
|
@@ -630,6 +660,15 @@ export interface BoardTask {
|
|
|
630
660
|
args: Record<string, unknown>
|
|
631
661
|
threadId: string
|
|
632
662
|
} | null
|
|
663
|
+
// Task dependencies (DAG)
|
|
664
|
+
blockedBy?: string[]
|
|
665
|
+
blocks?: string[]
|
|
666
|
+
// Task tags
|
|
667
|
+
tags?: string[]
|
|
668
|
+
// Due date
|
|
669
|
+
dueAt?: number | null
|
|
670
|
+
// Custom fields
|
|
671
|
+
customFields?: Record<string, string | number | boolean>
|
|
633
672
|
}
|
|
634
673
|
|
|
635
674
|
// --- MCP Servers ---
|