trusty-memory 0.24.0

MCP server (stdio + HTTP/SSE) for trusty-memory
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
//! Tests for palace delete, update, list-counts, and dream status.

use super::super::router;
use super::test_state;
use axum::body::{to_bytes, Body};
use axum::http::{Request, StatusCode};
use serde_json::{json, Value};
use tower::util::ServiceExt;

/// Why: Issue #180 — `?force=true` is the explicit destructive opt-in;
/// the conflict guard must yield and the palace must vanish even with
/// drawers present.
/// What: Same setup as the conflict test, but pass `?force=true` and
/// assert the 204 + 404 follow-up shape.
/// Test: This test itself.
#[tokio::test]
async fn delete_palace_force_removes_populated_palace() {
    let state = test_state();
    let app = router().with_state(state.clone());
    let resp = app
        .clone()
        .oneshot(
            Request::builder()
                .method("POST")
                .uri("/api/v1/palaces")
                .header("content-type", "application/json")
                .body(Body::from(json!({"name": "force-delete"}).to_string()))
                .unwrap(),
        )
        .await
        .unwrap();
    assert_eq!(resp.status(), StatusCode::OK);
    let resp = app
        .clone()
        .oneshot(
            Request::builder()
                .method("POST")
                .uri("/api/v1/palaces/force-delete/drawers")
                .header("content-type", "application/json")
                .body(Body::from(
                    json!({"content": "Sacrificial drawer for the force-delete path.", "tags": []})
                        .to_string(),
                ))
                .unwrap(),
        )
        .await
        .unwrap();
    assert_eq!(resp.status(), StatusCode::OK);

    let resp = app
        .clone()
        .oneshot(
            Request::builder()
                .method("DELETE")
                .uri("/api/v1/palaces/force-delete?force=true")
                .body(Body::empty())
                .unwrap(),
        )
        .await
        .unwrap();
    assert_eq!(resp.status(), StatusCode::NO_CONTENT);

    let resp = app
        .oneshot(
            Request::builder()
                .uri("/api/v1/palaces/force-delete")
                .body(Body::empty())
                .unwrap(),
        )
        .await
        .unwrap();
    assert_eq!(resp.status(), StatusCode::NOT_FOUND);
}

/// Why: Issue #180 — deleting a missing palace must yield 404 so
/// idempotent retries on the client are distinguishable from the
/// "drawers present" precondition failure.
/// What: DELETE against a never-created id and assert 404.
/// Test: This test itself.
#[tokio::test]
async fn delete_palace_returns_not_found_for_missing_id() {
    let state = test_state();
    let app = router().with_state(state);
    let resp = app
        .oneshot(
            Request::builder()
                .method("DELETE")
                .uri("/api/v1/palaces/never-existed")
                .body(Body::empty())
                .unwrap(),
        )
        .await
        .unwrap();
    assert_eq!(resp.status(), StatusCode::NOT_FOUND);
}

/// Regression test for issue #5231.
///
/// Why: `DELETE /palaces/{id}/drawers/{drawer_id}` answered `204 No Content`
/// for a drawer id that was never stored — identical to a real delete — because
/// `PalaceHandle::forget` returned `Ok(())` either way. `delete_palace` has
/// answered 404 for a missing id since #180; the drawer route now matches.
/// What: creates a palace, DELETEs a well-formed but unknown drawer UUID, and
/// asserts 404. A malformed id stays a 400.
/// Test: This test itself.
#[tokio::test]
async fn delete_drawer_404s_for_an_unknown_drawer_id() {
    let state = test_state();
    let app = router().with_state(state);
    let resp = app
        .clone()
        .oneshot(
            Request::builder()
                .method("POST")
                .uri("/api/v1/palaces")
                .header("content-type", "application/json")
                .body(Body::from(json!({"name": "ghost-drawer"}).to_string()))
                .unwrap(),
        )
        .await
        .unwrap();
    assert_eq!(resp.status(), StatusCode::OK);

    let resp = app
        .clone()
        .oneshot(
            Request::builder()
                .method("DELETE")
                .uri("/api/v1/palaces/ghost-drawer/drawers/deadbeef-0000-4000-8000-000000000000")
                .body(Body::empty())
                .unwrap(),
        )
        .await
        .unwrap();
    assert_eq!(resp.status(), StatusCode::NOT_FOUND);

    let resp = app
        .oneshot(
            Request::builder()
                .method("DELETE")
                .uri("/api/v1/palaces/ghost-drawer/drawers/not-a-uuid")
                .body(Body::empty())
                .unwrap(),
        )
        .await
        .unwrap();
    assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
}

