Initial public release of Warp.

Repo-Sync-Origin: warpdotdev/warp-internal@12af1d983b
This commit is contained in:
David Stern
2026-04-28 08:43:33 -05:00
commit 0dbd3d567a
4982 changed files with 1431549 additions and 0 deletions
File diff suppressed because it is too large Load Diff
+974
View File
@@ -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"));
}
+921
View File
@@ -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;
+36
View File
@@ -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(())
}
+192
View File
@@ -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"
}
})
);
}
+350
View File
@@ -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"));
});
}
+93
View File
@@ -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"
)),
}
}
}
+728
View File
@@ -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"))
}
}
}
}
+223
View File
@@ -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")),
}
}
}