trusty-common 0.40.1

Shared utilities and provider-agnostic streaming chat (ChatProvider, OllamaProvider, OpenRouter, tool-use) for trusty-* projects
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
//! Palace-level alias map: redirect one palace name to another (issue #1939).
//!
//! Why: trusty-mpm pins a managed session's `TRUSTY_MEMORY_PALACE` to the
//! `owner-repo` slug that [`crate::derive_palace_id`] produces (e.g.
//! `bobmatnyc-trusty-tools`), but the pre-existing claude-mpm-era palace for a
//! repo is the BARE repo name (`trusty-tools`). When the `owner-repo` palace has
//! never been created, every memory tool call fails with "palace metadata
//! missing" while the real history lives under the bare name — memory is
//! split-brained. This module persists a small alias map so a lookup for a
//! non-existent palace can be transparently redirected to an existing target,
//! letting BOTH names resolve to the one on-disk store.
//!
//! What: a JSON file `<registry_dir>/palace_aliases.json` mapping
//! `alias_name -> target_palace`, with atomic (tmp + rename) writes and a
//! forgiving read (a missing/empty/corrupt file is treated as "no aliases").
//! Resolution is consulted by [`crate::memory_core::registry::PalaceRegistry`]
//! ONLY when the requested palace has no metadata on disk, so aliases never
//! shadow a real palace of the same name. This is DISTINCT from the term/KG
//! entity aliases exposed by the `add_alias`/`discover_aliases` MCP tools — those
//! live INSIDE a palace's knowledge graph; this is a PALACE-level redirect that
//! lives beside the per-palace subdirectories.
//!
//! Test: `crate::palace_alias::tests` covers round-trip register/resolve, the
//! missing-file default, idempotent re-registration, self-alias rejection, and
//! the `palace_registry_dir_from` subdir resolution.

use std::collections::BTreeMap;
use std::path::{Path, PathBuf};

use anyhow::{Context, Result};
use serde::{Deserialize, Serialize};

/// Filename for the persisted palace-alias map (beside per-palace subdirs).
///
/// Why: the map must live in the SAME directory the daemon uses as its palace
/// registry root (`AppState::data_root`) so a single lookup finds it. Naming it
/// with a `.json` suffix (not a directory) means the palace-listing walker —
/// which only descends into subdirectories containing `palace.json` — skips it
/// automatically.
/// What: `"palace_aliases.json"`.
/// Test: `register_then_resolve_round_trips` writes/reads this path.
const PALACE_ALIASES_JSON: &str = "palace_aliases.json";

/// On-disk shape of the alias map (versioned for forward compatibility).
///
/// Why: wrapping the flat map in a struct with an explicit `version` lets the
/// schema evolve (e.g. add per-alias metadata) without breaking older readers.
/// What: `version` (defaulted to 1 for files written before it existed) plus the
/// `aliases` map. Serialised with `serde_json` pretty output.
/// Test: `register_then_resolve_round_trips`.
#[derive(Debug, Default, Clone, Serialize, Deserialize)]
struct PalaceAliasesFile {
    /// Schema version; defaults to 1 when absent so pre-version files still load.
    #[serde(default = "default_alias_schema_version")]
    version: u32,
    /// `alias_name -> target_palace` redirects. `BTreeMap` keeps the on-disk
    /// order stable so diffs are deterministic.
    #[serde(default)]
    aliases: BTreeMap<String, String>,
}

fn default_alias_schema_version() -> u32 {
    1
}

/// Stateless namespace for palace-alias persistence.
///
/// Why: like [`crate::memory_core::store::palace_store::PalaceStore`], alias
/// persistence has no state of its own — every operation is a pure function over
/// a registry directory. Grouping under a unit struct gives a stable import path.
/// What: `load_aliases` / `register_alias` / `resolve_alias`.
/// Test: this module's `tests`.
pub struct PalaceAliasStore;