/// Why: Issue #180 follow-up — verify the happy path of `PATCH
/// /api/v1/palaces/{id}`: create a palace, rename it, and confirm
/// `GET /api/v1/palaces/{id}` returns the new display name. The id
/// (which is the on-disk directory) must stay stable.
/// What: POST a palace named "rename-me", PATCH with a new display
/// name, expect 200 + payload showing the rename, then GET to confirm
/// persistence to disk.
/// Test: This test itself.
#[tokio::test]
async fn update_palace_name_renames_palace() {
    let state = test_state();
    let app = router().with_state(state);
    let resp = app
        .clone()
        .oneshot(
            Request::builder()
                .method("POST")
                .uri("/api/v1/palaces")
                .header("content-type", "application/json")
                .body(Body::from(json!({"name": "rename-me"}).to_string()))
                .unwrap(),
        )
        .await
        .unwrap();
    assert_eq!(resp.status(), StatusCode::OK);

    let resp = app
        .clone()
        .oneshot(
            Request::builder()
                .method("PATCH")
                .uri("/api/v1/palaces/rename-me")
                .header("content-type", "application/json")
                .body(Body::from(json!({"name": "New Display Name"}).to_string()))
                .unwrap(),
        )
        .await
        .unwrap();
    assert_eq!(resp.status(), StatusCode::OK);
    let bytes = to_bytes(resp.into_body(), 4096).await.unwrap();
    let v: Value = serde_json::from_slice(&bytes).unwrap();
    assert_eq!(v["id"].as_str(), Some("rename-me"));
    assert_eq!(v["name"].as_str(), Some("New Display Name"));

    let resp = app
        .oneshot(
            Request::builder()
                .uri("/api/v1/palaces/rename-me")
                .body(Body::empty())
                .unwrap(),
        )
        .await
        .unwrap();
    assert_eq!(resp.status(), StatusCode::OK);
    let bytes = to_bytes(resp.into_body(), 4096).await.unwrap();
    let v: Value = serde_json::from_slice(&bytes).unwrap();
    assert_eq!(v["id"].as_str(), Some("rename-me"));
    assert_eq!(v["name"].as_str(), Some("New Display Name"));
}

/// Why: Issue #180 follow-up — empty / whitespace-only names would
/// break the dashboard label. Reject with 400 so the caller knows the
/// request was well-formed but the value is invalid.
/// What: Create a palace, PATCH with `{"name": "   "}`, expect 400.
/// Test: This test itself.
#[tokio::test]
async fn update_palace_name_rejects_empty_name() {
    let state = test_state();
    let app = router().with_state(state);
    let resp = app
        .clone()
        .oneshot(
            Request::builder()
                .method("POST")
                .uri("/api/v1/palaces")
                .header("content-type", "application/json")
                .body(Body::from(json!({"name": "keep-name"}).to_string()))
                .unwrap(),
        )
        .await
        .unwrap();
    assert_eq!(resp.status(), StatusCode::OK);

    let resp = app
        .oneshot(
            Request::builder()
                .method("PATCH")
                .uri("/api/v1/palaces/keep-name")
                .header("content-type", "application/json")
                .body(Body::from(json!({"name": "   "}).to_string()))
                .unwrap(),
        )
        .await
        .unwrap();
    assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
}

