Make feed and app lookups self-diagnosing
Build Claw Telegram / build (push) Successful in 5m4s
Build Claw Telegram / cleanup (push) Successful in 2s

This commit is contained in:
Wylabb
2026-04-05 23:45:37 +02:00
parent 44f21595db
commit e7f03e0645
5 changed files with 500 additions and 39 deletions
@@ -193,23 +193,35 @@ pub struct WorkerBackgroundApprovalListResponse {
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct WorkerFeedListResponse {
pub items: Vec<FeedItemRecord>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub state_root: Option<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub warnings: Vec<String>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct WorkerFeedItemResponse {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub item: Option<FeedItemRecord>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub state_root: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct WorkerAppListResponse {
pub apps: Vec<LibraryAppRecord>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub state_root: Option<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub warnings: Vec<String>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct WorkerAppSnapshotResponse {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub app: Option<LibraryAppRecord>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub state_root: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
+33 -11
View File
@@ -308,10 +308,15 @@ async fn list_feed(
Query(query): Query<FeedQuery>,
) -> Result<Json<WorkerFeedListResponse>, StatusCode> {
authorize(&headers, &state.config.auth_token)?;
let items = ArtifactLibraryStore::new()
let store = ArtifactLibraryStore::new();
let items = store
.list_feed(query.turn_id.as_deref())
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
Ok(Json(WorkerFeedListResponse { items }))
Ok(Json(WorkerFeedListResponse {
items,
state_root: store.state_root_display().ok(),
warnings: Vec::new(),
}))
}
async fn get_feed_item(
@@ -320,10 +325,14 @@ async fn get_feed_item(
AxumPath(feed_item_id): AxumPath<String>,
) -> Result<Json<WorkerFeedItemResponse>, StatusCode> {
authorize(&headers, &state.config.auth_token)?;
let item = ArtifactLibraryStore::new()
let store = ArtifactLibraryStore::new();
let item = store
.get_feed_item(&feed_item_id)
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
Ok(Json(WorkerFeedItemResponse { item }))
Ok(Json(WorkerFeedItemResponse {
item,
state_root: store.state_root_display().ok(),
}))
}
async fn get_feed_file(
@@ -454,10 +463,15 @@ async fn list_apps(
headers: HeaderMap,
) -> Result<Json<WorkerAppListResponse>, StatusCode> {
authorize(&headers, &state.config.auth_token)?;
let apps = ArtifactLibraryStore::new()
.list_apps()
let store = ArtifactLibraryStore::new();
let (apps, warnings) = store
.list_apps_with_warnings()
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
Ok(Json(WorkerAppListResponse { apps }))
Ok(Json(WorkerAppListResponse {
apps,
state_root: store.state_root_display().ok(),
warnings,
}))
}
async fn get_app(
@@ -466,10 +480,14 @@ async fn get_app(
AxumPath(app_id): AxumPath<String>,
) -> Result<Json<WorkerAppSnapshotResponse>, StatusCode> {
authorize(&headers, &state.config.auth_token)?;
let app = ArtifactLibraryStore::new()
let store = ArtifactLibraryStore::new();
let app = store
.get_app(&app_id)
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
Ok(Json(WorkerAppSnapshotResponse { app }))
Ok(Json(WorkerAppSnapshotResponse {
app,
state_root: store.state_root_display().ok(),
}))
}
async fn get_app_version(
@@ -503,10 +521,14 @@ async fn launch_app(
AxumPath(app_id): AxumPath<String>,
) -> Result<Json<WorkerAppSnapshotResponse>, StatusCode> {
authorize(&headers, &state.config.auth_token)?;
let app = ArtifactLibraryStore::new()
let store = ArtifactLibraryStore::new();
let app = store
.mark_app_launched(&app_id)
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
Ok(Json(WorkerAppSnapshotResponse { app }))
Ok(Json(WorkerAppSnapshotResponse {
app,
state_root: store.state_root_display().ok(),
}))
}
async fn open_app_workspace(
@@ -211,6 +211,54 @@ pub struct LibraryAppRecord {
pub current_version: LibraryAppVersionRecord,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct FeedLookupDiagnostics {
pub state_root: String,
pub feed_item_id: String,
pub item_record_path: String,
pub item_record_exists: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub item_record_error: Option<String>,
pub payload_dir: String,
pub payload_dir_exists: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub resolved_file_path: Option<String>,
pub resolved_file_exists: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub resolved_file_error: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct AppLookupDiagnostics {
pub state_root: String,
pub app_id: String,
pub app_dir: String,
pub app_dir_exists: bool,
pub host_manifest_path: String,
pub host_manifest_exists: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub host_manifest_kind: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub host_manifest_error: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub current_version_id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub current_version_dir: Option<String>,
pub current_version_dir_exists: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub version_record_path: Option<String>,
pub version_record_exists: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub version_record_error: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub bundle_manifest_path: Option<String>,
pub bundle_manifest_exists: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub bundle_manifest_error: Option<String>,
pub repo_path: String,
pub repo_exists: bool,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct AppPackageRequest {
pub feed_item_id: String,
@@ -356,6 +404,132 @@ impl ArtifactLibraryStore {
Self
}
pub fn state_root_display(&self) -> io::Result<String> {
Ok(state_root()?.display().to_string())
}
pub fn feed_lookup_diagnostics(&self, feed_item_id: &str) -> io::Result<FeedLookupDiagnostics> {
let state_root_path = state_root()?;
let sanitized_feed_item_id = sanitize_state_component(feed_item_id);
let item_record_path = feed_item_path(&sanitized_feed_item_id)?;
let payload_dir = feed_payload_dir(&sanitized_feed_item_id)?;
let (item_record_exists, item_record_error, item) = match read_json::<FeedItemRecord>(&item_record_path) {
Ok(item) => (item_record_path.exists(), None, item),
Err(error) => (item_record_path.exists(), Some(error.to_string()), None),
};
let (resolved_file_path, resolved_file_exists, resolved_file_error) = if item.is_some() {
match self.feed_file_path(&sanitized_feed_item_id) {
Ok(Some(path)) => (Some(path.display().to_string()), path.exists(), None),
Ok(None) => (None, false, None),
Err(error) => (None, false, Some(error.to_string())),
}
} else {
(None, false, None)
};
Ok(FeedLookupDiagnostics {
state_root: state_root_path.display().to_string(),
feed_item_id: feed_item_id.to_string(),
item_record_path: item_record_path.display().to_string(),
item_record_exists,
item_record_error,
payload_dir: payload_dir.display().to_string(),
payload_dir_exists: payload_dir.exists(),
resolved_file_path,
resolved_file_exists,
resolved_file_error,
})
}
pub fn app_lookup_diagnostics(&self, app_id: &str) -> io::Result<AppLookupDiagnostics> {
let state_root_path = state_root()?;
let sanitized_app_id = sanitize_state_component(app_id);
let app_dir_path = app_dir(&sanitized_app_id)?;
let host_manifest_path = app_manifest_path(&sanitized_app_id)?;
let repo_path = app_repo_dir(&sanitized_app_id)?;
let mut host_manifest_kind = None;
let mut host_manifest_error = None;
let mut current_version_id = None;
match read_host_manifest(&sanitized_app_id) {
Ok(Some(StoredHostManifest::V2(manifest))) => {
host_manifest_kind = Some(String::from("v2"));
current_version_id = Some(manifest.current_version_id);
}
Ok(Some(StoredHostManifest::V1(legacy))) => {
host_manifest_kind = Some(String::from("v1_legacy"));
current_version_id = Some(legacy.current_version_id);
}
Ok(None) => {}
Err(error) => host_manifest_error = Some(error.to_string()),
}
let current_version_dir = current_version_id
.as_deref()
.map(|version_id| app_version_dir(&sanitized_app_id, version_id))
.transpose()?;
let version_record_path = current_version_id
.as_deref()
.map(|version_id| app_version_record_path(&sanitized_app_id, version_id))
.transpose()?;
let bundle_manifest_path = current_version_id
.as_deref()
.map(|version_id| app_version_bundle_dir(&sanitized_app_id, version_id).map(|path| app_bundle_manifest_path(&path)))
.transpose()?;
let version_record_exists = version_record_path
.as_ref()
.is_some_and(|path| path.exists());
let bundle_manifest_exists = bundle_manifest_path
.as_ref()
.is_some_and(|path| path.exists());
let current_version_dir_exists = current_version_dir
.as_ref()
.is_some_and(|path| path.exists());
let version_record_error = match version_record_path.as_ref() {
Some(path) => match read_json::<LibraryAppVersionRecord>(path) {
Ok(_) => None,
Err(error) => Some(error.to_string()),
},
None => None,
};
let bundle_manifest_error = match current_version_dir.as_ref() {
Some(path) => match read_bundle_manifest(&path.join("bundle")) {
Ok(_) => None,
Err(error) => Some(error.to_string()),
},
None => None,
};
Ok(AppLookupDiagnostics {
state_root: state_root_path.display().to_string(),
app_id: app_id.to_string(),
app_dir: app_dir_path.display().to_string(),
app_dir_exists: app_dir_path.exists(),
host_manifest_path: host_manifest_path.display().to_string(),
host_manifest_exists: host_manifest_path.exists(),
host_manifest_kind,
host_manifest_error,
current_version_id: current_version_id.clone(),
current_version_dir: current_version_dir
.as_ref()
.map(|path| path.display().to_string()),
current_version_dir_exists,
version_record_path: version_record_path
.as_ref()
.map(|path| path.display().to_string()),
version_record_exists,
version_record_error,
bundle_manifest_path: bundle_manifest_path
.as_ref()
.map(|path| path.display().to_string()),
bundle_manifest_exists,
bundle_manifest_error,
repo_path: repo_path.display().to_string(),
repo_exists: repo_path.exists(),
})
}
pub fn capture_workspace_snapshot(root: &Path) -> io::Result<WorkspaceSnapshot> {
let mut files = BTreeMap::new();
if !root.exists() {
@@ -709,11 +883,12 @@ impl ArtifactLibraryStore {
Ok(Some(AppPackageResult { app, created }))
}
pub fn list_apps(&self) -> io::Result<Vec<LibraryAppRecord>> {
pub fn list_apps_with_warnings(&self) -> io::Result<(Vec<LibraryAppRecord>, Vec<String>)> {
let mut apps = Vec::new();
let mut warnings = Vec::new();
let entries = match fs::read_dir(library_apps_dir()?) {
Ok(entries) => entries,
Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(apps),
Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok((apps, warnings)),
Err(error) => return Err(error),
};
for entry in entries {
@@ -730,10 +905,12 @@ impl ArtifactLibraryStore {
}
Ok(Some(_)) | Ok(None) => {}
Err(error) => {
eprintln!(
"artifact library: skipping unreadable app `{}` while listing apps: {}",
let warning = format!(
"skipping unreadable app `{}` while listing apps: {}",
app_id, error
);
eprintln!("artifact library: {warning}");
warnings.push(warning);
}
}
}
@@ -744,7 +921,11 @@ impl ArtifactLibraryStore {
.cmp(&left.manifest.last_launched_at)
.then_with(|| left.manifest.title.cmp(&right.manifest.title))
});
Ok(apps)
Ok((apps, warnings))
}
pub fn list_apps(&self) -> io::Result<Vec<LibraryAppRecord>> {
self.list_apps_with_warnings().map(|(apps, _warnings)| apps)
}
pub fn get_app(&self, app_id: &str) -> io::Result<Option<LibraryAppRecord>> {
@@ -2080,4 +2261,56 @@ mod tests {
std::env::remove_var("CLAW_WORKER_DEFAULT_CWD");
let _ = std::fs::remove_dir_all(&state_root);
}
#[test]
fn app_lookup_diagnostics_report_broken_manifest() {
let _lock = test_env_lock();
let state_root =
std::env::temp_dir().join(format!("artifact-lib-diag-app-{}", super::now_secs()));
let workspace = state_root.join("workspace");
let _ = std::fs::remove_dir_all(&state_root);
std::fs::create_dir_all(&workspace).expect("workspace should exist");
std::env::set_var("CLAW_WORKER_STATE_ROOT", &state_root);
std::env::set_var("CLAW_WORKER_DEFAULT_CWD", &workspace);
let broken_dir = super::library_apps_dir()
.expect("library apps dir")
.join("broken-app");
std::fs::create_dir_all(&broken_dir).expect("create broken dir");
std::fs::write(broken_dir.join("manifest.json"), "{not-json").expect("write broken manifest");
let diagnostics = ArtifactLibraryStore::new()
.app_lookup_diagnostics("broken-app")
.expect("diagnostics should load");
assert!(diagnostics.app_dir_exists);
assert!(diagnostics.host_manifest_exists);
assert!(diagnostics.host_manifest_error.is_some());
std::env::remove_var("CLAW_WORKER_STATE_ROOT");
std::env::remove_var("CLAW_WORKER_DEFAULT_CWD");
let _ = std::fs::remove_dir_all(&state_root);
}
#[test]
fn feed_lookup_diagnostics_report_missing_item_paths() {
let _lock = test_env_lock();
let state_root =
std::env::temp_dir().join(format!("artifact-lib-diag-feed-{}", super::now_secs()));
let workspace = state_root.join("workspace");
let _ = std::fs::remove_dir_all(&state_root);
std::fs::create_dir_all(&workspace).expect("workspace should exist");
std::env::set_var("CLAW_WORKER_STATE_ROOT", &state_root);
std::env::set_var("CLAW_WORKER_DEFAULT_CWD", &workspace);
let diagnostics = ArtifactLibraryStore::new()
.feed_lookup_diagnostics("missing-feed")
.expect("diagnostics should load");
assert!(!diagnostics.item_record_exists);
assert!(!diagnostics.payload_dir_exists);
assert!(diagnostics.item_record_path.ends_with("missing-feed.json"));
std::env::remove_var("CLAW_WORKER_STATE_ROOT");
std::env::remove_var("CLAW_WORKER_DEFAULT_CWD");
let _ = std::fs::remove_dir_all(&state_root);
}
}
+6 -5
View File
@@ -53,11 +53,12 @@ pub use background_approval_store::{
};
pub use artifact_library_store::{
AppBundleManifestV1, AppCapability, AppHistoryEntry, AppHistoryResponse, AppKind,
AppPackageRequest, AppPackageResult, AppPublishRequest, AppPublishResult, AppRepoLink,
AppVisibility, AppWorkspaceRecord, ArtifactLibraryStore, FeedFileInput,
FeedItemChangeKind, FeedItemKind, FeedItemRecord, FeedItemSource, FeedItemSourceKind,
FeedToAppLink, LibraryAppManifestV2, LibraryAppRecord, LibraryAppVersionRecord,
WorkspaceChange, WorkspaceSnapshot, WorkspaceSnapshotEntry,
AppLookupDiagnostics, AppPackageRequest, AppPackageResult, AppPublishRequest,
AppPublishResult, AppRepoLink, AppVisibility, AppWorkspaceRecord, ArtifactLibraryStore,
FeedFileInput, FeedItemChangeKind, FeedItemKind, FeedItemRecord, FeedItemSource,
FeedItemSourceKind, FeedLookupDiagnostics, FeedToAppLink, LibraryAppManifestV2,
LibraryAppRecord, LibraryAppVersionRecord, WorkspaceChange, WorkspaceSnapshot,
WorkspaceSnapshotEntry,
};
pub use bootstrap::{BootstrapPhase, BootstrapPlan};
pub use compact::{
+211 -18
View File
@@ -1515,7 +1515,8 @@ fn run_feed_list(_input: Value) -> Result<String, String> {
.collect();
to_pretty_json(json!({
"items": items,
"count": items.len()
"count": items.len(),
"state_root": store.state_root_display().ok(),
}))
}
@@ -1524,8 +1525,33 @@ fn run_feed_get(input: FeedGetInput) -> Result<String, String> {
let store = ArtifactLibraryStore::new();
let item = store
.get_feed_item(&input.feed_item_id)
.map_err(|error| error.to_string())?
.ok_or_else(|| format!("feed item not found: {}", input.feed_item_id))?;
.map_err(|error| {
let diagnostics = store
.feed_lookup_diagnostics(&input.feed_item_id)
.ok();
diagnostic_error(
format!("failed to read feed item: {}", input.feed_item_id).as_str(),
json!({
"feed_item_id": input.feed_item_id,
"state_root": store.state_root_display().ok(),
"diagnostics": diagnostics,
"error": error.to_string(),
}),
)
})?
.ok_or_else(|| {
let diagnostics = store
.feed_lookup_diagnostics(&input.feed_item_id)
.ok();
diagnostic_error(
format!("feed item not found: {}", input.feed_item_id).as_str(),
json!({
"feed_item_id": input.feed_item_id,
"state_root": store.state_root_display().ok(),
"diagnostics": diagnostics,
}),
)
})?;
let linked_app = item
.linked_app_id
.as_deref()
@@ -1536,12 +1562,15 @@ fn run_feed_get(input: FeedGetInput) -> Result<String, String> {
to_pretty_json(json!({
"item": item,
"linked_app": linked_app,
"state_root": store.state_root_display().ok(),
}))
}
fn run_app_list(_input: Value) -> Result<String, String> {
let store = ArtifactLibraryStore::new();
let apps = store.list_apps().map_err(|error| error.to_string())?;
let (apps, warnings) = store
.list_apps_with_warnings()
.map_err(|error| error.to_string())?;
let apps: Vec<_> = apps
.into_iter()
.enumerate()
@@ -1563,7 +1592,9 @@ fn run_app_list(_input: Value) -> Result<String, String> {
.collect();
to_pretty_json(json!({
"apps": apps,
"count": apps.len()
"count": apps.len(),
"state_root": store.state_root_display().ok(),
"warnings": warnings,
}))
}
@@ -1572,8 +1603,29 @@ fn run_app_get(input: AppGetInput) -> Result<String, String> {
let store = ArtifactLibraryStore::new();
let app = store
.get_app(&input.app_id)
.map_err(|error| error.to_string())?
.ok_or_else(|| format!("app not found: {}", input.app_id))?;
.map_err(|error| {
let diagnostics = store.app_lookup_diagnostics(&input.app_id).ok();
diagnostic_error(
format!("failed to read app: {}", input.app_id).as_str(),
json!({
"app_id": input.app_id,
"state_root": store.state_root_display().ok(),
"diagnostics": diagnostics,
"error": error.to_string(),
}),
)
})?
.ok_or_else(|| {
let diagnostics = store.app_lookup_diagnostics(&input.app_id).ok();
diagnostic_error(
format!("app not found: {}", input.app_id).as_str(),
json!({
"app_id": input.app_id,
"state_root": store.state_root_display().ok(),
"diagnostics": diagnostics,
}),
)
})?;
let linked_feed_items: Vec<_> = app
.manifest
.source_feed_item_ids
@@ -1591,6 +1643,7 @@ fn run_app_get(input: AppGetInput) -> Result<String, String> {
"linked_feed_items": linked_feed_items,
"history": history,
"workspace": workspace,
"state_root": store.state_root_display().ok(),
}))
}
@@ -1599,19 +1652,42 @@ fn run_app_create_from_feed(input: AppCreateFromFeedInput) -> Result<String, Str
let store = ArtifactLibraryStore::new();
match store
.package_feed_item(AppPackageRequest {
feed_item_id: input.feed_item_id,
feed_item_id: input.feed_item_id.clone(),
requested_app_id: input.app_id,
title: input.title,
description: input.description,
})
.map_err(|error| error.to_string())?
.map_err(|error| {
let diagnostics = store
.feed_lookup_diagnostics(&input.feed_item_id)
.ok();
diagnostic_error(
format!("failed to create app from feed item: {}", input.feed_item_id).as_str(),
json!({
"feed_item_id": input.feed_item_id,
"state_root": store.state_root_display().ok(),
"diagnostics": diagnostics,
"error": error.to_string(),
}),
)
})?
{
Some(result) => to_pretty_json(json!({
"created": result.created,
"app": result.app,
"state_root": store.state_root_display().ok(),
})),
None => Err(String::from(
"feed item could not be packaged into an app",
None => Err(diagnostic_error(
format!(
"feed item could not be packaged into an app: {}",
input.feed_item_id
)
.as_str(),
json!({
"feed_item_id": input.feed_item_id,
"state_root": store.state_root_display().ok(),
"diagnostics": store.feed_lookup_diagnostics(&input.feed_item_id).ok(),
}),
)),
}
}
@@ -1621,7 +1697,17 @@ fn run_app_open_workspace(input: AppIdInput) -> Result<String, String> {
let store = ArtifactLibraryStore::new();
match store
.open_workspace(&input.app_id)
.map_err(|error| error.to_string())?
.map_err(|error| {
diagnostic_error(
format!("failed to open app workspace: {}", input.app_id).as_str(),
json!({
"app_id": input.app_id,
"state_root": store.state_root_display().ok(),
"diagnostics": store.app_lookup_diagnostics(&input.app_id).ok(),
"error": error.to_string(),
}),
)
})?
{
Some(workspace) => {
persist_app_workspace_context(&AppWorkspaceContext {
@@ -1641,9 +1727,17 @@ fn run_app_open_workspace(input: AppIdInput) -> Result<String, String> {
.map_err(|error| error.to_string())?
.display()
.to_string(),
"state_root": store.state_root_display().ok(),
}))
}
None => Err(format!("app not found: {}", input.app_id)),
None => Err(diagnostic_error(
format!("app not found: {}", input.app_id).as_str(),
json!({
"app_id": input.app_id,
"state_root": store.state_root_display().ok(),
"diagnostics": store.app_lookup_diagnostics(&input.app_id).ok(),
}),
)),
}
}
@@ -1657,7 +1751,17 @@ fn run_app_publish(input: AppPublishInput) -> Result<String, String> {
message: input.message,
},
)
.map_err(|error| error.to_string())?
.map_err(|error| {
diagnostic_error(
format!("failed to publish app workspace: {}", input.app_id).as_str(),
json!({
"app_id": input.app_id,
"state_root": store.state_root_display().ok(),
"diagnostics": store.app_lookup_diagnostics(&input.app_id).ok(),
"error": error.to_string(),
}),
)
})?
{
Some(result) => {
persist_app_workspace_context(&AppWorkspaceContext {
@@ -1671,9 +1775,20 @@ fn run_app_publish(input: AppPublishInput) -> Result<String, String> {
.as_secs(),
})
.map_err(|error| error.to_string())?;
to_pretty_json(result)
to_pretty_json(json!({
"app": result.app,
"workspace": result.workspace,
"state_root": store.state_root_display().ok(),
}))
}
None => Err(format!("app not found: {}", input.app_id)),
None => Err(diagnostic_error(
format!("app not found: {}", input.app_id).as_str(),
json!({
"app_id": input.app_id,
"state_root": store.state_root_display().ok(),
"diagnostics": store.app_lookup_diagnostics(&input.app_id).ok(),
}),
)),
}
}
@@ -1682,7 +1797,17 @@ fn run_app_archive(input: AppIdInput) -> Result<String, String> {
let store = ArtifactLibraryStore::new();
match store
.archive_app(&input.app_id)
.map_err(|error| error.to_string())?
.map_err(|error| {
diagnostic_error(
format!("failed to archive app: {}", input.app_id).as_str(),
json!({
"app_id": input.app_id,
"state_root": store.state_root_display().ok(),
"diagnostics": store.app_lookup_diagnostics(&input.app_id).ok(),
"error": error.to_string(),
}),
)
})?
{
Some(app) => {
if load_app_workspace_context()
@@ -1694,9 +1819,17 @@ fn run_app_archive(input: AppIdInput) -> Result<String, String> {
to_pretty_json(json!({
"archived": true,
"app": app,
"state_root": store.state_root_display().ok(),
}))
}
None => Err(format!("app not found: {}", input.app_id)),
None => Err(diagnostic_error(
format!("app not found: {}", input.app_id).as_str(),
json!({
"app_id": input.app_id,
"state_root": store.state_root_display().ok(),
"diagnostics": store.app_lookup_diagnostics(&input.app_id).ok(),
}),
)),
}
}
@@ -2406,6 +2539,13 @@ fn to_pretty_json<T: serde::Serialize>(value: T) -> Result<String, String> {
serde_json::to_string_pretty(&value).map_err(|error| error.to_string())
}
fn diagnostic_error(summary: &str, details: Value) -> String {
match serde_json::to_string_pretty(&details) {
Ok(rendered) => format!("{summary}\n{rendered}"),
Err(error) => format!("{summary} ({error})"),
}
}
#[allow(clippy::needless_pass_by_value)]
fn io_to_string(error: std::io::Error) -> String {
error.to_string()
@@ -6200,6 +6340,7 @@ mod tests {
let listed_json: serde_json::Value =
serde_json::from_str(&listed).expect("listed json");
assert_eq!(listed_json["count"], 1);
assert!(listed_json["state_root"].as_str().is_some());
let opened = execute_tool(
"AppOpenWorkspace",
@@ -6264,6 +6405,7 @@ mod tests {
.as_array()
.is_some_and(|entries| entries.len() >= 2)
);
assert!(fetched_json["state_root"].as_str().is_some());
let archived = execute_tool(
"AppArchive",
@@ -6293,6 +6435,57 @@ mod tests {
result
}
#[test]
fn app_tools_report_lookup_diagnostics_for_missing_ids() {
let _guard = env_lock()
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
let state_root = temp_path("app-diag-state");
let workspace_root = temp_path("app-diag-workspace");
fs::create_dir_all(&state_root).expect("create state root");
fs::create_dir_all(&workspace_root).expect("create workspace root");
let old_state_root = std::env::var_os("CLAW_WORKER_STATE_ROOT");
let old_default_cwd = std::env::var_os("CLAW_WORKER_DEFAULT_CWD");
std::env::set_var("CLAW_WORKER_STATE_ROOT", &state_root);
std::env::set_var("CLAW_WORKER_DEFAULT_CWD", &workspace_root);
let result = (|| {
let error = execute_tool(
"AppGet",
&json!({
"app_id": "missing-app",
}),
)
.expect_err("missing app should report diagnostics");
assert!(error.contains("\"app_id\": \"missing-app\""));
assert!(error.contains("\"state_root\":"));
assert!(error.contains("\"app_dir_exists\": false"));
let error = execute_tool(
"FeedGet",
&json!({
"feed_item_id": "missing-feed",
}),
)
.expect_err("missing feed should report diagnostics");
assert!(error.contains("\"feed_item_id\": \"missing-feed\""));
assert!(error.contains("\"item_record_exists\": false"));
})();
match old_state_root {
Some(value) => std::env::set_var("CLAW_WORKER_STATE_ROOT", value),
None => std::env::remove_var("CLAW_WORKER_STATE_ROOT"),
}
match old_default_cwd {
Some(value) => std::env::set_var("CLAW_WORKER_DEFAULT_CWD", value),
None => std::env::remove_var("CLAW_WORKER_DEFAULT_CWD"),
}
let _ = fs::remove_dir_all(&state_root);
let _ = fs::remove_dir_all(&workspace_root);
result
}
#[test]
fn rejects_unknown_tool_names() {
let error = execute_tool("nope", &json!({})).expect_err("tool should be rejected");