huskies: merge 961
This commit is contained in:
@@ -7,6 +7,44 @@
|
||||
use std::collections::HashMap;
|
||||
use std::sync::{Mutex, OnceLock};
|
||||
|
||||
/// Typed key for the in-memory content store.
|
||||
///
|
||||
/// Each variant maps to a distinct raw key namespace so that content written
|
||||
/// under one variant is never visible under another — no raw `format!()`
|
||||
/// key construction is needed at call sites outside `db/`.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum ContentKey<'a> {
|
||||
/// Main markdown body of a work item (story, bug, spike, refactor, epic).
|
||||
Story(&'a str),
|
||||
/// Gate failure output from the last failed agent run.
|
||||
GateOutput(&'a str),
|
||||
/// Consecutive abort-respawn counter.
|
||||
AbortRespawnCount(&'a str),
|
||||
/// Mergemaster re-spawn counter.
|
||||
MergeMasterSpawnCount(&'a str),
|
||||
/// Evidence that `run_tests` passed during an agent session.
|
||||
RunTestsOk(&'a str),
|
||||
/// Flag indicating a commit-recovery respawn is in progress.
|
||||
CommitRecoveryPending(&'a str),
|
||||
}
|
||||
|
||||
impl<'a> ContentKey<'a> {
|
||||
/// Lower this typed key to the underlying storage string used by the
|
||||
/// CRDT content store (`{story_id}` for the base story, `{story_id}:<kind>`
|
||||
/// for per-purpose sub-keys). Internal — callers should use the typed
|
||||
/// `read_content` / `write_content` wrappers instead of touching strings.
|
||||
pub(super) fn as_raw_key(&self) -> String {
|
||||
match self {
|
||||
ContentKey::Story(id) => id.to_string(),
|
||||
ContentKey::GateOutput(id) => format!("{id}:gate_output"),
|
||||
ContentKey::AbortRespawnCount(id) => format!("{id}:abort_respawn_count"),
|
||||
ContentKey::MergeMasterSpawnCount(id) => format!("{id}:mergemaster_spawn_count"),
|
||||
ContentKey::RunTestsOk(id) => format!("{id}:run_tests_ok"),
|
||||
ContentKey::CommitRecoveryPending(id) => format!("{id}:commit_recovery_pending"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
static CONTENT_STORE: OnceLock<Mutex<HashMap<String, String>>> = OnceLock::new();
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -41,30 +79,28 @@ pub(super) fn get_content_store() -> Option<&'static Mutex<HashMap<String, Strin
|
||||
}
|
||||
}
|
||||
|
||||
/// Read the full markdown content of a story from the in-memory store.
|
||||
pub fn read_content(story_id: &str) -> Option<String> {
|
||||
/// Read content from the in-memory store by typed key.
|
||||
pub fn read_content(key: ContentKey<'_>) -> Option<String> {
|
||||
let store = get_content_store()?;
|
||||
let map = store.lock().ok()?;
|
||||
map.get(story_id).cloned()
|
||||
map.get(&key.as_raw_key()).cloned()
|
||||
}
|
||||
|
||||
/// Write (or overwrite) the full markdown content of a story.
|
||||
///
|
||||
/// Updates the in-memory store immediately.
|
||||
pub fn write_content(story_id: &str, content: &str) {
|
||||
/// Write (or overwrite) content in the in-memory store by typed key.
|
||||
pub fn write_content(key: ContentKey<'_>, content: &str) {
|
||||
if let Some(store) = get_content_store()
|
||||
&& let Ok(mut map) = store.lock()
|
||||
{
|
||||
map.insert(story_id.to_string(), content.to_string());
|
||||
map.insert(key.as_raw_key(), content.to_string());
|
||||
}
|
||||
}
|
||||
|
||||
/// Remove a story's content from the in-memory store.
|
||||
pub fn delete_content(story_id: &str) {
|
||||
/// Remove an entry from the in-memory store by typed key.
|
||||
pub fn delete_content(key: ContentKey<'_>) {
|
||||
if let Some(store) = get_content_store()
|
||||
&& let Ok(mut map) = store.lock()
|
||||
{
|
||||
map.remove(story_id);
|
||||
map.remove(&key.as_raw_key());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -103,3 +139,52 @@ pub fn all_content_ids() -> Vec<String> {
|
||||
pub(super) fn init_content_store(map: HashMap<String, String>) {
|
||||
let _ = CONTENT_STORE.set(Mutex::new(map));
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// AC 2 regression: writing under `ContentKey::Story` is not visible under
|
||||
/// `ContentKey::GateOutput` (and vice versa). The typed key namespace, not
|
||||
/// runtime substring matching, enforces the separation.
|
||||
#[test]
|
||||
fn wrong_key_variant_is_isolated() {
|
||||
ensure_content_store();
|
||||
let id = "9961_regression_key_isolation";
|
||||
|
||||
write_content(ContentKey::Story(id), "story body");
|
||||
|
||||
// A different variant for the same base id must not surface the story body.
|
||||
assert!(
|
||||
read_content(ContentKey::GateOutput(id)).is_none(),
|
||||
"GateOutput key must not read Story content"
|
||||
);
|
||||
assert!(
|
||||
read_content(ContentKey::RunTestsOk(id)).is_none(),
|
||||
"RunTestsOk key must not read Story content"
|
||||
);
|
||||
|
||||
// The Story variant itself must still return the content.
|
||||
assert_eq!(
|
||||
read_content(ContentKey::Story(id)).as_deref(),
|
||||
Some("story body")
|
||||
);
|
||||
|
||||
// Write under a second variant; reading under Story must still return
|
||||
// the original body, not the gate output.
|
||||
write_content(ContentKey::GateOutput(id), "gate failure text");
|
||||
assert_eq!(
|
||||
read_content(ContentKey::Story(id)).as_deref(),
|
||||
Some("story body"),
|
||||
"Story key must not be polluted by GateOutput write"
|
||||
);
|
||||
assert_eq!(
|
||||
read_content(ContentKey::GateOutput(id)).as_deref(),
|
||||
Some("gate failure text")
|
||||
);
|
||||
|
||||
// Cleanup.
|
||||
delete_content(ContentKey::Story(id));
|
||||
delete_content(ContentKey::GateOutput(id));
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user