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.
|
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
|
## See also
|
||||||
|
|
||||||
- [`../../docs/design/profiles-manifests-prompts.md`](../../docs/design/profiles-manifests-prompts.md)
|
- [`../../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.
|
/// is disabled; `Some` requires `default_action` during final resolve.
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub permissions: Option<PermissionConfigPartial>,
|
pub permissions: Option<PermissionConfigPartial>,
|
||||||
/// Explicit built-in feature/tool-surface enablement. Absent flags resolve
|
/// Built-in feature/tool-surface enablement. Absent tool flags resolve
|
||||||
/// disabled after cascade merge.
|
/// disabled after cascade merge; the read-only WIP workdir catalog defaults
|
||||||
|
/// enabled without granting authority.
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub feature: FeatureConfigPartial,
|
pub feature: FeatureConfigPartial,
|
||||||
/// Explicit Model Context Protocol provider declarations. Config parsing
|
/// Explicit Model Context Protocol provider declarations. Config parsing
|
||||||
@@ -95,6 +96,8 @@ pub struct FeatureConfigPartial {
|
|||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub manage_workdir: Option<FeatureFlagConfigPartial>,
|
pub manage_workdir: Option<FeatureFlagConfigPartial>,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
|
pub workdir_catalog: Option<FeatureFlagConfigPartial>,
|
||||||
|
#[serde(default)]
|
||||||
pub ticket: Option<TicketFeatureConfigPartial>,
|
pub ticket: Option<TicketFeatureConfigPartial>,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub merge_request: Option<MergeRequestFeatureConfigPartial>,
|
pub merge_request: Option<MergeRequestFeatureConfigPartial>,
|
||||||
@@ -136,6 +139,11 @@ impl FeatureConfigPartial {
|
|||||||
other.manage_workdir,
|
other.manage_workdir,
|
||||||
FeatureFlagConfigPartial::merge,
|
FeatureFlagConfigPartial::merge,
|
||||||
),
|
),
|
||||||
|
workdir_catalog: merge_option(
|
||||||
|
self.workdir_catalog,
|
||||||
|
other.workdir_catalog,
|
||||||
|
FeatureFlagConfigPartial::merge,
|
||||||
|
),
|
||||||
ticket: merge_option(self.ticket, other.ticket, TicketFeatureConfigPartial::merge),
|
ticket: merge_option(self.ticket, other.ticket, TicketFeatureConfigPartial::merge),
|
||||||
merge_request: merge_option(
|
merge_request: merge_option(
|
||||||
self.merge_request,
|
self.merge_request,
|
||||||
@@ -388,6 +396,12 @@ impl From<FeatureConfigPartial> for FeatureConfig {
|
|||||||
.manage_workdir
|
.manage_workdir
|
||||||
.map(FeatureFlagConfig::from)
|
.map(FeatureFlagConfig::from)
|
||||||
.unwrap_or_default(),
|
.unwrap_or_default(),
|
||||||
|
workdir_catalog: FeatureFlagConfig {
|
||||||
|
enabled: value
|
||||||
|
.workdir_catalog
|
||||||
|
.and_then(|flag| flag.enabled)
|
||||||
|
.unwrap_or(defaults::WORKDIR_CATALOG_ENABLED),
|
||||||
|
},
|
||||||
ticket: value
|
ticket: value
|
||||||
.ticket
|
.ticket
|
||||||
.map(TicketFeatureConfig::from)
|
.map(TicketFeatureConfig::from)
|
||||||
@@ -579,6 +593,7 @@ impl From<FeatureConfig> for FeatureConfigPartial {
|
|||||||
workspace_worker_discovery: Some(value.workspace_worker_discovery.into()),
|
workspace_worker_discovery: Some(value.workspace_worker_discovery.into()),
|
||||||
objective: Some(value.objective.into()),
|
objective: Some(value.objective.into()),
|
||||||
manage_workdir: Some(value.manage_workdir.into()),
|
manage_workdir: Some(value.manage_workdir.into()),
|
||||||
|
workdir_catalog: Some(value.workdir_catalog.into()),
|
||||||
ticket: Some(value.ticket.into()),
|
ticket: Some(value.ticket.into()),
|
||||||
merge_request: Some(value.merge_request.into()),
|
merge_request: Some(value.merge_request.into()),
|
||||||
orchestration: Some(value.orchestration.into()),
|
orchestration: Some(value.orchestration.into()),
|
||||||
@@ -766,7 +781,8 @@ impl WorkerManifestConfig {
|
|||||||
|
|
||||||
/// Base config populated with the in-code per-field defaults listed in
|
/// Base config populated with the in-code per-field defaults listed in
|
||||||
/// [`crate::defaults`]. This is not a selectable Profile and does not
|
/// [`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
|
/// resolvers start from this layer so every per-field default lives at
|
||||||
/// exactly one call site (the `defaults` module).
|
/// exactly one call site (the `defaults` module).
|
||||||
///
|
///
|
||||||
@@ -2232,7 +2248,7 @@ worker_max_turns = 7
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[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();
|
let manifest: WorkerManifest = minimal_valid().try_into().unwrap();
|
||||||
assert!(!manifest.feature.task.enabled);
|
assert!(!manifest.feature.task.enabled);
|
||||||
assert!(!manifest.feature.memory.profile.enabled);
|
assert!(!manifest.feature.memory.profile.enabled);
|
||||||
@@ -2240,10 +2256,106 @@ worker_max_turns = 7
|
|||||||
assert!(!manifest.feature.sub_worker.enabled);
|
assert!(!manifest.feature.sub_worker.enabled);
|
||||||
assert!(!manifest.feature.objective.enabled);
|
assert!(!manifest.feature.objective.enabled);
|
||||||
assert!(!manifest.feature.manage_workdir.enabled);
|
assert!(!manifest.feature.manage_workdir.enabled);
|
||||||
|
assert!(manifest.feature.workdir_catalog.enabled);
|
||||||
assert!(!manifest.feature.ticket.enabled);
|
assert!(!manifest.feature.ticket.enabled);
|
||||||
assert!(!manifest.feature.merge_request.any());
|
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]
|
#[test]
|
||||||
fn from_toml_parses_explicit_feature_flags() {
|
fn from_toml_parses_explicit_feature_flags() {
|
||||||
let cfg = WorkerManifestConfig::from_toml(
|
let cfg = WorkerManifestConfig::from_toml(
|
||||||
|
|||||||
@@ -6,6 +6,10 @@
|
|||||||
//! `pub const`. Both paths read from this module, so changing a
|
//! `pub const`. Both paths read from this module, so changing a
|
||||||
//! default requires editing exactly one line.
|
//! 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
|
/// Byte-size cap applied to any tool's `content` output when no
|
||||||
/// per-tool override is set. See [`crate::ToolOutputLimits`].
|
/// per-tool override is set. See [`crate::ToolOutputLimits`].
|
||||||
pub const TOOL_OUTPUT_MAX_BYTES: usize = 64 * 1024;
|
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.
|
/// permission layer is disabled and tool calls run as before.
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub permissions: Option<ToolPermissionConfig>,
|
pub permissions: Option<ToolPermissionConfig>,
|
||||||
/// Explicit built-in feature/tool-surface enablement. Omitted feature flags
|
/// Built-in feature/tool-surface enablement. Omitted tool features resolve
|
||||||
/// resolve disabled so Profile authors choose the exposed built-in surfaces.
|
/// disabled; the read-only WIP workdir catalog defaults enabled without
|
||||||
|
/// granting authority or registering normal Tools.
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub feature: FeatureConfig,
|
pub feature: FeatureConfig,
|
||||||
/// Explicit external Model Context Protocol provider configuration. This
|
/// Explicit external Model Context Protocol provider configuration. This
|
||||||
@@ -131,6 +132,15 @@ pub struct FeatureConfig {
|
|||||||
pub objective: FeatureFlagConfig,
|
pub objective: FeatureFlagConfig,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub manage_workdir: FeatureFlagConfig,
|
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)]
|
#[serde(default)]
|
||||||
pub ticket: TicketFeatureConfig,
|
pub ticket: TicketFeatureConfig,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
@@ -139,6 +149,22 @@ pub struct FeatureConfig {
|
|||||||
pub orchestration: FeatureFlagConfig,
|
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 {
|
impl Default for FeatureConfig {
|
||||||
fn default() -> Self {
|
fn default() -> Self {
|
||||||
Self {
|
Self {
|
||||||
@@ -153,6 +179,7 @@ impl Default for FeatureConfig {
|
|||||||
workspace_worker_discovery: FeatureFlagConfig::disabled(),
|
workspace_worker_discovery: FeatureFlagConfig::disabled(),
|
||||||
objective: FeatureFlagConfig::disabled(),
|
objective: FeatureFlagConfig::disabled(),
|
||||||
manage_workdir: FeatureFlagConfig::disabled(),
|
manage_workdir: FeatureFlagConfig::disabled(),
|
||||||
|
workdir_catalog: default_workdir_catalog(),
|
||||||
ticket: TicketFeatureConfig::default(),
|
ticket: TicketFeatureConfig::default(),
|
||||||
merge_request: MergeRequestFeatureConfig::default(),
|
merge_request: MergeRequestFeatureConfig::default(),
|
||||||
orchestration: FeatureFlagConfig::disabled(),
|
orchestration: FeatureFlagConfig::disabled(),
|
||||||
|
|||||||
@@ -181,6 +181,8 @@ pub fn validate_profile_execution_target(
|
|||||||
if feature.flow.enabled {
|
if feature.flow.enabled {
|
||||||
requirements.insert(WorkspaceAuthorityRequirement::Flow);
|
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 {
|
if feature.manage_workdir.enabled {
|
||||||
requirements.insert(WorkspaceAuthorityRequirement::ManageWorkdir);
|
requirements.insert(WorkspaceAuthorityRequirement::ManageWorkdir);
|
||||||
}
|
}
|
||||||
@@ -1586,6 +1588,59 @@ mod tests {
|
|||||||
assert!(!resolved.manifest.feature.manage_workdir.enabled);
|
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]
|
#[test]
|
||||||
fn standalone_rejects_profiles_that_require_workspace_authority() {
|
fn standalone_rejects_profiles_that_require_workspace_authority() {
|
||||||
let tmp = TempDir::new().unwrap();
|
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
|
// Workdir lifecycle is Workspace control-plane authority. The Worker
|
||||||
// receives only the injected WorkspaceClient and never Runtime URLs,
|
// receives only the injected WorkspaceClient and never Runtime URLs,
|
||||||
// repository paths, materializer handles, or cleanup sessions.
|
// repository paths, materializer handles, or cleanup sessions.
|
||||||
@@ -1520,7 +1526,7 @@ where
|
|||||||
}
|
}
|
||||||
let shutdown_registry = spawned_registry.clone();
|
let shutdown_registry = spawned_registry.clone();
|
||||||
let reopen_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(
|
crate::feature::builtin::manage_workdir::ManageWorkdirFeature::with_child_lifecycle(
|
||||||
workspace_client,
|
workspace_client,
|
||||||
worker.workdir_sessions(),
|
worker.workdir_sessions(),
|
||||||
@@ -1529,8 +1535,11 @@ where
|
|||||||
Box::pin(async move { child_registry.shutdown_internal().await })
|
Box::pin(async move { child_registry.shutdown_internal().await })
|
||||||
}),
|
}),
|
||||||
Arc::new(move || reopen_registry.reopen_internal()),
|
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 {
|
if feature_config.workspace_worker_discovery.enabled {
|
||||||
let workspace_client = worker.workspace_client_handle();
|
let workspace_client = worker.workspace_client_handle();
|
||||||
@@ -1585,9 +1594,23 @@ where
|
|||||||
}
|
}
|
||||||
|
|
||||||
let host_worker_observation_provider = worker.worker_observation_provider();
|
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 wip_permissions = worker.manifest().permissions.clone();
|
||||||
let mut wip_mount_registry = crate::wip::WipMountRegistry::new();
|
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 workspace_client = worker.workspace_client_handle();
|
||||||
let engine = worker.engine_mut();
|
let engine = worker.engine_mut();
|
||||||
|
|||||||
@@ -5,6 +5,8 @@
|
|||||||
//! endpoints, credentials, materializer handles, and operation sessions stay
|
//! endpoints, credentials, materializer handles, and operation sessions stay
|
||||||
//! behind [`WorkspaceClient`].
|
//! behind [`WorkspaceClient`].
|
||||||
|
|
||||||
|
pub mod wip;
|
||||||
|
|
||||||
use std::future::Future;
|
use std::future::Future;
|
||||||
use std::pin::Pin;
|
use std::pin::Pin;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
@@ -67,6 +69,7 @@ pub struct ManageWorkdirFeature {
|
|||||||
session_router: Arc<workdir::WorkdirSessionRouter>,
|
session_router: Arc<workdir::WorkdirSessionRouter>,
|
||||||
before_workdir_release: Option<BeforeWorkdirRelease>,
|
before_workdir_release: Option<BeforeWorkdirRelease>,
|
||||||
after_workdir_attach: Option<AfterWorkdirAttach>,
|
after_workdir_attach: Option<AfterWorkdirAttach>,
|
||||||
|
attachment_mutation_lock: Arc<tokio::sync::Mutex<()>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl std::fmt::Debug for ManageWorkdirFeature {
|
impl std::fmt::Debug for ManageWorkdirFeature {
|
||||||
@@ -86,6 +89,7 @@ impl ManageWorkdirFeature {
|
|||||||
session_router: Arc::new(workdir::WorkdirSessionRouter::new()),
|
session_router: Arc::new(workdir::WorkdirSessionRouter::new()),
|
||||||
before_workdir_release: None,
|
before_workdir_release: None,
|
||||||
after_workdir_attach: None,
|
after_workdir_attach: None,
|
||||||
|
attachment_mutation_lock: Arc::new(tokio::sync::Mutex::new(())),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -100,8 +104,19 @@ impl ManageWorkdirFeature {
|
|||||||
session_router,
|
session_router,
|
||||||
before_workdir_release: Some(before_workdir_release),
|
before_workdir_release: Some(before_workdir_release),
|
||||||
after_workdir_attach: Some(after_workdir_attach),
|
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 {
|
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> {
|
fn install(&self, context: &mut FeatureInstallContext<'_>) -> Result<(), FeatureInstallError> {
|
||||||
let backend = WorkspaceHttpWorkdirBackend::new(self.client.clone())
|
let backend = self.backend();
|
||||||
.with_session_router(self.session_router.clone())
|
|
||||||
.with_child_lifecycle(
|
|
||||||
self.before_workdir_release.clone(),
|
|
||||||
self.after_workdir_attach.clone(),
|
|
||||||
);
|
|
||||||
for (name, definition) in [
|
for (name, definition) in [
|
||||||
(
|
(
|
||||||
LIST_TOOL,
|
LIST_TOOL,
|
||||||
@@ -581,11 +591,24 @@ impl WorkspaceHttpWorkdirBackend {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn detach(&self, alias: &str) -> Result<ToolOutput, ToolError> {
|
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 workspace_id = encode_path_segment(self.workspace_id()?);
|
||||||
let alias_path = encode_path_segment(alias);
|
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 {
|
let response = self.execute_json::<WorkdirAttachmentResponse>(WorkspaceRequest {
|
||||||
method: WorkspaceRequestMethod::Delete,
|
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,
|
body: None,
|
||||||
})?;
|
})?;
|
||||||
workdir_output(
|
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>>(
|
fn execute_json<T: for<'de> Deserialize<'de>>(
|
||||||
&self,
|
&self,
|
||||||
request: WorkspaceRequest,
|
request: WorkspaceRequest,
|
||||||
@@ -697,42 +786,10 @@ impl Tool for WorkspaceHttpWorkdirTool {
|
|||||||
result
|
result
|
||||||
}
|
}
|
||||||
WorkdirOperation::Detach => {
|
WorkdirOperation::Detach => {
|
||||||
let _mutation_guard = self.backend.attachment_mutation_lock.lock().await;
|
|
||||||
let input = parse_input::<WorkdirDetachInput>(input_json)?;
|
let input = parse_input::<WorkdirDetachInput>(input_json)?;
|
||||||
let alias = validate_identity(&input.alias, DETACH_TOOL, "alias")?;
|
self.backend.detach_managed(&input.alias, None).await
|
||||||
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)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
WorkdirOperation::Delete => self
|
WorkdirOperation::Delete => self
|
||||||
.backend
|
.backend
|
||||||
.delete(parse_input::<WorkdirDeleteInput>(input_json)?),
|
.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>>(
|
fn decode_response<T: for<'de> Deserialize<'de>>(
|
||||||
response: WorkspaceResponse,
|
response: WorkspaceResponse,
|
||||||
) -> Result<T, ToolError> {
|
) -> 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() {
|
if !response.is_success() {
|
||||||
return Err(ToolError::ExecutionFailed(format!(
|
return Err(ToolError::ExecutionFailed(format!(
|
||||||
"Workspace Workdir API returned HTTP {}: {}",
|
"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
@@ -0,0 +1,175 @@
|
|||||||
|
# Repository, Workdir and attachment WIP projection
|
||||||
|
|
||||||
|
## Objects and operations
|
||||||
|
|
||||||
|
The Host owns these root namespaces, using the same `WipMountRegistry`
|
||||||
|
namespace/resolver/contribution contracts as Tickets, Objectives and Merge Requests:
|
||||||
|
|
||||||
|
```text
|
||||||
|
/
|
||||||
|
├── repositories list
|
||||||
|
│ └── <encoded repository key> read
|
||||||
|
├── workdirs list, create
|
||||||
|
│ └── <encoded Workdir ID> read, attach, delete (when supported)
|
||||||
|
└── workdir-attachments list
|
||||||
|
└── <connection lifetime ID> read, detach
|
||||||
|
```
|
||||||
|
|
||||||
|
Operations are Interface declarations, **not child files**. There is no
|
||||||
|
`/features/manage-workdir`, no writable Repository catalog and no Repository
|
||||||
|
content tree here. `/checkouts` content projection is a separate provider (T-699),
|
||||||
|
as is Workspace configuration (T-695). Management Objects do not grant access to
|
||||||
|
unattached contents. A future content provider should return its entrance from
|
||||||
|
an authorized, live attachment; it must not infer content/command capability
|
||||||
|
from catalog or lifecycle access.
|
||||||
|
|
||||||
|
`path` in inventory results is an absolute WIP Object path. It is a reference,
|
||||||
|
not an OS path, a capability grant, or an instruction to reinterpret another
|
||||||
|
string argument as a route. Source Repository references use `repository_path`;
|
||||||
|
attachment references use `workdir_path`. Attach results include
|
||||||
|
`attachments_path` to discover the current connection and its lifetime ID.
|
||||||
|
|
||||||
|
### Identity and route encoding
|
||||||
|
|
||||||
|
Repository identity is the formal Backend `repository_key`, never its display
|
||||||
|
name or a guessed `main`. Workdir identity is `working_directory_id`, never a
|
||||||
|
Worker-local alias. A segment containing only ASCII letters, digits, `-`, `_`
|
||||||
|
is unchanged. All other UTF-8 identities are encoded as `~` followed by lowercase
|
||||||
|
hexadecimal UTF-8 bytes. The `~` marker is itself always encoded. Only the
|
||||||
|
canonical encoding is accepted; no alternate spellings, recursive URL decoding,
|
||||||
|
case folding, slash traversal or namespace fallback are accepted. For example,
|
||||||
|
`repo/name` is `/repositories/~7265706f2f6e616d65`. This is injective and accepts
|
||||||
|
existing valid keys, including Unicode and slash-containing identities.
|
||||||
|
|
||||||
|
Attachment identity is a fresh opaque Backend connection ID for each lifetime,
|
||||||
|
including detach/reattach to the **same** alias and Workdir. Read exposes alias,
|
||||||
|
Workdir ID and effective attachment capabilities as attributes. Workdir existence
|
||||||
|
and Worker connection are separate Objects. Detaching a connection does not
|
||||||
|
remove the Workdir or delete a logical settings space.
|
||||||
|
|
||||||
|
## Feature and authority separation
|
||||||
|
|
||||||
|
`feature.workdir_catalog.enabled` independently supplies read-only Repository,
|
||||||
|
Workdir and attachment reference operations. It defaults enabled so a WIP Worker
|
||||||
|
with Workspace authority and appropriate permissions can find registered keys
|
||||||
|
without Ticket or conversation hints. It does not add ordinary Tools or require
|
||||||
|
a filesystem/Bash location. Standalone Workers without Workspace authority have
|
||||||
|
no Workspace catalog mounted.
|
||||||
|
|
||||||
|
`feature.manage_workdir.enabled` supplies management operation implementations
|
||||||
|
on the **same Objects** using `contribute_operations` and
|
||||||
|
`contribute_dynamic_operations`. It remains default disabled. It neither owns
|
||||||
|
another resolver nor confers permission. The object provider is unique; duplicate
|
||||||
|
namespace, route, operation name or inconsistent Interface contributions fail at
|
||||||
|
registration through the common registry.
|
||||||
|
|
||||||
|
Native permission identities are:
|
||||||
|
|
||||||
|
| Operation | Manifest permission identity | Bound identity in permission input |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| repositories.list | RepositoryList | list arguments |
|
||||||
|
| repository.read | RepositoryRead | repository_key |
|
||||||
|
| workdirs.list | WorkdirList | list arguments |
|
||||||
|
| workdir.read | WorkdirRead | working_directory_id |
|
||||||
|
| workdirs.create | WorkdirCreate | original create arguments |
|
||||||
|
| workdir.attach | WorkdirAttach | working_directory_id + alias |
|
||||||
|
| workdir.delete | WorkdirDelete | working_directory_id + reason |
|
||||||
|
| attachments.list | WorkdirAttachmentList | list arguments |
|
||||||
|
| attachment.read | WorkdirAttachmentRead | connection_id |
|
||||||
|
| attachment.detach | WorkdirDetach | current bound alias |
|
||||||
|
|
||||||
|
Read permissions gate direct item resolution as well as list item visibility.
|
||||||
|
The manifest's existing default policy is retained; `ask` is denied fail-closed.
|
||||||
|
An argument-dependent policy may advertise an operation that could be allowed,
|
||||||
|
but every call checks its exact reconstructed input before execution. Mutation
|
||||||
|
arguments cannot override path-bound Workdir or connection identity. Having
|
||||||
|
RepositoryRead is not WorkdirCreate; inventory/management permissions are not
|
||||||
|
file access, delegation or command execution grants. Backend caller/Workspace
|
||||||
|
identity, source proof, attachment capability ceilings and lifecycle policy are
|
||||||
|
checked by the existing paths on every request.
|
||||||
|
|
||||||
|
## Contextual Interfaces and stale observations
|
||||||
|
|
||||||
|
Providers opt into common Host contextual Interfaces. Operation publication is
|
||||||
|
the intersection of registered implementation, current target support and current
|
||||||
|
subject policy. The collection reference feature can remain available while the
|
||||||
|
management feature is disabled. External Workdirs without cleanup authority do
|
||||||
|
not expose delete. Only currently supported lifecycle operations are advertised;
|
||||||
|
no delete/command is added to logical attachments solely because they appear as
|
||||||
|
workspaces.
|
||||||
|
|
||||||
|
A contextual Interface reference is opaque and path-qualified as
|
||||||
|
`<base>/@/<hex UTF-8 Object path>`. Clients must use the returned reference, not
|
||||||
|
construct it. Fetch and call freshly resolve the same Object and filtered
|
||||||
|
Interface; base references cannot fetch an unfiltered descriptor. Descriptor and
|
||||||
|
Object validators invalidate old observations after target/connection/permission
|
||||||
|
changes. Validators are managed by the WIP Client, not manually by the LLM.
|
||||||
|
Cached Interface possession and known direct paths never bypass current checks.
|
||||||
|
Historic session text remains append-only and is not rewritten or replayed.
|
||||||
|
|
||||||
|
## Backend lifecycle and safe output
|
||||||
|
|
||||||
|
Catalogs are request-time projections of existing Backend authority, not another
|
||||||
|
store or synchronization job. WIP list pages default to 50 and accept 1–100
|
||||||
|
items. Repository/Workdir pages sort by stable identity and use the last identity
|
||||||
|
as `next_cursor`; they project the existing Backend inventory response rather
|
||||||
|
than maintaining a catalog copy. Attachment pages use the Backend's SQL-bounded,
|
||||||
|
alias-ordered limit/offset query and opaque `offset:<n>` cursors. A cursor is not
|
||||||
|
a snapshot or authority grant: restart listing after concurrent collection
|
||||||
|
changes. Filtering by exact `connection_id` is applied in the caller-scoped
|
||||||
|
Backend ledger before paging, so direct item lookup also works beyond the first
|
||||||
|
page. Unknown/expired IDs yield no item, never a current alias substitute.
|
||||||
|
Repository projection uses an explicit field
|
||||||
|
allowlist: formal key, kind/provider, any existing display metadata and explicit
|
||||||
|
nullable default selector. Source URI/path, credentials, remotes, Runtime
|
||||||
|
connection information and internal fingerprints are not model-visible.
|
||||||
|
Workdir projection includes stable ID, optional display name, public source kind
|
||||||
|
and Repository key, existing selector/ref/tree, status, cleanliness and occupancy
|
||||||
|
when available. Cleanup handles and External grant internals are not forwarded.
|
||||||
|
No missing attributes or implicit selectors are fabricated. Backend errors are
|
||||||
|
sanitized rather than forwarding raw diagnostic bodies with possible host paths.
|
||||||
|
|
||||||
|
Create and attach reuse the ordinary Workdir backend, request identity,
|
||||||
|
Worker-session router, mutation lock and Internal SubWorker lifecycle hooks.
|
||||||
|
Creation does **not** attach. Attach installs the Backend-attenuated session
|
||||||
|
capabilities, never a fabricated `ALL` capability for External Workdirs. Detach
|
||||||
|
uses the same begin/finish/cancel-detach path and command/operation exclusion as
|
||||||
|
Tools; the Backend checks expected connection identity under its existing
|
||||||
|
current-Worker lifecycle lock before closing sessions, then conditionally
|
||||||
|
updates that exact row in the existing connection ledger. Alias reuse cannot
|
||||||
|
retarget an old connection operation. Compensation retains the original
|
||||||
|
connection identity. Existing removal dispositions `removed`, `retained`, and
|
||||||
|
`attention_required`, plus retryability, remain intact.
|
||||||
|
|
||||||
|
Stale identity/validator rejection is distinct from unknown operation outcome.
|
||||||
|
Transport failure, invalid post-dispatch response or uncertain lifecycle failure
|
||||||
|
is **not** proof that no effect occurred; it must not be automatically retried.
|
||||||
|
Inspect current state or request operator guidance. No new retry or attachment
|
||||||
|
ledger is introduced by WIP.
|
||||||
|
|
||||||
|
When native management is mounted, `WorkdirList/Create/Attach/Detach/Delete` are
|
||||||
|
claimed as represented native capabilities, so `/tools/Workdir*` is not also
|
||||||
|
published. Ordinary Tools mode keeps its names and inputs. Existing native
|
||||||
|
Ticket/Objective/Merge Request routes retain their T-698 contracts.
|
||||||
|
|
||||||
|
## First-Workspace example
|
||||||
|
|
||||||
|
1. `WipDiscover(path="/", depth=1)` finds `repositories`, `workdirs` and
|
||||||
|
`workdir-attachments` when permitted.
|
||||||
|
2. Inspect the returned Repository collection Interface and call `list` with a
|
||||||
|
bounded limit. An empty registered/visible catalog explicitly returns an empty
|
||||||
|
list; do not invent a key. Follow `next_cursor` when supplied.
|
||||||
|
3. Discover the returned item `path`, inspect its Interface and call `read`.
|
||||||
|
`default_selector: null` means no configured default, not `main`.
|
||||||
|
4. Inspect the Workdir collection and `create` using the discovered formal key,
|
||||||
|
with optional selector, display name and Runtime under the existing contract.
|
||||||
|
5. Follow created `item.path`, discover/inspect and `attach` with a chosen
|
||||||
|
Worker-local alias. This is separate from creation.
|
||||||
|
6. Follow `attachments_path`, `list`, then discover/read the returned connection
|
||||||
|
path to confirm alias, Workdir and effective capabilities.
|
||||||
|
7. Call that connection's `detach` with no identity override. The observed
|
||||||
|
lifetime ID guards alias reuse; the Workdir remains in its inventory.
|
||||||
|
|
||||||
|
Each step uses current WIP observations and no Ticket-derived key, host path,
|
||||||
|
Runtime endpoint or manual validator. Explicitly refresh/reset discovery after
|
||||||
|
external changes and before reconstructing a rejected stale call.
|
||||||
@@ -75,7 +75,7 @@ The built-in native route migration is:
|
|||||||
| `/features/objective/objectives[/<reference>]` | `/objectives[/<reference>]` |
|
| `/features/objective/objectives[/<reference>]` | `/objectives[/<reference>]` |
|
||||||
| `/features/merge-request/merge-requests[/<reference>]` | `/merge-requests[/<reference>]` |
|
| `/features/merge-request/merge-requests[/<reference>]` | `/merge-requests[/<reference>]` |
|
||||||
|
|
||||||
`/repositories` and `/workdirs` can be allocated by a later provider using the same registration contract; this layer does not implement or pre-allocate those domains. `/checkouts` content projection is likewise outside this contract.
|
The built-in Repository/Workdir reference provider allocates `/repositories`, `/workdirs`, and `/workdir-attachments` using this contract; `manage-workdir` contributes operations rather than owning another Object. See [Repository, Workdir and attachment WIP projection](workdir-wip-projection.md) for identity encoding, reference/management permissions, attachment lifetime safety, and first-Workspace discovery. `/checkouts` content projection remains a separate provider.
|
||||||
|
|
||||||
### Registering a subsequent provider
|
### Registering a subsequent provider
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user