gatewayd: state, the posts of open approvals, and the turn of a session
Implemented-By: OpenCode session (model recorded in docs/implementer-log.md)
This commit is contained in:
@@ -51,6 +51,19 @@ struct StateFile {
|
||||
recent: Vec<String>,
|
||||
threads: Vec<String>,
|
||||
in_flight: Vec<InFlight>,
|
||||
/// Optional, so an M4a state file loads unchanged.
|
||||
#[serde(default)]
|
||||
approvals: Vec<ApprovalPosts>,
|
||||
}
|
||||
|
||||
/// The posts of an approval shown in Mattermost and not yet ended (M4b spec, section 8).
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct ApprovalPosts {
|
||||
pub approval: u64,
|
||||
pub channel: String,
|
||||
/// First part first; the last is the one that is edited when it ends.
|
||||
pub posts: Vec<String>,
|
||||
}
|
||||
|
||||
impl StateFile {
|
||||
@@ -84,6 +97,14 @@ impl StateFile {
|
||||
return Some(format!("a turn in flight is not valid: {:?}", turn.session));
|
||||
}
|
||||
}
|
||||
for shown in &self.approvals {
|
||||
if !valid_id(&shown.channel)
|
||||
|| shown.posts.is_empty()
|
||||
|| !shown.posts.iter().all(|post| valid_id(post))
|
||||
{
|
||||
return Some(format!("approval {}'s posts are not valid", shown.approval));
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
}
|
||||
@@ -250,4 +271,43 @@ impl State {
|
||||
}
|
||||
self.save().map(|()| taken)
|
||||
}
|
||||
|
||||
/// The turn of a session, if one is in flight.
|
||||
pub fn turn_of(&self, session: &str) -> Option<InFlight> {
|
||||
// The entry of in_flight with that session, cloned.
|
||||
self.file
|
||||
.in_flight
|
||||
.iter()
|
||||
.find(|turn| turn.session == session)
|
||||
.cloned()
|
||||
}
|
||||
|
||||
/// A new posting of an approval shown in Mattermost; it replaces any earlier one.
|
||||
pub fn add_approval(&mut self, shown: ApprovalPosts) -> Result<(), StateError> {
|
||||
// Remove any entry with the same approval, push this one, save.
|
||||
self.file.approvals.retain(|a| a.approval != shown.approval);
|
||||
self.file.approvals.push(shown);
|
||||
self.save()
|
||||
}
|
||||
|
||||
/// Stop tracking an approval; nothing saved when it was not tracked.
|
||||
pub fn remove_approval(&mut self, approval: u64) -> Result<(), StateError> {
|
||||
// Remove the entries with that approval, save only if one was removed.
|
||||
let before = self.file.approvals.len();
|
||||
self.file.approvals.retain(|a| a.approval != approval);
|
||||
if self.file.approvals.len() == before {
|
||||
return Ok(());
|
||||
}
|
||||
self.save()
|
||||
}
|
||||
|
||||
/// The approval posts left behind by the last run, removed from the state.
|
||||
pub fn take_approvals(&mut self) -> Result<Vec<ApprovalPosts>, StateError> {
|
||||
// Take them all out (std::mem::take), save only when there were some, return them.
|
||||
let taken = std::mem::take(&mut self.file.approvals);
|
||||
if taken.is_empty() {
|
||||
return Ok(taken);
|
||||
}
|
||||
self.save().map(|()| taken)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
//! The approval posts in the state file, for marking them at a restart; an M4a state file still
|
||||
//! loads; and the turn of a session (M4b spec, sections 6 and 8). Do not edit.
|
||||
|
||||
#[path = "support/tmp.rs"]
|
||||
mod tmp;
|
||||
|
||||
use gatewayd::state::{ApprovalPosts, InFlight, State, StateError};
|
||||
use tmp::TempDir;
|
||||
|
||||
const DM: &str = "d0000000000000000000000000";
|
||||
const ROOT: &str = "r0000000000000000000000000";
|
||||
|
||||
fn id(n: u32) -> String {
|
||||
format!("p{n:025}")
|
||||
}
|
||||
|
||||
fn shown(approval: u64, posts: &[u32]) -> ApprovalPosts {
|
||||
ApprovalPosts {
|
||||
approval,
|
||||
channel: DM.to_string(),
|
||||
posts: posts.iter().map(|n| id(*n)).collect(),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn approval_posts_survive_a_restart_and_are_taken_once() {
|
||||
let dir = TempDir::new("state-approvals");
|
||||
let path = dir.path().join("gateway/state.json");
|
||||
let mut s = State::load(&path).unwrap();
|
||||
s.add_approval(shown(42, &[1, 2])).unwrap();
|
||||
s.add_approval(shown(43, &[3])).unwrap();
|
||||
s.add_approval(shown(42, &[4])).unwrap();
|
||||
s.remove_approval(43).unwrap();
|
||||
s.remove_approval(99).unwrap();
|
||||
let mut again = State::load(&path).unwrap();
|
||||
assert_eq!(
|
||||
again.take_approvals().unwrap(),
|
||||
[shown(42, &[4])],
|
||||
"the later posting of 42 replaced the first"
|
||||
);
|
||||
assert!(
|
||||
State::load(&path)
|
||||
.unwrap()
|
||||
.take_approvals()
|
||||
.unwrap()
|
||||
.is_empty(),
|
||||
"taking is saved"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_m4a_state_file_loads() {
|
||||
let dir = TempDir::new("state-m4a");
|
||||
let text = format!(r#"{{"channels":{{"{DM}":5}},"recent":[],"threads":[],"in_flight":[]}}"#);
|
||||
let path = dir.write("state.json", &text);
|
||||
let mut s = State::load(&path).unwrap();
|
||||
assert_eq!(s.since(DM), Some(5));
|
||||
assert!(s.take_approvals().unwrap().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bad_approval_posts_are_refused() {
|
||||
let dir = TempDir::new("state-approvals-bad");
|
||||
for (n, approvals) in [
|
||||
r#"[{"approval":1,"channel":"../x","posts":["p0000000000000000000000001"]}]"#,
|
||||
r#"[{"approval":1,"channel":"d0000000000000000000000000","posts":[]}]"#,
|
||||
r#"[{"approval":1,"channel":"d0000000000000000000000000","posts":["short"]}]"#,
|
||||
r#"[{"approval":1,"channel":"d0000000000000000000000000","posts":["p0000000000000000000000001"],"extra":1}]"#,
|
||||
r#"[{"approval":-1,"channel":"d0000000000000000000000000","posts":["p0000000000000000000000001"]}]"#,
|
||||
]
|
||||
.iter()
|
||||
.enumerate()
|
||||
{
|
||||
let text = format!(r#"{{"channels":{{}},"recent":[],"threads":[],"in_flight":[],"approvals":{approvals}}}"#);
|
||||
let path = dir.write(&format!("s{n}.json"), &text);
|
||||
assert!(matches!(State::load(&path), Err(StateError::Read(..))), "{approvals}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_turn_of_a_session() {
|
||||
let dir = TempDir::new("state-turn-of");
|
||||
let mut s = State::load(&dir.path().join("state.json")).unwrap();
|
||||
let turn = InFlight {
|
||||
session: format!("mm-{ROOT}"),
|
||||
channel: DM.to_string(),
|
||||
root: ROOT.to_string(),
|
||||
};
|
||||
assert_eq!(s.turn_of(&turn.session), None);
|
||||
s.start_turn(turn.clone()).unwrap();
|
||||
assert_eq!(s.turn_of(&turn.session), Some(turn.clone()));
|
||||
assert_eq!(s.turn_of("mm-other"), None);
|
||||
s.end_turn(&turn.session).unwrap();
|
||||
assert_eq!(s.turn_of(&turn.session), None);
|
||||
}
|
||||
Reference in New Issue
Block a user