1297 lines
43 KiB
Rust
1297 lines
43 KiB
Rust
use petcore::db::{Database, EventRetentionPolicy, InsertEventOutcome};
|
|
use petcore::event_envelope::{NormalizedAgentEvent, MAX_RECENT_EVENTS};
|
|
use petcore_types::{
|
|
AgentEvent, AgentEventType, AgentSource, PetOrigin, PetSummary, QualityLevel, RenderSize,
|
|
};
|
|
use rusqlite::{params, Connection};
|
|
use serde_json::{json, Value};
|
|
use std::fs;
|
|
use std::path::{Path, PathBuf};
|
|
use std::sync::OnceLock;
|
|
use time::{format_description::well_known::Rfc3339, OffsetDateTime};
|
|
|
|
fn received_at() -> &'static str {
|
|
// Ordinary fixtures stay inside the retention window without embedding a
|
|
// calendar deadline. Pruning tests provide their own explicit timestamps.
|
|
static RECEIVED_AT: OnceLock<String> = OnceLock::new();
|
|
RECEIVED_AT
|
|
.get_or_init(|| {
|
|
OffsetDateTime::now_utc()
|
|
.format(&Rfc3339)
|
|
.expect("format current fixture timestamp")
|
|
})
|
|
.as_str()
|
|
}
|
|
const FORBIDDEN_VALUES: &[&str] = &[
|
|
"FORBIDDEN_PROMPT_VALUE_7f16",
|
|
"FORBIDDEN_COMMAND_VALUE_b8c2",
|
|
"FORBIDDEN_API_KEY_VALUE_4a91",
|
|
"FORBIDDEN_TOOL_RESPONSE_VALUE_19df",
|
|
"FORBIDDEN_TRANSCRIPT_PATH_82e1",
|
|
"FORBIDDEN_ARBITRARY_OUTPUT_VALUE_d611",
|
|
];
|
|
|
|
fn strict_event(
|
|
id: &str,
|
|
source: AgentSource,
|
|
session_id: Option<&str>,
|
|
created_at: &str,
|
|
) -> AgentEvent {
|
|
AgentEvent {
|
|
id: id.to_string(),
|
|
source,
|
|
project_path: None,
|
|
session_id: session_id.map(ToOwned::to_owned),
|
|
event_type: AgentEventType::Tool,
|
|
title: "Working".to_string(),
|
|
detail: Some("shell".to_string()),
|
|
payload_json: json!({
|
|
"schema_version": "apc.agent-event.v1",
|
|
"external_event_id": id,
|
|
"source_event": "PostToolUse",
|
|
"tool_name": "shell",
|
|
"outcome": "completed",
|
|
"diagnostic": false
|
|
}),
|
|
created_at: created_at.to_string(),
|
|
}
|
|
}
|
|
|
|
fn external_event(id: &str, session_id: Option<&str>) -> Value {
|
|
json!({
|
|
"id": id,
|
|
"session_id": session_id,
|
|
"event_type": "tool",
|
|
"title": "Working",
|
|
"detail": "shell",
|
|
"payload": {
|
|
"source_event": "PostToolUse",
|
|
"tool_name": "shell",
|
|
"outcome": "completed",
|
|
"diagnostic": false
|
|
}
|
|
})
|
|
}
|
|
|
|
#[test]
|
|
fn external_display_and_metadata_aliases_never_reach_visible_records() {
|
|
let event = NormalizedAgentEvent::from_external(
|
|
AgentSource::Codex,
|
|
json!({
|
|
"id": "alias-sentinels",
|
|
"session_id": "session-security",
|
|
"event_type": "tool",
|
|
"title": "RAW_TITLE_ALIAS_SENTINEL",
|
|
"detail": "RAW_DETAIL_ALIAS_SENTINEL",
|
|
"payload": {
|
|
"source_event": "RAW_SOURCE_EVENT_ALIAS_SENTINEL",
|
|
"tool_name": "RAW_TOOL_NAME_ALIAS_SENTINEL",
|
|
"outcome": "RAW_OUTCOME_ALIAS_SENTINEL",
|
|
"diagnostic": false
|
|
}
|
|
}),
|
|
received_at(),
|
|
)
|
|
.unwrap();
|
|
|
|
assert_eq!(event.title, AgentEventType::Tool.zh_label());
|
|
assert_eq!(event.detail, None);
|
|
assert_eq!(event.payload_json["source_event"], "unclassified");
|
|
assert_eq!(event.payload_json["tool_name"], "other");
|
|
assert_eq!(event.payload_json["outcome"], "unknown");
|
|
|
|
let visible_record = serde_json::to_string(&event).unwrap();
|
|
for sentinel in [
|
|
"RAW_TITLE_ALIAS_SENTINEL",
|
|
"RAW_DETAIL_ALIAS_SENTINEL",
|
|
"RAW_SOURCE_EVENT_ALIAS_SENTINEL",
|
|
"RAW_TOOL_NAME_ALIAS_SENTINEL",
|
|
"RAW_OUTCOME_ALIAS_SENTINEL",
|
|
] {
|
|
assert!(!visible_record.contains(sentinel), "leaked {sentinel}");
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn pi_input_is_a_first_class_allowlisted_lifecycle_event() {
|
|
let event = NormalizedAgentEvent::from_external(
|
|
AgentSource::Pi,
|
|
json!({
|
|
"id": "pi-input-allowlisted",
|
|
"session_id": "pi-input-session",
|
|
"event_type": "start",
|
|
"payload": {
|
|
"source_event": "input",
|
|
"session_active": true,
|
|
"message_role": "user",
|
|
"message_content": "下一条用户消息",
|
|
"diagnostic": false
|
|
}
|
|
}),
|
|
received_at(),
|
|
)
|
|
.unwrap();
|
|
|
|
assert_eq!(event.payload_json["source_event"], "input");
|
|
assert_eq!(event.payload_json["message_role"], "user");
|
|
assert_eq!(event.payload_json["message_content"], "下一条用户消息");
|
|
}
|
|
|
|
#[test]
|
|
fn activity_ingress_never_recurses_through_credential_containers() {
|
|
let sources = [
|
|
(AgentSource::Codex, "PostToolUse"),
|
|
(AgentSource::ClaudeCode, "PostToolUse"),
|
|
(AgentSource::Pi, "tool_execution_end"),
|
|
(AgentSource::Opencode, "tool.execute.after"),
|
|
];
|
|
for (source_index, (source, source_event)) in sources.into_iter().enumerate() {
|
|
for (container_index, container) in [
|
|
json!({"headers": {"content": "secret header"}}),
|
|
json!({"environment": {"message": "secret environment"}}),
|
|
json!({"tokens": {"output": "secret token"}}),
|
|
]
|
|
.into_iter()
|
|
.enumerate()
|
|
{
|
|
let event = NormalizedAgentEvent::from_external(
|
|
source,
|
|
json!({
|
|
"id": format!("sensitive-activity-{source_index}-{container_index}"),
|
|
"session_id": format!("sensitive-session-{source_index}"),
|
|
"event_type": "tool",
|
|
"payload": {
|
|
"source_event": source_event,
|
|
"session_active": true,
|
|
"activity_kind": "thinking",
|
|
"activity_content": serde_json::to_string(&container).unwrap(),
|
|
"diagnostic": false
|
|
}
|
|
}),
|
|
received_at(),
|
|
)
|
|
.unwrap();
|
|
assert!(event.payload_json["activity_content"].is_null());
|
|
}
|
|
}
|
|
|
|
let readable = NormalizedAgentEvent::from_external(
|
|
AgentSource::Codex,
|
|
json!({
|
|
"id": "readable-structured-activity",
|
|
"session_id": "readable-structured-session",
|
|
"event_type": "tool",
|
|
"payload": {
|
|
"source_event": "PostToolUse",
|
|
"session_active": true,
|
|
"activity_kind": "thinking",
|
|
"activity_content": "{\"file_path\":\"Sources/App.swift\"}",
|
|
"diagnostic": false
|
|
}
|
|
}),
|
|
received_at(),
|
|
)
|
|
.unwrap();
|
|
assert_eq!(
|
|
readable.payload_json["activity_content"],
|
|
"Sources/App.swift"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn session_display_fields_enforce_exact_utf8_byte_boundaries() {
|
|
let session_title = format!("{}a", "界".repeat(53));
|
|
let message_content = format!("{}a", "界".repeat(1365));
|
|
assert_eq!(session_title.len(), 160);
|
|
assert_eq!(message_content.len(), 4096);
|
|
|
|
let accepted = NormalizedAgentEvent::from_external(
|
|
AgentSource::Codex,
|
|
json!({
|
|
"id": "display-boundary-accepted",
|
|
"session_id": "display-boundary-session",
|
|
"event_type": "start",
|
|
"payload": {
|
|
"source_event": "UserPromptSubmit",
|
|
"session_active": true,
|
|
"message_role": "user",
|
|
"message_content": message_content,
|
|
"session_title": session_title,
|
|
"diagnostic": false
|
|
}
|
|
}),
|
|
received_at(),
|
|
)
|
|
.unwrap();
|
|
assert_eq!(
|
|
accepted.payload_json["session_title"]
|
|
.as_str()
|
|
.unwrap()
|
|
.len(),
|
|
160
|
|
);
|
|
assert_eq!(
|
|
accepted.payload_json["message_content"]
|
|
.as_str()
|
|
.unwrap()
|
|
.len(),
|
|
4096
|
|
);
|
|
|
|
for (index, key, maximum, oversized) in [
|
|
(0, "session_title", 160, format!("{}ab", "界".repeat(53))),
|
|
(
|
|
1,
|
|
"message_content",
|
|
4096,
|
|
format!("{}ab", "界".repeat(1365)),
|
|
),
|
|
] {
|
|
assert_eq!(oversized.len(), maximum + 1);
|
|
let mut payload = json!({
|
|
"source_event": "UserPromptSubmit",
|
|
"session_active": true,
|
|
"diagnostic": false
|
|
});
|
|
payload[key] = Value::String(oversized);
|
|
let error = NormalizedAgentEvent::from_external(
|
|
AgentSource::Codex,
|
|
json!({
|
|
"id": format!("display-boundary-rejected-{index}"),
|
|
"session_id": "display-boundary-session",
|
|
"event_type": "start",
|
|
"payload": payload
|
|
}),
|
|
received_at(),
|
|
)
|
|
.unwrap_err();
|
|
let error = error.to_string();
|
|
assert!(
|
|
error.contains(&format!(
|
|
"agent event payload {key} exceeds {maximum} UTF-8 bytes"
|
|
)),
|
|
"{error}"
|
|
);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn opencode_v2_activity_events_are_first_class_allowlisted_lifecycle_names() {
|
|
for (index, (source_event, event_type)) in [
|
|
("permission.v2.asked", "waiting"),
|
|
("permission.v2.replied", "start"),
|
|
("question.v2.asked", "waiting"),
|
|
("question.v2.replied", "start"),
|
|
("question.v2.rejected", "start"),
|
|
("session.next.prompt.admitted", "start"),
|
|
("session.next.reasoning.ended", "thinking"),
|
|
("session.plan.updated", "plan"),
|
|
]
|
|
.into_iter()
|
|
.enumerate()
|
|
{
|
|
let event = NormalizedAgentEvent::from_external(
|
|
AgentSource::Opencode,
|
|
json!({
|
|
"id": format!("opencode-v2-source-event-{index}"),
|
|
"session_id": "opencode-v2-source-event-session",
|
|
"event_type": event_type,
|
|
"payload": {
|
|
"source_event": source_event,
|
|
"session_active": true,
|
|
"diagnostic": false
|
|
}
|
|
}),
|
|
received_at(),
|
|
)
|
|
.unwrap();
|
|
|
|
assert_eq!(event.payload_json["source_event"], source_event);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn opencode_prompt_admitted_outcome_survives_the_envelope_vocabularies() {
|
|
let event = NormalizedAgentEvent::from_external(
|
|
AgentSource::Opencode,
|
|
json!({
|
|
"id": "opencode-prompt-admitted-outcome",
|
|
"session_id": "opencode-prompt-admitted-session",
|
|
"event_type": "start",
|
|
"payload": {
|
|
"source_event": "session.next.prompt.admitted",
|
|
"outcome": "prompt_admitted",
|
|
"session_active": true,
|
|
"diagnostic": false
|
|
}
|
|
}),
|
|
received_at(),
|
|
)
|
|
.unwrap();
|
|
|
|
assert_eq!(event.payload_json["outcome"], "prompt_admitted");
|
|
}
|
|
|
|
#[test]
|
|
fn payload_json_alias_is_normalized_through_the_same_closed_vocabularies() {
|
|
let event = NormalizedAgentEvent::from_external(
|
|
AgentSource::ClaudeCode,
|
|
json!({
|
|
"id": "payload-json-alias",
|
|
"source": "claude_code",
|
|
"event_type": "failed",
|
|
"title": "RAW_TITLE_ALIAS_SENTINEL",
|
|
"detail": "RAW_DETAIL_ALIAS_SENTINEL",
|
|
"payload_json": {
|
|
"schema_version": "apc.agent-event.v1",
|
|
"external_event_id": "payload-json-alias",
|
|
"source_event": "StopFailure",
|
|
"tool_name": "Bash",
|
|
"outcome": "api_failure",
|
|
"diagnostic": false
|
|
},
|
|
"created_at": received_at()
|
|
}),
|
|
received_at(),
|
|
)
|
|
.unwrap();
|
|
|
|
assert_eq!(event.title, AgentEventType::Failed.zh_label());
|
|
assert_eq!(event.detail, None);
|
|
assert_eq!(event.payload_json["source_event"], "StopFailure");
|
|
assert_eq!(event.payload_json["tool_name"], "shell");
|
|
assert_eq!(event.payload_json["outcome"], "api_failure");
|
|
let record = serde_json::to_string(&event).unwrap();
|
|
assert!(!record.contains("RAW_TITLE_ALIAS_SENTINEL"));
|
|
assert!(!record.contains("RAW_DETAIL_ALIAS_SENTINEL"));
|
|
}
|
|
|
|
#[test]
|
|
fn diagnostic_events_always_normalize_to_non_activity_metadata() {
|
|
for supplied in [None, Some(true), Some(false)] {
|
|
let mut payload = json!({
|
|
"source_event": "connection.test",
|
|
"diagnostic": true
|
|
});
|
|
if let Some(value) = supplied {
|
|
payload["affects_activity"] = json!(value);
|
|
}
|
|
let event = NormalizedAgentEvent::from_external(
|
|
AgentSource::Codex,
|
|
json!({
|
|
"id": format!("diagnostic-{supplied:?}"),
|
|
"event_type": "tool",
|
|
"payload": payload
|
|
}),
|
|
received_at(),
|
|
)
|
|
.unwrap();
|
|
|
|
assert_eq!(event.payload_json["diagnostic"], true);
|
|
assert_eq!(event.payload_json["affects_activity"], false);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn session_navigation_accepts_only_allowlisted_warp_focus_urls() {
|
|
let event = NormalizedAgentEvent::from_external(
|
|
AgentSource::Codex,
|
|
json!({
|
|
"id": "warp-session-target",
|
|
"event_type": "start",
|
|
"payload": {
|
|
"source_event": "UserPromptSubmit",
|
|
"session_active": true,
|
|
"session_open": true,
|
|
"session_surface": "cli_terminal",
|
|
"terminal_app": "warp",
|
|
"session_open_url": "warppreview://session/A1B2C3D4E5F6A1B2C3D4E5F6A1B2C3D4"
|
|
}
|
|
}),
|
|
received_at(),
|
|
)
|
|
.unwrap();
|
|
assert_eq!(event.payload_json["session_open"], true);
|
|
assert_eq!(event.payload_json["terminal_app"], "warp");
|
|
assert_eq!(
|
|
event.payload_json["session_open_url"],
|
|
"warppreview://session/A1B2C3D4E5F6A1B2C3D4E5F6A1B2C3D4"
|
|
);
|
|
|
|
for (index, invalid_url) in [
|
|
"https://example.com/session/not-allowed",
|
|
" warppreview://session/A1B2C3D4E5F6A1B2C3D4E5F6A1B2C3D4",
|
|
"warppreview://session/A1B2C3D4E5F6A1B2C3D4E5F6A1B2C3D4 ",
|
|
]
|
|
.into_iter()
|
|
.enumerate()
|
|
{
|
|
let rejected = NormalizedAgentEvent::from_external(
|
|
AgentSource::Codex,
|
|
json!({
|
|
"id": format!("unsafe-session-target-{index}"),
|
|
"event_type": "start",
|
|
"payload": {
|
|
"source_event": "UserPromptSubmit",
|
|
"session_open_url": invalid_url
|
|
}
|
|
}),
|
|
received_at(),
|
|
)
|
|
.unwrap_err();
|
|
assert!(
|
|
rejected.to_string().contains("not a supported session URL"),
|
|
"{rejected}"
|
|
);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn strict_ingest_rejects_unknown_top_level_and_envelope_fields() {
|
|
let top_level = NormalizedAgentEvent::from_external(
|
|
AgentSource::Codex,
|
|
json!({
|
|
"id": "unknown-top-level",
|
|
"event_type": "start",
|
|
"raw_prompt": "must not be ignored"
|
|
}),
|
|
received_at(),
|
|
)
|
|
.unwrap_err();
|
|
assert!(top_level.to_string().contains("field is not supported"));
|
|
|
|
let nested = NormalizedAgentEvent::from_external(
|
|
AgentSource::Codex,
|
|
json!({
|
|
"id": "unknown-envelope-field",
|
|
"event_type": "tool",
|
|
"payload": {
|
|
"source_event": "PreToolUse",
|
|
"tool_name": "Bash",
|
|
"outcome": "started",
|
|
"raw_command": "must not be ignored"
|
|
}
|
|
}),
|
|
received_at(),
|
|
)
|
|
.unwrap_err();
|
|
assert!(nested
|
|
.to_string()
|
|
.contains("payload field is not supported"));
|
|
}
|
|
|
|
#[test]
|
|
fn strict_ingest_rejects_ambiguous_payload_aliases_and_source_mismatch() {
|
|
let ambiguous = NormalizedAgentEvent::from_external(
|
|
AgentSource::Pi,
|
|
json!({
|
|
"event_type": "tool",
|
|
"payload": {},
|
|
"payload_json": {}
|
|
}),
|
|
received_at(),
|
|
)
|
|
.unwrap_err();
|
|
assert!(ambiguous.to_string().contains("only one of payload"));
|
|
|
|
let mismatch = NormalizedAgentEvent::from_external(
|
|
AgentSource::Pi,
|
|
json!({
|
|
"source": "codex",
|
|
"event_type": "start"
|
|
}),
|
|
received_at(),
|
|
)
|
|
.unwrap_err();
|
|
assert!(mismatch.to_string().contains("source does not match"));
|
|
}
|
|
|
|
fn security_fixture() -> Value {
|
|
let path = Path::new(env!("CARGO_MANIFEST_DIR"))
|
|
.join("../../fixtures/security/sensitive-agent-event.json");
|
|
serde_json::from_slice(&fs::read(path).unwrap()).unwrap()
|
|
}
|
|
|
|
#[test]
|
|
fn raw_hook_payload_cannot_cross_the_strict_ingest_boundary() {
|
|
let encoded = serde_json::to_string(&security_fixture()).unwrap();
|
|
let error =
|
|
NormalizedAgentEvent::from_external(AgentSource::Codex, security_fixture(), received_at())
|
|
.unwrap_err();
|
|
|
|
assert!(error.to_string().contains("payload field is not supported"));
|
|
for forbidden in FORBIDDEN_VALUES {
|
|
assert!(encoded.contains(forbidden));
|
|
assert!(!error.to_string().contains(forbidden));
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn external_title_and_detail_are_discarded_even_when_multibyte() {
|
|
let mut value = external_event("utf8-bounds", Some("session"));
|
|
value["title"] = Value::String("宠".repeat(100));
|
|
value["detail"] = Value::String("🙂".repeat(200));
|
|
|
|
let event = NormalizedAgentEvent::from_external(AgentSource::Pi, value, received_at()).unwrap();
|
|
|
|
assert_eq!(event.title, AgentEventType::Tool.zh_label());
|
|
assert_eq!(event.detail, None);
|
|
}
|
|
|
|
#[test]
|
|
fn missing_external_id_is_stable_for_the_same_canonical_input() {
|
|
let mut value = external_event("ignored", Some("session"));
|
|
value.as_object_mut().unwrap().remove("id");
|
|
|
|
let first =
|
|
NormalizedAgentEvent::from_external(AgentSource::ClaudeCode, value.clone(), received_at())
|
|
.unwrap();
|
|
let second =
|
|
NormalizedAgentEvent::from_external(AgentSource::ClaudeCode, value, received_at()).unwrap();
|
|
|
|
assert_eq!(first.id, second.id);
|
|
assert!(first.id.starts_with("evt_external_"));
|
|
}
|
|
|
|
#[test]
|
|
fn same_external_id_from_two_sources_is_not_deduplicated() {
|
|
let temp = tempfile::tempdir().unwrap();
|
|
let database = Database::new(temp.path().join("events.sqlite"));
|
|
database.init().unwrap();
|
|
let value = external_event("shared-id", Some("session"));
|
|
let codex =
|
|
NormalizedAgentEvent::from_external(AgentSource::Codex, value.clone(), received_at())
|
|
.unwrap();
|
|
let pi = NormalizedAgentEvent::from_external(AgentSource::Pi, value, received_at()).unwrap();
|
|
|
|
assert_eq!(
|
|
database.insert_event(&codex).unwrap(),
|
|
InsertEventOutcome::Inserted
|
|
);
|
|
assert_eq!(
|
|
database.insert_event(&pi).unwrap(),
|
|
InsertEventOutcome::Inserted
|
|
);
|
|
assert_eq!(database.recent_events(10).unwrap().len(), 2);
|
|
}
|
|
|
|
#[test]
|
|
fn same_id_in_two_sessions_is_not_deduplicated() {
|
|
let temp = tempfile::tempdir().unwrap();
|
|
let database = Database::new(temp.path().join("events.sqlite"));
|
|
database.init().unwrap();
|
|
let first = NormalizedAgentEvent::from_external(
|
|
AgentSource::Codex,
|
|
external_event("shared-id", Some("session-a")),
|
|
received_at(),
|
|
)
|
|
.unwrap();
|
|
let second = NormalizedAgentEvent::from_external(
|
|
AgentSource::Codex,
|
|
external_event("shared-id", Some("session-b")),
|
|
received_at(),
|
|
)
|
|
.unwrap();
|
|
|
|
assert_eq!(
|
|
database.insert_event(&first).unwrap(),
|
|
InsertEventOutcome::Inserted
|
|
);
|
|
assert_eq!(
|
|
database.insert_event(&second).unwrap(),
|
|
InsertEventOutcome::Inserted
|
|
);
|
|
assert_eq!(database.recent_events(10).unwrap().len(), 2);
|
|
}
|
|
|
|
#[test]
|
|
fn null_and_empty_sessions_share_a_stable_deduplication_namespace() {
|
|
let temp = tempfile::tempdir().unwrap();
|
|
let database = Database::new(temp.path().join("events.sqlite"));
|
|
database.init().unwrap();
|
|
let missing = NormalizedAgentEvent::from_external(
|
|
AgentSource::Opencode,
|
|
external_event("same-no-session", None),
|
|
received_at(),
|
|
)
|
|
.unwrap();
|
|
let empty = NormalizedAgentEvent::from_external(
|
|
AgentSource::Opencode,
|
|
external_event("same-no-session", Some(" ")),
|
|
received_at(),
|
|
)
|
|
.unwrap();
|
|
|
|
assert_eq!(
|
|
database.insert_event(&missing).unwrap(),
|
|
InsertEventOutcome::Inserted
|
|
);
|
|
assert_eq!(
|
|
database.insert_event(&empty).unwrap(),
|
|
InsertEventOutcome::Duplicate
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn internal_codex_suggestion_session_is_deleted_and_future_events_are_suppressed() {
|
|
let temp = tempfile::tempdir().unwrap();
|
|
let database = Database::new(temp.path().join("events.sqlite"));
|
|
database.init().unwrap();
|
|
let session_id = "019f6ed7-de50-7623-8462-6a857e367a96";
|
|
|
|
let session_start = AgentEvent {
|
|
id: "internal-session-start".to_string(),
|
|
source: AgentSource::Codex,
|
|
project_path: None,
|
|
session_id: Some(session_id.to_string()),
|
|
event_type: AgentEventType::Start,
|
|
title: AgentEventType::Start.zh_label().to_string(),
|
|
detail: None,
|
|
payload_json: json!({
|
|
"source_event": "SessionStart",
|
|
"session_active": false,
|
|
"diagnostic": false
|
|
}),
|
|
created_at: received_at().to_string(),
|
|
};
|
|
assert_eq!(
|
|
database.insert_event(&session_start).unwrap(),
|
|
InsertEventOutcome::Inserted
|
|
);
|
|
|
|
let internal_prompt = AgentEvent {
|
|
id: "internal-user-prompt".to_string(),
|
|
source: AgentSource::Codex,
|
|
project_path: None,
|
|
session_id: Some(session_id.to_string()),
|
|
event_type: AgentEventType::Start,
|
|
title: AgentEventType::Start.zh_label().to_string(),
|
|
detail: None,
|
|
payload_json: json!({
|
|
"source_event": "UserPromptSubmit",
|
|
"session_active": true,
|
|
"message_role": "user",
|
|
"message_content": "# Overview\n\nGenerate 0 to 3 hyperpersonalized suggestions for what this user can do with Codex in this Projectless task\n\nGet an understanding of the user's intent",
|
|
"diagnostic": false
|
|
}),
|
|
created_at: received_at().to_string(),
|
|
};
|
|
assert_eq!(
|
|
database.insert_event(&internal_prompt).unwrap(),
|
|
InsertEventOutcome::Suppressed
|
|
);
|
|
assert!(database.recent_events(10).unwrap().is_empty());
|
|
|
|
let later_tool = strict_event(
|
|
"internal-later-tool",
|
|
AgentSource::Codex,
|
|
Some(session_id),
|
|
received_at(),
|
|
);
|
|
assert_eq!(
|
|
database.insert_event(&later_tool).unwrap(),
|
|
InsertEventOutcome::Suppressed
|
|
);
|
|
let marker_count: i64 = Connection::open(database.path())
|
|
.unwrap()
|
|
.query_row(
|
|
"SELECT COUNT(*) FROM suppressed_agent_sessions WHERE source = 'codex' AND session_key = ?1",
|
|
params![format!("1:{session_id}")],
|
|
|row| row.get(0),
|
|
)
|
|
.unwrap();
|
|
assert_eq!(marker_count, 1);
|
|
}
|
|
|
|
#[test]
|
|
fn child_session_marker_deletes_existing_projection_and_blocks_future_events() {
|
|
let temp = tempfile::tempdir().unwrap();
|
|
let database = Database::new(temp.path().join("events.sqlite"));
|
|
database.init().unwrap();
|
|
let session_id = "opencode-child-session";
|
|
|
|
let ordinary = strict_event(
|
|
"child-before-lineage",
|
|
AgentSource::Opencode,
|
|
Some(session_id),
|
|
received_at(),
|
|
);
|
|
assert_eq!(
|
|
database.insert_event(&ordinary).unwrap(),
|
|
InsertEventOutcome::Inserted
|
|
);
|
|
|
|
let marker = AgentEvent {
|
|
id: "child-lineage-marker".to_string(),
|
|
source: AgentSource::Opencode,
|
|
project_path: None,
|
|
session_id: Some(session_id.to_string()),
|
|
event_type: AgentEventType::Start,
|
|
title: AgentEventType::Start.zh_label().to_string(),
|
|
detail: None,
|
|
payload_json: json!({
|
|
"source_event": "session.child",
|
|
"session_active": false,
|
|
"diagnostic": false
|
|
}),
|
|
created_at: received_at().to_string(),
|
|
};
|
|
assert_eq!(
|
|
database.insert_event(&marker).unwrap(),
|
|
InsertEventOutcome::Suppressed
|
|
);
|
|
assert!(database.recent_events(10).unwrap().is_empty());
|
|
|
|
let later = strict_event(
|
|
"child-after-lineage",
|
|
AgentSource::Opencode,
|
|
Some(session_id),
|
|
received_at(),
|
|
);
|
|
assert_eq!(
|
|
database.insert_event(&later).unwrap(),
|
|
InsertEventOutcome::Suppressed
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn title_only_app_server_activity_removes_an_existing_projected_session() {
|
|
let temp = tempfile::tempdir().unwrap();
|
|
let database = Database::new(temp.path().join("events.sqlite"));
|
|
database.init().unwrap();
|
|
let session_id = "019f6ed7-de50-7623-8462-6a857e367a96";
|
|
|
|
let ordinary_activity = AgentEvent {
|
|
id: "app-server-title-only-status".to_string(),
|
|
source: AgentSource::Codex,
|
|
project_path: None,
|
|
session_id: Some(session_id.to_string()),
|
|
event_type: AgentEventType::Done,
|
|
title: AgentEventType::Done.zh_label().to_string(),
|
|
detail: None,
|
|
payload_json: json!({
|
|
"source_event": "app_server_activity",
|
|
"session_active": false,
|
|
"session_title": "Visible user task",
|
|
"message_role": "assistant",
|
|
"message_content": "Done",
|
|
"diagnostic": false
|
|
}),
|
|
created_at: received_at().to_string(),
|
|
};
|
|
assert!(database
|
|
.upsert_codex_activity_event(&ordinary_activity)
|
|
.unwrap());
|
|
assert_eq!(database.recent_events(10).unwrap().len(), 1);
|
|
|
|
let internal_activity = AgentEvent {
|
|
payload_json: json!({
|
|
"source_event": "app_server_activity",
|
|
"session_active": false,
|
|
"session_title": "# Overview Generate 0 to 3 hyperpersonalized suggestions for what this user can do with Codex in this local project: /tmp/project",
|
|
"message_role": "assistant",
|
|
"message_content": "{\"suggestions\":[]}",
|
|
"diagnostic": false
|
|
}),
|
|
..ordinary_activity
|
|
};
|
|
assert!(!database
|
|
.upsert_codex_activity_event(&internal_activity)
|
|
.unwrap());
|
|
assert!(database.recent_events(10).unwrap().is_empty());
|
|
|
|
let marker_count: i64 = Connection::open(database.path())
|
|
.unwrap()
|
|
.query_row(
|
|
"SELECT COUNT(*) FROM suppressed_agent_sessions WHERE source = 'codex' AND session_key = ?1",
|
|
params![format!("1:{session_id}")],
|
|
|row| row.get(0),
|
|
)
|
|
.unwrap();
|
|
assert_eq!(marker_count, 1);
|
|
}
|
|
|
|
#[test]
|
|
fn schema_upgrade_scrubs_historical_internal_codex_suggestion_sessions() {
|
|
let temp = tempfile::tempdir().unwrap();
|
|
let database = Database::new(temp.path().join("events.sqlite"));
|
|
database.init().unwrap();
|
|
let session_id = "019f6ed2-390b-7fd2-b6a7-d9ffaa5acc23";
|
|
{
|
|
let connection = Connection::open(database.path()).unwrap();
|
|
connection
|
|
.execute(
|
|
r#"
|
|
INSERT INTO agent_events
|
|
(external_event_id, source, project_path, session_id, session_key,
|
|
event_type, title, detail, payload_json, created_at)
|
|
VALUES (?1, 'codex', NULL, ?2, ?2, 'start', '开始处理', NULL, ?3, ?4)
|
|
"#,
|
|
params![
|
|
"historical-internal-prompt",
|
|
session_id,
|
|
serde_json::to_string(&json!({
|
|
"source_event": "app_server_activity",
|
|
"session_active": false,
|
|
"session_title": "# Overview Generate 0 to 3 hyperpersonalized suggestions for what this user can do with Codex in this Projectless task",
|
|
"message_role": "assistant",
|
|
"message_content": "{\"suggestions\":[]}",
|
|
"diagnostic": false
|
|
}))
|
|
.unwrap(),
|
|
received_at()
|
|
],
|
|
)
|
|
.unwrap();
|
|
connection.pragma_update(None, "user_version", 4).unwrap();
|
|
}
|
|
|
|
database.init().unwrap();
|
|
|
|
assert!(database.recent_events(10).unwrap().is_empty());
|
|
let connection = Connection::open(database.path()).unwrap();
|
|
let marker_count: i64 = connection
|
|
.query_row(
|
|
"SELECT COUNT(*) FROM suppressed_agent_sessions WHERE source = 'codex' AND session_key = ?1",
|
|
params![session_id],
|
|
|row| row.get(0),
|
|
)
|
|
.unwrap();
|
|
assert_eq!(marker_count, 1);
|
|
let schema_version: u32 = connection
|
|
.query_row("PRAGMA user_version", [], |row| row.get(0))
|
|
.unwrap();
|
|
assert_eq!(schema_version, petcore::db::DATABASE_SCHEMA_VERSION);
|
|
}
|
|
|
|
#[test]
|
|
fn recent_events_clamps_limit() {
|
|
let temp = tempfile::tempdir().unwrap();
|
|
let database = Database::new(temp.path().join("events.sqlite"));
|
|
database.init().unwrap();
|
|
for index in 0..(MAX_RECENT_EVENTS + 5) {
|
|
let event = strict_event(
|
|
&format!("event-{index:03}"),
|
|
AgentSource::Codex,
|
|
Some("session"),
|
|
received_at(),
|
|
);
|
|
assert_eq!(
|
|
database.insert_event(&event).unwrap(),
|
|
InsertEventOutcome::Inserted
|
|
);
|
|
}
|
|
|
|
assert!(database.recent_events(0).unwrap().is_empty());
|
|
assert_eq!(
|
|
database.recent_events(usize::MAX).unwrap().len(),
|
|
MAX_RECENT_EVENTS
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn event_retention_prunes_oldest_rows() {
|
|
let temp = tempfile::tempdir().unwrap();
|
|
let database = Database::new(temp.path().join("events.sqlite"));
|
|
database.init().unwrap();
|
|
for (id, created_at) in [
|
|
("oldest", "2099-07-01T00:00:00Z"),
|
|
("middle", "2099-07-02T00:00:00Z"),
|
|
("newest", "2099-07-03T00:00:00Z"),
|
|
] {
|
|
database
|
|
.insert_event(&strict_event(
|
|
id,
|
|
AgentSource::ClaudeCode,
|
|
Some("session"),
|
|
created_at,
|
|
))
|
|
.unwrap();
|
|
}
|
|
let defaults = EventRetentionPolicy::default();
|
|
assert_eq!(defaults.max_rows, 10_000);
|
|
assert_eq!(defaults.max_age_days, 30);
|
|
|
|
let pruned = database
|
|
.prune_events(EventRetentionPolicy {
|
|
max_rows: 2,
|
|
max_age_days: 36_500,
|
|
})
|
|
.unwrap();
|
|
|
|
assert_eq!(pruned, 1);
|
|
let recent = database.recent_events(10).unwrap();
|
|
assert_eq!(recent.len(), 2);
|
|
assert!(recent.iter().all(|event| event.id != "oldest"));
|
|
let summarized: i64 = Connection::open(database.path())
|
|
.unwrap()
|
|
.query_row(
|
|
r#"
|
|
SELECT event_count
|
|
FROM agent_event_daily_counts
|
|
WHERE event_day = '2099-07-01'
|
|
AND source = 'claude_code'
|
|
AND event_type = 'tool'
|
|
"#,
|
|
[],
|
|
|row| row.get(0),
|
|
)
|
|
.unwrap();
|
|
assert_eq!(summarized, 1);
|
|
}
|
|
|
|
#[test]
|
|
fn session_alias_authority_survives_restart_migration_and_tracks_event_retention() {
|
|
let temp = tempfile::tempdir().unwrap();
|
|
let path = temp.path().join("events.sqlite");
|
|
let database = Database::new(path.clone());
|
|
database.init().unwrap();
|
|
for (id, session_id, created_at) in [
|
|
("alias-a-first", "raw-session-a", "2026-07-20T00:00:00Z"),
|
|
("alias-b-first", "raw-session-b", "2026-07-20T00:00:01Z"),
|
|
] {
|
|
database
|
|
.insert_event(&strict_event(
|
|
id,
|
|
AgentSource::ClaudeCode,
|
|
Some(session_id),
|
|
created_at,
|
|
))
|
|
.unwrap();
|
|
}
|
|
|
|
let read_aliases = || {
|
|
let connection = Connection::open(&path).unwrap();
|
|
let mut statement = connection
|
|
.prepare(
|
|
r#"
|
|
SELECT session_key, alias_sequence
|
|
FROM agent_session_aliases
|
|
WHERE source = 'claude_code'
|
|
ORDER BY alias_sequence
|
|
"#,
|
|
)
|
|
.unwrap();
|
|
statement
|
|
.query_map([], |row| {
|
|
Ok((row.get::<_, String>(0)?, row.get::<_, i64>(1)?))
|
|
})
|
|
.unwrap()
|
|
.collect::<std::result::Result<Vec<_>, _>>()
|
|
.unwrap()
|
|
};
|
|
let initial = read_aliases();
|
|
assert_eq!(
|
|
initial,
|
|
vec![
|
|
("1:raw-session-a".to_string(), 1),
|
|
("1:raw-session-b".to_string(), 2)
|
|
]
|
|
);
|
|
|
|
// A normal daemon restart preserves the assigned authority exactly.
|
|
Database::new(path.clone()).init().unwrap();
|
|
assert_eq!(read_aliases(), initial);
|
|
|
|
// Simulate a schema-5 database that already has retained events. Schema 6
|
|
// deterministically backfills by first-seen event order.
|
|
Connection::open(&path)
|
|
.unwrap()
|
|
.execute_batch(
|
|
r#"
|
|
DROP TABLE agent_session_aliases;
|
|
PRAGMA user_version = 5;
|
|
"#,
|
|
)
|
|
.unwrap();
|
|
Database::new(path.clone()).init().unwrap();
|
|
assert_eq!(read_aliases(), initial);
|
|
|
|
// New activity changes display recency but never reallocates the alias.
|
|
database
|
|
.insert_event(&strict_event(
|
|
"alias-a-newest",
|
|
AgentSource::ClaudeCode,
|
|
Some("raw-session-a"),
|
|
"2026-07-20T00:00:02Z",
|
|
))
|
|
.unwrap();
|
|
assert_eq!(read_aliases(), initial);
|
|
|
|
database
|
|
.prune_events(EventRetentionPolicy {
|
|
max_rows: 1,
|
|
max_age_days: 36_500,
|
|
})
|
|
.unwrap();
|
|
assert_eq!(read_aliases(), vec![("1:raw-session-a".to_string(), 1)]);
|
|
database
|
|
.insert_event(&strict_event(
|
|
"alias-c-after-retention",
|
|
AgentSource::ClaudeCode,
|
|
Some("raw-session-c"),
|
|
"2026-07-20T00:00:03Z",
|
|
))
|
|
.unwrap();
|
|
assert_eq!(
|
|
read_aliases(),
|
|
vec![
|
|
("1:raw-session-a".to_string(), 1),
|
|
("1:raw-session-c".to_string(), 3)
|
|
],
|
|
"pruned alias sequences must never be reused for a new session"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn legacy_payload_migration_removes_plaintext_and_rebuilds_rows() {
|
|
let temp = tempfile::tempdir().unwrap();
|
|
let path = temp.path().join("legacy.sqlite");
|
|
let connection = Connection::open(&path).unwrap();
|
|
connection
|
|
.execute_batch(
|
|
r#"
|
|
PRAGMA journal_mode = WAL;
|
|
PRAGMA user_version = 0;
|
|
CREATE TABLE agent_events (
|
|
id TEXT PRIMARY KEY,
|
|
source TEXT NOT NULL,
|
|
project_path TEXT,
|
|
session_id TEXT,
|
|
event_type TEXT NOT NULL,
|
|
title TEXT,
|
|
detail TEXT,
|
|
payload_json TEXT NOT NULL,
|
|
created_at TEXT NOT NULL
|
|
);
|
|
"#,
|
|
)
|
|
.unwrap();
|
|
connection
|
|
.execute(
|
|
r#"
|
|
INSERT INTO agent_events
|
|
(id, source, project_path, session_id, event_type, title, detail, payload_json, created_at)
|
|
VALUES (?1, 'codex', NULL, NULL, 'tool',
|
|
'FORBIDDEN_LEGACY_TITLE_ALIAS_0f31',
|
|
'FORBIDDEN_LEGACY_DETAIL_ALIAS_4b72', ?2, ?3)
|
|
"#,
|
|
params![
|
|
"legacy-event",
|
|
r#"{"prompt":"FORBIDDEN_LEGACY_PLAINTEXT_63bb","command":"cat secret"}"#,
|
|
received_at(),
|
|
],
|
|
)
|
|
.unwrap();
|
|
drop(connection);
|
|
|
|
let database = Database::new(path.clone());
|
|
database.init().unwrap();
|
|
|
|
let connection = Connection::open(&path).unwrap();
|
|
let columns = connection
|
|
.prepare("PRAGMA table_info(agent_events)")
|
|
.unwrap()
|
|
.query_map([], |row| row.get::<_, String>(1))
|
|
.unwrap()
|
|
.collect::<std::result::Result<Vec<_>, _>>()
|
|
.unwrap();
|
|
assert!(columns.iter().any(|column| column == "row_id"));
|
|
assert!(columns.iter().any(|column| column == "external_event_id"));
|
|
assert!(columns.iter().any(|column| column == "session_key"));
|
|
let payload_text: String = connection
|
|
.query_row("SELECT payload_json FROM agent_events", [], |row| {
|
|
row.get(0)
|
|
})
|
|
.unwrap();
|
|
let payload: Value = serde_json::from_str(&payload_text).unwrap();
|
|
assert_eq!(payload["external_event_id"], "legacy-event");
|
|
assert_eq!(payload["source_event"], "legacy");
|
|
assert!(!payload_text.contains("FORBIDDEN_LEGACY_PLAINTEXT_63bb"));
|
|
let (title, detail): (String, Option<String>) = connection
|
|
.query_row("SELECT title, detail FROM agent_events", [], |row| {
|
|
Ok((row.get(0)?, row.get(1)?))
|
|
})
|
|
.unwrap();
|
|
assert_eq!(title, AgentEventType::Tool.zh_label());
|
|
assert_eq!(detail, None);
|
|
let user_version: u32 = connection
|
|
.query_row("PRAGMA user_version", [], |row| row.get(0))
|
|
.unwrap();
|
|
assert!(user_version >= 1);
|
|
drop(connection);
|
|
|
|
for candidate in [
|
|
path.clone(),
|
|
PathBuf::from(format!("{}-wal", path.display())),
|
|
PathBuf::from(format!("{}-shm", path.display())),
|
|
] {
|
|
if candidate.exists() {
|
|
let bytes = fs::read(&candidate).unwrap();
|
|
for forbidden in [
|
|
b"FORBIDDEN_LEGACY_PLAINTEXT_63bb".as_slice(),
|
|
b"FORBIDDEN_LEGACY_TITLE_ALIAS_0f31".as_slice(),
|
|
b"FORBIDDEN_LEGACY_DETAIL_ALIAS_4b72".as_slice(),
|
|
] {
|
|
assert!(
|
|
!bytes
|
|
.windows(forbidden.len())
|
|
.any(|window| window == forbidden),
|
|
"legacy plaintext remained in {}",
|
|
candidate.display()
|
|
);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn pending_privacy_scrub_is_retried_before_schema_version_completes() {
|
|
let temp = tempfile::tempdir().unwrap();
|
|
let path = temp.path().join("interrupted-privacy.sqlite");
|
|
let connection = Connection::open(&path).unwrap();
|
|
connection
|
|
.execute_batch(
|
|
r#"
|
|
PRAGMA journal_mode = WAL;
|
|
PRAGMA user_version = 2;
|
|
CREATE TABLE agent_events (
|
|
row_id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
external_event_id TEXT NOT NULL,
|
|
source TEXT NOT NULL,
|
|
project_path TEXT,
|
|
session_id TEXT,
|
|
session_key TEXT NOT NULL,
|
|
event_type TEXT NOT NULL,
|
|
title TEXT,
|
|
detail TEXT,
|
|
payload_json TEXT NOT NULL,
|
|
created_at TEXT NOT NULL,
|
|
UNIQUE(source, session_key, external_event_id)
|
|
);
|
|
CREATE TABLE privacy_migrations (
|
|
migration_key TEXT PRIMARY KEY,
|
|
phase TEXT NOT NULL,
|
|
updated_at TEXT NOT NULL
|
|
);
|
|
INSERT INTO privacy_migrations (migration_key, phase, updated_at)
|
|
VALUES ('event-envelope-v4-secure-vacuum', 'pending_secure_vacuum',
|
|
'2026-07-10T00:00:00Z');
|
|
CREATE TABLE discarded_sensitive_pages (value TEXT NOT NULL);
|
|
INSERT INTO discarded_sensitive_pages VALUES (
|
|
'FORBIDDEN_INTERRUPTED_MIGRATION_PLAINTEXT_93ac'
|
|
);
|
|
DROP TABLE discarded_sensitive_pages;
|
|
"#,
|
|
)
|
|
.unwrap();
|
|
drop(connection);
|
|
|
|
let database = Database::new(path.clone());
|
|
database.init().unwrap();
|
|
|
|
let connection = Connection::open(&path).unwrap();
|
|
let pending: i64 = connection
|
|
.query_row(
|
|
"SELECT COUNT(*) FROM privacy_migrations WHERE migration_key = 'event-envelope-v4-secure-vacuum'",
|
|
[],
|
|
|row| row.get(0),
|
|
)
|
|
.unwrap();
|
|
assert_eq!(pending, 0);
|
|
let user_version: u32 = connection
|
|
.query_row("PRAGMA user_version", [], |row| row.get(0))
|
|
.unwrap();
|
|
assert!(user_version >= 4);
|
|
drop(connection);
|
|
|
|
for candidate in [
|
|
path.clone(),
|
|
PathBuf::from(format!("{}-wal", path.display())),
|
|
PathBuf::from(format!("{}-shm", path.display())),
|
|
] {
|
|
if candidate.exists() {
|
|
let bytes = fs::read(&candidate).unwrap();
|
|
let forbidden = b"FORBIDDEN_INTERRUPTED_MIGRATION_PLAINTEXT_93ac";
|
|
assert!(
|
|
!bytes
|
|
.windows(forbidden.len())
|
|
.any(|window| window == forbidden),
|
|
"interrupted migration plaintext remained in {}",
|
|
candidate.display()
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn state_revision_changes_for_every_client_visible_pet_field() {
|
|
let temp = tempfile::tempdir().unwrap();
|
|
let database = Database::new(temp.path().join("events.sqlite"));
|
|
database.init().unwrap();
|
|
let mut pet = PetSummary {
|
|
id: "pet-revision".to_string(),
|
|
name: "Pet".to_string(),
|
|
style: "pixel".to_string(),
|
|
quality: QualityLevel::Standard,
|
|
render_size: RenderSize {
|
|
width: 384,
|
|
height: 416,
|
|
},
|
|
states: petcore_types::default_pet_states(),
|
|
petpack_path: "/tmp/pet.petpack".to_string(),
|
|
cover_path: "/tmp/cover.png".to_string(),
|
|
origin: PetOrigin::ExternalImport,
|
|
generator: None,
|
|
provenance: None,
|
|
revision_id: None,
|
|
revision_count: 0,
|
|
active: false,
|
|
created_at: "2026-07-01T00:00:00Z".to_string(),
|
|
};
|
|
let mut previous = database.state_revision().unwrap();
|
|
|
|
macro_rules! mutate_and_assert_revision {
|
|
($mutation:expr) => {{
|
|
$mutation;
|
|
database.upsert_pet(&pet).unwrap();
|
|
let current = database.state_revision().unwrap();
|
|
assert!(current > previous, "revision did not advance");
|
|
previous = current;
|
|
}};
|
|
}
|
|
|
|
mutate_and_assert_revision!(pet.name = "Renamed".to_string());
|
|
mutate_and_assert_revision!(pet.style = "watercolor".to_string());
|
|
mutate_and_assert_revision!(pet.quality = QualityLevel::Low);
|
|
mutate_and_assert_revision!(pet.render_size.width = 192);
|
|
mutate_and_assert_revision!(pet.render_size.height = 208);
|
|
mutate_and_assert_revision!(pet.petpack_path = "/tmp/revision.petpack".to_string());
|
|
mutate_and_assert_revision!(pet.cover_path = "/tmp/revision-cover.png".to_string());
|
|
mutate_and_assert_revision!(pet.origin = PetOrigin::GeneratedByPetcoreJob);
|
|
mutate_and_assert_revision!(pet.generator = Some("codex".to_string()));
|
|
mutate_and_assert_revision!(pet.provenance = Some("verified".to_string()));
|
|
mutate_and_assert_revision!(pet.active = true);
|
|
mutate_and_assert_revision!(pet.created_at = "2026-07-02T00:00:00Z".to_string());
|
|
|
|
database
|
|
.set_pet_asset_validation("pet-revision", "fingerprint", false, Some("repair needed"))
|
|
.unwrap();
|
|
let validation_revision = database.state_revision().unwrap();
|
|
assert!(validation_revision > previous);
|
|
|
|
let connection = Connection::open(database.path()).unwrap();
|
|
let row_count: i64 = connection
|
|
.query_row("SELECT COUNT(*) FROM state_revision", [], |row| row.get(0))
|
|
.unwrap();
|
|
let stored: u64 = connection
|
|
.query_row(
|
|
"SELECT revision FROM state_revision WHERE singleton = 1",
|
|
[],
|
|
|row| row.get(0),
|
|
)
|
|
.unwrap();
|
|
assert_eq!(row_count, 1);
|
|
assert_eq!(stored, validation_revision);
|
|
}
|