auth: enforce workspace worker credentials over ticket REST
This commit is contained in:
@@ -1791,17 +1791,6 @@ where
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
||||||
#[serde(tag = "status", rename_all = "snake_case")]
|
|
||||||
pub enum TicketBackendHttpResponse {
|
|
||||||
Ok {
|
|
||||||
result: TicketBackendOperationResult,
|
|
||||||
},
|
|
||||||
Error {
|
|
||||||
message: String,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub struct LocalTicketBackend {
|
pub struct LocalTicketBackend {
|
||||||
root: PathBuf,
|
root: PathBuf,
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ pub enum WorkerExecutionOperation {
|
|||||||
Restore,
|
Restore,
|
||||||
Input,
|
Input,
|
||||||
ProtocolMethod,
|
ProtocolMethod,
|
||||||
|
ReplaceWorkspaceAccessToken,
|
||||||
Stop,
|
Stop,
|
||||||
Cancel,
|
Cancel,
|
||||||
}
|
}
|
||||||
@@ -331,6 +332,17 @@ pub trait WorkerExecutionBackend: Send + Sync + 'static {
|
|||||||
Vec::new()
|
Vec::new()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn replace_workspace_access_token(
|
||||||
|
&self,
|
||||||
|
_handle: &WorkerExecutionHandle,
|
||||||
|
_access_token: String,
|
||||||
|
) -> WorkerExecutionResult {
|
||||||
|
WorkerExecutionResult::unsupported(
|
||||||
|
WorkerExecutionOperation::ReplaceWorkspaceAccessToken,
|
||||||
|
"execution backend does not support replacing Workspace access tokens",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
fn stop_worker(&self, _handle: &WorkerExecutionHandle) -> WorkerExecutionResult {
|
fn stop_worker(&self, _handle: &WorkerExecutionHandle) -> WorkerExecutionResult {
|
||||||
WorkerExecutionResult::unsupported(
|
WorkerExecutionResult::unsupported(
|
||||||
WorkerExecutionOperation::Stop,
|
WorkerExecutionOperation::Stop,
|
||||||
@@ -443,6 +455,15 @@ impl WorkerExecutionBackendRef {
|
|||||||
self.backend.worker_completions(handle, kind, prefix)
|
self.backend.worker_completions(handle, kind, prefix)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub(crate) fn replace_workspace_access_token(
|
||||||
|
&self,
|
||||||
|
handle: &WorkerExecutionHandle,
|
||||||
|
access_token: String,
|
||||||
|
) -> WorkerExecutionResult {
|
||||||
|
self.backend
|
||||||
|
.replace_workspace_access_token(handle, access_token)
|
||||||
|
}
|
||||||
|
|
||||||
pub(crate) fn stop_worker(&self, handle: &WorkerExecutionHandle) -> WorkerExecutionResult {
|
pub(crate) fn stop_worker(&self, handle: &WorkerExecutionHandle) -> WorkerExecutionResult {
|
||||||
self.backend.stop_worker(handle)
|
self.backend.stop_worker(handle)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ use crate::auth::{
|
|||||||
};
|
};
|
||||||
use crate::catalog::{
|
use crate::catalog::{
|
||||||
ConfigBundleRef, CreateWorkerRequest, WorkerDetail, WorkerLifecycleAck, WorkerSummary,
|
ConfigBundleRef, CreateWorkerRequest, WorkerDetail, WorkerLifecycleAck, WorkerSummary,
|
||||||
WorkingDirectoryRequest, WorkingDirectoryStatus,
|
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;
|
||||||
@@ -193,6 +193,10 @@ fn runtime_http_router_with_optional_auth(
|
|||||||
)
|
)
|
||||||
.route("/v1/workers/{worker_id}/input", post(send_worker_input))
|
.route("/v1/workers/{worker_id}/input", post(send_worker_input))
|
||||||
.route("/v1/workers/{worker_id}/restore", post(restore_worker))
|
.route("/v1/workers/{worker_id}/restore", post(restore_worker))
|
||||||
|
.route(
|
||||||
|
"/v1/workers/{worker_id}/workspace-api",
|
||||||
|
post(replace_worker_workspace_api),
|
||||||
|
)
|
||||||
.route(
|
.route(
|
||||||
"/v1/workers/{worker_id}/completions",
|
"/v1/workers/{worker_id}/completions",
|
||||||
post(worker_completions),
|
post(worker_completions),
|
||||||
@@ -282,6 +286,12 @@ pub struct RuntimeHttpWorkerResponse {
|
|||||||
pub worker: WorkerDetail,
|
pub worker: WorkerDetail,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Replace the Workspace API binding for an existing Worker.
|
||||||
|
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
pub struct RuntimeHttpWorkerWorkspaceApiRequest {
|
||||||
|
pub workspace_api: WorkspaceApiRef,
|
||||||
|
}
|
||||||
|
|
||||||
/// Worker delete response.
|
/// Worker delete response.
|
||||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
pub struct RuntimeHttpWorkerDeleteResponse {
|
pub struct RuntimeHttpWorkerDeleteResponse {
|
||||||
@@ -509,6 +519,28 @@ async fn create_worker(
|
|||||||
Ok(Json(RuntimeHttpWorkerResponse { worker }))
|
Ok(Json(RuntimeHttpWorkerResponse { worker }))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn replace_worker_workspace_api(
|
||||||
|
State(state): State<RuntimeHttpState>,
|
||||||
|
auth: Option<Extension<RuntimeAuthContext>>,
|
||||||
|
Path(worker_id): Path<String>,
|
||||||
|
body: Result<Json<RuntimeHttpWorkerWorkspaceApiRequest>, JsonRejection>,
|
||||||
|
) -> RestResult<RuntimeHttpWorkerResponse> {
|
||||||
|
let Json(request) = body.map_err(RuntimeHttpRestError::json_rejection)?;
|
||||||
|
let worker_ref = worker_ref_for(&state.runtime, worker_id)?;
|
||||||
|
let worker = match auth_workspace_scope(&state, auth.as_ref())? {
|
||||||
|
Some(scope) => state.runtime.replace_worker_workspace_api_scoped(
|
||||||
|
&scope,
|
||||||
|
&worker_ref,
|
||||||
|
request.workspace_api,
|
||||||
|
),
|
||||||
|
None => state
|
||||||
|
.runtime
|
||||||
|
.replace_worker_workspace_api(&worker_ref, request.workspace_api),
|
||||||
|
}
|
||||||
|
.map_err(RuntimeHttpRestError::runtime)?;
|
||||||
|
Ok(Json(RuntimeHttpWorkerResponse { worker }))
|
||||||
|
}
|
||||||
|
|
||||||
async fn restore_worker(
|
async fn restore_worker(
|
||||||
State(state): State<RuntimeHttpState>,
|
State(state): State<RuntimeHttpState>,
|
||||||
auth: Option<Extension<RuntimeAuthContext>>,
|
auth: Option<Extension<RuntimeAuthContext>>,
|
||||||
@@ -960,6 +992,9 @@ fn required_runtime_permission(method: &Method, path: &str) -> Option<&'static s
|
|||||||
if path.starts_with("/v1/config-bundles") || path.starts_with("/v1/working-directories") {
|
if path.starts_with("/v1/config-bundles") || path.starts_with("/v1/working-directories") {
|
||||||
return Some("workers:create");
|
return Some("workers:create");
|
||||||
}
|
}
|
||||||
|
if path.ends_with("/workspace-api") {
|
||||||
|
return Some("workers:create");
|
||||||
|
}
|
||||||
if path.ends_with("/input") || path.ends_with("/restore") {
|
if path.ends_with("/input") || path.ends_with("/restore") {
|
||||||
return Some("workers:input");
|
return Some("workers:input");
|
||||||
}
|
}
|
||||||
@@ -1484,6 +1519,17 @@ mod tests {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn replace_workspace_access_token(
|
||||||
|
&self,
|
||||||
|
_handle: &WorkerExecutionHandle,
|
||||||
|
_access_token: String,
|
||||||
|
) -> WorkerExecutionResult {
|
||||||
|
WorkerExecutionResult::accepted(
|
||||||
|
WorkerExecutionOperation::ReplaceWorkspaceAccessToken,
|
||||||
|
WorkerExecutionRunState::Idle,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
fn stop_worker(&self, _handle: &WorkerExecutionHandle) -> WorkerExecutionResult {
|
fn stop_worker(&self, _handle: &WorkerExecutionHandle) -> WorkerExecutionResult {
|
||||||
WorkerExecutionResult::accepted(
|
WorkerExecutionResult::accepted(
|
||||||
WorkerExecutionOperation::Stop,
|
WorkerExecutionOperation::Stop,
|
||||||
@@ -1575,6 +1621,23 @@ mod tests {
|
|||||||
created.worker.worker_id
|
created.worker.worker_id
|
||||||
);
|
);
|
||||||
|
|
||||||
|
let response = authed_json_request(
|
||||||
|
app.clone(),
|
||||||
|
Method::POST,
|
||||||
|
&format!("/v1/workers/{}/workspace-api", created.worker.worker_id),
|
||||||
|
token,
|
||||||
|
&RuntimeHttpWorkerWorkspaceApiRequest {
|
||||||
|
workspace_api: WorkspaceApiRef {
|
||||||
|
workspace_id: "local".to_string(),
|
||||||
|
base_url: "http://127.0.0.1:8787".to_string(),
|
||||||
|
runtime_id: None,
|
||||||
|
access_token: Some("workspace-access-token".to_string()),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
assert_eq!(response.status(), StatusCode::OK);
|
||||||
|
|
||||||
let input = WorkerInput::user("hello from backend");
|
let input = WorkerInput::user("hello from backend");
|
||||||
let response = authed_json_request(
|
let response = authed_json_request(
|
||||||
app.clone(),
|
app.clone(),
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
use crate::catalog::{
|
use crate::catalog::{
|
||||||
ConfigBundleRef, CreateWorkerRequest, WorkerDetail, WorkerLifecycleAck, WorkerStatus,
|
ConfigBundleRef, CreateWorkerRequest, WorkerDetail, WorkerLifecycleAck, WorkerStatus,
|
||||||
WorkerSummary, WorkingDirectoryRequest,
|
WorkerSummary, WorkingDirectoryRequest,
|
||||||
WorkingDirectoryStatus as CatalogWorkingDirectoryStatus,
|
WorkingDirectoryStatus as CatalogWorkingDirectoryStatus, WorkspaceApiRef,
|
||||||
};
|
};
|
||||||
use crate::config_bundle::{
|
use crate::config_bundle::{
|
||||||
ConfigBundle, ConfigBundleAvailability, ConfigBundleSummary, validate_config_bundle,
|
ConfigBundle, ConfigBundleAvailability, ConfigBundleSummary, validate_config_bundle,
|
||||||
@@ -589,6 +589,94 @@ impl Runtime {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Replace the Workspace API binding persisted for a Worker and update the
|
||||||
|
/// live execution when one is connected.
|
||||||
|
pub fn replace_worker_workspace_api_scoped(
|
||||||
|
&self,
|
||||||
|
scope: &RuntimeWorkspaceScope,
|
||||||
|
worker_ref: &WorkerRef,
|
||||||
|
workspace_api: WorkspaceApiRef,
|
||||||
|
) -> Result<WorkerDetail, RuntimeError> {
|
||||||
|
self.ensure_worker_in_workspace(scope, worker_ref)?;
|
||||||
|
if workspace_api.workspace_id != scope.workspace_id {
|
||||||
|
return Err(RuntimeError::InvalidRequest(format!(
|
||||||
|
"Workspace API scope `{}` does not match authorized workspace `{}`",
|
||||||
|
workspace_api.workspace_id, scope.workspace_id
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
self.replace_worker_workspace_api(worker_ref, workspace_api)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn replace_worker_workspace_api(
|
||||||
|
&self,
|
||||||
|
worker_ref: &WorkerRef,
|
||||||
|
workspace_api: WorkspaceApiRef,
|
||||||
|
) -> Result<WorkerDetail, RuntimeError> {
|
||||||
|
let access_token = workspace_api
|
||||||
|
.access_token
|
||||||
|
.as_ref()
|
||||||
|
.filter(|token| !token.trim().is_empty())
|
||||||
|
.cloned()
|
||||||
|
.ok_or_else(|| {
|
||||||
|
RuntimeError::InvalidRequest(
|
||||||
|
"Workspace API replacement requires an access token".to_string(),
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
let (previous_workspace_api, live_execution) = {
|
||||||
|
let state = self.lock()?;
|
||||||
|
let worker = state.worker(worker_ref)?;
|
||||||
|
if let Some(existing) = worker.request.workspace_api.as_ref()
|
||||||
|
&& (existing.workspace_id != workspace_api.workspace_id
|
||||||
|
|| existing.base_url.trim_end_matches('/')
|
||||||
|
!= workspace_api.base_url.trim_end_matches('/')
|
||||||
|
|| existing.runtime_id.as_ref().is_some_and(|runtime_id| {
|
||||||
|
workspace_api.runtime_id.as_ref() != Some(runtime_id)
|
||||||
|
}))
|
||||||
|
{
|
||||||
|
return Err(RuntimeError::InvalidRequest(
|
||||||
|
"Workspace API replacement cannot change Worker Workspace identity, Runtime identity, or base URL"
|
||||||
|
.to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let live_execution = match (
|
||||||
|
state.execution_backend.clone(),
|
||||||
|
worker.execution_handle.clone(),
|
||||||
|
) {
|
||||||
|
(Some(backend), Some(handle)) => Some((backend, handle)),
|
||||||
|
_ => None,
|
||||||
|
};
|
||||||
|
(worker.request.workspace_api.clone(), live_execution)
|
||||||
|
};
|
||||||
|
|
||||||
|
{
|
||||||
|
let mut state = self.lock()?;
|
||||||
|
state.worker_mut(worker_ref)?.request.workspace_api = Some(workspace_api);
|
||||||
|
if let Err(error) = state.persist_runtime_snapshot() {
|
||||||
|
state.worker_mut(worker_ref)?.request.workspace_api = previous_workspace_api;
|
||||||
|
return Err(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some((backend, handle)) = live_execution {
|
||||||
|
let result = backend.replace_workspace_access_token(&handle, access_token);
|
||||||
|
if !result.is_accepted() {
|
||||||
|
let mut state = self.lock()?;
|
||||||
|
state.worker_mut(worker_ref)?.request.workspace_api = previous_workspace_api;
|
||||||
|
state.persist_runtime_snapshot()?;
|
||||||
|
return Err(RuntimeError::WorkerExecutionRejected {
|
||||||
|
worker_id: worker_ref.worker_id.clone(),
|
||||||
|
operation: result.operation,
|
||||||
|
outcome: result.outcome,
|
||||||
|
message: result.message_or_default(),
|
||||||
|
result,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let state = self.lock()?;
|
||||||
|
Ok(state.worker(worker_ref)?.detail())
|
||||||
|
}
|
||||||
|
|
||||||
/// Attach a live execution through a workspace-scoped Runtime authorization context.
|
/// Attach a live execution through a workspace-scoped Runtime authorization context.
|
||||||
pub fn restore_worker_scoped(
|
pub fn restore_worker_scoped(
|
||||||
&self,
|
&self,
|
||||||
@@ -953,7 +1041,8 @@ impl Runtime {
|
|||||||
WorkerExecutionOperation::Spawn
|
WorkerExecutionOperation::Spawn
|
||||||
| WorkerExecutionOperation::Restore
|
| WorkerExecutionOperation::Restore
|
||||||
| WorkerExecutionOperation::Input
|
| WorkerExecutionOperation::Input
|
||||||
| WorkerExecutionOperation::ProtocolMethod => return Ok(()),
|
| WorkerExecutionOperation::ProtocolMethod
|
||||||
|
| WorkerExecutionOperation::ReplaceWorkspaceAccessToken => return Ok(()),
|
||||||
};
|
};
|
||||||
if result.is_accepted() {
|
if result.is_accepted() {
|
||||||
return Ok(());
|
return Ok(());
|
||||||
@@ -2228,6 +2317,7 @@ mod tests {
|
|||||||
restore_result: Mutex<Option<WorkerExecutionSpawnResult>>,
|
restore_result: Mutex<Option<WorkerExecutionSpawnResult>>,
|
||||||
restore_count: Mutex<u64>,
|
restore_count: Mutex<u64>,
|
||||||
contexts: Mutex<BTreeMap<WorkerId, WorkerExecutionContext>>,
|
contexts: Mutex<BTreeMap<WorkerId, WorkerExecutionContext>>,
|
||||||
|
workspace_access_tokens: Mutex<BTreeMap<WorkerId, String>>,
|
||||||
#[cfg(feature = "ws-server")]
|
#[cfg(feature = "ws-server")]
|
||||||
snapshots: Mutex<BTreeMap<WorkerId, protocol::Event>>,
|
snapshots: Mutex<BTreeMap<WorkerId, protocol::Event>>,
|
||||||
}
|
}
|
||||||
@@ -2316,6 +2406,21 @@ mod tests {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn replace_workspace_access_token(
|
||||||
|
&self,
|
||||||
|
handle: &WorkerExecutionHandle,
|
||||||
|
access_token: String,
|
||||||
|
) -> WorkerExecutionResult {
|
||||||
|
self.workspace_access_tokens
|
||||||
|
.lock()
|
||||||
|
.unwrap()
|
||||||
|
.insert(handle.worker_ref().worker_id.clone(), access_token);
|
||||||
|
WorkerExecutionResult::accepted(
|
||||||
|
WorkerExecutionOperation::ReplaceWorkspaceAccessToken,
|
||||||
|
WorkerExecutionRunState::Idle,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
fn stop_worker(&self, _handle: &WorkerExecutionHandle) -> WorkerExecutionResult {
|
fn stop_worker(&self, _handle: &WorkerExecutionHandle) -> WorkerExecutionResult {
|
||||||
WorkerExecutionResult::accepted(
|
WorkerExecutionResult::accepted(
|
||||||
WorkerExecutionOperation::Stop,
|
WorkerExecutionOperation::Stop,
|
||||||
@@ -2442,6 +2547,46 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn workspace_api_replacement_updates_live_execution_and_persisted_request() {
|
||||||
|
let (runtime, backend) = runtime_and_backend();
|
||||||
|
let scope = scope("workspace-a", "server-a");
|
||||||
|
let worker = runtime
|
||||||
|
.create_worker_scoped(
|
||||||
|
&scope,
|
||||||
|
scoped_task_request("repair credential", "workspace-a"),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
let replacement = WorkspaceApiRef {
|
||||||
|
workspace_id: "workspace-a".to_string(),
|
||||||
|
base_url: "https://workspace.example/workspace-a/".to_string(),
|
||||||
|
runtime_id: Some("runtime-a".to_string()),
|
||||||
|
access_token: Some("replacement-token".to_string()),
|
||||||
|
};
|
||||||
|
|
||||||
|
runtime
|
||||||
|
.replace_worker_workspace_api_scoped(&scope, &worker.worker_ref, replacement.clone())
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
backend
|
||||||
|
.workspace_access_tokens
|
||||||
|
.lock()
|
||||||
|
.unwrap()
|
||||||
|
.get(&worker.worker_ref.worker_id),
|
||||||
|
Some(&"replacement-token".to_string())
|
||||||
|
);
|
||||||
|
let state = runtime.lock().unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
state
|
||||||
|
.worker(&worker.worker_ref)
|
||||||
|
.unwrap()
|
||||||
|
.request
|
||||||
|
.workspace_api,
|
||||||
|
Some(replacement)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn workspace_owner_binding_rejects_other_backend_and_forgets_after_last_worker_delete() {
|
fn workspace_owner_binding_rejects_other_backend_and_forgets_after_last_worker_delete() {
|
||||||
let runtime = runtime_with_backend();
|
let runtime = runtime_with_backend();
|
||||||
|
|||||||
@@ -1124,6 +1124,34 @@ where
|
|||||||
result
|
result
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn replace_workspace_access_token(
|
||||||
|
&self,
|
||||||
|
handle: &WorkerExecutionHandle,
|
||||||
|
access_token: String,
|
||||||
|
) -> WorkerExecutionResult {
|
||||||
|
let (worker, _busy) = match self.get_execution(handle) {
|
||||||
|
Ok(execution) => execution,
|
||||||
|
Err(mut result) => {
|
||||||
|
result.operation = WorkerExecutionOperation::ReplaceWorkspaceAccessToken;
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
worker
|
||||||
|
.replace_workspace_access_token(access_token)
|
||||||
|
.map(|_| {
|
||||||
|
WorkerExecutionResult::accepted(
|
||||||
|
WorkerExecutionOperation::ReplaceWorkspaceAccessToken,
|
||||||
|
WorkerExecutionRunState::Idle,
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.unwrap_or_else(|error| {
|
||||||
|
WorkerExecutionResult::errored(
|
||||||
|
WorkerExecutionOperation::ReplaceWorkspaceAccessToken,
|
||||||
|
error.to_string(),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
fn stop_worker(&self, handle: &WorkerExecutionHandle) -> WorkerExecutionResult {
|
fn stop_worker(&self, handle: &WorkerExecutionHandle) -> WorkerExecutionResult {
|
||||||
if handle.backend_id() != self.backend_id() {
|
if handle.backend_id() != self.backend_id() {
|
||||||
return WorkerExecutionResult::rejected(
|
return WorkerExecutionResult::rejected(
|
||||||
|
|||||||
@@ -26,7 +26,10 @@ use crate::shutdown_after_idle::{
|
|||||||
use crate::spawn::comm_tools::{read_worker_output_tool, send_to_worker_tool, stop_worker_tool};
|
use crate::spawn::comm_tools::{read_worker_output_tool, send_to_worker_tool, stop_worker_tool};
|
||||||
use crate::spawn::registry::SpawnedWorkerRegistry;
|
use crate::spawn::registry::SpawnedWorkerRegistry;
|
||||||
use crate::spawn::tool::spawn_worker_tool;
|
use crate::spawn::tool::spawn_worker_tool;
|
||||||
use crate::worker::{SystemItemCommitter, Worker, WorkerError, WorkerRunResult};
|
use crate::worker::{
|
||||||
|
SystemItemCommitter, Worker, WorkerError, WorkerRunResult, WorkspaceClient,
|
||||||
|
WorkspaceClientError,
|
||||||
|
};
|
||||||
use protocol::{
|
use protocol::{
|
||||||
AlertLevel, AlertSource, ErrorCode, Event, Method, RewindTargetId, RunResult, Segment,
|
AlertLevel, AlertSource, ErrorCode, Event, Method, RewindTargetId, RunResult, Segment,
|
||||||
TurnResult, WorkerStatus,
|
TurnResult, WorkerStatus,
|
||||||
@@ -40,6 +43,7 @@ use protocol::{
|
|||||||
pub struct WorkerHandle {
|
pub struct WorkerHandle {
|
||||||
method_tx: mpsc::Sender<Method>,
|
method_tx: mpsc::Sender<Method>,
|
||||||
event_tx: broadcast::Sender<Event>,
|
event_tx: broadcast::Sender<Event>,
|
||||||
|
workspace_client: Arc<dyn WorkspaceClient>,
|
||||||
pub shared_state: Arc<WorkerSharedState>,
|
pub shared_state: Arc<WorkerSharedState>,
|
||||||
pub runtime_dir: Arc<RuntimeDir>,
|
pub runtime_dir: Arc<RuntimeDir>,
|
||||||
pub alerter: Alerter,
|
pub alerter: Alerter,
|
||||||
@@ -113,6 +117,14 @@ impl WorkerHandle {
|
|||||||
pub fn alert(&self, level: AlertLevel, source: AlertSource, message: String) {
|
pub fn alert(&self, level: AlertLevel, source: AlertSource, message: String) {
|
||||||
self.alerter.alert(level, source, message);
|
self.alerter.alert(level, source, message);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Replace the Runtime-issued Workspace access token used by this live Worker.
|
||||||
|
pub fn replace_workspace_access_token(
|
||||||
|
&self,
|
||||||
|
access_token: String,
|
||||||
|
) -> Result<(), WorkspaceClientError> {
|
||||||
|
self.workspace_client.replace_access_token(access_token)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn set_controller_status(
|
async fn set_controller_status(
|
||||||
@@ -234,6 +246,7 @@ impl WorkerController {
|
|||||||
let (shutdown_tx, shutdown_rx) = oneshot::channel::<()>();
|
let (shutdown_tx, shutdown_rx) = oneshot::channel::<()>();
|
||||||
let (method_tx, method_rx) = mpsc::channel::<Method>(32);
|
let (method_tx, method_rx) = mpsc::channel::<Method>(32);
|
||||||
let (event_tx, _) = broadcast::channel::<Event>(256);
|
let (event_tx, _) = broadcast::channel::<Event>(256);
|
||||||
|
let workspace_client = worker.workspace_client_handle();
|
||||||
let alerter = Alerter::new(event_tx.clone());
|
let alerter = Alerter::new(event_tx.clone());
|
||||||
let in_flight = InFlightEvents::new(event_tx.clone());
|
let in_flight = InFlightEvents::new(event_tx.clone());
|
||||||
worker.attach_in_flight_events(in_flight.clone());
|
worker.attach_in_flight_events(in_flight.clone());
|
||||||
@@ -352,6 +365,7 @@ impl WorkerController {
|
|||||||
let handle = WorkerHandle {
|
let handle = WorkerHandle {
|
||||||
method_tx,
|
method_tx,
|
||||||
event_tx: event_tx.clone(),
|
event_tx: event_tx.clone(),
|
||||||
|
workspace_client,
|
||||||
shared_state: shared_state.clone(),
|
shared_state: shared_state.clone(),
|
||||||
runtime_dir: runtime_dir.clone(),
|
runtime_dir: runtime_dir.clone(),
|
||||||
alerter: alerter.clone(),
|
alerter: alerter.clone(),
|
||||||
|
|||||||
@@ -12,10 +12,10 @@ use std::{
|
|||||||
use ticket::{
|
use ticket::{
|
||||||
LocalTicketBackend, MarkdownText, NewOrchestrationPlanRecord, NewTicket, NewTicketEvent,
|
LocalTicketBackend, MarkdownText, NewOrchestrationPlanRecord, NewTicket, NewTicketEvent,
|
||||||
NewTicketRelation, OrchestrationPlanKind, OrchestrationPlanRecord, Result as TicketResult,
|
NewTicketRelation, OrchestrationPlanKind, OrchestrationPlanRecord, Result as TicketResult,
|
||||||
Ticket, TicketBackend, TicketBackendHttpResponse, TicketBackendOperation,
|
Ticket, TicketBackend, TicketBackendOperation, TicketBackendOperationResult,
|
||||||
TicketBackendOperationResult, TicketDoctorReport, TicketError, TicketIdOrSlug,
|
TicketDoctorReport, TicketError, TicketIdOrSlug, TicketIntakeSummary, TicketListQuery,
|
||||||
TicketIntakeSummary, TicketListQuery, TicketRef, TicketRelation, TicketRelationKind,
|
TicketRef, TicketRelation, TicketRelationKind, TicketRelationView, TicketReview,
|
||||||
TicketRelationView, TicketReview, TicketStateChange, TicketSummary,
|
TicketStateChange, TicketSummary,
|
||||||
config::{DEFAULT_TICKET_BACKEND_RELATIVE_PATH, TicketConfig},
|
config::{DEFAULT_TICKET_BACKEND_RELATIVE_PATH, TicketConfig},
|
||||||
tool::{TICKET_TOOL_NAMES, TicketToolBackend, ticket_tool_description, ticket_tools},
|
tool::{TICKET_TOOL_NAMES, TicketToolBackend, ticket_tool_description, ticket_tools},
|
||||||
};
|
};
|
||||||
@@ -387,57 +387,310 @@ impl WorkspaceHttpTicketBackend {
|
|||||||
Self { client }
|
Self { client }
|
||||||
}
|
}
|
||||||
|
|
||||||
fn endpoint(&self) -> String {
|
|
||||||
format!(
|
|
||||||
"/api/w/{}/tickets/backend",
|
|
||||||
self.client.workspace_id().unwrap_or_default()
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn invoke(
|
fn invoke(
|
||||||
&self,
|
&self,
|
||||||
operation: TicketBackendOperation,
|
operation: TicketBackendOperation,
|
||||||
) -> TicketResult<TicketBackendOperationResult> {
|
) -> TicketResult<TicketBackendOperationResult> {
|
||||||
let client = self.client.clone();
|
let client = self.client.clone();
|
||||||
let endpoint = self.endpoint();
|
let workspace_id = self.client.workspace_id().unwrap_or_default().to_string();
|
||||||
if tokio::runtime::Handle::try_current().is_ok() {
|
if tokio::runtime::Handle::try_current().is_ok() {
|
||||||
return std::thread::spawn(move || Self::invoke_client(client, endpoint, operation))
|
return std::thread::spawn(move || {
|
||||||
.join()
|
Self::invoke_client(client, workspace_id, operation)
|
||||||
.map_err(|_| {
|
})
|
||||||
TicketError::Conflict("ticket backend request thread panicked".to_string())
|
.join()
|
||||||
})?;
|
.map_err(|_| {
|
||||||
|
TicketError::Conflict("ticket REST request thread panicked".to_string())
|
||||||
|
})?;
|
||||||
}
|
}
|
||||||
Self::invoke_client(client, endpoint, operation)
|
Self::invoke_client(client, workspace_id, operation)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn ticket_path(id: &TicketIdOrSlug) -> String {
|
||||||
|
let value = match id {
|
||||||
|
TicketIdOrSlug::Id(value)
|
||||||
|
| TicketIdOrSlug::Slug(value)
|
||||||
|
| TicketIdOrSlug::Query(value) => value,
|
||||||
|
};
|
||||||
|
let mut encoded = String::with_capacity(value.len());
|
||||||
|
for byte in value.bytes() {
|
||||||
|
if byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b'.' | b'~') {
|
||||||
|
encoded.push(byte as char);
|
||||||
|
} else {
|
||||||
|
use std::fmt::Write as _;
|
||||||
|
let _ = write!(encoded, "%{byte:02X}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
encoded
|
||||||
|
}
|
||||||
|
|
||||||
|
fn request<T: serde::de::DeserializeOwned>(
|
||||||
|
client: Arc<dyn WorkspaceClient>,
|
||||||
|
method: WorkspaceRequestMethod,
|
||||||
|
endpoint: String,
|
||||||
|
body: Option<serde_json::Value>,
|
||||||
|
) -> TicketResult<T> {
|
||||||
|
let request = match body {
|
||||||
|
Some(body) => WorkspaceRequest::json(method, endpoint, body.to_string()),
|
||||||
|
None if method == WorkspaceRequestMethod::Get => WorkspaceRequest::get(endpoint),
|
||||||
|
None => WorkspaceRequest {
|
||||||
|
method,
|
||||||
|
path: endpoint,
|
||||||
|
body: None,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
let response = client.execute(request).map_err(|error| {
|
||||||
|
TicketError::Conflict(format!("ticket REST request failed: {error}"))
|
||||||
|
})?;
|
||||||
|
if !response.is_success() {
|
||||||
|
return Err(TicketError::Conflict(format!(
|
||||||
|
"ticket REST API returned HTTP {}: {}",
|
||||||
|
response.status, response.body
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
serde_json::from_str(&response.body)
|
||||||
|
.map_err(|error| TicketError::Conflict(format!("decode ticket REST response: {error}")))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn request_unit(
|
||||||
|
client: Arc<dyn WorkspaceClient>,
|
||||||
|
method: WorkspaceRequestMethod,
|
||||||
|
endpoint: String,
|
||||||
|
body: Option<serde_json::Value>,
|
||||||
|
) -> TicketResult<TicketBackendOperationResult> {
|
||||||
|
let request = match body {
|
||||||
|
Some(body) => WorkspaceRequest::json(method, endpoint, body.to_string()),
|
||||||
|
None => WorkspaceRequest {
|
||||||
|
method,
|
||||||
|
path: endpoint,
|
||||||
|
body: None,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
let response = client.execute(request).map_err(|error| {
|
||||||
|
TicketError::Conflict(format!("ticket REST request failed: {error}"))
|
||||||
|
})?;
|
||||||
|
if !response.is_success() {
|
||||||
|
return Err(TicketError::Conflict(format!(
|
||||||
|
"ticket REST API returned HTTP {}: {}",
|
||||||
|
response.status, response.body
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
Ok(TicketBackendOperationResult::Unit)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn invoke_client(
|
fn invoke_client(
|
||||||
client: Arc<dyn WorkspaceClient>,
|
client: Arc<dyn WorkspaceClient>,
|
||||||
endpoint: String,
|
workspace_id: String,
|
||||||
operation: TicketBackendOperation,
|
operation: TicketBackendOperation,
|
||||||
) -> TicketResult<TicketBackendOperationResult> {
|
) -> TicketResult<TicketBackendOperationResult> {
|
||||||
let body = serde_json::to_string(&operation).map_err(|error| {
|
let base = format!("/api/w/{workspace_id}/tickets");
|
||||||
TicketError::Conflict(format!("serialize ticket operation: {error}"))
|
match operation {
|
||||||
})?;
|
TicketBackendOperation::DefaultIntakeReadyStateChangeBody { from } => {
|
||||||
let response = client
|
let value = Self::request::<String>(
|
||||||
.execute(WorkspaceRequest::json(
|
client,
|
||||||
|
WorkspaceRequestMethod::Post,
|
||||||
|
format!("{base}/default-intake-ready-body"),
|
||||||
|
Some(serde_json::json!({ "from": from })),
|
||||||
|
)?;
|
||||||
|
Ok(TicketBackendOperationResult::Text(value))
|
||||||
|
}
|
||||||
|
TicketBackendOperation::List { filter } => {
|
||||||
|
let state = match filter.state {
|
||||||
|
ticket::TicketStateSelector::Active => "active".to_string(),
|
||||||
|
ticket::TicketStateSelector::All => "all".to_string(),
|
||||||
|
ticket::TicketStateSelector::States(states) => states
|
||||||
|
.into_iter()
|
||||||
|
.map(|state| state.as_str().to_string())
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join(","),
|
||||||
|
};
|
||||||
|
let tickets = Self::request(
|
||||||
|
client,
|
||||||
|
WorkspaceRequestMethod::Get,
|
||||||
|
format!("{base}/search?state={state}"),
|
||||||
|
None,
|
||||||
|
)?;
|
||||||
|
Ok(TicketBackendOperationResult::Tickets(tickets))
|
||||||
|
}
|
||||||
|
TicketBackendOperation::Show { id } => {
|
||||||
|
let ticket = Self::request(
|
||||||
|
client,
|
||||||
|
WorkspaceRequestMethod::Get,
|
||||||
|
format!("{base}/{}/record", Self::ticket_path(&id)),
|
||||||
|
None,
|
||||||
|
)?;
|
||||||
|
Ok(TicketBackendOperationResult::Ticket(ticket))
|
||||||
|
}
|
||||||
|
TicketBackendOperation::Create { input } => {
|
||||||
|
let ticket = Self::request(
|
||||||
|
client,
|
||||||
|
WorkspaceRequestMethod::Post,
|
||||||
|
base,
|
||||||
|
Some(serde_json::to_value(input).map_err(|error| {
|
||||||
|
TicketError::Conflict(format!("serialize Ticket create: {error}"))
|
||||||
|
})?),
|
||||||
|
)?;
|
||||||
|
Ok(TicketBackendOperationResult::TicketRef(ticket))
|
||||||
|
}
|
||||||
|
TicketBackendOperation::EditItem { id, edit } => {
|
||||||
|
let ticket = Self::request(
|
||||||
|
client,
|
||||||
|
WorkspaceRequestMethod::Patch,
|
||||||
|
format!("{base}/{}/item", Self::ticket_path(&id)),
|
||||||
|
Some(serde_json::to_value(edit).map_err(|error| {
|
||||||
|
TicketError::Conflict(format!("serialize Ticket edit: {error}"))
|
||||||
|
})?),
|
||||||
|
)?;
|
||||||
|
Ok(TicketBackendOperationResult::Ticket(ticket))
|
||||||
|
}
|
||||||
|
TicketBackendOperation::DependencyCheck { id } => {
|
||||||
|
let check = Self::request(
|
||||||
|
client,
|
||||||
|
WorkspaceRequestMethod::Get,
|
||||||
|
format!("{base}/{}/dependency-check", Self::ticket_path(&id)),
|
||||||
|
None,
|
||||||
|
)?;
|
||||||
|
Ok(TicketBackendOperationResult::DependencyCheck(check))
|
||||||
|
}
|
||||||
|
TicketBackendOperation::AddEvent { id, event } => Self::request_unit(
|
||||||
|
client,
|
||||||
WorkspaceRequestMethod::Post,
|
WorkspaceRequestMethod::Post,
|
||||||
endpoint,
|
format!("{base}/{}/thread-events", Self::ticket_path(&id)),
|
||||||
body,
|
Some(serde_json::to_value(event).map_err(|error| {
|
||||||
))
|
TicketError::Conflict(format!("serialize Ticket event: {error}"))
|
||||||
.map_err(|error| {
|
})?),
|
||||||
TicketError::Conflict(format!("ticket backend request failed: {error}"))
|
),
|
||||||
})?;
|
TicketBackendOperation::AddStateChanged { id, change } => Self::request_unit(
|
||||||
if !response.is_success() {
|
client,
|
||||||
return Err(TicketError::Conflict(format!(
|
WorkspaceRequestMethod::Post,
|
||||||
"ticket backend returned HTTP {}: {}",
|
format!("{base}/{}/state-changes", Self::ticket_path(&id)),
|
||||||
response.status, response.body
|
Some(serde_json::to_value(change).map_err(|error| {
|
||||||
)));
|
TicketError::Conflict(format!("serialize Ticket state change: {error}"))
|
||||||
}
|
})?),
|
||||||
match serde_json::from_str::<TicketBackendHttpResponse>(&response.body).map_err(
|
),
|
||||||
|error| TicketError::Conflict(format!("decode ticket backend response: {error}")),
|
TicketBackendOperation::AddIntakeSummary { id, summary } => Self::request_unit(
|
||||||
)? {
|
client,
|
||||||
TicketBackendHttpResponse::Ok { result } => Ok(result),
|
WorkspaceRequestMethod::Post,
|
||||||
TicketBackendHttpResponse::Error { message } => Err(TicketError::Conflict(message)),
|
format!("{base}/{}/intake-summaries", Self::ticket_path(&id)),
|
||||||
|
Some(serde_json::to_value(summary).map_err(|error| {
|
||||||
|
TicketError::Conflict(format!("serialize Ticket intake summary: {error}"))
|
||||||
|
})?),
|
||||||
|
),
|
||||||
|
TicketBackendOperation::SetStateField { id, field, change } => Self::request_unit(
|
||||||
|
client,
|
||||||
|
WorkspaceRequestMethod::Post,
|
||||||
|
format!(
|
||||||
|
"{base}/{}/state-fields/{}",
|
||||||
|
Self::ticket_path(&id),
|
||||||
|
Self::ticket_path(&TicketIdOrSlug::Query(field))
|
||||||
|
),
|
||||||
|
Some(serde_json::to_value(change).map_err(|error| {
|
||||||
|
TicketError::Conflict(format!("serialize Ticket state field change: {error}"))
|
||||||
|
})?),
|
||||||
|
),
|
||||||
|
TicketBackendOperation::SetWorkflowState { id, change } => Self::request_unit(
|
||||||
|
client,
|
||||||
|
WorkspaceRequestMethod::Post,
|
||||||
|
format!("{base}/{}/workflow-state", Self::ticket_path(&id)),
|
||||||
|
Some(serde_json::to_value(change).map_err(|error| {
|
||||||
|
TicketError::Conflict(format!("serialize Ticket workflow change: {error}"))
|
||||||
|
})?),
|
||||||
|
),
|
||||||
|
TicketBackendOperation::MarkIntakeReady {
|
||||||
|
id,
|
||||||
|
summary,
|
||||||
|
change,
|
||||||
|
} => Self::request_unit(
|
||||||
|
client,
|
||||||
|
WorkspaceRequestMethod::Post,
|
||||||
|
format!("{base}/{}/intake-ready", Self::ticket_path(&id)),
|
||||||
|
Some(serde_json::json!({ "summary": summary, "change": change })),
|
||||||
|
),
|
||||||
|
TicketBackendOperation::QueueReady { id, .. } => Self::request_unit(
|
||||||
|
client,
|
||||||
|
WorkspaceRequestMethod::Post,
|
||||||
|
format!("{base}/{}/workflow/queue", Self::ticket_path(&id)),
|
||||||
|
None,
|
||||||
|
),
|
||||||
|
TicketBackendOperation::Review { id, review } => Self::request_unit(
|
||||||
|
client,
|
||||||
|
WorkspaceRequestMethod::Post,
|
||||||
|
format!("{base}/{}/workflow/review", Self::ticket_path(&id)),
|
||||||
|
Some(serde_json::to_value(review).map_err(|error| {
|
||||||
|
TicketError::Conflict(format!("serialize Ticket review: {error}"))
|
||||||
|
})?),
|
||||||
|
),
|
||||||
|
TicketBackendOperation::Close { id, resolution } => Self::request_unit(
|
||||||
|
client,
|
||||||
|
WorkspaceRequestMethod::Post,
|
||||||
|
format!("{base}/{}/workflow/close", Self::ticket_path(&id)),
|
||||||
|
Some(serde_json::to_value(resolution).map_err(|error| {
|
||||||
|
TicketError::Conflict(format!("serialize Ticket close: {error}"))
|
||||||
|
})?),
|
||||||
|
),
|
||||||
|
TicketBackendOperation::AddTicketRelation { id, relation } => {
|
||||||
|
let relation = Self::request(
|
||||||
|
client,
|
||||||
|
WorkspaceRequestMethod::Post,
|
||||||
|
format!("{base}/{}/relations", Self::ticket_path(&id)),
|
||||||
|
Some(serde_json::to_value(relation).map_err(|error| {
|
||||||
|
TicketError::Conflict(format!("serialize Ticket relation: {error}"))
|
||||||
|
})?),
|
||||||
|
)?;
|
||||||
|
Ok(TicketBackendOperationResult::Relation(relation))
|
||||||
|
}
|
||||||
|
TicketBackendOperation::QueryTicketRelations { ticket, kind } => {
|
||||||
|
let relations = Self::request(
|
||||||
|
client,
|
||||||
|
WorkspaceRequestMethod::Post,
|
||||||
|
format!("{base}/relations/search"),
|
||||||
|
Some(serde_json::json!({ "ticket": ticket, "kind": kind })),
|
||||||
|
)?;
|
||||||
|
Ok(TicketBackendOperationResult::Relations(relations))
|
||||||
|
}
|
||||||
|
TicketBackendOperation::RelationView { id } => {
|
||||||
|
let view = Self::request(
|
||||||
|
client,
|
||||||
|
WorkspaceRequestMethod::Get,
|
||||||
|
format!("{base}/{}/relation-view", Self::ticket_path(&id)),
|
||||||
|
None,
|
||||||
|
)?;
|
||||||
|
Ok(TicketBackendOperationResult::RelationView(view))
|
||||||
|
}
|
||||||
|
TicketBackendOperation::AddOrchestrationPlanRecord { id, record } => {
|
||||||
|
let record = Self::request(
|
||||||
|
client,
|
||||||
|
WorkspaceRequestMethod::Post,
|
||||||
|
format!("{base}/{}/orchestration-plans", Self::ticket_path(&id)),
|
||||||
|
Some(serde_json::to_value(record).map_err(|error| {
|
||||||
|
TicketError::Conflict(format!(
|
||||||
|
"serialize Ticket orchestration plan: {error}"
|
||||||
|
))
|
||||||
|
})?),
|
||||||
|
)?;
|
||||||
|
Ok(TicketBackendOperationResult::OrchestrationPlanRecord(
|
||||||
|
record,
|
||||||
|
))
|
||||||
|
}
|
||||||
|
TicketBackendOperation::QueryOrchestrationPlanRecords { ticket, kind } => {
|
||||||
|
let records = Self::request(
|
||||||
|
client,
|
||||||
|
WorkspaceRequestMethod::Post,
|
||||||
|
format!("{base}/orchestration-plans/search"),
|
||||||
|
Some(serde_json::json!({ "ticket": ticket, "kind": kind })),
|
||||||
|
)?;
|
||||||
|
Ok(TicketBackendOperationResult::OrchestrationPlanRecords(
|
||||||
|
records,
|
||||||
|
))
|
||||||
|
}
|
||||||
|
TicketBackendOperation::Doctor => {
|
||||||
|
let report = Self::request(
|
||||||
|
client,
|
||||||
|
WorkspaceRequestMethod::Get,
|
||||||
|
format!("{base}/doctor"),
|
||||||
|
None,
|
||||||
|
)?;
|
||||||
|
Ok(TicketBackendOperationResult::DoctorReport(report))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1129,7 +1382,42 @@ provider = "github"
|
|||||||
})
|
})
|
||||||
.unwrap_err();
|
.unwrap_err();
|
||||||
|
|
||||||
assert!(error.to_string().contains("ticket backend request failed"));
|
assert!(error.to_string().contains("ticket REST request failed"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn workspace_http_backend_posts_ticket_event_subresource() {
|
||||||
|
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
|
||||||
|
let address = listener.local_addr().unwrap();
|
||||||
|
let server = std::thread::spawn(move || {
|
||||||
|
let (mut stream, _) = listener.accept().unwrap();
|
||||||
|
let mut buffer = [0_u8; 8192];
|
||||||
|
let size = stream.read(&mut buffer).unwrap();
|
||||||
|
let request = String::from_utf8_lossy(&buffer[..size]);
|
||||||
|
assert!(
|
||||||
|
request
|
||||||
|
.starts_with("POST /api/w/workspace-a/tickets/01TEST/thread-events HTTP/1.1")
|
||||||
|
);
|
||||||
|
assert!(!request.contains("\"operation\""));
|
||||||
|
assert!(request.contains("\"kind\":\"comment\""));
|
||||||
|
stream
|
||||||
|
.write_all(b"HTTP/1.1 204 No Content\r\nContent-Length: 0\r\n\r\n")
|
||||||
|
.unwrap();
|
||||||
|
});
|
||||||
|
let client = Arc::new(crate::worker::RuntimeWorkspaceHttpClient::new(
|
||||||
|
"workspace-a",
|
||||||
|
format!("http://{address}"),
|
||||||
|
"worker-a",
|
||||||
|
));
|
||||||
|
let backend = WorkspaceHttpTicketBackend::new(client);
|
||||||
|
|
||||||
|
backend
|
||||||
|
.add_event(
|
||||||
|
TicketIdOrSlug::Id("01TEST".to_string()),
|
||||||
|
NewTicketEvent::new(ticket::TicketEventKind::Comment, "REST comment"),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
server.join().unwrap();
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -1141,15 +1429,13 @@ provider = "github"
|
|||||||
let mut buffer = [0_u8; 8192];
|
let mut buffer = [0_u8; 8192];
|
||||||
let len = stream.read(&mut buffer).unwrap();
|
let len = stream.read(&mut buffer).unwrap();
|
||||||
let request = String::from_utf8_lossy(&buffer[..len]);
|
let request = String::from_utf8_lossy(&buffer[..len]);
|
||||||
assert!(request.starts_with("POST /api/w/workspace-a/tickets/backend HTTP/1.1"));
|
assert!(request.starts_with("POST /api/w/workspace-a/tickets HTTP/1.1"));
|
||||||
assert!(request.contains("\"operation\":\"create\""));
|
assert!(!request.contains("\"operation\""));
|
||||||
assert!(request.contains("\"title\":\"HTTP ticket\""));
|
assert!(request.contains("\"title\":\"HTTP ticket\""));
|
||||||
let response_body = serde_json::to_string(&TicketBackendHttpResponse::Ok {
|
let response_body = serde_json::to_string(&TicketRef {
|
||||||
result: TicketBackendOperationResult::TicketRef(TicketRef {
|
id: "01TEST".to_string(),
|
||||||
id: "01TEST".to_string(),
|
slug: "http-ticket".to_string(),
|
||||||
slug: "http-ticket".to_string(),
|
status: ticket::TicketStatus::Open,
|
||||||
status: ticket::TicketStatus::Open,
|
|
||||||
}),
|
|
||||||
})
|
})
|
||||||
.unwrap();
|
.unwrap();
|
||||||
write!(
|
write!(
|
||||||
|
|||||||
@@ -215,6 +215,13 @@ pub trait WorkspaceClient: std::fmt::Debug + Send + Sync {
|
|||||||
fn is_available(&self) -> bool;
|
fn is_available(&self) -> bool;
|
||||||
fn execute(&self, request: WorkspaceRequest)
|
fn execute(&self, request: WorkspaceRequest)
|
||||||
-> Result<WorkspaceResponse, WorkspaceClientError>;
|
-> Result<WorkspaceResponse, WorkspaceClientError>;
|
||||||
|
|
||||||
|
/// Replace the Runtime-issued Workspace access token for this live client.
|
||||||
|
fn replace_access_token(&self, _access_token: String) -> Result<(), WorkspaceClientError> {
|
||||||
|
Err(WorkspaceClientError::Unavailable(
|
||||||
|
"Workspace client does not support access token replacement".to_string(),
|
||||||
|
))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// HTTP forwarding client created by Runtime for one concrete Worker execution.
|
/// HTTP forwarding client created by Runtime for one concrete Worker execution.
|
||||||
@@ -323,6 +330,13 @@ impl WorkspaceClient for RuntimeWorkspaceHttpClient {
|
|||||||
}
|
}
|
||||||
Ok(result.0)
|
Ok(result.0)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn replace_access_token(&self, access_token: String) -> Result<(), WorkspaceClientError> {
|
||||||
|
*self.access_token.lock().map_err(|_| {
|
||||||
|
WorkspaceClientError::Request("workspace credential lock poisoned".to_string())
|
||||||
|
})? = Some(access_token);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn execute_runtime_workspace_http_with_refresh(
|
fn execute_runtime_workspace_http_with_refresh(
|
||||||
@@ -6318,13 +6332,28 @@ mod build_summary_prompt_tests {
|
|||||||
.with_access_token(Some("expired-token".to_string()));
|
.with_access_token(Some("expired-token".to_string()));
|
||||||
let response = client
|
let response = client
|
||||||
.execute(WorkspaceRequest::get(
|
.execute(WorkspaceRequest::get(
|
||||||
"/api/w/workspace-refresh/tickets/backend",
|
"/api/w/workspace-refresh/tickets/search",
|
||||||
))
|
))
|
||||||
.unwrap();
|
.unwrap();
|
||||||
assert_eq!(response.status, 200);
|
assert_eq!(response.status, 200);
|
||||||
server.join().unwrap();
|
server.join().unwrap();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn runtime_workspace_client_can_install_missing_access_token() {
|
||||||
|
let client =
|
||||||
|
RuntimeWorkspaceHttpClient::new("workspace-a", "https://workspace.example", "worker-a");
|
||||||
|
|
||||||
|
client
|
||||||
|
.replace_access_token("replacement-token".to_string())
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
client.access_token.lock().unwrap().as_deref(),
|
||||||
|
Some("replacement-token")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
fn minimal_manifest() -> WorkerManifest {
|
fn minimal_manifest() -> WorkerManifest {
|
||||||
let toml_str = r#"
|
let toml_str = r#"
|
||||||
[worker]
|
[worker]
|
||||||
|
|||||||
@@ -34,7 +34,8 @@ use worker_runtime::http_server::{
|
|||||||
RuntimeHttpErrorResponse, RuntimeHttpSummaryResponse, RuntimeHttpWorkerCompletionsRequest,
|
RuntimeHttpErrorResponse, RuntimeHttpSummaryResponse, RuntimeHttpWorkerCompletionsRequest,
|
||||||
RuntimeHttpWorkerCompletionsResponse, RuntimeHttpWorkerDeleteResponse,
|
RuntimeHttpWorkerCompletionsResponse, RuntimeHttpWorkerDeleteResponse,
|
||||||
RuntimeHttpWorkerInputResponse, RuntimeHttpWorkerLifecycleRequest,
|
RuntimeHttpWorkerInputResponse, RuntimeHttpWorkerLifecycleRequest,
|
||||||
RuntimeHttpWorkerLifecycleResponse, RuntimeHttpWorkerResponse, RuntimeHttpWorkersResponse,
|
RuntimeHttpWorkerLifecycleResponse, RuntimeHttpWorkerResponse,
|
||||||
|
RuntimeHttpWorkerWorkspaceApiRequest, RuntimeHttpWorkersResponse,
|
||||||
RuntimeHttpWorkingDirectoriesResponse, RuntimeHttpWorkingDirectoryResponse,
|
RuntimeHttpWorkingDirectoriesResponse, RuntimeHttpWorkingDirectoryResponse,
|
||||||
};
|
};
|
||||||
use worker_runtime::identity::{WorkerId as EmbeddedWorkerId, WorkerRef as EmbeddedWorkerRef};
|
use worker_runtime::identity::{WorkerId as EmbeddedWorkerId, WorkerRef as EmbeddedWorkerRef};
|
||||||
@@ -260,6 +261,14 @@ pub struct WorkerRestoreResult {
|
|||||||
pub diagnostics: Vec<RuntimeDiagnostic>,
|
pub diagnostics: Vec<RuntimeDiagnostic>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
|
pub struct WorkerWorkspaceApiResult {
|
||||||
|
pub state: WorkerOperationState,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub worker: Option<WorkerSummary>,
|
||||||
|
pub diagnostics: Vec<RuntimeDiagnostic>,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
pub struct RuntimeList<T> {
|
pub struct RuntimeList<T> {
|
||||||
pub items: Vec<T>,
|
pub items: Vec<T>,
|
||||||
@@ -415,6 +424,30 @@ pub struct ConfigBundleListResult {
|
|||||||
pub diagnostics: Vec<RuntimeDiagnostic>,
|
pub diagnostics: Vec<RuntimeDiagnostic>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn required_worker_workspace_api(
|
||||||
|
request: &WorkerSpawnRequest,
|
||||||
|
) -> Result<WorkspaceApiRef, RuntimeDiagnostic> {
|
||||||
|
let workspace_api = request.resolved_workspace_api.clone().ok_or_else(|| {
|
||||||
|
diagnostic(
|
||||||
|
"worker_workspace_credential_missing",
|
||||||
|
DiagnosticSeverity::Error,
|
||||||
|
"Workspace-bound Worker spawn requires a resolved Workspace API credential",
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
if workspace_api
|
||||||
|
.access_token
|
||||||
|
.as_deref()
|
||||||
|
.is_none_or(|token| token.trim().is_empty())
|
||||||
|
{
|
||||||
|
return Err(diagnostic(
|
||||||
|
"worker_workspace_credential_missing",
|
||||||
|
DiagnosticSeverity::Error,
|
||||||
|
"Workspace-bound Worker spawn requires a non-empty Workspace API access token",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
Ok(workspace_api)
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
#[serde(rename_all = "snake_case")]
|
#[serde(rename_all = "snake_case")]
|
||||||
pub enum WorkerOperationState {
|
pub enum WorkerOperationState {
|
||||||
@@ -622,6 +655,24 @@ pub trait WorkspaceWorkerRuntime: Send + Sync {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn replace_worker_workspace_api(
|
||||||
|
&self,
|
||||||
|
worker_id: &str,
|
||||||
|
_workspace_api: WorkspaceApiRef,
|
||||||
|
) -> WorkerWorkspaceApiResult {
|
||||||
|
WorkerWorkspaceApiResult {
|
||||||
|
state: WorkerOperationState::Unsupported,
|
||||||
|
worker: None,
|
||||||
|
diagnostics: vec![diagnostic(
|
||||||
|
"worker_workspace_api_replace_unsupported",
|
||||||
|
DiagnosticSeverity::Info,
|
||||||
|
format!(
|
||||||
|
"runtime does not support replacing the Workspace API for worker `{worker_id}`"
|
||||||
|
),
|
||||||
|
)],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn create_working_directory(
|
fn create_working_directory(
|
||||||
&self,
|
&self,
|
||||||
_request: WorkingDirectoryRequest,
|
_request: WorkingDirectoryRequest,
|
||||||
@@ -1072,6 +1123,18 @@ impl RuntimeRegistry {
|
|||||||
Ok(runtime.restore_worker(worker_id))
|
Ok(runtime.restore_worker(worker_id))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn replace_worker_workspace_api(
|
||||||
|
&self,
|
||||||
|
runtime_id: &str,
|
||||||
|
worker_id: &str,
|
||||||
|
workspace_api: WorkspaceApiRef,
|
||||||
|
) -> Result<WorkerWorkspaceApiResult, RuntimeRegistryError> {
|
||||||
|
validate_backend_identifier("runtime_id", runtime_id)?;
|
||||||
|
validate_backend_identifier("worker_id", worker_id)?;
|
||||||
|
let runtime = self.runtime(runtime_id)?;
|
||||||
|
Ok(runtime.replace_worker_workspace_api(worker_id, workspace_api))
|
||||||
|
}
|
||||||
|
|
||||||
pub fn spawn_worker(
|
pub fn spawn_worker(
|
||||||
&self,
|
&self,
|
||||||
runtime_id: &str,
|
runtime_id: &str,
|
||||||
@@ -1312,8 +1375,6 @@ impl RuntimeRegistry {
|
|||||||
pub struct EmbeddedWorkerRuntime {
|
pub struct EmbeddedWorkerRuntime {
|
||||||
runtime_id: String,
|
runtime_id: String,
|
||||||
host_id: String,
|
host_id: String,
|
||||||
workspace_id: String,
|
|
||||||
backend_base_url: Option<String>,
|
|
||||||
runtime: worker_runtime::Runtime,
|
runtime: worker_runtime::Runtime,
|
||||||
execution_enabled: bool,
|
execution_enabled: bool,
|
||||||
resource_broker: BackendResourceBroker,
|
resource_broker: BackendResourceBroker,
|
||||||
@@ -1366,18 +1427,11 @@ impl EmbeddedWorkerRuntime {
|
|||||||
self
|
self
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn with_backend_base_url(mut self, backend_base_url: impl Into<String>) -> Self {
|
|
||||||
self.backend_base_url = Some(backend_base_url.into().trim_end_matches('/').to_string());
|
|
||||||
self
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn from_runtime(workspace_id: impl AsRef<str>, runtime: worker_runtime::Runtime) -> Self {
|
pub fn from_runtime(workspace_id: impl AsRef<str>, runtime: worker_runtime::Runtime) -> Self {
|
||||||
let workspace_id = workspace_id.as_ref().to_string();
|
let workspace_id = workspace_id.as_ref().to_string();
|
||||||
Self {
|
Self {
|
||||||
runtime_id: EMBEDDED_RUNTIME_ID.to_string(),
|
runtime_id: EMBEDDED_RUNTIME_ID.to_string(),
|
||||||
host_id: host_id_for_embedded_workspace(&workspace_id),
|
host_id: host_id_for_embedded_workspace(&workspace_id),
|
||||||
workspace_id,
|
|
||||||
backend_base_url: None,
|
|
||||||
runtime,
|
runtime,
|
||||||
execution_enabled: false,
|
execution_enabled: false,
|
||||||
resource_broker: BackendResourceBroker::default(),
|
resource_broker: BackendResourceBroker::default(),
|
||||||
@@ -1626,6 +1680,39 @@ impl WorkspaceWorkerRuntime for EmbeddedWorkerRuntime {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn replace_worker_workspace_api(
|
||||||
|
&self,
|
||||||
|
worker_id: &str,
|
||||||
|
workspace_api: WorkspaceApiRef,
|
||||||
|
) -> WorkerWorkspaceApiResult {
|
||||||
|
let Some(worker_ref) = self.worker_ref(worker_id) else {
|
||||||
|
return WorkerWorkspaceApiResult {
|
||||||
|
state: WorkerOperationState::Rejected,
|
||||||
|
worker: None,
|
||||||
|
diagnostics: vec![diagnostic(
|
||||||
|
"embedded_worker_id_invalid",
|
||||||
|
DiagnosticSeverity::Warning,
|
||||||
|
"Worker id was empty and cannot receive Workspace access".to_string(),
|
||||||
|
)],
|
||||||
|
};
|
||||||
|
};
|
||||||
|
match self
|
||||||
|
.runtime
|
||||||
|
.replace_worker_workspace_api(&worker_ref, workspace_api)
|
||||||
|
{
|
||||||
|
Ok(detail) => WorkerWorkspaceApiResult {
|
||||||
|
state: WorkerOperationState::Accepted,
|
||||||
|
worker: Some(self.map_worker_detail(detail)),
|
||||||
|
diagnostics: Vec::new(),
|
||||||
|
},
|
||||||
|
Err(err) => WorkerWorkspaceApiResult {
|
||||||
|
state: WorkerOperationState::Rejected,
|
||||||
|
worker: None,
|
||||||
|
diagnostics: vec![embedded_runtime_diagnostic(&err)],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn create_working_directory(
|
fn create_working_directory(
|
||||||
&self,
|
&self,
|
||||||
_request: WorkingDirectoryRequest,
|
_request: WorkingDirectoryRequest,
|
||||||
@@ -1727,6 +1814,18 @@ impl WorkspaceWorkerRuntime for EmbeddedWorkerRuntime {
|
|||||||
.map_or((None, None), |(key, fingerprint)| {
|
.map_or((None, None), |(key, fingerprint)| {
|
||||||
(Some(key), Some(fingerprint))
|
(Some(key), Some(fingerprint))
|
||||||
});
|
});
|
||||||
|
let workspace_api = match required_worker_workspace_api(&request) {
|
||||||
|
Ok(workspace_api) => workspace_api,
|
||||||
|
Err(diagnostic) => {
|
||||||
|
diagnostics.push(diagnostic);
|
||||||
|
return WorkerSpawnResult {
|
||||||
|
state: WorkerOperationState::Rejected,
|
||||||
|
worker: None,
|
||||||
|
acceptance_evidence: Vec::new(),
|
||||||
|
diagnostics,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
};
|
||||||
let create_request = CreateWorkerRequest {
|
let create_request = CreateWorkerRequest {
|
||||||
idempotency_key,
|
idempotency_key,
|
||||||
idempotency_fingerprint,
|
idempotency_fingerprint,
|
||||||
@@ -1737,16 +1836,7 @@ impl WorkspaceWorkerRuntime for EmbeddedWorkerRuntime {
|
|||||||
initial_input: request.initial_input.clone(),
|
initial_input: request.initial_input.clone(),
|
||||||
working_directory_request: request.resolved_working_directory_request.clone(),
|
working_directory_request: request.resolved_working_directory_request.clone(),
|
||||||
working_directory: request.resolved_working_directory.clone(),
|
working_directory: request.resolved_working_directory.clone(),
|
||||||
workspace_api: request.resolved_workspace_api.clone().or_else(|| {
|
workspace_api: Some(workspace_api),
|
||||||
self.backend_base_url
|
|
||||||
.as_ref()
|
|
||||||
.map(|base_url| WorkspaceApiRef {
|
|
||||||
workspace_id: self.workspace_id.clone(),
|
|
||||||
base_url: base_url.clone(),
|
|
||||||
runtime_id: Some(self.runtime_id.clone()),
|
|
||||||
access_token: None,
|
|
||||||
})
|
|
||||||
}),
|
|
||||||
};
|
};
|
||||||
match self.runtime.create_worker(create_request) {
|
match self.runtime.create_worker(create_request) {
|
||||||
Ok(detail) => WorkerSpawnResult {
|
Ok(detail) => WorkerSpawnResult {
|
||||||
@@ -2602,6 +2692,28 @@ impl WorkspaceWorkerRuntime for RemoteWorkerRuntime {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn replace_worker_workspace_api(
|
||||||
|
&self,
|
||||||
|
worker_id: &str,
|
||||||
|
workspace_api: WorkspaceApiRef,
|
||||||
|
) -> WorkerWorkspaceApiResult {
|
||||||
|
match self.post_json::<_, RuntimeHttpWorkerResponse>(
|
||||||
|
&format!("/v1/workers/{worker_id}/workspace-api"),
|
||||||
|
&RuntimeHttpWorkerWorkspaceApiRequest { workspace_api },
|
||||||
|
) {
|
||||||
|
Ok(response) => WorkerWorkspaceApiResult {
|
||||||
|
state: WorkerOperationState::Accepted,
|
||||||
|
worker: Some(self.map_worker_detail(response.worker)),
|
||||||
|
diagnostics: Vec::new(),
|
||||||
|
},
|
||||||
|
Err(diagnostic) => WorkerWorkspaceApiResult {
|
||||||
|
state: WorkerOperationState::Rejected,
|
||||||
|
worker: None,
|
||||||
|
diagnostics: vec![diagnostic],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn create_working_directory(
|
fn create_working_directory(
|
||||||
&self,
|
&self,
|
||||||
request: WorkingDirectoryRequest,
|
request: WorkingDirectoryRequest,
|
||||||
@@ -2711,6 +2823,17 @@ impl WorkspaceWorkerRuntime for RemoteWorkerRuntime {
|
|||||||
.map_or((None, None), |(key, fingerprint)| {
|
.map_or((None, None), |(key, fingerprint)| {
|
||||||
(Some(key), Some(fingerprint))
|
(Some(key), Some(fingerprint))
|
||||||
});
|
});
|
||||||
|
let workspace_api = match required_worker_workspace_api(&request) {
|
||||||
|
Ok(workspace_api) => workspace_api,
|
||||||
|
Err(diagnostic) => {
|
||||||
|
return WorkerSpawnResult {
|
||||||
|
state: WorkerOperationState::Rejected,
|
||||||
|
worker: None,
|
||||||
|
acceptance_evidence: Vec::new(),
|
||||||
|
diagnostics: vec![diagnostic],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
};
|
||||||
let create = CreateWorkerRequest {
|
let create = CreateWorkerRequest {
|
||||||
idempotency_key,
|
idempotency_key,
|
||||||
idempotency_fingerprint,
|
idempotency_fingerprint,
|
||||||
@@ -2721,14 +2844,7 @@ impl WorkspaceWorkerRuntime for RemoteWorkerRuntime {
|
|||||||
initial_input: request.initial_input.clone(),
|
initial_input: request.initial_input.clone(),
|
||||||
working_directory_request: request.resolved_working_directory_request.clone(),
|
working_directory_request: request.resolved_working_directory_request.clone(),
|
||||||
working_directory: request.resolved_working_directory.clone(),
|
working_directory: request.resolved_working_directory.clone(),
|
||||||
workspace_api: request.resolved_workspace_api.clone().or_else(|| {
|
workspace_api: Some(workspace_api),
|
||||||
Some(WorkspaceApiRef {
|
|
||||||
workspace_id: self.workspace_id.clone(),
|
|
||||||
base_url: self.backend_base_url.clone(),
|
|
||||||
runtime_id: Some(self.runtime_id.clone()),
|
|
||||||
access_token: None,
|
|
||||||
})
|
|
||||||
}),
|
|
||||||
};
|
};
|
||||||
match self.post_json::<_, RuntimeHttpWorkerResponse>("/v1/workers", &create) {
|
match self.post_json::<_, RuntimeHttpWorkerResponse>("/v1/workers", &create) {
|
||||||
Ok(response) => WorkerSpawnResult {
|
Ok(response) => WorkerSpawnResult {
|
||||||
@@ -3700,6 +3816,15 @@ mod tests {
|
|||||||
use std::sync::{Arc, Mutex};
|
use std::sync::{Arc, Mutex};
|
||||||
use std::thread;
|
use std::thread;
|
||||||
|
|
||||||
|
fn test_workspace_api() -> WorkspaceApiRef {
|
||||||
|
WorkspaceApiRef {
|
||||||
|
workspace_id: "workspace-test".to_string(),
|
||||||
|
base_url: "http://127.0.0.1:8787".to_string(),
|
||||||
|
runtime_id: Some("runtime-test".to_string()),
|
||||||
|
access_token: Some("workspace-access-token".to_string()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn embedded_builtin_decodal_profiles_resolve_through_archive() {
|
fn embedded_builtin_decodal_profiles_resolve_through_archive() {
|
||||||
let root = tempfile::tempdir().unwrap();
|
let root = tempfile::tempdir().unwrap();
|
||||||
@@ -4222,10 +4347,31 @@ mod tests {
|
|||||||
resolved_working_directory_request: None,
|
resolved_working_directory_request: None,
|
||||||
resolved_working_directory: None,
|
resolved_working_directory: None,
|
||||||
resolved_config_bundle: None,
|
resolved_config_bundle: None,
|
||||||
resolved_workspace_api: None,
|
resolved_workspace_api: Some(test_workspace_api()),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn embedded_runtime_rejects_tokenless_workspace_spawn() {
|
||||||
|
let runtime = EmbeddedWorkerRuntime::new_memory_with_execution_backend(
|
||||||
|
"local:test",
|
||||||
|
Arc::new(AcceptingExecutionBackend::default()),
|
||||||
|
)
|
||||||
|
.expect("test backend should connect");
|
||||||
|
let mut request = embedded_spawn_request();
|
||||||
|
request.resolved_workspace_api = None;
|
||||||
|
|
||||||
|
let spawned = runtime.spawn_worker(request);
|
||||||
|
|
||||||
|
assert_eq!(spawned.state, WorkerOperationState::Rejected);
|
||||||
|
assert!(
|
||||||
|
spawned
|
||||||
|
.diagnostics
|
||||||
|
.iter()
|
||||||
|
.any(|diagnostic| { diagnostic.code == "worker_workspace_credential_missing" })
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn embedded_runtime_spawn_execution_failure_is_rejected_and_not_input_capable() {
|
fn embedded_runtime_spawn_execution_failure_is_rejected_and_not_input_capable() {
|
||||||
let runtime = EmbeddedWorkerRuntime::new_memory_with_execution_backend(
|
let runtime = EmbeddedWorkerRuntime::new_memory_with_execution_backend(
|
||||||
@@ -4350,7 +4496,7 @@ mod tests {
|
|||||||
resolved_working_directory_request: None,
|
resolved_working_directory_request: None,
|
||||||
resolved_working_directory: None,
|
resolved_working_directory: None,
|
||||||
resolved_config_bundle: None,
|
resolved_config_bundle: None,
|
||||||
resolved_workspace_api: None,
|
resolved_workspace_api: Some(test_workspace_api()),
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
@@ -4448,7 +4594,7 @@ mod tests {
|
|||||||
resolved_working_directory_request: None,
|
resolved_working_directory_request: None,
|
||||||
resolved_working_directory: None,
|
resolved_working_directory: None,
|
||||||
resolved_config_bundle: None,
|
resolved_config_bundle: None,
|
||||||
resolved_workspace_api: None,
|
resolved_workspace_api: Some(test_workspace_api()),
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
@@ -4482,7 +4628,7 @@ mod tests {
|
|||||||
resolved_working_directory_request: None,
|
resolved_working_directory_request: None,
|
||||||
resolved_working_directory: None,
|
resolved_working_directory: None,
|
||||||
resolved_config_bundle: None,
|
resolved_config_bundle: None,
|
||||||
resolved_workspace_api: None,
|
resolved_workspace_api: Some(test_workspace_api()),
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|||||||
+1001
-185
File diff suppressed because it is too large
Load Diff
@@ -650,6 +650,14 @@ pub trait ControlPlaneStore: Send + Sync {
|
|||||||
new_token: &str,
|
new_token: &str,
|
||||||
new_expires_at: &str,
|
new_expires_at: &str,
|
||||||
) -> Result<Option<WorkerWorkspaceCredentialRecord>>;
|
) -> Result<Option<WorkerWorkspaceCredentialRecord>>;
|
||||||
|
fn revoke_worker_workspace_credentials_except(
|
||||||
|
&self,
|
||||||
|
workspace_id: &str,
|
||||||
|
runtime_id: &str,
|
||||||
|
worker_id: &str,
|
||||||
|
active_credential_id: &str,
|
||||||
|
revoked_at: &str,
|
||||||
|
) -> Result<()>;
|
||||||
fn revoke_worker_workspace_credentials(
|
fn revoke_worker_workspace_credentials(
|
||||||
&self,
|
&self,
|
||||||
workspace_id: &str,
|
workspace_id: &str,
|
||||||
@@ -2338,6 +2346,31 @@ impl ControlPlaneStore for SqliteWorkspaceStore {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn revoke_worker_workspace_credentials_except(
|
||||||
|
&self,
|
||||||
|
workspace_id: &str,
|
||||||
|
runtime_id: &str,
|
||||||
|
worker_id: &str,
|
||||||
|
active_credential_id: &str,
|
||||||
|
revoked_at: &str,
|
||||||
|
) -> Result<()> {
|
||||||
|
self.with_conn(|conn| {
|
||||||
|
conn.execute(
|
||||||
|
r#"UPDATE worker_workspace_credentials SET revoked_at = ?5
|
||||||
|
WHERE workspace_id = ?1 AND runtime_id = ?2 AND worker_id = ?3
|
||||||
|
AND credential_id <> ?4 AND revoked_at IS NULL"#,
|
||||||
|
params![
|
||||||
|
workspace_id,
|
||||||
|
runtime_id,
|
||||||
|
worker_id,
|
||||||
|
active_credential_id,
|
||||||
|
revoked_at
|
||||||
|
],
|
||||||
|
)?;
|
||||||
|
Ok(())
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
fn revoke_worker_workspace_credentials(
|
fn revoke_worker_workspace_credentials(
|
||||||
&self,
|
&self,
|
||||||
workspace_id: &str,
|
workspace_id: &str,
|
||||||
|
|||||||
Reference in New Issue
Block a user