impl PalaceAliasStore {
    /// Load the full alias map for a registry directory.
    ///
    /// Why: callers that want the whole map (diagnostics, bulk resolution) get it
    /// in one read. A missing file is the common case (no aliases registered yet)
    /// and must NOT be an error, so a fresh install behaves like an empty map.
    /// What: reads `<registry_dir>/palace_aliases.json`. Returns an empty map when
    /// the file is absent, empty, or fails to parse (corruption is logged and
    /// treated as "no aliases" so a bad file can never wedge palace resolution).
    /// Test: `load_missing_is_empty`, `register_then_resolve_round_trips`.
    pub fn load_aliases(registry_dir: &Path) -> Result<BTreeMap<String, String>> {
        let path = registry_dir.join(PALACE_ALIASES_JSON);
        let bytes = match std::fs::read(&path) {
            Ok(b) => b,
            Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(BTreeMap::new()),
            Err(e) => {
                return Err(e)
                    .with_context(|| format!("read palace aliases at {}", path.display()));
            }
        };
        if bytes.iter().all(u8::is_ascii_whitespace) {
            return Ok(BTreeMap::new());
        }
        match serde_json::from_slice::<PalaceAliasesFile>(&bytes) {
            Ok(file) => Ok(file.aliases),
            Err(e) => {
                // A corrupt alias file must not break palace resolution — the
                // authoritative data is the palaces themselves. Log and degrade
                // to "no aliases".
                tracing::warn!(
                    path = %path.display(),
                    error = %e,
                    "palace alias file is unparseable; ignoring (treating as no aliases)"
                );
                Ok(BTreeMap::new())
            }
        }
    }

    /// Register (or overwrite) an alias `alias_name -> target`, persisting it.
    ///
    /// Why: trusty-mpm calls this at session launch to bind a derived
    /// `owner-repo` name to an existing bare-repo palace so the split-brain
    /// resolves. It must be idempotent — relaunching the same session repeatedly
    /// must converge on one entry, not error or duplicate.
    /// What: rejects empty operands and a self-alias (`alias == target`, which
    /// would be a useless no-op / cycle). Otherwise loads the current map,
    /// inserts/updates the entry, and atomically writes it back (tmp + rename) so
    /// a crash mid-write cannot leave a half-written map. Creating an alias that
    /// already maps to the same target is a cheap no-op write. Returns `Ok(())`.
    /// Test: `register_then_resolve_round_trips`, `register_is_idempotent`,
    /// `register_self_alias_is_rejected`, `register_rejects_empty`.
    pub fn register_alias(registry_dir: &Path, alias: &str, target: &str) -> Result<()> {
        let alias = alias.trim();
        let target = target.trim();
        if alias.is_empty() || target.is_empty() {
            anyhow::bail!(
                "palace alias and target must both be non-empty (alias={alias:?}, target={target:?})"
            );
        }
        if alias == target {
            anyhow::bail!(
                "refusing to register a self-referential palace alias {alias:?} -> {target:?}"
            );
        }

        std::fs::create_dir_all(registry_dir)
            .with_context(|| format!("create registry dir {}", registry_dir.display()))?;

        let mut aliases = Self::load_aliases(registry_dir)?;
        aliases.insert(alias.to_string(), target.to_string());

        let file = PalaceAliasesFile {
            version: default_alias_schema_version(),
            aliases,
        };
        let bytes = serde_json::to_vec_pretty(&file).context("serialize palace aliases")?;

        let target_path = registry_dir.join(PALACE_ALIASES_JSON);
        let tmp_path = registry_dir.join(format!("{PALACE_ALIASES_JSON}.tmp"));
        std::fs::write(&tmp_path, &bytes)
            .with_context(|| format!("write palace aliases tmp {}", tmp_path.display()))?;
        std::fs::rename(&tmp_path, &target_path)
            .with_context(|| format!("rename palace aliases into {}", target_path.display()))?;
        Ok(())
    }

