blob: acc280589f4adba48f0a12ce3b4c50f1bbfbdccb [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_event_log::{
EventLog, StoreError,
projects::{ProjectError, ProjectMutation, ProjectRegistration, ProjectRelinkContext},
};
use serde::{Deserialize, Serialize};
use sqlx::Connection;
fn registration(identity: &str, path: &str, worktree: bool) -> ProjectRegistration {
ProjectRegistration {
identity: identity.into(),
path: path.into(),
name: "Repository".into(),
is_worktree: worktree,
}
}
#[path = "projects/registration.rs"]
mod registration;
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
struct Membership {
project: Option<String>,
cwd: String,
}
fn reassign(member: &mut Membership, context: &ProjectRelinkContext) -> Result<(), StoreError> {
if let Some(id) = &member.project {
if context.previous_ids.contains(id) {
member.project = Some(context.project_id.clone());
member.cwd.clone_from(&context.destination_path);
} else if context.absorbed_ids.contains(id) {
member.project = Some(context.project_id.clone());
}
}
Ok(())
}
#[tokio::test]
async fn relink_merges_aliases_and_sessions_in_one_transaction_without_rewriting_runtime_facts() {
use maka_agent_facts::event::EventWrite;
use maka_agent_facts::event::Fact;
use maka_agent_facts::event::Invocation;
use maka_agent_facts::event::RuntimeEvent;
use maka_agent_facts::input::InvocationInput;
let temp = tempfile::tempdir().unwrap();
let path = temp.path().join("events.sqlite");
let log = EventLog::open(&path).await.unwrap();
let first = log
.register_project(registration("folder:/old", "/old", false), true, 1)
.await
.unwrap();
let second = log
.register_project(registration("git:/new/.git", "/new", false), true, 2)
.await
.unwrap();
log.register_project(registration("git:/new/.git", "/linked", true), false, 3)
.await
.unwrap();
let third = log
.register_project(registration("folder:/third", "/third", false), true, 4)
.await
.unwrap();
for (id, project, cwd) in [
("a", Some(first.id.clone()), "/old"),
("b", Some(second.id.clone()), "/linked"),
("c", Some(third.id.clone()), "/third"),
("unrelated", None, "/old"),
] {
log.create_session(
id,
&format!("original-{id}"),
&Membership {
project,
cwd: cwd.into(),
},
10,
)
.await
.unwrap();
}
let invocation = Invocation {
session_id: "a".into(),
turn_id: "turn".into(),
run_id: "run".into(),
invocation_id: "invocation".into(),
};
let opening = RuntimeEvent::new(
invocation,
Fact::InvocationOpened {
configuration: None,
input: InvocationInput::Message {
source_messages: Vec::new(),
content: "immutable opening".into(),
request_fingerprint: None,
},
},
);
log.append(&EventWrite::plain(opening).unwrap())
.await
.unwrap();
let facts = serde_json::to_value(log.prefix(32, 65536).await.unwrap()).unwrap();
let projects_before = log.list_projects().await.unwrap();
let sessions_before = log
.list_sessions::<Membership>(None, None, 32)
.await
.unwrap();
// A real SQLite failure after Session updates must roll back all participants.
let mut fault = sqlx::SqliteConnection::connect_with(
&sqlx::sqlite::SqliteConnectOptions::new().filename(&path),
)
.await
.unwrap();
sqlx::query(
"CREATE TRIGGER fail_project_relink BEFORE UPDATE OF identity ON projects
BEGIN SELECT RAISE(ABORT, 'project-relink-fault'); END",
)
.execute(&mut fault)
.await
.unwrap();
let failure = log
.relink_project(
&first.id,
registration("git:/new/.git", "/new", false),
30,
reassign,
)
.await
.unwrap_err();
assert!(failure.to_string().contains("project-relink-fault"));
assert_eq!(log.list_projects().await.unwrap(), projects_before);
let unchanged = log
.list_sessions::<Membership>(None, None, 32)
.await
.unwrap();
assert_eq!(unchanged.revision, sessions_before.revision);
assert_eq!(unchanged.sessions, sessions_before.sessions);
assert_eq!(
serde_json::to_value(log.prefix(32, 65536).await.unwrap()).unwrap(),
facts
);
sqlx::query("DROP TRIGGER fail_project_relink")
.execute(&mut fault)
.await
.unwrap();
fault.close().await.unwrap();
let merged = log
.relink_project(
&first.id,
registration("git:/new/.git", "/new", false),
31,
reassign,
)
.await
.unwrap();
assert_eq!(merged.updated_session_ids, ["a", "b"]);
assert_eq!(merged.project.id, first.id);
assert_eq!(merged.project.aliases, vec![second.id.clone()]);
assert_eq!(
merged
.project
.locations
.iter()
.map(|l| l.path.as_str())
.collect::<Vec<_>>(),
["/linked", "/new"]
);
let after = log
.list_sessions::<Membership>(None, None, 32)
.await
.unwrap();
assert_ne!(after.revision, sessions_before.revision);
for record in &after.sessions {
let old = sessions_before
.sessions
.iter()
.find(|old| old.id == record.id)
.unwrap();
assert_eq!(record.updated_at, old.updated_at);
if matches!(record.id.as_str(), "a" | "b") {
assert_eq!(record.revision, old.revision + 1);
assert_eq!(
record.configuration.project.as_deref(),
Some(first.id.as_str())
);
assert_eq!(
record.configuration.cwd,
if record.id == "a" { "/new" } else { "/linked" }
);
} else {
assert_eq!(record, old);
}
assert_eq!(
log.probe_session_create::<Membership>(&record.id, &format!("original-{}", record.id))
.await
.unwrap()
.unwrap(),
*record
);
}
// An absorbed alias can address the survivor during a later merge. All old
// identities continue to resolve; aliases never become independent owners.
let again = log
.relink_project(
&second.id,
registration("folder:/third", "/third", false),
32,
reassign,
)
.await
.unwrap();
assert_eq!(again.updated_session_ids, ["a", "b", "c"]);
assert_eq!(again.project.id, first.id);
assert_eq!(again.project.aliases.len(), 2);
assert_eq!(
serde_json::to_value(log.prefix(32, 65536).await.unwrap()).unwrap(),
facts
);
log.close().await.unwrap();
let reopened = EventLog::open(&path).await.unwrap();
for id in [&first.id, &second.id, &third.id] {
assert_eq!(
reopened.get_project(id).await.unwrap().unwrap(),
again.project
);
}
assert_eq!(reopened.list_projects().await.unwrap().len(), 1);
assert_eq!(
serde_json::to_value(reopened.prefix(32, 65536).await.unwrap()).unwrap(),
facts
);
assert_eq!(
reopened
.get_session::<Membership>("unrelated")
.await
.unwrap()
.unwrap()
.configuration,
Membership {
project: None,
cwd: "/old".into()
}
);
reopened.close().await.unwrap();
}
#[tokio::test]
async fn conditional_project_writes_reject_stale_rows_and_relink_only_the_owned_session_namespace()
{
use maka_event_log::projects::ProjectRelinkGuard;
let temp = tempfile::tempdir().unwrap();
let log = EventLog::open(&temp.path().join("projects.sqlite"))
.await
.unwrap();
let first = log
.register_project_if(
registration("owner:folder:/first", "/first", false),
true,
1,
None,
)
.await
.unwrap();
assert!(matches!(
log.register_project_if(
registration("owner:folder:/first", "/first", false),
true,
2,
None
)
.await,
Err(StoreError::Project(ProjectError::Changed))
));
let renamed = log
.mutate_project_if(first.clone(), ProjectMutation::Rename("Renamed".into()), 2)
.await
.unwrap();
assert!(matches!(
log.mutate_project_if(first.clone(), ProjectMutation::Archive, 3)
.await,
Err(StoreError::Project(ProjectError::Changed))
));
assert!(matches!(
log.select_project_if(first, vec!["/first".into()], 3).await,
Err(StoreError::Project(ProjectError::Changed))
));
let target = log
.register_project_if(
registration("owner:folder:/target", "/target", false),
true,
3,
None,
)
.await
.unwrap();
let other = log
.register_project(
registration("foreign:folder:/hidden", "/hidden", false),
true,
3,
)
.await
.unwrap();
assert_eq!(
log.projects_with_identity_prefix("owner:")
.await
.unwrap()
.len(),
2
);
assert_eq!(
log.projects_with_identity_prefix("foreign:").await.unwrap(),
vec![other]
);
let member = Membership {
project: Some(renamed.id.clone()),
cwd: "/first".into(),
};
log.create_session("own_session", "original", &member, 1)
.await
.unwrap();
let foreign = serde_json::json!({"unrelated":"a different service configuration"});
log.create_session("foreign_session", "foreign", &foreign, 1)
.await
.unwrap();
let stale = ProjectRelinkGuard {
source: renamed.clone(),
destination: Some(target.clone()),
session_prefix: "own_".into(),
};
let target = log
.mutate_project_if(
target,
ProjectMutation::Rename("Destination changed".into()),
4,
)
.await
.unwrap();
assert!(matches!(
log.relink_project_if(
stale,
registration("owner:folder:/target", "/target", false),
5,
reassign
)
.await,
Err(StoreError::Project(ProjectError::Changed))
));
assert_eq!(
log.get_session::<Membership>("own_session")
.await
.unwrap()
.unwrap()
.configuration,
member
);
assert_eq!(
log.get_project(&renamed.id).await.unwrap().unwrap(),
renamed
);
let merged = log
.relink_project_if(
ProjectRelinkGuard {
source: renamed,
destination: Some(target),
session_prefix: "own_".into(),
},
registration("owner:folder:/target", "/target", false),
6,
reassign,
)
.await
.unwrap();
assert_eq!(merged.updated_session_ids, ["own_session"]);
assert_eq!(
log.get_session::<Membership>("own_session")
.await
.unwrap()
.unwrap()
.configuration
.cwd,
"/target"
);
assert_eq!(
log.get_session::<serde_json::Value>("foreign_session")
.await
.unwrap()
.unwrap()
.configuration,
foreign
);
log.close().await.unwrap();
}