mirror of
https://github.com/akitaonrails/ai-memory.git
synced 2026-10-02 03:24:46 +08:00
feat(admin): GET /admin/projects — authoritative project inventory
Expose the full `(workspace, project)` list (with page counts + last-updated, reusing `list_projects_with_stats`) as a read-only admin endpoint. Useful to any external consumer that needs the live project inventory — a dashboard, an export, or a backup/mirror that lays the wiki out as `wiki/<workspace>/<project>/` and wants to reconcile against it: any directory whose project no longer appears here is an orphan (e.g. from a delete that bypassed the admission chain, such as an offline `--data-dir` purge or a direct DB edit) and can be pruned so the copy reflects the live server. Test: seeds two (workspace, project) scopes and asserts both surface in the `projects` array.
This commit is contained in:
@@ -8,6 +8,7 @@
|
||||
//! - `POST /admin/auto-improve/report` — read-only auto-improve telemetry report.
|
||||
//! - `POST /admin/curator` — dry-run or stage a rule-based curator report.
|
||||
//! - `GET /admin/status` — lifetime counts + server data-dir info.
|
||||
//! - `GET /admin/projects` — authoritative `(workspace, project)` list.
|
||||
//! - `GET /admin/search?q=` — FTS5 hits against the wiki index.
|
||||
//! - `POST /admin/reorg` — retro-fit sessions to per-cwd projects.
|
||||
//! - `POST /admin/lint` — run the M8 lint pass.
|
||||
@@ -523,6 +524,7 @@ pub fn admin_router(state: AdminState) -> Router {
|
||||
post(handle_pending_write_reject),
|
||||
)
|
||||
.route("/admin/status", get(handle_status))
|
||||
.route("/admin/projects", get(handle_list_projects))
|
||||
.route(
|
||||
"/admin/audit-contamination",
|
||||
get(handle_audit_contamination),
|
||||
@@ -752,6 +754,24 @@ pub struct StatusReport {
|
||||
pub providers: ProviderHealthSnapshot,
|
||||
}
|
||||
|
||||
/// `GET /admin/projects` — the authoritative list of `(workspace, project)`
|
||||
/// pairs (with page counts + last-updated), read-only. Useful to any external
|
||||
/// consumer that needs the live project inventory: a dashboard, an export, or
|
||||
/// a backup/mirror that lays the wiki out as `wiki/<workspace>/<project>/` and
|
||||
/// wants to reconcile against it — any directory whose project no longer
|
||||
/// appears here is an orphan (e.g. from a delete that bypassed the admission
|
||||
/// chain, such as an offline `--data-dir` purge or a direct DB edit) and can
|
||||
/// be pruned so the copy reflects the live server.
|
||||
async fn handle_list_projects(State(state): State<Arc<AdminState>>) -> impl IntoResponse {
|
||||
match state.reader.list_projects_with_stats().await {
|
||||
Ok(projects) => (
|
||||
StatusCode::OK,
|
||||
Json(serde_json::json!({ "projects": projects })),
|
||||
),
|
||||
Err(e) => internal_err(e.to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
async fn handle_status(State(state): State<Arc<AdminState>>) -> impl IntoResponse {
|
||||
match state.reader.status_counts().await {
|
||||
Ok(counts) => match state.reader.derived_index_status().await {
|
||||
|
||||
@@ -121,6 +121,74 @@ async fn status_returns_counts_and_paths() {
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn list_projects_returns_workspace_project_pairs() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let (state, store) = make_admin_state(&tmp).await;
|
||||
// Seed pages in two distinct (workspace, project) scopes.
|
||||
seed_page(&store, "A", "notes/a.md", "body a").await; // default/scratch
|
||||
let ws = store
|
||||
.writer
|
||||
.get_or_create_workspace("acme".to_string())
|
||||
.await
|
||||
.unwrap();
|
||||
let proj = store
|
||||
.writer
|
||||
.get_or_create_project(ws, "infra".to_string(), None)
|
||||
.await
|
||||
.unwrap();
|
||||
store
|
||||
.writer
|
||||
.upsert_page(NewPage {
|
||||
workspace_id: ws,
|
||||
project_id: proj,
|
||||
path: PagePath::new("notes/b.md").unwrap(),
|
||||
title: "B".into(),
|
||||
body: "body b".into(),
|
||||
tier: Tier::Semantic,
|
||||
frontmatter_json: serde_json::json!({}),
|
||||
pinned: false,
|
||||
links: Vec::new(),
|
||||
author_id: None,
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
let app = admin_router(state);
|
||||
|
||||
let resp = app
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("GET")
|
||||
.uri("/admin/projects")
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::OK);
|
||||
|
||||
let body: serde_json::Value = serde_json::from_slice(&body_bytes(resp).await).unwrap();
|
||||
let pairs: Vec<(String, String)> = body["projects"]
|
||||
.as_array()
|
||||
.expect("projects array")
|
||||
.iter()
|
||||
.map(|p| {
|
||||
(
|
||||
p["workspace_name"].as_str().unwrap().to_string(),
|
||||
p["project_name"].as_str().unwrap().to_string(),
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
assert!(
|
||||
pairs.contains(&("default".to_string(), "scratch".to_string())),
|
||||
"{pairs:?}"
|
||||
);
|
||||
assert!(
|
||||
pairs.contains(&("acme".to_string(), "infra".to_string())),
|
||||
"{pairs:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn search_returns_matching_hits() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
|
||||
Reference in New Issue
Block a user