    /// Resolve a single alias to its target palace name, if one is registered.
    ///
    /// Why: the palace registry consults this when a requested palace has no
    /// on-disk metadata — a hit redirects the open to the target's store.
    /// What: loads the map and returns `Some(target)` when `alias` is present,
    /// `None` otherwise. A missing map yields `None`.
    /// Test: `register_then_resolve_round_trips`, `resolve_unknown_is_none`.
    pub fn resolve_alias(registry_dir: &Path, alias: &str) -> Result<Option<String>> {
        Ok(Self::load_aliases(registry_dir)?.get(alias.trim()).cloned())
    }
}

/// Name the palace an alias redirects to, but only when the redirect actually fires.
///
/// Why (#5810): the registry applied this rule privately, so every caller that
/// wanted to *name* the palace it was about to reach had no way to ask. The
/// `UserPromptSubmit` hook printed the derived slug — `bobmatnyc-trusty-tools` —
/// while the drawers under it came from `trusty-tools`, and `palace_list` returns
/// only the latter. A second copy of the rule in trusty-memory would be the drift
/// this workspace's common-entry-point rule forbids, so the rule lives here and
/// [`crate::memory_core::registry`] now reads it from the same place.
/// What: returns `Some(target)` only when `<registry_dir>/<palace_id>/palace.json`
/// is absent AND an alias maps `palace_id` to a `target` whose own `palace.json`
/// is present. Returns `None` in every other case — the palace exists, no alias is
/// registered, the target is missing, or the alias file cannot be read — so a
/// caller's `unwrap_or(derived)` keeps today's behaviour on every degraded path.
///
/// Presence is `try_exists`, and only `Ok(false)` counts as absent (#5592,
/// ADR-0045): a path we are denied to stat presumes PRESENT, so an unverifiable
/// alias target never wins a redirect and an unstattable palace is never shadowed.
/// Test: `alias_target_is_none_when_the_palace_exists`,
/// `alias_target_names_the_redirect`,
/// `alias_target_is_none_when_the_target_is_missing`,
/// `alias_target_is_none_without_an_alias`.
pub fn alias_target_if_absent(registry_dir: &Path, palace_id: &str) -> Option<String> {
    let exists = |id: &str| {
        !matches!(
            registry_dir.join(id).join("palace.json").try_exists(),
            Ok(false)
        )
    };
    if exists(palace_id) {
        return None;
    }
    match PalaceAliasStore::resolve_alias(registry_dir, palace_id) {
        Ok(Some(target)) if exists(&target) => Some(target),
        _ => None,
    }
}

/// Resolve the directory that holds the per-palace subdirectories for a data dir.
///
/// Why: two on-disk layouts exist in the wild. The current code treats the data
/// dir itself as the parent of per-palace dirs (`<data_dir>/<id>/palace.json`);
/// legacy standalone installs nest everything under a `palaces/` subdirectory
/// (`<data_dir>/palaces/<id>/palace.json`), which is where real installs' data
/// lives. trusty-mpm (which registers aliases) and trusty-memory (which resolves
/// them) MUST agree on this directory or the alias file lands somewhere the
/// daemon never reads. Centralising the choice here keeps the two in lockstep —
/// trusty-memory's `resolve_palace_registry_dir` delegates to this.
/// What: returns `<data_dir>/palaces` when that subdirectory exists, else
/// `<data_dir>` itself.
/// Test: `palace_registry_dir_prefers_palaces_subdir`,
/// `palace_registry_dir_falls_back_to_data_dir`.
pub fn palace_registry_dir_from(data_dir: PathBuf) -> PathBuf {
    let nested = data_dir.join("palaces");
    if nested.is_dir() { nested } else { data_dir }
}

/// Resolve the default trusty-memory palace registry directory for this machine.
///
/// Why: trusty-mpm's session-launch alias registration must write the alias file
/// to the exact directory the trusty-memory daemon uses as its palace registry
/// root, WITHOUT depending on the `trusty-memory` crate. Reusing
/// [`crate::resolve_data_dir`] (which honours `TRUSTY_DATA_DIR_OVERRIDE`) keeps
/// path derivation identical to the daemon's, so tests and production agree.
/// What: resolves `resolve_data_dir("trusty-memory")` (the app data dir, created
/// if absent) and applies [`palace_registry_dir_from`] to pick the `palaces/`
/// subdir when present. Returns the registry directory.
/// Test: side-effect-bearing (creates the app data dir); the pure subdir choice
/// it composes is covered by `palace_registry_dir_*`.
pub fn default_palace_registry_dir() -> Result<PathBuf> {
    let data_dir = crate::resolve_data_dir("trusty-memory")?;
    Ok(palace_registry_dir_from(data_dir))
}