/// Why: Issue #180 follow-up — patching a non-existent palace must
/// yield 404 so retries against the wrong id surface the real problem
/// rather than silently no-op'ing.
/// What: PATCH against a never-created id and assert 404.
/// Test: This test itself.
#[tokio::test]
async fn update_palace_name_returns_not_found_for_missing_id() {
    let state = test_state();
    let app = router().with_state(state);
    let resp = app
        .oneshot(
            Request::builder()
                .method("PATCH")
                .uri("/api/v1/palaces/no-such-palace")
                .header("content-type", "application/json")
                .body(Body::from(json!({"name": "irrelevant"}).to_string()))
                .unwrap(),
        )
        .await
        .unwrap();
    assert_eq!(resp.status(), StatusCode::NOT_FOUND);
}

/// Restore a directory's mode on drop, including while unwinding.
///
/// Why: the denial test below strips a palace directory to mode 000. The
/// fixture's tempdir is deliberately leaked, so a mode-000 directory left
/// behind by a failed assertion is a permanent undeletable leak rather than a
/// transient one.
#[cfg(unix)]
struct RestoreMode(std::path::PathBuf);

#[cfg(unix)]
impl Drop for RestoreMode {
    fn drop(&mut self) {
        use std::os::unix::fs::PermissionsExt;
        let _ = std::fs::set_permissions(&self.0, std::fs::Permissions::from_mode(0o700));
    }
}

/// Why (#5549): hardening `load_palace` to distinguish absent from
/// undeterminable buys nothing if the caller flattens the distinction again.
/// `update_palace_name_typed` mapped EVERY `PalaceStoreError` through
/// `ServiceError::not_found`, so a palace whose `palace.json` could not be
/// stat'd was reported to the HTTP client as 404 — "this palace does not
/// exist" — for a denial or a transient `EIO` that established no such thing.
/// What: creates a palace, strips its directory to mode 000 so stat of the
/// metadata inside is denied, and PATCHes it. Asserts the response is 500 and
/// specifically NOT 404. Panics rather than passing vacuously if the denial
/// does not take hold.
/// Test: This test itself.
#[cfg(unix)]
#[tokio::test]
async fn update_palace_name_reports_an_unstattable_palace_as_internal() {
    use std::os::unix::fs::PermissionsExt;

    let state = test_state();
    let palace_dir = state.data_root.join("locked-palace");
    let app = router().with_state(state);

    let resp = app
        .clone()
        .oneshot(
            Request::builder()
                .method("POST")
                .uri("/api/v1/palaces")
                .header("content-type", "application/json")
                .body(Body::from(json!({"name": "locked-palace"}).to_string()))
                .unwrap(),
        )
        .await
        .unwrap();
    assert_eq!(resp.status(), StatusCode::OK);

    std::fs::set_permissions(&palace_dir, std::fs::Permissions::from_mode(0o000)).unwrap();
    let _restore = RestoreMode(palace_dir.clone());

    // Root bypasses the mode bits outright, and some filesystems ignore them,
    // so confirm the denial actually took hold. A vacuous pass on a fail-open
    // guard is worse than no test at all.
    let target = palace_dir.join("palace.json");
    match std::fs::metadata(&target) {
        Ok(_) => panic!(
            "cannot exercise #5549: stat of {} still succeeds with its parent at mode 000. \
             Run this suite as a non-root user on a filesystem that honours POSIX \
             permission bits.",
            target.display()
        ),
        Err(e) => assert_eq!(
            e.kind(),
            std::io::ErrorKind::PermissionDenied,
            "expected the locked palace dir to deny stat of its metadata, got {e}"
        ),
    }

    let resp = app
        .oneshot(
            Request::builder()
                .method("PATCH")
                .uri("/api/v1/palaces/locked-palace")
                .header("content-type", "application/json")
                .body(Body::from(json!({"name": "New Display Name"}).to_string()))
                .unwrap(),
        )
        .await
        .unwrap();

    assert_ne!(
        resp.status(),
        StatusCode::NOT_FOUND,
        "a palace whose metadata cannot be stat'd was reported as absent — that is the \
         #5549 coercion re-created one crate up, at the caller"
    );
    assert_eq!(resp.status(), StatusCode::INTERNAL_SERVER_ERROR);
}

