Compare commits
15
Commits
6b1b8a8846
...
c10d6c6914
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c10d6c6914 | ||
|
|
e67f9bee08 | ||
|
|
7c1d81cee9 | ||
|
|
7abc6aca45 | ||
|
|
f1bcd41ad9 | ||
|
|
8022128993 | ||
|
|
f3af8f21dc | ||
|
|
7c056b1db8 | ||
|
|
da14c82f71 | ||
|
|
1f32c693df | ||
|
|
74bfbe941e | ||
|
|
2884c08466 | ||
|
|
85e1ea320a | ||
|
|
7be428d8bf | ||
|
|
56798f9fb4 |
@@ -1,3 +0,0 @@
|
|||||||
[build]
|
|
||||||
build-dir = "{cargo-cache-home}/build/{workspace-path-hash}"
|
|
||||||
incremental = false
|
|
||||||
Generated
+1
@@ -6706,6 +6706,7 @@ dependencies = [
|
|||||||
name = "workspace-api"
|
name = "workspace-api"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
"protocol",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"ts-rs",
|
"ts-rs",
|
||||||
|
|||||||
@@ -68,6 +68,12 @@ default-members = [
|
|||||||
edition = "2024"
|
edition = "2024"
|
||||||
license = "MIT"
|
license = "MIT"
|
||||||
|
|
||||||
|
[profile.dev]
|
||||||
|
debug = "line-tables-only"
|
||||||
|
|
||||||
|
[profile.dev.package."*"]
|
||||||
|
debug = false
|
||||||
|
|
||||||
[workspace.dependencies]
|
[workspace.dependencies]
|
||||||
# Internal crates
|
# Internal crates
|
||||||
client = { path = "crates/client" }
|
client = { path = "crates/client" }
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
|
|
||||||
use agen::llm_client::scheme::{Scheme, anthropic::AnthropicScheme};
|
use agen::llm_client::scheme::{Scheme, anthropic::AnthropicScheme};
|
||||||
use agen::llm_client::transport::{HttpTransport, ResolvedAuth};
|
use agen::llm_client::transport::{HttpTransport, ResolvedAuth};
|
||||||
use agen::{Engine, EngineRunExit, StopReason};
|
use agen::{Engine, EngineRunExit, RunInterruptionReason};
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
|
|
||||||
#[tokio::main]
|
#[tokio::main]
|
||||||
@@ -51,7 +51,7 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
EngineRunExit::Finished => println!("✅ Task completed normally"),
|
EngineRunExit::Finished => println!("✅ Task completed normally"),
|
||||||
EngineRunExit::Paused => println!("⏸️ Task paused"),
|
EngineRunExit::Paused => println!("⏸️ Task paused"),
|
||||||
EngineRunExit::Yielded => println!("↩️ Task yielded"),
|
EngineRunExit::Yielded => println!("↩️ Task yielded"),
|
||||||
EngineRunExit::Interrupted(StopReason::LimitReached) => {
|
EngineRunExit::Interrupted(RunInterruptionReason::LimitReached) => {
|
||||||
println!("🔒 Turn limit reached")
|
println!("🔒 Turn limit reached")
|
||||||
}
|
}
|
||||||
EngineRunExit::Interrupted(reason) => println!("❌ Task interrupted: {reason:?}"),
|
EngineRunExit::Interrupted(reason) => println!("❌ Task interrupted: {reason:?}"),
|
||||||
|
|||||||
@@ -39,7 +39,7 @@ use tracing::info;
|
|||||||
use tracing_subscriber::EnvFilter;
|
use tracing_subscriber::EnvFilter;
|
||||||
|
|
||||||
use agen::{
|
use agen::{
|
||||||
Engine, EngineRunExit, StopReason,
|
Engine, EngineRunExit, RunInterruptionReason,
|
||||||
interceptor::{Interceptor, PostToolAction, ToolResultInfo},
|
interceptor::{Interceptor, PostToolAction, ToolResultInfo},
|
||||||
llm_client::{
|
llm_client::{
|
||||||
LlmClient,
|
LlmClient,
|
||||||
@@ -478,7 +478,8 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
// One-shot mode
|
// One-shot mode
|
||||||
if let Some(prompt) = args.prompt {
|
if let Some(prompt) = args.prompt {
|
||||||
let output = engine.run(&mut history, &prompt).await;
|
let output = engine.run(&mut history, &prompt).await;
|
||||||
if let EngineRunExit::Interrupted(StopReason::Unexpected(error)) = output.result {
|
if let EngineRunExit::Interrupted(RunInterruptionReason::Unexpected(error)) = output.result
|
||||||
|
{
|
||||||
eprintln!("\n❌ Error: {error}");
|
eprintln!("\n❌ Error: {error}");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -518,7 +519,7 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
if let EngineRunExit::Interrupted(StopReason::Unexpected(error)) =
|
if let EngineRunExit::Interrupted(RunInterruptionReason::Unexpected(error)) =
|
||||||
locked.run(&mut history, input).await
|
locked.run(&mut history, input).await
|
||||||
{
|
{
|
||||||
eprintln!("\n❌ Error: {error}");
|
eprintln!("\n❌ Error: {error}");
|
||||||
|
|||||||
@@ -147,12 +147,12 @@ pub enum EngineRunExit {
|
|||||||
Finished,
|
Finished,
|
||||||
Paused,
|
Paused,
|
||||||
Yielded,
|
Yielded,
|
||||||
Interrupted(StopReason),
|
Interrupted(RunInterruptionReason),
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A typed reason why an engine run could not finish normally.
|
/// A typed reason why an engine run could not finish normally.
|
||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
pub enum StopReason {
|
pub enum RunInterruptionReason {
|
||||||
LimitReached,
|
LimitReached,
|
||||||
ContextWindowExceeded,
|
ContextWindowExceeded,
|
||||||
Cancelled,
|
Cancelled,
|
||||||
@@ -165,13 +165,15 @@ impl From<Result<EngineResult, EngineError>> for EngineRunExit {
|
|||||||
Ok(EngineResult::Finished) => Self::Finished,
|
Ok(EngineResult::Finished) => Self::Finished,
|
||||||
Ok(EngineResult::Paused) => Self::Paused,
|
Ok(EngineResult::Paused) => Self::Paused,
|
||||||
Ok(EngineResult::Yielded) => Self::Yielded,
|
Ok(EngineResult::Yielded) => Self::Yielded,
|
||||||
Ok(EngineResult::LimitReached) => Self::Interrupted(StopReason::LimitReached),
|
Ok(EngineResult::LimitReached) => {
|
||||||
Err(EngineError::Client(ClientError::ContextWindowExceeded)) => {
|
Self::Interrupted(RunInterruptionReason::LimitReached)
|
||||||
Self::Interrupted(StopReason::ContextWindowExceeded)
|
|
||||||
}
|
}
|
||||||
Err(EngineError::Cancelled) => Self::Interrupted(StopReason::Cancelled),
|
Err(EngineError::Client(ClientError::ContextWindowExceeded)) => {
|
||||||
|
Self::Interrupted(RunInterruptionReason::ContextWindowExceeded)
|
||||||
|
}
|
||||||
|
Err(EngineError::Cancelled) => Self::Interrupted(RunInterruptionReason::Cancelled),
|
||||||
Err(EngineError::PauseRequested) => Self::Paused,
|
Err(EngineError::PauseRequested) => Self::Paused,
|
||||||
Err(error) => Self::Interrupted(StopReason::Unexpected(error)),
|
Err(error) => Self::Interrupted(RunInterruptionReason::Unexpected(error)),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ pub use agen_macros::{description, tool, tool_registry};
|
|||||||
pub use callback::{TextBlockScope, ThinkingBlockScope, ToolUseBlockScope};
|
pub use callback::{TextBlockScope, ThinkingBlockScope, ToolUseBlockScope};
|
||||||
pub use engine::{
|
pub use engine::{
|
||||||
Engine, EngineConfig, EngineError, EngineResult, EngineRunExit, EngineRunOutput,
|
Engine, EngineConfig, EngineError, EngineResult, EngineRunExit, EngineRunOutput,
|
||||||
LlmRetryNotice, StopReason, ToolRegistryError,
|
LlmRetryNotice, RunInterruptionReason, ToolRegistryError,
|
||||||
};
|
};
|
||||||
pub use handler::ToolUseBlockStart;
|
pub use handler::ToolUseBlockStart;
|
||||||
pub use history::{History, HistoryEntry};
|
pub use history::{History, HistoryEntry};
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ use agen::interceptor::{
|
|||||||
};
|
};
|
||||||
use agen::llm_client::event::{Event, ResponseStatus, StatusEvent};
|
use agen::llm_client::event::{Event, ResponseStatus, StatusEvent};
|
||||||
use agen::tool::{Tool, ToolDefinition, ToolError, ToolMeta, ToolOutput};
|
use agen::tool::{Tool, ToolDefinition, ToolError, ToolMeta, ToolOutput};
|
||||||
use agen::{Engine, EngineError, EngineRunExit, History, StopReason};
|
use agen::{Engine, EngineError, EngineRunExit, History, RunInterruptionReason};
|
||||||
use async_trait::async_trait;
|
use async_trait::async_trait;
|
||||||
use common::MockLlmClient;
|
use common::MockLlmClient;
|
||||||
|
|
||||||
@@ -205,7 +205,7 @@ async fn history_append_failure_stops_before_tool_execution() {
|
|||||||
let exit = engine.run(&mut history, "use the tool").await;
|
let exit = engine.run(&mut history, "use the tool").await;
|
||||||
|
|
||||||
assert!(
|
assert!(
|
||||||
matches!(exit, EngineRunExit::Interrupted(StopReason::Unexpected(EngineError::HistoryAppend(ref message))) if message == "simulated ENOSPC")
|
matches!(exit, EngineRunExit::Interrupted(RunInterruptionReason::Unexpected(EngineError::HistoryAppend(ref message))) if message == "simulated ENOSPC")
|
||||||
);
|
);
|
||||||
assert_eq!(tool.call_count(), 0);
|
assert_eq!(tool.call_count(), 0);
|
||||||
assert_eq!(history.len(), 1);
|
assert_eq!(history.len(), 1);
|
||||||
@@ -730,7 +730,7 @@ async fn paused_tool_resume_does_not_reset_the_consumed_turn_budget() {
|
|||||||
|
|
||||||
assert!(matches!(
|
assert!(matches!(
|
||||||
engine.resume(&mut history).await,
|
engine.resume(&mut history).await,
|
||||||
EngineRunExit::Interrupted(StopReason::LimitReached)
|
EngineRunExit::Interrupted(RunInterruptionReason::LimitReached)
|
||||||
));
|
));
|
||||||
assert_eq!(engine.turn_count(), 1);
|
assert_eq!(engine.turn_count(), 1);
|
||||||
assert_eq!(engine.active_run_turn_count(), None);
|
assert_eq!(engine.active_run_turn_count(), None);
|
||||||
@@ -785,7 +785,7 @@ async fn interceptor_continuation_consumes_the_logical_run_budget() {
|
|||||||
|
|
||||||
assert!(matches!(
|
assert!(matches!(
|
||||||
engine.run(&mut history, "start").await,
|
engine.run(&mut history, "start").await,
|
||||||
EngineRunExit::Interrupted(StopReason::LimitReached)
|
EngineRunExit::Interrupted(RunInterruptionReason::LimitReached)
|
||||||
));
|
));
|
||||||
assert_eq!(engine.turn_count(), 1);
|
assert_eq!(engine.turn_count(), 1);
|
||||||
assert_eq!(engine.llm_call_count(), 1);
|
assert_eq!(engine.llm_call_count(), 1);
|
||||||
@@ -803,7 +803,7 @@ async fn restored_active_run_budget_is_enforced_before_another_llm_call() {
|
|||||||
|
|
||||||
assert!(matches!(
|
assert!(matches!(
|
||||||
engine.resume(&mut history).await,
|
engine.resume(&mut history).await,
|
||||||
EngineRunExit::Interrupted(StopReason::LimitReached)
|
EngineRunExit::Interrupted(RunInterruptionReason::LimitReached)
|
||||||
));
|
));
|
||||||
assert_eq!(engine.turn_count(), 7);
|
assert_eq!(engine.turn_count(), 7);
|
||||||
assert_eq!(engine.llm_call_count(), 0);
|
assert_eq!(engine.llm_call_count(), 0);
|
||||||
|
|||||||
@@ -580,7 +580,7 @@ async fn cooperative_cancellation_commits_bounded_terminal_output() {
|
|||||||
);
|
);
|
||||||
assert!(matches!(
|
assert!(matches!(
|
||||||
output.result,
|
output.result,
|
||||||
agen::EngineRunExit::Interrupted(agen::StopReason::Cancelled)
|
agen::EngineRunExit::Interrupted(agen::RunInterruptionReason::Cancelled)
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1214,7 +1214,7 @@ async fn post_tool_abort_commits_confirmed_result_before_stopping_run() {
|
|||||||
);
|
);
|
||||||
assert!(matches!(
|
assert!(matches!(
|
||||||
output.result,
|
output.result,
|
||||||
agen::EngineRunExit::Interrupted(agen::StopReason::Unexpected(
|
agen::EngineRunExit::Interrupted(agen::RunInterruptionReason::Unexpected(
|
||||||
agen::EngineError::Aborted(ref reason)
|
agen::EngineError::Aborted(ref reason)
|
||||||
)) if reason == "policy stopped the run"
|
)) if reason == "policy stopped the run"
|
||||||
));
|
));
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
use reqwest::Method;
|
use reqwest::Method;
|
||||||
|
use serde::Serialize;
|
||||||
use serde::de::DeserializeOwned;
|
use serde::de::DeserializeOwned;
|
||||||
use serde::{Deserialize, Serialize};
|
|
||||||
use ticket::{
|
use ticket::{
|
||||||
MarkdownText, NewOrchestrationPlanRecord, NewTicket, NewTicketEvent, NewTicketRelation,
|
MarkdownText, NewOrchestrationPlanRecord, NewTicket, NewTicketEvent, NewTicketRelation,
|
||||||
OrchestrationPlanKind, OrchestrationPlanRecord, Ticket, TicketBackend, TicketDependencyCheck,
|
OrchestrationPlanKind, OrchestrationPlanRecord, Ticket, TicketBackend, TicketDependencyCheck,
|
||||||
@@ -9,39 +9,17 @@ use ticket::{
|
|||||||
TicketRelationKind, TicketRelationView, TicketStateChange, TicketStateSelector, TicketSummary,
|
TicketRelationKind, TicketRelationView, TicketStateChange, TicketStateSelector, TicketSummary,
|
||||||
};
|
};
|
||||||
use workspace_api::{
|
use workspace_api::{
|
||||||
ListResponse, ObjectiveCreateRequest, ObjectiveDetail, ObjectiveEditRequest,
|
BrowserCreateWorkerResponse, BrowserWorkspaceOrchestratorResponse,
|
||||||
ObjectiveLinkTicketRequest, ObjectiveStateRequest, ObjectiveSummary,
|
CreateWorkspaceWorkerRequest, ListResponse, ObjectiveCreateRequest, ObjectiveDetail,
|
||||||
|
ObjectiveEditRequest, ObjectiveLinkTicketRequest, ObjectiveStateRequest, ObjectiveSummary,
|
||||||
TICKET_ORCHESTRATION_PLANS_QUERY_PATH, TICKET_RELATIONS_QUERY_PATH,
|
TICKET_ORCHESTRATION_PLANS_QUERY_PATH, TICKET_RELATIONS_QUERY_PATH,
|
||||||
|
WorkerLaunchOptionsResponse,
|
||||||
};
|
};
|
||||||
|
|
||||||
use crate::{BackendApiClient, BackendWorkspaceClientError};
|
use crate::{BackendApiClient, BackendWorkspaceClientError};
|
||||||
|
|
||||||
const DEFAULT_PRODUCT_LIST_LIMIT: usize = 1_000;
|
const DEFAULT_PRODUCT_LIST_LIMIT: usize = 1_000;
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
|
||||||
struct BackendWorkerLaunchOptions {
|
|
||||||
runtimes: Vec<BackendWorkerLaunchRuntime>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
|
||||||
struct BackendWorkerLaunchRuntime {
|
|
||||||
runtime_id: String,
|
|
||||||
worker_creation_available: bool,
|
|
||||||
working_directory_required: bool,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
|
||||||
struct BackendCreateWorkerResponse {
|
|
||||||
runtime_id: String,
|
|
||||||
worker_id: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
|
||||||
struct BackendWorkspaceOrchestratorResponse {
|
|
||||||
disposition: String,
|
|
||||||
worker: Option<BackendCreateWorkerResponse>,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Workspace-scoped Backend client for Ticket and Objective product state.
|
/// Workspace-scoped Backend client for Ticket and Objective product state.
|
||||||
///
|
///
|
||||||
/// Construction requires both the selected Backend URL and Workspace identity.
|
/// Construction requires both the selected Backend URL and Workspace identity.
|
||||||
@@ -267,7 +245,7 @@ impl BackendWorkspaceProductClient {
|
|||||||
&self,
|
&self,
|
||||||
ticket_id: &str,
|
ticket_id: &str,
|
||||||
) -> Result<String, BackendWorkspaceClientError> {
|
) -> Result<String, BackendWorkspaceClientError> {
|
||||||
let options: BackendWorkerLaunchOptions = self.get_json("/workers/launch-options")?;
|
let options: WorkerLaunchOptionsResponse = self.get_json("/workers/launch-options")?;
|
||||||
let runtime = options
|
let runtime = options
|
||||||
.runtimes
|
.runtimes
|
||||||
.iter()
|
.iter()
|
||||||
@@ -278,19 +256,19 @@ impl BackendWorkspaceProductClient {
|
|||||||
.to_string(),
|
.to_string(),
|
||||||
)
|
)
|
||||||
})?;
|
})?;
|
||||||
let response: BackendCreateWorkerResponse = self.send_json(
|
let request = CreateWorkspaceWorkerRequest {
|
||||||
Method::POST,
|
runtime_id: runtime.runtime_id.clone(),
|
||||||
"/workers",
|
display_name: format!("intake-{ticket_id}"),
|
||||||
Some(&serde_json::json!({
|
profile: Some("builtin:intake".to_string()),
|
||||||
"runtime_id": runtime.runtime_id,
|
ticket_assignment: None,
|
||||||
"display_name": format!("intake-{ticket_id}"),
|
initial_submit: vec![protocol::Segment::Text {
|
||||||
"profile": "builtin:intake",
|
content: format!("Please handle intake for Ticket {ticket_id}."),
|
||||||
"initial_submit": [{
|
}],
|
||||||
"kind": "text",
|
working_directory: None,
|
||||||
"content": format!("Please handle intake for Ticket {ticket_id}.")
|
control_operation_id: None,
|
||||||
}]
|
};
|
||||||
})),
|
let response: BrowserCreateWorkerResponse =
|
||||||
)?;
|
self.send_json(Method::POST, "/workers", Some(&request))?;
|
||||||
Ok(format!(
|
Ok(format!(
|
||||||
"Started Intake Worker {}/{} for Ticket {ticket_id}",
|
"Started Intake Worker {}/{} for Ticket {ticket_id}",
|
||||||
response.runtime_id, response.worker_id
|
response.runtime_id, response.worker_id
|
||||||
@@ -298,7 +276,7 @@ impl BackendWorkspaceProductClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn start_workspace_orchestrator(&self) -> Result<String, BackendWorkspaceClientError> {
|
pub fn start_workspace_orchestrator(&self) -> Result<String, BackendWorkspaceClientError> {
|
||||||
let response: BackendWorkspaceOrchestratorResponse =
|
let response: BrowserWorkspaceOrchestratorResponse =
|
||||||
self.send_json::<(), _>(Method::POST, "/orchestrator", None)?;
|
self.send_json::<(), _>(Method::POST, "/orchestrator", None)?;
|
||||||
let worker = response.worker.ok_or_else(|| {
|
let worker = response.worker.ok_or_else(|| {
|
||||||
BackendWorkspaceClientError::InvalidTarget(
|
BackendWorkspaceClientError::InvalidTarget(
|
||||||
@@ -792,11 +770,11 @@ mod tests {
|
|||||||
let (base_url, requests, handle) = response_sequence_server(vec![
|
let (base_url, requests, handle) = response_sequence_server(vec![
|
||||||
(
|
(
|
||||||
"200 OK",
|
"200 OK",
|
||||||
r#"{"runtimes":[{"runtime_id":"embedded","worker_creation_available":true,"working_directory_required":false}]}"#,
|
r#"{"workspace_id":"workspace-a","runtimes":[{"runtime_id":"embedded","display_name":"Embedded","built_in":true,"worker_creation_available":true,"working_directory_required":false,"status":"connected","diagnostics":[]}],"default_profile":null,"profiles":[],"repositories":[],"working_directories":[],"diagnostics":[]}"#,
|
||||||
),
|
),
|
||||||
(
|
(
|
||||||
"200 OK",
|
"200 OK",
|
||||||
r#"{"runtime_id":"embedded","worker_id":"worker-1"}"#,
|
r#"{"workspace_id":"workspace-a","runtime_id":"embedded","worker_id":"worker-1","console_href":"/w/workspace-a/workers/worker-1","worker":{"runtime_id":"embedded","worker_id":"worker-1","host_id":"embedded","display_name":"Intake","label":"worker-1","profile":"builtin:intake","singleton_key":null,"tags":[],"workspace":{"visibility":"workspace","identity":"workspace-a","workspace_id":"workspace-a"},"state":"idle","last_seen_at":null,"pinned":false,"retention_state":"active","implementation":{"kind":"runtime","display_hint":"Runtime Worker"},"capabilities":{"can_stop":true,"can_spawn_followup":false},"diagnostics":[]},"diagnostics":[]}"#,
|
||||||
),
|
),
|
||||||
]);
|
]);
|
||||||
let client = BackendWorkspaceProductClient::new_with_access_token(
|
let client = BackendWorkspaceProductClient::new_with_access_token(
|
||||||
@@ -824,7 +802,7 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn workspace_orchestrator_launch_uses_scoped_backend_route() {
|
fn workspace_orchestrator_launch_uses_scoped_backend_route() {
|
||||||
let body = r#"{"disposition":"created","worker":{"runtime_id":"embedded","worker_id":"worker-2"}}"#;
|
let body = r#"{"workspace_id":"workspace-a","online":true,"disposition":"created","worker":{"runtime_id":"embedded","worker_id":"worker-2","host_id":"embedded","display_name":"Orchestrator","label":"worker-2","profile":"builtin:orchestrator","singleton_key":"workspace-orchestrator","tags":[],"workspace":{"visibility":"workspace","identity":"workspace-a","workspace_id":"workspace-a"},"state":"idle","last_seen_at":null,"pinned":true,"retention_state":"active","implementation":{"kind":"runtime","display_hint":"Runtime Worker"},"capabilities":{"can_stop":true,"can_spawn_followup":false},"diagnostics":[]},"diagnostics":[]}"#;
|
||||||
let (base_url, request, handle) = one_response_server("200 OK", body);
|
let (base_url, request, handle) = one_response_server("200 OK", body);
|
||||||
let client = BackendWorkspaceProductClient::new_with_access_token(
|
let client = BackendWorkspaceProductClient::new_with_access_token(
|
||||||
base_url,
|
base_url,
|
||||||
|
|||||||
@@ -274,6 +274,12 @@ pub struct RegisterReviewerChildSession {
|
|||||||
pub reviewer_profile: String,
|
pub reviewer_profile: String,
|
||||||
pub now: DateTime<Utc>,
|
pub now: DateTime<Utc>,
|
||||||
}
|
}
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub struct ReviewSubmissionAuthorization {
|
||||||
|
pub workspace_id: String,
|
||||||
|
pub subject_ref: String,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub struct SubmitMergeRequestReview {
|
pub struct SubmitMergeRequestReview {
|
||||||
pub ticket_id: String,
|
pub ticket_id: String,
|
||||||
@@ -535,6 +541,34 @@ impl MergeRequestStore {
|
|||||||
t.commit()?;
|
t.commit()?;
|
||||||
Ok(RequestedMergeRequestReview { request_event: e })
|
Ok(RequestedMergeRequestReview { request_event: e })
|
||||||
}
|
}
|
||||||
|
pub fn authorize_review_submission(
|
||||||
|
&self,
|
||||||
|
ticket_id: &str,
|
||||||
|
capability_token: &str,
|
||||||
|
) -> Result<ReviewSubmissionAuthorization, MergeRequestError> {
|
||||||
|
let connection = self.lock()?;
|
||||||
|
connection
|
||||||
|
.query_row(
|
||||||
|
"SELECT g.workspace_id,g.subject_ref
|
||||||
|
FROM merge_request_review_grants g
|
||||||
|
JOIN merge_request_ticket_relations rel
|
||||||
|
ON rel.workspace_id=g.workspace_id AND rel.merge_request_id=g.merge_request_id
|
||||||
|
JOIN merge_requests mr
|
||||||
|
ON mr.workspace_id=g.workspace_id AND mr.merge_request_id=g.merge_request_id
|
||||||
|
WHERE g.capability_token=?1 AND rel.ticket_id=?2
|
||||||
|
AND g.status='issued' AND mr.state='open'",
|
||||||
|
params![capability_token, ticket_id],
|
||||||
|
|row| {
|
||||||
|
Ok(ReviewSubmissionAuthorization {
|
||||||
|
workspace_id: row.get(0)?,
|
||||||
|
subject_ref: row.get(1)?,
|
||||||
|
})
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.optional()?
|
||||||
|
.ok_or_else(|| MergeRequestError::Unauthorized("review grant invalid".into()))
|
||||||
|
}
|
||||||
|
|
||||||
pub fn submit_review(
|
pub fn submit_review(
|
||||||
&self,
|
&self,
|
||||||
i: SubmitMergeRequestReview,
|
i: SubmitMergeRequestReview,
|
||||||
|
|||||||
@@ -91,6 +91,23 @@ fn approve(s: &MergeRequestStore, subject: &str, token: &str) -> ReviewEvent {
|
|||||||
})
|
})
|
||||||
.unwrap()
|
.unwrap()
|
||||||
}
|
}
|
||||||
|
#[test]
|
||||||
|
fn review_submission_authorization_rejects_invalid_grants_before_side_effects() {
|
||||||
|
let (_d, store) = fixture();
|
||||||
|
open(&store);
|
||||||
|
request(&store, "published-source", "valid-token");
|
||||||
|
|
||||||
|
let invalid = store
|
||||||
|
.authorize_review_submission("T", "invalid-token")
|
||||||
|
.unwrap_err();
|
||||||
|
assert!(matches!(invalid, MergeRequestError::Unauthorized(_)));
|
||||||
|
let authorized = store
|
||||||
|
.authorize_review_submission("T", "valid-token")
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(authorized.workspace_id, "W");
|
||||||
|
assert_eq!(authorized.subject_ref, "published-source");
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn selectors_thread_and_completion_have_no_revision_or_commit_api() {
|
fn selectors_thread_and_completion_have_no_revision_or_commit_api() {
|
||||||
let (d, s) = fixture();
|
let (d, s) = fixture();
|
||||||
|
|||||||
@@ -557,7 +557,6 @@ pub enum SubscriptionWorkerState {
|
|||||||
Running,
|
Running,
|
||||||
Paused,
|
Paused,
|
||||||
Stopped,
|
Stopped,
|
||||||
Cancelled,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
@@ -1110,6 +1109,25 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn worker_subscription_state_has_exactly_four_lifecycle_values() {
|
||||||
|
for (state, wire) in [
|
||||||
|
(SubscriptionWorkerState::Idle, "idle"),
|
||||||
|
(SubscriptionWorkerState::Running, "running"),
|
||||||
|
(SubscriptionWorkerState::Paused, "paused"),
|
||||||
|
(SubscriptionWorkerState::Stopped, "stopped"),
|
||||||
|
] {
|
||||||
|
assert_eq!(
|
||||||
|
serde_json::to_value(state).unwrap(),
|
||||||
|
serde_json::json!(wire)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
assert!(
|
||||||
|
serde_json::from_value::<SubscriptionWorkerState>(serde_json::json!("cancelled"))
|
||||||
|
.is_err()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn client_selector_has_no_workspace_scope_field() {
|
fn client_selector_has_no_workspace_scope_field() {
|
||||||
let json = serde_json::to_value(EventSubscriptionSelector::WorkspaceWorkers).unwrap();
|
let json = serde_json::to_value(EventSubscriptionSelector::WorkspaceWorkers).unwrap();
|
||||||
|
|||||||
@@ -195,7 +195,7 @@ async fn run_and_persist(
|
|||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
}
|
}
|
||||||
agen::EngineRunExit::Interrupted(agen::StopReason::LimitReached) => {
|
agen::EngineRunExit::Interrupted(agen::RunInterruptionReason::LimitReached) => {
|
||||||
session_store::save_run_completed(
|
session_store::save_run_completed(
|
||||||
store,
|
store,
|
||||||
session_id,
|
session_id,
|
||||||
|
|||||||
@@ -179,6 +179,30 @@ pub struct WorkingDirectoryRequest {
|
|||||||
pub materialization: Option<RepositoryMaterializationContext>,
|
pub materialization: Option<RepositoryMaterializationContext>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Backend-authorized request to freshly resolve one Repository provider ref.
|
||||||
|
///
|
||||||
|
/// Runtime executes this against the registered source itself rather than a Workdir
|
||||||
|
/// or Runtime cache. Secret material is fetched through `materialization` and never
|
||||||
|
/// appears in the result.
|
||||||
|
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
pub struct RepositoryRefObservationRequest {
|
||||||
|
pub repository: WorkingDirectoryRepository,
|
||||||
|
pub selector: String,
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub materialization: Option<RepositoryMaterializationContext>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Provider-neutral proof of one freshly observed Repository ref.
|
||||||
|
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
pub struct RepositoryRefObservation {
|
||||||
|
pub repository_id: String,
|
||||||
|
pub source_revision: u64,
|
||||||
|
pub source_fingerprint: String,
|
||||||
|
pub selector: String,
|
||||||
|
pub revision_ref: String,
|
||||||
|
pub observed_at_epoch_seconds: u64,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
pub struct WorkingDirectoryClaim {
|
pub struct WorkingDirectoryClaim {
|
||||||
pub working_directory_id: String,
|
pub working_directory_id: String,
|
||||||
@@ -250,6 +274,10 @@ pub struct CreateWorkerRequest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Worker lifecycle status for the in-memory embedded runtime.
|
/// Worker lifecycle status for the in-memory embedded runtime.
|
||||||
|
///
|
||||||
|
/// Run termination details are carried separately by the Worker protocol. In
|
||||||
|
/// particular, cancellation returns a Worker to `Idle`; it is not a lifecycle
|
||||||
|
/// state of its own.
|
||||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
#[serde(rename_all = "snake_case")]
|
#[serde(rename_all = "snake_case")]
|
||||||
pub enum WorkerStatus {
|
pub enum WorkerStatus {
|
||||||
@@ -257,7 +285,6 @@ pub enum WorkerStatus {
|
|||||||
Running,
|
Running,
|
||||||
Paused,
|
Paused,
|
||||||
Stopped,
|
Stopped,
|
||||||
Cancelled,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl WorkerStatus {
|
impl WorkerStatus {
|
||||||
@@ -266,6 +293,13 @@ impl WorkerStatus {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "snake_case")]
|
||||||
|
pub(crate) enum WorkerRestoreIntent {
|
||||||
|
Automatic,
|
||||||
|
Explicit,
|
||||||
|
}
|
||||||
|
|
||||||
/// Lightweight catalog row.
|
/// Lightweight catalog row.
|
||||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
pub struct WorkerSummary {
|
pub struct WorkerSummary {
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
use crate::catalog::{
|
use crate::catalog::{
|
||||||
|
RepositoryRefObservation, RepositoryRefObservationRequest,
|
||||||
WorkingDirectoryRepositoryAccessRequest, WorkingDirectoryRequest, WorkingDirectoryStatus,
|
WorkingDirectoryRepositoryAccessRequest, WorkingDirectoryRequest, WorkingDirectoryStatus,
|
||||||
};
|
};
|
||||||
use crate::config_bundle::ConfigBundle;
|
use crate::config_bundle::ConfigBundle;
|
||||||
@@ -333,6 +334,16 @@ pub trait WorkerExecutionBackend: Send + Sync + 'static {
|
|||||||
))
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn observe_repository_ref(
|
||||||
|
&self,
|
||||||
|
_request: &RepositoryRefObservationRequest,
|
||||||
|
) -> Result<RepositoryRefObservation, WorkingDirectoryDiagnostic> {
|
||||||
|
Err(WorkingDirectoryDiagnostic::rejected(
|
||||||
|
"repository_ref_provider_unavailable",
|
||||||
|
"Worker execution backend does not support Repository ref observation",
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
fn list_working_directories(&self) -> Vec<WorkingDirectoryStatus> {
|
fn list_working_directories(&self) -> Vec<WorkingDirectoryStatus> {
|
||||||
Vec::new()
|
Vec::new()
|
||||||
}
|
}
|
||||||
@@ -501,6 +512,13 @@ impl WorkerExecutionBackendRef {
|
|||||||
.authorize_working_directory_repository_access(request)
|
.authorize_working_directory_repository_access(request)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub(crate) fn observe_repository_ref(
|
||||||
|
&self,
|
||||||
|
request: &RepositoryRefObservationRequest,
|
||||||
|
) -> Result<RepositoryRefObservation, WorkingDirectoryDiagnostic> {
|
||||||
|
self.backend.observe_repository_ref(request)
|
||||||
|
}
|
||||||
|
|
||||||
pub(crate) fn list_working_directories(&self) -> Vec<WorkingDirectoryStatus> {
|
pub(crate) fn list_working_directories(&self) -> Vec<WorkingDirectoryStatus> {
|
||||||
self.backend.list_working_directories()
|
self.backend.list_working_directories()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
use crate::catalog::{CreateWorkerRequest, WorkingDirectoryStatus};
|
use crate::catalog::{
|
||||||
|
CreateWorkerRequest, WorkerRestoreIntent, WorkerStatus, WorkingDirectoryStatus,
|
||||||
|
};
|
||||||
use crate::config_bundle::ConfigBundle;
|
use crate::config_bundle::ConfigBundle;
|
||||||
use crate::diagnostics::{DiagnosticSeverity, RuntimeDiagnostic};
|
use crate::diagnostics::{DiagnosticSeverity, RuntimeDiagnostic};
|
||||||
use crate::error::RuntimeError;
|
use crate::error::RuntimeError;
|
||||||
@@ -13,7 +15,7 @@ use std::io::{BufReader, Write};
|
|||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
use std::sync::atomic::{AtomicU64, Ordering};
|
use std::sync::atomic::{AtomicU64, Ordering};
|
||||||
|
|
||||||
const SCHEMA_VERSION: u32 = 3;
|
const SCHEMA_VERSION: u32 = 4;
|
||||||
const RUNTIME_FILE: &str = "runtime.json";
|
const RUNTIME_FILE: &str = "runtime.json";
|
||||||
const WORKERS_DIR: &str = "workers";
|
const WORKERS_DIR: &str = "workers";
|
||||||
const WORKER_FILE: &str = "worker.json";
|
const WORKER_FILE: &str = "worker.json";
|
||||||
@@ -274,13 +276,24 @@ pub(crate) struct PersistedRuntimeState {
|
|||||||
pub(crate) diagnostics: Vec<RuntimeDiagnostic>,
|
pub(crate) diagnostics: Vec<RuntimeDiagnostic>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
pub(crate) struct PersistedWorkerExecutionBinding {
|
||||||
|
pub(crate) run_generation: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
pub(crate) struct PersistedWorkerExecution {
|
||||||
|
pub(crate) binding: Option<PersistedWorkerExecutionBinding>,
|
||||||
|
pub(crate) restore_intent: WorkerRestoreIntent,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Clone, Debug)]
|
#[derive(Clone, Debug)]
|
||||||
pub(crate) struct PersistedWorkerRecord {
|
pub(crate) struct PersistedWorkerRecord {
|
||||||
pub(crate) worker_ref: WorkerRef,
|
pub(crate) worker_ref: WorkerRef,
|
||||||
pub(crate) worker_id: WorkerId,
|
pub(crate) worker_id: WorkerId,
|
||||||
pub(crate) request: CreateWorkerRequest,
|
pub(crate) request: CreateWorkerRequest,
|
||||||
/// Last generation durably reserved for this Worker's execution.
|
pub(crate) status: WorkerStatus,
|
||||||
pub(crate) run_generation: u64,
|
pub(crate) execution: PersistedWorkerExecution,
|
||||||
pub(crate) workspace_id: Option<String>,
|
pub(crate) workspace_id: Option<String>,
|
||||||
pub(crate) working_directory: Option<WorkingDirectoryStatus>,
|
pub(crate) working_directory: Option<WorkingDirectoryStatus>,
|
||||||
}
|
}
|
||||||
@@ -357,8 +370,8 @@ fn plan_runtime_store_migration(
|
|||||||
format!("Runtime store schema version {schema_version} is out of range"),
|
format!("Runtime store schema version {schema_version} is out of range"),
|
||||||
)
|
)
|
||||||
})?;
|
})?;
|
||||||
let staging = migration_sibling(root, "schema-v3-staging")?;
|
let staging = migration_sibling(root, "schema-v4-staging")?;
|
||||||
let backup = migration_sibling(root, "pre-schema-v3-backup")?;
|
let backup = migration_sibling(root, "pre-schema-v4-backup")?;
|
||||||
if staging.exists() || backup.exists() {
|
if staging.exists() || backup.exists() {
|
||||||
return Err(runtime_store_corrupt(
|
return Err(runtime_store_corrupt(
|
||||||
root,
|
root,
|
||||||
@@ -384,11 +397,11 @@ fn plan_runtime_store_migration(
|
|||||||
};
|
};
|
||||||
return Ok((plan, Vec::new()));
|
return Ok((plan, Vec::new()));
|
||||||
}
|
}
|
||||||
if !matches!(current_schema_version, 1 | 2) {
|
if current_schema_version != 3 {
|
||||||
return Err(runtime_store_corrupt(
|
return Err(runtime_store_corrupt(
|
||||||
&runtime_path,
|
&runtime_path,
|
||||||
format!(
|
format!(
|
||||||
"unsupported Runtime store schema version {schema_version}; expected 1, 2, or {SCHEMA_VERSION}"
|
"unsupported Runtime store schema version {schema_version}; expected 3 or {SCHEMA_VERSION}"
|
||||||
),
|
),
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
@@ -448,7 +461,7 @@ fn plan_runtime_store_migration(
|
|||||||
let worker_id = name.parse::<WorkerId>().map_err(|_| {
|
let worker_id = name.parse::<WorkerId>().map_err(|_| {
|
||||||
runtime_store_corrupt(
|
runtime_store_corrupt(
|
||||||
&source_dir,
|
&source_dir,
|
||||||
format!("schema-v2 Worker directory name must be a UUIDv7, found {name}"),
|
format!("pre-v4 Worker directory name must be a UUIDv7, found {name}"),
|
||||||
)
|
)
|
||||||
})?;
|
})?;
|
||||||
(worker_id, None, None)
|
(worker_id, None, None)
|
||||||
@@ -610,7 +623,7 @@ fn migrate_worker_document(
|
|||||||
snapshot_path: &Path,
|
snapshot_path: &Path,
|
||||||
) -> Result<serde_json::Value, RuntimeError> {
|
) -> Result<serde_json::Value, RuntimeError> {
|
||||||
if source_schema_version == 1 {
|
if source_schema_version == 1 {
|
||||||
return migrate_v1_worker_document(
|
document = migrate_v1_worker_document(
|
||||||
document,
|
document,
|
||||||
mapping.ok_or_else(|| {
|
mapping.ok_or_else(|| {
|
||||||
runtime_store_corrupt(
|
runtime_store_corrupt(
|
||||||
@@ -619,7 +632,7 @@ fn migrate_worker_document(
|
|||||||
)
|
)
|
||||||
})?,
|
})?,
|
||||||
snapshot_path,
|
snapshot_path,
|
||||||
);
|
)?;
|
||||||
}
|
}
|
||||||
let object = document.as_object_mut().ok_or_else(|| {
|
let object = document.as_object_mut().ok_or_else(|| {
|
||||||
runtime_store_corrupt(
|
runtime_store_corrupt(
|
||||||
@@ -627,10 +640,46 @@ fn migrate_worker_document(
|
|||||||
"Worker snapshot must be an object".to_string(),
|
"Worker snapshot must be an object".to_string(),
|
||||||
)
|
)
|
||||||
})?;
|
})?;
|
||||||
|
let run_generation = object
|
||||||
|
.remove("run_generation")
|
||||||
|
.map(|value| {
|
||||||
|
value.as_u64().ok_or_else(|| {
|
||||||
|
runtime_store_corrupt(
|
||||||
|
snapshot_path,
|
||||||
|
"Worker snapshot run_generation must be an unsigned integer".to_string(),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.transpose()?
|
||||||
|
.filter(|generation| *generation > 0);
|
||||||
|
let legacy_execution = object.remove("execution");
|
||||||
|
if !object.contains_key("working_directory") {
|
||||||
|
if let Some(working_directory) = legacy_execution
|
||||||
|
.as_ref()
|
||||||
|
.and_then(serde_json::Value::as_object)
|
||||||
|
.and_then(|execution| execution.get("working_directory"))
|
||||||
|
.cloned()
|
||||||
|
{
|
||||||
|
object.insert("working_directory".to_string(), working_directory);
|
||||||
|
}
|
||||||
|
}
|
||||||
object.insert(
|
object.insert(
|
||||||
"schema_version".to_string(),
|
"schema_version".to_string(),
|
||||||
serde_json::Value::from(SCHEMA_VERSION),
|
serde_json::Value::from(SCHEMA_VERSION),
|
||||||
);
|
);
|
||||||
|
object.insert(
|
||||||
|
"status".to_string(),
|
||||||
|
serde_json::Value::String("stopped".to_string()),
|
||||||
|
);
|
||||||
|
object.insert(
|
||||||
|
"execution".to_string(),
|
||||||
|
serde_json::json!({
|
||||||
|
"binding": run_generation.map(|run_generation| {
|
||||||
|
serde_json::json!({ "run_generation": run_generation })
|
||||||
|
}),
|
||||||
|
"restore_intent": "explicit",
|
||||||
|
}),
|
||||||
|
);
|
||||||
Ok(document)
|
Ok(document)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1005,8 +1054,8 @@ fn migrate_runtime_store(
|
|||||||
if !plan.migration_required {
|
if !plan.migration_required {
|
||||||
return Ok(plan);
|
return Ok(plan);
|
||||||
}
|
}
|
||||||
let staging = migration_sibling(root, "schema-v3-staging")?;
|
let staging = migration_sibling(root, "schema-v4-staging")?;
|
||||||
let backup = migration_sibling(root, "pre-schema-v3-backup")?;
|
let backup = migration_sibling(root, "pre-schema-v4-backup")?;
|
||||||
if staging.exists() || backup.exists() {
|
if staging.exists() || backup.exists() {
|
||||||
return Err(runtime_store_corrupt(
|
return Err(runtime_store_corrupt(
|
||||||
root,
|
root,
|
||||||
@@ -1236,22 +1285,12 @@ struct WorkerSnapshot {
|
|||||||
worker_ref: WorkerRef,
|
worker_ref: WorkerRef,
|
||||||
worker_id: WorkerId,
|
worker_id: WorkerId,
|
||||||
request: CreateWorkerRequest,
|
request: CreateWorkerRequest,
|
||||||
#[serde(default)]
|
status: WorkerStatus,
|
||||||
run_generation: u64,
|
execution: PersistedWorkerExecution,
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
workspace_id: Option<String>,
|
workspace_id: Option<String>,
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
working_directory: Option<WorkingDirectoryStatus>,
|
working_directory: Option<WorkingDirectoryStatus>,
|
||||||
/// One-way migration input for schema-v1 snapshots. New snapshots never
|
|
||||||
/// write the removed execution projection.
|
|
||||||
#[serde(default, rename = "execution", skip_serializing)]
|
|
||||||
legacy_execution: Option<LegacyWorkerExecutionProjection>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone, Debug, Deserialize)]
|
|
||||||
struct LegacyWorkerExecutionProjection {
|
|
||||||
#[serde(default)]
|
|
||||||
working_directory: Option<WorkingDirectoryStatus>,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl WorkerSnapshot {
|
impl WorkerSnapshot {
|
||||||
@@ -1261,10 +1300,10 @@ impl WorkerSnapshot {
|
|||||||
worker_ref: worker.worker_ref.clone(),
|
worker_ref: worker.worker_ref.clone(),
|
||||||
worker_id: worker.worker_id.clone(),
|
worker_id: worker.worker_id.clone(),
|
||||||
request: worker.request.clone(),
|
request: worker.request.clone(),
|
||||||
run_generation: worker.run_generation,
|
status: worker.status,
|
||||||
|
execution: worker.execution.clone(),
|
||||||
workspace_id: worker.workspace_id.clone(),
|
workspace_id: worker.workspace_id.clone(),
|
||||||
working_directory: worker.working_directory.clone(),
|
working_directory: worker.working_directory.clone(),
|
||||||
legacy_execution: None,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1289,6 +1328,51 @@ impl WorkerSnapshot {
|
|||||||
),
|
),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
match (self.status, self.execution.restore_intent) {
|
||||||
|
(status, WorkerRestoreIntent::Automatic) if status.is_active() => {
|
||||||
|
let Some(binding) = self.execution.binding.as_ref() else {
|
||||||
|
return Err(RuntimeError::StoreCorrupt {
|
||||||
|
operation: "read worker snapshot",
|
||||||
|
path: path.to_path_buf(),
|
||||||
|
message: "automatic restore intent requires an execution binding"
|
||||||
|
.to_string(),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
if binding.run_generation == 0 {
|
||||||
|
return Err(RuntimeError::StoreCorrupt {
|
||||||
|
operation: "read worker snapshot",
|
||||||
|
path: path.to_path_buf(),
|
||||||
|
message: "execution binding run_generation must be greater than zero"
|
||||||
|
.to_string(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
(WorkerStatus::Stopped, WorkerRestoreIntent::Explicit) => {
|
||||||
|
if self
|
||||||
|
.execution
|
||||||
|
.binding
|
||||||
|
.as_ref()
|
||||||
|
.is_some_and(|binding| binding.run_generation == 0)
|
||||||
|
{
|
||||||
|
return Err(RuntimeError::StoreCorrupt {
|
||||||
|
operation: "read worker snapshot",
|
||||||
|
path: path.to_path_buf(),
|
||||||
|
message: "execution binding run_generation must be greater than zero"
|
||||||
|
.to_string(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => {
|
||||||
|
return Err(RuntimeError::StoreCorrupt {
|
||||||
|
operation: "read worker snapshot",
|
||||||
|
path: path.to_path_buf(),
|
||||||
|
message: format!(
|
||||||
|
"worker status {:?} conflicts with restore intent {:?}",
|
||||||
|
self.status, self.execution.restore_intent
|
||||||
|
),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1303,12 +1387,10 @@ impl WorkerSnapshot {
|
|||||||
worker_ref: self.worker_ref,
|
worker_ref: self.worker_ref,
|
||||||
worker_id: self.worker_id,
|
worker_id: self.worker_id,
|
||||||
request: self.request,
|
request: self.request,
|
||||||
run_generation: self.run_generation,
|
status: self.status,
|
||||||
|
execution: self.execution,
|
||||||
workspace_id,
|
workspace_id,
|
||||||
working_directory: self.working_directory.or_else(|| {
|
working_directory: self.working_directory,
|
||||||
self.legacy_execution
|
|
||||||
.and_then(|execution| execution.working_directory)
|
|
||||||
}),
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,9 +11,9 @@ use crate::auth::{
|
|||||||
verify_capability_token,
|
verify_capability_token,
|
||||||
};
|
};
|
||||||
use crate::catalog::{
|
use crate::catalog::{
|
||||||
ConfigBundleRef, CreateWorkerRequest, WorkerDetail, WorkerLifecycleAck, WorkerSummary,
|
ConfigBundleRef, CreateWorkerRequest, RepositoryRefObservationRequest, WorkerDetail,
|
||||||
WorkingDirectoryRepositoryAccessRequest, WorkingDirectoryRequest, WorkingDirectoryStatus,
|
WorkerLifecycleAck, WorkerSummary, WorkingDirectoryRepositoryAccessRequest,
|
||||||
WorkspaceApiRef,
|
WorkingDirectoryRequest, WorkingDirectoryStatus, WorkspaceApiRef,
|
||||||
};
|
};
|
||||||
use crate::config_bundle::{ConfigBundle, ConfigBundleAvailability, ConfigBundleSummary};
|
use crate::config_bundle::{ConfigBundle, ConfigBundleAvailability, ConfigBundleSummary};
|
||||||
use crate::error::RuntimeError;
|
use crate::error::RuntimeError;
|
||||||
@@ -208,6 +208,7 @@ fn runtime_http_router_with_optional_auth(
|
|||||||
"/v1/working-directories/repository-access",
|
"/v1/working-directories/repository-access",
|
||||||
post(authorize_working_directory_repository_access),
|
post(authorize_working_directory_repository_access),
|
||||||
)
|
)
|
||||||
|
.route("/v1/repository-refs/observe", post(observe_repository_ref))
|
||||||
.route(
|
.route(
|
||||||
"/v1/working-directories/{working_directory_id}/sessions",
|
"/v1/working-directories/{working_directory_id}/sessions",
|
||||||
post(open_workdir_session),
|
post(open_workdir_session),
|
||||||
@@ -583,6 +584,31 @@ async fn authorize_working_directory_repository_access(
|
|||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn observe_repository_ref(
|
||||||
|
State(state): State<RuntimeHttpState>,
|
||||||
|
Extension(auth): Extension<RuntimeAuthContext>,
|
||||||
|
body: Result<Json<RepositoryRefObservationRequest>, JsonRejection>,
|
||||||
|
) -> RestResult<crate::catalog::RepositoryRefObservation> {
|
||||||
|
let Json(request) = body.map_err(RuntimeHttpRestError::json_rejection)?;
|
||||||
|
if request
|
||||||
|
.materialization
|
||||||
|
.as_ref()
|
||||||
|
.is_some_and(|materialization| materialization.workspace_id != auth.workspace_id)
|
||||||
|
{
|
||||||
|
return Err(RuntimeHttpRestError::new(
|
||||||
|
StatusCode::FORBIDDEN,
|
||||||
|
"repository_ref_observation_workspace_mismatch",
|
||||||
|
"Repository ref observation authority does not match the authenticated Workspace",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let observation = state
|
||||||
|
.runtime
|
||||||
|
.observe_repository_ref_from_resource(request)
|
||||||
|
.await
|
||||||
|
.map_err(RuntimeHttpRestError::runtime)?;
|
||||||
|
Ok(Json(observation))
|
||||||
|
}
|
||||||
|
|
||||||
async fn list_working_directories(
|
async fn list_working_directories(
|
||||||
State(state): State<RuntimeHttpState>,
|
State(state): State<RuntimeHttpState>,
|
||||||
) -> RestResult<RuntimeHttpWorkingDirectoriesResponse> {
|
) -> RestResult<RuntimeHttpWorkingDirectoriesResponse> {
|
||||||
@@ -1750,7 +1776,10 @@ fn required_runtime_permission(method: &Method, path: &str) -> Option<&'static s
|
|||||||
if path == "/v1/workers" && *method == Method::POST {
|
if path == "/v1/workers" && *method == Method::POST {
|
||||||
return Some("workers:create");
|
return Some("workers:create");
|
||||||
}
|
}
|
||||||
if path == "/v1/working-directories/repository-access" && *method == Method::POST {
|
if (path == "/v1/working-directories/repository-access"
|
||||||
|
|| path == "/v1/repository-refs/observe")
|
||||||
|
&& *method == Method::POST
|
||||||
|
{
|
||||||
return Some("workdirs:operate");
|
return Some("workdirs:operate");
|
||||||
}
|
}
|
||||||
if path.starts_with("/v1/workdir-sessions")
|
if path.starts_with("/v1/workdir-sessions")
|
||||||
@@ -1941,6 +1970,33 @@ fn status_for_runtime_error(error: &RuntimeError) -> StatusCode {
|
|||||||
{
|
{
|
||||||
StatusCode::NOT_FOUND
|
StatusCode::NOT_FOUND
|
||||||
}
|
}
|
||||||
|
RuntimeError::WorkingDirectory(diagnostic)
|
||||||
|
if matches!(
|
||||||
|
diagnostic.code.as_str(),
|
||||||
|
"repository_ref_provider_unavailable"
|
||||||
|
| "repository_ref_provider_timeout"
|
||||||
|
| "repository_access_provider_unavailable"
|
||||||
|
) =>
|
||||||
|
{
|
||||||
|
StatusCode::SERVICE_UNAVAILABLE
|
||||||
|
}
|
||||||
|
RuntimeError::WorkingDirectory(diagnostic)
|
||||||
|
if matches!(
|
||||||
|
diagnostic.code.as_str(),
|
||||||
|
"repository_ref_provider_auth_failed"
|
||||||
|
| "repository_access_credential_expired"
|
||||||
|
| "repository_access_credential_unavailable"
|
||||||
|
| "repository_access_credential_unauthorized"
|
||||||
|
| "repository_access_credential_invalid"
|
||||||
|
) =>
|
||||||
|
{
|
||||||
|
StatusCode::FORBIDDEN
|
||||||
|
}
|
||||||
|
RuntimeError::WorkingDirectory(diagnostic)
|
||||||
|
if diagnostic.code == "repository_ref_not_found" =>
|
||||||
|
{
|
||||||
|
StatusCode::NOT_FOUND
|
||||||
|
}
|
||||||
RuntimeError::RuntimeStopped
|
RuntimeError::RuntimeStopped
|
||||||
| RuntimeError::WorkerExecutionUnavailable { .. }
|
| RuntimeError::WorkerExecutionUnavailable { .. }
|
||||||
| RuntimeError::ExecutionBackendUnavailable { .. }
|
| RuntimeError::ExecutionBackendUnavailable { .. }
|
||||||
@@ -1951,8 +2007,8 @@ fn status_for_runtime_error(error: &RuntimeError) -> StatusCode {
|
|||||||
| RuntimeError::InvalidInitialInputKind { .. }
|
| RuntimeError::InvalidInitialInputKind { .. }
|
||||||
| RuntimeError::ConfigBundleDigestMismatch { .. }
|
| RuntimeError::ConfigBundleDigestMismatch { .. }
|
||||||
| RuntimeError::InvalidProfileSelector { .. }
|
| RuntimeError::InvalidProfileSelector { .. }
|
||||||
| RuntimeError::UnsupportedConfigDeclaration { .. }
|
| RuntimeError::UnsupportedConfigDeclaration { .. } => StatusCode::BAD_REQUEST,
|
||||||
| RuntimeError::WorkingDirectory(_) => StatusCode::BAD_REQUEST,
|
RuntimeError::WorkingDirectory(_) => StatusCode::BAD_REQUEST,
|
||||||
RuntimeError::StoreIo { .. }
|
RuntimeError::StoreIo { .. }
|
||||||
| RuntimeError::StoreMissing { .. }
|
| RuntimeError::StoreMissing { .. }
|
||||||
| RuntimeError::StoreCorrupt { .. }
|
| RuntimeError::StoreCorrupt { .. }
|
||||||
@@ -2424,6 +2480,10 @@ mod tests {
|
|||||||
required_runtime_permission(&Method::POST, "/v1/working-directories/repository-access",),
|
required_runtime_permission(&Method::POST, "/v1/working-directories/repository-access",),
|
||||||
Some("workdirs:operate")
|
Some("workdirs:operate")
|
||||||
);
|
);
|
||||||
|
assert_eq!(
|
||||||
|
required_runtime_permission(&Method::POST, "/v1/repository-refs/observe"),
|
||||||
|
Some("workdirs:operate")
|
||||||
|
);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
required_runtime_permission(&Method::POST, "/v1/working-directories/wd-1/sessions"),
|
required_runtime_permission(&Method::POST, "/v1/working-directories/wd-1/sessions"),
|
||||||
Some("workdirs:operate")
|
Some("workdirs:operate")
|
||||||
@@ -2934,17 +2994,25 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn workdir_runtime_errors_preserve_diagnostic_code() {
|
fn workdir_runtime_errors_preserve_diagnostic_code() {
|
||||||
let error =
|
let cases = [
|
||||||
RuntimeError::WorkingDirectory(crate::working_directory::WorkingDirectoryDiagnostic {
|
("working_directory_not_found", StatusCode::NOT_FOUND),
|
||||||
code: "working_directory_not_found".to_string(),
|
(
|
||||||
message: "working directory missing-workdir was not found".to_string(),
|
"repository_ref_provider_timeout",
|
||||||
});
|
StatusCode::SERVICE_UNAVAILABLE,
|
||||||
|
),
|
||||||
assert_eq!(status_for_runtime_error(&error), StatusCode::NOT_FOUND);
|
("repository_ref_provider_auth_failed", StatusCode::FORBIDDEN),
|
||||||
assert_eq!(
|
("repository_ref_not_found", StatusCode::NOT_FOUND),
|
||||||
code_for_runtime_error(&error),
|
];
|
||||||
"working_directory_not_found"
|
for (code, expected_status) in cases {
|
||||||
);
|
let error = RuntimeError::WorkingDirectory(
|
||||||
|
crate::working_directory::WorkingDirectoryDiagnostic {
|
||||||
|
code: code.to_string(),
|
||||||
|
message: "bounded diagnostic".to_string(),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
assert_eq!(status_for_runtime_error(&error), expected_status);
|
||||||
|
assert_eq!(code_for_runtime_error(&error), code);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1028,14 +1028,14 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn migration_dry_run_accepts_real_v1_document_without_workers_field() {
|
fn migration_dry_run_accepts_previous_schema_document_without_workers_field() {
|
||||||
let temp = tempfile::tempdir().unwrap();
|
let temp = tempfile::tempdir().unwrap();
|
||||||
let root = temp.path().join("runtime");
|
let root = temp.path().join("runtime");
|
||||||
std::fs::create_dir_all(root.join("workers")).unwrap();
|
std::fs::create_dir_all(root.join("workers")).unwrap();
|
||||||
std::fs::write(
|
std::fs::write(
|
||||||
root.join("runtime.json"),
|
root.join("runtime.json"),
|
||||||
serde_json::to_vec_pretty(&serde_json::json!({
|
serde_json::to_vec_pretty(&serde_json::json!({
|
||||||
"schema_version": 1,
|
"schema_version": 3,
|
||||||
"display_name": "local",
|
"display_name": "local",
|
||||||
"backend": "fs_store",
|
"backend": "fs_store",
|
||||||
"status": "running",
|
"status": "running",
|
||||||
@@ -1067,14 +1067,14 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn migration_dry_run_rejects_v1_document_that_cannot_decode_as_v3() {
|
fn migration_dry_run_rejects_previous_schema_document_that_cannot_decode_as_v4() {
|
||||||
let temp = tempfile::tempdir().unwrap();
|
let temp = tempfile::tempdir().unwrap();
|
||||||
let root = temp.path().join("runtime");
|
let root = temp.path().join("runtime");
|
||||||
std::fs::create_dir_all(root.join("workers")).unwrap();
|
std::fs::create_dir_all(root.join("workers")).unwrap();
|
||||||
std::fs::write(
|
std::fs::write(
|
||||||
root.join("runtime.json"),
|
root.join("runtime.json"),
|
||||||
serde_json::to_vec_pretty(&serde_json::json!({
|
serde_json::to_vec_pretty(&serde_json::json!({
|
||||||
"schema_version": 1,
|
"schema_version": 3,
|
||||||
"display_name": "local",
|
"display_name": "local",
|
||||||
"backend": "fs_store",
|
"backend": "fs_store",
|
||||||
"status": 3,
|
"status": 3,
|
||||||
|
|||||||
@@ -43,7 +43,6 @@ pub struct RuntimeSummary {
|
|||||||
pub worker_count: usize,
|
pub worker_count: usize,
|
||||||
pub active_worker_count: usize,
|
pub active_worker_count: usize,
|
||||||
pub stopped_worker_count: usize,
|
pub stopped_worker_count: usize,
|
||||||
pub cancelled_worker_count: usize,
|
|
||||||
pub diagnostic_count: usize,
|
pub diagnostic_count: usize,
|
||||||
#[serde(default = "unknown_platform_component")]
|
#[serde(default = "unknown_platform_component")]
|
||||||
pub os: String,
|
pub os: String,
|
||||||
|
|||||||
@@ -303,7 +303,12 @@ impl FsWorkerRetentionProvider {
|
|||||||
));
|
));
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
match self.inventory(workspace_id, runtime_id, worker_id, snapshot.run_generation) {
|
match self.inventory(
|
||||||
|
workspace_id,
|
||||||
|
runtime_id,
|
||||||
|
worker_id,
|
||||||
|
snapshot.run_generation(),
|
||||||
|
) {
|
||||||
Ok(item) => workers.push(item),
|
Ok(item) => workers.push(item),
|
||||||
Err(_) => diagnostics.push(runtime_aggregate_diagnostic(
|
Err(_) => diagnostics.push(runtime_aggregate_diagnostic(
|
||||||
&bounded_id,
|
&bounded_id,
|
||||||
@@ -388,10 +393,11 @@ impl WorkerRetentionProvider for FsWorkerRetentionProvider {
|
|||||||
if worker.workspace_id.as_deref() != Some(workspace_id) {
|
if worker.workspace_id.as_deref() != Some(workspace_id) {
|
||||||
return Err(RuntimeError::WorkerNotFound { worker_id });
|
return Err(RuntimeError::WorkerNotFound { worker_id });
|
||||||
}
|
}
|
||||||
if worker.run_generation != run_generation {
|
let current_run_generation = worker.run_generation();
|
||||||
|
if current_run_generation != run_generation {
|
||||||
return Err(RuntimeError::InvalidRequest(format!(
|
return Err(RuntimeError::InvalidRequest(format!(
|
||||||
"Worker retention inventory expected generation {run_generation}, current generation is {}",
|
"Worker retention inventory expected generation {run_generation}, current generation is {}",
|
||||||
worker.run_generation
|
current_run_generation
|
||||||
)));
|
)));
|
||||||
}
|
}
|
||||||
let session_dir = worker_dir.join("session");
|
let session_dir = worker_dir.join("session");
|
||||||
@@ -498,10 +504,11 @@ impl WorkerRetentionProvider for FsWorkerRetentionProvider {
|
|||||||
worker_id: request.worker_id,
|
worker_id: request.worker_id,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
if snapshot.run_generation != request.expected_run_generation {
|
let run_generation = snapshot.run_generation();
|
||||||
|
if run_generation != request.expected_run_generation {
|
||||||
return Err(RuntimeError::InvalidRequest(format!(
|
return Err(RuntimeError::InvalidRequest(format!(
|
||||||
"Worker retention plan expected generation {}, current generation is {}",
|
"Worker retention plan expected generation {}, current generation is {}",
|
||||||
request.expected_run_generation, snapshot.run_generation
|
request.expected_run_generation, run_generation
|
||||||
)));
|
)));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -568,10 +575,29 @@ impl WorkerRetentionProvider for FsWorkerRetentionProvider {
|
|||||||
struct WorkerGenerationSnapshot {
|
struct WorkerGenerationSnapshot {
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
workspace_id: Option<String>,
|
workspace_id: Option<String>,
|
||||||
#[serde(default)]
|
execution: WorkerGenerationExecution,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct WorkerGenerationExecution {
|
||||||
|
binding: Option<WorkerGenerationBinding>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct WorkerGenerationBinding {
|
||||||
run_generation: u64,
|
run_generation: u64,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl WorkerGenerationSnapshot {
|
||||||
|
fn run_generation(&self) -> u64 {
|
||||||
|
self.execution
|
||||||
|
.binding
|
||||||
|
.as_ref()
|
||||||
|
.map(|binding| binding.run_generation)
|
||||||
|
.unwrap_or(0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
#[derive(Deserialize)]
|
||||||
struct CanonicalSessionManifest {
|
struct CanonicalSessionManifest {
|
||||||
session_id: String,
|
session_id: String,
|
||||||
@@ -1264,7 +1290,10 @@ mod tests {
|
|||||||
let worker = root.join("workers").join(worker_id.to_string());
|
let worker = root.join("workers").join(worker_id.to_string());
|
||||||
write_json(
|
write_json(
|
||||||
&worker.join("worker.json"),
|
&worker.join("worker.json"),
|
||||||
&serde_json::json!({"workspace_id": "workspace-a", "run_generation": generation}),
|
&serde_json::json!({
|
||||||
|
"workspace_id": "workspace-a",
|
||||||
|
"execution": {"binding": {"run_generation": generation}}
|
||||||
|
}),
|
||||||
);
|
);
|
||||||
write_json(
|
write_json(
|
||||||
&worker.join("session/session.json"),
|
&worker.join("session/session.json"),
|
||||||
@@ -1462,7 +1491,10 @@ mod tests {
|
|||||||
.join("workers")
|
.join("workers")
|
||||||
.join(other_worker.to_string())
|
.join(other_worker.to_string())
|
||||||
.join("worker.json"),
|
.join("worker.json"),
|
||||||
&serde_json::json!({"workspace_id": "other-workspace", "run_generation": 1}),
|
&serde_json::json!({
|
||||||
|
"workspace_id": "other-workspace",
|
||||||
|
"execution": {"binding": {"run_generation": 1}}
|
||||||
|
}),
|
||||||
);
|
);
|
||||||
fs::create_dir_all(temp.path().join("workers/not-a-worker")).unwrap();
|
fs::create_dir_all(temp.path().join("workers/not-a-worker")).unwrap();
|
||||||
fs::write(
|
fs::write(
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -20,6 +20,7 @@ use crate::auth::{
|
|||||||
};
|
};
|
||||||
use crate::catalog::{
|
use crate::catalog::{
|
||||||
CreateWorkerRequest, ProfileSourceArchiveHttpRef, ProfileSourceArchiveSource,
|
CreateWorkerRequest, ProfileSourceArchiveHttpRef, ProfileSourceArchiveSource,
|
||||||
|
RepositoryRefObservation, RepositoryRefObservationRequest,
|
||||||
WorkingDirectoryRepositoryAccessRequest, WorkingDirectoryRequest, WorkingDirectoryStatus,
|
WorkingDirectoryRepositoryAccessRequest, WorkingDirectoryRequest, WorkingDirectoryStatus,
|
||||||
};
|
};
|
||||||
use crate::execution::{
|
use crate::execution::{
|
||||||
@@ -1645,6 +1646,19 @@ where
|
|||||||
materializer.authorize_repository_access(request)
|
materializer.authorize_repository_access(request)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn observe_repository_ref(
|
||||||
|
&self,
|
||||||
|
request: &RepositoryRefObservationRequest,
|
||||||
|
) -> Result<RepositoryRefObservation, WorkingDirectoryDiagnostic> {
|
||||||
|
let materializer = self.working_directory_materializer.as_ref().ok_or_else(|| {
|
||||||
|
WorkingDirectoryDiagnostic::rejected(
|
||||||
|
"repository_ref_provider_unavailable",
|
||||||
|
"Repository ref observation requested, but no materializer is configured for this Runtime backend",
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
materializer.observe_repository_ref(request)
|
||||||
|
}
|
||||||
|
|
||||||
fn list_working_directories(&self) -> Vec<WorkingDirectoryStatus> {
|
fn list_working_directories(&self) -> Vec<WorkingDirectoryStatus> {
|
||||||
self.working_directory_materializer
|
self.working_directory_materializer
|
||||||
.as_ref()
|
.as_ref()
|
||||||
@@ -3665,6 +3679,60 @@ mod tests {
|
|||||||
assert_eq!(call_count.load(Ordering::SeqCst), 3);
|
assert_eq!(call_count.load(Ordering::SeqCst), 3);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn stopped_runtime_worker_can_restore_and_accept_input() {
|
||||||
|
let client = MockClient::new(simple_text_events());
|
||||||
|
let runtime_base = tempfile::tempdir().unwrap();
|
||||||
|
let cwd = tempfile::tempdir().unwrap();
|
||||||
|
let store = tempfile::tempdir().unwrap();
|
||||||
|
let factory = MockFactory {
|
||||||
|
client,
|
||||||
|
runtime_base: runtime_base.path().to_path_buf(),
|
||||||
|
cwd: cwd.path().to_path_buf(),
|
||||||
|
store_dir: store.path().join("sessions"),
|
||||||
|
worker_metadata_dir: store.path().join("workers"),
|
||||||
|
observed_cwds: Arc::new(Mutex::new(Vec::new())),
|
||||||
|
observed_workspace_clients: Arc::new(Mutex::new(Vec::new())),
|
||||||
|
};
|
||||||
|
let backend = Arc::new(WorkerRuntimeExecutionBackend::new(factory).unwrap());
|
||||||
|
let runtime =
|
||||||
|
EmbeddedRuntime::with_execution_backend(RuntimeOptions::default(), backend.clone())
|
||||||
|
.unwrap();
|
||||||
|
runtime.store_config_bundle(test_bundle()).unwrap();
|
||||||
|
let detail = runtime
|
||||||
|
.create_worker(create_request("restore-after-stop"))
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
runtime.stop_worker(&detail.worker_ref, None).unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
runtime.worker_detail(&detail.worker_ref).unwrap().status,
|
||||||
|
crate::catalog::WorkerStatus::Stopped
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
!backend
|
||||||
|
.workers
|
||||||
|
.lock()
|
||||||
|
.unwrap()
|
||||||
|
.contains_key(&detail.worker_ref)
|
||||||
|
);
|
||||||
|
|
||||||
|
runtime.restore_worker(&detail.worker_ref).unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
runtime.worker_detail(&detail.worker_ref).unwrap().status,
|
||||||
|
crate::catalog::WorkerStatus::Idle
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
backend
|
||||||
|
.workers
|
||||||
|
.lock()
|
||||||
|
.unwrap()
|
||||||
|
.contains_key(&detail.worker_ref)
|
||||||
|
);
|
||||||
|
runtime
|
||||||
|
.send_input(&detail.worker_ref, WorkerInput::user("continue"))
|
||||||
|
.unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn stopping_and_deleting_worker_preserves_bound_working_directory() {
|
fn stopping_and_deleting_worker_preserves_bound_working_directory() {
|
||||||
let client = MockClient::new(simple_text_events());
|
let client = MockClient::new(simple_text_events());
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
use crate::catalog::{
|
use crate::catalog::{
|
||||||
MaterializerKind, RepositorySshMaterializationAccess, WorkingDirectoryCleanupTarget,
|
MaterializerKind, RepositoryRefObservation, RepositoryRefObservationRequest,
|
||||||
|
RepositorySshMaterializationAccess, WorkingDirectoryCleanupTarget,
|
||||||
WorkingDirectoryRepositoryAccessRequest, WorkingDirectoryRequest, WorkingDirectoryStatus,
|
WorkingDirectoryRepositoryAccessRequest, WorkingDirectoryRequest, WorkingDirectoryStatus,
|
||||||
WorkingDirectoryStatusKind, WorkingDirectorySummary,
|
WorkingDirectoryStatusKind, WorkingDirectorySummary,
|
||||||
};
|
};
|
||||||
@@ -196,6 +197,11 @@ pub trait WorkingDirectoryMaterializer: Send + Sync + 'static {
|
|||||||
request: &WorkingDirectoryRepositoryAccessRequest,
|
request: &WorkingDirectoryRepositoryAccessRequest,
|
||||||
) -> Result<(), WorkingDirectoryDiagnostic>;
|
) -> Result<(), WorkingDirectoryDiagnostic>;
|
||||||
|
|
||||||
|
fn observe_repository_ref(
|
||||||
|
&self,
|
||||||
|
request: &RepositoryRefObservationRequest,
|
||||||
|
) -> Result<RepositoryRefObservation, WorkingDirectoryDiagnostic>;
|
||||||
|
|
||||||
fn bind_working_directory(
|
fn bind_working_directory(
|
||||||
&self,
|
&self,
|
||||||
working_directory_id: &str,
|
working_directory_id: &str,
|
||||||
@@ -943,6 +949,72 @@ impl WorkingDirectoryMaterializer for RuntimeGitCacheMaterializer {
|
|||||||
self.cache_repository_access(&request.working_directory_id, ssh)
|
self.cache_repository_access(&request.working_directory_id, ssh)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn observe_repository_ref(
|
||||||
|
&self,
|
||||||
|
request: &RepositoryRefObservationRequest,
|
||||||
|
) -> Result<RepositoryRefObservation, WorkingDirectoryDiagnostic> {
|
||||||
|
let selector = request.selector.trim();
|
||||||
|
validate_exact_branch_selector(selector)?;
|
||||||
|
|
||||||
|
let working_request = WorkingDirectoryRequest {
|
||||||
|
repository: request.repository.clone(),
|
||||||
|
materializer: MaterializerKind::RuntimeGitCache,
|
||||||
|
backend_workdir_id: None,
|
||||||
|
materialization: request.materialization.clone(),
|
||||||
|
};
|
||||||
|
Self::validate_request(&working_request)?;
|
||||||
|
let access = RepositoryCommandAccess::prepare(&self.runtime_root, &working_request)?;
|
||||||
|
let mut command = repository_git_command(&working_request, access.as_ref());
|
||||||
|
command.args([
|
||||||
|
"ls-remote",
|
||||||
|
"--exit-code",
|
||||||
|
"--refs",
|
||||||
|
request.repository.source.uri.as_str(),
|
||||||
|
selector,
|
||||||
|
]);
|
||||||
|
let output = run_repository_git_stdout(command, request.repository.source.kind)?;
|
||||||
|
let mut lines = output.lines();
|
||||||
|
let line = lines.next().ok_or_else(|| {
|
||||||
|
WorkingDirectoryDiagnostic::new(
|
||||||
|
"repository_ref_not_found",
|
||||||
|
"Repository provider did not return the requested ref",
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
if lines.next().is_some() {
|
||||||
|
return Err(WorkingDirectoryDiagnostic::new(
|
||||||
|
"repository_ref_response_invalid",
|
||||||
|
"Repository provider returned an ambiguous ref observation",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let (revision_ref, observed_selector) = line.split_once('\t').ok_or_else(|| {
|
||||||
|
WorkingDirectoryDiagnostic::new(
|
||||||
|
"repository_ref_response_invalid",
|
||||||
|
"Repository provider returned an invalid ref observation",
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
if observed_selector != selector
|
||||||
|
|| !matches!(revision_ref.len(), 40 | 64)
|
||||||
|
|| !revision_ref.bytes().all(|byte| byte.is_ascii_hexdigit())
|
||||||
|
{
|
||||||
|
return Err(WorkingDirectoryDiagnostic::new(
|
||||||
|
"repository_ref_response_invalid",
|
||||||
|
"Repository provider returned an invalid ref observation",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(RepositoryRefObservation {
|
||||||
|
repository_id: request.repository.id.clone(),
|
||||||
|
source_revision: request.repository.source_revision,
|
||||||
|
source_fingerprint: request.repository.source_fingerprint.clone(),
|
||||||
|
selector: selector.to_string(),
|
||||||
|
revision_ref: revision_ref.to_ascii_lowercase(),
|
||||||
|
observed_at_epoch_seconds: SystemTime::now()
|
||||||
|
.duration_since(UNIX_EPOCH)
|
||||||
|
.unwrap_or_default()
|
||||||
|
.as_secs(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
fn bind_working_directory(
|
fn bind_working_directory(
|
||||||
&self,
|
&self,
|
||||||
working_directory_id: &str,
|
working_directory_id: &str,
|
||||||
@@ -2022,6 +2094,146 @@ fn repository_git_command(
|
|||||||
command
|
command
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn validate_exact_branch_selector(selector: &str) -> Result<(), WorkingDirectoryDiagnostic> {
|
||||||
|
validate_selector(selector).map_err(|_| {
|
||||||
|
WorkingDirectoryDiagnostic::new(
|
||||||
|
"repository_ref_selector_invalid",
|
||||||
|
"Repository ref observation requires a valid exact branch selector",
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
if !selector.starts_with("refs/heads/") {
|
||||||
|
return Err(WorkingDirectoryDiagnostic::new(
|
||||||
|
"repository_ref_selector_invalid",
|
||||||
|
"Repository ref observation requires an exact branch selector",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let status = Command::new("git")
|
||||||
|
.args(["check-ref-format", selector])
|
||||||
|
.stdin(Stdio::null())
|
||||||
|
.stdout(Stdio::null())
|
||||||
|
.stderr(Stdio::null())
|
||||||
|
.status()
|
||||||
|
.map_err(|_| {
|
||||||
|
WorkingDirectoryDiagnostic::new(
|
||||||
|
"repository_ref_provider_unavailable",
|
||||||
|
"Git ref validation could not be started",
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
if !status.success() {
|
||||||
|
return Err(WorkingDirectoryDiagnostic::new(
|
||||||
|
"repository_ref_selector_invalid",
|
||||||
|
"Repository ref observation requires a valid exact branch selector",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn read_bounded_command_output(mut reader: impl Read) -> Vec<u8> {
|
||||||
|
const MAX_CAPTURE_BYTES: usize = 8192;
|
||||||
|
let mut captured = Vec::new();
|
||||||
|
let mut chunk = [0_u8; 4096];
|
||||||
|
loop {
|
||||||
|
match reader.read(&mut chunk) {
|
||||||
|
Ok(0) | Err(_) => break,
|
||||||
|
Ok(read) => {
|
||||||
|
let remaining = MAX_CAPTURE_BYTES.saturating_sub(captured.len());
|
||||||
|
captured.extend_from_slice(&chunk[..read.min(remaining)]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
captured
|
||||||
|
}
|
||||||
|
|
||||||
|
fn run_repository_git_stdout(
|
||||||
|
mut command: Command,
|
||||||
|
source_kind: workspace_api::RepositorySourceKind,
|
||||||
|
) -> Result<String, WorkingDirectoryDiagnostic> {
|
||||||
|
command
|
||||||
|
.stdin(Stdio::null())
|
||||||
|
.stdout(Stdio::piped())
|
||||||
|
.stderr(Stdio::piped());
|
||||||
|
let mut child = command.spawn().map_err(|_| {
|
||||||
|
WorkingDirectoryDiagnostic::new(
|
||||||
|
"repository_ref_provider_unavailable",
|
||||||
|
"Repository provider operation could not be started",
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
let stdout = child.stdout.take().ok_or_else(|| {
|
||||||
|
WorkingDirectoryDiagnostic::new(
|
||||||
|
"repository_ref_provider_unavailable",
|
||||||
|
"Repository provider response could not be captured",
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
let stderr = child.stderr.take().ok_or_else(|| {
|
||||||
|
WorkingDirectoryDiagnostic::new(
|
||||||
|
"repository_ref_provider_unavailable",
|
||||||
|
"Repository provider diagnostic could not be captured",
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
let stdout_reader = std::thread::spawn(move || read_bounded_command_output(stdout));
|
||||||
|
let stderr_reader = std::thread::spawn(move || read_bounded_command_output(stderr));
|
||||||
|
let started = Instant::now();
|
||||||
|
let status = loop {
|
||||||
|
if let Some(status) = child.try_wait().map_err(|_| {
|
||||||
|
WorkingDirectoryDiagnostic::new(
|
||||||
|
"repository_ref_provider_unavailable",
|
||||||
|
"Repository provider operation status could not be observed",
|
||||||
|
)
|
||||||
|
})? {
|
||||||
|
break status;
|
||||||
|
}
|
||||||
|
if started.elapsed() >= REPOSITORY_COMMAND_TIMEOUT {
|
||||||
|
let _ = child.kill();
|
||||||
|
let _ = child.wait();
|
||||||
|
let _ = stdout_reader.join();
|
||||||
|
let _ = stderr_reader.join();
|
||||||
|
return Err(WorkingDirectoryDiagnostic::new(
|
||||||
|
"repository_ref_provider_timeout",
|
||||||
|
"Repository provider operation exceeded the Runtime time limit",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
std::thread::sleep(Duration::from_millis(25));
|
||||||
|
};
|
||||||
|
let stdout = stdout_reader.join().unwrap_or_default();
|
||||||
|
let stderr = stderr_reader.join().unwrap_or_default();
|
||||||
|
if status.success() {
|
||||||
|
return String::from_utf8(stdout).map_err(|_| {
|
||||||
|
WorkingDirectoryDiagnostic::new(
|
||||||
|
"repository_ref_response_invalid",
|
||||||
|
"Repository provider returned a non-UTF-8 ref observation",
|
||||||
|
)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if status.code() == Some(2) {
|
||||||
|
return Err(WorkingDirectoryDiagnostic::new(
|
||||||
|
"repository_ref_not_found",
|
||||||
|
"Repository provider did not return the requested ref",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let diagnostic = String::from_utf8_lossy(&stderr).to_ascii_lowercase();
|
||||||
|
let auth_failed = source_kind.is_remote()
|
||||||
|
&& [
|
||||||
|
"authentication failed",
|
||||||
|
"permission denied",
|
||||||
|
"could not read username",
|
||||||
|
"publickey",
|
||||||
|
]
|
||||||
|
.iter()
|
||||||
|
.any(|marker| diagnostic.contains(marker));
|
||||||
|
Err(WorkingDirectoryDiagnostic::new(
|
||||||
|
if auth_failed {
|
||||||
|
"repository_ref_provider_auth_failed"
|
||||||
|
} else {
|
||||||
|
"repository_ref_provider_unavailable"
|
||||||
|
},
|
||||||
|
if auth_failed {
|
||||||
|
"Repository provider rejected the operation-scoped authentication"
|
||||||
|
} else {
|
||||||
|
"Repository provider operation failed"
|
||||||
|
},
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
fn run_repository_git(
|
fn run_repository_git(
|
||||||
mut command: Command,
|
mut command: Command,
|
||||||
code: &'static str,
|
code: &'static str,
|
||||||
@@ -2493,6 +2705,157 @@ mod tests {
|
|||||||
WorkerRef::new(WorkerId::from_legacy_u64(sequence))
|
WorkerRef::new(WorkerId::from_legacy_u64(sequence))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn repository_ref_observation_reads_the_provider_fresh() {
|
||||||
|
let repo = create_clean_repo();
|
||||||
|
git(repo.path(), &["branch", "published"]);
|
||||||
|
let runtime_root = tempfile::tempdir().unwrap();
|
||||||
|
let materializer = RuntimeGitCacheMaterializer::new(runtime_root.path());
|
||||||
|
let repository = request(repo.path()).repository;
|
||||||
|
let observation_request = RepositoryRefObservationRequest {
|
||||||
|
repository,
|
||||||
|
selector: "refs/heads/published".to_string(),
|
||||||
|
materialization: None,
|
||||||
|
};
|
||||||
|
|
||||||
|
let first = materializer
|
||||||
|
.observe_repository_ref(&observation_request)
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
first.revision_ref,
|
||||||
|
git_stdout(repo.path(), ["rev-parse", "published"]).unwrap()
|
||||||
|
);
|
||||||
|
fs::write(repo.path().join("second.txt"), "second\n").unwrap();
|
||||||
|
git(repo.path(), &["add", "second.txt"]);
|
||||||
|
git(repo.path(), &["commit", "-m", "second"]);
|
||||||
|
git(repo.path(), &["branch", "-f", "published"]);
|
||||||
|
|
||||||
|
let second = materializer
|
||||||
|
.observe_repository_ref(&observation_request)
|
||||||
|
.unwrap();
|
||||||
|
assert_ne!(first.revision_ref, second.revision_ref);
|
||||||
|
assert_eq!(
|
||||||
|
second.revision_ref,
|
||||||
|
git_stdout(repo.path(), ["rev-parse", "published"]).unwrap()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn repository_ref_observation_ignores_unpublished_and_stale_workdir_or_cache_refs() {
|
||||||
|
let seed = create_clean_repo();
|
||||||
|
let layout = tempfile::tempdir().unwrap();
|
||||||
|
let provider = layout.path().join("provider.git");
|
||||||
|
git(
|
||||||
|
layout.path(),
|
||||||
|
&[
|
||||||
|
"clone",
|
||||||
|
"--bare",
|
||||||
|
seed.path().to_str().unwrap(),
|
||||||
|
provider.to_str().unwrap(),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
let cache = layout.path().join("cache");
|
||||||
|
git(
|
||||||
|
layout.path(),
|
||||||
|
&["clone", provider.to_str().unwrap(), cache.to_str().unwrap()],
|
||||||
|
);
|
||||||
|
let workdir = layout.path().join("workdir");
|
||||||
|
git(
|
||||||
|
layout.path(),
|
||||||
|
&[
|
||||||
|
"clone",
|
||||||
|
provider.to_str().unwrap(),
|
||||||
|
workdir.to_str().unwrap(),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
git(&workdir, &["config", "user.name", "Yoi Test"]);
|
||||||
|
git(&workdir, &["config", "user.email", "yoi@example.com"]);
|
||||||
|
git(&workdir, &["switch", "-c", "published-source"]);
|
||||||
|
fs::write(workdir.join("source.txt"), "first\n").unwrap();
|
||||||
|
git(&workdir, &["add", "source.txt"]);
|
||||||
|
git(&workdir, &["commit", "-m", "source first"]);
|
||||||
|
|
||||||
|
let runtime_root = tempfile::tempdir().unwrap();
|
||||||
|
let materializer = RuntimeGitCacheMaterializer::new(runtime_root.path());
|
||||||
|
let repository = request(&provider).repository;
|
||||||
|
let observation_request = RepositoryRefObservationRequest {
|
||||||
|
repository,
|
||||||
|
selector: "refs/heads/published-source".to_string(),
|
||||||
|
materialization: None,
|
||||||
|
};
|
||||||
|
assert_eq!(
|
||||||
|
materializer
|
||||||
|
.observe_repository_ref(&observation_request)
|
||||||
|
.unwrap_err()
|
||||||
|
.code,
|
||||||
|
"repository_ref_not_found"
|
||||||
|
);
|
||||||
|
|
||||||
|
git(
|
||||||
|
&workdir,
|
||||||
|
&["push", "origin", "HEAD:refs/heads/published-source"],
|
||||||
|
);
|
||||||
|
let first = materializer
|
||||||
|
.observe_repository_ref(&observation_request)
|
||||||
|
.unwrap();
|
||||||
|
fs::write(workdir.join("source.txt"), "second\n").unwrap();
|
||||||
|
git(&workdir, &["add", "source.txt"]);
|
||||||
|
git(&workdir, &["commit", "-m", "source second"]);
|
||||||
|
let unpublished_second = git_stdout(&workdir, ["rev-parse", "HEAD"]).unwrap();
|
||||||
|
let still_first = materializer
|
||||||
|
.observe_repository_ref(&observation_request)
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(still_first.revision_ref, first.revision_ref);
|
||||||
|
assert_ne!(still_first.revision_ref, unpublished_second);
|
||||||
|
|
||||||
|
git(
|
||||||
|
&workdir,
|
||||||
|
&["push", "origin", "HEAD:refs/heads/published-source"],
|
||||||
|
);
|
||||||
|
let second = materializer
|
||||||
|
.observe_repository_ref(&observation_request)
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(second.revision_ref, unpublished_second);
|
||||||
|
assert_ne!(second.revision_ref, first.revision_ref);
|
||||||
|
assert_ne!(
|
||||||
|
git_stdout(&cache, ["rev-parse", "HEAD"]).unwrap(),
|
||||||
|
second.revision_ref
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn repository_ref_observation_rejects_missing_and_non_branch_selectors() {
|
||||||
|
let repo = create_clean_repo();
|
||||||
|
let runtime_root = tempfile::tempdir().unwrap();
|
||||||
|
let materializer = RuntimeGitCacheMaterializer::new(runtime_root.path());
|
||||||
|
let repository = request(repo.path()).repository;
|
||||||
|
|
||||||
|
let missing = materializer
|
||||||
|
.observe_repository_ref(&RepositoryRefObservationRequest {
|
||||||
|
repository: repository.clone(),
|
||||||
|
selector: "refs/heads/not-published".to_string(),
|
||||||
|
materialization: None,
|
||||||
|
})
|
||||||
|
.unwrap_err();
|
||||||
|
assert_eq!(missing.code, "repository_ref_not_found");
|
||||||
|
let non_branch = materializer
|
||||||
|
.observe_repository_ref(&RepositoryRefObservationRequest {
|
||||||
|
repository: repository.clone(),
|
||||||
|
selector: "HEAD".to_string(),
|
||||||
|
materialization: None,
|
||||||
|
})
|
||||||
|
.unwrap_err();
|
||||||
|
assert_eq!(non_branch.code, "repository_ref_selector_invalid");
|
||||||
|
let wildcard = materializer
|
||||||
|
.observe_repository_ref(&RepositoryRefObservationRequest {
|
||||||
|
repository,
|
||||||
|
selector: "refs/heads/release/*".to_string(),
|
||||||
|
materialization: None,
|
||||||
|
})
|
||||||
|
.unwrap_err();
|
||||||
|
assert_eq!(wildcard.code, "repository_ref_selector_invalid");
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn local_git_repo_materializes_detached_worktree_under_runtime_root() {
|
fn local_git_repo_materializes_detached_worktree_under_runtime_root() {
|
||||||
let repo = create_clean_repo();
|
let repo = create_clean_repo();
|
||||||
|
|||||||
+24
-25
@@ -10,8 +10,8 @@ use agen::llm_client::client::LlmClient;
|
|||||||
use agen::llm_client::types::Role;
|
use agen::llm_client::types::Role;
|
||||||
use agen::state::Mutable;
|
use agen::state::Mutable;
|
||||||
use agen::{
|
use agen::{
|
||||||
Engine, EngineError, EngineResult, EngineRunExit, History, HistoryEntry, Item, StopReason,
|
Engine, EngineError, EngineResult, EngineRunExit, History, HistoryEntry, Item,
|
||||||
ToolExecutionPolicy, ToolOutputLimits, UsageRecord,
|
RunInterruptionReason, ToolExecutionPolicy, ToolOutputLimits, UsageRecord,
|
||||||
};
|
};
|
||||||
use arc_swap::ArcSwap;
|
use arc_swap::ArcSwap;
|
||||||
use session_store::{
|
use session_store::{
|
||||||
@@ -2605,7 +2605,7 @@ impl<C: LlmClient + 'static, St: Store> Worker<C, St> {
|
|||||||
) -> bool {
|
) -> bool {
|
||||||
if !matches!(
|
if !matches!(
|
||||||
result,
|
result,
|
||||||
EngineRunExit::Paused | EngineRunExit::Interrupted(StopReason::Cancelled)
|
EngineRunExit::Paused | EngineRunExit::Interrupted(RunInterruptionReason::Cancelled)
|
||||||
) {
|
) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
@@ -3403,15 +3403,15 @@ impl<C: LlmClient + 'static, St: Store> Worker<C, St> {
|
|||||||
self.last_run_interrupted = true;
|
self.last_run_interrupted = true;
|
||||||
Ok(WorkerRunResult::Paused)
|
Ok(WorkerRunResult::Paused)
|
||||||
}
|
}
|
||||||
EngineRunExit::Interrupted(StopReason::LimitReached) => {
|
EngineRunExit::Interrupted(RunInterruptionReason::LimitReached) => {
|
||||||
self.last_run_interrupted = false;
|
self.last_run_interrupted = false;
|
||||||
Ok(WorkerRunResult::LimitReached)
|
Ok(WorkerRunResult::LimitReached)
|
||||||
}
|
}
|
||||||
EngineRunExit::Interrupted(reason) => {
|
EngineRunExit::Interrupted(reason) => {
|
||||||
self.last_run_interrupted = true;
|
self.last_run_interrupted = true;
|
||||||
Ok(WorkerRunResult::Interrupted {
|
Ok(WorkerRunResult::Interrupted {
|
||||||
code: stop_reason_error_code(&reason),
|
code: run_interruption_reason_error_code(&reason),
|
||||||
message: stop_reason_message(&reason),
|
message: run_interruption_reason_message(&reason),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
EngineRunExit::Yielded => unreachable!("yielded handled above"),
|
EngineRunExit::Yielded => unreachable!("yielded handled above"),
|
||||||
@@ -3731,9 +3731,9 @@ impl<C: LlmClient + 'static, St: Store> Worker<C, St> {
|
|||||||
result,
|
result,
|
||||||
EngineRunExit::Paused
|
EngineRunExit::Paused
|
||||||
| EngineRunExit::Yielded
|
| EngineRunExit::Yielded
|
||||||
| EngineRunExit::Interrupted(StopReason::Cancelled)
|
| EngineRunExit::Interrupted(RunInterruptionReason::Cancelled)
|
||||||
| EngineRunExit::Interrupted(StopReason::ContextWindowExceeded)
|
| EngineRunExit::Interrupted(RunInterruptionReason::ContextWindowExceeded)
|
||||||
| EngineRunExit::Interrupted(StopReason::Unexpected(_))
|
| EngineRunExit::Interrupted(RunInterruptionReason::Unexpected(_))
|
||||||
);
|
);
|
||||||
let active_run_turn_count = self.engine.as_ref().unwrap().active_run_turn_count();
|
let active_run_turn_count = self.engine.as_ref().unwrap().active_run_turn_count();
|
||||||
match result {
|
match result {
|
||||||
@@ -3751,7 +3751,7 @@ impl<C: LlmClient + 'static, St: Store> Worker<C, St> {
|
|||||||
active_run_turn_count,
|
active_run_turn_count,
|
||||||
})?;
|
})?;
|
||||||
}
|
}
|
||||||
EngineRunExit::Interrupted(StopReason::LimitReached) => {
|
EngineRunExit::Interrupted(RunInterruptionReason::LimitReached) => {
|
||||||
self.commit_entry(LogEntry::RunCompleted {
|
self.commit_entry(LogEntry::RunCompleted {
|
||||||
ts: segment_log::now_millis(),
|
ts: segment_log::now_millis(),
|
||||||
interrupted: false,
|
interrupted: false,
|
||||||
@@ -3763,7 +3763,7 @@ impl<C: LlmClient + 'static, St: Store> Worker<C, St> {
|
|||||||
self.commit_entry(LogEntry::RunErrored {
|
self.commit_entry(LogEntry::RunErrored {
|
||||||
ts: segment_log::now_millis(),
|
ts: segment_log::now_millis(),
|
||||||
interrupted,
|
interrupted,
|
||||||
message: stop_reason_message(reason),
|
message: run_interruption_reason_message(reason),
|
||||||
})?;
|
})?;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -6122,15 +6122,14 @@ fn restore_manifest_from_worker_metadata_snapshot(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn stop_reason_error_code(reason: &StopReason) -> ErrorCode {
|
fn run_interruption_reason_error_code(reason: &RunInterruptionReason) -> ErrorCode {
|
||||||
match reason {
|
match reason {
|
||||||
StopReason::ContextWindowExceeded | StopReason::Unexpected(EngineError::Client(_)) => {
|
RunInterruptionReason::ContextWindowExceeded
|
||||||
ErrorCode::ProviderError
|
| RunInterruptionReason::Unexpected(EngineError::Client(_)) => ErrorCode::ProviderError,
|
||||||
}
|
RunInterruptionReason::Unexpected(EngineError::Tool(_)) => ErrorCode::ToolError,
|
||||||
StopReason::Unexpected(EngineError::Tool(_)) => ErrorCode::ToolError,
|
RunInterruptionReason::LimitReached
|
||||||
StopReason::LimitReached
|
| RunInterruptionReason::Cancelled
|
||||||
| StopReason::Cancelled
|
| RunInterruptionReason::Unexpected(
|
||||||
| StopReason::Unexpected(
|
|
||||||
EngineError::Aborted(_)
|
EngineError::Aborted(_)
|
||||||
| EngineError::Cancelled
|
| EngineError::Cancelled
|
||||||
| EngineError::PauseRequested
|
| EngineError::PauseRequested
|
||||||
@@ -6141,12 +6140,12 @@ fn stop_reason_error_code(reason: &StopReason) -> ErrorCode {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn stop_reason_message(reason: &StopReason) -> String {
|
fn run_interruption_reason_message(reason: &RunInterruptionReason) -> String {
|
||||||
match reason {
|
match reason {
|
||||||
StopReason::LimitReached => "engine turn limit reached".to_string(),
|
RunInterruptionReason::LimitReached => "engine turn limit reached".to_string(),
|
||||||
StopReason::ContextWindowExceeded => "model context window reached".to_string(),
|
RunInterruptionReason::ContextWindowExceeded => "model context window reached".to_string(),
|
||||||
StopReason::Cancelled => "engine run cancelled".to_string(),
|
RunInterruptionReason::Cancelled => "engine run cancelled".to_string(),
|
||||||
StopReason::Unexpected(error) => format!("unexpected engine failure: {error}"),
|
RunInterruptionReason::Unexpected(error) => format!("unexpected engine failure: {error}"),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -8695,7 +8694,7 @@ mod build_summary_prompt_tests {
|
|||||||
]);
|
]);
|
||||||
let _ = worker
|
let _ = worker
|
||||||
.handle_worker_result(
|
.handle_worker_result(
|
||||||
EngineRunExit::Interrupted(StopReason::Cancelled),
|
EngineRunExit::Interrupted(RunInterruptionReason::Cancelled),
|
||||||
worker.history().len(),
|
worker.history().len(),
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
|
|||||||
@@ -7,9 +7,10 @@ publish = false
|
|||||||
|
|
||||||
[features]
|
[features]
|
||||||
default = []
|
default = []
|
||||||
typescript = ["dep:ts-rs"]
|
typescript = ["dep:ts-rs", "protocol/typescript"]
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
|
protocol.workspace = true
|
||||||
serde = { workspace = true, features = ["derive"] }
|
serde = { workspace = true, features = ["derive"] }
|
||||||
ts-rs = { version = "12.0.1", optional = true }
|
ts-rs = { version = "12.0.1", optional = true }
|
||||||
|
|
||||||
@@ -24,6 +25,10 @@ serde_json.workspace = true
|
|||||||
name = "generate_workdir_api_types"
|
name = "generate_workdir_api_types"
|
||||||
required-features = ["typescript"]
|
required-features = ["typescript"]
|
||||||
|
|
||||||
|
[[example]]
|
||||||
|
name = "generate_worker_launch_api_types"
|
||||||
|
required-features = ["typescript"]
|
||||||
|
|
||||||
[[example]]
|
[[example]]
|
||||||
name = "generate_companion_api_types"
|
name = "generate_companion_api_types"
|
||||||
required-features = ["typescript"]
|
required-features = ["typescript"]
|
||||||
|
|||||||
@@ -0,0 +1,3 @@
|
|||||||
|
fn main() {
|
||||||
|
print!("{}", workspace_api::worker_launch_api_typescript());
|
||||||
|
}
|
||||||
@@ -579,6 +579,8 @@ pub struct WorkingDirectoryOccupancy {
|
|||||||
/// retains the Backend-generated Repository id and is never a Workspace public
|
/// retains the Backend-generated Repository id and is never a Workspace public
|
||||||
/// projection.
|
/// projection.
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
|
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||||
|
#[cfg_attr(feature = "typescript", ts(optional_fields = nullable))]
|
||||||
#[serde(deny_unknown_fields)]
|
#[serde(deny_unknown_fields)]
|
||||||
pub struct RuntimeWorkingDirectoryCleanupTarget {
|
pub struct RuntimeWorkingDirectoryCleanupTarget {
|
||||||
pub kind: String,
|
pub kind: String,
|
||||||
@@ -590,6 +592,8 @@ pub struct RuntimeWorkingDirectoryCleanupTarget {
|
|||||||
/// surfaces must project this through [`WorkingDirectorySummary`] so the UUID is
|
/// surfaces must project this through [`WorkingDirectorySummary`] so the UUID is
|
||||||
/// replaced with `repository_key`.
|
/// replaced with `repository_key`.
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
|
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||||
|
#[cfg_attr(feature = "typescript", ts(optional_fields = nullable))]
|
||||||
#[serde(deny_unknown_fields)]
|
#[serde(deny_unknown_fields)]
|
||||||
pub struct RuntimeWorkingDirectorySummary {
|
pub struct RuntimeWorkingDirectorySummary {
|
||||||
pub working_directory_id: String,
|
pub working_directory_id: String,
|
||||||
@@ -607,6 +611,7 @@ pub struct RuntimeWorkingDirectorySummary {
|
|||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
pub current_tree: Option<String>,
|
pub current_tree: Option<String>,
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
#[cfg_attr(feature = "typescript", ts(type = "number | null"))]
|
||||||
pub observed_at_epoch_seconds: Option<u64>,
|
pub observed_at_epoch_seconds: Option<u64>,
|
||||||
pub materializer_kind: WorkingDirectoryMaterializerKind,
|
pub materializer_kind: WorkingDirectoryMaterializerKind,
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
@@ -908,6 +913,7 @@ pub struct RuntimeConnectionTestResponse {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
|
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||||
pub struct WorkerWorkspaceSummary {
|
pub struct WorkerWorkspaceSummary {
|
||||||
pub visibility: String,
|
pub visibility: String,
|
||||||
pub identity: String,
|
pub identity: String,
|
||||||
@@ -916,12 +922,14 @@ pub struct WorkerWorkspaceSummary {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
|
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||||
pub struct WorkerImplementationSummary {
|
pub struct WorkerImplementationSummary {
|
||||||
pub kind: String,
|
pub kind: String,
|
||||||
pub display_hint: String,
|
pub display_hint: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
|
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||||
pub struct WorkerCapabilitySummary {
|
pub struct WorkerCapabilitySummary {
|
||||||
pub can_stop: bool,
|
pub can_stop: bool,
|
||||||
pub can_spawn_followup: bool,
|
pub can_spawn_followup: bool,
|
||||||
@@ -1095,6 +1103,7 @@ pub struct WorkspaceWorkerDiscoveryPage {
|
|||||||
/// do not carry one. The Workspace Server must resolve it from Workspace
|
/// do not carry one. The Workspace Server must resolve it from Workspace
|
||||||
/// authority before constructing this response.
|
/// authority before constructing this response.
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
|
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||||
pub struct WorkerSummary {
|
pub struct WorkerSummary {
|
||||||
pub runtime_id: String,
|
pub runtime_id: String,
|
||||||
pub worker_id: String,
|
pub worker_id: String,
|
||||||
@@ -1122,6 +1131,142 @@ pub struct WorkerSummary {
|
|||||||
pub diagnostics: Vec<Diagnostic>,
|
pub diagnostics: Vec<Diagnostic>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Runtime-owned Worker summary embedded in Worker launch responses.
|
||||||
|
///
|
||||||
|
/// This preserves the existing launch wire shape. Workspace-owned Worker list
|
||||||
|
/// and detail responses use [`WorkerSummary`] instead.
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
|
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||||
|
#[serde(deny_unknown_fields)]
|
||||||
|
pub struct WorkerLaunchWorkerSummary {
|
||||||
|
pub runtime_id: String,
|
||||||
|
pub worker_id: String,
|
||||||
|
pub host_id: String,
|
||||||
|
pub display_name: String,
|
||||||
|
pub label: String,
|
||||||
|
pub profile: Option<String>,
|
||||||
|
pub singleton_key: Option<String>,
|
||||||
|
pub tags: Vec<String>,
|
||||||
|
pub workspace: WorkerWorkspaceSummary,
|
||||||
|
pub state: String,
|
||||||
|
pub last_seen_at: Option<String>,
|
||||||
|
pub pinned: bool,
|
||||||
|
pub retention_state: String,
|
||||||
|
pub implementation: WorkerImplementationSummary,
|
||||||
|
pub capabilities: WorkerCapabilitySummary,
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
#[cfg_attr(feature = "typescript", ts(optional = nullable))]
|
||||||
|
pub working_directory: Option<RuntimeWorkingDirectorySummary>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub diagnostics: Vec<Diagnostic>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
|
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||||
|
#[serde(deny_unknown_fields)]
|
||||||
|
pub struct WorkerLaunchOptionsResponse {
|
||||||
|
pub workspace_id: String,
|
||||||
|
pub runtimes: Vec<WorkerLaunchRuntimeOption>,
|
||||||
|
pub default_profile: Option<String>,
|
||||||
|
pub profiles: Vec<WorkerLaunchProfileCandidate>,
|
||||||
|
pub repositories: Vec<WorkingDirectoryRepositoryOption>,
|
||||||
|
pub working_directories: Vec<WorkingDirectorySummary>,
|
||||||
|
pub diagnostics: Vec<Diagnostic>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
|
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||||
|
#[serde(deny_unknown_fields)]
|
||||||
|
pub struct WorkerLaunchRuntimeOption {
|
||||||
|
pub runtime_id: String,
|
||||||
|
pub display_name: String,
|
||||||
|
pub built_in: bool,
|
||||||
|
pub worker_creation_available: bool,
|
||||||
|
pub working_directory_required: bool,
|
||||||
|
pub status: String,
|
||||||
|
pub diagnostics: Vec<Diagnostic>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
|
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||||
|
#[serde(deny_unknown_fields)]
|
||||||
|
pub struct WorkerLaunchProfileCandidate {
|
||||||
|
pub id: String,
|
||||||
|
pub label: String,
|
||||||
|
pub description: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
|
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||||
|
#[serde(deny_unknown_fields)]
|
||||||
|
pub struct WorkingDirectoryRepositoryOption {
|
||||||
|
pub repository_key: String,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
#[cfg_attr(feature = "typescript", ts(optional = nullable))]
|
||||||
|
pub default_selector: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
|
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||||
|
#[serde(deny_unknown_fields)]
|
||||||
|
pub struct BrowserWorkerWorkingDirectorySelection {
|
||||||
|
pub working_directory_id: String,
|
||||||
|
#[serde(default)]
|
||||||
|
pub relative_cwd: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
|
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||||
|
#[serde(deny_unknown_fields)]
|
||||||
|
pub struct CreateWorkspaceWorkerTicketAssignmentRequest {
|
||||||
|
pub ticket_id: String,
|
||||||
|
pub operation_id: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
|
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||||
|
#[serde(deny_unknown_fields)]
|
||||||
|
pub struct CreateWorkspaceWorkerRequest {
|
||||||
|
pub runtime_id: String,
|
||||||
|
pub display_name: String,
|
||||||
|
#[serde(default)]
|
||||||
|
pub profile: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub ticket_assignment: Option<CreateWorkspaceWorkerTicketAssignmentRequest>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub initial_submit: Vec<protocol::Segment>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub working_directory: Option<BrowserWorkerWorkingDirectorySelection>,
|
||||||
|
/// Backend idempotency key used only for authenticated Worker-owned spawn/control.
|
||||||
|
#[serde(default)]
|
||||||
|
pub control_operation_id: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
|
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||||
|
#[serde(deny_unknown_fields)]
|
||||||
|
pub struct BrowserCreateWorkerResponse {
|
||||||
|
pub workspace_id: String,
|
||||||
|
pub runtime_id: String,
|
||||||
|
pub worker_id: String,
|
||||||
|
pub console_href: String,
|
||||||
|
pub worker: WorkerLaunchWorkerSummary,
|
||||||
|
pub diagnostics: Vec<Diagnostic>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
|
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||||
|
#[serde(deny_unknown_fields)]
|
||||||
|
pub struct BrowserWorkspaceOrchestratorResponse {
|
||||||
|
pub workspace_id: String,
|
||||||
|
pub online: bool,
|
||||||
|
pub disposition: String,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
#[cfg_attr(feature = "typescript", ts(optional = nullable))]
|
||||||
|
pub worker: Option<WorkerLaunchWorkerSummary>,
|
||||||
|
pub diagnostics: Vec<Diagnostic>,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
|
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
#[serde(rename_all = "snake_case")]
|
#[serde(rename_all = "snake_case")]
|
||||||
pub enum WorkerOperationState {
|
pub enum WorkerOperationState {
|
||||||
@@ -1390,6 +1535,74 @@ pub fn workdir_api_typescript() -> String {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(feature = "typescript")]
|
||||||
|
pub fn worker_launch_api_typescript() -> String {
|
||||||
|
use ts_rs::TS;
|
||||||
|
|
||||||
|
let config = ts_rs::Config::default();
|
||||||
|
let declarations = [
|
||||||
|
DiagnosticSeverity::decl(&config),
|
||||||
|
Diagnostic::decl(&config),
|
||||||
|
WorkingDirectoryMaterializerKind::decl(&config),
|
||||||
|
WorkingDirectoryStatusKind::decl(&config),
|
||||||
|
WorkingDirectoryCleanupTarget::decl(&config),
|
||||||
|
RuntimeWorkingDirectoryCleanupTarget::decl(&config),
|
||||||
|
RuntimeWorkingDirectorySummary::decl(&config),
|
||||||
|
WorkingDirectoryOccupancy::decl(&config),
|
||||||
|
WorkingDirectorySummary::decl(&config),
|
||||||
|
WorkerWorkspaceSummary::decl(&config),
|
||||||
|
WorkerImplementationSummary::decl(&config),
|
||||||
|
WorkerCapabilitySummary::decl(&config),
|
||||||
|
WorkerLaunchWorkerSummary::decl(&config),
|
||||||
|
WorkerLaunchRuntimeOption::decl(&config),
|
||||||
|
WorkerLaunchProfileCandidate::decl(&config),
|
||||||
|
WorkingDirectoryRepositoryOption::decl(&config),
|
||||||
|
WorkerLaunchOptionsResponse::decl(&config),
|
||||||
|
BrowserWorkerWorkingDirectorySelection::decl(&config),
|
||||||
|
CreateWorkspaceWorkerTicketAssignmentRequest::decl(&config),
|
||||||
|
CreateWorkspaceWorkerRequest::decl(&config),
|
||||||
|
BrowserCreateWorkerResponse::decl(&config),
|
||||||
|
BrowserWorkspaceOrchestratorResponse::decl(&config),
|
||||||
|
];
|
||||||
|
format!(
|
||||||
|
"// Generated from workspace-api. Do not edit by hand.\n// Regenerate: cargo run -q -p workspace-api --features typescript --example generate_worker_launch_api_types > web/workspace/src/lib/generated/worker-launch-api.ts\n\nimport type {{ Segment }} from \"./protocol\";\n\n{}\n",
|
||||||
|
declarations
|
||||||
|
.into_iter()
|
||||||
|
.map(|declaration| format!("export {declaration}"))
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join("\n\n")
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(all(test, feature = "typescript"))]
|
||||||
|
mod worker_launch_typescript_tests {
|
||||||
|
#[test]
|
||||||
|
fn generated_worker_launch_api_contract_is_current() {
|
||||||
|
let expected = super::worker_launch_api_typescript();
|
||||||
|
let path = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
|
||||||
|
.join("../../web/workspace/src/lib/generated/worker-launch-api.ts");
|
||||||
|
let actual = std::fs::read_to_string(&path)
|
||||||
|
.unwrap_or_else(|error| panic!("failed to read {}: {error}", path.display()));
|
||||||
|
assert_eq!(
|
||||||
|
normalize(&actual),
|
||||||
|
normalize(&expected),
|
||||||
|
"regenerate Worker launch API TypeScript types with `cargo run -q -p workspace-api --features typescript --example generate_worker_launch_api_types > web/workspace/src/lib/generated/worker-launch-api.ts` and format the generated file",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn normalize(value: &str) -> String {
|
||||||
|
value
|
||||||
|
.chars()
|
||||||
|
.filter_map(|character| match character {
|
||||||
|
character if character.is_whitespace() => None,
|
||||||
|
',' => Some(';'),
|
||||||
|
character => Some(character),
|
||||||
|
})
|
||||||
|
.collect::<String>()
|
||||||
|
.replace("=|", "=")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(all(test, feature = "typescript"))]
|
#[cfg(all(test, feature = "typescript"))]
|
||||||
mod workdir_typescript_tests {
|
mod workdir_typescript_tests {
|
||||||
#[test]
|
#[test]
|
||||||
@@ -1423,6 +1636,113 @@ mod workdir_typescript_tests {
|
|||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
|
fn worker_launch_summary() -> WorkerLaunchWorkerSummary {
|
||||||
|
WorkerLaunchWorkerSummary {
|
||||||
|
runtime_id: "runtime-a".to_string(),
|
||||||
|
worker_id: "worker-a".to_string(),
|
||||||
|
host_id: "host-a".to_string(),
|
||||||
|
display_name: "Worker A".to_string(),
|
||||||
|
label: "worker-a".to_string(),
|
||||||
|
profile: None,
|
||||||
|
singleton_key: None,
|
||||||
|
tags: Vec::new(),
|
||||||
|
workspace: WorkerWorkspaceSummary {
|
||||||
|
visibility: "workspace".to_string(),
|
||||||
|
identity: "workspace-a".to_string(),
|
||||||
|
workspace_id: Some("workspace-a".to_string()),
|
||||||
|
},
|
||||||
|
state: "idle".to_string(),
|
||||||
|
last_seen_at: None,
|
||||||
|
pinned: false,
|
||||||
|
retention_state: "active".to_string(),
|
||||||
|
implementation: WorkerImplementationSummary {
|
||||||
|
kind: "runtime".to_string(),
|
||||||
|
display_hint: "Runtime Worker".to_string(),
|
||||||
|
},
|
||||||
|
capabilities: WorkerCapabilitySummary {
|
||||||
|
can_stop: true,
|
||||||
|
can_spawn_followup: false,
|
||||||
|
},
|
||||||
|
working_directory: None,
|
||||||
|
diagnostics: Vec::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn worker_launch_optional_omission_and_request_shape_are_stable() {
|
||||||
|
assert_eq!(
|
||||||
|
serde_json::to_value(WorkingDirectoryRepositoryOption {
|
||||||
|
repository_key: "main".to_string(),
|
||||||
|
default_selector: None,
|
||||||
|
})
|
||||||
|
.unwrap(),
|
||||||
|
serde_json::json!({ "repository_key": "main" })
|
||||||
|
);
|
||||||
|
|
||||||
|
let orchestrator = serde_json::to_value(BrowserWorkspaceOrchestratorResponse {
|
||||||
|
workspace_id: "workspace-a".to_string(),
|
||||||
|
online: false,
|
||||||
|
disposition: "unavailable".to_string(),
|
||||||
|
worker: None,
|
||||||
|
diagnostics: Vec::new(),
|
||||||
|
})
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
orchestrator,
|
||||||
|
serde_json::json!({
|
||||||
|
"workspace_id": "workspace-a",
|
||||||
|
"online": false,
|
||||||
|
"disposition": "unavailable",
|
||||||
|
"diagnostics": [],
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
let worker = serde_json::to_value(worker_launch_summary()).unwrap();
|
||||||
|
assert!(
|
||||||
|
!worker
|
||||||
|
.as_object()
|
||||||
|
.unwrap()
|
||||||
|
.contains_key("working_directory")
|
||||||
|
);
|
||||||
|
assert_eq!(worker["profile"], serde_json::Value::Null);
|
||||||
|
assert_eq!(worker["singleton_key"], serde_json::Value::Null);
|
||||||
|
assert_eq!(worker["last_seen_at"], serde_json::Value::Null);
|
||||||
|
|
||||||
|
let request = serde_json::to_value(CreateWorkspaceWorkerRequest {
|
||||||
|
runtime_id: "runtime-a".to_string(),
|
||||||
|
display_name: "Worker A".to_string(),
|
||||||
|
profile: None,
|
||||||
|
ticket_assignment: None,
|
||||||
|
initial_submit: Vec::new(),
|
||||||
|
working_directory: None,
|
||||||
|
control_operation_id: None,
|
||||||
|
})
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
request,
|
||||||
|
serde_json::json!({
|
||||||
|
"runtime_id": "runtime-a",
|
||||||
|
"display_name": "Worker A",
|
||||||
|
"profile": null,
|
||||||
|
"ticket_assignment": null,
|
||||||
|
"initial_submit": [],
|
||||||
|
"working_directory": null,
|
||||||
|
"control_operation_id": null,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn worker_launch_request_rejects_unknown_fields() {
|
||||||
|
let error = serde_json::from_value::<CreateWorkspaceWorkerRequest>(serde_json::json!({
|
||||||
|
"runtime_id": "runtime-a",
|
||||||
|
"display_name": "Worker A",
|
||||||
|
"unexpected": true,
|
||||||
|
}))
|
||||||
|
.unwrap_err();
|
||||||
|
assert!(error.to_string().contains("unknown field"));
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn repository_key_validation_is_canonical_and_bounded() {
|
fn repository_key_validation_is_canonical_and_bounded() {
|
||||||
let max = "a".repeat(64);
|
let max = "a".repeat(64);
|
||||||
|
|||||||
@@ -23,10 +23,10 @@ use worker_runtime::RuntimeWorkspaceScope;
|
|||||||
use worker_runtime::auth::{CapabilityTokenSigner, capability_claims};
|
use worker_runtime::auth::{CapabilityTokenSigner, capability_claims};
|
||||||
use worker_runtime::catalog::{
|
use worker_runtime::catalog::{
|
||||||
ConfigBundleRef, CreateWorkerRequest, ProfileSelector, ProfileSourceArchiveHttpRef,
|
ConfigBundleRef, CreateWorkerRequest, ProfileSelector, ProfileSourceArchiveHttpRef,
|
||||||
ProfileSourceArchiveSource, WorkerDetail as EmbeddedWorkerDetail,
|
ProfileSourceArchiveSource, RepositoryRefObservation, RepositoryRefObservationRequest,
|
||||||
WorkerStatus as EmbeddedWorkerStatus, WorkingDirectoryClaim,
|
WorkerDetail as EmbeddedWorkerDetail, WorkerStatus as EmbeddedWorkerStatus,
|
||||||
WorkingDirectoryRepositoryAccessRequest, WorkingDirectoryRequest, WorkingDirectoryStatus,
|
WorkingDirectoryClaim, WorkingDirectoryRepositoryAccessRequest, WorkingDirectoryRequest,
|
||||||
WorkingDirectorySummary, WorkspaceApiRef,
|
WorkingDirectoryStatus, WorkingDirectorySummary, WorkspaceApiRef,
|
||||||
};
|
};
|
||||||
use worker_runtime::config_bundle::{ConfigBundle, ConfigBundleAvailability, ConfigBundleSummary};
|
use worker_runtime::config_bundle::{ConfigBundle, ConfigBundleAvailability, ConfigBundleSummary};
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
@@ -830,6 +830,17 @@ pub trait WorkspaceWorkerRuntime: Send + Sync {
|
|||||||
))
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn observe_repository_ref(
|
||||||
|
&self,
|
||||||
|
_request: RepositoryRefObservationRequest,
|
||||||
|
) -> std::result::Result<RepositoryRefObservation, Error> {
|
||||||
|
Err(Error::RuntimeOperationFailed {
|
||||||
|
runtime_id: self.runtime_id().to_string(),
|
||||||
|
code: "repository_ref_provider_unavailable".to_string(),
|
||||||
|
message: "Runtime does not support Repository ref observation".to_string(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
fn list_working_directories(&self) -> RuntimeList<WorkingDirectoryStatus> {
|
fn list_working_directories(&self) -> RuntimeList<WorkingDirectoryStatus> {
|
||||||
RuntimeList::new(Vec::new(), Vec::new())
|
RuntimeList::new(Vec::new(), Vec::new())
|
||||||
}
|
}
|
||||||
@@ -1449,6 +1460,31 @@ impl RuntimeRegistry {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn observe_repository_ref(
|
||||||
|
&self,
|
||||||
|
runtime_id: &str,
|
||||||
|
request: RepositoryRefObservationRequest,
|
||||||
|
) -> Result<RepositoryRefObservation, RuntimeRegistryError> {
|
||||||
|
validate_backend_identifier("runtime_id", runtime_id)?;
|
||||||
|
let runtime = self.runtime(runtime_id)?;
|
||||||
|
runtime
|
||||||
|
.observe_repository_ref(request)
|
||||||
|
.map_err(|error| match error {
|
||||||
|
Error::RuntimeOperationFailed { code, message, .. } => {
|
||||||
|
RuntimeRegistryError::RuntimeOperationFailed {
|
||||||
|
runtime_id: runtime_id.to_string(),
|
||||||
|
code,
|
||||||
|
message,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
other => RuntimeRegistryError::RuntimeOperationFailed {
|
||||||
|
runtime_id: runtime_id.to_string(),
|
||||||
|
code: "repository_ref_provider_unavailable".to_string(),
|
||||||
|
message: other.to_string(),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
pub fn list_working_directories(
|
pub fn list_working_directories(
|
||||||
&self,
|
&self,
|
||||||
runtime_id: &str,
|
runtime_id: &str,
|
||||||
@@ -2143,6 +2179,28 @@ impl WorkspaceWorkerRuntime for EmbeddedWorkerRuntime {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn observe_repository_ref(
|
||||||
|
&self,
|
||||||
|
request: RepositoryRefObservationRequest,
|
||||||
|
) -> std::result::Result<RepositoryRefObservation, Error> {
|
||||||
|
self.runtime
|
||||||
|
.observe_repository_ref(request)
|
||||||
|
.map_err(|error| match error {
|
||||||
|
worker_runtime::error::RuntimeError::WorkingDirectory(diagnostic) => {
|
||||||
|
Error::RuntimeOperationFailed {
|
||||||
|
runtime_id: self.runtime_id.clone(),
|
||||||
|
code: diagnostic.code,
|
||||||
|
message: diagnostic.message,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
error => Error::RuntimeOperationFailed {
|
||||||
|
runtime_id: self.runtime_id.clone(),
|
||||||
|
code: "repository_ref_provider_unavailable".to_string(),
|
||||||
|
message: error.to_string(),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
fn list_working_directories(&self) -> RuntimeList<WorkingDirectoryStatus> {
|
fn list_working_directories(&self) -> RuntimeList<WorkingDirectoryStatus> {
|
||||||
RuntimeList::new(Vec::new(), Vec::new())
|
RuntimeList::new(Vec::new(), Vec::new())
|
||||||
}
|
}
|
||||||
@@ -3355,6 +3413,18 @@ impl WorkspaceWorkerRuntime for RemoteWorkerRuntime {
|
|||||||
.map_err(|diagnostic| Error::RegistryInconsistency(diagnostic.message))
|
.map_err(|diagnostic| Error::RegistryInconsistency(diagnostic.message))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn observe_repository_ref(
|
||||||
|
&self,
|
||||||
|
request: RepositoryRefObservationRequest,
|
||||||
|
) -> std::result::Result<RepositoryRefObservation, Error> {
|
||||||
|
self.post_json::<_, RepositoryRefObservation>("/v1/repository-refs/observe", &request)
|
||||||
|
.map_err(|diagnostic| Error::RuntimeOperationFailed {
|
||||||
|
runtime_id: self.runtime_id.clone(),
|
||||||
|
code: diagnostic.code,
|
||||||
|
message: diagnostic.message,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
fn list_working_directories(&self) -> RuntimeList<WorkingDirectoryStatus> {
|
fn list_working_directories(&self) -> RuntimeList<WorkingDirectoryStatus> {
|
||||||
match self.get_json::<RuntimeHttpWorkingDirectoriesResponse>("/v1/working-directories") {
|
match self.get_json::<RuntimeHttpWorkingDirectoriesResponse>("/v1/working-directories") {
|
||||||
Ok(response) => RuntimeList::new(response.working_directories, Vec::new()),
|
Ok(response) => RuntimeList::new(response.working_directories, Vec::new()),
|
||||||
@@ -3772,7 +3842,6 @@ fn embedded_worker_status_label(status: EmbeddedWorkerStatus) -> &'static str {
|
|||||||
EmbeddedWorkerStatus::Running => "running",
|
EmbeddedWorkerStatus::Running => "running",
|
||||||
EmbeddedWorkerStatus::Paused => "paused",
|
EmbeddedWorkerStatus::Paused => "paused",
|
||||||
EmbeddedWorkerStatus::Stopped => "stopped",
|
EmbeddedWorkerStatus::Stopped => "stopped",
|
||||||
EmbeddedWorkerStatus::Cancelled => "cancelled",
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -4790,6 +4859,85 @@ mod tests {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
struct ObservingExecutionBackend {
|
||||||
|
response: RepositoryRefObservation,
|
||||||
|
observed: Arc<Mutex<Vec<RepositoryRefObservationRequest>>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl worker_runtime::execution::WorkerExecutionBackend for ObservingExecutionBackend {
|
||||||
|
fn backend_id(&self) -> &str {
|
||||||
|
"repository-observation-test-backend"
|
||||||
|
}
|
||||||
|
|
||||||
|
fn spawn_worker(
|
||||||
|
&self,
|
||||||
|
_request: worker_runtime::execution::WorkerExecutionSpawnRequest,
|
||||||
|
) -> worker_runtime::execution::WorkerExecutionSpawnResult {
|
||||||
|
unreachable!("Repository observation test does not spawn Workers")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn dispatch_input(
|
||||||
|
&self,
|
||||||
|
_handle: &worker_runtime::execution::WorkerExecutionHandle,
|
||||||
|
_input: EmbeddedWorkerInput,
|
||||||
|
) -> worker_runtime::execution::WorkerExecutionResult {
|
||||||
|
unreachable!("Repository observation test does not dispatch Worker input")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn observe_repository_ref(
|
||||||
|
&self,
|
||||||
|
request: &RepositoryRefObservationRequest,
|
||||||
|
) -> Result<
|
||||||
|
RepositoryRefObservation,
|
||||||
|
worker_runtime::working_directory::WorkingDirectoryDiagnostic,
|
||||||
|
> {
|
||||||
|
self.observed.lock().unwrap().push(request.clone());
|
||||||
|
Ok(self.response.clone())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn embedded_runtime_forwards_repository_ref_observation_to_execution_backend() {
|
||||||
|
let observed = Arc::new(Mutex::new(Vec::new()));
|
||||||
|
let expected = RepositoryRefObservation {
|
||||||
|
repository_id: "repository-1".to_string(),
|
||||||
|
source_revision: 7,
|
||||||
|
source_fingerprint: "sha256:source".to_string(),
|
||||||
|
selector: "refs/heads/published".to_string(),
|
||||||
|
revision_ref: "0123456789012345678901234567890123456789".to_string(),
|
||||||
|
observed_at_epoch_seconds: 42,
|
||||||
|
};
|
||||||
|
let runtime = EmbeddedWorkerRuntime::new_memory_with_execution_backend(
|
||||||
|
"workspace-test",
|
||||||
|
Arc::new(ObservingExecutionBackend {
|
||||||
|
response: expected.clone(),
|
||||||
|
observed: observed.clone(),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
let request = RepositoryRefObservationRequest {
|
||||||
|
repository: worker_runtime::catalog::WorkingDirectoryRepository {
|
||||||
|
id: "repository-1".to_string(),
|
||||||
|
provider: "git".to_string(),
|
||||||
|
source: workspace_api::RepositorySource {
|
||||||
|
kind: workspace_api::RepositorySourceKind::LocalPath,
|
||||||
|
uri: "/provider/repository.git".to_string(),
|
||||||
|
},
|
||||||
|
source_revision: 7,
|
||||||
|
source_fingerprint: "sha256:source".to_string(),
|
||||||
|
selector: None,
|
||||||
|
},
|
||||||
|
selector: "refs/heads/published".to_string(),
|
||||||
|
materialization: None,
|
||||||
|
};
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
runtime.observe_repository_ref(request.clone()).unwrap(),
|
||||||
|
expected
|
||||||
|
);
|
||||||
|
assert_eq!(observed.lock().unwrap().as_slice(), &[request]);
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Default)]
|
#[derive(Default)]
|
||||||
struct AcceptingExecutionBackend {
|
struct AcceptingExecutionBackend {
|
||||||
contexts:
|
contexts:
|
||||||
@@ -5678,7 +5826,7 @@ mod tests {
|
|||||||
json!({
|
json!({
|
||||||
"workers": [
|
"workers": [
|
||||||
worker_json_with_status("remote:primary", &worker_ids[0], "stopped"),
|
worker_json_with_status("remote:primary", &worker_ids[0], "stopped"),
|
||||||
worker_json_with_status("remote:primary", &worker_ids[1], "cancelled"),
|
worker_json_with_status("remote:primary", &worker_ids[1], "running"),
|
||||||
worker_json_with_status("remote:primary", &worker_ids[2], "paused"),
|
worker_json_with_status("remote:primary", &worker_ids[2], "paused"),
|
||||||
worker_json_with_status("remote:primary", &worker_ids[3], "idle")
|
worker_json_with_status("remote:primary", &worker_ids[3], "idle")
|
||||||
]
|
]
|
||||||
@@ -5717,11 +5865,11 @@ mod tests {
|
|||||||
let workers = registry.list_workers(10);
|
let workers = registry.list_workers(10);
|
||||||
assert_eq!(workers.items.len(), 4);
|
assert_eq!(workers.items.len(), 4);
|
||||||
assert!(!workers.items[0].capabilities.can_stop);
|
assert!(!workers.items[0].capabilities.can_stop);
|
||||||
assert!(!workers.items[1].capabilities.can_stop);
|
assert!(workers.items[1].capabilities.can_stop);
|
||||||
assert!(workers.items[2].capabilities.can_stop);
|
assert!(workers.items[2].capabilities.can_stop);
|
||||||
assert!(workers.items[3].capabilities.can_stop);
|
assert!(workers.items[3].capabilities.can_stop);
|
||||||
assert_eq!(workers.items[0].state, "stopped");
|
assert_eq!(workers.items[0].state, "stopped");
|
||||||
assert_eq!(workers.items[1].state, "cancelled");
|
assert_eq!(workers.items[1].state, "running");
|
||||||
assert_eq!(workers.items[2].state, "paused");
|
assert_eq!(workers.items[2].state, "paused");
|
||||||
assert_eq!(workers.items[3].state, "idle");
|
assert_eq!(workers.items[3].state, "idle");
|
||||||
|
|
||||||
|
|||||||
@@ -314,12 +314,21 @@ pub struct TicketMergeRequestSummary {
|
|||||||
pub review_excerpt: Option<String>,
|
pub review_excerpt: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
|
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||||
|
pub struct MergeRequestRefDiagnostic {
|
||||||
|
pub code: String,
|
||||||
|
pub message: String,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||||
pub struct MergeRequestListItem {
|
pub struct MergeRequestListItem {
|
||||||
pub summary: TicketMergeRequestSummary,
|
pub summary: TicketMergeRequestSummary,
|
||||||
pub ticket_ids: Vec<String>,
|
pub ticket_ids: Vec<String>,
|
||||||
pub thread_event_count: usize,
|
pub thread_event_count: usize,
|
||||||
|
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||||
|
pub ref_diagnostics: Vec<MergeRequestRefDiagnostic>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
@@ -470,6 +479,7 @@ pub fn ticket_api_typescript() -> String {
|
|||||||
TicketAssignmentPrincipalSummary::decl(&config),
|
TicketAssignmentPrincipalSummary::decl(&config),
|
||||||
TicketActionEligibility::decl(&config),
|
TicketActionEligibility::decl(&config),
|
||||||
TicketMergeRequestSummary::decl(&config),
|
TicketMergeRequestSummary::decl(&config),
|
||||||
|
MergeRequestRefDiagnostic::decl(&config),
|
||||||
MergeRequestListItem::decl(&config),
|
MergeRequestListItem::decl(&config),
|
||||||
MergeRequestListResponse::decl(&config),
|
MergeRequestListResponse::decl(&config),
|
||||||
TicketEvidenceSummary::decl(&config),
|
TicketEvidenceSummary::decl(&config),
|
||||||
|
|||||||
@@ -392,7 +392,7 @@ impl RepositoryRegistryReader {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn normalize_target_branch_selector(
|
pub(crate) fn normalize_target_branch_selector(
|
||||||
id: &str,
|
id: &str,
|
||||||
selector: &str,
|
selector: &str,
|
||||||
) -> Result<String, RepositoryLookupError> {
|
) -> Result<String, RepositoryLookupError> {
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
+1
-2
@@ -13,9 +13,8 @@ pkgs.mkShell {
|
|||||||
PLAYWRIGHT_BROWSERS_PATH = "${pkgs.playwright-driver.browsers}";
|
PLAYWRIGHT_BROWSERS_PATH = "${pkgs.playwright-driver.browsers}";
|
||||||
PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD = "1";
|
PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD = "1";
|
||||||
|
|
||||||
# sccache is additive to Cargo's shared build-dir, so keep its disk usage bounded.
|
# Cache storage and limits belong to the host's sccache configuration.
|
||||||
RUSTC_WRAPPER = "${pkgs.sccache}/bin/sccache";
|
RUSTC_WRAPPER = "${pkgs.sccache}/bin/sccache";
|
||||||
SCCACHE_CACHE_SIZE = "5G";
|
|
||||||
|
|
||||||
buildInputs = with pkgs; [
|
buildInputs = with pkgs; [
|
||||||
pkg-config
|
pkg-config
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
"dev": "deno run -A npm:vite@7.2.7 dev",
|
"dev": "deno run -A npm:vite@7.2.7 dev",
|
||||||
"dev:backend": "cd ../.. && cargo run -p yoi-workspace-server --bin yoi-server -- serve --listen 127.0.0.1:8787",
|
"dev:backend": "cd ../.. && cargo run -p yoi-workspace-server --bin yoi-server -- serve --listen 127.0.0.1:8787",
|
||||||
"check": "deno run -A npm:@sveltejs/kit@2.49.4 sync && deno run -A npm:svelte-check@4.3.4 --tsconfig ./tsconfig.json",
|
"check": "deno run -A npm:@sveltejs/kit@2.49.4 sync && deno run -A npm:svelte-check@4.3.4 --tsconfig ./tsconfig.json",
|
||||||
"test": "deno test --allow-read=src,test,tests --allow-env=LOG,VSCODE_TEXTMATE_DEBUG,NODE_ENV tests/workspace-model.test.ts tests/workspace-catalog.test.ts tests/profile-api.test.ts src/lib/workspace/auth/model.test.ts src/lib/workspace/api/http.test.ts src/lib/workspace/header/breadcrumb-model.test.ts src/lib/workspace/console/chat-submit.test.ts test/composer-history.test.ts tests/composer-paste.test.ts src/lib/workspace/console/composer-command.test.ts src/lib/workspace/console/composer-draft.test.ts src/lib/workspace/console/composer-completion.test.ts src/lib/workspace/console/markdown.test.ts test/console/ansi.test.ts src/lib/workspace/console/model.test.ts src/lib/workspace/companion/api.test.ts tests/workdir-api.test.ts src/lib/workspace/console/tasks.test.ts test/ticket-detail-route-reuse.test.ts test/repositories/ui.test.ts src/lib/workspace/console/worker-console.ui.test.ts src/lib/workspace/settings/model.test.ts src/lib/workspace/sidebar/override-stack.test.ts src/lib/workspace/sidebar/workers.test.ts src/lib/workspace/sidebar/workspace-switcher.test.ts src/lib/workspace/sidebar/worker-subscription.test.ts src/lib/workspace/sidebar/worker-launch.test.ts test/sidebar/worker-actions.test.ts src/lib/workspace/tickets/merge-request-resources.test.ts src/lib/workspace/tickets/ticket-panel.test.ts test/merge-request-status.test.ts test/config-source/decodal-grammar.test.ts test/config-source/editor-state.test.ts test/config-source/fixed-schema-wrapper.test.ts test/config-source/toolchain.test.ts test/config-source/wasm-parity.test.ts test/repository-access/api.test.ts test/repository-access/loader.test.ts test/repository-access/ui.test.ts",
|
"test": "deno test --allow-read=src,test,tests --allow-env=LOG,VSCODE_TEXTMATE_DEBUG,NODE_ENV tests/workspace-model.test.ts tests/workspace-catalog.test.ts tests/profile-api.test.ts src/lib/workspace/auth/model.test.ts src/lib/workspace/api/http.test.ts src/lib/workspace/api/workers.test.ts src/lib/workspace/header/breadcrumb-model.test.ts src/lib/workspace/console/chat-submit.test.ts test/composer-history.test.ts tests/composer-paste.test.ts src/lib/workspace/console/composer-command.test.ts src/lib/workspace/console/composer-draft.test.ts src/lib/workspace/console/composer-completion.test.ts src/lib/workspace/console/markdown.test.ts test/console/ansi.test.ts src/lib/workspace/console/model.test.ts src/lib/workspace/companion/api.test.ts tests/workdir-api.test.ts src/lib/workspace/console/tasks.test.ts test/ticket-detail-route-reuse.test.ts test/repositories/ui.test.ts src/lib/workspace/console/worker-console.ui.test.ts src/lib/workspace/settings/model.test.ts src/lib/workspace/sidebar/override-stack.test.ts src/lib/workspace/sidebar/workers.test.ts src/lib/workspace/sidebar/workspace-switcher.test.ts src/lib/workspace/sidebar/worker-subscription.test.ts src/lib/workspace/sidebar/worker-launch.test.ts test/sidebar/worker-actions.test.ts src/lib/workspace/tickets/merge-request-resources.test.ts src/lib/workspace/tickets/ticket-panel.test.ts test/merge-request-status.test.ts test/config-source/decodal-grammar.test.ts test/config-source/editor-state.test.ts test/config-source/fixed-schema-wrapper.test.ts test/config-source/toolchain.test.ts test/config-source/wasm-parity.test.ts test/repository-access/api.test.ts test/repository-access/loader.test.ts test/repository-access/ui.test.ts",
|
||||||
"build": "deno run -A npm:vite@7.2.7 build",
|
"build": "deno run -A npm:vite@7.2.7 build",
|
||||||
"preview": "deno run -A npm:vite@7.2.7 preview"
|
"preview": "deno run -A npm:vite@7.2.7 preview"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -178,7 +178,7 @@ export type SubscriptionWorkdirId = string;
|
|||||||
|
|
||||||
export type SubscriptionWorkerIds = Array<SubscriptionWorkerId>;
|
export type SubscriptionWorkerIds = Array<SubscriptionWorkerId>;
|
||||||
|
|
||||||
export type SubscriptionWorkerState = "idle" | "running" | "paused" | "stopped" | "cancelled";
|
export type SubscriptionWorkerState = "idle" | "running" | "paused" | "stopped";
|
||||||
|
|
||||||
export type EventSubscriptionSelector = { "topic": "runtime_workers" } | { "topic": "worker_lifecycle", worker_ids: SubscriptionWorkerIds, } | { "topic": "worker_protocol", worker_id: SubscriptionWorkerId, runtime_id?: string | null, } | { "topic": "workspace_workers" } | { "topic": "workspace_workdirs" };
|
export type EventSubscriptionSelector = { "topic": "runtime_workers" } | { "topic": "worker_lifecycle", worker_ids: SubscriptionWorkerIds, } | { "topic": "worker_protocol", worker_id: SubscriptionWorkerId, runtime_id?: string | null, } | { "topic": "workspace_workers" } | { "topic": "workspace_workdirs" };
|
||||||
|
|
||||||
|
|||||||
@@ -25,7 +25,9 @@ export type TicketActionEligibility = { can_assign_orchestrator: boolean, can_un
|
|||||||
|
|
||||||
export type TicketMergeRequestSummary = { merge_request_id: string, repository_key: string, state: string, review_status: string, selector_from: string | null, selector_to: string, updated_at: string, current_subject_ref: string | null, review_subject_ref: string | null, review_requested_at: string | null, review_submitted_at: string | null, review_excerpt: string | null, };
|
export type TicketMergeRequestSummary = { merge_request_id: string, repository_key: string, state: string, review_status: string, selector_from: string | null, selector_to: string, updated_at: string, current_subject_ref: string | null, review_subject_ref: string | null, review_requested_at: string | null, review_submitted_at: string | null, review_excerpt: string | null, };
|
||||||
|
|
||||||
export type MergeRequestListItem = { summary: TicketMergeRequestSummary, ticket_ids: Array<string>, thread_event_count: number, };
|
export type MergeRequestRefDiagnostic = { code: string, message: string, };
|
||||||
|
|
||||||
|
export type MergeRequestListItem = { summary: TicketMergeRequestSummary, ticket_ids: Array<string>, thread_event_count: number, ref_diagnostics?: Array<MergeRequestRefDiagnostic>, };
|
||||||
|
|
||||||
export type MergeRequestListResponse = { items: Array<MergeRequestListItem>, next_cursor: string | null, };
|
export type MergeRequestListResponse = { items: Array<MergeRequestListItem>, next_cursor: string | null, };
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,185 @@
|
|||||||
|
// Generated from workspace-api. Do not edit by hand.
|
||||||
|
// Regenerate: cargo run -q -p workspace-api --features typescript --example generate_worker_launch_api_types > web/workspace/src/lib/generated/worker-launch-api.ts
|
||||||
|
|
||||||
|
import type { Segment } from "./protocol";
|
||||||
|
|
||||||
|
export type DiagnosticSeverity = "info" | "warning" | "error";
|
||||||
|
|
||||||
|
export type Diagnostic = {
|
||||||
|
code: string;
|
||||||
|
severity: DiagnosticSeverity;
|
||||||
|
message: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type WorkingDirectoryMaterializerKind =
|
||||||
|
| "runtime_git_cache"
|
||||||
|
| "local_git_worktree";
|
||||||
|
|
||||||
|
export type WorkingDirectoryStatusKind =
|
||||||
|
| "active"
|
||||||
|
| "cleanup_pending"
|
||||||
|
| "corrupted"
|
||||||
|
| "not_found"
|
||||||
|
| "unknown";
|
||||||
|
|
||||||
|
export type WorkingDirectoryCleanupTarget = {
|
||||||
|
kind: string;
|
||||||
|
working_directory_id: string;
|
||||||
|
repository_key: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type RuntimeWorkingDirectoryCleanupTarget = {
|
||||||
|
kind: string;
|
||||||
|
working_directory_id: string;
|
||||||
|
repository_id: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type RuntimeWorkingDirectorySummary = {
|
||||||
|
working_directory_id: string;
|
||||||
|
repository_id: string;
|
||||||
|
creation_selector?: string | null;
|
||||||
|
creation_ref?: string | null;
|
||||||
|
creation_tree?: string | null;
|
||||||
|
current_selector?: string | null;
|
||||||
|
current_ref?: string | null;
|
||||||
|
current_tree?: string | null;
|
||||||
|
observed_at_epoch_seconds?: number | null;
|
||||||
|
materializer_kind: WorkingDirectoryMaterializerKind;
|
||||||
|
cleanup_target?: RuntimeWorkingDirectoryCleanupTarget | null;
|
||||||
|
status: WorkingDirectoryStatusKind;
|
||||||
|
cleanliness?: string | null;
|
||||||
|
primary_worker_id?: string | null;
|
||||||
|
occupied_by?: WorkingDirectoryOccupancy | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type WorkingDirectoryOccupancy = {
|
||||||
|
runtime_id: string;
|
||||||
|
worker_id: string;
|
||||||
|
display_name: string;
|
||||||
|
linked_at: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type WorkingDirectorySummary = {
|
||||||
|
working_directory_id: string;
|
||||||
|
repository_key: string;
|
||||||
|
creation_selector?: string | null;
|
||||||
|
creation_ref?: string | null;
|
||||||
|
creation_tree?: string | null;
|
||||||
|
current_selector?: string | null;
|
||||||
|
current_ref?: string | null;
|
||||||
|
current_tree?: string | null;
|
||||||
|
observed_at_epoch_seconds?: number | null;
|
||||||
|
materializer_kind: WorkingDirectoryMaterializerKind;
|
||||||
|
cleanup_target?: WorkingDirectoryCleanupTarget | null;
|
||||||
|
status: WorkingDirectoryStatusKind;
|
||||||
|
cleanliness?: string | null;
|
||||||
|
primary_worker_id?: string | null;
|
||||||
|
occupied_by?: WorkingDirectoryOccupancy | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type WorkerWorkspaceSummary = {
|
||||||
|
visibility: string;
|
||||||
|
identity: string;
|
||||||
|
workspace_id?: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type WorkerImplementationSummary = {
|
||||||
|
kind: string;
|
||||||
|
display_hint: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type WorkerCapabilitySummary = {
|
||||||
|
can_stop: boolean;
|
||||||
|
can_spawn_followup: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type WorkerLaunchWorkerSummary = {
|
||||||
|
runtime_id: string;
|
||||||
|
worker_id: string;
|
||||||
|
host_id: string;
|
||||||
|
display_name: string;
|
||||||
|
label: string;
|
||||||
|
profile: string | null;
|
||||||
|
singleton_key: string | null;
|
||||||
|
tags: Array<string>;
|
||||||
|
workspace: WorkerWorkspaceSummary;
|
||||||
|
state: string;
|
||||||
|
last_seen_at: string | null;
|
||||||
|
pinned: boolean;
|
||||||
|
retention_state: string;
|
||||||
|
implementation: WorkerImplementationSummary;
|
||||||
|
capabilities: WorkerCapabilitySummary;
|
||||||
|
working_directory?: RuntimeWorkingDirectorySummary | null;
|
||||||
|
diagnostics: Array<Diagnostic>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type WorkerLaunchRuntimeOption = {
|
||||||
|
runtime_id: string;
|
||||||
|
display_name: string;
|
||||||
|
built_in: boolean;
|
||||||
|
worker_creation_available: boolean;
|
||||||
|
working_directory_required: boolean;
|
||||||
|
status: string;
|
||||||
|
diagnostics: Array<Diagnostic>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type WorkerLaunchProfileCandidate = {
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
description: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type WorkingDirectoryRepositoryOption = {
|
||||||
|
repository_key: string;
|
||||||
|
default_selector?: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type WorkerLaunchOptionsResponse = {
|
||||||
|
workspace_id: string;
|
||||||
|
runtimes: Array<WorkerLaunchRuntimeOption>;
|
||||||
|
default_profile: string | null;
|
||||||
|
profiles: Array<WorkerLaunchProfileCandidate>;
|
||||||
|
repositories: Array<WorkingDirectoryRepositoryOption>;
|
||||||
|
working_directories: Array<WorkingDirectorySummary>;
|
||||||
|
diagnostics: Array<Diagnostic>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type BrowserWorkerWorkingDirectorySelection = {
|
||||||
|
working_directory_id: string;
|
||||||
|
relative_cwd: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type CreateWorkspaceWorkerTicketAssignmentRequest = {
|
||||||
|
ticket_id: string;
|
||||||
|
operation_id: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type CreateWorkspaceWorkerRequest = {
|
||||||
|
runtime_id: string;
|
||||||
|
display_name: string;
|
||||||
|
profile: string | null;
|
||||||
|
ticket_assignment: CreateWorkspaceWorkerTicketAssignmentRequest | null;
|
||||||
|
initial_submit: Array<Segment>;
|
||||||
|
working_directory: BrowserWorkerWorkingDirectorySelection | null;
|
||||||
|
/**
|
||||||
|
* Backend idempotency key used only for authenticated Worker-owned spawn/control.
|
||||||
|
*/
|
||||||
|
control_operation_id: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type BrowserCreateWorkerResponse = {
|
||||||
|
workspace_id: string;
|
||||||
|
runtime_id: string;
|
||||||
|
worker_id: string;
|
||||||
|
console_href: string;
|
||||||
|
worker: WorkerLaunchWorkerSummary;
|
||||||
|
diagnostics: Array<Diagnostic>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type BrowserWorkspaceOrchestratorResponse = {
|
||||||
|
workspace_id: string;
|
||||||
|
online: boolean;
|
||||||
|
disposition: string;
|
||||||
|
worker?: WorkerLaunchWorkerSummary | null;
|
||||||
|
diagnostics: Array<Diagnostic>;
|
||||||
|
};
|
||||||
@@ -55,7 +55,7 @@ export function parseWorkingDirectoryListResponse(
|
|||||||
);
|
);
|
||||||
return {
|
return {
|
||||||
workspace_id: stringField(record, "workspace_id"),
|
workspace_id: stringField(record, "workspace_id"),
|
||||||
items: arrayField(record, "items").map(parseSummary),
|
items: arrayField(record, "items").map(parseWorkingDirectorySummary),
|
||||||
diagnostics: arrayField(record, "diagnostics").map(parseDiagnostic),
|
diagnostics: arrayField(record, "diagnostics").map(parseDiagnostic),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -101,12 +101,14 @@ function parseDetailLike(
|
|||||||
return {
|
return {
|
||||||
workspace_id: stringField(record, "workspace_id"),
|
workspace_id: stringField(record, "workspace_id"),
|
||||||
runtime_id: stringField(record, "runtime_id"),
|
runtime_id: stringField(record, "runtime_id"),
|
||||||
item: parseSummary(record.item),
|
item: parseWorkingDirectorySummary(record.item),
|
||||||
diagnostics: arrayField(record, "diagnostics").map(parseDiagnostic),
|
diagnostics: arrayField(record, "diagnostics").map(parseDiagnostic),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function parseSummary(value: unknown): WorkingDirectorySummary {
|
export function parseWorkingDirectorySummary(
|
||||||
|
value: unknown,
|
||||||
|
): WorkingDirectorySummary {
|
||||||
const record = exactRecord(value, SUMMARY_KEYS, "Workdir summary");
|
const record = exactRecord(value, SUMMARY_KEYS, "Workdir summary");
|
||||||
const summary: WorkingDirectorySummary = {
|
const summary: WorkingDirectorySummary = {
|
||||||
working_directory_id: stringField(record, "working_directory_id"),
|
working_directory_id: stringField(record, "working_directory_id"),
|
||||||
|
|||||||
@@ -0,0 +1,183 @@
|
|||||||
|
declare const Deno: {
|
||||||
|
test(name: string, fn: () => Promise<void> | void): void;
|
||||||
|
};
|
||||||
|
|
||||||
|
function assertEquals(actual: unknown, expected: unknown): void {
|
||||||
|
const actualJson = JSON.stringify(actual);
|
||||||
|
const expectedJson = JSON.stringify(expected);
|
||||||
|
if (actualJson !== expectedJson) {
|
||||||
|
throw new Error(`Expected ${expectedJson}, received ${actualJson}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function assertThrows(
|
||||||
|
operation: () => unknown,
|
||||||
|
errorClass: typeof Error,
|
||||||
|
message: string,
|
||||||
|
): void {
|
||||||
|
try {
|
||||||
|
operation();
|
||||||
|
} catch (error) {
|
||||||
|
if (!(error instanceof errorClass) || !error.message.includes(message)) {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
throw new Error(`Expected operation to throw ${errorClass.name}: ${message}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
import {
|
||||||
|
parseBrowserCreateWorkerResponse,
|
||||||
|
parseBrowserWorkspaceOrchestratorResponse,
|
||||||
|
parseCreateWorkspaceWorkerRequest,
|
||||||
|
parseWorkerLaunchOptionsResponse,
|
||||||
|
} from "./workers.ts";
|
||||||
|
|
||||||
|
const worker = {
|
||||||
|
runtime_id: "runtime-a",
|
||||||
|
worker_id: "worker-a",
|
||||||
|
host_id: "host-a",
|
||||||
|
display_name: "Worker A",
|
||||||
|
label: "worker-a",
|
||||||
|
profile: "builtin:coder",
|
||||||
|
singleton_key: null,
|
||||||
|
tags: [],
|
||||||
|
workspace: {
|
||||||
|
visibility: "workspace",
|
||||||
|
identity: "workspace-a",
|
||||||
|
workspace_id: "workspace-a",
|
||||||
|
},
|
||||||
|
state: "idle",
|
||||||
|
last_seen_at: null,
|
||||||
|
pinned: false,
|
||||||
|
retention_state: "active",
|
||||||
|
implementation: {
|
||||||
|
kind: "runtime",
|
||||||
|
display_hint: "Runtime Worker",
|
||||||
|
},
|
||||||
|
capabilities: {
|
||||||
|
can_stop: true,
|
||||||
|
can_spawn_followup: false,
|
||||||
|
},
|
||||||
|
diagnostics: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
Deno.test("Worker launch options parser accepts the generated wire shape", () => {
|
||||||
|
const parsed = parseWorkerLaunchOptionsResponse({
|
||||||
|
workspace_id: "workspace-a",
|
||||||
|
runtimes: [{
|
||||||
|
runtime_id: "runtime-a",
|
||||||
|
display_name: "Runtime A",
|
||||||
|
built_in: false,
|
||||||
|
worker_creation_available: true,
|
||||||
|
working_directory_required: true,
|
||||||
|
status: "connected",
|
||||||
|
diagnostics: [],
|
||||||
|
}],
|
||||||
|
default_profile: null,
|
||||||
|
profiles: [{ id: "builtin:coder", label: "Coder", description: "Code" }],
|
||||||
|
repositories: [{ repository_key: "main" }],
|
||||||
|
working_directories: [],
|
||||||
|
diagnostics: [],
|
||||||
|
});
|
||||||
|
|
||||||
|
assertEquals(parsed.runtimes[0].runtime_id, "runtime-a");
|
||||||
|
assertEquals(parsed.repositories[0].default_selector, undefined);
|
||||||
|
});
|
||||||
|
|
||||||
|
Deno.test("Worker launch response parsers reject missing and unknown fields", () => {
|
||||||
|
assertThrows(
|
||||||
|
() =>
|
||||||
|
parseWorkerLaunchOptionsResponse({
|
||||||
|
workspace_id: "workspace-a",
|
||||||
|
runtimes: [],
|
||||||
|
profiles: [],
|
||||||
|
repositories: [],
|
||||||
|
working_directories: [],
|
||||||
|
diagnostics: [],
|
||||||
|
}),
|
||||||
|
Error,
|
||||||
|
"default_profile",
|
||||||
|
);
|
||||||
|
|
||||||
|
assertThrows(
|
||||||
|
() =>
|
||||||
|
parseBrowserCreateWorkerResponse({
|
||||||
|
workspace_id: "workspace-a",
|
||||||
|
runtime_id: "runtime-a",
|
||||||
|
worker_id: "worker-a",
|
||||||
|
console_href: "/workers/worker-a",
|
||||||
|
worker,
|
||||||
|
diagnostics: [],
|
||||||
|
unexpected: true,
|
||||||
|
}),
|
||||||
|
Error,
|
||||||
|
"unknown field unexpected",
|
||||||
|
);
|
||||||
|
|
||||||
|
assertThrows(
|
||||||
|
() =>
|
||||||
|
parseBrowserWorkspaceOrchestratorResponse({
|
||||||
|
workspace_id: "workspace-a",
|
||||||
|
online: false,
|
||||||
|
disposition: "missing",
|
||||||
|
diagnostics: [],
|
||||||
|
extra: false,
|
||||||
|
}),
|
||||||
|
Error,
|
||||||
|
"unknown field extra",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
Deno.test("Worker create request parser requires the complete shared request", () => {
|
||||||
|
const request = {
|
||||||
|
runtime_id: "runtime-a",
|
||||||
|
display_name: "Worker A",
|
||||||
|
profile: "builtin:coder",
|
||||||
|
ticket_assignment: null,
|
||||||
|
initial_submit: [
|
||||||
|
{ kind: "text", content: "Implement T-565." },
|
||||||
|
{ kind: "flow", selector: "builtin:coder-review" },
|
||||||
|
],
|
||||||
|
working_directory: {
|
||||||
|
working_directory_id: "workdir-a",
|
||||||
|
relative_cwd: null,
|
||||||
|
},
|
||||||
|
control_operation_id: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
assertEquals(parseCreateWorkspaceWorkerRequest(request), request);
|
||||||
|
assertThrows(
|
||||||
|
() =>
|
||||||
|
parseCreateWorkspaceWorkerRequest({
|
||||||
|
...request,
|
||||||
|
operation_id: "legacy-literal",
|
||||||
|
}),
|
||||||
|
Error,
|
||||||
|
"unknown field operation_id",
|
||||||
|
);
|
||||||
|
const { initial_submit: _initialSubmit, ...missingInitialSubmit } = request;
|
||||||
|
assertThrows(
|
||||||
|
() => parseCreateWorkspaceWorkerRequest(missingInitialSubmit),
|
||||||
|
Error,
|
||||||
|
"initial_submit",
|
||||||
|
);
|
||||||
|
assertThrows(
|
||||||
|
() =>
|
||||||
|
parseCreateWorkspaceWorkerRequest({
|
||||||
|
...request,
|
||||||
|
initial_submit: [{ kind: "flow" }],
|
||||||
|
}),
|
||||||
|
Error,
|
||||||
|
"selector",
|
||||||
|
);
|
||||||
|
assertThrows(
|
||||||
|
() =>
|
||||||
|
parseCreateWorkspaceWorkerRequest({
|
||||||
|
...request,
|
||||||
|
initial_submit: [{ kind: "newer_client_segment" }],
|
||||||
|
}),
|
||||||
|
Error,
|
||||||
|
"kind is invalid",
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -0,0 +1,665 @@
|
|||||||
|
import type {
|
||||||
|
BrowserCreateWorkerResponse,
|
||||||
|
BrowserWorkerWorkingDirectorySelection,
|
||||||
|
BrowserWorkspaceOrchestratorResponse,
|
||||||
|
CreateWorkspaceWorkerRequest,
|
||||||
|
CreateWorkspaceWorkerTicketAssignmentRequest,
|
||||||
|
Diagnostic,
|
||||||
|
DiagnosticSeverity,
|
||||||
|
RuntimeWorkingDirectoryCleanupTarget,
|
||||||
|
RuntimeWorkingDirectorySummary,
|
||||||
|
WorkerCapabilitySummary,
|
||||||
|
WorkerImplementationSummary,
|
||||||
|
WorkerLaunchOptionsResponse,
|
||||||
|
WorkerLaunchProfileCandidate,
|
||||||
|
WorkerLaunchRuntimeOption,
|
||||||
|
WorkerLaunchWorkerSummary,
|
||||||
|
WorkerWorkspaceSummary,
|
||||||
|
WorkingDirectoryRepositoryOption,
|
||||||
|
} from "$lib/generated/worker-launch-api";
|
||||||
|
import type { Segment } from "$lib/generated/protocol";
|
||||||
|
import { parseWorkingDirectorySummary } from "$lib/workspace/api/workdirs";
|
||||||
|
|
||||||
|
const DIAGNOSTIC_SEVERITIES = new Set<DiagnosticSeverity>([
|
||||||
|
"info",
|
||||||
|
"warning",
|
||||||
|
"error",
|
||||||
|
]);
|
||||||
|
|
||||||
|
function record(value: unknown, label: string): Record<string, unknown> {
|
||||||
|
if (typeof value !== "object" || value === null || Array.isArray(value)) {
|
||||||
|
throw new Error(`${label} must be an object`);
|
||||||
|
}
|
||||||
|
return value as Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function exact(
|
||||||
|
value: Record<string, unknown>,
|
||||||
|
allowed: readonly string[],
|
||||||
|
label: string,
|
||||||
|
): void {
|
||||||
|
const unexpected = Object.keys(value).filter((key) => !allowed.includes(key));
|
||||||
|
if (unexpected.length > 0) {
|
||||||
|
throw new Error(`${label} contains unknown field ${unexpected[0]}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function string(value: unknown, label: string): string {
|
||||||
|
if (typeof value !== "string") throw new Error(`${label} must be a string`);
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function boolean(value: unknown, label: string): boolean {
|
||||||
|
if (typeof value !== "boolean") throw new Error(`${label} must be a boolean`);
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function number(value: unknown, label: string): number {
|
||||||
|
if (typeof value !== "number" || !Number.isFinite(value)) {
|
||||||
|
throw new Error(`${label} must be a finite number`);
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function nullableString(value: unknown, label: string): string | null {
|
||||||
|
return value === null ? null : string(value, label);
|
||||||
|
}
|
||||||
|
|
||||||
|
function array<T>(
|
||||||
|
value: unknown,
|
||||||
|
label: string,
|
||||||
|
parse: (item: unknown, label: string) => T,
|
||||||
|
): T[] {
|
||||||
|
if (!Array.isArray(value)) throw new Error(`${label} must be an array`);
|
||||||
|
return value.map((item, index) => parse(item, `${label}[${index}]`));
|
||||||
|
}
|
||||||
|
|
||||||
|
function optional<T>(
|
||||||
|
value: unknown,
|
||||||
|
label: string,
|
||||||
|
parse: (item: unknown, label: string) => T,
|
||||||
|
): T | null | undefined {
|
||||||
|
return value === undefined
|
||||||
|
? undefined
|
||||||
|
: value === null
|
||||||
|
? null
|
||||||
|
: parse(value, label);
|
||||||
|
}
|
||||||
|
|
||||||
|
function diagnostic(value: unknown, label: string): Diagnostic {
|
||||||
|
const item = record(value, label);
|
||||||
|
exact(item, ["code", "severity", "message"], label);
|
||||||
|
const severity = string(item.severity, `${label}.severity`);
|
||||||
|
if (!DIAGNOSTIC_SEVERITIES.has(severity as DiagnosticSeverity)) {
|
||||||
|
throw new Error(`${label}.severity is invalid`);
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
code: string(item.code, `${label}.code`),
|
||||||
|
severity: severity as DiagnosticSeverity,
|
||||||
|
message: string(item.message, `${label}.message`),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function runtimeOption(
|
||||||
|
value: unknown,
|
||||||
|
label: string,
|
||||||
|
): WorkerLaunchRuntimeOption {
|
||||||
|
const item = record(value, label);
|
||||||
|
exact(
|
||||||
|
item,
|
||||||
|
[
|
||||||
|
"runtime_id",
|
||||||
|
"display_name",
|
||||||
|
"built_in",
|
||||||
|
"worker_creation_available",
|
||||||
|
"working_directory_required",
|
||||||
|
"status",
|
||||||
|
"diagnostics",
|
||||||
|
],
|
||||||
|
label,
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
runtime_id: string(item.runtime_id, `${label}.runtime_id`),
|
||||||
|
display_name: string(item.display_name, `${label}.display_name`),
|
||||||
|
built_in: boolean(item.built_in, `${label}.built_in`),
|
||||||
|
worker_creation_available: boolean(
|
||||||
|
item.worker_creation_available,
|
||||||
|
`${label}.worker_creation_available`,
|
||||||
|
),
|
||||||
|
working_directory_required: boolean(
|
||||||
|
item.working_directory_required,
|
||||||
|
`${label}.working_directory_required`,
|
||||||
|
),
|
||||||
|
status: string(item.status, `${label}.status`),
|
||||||
|
diagnostics: array(item.diagnostics, `${label}.diagnostics`, diagnostic),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function profileCandidate(
|
||||||
|
value: unknown,
|
||||||
|
label: string,
|
||||||
|
): WorkerLaunchProfileCandidate {
|
||||||
|
const item = record(value, label);
|
||||||
|
exact(item, ["id", "label", "description"], label);
|
||||||
|
return {
|
||||||
|
id: string(item.id, `${label}.id`),
|
||||||
|
label: string(item.label, `${label}.label`),
|
||||||
|
description: string(item.description, `${label}.description`),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function repositoryOption(
|
||||||
|
value: unknown,
|
||||||
|
label: string,
|
||||||
|
): WorkingDirectoryRepositoryOption {
|
||||||
|
const item = record(value, label);
|
||||||
|
exact(item, ["repository_key", "default_selector"], label);
|
||||||
|
return {
|
||||||
|
repository_key: string(item.repository_key, `${label}.repository_key`),
|
||||||
|
default_selector: optional(
|
||||||
|
item.default_selector,
|
||||||
|
`${label}.default_selector`,
|
||||||
|
string,
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseWorkerLaunchOptionsResponse(
|
||||||
|
value: unknown,
|
||||||
|
): WorkerLaunchOptionsResponse {
|
||||||
|
const item = record(value, "Worker launch options response");
|
||||||
|
exact(
|
||||||
|
item,
|
||||||
|
[
|
||||||
|
"workspace_id",
|
||||||
|
"runtimes",
|
||||||
|
"default_profile",
|
||||||
|
"profiles",
|
||||||
|
"repositories",
|
||||||
|
"working_directories",
|
||||||
|
"diagnostics",
|
||||||
|
],
|
||||||
|
"Worker launch options response",
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
workspace_id: string(item.workspace_id, "workspace_id"),
|
||||||
|
runtimes: array(item.runtimes, "runtimes", runtimeOption),
|
||||||
|
default_profile: nullableString(item.default_profile, "default_profile"),
|
||||||
|
profiles: array(item.profiles, "profiles", profileCandidate),
|
||||||
|
repositories: array(item.repositories, "repositories", repositoryOption),
|
||||||
|
working_directories: array(
|
||||||
|
item.working_directories,
|
||||||
|
"working_directories",
|
||||||
|
parseWorkingDirectorySummary,
|
||||||
|
),
|
||||||
|
diagnostics: array(item.diagnostics, "diagnostics", diagnostic),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function workspaceSummary(
|
||||||
|
value: unknown,
|
||||||
|
label: string,
|
||||||
|
): WorkerWorkspaceSummary {
|
||||||
|
const item = record(value, label);
|
||||||
|
exact(item, ["visibility", "identity", "workspace_id"], label);
|
||||||
|
return {
|
||||||
|
visibility: string(item.visibility, `${label}.visibility`),
|
||||||
|
identity: string(item.identity, `${label}.identity`),
|
||||||
|
workspace_id: optional(item.workspace_id, `${label}.workspace_id`, string),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function implementationSummary(
|
||||||
|
value: unknown,
|
||||||
|
label: string,
|
||||||
|
): WorkerImplementationSummary {
|
||||||
|
const item = record(value, label);
|
||||||
|
exact(item, ["kind", "display_hint"], label);
|
||||||
|
return {
|
||||||
|
kind: string(item.kind, `${label}.kind`),
|
||||||
|
display_hint: string(item.display_hint, `${label}.display_hint`),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function capabilitySummary(
|
||||||
|
value: unknown,
|
||||||
|
label: string,
|
||||||
|
): WorkerCapabilitySummary {
|
||||||
|
const item = record(value, label);
|
||||||
|
exact(item, ["can_stop", "can_spawn_followup"], label);
|
||||||
|
return {
|
||||||
|
can_stop: boolean(item.can_stop, `${label}.can_stop`),
|
||||||
|
can_spawn_followup: boolean(
|
||||||
|
item.can_spawn_followup,
|
||||||
|
`${label}.can_spawn_followup`,
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function runtimeCleanupTarget(
|
||||||
|
value: unknown,
|
||||||
|
label: string,
|
||||||
|
): RuntimeWorkingDirectoryCleanupTarget {
|
||||||
|
const item = record(value, label);
|
||||||
|
exact(item, ["kind", "working_directory_id", "repository_id"], label);
|
||||||
|
return {
|
||||||
|
kind: string(item.kind, `${label}.kind`),
|
||||||
|
working_directory_id: string(
|
||||||
|
item.working_directory_id,
|
||||||
|
`${label}.working_directory_id`,
|
||||||
|
),
|
||||||
|
repository_id: string(item.repository_id, `${label}.repository_id`),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function runtimeWorkingDirectory(
|
||||||
|
value: unknown,
|
||||||
|
label: string,
|
||||||
|
): RuntimeWorkingDirectorySummary {
|
||||||
|
const item = record(value, label);
|
||||||
|
exact(
|
||||||
|
item,
|
||||||
|
[
|
||||||
|
"working_directory_id",
|
||||||
|
"repository_id",
|
||||||
|
"creation_selector",
|
||||||
|
"creation_ref",
|
||||||
|
"creation_tree",
|
||||||
|
"current_selector",
|
||||||
|
"current_ref",
|
||||||
|
"current_tree",
|
||||||
|
"observed_at_epoch_seconds",
|
||||||
|
"materializer_kind",
|
||||||
|
"cleanup_target",
|
||||||
|
"status",
|
||||||
|
"cleanliness",
|
||||||
|
"primary_worker_id",
|
||||||
|
"occupied_by",
|
||||||
|
],
|
||||||
|
label,
|
||||||
|
);
|
||||||
|
const materializerKind = string(
|
||||||
|
item.materializer_kind,
|
||||||
|
`${label}.materializer_kind`,
|
||||||
|
);
|
||||||
|
if (
|
||||||
|
materializerKind !== "runtime_git_cache" &&
|
||||||
|
materializerKind !== "local_git_worktree"
|
||||||
|
) {
|
||||||
|
throw new Error(`${label}.materializer_kind is invalid`);
|
||||||
|
}
|
||||||
|
const status = string(item.status, `${label}.status`);
|
||||||
|
if (
|
||||||
|
!["active", "cleanup_pending", "corrupted", "not_found", "unknown"]
|
||||||
|
.includes(status)
|
||||||
|
) {
|
||||||
|
throw new Error(`${label}.status is invalid`);
|
||||||
|
}
|
||||||
|
const occupied = optional(
|
||||||
|
item.occupied_by,
|
||||||
|
`${label}.occupied_by`,
|
||||||
|
(value, occupiedLabel) => {
|
||||||
|
const occupancy = record(value, occupiedLabel);
|
||||||
|
exact(
|
||||||
|
occupancy,
|
||||||
|
["runtime_id", "worker_id", "display_name", "linked_at"],
|
||||||
|
occupiedLabel,
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
runtime_id: string(occupancy.runtime_id, `${occupiedLabel}.runtime_id`),
|
||||||
|
worker_id: string(occupancy.worker_id, `${occupiedLabel}.worker_id`),
|
||||||
|
display_name: string(
|
||||||
|
occupancy.display_name,
|
||||||
|
`${occupiedLabel}.display_name`,
|
||||||
|
),
|
||||||
|
linked_at: string(occupancy.linked_at, `${occupiedLabel}.linked_at`),
|
||||||
|
};
|
||||||
|
},
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
working_directory_id: string(
|
||||||
|
item.working_directory_id,
|
||||||
|
`${label}.working_directory_id`,
|
||||||
|
),
|
||||||
|
repository_id: string(item.repository_id, `${label}.repository_id`),
|
||||||
|
creation_selector: optional(
|
||||||
|
item.creation_selector,
|
||||||
|
`${label}.creation_selector`,
|
||||||
|
string,
|
||||||
|
),
|
||||||
|
creation_ref: optional(item.creation_ref, `${label}.creation_ref`, string),
|
||||||
|
creation_tree: optional(
|
||||||
|
item.creation_tree,
|
||||||
|
`${label}.creation_tree`,
|
||||||
|
string,
|
||||||
|
),
|
||||||
|
current_selector: optional(
|
||||||
|
item.current_selector,
|
||||||
|
`${label}.current_selector`,
|
||||||
|
string,
|
||||||
|
),
|
||||||
|
current_ref: optional(item.current_ref, `${label}.current_ref`, string),
|
||||||
|
current_tree: optional(item.current_tree, `${label}.current_tree`, string),
|
||||||
|
observed_at_epoch_seconds: optional(
|
||||||
|
item.observed_at_epoch_seconds,
|
||||||
|
`${label}.observed_at_epoch_seconds`,
|
||||||
|
number,
|
||||||
|
),
|
||||||
|
materializer_kind: materializerKind,
|
||||||
|
cleanup_target: optional(
|
||||||
|
item.cleanup_target,
|
||||||
|
`${label}.cleanup_target`,
|
||||||
|
runtimeCleanupTarget,
|
||||||
|
),
|
||||||
|
status: status as RuntimeWorkingDirectorySummary["status"],
|
||||||
|
cleanliness: optional(item.cleanliness, `${label}.cleanliness`, string),
|
||||||
|
primary_worker_id: optional(
|
||||||
|
item.primary_worker_id,
|
||||||
|
`${label}.primary_worker_id`,
|
||||||
|
string,
|
||||||
|
),
|
||||||
|
occupied_by: occupied,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function workerSummary(
|
||||||
|
value: unknown,
|
||||||
|
label: string,
|
||||||
|
): WorkerLaunchWorkerSummary {
|
||||||
|
const item = record(value, label);
|
||||||
|
exact(
|
||||||
|
item,
|
||||||
|
[
|
||||||
|
"runtime_id",
|
||||||
|
"worker_id",
|
||||||
|
"host_id",
|
||||||
|
"display_name",
|
||||||
|
"label",
|
||||||
|
"profile",
|
||||||
|
"singleton_key",
|
||||||
|
"tags",
|
||||||
|
"workspace",
|
||||||
|
"state",
|
||||||
|
"last_seen_at",
|
||||||
|
"pinned",
|
||||||
|
"retention_state",
|
||||||
|
"implementation",
|
||||||
|
"capabilities",
|
||||||
|
"working_directory",
|
||||||
|
"diagnostics",
|
||||||
|
],
|
||||||
|
label,
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
runtime_id: string(item.runtime_id, `${label}.runtime_id`),
|
||||||
|
worker_id: string(item.worker_id, `${label}.worker_id`),
|
||||||
|
host_id: string(item.host_id, `${label}.host_id`),
|
||||||
|
display_name: string(item.display_name, `${label}.display_name`),
|
||||||
|
label: string(item.label, `${label}.label`),
|
||||||
|
profile: nullableString(item.profile, `${label}.profile`),
|
||||||
|
singleton_key: nullableString(item.singleton_key, `${label}.singleton_key`),
|
||||||
|
tags: array(item.tags, `${label}.tags`, string),
|
||||||
|
workspace: workspaceSummary(item.workspace, `${label}.workspace`),
|
||||||
|
state: string(item.state, `${label}.state`),
|
||||||
|
last_seen_at: nullableString(item.last_seen_at, `${label}.last_seen_at`),
|
||||||
|
pinned: boolean(item.pinned, `${label}.pinned`),
|
||||||
|
retention_state: string(item.retention_state, `${label}.retention_state`),
|
||||||
|
implementation: implementationSummary(
|
||||||
|
item.implementation,
|
||||||
|
`${label}.implementation`,
|
||||||
|
),
|
||||||
|
capabilities: capabilitySummary(item.capabilities, `${label}.capabilities`),
|
||||||
|
working_directory: optional(
|
||||||
|
item.working_directory,
|
||||||
|
`${label}.working_directory`,
|
||||||
|
runtimeWorkingDirectory,
|
||||||
|
),
|
||||||
|
diagnostics: array(item.diagnostics, `${label}.diagnostics`, diagnostic),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseBrowserCreateWorkerResponse(
|
||||||
|
value: unknown,
|
||||||
|
): BrowserCreateWorkerResponse {
|
||||||
|
const item = record(value, "Worker create response");
|
||||||
|
exact(
|
||||||
|
item,
|
||||||
|
[
|
||||||
|
"workspace_id",
|
||||||
|
"runtime_id",
|
||||||
|
"worker_id",
|
||||||
|
"console_href",
|
||||||
|
"worker",
|
||||||
|
"diagnostics",
|
||||||
|
],
|
||||||
|
"Worker create response",
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
workspace_id: string(item.workspace_id, "workspace_id"),
|
||||||
|
runtime_id: string(item.runtime_id, "runtime_id"),
|
||||||
|
worker_id: string(item.worker_id, "worker_id"),
|
||||||
|
console_href: string(item.console_href, "console_href"),
|
||||||
|
worker: workerSummary(item.worker, "worker"),
|
||||||
|
diagnostics: array(item.diagnostics, "diagnostics", diagnostic),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseBrowserWorkspaceOrchestratorResponse(
|
||||||
|
value: unknown,
|
||||||
|
): BrowserWorkspaceOrchestratorResponse {
|
||||||
|
const item = record(value, "Workspace Orchestrator response");
|
||||||
|
exact(
|
||||||
|
item,
|
||||||
|
["workspace_id", "online", "disposition", "worker", "diagnostics"],
|
||||||
|
"Workspace Orchestrator response",
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
workspace_id: string(item.workspace_id, "workspace_id"),
|
||||||
|
online: boolean(item.online, "online"),
|
||||||
|
disposition: string(item.disposition, "disposition"),
|
||||||
|
worker: optional(item.worker, "worker", workerSummary),
|
||||||
|
diagnostics: array(item.diagnostics, "diagnostics", diagnostic),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function workingDirectorySelection(
|
||||||
|
value: unknown,
|
||||||
|
label: string,
|
||||||
|
): BrowserWorkerWorkingDirectorySelection {
|
||||||
|
const item = record(value, label);
|
||||||
|
exact(item, ["working_directory_id", "relative_cwd"], label);
|
||||||
|
return {
|
||||||
|
working_directory_id: string(
|
||||||
|
item.working_directory_id,
|
||||||
|
`${label}.working_directory_id`,
|
||||||
|
),
|
||||||
|
relative_cwd: nullableString(item.relative_cwd, `${label}.relative_cwd`),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function ticketAssignment(
|
||||||
|
value: unknown,
|
||||||
|
label: string,
|
||||||
|
): CreateWorkspaceWorkerTicketAssignmentRequest {
|
||||||
|
const item = record(value, label);
|
||||||
|
exact(item, ["ticket_id", "operation_id"], label);
|
||||||
|
return {
|
||||||
|
ticket_id: string(item.ticket_id, `${label}.ticket_id`),
|
||||||
|
operation_id: string(item.operation_id, `${label}.operation_id`),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function unsignedInteger(value: unknown, label: string): number {
|
||||||
|
const parsed = number(value, label);
|
||||||
|
if (!Number.isSafeInteger(parsed) || parsed < 0) {
|
||||||
|
throw new Error(`${label} must be a non-negative safe integer`);
|
||||||
|
}
|
||||||
|
return parsed;
|
||||||
|
}
|
||||||
|
|
||||||
|
function pasteArtifact(
|
||||||
|
value: unknown,
|
||||||
|
label: string,
|
||||||
|
): Extract<Segment, { kind: "paste_artifact" }>["artifact"] {
|
||||||
|
const item = record(value, label);
|
||||||
|
exact(
|
||||||
|
item,
|
||||||
|
[
|
||||||
|
"artifact_id",
|
||||||
|
"created_at_ms",
|
||||||
|
"media_type",
|
||||||
|
"availability",
|
||||||
|
"byte_len",
|
||||||
|
"char_count",
|
||||||
|
"line_count",
|
||||||
|
"sha256",
|
||||||
|
"source_entry_id",
|
||||||
|
],
|
||||||
|
label,
|
||||||
|
);
|
||||||
|
const mediaType = string(item.media_type, `${label}.media_type`);
|
||||||
|
if (mediaType !== "text_plain_utf8") {
|
||||||
|
throw new Error(`${label}.media_type is invalid`);
|
||||||
|
}
|
||||||
|
const availability = string(item.availability, `${label}.availability`);
|
||||||
|
if (
|
||||||
|
!["available", "unavailable", "integrity_failed"].includes(availability)
|
||||||
|
) {
|
||||||
|
throw new Error(`${label}.availability is invalid`);
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
artifact_id: string(item.artifact_id, `${label}.artifact_id`),
|
||||||
|
created_at_ms: unsignedInteger(
|
||||||
|
item.created_at_ms,
|
||||||
|
`${label}.created_at_ms`,
|
||||||
|
),
|
||||||
|
media_type: mediaType,
|
||||||
|
availability: availability as Extract<Segment, { kind: "paste_artifact" }>[
|
||||||
|
"artifact"
|
||||||
|
]["availability"],
|
||||||
|
byte_len: unsignedInteger(item.byte_len, `${label}.byte_len`),
|
||||||
|
char_count: unsignedInteger(item.char_count, `${label}.char_count`),
|
||||||
|
line_count: unsignedInteger(item.line_count, `${label}.line_count`),
|
||||||
|
sha256: string(item.sha256, `${label}.sha256`),
|
||||||
|
source_entry_id: string(item.source_entry_id, `${label}.source_entry_id`),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function uploadedFile(
|
||||||
|
value: unknown,
|
||||||
|
label: string,
|
||||||
|
): Extract<Segment, { kind: "uploaded_file" }>["file"] {
|
||||||
|
const item = record(value, label);
|
||||||
|
exact(
|
||||||
|
item,
|
||||||
|
[
|
||||||
|
"artifact_id",
|
||||||
|
"file_name",
|
||||||
|
"media_type",
|
||||||
|
"created_at_ms",
|
||||||
|
"availability",
|
||||||
|
"byte_len",
|
||||||
|
"sha256",
|
||||||
|
"source_entry_id",
|
||||||
|
],
|
||||||
|
label,
|
||||||
|
);
|
||||||
|
const availability = string(item.availability, `${label}.availability`);
|
||||||
|
if (
|
||||||
|
!["available", "unavailable", "integrity_failed"].includes(availability)
|
||||||
|
) {
|
||||||
|
throw new Error(`${label}.availability is invalid`);
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
artifact_id: string(item.artifact_id, `${label}.artifact_id`),
|
||||||
|
file_name: string(item.file_name, `${label}.file_name`),
|
||||||
|
media_type: string(item.media_type, `${label}.media_type`),
|
||||||
|
created_at_ms: unsignedInteger(
|
||||||
|
item.created_at_ms,
|
||||||
|
`${label}.created_at_ms`,
|
||||||
|
),
|
||||||
|
availability: availability as Extract<Segment, { kind: "uploaded_file" }>[
|
||||||
|
"file"
|
||||||
|
]["availability"],
|
||||||
|
byte_len: unsignedInteger(item.byte_len, `${label}.byte_len`),
|
||||||
|
sha256: string(item.sha256, `${label}.sha256`),
|
||||||
|
source_entry_id: optional(
|
||||||
|
item.source_entry_id,
|
||||||
|
`${label}.source_entry_id`,
|
||||||
|
string,
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function segment(value: unknown, label: string): Segment {
|
||||||
|
const item = record(value, label);
|
||||||
|
const kind = string(item.kind, `${label}.kind`) as Segment["kind"];
|
||||||
|
switch (kind) {
|
||||||
|
case "text":
|
||||||
|
exact(item, ["kind", "content"], label);
|
||||||
|
return { kind, content: string(item.content, `${label}.content`) };
|
||||||
|
case "paste":
|
||||||
|
exact(item, ["kind", "id", "chars", "lines", "content"], label);
|
||||||
|
return {
|
||||||
|
kind,
|
||||||
|
id: unsignedInteger(item.id, `${label}.id`),
|
||||||
|
chars: unsignedInteger(item.chars, `${label}.chars`),
|
||||||
|
lines: unsignedInteger(item.lines, `${label}.lines`),
|
||||||
|
content: string(item.content, `${label}.content`),
|
||||||
|
};
|
||||||
|
case "paste_artifact":
|
||||||
|
exact(item, ["kind", "artifact"], label);
|
||||||
|
return {
|
||||||
|
kind,
|
||||||
|
artifact: pasteArtifact(item.artifact, `${label}.artifact`),
|
||||||
|
};
|
||||||
|
case "uploaded_file":
|
||||||
|
exact(item, ["kind", "file"], label);
|
||||||
|
return { kind, file: uploadedFile(item.file, `${label}.file`) };
|
||||||
|
case "file_ref":
|
||||||
|
exact(item, ["kind", "path"], label);
|
||||||
|
return { kind, path: string(item.path, `${label}.path`) };
|
||||||
|
case "flow":
|
||||||
|
exact(item, ["kind", "selector"], label);
|
||||||
|
return { kind, selector: string(item.selector, `${label}.selector`) };
|
||||||
|
case "unknown":
|
||||||
|
throw new Error(`${label}.kind is not supported by Worker creation`);
|
||||||
|
}
|
||||||
|
const exhaustive: never = kind;
|
||||||
|
throw new Error(`${label}.kind is invalid: ${exhaustive}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseCreateWorkspaceWorkerRequest(
|
||||||
|
value: unknown,
|
||||||
|
): CreateWorkspaceWorkerRequest {
|
||||||
|
const item = record(value, "Worker create request");
|
||||||
|
exact(
|
||||||
|
item,
|
||||||
|
[
|
||||||
|
"runtime_id",
|
||||||
|
"display_name",
|
||||||
|
"profile",
|
||||||
|
"ticket_assignment",
|
||||||
|
"initial_submit",
|
||||||
|
"working_directory",
|
||||||
|
"control_operation_id",
|
||||||
|
],
|
||||||
|
"Worker create request",
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
runtime_id: string(item.runtime_id, "runtime_id"),
|
||||||
|
display_name: string(item.display_name, "display_name"),
|
||||||
|
profile: nullableString(item.profile, "profile"),
|
||||||
|
ticket_assignment: item.ticket_assignment === null
|
||||||
|
? null
|
||||||
|
: ticketAssignment(item.ticket_assignment, "ticket_assignment"),
|
||||||
|
initial_submit: array(item.initial_submit, "initial_submit", segment),
|
||||||
|
working_directory: item.working_directory === null
|
||||||
|
? null
|
||||||
|
: workingDirectorySelection(item.working_directory, "working_directory"),
|
||||||
|
control_operation_id: nullableString(
|
||||||
|
item.control_operation_id,
|
||||||
|
"control_operation_id",
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -1,3 +1,12 @@
|
|||||||
|
import type {
|
||||||
|
BrowserCreateWorkerResponse as SharedBrowserCreateWorkerResponse,
|
||||||
|
BrowserWorkerWorkingDirectorySelection
|
||||||
|
as SharedBrowserWorkerWorkingDirectorySelection,
|
||||||
|
WorkerLaunchOptionsResponse as SharedWorkerLaunchOptionsResponse,
|
||||||
|
WorkerLaunchProfileCandidate as SharedWorkerLaunchProfileCandidate,
|
||||||
|
WorkerLaunchRuntimeOption as SharedWorkerLaunchRuntimeOption,
|
||||||
|
WorkingDirectoryRepositoryOption as SharedWorkingDirectoryRepositoryOption,
|
||||||
|
} from "$lib/generated/worker-launch-api";
|
||||||
import type {
|
import type {
|
||||||
WorkingDirectoryCreateRequest,
|
WorkingDirectoryCreateRequest,
|
||||||
WorkingDirectoryCreateResponse,
|
WorkingDirectoryCreateResponse,
|
||||||
@@ -101,26 +110,10 @@ export type Worker = {
|
|||||||
|
|
||||||
export type WorkerOperationState = "accepted" | "unsupported" | "rejected";
|
export type WorkerOperationState = "accepted" | "unsupported" | "rejected";
|
||||||
|
|
||||||
export type WorkerLaunchRuntimeOption = {
|
export type WorkerLaunchRuntimeOption = SharedWorkerLaunchRuntimeOption;
|
||||||
runtime_id: string;
|
export type WorkerLaunchProfileCandidate = SharedWorkerLaunchProfileCandidate;
|
||||||
display_name: string;
|
export type WorkingDirectoryRepositoryOption =
|
||||||
built_in: boolean;
|
SharedWorkingDirectoryRepositoryOption;
|
||||||
worker_creation_available: boolean;
|
|
||||||
working_directory_required: boolean;
|
|
||||||
status: string;
|
|
||||||
diagnostics: Diagnostic[];
|
|
||||||
};
|
|
||||||
|
|
||||||
export type WorkerLaunchProfileCandidate = {
|
|
||||||
id: string;
|
|
||||||
label: string;
|
|
||||||
description: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type WorkingDirectoryRepositoryOption = {
|
|
||||||
repository_key: string;
|
|
||||||
default_selector?: string | null;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type CleanupTargetKind =
|
export type CleanupTargetKind =
|
||||||
| "worker_delete"
|
| "worker_delete"
|
||||||
@@ -185,29 +178,10 @@ export type RuntimeCleanupExecutionResponse = {
|
|||||||
diagnostics: Diagnostic[];
|
diagnostics: Diagnostic[];
|
||||||
};
|
};
|
||||||
|
|
||||||
export type BrowserWorkerWorkingDirectorySelection = {
|
export type BrowserWorkerWorkingDirectorySelection =
|
||||||
working_directory_id: string;
|
SharedBrowserWorkerWorkingDirectorySelection;
|
||||||
relative_cwd?: string | null;
|
export type WorkerLaunchOptionsResponse = SharedWorkerLaunchOptionsResponse;
|
||||||
};
|
export type BrowserCreateWorkerResponse = SharedBrowserCreateWorkerResponse;
|
||||||
|
|
||||||
export type WorkerLaunchOptionsResponse = {
|
|
||||||
workspace_id: string;
|
|
||||||
runtimes: WorkerLaunchRuntimeOption[];
|
|
||||||
default_profile?: string | null;
|
|
||||||
profiles: WorkerLaunchProfileCandidate[];
|
|
||||||
repositories: WorkingDirectoryRepositoryOption[];
|
|
||||||
working_directories: WorkingDirectorySummary[];
|
|
||||||
diagnostics: Diagnostic[];
|
|
||||||
};
|
|
||||||
|
|
||||||
export type BrowserCreateWorkerResponse = {
|
|
||||||
workspace_id: string;
|
|
||||||
runtime_id: string;
|
|
||||||
worker_id: string;
|
|
||||||
console_href: string;
|
|
||||||
worker: Worker;
|
|
||||||
diagnostics: Diagnostic[];
|
|
||||||
};
|
|
||||||
|
|
||||||
export type WorkerInputResult = {
|
export type WorkerInputResult = {
|
||||||
state: WorkerOperationState;
|
state: WorkerOperationState;
|
||||||
|
|||||||
@@ -46,7 +46,7 @@ function diagnosticMessage(
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function canDeleteSidebarWorker(worker: Worker): boolean {
|
export function canDeleteSidebarWorker(worker: Worker): boolean {
|
||||||
return worker.state === "stopped" || worker.state === "cancelled";
|
return worker.state === "stopped";
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function stopSidebarWorker(
|
export async function stopSidebarWorker(
|
||||||
|
|||||||
@@ -196,11 +196,13 @@ Deno.test("buildCreateWorkspaceWorkerRequest sends working_directory id and rela
|
|||||||
runtime_id: "embedded",
|
runtime_id: "embedded",
|
||||||
display_name: "Worker",
|
display_name: "Worker",
|
||||||
profile: "builtin:coder",
|
profile: "builtin:coder",
|
||||||
|
ticket_assignment: null,
|
||||||
initial_submit: [{ kind: "text", content: "go" }],
|
initial_submit: [{ kind: "text", content: "go" }],
|
||||||
working_directory: {
|
working_directory: {
|
||||||
working_directory_id: "wd-1-repo",
|
working_directory_id: "wd-1-repo",
|
||||||
relative_cwd: "crates/yoi",
|
relative_cwd: "crates/yoi",
|
||||||
},
|
},
|
||||||
|
control_operation_id: null,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -219,7 +221,7 @@ Deno.test("buildCreateWorkspaceWorkerRequest sends no initial segments for an em
|
|||||||
assertEquals(request.initial_submit, []);
|
assertEquals(request.initial_submit, []);
|
||||||
});
|
});
|
||||||
|
|
||||||
Deno.test("buildCreateWorkspaceWorkerRequest omits working_directory for embedded no-workdir launches", () => {
|
Deno.test("buildCreateWorkspaceWorkerRequest emits null for embedded no-workdir launches", () => {
|
||||||
const request = buildCreateWorkspaceWorkerRequest({
|
const request = buildCreateWorkspaceWorkerRequest({
|
||||||
runtime_id: "embedded",
|
runtime_id: "embedded",
|
||||||
display_name: "Worker",
|
display_name: "Worker",
|
||||||
@@ -235,6 +237,9 @@ Deno.test("buildCreateWorkspaceWorkerRequest omits working_directory for embedde
|
|||||||
runtime_id: "embedded",
|
runtime_id: "embedded",
|
||||||
display_name: "Worker",
|
display_name: "Worker",
|
||||||
profile: "builtin:companion",
|
profile: "builtin:companion",
|
||||||
|
ticket_assignment: null,
|
||||||
initial_submit: [{ kind: "text", content: "chat" }],
|
initial_submit: [{ kind: "text", content: "chat" }],
|
||||||
|
working_directory: null,
|
||||||
|
control_operation_id: null,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,9 +1,7 @@
|
|||||||
import type { Segment } from "$lib/generated/protocol";
|
import type { CreateWorkspaceWorkerRequest } from "$lib/generated/worker-launch-api";
|
||||||
|
import { parseCreateWorkspaceWorkerRequest } from "$lib/workspace/api/workers";
|
||||||
|
|
||||||
import type {
|
import type { WorkerLaunchOptionsResponse } from "./types";
|
||||||
BrowserWorkerWorkingDirectorySelection,
|
|
||||||
WorkerLaunchOptionsResponse,
|
|
||||||
} from "./types";
|
|
||||||
|
|
||||||
export type WorkerLaunchFormState = {
|
export type WorkerLaunchFormState = {
|
||||||
runtime_id: string;
|
runtime_id: string;
|
||||||
@@ -16,14 +14,6 @@ export type WorkerLaunchFormState = {
|
|||||||
relative_cwd: string;
|
relative_cwd: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type CreateWorkspaceWorkerRequest = {
|
|
||||||
runtime_id: string;
|
|
||||||
display_name: string;
|
|
||||||
profile: string;
|
|
||||||
initial_submit: Segment[];
|
|
||||||
working_directory?: BrowserWorkerWorkingDirectorySelection;
|
|
||||||
};
|
|
||||||
|
|
||||||
export function defaultWorkerLaunchForm(
|
export function defaultWorkerLaunchForm(
|
||||||
options: WorkerLaunchOptionsResponse | null,
|
options: WorkerLaunchOptionsResponse | null,
|
||||||
current: WorkerLaunchFormState,
|
current: WorkerLaunchFormState,
|
||||||
@@ -86,7 +76,8 @@ export function defaultWorkerLaunchForm(
|
|||||||
)
|
)
|
||||||
? current.working_directory_id
|
? current.working_directory_id
|
||||||
: preferredWorkingDirectory?.working_directory_id || "",
|
: preferredWorkingDirectory?.working_directory_id || "",
|
||||||
working_directory_repository_key: current.working_directory_repository_key ||
|
working_directory_repository_key:
|
||||||
|
current.working_directory_repository_key ||
|
||||||
preferredRepository?.repository_key || "",
|
preferredRepository?.repository_key || "",
|
||||||
working_directory_selector: current.working_directory_selector ||
|
working_directory_selector: current.working_directory_selector ||
|
||||||
preferredRepository?.default_selector || "HEAD",
|
preferredRepository?.default_selector || "HEAD",
|
||||||
@@ -97,22 +88,21 @@ export function defaultWorkerLaunchForm(
|
|||||||
export function buildCreateWorkspaceWorkerRequest(
|
export function buildCreateWorkspaceWorkerRequest(
|
||||||
form: WorkerLaunchFormState,
|
form: WorkerLaunchFormState,
|
||||||
): CreateWorkspaceWorkerRequest {
|
): CreateWorkspaceWorkerRequest {
|
||||||
const request: CreateWorkspaceWorkerRequest = {
|
const initialMessage = form.initial_text.trim();
|
||||||
runtime_id: form.runtime_id,
|
return parseCreateWorkspaceWorkerRequest({
|
||||||
display_name: form.display_name,
|
runtime_id: form.runtime_id.trim(),
|
||||||
profile: form.profile,
|
display_name: form.display_name.trim(),
|
||||||
initial_submit: form.initial_text.trim()
|
profile: form.profile.trim() || null,
|
||||||
|
ticket_assignment: null,
|
||||||
|
initial_submit: initialMessage
|
||||||
? [{ kind: "text", content: form.initial_text }]
|
? [{ kind: "text", content: form.initial_text }]
|
||||||
: [],
|
: [],
|
||||||
};
|
working_directory: form.working_directory_id
|
||||||
if (form.working_directory_id) {
|
? {
|
||||||
request.working_directory = {
|
working_directory_id: form.working_directory_id,
|
||||||
working_directory_id: form.working_directory_id,
|
relative_cwd: form.relative_cwd.trim() || null,
|
||||||
};
|
}
|
||||||
const relativeCwd = form.relative_cwd.trim();
|
: null,
|
||||||
if (relativeCwd) {
|
control_operation_id: null,
|
||||||
request.working_directory.relative_cwd = relativeCwd;
|
});
|
||||||
}
|
|
||||||
}
|
|
||||||
return request;
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -94,7 +94,7 @@ function projectWorker(worker: SubscriptionWorker): SidebarWorker {
|
|||||||
display_hint: 'Workspace-authorized Runtime Worker',
|
display_hint: 'Workspace-authorized Runtime Worker',
|
||||||
},
|
},
|
||||||
capabilities: {
|
capabilities: {
|
||||||
can_stop: worker.state !== 'stopped' && worker.state !== 'cancelled',
|
can_stop: worker.state !== 'stopped',
|
||||||
can_spawn_followup: false,
|
can_spawn_followup: false,
|
||||||
},
|
},
|
||||||
repository_key: worker.repository_key ?? null,
|
repository_key: worker.repository_key ?? null,
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import type { BrowserWorkspaceOrchestratorResponse } from "$lib/generated/worker-launch-api";
|
||||||
import type { TicketDetail, TicketSummary } from "$lib/generated/ticket-api";
|
import type { TicketDetail, TicketSummary } from "$lib/generated/ticket-api";
|
||||||
|
|
||||||
export const TICKET_STATES = [
|
export const TICKET_STATES = [
|
||||||
@@ -12,22 +13,7 @@ export const TICKET_STATES = [
|
|||||||
export type TicketState = (typeof TICKET_STATES)[number];
|
export type TicketState = (typeof TICKET_STATES)[number];
|
||||||
export type TicketWorkerRole = "coder" | "reviewer";
|
export type TicketWorkerRole = "coder" | "reviewer";
|
||||||
|
|
||||||
export type WorkspaceOrchestratorStatus = {
|
export type WorkspaceOrchestratorStatus = BrowserWorkspaceOrchestratorResponse;
|
||||||
workspace_id: string;
|
|
||||||
online: boolean;
|
|
||||||
disposition: string;
|
|
||||||
worker?: {
|
|
||||||
runtime_id: string;
|
|
||||||
worker_id: string;
|
|
||||||
state: string;
|
|
||||||
display_name: string;
|
|
||||||
} | null;
|
|
||||||
diagnostics: Array<{
|
|
||||||
code: string;
|
|
||||||
severity: string;
|
|
||||||
message: string;
|
|
||||||
}>;
|
|
||||||
};
|
|
||||||
|
|
||||||
const LANE_DEFINITIONS = [
|
const LANE_DEFINITIONS = [
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
import { untrack } from "svelte";
|
import { untrack } from "svelte";
|
||||||
import type { ApiResult } from "$lib/workspace/api/http";
|
import type { ApiResult } from "$lib/workspace/api/http";
|
||||||
import { loadJson, workspaceApiPath } from "$lib/workspace/api/http";
|
import { loadJson, workspaceApiPath } from "$lib/workspace/api/http";
|
||||||
|
import { parseBrowserWorkspaceOrchestratorResponse } from "$lib/workspace/api/workers";
|
||||||
import type {
|
import type {
|
||||||
QueryPage,
|
QueryPage,
|
||||||
TicketListResponse,
|
TicketListResponse,
|
||||||
@@ -99,6 +100,7 @@
|
|||||||
fetch,
|
fetch,
|
||||||
workspaceApiPath(data.workspaceId, "/orchestrator"),
|
workspaceApiPath(data.workspaceId, "/orchestrator"),
|
||||||
{ method: "POST" },
|
{ method: "POST" },
|
||||||
|
parseBrowserWorkspaceOrchestratorResponse,
|
||||||
);
|
);
|
||||||
orchestratorStarting = false;
|
orchestratorStarting = false;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { parseBrowserWorkspaceOrchestratorResponse } from "$lib/workspace/api/workers";
|
||||||
import { loadJson, workspaceApiPath } from "$lib/workspace/api/http";
|
import { loadJson, workspaceApiPath } from "$lib/workspace/api/http";
|
||||||
import type { TicketListResponse } from "$lib/generated/ticket-api";
|
import type { TicketListResponse } from "$lib/generated/ticket-api";
|
||||||
import type { WorkspaceOrchestratorStatus } from "$lib/workspace/tickets/ticket-panel";
|
import type { WorkspaceOrchestratorStatus } from "$lib/workspace/tickets/ticket-panel";
|
||||||
@@ -42,6 +43,8 @@ export const load: PageLoad = async ({ fetch, params }) => {
|
|||||||
loadJson<WorkspaceOrchestratorStatus>(
|
loadJson<WorkspaceOrchestratorStatus>(
|
||||||
fetch,
|
fetch,
|
||||||
workspaceApiPath(workspaceId, "/orchestrator"),
|
workspaceApiPath(workspaceId, "/orchestrator"),
|
||||||
|
undefined,
|
||||||
|
parseBrowserWorkspaceOrchestratorResponse,
|
||||||
),
|
),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
|||||||
@@ -6,10 +6,13 @@
|
|||||||
parseWorkingDirectoryCreateResponse,
|
parseWorkingDirectoryCreateResponse,
|
||||||
validateWorkingDirectoryCreateRequest,
|
validateWorkingDirectoryCreateRequest,
|
||||||
} from '$lib/workspace/api/workdirs';
|
} from '$lib/workspace/api/workdirs';
|
||||||
|
import {
|
||||||
|
parseBrowserCreateWorkerResponse,
|
||||||
|
parseWorkerLaunchOptionsResponse,
|
||||||
|
} from '$lib/workspace/api/workers';
|
||||||
import { formatCurrentWorkdirRevision } from '$lib/workspace/settings/workdir-revision';
|
import { formatCurrentWorkdirRevision } from '$lib/workspace/settings/workdir-revision';
|
||||||
import { buildCreateWorkspaceWorkerRequest, defaultWorkerLaunchForm } from '$lib/workspace/sidebar/worker-launch';
|
import { buildCreateWorkspaceWorkerRequest, defaultWorkerLaunchForm } from '$lib/workspace/sidebar/worker-launch';
|
||||||
import type {
|
import type {
|
||||||
BrowserCreateWorkerResponse,
|
|
||||||
Diagnostic,
|
Diagnostic,
|
||||||
WorkerLaunchOptionsResponse,
|
WorkerLaunchOptionsResponse,
|
||||||
WorkingDirectorySummary,
|
WorkingDirectorySummary,
|
||||||
@@ -115,7 +118,7 @@
|
|||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new Error(`worker launch options failed (${response.status})`);
|
throw new Error(`worker launch options failed (${response.status})`);
|
||||||
}
|
}
|
||||||
const payload = (await response.json()) as WorkerLaunchOptionsResponse;
|
const payload = parseWorkerLaunchOptionsResponse(await response.json());
|
||||||
options = payload;
|
options = payload;
|
||||||
const form = defaultWorkerLaunchForm(payload, {
|
const form = defaultWorkerLaunchForm(payload, {
|
||||||
runtime_id: runtimeId,
|
runtime_id: runtimeId,
|
||||||
@@ -229,7 +232,7 @@
|
|||||||
submitError = await responseDisplayError(response, 'worker create failed');
|
submitError = await responseDisplayError(response, 'worker create failed');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const payload = (await response.json()) as BrowserCreateWorkerResponse;
|
const payload = parseBrowserCreateWorkerResponse(await response.json());
|
||||||
await goto(payload.console_href);
|
await goto(payload.console_href);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
submitError = exceptionDisplayError(err, 'worker create failed');
|
submitError = exceptionDisplayError(err, 'worker create failed');
|
||||||
|
|||||||
@@ -155,10 +155,9 @@ Deno.test("sidebar Delete reports cleanup-plan blocking reasons", async () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
Deno.test("sidebar Delete is enabled only for terminal Worker states", () => {
|
Deno.test("sidebar Delete is enabled only for stopped Workers", () => {
|
||||||
assert(!canDeleteSidebarWorker(worker));
|
assert(!canDeleteSidebarWorker(worker));
|
||||||
assert(canDeleteSidebarWorker({ ...worker, state: "stopped" }));
|
assert(canDeleteSidebarWorker({ ...worker, state: "stopped" }));
|
||||||
assert(canDeleteSidebarWorker({ ...worker, state: "cancelled" }));
|
|
||||||
});
|
});
|
||||||
|
|
||||||
Deno.test("Worker navigation exposes an accessible hover action menu", async () => {
|
Deno.test("Worker navigation exposes an accessible hover action menu", async () => {
|
||||||
|
|||||||
Reference in New Issue
Block a user