#[cfg(test)]
mod tests {
    use super::*;
    use tempfile::tempdir;

    /// Why: a fresh install has no alias file; load must return an empty map,
    /// never an error, so palace resolution proceeds normally.
    /// Test: itself.
    #[test]
    fn load_missing_is_empty() {
        let tmp = tempdir().unwrap();
        let aliases = PalaceAliasStore::load_aliases(tmp.path()).expect("load");
        assert!(aliases.is_empty());
    }

    /// Why: the core contract — a registered alias must be readable back both via
    /// the full map and the single-key resolver, and it must survive as an
    /// on-disk file (a fresh read from the same dir).
    /// Test: itself.
    #[test]
    fn register_then_resolve_round_trips() {
        let tmp = tempdir().unwrap();
        PalaceAliasStore::register_alias(tmp.path(), "bobmatnyc-trusty-tools", "trusty-tools")
            .expect("register");

        // Single-key resolve.
        assert_eq!(
            PalaceAliasStore::resolve_alias(tmp.path(), "bobmatnyc-trusty-tools")
                .expect("resolve")
                .as_deref(),
            Some("trusty-tools")
        );
        // Full-map load reflects it too.
        let all = PalaceAliasStore::load_aliases(tmp.path()).expect("load");
        assert_eq!(
            all.get("bobmatnyc-trusty-tools").map(String::as_str),
            Some("trusty-tools")
        );
        // File actually exists on disk.
        assert!(tmp.path().join(PALACE_ALIASES_JSON).exists());
    }

    /// Why: relaunching the same managed session repeatedly must not duplicate or
    /// error — re-registering the same pair converges on one entry, and pointing
    /// an alias at a new target overwrites in place.
    /// Test: itself.
    #[test]
    fn register_is_idempotent() {
        let tmp = tempdir().unwrap();
        PalaceAliasStore::register_alias(tmp.path(), "a", "b").unwrap();
        PalaceAliasStore::register_alias(tmp.path(), "a", "b").unwrap();
        let all = PalaceAliasStore::load_aliases(tmp.path()).unwrap();
        assert_eq!(all.len(), 1);
        assert_eq!(all.get("a").map(String::as_str), Some("b"));

        // Overwrite to a new target.
        PalaceAliasStore::register_alias(tmp.path(), "a", "c").unwrap();
        let all = PalaceAliasStore::load_aliases(tmp.path()).unwrap();
        assert_eq!(all.len(), 1);
        assert_eq!(all.get("a").map(String::as_str), Some("c"));
    }

    /// Why: a self-alias is a useless no-op / potential cycle; it must be
    /// rejected so a mis-derived slug can never point a palace at itself.
    /// Test: itself.
    #[test]
    fn register_self_alias_is_rejected() {
        let tmp = tempdir().unwrap();
        assert!(PalaceAliasStore::register_alias(tmp.path(), "same", "same").is_err());
        assert!(PalaceAliasStore::register_alias(tmp.path(), "same", " same ").is_err());
    }

    /// Why: empty operands would create a meaningless map entry; guard against
    /// them so a caller passing a blank slug fails loudly rather than silently.
    /// Test: itself.
    #[test]
    fn register_rejects_empty() {
        let tmp = tempdir().unwrap();
        assert!(PalaceAliasStore::register_alias(tmp.path(), "", "b").is_err());
        assert!(PalaceAliasStore::register_alias(tmp.path(), "a", "  ").is_err());
    }

