blob: 8c8cb24da5a43f3c6f210bf0dcb123a2d71c2ce0 [file]
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
use maka_agent_facts::event::EventWrite;
use maka_agent_facts::event::Fact;
use maka_agent_facts::event::InvocationOutcome;
use maka_agent_facts::event::RuntimeEvent;
use maka_agent_facts::message::MessageDisposition as Disposition;
use maka_agent_facts::message::Placement;
use maka_event_log::{
EventLog, StoreError,
message_queue::{QueueCommandKind as Kind, QueueEdit},
message_resolution::MessageResolution,
};
use serde_json::json;
#[path = "support/message_queue.rs"]
mod support;
use support::{admission, append, command, invocation, opening};
#[tokio::test]
async fn removal_atomically_cancels_pending_work_and_keeps_accepted_settlement_across_restart() {
use maka_event_log::sessions::{SessionRemovalResult as Result, SessionRetirement as State};
for active in [false, true] {
let temp = tempfile::tempdir().unwrap();
let path = temp.path().join("removal.sqlite");
let log = EventLog::open(&path).await.unwrap();
log.create_session("session", "create", &json!({}), 1)
.await
.unwrap();
let process = uuid::Uuid::new_v4();
log.admit_session_process("session", process).await.unwrap();
let owner = invocation("first");
if active {
append(&log, &owner, opening()).await;
}
let pending = admission(
&owner,
"pending",
if active {
Disposition::Followup
} else {
Disposition::TurnStarted
},
);
log.admit_message(pending.clone()).await.unwrap();
let revision = log
.get_session::<serde_json::Value>("session")
.await
.unwrap()
.unwrap()
.revision;
assert!(matches!(
log.begin_session_removal("session", revision + 1)
.await
.unwrap(),
Result::RevisionConflict { .. }
));
let inspect = rusqlite::Connection::open(&path).unwrap();
inspect
.execute_batch(
"CREATE TRIGGER reject_retirement BEFORE INSERT ON message_cancellations
BEGIN SELECT RAISE(ABORT, 'cancellation failure'); END;",
)
.unwrap();
assert!(
log.begin_session_removal("session", revision)
.await
.is_err()
);
assert_eq!(log.session_retirement("session").await.unwrap(), None);
assert_eq!(
log.pending_messages("session").await.unwrap(),
vec![pending.clone()]
);
inspect
.execute_batch("DROP TRIGGER reject_retirement")
.unwrap();
drop(inspect);
assert_eq!(
log.begin_session_removal("session", revision)
.await
.unwrap(),
Result::Accepted(State::Removing)
);
assert!(!log.session_retirement_ready("session").await.unwrap());
assert!(
log.admit_session_process("session", uuid::Uuid::new_v4())
.await
.is_err()
);
assert!(log.message_cancelled("session", "pending").await.unwrap());
assert!(
log.get_session::<serde_json::Value>("session")
.await
.unwrap()
.is_none()
);
assert!(
log.list_sessions::<serde_json::Value>(None, None, 32)
.await
.unwrap()
.sessions
.is_empty()
);
assert!(
log.retiring_session::<serde_json::Value>("session")
.await
.unwrap()
.is_some()
);
assert!(
log.pending_message_sessions("maka-runtime-host", None)
.await
.unwrap()
.is_empty()
);
assert!(log.admit_message(pending).await.is_err());
assert!(matches!(
log.retain_session("session").await,
Err(StoreError::SessionRetired)
));
assert!(
log.update_session_metadata(
"session",
revision + 1,
|_: &mut serde_json::Value| Ok(())
)
.await
.is_err()
);
if active {
assert_eq!(
log.finish_session_retirement("session").await.unwrap(),
State::Removing
);
append(
&log,
&owner,
Fact::InvocationEnded {
outcome: InvocationOutcome::Cancelled {
source: "session_removal".into(),
},
},
)
.await;
}
log.close().await.unwrap();
let log = EventLog::open(&path).await.unwrap();
assert!(
!log.session_retirement_ready("session").await.unwrap(),
"losing the native process handle does not prove cleanup"
);
assert_eq!(
log.finish_session_retirement("session").await.unwrap(),
State::Removing
);
log.clean_session_process(process).await.unwrap();
assert!(log.session_retirement_ready("session").await.unwrap());
assert_eq!(
log.pending_session_retirements(None).await.unwrap(),
["session"]
);
assert_eq!(
log.begin_session_removal("session", revision)
.await
.unwrap(),
Result::Accepted(State::Removing)
);
assert_eq!(
log.finish_session_retirement("session").await.unwrap(),
State::Removed
);
assert_eq!(
log.finish_session_retirement("session").await.unwrap(),
State::Removed
);
assert_eq!(
log.begin_session_removal("session", revision)
.await
.unwrap(),
Result::Accepted(State::Removed)
);
assert!(
log.get_session::<serde_json::Value>("session")
.await
.unwrap()
.is_none()
);
assert!(
log.create_session("session", "create", &json!({}), 2)
.await
.is_err()
);
assert!(
log.append(
&EventWrite::plain(RuntimeEvent::new(invocation("late"), opening())).unwrap()
)
.await
.is_err()
);
assert!(
log.pending_session_retirements(None)
.await
.unwrap()
.is_empty()
);
assert!(log.message_cancelled("session", "pending").await.unwrap());
log.close().await.unwrap();
}
}
#[tokio::test]
async fn queue_edits_cancel_or_deliver_once_with_atomic_revision_and_original_ownership() {
let temp = tempfile::tempdir().unwrap();
let path = temp.path().join("queue.sqlite");
let log = EventLog::open(&path).await.unwrap();
log.create_session("session", "create", &json!({}), 1)
.await
.unwrap();
let first = invocation("first");
append(&log, &first, opening()).await;
let mut notices = log.subscribe_commits();
let before = *notices.borrow_and_update();
let targets = ["session".into()];
let version = log.observation_versions(&targets).await.unwrap()["session"];
for id in ["one", "two", "three"] {
let mut pending = admission(&first, id, Disposition::Followup);
if id == "two" {
pending.source.message.origin =
Some(maka_agent_facts::message::MessageOrigin::WorkhubGraph {
graph_id: "graph-original".into(),
});
}
log.admit_message(pending).await.unwrap();
}
notices.changed().await.unwrap();
assert_eq!(
*notices.borrow_and_update(),
before,
"queue wakeup does not invent a log event"
);
let queue = log.message_queue("session").await.unwrap();
assert_eq!(queue.revision, 3);
let queued_version = log.observation_versions(&targets).await.unwrap()["session"];
assert_eq!(queued_version.event, version.event);
assert_eq!(
queued_version.queue, queue.revision,
"queue-only commits must invalidate observation"
);
let reordered = log
.edit_message_queue(
"session",
3,
QueueEdit::Reorder {
message_ids: vec!["three".into(), "one".into(), "two".into()],
},
command("reorder", Kind::Reorder),
)
.await
.unwrap();
assert_eq!(reordered.revision, 4);
let reordered = log.message_queue("session").await.unwrap();
assert_eq!(
reordered
.entries
.iter()
.map(|e| e.source.message.message_id.as_str())
.collect::<Vec<_>>(),
["three", "one", "two"]
);
assert!(matches!(
log.edit_message_queue(
"session",
3,
QueueEdit::RetractAll {
cancellation_id: "stale".into()
},
command("stale", Kind::RetractAll),
)
.await,
Err(StoreError::RevisionConflict { .. })
));
assert_eq!(log.message_queue("session").await.unwrap(), reordered);
let same = log
.edit_message_queue(
"session",
4,
QueueEdit::Reorder {
message_ids: vec!["three".into(), "one".into(), "two".into()],
},
command("same", Kind::Reorder),
)
.await
.unwrap();
assert_eq!(same.revision, reordered.revision);
let db = rusqlite::Connection::open(&path).unwrap();
db.execute_batch(
"CREATE TRIGGER reject_cancel BEFORE INSERT ON message_cancellations
WHEN NEW.message_id = 'one' BEGIN SELECT RAISE(ABORT, 'cancel fault'); END;",
)
.unwrap();
assert!(
log.edit_message_queue(
"session",
4,
QueueEdit::RetractAll {
cancellation_id: "fault".into()
},
command("fault", Kind::RetractAll),
)
.await
.is_err()
);
assert_eq!(log.message_queue("session").await.unwrap(), reordered);
for id in ["one", "two", "three"] {
assert!(!log.message_cancelled("session", id).await.unwrap());
}
db.execute_batch("DROP TRIGGER reject_cancel").unwrap();
let receipt: maka_agent_facts::input::InputReceipt = serde_json::from_value(json!({
"source":{"kind":"input","name":"review","packageId":"reviewer","entryId":"entry","activation":"1","revision":"1"},
"receipt":{"document":"report.md"}
})).unwrap();
let mut content: maka_agent_facts::input::MessageInput = "edited 😀".into();
content.preparation.push(receipt.clone());
let updated = log
.edit_message_queue(
"session",
4,
QueueEdit::Update {
message_id: "two".into(),
unprepared_content: Box::new("edited 😀".into()),
content: Box::new(content),
required_tools: ["Read".into()].into(),
},
command("update", Kind::Update),
)
.await
.unwrap();
assert_eq!(updated.revision, 5);
append(
&log,
&first,
Fact::InvocationEnded {
outcome: InvocationOutcome::Completed,
},
)
.await;
let second = invocation("second");
append(&log, &second, opening()).await;
let promoted = log
.edit_message_queue(
"session",
5,
QueueEdit::Promote {
message_id: "two".into(),
invocation: second.clone(),
},
command("promote", Kind::Promote),
)
.await
.unwrap();
assert_eq!(promoted.revision, 6);
let promoted = log.message_queue("session").await.unwrap();
let entry = promoted
.entries
.iter()
.find(|e| e.source.message.message_id == "two")
.unwrap();
assert_eq!(
entry.invocation, first,
"promotion does not rewrite original admission ownership"
);
let origin = Some(maka_agent_facts::message::MessageOrigin::WorkhubGraph {
graph_id: "graph-original".into(),
});
assert_eq!(entry.source.message.origin, origin);
assert_eq!(entry.steering_target(), &second);
assert_eq!(entry.required_tools, ["Read".into()].into());
assert_eq!(entry.source.submitted_placement, Placement::NextTurn);
let mut omitted = entry.source.message.clone();
omitted.content.preparation.clear();
let omitted_receipt = EventWrite::plain(RuntimeEvent::new(
second.clone(),
Fact::MessageSteered {
source: None,
message: Box::new(omitted),
},
))
.unwrap();
assert!(log.append(&omitted_receipt).await.is_err());
assert_eq!(log.message_queue("session").await.unwrap(), promoted);
assert_eq!(log.commit_pending_steering(&second).await.unwrap(), 1);
assert!(matches!(
log.edit_message_queue(
"session",
6,
QueueEdit::Retract {
message_id: "two".into(),
cancellation_id: "late".into()
},
command("late", Kind::Retract),
)
.await,
Err(StoreError::RevisionConflict { .. })
));
let proof = log
.steering_message("session", "two")
.await
.unwrap()
.unwrap();
assert_eq!(proof.event.invocation, second);
assert!(
matches!(&proof.event.fact, Fact::MessageSteered { message, .. } if message.content.preparation == [receipt] && message.origin == origin)
);
let before_retraction = log.message_queue("session").await.unwrap();
let cancelled = log
.edit_message_queue(
"session",
before_retraction.revision,
QueueEdit::RetractAll {
cancellation_id: "cancel-rest".into(),
},
command("cancel-rest", Kind::RetractAll),
)
.await
.unwrap();
assert_eq!(cancelled.retracted.len(), 2);
assert_eq!(cancelled.retracted, before_retraction.entries);
let queue = log.message_queue("session").await.unwrap();
assert!(queue.entries.is_empty());
for id in ["one", "three"] {
assert!(log.message_cancelled("session", id).await.unwrap());
assert!(
log.admit_message(admission(&second, id, Disposition::Steering))
.await
.is_err()
);
assert!(
log.append(
&EventWrite::plain(RuntimeEvent::new(
second.clone(),
Fact::MessageSteered {
source: None,
message: Box::new(
admission(&second, id, Disposition::Steering).source.message
),
}
))
.unwrap()
)
.await
.is_err(),
"cancelled identity cannot bypass the pending path"
);
}
let ids: Vec<_> = ["one", "two", "three", "absent"].map(String::from).into();
let resolutions = log.message_resolutions("session", &ids).await.unwrap();
assert_eq!(
resolutions,
vec![
MessageResolution::Cancelled {
message_id: "one".into()
},
MessageResolution::Owned {
message_id: "two".into(),
invocation: second
},
MessageResolution::Cancelled {
message_id: "three".into()
},
MessageResolution::Absent {
message_id: "absent".into()
},
]
);
assert!(
log.message_resolutions("foreign", &ids)
.await
.unwrap()
.is_empty()
);
let prefix = serde_json::to_vec(&log.prefix(100, 1024 * 1024).await.unwrap()).unwrap();
log.close().await.unwrap();
drop(db);
let log = EventLog::open(&path).await.unwrap();
assert_eq!(log.message_queue("session").await.unwrap(), queue);
assert_eq!(
log.queue_command_receipt("session", &command("cancel-rest", Kind::RetractAll))
.await
.unwrap(),
Some(cancelled)
);
assert_eq!(
log.message_resolutions("session", &ids).await.unwrap(),
resolutions
);
assert_eq!(
serde_json::to_vec(&log.prefix(100, 1024 * 1024).await.unwrap()).unwrap(),
prefix
);
log.close().await.unwrap();
}
#[tokio::test]
async fn pending_dispatchers_remain_disjoint_across_reopen_and_receipts_bind_execution() {
let temp = tempfile::tempdir().unwrap();
let path = temp.path().join("dispatch.sqlite");
let log = EventLog::open(&path).await.unwrap();
for (session, dispatcher) in [
("a", "native-a"),
("b", "host"),
("c", "native-a"),
("d", "native-b"),
] {
log.create_session(session, "create", &json!({}), 1)
.await
.unwrap();
let mut owner = invocation(session);
owner.session_id = session.into();
append(&log, &owner, opening()).await;
let mut pending = admission(&owner, "message", Disposition::Followup);
pending.execution.owner = dispatcher.into();
// Accepted execution may exceed the receipt's 64KiB budget. Only its
// digest belongs in the receipt, while the original basis stays pending.
pending.execution.basis = json!({"model":"accepted","parameters":"x".repeat(70 * 1024)});
let receipt = log
.admit_queued_message("epoch", 0, pending.clone())
.await
.unwrap();
assert_eq!(
log.admit_queued_message("epoch", 0, pending.clone())
.await
.unwrap(),
receipt
);
let mut reordered = pending.clone();
reordered.execution.basis = json!({"parameters":"x".repeat(70 * 1024),"model":"accepted"});
assert_eq!(
log.admit_queued_message("epoch", 0, reordered)
.await
.unwrap(),
receipt
);
let mut changed = pending.clone();
changed.execution.basis = json!({"model":"replacement"});
assert!(log.admit_queued_message("epoch", 0, changed).await.is_err());
let mut changed = pending.clone();
changed.execution.owner = "another-owner".into();
assert!(
log.admit_queued_message("epoch", 0, changed.clone())
.await
.is_err()
);
changed.source.message.message_id = "another-message".into();
assert!(
log.admit_queued_message("epoch", receipt.queue_revision, changed)
.await
.is_err()
);
assert_eq!(log.pending_messages(session).await.unwrap(), vec![pending]);
}
log.shutdown().await.unwrap();
let log = EventLog::open(&path).await.unwrap();
assert_eq!(
log.pending_message_sessions("native-a", None)
.await
.unwrap(),
["a", "c"]
);
assert_eq!(
log.pending_message_sessions("native-a", Some("a"))
.await
.unwrap(),
["c"]
);
assert_eq!(
log.pending_message_sessions("host", None).await.unwrap(),
["b"]
);
assert_eq!(
log.pending_message_sessions("native-b", None)
.await
.unwrap(),
["d"]
);
assert!(
log.pending_message_sessions("absent", None)
.await
.unwrap()
.is_empty()
);
// A receipt remains original evidence even after the pending row is gone.
let entry = log.pending_messages("a").await.unwrap().remove(0);
let revision = log.message_queue("a").await.unwrap().revision;
log.edit_message_queue(
"a",
revision,
QueueEdit::Retract {
message_id: "message".into(),
cancellation_id: "cancel".into(),
},
command("cancel", Kind::Retract),
)
.await
.unwrap();
let mut changed = entry.clone();
changed.execution.basis = json!({"model":"replacement"});
assert!(log.admit_queued_message("epoch", 0, changed).await.is_err());
assert!(log.admit_queued_message("epoch", 0, entry).await.is_ok());
log.shutdown().await.unwrap();
}
#[tokio::test]
async fn pending_queue_preserves_more_than_sixty_four_messages_through_reorder() {
let temp = tempfile::tempdir().unwrap();
let path = temp.path().join("many-pending.sqlite");
let log = EventLog::open(&path).await.unwrap();
log.create_session("session", "create", &json!({}), 1)
.await
.unwrap();
let owner = invocation("many");
append(&log, &owner, opening()).await;
for index in 0..65 {
let id = format!("pending-{index:02}");
log.admit_message(admission(&owner, &id, Disposition::Followup))
.await
.unwrap();
}
let queue = log.message_queue("session").await.unwrap();
assert_eq!(queue.revision, 65);
assert_eq!(queue.entries.len(), 65);
assert_eq!(
log.pending_messages("session").await.unwrap(),
queue.entries
);
for (index, entry) in queue.entries.iter().enumerate() {
assert_eq!(
entry.source.message.message_id,
format!("pending-{index:02}")
);
assert_eq!(
entry.source.unprepared_content.text,
format!("pending-{index:02}")
);
}
let ids: Vec<_> = (0..65)
.rev()
.map(|index| format!("pending-{index:02}"))
.collect();
let reordered = log
.edit_message_queue(
"session",
65,
QueueEdit::Reorder {
message_ids: ids.clone(),
},
command("many-reorder", Kind::Reorder),
)
.await
.unwrap();
assert_eq!(reordered.revision, 66);
let queue = log.message_queue("session").await.unwrap();
assert_eq!(
queue
.entries
.iter()
.map(|entry| entry.source.message.message_id.clone())
.collect::<Vec<_>>(),
ids
);
let mut duplicates = ids.clone();
duplicates[64] = duplicates[0].clone();
assert!(
log.edit_message_queue(
"session",
66,
QueueEdit::Reorder {
message_ids: duplicates
},
command("many-duplicate", Kind::Reorder),
)
.await
.is_err()
);
assert_eq!(log.message_queue("session").await.unwrap(), queue);
log.close().await.unwrap();
}
#[tokio::test]
async fn pending_queue_preserves_more_than_eight_mib_through_admission_edit_and_receipt_restart() {
let temp = tempfile::tempdir().unwrap();
let path = temp.path().join("large-pending.sqlite");
let log = EventLog::open(&path).await.unwrap();
log.create_session("session", "create", &json!({}), 1)
.await
.unwrap();
let owner = invocation("large");
append(&log, &owner, opening()).await;
for index in 0..7 {
let mut pending = admission(&owner, &format!("large-{index}"), Disposition::Followup);
let text = char::from(b'a' + index).to_string().repeat(600 * 1024);
pending.source.message.content.text = text.clone();
pending.source.unprepared_content.text = text;
log.admit_message(pending).await.unwrap();
}
let queue = log.message_queue("session").await.unwrap();
assert_eq!(queue.revision, 7);
assert_eq!(queue.entries.len(), 7);
assert!(
queue
.entries
.iter()
.map(|entry| serde_json::to_vec(entry).unwrap().len())
.sum::<usize>()
> 8 * 1024 * 1024
);
assert_eq!(
log.pending_messages("session").await.unwrap(),
queue.entries
);
for (index, entry) in queue.entries.iter().enumerate() {
let text = char::from(b'a' + index as u8)
.to_string()
.repeat(600 * 1024);
assert_eq!(entry.source.message.message_id, format!("large-{index}"));
assert_eq!(entry.source.message.content.text, text);
assert_eq!(entry.source.unprepared_content.text, text);
}
let mut content = queue.entries[0].source.message.content.clone();
let mut unprepared = queue.entries[0].source.unprepared_content.clone();
content.text = "edited-large-source".repeat(30_000);
unprepared.text = content.text.clone();
let update = QueueEdit::Update {
message_id: "large-0".into(),
content: Box::new(content.clone()),
unprepared_content: Box::new(unprepared.clone()),
required_tools: Default::default(),
};
let updated = log
.edit_message_queue("session", 7, update, command("large-update", Kind::Update))
.await
.unwrap();
assert_eq!(updated.revision, 8);
let queue = log.message_queue("session").await.unwrap();
assert_eq!(queue.entries[0].source.message.content, content);
assert_eq!(queue.entries[0].source.unprepared_content, unprepared);
assert_eq!(
queue.entries[0].source.message.submitted_content_digest,
format!("sha256:{}", "c".repeat(64))
);
let edit = QueueEdit::RetractAll {
cancellation_id: "large-cancel".into(),
};
let cancel = command("large-retract", Kind::RetractAll);
let receipt = log
.edit_message_queue("session", 8, edit.clone(), cancel.clone())
.await
.unwrap();
assert_eq!(receipt.revision, 9);
assert_eq!(receipt.retracted, queue.entries);
assert!(serde_json::to_vec(&receipt).unwrap().len() > 8 * 1024 * 1024);
assert_eq!(
log.edit_message_queue("session", 8, edit, cancel.clone())
.await
.unwrap(),
receipt
);
for index in 0..7 {
assert!(
log.message_cancelled("session", &format!("large-{index}"))
.await
.unwrap()
);
}
assert!(log.pending_messages("session").await.unwrap().is_empty());
log.close().await.unwrap();
let log = EventLog::open(&path).await.unwrap();
assert_eq!(
log.queue_command_receipt("session", &cancel).await.unwrap(),
Some(receipt)
);
log.close().await.unwrap();
}