xworkmate-app/lib/runtime/gateway_runtime_api.dart

619 lines
20 KiB
Dart

part of 'gateway_runtime_core.dart';
extension GatewayRuntimeApiInternal on GatewayRuntime {
Future<Map<String, dynamic>> _healthInternal() async {
final payload = asMap(await request('health'));
snapshotInternal = snapshotInternal.copyWith(healthPayload: payload);
appendLogInternal(this, 'debug', 'health', 'health snapshot refreshed');
_notifyRuntimeChangedInternal();
return payload;
}
Future<Map<String, dynamic>> _statusInternal() async {
final payload = asMap(await request('status'));
snapshotInternal = snapshotInternal.copyWith(statusPayload: payload);
appendLogInternal(this, 'debug', 'health', 'status snapshot refreshed');
_notifyRuntimeChangedInternal();
return payload;
}
Future<List<GatewayAgentSummary>> _listAgentsInternal() async {
final payload = asMap(
await request('agents.list', params: const <String, dynamic>{}),
);
final agents = asList(payload['agents'])
.map((item) {
final map = asMap(item);
final identity = asMap(map['identity']);
return GatewayAgentSummary(
id: stringValue(map['id']) ?? 'unknown',
name:
stringValue(map['name']) ??
stringValue(identity['name']) ??
'Agent',
emoji: stringValue(identity['emoji']) ?? '·',
theme: stringValue(identity['theme']) ?? 'default',
);
})
.toList(growable: false);
if (snapshotInternal.mainSessionKey == null ||
snapshotInternal.mainSessionKey!.trim().isEmpty) {
snapshotInternal = snapshotInternal.copyWith(
mainSessionKey: stringValue(payload['mainKey']) ?? 'main',
);
_notifyRuntimeChangedInternal();
}
return agents;
}
Future<List<GatewaySessionSummary>> _listSessionsInternal({
String? agentId,
int limit = 24,
}) async {
final payload = asMap(
await request(
'sessions.list',
params: <String, dynamic>{
'includeGlobal': true,
'includeUnknown': false,
'includeDerivedTitles': true,
'includeLastMessage': true,
'limit': limit,
if (agentId != null && agentId.trim().isNotEmpty)
'agentId': agentId.trim(),
},
),
);
return asList(payload['sessions'])
.map((item) {
final map = asMap(item);
return GatewaySessionSummary(
key: stringValue(map['key']) ?? 'main',
kind: stringValue(map['kind']),
displayName:
stringValue(map['displayName']) ?? stringValue(map['label']),
surface: stringValue(map['surface']),
subject: stringValue(map['subject']),
room: stringValue(map['room']),
space: stringValue(map['space']),
updatedAtMs: doubleValue(map['updatedAt']),
sessionId: stringValue(map['sessionId']),
systemSent: boolValue(map['systemSent']),
abortedLastRun: boolValue(map['abortedLastRun']),
thinkingLevel: stringValue(map['thinkingLevel']),
verboseLevel: stringValue(map['verboseLevel']),
inputTokens: intValue(map['inputTokens']),
outputTokens: intValue(map['outputTokens']),
totalTokens: intValue(map['totalTokens']),
model: stringValue(map['model']),
contextTokens: intValue(map['contextTokens']),
derivedTitle: stringValue(map['derivedTitle']),
lastMessagePreview: stringValue(map['lastMessagePreview']),
);
})
.toList(growable: false);
}
Future<List<GatewayChatMessage>> _loadHistoryInternal(
String sessionKey, {
int limit = 120,
}) async {
final payload = asMap(
await request(
'chat.history',
params: <String, dynamic>{'sessionKey': sessionKey, 'limit': limit},
),
);
return asList(payload['messages'])
.map((item) {
final map = asMap(item);
return GatewayChatMessage(
id: randomIdInternal(),
role: stringValue(map['role']) ?? 'assistant',
text: extractMessageText(map),
timestampMs: doubleValue(map['timestamp']),
toolCallId:
stringValue(map['toolCallId']) ??
stringValue(map['tool_call_id']),
toolName:
stringValue(map['toolName']) ?? stringValue(map['tool_name']),
stopReason: stringValue(map['stopReason']),
pending: false,
error: false,
);
})
.toList(growable: false);
}
Future<String> _sendChatInternal({
required String sessionKey,
required String message,
required String thinking,
List<GatewayChatAttachmentPayload> attachments =
const <GatewayChatAttachmentPayload>[],
String? agentId,
Map<String, dynamic>? metadata,
}) async {
final runId = randomIdInternal();
final payload = asMap(
await request(
'chat.send',
params: <String, dynamic>{
'sessionKey': sessionKey,
'message': message,
'thinking': thinking,
'timeoutMs': 30000,
'idempotencyKey': runId,
if (agentId != null && agentId.trim().isNotEmpty)
'agentId': agentId.trim(),
if (attachments.isNotEmpty)
'attachments': attachments
.map((attachment) => attachment.toJson())
.toList(growable: false),
},
timeout: const Duration(seconds: 35),
),
);
return stringValue(payload['runId']) ?? runId;
}
Future<void> _abortChatInternal({
required String sessionKey,
required String runId,
}) async {
await request(
'chat.abort',
params: <String, dynamic>{'sessionKey': sessionKey, 'runId': runId},
timeout: const Duration(seconds: 10),
);
}
Future<List<GatewayInstanceSummary>> _listInstancesInternal() async {
final payload = await request(
'system-presence',
params: const <String, dynamic>{},
);
return asList(payload)
.map((item) {
final map = asMap(item);
return GatewayInstanceSummary(
id: stringValue(map['id']) ?? randomIdInternal(),
host: stringValue(map['host']),
ip: stringValue(map['ip']),
version: stringValue(map['version']),
platform: stringValue(map['platform']),
deviceFamily: stringValue(map['deviceFamily']),
modelIdentifier: stringValue(map['modelIdentifier']),
lastInputSeconds: intValue(map['lastInputSeconds']),
mode: stringValue(map['mode']),
reason: stringValue(map['reason']),
text: stringValue(map['text']) ?? '',
timestampMs:
doubleValue(map['ts']) ??
DateTime.now().millisecondsSinceEpoch.toDouble(),
);
})
.toList(growable: false);
}
Future<List<GatewaySkillSummary>> _listSkillsInternal({
String? agentId,
}) async {
final params = <String, dynamic>{
if (agentId != null && agentId.trim().isNotEmpty)
'agentId': agentId.trim(),
};
if (sessionClientInternal == null) {
throw GatewayRuntimeException(
'skills.status requires bridge session (ACP transport)',
code: 'BRIDGE_NOT_CONFIGURED',
);
}
// Use allowErrorPayload so the bridge can return cached skills even when
// the upstream OpenClaw gateway is temporarily offline (ok:false + payload).
final payload = asMap(
await sessionClientInternal!.request(
runtimeId: runtimeIdInternal,
method: 'skills.status',
params: params,
allowErrorPayload: true,
),
);
final skillsList = asList(payload['skills']);
// When the skills key is entirely absent (not just an empty list), the
// gateway may have returned a stub or error payload. Distinguish between
// "genuinely no skills" and "gateway responded without skills data".
if (!payload.containsKey('skills')) {
final hasWorkspaceMeta = payload.containsKey('workspaceDir') ||
payload.containsKey('managedSkillsDir');
if (!hasWorkspaceMeta) {
appendLogInternal(
this,
'warn',
'skills',
'skills.status returned payload without skills key and without'
' workspace metadata — likely a gateway error or unimplemented method',
);
throw GatewayRuntimeException(
'OpenClaw gateway did not return skills data.'
' The gateway may not have skills.status implemented.',
code: 'SKILLS_STATUS_MISSING',
);
}
// Gateway responded with workspace metadata but no skills key —
// genuinely no skills installed. Return empty list.
appendLogInternal(
this,
'debug',
'skills',
'skills.status returned workspace metadata with zero skills',
);
return const <GatewaySkillSummary>[];
}
if (skillsList.isEmpty) {
appendLogInternal(
this,
'debug',
'skills',
'skills.status returned empty skills list (${payload.length} payload keys)',
);
}
return skillsList
.map((item) {
final map = asMap(item);
return GatewaySkillSummary(
name:
stringValue(map['name']) ??
stringValue(map['title']) ??
stringValue(map['id']) ??
'Skill',
description: stringValue(map['description']) ?? '',
source: stringValue(map['source']) ?? 'workspace',
skillKey:
stringValue(map['skillKey']) ??
stringValue(map['skill_key']) ??
stringValue(map['key']) ??
stringValue(map['id']) ??
stringValue(map['name']) ??
'skill',
primaryEnv: stringValue(map['primaryEnv']),
eligible: boolValue(map['eligible']) ?? false,
disabled: boolValue(map['disabled']) ?? false,
missingBins: skillMissingListInternal(map, 'bins', 'missingBins'),
missingEnv: skillMissingListInternal(map, 'env', 'missingEnv'),
missingConfig: skillMissingListInternal(
map,
'config',
'missingConfig',
),
);
})
.toList(growable: false);
}
Future<List<GatewayConnectorSummary>> _listConnectorsInternal() async {
final payload = asMap(
await request(
'channels.status',
params: const <String, dynamic>{'probe': true, 'timeoutMs': 8000},
timeout: const Duration(seconds: 16),
),
);
final channelMeta = <String, Map<String, dynamic>>{
for (final entry in asList(payload['channelMeta']))
if (stringValue(asMap(entry)['id']) != null)
stringValue(asMap(entry)['id'])!: asMap(entry),
};
final labels = asMap(payload['channelLabels']);
final detailLabels = asMap(payload['channelDetailLabels']);
final accounts = asMap(payload['channelAccounts']);
final order = stringList(payload['channelOrder']);
final summaries = <GatewayConnectorSummary>[];
for (final channelId in order) {
final channelAccounts = asList(accounts[channelId]);
if (channelAccounts.isEmpty) {
final meta = channelMeta[channelId] ?? const <String, dynamic>{};
summaries.add(
GatewayConnectorSummary(
id: channelId,
label:
stringValue(meta['label']) ??
stringValue(labels[channelId]) ??
channelId,
detailLabel:
stringValue(meta['detailLabel']) ??
stringValue(detailLabels[channelId]) ??
channelId,
accountName: null,
configured: false,
enabled: false,
running: false,
connected: false,
status: 'idle',
lastError: null,
meta: const <String>[],
),
);
continue;
}
for (final account in channelAccounts) {
final map = asMap(account);
final configured = boolValue(map['configured']) ?? false;
final enabled = boolValue(map['enabled']) ?? configured;
final running = boolValue(map['running']) ?? false;
final connected =
boolValue(map['connected']) ?? boolValue(map['linked']) ?? false;
final lastError = stringValue(map['lastError']);
final status = lastError != null && lastError.trim().isNotEmpty
? 'error'
: connected
? 'connected'
: running
? 'running'
: configured
? 'configured'
: 'idle';
final mode = stringValue(map['mode']);
final tokenSource = stringValue(map['tokenSource']);
final baseUrl = stringValue(map['baseUrl']);
summaries.add(
GatewayConnectorSummary(
id: channelId,
label:
stringValue(channelMeta[channelId]?['label']) ??
stringValue(labels[channelId]) ??
channelId,
detailLabel:
stringValue(channelMeta[channelId]?['detailLabel']) ??
stringValue(detailLabels[channelId]) ??
channelId,
accountName:
stringValue(map['name']) ?? stringValue(map['accountId']),
configured: configured,
enabled: enabled,
running: running,
connected: connected,
status: status,
lastError: lastError,
meta: [
...?(mode == null ? null : <String>[mode]),
...?(tokenSource == null ? null : <String>[tokenSource]),
...?(baseUrl == null ? null : <String>[baseUrl]),
],
),
);
}
}
return summaries;
}
Future<List<GatewayModelSummary>> _listModelsInternal() async {
final payload = asMap(
await request(
'models.list',
params: const <String, dynamic>{},
timeout: const Duration(seconds: 16),
),
);
return asList(payload['models'])
.map((item) {
final map = asMap(item);
return GatewayModelSummary(
id: stringValue(map['id']) ?? 'unknown',
name:
stringValue(map['name']) ?? stringValue(map['id']) ?? 'unknown',
provider: stringValue(map['provider']) ?? 'unknown',
contextWindow: intValue(map['contextWindow']),
maxOutputTokens: intValue(map['maxOutputTokens']),
);
})
.toList(growable: false);
}
Future<List<GatewayCronJobSummary>> _listCronJobsInternal() async {
final payload = asMap(
await request(
'cron.list',
params: const <String, dynamic>{'includeDisabled': true},
timeout: const Duration(seconds: 16),
),
);
return asList(payload['jobs'])
.map((item) {
final map = asMap(item);
final state = asMap(map['state']);
return GatewayCronJobSummary(
id: stringValue(map['id']) ?? randomIdInternal(),
name: stringValue(map['name']) ?? 'Untitled job',
description: stringValue(map['description']),
enabled: boolValue(map['enabled']) ?? true,
agentId: stringValue(map['agentId']),
scheduleLabel: cronScheduleLabelInternal(asMap(map['schedule'])),
nextRunAtMs: intValue(state['nextRunAtMs']),
lastRunAtMs: intValue(state['lastRunAtMs']),
lastStatus: stringValue(state['lastStatus']),
lastError: stringValue(state['lastError']),
);
})
.toList(growable: false);
}
Future<GatewayDevicePairingList> _listDevicePairingInternal() async {
final payload = asMap(
await request(
'device.pair.list',
params: const <String, dynamic>{},
timeout: const Duration(seconds: 12),
),
);
final identity = await storeInternal.loadDeviceIdentity();
return GatewayDevicePairingList(
pending: asList(payload['pending'])
.map((item) => parsePendingDeviceInternal(asMap(item)))
.toList(growable: false),
paired: asList(payload['paired'])
.map(
(item) => parsePairedDeviceInternal(
asMap(item),
currentDeviceId: identity?.deviceId,
),
)
.toList(growable: false),
);
}
Future<GatewayPairedDevice?> _approveDevicePairingInternal(
String requestId,
) async {
appendLogInternal(this, 'info', 'pairing', 'approve request $requestId');
final payload = asMap(
await request(
'device.pair.approve',
params: <String, dynamic>{'requestId': requestId},
timeout: const Duration(seconds: 12),
),
);
final identity = await storeInternal.loadDeviceIdentity();
final device = asMap(payload['device']);
if (device.isEmpty) {
return null;
}
return parsePairedDeviceInternal(
device,
currentDeviceId: identity?.deviceId,
);
}
Future<void> _rejectDevicePairingInternal(String requestId) async {
appendLogInternal(this, 'info', 'pairing', 'reject request $requestId');
await request(
'device.pair.reject',
params: <String, dynamic>{'requestId': requestId},
timeout: const Duration(seconds: 12),
);
}
Future<void> _removePairedDeviceInternal(String deviceId) async {
appendLogInternal(this, 'info', 'pairing', 'remove device $deviceId');
await request(
'device.pair.remove',
params: <String, dynamic>{'deviceId': deviceId},
timeout: const Duration(seconds: 12),
);
}
Future<String> _rotateDeviceTokenInternal({
required String deviceId,
required String role,
List<String> scopes = const <String>[],
}) async {
appendLogInternal(
this,
'info',
'token',
'rotate role token | device: $deviceId | role: $role',
);
final payload = asMap(
await request(
'device.token.rotate',
params: <String, dynamic>{
'deviceId': deviceId,
'role': role,
if (scopes.isNotEmpty) 'scopes': scopes,
},
timeout: const Duration(seconds: 12),
),
);
final token = stringValue(payload['token']) ?? '';
final identity = await storeInternal.loadDeviceIdentity();
final resolvedRole = stringValue(payload['role']) ?? role;
if (token.isNotEmpty &&
identity != null &&
(stringValue(payload['deviceId']) ?? deviceId) == identity.deviceId) {
await storeInternal.saveDeviceToken(
deviceId: identity.deviceId,
role: resolvedRole,
token: token,
);
}
return token;
}
Future<void> _revokeDeviceTokenInternal({
required String deviceId,
required String role,
}) async {
appendLogInternal(
this,
'info',
'token',
'revoke role token | device: $deviceId | role: $role',
);
await request(
'device.token.revoke',
params: <String, dynamic>{'deviceId': deviceId, 'role': role},
timeout: const Duration(seconds: 12),
);
final identity = await storeInternal.loadDeviceIdentity();
if (identity != null && deviceId == identity.deviceId) {
await storeInternal.clearDeviceToken(
deviceId: identity.deviceId,
role: role,
);
}
}
Future<dynamic> _requestInternal(
String method, {
Map<String, dynamic>? params,
Duration timeout = const Duration(seconds: 15),
}) async {
if (sessionClientInternal != null) {
if (!isConnected) {
appendLogInternal(
this,
'warn',
'rpc',
'blocked request $method | offline',
);
throw GatewayRuntimeException('gateway not connected', code: 'OFFLINE');
}
return sessionClientInternal!.request(
runtimeId: runtimeIdInternal,
method: method,
params: params,
timeout: timeout,
);
}
if (channelInternal == null || !isConnected) {
appendLogInternal(
this,
'warn',
'rpc',
'blocked request $method | offline',
);
throw GatewayRuntimeException('gateway not connected', code: 'OFFLINE');
}
final result = await requestRawInternal(
this,
method,
params: params,
timeout: timeout,
);
return result.payload;
}
}
List<String> skillMissingListInternal(
Map<String, dynamic> skill,
String nestedKey,
String flatKey,
) {
final flat = stringList(skill[flatKey]);
if (flat.isNotEmpty) {
return flat;
}
return stringList(asMap(skill['missing'])[nestedKey]);
}