    /// Why: an alias that was never registered must resolve to `None` so the
    /// registry surfaces the normal "metadata missing" error.
    /// Test: itself.
    #[test]
    fn resolve_unknown_is_none() {
        let tmp = tempdir().unwrap();
        PalaceAliasStore::register_alias(tmp.path(), "a", "b").unwrap();
        assert_eq!(
            PalaceAliasStore::resolve_alias(tmp.path(), "zzz").unwrap(),
            None
        );
    }

    /// Why: a corrupt alias file must degrade to "no aliases", never panic or
    /// error, so it cannot wedge palace resolution.
    /// Test: itself.
    #[test]
    fn corrupt_file_degrades_to_empty() {
        let tmp = tempdir().unwrap();
        std::fs::write(tmp.path().join(PALACE_ALIASES_JSON), b"{ not json ]").unwrap();
        let all =
            PalaceAliasStore::load_aliases(tmp.path()).expect("load never errors on corruption");
        assert!(all.is_empty());
    }

    /// Why: when a `palaces/` subdir exists (the real-install layout) the
    /// registry dir must point INTO it so aliases sit beside the palaces.
    /// Test: itself.
    #[test]
    fn palace_registry_dir_prefers_palaces_subdir() {
        let tmp = tempdir().unwrap();
        let nested = tmp.path().join("palaces");
        std::fs::create_dir_all(&nested).unwrap();
        assert_eq!(palace_registry_dir_from(tmp.path().to_path_buf()), nested);
    }

    /// Write a minimal `palace.json` so the presence probe sees a real palace.
    fn seed_palace(registry_dir: &Path, id: &str) {
        let dir = registry_dir.join(id);
        std::fs::create_dir_all(&dir).unwrap();
        std::fs::write(dir.join("palace.json"), b"{}").unwrap();
    }

    /// Why: an alias must never shadow a real palace, so a palace that exists on
    /// disk reports no redirect even when an alias names it.
    /// Test: itself.
    #[test]
    fn alias_target_is_none_when_the_palace_exists() {
        let tmp = tempdir().unwrap();
        seed_palace(tmp.path(), "owner-repo");
        seed_palace(tmp.path(), "repo");
        PalaceAliasStore::register_alias(tmp.path(), "owner-repo", "repo").unwrap();
        assert_eq!(alias_target_if_absent(tmp.path(), "owner-repo"), None);
    }

    /// Why: the split-brain case this exists for — the derived name owns no
    /// directory, the alias target does, so the target is the usable name.
    /// Test: itself.
    #[test]
    fn alias_target_names_the_redirect() {
        let tmp = tempdir().unwrap();
        seed_palace(tmp.path(), "trusty-tools");
        PalaceAliasStore::register_alias(tmp.path(), "bobmatnyc-trusty-tools", "trusty-tools")
            .unwrap();
        assert_eq!(
            alias_target_if_absent(tmp.path(), "bobmatnyc-trusty-tools").as_deref(),
            Some("trusty-tools")
        );
    }

    /// Why: a stale alias pointing at a deleted palace must not rename anything —
    /// the caller keeps the original id so its error names the palace asked for.
    /// Test: itself.
    #[test]
    fn alias_target_is_none_when_the_target_is_missing() {
        let tmp = tempdir().unwrap();
        PalaceAliasStore::register_alias(tmp.path(), "ghost", "also-gone").unwrap();
        assert_eq!(alias_target_if_absent(tmp.path(), "ghost"), None);
    }

    /// Why: the common case — no alias file at all — must be a cheap `None`.
    /// Test: itself.
    #[test]
    fn alias_target_is_none_without_an_alias() {
        let tmp = tempdir().unwrap();
        assert_eq!(alias_target_if_absent(tmp.path(), "anything"), None);
    }

    /// Why: absent a `palaces/` subdir the data dir itself is the registry root
    /// (the monorepo layout), matching trusty-memory's fallback.
    /// Test: itself.
    #[test]
    fn palace_registry_dir_falls_back_to_data_dir() {
        let tmp = tempdir().unwrap();
        assert_eq!(
            palace_registry_dir_from(tmp.path().to_path_buf()),
            tmp.path().to_path_buf()
        );
    }
}