282 lines
15 KiB
Rust
282 lines
15 KiB
Rust
//! The pending-changes queue (SPEC 25): dry runs, accept/reject flows,
|
|
//! conflicts, the cap, and hostile params.
|
|
|
|
#![allow(dead_code)] // shared fixtures: only some helpers are used here
|
|
|
|
use notescraft_engine::{
|
|
AssistantGate, AssistantSettings, Decision, Error, GateSource, PendingApply, PendingChange, PendingKind, Registry, Session, apply_pending, compute_pending,
|
|
register_core, run_as_assistant,
|
|
};
|
|
use serde_json::{Value, json};
|
|
|
|
mod common;
|
|
|
|
const ALPHA: &str = "---\nstatus: draft\ntags: [work]\n---\n\n# Alpha\n\nbody line\n";
|
|
|
|
/// A registry and a session whose assistant may write, with review on.
|
|
fn spun_up(name: &str) -> (common::TempVault, Session, Registry<Session>) {
|
|
let vault = common::temp_vault(name);
|
|
common::seed(vault.path(), &[("alpha.md", ALPHA)]);
|
|
let mut session = Session::open(vault.path()).expect("open the test vault");
|
|
session.set_assistant_settings(AssistantSettings { allow_write: true, allow_delete: true, review_writes: true, ..AssistantSettings::default() });
|
|
let mut registry = Registry::new();
|
|
register_core(&mut registry).expect("register the core commands");
|
|
(vault, session, registry)
|
|
}
|
|
|
|
/// Queues one `note.write` for `alpha.md` and answers with the pending id.
|
|
fn queue_write(session: &mut Session, registry: &Registry<Session>, content: &str) -> u64 {
|
|
let reply = run_as_assistant(session, registry, "note.write", &json!({ "path": "alpha.md", "content": content })).expect("queue the write");
|
|
reply.get("pending_id").and_then(Value::as_u64).expect("the reply carries the pending id")
|
|
}
|
|
|
|
/// Queues an arbitrary command and answers with the pending id.
|
|
fn queue(session: &mut Session, registry: &Registry<Session>, id: &str, params: &Value) -> u64 {
|
|
let reply = run_as_assistant(session, registry, id, params).expect("queue the command");
|
|
reply.get("pending_id").and_then(Value::as_u64).expect("the reply carries the pending id")
|
|
}
|
|
|
|
/// The newest queued change.
|
|
fn last(session: &Session) -> PendingChange {
|
|
session.pending_changes().last().cloned().expect("something is queued")
|
|
}
|
|
|
|
/// The queued change kind, read out for assertions.
|
|
fn kind_name(session: &Session, id: u64) -> Option<&'static str> {
|
|
session.pending_changes().iter().find(|change| change.id == id).map(|change| kind_of(&change.kind))
|
|
}
|
|
|
|
fn kind_of(kind: &PendingKind) -> &'static str {
|
|
match kind {
|
|
PendingKind::Create => "create",
|
|
PendingKind::Modify => "modify",
|
|
PendingKind::Delete => "delete",
|
|
PendingKind::Rename => "rename",
|
|
PendingKind::Opaque { .. } => "opaque",
|
|
}
|
|
}
|
|
|
|
/// Fails the test when the engine error is not the expected command failure.
|
|
fn command_code(outcome: Result<Value, Error>) -> (String, String) {
|
|
match outcome {
|
|
Err(Error::Command { source, .. }) => (source.code, source.message),
|
|
other => panic!("expected a command error, found {other:?}"),
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn dry_runs_compute_old_and_new_without_writing() {
|
|
let (_vault, mut session, registry) = spun_up("queue-dry-run");
|
|
let before = session.read_note("alpha.md").expect("read alpha");
|
|
|
|
let write_id = queue_write(&mut session, ®istry, "replaced");
|
|
let pending = last(&session);
|
|
assert_eq!(pending.id, write_id);
|
|
assert_eq!(kind_name(&session, write_id), Some("modify"));
|
|
assert_eq!(pending.old.as_deref(), Some(before.as_str()), "old is the disk text");
|
|
assert_eq!(pending.new.as_deref(), Some("replaced"));
|
|
assert_eq!(session.read_note("alpha.md").expect("reread"), before, "nothing was written");
|
|
|
|
let _ = queue(&mut session, ®istry, "note.create", &json!({ "path": "fresh.md", "content": "created" }));
|
|
let created = last(&session);
|
|
assert_eq!(kind_name(&session, created.id), Some("create"));
|
|
assert_eq!(created.new.as_deref(), Some("created"));
|
|
assert!(created.old.is_none());
|
|
assert!(!session.vault().resolve("fresh.md").is_ok_and(|path| path.exists()), "create did not run");
|
|
|
|
let _ = queue(&mut session, ®istry, "note.delete", &json!({ "path": "alpha.md" }));
|
|
let doomed = last(&session);
|
|
assert_eq!(kind_name(&session, doomed.id), Some("delete"));
|
|
assert_eq!(doomed.old.as_deref(), Some(before.as_str()));
|
|
assert!(session.vault().resolve("alpha.md").is_ok_and(|path| path.exists()), "delete did not run");
|
|
|
|
let _ = queue(&mut session, ®istry, "note.rename", &json!({ "from": "alpha.md", "to": "alpha-moved.md" }));
|
|
let renamed = last(&session);
|
|
assert_eq!(kind_name(&session, renamed.id), Some("rename"));
|
|
assert_eq!(renamed.path, "alpha.md");
|
|
assert_eq!(renamed.new.as_deref(), Some("alpha-moved.md"));
|
|
|
|
let _ = queue(&mut session, ®istry, "note.property_set", &json!({ "path": "alpha.md", "key": "status", "value": "done", "type": "text" }));
|
|
let property = last(&session);
|
|
assert_eq!(kind_name(&session, property.id), Some("modify"));
|
|
assert_eq!(property.old.as_deref(), Some(before.as_str()));
|
|
let new_text = property.new.as_deref().expect("the dry run computed the text");
|
|
assert!(new_text.contains("status: done"), "{new_text}");
|
|
assert!(new_text.contains("# Alpha"), "the body survived the dry run");
|
|
assert_eq!(session.read_note("alpha.md").expect("reread"), before, "the property edit did not run");
|
|
}
|
|
|
|
/// A write on a missing note queues as a create; a folder command and an
|
|
/// invalid property value queue as opaque with a summary.
|
|
#[test]
|
|
fn opaque_kinds_carry_a_summary() {
|
|
let (_vault, mut session, registry) = spun_up("queue-opaque");
|
|
let _ = queue(&mut session, ®istry, "note.write", &json!({ "path": "later.md", "content": "text" }));
|
|
assert_eq!(kind_name(&session, last(&session).id), Some("create"), "a write to a missing note is a create");
|
|
|
|
let _ = queue(&mut session, ®istry, "folder.create", &json!({ "folder": "brand-new" }));
|
|
let folder = last(&session);
|
|
assert_eq!(kind_name(&session, folder.id), Some("opaque"));
|
|
match folder.kind {
|
|
PendingKind::Opaque { command, summary } => {
|
|
assert_eq!(command, "folder.create");
|
|
assert!(summary.contains("folder.create"), "{summary}");
|
|
}
|
|
_ => panic!("expected opaque"),
|
|
}
|
|
|
|
let _ = queue(&mut session, ®istry, "note.property_set", &json!({ "path": "alpha.md", "key": "when", "value": "not a date", "type": "date" }));
|
|
assert_eq!(kind_name(&session, last(&session).id), Some("opaque"), "an invalid dry run queues as opaque");
|
|
}
|
|
|
|
/// Accept applies the change through the normal write path.
|
|
#[test]
|
|
fn accept_applies_modify_create_delete_and_rename() {
|
|
let (_vault, mut session, registry) = spun_up("queue-accept");
|
|
|
|
let write_id = queue_write(&mut session, ®istry, "replaced body");
|
|
let reply = registry.run("assistant.accept", &mut session, &json!({ "id": write_id })).expect("accept");
|
|
assert_eq!(reply.get("accepted").and_then(Value::as_array).map(Vec::len), Some(1));
|
|
assert_eq!(session.read_note("alpha.md").expect("read"), "replaced body");
|
|
assert_eq!(session.pending_changes().len(), 0, "applied changes leave the queue");
|
|
|
|
let _ = queue(&mut session, ®istry, "note.create", &json!({ "path": "brand-new.md", "content": "hello" }));
|
|
let create_id = last(&session).id;
|
|
registry.run("assistant.accept", &mut session, &json!({ "id": create_id })).expect("accept the create");
|
|
assert_eq!(session.read_note("brand-new.md").expect("read"), "hello");
|
|
|
|
let _ = queue(&mut session, ®istry, "note.delete", &json!({ "path": "brand-new.md" }));
|
|
let delete_id = last(&session).id;
|
|
registry.run("assistant.accept", &mut session, &json!({ "id": delete_id })).expect("accept the delete");
|
|
assert!(!session.vault().resolve("brand-new.md").is_ok_and(|path| path.exists()), "the note is gone");
|
|
|
|
let _ = queue(&mut session, ®istry, "note.rename", &json!({ "from": "alpha.md", "to": "renamed.md" }));
|
|
let rename_id = last(&session).id;
|
|
registry.run("assistant.accept", &mut session, &json!({ "id": rename_id })).expect("accept the rename");
|
|
assert!(session.vault().resolve("renamed.md").is_ok_and(|path| path.exists()));
|
|
assert!(!session.vault().resolve("alpha.md").is_ok_and(|path| path.exists()));
|
|
}
|
|
|
|
/// A change whose note moved since is a conflict and stays queued.
|
|
#[test]
|
|
fn a_conflict_stays_queued_with_its_id() {
|
|
let (_vault, mut session, registry) = spun_up("queue-conflict");
|
|
let write_id = queue_write(&mut session, ®istry, "the agent text");
|
|
// The user edits the note meanwhile.
|
|
session.write_note("alpha.md", "---\nstatus: edited\n---\n\nmoved on\n").expect("the user writes");
|
|
let (code, _message) = command_code(registry.run("assistant.accept", &mut session, &json!({ "id": write_id })));
|
|
assert_eq!(code, "conflict");
|
|
assert_eq!(session.pending_changes().len(), 1, "the change stays queued");
|
|
assert_eq!(session.pending_changes().first().map(|change| change.id), Some(write_id), "same id");
|
|
// And the user can still reject it.
|
|
registry.run("assistant.reject", &mut session, &json!({ "id": write_id })).expect("reject");
|
|
assert_eq!(session.pending_changes().len(), 0);
|
|
}
|
|
|
|
/// `accept all` reports conflicts as data and applies the rest.
|
|
#[test]
|
|
fn accept_all_reports_conflicts_as_data() {
|
|
let (_vault, mut session, registry) = spun_up("queue-accept-all");
|
|
let _ = queue(&mut session, ®istry, "note.create", &json!({ "path": "one.md", "content": "one" }));
|
|
let _ = queue(&mut session, ®istry, "note.create", &json!({ "path": "two.md", "content": "two" }));
|
|
// A conflict is engineered by creating the file out of band.
|
|
session.write_note("one.md", "already here").expect("the user writes");
|
|
let reply = registry.run("assistant.accept", &mut session, &json!({ "all": true })).expect("accept all");
|
|
let accepted = reply.get("accepted").and_then(Value::as_array).cloned().expect("the per-change report");
|
|
assert_eq!(accepted.len(), 2);
|
|
assert!(accepted.iter().any(|row| row.get("conflict").is_some()), "the conflict is reported: {accepted:?}");
|
|
assert!(accepted.iter().any(|row| row.get("applied").is_some()), "the other change applied: {accepted:?}");
|
|
assert_eq!(session.read_note("two.md").expect("read"), "two", "two.md exists");
|
|
// The conflicted one stays queued.
|
|
assert_eq!(session.pending_changes().len(), 1);
|
|
}
|
|
|
|
/// Reject drops one change; reject-all drops everything.
|
|
#[test]
|
|
fn reject_drops_one_or_every_pending_change() {
|
|
let (_vault, mut session, registry) = spun_up("queue-reject");
|
|
let first = queue_write(&mut session, ®istry, "one");
|
|
let _ = queue(&mut session, ®istry, "note.create", &json!({ "path": "x.md", "content": "x" }));
|
|
let second = last(&session).id;
|
|
assert_ne!(first, second);
|
|
registry.run("assistant.reject", &mut session, &json!({ "id": first })).expect("reject one");
|
|
assert_eq!(session.pending_changes().len(), 1);
|
|
assert_eq!(session.pending_changes().first().map(|change| change.id), Some(second));
|
|
registry.run("assistant.reject", &mut session, &json!({ "all": true })).expect("reject all");
|
|
assert_eq!(session.pending_changes().len(), 0);
|
|
// Rejecting an unknown id is an error, not data.
|
|
assert!(registry.run("assistant.reject", &mut session, &json!({ "id": 999 })).is_err());
|
|
}
|
|
|
|
/// The queue caps at 200: the oldest is dropped with a notice.
|
|
#[test]
|
|
fn the_queue_caps_at_200_oldest_first() {
|
|
let (_vault, mut session, registry) = spun_up("queue-cap");
|
|
for step in 0..201 {
|
|
let path = format!("cap-{step}.md");
|
|
let _ = queue(&mut session, ®istry, "note.create", &json!({ "path": path, "content": "x" }));
|
|
}
|
|
assert_eq!(session.pending_changes().len(), 200, "the cap holds");
|
|
assert_eq!(session.pending_changes().first().map(|change| change.id), Some(2), "the oldest pending change was dropped");
|
|
assert!(session.notices().iter().any(|notice| notice.contains("queue is full")), "{:?}", session.notices());
|
|
assert_eq!(session.pending_changes().last().map(|change| change.id), Some(201), "ids keep counting up");
|
|
}
|
|
|
|
/// Hostile params queue as opaque and never panic the gate.
|
|
#[test]
|
|
fn hostile_params_queue_as_opaque() {
|
|
let (_vault, mut session, registry) = spun_up("queue-hostile");
|
|
let reply = run_as_assistant(&mut session, ®istry, "note.write", &Value::Null).expect("null params still queue");
|
|
assert_eq!(reply.get("queued").and_then(Value::as_bool), Some(true));
|
|
assert!(matches!(last(&session).kind, PendingKind::Opaque { .. }), "no path, no dry run");
|
|
|
|
let _ = queue(&mut session, ®istry, "note.write", &json!({ "path": 42, "content": [1, 2, 3] }));
|
|
assert_eq!(kind_name(&session, last(&session).id), Some("opaque"), "typed params still queue");
|
|
|
|
let _ = queue(&mut session, ®istry, "note.write", &json!({ "path": "", "content": "" }));
|
|
assert_eq!(kind_name(&session, last(&session).id), Some("create"), "empty path queues; accept reports the registry error");
|
|
}
|
|
|
|
/// `apply_pending` is also callable directly: opaque stays queued, done applies.
|
|
#[test]
|
|
fn apply_pending_reports_done_and_opaque() {
|
|
let (_vault, mut session, _registry) = spun_up("queue-apply-direct");
|
|
let change = compute_pending(&session, "note.write", &json!({ "path": "alpha.md", "content": "direct" }));
|
|
match apply_pending(&mut session, &change) {
|
|
Ok(PendingApply::Done { path }) => assert_eq!(path.as_deref(), Some("alpha.md")),
|
|
other => panic!("expected done, got {other:?}"),
|
|
}
|
|
assert_eq!(session.read_note("alpha.md").expect("read"), "direct");
|
|
let opaque = compute_pending(&session, "folder.create", &json!({ "folder": "elsewhere" }));
|
|
match apply_pending(&mut session, &opaque) {
|
|
Ok(PendingApply::Opaque) => {}
|
|
other => panic!("expected opaque, got {other:?}"),
|
|
}
|
|
}
|
|
|
|
/// The session grant and the `assistant.*` commands agree with the gate.
|
|
#[test]
|
|
fn the_session_grant_and_status_commands_work() {
|
|
let (_vault, mut session, registry) = spun_up("queue-grant");
|
|
// Without a grant, a closed gate denies the write.
|
|
session.set_assistant_settings(AssistantSettings { allow_write: false, review_writes: true, ..AssistantSettings::default() });
|
|
assert!(run_as_assistant(&mut session, ®istry, "note.write", &json!({ "path": "alpha.md", "content": "x" })).is_err(), "deny without a grant");
|
|
// The user grants writes for the session.
|
|
registry.run("assistant.grant_write", &mut session, &json!({ "session": true })).expect("grant");
|
|
let status = registry.run("assistant.status", &mut session, &Value::Null).expect("status");
|
|
assert_eq!(status.get("session_write").and_then(Value::as_bool), Some(true));
|
|
let write_id = queue_write(&mut session, ®istry, "with the grant");
|
|
assert_eq!(kind_name(&session, write_id), Some("modify"));
|
|
// The pending list carries the diff for the panel.
|
|
let pending = registry.run("assistant.pending", &mut session, &Value::Null).expect("pending");
|
|
assert_eq!(pending.get("count").and_then(Value::as_u64), Some(1));
|
|
// The same gate the tool call saw still decides for a probe.
|
|
let decision = AssistantGate::check(GateSource::Assistant, &session, "note.write", &json!({ "path": "alpha.md", "content": "probe" }));
|
|
assert!(matches!(decision, Decision::Queue(_)));
|
|
// A grant-off status clears again.
|
|
registry.run("assistant.grant_write", &mut session, &json!({ "session": false })).expect("clear the grant");
|
|
let status = registry.run("assistant.status", &mut session, &Value::Null).expect("status");
|
|
assert_eq!(status.get("session_write").and_then(Value::as_bool), Some(false));
|
|
assert!(run_as_assistant(&mut session, ®istry, "note.write", &json!({ "path": "alpha.md", "content": "x" })).is_err());
|
|
}
|