flow: own worker flow state in runtime sessions

This commit is contained in:
2026-08-09 18:04:32 +09:00
parent c0532fda4e
commit 068ba9afa5
46 changed files with 5112 additions and 76 deletions
+319 -9
View File
@@ -12,6 +12,7 @@ use axum::response::{IntoResponse, Response};
use axum::routing::{delete, get, patch, post, put};
use axum::{Json, Router};
use chrono::{Duration, SecondsFormat, Utc};
use flow::{FlowSourceKind, FlowSourceResolveRequest, ResolvedFlowSource};
use futures::{SinkExt, StreamExt};
use memory::backend::{
MemoryBackendHttpResponse, MemoryBackendOperation, MemoryConsolidateStagingOperation,
@@ -96,9 +97,9 @@ use crate::runtime_subscription::RuntimeSubscriptionBroker;
use crate::skills;
use crate::store::{
AccountRecord, ApiTokenRecord, AuthChallengeRecord, BrowserSessionRecord, ControlPlaneStore,
DeviceLoginFlowRecord, PasskeyCredentialRecord, RepositoryRecord, TicketWorkerAssignmentRecord,
UserRecord, WorkdirRegistryRecord, WorkerRegistryRecord, WorkerWorkdirLinkRecord,
WorkspaceRecord,
DeviceLoginFlowRecord, FlowSourceRecord, PasskeyCredentialRecord, RepositoryRecord,
TicketWorkerAssignmentRecord, UserRecord, WorkdirRegistryRecord, WorkerRegistryRecord,
WorkerWorkdirLinkRecord, WorkspaceRecord,
};
use crate::{Error, Result};
use worker_runtime::catalog::{
@@ -700,6 +701,18 @@ pub fn build_router(api: WorkspaceApi) -> Router {
.put(scoped_update_profile_source)
.delete(scoped_delete_profile_source),
)
.route(
"/api/w/{workspace_id}/flows",
get(scoped_list_flows).put(scoped_put_flow),
)
.route(
"/api/w/{workspace_id}/flows/resolve",
post(scoped_resolve_flow_source),
)
.route(
"/api/w/{workspace_id}/flows/{flow_id}",
get(scoped_get_flow),
)
.route("/api/tickets", get(list_tickets))
.route(
"/api/w/{workspace_id}/tickets",
@@ -1669,6 +1682,19 @@ struct ScopedWorkspacePath {
workspace_id: String,
}
#[derive(Debug, Deserialize)]
struct ScopedFlowPath {
workspace_id: String,
flow_id: String,
}
#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields)]
struct PutFlowRequest {
path: String,
content: String,
}
#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields)]
struct AttachCurrentWorkerWorkdirRequest {
@@ -1753,6 +1779,107 @@ fn validate_workspace_scope(api: &WorkspaceApi, workspace_id: &str) -> ApiResult
}
}
async fn scoped_list_flows(
State(api): State<WorkspaceApi>,
AxumPath(path): AxumPath<ScopedWorkspacePath>,
) -> ApiResult<Json<Vec<FlowSourceRecord>>> {
validate_workspace_scope(&api, &path.workspace_id)?;
Ok(Json(api.store.list_flow_sources(&path.workspace_id)?))
}
async fn scoped_put_flow(
State(api): State<WorkspaceApi>,
AxumPath(path): AxumPath<ScopedWorkspacePath>,
Json(request): Json<PutFlowRequest>,
) -> ApiResult<Json<FlowSourceRecord>> {
validate_workspace_scope(&api, &path.workspace_id)?;
let definition = flow::compile_flow_source(&request.content).map_err(|error| {
Error::InvalidInput(format!(
"invalid Flow source: {}",
serde_json::to_string(&error.diagnostics)
.unwrap_or_else(|_| "diagnostics unavailable".to_string())
))
})?;
let expected_path = format!("flows/{}.dcdl", definition.name);
if request.path != expected_path {
return Err(
Error::InvalidInput(format!("Flow source path must be `{expected_path}`")).into(),
);
}
let now = Utc::now().to_rfc3339_opts(SecondsFormat::Millis, true);
Ok(Json(api.store.put_flow_source_for_kind(
&path.workspace_id,
FlowSourceKind::Workspace,
&request.path,
&request.content,
&now,
)?))
}
async fn scoped_resolve_flow_source(
State(api): State<WorkspaceApi>,
AxumPath(path): AxumPath<ScopedWorkspacePath>,
Json(request): Json<FlowSourceResolveRequest>,
) -> ApiResult<Json<ResolvedFlowSource>> {
validate_workspace_scope(&api, &path.workspace_id)?;
let resolved = match &request.selector {
flow::FlowSelector::Builtin { slug } => {
let builtin = flow::builtin_flow_source(slug)
.ok_or_else(|| Error::InvalidRecordId(request.selector.to_string()))?;
let definition = builtin.compile().map_err(|error| {
Error::Store(format!(
"compile built-in Flow {slug:?}: {:?}",
error.diagnostics
))
})?;
ResolvedFlowSource {
selector: request.selector.clone(),
workspace_id: path.workspace_id,
flow_id: format!("builtin:{slug}"),
revision: builtin.revision,
content_digest: definition.content_digest.clone(),
definition,
}
}
flow::FlowSelector::Workspace { slug } => {
let source = api
.store
.get_flow_source_by_name(&path.workspace_id, FlowSourceKind::Workspace, slug)?
.ok_or_else(|| Error::InvalidRecordId(request.selector.to_string()))?;
let revision = api
.store
.get_flow_source_revision(&path.workspace_id, &source.flow_id, source.revision)?
.ok_or_else(|| {
Error::Store(format!(
"resolved Flow revision {}@{} is missing",
source.flow_id, source.revision
))
})?;
ResolvedFlowSource {
selector: request.selector.clone(),
workspace_id: path.workspace_id,
flow_id: source.flow_id,
revision: source.revision,
content_digest: revision.content_digest,
definition: revision.definition,
}
}
};
Ok(Json(resolved))
}
async fn scoped_get_flow(
State(api): State<WorkspaceApi>,
AxumPath(path): AxumPath<ScopedFlowPath>,
) -> ApiResult<Json<FlowSourceRecord>> {
validate_workspace_scope(&api, &path.workspace_id)?;
let source = api
.store
.get_flow_source(&path.workspace_id, &path.flow_id)?
.ok_or_else(|| Error::InvalidRecordId(path.flow_id))?;
Ok(Json(source))
}
async fn scoped_get_workspace(
State(api): State<WorkspaceApi>,
AxumPath(path): AxumPath<ScopedWorkspacePath>,
@@ -3157,7 +3284,7 @@ fn notify_ticket_recipients(
fn authenticate_worker_mutation_source(
api: &WorkspaceApi,
_workspace_id: &str,
workspace_id: &str,
headers: &HeaderMap,
) -> Result<WorkerMutationSource> {
let runtime_id = headers
@@ -3173,9 +3300,14 @@ fn authenticate_worker_mutation_source(
Error::WorkerSourceIdentity("missing Runtime-bound Worker id".to_string())
})?;
let worker = RuntimeWorkerRef::new(runtime_id, worker_id);
api.runtime.worker(&worker).map_err(|_| {
let summary = api.runtime.worker(&worker).map_err(|_| {
Error::WorkerSourceIdentity("Runtime-bound Worker identity does not exist".to_string())
})?;
if summary.workspace.workspace_id.as_deref() != Some(workspace_id) {
return Err(Error::WorkerSourceIdentity(format!(
"Runtime-bound Worker is not scoped to Workspace {workspace_id}"
)));
}
Ok(worker)
}
@@ -6757,7 +6889,7 @@ async fn create_workspace_worker(
} else {
Some(EmbeddedWorkerInput {
kind: EmbeddedWorkerInputKind::User,
content: initial_text,
content: initial_text.clone(),
segments: None,
})
};
@@ -6776,8 +6908,9 @@ async fn create_workspace_worker(
if resolved_working_directory.is_none() {
reject_no_workdir_for_non_embedded_runtime(&request.runtime_id)?;
}
let runtime_id = request.runtime_id.clone();
let result = api.spawn_workspace_worker(
&request.runtime_id,
&runtime_id,
WorkerSpawnRequest {
requested_worker_name: Some(display_name.clone()),
intent: WorkerSpawnIntent::WorkspaceCoding,
@@ -6798,7 +6931,7 @@ async fn create_workspace_worker(
)?;
Ok(Json(record_browser_worker_spawn(
&api,
request.runtime_id,
runtime_id,
display_name,
selected_working_directory_id,
result,
@@ -8949,6 +9082,7 @@ fn worker_summary_from_registry(record: &WorkerRegistryRecord) -> WorkerSummary
workspace: WorkerWorkspaceSummary {
visibility: "backend_registry".to_string(),
identity: record.workspace_id.clone(),
workspace_id: Some(record.workspace_id.clone()),
},
profile: record.profile.clone(),
implementation: WorkerImplementationSummary {
@@ -9729,7 +9863,7 @@ impl IntoResponse for ApiError {
Error::TicketAssignmentConflict(_) | Error::WorkdirAttachmentConflict(_) => {
StatusCode::CONFLICT
}
Error::WorkerSourceIdentity(_) => StatusCode::BAD_REQUEST,
Error::WorkerSourceIdentity(_) | Error::InvalidInput(_) => StatusCode::BAD_REQUEST,
Error::InvalidRuntimeIdentifier { .. } | Error::ReservedWorkerName(_) => {
StatusCode::BAD_REQUEST
}
@@ -10008,6 +10142,107 @@ mod tests {
assert!(!serialized.contains("materialized_path"));
}
#[tokio::test]
async fn flow_source_resolution_returns_immutable_workspace_and_builtin_snapshots() {
let workspace = tempfile::tempdir().unwrap();
init_clean_git_workspace(workspace.path());
let api = test_api(workspace.path()).await;
let source = r#"{
schema_version = 1;
name = "browser-flow";
initial = "work";
states = {
work = {
instructions = "Implement and validate the requested change.";
transitions = {
done = { target = "done"; condition = "The work is complete."; };
};
};
done = { instructions = ""; terminal = true; };
};
}"#;
let stored = api
.store
.put_flow_source_for_kind(
&api.config.workspace_id,
FlowSourceKind::Workspace,
"flows/browser-flow.dcdl",
source,
"2026-08-06T00:00:00Z",
)
.unwrap();
let Json(resolved) = scoped_resolve_flow_source(
State(api.clone()),
AxumPath(ScopedWorkspacePath {
workspace_id: api.config.workspace_id.clone(),
}),
Json(FlowSourceResolveRequest {
selector: "workspace:browser-flow".parse().unwrap(),
}),
)
.await
.unwrap();
assert_eq!(resolved.flow_id, stored.flow_id);
assert_eq!(resolved.revision, stored.revision);
assert_eq!(resolved.content_digest, stored.content_digest);
assert_eq!(resolved.definition.name, "browser-flow");
let Json(builtin) = scoped_resolve_flow_source(
State(api.clone()),
AxumPath(ScopedWorkspacePath {
workspace_id: api.config.workspace_id.clone(),
}),
Json(FlowSourceResolveRequest {
selector: "builtin:coder-review".parse().unwrap(),
}),
)
.await
.unwrap();
assert_eq!(builtin.definition.name, "coder-review");
assert_eq!(builtin.selector.to_string(), "builtin:coder-review");
assert_eq!(builtin.flow_id, "builtin:coder-review");
assert_eq!(builtin.revision, 1);
assert_eq!(
api.store
.list_flow_sources(&api.config.workspace_id)
.unwrap(),
vec![stored],
"built-in resolution must not mutate Workspace source authority",
);
}
#[tokio::test]
async fn worker_source_auth_rejects_cross_workspace_mutation() {
let workspace = tempfile::tempdir().unwrap();
init_clean_git_workspace(workspace.path());
let api = test_api(workspace.path()).await;
let Json(created) = create_workspace_worker(
State(api.clone()),
Json(BrowserCreateWorkerRequest {
runtime_id: EMBEDDED_WORKER_RUNTIME_ID.to_string(),
display_name: "Scoped Worker".to_string(),
profile: Some("builtin:coder".to_string()),
initial_text: String::new(),
working_directory: None,
}),
)
.await
.unwrap();
let mut headers = HeaderMap::new();
headers.insert(
"x-yoi-runtime-id",
axum::http::HeaderValue::from_str(&created.worker_ref.runtime_id).unwrap(),
);
headers.insert(
"x-yoi-worker-id",
axum::http::HeaderValue::from_str(&created.worker_ref.worker_id).unwrap(),
);
let error =
authenticate_worker_mutation_source(&api, "other-workspace", &headers).unwrap_err();
assert!(matches!(error, Error::WorkerSourceIdentity(_)));
}
#[tokio::test]
async fn explicit_orchestrator_launch_marks_only_the_dedicated_worker() {
let workspace = tempfile::tempdir().unwrap();
@@ -14375,6 +14610,81 @@ mod tests {
server.abort();
}
#[tokio::test]
async fn scoped_flow_source_route_persists_compiled_dcdl() {
let temp = tempfile::tempdir().unwrap();
let app = test_app(temp.path()).await;
let workspace_id = test_identity().workspace_id;
let source = r#"{
schema_version = 1;
name = "route-flow";
initial = "work";
states = {
work = {
instructions = "Do the work.";
transitions = {
done = {
target = "done";
condition = "The work is complete.";
};
};
};
done = { instructions = ""; terminal = true; };
};
}"#;
let response = app
.clone()
.oneshot(
Request::builder()
.method(Method::PUT)
.uri(format!("/api/w/{workspace_id}/flows"))
.header(CONTENT_TYPE, "application/json")
.body(Body::from(
serde_json::json!({
"path": "flows/route-flow.dcdl",
"content": source,
})
.to_string(),
))
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let response = app
.clone()
.oneshot(
Request::builder()
.method(Method::GET)
.uri(format!("/api/w/{workspace_id}/flows"))
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let response = app
.oneshot(
Request::builder()
.method(Method::PUT)
.uri(format!("/api/w/{workspace_id}/flows"))
.header(CONTENT_TYPE, "application/json")
.body(Body::from(
serde_json::json!({
"path": "flows/broken.dcdl",
"content": "{ schema_version = 1; }",
})
.to_string(),
))
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::BAD_REQUEST);
}
#[tokio::test]
async fn passkey_registration_rejects_unverified_credential_response() {
let dir = tempfile::tempdir().unwrap();