Initial public release of Warp.
Repo-Sync-Origin: warpdotdev/warp-internal@12af1d983b
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,974 @@
|
||||
use chrono::TimeZone;
|
||||
use chrono::Utc;
|
||||
|
||||
use super::{
|
||||
build_list_agent_runs_url, AgentMessageHeader, AgentRunEvent, AgentSource,
|
||||
AmbientAgentTaskState, Artifact, ArtifactDownloadResponse, ArtifactType, ExecutionLocation,
|
||||
ListRunsResponse, ReadAgentMessageResponse, RunSortBy, RunSortOrder, TaskListFilter,
|
||||
};
|
||||
use crate::notebooks::NotebookId;
|
||||
|
||||
#[test]
|
||||
fn test_deserialize_file_artifact_download_response() {
|
||||
let json = r#"{
|
||||
"artifact_uid": "artifact-123",
|
||||
"artifact_type": "FILE",
|
||||
"created_at": "2024-01-15T10:30:00Z",
|
||||
"data": {
|
||||
"download_url": "https://storage.example.com/report.txt",
|
||||
"expires_at": "2024-01-15T11:30:00Z",
|
||||
"content_type": "text/plain",
|
||||
"filepath": "outputs/report.txt",
|
||||
"filename": "report.txt",
|
||||
"description": "daily summary",
|
||||
"size_bytes": 42
|
||||
}
|
||||
}"#;
|
||||
|
||||
let artifact: ArtifactDownloadResponse = serde_json::from_str(json).unwrap();
|
||||
|
||||
let ArtifactDownloadResponse::File { common, data } = artifact else {
|
||||
panic!("expected File artifact download response");
|
||||
};
|
||||
assert_eq!(common.artifact_uid, "artifact-123");
|
||||
assert_eq!(common.created_at.to_rfc3339(), "2024-01-15T10:30:00+00:00");
|
||||
assert_eq!(data.download_url, "https://storage.example.com/report.txt");
|
||||
assert_eq!(data.expires_at.to_rfc3339(), "2024-01-15T11:30:00+00:00");
|
||||
assert_eq!(data.content_type, "text/plain");
|
||||
assert_eq!(data.filepath, "outputs/report.txt");
|
||||
assert_eq!(data.filename, "report.txt");
|
||||
assert_eq!(data.description.as_deref(), Some("daily summary"));
|
||||
assert_eq!(data.size_bytes, Some(42));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_deserialize_screenshot_artifact_download_response() {
|
||||
let json = r#"{
|
||||
"artifact_uid": "screenshot-123",
|
||||
"artifact_type": "SCREENSHOT",
|
||||
"created_at": "2024-01-15T10:30:00Z",
|
||||
"data": {
|
||||
"download_url": "https://storage.example.com/screenshot.png",
|
||||
"expires_at": "2024-01-15T11:30:00Z",
|
||||
"content_type": "image/png",
|
||||
"description": "dashboard screenshot"
|
||||
}
|
||||
}"#;
|
||||
|
||||
let artifact: ArtifactDownloadResponse = serde_json::from_str(json).unwrap();
|
||||
|
||||
let ArtifactDownloadResponse::Screenshot { common, data } = artifact else {
|
||||
panic!("expected Screenshot artifact download response");
|
||||
};
|
||||
assert_eq!(common.artifact_uid, "screenshot-123");
|
||||
assert_eq!(common.created_at.to_rfc3339(), "2024-01-15T10:30:00+00:00");
|
||||
assert_eq!(
|
||||
data.download_url,
|
||||
"https://storage.example.com/screenshot.png"
|
||||
);
|
||||
assert_eq!(data.expires_at.to_rfc3339(), "2024-01-15T11:30:00+00:00");
|
||||
assert_eq!(data.content_type, "image/png");
|
||||
assert_eq!(data.description.as_deref(), Some("dashboard screenshot"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_deserialize_plan_artifact() {
|
||||
let json = r#"{
|
||||
"created_at": "2024-01-15T10:30:00Z",
|
||||
"artifact_type": "PLAN",
|
||||
"data": {
|
||||
"document_uid": "doc-uid-123",
|
||||
"notebook_uid": "1234567890123456789012",
|
||||
"title": "My Plan"
|
||||
}
|
||||
}"#;
|
||||
|
||||
let artifact: Artifact = serde_json::from_str(json).unwrap();
|
||||
|
||||
let Artifact::Plan {
|
||||
document_uid,
|
||||
notebook_uid,
|
||||
title,
|
||||
} = &artifact
|
||||
else {
|
||||
panic!("expected Plan artifact");
|
||||
};
|
||||
assert_eq!(document_uid, "doc-uid-123");
|
||||
assert_eq!(
|
||||
notebook_uid.as_ref().map(|n| n.to_string()),
|
||||
Some("1234567890123456789012".to_string())
|
||||
);
|
||||
assert_eq!(*title, Some("My Plan".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_deserialize_pull_request_artifact() {
|
||||
let json = r#"{
|
||||
"created_at": "2024-01-15T10:30:00Z",
|
||||
"artifact_type": "PULL_REQUEST",
|
||||
"data": {
|
||||
"url": "https://github.com/org/repo/pull/42",
|
||||
"branch": "feature-branch"
|
||||
}
|
||||
}"#;
|
||||
|
||||
let artifact: Artifact = serde_json::from_str(json).unwrap();
|
||||
|
||||
let Artifact::PullRequest {
|
||||
url,
|
||||
branch,
|
||||
repo,
|
||||
number,
|
||||
} = &artifact
|
||||
else {
|
||||
panic!("expected PullRequest artifact");
|
||||
};
|
||||
assert_eq!(url, "https://github.com/org/repo/pull/42");
|
||||
assert_eq!(branch, "feature-branch");
|
||||
assert_eq!(*repo, Some("repo".to_string()));
|
||||
assert_eq!(*number, Some(42));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_deserialize_pull_request_non_github_url() {
|
||||
let json = r#"{
|
||||
"created_at": "2024-01-15T10:30:00Z",
|
||||
"artifact_type": "PULL_REQUEST",
|
||||
"data": {
|
||||
"url": "https://gitlab.com/org/repo/merge_requests/42",
|
||||
"branch": "feature-branch"
|
||||
}
|
||||
}"#;
|
||||
|
||||
let artifact: Artifact = serde_json::from_str(json).unwrap();
|
||||
|
||||
let Artifact::PullRequest { repo, number, .. } = &artifact else {
|
||||
panic!("expected PullRequest artifact");
|
||||
};
|
||||
assert_eq!(*repo, None);
|
||||
assert_eq!(*number, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_deserialize_plan_artifact_with_optional_fields_missing() {
|
||||
let json = r#"{
|
||||
"created_at": "2024-01-15T10:30:00Z",
|
||||
"artifact_type": "PLAN",
|
||||
"data": {
|
||||
"document_uid": "doc-uid-123",
|
||||
"notebook_uid": "abcdefghijklmnopqrstuv"
|
||||
}
|
||||
}"#;
|
||||
|
||||
let artifact: Artifact = serde_json::from_str(json).unwrap();
|
||||
|
||||
let Artifact::Plan {
|
||||
document_uid,
|
||||
notebook_uid,
|
||||
title,
|
||||
} = &artifact
|
||||
else {
|
||||
panic!("expected Plan artifact");
|
||||
};
|
||||
assert_eq!(document_uid, "doc-uid-123");
|
||||
assert_eq!(
|
||||
notebook_uid.as_ref().map(|n| n.to_string()),
|
||||
Some("abcdefghijklmnopqrstuv".to_string())
|
||||
);
|
||||
assert!(title.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_deserialize_list_tasks_response_with_artifacts() {
|
||||
let json = r#"{
|
||||
"runs": [
|
||||
{
|
||||
"task_id": "550e8400-e29b-41d4-a716-446655440000",
|
||||
"title": "Test Task",
|
||||
"state": "SUCCEEDED",
|
||||
"prompt": "test prompt",
|
||||
"created_at": "2024-01-15T10:00:00Z",
|
||||
"updated_at": "2024-01-15T10:30:00Z",
|
||||
"is_sandbox_running": true,
|
||||
"artifacts": [
|
||||
{
|
||||
"created_at": "2024-01-15T10:20:00Z",
|
||||
"artifact_type": "PLAN",
|
||||
"data": {
|
||||
"document_uid": "doc-1",
|
||||
"notebook_uid": "xyz1234567890123456789",
|
||||
"title": "Plan Title"
|
||||
}
|
||||
},
|
||||
{
|
||||
"created_at": "2024-01-15T10:25:00Z",
|
||||
"artifact_type": "PULL_REQUEST",
|
||||
"data": {
|
||||
"url": "https://github.com/org/repo/pull/1",
|
||||
"branch": "main"
|
||||
}
|
||||
},
|
||||
{
|
||||
"created_at": "2024-01-15T10:27:00Z",
|
||||
"artifact_type": "FILE",
|
||||
"data": {
|
||||
"artifact_uid": "artifact-file-1",
|
||||
"filepath": "outputs/report.txt",
|
||||
"filename": "report.txt",
|
||||
"mime_type": "text/plain",
|
||||
"description": "Daily summary",
|
||||
"size_bytes": 42
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}"#;
|
||||
|
||||
let response: ListRunsResponse = serde_json::from_str(json).unwrap();
|
||||
|
||||
assert_eq!(response.runs.len(), 1);
|
||||
let task = &response.runs[0];
|
||||
assert_eq!(
|
||||
task.task_id.to_string(),
|
||||
"550e8400-e29b-41d4-a716-446655440000"
|
||||
);
|
||||
assert_eq!(task.artifacts.len(), 3);
|
||||
|
||||
// Check first artifact (Plan)
|
||||
let Artifact::Plan {
|
||||
document_uid,
|
||||
title,
|
||||
..
|
||||
} = &task.artifacts[0]
|
||||
else {
|
||||
panic!("expected Plan artifact");
|
||||
};
|
||||
assert_eq!(document_uid, "doc-1");
|
||||
assert_eq!(*title, Some("Plan Title".to_string()));
|
||||
|
||||
// Check second artifact (PullRequest)
|
||||
let Artifact::PullRequest {
|
||||
url,
|
||||
branch,
|
||||
repo,
|
||||
number,
|
||||
..
|
||||
} = &task.artifacts[1]
|
||||
else {
|
||||
panic!("expected PullRequest artifact");
|
||||
};
|
||||
assert_eq!(url, "https://github.com/org/repo/pull/1");
|
||||
assert_eq!(branch, "main");
|
||||
assert_eq!(*repo, Some("repo".to_string()));
|
||||
assert_eq!(*number, Some(1));
|
||||
|
||||
let Artifact::File {
|
||||
artifact_uid,
|
||||
filepath,
|
||||
filename,
|
||||
mime_type,
|
||||
description,
|
||||
size_bytes,
|
||||
} = &task.artifacts[2]
|
||||
else {
|
||||
panic!("expected File artifact");
|
||||
};
|
||||
assert_eq!(artifact_uid, "artifact-file-1");
|
||||
assert_eq!(filepath, "outputs/report.txt");
|
||||
assert_eq!(filename, "report.txt");
|
||||
assert_eq!(mime_type, "text/plain");
|
||||
assert_eq!(*description, Some("Daily summary".to_string()));
|
||||
assert_eq!(*size_bytes, Some(42));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_deserialize_list_tasks_response_empty_artifacts() {
|
||||
let json = r#"{
|
||||
"runs": [
|
||||
{
|
||||
"task_id": "550e8400-e29b-41d4-a716-446655440001",
|
||||
"title": "Test Task",
|
||||
"state": "INPROGRESS",
|
||||
"prompt": "test prompt",
|
||||
"created_at": "2024-01-15T10:00:00Z",
|
||||
"updated_at": "2024-01-15T10:30:00Z",
|
||||
"is_sandbox_running": true,
|
||||
"artifacts": []
|
||||
}
|
||||
]
|
||||
}"#;
|
||||
|
||||
let response: ListRunsResponse = serde_json::from_str(json).unwrap();
|
||||
|
||||
assert_eq!(response.runs.len(), 1);
|
||||
assert!(response.runs[0].artifacts.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_deserialize_list_tasks_response_missing_artifacts_field() {
|
||||
// Server may not include artifacts field at all for older responses
|
||||
let json = r#"{
|
||||
"runs": [
|
||||
{
|
||||
"task_id": "550e8400-e29b-41d4-a716-446655440002",
|
||||
"title": "Test Task",
|
||||
"state": "QUEUED",
|
||||
"prompt": "test prompt",
|
||||
"created_at": "2024-01-15T10:00:00Z",
|
||||
"updated_at": "2024-01-15T10:30:00Z",
|
||||
"is_sandbox_running": true
|
||||
}
|
||||
]
|
||||
}"#;
|
||||
|
||||
let response: ListRunsResponse = serde_json::from_str(json).unwrap();
|
||||
|
||||
assert_eq!(response.runs.len(), 1);
|
||||
assert!(response.runs[0].artifacts.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_deserialize_artifacts_skips_invalid_items() {
|
||||
// deserialize_artifacts should skip invalid items and keep valid ones
|
||||
let json = r#"{
|
||||
"runs": [
|
||||
{
|
||||
"task_id": "550e8400-e29b-41d4-a716-446655440000",
|
||||
"title": "Test Task",
|
||||
"state": "SUCCEEDED",
|
||||
"prompt": "test prompt",
|
||||
"created_at": "2024-01-15T10:00:00Z",
|
||||
"updated_at": "2024-01-15T10:30:00Z",
|
||||
"is_sandbox_running": true,
|
||||
"artifacts": [
|
||||
{
|
||||
"created_at": "2024-01-15T10:20:00Z",
|
||||
"artifact_type": "PLAN",
|
||||
"data": {
|
||||
"document_uid": "valid-doc",
|
||||
"notebook_uid": "validnotebook123456789",
|
||||
"title": "Valid Plan"
|
||||
}
|
||||
},
|
||||
{
|
||||
"created_at": "2024-01-15T10:25:00Z",
|
||||
"artifact_type": "UNKNOWN_TYPE",
|
||||
"data": {
|
||||
"some_field": "value"
|
||||
}
|
||||
},
|
||||
{
|
||||
"created_at": "2024-01-15T10:30:00Z",
|
||||
"artifact_type": "PULL_REQUEST",
|
||||
"data": {
|
||||
"url": "https://github.com/org/repo/pull/1",
|
||||
"branch": "main"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}"#;
|
||||
|
||||
let response: ListRunsResponse = serde_json::from_str(json).unwrap();
|
||||
|
||||
assert_eq!(response.runs.len(), 1);
|
||||
// Invalid artifact skipped, valid ones kept
|
||||
assert_eq!(response.runs[0].artifacts.len(), 2);
|
||||
assert!(matches!(
|
||||
response.runs[0].artifacts[0],
|
||||
Artifact::Plan { .. }
|
||||
));
|
||||
assert!(matches!(
|
||||
response.runs[0].artifacts[1],
|
||||
Artifact::PullRequest { .. }
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_deserialize_artifacts_all_invalid_returns_empty() {
|
||||
// When all artifacts are invalid, result should be empty vec
|
||||
let json = r#"{
|
||||
"runs": [
|
||||
{
|
||||
"task_id": "550e8400-e29b-41d4-a716-446655440000",
|
||||
"title": "Test Task",
|
||||
"state": "SUCCEEDED",
|
||||
"prompt": "test prompt",
|
||||
"created_at": "2024-01-15T10:00:00Z",
|
||||
"updated_at": "2024-01-15T10:30:00Z",
|
||||
"is_sandbox_running": true,
|
||||
"artifacts": [
|
||||
{
|
||||
"created_at": "2024-01-15T10:20:00Z",
|
||||
"artifact_type": "UNKNOWN_TYPE",
|
||||
"data": {}
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}"#;
|
||||
|
||||
let response: ListRunsResponse = serde_json::from_str(json).unwrap();
|
||||
|
||||
assert_eq!(response.runs.len(), 1);
|
||||
assert!(response.runs[0].artifacts.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_deserialize_artifact_missing_data_field() {
|
||||
let json = r#"{
|
||||
"created_at": "2024-01-15T10:30:00Z",
|
||||
"artifact_type": "PLAN"
|
||||
}"#;
|
||||
|
||||
let result = serde_json::from_str::<Artifact>(json);
|
||||
assert!(result.is_err());
|
||||
assert!(result.unwrap_err().to_string().contains("missing field"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_deserialize_artifact_invalid_plan_data() {
|
||||
// Missing required `document_uid` field should fail deserialization
|
||||
let json = r#"{
|
||||
"created_at": "2024-01-15T10:30:00Z",
|
||||
"artifact_type": "PLAN",
|
||||
"data": {
|
||||
"title": "Only title, no document_uid"
|
||||
}
|
||||
}"#;
|
||||
|
||||
let result = serde_json::from_str::<Artifact>(json);
|
||||
assert!(result.is_err());
|
||||
assert!(result.unwrap_err().to_string().contains("missing field"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_deserialize_artifact_invalid_pr_data() {
|
||||
let json = r#"{
|
||||
"created_at": "2024-01-15T10:30:00Z",
|
||||
"artifact_type": "PULL_REQUEST",
|
||||
"data": {
|
||||
"url": "https://github.com/org/repo/pull/1"
|
||||
}
|
||||
}"#;
|
||||
|
||||
let result = serde_json::from_str::<Artifact>(json);
|
||||
assert!(result.is_err());
|
||||
assert!(result.unwrap_err().to_string().contains("missing field"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_deserialize_artifact_unknown_variant() {
|
||||
let json = r#"{
|
||||
"created_at": "2024-01-15T10:30:00Z",
|
||||
"artifact_type": "UNKNOWN_TYPE",
|
||||
"data": {
|
||||
"some_field": "value"
|
||||
}
|
||||
}"#;
|
||||
|
||||
let result = serde_json::from_str::<Artifact>(json);
|
||||
assert!(result.is_err());
|
||||
let error_msg = result.unwrap_err().to_string();
|
||||
assert!(error_msg.contains("unknown variant"));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------------------------------------------
|
||||
// Tests for resilient task list deserialization (skipping malformed tasks while tolerating unknown states)
|
||||
// ---------------------------------------------------------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn test_deserialize_list_tasks_skips_invalid_task() {
|
||||
// One valid task and one invalid task (missing required field)
|
||||
let json = r#"{
|
||||
"runs": [
|
||||
{
|
||||
"task_id": "550e8400-e29b-41d4-a716-446655440000",
|
||||
"title": "Valid Task",
|
||||
"state": "SUCCEEDED",
|
||||
"prompt": "test prompt",
|
||||
"created_at": "2024-01-15T10:00:00Z",
|
||||
"updated_at": "2024-01-15T10:30:00Z",
|
||||
"is_sandbox_running": true
|
||||
},
|
||||
{
|
||||
"task_id": "550e8400-e29b-41d4-a716-446655440001",
|
||||
"title": "Invalid Task",
|
||||
"state": "INPROGRESS"
|
||||
}
|
||||
]
|
||||
}"#;
|
||||
|
||||
let response: ListRunsResponse = serde_json::from_str(json).unwrap();
|
||||
|
||||
// Should only have the valid task
|
||||
assert_eq!(response.runs.len(), 1);
|
||||
assert_eq!(
|
||||
response.runs[0].task_id.to_string(),
|
||||
"550e8400-e29b-41d4-a716-446655440000"
|
||||
);
|
||||
assert_eq!(response.runs[0].title, "Valid Task");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_deserialize_list_tasks_error_and_blocked_states() {
|
||||
let json = r#"{
|
||||
"runs": [
|
||||
{
|
||||
"task_id": "550e8400-e29b-41d4-a716-446655440000",
|
||||
"title": "Errored Task",
|
||||
"state": "ERROR",
|
||||
"prompt": "test prompt",
|
||||
"created_at": "2024-01-15T10:00:00Z",
|
||||
"updated_at": "2024-01-15T10:30:00Z",
|
||||
"is_sandbox_running": false
|
||||
},
|
||||
{
|
||||
"task_id": "550e8400-e29b-41d4-a716-446655440001",
|
||||
"title": "Blocked Task",
|
||||
"state": "BLOCKED",
|
||||
"prompt": "test prompt",
|
||||
"created_at": "2024-01-15T10:00:00Z",
|
||||
"updated_at": "2024-01-15T10:30:00Z",
|
||||
"is_sandbox_running": false
|
||||
}
|
||||
]
|
||||
}"#;
|
||||
|
||||
let response: ListRunsResponse = serde_json::from_str(json).unwrap();
|
||||
assert_eq!(response.runs.len(), 2);
|
||||
assert_eq!(response.runs[0].state, AmbientAgentTaskState::Error);
|
||||
assert_eq!(response.runs[1].state, AmbientAgentTaskState::Blocked);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_deserialize_list_tasks_all_tasks_invalid_returns_empty() {
|
||||
// All tasks are missing required fields
|
||||
let json = r#"{
|
||||
"runs": [
|
||||
{
|
||||
"task_id": "550e8400-e29b-41d4-a716-446655440000",
|
||||
"title": "Missing State"
|
||||
},
|
||||
{
|
||||
"task_id": "550e8400-e29b-41d4-a716-446655440001",
|
||||
"state": "SUCCEEDED"
|
||||
}
|
||||
]
|
||||
}"#;
|
||||
|
||||
let response: ListRunsResponse = serde_json::from_str(json).unwrap();
|
||||
|
||||
// Should return empty list, not fail
|
||||
assert_eq!(response.runs.len(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_deserialize_list_tasks_invalid_state_enum() {
|
||||
// Task with an unknown state enum value
|
||||
let json = r#"{
|
||||
"runs": [
|
||||
{
|
||||
"task_id": "550e8400-e29b-41d4-a716-446655440000",
|
||||
"title": "Valid Task",
|
||||
"state": "SUCCEEDED",
|
||||
"prompt": "test prompt",
|
||||
"created_at": "2024-01-15T10:00:00Z",
|
||||
"updated_at": "2024-01-15T10:30:00Z",
|
||||
"is_sandbox_running": true
|
||||
},
|
||||
{
|
||||
"task_id": "550e8400-e29b-41d4-a716-446655440001",
|
||||
"title": "Task with Invalid State",
|
||||
"state": "INVALID_STATE",
|
||||
"prompt": "test prompt",
|
||||
"created_at": "2024-01-15T10:00:00Z",
|
||||
"updated_at": "2024-01-15T10:30:00Z",
|
||||
"is_sandbox_running": true
|
||||
}
|
||||
]
|
||||
}"#;
|
||||
|
||||
let response: ListRunsResponse = serde_json::from_str(json).unwrap();
|
||||
|
||||
// Unknown states should deserialize to AmbientAgentTaskState::Unknown.
|
||||
assert_eq!(response.runs.len(), 2);
|
||||
assert_eq!(response.runs[0].title, "Valid Task");
|
||||
assert_eq!(response.runs[1].title, "Task with Invalid State");
|
||||
assert_eq!(response.runs[1].state, AmbientAgentTaskState::Unknown);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_deserialize_list_tasks_corrupted_json_in_middle() {
|
||||
// Mix of valid and completely malformed JSON
|
||||
let json = r#"{
|
||||
"runs": [
|
||||
{
|
||||
"task_id": "550e8400-e29b-41d4-a716-446655440000",
|
||||
"title": "First Valid Task",
|
||||
"state": "SUCCEEDED",
|
||||
"prompt": "test prompt",
|
||||
"created_at": "2024-01-15T10:00:00Z",
|
||||
"updated_at": "2024-01-15T10:30:00Z",
|
||||
"is_sandbox_running": true
|
||||
},
|
||||
{
|
||||
"task_id": 12345,
|
||||
"title": 999,
|
||||
"state": true
|
||||
},
|
||||
{
|
||||
"task_id": "550e8400-e29b-41d4-a716-446655440002",
|
||||
"title": "Second Valid Task",
|
||||
"state": "INPROGRESS",
|
||||
"prompt": "test prompt 2",
|
||||
"created_at": "2024-01-15T11:00:00Z",
|
||||
"updated_at": "2024-01-15T11:30:00Z",
|
||||
"is_sandbox_running": false
|
||||
}
|
||||
]
|
||||
}"#;
|
||||
|
||||
let response: ListRunsResponse = serde_json::from_str(json).unwrap();
|
||||
|
||||
// Should have both valid tasks, malformed one skipped
|
||||
assert_eq!(response.runs.len(), 2);
|
||||
assert_eq!(response.runs[0].title, "First Valid Task");
|
||||
assert_eq!(response.runs[1].title, "Second Valid Task");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_deserialize_list_tasks_empty_tasks_array() {
|
||||
// Empty tasks array should work fine
|
||||
let json = r#"{
|
||||
"runs": []
|
||||
}"#;
|
||||
|
||||
let response: ListRunsResponse = serde_json::from_str(json).unwrap();
|
||||
assert_eq!(response.runs.len(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_deserialize_list_tasks_all_tasks_valid() {
|
||||
// Ensure we don't break the happy path
|
||||
let json = r#"{
|
||||
"runs": [
|
||||
{
|
||||
"task_id": "550e8400-e29b-41d4-a716-446655440000",
|
||||
"title": "Task 1",
|
||||
"state": "SUCCEEDED",
|
||||
"prompt": "test prompt",
|
||||
"created_at": "2024-01-15T10:00:00Z",
|
||||
"updated_at": "2024-01-15T10:30:00Z",
|
||||
"is_sandbox_running": true
|
||||
},
|
||||
{
|
||||
"task_id": "550e8400-e29b-41d4-a716-446655440001",
|
||||
"title": "Task 2",
|
||||
"state": "INPROGRESS",
|
||||
"prompt": "test prompt 2",
|
||||
"created_at": "2024-01-15T11:00:00Z",
|
||||
"updated_at": "2024-01-15T11:30:00Z",
|
||||
"is_sandbox_running": false
|
||||
},
|
||||
{
|
||||
"task_id": "550e8400-e29b-41d4-a716-446655440002",
|
||||
"title": "Task 3",
|
||||
"state": "FAILED",
|
||||
"prompt": "test prompt 3",
|
||||
"created_at": "2024-01-15T12:00:00Z",
|
||||
"updated_at": "2024-01-15T12:30:00Z",
|
||||
"is_sandbox_running": false
|
||||
}
|
||||
]
|
||||
}"#;
|
||||
|
||||
let response: ListRunsResponse = serde_json::from_str(json).unwrap();
|
||||
|
||||
// All tasks should be present
|
||||
assert_eq!(response.runs.len(), 3);
|
||||
assert_eq!(response.runs[0].title, "Task 1");
|
||||
assert_eq!(response.runs[1].title, "Task 2");
|
||||
assert_eq!(response.runs[2].title, "Task 3");
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------------------------------------------
|
||||
// We test roundtripping serialize and deserialize since we use this for persisting artifacts for local conversations.
|
||||
// ---------------------------------------------------------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn test_artifact_plan_serialize_deserialize_roundtrip() {
|
||||
let original = Artifact::Plan {
|
||||
document_uid: "doc-123".to_string(),
|
||||
notebook_uid: Some(NotebookId::from("notebook12345678901234".to_string())),
|
||||
title: Some("My Plan".to_string()),
|
||||
};
|
||||
|
||||
let serialized = serde_json::to_string(&original).unwrap();
|
||||
let deserialized: Artifact = serde_json::from_str(&serialized).unwrap();
|
||||
|
||||
assert_eq!(original, deserialized);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_deserialize_agent_message_headers() {
|
||||
let json = r#"[
|
||||
{
|
||||
"message_id": "message-1",
|
||||
"sender_run_id": "run-1",
|
||||
"subject": "Build finished",
|
||||
"sent_at": "2026-04-09T20:00:00Z",
|
||||
"delivered_at": "2026-04-09T20:01:00Z",
|
||||
"read_at": null
|
||||
}
|
||||
]"#;
|
||||
|
||||
let headers: Vec<AgentMessageHeader> = serde_json::from_str(json).unwrap();
|
||||
|
||||
assert_eq!(headers.len(), 1);
|
||||
assert_eq!(headers[0].message_id, "message-1");
|
||||
assert_eq!(headers[0].sender_run_id, "run-1");
|
||||
assert_eq!(headers[0].subject, "Build finished");
|
||||
assert_eq!(headers[0].sent_at, "2026-04-09T20:00:00Z");
|
||||
assert_eq!(
|
||||
headers[0].delivered_at.as_deref(),
|
||||
Some("2026-04-09T20:01:00Z")
|
||||
);
|
||||
assert_eq!(headers[0].read_at, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_deserialize_read_agent_message_response_with_timestamps() {
|
||||
let json = r#"{
|
||||
"message_id": "message-1",
|
||||
"sender_run_id": "run-1",
|
||||
"subject": "Build finished",
|
||||
"body": "Everything passed.",
|
||||
"sent_at": "2026-04-09T20:00:00Z",
|
||||
"delivered_at": "2026-04-09T20:01:00Z",
|
||||
"read_at": "2026-04-09T20:02:00Z"
|
||||
}"#;
|
||||
|
||||
let response: ReadAgentMessageResponse = serde_json::from_str(json).unwrap();
|
||||
|
||||
assert_eq!(response.message_id, "message-1");
|
||||
assert_eq!(response.sender_run_id, "run-1");
|
||||
assert_eq!(response.subject, "Build finished");
|
||||
assert_eq!(response.body, "Everything passed.");
|
||||
assert_eq!(response.sent_at, "2026-04-09T20:00:00Z");
|
||||
assert_eq!(
|
||||
response.delivered_at.as_deref(),
|
||||
Some("2026-04-09T20:01:00Z")
|
||||
);
|
||||
assert_eq!(response.read_at.as_deref(), Some("2026-04-09T20:02:00Z"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_deserialize_agent_run_events_with_optional_fields() {
|
||||
let json = r#"[
|
||||
{
|
||||
"event_type": "run_started",
|
||||
"run_id": "run-1",
|
||||
"ref_id": null,
|
||||
"execution_id": "exec-1",
|
||||
"occurred_at": "2026-04-09T20:00:00Z",
|
||||
"sequence": 7
|
||||
},
|
||||
{
|
||||
"event_type": "new_message",
|
||||
"run_id": "run-2",
|
||||
"ref_id": "message-9",
|
||||
"execution_id": null,
|
||||
"occurred_at": "2026-04-09T20:05:00Z",
|
||||
"sequence": 8
|
||||
}
|
||||
]"#;
|
||||
|
||||
let events: Vec<AgentRunEvent> = serde_json::from_str(json).unwrap();
|
||||
|
||||
assert_eq!(events.len(), 2);
|
||||
assert_eq!(events[0].event_type, "run_started");
|
||||
assert_eq!(events[0].execution_id.as_deref(), Some("exec-1"));
|
||||
assert_eq!(events[0].ref_id, None);
|
||||
assert_eq!(events[0].sequence, 7);
|
||||
assert_eq!(events[1].event_type, "new_message");
|
||||
assert_eq!(events[1].ref_id.as_deref(), Some("message-9"));
|
||||
assert_eq!(events[1].execution_id, None);
|
||||
assert_eq!(events[1].sequence, 8);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_artifact_plan_serialize_deserialize_roundtrip_no_notebook_uid() {
|
||||
let original = Artifact::Plan {
|
||||
document_uid: "doc-123".to_string(),
|
||||
notebook_uid: None,
|
||||
title: Some("My Plan".to_string()),
|
||||
};
|
||||
|
||||
let serialized = serde_json::to_string(&original).unwrap();
|
||||
let deserialized: Artifact = serde_json::from_str(&serialized).unwrap();
|
||||
|
||||
assert_eq!(original, deserialized);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_artifact_pr_serialize_deserialize_roundtrip() {
|
||||
let original = Artifact::PullRequest {
|
||||
url: "https://github.com/org/repo/pull/42".to_string(),
|
||||
branch: "feature-branch".to_string(),
|
||||
repo: Some("repo".to_string()),
|
||||
number: Some(42),
|
||||
};
|
||||
|
||||
let serialized = serde_json::to_string(&original).unwrap();
|
||||
let deserialized: Artifact = serde_json::from_str(&serialized).unwrap();
|
||||
|
||||
// repo/number are re-derived from URL on deserialize, so should match
|
||||
assert_eq!(original, deserialized);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_artifact_file_serialize_deserialize_roundtrip() {
|
||||
let original = Artifact::File {
|
||||
artifact_uid: "artifact-file-1".to_string(),
|
||||
filepath: "outputs/report.txt".to_string(),
|
||||
filename: "report.txt".to_string(),
|
||||
mime_type: "text/plain".to_string(),
|
||||
description: Some("Daily summary".to_string()),
|
||||
size_bytes: Some(42),
|
||||
};
|
||||
|
||||
let serialized = serde_json::to_string(&original).unwrap();
|
||||
let deserialized: Artifact = serde_json::from_str(&serialized).unwrap();
|
||||
|
||||
assert_eq!(original, deserialized);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_artifact_vec_serialize_deserialize_roundtrip() {
|
||||
let original = vec![
|
||||
Artifact::Plan {
|
||||
document_uid: "doc-1".to_string(),
|
||||
notebook_uid: None,
|
||||
title: Some("Plan 1".to_string()),
|
||||
},
|
||||
Artifact::PullRequest {
|
||||
url: "https://github.com/org/repo/pull/1".to_string(),
|
||||
branch: "main".to_string(),
|
||||
repo: Some("repo".to_string()),
|
||||
number: Some(1),
|
||||
},
|
||||
Artifact::File {
|
||||
artifact_uid: "artifact-file-1".to_string(),
|
||||
filepath: "outputs/report.txt".to_string(),
|
||||
filename: "report.txt".to_string(),
|
||||
mime_type: "text/plain".to_string(),
|
||||
description: Some("Daily summary".to_string()),
|
||||
size_bytes: Some(42),
|
||||
},
|
||||
];
|
||||
|
||||
let serialized = serde_json::to_string(&original).unwrap();
|
||||
let deserialized: Vec<Artifact> = serde_json::from_str(&serialized).unwrap();
|
||||
|
||||
assert_eq!(original, deserialized);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_list_agent_runs_url_empty_filter() {
|
||||
let url = build_list_agent_runs_url(10, &TaskListFilter::default());
|
||||
assert_eq!(url, "agent/runs?limit=10");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_list_agent_runs_url_all_fields() {
|
||||
let filter = TaskListFilter {
|
||||
creator_uid: Some("user-uid".to_string()),
|
||||
updated_after: Some(Utc.with_ymd_and_hms(2026, 4, 3, 12, 30, 0).unwrap()),
|
||||
created_after: Some(Utc.with_ymd_and_hms(2026, 4, 1, 0, 0, 0).unwrap()),
|
||||
created_before: Some(Utc.with_ymd_and_hms(2026, 4, 2, 0, 0, 0).unwrap()),
|
||||
states: Some(vec![
|
||||
AmbientAgentTaskState::Failed,
|
||||
AmbientAgentTaskState::Error,
|
||||
]),
|
||||
source: Some(AgentSource::AgentWebhook),
|
||||
execution_location: Some(ExecutionLocation::Remote),
|
||||
environment_id: Some("env-123".to_string()),
|
||||
skill_spec: Some("owner/repo:SKILL.md".to_string()),
|
||||
schedule_id: Some("sched-1".to_string()),
|
||||
ancestor_run_id: Some("run-parent".to_string()),
|
||||
config_name: Some("nightly".to_string()),
|
||||
model_id: Some("claude-4-5".to_string()),
|
||||
artifact_type: Some(ArtifactType::PullRequest),
|
||||
search_query: Some("oz run".to_string()),
|
||||
sort_by: Some(RunSortBy::CreatedAt),
|
||||
sort_order: Some(RunSortOrder::Asc),
|
||||
cursor: Some("abcd==".to_string()),
|
||||
};
|
||||
|
||||
let url = build_list_agent_runs_url(42, &filter);
|
||||
assert_eq!(
|
||||
url,
|
||||
"agent/runs?limit=42\
|
||||
&creator=user-uid\
|
||||
&updated_after=2026-04-03T12%3A30%3A00%2B00%3A00\
|
||||
&created_after=2026-04-01T00%3A00%3A00%2B00%3A00\
|
||||
&created_before=2026-04-02T00%3A00%3A00%2B00%3A00\
|
||||
&state=FAILED\
|
||||
&state=ERROR\
|
||||
&source=API\
|
||||
&execution_location=REMOTE\
|
||||
&environment_id=env-123\
|
||||
&skill_spec=owner%2Frepo%3ASKILL.md\
|
||||
&schedule_id=sched-1\
|
||||
&ancestor_run_id=run-parent\
|
||||
&name=nightly\
|
||||
&model_id=claude-4-5\
|
||||
&artifact_type=PULL_REQUEST\
|
||||
&q=oz%20run\
|
||||
&sort_by=created_at\
|
||||
&sort_order=asc\
|
||||
&cursor=abcd%3D%3D"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_list_agent_runs_url_repeats_state_filter() {
|
||||
let filter = TaskListFilter {
|
||||
states: Some(vec![
|
||||
AmbientAgentTaskState::Queued,
|
||||
AmbientAgentTaskState::InProgress,
|
||||
AmbientAgentTaskState::Succeeded,
|
||||
]),
|
||||
..TaskListFilter::default()
|
||||
};
|
||||
let url = build_list_agent_runs_url(5, &filter);
|
||||
assert_eq!(
|
||||
url,
|
||||
"agent/runs?limit=5&state=QUEUED&state=INPROGRESS&state=SUCCEEDED"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_list_agent_runs_url_skips_unknown_state() {
|
||||
// The deserializer keeps `Unknown` for forward compatibility, but we shouldn't send it to
|
||||
// the server as a filter value.
|
||||
let filter = TaskListFilter {
|
||||
states: Some(vec![
|
||||
AmbientAgentTaskState::Unknown,
|
||||
AmbientAgentTaskState::Succeeded,
|
||||
]),
|
||||
..TaskListFilter::default()
|
||||
};
|
||||
let url = build_list_agent_runs_url(1, &filter);
|
||||
assert_eq!(url, "agent/runs?limit=1&state=SUCCEEDED");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_list_agent_runs_url_routes_to_runs_not_tasks() {
|
||||
let url = build_list_agent_runs_url(10, &TaskListFilter::default());
|
||||
assert!(url.starts_with("agent/runs?"));
|
||||
assert!(!url.starts_with("agent/tasks"));
|
||||
}
|
||||
@@ -0,0 +1,921 @@
|
||||
use std::{result::Result as StdResult, sync::Arc};
|
||||
|
||||
use anyhow::{anyhow, bail, Context as _, Result};
|
||||
use async_trait::async_trait;
|
||||
use cynic::{MutationBuilder, QueryBuilder};
|
||||
use firebase::{FetchAccessTokenResponse, FirebaseError};
|
||||
use futures::FutureExt;
|
||||
use instant::Duration;
|
||||
#[cfg(test)]
|
||||
use mockall::{automock, predicate::*};
|
||||
use oauth2::TokenResponse;
|
||||
use thiserror::Error;
|
||||
use warp_core::errors::{AnyhowErrorExt, ErrorExt};
|
||||
use warp_graphql::client::Operation;
|
||||
use warp_graphql::mutations::expire_api_key::{
|
||||
ExpireApiKey, ExpireApiKeyResult, ExpireApiKeyVariables,
|
||||
};
|
||||
use warp_graphql::queries::get_conversation_usage::{
|
||||
ConversationUsage, GetConversationUsage, GetConversationUsageVariables, UserResult,
|
||||
};
|
||||
|
||||
use warp_graphql::mutations::set_user_is_onboarded::{
|
||||
SetUserIsOnboarded, SetUserIsOnboardedResult, SetUserIsOnboardedVariables,
|
||||
};
|
||||
use warp_graphql::mutations::update_user_settings::{
|
||||
UpdateUserSettings, UpdateUserSettingsInput, UpdateUserSettingsResult,
|
||||
UpdateUserSettingsVariables,
|
||||
};
|
||||
use warp_graphql::mutations::{
|
||||
create_anonymous_user::{
|
||||
AnonymousUserType, CreateAnonymousUser, CreateAnonymousUserResult,
|
||||
CreateAnonymousUserVariables,
|
||||
},
|
||||
generate_api_key::{
|
||||
GenerateApiKey, GenerateApiKeyInput, GenerateApiKeyResult, GenerateApiKeyVariables,
|
||||
},
|
||||
mint_custom_token::{MintCustomTokenResult, MintCustomTokenVariables},
|
||||
};
|
||||
use warp_graphql::object_permissions::OwnerType;
|
||||
use warp_graphql::queries::api_keys::{
|
||||
ApiKeyProperties, ApiKeyPropertiesResult, ApiKeys, ApiKeysVariables,
|
||||
};
|
||||
use warp_graphql::queries::get_user::{GetUser, GetUserVariables, UserOutput as GqlUserOutput};
|
||||
use warp_graphql::queries::get_user_settings::{GetUserSettings, GetUserSettingsVariables};
|
||||
use warpui::r#async::BoxFuture;
|
||||
|
||||
use crate::auth::UserUid;
|
||||
use crate::server::graphql::{default_request_options, get_user_facing_error_message};
|
||||
use crate::server::ids::ApiKeyUid;
|
||||
use crate::server::server_api::register_error;
|
||||
use crate::server::server_api::EXPERIMENT_ID_HEADER;
|
||||
use crate::settings::PrivacySettingsSnapshot;
|
||||
use crate::{
|
||||
auth::{
|
||||
credentials::{AuthToken, Credentials, FirebaseToken, LoginToken, RefreshToken},
|
||||
user::FirebaseAuthTokens,
|
||||
user::User,
|
||||
},
|
||||
channel::ChannelState,
|
||||
convert_to_server_experiment,
|
||||
server::{
|
||||
datetime_ext::DateTimeExt as _, experiments::ServerExperiment,
|
||||
graphql::get_request_context, server_api::ServerApiEvent,
|
||||
},
|
||||
};
|
||||
|
||||
use super::ServerApi;
|
||||
|
||||
/// Error messages returned from the Firebase REST API when attempting to convert a refresh token
|
||||
/// into an access token that indicate the user's token is in an errored state.
|
||||
/// These are "soft" errors because the user likely just needs to log in again.
|
||||
/// See https://firebase.google.com/docs/reference/rest/auth#section-refresh-token.
|
||||
static FETCH_ACCESS_TOKEN_SOFT_ERROR_MESSAGES: &[&str] = &[
|
||||
"TOKEN_EXPIRED",
|
||||
"INVALID_REFRESH_TOKEN",
|
||||
"MISSING_REFRESH_TOKEN",
|
||||
];
|
||||
|
||||
/// Error messages returned from the Firebase REST API when attempting to convert a refresh token
|
||||
/// into an access token that indicate the user's account is in an errored state.
|
||||
/// These are "hard" errors because the user likely can no longer sign in with their account,
|
||||
/// for example if it were disabled or deleted.
|
||||
/// See https://firebase.google.com/docs/reference/rest/auth#section-refresh-token.
|
||||
static FETCH_ACCESS_TOKEN_HARD_ERROR_MESSAGES: &[&str] = &["USER_DISABLED", "USER_NOT_FOUND"];
|
||||
|
||||
const FETCH_ACCESS_TOKEN_TIMEOUT: Duration = Duration::from_secs(5);
|
||||
|
||||
/// Header key for the ambient workload token attached to multi-agent requests.
|
||||
pub const AMBIENT_WORKLOAD_TOKEN_HEADER: &str = "X-Warp-Ambient-Workload-Token";
|
||||
|
||||
/// Header key for the cloud agent task ID attached to requests from ambient agents.
|
||||
pub const CLOUD_AGENT_ID_HEADER: &str = "X-Warp-Cloud-Agent-ID";
|
||||
|
||||
/// Duration for which the ambient workload token is valid (3 hours).
|
||||
const AMBIENT_WORKLOAD_TOKEN_DURATION: Duration = Duration::from_secs(3 * 60 * 60);
|
||||
|
||||
/// User settings that are currently 'synced' (e.g. stored server-side) on a per-user basis.
|
||||
#[derive(Copy, Clone, Debug, Default)]
|
||||
pub struct SyncedUserSettings {
|
||||
pub is_cloud_conversation_storage_enabled: bool,
|
||||
pub is_crash_reporting_enabled: bool,
|
||||
pub is_telemetry_enabled: bool,
|
||||
}
|
||||
|
||||
/// Results of an attempt to fetch the current user.
|
||||
pub struct FetchUserResult {
|
||||
pub user: User,
|
||||
/// The credentials used to authenticate this user.
|
||||
pub credentials: Credentials,
|
||||
pub server_experiments: Vec<ServerExperiment>,
|
||||
/// Whether this attempt to fetch the user was for refreshing an existing logged-in user.
|
||||
pub from_refresh: bool,
|
||||
/// LLM model choices for this user.
|
||||
pub llms: crate::ai::llms::ModelsByFeature,
|
||||
}
|
||||
|
||||
#[cfg_attr(test, automock)]
|
||||
#[cfg_attr(not(target_family = "wasm"), async_trait)]
|
||||
#[cfg_attr(target_family = "wasm", async_trait(?Send))]
|
||||
pub trait AuthClient: 'static + Send + Sync {
|
||||
/// Creates an anonymous user, who is allowed to use Warp but may lack the ability
|
||||
/// to interact with particular features.
|
||||
async fn create_anonymous_user(
|
||||
&self,
|
||||
referral_code: Option<String>,
|
||||
anonymous_user_type: AnonymousUserType,
|
||||
) -> Result<CreateAnonymousUserResult>;
|
||||
|
||||
/// Returns the cached access token, if it is still valid. If it has expired, fetches a new
|
||||
/// access token using the user's refresh token, caches it, and the returns it.
|
||||
/// Returns an auth mode that may not require an Authorization header (e.g. session cookies or
|
||||
/// test credentials).
|
||||
async fn get_or_refresh_access_token(&self) -> Result<AuthToken>;
|
||||
|
||||
/// Fetches data required to construct the [`User`] object. This includes the user's metadata
|
||||
/// and authentication tokens.
|
||||
async fn fetch_user(
|
||||
&self,
|
||||
token: LoginToken,
|
||||
for_refresh: bool,
|
||||
) -> StdResult<FetchUserResult, UserAuthenticationError>;
|
||||
|
||||
/// Creates and fetches an new custom token for the current user from Firebase.
|
||||
/// This only works for anonymous users, and will surface an error if the user is not anonymous.
|
||||
async fn fetch_new_custom_token(&self) -> Result<MintCustomTokenResult>;
|
||||
|
||||
/// Handles the response from [`Self::fetch_new_custom_token`], returning the newly-minted custom token.
|
||||
fn on_custom_token_fetched(
|
||||
&self,
|
||||
response: Result<MintCustomTokenResult>,
|
||||
) -> Result<String, MintCustomTokenError>;
|
||||
|
||||
/// Queries warp-server for a set of the currently logged-in user's fields.
|
||||
async fn fetch_user_properties<'a>(&self, auth_token: Option<&'a str>)
|
||||
-> Result<GqlUserOutput>;
|
||||
|
||||
/// Upon success, returns an `Option` containing the user's settings retrieved from the server,
|
||||
/// if any. The user may not have server-side settings if they onboarded prior to the launch
|
||||
/// of telemetry opt-out, have not logged in since the launch, and have never changed defaults
|
||||
/// for any of the settings in [`SyncedUserSettings`]. If the fetched settings object exists
|
||||
/// but is missing required fields, or if the request itself failed, returns an error.
|
||||
async fn get_user_settings(&self) -> Result<Option<SyncedUserSettings>>;
|
||||
|
||||
/// Returns conversation usage history for the current user over the past n days.
|
||||
/// If last_updated_end_timestamp is provided, only conversations with
|
||||
/// lastUpdated earlier than this timestamp are returned.
|
||||
async fn get_conversation_usage_history(
|
||||
&self,
|
||||
days: Option<i32>,
|
||||
limit: Option<i32>,
|
||||
last_updated_end_timestamp: Option<warp_graphql::scalars::Time>,
|
||||
) -> Result<Vec<ConversationUsage>>;
|
||||
|
||||
async fn set_is_telemetry_enabled(&self, value: bool) -> Result<()>;
|
||||
|
||||
async fn set_is_crash_reporting_enabled(&self, value: bool) -> Result<()>;
|
||||
|
||||
async fn set_is_cloud_conversation_storage_enabled(&self, value: bool) -> Result<()>;
|
||||
|
||||
/// Sends a request to update the user's settings on the server with values contained in the
|
||||
/// given `settings_snapshot`.
|
||||
async fn update_user_settings(&self, settings_snapshot: PrivacySettingsSnapshot) -> Result<()>;
|
||||
|
||||
async fn set_user_is_onboarded(&self) -> Result<bool>;
|
||||
|
||||
/// Requests a device authorization code from the server. This is only used for headless CLI/SDK authentication.
|
||||
async fn request_device_code(
|
||||
&self,
|
||||
) -> StdResult<oauth2::StandardDeviceAuthorizationResponse, UserAuthenticationError>;
|
||||
|
||||
/// Wait for the request to be approved or rejected and exchange it for a short-lived custom access token.
|
||||
async fn exchange_device_access_token(
|
||||
&self,
|
||||
details: &oauth2::StandardDeviceAuthorizationResponse,
|
||||
timeout: Duration,
|
||||
) -> StdResult<FirebaseToken, UserAuthenticationError>;
|
||||
// API Keys
|
||||
async fn list_api_keys(&self) -> Result<Vec<ApiKeyProperties>>;
|
||||
|
||||
async fn create_api_key(
|
||||
&self,
|
||||
name: String,
|
||||
team_id: Option<cynic::Id>,
|
||||
expires_at: Option<warp_graphql::scalars::Time>,
|
||||
) -> Result<GenerateApiKeyResult>;
|
||||
|
||||
async fn expire_api_key(&self, key_uid: &ApiKeyUid) -> Result<ExpireApiKeyResult>;
|
||||
|
||||
/// Returns a cached ambient workload token, or issues a new one if not present or expired.
|
||||
///
|
||||
/// Returns `Ok(None)` if not running in an isolation platform (e.g., Namespace) or on WASM.
|
||||
async fn get_or_create_ambient_workload_token(&self) -> Result<Option<String>>;
|
||||
}
|
||||
|
||||
#[cfg_attr(not(target_family = "wasm"), async_trait)]
|
||||
#[cfg_attr(target_family = "wasm", async_trait(?Send))]
|
||||
impl AuthClient for ServerApi {
|
||||
async fn create_anonymous_user(
|
||||
&self,
|
||||
referral_code: Option<String>,
|
||||
anonymous_user_type: AnonymousUserType,
|
||||
) -> Result<CreateAnonymousUserResult> {
|
||||
let variables = CreateAnonymousUserVariables {
|
||||
input: warp_graphql::mutations::create_anonymous_user::CreateAnonymousUserInput {
|
||||
anonymous_user_type,
|
||||
expiration_type: warp_graphql::mutations::create_anonymous_user::AnonymousUserExpirationType::NoExpiration,
|
||||
referral_code,
|
||||
},
|
||||
request_context: get_request_context(),
|
||||
};
|
||||
|
||||
let operation = CreateAnonymousUser::build(variables);
|
||||
let response = operation
|
||||
.send_request(self.client.clone(), default_request_options())
|
||||
.await?;
|
||||
|
||||
Ok(response
|
||||
.data
|
||||
.ok_or_else(|| anyhow!("missing data in response"))?
|
||||
.create_anonymous_user)
|
||||
}
|
||||
|
||||
async fn get_or_refresh_access_token(&self) -> Result<AuthToken> {
|
||||
if cfg!(feature = "skip_login") {
|
||||
bail!("skip_login enabled; failing all authenticated requests");
|
||||
}
|
||||
|
||||
let Some(credentials) = self.auth_state.credentials() else {
|
||||
bail!("Attempted to retrieve access token when user is logged out");
|
||||
};
|
||||
|
||||
match credentials {
|
||||
Credentials::ApiKey { key, .. } => Ok(AuthToken::ApiKey(key)),
|
||||
Credentials::Firebase(auth_tokens) => {
|
||||
let expiration_time = auth_tokens.expiration_time;
|
||||
|
||||
// Generate a new ID token if the token has expired or will expire in the
|
||||
// next five minutes. This matches the behavior of the Firebase Auth SDK.
|
||||
if chrono::DateTime::now() + chrono::Duration::minutes(5) >= expiration_time {
|
||||
let refresh_token = auth_tokens.refresh_token.clone();
|
||||
let firebase_token = FirebaseToken::Refresh(RefreshToken::new(refresh_token));
|
||||
|
||||
let result = fetch_auth_tokens(self.client.clone(), firebase_token).await;
|
||||
|
||||
if let Err(UserAuthenticationError::DeniedAccessToken(_)) = result {
|
||||
let _ = self.event_sender.send(ServerApiEvent::NeedsReauth).await;
|
||||
}
|
||||
let new_firebase_token_info = result?;
|
||||
self.auth_state
|
||||
.update_firebase_tokens(new_firebase_token_info.clone());
|
||||
return Ok(AuthToken::Firebase(new_firebase_token_info.id_token));
|
||||
}
|
||||
|
||||
Ok(AuthToken::Firebase(auth_tokens.id_token))
|
||||
}
|
||||
Credentials::SessionCookie => Ok(AuthToken::NoAuth),
|
||||
#[cfg(any(test, feature = "integration_tests", feature = "skip_login"))]
|
||||
Credentials::Test => Ok(AuthToken::NoAuth),
|
||||
}
|
||||
}
|
||||
|
||||
async fn fetch_user(
|
||||
&self,
|
||||
token: LoginToken,
|
||||
for_refresh: bool,
|
||||
) -> StdResult<FetchUserResult, UserAuthenticationError> {
|
||||
let new_credentials = exchange_credentials(self.client.clone(), token).await?;
|
||||
let auth_token = new_credentials.bearer_token();
|
||||
let user_output = self
|
||||
.fetch_user_properties(auth_token.as_bearer_token())
|
||||
.await
|
||||
.context("Failed to fetch user response data")
|
||||
.map_err(UserAuthenticationError::Unexpected)?;
|
||||
|
||||
let UserProperties {
|
||||
user,
|
||||
server_experiments,
|
||||
llms,
|
||||
api_key_owner_type,
|
||||
} = user_output.into();
|
||||
|
||||
// Store the owner type if using an API key.
|
||||
let new_credentials = match new_credentials {
|
||||
Credentials::ApiKey { key, .. } => Credentials::ApiKey {
|
||||
key,
|
||||
owner_type: api_key_owner_type,
|
||||
},
|
||||
other => other,
|
||||
};
|
||||
|
||||
Ok(FetchUserResult {
|
||||
user,
|
||||
credentials: new_credentials,
|
||||
server_experiments,
|
||||
from_refresh: for_refresh,
|
||||
llms,
|
||||
})
|
||||
}
|
||||
|
||||
async fn fetch_new_custom_token(&self) -> Result<MintCustomTokenResult> {
|
||||
let variables = MintCustomTokenVariables {
|
||||
request_context: get_request_context(),
|
||||
};
|
||||
|
||||
let operation =
|
||||
warp_graphql::mutations::mint_custom_token::MintCustomToken::build(variables);
|
||||
let response = self.send_graphql_request(operation, None).await?;
|
||||
Ok(response.mint_custom_token)
|
||||
}
|
||||
|
||||
fn on_custom_token_fetched(
|
||||
&self,
|
||||
response: Result<MintCustomTokenResult>,
|
||||
) -> Result<String, MintCustomTokenError> {
|
||||
match response {
|
||||
Ok(response_data) => match response_data {
|
||||
MintCustomTokenResult::MintCustomTokenOutput(output) => Ok(output.custom_token),
|
||||
MintCustomTokenResult::UserFacingError(user_facing_error) => {
|
||||
Err(MintCustomTokenError::UserFacingError(
|
||||
get_user_facing_error_message(user_facing_error),
|
||||
))
|
||||
}
|
||||
MintCustomTokenResult::Unknown => Err(MintCustomTokenError::Unknown),
|
||||
},
|
||||
Err(_) => Err(MintCustomTokenError::Unknown),
|
||||
}
|
||||
}
|
||||
|
||||
async fn fetch_user_properties<'a>(
|
||||
&self,
|
||||
auth_token: Option<&'a str>,
|
||||
) -> Result<GqlUserOutput> {
|
||||
let variables = GetUserVariables {
|
||||
request_context: get_request_context(),
|
||||
};
|
||||
let operation = GetUser::build(variables);
|
||||
let response = operation
|
||||
.send_request(
|
||||
self.client.clone(),
|
||||
warp_graphql::client::RequestOptions {
|
||||
auth_token: auth_token.map(ToOwned::to_owned),
|
||||
headers: std::collections::HashMap::from([(
|
||||
EXPERIMENT_ID_HEADER.to_string(),
|
||||
self.auth_state.anonymous_id(),
|
||||
)]),
|
||||
..default_request_options()
|
||||
},
|
||||
)
|
||||
.await?
|
||||
.data
|
||||
.ok_or_else(|| anyhow!("Expected valid response.data"))?;
|
||||
|
||||
match response.user {
|
||||
warp_graphql::queries::get_user::UserResult::UserOutput(user_output) => Ok(user_output),
|
||||
warp_graphql::queries::get_user::UserResult::Unknown => {
|
||||
Err(anyhow!("Unable to fetch user"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn get_user_settings(&self) -> Result<Option<SyncedUserSettings>> {
|
||||
let variables = GetUserSettingsVariables {
|
||||
request_context: get_request_context(),
|
||||
};
|
||||
let operation = GetUserSettings::build(variables);
|
||||
let response = self.send_graphql_request(operation, None).await?;
|
||||
|
||||
match response.user {
|
||||
warp_graphql::queries::get_user_settings::UserResult::UserOutput(user_output) => {
|
||||
match user_output.user.settings {
|
||||
Some(user_settings) => Ok(Some(SyncedUserSettings {
|
||||
is_cloud_conversation_storage_enabled: user_settings
|
||||
.is_cloud_conversation_storage_enabled,
|
||||
is_crash_reporting_enabled: user_settings.is_crash_reporting_enabled,
|
||||
is_telemetry_enabled: user_settings.is_telemetry_enabled,
|
||||
})),
|
||||
None => Ok(None),
|
||||
}
|
||||
}
|
||||
warp_graphql::queries::get_user_settings::UserResult::Unknown => {
|
||||
Err(anyhow!("Unable to fetch user settings"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Returns a history of the current user's conversation usage over the past n days.
|
||||
async fn get_conversation_usage_history(
|
||||
&self,
|
||||
days: Option<i32>,
|
||||
limit: Option<i32>,
|
||||
last_updated_end_timestamp: Option<warp_graphql::scalars::Time>,
|
||||
) -> Result<Vec<ConversationUsage>> {
|
||||
let operation = GetConversationUsage::build(GetConversationUsageVariables {
|
||||
request_context: get_request_context(),
|
||||
days,
|
||||
limit,
|
||||
last_updated_end_timestamp,
|
||||
});
|
||||
let response = self.send_graphql_request(operation, None).await?;
|
||||
match response.user {
|
||||
UserResult::UserOutput(out) => Ok(out.user.conversation_usage),
|
||||
UserResult::Unknown => Err(anyhow!("Unable to fetch conversation usage")),
|
||||
}
|
||||
}
|
||||
|
||||
async fn set_is_telemetry_enabled(&self, value: bool) -> Result<()> {
|
||||
let variables = UpdateUserSettingsVariables {
|
||||
input: UpdateUserSettingsInput {
|
||||
telemetry_enabled: Some(value),
|
||||
..Default::default()
|
||||
},
|
||||
request_context: get_request_context(),
|
||||
};
|
||||
|
||||
let operation = UpdateUserSettings::build(variables);
|
||||
let result = self
|
||||
.send_graphql_request(operation, None)
|
||||
.await?
|
||||
.update_user_settings;
|
||||
|
||||
match result {
|
||||
UpdateUserSettingsResult::UpdateUserSettingsOutput(_) => Ok(()),
|
||||
UpdateUserSettingsResult::UserFacingError(user_facing_error) => {
|
||||
Err(anyhow!(get_user_facing_error_message(user_facing_error)))
|
||||
}
|
||||
UpdateUserSettingsResult::Unknown => Err(anyhow!("failed to set telemetry enabled")),
|
||||
}
|
||||
}
|
||||
|
||||
async fn set_is_crash_reporting_enabled(&self, value: bool) -> Result<()> {
|
||||
let variables = UpdateUserSettingsVariables {
|
||||
input: UpdateUserSettingsInput {
|
||||
crash_reporting_enabled: Some(value),
|
||||
..Default::default()
|
||||
},
|
||||
request_context: get_request_context(),
|
||||
};
|
||||
|
||||
let operation = UpdateUserSettings::build(variables);
|
||||
let result = self
|
||||
.send_graphql_request(operation, None)
|
||||
.await?
|
||||
.update_user_settings;
|
||||
|
||||
match result {
|
||||
UpdateUserSettingsResult::UpdateUserSettingsOutput(_) => Ok(()),
|
||||
UpdateUserSettingsResult::UserFacingError(user_facing_error) => {
|
||||
Err(anyhow!(get_user_facing_error_message(user_facing_error)))
|
||||
}
|
||||
UpdateUserSettingsResult::Unknown => {
|
||||
Err(anyhow!("failed to set crash reporting enabled"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn set_is_cloud_conversation_storage_enabled(&self, value: bool) -> Result<()> {
|
||||
let variables = UpdateUserSettingsVariables {
|
||||
input: UpdateUserSettingsInput {
|
||||
cloud_conversation_storage_enabled: Some(value),
|
||||
..Default::default()
|
||||
},
|
||||
request_context: get_request_context(),
|
||||
};
|
||||
|
||||
let operation = UpdateUserSettings::build(variables);
|
||||
let result = self
|
||||
.send_graphql_request(operation, None)
|
||||
.await?
|
||||
.update_user_settings;
|
||||
|
||||
match result {
|
||||
UpdateUserSettingsResult::UpdateUserSettingsOutput(_) => Ok(()),
|
||||
UpdateUserSettingsResult::UserFacingError(user_facing_error) => {
|
||||
Err(anyhow!(get_user_facing_error_message(user_facing_error)))
|
||||
}
|
||||
UpdateUserSettingsResult::Unknown => {
|
||||
Err(anyhow!("failed to set cloud conversation storage enabled"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn update_user_settings(&self, settings_snapshot: PrivacySettingsSnapshot) -> Result<()> {
|
||||
let variables = UpdateUserSettingsVariables {
|
||||
input: UpdateUserSettingsInput {
|
||||
telemetry_enabled: Some(settings_snapshot.is_telemetry_enabled()),
|
||||
crash_reporting_enabled: Some(settings_snapshot.is_crash_reporting_enabled()),
|
||||
cloud_conversation_storage_enabled: settings_snapshot
|
||||
.cloud_conversation_storage_enabled(),
|
||||
},
|
||||
request_context: get_request_context(),
|
||||
};
|
||||
|
||||
let operation = UpdateUserSettings::build(variables);
|
||||
let result = self
|
||||
.send_graphql_request(operation, None)
|
||||
.await?
|
||||
.update_user_settings;
|
||||
|
||||
match result {
|
||||
UpdateUserSettingsResult::UpdateUserSettingsOutput(_) => Ok(()),
|
||||
UpdateUserSettingsResult::UserFacingError(user_facing_error) => {
|
||||
Err(anyhow!(get_user_facing_error_message(user_facing_error)))
|
||||
}
|
||||
UpdateUserSettingsResult::Unknown => Err(anyhow!("failed to update user settings")),
|
||||
}
|
||||
}
|
||||
|
||||
async fn set_user_is_onboarded(&self) -> Result<bool> {
|
||||
let variables = SetUserIsOnboardedVariables {
|
||||
request_context: get_request_context(),
|
||||
};
|
||||
|
||||
let operation = SetUserIsOnboarded::build(variables);
|
||||
let result = self
|
||||
.send_graphql_request(operation, None)
|
||||
.await?
|
||||
.set_user_is_onboarded;
|
||||
|
||||
match result {
|
||||
SetUserIsOnboardedResult::SetUserIsOnboardedOutput(_) => Ok(true),
|
||||
SetUserIsOnboardedResult::UserFacingError(user_facing_error) => {
|
||||
Err(anyhow!(get_user_facing_error_message(user_facing_error)))
|
||||
}
|
||||
SetUserIsOnboardedResult::Unknown => Err(anyhow!("failed to set user is onboarded")),
|
||||
}
|
||||
}
|
||||
|
||||
async fn request_device_code(
|
||||
&self,
|
||||
) -> StdResult<oauth2::StandardDeviceAuthorizationResponse, UserAuthenticationError> {
|
||||
self.oauth_client
|
||||
.exchange_device_code()
|
||||
.request_async(self.client.as_ref())
|
||||
.await
|
||||
.context("Failed to generate device code")
|
||||
.map_err(UserAuthenticationError::Unexpected)
|
||||
}
|
||||
|
||||
async fn exchange_device_access_token(
|
||||
&self,
|
||||
details: &oauth2::StandardDeviceAuthorizationResponse,
|
||||
timeout: Duration,
|
||||
) -> StdResult<FirebaseToken, UserAuthenticationError> {
|
||||
let result = self
|
||||
.oauth_client
|
||||
.exchange_device_access_token(details)
|
||||
.request_async(
|
||||
self.client.as_ref(),
|
||||
|delay| warpui::r#async::Timer::after(delay).map(|_| ()),
|
||||
Some(timeout),
|
||||
)
|
||||
.await
|
||||
.context("Unable to obtain access token")
|
||||
.map_err(UserAuthenticationError::Unexpected)?;
|
||||
|
||||
// Firebase doesn't directly support the device flow. Instead, the server mints a short-lived
|
||||
// custom access token, which we can then exchange for a refresh token.
|
||||
Ok(FirebaseToken::Custom(
|
||||
result.access_token().secret().to_string(),
|
||||
))
|
||||
}
|
||||
|
||||
// API Keys
|
||||
async fn list_api_keys(&self) -> Result<Vec<ApiKeyProperties>> {
|
||||
let variables = ApiKeysVariables {
|
||||
request_context: get_request_context(),
|
||||
};
|
||||
let operation = ApiKeys::build(variables);
|
||||
let response = self.send_graphql_request(operation, None).await?;
|
||||
match response.api_keys {
|
||||
ApiKeyPropertiesResult::ApiKeyPropertiesOutput(output) => Ok(output.api_keys),
|
||||
ApiKeyPropertiesResult::UserFacingError(e) => {
|
||||
Err(anyhow!(get_user_facing_error_message(e)))
|
||||
}
|
||||
ApiKeyPropertiesResult::Unknown => Err(anyhow!("failed to fetch API keys")),
|
||||
}
|
||||
}
|
||||
|
||||
async fn create_api_key(
|
||||
&self,
|
||||
name: String,
|
||||
team_id: Option<cynic::Id>,
|
||||
expires_at: Option<warp_graphql::scalars::Time>,
|
||||
) -> Result<GenerateApiKeyResult> {
|
||||
let variables = GenerateApiKeyVariables {
|
||||
input: GenerateApiKeyInput {
|
||||
name,
|
||||
team_id,
|
||||
expires_at,
|
||||
},
|
||||
request_context: get_request_context(),
|
||||
};
|
||||
let operation = GenerateApiKey::build(variables);
|
||||
let response = self.send_graphql_request(operation, None).await?;
|
||||
Ok(response.generate_api_key)
|
||||
}
|
||||
async fn expire_api_key(&self, key_uid: &ApiKeyUid) -> Result<ExpireApiKeyResult> {
|
||||
let variables = ExpireApiKeyVariables {
|
||||
key_uid: key_uid.into(),
|
||||
request_context: get_request_context(),
|
||||
};
|
||||
let op = ExpireApiKey::build(variables);
|
||||
let res = self.send_graphql_request(op, None).await?;
|
||||
Ok(res.expire_api_key)
|
||||
}
|
||||
|
||||
async fn get_or_create_ambient_workload_token(&self) -> Result<Option<String>> {
|
||||
if cfg!(target_family = "wasm") {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
// Check if we have a cached token that's still valid (with 5 minute buffer).
|
||||
// Tokens without an expiration time are always considered valid.
|
||||
{
|
||||
let cached = self.ambient_workload_token.lock();
|
||||
if let Some(ref token) = *cached {
|
||||
let is_valid = token.expires_at.is_none_or(|expires_at| {
|
||||
chrono::Utc::now() + chrono::Duration::minutes(5) < expires_at
|
||||
});
|
||||
if is_valid {
|
||||
return Ok(Some(token.token.clone()));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Issue a new token.
|
||||
let workload_token = match warp_isolation_platform::issue_workload_token(Some(
|
||||
AMBIENT_WORKLOAD_TOKEN_DURATION,
|
||||
))
|
||||
.await
|
||||
{
|
||||
Ok(token) => token,
|
||||
Err(warp_isolation_platform::IsolationPlatformError::NoIsolationPlatformDetected) => {
|
||||
return Ok(None);
|
||||
}
|
||||
Err(e) => return Err(e.into()),
|
||||
};
|
||||
|
||||
let token_str = workload_token.token.clone();
|
||||
|
||||
{
|
||||
let mut cached = self.ambient_workload_token.lock();
|
||||
*cached = Some(workload_token);
|
||||
}
|
||||
|
||||
Ok(Some(token_str))
|
||||
}
|
||||
}
|
||||
|
||||
/// Exchange a long-lived token for fresh [`Credentials`].
|
||||
async fn exchange_credentials(
|
||||
client: Arc<http_client::Client>,
|
||||
token: LoginToken,
|
||||
) -> StdResult<Credentials, UserAuthenticationError> {
|
||||
match token {
|
||||
LoginToken::Firebase(firebase_token) => {
|
||||
let tokens = fetch_auth_tokens(client, firebase_token).await?;
|
||||
Ok(Credentials::Firebase(tokens))
|
||||
}
|
||||
LoginToken::ApiKey(key) => Ok(Credentials::ApiKey {
|
||||
key,
|
||||
owner_type: None,
|
||||
}),
|
||||
LoginToken::SessionCookie => Ok(Credentials::SessionCookie),
|
||||
}
|
||||
}
|
||||
|
||||
fn fetch_auth_tokens(
|
||||
client: Arc<http_client::Client>,
|
||||
token: FirebaseToken,
|
||||
) -> BoxFuture<'static, StdResult<FirebaseAuthTokens, UserAuthenticationError>> {
|
||||
Box::pin(async move {
|
||||
let firebase_api_key = ChannelState::firebase_api_key();
|
||||
let url = token.access_token_url(&firebase_api_key);
|
||||
let request_body = token.access_token_request_body();
|
||||
let proxy_url = token.proxy_url(&ChannelState::server_root_url(), &firebase_api_key);
|
||||
let response = match client
|
||||
.post(&url)
|
||||
.form(&request_body)
|
||||
.timeout(FETCH_ACCESS_TOKEN_TIMEOUT)
|
||||
.send()
|
||||
.await
|
||||
{
|
||||
Ok(response) => match response.error_for_status_ref() {
|
||||
Ok(_) => Ok(response),
|
||||
Err(error) => {
|
||||
log::warn!(
|
||||
"Request to firebase to fetch access token completed, but was unsuccessful: {error:?}"
|
||||
);
|
||||
|
||||
fetch_access_token_via_proxy(client, &request_body, proxy_url).await
|
||||
}
|
||||
},
|
||||
Err(error) => {
|
||||
log::warn!("Failed to make response to firebase to fetch access token: {error:?}");
|
||||
|
||||
fetch_access_token_via_proxy(client, &request_body, proxy_url).await
|
||||
}
|
||||
}?;
|
||||
|
||||
let response = response
|
||||
.json::<FetchAccessTokenResponse>()
|
||||
.await
|
||||
.map_err(anyhow::Error::from)?;
|
||||
match response {
|
||||
FetchAccessTokenResponse::Success {
|
||||
id_token,
|
||||
expires_in,
|
||||
refresh_token,
|
||||
} => Ok(FirebaseAuthTokens::from_response(
|
||||
id_token,
|
||||
refresh_token,
|
||||
expires_in,
|
||||
)?),
|
||||
FetchAccessTokenResponse::Error { error } => Err(error.into()),
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn fetch_access_token_via_proxy<'a>(
|
||||
client: Arc<http_client::Client>,
|
||||
request_body: &'a [(&'a str, &'a str)],
|
||||
proxy_url: String,
|
||||
) -> BoxFuture<'a, Result<http_client::Response>> {
|
||||
Box::pin(async move {
|
||||
client
|
||||
.post(&proxy_url)
|
||||
.form(request_body)
|
||||
.send()
|
||||
.await
|
||||
.map_err(anyhow::Error::from)
|
||||
})
|
||||
}
|
||||
|
||||
/// The [`oauth2::Client`] type, specialized to the endpoints that we require.
|
||||
pub type OAuth2Client = oauth2::basic::BasicClient<
|
||||
oauth2::EndpointNotSet, // HasAuthUrl
|
||||
oauth2::EndpointSet, // HasDeviceAuthUrl
|
||||
oauth2::EndpointNotSet, // HasIntrospectionUrl
|
||||
oauth2::EndpointNotSet, // HasRevocationUrl
|
||||
oauth2::EndpointSet, // HasTokenUrl
|
||||
>;
|
||||
|
||||
/// Intermediate type produced by converting a [`GqlUserOutput`] from the server.
|
||||
struct UserProperties {
|
||||
user: User,
|
||||
server_experiments: Vec<ServerExperiment>,
|
||||
llms: crate::ai::llms::ModelsByFeature,
|
||||
api_key_owner_type: Option<OwnerType>,
|
||||
}
|
||||
|
||||
impl From<GqlUserOutput> for UserProperties {
|
||||
fn from(user_output: GqlUserOutput) -> Self {
|
||||
let principal_type = user_output
|
||||
.principal_type
|
||||
.map(|pt| pt.into())
|
||||
.unwrap_or_default();
|
||||
let user_properties = user_output.user;
|
||||
|
||||
let is_on_work_domain = user_properties.is_on_work_domain;
|
||||
let is_onboarded = user_properties.is_onboarded;
|
||||
let api_key_owner_type = user_output.api_key_owner_type;
|
||||
|
||||
let linked_at = user_properties
|
||||
.anonymous_user_info
|
||||
.as_ref()
|
||||
.and_then(|info| info.linked_at);
|
||||
|
||||
let anonymous_user_type = user_properties
|
||||
.anonymous_user_info
|
||||
.as_ref()
|
||||
.map(|info| info.anonymous_user_type.clone());
|
||||
let personal_object_limits = user_properties
|
||||
.anonymous_user_info
|
||||
.and_then(|info| info.personal_object_limits.clone());
|
||||
let user_profile = user_properties.profile;
|
||||
let local_id = UserUid::new(user_profile.uid.as_str());
|
||||
let needs_sso_link = user_profile.needs_sso_link;
|
||||
|
||||
let server_experiments: Vec<ServerExperiment> = user_properties
|
||||
.experiments
|
||||
.and_then(|experiments| convert_to_server_experiment!(experiments))
|
||||
.unwrap_or_default();
|
||||
|
||||
// Convert LLM model choices from GraphQL response
|
||||
let llms = user_properties.llms.try_into().unwrap_or_default();
|
||||
|
||||
let user = User {
|
||||
is_onboarded,
|
||||
local_id,
|
||||
metadata: user_profile.into(),
|
||||
needs_sso_link,
|
||||
anonymous_user_type: anonymous_user_type.and_then(|t| t.try_into().ok()),
|
||||
is_on_work_domain,
|
||||
linked_at,
|
||||
personal_object_limits: personal_object_limits.and_then(|t| t.try_into().ok()),
|
||||
principal_type,
|
||||
};
|
||||
|
||||
UserProperties {
|
||||
user,
|
||||
server_experiments,
|
||||
llms,
|
||||
api_key_owner_type,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Error, Debug)]
|
||||
/// Error type when retrieving a user and validating it against Firebase.
|
||||
pub enum UserAuthenticationError {
|
||||
/// The user's refresh token is invalid. This could occur if the user authed through
|
||||
/// e.g. Google/GitHub and changed their password.
|
||||
#[error("Firebase returned a token error when fetching an ID token")]
|
||||
DeniedAccessToken(FirebaseError),
|
||||
/// The user's account is invalid. This could occur if the user requested their account
|
||||
/// be deleted per their GDPR/CCPA rights.
|
||||
#[error("Firebase returned a user error when fetching an ID token")]
|
||||
UserAccountDisabled(FirebaseError),
|
||||
#[error("Invalid state parameter in auth redirect")]
|
||||
InvalidStateParameter,
|
||||
#[error("Missing state parameter in auth redirect")]
|
||||
MissingStateParameter,
|
||||
#[error("unexpected error occurred when fetching an ID token: {0:#}")]
|
||||
Unexpected(#[from] anyhow::Error),
|
||||
}
|
||||
|
||||
impl ErrorExt for UserAuthenticationError {
|
||||
fn is_actionable(&self) -> bool {
|
||||
match self {
|
||||
UserAuthenticationError::DeniedAccessToken(err) => {
|
||||
// If a request to our server failed because the user's refresh token
|
||||
// has expired, they should re-auth, but there's no value in reporting
|
||||
// this back to us.
|
||||
log::info!("ignoring denied access token error: {err:#}");
|
||||
false
|
||||
}
|
||||
UserAuthenticationError::UserAccountDisabled(err) => {
|
||||
// Similarly, if their account is disabled, they can't make requests.
|
||||
log::info!("ignoring user account disabled error: {err:#}");
|
||||
false
|
||||
}
|
||||
UserAuthenticationError::Unexpected(err) => err.is_actionable(),
|
||||
UserAuthenticationError::InvalidStateParameter
|
||||
| UserAuthenticationError::MissingStateParameter => {
|
||||
// For now, we're marking these as actionable, since a surplus of these errors
|
||||
// could mean that something is wrong in our login flow (e.g. we're not properly
|
||||
// passing the `state` variable back to the desktop client).
|
||||
// But in general, someone attempting to trick another into logging into their
|
||||
// account with a spoofed `state` variable is not actionable.
|
||||
true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
register_error!(UserAuthenticationError);
|
||||
|
||||
impl From<FirebaseError> for UserAuthenticationError {
|
||||
fn from(error: FirebaseError) -> Self {
|
||||
if FETCH_ACCESS_TOKEN_SOFT_ERROR_MESSAGES.contains(&error.message.as_str()) {
|
||||
UserAuthenticationError::DeniedAccessToken(error)
|
||||
} else if FETCH_ACCESS_TOKEN_HARD_ERROR_MESSAGES.contains(&error.message.as_str()) {
|
||||
UserAuthenticationError::UserAccountDisabled(error)
|
||||
} else {
|
||||
UserAuthenticationError::Unexpected(
|
||||
anyhow::Error::from(error)
|
||||
.context("Failed to exchange refresh token with access token."),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Error, Debug)]
|
||||
/// Error type when creating anonymous users
|
||||
pub enum AnonymousUserCreationError {
|
||||
#[error("The network request to create the anonymous user failed")]
|
||||
CreationFailed,
|
||||
|
||||
#[error("Received a user facing error: {0}")]
|
||||
UserFacingError(String),
|
||||
|
||||
/// Failure that occurs after the user is created, but the ID token could not be fetched.
|
||||
#[error("The user was created, but the ID token could not be fetched")]
|
||||
UserAuthenticationFailed(#[from] UserAuthenticationError),
|
||||
|
||||
#[error("Failed to create anonymous user with unknown error")]
|
||||
Unknown,
|
||||
}
|
||||
|
||||
#[derive(Error, Debug)]
|
||||
/// Error type when minting a new custom token for an anonymous user
|
||||
pub enum MintCustomTokenError {
|
||||
#[error("Received a user facing error: {0}")]
|
||||
UserFacingError(String),
|
||||
#[error("Failed to create new custom token with unknown error")]
|
||||
Unknown,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "auth_test.rs"]
|
||||
mod tests;
|
||||
@@ -0,0 +1,36 @@
|
||||
use crate::auth::credentials::{FirebaseToken, RefreshToken};
|
||||
use anyhow::Result;
|
||||
|
||||
#[test]
|
||||
fn test_firebase_token_urls() -> Result<()> {
|
||||
let custom_token = FirebaseToken::Custom("ct".to_string());
|
||||
let refresh_token = FirebaseToken::Refresh(RefreshToken::new("rt".to_string()));
|
||||
|
||||
assert_eq!(
|
||||
custom_token.access_token_url("api_key"),
|
||||
"https://identitytoolkit.googleapis.com/v1/accounts:signInWithCustomToken?key=api_key"
|
||||
);
|
||||
assert_eq!(
|
||||
refresh_token.access_token_url("api_key"),
|
||||
"https://securetoken.googleapis.com/v1/token?key=api_key"
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
custom_token.access_token_request_body(),
|
||||
vec![("returnSecureToken", "true"), ("token", "ct")]
|
||||
);
|
||||
assert_eq!(
|
||||
refresh_token.access_token_request_body(),
|
||||
vec![("grant_type", "refresh_token"), ("refresh_token", "rt")],
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
custom_token.proxy_url("https://staging.warp.dev", "api_key"),
|
||||
"https://staging.warp.dev/proxy/customToken?key=api_key"
|
||||
);
|
||||
assert_eq!(
|
||||
refresh_token.proxy_url("https://staging.warp.dev", "api_key"),
|
||||
"https://staging.warp.dev/proxy/token?key=api_key"
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,192 @@
|
||||
use super::auth::AuthClient;
|
||||
use super::ServerApi;
|
||||
use crate::ai::generate_block_title::api::{GenerateBlockTitleRequest, GenerateBlockTitleResponse};
|
||||
use crate::server::{
|
||||
block::{Block, DisplaySetting},
|
||||
graphql::{get_request_context, get_user_facing_error_message},
|
||||
};
|
||||
use anyhow::anyhow;
|
||||
use async_trait::async_trait;
|
||||
use chrono::Utc;
|
||||
use cynic::{MutationBuilder, QueryBuilder};
|
||||
#[cfg(test)]
|
||||
use mockall::automock;
|
||||
use std::convert::TryFrom;
|
||||
use warp_core::channel::{Channel, ChannelState};
|
||||
use warp_graphql::{
|
||||
mutations::{
|
||||
share_block::{BlockInput, ShareBlock, ShareBlockResult, ShareBlockVariables},
|
||||
unshare_block::{
|
||||
UnshareBlock, UnshareBlockInput, UnshareBlockResult, UnshareBlockVariables,
|
||||
},
|
||||
},
|
||||
queries::get_blocks_for_user::{
|
||||
Block as GqlBlock, GetBlocksForUser, GetBlocksForUserVariables,
|
||||
},
|
||||
};
|
||||
|
||||
#[cfg_attr(test, automock)]
|
||||
#[cfg_attr(not(target_family = "wasm"), async_trait)]
|
||||
#[cfg_attr(target_family = "wasm", async_trait(?Send))]
|
||||
pub trait BlockClient: 'static + Send + Sync {
|
||||
/// Unshares a block identified at `block_id`.
|
||||
async fn unshare_block(&self, block_id: String) -> Result<(), anyhow::Error>;
|
||||
|
||||
/// Uploads a given block to the server via the /share_block endpoint.
|
||||
async fn save_block(
|
||||
&self,
|
||||
block: &Block,
|
||||
title: Option<String>,
|
||||
show_prompt: bool,
|
||||
display_setting: DisplaySetting,
|
||||
) -> Result<String, anyhow::Error>;
|
||||
|
||||
async fn blocks_owned_by_user(&self) -> Result<Vec<Block>, anyhow::Error>;
|
||||
|
||||
async fn generate_shared_block_title(
|
||||
&self,
|
||||
request: GenerateBlockTitleRequest,
|
||||
) -> Result<GenerateBlockTitleResponse, anyhow::Error>;
|
||||
}
|
||||
|
||||
#[cfg_attr(not(target_family = "wasm"), async_trait)]
|
||||
#[cfg_attr(target_family = "wasm", async_trait(?Send))]
|
||||
impl BlockClient for ServerApi {
|
||||
async fn unshare_block(&self, block_uid: String) -> Result<(), anyhow::Error> {
|
||||
let variables = UnshareBlockVariables {
|
||||
input: UnshareBlockInput { block_uid },
|
||||
request_context: get_request_context(),
|
||||
};
|
||||
|
||||
let operation = UnshareBlock::build(variables);
|
||||
let response = self.send_graphql_request(operation, None).await?;
|
||||
match response.unshare_block {
|
||||
UnshareBlockResult::UnshareBlockOutput(output) => {
|
||||
if output.success {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(anyhow!("Failed to unshare block"))
|
||||
}
|
||||
}
|
||||
UnshareBlockResult::UserFacingError(error) => {
|
||||
Err(anyhow!(get_user_facing_error_message(error)))
|
||||
}
|
||||
UnshareBlockResult::Unknown => Err(anyhow!("Failed to unshare block")),
|
||||
}
|
||||
}
|
||||
|
||||
async fn save_block(
|
||||
&self,
|
||||
block: &Block,
|
||||
title: Option<String>,
|
||||
show_prompt: bool,
|
||||
display_setting: DisplaySetting,
|
||||
) -> Result<String, anyhow::Error> {
|
||||
let variables = ShareBlockVariables {
|
||||
block: BlockInput {
|
||||
command: block.command.as_deref(),
|
||||
embed_display_setting: display_setting.into(),
|
||||
output: block.output.as_deref(),
|
||||
show_prompt,
|
||||
stylized_command: block.stylized_command.as_deref(),
|
||||
stylized_output: block.stylized_output.as_deref(),
|
||||
stylized_prompt: block.stylized_prompt.as_deref(),
|
||||
stylized_prompt_and_command: block.stylized_prompt_and_command.as_deref(),
|
||||
time_started_term: Some(block.time_started_term.with_timezone(&Utc).into()),
|
||||
title: title.as_deref(),
|
||||
},
|
||||
request_context: get_request_context(),
|
||||
};
|
||||
|
||||
let operation = ShareBlock::build(variables);
|
||||
let response = self.send_graphql_request(operation, None).await?;
|
||||
match response.share_block {
|
||||
ShareBlockResult::ShareBlockOutput(output) => {
|
||||
let mut created_url =
|
||||
format!("{}{}", ChannelState::server_root_url(), output.url_ending);
|
||||
|
||||
// If this is a preview build, ensure the link routes to a preview build.
|
||||
if matches!(ChannelState::channel(), Channel::Preview) {
|
||||
created_url.push_str("?preview=true");
|
||||
}
|
||||
|
||||
Ok(created_url)
|
||||
}
|
||||
ShareBlockResult::UserFacingError(error) => {
|
||||
Err(anyhow!(get_user_facing_error_message(error)))
|
||||
}
|
||||
ShareBlockResult::Unknown => Err(anyhow!("Failed to share block")),
|
||||
}
|
||||
}
|
||||
|
||||
async fn blocks_owned_by_user(&self) -> Result<Vec<Block>, anyhow::Error> {
|
||||
let variables = GetBlocksForUserVariables {
|
||||
request_context: get_request_context(),
|
||||
};
|
||||
let operation = GetBlocksForUser::build(variables);
|
||||
let response = self.send_graphql_request(operation, None).await?;
|
||||
|
||||
match response.user {
|
||||
warp_graphql::queries::get_blocks_for_user::UserResult::UserOutput(user_output) => {
|
||||
Ok(user_output
|
||||
.user
|
||||
.blocks
|
||||
.into_iter()
|
||||
.filter_map(|block| block.try_into().ok())
|
||||
.collect())
|
||||
}
|
||||
warp_graphql::queries::get_blocks_for_user::UserResult::Unknown => {
|
||||
Err(anyhow!("Unable to fetch blocks"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn generate_shared_block_title(
|
||||
&self,
|
||||
request: GenerateBlockTitleRequest,
|
||||
) -> Result<GenerateBlockTitleResponse, anyhow::Error> {
|
||||
let auth_token = self.get_or_refresh_access_token().await?;
|
||||
let request_builder = self.client.post(format!(
|
||||
"{}/ai/generate_block_title",
|
||||
ChannelState::server_root_url()
|
||||
));
|
||||
let response = if let Some(token) = auth_token.as_bearer_token() {
|
||||
request_builder.bearer_auth(token)
|
||||
} else {
|
||||
request_builder
|
||||
}
|
||||
.json(&request)
|
||||
.send()
|
||||
.await?
|
||||
.error_for_status()?
|
||||
.json()
|
||||
.await?;
|
||||
Ok(response)
|
||||
}
|
||||
}
|
||||
|
||||
impl TryFrom<GqlBlock> for Block {
|
||||
type Error = anyhow::Error;
|
||||
|
||||
fn try_from(value: GqlBlock) -> Result<Self, Self::Error> {
|
||||
match (value.uid, value.time_started_term) {
|
||||
(uid, Some(time_started_term)) => {
|
||||
Ok(Block {
|
||||
id: Some(uid.into_inner()),
|
||||
command: value.command,
|
||||
output: None,
|
||||
stylized_command: None,
|
||||
stylized_output: None,
|
||||
pwd: None,
|
||||
time_started_term: time_started_term.utc().into(),
|
||||
// This is a dummy value - we are no longer using time_completed_term,
|
||||
// and GqlBlock does not have a time_completed_term field.
|
||||
time_completed_term: time_started_term.utc().into(),
|
||||
stylized_prompt: None,
|
||||
stylized_prompt_and_command: None,
|
||||
})
|
||||
}
|
||||
_ => Err(anyhow!("missing id or time_started_term")),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,293 @@
|
||||
// We don't directly run agent harnesses on WASM, so this code is unused.
|
||||
#![cfg_attr(target_family = "wasm", expect(dead_code))]
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use async_trait::async_trait;
|
||||
#[cfg(test)]
|
||||
use mockall::automock;
|
||||
|
||||
use super::ServerApi;
|
||||
use crate::ai::agent::conversation::AIConversationId;
|
||||
#[cfg(not(target_family = "wasm"))]
|
||||
use crate::ai::agent_sdk::retry::with_bounded_retry;
|
||||
use crate::ai::artifacts::Artifact;
|
||||
|
||||
/// A presigned upload target returned by the server.
|
||||
#[derive(Debug, Clone, serde::Deserialize)]
|
||||
pub struct UploadTarget {
|
||||
pub url: String,
|
||||
pub method: String,
|
||||
pub headers: HashMap<String, String>,
|
||||
}
|
||||
|
||||
/// Request body for upload-snapshot upload targets.
|
||||
#[derive(Debug, Clone, serde::Serialize)]
|
||||
pub struct SnapshotUploadRequest {
|
||||
pub files: Vec<SnapshotFileInfo>,
|
||||
}
|
||||
|
||||
/// Describes a single file in a snapshot upload request.
|
||||
#[derive(Debug, Clone, serde::Serialize)]
|
||||
pub struct SnapshotFileInfo {
|
||||
pub filename: String,
|
||||
pub mime_type: String,
|
||||
}
|
||||
|
||||
/// Response from the upload-snapshot endpoint.
|
||||
///
|
||||
/// The `uploads` list is aligned by index with the [`SnapshotUploadRequest::files`]
|
||||
/// list in the request, so callers match each upload target back to the filename
|
||||
/// they requested by position. The server does not include filenames on the
|
||||
/// response entries — see the `UploadSnapshotResponse` schema in
|
||||
/// `warp-server`'s `public_api/openapi.yaml`.
|
||||
#[derive(Debug, Clone, serde::Deserialize)]
|
||||
pub struct SnapshotUploadResponse {
|
||||
pub uploads: Vec<UploadTarget>,
|
||||
}
|
||||
|
||||
#[derive(serde::Serialize)]
|
||||
struct CreateExternalConversationRequest {
|
||||
format: String,
|
||||
}
|
||||
|
||||
#[derive(serde::Deserialize)]
|
||||
struct CreateExternalConversationResponse {
|
||||
conversation_id: String,
|
||||
}
|
||||
|
||||
#[derive(serde::Serialize)]
|
||||
struct GetUploadTargetRequest {
|
||||
conversation_id: String,
|
||||
}
|
||||
|
||||
/// Skill attached to a resolve-prompt request,
|
||||
/// used when invoking a third-party harness with a skill
|
||||
/// via the CLI.
|
||||
#[derive(serde::Serialize)]
|
||||
pub struct ResolvePromptAttachedSkill {
|
||||
pub name: String,
|
||||
pub content: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub path: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(serde::Serialize)]
|
||||
pub struct ResolvePromptRequest {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub skill: Option<ResolvePromptAttachedSkill>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub attachments_dir: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(serde::Deserialize)]
|
||||
pub struct ResolvedHarnessPrompt {
|
||||
pub prompt: String,
|
||||
#[serde(default)]
|
||||
pub system_prompt: Option<String>,
|
||||
/// Optional user-turn preamble for resumed third-party harness sessions. The harness
|
||||
/// decides how to surface this — Claude Code prepends it to the user-turn prompt fed
|
||||
/// into the CLI so the agent treats it as immediate intent rather than background
|
||||
/// system context. Empty when no resumption is in effect.
|
||||
#[serde(default)]
|
||||
pub resumption_prompt: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, serde::Deserialize, serde::Serialize)]
|
||||
pub struct ReportArtifactResponse {
|
||||
pub artifact_uid: String,
|
||||
}
|
||||
|
||||
#[derive(serde::Serialize)]
|
||||
struct NotifyUserRequest {
|
||||
message: String,
|
||||
}
|
||||
|
||||
#[derive(serde::Serialize)]
|
||||
struct FinishTaskRequest {
|
||||
success: bool,
|
||||
summary: String,
|
||||
}
|
||||
|
||||
/// Trait for API endpoints used to support third-party agent harnesses in Oz.
|
||||
#[cfg_attr(test, automock)]
|
||||
#[cfg_attr(not(target_family = "wasm"), async_trait)]
|
||||
#[cfg_attr(target_family = "wasm", async_trait(?Send))]
|
||||
pub trait HarnessSupportClient: 'static + Send + Sync {
|
||||
/// Create a new external conversation for a third-party harness.
|
||||
async fn create_external_conversation(&self, format: &str) -> Result<AIConversationId>;
|
||||
|
||||
/// Get a presigned upload target for the conversation's raw transcript.
|
||||
async fn get_transcript_upload_target(
|
||||
&self,
|
||||
conversation_id: &AIConversationId,
|
||||
) -> Result<UploadTarget>;
|
||||
|
||||
/// Get a presigned upload target for the conversation's block snapshot.
|
||||
async fn get_block_snapshot_upload_target(
|
||||
&self,
|
||||
conversation_id: &AIConversationId,
|
||||
) -> Result<UploadTarget>;
|
||||
|
||||
/// Resolve the prompt for a third-party harness run for a task stored on the server.
|
||||
async fn resolve_prompt(&self, request: ResolvePromptRequest) -> Result<ResolvedHarnessPrompt>;
|
||||
|
||||
/// Report an artifact created by a third-party harness back to the Oz platform.
|
||||
async fn report_artifact(&self, artifact: &Artifact) -> Result<ReportArtifactResponse>;
|
||||
|
||||
/// Send a progress notification to the task's originating platform.
|
||||
async fn notify_user(&self, message: &str) -> Result<()>;
|
||||
|
||||
/// Report task completion or failure. The server derives PR links/branches from
|
||||
/// artifacts already reported via `report_artifact`.
|
||||
async fn finish_task(&self, success: bool, summary: &str) -> Result<()>;
|
||||
|
||||
/// Get presigned upload targets for a workspace state snapshot.
|
||||
///
|
||||
/// The returned list is aligned by index with `request.files`. See
|
||||
/// [`SnapshotUploadResponse`] for details on the server contract.
|
||||
async fn get_snapshot_upload_targets(
|
||||
&self,
|
||||
request: &SnapshotUploadRequest,
|
||||
) -> Result<Vec<UploadTarget>>;
|
||||
|
||||
/// Download the raw third-party harness transcript bytes for the current task's
|
||||
/// conversation.
|
||||
///
|
||||
/// Hits `GET /harness-support/transcript`, which redirects to a signed GCS URL.
|
||||
/// The conversation is resolved from the task's `agent_conversation_id` server-side,
|
||||
/// so callers do not pass a conversation id. Each harness deserializes the returned
|
||||
/// bytes into its own envelope shape (e.g. Claude Code parses
|
||||
/// `ClaudeTranscriptEnvelope`). Transient failures retry with bounded exponential
|
||||
/// backoff; permanent 4xx (e.g. 404 "no transcript") fail fast so the caller can
|
||||
/// surface a resume-specific error.
|
||||
async fn fetch_transcript(&self) -> Result<bytes::Bytes>;
|
||||
|
||||
/// Get an HTTP client to use with [`UploadTarget`]s for saving blobs.
|
||||
fn http_client(&self) -> &http_client::Client;
|
||||
}
|
||||
|
||||
#[cfg_attr(not(target_family = "wasm"), async_trait)]
|
||||
#[cfg_attr(target_family = "wasm", async_trait(?Send))]
|
||||
impl HarnessSupportClient for ServerApi {
|
||||
async fn create_external_conversation(&self, format: &str) -> Result<AIConversationId> {
|
||||
let response: CreateExternalConversationResponse = self
|
||||
.post_public_api(
|
||||
"harness-support/external-conversation",
|
||||
&CreateExternalConversationRequest {
|
||||
format: format.to_string(),
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
|
||||
AIConversationId::try_from(response.conversation_id)
|
||||
.context("Server returned an invalid conversation ID")
|
||||
}
|
||||
|
||||
async fn get_transcript_upload_target(
|
||||
&self,
|
||||
conversation_id: &AIConversationId,
|
||||
) -> Result<UploadTarget> {
|
||||
self.post_public_api(
|
||||
"harness-support/transcript",
|
||||
&GetUploadTargetRequest {
|
||||
conversation_id: conversation_id.to_string(),
|
||||
},
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn get_block_snapshot_upload_target(
|
||||
&self,
|
||||
conversation_id: &AIConversationId,
|
||||
) -> Result<UploadTarget> {
|
||||
self.post_public_api(
|
||||
"harness-support/block-snapshot",
|
||||
&GetUploadTargetRequest {
|
||||
conversation_id: conversation_id.to_string(),
|
||||
},
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn resolve_prompt(&self, request: ResolvePromptRequest) -> Result<ResolvedHarnessPrompt> {
|
||||
self.post_public_api("harness-support/resolve-prompt", &request)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn report_artifact(&self, artifact: &Artifact) -> Result<ReportArtifactResponse> {
|
||||
self.post_public_api("harness-support/report-artifact", artifact)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn notify_user(&self, message: &str) -> Result<()> {
|
||||
self.post_public_api_unit(
|
||||
"harness-support/notify-user",
|
||||
&NotifyUserRequest {
|
||||
message: message.to_string(),
|
||||
},
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn finish_task(&self, success: bool, summary: &str) -> Result<()> {
|
||||
self.post_public_api_unit(
|
||||
"harness-support/finish-task",
|
||||
&FinishTaskRequest {
|
||||
success,
|
||||
summary: summary.to_string(),
|
||||
},
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn get_snapshot_upload_targets(
|
||||
&self,
|
||||
request: &SnapshotUploadRequest,
|
||||
) -> Result<Vec<UploadTarget>> {
|
||||
let response: SnapshotUploadResponse = self
|
||||
.post_public_api("harness-support/upload-snapshot", request)
|
||||
.await?;
|
||||
Ok(response.uploads)
|
||||
}
|
||||
|
||||
async fn fetch_transcript(&self) -> Result<bytes::Bytes> {
|
||||
#[cfg(not(target_family = "wasm"))]
|
||||
{
|
||||
with_bounded_retry("fetch harness-support transcript", || async {
|
||||
let response = self
|
||||
.get_public_api_response("harness-support/transcript")
|
||||
.await?;
|
||||
response
|
||||
.bytes()
|
||||
.await
|
||||
.context("Failed to read harness-support transcript body")
|
||||
})
|
||||
.await
|
||||
}
|
||||
#[cfg(target_family = "wasm")]
|
||||
{
|
||||
unreachable!(
|
||||
"fetch_transcript is not supported on wasm; agent_sdk is not built on this target"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
fn http_client(&self) -> &http_client::Client {
|
||||
&self.client
|
||||
}
|
||||
}
|
||||
|
||||
/// Upload a blob to a presigned upload target.
|
||||
pub async fn upload_to_target(
|
||||
http_client: &http_client::Client,
|
||||
target: &UploadTarget,
|
||||
body: impl Into<reqwest::Body>,
|
||||
) -> Result<()> {
|
||||
super::presigned_upload::upload_to_target(http_client, target, body).await
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "harness_support_tests.rs"]
|
||||
mod tests;
|
||||
@@ -0,0 +1,25 @@
|
||||
use crate::ai::artifacts::Artifact;
|
||||
|
||||
/// Assert that `Artifact`s serialize to the expected format for the /harness-support/report-artifact
|
||||
/// endpoint.
|
||||
/// If `Artifact` serialization changes, this test will catch it.
|
||||
#[test]
|
||||
fn pull_request_artifact_serializes_to_expected_wire_format() {
|
||||
let artifact = Artifact::PullRequest {
|
||||
url: "https://github.com/org/repo/pull/42".to_string(),
|
||||
branch: "feature-branch".to_string(),
|
||||
repo: Some("repo".to_string()),
|
||||
number: Some(42),
|
||||
};
|
||||
let json = serde_json::to_value(&artifact).unwrap();
|
||||
assert_eq!(
|
||||
json,
|
||||
serde_json::json!({
|
||||
"artifact_type": "PULL_REQUEST",
|
||||
"data": {
|
||||
"url": "https://github.com/org/repo/pull/42",
|
||||
"branch": "feature-branch"
|
||||
}
|
||||
})
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,350 @@
|
||||
use super::ServerApi;
|
||||
use anyhow::{anyhow, Result};
|
||||
use async_trait::async_trait;
|
||||
use cynic::{MutationBuilder, QueryBuilder};
|
||||
|
||||
use crate::channel::ChannelState;
|
||||
use crate::features::FeatureFlag;
|
||||
#[cfg(test)]
|
||||
use mockall::automock;
|
||||
|
||||
use crate::server::graphql::{get_request_context, get_user_facing_error_message};
|
||||
use warp_graphql::mutations::create_simple_integration::{
|
||||
CreateSimpleIntegration, CreateSimpleIntegrationOutput, CreateSimpleIntegrationResult,
|
||||
CreateSimpleIntegrationVariables, SimpleIntegrationConfig,
|
||||
};
|
||||
use warp_graphql::queries::get_integrations_using_environment::{
|
||||
GetIntegrationsUsingEnvironment, GetIntegrationsUsingEnvironmentInput,
|
||||
GetIntegrationsUsingEnvironmentOutput, GetIntegrationsUsingEnvironmentResult,
|
||||
GetIntegrationsUsingEnvironmentVariables,
|
||||
};
|
||||
use warp_graphql::queries::get_oauth_connect_tx_status::{
|
||||
GetOAuthConnectTxStatus, GetOAuthConnectTxStatusInput, GetOAuthConnectTxStatusResult,
|
||||
GetOAuthConnectTxStatusVariables, OauthConnectTxStatus,
|
||||
};
|
||||
use warp_graphql::queries::get_simple_integrations::{
|
||||
SimpleIntegrations, SimpleIntegrationsInput, SimpleIntegrationsOutput,
|
||||
SimpleIntegrationsResult, SimpleIntegrationsVariables,
|
||||
};
|
||||
use warp_graphql::queries::suggest_cloud_environment_image::{
|
||||
RepoInput as SuggestCloudEnvironmentImageRepoInput, SuggestCloudEnvironmentImage,
|
||||
SuggestCloudEnvironmentImageInput, SuggestCloudEnvironmentImageResult,
|
||||
SuggestCloudEnvironmentImageVariables,
|
||||
};
|
||||
use warp_graphql::queries::user_github_info::{
|
||||
GithubAuthRequiredOutput, UserGithubInfo, UserGithubInfoResult, UserGithubInfoVariables,
|
||||
};
|
||||
use warp_graphql::queries::user_repo_auth_status::{
|
||||
RepoInput as UserRepoAuthStatusRepoInput, UserRepoAuthStatus, UserRepoAuthStatusInput,
|
||||
UserRepoAuthStatusOutput, UserRepoAuthStatusResult, UserRepoAuthStatusVariables,
|
||||
};
|
||||
|
||||
#[cfg(not(target_family = "wasm"))]
|
||||
pub trait IntegrationsClientBounds: Send + Sync {}
|
||||
|
||||
#[cfg(not(target_family = "wasm"))]
|
||||
impl<T: 'static + Send + Sync> IntegrationsClientBounds for T {}
|
||||
|
||||
#[cfg(target_family = "wasm")]
|
||||
pub trait IntegrationsClientBounds {}
|
||||
|
||||
#[cfg(target_family = "wasm")]
|
||||
impl<T: 'static> IntegrationsClientBounds for T {}
|
||||
|
||||
#[cfg_attr(test, automock)]
|
||||
#[cfg_attr(target_family = "wasm", allow(dead_code))]
|
||||
#[cfg_attr(target_family = "wasm", async_trait(?Send))]
|
||||
#[cfg_attr(not(target_family = "wasm"), async_trait)]
|
||||
pub trait IntegrationsClient: 'static + IntegrationsClientBounds {
|
||||
/// Checks the user's GitHub authorization status for the given repositories.
|
||||
///
|
||||
/// Returns a list of statuses for each repo, indicating whether the user has
|
||||
/// access to the repo, and an optional auth URL for the user to authorize.
|
||||
async fn check_user_repo_auth_status(
|
||||
&self,
|
||||
repos: Vec<(String, String)>,
|
||||
) -> Result<UserRepoAuthStatusOutput>;
|
||||
|
||||
/// Creates or updates a simple integration on the server.
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `integration_type` - The type of integration (e.g. "github", "linear", "slack")
|
||||
/// * `is_update` - Whether this is an update to an existing integration
|
||||
/// * `environment_uid` - The UID of the environment to associate with this integration
|
||||
/// * `base_prompt` - Optional base prompt for the integration
|
||||
/// * `model_id` - Optional model ID for the integration
|
||||
/// * `mcp_servers_json` - Optional JSON string encoding a map[string]MCPServerConfig (ambient agent spec)
|
||||
/// * `remove_mcp_server_names` - Optional list of MCP server names to remove (applies on update)
|
||||
/// * `worker_host` - Optional worker host ID for self-hosted workers
|
||||
/// * `enabled` - Whether the integration should be enabled on creation
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
async fn create_or_update_simple_integration(
|
||||
&self,
|
||||
integration_type: String,
|
||||
is_update: bool,
|
||||
environment_uid: Option<String>,
|
||||
base_prompt: Option<String>,
|
||||
model_id: Option<String>,
|
||||
mcp_servers_json: Option<String>,
|
||||
remove_mcp_server_names: Option<Vec<String>>,
|
||||
worker_host: Option<String>,
|
||||
enabled: bool,
|
||||
) -> Result<CreateSimpleIntegrationOutput>;
|
||||
|
||||
/// Lists simple integrations for a fixed set of provider slugs.
|
||||
///
|
||||
/// The server will return one SimpleIntegration entry per requested provider,
|
||||
/// regardless of whether the connection or integration currently exists.
|
||||
async fn list_simple_integrations(
|
||||
&self,
|
||||
providers: Vec<String>,
|
||||
) -> Result<SimpleIntegrationsOutput>;
|
||||
|
||||
/// Polls the status of an OAuth connect transaction.
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `tx_id` - The transaction ID returned from create_simple_integration
|
||||
///
|
||||
/// # Returns
|
||||
/// * `Ok(OauthConnectTxStatus)` - The current status of the transaction
|
||||
/// * `Err` - If the transaction is not found or polling fails
|
||||
async fn poll_oauth_connect_status(&self, tx_id: String) -> Result<OauthConnectTxStatus>;
|
||||
|
||||
/// Gets the list of integration provider names that are using the specified environment.
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `environment_id` - The ID of the environment to check
|
||||
///
|
||||
/// # Returns
|
||||
/// * `Ok(Vec<String>)` - List of provider names (e.g., ["linear", "slack"]) using this environment
|
||||
/// * `Err` - If the query fails
|
||||
async fn get_integrations_using_environment(
|
||||
&self,
|
||||
environment_id: String,
|
||||
) -> Result<GetIntegrationsUsingEnvironmentOutput>;
|
||||
|
||||
/// Gets the user's GitHub connection info, including accessible repos.
|
||||
///
|
||||
/// # Returns
|
||||
/// * `Ok(UserGithubInfoResult)` - Either connected with repos, or auth required
|
||||
/// * `Err` - If the query fails
|
||||
async fn get_user_github_info(&self) -> Result<UserGithubInfoResult>;
|
||||
|
||||
/// Suggests a Docker image for a cloud environment based on the provided repos.
|
||||
async fn suggest_cloud_environment_image(
|
||||
&self,
|
||||
repos: Vec<(String, String)>,
|
||||
) -> Result<SuggestCloudEnvironmentImageResult>;
|
||||
}
|
||||
|
||||
#[cfg_attr(target_family = "wasm", async_trait(?Send))]
|
||||
#[cfg_attr(not(target_family = "wasm"), async_trait)]
|
||||
impl IntegrationsClient for ServerApi {
|
||||
async fn check_user_repo_auth_status(
|
||||
&self,
|
||||
repos: Vec<(String, String)>,
|
||||
) -> Result<UserRepoAuthStatusOutput> {
|
||||
let repo_inputs: Vec<UserRepoAuthStatusRepoInput> = repos
|
||||
.into_iter()
|
||||
.map(|(owner, repo)| UserRepoAuthStatusRepoInput { owner, repo })
|
||||
.collect();
|
||||
|
||||
let variables = UserRepoAuthStatusVariables {
|
||||
request_context: get_request_context(),
|
||||
input: UserRepoAuthStatusInput { repos: repo_inputs },
|
||||
};
|
||||
|
||||
let operation = UserRepoAuthStatus::build(variables);
|
||||
let response = self.send_graphql_request(operation, None).await?;
|
||||
|
||||
match response.user_repo_auth_status {
|
||||
UserRepoAuthStatusResult::UserRepoAuthStatusOutput(output) => Ok(output),
|
||||
UserRepoAuthStatusResult::Unknown => Err(anyhow::anyhow!(
|
||||
"Failed to check GitHub auth status: unknown response"
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
async fn create_or_update_simple_integration(
|
||||
&self,
|
||||
integration_type: String,
|
||||
is_update: bool,
|
||||
environment_uid: Option<String>,
|
||||
base_prompt: Option<String>,
|
||||
model_id: Option<String>,
|
||||
mcp_servers_json: Option<String>,
|
||||
remove_mcp_server_names: Option<Vec<String>>,
|
||||
worker_host: Option<String>,
|
||||
enabled: bool,
|
||||
) -> Result<CreateSimpleIntegrationOutput> {
|
||||
let variables = CreateSimpleIntegrationVariables {
|
||||
config: SimpleIntegrationConfig {
|
||||
base_prompt,
|
||||
environment_uid,
|
||||
model_id,
|
||||
mcp_servers_json,
|
||||
remove_mcp_server_names,
|
||||
worker_host,
|
||||
},
|
||||
enabled,
|
||||
integration_type,
|
||||
is_update,
|
||||
request_context: get_request_context(),
|
||||
};
|
||||
|
||||
let operation = CreateSimpleIntegration::build(variables);
|
||||
let response = self.send_graphql_request(operation, None).await?;
|
||||
match response.create_simple_integration {
|
||||
CreateSimpleIntegrationResult::CreateSimpleIntegrationOutput(output) => Ok(output),
|
||||
CreateSimpleIntegrationResult::UserFacingError(error) => {
|
||||
Err(anyhow!(get_user_facing_error_message(error)))
|
||||
}
|
||||
CreateSimpleIntegrationResult::Unknown => {
|
||||
Err(anyhow!("Unknown error while creating integration"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn get_integrations_using_environment(
|
||||
&self,
|
||||
environment_id: String,
|
||||
) -> Result<GetIntegrationsUsingEnvironmentOutput> {
|
||||
let variables = GetIntegrationsUsingEnvironmentVariables {
|
||||
request_context: get_request_context(),
|
||||
input: GetIntegrationsUsingEnvironmentInput { environment_id },
|
||||
};
|
||||
|
||||
let operation = GetIntegrationsUsingEnvironment::build(variables);
|
||||
let response = self.send_graphql_request(operation, None).await?;
|
||||
|
||||
match response.get_integrations_using_environment {
|
||||
GetIntegrationsUsingEnvironmentResult::GetIntegrationsUsingEnvironmentOutput(
|
||||
output,
|
||||
) => Ok(output),
|
||||
GetIntegrationsUsingEnvironmentResult::UserFacingError(error) => {
|
||||
Err(anyhow!(get_user_facing_error_message(error)))
|
||||
}
|
||||
GetIntegrationsUsingEnvironmentResult::Unknown => Err(anyhow!(
|
||||
"Unknown error while getting integrations using environment"
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
async fn list_simple_integrations(
|
||||
&self,
|
||||
providers: Vec<String>,
|
||||
) -> Result<SimpleIntegrationsOutput> {
|
||||
let variables = SimpleIntegrationsVariables {
|
||||
request_context: get_request_context(),
|
||||
input: SimpleIntegrationsInput { providers },
|
||||
};
|
||||
|
||||
let operation = SimpleIntegrations::build(variables);
|
||||
let response = self.send_graphql_request(operation, None).await?;
|
||||
|
||||
match response.simple_integrations {
|
||||
SimpleIntegrationsResult::SimpleIntegrationsOutput(output) => Ok(output),
|
||||
SimpleIntegrationsResult::UserFacingError(error) => {
|
||||
Err(anyhow!(get_user_facing_error_message(error)))
|
||||
}
|
||||
SimpleIntegrationsResult::Unknown => {
|
||||
Err(anyhow!("Unknown error while listing simple integrations"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn poll_oauth_connect_status(&self, tx_id: String) -> Result<OauthConnectTxStatus> {
|
||||
let variables = GetOAuthConnectTxStatusVariables {
|
||||
request_context: get_request_context(),
|
||||
input: GetOAuthConnectTxStatusInput {
|
||||
tx_id: cynic::Id::new(tx_id),
|
||||
},
|
||||
};
|
||||
|
||||
let operation = GetOAuthConnectTxStatus::build(variables);
|
||||
let response = self.send_graphql_request(operation, None).await?;
|
||||
|
||||
match response.get_oauth_connect_tx_status {
|
||||
GetOAuthConnectTxStatusResult::GetOAuthConnectTxStatusOutput(output) => {
|
||||
Ok(output.status)
|
||||
}
|
||||
GetOAuthConnectTxStatusResult::UserFacingError(error) => {
|
||||
Err(anyhow!(get_user_facing_error_message(error)))
|
||||
}
|
||||
GetOAuthConnectTxStatusResult::Unknown => {
|
||||
Err(anyhow!("Unknown error while polling OAuth status"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn get_user_github_info(&self) -> Result<UserGithubInfoResult> {
|
||||
let variables = UserGithubInfoVariables {
|
||||
request_context: get_request_context(),
|
||||
};
|
||||
|
||||
let operation = UserGithubInfo::build(variables);
|
||||
let response = self.send_graphql_request(operation, None).await?;
|
||||
|
||||
let result = response.user_github_info;
|
||||
|
||||
// Dev-only helper for testing GitHub-unauthed flows.
|
||||
//
|
||||
// Important: this runs after the network request completes so the UI can still
|
||||
// show the loading state.
|
||||
if FeatureFlag::SimulateGithubUnauthed.is_enabled() {
|
||||
if let UserGithubInfoResult::GithubConnectedOutput(connected) = &result {
|
||||
let auth_url = format!("{}/oauth/connect/github", ChannelState::server_root_url());
|
||||
return Ok(UserGithubInfoResult::GithubAuthRequiredOutput(
|
||||
GithubAuthRequiredOutput {
|
||||
auth_url,
|
||||
// This value is unused by the app UI; it exists in the schema for
|
||||
// tx-bound flows. We intentionally omit txId from the auth URL so
|
||||
// the web flow can proceed without a server-created tx.
|
||||
tx_id: cynic::Id::new("simulated"),
|
||||
app_install_link: connected.app_install_link.clone(),
|
||||
},
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
async fn suggest_cloud_environment_image(
|
||||
&self,
|
||||
repos: Vec<(String, String)>,
|
||||
) -> Result<SuggestCloudEnvironmentImageResult> {
|
||||
let repo_inputs: Vec<SuggestCloudEnvironmentImageRepoInput> = repos
|
||||
.into_iter()
|
||||
.map(|(owner, repo)| SuggestCloudEnvironmentImageRepoInput { owner, repo })
|
||||
.collect();
|
||||
|
||||
let variables = SuggestCloudEnvironmentImageVariables {
|
||||
request_context: get_request_context(),
|
||||
input: SuggestCloudEnvironmentImageInput { repos: repo_inputs },
|
||||
};
|
||||
|
||||
let operation = SuggestCloudEnvironmentImage::build(variables);
|
||||
let response = self.send_graphql_request(operation, None).await?;
|
||||
|
||||
match response.suggest_cloud_environment_image {
|
||||
SuggestCloudEnvironmentImageResult::SuggestCloudEnvironmentImageAuthRequiredOutput(
|
||||
output,
|
||||
) => Ok(
|
||||
SuggestCloudEnvironmentImageResult::SuggestCloudEnvironmentImageAuthRequiredOutput(
|
||||
output,
|
||||
),
|
||||
),
|
||||
SuggestCloudEnvironmentImageResult::SuggestCloudEnvironmentImageOutput(output) => {
|
||||
Ok(SuggestCloudEnvironmentImageResult::SuggestCloudEnvironmentImageOutput(output))
|
||||
}
|
||||
SuggestCloudEnvironmentImageResult::UserFacingError(error) => {
|
||||
Err(anyhow!(get_user_facing_error_message(error)))
|
||||
}
|
||||
SuggestCloudEnvironmentImageResult::Unknown => Err(anyhow!(
|
||||
"Unknown response from suggestCloudEnvironmentImage query"
|
||||
)),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,291 @@
|
||||
use std::collections::HashMap;
|
||||
|
||||
use anyhow::{anyhow, Context, Result};
|
||||
use async_trait::async_trait;
|
||||
use cynic::{MutationBuilder, QueryBuilder};
|
||||
use warp_graphql::mutations::issue_task_identity_token::{
|
||||
IssueTaskIdentityToken, IssueTaskIdentityTokenInput, IssueTaskIdentityTokenResult,
|
||||
IssueTaskIdentityTokenVariables,
|
||||
};
|
||||
use warp_graphql::object_permissions::OwnerType;
|
||||
use warp_graphql::queries::list_managed_secrets::{
|
||||
ListManagedSecrets, ListManagedSecretsVariables, ManagedSecretsInput, ManagedSecretsResult,
|
||||
};
|
||||
use warp_graphql::queries::managed_secret_config::{
|
||||
GetManagedSecretConfig, GetManagedSecretConfigVariables, UserResult,
|
||||
};
|
||||
use warp_graphql::queries::task_secrets::{
|
||||
ManagedSecretValue, TaskSecrets, TaskSecretsInput, TaskSecretsResult, TaskSecretsVariables,
|
||||
};
|
||||
use warp_graphql::{
|
||||
managed_secrets::{ManagedSecret, ManagedSecretType},
|
||||
mutations::{
|
||||
create_managed_secret::{
|
||||
CreateManagedSecret, CreateManagedSecretInput, CreateManagedSecretResult,
|
||||
CreateManagedSecretVariables,
|
||||
},
|
||||
delete_managed_secret::{
|
||||
DeleteManagedSecret, DeleteManagedSecretInput, DeleteManagedSecretResult,
|
||||
DeleteManagedSecretVariables,
|
||||
},
|
||||
update_managed_secret::{
|
||||
UpdateManagedSecret, UpdateManagedSecretInput, UpdateManagedSecretResult,
|
||||
UpdateManagedSecretVariables,
|
||||
},
|
||||
},
|
||||
object_permissions::Owner,
|
||||
};
|
||||
use warp_managed_secrets::client::{SecretOwner, TaskIdentityToken};
|
||||
|
||||
use super::ServerApi;
|
||||
use crate::server::graphql::{get_request_context, get_user_facing_error_message};
|
||||
|
||||
pub use warp_managed_secrets::client::{ManagedSecretConfigs, ManagedSecretsClient};
|
||||
|
||||
#[cfg_attr(not(target_family = "wasm"), async_trait)]
|
||||
#[cfg_attr(target_family = "wasm", async_trait(?Send))]
|
||||
impl ManagedSecretsClient for ServerApi {
|
||||
async fn get_managed_secret_configs(&self) -> Result<ManagedSecretConfigs> {
|
||||
let variables = GetManagedSecretConfigVariables {
|
||||
request_context: get_request_context(),
|
||||
};
|
||||
let operation = GetManagedSecretConfig::build(variables);
|
||||
let response = self.send_graphql_request(operation, None).await?;
|
||||
|
||||
match response.user {
|
||||
UserResult::UserOutput(output) => {
|
||||
let mut team_configs = HashMap::new();
|
||||
for workspace in output.user.workspaces {
|
||||
for team in workspace.teams {
|
||||
if let Some(config) = team.managed_secrets {
|
||||
// DO NOT inline the `insert` call into the `debug_assert!` macro. It will get compiled out in release builds.
|
||||
let prior_config = team_configs.insert(team.uid.into_inner(), config);
|
||||
debug_assert!(
|
||||
prior_config.is_none(),
|
||||
"Duplicate team UID returned from server"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(ManagedSecretConfigs {
|
||||
user_secrets: output.user.managed_secrets,
|
||||
team_secrets: team_configs,
|
||||
})
|
||||
}
|
||||
UserResult::UserFacingError(error) => {
|
||||
Err(anyhow!(get_user_facing_error_message(error)))
|
||||
}
|
||||
UserResult::Unknown => Err(anyhow!(
|
||||
"Unknown error while getting managed secret configs"
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
async fn create_managed_secret(
|
||||
&self,
|
||||
owner: SecretOwner,
|
||||
name: String,
|
||||
secret_type: ManagedSecretType,
|
||||
encrypted_value: String,
|
||||
description: Option<String>,
|
||||
) -> Result<ManagedSecret> {
|
||||
let graphql_owner = match owner {
|
||||
SecretOwner::CurrentUser => Owner {
|
||||
type_: OwnerType::User,
|
||||
uid: None,
|
||||
},
|
||||
SecretOwner::Team { team_uid } => Owner {
|
||||
type_: OwnerType::Team,
|
||||
uid: Some(cynic::Id::new(team_uid)),
|
||||
},
|
||||
};
|
||||
|
||||
let variables = CreateManagedSecretVariables {
|
||||
input: CreateManagedSecretInput {
|
||||
description,
|
||||
encrypted_value,
|
||||
name,
|
||||
owner: graphql_owner,
|
||||
type_: secret_type,
|
||||
},
|
||||
request_context: get_request_context(),
|
||||
};
|
||||
let operation = CreateManagedSecret::build(variables);
|
||||
let response = self.send_graphql_request(operation, None).await?;
|
||||
|
||||
match response.create_managed_secret {
|
||||
CreateManagedSecretResult::CreateManagedSecretOutput(output) => {
|
||||
Ok(output.managed_secret)
|
||||
}
|
||||
CreateManagedSecretResult::UserFacingError(error) => {
|
||||
Err(anyhow!(get_user_facing_error_message(error)))
|
||||
}
|
||||
CreateManagedSecretResult::Unknown => {
|
||||
Err(anyhow!("Unknown error while creating managed secret"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn delete_managed_secret(&self, owner: SecretOwner, name: String) -> Result<()> {
|
||||
let graphql_owner = match owner {
|
||||
SecretOwner::CurrentUser => Owner {
|
||||
type_: OwnerType::User,
|
||||
uid: None,
|
||||
},
|
||||
SecretOwner::Team { team_uid } => Owner {
|
||||
type_: OwnerType::Team,
|
||||
uid: Some(cynic::Id::new(team_uid)),
|
||||
},
|
||||
};
|
||||
|
||||
let variables = DeleteManagedSecretVariables {
|
||||
input: DeleteManagedSecretInput {
|
||||
name,
|
||||
owner: graphql_owner,
|
||||
},
|
||||
request_context: get_request_context(),
|
||||
};
|
||||
let operation = DeleteManagedSecret::build(variables);
|
||||
let response = self.send_graphql_request(operation, None).await?;
|
||||
|
||||
match response.delete_managed_secret {
|
||||
DeleteManagedSecretResult::DeleteManagedSecretOutput(_) => Ok(()),
|
||||
DeleteManagedSecretResult::UserFacingError(error) => {
|
||||
Err(anyhow!(get_user_facing_error_message(error)))
|
||||
}
|
||||
DeleteManagedSecretResult::Unknown => {
|
||||
Err(anyhow!("Unknown error while deleting managed secret"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn update_managed_secret(
|
||||
&self,
|
||||
owner: SecretOwner,
|
||||
name: String,
|
||||
encrypted_value: Option<String>,
|
||||
description: Option<String>,
|
||||
) -> Result<ManagedSecret> {
|
||||
let graphql_owner = match owner {
|
||||
SecretOwner::CurrentUser => Owner {
|
||||
type_: OwnerType::User,
|
||||
uid: None,
|
||||
},
|
||||
SecretOwner::Team { team_uid } => Owner {
|
||||
type_: OwnerType::Team,
|
||||
uid: Some(cynic::Id::new(team_uid)),
|
||||
},
|
||||
};
|
||||
|
||||
let variables = UpdateManagedSecretVariables {
|
||||
input: UpdateManagedSecretInput {
|
||||
name,
|
||||
owner: graphql_owner,
|
||||
encrypted_value,
|
||||
description,
|
||||
},
|
||||
request_context: get_request_context(),
|
||||
};
|
||||
let operation = UpdateManagedSecret::build(variables);
|
||||
let response = self.send_graphql_request(operation, None).await?;
|
||||
|
||||
match response.update_managed_secret {
|
||||
UpdateManagedSecretResult::UpdateManagedSecretOutput(output) => {
|
||||
Ok(output.managed_secret)
|
||||
}
|
||||
UpdateManagedSecretResult::UserFacingError(error) => {
|
||||
Err(anyhow!(get_user_facing_error_message(error)))
|
||||
}
|
||||
UpdateManagedSecretResult::Unknown => {
|
||||
Err(anyhow!("Unknown error while updating managed secret"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn list_secrets(&self) -> Result<Vec<ManagedSecret>> {
|
||||
let variables = ListManagedSecretsVariables {
|
||||
// Pagination over managed secrets is not yet supported.
|
||||
input: ManagedSecretsInput { cursor: None },
|
||||
request_context: get_request_context(),
|
||||
};
|
||||
let operation = ListManagedSecrets::build(variables);
|
||||
let response = self.send_graphql_request(operation, None).await?;
|
||||
|
||||
match response.managed_secrets {
|
||||
ManagedSecretsResult::ManagedSecretsOutput(output) => Ok(output.managed_secrets),
|
||||
ManagedSecretsResult::UserFacingError(error) => {
|
||||
Err(anyhow!(get_user_facing_error_message(error)))
|
||||
}
|
||||
ManagedSecretsResult::Unknown => {
|
||||
Err(anyhow!("Unknown error while listing managed secrets"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn get_task_secrets(
|
||||
&self,
|
||||
task_id: String,
|
||||
workload_token: String,
|
||||
) -> Result<HashMap<String, ManagedSecretValue>> {
|
||||
let variables = TaskSecretsVariables {
|
||||
input: TaskSecretsInput {
|
||||
task_id: cynic::Id::new(task_id),
|
||||
workload_token,
|
||||
},
|
||||
request_context: get_request_context(),
|
||||
};
|
||||
let operation = TaskSecrets::build(variables);
|
||||
let response = self.send_graphql_request(operation, None).await?;
|
||||
|
||||
match response.task_secrets {
|
||||
TaskSecretsResult::TaskSecretsOutput(output) => {
|
||||
let mut secrets = HashMap::new();
|
||||
for entry in output.secrets {
|
||||
secrets.insert(entry.name, entry.value);
|
||||
}
|
||||
Ok(secrets)
|
||||
}
|
||||
TaskSecretsResult::UserFacingError(error) => {
|
||||
Err(anyhow!(get_user_facing_error_message(error)))
|
||||
}
|
||||
TaskSecretsResult::Unknown => Err(anyhow!("Unknown error while getting task secrets")),
|
||||
}
|
||||
}
|
||||
|
||||
async fn issue_task_identity_token(
|
||||
&self,
|
||||
options: warp_managed_secrets::client::IdentityTokenOptions,
|
||||
) -> Result<TaskIdentityToken> {
|
||||
let requested_duration_seconds = options
|
||||
.requested_duration
|
||||
.as_secs()
|
||||
.try_into()
|
||||
.context("Requested duration out of bounds")?;
|
||||
let variables = IssueTaskIdentityTokenVariables {
|
||||
input: IssueTaskIdentityTokenInput {
|
||||
audience: options.audience,
|
||||
requested_duration_seconds,
|
||||
subject_template: Some(options.subject_template.into_vec()),
|
||||
},
|
||||
request_context: get_request_context(),
|
||||
};
|
||||
let operation = IssueTaskIdentityToken::build(variables);
|
||||
let response = self.send_graphql_request(operation, None).await?;
|
||||
|
||||
match response.issue_task_identity_token {
|
||||
IssueTaskIdentityTokenResult::IssueTaskIdentityTokenOutput(output) => {
|
||||
Ok(TaskIdentityToken {
|
||||
token: output.token,
|
||||
expires_at: output.expires_at.utc(),
|
||||
issuer: output.issuer,
|
||||
})
|
||||
}
|
||||
IssueTaskIdentityTokenResult::UserFacingError(error) => {
|
||||
Err(anyhow!(get_user_facing_error_message(error)))
|
||||
}
|
||||
IssueTaskIdentityTokenResult::Unknown => {
|
||||
Err(anyhow!("Unknown error while issuing task identity token"))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,268 @@
|
||||
#[cfg(not(target_family = "wasm"))]
|
||||
use std::path::{Path, PathBuf};
|
||||
#[cfg(not(target_family = "wasm"))]
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use anyhow::{anyhow, Context, Result};
|
||||
#[cfg(not(target_family = "wasm"))]
|
||||
use async_stream::try_stream;
|
||||
#[cfg(not(target_family = "wasm"))]
|
||||
use base64::{engine::general_purpose::STANDARD, Engine as _};
|
||||
#[cfg(not(target_family = "wasm"))]
|
||||
use bytes::Bytes;
|
||||
#[cfg(not(target_family = "wasm"))]
|
||||
use crc::{Crc, CRC_32_ISCSI};
|
||||
#[cfg(not(target_family = "wasm"))]
|
||||
use futures_lite::io::AsyncReadExt as _;
|
||||
use thiserror::Error;
|
||||
|
||||
#[cfg(not(target_family = "wasm"))]
|
||||
use super::ai::FileArtifactUploadTargetInfo;
|
||||
use super::harness_support::UploadTarget;
|
||||
|
||||
/// Typed error for HTTP-backed operations so downstream classifiers (e.g. the agent-SDK
|
||||
/// retry helper) can decide transient vs permanent failures without string-parsing the
|
||||
/// anyhow Display.
|
||||
///
|
||||
/// Emitted as the source cause of an upload failure; callers typically also attach a
|
||||
/// human-facing context message via `.context(...)` so `err.to_string()` remains useful.
|
||||
#[derive(Debug, Error)]
|
||||
#[error("HTTP request failed with status {status}: {body}")]
|
||||
pub struct HttpStatusError {
|
||||
pub status: u16,
|
||||
pub body: String,
|
||||
}
|
||||
|
||||
#[cfg(not(target_family = "wasm"))]
|
||||
static CRC32C: Crc<u32> = Crc::<u32>::new(&CRC_32_ISCSI);
|
||||
const CONTENT_LENGTH_HEADER_NAME: &str = "content-length";
|
||||
#[cfg(not(target_family = "wasm"))]
|
||||
const FILE_UPLOAD_CHUNK_SIZE: usize = 64 * 1024;
|
||||
|
||||
struct NormalizedUploadTarget<'a> {
|
||||
url: &'a str,
|
||||
method: &'a str,
|
||||
headers: Vec<(&'a str, &'a str)>,
|
||||
}
|
||||
|
||||
impl<'a> From<&'a UploadTarget> for NormalizedUploadTarget<'a> {
|
||||
fn from(target: &'a UploadTarget) -> Self {
|
||||
Self {
|
||||
url: &target.url,
|
||||
method: &target.method,
|
||||
headers: target
|
||||
.headers
|
||||
.iter()
|
||||
.map(|(name, value)| (name.as_str(), value.as_str()))
|
||||
.collect(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(target_family = "wasm"))]
|
||||
impl<'a> From<&'a FileArtifactUploadTargetInfo> for NormalizedUploadTarget<'a> {
|
||||
fn from(target: &'a FileArtifactUploadTargetInfo) -> Self {
|
||||
Self {
|
||||
url: &target.url,
|
||||
method: &target.method,
|
||||
headers: target
|
||||
.headers
|
||||
.iter()
|
||||
.map(|header| (header.name.as_str(), header.value.as_str()))
|
||||
.collect(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(target_family = "wasm"))]
|
||||
#[derive(Clone)]
|
||||
struct SharedChecksumState(Arc<Mutex<Option<crc::Digest<'static, u32>>>>);
|
||||
|
||||
#[derive(Copy, Clone)]
|
||||
struct UploadErrorContext {
|
||||
transport: &'static str,
|
||||
failure: &'static str,
|
||||
}
|
||||
|
||||
#[cfg(not(target_family = "wasm"))]
|
||||
impl SharedChecksumState {
|
||||
fn new() -> Self {
|
||||
Self(Arc::new(Mutex::new(Some(CRC32C.digest()))))
|
||||
}
|
||||
|
||||
fn update(&self, bytes: &[u8]) {
|
||||
if bytes.is_empty() {
|
||||
return;
|
||||
}
|
||||
|
||||
let mut digest = self.0.lock().expect("checksum state mutex poisoned");
|
||||
digest
|
||||
.as_mut()
|
||||
.expect("checksum already finalized")
|
||||
.update(bytes);
|
||||
}
|
||||
|
||||
fn finalize(&self) -> Result<String> {
|
||||
let digest = self
|
||||
.0
|
||||
.lock()
|
||||
.map_err(|_| anyhow!("checksum state mutex poisoned"))?
|
||||
.take()
|
||||
.ok_or_else(|| anyhow!("checksum already finalized"))?;
|
||||
Ok(encode_crc32c_base64(digest.finalize()))
|
||||
}
|
||||
}
|
||||
|
||||
fn build_upload_request<'a>(
|
||||
http_client: &'a http_client::Client,
|
||||
target: NormalizedUploadTarget<'_>,
|
||||
content_length: Option<u64>,
|
||||
) -> Result<http_client::RequestBuilder<'a>> {
|
||||
let method = target.method.to_ascii_uppercase();
|
||||
let has_content_length = target
|
||||
.headers
|
||||
.iter()
|
||||
.any(|(name, _)| name.eq_ignore_ascii_case(CONTENT_LENGTH_HEADER_NAME));
|
||||
|
||||
let mut request = match method.as_str() {
|
||||
"GET" => http_client.get(target.url),
|
||||
"POST" => http_client.post(target.url),
|
||||
"PUT" => http_client.put(target.url),
|
||||
"DELETE" => http_client.delete(target.url),
|
||||
other => return Err(anyhow!("Unsupported HTTP method: {other}")),
|
||||
};
|
||||
|
||||
for (name, value) in target.headers {
|
||||
request = request.header(name, value);
|
||||
}
|
||||
|
||||
if let Some(content_length) = content_length.filter(|_| !has_content_length) {
|
||||
request = request.header(CONTENT_LENGTH_HEADER_NAME, content_length.to_string());
|
||||
}
|
||||
|
||||
Ok(request)
|
||||
}
|
||||
|
||||
async fn ensure_upload_succeeded(
|
||||
response: http_client::Response,
|
||||
error_context: UploadErrorContext,
|
||||
) -> Result<()> {
|
||||
if response.status().is_success() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let status = response.status();
|
||||
let body = response.text().await.unwrap_or_default();
|
||||
let status_err = HttpStatusError {
|
||||
status: status.as_u16(),
|
||||
body: body.clone(),
|
||||
};
|
||||
Err(anyhow::Error::new(status_err).context(format!(
|
||||
"{} failed with status {status}: {body}",
|
||||
error_context.failure
|
||||
)))
|
||||
}
|
||||
|
||||
async fn send_upload_request(
|
||||
http_client: &http_client::Client,
|
||||
target: NormalizedUploadTarget<'_>,
|
||||
body: impl Into<reqwest::Body>,
|
||||
content_length: Option<u64>,
|
||||
error_context: UploadErrorContext,
|
||||
) -> Result<()> {
|
||||
let response = build_upload_request(http_client, target, content_length)?
|
||||
.body(body)
|
||||
.send()
|
||||
.await
|
||||
.context(error_context.transport)?;
|
||||
|
||||
ensure_upload_succeeded(response, error_context).await
|
||||
}
|
||||
|
||||
pub(crate) async fn upload_to_target(
|
||||
http_client: &http_client::Client,
|
||||
target: &UploadTarget,
|
||||
body: impl Into<reqwest::Body>,
|
||||
) -> Result<()> {
|
||||
send_upload_request(
|
||||
http_client,
|
||||
target.into(),
|
||||
body,
|
||||
None,
|
||||
UploadErrorContext {
|
||||
transport: "Failed to upload to presigned URL",
|
||||
failure: "Upload",
|
||||
},
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
#[cfg(not(target_family = "wasm"))]
|
||||
fn encode_crc32c_base64(crc32c: u32) -> String {
|
||||
// Storage providers expect the checksum as base64 of the raw big-endian CRC32C bytes,
|
||||
// not the more human-readable hex string we typically log.
|
||||
STANDARD.encode(crc32c.to_be_bytes())
|
||||
}
|
||||
|
||||
#[cfg(not(target_family = "wasm"))]
|
||||
fn file_upload_stream(
|
||||
mut file: async_fs::File,
|
||||
path: PathBuf,
|
||||
checksum: SharedChecksumState,
|
||||
) -> impl futures::Stream<Item = std::io::Result<Bytes>> + Send + 'static {
|
||||
try_stream! {
|
||||
loop {
|
||||
let mut chunk = vec![0; FILE_UPLOAD_CHUNK_SIZE];
|
||||
let bytes_read = file.read(&mut chunk).await.map_err(|err| {
|
||||
std::io::Error::other(format!(
|
||||
"Failed to read artifact file '{}': {err}",
|
||||
path.display()
|
||||
))
|
||||
})?;
|
||||
|
||||
if bytes_read == 0 {
|
||||
break;
|
||||
}
|
||||
|
||||
chunk.truncate(bytes_read);
|
||||
checksum.update(&chunk);
|
||||
yield Bytes::from(chunk);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(target_family = "wasm"))]
|
||||
pub(crate) async fn upload_file_to_target(
|
||||
http_client: &http_client::Client,
|
||||
target: &FileArtifactUploadTargetInfo,
|
||||
path: &Path,
|
||||
file_size: u64,
|
||||
) -> Result<String> {
|
||||
let file = async_fs::File::open(path)
|
||||
.await
|
||||
.with_context(|| format!("Failed to open artifact file '{}'", path.display()))?;
|
||||
let checksum = SharedChecksumState::new();
|
||||
let body = reqwest::Body::wrap_stream(file_upload_stream(
|
||||
file,
|
||||
path.to_path_buf(),
|
||||
checksum.clone(),
|
||||
));
|
||||
|
||||
send_upload_request(
|
||||
http_client,
|
||||
target.into(),
|
||||
body,
|
||||
Some(file_size),
|
||||
UploadErrorContext {
|
||||
transport: "Failed to upload artifact bytes",
|
||||
failure: "Artifact upload",
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
|
||||
checksum.finalize()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "presigned_upload_tests.rs"]
|
||||
mod tests;
|
||||
@@ -0,0 +1,125 @@
|
||||
use std::collections::HashMap;
|
||||
use std::fs;
|
||||
|
||||
use futures::executor::block_on;
|
||||
use mockito::Server;
|
||||
use tempfile::tempdir;
|
||||
|
||||
use super::*;
|
||||
use crate::server::server_api::ai::{FileArtifactUploadHeaderInfo, FileArtifactUploadTargetInfo};
|
||||
|
||||
#[test]
|
||||
fn encode_crc32c_base64_matches_spec_example() {
|
||||
assert_eq!(encode_crc32c_base64(0x1234_5678), "EjRWeA==");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shared_checksum_state_finalize_returns_error_when_called_twice() {
|
||||
let checksum = SharedChecksumState::new();
|
||||
checksum.update(b"artifact payload");
|
||||
|
||||
let finalized = checksum.finalize().unwrap();
|
||||
let err = checksum.finalize().unwrap_err();
|
||||
|
||||
assert_eq!(
|
||||
finalized,
|
||||
encode_crc32c_base64(CRC32C.checksum(b"artifact payload"))
|
||||
);
|
||||
assert!(err.to_string().contains("checksum already finalized"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn upload_to_target_replays_headers_for_byte_uploads() {
|
||||
block_on(async {
|
||||
let mut server = Server::new();
|
||||
let mock = server
|
||||
.mock("POST", "/upload")
|
||||
.match_header("x-test-header", "expected-header")
|
||||
.match_body("serialized body")
|
||||
.with_status(200)
|
||||
.create();
|
||||
|
||||
let client = http_client::Client::new_for_test();
|
||||
let target = UploadTarget {
|
||||
url: format!("{}/upload", server.url()),
|
||||
method: "POST".to_string(),
|
||||
headers: HashMap::from([("x-test-header".to_string(), "expected-header".to_string())]),
|
||||
};
|
||||
|
||||
upload_to_target(&client, &target, "serialized body".to_string())
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
mock.assert();
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn upload_file_to_target_replays_headers_sets_content_length_and_returns_checksum() {
|
||||
block_on(async {
|
||||
let tempdir = tempdir().unwrap();
|
||||
let path = tempdir.path().join("artifact.bin");
|
||||
let body = b"artifact payload";
|
||||
let content_length = body.len().to_string();
|
||||
fs::write(&path, body).unwrap();
|
||||
|
||||
let mut server = Server::new();
|
||||
let mock = server
|
||||
.mock("POST", "/upload")
|
||||
.match_header("x-test-header", "expected-header")
|
||||
.match_header("content-length", content_length.as_str())
|
||||
.match_body(body.to_vec())
|
||||
.with_status(200)
|
||||
.create();
|
||||
|
||||
let client = http_client::Client::new_for_test();
|
||||
let target = FileArtifactUploadTargetInfo {
|
||||
url: format!("{}/upload", server.url()),
|
||||
method: "POST".to_string(),
|
||||
headers: vec![FileArtifactUploadHeaderInfo {
|
||||
name: "x-test-header".to_string(),
|
||||
value: "expected-header".to_string(),
|
||||
}],
|
||||
};
|
||||
|
||||
let checksum = upload_file_to_target(&client, &target, &path, body.len() as u64)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
mock.assert();
|
||||
assert_eq!(checksum, encode_crc32c_base64(CRC32C.checksum(body)));
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn upload_file_to_target_returns_status_and_body_for_failed_uploads() {
|
||||
block_on(async {
|
||||
let tempdir = tempdir().unwrap();
|
||||
let path = tempdir.path().join("artifact.bin");
|
||||
fs::write(&path, b"artifact payload").unwrap();
|
||||
|
||||
let mut server = Server::new();
|
||||
let mock = server
|
||||
.mock("PUT", "/upload")
|
||||
.with_status(403)
|
||||
.with_body("denied")
|
||||
.create();
|
||||
|
||||
let client = http_client::Client::new_for_test();
|
||||
let target = FileArtifactUploadTargetInfo {
|
||||
url: format!("{}/upload", server.url()),
|
||||
method: "PUT".to_string(),
|
||||
headers: Vec::new(),
|
||||
};
|
||||
|
||||
let err =
|
||||
upload_file_to_target(&client, &target, &path, fs::metadata(&path).unwrap().len())
|
||||
.await
|
||||
.unwrap_err();
|
||||
|
||||
mock.assert();
|
||||
assert!(err
|
||||
.to_string()
|
||||
.contains("Artifact upload failed with status 403 Forbidden: denied"));
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
use super::ServerApi;
|
||||
use crate::server::graphql::{get_request_context, get_user_facing_error_message};
|
||||
use anyhow::{anyhow, Result};
|
||||
use async_trait::async_trait;
|
||||
use cynic::{MutationBuilder, QueryBuilder};
|
||||
#[cfg(test)]
|
||||
use mockall::{automock, predicate::*};
|
||||
use warp_core::channel::ChannelState;
|
||||
use warp_graphql::{
|
||||
mutations::send_referral_invite_emails::{
|
||||
SendReferralInviteEmails, SendReferralInviteEmailsResult, SendReferralInviteEmailsVariables,
|
||||
},
|
||||
queries::get_referral_info::{GetReferralInfo, GetReferralInfoVariables},
|
||||
};
|
||||
|
||||
/// Referral information for the logged-in user
|
||||
pub struct ReferralInfo {
|
||||
/// Shareable URL that the user can use to invite friends
|
||||
pub url: String,
|
||||
/// The underlying referral code associated with the user
|
||||
pub code: String,
|
||||
/// Number of other users who have signed up with this user's referral code
|
||||
pub number_claimed: usize,
|
||||
/// Whether the user has been referred by another user
|
||||
pub is_referred: bool,
|
||||
}
|
||||
|
||||
#[cfg_attr(test, automock)]
|
||||
#[cfg_attr(not(target_family = "wasm"), async_trait)]
|
||||
#[cfg_attr(target_family = "wasm", async_trait(?Send))]
|
||||
pub trait ReferralsClient: 'static + Send + Sync {
|
||||
/// Gets the user's referral information.
|
||||
async fn get_referral_info(&self) -> Result<ReferralInfo>;
|
||||
|
||||
/// Send one or more email invites.
|
||||
async fn send_invite(&self, emails: Vec<String>) -> Result<Vec<String>>;
|
||||
}
|
||||
|
||||
#[cfg_attr(not(target_family = "wasm"), async_trait)]
|
||||
#[cfg_attr(target_family = "wasm", async_trait(?Send))]
|
||||
impl ReferralsClient for ServerApi {
|
||||
async fn get_referral_info(&self) -> Result<ReferralInfo> {
|
||||
let variables = GetReferralInfoVariables {
|
||||
request_context: get_request_context(),
|
||||
};
|
||||
let operation = GetReferralInfo::build(variables);
|
||||
let response = self.send_graphql_request(operation, None).await?;
|
||||
|
||||
match response.user {
|
||||
warp_graphql::queries::get_referral_info::UserResult::UserOutput(user_output) => {
|
||||
Ok(ReferralInfo {
|
||||
url: format!(
|
||||
"{}/referral/{}",
|
||||
ChannelState::server_root_url(),
|
||||
user_output.user.referrals.referral_code
|
||||
),
|
||||
code: user_output.user.referrals.referral_code,
|
||||
number_claimed: usize::try_from(user_output.user.referrals.number_claimed)
|
||||
.expect("Negative referral count"),
|
||||
is_referred: user_output.user.referrals.is_referred,
|
||||
})
|
||||
}
|
||||
warp_graphql::queries::get_referral_info::UserResult::Unknown => {
|
||||
Err(anyhow!("Unable to fetch referral info"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn send_invite(&self, emails: Vec<String>) -> Result<Vec<String>> {
|
||||
let variables = SendReferralInviteEmailsVariables {
|
||||
input: warp_graphql::mutations::send_referral_invite_emails::SendReferralInviteEmailsInput {
|
||||
emails,
|
||||
},
|
||||
request_context: get_request_context(),
|
||||
};
|
||||
let operation = SendReferralInviteEmails::build(variables);
|
||||
let response = self.send_graphql_request(operation, None).await?;
|
||||
|
||||
let send_referral_invite_emails_result = response.send_referral_invite_emails;
|
||||
|
||||
match send_referral_invite_emails_result {
|
||||
SendReferralInviteEmailsResult::SendReferralInviteEmailsOutput(output) => {
|
||||
Ok(output.successful_emails)
|
||||
}
|
||||
SendReferralInviteEmailsResult::UserFacingError(error) => {
|
||||
Err(anyhow!(get_user_facing_error_message(error)))
|
||||
}
|
||||
SendReferralInviteEmailsResult::Unknown => Err(anyhow!(
|
||||
"unknown error while sending referral invite emails"
|
||||
)),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,728 @@
|
||||
use super::ServerApi;
|
||||
use crate::auth::UserUid;
|
||||
use crate::cloud_object::CloudObjectEventEntrypoint;
|
||||
use crate::workspaces::team::{DiscoverableTeam, MembershipRole};
|
||||
use crate::workspaces::workspace::Workspace;
|
||||
use anyhow::{anyhow, Result};
|
||||
use async_trait::async_trait;
|
||||
use cynic::{MutationBuilder, QueryBuilder};
|
||||
use warp_graphql::mutations::add_invite_link_domain_restriction::{
|
||||
AddInviteLinkDomainRestriction, AddInviteLinkDomainRestrictionInput,
|
||||
AddInviteLinkDomainRestrictionResult, AddInviteLinkDomainRestrictionVariables,
|
||||
};
|
||||
use warp_graphql::mutations::create_team::{
|
||||
CreateTeam, CreateTeamInput, CreateTeamResult, CreateTeamVariables,
|
||||
};
|
||||
use warp_graphql::mutations::delete_invite_link_domain_restriction::{
|
||||
DeleteInviteLinkDomainRestriction, DeleteInviteLinkDomainRestrictionInput,
|
||||
DeleteInviteLinkDomainRestrictionResult, DeleteInviteLinkDomainRestrictionVariables,
|
||||
};
|
||||
use warp_graphql::mutations::delete_team_invite::{
|
||||
DeleteTeamInvite, DeleteTeamInviteInput, DeleteTeamInviteResult, DeleteTeamInviteVariables,
|
||||
};
|
||||
use warp_graphql::mutations::join_team_with_team_discovery::{
|
||||
JoinTeamWithTeamDiscovery, JoinTeamWithTeamDiscoveryInput, JoinTeamWithTeamDiscoveryResult,
|
||||
JoinTeamWithTeamDiscoveryVariables, TeamDiscoveryEntrypoint,
|
||||
};
|
||||
use warp_graphql::mutations::remove_user_from_team::{
|
||||
RemoveUserFromTeam, RemoveUserFromTeamInput, RemoveUserFromTeamResult,
|
||||
RemoveUserFromTeamVariables,
|
||||
};
|
||||
use warp_graphql::mutations::rename_team::{
|
||||
RenameTeam, RenameTeamInput, RenameTeamResult, RenameTeamVariables,
|
||||
};
|
||||
use warp_graphql::mutations::reset_invite_links::{
|
||||
ResetInviteLinks, ResetInviteLinksInput, ResetInviteLinksResult, ResetInviteLinksVariables,
|
||||
};
|
||||
use warp_graphql::mutations::send_team_invite_email::{
|
||||
SendTeamInviteEmail, SendTeamInviteEmailInput, SendTeamInviteEmailResult,
|
||||
SendTeamInviteEmailVariables,
|
||||
};
|
||||
use warp_graphql::mutations::set_is_invite_link_enabled::{
|
||||
SetIsInviteLinkEnabled, SetIsInviteLinkEnabledInput, SetIsInviteLinkEnabledResult,
|
||||
SetIsInviteLinkEnabledVariables,
|
||||
};
|
||||
use warp_graphql::mutations::set_team_discoverability::{
|
||||
SetTeamDiscoverability, SetTeamDiscoverabilityInput, SetTeamDiscoverabilityResult,
|
||||
SetTeamDiscoverabilityVariables,
|
||||
};
|
||||
use warp_graphql::mutations::set_team_member_role::{
|
||||
SetTeamMemberRole, SetTeamMemberRoleInput, SetTeamMemberRoleResult, SetTeamMemberRoleVariables,
|
||||
};
|
||||
use warp_graphql::mutations::transfer_team_ownership::{
|
||||
TransferTeamOwnership, TransferTeamOwnershipInput, TransferTeamOwnershipResult,
|
||||
TransferTeamOwnershipVariables,
|
||||
};
|
||||
use warp_graphql::queries::get_discoverable_teams::{
|
||||
GetDiscoverableTeams, GetDiscoverableTeamsVariables,
|
||||
};
|
||||
use warp_graphql::queries::get_workspaces_metadata_for_user::{
|
||||
GetWorkspacesMetadataForUser, GetWorkspacesMetadataForUserVariables, PricingInfoResult,
|
||||
};
|
||||
|
||||
use crate::server::graphql::{get_request_context, get_user_facing_error_message};
|
||||
use crate::server::ids::ServerId;
|
||||
use crate::workspaces::user_workspaces::{CreateTeamResponse, WorkspacesMetadataWithPricing};
|
||||
|
||||
#[cfg(test)]
|
||||
use mockall::{automock, predicate::*};
|
||||
|
||||
#[cfg_attr(test, automock)]
|
||||
#[cfg_attr(not(target_family = "wasm"), async_trait)]
|
||||
#[cfg_attr(target_family = "wasm", async_trait(?Send))]
|
||||
pub trait TeamClient: 'static + Send + Sync {
|
||||
async fn workspaces_metadata(&self) -> Result<WorkspacesMetadataWithPricing>;
|
||||
|
||||
async fn add_invite_link_domain_restriction(
|
||||
&self,
|
||||
team_uid: ServerId,
|
||||
domain: String,
|
||||
) -> Result<WorkspacesMetadataWithPricing>;
|
||||
|
||||
async fn delete_invite_link_domain_restriction(
|
||||
&self,
|
||||
team_uid: ServerId,
|
||||
domain_uid: ServerId,
|
||||
) -> Result<WorkspacesMetadataWithPricing>;
|
||||
|
||||
/// Creates a team and returns the result from the server with the newly created team.
|
||||
async fn create_team(
|
||||
&self,
|
||||
name: String,
|
||||
entrypoint: CloudObjectEventEntrypoint,
|
||||
discoverable: Option<bool>,
|
||||
) -> Result<CreateTeamResponse>;
|
||||
|
||||
/// Removes the user from the selected team and returns a list of all teams that a user is
|
||||
/// still a member of (including updated team members).
|
||||
async fn remove_user_from_team(
|
||||
&self,
|
||||
user_uid: UserUid,
|
||||
team_uid: ServerId,
|
||||
entrypoint: CloudObjectEventEntrypoint,
|
||||
) -> Result<WorkspacesMetadataWithPricing>;
|
||||
|
||||
/// Removes the _current_ user from the team (user leaving the team) and returns the list of
|
||||
/// all teams that the current user is still a member of.
|
||||
async fn leave_team(
|
||||
&self,
|
||||
user_uid: UserUid,
|
||||
team_uid: ServerId,
|
||||
entrypoint: CloudObjectEventEntrypoint,
|
||||
) -> Result<WorkspacesMetadataWithPricing>;
|
||||
|
||||
async fn join_team_with_team_discovery(
|
||||
&self,
|
||||
team_uid: ServerId,
|
||||
) -> Result<WorkspacesMetadataWithPricing>;
|
||||
|
||||
async fn send_team_invite_email(
|
||||
&self,
|
||||
team_uid: ServerId,
|
||||
email: String,
|
||||
) -> Result<WorkspacesMetadataWithPricing>;
|
||||
|
||||
async fn delete_team_invite(
|
||||
&self,
|
||||
team_uid: ServerId,
|
||||
email: String,
|
||||
) -> Result<WorkspacesMetadataWithPricing>;
|
||||
|
||||
async fn get_discoverable_teams(&self) -> Result<Vec<DiscoverableTeam>>;
|
||||
|
||||
async fn rename_team(
|
||||
&self,
|
||||
new_name: String,
|
||||
team_uid: ServerId,
|
||||
) -> Result<WorkspacesMetadataWithPricing>;
|
||||
|
||||
async fn reset_invite_links(&self, team_uid: ServerId)
|
||||
-> Result<WorkspacesMetadataWithPricing>;
|
||||
|
||||
async fn set_is_invite_link_enabled(
|
||||
&self,
|
||||
team_uid: ServerId,
|
||||
new_value: bool,
|
||||
) -> Result<WorkspacesMetadataWithPricing>;
|
||||
|
||||
async fn set_team_discoverability(
|
||||
&self,
|
||||
team_uid: ServerId,
|
||||
discoverable: bool,
|
||||
) -> Result<WorkspacesMetadataWithPricing>;
|
||||
|
||||
async fn transfer_team_ownership(
|
||||
&self,
|
||||
new_owner_email: String,
|
||||
) -> Result<WorkspacesMetadataWithPricing>;
|
||||
|
||||
async fn set_team_member_role(
|
||||
&self,
|
||||
user_uid: UserUid,
|
||||
team_uid: ServerId,
|
||||
role: MembershipRole,
|
||||
) -> Result<WorkspacesMetadataWithPricing>;
|
||||
}
|
||||
|
||||
#[cfg_attr(not(target_family = "wasm"), async_trait)]
|
||||
#[cfg_attr(target_family = "wasm", async_trait(?Send))]
|
||||
impl TeamClient for ServerApi {
|
||||
async fn workspaces_metadata(&self) -> Result<WorkspacesMetadataWithPricing> {
|
||||
let variables = GetWorkspacesMetadataForUserVariables {
|
||||
request_context: get_request_context(),
|
||||
};
|
||||
let operation = GetWorkspacesMetadataForUser::build(variables);
|
||||
let response = self.send_graphql_request(operation, None).await?;
|
||||
|
||||
let metadata = match response.user {
|
||||
warp_graphql::queries::get_workspaces_metadata_for_user::UserResult::UserOutput(
|
||||
user_output,
|
||||
) => user_output.user.into(),
|
||||
warp_graphql::queries::get_workspaces_metadata_for_user::UserResult::Unknown => {
|
||||
return Err(anyhow!("Unable to fetch workspaces metadata"));
|
||||
}
|
||||
};
|
||||
|
||||
let pricing_info = match response.pricing_info {
|
||||
PricingInfoResult::PricingInfoOutput(pricing_output) => {
|
||||
Some(pricing_output.pricing_info)
|
||||
}
|
||||
PricingInfoResult::Unknown => None,
|
||||
};
|
||||
|
||||
Ok(WorkspacesMetadataWithPricing {
|
||||
metadata,
|
||||
pricing_info,
|
||||
})
|
||||
}
|
||||
|
||||
async fn add_invite_link_domain_restriction(
|
||||
&self,
|
||||
team_uid: ServerId,
|
||||
domain: String,
|
||||
) -> Result<WorkspacesMetadataWithPricing> {
|
||||
let variables = AddInviteLinkDomainRestrictionVariables {
|
||||
input: AddInviteLinkDomainRestrictionInput {
|
||||
team_uid: team_uid.into(),
|
||||
domain,
|
||||
},
|
||||
request_context: get_request_context(),
|
||||
};
|
||||
|
||||
let operation = AddInviteLinkDomainRestriction::build(variables);
|
||||
let result = self
|
||||
.send_graphql_request(operation, None)
|
||||
.await?
|
||||
.add_invite_link_domain_restriction;
|
||||
|
||||
match result {
|
||||
AddInviteLinkDomainRestrictionResult::AddInviteLinkDomainRestrictionOutput(result) => {
|
||||
if !result.success {
|
||||
return Err(anyhow!("failed to add invite link domain restriction"));
|
||||
}
|
||||
}
|
||||
AddInviteLinkDomainRestrictionResult::UserFacingError(user_facing_error) => {
|
||||
return Err(anyhow!(get_user_facing_error_message(user_facing_error)))
|
||||
}
|
||||
AddInviteLinkDomainRestrictionResult::Unknown => {
|
||||
return Err(anyhow!(
|
||||
"unknown error while adding invite link domain restriction"
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
self.workspaces_metadata().await
|
||||
}
|
||||
|
||||
async fn delete_invite_link_domain_restriction(
|
||||
&self,
|
||||
team_uid: ServerId,
|
||||
domain_uid: ServerId,
|
||||
) -> Result<WorkspacesMetadataWithPricing> {
|
||||
let variables = DeleteInviteLinkDomainRestrictionVariables {
|
||||
input: DeleteInviteLinkDomainRestrictionInput {
|
||||
uid: domain_uid.into(),
|
||||
team_uid: team_uid.into(),
|
||||
},
|
||||
request_context: get_request_context(),
|
||||
};
|
||||
let operation = DeleteInviteLinkDomainRestriction::build(variables);
|
||||
let result = self
|
||||
.send_graphql_request(operation, None)
|
||||
.await?
|
||||
.delete_invite_link_domain_restriction;
|
||||
|
||||
match result {
|
||||
DeleteInviteLinkDomainRestrictionResult::DeleteInviteLinkDomainRestrictionOutput(
|
||||
result,
|
||||
) => {
|
||||
if !result.success {
|
||||
return Err(anyhow!("failed to delete invite link domain restriction"));
|
||||
}
|
||||
}
|
||||
DeleteInviteLinkDomainRestrictionResult::UserFacingError(user_facing_error) => {
|
||||
return Err(anyhow!(get_user_facing_error_message(user_facing_error)))
|
||||
}
|
||||
DeleteInviteLinkDomainRestrictionResult::Unknown => {
|
||||
return Err(anyhow!(
|
||||
"unknown error while deleting invite link domain restriction"
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
self.workspaces_metadata().await
|
||||
}
|
||||
|
||||
async fn create_team(
|
||||
&self,
|
||||
name: String,
|
||||
entrypoint: CloudObjectEventEntrypoint,
|
||||
discoverable: Option<bool>,
|
||||
) -> Result<CreateTeamResponse> {
|
||||
let variables = CreateTeamVariables {
|
||||
input: CreateTeamInput {
|
||||
name,
|
||||
entrypoint: entrypoint.into(),
|
||||
discoverable: discoverable.unwrap_or(false),
|
||||
},
|
||||
request_context: get_request_context(),
|
||||
};
|
||||
|
||||
let operation = CreateTeam::build(variables);
|
||||
let result = self
|
||||
.send_graphql_request(operation, None)
|
||||
.await?
|
||||
.create_team;
|
||||
|
||||
match result {
|
||||
CreateTeamResult::CreateTeamOutput(output) => {
|
||||
let workspace: Workspace = output.workspace.clone().into();
|
||||
if let Some(team) = workspace.teams.first() {
|
||||
Ok(CreateTeamResponse {
|
||||
workspace: workspace.clone(),
|
||||
team: team.clone(),
|
||||
})
|
||||
} else {
|
||||
Err(anyhow!("failed to create team"))
|
||||
}
|
||||
}
|
||||
CreateTeamResult::UserFacingError(user_facing_error) => {
|
||||
Err(anyhow!(get_user_facing_error_message(user_facing_error)))
|
||||
}
|
||||
CreateTeamResult::Unknown => Err(anyhow!("unknown error while creating team")),
|
||||
}
|
||||
}
|
||||
|
||||
async fn remove_user_from_team(
|
||||
&self,
|
||||
user_uid: UserUid,
|
||||
team_uid: ServerId,
|
||||
entrypoint: CloudObjectEventEntrypoint,
|
||||
) -> Result<WorkspacesMetadataWithPricing> {
|
||||
let variables = RemoveUserFromTeamVariables {
|
||||
input: RemoveUserFromTeamInput {
|
||||
user_uid: user_uid.as_str().into(),
|
||||
team_uid: team_uid.into(),
|
||||
entrypoint: entrypoint.into(),
|
||||
},
|
||||
request_context: get_request_context(),
|
||||
};
|
||||
|
||||
let operation = RemoveUserFromTeam::build(variables);
|
||||
let result = self
|
||||
.send_graphql_request(operation, None)
|
||||
.await?
|
||||
.remove_user_from_team;
|
||||
|
||||
match result {
|
||||
RemoveUserFromTeamResult::RemoveUserFromTeamOutput(output) => {
|
||||
if !output.success {
|
||||
return Err(anyhow!("failed to remove user from team"));
|
||||
} else {
|
||||
self.workspaces_metadata().await
|
||||
}
|
||||
}
|
||||
RemoveUserFromTeamResult::UserFacingError(user_facing_error) => {
|
||||
Err(anyhow!(get_user_facing_error_message(user_facing_error)))
|
||||
}
|
||||
RemoveUserFromTeamResult::Unknown => {
|
||||
Err(anyhow!("unknown error while removing user from team"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn leave_team(
|
||||
&self,
|
||||
user_uid: UserUid,
|
||||
team_uid: ServerId,
|
||||
entrypoint: CloudObjectEventEntrypoint,
|
||||
) -> Result<WorkspacesMetadataWithPricing> {
|
||||
let variables = RemoveUserFromTeamVariables {
|
||||
input: RemoveUserFromTeamInput {
|
||||
user_uid: user_uid.into(),
|
||||
team_uid: team_uid.into(),
|
||||
entrypoint: entrypoint.into(),
|
||||
},
|
||||
request_context: get_request_context(),
|
||||
};
|
||||
|
||||
let operation = RemoveUserFromTeam::build(variables);
|
||||
let result = self
|
||||
.send_graphql_request(operation, None)
|
||||
.await?
|
||||
.remove_user_from_team;
|
||||
|
||||
match result {
|
||||
RemoveUserFromTeamResult::RemoveUserFromTeamOutput(output) => {
|
||||
if !output.success {
|
||||
return Err(anyhow!("failed to leave team"));
|
||||
} else {
|
||||
self.workspaces_metadata().await
|
||||
}
|
||||
}
|
||||
RemoveUserFromTeamResult::UserFacingError(user_facing_error) => {
|
||||
Err(anyhow!(get_user_facing_error_message(user_facing_error)))
|
||||
}
|
||||
RemoveUserFromTeamResult::Unknown => Err(anyhow!("unknown error while leaving team")),
|
||||
}
|
||||
}
|
||||
|
||||
async fn join_team_with_team_discovery(
|
||||
&self,
|
||||
team_uid: ServerId,
|
||||
) -> Result<WorkspacesMetadataWithPricing> {
|
||||
let variables = JoinTeamWithTeamDiscoveryVariables {
|
||||
input: JoinTeamWithTeamDiscoveryInput {
|
||||
team_uid: team_uid.into(),
|
||||
entrypoint: TeamDiscoveryEntrypoint::TeamSettings,
|
||||
},
|
||||
request_context: get_request_context(),
|
||||
};
|
||||
|
||||
let operation = JoinTeamWithTeamDiscovery::build(variables);
|
||||
let result = self
|
||||
.send_graphql_request(operation, None)
|
||||
.await?
|
||||
.join_team_with_team_discovery;
|
||||
|
||||
match result {
|
||||
JoinTeamWithTeamDiscoveryResult::JoinTeamWithTeamDiscoveryOutput(output) => {
|
||||
if !output.success {
|
||||
return Err(anyhow!("failed to join team"));
|
||||
} else {
|
||||
self.workspaces_metadata().await
|
||||
}
|
||||
}
|
||||
JoinTeamWithTeamDiscoveryResult::UserFacingError(user_facing_error) => {
|
||||
Err(anyhow!(get_user_facing_error_message(user_facing_error)))
|
||||
}
|
||||
JoinTeamWithTeamDiscoveryResult::Unknown => {
|
||||
Err(anyhow!("unknown error while joining team"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn send_team_invite_email(
|
||||
&self,
|
||||
team_uid: ServerId,
|
||||
email: String,
|
||||
) -> Result<WorkspacesMetadataWithPricing> {
|
||||
let variables = SendTeamInviteEmailVariables {
|
||||
input: SendTeamInviteEmailInput {
|
||||
team_uid: team_uid.into(),
|
||||
email,
|
||||
},
|
||||
request_context: get_request_context(),
|
||||
};
|
||||
|
||||
let operation = SendTeamInviteEmail::build(variables);
|
||||
let result = self
|
||||
.send_graphql_request(operation, None)
|
||||
.await?
|
||||
.send_team_invite_email;
|
||||
|
||||
match result {
|
||||
SendTeamInviteEmailResult::SendTeamInviteEmailOutput(output) => {
|
||||
if !output.success {
|
||||
return Err(anyhow!("failed to send team invite"));
|
||||
} else {
|
||||
self.workspaces_metadata().await
|
||||
}
|
||||
}
|
||||
SendTeamInviteEmailResult::UserFacingError(user_facing_error) => {
|
||||
Err(anyhow!(get_user_facing_error_message(user_facing_error)))
|
||||
}
|
||||
SendTeamInviteEmailResult::Unknown => {
|
||||
Err(anyhow!("unknown error while sending team invite"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn delete_team_invite(
|
||||
&self,
|
||||
team_uid: ServerId,
|
||||
email: String,
|
||||
) -> Result<WorkspacesMetadataWithPricing> {
|
||||
let variables = DeleteTeamInviteVariables {
|
||||
input: DeleteTeamInviteInput {
|
||||
team_uid: team_uid.into(),
|
||||
email,
|
||||
},
|
||||
request_context: get_request_context(),
|
||||
};
|
||||
|
||||
let operation = DeleteTeamInvite::build(variables);
|
||||
let result = self
|
||||
.send_graphql_request(operation, None)
|
||||
.await?
|
||||
.delete_team_invite;
|
||||
|
||||
match result {
|
||||
DeleteTeamInviteResult::DeleteTeamInviteOutput(output) => {
|
||||
if !output.success {
|
||||
return Err(anyhow!("failed to delete team invite"));
|
||||
} else {
|
||||
self.workspaces_metadata().await
|
||||
}
|
||||
}
|
||||
DeleteTeamInviteResult::UserFacingError(user_facing_error) => {
|
||||
Err(anyhow!(get_user_facing_error_message(user_facing_error)))
|
||||
}
|
||||
DeleteTeamInviteResult::Unknown => {
|
||||
Err(anyhow!("unknown error while deleting team invite"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn get_discoverable_teams(&self) -> Result<Vec<DiscoverableTeam>, anyhow::Error> {
|
||||
let variables = GetDiscoverableTeamsVariables {
|
||||
request_context: get_request_context(),
|
||||
};
|
||||
let operation = GetDiscoverableTeams::build(variables);
|
||||
let result = self.send_graphql_request(operation, None).await?;
|
||||
|
||||
match result.user {
|
||||
warp_graphql::queries::get_discoverable_teams::UserResult::UserOutput(user_output) => {
|
||||
Ok(user_output
|
||||
.user
|
||||
.discoverable_teams
|
||||
.into_iter()
|
||||
.map(|gql_team_data| Ok(gql_team_data.into()))
|
||||
.collect::<Result<Vec<DiscoverableTeam>>>()?)
|
||||
}
|
||||
warp_graphql::queries::get_discoverable_teams::UserResult::UserFacingError(
|
||||
user_facing_error,
|
||||
) => Err(anyhow!(get_user_facing_error_message(user_facing_error))),
|
||||
warp_graphql::queries::get_discoverable_teams::UserResult::Unknown => {
|
||||
Err(anyhow!("unknown error while getting discoverable teams"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn rename_team(
|
||||
&self,
|
||||
new_name: String,
|
||||
team_uid: ServerId,
|
||||
) -> Result<WorkspacesMetadataWithPricing> {
|
||||
let variables = RenameTeamVariables {
|
||||
input: RenameTeamInput {
|
||||
new_name,
|
||||
team_uid: team_uid.into(),
|
||||
},
|
||||
request_context: get_request_context(),
|
||||
};
|
||||
let operation = RenameTeam::build(variables);
|
||||
let result = self
|
||||
.send_graphql_request(operation, None)
|
||||
.await?
|
||||
.rename_team;
|
||||
|
||||
match result {
|
||||
RenameTeamResult::RenameTeamOutput(output) => {
|
||||
if output.success {
|
||||
self.workspaces_metadata().await
|
||||
} else {
|
||||
Err(anyhow!("failed to rename team"))
|
||||
}
|
||||
}
|
||||
RenameTeamResult::UserFacingError(user_facing_error) => {
|
||||
Err(anyhow!(get_user_facing_error_message(user_facing_error)))
|
||||
}
|
||||
RenameTeamResult::Unknown => Err(anyhow!("unknown error while renaming team")),
|
||||
}
|
||||
}
|
||||
|
||||
async fn reset_invite_links(
|
||||
&self,
|
||||
team_uid: ServerId,
|
||||
) -> Result<WorkspacesMetadataWithPricing> {
|
||||
let variables = ResetInviteLinksVariables {
|
||||
input: ResetInviteLinksInput {
|
||||
team_uid: team_uid.into(),
|
||||
},
|
||||
request_context: get_request_context(),
|
||||
};
|
||||
|
||||
let operation = ResetInviteLinks::build(variables);
|
||||
let result = self
|
||||
.send_graphql_request(operation, None)
|
||||
.await?
|
||||
.reset_invite_links;
|
||||
|
||||
match result {
|
||||
ResetInviteLinksResult::ResetInviteLinksOutput(output) => {
|
||||
if output.success {
|
||||
self.workspaces_metadata().await
|
||||
} else {
|
||||
Err(anyhow!("failed to reset invite links"))
|
||||
}
|
||||
}
|
||||
ResetInviteLinksResult::UserFacingError(user_facing_error) => {
|
||||
Err(anyhow!(get_user_facing_error_message(user_facing_error)))
|
||||
}
|
||||
ResetInviteLinksResult::Unknown => {
|
||||
Err(anyhow!("unknown error while resetting invite links"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn set_is_invite_link_enabled(
|
||||
&self,
|
||||
team_uid: ServerId,
|
||||
new_value: bool,
|
||||
) -> Result<WorkspacesMetadataWithPricing> {
|
||||
let variables = SetIsInviteLinkEnabledVariables {
|
||||
input: SetIsInviteLinkEnabledInput {
|
||||
team_uid: team_uid.into(),
|
||||
new_value,
|
||||
},
|
||||
request_context: get_request_context(),
|
||||
};
|
||||
|
||||
let operation = SetIsInviteLinkEnabled::build(variables);
|
||||
let result = self
|
||||
.send_graphql_request(operation, None)
|
||||
.await?
|
||||
.set_is_invite_link_enabled;
|
||||
|
||||
match result {
|
||||
SetIsInviteLinkEnabledResult::SetIsInviteLinkEnabledOutput(output) => {
|
||||
if output.success {
|
||||
self.workspaces_metadata().await
|
||||
} else {
|
||||
Err(anyhow!("failed to set invite link enabled"))
|
||||
}
|
||||
}
|
||||
SetIsInviteLinkEnabledResult::UserFacingError(user_facing_error) => {
|
||||
Err(anyhow!(get_user_facing_error_message(user_facing_error)))
|
||||
}
|
||||
SetIsInviteLinkEnabledResult::Unknown => {
|
||||
Err(anyhow!("unknown error while setting invite link enabled"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn set_team_discoverability(
|
||||
&self,
|
||||
team_uid: ServerId,
|
||||
new_value: bool,
|
||||
) -> Result<WorkspacesMetadataWithPricing> {
|
||||
let variables = SetTeamDiscoverabilityVariables {
|
||||
input: SetTeamDiscoverabilityInput {
|
||||
team_uid: team_uid.into(),
|
||||
discoverable: new_value,
|
||||
},
|
||||
request_context: get_request_context(),
|
||||
};
|
||||
|
||||
let operation = SetTeamDiscoverability::build(variables);
|
||||
let result = self
|
||||
.send_graphql_request(operation, None)
|
||||
.await?
|
||||
.set_team_discoverability;
|
||||
|
||||
match result {
|
||||
SetTeamDiscoverabilityResult::SetTeamDiscoverabilityOutput(output) => {
|
||||
if output.success {
|
||||
self.workspaces_metadata().await
|
||||
} else {
|
||||
Err(anyhow!("failed to set team discoverability"))
|
||||
}
|
||||
}
|
||||
SetTeamDiscoverabilityResult::UserFacingError(user_facing_error) => {
|
||||
Err(anyhow!(get_user_facing_error_message(user_facing_error)))
|
||||
}
|
||||
SetTeamDiscoverabilityResult::Unknown => {
|
||||
Err(anyhow!("unknown error while setting team discoverability"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn transfer_team_ownership(
|
||||
&self,
|
||||
new_owner_email: String,
|
||||
) -> Result<WorkspacesMetadataWithPricing> {
|
||||
let variables = TransferTeamOwnershipVariables {
|
||||
input: TransferTeamOwnershipInput { new_owner_email },
|
||||
request_context: get_request_context(),
|
||||
};
|
||||
let operation = TransferTeamOwnership::build(variables);
|
||||
let result = self
|
||||
.send_graphql_request(operation, None)
|
||||
.await?
|
||||
.transfer_team_ownership;
|
||||
|
||||
match result {
|
||||
TransferTeamOwnershipResult::TransferTeamOwnershipOutput(output) => {
|
||||
if !output.success {
|
||||
return Err(anyhow!("failed to transfer team ownership"));
|
||||
} else {
|
||||
self.workspaces_metadata().await
|
||||
}
|
||||
}
|
||||
TransferTeamOwnershipResult::UserFacingError(user_facing_error) => {
|
||||
Err(anyhow!(get_user_facing_error_message(user_facing_error)))
|
||||
}
|
||||
TransferTeamOwnershipResult::Unknown => {
|
||||
Err(anyhow!("unknown error while transferring team ownership"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn set_team_member_role(
|
||||
&self,
|
||||
user_uid: UserUid,
|
||||
team_uid: ServerId,
|
||||
role: MembershipRole,
|
||||
) -> Result<WorkspacesMetadataWithPricing> {
|
||||
let variables = SetTeamMemberRoleVariables {
|
||||
input: SetTeamMemberRoleInput {
|
||||
user_uid: user_uid.as_str().into(),
|
||||
team_uid: team_uid.into(),
|
||||
role: role.into(),
|
||||
},
|
||||
request_context: get_request_context(),
|
||||
};
|
||||
let operation = SetTeamMemberRole::build(variables);
|
||||
let result = self
|
||||
.send_graphql_request(operation, None)
|
||||
.await?
|
||||
.set_team_member_role;
|
||||
|
||||
match result {
|
||||
SetTeamMemberRoleResult::SetTeamMemberRoleOutput(output) => {
|
||||
if output.success {
|
||||
self.workspaces_metadata().await
|
||||
} else {
|
||||
Err(anyhow!("failed to set team member role"))
|
||||
}
|
||||
}
|
||||
SetTeamMemberRoleResult::UserFacingError(user_facing_error) => {
|
||||
Err(anyhow!(get_user_facing_error_message(user_facing_error)))
|
||||
}
|
||||
SetTeamMemberRoleResult::Unknown => {
|
||||
Err(anyhow!("unknown error while setting team member role"))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,223 @@
|
||||
use super::{team::TeamClient, ServerApi};
|
||||
use crate::workspaces::user_workspaces::WorkspacesMetadataResponse;
|
||||
use crate::workspaces::workspace::AiOverages;
|
||||
use anyhow::{anyhow, Result};
|
||||
use async_trait::async_trait;
|
||||
use cynic::{MutationBuilder, QueryBuilder};
|
||||
use warp_graphql::error::UserFacingErrorInterface;
|
||||
use warp_graphql::mutations::purchase_addon_credits::{
|
||||
PurchaseAddonCredits, PurchaseAddonCreditsInput, PurchaseAddonCreditsResult,
|
||||
PurchaseAddonCreditsVariables,
|
||||
};
|
||||
use warp_graphql::mutations::stripe_billing_portal::{
|
||||
StripeBillingPortal, StripeBillingPortalInput, StripeBillingPortalResult,
|
||||
StripeBillingPortalVariables,
|
||||
};
|
||||
use warp_graphql::mutations::update_workspace_settings::{
|
||||
AddonCreditsSettingsInput, UpdateWorkspaceSettings, UpdateWorkspaceSettingsInput,
|
||||
UpdateWorkspaceSettingsResult, UpdateWorkspaceSettingsVariables,
|
||||
UsageBasedPricingSettingsInput,
|
||||
};
|
||||
use warp_graphql::queries::get_ai_overages_for_workspace::{
|
||||
GetAiOveragesForWorkspace, GetAiOveragesForWorkspaceVariables, UserResult,
|
||||
};
|
||||
|
||||
use crate::server::graphql::{get_request_context, get_user_facing_error_message};
|
||||
use crate::server::ids::ServerId;
|
||||
|
||||
#[cfg(test)]
|
||||
use mockall::{automock, predicate::*};
|
||||
|
||||
#[cfg_attr(test, automock)]
|
||||
#[cfg_attr(not(target_family = "wasm"), async_trait)]
|
||||
#[cfg_attr(target_family = "wasm", async_trait(?Send))]
|
||||
pub trait WorkspaceClient: 'static + Send + Sync {
|
||||
async fn generate_stripe_billing_portal_link(&self, team_uid: ServerId) -> Result<String>;
|
||||
|
||||
async fn update_usage_based_pricing_settings(
|
||||
&self,
|
||||
team_uid: ServerId,
|
||||
usage_based_pricing_enabled: bool,
|
||||
max_monthly_spend_cents: Option<u32>,
|
||||
) -> Result<WorkspacesMetadataResponse>;
|
||||
|
||||
async fn refresh_ai_overages(&self) -> Result<AiOverages>;
|
||||
|
||||
async fn purchase_addon_credits(
|
||||
&self,
|
||||
team_uid: ServerId,
|
||||
credits: i32,
|
||||
) -> Result<WorkspacesMetadataResponse>;
|
||||
|
||||
async fn update_addon_credits_settings(
|
||||
&self,
|
||||
team_uid: ServerId,
|
||||
auto_reload_enabled: Option<bool>,
|
||||
max_monthly_spend_cents: Option<i32>,
|
||||
selected_auto_reload_credit_denomination: Option<i32>,
|
||||
) -> Result<WorkspacesMetadataResponse>;
|
||||
}
|
||||
|
||||
#[cfg_attr(not(target_family = "wasm"), async_trait)]
|
||||
#[cfg_attr(target_family = "wasm", async_trait(?Send))]
|
||||
impl WorkspaceClient for ServerApi {
|
||||
async fn generate_stripe_billing_portal_link(&self, team_uid: ServerId) -> Result<String> {
|
||||
let variables = StripeBillingPortalVariables {
|
||||
input: StripeBillingPortalInput {
|
||||
team_uid: team_uid.into(),
|
||||
},
|
||||
request_context: get_request_context(),
|
||||
};
|
||||
let operation = StripeBillingPortal::build(variables);
|
||||
let response = self.send_graphql_request(operation, None).await?;
|
||||
|
||||
match response.stripe_billing_portal {
|
||||
StripeBillingPortalResult::StripeBillingPortalOutput(output) => Ok(output.url),
|
||||
StripeBillingPortalResult::UserFacingError(error) => {
|
||||
Err(anyhow!(get_user_facing_error_message(error)))
|
||||
}
|
||||
StripeBillingPortalResult::Unknown => Err(anyhow!("Unknown error")),
|
||||
}
|
||||
}
|
||||
|
||||
async fn update_usage_based_pricing_settings(
|
||||
&self,
|
||||
team_uid: ServerId,
|
||||
usage_based_pricing_enabled: bool,
|
||||
max_monthly_spend_cents: Option<u32>,
|
||||
) -> Result<WorkspacesMetadataResponse> {
|
||||
if let Some(cents) = max_monthly_spend_cents {
|
||||
if cents > i32::MAX as u32 {
|
||||
return Err(anyhow!(
|
||||
"Maximum monthly spend cannot exceed {} cents",
|
||||
i32::MAX
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
let variables = UpdateWorkspaceSettingsVariables {
|
||||
input: UpdateWorkspaceSettingsInput {
|
||||
workspace_uid: team_uid.to_string(),
|
||||
set_usage_based_pricing_settings: Some(UsageBasedPricingSettingsInput {
|
||||
enabled: Some(usage_based_pricing_enabled),
|
||||
max_monthly_spend_cents: max_monthly_spend_cents.map(|cents| cents as i32),
|
||||
}),
|
||||
set_addon_credits_settings: None,
|
||||
},
|
||||
request_context: get_request_context(),
|
||||
};
|
||||
let operation = UpdateWorkspaceSettings::build(variables);
|
||||
let response = self.send_graphql_request(operation, None).await?;
|
||||
|
||||
match response.update_workspace_settings {
|
||||
UpdateWorkspaceSettingsResult::UpdateWorkspaceSettingsOutput(_) => {
|
||||
TeamClient::workspaces_metadata(self)
|
||||
.await
|
||||
.map(|w| w.metadata)
|
||||
}
|
||||
UpdateWorkspaceSettingsResult::UserFacingError(error) => {
|
||||
Err(anyhow!(get_user_facing_error_message(error)))
|
||||
}
|
||||
UpdateWorkspaceSettingsResult::Unknown => Err(anyhow!("Unknown error")),
|
||||
}
|
||||
}
|
||||
|
||||
async fn refresh_ai_overages(&self) -> Result<AiOverages> {
|
||||
let variables = GetAiOveragesForWorkspaceVariables {
|
||||
request_context: get_request_context(),
|
||||
};
|
||||
let operation = GetAiOveragesForWorkspace::build(variables);
|
||||
let response = self.send_graphql_request(operation, None).await?;
|
||||
|
||||
match response.user {
|
||||
UserResult::UserOutput(user_output) => user_output
|
||||
.user
|
||||
.workspaces
|
||||
.first()
|
||||
.as_ref()
|
||||
.ok_or_else(|| anyhow!("No workspace found"))?
|
||||
.billing_metadata
|
||||
.ai_overages
|
||||
.as_ref()
|
||||
.ok_or_else(|| anyhow!("No AI overages found"))
|
||||
.map(|overages| AiOverages {
|
||||
current_monthly_request_cost_cents: overages.current_monthly_request_cost_cents,
|
||||
current_monthly_requests_used: overages.current_monthly_requests_used,
|
||||
current_period_end: overages.current_period_end.utc(),
|
||||
}),
|
||||
UserResult::Unknown => Err(anyhow!("Unknown error")),
|
||||
}
|
||||
}
|
||||
|
||||
async fn purchase_addon_credits(
|
||||
&self,
|
||||
team_uid: ServerId,
|
||||
credits: i32,
|
||||
) -> Result<WorkspacesMetadataResponse> {
|
||||
let variables = PurchaseAddonCreditsVariables {
|
||||
input: PurchaseAddonCreditsInput {
|
||||
team_uid: team_uid.into(),
|
||||
credits,
|
||||
},
|
||||
request_context: get_request_context(),
|
||||
};
|
||||
let operation = PurchaseAddonCredits::build(variables);
|
||||
let response = self.send_graphql_request(operation, None).await;
|
||||
|
||||
match response {
|
||||
Err(_) => Err(anyhow!("Failed to purchase add-on credits")),
|
||||
Ok(response) => match response.purchase_addon_credits {
|
||||
PurchaseAddonCreditsResult::PurchaseAddonCreditsOutput(_) => {
|
||||
TeamClient::workspaces_metadata(self)
|
||||
.await
|
||||
.map(|w| w.metadata)
|
||||
}
|
||||
PurchaseAddonCreditsResult::UserFacingError(error) => match error.error {
|
||||
UserFacingErrorInterface::BudgetExceededError(budget_error) => {
|
||||
Err(budget_error.into())
|
||||
}
|
||||
UserFacingErrorInterface::PaymentMethodDeclinedError(
|
||||
payment_declined_error,
|
||||
) => Err(payment_declined_error.into()),
|
||||
_ => Err(anyhow!(get_user_facing_error_message(error))),
|
||||
},
|
||||
PurchaseAddonCreditsResult::Unknown => Err(anyhow!("Unknown error")),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
async fn update_addon_credits_settings(
|
||||
&self,
|
||||
team_uid: ServerId,
|
||||
auto_reload_enabled: Option<bool>,
|
||||
max_monthly_spend_cents: Option<i32>,
|
||||
selected_auto_reload_credit_denomination: Option<i32>,
|
||||
) -> Result<WorkspacesMetadataResponse> {
|
||||
let variables = UpdateWorkspaceSettingsVariables {
|
||||
input: UpdateWorkspaceSettingsInput {
|
||||
workspace_uid: team_uid.to_string(),
|
||||
set_usage_based_pricing_settings: None,
|
||||
set_addon_credits_settings: Some(AddonCreditsSettingsInput {
|
||||
auto_reload_enabled,
|
||||
max_monthly_spend_cents,
|
||||
selected_auto_reload_credit_denomination,
|
||||
}),
|
||||
},
|
||||
request_context: get_request_context(),
|
||||
};
|
||||
let operation = UpdateWorkspaceSettings::build(variables);
|
||||
let response = self.send_graphql_request(operation, None).await?;
|
||||
|
||||
match response.update_workspace_settings {
|
||||
UpdateWorkspaceSettingsResult::UpdateWorkspaceSettingsOutput(_) => {
|
||||
TeamClient::workspaces_metadata(self)
|
||||
.await
|
||||
.map(|w| w.metadata)
|
||||
}
|
||||
UpdateWorkspaceSettingsResult::UserFacingError(error) => {
|
||||
Err(anyhow!(get_user_facing_error_message(error)))
|
||||
}
|
||||
UpdateWorkspaceSettingsResult::Unknown => Err(anyhow!("Unknown error")),
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user