CODEX_ADK/FRONTEND/lib/api/endpoints/execution_endpoint.dart
Svrnty 229a0698a3 Initial commit: CODEX_ADK monorepo
Multi-agent AI laboratory with ASP.NET Core 8.0 backend and Flutter frontend.
Implements CQRS architecture, OpenAPI contract-first API design.

BACKEND: Agent management, conversations, executions with PostgreSQL + Ollama
FRONTEND: Cross-platform UI with strict typing and Result-based error handling

Co-Authored-By: Jean-Philippe Brule <jp@svrnty.io>
2025-10-26 23:12:32 -04:00

569 lines
17 KiB
Dart

/// Agent execution endpoints for CQRS API
library;
import 'dart:convert';
import 'dart:io';
import 'dart:async';
import 'package:http/http.dart' as http;
import '../client.dart';
import '../types.dart';
// =============================================================================
// Enums
// =============================================================================
/// Represents the current status of an agent execution
enum ExecutionStatus {
pending('Pending'),
running('Running'),
completed('Completed'),
failed('Failed'),
cancelled('Cancelled');
const ExecutionStatus(this.value);
final String value;
static ExecutionStatus fromString(String value) {
return ExecutionStatus.values.firstWhere(
(status) => status.value == value,
orElse: () => ExecutionStatus.pending,
);
}
static ExecutionStatus fromInt(int value) {
if (value >= 0 && value < ExecutionStatus.values.length) {
return ExecutionStatus.values[value];
}
return ExecutionStatus.pending;
}
}
// =============================================================================
// Commands
// =============================================================================
/// Command to start an agent execution
class StartAgentExecutionCommand implements Serializable {
final String agentId;
final String? conversationId;
final String userPrompt;
const StartAgentExecutionCommand({
required this.agentId,
this.conversationId,
required this.userPrompt,
});
@override
Map<String, Object?> toJson() => {
'agentId': agentId,
if (conversationId != null) 'conversationId': conversationId,
'userPrompt': userPrompt,
};
}
/// Command to complete an agent execution with results
class CompleteAgentExecutionCommand implements Serializable {
final String executionId;
final ExecutionStatus status;
final String? response;
final int? inputTokens;
final int? outputTokens;
final double? estimatedCost;
final String? errorMessage;
const CompleteAgentExecutionCommand({
required this.executionId,
required this.status,
this.response,
this.inputTokens,
this.outputTokens,
this.estimatedCost,
this.errorMessage,
});
@override
Map<String, Object?> toJson() => {
'executionId': executionId,
'status': ExecutionStatus.values.indexOf(status),
if (response != null) 'response': response,
if (inputTokens != null) 'inputTokens': inputTokens,
if (outputTokens != null) 'outputTokens': outputTokens,
if (estimatedCost != null) 'estimatedCost': estimatedCost,
if (errorMessage != null) 'errorMessage': errorMessage,
};
}
// =============================================================================
// Queries
// =============================================================================
/// Query to get a single execution by ID
class GetAgentExecutionQuery implements Serializable {
final String id;
const GetAgentExecutionQuery({required this.id});
@override
Map<String, Object?> toJson() => {'id': id};
}
// =============================================================================
// DTOs
// =============================================================================
/// Response when starting an execution (returns only ID)
class StartExecutionResult {
final String id;
const StartExecutionResult({required this.id});
factory StartExecutionResult.fromJson(Map<String, Object?> json) {
return StartExecutionResult(
id: json['id'] as String,
);
}
Map<String, Object?> toJson() => {'id': id};
}
/// Full agent execution details
class AgentExecutionDto {
final String id;
final String agentId;
final String? conversationId;
final String userPrompt;
final String? response;
final ExecutionStatus status;
final DateTime startedAt;
final DateTime? completedAt;
final int? inputTokens;
final int? outputTokens;
final double? estimatedCost;
final int messageCount;
final String? errorMessage;
const AgentExecutionDto({
required this.id,
required this.agentId,
this.conversationId,
required this.userPrompt,
this.response,
required this.status,
required this.startedAt,
this.completedAt,
this.inputTokens,
this.outputTokens,
this.estimatedCost,
required this.messageCount,
this.errorMessage,
});
factory AgentExecutionDto.fromJson(Map<String, Object?> json) {
// Handle status as either int or string
ExecutionStatus status;
final Object? statusValue = json['status'];
if (statusValue is int) {
status = ExecutionStatus.fromInt(statusValue);
} else if (statusValue is String) {
status = ExecutionStatus.fromString(statusValue);
} else {
status = ExecutionStatus.pending;
}
return AgentExecutionDto(
id: json['id'] as String,
agentId: json['agentId'] as String,
conversationId: json['conversationId'] as String?,
userPrompt: json['userPrompt'] as String,
response: json['response'] as String?,
status: status,
startedAt: DateTime.parse(json['startedAt'] as String),
completedAt: json['completedAt'] != null
? DateTime.parse(json['completedAt'] as String)
: null,
inputTokens: json['inputTokens'] as int?,
outputTokens: json['outputTokens'] as int?,
estimatedCost: json['estimatedCost'] != null
? (json['estimatedCost'] as num).toDouble()
: null,
messageCount: json['messageCount'] as int,
errorMessage: json['errorMessage'] as String?,
);
}
Map<String, Object?> toJson() => {
'id': id,
'agentId': agentId,
'conversationId': conversationId,
'userPrompt': userPrompt,
'response': response,
'status': status.value,
'startedAt': startedAt.toIso8601String(),
'completedAt': completedAt?.toIso8601String(),
'inputTokens': inputTokens,
'outputTokens': outputTokens,
'estimatedCost': estimatedCost,
'messageCount': messageCount,
'errorMessage': errorMessage,
};
}
/// Execution list item (lightweight version for lists)
class ExecutionListItemDto {
final String id;
final String agentId;
final String agentName;
final String? conversationId;
final String userPrompt;
final ExecutionStatus status;
final DateTime startedAt;
final DateTime? completedAt;
final int? inputTokens;
final int? outputTokens;
final double? estimatedCost;
final int messageCount;
final String? errorMessage;
const ExecutionListItemDto({
required this.id,
required this.agentId,
required this.agentName,
this.conversationId,
required this.userPrompt,
required this.status,
required this.startedAt,
this.completedAt,
this.inputTokens,
this.outputTokens,
this.estimatedCost,
required this.messageCount,
this.errorMessage,
});
factory ExecutionListItemDto.fromJson(Map<String, Object?> json) {
// Handle status as either int or string
ExecutionStatus status;
final Object? statusValue = json['status'];
if (statusValue is int) {
status = ExecutionStatus.fromInt(statusValue);
} else if (statusValue is String) {
status = ExecutionStatus.fromString(statusValue);
} else {
status = ExecutionStatus.pending;
}
return ExecutionListItemDto(
id: json['id'] as String,
agentId: json['agentId'] as String,
agentName: json['agentName'] as String,
conversationId: json['conversationId'] as String?,
userPrompt: json['userPrompt'] as String,
status: status,
startedAt: DateTime.parse(json['startedAt'] as String),
completedAt: json['completedAt'] != null
? DateTime.parse(json['completedAt'] as String)
: null,
inputTokens: json['inputTokens'] as int?,
outputTokens: json['outputTokens'] as int?,
estimatedCost: json['estimatedCost'] != null
? (json['estimatedCost'] as num).toDouble()
: null,
messageCount: json['messageCount'] as int,
errorMessage: json['errorMessage'] as String?,
);
}
Map<String, Object?> toJson() => {
'id': id,
'agentId': agentId,
'agentName': agentName,
'conversationId': conversationId,
'userPrompt': userPrompt,
'status': status.value,
'startedAt': startedAt.toIso8601String(),
'completedAt': completedAt?.toIso8601String(),
'inputTokens': inputTokens,
'outputTokens': outputTokens,
'estimatedCost': estimatedCost,
'messageCount': messageCount,
'errorMessage': errorMessage,
};
}
// =============================================================================
// Extension Methods
// =============================================================================
/// Agent execution management endpoints
extension ExecutionEndpoint on CqrsApiClient {
/// Start an agent execution
///
/// Returns the ID of the newly created execution.
///
/// Example:
/// ```dart
/// final result = await client.startAgentExecution(
/// StartAgentExecutionCommand(
/// agentId: 'agent-uuid',
/// conversationId: 'conversation-uuid', // Optional
/// userPrompt: 'Generate a function to calculate factorial',
/// ),
/// );
///
/// result.when(
/// success: (started) => print('Execution ID: ${started.id}'),
/// error: (error) => print('Error: ${error.message}'),
/// );
/// ```
Future<Result<StartExecutionResult>> startAgentExecution(
StartAgentExecutionCommand command,
) async {
try {
final Uri url =
Uri.parse('${config.baseUrl}/api/command/startAgentExecution');
final String body = jsonEncode(command.toJson());
final http.Response response = await http
.post(
url,
headers: config.defaultHeaders,
body: body,
)
.timeout(config.timeout);
if (response.statusCode >= 200 && response.statusCode < 300) {
try {
final Object? json = jsonDecode(response.body);
final StartExecutionResult result =
StartExecutionResult.fromJson(json as Map<String, Object?>);
return ApiSuccess<StartExecutionResult>(result);
} catch (e) {
return ApiError<StartExecutionResult>(
ApiErrorInfo(
message: 'Failed to parse start execution response',
statusCode: response.statusCode,
type: ApiErrorType.serialization,
details: e.toString(),
),
);
}
} else {
return ApiError<StartExecutionResult>(
ApiErrorInfo(
message: 'Start execution failed',
statusCode: response.statusCode,
type: ApiErrorType.http,
),
);
}
} on TimeoutException catch (e) {
return ApiError<StartExecutionResult>(
ApiErrorInfo(
message: 'Request timeout: ${e.message ?? "Operation took too long"}',
type: ApiErrorType.timeout,
),
);
} on SocketException catch (e) {
return ApiError<StartExecutionResult>(
ApiErrorInfo(
message: 'Network error: ${e.message}',
type: ApiErrorType.network,
details: e.osError?.message,
),
);
} catch (e) {
return ApiError<StartExecutionResult>(
ApiErrorInfo(
message: 'Unexpected error: $e',
type: ApiErrorType.unknown,
),
);
}
}
/// Complete an agent execution with results
///
/// Example:
/// ```dart
/// final result = await client.completeAgentExecution(
/// CompleteAgentExecutionCommand(
/// executionId: 'execution-uuid',
/// status: ExecutionStatus.completed,
/// response: 'Here is the factorial function...',
/// inputTokens: 100,
/// outputTokens: 200,
/// estimatedCost: 0.003,
/// ),
/// );
/// ```
Future<Result<void>> completeAgentExecution(
CompleteAgentExecutionCommand command,
) async {
return executeCommand(
endpoint: 'completeAgentExecution',
command: command,
);
}
/// Get a single execution by ID with full details
///
/// Example:
/// ```dart
/// final result = await client.getAgentExecution('execution-uuid');
///
/// result.when(
/// success: (execution) {
/// print('Status: ${execution.status.value}');
/// print('Prompt: ${execution.userPrompt}');
/// print('Response: ${execution.response}');
/// print('Tokens: ${execution.inputTokens} in, ${execution.outputTokens} out');
/// },
/// error: (error) => print('Error: ${error.message}'),
/// );
/// ```
Future<Result<AgentExecutionDto>> getAgentExecution(String id) async {
return executeQuery<AgentExecutionDto>(
endpoint: 'getAgentExecution',
query: GetAgentExecutionQuery(id: id),
fromJson: (Object? json) =>
AgentExecutionDto.fromJson(json as Map<String, Object?>),
);
}
/// List all executions
///
/// Returns a list of all executions from the backend.
/// Backend endpoint: GET /api/executions
///
/// Example:
/// ```dart
/// final result = await client.listExecutions();
///
/// result.when(
/// success: (executions) {
/// print('Found ${executions.length} executions');
/// for (final exec in executions) {
/// print('${exec.agentName}: ${exec.status.value}');
/// }
/// },
/// error: (error) => print('Error: ${error.message}'),
/// );
/// ```
Future<Result<List<ExecutionListItemDto>>> listExecutions() async {
try {
final Uri url = Uri.parse('${config.baseUrl}/api/executions');
final http.Response response = await http
.get(url, headers: config.defaultHeaders)
.timeout(config.timeout);
if (response.statusCode >= 200 && response.statusCode < 300) {
final Object? jsonData = jsonDecode(response.body);
if (jsonData is! List) {
return ApiError<List<ExecutionListItemDto>>(ApiErrorInfo(
message: 'Expected array response, got ${jsonData.runtimeType}',
type: ApiErrorType.serialization,
));
}
final List<ExecutionListItemDto> executions = jsonData
.map((Object? item) =>
ExecutionListItemDto.fromJson(item as Map<String, Object?>))
.toList();
return ApiSuccess<List<ExecutionListItemDto>>(executions);
}
return ApiError<List<ExecutionListItemDto>>(ApiErrorInfo(
message: 'Failed to load executions',
type: ApiErrorType.http,
statusCode: response.statusCode,
));
} on TimeoutException {
return ApiError<List<ExecutionListItemDto>>(ApiErrorInfo(
message: 'Request timed out',
type: ApiErrorType.timeout,
));
} on SocketException {
return ApiError<List<ExecutionListItemDto>>(ApiErrorInfo(
message: 'No internet connection',
type: ApiErrorType.network,
));
} catch (e) {
return ApiError<List<ExecutionListItemDto>>(ApiErrorInfo(
message: 'Unexpected error: $e',
type: ApiErrorType.unknown,
));
}
}
/// List executions filtered by status
///
/// Returns executions matching the specified status.
/// Backend endpoint: GET /api/executions/status/{status}
///
/// Example:
/// ```dart
/// final result = await client.listExecutionsByStatus(ExecutionStatus.running);
///
/// result.when(
/// success: (executions) {
/// print('Found ${executions.length} running executions');
/// },
/// error: (error) => print('Error: ${error.message}'),
/// );
/// ```
Future<Result<List<ExecutionListItemDto>>> listExecutionsByStatus(
ExecutionStatus status,
) async {
try {
final String statusValue =
ExecutionStatus.values.indexOf(status).toString();
final Uri url =
Uri.parse('${config.baseUrl}/api/executions/status/$statusValue');
final http.Response response = await http
.get(url, headers: config.defaultHeaders)
.timeout(config.timeout);
if (response.statusCode >= 200 && response.statusCode < 300) {
final Object? jsonData = jsonDecode(response.body);
if (jsonData is! List) {
return ApiError<List<ExecutionListItemDto>>(ApiErrorInfo(
message: 'Expected array response, got ${jsonData.runtimeType}',
type: ApiErrorType.serialization,
));
}
final List<ExecutionListItemDto> executions = jsonData
.map((Object? item) =>
ExecutionListItemDto.fromJson(item as Map<String, Object?>))
.toList();
return ApiSuccess<List<ExecutionListItemDto>>(executions);
}
return ApiError<List<ExecutionListItemDto>>(ApiErrorInfo(
message: 'Failed to load executions by status',
type: ApiErrorType.http,
statusCode: response.statusCode,
));
} on TimeoutException {
return ApiError<List<ExecutionListItemDto>>(ApiErrorInfo(
message: 'Request timed out',
type: ApiErrorType.timeout,
));
} on SocketException {
return ApiError<List<ExecutionListItemDto>>(ApiErrorInfo(
message: 'No internet connection',
type: ApiErrorType.network,
));
} catch (e) {
return ApiError<List<ExecutionListItemDto>>(ApiErrorInfo(
message: 'Unexpected error: $e',
type: ApiErrorType.unknown,
));
}
}
}