use actix_web::{http::StatusCode, web, HttpResponse};
use bamboo_skills::legacy::{LegacySyncOutcome, LegacyWorkflowMigrationOutcome};
use bamboo_skills::types::SkillDefinition;
use bamboo_skills::LegacyWorkflowMigrationStatus;
use tokio::fs;
use tokio::io::AsyncWriteExt;
use crate::{app_state::AppState, error::AppError};
use super::types::{
MigrateWorkflowRequest, MigrateWorkflowResponse, SaveWorkflowRequest, WorkflowCatalogQuery,
WorkflowGetResponse, WorkflowListItem,
};
use super::validation::is_safe_workflow_name;
fn legacy_workflow_io_lock() -> &'static tokio::sync::Mutex<()> {
static LOCK: std::sync::OnceLock<tokio::sync::Mutex<()>> = std::sync::OnceLock::new();
LOCK.get_or_init(|| tokio::sync::Mutex::new(()))
}
async fn workspace_skills_dir(workspace: &std::path::Path) -> Result<std::path::PathBuf, AppError> {
let workspace = tokio::fs::canonicalize(workspace).await?;
let bamboo_dir = workspace.join(".bamboo");
let skills_dir = bamboo_dir.join("skills");
for directory in [&bamboo_dir, &skills_dir] {
match tokio::fs::symlink_metadata(directory).await {
Ok(metadata) if metadata.file_type().is_symlink() || !metadata.is_dir() => {
return Err(AppError::Forbidden(format!(
"Workspace publication directory '{}' must be a real directory",
directory
.strip_prefix(&workspace)
.unwrap_or(directory)
.display()
)));
}
Ok(_) => {}
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
tokio::fs::create_dir(directory).await?;
}
Err(error) => return Err(AppError::StorageError(error)),
}
let canonical = tokio::fs::canonicalize(directory).await?;
if !canonical.starts_with(&workspace) {
return Err(AppError::Forbidden(
"Workspace publication directory escapes the trusted workspace".to_string(),
));
}
}
tokio::fs::canonicalize(skills_dir)
.await
.map_err(AppError::StorageError)
}
pub async fn list_workflow_catalog(
app_state: web::Data<AppState>,
query: web::Query<WorkflowCatalogQuery>,
) -> Result<HttpResponse, AppError> {
let snapshot = if let Some(session_id) = query
.session_id
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
{
let session = app_state
.load_session(session_id)
.await
.ok_or_else(|| AppError::NotFound(format!("Session '{session_id}'")))?;
let project_id =
match bamboo_engine::project_context::ProjectContextResolver::session_project_identity(
&session,
) {
bamboo_engine::project_context::SessionProjectIdentity::Assigned(project_id) => {
Some(project_id)
}
bamboo_engine::project_context::SessionProjectIdentity::Unassigned => None,
bamboo_engine::project_context::SessionProjectIdentity::Invalid {
raw,
message,
} => {
return Err(AppError::BadRequest(format!(
"Session carries an invalid Project identity '{raw}': {message}"
)));
}
};
let workspace = crate::project_context::validate_workspace_assignment(
&app_state.project_store,
project_id.as_ref(),
session.workspace_path_meta().as_deref(),
)
.map_err(|error| match error {
crate::project_context::ProjectWorkspaceValidationError::Invalid { .. }
| crate::project_context::ProjectWorkspaceValidationError::Conflict { .. } => {
AppError::BadRequest(error.to_string())
}
crate::project_context::ProjectWorkspaceValidationError::Store(error) => {
AppError::InternalError(anyhow::anyhow!(error))
}
})?;
if let Some(project_id) = project_id {
app_state.project_store.get(&project_id).map_err(|error| {
AppError::BadRequest(format!("Assigned Project is unavailable: {error}"))
})?;
let project_home = app_state.project_store.paths().project_home(&project_id);
app_state
.skill_manager
.workflow_catalog_for_project_workspace(
&project_id,
&project_home,
workspace.as_deref(),
)
.await
.map_err(|error| AppError::InternalError(anyhow::anyhow!(error)))?
} else if let Some(workspace) = workspace.as_ref() {
app_state
.skill_manager
.store()
.workflow_catalog_for_workspace(workspace)
.await
.map_err(|error| AppError::InternalError(anyhow::anyhow!(error)))?
} else {
app_state
.skill_manager
.store()
.workflow_catalog_snapshot()
.await
}
} else {
app_state
.skill_manager
.store()
.workflow_catalog_snapshot()
.await
};
Ok(HttpResponse::Ok()
.insert_header(("Cache-Control", "no-store"))
.json(snapshot))
}
pub async fn migrate_workflow(
app_state: web::Data<AppState>,
workflow_id: web::Path<String>,
payload: web::Json<MigrateWorkflowRequest>,
) -> Result<HttpResponse, AppError> {
let workflow_id = workflow_id.into_inner();
let session_id = payload.session_id.trim();
if session_id.is_empty() {
return Err(AppError::BadRequest("session_id is required".to_string()));
}
let session = app_state
.load_session(session_id)
.await
.ok_or_else(|| AppError::NotFound(format!("Session '{session_id}'")))?;
let project_id =
match bamboo_engine::project_context::ProjectContextResolver::session_project_identity(
&session,
) {
bamboo_engine::project_context::SessionProjectIdentity::Assigned(project_id) => {
Some(project_id)
}
bamboo_engine::project_context::SessionProjectIdentity::Unassigned => None,
bamboo_engine::project_context::SessionProjectIdentity::Invalid { raw, message } => {
return Err(AppError::BadRequest(format!(
"Session carries an invalid Project identity '{raw}': {message}"
)));
}
};
let workspace = crate::project_context::validate_workspace_assignment(
&app_state.project_store,
project_id.as_ref(),
session.workspace_path_meta().as_deref(),
)
.map_err(|error| match error {
crate::project_context::ProjectWorkspaceValidationError::Invalid { .. }
| crate::project_context::ProjectWorkspaceValidationError::Conflict { .. } => {
AppError::BadRequest(error.to_string())
}
crate::project_context::ProjectWorkspaceValidationError::Store(error) => {
AppError::InternalError(anyhow::anyhow!(error))
}
})?
.ok_or_else(|| {
AppError::BadRequest("Legacy workflow migration requires a session workspace".to_string())
})?;
let store = if let Some(project_id) = project_id {
app_state.project_store.get(&project_id).map_err(|error| {
AppError::BadRequest(format!("Assigned Project is unavailable: {error}"))
})?;
let project_home = app_state.project_store.paths().project_home(&project_id);
app_state
.skill_manager
.store_for_project_workspace(&project_id, &project_home, Some(&workspace))
.await
} else {
app_state
.skill_manager
.store_for_workspace(Some(&workspace))
.await
}
.map_err(|error| AppError::InternalError(anyhow::anyhow!(error)))?;
store
.reload()
.await
.map_err(|error| AppError::InternalError(anyhow::anyhow!(error)))?;
let catalog = store.workflow_catalog_snapshot().await;
let entry = catalog
.entries
.iter()
.find(|entry| entry.id == workflow_id)
.ok_or_else(|| AppError::NotFound(format!("Workflow '{workflow_id}'")))?;
if entry.migration_status == Some(LegacyWorkflowMigrationStatus::Migrated) {
return Ok(HttpResponse::Ok()
.insert_header(("Cache-Control", "no-store"))
.json(MigrateWorkflowResponse {
workflow_id,
outcome: LegacyWorkflowMigrationOutcome::AlreadyMigrated,
source_preserved: true,
catalog_revision: catalog.revision,
}));
}
if entry.migration_status != Some(LegacyWorkflowMigrationStatus::Available) {
if entry.shadowed_candidates.iter().any(|candidate| {
candidate.migration_status == Some(LegacyWorkflowMigrationStatus::Available)
}) {
return Ok(crate::error::json_error(
StatusCode::CONFLICT,
format!(
"Workflow '{workflow_id}' already has a target Skill bundle; it was not overwritten"
),
));
}
return Err(AppError::BadRequest(format!(
"Workflow '{workflow_id}' is not a migratable legacy workflow"
)));
}
let source = store.get_skill_root(&workflow_id).await.map_err(|error| {
AppError::BadRequest(format!("Legacy workflow is unavailable: {error}"))
})?;
let source = tokio::fs::canonicalize(&source).await.map_err(|error| {
AppError::BadRequest(format!("Legacy workflow source is unavailable: {error}"))
})?;
let canonical_workspace = tokio::fs::canonicalize(&workspace).await?;
let workspace_legacy = workspace.join(".bamboo/workflows");
let workspace_source_identity = match tokio::fs::canonicalize(&workspace_legacy).await {
Ok(root) => {
if root.starts_with(&canonical_workspace) && source.parent() == Some(root.as_path()) {
source
.file_name()
.and_then(|filename| filename.to_str())
.map(|filename| format!(".bamboo/workflows/{filename}"))
} else {
None
}
}
Err(error) if error.kind() == std::io::ErrorKind::NotFound => None,
Err(error) => return Err(AppError::StorageError(error)),
};
let plugin_source_identity =
match tokio::fs::canonicalize(app_state.app_data_dir.join("plugins")).await {
Ok(root) => source.strip_prefix(&root).ok().and_then(|relative| {
let components: Vec<_> = relative.components().collect();
if components.len() != 3 || components[1].as_os_str() != "workflows" {
return None;
}
Some(format!(
"plugins/{}/workflows/{}",
components[0].as_os_str().to_str()?,
components[2].as_os_str().to_str()?
))
}),
Err(error) if error.kind() == std::io::ErrorKind::NotFound => None,
Err(error) => return Err(AppError::StorageError(error)),
};
let source_identity = workspace_source_identity
.or(plugin_source_identity)
.ok_or_else(|| {
AppError::Forbidden(
"Legacy workflow source is outside a migratable workspace/plugin scope".to_string(),
)
})?;
let skills_dir = workspace_skills_dir(&canonical_workspace).await?;
let outcome = bamboo_skills::legacy::migrate_legacy_markdown_workflow(
&source,
&source_identity,
&skills_dir,
&workflow_id,
payload.description.as_deref(),
)
.await
.map_err(|error| AppError::BadRequest(format!("Legacy workflow migration failed: {error}")))?;
if outcome == LegacyWorkflowMigrationOutcome::Conflict {
return Ok(crate::error::json_error(
StatusCode::CONFLICT,
format!(
"Workflow '{workflow_id}' already has a target Skill bundle; it was not overwritten"
),
));
}
store
.reload()
.await
.map_err(|error| AppError::InternalError(anyhow::anyhow!(error)))?;
let revision = store.workflow_catalog_snapshot().await.revision;
Ok(HttpResponse::Ok()
.insert_header(("Cache-Control", "no-store"))
.json(MigrateWorkflowResponse {
workflow_id,
outcome,
source_preserved: true,
catalog_revision: revision,
}))
}
pub async fn list_workflows(app_state: web::Data<AppState>) -> Result<HttpResponse, AppError> {
let workflows_dir = app_state.app_data_dir.join("workflows");
let mut workflows: Vec<WorkflowListItem> = app_state
.skill_manager
.store()
.list_skills(None, false)
.await
.into_iter()
.filter(|skill| legacy_source(skill, &workflows_dir).is_some())
.map(|skill| WorkflowListItem {
name: legacy_name(&skill).to_string(),
filename: format!("{}.md", legacy_name(&skill)),
size: skill.prompt.len() as u64,
modified_at: None,
})
.collect();
workflows.sort_by(|left, right| left.name.cmp(&right.name));
Ok(legacy_response().json(workflows))
}
pub async fn get_workflow(
app_state: web::Data<AppState>,
workflow_name: web::Path<String>,
) -> Result<HttpResponse, AppError> {
let name = workflow_name.into_inner();
if !is_safe_workflow_name(&name) {
return Err(AppError::BadRequest("Invalid workflow name".to_string()));
}
let dir = app_state.app_data_dir.join("workflows");
let filename = format!("{name}.md");
let skill_id = bamboo_skills::legacy::legacy_workflow_skill_id(&name);
let skill = app_state
.skill_manager
.store()
.get_skill(&skill_id)
.await
.map_err(|_| AppError::NotFound(format!("Workflow '{name}'")))?;
if legacy_source(&skill, &dir).is_none() || legacy_name(&skill) != name {
return Err(AppError::NotFound(format!("Workflow '{name}'")));
}
let content = skill.prompt;
let size = content.len() as u64;
Ok(legacy_response().json(WorkflowGetResponse {
name,
filename,
content,
size,
modified_at: None,
}))
}
pub async fn save_workflow(
app_state: web::Data<AppState>,
payload: web::Json<SaveWorkflowRequest>,
) -> Result<HttpResponse, AppError> {
let _io_guard = legacy_workflow_io_lock().lock().await;
let name = payload.name.trim();
if !is_safe_workflow_name(name) {
return Err(AppError::BadRequest("Invalid workflow name".to_string()));
}
let dir = app_state.app_data_dir.join("workflows");
fs::create_dir_all(&dir).await?;
let file_path = dir.join(format!("{}.md", name));
let skill_id = bamboo_skills::legacy::legacy_workflow_skill_id(name);
let preflight = bamboo_skills::legacy::legacy_bundle_preflight(
&file_path,
&app_state.app_data_dir.join("skills"),
&skill_id,
)
.await;
match preflight {
Ok(true) => {}
Ok(false) => {
return Err(AppError::BadRequest(format!(
"Workflow '{name}' conflicts with a non-legacy skill bundle"
)))
}
Err(error) => {
return Err(AppError::InternalError(anyhow::anyhow!(error)));
}
}
let temporary = dir.join(format!(".{name}.{}.tmp", uuid::Uuid::new_v4()));
let mut staging = fs::OpenOptions::new()
.write(true)
.create_new(true)
.open(&temporary)
.await?;
if let Err(error) = async {
staging.write_all(payload.content.as_bytes()).await?;
staging.flush().await?;
staging.sync_all().await?;
drop(staging);
bamboo_skills::legacy::atomic_replace_file(&temporary, &file_path).await
}
.await
{
let _ = fs::remove_file(&temporary).await;
return Err(error.into());
}
let outcome = bamboo_skills::legacy::sync_legacy_markdown_bundle(
&file_path,
&app_state.app_data_dir.join("skills"),
&skill_id,
&payload.content,
)
.await
.map_err(|error| AppError::InternalError(anyhow::anyhow!(error)))?;
if outcome == LegacySyncOutcome::Conflict {
return Err(AppError::BadRequest(format!(
"Workflow '{name}' ownership changed during update; source was committed and will not overwrite the bundle"
)));
}
app_state
.skill_manager
.store()
.reload()
.await
.map_err(|error| AppError::InternalError(anyhow::anyhow!(error)))?;
Ok(legacy_response().json(serde_json::json!({
"success": true,
"path": file_path.to_string_lossy(),
"catalog_revision": app_state.skill_manager.store().workflow_catalog_snapshot().await.revision,
})))
}
pub async fn delete_workflow(
app_state: web::Data<AppState>,
workflow_name: web::Path<String>,
) -> Result<HttpResponse, AppError> {
let _io_guard = legacy_workflow_io_lock().lock().await;
let name = workflow_name.into_inner();
if !is_safe_workflow_name(&name) {
return Err(AppError::BadRequest("Invalid workflow name".to_string()));
}
let dir = app_state.app_data_dir.join("workflows");
let file_path = dir.join(format!("{}.md", name));
let skill_id = bamboo_skills::legacy::legacy_workflow_skill_id(&name);
if !file_path.exists() {
return Err(AppError::NotFound(format!("Workflow '{}'", name)));
}
let removed_bundle = app_state
.skill_manager
.store()
.remove_legacy_workflow(&file_path, &skill_id)
.await
.map_err(|error| AppError::InternalError(anyhow::anyhow!(error)))?;
if !removed_bundle {
return Err(AppError::BadRequest(format!(
"Workflow '{name}' is not owned by the legacy adapter"
)));
}
Ok(legacy_response().json(serde_json::json!({ "success": true })))
}
fn legacy_source(skill: &SkillDefinition, workflows_dir: &std::path::Path) -> Option<String> {
let metadata = skill.metadata.as_ref()?;
if metadata
.get("legacy_import")
.and_then(|value| value.as_bool())
!= Some(true)
{
return None;
}
let source = metadata.get("original_source")?.as_str()?;
let expected = workflows_dir.join(format!("{}.md", legacy_name(skill)));
(std::path::Path::new(source) == expected).then(|| source.to_string())
}
fn legacy_name(skill: &SkillDefinition) -> &str {
skill
.metadata
.as_ref()
.and_then(|metadata| metadata.get("legacy_name"))
.and_then(|value| value.as_str())
.unwrap_or(&skill.id)
}
fn legacy_response() -> actix_web::HttpResponseBuilder {
let mut response = HttpResponse::Ok();
response
.insert_header(("Deprecation", "true"))
.insert_header(("Sunset", "2026-12-01"))
.insert_header((
"Link",
"</api/v1/bamboo/workflow-catalog>; rel=\"successor-version\"",
));
response
}