@swarmclawai/swarmclaw 1.8.11 → 1.8.13

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.
@@ -1,11 +1,13 @@
1
1
  import { listPendingApprovals } from '@/lib/server/approvals'
2
2
  import { getConnectorReadiness } from '@/lib/connectors/connector-readiness'
3
3
  import { loadConnectors } from '@/lib/server/connectors/connector-repository'
4
+ import { listOpenClawGatewayProfiles } from '@/lib/server/gateways/gateway-profile-service'
4
5
  import { listMissions } from '@/lib/server/missions/mission-repository'
5
6
  import { listUnifiedRuns } from '@/lib/server/runs/unified-run-queries'
6
7
  import type {
7
8
  ApprovalRequest,
8
9
  Connector,
10
+ GatewayProfile,
9
11
  Mission,
10
12
  OperationPulse,
11
13
  OperationPulseAction,
@@ -26,6 +28,7 @@ const SEVERITY_RANK: Record<OperationPulseSeverity, number> = {
26
28
  }
27
29
 
28
30
  const ACTIVE_MISSION_STATUSES = new Set<Mission['status']>(['running', 'paused'])
31
+ const GATEWAY_TOPOLOGY_STALE_MS = 30 * 60_000
29
32
 
30
33
  export function normalizeOperationPulseRange(value: string | null | undefined): OperationPulseRange {
31
34
  return value === '7d' ? '7d' : '24h'
@@ -68,6 +71,67 @@ function addAction(actions: OperationPulseAction[], action: OperationPulseAction
68
71
  actions.push(action)
69
72
  }
70
73
 
74
+ function gatewayPendingPairings(gateway: GatewayProfile): number {
75
+ return (gateway.stats?.pendingNodePairings || 0) + (gateway.stats?.pendingDevicePairings || 0)
76
+ }
77
+
78
+ function gatewayAttentionReason(gateway: GatewayProfile, now: number): {
79
+ severity: OperationPulseSeverity
80
+ summary: string
81
+ evidence: string[]
82
+ } | null {
83
+ const pendingPairings = gatewayPendingPairings(gateway)
84
+ const errorCount = gateway.stats?.lastTopologyErrorCount || 0
85
+ const checkedAt = gateway.stats?.lastTopologyCheckedAt || gateway.lastCheckedAt || null
86
+ const staleTopology = !checkedAt || now - checkedAt > GATEWAY_TOPOLOGY_STALE_MS
87
+ const evidence = [
88
+ `status:${gateway.status}`,
89
+ `${gateway.stats?.connectedNodeCount || 0}/${gateway.stats?.nodeCount || 0} nodes`,
90
+ ]
91
+
92
+ if (gateway.status === 'offline') {
93
+ return {
94
+ severity: 'high',
95
+ summary: `${gateway.name} is offline${gateway.lastError ? `: ${gateway.lastError}` : '.'}`,
96
+ evidence,
97
+ }
98
+ }
99
+
100
+ if (gateway.status === 'degraded') {
101
+ return {
102
+ severity: 'high',
103
+ summary: `${gateway.name} is degraded${gateway.lastError ? `: ${gateway.lastError}` : '.'}`,
104
+ evidence,
105
+ }
106
+ }
107
+
108
+ if (errorCount > 0) {
109
+ return {
110
+ severity: 'medium',
111
+ summary: `${gateway.name} topology refresh reported ${errorCount} error${errorCount === 1 ? '' : 's'}.`,
112
+ evidence: [...evidence, gateway.stats?.lastTopologyError || 'topology error'].filter(Boolean),
113
+ }
114
+ }
115
+
116
+ if (pendingPairings > 0) {
117
+ return {
118
+ severity: 'medium',
119
+ summary: `${gateway.name} has ${pendingPairings} pending OpenClaw pairing request${pendingPairings === 1 ? '' : 's'}.`,
120
+ evidence: [...evidence, `${pendingPairings} pending pairings`],
121
+ }
122
+ }
123
+
124
+ if (staleTopology) {
125
+ return {
126
+ severity: 'medium',
127
+ summary: `${gateway.name} topology has not been refreshed in the last 30 minutes.`,
128
+ evidence,
129
+ }
130
+ }
131
+
132
+ return null
133
+ }
134
+
71
135
  function sortActions(actions: OperationPulseAction[]): OperationPulseAction[] {
72
136
  return [...actions]
73
137
  .sort((left, right) => {
@@ -85,6 +149,7 @@ export function buildOperationPulse(input: {
85
149
  runs: SessionRunRecord[]
86
150
  approvals: ApprovalRequest[]
87
151
  connectors: Connector[]
152
+ gateways?: GatewayProfile[]
88
153
  }): OperationPulse {
89
154
  const windowStart = input.now - RANGE_MS[input.range]
90
155
  const windowRuns = input.runs.filter((run) => run.status === 'running' || run.status === 'queued' || isWithinWindow(runActivityAt(run), windowStart))
@@ -94,6 +159,9 @@ export function buildOperationPulse(input: {
94
159
  const pendingApprovals = input.approvals.filter((approval) => approval.status === 'pending')
95
160
  const connectorReadiness = input.connectors.map((connector) => ({ connector, readiness: getConnectorReadiness(connector) }))
96
161
  const connectorAttention = connectorReadiness.filter((item) => item.readiness.state !== 'healthy')
162
+ const gatewayAttention = (input.gateways || [])
163
+ .map((gateway) => ({ gateway, reason: gatewayAttentionReason(gateway, input.now) }))
164
+ .filter((item): item is { gateway: GatewayProfile; reason: NonNullable<ReturnType<typeof gatewayAttentionReason>> } => Boolean(item.reason))
97
165
  const budgetWarnings = input.missions
98
166
  .map((mission) => ({ mission, pressure: budgetPressure(mission, input.now) }))
99
167
  .filter((item) => item.pressure)
@@ -141,6 +209,19 @@ export function buildOperationPulse(input: {
141
209
  })
142
210
  }
143
211
 
212
+ for (const item of gatewayAttention.slice(0, 5)) {
213
+ addAction(actions, {
214
+ id: `gateway:${item.gateway.id}`,
215
+ kind: 'gateway',
216
+ severity: item.reason.severity,
217
+ title: 'Review OpenClaw gateway',
218
+ summary: item.reason.summary,
219
+ href: '/providers',
220
+ evidence: item.reason.evidence,
221
+ createdAt: item.gateway.stats?.lastTopologyCheckedAt || item.gateway.lastCheckedAt || item.gateway.updatedAt || item.gateway.createdAt,
222
+ })
223
+ }
224
+
144
225
  for (const item of budgetWarnings.slice(0, 5)) {
145
226
  if (!item.pressure) continue
146
227
  addAction(actions, {
@@ -178,6 +259,7 @@ export function buildOperationPulse(input: {
178
259
  failedRuns: failedRuns.length,
179
260
  pendingApprovals: pendingApprovals.length,
180
261
  connectorAttention: connectorAttention.length,
262
+ gatewayAttention: gatewayAttention.length,
181
263
  budgetWarnings: budgetWarnings.length,
182
264
  },
183
265
  actions: sortActions(actions),
@@ -193,5 +275,6 @@ export function getOperationPulse(range: OperationPulseRange): OperationPulse {
193
275
  runs: listUnifiedRuns({ limit: 500 }),
194
276
  approvals: listPendingApprovals(),
195
277
  connectors: Object.values(loadConnectors()),
278
+ gateways: listOpenClawGatewayProfiles(),
196
279
  })
197
280
  }
@@ -124,6 +124,14 @@ function applyTaskPolicyDefaults(task: BoardTask): void {
124
124
  if (task.deadLetteredAt === undefined) task.deadLetteredAt = null
125
125
  }
126
126
 
127
+ function formatRetryError(reason: string): string {
128
+ return `Retry scheduled after failure: ${reason}`.slice(0, 500)
129
+ }
130
+
131
+ function isRepeatedRetryFailure(previousError: unknown, reason: string): boolean {
132
+ return typeof previousError === 'string' && previousError === formatRetryError(reason)
133
+ }
134
+
127
135
  export interface TaskResumeState {
128
136
  claudeSessionId: string | null
129
137
  codexThreadId: string | null
@@ -971,9 +979,10 @@ function scheduleRetryOrDeadLetter(task: BoardTask, reason: string): 'retry' | '
971
979
  }
972
980
  applyTaskPolicyDefaults(task)
973
981
  const now = Date.now()
982
+ const repeatedRetryFailure = isRepeatedRetryFailure(task.error, reason)
974
983
  task.attempts = (task.attempts || 0) + 1
975
984
 
976
- if ((task.attempts || 0) < (task.maxAttempts || 1)) {
985
+ if ((task.attempts || 0) < (task.maxAttempts || 1) && !repeatedRetryFailure) {
977
986
  const delayMs = jitteredBackoff((task.retryBackoffSec || 30) * 1000, Math.max(0, (task.attempts || 1) - 1), 6 * 3600_000)
978
987
  task.status = 'queued'
979
988
  task.retryScheduledAt = now + delayMs
@@ -982,7 +991,7 @@ function scheduleRetryOrDeadLetter(task: BoardTask, reason: string): 'retry' | '
982
991
  // recovery loop burns CPU re-queueing a task that can never run.
983
992
  task.checkoutRunId = null
984
993
  task.updatedAt = now
985
- task.error = `Retry scheduled after failure: ${reason}`.slice(0, 500)
994
+ task.error = formatRetryError(reason)
986
995
  if (!task.comments) task.comments = []
987
996
  task.comments.push({
988
997
  id: genId(),
@@ -0,0 +1,98 @@
1
+ import assert from 'node:assert/strict'
2
+ import fs from 'node:fs'
3
+ import os from 'node:os'
4
+ import path from 'node:path'
5
+ import { spawnSync } from 'node:child_process'
6
+ import { test } from 'node:test'
7
+
8
+ const repoRoot = path.resolve(path.dirname(new URL(import.meta.url).pathname), '../../../..')
9
+
10
+ function runWithTempDataDir<T extends Record<string, unknown>>(script: string): T {
11
+ const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'swarmclaw-queue-retry-policy-'))
12
+ try {
13
+ const result = spawnSync(process.execPath, ['--import', 'tsx', '--input-type=module', '--eval', script], {
14
+ cwd: repoRoot,
15
+ env: {
16
+ ...process.env,
17
+ DATA_DIR: path.join(tempDir, 'data'),
18
+ WORKSPACE_DIR: path.join(tempDir, 'workspace'),
19
+ SWARMCLAW_BUILD_MODE: '1',
20
+ },
21
+ encoding: 'utf-8',
22
+ timeout: 15000,
23
+ })
24
+ assert.equal(result.status, 0, result.stderr || result.stdout || 'subprocess failed')
25
+ const lines = (result.stdout || '')
26
+ .trim()
27
+ .split('\n')
28
+ .map((line) => line.trim())
29
+ .filter(Boolean)
30
+ const jsonLine = [...lines].reverse().find((line) => line.startsWith('{'))
31
+ return JSON.parse(jsonLine || '{}') as T
32
+ } finally {
33
+ fs.rmSync(tempDir, { recursive: true, force: true })
34
+ }
35
+ }
36
+
37
+ test('recoverStalledRunningTasks dead-letters repeated retry failures instead of repeating identical work', () => {
38
+ const output = runWithTempDataDir<{
39
+ result: { recovered: number; deadLettered: number }
40
+ status: string | null
41
+ attempts: number | null
42
+ queued: string[]
43
+ retryScheduledAt: number | null
44
+ deadLetteredAt: number | null
45
+ error: string | null
46
+ }>(`
47
+ const storageMod = await import('@/lib/server/storage')
48
+ const queueMod = await import('@/lib/server/runtime/queue')
49
+ const storage = storageMod.default || storageMod
50
+ const queue = queueMod.default || queueMod
51
+
52
+ const now = Date.now()
53
+ const reason = 'Detected stalled run after 5m without progress'
54
+ storage.saveSettings({
55
+ ...storage.loadSettings(),
56
+ taskStallTimeoutMin: 5,
57
+ taskRetryBackoffSec: 30,
58
+ })
59
+ storage.saveTasks({
60
+ repeat: {
61
+ id: 'repeat',
62
+ title: 'Repeated structural failure',
63
+ description: 'A task that already retried the same failure reason',
64
+ status: 'running',
65
+ agentId: 'agent-a',
66
+ startedAt: now - 600_000,
67
+ updatedAt: now - 600_000,
68
+ createdAt: now - 700_000,
69
+ maxAttempts: 3,
70
+ attempts: 1,
71
+ checkoutRunId: 'repeat-run-id',
72
+ error: 'Retry scheduled after failure: ' + reason,
73
+ },
74
+ })
75
+ storage.saveQueue([])
76
+
77
+ const result = queue.recoverStalledRunningTasks()
78
+ const task = storage.loadTasks().repeat
79
+ console.log(JSON.stringify({
80
+ result,
81
+ status: task?.status ?? null,
82
+ attempts: task?.attempts ?? null,
83
+ queued: storage.loadQueue(),
84
+ retryScheduledAt: task?.retryScheduledAt ?? null,
85
+ deadLetteredAt: task?.deadLetteredAt ?? null,
86
+ error: task?.error ?? null,
87
+ }))
88
+ `)
89
+
90
+ assert.equal(output.result.recovered, 0)
91
+ assert.equal(output.result.deadLettered, 1)
92
+ assert.equal(output.status, 'failed')
93
+ assert.equal(output.attempts, 2)
94
+ assert.deepEqual(output.queued, [])
95
+ assert.equal(output.retryScheduledAt, null)
96
+ assert.equal(typeof output.deadLetteredAt, 'number')
97
+ assert.match(output.error || '', /Dead-lettered after 2\/3 attempts/)
98
+ })
@@ -94,6 +94,8 @@ export interface ToolBuildContext {
94
94
  fileAccessPolicy?: { allowedPaths?: string[]; blockedPaths?: string[] } | null
95
95
  /** Agent's sandbox config — passed to shell for session-scoped container execution */
96
96
  sandboxConfig?: NonNullable<Agent['sandboxConfig']> | null
97
+ /** Loaded agent record for tool builders that need per-agent runtime settings */
98
+ agentRecord?: Agent | null
97
99
  /** Agent's filesystem scope — 'machine' allows file access outside the workspace */
98
100
  filesystemScope?: 'workspace' | 'machine'
99
101
  }
@@ -1,7 +1,40 @@
1
- import { describe, it } from 'node:test'
2
1
  import assert from 'node:assert/strict'
2
+ import fs from 'node:fs'
3
+ import os from 'node:os'
4
+ import path from 'node:path'
5
+ import { spawnSync } from 'node:child_process'
6
+ import { describe, it, test } from 'node:test'
3
7
  import { redactSecrets, buildCredentialEnv } from './credential-env'
4
8
 
9
+ const repoRoot = path.resolve(path.dirname(new URL(import.meta.url).pathname), '../../../..')
10
+
11
+ function runWithTempDataDir<T extends Record<string, unknown>>(script: string): T {
12
+ const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'swarmclaw-execute-tool-'))
13
+ try {
14
+ const result = spawnSync(process.execPath, ['--import', 'tsx', '--input-type=module', '--eval', script], {
15
+ cwd: repoRoot,
16
+ env: {
17
+ ...process.env,
18
+ DATA_DIR: path.join(tempDir, 'data'),
19
+ WORKSPACE_DIR: path.join(tempDir, 'workspace'),
20
+ SWARMCLAW_BUILD_MODE: '1',
21
+ },
22
+ encoding: 'utf-8',
23
+ timeout: 15000,
24
+ })
25
+ assert.equal(result.status, 0, result.stderr || result.stdout || 'subprocess failed')
26
+ const lines = (result.stdout || '')
27
+ .trim()
28
+ .split('\n')
29
+ .map((line) => line.trim())
30
+ .filter(Boolean)
31
+ const jsonLine = [...lines].reverse().find((line) => line.startsWith('{'))
32
+ return JSON.parse(jsonLine || '{}') as T
33
+ } finally {
34
+ fs.rmSync(tempDir, { recursive: true, force: true })
35
+ }
36
+ }
37
+
5
38
  describe('credential-env', () => {
6
39
  describe('redactSecrets', () => {
7
40
  it('redacts secret values from text', () => {
@@ -56,3 +89,62 @@ describe('credential-env', () => {
56
89
  })
57
90
  })
58
91
  })
92
+
93
+ test('buildExecuteTools uses the current agent executeConfig backend', () => {
94
+ const output = runWithTempDataDir<{
95
+ hostOutput: string
96
+ sandboxOutput: string
97
+ }>(`
98
+ const storageMod = await import('@/lib/server/storage')
99
+ const executeModImport = await import('./src/lib/server/session-tools/execute.ts')
100
+ const executeMod = executeModImport.default || executeModImport
101
+ const storage = storageMod.default || storageMod
102
+ const now = Date.now()
103
+
104
+ storage.saveAgents({
105
+ 'agent-host': {
106
+ id: 'agent-host',
107
+ name: 'Host Agent',
108
+ provider: 'openai',
109
+ model: 'gpt-test',
110
+ executeConfig: { backend: 'host', network: { enabled: true }, timeout: 30 },
111
+ createdAt: now,
112
+ updatedAt: now,
113
+ },
114
+ 'agent-sandbox': {
115
+ id: 'agent-sandbox',
116
+ name: 'Sandbox Agent',
117
+ provider: 'openai',
118
+ model: 'gpt-test',
119
+ createdAt: now,
120
+ updatedAt: now,
121
+ },
122
+ })
123
+
124
+ const makeContext = (agentId) => ({
125
+ cwd: process.cwd(),
126
+ ctx: { agentId, sessionId: 'session-' + agentId },
127
+ hasExtension: (name) => name === 'execute',
128
+ hasTool: (name) => name === 'execute',
129
+ cleanupFns: [],
130
+ commandTimeoutMs: 30000,
131
+ claudeTimeoutMs: 30000,
132
+ cliProcessTimeoutMs: 30000,
133
+ persistDelegateResumeId: () => {},
134
+ readStoredDelegateResumeId: () => null,
135
+ resolveCurrentSession: () => ({ id: 'session-' + agentId, agentId }),
136
+ activeExtensions: ['execute'],
137
+ agentRecord: storage.loadAgent(agentId),
138
+ })
139
+
140
+ const hostTool = executeMod.buildExecuteTools(makeContext('agent-host'))[0]
141
+ const sandboxTool = executeMod.buildExecuteTools(makeContext('agent-sandbox'))[0]
142
+ const hostOutput = await hostTool.invoke({ code: 'printf host-ok', persistent: true })
143
+ const sandboxOutput = await sandboxTool.invoke({ code: 'printf sandbox-no', persistent: true })
144
+
145
+ console.log(JSON.stringify({ hostOutput, sandboxOutput }))
146
+ `)
147
+
148
+ assert.equal(output.hostOutput, 'host-ok')
149
+ assert.match(output.sandboxOutput, /requires `executeConfig\.backend = "host"`/)
150
+ })
@@ -26,6 +26,7 @@ import {
26
26
  normalizeAgentExecuteConfig,
27
27
  type AgentExecuteConfig,
28
28
  } from '@/lib/agent-execute-defaults'
29
+ import { loadAgent } from '../storage'
29
30
 
30
31
  const TAG = 'execute'
31
32
 
@@ -310,9 +311,9 @@ registerNativeCapability('execute', ExecuteExtension)
310
311
  export function buildExecuteTools(bctx: ToolBuildContext) {
311
312
  if (!bctx.hasExtension('execute')) return []
312
313
 
313
- // Resolve execute config from the agent
314
- const session = bctx.resolveCurrentSession?.()
315
- const agent = session?.agent as (Agent & { executeConfig?: ExecuteConfig }) | undefined
314
+ const agentId = typeof bctx.ctx?.agentId === 'string' ? bctx.ctx.agentId.trim() : ''
315
+ const agent = (bctx.agentRecord as (Agent & { executeConfig?: ExecuteConfig }) | null | undefined)
316
+ ?? (agentId ? (loadAgent(agentId) as (Agent & { executeConfig?: ExecuteConfig }) | null) : null)
316
317
  const executeConfig = normalizeAgentExecuteConfig(agent?.executeConfig)
317
318
 
318
319
  return [
@@ -1,6 +1,6 @@
1
1
  import { z } from 'zod'
2
2
  import { tool, type StructuredToolInterface } from '@langchain/core/tools'
3
- import type { Session } from '@/types'
3
+ import type { Agent, Session } from '@/types'
4
4
  import { dedup, errorMessage } from '@/lib/shared-utils'
5
5
  import { loadSettings, loadSession, loadAgent, loadMcpServers, patchAgent, patchSession } from '../storage'
6
6
  import { loadRuntimeSettings } from '@/lib/server/runtime/runtime-settings'
@@ -221,6 +221,7 @@ export async function buildSessionTools(cwd: string, enabledExtensions: string[]
221
221
  activeExtensions,
222
222
  fileAccessPolicy: effectiveFileAccessPolicy,
223
223
  sandboxConfig: agentRecord?.sandboxConfig ?? null,
224
+ agentRecord: agentRecord as Agent | null,
224
225
  filesystemScope,
225
226
  }
226
227
 
@@ -49,6 +49,39 @@ test('validateTaskCompletion still enforces stricter minimum for implementation
49
49
  assert.ok(validation.reasons.some((reason) => reason.includes('Result summary is too short')))
50
50
  })
51
51
 
52
+ test('validateTaskCompletion does not auto-apply implementation quality gates to scheduled tasks', () => {
53
+ const validation = validateTaskCompletion({
54
+ title: '[Sched] Daily wiki hygiene (run #1)',
55
+ description: 'Run scripts/wiki-hygiene.mjs and post the digest to Slack.',
56
+ result: 'Ran node scripts/wiki-hygiene.mjs and posted the digest to Slack with exit code 0.',
57
+ sourceType: 'schedule',
58
+ sourceScheduleId: 'schedule-wiki',
59
+ error: null,
60
+ } as Partial<BoardTask>)
61
+
62
+ assert.equal(validation.ok, true)
63
+ })
64
+
65
+ test('validateTaskCompletion still enforces explicit quality gates on scheduled tasks', () => {
66
+ const validation = validateTaskCompletion({
67
+ title: '[Sched] Daily wiki hygiene (run #1)',
68
+ description: 'Run scripts/wiki-hygiene.mjs and post the digest to Slack.',
69
+ result: 'Ran node scripts/wiki-hygiene.mjs and posted the digest to Slack with exit code 0.',
70
+ sourceType: 'schedule',
71
+ sourceScheduleId: 'schedule-wiki',
72
+ qualityGate: {
73
+ enabled: true,
74
+ minResultChars: 20,
75
+ minEvidenceItems: 2,
76
+ requireVerification: true,
77
+ },
78
+ error: null,
79
+ } as Partial<BoardTask>)
80
+
81
+ assert.equal(validation.ok, false)
82
+ assert.ok(validation.reasons.some((reason) => reason.includes('verification evidence is required')))
83
+ })
84
+
52
85
  test('validateTaskCompletion fails implementation task with unfinished next-step language', () => {
53
86
  const validation = validateTaskCompletion({
54
87
  title: 'Build weather dashboard',
@@ -85,7 +85,8 @@ export function validateTaskCompletion(
85
85
  const report = options.report || null
86
86
  const hasExplicitQualityGate = !!task.qualityGate && typeof task.qualityGate === 'object'
87
87
  const qualityGate = normalizeTaskQualityGate(task.qualityGate || null, options.settings || null)
88
- const implementationTask = IMPLEMENTATION_HINT.test(title) || IMPLEMENTATION_HINT.test(description)
88
+ const scheduleTask = task.sourceType === 'schedule' || typeof task.sourceScheduleId === 'string'
89
+ const implementationTask = !scheduleTask && (IMPLEMENTATION_HINT.test(title) || IMPLEMENTATION_HINT.test(description))
89
90
 
90
91
  if (error) reasons.push('Task has a non-empty error field.')
91
92
  if (/^untitled task$/i.test(title) && !description) {
package/src/types/misc.ts CHANGED
@@ -670,6 +670,11 @@ export interface OpenClawGatewayStats {
670
670
  pairedDeviceCount?: number
671
671
  pendingDevicePairings?: number
672
672
  externalRuntimeCount?: number
673
+ sessionCount?: number
674
+ presenceCount?: number
675
+ lastTopologyCheckedAt?: number
676
+ lastTopologyErrorCount?: number
677
+ lastTopologyError?: string | null
673
678
  }
674
679
 
675
680
  export interface OpenClawDeploymentConfig {
@@ -773,6 +778,59 @@ export interface OpenClawPairingSnapshot {
773
778
  paired?: OpenClawPairedDevice[]
774
779
  }
775
780
 
781
+ export interface OpenClawGatewayRpcError {
782
+ method: string
783
+ message: string
784
+ }
785
+
786
+ export interface OpenClawGatewaySession {
787
+ id: string
788
+ key?: string | null
789
+ title?: string | null
790
+ channel?: string | null
791
+ sender?: string | null
792
+ updatedAt?: number | null
793
+ status?: string | null
794
+ }
795
+
796
+ export interface OpenClawGatewayPresenceEntry {
797
+ id: string
798
+ label?: string | null
799
+ mode?: string | null
800
+ deviceId?: string | null
801
+ host?: string | null
802
+ status?: string | null
803
+ updatedAt?: number | null
804
+ }
805
+
806
+ export interface OpenClawGatewayTopologyStats extends OpenClawGatewayStats {
807
+ pendingPairingCount: number
808
+ hasErrors: boolean
809
+ }
810
+
811
+ export interface OpenClawGatewayTopology {
812
+ profile: GatewayProfile
813
+ connected: boolean
814
+ refreshedAt: number
815
+ stats: OpenClawGatewayTopologyStats
816
+ nodes: OpenClawNode[]
817
+ nodePairings: OpenClawNodePairRequest[]
818
+ devicePairings: OpenClawDevicePairRequest[]
819
+ pairedDevices: OpenClawPairedDevice[]
820
+ sessions: OpenClawGatewaySession[]
821
+ presence: OpenClawGatewayPresenceEntry[]
822
+ errors: OpenClawGatewayRpcError[]
823
+ }
824
+
825
+ export interface OpenClawGatewayFleetTopology {
826
+ generatedAt: number
827
+ gateways: OpenClawGatewayTopology[]
828
+ totals: OpenClawGatewayTopologyStats & {
829
+ gatewayCount: number
830
+ connectedGatewayCount: number
831
+ }
832
+ }
833
+
776
834
  // --- ClawHub ---
777
835
 
778
836
  export interface ClawHubSkill {
@@ -7,6 +7,7 @@ export type OperationPulseActionKind =
7
7
  | 'run'
8
8
  | 'approval'
9
9
  | 'connector'
10
+ | 'gateway'
10
11
  | 'budget'
11
12
  | 'quality'
12
13
 
@@ -16,6 +17,7 @@ export interface OperationPulseKpis {
16
17
  failedRuns: number
17
18
  pendingApprovals: number
18
19
  connectorAttention: number
20
+ gatewayAttention: number
19
21
  budgetWarnings: number
20
22
  }
21
23