@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.
Files changed (38) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +2 -2
  3. package/package.json +2 -1
  4. package/public/screenshots/agents.png +0 -0
  5. package/public/screenshots/dashboard.png +0 -0
  6. package/public/screenshots/providers.png +0 -0
  7. package/public/screenshots/tasks.png +0 -0
  8. package/src/app/api/activity/route.ts +30 -0
  9. package/src/app/api/agents/[id]/route.ts +3 -1
  10. package/src/app/api/agents/route.ts +2 -1
  11. package/src/app/api/connectors/[id]/route.ts +4 -1
  12. package/src/app/api/openclaw/approvals/route.ts +20 -0
  13. package/src/app/api/tasks/[id]/route.ts +37 -1
  14. package/src/app/api/tasks/route.ts +7 -1
  15. package/src/app/api/usage/route.ts +74 -22
  16. package/src/app/api/webhooks/[id]/route.ts +62 -22
  17. package/src/cli/index.js +7 -0
  18. package/src/cli/spec.js +6 -0
  19. package/src/components/activity/activity-feed.tsx +91 -0
  20. package/src/components/chat/exec-approval-card.tsx +6 -3
  21. package/src/components/layout/app-layout.tsx +21 -7
  22. package/src/components/tasks/task-board.tsx +40 -2
  23. package/src/components/tasks/task-card.tsx +40 -2
  24. package/src/components/tasks/task-sheet.tsx +147 -1
  25. package/src/components/usage/metrics-dashboard.tsx +278 -0
  26. package/src/hooks/use-page-active.ts +21 -0
  27. package/src/hooks/use-ws.ts +13 -1
  28. package/src/lib/fetch-dedup.ts +20 -0
  29. package/src/lib/optimistic.ts +25 -0
  30. package/src/lib/server/connectors/manager.ts +18 -0
  31. package/src/lib/server/daemon-state.ts +205 -20
  32. package/src/lib/server/queue.ts +16 -0
  33. package/src/lib/server/storage.ts +34 -0
  34. package/src/lib/view-routes.ts +1 -0
  35. package/src/lib/ws-client.ts +2 -1
  36. package/src/stores/use-app-store.ts +48 -1
  37. package/src/stores/use-approval-store.ts +21 -7
  38. 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 any[]) {
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: any) {
272
+ } catch (err: unknown) {
251
273
  current.failCount += 1
274
+ current.wakeAttempts += 1
252
275
  ds.connectorRestartState.set(connector.id, current)
253
- console.warn(`[health] Connector auto-restart failed for ${connector.name}: ${err?.message || String(err)}`)
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 any[]) {
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(session.id)
456
+ ds.staleSessionIds.delete(sessionId)
289
457
  await sendHealthAlert(
290
- `Auto-disabled heartbeat for stale session "${session.name || session.id}" after ${Math.round(staleForMs / 60_000)}m of inactivity.`,
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(session.id)
463
+ currentlyStale.add(sessionId)
296
464
  // Only alert on transition from healthy → stale (once per stale episode)
297
- if (!ds.staleSessionIds.has(session.id)) {
298
- ds.staleSessionIds.add(session.id)
465
+ if (!ds.staleSessionIds.has(sessionId)) {
466
+ ds.staleSessionIds.add(sessionId)
299
467
  await sendHealthAlert(
300
- `Session "${session.name || session.id}" heartbeat appears stale (last active ${(Math.round(staleForMs / 1000))}s ago, interval ${intervalSec}s).`,
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 any[]) {
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
  }
@@ -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
@@ -20,6 +20,7 @@ export const VIEW_TO_PATH: Record<AppView, string> = {
20
20
  logs: '/logs',
21
21
  settings: '/settings',
22
22
  projects: '/projects',
23
+ activity: '/activity',
23
24
  }
24
25
 
25
26
  const entries = Object.entries(VIEW_TO_PATH) as [AppView, string][]
@@ -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
- set((s) => {
50
- const approval = s.approvals[id]
51
- if (!approval) return s
52
- return { approvals: { ...s.approvals, [id]: { ...approval, resolving: false, error: message } } }
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
  }
@@ -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 ---