use crate::lifecycle_store::lifecycle_root_from_config;
use serde::{Deserialize, Serialize};
use std::fs;
use std::path::{Path, PathBuf};
use std::time::{SystemTime, UNIX_EPOCH};
use ts_rs::TS;
const TRACE_FILE_NAME: &str = "prompt-optimize.latest.json";
const TRACE_SCHEMA_VERSION: &str = "prompt-optimize-trace.v1";
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, TS)]
#[ts(export, export_to = "../frontend/src/lib/types/generated/")]
pub struct PromptOptimizeTrace {
pub schema_version: String,
pub recorded_at: String,
pub source: String,
pub cwd: String,
pub task: String,
pub target: String,
pub profile: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub provider: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub session_id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub matched_project_id: Option<String>,
pub note_count: usize,
pub used_vault_root: String,
}
impl PromptOptimizeTrace {
#[allow(clippy::too_many_arguments)]
pub fn new(
cwd: impl Into<String>,
task: impl Into<String>,
target: impl Into<String>,
profile: impl Into<String>,
provider: Option<String>,
session_id: Option<String>,
matched_project_id: Option<String>,
note_count: usize,
used_vault_root: impl Into<String>,
) -> Self {
Self {
schema_version: TRACE_SCHEMA_VERSION.to_string(),
recorded_at: timestamp_string(),
source: "mcp.prompt_optimize".to_string(),
cwd: cwd.into(),
task: task.into(),
target: target.into(),
profile: profile.into(),
provider,
session_id,
matched_project_id,
note_count,
used_vault_root: used_vault_root.into(),
}
}
}
pub fn write_latest_prompt_optimize_trace(
config_path: &Path,
trace: &PromptOptimizeTrace,
) -> anyhow::Result<()> {
let path = trace_file_path(config_path);
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)?;
}
let temp_path = path.with_extension("tmp");
fs::write(&temp_path, serde_json::to_vec_pretty(trace)?)?;
fs::rename(temp_path, path)?;
Ok(())
}
pub fn read_latest_prompt_optimize_trace(
config_path: &Path,
) -> anyhow::Result<Option<PromptOptimizeTrace>> {
let path = trace_file_path(config_path);
if !path.exists() {
return Ok(None);
}
let trace = serde_json::from_slice::<PromptOptimizeTrace>(&fs::read(path)?)?;
if trace.schema_version != TRACE_SCHEMA_VERSION {
return Ok(None);
}
Ok(Some(trace))
}
fn trace_file_path(config_path: &Path) -> PathBuf {
let config_dir = config_path.parent().unwrap_or_else(|| Path::new("."));
lifecycle_root_from_config(config_dir).join(TRACE_FILE_NAME)
}
fn timestamp_string() -> String {
let seconds = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_secs();
format!("unix:{seconds}")
}
#[cfg(test)]
mod tests {
use super::{
PromptOptimizeTrace, read_latest_prompt_optimize_trace, write_latest_prompt_optimize_trace,
};
use std::fs;
use tempfile::tempdir;
#[test]
fn prompt_optimize_trace_should_round_trip_from_config_path() {
let temp = tempdir().unwrap();
let config_path = temp.path().join("spool.toml");
fs::write(&config_path, "[vault]\nroot = \"/tmp\"\n").unwrap();
let trace = PromptOptimizeTrace::new(
"/tmp/repo",
"continue task",
"codex",
"project",
Some("codex".to_string()),
Some("codex:session-1".to_string()),
Some("spool".to_string()),
3,
"/tmp/vault",
);
write_latest_prompt_optimize_trace(&config_path, &trace).unwrap();
let loaded = read_latest_prompt_optimize_trace(&config_path)
.unwrap()
.unwrap();
assert_eq!(loaded.source, "mcp.prompt_optimize");
assert_eq!(loaded.cwd, "/tmp/repo");
assert_eq!(loaded.provider.as_deref(), Some("codex"));
assert_eq!(loaded.session_id.as_deref(), Some("codex:session-1"));
assert_eq!(loaded.note_count, 3);
}
}