/// Why: The operator TUI's MEMORY tab reads `node_count`, `edge_count`,
/// `community_count`, and `is_compacting` straight off the
/// `/api/v1/palaces` payload. If any of those fields disappear or change
/// type the spinner / counters break silently. Pin the shape here.
/// What: Creates a palace, lists `/api/v1/palaces`, and asserts every new
/// field is present and typed as expected (numbers default to 0, the
/// compacting flag defaults to false on a freshly-opened palace).
/// Test: This test itself.
#[tokio::test]
async fn palace_list_includes_graph_counts() {
    let state = test_state();
    let app = router().with_state(state.clone());
    let body = json!({"name": "graph-counts", "description": null}).to_string();
    let resp = app
        .clone()
        .oneshot(
            Request::builder()
                .method("POST")
                .uri("/api/v1/palaces")
                .header("content-type", "application/json")
                .body(Body::from(body))
                .unwrap(),
        )
        .await
        .unwrap();
    assert_eq!(resp.status(), StatusCode::OK);

    let resp = app
        .oneshot(
            Request::builder()
                .uri("/api/v1/palaces")
                .body(Body::empty())
                .unwrap(),
        )
        .await
        .unwrap();
    assert_eq!(resp.status(), StatusCode::OK);
    let bytes = to_bytes(resp.into_body(), 4096).await.unwrap();
    let v: Value = serde_json::from_slice(&bytes).unwrap();
    let arr = v.as_array().expect("array");
    let row = arr
        .iter()
        .find(|p| p["id"] == "graph-counts")
        .expect("created palace must appear in list");
    assert_eq!(row["node_count"].as_u64(), Some(0));
    assert_eq!(row["edge_count"].as_u64(), Some(0));
    assert_eq!(row["community_count"].as_u64(), Some(0));
    assert_eq!(row["is_compacting"].as_bool(), Some(false));
}

/// Why: The enriched status payload backs the dashboard's top-row stats;
/// it must always include the new total_* counters, even on an empty data
/// root, so the UI can render zeros without special-casing missing fields.
/// What: Hit `/api/v1/status` on a fresh state and assert the new fields
/// are present and set to 0.
/// Test: This test itself.
#[tokio::test]
async fn status_includes_total_counters() {
    let state = test_state();
    let app = router().with_state(state);
    let resp = app
        .oneshot(
            Request::builder()
                .uri("/api/v1/status")
                .body(Body::empty())
                .unwrap(),
        )
        .await
        .unwrap();
    let bytes = to_bytes(resp.into_body(), 4096).await.unwrap();
    let v: Value = serde_json::from_slice(&bytes).unwrap();
    assert_eq!(v["total_drawers"], 0);
    assert_eq!(v["total_vectors"], 0);
    assert_eq!(v["total_kg_triples"], 0);
}

/// Why: `/api/v1/dream/status` must return a well-shaped payload even
/// when no palace has ever run a dream cycle (so the dashboard's first
/// load doesn't error).
/// What: Hit the endpoint on a fresh state and assert `last_run_at` is
/// null and the counters are zero.
/// Test: This test itself.
#[tokio::test]
async fn dream_status_empty_returns_nulls() {
    let state = test_state();
    let app = router().with_state(state);
    let resp = app
        .oneshot(
            Request::builder()
                .uri("/api/v1/dream/status")
                .body(Body::empty())
                .unwrap(),
        )
        .await
        .unwrap();
    assert_eq!(resp.status(), StatusCode::OK);
    let bytes = to_bytes(resp.into_body(), 4096).await.unwrap();
    let v: Value = serde_json::from_slice(&bytes).unwrap();
    assert!(v["last_run_at"].is_null());
    assert_eq!(v["merged"], 0);
    assert_eq!(v["pruned"], 0);
}