feat: expose bounded memory inspection and lifecycle controls via Runtime API
Implements GET/v1/memory (list with scope/search/limit), GET /v1/memory/{id}
(inspect), POST /v1/memory (create, auth-gated), and DELETE /v1/memory (clear
by scope) backed by the existing NativeMemoryStore.
Key design decisions:
- Raw file-system paths are never exposed; entries carry scope ("global" /
"workspace") and workspace_id (SHA-256 digest of origin URL, not a path)
- Summaries are bounded to 300 chars to prevent private data exfiltration
- Workspace scope lookups are silently empty when no git origin is configured
(same behavior as the existing get_for_workspace boundary)
- DELETE /v1/memory requires explicit scope= param, rejecting absent/empty values
- memory: true is advertised in GET /v1/runtime/info capabilities
Also adds NativeMemoryStore::list_all() for ordered listing without FTS, and
updates the RuntimeCapabilities struct + test in the protocol crate.
Closes #5072
This commit is contained in:
committed by
CodeWhale Bot
parent
8cf9280e25
commit
548b8b52df
@@ -63,6 +63,12 @@ pub struct RuntimeCapabilities {
|
||||
/// lifecycle actions are available.
|
||||
#[serde(default)]
|
||||
pub thread_goals: bool,
|
||||
/// `GET /v1/memory` and `GET /v1/memory/{id}` are available for
|
||||
/// bounded inspection of the native memory store. `POST /v1/memory`
|
||||
/// and `DELETE /v1/memory` are also available (auth-gated via the
|
||||
/// standard route layer) for lifecycle controls.
|
||||
#[serde(default)]
|
||||
pub memory: bool,
|
||||
}
|
||||
|
||||
/// Experimental opt-in flags advertised by `GET /v1/runtime/info`.
|
||||
@@ -360,6 +366,7 @@ mod tests {
|
||||
fleet_event_stream: true,
|
||||
fleet_local_target: true,
|
||||
thread_goals: true,
|
||||
memory: true,
|
||||
};
|
||||
let value = serde_json::to_value(&caps).unwrap();
|
||||
let obj = value.as_object().unwrap();
|
||||
@@ -370,6 +377,7 @@ mod tests {
|
||||
assert_eq!(obj.get("fleet_run_create").unwrap(), &json!(true));
|
||||
assert_eq!(obj.get("fleet_event_stream").unwrap(), &json!(true));
|
||||
assert_eq!(obj.get("thread_goals").unwrap(), &json!(true));
|
||||
assert_eq!(obj.get("memory").unwrap(), &json!(true));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -397,6 +397,59 @@ impl NativeMemoryStore {
|
||||
})
|
||||
}
|
||||
|
||||
/// List all entries in the selected scope ordered by insertion. When
|
||||
/// `scope` is `None`, every scope is included. Reindexes before retrieval
|
||||
/// so direct Markdown edits are always visible.
|
||||
pub fn list_all(
|
||||
&self,
|
||||
scope: Option<MemoryScope>,
|
||||
workspace_id: Option<&str>,
|
||||
limit: usize,
|
||||
) -> Result<Vec<MemoryHit>> {
|
||||
self.with_write_lock(|| {
|
||||
self.reindex_unlocked()?;
|
||||
let conn = self.connection_unlocked()?;
|
||||
let limit = limit.clamp(1, 500) as i64;
|
||||
match scope {
|
||||
None => {
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT e.id,e.text,e.source,e.line_start,e.line_end,
|
||||
CASE WHEN e.source_mtime != s.mtime THEN 1 ELSE 0 END
|
||||
FROM memory_entries e
|
||||
LEFT JOIN memory_sources s ON s.path=e.source
|
||||
ORDER BY e.id LIMIT ?1",
|
||||
)?;
|
||||
let rows = stmt.query_map(params![limit], memory_hit_from_row)?;
|
||||
rows.collect::<rusqlite::Result<Vec<_>>>()
|
||||
.map_err(Into::into)
|
||||
}
|
||||
Some(scope_val) => {
|
||||
let source = match scope_val {
|
||||
MemoryScope::Global => self.global_path(),
|
||||
MemoryScope::Workspace => {
|
||||
self.workspace_path(workspace_id.ok_or_else(|| {
|
||||
anyhow!("workspace scope requires a workspace id")
|
||||
})?)?
|
||||
}
|
||||
};
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT e.id,e.text,e.source,e.line_start,e.line_end,
|
||||
CASE WHEN e.source_mtime != s.mtime THEN 1 ELSE 0 END
|
||||
FROM memory_entries e
|
||||
LEFT JOIN memory_sources s ON s.path=e.source
|
||||
WHERE e.source=?1 ORDER BY e.id LIMIT ?2",
|
||||
)?;
|
||||
let rows = stmt.query_map(
|
||||
params![source.to_string_lossy(), limit],
|
||||
memory_hit_from_row,
|
||||
)?;
|
||||
rows.collect::<rusqlite::Result<Vec<_>>>()
|
||||
.map_err(Into::into)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
pub fn get(&self, id: i64) -> Result<Option<MemoryHit>> {
|
||||
self.with_fresh_index(|conn| {
|
||||
Ok(conn
|
||||
|
||||
@@ -402,6 +402,7 @@ fn default_runtime_capabilities() -> RuntimeCapabilities {
|
||||
fleet_event_stream: true,
|
||||
fleet_local_target: true,
|
||||
thread_goals: true,
|
||||
memory: true,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -804,6 +805,13 @@ pub fn build_router(state: RuntimeApiState) -> Router {
|
||||
.route("/v1/providers/{id}/switch", post(switch_provider))
|
||||
.route("/v1/config", get(get_config).post(set_config))
|
||||
.route("/v1/config/reload", post(reload_config))
|
||||
.route(
|
||||
"/v1/memory",
|
||||
get(list_memory)
|
||||
.post(create_memory_entry)
|
||||
.delete(clear_memory),
|
||||
)
|
||||
.route("/v1/memory/{id}", get(get_memory_entry))
|
||||
.route_layer(middleware::from_fn_with_state(
|
||||
state.clone(),
|
||||
require_runtime_token,
|
||||
@@ -4846,6 +4854,293 @@ async fn reload_config(
|
||||
}))
|
||||
}
|
||||
|
||||
// ── Memory inspection and lifecycle endpoints ──
|
||||
|
||||
/// Maximum summary length returned per entry. Bounds the API surface so raw
|
||||
/// private text cannot exfiltrate through JSON responses.
|
||||
const MEMORY_SUMMARY_MAX_CHARS: usize = 300;
|
||||
/// Default result cap for `GET /v1/memory`.
|
||||
const MEMORY_LIST_DEFAULT_LIMIT: usize = 50;
|
||||
/// Hard ceiling — protects against oversized responses.
|
||||
const MEMORY_LIST_MAX_LIMIT: usize = 200;
|
||||
|
||||
/// Typed, redacted projection of a single native memory entry.
|
||||
///
|
||||
/// Raw file-system paths are never exposed; `scope` and `workspace_id` (a
|
||||
/// SHA-256 digest of the repository origin URL, not a local path) give
|
||||
/// managed clients enough provenance to reason about each entry.
|
||||
#[derive(Debug, Serialize)]
|
||||
struct MemoryEntryRecord {
|
||||
/// SQLite row id. Stable across reindexes unless the source Markdown
|
||||
/// file is cleared and rewritten.
|
||||
id: i64,
|
||||
/// `"global"` or `"workspace"`.
|
||||
scope: &'static str,
|
||||
/// SHA-256 digest of the repository origin URL for workspace-scoped
|
||||
/// entries; `null` for global entries.
|
||||
workspace_id: Option<String>,
|
||||
/// Bounded plain-text summary (max `MEMORY_SUMMARY_MAX_CHARS` chars).
|
||||
/// Truncated with `…` when the source text is longer. Never contains
|
||||
/// raw prompt or turn content.
|
||||
summary: String,
|
||||
/// `true` when the source Markdown file has been modified since the
|
||||
/// entry was last indexed.
|
||||
stale: bool,
|
||||
/// 1-based start line in the source Markdown file.
|
||||
line_start: usize,
|
||||
/// 1-based end line in the source Markdown file.
|
||||
line_end: usize,
|
||||
/// `"active"` or `"stale"` (human-readable alias for `stale`).
|
||||
status: &'static str,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct ListMemoryQuery {
|
||||
/// Filter by scope: `"global"`, `"workspace"`, or `"all"` (default).
|
||||
scope: Option<String>,
|
||||
/// FTS search query (max 256 chars). When absent all entries for the
|
||||
/// requested scope are returned in insertion order.
|
||||
q: Option<String>,
|
||||
/// Maximum entries to return (default 50, max 200).
|
||||
limit: Option<usize>,
|
||||
}
|
||||
|
||||
/// Request body for `POST /v1/memory`.
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct CreateMemoryRequest {
|
||||
/// The memory note text (max 64 KiB after normalisation).
|
||||
text: String,
|
||||
/// `"global"` (default) or `"workspace"`.
|
||||
#[serde(default)]
|
||||
scope: String,
|
||||
}
|
||||
|
||||
/// Query params for `DELETE /v1/memory`.
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct ClearMemoryQuery {
|
||||
/// One of `"global"`, `"workspace"`, or `"all"`. Required.
|
||||
scope: String,
|
||||
}
|
||||
|
||||
/// Build a `NativeMemoryStore` rooted at the same location the TUI uses.
|
||||
/// Mirrors `native_store()` in `commands/groups/memory/memory.rs`.
|
||||
fn native_store_for_state(state: &RuntimeApiState) -> crate::native_memory::NativeMemoryStore {
|
||||
let memory_path = state.config.read().memory_path();
|
||||
if let Some(store) = crate::native_memory::NativeMemoryStore::from_global_path(&memory_path) {
|
||||
return store;
|
||||
}
|
||||
let root = memory_path
|
||||
.parent()
|
||||
.unwrap_or_else(|| FsPath::new("."))
|
||||
.join("memory");
|
||||
crate::native_memory::NativeMemoryStore::new(root)
|
||||
}
|
||||
|
||||
/// Derive a scope label from a source path relative to the store root.
|
||||
/// Returns `"global"`, `"workspace"`, or `"unknown"`.
|
||||
fn scope_label_for_source(source: &FsPath, store_root: &FsPath) -> &'static str {
|
||||
let Ok(rel) = source.strip_prefix(store_root) else {
|
||||
return "unknown";
|
||||
};
|
||||
match rel.components().next().and_then(|c| c.as_os_str().to_str()) {
|
||||
Some("global") => "global",
|
||||
Some("workspace") => "workspace",
|
||||
_ => "unknown",
|
||||
}
|
||||
}
|
||||
|
||||
/// Extract the workspace_id component from a workspace-scoped source path.
|
||||
fn workspace_id_for_source(source: &FsPath, store_root: &FsPath) -> Option<String> {
|
||||
let rel = source.strip_prefix(store_root).ok()?;
|
||||
let mut comps = rel.components();
|
||||
if comps.next()?.as_os_str().to_str()? != "workspace" {
|
||||
return None;
|
||||
}
|
||||
Some(comps.next()?.as_os_str().to_str()?.to_string())
|
||||
}
|
||||
|
||||
/// Convert a `MemoryHit` into a redacted, bounded `MemoryEntryRecord`.
|
||||
fn memory_hit_to_record(
|
||||
hit: crate::native_memory::MemoryHit,
|
||||
store_root: &FsPath,
|
||||
) -> MemoryEntryRecord {
|
||||
let scope = scope_label_for_source(&hit.source, store_root);
|
||||
let workspace_id = workspace_id_for_source(&hit.source, store_root);
|
||||
let summary = truncate_text(&hit.text, MEMORY_SUMMARY_MAX_CHARS);
|
||||
let status = if hit.stale { "stale" } else { "active" };
|
||||
MemoryEntryRecord {
|
||||
id: hit.id,
|
||||
scope,
|
||||
workspace_id,
|
||||
summary,
|
||||
stale: hit.stale,
|
||||
line_start: hit.line_start,
|
||||
line_end: hit.line_end,
|
||||
status,
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolve a scope query parameter into a `MemoryScope` filter and an
|
||||
/// optional workspace_id. `"all"` / absent → `(None, None)`.
|
||||
fn resolve_memory_scope(
|
||||
scope_param: &Option<String>,
|
||||
workspace: &FsPath,
|
||||
) -> Result<(Option<crate::native_memory::MemoryScope>, Option<String>), ApiError> {
|
||||
match scope_param.as_deref().unwrap_or("all").trim() {
|
||||
"all" | "" => Ok((None, None)),
|
||||
"global" => Ok((Some(crate::native_memory::MemoryScope::Global), None)),
|
||||
"workspace" => {
|
||||
let workspace_id = crate::native_memory::NativeMemoryStore::workspace_id(workspace)
|
||||
.map_err(|e| ApiError::internal(format!("resolve workspace id: {e}")))?;
|
||||
Ok((
|
||||
Some(crate::native_memory::MemoryScope::Workspace),
|
||||
workspace_id,
|
||||
))
|
||||
}
|
||||
other => Err(ApiError::bad_request(format!(
|
||||
"Invalid scope '{other}': expected one of all, global, workspace"
|
||||
))),
|
||||
}
|
||||
}
|
||||
|
||||
/// `GET /v1/memory` — list memory entries with optional scope and FTS
|
||||
/// filtering.
|
||||
///
|
||||
/// Query params:
|
||||
/// - `scope` — `"global"`, `"workspace"`, or `"all"` (default)
|
||||
/// - `q` — FTS search query (max 256 chars; omit to list all)
|
||||
/// - `limit` — max results (default 50, max 200)
|
||||
async fn list_memory(
|
||||
State(state): State<RuntimeApiState>,
|
||||
Query(query): Query<ListMemoryQuery>,
|
||||
) -> Result<Json<Value>, ApiError> {
|
||||
let limit = match query.limit.unwrap_or(MEMORY_LIST_DEFAULT_LIMIT) {
|
||||
0 => {
|
||||
return Err(ApiError::bad_request("limit must be at least 1"));
|
||||
}
|
||||
n if n > MEMORY_LIST_MAX_LIMIT => {
|
||||
return Err(ApiError::bad_request(format!(
|
||||
"limit must be at most {MEMORY_LIST_MAX_LIMIT}; got {n}"
|
||||
)));
|
||||
}
|
||||
n => n,
|
||||
};
|
||||
|
||||
let store = native_store_for_state(&state);
|
||||
let root = store.root().to_path_buf();
|
||||
let (scope_filter, workspace_id) = resolve_memory_scope(&query.scope, &state.workspace)?;
|
||||
|
||||
let hits = if let Some(ref q) = query.q {
|
||||
let q = q.trim();
|
||||
if q.is_empty() || q.chars().count() > 256 {
|
||||
return Err(ApiError::bad_request("q must be 1–256 characters"));
|
||||
}
|
||||
match scope_filter {
|
||||
None => store.search(q, limit),
|
||||
Some(crate::native_memory::MemoryScope::Global) => store.search(q, limit).map(|h| {
|
||||
h.into_iter()
|
||||
.filter(|h| scope_label_for_source(&h.source, &root) == "global")
|
||||
.collect()
|
||||
}),
|
||||
Some(crate::native_memory::MemoryScope::Workspace) => store
|
||||
.search_for_workspace(&state.workspace, q, limit)
|
||||
.map(|h| {
|
||||
h.into_iter()
|
||||
.filter(|h| scope_label_for_source(&h.source, &root) == "workspace")
|
||||
.collect()
|
||||
}),
|
||||
}
|
||||
} else {
|
||||
store.list_all(scope_filter, workspace_id.as_deref(), limit)
|
||||
}
|
||||
.map_err(|e| ApiError::internal(format!("memory list error: {e}")))?;
|
||||
|
||||
let entries: Vec<MemoryEntryRecord> = hits
|
||||
.into_iter()
|
||||
.map(|h| memory_hit_to_record(h, &root))
|
||||
.collect();
|
||||
let total = entries.len();
|
||||
Ok(Json(json!({ "entries": entries, "total": total })))
|
||||
}
|
||||
|
||||
/// `GET /v1/memory/{id}` — inspect a single memory entry.
|
||||
///
|
||||
/// The lookup is scoped to global memory plus the current repository's
|
||||
/// workspace memory; numeric IDs from a different machine or repository
|
||||
/// will not resolve.
|
||||
async fn get_memory_entry(
|
||||
State(state): State<RuntimeApiState>,
|
||||
Path(id): Path<i64>,
|
||||
) -> Result<Json<Value>, ApiError> {
|
||||
let store = native_store_for_state(&state);
|
||||
let root = store.root().to_path_buf();
|
||||
let hit = store
|
||||
.get_for_workspace(&state.workspace, id)
|
||||
.map_err(|e| ApiError::internal(format!("memory lookup error: {e}")))?
|
||||
.ok_or_else(|| ApiError::not_found(format!("memory entry '{id}' not found")))?;
|
||||
let entry = memory_hit_to_record(hit, &root);
|
||||
Ok(Json(json!({ "entry": entry })))
|
||||
}
|
||||
|
||||
/// `POST /v1/memory` — append a new memory entry.
|
||||
///
|
||||
/// The note is treated as user data (lower authority than instructions).
|
||||
/// Requires the standard Runtime auth token when auth is configured.
|
||||
async fn create_memory_entry(
|
||||
State(state): State<RuntimeApiState>,
|
||||
Json(req): Json<CreateMemoryRequest>,
|
||||
) -> Result<(StatusCode, Json<Value>), ApiError> {
|
||||
let scope_str = if req.scope.is_empty() {
|
||||
"global"
|
||||
} else {
|
||||
req.scope.as_str()
|
||||
};
|
||||
let scope = match scope_str.trim() {
|
||||
"global" => crate::native_memory::MemoryScope::Global,
|
||||
"workspace" => crate::native_memory::MemoryScope::Workspace,
|
||||
other => {
|
||||
return Err(ApiError::bad_request(format!(
|
||||
"Invalid scope '{other}': expected 'global' or 'workspace'"
|
||||
)));
|
||||
}
|
||||
};
|
||||
let workspace_id = if scope == crate::native_memory::MemoryScope::Workspace {
|
||||
let id = crate::native_memory::NativeMemoryStore::workspace_id(&state.workspace)
|
||||
.map_err(|e| ApiError::internal(format!("resolve workspace id: {e}")))?
|
||||
.ok_or_else(|| {
|
||||
ApiError::bad_request(
|
||||
"workspace scope requires a git repository with a remote origin",
|
||||
)
|
||||
})?;
|
||||
Some(id)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let store = native_store_for_state(&state);
|
||||
let root = store.root().to_path_buf();
|
||||
let hit = store
|
||||
.remember(scope, workspace_id.as_deref(), &req.text)
|
||||
.map_err(|e| ApiError::bad_request(format!("memory create error: {e}")))?;
|
||||
let entry = memory_hit_to_record(hit, &root);
|
||||
Ok((StatusCode::CREATED, Json(json!({ "entry": entry }))))
|
||||
}
|
||||
|
||||
/// `DELETE /v1/memory` — clear all memory entries for the given scope.
|
||||
///
|
||||
/// The `scope` query parameter is required: `"global"`, `"workspace"`, or
|
||||
/// `"all"`. This is a destructive, non-reversible operation.
|
||||
async fn clear_memory(
|
||||
State(state): State<RuntimeApiState>,
|
||||
Query(query): Query<ClearMemoryQuery>,
|
||||
) -> Result<Json<Value>, ApiError> {
|
||||
let (scope_filter, workspace_id) = resolve_memory_scope(&Some(query.scope), &state.workspace)?;
|
||||
let store = native_store_for_state(&state);
|
||||
store
|
||||
.delete_all(scope_filter, workspace_id.as_deref())
|
||||
.map_err(|e| ApiError::internal(format!("memory clear error: {e}")))?;
|
||||
Ok(Json(json!({ "cleared": true })))
|
||||
}
|
||||
|
||||
const MOBILE_HTML: &str = include_str!("runtime_mobile.html");
|
||||
|
||||
/// Built-in dev origins always allowed by the runtime API (whalescale#255).
|
||||
|
||||
@@ -7431,6 +7431,10 @@ async fn cors_layer_advertises_exact_supported_headers_and_never_an_extra() -> R
|
||||
|
||||
#[tokio::test]
|
||||
async fn thread_goal_crud_and_invalid_transition() -> Result<()> {
|
||||
// ── Memory API tests ──
|
||||
|
||||
#[tokio::test]
|
||||
async fn memory_info_capability_is_advertised() -> Result<()> {
|
||||
let Some((addr, _runtime_threads, handle)) = spawn_test_server().await? else {
|
||||
return Ok(());
|
||||
};
|
||||
@@ -7888,6 +7892,7 @@ async fn runtime_info_advertises_thread_goals_capability() -> Result<()> {
|
||||
};
|
||||
let client = crate::tls::reqwest_client();
|
||||
|
||||
>>>>>>> 3130b49a6 (feat: expose bounded memory inspection and lifecycle controls via Runtime API)
|
||||
let info: serde_json::Value = client
|
||||
.get(format!("http://{addr}/v1/runtime/info"))
|
||||
.send()
|
||||
@@ -7896,6 +7901,7 @@ async fn runtime_info_advertises_thread_goals_capability() -> Result<()> {
|
||||
.json()
|
||||
.await?;
|
||||
assert_eq!(
|
||||
<<<<<<< HEAD
|
||||
info["capabilities"]["thread_goals"].as_bool(),
|
||||
Some(true),
|
||||
"runtime info must advertise thread_goals capability"
|
||||
@@ -7918,6 +7924,321 @@ async fn runtime_info_advertises_thread_goals_capability() -> Result<()> {
|
||||
.send()
|
||||
.await?;
|
||||
assert_eq!(missing.status(), 404);
|
||||
=======
|
||||
info["capabilities"]["memory"], true,
|
||||
"memory capability must be advertised in runtime/info"
|
||||
);
|
||||
handle.abort();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn memory_list_returns_empty_for_fresh_store() -> Result<()> {
|
||||
let root = std::env::temp_dir().join(format!("cw-memory-list-{}", Uuid::new_v4()));
|
||||
let sessions_dir = root.join("sessions");
|
||||
let _lock = lock_test_env();
|
||||
let home = root.join("home");
|
||||
fs::create_dir_all(&home)?;
|
||||
let _codewhale_home = EnvVarGuard::set("CODEWHALE_HOME", &home);
|
||||
let Some((addr, _rt, handle)) = spawn_test_server_with_root(root, sessions_dir).await? else {
|
||||
return Ok(());
|
||||
};
|
||||
let client = crate::tls::reqwest_client();
|
||||
|
||||
let body: serde_json::Value = client
|
||||
.get(format!("http://{addr}/v1/memory"))
|
||||
.send()
|
||||
.await?
|
||||
.error_for_status()?
|
||||
.json()
|
||||
.await?;
|
||||
assert_eq!(body["entries"].as_array().map(Vec::len), Some(0));
|
||||
assert_eq!(body["total"], 0);
|
||||
|
||||
// scope=global should also be empty.
|
||||
let global: serde_json::Value = client
|
||||
.get(format!("http://{addr}/v1/memory?scope=global"))
|
||||
.send()
|
||||
.await?
|
||||
.error_for_status()?
|
||||
.json()
|
||||
.await?;
|
||||
assert_eq!(global["total"], 0);
|
||||
|
||||
// Invalid scope returns 400.
|
||||
let bad = client
|
||||
.get(format!("http://{addr}/v1/memory?scope=invalid"))
|
||||
.send()
|
||||
.await?;
|
||||
assert_eq!(bad.status(), StatusCode::BAD_REQUEST);
|
||||
|
||||
// limit=0 returns 400.
|
||||
let bad_limit = client
|
||||
.get(format!("http://{addr}/v1/memory?limit=0"))
|
||||
.send()
|
||||
.await?;
|
||||
assert_eq!(bad_limit.status(), StatusCode::BAD_REQUEST);
|
||||
|
||||
// limit above max returns 400.
|
||||
let over_limit = client
|
||||
.get(format!("http://{addr}/v1/memory?limit=201"))
|
||||
.send()
|
||||
.await?;
|
||||
assert_eq!(over_limit.status(), StatusCode::BAD_REQUEST);
|
||||
|
||||
handle.abort();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn memory_create_list_and_get_entry() -> Result<()> {
|
||||
let root = std::env::temp_dir().join(format!("cw-memory-create-{}", Uuid::new_v4()));
|
||||
let sessions_dir = root.join("sessions");
|
||||
let _lock = lock_test_env();
|
||||
let home = root.join("home");
|
||||
fs::create_dir_all(&home)?;
|
||||
let _codewhale_home = EnvVarGuard::set("CODEWHALE_HOME", &home);
|
||||
let Some((addr, _rt, handle)) = spawn_test_server_with_root(root.clone(), sessions_dir).await?
|
||||
else {
|
||||
return Ok(());
|
||||
};
|
||||
let client = crate::tls::reqwest_client();
|
||||
|
||||
// Create a global memory entry.
|
||||
let create_resp = client
|
||||
.post(format!("http://{addr}/v1/memory"))
|
||||
.json(&json!({ "text": "prefer snake_case for identifiers", "scope": "global" }))
|
||||
.send()
|
||||
.await?;
|
||||
assert_eq!(create_resp.status(), StatusCode::CREATED);
|
||||
let created: serde_json::Value = create_resp.json().await?;
|
||||
assert_eq!(created["entry"]["scope"], "global");
|
||||
assert_eq!(created["entry"]["status"], "active");
|
||||
assert!(created["entry"]["id"].is_number());
|
||||
assert!(
|
||||
created["entry"]["summary"]
|
||||
.as_str()
|
||||
.unwrap_or("")
|
||||
.contains("snake_case"),
|
||||
"summary must include the note text"
|
||||
);
|
||||
|
||||
let entry_id = created["entry"]["id"].as_i64().unwrap();
|
||||
|
||||
// GET /v1/memory lists the entry.
|
||||
let list: serde_json::Value = client
|
||||
.get(format!("http://{addr}/v1/memory?scope=global"))
|
||||
.send()
|
||||
.await?
|
||||
.error_for_status()?
|
||||
.json()
|
||||
.await?;
|
||||
assert_eq!(list["total"], 1);
|
||||
assert_eq!(list["entries"][0]["id"], entry_id);
|
||||
assert_eq!(list["entries"][0]["scope"], "global");
|
||||
// workspace_id must be absent for global entries.
|
||||
assert!(list["entries"][0]["workspace_id"].is_null());
|
||||
|
||||
// GET /v1/memory/{id} returns the entry.
|
||||
let single: serde_json::Value = client
|
||||
.get(format!("http://{addr}/v1/memory/{entry_id}"))
|
||||
.send()
|
||||
.await?
|
||||
.error_for_status()?
|
||||
.json()
|
||||
.await?;
|
||||
assert_eq!(single["entry"]["id"], entry_id);
|
||||
assert_eq!(single["entry"]["scope"], "global");
|
||||
assert!(single["entry"]["stale"].is_boolean());
|
||||
|
||||
// GET /v1/memory/{id} for a missing id returns 404.
|
||||
let missing = client
|
||||
.get(format!("http://{addr}/v1/memory/999999"))
|
||||
.send()
|
||||
.await?;
|
||||
assert_eq!(missing.status(), StatusCode::NOT_FOUND);
|
||||
|
||||
handle.abort();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn memory_summary_is_redacted_to_max_chars() -> Result<()> {
|
||||
let root = std::env::temp_dir().join(format!("cw-memory-redact-{}", Uuid::new_v4()));
|
||||
let sessions_dir = root.join("sessions");
|
||||
let _lock = lock_test_env();
|
||||
let home = root.join("home");
|
||||
fs::create_dir_all(&home)?;
|
||||
let _codewhale_home = EnvVarGuard::set("CODEWHALE_HOME", &home);
|
||||
let Some((addr, _rt, handle)) = spawn_test_server_with_root(root.clone(), sessions_dir).await?
|
||||
else {
|
||||
return Ok(());
|
||||
};
|
||||
let client = crate::tls::reqwest_client();
|
||||
|
||||
let long_note = "x".repeat(350);
|
||||
let create_resp = client
|
||||
.post(format!("http://{addr}/v1/memory"))
|
||||
.json(&json!({ "text": long_note, "scope": "global" }))
|
||||
.send()
|
||||
.await?;
|
||||
assert_eq!(create_resp.status(), StatusCode::CREATED);
|
||||
let created: serde_json::Value = create_resp.json().await?;
|
||||
let summary = created["entry"]["summary"].as_str().unwrap_or("");
|
||||
// Summary must be bounded: at most 300 chars + 3 for the "…" suffix.
|
||||
assert!(
|
||||
summary.chars().count() <= 303,
|
||||
"summary must be bounded; got {} chars",
|
||||
summary.chars().count()
|
||||
);
|
||||
assert!(
|
||||
summary.ends_with("…") || summary.chars().count() <= 300,
|
||||
"overlong text must be truncated with an ellipsis"
|
||||
);
|
||||
|
||||
handle.abort();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn memory_clear_removes_global_scope() -> Result<()> {
|
||||
let root = std::env::temp_dir().join(format!("cw-memory-clear-{}", Uuid::new_v4()));
|
||||
let sessions_dir = root.join("sessions");
|
||||
let _lock = lock_test_env();
|
||||
let home = root.join("home");
|
||||
fs::create_dir_all(&home)?;
|
||||
let _codewhale_home = EnvVarGuard::set("CODEWHALE_HOME", &home);
|
||||
let Some((addr, _rt, handle)) = spawn_test_server_with_root(root.clone(), sessions_dir).await?
|
||||
else {
|
||||
return Ok(());
|
||||
};
|
||||
let client = crate::tls::reqwest_client();
|
||||
|
||||
// Seed two entries.
|
||||
for note in ["first note", "second note"] {
|
||||
client
|
||||
.post(format!("http://{addr}/v1/memory"))
|
||||
.json(&json!({ "text": note, "scope": "global" }))
|
||||
.send()
|
||||
.await?
|
||||
.error_for_status()?;
|
||||
}
|
||||
|
||||
let before: serde_json::Value = client
|
||||
.get(format!("http://{addr}/v1/memory?scope=global"))
|
||||
.send()
|
||||
.await?
|
||||
.error_for_status()?
|
||||
.json()
|
||||
.await?;
|
||||
assert_eq!(
|
||||
before["total"], 2,
|
||||
"seed entries must be present before clear"
|
||||
);
|
||||
|
||||
// Clear global scope.
|
||||
let clear: serde_json::Value = client
|
||||
.delete(format!("http://{addr}/v1/memory?scope=global"))
|
||||
.send()
|
||||
.await?
|
||||
.error_for_status()?
|
||||
.json()
|
||||
.await?;
|
||||
assert_eq!(clear["cleared"], true);
|
||||
|
||||
let after: serde_json::Value = client
|
||||
.get(format!("http://{addr}/v1/memory?scope=global"))
|
||||
.send()
|
||||
.await?
|
||||
.error_for_status()?
|
||||
.json()
|
||||
.await?;
|
||||
assert_eq!(after["total"], 0, "global scope must be empty after clear");
|
||||
|
||||
handle.abort();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn memory_create_rejects_empty_text_and_bad_scope() -> Result<()> {
|
||||
let root = std::env::temp_dir().join(format!("cw-memory-invalid-{}", Uuid::new_v4()));
|
||||
let sessions_dir = root.join("sessions");
|
||||
let _lock = lock_test_env();
|
||||
let home = root.join("home");
|
||||
fs::create_dir_all(&home)?;
|
||||
let _codewhale_home = EnvVarGuard::set("CODEWHALE_HOME", &home);
|
||||
let Some((addr, _rt, handle)) = spawn_test_server_with_root(root.clone(), sessions_dir).await?
|
||||
else {
|
||||
return Ok(());
|
||||
};
|
||||
let client = crate::tls::reqwest_client();
|
||||
|
||||
// Empty text must be rejected with 400.
|
||||
let empty = client
|
||||
.post(format!("http://{addr}/v1/memory"))
|
||||
.json(&json!({ "text": "", "scope": "global" }))
|
||||
.send()
|
||||
.await?;
|
||||
assert_eq!(empty.status(), StatusCode::BAD_REQUEST);
|
||||
|
||||
// An unknown scope must be rejected with 400.
|
||||
let bad_scope = client
|
||||
.post(format!("http://{addr}/v1/memory"))
|
||||
.json(&json!({ "text": "valid note", "scope": "thread" }))
|
||||
.send()
|
||||
.await?;
|
||||
assert_eq!(bad_scope.status(), StatusCode::BAD_REQUEST);
|
||||
|
||||
handle.abort();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn memory_search_query_filters_results() -> Result<()> {
|
||||
let root = std::env::temp_dir().join(format!("cw-memory-search-{}", Uuid::new_v4()));
|
||||
let sessions_dir = root.join("sessions");
|
||||
let _lock = lock_test_env();
|
||||
let home = root.join("home");
|
||||
fs::create_dir_all(&home)?;
|
||||
let _codewhale_home = EnvVarGuard::set("CODEWHALE_HOME", &home);
|
||||
let Some((addr, _rt, handle)) = spawn_test_server_with_root(root.clone(), sessions_dir).await?
|
||||
else {
|
||||
return Ok(());
|
||||
};
|
||||
let client = crate::tls::reqwest_client();
|
||||
|
||||
// Seed two entries with distinct text.
|
||||
for note in ["prefer functional style", "always use snake_case"] {
|
||||
client
|
||||
.post(format!("http://{addr}/v1/memory"))
|
||||
.json(&json!({ "text": note, "scope": "global" }))
|
||||
.send()
|
||||
.await?
|
||||
.error_for_status()?;
|
||||
}
|
||||
|
||||
// Searching for "functional" returns only the matching entry.
|
||||
let resp: serde_json::Value = client
|
||||
.get(format!("http://{addr}/v1/memory?q=functional&scope=global"))
|
||||
.send()
|
||||
.await?
|
||||
.error_for_status()?
|
||||
.json()
|
||||
.await?;
|
||||
assert_eq!(resp["total"], 1);
|
||||
let summary = resp["entries"][0]["summary"].as_str().unwrap_or("");
|
||||
assert!(
|
||||
summary.contains("functional"),
|
||||
"search must return the matching entry"
|
||||
);
|
||||
|
||||
// An empty q must be rejected with 400.
|
||||
let empty_q = client
|
||||
.get(format!("http://{addr}/v1/memory?q="))
|
||||
.send()
|
||||
.await?;
|
||||
assert_eq!(empty_q.status(), StatusCode::BAD_REQUEST);
|
||||
|
||||
handle.abort();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user