feat: publish Repository and Workdir WIP objects
Use Host root namespace ownership and disjoint Feature operation contributions. Separate read-only workdir_catalog from manage_workdir, encode all existing identities canonically, publish safe bounded inventory and lifetime-bound attachment Objects, and reuse normal lifecycle/session router/lock authority. Contextual interfaces resolve the current permission/capability intersection; object and interface validators reject stale cached access. Cover fresh workspace WIP-only discovery/create/attach/read/detach, readonly and unsupported targets, collision/revocation/alias reuse, active cleanup exclusion and terminal unknown outcomes without retries. Validation: WIP 40 tests; worker 730 unit + 96 integration tests serial; manifest 182 tests; workspace-root cargo check; cargo fmt --all -- --check; git diff --check HEAD.
This commit is contained in:
@@ -25,6 +25,8 @@ Does not own:
|
||||
|
||||
Profiles are reusable recipes; resolved Manifests are runtime contracts. Keep runtime-bound fields such as Worker name, concrete delegated scope, sockets, session pointers, and raw secrets out of reusable Profiles.
|
||||
|
||||
`feature.workdir_catalog.enabled` defaults to `true` for read-only Repository/Workdir/attachment reference discovery in WIP mode. It is independent of `feature.manage_workdir` (which defaults disabled), registers no normal Tools, and does not grant filesystem, attachment, or Workspace authority. Permissions remain independent. Standalone Tools-mode profiles may retain this default; only WIP installation consumes the flag. Set it to `false` to opt out.
|
||||
|
||||
## See also
|
||||
|
||||
- [`../../docs/design/profiles-manifests-prompts.md`](../../docs/design/profiles-manifests-prompts.md)
|
||||
|
||||
@@ -51,8 +51,9 @@ pub struct WorkerManifestConfig {
|
||||
/// is disabled; `Some` requires `default_action` during final resolve.
|
||||
#[serde(default)]
|
||||
pub permissions: Option<PermissionConfigPartial>,
|
||||
/// Explicit built-in feature/tool-surface enablement. Absent flags resolve
|
||||
/// disabled after cascade merge.
|
||||
/// Built-in feature/tool-surface enablement. Absent tool flags resolve
|
||||
/// disabled after cascade merge; the read-only WIP workdir catalog defaults
|
||||
/// enabled without granting authority.
|
||||
#[serde(default)]
|
||||
pub feature: FeatureConfigPartial,
|
||||
/// Explicit Model Context Protocol provider declarations. Config parsing
|
||||
@@ -95,6 +96,8 @@ pub struct FeatureConfigPartial {
|
||||
#[serde(default)]
|
||||
pub manage_workdir: Option<FeatureFlagConfigPartial>,
|
||||
#[serde(default)]
|
||||
pub workdir_catalog: Option<FeatureFlagConfigPartial>,
|
||||
#[serde(default)]
|
||||
pub ticket: Option<TicketFeatureConfigPartial>,
|
||||
#[serde(default)]
|
||||
pub merge_request: Option<MergeRequestFeatureConfigPartial>,
|
||||
@@ -136,6 +139,11 @@ impl FeatureConfigPartial {
|
||||
other.manage_workdir,
|
||||
FeatureFlagConfigPartial::merge,
|
||||
),
|
||||
workdir_catalog: merge_option(
|
||||
self.workdir_catalog,
|
||||
other.workdir_catalog,
|
||||
FeatureFlagConfigPartial::merge,
|
||||
),
|
||||
ticket: merge_option(self.ticket, other.ticket, TicketFeatureConfigPartial::merge),
|
||||
merge_request: merge_option(
|
||||
self.merge_request,
|
||||
@@ -388,6 +396,12 @@ impl From<FeatureConfigPartial> for FeatureConfig {
|
||||
.manage_workdir
|
||||
.map(FeatureFlagConfig::from)
|
||||
.unwrap_or_default(),
|
||||
workdir_catalog: FeatureFlagConfig {
|
||||
enabled: value
|
||||
.workdir_catalog
|
||||
.and_then(|flag| flag.enabled)
|
||||
.unwrap_or(defaults::WORKDIR_CATALOG_ENABLED),
|
||||
},
|
||||
ticket: value
|
||||
.ticket
|
||||
.map(TicketFeatureConfig::from)
|
||||
@@ -579,6 +593,7 @@ impl From<FeatureConfig> for FeatureConfigPartial {
|
||||
workspace_worker_discovery: Some(value.workspace_worker_discovery.into()),
|
||||
objective: Some(value.objective.into()),
|
||||
manage_workdir: Some(value.manage_workdir.into()),
|
||||
workdir_catalog: Some(value.workdir_catalog.into()),
|
||||
ticket: Some(value.ticket.into()),
|
||||
merge_request: Some(value.merge_request.into()),
|
||||
orchestration: Some(value.orchestration.into()),
|
||||
@@ -766,7 +781,8 @@ impl WorkerManifestConfig {
|
||||
|
||||
/// Base config populated with the in-code per-field defaults listed in
|
||||
/// [`crate::defaults`]. This is not a selectable Profile and does not
|
||||
/// enable a launch capability surface. Profile and one-file Manifest
|
||||
/// enable launch authority or normal Tools. The read-only WIP workdir
|
||||
/// catalog defaults enabled independently. Profile and one-file Manifest
|
||||
/// resolvers start from this layer so every per-field default lives at
|
||||
/// exactly one call site (the `defaults` module).
|
||||
///
|
||||
@@ -2232,7 +2248,7 @@ worker_max_turns = 7
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn feature_flags_default_disabled_in_resolved_manifest() {
|
||||
fn tool_feature_flags_default_disabled_except_read_only_workdir_catalog() {
|
||||
let manifest: WorkerManifest = minimal_valid().try_into().unwrap();
|
||||
assert!(!manifest.feature.task.enabled);
|
||||
assert!(!manifest.feature.memory.profile.enabled);
|
||||
@@ -2240,10 +2256,106 @@ worker_max_turns = 7
|
||||
assert!(!manifest.feature.sub_worker.enabled);
|
||||
assert!(!manifest.feature.objective.enabled);
|
||||
assert!(!manifest.feature.manage_workdir.enabled);
|
||||
assert!(manifest.feature.workdir_catalog.enabled);
|
||||
assert!(!manifest.feature.ticket.enabled);
|
||||
assert!(!manifest.feature.merge_request.any());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn workdir_catalog_defaults_agree_for_partial_and_resolved_configs() {
|
||||
assert!(FeatureConfig::default().workdir_catalog.enabled);
|
||||
assert!(FeatureConfigPartial::default().workdir_catalog.is_none());
|
||||
for source in ["", "[feature]\n", "[feature.workdir_catalog]\n"] {
|
||||
let partial = WorkerManifestConfig::from_toml(source).unwrap();
|
||||
let feature = FeatureConfig::from(partial.feature);
|
||||
assert!(feature.workdir_catalog.enabled, "{source}");
|
||||
let value: toml::Value = toml::from_str(source).unwrap();
|
||||
let resolved: FeatureConfig = serde_json::from_value(
|
||||
serde_json::to_value(
|
||||
value
|
||||
.get("feature")
|
||||
.cloned()
|
||||
.unwrap_or_else(|| toml::Value::Table(Default::default())),
|
||||
)
|
||||
.unwrap(),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(feature, resolved);
|
||||
}
|
||||
for source in ["{}", r#"{"workdir_catalog": {}}"#] {
|
||||
let feature: FeatureConfig = serde_json::from_str(source).unwrap();
|
||||
assert!(feature.workdir_catalog.enabled);
|
||||
assert!(!feature.manage_workdir.enabled);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn workdir_catalog_is_independent_and_merges_partial_layers() {
|
||||
for catalog in [false, true] {
|
||||
for manage in [false, true] {
|
||||
let source = format!(
|
||||
"[feature.workdir_catalog]\nenabled = {catalog}\n\
|
||||
[feature.manage_workdir]\nenabled = {manage}\n"
|
||||
);
|
||||
let lower = WorkerManifestConfig::from_toml(&source).unwrap();
|
||||
let empty = WorkerManifestConfig::from_toml("[feature.workdir_catalog]").unwrap();
|
||||
let cfg = minimal_valid().merge(lower.clone()).merge(empty);
|
||||
let manifest: WorkerManifest = cfg.try_into().unwrap();
|
||||
assert_eq!(manifest.feature.workdir_catalog.enabled, catalog);
|
||||
assert_eq!(manifest.feature.manage_workdir.enabled, manage);
|
||||
|
||||
let override_source =
|
||||
format!("[feature.workdir_catalog]\nenabled = {}\n", !catalog);
|
||||
let upper = WorkerManifestConfig::from_toml(&override_source).unwrap();
|
||||
let manifest: WorkerManifest = minimal_valid()
|
||||
.merge(lower)
|
||||
.merge(upper)
|
||||
.try_into()
|
||||
.unwrap();
|
||||
assert_eq!(manifest.feature.workdir_catalog.enabled, !catalog);
|
||||
assert_eq!(manifest.feature.manage_workdir.enabled, manage);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn workdir_catalog_roundtrips_config_and_persisted_snapshots() {
|
||||
for enabled in [false, true] {
|
||||
let mut cfg = minimal_valid();
|
||||
cfg.feature.workdir_catalog = Some(FeatureFlagConfigPartial {
|
||||
enabled: Some(enabled),
|
||||
});
|
||||
let manifest: WorkerManifest = cfg.try_into().unwrap();
|
||||
let partial = FeatureConfigPartial::from(manifest.feature.clone());
|
||||
assert_eq!(
|
||||
partial.workdir_catalog.as_ref().unwrap().enabled,
|
||||
Some(enabled)
|
||||
);
|
||||
let serialized = toml::to_string(&partial).unwrap();
|
||||
let partial: FeatureConfigPartial = toml::from_str(&serialized).unwrap();
|
||||
assert_eq!(FeatureConfig::from(partial), manifest.feature);
|
||||
let serialized = serde_json::to_value(&manifest.feature).unwrap();
|
||||
assert_eq!(serialized["workdir_catalog"]["enabled"], enabled);
|
||||
let feature: FeatureConfig = serde_json::from_value(serialized).unwrap();
|
||||
assert_eq!(feature, manifest.feature);
|
||||
|
||||
let snapshot = crate::write_persisted_worker_manifest_snapshot(&manifest).unwrap();
|
||||
let restored =
|
||||
crate::read_persisted_worker_manifest_snapshot(snapshot.clone()).unwrap();
|
||||
assert_eq!(restored.feature.workdir_catalog.enabled, enabled);
|
||||
assert!(!restored.feature.manage_workdir.enabled);
|
||||
|
||||
let mut legacy = snapshot;
|
||||
legacy["manifest"]["feature"]
|
||||
.as_object_mut()
|
||||
.unwrap()
|
||||
.remove("workdir_catalog");
|
||||
let restored = crate::read_persisted_worker_manifest_snapshot(legacy).unwrap();
|
||||
assert!(restored.feature.workdir_catalog.enabled);
|
||||
assert!(!restored.feature.manage_workdir.enabled);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn from_toml_parses_explicit_feature_flags() {
|
||||
let cfg = WorkerManifestConfig::from_toml(
|
||||
|
||||
@@ -6,6 +6,10 @@
|
||||
//! `pub const`. Both paths read from this module, so changing a
|
||||
//! default requires editing exactly one line.
|
||||
|
||||
/// Read-only Repository/Workdir/attachment reference discovery in WIP mode.
|
||||
/// This enables a reference provider, not normal Tools or any authority grant.
|
||||
pub const WORKDIR_CATALOG_ENABLED: bool = true;
|
||||
|
||||
/// Byte-size cap applied to any tool's `content` output when no
|
||||
/// per-tool override is set. See [`crate::ToolOutputLimits`].
|
||||
pub const TOOL_OUTPUT_MAX_BYTES: usize = 64 * 1024;
|
||||
|
||||
@@ -65,8 +65,9 @@ pub struct WorkerManifest {
|
||||
/// permission layer is disabled and tool calls run as before.
|
||||
#[serde(default)]
|
||||
pub permissions: Option<ToolPermissionConfig>,
|
||||
/// Explicit built-in feature/tool-surface enablement. Omitted feature flags
|
||||
/// resolve disabled so Profile authors choose the exposed built-in surfaces.
|
||||
/// Built-in feature/tool-surface enablement. Omitted tool features resolve
|
||||
/// disabled; the read-only WIP workdir catalog defaults enabled without
|
||||
/// granting authority or registering normal Tools.
|
||||
#[serde(default)]
|
||||
pub feature: FeatureConfig,
|
||||
/// Explicit external Model Context Protocol provider configuration. This
|
||||
@@ -131,6 +132,15 @@ pub struct FeatureConfig {
|
||||
pub objective: FeatureFlagConfig,
|
||||
#[serde(default)]
|
||||
pub manage_workdir: FeatureFlagConfig,
|
||||
/// Read-only Repository/Workdir/attachment reference discovery. Defaults
|
||||
/// enabled, is installed only in WIP mode, and exposes no normal Tools.
|
||||
/// Independent of `manage_workdir`; permissions and host authority remain
|
||||
/// separate, and discovering a reference grants no attachment or file access.
|
||||
#[serde(
|
||||
default = "default_workdir_catalog",
|
||||
deserialize_with = "deserialize_workdir_catalog"
|
||||
)]
|
||||
pub workdir_catalog: FeatureFlagConfig,
|
||||
#[serde(default)]
|
||||
pub ticket: TicketFeatureConfig,
|
||||
#[serde(default)]
|
||||
@@ -139,6 +149,22 @@ pub struct FeatureConfig {
|
||||
pub orchestration: FeatureFlagConfig,
|
||||
}
|
||||
|
||||
fn default_workdir_catalog() -> FeatureFlagConfig {
|
||||
FeatureFlagConfig {
|
||||
enabled: defaults::WORKDIR_CATALOG_ENABLED,
|
||||
}
|
||||
}
|
||||
|
||||
fn deserialize_workdir_catalog<'de, D>(deserializer: D) -> Result<FeatureFlagConfig, D::Error>
|
||||
where
|
||||
D: serde::Deserializer<'de>,
|
||||
{
|
||||
let flag = config::FeatureFlagConfigPartial::deserialize(deserializer)?;
|
||||
Ok(FeatureFlagConfig {
|
||||
enabled: flag.enabled.unwrap_or(defaults::WORKDIR_CATALOG_ENABLED),
|
||||
})
|
||||
}
|
||||
|
||||
impl Default for FeatureConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
@@ -153,6 +179,7 @@ impl Default for FeatureConfig {
|
||||
workspace_worker_discovery: FeatureFlagConfig::disabled(),
|
||||
objective: FeatureFlagConfig::disabled(),
|
||||
manage_workdir: FeatureFlagConfig::disabled(),
|
||||
workdir_catalog: default_workdir_catalog(),
|
||||
ticket: TicketFeatureConfig::default(),
|
||||
merge_request: MergeRequestFeatureConfig::default(),
|
||||
orchestration: FeatureFlagConfig::disabled(),
|
||||
|
||||
@@ -181,6 +181,8 @@ pub fn validate_profile_execution_target(
|
||||
if feature.flow.enabled {
|
||||
requirements.insert(WorkspaceAuthorityRequirement::Flow);
|
||||
}
|
||||
// workdir_catalog is a WIP-only reference provider, not Workspace authority
|
||||
// or normal Tools. It must not prevent standalone Tools-mode launches.
|
||||
if feature.manage_workdir.enabled {
|
||||
requirements.insert(WorkspaceAuthorityRequirement::ManageWorkdir);
|
||||
}
|
||||
@@ -1586,6 +1588,59 @@ mod tests {
|
||||
assert!(!resolved.manifest.feature.manage_workdir.enabled);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn workdir_catalog_does_not_require_workspace_authority_or_grant_scope() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
for catalog in [None, Some(false), Some(true)] {
|
||||
let mut artifact = serde_json::json!({
|
||||
"worker": { "mode": "tools" },
|
||||
"feature": { "manage_workdir": { "enabled": false } },
|
||||
"permissions": { "default_action": "deny" }
|
||||
});
|
||||
if let Some(enabled) = catalog {
|
||||
artifact["feature"]["workdir_catalog"] = serde_json::json!({ "enabled": enabled });
|
||||
}
|
||||
let resolved = resolve_profile_artifact_value(
|
||||
artifact,
|
||||
ProfileSource::Archive {
|
||||
archive_id: "catalog-test".into(),
|
||||
source: "test".into(),
|
||||
},
|
||||
tmp.path(),
|
||||
"catalog-worker",
|
||||
)
|
||||
.unwrap();
|
||||
let manifest = resolved.manifest;
|
||||
assert_eq!(
|
||||
manifest.feature.workdir_catalog.enabled,
|
||||
catalog.unwrap_or(true)
|
||||
);
|
||||
assert!(!manifest.feature.manage_workdir.enabled);
|
||||
assert_eq!(manifest.worker.mode, crate::WorkerMode::Tools);
|
||||
assert!(manifest.scope.allow.is_empty());
|
||||
assert!(manifest.delegation_scope.allow.is_empty());
|
||||
assert_eq!(
|
||||
manifest.permissions.as_ref().unwrap().default_action,
|
||||
crate::ToolPermissionAction::Deny
|
||||
);
|
||||
validate_profile_execution_target(&manifest, ProfileExecutionTarget::Standalone)
|
||||
.unwrap();
|
||||
|
||||
let mut manage_manifest = manifest;
|
||||
manage_manifest.feature.manage_workdir.enabled = true;
|
||||
let error = validate_profile_execution_target(
|
||||
&manage_manifest,
|
||||
ProfileExecutionTarget::Standalone,
|
||||
)
|
||||
.unwrap_err();
|
||||
assert!(matches!(
|
||||
error,
|
||||
ProfileError::UnsupportedExecutionTarget { requirements, .. }
|
||||
if requirements == vec![WorkspaceAuthorityRequirement::ManageWorkdir]
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn standalone_rejects_profiles_that_require_workspace_authority() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
|
||||
@@ -1504,7 +1504,13 @@ where
|
||||
),
|
||||
);
|
||||
}
|
||||
if feature_config.manage_workdir.enabled {
|
||||
let wip_mode = worker.manifest().worker.mode == manifest::WorkerMode::Wip;
|
||||
let mut workdir_wip_feature = None;
|
||||
if feature_config.manage_workdir.enabled
|
||||
|| (wip_mode
|
||||
&& feature_config.workdir_catalog.enabled
|
||||
&& worker.workspace_client_handle().is_available())
|
||||
{
|
||||
// Workdir lifecycle is Workspace control-plane authority. The Worker
|
||||
// receives only the injected WorkspaceClient and never Runtime URLs,
|
||||
// repository paths, materializer handles, or cleanup sessions.
|
||||
@@ -1520,7 +1526,7 @@ where
|
||||
}
|
||||
let shutdown_registry = spawned_registry.clone();
|
||||
let reopen_registry = spawned_registry.clone();
|
||||
feature_registry.add_module(
|
||||
let module =
|
||||
crate::feature::builtin::manage_workdir::ManageWorkdirFeature::with_child_lifecycle(
|
||||
workspace_client,
|
||||
worker.workdir_sessions(),
|
||||
@@ -1529,8 +1535,11 @@ where
|
||||
Box::pin(async move { child_registry.shutdown_internal().await })
|
||||
}),
|
||||
Arc::new(move || reopen_registry.reopen_internal()),
|
||||
),
|
||||
);
|
||||
);
|
||||
if feature_config.manage_workdir.enabled {
|
||||
feature_registry.add_module(module.clone());
|
||||
}
|
||||
workdir_wip_feature = Some(module);
|
||||
}
|
||||
if feature_config.workspace_worker_discovery.enabled {
|
||||
let workspace_client = worker.workspace_client_handle();
|
||||
@@ -1585,9 +1594,23 @@ where
|
||||
}
|
||||
|
||||
let host_worker_observation_provider = worker.worker_observation_provider();
|
||||
let wip_mode = worker.manifest().worker.mode == manifest::WorkerMode::Wip;
|
||||
let wip_permissions = worker.manifest().permissions.clone();
|
||||
let mut wip_mount_registry = crate::wip::WipMountRegistry::new();
|
||||
if wip_mode && let Some(feature) = &workdir_wip_feature {
|
||||
crate::feature::builtin::manage_workdir::wip::mount_workspace_workdir_wip(
|
||||
&mut wip_mount_registry,
|
||||
feature,
|
||||
feature_config.workdir_catalog.enabled,
|
||||
feature_config.manage_workdir.enabled,
|
||||
wip_permissions.clone(),
|
||||
)
|
||||
.map_err(|error| {
|
||||
std::io::Error::new(
|
||||
std::io::ErrorKind::InvalidInput,
|
||||
format!("mount Workdir WIP projection: {error}"),
|
||||
)
|
||||
})?;
|
||||
}
|
||||
{
|
||||
let workspace_client = worker.workspace_client_handle();
|
||||
let engine = worker.engine_mut();
|
||||
|
||||
@@ -5,6 +5,8 @@
|
||||
//! endpoints, credentials, materializer handles, and operation sessions stay
|
||||
//! behind [`WorkspaceClient`].
|
||||
|
||||
pub mod wip;
|
||||
|
||||
use std::future::Future;
|
||||
use std::pin::Pin;
|
||||
use std::sync::Arc;
|
||||
@@ -67,6 +69,7 @@ pub struct ManageWorkdirFeature {
|
||||
session_router: Arc<workdir::WorkdirSessionRouter>,
|
||||
before_workdir_release: Option<BeforeWorkdirRelease>,
|
||||
after_workdir_attach: Option<AfterWorkdirAttach>,
|
||||
attachment_mutation_lock: Arc<tokio::sync::Mutex<()>>,
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for ManageWorkdirFeature {
|
||||
@@ -86,6 +89,7 @@ impl ManageWorkdirFeature {
|
||||
session_router: Arc::new(workdir::WorkdirSessionRouter::new()),
|
||||
before_workdir_release: None,
|
||||
after_workdir_attach: None,
|
||||
attachment_mutation_lock: Arc::new(tokio::sync::Mutex::new(())),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -100,8 +104,19 @@ impl ManageWorkdirFeature {
|
||||
session_router,
|
||||
before_workdir_release: Some(before_workdir_release),
|
||||
after_workdir_attach: Some(after_workdir_attach),
|
||||
attachment_mutation_lock: Arc::new(tokio::sync::Mutex::new(())),
|
||||
}
|
||||
}
|
||||
fn backend(&self) -> WorkspaceHttpWorkdirBackend {
|
||||
let mut backend = WorkspaceHttpWorkdirBackend::new(self.client.clone())
|
||||
.with_session_router(self.session_router.clone())
|
||||
.with_child_lifecycle(
|
||||
self.before_workdir_release.clone(),
|
||||
self.after_workdir_attach.clone(),
|
||||
);
|
||||
backend.attachment_mutation_lock = self.attachment_mutation_lock.clone();
|
||||
backend
|
||||
}
|
||||
}
|
||||
|
||||
pub fn manage_workdir_feature(client: Arc<dyn WorkspaceClient>) -> ManageWorkdirFeature {
|
||||
@@ -120,12 +135,7 @@ impl FeatureModule for ManageWorkdirFeature {
|
||||
}
|
||||
|
||||
fn install(&self, context: &mut FeatureInstallContext<'_>) -> Result<(), FeatureInstallError> {
|
||||
let backend = WorkspaceHttpWorkdirBackend::new(self.client.clone())
|
||||
.with_session_router(self.session_router.clone())
|
||||
.with_child_lifecycle(
|
||||
self.before_workdir_release.clone(),
|
||||
self.after_workdir_attach.clone(),
|
||||
);
|
||||
let backend = self.backend();
|
||||
for (name, definition) in [
|
||||
(
|
||||
LIST_TOOL,
|
||||
@@ -581,11 +591,24 @@ impl WorkspaceHttpWorkdirBackend {
|
||||
}
|
||||
|
||||
fn detach(&self, alias: &str) -> Result<ToolOutput, ToolError> {
|
||||
self.detach_expected(alias, None)
|
||||
}
|
||||
|
||||
fn detach_expected(
|
||||
&self,
|
||||
alias: &str,
|
||||
expected_connection_id: Option<&str>,
|
||||
) -> Result<ToolOutput, ToolError> {
|
||||
let workspace_id = encode_path_segment(self.workspace_id()?);
|
||||
let alias_path = encode_path_segment(alias);
|
||||
let suffix = expected_connection_id
|
||||
.map(|id| format!("?expected_connection_id={}", encode_path_segment(id)))
|
||||
.unwrap_or_default();
|
||||
let response = self.execute_json::<WorkdirAttachmentResponse>(WorkspaceRequest {
|
||||
method: WorkspaceRequestMethod::Delete,
|
||||
path: format!("/api/w/{workspace_id}/workers/self/workdir-attachments/{alias_path}"),
|
||||
path: format!(
|
||||
"/api/w/{workspace_id}/workers/self/workdir-attachments/{alias_path}{suffix}"
|
||||
),
|
||||
body: None,
|
||||
})?;
|
||||
workdir_output(
|
||||
@@ -622,6 +645,72 @@ impl WorkspaceHttpWorkdirBackend {
|
||||
)
|
||||
}
|
||||
|
||||
async fn detach_managed(
|
||||
&self,
|
||||
alias: &str,
|
||||
expected_connection_id: Option<&str>,
|
||||
) -> Result<ToolOutput, ToolError> {
|
||||
let _mutation_guard = self.attachment_mutation_lock.lock().await;
|
||||
let alias = validate_identity(alias, DETACH_TOOL, "alias")?;
|
||||
if let Some(expected) = expected_connection_id {
|
||||
// Under the shared local mutation lock, check lifetime before any
|
||||
// local route exclusion or child cleanup. Backend DELETE still performs
|
||||
// the authoritative conditional mutation under its lifecycle lock.
|
||||
let workspace = encode_path_segment(self.workspace_id()?);
|
||||
let page: serde_json::Value = self.execute_json(WorkspaceRequest::get(format!(
|
||||
"/api/w/{workspace}/workers/self/workdir-attachments?limit=1&connection_id={}",
|
||||
encode_path_segment(expected)
|
||||
)))?;
|
||||
let current = page
|
||||
.get("items")
|
||||
.and_then(serde_json::Value::as_array)
|
||||
.is_some_and(|items| {
|
||||
items.iter().any(|item| {
|
||||
item.get("connection_id")
|
||||
.and_then(serde_json::Value::as_str)
|
||||
== Some(expected)
|
||||
&& item.get("alias").and_then(serde_json::Value::as_str) == Some(alias)
|
||||
})
|
||||
});
|
||||
if !current {
|
||||
return Err(ToolError::StructuredConflict {
|
||||
code: "workspace_http_409".into(),
|
||||
message: "attachment lifetime changed before local cleanup".into(),
|
||||
});
|
||||
}
|
||||
}
|
||||
let alias_key = workdir::WorkdirAttachmentAlias::new(alias)
|
||||
.map_err(|error| ToolError::InvalidArgument(error.to_string()))?;
|
||||
self.session_router
|
||||
.begin_detach(&alias_key)
|
||||
.await
|
||||
.map_err(|error| ToolError::ExecutionFailed(error.to_string()))?;
|
||||
if let Some(before_release) = &self.before_workdir_release
|
||||
&& let Err(error) = before_release().await
|
||||
{
|
||||
self.session_router.cancel_detach(&alias_key);
|
||||
return Err(ToolError::ExecutionFailed(format!(
|
||||
"stop Internal SubWorkers before Workdir detach: {error}"
|
||||
)));
|
||||
}
|
||||
let result = match self.detach_expected(alias, expected_connection_id) {
|
||||
Ok(result) => result,
|
||||
Err(error) => {
|
||||
self.session_router.cancel_detach(&alias_key);
|
||||
return Err(error);
|
||||
}
|
||||
};
|
||||
self.session_router
|
||||
.finish_detach(&alias_key)
|
||||
.await
|
||||
.map_err(|error| {
|
||||
ToolError::ExecutionFailed(format!(
|
||||
"detach Workdir session route after Backend release: {error}"
|
||||
))
|
||||
})?;
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
fn execute_json<T: for<'de> Deserialize<'de>>(
|
||||
&self,
|
||||
request: WorkspaceRequest,
|
||||
@@ -697,42 +786,10 @@ impl Tool for WorkspaceHttpWorkdirTool {
|
||||
result
|
||||
}
|
||||
WorkdirOperation::Detach => {
|
||||
let _mutation_guard = self.backend.attachment_mutation_lock.lock().await;
|
||||
let input = parse_input::<WorkdirDetachInput>(input_json)?;
|
||||
let alias = validate_identity(&input.alias, DETACH_TOOL, "alias")?;
|
||||
let alias_key = workdir::WorkdirAttachmentAlias::new(alias)
|
||||
.map_err(|error| ToolError::InvalidArgument(error.to_string()))?;
|
||||
self.backend
|
||||
.session_router
|
||||
.begin_detach(&alias_key)
|
||||
.await
|
||||
.map_err(|error| ToolError::ExecutionFailed(error.to_string()))?;
|
||||
if let Some(before_release) = &self.backend.before_workdir_release
|
||||
&& let Err(error) = before_release().await
|
||||
{
|
||||
self.backend.session_router.cancel_detach(&alias_key);
|
||||
return Err(ToolError::ExecutionFailed(format!(
|
||||
"stop Internal SubWorkers before Workdir detach: {error}"
|
||||
)));
|
||||
}
|
||||
let result = match self.backend.detach(alias) {
|
||||
Ok(result) => result,
|
||||
Err(error) => {
|
||||
self.backend.session_router.cancel_detach(&alias_key);
|
||||
return Err(error);
|
||||
}
|
||||
};
|
||||
self.backend
|
||||
.session_router
|
||||
.finish_detach(&alias_key)
|
||||
.await
|
||||
.map_err(|error| {
|
||||
ToolError::ExecutionFailed(format!(
|
||||
"detach Workdir session route after Backend release: {error}"
|
||||
))
|
||||
})?;
|
||||
Ok(result)
|
||||
self.backend.detach_managed(&input.alias, None).await
|
||||
}
|
||||
|
||||
WorkdirOperation::Delete => self
|
||||
.backend
|
||||
.delete(parse_input::<WorkdirDeleteInput>(input_json)?),
|
||||
@@ -747,6 +804,12 @@ fn parse_input<T: for<'de> Deserialize<'de>>(input: &str) -> Result<T, ToolError
|
||||
fn decode_response<T: for<'de> Deserialize<'de>>(
|
||||
response: WorkspaceResponse,
|
||||
) -> Result<T, ToolError> {
|
||||
if matches!(response.status, 401 | 403 | 404 | 409) {
|
||||
return Err(ToolError::StructuredConflict {
|
||||
code: format!("workspace_http_{}", response.status),
|
||||
message: "Workspace lifecycle authority rejected the request before execution".into(),
|
||||
});
|
||||
}
|
||||
if !response.is_success() {
|
||||
return Err(ToolError::ExecutionFailed(format!(
|
||||
"Workspace Workdir API returned HTTP {}: {}",
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
+1260
-25
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user