diff --git a/crates/persisting-pchronicle-cli/README.md b/crates/persisting-pchronicle-cli/README.md index 30501317..551d72bc 100644 --- a/crates/persisting-pchronicle-cli/README.md +++ b/crates/persisting-pchronicle-cli/README.md @@ -11,7 +11,9 @@ export support ATIF, OpenAI Messages, ACTF, and Storyline JSON. ## Orchestrator control plane `pchronicle serve --storage URI --control 127.0.0.1:0` starts the write-capable -storage control plane used by pPilot and pVisor. It owns Run lease acquisition +storage control plane used by pPilot and pVisor. Repeat `--storage` to mount +several read-only Datasets; `--control` still requires a Dataset named +`default`. It owns Run lease acquisition and renewal, fencing, terminal commits, Attempt registry access, and trajectory append. The process publishes one structured readiness record through stdout, including the bound loopback endpoint and one-time token, then serves versioned @@ -115,7 +117,8 @@ Its response reports `fact_rows` and omits `input_bytes`; explicit `--output-format preserve` is invalid for canonical events. `serve --storage URI` converges deterministic sibling Storyline projections -before readiness and maintains them as canonical events are appended. Runtime +before readiness and maintains them as canonical events are appended. Repeated +`--storage` values each become a Dataset mount. Runtime failures are retried without blocking durable writes. `status URI --format json` reports each projection's state and watermark. diff --git a/crates/persisting-pchronicle-cli/src/lib.rs b/crates/persisting-pchronicle-cli/src/lib.rs index 65c305b7..3236592e 100644 --- a/crates/persisting-pchronicle-cli/src/lib.rs +++ b/crates/persisting-pchronicle-cli/src/lib.rs @@ -491,9 +491,9 @@ struct ServeArgs { #[arg(long, value_name = "FILE", conflicts_with = "storage")] config: Option, - /// Single Dataset URI and durable Control root. + /// Dataset URI and durable Control root. Repeatable; NAME=URI overrides the derived name. #[arg(long, value_name = "URI", conflicts_with = "config")] - storage: Option, + storage: Vec, /// Loopback address for the read-only API and Web UI. #[arg(long)] @@ -1214,7 +1214,14 @@ fn write_projection_diagnostic( async fn run_serve(args: ServeArgs, stdout: &mut dyn Write, stderr: &mut dyn Write) -> Result<()> { let config = resolve_serve_config(&args)?; - prepare_local_control_storage(&args).await?; + let control_uri = args + .control + .is_some() + .then(|| control_storage_uri(&config).map(str::to_owned)) + .transpose()?; + if let Some(uri) = control_uri.as_deref() { + prepare_local_control_storage(uri).await?; + } let (diagnostic_tx, diagnostic_rx) = tokio::sync::mpsc::channel(256); let mut projections = projection_supervisor::ProjectionSupervisor::new(config.clone(), None, diagnostic_tx); @@ -1241,9 +1248,9 @@ async fn run_serve(args: ServeArgs, stdout: &mut dyn Write, stderr: &mut dyn Wri let control = match args.control { Some(listen) => Some( control::PreparedControl::bind( - args.storage - .as_deref() - .context("pChronicle Control requires --storage")?, + control_uri.as_deref().context( + "pChronicle Control requires a Dataset named 'default'; pass --storage default=URI", + )?, listen, ) .await?, @@ -1322,15 +1329,8 @@ async fn run_serve(args: ServeArgs, stdout: &mut dyn Write, stderr: &mut dyn Wri .await } -async fn prepare_local_control_storage(args: &ServeArgs) -> Result<()> { - if args.control.is_none() { - return Ok(()); - } - let storage = args - .storage - .as_deref() - .context("pChronicle Control requires --storage")?; - let Some(path) = local_dataset_path(storage)? else { +async fn prepare_local_control_storage(uri: &str) -> Result<()> { + let Some(path) = local_dataset_path(uri)? else { return Ok(()); }; tokio::fs::create_dir_all(&path) @@ -1339,16 +1339,129 @@ async fn prepare_local_control_storage(args: &ServeArgs) -> Result<()> { } fn resolve_serve_config(args: &ServeArgs) -> Result { - match (args.config.as_deref(), args.storage.as_deref()) { - (Some(config), None) => load_warehouse_config(config), - (None, Some(storage)) => server::ChronicleServerConfig::mounted(vec![DatasetMount::new( - SERVE_STORAGE_DATASET_NAME, - storage, - )?]), + match (args.config.as_deref(), args.storage.as_slice()) { + (Some(config), []) => load_warehouse_config(config), + (None, storage) if !storage.is_empty() => { + let mut config = + server::ChronicleServerConfig::mounted(resolve_storage_mounts(storage)?)?; + if config + .datasets + .iter() + .any(|dataset| dataset.name == SERVE_STORAGE_DATASET_NAME) + { + config.default_dataset = Some(SERVE_STORAGE_DATASET_NAME.into()); + } + // A single unreadable source (for example a trajectory file that + // exceeds max_file_bytes) must degrade to an error source instead + // of preventing the Warehouse from serving the remaining data. + config.catalog_options.error_policy = CatalogErrorPolicy::Report; + Ok(config) + } _ => bail!("serve requires exactly one of --config or --storage"), } } +fn resolve_storage_mounts(storages: &[String]) -> Result> { + anyhow::ensure!(!storages.is_empty(), "serve requires --storage"); + let parsed = storages + .iter() + .map(|value| parse_storage_argument(value)) + .collect::>>()?; + if parsed.len() == 1 { + let (name, uri) = &parsed[0]; + let name = name.as_deref().unwrap_or(SERVE_STORAGE_DATASET_NAME); + return Ok(vec![DatasetMount::new(name, uri.clone())?]); + } + parsed + .into_iter() + .map(|(name, uri)| { + let name = match name { + Some(name) => name, + None => derived_dataset_name(&uri)?, + }; + DatasetMount::new(name, uri) + }) + .collect() +} + +fn parse_storage_argument(raw: &str) -> Result<(Option, String)> { + let raw = raw.trim(); + anyhow::ensure!(!raw.is_empty(), "--storage URI must not be empty"); + if let Some((name, uri)) = raw.split_once('=') { + if looks_like_dataset_name(name) { + let uri = uri.trim(); + anyhow::ensure!(!uri.is_empty(), "--storage NAME=URI must include a URI"); + return Ok(( + Some(DatasetMount::new(name, "validation")?.name), + uri.to_string(), + )); + } + } + Ok((None, raw.to_string())) +} + +fn looks_like_dataset_name(name: &str) -> bool { + DatasetMount::new(name, "validation").is_ok() +} + +fn derived_dataset_name(uri: &str) -> Result { + let basename = storage_basename(uri)?; + sanitize_derived_dataset_name(&basename).with_context(|| { + format!("cannot derive Dataset name from '{uri}'; pass --storage NAME=URI") + }) +} + +fn storage_basename(uri: &str) -> Result { + if uri.contains("://") { + let url = Url::parse(uri).with_context(|| format!("parse --storage URI '{uri}'"))?; + if let Some(segment) = url + .path_segments() + .into_iter() + .flatten() + .rev() + .find(|segment| !segment.is_empty()) + { + return Ok(segment.to_string()); + } + if let Some(host) = url.host_str() { + return Ok(host.to_string()); + } + bail!("cannot derive Dataset name from '{uri}'; pass --storage NAME=URI"); + } + match Path::new(uri).file_name().and_then(|name| name.to_str()) { + Some(name) if name != "." && name != ".." => Ok(name.to_string()), + _ => bail!("cannot derive Dataset name from '{uri}'; pass --storage NAME=URI"), + } +} + +fn sanitize_derived_dataset_name(raw: &str) -> Result { + let name: String = raw + .chars() + .map(|character| match character { + '-' | '.' => '_', + other => other, + }) + .collect(); + DatasetMount::new(&name, "validation") + .map(|mount| mount.name) + .with_context(|| { + format!( + "derived Dataset name from '{raw}' is not a valid SQL alias; pass --storage NAME=URI" + ) + }) +} + +fn control_storage_uri(config: &server::ChronicleServerConfig) -> Result<&str> { + config + .datasets + .iter() + .find(|dataset| dataset.name == SERVE_STORAGE_DATASET_NAME) + .map(|dataset| dataset.uri.as_str()) + .context( + "pChronicle Control requires a Dataset named 'default'; pass --storage default=URI", + ) +} + async fn run_echo(args: EchoArgs, stderr: &mut dyn Write) -> Result<()> { anyhow::ensure!( args.listen.ip().is_loopback(), diff --git a/crates/persisting-pchronicle-cli/src/server/explorer.rs b/crates/persisting-pchronicle-cli/src/server/explorer.rs index 68c998eb..02322f7c 100644 --- a/crates/persisting-pchronicle-cli/src/server/explorer.rs +++ b/crates/persisting-pchronicle-cli/src/server/explorer.rs @@ -14,12 +14,207 @@ pub(crate) struct ExplorerRunsQuery { pub(crate) agent: Option, pub(crate) model: Option, pub(crate) path: Option, + pub(crate) file: Option, pub(crate) sort: Option, pub(crate) direction: Option, pub(crate) offset: Option, pub(crate) limit: Option, } +#[derive(Clone, Debug, Default, Deserialize)] +pub(crate) struct ExplorerTreeQuery { + pub(crate) dataset: Option, + pub(crate) prefix: Option, +} + +pub(crate) const MAX_TREE_CHILDREN: usize = 16; + +#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize)] +pub(crate) struct CatalogTree { + pub(crate) dataset: Option, + #[serde(default)] + pub(crate) prefix: String, + pub(crate) run_count: usize, + pub(crate) failed_count: usize, + #[serde(skip_serializing_if = "Option::is_none")] + pub(crate) ready_sources: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub(crate) error_sources: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub(crate) duration_ms: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub(crate) total_tokens: Option, + pub(crate) children: Vec, +} + +#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize)] +pub(crate) struct CatalogTreeChild { + pub(crate) name: String, + pub(crate) kind: String, + pub(crate) path: String, + pub(crate) run_count: usize, + pub(crate) failed_count: usize, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub(crate) entries: Vec, +} + +pub(crate) fn catalog_tree( + summaries: &[RunSummary], + dataset: Option<&str>, + prefix: &str, + max_children: usize, +) -> CatalogTree { + let prefix = prefix.trim().trim_matches('/'); + let scoped = summaries + .iter() + .filter(|run| { + dataset.is_none_or(|name| run.dataset == name) + && (dataset.is_none() || file_matches_prefix(&run.file, prefix)) + }) + .collect::>(); + let run_count = scoped.len(); + let failed_count = scoped + .iter() + .filter(|run| is_failed_status(&run.status)) + .count(); + let children = if dataset.is_none() { + fold_tree_children(dataset_children(&scoped), max_children, prefix) + } else { + fold_tree_children(file_children(&scoped, prefix), max_children, prefix) + }; + CatalogTree { + dataset: dataset.map(str::to_string), + prefix: prefix.to_string(), + run_count, + failed_count, + children, + ..CatalogTree::default() + } +} + +fn is_failed_status(status: &str) -> bool { + matches!(status, "failed" | "error") +} + +fn file_matches_prefix(file: &str, prefix: &str) -> bool { + prefix.is_empty() || file == prefix || file.starts_with(&format!("{prefix}/")) +} + +struct ChildAcc { + run_count: usize, + failed_count: usize, + has_deeper: bool, +} + +fn dataset_children(runs: &[&RunSummary]) -> Vec { + let mut groups = BTreeMap::::new(); + for run in runs { + let entry = groups.entry(run.dataset.clone()).or_insert(ChildAcc { + run_count: 0, + failed_count: 0, + has_deeper: false, + }); + entry.run_count += 1; + if is_failed_status(&run.status) { + entry.failed_count += 1; + } + } + groups + .into_iter() + .map(|(name, acc)| CatalogTreeChild { + name: name.clone(), + kind: "dataset".into(), + path: name, + run_count: acc.run_count, + failed_count: acc.failed_count, + entries: Vec::new(), + }) + .collect() +} + +fn file_children(runs: &[&RunSummary], prefix: &str) -> Vec { + let mut groups = BTreeMap::::new(); + for run in runs { + if !prefix.is_empty() && run.file == prefix { + continue; + } + let rest = if prefix.is_empty() { + run.file.as_str() + } else { + match run.file.strip_prefix(&format!("{prefix}/")) { + Some(rest) => rest, + None => continue, + } + }; + if rest.is_empty() { + continue; + } + let (name, has_deeper) = match rest.split_once('/') { + Some((name, _)) => (name, true), + None => (rest, false), + }; + if name.is_empty() { + continue; + } + let entry = groups.entry(name.to_string()).or_insert(ChildAcc { + run_count: 0, + failed_count: 0, + has_deeper: false, + }); + entry.run_count += 1; + if is_failed_status(&run.status) { + entry.failed_count += 1; + } + entry.has_deeper |= has_deeper; + } + groups + .into_iter() + .map(|(name, acc)| CatalogTreeChild { + path: if prefix.is_empty() { + name.clone() + } else { + format!("{prefix}/{name}") + }, + kind: if acc.has_deeper { + "dir".into() + } else { + "file".into() + }, + name, + run_count: acc.run_count, + failed_count: acc.failed_count, + entries: Vec::new(), + }) + .collect() +} + +fn fold_tree_children( + mut children: Vec, + max_children: usize, + prefix: &str, +) -> Vec { + children.sort_by(|left, right| { + right + .run_count + .cmp(&left.run_count) + .then(left.name.cmp(&right.name)) + }); + if max_children == 0 || children.len() <= max_children { + return children; + } + let keep = max_children.saturating_sub(1); + let rest = children.split_off(keep); + children.push(CatalogTreeChild { + name: "other".into(), + kind: "other".into(), + path: prefix.to_string(), + run_count: rest.iter().map(|child| child.run_count).sum(), + failed_count: rest.iter().map(|child| child.failed_count).sum(), + entries: rest, + }); + children +} + #[derive(Clone, Debug, Serialize)] pub(crate) struct ExplorerPage { pub(crate) snapshot: PageSnapshot, @@ -196,6 +391,15 @@ pub(crate) fn run_page(summaries: Vec, query: &ExplorerRunsQuery) -> item.model.as_deref().unwrap_or_default(), query.model.as_deref(), ) + && file_matches_prefix( + &item.run.file, + query + .file + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + .unwrap_or(""), + ) }) .collect::>(); let path_index = records.iter().map(|item| item.run.clone()).collect(); @@ -1031,4 +1235,162 @@ mod tests { assert!(from_markup.modalities.contains(&"tool_call".to_string())); assert!(from_markup.modalities.contains(&"text".to_string())); } + + fn sample_run(dataset: &str, file: &str, status: &str, session: &str) -> RunSummary { + RunSummary { + dataset: dataset.into(), + file: file.into(), + document_id: session.into(), + run_id: None, + agent_id: "agent".into(), + model_name: None, + session_id: session.into(), + root_session_id: None, + path: format!("{dataset}/{file}/{session}"), + row_count: 1, + duplicate_event_ids: 0, + status: status.into(), + } + } + + #[test] + fn warehouse_tree_sizes_datasets_by_run_count() { + let tree = catalog_tree( + &[ + sample_run("evals", "a.json", "completed", "s1"), + sample_run("evals", "b.json", "failed", "s2"), + sample_run("archive", "c.json", "completed", "s3"), + ], + None, + "", + 16, + ); + assert_eq!(tree.dataset, None); + assert_eq!(tree.run_count, 3); + assert_eq!(tree.failed_count, 1); + let names: Vec<_> = tree + .children + .iter() + .map(|child| (child.name.as_str(), child.kind.as_str(), child.run_count)) + .collect(); + assert_eq!( + names, + vec![("evals", "dataset", 2), ("archive", "dataset", 1)] + ); + } + + #[test] + fn dataset_tree_groups_the_next_file_segment() { + let tree = catalog_tree( + &[ + sample_run("evals", "gsm8k/train/events.lance", "completed", "s1"), + sample_run("evals", "gsm8k/test/events.lance", "failed", "s2"), + sample_run("evals", "mmlu.json", "completed", "s3"), + sample_run("archive", "skip.json", "completed", "s4"), + ], + Some("evals"), + "", + 16, + ); + assert_eq!(tree.dataset.as_deref(), Some("evals")); + assert_eq!(tree.run_count, 3); + assert_eq!(tree.failed_count, 1); + assert_eq!( + tree.children + .iter() + .map(|child| ( + child.name.as_str(), + child.kind.as_str(), + child.path.as_str(), + child.run_count + )) + .collect::>(), + vec![ + ("gsm8k", "dir", "gsm8k", 2), + ("mmlu.json", "file", "mmlu.json", 1), + ] + ); + + let nested = catalog_tree( + &[ + sample_run("evals", "gsm8k/train/events.lance", "completed", "s1"), + sample_run("evals", "gsm8k/test/events.lance", "failed", "s2"), + ], + Some("evals"), + "gsm8k", + 16, + ); + assert_eq!(nested.prefix, "gsm8k"); + assert_eq!( + nested + .children + .iter() + .map(|child| child.name.as_str()) + .collect::>(), + vec!["test", "train"] + ); + } + + #[test] + fn exact_file_prefix_has_no_children() { + let tree = catalog_tree( + &[sample_run("evals", "mmlu.json", "completed", "s1")], + Some("evals"), + "mmlu.json", + 16, + ); + assert_eq!(tree.run_count, 1); + assert!(tree.children.is_empty()); + } + + #[test] + fn tree_folds_the_tail_into_other() { + let runs: Vec<_> = (0..5) + .map(|index| { + sample_run( + "evals", + &format!("f{index}.json"), + "completed", + &format!("s{index}"), + ) + }) + .collect(); + let tree = catalog_tree(&runs, Some("evals"), "", 3); + assert_eq!(tree.children.len(), 3); + let other = tree.children.last().unwrap(); + assert_eq!(other.kind, "other"); + assert_eq!(other.run_count, 3); + assert_eq!( + other + .entries + .iter() + .map(|child| child.name.as_str()) + .collect::>(), + vec!["f2.json", "f3.json", "f4.json"] + ); + } + + #[test] + fn run_page_file_prefix_is_not_run_path() { + let summaries = vec![ + sample_run("evals", "gsm8k/train/events.lance", "completed", "s1"), + sample_run("evals", "gsm8k/test/events.lance", "completed", "s2"), + sample_run("evals", "mmlu.json", "completed", "s3"), + ]; + let page = run_page( + summaries, + &ExplorerRunsQuery { + dataset: Some("evals".into()), + file: Some("gsm8k".into()), + limit: Some(50), + ..ExplorerRunsQuery::default() + }, + ); + assert_eq!(page.snapshot.total, 2); + assert!(page + .records + .iter() + .all(|item| item.run.file.starts_with("gsm8k"))); + assert_eq!(page.path_index.len(), 2); + } } diff --git a/crates/persisting-pchronicle-cli/src/server/mod.rs b/crates/persisting-pchronicle-cli/src/server/mod.rs index a5b1b678..b38e1462 100644 --- a/crates/persisting-pchronicle-cli/src/server/mod.rs +++ b/crates/persisting-pchronicle-cli/src/server/mod.rs @@ -183,6 +183,7 @@ fn api_routes() -> Router { .route("/health", get(warehouse_health)) .route("/runs", get(runs)) .route("/explorer/runs", get(explorer_runs)) + .route("/explorer/tree", get(explorer_tree)) .route("/explorer/run", get(explorer_run)) .route("/explorer/turns", get(explorer_turns)) .route("/explorer/turn", get(explorer_turn)) @@ -416,6 +417,97 @@ async fn explorer_runs( Ok(Json(explorer::run_page(summaries, &query))) } +async fn explorer_tree( + State(state): State, + query: Result, QueryRejection>, +) -> Result, ApiError> { + let query = api_query(query)?; + let summaries = load_run_summaries(&state).await?; + let dataset = query + .dataset + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()); + let prefix = query.prefix.as_deref().unwrap_or(""); + let mut tree = explorer::catalog_tree(&summaries, dataset, prefix, explorer::MAX_TREE_CHILDREN); + if let Some(name) = tree.dataset.clone() { + let runtime = current_catalog(&state).await?; + if tree.prefix.is_empty() { + if let Some(dataset) = runtime.snapshot.dataset(&name) { + tree.ready_sources = Some(dataset.ready_source_count()); + tree.error_sources = Some(dataset.error_source_count()); + } + } + let (duration_ms, total_tokens) = tree_prefix_metrics(&runtime, &name, &tree.prefix).await; + tree.duration_ms = duration_ms; + tree.total_tokens = total_tokens; + } + Ok(Json(tree)) +} + +fn sql_ident(name: &str) -> Option<&str> { + let mut chars = name.chars(); + let first = chars.next()?; + (first.is_ascii_alphabetic() || first == '_') + .then_some(name) + .filter(|_| chars.all(|ch| ch.is_ascii_alphanumeric() || ch == '_')) +} + +async fn tree_prefix_metrics( + runtime: &CatalogRuntime, + dataset: &str, + prefix: &str, +) -> (Option, Option) { + let Some(ident) = sql_ident(dataset) else { + return (None, None); + }; + let file_clause = if prefix.is_empty() { + String::new() + } else { + let escaped = prefix.replace('\'', "''"); + format!(" WHERE _file_ = '{escaped}' OR _file_ LIKE '{escaped}/%'") + }; + let sql = format!( + "SELECT MIN(timestamp) AS start_ts, MAX(timestamp) AS end_ts FROM {ident}.steps{file_clause}" + ); + let mut buffer = Vec::new(); + let write = tokio::time::timeout( + Duration::from_secs(3), + runtime + .engine + .write_query_jsonl_with_max_rows(&sql, &mut buffer, Some(1)), + ) + .await; + let Ok(Ok(())) = write else { + return (None, None); + }; + let line = String::from_utf8(buffer).unwrap_or_default(); + let line = line.lines().find(|line| !line.trim().is_empty()); + let Some(Ok(row)) = line.map(serde_json::from_str::) else { + return (None, None); + }; + ( + timestamp_span_ms(row.get("start_ts"), row.get("end_ts")), + row.get("total_tokens").and_then(Value::as_u64), + ) +} + +fn timestamp_span_ms(start: Option<&Value>, end: Option<&Value>) -> Option { + let start = json_timestamp_ms(start?)?; + let end = json_timestamp_ms(end?)?; + (end >= start).then_some(end - start) +} + +fn json_timestamp_ms(value: &Value) -> Option { + match value { + Value::Number(number) => number + .as_i64() + .or_else(|| number.as_f64().map(|value| value as i64)), + Value::String(text) if !text.is_empty() => text.parse().ok(), + _ => None, + } +} + async fn resolve_run_summary( state: &AppState, query: &SessionQuery, @@ -1272,7 +1364,7 @@ async fn query_evidence( Some(max_rows.saturating_add(1) as u64), ) .await; - let bytes = match output.finish(write_result).map_err(ApiError::internal)? { + let bytes = match output.finish(write_result).map_err(query_evidence_error)? { QueryEvidenceWriteOutcome::Complete(bytes) => bytes, QueryEvidenceWriteOutcome::LimitExceeded => { return Err(ApiError::resource_exhausted( @@ -1358,6 +1450,24 @@ impl std::io::Write for BoundedOutput { } } +/// Map a query-evidence execution failure to a client-visible error. +/// +/// The SQL is caller-supplied, so planning and streaming failures (unknown +/// columns, invalid syntax, unsupported expressions) are input problems. The +/// original message is surfaced so Copilot tool loops and the query console +/// can self-correct instead of retrying against an opaque 500. +fn query_evidence_error(error: anyhow::Error) -> ApiError { + let detail = format!("{error:#}"); + const MAX_DETAIL_CHARS: usize = 1500; + let message = if detail.chars().count() > MAX_DETAIL_CHARS { + let truncated: String = detail.chars().take(MAX_DETAIL_CHARS).collect(); + format!("{truncated}…") + } else { + detail + }; + ApiError::invalid_request(message) +} + fn bounded_evidence_sql(sql: &str, max_rows: usize) -> String { let statement = sql.trim().strip_suffix(';').unwrap_or(sql.trim()); if statement diff --git a/crates/persisting-pchronicle-cli/src/server/tests.rs b/crates/persisting-pchronicle-cli/src/server/tests.rs index 3cd5106d..c2e92625 100644 --- a/crates/persisting-pchronicle-cli/src/server/tests.rs +++ b/crates/persisting-pchronicle-cli/src/server/tests.rs @@ -1102,6 +1102,35 @@ async fn boundary_unsupported_query_input_returns_unprocessable_entity() { std::fs::remove_dir_all(root).unwrap(); } +#[tokio::test] +async fn query_evidence_sql_failure_returns_visible_invalid_request() { + use tower::ServiceExt as _; + + let root = json_dataset_root(); + let response = router(root.to_string_lossy().to_string()) + .oneshot( + axum::http::Request::builder() + .method("POST") + .uri("/api/query/evidence") + .header(header::CONTENT_TYPE, "application/json") + .body(axum::body::Body::from( + json!({"sql":"SELECT no_such_column_xyz FROM runs"}).to_string(), + )) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::BAD_REQUEST); + let body = response_json(response).await; + assert_eq!(body["code"], "invalid_request"); + let message = body["message"].as_str().unwrap(); + assert!( + message.contains("no_such_column_xyz"), + "error message should expose the failing column: {message}" + ); + std::fs::remove_dir_all(root).unwrap(); +} + #[tokio::test] async fn query_evidence_byte_budget_uses_writer_exhaustion_outcome() { use tower::ServiceExt as _; @@ -1127,6 +1156,78 @@ async fn query_evidence_byte_budget_uses_writer_exhaustion_outcome() { std::fs::remove_dir_all(root).unwrap(); } +#[tokio::test] +async fn explorer_tree_lists_mounted_datasets_by_run_count() -> anyhow::Result<()> { + use http_body_util::BodyExt; + use tower::ServiceExt; + + let live = json_dataset_root(); + std::fs::create_dir_all(live.join("nested"))?; + write_gateway_fixture(&live, "nested/run.json", "nested-session", "nested-job"); + let archive = json_dataset_root(); + let config = ChronicleServerConfig::mounted(vec![ + DatasetMount::new("live", live.to_string_lossy())?, + DatasetMount::new("archive", archive.to_string_lossy())?, + ])?; + let app = test_router_with_config(config); + + let warehouse = app + .clone() + .oneshot( + axum::http::Request::builder() + .uri("/api/explorer/tree") + .body(axum::body::Body::empty())?, + ) + .await?; + assert_eq!(warehouse.status(), StatusCode::OK); + let warehouse: Value = + serde_json::from_slice(&warehouse.into_body().collect().await?.to_bytes())?; + assert_eq!(warehouse["run_count"], 3); + assert_eq!(warehouse["children"][0]["name"], "live"); + assert_eq!(warehouse["children"][0]["kind"], "dataset"); + assert_eq!(warehouse["children"][0]["run_count"], 2); + assert_eq!(warehouse["children"][1]["name"], "archive"); + assert_eq!(warehouse["children"][1]["run_count"], 1); + + let dataset = app + .clone() + .oneshot( + axum::http::Request::builder() + .uri("/api/explorer/tree?dataset=live") + .body(axum::body::Body::empty())?, + ) + .await?; + assert_eq!(dataset.status(), StatusCode::OK); + let dataset: Value = serde_json::from_slice(&dataset.into_body().collect().await?.to_bytes())?; + assert_eq!(dataset["dataset"], "live"); + assert_eq!(dataset["run_count"], 2); + assert!(dataset["ready_sources"].as_u64().unwrap() >= 1); + let names: Vec<_> = dataset["children"] + .as_array() + .unwrap() + .iter() + .map(|child| child["name"].as_str().unwrap().to_string()) + .collect(); + assert!(names.contains(&"gateway.json".into())); + assert!(names.contains(&"nested".into())); + + let prefixed = app + .oneshot( + axum::http::Request::builder() + .uri("/api/explorer/runs?dataset=live&file=nested&limit=10") + .body(axum::body::Body::empty())?, + ) + .await?; + let prefixed: Value = + serde_json::from_slice(&prefixed.into_body().collect().await?.to_bytes())?; + assert_eq!(prefixed["snapshot"]["total"], 1); + assert_eq!(prefixed["records"][0]["file"], "nested/run.json"); + + std::fs::remove_dir_all(live)?; + std::fs::remove_dir_all(archive)?; + Ok(()) +} + #[test] fn sql_validation_rejects_empty_and_mutating_statements() { assert!(validate_read_only_sql("SELECT 1").is_ok()); diff --git a/crates/persisting-pchronicle-cli/src/tests.rs b/crates/persisting-pchronicle-cli/src/tests.rs index 01079bf3..876b1047 100644 --- a/crates/persisting-pchronicle-cli/src/tests.rs +++ b/crates/persisting-pchronicle-cli/src/tests.rs @@ -461,10 +461,12 @@ async fn status_reports_projection_stale_and_safe_errors() -> Result<()> { let lineage_free = storage.join("agent/lineage-free/storyline/CURRENT"); let mut pointer: Value = serde_json::from_slice(&fs::read(&lineage_free)?)?; - pointer - .as_object_mut() - .expect("CURRENT object") + let removed = pointer + .get_mut("committed") + .and_then(Value::as_object_mut) + .expect("CURRENT committed snapshot") .remove("projection"); + assert!(removed.is_some(), "projection lineage fixture"); fs::write(&lineage_free, serde_json::to_vec(&pointer)?)?; let malformed = storage.join("agent/malformed/storyline/CURRENT"); fs::write(&malformed, b"{broken")?; @@ -2817,6 +2819,177 @@ fn preserves_uri_roots_while_trimming_prefixes() { ); } +fn serve_args_with_storage(storage: Vec) -> ServeArgs { + ServeArgs { + config: None, + storage, + listen: None, + control: None, + open: false, + gateway: None, + gateway_dataset: None, + gateway_state: None, + gateway_object_store_manifest_mode: GatewayObjectStoreManifestMode::default(), + gateway_stream_markdown: false, + debug: false, + } +} + +#[test] +fn serve_storage_config_uses_report_error_policy() -> Result<()> { + let temp = tempfile::tempdir()?; + let dataset = temp.path().join("dataset"); + fs::create_dir(&dataset)?; + + let args = serve_args_with_storage(vec![dataset.to_string_lossy().into_owned()]); + let config = resolve_serve_config(&args)?; + assert_eq!(config.datasets.len(), 1); + assert_eq!(config.datasets[0].name, "default"); + assert_eq!(config.default_dataset.as_deref(), Some("default")); + assert_eq!( + config.catalog_options.error_policy, + CatalogErrorPolicy::Report + ); + Ok(()) +} + +#[test] +fn serve_repeated_storage_mounts_basename_datasets() -> Result<()> { + let args = serve_args_with_storage(vec!["./tmp".into(), "./data/evals".into()]); + let config = resolve_serve_config(&args)?; + let mounts: Vec<_> = config + .datasets + .iter() + .map(|dataset| (dataset.name.as_str(), dataset.uri.as_str())) + .collect(); + assert_eq!(mounts, vec![("tmp", "./tmp"), ("evals", "./data/evals")]); + assert_eq!(config.default_dataset, None); + assert_eq!( + config.catalog_options.error_policy, + CatalogErrorPolicy::Report + ); + Ok(()) +} + +#[test] +fn serve_storage_name_uri_overrides_basename() -> Result<()> { + let args = serve_args_with_storage(vec!["default=./tmp".into(), "archive=./data/evals".into()]); + let config = resolve_serve_config(&args)?; + let mounts: Vec<_> = config + .datasets + .iter() + .map(|dataset| (dataset.name.as_str(), dataset.uri.as_str())) + .collect(); + assert_eq!( + mounts, + vec![("default", "./tmp"), ("archive", "./data/evals")] + ); + assert_eq!(config.default_dataset.as_deref(), Some("default")); + Ok(()) +} + +#[test] +fn serve_single_named_storage_keeps_explicit_name() -> Result<()> { + let args = serve_args_with_storage(vec!["evals=./data".into()]); + let config = resolve_serve_config(&args)?; + assert_eq!(config.datasets.len(), 1); + assert_eq!(config.datasets[0].name, "evals"); + assert_eq!(config.datasets[0].uri, "./data"); + assert_eq!(config.default_dataset.as_deref(), Some("evals")); + Ok(()) +} + +#[test] +fn serve_storage_sanitizes_hyphenated_basename() -> Result<()> { + let args = serve_args_with_storage(vec!["./tmp".into(), "./trajectory-data".into()]); + let config = resolve_serve_config(&args)?; + let names: Vec<_> = config + .datasets + .iter() + .map(|dataset| dataset.name.as_str()) + .collect(); + assert_eq!(names, vec!["tmp", "trajectory_data"]); + Ok(()) +} + +#[test] +fn serve_storage_derives_object_uri_basename() -> Result<()> { + let args = serve_args_with_storage(vec![ + "s3://bucket/archive".into(), + "s3://other/evals".into(), + ]); + let config = resolve_serve_config(&args)?; + let mounts: Vec<_> = config + .datasets + .iter() + .map(|dataset| (dataset.name.as_str(), dataset.uri.as_str())) + .collect(); + assert_eq!( + mounts, + vec![ + ("archive", "s3://bucket/archive"), + ("evals", "s3://other/evals") + ] + ); + Ok(()) +} + +#[test] +fn serve_storage_keeps_object_uri_with_equals_in_path() -> Result<()> { + let args = serve_args_with_storage(vec!["./tmp".into(), "s3://bucket/key=value/path".into()]); + let config = resolve_serve_config(&args)?; + assert_eq!(config.datasets[1].name, "path"); + assert_eq!(config.datasets[1].uri, "s3://bucket/key=value/path"); + Ok(()) +} + +#[test] +fn serve_storage_rejects_duplicate_names() { + let error = resolve_serve_config(&serve_args_with_storage(vec![ + "./a/data".into(), + "./b/data".into(), + ])) + .unwrap_err(); + let message = format!("{error:#}"); + assert!( + message.contains("unique") || message.contains("duplicate"), + "unexpected error: {message}" + ); +} + +#[test] +fn serve_storage_rejects_underivable_basename() { + let error = resolve_serve_config(&serve_args_with_storage(vec!["./tmp".into(), ".".into()])) + .unwrap_err(); + let message = format!("{error:#}"); + assert!(message.contains("NAME=URI"), "unexpected error: {message}"); +} + +#[test] +fn serve_control_storage_uses_default_mount() -> Result<()> { + let config = resolve_serve_config(&serve_args_with_storage(vec![ + "default=./tmp".into(), + "evals=./data".into(), + ]))?; + assert_eq!(control_storage_uri(&config)?, "./tmp"); + Ok(()) +} + +#[test] +fn serve_control_storage_requires_default_mount() { + let config = resolve_serve_config(&serve_args_with_storage(vec![ + "./tmp".into(), + "./data".into(), + ])) + .unwrap(); + let error = control_storage_uri(&config).unwrap_err(); + let message = format!("{error:#}"); + assert!( + message.contains("default=URI"), + "unexpected error: {message}" + ); +} + #[test] fn warehouse_config_normalizes_mounts_and_selects_default() -> Result<()> { let temp = tempfile::tempdir()?; @@ -2945,6 +3118,16 @@ fn serve_cli_requires_one_dataset_source_and_an_explicit_service() -> Result<()> "--listen", "127.0.0.1:0", ], + vec![ + "pchronicle", + "serve", + "--storage", + "./tmp", + "--storage", + "./data", + "--listen", + "127.0.0.1:0", + ], ] { assert!( Cli::try_parse_from(arguments.clone()).is_ok(), diff --git a/crates/persisting-pchronicle/src/formats/openai_corpus.rs b/crates/persisting-pchronicle/src/formats/openai_corpus.rs index 0cf9f18b..847ce895 100644 --- a/crates/persisting-pchronicle/src/formats/openai_corpus.rs +++ b/crates/persisting-pchronicle/src/formats/openai_corpus.rs @@ -1696,6 +1696,29 @@ fn encode_tool_calls(calls: &[StorylineToolCall]) -> Result { .map(Value::Array) } +/// Trim whitespace plus literal `\n` / `\r` / `\t` escape sequences that models +/// often emit around embedded tool call fields (e.g. `\ncat foo\n`). +fn clean_embedded_text(value: &str) -> String { + let mut text = value.trim(); + while let Some(stripped) = text + .strip_prefix("\\r\\n") + .or_else(|| text.strip_prefix("\\n")) + .or_else(|| text.strip_prefix("\\r")) + .or_else(|| text.strip_prefix("\\t")) + { + text = stripped.trim_start(); + } + while let Some(stripped) = text + .strip_suffix("\\r\\n") + .or_else(|| text.strip_suffix("\\n")) + .or_else(|| text.strip_suffix("\\r")) + .or_else(|| text.strip_suffix("\\t")) + { + text = stripped.trim_end(); + } + text.to_string() +} + fn parse_embedded_tool_call( content: Option<&Value>, step_id: i64, @@ -1705,7 +1728,7 @@ fn parse_embedded_tool_call( .split_once("") .map(|(_, value)| value) .or_else(|| text.split_once("', '\n', '<']).next()?.trim(); + let name = clean_embedded_text(name.split(['>', '\n', '<']).next().unwrap_or(name)); if name.is_empty() { return None; } @@ -1723,7 +1746,7 @@ fn parse_embedded_tool_call( let (value, rest) = after_opening .split_once("") .unwrap_or((after_opening, "")); - arguments.insert(key.to_string(), Value::String(value.trim().to_string())); + arguments.insert(key.to_string(), Value::String(clean_embedded_text(value))); remaining = rest; } Some(vec![StorylineToolCall { diff --git a/crates/persisting-pchronicle/src/projection/automatic.rs b/crates/persisting-pchronicle/src/projection/automatic.rs index 7c5d6c97..42e6e503 100644 --- a/crates/persisting-pchronicle/src/projection/automatic.rs +++ b/crates/persisting-pchronicle/src/projection/automatic.rs @@ -631,7 +631,7 @@ mod tests { }; let current = projection_a.join("CURRENT"); let mut pointer: serde_json::Value = serde_json::from_slice(&std::fs::read(¤t)?)?; - pointer + pointer["committed"] .as_object_mut() .expect("CURRENT pointer object") .remove("projection"); @@ -728,15 +728,15 @@ mod tests { let current = projection.join("CURRENT"); let mut pointer: serde_json::Value = serde_json::from_slice(&std::fs::read(¤t)?)?; - pointer["projection"]["recipe_hash"] = serde_json::json!("blake3:obsolete"); + pointer["committed"]["projection"]["recipe_hash"] = serde_json::json!("blake3:obsolete"); std::fs::write(¤t, serde_json::to_vec(&pointer)?)?; let rebuilt = maintain_automatic_storyline_projection(&target).await?; assert_eq!(rebuilt.mode, AutomaticProjectionMaintenanceMode::Rebuilt); assert!(rebuilt.published()); let mut pointer: serde_json::Value = serde_json::from_slice(&std::fs::read(¤t)?)?; - pointer["projection"]["source"]["fact_version"] = serde_json::json!(999); - pointer["projection"]["source"]["fact_rows"] = serde_json::json!(999); + pointer["committed"]["projection"]["source"]["fact_version"] = serde_json::json!(999); + pointer["committed"]["projection"]["source"]["fact_rows"] = serde_json::json!(999); std::fs::write(¤t, serde_json::to_vec(&pointer)?)?; let rebuilt_non_monotonic = maintain_automatic_storyline_projection(&target).await?; assert_eq!( @@ -745,7 +745,8 @@ mod tests { ); let mut pointer: serde_json::Value = serde_json::from_slice(&std::fs::read(¤t)?)?; - pointer["projection"]["source"]["source_uri"] = serde_json::json!("/foreign/events.lance"); + pointer["committed"]["projection"]["source"]["source_uri"] = + serde_json::json!("/foreign/events.lance"); std::fs::write(¤t, serde_json::to_vec(&pointer)?)?; let before = std::fs::read(¤t)?; let error = maintain_automatic_storyline_projection(&target) diff --git a/crates/persisting-pchronicle/src/store/storyline/mod.rs b/crates/persisting-pchronicle/src/store/storyline/mod.rs index c3f7b8fa..4cd32147 100644 --- a/crates/persisting-pchronicle/src/store/storyline/mod.rs +++ b/crates/persisting-pchronicle/src/store/storyline/mod.rs @@ -20,6 +20,7 @@ mod content; pub(super) mod datafusion; mod mutation; pub(super) mod rows; +mod writer_control; use mutation::{ externalize_rows, next_storyline_stream_chunk, replace_table_batches, write_batches, @@ -67,7 +68,6 @@ use lance_index::optimize::OptimizeOptions; use lance_index::scalar::{BuiltinIndexType, ScalarIndexParams}; use lance_index::IndexType; use object_store::path::Path as ObjectPath; -use object_store::{Error as ObjectStoreError, ObjectStoreExt, PutMode, UpdateVersion}; use serde::{Deserialize, Serialize}; use super::storyline_model::{ @@ -212,6 +212,7 @@ pub struct StorylineLanceStore { object_store: std::sync::Arc, object_root: ObjectPath, write_lock: Arc>, + control_lock: Arc>, content_options: StorylineContentOptions, } @@ -228,11 +229,6 @@ impl Drop for StoreWriteGuard { } } -struct CurrentPointerState { - pointer: Option, - version: Option, -} - #[derive(Debug, Clone, Default, PartialEq, Eq)] pub struct StorylineMaintenanceReport { pub generation: Option, @@ -269,6 +265,23 @@ fn published_storyline_report( } } +fn attach_stream_cleanup_failures( + result: Result, + cleanup_failures: Vec, +) -> Result { + if cleanup_failures.is_empty() { + return result; + } + let cleanup = format!( + "Storyline cleanup also failed: {}", + cleanup_failures.join("; ") + ); + match result { + Ok(_) => Err(anyhow::anyhow!(cleanup)), + Err(error) => Err(error.context(cleanup)), + } +} + #[derive(Debug, Clone, Copy, PartialEq, Eq)] enum StorylineStreamWriteMode { Replace, @@ -301,6 +314,18 @@ static REPLACEMENT_AFTER_CURRENT_READ_BARRIER: std::sync::Mutex< Option, > = std::sync::Mutex::new(None); +#[cfg(test)] +#[derive(Clone)] +struct MaintenanceAfterPublishPauseHook { + root_uri: String, + reached: Arc, + resume: Arc, +} + +#[cfg(test)] +static MAINTENANCE_AFTER_PUBLISH_PAUSE: std::sync::Mutex> = + std::sync::Mutex::new(None); + #[cfg(test)] async fn wait_after_empty_current_read(root_uri: &str) { let barrier = CREATE_AFTER_EMPTY_READ_BARRIER @@ -327,6 +352,20 @@ async fn wait_after_replacement_current_read(root_uri: &str) { } } +#[cfg(test)] +async fn wait_after_maintenance_publish(root_uri: &str) { + let hook = MAINTENANCE_AFTER_PUBLISH_PAUSE + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .as_ref() + .filter(|hook| hook.root_uri == root_uri) + .cloned(); + if let Some(hook) = hook { + hook.reached.notify_one(); + hook.resume.notified().await; + } +} + #[cfg(test)] async fn wait_for_first_content_create(root_uri: &str) -> bool { let hook = CREATE_AFTER_EMPTY_READ_BARRIER @@ -441,6 +480,7 @@ impl StorylineLanceStore { Ok(Self { root: PathBuf::from(&root_uri), write_lock: root_write_lock::for_root(&root_uri), + control_lock: Arc::new(tokio::sync::Mutex::new(())), root_uri, object_store, object_root, @@ -517,7 +557,8 @@ impl StorylineLanceStore { } pub(crate) async fn resolve_current_table_paths(&self) -> Result> { - let Some(pointer) = self.read_current_pointer().await?.pointer else { + let current = self.read_current_control().await?; + let Some(pointer) = current.control.committed else { return Ok(None); }; let mut paths = self.paths_for_generation(&pointer.table_generation); @@ -530,61 +571,6 @@ impl StorylineLanceStore { Ok(Some(paths)) } - async fn read_current_pointer(&self) -> Result { - let pointer = self.object_root.clone().join(CURRENT_FILE); - let result = match self.object_store.inner.get(&pointer).await { - Ok(result) => result, - Err(ObjectStoreError::NotFound { .. }) => { - return Ok(CurrentPointerState { - pointer: None, - version: None, - }); - } - Err(error) => { - return Err(error).with_context(|| { - format!("read Storyline commit pointer {}/CURRENT", self.root_uri) - }); - } - }; - let version = UpdateVersion { - e_tag: result.meta.e_tag.clone(), - version: result.meta.version.clone(), - }; - let contents = result - .bytes() - .await - .with_context(|| format!("read Storyline commit pointer {}/CURRENT", self.root_uri))?; - let contents = std::str::from_utf8(&contents) - .context("Storyline commit pointer is not valid UTF-8")? - .trim(); - if !contents.starts_with('{') { - validate_generation_name(contents)?; - anyhow::bail!( - "Storyline generation '{contents}' is incomplete: CURRENT must pin all table and object versions" - ); - } - let pointer = serde_json::from_str::(contents) - .context("decode Storyline snapshot pointer")?; - anyhow::ensure!( - pointer.schema_version == STORYLINE_LANCE_SCHEMA_VERSION, - "unsupported Storyline Lance schema_version {}; expected {}", - pointer.schema_version, - STORYLINE_LANCE_SCHEMA_VERSION - ); - if let Some(projection) = &pointer.projection { - projection.validate()?; - } - validate_generation_name(&pointer.generation)?; - if let Some(parent) = &pointer.parent_generation { - validate_generation_name(parent)?; - } - validate_generation_name(&pointer.table_generation)?; - Ok(CurrentPointerState { - pointer: Some(pointer), - version: Some(version), - }) - } - pub async fn replace_storyline(&self, story: &StorylineDocument) -> Result<()> { self.replace_storylines(std::slice::from_ref(story)).await } @@ -730,9 +716,32 @@ impl StorylineLanceStore { if mode == StorylineStreamWriteMode::Replace { wait_after_replacement_current_read(&self.root_uri).await; } + let writer_owner = next_generation(); + let writer_lease = if mode == StorylineStreamWriteMode::Replace && original.is_some() { + Some( + self.acquire_writer_lease_for_generation( + &writer_owner, + expected_generation.as_deref(), + ) + .await?, + ) + } else { + None + }; + let mut writer_renewal = writer_lease + .as_ref() + .map(|lease| self.start_writer_lease_renewal(writer_owner.clone(), lease.lease.epoch)); let rebuild = mode == StorylineStreamWriteMode::Rebuild; - let mut paths = if rebuild { None } else { original.clone() }; - let mut new_table_generation = None; + let takeover_generation = writer_lease + .as_ref() + .filter(|lease| lease.takeover) + .map(|_| next_generation()); + let mut paths = if rebuild || takeover_generation.is_some() { + None + } else { + original.clone() + }; + let mut new_table_generation = takeover_generation.clone(); let mut iterator = stories.into_iter(); let mut chunk_state = StorylineChunkState::default(); let mut next_storage_ordinal = if rebuild { @@ -745,6 +754,12 @@ impl StorylineLanceStore { let mut report = StorylineStreamImportReport::default(); let result = async { + if let Some(generation) = takeover_generation.as_deref() { + let source = original + .as_ref() + .context("missing committed Storyline generation during lease takeover")?; + paths = Some(self.clone_table_generation(source, generation).await?); + } loop { let Some(mut chunk) = next_storyline_stream_chunk( &mut iterator, @@ -939,7 +954,21 @@ impl StorylineLanceStore { objects_version: current.objects_version, projection, }; - let published = if mode == StorylineStreamWriteMode::CreateProjection { + let published = if let Some(lease) = &writer_lease { + let renewal = writer_renewal + .take() + .context("missing Storyline writer lease renewal")?; + anyhow::ensure!(renewal.stop().await, "Storyline writer lease lost"); + let published = self + .publish_writer_snapshot(&writer_owner, lease.lease.epoch, &snapshot) + .await?; + anyhow::ensure!( + published, + "Storyline writer lease lost while publishing generation {}", + snapshot.generation + ); + true + } else if mode == StorylineStreamWriteMode::CreateProjection { self.try_commit_snapshot(&snapshot, expected_generation.as_deref()) .await? } else { @@ -955,6 +984,27 @@ impl StorylineLanceStore { } .await; + let mut cleanup_failures = Vec::new(); + if let Some(renewal) = writer_renewal.take() { + if !renewal.stop().await { + cleanup_failures.push("writer lease renewal reported ownership loss".to_string()); + } + } + if result.is_err() { + if let Some(lease) = &writer_lease { + match self + .release_writer_lease(&writer_owner, lease.lease.epoch) + .await + { + Ok(true) => {} + Ok(false) => cleanup_failures + .push("writer lease was lost before error cleanup".to_string()), + Err(error) => cleanup_failures + .push(format!("release writer lease after error: {error:#}")), + } + } + } + if result.is_err() || matches!( &result, @@ -962,13 +1012,18 @@ impl StorylineLanceStore { ) { if let Some(generation) = new_table_generation { - let _ = self + if let Err(error) = self .object_store .remove_dir_all(self.generation_object_path(&generation)) - .await; + .await + { + cleanup_failures.push(format!( + "remove uncommitted Storyline generation {generation}: {error:#}" + )); + } } } - result + attach_stream_cleanup_failures(result, cleanup_failures) } /// Compact fragments, extend scalar indices to appended fragments, and @@ -979,88 +1034,170 @@ impl StorylineLanceStore { options: &LanceMaintenanceOptions, ) -> Result { let _guard = self.acquire_write_guard().await?; - let Some(paths) = self.resolve_current_table_paths().await? else { + let Some(original) = self.resolve_current_table_paths().await? else { return Ok(StorylineMaintenanceReport::default()); }; - let (runs, steps, tool_calls) = tokio::try_join!( - maintain_table_layout(&paths.runs, paths.runs_version, &RUN_INDEXES, options,), - maintain_table_layout(&paths.steps, paths.steps_version, &STEP_INDEXES, options,), - maintain_table_layout( - &paths.tool_calls, - paths.tool_calls_version, - &TOOL_CALL_INDEXES, - options, - ), - )?; - let runs_version = runs - .final_version - .context("missing maintained runs version")?; - let steps_version = steps - .final_version - .context("missing maintained steps version")?; - let tool_calls_version = tool_calls - .final_version - .context("missing maintained tool_calls version")?; - let run_content_columns = content_column_projection(StorylineTableKind::Runs); - let step_content_columns = content_column_projection(StorylineTableKind::Steps); - let tool_call_content_columns = content_column_projection(StorylineTableKind::ToolCalls); - let (run_batches, step_batches, tool_call_batches) = tokio::try_join!( - read_projected_batches(&paths.runs, runs_version, &run_content_columns, None), - read_projected_batches(&paths.steps, steps_version, &step_content_columns, None), - read_projected_batches( - &paths.tool_calls, - tool_calls_version, - &tool_call_content_columns, - None, - ), - )?; - let mut live_objects = collect_content_ids(&run_batches, StorylineTableKind::Runs)?; - live_objects.extend(collect_content_ids( - &step_batches, - StorylineTableKind::Steps, - )?); - live_objects.extend(collect_content_ids( - &tool_call_batches, - StorylineTableKind::ToolCalls, - )?); - let (objects_version, objects_removed) = - prune_unreferenced_objects(&paths.objects, paths.objects_version, &live_objects) - .await?; - let generation = next_generation(); - self.commit_snapshot( - &StorylineSnapshotPointer { + // Freeze deletion candidates before acquiring the lease. If this + // worker later loses ownership, a successor generation created after + // this point can never enter the stale worker's deletion set. + let expired_generations = self + .expired_generation_candidates(&original.table_generation, options.vacuum_older_than) + .await?; + let writer_owner = next_generation(); + let writer_lease = self + .acquire_writer_lease_for_generation(&writer_owner, Some(&original.generation)) + .await?; + let mut writer_renewal = + Some(self.start_writer_lease_renewal(writer_owner.clone(), writer_lease.lease.epoch)); + let takeover_generation = writer_lease.takeover.then(next_generation); + let mut published = false; + + let mut result: Result = async { + let paths = if let Some(generation) = takeover_generation.as_deref() { + self.clone_table_generation(&original, generation).await? + } else { + original.clone() + }; + let (runs, steps, tool_calls) = tokio::try_join!( + maintain_table_layout(&paths.runs, paths.runs_version, &RUN_INDEXES, options,), + maintain_table_layout(&paths.steps, paths.steps_version, &STEP_INDEXES, options,), + maintain_table_layout( + &paths.tool_calls, + paths.tool_calls_version, + &TOOL_CALL_INDEXES, + options, + ), + )?; + let runs_version = runs + .final_version + .context("missing maintained runs version")?; + let steps_version = steps + .final_version + .context("missing maintained steps version")?; + let tool_calls_version = tool_calls + .final_version + .context("missing maintained tool_calls version")?; + let run_content_columns = content_column_projection(StorylineTableKind::Runs); + let step_content_columns = content_column_projection(StorylineTableKind::Steps); + let tool_call_content_columns = + content_column_projection(StorylineTableKind::ToolCalls); + let (run_batches, step_batches, tool_call_batches) = tokio::try_join!( + read_projected_batches(&paths.runs, runs_version, &run_content_columns, None), + read_projected_batches(&paths.steps, steps_version, &step_content_columns, None), + read_projected_batches( + &paths.tool_calls, + tool_calls_version, + &tool_call_content_columns, + None, + ), + )?; + let mut live_objects = collect_content_ids(&run_batches, StorylineTableKind::Runs)?; + live_objects.extend(collect_content_ids( + &step_batches, + StorylineTableKind::Steps, + )?); + live_objects.extend(collect_content_ids( + &tool_call_batches, + StorylineTableKind::ToolCalls, + )?); + let (objects_version, objects_removed) = + prune_unreferenced_objects(&paths.objects, paths.objects_version, &live_objects) + .await?; + let generation = next_generation(); + let snapshot = StorylineSnapshotPointer { schema_version: STORYLINE_LANCE_SCHEMA_VERSION, generation: generation.clone(), - parent_generation: Some(paths.generation.clone()), + parent_generation: Some(original.generation.clone()), table_generation: paths.table_generation.clone(), runs_version, steps_version, tool_calls_version, objects_version, projection: paths.projection.clone(), - }, - Some(&paths.generation), - ) - .await?; + }; + let published_snapshot = self + .publish_writer_snapshot_retaining_lease( + &writer_owner, + writer_lease.lease.epoch, + &snapshot, + ) + .await?; + anyhow::ensure!( + published_snapshot, + "Storyline writer lease lost while publishing generation {}", + snapshot.generation + ); + published = true; + #[cfg(test)] + wait_after_maintenance_publish(&self.root_uri).await; + + let (runs_vacuum, steps_vacuum, tool_calls_vacuum) = tokio::try_join!( + vacuum_table(&paths.runs, options.vacuum_older_than), + vacuum_table(&paths.steps, options.vacuum_older_than), + vacuum_table(&paths.tool_calls, options.vacuum_older_than), + )?; + // Local stores remain protected by the cross-process file lock. + // Remote stores share objects.lance across physical generations, + // so vacuuming it could remove a version pinned by a successor + // after this lease expires. + let objects_vacuum = if matches!(self.storage_scheme(), "file" | "file+uring") { + vacuum_table(&paths.objects, options.vacuum_older_than).await? + } else { + LanceMaintenanceReport::default() + }; + let generations_removed = self + .prune_generation_candidates(expired_generations) + .await?; + Ok(StorylineMaintenanceReport { + generation: Some(generation), + runs: merge_maintenance_reports(runs, runs_vacuum), + steps: merge_maintenance_reports(steps, steps_vacuum), + tool_calls: merge_maintenance_reports(tool_calls, tool_calls_vacuum), + objects: objects_vacuum, + objects_removed, + generations_removed, + }) + } + .await; - let (runs_vacuum, steps_vacuum, tool_calls_vacuum, objects_vacuum) = tokio::try_join!( - vacuum_table(&paths.runs, options.vacuum_older_than), - vacuum_table(&paths.steps, options.vacuum_older_than), - vacuum_table(&paths.tool_calls, options.vacuum_older_than), - vacuum_table(&paths.objects, options.vacuum_older_than), - )?; - let generations_removed = self - .prune_expired_generations(&paths.table_generation, options.vacuum_older_than) - .await?; - Ok(StorylineMaintenanceReport { - generation: Some(generation), - runs: merge_maintenance_reports(runs, runs_vacuum), - steps: merge_maintenance_reports(steps, steps_vacuum), - tool_calls: merge_maintenance_reports(tool_calls, tool_calls_vacuum), - objects: objects_vacuum, - objects_removed, - generations_removed, - }) + let mut cleanup_failures = Vec::new(); + if let Some(renewal) = writer_renewal.take() { + if !renewal.stop().await { + cleanup_failures.push("writer lease renewal reported ownership loss".to_string()); + } + } + match self + .release_writer_lease(&writer_owner, writer_lease.lease.epoch) + .await + { + Ok(true) => {} + Ok(false) => cleanup_failures.push("writer lease was lost before release".to_string()), + Err(error) => cleanup_failures.push(format!("release writer lease: {error:#}")), + } + if result.is_err() && !published { + if let Some(generation) = takeover_generation { + if let Err(error) = self + .object_store + .remove_dir_all(self.generation_object_path(&generation)) + .await + { + cleanup_failures.push(format!( + "remove uncommitted Storyline generation {generation}: {error:#}" + )); + } + } + } + if !cleanup_failures.is_empty() { + let cleanup = format!( + "Storyline maintenance cleanup failed: {}", + cleanup_failures.join("; ") + ); + result = match result { + Ok(_) => Err(anyhow::anyhow!(cleanup)), + Err(error) => Err(error.context(cleanup)), + }; + } + result } /// Atomically replace multiple Storylines in one snapshot. @@ -1214,6 +1351,46 @@ impl StorylineLanceStore { } } + async fn clone_table_generation( + &self, + source: &StorylineTablePaths, + generation: &str, + ) -> Result { + let (run_batches, step_batches, tool_call_batches) = tokio::try_join!( + read_projected_batches(&source.runs, source.runs_version, &[], None), + read_projected_batches(&source.steps, source.steps_version, &[], None), + read_projected_batches(&source.tool_calls, source.tool_calls_version, &[], None,), + )?; + let mut cloned = self.paths_for_generation(generation); + let (runs_version, steps_version, tool_calls_version) = tokio::try_join!( + write_batches( + &cloned.runs, + run_batches, + story_runs_arrow_schema(), + &RUN_INDEXES, + ), + write_batches( + &cloned.steps, + step_batches, + story_steps_arrow_schema(), + &STEP_INDEXES, + ), + write_batches( + &cloned.tool_calls, + tool_call_batches, + story_tool_calls_arrow_schema(), + &TOOL_CALL_INDEXES, + ), + )?; + cloned.generation.clone_from(&source.generation); + cloned.runs_version = runs_version; + cloned.steps_version = steps_version; + cloned.tool_calls_version = tool_calls_version; + cloned.objects_version = source.objects_version; + cloned.projection.clone_from(&source.projection); + Ok(cloned) + } + fn generation_object_path(&self, generation: &str) -> ObjectPath { self.object_root .clone() @@ -1221,13 +1398,13 @@ impl StorylineLanceStore { .join(generation) } - async fn prune_expired_generations( + async fn expired_generation_candidates( &self, current: &str, retention: Option, - ) -> Result { + ) -> Result> { let Some(retention) = retention else { - return Ok(0); + return Ok(std::collections::BTreeSet::new()); }; let cutoff_nanos = SystemTime::now() .duration_since(UNIX_EPOCH) @@ -1262,6 +1439,13 @@ impl StorylineLanceStore { } } + Ok(candidates) + } + + async fn prune_generation_candidates( + &self, + candidates: std::collections::BTreeSet, + ) -> Result { let mut removed = 0; for generation in candidates { self.object_store @@ -1295,39 +1479,57 @@ impl StorylineLanceStore { snapshot: &StorylineSnapshotPointer, expected_generation: Option<&str>, ) -> Result { - let pointer = self.object_root.clone().join(CURRENT_FILE); - let contents = serde_json::to_vec(snapshot).context("encode Storyline snapshot pointer")?; - let current = self.read_current_pointer().await?; - let actual_generation = current - .pointer - .as_ref() - .map(|pointer| pointer.generation.as_str()); - if actual_generation != expected_generation { - return Ok(false); - } + self.try_publish_unleased_snapshot(snapshot, expected_generation) + .await + } +} - if matches!(self.storage_scheme(), "file" | "file+uring") { - write_local_current(self.root.join(CURRENT_FILE), contents).await?; - return Ok(true); - } +fn validate_snapshot_pointer(pointer: &StorylineSnapshotPointer) -> Result<()> { + anyhow::ensure!( + pointer.schema_version == STORYLINE_LANCE_SCHEMA_VERSION, + "unsupported Storyline Lance schema_version {}; expected {}", + pointer.schema_version, + STORYLINE_LANCE_SCHEMA_VERSION + ); + if let Some(projection) = &pointer.projection { + projection.validate()?; + } + validate_generation_name(&pointer.generation)?; + if let Some(parent) = &pointer.parent_generation { + validate_generation_name(parent)?; + } + validate_generation_name(&pointer.table_generation) +} - let mode = match current.version { - None => PutMode::Create, - Some(version) => PutMode::Update(version), - }; - match self - .object_store - .inner - .put_opts(&pointer, contents.into(), mode.into()) - .await - { - Ok(_) => Ok(true), - Err(ObjectStoreError::AlreadyExists { .. }) - | Err(ObjectStoreError::Precondition { .. }) => Ok(false), - Err(error) => Err(error) - .with_context(|| format!("commit Storyline generation {}", snapshot.generation)), - } +fn validate_current_control(control: &writer_control::StorylineCurrentControl) -> Result<()> { + anyhow::ensure!( + control.control_version == writer_control::CURRENT_CONTROL_VERSION, + "unsupported Storyline CURRENT control_version {}; expected {}", + control.control_version, + writer_control::CURRENT_CONTROL_VERSION + ); + if let Some(pointer) = &control.committed { + validate_snapshot_pointer(pointer)?; } + if let Some(lease) = &control.lease { + anyhow::ensure!( + !lease.owner_id.trim().is_empty(), + "Storyline writer lease owner must not be empty" + ); + anyhow::ensure!( + lease.expires_at_unix_ms > lease.issued_at_unix_ms, + "Storyline writer lease expiry must follow issuance" + ); + anyhow::ensure!( + lease.base_generation.as_deref() + == control + .committed + .as_ref() + .map(|pointer| pointer.generation.as_str()), + "Storyline writer lease base generation does not match committed generation" + ); + } + Ok(()) } async fn write_local_current(path: PathBuf, contents: Vec) -> Result<()> { diff --git a/crates/persisting-pchronicle/src/store/storyline/tests.rs b/crates/persisting-pchronicle/src/store/storyline/tests.rs index ed28d5a5..65b7db5e 100644 --- a/crates/persisting-pchronicle/src/store/storyline/tests.rs +++ b/crates/persisting-pchronicle/src/store/storyline/tests.rs @@ -36,6 +36,22 @@ fn non_create_publication_mismatch_is_an_operational_error() { .contains("non-create Storyline publication reported nonempty output")); } +#[test] +fn create_projection_cleanup_failure_is_not_silently_discarded() { + let error = attach_stream_cleanup_failures( + Ok(StorylineProjectionPublicationOutcome::OutputNotEmpty), + vec!["remove staged generation: denied".into()], + ) + .unwrap_err(); + + assert!( + error + .to_string() + .contains("remove staged generation: denied"), + "{error:#}" + ); +} + async fn put_remote_object(uri: &str, relative: &str, contents: &[u8]) { let (store, root) = ObjectStore::from_uri(uri).await.unwrap(); store.put(&root.join(relative), contents).await.unwrap(); @@ -89,6 +105,46 @@ impl Drop for CreateAfterEmptyReadBarrier { } } +struct MaintenanceAfterPublishPause { + reached: Arc, + resume: Arc, +} + +impl MaintenanceAfterPublishPause { + fn install(root_uri: &str) -> Self { + let reached = Arc::new(tokio::sync::Notify::new()); + let resume = Arc::new(tokio::sync::Notify::new()); + *MAINTENANCE_AFTER_PUBLISH_PAUSE + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) = + Some(MaintenanceAfterPublishPauseHook { + root_uri: root_uri.to_string(), + reached: reached.clone(), + resume: resume.clone(), + }); + Self { reached, resume } + } + + async fn wait_until_reached(&self) { + tokio::time::timeout(std::time::Duration::from_secs(10), self.reached.notified()) + .await + .expect("maintenance did not reach the post-publication pause"); + } + + fn resume(&self) { + self.resume.notify_one(); + } +} + +impl Drop for MaintenanceAfterPublishPause { + fn drop(&mut self) { + self.resume.notify_waiters(); + *MAINTENANCE_AFTER_PUBLISH_PAUSE + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) = None; + } +} + fn story(session_id: &str) -> StorylineDocument { StorylineDocument { schema_version: crate::model::STORYLINE_SCHEMA_VERSION.into(), @@ -665,6 +721,42 @@ async fn maintenance_vacuums_unreferenced_objects() { ); } +#[tokio::test] +async fn remote_maintenance_preserves_shared_object_versions() { + let uri = remote_uri("remote-maintenance-object-versions"); + let options = StorylineContentOptions { + offload_threshold: 32, + ..Default::default() + }; + let store = StorylineLanceStore::open_uri_with_content_options(&uri, options) + .await + .unwrap(); + let mut document = story("remote-vacuum-objects"); + document.notes = Some("old unreachable object ".repeat(64)); + store.replace_storyline(&document).await.unwrap(); + document.notes = Some("new live object ".repeat(64)); + store.replace_storyline(&document).await.unwrap(); + + let report = store + .maintain(&LanceMaintenanceOptions { + vacuum_older_than: Some(std::time::Duration::ZERO), + ..Default::default() + }) + .await + .unwrap(); + + assert_eq!(report.objects_removed, 1); + assert_eq!(report.objects.old_versions_removed, 0); + assert_eq!(report.objects.bytes_removed, 0); + assert_eq!( + store + .get_storyline_full("remote-vacuum-objects") + .await + .unwrap(), + Some(document) + ); +} + #[tokio::test] async fn maintenance_prunes_expired_physical_generations() { let dir = tempfile::tempdir().unwrap(); @@ -1439,6 +1531,296 @@ async fn object_store_uri_round_trips_across_store_instances() { .starts_with("shared-memory://pchronicle-storyline-")); } +#[tokio::test] +async fn generation_mismatch_releases_the_lease_immediately() { + let uri = remote_uri("generation-mismatch-release"); + let store = StorylineLanceStore::open_uri(&uri).await.unwrap(); + store.replace_storyline(&story("baseline")).await.unwrap(); + + let error = store + .acquire_writer_lease_for_generation("mismatched", Some("gen-stale-1-1")) + .await + .unwrap_err(); + + assert!(error.to_string().contains("commit conflict"), "{error:#}"); + assert!(store + .read_current_control() + .await + .unwrap() + .control + .lease + .is_none()); +} + +#[tokio::test] +async fn live_writer_lease_rejects_replacement_before_table_mutation() { + let uri = remote_uri("live-lease-before-table-mutation"); + let store = StorylineLanceStore::open_uri(&uri).await.unwrap(); + store.replace_storyline(&story("baseline")).await.unwrap(); + let paths = store.current_table_paths().await.unwrap().unwrap(); + let before = tokio::join!( + latest_table_version(&paths.runs), + latest_table_version(&paths.steps), + latest_table_version(&paths.tool_calls), + ); + store + .try_acquire_writer_lease( + "holder", + writer_control::unix_now_ms(), + writer_control::WRITER_LEASE_TTL_MS, + ) + .await + .unwrap(); + + let error = store + .replace_storyline(&story("must-not-mutate")) + .await + .unwrap_err(); + assert!(error.to_string().contains("commit conflict"), "{error:#}"); + let after = tokio::join!( + latest_table_version(&paths.runs), + latest_table_version(&paths.steps), + latest_table_version(&paths.tool_calls), + ); + assert_eq!( + (before.0.unwrap(), before.1.unwrap(), before.2.unwrap()), + (after.0.unwrap(), after.1.unwrap(), after.2.unwrap()) + ); +} + +#[tokio::test] +async fn live_writer_lease_rejects_maintenance_before_table_mutation() { + let uri = remote_uri("live-lease-before-maintenance-mutation"); + let store = StorylineLanceStore::open_uri(&uri).await.unwrap(); + for revision in 0..4 { + let mut document = story("baseline"); + document.notes = Some(format!("revision {revision}")); + store.replace_storyline(&document).await.unwrap(); + } + let paths = store.current_table_paths().await.unwrap().unwrap(); + let before = tokio::join!( + latest_table_version(&paths.runs), + latest_table_version(&paths.steps), + latest_table_version(&paths.tool_calls), + ); + store + .try_acquire_writer_lease( + "holder", + writer_control::unix_now_ms(), + writer_control::WRITER_LEASE_TTL_MS, + ) + .await + .unwrap(); + + let error = store + .maintain(&LanceMaintenanceOptions { + compact: true, + optimize_indices: true, + vacuum_older_than: None, + target_rows_per_fragment: 1024, + }) + .await + .unwrap_err(); + + assert!(error.to_string().contains("commit conflict"), "{error:#}"); + let after = tokio::join!( + latest_table_version(&paths.runs), + latest_table_version(&paths.steps), + latest_table_version(&paths.tool_calls), + ); + assert_eq!( + (before.0.unwrap(), before.1.unwrap(), before.2.unwrap()), + (after.0.unwrap(), after.1.unwrap(), after.2.unwrap()) + ); +} + +#[tokio::test] +async fn expired_lease_takeover_clones_the_committed_generation() { + let uri = remote_uri("expired-lease-isolation"); + let store = StorylineLanceStore::open_uri(&uri).await.unwrap(); + let preserved = story("preserved"); + store + .replace_storylines(&[preserved.clone(), story("updated")]) + .await + .unwrap(); + let before = store.current_table_paths().await.unwrap().unwrap(); + store + .try_acquire_writer_lease("expired", 0, 1) + .await + .unwrap(); + let mut updated = story("updated"); + updated.notes = Some("after takeover".into()); + + store.replace_storyline(&updated).await.unwrap(); + + let after = store.current_table_paths().await.unwrap().unwrap(); + assert_ne!(after.table_generation, before.table_generation); + assert_eq!( + store.get_storyline_full("preserved").await.unwrap(), + Some(preserved) + ); + assert_eq!( + store.get_storyline_full("updated").await.unwrap(), + Some(updated) + ); +} + +#[tokio::test] +async fn expired_lease_maintenance_clones_the_committed_generation() { + let uri = remote_uri("expired-maintenance-lease-isolation"); + let store = StorylineLanceStore::open_uri(&uri).await.unwrap(); + let documents = [story("preserved"), story("maintained")]; + store.replace_storylines(&documents).await.unwrap(); + let before = store.current_table_paths().await.unwrap().unwrap(); + store + .try_acquire_writer_lease("expired", 0, 1) + .await + .unwrap(); + + store + .maintain(&LanceMaintenanceOptions { + vacuum_older_than: None, + ..Default::default() + }) + .await + .unwrap(); + + let after = store.current_table_paths().await.unwrap().unwrap(); + assert_ne!(after.table_generation, before.table_generation); + for document in documents { + assert_eq!( + store + .get_storyline_full(&document.session_id) + .await + .unwrap(), + Some(document) + ); + } +} + +#[tokio::test] +async fn stale_maintenance_cannot_prune_a_successor_generation() { + let uri = remote_uri("stale-maintenance-pruning"); + let store = StorylineLanceStore::open_uri(&uri).await.unwrap(); + let document = story("survives-stale-maintenance"); + store.replace_storyline(&document).await.unwrap(); + let pause = MaintenanceAfterPublishPause::install(&uri); + let maintenance_store = store.clone(); + let maintenance = tokio::spawn(async move { + maintenance_store + .maintain(&LanceMaintenanceOptions { + vacuum_older_than: Some(std::time::Duration::ZERO), + ..Default::default() + }) + .await + }); + pause.wait_until_reached().await; + + let current = store.current_table_paths().await.unwrap().unwrap(); + let mut expired_control = store.read_current_control().await.unwrap().control; + expired_control.revision += 1; + let expired_lease = expired_control + .lease + .as_mut() + .expect("maintenance must retain its lease after publication"); + expired_lease.issued_at_unix_ms = 0; + expired_lease.expires_at_unix_ms = 1; + put_remote_object( + &uri, + CURRENT_FILE, + &serde_json::to_vec(&expired_control).unwrap(), + ) + .await; + + let successor_owner = "successor"; + let successor_lease = match store + .try_acquire_writer_lease( + successor_owner, + writer_control::unix_now_ms(), + writer_control::WRITER_LEASE_TTL_MS, + ) + .await + .unwrap() + { + writer_control::LeaseAcquireOutcome::Acquired(lease) => lease, + writer_control::LeaseAcquireOutcome::Held(lease) => { + panic!( + "expired maintenance lease remained held by {}", + lease.owner_id + ) + } + }; + assert!(successor_lease.takeover); + let successor_table_generation = next_generation(); + let cloned = store + .clone_table_generation(¤t, &successor_table_generation) + .await + .unwrap(); + let successor_generation = next_generation(); + let successor = StorylineSnapshotPointer { + schema_version: STORYLINE_LANCE_SCHEMA_VERSION, + generation: successor_generation.clone(), + parent_generation: Some(current.generation), + table_generation: successor_table_generation.clone(), + runs_version: cloned.runs_version, + steps_version: cloned.steps_version, + tool_calls_version: cloned.tool_calls_version, + objects_version: cloned.objects_version, + projection: cloned.projection, + }; + assert!(store + .publish_writer_snapshot(successor_owner, successor_lease.lease.epoch, &successor,) + .await + .unwrap()); + + pause.resume(); + let error = maintenance.await.unwrap().unwrap_err(); + assert!( + error.to_string().contains("lease was lost"), + "unexpected stale maintenance result: {error:#}" + ); + let after = store.current_table_paths().await.unwrap().unwrap(); + assert_eq!(after.generation, successor_generation); + assert_eq!(after.table_generation, successor_table_generation); + assert_eq!( + store + .get_storyline_full("survives-stale-maintenance") + .await + .unwrap(), + Some(document) + ); +} + +#[tokio::test] +async fn legacy_current_pointer_upgrades_on_first_replacement() { + let uri = remote_uri("legacy-current-upgrade"); + let store = StorylineLanceStore::open_uri(&uri).await.unwrap(); + store.replace_storyline(&story("baseline")).await.unwrap(); + let legacy = store + .read_current_control() + .await + .unwrap() + .control + .committed + .unwrap(); + put_remote_object(&uri, CURRENT_FILE, &serde_json::to_vec(&legacy).unwrap()).await; + + let replacement = story("after-upgrade"); + store.replace_storyline(&replacement).await.unwrap(); + + let control = store.read_current_control().await.unwrap().control; + assert_eq!( + control.control_version, + writer_control::CURRENT_CONTROL_VERSION + ); + assert!(control.revision > 0); + assert!(control.lease.is_none()); + assert_eq!( + store.get_storyline_full("after-upgrade").await.unwrap(), + Some(replacement) + ); +} + #[tokio::test] async fn object_store_rejects_invalid_utf8_unsafe_and_dangling_current() { let cases: [(&str, &[u8], &str); 3] = [ diff --git a/crates/persisting-pchronicle/src/store/storyline/writer_control.rs b/crates/persisting-pchronicle/src/store/storyline/writer_control.rs new file mode 100644 index 00000000..328695f1 --- /dev/null +++ b/crates/persisting-pchronicle/src/store/storyline/writer_control.rs @@ -0,0 +1,628 @@ +use anyhow::{Context, Result}; +use object_store::path::Path as ObjectPath; +use object_store::{Error as ObjectStoreError, ObjectStoreExt, PutMode, UpdateVersion}; +use serde::{Deserialize, Serialize}; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::Arc; + +use super::{ + validate_current_control, write_local_current, StorylineLanceStore, StorylineSnapshotPointer, + CURRENT_FILE, +}; + +const CONTROL_CAS_RETRIES: usize = 32; +pub(super) const WRITER_LEASE_TTL_MS: u64 = 60_000; +pub(super) const CURRENT_CONTROL_VERSION: u32 = 1; + +pub(super) fn unix_now_ms() -> u64 { + u64::try_from( + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_millis(), + ) + .unwrap_or(u64::MAX) +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub(super) struct StorylineCurrentControl { + pub(super) control_version: u32, + pub(super) revision: u64, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub(super) committed: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub(super) lease: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub(super) struct StorylineWriterLease { + pub(super) epoch: u64, + pub(super) owner_id: String, + pub(super) issued_at_unix_ms: u64, + pub(super) expires_at_unix_ms: u64, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub(super) base_generation: Option, +} + +#[derive(Debug, Clone)] +pub(super) struct CurrentControlState { + pub(super) control: StorylineCurrentControl, + pub(super) version: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(super) struct AcquiredLease { + pub(super) lease: StorylineWriterLease, + pub(super) takeover: bool, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(super) enum LeaseAcquireOutcome { + Acquired(AcquiredLease), + Held(StorylineWriterLease), +} + +pub(super) struct WriterLeaseRenewal { + lost: Arc, + stop: Option>, + task: Option>, +} + +impl WriterLeaseRenewal { + pub(super) async fn stop(mut self) -> bool { + if let Some(stop) = self.stop.take() { + let _ = stop.send(()); + } + if let Some(task) = self.task.take() { + let _ = task.await; + } + !self.lost.load(Ordering::Acquire) + } +} + +impl Drop for WriterLeaseRenewal { + fn drop(&mut self) { + if let Some(stop) = self.stop.take() { + let _ = stop.send(()); + } + } +} + +pub(super) fn empty_control() -> StorylineCurrentControl { + StorylineCurrentControl { + control_version: CURRENT_CONTROL_VERSION, + revision: 0, + committed: None, + lease: None, + } +} + +pub(super) fn decode_control(contents: &str) -> Result { + let value = serde_json::from_str::(contents) + .context("decode Storyline CURRENT JSON")?; + if value.get("control_version").is_some() { + let control = serde_json::from_value::(value) + .context("decode Storyline CURRENT control envelope")?; + anyhow::ensure!( + control.control_version == CURRENT_CONTROL_VERSION, + "unsupported Storyline CURRENT control_version {}; expected {}", + control.control_version, + CURRENT_CONTROL_VERSION + ); + return Ok(control); + } + let pointer = serde_json::from_value::(value) + .context("decode Storyline snapshot pointer")?; + Ok(StorylineCurrentControl { + committed: Some(pointer), + ..empty_control() + }) +} + +pub(super) fn acquire_transition( + current: &StorylineCurrentControl, + owner_id: &str, + now_unix_ms: u64, + ttl_ms: u64, +) -> Result<(LeaseAcquireOutcome, Option)> { + anyhow::ensure!( + !owner_id.trim().is_empty(), + "writer lease owner must not be empty" + ); + anyhow::ensure!(ttl_ms > 0, "writer lease TTL must be positive"); + if let Some(lease) = ¤t.lease { + if lease.expires_at_unix_ms > now_unix_ms { + return Ok((LeaseAcquireOutcome::Held(lease.clone()), None)); + } + } + let revision = current + .revision + .checked_add(1) + .context("Storyline CURRENT revision overflow")?; + let lease = StorylineWriterLease { + epoch: revision, + owner_id: owner_id.to_string(), + issued_at_unix_ms: now_unix_ms, + expires_at_unix_ms: now_unix_ms.saturating_add(ttl_ms), + base_generation: current + .committed + .as_ref() + .map(|pointer| pointer.generation.clone()), + }; + let acquired = AcquiredLease { + lease: lease.clone(), + takeover: current.lease.is_some(), + }; + let mut next = current.clone(); + next.revision = revision; + next.lease = Some(lease); + Ok((LeaseAcquireOutcome::Acquired(acquired), Some(next))) +} + +fn owns_lease(current: &StorylineCurrentControl, owner_id: &str, epoch: u64) -> bool { + current.lease.as_ref().is_some_and(|lease| { + lease.owner_id == owner_id + && lease.epoch == epoch + && lease.base_generation + == current + .committed + .as_ref() + .map(|pointer| pointer.generation.clone()) + }) +} + +fn publish_transition_with_lease( + current: &StorylineCurrentControl, + owner_id: &str, + epoch: u64, + now_unix_ms: u64, + snapshot: &StorylineSnapshotPointer, + retain_lease: bool, +) -> Result> { + if !owns_lease(current, owner_id, epoch) + || current + .lease + .as_ref() + .is_none_or(|lease| lease.expires_at_unix_ms <= now_unix_ms) + { + return Ok(None); + } + let mut next = current.clone(); + next.revision = next + .revision + .checked_add(1) + .context("Storyline CURRENT revision overflow")?; + next.committed = Some(snapshot.clone()); + if retain_lease { + if let Some(lease) = next.lease.as_mut() { + lease.base_generation = Some(snapshot.generation.clone()); + } + } else { + next.lease = None; + } + Ok(Some(next)) +} + +pub(super) fn publish_transition( + current: &StorylineCurrentControl, + owner_id: &str, + epoch: u64, + now_unix_ms: u64, + snapshot: &StorylineSnapshotPointer, +) -> Result> { + publish_transition_with_lease(current, owner_id, epoch, now_unix_ms, snapshot, false) +} + +pub(super) fn publish_and_retain_lease_transition( + current: &StorylineCurrentControl, + owner_id: &str, + epoch: u64, + now_unix_ms: u64, + snapshot: &StorylineSnapshotPointer, +) -> Result> { + publish_transition_with_lease(current, owner_id, epoch, now_unix_ms, snapshot, true) +} + +pub(super) fn renew_transition( + current: &StorylineCurrentControl, + owner_id: &str, + epoch: u64, + now_unix_ms: u64, + ttl_ms: u64, +) -> Result> { + anyhow::ensure!(ttl_ms > 0, "writer lease TTL must be positive"); + if !owns_lease(current, owner_id, epoch) + || current + .lease + .as_ref() + .is_none_or(|lease| lease.expires_at_unix_ms <= now_unix_ms) + { + return Ok(None); + } + let mut next = current.clone(); + next.revision = next + .revision + .checked_add(1) + .context("Storyline CURRENT revision overflow")?; + if let Some(lease) = next.lease.as_mut() { + lease.expires_at_unix_ms = now_unix_ms.saturating_add(ttl_ms); + } + Ok(Some(next)) +} + +pub(super) fn release_transition( + current: &StorylineCurrentControl, + owner_id: &str, + epoch: u64, +) -> Result> { + if !owns_lease(current, owner_id, epoch) { + return Ok(None); + } + let mut next = current.clone(); + next.revision = next + .revision + .checked_add(1) + .context("Storyline CURRENT revision overflow")?; + next.lease = None; + Ok(Some(next)) +} + +pub(super) fn unleased_publish_transition( + current: &StorylineCurrentControl, + expected_generation: Option<&str>, + snapshot: &StorylineSnapshotPointer, +) -> Result> { + if current.lease.is_some() + || current + .committed + .as_ref() + .map(|pointer| pointer.generation.as_str()) + != expected_generation + { + return Ok(None); + } + let mut next = current.clone(); + next.revision = next + .revision + .checked_add(1) + .context("Storyline CURRENT revision overflow")?; + next.committed = Some(snapshot.clone()); + Ok(Some(next)) +} + +impl StorylineLanceStore { + pub(super) async fn read_current_control(&self) -> Result { + let pointer = self.object_root.clone().join(CURRENT_FILE); + let result = match self.object_store.inner.get(&pointer).await { + Ok(result) => result, + Err(ObjectStoreError::NotFound { .. }) => { + return Ok(CurrentControlState { + control: empty_control(), + version: None, + }); + } + Err(error) => { + return Err(error).with_context(|| { + format!("read Storyline commit pointer {}/CURRENT", self.root_uri) + }); + } + }; + let version = UpdateVersion { + e_tag: result.meta.e_tag.clone(), + version: result.meta.version.clone(), + }; + let contents = result + .bytes() + .await + .with_context(|| format!("read Storyline commit pointer {}/CURRENT", self.root_uri))?; + let contents = std::str::from_utf8(&contents) + .context("Storyline commit pointer is not valid UTF-8")? + .trim(); + if !contents.starts_with('{') { + super::validate_generation_name(contents)?; + anyhow::bail!( + "Storyline generation '{contents}' is incomplete: CURRENT must pin all table and object versions" + ); + } + let control = decode_control(contents)?; + validate_current_control(&control)?; + Ok(CurrentControlState { + control, + version: Some(version), + }) + } + + async fn try_write_current_control( + &self, + control: &StorylineCurrentControl, + expected: Option, + ) -> Result { + validate_current_control(control)?; + let contents = serde_json::to_vec(control).context("encode Storyline CURRENT control")?; + if matches!(self.storage_scheme(), "file" | "file+uring") { + write_local_current(self.root.join(CURRENT_FILE), contents).await?; + return Ok(true); + } + let pointer: ObjectPath = self.object_root.clone().join(CURRENT_FILE); + let mode = match expected { + None => PutMode::Create, + Some(version) => PutMode::Update(version), + }; + match self + .object_store + .inner + .put_opts(&pointer, contents.into(), mode.into()) + .await + { + Ok(_) => Ok(true), + Err(ObjectStoreError::AlreadyExists { .. }) + | Err(ObjectStoreError::Precondition { .. }) => Ok(false), + Err(error) => Err(error) + .with_context(|| format!("update Storyline CURRENT control for {}", self.root_uri)), + } + } + + pub(super) async fn try_acquire_writer_lease( + &self, + owner_id: &str, + now_unix_ms: u64, + ttl_ms: u64, + ) -> Result { + let _control_guard = self.control_lock.lock().await; + for _ in 0..CONTROL_CAS_RETRIES { + let current = self.read_current_control().await?; + let (outcome, next) = + acquire_transition(¤t.control, owner_id, now_unix_ms, ttl_ms)?; + let Some(next) = next else { + return Ok(outcome); + }; + if self + .try_write_current_control(&next, current.version) + .await? + { + return Ok(outcome); + } + } + anyhow::bail!("Storyline commit conflict while acquiring writer lease") + } + + pub(super) async fn acquire_writer_lease_for_generation( + &self, + owner_id: &str, + expected_generation: Option<&str>, + ) -> Result { + let acquired = match self + .try_acquire_writer_lease(owner_id, unix_now_ms(), WRITER_LEASE_TTL_MS) + .await? + { + LeaseAcquireOutcome::Held(_) => { + anyhow::bail!("Storyline commit conflict while acquiring writer lease") + } + LeaseAcquireOutcome::Acquired(acquired) => acquired, + }; + if acquired.lease.base_generation.as_deref() == expected_generation { + return Ok(acquired); + } + let conflict = anyhow::anyhow!("Storyline commit conflict while acquiring writer lease"); + match self + .release_writer_lease(owner_id, acquired.lease.epoch) + .await + { + Ok(true) => Err(conflict), + Ok(false) => Err(conflict.context("mismatched writer lease was lost before release")), + Err(error) => Err(conflict.context(format!( + "failed to release mismatched writer lease: {error:#}" + ))), + } + } + + async fn transition_current_control( + &self, + transition: impl Fn(&StorylineCurrentControl) -> Result>, + ) -> Result { + let _control_guard = self.control_lock.lock().await; + for _ in 0..CONTROL_CAS_RETRIES { + let current = self.read_current_control().await?; + let Some(next) = transition(¤t.control)? else { + return Ok(false); + }; + if self + .try_write_current_control(&next, current.version) + .await? + { + return Ok(true); + } + } + Ok(false) + } + + pub(super) async fn publish_writer_snapshot( + &self, + owner_id: &str, + epoch: u64, + snapshot: &StorylineSnapshotPointer, + ) -> Result { + self.transition_current_control(|current| { + publish_transition(current, owner_id, epoch, unix_now_ms(), snapshot) + }) + .await + } + + pub(super) async fn publish_writer_snapshot_retaining_lease( + &self, + owner_id: &str, + epoch: u64, + snapshot: &StorylineSnapshotPointer, + ) -> Result { + self.transition_current_control(|current| { + publish_and_retain_lease_transition(current, owner_id, epoch, unix_now_ms(), snapshot) + }) + .await + } + + pub(super) async fn renew_writer_lease( + &self, + owner_id: &str, + epoch: u64, + now_unix_ms: u64, + ttl_ms: u64, + ) -> Result { + self.transition_current_control(|current| { + renew_transition(current, owner_id, epoch, now_unix_ms, ttl_ms) + }) + .await + } + + pub(super) fn start_writer_lease_renewal( + &self, + owner_id: String, + epoch: u64, + ) -> WriterLeaseRenewal { + let store = self.clone(); + let lost = Arc::new(AtomicBool::new(false)); + let task_lost = lost.clone(); + let (stop, mut stopped) = tokio::sync::oneshot::channel(); + let interval = std::time::Duration::from_millis(WRITER_LEASE_TTL_MS / 3); + let task = tokio::spawn(async move { + loop { + tokio::select! { + _ = tokio::time::sleep(interval) => { + match store + .renew_writer_lease( + &owner_id, + epoch, + unix_now_ms(), + WRITER_LEASE_TTL_MS, + ) + .await + { + Ok(true) => {} + Ok(false) | Err(_) => { + task_lost.store(true, Ordering::Release); + break; + } + } + } + _ = &mut stopped => break, + } + } + }); + WriterLeaseRenewal { + lost, + stop: Some(stop), + task: Some(task), + } + } + + pub(super) async fn release_writer_lease(&self, owner_id: &str, epoch: u64) -> Result { + self.transition_current_control(|current| release_transition(current, owner_id, epoch)) + .await + } + + pub(super) async fn try_publish_unleased_snapshot( + &self, + snapshot: &StorylineSnapshotPointer, + expected_generation: Option<&str>, + ) -> Result { + self.transition_current_control(|current| { + unleased_publish_transition(current, expected_generation, snapshot) + }) + .await + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn pointer(generation: &str) -> StorylineSnapshotPointer { + StorylineSnapshotPointer { + schema_version: 2, + generation: generation.into(), + parent_generation: None, + table_generation: generation.into(), + runs_version: 1, + steps_version: 1, + tool_calls_version: 1, + objects_version: 1, + projection: None, + } + } + + fn leased_control() -> StorylineCurrentControl { + StorylineCurrentControl { + control_version: CURRENT_CONTROL_VERSION, + revision: 8, + committed: Some(pointer("gen-1-1-1")), + lease: Some(StorylineWriterLease { + epoch: 6, + owner_id: "writer".into(), + issued_at_unix_ms: 100, + expires_at_unix_ms: 300, + base_generation: Some("gen-1-1-1".into()), + }), + } + } + + #[test] + fn legacy_pointer_decodes_as_committed_control() { + let control = + decode_control(&serde_json::to_string(&pointer("gen-1-1-1")).unwrap()).unwrap(); + assert_eq!(control.committed, Some(pointer("gen-1-1-1"))); + assert!(control.lease.is_none()); + } + + #[test] + fn live_foreign_lease_is_held_without_mutation() { + let current = leased_control(); + let (outcome, next) = acquire_transition(¤t, "right", 150, 100).unwrap(); + assert!(matches!(outcome, LeaseAcquireOutcome::Held(_))); + assert!(next.is_none()); + } + + #[test] + fn expired_lease_takeover_advances_epoch() { + let current = leased_control(); + let (outcome, next) = acquire_transition(¤t, "right", 300, 100).unwrap(); + let LeaseAcquireOutcome::Acquired(acquired) = outcome else { + panic!("expired lease must be acquired"); + }; + assert!(acquired.takeover); + assert_eq!(acquired.lease.epoch, 9); + assert_eq!(next.unwrap().revision, 9); + } + + #[test] + fn stale_or_expired_owner_cannot_publish_or_release() { + let current = leased_control(); + assert!( + publish_transition(¤t, "old", 5, 200, &pointer("gen-2-1-1")) + .unwrap() + .is_none() + ); + assert!( + publish_transition(¤t, "writer", 6, 300, &pointer("gen-2-1-1")) + .unwrap() + .is_none() + ); + assert!(release_transition(¤t, "old", 5).unwrap().is_none()); + } + + #[test] + fn renewal_and_retained_publication_preserve_ownership() { + let current = leased_control(); + let renewed = renew_transition(¤t, "writer", 6, 200, 100) + .unwrap() + .unwrap(); + assert_eq!(renewed.lease.as_ref().unwrap().expires_at_unix_ms, 300); + let mut snapshot = pointer("gen-2-1-1"); + snapshot.parent_generation = Some("gen-1-1-1".into()); + let published = publish_and_retain_lease_transition(&renewed, "writer", 6, 250, &snapshot) + .unwrap() + .unwrap(); + assert_eq!( + published.lease.unwrap().base_generation.as_deref(), + Some("gen-2-1-1") + ); + } +} diff --git a/docs/src/pchronicle/guides/serve.md b/docs/src/pchronicle/guides/serve.md index ce2e856a..0e65784a 100644 --- a/docs/src/pchronicle/guides/serve.md +++ b/docs/src/pchronicle/guides/serve.md @@ -46,11 +46,17 @@ authenticated Control protocol used by pPilot and pVisor: ```bash pchronicle serve --storage ./trajectory-data --control 127.0.0.1:0 +pchronicle serve --storage ./tmp --storage ./data/evals --listen 127.0.0.1:9980 +pchronicle serve --storage default=./tmp --storage evals=./data --control 127.0.0.1:0 ``` `--config` and `--storage` are mutually exclusive, and `--control` requires -`--storage`. The process writes one machine-readable readiness record to -stdout; its Control token is never written to stderr. +`--storage`. One `--storage URI` mounts a Dataset named `default`. Repeat +`--storage` to mount several Datasets; each default name is the URI's last +path component, and `NAME=URI` overrides it. `--control` uses the mount named +`default` (the implicit name for a single bare URI, or an explicit +`default=URI` among several). The process writes one machine-readable +readiness record to stdout; its Control token is never written to stderr. For `--storage`, `serve` first discovers validated non-empty canonical `events.lance` Stores and converges each deterministic sibling `storyline`; diff --git a/docs/src/pchronicle/guides/serve.zh.md b/docs/src/pchronicle/guides/serve.zh.md index 4f97abcd..818947c1 100644 --- a/docs/src/pchronicle/guides/serve.zh.md +++ b/docs/src/pchronicle/guides/serve.zh.md @@ -42,10 +42,15 @@ pchronicle serve --config warehouse.toml \ ```bash pchronicle serve --storage ./trajectory-data --control 127.0.0.1:0 +pchronicle serve --storage ./tmp --storage ./data/evals --listen 127.0.0.1:9980 +pchronicle serve --storage default=./tmp --storage evals=./data --control 127.0.0.1:0 ``` -`--config` 与 `--storage` 互斥,`--control` 要求使用 `--storage`。进程只向 stdout 写一条 -机器可读的 readiness 记录,Control token 不会写入 stderr。 +`--config` 与 `--storage` 互斥,`--control` 要求使用 `--storage`。只传一次 +`--storage URI` 时,Dataset 名为 `default`。重复 `--storage` 会挂载多个 Dataset; +默认名是 URI 的最后一段路径,也可用 `NAME=URI` 覆盖。`--control` 只绑定名为 +`default` 的挂载(单次裸 URI 会隐式使用该名;多次时需显式 `default=URI`)。 +进程只向 stdout 写一条机器可读的 readiness 记录,Control token 不会写入 stderr。 使用 `--storage` 时,`serve` 会先发现经过验证且非空的 canonical `events.lance` Store, 将每个投影收敛到确定的同级 `storyline`,全部 startup target 变为 fresh 后才输出 readiness。 diff --git a/docs/src/pchronicle/reference/cli.md b/docs/src/pchronicle/reference/cli.md index 1aebe6e9..56ae6204 100644 --- a/docs/src/pchronicle/reference/cli.md +++ b/docs/src/pchronicle/reference/cli.md @@ -273,14 +273,17 @@ uri = "../data/atif" ```bash pchronicle serve --config warehouse.toml --listen 127.0.0.1:8081 --open pchronicle serve --storage ./trajectory-data --control 127.0.0.1:0 +pchronicle serve --storage ./tmp --storage ./data/evals --listen 127.0.0.1:9980 ``` Relative local Dataset paths are resolved from the configuration file's directory. At least one of `--listen`, `--control`, or `--gateway` is required. `--config` and `--storage` are mutually exclusive: configuration mounts named -Datasets, while `--storage URI` mounts one Dataset named `default`. `--listen` -enables Warehouse HTTP; omitting it does not start Warehouse. `--control` -requires `--storage` and enables the authenticated write/control protocol on a +Datasets, while `--storage URI` mounts one Dataset named `default`. Repeat +`--storage` to mount several Datasets named from each URI's last path +component; `NAME=URI` overrides that name. `--listen` enables Warehouse HTTP; +omitting it does not start Warehouse. `--control` requires `--storage` and +uses the Dataset named `default` as the authenticated write/control root on a loopback listener. `--open` requires `--listen`. Warehouse rejects non-loopback listeners because it has no authentication. Its diff --git a/docs/superpowers/plans/2026-08-19-storyline-unknown-fields.md b/docs/superpowers/plans/2026-08-19-storyline-unknown-fields.md deleted file mode 100644 index 54385b49..00000000 --- a/docs/superpowers/plans/2026-08-19-storyline-unknown-fields.md +++ /dev/null @@ -1,877 +0,0 @@ -# Storyline Unified Unknown Fields Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Replace `StorylinePresence` and format-specific residual blobs with one bounded, namespaced unknown-fields mechanism that survives same-format, cross-format, and Storyline Lance round trips. - -**Architecture:** `StorylineDocument` owns sparse RFC 6901 pointer maps grouped by source format and source document. A shared codec utility validates pointers, computes per-trajectory wildcard counts, enforces the 4096/1 MiB admission limits, carries foreign residuals in a `_storyline` envelope, and restores target-format residuals without overwriting canonical Storyline fields. Lance stores the maps on the run row and externalizes large individual values into the existing content-addressed `objects.lance` store. - -**Tech Stack:** Rust 2021, serde/serde_json/serde_yaml, BLAKE3, Arrow, Lance 9, DataFusion, cargo test. - -**Spec:** `docs/superpowers/specs/2026-08-19-storyline-unknown-fields-design.md` - -## Global Constraints - -- Known source fields with missing and explicit `null` are equivalent and produce no residual entry. -- An unknown key whose value is `null` is retained because the key itself is unknown to Storyline. -- Exact locations use RFC 6901 JSON Pointer; wildcard paths are derived only through format-aware array positions. -- Admission defaults are 4096 entries and 1 MiB of source IDs, pointers, and compact JSON values per Storyline across all sources. -- Exceeding either admission limit rejects the complete Storyline; truncation and unlimited configuration are forbidden. -- `_storyline` is reserved for transport, never recaptured as an unknown source key. -- Target-format canonical fields win; conflicting residual values fail closed with source, document ID, trajectory, and pointer context. -- Physical single-object/array/JSONL shape is canonicalized by the target codec and is not retained. -- Logical document-level residual is copied into every Storyline split from that document; large repeated values may deduplicate only inside Lance `objects.lance`. -- Do not modify TTAS, Queue/Sampler, Search, or `persisting-dlcapt`. -- Preserve the user's current edits in `crates/persisting-pchronicle/src/store/files/atif_stream.rs`, `crates/persisting-pchronicle/src/store/files/mod.rs`, and `crates/persisting-pchronicle/src/store/files/json_stream.rs`; the projected ATIF query path is outside this feature. -- Use targeted `persisting-pchronicle` tests with `--no-default-features` or `--features lance-store`; do not use workspace-wide acceptance commands. - -## File Structure - -- Create `crates/persisting-pchronicle/src/formats/unknown_fields.rs`: residual types, limits, JSON Pointer operations, format-aware count normalization, source document IDs, envelope parsing/writing, and carrier bindings. -- Modify `crates/persisting-pchronicle/src/formats/storyline.rs`: replace `presence` with `unknown_fields` and `unknown_key_counts`; keep `FieldPresence` only where it is a canonical Storyline field such as tool results. -- Modify `crates/persisting-pchronicle/src/document.rs`: canonical container policy and common JSON codec orchestration. -- Modify `crates/persisting-pchronicle/src/atif.rs` and `crates/persisting-pchronicle/src/convert/atif.rs`: capture and restore ATIF unknown members without missing/null sidecars. -- Modify `crates/persisting-pchronicle/src/formats/actf.rs` and `crates/persisting-pchronicle/src/convert/actf.rs`: replace `persisting.dev/actf/v1` blobs with exact pointers. -- Modify `crates/persisting-pchronicle/src/formats/openai_corpus.rs`: replace `persisting.dev/openai-msg/v1` blobs with canonical row generation plus exact pointers. -- Modify `crates/persisting-pchronicle/src/agenticmd/convert.rs`: carry the same Storyline residual in existing frontmatter metadata and capture AgenticMD-only keys. -- Modify `crates/persisting-pchronicle/src/store/files/atif_reader.rs`: route full ATIF materialization through the new codec while leaving projected streaming untouched. -- Modify `crates/persisting-pchronicle/src/store/storyline/{model,rows,content,mutation,mod}.rs`: run-row persistence, schema upgrade, limits, and per-value content offload. -- Create `crates/persisting-pchronicle/tests/unknown_fields_roundtrip.rs`: cross-format and Lance acceptance matrix. -- Modify `crates/persisting-pchronicle/README.md` and affected focused tests to document the new semantic lossless boundary. - ---- - -### Task 1: Residual Core Types, Limits, and Pointer Operations - -**Files:** -- Create: `crates/persisting-pchronicle/src/formats/unknown_fields.rs` -- Modify: `crates/persisting-pchronicle/src/formats/mod.rs` -- Modify: `crates/persisting-pchronicle/src/model.rs` -- Test: `crates/persisting-pchronicle/src/formats/unknown_fields.rs` - -**Interfaces:** -- Produces: `UnknownFieldLimits`, `SourceUnknownFields`, `StorylineUnknownFields`, `UnknownKeyCounts`, `UnknownFieldCounts`, `validate_json_pointer`, `restore_json_pointer`, `canonical_source_document_id`, `compute_unknown_key_counts`, `validate_unknown_fields_with`, and `validate_unknown_fields`. -- Consumes: `DocumentFormat::as_str()`, `InputIssue`, `InputResult`, `serde_json::Value`. - -- [ ] **Step 1: Write failing unit tests for pointer validation, restoration, byte accounting, and limits** - -```rust -fn normalize_test_pointer(source: &str, pointer: &str) -> InputResult { - assert_eq!(source, "atif"); - Ok(pointer.replacen("/steps/0/", "/steps/*/", 1)) -} - -#[test] -fn unknown_fields_validate_pointer_counts_and_limits() { - let mut fields = StorylineUnknownFields::default(); - fields.insert( - "atif", - "source-1", - "/steps/0/vendor~1field", - json!({"kept": true}), - ).unwrap(); - let counts = fields.validate_with( - UnknownFieldLimits::default(), - |source, pointer| normalize_test_pointer(source, pointer), - ).unwrap(); - assert_eq!(counts["atif"]["/steps/*/vendor~1field"], 1); - - let too_many = UnknownFieldLimits { max_fields: 0, max_bytes: 1_048_576 }; - assert!(fields.validate_with(too_many, normalize_test_pointer).is_err()); - assert!(validate_json_pointer("/bad~2escape").is_err()); -} - -#[test] -fn restore_pointer_rejects_canonical_collision() { - let mut target = json!({"steps": [{"message": "canonical"}]}); - let error = restore_json_pointer( - &mut target, - "/steps/0/message", - json!("residual"), - PointerWrite::InsertOnly, - ).unwrap_err(); - assert!(error.to_string().contains("/steps/0/message")); -} -``` - -- [ ] **Step 2: Run the focused tests and verify the module is absent** - -Run: `cargo test -p persisting-pchronicle --no-default-features unknown_fields --lib` - -Expected: FAIL because `formats::unknown_fields` and its types do not exist. - -- [ ] **Step 3: Implement the residual types and deterministic size calculation** - -```rust -pub const DEFAULT_MAX_UNKNOWN_FIELDS: usize = 4096; -pub const DEFAULT_MAX_UNKNOWN_BYTES: usize = 1024 * 1024; - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub struct UnknownFieldLimits { - pub max_fields: usize, - pub max_bytes: usize, -} - -#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] -pub struct SourceUnknownFields { - pub source_document_id: String, - pub fields: BTreeMap, -} - -#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] -pub struct StorylineUnknownFields { - pub sources: BTreeMap, -} - -pub type UnknownFieldCounts = BTreeMap; -pub type UnknownKeyCounts = BTreeMap; -``` - -Implement `UnknownFieldLimits::validate()` so zero/unbounded values are rejected. Implement `StorylineUnknownFields::insert()` so a source format cannot silently change `source_document_id`. Define logical byte size as the source ID once per source plus every pointer UTF-8 length plus `serde_json::to_vec(value)?.len()`. - -`compute_unknown_key_counts(fields)` dispatches to `normalize_unknown_pointer(source, pointer)` without applying a quota. `validate_unknown_fields(fields, limits)` first validates finite limits and logical size, then returns the same computed counts. The initial normalizer preserves the exact pointer for sources without an adapter; Tasks 4-7 add format-aware array normalization branches while keeping unknown future source namespaces carryable. - -- [ ] **Step 4: Implement strict RFC 6901 decoding and restore policies** - -```rust -pub(crate) enum PointerWrite { - InsertOnly, - ReplaceResidualOwned, -} - -pub(crate) fn restore_json_pointer( - target: &mut Value, - pointer: &str, - value: Value, - write: PointerWrite, -) -> Result<()>; - -pub(crate) fn canonical_source_document_id(value: &Value) -> Result; -``` - -`validate_json_pointer` must accept `""` and slash-prefixed pointers, decode only `~0` and `~1`, reject bad escapes, and never create missing array slots. `canonical_source_document_id` removes a root `_storyline`, recursively sorts object keys, serializes compact JSON, and returns the BLAKE3 hex digest. - -- [ ] **Step 5: Run the core tests** - -Run: `cargo test -p persisting-pchronicle --no-default-features unknown_fields --lib` - -Expected: PASS, including exact boundary cases at 4096 entries and 1 MiB. - -- [ ] **Step 6: Commit the core utility** - -```bash -git add crates/persisting-pchronicle/src/formats/unknown_fields.rs crates/persisting-pchronicle/src/formats/mod.rs crates/persisting-pchronicle/src/model.rs -git commit -m "feat(pchronicle): add bounded unknown fields core" -``` - -### Task 2: Replace `StorylinePresence` in the Authoritative Model - -**Files:** -- Modify: `crates/persisting-pchronicle/src/formats/storyline.rs` -- Modify: `crates/persisting-pchronicle/src/model.rs` -- Modify: `crates/persisting-pchronicle/src/lib.rs` -- Modify: `crates/persisting-pchronicle/src/convert/{atif,actf,events}.rs` -- Modify: `crates/persisting-pchronicle/src/formats/openai_corpus.rs` -- Modify: `crates/persisting-pchronicle/src/agenticmd/convert.rs` -- Modify: `crates/persisting-pchronicle/src/document.rs` -- Modify: `crates/persisting-pchronicle/src/store/storyline/model.rs` -- Modify: `crates/persisting-pchronicle/src/store/storyline/rows.rs` -- Modify: `crates/persisting-pchronicle/src/store/storyline/tests.rs` -- Modify: focused in-scope Storyline test constructors returned by `rg -l 'presence:' crates/persisting-pchronicle --glob '!src/store/files/atif_stream.rs'` -- Test: `crates/persisting-pchronicle/src/formats/storyline.rs` -- Test: `crates/persisting-pchronicle/src/document.rs` - -**Interfaces:** -- Consumes: Task 1 residual types and limits. -- Produces: `StorylineDocument::{unknown_fields, unknown_key_counts}`, canonical ATIF container selection, and model validation without `StorylinePresence`. - -- [ ] **Step 1: Replace presence-focused tests with canonical semantic tests** - -```rust -fn atif_fixture_value() -> Value { - json!({ - "schema_version": "ATIF-v1.7", - "trajectory_id": "one", - "agent": {"name": "agent", "version": "1"}, - "steps": [] - }) -} - -#[test] -fn storyline_serialization_has_no_presence_sidecar() { - let story = StorylineDocument::new("session", "agent"); - let value = serde_json::to_value(story).unwrap(); - assert!(value.get("presence").is_none()); - assert!(value.get("unknown_fields").is_none()); -} - -#[test] -fn atif_singleton_object_and_array_encode_canonically() { - let object = atif_fixture_value(); - let from_object = decode_json_storylines(DocumentFormat::Atif, &object.to_string(), "a.json").unwrap(); - let from_array = decode_json_storylines(DocumentFormat::Atif, &json!([object]).to_string(), "a.json").unwrap(); - assert_eq!( - encode_json_storylines(DocumentFormat::Atif, &from_object).unwrap(), - encode_json_storylines(DocumentFormat::Atif, &from_array).unwrap(), - ); -} -``` - -- [ ] **Step 2: Run focused model/document tests and verify old semantics fail the new expectations** - -Run: `cargo test -p persisting-pchronicle --no-default-features --lib storyline_serialization_has_no_presence_sidecar` - -Run: `cargo test -p persisting-pchronicle --no-default-features --lib atif_singleton_object_and_array_encode_canonically` - -Expected: FAIL because `presence` is still serialized and singleton array shape is preserved. - -- [ ] **Step 3: Replace the field and delete the sidecar enums** - -```rust -pub struct StorylineDocument { - // existing fields - #[serde(default, skip_serializing_if = "StorylineUnknownFields::is_empty")] - pub unknown_fields: StorylineUnknownFields, - #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] - pub unknown_key_counts: UnknownKeyCounts, - pub turns: Vec, -} -``` - -Delete `PresenceState`, `StorylinePresence`, `StorylineRootField`, `StorylineAgentField`, `StorylineTurnField`, and `StorylineCollectionShape`. Keep `FieldPresence` because `StorylineToolCall::result` is an explicit canonical three-state field. Update every in-scope struct literal to use empty unknown fields/counts. - -- [ ] **Step 4: Make validation recompute and compare counts** - -```rust -impl StorylineDocument { - pub fn refresh_unknown_key_counts(&mut self) -> InputResult<()> { - self.unknown_key_counts = compute_unknown_key_counts(&self.unknown_fields)?; - Ok(()) - } -} -``` - -`StorylineDocument::validate` recomputes counts without a quota and rejects any mismatch, including non-empty residual paired with empty counts. Admission boundaries separately call `validate_unknown_fields` with their selected limits, so callers may intentionally configure limits above or below the defaults. Constructors set both fields empty; every codec calls `refresh_unknown_key_counts` after capture/envelope merge. - -- [ ] **Step 5: Remove container provenance and choose one canonical ATIF encoding rule** - -Change `encode_json_storylines` so one top-level ATIF root encodes as an object and two or more roots encode as an array. Preserve the provided `stories` order and parent/child order; delete `prepare_atif_collection`, shape conflict checks, and ordinal sorting. Change `atif_collection_to_storylines` to accept only `&AtifTrajectory` and stop writing shape/ordinal metadata. - -- [ ] **Step 6: Keep the Lance feature compiling with the new authoritative fields** - -Replace `StoryRunRow::presence` with `unknown_fields` and `unknown_key_counts`; replace the run batch value with nullable `unknown_fields_json` and `unknown_key_counts_json`. Retain a nullable legacy `presence_json` physical column written as null, but ignore it on read. Update storage test constructors. Schema-upgrade behavior and configurable storage limits remain Task 8. - -- [ ] **Step 7: Run no-Lance and Lance model tests** - -Run: `cargo test -p persisting-pchronicle --no-default-features --lib` - -Expected: PASS. Tests that asserted missing/null or singleton-array structural preservation are replaced with canonical semantic assertions. - -Run: `cargo test -p persisting-pchronicle --no-default-features --features lance-store --lib three_table_roundtrip` - -Expected: PASS with unknown fields/counts crossing the in-memory three-table split/reconstruct boundary. - -- [ ] **Step 8: Commit the authoritative model change** - -```bash -git add crates/persisting-pchronicle/src/formats/storyline.rs crates/persisting-pchronicle/src/model.rs crates/persisting-pchronicle/src/lib.rs crates/persisting-pchronicle/src/convert/atif.rs crates/persisting-pchronicle/src/convert/actf.rs crates/persisting-pchronicle/src/convert/events.rs crates/persisting-pchronicle/src/formats/openai_corpus.rs crates/persisting-pchronicle/src/agenticmd/convert.rs crates/persisting-pchronicle/src/document.rs crates/persisting-pchronicle/src/store/storyline/model.rs crates/persisting-pchronicle/src/store/storyline/rows.rs crates/persisting-pchronicle/src/store/storyline/tests.rs crates/persisting-pchronicle/src/store/catalog/tests.rs crates/persisting-pchronicle/src/tests.rs -git commit -m "refactor(pchronicle): replace storyline presence sidecar" -``` - -### Task 3: Unified `_storyline` Envelope and Carrier Binding - -**Files:** -- Modify: `crates/persisting-pchronicle/src/formats/unknown_fields.rs` -- Modify: `crates/persisting-pchronicle/src/document.rs` -- Test: `crates/persisting-pchronicle/src/formats/unknown_fields.rs` - -**Interfaces:** -- Consumes: `StorylineUnknownFields` from Task 1 and `StorylineDocument` fields from Task 2. -- Produces: `DocumentCodecOptions`, option-aware document entry points, `CarrierBinding`, `take_unknown_fields_envelope`, `attach_carried_unknown_fields`, and `write_foreign_unknown_fields_envelope`. - -- [ ] **Step 1: Write failing envelope tests for carrier distribution and reserved-key rejection** - -```rust -#[test] -fn envelope_distributes_foreign_sources_by_carrier() { - let mut raw = json!({ - "attempts": {"1": {}}, - "_storyline": {"unknown_fields": {"version": 1, "by_trajectory": { - "/attempts/1": {"sources": { - "atif": {"source_document_id": "a", "fields": {"/vendor": 7}} - }} - }}} - }); - let envelope = take_unknown_fields_envelope(&mut raw).unwrap(); - assert!(raw.get("_storyline").is_none()); - let mut stories = vec![StorylineDocument::new("s", "a")]; - attach_carried_unknown_fields( - envelope, - &[CarrierBinding { story_index: 0, pointer: "/attempts/1".into() }], - &mut stories, - UnknownFieldLimits::default(), - ).unwrap(); - assert_eq!(stories[0].unknown_fields.sources["atif"].fields["/vendor"], 7); -} -``` - -- [ ] **Step 2: Run the test and verify envelope helpers are missing** - -Run: `cargo test -p persisting-pchronicle --no-default-features envelope_ --lib` - -Expected: FAIL because the envelope API is undefined. - -- [ ] **Step 3: Implement versioned envelope DTOs and exact carrier matching** - -```rust -pub(crate) struct CarrierBinding { - pub story_index: usize, - pub pointer: String, -} - -pub(crate) fn take_unknown_fields_envelope( - document: &mut Value, -) -> InputResult>; - -pub(crate) fn write_foreign_unknown_fields_envelope( - target_format: DocumentFormat, - document: &mut Value, - stories: &[StorylineDocument], - carriers: &[CarrierBinding], -) -> Result<()>; -``` - -Represent the payload as `_storyline.unknown_fields` with integer `version: 1` and `by_trajectory..sources`. Reject non-object `_storyline`, missing/non-1 versions, extra envelope keys, duplicate carriers, bad carrier pointers, unbound carriers, and any source namespace that changes its `source_document_id` during merge. Exclude `sources[target_format.as_str()]` when writing a target document. - -- [ ] **Step 4: Recompute counts after attachment and enforce total per-trajectory limits** - -Merge all carried namespaces before calling validation so a document cannot bypass 4096/1 MiB by splitting data across source formats. Ensure the envelope structure itself is not included in byte accounting. - -- [ ] **Step 5: Add option-aware public codec entry points** - -```rust -#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] -pub struct DocumentCodecOptions { - pub unknown_fields: UnknownFieldLimits, -} - -pub fn decode_json_storylines_with_options( - format: DocumentFormat, - input: &str, - relative_path: impl AsRef, - options: DocumentCodecOptions, -) -> InputResult>; - -pub fn encode_json_storylines_with_options( - format: DocumentFormat, - stories: &[StorylineDocument], - options: DocumentCodecOptions, -) -> Result; -``` - -Keep the existing functions as compatibility wrappers using `DocumentCodecOptions::default()`. Validate options before parsing or encoding; zero limits fail even for empty residual. - -- [ ] **Step 6: Run envelope/core tests** - -Run: `cargo test -p persisting-pchronicle --no-default-features unknown_fields --lib` - -Expected: PASS. - -- [ ] **Step 7: Commit the envelope layer** - -```bash -git add crates/persisting-pchronicle/src/formats/unknown_fields.rs crates/persisting-pchronicle/src/document.rs -git commit -m "feat(pchronicle): add unknown fields wire envelope" -``` - -### Task 4: ATIF Capture, Restore, and Full-Document Readers - -**Files:** -- Modify: `crates/persisting-pchronicle/src/atif.rs` -- Modify: `crates/persisting-pchronicle/src/convert/atif.rs` -- Modify: `crates/persisting-pchronicle/src/document.rs` -- Modify: `crates/persisting-pchronicle/src/store/files/atif_reader.rs` -- Test: `crates/persisting-pchronicle/src/convert/atif.rs` -- Test: `crates/persisting-pchronicle/src/document.rs` -- Test: `crates/persisting-pchronicle/src/store/files/atif_reader.rs` - -**Interfaces:** -- Consumes: core residual and envelope APIs. -- Produces: `atif_value_to_storylines`, `storylines_to_atif_value`, ATIF carrier bindings, and `normalize_atif_pointer`. - -- [ ] **Step 1: Write failing tests for root, agent, step, tool, child, and null unknown values** - -```rust -#[test] -fn atif_unknown_fields_round_trip_without_presence() { - let input = json!({ - "schema_version": "ATIF-v1.7", - "session_id": null, - "trajectory_id": "t1", - "vendor_root": null, - "agent": {"name": "a", "version": "1", "vendor_agent": {"x": 1}}, - "steps": [{ - "step_id": 1, "source": "user", "message": "hi", - "vendor_step": [1, 2] - }] - }); - let stories = decode_json_storylines(DocumentFormat::Atif, &input.to_string(), "t.json").unwrap(); - assert_eq!(stories[0].unknown_fields.sources["atif"].fields["/vendor_root"], Value::Null); - assert_eq!(stories[0].unknown_key_counts["atif"]["/steps/*/vendor_step"], 1); - let output = encode_json_storylines(DocumentFormat::Atif, &stories).unwrap(); - assert_eq!(output["vendor_root"], Value::Null); - assert!(output.get("session_id").is_some()); // canonical effective identity -} -``` - -- [ ] **Step 2: Run ATIF tests and verify unknown keys are currently discarded** - -Run: `cargo test -p persisting-pchronicle --no-default-features atif_unknown_fields --lib` - -Expected: FAIL because ATIF serde DTOs discard `vendor_*` keys. - -- [ ] **Step 3: Add flattened unknown maps to ATIF wire DTOs** - -Add `#[serde(default, flatten)] pub unknown: Map` to `AtifTrajectory`, `AtifAgent`, `AtifStep`, and `AtifToolCall`. Update in-scope literals. `_storyline` is removed before deserialization and must never enter these maps. - -- [ ] **Step 4: Capture absolute pointers while flattening embedded trajectories** - -Change the recursive ATIF visitor to accept `(source_document_id, source_pointer)` and insert unknown members at paths such as `/agent/vendor_agent`, `/steps/0/vendor_step`, and `/subagent_trajectories/0/vendor_child`. All Storylines belonging to one top-level trajectory share its source document ID; each Storyline receives the entries belonging to its own subtree plus any truly document-shared entries. - -- [ ] **Step 5: Restore ATIF-owned residual after canonical tree reconstruction** - -Serialize the canonical `AtifTrajectory`, group flattened Storylines by source document ID, merge identical copied fields, and apply residual pointers with `PointerWrite::InsertOnly`. Treat ATIF known fields as canonical-owned; collision errors must include the pointer and trajectory. Generate carrier bindings for a top-level root and embedded child pointers before writing foreign envelopes. - -- [ ] **Step 6: Normalize ATIF array positions for counts** - -Implement `normalize_atif_pointer` with array positions only at `steps/`, `steps//tool_calls/`, and recursive `subagent_trajectories/`. A numeric unknown object key outside those schema positions remains numeric rather than becoming `*`. - -- [ ] **Step 7: Route full ATIF materialization through the same codec** - -Make `document::decode_json_storylines` and `store/files/atif_reader.rs` use the same value-to-storylines helper for object, array, and line records. Keep root record order but do not retain input container shape. Do not edit `store/files/atif_stream.rs` or `store/files/json_stream.rs`. - -- [ ] **Step 8: Run ATIF-focused tests** - -Run: `cargo test -p persisting-pchronicle --no-default-features atif --lib` - -Expected: PASS, including embedded subagents, null/missing canonicalization, pointer escaping, limit rejection, and object/array canonical equivalence. - -- [ ] **Step 9: Commit the ATIF adapter** - -```bash -git add crates/persisting-pchronicle/src/atif.rs crates/persisting-pchronicle/src/convert/atif.rs crates/persisting-pchronicle/src/document.rs crates/persisting-pchronicle/src/store/files/atif_reader.rs -git commit -m "feat(pchronicle): preserve ATIF unknown fields" -``` - -### Task 5: ACTF Exact-Path Residual and Multi-Attempt Merge - -**Files:** -- Modify: `crates/persisting-pchronicle/src/convert/actf.rs` -- Modify: `crates/persisting-pchronicle/src/formats/actf.rs` -- Modify: `crates/persisting-pchronicle/src/document.rs` -- Test: `crates/persisting-pchronicle/src/convert/actf.rs` - -**Interfaces:** -- Consumes: Task 3 envelope API and pointer write policies. -- Produces: ACTF residual capture/restore, `/attempts/` carrier bindings, `normalize_actf_pointer`, and legacy `persisting.dev/actf/v1` migration. - -- [ ] **Step 1: Rewrite the existing residual test to assert exact pointer maps** - -```rust -#[test] -fn actf_residual_is_namespaced_exact_paths() { - let mut value: Value = serde_json::from_str(FIXTURE).unwrap(); - value["root_unknown"] = Value::Null; - value["attempts"]["1"]["trajectory"]["steps"][0]["step_unknown"] = json!({"x": 1}); - let document: ActfDocument = serde_json::from_value(value.clone()).unwrap(); - let stories = actf_to_storylines(&document).unwrap(); - let source = &stories[0].unknown_fields.sources["actf"]; - assert_eq!(source.fields["/root_unknown"], Value::Null); - assert_eq!(source.fields["/attempts/1/trajectory/steps/0/step_unknown"], json!({"x": 1})); - assert!(!serde_json::to_string(&stories).unwrap().contains("persisting.dev/actf/v1")); -} -``` - -- [ ] **Step 2: Run the test and verify legacy blobs fail it** - -Run: `cargo test -p persisting-pchronicle --no-default-features actf_residual_is_namespaced_exact_paths --lib` - -Expected: FAIL because residual is stored under `Storyline.extra`. - -- [ ] **Step 3: Replace nested residual blobs with pointer insertion** - -Use `document.task_id` as `source_document_id`. Convert the current `root_metadata`, `attempt_residual`, `trajectory_residual`, `step_residual`, and `tool_residual` outputs into exact members at their original locations. Copy root-level residual entries into every attempt Storyline; store only the current attempt subtree on that Storyline. Do not store timestamp spelling, missing/null flags, or input-vs-command presentation when the ACTF encoder has a canonical spelling. - -- [ ] **Step 4: Generate canonical ACTF then restore residual-owned paths** - -Keep canonical Storyline mappings for task ID, correctness, score/status, message, reasoning, metrics, tool calls, and observations. Mark required ACTF placeholders that have no Storyline canonical owner as `ReplaceResidualOwned`; all other pre-existing target paths use `InsertOnly`. Merge stories with the same source document ID, deduplicate equal copied root entries, and fail on unequal values. - -- [ ] **Step 5: Add carrier and count normalization rules** - -Carrier for attempt `1` is `/attempts/1`. Array indices become `*` only below `/attempts//trajectory/steps`, `assistant_content/tool_calls`, `tools`, and `observation`; numeric attempt IDs remain object keys. - -- [ ] **Step 6: Migrate readable legacy ACTF residuals** - -Add a crate-private migration helper that detects `persisting.dev/actf/v1`, reconstructs an ACTF value with the legacy converter, then captures it with the new pointer codec. Remove only that recognized key from Storyline `extra`; retain unrelated business extra. Fail when legacy attempt grouping is incomplete or conflicting. - -- [ ] **Step 7: Run ACTF tests** - -Run: `cargo test -p persisting-pchronicle --no-default-features actf --lib` - -Expected: PASS for single/multiple attempts, unknown null values, shared-root deduplication, conflict failure, and no legacy extension keys in new Storyline output. - -- [ ] **Step 8: Commit the ACTF adapter** - -```bash -git add crates/persisting-pchronicle/src/formats/actf.rs crates/persisting-pchronicle/src/convert/actf.rs crates/persisting-pchronicle/src/document.rs -git commit -m "feat(pchronicle): unify ACTF unknown fields" -``` - -### Task 6: OpenAI Message Canonical Rows and Residual Paths - -**Files:** -- Modify: `crates/persisting-pchronicle/src/formats/openai_corpus.rs` -- Modify: `crates/persisting-pchronicle/src/document.rs` -- Test: `crates/persisting-pchronicle/src/formats/openai_corpus.rs` - -**Interfaces:** -- Consumes: pointer/envelope helpers and canonical Storyline model. -- Produces: OpenAI source grouping, row carriers, exact residual maps, `normalize_openai_pointer`, and legacy `persisting.dev/openai-msg/v1` migration. - -- [ ] **Step 1: Write failing tests for canonical envelope output and unknown row members** - -```rust -#[test] -fn openai_unknown_fields_use_exact_row_paths() { - let input = json!({"root_vendor": 1, "session_steps": [{ - "session_id": "s", "step_id": 1, - "messages": [{"role": "user", "content": "hi", "message_vendor": null}], - "response": {"role": "assistant", "content": "ok"}, - "row_vendor": [3, 2, 1] - }]}); - let stories = parse_openai_msg_corpus_value(&input, "corpus.json").unwrap(); - let fields = &stories[0].unknown_fields.sources["openai-msg"].fields; - assert_eq!(fields["/root_vendor"], 1); - assert_eq!(fields["/session_steps/0/row_vendor"], json!([3, 2, 1])); - assert_eq!(fields["/session_steps/0/messages/0/message_vendor"], Value::Null); -} -``` - -- [ ] **Step 2: Run the test and verify legacy metadata fails it** - -Run: `cargo test -p persisting-pchronicle --no-default-features openai_unknown_fields_use_exact_row_paths --lib` - -Expected: FAIL because OpenAI residual is stored in format extension blobs. - -- [ ] **Step 3: Define the OpenAI canonical output shape** - -Always synthesize `{"session_steps": [...]}` for JSON output. Generate one canonical record for each agent response turn, with session ID, step ID, request messages, response, model/run IDs, timestamps, and metrics derived from Storyline. Do not retain whether input was an array or envelope, whether output lived in `messages` or `response`, timestamp numeric spelling, or missing/null presentation. - -- [ ] **Step 4: Capture all unconsumed members as exact pointers** - -Use validated `relative_path` as `source_document_id`. Copy root members other than `session_steps` into every session Storyline. For each session, capture only its rows' unconsumed members, including nested message/tool-call members, at absolute canonical envelope pointers. A captured unknown object member stores its complete subtree. - -- [ ] **Step 5: Restore rows, foreign envelopes, and ordering** - -Group Storylines by source document ID, order canonical rows by retained source ordinal when available as a Storyline ordering hint and otherwise by stable Storyline/turn order, apply OpenAI-owned residual, then write foreign sources under root `_storyline`. Use `/session_steps/` carrier pointers. Filtered exports may contain ordinal gaps but cannot contain duplicate target carriers. - -- [ ] **Step 6: Normalize OpenAI pointers and migrate legacy blobs** - -Wildcard array positions for `session_steps`, `messages`, `tool_calls`, and other codec-declared arrays; keep numeric object keys literal. Migrate recognized legacy OpenAI metadata by using the old recovery function once, recapturing the reconstructed file, then deleting only `persisting.dev/openai-msg/v1`. - -- [ ] **Step 7: Run OpenAI tests** - -Run: `cargo test -p persisting-pchronicle --no-default-features openai --lib` - -Expected: PASS for multi-session files, unknown null values, canonical envelope output, unsafe relative path rejection, conflicts, and legacy migration. - -- [ ] **Step 8: Commit the OpenAI adapter** - -```bash -git add crates/persisting-pchronicle/src/formats/openai_corpus.rs crates/persisting-pchronicle/src/document.rs -git commit -m "feat(pchronicle): unify OpenAI unknown fields" -``` - -### Task 7: AgenticMD Frontmatter Transport - -**Files:** -- Modify: `crates/persisting-pchronicle/src/agenticmd/convert.rs` -- Modify: `crates/persisting-pchronicle/src/agenticmd/validate.rs` -- Test: `crates/persisting-pchronicle/src/agenticmd/convert.rs` - -**Interfaces:** -- Consumes: Storyline residual fields and common validation. -- Produces: AgenticMD capture/restore at logical `/frontmatter` and `/blocks//header` pointers without a second Markdown block protocol. - -- [ ] **Step 1: Write a failing AgenticMD foreign-residual round-trip test** - -```rust -#[test] -fn agenticmd_frontmatter_carries_unknown_sources() { - let mut story = StorylineDocument::new("s", "a"); - story.unknown_fields.insert("atif", "source", "/vendor", json!(7)).unwrap(); - story.refresh_unknown_key_counts().unwrap(); - let encoded = encode_agenticmd(&story).unwrap(); - let decoded = parse_agenticmd(&encoded).unwrap(); - assert_eq!(decoded.unknown_fields, story.unknown_fields); - assert_eq!(decoded.unknown_key_counts, story.unknown_key_counts); -} -``` - -- [ ] **Step 2: Run the test and confirm model validation/capture is incomplete** - -Run: `cargo test -p persisting-pchronicle --no-default-features agenticmd_frontmatter_carries_unknown_sources --lib` - -Expected: FAIL until frontmatter handling explicitly validates and preserves the residual. - -- [ ] **Step 3: Carry residual in existing Storyline frontmatter metadata** - -Keep `frontmatter.storyline.unknown_fields` and `unknown_key_counts`; do not introduce `_storyline` in Markdown body or block headers. Recompute counts when parsing and reject mismatches rather than trusting serialized counts. - -- [ ] **Step 4: Capture AgenticMD-only unknown keys** - -Capture unconsumed top-level frontmatter and block header fields under source `agenticmd`, using the source document hash after removing `frontmatter.storyline`. Restore them during encode only where they do not collide with authoritative frontmatter or block fields. Normalize only `/blocks/` as an array position. - -- [ ] **Step 5: Run AgenticMD tests** - -Run: `cargo test -p persisting-pchronicle --no-default-features agenticmd --lib` - -Expected: PASS for authoritative Storyline metadata, human-readable fallback parsing, unknown null fields, and collision rejection. - -- [ ] **Step 6: Commit the AgenticMD transport** - -```bash -git add crates/persisting-pchronicle/src/agenticmd/convert.rs crates/persisting-pchronicle/src/agenticmd/validate.rs -git commit -m "feat(pchronicle): carry unknown fields through AgenticMD" -``` - -### Task 8: Lance Run Rows and Legacy Schema Upgrade - -**Files:** -- Modify: `crates/persisting-pchronicle/src/store/storyline/model.rs` -- Modify: `crates/persisting-pchronicle/src/store/storyline/rows.rs` -- Modify: `crates/persisting-pchronicle/src/store/storyline/mutation.rs` -- Modify: `crates/persisting-pchronicle/src/store/storyline/mod.rs` -- Modify: `crates/persisting-pchronicle/src/store/storyline/tests.rs` -- Test: `crates/persisting-pchronicle/src/store/storyline/model.rs` -- Test: `crates/persisting-pchronicle/src/store/storyline/rows.rs` -- Test: `crates/persisting-pchronicle/src/store/storyline/tests.rs` - -**Interfaces:** -- Consumes: authoritative unknown fields/counts and default/configured limits. -- Produces: automatic nullable-column upgrade for legacy runs datasets and configurable storage admission limits. - -- [ ] **Step 1: Write failing legacy-schema append and configured-limit tests** - -```rust -#[tokio::test] -async fn legacy_runs_schema_upgrades_before_append() { - let temporary = tempfile::tempdir().unwrap(); - let store = create_legacy_presence_store(temporary.path()).await.unwrap(); - let mut story = StorylineDocument::new("new", "agent"); - story.unknown_fields.insert("atif", "source", "/vendor", json!(1)).unwrap(); - story.refresh_unknown_key_counts().unwrap(); - store.replace_storyline(&story).await.unwrap(); - assert_eq!(store.get_storyline_full("new").await.unwrap().unwrap().unknown_fields, story.unknown_fields); -} -``` - -- [ ] **Step 2: Run the legacy test and verify append rejects the old schema** - -Run: `cargo test -p persisting-pchronicle --no-default-features --features lance-store --lib legacy_runs_schema_upgrades_before_append` - -Expected: FAIL because the existing runs table lacks the new nullable columns. - -- [ ] **Step 3: Validate residual on every split/write boundary** - -Add `max_unknown_fields` and `max_unknown_bytes` as finite positive fields on `StorylineContentOptions`, defaulting to 4096 and 1 MiB. `split_storyline` and `next_storyline_stream_chunk` validate logical hydrated residual before row creation and report actual/limit values. - -- [ ] **Step 4: Add nullable columns to legacy runs datasets before append** - -When opening a committed runs table for writing, inspect its Arrow schema. If either new column is missing, call Lance `Dataset::add_columns(NewColumnTransform::SqlExpressions(...))` with `CAST(NULL AS STRING)` for that column while holding the existing store write guard. Pin the returned version in the new snapshot; do not mutate a snapshot during read-only open. - -- [ ] **Step 5: Test legacy and new schemas** - -Complete `create_legacy_presence_store(path: &Path) -> Result` in the test module by writing the pre-change 20-column runs batch plus matching empty steps/tool-calls/object datasets and CURRENT pointer. Verify read returns empty residual before migration, append one new Storyline, and verify both rows read. Verify zero limit options are rejected and configured smaller positive limits reject over-budget input. - -- [ ] **Step 6: Run focused Lance tests** - -Run: `cargo test -p persisting-pchronicle --no-default-features --features lance-store --lib legacy_runs_schema` - -Run: `cargo test -p persisting-pchronicle --no-default-features --features lance-store --lib unknown_field_limit` - -Expected: PASS. - -- [ ] **Step 7: Commit Lance schema and admission changes** - -```bash -git add crates/persisting-pchronicle/src/store/storyline/model.rs crates/persisting-pchronicle/src/store/storyline/rows.rs crates/persisting-pchronicle/src/store/storyline/mutation.rs crates/persisting-pchronicle/src/store/storyline/mod.rs crates/persisting-pchronicle/src/store/storyline/tests.rs -git commit -m "feat(pchronicle): persist storyline unknown fields" -``` - -### Task 9: Per-Value `objects.lance` Offload and Hydration - -**Files:** -- Modify: `crates/persisting-pchronicle/src/store/storyline/content.rs` -- Modify: `crates/persisting-pchronicle/src/store/storyline/mod.rs` -- Modify: `crates/persisting-pchronicle/src/store/storyline/tests.rs` -- Test: `crates/persisting-pchronicle/src/store/storyline/content.rs` - -**Interfaces:** -- Consumes: `unknown_fields_json`, existing `ContentRef`, `PendingContent`, `objects.lance` commit/hydrate lifecycle. -- Produces: `externalize_unknown_field_values` and `hydrate_unknown_field_values` at residual value boundaries. - -- [ ] **Step 1: Write failing tests for repeated-value dedup and descriptor collision** - -```rust -#[tokio::test] -async fn repeated_unknown_value_is_stored_once() { - let large = json!({"payload": "x".repeat(DEFAULT_CONTENT_OFFLOAD_THRESHOLD)}); - let mut first = story("residual-first"); - first.unknown_fields.insert("actf", "task-1", "/shared", large.clone()).unwrap(); - first.refresh_unknown_key_counts().unwrap(); - let mut second = story("residual-second"); - second.unknown_fields.insert("actf", "task-1", "/shared", large.clone()).unwrap(); - second.refresh_unknown_key_counts().unwrap(); - let temporary = tempfile::tempdir().unwrap(); - let store = StorylineLanceStore::open(temporary.path()).await.unwrap(); - store.replace_storylines(&[first, second]).await.unwrap(); - let paths = store.current_table_paths().await.unwrap().unwrap(); - let objects = open_objects(&paths.objects, paths.objects_version).await.unwrap(); - assert_eq!(objects.count_rows(None).await.unwrap(), 1); - let hydrated = store.get_storyline_full("residual-first").await.unwrap().unwrap(); - assert_eq!(hydrated.unknown_fields.sources["actf"].fields["/shared"], large); -} -``` - -- [ ] **Step 2: Run the test and verify duplicated residual is inline** - -Run: `cargo test -p persisting-pchronicle --no-default-features --features lance-store repeated_unknown_value_is_stored_once --lib` - -Expected: FAIL because content externalization does not inspect nested residual values. - -- [ ] **Step 3: Externalize each residual value before run-batch serialization** - -Parse/operate on typed `StorylineUnknownFields`, serialize each value independently, and call the existing BLAKE3/zstd `build_object` with `LogicalType::Json` when the compact value reaches `offload_threshold`. Replace only the internal run-row value with `Value::String(ContentRef::encode())`. Force offload when a user string starts with `CONTENT_REF_MAGIC`, even if small. - -- [ ] **Step 4: Hydrate nested descriptors before public model reconstruction** - -Collect all descriptors found at `sources.*.fields.*`, batch-resolve them with the existing content index, verify content ID/raw length/codec, parse the bytes back into one JSON `Value`, and replace the descriptor. Missing objects and invalid JSON fail closed. `unknown_key_counts` remains inline and is recomputed after hydration. - -- [ ] **Step 5: Ensure admission limits use logical values** - -Validate before externalization on write and after hydration on read. Add a regression test proving a 1 MiB+1 logical value is rejected even when its compressed object would be tiny. - -- [ ] **Step 6: Run content/store tests** - -Run: `cargo test -p persisting-pchronicle --no-default-features --features lance-store --lib repeated_unknown_value_is_stored_once` - -Run: `cargo test -p persisting-pchronicle --no-default-features --features lance-store --lib logical_unknown_limit` - -Run: `cargo test -p persisting-pchronicle --no-default-features --features lance-store --lib content_ref` - -Expected: PASS with one content object for repeated large values and exact public hydration. - -- [ ] **Step 7: Commit content offload** - -```bash -git add crates/persisting-pchronicle/src/store/storyline/content.rs crates/persisting-pchronicle/src/store/storyline/mod.rs crates/persisting-pchronicle/src/store/storyline/tests.rs -git commit -m "feat(pchronicle): offload large unknown field values" -``` - -### Task 10: Cross-Format Acceptance, Documentation, and Cleanup - -**Files:** -- Create: `crates/persisting-pchronicle/tests/unknown_fields_roundtrip.rs` -- Modify: `crates/persisting-pchronicle/tests/storyline_lance_roundtrip.rs` -- Modify: `crates/persisting-pchronicle/tests/import_roundtrip_fixtures.rs` -- Modify: `crates/persisting-pchronicle/README.md` -- Modify: `crates/persisting-pchronicle/src/formats/{storyline,unknown_fields}.rs` -- Modify: any in-scope file returned by `rg -n 'StorylinePresence|PresenceState|StorylineCollectionShape|persisting\.dev/(actf|openai-msg)' crates/persisting-pchronicle docs/superpowers/specs/2026-08-19-storyline-unknown-fields-design.md` - -**Interfaces:** -- Consumes: all prior tasks. -- Produces: end-to-end semantic-lossless guarantees, final stale-symbol cleanup, and user documentation. - -- [ ] **Step 1: Add a cross-format round-trip matrix test** - -```rust -fn atif_with_unknowns() -> Value { - json!({ - "schema_version": "ATIF-v1.7", - "trajectory_id": "t1", - "vendor_root": null, - "agent": {"name": "agent", "version": "1"}, - "steps": [{"step_id": 1, "source": "user", "message": "hi", "vendor_step": 7}] - }) -} - -fn assert_unknown_fields_equal(left: &Value, right: &Value, format: DocumentFormat) -> Result<()> { - let left = decode_json_storylines(format, &left.to_string(), "left.json")?; - let right = decode_json_storylines(format, &right.to_string(), "right.json")?; - assert_eq!(left.len(), right.len()); - for (left, right) in left.iter().zip(&right) { - assert_eq!(left.unknown_fields, right.unknown_fields); - } - Ok(()) -} - -#[test] -fn foreign_unknowns_survive_atif_actf_atif() -> Result<()> { - let atif = atif_with_unknowns(); - let stories = decode_json_storylines(DocumentFormat::Atif, &atif.to_string(), "a.json")?; - let actf = encode_json_storylines(DocumentFormat::Actf, &stories)?; - assert!(actf.get("_storyline").is_some()); - let through = decode_json_storylines(DocumentFormat::Actf, &actf.to_string(), "b.actf.json")?; - let recovered = encode_json_storylines(DocumentFormat::Atif, &through)?; - assert_unknown_fields_equal(&atif, &recovered, DocumentFormat::Atif)?; - Ok(()) -} -``` - -Add reverse ACTF/OpenAI paths, a three-format hop, unknown null values, numeric object keys, malformed pointers, foreign envelope conflicts, filtered/path-invalid output, 4096/1 MiB exact boundaries, and object-order-insensitive comparison. The comparator deletes known null object members before comparing canonical known fields but never deletes an unknown null key. - -- [ ] **Step 2: Run the new test before final cleanup** - -Run: `cargo test -p persisting-pchronicle --no-default-features --features lance-store --test unknown_fields_roundtrip` - -Expected: PASS for completed adapters; any failure is fixed in its owning codec rather than weakened in the comparator. - -- [ ] **Step 3: Update Lance and fixture integration tests** - -Replace assertions for ATIF missing/null three-state and singleton-array shape with canonical semantic assertions. Add a Storyline Lance test that imports a multi-attempt ACTF document with shared unknown values, verifies per-trajectory logical copies, verifies `objects.lance` deduplication, and exports the unknown values exactly. - -- [ ] **Step 4: Remove stale sidecar and format-extension code** - -Run: `rg -n 'StorylinePresence|PresenceState|StorylineCollectionShape|root_nulls|agent_nulls|turn_nulls|tool_call_extra_nulls|persisting\.dev/(actf|openai-msg)' crates/persisting-pchronicle` - -Expected: no production matches. Test fixture strings may retain legacy extension keys only inside explicit migration tests. - -- [ ] **Step 5: Update pChronicle documentation** - -Document that known null/missing and input container shape are canonicalized; Storyline-unmodeled keys use namespaced exact-pointer residual; cross-format multi-hop uses `_storyline`; per-trajectory defaults are 4096/1 MiB and reject on overflow; `objects.lance` is an internal self-invisible optimization. Remove claims that ATIF null/missing/value and singleton array shape are retained. - -- [ ] **Step 6: Run targeted final verification** - -Run: `cargo test -p persisting-pchronicle --no-default-features --lib` - -Expected: PASS. - -Run: `cargo test -p persisting-pchronicle --no-default-features --features lance-store --test unknown_fields_roundtrip --test storyline_lance_roundtrip --test import_roundtrip_fixtures` - -Expected: PASS. - -Run: `cargo clippy -p persisting-pchronicle --no-default-features --features lance-store --lib --tests -- -D warnings` - -Expected: PASS with no warnings. Failures from the explicitly excluded subsystems are not invoked by these commands. - -Run: `git diff --check` - -Expected: PASS. Verify separately that the user's pre-existing `atif_stream.rs`, `files/mod.rs`, and `json_stream.rs` edits are unchanged by this implementation. - -- [ ] **Step 7: Commit acceptance and docs** - -```bash -git add crates/persisting-pchronicle/tests/unknown_fields_roundtrip.rs crates/persisting-pchronicle/tests/storyline_lance_roundtrip.rs crates/persisting-pchronicle/tests/import_roundtrip_fixtures.rs crates/persisting-pchronicle/README.md crates/persisting-pchronicle/src/formats/storyline.rs crates/persisting-pchronicle/src/formats/unknown_fields.rs -git commit -m "test(pchronicle): verify unified unknown fields round trips" -``` diff --git a/docs/superpowers/plans/2026-08-20-pchronicle-storyline-squash-import.md b/docs/superpowers/plans/2026-08-20-pchronicle-storyline-squash-import.md deleted file mode 100644 index e9212120..00000000 --- a/docs/superpowers/plans/2026-08-20-pchronicle-storyline-squash-import.md +++ /dev/null @@ -1,520 +0,0 @@ -# pChronicle Storyline Squash Import Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Make every `pchronicle import --output-format storyline` invocation publish one atomic Storyline Lance Store at the output root. - -**Architecture:** Preserve imports keep their existing per-file staging path. Storyline imports adapt directory, file, and stdin inputs into one lazy `Iterator>`, feed it to one root `StorylineLanceStore::replace_storyline_stream` call, and retain input paths only in CLI diagnostics and aggregate response metadata. - -**Tech Stack:** Rust, Tokio, Clap, anyhow, serde_json, pChronicle Storyline model, StorylineLanceStore, DataFusion-backed CLI query tests. - -**Spec:** `docs/superpowers/specs/2026-08-20-pchronicle-storyline-squash-import-design.md` - -## Global Constraints - -- `--output-format preserve` retains its current byte-for-byte relative-path behavior. -- Directory, regular-file, and stdin Storyline imports all write one Store at `OUTPUT`. -- The resulting physical Source is `.` and every normalized row has `_file_ = '.'`. -- Preserve `run_id`, `document_id`, and `session_id`; never prefix or rewrite identities. -- Reject duplicate `document_id` and duplicate `session_id` globally with both diagnostic Source paths. -- Source paths are diagnostics only and must not be added to Storyline or Lance schemas. -- Keep the serialized `ImportResponse` schema unchanged. -- Explicit `--max-input-bytes` remains per Source; add no aggregate directory limit. -- Publish only after all inputs and Store indexes succeed; leave no output on any failure. -- Do not modify TTAS, Queue, Search, or standalone `persisting-dlcapt`. -- The shared worktree already contains overlapping user edits in the target files. Preserve those edits and leave implementation changes unstaged rather than committing unrelated hunks. - -## File map - -- `crates/persisting-pchronicle-cli/src/exchange.rs`: split decode from physical staging, implement the lazy multi-Source Storyline iterator, global collision diagnostics, and one root Store write. -- `crates/persisting-pchronicle-cli/src/lib.rs`: update Clap help text; keep CLI types and response schema stable. -- `crates/persisting-pchronicle-cli/src/tests.rs`: replace old nested-layout assertions and add root Store, stdin, collision, and late-failure regression coverage. -- `crates/persisting-pchronicle-cli/README.md`: describe Storyline squash behavior. -- `docs/src/pchronicle/reference/cli.md`: document layout, response semantics, `_file_`, and collision policy. -- `docs/src/pchronicle/guides/exchange.md`: update the English import workflow. -- `docs/src/pchronicle/guides/exchange.zh.md`: update the Chinese import workflow. - ---- - -### Task 1: Build one root Store for every Storyline input shape - -**Files:** -- Modify: `crates/persisting-pchronicle-cli/src/tests.rs:1400-1690` -- Modify: `crates/persisting-pchronicle-cli/src/exchange.rs:1-155,594-805` - -**Interfaces:** -- Consumes: existing `ImportFileCandidate`, `decode_json_storylines`, `read_bounded`, and `StorylineLanceStore::replace_storyline_stream`. -- Produces: `DecodedImportSource`, `StorylineImportInputs<'a>`, and `StorylineImportIterator<'a>` used by Task 2 for provenance-aware collision checks. - -- [ ] **Step 1: Replace the single-file nested-layout test with a failing root-layout test** - -Rename the test to `import_storyline_output_writes_one_root_lance_store` and assert both the root layout and query Source: - -```rust -assert!(output.join("CURRENT").is_file()); -assert!(output.join("generations").is_dir()); -assert!(output.join("objects.lance").is_dir()); -assert!(!output.join("session_steps.json").exists()); - -let cli = Cli::try_parse_from([ - "pchronicle", - "query", - output.to_str().unwrap(), - "SELECT _file_ AS source_file, COUNT(*) AS runs FROM dataset.runs GROUP BY _file_", - "--format", - "jsonl", -])?; -let mut query_stdout = Vec::new(); -run(cli, false, &mut query_stdout, &mut Vec::new()).await?; -let row: Value = serde_json::from_slice(&query_stdout)?; -assert_eq!(row["source_file"], "."); -assert_eq!(row["runs"], 2); -``` - -- [ ] **Step 2: Replace the directory per-Source test with a failing squash test** - -Write two ATIF fixtures with distinct `trajectory_id` and `session_id`, retain the current response-count assertions, and replace nested `CURRENT` assertions with: - -```rust -assert!(output.join("CURRENT").is_file()); -assert!(!output.join("first/shared.json").exists()); -assert!(!output.join("second/shared.json").exists()); -``` - -Query grouped by `_file_` and assert one `.` row containing both runs. In `directory_import_reads_atif_jsonl_and_ndjson_in_both_output_modes`, keep the preserve assertion and change the Storyline assertion to `output.join("CURRENT").is_file()` plus absence of the nested path. - -- [ ] **Step 3: Add a failing stdin Storyline root-layout test** - -Add `storyline_import_from_stdin_writes_one_root_store` beside `import_reads_a_bounded_explicit_stdin_stream`: - -```rust -let cli = Cli::try_parse_from([ - "pchronicle", "import", "--from", "-", "--stream", "--format", "atif", - "--output", output.to_str().unwrap(), "--output-format", "storyline", -])?; -let mut stdin = input.as_slice(); -run_with_stdin(cli, false, &mut stdin, &mut Vec::new(), &mut Vec::new()).await?; -assert!(output.join("CURRENT").is_file()); -assert!(!output.join("trajectories.atif.json").exists()); -``` - -- [ ] **Step 4: Run the layout tests and confirm RED** - -Run: - -```sh -cargo test -p persisting-pchronicle-cli \ - import_storyline_output_writes_one_root_lance_store -- --nocapture -cargo test -p persisting-pchronicle-cli \ - directory_storyline_output_squashes_sources_into_one_root_store -- --nocapture -cargo test -p persisting-pchronicle-cli \ - storyline_import_from_stdin_writes_one_root_store -- --nocapture -``` - -Expected: each test fails because `CURRENT` exists under an input-derived child path instead of `OUTPUT`. - -- [ ] **Step 5: Separate Source decoding from preserve-file staging** - -Replace the write-coupled result with a decoded unit: - -```rust -struct DecodedImportSource { - diagnostic_path: PathBuf, - metadata: ImportedSource, - storylines: std::vec::IntoIter, -} - -fn decode_import_source( - requested_format: ExchangeFormat, - output_format: ImportOutputFormat, - input_path: Option<&Path>, - decode_relative_path: Option<&Path>, - logical_source_path: Option<&Path>, - input: &[u8], - unknown_field_warnings: &mut persisting_pchronicle::model::UnknownFieldImportWarnings, -) -> Result -``` - -Move UTF-8 conversion, format resolution, Storyline decoding, unknown-field observation, logical source naming, trajectory count, and byte count into this synchronous helper. Do not perform global duplicate checks here. Preserve imports call the existing `validate_import_storylines` before writing the original bytes. - -Rename the physical writer to `stage_preserved_import_source`; it calls `decode_import_source`, creates the original relative path below staging, writes and syncs the exact bytes, and returns `ImportedSource`. Remove the Storyline Store branch from this helper. - -- [ ] **Step 6: Add the lazy Storyline input iterator** - -Implement the following input state and iterator in `exchange.rs`: - -```rust -enum StorylineImportInputs<'a> { - Stdin(Option<&'a mut dyn Read>), - Files { - candidates: &'a [ImportFileCandidate], - next: usize, - }, -} - -struct StorylineImportIterator<'a> { - requested_format: ExchangeFormat, - output_format: ImportOutputFormat, - max_input_bytes: usize, - inputs: StorylineImportInputs<'a>, - current: std::vec::IntoIter, - current_diagnostic_path: PathBuf, - imported_sources: Vec, - unknown_field_warnings: persisting_pchronicle::model::UnknownFieldImportWarnings, - failed: bool, -} - -impl Iterator for StorylineImportIterator<'_> { - type Item = Result; - - fn next(&mut self) -> Option { - loop { - if let Some(storyline) = self.current.next() { - return Some(Ok(storyline)); - } - if self.failed { - return None; - } - match self.decode_next_source() { - Ok(Some(decoded)) => { - self.current_diagnostic_path = decoded.diagnostic_path; - self.imported_sources.push(decoded.metadata); - self.current = decoded.storylines; - } - Ok(None) => return None, - Err(error) => { - self.failed = true; - return Some(Err(error)); - } - } - } - } -} -``` - -Implement these helper signatures on the iterator: - -```rust -fn stdin( - requested_format: ExchangeFormat, - max_input_bytes: usize, - stdin: &mut dyn Read, -) -> StorylineImportIterator<'_>; - -fn files( - requested_format: ExchangeFormat, - max_input_bytes: usize, - candidates: &[ImportFileCandidate], -) -> StorylineImportIterator<'_>; - -fn decode_next_source(&mut self) -> Result>; - -fn into_result_parts( - self, -) -> ( - Vec, - persisting_pchronicle::model::UnknownFieldImportWarnings, -); -``` - -`decode_next_source` matches `StorylineImportInputs`: the file branch increments `next`, opens the selected candidate, bounded-reads it with the existing `import source ` label, and passes its physical and relative paths to `decode_import_source`; the stdin branch `take()`s its reader, bounded-reads it with label `stdin`, and passes no physical or relative path. A consumed stdin or exhausted candidate slice returns `Ok(None)`. - -- [ ] **Step 7: Dispatch Storyline output to one Store at staging root** - -Refactor `run_import` so the preserve branch retains the current loop, while Storyline uses: - -```rust -let store = StorylineLanceStore::open(staging.path()) - .await - .context("create squashed Storyline Lance Dataset")?; -let mut import = if args.stream { - StorylineImportIterator::stdin(args.format, max_input_bytes, stdin) -} else { - StorylineImportIterator::files(args.format, max_input_bytes, &candidates) -}; -let report = store - .replace_storyline_stream(&mut import) - .await - .context("write squashed Storyline Lance Dataset")?; -anyhow::ensure!( - store.current_table_paths().await?.is_some(), - "squashed Storyline Lance Dataset has no committed snapshot" -); -let (imported_sources, unknown_field_warnings) = import.into_result_parts(); -``` - -Check `report.storylines` against the checked sum in `imported_sources`. Keep the existing staging sync, no-replace rename, parent sync, response serialization, and warning output unchanged after this branch returns its metadata. - -- [ ] **Step 8: Run the layout tests and preserve regressions** - -Run: - -```sh -cargo test -p persisting-pchronicle-cli \ - import_storyline_output_writes_one_root_lance_store -- --nocapture -cargo test -p persisting-pchronicle-cli \ - directory_storyline_output_squashes_sources_into_one_root_store -- --nocapture -cargo test -p persisting-pchronicle-cli \ - storyline_import_from_stdin_writes_one_root_store -- --nocapture -cargo test -p persisting-pchronicle-cli \ - directory_import_reads_atif_jsonl_and_ndjson_in_both_output_modes -- --nocapture -cargo test -p persisting-pchronicle-cli \ - import_recurses_directories_and_preserves_relative_source_paths -- --nocapture -``` - -Expected: all selected tests pass. - -- [ ] **Step 9: Review the Task 1 diff without staging overlapping user work** - -Run: - -```sh -git diff --check -- \ - crates/persisting-pchronicle-cli/src/exchange.rs \ - crates/persisting-pchronicle-cli/src/tests.rs -git diff --stat -- \ - crates/persisting-pchronicle-cli/src/exchange.rs \ - crates/persisting-pchronicle-cli/src/tests.rs -``` - -Expected: no whitespace errors. Leave both files unstaged because they contained pre-existing changes before this plan. - -### Task 2: Enforce global identity uniqueness with provenance-aware errors - -**Files:** -- Modify: `crates/persisting-pchronicle-cli/src/tests.rs:1930-2020` -- Modify: `crates/persisting-pchronicle-cli/src/exchange.rs:594-805` - -**Interfaces:** -- Consumes: `StorylineImportIterator<'a>` and its `current_diagnostic_path` from Task 1. -- Produces: `record_import_identity`, global document/session maps, and stable invalid-input diagnostics. - -- [ ] **Step 1: Add failing cross-Source collision tests** - -Add one table-driven test `storyline_squash_rejects_global_identity_collisions` with two cases. Construct minimal ATIF documents so the document case has equal `trajectory_id` and distinct `session_id`, while the session case has distinct `trajectory_id` and equal `session_id`. Store them as `first.json` and `nested/second.json`, import the directory as Storyline, and assert: - -```rust -let message = format!("{error:#}"); -assert!(message.contains(field), "{message}"); -assert!(message.contains(value), "{message}"); -assert!(message.contains("first.json"), "{message}"); -assert!(message.contains("nested/second.json"), "{message}"); -assert!(!output.exists()); -``` - -Add a same-Source duplicate case and assert the same path appears in both labeled positions in the error. - -- [ ] **Step 2: Run the collision test and confirm RED** - -Run: - -```sh -cargo test -p persisting-pchronicle-cli \ - storyline_squash_rejects_global_identity_collisions -- --nocapture -``` - -Expected: the old implementation either accepts duplicate sessions or emits the Store's generic duplicate-document error without both Source paths. - -- [ ] **Step 3: Add document and session provenance maps** - -Extend the iterator: - -```rust -seen_document_ids: HashMap, -seen_session_ids: HashMap, -``` - -Import `HashMap` beside the existing `HashSet`. Add: - -```rust -fn record_import_identity( - seen: &mut HashMap, - field: &str, - value: &str, - diagnostic_path: &Path, -) -> Result<()> { - if let Some(first_path) = seen.get(value) { - return Err(cli_boundary_error( - BoundaryCode::InvalidRequest, - format!( - "import contains duplicate {field} '{value}' in Sources '{}' and '{}'", - first_path.display(), - diagnostic_path.display() - ), - )); - } - seen.insert(value.to_owned(), diagnostic_path.to_path_buf()); - Ok(()) -} -``` - -Before yielding each current Storyline, call it first with `story.document_id()` and then with `&story.session_id`. On error, set the iterator's terminal failure flag and yield the error exactly once. Do not apply these global maps to preserve output. - -- [ ] **Step 4: Add a failing late-Source atomicity regression** - -Add `storyline_squash_late_source_failure_removes_staging`. Write `a-valid.json` as an ATIF JSON array of 256 documents with unique trajectory/session identities, then write invalid JSON to `z-invalid.json`. Import as Storyline and assert the error names `z-invalid.json`, `OUTPUT` does not exist, and no sibling entry starts with `.pchronicle-import-`. - -- [ ] **Step 5: Run collision and atomicity tests to GREEN** - -Run: - -```sh -cargo test -p persisting-pchronicle-cli \ - storyline_squash_rejects_global_identity_collisions -- --nocapture -cargo test -p persisting-pchronicle-cli \ - storyline_squash_late_source_failure_removes_staging -- --nocapture -cargo test -p persisting-pchronicle-cli \ - import_is_create_only_and_rejects_duplicate_documents -- --nocapture -cargo test -p persisting-pchronicle-cli \ - directory_import_failure_does_not_publish_partial_output -- --nocapture -``` - -Expected: all selected tests pass; the existing preserve-mode shared-session case remains accepted. - -- [ ] **Step 6: Review the Task 2 diff without staging overlapping user work** - -Run `git diff --check -- crates/persisting-pchronicle-cli/src/exchange.rs crates/persisting-pchronicle-cli/src/tests.rs` and leave the files unstaged. - -### Task 3: Update CLI language and user documentation - -**Files:** -- Modify: `crates/persisting-pchronicle-cli/src/lib.rs:375-410` -- Modify: `crates/persisting-pchronicle-cli/README.md:94-108` -- Modify: `docs/src/pchronicle/reference/cli.md:168-205` -- Modify: `docs/src/pchronicle/guides/exchange.md:10-38` -- Modify: `docs/src/pchronicle/guides/exchange.zh.md:10-40` - -**Interfaces:** -- Consumes: the Task 1 root Store behavior and Task 2 collision semantics. -- Produces: accurate help and documentation with no old per-Source Store promise. - -- [ ] **Step 1: Add a failing Clap help assertion** - -In `command_tree_contains_the_product_commands`, locate the `import` command and `output-format` argument, then assert its help contains `squash into one Storyline Lance Store at the Dataset root`. - -- [ ] **Step 2: Update `ImportOutputFormat` and `ImportArgs` help** - -Use these exact descriptions: - -```rust -/// Decode all input Sources into one squashed Storyline Lance Store at the Dataset root. -Storyline, - -/// Physical Dataset output: preserve source files, or squash into one Storyline Lance Store at the Dataset root. -#[arg(long, value_enum, default_value_t = ImportOutputFormat::Preserve)] -output_format: ImportOutputFormat, -``` - -- [ ] **Step 3: Update the README and reference contract** - -Replace every statement that Storyline writes one Store per Source with the following facts: - -- all decoded Sources feed one Store at the output root; -- the result has one physical Source `.` and `_file_ = '.'`; -- `sources` in the import response still counts logical inputs; -- `document_id` and `session_id` must be globally unique; -- original paths remain available in import errors but are not query provenance; -- use preserve mode when Source boundaries matter. - -- [ ] **Step 4: Update both exchange guides symmetrically** - -Show the same `--output-format storyline` command but describe one squashed root Store. Add a short query example that omits `--source`, and state that `_file_` is `.` after squash. The Chinese guide must convey the same collision and provenance policy as the English guide. - -- [ ] **Step 5: Run help and stale-language checks** - -Run: - -```sh -cargo test -p persisting-pchronicle-cli command_tree_contains_the_product_commands -- --nocapture -rg -n "one normalized Storyline Lance store per Source|one Storyline Lance store at each|each Source into its own|每个 Source.*Storyline|独立 Storyline" \ - crates/persisting-pchronicle-cli/README.md \ - docs/src/pchronicle/reference/cli.md \ - docs/src/pchronicle/guides/exchange.md \ - docs/src/pchronicle/guides/exchange.zh.md -``` - -Expected: the help test passes and `rg` returns no stale old-layout claim. - -- [ ] **Step 6: Review documentation diffs without staging overlapping user work** - -Run `git diff --check` for the five Task 3 files and leave them unstaged. - -### Task 4: Focused verification and real-directory smoke test - -**Files:** -- Verify only; no planned source edits. - -**Interfaces:** -- Consumes: all behavior and documentation from Tasks 1-3. -- Produces: evidence that the implementation passes focused checks and the reported user workflow creates one root Store. - -- [ ] **Step 1: Format the touched Rust package** - -Run: - -```sh -cargo fmt -p persisting-pchronicle-cli -cargo fmt -p persisting-pchronicle-cli -- --check -``` - -Expected: both commands exit zero and do not format excluded packages. - -- [ ] **Step 2: Run the complete CLI test suite** - -Run: - -```sh -cargo test -p persisting-pchronicle-cli -``` - -Expected: all CLI unit and integration tests pass. If a pre-existing unrelated test fails, rerun that test against the pre-change state or otherwise establish evidence before classifying it as unrelated. - -- [ ] **Step 3: Run focused Clippy** - -Run: - -```sh -cargo clippy -p persisting-pchronicle-cli --all-targets -- -D warnings -``` - -Expected: exit zero with no warnings. - -- [ ] **Step 4: Build release and smoke-test the user's directory** - -Run: - -```sh -cargo build -p persisting-pchronicle-cli --release -smoke_parent="$(mktemp -d)" -target/release/pchronicle import \ - --format actf \ - --from data/caiyuxuan/debug/ \ - --output "$smoke_parent/test" \ - --output-format storyline -test -f "$smoke_parent/test/CURRENT" -test ! -e "$smoke_parent/test/terminal_bench_2_1" -target/release/pchronicle query "$smoke_parent/test" \ - "SELECT _file_ AS source_file, COUNT(*) AS runs FROM dataset.runs GROUP BY _file_" \ - --format jsonl -``` - -Expected: import succeeds, the only query row has `source_file` equal to `.` and a positive run count, and no input-derived hierarchy exists. Keep the printed temporary path until results are recorded; remove only that exact `mktemp` directory afterward. - -- [ ] **Step 5: Inspect final scope and working-tree safety** - -Run: - -```sh -git diff --check -git status --short -git diff --stat -- \ - crates/persisting-pchronicle-cli/src/exchange.rs \ - crates/persisting-pchronicle-cli/src/lib.rs \ - crates/persisting-pchronicle-cli/src/tests.rs \ - crates/persisting-pchronicle-cli/README.md \ - docs/src/pchronicle/reference/cli.md \ - docs/src/pchronicle/guides/exchange.md \ - docs/src/pchronicle/guides/exchange.zh.md -``` - -Expected: no whitespace errors, no excluded subsystem changes attributable to this implementation, and all pre-existing user modifications remain present and unstaged. diff --git a/docs/superpowers/plans/2026-08-21-openai-messages-field-mapping.md b/docs/superpowers/plans/2026-08-21-openai-messages-field-mapping.md deleted file mode 100644 index 1e5f75c7..00000000 --- a/docs/superpowers/plans/2026-08-21-openai-messages-field-mapping.md +++ /dev/null @@ -1,667 +0,0 @@ -# OpenAI Messages Field Mapping Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Make the pChronicle OpenAI Messages adapter map every supported source field into Storyline/ATIF, ignore known optional empty values, and retain only genuinely unmapped values in `unknown_fields`. - -**Architecture:** Decode each owned OpenAI row through a destructive projection: mapping code removes every consumed field from the row, while known optional empty fields are discarded. The residual row is then the sole input to unknown-field capture. OpenAI export is canonical and reconstructs cumulative messages plus rows from Storyline semantics; it does not preserve the source document's physical layout. - -**Tech Stack:** Rust 2021, `serde_json::Value`, Storyline/ATIF conversion types, pChronicle unknown-fields support, Cargo unit and integration tests. - -**Spec:** `docs/superpowers/specs/2026-08-20-openai-messages-field-mapping-design.md` - -## Global Constraints - -- Map only fields named by the spec into existing Storyline/ATIF fields; do not use `extra` as a source metadata bucket. -- Treat known optional `null`, empty arrays, and empty objects as absent; preserve empty `content` because it is a mapped message value. -- Put every remaining source member in `unknown_fields` with its complete JSON value. -- Preserve the existing embedded-text tool-call parser and prefer structured `tool_calls` when present. -- Guarantee logical Storyline roundtrip, not byte identity, row order, snapshot-window shape, object-key order, or null-versus-missing identity. -- Do not add a consumed-path registry, row carrier, wire/schema field, Lance column, or generic unknown-fields protocol. -- Do not modify TTAS, Queue/Sampler, Search, or standalone `persisting-dlcapt`. -- Preserve unrelated dirty-worktree changes; stage only task-owned files or hunks. - ---- - -### Task 1: Make field projection authoritative for unknown capture - -**Files:** -- Modify: `crates/persisting-pchronicle/src/formats/openai_corpus.rs:32-274` -- Modify: `crates/persisting-pchronicle/src/formats/openai_corpus.rs:875-1362` -- Test: `crates/persisting-pchronicle/src/formats/openai_corpus.rs:1364-1614` - -**Interfaces:** -- Consumes: `StorylineDocument`, `StorylineTurn`, `StorylineToolCall`, `StorylineTimestamp`, and the existing `StorylineUnknownFields::insert` API. -- Produces: `rows_to_storyline(session_id, records: &mut [(usize, Value)], relative_path) -> InputResult`; residual rows whose mapped fields have been removed; `normalized_metrics` containing all approved row and env-state keys. - -- [ ] **Step 1: Replace the old residual expectations with a failing field-partition test** - -Replace `openai_step_id_without_formal_carrier_is_unknown` and update `openai_noncanonical_source_fields_are_unknown_without_source_extra` so the fixture includes all mapping classes and asserts the exclusive partition: - -```rust -#[test] -fn openai_maps_known_fields_and_only_keeps_unmapped_values() { - let input = json!({"session_steps": [{ - "dataset_type": "TEST", - "id": "event-1", - "session_id": "session-1", - "step_id": 1, - "job_id": "job-7", - "agent_model": "model-3", - "created_at": 1_785_578_400.25, - "reward": 0.75, - "step_reward": -0.25, - "is_terminal": true, - "is_truncated": false, - "is_session_completed": true, - "is_trainable": false, - "env_id": "session-1", - "messages": [{ - "role": "user", - "content": "inspect", - "name": null, - "refusal": null, - "tool_call_id": null, - "tool_calls": null - }], - "response": { - "role": "assistant", - "content": "done", - "name": null, - "refusal": null, - "tool_call_id": null, - "tool_calls": null - }, - "meta_json": { - "source": "fixture", - "group_id": "group-1", - "env_state": { - "session_id": "session-1", - "requested_model": "model-3", - "llm_step_index": 1, - "total_tokens": 3, - "total_latency_ms": 12.75, - "ttft_ms": 2.5, - "request_id": "request-1" - } - }, - "blob_manifest": [], - "chosen_response": null, - "vendor_row": {"kept": true} - }]}); - - let stories = parse_openai_msg_corpus_value(&input, "source.json").unwrap(); - let story = &stories[0]; - let fields = &story.unknown_fields.sources["openai-msg"].fields; - - assert_eq!(story.agent.id, "fixture"); - assert_eq!(story.run_id.as_deref(), Some("job-7")); - assert_eq!(story.agent.model_name.as_deref(), Some("model-3")); - assert_eq!(story.turns[1].metrics.as_ref().unwrap()["is_terminal"], true); - assert_eq!(story.turns[1].metrics.as_ref().unwrap()["is_session_completed"], true); - assert_eq!(story.turns[1].metrics.as_ref().unwrap()["total_tokens"], 3); - assert_eq!(story.turns[1].latency_ms, Some(12)); - assert_eq!(story.turns[1].ttft_ms, Some(2)); - - assert_eq!(fields["/session_steps/0/dataset_type"], "TEST"); - assert_eq!(fields["/session_steps/0/id"], "event-1"); - assert_eq!(fields["/session_steps/0/vendor_row"], json!({"kept": true})); - assert_eq!(fields["/session_steps/0/meta_json/group_id"], "group-1"); - assert_eq!( - fields["/session_steps/0/meta_json/env_state/request_id"], - "request-1" - ); - - for mapped in [ - "/session_steps/0/step_id", - "/session_steps/0/is_terminal", - "/session_steps/0/is_truncated", - "/session_steps/0/is_session_completed", - "/session_steps/0/is_trainable", - "/session_steps/0/env_id", - "/session_steps/0/messages/0/role", - "/session_steps/0/messages/0/content", - "/session_steps/0/messages/0/name", - "/session_steps/0/messages/0/refusal", - "/session_steps/0/messages/0/tool_call_id", - "/session_steps/0/messages/0/tool_calls", - "/session_steps/0/response/role", - "/session_steps/0/response/content", - ] { - assert!(!fields.contains_key(mapped), "mapped field leaked: {mapped}"); - } -} -``` - -- [ ] **Step 2: Run the field-partition test and verify RED** - -Run: - -```bash -cargo test -p persisting-pchronicle openai_maps_known_fields_and_only_keeps_unmapped_values -- --nocapture -``` - -Expected: FAIL because `step_id`, status fields, empty message options, and the complete `meta_json` are still captured as unknown, and the four row status flags are absent from metrics. - -- [ ] **Step 3: Change parsing to mutate residual rows instead of classifying them twice** - -Change the group loop to retain one mutable row collection: - -```rust -for (session_id, mut records) in groups { - let story_index = stories.len(); - let mut story = rows_to_storyline(&session_id, &mut records, &relative_path)?; - capture_openai_unknowns(&mut story, &relative_path, &root_unknown, &records)?; - story.unknown_key_counts = validate_unknown_fields_with( - &story.unknown_fields, - UnknownFieldLimits::default(), - normalize_openai_pointer, - )?; - for (ordinal, _) in records { - carriers.push(CarrierBinding { - story_index, - pointer: format!("/session_steps/{ordinal}"), - }); - } - stories.push(story); -} -``` - -Delete `is_canonical_openai_row_field`. Reduce `capture_openai_unknowns` to copying root unknowns and the members still present in each residual row. Keep specialized traversal only for partially consumed `messages`, `response`, `meta_json`, and `meta_json.env_state`, so their remaining direct members retain paths such as `/messages/0/name` and `/meta_json/env_state/request_id`; this traversal must not decide canonical ownership. - -Add schema-aware empty handling: - -```rust -fn is_known_optional_empty(value: &Value) -> bool { - match value { - Value::Null => true, - Value::Array(values) => values.is_empty(), - Value::Object(values) => values.is_empty(), - _ => false, - } -} - -fn discard_known_optional_empty(object: &mut Map, key: &str) { - if object.get(key).is_some_and(is_known_optional_empty) { - object.remove(key); - } -} -``` - -Use it only for source-schema optional keys: `response`, `name`, `refusal`, `tool_call_id`, `tool_calls`, `blob_manifest`, `chosen_response`, `rejected_response`, `ground_truth_answer`, and `reference_answer`. Do not apply it to arbitrary vendor keys or `content`. - -- [ ] **Step 4: Consume row, message, tool-call, and meta fields during mapping** - -Change the function signature to accept `records: &mut [(usize, Value)]`, then sort the owned residual rows in place with: - -```rust -records.sort_by_key(|(_, row)| row.get("step_id").and_then(Value::as_i64)); -``` - -For each row, read and remove `session_id`, positive integer `step_id`, `agent_id`, run/model/timestamp fields, row metrics, valid aliases, and known optional empty fields. Use `agent_id` first, then `meta_json.source`, then the selected model as the Storyline agent ID. Parse `meta_json` whether it is a JSON string or object, remove `source`, approved aliases, and metric-whitelist members, then reinsert only the residual meta object when it is non-empty. If parsing fails, reinsert the original `meta_json` unchanged. - -For each recognized message object: - -1. clone the complete `content` for Storyline before removing `content`; -2. remove a recognized `role` after it selects the Storyline source; -3. parse valid structured `tool_calls`, remove `id`, `type=function`, `function.name`, and `function.arguments`, and retain any remaining tool-call members at their original positions; -4. remove a linked role=`tool` `tool_call_id` after building an observation/result; -5. remove explicit `reasoning_content` after mapping it; -6. discard only the approved optional empty fields; -7. leave non-empty `name`, malformed tool calls, unlinked IDs, and vendor members in the residual message. - -Update `normalized_metrics` to include this exact row list: - -```rust -const ROW_METRIC_FIELDS: &[&str] = &[ - "reward", - "step_reward", - "is_terminal", - "is_truncated", - "is_session_completed", - "is_trainable", -]; -``` - -and this exact env-state list: - -```rust -const ENV_METRIC_FIELDS: &[&str] = &[ - "prompt_tokens", "completion_tokens", "total_tokens", - "request_bytes", "response_bytes", "output_bytes", "output_chunk_count", - "upstream_latency_ms", "gateway_overhead_ms", "total_latency_ms", "ttft_ms", - "retry_count", "status_code", "finish_reason", "truncate_reason", - "error_type", "error_text", "client_cancelled", "upstream_cancelled", - "synthetic_stop", "is_truncated", "is_session_completed", "max_steps", - "is_stream", "payload_sampled", "created_at", "completed_at", -]; -``` - -Row metrics win over same-named env metrics. Remove an equal env duplicate; retain a differing duplicate in the residual env object. - -- [ ] **Step 5: Map refusal text and verify GREEN** - -Update `openai_refusal_only_response_is_a_known_output` to require: - -```rust -assert_eq!(stories[0].turns[1].message, "I cannot help with that."); -assert!(!stories[0].unknown_fields.sources["openai-msg"] - .fields - .contains_key("/session_steps/0/response/refusal")); -``` - -Run: - -```bash -cargo test -p persisting-pchronicle openai_maps_known_fields_and_only_keeps_unmapped_values -- --nocapture -cargo test -p persisting-pchronicle openai_refusal_only_response_is_a_known_output -- --nocapture -``` - -Expected: both PASS. - -- [ ] **Step 6: Commit the field projection** - -Stage only task-owned changes and commit: - -```bash -git add -p crates/persisting-pchronicle/src/formats/openai_corpus.rs -git commit -m "fix(pchronicle): map known OpenAI corpus fields" -``` - ---- - -### Task 2: Import first-row context and derive stable turn IDs - -**Files:** -- Modify: `crates/persisting-pchronicle/src/formats/openai_corpus.rs:875-1106` -- Test: `crates/persisting-pchronicle/src/formats/openai_corpus.rs:1673-1944` -- Test: `crates/persisting-pchronicle/src/tests.rs:453-495` - -**Interfaces:** -- Consumes: mutable residual rows and mapped-message helpers from Task 1. -- Produces: context turns marked `is_copied_context=true`; user/agent turn IDs derived from source `step_id`; one current interaction per row. - -- [ ] **Step 1: Add a failing context and step-ID test** - -```rust -#[test] -fn openai_imports_first_row_context_once_and_offsets_step_ids() { - let input = json!({"session_steps": [ - { - "session_id": "s", - "step_id": 1, - "messages": [ - {"role": "system", "content": "policy"}, - {"role": "user", "content": "prior question"}, - {"role": "user", "content": "first question"}, - {"role": "assistant", "content": "first answer"} - ], - "response": null - }, - { - "session_id": "s", - "step_id": 2, - "messages": [ - {"role": "system", "content": "policy"}, - {"role": "user", "content": "prior question"}, - {"role": "user", "content": "first question"}, - {"role": "assistant", "content": "first answer"}, - {"role": "user", "content": "second question"}, - {"role": "assistant", "content": "second answer"} - ], - "response": null - } - ]}); - - let story = parse_openai_msg_corpus_value(&input, "context.json") - .unwrap() - .remove(0); - assert_eq!(story.turns.len(), 6); - assert_eq!(story.turns.iter().map(|turn| turn.id).collect::>(), vec![1, 2, 3, 4, 5, 6]); - assert_eq!(story.turns[0].source, "system"); - assert_eq!(story.turns[0].message, "policy"); - assert_eq!(story.turns[1].message, "prior question"); - assert_eq!(story.turns[0].is_copied_context, Some(true)); - assert_eq!(story.turns[1].is_copied_context, Some(true)); - assert_eq!(story.turns[2].message, "first question"); - assert_eq!(story.turns[3].message, "first answer"); - assert_eq!(story.turns[4].message, "second question"); - assert_eq!(story.turns[5].message, "second answer"); -} -``` - -- [ ] **Step 2: Run the context test and verify RED** - -Run: - -```bash -cargo test -p persisting-pchronicle openai_imports_first_row_context_once_and_offsets_step_ids -- --nocapture -``` - -Expected: FAIL because the current importer emits only four current-interaction turns and assigns sequential IDs without first-row context. - -- [ ] **Step 3: Build context before consuming current interactions** - -For the first sorted row, identify the selected output and the final user preceding it. Convert every recognized message before that user into one context turn. Preserve full content, explicit reasoning, structured tool calls, tool observations, and role mapping through this constructor: - -```rust -fn openai_context_turn( - id: i64, - source: String, - message: Value, - reasoning_content: Option, - tool_calls: Option>, - observation: Option, -) -> StorylineTurn { - StorylineTurn { - id, - kind: None, - timestamp: None, - source, - message, - reasoning_content, - reasoning_effort: None, - tool_calls, - observation, - metrics: None, - model_name: None, - llm_call_count: None, - is_copied_context: Some(true), - latency_ms: None, - ttft_ms: None, - extra: None, - } -} -``` - -For every row with source `step_id = n`, assign: - -```rust -let user_turn_id = context_count + 2 * step_id - 1; -let agent_turn_id = context_count + 2 * step_id; -``` - -Reject a non-positive step ID before multiplication. Use checked integer arithmetic and return `InputIssue::invalid("OpenAI corpus step_id overflows Storyline turn id")` if the formula overflows. Continue rejecting duplicate step IDs. Do not check continuity. - -- [ ] **Step 4: Consume repeated snapshots without emitting duplicate turns** - -Process the known message fields of every row so `role`, `content`, valid tool calls, linked tool IDs, and approved empty optionals are removed from the residual even when the message belongs to repeated history. Only first-row leading context and each row's final current user/output create turns. - -Run: - -```bash -cargo test -p persisting-pchronicle openai_imports_first_row_context_once_and_offsets_step_ids -- --nocapture -cargo test -p persisting-pchronicle corpus_preserves_run_group_and_user_agent_turns -- --nocapture -``` - -Expected: both PASS. - -- [ ] **Step 5: Update zero-based legacy fixtures to the positive source-step contract** - -In `crates/persisting-pchronicle/src/tests.rs`, change the OpenAI fixture `step_id` values in `openai_msg_preserves_user_and_llm_turns` and `convert_openai_msg_storyline_roundtrip_messages` from `0` to `1`. Add assertions that the two generated turn IDs are `1` and `2` when there is no context. - -Run: - -```bash -cargo test -p persisting-pchronicle openai_msg_preserves_user_and_llm_turns -- --nocapture -cargo test -p persisting-pchronicle convert_openai_msg_storyline_roundtrip_messages -- --nocapture -``` - -Expected: both PASS. - -- [ ] **Step 6: Commit context and ID mapping** - -```bash -git add -p crates/persisting-pchronicle/src/formats/openai_corpus.rs crates/persisting-pchronicle/src/tests.rs -git commit -m "feat(pchronicle): import OpenAI message context" -``` - ---- - -### Task 3: Replace physical recovery with canonical logical export - -**Files:** -- Modify: `crates/persisting-pchronicle/src/formats/openai_corpus.rs:274-874` -- Test: `crates/persisting-pchronicle/src/formats/openai_corpus.rs:1538-2054` -- Test: `crates/persisting-pchronicle/tests/import_roundtrip_fixtures.rs:1-90` - -**Interfaces:** -- Consumes: context markers, mapped turns, metrics, and unknown residuals from Tasks 1-2. -- Produces: canonical `session_steps` rows with cumulative `messages`, current `response`, inverse step IDs, mapped metadata, and existing unknown-field carriage. -- Produces: `encode_context_turns(&[StorylineTurn]) -> Result>`, `storyline_interactions(&[StorylineTurn]) -> Result>`, `encode_agent_response(&StorylineTurn) -> Result`, `encode_agent_history(&StorylineTurn) -> Result>`, and `openai_step_id(&StorylineDocument, i64, usize, &StorylineTurn, &StorylineTurn) -> Result`. - -- [ ] **Step 1: Replace exact-source tests with a failing logical-roundtrip test** - -```rust -#[test] -fn openai_logical_roundtrip_preserves_mapped_storyline_fields() { - let input = json!({"session_steps": [ - { - "session_id": "s", - "step_id": 1, - "agent_model": "model-1", - "is_terminal": false, - "messages": [ - {"role": "system", "content": "policy"}, - {"role": "user", "content": "one"} - ], - "response": {"role": "assistant", "content": "first"} - }, - { - "session_id": "s", - "step_id": 2, - "agent_model": "model-1", - "is_terminal": true, - "messages": [ - {"role": "system", "content": "policy"}, - {"role": "user", "content": "one"}, - {"role": "assistant", "content": "first"}, - {"role": "user", "content": "two"} - ], - "response": {"role": "assistant", "content": "second"} - } - ]}); - - let first = parse_openai_msg_corpus_value(&input, "logical.json").unwrap(); - let encoded = recover_openai_msg_files(&first).unwrap().remove(0).document; - let second = parse_openai_msg_corpus_value(&encoded, "logical.json").unwrap(); - assert_eq!(second, first); - assert_eq!(encoded["session_steps"][0]["messages"].as_array().unwrap().len(), 2); - assert_eq!(encoded["session_steps"][1]["messages"].as_array().unwrap().len(), 4); - assert_eq!(encoded["session_steps"][1]["step_id"], 2); -} -``` - -- [ ] **Step 2: Run the logical roundtrip and verify RED** - -Run: - -```bash -cargo test -p persisting-pchronicle openai_logical_roundtrip_preserves_mapped_storyline_fields -- --nocapture -``` - -Expected: FAIL because current recovery requires `step_id` in residual templates, preserves source output locations, and does not encode first-row context as cumulative history. - -- [ ] **Step 3: Remove source-template recovery machinery** - -Delete `OpenaiSourceRowTemplate`, `openai_source_row_templates`, `insert_openai_template_value`, `recover_openai_source_row`, `patch_openai_tool_calls`, and `remap_openai_pointer`. Remove `OpenaiEncodingMode::Recovery`; retain one canonical encoder for both projection and same-source recovery. - -Keep `recover_openai_msg_files` grouping stories by OpenAI `source_document_id`/origin path, but have it call the canonical encoder. After canonical rows exist, restore source-format residual pointers directly with `PointerWrite::InsertOnly`; mapped fields are absent from residual by construction, so they cannot compete with canonical values. - -- [ ] **Step 4: Encode context and cumulative messages** - -For each Storyline, split the leading `is_copied_context == Some(true)` turns from interaction turns. Encode context roles as `system`, `user`, `assistant`, or `tool`. Iterate the remaining turns as required user/agent pairs: - -```rust -let context_len = story - .turns - .iter() - .take_while(|turn| turn.is_copied_context == Some(true)) - .count(); -let context_count = i64::try_from(context_len)?; -let context = &story.turns[..context_len]; -let interactions = storyline_interactions(&story.turns[context_len..])?; -let mut history = encode_context_turns(context)?; -for (interaction_index, (user, agent)) in interactions.into_iter().enumerate() { - let step_id = openai_step_id( - story, - context_count, - interaction_index, - user, - agent, - )?; - let mut messages = history.clone(); - messages.push(json!({"role": "user", "content": user.message})); - let response = encode_agent_response(agent)?; - rows.push(json!({ - "session_id": story.session_id, - "step_id": step_id, - "messages": messages, - "response": response, - })); - history.push(json!({"role": "user", "content": user.message})); - history.extend(encode_agent_history(agent)?); -} -``` - -`openai_step_id` uses the inverse ID formula for OpenAI-origin stories: `n = (agent.id - k) / 2`, then verifies user ID `k + 2n - 1` and agent ID `k + 2n`. For Storylines projected from another format it returns `interaction_index + 1` without inventing source metadata. - -- [ ] **Step 5: Encode mapped row fields and env-state metrics** - -Populate row `job_id`, `agent_id`, `agent_model`, `created_at`, reward/status fields, and a canonical `meta_json.env_state` object from the matching Storyline fields. Put the approved env metrics back under `env_state`; do not export arbitrary metrics as OpenAI metadata. Encode structured tool calls with `type=function`, JSON-string arguments, observations/results, and explicit reasoning when present. - -Update tests that formerly asserted byte/model-exact source restoration: - -- `openai_recovery_preserves_message_output_location_and_embedded_tool_encoding` must assert semantic message/tool-call values instead of the original output location; -- `openai_recovery_preserves_nondefault_source_fields_without_fabrication` must assert unmapped values remain available through `unknown_fields` and mapped values survive reparse; -- `openai_recovery_keeps_known_message_shape_and_argument_semantics` must compare reparsed Storyline semantics; -- Lance roundtrip tests must compare canonical encoded output or reparsed Storylines, not the original JSON model. - -- [ ] **Step 6: Run logical and fixture roundtrips** - -```bash -cargo test -p persisting-pchronicle openai_logical_roundtrip_preserves_mapped_storyline_fields -- --nocapture -cargo test -p persisting-pchronicle --test import_roundtrip_fixtures -- --nocapture -cargo test -p persisting-pchronicle corpus_import_and_recovery_roundtrip_through_lance --features lance-store -- --nocapture -``` - -Expected: all PASS. - -- [ ] **Step 7: Commit canonical export** - -```bash -git add -p crates/persisting-pchronicle/src/formats/openai_corpus.rs crates/persisting-pchronicle/tests/import_roundtrip_fixtures.rs -git commit -m "refactor(pchronicle): canonicalize OpenAI message export" -``` - ---- - -### Task 4: Verify CLI warnings, SQL projection, and the real corpus - -**Files:** -- Modify: `crates/persisting-pchronicle-cli/src/tests.rs:1347-1424` -- Test: `crates/persisting-pchronicle-cli/src/tests.rs` -- Verify: `data/cybergym_0729001.json` - -**Interfaces:** -- Consumes: completed OpenAI mapping and canonical encoder from Tasks 1-3. -- Produces: user-visible warnings that contain only unmapped fields; evidence for the 46 MB corpus counts and queryable status metrics. - -- [ ] **Step 1: Update the failing CLI warning test** - -In `import_counts_shared_openai_root_unknown_once_across_sessions`, keep the root warning assertion and replace the old nullable-message warning expectations with: - -```rust -for mapped in [ - "/step_id", - "/is_terminal", - "/is_truncated", - "/is_session_completed", - "/is_trainable", - "/messages/*/role", - "/messages/*/content", - "/messages/*/name", - "/messages/*/refusal", - "/messages/*/tool_call_id", -] { - assert!(!stderr.contains(mapped), "mapped OpenAI field warned: {mapped}\n{stderr}"); -} -assert!(stderr.contains("source=openai-msg key=/vendor_root occurrences=1")); -``` - -Add one non-empty `vendor_row` field to each row and assert its normalized warning count is `2`. - -- [ ] **Step 2: Run the CLI warning test and verify behavior** - -```bash -cargo test -p persisting-pchronicle-cli import_counts_shared_openai_root_unknown_once_across_sessions -- --nocapture -``` - -Expected: PASS after Tasks 1-3; before updating assertions, the old test fails because nullable known fields no longer warn. - -- [ ] **Step 3: Run all targeted OpenAI tests** - -```bash -cargo test -p persisting-pchronicle openai -- --nocapture -cargo test -p persisting-pchronicle-cli openai -- --nocapture -cargo test -p persisting-pchronicle --test conversion_semantics -- --nocapture -cargo test -p persisting-pchronicle --test import_roundtrip_fixtures -- --nocapture -``` - -Expected: all PASS. - -- [ ] **Step 4: Build the release CLI and import the real corpus** - -```bash -cargo build -p persisting-pchronicle-cli --release -check_dir="$(mktemp -d /tmp/pchronicle-openai-map.XXXXXX)" -./target/release/pchronicle import \ - --format openai-messages \ - --from data/cybergym_0729001.json \ - --output "$check_dir/dataset" \ - --output-format storyline \ - >"$check_dir/import.json" \ - 2>"$check_dir/import.stderr" -``` - -Check that `import.stderr` does not contain mapped warning keys: - -```bash -if rg 'key=/session_steps/\*/(step_id|messages/\*/(role|content|tool_calls)|response/)' "$check_dir/import.stderr"; then - exit 1 -fi -``` - -- [ ] **Step 5: Query corpus counts and mapped status fields** - -```bash -./target/release/pchronicle query "$check_dir/dataset" \ - 'SELECT COUNT(*) AS trajectories FROM dataset.runs' --format jsonl -./target/release/pchronicle query "$check_dir/dataset" \ - 'SELECT COUNT(*) AS turns FROM dataset.steps' --format jsonl -./target/release/pchronicle query "$check_dir/dataset" \ - 'SELECT COUNT(*) AS tool_calls FROM dataset.tool_calls' --format jsonl -./target/release/pchronicle query "$check_dir/dataset" \ - "SELECT COUNT(*) AS completed FROM dataset.steps WHERE source = 'agent' AND metrics_json LIKE '%\"is_session_completed\":true%'" \ - --format jsonl -``` - -Expected: `trajectories=8`, `turns=964`, `tool_calls=461`, and `completed=4`. - -- [ ] **Step 6: Run formatting, lint, and focused regression checks** - -```bash -cargo fmt -p persisting-pchronicle -p persisting-pchronicle-cli -- --check -cargo clippy -p persisting-pchronicle -p persisting-pchronicle-cli --all-targets -- -D warnings -cargo test -p persisting-pchronicle -cargo test -p persisting-pchronicle-cli -``` - -Expected: targeted crates pass. Report unrelated failures without expanding into excluded subsystems. - -- [ ] **Step 7: Commit CLI acceptance coverage** - -```bash -git add -p crates/persisting-pchronicle-cli/src/tests.rs -git commit -m "test(pchronicle): verify OpenAI mapping warnings" -``` diff --git a/docs/superpowers/plans/2026-08-21-pchronicle-automatic-projection.md b/docs/superpowers/plans/2026-08-21-pchronicle-automatic-projection.md deleted file mode 100644 index 0e02485c..00000000 --- a/docs/superpowers/plans/2026-08-21-pchronicle-automatic-projection.md +++ /dev/null @@ -1,1314 +0,0 @@ -# pChronicle Automatic Storyline Projection Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Remove the public `pchronicle project` command and make canonical `events.lance` projection a create-only `import` mode plus an automatic, continuously maintained `serve` responsibility reported by `status`. - -**Architecture:** Add a library-owned automatic-projection inventory and convergence layer on top of the existing pinned canonical-event and generation/CAS Storyline primitives. The CLI uses that layer at three boundaries: `import` performs one explicit create-only projection, `status` performs read-only inspection, and `serve` converges before readiness then runs a bounded per-source retry supervisor. Warehouse serving receives a prepared shared Catalog handle so successful projection publication or changed canonical facts can build a complete replacement Catalog runtime and atomically swap it without interrupting the old snapshot. - -**Tech Stack:** Rust 2021 workspace, Tokio, Clap, Futures, Lance, DataFusion, Axum, object_store, Serde, existing pChronicle Catalog and Storyline generation/CAS storage. - -**Spec:** `docs/superpowers/specs/2026-08-21-pchronicle-automatic-projection-design.md` - -## Global Constraints - -- `events.lance` remains the source of truth; `storyline` is rebuildable derived state. -- Do not put projection work on the Gateway or Control append acknowledgement path. -- Do not change canonical event ordering, append acknowledgement, fencing, or physical storage semantics. -- Automatic destinations are deterministic siblings: `run/events.lance` maps to `run/storyline` for local and object-store URIs. -- Never overwrite an existing destination whose committed lineage does not identify the matching canonical source. -- Initial projection failure prevents the single `serve` readiness record; runtime projection failure does not stop Warehouse, Control, or Gateway. -- Runtime work is bounded, uses capped per-source backoff, and coalesces Catalog refreshes. -- A failed Catalog rebuild retains the previously installed Catalog runtime. -- `serve` without `--listen` does not construct an unused Warehouse Catalog runtime. -- `status` is observational and never creates, syncs, rebuilds, or publishes projection state. -- Remove `project` without an alias while retaining the underlying Rust projection operations for internal callers and tests. -- Keep TTAS, Queue, Search, and `persisting-dlcapt` out of scope. - ---- - -## File map - -- Create `crates/persisting-pchronicle/src/projection/automatic.rs`: deterministic target derivation, Catalog inventory, read-only health inspection, and one-source convergence. -- Modify `crates/persisting-pchronicle/src/projection/mod.rs`: expose the automatic-projection types and functions. -- Modify `crates/persisting-pchronicle/src/store/events/datafusion.rs`: add manifest-only canonical-store probing without opening every Lance segment. -- Modify `crates/persisting-pchronicle/src/store/catalog/mod.rs`: expose an internal pinned canonical-source view used to build snapshot-consistent inventories. -- Modify `crates/persisting-pchronicle/src/store/storyline/mod.rs`: add a read-only destination-existence check for create-only import. -- Modify `crates/persisting-pchronicle/src/storage.rs`: re-export the new library boundary. -- Modify `crates/persisting-pchronicle/src/projection/storyline.rs`: make the minimum lineage helpers visible to the sibling automatic module and remove CLI-specific error wording. -- Modify `crates/persisting-pchronicle-cli/src/exchange.rs`: split JSON import from canonical-event projection import. -- Modify `crates/persisting-pchronicle-cli/src/lib.rs`: make `--output-format` contextual, remove `project`, add projection status records, and wire the supervisor into `serve`. -- Modify `crates/persisting-pchronicle-cli/src/output.rs`: add the compact projection summary to table status output. -- Create `crates/persisting-pchronicle-cli/src/projection_supervisor.rs`: startup convergence, runtime discovery, per-source retry state, shutdown, and Catalog refresh coalescing. -- Modify `crates/persisting-pchronicle-cli/src/server/mod.rs`: introduce a prepared Warehouse handle with atomic Catalog replacement. -- Modify `crates/persisting-pchronicle-cli/src/tests.rs`: parser, import, status, and in-process supervisor coverage. -- Modify `crates/persisting-pchronicle-cli/src/server/tests.rs`: prepared Catalog and failed-refresh retention coverage. -- Modify `crates/persisting-pchronicle-cli/tests/control_process.rs`: readiness, runtime discovery, durable-write independence, and Warehouse refresh process coverage. -- Modify `crates/persisting-pchronicle-cli/tests/binary_contract.rs`: absence of `project` and release-profile canonical import smoke coverage. -- Modify pChronicle READMEs and the English/Chinese CLI, exchange, serve, Storyline, and Catalog documentation listed in Task 8. - -### Task 1: Canonical-store probing and deterministic projection inventory - -**Files:** -- Create: `crates/persisting-pchronicle/src/projection/automatic.rs` -- Modify: `crates/persisting-pchronicle/src/projection/mod.rs` -- Modify: `crates/persisting-pchronicle/src/store/events/datafusion.rs` -- Modify: `crates/persisting-pchronicle/src/store/catalog/mod.rs` -- Modify: `crates/persisting-pchronicle/src/store/storyline/mod.rs` -- Modify: `crates/persisting-pchronicle/src/storage.rs` - -**Interfaces:** -- Consumes: `DatasetCatalogSnapshot`, `DatasetMount`, `DiscoveredSource`, `CatalogSourceRevision::Events`, `RawEventDataSource`, `StorylineLanceStore`, and `EventFactSnapshot`. -- Produces: - -```rust -pub async fn probe_canonical_event_store( - uri: impl AsRef, -) -> anyhow::Result>; - -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct AutomaticProjectionTarget { - pub dataset: String, - pub source_path: String, - pub source_uri: String, - pub projection_path: String, - pub projection_uri: String, - pub source_snapshot: EventFactSnapshot, -} - -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct AutomaticProjectionInventoryError { - pub dataset: String, - pub source_path: String, - pub projection_path: String, -} - -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct AutomaticProjectionInventory { - pub snapshot_id: String, - pub targets: Vec, - pub errors: Vec, -} - -pub fn automatic_projection_inventory( - snapshot: &DatasetCatalogSnapshot, -) -> anyhow::Result; - -#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize)] -#[serde(rename_all = "snake_case")] -pub enum AutomaticProjectionState { - Fresh, - Stale, - Missing, -} - -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct AutomaticProjectionInspection { - pub state: AutomaticProjectionState, - pub generation: Option, - pub fact_version: u64, - pub fact_rows: u64, -} - -pub async fn inspect_automatic_storyline_projection( - target: &AutomaticProjectionTarget, -) -> anyhow::Result; - -pub async fn storyline_projection_destination_exists( - uri: impl AsRef, -) -> anyhow::Result; -``` - -- `DatasetCatalogSnapshot` adds a crate-visible `canonical_event_sources()` accessor returning the exact pinned `source_uri`, source path, Dataset name, and `EventFactSnapshot`; it does not expose mutable Catalog internals. -- `RawEventDataSource` adds `pub async fn probe_uri(uri: impl AsRef) -> Result>`; the free `probe_canonical_event_store` wrapper lives in `projection::automatic` so CLI callers need only the projection boundary. - -- [ ] **Step 1: Write failing manifest-probe and URI-mapping tests** - -Add unit tests that distinguish a real manifest from a suffix and cover local, nested, direct-root, and object-store names: - -```rust -#[tokio::test] -async fn canonical_probe_requires_a_valid_nonempty_manifest() -> Result<()> { - let temp = tempfile::tempdir()?; - let suffix_only = temp.path().join("events.lance"); - std::fs::create_dir(&suffix_only)?; - assert_eq!(probe_canonical_event_store(suffix_only.to_string_lossy()).await?, None); - - let storage = temp.path().join("capture"); - append_note(&storage, "session", 0).await?; - let source = raw_event_lance_path(&coords(&storage, "session"))?; - let snapshot = probe_canonical_event_store(source.to_string_lossy()) - .await? - .expect("written canonical store must be detected"); - assert_eq!(snapshot.fact_rows, 1); - Ok(()) -} - -#[test] -fn projection_target_is_a_sibling_for_local_and_object_uris() -> Result<()> { - assert_eq!( - automatic_projection_uri("/tmp/run/events.lance")?, - "/tmp/run/storyline" - ); - assert_eq!( - automatic_projection_uri("s3://bucket/jobs/7/events.lance")?, - "s3://bucket/jobs/7/storyline" - ); - assert!(automatic_projection_uri("/tmp/run/not-events").is_err()); - Ok(()) -} -``` - -- [ ] **Step 2: Run the focused tests and verify failure** - -Run: - -```bash -cargo test -p persisting-pchronicle --lib projection::automatic::tests::canonical_probe_requires_a_valid_nonempty_manifest -cargo test -p persisting-pchronicle --lib projection::automatic::tests::projection_target_is_a_sibling_for_local_and_object_uris -``` - -Expected: compilation fails because `automatic` and `probe_canonical_event_store` do not exist. - -- [ ] **Step 3: Implement manifest-only probing and deterministic URI mapping** - -Normalize existing local inputs with `std::fs::canonicalize`, retain object-store URIs, call the existing validated manifest reader, and return `None` only when no manifest exists. A malformed manifest or a manifest without visible segments is an error: - -```rust -pub async fn probe_uri( - uri: impl AsRef, -) -> Result> { - let requested = uri.as_ref(); - let normalized = if requested.contains("://") { - requested.trim_end_matches('/').to_owned() - } else { - match std::fs::canonicalize(requested) { - Ok(path) => path.to_string_lossy().into_owned(), - Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None), - Err(error) => return Err(error).context("canonicalize canonical event probe"), - } - }; - let Some(manifest) = super::pin_visible_snapshot(&normalized).await? else { - return Ok(None); - }; - anyhow::ensure!( - !manifest.segments.is_empty(), - "canonical event manifest has no visible segments at {normalized}" - ); - Ok(Some(EventFactSnapshot { - source_uri: normalized, - fact_version: manifest.fact_version, - fact_rows: manifest.fact_rows, - layout_revision: manifest.revision, - })) -} - -pub async fn probe_canonical_event_store( - uri: impl AsRef, -) -> Result> { - RawEventDataSource::probe_uri(uri).await -} -``` - -For object URIs, remove exactly the terminal `/events.lance` segment and append `/storyline`. For local paths, require a terminal `events.lance` component and use `Path::parent().join("storyline")`. - -- [ ] **Step 4: Write failing inventory and observational-inspection tests** - -Build a Dataset containing two nested canonical stores, one fresh sidecar, one absent sidecar, and a separate malformed Storyline pointer. Assert stable sorting and that inspection creates no directory: - -```rust -let inventory = automatic_projection_inventory(&snapshot)?; -assert_eq!( - inventory.targets.iter().map(|target| target.source_path.as_str()).collect::>(), - ["a/events.lance", "b/events.lance"] -); -assert_eq!(inventory.targets[0].projection_path, "a/storyline"); -assert_eq!(inventory.targets[1].projection_path, "b/storyline"); - -let missing = inspect_automatic_storyline_projection(&inventory.targets[1]).await?; -assert_eq!(missing.state, AutomaticProjectionState::Missing); -assert!(!temp.path().join("b/storyline").exists()); -``` - -Also assert that `storyline_projection_destination_exists` returns true for an existing empty local directory and for an object-store prefix containing a sentinel object, while read-only inspection does not create either destination. - -- [ ] **Step 5: Run inventory tests and verify failure** - -Run: - -```bash -cargo test -p persisting-pchronicle --lib projection::automatic::tests::inventory_is_sorted_and_uses_pinned_event_snapshots -cargo test -p persisting-pchronicle --lib projection::automatic::tests::missing_inspection_is_observational -cargo test -p persisting-pchronicle --lib projection::automatic::tests::destination_existence_covers_local_and_object_stores -``` - -Expected: compilation fails because the inventory, inspection, and existence APIs are absent. - -- [ ] **Step 6: Implement the inventory and read-only inspection** - -Have the Catalog accessor obtain exact URIs from `LazySourceSpec::Events`, and build error records from canonical-event `DiscoveredSource` rows whose status is `Error`. Direct-root mounts display `events.lance`/`storyline`, not `.`. Inspection reads `CURRENT` and compares its lineage to `target.source_snapshot`; it returns an error for lineage-free, foreign-source, or malformed destinations. - -The Catalog accessor walks `self.prepared` in Dataset order and retains only event specs: - -```rust -pub(crate) fn canonical_event_sources(&self) -> Vec { - self.prepared - .iter() - .flat_map(|dataset| { - dataset.sources.iter().filter_map(|source| match &source.spec { - LazySourceSpec::Events { uri, snapshot, .. } => { - Some(CatalogCanonicalEventSource { - dataset: dataset.name.clone(), - source_path: source.file.clone(), - source_uri: uri.clone(), - snapshot: snapshot.fact_snapshot(), - }) - } - _ => None, - }) - }) - .collect() -} -``` - -```rust -match storyline_projection_status(&target.projection_uri).await? { - status if status.generation.is_none() => Ok(inspection(target, Missing, None)), - status => { - let lineage = status.lineage.as_ref().context( - "automatic Storyline destination has no canonical lineage", - )?; - ensure_matching_source(&target.source_snapshot, lineage)?; - let state = if projection_lineage_is_fresh(&target.source_snapshot, lineage) { - AutomaticProjectionState::Fresh - } else { - AutomaticProjectionState::Stale - }; - Ok(inspection(target, state, status.generation)) - } -} -``` - -Implement create-only existence without writing a lock file or directory: - -```rust -pub async fn destination_exists(root: impl AsRef) -> Result { - let store = Self::open_uri_unchecked(root).await?; - if matches!(store.storage_scheme(), "file" | "file+uring") { - return Ok(store.root.exists()); - } - let mut objects = store.object_store.inner.list(Some(&store.object_root)); - objects - .try_next() - .await - .context("inspect Storyline destination prefix") - .map(|object| object.is_some()) -} - -pub async fn storyline_projection_destination_exists( - uri: impl AsRef, -) -> Result { - StorylineLanceStore::destination_exists(uri).await -} -``` - -- [ ] **Step 7: Run the focused library tests** - -Run: - -```bash -cargo test -p persisting-pchronicle --lib projection::automatic -cargo test -p persisting-pchronicle --lib store::catalog::tests -``` - -Expected: all tests pass. - -- [ ] **Step 8: Commit the inventory boundary** - -```bash -git add crates/persisting-pchronicle/src/projection/automatic.rs \ - crates/persisting-pchronicle/src/projection/mod.rs \ - crates/persisting-pchronicle/src/store/events/datafusion.rs \ - crates/persisting-pchronicle/src/store/catalog/mod.rs \ - crates/persisting-pchronicle/src/store/storyline/mod.rs \ - crates/persisting-pchronicle/src/storage.rs -git commit -m "feat(pchronicle): inventory automatic Storyline projections" -``` - -### Task 2: Safe one-source automatic convergence - -**Files:** -- Modify: `crates/persisting-pchronicle/src/projection/automatic.rs` -- Modify: `crates/persisting-pchronicle/src/projection/storyline.rs` -- Modify: `crates/persisting-pchronicle/src/projection/mod.rs` -- Modify: `crates/persisting-pchronicle/src/storage.rs` - -**Interfaces:** -- Consumes: `AutomaticProjectionTarget`, `build_storyline_projection`, `sync_storyline_projection`, `rebuild_storyline_projection`, `verify_storyline_projection`, and existing Storyline `CURRENT` CAS publication. -- Produces: - -```rust -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum AutomaticProjectionMaintenanceMode { - Unchanged, - Built, - Incremental, - Rebuilt, - ConcurrentWinner, -} - -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct AutomaticProjectionMaintenanceReport { - pub mode: AutomaticProjectionMaintenanceMode, - pub generation: String, - pub fact_version: u64, - pub fact_rows: u64, - pub trajectories: Option, -} - -impl AutomaticProjectionMaintenanceReport { - pub fn published(&self) -> bool; -} - -pub async fn maintain_automatic_storyline_projection( - target: &AutomaticProjectionTarget, -) -> anyhow::Result; -``` - -- [ ] **Step 1: Write failing convergence state-machine tests** - -Cover missing build, fresh no-op, append-only incremental sync, matching obsolete recipe rebuild, non-monotonic watermark rebuild, and foreign/no-lineage refusal: - -```rust -let built = maintain_automatic_storyline_projection(&target).await?; -assert_eq!(built.mode, AutomaticProjectionMaintenanceMode::Built); -assert!(built.published()); - -let unchanged = maintain_automatic_storyline_projection(&target).await?; -assert_eq!(unchanged.mode, AutomaticProjectionMaintenanceMode::Unchanged); -assert!(!unchanged.published()); - -append_note(&storage, "session", 1).await?; -let incremental = maintain_automatic_storyline_projection(&rediscovered_target).await?; -assert_eq!(incremental.mode, AutomaticProjectionMaintenanceMode::Incremental); -assert_eq!(incremental.fact_rows, 2); - -let before = std::fs::read(projection.join("CURRENT"))?; -let error = maintain_automatic_storyline_projection(&foreign_target) - .await - .unwrap_err(); -assert!(error.to_string().contains("matching canonical source")); -assert_eq!(std::fs::read(projection.join("CURRENT"))?, before); -``` - -- [ ] **Step 2: Run state-machine tests and verify failure** - -Run: - -```bash -cargo test -p persisting-pchronicle --lib projection::automatic::tests::maintenance_builds_syncs_and_noops -cargo test -p persisting-pchronicle --lib projection::automatic::tests::maintenance_rebuilds_only_owned_outputs -``` - -Expected: compilation fails because `maintain_automatic_storyline_projection` is absent. - -- [ ] **Step 3: Implement ownership-first convergence** - -Use the following decision order: - -```rust -match inspect_automatic_storyline_projection(target).await { - Ok(inspection) if inspection.state == AutomaticProjectionState::Missing => { - build_or_accept_concurrent_winner(target).await - } - Ok(inspection) if inspection.state == AutomaticProjectionState::Fresh => { - Ok(report_from_inspection(Unchanged, inspection)) - } - Ok(_) => match sync_storyline_projection( - &target.source_uri, - &target.projection_uri, - ).await? { - StorylineProjectionSyncOutcome::Synced(report) => map_sync_report(report), - StorylineProjectionSyncOutcome::MissingProjection => { - build_or_accept_concurrent_winner(target).await - } - StorylineProjectionSyncOutcome::RequiresRebuild(_) => { - ensure_current_lineage_owns_target(target).await?; - map_rebuild_report( - rebuild_storyline_projection( - &target.source_uri, - &target.projection_uri, - &target.source_path, - ).await? - ) - } - }, - Err(error) => Err(error), -} -``` - -Before rebuild, require the canonical source URI/source ID to match. Missing lineage and foreign lineage remain conflicts. If build/sync/rebuild loses a publication race, re-run verification: a fresh matching winner maps to `ConcurrentWinner`; any other state returns the original conflict. - -Change the internal sync diagnostic from “use `project rebuild`” to “projection requires a complete rebuild” so the removed CLI is never suggested. - -- [ ] **Step 4: Write and run a concurrent-winner test** - -```rust -let (left, right) = tokio::join!( - maintain_automatic_storyline_projection(&target), - maintain_automatic_storyline_projection(&target), -); -let reports = [left?, right?]; -assert!(reports.iter().all(|report| matches!( - report.mode, - AutomaticProjectionMaintenanceMode::Built - | AutomaticProjectionMaintenanceMode::ConcurrentWinner -))); -assert_eq!(inspect_automatic_storyline_projection(&target).await?.state, Fresh); -``` - -Run: - -```bash -cargo test -p persisting-pchronicle --lib projection::automatic::tests::concurrent_maintenance_accepts_one_fresh_winner -``` - -Expected: pass, with exactly one committed fresh generation and no in-place mutation. - -- [ ] **Step 5: Run the complete projection test group** - -Run: - -```bash -cargo test -p persisting-pchronicle --lib projection:: -cargo test -p persisting-pchronicle --features s3-store --test s3_storage projection -``` - -Expected: all selected tests pass. If the environment has no S3 test configuration, the existing S3 tests must skip through their current harness rather than becoming acceptance blockers. - -- [ ] **Step 6: Commit convergence** - -```bash -git add crates/persisting-pchronicle/src/projection/automatic.rs \ - crates/persisting-pchronicle/src/projection/storyline.rs \ - crates/persisting-pchronicle/src/projection/mod.rs \ - crates/persisting-pchronicle/src/storage.rs -git commit -m "feat(pchronicle): converge owned Storyline projections" -``` - -### Task 3: Absorb one-shot canonical projection into `import` - -**Files:** -- Modify: `crates/persisting-pchronicle-cli/src/lib.rs` -- Modify: `crates/persisting-pchronicle-cli/src/exchange.rs` -- Modify: `crates/persisting-pchronicle-cli/src/settings.rs` -- Test: `crates/persisting-pchronicle-cli/src/tests.rs` - -**Interfaces:** -- Consumes: `probe_canonical_event_store`, `storyline_projection_destination_exists`, and `build_storyline_projection`. -- Produces: `ImportArgs.output_format: Option` and an `ImportResponse` with optional `input_bytes` plus optional `fact_rows`. - -```rust -#[derive(Debug, Serialize)] -struct ImportResponse { - dataset_uri: String, - #[serde(skip_serializing_if = "Option::is_none")] - source_path: Option, - #[serde(skip_serializing_if = "Option::is_none")] - format: Option, - output_format: String, - sources: usize, - trajectories: usize, - #[serde(skip_serializing_if = "Option::is_none")] - fact_rows: Option, - #[serde(skip_serializing_if = "Option::is_none")] - input_bytes: Option, -} -``` - -- [ ] **Step 1: Write failing local canonical-import tests** - -Assert auto-detection, contextual output format, source immutability, response fields, queryability, explicit `storyline` acceptance, explicit `preserve` rejection, and create-only output: - -```rust -let before = std::fs::read(source.join("_manifest.json"))?; -let response = run_cli([ - "import", "--from", source_str, "--output", projection_str, -]).await?.json()?; -assert_eq!(response["format"], "events"); -assert_eq!(response["source_path"], "events.lance"); -assert_eq!(response["output_format"], "storyline-lance"); -assert_eq!(response["sources"], 1); -assert_eq!(response["trajectories"], 1); -assert_eq!(response["fact_rows"], 1); -assert!(response.get("input_bytes").is_none()); -assert_eq!(std::fs::read(source.join("_manifest.json"))?, before); -assert!(projection.join("CURRENT").is_file()); -``` - -The suffix-only directory test must continue into ordinary directory import and report “contains no .json, .jsonl, or .ndjson files”; it must not be treated as canonical events. - -- [ ] **Step 2: Run local import tests and verify failure** - -Run: - -```bash -cargo test -p persisting-pchronicle-cli --lib canonical_event_import -``` - -Expected: tests fail because the current import path scans `events.lance` as a JSON directory and defaults `output_format` to `preserve` before input classification. - -- [ ] **Step 3: Split import classification before JSON file collection** - -Probe non-stream input before calling `collect_import_candidates`. Resolve output mode contextually: - -```rust -let canonical = if args.stream { - None -} else { - probe_canonical_event_store(&args.from).await? -}; - -if let Some(snapshot) = canonical { - anyhow::ensure!( - matches!(args.format, ExchangeFormat::Auto), - "canonical event import does not accept a JSON exchange --format" - ); - anyhow::ensure!( - args.output_format != Some(ImportOutputFormat::Preserve), - "canonical event import cannot preserve an existing event Store" - ); - return run_canonical_event_import(args, snapshot, settings_override, stdout, stderr).await; -} - -let output_format = args.output_format.unwrap_or(ImportOutputFormat::Preserve); -``` - -`run_canonical_event_import` may use a local default output when `--output` is omitted, but it must accept an explicit local path or supported object-store URI. Check destination existence before building; map `OutputNotEmpty` to `BoundaryCode::Conflict`; omit `input_bytes` from canonical-import JSON and stderr output. - -- [ ] **Step 4: Preserve the existing JSON response contract** - -Update existing JSON-import assertions to require the same numeric `input_bytes` and the same `preserve` default when `--output-format` is absent: - -```rust -assert_eq!(response["output_format"], "preserve"); -assert_eq!(response["input_bytes"], std::fs::metadata(&input)?.len()); -assert!(response.get("fact_rows").is_none()); -``` - -Run: - -```bash -cargo test -p persisting-pchronicle-cli --lib import_ -cargo test -p persisting-pchronicle-cli --test command_matrix import_matrix -``` - -Expected: all JSON, JSONL, NDJSON, recursive-directory, symlink, warning, and atomic-publication import tests pass unchanged except for the intentionally optional response members. - -- [ ] **Step 5: Add object-store canonical import coverage** - -Create canonical facts and the output under unique `shared-memory://` roots in the same process, then query the resulting Storyline Store: - -```rust -let output = format!("shared-memory://canonical-import-{id}/storyline"); -let response = run_cli([ - "import", "--from", source.as_str(), "--output", output.as_str(), -]).await?.json()?; -assert_eq!(response["fact_rows"], 1); -let store = StorylineLanceStore::open_uri(&output).await?; -assert!(store.current_table_paths().await?.is_some()); -``` - -Run: - -```bash -cargo test -p persisting-pchronicle-cli --lib canonical_event_import_supports_object_store_uris -``` - -Expected: pass. - -- [ ] **Step 6: Commit canonical import** - -```bash -git add crates/persisting-pchronicle-cli/src/lib.rs \ - crates/persisting-pchronicle-cli/src/exchange.rs \ - crates/persisting-pchronicle-cli/src/settings.rs \ - crates/persisting-pchronicle-cli/src/tests.rs -git commit -m "feat(pchronicle): import canonical events as Storyline" -``` - -### Task 4: Fold projection health into `status` and remove `project` - -**Files:** -- Modify: `crates/persisting-pchronicle-cli/src/lib.rs` -- Modify: `crates/persisting-pchronicle-cli/src/output.rs` -- Modify: `crates/persisting-pchronicle-cli/src/tests.rs` -- Modify: `crates/persisting-pchronicle-cli/tests/binary_contract.rs` - -**Interfaces:** -- Consumes: `automatic_projection_inventory` and `inspect_automatic_storyline_projection`. -- Produces: - -```rust -#[derive(Debug, Serialize)] -struct ProjectionStatusResponse { - source_path: String, - projection_path: String, - status: ProjectionStatusName, - #[serde(skip_serializing_if = "Option::is_none")] - generation: Option, - #[serde(skip_serializing_if = "Option::is_none")] - fact_version: Option, - #[serde(skip_serializing_if = "Option::is_none")] - fact_rows: Option, -} - -#[derive(Debug, Clone, Copy, Serialize)] -#[serde(rename_all = "snake_case")] -enum ProjectionStatusName { Fresh, Stale, Missing, Error } -``` - -`StatusResponse` adds `projections: Vec`. - -- [ ] **Step 1: Replace project parser tests with absence and status tests** - -Delete the `project watch` and `project verify` CLI tests. Change the command tree assertion and binary help contract: - -```rust -assert_eq!( - names, - [ - "onboard", "default", "ls", "status", "query", "analysis", "find", - "import", "export", "echo", "serve", - ] -); -assert!(Cli::try_parse_from(["pchronicle", "project", "status"]).is_err()); -``` - -Add status cases for fresh, stale, missing, lineage-free, malformed `CURRENT`, and two nested event sources. Assert array ordering by `source_path`, optional members, and no filesystem writes during status. - -- [ ] **Step 2: Run parser and status tests and verify failure** - -Run: - -```bash -cargo test -p persisting-pchronicle-cli --lib command_tree_contains_the_product_commands -cargo test -p persisting-pchronicle-cli --lib status_reports_projection_ -cargo test -p persisting-pchronicle-cli --test binary_contract help_exposes_the_supported_product_surface -``` - -Expected: parser tests fail because `project` still exists and status lacks `projections`. - -- [ ] **Step 3: Remove the public project surface** - -Remove `Command::Project`, all `Project*Args`, `ProjectCommand`, `run_project`, `run_project_watch`, watch-only response types/constants/imports, and the dispatch arm. Do not remove or deprecate the library projection functions. - -Add `project` to the explicit forbidden command list in `binary_contract.rs`: - -```rust -for command in ["control", "project", "search", "maintain"] { - assert!(!stdout.lines().any(|line| { - line.trim_start().starts_with(command) - })); -} -``` - -- [ ] **Step 4: Implement read-only projection status aggregation** - -Build inventory from the already pinned status Catalog. Inspect ready targets with `buffered(STATUS_PROJECTION_CONCURRENCY)`, where `STATUS_PROJECTION_CONCURRENCY` is a fixed `16`, so output order remains stable without adding another public flag. Convert each inspection error and each inventory error into an `error` record without exposing its source chain in JSON. - -```rust -const STATUS_PROJECTION_CONCURRENCY: usize = 16; - -let inventory = automatic_projection_inventory(snapshot.as_ref())?; -let mut projections = stream::iter(inventory.targets) - .map(|target| async move { - match inspect_automatic_storyline_projection(&target).await { - Ok(inspection) => ProjectionStatusResponse::from_inspection(target, inspection), - Err(error) => { - tracing::error!(error = ?error, source = %target.source_path, - "pChronicle projection status inspection failed"); - ProjectionStatusResponse::error(target.source_path, target.projection_path) - } - } - }) - .buffered(STATUS_PROJECTION_CONCURRENCY) - .collect::>() - .await; -projections.extend(inventory.errors.into_iter().map(|error| { - ProjectionStatusResponse::error(error.source_path, error.projection_path) -})); -projections.sort_by(|left, right| left.source_path.cmp(&right.source_path)); -``` - -The table output adds a compact block after aggregate counts: - -```text -PROJECTION STATUS FACT_VERSION FACT_ROWS GENERATION -a/events.lance -> a/storyline fresh 12 4812 generation-id -b/events.lance -> b/storyline missing 3 97 -``` - -- [ ] **Step 5: Run status and binary contracts** - -Run: - -```bash -cargo test -p persisting-pchronicle-cli --lib status_ -cargo test -p persisting-pchronicle-cli --test command_matrix -cargo test -p persisting-pchronicle-cli --test binary_contract -``` - -Expected: all tests pass and `project` is rejected as an unknown subcommand. - -- [ ] **Step 6: Commit status consolidation** - -```bash -git add crates/persisting-pchronicle-cli/src/lib.rs \ - crates/persisting-pchronicle-cli/src/output.rs \ - crates/persisting-pchronicle-cli/src/tests.rs \ - crates/persisting-pchronicle-cli/tests/binary_contract.rs -git commit -m "feat(pchronicle): report projections through status" -``` - -### Task 5: Prepare and atomically refresh Warehouse Catalog runtimes - -**Files:** -- Modify: `crates/persisting-pchronicle-cli/src/server/mod.rs` -- Modify: `crates/persisting-pchronicle-cli/src/server/tests.rs` -- Modify: `crates/persisting-pchronicle-cli/src/lib.rs` - -**Interfaces:** -- Consumes: `ChronicleServerConfig`, `DatasetCatalogSnapshot::discover`, and `ChronicleQueryEngine`. -- Produces: - -```rust -#[derive(Clone)] -pub(crate) struct PreparedWarehouse { - state: AppState, -} - -impl PreparedWarehouse { - pub(crate) async fn prepare(config: ChronicleServerConfig) -> anyhow::Result; - pub(crate) async fn refresh_catalog(&self) -> anyhow::Result; - pub(crate) fn router(&self) -> Router; - #[cfg(test)] - pub(crate) async fn current_snapshot_id(&self) -> Option; -} - -pub(crate) async fn serve_prepared_warehouse_with_listener_and_shutdown( - warehouse: PreparedWarehouse, - listener: tokio::net::TcpListener, - shutdown: impl Future + Send + 'static, -) -> anyhow::Result<()>; -``` - -- [ ] **Step 1: Write failing prepared-Catalog tests** - -Assert that prepare installs a Catalog before any request, refresh atomically replaces it, and failed refresh leaves the old snapshot and trajectory cache available: - -```rust -let prepared = PreparedWarehouse::prepare(config).await?; -let first = prepared.current_snapshot_id().await.expect("prepared snapshot"); - -std::fs::write(root.join("second.json"), fixture_bytes())?; -let second = prepared.refresh_catalog().await?; -assert_ne!(second, first); - -std::fs::create_dir(root.join("broken"))?; -std::fs::write(root.join("broken/CURRENT"), "{")?; -assert!(prepared.refresh_catalog().await.is_err()); -assert_eq!(prepared.current_snapshot_id().await.as_deref(), Some(second.as_str())); -``` - -Keep the existing HTTP `POST /api/catalog` atomicity test and route it through the same handle method. - -- [ ] **Step 2: Run the focused server tests and verify failure** - -Run: - -```bash -cargo test -p persisting-pchronicle-cli --lib server::tests::prepared_catalog_ -cargo test -p persisting-pchronicle-cli --lib server::tests::catalog_refresh_is_atomic_and_dataset_filtering_is_explicit -``` - -Expected: compilation fails because `PreparedWarehouse` does not exist. - -- [ ] **Step 3: Implement prepared state and one swap primitive** - -Build the complete `CatalogRuntime` outside the write lock. Install the runtime and clear the trajectory cache only after construction succeeds: - -```rust -async fn install_catalog_runtime(&self, runtime: Arc) -> String { - let snapshot_id = runtime.snapshot.snapshot_id().to_owned(); - *self.state.catalog.write().await = Some(runtime); - *self.state.trajectory_cache.write().await = None; - snapshot_id -} - -pub(crate) async fn refresh_catalog(&self) -> Result { - let runtime = build_catalog_runtime(&self.state.config).await?; - Ok(self.install_catalog_runtime(runtime).await) -} -``` - -`warehouse_router(config)` remains available for existing library and HTTP tests; it creates lazy state as before. The unified `serve` path uses `PreparedWarehouse::prepare` and the prepared listener function so readiness implies a complete initial Catalog whenever `--listen` is present. - -- [ ] **Step 4: Run server tests** - -Run: - -```bash -cargo test -p persisting-pchronicle-cli --lib server::tests -cargo test -p persisting-pchronicle-cli --test server_http_contract -``` - -Expected: all tests pass. - -- [ ] **Step 5: Commit prepared Warehouse support** - -```bash -git add crates/persisting-pchronicle-cli/src/server/mod.rs \ - crates/persisting-pchronicle-cli/src/server/tests.rs \ - crates/persisting-pchronicle-cli/src/lib.rs -git commit -m "refactor(pchronicle): prepare atomic Warehouse catalogs" -``` - -### Task 6: Add the bounded projection supervisor to `serve` - -**Files:** -- Create: `crates/persisting-pchronicle-cli/src/projection_supervisor.rs` -- Modify: `crates/persisting-pchronicle-cli/src/lib.rs` -- Test: `crates/persisting-pchronicle-cli/src/tests.rs` - -**Interfaces:** -- Consumes: `ChronicleServerConfig`, `PreparedWarehouse`, `automatic_projection_inventory`, and `maintain_automatic_storyline_projection`. -- Produces: - -```rust -#[derive(Debug, Clone, Copy)] -pub(crate) struct ProjectionSupervisorOptions { - pub(crate) interval: Duration, - pub(crate) max_backoff: Duration, - pub(crate) max_concurrent: usize, -} - -pub(crate) struct ProjectionSupervisor { - config: server::ChronicleServerConfig, - warehouse: Option, - options: ProjectionSupervisorOptions, - diagnostics: tokio::sync::mpsc::Sender, - retries: BTreeMap, - catalog_retry: Option, - observed_snapshot_id: Option, - catalog_dirty: bool, -} - -#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)] -pub(crate) struct ProjectionIterationReport { - pub(crate) succeeded: usize, - pub(crate) failed: usize, - pub(crate) publications: usize, - pub(crate) catalog_refreshes: usize, -} - -#[derive(Debug, Clone, PartialEq, Eq)] -pub(crate) struct ProjectionDiagnostic { - pub(crate) source_path: String, - pub(crate) projection_path: String, - pub(crate) status: &'static str, - pub(crate) retry_ms: u64, -} - -fn sanitize_log_field(value: &str) -> String { - value.chars().flat_map(char::escape_default).collect() -} - -impl ProjectionSupervisor { - pub(crate) fn new( - config: server::ChronicleServerConfig, - warehouse: Option, - diagnostics: tokio::sync::mpsc::Sender, - ) -> Self; - - pub(crate) fn set_warehouse( - &mut self, - warehouse: Option, - ); - - pub(crate) async fn converge_before_readiness(&mut self) -> anyhow::Result<()>; - - pub(crate) async fn run_iteration( - &mut self, - now: tokio::time::Instant, - ) -> ProjectionIterationReport; - - pub(crate) async fn run( - self, - stop: tokio::sync::watch::Receiver, - ) -> anyhow::Result<()>; -} -``` - -- [ ] **Step 1: Write failing startup and runtime iteration tests** - -Use short test-only options to cover: - -- startup builds all initially discovered projections before returning; -- startup rejects a foreign or lineage-free deterministic destination; -- two sources are attempted even if one fails; -- appending facts leads to incremental convergence; -- a newly created `events.lance` is discovered after the first iteration; -- a failed source receives capped exponential backoff without delaying healthy sources; -- one iteration with multiple publications requests one Catalog refresh; -- changed canonical membership/watermarks mark the Catalog dirty even when projection fails, so Warehouse can publish a stale/fallback Catalog; -- a failed Catalog refresh retains `catalog_dirty=true` and is retried. -- a source or object key containing newline or ANSI escape characters still emits exactly one escaped diagnostic line. - -Representative assertion: - -```rust -supervisor.converge_before_readiness().await?; -assert_eq!(projection_state(&first).await?, Fresh); - -append_note(&second_storage, "new-session", 0).await?; -let report = supervisor.run_iteration(Instant::now()).await; -assert_eq!(report.succeeded, 2); -assert_eq!(report.failed, 0); -assert_eq!(report.catalog_refreshes, 1); -``` - -- [ ] **Step 2: Run supervisor tests and verify failure** - -Run: - -```bash -cargo test -p persisting-pchronicle-cli --lib projection_supervisor::tests -``` - -Expected: compilation fails because the module is absent. - -- [ ] **Step 3: Implement bounded iteration and per-source retry state** - -Discover a fresh Report-policy Catalog each iteration, derive its inventory, and run due targets with `buffer_unordered(options.max_concurrent)`. Store retry state by `source_uri`: - -```rust -fn retry_delay(&self, failures: u32) -> Duration { - let exponent = failures.saturating_sub(1).min(20); - let multiplier = 1u32.checked_shl(exponent).unwrap_or(u32::MAX); - self.options.interval - .saturating_mul(multiplier) - .min(self.options.max_backoff) -} -``` - -Success removes the retry entry. Failure increments only that source's entry and uses `try_send` to place one sanitized `ProjectionDiagnostic` into a bounded channel containing only `source_path`, `projection_path`, state, and retry delay. A full diagnostic channel drops the duplicate diagnostic rather than delaying maintenance; no Control token or error source chain is passed into this module. - -Set `catalog_dirty` when the discovered snapshot ID changes or a projection publishes. If a prepared Warehouse exists and the iteration reaches its refresh phase, call `refresh_catalog` once. Clear `catalog_dirty` and `catalog_retry` only on successful installation. A failed Catalog build advances its own `catalog_retry` with the same capped delay function, so it is retried independently without re-running already-fresh projections. - -- [ ] **Step 4: Implement startup and shutdown semantics** - -`converge_before_readiness` attempts every initial target with bounded concurrency, then returns one summarized error if any discovery/maintenance error remains. It does not apply runtime backoff. - -The runtime loop checks the stop watch between iterations. Once an iteration starts, await its maintenance futures and Catalog publication before returning, ensuring shutdown cannot abandon a `CURRENT` publication future midway: - -```rust -loop { - tokio::select! { - changed = stop.changed() => { - if changed.is_err() || *stop.borrow() { return Ok(()); } - } - _ = tokio::time::sleep(next_delay) => { - self.run_iteration(Instant::now()).await; - } - } -} -``` - -- [ ] **Step 5: Wire startup ordering into `run_serve`** - -The order must be: - -```rust -let config = resolve_serve_config(&args)?; -let (diagnostic_tx, diagnostic_rx) = tokio::sync::mpsc::channel(256); -let mut projections = ProjectionSupervisor::new( - config.clone(), - None, - diagnostic_tx, -); -projections.converge_before_readiness().await?; - -let warehouse = match args.listen { - Some(_) => Some(server::PreparedWarehouse::prepare(config.clone()).await?), - None => None, -}; -projections.set_warehouse(warehouse.clone()); - -// Bind/prepare enabled listeners and services. -// Emit and flush exactly one ChronicleServeReady JSON line. -// Run Warehouse, Control, Gateway, the projection supervisor, and the -// diagnostic receiver together. -``` - -No Warehouse Catalog is prepared when `--listen` is absent. Add the supervisor as a managed sibling in `serve_components`; a runtime source error remains internal to the supervisor and therefore does not end sibling services. - -Pass `diagnostic_rx` and the existing borrowed `stderr: &mut dyn Write` into `serve_components`. Its `tokio::select!` drains diagnostics and writes one escaped line at a time while also waiting for shutdown or a service completion: - -```rust -diagnostic = diagnostic_rx.recv() => { - if let Some(diagnostic) = diagnostic { - writeln!( - stderr, - "projection source={} output={} status={} retry_ms={}", - sanitize_log_field(&diagnostic.source_path), - sanitize_log_field(&diagnostic.projection_path), - diagnostic.status, - diagnostic.retry_ms, - )?; - } -} -``` - -Do not call `eprintln!`: `main` holds a `StderrLock` for the lifetime of `run_with_stdio`, so direct background stderr locking can deadlock. On shutdown, signal services first, await the active supervisor iteration, then drain diagnostics until its sender is dropped. - -- [ ] **Step 6: Run in-process serve and supervisor tests** - -Run: - -```bash -cargo test -p persisting-pchronicle-cli --lib projection_supervisor::tests -cargo test -p persisting-pchronicle-cli --lib serve_ -``` - -Expected: all tests pass, stdout contains no maintenance events, and shutdown waits for an active iteration. - -- [ ] **Step 7: Commit the supervisor** - -```bash -git add crates/persisting-pchronicle-cli/src/projection_supervisor.rs \ - crates/persisting-pchronicle-cli/src/lib.rs \ - crates/persisting-pchronicle-cli/src/tests.rs -git commit -m "feat(pchronicle): maintain projections under serve" -``` - -### Task 7: Prove process-level readiness, refresh, fallback, and CAS behavior - -**Files:** -- Modify: `crates/persisting-pchronicle-cli/tests/control_process.rs` -- Modify: `crates/persisting-pchronicle-cli/tests/binary_contract.rs` - -**Interfaces:** -- Consumes: the public `pchronicle import`, `serve`, Control protocol, Warehouse HTTP API, and Storyline storage inspection. -- Produces: end-to-end acceptance coverage; no new production API. - -- [ ] **Step 1: Add a process helper with bounded polling** - -```rust -async fn wait_until( - timeout: Duration, - mut condition: F, -) -> Result<()> -where - F: FnMut() -> Fut, - Fut: Future>, -{ - tokio::time::timeout(timeout, async { - loop { - if condition().await? { return Ok(()); } - tokio::time::sleep(Duration::from_millis(25)).await; - } - }).await.context("timed out waiting for pChronicle state")??; - Ok(()) -} -``` - -All child processes use `kill_on_drop(true)` and consume stderr after termination so a full pipe cannot deadlock the test. - -- [ ] **Step 2: Write readiness and runtime discovery process tests** - -Create one canonical source before launch and assert its sibling projection is fresh before the readiness line is accepted. Then append a second source through Control after readiness and poll for its sibling projection while continuing to ping Control. - -```rust -let ready = read_ready(&mut child).await?; -assert_eq!(inspect_source(&initial).await?.state, Fresh); - -append_through_control(&ready, second_request).await?; -wait_until(Duration::from_secs(10), || async { - Ok(inspect_source(&second).await.is_ok_and(|inspection| { - inspection.state == AutomaticProjectionState::Fresh - })) -}).await?; -ping_control(&ready).await?; -``` - -- [ ] **Step 3: Write startup-conflict and runtime-failure tests** - -For startup, create a lineage-free valid Storyline Store at the deterministic destination and assert the process exits non-zero without a stdout readiness line and without changing `CURRENT`. - -For runtime, start with an empty Dataset, create a foreign destination, append the matching canonical source through Control, wait for a projection error line on stderr, and assert a later Control append and ping still succeed. - -- [ ] **Step 4: Write Warehouse atomic-refresh and fallback tests** - -Start `serve --listen ... --control ...`, capture `/api/catalog` snapshot ID, append canonical facts, and poll until a new snapshot reports a fresh projection generation. Then place a valid foreign Storyline Store at a newly created source's deterministic destination before appending its canonical facts. Assert the replacement Catalog reports that exact event `_file_` as missing/error and an `_file_ = '.../events.lance'` bounded query uses canonical fallback; the foreign Source must not be mistaken for matching lineage. - -For Catalog build failure, add a malformed committed Source before refresh, assert `/api/catalog` retains the old snapshot ID, remove the malformed Source, and assert the supervisor retry eventually installs a new snapshot. - -- [ ] **Step 5: Write a two-process CAS test** - -Start two `serve --storage ROOT --control 127.0.0.1:0` processes against the same initially unprojected source. Both must emit readiness, the deterministic destination must have one valid fresh `CURRENT`, and neither process may overwrite it with foreign lineage. - -- [ ] **Step 6: Add release-profile canonical import smoke coverage** - -In `binary_contract.rs`, create a local canonical source, execute `CARGO_BIN_EXE_pchronicle import --from EVENTS --output STORYLINE`, parse stdout, and query the new output. Run the test under release profile: - -```bash -cargo test --release -p persisting-pchronicle-cli \ - --test binary_contract canonical_event_import_is_queryable_in_release -``` - -Expected: response is `format=events`, `output_format=storyline-lance`, `fact_rows=1`, no `input_bytes`, and the query observes one trajectory. - -- [ ] **Step 7: Run all process contracts** - -Run: - -```bash -cargo test -p persisting-pchronicle-cli --test control_process -- --test-threads=1 -cargo test -p persisting-pchronicle-cli --test binary_contract -cargo test -p persisting-pchronicle-cli --test server_http_contract -``` - -Expected: all tests pass without timing-dependent sleeps beyond bounded polling. - -- [ ] **Step 8: Commit process coverage** - -```bash -git add crates/persisting-pchronicle-cli/tests/control_process.rs \ - crates/persisting-pchronicle-cli/tests/binary_contract.rs -git commit -m "test(pchronicle): cover automatic projection lifecycle" -``` - -### Task 8: Replace manual projection documentation and verify the scoped product - -**Files:** -- Modify: `crates/persisting-pchronicle-cli/README.md` -- Modify: `crates/persisting-pchronicle/README.md` -- Modify: `docs/src/pchronicle/reference/cli.md` -- Modify: `docs/src/pchronicle/guides/exchange.md` -- Modify: `docs/src/pchronicle/guides/exchange.zh.md` -- Modify: `docs/src/pchronicle/guides/serve.md` -- Modify: `docs/src/pchronicle/guides/serve.zh.md` -- Modify: `docs/src/pchronicle/guides/serve-gateway.md` -- Modify: `docs/src/pchronicle/guides/serve-gateway.zh.md` -- Modify: `docs/src/pchronicle/design/storyline-lance.md` -- Modify: `docs/src/pchronicle/design/storyline-lance.zh.md` -- Modify: `docs/src/pchronicle/design/catalog.md` -- Modify: `docs/src/pchronicle/design/catalog.zh.md` - -**Interfaces:** -- Consumes: the final CLI help and behavior from Tasks 3–7. -- Produces: one user model centered on `import`, `serve`, and `status`. - -- [ ] **Step 1: Update command reference and exchange docs** - -Document both contextual forms exactly: - -```bash -# JSON remains byte-preserving unless explicitly squashed. -pchronicle import --from ./corpus --output ./dataset - -# A validated canonical event Store always creates Storyline Lance. -pchronicle import \ - --from ./run/events.lance \ - --output ./run/storyline -``` - -State that canonical import omits `input_bytes`, reports `fact_rows`, accepts local/object-store URIs, does not mutate the source, and is create-only. Explain that explicit `--output-format preserve` is invalid for canonical events. - -- [ ] **Step 2: Update serve, Storyline, and Catalog docs** - -Replace every manual build/sync/watch/rebuild command with: - -```bash -pchronicle serve --storage ./trajectory-data --control 127.0.0.1:0 -pchronicle status ./trajectory-data --format json -``` - -Document pre-readiness convergence, deterministic sibling placement, runtime discovery, bounded retry, durable-write independence, stale canonical fallback, complete Catalog rebuild plus atomic swap, and old-snapshot retention after refresh failure. Remove statements that Catalog refresh is only explicit. - -- [ ] **Step 3: Prove no public manual command remains in maintained docs/code** - -Run: - -```bash -rg -n "pchronicle project|project (build|status|verify|sync|watch|rebuild)" \ - crates/persisting-pchronicle-cli crates/persisting-pchronicle/README.md \ - docs/src/pchronicle -g '*.rs' -g '*.md' -``` - -Expected: no matches. Internal Rust function names such as `build_storyline_projection` are allowed and are not matched by this command-oriented expression. - -- [ ] **Step 4: Run formatting and focused static checks** - -Run: - -```bash -cargo fmt --all -- --check -cargo clippy -p persisting-pchronicle --all-targets --features lance-store,s3-store -- -D warnings -cargo clippy -p persisting-pchronicle-cli --all-targets -- -D warnings -``` - -Expected: all checks pass. - -- [ ] **Step 5: Run the scoped test suite** - -Run: - -```bash -cargo test -p persisting-pchronicle --lib --features lance-store,s3-store -cargo test -p persisting-pchronicle \ - --test document_source \ - --test query_engine \ - --test storyline_lance_roundtrip \ - --test s3_storage -cargo test -p persisting-pchronicle-cli --lib -cargo test -p persisting-pchronicle-cli --tests -- --test-threads=1 -``` - -Expected: all in-scope tests pass. Do not broaden acceptance to Search, Queue, TTAS, or `persisting-dlcapt`. - -- [ ] **Step 6: Build strict documentation and run release smoke** - -Run: - -```bash -just docs-links -cargo test --release -p persisting-pchronicle-cli \ - --test binary_contract canonical_event_import_is_queryable_in_release -``` - -Expected: strict MkDocs build passes and the release-profile smoke passes. - -- [ ] **Step 7: Commit documentation** - -```bash -git add crates/persisting-pchronicle-cli/README.md \ - crates/persisting-pchronicle/README.md \ - docs/src/pchronicle/reference/cli.md \ - docs/src/pchronicle/guides/exchange.md \ - docs/src/pchronicle/guides/exchange.zh.md \ - docs/src/pchronicle/guides/serve.md \ - docs/src/pchronicle/guides/serve.zh.md \ - docs/src/pchronicle/guides/serve-gateway.md \ - docs/src/pchronicle/guides/serve-gateway.zh.md \ - docs/src/pchronicle/design/storyline-lance.md \ - docs/src/pchronicle/design/storyline-lance.zh.md \ - docs/src/pchronicle/design/catalog.md \ - docs/src/pchronicle/design/catalog.zh.md -git commit -m "docs(pchronicle): document automatic Storyline projection" -``` - -- [ ] **Step 8: Record final evidence** - -Capture the exact passing commands, test counts, skipped environment-dependent object-store cases, and release-smoke result in the final handoff. Report any pre-existing unrelated dirty-worktree changes separately and do not include them in these commits. diff --git a/docs/superpowers/plans/2026-08-21-pchronicle-serve-control-consolidation.md b/docs/superpowers/plans/2026-08-21-pchronicle-serve-control-consolidation.md deleted file mode 100644 index dba91f6c..00000000 --- a/docs/superpowers/plans/2026-08-21-pchronicle-serve-control-consolidation.md +++ /dev/null @@ -1,357 +0,0 @@ -# pChronicle Serve and Control Consolidation Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Host the authenticated pChronicle Control protocol as an optional `serve` component and migrate pPilot/pVisor from the removed standalone `control` command. - -**Architecture:** `serve` resolves either one `--storage` Dataset or a multi-Dataset `--config`, pre-binds the independently requested Warehouse, Control, and Gateway listeners, emits one unified readiness record, and supervises all enabled services with shared shutdown. Control keeps its existing TCP protocol and durable stores; only its process ownership and readiness adapter change. - -**Tech Stack:** Rust, Tokio TCP/process primitives, Clap derive validation, Serde JSON, Axum Warehouse/Gateway servers, existing `ChronicleControl` protocol and pChronicle stores. - -**Spec:** `docs/superpowers/specs/2026-08-21-pchronicle-serve-control-consolidation-design.md` - -## Global Constraints - -- `--storage` and `--config` are mutually exclusive and exactly one is required. -- `--listen`, `--control`, and `--gateway` independently enable Warehouse, Control, and Gateway; at least one is required. -- Omitting `--listen` must create no Warehouse HTTP listener. -- `--control` requires `--storage`; `--open` requires `--listen`. -- Warehouse and Control remain loopback-only; Control remains token-authenticated TCP, not HTTP. -- Existing Run control, Attempt registry, and trajectory storage formats must not change. -- Do not add automatic Warehouse refresh after Control writes. -- Remove both the `pchronicle control` command and `ChronicleControlProcessClient` compatibility alias. -- Preserve all unrelated changes in the shared dirty worktree. Do not create implementation commits from files that already contain user changes; use test checkpoints instead. - ---- - -### Task 1: Unified serve readiness protocol - -**Files:** -- Modify: `crates/persisting-events/src/control.rs` -- Test: `crates/persisting-events/src/control.rs` - -**Interfaces:** -- Produces: `CHRONICLE_SERVE_READY_VERSION: u32`, `ChronicleServeControlReady`, and `ChronicleServeReady`. -- `ChronicleServeReady` has optional `warehouse_endpoint`, `control`, `gateway_endpoint`, and `gateway_admin_endpoint` members with disabled members omitted by Serde. - -- [ ] **Step 1: Write failing readiness serialization tests** - -Add tests asserting that a Control-only value serializes without Warehouse/Gateway keys and that decoding rejects unknown fields: - -```rust -let ready = ChronicleServeReady { - version: CHRONICLE_SERVE_READY_VERSION, - warehouse_endpoint: None, - control: Some(ChronicleServeControlReady { - endpoint: "127.0.0.1:4000".into(), - auth_token: "secret".into(), - }), - gateway_endpoint: None, - gateway_admin_endpoint: None, -}; -let value = serde_json::to_value(ready).unwrap(); -assert!(value.get("warehouse_endpoint").is_none()); -assert_eq!(value["control"]["endpoint"], "127.0.0.1:4000"); -``` - -- [ ] **Step 2: Run the focused test and observe the missing-type failure** - -Run: `cargo test -p persisting-events serve_ready --lib` - -Expected: compilation fails because `ChronicleServeReady` does not exist. - -- [ ] **Step 3: Add the readiness types** - -Implement: - -```rust -pub const CHRONICLE_SERVE_READY_VERSION: u32 = 1; - -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(deny_unknown_fields)] -pub struct ChronicleServeControlReady { - pub endpoint: String, - pub auth_token: String, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(deny_unknown_fields)] -pub struct ChronicleServeReady { - pub version: u32, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub warehouse_endpoint: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub control: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub gateway_endpoint: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub gateway_admin_endpoint: Option, -} -``` - -- [ ] **Step 4: Run the focused tests** - -Run: `cargo test -p persisting-events serve_ready --lib` - -Expected: all readiness tests pass. - -### Task 2: Embeddable shutdown-aware Control service - -**Files:** -- Modify: `crates/persisting-pchronicle-cli/src/control.rs` -- Test: `crates/persisting-pchronicle-cli/src/control.rs` - -**Interfaces:** -- Consumes: existing `RunControlStore`, `AttemptRegistry`, and Control request handlers. -- Produces: `PreparedControl::bind(storage: &str, listen: SocketAddr) -> Result`, `PreparedControl::ready() -> ChronicleServeControlReady`, and `PreparedControl::serve(self, shutdown: impl Future) -> Result<()>`. - -- [ ] **Step 1: Add a failing lifecycle test** - -Bind a prepared Control service to `127.0.0.1:0`, start it with a oneshot shutdown future, send a `Ping` envelope using the advertised token, assert `Pong`, signal shutdown, and require the serving future to finish within five seconds. - -- [ ] **Step 2: Run the focused test and observe the missing API failure** - -Run: `cargo test -p persisting-pchronicle-cli control::tests --lib` - -Expected: compilation fails because `PreparedControl` is undefined. - -- [ ] **Step 3: Extract listener preparation from `run_control`** - -Move store opening, listener binding, endpoint discovery, and token generation into `PreparedControl::bind`. Keep `serve_connection`, `decode_request`, `handle_request`, and response mapping semantically unchanged. - -Use this ownership boundary: - -```rust -pub(super) struct PreparedControl { - listener: tokio::net::TcpListener, - endpoint: SocketAddr, - auth_token: String, - control: Arc, - attempts: Arc, -} -``` - -Implement a shutdown-aware accept loop: - -```rust -loop { - tokio::select! { - _ = &mut shutdown => break, - accepted = self.listener.accept() => { - let (stream, _) = accepted.context("accept pChronicle control client")?; - stream.set_nodelay(true).context("configure pChronicle control socket")?; - let control = Arc::clone(&self.control); - let attempts = Arc::clone(&self.attempts); - let auth_token = self.auth_token.clone(); - tokio::spawn(async move { - if let Err(error) = serve_connection(stream, control, attempts, auth_token).await { - eprintln!("pChronicle control request failed: {error:#}"); - } - }); - } - } -} -Ok(()) -``` - -Do not write readiness from this module; the `serve` supervisor owns stdout. - -- [ ] **Step 4: Run Control unit and process protocol tests** - -Run: `cargo test -p persisting-pchronicle-cli control::tests --lib` - -Expected: lifecycle, authentication, frame-limit, and request mapping tests pass. - -### Task 3: Optional service CLI and unified supervisor - -**Files:** -- Modify: `crates/persisting-pchronicle-cli/src/lib.rs` -- Test: `crates/persisting-pchronicle-cli/src/tests.rs` -- Test: `crates/persisting-pchronicle-cli/tests/server_http_contract.rs` - -**Interfaces:** -- Consumes: `PreparedControl` and `ChronicleServeReady` from Tasks 1-2. -- Produces: optional `ServeArgs.listen`, optional `ServeArgs.control`, mutually exclusive Dataset source arguments, `resolve_serve_config`, and a supervisor for any non-empty combination of Warehouse/Control/Gateway. - -- [ ] **Step 1: Replace old CLI expectations with failing matrix tests** - -Assert these parse successfully: - -```text -serve --storage /tmp/data --control 127.0.0.1:0 -serve --storage /tmp/data --listen 127.0.0.1:0 -serve --storage /tmp/data --gateway gateway.toml -serve --config warehouse.toml --listen 127.0.0.1:0 -``` - -Assert these fail in Clap or startup validation: - -```text -serve --storage /tmp/data -serve --config warehouse.toml -serve --storage a --config b --listen 127.0.0.1:0 -serve --config warehouse.toml --control 127.0.0.1:0 -serve --storage /tmp/data --open --control 127.0.0.1:0 -``` - -Also change the product command list expectation to omit `control`. - -- [ ] **Step 2: Run the CLI tests and observe failures against the old defaults** - -Run: `cargo test -p persisting-pchronicle-cli serve_cli --lib` - -Expected: failures show mandatory `--config`, default `--listen`, and missing `--storage`/`--control` support. - -- [ ] **Step 3: Implement the new `ServeArgs` contract and Dataset resolution** - -Use Clap conflicts/requires plus startup validation. `resolve_serve_config` loads `--config` or creates: - -```rust -server::ChronicleServerConfig::mounted(vec![DatasetMount::new( - DEFAULT_DATASET_NAME, - storage, -)?])? -``` - -Validate loopback addresses only for enabled Warehouse and Control listeners. Preserve current Gateway Dataset selection rules, with the automatic storage mount acting as `default`. - -- [ ] **Step 4: Add failing readiness and no-implicit-HTTP tests** - -Start `serve --storage --control 127.0.0.1:0`, parse stdout as `ChronicleServeReady`, assert `warehouse_endpoint` is absent, connect to Control and receive `Pong`, and assert stderr does not contain the token. Add a Gateway-only test using pre-bound ephemeral Gateway/admin ports and assert no Warehouse endpoint is published. - -- [ ] **Step 5: Implement pre-binding, readiness, and shared supervision** - -Change dispatch to pass both output streams: - -```rust -Command::Serve(args) => run_serve(args, stdout, stderr).await, -``` - -Prepare every enabled component before serializing one `ChronicleServeReady` line to stdout. Run enabled services under one stop signal. On external shutdown, stop and drain all services. If a service returns before shutdown, cancel siblings, drain them, and return the original error or an explicit `" stopped unexpectedly"` error. Always finish the Gateway capture writer after Gateway stops. - -- [ ] **Step 6: Run the serve and HTTP contract tests** - -Run: `cargo test -p persisting-pchronicle-cli serve --lib` - -Run: `cargo test -p persisting-pchronicle-cli --test server_http_contract` - -Expected: new service matrix and existing read-only HTTP contracts pass. - -### Task 4: Process-client and launcher migration - -**Files:** -- Modify: `crates/persisting-events/src/control.rs` -- Modify: `crates/persisting-pchronicle-cli/src/lib.rs` -- Modify: `crates/persisting-pvisor/src/pvisor.rs` -- Modify: `crates/persisting-pvisor/src/cli/trajectory.rs` -- Modify: `crates/persisting-ppilot/src/cli.rs` -- Modify: `crates/persisting-ppilot/src/coordination.rs` -- Test: `crates/persisting-pchronicle-cli/tests/control_process.rs` - -**Interfaces:** -- Consumes: the Task 1 unified ready envelope and Task 3 Control-only serve mode. -- Produces: `ChronicleServeProcessClient` implementing the unchanged `ChronicleControl` trait. - -- [ ] **Step 1: Rename the integration test to the new process adapter and run it red** - -Change imports and construction to: - -```rust -let client = ChronicleServeProcessClient::spawn( - env!("CARGO_BIN_EXE_pchronicle"), - root.path().to_string_lossy(), -).await?; -``` - -Run: `cargo test -p persisting-pchronicle-cli --test control_process` - -Expected: compilation fails because `ChronicleServeProcessClient` does not exist. - -- [ ] **Step 2: Replace the process adapter** - -Rename the public type and debug label, spawn arguments, and readiness parsing. The child command is exactly: - -```rust -Command::new(&binary) - .arg("serve") - .arg("--storage") - .arg(&root_uri) - .arg("--control") - .arg("127.0.0.1:0") -``` - -Decode `ChronicleServeReady`, require `version == CHRONICLE_SERVE_READY_VERSION`, require `control`, validate its loopback endpoint, then perform the existing `Ping` handshake. Remove `ChronicleControlReady` and do not retain a `ChronicleControlProcessClient` alias. - -- [ ] **Step 3: Migrate every pPilot/pVisor launch site** - -Replace imports and constructors in the four listed launcher files. Keep binary and storage configuration names unchanged because they still identify the pChronicle executable and durable root. - -- [ ] **Step 4: Remove the standalone command** - -Delete `Command::Control`, `ControlArgs`, and its dispatch arm. Keep `control.rs` as the embedded service module. Update command-tree tests to ensure `control` is rejected as an unknown subcommand. - -- [ ] **Step 5: Run process and consumer tests** - -Run: `cargo test -p persisting-pchronicle-cli --test control_process` - -Run: `cargo test -p persisting-ppilot coordination --lib` - -Run: `cargo test -p persisting-pvisor --lib` - -Expected: process protocol, coordination, and pVisor tests pass without invoking the removed subcommand. - -### Task 5: Documentation and final verification - -**Files:** -- Modify: `crates/persisting-pchronicle-cli/README.md` -- Modify: `docs/src/pchronicle/reference/cli.md` -- Modify: `docs/src/pchronicle/guides/serve.md` -- Modify: `docs/src/pchronicle/guides/serve.zh.md` -- Modify: `docs/src/pchronicle/guides/serve-gateway.md` -- Modify: `docs/src/pchronicle/guides/serve-gateway.zh.md` -- Modify: examples returned by `rg -l 'pchronicle serve --config' docs examples crates/persisting-pchronicle-cli/README.md` where the command intends to start Warehouse HTTP - -**Interfaces:** -- Documents the final Task 3 CLI and Task 4 migration with no deprecated command. - -- [ ] **Step 1: Update reference and guide commands** - -Add explicit `--listen 127.0.0.1:8080` to commands that intend to start Warehouse HTTP. Document Control-only and combined examples, the `default` mount created by `--storage`, unified ready stdout, Gateway-only behavior, and the `--config`/`--storage` conflict. Remove statements that Control is a separate executable mode. - -- [ ] **Step 2: Scan for stale public names** - -Run: - -```text -rg -n 'pchronicle control|ChronicleControlProcessClient|ChronicleControlReady|serve --config[^\n]*$' crates docs examples -``` - -Expected: no stale product/API references; remaining `serve --config` examples include an explicit service option on the same or following command lines. - -- [ ] **Step 3: Run formatting and static checks** - -Run: `cargo fmt --all -- --check` - -Run: `cargo clippy -p persisting-events -p persisting-pchronicle-cli -p persisting-ppilot -p persisting-pvisor --all-targets -- -D warnings` - -Expected: both commands exit successfully. - -- [ ] **Step 4: Run scoped regression suites** - -Run: `cargo test -p persisting-events` - -Run: `cargo test -p persisting-pchronicle-cli` - -Run: `cargo test -p persisting-ppilot` - -Run: `cargo test -p persisting-pvisor` - -Expected: all non-environment-dependent tests pass; any pre-existing opt-in test remains explicitly reported as ignored. - -- [ ] **Step 5: Build and smoke-test the release binary** - -Run: `cargo build -p persisting-pchronicle-cli --release` - -Start a release Control-only server against a temporary storage root, parse the ready envelope, perform a protocol `Ping`, verify that no Warehouse endpoint is present, terminate it, and move temporary artifacts to trash. - -Expected: release build succeeds, Control responds with `Pong`, no Warehouse socket is advertised, and the child exits cleanly. diff --git a/docs/superpowers/plans/2026-08-22-explorer-steps-chats.md b/docs/superpowers/plans/2026-08-22-explorer-steps-chats.md deleted file mode 100644 index 5497c53c..00000000 --- a/docs/superpowers/plans/2026-08-22-explorer-steps-chats.md +++ /dev/null @@ -1,28 +0,0 @@ -# Explorer Steps / Chats Implementation Plan - -> **For agentic workers:** Implement in this session. TDD for grouping; UI wires the same functions. - -**Goal:** Trace 默认 Chats,可切 Steps;分组只在前端。 - -**Architecture:** 纯函数 `group_chats` 把 `TurnSummary[]` 收成 `TraceCard`。`TrajectoryView` 按 `view` 渲染。URL `view=chats|steps`,旧值 `tree` 视为 chats。 - -**Tech Stack:** Dioxus 0.7, Rust unit tests in `pchronicle-web` - -## Global Constraints - -- 不改 Storyline / ATIF / Lance -- 不伪造 user turn -- 详情仍用 `turn.id` - ---- - -## Task 1: 分组函数 - -- [x] `pchronicle-web/src/chat_view.rs` 先写失败测试,再实现 `normalize_trace_view` / `group_chats` -- [x] `cargo test --manifest-path pchronicle-web/Cargo.toml --bin pchronicle-web -- chat_view` - -## Task 2: Trace UI - -- [x] 工具栏 Chats / Steps;默认 chats -- [x] Chats 卡片 + Steps 平铺 turn 行 -- [x] 嵌入 Copilot 引用保持 Steps diff --git a/docs/superpowers/plans/2026-08-22-json-value-renderer.md b/docs/superpowers/plans/2026-08-22-json-value-renderer.md deleted file mode 100644 index 52a43e19..00000000 --- a/docs/superpowers/plans/2026-08-22-json-value-renderer.md +++ /dev/null @@ -1,764 +0,0 @@ -# JSON Value Renderer Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Turn evidence 里的 JSON 值按形态派发:一层表、嵌套树(默认折叠);字符串里的 JSON 对象/数组先 peel。 - -**Architecture:** `pchronicle-web/src/json_value.rs` 提供 `peel_json` / `classify_json` 和 `JsonValue`。`JsonValue` 先 peel 再派发到 `JsonScalar` / `JsonKvTable` / `JsonRecordTable` / `JsonTree`。表格单元格和树子节点回调 `JsonValue`。`EvidenceBlock` 改成标题 + children 宿主。 - -**Tech Stack:** Dioxus 0.7, `serde_json::Value`, Rust unit tests in `pchronicle-web` bin - -## Global Constraints - -- 只改 `pchronicle-web`;不改 Storyline / 后端 wire / agent pretty JSON -- 不识别 `{fn, args}` 等协议形状 -- 不改查询单元格弹窗 -- Reasoning 始终纯文本,不 peel -- 样式加在现有 `assets/components.css` / `assets/inline-trace.css`,`pc2-` 前缀 -- 单测只覆盖 peel / classify / 列名 / 摘要;不要求 WASM 组件快照 -- 规范:[`docs/superpowers/specs/2026-08-22-json-value-renderer-design.md`](../specs/2026-08-22-json-value-renderer-design.md) - ---- - -## File Structure - -| File | Responsibility | -|---|---| -| Create: `pchronicle-web/src/json_value.rs` | peel、classify、摘要、列名、`JsonValue` 与四个形态组件 | -| Modify: `pchronicle-web/src/main.rs` | `mod json_value` | -| Modify: `pchronicle-web/src/components.rs` | `EvidenceBlock` 宿主化;`InlineTurnDetail` 接入 | -| Modify: `pchronicle-web/assets/components.css` | `.pc2-json-*` 表与树 | -| Modify: `pchronicle-web/assets/inline-trace.css` | evidence 内 JSON 滚动高度;bump `index.html` 的 `?v=` | - ---- - -### Task 1: peel / classify 纯函数 - -**Files:** -- Create: `pchronicle-web/src/json_value.rs` -- Modify: `pchronicle-web/src/main.rs` - -**Interfaces:** -- Consumes: `serde_json::Value` -- Produces: - - `#[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum JsonShape { Scalar, KvTable, RecordTable, Tree }` - - `pub fn peel_json(value: &Value) -> Value` - - `pub fn classify_json(value: &Value) -> JsonShape`(先 `peel_json` 再分类;判断子字段是否标量时不 peel) - - `pub fn is_structured_json(value: &Value) -> bool`(peel 后是 object 或 array) - -- [ ] **Step 1: 声明模块并写失败测试** - -在 `pchronicle-web/src/main.rs` 的 `mod` 列表中、`mod components;` 旁加入: - -```rust -mod json_value; -``` - -创建 `pchronicle-web/src/json_value.rs`,先只放测试引用的空壳,让测试编译失败或断言失败: - -```rust -use serde_json::Value; - -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub enum JsonShape { - Scalar, - KvTable, - RecordTable, - Tree, -} - -pub fn peel_json(_value: &Value) -> Value { - Value::Null -} - -pub fn classify_json(_value: &Value) -> JsonShape { - JsonShape::Scalar -} - -pub fn is_structured_json(_value: &Value) -> bool { - false -} - -#[cfg(test)] -mod tests { - use super::*; - use serde_json::json; - - #[test] - fn peel_promotes_object_and_array_strings_only() { - assert_eq!(peel_json(&json!({"a": 1})), json!({"a": 1})); - assert_eq!(peel_json(&json!("{\"a\":1}")), json!({"a": 1})); - assert_eq!(peel_json(&json!("[1,2]")), json!([1, 2])); - assert_eq!(peel_json(&json!("not-json")), json!("not-json")); - assert_eq!(peel_json(&json!("{")), json!("{")); - assert_eq!(peel_json(&json!("\"hello\"")), json!("\"hello\"")); - assert_eq!(peel_json(&json!(7)), json!(7)); - } - - #[test] - fn classify_matches_one_level_tables_and_trees() { - assert_eq!(classify_json(&json!("plain")), JsonShape::Scalar); - assert_eq!(classify_json(&json!({})), JsonShape::KvTable); - assert_eq!(classify_json(&json!({"b": true, "a": 1})), JsonShape::KvTable); - assert_eq!( - classify_json(&json!([{"b": 2, "a": 1}, {"a": 3, "c": null}])), - JsonShape::RecordTable - ); - assert_eq!( - classify_json(&json!([{"fn": "read", "args": "{\"path\":\"x\"}"}])), - JsonShape::RecordTable - ); - assert_eq!( - classify_json(&json!("{\"fn\":\"read\",\"args\":\"{\\\"path\\\":\\\"x\\\"}\"}")), - JsonShape::KvTable - ); - assert_eq!( - classify_json(&peel_json(&json!("{\"path\":\"x\"}"))), - JsonShape::KvTable - ); - assert_eq!(classify_json(&json!({"nested": {"x": 1}})), JsonShape::Tree); - assert_eq!(classify_json(&json!([1, 2, 3])), JsonShape::Tree); - assert_eq!(classify_json(&json!([])), JsonShape::Tree); - assert_eq!(classify_json(&json!([{"a": 1}, "tail"])), JsonShape::Tree); - assert_eq!(classify_json(&json!([{"a": {"b": 1}}])), JsonShape::Tree); - } - - #[test] - fn structured_detection_follows_peel() { - assert!(!is_structured_json(&json!("hello"))); - assert!(is_structured_json(&json!({"a": 1}))); - assert!(is_structured_json(&json!("{\"a\":1}"))); - assert!(!is_structured_json(&json!("\"hello\""))); - } -} -``` - -- [ ] **Step 2: 跑测试,确认失败** - -Run: - -```bash -cargo test --manifest-path pchronicle-web/Cargo.toml --bin pchronicle-web -- json_value -- --test-threads=1 -``` - -Expected: FAIL(`peel_promotes_object_and_array_strings_only` 断言 `Null != Object`,或同类) - -- [ ] **Step 3: 实现 peel / classify** - -把 `pchronicle-web/src/json_value.rs` 的三个函数换成: - -```rust -use serde_json::Value; - -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub enum JsonShape { - Scalar, - KvTable, - RecordTable, - Tree, -} - -pub fn peel_json(value: &Value) -> Value { - match value { - Value::String(raw) => match serde_json::from_str::(raw) { - Ok(parsed) if parsed.is_object() || parsed.is_array() => parsed, - _ => value.clone(), - }, - other => other.clone(), - } -} - -pub fn classify_json(value: &Value) -> JsonShape { - classify_peeled(&peel_json(value)) -} - -pub fn is_structured_json(value: &Value) -> bool { - let peeled = peel_json(value); - peeled.is_object() || peeled.is_array() -} - -fn is_scalar(value: &Value) -> bool { - matches!( - value, - Value::Null | Value::Bool(_) | Value::Number(_) | Value::String(_) - ) -} - -fn classify_peeled(value: &Value) -> JsonShape { - match value { - Value::Null | Value::Bool(_) | Value::Number(_) | Value::String(_) => JsonShape::Scalar, - Value::Object(object) => { - if object.values().all(is_scalar) { - JsonShape::KvTable - } else { - JsonShape::Tree - } - } - Value::Array(items) => { - if !items.is_empty() - && items.iter().all(|item| { - item.as_object() - .is_some_and(|object| object.values().all(is_scalar)) - }) - { - JsonShape::RecordTable - } else { - JsonShape::Tree - } - } - } -} -``` - -保留 Step 1 的 `#[cfg(test)]` 模块,不要删。 - -- [ ] **Step 4: 再跑测试,确认通过** - -Run: - -```bash -cargo test --manifest-path pchronicle-web/Cargo.toml --bin pchronicle-web -- json_value -- --test-threads=1 -``` - -Expected: `3 passed` - -- [ ] **Step 5: Commit** - -```bash -git add pchronicle-web/src/json_value.rs pchronicle-web/src/main.rs -git commit -m "$(cat <<'EOF' -Add JSON shape classification for turn evidence. - -EOF -)" -``` - ---- - -### Task 2: 列名并集与树摘要 - -**Files:** -- Modify: `pchronicle-web/src/json_value.rs` - -**Interfaces:** -- Consumes: Task 1 的 `peel_json` -- Produces: - - `pub fn record_columns(rows: &[Value]) -> Vec`(只收集 object key,首次出现顺序) - - `pub fn json_summary(value: &Value) -> String`(先 peel;object → `{n keys}`;array → `[n items]`;string → `string`;number → `number`;bool → `boolean`;null → `null`) - -- [ ] **Step 1: 写失败测试** - -在 `pchronicle-web/src/json_value.rs` 的 `tests` 模块末尾追加: - -```rust - #[test] - fn record_columns_keep_first_seen_union() { - let rows = vec![ - json!({"b": 2, "a": 1}), - json!({"a": 3, "c": null}), - ]; - assert_eq!(record_columns(&rows), vec!["a", "b", "c"]); - } - - #[test] - fn json_summary_peels_and_names_types() { - assert_eq!(json_summary(&json!({"a": 1, "b": 2})), "{2 keys}"); - assert_eq!(json_summary(&json!([1, 2, 3])), "[3 items]"); - assert_eq!(json_summary(&json!([])), "[0 items]"); - assert_eq!(json_summary(&json!("{}")), "{0 keys}"); - assert_eq!(json_summary(&json!("hello")), "string"); - assert_eq!(json_summary(&json!(true)), "boolean"); - assert_eq!(json_summary(&json!(1)), "number"); - assert_eq!(json_summary(&json!(null)), "null"); - } -``` - -- [ ] **Step 2: 跑测试,确认失败** - -Run: - -```bash -cargo test --manifest-path pchronicle-web/Cargo.toml --bin pchronicle-web -- record_columns_keep_first_seen -- --test-threads=1 -``` - -Expected: FAIL(`cannot find function record_columns`) - -- [ ] **Step 3: 实现两个函数** - -在 `classify_peeled` 之后、`#[cfg(test)]` 之前加入: - -```rust -pub fn record_columns(rows: &[Value]) -> Vec { - let mut columns = Vec::new(); - for row in rows { - if let Value::Object(object) = row { - for key in object.keys() { - if !columns.contains(key) { - columns.push(key.clone()); - } - } - } - } - columns -} - -pub fn json_summary(value: &Value) -> String { - match peel_json(value) { - Value::Object(object) => format!("{{{} keys}}", object.len()), - Value::Array(items) => format!("[{} items]", items.len()), - Value::String(_) => "string".into(), - Value::Number(_) => "number".into(), - Value::Bool(_) => "boolean".into(), - Value::Null => "null".into(), - } -} -``` - -`serde_json` 默认 `Map` 是 `BTreeMap`,单行 object 的 key 顺序是字典序,所以 `{"b":2,"a":1}` 的首次顺序是 `a` 然后 `b`。测试按这个写。 - -- [ ] **Step 4: 再跑测试** - -Run: - -```bash -cargo test --manifest-path pchronicle-web/Cargo.toml --bin pchronicle-web -- json_value -- --test-threads=1 -``` - -Expected: `5 passed` - -- [ ] **Step 5: Commit** - -```bash -git add pchronicle-web/src/json_value.rs -git commit -m "$(cat <<'EOF' -Add JSON table columns and tree summaries. - -EOF -)" -``` - ---- - -### Task 3: `JsonValue` 派发组件与样式 - -**Files:** -- Modify: `pchronicle-web/src/json_value.rs` -- Modify: `pchronicle-web/assets/components.css` -- Modify: `pchronicle-web/assets/inline-trace.css` -- Modify: `pchronicle-web/index.html` - -**Interfaces:** -- Consumes: `peel_json`, `classify_json` 的内部 `classify_peeled`(组件内先 `peel_json` 再 `match classify_json` 对 peeled 值渲染)、`record_columns`, `json_summary` -- Produces: `#[component] pub fn JsonValue(value: Value) -> Element` - -- [ ] **Step 1: 实现四个形态 + 门面** - -在 `pchronicle-web/src/json_value.rs` 顶部把 import 换成: - -```rust -use dioxus::prelude::*; -use serde_json::Value; -``` - -在 `json_summary` 之后、`#[cfg(test)]` 之前加入: - -```rust -fn scalar_text(value: &Value) -> String { - match value { - Value::Null => "null".into(), - Value::Bool(value) => value.to_string(), - Value::Number(value) => value.to_string(), - Value::String(value) => value.clone(), - other => other.to_string(), - } -} - -#[component] -pub fn JsonValue(value: Value) -> Element { - let peeled = peel_json(&value); - match classify_json(&value) { - JsonShape::Scalar => rsx! { span { class: "pc2-json-scalar", "{scalar_text(&peeled)}" } }, - JsonShape::KvTable => rsx! { JsonKvTable { value: peeled } }, - JsonShape::RecordTable => rsx! { JsonRecordTable { value: peeled } }, - JsonShape::Tree => rsx! { JsonTree { value: peeled } }, - } -} - -#[component] -fn JsonKvTable(value: Value) -> Element { - let map = match value { - Value::Object(map) => map, - _ => return rsx! { span { class: "pc2-json-scalar", "—" } }, - }; - rsx! { - table { class: "pc2-json-table pc2-json-kv", - thead { tr { th { "key" } th { "value" } } } - tbody { - for (key, child) in map { - tr { key: "{key}", - th { scope: "row", "{key}" } - td { JsonValue { value: child } } - } - } - } - } - } -} - -#[component] -fn JsonRecordTable(value: Value) -> Element { - let rows = match value { - Value::Array(rows) => rows, - _ => return rsx! { span { class: "pc2-json-scalar", "—" } }, - }; - let columns = record_columns(&rows); - rsx! { - div { class: "pc2-json-scroll", - table { class: "pc2-json-table pc2-json-records", - thead { tr { for column in columns.iter() { th { "{column}" } } } } - tbody { - for (row_index, row) in rows.iter().enumerate() { - tr { key: "{row_index}", - for column in columns.iter() { - td { - JsonValue { - value: match row { - Value::Object(object) => { - object.get(column).cloned().unwrap_or(Value::Null) - } - _ => Value::Null, - } - } - } - } - } - } - } - } - } - } -} - -#[component] -fn JsonTree(value: Value) -> Element { - match value { - Value::Object(map) if map.is_empty() => rsx! { - details { class: "pc2-json-node", summary { span { class: "pc2-json-size", "{0 keys}" } } } - }, - Value::Array(items) if items.is_empty() => rsx! { - details { class: "pc2-json-node", summary { span { class: "pc2-json-size", "[0 items]" } } } - }, - Value::Object(map) => rsx! { - div { class: "pc2-json-tree", - for (key, child) in map { - JsonTreeNode { key: "{key}", label: key, value: child } - } - } - }, - Value::Array(items) => rsx! { - div { class: "pc2-json-tree", - for (index, child) in items.into_iter().enumerate() { - JsonTreeNode { key: "{index}", label: format!("[{index}]"), value: child } - } - } - }, - other => rsx! { span { class: "pc2-json-scalar", "{scalar_text(&other)}" } }, - } -} - -#[component] -fn JsonTreeNode(label: String, value: Value) -> Element { - let summary = json_summary(&value); - rsx! { - details { class: "pc2-json-node", - summary { span { class: "pc2-json-key", "{label}" } span { class: "pc2-json-size", "{summary}" } } - JsonValue { value } - } - } -} -``` - -注意:`JsonTree` 里空对象分支的 `"{0 keys}"` 必须写成 `"{0 keys}"` 对应 `json_summary` 的 `{0 keys}`。空对象走 `classify` 是 **KvTable**,`JsonValue` 不会把空对象派到 `JsonTree`。空对象分支可删,只留空数组: - -把 `JsonTree` 换成: - -```rust -#[component] -fn JsonTree(value: Value) -> Element { - match value { - Value::Array(items) if items.is_empty() => rsx! { - details { class: "pc2-json-node", - summary { span { class: "pc2-json-size", "[0 items]" } } - } - }, - Value::Object(map) => rsx! { - div { class: "pc2-json-tree", - for (key, child) in map { - JsonTreeNode { key: "{key}", label: key, value: child } - } - } - }, - Value::Array(items) => rsx! { - div { class: "pc2-json-tree", - for (index, child) in items.into_iter().enumerate() { - JsonTreeNode { key: "{index}", label: format!("[{index}]"), value: child } - } - } - }, - other => rsx! { span { class: "pc2-json-scalar", "{scalar_text(&other)}" } }, - } -} -``` - -`
` 不要写 `open`,默认折叠。 - -- [ ] **Step 2: 加 CSS** - -在 `pchronicle-web/assets/components.css` 文件末尾追加: - -```css -.pc2-json-scroll { - max-height: 360px; - overflow: auto; -} - -.pc2-json-table { - width: 100%; - border-collapse: collapse; - font-size: 11px; -} - -.pc2-json-table th, -.pc2-json-table td { - padding: 5px 8px; - border: 1px solid #eef0f3; - text-align: left; - vertical-align: top; -} - -.pc2-json-table thead th, -.pc2-json-kv th[scope="row"] { - background: #f8fafc; - color: #667085; - font-size: 9px; - font-weight: 700; -} - -.pc2-json-scalar { - white-space: pre-wrap; - word-break: break-word; - color: #344054; -} - -.pc2-json-tree { - display: flex; - flex-direction: column; - gap: 2px; -} - -.pc2-json-node { - border-left: 1px solid #e4e7ec; - padding-left: 8px; -} - -.pc2-json-node > summary { - display: flex; - align-items: center; - gap: 8px; - cursor: pointer; - list-style: none; -} - -.pc2-json-node > summary::-webkit-details-marker { - display: none; -} - -.pc2-json-key { - color: #101828; - font-family: ui-monospace, SFMono-Regular, Menlo, monospace; - font-size: 11px; -} - -.pc2-json-size { - color: #667085; - font-size: 9px; -} - -.pc2-json-node > .pc2-json-table, -.pc2-json-node > .pc2-json-tree, -.pc2-json-node > .pc2-json-scroll, -.pc2-json-node > .pc2-json-scalar { - margin: 6px 0 8px; -} -``` - -在 `pchronicle-web/assets/inline-trace.css` 的 `.pc2-inline-detail .pc2-evidence-block pre` 规则旁追加: - -```css -.pc2-inline-detail .pc2-evidence-block .pc2-json-scroll, -.pc2-inline-detail .pc2-evidence-block .pc2-json-tree { - max-height: 420px; - overflow: auto; -} -``` - -把 `pchronicle-web/index.html` 里: - -```html - - -``` - -改成: - -```html - - -``` - -- [ ] **Step 3: 编译并跑 json_value 测试** - -Run: - -```bash -cargo test --manifest-path pchronicle-web/Cargo.toml --bin pchronicle-web -- json_value -- --test-threads=1 -``` - -Expected: `5 passed`;含 `JsonValue` 的 bin 能编过。 - -- [ ] **Step 4: Commit** - -```bash -git add pchronicle-web/src/json_value.rs pchronicle-web/assets/components.css pchronicle-web/assets/inline-trace.css pchronicle-web/index.html -git commit -m "$(cat <<'EOF' -Render classified JSON as tables and collapsed trees. - -EOF -)" -``` - ---- - -### Task 4: Turn evidence 接入 - -**Files:** -- Modify: `pchronicle-web/src/components.rs` - -**Interfaces:** -- Consumes: `crate::json_value::{is_structured_json, JsonValue}` -- Produces: `EvidenceBlock { title: &'static str, open: bool, children: Element }`;`InlineTurnDetail` 按 spec 表接入 - -- [ ] **Step 1: 改 `EvidenceBlock` 为宿主** - -在 `pchronicle-web/src/components.rs` 顶部 import 区增加: - -```rust -use crate::json_value::{is_structured_json, JsonValue}; -``` - -把现有 - -```rust -fn EvidenceBlock(title: &'static str, value: String) -> Element { - rsx! { details { class: "pc2-evidence-block", open: title == "Message", summary { "{title}" } pre { "{value}" } } } -} -``` - -换成: - -```rust -#[component] -fn EvidenceBlock(title: &'static str, #[props(default = false)] open: bool, children: Element) -> Element { - rsx! { details { class: "pc2-evidence-block", open, summary { "{title}" } {children} } } -} -``` - -- [ ] **Step 2: 改 `InlineTurnDetail`** - -把 `InlineTurnDetail` 整段换成(facts 行保持原样,只换块): - -```rust -#[component] -fn InlineTurnDetail(value: TurnDetail) -> Element { - let message = value.turn.message.clone(); - let message_text = value.turn.text(); - let structured_message = is_structured_json(&message); - let tool_calls = serde_json::to_value(&value.wire_tool_calls).unwrap_or(Value::Array(Vec::new())); - let events = serde_json::to_value(&value.events).unwrap_or(Value::Array(Vec::new())); - rsx! { div { class: "pc2-inline-detail-head", strong { "Full turn evidence" } } - div { class: "pc2-inspector-facts", Fact { label: "Turn", value: format!("#{}", value.summary.id) } Fact { label: "Source", value: value.summary.source.clone() } Fact { label: "Kind", value: value.summary.kind.clone().unwrap_or_else(|| "unavailable".into()) } Fact { label: "Model", value: value.summary.model_name.clone().unwrap_or_else(|| "unavailable".into()) } Fact { label: "Latency", value: value.summary.latency_ms.map(format_ms).unwrap_or_else(|| "unavailable".into()) } Fact { label: "TTFT", value: value.summary.ttft_ms.map(format_ms).unwrap_or_else(|| "unavailable".into()) } Fact { label: "Tokens", value: value.summary.total_tokens.map(|tokens| tokens.to_string()).unwrap_or_else(|| "unavailable".into()) } Fact { label: "Token split", value: format!("{} in · {} out", optional_u64(value.summary.prompt_tokens), optional_u64(value.summary.completion_tokens)) } Fact { label: "Events", value: value.events.len().to_string() } } - if structured_message { - EvidenceBlock { title: "Message", open: true, JsonValue { value: message } } - } else { - EvidenceBlock { title: "Message", open: true, pre { "{message_text}" } } - } - if let Some(reasoning) = &value.turn.reasoning_content { - EvidenceBlock { title: "Reasoning", pre { "{reasoning.clone()}" } } - } - if !value.wire_tool_calls.is_empty() { - EvidenceBlock { title: "Tool calls", JsonValue { value: tool_calls } } - } - if let Some(observation) = value.turn.observation.clone() { - EvidenceBlock { title: "Observation", JsonValue { value: observation } } - } - if !value.events.is_empty() { - EvidenceBlock { title: "Raw linked events", JsonValue { value: events } } - } - if let Some(extra) = value.turn.extra.clone() { - EvidenceBlock { title: "Extra", JsonValue { value: extra } } - } - if let Some(metrics) = value.turn.metrics.clone() { - EvidenceBlock { title: "Metrics", JsonValue { value: metrics } } - } - } -} -``` - -`TurnDetail.turn` 已有 `extra: Option` 和 `metrics: Option`,不要改 `model.rs`。不要改 `agent.rs` 里给模型的 pretty JSON。不要改 `CellValue` / 查询弹窗。 - -- [ ] **Step 3: 编译测试** - -Run: - -```bash -cargo test --manifest-path pchronicle-web/Cargo.toml --bin pchronicle-web -- --test-threads=1 -``` - -Expected: 全部通过(含 `json_value` 与现有 `components` / `chat_view` / `model` 测试)。 - -本地预览(需要时): - -```bash -just chronicle-web-build -``` - -然后用已有 `pchronicle serve` 看一条带 tool calls / observation 的 turn:扁平对象应是表,嵌套应是默认折叠的树,JSON 字符串格子里再派发。 - -- [ ] **Step 4: Commit** - -```bash -git add pchronicle-web/src/components.rs -git commit -m "$(cat <<'EOF' -Host turn evidence JSON through JsonValue. - -EOF -)" -``` - ---- - -## Spec coverage - -| Spec | Task | -|---|---| -| peel 只处理当前节点;子字段不 peel | 1 | -| Scalar / KvTable / RecordTable / Tree 判定 | 1 | -| 空对象 KvTable;空数组 Tree | 1 | -| 非法 JSON / JSON 标量字符串 → Scalar | 1 | -| RecordTable 列并集、首次出现 | 2 | -| 树摘要 `{n keys}` / `[n items]` | 2 | -| `JsonValue` 派发四形态;单元格/子节点回调 | 3 | -| `
` 默认折叠 | 3 | -| `pc2-` 样式、不新开 CSS 管线 | 3 | -| EvidenceBlock 宿主;Message / Reasoning / 各 JSON 块 / Extra / Metrics | 4 | -| 不改查询单元格、不改 agent pretty JSON | 4(明确不碰) | diff --git a/docs/superpowers/plans/2026-08-22-persisting-replay-adapter-module-split.md b/docs/superpowers/plans/2026-08-22-persisting-replay-adapter-module-split.md deleted file mode 100644 index f994fb2a..00000000 --- a/docs/superpowers/plans/2026-08-22-persisting-replay-adapter-module-split.md +++ /dev/null @@ -1,312 +0,0 @@ -# Persisting Replay Adapter Module Split Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Physically move every Agent-specific parser and executor out of `adapter/mod.rs` into its versioned Agent module without changing replay behavior or public contracts. - -**Architecture:** `adapter/mod.rs` remains the static dispatcher and owns only shared context, helpers, and the existing Mini/SWE SDK bridge. Each Agent module owns its native parsing, prefix preparation, execution, reconstruction helpers, terminal interpretation, and focused tests. - -**Tech Stack:** Rust 2024, serde/serde_json, Cargo test and Clippy. - -**Spec:** `docs/superpowers/specs/2026-08-22-persisting-replay-adapter-module-split-design.md` - -## Global Constraints - -- Preserve `ReplayPlan`, `AdapterPlan`, `PlaybackRequest`, `ReplayOutcome`, and all serialized request/result contracts. -- Preserve every artifact filename and runtime command/environment variable. -- Do not redesign `run_sdk_bridge`; it remains shared by Mini and SWE. -- Use explicit imports in Agent modules; do not retain `use super::*`. -- Do not modify Gateway, pChronicle, Queue, Search, TTAS, or `persisting-dlcapt`. -- Stage only replay adapter files and this plan. - ---- - -### Task 1: Move OpenHands implementation - -**Files:** -- Modify: `crates/persisting-replay/src/adapter/mod.rs` -- Modify: `crates/persisting-replay/src/adapter/openhands.rs` - -**Interfaces:** -- Consumes: `RunContext`, `check_boundary`, `prepared_outcome`, `agent_command`, `MAX_TOOL_OUTPUT_BYTES`, `run_process`, and common IO/error/model types. -- Produces: the existing `openhands::build` and `openhands::execute` signatures with all OpenHands implementation private to `openhands.rs`. - -- [ ] **Step 1: Record the focused characterization baseline** - -Run: - -```bash -cargo test -p persisting-replay openhands_ -- --nocapture -cargo test -p persisting-replay --test replay_contract openhands_ -- --nocapture -``` - -Expected: all selected tests pass before movement. - -- [ ] **Step 2: Move the OpenHands parser and executor** - -Move these functions unchanged into `openhands.rs`, below the existing public-to-parent entrypoints: - -```text -build_openhands_plan -openhands_action_signature -openhands_reconstructed_tool_metadata -openhands_reconstructed_tool_arguments -event_id -run_openhands -openhands_fatal_controller_marker -openhands_observation_content -openhands_complete_batches -prepend_openhands_runtime_tools -``` - -Change the entrypoints to call module-local functions: - -```rust -pub(super) fn build(request: &PlaybackRequest) -> Result { - build_openhands_plan(request).map(AdapterPlan::Openhands) -} - -pub(super) fn execute( - plan: &ReplayPlan, - context: &RunContext<'_>, - journal: &mut Journal, -) -> Result { - run_openhands(plan, context, journal) -} -``` - -Move the five `openhands_*` unit tests from the root test module into a local `#[cfg(test)] mod tests` in `openhands.rs`. - -- [ ] **Step 3: Verify OpenHands after movement** - -Run the two Step 1 commands again, then: - -```bash -cargo check -p persisting-replay -``` - -Expected: all commands exit zero. - -- [ ] **Step 4: Commit the OpenHands split** - -```bash -git add crates/persisting-replay/src/adapter/mod.rs crates/persisting-replay/src/adapter/openhands.rs -git commit -m "refactor(replay): move OpenHands adapter implementation" -``` - -### Task 2: Move Mini implementation - -**Files:** -- Modify: `crates/persisting-replay/src/adapter/mod.rs` -- Modify: `crates/persisting-replay/src/adapter/mini_swe_agent.rs` - -**Interfaces:** -- Consumes: shared `check_boundary`, `prepared_outcome`, and `run_sdk_bridge`. -- Produces: module-local Mini parsing/preparation with unchanged `mini_swe_agent::build` and `mini_swe_agent::execute` entrypoints. - -- [ ] **Step 1: Record the Mini characterization baseline** - -```bash -cargo test -p persisting-replay mini_ -- --nocapture -cargo test -p persisting-replay --test replay_contract mini_ -- --nocapture -``` - -Expected: all selected tests pass. - -- [ ] **Step 2: Move Mini-specific code** - -Move these functions unchanged into `mini_swe_agent.rs`: - -```text -mini_reasoning -mini_batch_signature -build_mini_plan -mini_submission_in_prefix -mini_calls -mini_observation -run_mini -``` - -Keep `run_sdk_bridge` in `adapter/mod.rs`; expose it as `pub(super)` so the -module-local `run_mini` can call: - -```rust -run_sdk_bridge(plan, context, journal, AgentKind::MiniSweAgent) -``` - -Move `mini_submit_is_rejected_only_inside_the_selected_prefix` into the Mini -module test block. Move version-probe and portable-runtime tests to -`adapter/runtime.rs`, because they test runtime resolution rather than native -Mini trajectory behavior. - -- [ ] **Step 3: Verify and commit Mini** - -```bash -cargo test -p persisting-replay mini_ -- --nocapture -cargo test -p persisting-replay --test replay_contract mini_ -- --nocapture -cargo check -p persisting-replay -git add crates/persisting-replay/src/adapter/mod.rs crates/persisting-replay/src/adapter/mini_swe_agent.rs crates/persisting-replay/src/adapter/runtime.rs -git commit -m "refactor(replay): move Mini adapter implementation" -``` - -Expected: tests and check pass before the commit. - -### Task 3: Move SWE implementation - -**Files:** -- Modify: `crates/persisting-replay/src/adapter/mod.rs` -- Modify: `crates/persisting-replay/src/adapter/swe_agent.rs` - -**Interfaces:** -- Consumes: shared `check_boundary`, `prepared_outcome`, and `run_sdk_bridge`. -- Produces: module-local SWE parser, asset resolution, and prefix preparation. - -- [ ] **Step 1: Record the SWE characterization baseline** - -```bash -cargo test -p persisting-replay --test replay_contract swe_ -- --nocapture -``` - -Expected: the SWE total-budget contract test passes. - -- [ ] **Step 2: Move SWE-specific code** - -Move these functions unchanged into `swe_agent.rs`: - -```text -build_swe_plan -resolve_swe_problem_asset -run_swe -``` - -The module-local executor continues to call exactly: - -```rust -run_sdk_bridge(plan, context, journal, AgentKind::SweAgent) -``` - -Do not move or rewrite any Mini/SWE result parsing in `run_sdk_bridge` during -this task. - -- [ ] **Step 3: Verify and commit SWE** - -```bash -cargo test -p persisting-replay --test replay_contract swe_ -- --nocapture -cargo check -p persisting-replay -git add crates/persisting-replay/src/adapter/mod.rs crates/persisting-replay/src/adapter/swe_agent.rs -git commit -m "refactor(replay): move SWE adapter implementation" -``` - -Expected: tests and check pass before the commit. - -### Task 4: Move Claude implementation and tests - -**Files:** -- Modify: `crates/persisting-replay/src/adapter/mod.rs` -- Modify: `crates/persisting-replay/src/adapter/claude_code.rs` - -**Interfaces:** -- Consumes: shared `check_boundary`, `required_str`, `agent_command`, and process/IO/error types. -- Produces: all Claude parsing, tool execution, reconstruction, resume cleanup, and Claude unit tests inside `claude_code.rs`. - -- [ ] **Step 1: Record the Claude characterization baseline** - -```bash -cargo test -p persisting-replay claude_ -- --nocapture -cargo test -p persisting-replay stale_observations_ -- --nocapture -cargo test -p persisting-replay prepare_only_executes_no_historical_tool -- --nocapture -cargo test -p persisting-replay bash_ -- --nocapture -cargo test -p persisting-replay wildcard_ -- --nocapture -``` - -Expected: all selected tests pass. - -- [ ] **Step 2: Move all Claude-specific implementation** - -Move the Claude code ranges beginning with `claude_boundary_tool_use_ids` and -`build_claude_plan`, plus `run_claude` through its native tool/reconstruction -helpers, into `claude_code.rs`. The resulting module must own these groups: - -```text -Claude canonical-message and active-chain parsing -Claude ToolUse/ToolResult batch parsing -Claude tool policy and historical tool execution -Bash/Edit/Read/Glob/Grep replay helpers and wildcard traversal -Claude native session rebuilding and continuation cleanup -Resume Transport attachment, UUID, nonce, and parent-chain validation -Claude max-turn terminal-result validation -``` - -Move every root unit test whose name starts with `claude_`, `stale_`, `bash_`, -or `wildcard_`, plus `prepare_only_executes_no_historical_tool`, into the -Claude module test block. Keep `direct_agents_keep_model_credentials_but_claude_tools_do_not` -in `adapter/mod.rs` because it tests the shared environment policy. - -- [ ] **Step 3: Remove obsolete root imports and verify structural boundaries** - -Remove imports and constants from `adapter/mod.rs` that are now used only by -Claude. Confirm the forbidden functions no longer exist in the root: - -```bash -rg -n 'fn (build_(claude|mini|openhands|swe)_plan|run_(claude|mini|openhands|swe))' crates/persisting-replay/src/adapter/mod.rs -``` - -Expected: no matches. - -- [ ] **Step 4: Verify and commit Claude** - -Run every Step 1 command, then: - -```bash -cargo check -p persisting-replay -cargo fmt --check -p persisting-replay -git add crates/persisting-replay/src/adapter/mod.rs crates/persisting-replay/src/adapter/claude_code.rs -git commit -m "refactor(replay): move Claude adapter implementation" -``` - -Expected: all commands exit zero. - -### Task 5: Full regression and scope verification - -**Files:** -- Verify only; modify adapter files only if a verification failure was introduced by this refactor. - -**Interfaces:** -- Consumes: all four physically split adapters. -- Produces: evidence that the refactor preserved behavior and repository scope. - -- [ ] **Step 1: Run full replay verification** - -```bash -cargo fmt --check -p persisting-replay -cargo test -p persisting-replay -cargo clippy -p persisting-replay --all-targets -- -D warnings -git diff --check -``` - -Expected: every command exits zero; the test output includes all replay unit, -contract, and doc tests with zero failures. - -- [ ] **Step 2: Inspect the final module sizes and boundaries** - -```bash -wc -l crates/persisting-replay/src/adapter/*.rs -rg -n 'fn (build_(claude|mini|openhands|swe)_plan|run_(claude|mini|openhands|swe))' crates/persisting-replay/src/adapter/mod.rs -git status --short -``` - -Expected: all four Agent files contain substantial implementations, the `rg` -command returns no matches, and no Gateway/pChronicle/storyline or `.workbuddy` -path is staged by this work. - -- [ ] **Step 3: Commit any verification-only import cleanup** - -If Step 1 required an adapter import or visibility cleanup, stage only those -adapter files and commit: - -```bash -git add crates/persisting-replay/src/adapter -git commit -m "refactor(replay): finish physical adapter split" -``` - -If no cleanup was required, do not create an empty commit. diff --git a/docs/superpowers/plans/2026-08-22-persisting-replay-reliability.md b/docs/superpowers/plans/2026-08-22-persisting-replay-reliability.md deleted file mode 100644 index 9c142576..00000000 --- a/docs/superpowers/plans/2026-08-22-persisting-replay-reliability.md +++ /dev/null @@ -1,433 +0,0 @@ -# Persisting Replay Reliability Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking. - -**Goal:** Make prepare, replay, and continuation semantics reliable across all four supported Agents while bounding child processes and publishing an explicit v3 result contract. - -**Architecture:** The common engine owns phase transitions, state/output lifecycle, results, journaling, and process supervision. Static Agent adapters own version-pinned native parsing and execution; behavioral coverage lands before the monolithic adapter is split. - -**Tech Stack:** Rust 2021, serde/serde_json, clap, tokio/axum/reqwest, Unix process groups through libc, and pinned Python runners for mini-swe-agent and SWE-agent. - -**Spec:** docs/superpowers/specs/2026-08-22-persisting-replay-reliability-design.md - -## Global Constraints - -- Keep Claude Code at 2.1.220, mini-swe-agent at 2.4.6, OpenHands at 0.53.0, and SWE-agent at 1.1.0. -- Do not add dynamic adapter loading, Gateway capture, pChronicle storage, or model-traffic capture. -- Do not modify TTAS, Queue, Search, or persisting-dlcapt. -- max_steps means total Agent action/model steps including the replay prefix. -- Write and observe a failing regression test before every production behavior change. -- Preserve unrelated .workbuddy and worktree changes. - ---- - -## File Map - -- crates/persisting-replay/src/model.rs: request modes and v3 result/status types. -- crates/persisting-replay/src/config.rs: strict TOML/JSON mapping. -- crates/persisting-replay/src/process.rs: bounded process-group supervisor. -- crates/persisting-replay/src/adapter/: static dispatch and Agent-specific plans. -- crates/persisting-replay/src/engine.rs: validated phase state machine and result publication. -- crates/persisting-replay/src/journal.rs: conservative ambiguity detection. -- crates/persisting-replay/assets/: native replay runners. -- crates/persisting-replay/tests/replay_contract.rs: fake-runtime integration contract. -- crates/persisting-pvisor/src/cli/: public CLI and error output. -- pVisor README and replay docs: migration documentation. - ---- - -### Task 1: Explicit replay modes and request compatibility - -**Files:** -- Modify: crates/persisting-replay/src/model.rs -- Modify: crates/persisting-replay/src/config.rs -- Modify: crates/persisting-replay/src/lib.rs -- Modify: crates/persisting-pvisor/src/cli/replay.rs -- Modify: crates/persisting-pvisor/src/cli/mod.rs - -**Interfaces:** -- Produces ReplayMode with PrepareOnly, ReplayOnly, and ReplayAndContinue. -- Produces PlaybackRequest.mode and PlaybackRequest.allow_stale_observations. - -- [ ] **Step 1: Write failing mode tests** - -Add config tests using the desired API: - - assert_eq!(prepare.into_request(cwd)?.mode, ReplayMode::PrepareOnly); - assert_eq!(replay.into_request(cwd)?.mode, ReplayMode::ReplayOnly); - assert!(both.into_request(cwd).is_err()); - -Add clap tests accepting each flag, rejecting both, and proving managed replay propagates prepare-only and allow-stale-observations. - -- [ ] **Step 2: Verify RED** - - cargo test -p persisting-replay config::tests - cargo test -p persisting-pvisor cli::replay - -Expected: compile/test failure because the new mode and flags do not exist. - -- [ ] **Step 3: Implement the request contract** - -Add a serde snake_case ReplayMode enum. Replace PlaybackRequest.replay_only. Add strict prepare_only and allow_stale_observations fields to TOML and v1 JSON. Map legacy replay_only=true to corrected ReplayOnly, reject both booleans, and default to live continuation. Add conflicting clap flags and managed-command propagation. - -- [ ] **Step 4: Update request literals and verify GREEN** - -Use mode=PrepareOnly and allow_stale_observations=false in existing prepare-only tests, then rerun Step 2. - -- [ ] **Step 5: Commit** - - git commit -m "feat(replay): separate prepare replay and continuation modes" - ---- - -### Task 2: V3 results and failure locations - -**Files:** -- Modify: crates/persisting-replay/src/model.rs -- Modify: crates/persisting-replay/src/error.rs -- Modify: crates/persisting-replay/src/engine.rs -- Modify: crates/persisting-pvisor/src/cli/replay.rs - -**Interfaces:** -- Produces ReplayPhase, ReplayQuality, AgentStatus, and ReplayFailure. -- Produces ExecutionReport containing ReplayResult and exit_code. - -- [ ] **Step 1: Write failing serialization tests** - -Assert representative JSON contains: - - assert_eq!(value["schema_version"], "sandbox-playback.result/v3"); - assert_eq!(value["phase"], "replayed"); - assert_eq!(value["quality"], "degraded"); - assert_eq!(value["agent_status"], "not_started"); - assert_eq!(value["failure"], Value::Null); - -Add a CLI helper test requiring run_id, state_dir, and output_dir in failure JSON. - -- [ ] **Step 2: Verify RED** - - cargo test -p persisting-replay model::tests - cargo test -p persisting-pvisor cli::replay::tests::failure_json_keeps_run_locations - -- [ ] **Step 3: Implement typed v3 results** - -Add snake-case enums for Prepared/Replayed/Continued, Verified/Degraded, and Completed/MaxSteps/Failed/NotStarted. Replace string statuses, set RESULT_SCHEMA_VERSION to v3, add both roots and optional structured failure, and retain partial artifacts on runtime failure. - -- [ ] **Step 4: Update CLI and verify GREEN** - -Print ExecutionReport.result for success and execution failure, returning its exit code. Pre-execution errors use a structured envelope containing every known location. - -- [ ] **Step 5: Commit** - - git commit -m "feat(replay): publish structured v3 execution results" - ---- - -### Task 3: Exact runtime versions and portable runtime paths - -**Files:** -- Create: crates/persisting-replay/src/adapter/mod.rs -- Create: crates/persisting-replay/src/adapter/runtime.rs -- Modify/delete: crates/persisting-replay/src/adapter.rs - -**Interfaces:** -- Produces resolve_launch_spec(PlaybackRequest). -- Produces parse_version(AgentKind, output) with exact matching. - -- [ ] **Step 1: Write failing exact-version tests** - - assert_eq!(parse_version(ClaudeCode, "2.1.220 (Claude Code)"), Some("2.1.220")); - assert_eq!(parse_version(ClaudeCode, "12.1.220"), None); - assert_eq!(parse_version(Openhands, "0.53.0\n"), Some("0.53.0")); - assert_eq!(parse_version(Openhands, "warning 0.53.0 actual 0.54.0"), None); - -Canonicalize the expected mini Python path before equality assertions. - -- [ ] **Step 2: Verify RED** - - cargo test -p persisting-replay adapter::runtime::tests::version_probes_require_exact_banners - -Expected: the old substring matcher accepts the wrong Claude banner. - -- [ ] **Step 3: Extract and implement runtime parsing** - -Move LaunchSpec, runtime manifest handling, safe relative paths, version probes, and mini Python discovery/configuration into adapter/runtime.rs. Require an exact first Claude version token, exact trimmed metadata output for OpenHands/SWE-agent, and the exact mini banner. - -- [ ] **Step 4: Verify GREEN** - - cargo test -p persisting-replay adapter::runtime - cargo clippy -p persisting-replay --all-targets -- -D warnings - -- [ ] **Step 5: Commit** - - git commit -m "refactor(replay): isolate exact agent runtime resolution" - ---- - -### Task 4: Bounded process-group supervisor - -**Files:** -- Create: crates/persisting-replay/src/process.rs -- Modify: crates/persisting-replay/src/lib.rs - -**Interfaces:** -- Produces run_process(ProcessSpec) returning ProcessOutput. -- ProcessOutput carries status, bounded stdout/stderr tails, byte totals, truncation, timeout, and background cleanup state. - -- [ ] **Step 1: Read test guidance and write regression tests** - -Read superpowers/test-driven-development/writing-good-tests.md. Add Unix tests that produce 8 MiB with a 64 KiB retained cap, start sleep 30 in the background, and time out a foreground process. Assert complete log draining, bounded retained bytes, prompt return, and no surviving process-group member. - -- [ ] **Step 2: Verify RED** - - cargo test -p persisting-replay process::tests -- --nocapture - -- [ ] **Step 3: Implement streaming supervision** - -Use a dedicated Unix process group. Reader threads stream all chunks to an owner-only log and retain only the configured amount. Poll the leader, enforce timeout, terminate the negative PGID with TERM then KILL, reap the leader, and kill a group whose pipes remain open after leader exit. Do not use read_to_end, Command::output, or wait_with_output. - -- [ ] **Step 4: Verify GREEN** - - cargo test -p persisting-replay process::tests -- --nocapture - cargo clippy -p persisting-replay --all-targets -- -D warnings - -- [ ] **Step 5: Commit** - - git commit -m "feat(replay): supervise child process groups with bounded output" - ---- - -### Task 5: Verified Claude replay by default - -**Files:** -- Create: crates/persisting-replay/src/adapter/claude.rs -- Modify: crates/persisting-replay/src/adapter/mod.rs -- Modify: crates/persisting-replay/src/engine.rs -- Modify: crates/persisting-replay/src/process.rs - -**Interfaces:** -- Produces private ClaudePlan and phase-oriented prepare/replay/continue functions. -- Consumes allow_stale_observations and the process supervisor. - -- [ ] **Step 1: Write failing stale-observation tests** - -Use a prefix containing Agent(Explore), TaskOutput, and Task/Todo state calls. Assert default validation fails before execution. With opt-in, assert Degraded quality and per-call degradation=stale_source_observation. Assert Find is rejected at plan time. - -- [ ] **Step 2: Verify RED** - - cargo test -p persisting-replay adapter::claude::tests::stale_observations_fail_closed_by_default - cargo test -p persisting-replay adapter::claude::tests::stale_observations_are_explicitly_degraded - -- [ ] **Step 3: Split Claude code and enforce quality** - -Move Claude parsing, chain reconstruction, tools, and continuation into adapter/claude.rs. Classify tools as fresh, stale-opt-in, or unsupported. Prepare-only keeps the selected source prefix without execution; replay-only executes and rebuilds without starting the bridge; live mode continues from replayed state. - -- [ ] **Step 4: Integrate supervision** - -Replace Bash readers and Claude wait_with_output with run_process. Retain at most 4 MiB for observations, keep full owner-only logs, propagate truncation, and report terminated background descendants as an error observation. - -- [ ] **Step 5: Verify and commit** - - cargo test -p persisting-replay adapter::claude - cargo test -p persisting-replay claude_resume - cargo test -p persisting-replay claude_bridge - git commit -m "fix(replay): fail closed on stale Claude observations" - ---- - -### Task 6: True mini-swe-agent and SWE-agent replay-only execution - -**Files:** -- Create: crates/persisting-replay/src/adapter/mini_swe.rs -- Create: crates/persisting-replay/src/adapter/swe_agent.rs -- Modify: crates/persisting-replay/src/adapter/mod.rs -- Modify: crates/persisting-replay/assets/mini_swe_agent_runner.py -- Modify: crates/persisting-replay/assets/swe_agent_runner.py -- Create: crates/persisting-replay/tests/replay_contract.rs -- Create: crates/persisting-replay/tests/fixtures/fake_agent_runtime.py - -**Interfaces:** -- Runner request gains mode and max_steps. -- Runner result gains phase, agent_status, replayed_steps, and continued_steps. - -- [ ] **Step 1: Write failing fake-runtime tests** - -Prove prepare-only starts no runtime; replay-only executes exactly after_step actions and zero live calls; live mode performs no more than max_steps-after_step live calls; replay-only without runtime fails before a workspace marker is written. - -- [ ] **Step 2: Verify RED** - - cargo test -p persisting-replay --test replay_contract mini_replay_only_executes_prefix_without_live_model - cargo test -p persisting-replay --test replay_contract swe_max_steps_caps_total_actions - -- [ ] **Step 3: Update mini runner** - -After the historical loop and observation write, save and return a structured replay result in replay-only mode. Call continuation only in live mode. Preserve prefix n_calls and set the native step_limit to total max_steps. - -- [ ] **Step 4: Update SWE-agent runner with a bounded loop** - -Pass mode and max_steps. Wrap the pinned DefaultAgent so its run calls setup, then step no more than the total budget while saving after each step. ReplayThenLiveModel supplies the source prefix. Stop before the first live query in replay-only; emit MaxSteps when live mode reaches the cap. Use SWE-agent 1.1.0 APIs setup, step, save_trajectory, get_trajectory_data, and AgentRunResult. Reject retry-agent configs before side effects. - -- [ ] **Step 5: Split adapters and use supervision** - -Move parsers and runner result interpretation into focused modules. Replace Command::output with run_process and parse a structured runner result file. - -- [ ] **Step 6: Verify and commit** - - cargo test -p persisting-replay --test replay_contract mini_ - cargo test -p persisting-replay --test replay_contract swe_ - cargo test -p persisting-replay adapter::mini_swe - cargo test -p persisting-replay adapter::swe_agent - git commit -m "fix(replay): execute SDK prefixes without unwanted continuation" - ---- - -### Task 7: OpenHands replay boundary and fatal status - -**Files:** -- Create: crates/persisting-replay/src/adapter/openhands.rs -- Modify: crates/persisting-replay/src/adapter/mod.rs -- Modify: crates/persisting-replay/src/engine.rs -- Modify: crates/persisting-replay/tests/replay_contract.rs - -**Interfaces:** -- Produces private OpenHandsPlan and typed terminal status. - -- [ ] **Step 1: Write failing integration tests** - -Use a fake entrypoint to prove replay-only executes the prefix and emits no live action. Add a zero-exit log containing Error while running the agent; require Failed status, nonzero report exit code, and a retained partial trajectory artifact. - -- [ ] **Step 2: Verify RED** - - cargo test -p persisting-replay --test replay_contract openhands_replay_only_stops_at_boundary - cargo test -p persisting-replay --test replay_contract openhands_fatal_status_is_not_success - -- [ ] **Step 3: Split OpenHands and stop at the boundary** - -Move parsing/output code into adapter/openhands.rs. In replay-only, run the pinned ReplayManager with the selected-prefix iteration limit and validate exactly after_step complete pairs with no live action. In live mode map and verify the total budget without a silent offset. - -- [ ] **Step 4: Supervise and type terminal states** - -Replace wait_with_output with run_process. Map fatal markers to Failed, exact maximum-iteration termination to MaxSteps, otherwise Completed. Publish a valid partial artifact before returning failure. - -- [ ] **Step 5: Verify and commit** - - cargo test -p persisting-replay adapter::openhands - cargo test -p persisting-replay --test replay_contract openhands_ - git commit -m "fix(replay): separate OpenHands replay from continuation" - ---- - -### Task 8: Conservative journal and output lifecycle - -**Files:** -- Modify: crates/persisting-replay/src/journal.rs -- Modify: crates/persisting-replay/src/engine.rs -- Modify: crates/persisting-replay/tests/replay_contract.rs - -**Interfaces:** -- Produces ordered journal-state inspection that rejects every nonterminal tool-started run. - -- [ ] **Step 1: Write failing lifecycle tests** - -Cover: finished tool without terminal run is ambiguous; prepare-only interruption is retryable; invalid input does not create an explicit output run directory; failed execution publishes result paths and partial artifacts. - -- [ ] **Step 2: Verify RED** - - cargo test -p persisting-replay journal::tests::finished_tool_without_terminal_run_is_ambiguous - cargo test -p persisting-replay engine::tests::validation_does_not_consume_output_run_id - -- [ ] **Step 3: Implement conservative state inspection** - -Treat v3 success/failure terminal events as terminal. If any tool_started exists without one, report ambiguity regardless of tool_finished. Hold the exclusive lock from before state/output allocation through result publication. - -- [ ] **Step 4: Reorder validation and finalization** - -Validate paths, runtime, exact version, and complete plan before output allocation. After allocation, route errors through a finalizer that writes available artifacts, a v3 failed result, and a terminal journal event. - -- [ ] **Step 5: Verify and commit** - - cargo test -p persisting-replay journal::tests - cargo test -p persisting-replay engine::tests - cargo test -p persisting-replay --test replay_contract failure_ - git commit -m "fix(replay): reject ambiguous same-sandbox retries" - ---- - -### Task 9: Typed static adapter dispatch - -**Files:** -- Modify: crates/persisting-replay/src/adapter/mod.rs -- Modify: all four Agent adapter modules -- Modify: crates/persisting-replay/src/model.rs - -**Interfaces:** -- Produces AdapterPlan variants for all four Agents and common phase records. -- Removes common-engine indexing into Agent-specific ReplayPlan.native values. - -- [ ] **Step 1: Write a failing dispatch test** - -Build all four fixture plans and use only agent, after_step, calls, and source_sha256 common accessors. It must fail before AdapterPlan exists. - -- [ ] **Step 2: Introduce typed dispatch** - -Delegate common accessors with explicit enum matches. Keep native JSON private to each plan. Replace old shared build_plan/run with engine-driven prepare/replay/continue dispatch. Leave adapter/mod.rs containing common types, declarations, and static dispatch. - -- [ ] **Step 3: Verify and commit** - - cargo test -p persisting-replay - cargo clippy -p persisting-replay --all-targets -- -D warnings - git commit -m "refactor(replay): split versioned agent adapters" - ---- - -### Task 10: Documentation, migration, and release verification - -**Files:** -- Modify: crates/persisting-pvisor/README.md -- Modify: docs/src/pvisor/guides/sandbox-replay.md -- Modify: docs/src/pvisor/guides/sandbox-replay.zh.md -- Modify: docs/src/pvisor/reference/cli.md -- Modify: replay smoke TOML fixtures - -**Interfaces:** -- Documents three modes, v1 request compatibility, v3 output, total-step budget, stale opt-in, and fatal status. - -- [ ] **Step 1: Write failing help assertions** - -Assert replay help contains prepare-only, corrected replay-only wording, allow-stale-observations, and the total-step definition for max-steps. - -- [ ] **Step 2: Verify RED** - - cargo test -p persisting-pvisor cli::tests::replay_help_describes_phase_modes - -- [ ] **Step 3: Update English/Chinese docs and fixtures** - -State that old non-Claude replay_only callers that only constructed a prefix must use prepare_only; v3 replay-only always executes the selected prefix and requires a runtime. Document v3 fields and change runtime-free smoke fixtures to prepare-only. - -- [ ] **Step 4: Run final verification** - - cargo fmt --check - cargo test -p persisting-replay - cargo test -p persisting-pvisor cli::replay - cargo test -p persisting-pvisor cli::tests::standalone_cli_is_small_and_run_can_be_explicit - cargo clippy -p persisting-replay --all-targets -- -D warnings - cargo clippy -p persisting-pvisor --lib --bin pvisor -- -D warnings - git diff --check - -Expected: every command exits zero. Workspace-wide tests remain outside acceptance because excluded subsystems are out of scope. - -- [ ] **Step 5: Inspect scope and commit** - -Confirm no excluded subsystem or .workbuddy path is staged, then commit documentation and fixtures with: - - git commit -m "docs(replay): document reliable phase and result contracts" - ---- - -## Plan Self-review - -- Every acceptance criterion maps to a task and focused regression test. -- Mode and result contracts land before adapter behavior consumes them. -- Process supervision is verified independently before replacing child paths. -- Behavioral tests precede final modularization, making Task 9 a green refactor. -- No task modifies an AGENTS.md-excluded subsystem. diff --git a/docs/superpowers/plans/2026-08-22-storyline-prompt.md b/docs/superpowers/plans/2026-08-22-storyline-prompt.md deleted file mode 100644 index 57d075c9..00000000 --- a/docs/superpowers/plans/2026-08-22-storyline-prompt.md +++ /dev/null @@ -1,47 +0,0 @@ -# Storyline `/prompt` Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:executing-plans. User asked to execute this spec in-session. - -**Goal:** Map ACTF `system_prompt` / `user_content` onto Storyline document `/prompt` plus turn `/prompt` overlays, without changing `/msg`. - -**Architecture:** Optional `{system, user}` object on the document and each turn. First non-empty pair is the document baseline; differing steps write a full-replace overlay. Lance stores the objects as `prompt_json` on `runs` and `steps`. - -**Tech Stack:** Rust, serde, Storyline `storyline/v1`, ACTF convert, three-table Lance. - -## Global Constraints - -- `schema_version` stays `storyline/v1` -- `/turns/{t}/msg` stays `assistant_content.content` -- Prompt is not `/task`, `env`, or `extra` -- OpenAI / ATIF / AgenticMD / Events do not gain first-class prompt fields -- Attempt `extra` / `meta` stay residual -- Do not change TTAS, Queue, Search, or `persisting-dlcapt` -- Do not commit unless the user asks - ---- - -### Task 1: Wire type + validation - -**Files:** `crates/persisting-pchronicle/src/formats/storyline.rs`, `crates/persisting-pchronicle/src/model.rs` - -Add `StorylinePrompt { system, user }`, document and turn optional `prompt`, validation, `effective_prompt`. - -### Task 2: ACTF import/export - -**Files:** `crates/persisting-pchronicle/src/convert/actf.rs` - -Baseline + overlay algorithm; consume residuals; export from effective prompt. - -### Task 3: Lance projection - -**Files:** `crates/persisting-pchronicle/src/store/storyline/{model,rows,content}.rs` - -`runs.prompt_json` and `steps.prompt_json`; missing columns decode as absent. - -### Task 4: Downstream literals + RFCs - -**Files:** Gateway / CLI / other `StorylineTurn` literals; RFC-0001, RFC-0004; `storyline-lance.md` - -### Task 5: Verify - -`cargo test -p persisting-pchronicle --lib` and targeted convert/store tests. diff --git a/docs/superpowers/plans/2026-08-22-storyline-task-env-response.md b/docs/superpowers/plans/2026-08-22-storyline-task-env-response.md deleted file mode 100644 index b9c71f15..00000000 --- a/docs/superpowers/plans/2026-08-22-storyline-task-env-response.md +++ /dev/null @@ -1,74 +0,0 @@ -# Storyline task / env / tool response Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Promote ACTF eval/budget/time/tool status and OpenAI env_state into first-class Storyline `task`, document/turn timestamps, turn `env`, and tool `kind`/`response`. - -**Architecture:** Extend `storyline/v1` optional wire structs in `formats/storyline.rs`, map them in ACTF and OpenAI converters with 1:1 JSON Pointer authority, and project the same objects onto the existing three Lance tables as additive nullable columns. - -**Tech Stack:** Rust 2021, serde/serde_json, Arrow/Lance Storyline store. - -**Spec:** docs/superpowers/specs/2026-08-22-storyline-task-env-response-design.md - -## Global Constraints - -- Keep `schema_version` as `storyline/v1`; all new fields optional. -- Each source JSON Pointer has exactly one authoritative Storyline target. -- Do not map `system_prompt` / `user_content` / attempt `extra` / `meta`. -- Do not copy `/metrics` env_state keys into `env`. -- Do not modify TTAS, Queue, Search, or persisting-dlcapt. -- Do not commit unless the user asks. -- New Lance columns must decode as absent on older tables (`*_if_present`). - -## File Map - -- `crates/persisting-pchronicle/src/formats/storyline.rs` — wire structs and validation -- `crates/persisting-pchronicle/src/convert/actf.rs` — ACTF import/export -- `crates/persisting-pchronicle/src/formats/openai_corpus.rs` — OpenAI env mapping -- `crates/persisting-pchronicle/src/store/storyline/{model,rows,content}.rs` — Lance projection -- `docs/src/rfcs/0001-storyline-format.md`, `0004-actf-format.md`, `0009-openai-messages-format.md` - ---- - -### Task 1: Storyline wire types - -**Files:** `crates/persisting-pchronicle/src/formats/storyline.rs` - -- [ ] Add `StorylineTask`, `StorylineEnv`, `StorylineTaskLlm`, `StorylineTaskResult`, `StorylineToolResponse` -- [ ] Add document `task` / `started_at` / `finished_at`, turn `env` / `finished_at`, tool `kind` / `response` -- [ ] Validate empty-task rejection, positive `k`, empty `kind` as missing -- [ ] Tests: JSON roundtrip of the new fields; empty objects omitted - -### Task 2: ACTF mapping - -**Files:** `crates/persisting-pchronicle/src/convert/actf.rs` - -- [ ] Import result/budget/llm/k/timestamps/tool kind+response -- [ ] Lift `task_correct`/`correct`/`status`/`score` into `final_metrics` -- [ ] Stop recording mapped keys as unknown fields -- [ ] Export restores those keys from first-class fields -- [ ] Update `actf_noncanonical_source_fields_are_unknown_without_source_extra` - -### Task 3: OpenAI env mapping - -**Files:** `crates/persisting-pchronicle/src/formats/openai_corpus.rs` and tests - -- [ ] Stable env keys → `/task/env`; step keys → response turn `/env` -- [ ] Equal later values consumed; unequal values become turn env -- [ ] Export writes env back onto rows/`meta_json.env_state` -- [ ] Update `openai_only_reports_unmapped_source_fields` - -### Task 4: Lance three-table projection - -**Files:** `store/storyline/model.rs`, `rows.rs`, `content.rs` - -- [ ] runs: `task_json`, `started_at`, `finished_at` (+ source json) -- [ ] steps: `env_json`, `finished_at` (+ source json) -- [ ] tool_calls: `kind`, `response_json` -- [ ] Roundtrip through `split_storyline` / `reconstruct_storyline` - -### Task 5: RFC docs - -**Files:** `docs/src/rfcs/0001-storyline-format.md`, `0004-actf-format.md`, `0009-openai-messages-format.md` - -- [ ] Wire tables and mapping rows matching the spec diff --git a/docs/superpowers/reviews/2026-08-23-product-phase-review.md b/docs/superpowers/reviews/2026-08-23-product-phase-review.md new file mode 100644 index 00000000..d456f480 --- /dev/null +++ b/docs/superpowers/reviews/2026-08-23-product-phase-review.md @@ -0,0 +1,114 @@ +# 产品阶段性 Review:定位、架构与功能暴露路线 + +## Status + +- 评审时间:2026-08-23 +- 覆盖范围:pChronicle Web 全部功能面(后端 17 条路由、前端 12 个模块、20 份 spec)、`pchronicle serve` 数据链路、Datasets / Runs / Run Detail / Analyze 四个界面(基于 2026-08-23 实机截图) +- 评审视角:产品定位(PM)+ 分布式系统 / 存储 + Agent 生态竞品(LangSmith、Laminar、Langfuse、Phoenix) +- 性质:项目执行阶段 review,结论供下一阶段排期参考 + +## 结论摘要 + +pChronicle 当前的形态是「单二进制本地 Agent 轨迹仓库 + 分析工作台」:files-first、SQL-first、WASM 前端内嵌、Copilot 兜底。三个核心判断: + +1. **定位差异化成立**。竞品全是 SaaS pipeline-first,数据在他们云上;pChronicle 数据永远在用户磁盘上。对企业内部数据、科研数据集是刚需,SaaS 方案在这些场景无法进入。 +2. **架构是「存储正确」的**。Dataset mount + Warehouse 虚拟根 + `_file_` 前缀是干净的数据湖抽象;Report 错误策略让坏数据降级而不阻塞;bounded query 在引擎层做了资源边界。 +3. **最大的杠杆不在建新能力,而在暴露已建成的能力**。后端约一半能力(导出、revisions、events、multi-storage、compare)已建成或已有 spec,但没有 UI 入口,产品回报为零。 + +## 现状盘点:能力分层与 UI 暴露度 + +![pChronicle 能力分层与 UI 暴露度](assets/2026-08-23-capability-layers.svg) + +约一半的后端能力已建成(或已有 spec)但没有 UI 入口,产品回报为零。逐项明细: + +| 层 | 能力 | UI 暴露状态 | +|---|---|---| +| L1 数据与存储 | dataset mounts、catalog treemap | ✅ 已暴露(Datasets 页) | +| L1 | error sources | ⚠️ 部分暴露(红色横幅,无管理操作) | +| L1 | `/revisions` 数据集快照 | ❌ 后端已有,无 UI | +| L1 | 多 `--storage` 挂载 | ❌ spec 已批准(2026-08-23),未实现 | +| L2 查询引擎 | query console(tables + SQL) | ✅ 已暴露 | +| L2 | evidence bounded 查询 | ✅ 已暴露(Analyze agent 消费) | +| L2 | `/export/har`、`/export/otlp` | ❌ 后端已有,无 UI | +| L3 派生视图 | explorer runs / tree / turns、storyline、trace + span timeline | ✅ 已暴露 | +| L3 | `/events` 原始事件流 | ❌ 后端已有,无 UI | +| L3 | trajectory compare | ❌ spec 已写(2026-08-22),未实现 | +| L4 分析与协作 | analyze agent(plan-review-run)、analysis sessions | ✅ 已暴露(Analyze 页) | +| L4 | signals 检测器(Laminar 式) | ❌ 仅有产品讨论,无 spec | +| L4 | deep link / 分享 / 报告导出 | ❌ 未建设 | + +## Review 发现 + +### 产品定位层 + +- **在「轨迹查看器」与「分析平台」之间摇摆**。Span Timeline、Trace、JSON renderer 是世界级的查看体验;但分析侧(Analyze agent、Query Console)的能力密度没有透出来。用户第一印象会是「好看的 trace viewer」,而非「能回答质量问题的分析平台」。 +- **两个 AI 入口的关系没有交代**。Copilot(对话式问答)与 Analyze(plan-review-run 可复算取证)能力重叠,用户不知道何时用哪个。建议明确分工叙事并在两个入口互相引流。 +- **Query Console 是埋没的杀手锏**。「对轨迹数据写 SQL」是对 SaaS 竞品最硬的差异,但藏在开发者角落:无 schema 引导、无示例、无保存。 + +### 架构层(分布式 / 存储) + +做对了的: + +1. Dataset mount / `_file_` 前缀抽象,天然支持多数据集与路径钻取(catalog treemap 是其直接产物);multi-storage spec 使其成为 CLI 一等公民。 +2. Report 错误策略 + error sources 显式暴露(624MB 超限文件降级为 error source 而非炸掉启动,2026-08-22 修复)——「坏数据不阻塞好数据」。 +3. Bounded query(max_rows / max_bytes)引擎层资源上限;SQL 执行错误映射为带详情的 400(2026-08-22 修复),查询路径已工程化成熟。 + +架构债: + +1. **数据新鲜度是手动的且不可见**。`POST /catalog` 刷新存在,但 UI 无「我看到的是哪个快照 / 是否有新文件」的心智模型。分布式系统用户对 staleness 极其敏感。 +2. **单用户单进程**。无并发写、无协作故事。短期可接受,产品叙事只能停留在「个人 / 小队工具」。 +3. **`/revisions` 已付工程成本但未收产品回报**。 + +### 界面交互层(基于 2026-08-23 实机截图) + +主链路 Datasets → Runs → Run Detail(Trace / Analysis)→ Analyze 通畅,「先看分布再钻取」心智正确。详细交互问题见当日会话记录(P0 loading 反馈缺失、tile 颜色无语义、URL 不反映状态等)。产品层级补充: + +- **P1**:Analyze 页首屏价值密度低——大标题 + 空的 Recent analysis 把核心能力(问题输入)推到中下区域。 +- **P1**:Run Detail 加载 >10s 仅显示 "Building trajectory evidence…",需要分阶段 skeleton。 +- **P2**:Coverage 卡 0 值行、Duration/Tokens "—" 缺解释,属视觉噪音。 + +## 行动项 + +候选功能按「用户价值 × 实现成本」排布如下: + +![候选功能价值-成本矩阵](assets/2026-08-23-feature-priority-matrix.svg) + +### 立即做(高价值低成本:给已有 API 加 UI) + +| ID | 行动项 | 优先级 | 验收标准 | +|---|---|---|---| +| A1 | Run Detail 工具栏增加导出 HAR / OTLP 按钮 | P0 | 任一 run 可一键下载 HAR 与 OTLP 文件 | +| A2 | 全局 URL 状态(page / dataset / run / catalog 路径),刷新与分享不丢状态 | P0 | 复制 URL 打开还原同一视图 | +| A3 | 顶部 catalog 快照状态条:快照时间 + 新文件待扫描提示 + 手动/自动刷新 | P1 | staleness 可见且可操作 | +| A4 | Query Console 侧栏 schema 浏览器:列级说明 + 示例值 + 一键插入 | P1 | 不读文档即可写出正确 SQL | +| A5 | error source 管理面板:失败原因(如超 max_file_bytes)+ 调整上限重试 | P1 | 624MB 文件可通过面板处理后入库 | + +### 规划做(高价值高成本:产品叙事下一级台阶) + +| ID | 行动项 | 优先级 | 说明 | +|---|---|---|---| +| B1 | trajectory compare 工作区 | P1 | spec 已完成(2026-08-22),Pin + 对齐 diff;A/B 评估前置 | +| B2 | signals 检测器(Failure / Logic / Task / Friction / Hallucination / Intent) | P1 | 参照 Laminar 模板;先落 Failure + Task,Runs 列表加信号列,CompactOverviewStrip 加 risk badges | +| B3 | revisions 时间旅行 UI | P2 | `/revisions` 已有;数据集版本切换,配 B1 可做版本差异审计 | +| B4 | 分析报告导出(Markdown / HTML) | P2 | Analyze 产出脱离 session,完成「取证→结论→交付」闭环 | + +### 顺手做 + +| ID | 行动项 | 说明 | +|---|---|---| +| C1 | 多 dataset 切换器 | 依赖 multi-storage spec 落地 | +| C2 | SQL 收藏 / 模板 | Query Console 增强 | +| C3 | `/events` 原始事件流视图 | power user 排障 | + +## 附录:竞品参照——Laminar Signal 角色分工 + +行动项 B2(signals 检测器)的产品化参照。Laminar 把 agent trace 的自动分析拆成 6 个「专职检测员」,横轴是问题阶段(意图理解 / 推理与执行 / 输出验证),纵轴是受影响对象(agent / 用户 / 任务)。关键启示:**Friction Detector 把 UX 问题从「任务是否完成」中独立出来**——agent 可能完成了任务但用户已被糟糕交互折磨,这是 trajectory 分析工具最容易忽略的维度。pChronicle 落地顺序建议:先 Failure Detector(Behavior 已有基础)与 Task Evaluator(直接回答完成度),再 Friction Detector,LLM judge 类(Hallucination / Logic / Intent)后置。 + +![Laminar 6 Signal 角色分工矩阵](assets/2026-08-23-laminar-signal-roles.svg) + +## 遗留与风险 + +- 既有失败测试 `status_reports_projection_stale_and_safe_errors`(已验证与近期改动无关),需单独排查。 +- 2026-08-22 报告的 WASM panic 疑似卡死状态伴生现象,未复现;若再出现需保留完整控制台堆栈。 +- Analyze 页与 Copilot 的双入口叙事未定,影响 B2 的入口设计,需在 signals spec 前决策。 +- 单用户架构是有意选择还是过渡状态,影响 B4(报告导出)之后的协作类功能排序,建议下阶段明确。 diff --git a/docs/superpowers/reviews/README.md b/docs/superpowers/reviews/README.md new file mode 100644 index 00000000..bca0a250 --- /dev/null +++ b/docs/superpowers/reviews/README.md @@ -0,0 +1,33 @@ +# Phase Reviews(阶段性 Review) + +本目录存放 pChronicle 项目执行阶段的阶段性 review 文档,与 `../specs/`(设计文档)平级、互补: + +- **specs/** 记录「要做什么、为什么这样设计」——写于动手之前。 +- **reviews/** 记录「现在做得怎么样、下一步往哪走」——写于阶段节点,回看已完成的工作。 + +## 命名约定 + +``` +YYYY-MM-DD--review.md +``` + +例如:`2026-08-23-product-phase-review.md`。 + +## 文档结构约定 + +每篇阶段性 review 建议包含以下章节: + +1. **Status** —— 评审时间、覆盖范围(代码区间 / 功能面)、评审人。 +2. **结论摘要** —— 一段话总评 + 最重要的 3 个判断。 +3. **现状盘点** —— 已建成能力的分层盘点,标注「UI 已暴露 / 后端已有未暴露 / 仅有 spec」。 +4. **Review 发现** —— 按层级(产品定位 / 架构 / 界面交互)组织,标注优先级(P0/P1/P2)。 +5. **行动项** —— 带 ID、优先级、验收标准的可执行列表,供下一阶段排期引用。 +6. **遗留与风险** —— 已知未解决问题、暂缓项及其理由。 + +## 已有 review + +| 日期 | 文档 | 主题 | +|---|---|---| +| 2026-08-23 | [product-phase-review](2026-08-23-product-phase-review.md) | 产品定位、架构与界面整体阶段 review,功能暴露路线 | + +配图统一放在 `assets/` 下,命名 `YYYY-MM-DD-.svg`,文档内以相对路径 `assets/...` 引用。SVG 使用 CSS 变量 + fallback(如 `var(--color-text-primary, #2c2c2a)`),独立浏览器打开和嵌入文档站点均可正常渲染。 diff --git a/docs/superpowers/reviews/assets/2026-08-23-capability-layers.svg b/docs/superpowers/reviews/assets/2026-08-23-capability-layers.svg new file mode 100644 index 00000000..b7102f0c --- /dev/null +++ b/docs/superpowers/reviews/assets/2026-08-23-capability-layers.svg @@ -0,0 +1,56 @@ +pChronicle 能力分层与 UI 暴露度四层能力图:已暴露于 UI 的能力用彩色,后端已有但 UI 未暴露的用灰色虚线。 + + +UI 已暴露 + +后端已有 / 已有 spec,UI 未暴露 + + +L1 · 数据与存储 + +dataset mountscatalog treemap + +error sources红色横幅提示 + +/revisions快照时间旅行 + +多 storage 挂载spec 已批准 + + +L2 · 查询引擎 + +query consoletables + SQL + +evidencebounded 查询 + +/export/har浏览器可回放 + +/export/otlp接入 OTel 生态 + + +L3 · 派生视图 + +runs + treeexplorer 全家桶 + +storyline/turnstrace + timeline + +/events 原始流未派生的事件浏览 + +trajectory diffspec 已写未实现 + + +L4 · 分析与协作 + +analyze agentplan-review-run + +sessionsrecent analysis + +signals 检测器Laminar 式模板 + +分享 / 协作deep link + 报告导出 + diff --git a/docs/superpowers/reviews/assets/2026-08-23-feature-priority-matrix.svg b/docs/superpowers/reviews/assets/2026-08-23-feature-priority-matrix.svg new file mode 100644 index 00000000..088c342d --- /dev/null +++ b/docs/superpowers/reviews/assets/2026-08-23-feature-priority-matrix.svg @@ -0,0 +1,31 @@ +候选功能价值-成本矩阵按用户价值与实现成本排列的候选功能象限图。 + + + + +实现成本 → +用户价值 → +高价值低成本 · 立即做 +高价值高成本 · 规划做 +顺手做 +暂缓 + +导出 HAR / OTLP 按钮 +deep link 分享 +catalog 自动刷新 +schema 浏览器 +error source 管理 + +trajectory compare +signals 检测器 +revisions 时间旅行 +报告导出(md/html) + +多 dataset 切换器 +/events 原始流视图 +SQL 收藏 / 模板 + diff --git a/docs/superpowers/reviews/assets/2026-08-23-laminar-signal-roles.svg b/docs/superpowers/reviews/assets/2026-08-23-laminar-signal-roles.svg new file mode 100644 index 00000000..1af3bbda --- /dev/null +++ b/docs/superpowers/reviews/assets/2026-08-23-laminar-signal-roles.svg @@ -0,0 +1,72 @@ +Laminar 六个 Signal 的角色分工按问题阶段(意图/推理/输出)与受影响对象(agent/用户/任务)交叉划分的六类检测员。 + + + + + + + +问题阶段 → +受影响对象 → + + +意图理解 + +推理与执行 + +输出验证 + + +agent / trace + +用户 / 体验 + +任务 / 目标 + + + + intent classifier + classify what the user + was trying to accomplish + + + + + failure detector + errors, loops, wrong tools + + + + logic analyzer + flaws in reasoning + + + + + hallucination detector + made-up facts & claims + + + + task evaluator + completed the request? + + + + + friction detector + identify user frustration and poor UX + + + + + cross-cutting: task success + task evaluator & intent classifier jointly answer + "did the agent do what the user actually wanted?" + + diff --git a/docs/superpowers/specs/2026-08-19-storyline-unknown-fields-design.md b/docs/superpowers/specs/2026-08-19-storyline-unknown-fields-design.md deleted file mode 100644 index be63763e..00000000 --- a/docs/superpowers/specs/2026-08-19-storyline-unknown-fields-design.md +++ /dev/null @@ -1,306 +0,0 @@ -# Storyline 统一 Unknown Fields 设计 - -## 背景 - -`StorylinePresence` 目前记录 ATIF 字段的 missing/null/value 三态、tool call -`extra` 的显式 `null`,以及输入文档的容器形状和顺序。这使 Storyline 可以恢复输入的 -物理表示,但这些信息不是轨迹语义;它们还通过按层级硬编码的集合形成一套影子模型,新增 -字段时必须同步扩展 presence 枚举和转换器。 - -ACTF 和 OpenAI Msg 又各自通过 `extra` 保存格式专属 residual。不同 codec 因而使用 -不同的无损机制,格式专属数据会混入 Storyline 的业务 `extra`,跨格式多跳也无法统一携带。 - -本设计删除 `StorylinePresence`,改为统一、稀疏、受限的 unknown-fields residual。 -这里的 “unknown” 指 **Storyline 没有正式字段承载的来源字段**,既包括来源格式规范中的 -已知字段,也包括厂商扩展字段;它不表示来源 codec 一定不认识该字段。 - -## 目标 - -- Storyline 只把可理解的轨迹语义建模为正式字段。 -- 所有双向外围格式使用同一套 unknown-fields 捕获、携带、恢复和校验机制。 -- 同格式往返和跨格式多跳都保留 Storyline 未建模的 key 与 JSON value。 -- 已知字段的 missing 与显式 `null` 等价,不再为此保存旁路状态。 -- residual 保持稀疏,并有明确的条目数和字节数上限;超限时 fail closed。 -- Lance 可以把 residual 中较大的重复 value 内容寻址到 `objects.lance`,但该优化不改变 - 对外模型或自包含 wire 表示。 -- 在轨迹级提供归一化 unknown-key 路径及出现次数,便于分析格式扩展的分布。 - -## 非目标 - -- 不保证空白、缩进、对象键顺序、数字原始词法或重复 JSON object key 的逐字节恢复。 -- 不把 unknown value 解释成 Storyline 语义。 -- 不保留输入是单对象、单元素数组、JSONL 还是 NDJSON;目标 codec 决定 canonical - 容器表示。 -- 不从 Storyline 反建 Canonical Event。Canonical Event 到 Storyline 仍是单向投影。 -- 本设计不进入 TTAS、Queue/Sampler、Search 或 `persisting-dlcapt`。 - -## 权威数据模型 - -`StorylinePresence`、`PresenceState`、字段枚举以及 `StorylineCollectionShape` 被删除。 -`StorylineDocument` 改为持有格式隔离的 residual: - -```rust -pub struct StorylineDocument { - // existing canonical fields... - - #[serde(default, skip_serializing_if = "StorylineUnknownFields::is_empty")] - pub unknown_fields: StorylineUnknownFields, - - #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] - pub unknown_key_counts: BTreeMap>, -} - -pub struct StorylineUnknownFields { - /// Canonical DocumentFormat name -> residual belonging to that source. - pub sources: BTreeMap, -} - -pub struct SourceUnknownFields { - /// Groups slices that came from the same physical source document. - pub source_document_id: String, - - /// RFC 6901 JSON Pointer -> complete original JSON value. - pub fields: BTreeMap, -} -``` - -通常一条轨迹对每种来源格式只有一个 `SourceUnknownFields`。同一物理来源文档拆成多条 -Storyline 时,例如 ACTF 的多个 attempt,文档级 unknown fields 逻辑复制到每条轨迹, -每条轨迹再保存自身子树的 residual。`source_document_id` 用来在导出时重新组合这些切片。 -如果来源格式有稳定文档标识,codec 使用该标识;否则使用 canonical JSON 的 BLAKE3 -摘要。计算摘要前先移除 `_storyline` envelope,并递归按 object key 排序。该 ID 只用于 -residual 分组,不成为 Storyline 的轨迹身份。 - -`unknown_key_counts` 是物化的派生数据,不是恢复来源文档的事实源。它在导入、hydrate -和显式 Storyline 校验时从 `unknown_fields` 重新计算,不能单独修改。第一层 key 是 -canonical `DocumentFormat` 名称,第二层 key 是把数组下标替换成 `*` 后的归一化 -JSON Pointer。例如: - -```json -{ - "atif": { - "/steps/*/vendor_data": 12, - "/agent/experimental_config": 1 - } -} -``` - -计数是轨迹局部统计。因为文档级 residual 会复制到同一来源文档产生的每条轨迹,聚合多条 -轨迹的计数时也会看到相应重复;它不声称是物理源文件级的唯一出现次数。 - -## 捕获规则 - -每个外围 codec 声明其消耗到 Storyline canonical 字段的来源路径。导入时,codec 在原始 -逻辑 JSON/YAML 树上执行 schema-aware 遍历: - -1. 移除并解析保留的 Storyline envelope。 -2. 把能映射的值写入 Storyline 正式字段。 -3. 对每个没有正式 Storyline 承载位置的 object member,保存该 member 的精确 JSON - Pointer 和完整 value。 -4. 一旦一个 member 被判定为 unknown,就保存整个 value 子树,不再拆分叶子。 -5. 对部分被消费的结构继续向下遍历,捕获其中未消费的直接 member。 -6. 对 Storyline 以 `Value` 作为完整语义承载的开放字段,不检查其内部 key。 - -已知字段的显式 `null` 和 missing 都不进入 residual。unknown key 即使 value 是 `null` -也必须进入 residual,因为此时 key 的存在本身属于需要保留的数据。空数组、空对象、`false` -和数值零同样不得省略。 - -精确路径使用 RFC 6901 JSON Pointer,不实现完整 JSONPath。codec 在捕获路径时知道每个 -segment 的父容器类型,因此可以可靠地把数组下标归一化为统计路径中的 `*`;数字形式的 -object key 不会被误判成数组下标。 - -## 统一 Wire Envelope - -JSON 外围格式使用保留的 `_storyline` object。一个物理文档只含一条轨迹时,仍使用统一 -的 `by_trajectory` 形状,避免单条和多条出现两套语义。`by_trajectory` 的 key 是目标 -codec 生成的 carrier JSON Pointer:它指向目标物理文档中承载该轨迹的对象,而不是假设 -跨格式转换前后的 Storyline identity 必然相同。空 pointer 表示物理文档根对象。 - -```json -{ - "_storyline": { - "unknown_fields": { - "version": 1, - "by_trajectory": { - "/attempts/1": { - "sources": { - "atif": { - "source_document_id": "source-id", - "fields": { - "/steps/0/vendor_data": {"x": 1} - } - } - } - } - } - } - } -} -``` - -一个物理目标文档承载多条 Storyline 时,目标 codec 必须为每条轨迹生成唯一 carrier, -例如 ACTF attempt 的 `/attempts/1`。重新导入时,codec 通过 carrier 把 residual 分发给 -对应轨迹,不要求跨格式转换前后的 Storyline identity 相同。重复、失效或指向非 object -的 carrier 都是输入错误。 - -AgenticMD 使用相同逻辑 envelope,但放入现有 Storyline frontmatter metadata,而不是在 -Markdown 正文中发明新的 block。Storyline Lance 直接持久化权威字段,不使用 wire -envelope。Canonical Event 不携带 envelope。 - -`_storyline` 是保留扩展字段:解析时它不会再次被捕获成 unknown。`unknown_fields.version` -当前只接受整数 `1`;非 object、缺少或未知版本、 -无法关联的 `by_trajectory` 或格式不合法的 envelope 都 fail closed。 - -## 跨格式展开与携带 - -目标格式采用“展开自身,携带其他来源”的规则: - -- 导出格式 `F` 时,把 `sources[F]` 恢复到格式 `F` 的原始位置,并从 envelope 中移除 - 该来源,避免原生字段和 envelope 重复。 -- 其他来源 residual 原样放进目标格式的 `_storyline` envelope。 -- 重新导入目标格式时,原生 unknown fields 被重新捕获,携带的其他来源被解包回对应 - Storyline。 - -例如 ATIF residual 经过 ACTF 时作为 ACTF envelope 中的不透明数据携带;再次导出 ATIF -时恢复到 ATIF 路径。ACTF 消费者无需理解 ATIF 字段,但数据不会丢失。 - -同一来源文档拆出的多条 Storyline 在导出时按 `source_document_id` 合并。各轨迹复制的 -文档级路径必须值相同;相同值去重,不同值报冲突。各轨迹子树路径取并集。 - -## 恢复和冲突规则 - -codec 先从 Storyline canonical 字段生成目标文档,再恢复 residual: - -- residual 只能写入目标 codec 当前仍不映射到 Storyline 正式字段的路径。 -- Storyline canonical 字段永远是权威值,residual 不得覆盖它。 -- residual 路径在新版 codec 中变成正式字段时,如果 canonical 值与 residual 值不同, - 导出失败;不静默覆盖或丢弃。 -- 输入同时在原生位置和 `_storyline` 中携带同来源、同路径数据时,值相同则去重,值不同 - 则输入无效。 -- 不同来源格式位于不同 namespace,相同 JSON Pointer 不构成冲突。 -- 恢复前验证 JSON Pointer,并验证所有父容器存在且类型正确。 -- Storyline 修改导致数组元素删除、重新排序或父路径失效时,恢复失败,不根据邻近元素 - 猜测新位置。 - -所有冲突错误都包含来源格式、`source_document_id`、轨迹 identity 和 JSON Pointer。 - -## 容器和顺序 - -物理容器由目标 codec 的 canonical policy 决定:要求数组的格式始终输出数组,要求单 -document 的格式输出对象,JSONL/NDJSON 由文件编码层逐行输出。单对象和单元素数组被视为 -等价,不参与无损比较。 - -Storyline 自身的稳定轨迹顺序继续由 `Vec` 和 Lance -`storage_ordinal` 保证。原输入的 `collection_shape` 和 `collection_ordinal` 不再进入 -Storyline 模型。 - -## 大小限制 - -限制按每条 Storyline、所有来源 residual 合计,在任何 offload 之前计算: - -- 最多 4096 个 unknown field; -- 所有 exact pointer、`source_document_id` 和 value 的紧凑 JSON 表示合计最多 1 MiB; -- 两个限制都必须是有限正数,可以通过 codec/import options 调高或调低; -- 任一限制超出时拒绝整条输入,不截断、不丢弃、不只保留计数; -- `_storyline` envelope 的结构开销和派生的 `unknown_key_counts` 不计入 value 字节数; -- count 使用饱和加法,避免恶意输入触发整数溢出。 - -超限错误报告实际条目数、实际字节数、配置上限以及按序列化大小排序的最大若干路径。 -字节数的确定算法是:每个来源的 `source_document_id` UTF-8 长度计算一次,再加每个 -pointer 的 UTF-8 长度和 `serde_json::to_vec(value)` 的长度;map/envelope 标点不计入。 - -## Lance 存储与 `objects.lance` - -`runs` 投影新增 `unknown_fields_json` 和 `unknown_key_counts_json`。旧的 -`presence_json` 不再写入;读取旧数据集时忽略其中的 missing/null 和容器形状信息,并在 -新字段缺失时返回空 residual。向旧 schema 追加前必须先增加新的 nullable columns;旧 -数据无法追溯生成 unknown fields。 - -ACTF/OpenAI Msg 现有的 `persisting.dev/...` 格式 residual 不再写入 Storyline 业务 -`extra`。升级旧数据集时,迁移器用旧 codec 重建对应外围文档,再由新 codec 捕获为精确 -pointer map;重建或分组不唯一时迁移失败,不静默把旧 residual 当成普通业务 `extra`。 -纯业务 `extra` 和无法识别为既有格式扩展的内容保持不变。 - -多 attempt 等场景仍在每条轨迹逻辑复制文档级 residual,以保持读取和删除单条轨迹时的 -模型自足。为了控制物理重复,Lance content externalizer 在 residual 的 value 边界工作: - -- 小 value 内联在 `unknown_fields_json`; -- 达到现有 content offload threshold 的 value 按紧凑 JSON bytes 计算 BLAKE3 content - ID,写入 `objects.lance`,run row 保存内部 descriptor; -- 多条轨迹复制的相同 value 因 content ID 相同只存一份 object; -- public reader 在返回 `StorylineDocument` 前完整 hydrate descriptor; -- wire envelope 永远写回完整 JSON value,不暴露内部引用; -- admission limit 按 hydrate 后的逻辑数据计算,不能靠 offload 绕过; -- 缺失 object、长度不符或 hash/codec 冲突都 fail closed。 - -如果用户提供的 string value 与内部 descriptor magic 前缀冲突,externalizer 必须像现有 -content cell 逻辑一样强制 offload/escape,保证用户值不会被误当成引用。 - -## 无损定义 - -同格式或跨格式回程后的来源文档满足以下条件时,称为 JSON 数据模型级语义无损: - -- Storyline 已知字段经过 codec canonicalization 后相等; -- 已知字段的 missing 与显式 `null` 等价; -- unknown key 仍存在,且对应 `serde_json::Value` 相等; -- unknown value 为 `null` 时也必须保留该 key; -- object key 顺序不参与比较; -- array 顺序和元素参与比较; -- empty、false 和 zero 不被当成 missing; -- 目标格式的 canonical 容器形状不与输入物理形状比较; -- `_storyline` 只作为传输 envelope,不算来源格式业务字段。 - -数字比较使用 `serde_json::Value` 的数据模型相等,不保存原始数字词法。重复 JSON object -key 在解析阶段已不属于可表达的数据模型,因此不在保证范围内。 - -## Codec 边界 - -格式专属逻辑保持在对应 codec 中: - -```rust -trait UnknownFieldCodec { - fn capture_unknown_fields( - &self, - input: &serde_json::Value, - stories: &mut [StorylineDocument], - ) -> InputResult<()>; - - fn restore_unknown_fields( - &self, - stories: &[StorylineDocument], - output: &mut serde_json::Value, - ) -> Result<()>; -} -``` - -通用 residual 层负责 JSON Pointer、envelope、namespace、限额、计数、合并和通用冲突 -检查;codec 负责 consumed-path schema、动态 map/array 的合法结构、轨迹 identity 对应和 -格式 canonical container。这样新增格式时复用安全策略,但不把格式 schema 硬编码回 -Storyline 核心。 - -## 验证与测试 - -至少覆盖以下测试组: - -1. ATIF、ACTF、OpenAI Msg 和 AgenticMD 的同格式往返。 -2. 任意 JSON 外围格式 `A -> Storyline -> B -> Storyline -> A` 的跨格式往返。 -3. 上述路径经过 Storyline Lance 三表和 `objects.lance` 后的往返。 -4. root、嵌套 object、动态 map、数组元素、空值和 `null` unknown value。 -5. 已知字段 missing/null 等价,而 unknown null key 必须保留。 -6. 多 ACTF attempt 和多 OpenAI record 的共享 residual 复制、合并、去重与冲突。 -7. 同来源原生值/envelope 值相同去重、不同值报错。 -8. malformed pointer、父容器类型错误、数组改序和路径失效。 -9. 4096/1 MiB 边界恰好通过,任一超过一单位时整条拒绝。 -10. 大 residual value offload、跨轨迹 content-ID 去重、hydrate 和 object 缺失失败。 -11. legacy `presence_json` 读取为空 residual,以及新 nullable columns 的 schema 升级。 -12. unknown-key wildcard 统计、数字 object key、饱和计数和跨来源 namespace 隔离。 - -验收不包含工作区默认排除的 TTAS、Queue/Sampler、Search 和 `persisting-dlcapt` 测试。 - -## 文档变化 - -pChronicle 文档中的无损边界改为:known missing/null 被 canonicalize;所有 Storyline 未 -建模的来源字段通过统一 unknown-fields residual 保留;跨格式多跳通过 namespaced -`_storyline` envelope 携带。删除“ATIF 三态”和“输入单对象/数组形态属于 Storyline -集合语义”的现有描述,并说明 4096/1 MiB fail-closed 限制与 `objects.lance` 仅是内部 -物理优化。 diff --git a/docs/superpowers/specs/2026-08-20-openai-messages-field-mapping-design.md b/docs/superpowers/specs/2026-08-20-openai-messages-field-mapping-design.md deleted file mode 100644 index a547274f..00000000 --- a/docs/superpowers/specs/2026-08-20-openai-messages-field-mapping-design.md +++ /dev/null @@ -1,234 +0,0 @@ -# pChronicle OpenAI Messages 字段映射设计 - -## 状态 - -本设计于 2026-08-20 在对话中确认。它定义 OpenAI Messages corpus 与 -Storyline/ATIF 之间的字段映射,以及无法映射字段的 unknown-field 行为。 - -## 背景 - -当前 OpenAI Messages adapter 把两类数据都放进 `unknown_fields`: - -1. Storyline/ATIF 确实没有正式字段承载的数据; -2. adapter 已经理解、甚至已经用于构造 Storyline,但为了恢复原始 JSON 仍保留的字段。 - -因此 `pchronicle import` 会把 `step_id`、`messages[].role`、 -`messages[].content`、`tool_calls` 和若干状态字段报告为 unknown。用户无法从 warning -区分真正未映射的数据,也无法通过 SQL 查询其中一些本来可以规范化的字段。 - -本设计把规则收敛为一个简单原则:**有明确映射的字段写入 Storyline/ATIF;已知可选空值 -视为未提供;其余字段写入 `unknown_fields`。** - -## 目标 - -- 为 OpenAI Messages corpus 中可表达的字段建立明确映射。 -- 让 `step_id`、消息、tool calls、状态和性能数据进入 Storyline/ATIF 正式字段。 -- 让 warning 只报告没有映射规则的字段。 -- 保持 OpenAI Messages 与 Storyline 之间的逻辑双向转换。 -- 保留现有嵌入文本 tool-call 解析能力。 -- 使用当前统一 `unknown_fields` 模型,不增加新的旁路状态或恢复协议。 - -## 非目标 - -- 不逐字节恢复原始 JSON。 -- 不保留原始 row 顺序、滑动窗口边界、object key 顺序或 `null` 与 missing 的区别。 -- 不为原始物理布局增加 consumed-path registry、row carrier 或路径重定位协议。 -- 不把任意来源 metadata 塞进 `extra` 或伪装成 metric。 -- 不从正文中的 `` 标签推断 `reasoning_content`;只有显式来源字段才映射。 -- 不修改 TTAS、Queue/Sampler、Search 或 `persisting-dlcapt`。 - -## 核心算法 - -adapter 取得一个可变来源 object,并按字段规则逐项 `remove`/`take`: - -```text -读取字段 - ├─ 有明确映射规则 -> 写入 Storyline/ATIF - ├─ 已知可选字段且为空 -> 忽略 - └─ 没有映射规则 -> 写入 unknown_fields -``` - -部分可映射的嵌套对象使用相同过程。例如,处理一个 message 时取走 `role`、`content`、 -可映射的 `tool_calls` 和可关联的 `tool_call_id`,剩余 member 进入 unknown。处理 -`meta_json.env_state` 时只取走白名单字段,剩余 member 进入 unknown。 - -不需要另一套字段判定表来猜测哪些路径“看起来 canonical”。映射函数本身就是唯一事实源: -成功取走的字段已映射,处理结束后剩余的字段未映射。 - -## 轨迹与 turn 构造 - -rows 按 `session_id` 分组,并在 session 内按正整数 `step_id` 排序。每个 session 生成一条 -Storyline: - -1. 只从第一行导入当前交互之前的 leading messages,作为 context turns; -2. 每一行只新增当前 user 和当前 agent 两个 turns; -3. 后续行重复携带的历史 messages 是已知快照表示,不重复生成 turns,也不作为 unknown; -4. 有有效 `response` 时使用 `response` 作为当前 agent 输出,否则使用 `messages` 中最后一个 - 有效 assistant message。 - -设第一行 context turn 数为 `k`,来源 `step_id` 为 `n`: - -- context turn IDs 为 `1..k`; -- 当前 user turn ID 为 `k + 2n - 1`; -- 当前 agent turn ID 为 `k + 2n`。 - -导出时使用同一公式反向计算 `step_id`。输入契约保证 session 内 step ID 可用于该计算; -adapter 不额外检查连续性、时间单调性或指标数值合理性。 - -## 字段映射 - -### 轨迹与 agent - -| OpenAI 字段 | Storyline/ATIF 字段 | 规则 | -| --- | --- | --- | -| `session_id` | `session_id` | 一个 session 对应一条轨迹 | -| `job_id` / `run_id` / `run_bucket` | `run_id` | 按现有优先级选取 | -| `agent_id`,否则 `meta_json.source` | `agent.id` | 显式 `agent_id` 优先 | -| `agent_model` / `llm_model` | `agent.model_name`、agent turn `model_name` | session 级模型信息 | -| `step_id` | turn `id` | 使用 context 偏移公式 | -| `created_at` | 当前 user、agent turn `timestamp` | 导出时以 agent timestamp 生成 row 字段 | - -以下字段作为等价别名处理: - -- `env_id == session_id`; -- `meta_json.env_state.session_id == session_id`; -- `meta_json.env_state.requested_model == agent_model`; -- `meta_json.env_state.llm_step_index == step_id`。 - -值相等时字段已被理解并消费;值不相等时没有正式承载位置,因此进入 unknown。 - -### Messages 与 tool calls - -| OpenAI 字段 | Storyline/ATIF 字段 | 规则 | -| --- | --- | --- | -| `role=system` | `source=system` | 作为 context turn | -| `role=user` | `source=user` | 保留完整 `content` JSON value | -| `role=assistant` | `source=agent` | 保留完整 `content` JSON value | -| `role=tool` | `observation` 或 tool-call `result` | 使用 `tool_call_id` 关联已有调用 | -| `tool_calls[].id` | `tool_call_id` | 非空字符串 | -| `tool_calls[].type=function` | tool-call 类型 | 已知 discriminator,消费但无需独立字段 | -| `tool_calls[].function.name` | `function_name` | 非空字符串 | -| `tool_calls[].function.arguments` | `arguments` | JSON 字符串可解析时转为 JSON value,否则保留字符串 | -| 显式 `reasoning_content` | `reasoning_content` | 不解析正文标签 | -| `refusal` | agent `message` | 仅在 `content` 为空时作为输出正文;导出规范化为 `content` | - -第一行 leading context turns 标记 `is_copied_context=true`。标准结构化 tool calls 优先; -没有结构化调用时,继续使用当前嵌入文本 tool-call parser。无法关联的非空 -`tool_call_id`、非空 `name` 等没有正式承载位置的字段进入 unknown。 - -### Metrics - -行级字段映射到当前 agent turn 的 `metrics`: - -- `reward`、`step_reward`; -- `is_terminal`、`is_truncated`、`is_session_completed`、`is_trainable`。 - -`meta_json.env_state` 只消费以下白名单字段: - -- token/容量:`prompt_tokens`、`completion_tokens`、`total_tokens`、`request_bytes`、 - `response_bytes`、`output_bytes`、`output_chunk_count`; -- 性能:`upstream_latency_ms`、`gateway_overhead_ms`、`total_latency_ms`、`ttft_ms`、 - `retry_count`; -- 状态:`status_code`、`finish_reason`、`truncate_reason`、`error_type`、`error_text`、 - `client_cancelled`、`upstream_cancelled`、`synthetic_stop`、`is_truncated`、 - `is_session_completed`; -- 运行参数:`max_steps`、`is_stream`、`payload_sampled`、`created_at`、`completed_at`。 - -`total_latency_ms` 和 `ttft_ms` 同时投影到 Storyline 的专用 timing 字段。metrics 保留原始 -JSON number,专用整数字段按现有数值转换规则计算。行级字段优先;env-state 中的同名字段 -仅在行级值缺失时补充。同名值相等时 env-state 值作为冗余表示消费,值不等时 env-state -值进入 unknown。最后一个 agent turn 的 metrics 同时成为轨迹 `final_metrics`。 - -### 无正式映射的字段 - -下列非空字段没有合适的 Storyline/ATIF 正式字段,因此进入 unknown: - -- `dataset_type`、`dt`、`env_name`、row `id`; -- `meta_json.group_id`、`request_id`、`event_type`; -- `endpoint`、`upstream_base_url`、`redaction_policy`、`weight_version`; -- 非空 `blob_manifest`; -- `chosen_response`、`rejected_response`、`ground_truth_answer`、`reference_answer`; -- 其他没有列入映射表或 metric 白名单的来源字段。 - -来源 schema 中已知可选字段的 `null`、空数组和空对象视为未提供,不进入 unknown,也不 -产生 warning;这包括 `name`、`refusal`、`tool_call_id`、`tool_calls`、`blob_manifest` 和 -几个 answer/preference 字段。`content` 仍作为完整 message value 映射,即使它为空。未知 -厂商 key 不享受空值规则:adapter 不知道其空值是否具有语义,因此仍写入 unknown。 - -## 导出语义 - -OpenAI 导出由已映射的 Storyline 字段生成规范化 rows: - -- `messages` 包含首行 context、此前规范交互以及当前 user; -- 当前 agent 固定写入 `response`; -- `step_id` 由 turn ID 反向计算; -- agent、timestamp、metrics 和 tool calls 使用上述映射的逆过程生成; -- 未映射数据继续由现有 `unknown_fields` 机制携带。 - -逻辑双向的判定是 OpenAI -> Storyline -> OpenAI -> Storyline 后,正式 Storyline 字段保持 -一致;不要求中间 OpenAI JSON 与原输入物理相同。 - -## 错误行为 - -- 缺少非空 `session_id`、正整数 `step_id`、当前 user 或当前 agent output:输入无效。 -- session 内重复 `step_id` 或冲突的 session 级 `run_id`:输入无效。 -- 可选结构无法解析或形状不合法,例如畸形 `tool_calls` 或 `meta_json`:该完整来源值进入 - unknown 并产生 warning,不阻断其他可映射字段。 -- 不增加额外的数据连续性、时间或数值正确性验证。 - -## 实现范围 - -主要修改 `crates/persisting-pchronicle/src/formats/openai_corpus.rs`: - -- 用逐字段 take/remove 的映射流程替代“canonical key + recovery residual”混合判定; -- 增加 context turn 构造和 step-ID 偏移; -- 扩充状态与 env-state metric 映射; -- 更新反向编码; -- 更新该模块内的字段级单元测试。 - -CLI 侧只更新与 unknown warning 相关的测试。除非实现过程中发现现有公共 helper 缺少必要 -能力,否则不改变 Storyline/ATIF wire schema、Lance schema 或通用 unknown-fields 模型。 - -## 测试策略 - -实现遵循 RED -> GREEN,使用小型代表性 fixture 覆盖: - -1. `step_id` 映射为 context 偏移后的 turn IDs,并可反向计算; -2. `role`、`content`、`response`、`tool_calls`、tool results 映射到正确字段; -3. reward、状态和性能字段进入 metrics 与专用 timing 字段; -4. 已知可选空值不进入 unknown、不产生 warning; -5. 任意没有映射规则的字段完整进入 unknown; -6. 已映射字段不再出现在 unknown warning 中; -7. OpenAI -> Storyline -> OpenAI -> Storyline 的正式字段保持一致; -8. 更新原来断言 `step_id`、`role/content` 属于 unknown 的旧测试。 - -再使用 `data/cybergym_0729001.json` 做手动验收,预期: - -- 8 条 trajectories; -- 964 个 turns,其中包含每个 session 的首行 context; -- 461 个 tool calls; -- warning 不含 `step_id`、`messages/*/role`、`messages/*/content`、 - `messages/*/tool_calls`、`response/*` 等已映射字段; -- `is_terminal`、`is_session_completed` 等字段可通过 SQL 查询; -- 真正没有映射规则的非空字段仍产生 warning。 - -定向验证范围为 pChronicle library 与 CLI: - -```text -cargo test -p persisting-pchronicle openai -cargo test -p persisting-pchronicle-cli openai -cargo fmt -p persisting-pchronicle -p persisting-pchronicle-cli -- --check -cargo clippy -p persisting-pchronicle -p persisting-pchronicle-cli --all-targets -- -D warnings -``` - -其他子系统的现有失败不扩大本任务的验收范围。 - -## 验收标准 - -- 每个已声明映射的非空字段进入对应 Storyline/ATIF 正式字段; -- 已知可选空值不进入 unknown; -- 每个没有映射规则的字段进入 `unknown_fields`; -- warning 不再把已映射字段报告为 unknown; -- 当前 corpus 的轨迹、turn、tool-call 计数符合预期; -- 状态与性能字段可以通过 pChronicle SQL 查询; -- 逻辑双向测试、定向测试、格式检查和 Clippy 通过。 diff --git a/docs/superpowers/specs/2026-08-20-pchronicle-storyline-squash-import-design.md b/docs/superpowers/specs/2026-08-20-pchronicle-storyline-squash-import-design.md deleted file mode 100644 index 036a1bc9..00000000 --- a/docs/superpowers/specs/2026-08-20-pchronicle-storyline-squash-import-design.md +++ /dev/null @@ -1,261 +0,0 @@ -# pChronicle Storyline Squash Import Design - -## Status - -Approved in conversation on 2026-08-20. This document defines the intended behavior before implementation. - -## Context - -`pchronicle import --output-format storyline` currently opens one `StorylineLanceStore` below each -input Source path. Importing a directory therefore reproduces the input hierarchy and places a complete -Lance Store at every leaf. A directory containing many ACTF files can produce dozens of nested -`CURRENT`, `generations`, and `objects.lance` trees even though the user requested one output Dataset. - -Storyline output is a normalized Dataset, not a preservation format. Its import behavior should match a -Git-style squash: combine the decoded histories into one published snapshot and remove the physical -boundaries between the original Sources. - -## Goals - -- Make every `--output-format storyline` import produce exactly one Storyline Lance Store at the output - root. -- Apply the same root-Store layout to directory, regular-file, and stdin inputs. -- Decode Sources incrementally and feed one bounded Storyline Store write instead of collecting the - complete directory in memory. -- Preserve Storyline identities without prefixing, rewriting, or silently deduplicating them. -- Fail the whole import atomically when any Source is invalid or identities collide globally. -- Keep the import JSON response schema and `--output-format preserve` behavior compatible. - -## Non-goals - -- Do not add a `squash` subcommand or an opt-in squash flag. -- Do not add a compatibility flag for the old one-Store-per-Source Storyline layout. -- Do not add input-file provenance to the Storyline wire model, Lance schemas, or query tables. -- Do not migrate or rewrite existing imported Datasets. -- Do not make every source-format decoder record-streaming; a decoder may still materialize one Source. -- Do not add a total-directory byte or trajectory limit. -- Do not change TTAS, Queue, Search, or standalone `persisting-dlcapt`. - -## User-facing contract - -The existing command remains the complete interface: - -```sh -pchronicle import \ - --format actf \ - --from INPUT \ - --output OUTPUT \ - --output-format storyline -``` - -For a directory, regular file, or stdin, `OUTPUT` is one Store: - -```text -OUTPUT/ -├── CURRENT -├── generations/ -│ └── gen-.../ -│ ├── runs.lance/ -│ ├── steps.lance/ -│ └── tool_calls.lance/ -└── objects.lance/ -``` - -No input-relative directories are created inside `OUTPUT`. Catalog discovery sees one physical Source -whose path is `"."`; consequently `_file_` is `"."` for every row in `dataset.runs`, `dataset.steps`, -and `dataset.tool_calls`. Commands that specify a Source must use `.` or omit the Source filter. - -`--output-format preserve` remains the source-preserving mode. It retains the current relative file -layout and permits identities that are unique only within their physical Source. - -## Architecture - -### Two explicit output paths - -`run_import` dispatches by output format after validating input and output arguments: - -1. The preserve path continues to stage each input Source at its existing relative output path. -2. The Storyline path creates one `StorylineLanceStore` at the staging root and invokes one - `replace_storyline_stream` operation for all input Sources. - -The Storyline path must not call `StorylineLanceStore::open` once per input Source. A successful run has -one Store writer, one logical snapshot publication inside staging, and one Dataset publication from -staging to `OUTPUT`. - -### Import Source adapter - -Introduce a CLI-internal Source descriptor containing the physical input path when one exists, its -Dataset-relative diagnostic path, and the information needed for format detection. Directory scanning -continues to produce descriptors in the current stable order. Regular-file and stdin imports are adapted -to a one-element Source sequence so all Storyline inputs use the same pipeline. - -A lazy iterator over those descriptors performs the following work for one Source at a time: - -1. open and bounded-read the Source; -2. resolve the requested or detected exchange format; -3. decode and validate its Storyline documents; -4. collect existing unknown-field observations; -5. update checked aggregate counts; -6. validate global identities with diagnostic provenance; -7. yield documents to `replace_storyline_stream`; -8. release that Source's bytes and decoded document collection before opening the next Source. - -The adapter yields `Result` so an error discovered after earlier chunks have been -written aborts the Store operation. The Store's staged `CURRENT` is never published on that failure, and -the outer temporary Dataset is removed. - -### Resource bounds - -This design is Source-streaming, not necessarily JSON-record-streaming. Peak CLI memory consists -primarily of: - -- one Source's input bytes and decoded Storyline documents; -- the Store's bounded normalization/write chunk; -- aggregate import metadata and unknown-field warnings; -- a global identity index containing IDs and compact references to diagnostic Source paths. - -The identity index grows with the number of trajectories, but the full decoded contents of prior Sources -do not remain resident. An explicitly supplied `--max-input-bytes` applies independently to every file or -to stdin, as it does today. No aggregate directory limit is introduced. - -## Identity and Source semantics - -The squash preserves the decoded `run_id`, `document_id`, and `session_id` values exactly. It does not -prefix them with a path or generate replacement IDs. - -The merged Store requires global uniqueness for both `document_id` and `session_id`. The Source adapter -tracks the first diagnostic Source path for each value before yielding a document. A repeated value, -whether it occurs in another Source or in the same Source, returns an invalid-input error containing: - -- the identity field name; -- the conflicting value; -- the first Source's relative diagnostic path; -- the second Source's relative diagnostic path. - -`run_id` is preserved but is not a new squash collision key. Existing Storyline validation continues to -govern any other identity invariants. - -The diagnostic path exists only during import. It is used in decoder, validator, identity-collision, and -unknown-field messages, but is not written into Storyline origin metadata or Lance tables. After a -successful squash, the original Source boundary cannot be recovered through `_file_`. - -## Atomicity and failure behavior - -The current create-only publication model remains authoritative: - -1. Validate that `OUTPUT` names a new local Dataset path. -2. Create a temporary staging directory beside `OUTPUT`. -3. Build and verify the single Storyline Store at the staging root. -4. Sync the staging directory. -5. Publish with the existing no-replace atomic rename. -6. Sync the output parent and disarm cleanup. - -Any read, detection, decoding, validation, collision, Store, indexing, sync, or publication failure aborts -the command. Before the final rename, cleanup removes staging and `OUTPUT` does not exist. Existing output -paths are never overwritten. Intermediate Lance versions written inside staging before a late error are -unreachable and disappear with staging cleanup. - -## Import response - -The serialized `ImportResponse` schema does not change: - -- `dataset_uri` is the published root Store path; -- `sources` is the number of logical input Sources successfully consumed, not the number of physical - Sources in the result; -- `trajectories` is the total number of merged Storyline documents; -- `input_bytes` is the checked sum of input Source byte counts; -- `output_format` remains `storyline-lance`; -- for regular-file and stdin imports, optional `source_path` and `format` retain their current input - metadata meaning; -- directory imports continue to omit the single-Source-only `source_path` and `format` fields. - -No `squashed` response field is added. The requested output format fully determines the behavior. - -## Compatibility - -This is an intentional layout change for newly created Storyline outputs. Code that appends an input -relative path to `OUTPUT` to find a nested Store must instead open `OUTPUT` itself. SQL that assumed the -original path in `_file_` must use `.` or remove the Source predicate. - -Existing nested Storyline Datasets remain readable because catalog discovery is unchanged. They are not -automatically migrated. Users that need multiple physical Storyline Stores can run separate imports with -separate output paths. Users that need original file boundaries in one Dataset should select -`--output-format preserve`. - -## Alternatives considered - -### Import-integrated squash — selected - -Decode each Source and feed one root Store operation. This performs the least I/O, exposes one atomic -result, and makes the command's output match its Dataset-level destination. - -### Per-Source staging followed by a merge pass - -This would reuse the old writer path, then reopen and combine every temporary Store. It doubles much of -the storage I/O, consumes extra temporary space, and adds another failure phase without preserving any -user-visible value. - -### Separate `squash` subcommand - -This is composable but requires users to first create the complex layout they want to eliminate and then -run a second command. It also creates avoidable policy questions about deleting or retaining the input -Dataset. - -## Test strategy - -Implementation follows RED then GREEN. Update tests that currently require nested Storyline Stores and -add focused coverage for the new contract: - -1. A directory containing multiple supported Sources creates `OUTPUT/CURRENT` and no nested `CURRENT`; - catalog discovery returns one Source named `.`, and SQL observes every imported trajectory with - `_file_ = '.'`. -2. Regular-file and stdin Storyline outputs both create the same root-Store layout. -3. Mixed supported input formats continue to decode in stable scan order and produce correct aggregate - counts and unknown-field warnings. -4. Cross-Source duplicate `document_id` and duplicate `session_id` cases each fail with both relative - paths and leave no output. -5. Same-Source duplicate identity cases use the same diagnostic contract. -6. A malformed later Source, after enough earlier documents to flush a Store chunk, still leaves no - published Dataset. -7. Import JSON remains schema-compatible and reports logical Source, trajectory, and byte totals. -8. Preserve-mode tests continue to assert original relative paths and Source-local identity behavior. -9. Existing old-layout discovery fixtures remain readable, demonstrating that no reader migration is - required. - -Focused verification commands: - -```text -cargo test -p persisting-pchronicle-cli -cargo fmt -p persisting-pchronicle-cli -- --check -cargo clippy -p persisting-pchronicle-cli --all-targets -- -D warnings -``` - -Any broader check that encounters unrelated dirty-worktree failures is reported separately and does not -expand this task into excluded subsystems. - -## Documentation changes - -Update the following user-facing surfaces that currently describe or imply one Store per Source: - -- pChronicle CLI help for `--output-format storyline`; -- `crates/persisting-pchronicle-cli/README.md`; -- `docs/src/pchronicle/reference/cli.md`; -- exchange guides or examples that inspect the old nested layout. - -Documentation must show the root-Store tree, define logical input `sources` versus the single physical -Source, state `_file_ = "."`, describe global identity collision failure, and point users to `preserve` -when Source boundaries matter. - -## Acceptance criteria - -The change is complete when all of the following are true: - -- every successful Storyline import shape publishes one queryable Store at `OUTPUT`; -- directory imports no longer reproduce input paths as nested Lance Stores; -- merged rows are queryable through Source `.`; -- duplicate document or session identity failures name both input paths and publish nothing; -- late decode or write failures publish nothing; -- preserve output and existing nested-Store reads remain compatible; -- import response fields retain their documented schema and meanings; -- focused tests, formatting, and Clippy pass; -- CLI documentation describes the new behavior without promising retained provenance. diff --git a/docs/superpowers/specs/2026-08-21-pchronicle-automatic-projection-design.md b/docs/superpowers/specs/2026-08-21-pchronicle-automatic-projection-design.md deleted file mode 100644 index 38fa4d5c..00000000 --- a/docs/superpowers/specs/2026-08-21-pchronicle-automatic-projection-design.md +++ /dev/null @@ -1,247 +0,0 @@ -# pChronicle Automatic Storyline Projection Design - -**Date:** 2026-08-21 - -## Goal - -Remove the public `pchronicle project` command and make canonical-events to -Storyline projection an automatic part of normal pChronicle workflows: - -- `import` performs an explicit, one-shot projection when its input is exactly - one canonical `events.lance` Store; -- `serve` builds, rebuilds, synchronizes, verifies, and discovers projections - automatically; -- `status` reports projection health alongside Dataset health. - -The canonical `events.lance` Store remains the source of truth. The Storyline -Lance Store remains a rebuildable derived representation and never becomes an -independent authority for canonical event facts. - -## Non-goals - -- Do not merge projection logic into the Gateway or Control append hot path. -- Do not change canonical event ordering, append acknowledgement, fencing, or - physical storage semantics. -- Do not replace the existing Storyline three-table Store or its lineage model. -- Do not retain a hidden, deprecated, or compatibility `project` command. -- Do not expose manual projection lifecycle flags through `serve`. - -## Public CLI - -### One-shot import - -An input that is exactly one canonical event Store uses this form: - -```bash -pchronicle import \ - --from ./run/events.lance \ - --output ./run/storyline -``` - -`--from` accepts a local path or object-store URI for this mode. Detection must -open and validate the canonical event manifest; a directory name or `.lance` -suffix alone is not sufficient. If the input is not a canonical event Store, -the existing JSON, JSONL, NDJSON, directory-recursive, or stdin import behavior -applies unchanged. - -Canonical-event import always writes a Storyline Lance projection. It does not -require `--format events` or `--output-format storyline`, and it does not copy, -move, or mutate the source Store. Internally, `--output-format` becomes optional: -omission still means `preserve` for JSON imports and means `storyline` for a -canonical event Store. Explicit `--output-format storyline` is also accepted; -explicit `preserve` is rejected for canonical events rather than silently -ignored. The destination remains create-only. A non-empty or existing -destination is a conflict, including a direct Storyline Store without matching -canonical lineage. - -The successful response extends the existing import response boundary and -reports `format=events`, `output_format=storyline-lance`, `sources=1`, the -projected trajectory count, and `fact_rows`. `input_bytes` becomes optional: it -remains present with unchanged meaning for byte-backed JSON imports and is -omitted for a canonical event Store, where physical encoded size is not a -stable logical input measure. Canonical-event import does not claim -byte-preserving import. - -### Removed command - -These commands are removed without aliases: - -```text -pchronicle project build -pchronicle project status -pchronicle project verify -pchronicle project sync -pchronicle project watch -pchronicle project rebuild -``` - -The underlying Rust projection operations remain available for internal reuse -by `import`, `serve`, `status`, and tests. - -### Unified status - -`pchronicle status` includes a `projections` array. Each entry contains: - -```json -{ - "source_path": "run/events.lance", - "projection_path": "run/storyline", - "status": "fresh", - "generation": "generation-id", - "fact_version": 12, - "fact_rows": 4812 -} -``` - -The stable states are: - -- `fresh`: lineage matches the current canonical fact snapshot; -- `stale`: a projection exists but its fact watermark is behind; -- `missing`: no projection exists at the deterministic destination; -- `error`: the source, projection, lineage, or current generation cannot be - opened or verified. - -Nullable generation and watermark members are omitted when unavailable. Table -output includes a compact projection summary; JSON output preserves the full -per-source records. Status is observational and never performs maintenance. - -## Deterministic projection location - -For every discovered canonical Source named `events.lance`, its automatic -projection is the sibling `storyline` Store: - -```text -run/events.lance -> run/storyline -``` - -The same URI join rule applies to local filesystems and object stores. Multiple -canonical Sources in different directories therefore receive independent -sibling projections. - -The destination is owned by automatic projection only when its committed -lineage identifies the matching canonical source. A pre-existing destination -without canonical lineage, with lineage for another source, or with malformed -state is never overwritten. Initial startup reports a conflict; a runtime -discovery records an error and retries without changing the destination. - -## Serve startup - -`serve` scans every mounted Dataset for canonical event Sources before -publishing its single readiness record. It processes Sources with bounded -concurrency and applies this state machine to each deterministic destination: - -1. missing destination: build a complete projection from one pinned fact - snapshot; -2. fresh matching projection: no-op; -3. stale matching projection with a valid append watermark: incrementally sync; -4. matching projection that requires rebuild: publish a complete new physical - generation, then atomically switch `CURRENT`; -5. foreign, lineage-free, or malformed destination: fail closed. - -Readiness is emitted only after every initially discovered canonical Source is -fresh. A projection failure therefore prevents Warehouse, Control, Gateway, or -combined `serve` modes from advertising readiness. A Dataset with no canonical -event Source requires no projection work. - -Two `serve` processes may race on the same source. Publication continues to use -the existing generation and compare-and-swap contracts. A process that loses a -race reloads `CURRENT`; a matching fresh winner is success, while a conflicting -or malformed winner remains an error. No process mutates a published generation -in place. - -## Runtime maintenance - -After readiness, an internal projection supervisor periodically: - -1. discovers newly created canonical event Sources; -2. reads each current canonical fact watermark; -3. incrementally synchronizes append suffixes when the existing proof - obligations permit it; -4. performs a complete rebuild when lineage, recipe, or monotonic-watermark - checks require one; -5. periodically verifies apparently fresh projections. - -The worker uses bounded concurrency and capped exponential backoff. One Source -failure does not stop maintenance for other Sources. - -Runtime projection work remains outside Gateway and Control append -acknowledgement. Canonical writes are durable when their existing append -contract succeeds; they do not wait for Storyline projection. A projection -failure after readiness is written to stderr and retained in supervisor state -for diagnostics, but it does not stop Warehouse, Control, or Gateway. Queries -continue to use the existing bounded canonical fallback when no fresh -projection is available. - -Process shutdown stops new maintenance iterations, waits for an active atomic -publication boundary to settle, and then participates in the unified `serve` -shutdown. It never leaves `CURRENT` pointing at an incomplete generation. - -## Warehouse Catalog refresh - -Automatic projection supersedes the earlier rule that `serve` never refreshes -Warehouse automatically. After a successful projection build, sync, or -rebuild, `serve` constructs a complete new Catalog Snapshot for the mounted -Datasets. Only a fully successful Snapshot is atomically installed. - -Snapshot construction failure leaves the previous Snapshot serving requests -and enters the same bounded retry path as projection maintenance. Queries do -not observe partially discovered Sources or half-published projections. Several -projection publications in one maintenance iteration are coalesced into one -Catalog refresh. - -Gateway-only or Control-only `serve` modes still maintain projections but do -not construct an unused Warehouse Snapshot when `--listen` is absent. If a -Warehouse listener is enabled later only by starting a new process, its startup -scan establishes a fresh projection and initial Snapshot before readiness. - -## Error and output boundaries - -- `import` failures use the existing CLI boundary codes and never publish a - partial destination. -- Startup projection failures prevent stdout readiness and are reported on - stderr without secrets. -- Runtime maintenance failures never write machine events to stdout because - stdout is reserved for the one `serve` readiness record. -- Control authentication tokens remain present only in readiness JSON and are - never included in projection or Catalog diagnostics. -- `status` converts per-projection read or verification failures into stable - `error` records; debug source chains remain behind the existing debug error - boundary. - -## Documentation and migration - -Documentation presents only three normal lifecycle commands: - -```text -pchronicle import ... -pchronicle serve ... -pchronicle status ... -``` - -Manual build/sync/watch/rebuild instructions are removed. Existing automation -using `pchronicle project` must migrate as follows: - -| Old workflow | Replacement | -| --- | --- | -| `project build` | `import --from EVENTS_URI --output STORYLINE_URI` | -| `project status` / `project verify` | `status DATASET_URI` | -| `project sync` / `project watch` | automatic under `serve` | -| `project rebuild` | automatic under `serve` | - -## Testing - -Required tests cover: - -- local and object-store canonical-event import; -- manifest-based input detection rather than suffix-only detection; -- create-only publication and protection of foreign or lineage-free outputs; -- startup build, incremental sync, automatic rebuild, and readiness ordering; -- runtime discovery of a newly created canonical Source; -- projection retry without blocking Gateway or Control durable writes; -- automatic Catalog refresh, atomic Snapshot switching, and retention of the - previous Snapshot after refresh failure; -- multiple Sources and concurrent `serve` processes using existing CAS; -- `fresh`, `stale`, `missing`, and `error` status encoding; -- CLI help and command parsing proving `project` is absent; -- release smoke coverage for canonical-event import and automatic `serve` - projection. diff --git a/docs/superpowers/specs/2026-08-21-pchronicle-serve-control-consolidation-design.md b/docs/superpowers/specs/2026-08-21-pchronicle-serve-control-consolidation-design.md deleted file mode 100644 index fa3f54d9..00000000 --- a/docs/superpowers/specs/2026-08-21-pchronicle-serve-control-consolidation-design.md +++ /dev/null @@ -1,182 +0,0 @@ -# pChronicle Serve and Control Consolidation Design - -## Goal - -Replace the standalone `pchronicle control` process with an optional Control -service hosted by `pchronicle serve`. pPilot and pVisor will start `serve` in -Control-only mode where they currently start `control`. - -This is a process and CLI consolidation. The authenticated Control protocol, -Run lease semantics, Attempt registry, trajectory append behavior, and durable -storage layouts remain unchanged. - -## CLI contract - -`serve` accepts exactly one Dataset source: - -- `--storage URI` creates a single Dataset mount named `default` and supplies - the durable root required by Control; -- `--config FILE` loads the existing multi-Dataset Warehouse configuration. - -`--storage` and `--config` are mutually exclusive. One of them is required. - -The independently selectable services are: - -- `--listen ADDR` enables the Warehouse HTTP API and Web UI; -- `--control ADDR` enables the authenticated Control TCP listener and requires - `--storage`; -- `--gateway FILE` enables the Gateway using its existing TOML configuration. - -At least one of `--listen`, `--control`, or `--gateway` is required. The -Warehouse listener no longer has an implicit default: omitting `--listen` -means that no Warehouse HTTP socket is created. `--open` requires `--listen`. -Warehouse and Control listeners must use loopback addresses. - -The supported modes include: - -```text -pchronicle serve --storage URI --control 127.0.0.1:0 -pchronicle serve --storage URI --listen 127.0.0.1:8080 -pchronicle serve --storage URI --listen 127.0.0.1:8080 --control 127.0.0.1:0 -pchronicle serve --config warehouse.toml --listen 127.0.0.1:8080 -pchronicle serve --storage URI --gateway gateway.toml --control 127.0.0.1:0 -``` - -Gateway may run without Warehouse HTTP. In `--storage` mode its Dataset is the -automatic `default` mount. In `--config` mode its existing explicit/default -Dataset selection rules continue to apply. - -The `pchronicle control` subcommand and its compatibility aliases are removed. - -## Service architecture - -The current Control command implementation becomes an embeddable Control -service with three phases: - -1. open the Run control store and Attempt registry; -2. bind the configured TCP listener and generate a per-process authentication - token; -3. serve authenticated, newline-delimited Control requests until shutdown. - -`run_serve` becomes the supervisor for Warehouse, Control, and Gateway. It -resolves the Dataset source, prepares every enabled component, and binds every -requested listener before starting any public serving loop. - -All enabled services share one cancellation token. A termination signal -cancels all services and performs their existing graceful shutdown work. If -any service returns unexpectedly or fails after readiness, the supervisor -cancels the remaining services and exits with the original error. A partially -initialized process never publishes readiness. - -Control remains a separate loopback TCP listener rather than becoming a -Warehouse HTTP write route. This preserves the authentication and trust -boundary of the existing write-capable protocol while allowing one process to -own its lifecycle. - -## Readiness protocol - -After all enabled listeners have bound successfully, `serve` writes exactly -one newline-terminated JSON object to stdout: - -```json -{ - "version": 1, - "warehouse_endpoint": "127.0.0.1:8080", - "control": { - "endpoint": "127.0.0.1:49152", - "auth_token": "generated-secret" - }, - "gateway_endpoint": "127.0.0.1:8081", - "gateway_admin_endpoint": "127.0.0.1:8082" -} -``` - -Members for disabled services are omitted. The ready-envelope version is -independent of the existing Control protocol version. Once readiness has been -written, `serve` writes no further data to stdout. Human-readable endpoints, -diagnostics, and runtime errors remain on stderr. The Control authentication -token is never included in stderr diagnostics. - -Control-only process clients require the `control` member and reject a ready -envelope that omits it or uses an unsupported version. - -## pPilot and pVisor migration - -`ChronicleControlProcessClient` is replaced by -`ChronicleServeProcessClient`. It starts: - -```text -pchronicle serve --storage --control 127.0.0.1:0 -``` - -It parses the unified ready envelope, extracts the Control endpoint and token, -and then uses the existing `ChronicleControl` request/response implementation. -The `ChronicleControl` trait and in-memory implementation remain available; -only the executable process adapter changes. - -All current launch sites in pPilot, pVisor, coordination, and trajectory CLI -flows migrate to the new process client. Existing binary-path and storage-root -configuration remains valid. Child-process failure and shutdown continue to -propagate through the process client. - -This is an intentional breaking CLI and Rust API change: no deprecated -`pchronicle control` wrapper or `ChronicleControlProcessClient` type alias is -retained. - -## Storage and visibility semantics - -The consolidated process continues to use the existing storage structures: - -- `run-control/` for CAS-managed Run leases and terminal commits; -- `attempt-registry/` for Attempt liveness and terminal results; -- the existing raw trajectory event destinations carried by append requests. - -Local locking, object-store conditional updates, lease epochs, fencing, -idempotent terminal publication, and trajectory append acknowledgement do not -change. Existing local and object-store roots remain readable and writable -without migration. - -Hosting Warehouse and Control in one process does not introduce implicit -catalog refreshes. Control writes become visible according to the Warehouse's -existing snapshot and explicit refresh behavior. - -## Error handling and security - -- Invalid service combinations fail during CLI validation. -- `--control` without `--storage`, `--open` without `--listen`, and a process - with no enabled service are rejected. -- Non-loopback Warehouse or Control addresses are rejected before binding. -- Failure to open storage or bind any enabled listener prevents readiness. -- Unsupported ready-envelope and Control-protocol versions fail closed. -- Control retains its random per-process bearer token and maximum frame size. -- Warehouse remains read-only; no Control operations are added to its HTTP - router. -- Unexpected service termination shuts down sibling services rather than - leaving a partially functional process alive. - -## Testing - -Tests cover: - -- Clap help and validation for all valid and invalid service combinations; -- the absence of a Warehouse socket when `--listen` is omitted; -- Warehouse-only, Control-only, Gateway-only, and combined service startup; -- `--storage` creating the automatic `default` Dataset mount; -- readiness being emitted once and only after every listener has bound; -- omission of disabled endpoints and omission of the Control token from - stderr; -- unified shutdown on Ctrl-C and unexpected component failure; -- lease acquire/renew/takeover, fencing, Attempt heartbeat/terminal state, - Run commit, and trajectory append through a spawned `serve` process; -- pPilot and pVisor process-launch integration; -- continued access to existing local and object-store Control state; -- existing Warehouse, Gateway, and read-only HTTP contract regressions. - -## Out of scope - -- exposing Control as Warehouse HTTP write endpoints; -- remote or non-loopback Control access; -- automatic Warehouse catalog refresh after Control writes; -- combining `--config` and `--storage`; -- retaining a hidden or deprecated `pchronicle control` command; -- changing Run, Attempt, trajectory, Gateway, or Dataset storage formats. diff --git a/docs/superpowers/specs/2026-08-22-explorer-run-paths-design.md b/docs/superpowers/specs/2026-08-22-explorer-run-paths-design.md deleted file mode 100644 index e8a57091..00000000 --- a/docs/superpowers/specs/2026-08-22-explorer-run-paths-design.md +++ /dev/null @@ -1,29 +0,0 @@ -# Explorer Run paths:squash 后不再用导入前目录 - -## Status - -Approved as approach A on 2026-08-22. - -## Context - -`--output-format storyline` squash 之后,Catalog 只有一个 `_file_ = "."` 的 Source。 -Run paths 仍把 `run_id`(常为原文件名 / job 名)当成根目录,拼出 -`{dataset}/{run_id}/subagents/{session_id}`。这不是 Storyline 父子关系。 - -## Decision - -当 `_file_ == "."`: - -- 叶子用 `document_id`:`{dataset}/{document_id}` -- 仅当 `parent.psid` / `parent_session_id` 存在且不等于 `session_id` 时: - `{dataset}/{parent}/subagents/{document_id}` -- **不用 `run_id` 做路径段** - -`_file_ != "."`(preserve / 多文件 Catalog)保持现有 `{dataset}/{file}/…` 规则。 -`RunSummary.root_session_id` 仍可回退到 `run_id`,只改展示路径。 - -## Non-goals - -- 改 Catalog source 发现或 `_file_` 列 -- 扁平化真实 parent/child -- 重编嵌入前端 diff --git a/docs/superpowers/specs/2026-08-22-explorer-steps-chats-design.md b/docs/superpowers/specs/2026-08-22-explorer-steps-chats-design.md deleted file mode 100644 index c9fcb78e..00000000 --- a/docs/superpowers/specs/2026-08-22-explorer-steps-chats-design.md +++ /dev/null @@ -1,82 +0,0 @@ -# Explorer Trace:Steps / Chats 两种前端视图 - -## Status - -Implemented. Approved in conversation on 2026-08-22: two frontend-only layouts; -default open is **Chats**; names are **Steps** and **Chats**. - -## Context - -Harbor ATIF 一个 step 只有一个 `source`(`system` / `user` / `agent`)。 -Storyline turn 对齐这个模型。聊天习惯则把「用户一句 + 随后的助手回复」看成一轮交互。 - -后端不改 wire、不合成 turn、不发明 user 行。两种读法都在 `pchronicle-web` -对已加载的 `TurnSummary[]` 做分组。 - -现有 Trace 按 `call_id` 收成 span,那是工具跨度,不是对话轮。Chats 另做一层, -不和 span 混用同一套 key。 - -URL 已有未使用的 `view`(默认曾是 `tree`)。本功能占用它。 - -## Goals - -1. Trace 工具栏可切换 **Chats** / **Steps**。打开 Run 默认为 Chats。 -2. 分组、计数、卡片结构只在前端完成;点开仍用原始 `turn.id` 拉详情。 -3. Analysis、source 过滤、`turn=` 深链继续针对原始 turn。 -4. ACTF 目前几乎全是 `src=agent` 时,Chats 不强行画用户气泡。 - -## Non-goals - -- 改 Storyline / ATIF / ACTF 映射或 Lance 投影。 -- 用 `/prompt.user` 伪造 `src=user` turn。 -- 引入 exchange / chat id 或后端聚合 API。 -- 改 Analysis 图表的聚合口径。 - -## Decision - -### 名称与默认 - -| 开关 | URL | 含义 | -|---|---|---| -| **Chats** | `view=chats`(默认) | 交互轮:一个 user 开一轮,随后连续 agent 是这轮回复 | -| **Steps** | `view=steps` | ATIF 步:一条 turn 一行,一个 `source` | - -未知或旧值 `tree` 视为 `chats`。刷新保持 `view`。 - -### Chats 分组(数组顺序,稳定) - -对当前列表(已 overlay source / 文本过滤)从左到右扫: - -1. `user` 开启一轮,吃掉后面连续的 `agent`。 -2. 下一个 `user` 或 `system` 结束上一轮。 -3. 单独的 `system`(含 compaction)自成一轮,不并入相邻 chat。 -4. 没有前置 `user` 的 `agent` 各自自成一轮(只有助手侧)。 -5. 连续多个 `user`:各开一轮;没有 agent 的 user 也是一轮。 - -主界面仍是原来的 span 表 + occupancy 时间轴。Chats 把一轮交互收成一行 -(bar 覆盖该轮 `event_seqs`);展开后仍是原来的 turn 行。 - -### Steps - -一条 turn 一行,同样走 span 表和时间轴,不再用 `call_id` 合并。点开证据与现在相同。 - -### 过滤与 URL - -- source / `turn_q` 只在前端过滤。Chats 先分组再按「行里是否含该 source / 文本」 - 决定显隐,匹配行保留全部成员。Steps 仍按单条 turn 滤。不再为过滤重拉 API。 -- 轴标题是 Sequence / occupancy;bar 按 `user` / `agent` / `system` 上色。 -- Chat 行 Overview 是 user preview,没有 user 则 `No user turn`。 -- 助手侧文案统一 Copilot。 -- `turn=` 仍是 turn id。Chats 下若该 id 在某轮内,展开那一轮并选中该 turn。 -- 不新增 query 参数。 - -## Files - -- `pchronicle-web/src/components.rs` — 分组函数 + Chats / Steps 列表 -- `pchronicle-web/src/workspace.rs` — 工具栏开关;`view` 默认 `chats` -- 现有 `assets/*.css` 加 Chats 卡片样式,不新开 CSS 管线 - -## Test - -- 分组单测:user→agent→agent、前导 agent、中段 system、连续 user。 -- 不要求后端 / e2e。 diff --git a/docs/superpowers/specs/2026-08-22-explorer-structure-overview-design.md b/docs/superpowers/specs/2026-08-22-explorer-structure-overview-design.md deleted file mode 100644 index c9bcc2a3..00000000 --- a/docs/superpowers/specs/2026-08-22-explorer-structure-overview-design.md +++ /dev/null @@ -1,152 +0,0 @@ -# Explorer Trace:Structure 结构芯片 + Overview 摘要 - -## Status - -Approved in conversation on 2026-08-22: Structure holds type chips and -counts; Overview is the extracted utterance; list API adds `char_count` -and `modalities`, and `preview` becomes extracted text. - -## Context - -Chats / Steps 共用原来的 span 表。Structure 几乎只有 `Chat 5` 和 -`user → agent · seq 4–5`。Overview 把 `turn.message` 原样 -`to_string` 再截断,多模态数组先露出 `image_bytes:null` 一类外壳, -真正的 `text` 被挤掉。模型名、耗时、SYSTEM/CHAT 徽章和 Structure / -Evidence 重复。 - -扫 60 行时,两列要分工:左边回答「这是什么、多大」,右边回答「说了什么」。 - -## Goals - -1. Structure 展示结构化信息:大类型 + 细节形态芯片 + 组成 + 字符数 + seq。 -2. Overview 只展示抽出的对话摘要。 -3. 列表 `preview` 改为抽出正文,过滤 / 搜索 / Copilot 能命中原话。 -4. 形态芯片可靠,不依赖 180 字截断后的 JSON 残片。 - -## Non-goals - -- 改 Storyline / ATIF / ACTF 映射或 Lance 投影。 -- 改 Sequence 轴(会话相对位置 + 按 turn 类型上色)。 -- 改 Evidence 列。 -- 为列表再拉 turn 详情。 -- 用模型生成摘要;摘要就是抽出的正文截断。 -- 在 Overview 保留模型名、耗时、角色大徽章。 - -## Decision - -### 两列分工 - -| 列 | 放什么 | 不放什么 | -|---|---|---| -| Structure | 大类型芯片、形态芯片、组成、字符数、seq、行标题 | 正文、模型、耗时 | -| Overview | 抽出的摘要,单行截断,`title` 为同一段 `preview` | 类型芯片、JSON 外壳 | - -去掉 Structure 左侧色点和 Overview 里的 SYSTEM / USER / CHAT 大徽章。 -大类型只留一枚芯片,紧挨行标题(`Chat 5` / `System` / `#8`)。 - -**Structure 一格顺序** - -1. 行标题 + 大类型芯片 -2. 细节芯片(没有的不画) -3. 一行小字:`1 user + 1 agent · 1 tool · 842 chars · seq 4–5` - -### 大类型 - -| 行 | 芯片 | -|---|---| -| Chats:`TraceCard::Chat`(含无 user 的前导 agent) | `Chat` | -| Chats:`TraceCard::System` | `System` | -| Steps 行 | `User` / `Agent` / `System`(`source`) | -| 根行 | 无大类型芯片;标题仍是 `trajectory · N chats\|steps` | - -展开后的 `CompactTurnRow` 不复制整套 Structure 芯片;角色仍用现有 -`pc2-role`,正文改用抽出的 `preview`。 - -### 细节形态 - -稳定顺序:`text` · `image` · `audio` · `tool_call`。只画实际出现的。 - -Chat 行 / 根行:成员 `modalities` 并集。单条 turn:自己的列表。 - -### 组成与长度 - -- 组成:按 `source` 计数,工具数用 `tool_names.len()` 之和。 - 例:`1 user + 1 agent · 1 tool`;只有 system:`1 system`; - 无 user 的 agent 行:`1 agent · 1 tool`。零工具时省略 `· 0 tool`。 -- 长度:抽出正文的**完整**字符数(截断前),展示为 `842 chars` 或 - `1.2k chars`(≥1000 用一位小数的 `k`)。 -- **Chat 行的 `chars` 只计用户摘要**。没有 user → `0 chars`,Overview - 为 `No user turn`。 -- 根行:各源计数 + 全会话形态并集 + **用户摘要字符合计**。Overview - 不编摘要。 - -### 摘要 - -- Chat 行:第一条 user 的 `preview`。没有 user → `No user turn`。 -- Steps 行:该 turn 的 `preview`。空 → `No text`。 -- 展开的 `CompactTurnRow`:同样用该 turn 的 `preview` / `No text`。 -- 单行截断;`title` 等于 `preview`(服务端已截到约 180 字)。全文在 - turn 详情,不把未截断 message 放进列表。 - -### 列表 wire - -`TurnSummary`(CLI explorer 与 `pchronicle-web` 对齐)在现有字段上: - -- `preview`:**抽出的可读正文**,再 `compact` 到约 180 字。不再 - `serde_json::to_string(message)`。 -- `char_count: u64`:抽出正文截断前的字符数。 -- `modalities: Vec`:`text` / `image` / `audio` / `tool_call`。 - -旧客户端缺字段:前端当 `preview` 空、`char_count = 0`、无形态,不报错。 -不改 Storyline schema。搜索 / 过滤继续用 `preview` 和 `source`。 - -### 从 `turn.message` 抽出 - -服务端在 `turn_summary` 里算,前端只展示和按行聚合。 - -**正文** - -- 字符串 message → 整段。 -- 数组 / 对象:递归走进子节点,收集非空字符串 `text`,以及本身是 - 字符串的 `content`。多段用空格拼接。`null` / 空串不算。 -- 抽不出 → `preview` 空,`char_count = 0`。 - -**形态(有才记)** - -- `text`:抽出正文非空。 -- `image`:`image` / `image_url` / `image_bytes` 有非空值,或 part - `type` 为 `image` / `image_url`。 -- `audio`:`input_audio` / `audio` 有非空值,或 `type` 为 `audio` / - `input_audio`。 -- `tool_call`:`display_tool_calls` 非空,或正文含 ``。 - -空字符串、`null`、缺省 key 都不构成形态。 - -### 文件 - -- `crates/persisting-pchronicle-cli/src/server/explorer.rs` — 抽出、 - `TurnSummary` 新字段、单测 -- `pchronicle-web/src/model.rs` — `TurnSummary` 对齐 -- `pchronicle-web/src/components.rs` — Structure / Overview 渲染与聚合 -- `pchronicle-web/assets/span-timeline.css` — 芯片样式 -- 现有 `chat_view` 分组与过滤规则不动 - -## Test - -服务端(`explorer`): - -- 多模态数组:前部 `null` 媒体字段 + 末尾 `text` → 正文是那句 text, - `char_count` 为完整长度,`modalities` 含 `text`,不含空 image/audio -- 纯字符串 message → 正文即字符串,`text` -- 只有非空 `image_url`、无 text → 空 preview,`0`,`[image]` -- 有 `tool_names` 或 `` → 含 `tool_call` - -前端(`pchronicle-web`): - -- user + agent Chat:形态并集;`chars` 等于 user `char_count`; - Overview 是 user `preview` -- 无 user 的 Chat:Overview `No user turn`,`0 chars`,大类型仍是 - `Chat` -- Steps 空 preview → `No text` - -不要求 e2e。 diff --git a/docs/superpowers/specs/2026-08-22-json-value-renderer-design.md b/docs/superpowers/specs/2026-08-22-json-value-renderer-design.md deleted file mode 100644 index 17c4ad7d..00000000 --- a/docs/superpowers/specs/2026-08-22-json-value-renderer-design.md +++ /dev/null @@ -1,123 +0,0 @@ -# Turn evidence:按类型派发的 JSON 值渲染 - -## Status - -Approved in conversation on 2026-08-22: approach 2 (classifier + small -components behind `JsonValue`); first host is Turn evidence. - -## Context - -Turn evidence 把 Tool calls、Observation、Raw events,以及非字符串 -`message`,一律 `serde_json::to_string_pretty` 丢进 `
`。扁平对象和深层
-树看起来一样,工具 `arguments` 经常还是 JSON 字符串。
-
-查询单元格弹窗也是 `
`,本轮不改。
-
-## Goals
-
-1. 通用 `JsonValue`:按 JSON 形态派发,不按 evidence 块名特判。
-2. 一层扁平对象 → 两列表;一层扁平对象数组 → 多列表。
-3. 更深的对象/数组 → `
` 树,默认全关,打开只露一层。 -4. 当前节点若是能解成对象/数组的字符串,先 peel 再分类。 -5. 接到现有 Turn evidence;`extra` / `metrics` 有值才出块。 - -## Non-goals - -- 查询结果单元格 / 单元格弹窗。 -- 识别 `{fn, args}` 等协议形状。 -- 改 agent 送给模型的 pretty JSON。 -- 改 Reasoning 的纯文本语义(不 peel)。 -- 重编嵌入前端以外的后端 wire。 - -## Decision - -### 模块 - -`pchronicle-web/src/json_value.rs` 只暴露: - -- `peel_json(&Value) -> Value` -- `classify_json(&Value) -> JsonShape` -- `JsonValue` 组件 - -`JsonShape`:`Scalar` | `KvTable` | `RecordTable` | `Tree`。 -四个小组件各管一种形态。表格单元格和树子节点只回调 `JsonValue`。 - -`components.rs` 的 `EvidenceBlock` 改成宿主(`title` + 子内容),不再收 -pretty 字符串。 - -### Peel - -只处理**当前**节点: - -- 字符串 `from_str` 得到对象或数组 → 用解析结果。 -- 失败、得到标量、非字符串 → 原值。 -- 不递归 peel 子字段。因此 `[{fn, args:"{...}"}]` 仍是一层 RecordTable, - `args` 格子里再派发成表或树。 - -`peel` 最多跟节点走,不设跨节点深度预算;循环嵌套 JSON 字符串由下一层 -`JsonValue` 再 peel。 - -### 分类(对 peel 后的当前值;判断子字段是否标量时不 peel) - -标量:`null` / bool / number / string。 - -| 形态 | 条件 | -|---|---| -| Scalar | 标量 | -| KvTable | 对象(含空对象),且每个值都是标量 | -| RecordTable | 非空数组,每项都是对象,且每个对象的每个字段都是标量 | -| Tree | 其余对象或数组(含空数组、标量数组、混杂项、含嵌套的对象) | - -空对象:0 行 KvTable。空数组:折叠树,摘要 `[0 items]`。 - -### 渲染 - -- Scalar:纯文本(null / bool / number 用 `Display`;字符串原文)。 -- KvTable:两列 `key` / `value`;value 格是 `JsonValue`。 -- RecordTable:列名取对象 key 并集,稳定顺序(首次出现);单元格是 `JsonValue`。 -- Tree:每个子项一个 `
`,默认 `open=false`。摘要:`key` 或 `[i]` + - 类型规模(`{3 keys}` / `[12 items]`),不铺整段 JSON。展开后子项再走 - `JsonValue`。 - -### Turn evidence 接入 - -`InlineTurnDetail`: - -| 块 | 规则 | -|---|---| -| Message | peel 后仍是标量 → 现有正文;对象/数组 → `JsonValue` | -| Reasoning | 始终纯文本,不 peel | -| Tool calls | `wire_tool_calls` 序列化成 `Value` 后 `JsonValue` | -| Observation | 有值则 `JsonValue` | -| Raw linked events | 非空则 `JsonValue` | -| Extra / Metrics | `turn.extra` / `turn.metrics` 有值才出块,走 `JsonValue` | - -块外壳仍是 `
`;Message 默认展开,其余 -默认折叠。块内不再用整段 `
` 作为主渲染。
-
-### 样式
-
-沿用 `pc2-` 前缀,加在现有 `assets/components.css` / `assets/inline-trace.css`。
-不新开 CSS 管线。树用原生 `
`。 - -## Files - -- `pchronicle-web/src/json_value.rs` — peel / classify / 四个形态 / `JsonValue` -- `pchronicle-web/src/components.rs` — `EvidenceBlock` 宿主化;`InlineTurnDetail` 接入 -- `pchronicle-web/src/main.rs` — `mod json_value` -- `pchronicle-web/assets/components.css`、`assets/inline-trace.css` — 表与树样式 - -## Test - -单测 `peel_json` / `classify_json`: - -- 扁平对象 → KvTable -- 扁平对象数组 → RecordTable -- 对象数组但 `args` 是 JSON 字符串 → 仍 RecordTable(子字段不 peel) -- 该字符串 peel 后 → KvTable 或 Tree -- 含嵌套对象 → Tree -- 标量数组、空数组 → Tree -- 空对象 → KvTable -- 非法 JSON 字符串、JSON 编码的标量字符串 → Scalar - -不要求 WASM 组件快照。 diff --git a/docs/superpowers/specs/2026-08-22-persisting-replay-adapter-module-split-design.md b/docs/superpowers/specs/2026-08-22-persisting-replay-adapter-module-split-design.md deleted file mode 100644 index 4b2354d0..00000000 --- a/docs/superpowers/specs/2026-08-22-persisting-replay-adapter-module-split-design.md +++ /dev/null @@ -1,99 +0,0 @@ -# Persisting Replay Adapter Module Split Design - -## Objective - -Replace the current facade-only Agent adapter modules with physical module -boundaries. Each Agent module will own its parser, preparation logic, execution -logic, native reconstruction helpers, and Agent-specific tests. This is a -behavior-preserving refactor. - -## Scope - -In scope: - -- `crates/persisting-replay/src/adapter/mod.rs` -- `crates/persisting-replay/src/adapter/claude_code.rs` -- `crates/persisting-replay/src/adapter/mini_swe_agent.rs` -- `crates/persisting-replay/src/adapter/openhands.rs` -- `crates/persisting-replay/src/adapter/swe_agent.rs` -- `crates/persisting-replay/src/adapter/runtime.rs` only when an import or - visibility adjustment is required by the move - -Out of scope: - -- Changes to `ReplayPlan`, `AdapterPlan`, public request/result schemas, replay - modes, artifact names, or runtime behavior -- Redesign of the Mini/SWE SDK bridge -- Deduplication of comparison, artifact, status, or process configuration code -- Gateway, pChronicle, Queue, Search, TTAS, and `persisting-dlcapt` - -## Module Ownership - -### `adapter/mod.rs` - -The module root owns only: - -- child-module declarations; -- `RunContext`; -- the public `build_plan` and `run` static dispatch functions; -- helpers used by at least two Agent modules; -- the shared Mini/SWE SDK bridge that is explicitly outside this refactor. - -It must not contain `build_claude_plan`, `build_mini_plan`, -`build_openhands_plan`, `build_swe_plan`, `run_claude`, `run_mini`, `run_swe`, -or `run_openhands`. - -### Agent modules - -Each Agent module owns its version-pinned native parsing, boundary extraction, -prefix construction, replay/continuation orchestration, native reconstruction, -terminal-state interpretation, and focused unit tests. - -The only callable module surface from `adapter/mod.rs` remains: - -```rust -pub(super) fn build(request: &PlaybackRequest) -> Result; - -pub(super) fn execute( - plan: &ReplayPlan, - context: &RunContext<'_>, - journal: &mut Journal, -) -> Result; -``` - -Agent-specific helpers remain private to their module. Shared helpers use -`pub(super)` only when a child module must call them. - -## Migration Strategy - -Move one Agent at a time. After each move, compile and run that Agent's focused -tests before moving the next Agent. Start with OpenHands, then Mini, SWE, and -finally Claude; this leaves the largest and most coupled implementation until -the common helper boundary is established. - -Tests move with the implementation they cover. Tests for common environment, -process, or dispatch behavior remain in `adapter/mod.rs` or their existing -common module. - -No logic is rewritten during movement. Necessary edits are limited to module -paths, imports, visibility, and calls through `super`. - -## Acceptance Criteria - -- All four Agent modules contain their actual parser and executor implementations. -- `adapter/mod.rs` contains none of the eight Agent-specific build/run functions - listed above. -- The four dispatch entrypoints retain their current signatures. -- `cargo fmt --check -p persisting-replay` passes. -- `cargo test -p persisting-replay` passes. -- `cargo clippy -p persisting-replay --all-targets -- -D warnings` passes. -- `git diff --check` passes for the refactor. -- No excluded subsystem or unrelated concurrent change is staged. - -## Risks and Controls - -The primary risk is accidental behavioral change while resolving imports or -visibility. Mechanical moves are therefore separated by Agent and verified -incrementally. A second risk is creating a new generic abstraction merely to -make the files compile; the explicit out-of-scope rules prohibit that. Shared -code stays shared unless it already has a clear multi-Agent use. diff --git a/docs/superpowers/specs/2026-08-22-persisting-replay-reliability-design.md b/docs/superpowers/specs/2026-08-22-persisting-replay-reliability-design.md deleted file mode 100644 index 3199382a..00000000 --- a/docs/superpowers/specs/2026-08-22-persisting-replay-reliability-design.md +++ /dev/null @@ -1,311 +0,0 @@ -# Persisting Replay Reliability and Adapter Design - -## Status - -Approved in conversation on 2026-08-22. - -## Context - -`persisting-replay` reconstructs an Agent-native trajectory at a complete tool -batch boundary, re-executes the selected prefix in a fresh workspace, and can -continue the Agent from the resulting observations. The current implementation -supports Claude Code 2.1.220, mini-swe-agent 2.4.6, OpenHands 0.53.0, and -SWE-agent 1.1.0. - -The initial implementation proved the native-resume approach, especially the -Claude Resume Transport cleanup, but exposes several incompatible meanings -through one `replay_only` boolean. It also centralizes parsing, execution, -process management, and result extraction in one large adapter module. Some -public controls are consequently advisory rather than enforced: non-Claude -`replay_only` does not execute tools, SWE-agent ignores `max_steps`, and an -OpenHands controller failure can still produce a successful result. - -This design makes the replay contract explicit and fail-closed while retaining -the existing four version-pinned Agent integrations. - -## Goals - -1. Give prepare, replay, and continuation distinct, consistent public - semantics across every Agent. -2. Ensure advertised limits and terminal statuses are enforced rather than - silently ignored. -3. Bound child-process memory use and reliably clean up process groups. -4. Make stale observations an explicit opt-in degradation. -5. Return enough failure context to locate partial artifacts and ambiguous - state. -6. Split Agent-specific behavior from the common replay lifecycle without - introducing a dynamic plugin system. -7. Add contract-level integration coverage around fake runtimes and committed - trajectory fixtures. - -## Non-goals - -- Supporting additional Agent versions or Agent families. -- Dynamically loading replay adapters. -- Guaranteeing that a replayed next action is identical to the source action. -- Resuming a partially executed, side-effecting prefix in the same sandbox. -- Adding Gateway capture, pChronicle storage, or a model-traffic audit. -- Changing TTAS, Queue, Search, or `persisting-dlcapt`. - -## Public execution modes - -Replace the internal `replay_only: bool` with a `ReplayMode` enum: - -```rust -pub enum ReplayMode { - PrepareOnly, - ReplayOnly, - ReplayAndContinue, -} -``` - -The CLI exposes the modes as follows: - -- `--prepare-only` parses the source trajectory and constructs the selected - native prefix. It executes no historical tool and starts no live Agent. An - Agent runtime is optional. -- `--replay-only` parses the trajectory, executes the complete selected tool - prefix, writes fresh observations into the reconstructed native context, and - stops before the first new model request. It requires an Agent runtime for - every Agent. -- Supplying neither flag performs prepare, replay, and live continuation. It - requires an Agent runtime. -- `--prepare-only` and `--replay-only` conflict. - -The existing v1 JSON request and TOML `replay_only = true` retain their field -shape but adopt the corrected `ReplayOnly` meaning. Callers that relied on the -old non-Claude prepare-only behavior must migrate to `prepare_only = true` or -`--prepare-only`. Deserializers reject both booleans being true. - -## Step budget - -`max_steps` is the total number of Agent action/model steps, including the -selected replay prefix. Every live adapter must either enforce that definition -or reject the request as unsupported before executing tools. - -- Claude passes `max_steps - prefix_model_turns` as `--max-turns`. -- mini-swe-agent initializes its native call counter from the prefix and uses - `max_steps` as the Agent `step_limit`. -- OpenHands maps the total limit to the runtime iteration control and verifies - the resulting action count. Adapter tests lock down any framework-specific - offset. -- SWE-agent receives `max_steps` in its runner request and applies the - remaining live-step budget through the version-pinned Agent configuration. - -`max_steps <= prefix_model_turns` is rejected for live continuation. It is -valid in `ReplayOnly` mode when it equals the selected prefix length. - -## Result protocol - -Output advances from `sandbox-playback.result/v2` to -`sandbox-playback.result/v3`. A successful or failed result contains: - -```text -phase: prepared | replayed | continued -quality: verified | degraded -agent_status: completed | max_steps | failed | not_started -``` - -Definitions: - -- `phase` is the furthest successfully completed replay phase. -- `quality` describes whether every reconstructed observation came from a - supported execution path. It is independent of observation equality. -- `agent_status` describes live continuation only. Prepare and replay-only - results use `not_started`. - -The result also contains `run_id`, `state_dir`, `output_dir`, produced -artifacts, replayed call count, prefix step count, continued step count, and -optional structured failure information. A failed live Agent returns a nonzero -CLI exit code even when a partial native trajectory is available. Partial -artifacts remain listed in the failure result. - -The CLI continues to accept v1 JSON requests. The implementation emits only v3 -results; it does not maintain two output code paths. - -## Degraded observations - -The default invariant is that every observation inserted into the reconstructed -prefix was produced from the fresh workspace by a supported execution path. - -Claude Code tools such as `Agent` and `TaskOutput` currently cannot satisfy -that invariant because their original results are copied from the source -trajectory. Such a call inside the selected prefix fails validation by default. - -`--allow-stale-observations` permits these calls for research and migration -workflows. Each copied observation records a degradation reason and source call -ID. The overall result has `quality: degraded`; it can never be reported as -`verified`. Synthetic Task/Todo acknowledgements are subject to the same rule. -Unsupported tools remain errors rather than synthetic successes. - -## Adapter architecture - -The common engine owns configuration validation, directory allocation, -journaling, phase transitions, artifact publication, process supervision, and -result serialization. Agent modules own native trajectory parsing, typed plan -data, prefix construction, historical execution, continuation launch, and -continued-trajectory interpretation. - -The source layout becomes: - -```text -src/ - adapter/ - mod.rs - claude.rs - mini_swe.rs - openhands.rs - swe_agent.rs - runtime.rs - process.rs - engine.rs - model.rs -``` - -`adapter::mod` defines an internal dispatch enum over four concrete adapters. -This is static dispatch: adding a supported Agent still requires a code change -and a pinned profile. The design deliberately avoids object-safe dynamic -plugins. - -Each adapter exposes the same phase-oriented operations conceptually: - -```rust -trait ReplayAdapter { - type Plan; - - fn build_plan(&self, request: &PlaybackRequest) -> Result; - fn prepare(&self, plan: &Self::Plan, context: &RunContext) -> Result; - fn replay( - &self, - plan: &Self::Plan, - prepared: Prepared, - context: &RunContext, - journal: &mut Journal, - ) -> Result; - fn continue_run( - &self, - plan: &Self::Plan, - replayed: Replayed, - context: &RunContext, - journal: &mut Journal, - ) -> Result; -} -``` - -The concrete implementation may use an internal enum instead of a public Rust -trait where associated plan types make dispatch clearer. The invariant is that -Agent-specific native `serde_json::Value` does not leak into the common engine. -Each module wraps native data in a private plan type and validates it before a -later phase can consume it. - -Runtime manifest parsing and exact version probing live in `adapter/runtime.rs`. -Every Agent has a version-banner parser. A parser must return one exact semantic -version; substring containment is not sufficient. - -## Process supervision - -All spawned historical tools and Agent runners use a common process supervisor. -It provides: - -- a dedicated process group on Unix; -- a wall-clock timeout and cancellation path; -- concurrent stdout and stderr draining; -- streaming logs written to an owner-only file; -- a bounded in-memory tail used for observation content and error - classification; -- an explicit truncation flag and total byte counters; -- process-group termination followed by child reaping on timeout, cancellation, - or unsupported background-process survival. - -The supervisor never collects unbounded output with `read_to_end`, `output`, or -`wait_with_output`. Once the configured observation limit is reached it -continues draining to the log while retaining no additional in-memory bytes. - -Historical Claude Bash does not support persistent background work in this -version. After the shell exits, surviving members of its process group are -terminated and reported as a degraded/error observation according to the -native command outcome. This prevents a background child from holding output -pipes indefinitely or mutating later replay steps asynchronously. - -Agent continuation processes may run their own managed descendants, but the -whole process group is still terminated when the top-level continuation -reaches timeout or cancellation. - -## Journal and directory lifecycle - -Configuration, input trajectory, runtime manifest, exact runtime version, and -the complete plan are validated before allocating the unique output directory. -Validation failure therefore does not consume a caller-selected run ID. - -The state lock is acquired before any replay side effect. Journal events record -phase transitions and tool starts/finishes. Same-sandbox recovery is permitted -only when the previous journal contains no `tool_started` event. If any tool -started and the run lacks a terminal event, the state is ambiguous regardless -of whether a corresponding `tool_finished` was synced: the engine always -restarts from the first tool and cannot prove that repeating completed effects -is safe. - -A failed execution writes a v3 result whenever the state and output locations -are writable. The CLI also includes the generated run ID and paths in its JSON -error envelope if result publication itself fails. - -## Agent-specific terminal behavior - -- Claude Resume Transport keeps the existing nonce, boundary observation hash, - canonical prefix hash, and fail-closed cleanup. The bridge remains local and - authenticated. -- OpenHands controller fatal markers set `agent_status: failed` and a nonzero - exit result. A maximum-iteration terminal state sets `agent_status: - max_steps` and is not confused with an infrastructure failure. -- mini-swe-agent and SWE-agent runners publish structured terminal metadata - rather than forcing Rust to infer status exclusively from free-form logs. - -## Testing strategy - -Development follows test-first cycles. The permanent suite includes: - -1. Mode contract tests proving that prepare-only executes zero tools, - replay-only executes the selected prefix for all four Agents, and live mode - performs continuation. -2. A fake SWE-agent runtime proving `max_steps` reaches the runner and caps live - model calls. -3. Claude tests proving opaque calls fail by default and opt-in runs produce a - degraded result with per-call reasons. -4. OpenHands tests proving fatal controller markers produce a failed Agent - status and nonzero CLI result while retaining partial artifacts. -5. Exact version-parser tests covering prefixes, suffixes, warnings, and wrong - versions that contain the expected digits. -6. Process tests producing output beyond the memory limit and starting a - background child. Tests assert bounded retained bytes, complete log draining, - prompt return, and no surviving process-group member. -7. Journal tests proving that any interrupted side-effecting run is ambiguous - and a prepare-only interruption is safely repeatable. -8. CLI integration tests that execute the committed smoke fixtures with fake - version-pinned runtimes and validate v3 results and artifacts. -9. A portable mini-swe-agent runtime-path test that compares canonical paths on - macOS and Linux. - -Real third-party Agent installations and model calls remain outside ordinary -CI. They are exposed as ignored/profile tests for release qualification. - -## Documentation and migration - -The pVisor README, replay guide, and CLI reference will document the three -modes, the corrected meaning of `replay_only`, the stale-observation opt-in, -the total-step budget, and v3 result fields. The migration note will call out -that old non-Claude `replay_only = true` callers that only wanted prefix -construction must switch to `prepare_only = true`. - -## Acceptance criteria - -- All four Agents implement the same three mode semantics. -- `max_steps` is enforced or rejected before side effects for every Agent. -- A normal run cannot return verified quality after copying a source - observation. -- A fatal Agent terminal state cannot return a successful CLI exit code. -- Child output memory is bounded and background descendants cannot stall the - replay engine. -- Exact version profiles reject substring-only matches. -- Failed runs identify their run and artifact locations. -- Targeted replay and pVisor CLI tests pass on macOS and Linux. -- Clippy passes for `persisting-replay` and the touched pVisor CLI targets. diff --git a/docs/superpowers/specs/2026-08-22-storyline-prompt-design.md b/docs/superpowers/specs/2026-08-22-storyline-prompt-design.md deleted file mode 100644 index 110c308b..00000000 --- a/docs/superpowers/specs/2026-08-22-storyline-prompt-design.md +++ /dev/null @@ -1,159 +0,0 @@ -# Storyline `/prompt`:ACTF `system_prompt` / `user_content` - -## Status - -Implemented. Approach C approved in conversation on 2026-08-22: keep `/turns/{t}/msg` -as the assistant utterance; store the ACTF prompt pair as `{system, user}` JSON -on the document and, when it differs, on the turn. - -This increment supersedes the residual classification of those two ACTF step -keys in [2026-08-22-storyline-task-env-response-design.md](2026-08-22-storyline-task-env-response-design.md). -Attempt `extra` / `meta` stay residual. - -## Context - -ACTF 一步同时带三份文本:`system_prompt`、`user_content`、 -`assistant_content.content`。当前只有助手正文进入 `/turns/{t}/msg`;两份 prompt -落入 `unknown_fields`,导出时被写成空字符串。 - -`/task/env` 是运行时/基础设施,不是这两份模型输入。`msg` 已是助手正文的权威槽, -不能改成 `{system, user}` 或 `{system, user, content}`。 - -实测语料上同一 attempt 的 5454 步往往共享同一对 prompt。权威槽必须能去重, -否则每条 `message_json` 都会再存一份。 - -## Goals - -1. ACTF `system_prompt` 与 `user_content` 各有恰好一个权威目标;导入后不再进入 - `unknown_fields`。 -2. `/turns/{t}/msg` 继续只映射 `assistant_content.content`;字符串 `msg` 保持合法。 -3. 文档基线 + turn 整段覆盖;覆盖相对 `/prompt`,不相对上一 turn。 -4. `schema_version` 仍为 `storyline/v1`。Lance 仍是三表,不新增第四张表。 -5. OpenAI / ATIF / AgenticMD / Events 不新增一等 prompt 字段。 - -## Non-goals - -- 把 `src` 与 `msg` 收成 enum,或改变 `msg` 的话语语义。 -- 把 prompt 写进 `/task`、`/task/env`、`turns[].env`、`extra` 或 `unknown_fields`。 -- 拆出 `src=system` / `src=user` turn,或改变 `step_id` / turn 基数。 -- 把 OpenAI / ATIF 的 system/user 消息提升到 `/prompt`。 -- 提升 attempt `extra` / `meta`。 -- 新增 `storyline/v2`。 -- 改变 TTAS、Queue、Search、`persisting-dlcapt`。 - -## Wire - -`prompt` 保持全名(与 `task` / `env` 相同)。对象 `deny_unknown_fields`。 - -```text -/prompt -├── system string? ACTF system_prompt -└── user string? ACTF user_content -``` - -根对象增加可选 `/prompt`。`turns[]` 增加可选 `/prompt`,形状相同。 - -空对象、只含空字符串的对象,在**文档**上视为缺省,不序列化。 -`system` / `user` 若为空字符串,默认不序列化。 - -`copied == true` 的 context turn 不得写 `prompt`。 - -`effective_kind()` 不读取 `prompt`。 - -## 有效 prompt(整段覆盖,不是浅合并) - -```text -effective(turn) = - turn.prompt 若该 turn 有 /prompt - 否则 document /prompt -``` - -turn 一旦带 `/prompt`,就整段替换文档基线:缺省的 `system` / `user` 视为空字符串, -**不**从文档继承。不合并更早的 turns。查询层按此计算;存储层不物化合并结果。 - -因此,只要某步与文档基线不同,导入必须把**当前完整 pair**写到该 turn -(即使只有一侧变了,未变的一侧也要写上,否则整段覆盖会把它打成空)。 - -两侧都空、且文档基线非空时,turn 必须显式写出 -`{"system":"","user":""}`,以便和「缺省 = 继承文档」区分。这种显式空 pair -是 turn 上唯一允许的「双空」`/prompt`。 - -## ACTF 映射(RFC-0004) - -`pair(step) := (system_prompt, user_content)`,空字符串就是空字符串,不是缺失。 - -| ACTF JSON Pointer | Storyline JSON Pointer | -|---|---| -| `/attempts/{a}/trajectory/steps/{s}/system_prompt` | 见下:文档 `/prompt/system` 或 `/turns/{t}/prompt/system` | -| `/attempts/{a}/trajectory/steps/{s}/user_content` | 见下:文档 `/prompt/user` 或 `/turns/{t}/prompt/user` | -| `/attempts/{a}/trajectory/steps/{s}/assistant_content/content` | `/turns/{t}/msg`(不变) | - -每个源 pointer 仍只有一个权威目标。同一对值不会同时写在文档和该 turn 上。 - -导入算法(单次顺序扫描即可,但空步若出现在基线之前,需要在基线确定后补写): - -1. `baseline` = 第一个至少一侧非空的 `pair`。写入文档 `/prompt`(空字符串键省略)。 - 若全部 step 都是双空,文档 `/prompt` 缺省。 -2. 对每个 step,按 turn 顺序: - - `pair == baseline`(双空对「无基线」也算相等)→ 已消费,turn 不写 `/prompt`。 - - 否则 → 该 turn `/prompt` = 当前完整 pair。双空且文档有基线时写 - `{"system":"","user":""}`。 -3. 基线落在 step `k`、且 `0..k-1` 为双空时:那些 turn 必须带显式空 - `/prompt`,否则还原会错误继承后来的基线。 - -导出: - -```text -system_prompt = effective(turn).system or "" -user_content = effective(turn).user or "" -``` - -ACTF 这两个键保持必填字符串;缺省写成 `""`,不再无条件空写。 - -`system_prompt` / `user_content` 不再列入残差表。attempt `extra` / `meta` 仍是残差。 - -## 其它格式 - -- OpenAI Messages:不写 `/prompt`。角色已经是独立 turn 的 `src` + `msg`。 -- ATIF / AgenticMD / Events:不扩张一等 schema。Storyline-only `/prompt` 走既有 - `_storyline` envelope,不进 ATIF `extra`。 -- 没有 `/prompt` 的普通 Storyline 导出 ACTF 时,两键仍为 `""`(合成转换,与现在一致)。 - -## Lance - -不新增表。JSON 列保存完整对象,不把 `system` / `user` 展平为独立列。 - -| 表 | 新列 | 内容 | -|---|---|---| -| `runs` | `prompt_json` | 文档 `/prompt`,或缺省 | -| `steps` | `prompt_json` | 该 turn 的 `/prompt`,或缺省 | - -旧表缺列按缺失解码。`content.rs` 的 `externalize_batch` 对缺列 skip。 - -## 校验 - -- `/prompt` 若存在:文档侧至少一个键为非空字符串。 -- turn 侧:至少一个非空键,或两个键都在且都是空字符串(显式清空)。 -- 空对象 `{}` 非法(文档与 turn 皆是)。 -- `system` / `user` 必须是字符串;其它 JSON 类型导入失败。 -- 未知键 `deny_unknown_fields`。 - -## 实现落点 - -- `crates/persisting-pchronicle/src/formats/storyline.rs`:`StorylinePrompt`,文档与 turn 字段 -- `crates/persisting-pchronicle/src/convert/actf.rs`:导入算法与导出还原 -- `crates/persisting-pchronicle/src/store/storyline/{model,rows,content}.rs`:`prompt_json` -- Gateway / CLI 测试里的 `StorylineTurn` / `StorylineDocument` 字面量补字段 -- RFC-0001 wire 表;RFC-0004 权威映射与残差表;`docs/src/pchronicle/design/storyline-lance.md` - -## 验收 - -1. 原 ACTF 语料导入 `storyline-lance` 后, - `/attempts/*/trajectory/steps/*/system_prompt` 与 - `/attempts/*/trajectory/steps/*/user_content` 不再出现在 unknown-field warning。 -2. 全程同一 pair:只出现文档 `/prompt`,turns 不写 `/prompt`;`msg` 仍是各步助手正文。 -3. 中途 pair 变化:变化步带完整 turn `/prompt`;未变步不写;导出还原每步原字符串。 -4. 前缀双空、其后出现非空基线:前缀 turn 带显式空 `/prompt`,导出仍是 `""`。 -5. ACTF → Storyline → ACTF 还原这两键,不再依赖 `unknown_fields`。 -6. 既有 ATIF / OpenAI fixture 与无 `/prompt` 的旧 Storyline 仍合法。 -7. 仍为残差:attempt `extra`、`meta`。 diff --git a/docs/superpowers/specs/2026-08-22-storyline-task-env-response-design.md b/docs/superpowers/specs/2026-08-22-storyline-task-env-response-design.md deleted file mode 100644 index 472cc9c5..00000000 --- a/docs/superpowers/specs/2026-08-22-storyline-task-env-response-design.md +++ /dev/null @@ -1,284 +0,0 @@ -# Storyline `task` / `env` / tool `response` 扩展 - -## Status - -Draft. Approved in conversation on 2026-08-22; amended the same day to put -eval budget on `/task`, `k` on `/task/llm`, and `started_at` / `finished_at` -on the Storyline document (plus turn-level `finished_at`). - -## Context - -ACTF 与 OpenAI Messages 导入 Storyline 时,评测结果、运行时环境和工具执行状态 -大量落入 `unknown_fields`。Storyline 现有 schema 是 ATIF-first:有 `final_metrics` -和任意 JSON 的 `tool_calls[].result`,没有文档级任务对象,也没有步级环境变化。 - -本设计给 `storyline/v1` 增加可选结构,不升 schema 版本: - -1. 根对象 `/task`:文档级环境、LLM 推理参数(先收 `k`)、评测结果与评测预算 -2. 根对象 `/started_at`、`/finished_at`:文档级时间窗 -3. `/turns/{t}/env`:相对 `/task/env` 的环境变化 -4. `/turns/{t}/finished_at`:该 turn 的结束时间(开始时间仍是 `/ts`) -5. `/tool_calls/{c}/kind` 与 `/tool_calls/{c}/response`:工具事件类型与结构化执行结果 - -`agent.model`、`turns[].model`、`effort` 仍是 model / thinking 的权威字段;本设计 -不把它们搬进 `/task/llm`。ACTF 的 `system_prompt` / `user_content` 不是运行时环境, -本设计不吸收;它们改由 -[2026-08-22-storyline-prompt-design.md](2026-08-22-storyline-prompt-design.md) -的 `/prompt` 承接。 - -## Goals - -1. 把两次导入 warning 中的评测结果、评测预算、文档/步时间窗、OpenAI `env_state` - 基础设施键、以及 ACTF 工具 `type` / `status` / `exit_code` 提升为 Storyline - 领域字段。`k` 作为文档级 LLM 推理参数进入 `/task/llm/k`。 -2. 每个源 JSON Pointer 仍只有一个权威目标;`/final_metrics` 上的 - `task_correct` / `correct` / `status` / `score` 改为从 `/task/result` 提升。 -3. 步级环境按「相对文档环境的差异」存储,还原时用 `/task/env` 覆盖该 turn 的 - `env`,不要求折叠更早的 turns。 -4. Lance 仍是 `runs` / `steps` / `tool_calls` 三表,不新增第四张表。 - -## Non-goals - -- 把 ACTF `system_prompt` / `user_content` 提升为一等字段(见后续 `/prompt` 规格)。 -- 把 attempt `extra` / `meta` 提升为一等字段。 -- 把已进入 `/metrics` 的 `env_state` 键(token、latency、`status_code`、 - `finish_reason` 等)再复制进 `env`。 -- 把 `agent.model` / `turns[].model` / `effort` 的权威目标改到 `/task/llm`。 -- 新增 `storyline/v2`,或修改 ATIF / AgenticMD / Events 的一等 schema。 -- 把 `env_state` 展平为独立 SQL 列。 -- 改变 TTAS、Queue、Search、`persisting-dlcapt`。 - -## Wire schema(RFC-0001 增补) - -`schema_version` 仍为 `storyline/v1`。新对象全部 optional;缺省或空对象不序列化。 -拥有字段的对象继续 `deny_unknown_fields`;`env.state` 的值是开放 JSON object。 - -保持全名(不引入短名):`task` / `env` / `llm` / `result` / `response` / -`started_at` / `finished_at`。`tool_calls[].kind` 与 `turns[].kind` 同名不同槽: -前者是工具事件类型,后者是叙事种类。`turns[].effective_kind()` 不得读取 -`tool_calls[].kind`。 - -根对象增加: - -| Wire | Type | Status | -|---|---|---| -| `task` | object | Optional | -| `started_at` | string \| number | Optional;与 turn `ts` 相同的时间表示 | -| `finished_at` | string \| number | Optional;与 turn `ts` 相同的时间表示 | - -`task` 保持全名(与 `agent` 相同)。内部: - -```text -/task -├── env 文档级环境:身份与稳定配置 -│ ├── name string? -│ ├── endpoint string? -│ ├── id string? -│ ├── event_type string? -│ ├── request_id string? -│ └── state object? 其余稳定基础设施键 -├── llm 文档级 LLM 推理参数 -│ └── k integer? ACTF `/k`;不是采样温度 -└── result 评测结果 + 文档级评测预算 - ├── task_correct bool? 源文档根级 correct(可与 attempt 不同) - ├── correct bool? - ├── final_answer any? - ├── ground_truth any? - ├── status string? - ├── score any? - ├── error string? - ├── artifacts any? - ├── category string? - ├── attempts_tried integer? - ├── solved_at string? - ├── retry_count any? - └── retry_counts any? -``` - -`/k` 的权威目标是 `/task/llm/k`,不是 `/task/result`。ACTF 源里它是 attempt 预算, -Storyline 按调用方约定把它放在 LLM 推理参数槽;导出 ACTF 仍写回根 `/k`。 - -`turns[]` 增加: - -| Wire | Type | Status | -|---|---|---| -| `env` | object | Optional;形状与 `/task/env` 相同 | -| `finished_at` | string \| number | Optional;该 turn 结束时间,表示规则与 `ts` 相同 | - -开始时间仍是 `/turns/{t}/ts`,不新增 turn 级 `started_at`。`copied == true` 的 -context turn 不得写 `env`。OpenAI 一行拆成 request + response 时,`env` 只挂在 -response turn。`finished_at` 同样只写在产生该 step 语义的 turn 上(ACTF:该 -step 对应的唯一 turn;OpenAI:response turn)。 - -`tool_calls[]` 增加: - -| Wire | Type | Status | -|---|---|---| -| `kind` | string | Optional;工具事件类型,与 turn 的叙事 `kind` 不是同一个字段 | -| `response` | object | Optional | - -```text -/tool_calls/{c}/response -├── status string? -└── exit_code integer? -``` - -现有 `result` 仍是输出体(ACTF `aggregated_output`)。没有 `status` 且没有 -`exit_code` 时不写 `response`。 - -校验增补: - -- `/task` 若存在,则 `env`、`llm`、`result` 至少有一个含非 null 字段。 -- `/started_at`、`/finished_at`、`turns[].finished_at` 的合法值与 `ts` 相同: - RFC3339 字符串,或可精确表示为纳秒的 Unix epoch 秒数。 -- 若文档同时有 `/started_at` 与 `/finished_at`,不得要求 `finished_at >= started_at` - 以外的推导(源格式可以记录与 turn 窗口不一致的轨迹时间)。 -- `task.llm.k` 若出现,必须是正整数。 -- `tool_calls[].kind` 非空字符串;空字符串视为缺失。 -- `exit_code` 必须是整数(含负值);JSON number 带小数则导入失败。 -- 还原某 turn 的完整环境:以 `/task/env` 为底,对象浅合并该 turn 的 `env` - (turn 侧同名键覆盖;`state` 也是浅合并)。不合并更早的 turns。 - 查询层按此规则计算;存储层不物化合并结果。 - -## 环境键分流 - -文档级 `/task/env` 只保存 session 内作为默认配置的键。步级 `/turns/{t}/env` -只保存本步才有、或相对文档环境发生变化的键。 - -首个为该键提供非空值的 OpenAI row 写入 `/task/env`(见下表「稳定键」)。后续 -row:值相等则视为冗余别名并消费;值不等则写入该 row 的 response turn -`/env`(环境变化)。不得因后续行不一致而失败。 - -只存在于步上的键从不写入 `/task/env`。 - -已进入 `/metrics` 的 `env_state` 键不进入 `env`。OpenAI `env_state.created_at` / -`completed_at` 仍只在 `/metrics`,不提升为文档 `/started_at` / `/finished_at`。 - -## ACTF 映射变更(RFC-0004) - -权威目标搬家或从残差提升: - -| ACTF JSON Pointer | 新权威目标 | -|---|---| -| `/correct` | `/task/result/task_correct` | -| `/k` | `/task/llm/k` | -| `/category` | `/task/result/category` | -| `/attempts_tried` | `/task/result/attempts_tried` | -| `/solved_at` | `/task/result/solved_at` | -| `/retry_count` | `/task/result/retry_count` | -| `/retry_counts` | `/task/result/retry_counts` | -| `/attempts/{a}/correct` | `/task/result/correct` | -| `/attempts/{a}/status` | `/task/result/status` | -| `/attempts/{a}/score` | `/task/result/score` | -| `/attempts/{a}/final_answer` | `/task/result/final_answer` | -| `/attempts/{a}/ground_truth` | `/task/result/ground_truth` | -| `/attempts/{a}/error` | `/task/result/error` | -| `/attempts/{a}/artifacts` | `/task/result/artifacts` | -| `/attempts/{a}/trajectory/started_at` | `/started_at` | -| `/attempts/{a}/trajectory/finished_at` | `/finished_at` | -| `/attempts/{a}/trajectory/steps/{s}/finished_at` | `/turns/{t}/finished_at` | -| `/attempts/{a}/trajectory/steps/{s}/tools/{c}/type` | `/turns/{t}/tool_calls/{c}/kind` | -| `/attempts/{a}/trajectory/steps/{s}/tools/{c}/status` | `/turns/{t}/tool_calls/{c}/response/status` | -| `/attempts/{a}/trajectory/steps/{s}/tools/{c}/exit_code` | `/turns/{t}/tool_calls/{c}/response/exit_code` | - -`steps/{s}/started_at` 仍只映射到 `/turns/{t}/ts`,即使它与文档 `/started_at` -数值相同,也不把文档时间当成 step 时间的第二权威目标。 - -`assistant_content.tool_calls` 必须与 `tools` 相等;其上的 `type` / `status` / -`exit_code` 视为已消费,不另写目标,不进残差。 - -`name` 为空或缺失时 `/fn` 仍可由同 call 的 `kind` 派生。`type` 的唯一权威目标是 -`/kind`,`/fn` 不是它的第二权威目标。 - -便利提升:将 `/task/result` 的 `task_correct` / `correct` / `status` / `score` -再写入 `/final_metrics` 同名键。`analysis_result` 仍只在 `/final_metrics`。 - -ACTF 没有 OpenAI `env_state` 时,`/task/env` 与 `/turns/{t}/env` 缺省。 - -attempt `extra` / `meta` 现进入文档 `/extra` / `/meta`,不再作为残差。 - -`system_prompt` / `user_content` 不再由本规格吸收;见 -[2026-08-22-storyline-prompt-design.md](2026-08-22-storyline-prompt-design.md)。 - -空字符串 `error`、空 `artifacts`、空 `solved_at` 按缺失省略。 - -## OpenAI Messages 映射变更(RFC-0009) - -稳定键(首条非空值 → `/task/env`;后续相等则消费): - -| OpenAI JSON Pointer | Storyline JSON Pointer | -|---|---| -| `/session_steps/{r}/env_name` | `/task/env/name` | -| `/session_steps/{r}/meta_json/env_state/endpoint` | `/task/env/endpoint` | -| `/session_steps/{r}/dataset_type` | `/task/env/state/dataset_type` | -| `/session_steps/{r}/dt` | `/task/env/state/dt` | -| `/session_steps/{r}/meta_json/group_id` | `/task/env/state/group_id` | -| `/session_steps/{r}/meta_json/env_state/redaction_policy` | `/task/env/state/redaction_policy` | -| `/session_steps/{r}/meta_json/env_state/upstream_base_url` | `/task/env/state/upstream_base_url` | -| `/session_steps/{r}/meta_json/env_state/weight_version` | `/task/env/state/weight_version` | - -步级键(只进 response turn): - -| OpenAI JSON Pointer | Storyline JSON Pointer | -|---|---| -| `/session_steps/{r}/id` | `/turns/{response-t}/env/id` | -| `/session_steps/{r}/meta_json/env_state/event_type` | `/turns/{response-t}/env/event_type` | -| `/session_steps/{r}/meta_json/env_state/request_id` | `/turns/{response-t}/env/request_id` | - -OpenAI 没有 ACTF 评测结果、`k`、文档时间窗或工具 `response` 时,对应 Storyline -字段缺省。row `created_at` 仍只权威写入 response `/ts`,不写文档 `/started_at`。 - -## Lance 投影 - -不新增表。JSON 列保存完整对象,不把 `env_state` 展平为独立列。 - -| 表 | 新列 | 内容 | -|---|---|---| -| `runs` | `task` | `/task` 对象,或缺省 | -| `runs` | `started_at` | 文档开始时间,或缺省 | -| `runs` | `finished_at` | 文档结束时间,或缺省 | -| `steps` | `env` | 该 turn 的 `/env`,或缺省 | -| `steps` | `finished_at` | 该 turn 结束时间,或缺省 | -| `tool_calls` | `kind` | 字符串,或缺省 | -| `tool_calls` | `response` | `{status, exit_code}` 对象,或缺省 | - -查询完整环境时由查询层合并 `runs.task.env` 与 `steps.env`;存储层不物化合并结果。 - -## 跨格式 - -- ACTF ↔ Storyline、OpenAI Messages ↔ Storyline:按上表还原。 -- Storyline 一等字段不是 `unknown_fields`。ATIF 本设计不新增一等键;Storyline → - ATIF 时,ATIF 无法表达的 `/task`、`/started_at`、`/finished_at`、`turns[].env`、 - `turns[].finished_at`、`tool_calls[].kind` / `response` 走既有 `_storyline` - envelope,不得塞进 ATIF `extra`(`extra` 仍 1:1 对应 Storyline `extra`)。 -- AgenticMD / Events 同理:不扩张其 schema;经 Storyline 的 roundtrip 必须能恢复 - 这些新字段。 - -## 实现落点(文档,非本阶段改代码) - -- `crates/persisting-pchronicle/src/formats/storyline.rs`:wire 结构 -- `crates/persisting-pchronicle/src/store/storyline/model.rs`:三表行 -- `crates/persisting-pchronicle/src/convert/actf.rs` 与 RFC-0004 -- `crates/persisting-pchronicle/src/formats/openai_corpus.rs` 与 RFC-0009 -- `docs/src/rfcs/0001-storyline-format.md` 增补 wire 表 - -## 验收 - -1. 用产生原 ACTF warning 的语料导入 `storyline-lance` 后,下列 key 不再出现在 - unknown-field warning:`artifacts`、`error`、`final_answer`、`ground_truth`、 - `category`、`k`、`attempts_tried`、`solved_at`、`retry_count`、`retry_counts`、 - `trajectory/started_at`、`trajectory/finished_at`、`steps/*/finished_at`、 - `tools/*/type`、`tools/*/status`、`tools/*/exit_code`,以及 - `assistant_content/tool_calls` 上的同名三键。 -2. 用产生原 OpenAI warning 的 `session_steps.json` 导入后,下列 key 不再 warning: - `dataset_type`、`dt`、`env_name`、`id`、`meta_json/group_id`、 - `env_state/endpoint`、`event_type`、`redaction_policy`、`request_id`、 - `upstream_base_url`、`weight_version`。 -3. ACTF → Storyline → ACTF 还原上表权威字段;`type` 从 `/kind` 还原,`k` 从 - `/task/llm/k` 还原,不再依赖 `unknown_fields`。 -4. OpenAI → Storyline → OpenAI 还原上表环境键;session 内变化的稳定键出现在对应 - step 的源字段上,而不是被首条覆盖。 -5. 既有 ATIF fixture 与 `final_metrics` 提升行为保持:有 `/task/result` 时 - `/final_metrics` 含对应键;无 `/task` 的旧文档仍合法。 -6. 仍为残差(允许继续 warning):attempt `extra`、`meta`。 - `system_prompt` / `user_content` 改由 `/prompt` 规格验收,不再列为本规格残差。 diff --git a/pchronicle-web/Cargo.lock b/pchronicle-web/Cargo.lock index 33c38407..ce859f76 100644 --- a/pchronicle-web/Cargo.lock +++ b/pchronicle-web/Cargo.lock @@ -1940,9 +1940,11 @@ dependencies = [ "gloo-net", "serde", "serde_json", + "time", "urlencoding", "wasm-bindgen", "web-sys", + "web-time", ] [[package]] diff --git a/pchronicle-web/Cargo.toml b/pchronicle-web/Cargo.toml index 492a567e..066ceb4d 100644 --- a/pchronicle-web/Cargo.toml +++ b/pchronicle-web/Cargo.toml @@ -13,9 +13,11 @@ futures-util = "0.3" gloo-net = { version = "0.6", features = ["http"] } serde = { version = "1", features = ["derive"] } serde_json = "1" +time = { version = "=0.3.55", features = ["formatting", "parsing"] } urlencoding = "2" wasm-bindgen = "0.2" -web-sys = { version = "0.3", features = ["Document", "DomRect", "Element", "History", "HtmlElement", "KeyboardEvent", "Location", "Storage", "UrlSearchParams", "Window"] } +web-sys = { version = "0.3", features = ["Document", "DomRect", "Element", "History", "HtmlElement", "HtmlTextAreaElement", "KeyboardEvent", "Location", "Storage", "UrlSearchParams", "Window"] } +web-time = "=1.1.0" [profile.dev] debug = 0 diff --git a/pchronicle-web/assets/analysis.css b/pchronicle-web/assets/analysis.css index c554d7d0..5dfa9ca0 100644 --- a/pchronicle-web/assets/analysis.css +++ b/pchronicle-web/assets/analysis.css @@ -1 +1,23 @@ -.pc2-detail-tabs{display:flex;align-items:center;gap:3px;margin:0 20px 10px;border-bottom:1px solid #dfe3e8}.pc2-detail-tabs button{position:relative;padding:8px 12px;border:0;background:transparent;color:#667085;font-size:10px;font-weight:700;cursor:pointer}.pc2-detail-tabs button:after{position:absolute;right:7px;bottom:-1px;left:7px;height:2px;border-radius:2px;background:transparent;content:""}.pc2-detail-tabs button.active{color:#1d4ed8}.pc2-detail-tabs button.active:after{background:#2563eb}.pc2-detail-tabs button:focus-visible,.pc2-analysis-tabs button:focus-visible,.pc2-turn-bars button:focus-visible{outline:2px solid #60a5fa;outline-offset:2px}.pc2-detail-tabs>span{margin-left:auto;color:#98a2b3;font-size:9px}.pc2-analysis-workspace{min-height:0;display:flex;flex:1;flex-direction:column;margin:0 20px 18px;border:1px solid #dfe3e8;border-radius:10px;background:#fff;overflow:hidden}.pc2-analysis-tabs{display:flex;align-items:center;gap:4px;padding:8px 11px;border-bottom:1px solid #e8ebef;background:#f8fafc}.pc2-analysis-tabs button{padding:6px 10px;border:1px solid transparent;border-radius:6px;background:transparent;color:#667085;font-size:9px;font-weight:700;cursor:pointer}.pc2-analysis-tabs button:hover{background:#fff;color:#344054}.pc2-analysis-tabs button.active{border-color:#bfdbfe;background:#eff6ff;color:#1d4ed8;box-shadow:0 1px 2px #1018280a}.pc2-analysis-scroll{min-height:0;flex:1;padding:12px;overflow:auto;scrollbar-gutter:stable}.pc2-analysis-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:11px}.pc2-analysis-card{min-width:0;padding:13px;border:1px solid #e4e7ec;border-radius:9px;background:#fff;box-shadow:0 1px 2px #10182808}.pc2-analysis-card.wide{grid-column:1/-1}.pc2-analysis-card>header{min-height:35px;display:flex;align-items:flex-start;justify-content:space-between;gap:12px;margin-bottom:11px}.pc2-analysis-card h3{margin:0;color:#1d2939;font-size:11px}.pc2-analysis-card header p{margin:3px 0 0;color:#98a2b3;font-size:8px;line-height:1.4}.pc2-analysis-card header>span,.pc2-analysis-card header>strong{color:#667085;font:600 9px ui-monospace,SFMono-Regular,Menlo,monospace}.pc2-analysis-empty{min-height:90px;display:flex;align-items:center;justify-content:center;color:#98a2b3;font-size:9px;text-align:center}.pc2-dimension-list{display:flex;flex-direction:column;gap:9px}.pc2-dimension-row>div{display:flex;align-items:center;justify-content:space-between;gap:8px}.pc2-dimension-row>div span{overflow:hidden;color:#475467;font-size:9px;font-weight:600;text-overflow:ellipsis;white-space:nowrap}.pc2-dimension-row code{color:#344054;font-size:9px}.pc2-dimension-track,.pc2-coverage-track,.pc2-mini-track{display:block;height:6px;margin-top:4px;border-radius:999px;background:#eef1f5;overflow:hidden}.pc2-dimension-track i,.pc2-coverage-track i,.pc2-mini-track i{display:block;height:100%;border-radius:inherit;background:#60a5fa}.pc2-dimension-list.violet .pc2-dimension-track i{background:#a78bfa}.pc2-dimension-list.green .pc2-dimension-track i{background:#34d399}.pc2-dimension-list.amber .pc2-dimension-track i{background:#f59e0b}.pc2-dimension-list.red .pc2-dimension-track i{background:#ef4444}.pc2-dimension-row small{display:block;margin-top:3px;color:#98a2b3;font-size:7px}.pc2-coverage-list{display:flex;flex-direction:column;gap:11px}.pc2-coverage-list>div>div{display:flex;justify-content:space-between;color:#667085;font-size:9px}.pc2-coverage-list code{color:#344054;font-size:8px}.pc2-coverage-track{height:7px;margin-top:5px}.pc2-coverage-track i{background:linear-gradient(90deg,#2563eb,#60a5fa)}.pc2-run-span>code{display:block;padding:10px;border:1px solid #e4e7ec;border-radius:7px;background:#f8fafc;color:#344054;font-size:9px;white-space:normal;word-break:break-word}.pc2-run-span-meta{display:flex;flex-wrap:wrap;gap:8px;margin-top:10px}.pc2-run-span-meta span{padding:5px 7px;border-radius:6px;background:#f2f4f7;color:#667085;font-size:8px}.pc2-run-span-meta strong{color:#344054;font-size:9px}.pc2-histogram{height:155px;display:flex;align-items:stretch;gap:7px;padding-top:8px}.pc2-histogram-column{min-width:0;display:grid;grid-template-rows:16px 1fr 22px;flex:1;text-align:center}.pc2-histogram-column>span{color:#667085;font:600 8px ui-monospace,SFMono-Regular,Menlo,monospace}.pc2-histogram-column>div{display:flex;align-items:flex-end;border-bottom:1px solid #d0d5dd;background:linear-gradient(180deg,transparent,#f8fafc)}.pc2-histogram-column i{width:70%;min-height:2px;margin:0 auto;border-radius:4px 4px 0 0;background:linear-gradient(180deg,#60a5fa,#2563eb)}.pc2-histogram-column small{padding-top:5px;color:#98a2b3;font-size:7px;white-space:nowrap}.pc2-percentiles{display:flex;flex-direction:column;gap:12px}.pc2-percentile>div{display:flex;align-items:center;justify-content:space-between;color:#667085;font-size:9px}.pc2-percentile code{color:#344054;font-size:9px}.pc2-percentile>span{display:block;height:8px;margin-top:4px;border-radius:999px;background:#eef1f5;overflow:hidden}.pc2-percentile i{display:block;height:100%;border-radius:inherit;background:linear-gradient(90deg,#93c5fd,#2563eb)}.pc2-percentile-coverage{padding-top:8px;border-top:1px solid #eef0f3;color:#98a2b3;font-size:8px}.pc2-turn-chart{min-height:190px}.pc2-turn-bars{height:180px;display:flex;align-items:flex-end;gap:2px;padding:8px 4px 0;border-bottom:1px solid #d0d5dd;background:repeating-linear-gradient(to top,#f8fafc 0,#f8fafc 1px,transparent 1px,transparent 45px)}.pc2-turn-bars button{min-width:2px;display:flex;align-items:flex-end;align-self:stretch;flex:1;padding:0;border:0;background:transparent;cursor:pointer}.pc2-turn-bars button:hover{background:#eff6ff}.pc2-turn-bars i{width:100%;min-height:2px;border-radius:3px 3px 0 0;background:#60a5fa}.pc2-turn-bars i.agent{background:#34d399}.pc2-turn-bars i.system{background:#fbbf24}.pc2-turn-bars i.user{background:#60a5fa}.pc2-chart-note{margin:6px 0 0;color:#98a2b3;font-size:7px;text-align:right}.pc2-ranked-list{display:flex;flex-direction:column}.pc2-ranked-list>button{display:grid;grid-template-columns:25px minmax(0,1fr) 75px;gap:8px;align-items:center;padding:8px;border:0;border-top:1px solid #eef0f3;background:#fff;text-align:left;cursor:pointer}.pc2-ranked-list>button:first-child{border-top:0}.pc2-ranked-list>button:hover{background:#f7faff}.pc2-rank{width:20px;height:20px;display:grid;place-items:center;border-radius:5px;background:#eff6ff;color:#2563eb;font:700 8px ui-monospace,SFMono-Regular,Menlo,monospace}.pc2-ranked-copy{min-width:0;display:flex;flex-direction:column;gap:2px}.pc2-ranked-copy strong,.pc2-ranked-copy small{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.pc2-ranked-copy strong{color:#344054;font-size:9px}.pc2-ranked-copy small{color:#98a2b3;font-size:8px}.pc2-ranked-list code{color:#475467;font-size:9px;text-align:right}.pc2-token-track{height:14px;display:flex;border-radius:999px;background:#eef1f5;overflow:hidden}.pc2-token-track i{height:100%}.pc2-token-track .prompt,.pc2-token-legend .prompt{background:#2563eb}.pc2-token-track .completion,.pc2-token-legend .completion{background:#a78bfa}.pc2-token-legend{display:flex;gap:14px;margin-top:8px;color:#667085;font-size:8px}.pc2-token-legend span{display:flex;align-items:center;gap:5px}.pc2-token-legend i{width:7px;height:7px;border-radius:2px}.pc2-tool-table{min-width:720px}.pc2-tool-head,.pc2-tool-row{display:grid;grid-template-columns:minmax(180px,2fr) repeat(5,minmax(70px,1fr));gap:10px;align-items:center}.pc2-tool-head{padding:7px 8px;border-bottom:1px solid #dfe3e8;color:#98a2b3;font-size:7px;font-weight:700;text-transform:uppercase;letter-spacing:.05em}.pc2-tool-row{min-height:48px;padding:7px 8px;border-bottom:1px solid #eef0f3;color:#475467;font-size:9px}.pc2-tool-row:last-child{border-bottom:0}.pc2-tool-row>div:first-child{min-width:0}.pc2-tool-row strong{display:block;overflow:hidden;color:#344054;font-size:9px;text-overflow:ellipsis;white-space:nowrap}.pc2-tool-row code{font-size:8px}.pc2-mini-track{width:110px;height:4px}.pc2-tool-errors{width:max-content;padding:2px 6px;border-radius:999px;background:#f2f4f7;color:#667085;font-size:8px}.pc2-tool-errors.active{background:#fff1f0;color:#b42318}@media(max-width:1150px){.pc2-analysis-grid{grid-template-columns:1fr}.pc2-analysis-card.wide{grid-column:auto}.pc2-detail-tabs>span{display:none}}@media(max-width:850px){.pc2-detail-tabs{margin-right:12px;margin-left:12px}.pc2-analysis-workspace{margin-right:12px;margin-left:12px}.pc2-analysis-tabs{overflow-x:auto}.pc2-analysis-tabs button{white-space:nowrap}.pc2-analysis-scroll{padding:8px}.pc2-tool-table{overflow-x:auto}} +.pc2-detail-tabs{display:flex;align-items:center;gap:3px;margin:0 20px 10px;border-bottom:1px solid #dfe3e8}.pc2-detail-tabs button{position:relative;padding:8px 12px;border:0;background:transparent;color:#667085;font-size:10px;font-weight:700;cursor:pointer}.pc2-detail-tabs button:after{position:absolute;right:7px;bottom:-1px;left:7px;height:2px;border-radius:2px;background:transparent;content:""}.pc2-detail-tabs button.active{color:#1d4ed8}.pc2-detail-tabs button.active:after{background:#2563eb}.pc2-detail-tabs button:focus-visible,.pc2-analysis-tabs button:focus-visible,.pc2-turn-bars button:focus-visible{outline:2px solid #60a5fa;outline-offset:2px}.pc2-detail-tabs>span{margin-left:auto;color:#98a2b3;font-size:9px}.pc2-analysis-workspace{min-height:0;display:flex;flex:1;flex-direction:column;margin:0 20px 18px;border:1px solid #dfe3e8;border-radius:10px;background:#fff;overflow:hidden}.pc2-analysis-tabs{display:flex;align-items:center;gap:4px;padding:8px 11px;border-bottom:1px solid #e8ebef;background:#f8fafc}.pc2-analysis-tabs button{padding:6px 10px;border:1px solid transparent;border-radius:6px;background:transparent;color:#667085;font-size:9px;font-weight:700;cursor:pointer}.pc2-analysis-tabs button:hover{background:#fff;color:#344054}.pc2-analysis-tabs button.active{border-color:#bfdbfe;background:#eff6ff;color:#1d4ed8;box-shadow:0 1px 2px #1018280a}.pc2-analysis-scroll{min-height:0;flex:1;padding:12px;overflow:auto;scrollbar-gutter:stable}.pc2-analysis-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:11px}.pc2-analysis-card{min-width:0;padding:13px;border:1px solid #e4e7ec;border-radius:9px;background:#fff;box-shadow:0 1px 2px #10182808}.pc2-analysis-card.wide{grid-column:1/-1}.pc2-analysis-card>header{min-height:35px;display:flex;align-items:flex-start;justify-content:space-between;gap:12px;margin-bottom:11px}.pc2-analysis-card h3{margin:0;color:#1d2939;font-size:11px}.pc2-analysis-card header p{margin:3px 0 0;color:#98a2b3;font-size:8px;line-height:1.4}.pc2-analysis-card header>span,.pc2-analysis-card header>strong{color:#667085;font:600 9px ui-monospace,SFMono-Regular,Menlo,monospace}.pc2-analysis-empty{min-height:90px;display:flex;align-items:center;justify-content:center;color:#98a2b3;font-size:9px;text-align:center}.pc2-dimension-list{display:flex;flex-direction:column;gap:9px}.pc2-dimension-row>div{display:flex;align-items:center;justify-content:space-between;gap:8px}.pc2-dimension-row>div span{overflow:hidden;color:#475467;font-size:9px;font-weight:600;text-overflow:ellipsis;white-space:nowrap}.pc2-dimension-row code{color:#344054;font-size:9px}.pc2-dimension-track,.pc2-coverage-track,.pc2-mini-track{display:block;height:6px;margin-top:4px;border-radius:999px;background:#eef1f5;overflow:hidden}.pc2-dimension-track i,.pc2-coverage-track i,.pc2-mini-track i{display:block;height:100%;border-radius:inherit;background:#60a5fa}.pc2-dimension-list.violet .pc2-dimension-track i{background:#a78bfa}.pc2-dimension-list.green .pc2-dimension-track i{background:#34d399}.pc2-dimension-list.amber .pc2-dimension-track i{background:#f59e0b}.pc2-dimension-list.red .pc2-dimension-track i{background:#ef4444}.pc2-dimension-row small{display:block;margin-top:3px;color:#98a2b3;font-size:7px}.pc2-coverage-list{display:flex;flex-direction:column;gap:11px}.pc2-coverage-list>div>div{display:flex;justify-content:space-between;color:#667085;font-size:9px}.pc2-coverage-list code{color:#344054;font-size:8px}.pc2-coverage-track{height:7px;margin-top:5px}.pc2-coverage-track i{background:linear-gradient(90deg,#2563eb,#60a5fa)}.pc2-run-span>code{display:block;padding:10px;border:1px solid #e4e7ec;border-radius:7px;background:#f8fafc;color:#344054;font-size:9px;white-space:normal;word-break:break-word}.pc2-run-span-meta{display:flex;flex-wrap:wrap;gap:8px;margin-top:10px}.pc2-run-span-meta span{padding:5px 7px;border-radius:6px;background:#f2f4f7;color:#667085;font-size:8px}.pc2-run-span-meta strong{color:#344054;font-size:9px}.pc2-histogram{height:155px;display:flex;align-items:stretch;gap:7px;padding-top:8px}.pc2-histogram-column{min-width:0;display:grid;grid-template-rows:16px 1fr 22px;flex:1;text-align:center}.pc2-histogram-column>span{color:#667085;font:600 8px ui-monospace,SFMono-Regular,Menlo,monospace}.pc2-histogram-column>div{display:flex;align-items:flex-end;border-bottom:1px solid #d0d5dd;background:linear-gradient(180deg,transparent,#f8fafc)}.pc2-histogram-column i{width:70%;min-height:2px;margin:0 auto;border-radius:4px 4px 0 0;background:linear-gradient(180deg,#60a5fa,#2563eb)}.pc2-histogram-column small{padding-top:5px;color:#98a2b3;font-size:7px;white-space:nowrap}.pc2-percentiles{display:flex;flex-direction:column;gap:12px}.pc2-percentile>div{display:flex;align-items:center;justify-content:space-between;color:#667085;font-size:9px}.pc2-percentile code{color:#344054;font-size:9px}.pc2-percentile>span{display:block;height:8px;margin-top:4px;border-radius:999px;background:#eef1f5;overflow:hidden}.pc2-percentile i{display:block;height:100%;border-radius:inherit;background:linear-gradient(90deg,#93c5fd,#2563eb)}.pc2-percentile-coverage{padding-top:8px;border-top:1px solid #eef0f3;color:#98a2b3;font-size:8px}.pc2-turn-chart{min-height:190px}.pc2-turn-bars{height:180px;display:flex;align-items:flex-end;gap:2px;padding:8px 4px 0;border-bottom:1px solid #d0d5dd;background:repeating-linear-gradient(to top,#f8fafc 0,#f8fafc 1px,transparent 1px,transparent 45px)}.pc2-turn-bars button{min-width:2px;display:flex;align-items:flex-end;align-self:stretch;flex:1;padding:0;border:0;background:transparent;cursor:pointer}.pc2-turn-bars button:hover{background:#eff6ff}.pc2-turn-bars i{width:100%;min-height:2px;border-radius:3px 3px 0 0;background:#60a5fa}.pc2-turn-bars i.agent{background:#34d399}.pc2-turn-bars i.system{background:#fbbf24}.pc2-turn-bars i.user{background:#60a5fa}.pc2-chart-note{margin:6px 0 0;color:#98a2b3;font-size:7px;text-align:right}.pc2-ranked-list{display:flex;flex-direction:column}.pc2-ranked-list>button{display:grid;grid-template-columns:25px minmax(0,1fr) 75px;gap:8px;align-items:center;padding:8px;border:0;border-top:1px solid #eef0f3;background:#fff;text-align:left;cursor:pointer}.pc2-ranked-list>button:first-child{border-top:0}.pc2-ranked-list>button:hover{background:#f7faff}.pc2-rank{width:20px;height:20px;display:grid;place-items:center;border-radius:5px;background:#eff6ff;color:#2563eb;font:700 8px ui-monospace,SFMono-Regular,Menlo,monospace}.pc2-ranked-copy{min-width:0;display:flex;flex-direction:column;gap:2px}.pc2-ranked-copy strong,.pc2-ranked-copy small{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.pc2-ranked-copy strong{color:#344054;font-size:9px}.pc2-ranked-copy small{color:#98a2b3;font-size:8px}.pc2-ranked-list code{color:#475467;font-size:9px;text-align:right}.pc2-token-track{height:14px;display:flex;border-radius:999px;background:#eef1f5;overflow:hidden}.pc2-token-track i{height:100%}.pc2-token-track .prompt,.pc2-token-legend .prompt{background:#2563eb}.pc2-token-track .completion,.pc2-token-legend .completion{background:#a78bfa}.pc2-token-legend{display:flex;gap:14px;margin-top:8px;color:#667085;font-size:8px}.pc2-token-legend span{display:flex;align-items:center;gap:5px}.pc2-token-legend i{width:7px;height:7px;border-radius:2px}.pc2-tool-table{min-width:720px}.pc2-tool-head,.pc2-tool-row{display:grid;grid-template-columns:minmax(180px,2fr) repeat(5,minmax(70px,1fr));gap:10px;align-items:center}.pc2-tool-head{padding:7px 8px;border-bottom:1px solid #dfe3e8;color:#98a2b3;font-size:7px;font-weight:700;text-transform:uppercase;letter-spacing:.05em}.pc2-tool-row{min-height:48px;padding:7px 8px;border-bottom:1px solid #eef0f3;color:#475467;font-size:9px}.pc2-tool-row:last-child{border-bottom:0}.pc2-tool-row>div:first-child{min-width:0}.pc2-tool-row strong{display:block;overflow:hidden;color:#344054;font-size:9px;text-overflow:ellipsis;white-space:nowrap}.pc2-tool-row code{font-size:8px}.pc2-mini-track{width:110px;height:4px}.pc2-tool-errors{width:max-content;padding:2px 6px;border-radius:999px;background:#f2f4f7;color:#667085;font-size:8px}.pc2-tool-errors.active{background:#fff1f0;color:#b42318}@media(max-width:1150px){.pc2-analysis-grid{grid-template-columns:1fr}.pc2-analysis-card.wide{grid-column:auto}.pc2-detail-tabs>span{display:none}}@media(max-width:850px){.pc2-detail-tabs{margin-right:12px;margin-left:12px}.pc2-analysis-workspace{margin-right:12px;margin-left:12px}.pc2-analysis-tabs{overflow-x:auto}.pc2-analysis-tabs button{white-space:nowrap}.pc2-analysis-scroll{padding:8px}.pc2-tool-table{overflow-x:auto}.pc2-trace-overview{margin-right:12px;margin-left:12px;grid-template-columns:1fr 1fr}} +.pc2-trace-overview{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:8px;margin:0 20px 8px} +.pc2-trace-overview-card{min-width:0;display:flex;flex-direction:column;gap:6px;padding:8px 10px;border:1px solid #e4e7ec;border-radius:8px;background:#fff;color:inherit;text-align:left;cursor:pointer} +.pc2-trace-overview-card:hover,.pc2-trace-overview-card:focus-visible{border-color:#93c5fd;background:#f7faff;outline:0} +.pc2-trace-overview-title{color:#667085;font-size:10px;font-weight:700;letter-spacing:.05em;text-transform:uppercase} +.pc2-trace-overview-empty{color:#98a2b3;font-size:11px} +.pc2-mix-track{display:flex;height:8px;overflow:hidden;border-radius:999px;background:#eef1f5} +.pc2-mix-track i{display:block;height:100%;min-width:0} +.pc2-mix-track.blue i:nth-child(1){background:#2563eb}.pc2-mix-track.blue i:nth-child(2){background:#60a5fa}.pc2-mix-track.blue i:nth-child(3){background:#93c5fd}.pc2-mix-track.blue i:nth-child(4){background:#cbd5e1} +.pc2-mix-track.violet i:nth-child(1){background:#7c3aed}.pc2-mix-track.violet i:nth-child(2){background:#a78bfa}.pc2-mix-track.violet i:nth-child(3){background:#c4b5fd}.pc2-mix-track.violet i:nth-child(4){background:#cbd5e1} +.pc2-mix-track.green i:nth-child(1){background:#059669}.pc2-mix-track.green i:nth-child(2){background:#34d399}.pc2-mix-track.green i:nth-child(3){background:#6ee7b7}.pc2-mix-track.green i:nth-child(4){background:#cbd5e1} +.pc2-mix-legend{display:flex;flex-wrap:wrap;gap:4px 8px;min-height:16px} +.pc2-mix-key{overflow:hidden;color:#475467;font-size:11px;text-overflow:ellipsis;white-space:nowrap} +.pc2-mix-key:before{display:inline-block;width:6px;height:6px;margin-right:4px;border-radius:50%;vertical-align:middle;background:currentColor;content:""} +.pc2-mix-key.blue.n0:before{background:#2563eb}.pc2-mix-key.blue.n1:before{background:#60a5fa}.pc2-mix-key.blue.n2:before{background:#93c5fd}.pc2-mix-key.blue.n3:before{background:#cbd5e1} +.pc2-mix-key.violet.n0:before{background:#7c3aed}.pc2-mix-key.violet.n1:before{background:#a78bfa}.pc2-mix-key.violet.n2:before{background:#c4b5fd}.pc2-mix-key.violet.n3:before{background:#cbd5e1} +.pc2-mix-key.green.n0:before{background:#059669}.pc2-mix-key.green.n1:before{background:#34d399}.pc2-mix-key.green.n2:before{background:#6ee7b7}.pc2-mix-key.green.n3:before{background:#cbd5e1} +.pc2-mini-coverage{display:grid;grid-template-columns:1fr 1fr;gap:5px 10px} +.pc2-mini-coverage-row{min-width:0;display:grid;grid-template-columns:minmax(0,1fr) auto;gap:2px 6px;align-items:center} +.pc2-mini-coverage-row>span{overflow:hidden;color:#667085;font-size:10px;text-overflow:ellipsis;white-space:nowrap} +.pc2-mini-coverage-row>code{color:#344054;font-size:10px} +.pc2-mini-coverage-row>.pc2-coverage-track{grid-column:1/-1;height:5px;margin-top:0} +@media(max-width:1150px){.pc2-trace-overview{grid-template-columns:repeat(2,minmax(0,1fr))}} diff --git a/pchronicle-web/assets/analyze-workspace.css b/pchronicle-web/assets/analyze-workspace.css new file mode 100644 index 00000000..1338c3a9 --- /dev/null +++ b/pchronicle-web/assets/analyze-workspace.css @@ -0,0 +1,1606 @@ +.analyze-workspace { + --analyze-ink: #17212b; + --analyze-muted: #647184; + --analyze-line: #dde3e8; + --analyze-soft: #f4f7f8; + --analyze-accent: #126b62; + --analyze-accent-dark: #0c514b; + min-height: 100%; + overflow: auto; + background: + radial-gradient(circle at 12% -15%, rgba(52, 143, 129, 0.12), transparent 32rem), + #f6f8f8; + color: var(--analyze-ink); +} + +.analyze-header { + padding: 16px 0 18px; + border-bottom: 1px solid var(--analyze-line); + background: rgba(255, 255, 255, 0.84); + backdrop-filter: blur(16px); +} + +.analyze-header-inner { + width: min(1280px, calc(100% - 56px)); + margin: 0 auto; +} + +.analyze-header-bar { + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px 24px; + min-height: 36px; +} + +.analyze-header h1 { + margin: 10px 0 6px; + font: 600 22px/1.25 Georgia, "Times New Roman", serif; + letter-spacing: -0.02em; +} + +.analyze-header-lede, +.analyze-section-heading p, +.analyze-question-actions p, +.analyze-plan-actions p { + margin: 0; + color: var(--analyze-muted); + font-size: 12px; + line-height: 1.55; +} + +.analyze-eyebrow { + margin: 0; + color: var(--analyze-accent) !important; + font-size: 9px !important; + font-weight: 800; + letter-spacing: 0.14em; + text-transform: uppercase; +} + +.analyze-settings-button { + flex: none; +} + +.analyze-settings-button span { + margin-right: 6px; +} + +.analyze-header-actions { + display: flex; + flex: none; + flex-wrap: wrap; + align-items: center; + justify-content: flex-end; + gap: 8px; +} + +.analyze-recent-select { + display: flex; + align-items: center; + gap: 8px; + color: #647184; + font-size: 9px; + font-weight: 700; +} + +.analyze-recent-select select { + width: 210px; + min-height: 34px; + padding: 6px 28px 6px 9px; + border: 1px solid #cfd8dd; + border-radius: 7px; + background: #fff; + color: #35424e; + font: inherit; +} + +.analyze-clear-confirmation { + display: flex; + align-items: center; + gap: 6px; + padding: 5px 7px; + border: 1px solid #ecd2ae; + border-radius: 7px; + background: #fff9f1; + color: #76552c; + font-size: 9px; +} + +.analyze-clear-confirmation .button { + min-height: 27px; + padding: 4px 8px; + border-color: #dbb67f; +} + +.analyze-layout { + display: grid; + grid-template-columns: minmax(200px, 260px) minmax(0, 1fr); + align-items: start; + gap: 24px; + width: min(1280px, calc(100% - 56px)); + margin: 0 auto; + padding: 28px 0 64px; +} + +.analyze-schema, +.analyze-main { + min-width: 0; +} + +.analyze-main { + display: flex; + flex-direction: column; + gap: 18px; +} + +.analyze-schema { + position: sticky; + top: 16px; + max-height: calc(100vh - 48px); + overflow: auto; + border: 1px solid var(--analyze-line); + border-radius: 13px; + background: #fff; + box-shadow: 0 7px 24px rgba(31, 45, 52, 0.045); +} + +.analyze-schema-heading { + padding: 18px 16px 12px; +} + +.analyze-schema-heading h2 { + margin: 4px 0 6px; + color: #202b35; + font-size: 14px; + line-height: 1.3; +} + +.analyze-schema-heading p, +.analyze-schema-empty, +.analyze-schema-table-copy { + margin: 0; + color: var(--analyze-muted); + font-size: 11px; + line-height: 1.45; +} + +.analyze-schema-empty { + padding: 0 16px 18px; +} + +.analyze-schema-tables, +.analyze-schema-fields ul { + margin: 0; + padding: 0; + list-style: none; +} + +.analyze-schema-table, +.analyze-schema-field { + display: block; + width: 100%; + box-sizing: border-box; + border: 0; + background: transparent; + color: inherit; + text-align: left; + cursor: pointer; +} + +.analyze-schema-table { + padding: 9px 16px; +} + +.analyze-schema-table strong { + display: block; + color: #24313c; + font-size: 12px; +} + +.analyze-schema-table small { + display: block; + margin-top: 2px; + color: #7a8793; + font-size: 9px; +} + +.analyze-schema-table:hover, +.analyze-schema-field:hover:not(:disabled) { + background: #f4f8f7; +} + +.analyze-schema-table.active { + background: #edf7f5; +} + +.analyze-schema-fields { + padding: 4px 10px 14px; + border-top: 1px solid #e8ecef; +} + +.analyze-schema-fields h3 { + margin: 12px 6px 6px; + color: #202b35; + font-size: 11px; +} + +.analyze-schema-table-copy { + margin: 0 6px 8px; +} + +.analyze-schema-field { + padding: 7px 8px; + border-radius: 8px; +} + +.analyze-schema-field:disabled { + cursor: not-allowed; + opacity: 0.55; +} + +.analyze-schema-field code { + display: block; + color: #1d4f4a; + font: 11px/1.3 ui-monospace, SFMono-Regular, Menlo, monospace; +} + +.analyze-schema-field small { + display: block; + margin-top: 1px; + color: #6a7884; + font-size: 9px; +} + +.analyze-schema-field span { + display: block; + margin-top: 2px; + color: #7b8894; + font-size: 9px; + line-height: 1.35; +} + +.analyze-storage-notice-inline { + margin: 0; + padding: 10px 14px; + border: 1px solid #f0d5a8; + border-radius: 10px; + background: #fffbf3; + color: #76581f; + font-size: 11px; + line-height: 1.45; +} + +.analyze-question-card, +.analyze-sql-card, +.analyze-plan-card, +.analyze-result-card { + border: 1px solid var(--analyze-line); + border-radius: 13px; + background: #fff; + box-shadow: 0 7px 24px rgba(31, 45, 52, 0.045); + padding: 24px; +} + +.analyze-section-heading { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 18px; + margin-bottom: 20px; +} + +.analyze-section-heading > div:first-child { + display: flex; + align-items: flex-start; + gap: 12px; +} + +.analyze-section-heading > div:first-child > span { + display: grid; + width: auto; + min-width: 28px; + height: 28px; + flex: none; + padding: 0 6px; + place-items: center; + border: 1px solid #b9d5d0; + border-radius: 999px; + color: var(--analyze-accent); + font: 700 9px/1 ui-monospace, SFMono-Regular, Menlo, monospace; +} + +.analyze-section-heading h2 { + margin: 0 0 4px; + color: #202b35; + font-size: 14px; + line-height: 1.3; +} + +.analyze-step-state, +.analyze-edited-badge { + flex: none; + padding: 5px 8px; + border-radius: 999px; + background: #edf7f5; + color: var(--analyze-accent-dark); + font-size: 9px; + font-weight: 700; +} + +.analyze-question-label, +.analyze-sql-details label { + display: block; + margin-bottom: 7px; + color: #394651; + font-size: 10px; + font-weight: 700; +} + +.analyze-question-input, +.analyze-sql-editor { + width: 100%; + box-sizing: border-box; + resize: vertical; + border: 1px solid #cdd5dc; + border-radius: 9px; + outline: none; + background: #fcfdfd; + color: var(--analyze-ink); + transition: border-color 120ms ease, box-shadow 120ms ease; +} + +.analyze-question-input { + min-height: 118px; + padding: 15px; + font: 15px/1.55 Georgia, "Times New Roman", serif; +} + +.analyze-question-input:focus, +.analyze-sql-editor:focus { + border-color: #4d9d91; + box-shadow: 0 0 0 3px rgba(18, 107, 98, 0.11); +} + +.analyze-context-row { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 7px; + margin-top: 10px; +} + +.analyze-status, +.analyze-chip { + display: inline-flex; + align-items: center; + min-height: 24px; + box-sizing: border-box; + padding: 4px 8px; + border: 1px solid #d9e0e5; + border-radius: 999px; + background: #f9fafb; + color: #556270; + font-size: 9px; + font-weight: 650; +} + +.analyze-status > span { + width: 6px; + height: 6px; + margin-right: 6px; + border-radius: 50%; + background: #98a2b3; +} + +.analyze-status.ready > span { + background: #12a47d; + box-shadow: 0 0 0 3px rgba(18, 164, 125, 0.1); +} + +.analyze-chip.lock::before { + content: "⌁"; + margin-right: 5px; + color: var(--analyze-accent); +} + +.analyze-chip-remove { + display: inline-grid; + width: 16px; + height: 16px; + margin: -1px -3px -1px 5px; + padding: 0; + place-items: center; + border: 0; + border-radius: 50%; + background: transparent; + color: #667582; + font: inherit; + line-height: 1; + cursor: pointer; +} + +.analyze-chip-remove:hover:not(:disabled), +.analyze-chip-remove:focus-visible:not(:disabled) { + background: #dcebe8; + color: var(--analyze-accent-dark); +} + +.analyze-chip-remove:disabled { + color: #b4bdc5; + cursor: not-allowed; +} + +.analyze-starters { + margin-top: 19px; +} + +.analyze-starters > span { + color: #7b8794; + font-size: 9px; + font-weight: 700; + letter-spacing: 0.07em; + text-transform: uppercase; +} + +.analyze-starters > div { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 8px; + margin-top: 8px; +} + +.analyze-starters button { + min-height: 58px; + padding: 9px 10px; + border: 1px solid #e0e5e9; + border-radius: 8px; + background: #f9fbfb; + color: #43515e; + font: inherit; + font-size: 10px; + line-height: 1.42; + text-align: left; + cursor: pointer; +} + +.analyze-starters button:hover:not(:disabled) { + border-color: #9fc7c0; + background: #f2f9f7; + color: #164f49; +} + +.analyze-config-callout, +.analyze-error, +.analyze-empty-result { + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; + margin-top: 16px; + padding: 12px 14px; + border: 1px solid #f0d5a8; + border-radius: 8px; + background: #fffbf3; +} + +.analyze-config-callout strong, +.analyze-error strong, +.analyze-empty-result strong { + display: block; + margin-bottom: 2px; + color: #584318; + font-size: 10px; +} + +.analyze-config-callout p, +.analyze-error p, +.analyze-empty-result p { + margin: 0; + color: #775f2c; + font-size: 10px; +} + +.analyze-question-actions, +.analyze-plan-actions { + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; + margin-top: 20px; + padding-top: 17px; + border-top: 1px solid #e8ecef; +} + +.analyze-revision-timeline { + display: flex; + gap: 8px; + overflow-x: auto; + padding: 2px 1px 6px; +} + +.analyze-revision { + display: flex; + min-width: 185px; + max-width: 260px; + align-items: flex-start; + gap: 8px; + padding: 10px; + border: 1px solid #dce3e7; + border-radius: 9px; + background: rgba(255, 255, 255, 0.78); + color: #46535e; + text-align: left; + cursor: pointer; +} + +.analyze-revision:hover, +.analyze-revision.active { + border-color: #9fc7c0; + background: #f2f9f7; +} + +.analyze-revision-marker { + width: 8px; + height: 8px; + flex: none; + margin-top: 3px; + border: 2px solid #91aaa5; + border-radius: 50%; + background: #fff; +} + +.analyze-revision.active .analyze-revision-marker { + border-color: var(--analyze-accent); + background: var(--analyze-accent); + box-shadow: 0 0 0 3px rgba(18, 107, 98, 0.1); +} + +.analyze-revision-copy { + display: flex; + min-width: 0; + flex-direction: column; + gap: 4px; +} + +.analyze-revision-copy strong { + overflow: hidden; + color: #2b3943; + font-size: 10px; + line-height: 1.35; + text-overflow: ellipsis; + white-space: nowrap; +} + +.analyze-revision-copy small { + color: #7a8792; + font-size: 8px; + line-height: 1.4; +} + +.analyze-plan-actions { + justify-content: flex-end; +} + +.analyze-spinner { + display: inline-block; + width: 9px; + height: 9px; + margin-right: 7px; + border: 1.5px solid currentColor; + border-right-color: transparent; + border-radius: 50%; + animation: analyze-spin 700ms linear infinite; +} + +@keyframes analyze-spin { + to { transform: rotate(360deg); } +} + +.analyze-plan-summary { + margin: 0; + border-top: 1px solid #e7ebee; +} + +.analyze-plan-summary > div { + display: grid; + grid-template-columns: 110px minmax(0, 1fr); + gap: 18px; + padding: 12px 2px; + border-bottom: 1px solid #e7ebee; +} + +.analyze-plan-summary dt { + color: #75818e; + font-size: 9px; + font-weight: 800; + letter-spacing: 0.06em; + text-transform: uppercase; +} + +.analyze-plan-summary dd { + margin: 0; + color: #35424e; + font-size: 11px; + line-height: 1.5; +} + +.analyze-plan-summary ul, +.analyze-warnings ul { + display: flex; + flex-wrap: wrap; + gap: 6px; + margin: 0; + padding: 0; + list-style: none; +} + +.analyze-plan-summary li { + padding: 3px 7px; + border-radius: 5px; + background: #f0f4f4; + font-size: 10px; +} + +.analyze-none { + color: #98a2ad; + font-style: italic; +} + +.analyze-warnings { + margin-top: 14px; + padding: 12px 14px; + border-left: 3px solid #d99a34; + background: #fffbf2; + color: #684d20; +} + +.analyze-warnings strong { + display: block; + margin-bottom: 7px; + font-size: 10px; +} + +.analyze-warnings ul { + display: block; + padding-left: 17px; + list-style: disc; + font-size: 10px; + line-height: 1.5; +} + +.analyze-sql-details { + margin-top: 16px; + border: 1px solid #e0e5e8; + border-radius: 8px; + background: #fafbfb; +} + +.analyze-sql-details summary { + padding: 11px 13px; + color: #485765; + font-size: 10px; + font-weight: 700; + cursor: pointer; +} + +.analyze-sql-details[open] summary { + border-bottom: 1px solid #e0e5e8; +} + +.analyze-sql-details label { + margin: 12px 13px 6px; +} + +.analyze-sql-editor { + width: 100%; + box-sizing: border-box; + min-height: 180px; + margin: 0; + padding: 12px 13px; + background: #111b22; + color: #dbe9e7; + font: 12px/1.55 ui-monospace, SFMono-Regular, Menlo, monospace; +} + +.analyze-error { + display: block; + margin-top: 12px; + border-color: #f0b9b6; + background: #fff6f5; +} + +.analyze-error strong, +.analyze-error p { + color: #8c2f2a; +} + +.analyze-result-card .pc2-data-component { + border-color: #dde3e8; + box-shadow: none; +} + +.analyze-interpretation-status, +.analyze-interpretation-error { + display: flex; + align-items: center; + gap: 12px; + margin-top: 14px; + padding: 12px 14px; + border: 1px solid #d8e5e2; + border-radius: 8px; + background: #f4f9f8; +} + +.analyze-interpretation-status strong, +.analyze-interpretation-error strong { + display: block; + margin-bottom: 2px; + color: #28534e; + font-size: 10px; +} + +.analyze-interpretation-status p, +.analyze-interpretation-error p { + margin: 0; + color: #657671; + font-size: 9px; + line-height: 1.5; +} + +.analyze-interpretation-error { + justify-content: space-between; + border-color: #f0b9b6; + background: #fff6f5; +} + +.analyze-interpretation-error strong, +.analyze-interpretation-error p { + color: #8c2f2a; +} + +.analyze-saved-interpretation-note { + margin-bottom: 14px; + padding: 10px 12px; + border-left: 3px solid #73958f; + background: #f3f7f6; +} + +.analyze-saved-interpretation-note strong { + display: block; + margin-bottom: 2px; + color: #365852; + font-size: 9px; +} + +.analyze-saved-interpretation-note p { + margin: 0; + color: #687873; + font-size: 9px; + line-height: 1.5; +} + +.analyze-interpretation { + margin-top: 16px; + border: 1px solid #dce3e7; + border-radius: 10px; + background: #fbfcfc; +} + +.analyze-interpretation-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); +} + +.analyze-interpretation-block { + min-width: 0; + padding: 16px; + border-bottom: 1px solid #e2e7ea; +} + +.analyze-interpretation-block:nth-child(odd) { + border-right: 1px solid #e2e7ea; +} + +.analyze-interpretation-block:nth-last-child(-n + 2) { + border-bottom: 0; +} + +.analyze-interpretation-block.observed { + border-top: 3px solid #268579; +} + +.analyze-interpretation-block.inferred { + border-top: 3px solid #8a6a35; + background: #fffdf8; +} + +.analyze-interpretation-block.limitations { + background: #fafafa; +} + +.analyze-interpretation-block h3 { + margin: 0 0 9px; + color: #2b3943; + font-size: 11px; +} + +.analyze-interpretation-block ul { + margin: 0; + padding-left: 17px; + color: #46535e; + font-size: 10px; + line-height: 1.55; +} + +.analyze-interpretation-block li + li { + margin-top: 5px; +} + +.analyze-interpretation-references { + display: flex; + flex-wrap: wrap; + gap: 6px; + margin-top: 12px; +} + +.analyze-interpretation-reference { + display: inline-flex; + align-items: center; + gap: 5px; + padding: 4px 6px; + border: 1px solid #dfe5e8; + border-radius: 5px; + background: #f5f7f8; + color: #65717c; + font-size: 8px; +} + +.analyze-interpretation-reference.linked { + border-color: #cde0dc; + background: #f0f7f5; +} + +.analyze-interpretation-reference a { + color: #126b62; + font-weight: 750; + text-decoration: none; +} + +.analyze-follow-up-list { + display: flex; + flex-direction: column; + gap: 8px; +} + +.analyze-follow-up { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + padding: 9px 10px; + border: 1px solid #dfe5e8; + border-radius: 7px; + background: #fff; +} + +.analyze-follow-up p, +.analyze-follow-up-stale { + margin: 0; + color: #46535e; + font-size: 9px; + line-height: 1.45; +} + +.analyze-follow-up > div { + display: flex; + flex: none; + align-items: center; + gap: 8px; +} + +.analyze-follow-up .button { + min-height: 28px; + padding: 5px 9px; + font-size: 8px; +} + +.analyze-follow-up-stale { + margin-bottom: 9px; + color: #8a642b; +} + +.result-explorer { + overflow: hidden; + border: 1px solid #dce3e7; + border-radius: 10px; + background: #fff; +} + +.result-explorer-header, +.result-explorer-footer { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + padding: 12px 14px; + background: #f7f9f9; +} + +.result-explorer-header { + border-bottom: 1px solid #dce3e7; +} + +.result-explorer-header > div { + display: flex; + flex-direction: column; + gap: 3px; +} + +.result-explorer-header strong { + color: #26343f; + font-size: 11px; +} + +.result-explorer-header span, +.result-explorer-footer, +.result-profile-scope, +.result-profile-panel small { + color: #6d7985; + font-size: 9px; + line-height: 1.45; +} + +.result-explorer-count { + flex: none; + padding: 4px 7px; + border-radius: 999px; + background: #eaf3f1; + color: #235e57 !important; + font-weight: 700; +} + +.result-refinement-stale { + display: flex; + align-items: baseline; + gap: 8px; + padding: 9px 14px; + border-bottom: 1px solid #ecd2ae; + background: #fff9f1; + color: #76552c; + font-size: 9px; + line-height: 1.45; +} + +.result-refinement-stale strong { + flex: none; + color: #68471f; +} + +.result-explorer-layout { + display: grid; + grid-template-columns: minmax(0, 1fr) 210px; + min-width: 0; +} + +.result-explorer-table-region { + min-width: 0; +} + +.result-explorer-scroll { + max-width: 100%; + overflow: auto; +} + +.result-explorer-table { + width: 100%; + min-width: max-content; + border-collapse: separate; + border-spacing: 0; + color: #384550; + font-size: 10px; +} + +.result-explorer-table th { + position: sticky; + z-index: 2; + top: 0; + width: 150px; + min-width: 150px; + padding: 0; + border-right: 1px solid #dfe5e8; + border-bottom: 1px solid #cfd8dd; + background: #f6f9f9; + text-align: left; + vertical-align: top; +} + +.result-explorer-table td { + max-width: 260px; + padding: 9px 10px; + border-right: 1px solid #edf0f2; + border-bottom: 1px solid #edf0f2; + vertical-align: top; +} + +.result-explorer-table tbody tr:hover td { + background: #f8fbfa; +} + +.result-profile-header { + min-height: 104px; + box-sizing: border-box; + padding: 9px 10px 8px; + border-top: 2px solid transparent; +} + +.result-profile-header.selected { + border-top-color: var(--analyze-accent); + background: #f0f7f6; +} + +.result-profile-title { + display: flex; + width: 100%; + align-items: baseline; + justify-content: space-between; + gap: 8px; + padding: 0; + border: 0; + background: transparent; + color: inherit; + font: inherit; + text-align: left; + cursor: pointer; +} + +.result-profile-title strong { + overflow: hidden; + color: #293641; + font-size: 10px; + text-overflow: ellipsis; +} + +.result-profile-title span, +.result-profile-kind { + color: #71808c; + font-size: 8px; + font-weight: 750; + letter-spacing: .04em; + text-transform: uppercase; +} + +.result-mini-profile { + display: flex; + height: 32px; + align-items: flex-end; + gap: 2px; + margin: 8px 0 6px; +} + +.result-mini-bar { + display: flex; + height: 100%; + min-width: 4px; + flex: 1; + align-items: flex-end; + padding: 0; + border: 0; + background: transparent; +} + +button.result-mini-bar { + cursor: pointer; +} + +.result-mini-bar i { + display: block; + width: 100%; + min-height: 2px; + border-radius: 2px 2px 0 0; + background: #69a99f; +} + +button.result-mini-bar:hover i { + background: #126b62; +} + +.result-profile-meta { + display: flex; + align-items: center; + justify-content: space-between; + gap: 6px; + color: #687681; + font-size: 8px; +} + +.result-profile-meta button { + padding: 0; + border: 0; + background: transparent; + color: #9b5a28; + font: inherit; + cursor: pointer; +} + +.result-identity-links { + display: flex; + gap: 5px; + margin-bottom: 5px; +} + +.result-identity-links a { + padding: 2px 5px; + border-radius: 4px; + background: #edf6f4; + color: #126b62; + font-size: 8px; + font-weight: 750; + text-decoration: none; +} + +.result-cell { + display: inline-block; + max-width: 240px; + overflow-wrap: anywhere; + line-height: 1.45; + white-space: pre-wrap; +} + +.result-cell.null { + color: #9aa3aa; + font-style: italic; +} + +.result-cell.number, +.result-cell.boolean { + color: #185d57; + font-family: ui-monospace, SFMono-Regular, Menlo, monospace; +} + +.result-cell-expand { + padding: 0; + border: 0; + background: transparent; + color: inherit; + font: inherit; + text-align: left; + cursor: pointer; +} + +.result-cell-expand i { + margin-left: 4px; + color: var(--analyze-accent); + font-style: normal; +} + +.result-profile-panel { + min-width: 0; + padding: 15px; + border-left: 1px solid #dce3e7; + background: #fbfcfc; +} + +.result-profile-panel h3 { + overflow: hidden; + margin: 4px 0 2px; + color: #25333e; + font-size: 13px; + text-overflow: ellipsis; +} + +.result-profile-scope { + margin: 9px 0 12px; +} + +.result-profile-stats { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 6px; + margin: 0 0 13px; +} + +.result-profile-stats > div { + padding: 7px; + border: 1px solid #e2e7ea; + border-radius: 6px; + background: #fff; +} + +.result-profile-stats dt { + color: #7b8791; + font-size: 7px; + text-transform: uppercase; +} + +.result-profile-stats dd { + overflow: hidden; + margin: 2px 0 0; + color: #35434d; + font: 700 9px/1.3 ui-monospace, SFMono-Regular, Menlo, monospace; + text-overflow: ellipsis; +} + +.result-profile-bars { + display: flex; + flex-direction: column; + gap: 5px; +} + +.result-profile-bars > button, +.result-profile-bars > div { + display: grid; + grid-template-columns: minmax(0, 1fr) 52px 25px; + gap: 5px; + align-items: center; + min-width: 0; + padding: 3px 0; + border: 0; + background: transparent; + color: #56636e; + font: inherit; + font-size: 8px; + text-align: left; +} + +.result-profile-bars > button { + cursor: pointer; +} + +.result-profile-bars > button > span:first-child, +.result-profile-bars > div > span:first-child { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.result-profile-bars i { + display: block; + height: 5px; + overflow: hidden; + border-radius: 999px; + background: #e4ebe9; +} + +.result-profile-bars i span { + display: block; + height: 100%; + border-radius: inherit; + background: #68a99f; +} + +.result-profile-bars code { + color: #53616c; + font-size: 8px; + text-align: right; +} + +.result-profile-missing { + width: 100%; + margin-top: 10px; + padding: 6px 8px; + border: 1px dashed #d6b48d; + border-radius: 6px; + background: #fffaf4; + color: #83552c; + font: inherit; + font-size: 8px; + text-align: left; + cursor: pointer; +} + +.result-full-profile { + width: 100%; + margin-top: 14px; +} + +.result-profile-panel > small { + display: block; + margin-top: 7px; +} + +.result-refinement-stage { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + margin: 10px; + padding: 10px 11px; + border: 1px solid #bcd7d2; + border-radius: 8px; + background: #f2f9f7; +} + +.result-refinement-stage > div { + display: flex; + min-width: 0; + flex-direction: column; + gap: 2px; +} + +.result-refinement-stage span { + color: #3e776f; + font-size: 8px; + font-weight: 750; + text-transform: uppercase; +} + +.result-refinement-stage strong { + overflow: hidden; + color: #24443f; + font-size: 10px; + text-overflow: ellipsis; +} + +.result-refinement-stage small { + color: #657872; + font-size: 8px; +} + +.result-refinement-actions { + display: flex; + flex: 0 0 auto; + align-items: center; + gap: 10px; +} + +.result-refinement-actions .analyze-link-button { + margin-top: 0; +} + +.result-explorer-footer { + justify-content: flex-start; + flex-wrap: wrap; + border-top: 1px solid #dce3e7; +} + +.result-cell-backdrop { + position: fixed; + z-index: 1000; + inset: 0; + display: grid; + padding: 28px; + place-items: center; + background: rgba(17, 29, 35, .58); +} + +.result-cell-modal { + display: flex; + width: min(760px, 100%); + max-height: min(720px, calc(100vh - 56px)); + overflow: hidden; + flex-direction: column; + border-radius: 11px; + background: #fff; + box-shadow: 0 20px 60px rgba(0, 0, 0, .24); +} + +.result-cell-modal > header, +.result-cell-modal > footer { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + padding: 12px 14px; + border-bottom: 1px solid #e1e6e9; +} + +.result-cell-modal > footer { + border-top: 1px solid #e1e6e9; + border-bottom: 0; +} + +.result-cell-modal > header span, +.result-cell-modal > footer span { + margin-left: 7px; + color: #7a8690; + font-size: 9px; +} + +.result-cell-modal > header button { + padding: 0 5px; + border: 0; + background: transparent; + color: #63707b; + font-size: 21px; + cursor: pointer; +} + +.result-cell-modal > pre, +.result-cell-json { + min-height: 0; + margin: 0; + overflow: auto; + padding: 16px; + color: #263740; + font: 10px/1.55 ui-monospace, SFMono-Regular, Menlo, monospace; + white-space: pre-wrap; +} + +.analyze-empty-result { + align-items: flex-start; + border-color: #dce3e7; + background: #f8fafb; +} + +.analyze-empty-result strong, +.analyze-empty-result p { + color: #4e5c68; +} + +.analyze-aside > section { + padding: 18px; +} + +.analyze-aside ol { + display: flex; + flex-direction: column; + gap: 14px; + margin: 17px 0 0; + padding: 0; + list-style: none; +} + +.analyze-aside li { + display: flex; + gap: 10px; +} + +.analyze-aside li > span { + display: grid; + width: 22px; + height: 22px; + flex: none; + place-items: center; + border-radius: 50%; + background: #edf5f4; + color: var(--analyze-accent); + font-size: 9px; + font-weight: 800; +} + +.analyze-aside li strong { + display: block; + margin-bottom: 2px; + color: #35434f; + font-size: 10px; +} + +.analyze-privacy { + border-color: #cfe2df !important; + background: linear-gradient(145deg, #f7fbfa, #fff) !important; +} + +.analyze-link-button { + margin-top: 12px; + padding: 0; + border: 0; + background: transparent; + color: var(--analyze-accent); + font-size: 10px; + font-weight: 750; + cursor: pointer; +} + +.analyze-catalog-card dl { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 6px; + margin: 13px 0; +} + +.analyze-catalog-card dl > div { + padding: 8px; + border-radius: 7px; + background: var(--analyze-soft); +} + +.analyze-catalog-card dt { + color: #84909b; + font-size: 8px; + text-transform: uppercase; +} + +.analyze-catalog-card dd { + margin: 3px 0 0; + color: #34424e; + font-size: 10px; + font-weight: 700; +} + +.analyze-catalog-card code { + display: block; + overflow: hidden; + color: #697682; + font-size: 9px; + text-overflow: ellipsis; + white-space: nowrap; +} + +.analyze-storage-notice { + border-color: #f0d5a8 !important; + background: #fffbf3 !important; + color: #76581f !important; +} + +@media (max-width: 980px) { + .analyze-layout { + grid-template-columns: minmax(0, 1fr); + } + + .analyze-schema { + position: static; + max-height: none; + } + + .result-explorer-layout { + grid-template-columns: minmax(0, 1fr); + } + + .result-profile-panel { + border-top: 1px solid #dce3e7; + border-left: 0; + } +} + +@media (max-width: 700px) { + .analyze-header-inner, + .analyze-layout { + width: calc(100% - 28px); + } + + .analyze-header-bar { + align-items: flex-start; + flex-direction: column; + } + + .analyze-header-actions, + .analyze-recent-select { + width: 100%; + justify-content: flex-start; + } + + .analyze-recent-select select { + flex: 1; + width: auto; + min-width: 0; + } + + .analyze-starters > div, + .analyze-interpretation-grid { + grid-template-columns: 1fr; + } + + .analyze-interpretation-block, + .analyze-interpretation-block:nth-child(odd), + .analyze-interpretation-block:nth-last-child(-n + 2) { + border-right: 0; + border-bottom: 1px solid #e2e7ea; + } + + .analyze-interpretation-block:last-child { + border-bottom: 0; + } + + .analyze-follow-up { + align-items: stretch; + flex-direction: column; + } + + .analyze-question-actions, + .analyze-config-callout, + .result-refinement-stage { + align-items: stretch; + flex-direction: column; + } + + .result-cell-backdrop { + padding: 12px; + } +} + +@media (prefers-reduced-motion: reduce) { + .analyze-spinner { + animation: none; + } +} diff --git a/pchronicle-web/assets/catalog.css b/pchronicle-web/assets/catalog.css new file mode 100644 index 00000000..714bdac2 --- /dev/null +++ b/pchronicle-web/assets/catalog.css @@ -0,0 +1,34 @@ +.pc-catalog{min-height:0;flex:1;display:flex;flex-direction:column;padding:18px 22px 16px;overflow:hidden} +.pc-catalog-head{display:flex;align-items:flex-start;justify-content:space-between;gap:16px;margin-bottom:12px} +.pc-catalog-title h1{margin:2px 0 4px;display:flex;flex-wrap:wrap;align-items:baseline;gap:0;font-size:22px;line-height:1.25} +.pc-catalog-title p:not(.eyebrow){margin:0;color:#667085;font-size:12px} +.pc-catalog-crumb{border:0;padding:0;background:transparent;color:#2563eb;font:inherit;cursor:pointer} +.pc-catalog-crumb:hover{text-decoration:underline} +.pc-catalog-stats{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));margin-bottom:10px;border:1px solid #dfe3e8;border-radius:10px;background:#fff;overflow:hidden} +.pc-catalog-stats>div{display:flex;flex-direction:column;gap:3px;padding:10px 12px;border-right:1px solid #eceef1} +.pc-catalog-stats>div:last-child{border-right:0} +.pc-catalog-stats span{color:#667085;font-size:9px;font-weight:700;text-transform:uppercase;letter-spacing:.05em} +.pc-catalog-stats strong{color:#101828;font:600 18px ui-monospace,SFMono-Regular,Menlo,monospace} +.pc-catalog-errors{margin:0 0 10px;color:#b42318;font-size:11px} +.pc-catalog-mosaic{min-height:0;flex:1;display:flex;flex-direction:column} +.pc-catalog-tree{position:relative;min-height:0;flex:1;border:1px solid #dfe3e8;border-radius:12px;background:#eef2f6;overflow:hidden} +.pc-catalog-tile{position:absolute;display:flex;flex-direction:column;align-items:flex-start;justify-content:space-between;padding:10px;border:1px solid #ffffffaa;border-radius:8px;color:#102033;text-align:left;cursor:pointer;overflow:hidden} +.pc-catalog-tile strong{max-width:100%;overflow:hidden;font-size:13px;text-overflow:ellipsis;white-space:nowrap} +.pc-catalog-tile small{color:#1f2937cc;font:11px ui-monospace,SFMono-Regular,Menlo,monospace} +.pc-catalog-tile.tone-0{background:#93c5fd} +.pc-catalog-tile.tone-1{background:#6ea8ff} +.pc-catalog-tile.tone-2{background:#67e8f9} +.pc-catalog-tile.tone-3{background:#86efac} +.pc-catalog-tile.tone-4{background:#fde68a} +.pc-catalog-tile.tone-5{background:#e2e8f0} +.pc-catalog-tile.kind-other{background:#cbd5e1} +.pc-catalog-tile:hover,.pc-catalog-tile:focus-visible{outline:0;box-shadow:inset 0 0 0 2px #1d4ed8} +.pc-catalog-empty{min-height:220px;flex:1;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:6px;border:1px dashed #d0d5dd;border-radius:12px;color:#98a2b3;text-align:center} +.pc-catalog-empty strong{color:#475467;font-size:13px} +.pc-catalog-empty span{font-size:11px} +.pc-catalog-other{margin:10px 0 0;padding:8px;border:1px solid #dfe3e8;border-radius:10px;background:#fff;max-height:160px;overflow:auto} +.pc-catalog-other{list-style:none} +.pc-catalog-other button{width:100%;display:flex;justify-content:space-between;gap:12px;padding:8px 10px;border:0;border-radius:7px;background:transparent;color:#344054;cursor:pointer} +.pc-catalog-other button:hover{background:#f8fafc} +.pc-catalog-other span{color:#667085;font:11px ui-monospace,SFMono-Regular,Menlo,monospace} +@media(max-width:850px){.pc-catalog-stats{grid-template-columns:repeat(2,1fr)}.pc-catalog-stats>div:nth-child(2){border-right:0}.pc-catalog-stats>div:nth-child(-n+2){border-bottom:1px solid #eceef1}} diff --git a/pchronicle-web/assets/components.css b/pchronicle-web/assets/components.css index 2c22a28a..2d0c67a5 100644 --- a/pchronicle-web/assets/components.css +++ b/pchronicle-web/assets/components.css @@ -1,3 +1,5 @@ +@import url("/assets/analyze-workspace.css?v=2"); + .pc2-data-component { min-width: 0; overflow: hidden; diff --git a/pchronicle-web/assets/inline-trace.css b/pchronicle-web/assets/inline-trace.css index a1a976a5..e082d7af 100644 --- a/pchronicle-web/assets/inline-trace.css +++ b/pchronicle-web/assets/inline-trace.css @@ -148,26 +148,6 @@ padding: 10px; } -.pc2-inline-detail-head { - min-height: 34px; - display: flex; - align-items: center; - justify-content: space-between; - gap: 12px; - margin-bottom: 8px; -} - -.pc2-inline-detail-head strong { - color: #344054; - font-size: 10px; -} - -.pc2-inline-detail-head .button { - min-height: 27px; - padding: 0 8px; - font-size: 9px; -} - .pc2-inline-unavailable { padding: 12px; border: 1px dashed #d0d5dd; @@ -191,6 +171,118 @@ overflow: auto; } +.pc2-tool-call-stack { + display: grid; + gap: 8px; +} + +.pc2-tool-call-card { + overflow: hidden; + border: 1px solid #d0d5dd; + border-left: 3px solid #667085; + border-radius: 6px; + background: #fff; +} + +.pc2-tool-call-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + padding: 6px 9px; + border-bottom: 1px solid #eaecf0; + background: #f2f4f7; +} + +.pc2-tool-call-head-left { + display: flex; + align-items: center; + gap: 8px; + min-width: 0; +} + +.pc2-tool-call-head-right { + display: flex; + align-items: center; + gap: 8px; + flex-shrink: 0; +} + +.pc2-tool-call-type { + color: #667085; + font-size: 8px; + font-weight: 700; + text-transform: uppercase; + letter-spacing: .055em; +} + +.pc2-tool-call-header strong { + color: #1d2939; + font: 600 11px/1.3 ui-monospace, SFMono-Regular, Menlo, monospace; +} + +.pc2-tool-call-meta, +.pc2-tool-call-id { + color: #667085; + font: 500 9px/1.3 ui-monospace, SFMono-Regular, Menlo, monospace; +} + +.pc2-tool-call-id { + max-width: 120px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.pc2-tool-call-body { + padding: 8px 10px; +} + +.pc2-tool-call-raw { + border-top: 1px solid #eaecf0; + background: #f9fafb; +} + +.pc2-tool-call-raw summary { + padding: 5px 9px; + color: #667085; + font-size: 9px; + font-weight: 600; + cursor: pointer; + user-select: none; +} + +.pc2-tool-call-raw pre { + margin: 0; + padding: 8px 10px; + max-height: 240px; + overflow: auto; + color: #475467; + font-size: 9px; + background: #fff; +} + +.pc2-tool-call-arg { + display: flex; + align-items: baseline; + gap: 8px; + padding: 3px 0; + font-size: 11px; +} + +.pc2-tool-call-arg code { + flex: 0 0 auto; + color: #667085; + font: 500 10px/1.3 ui-monospace, SFMono-Regular, Menlo, monospace; +} + +.pc2-tool-call-arg span { + min-width: 0; + color: #1d2939; + white-space: pre-wrap; + word-break: break-word; +} + @media (max-width: 1050px) { .pc2-inline-detail .pc2-inspector-facts { grid-template-columns: repeat(2, minmax(0, 1fr)); diff --git a/pchronicle-web/index.html b/pchronicle-web/index.html index ef4cd38b..c600bdee 100644 --- a/pchronicle-web/index.html +++ b/pchronicle-web/index.html @@ -8,11 +8,12 @@ - + + - +
diff --git a/pchronicle-web/src/agent.rs b/pchronicle-web/src/agent.rs index 20b473f3..ce2df201 100644 --- a/pchronicle-web/src/agent.rs +++ b/pchronicle-web/src/agent.rs @@ -1,48 +1,182 @@ -use std::cmp::Ordering; +use std::collections::{HashMap, VecDeque}; +use std::sync::atomic::{AtomicUsize, Ordering}; -use gloo_net::http::Request; use serde::{Deserialize, Serialize}; use serde_json::{json, Value}; +use web_time::{SystemTime, UNIX_EPOCH}; use crate::api; -use crate::components::{table_fence, trajectory_fence}; -use crate::model::{RunAnalysis, RunSummary, TurnDetail, TurnSummary}; +use crate::components::trajectory_fence; +use crate::llm::{self, LlmConfig}; +use crate::model::{QueryCatalog, QueryEvidence, RunAnalysis, RunSummary, TurnDetail}; -const STORAGE_KEY: &str = "pchronicle_llm_config"; -const DEFAULT_CONTEXT_LIMIT: usize = 32 * 1024; -const FULL_CONTEXT_LIMIT: usize = 64 * 1024; +pub const THREAD_BYTE_LIMIT: usize = 200 * 1024; +pub const LLM_MESSAGE_BYTE_LIMIT: usize = 32 * 1024; +pub const TURN_BODY_LIMIT: usize = 8 * 1024; +pub const TOOL_NAMES: [&str; 3] = ["get_analysis", "get_turn", "query_sql"]; +static JSON_TOOL_ID: AtomicUsize = AtomicUsize::new(1); + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum ThreadRole { + User, + Assistant, + Tool, +} #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] -pub struct LlmConfig { - pub api_base: String, - pub api_key: String, - pub model: String, -} - -impl Default for LlmConfig { - fn default() -> Self { - Self { - api_base: "https://api.deepseek.com/v1".into(), - api_key: String::new(), - model: "deepseek-chat".into(), - } - } +pub struct ThreadMessage { + pub role: ThreadRole, + pub text: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub tool_calls: Option>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub tool_call_id: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub tool_name: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub sql: Option, + #[serde(default)] + pub truncated: bool, } -impl LlmConfig { - pub fn is_configured(&self) -> bool { - !self.api_base.trim().is_empty() - && !self.api_key.trim().is_empty() - && !self.model.trim().is_empty() +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct CopilotThread { + pub messages: Vec, + pub updated_at: i64, + #[serde(default)] + pub truncated: bool, +} + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct ParsedToolCall { + pub id: String, + pub name: String, + pub arguments: Value, +} + +#[derive(Clone, Debug, PartialEq)] +pub enum AssistantTurn { + ToolCalls(Vec), + Final(String), + Invalid, +} + +pub const MAX_TOOL_ROUNDS: usize = 8; + +pub struct LoopState { + pub messages: Vec, + pub tool_rounds: usize, + pub json_mode: bool, + pub illegal_json_streak: usize, + pub fetched_turn_ids: Vec, + pub force_final: bool, +} + +#[derive(Debug, PartialEq)] +pub enum DriveResult { + Continue, + Done { text: String }, + Failed { message: String }, +} + +pub fn apply_model_turn( + state: &mut LoopState, + turn: AssistantTurn, + mut execute: impl FnMut(&ParsedToolCall) -> String, +) -> DriveResult { + if state.force_final { + return match turn { + AssistantTurn::Final(text) if !text.trim().is_empty() => { + state.illegal_json_streak = 0; + DriveResult::Done { text } + } + _ => DriveResult::Failed { + message: + "The model did not produce a final answer because the available evidence was insufficient." + .into(), + }, + }; + } + + match turn { + AssistantTurn::ToolCalls(calls) => { + let remaining = MAX_TOOL_ROUNDS.saturating_sub(state.tool_rounds); + let calls = calls.into_iter().take(remaining).collect::>(); + if !calls.is_empty() { + state.messages.push(ThreadMessage { + role: ThreadRole::Assistant, + text: String::new(), + tool_calls: Some(calls.clone()), + tool_call_id: None, + tool_name: None, + sql: None, + truncated: false, + }); + } + + for call in calls { + let result = execute(&call); + state.tool_rounds += 1; + + if call.name == "get_turn" { + let turn_id = call.arguments.get("turn_id").and_then(|value| { + value + .as_i64() + .or_else(|| value.as_str().and_then(|raw| raw.parse().ok())) + }); + if let Some(turn_id) = turn_id { + let marker = format!("[turn:{turn_id}]"); + if result.contains(&marker) && !state.fetched_turn_ids.contains(&turn_id) { + state.fetched_turn_ids.push(turn_id); + } + } + } + + state.messages.push(ThreadMessage { + role: ThreadRole::Tool, + text: result, + tool_calls: None, + tool_call_id: Some(call.id), + tool_name: Some(call.name), + sql: None, + truncated: false, + }); + } + + if state.tool_rounds >= MAX_TOOL_ROUNDS { + state.force_final = true; + } + DriveResult::Continue + } + AssistantTurn::Final(text) => { + state.illegal_json_streak = 0; + DriveResult::Done { text } + } + AssistantTurn::Invalid if !state.json_mode => { + state.json_mode = true; + DriveResult::Continue + } + AssistantTurn::Invalid => { + state.illegal_json_streak += 1; + if state.illegal_json_streak >= 2 { + DriveResult::Failed { + message: "The model could not use tool-calling. Try a different OpenAI-compatible model in Settings.".into(), + } + } else { + DriveResult::Continue + } + } } } #[derive(Clone, Debug, PartialEq)] pub struct AgentAnswer { + pub thread: CopilotThread, pub text: String, - pub action: String, pub sql: Option, pub truncated: bool, + pub fetched_turn_ids: Vec, } pub struct AnswerRequest<'a> { @@ -50,494 +184,567 @@ pub struct AnswerRequest<'a> { pub user_message: &'a str, pub run: &'a RunSummary, pub analysis: &'a RunAnalysis, - pub turns: &'a [TurnSummary], - pub selected: Option<&'a TurnDetail>, - pub include_full_turn: bool, + pub focused_turn_id: Option, + pub thread: CopilotThread, + pub on_step: Option<&'a dyn Fn(&str)>, } -#[derive(Debug, Deserialize)] -struct Selection { - action: String, - skill_id: Option, - sql: Option, - #[serde(default)] - reply: String, -} - -pub fn load_config() -> LlmConfig { +pub fn load_thread(run: &RunSummary) -> CopilotThread { let Some(window) = web_sys::window() else { - return LlmConfig::default(); + return CopilotThread { + messages: Vec::new(), + updated_at: 0, + truncated: false, + }; }; let Some(storage) = window.local_storage().ok().flatten() else { - return LlmConfig::default(); + return CopilotThread { + messages: Vec::new(), + updated_at: 0, + truncated: false, + }; }; storage - .get_item(STORAGE_KEY) + .get_item(&thread_storage_key(run)) .ok() .flatten() .and_then(|raw| serde_json::from_str(&raw).ok()) - .unwrap_or_default() + .unwrap_or(CopilotThread { + messages: Vec::new(), + updated_at: 0, + truncated: false, + }) } -pub fn save_config(config: &LlmConfig) { +pub fn save_thread(run: &RunSummary, thread: &CopilotThread) { let Some(window) = web_sys::window() else { return; }; let Some(storage) = window.local_storage().ok().flatten() else { return; }; - if let Ok(raw) = serde_json::to_string(config) { - let _ = storage.set_item(STORAGE_KEY, &raw); + let mut thread = thread.clone(); + thread.updated_at = now_millis(); + trim_thread(&mut thread); + if let Ok(raw) = serde_json::to_string(&thread) { + let _ = storage.set_item(&thread_storage_key(run), &raw); } } -pub fn skill_ids() -> &'static [&'static str] { - &[ - "trajectory_summary", - "failure_locator", - "latency_hotspots", - "tool_usage", - "cohort_compare", - ] +fn now_millis() -> i64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|duration| duration.as_millis().min(i64::MAX as u128) as i64) + .unwrap_or(1) + .max(1) } -pub async fn answer(request: AnswerRequest<'_>) -> Result { - let AnswerRequest { - config, - user_message, - run, - analysis, - turns, - selected, - include_full_turn, - } = request; - let (base_context, context_truncated) = - evidence_context(run, analysis, turns, selected, include_full_turn); - let explicit_skill = resolve_skill(user_message); - if !config.is_configured() { - let skill = explicit_skill.unwrap_or("trajectory_summary"); - let (evidence, sql) = run_skill(skill, run, analysis, turns).await?; - let evidence = decorate_skill_evidence(skill, evidence, turns); - return Ok(AgentAnswer { - text: format!( - "**{}**\n\n{}\n\nConfigure an OpenAI-compatible model in Settings for a natural-language interpretation.", - skill_title(skill), - evidence - ), - action: skill.into(), - sql, - truncated: context_truncated, - }); - } +pub fn thread_storage_key(run: &RunSummary) -> String { + format!("pchronicle_copilot:{}", run.query()) +} - let selection = if let Some(skill) = explicit_skill { - Selection { - action: "skill".into(), - skill_id: Some(skill.into()), - sql: None, - reply: String::new(), - } - } else { - select_action(config, user_message, &base_context).await? - }; +pub fn thread_byte_size(thread: &CopilotThread) -> usize { + serde_json::to_string(thread) + .map(|raw| raw.len()) + .unwrap_or(0) +} - match selection.action.as_str() { - "sql" => { - let sql = selection - .sql - .filter(|sql| !sql.trim().is_empty()) - .ok_or_else(|| "The model selected SQL without returning a query.".to_string())?; - let result = api::query_evidence(&sql).await?; - let evidence = format!( - "SQL:\n{sql}\n\nreturned_rows={} truncated={}\n{}", - result.returned_rows, - result.truncated, - serde_json::to_string_pretty(&result.rows).unwrap_or_default() - ); - let summary = summarize(config, user_message, &base_context, &evidence).await?; - let component = table_fence("SQL query result", result.clone()); - Ok(AgentAnswer { - text: format!("{summary}\n\n{component}"), - action: "read-only SQL".into(), - sql: Some(sql), - truncated: context_truncated || result.truncated, - }) - } - "answer" => Ok(AgentAnswer { - text: if selection.reply.trim().is_empty() { - "I could not map that request to available trajectory evidence.".into() - } else { - selection.reply - }, - action: "context answer".into(), - sql: None, - truncated: context_truncated, - }), - _ => { - let skill = selection - .skill_id - .as_deref() - .filter(|skill| skill_ids().contains(skill)) - .unwrap_or("trajectory_summary"); - let (evidence, sql) = run_skill(skill, run, analysis, turns).await?; - let evidence = decorate_skill_evidence(skill, evidence, turns); - let components = component_fences(&evidence); - let summary = summarize(config, user_message, &base_context, &evidence).await?; - Ok(AgentAnswer { - text: if components.is_empty() { - summary - } else { - format!("{summary}\n\n{components}") - }, - action: skill.into(), - sql, - truncated: context_truncated, - }) - } +fn shrink_tool_text(text: &str) -> String { + const KEEP: usize = 512; + if text.len() <= KEEP { + return text.to_string(); + } + let mut end = KEEP.min(text.len()); + while end > 0 && !text.is_char_boundary(end) { + end -= 1; } + format!("{}\n[… truncated …]", &text[..end]) } -async fn run_skill( - skill: &str, - run: &RunSummary, - analysis: &RunAnalysis, - turns: &[TurnSummary], -) -> Result<(String, Option), String> { - match skill { - "failure_locator" => { - let evidence = turns - .iter() - .filter(|turn| turn.has_error) - .take(20) - .map(|turn| { - format!( - "- [turn:{}] {} {} — {}", - turn.id, - turn.source, - turn.kind.as_deref().unwrap_or("unknown"), - turn.preview - ) - }) - .collect::>(); - Ok(( - if evidence.is_empty() { - "No turns contain an explicit error kind, failing status, non-null error_type, or HTTP status >= 400. This does not prove the run succeeded.".into() - } else { - format!("Explicit error evidence:\n{}", evidence.join("\n")) - }, - None, - )) - } - "latency_hotspots" => { - let mut ranked = turns - .iter() - .filter_map(|turn| turn.latency_ms.map(|latency| (turn, latency))) - .collect::>(); - ranked.sort_by(|left, right| right.1.partial_cmp(&left.1).unwrap_or(Ordering::Equal)); - let lines = ranked - .into_iter() - .take(20) - .map(|(turn, latency)| { - format!("- [turn:{}] {:.1} ms — {}", turn.id, latency, turn.preview) - }) - .collect::>(); - Ok(( - format!( - "Latency coverage: {}/{} turns; P50={}; P95={}; max={}\n{}", - analysis.latency_ms.sample_count, - analysis.latency_ms.total_count, - optional_number(analysis.latency_ms.p50), - optional_number(analysis.latency_ms.p95), - optional_number(analysis.latency_ms.max), - if lines.is_empty() { - "No captured latency samples.".into() - } else { - lines.join("\n") - } - ), - None, - )) - } - "tool_usage" => Ok(( - if analysis.tools.is_empty() { - "No structured tool calls were captured.".into() - } else { - analysis - .tools - .iter() - .map(|tool| { - format!( - "- {}: {} calls, duration coverage {}/{}, total {}, average {}, max {}, error-associated {}", - tool.name, - tool.count, - tool.duration_sample_count, - tool.count, - optional_number(tool.total_duration_ms), - optional_number(tool.average_duration_ms), - optional_number(tool.max_duration_ms), - tool.error_associated_count, - ) - }) - .collect::>() - .join("\n") - }, - None, - )), - "cohort_compare" => { - let catalog = api::query_catalog().await?; - let database = catalog.database; - let session = sql_literal(&run.session_id); - let sql = format!( - "SELECT session_id, COUNT(*) AS step_count, AVG(latency_ms) AS avg_latency_ms, MAX(latency_ms) AS max_latency_ms FROM {database}.steps GROUP BY session_id ORDER BY avg_latency_ms DESC NULLS LAST LIMIT 50" - ); - let result = api::query_evidence(&sql).await?; - let component = table_fence("Cohort comparison", result.clone()); - Ok(( - format!( - "Selected session: {session}\nCohort rows={} truncated={}\n\n{}", - result.returned_rows, result.truncated, component - ), - Some(sql), - )) - } - _ => Ok((overview_evidence(run, analysis, turns), None)), +pub fn trim_thread(thread: &mut CopilotThread) { + while thread_byte_size(thread) > THREAD_BYTE_LIMIT { + let Some(index) = thread + .messages + .iter() + .position(|message| message.role == ThreadRole::Tool && !message.truncated) + else { + break; + }; + thread.messages[index].text = shrink_tool_text(&thread.messages[index].text); + thread.messages[index].truncated = true; + thread.truncated = true; } } -fn decorate_skill_evidence(skill: &str, evidence: String, turns: &[TurnSummary]) -> String { - let mut selected = match skill { - "failure_locator" => turns - .iter() - .filter(|turn| turn.has_error) - .map(|turn| turn.id) - .take(20) - .collect::>(), - "latency_hotspots" => { - let mut ranked = turns - .iter() - .filter_map(|turn| turn.latency_ms.map(|latency| (turn.id, latency))) - .collect::>(); - ranked.sort_by(|left, right| right.1.partial_cmp(&left.1).unwrap_or(Ordering::Equal)); - ranked.into_iter().map(|(id, _)| id).take(20).collect() +pub fn compress_messages_for_llm(messages: &[ThreadMessage]) -> Vec { + let mut out = messages.to_vec(); + loop { + let encoded = serde_json::to_string(&out).unwrap_or_default(); + if encoded.len() <= LLM_MESSAGE_BYTE_LIMIT { + return out; } - "tool_usage" => turns - .iter() - .filter(|turn| !turn.tool_names.is_empty()) - .map(|turn| turn.id) - .take(20) - .collect(), - "trajectory_summary" => turns.iter().map(|turn| turn.id).take(20).collect(), - _ => Vec::new(), - }; - selected.dedup(); - if selected.is_empty() { - evidence - } else { - format!( - "{evidence}\n\n{}", - trajectory_fence(skill_title(skill), selected) - ) + let Some(index) = out.iter().position(|message| { + message.role == ThreadRole::Tool + && message.text.len() > 64 + && shrink_tool_text(&message.text) != message.text + }) else { + return out; + }; + out[index].text = shrink_tool_text(&out[index].text); + out[index].truncated = true; } } -fn component_fences(value: &str) -> String { - let lines = value.lines().collect::>(); - let mut fences = Vec::new(); - let mut index = 0; - while index < lines.len() { - if lines[index].starts_with("```pchronicle:") { - let start = index; - index += 1; - while index < lines.len() && lines[index] != "```" { - index += 1; +pub async fn answer(request: AnswerRequest<'_>) -> Result { + if !request.config.is_configured() { + return Err( + "Configure an OpenAI-compatible model in Settings before asking Copilot.".into(), + ); + } + + let mut state = LoopState { + messages: request.thread.messages.clone(), + tool_rounds: 0, + json_mode: false, + illegal_json_streak: 0, + fetched_turn_ids: Vec::new(), + force_final: false, + }; + state.messages.push(ThreadMessage { + role: ThreadRole::User, + text: request.user_message.to_string(), + tool_calls: None, + tool_call_id: None, + tool_name: None, + sql: None, + truncated: false, + }); + + let catalog_context = match api::query_catalog().await { + Ok(catalog) => format_catalog_schema(&catalog), + Err(_) => "SQL catalog unavailable; do not guess table or column names.".into(), + }; + let base_system = system_prompt( + request.run, + request.analysis, + request.focused_turn_id, + &catalog_context, + ); + let mut last_sql = None; + let mut evidence_truncated = false; + + loop { + let tools_enabled = !state.json_mode && !state.force_final; + let system = mode_system_prompt(&base_system, state.json_mode, state.force_final); + let messages = + openai_messages(&compress_messages_for_llm(&state.messages), state.json_mode); + let message = match chat_with_tools( + request.config, + &system, + messages, + tools_enabled, + state.json_mode, + ) + .await + { + Ok(message) => message, + Err(error) if !state.json_mode && error.suggests_tools_unsupported() => { + state.json_mode = true; + continue; } - if index < lines.len() { - fences.push(lines[start..=index].join("\n")); + Err(error) => return Err(error.message), + }; + + let turn = if state.json_mode { + message + .get("content") + .and_then(Value::as_str) + .map(parse_json_fallback) + .unwrap_or(AssistantTurn::Invalid) + } else { + parse_native_message(&message) + }; + + let mut results = VecDeque::new(); + let mut sql_by_call = HashMap::new(); + if let AssistantTurn::ToolCalls(calls) = &turn { + let remaining = MAX_TOOL_ROUNDS.saturating_sub(state.tool_rounds); + for call in calls.iter().take(remaining) { + if let Some(on_step) = request.on_step { + on_step(&tool_step(call)); + } + let execution = execute_tool(call, request.run, request.analysis).await; + if execution.truncated { + evidence_truncated = true; + } + if let Some(sql) = execution.sql { + last_sql = Some(sql.clone()); + sql_by_call.insert(call.id.clone(), sql); + } + results.push_back(execution.text); + } + } + + let result = apply_model_turn(&mut state, turn, |_| { + results + .pop_front() + .unwrap_or_else(|| "Tool call budget exhausted.".into()) + }); + for message in state.messages.iter_mut().rev() { + let Some(call_id) = message.tool_call_id.as_ref() else { + continue; + }; + if let Some(sql) = sql_by_call.remove(call_id) { + message.sql = Some(sql); + } + if sql_by_call.is_empty() { + break; + } + } + + match result { + DriveResult::Continue => {} + DriveResult::Done { text } => { + return Ok(finish_answer( + request.thread, + state, + text, + last_sql, + evidence_truncated, + )); + } + DriveResult::Failed { message } => { + return Ok(finish_answer( + request.thread, + state, + message, + last_sql, + evidence_truncated, + )); } } - index += 1; } - fences.join("\n\n") } -fn overview_evidence(run: &RunSummary, analysis: &RunAnalysis, turns: &[TurnSummary]) -> String { - let top = turns - .iter() - .take(12) - .map(|turn| format!("- [turn:{}] {} — {}", turn.id, turn.source, turn.preview)) - .collect::>() - .join("\n"); - format!( - "Run: agent={} session={} status={}\nEvents={} turns={} tools={} explicit_errors={}\nTokens: prompt={} completion={} total={}\nLatency: samples={}/{} p50={} p95={} max={}\nTurn evidence:\n{}", - run.agent_id, +fn system_prompt( + run: &RunSummary, + analysis: &RunAnalysis, + focused_turn_id: Option, + catalog_context: &str, +) -> String { + let mut prompt = format!( + "You are pChronicle Copilot for local agent trajectory debugging. Gather evidence only for the current run. Call tools when details are needed; do not invent evidence. Missing measurements are not zero. Do not infer an error from arbitrary message text. Answer in the user's language, preferably in 3–7 concise bullets. Separate captured facts from inference. Cite every inspected turn as [turn:ID]. Mention coverage or truncation when tool results report it.\n\nCurrent run analysis:\nsession={}\nstatus={}\nturn_count={}\nevent_count={}\nerror_count={}\ntotal_tokens={}\nlatency_p95={}\nlatency_samples={}/{}\n\nquery_sql schema:\n{}", run.session_id, run.status, - analysis.event_count, analysis.turn_count, - analysis.tool_call_count, + analysis.event_count, analysis.error_count, - optional_u64(analysis.prompt_tokens), - optional_u64(analysis.completion_tokens), - optional_u64(analysis.total_tokens), + analysis + .total_tokens + .map(|value| value.to_string()) + .unwrap_or_else(|| "unavailable".into()), + analysis + .latency_ms + .p95 + .map(|value| format!("{value:.1}")) + .unwrap_or_else(|| "unavailable".into()), analysis.latency_ms.sample_count, analysis.latency_ms.total_count, - optional_number(analysis.latency_ms.p50), - optional_number(analysis.latency_ms.p95), - optional_number(analysis.latency_ms.max), - top - ) + catalog_context, + ); + if let Some(turn_id) = focused_turn_id { + prompt.push_str(&format!( + "\nThe user is currently viewing turn #{turn_id}. Do not assume its body; call get_turn if needed." + )); + } + prompt } -fn evidence_context( - run: &RunSummary, - analysis: &RunAnalysis, - turns: &[TurnSummary], - selected: Option<&TurnDetail>, - include_full_turn: bool, -) -> (String, bool) { - let mut context = overview_evidence(run, analysis, turns); - if let Some(detail) = selected { - context.push_str(&format!( - "\nSelected [turn:{}]: source={} kind={} model={} latency={} tools={}\n", - detail.summary.id, - detail.summary.source, - detail.summary.kind.as_deref().unwrap_or("unknown"), - detail - .summary - .model_name - .as_deref() - .unwrap_or("unavailable"), - optional_number(detail.summary.latency_ms), - detail.summary.tool_names.join(", ") - )); - if detail.summary.source != "system" { - let text = detail.turn.text(); - let excerpt_limit = if include_full_turn { - FULL_CONTEXT_LIMIT - } else { - 4 * 1024 - }; - context.push_str("Selected content:\n"); - context.push_str(&truncate(&text, excerpt_limit).0); - } else { - context.push_str("System content omitted by the minimal-evidence policy."); - } - if include_full_turn { - context.push_str("\nTool calls:\n"); - context.push_str( - &truncate( - &serde_json::to_string_pretty(&detail.wire_tool_calls).unwrap_or_default(), - 12 * 1024, - ) - .0, - ); - } +fn format_catalog_schema(catalog: &QueryCatalog) -> String { + let tables = crate::model::queryable_tables(catalog); + if tables.is_empty() { + return "SQL catalog is available but contains no tables.".into(); } - let limit = if include_full_turn { - FULL_CONTEXT_LIMIT - } else { - DEFAULT_CONTEXT_LIMIT - }; - truncate(&context, limit) + tables + .iter() + .map(|table| { + let fields = table + .fields + .iter() + .map(|field| format!("{} {}", field.name, field.data_type)) + .collect::>() + .join(", "); + format!("{} ({fields})", table.name) + }) + .collect::>() + .join("\n") } -async fn select_action( - config: &LlmConfig, - user_message: &str, - context: &str, -) -> Result { - let catalog = api::query_catalog().await.ok(); - let database = catalog - .as_ref() - .map(|catalog| catalog.database.as_str()) - .unwrap_or("data"); - let system = format!( - "You are pChronicle Copilot for local agent trajectory debugging. Select exactly one action. Return JSON only: {{\"action\":\"skill|sql|answer\",\"skill_id\":\"trajectory_summary|failure_locator|latency_hotspots|tool_usage|cohort_compare|null\",\"sql\":null,\"reply\":\"\"}}. For SQL, emit exactly one read-only SELECT/WITH/EXPLAIN over {database}.runs, {database}.steps, {database}.tool_calls, or {database}.trajectories. Prefer a built-in skill. Never claim missing data is zero and never infer an error from arbitrary message text.\n\nWorkspace evidence:\n{context}" - ); - let text = chat(config, &system, user_message, true).await?; - serde_json::from_str(extract_json(&text)) - .map_err(|error| format!("The model returned invalid routing JSON: {error}")) +fn mode_system_prompt(base: &str, json_mode: bool, force_final: bool) -> String { + let mut prompt = base.to_string(); + if json_mode { + prompt.push_str( + "\nReturn JSON only: either {\"tool\":\"get_analysis|get_turn|query_sql\",\"arguments\":{}} or {\"final\":\"...\"}.", + ); + } + if force_final { + prompt.push_str("\nAnswer now from evidence already gathered. Do not call tools."); + } + prompt } -async fn summarize( - config: &LlmConfig, - question: &str, - context: &str, - evidence: &str, -) -> Result { - let system = "You are pChronicle Copilot. Answer in the user's language in 3-7 concise bullets. Separate captured facts from inference. Cite relevant turns using the exact form [turn:ID]. Mention coverage and truncation when present. Do not invent costs, errors, or missing measurements."; - let user = format!( - "Question: {question}\n\nMinimal workspace context:\n{context}\n\nExecuted evidence:\n{evidence}" - ); - chat(config, system, &user, false).await +fn openai_messages(messages: &[ThreadMessage], json_mode: bool) -> Vec { + let mut mapped = Vec::new(); + for message in messages { + match message.role { + ThreadRole::User => { + mapped.push(json!({"role": "user", "content": message.text})); + } + ThreadRole::Assistant => { + if let Some(calls) = message + .tool_calls + .as_ref() + .filter(|calls| !calls.is_empty()) + { + if json_mode { + let replay = calls + .iter() + .map(|call| { + serde_json::to_string(&json!({ + "tool": call.name, + "arguments": call.arguments, + })) + .unwrap_or_else(|_| "{}".into()) + }) + .collect::>() + .join("\n"); + mapped.push(json!({"role": "assistant", "content": replay})); + } else { + let tool_calls = calls + .iter() + .map(|call| { + json!({ + "id": call.id, + "type": "function", + "function": { + "name": call.name, + "arguments": serde_json::to_string(&call.arguments) + .unwrap_or_else(|_| "{}".into()), + }, + }) + }) + .collect::>(); + mapped.push(json!({ + "role": "assistant", + "content": null, + "tool_calls": tool_calls, + })); + } + } else { + mapped.push(json!({"role": "assistant", "content": message.text})); + } + } + ThreadRole::Tool if json_mode => { + let name = message.tool_name.as_deref().unwrap_or("unknown"); + mapped.push(json!({ + "role": "user", + "content": format!("Tool {name} result:\n{}", message.text), + })); + } + ThreadRole::Tool => { + mapped.push(json!({ + "role": "tool", + "tool_call_id": message.tool_call_id, + "content": message.text, + })); + } + } + } + + mapped +} + +fn tools_payload() -> Value { + json!([ + { + "type": "function", + "function": { + "name": TOOL_NAMES[0], + "description": "Get aggregate analysis for the current run.", + "parameters": {"type": "object", "properties": {}} + } + }, + { + "type": "function", + "function": { + "name": TOOL_NAMES[1], + "description": "Fetch one turn in the current run.", + "parameters": { + "type": "object", + "properties": {"turn_id": {"type": "integer"}}, + "required": ["turn_id"] + } + } + }, + { + "type": "function", + "function": { + "name": TOOL_NAMES[2], + "description": "Run one server-enforced read-only SQL query.", + "parameters": { + "type": "object", + "properties": {"sql": {"type": "string"}}, + "required": ["sql"] + } + } + } + ]) } -async fn chat( +async fn chat_with_tools( config: &LlmConfig, system: &str, - user: &str, + messages: Vec, + tools_enabled: bool, json_mode: bool, -) -> Result { - let url = format!( - "{}/chat/completions", - config.api_base.trim().trim_end_matches('/') - ); - let mut body = json!({ - "model": config.model.trim(), - "temperature": if json_mode { 0.1 } else { 0.3 }, - "messages": [ - {"role":"system","content":system}, - {"role":"user","content":user} - ] - }); - if json_mode { - body["response_format"] = json!({"type":"json_object"}); +) -> Result { + let first = llm::complete( + config, + llm::CompletionRequest { + system: system.into(), + messages: messages.clone(), + tools: tools_enabled.then(tools_payload), + response_format: json_mode.then(|| json!({"type":"json_object"})), + temperature: if json_mode { 0.1 } else { 0.3 }, + }, + ) + .await; + match first { + Err(error) if json_mode && error.suggests_response_format_unsupported() => { + llm::complete( + config, + llm::CompletionRequest { + system: system.into(), + messages, + tools: tools_enabled.then(tools_payload), + response_format: None, + temperature: if json_mode { 0.1 } else { 0.3 }, + }, + ) + .await + } + result => result, } - let response = Request::post(&url) - .header( - "Authorization", - &format!("Bearer {}", config.api_key.trim()), - ) - .header("Content-Type", "application/json") - .json(&body) - .map_err(|error| error.to_string())? - .send() - .await - .map_err(|error| format!("LLM request failed (check API base, key, and CORS): {error}"))?; - let status = response.status(); - let value: Value = response.json().await.map_err(|error| error.to_string())?; - if !(200..300).contains(&status) { - return Err(format!("LLM HTTP {status}: {value}")); - } - value["choices"][0]["message"]["content"] - .as_str() - .filter(|value| !value.trim().is_empty()) - .map(str::to_string) - .ok_or_else(|| "LLM returned an empty response".into()) -} - -fn resolve_skill(message: &str) -> Option<&'static str> { - let normalized = message.trim().trim_start_matches('/').to_ascii_lowercase(); - skill_ids().iter().copied().find(|skill| { - normalized == *skill - || normalized.starts_with(&format!("{skill} ")) - || match *skill { - "failure_locator" => normalized.contains("fail") || normalized.contains("error"), - "latency_hotspots" => normalized.contains("slow") || normalized.contains("latency"), - "tool_usage" => normalized.contains("tool"), - "cohort_compare" => normalized.contains("compare") || normalized.contains("cohort"), - _ => false, +} + +struct ToolExecution { + text: String, + sql: Option, + truncated: bool, +} + +async fn execute_tool( + call: &ParsedToolCall, + run: &RunSummary, + analysis: &RunAnalysis, +) -> ToolExecution { + match call.name.as_str() { + "get_analysis" => ToolExecution { + text: format_analysis_result(analysis), + sql: None, + truncated: false, + }, + "get_turn" => { + let turn_id = call.arguments.get("turn_id").and_then(|value| { + value + .as_i64() + .or_else(|| value.as_str().and_then(|raw| raw.parse().ok())) + }); + let text = match turn_id { + Some(turn_id) => api::turn_detail(run, turn_id) + .await + .map(|detail| format_turn_result(&detail)) + .unwrap_or_else(|error| format!("get_turn failed: {error}")), + None => "get_turn failed: `turn_id` must be an integer.".into(), + }; + ToolExecution { + truncated: text.contains("truncated=true"), + text, + sql: None, } - }) + } + "query_sql" => { + let Some(sql) = call.arguments.get("sql").and_then(Value::as_str) else { + return ToolExecution { + text: "query_sql failed: `sql` must be a string.".into(), + sql: None, + truncated: false, + }; + }; + match api::query_evidence(sql).await { + Ok(evidence) => ToolExecution { + text: format_sql_result(sql, &evidence), + sql: Some(sql.to_string()), + truncated: evidence.truncated, + }, + Err(error) => ToolExecution { + text: format!("query_sql failed: {error}"), + sql: None, + truncated: false, + }, + } + } + name => ToolExecution { + text: unknown_tool_result(name), + sql: None, + truncated: false, + }, + } } -fn skill_title(skill: &str) -> &'static str { - match skill { - "failure_locator" => "Failure locator", - "latency_hotspots" => "Latency hotspots", - "tool_usage" => "Tool usage", - "cohort_compare" => "Cohort compare", - _ => "Trajectory summary", +fn tool_step(call: &ParsedToolCall) -> String { + if call.name == "get_turn" { + if let Some(turn_id) = call.arguments.get("turn_id").and_then(|value| { + value + .as_i64() + .or_else(|| value.as_str().and_then(|raw| raw.parse().ok())) + }) { + return format!("get_turn #{turn_id}"); + } + } + call.name.clone() +} + +fn finish_answer( + mut thread: CopilotThread, + state: LoopState, + mut text: String, + sql: Option, + evidence_truncated: bool, +) -> AgentAnswer { + let fetched_turn_ids = state.fetched_turn_ids; + if !fetched_turn_ids.is_empty() { + text.push_str("\n\n"); + text.push_str(&trajectory_fence("Cited turns", fetched_turn_ids.clone())); + } + thread.messages = state.messages; + thread.messages.push(ThreadMessage { + role: ThreadRole::Assistant, + text: text.clone(), + tool_calls: None, + tool_call_id: None, + tool_name: None, + sql: sql.clone(), + truncated: evidence_truncated, + }); + thread.updated_at = now_millis(); + trim_thread(&mut thread); + AgentAnswer { + truncated: evidence_truncated || thread.truncated, + thread, + text, + sql, + fetched_turn_ids, } } @@ -556,6 +763,97 @@ fn extract_json(value: &str) -> &str { value } +fn parse_arguments(value: &Value) -> Result { + match value { + Value::String(raw) => { + let parsed: Value = serde_json::from_str(raw).map_err(|_| ())?; + if parsed.is_object() { + Ok(parsed) + } else { + Err(()) + } + } + Value::Object(_) => Ok(value.clone()), + _ => Err(()), + } +} + +pub fn parse_native_message(message: &Value) -> AssistantTurn { + if let Some(tool_calls) = message.get("tool_calls") { + let Some(calls) = tool_calls.as_array() else { + return AssistantTurn::Invalid; + }; + if !calls.is_empty() { + let mut parsed = Vec::new(); + for (index, call) in calls.iter().enumerate() { + let fallback = format!("call-{index}"); + let id = call + .get("id") + .and_then(Value::as_str) + .unwrap_or(&fallback) + .to_string(); + let name = call + .pointer("/function/name") + .and_then(Value::as_str) + .unwrap_or("") + .to_string(); + let arguments = call + .pointer("/function/arguments") + .ok_or(()) + .and_then(parse_arguments); + match (name.is_empty(), arguments) { + (false, Ok(arguments)) => parsed.push(ParsedToolCall { + id, + name, + arguments, + }), + _ => return AssistantTurn::Invalid, + } + } + return AssistantTurn::ToolCalls(parsed); + } + // empty array: fall through to content + } + match message.get("content").and_then(Value::as_str) { + Some(text) if !text.trim().is_empty() => AssistantTurn::Final(text.to_string()), + _ => AssistantTurn::Invalid, + } +} + +pub fn parse_json_fallback(content: &str) -> AssistantTurn { + let raw = extract_json(content); + let Ok(value) = serde_json::from_str::(raw) else { + return AssistantTurn::Invalid; + }; + if let Some(final_text) = value + .get("final") + .and_then(Value::as_str) + .filter(|text| !text.trim().is_empty()) + { + return AssistantTurn::Final(final_text.to_string()); + } + let Some(name) = value.get("tool").and_then(Value::as_str) else { + return AssistantTurn::Invalid; + }; + if !matches!(name, "get_analysis" | "get_turn" | "query_sql") { + return AssistantTurn::Invalid; + } + let arguments = match value.get("arguments") { + None => json!({}), + Some(args) if args.is_object() => args.clone(), + Some(_) => return AssistantTurn::Invalid, + }; + AssistantTurn::ToolCalls(vec![ParsedToolCall { + id: format!( + "json-{}-{}", + now_millis(), + JSON_TOOL_ID.fetch_add(1, Ordering::Relaxed) + ), + name: name.into(), + arguments, + }]) +} + fn truncate(value: &str, limit: usize) -> (String, bool) { if value.len() <= limit { return (value.to_string(), false); @@ -567,25 +865,333 @@ fn truncate(value: &str, limit: usize) -> (String, bool) { (format!("{}\n[… truncated …]", &value[..end]), true) } -fn optional_number(value: Option) -> String { - value - .map(|value| format!("{value:.1}")) - .unwrap_or_else(|| "unavailable".into()) +pub fn unknown_tool_result(name: &str) -> String { + format!("Unknown tool `{name}`. Valid tools: get_analysis, get_turn, query_sql.") } -fn optional_u64(value: Option) -> String { - value - .map(|value| value.to_string()) - .unwrap_or_else(|| "unavailable".into()) +pub fn format_analysis_result(analysis: &RunAnalysis) -> String { + format!( + "turns={} events={} tools={} explicit_errors={} tokens={} latency_p95={} latency_samples={}/{}\nsources={:?}\nkinds={:?}\nmodels={:?}\ntool_names={:?}", + analysis.turn_count, + analysis.event_count, + analysis.tool_call_count, + analysis.error_count, + analysis + .total_tokens + .map(|value| value.to_string()) + .unwrap_or_else(|| "unavailable".into()), + analysis + .latency_ms + .p95 + .map(|value| format!("{value:.1}")) + .unwrap_or_else(|| "unavailable".into()), + analysis.latency_ms.sample_count, + analysis.latency_ms.total_count, + analysis + .source_breakdown + .iter() + .map(|item| format!("{}:{}", item.name, item.turn_count)) + .collect::>(), + analysis + .kind_breakdown + .iter() + .map(|item| format!("{}:{}", item.name, item.turn_count)) + .collect::>(), + analysis + .model_breakdown + .iter() + .map(|item| format!("{}:{}", item.name, item.turn_count)) + .collect::>(), + analysis + .tools + .iter() + .map(|tool| format!("{}:{}", tool.name, tool.count)) + .collect::>(), + ) } -fn sql_literal(value: &str) -> String { - format!("'{}'", value.replace('\'', "''")) +pub fn format_turn_result(detail: &TurnDetail) -> String { + let (body, truncated) = truncate(&detail.turn.text(), TURN_BODY_LIMIT); + format!( + "[turn:{}] source={} kind={} model={} latency={} tools={}\n{}\n{}", + detail.summary.id, + detail.summary.source, + detail.summary.kind.as_deref().unwrap_or("unknown"), + detail + .summary + .model_name + .as_deref() + .unwrap_or("unavailable"), + detail + .summary + .latency_ms + .map(|value| format!("{value:.1}")) + .unwrap_or_else(|| "unavailable".into()), + detail.summary.tool_names.join(","), + body, + if truncated { + "truncated=true" + } else { + "truncated=false" + } + ) +} + +pub fn format_sql_result(sql: &str, evidence: &QueryEvidence) -> String { + format!( + "SQL:\n{sql}\nreturned_rows={} truncated={}\n{}", + evidence.returned_rows, + evidence.truncated, + serde_json::to_string(&evidence.rows).unwrap_or_default() + ) } #[cfg(test)] mod tests { use super::*; + use crate::model::TurnSummary; + + fn empty_state() -> LoopState { + LoopState { + messages: Vec::new(), + tool_rounds: 0, + json_mode: false, + illegal_json_streak: 0, + fetched_turn_ids: Vec::new(), + force_final: false, + } + } + + #[test] + fn unconfigured_config_is_detected() { + let config = LlmConfig::default(); + assert!(!config.is_configured()); + } + + #[test] + fn loop_runs_three_tools_then_final() { + let mut state = empty_state(); + let calls = vec![ + ParsedToolCall { + id: "1".into(), + name: "get_analysis".into(), + arguments: json!({}), + }, + ParsedToolCall { + id: "2".into(), + name: "get_turn".into(), + arguments: json!({"turn_id": 4}), + }, + ParsedToolCall { + id: "3".into(), + name: "query_sql".into(), + arguments: json!({"sql": "SELECT 1"}), + }, + ]; + let result = apply_model_turn(&mut state, AssistantTurn::ToolCalls(calls), |call| { + format!("ok {}", call.name) + }); + assert!(matches!(result, DriveResult::Continue)); + assert_eq!(state.tool_rounds, 3); + assert_eq!(state.messages.len(), 4); + assert_eq!(state.messages[0].role, ThreadRole::Assistant); + assert_eq!(state.messages[0].tool_calls.as_ref().unwrap().len(), 3); + let done = apply_model_turn( + &mut state, + AssistantTurn::Final("see [turn:4]".into()), + |_| String::new(), + ); + assert_eq!( + done, + DriveResult::Done { + text: "see [turn:4]".into() + } + ); + } + + #[test] + fn unknown_tool_does_not_stop_the_loop() { + let mut state = empty_state(); + let call = ParsedToolCall { + id: "1".into(), + name: "drop".into(), + arguments: json!({}), + }; + let result = apply_model_turn(&mut state, AssistantTurn::ToolCalls(vec![call]), |call| { + unknown_tool_result(&call.name) + }); + assert!(matches!(result, DriveResult::Continue)); + assert!(state.messages[1].text.contains("Unknown tool")); + } + + #[test] + fn two_invalid_json_rounds_stop() { + let mut state = empty_state(); + state.json_mode = true; + assert!(matches!( + apply_model_turn(&mut state, AssistantTurn::Invalid, |_| String::new()), + DriveResult::Continue + )); + match apply_model_turn(&mut state, AssistantTurn::Invalid, |_| String::new()) { + DriveResult::Failed { message } => assert!(message.contains("tool-calling")), + other => panic!("{other:?}"), + } + } + + #[test] + fn eighth_tool_sets_force_final() { + let mut state = empty_state(); + state.tool_rounds = 7; + let call = ParsedToolCall { + id: "1".into(), + name: "get_analysis".into(), + arguments: json!({}), + }; + apply_model_turn(&mut state, AssistantTurn::ToolCalls(vec![call]), |_| { + "ok".into() + }); + assert_eq!(state.tool_rounds, 8); + assert!(state.force_final); + } + + #[test] + fn apply_model_turn_force_final_tool_calls_fail_instead_of_continuing() { + let mut state = empty_state(); + state.force_final = true; + let call = ParsedToolCall { + id: "late".into(), + name: "get_analysis".into(), + arguments: json!({}), + }; + let mut executed = false; + let result = apply_model_turn(&mut state, AssistantTurn::ToolCalls(vec![call]), |_| { + executed = true; + "should not execute".into() + }); + match result { + DriveResult::Failed { message } => { + assert!(message.contains("final answer")); + assert!(message.contains("evidence")); + } + other => panic!("{other:?}"), + } + assert!(!executed); + assert!(state.messages.is_empty()); + } + + #[test] + fn tool_batch_executes_only_remaining_budget() { + let mut state = empty_state(); + state.tool_rounds = 7; + let calls = vec![ + ParsedToolCall { + id: "first".into(), + name: "get_analysis".into(), + arguments: json!({}), + }, + ParsedToolCall { + id: "leftover".into(), + name: "query_sql".into(), + arguments: json!({"sql": "SELECT 1"}), + }, + ]; + let mut executed = Vec::new(); + let result = apply_model_turn(&mut state, AssistantTurn::ToolCalls(calls), |call| { + executed.push(call.id.clone()); + "ok".into() + }); + assert_eq!(result, DriveResult::Continue); + assert_eq!(executed, vec!["first"]); + assert_eq!(state.messages.len(), 2); + assert_eq!( + state.messages[0] + .tool_calls + .as_ref() + .unwrap() + .iter() + .map(|call| call.id.as_str()) + .collect::>(), + vec!["first"] + ); + assert!(state.force_final); + } + + #[test] + fn records_only_successfully_fetched_turn_ids() { + let mut state = empty_state(); + let calls = vec![ + ParsedToolCall { + id: "number".into(), + name: "get_turn".into(), + arguments: json!({"turn_id": 4}), + }, + ParsedToolCall { + id: "string".into(), + name: "get_turn".into(), + arguments: json!({"turn_id": "5"}), + }, + ParsedToolCall { + id: "duplicate".into(), + name: "get_turn".into(), + arguments: json!({"turn_id": 4}), + }, + ParsedToolCall { + id: "missing-marker".into(), + name: "get_turn".into(), + arguments: json!({"turn_id": 6}), + }, + ]; + apply_model_turn( + &mut state, + AssistantTurn::ToolCalls(calls), + |call| match call.id.as_str() { + "number" | "duplicate" => "[turn:4] evidence".into(), + "string" => "[turn:5] evidence".into(), + _ => "Turn evidence could not be loaded".into(), + }, + ); + assert_eq!(state.fetched_turn_ids, vec![4, 5]); + } + + #[test] + fn native_invalid_switches_mode_without_incrementing_streak() { + let mut state = empty_state(); + assert_eq!( + apply_model_turn(&mut state, AssistantTurn::Invalid, |_| String::new()), + DriveResult::Continue + ); + assert!(state.json_mode); + assert_eq!(state.illegal_json_streak, 0); + } + + #[test] + fn final_resets_invalid_streak_and_tool_messages_keep_call_metadata() { + let mut state = empty_state(); + state.illegal_json_streak = 1; + let call = ParsedToolCall { + id: "call-7".into(), + name: "query_sql".into(), + arguments: json!({"sql": "SELECT 7"}), + }; + apply_model_turn(&mut state, AssistantTurn::ToolCalls(vec![call]), |_| { + "seven".into() + }); + assert_eq!(state.messages[0].role, ThreadRole::Assistant); + assert_eq!(state.messages[1].role, ThreadRole::Tool); + assert_eq!(state.messages[1].tool_call_id.as_deref(), Some("call-7")); + assert_eq!(state.messages[1].tool_name.as_deref(), Some("query_sql")); + assert_eq!(state.messages[1].sql, None); + + assert_eq!( + apply_model_turn(&mut state, AssistantTurn::Final("done".into()), |_| { + String::new() + }), + DriveResult::Done { + text: "done".into() + } + ); + assert_eq!(state.illegal_json_streak, 0); + } #[test] fn context_truncation_stays_on_utf8_boundaries() { @@ -594,9 +1200,597 @@ mod tests { assert!(value.starts_with("轨轨")); } + fn sample_run(session: &str, run_id: Option<&str>) -> RunSummary { + RunSummary { + dataset: "captures".into(), + file: "events.lance".into(), + run_id: run_id.map(str::to_string), + agent_id: "agent".into(), + model_name: None, + session_id: session.into(), + root_session_id: None, + path: String::new(), + row_count: 1, + duplicate_event_ids: 0, + status: "completed".into(), + } + } + + fn tool_msg(text: &str) -> ThreadMessage { + ThreadMessage { + role: ThreadRole::Tool, + text: text.into(), + tool_calls: None, + tool_call_id: Some("call-1".into()), + tool_name: Some("query_sql".into()), + sql: None, + truncated: false, + } + } + + #[test] + fn thread_key_follows_run_query_and_isolates_sessions() { + let a = sample_run("s-a", Some("r1")); + let b = sample_run("s-b", Some("r1")); + let no_run = sample_run("s-a", None); + assert_eq!( + thread_storage_key(&a), + format!("pchronicle_copilot:{}", a.query()) + ); + assert_ne!(thread_storage_key(&a), thread_storage_key(&b)); + assert_eq!( + thread_storage_key(&no_run), + format!("pchronicle_copilot:{}", no_run.query()) + ); + assert!(!thread_storage_key(&no_run).contains("run_id=")); + } + + #[test] + fn trim_thread_shrinks_oldest_tool_results_first() { + let mut thread = CopilotThread { + messages: vec![ + ThreadMessage { + role: ThreadRole::User, + text: "keep me".into(), + tool_calls: None, + tool_call_id: None, + tool_name: None, + sql: None, + truncated: false, + }, + tool_msg(&"x".repeat(180 * 1024)), + tool_msg(&"y".repeat(180 * 1024)), + ThreadMessage { + role: ThreadRole::Assistant, + text: "final".into(), + tool_calls: None, + tool_call_id: None, + tool_name: None, + sql: None, + truncated: false, + }, + ], + updated_at: 1, + truncated: false, + }; + trim_thread(&mut thread); + assert!(thread.truncated); + assert!(thread_byte_size(&thread) <= THREAD_BYTE_LIMIT); + assert_eq!(thread.messages[0].text, "keep me"); + assert_eq!(thread.messages[3].text, "final"); + assert!(thread.messages[1].truncated); + assert!(thread.messages[1].text.len() < 180 * 1024); + } + + #[test] + fn compress_messages_for_llm_caps_tool_payload() { + let messages = vec![ + tool_msg(&"z".repeat(40 * 1024)), + ThreadMessage { + role: ThreadRole::User, + text: "q".into(), + tool_calls: None, + tool_call_id: None, + tool_name: None, + sql: None, + truncated: false, + }, + ]; + let compressed = compress_messages_for_llm(&messages); + let encoded = serde_json::to_string(&compressed).unwrap(); + assert!(encoded.len() <= LLM_MESSAGE_BYTE_LIMIT); + assert_eq!(compressed.last().unwrap().text, "q"); + } + + #[test] + fn compress_messages_for_llm_stops_when_tool_payload_cannot_shrink() { + let unshrinkable_tool = tool_msg(&"t".repeat(200)); + let bulk = ThreadMessage { + role: ThreadRole::User, + text: "u".repeat(35 * 1024), + tool_calls: None, + tool_call_id: None, + tool_name: None, + sql: None, + truncated: false, + }; + let messages = vec![unshrinkable_tool, bulk]; + let before = serde_json::to_string(&messages).unwrap(); + assert!(before.len() > LLM_MESSAGE_BYTE_LIMIT); + + let compressed = compress_messages_for_llm(&messages); + let after = serde_json::to_string(&compressed).unwrap(); + assert!(after.len() <= before.len()); + assert_eq!(compressed[0].text, "t".repeat(200)); + } + + #[test] + fn native_tool_calls_parse_arguments_string() { + let message = serde_json::json!({ + "role": "assistant", + "content": null, + "tool_calls": [{ + "id": "c1", + "type": "function", + "function": {"name": "get_turn", "arguments": "{\"turn_id\":12}"} + }] + }); + match parse_native_message(&message) { + AssistantTurn::ToolCalls(calls) => { + assert_eq!(calls[0].id, "c1"); + assert_eq!(calls[0].name, "get_turn"); + assert_eq!(calls[0].arguments["turn_id"], 12); + } + other => panic!("{other:?}"), + } + } + + #[test] + fn native_prose_is_final_not_invalid() { + let message = serde_json::json!({"content": "3 turns, no explicit errors."}); + assert_eq!( + parse_native_message(&message), + AssistantTurn::Final("3 turns, no explicit errors.".into()) + ); + } + + #[test] + fn json_fallback_accepts_tool_and_final() { + assert!(matches!( + parse_json_fallback(r#"{"tool":"get_analysis","arguments":{}}"#), + AssistantTurn::ToolCalls(_) + )); + assert_eq!( + parse_json_fallback("```json\n{\"final\":\"done\"}\n```"), + AssistantTurn::Final("done".into()) + ); + assert_eq!(parse_json_fallback("not json"), AssistantTurn::Invalid); + } + + #[test] + fn json_fallback_tool_call_ids_are_unique() { + let first = parse_json_fallback(r#"{"tool":"get_analysis","arguments":{}}"#); + let second = parse_json_fallback(r#"{"tool":"get_analysis","arguments":{}}"#); + let AssistantTurn::ToolCalls(first) = first else { + panic!("expected first tool call"); + }; + let AssistantTurn::ToolCalls(second) = second else { + panic!("expected second tool call"); + }; + assert_ne!(first[0].id, second[0].id); + } + + #[test] + fn json_fallback_rejects_empty_final() { + assert_eq!( + parse_json_fallback(r#"{"final":""}"#), + AssistantTurn::Invalid + ); + assert_eq!( + parse_json_fallback(r#"{"final":" \n\t"}"#), + AssistantTurn::Invalid + ); + } + + #[test] + fn openai_messages_precede_native_tool_runs_with_assistant_calls() { + let messages = vec![ + ThreadMessage { + role: ThreadRole::User, + text: "inspect".into(), + tool_calls: None, + tool_call_id: None, + tool_name: None, + sql: None, + truncated: false, + }, + ThreadMessage { + role: ThreadRole::Assistant, + text: String::new(), + tool_calls: Some(vec![ + ParsedToolCall { + id: "analysis-1".into(), + name: "get_analysis".into(), + arguments: json!({}), + }, + ParsedToolCall { + id: "turn-1".into(), + name: "get_turn".into(), + arguments: json!({"turn_id": 4}), + }, + ]), + tool_call_id: None, + tool_name: None, + sql: None, + truncated: false, + }, + ThreadMessage { + role: ThreadRole::Tool, + text: "analysis result".into(), + tool_calls: None, + tool_call_id: Some("analysis-1".into()), + tool_name: Some("get_analysis".into()), + sql: None, + truncated: false, + }, + ThreadMessage { + role: ThreadRole::Tool, + text: "turn result".into(), + tool_calls: None, + tool_call_id: Some("turn-1".into()), + tool_name: Some("get_turn".into()), + sql: None, + truncated: false, + }, + ThreadMessage { + role: ThreadRole::Assistant, + text: "done".into(), + tool_calls: None, + tool_call_id: None, + tool_name: None, + sql: None, + truncated: false, + }, + ]; + + let mapped = openai_messages(&messages, false); + assert_eq!(mapped.len(), 5); + assert_eq!(mapped[1]["role"], "assistant"); + assert!(mapped[1]["content"].is_null()); + assert_eq!(mapped[1]["tool_calls"][0]["id"], "analysis-1"); + assert_eq!( + mapped[1]["tool_calls"][0]["function"]["name"], + "get_analysis" + ); + assert_eq!(mapped[1]["tool_calls"][0]["function"]["arguments"], "{}"); + assert_eq!(mapped[1]["tool_calls"][1]["id"], "turn-1"); + assert_eq!(mapped[1]["tool_calls"][1]["function"]["name"], "get_turn"); + assert_eq!( + mapped[1]["tool_calls"][1]["function"]["arguments"], + r#"{"turn_id":4}"# + ); + assert_eq!(mapped[2]["role"], "tool"); + assert_eq!(mapped[2]["tool_call_id"], "analysis-1"); + assert_eq!(mapped[3]["role"], "tool"); + assert_eq!(mapped[3]["tool_call_id"], "turn-1"); + assert_eq!(mapped[4]["role"], "assistant"); + } + + #[test] + fn openai_messages_preserve_sequential_tool_call_rounds_and_arguments() { + let mut state = empty_state(); + apply_model_turn( + &mut state, + AssistantTurn::ToolCalls(vec![ParsedToolCall { + id: "round-1".into(), + name: "get_analysis".into(), + arguments: json!({}), + }]), + |_| "analysis".into(), + ); + apply_model_turn( + &mut state, + AssistantTurn::ToolCalls(vec![ParsedToolCall { + id: "round-2".into(), + name: "get_turn".into(), + arguments: json!({"turn_id": 4}), + }]), + |_| "[turn:4] evidence".into(), + ); + + let mapped = openai_messages(&state.messages, false); + let assistant_tool_calls = mapped + .iter() + .filter(|message| { + message["role"] == "assistant" + && message.get("tool_calls").is_some_and(Value::is_array) + }) + .collect::>(); + + assert_eq!(assistant_tool_calls.len(), 2); + assert_eq!(assistant_tool_calls[0]["tool_calls"][0]["id"], "round-1"); + assert_eq!(assistant_tool_calls[1]["tool_calls"][0]["id"], "round-2"); + assert_eq!( + assistant_tool_calls[1]["tool_calls"][0]["function"]["arguments"], + r#"{"turn_id":4}"# + ); + } + + #[test] + fn openai_messages_map_json_mode_tools_as_text() { + let mapped = openai_messages( + &[ + ThreadMessage { + role: ThreadRole::Assistant, + text: String::new(), + tool_calls: Some(vec![ParsedToolCall { + id: "sql-1".into(), + name: "query_sql".into(), + arguments: json!({"sql": "SELECT 1"}), + }]), + tool_call_id: None, + tool_name: None, + sql: None, + truncated: false, + }, + ThreadMessage { + role: ThreadRole::Tool, + text: "three rows".into(), + tool_calls: None, + tool_call_id: Some("sql-1".into()), + tool_name: Some("query_sql".into()), + sql: Some("SELECT 1".into()), + truncated: false, + }, + ], + true, + ); + + assert_eq!( + mapped, + vec![ + json!({ + "role": "assistant", + "content": r#"{"arguments":{"sql":"SELECT 1"},"tool":"query_sql"}"# + }), + json!({ + "role": "user", + "content": "Tool query_sql result:\nthree rows" + }) + ] + ); + assert!(mapped + .iter() + .all(|message| message.get("tool_calls").is_none())); + assert!(mapped.iter().all(|message| message["role"] != "tool")); + } + + #[test] + fn tools_unsupported_requires_protocol_keyword_in_client_error() { + let unknown_model = llm::CompletionError { + status: Some(400), + message: "LLM HTTP 400: unknown model".into(), + }; + let tool_choice = llm::CompletionError { + status: Some(400), + message: "LLM HTTP 400: unsupported tool_choice".into(), + }; + assert!(!unknown_model.suggests_tools_unsupported()); + assert!(tool_choice.suggests_tools_unsupported()); + } + + #[test] + fn native_tool_calls_reject_malformed_entries() { + let missing_name = serde_json::json!({ + "tool_calls": [{ + "id": "c1", + "function": {"arguments": "{}"} + }] + }); + assert_eq!(parse_native_message(&missing_name), AssistantTurn::Invalid); + + let bad_arguments = serde_json::json!({ + "tool_calls": [{ + "id": "c1", + "function": {"name": "get_turn", "arguments": "not-json"} + }] + }); + assert_eq!(parse_native_message(&bad_arguments), AssistantTurn::Invalid); + } + + #[test] + fn native_tool_calls_accept_object_arguments() { + let message = serde_json::json!({ + "tool_calls": [{ + "id": "c1", + "function": {"name": "get_turn", "arguments": {"turn_id": 12}} + }] + }); + match parse_native_message(&message) { + AssistantTurn::ToolCalls(calls) => { + assert_eq!(calls[0].name, "get_turn"); + assert_eq!(calls[0].arguments["turn_id"], 12); + } + other => panic!("{other:?}"), + } + } + + #[test] + fn native_empty_content_without_tool_calls_is_invalid() { + let message = serde_json::json!({"content": ""}); + assert_eq!(parse_native_message(&message), AssistantTurn::Invalid); + } + + #[test] + fn native_non_array_tool_calls_with_content_is_invalid() { + let string_tool_calls = serde_json::json!({ + "tool_calls": "not-an-array", + "content": "3 turns, no explicit errors." + }); + assert_eq!( + parse_native_message(&string_tool_calls), + AssistantTurn::Invalid + ); + + let object_tool_calls = serde_json::json!({ + "tool_calls": {"id": "c1"}, + "content": "3 turns, no explicit errors." + }); + assert_eq!( + parse_native_message(&object_tool_calls), + AssistantTurn::Invalid + ); + } + + #[test] + fn native_empty_tool_calls_falls_through_to_content() { + let message = serde_json::json!({ + "tool_calls": [], + "content": "done" + }); + assert_eq!( + parse_native_message(&message), + AssistantTurn::Final("done".into()) + ); + } + + #[test] + fn native_tool_calls_reject_non_object_arguments() { + let array_args = serde_json::json!({ + "tool_calls": [{ + "id": "c1", + "function": {"name": "get_turn", "arguments": [1, 2]} + }] + }); + assert_eq!(parse_native_message(&array_args), AssistantTurn::Invalid); + + let number_args = serde_json::json!({ + "tool_calls": [{ + "id": "c1", + "function": {"name": "get_turn", "arguments": 42} + }] + }); + assert_eq!(parse_native_message(&number_args), AssistantTurn::Invalid); + + let string_array_args = serde_json::json!({ + "tool_calls": [{ + "id": "c1", + "function": {"name": "get_turn", "arguments": "[1,2]"} + }] + }); + assert_eq!( + parse_native_message(&string_array_args), + AssistantTurn::Invalid + ); + } + + #[test] + fn json_fallback_rejects_non_object_arguments() { + assert_eq!( + parse_json_fallback(r#"{"tool":"get_analysis","arguments":[]}"#), + AssistantTurn::Invalid + ); + assert_eq!( + parse_json_fallback(r#"{"tool":"get_turn","arguments":42}"#), + AssistantTurn::Invalid + ); + } + + use crate::model::{MetricStats, StorylineTurn}; + + fn stats() -> MetricStats { + MetricStats { + sample_count: 1, + total_count: 3, + p50: None, + p95: None, + max: None, + } + } + + fn sample_analysis() -> RunAnalysis { + RunAnalysis { + run: sample_run("s-a", Some("r1")), + event_count: 3, + turn_count: 3, + tool_call_count: 0, + error_count: 0, + start_timestamp: None, + end_timestamp: None, + models: Vec::new(), + prompt_tokens: None, + completion_tokens: None, + total_tokens: None, + latency_ms: stats(), + ttft_ms: stats(), + latency_histogram: Vec::new(), + source_breakdown: Vec::new(), + kind_breakdown: Vec::new(), + model_breakdown: Vec::new(), + tools: Vec::new(), + } + } + + fn sample_detail(message: Value) -> TurnDetail { + TurnDetail { + summary: TurnSummary { + id: 9, + source: "agent".into(), + kind: None, + timestamp: None, + call_id: None, + preview: String::new(), + char_count: 0, + modalities: Vec::new(), + model_name: None, + latency_ms: None, + ttft_ms: None, + prompt_tokens: None, + completion_tokens: None, + total_tokens: None, + tool_names: Vec::new(), + event_seqs: Vec::new(), + has_error: false, + }, + turn: StorylineTurn { + id: 9, + kind: None, + timestamp: None, + source: "agent".into(), + message, + reasoning_content: None, + tool_calls: None, + observation: None, + metrics: None, + model_name: None, + latency_ms: None, + ttft_ms: None, + extra: None, + }, + wire_tool_calls: Vec::new(), + events: Vec::new(), + } + } + + #[test] + fn turn_formatter_truncates_on_utf8_boundary() { + let detail = sample_detail(Value::String("轨".repeat(20_000))); + let text = format_turn_result(&detail); + assert!(text.contains("[… truncated …]")); + assert!(text.is_char_boundary(text.find("[… truncated …]").unwrap())); + } + + #[test] + fn unknown_tool_is_an_error_string() { + let text = unknown_tool_result("drop_table"); + assert!(text.contains("drop_table")); + assert!(text.to_ascii_lowercase().contains("unknown")); + } + #[test] - fn explicit_commands_resolve_to_known_skills() { - assert_eq!(resolve_skill("/latency_hotspots"), Some("latency_hotspots")); - assert_eq!(resolve_skill("compare this cohort"), Some("cohort_compare")); + fn analysis_formatter_omits_turn_bodies() { + let text = format_analysis_result(&sample_analysis()); + assert!(text.contains("turns=3")); + assert!(!text.contains("preview")); } } diff --git a/pchronicle-web/src/analysis.rs b/pchronicle-web/src/analysis.rs new file mode 100644 index 00000000..32bddeb4 --- /dev/null +++ b/pchronicle-web/src/analysis.rs @@ -0,0 +1,2340 @@ +use dioxus::prelude::*; +use wasm_bindgen::JsCast; +use web_time::{SystemTime, UNIX_EPOCH}; + +use crate::analysis_agent::{self, EvidenceDigest, InterpretationRequest, PlanRequest}; +use crate::analysis_session::{ + self, AnalysisEffect, AnalysisInterpretation, AnalysisOperationId, AnalysisPlan, + AnalysisRevision, AnalysisScope, AnalysisScopeItem, AnalysisSession, EvidenceReference, + RevisionState, SuggestedView, +}; +use crate::api; +use crate::llm; +use crate::llm_settings::LlmSettings; +use crate::model::{QueryCatalog, QueryEvidence}; +use crate::result_explorer::{identity_href, ResultExplorer, ResultIdentity}; +use crate::result_profile::{profile_rows, AnalysisRefinement, ColumnProfile}; + +const QUESTION_STARTERS: [&str; 3] = [ + "Compare successful and failed runs in this scope", + "Find latency outliers and the tools associated with them", + "Summarize explicit errors by tool and model", +]; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum PrimaryAction { + GeneratePlan, + RunAnalysis, + RetryAnalysis, + None, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +struct AnalysisViewModel { + primary_action: PrimaryAction, + run_enabled: bool, + question_out_of_date: bool, + query_in_flight: bool, + manually_edited: bool, + sql_disclosure_label: &'static str, +} + +impl AnalysisViewModel { + fn from_revision(revision: &AnalysisRevision, draft_question: &str) -> Self { + let primary_action = match revision.state { + RevisionState::Draft | RevisionState::PlanError | RevisionState::Stale => { + PrimaryAction::GeneratePlan + } + RevisionState::PlanReady => PrimaryAction::RunAnalysis, + RevisionState::QueryError => PrimaryAction::RetryAnalysis, + _ => PrimaryAction::None, + }; + let review_is_runnable = matches!( + primary_action, + PrimaryAction::RunAnalysis | PrimaryAction::RetryAnalysis + ); + let question_matches = draft_question.trim() == revision.question.trim(); + let sql_ready = revision + .plan + .as_ref() + .is_some_and(|plan| !plan.sql.trim().is_empty()); + Self { + primary_action, + run_enabled: review_is_runnable + && sql_ready + && (question_matches || revision.manually_edited), + question_out_of_date: review_is_runnable + && !question_matches + && !revision.manually_edited, + query_in_flight: revision.state == RevisionState::Executing, + manually_edited: revision.manually_edited, + sql_disclosure_label: "SQL", + } + } +} + +fn insert_sql_token(sql: &str, cursor: usize, token: &str) -> String { + let mut index = cursor.min(sql.len()); + while index > 0 && !sql.is_char_boundary(index) { + index -= 1; + } + format!("{}{}{}", &sql[..index], token, &sql[index..]) +} + +fn apply_inserted_token( + revision: &mut AnalysisRevision, + token: &str, + cursor: usize, +) -> Result { + if matches!( + revision.state, + RevisionState::GeneratingPlan | RevisionState::Executing | RevisionState::Interpreting + ) { + return Err("SQL cannot be edited while an operation is running.".into()); + } + let sql = revision + .plan + .as_ref() + .map(|plan| plan.sql.clone()) + .unwrap_or_default(); + let mut index = cursor.min(sql.len()); + while index > 0 && !sql.is_char_boundary(index) { + index -= 1; + } + apply_manual_sql(revision, insert_sql_token(&sql, cursor, token))?; + Ok(index + token.len()) +} + +fn apply_manual_sql(revision: &mut AnalysisRevision, sql: String) -> Result<(), String> { + if matches!( + revision.state, + RevisionState::GeneratingPlan | RevisionState::Executing | RevisionState::Interpreting + ) { + return Err("SQL cannot be edited while an operation is running.".into()); + } + if !matches!( + revision.state, + RevisionState::Draft + | RevisionState::PlanError + | RevisionState::Stale + | RevisionState::PlanReady + | RevisionState::QueryError + | RevisionState::Complete + | RevisionState::InterpretationError + ) { + return Err("SQL can only be edited on a draft or reviewed revision.".into()); + } + if let Some(plan) = revision.plan.as_mut() { + plan.sql = sql; + } else { + revision.plan = Some(AnalysisPlan { + id: revision.id, + question: revision.question.clone(), + intent_summary: "Manual SQL".into(), + scope_summary: revision + .scope + .items + .first() + .map(|item| scope_item_label(item)) + .unwrap_or_default(), + filters: Vec::new(), + groupings: Vec::new(), + measures: Vec::new(), + expected_columns: Vec::new(), + suggested_view: SuggestedView::Table, + sql, + warnings: Vec::new(), + }); + } + revision.manually_edited = true; + revision.state = RevisionState::PlanReady; + revision.error = None; + revision.execution = None; + revision.evidence = None; + revision.interpretation = None; + revision.needs_rerun = false; + revision.pending_effect = None; + revision.active_operation_id = None; + Ok(()) +} + +#[derive(Clone)] +struct PreparedInterpretation { + revision_id: u64, + operation_id: AnalysisOperationId, + digest: EvidenceDigest, +} + +fn finish_query_for_interpretation( + revision: &mut AnalysisRevision, + revision_id: u64, + operation_id: AnalysisOperationId, + evidence: QueryEvidence, + profiles: Vec, +) -> Result, String> { + let effect = revision.finish_query(revision_id, operation_id, evidence, profiles)?; + let Some(AnalysisEffect::Interpret { + revision_id, + operation_id, + }) = effect + else { + return Ok(None); + }; + let plan = revision + .plan + .as_ref() + .ok_or_else(|| "A plan is required before interpreting query evidence.".to_string())?; + let evidence = revision + .evidence + .as_ref() + .ok_or_else(|| "Query evidence is required before interpretation.".to_string())?; + let profiles = revision + .execution + .as_ref() + .map(|execution| execution.profiles.as_slice()) + .unwrap_or_default(); + let digest = analysis_agent::build_evidence_digest(plan, &revision.scope, evidence, profiles); + let _ = revision.take_pending_effect(); + Ok(Some(PreparedInterpretation { + revision_id, + operation_id, + digest, + })) +} + +fn retry_interpretation_from_evidence( + revision: &mut AnalysisRevision, +) -> Result { + let effect = revision.retry_interpretation()?; + let AnalysisEffect::Interpret { + revision_id, + operation_id, + } = effect + else { + return Err("Retry did not prepare an interpretation operation.".into()); + }; + let plan = revision + .plan + .as_ref() + .ok_or_else(|| "The reviewed plan is unavailable for interpretation.".to_string())?; + let evidence = revision.evidence.as_ref().ok_or_else(|| { + "The query evidence is unavailable; rerun the analysis first.".to_string() + })?; + let profiles = revision + .execution + .as_ref() + .map(|execution| execution.profiles.as_slice()) + .unwrap_or_default(); + let digest = analysis_agent::build_evidence_digest(plan, &revision.scope, evidence, profiles); + let _ = revision.take_pending_effect(); + Ok(PreparedInterpretation { + revision_id, + operation_id, + digest, + }) +} + +fn follow_up_plan_allowed( + source_revision_id: u64, + active_revision_id: u64, + draft_question: &str, + source_question: &str, +) -> bool { + source_revision_id == active_revision_id && draft_question.trim() == source_question.trim() +} + +fn interpretation_reference_identity(reference: &EvidenceReference) -> Option { + identity_href(&serde_json::json!({ + "dataset": reference.dataset, + "_file_": reference.file, + "run_id": reference.run_id, + "agent_id": reference.agent_id, + "session_id": reference.session_id, + "root_session_id": reference.root_session_id, + "turn_id": reference.turn_id, + })) +} + +fn revision_for_callback<'a>( + session: &'a mut AnalysisSession, + expected_session_id: &str, + revision_id: u64, +) -> Option<&'a mut AnalysisRevision> { + if session.id != expected_session_id { + return None; + } + session + .revisions + .iter_mut() + .find(|revision| revision.id == revision_id) +} + +fn scope_without_item( + scope: &AnalysisScope, + index: usize, + catalog: Option<&QueryCatalog>, +) -> Option { + if index >= scope.items.len() { + return None; + } + if scope.items.len() == 1 { + return matches!( + scope.items.first(), + Some(AnalysisScopeItem::Root { .. } | AnalysisScopeItem::Run { .. }) + ) + .then(|| catalog.map(AnalysisScope::from_catalog)) + .flatten(); + } + let mut next = scope.clone(); + next.items.remove(index); + Some(next) +} + +fn scope_item_removal_enabled( + scope: &AnalysisScope, + catalog: Option<&QueryCatalog>, + state: Option<&RevisionState>, +) -> bool { + scope_without_item(scope, 0, catalog).is_some() + && !matches!( + state, + Some(RevisionState::GeneratingPlan | RevisionState::Executing) + ) +} + +fn launch_interpretation( + config: llm::LlmConfig, + expected_session_id: String, + prepared: PreparedInterpretation, + mut session: Signal>, + mut recent_sessions: Signal>, + mut storage_notice: Signal>, +) { + spawn(async move { + let result = analysis_agent::interpret(InterpretationRequest { + config, + revision_id: prepared.revision_id, + digest: prepared.digest.clone(), + }) + .await; + let Some(mut current) = session() else { + return; + }; + if current.id != expected_session_id { + return; + } + let Some(revision) = + revision_for_callback(&mut current, &expected_session_id, prepared.revision_id) + else { + return; + }; + match result { + Ok(mut interpretation) => { + analysis_agent::ensure_truncation_limitation(&mut interpretation, &prepared.digest); + let _ = revision.finish_interpretation( + prepared.revision_id, + prepared.operation_id, + interpretation, + ); + } + Err(error) => { + let _ = revision.fail_interpretation( + prepared.revision_id, + prepared.operation_id, + error.message, + ); + } + } + persist_session(¤t, &mut recent_sessions, &mut storage_notice); + session.set(Some(current)); + }); +} + +#[component] +pub fn AnalysisWorkspace( + catalog: Option, + initial_scope: Option, + requested_session_id: Option, + on_session_change: EventHandler, +) -> Element { + let default_scope = catalog.as_ref().map(AnalysisScope::from_catalog); + let initial_workspace_scope = initial_scope.or(default_scope); + let mut scope = use_signal(move || initial_workspace_scope); + let mut question = use_signal(String::new); + let mut session = use_signal(|| None::); + let mut recent_sessions = use_signal(Vec::::new); + let mut config = use_signal(llm::load_config); + let mut settings_open = use_signal(|| false); + let mut storage_notice = use_signal(|| None::); + let mut clear_confirmation = use_signal(|| false); + let mut restored = use_signal(|| false); + let mut selected_table = use_signal(String::new); + let mut sql_caret = use_signal(|| None::); + + use_effect(use_reactive( + (&catalog, &requested_session_id), + move |(restore_catalog, restore_requested)| { + if restored() { + return; + } + let Some(catalog) = restore_catalog.as_ref() else { + return; + }; + restored.set(true); + let fingerprint = + analysis_session::storage_fingerprint(&catalog.database, &catalog.storage_path); + let mut sessions = match analysis_session::load_sessions(&fingerprint) { + Ok(sessions) => sessions, + Err(message) => { + storage_notice.set(Some(message)); + Vec::new() + } + }; + let requested_id = restore_requested.as_deref().filter(|id| !id.is_empty()); + let mut restored_session = requested_id + .and_then(|id| { + sessions + .iter() + .find(|candidate| candidate.id == id) + .cloned() + }) + .unwrap_or_else(|| { + let initial_scope = + scope().unwrap_or_else(|| AnalysisScope::from_catalog(catalog)); + AnalysisSession::with_revision(AnalysisRevision::draft(1, "", initial_scope)) + }); + restored_session.storage_fingerprint = fingerprint; + restored_session.reconcile_catalog(&catalog.snapshot_id); + if let Some(revision) = restored_session.active_revision() { + question.set(revision.question.clone()); + scope.set(Some(scope_for_catalog(&revision.scope, catalog))); + } + let session_id = restored_session.id.clone(); + sessions.retain(|saved| saved.id != session_id); + sessions.push(restored_session.clone()); + analysis_session::trim_sessions(&mut sessions); + recent_sessions.set(sessions); + let persisted = + persist_session(&restored_session, &mut recent_sessions, &mut storage_notice); + if let Some(session_id) = persisted_session_id(&session_id, persisted) { + on_session_change.call(session_id); + } + session.set(Some(restored_session)); + }, + )); + + use_effect(move || { + if let Some(index) = sql_caret() { + set_sql_textarea_cursor(index); + sql_caret.set(None); + } + }); + + let active_revision = session().and_then(|value| { + value + .revisions + .into_iter() + .find(|revision| revision.id == value.active_revision_id) + }); + let draft_question = question(); + let view_model = active_revision + .as_ref() + .map(|revision| AnalysisViewModel::from_revision(revision, &draft_question)); + let generating = active_revision + .as_ref() + .is_some_and(|revision| revision.state == RevisionState::GeneratingPlan); + let can_generate = catalog.is_some() + && scope().is_some() + && config().is_configured() + && !question().trim().is_empty() + && !generating; + + let scope_for_generate = scope; + let catalog_for_generate = catalog.clone(); + let generate_plan = move |_| { + let Some(catalog) = catalog_for_generate.clone() else { + return; + }; + let Some(scope) = scope_for_generate() else { + return; + }; + let prompt = question().trim().to_string(); + if prompt.is_empty() || !config().is_configured() { + return; + } + + let mut next_session = session().unwrap_or_else(|| { + AnalysisSession::with_revision(AnalysisRevision::draft( + 1, + prompt.clone(), + scope.clone(), + )) + }); + let previous_plan = next_session.active_revision_mut().and_then(|revision| { + revision + .plan + .clone() + .or_else(|| revision.prior_plan_context.clone()) + }); + let needs_new_revision = next_session.active_revision_mut().is_some_and(|revision| { + !matches!( + revision.state, + RevisionState::Draft | RevisionState::PlanError | RevisionState::Stale + ) || revision.question != prompt + || revision.scope != scope + }); + if needs_new_revision { + let revision = next_session.new_revision(prompt.clone(), scope.clone()); + revision.prior_plan_context = previous_plan.clone(); + } + if next_session.title.trim().is_empty() { + next_session.title = prompt.clone(); + } + let Some(revision) = next_session.active_revision_mut() else { + return; + }; + revision.question = prompt.clone(); + let revision_id = revision.id; + let Ok(operation_id) = revision.begin_plan_generation() else { + return; + }; + let expected_session_id = next_session.id.clone(); + let persisted = persist_session(&next_session, &mut recent_sessions, &mut storage_notice); + if let Some(session_id) = persisted_session_id(&expected_session_id, persisted) { + on_session_change.call(session_id); + } + session.set(Some(next_session)); + + let request = PlanRequest { + config: config(), + catalog, + scope, + question: prompt, + plan_id: revision_id, + previous_plan, + refinement: None, + }; + spawn(async move { + let result = analysis_agent::generate_plan(request).await; + let Some(mut current) = session() else { + return; + }; + if current.id != expected_session_id { + return; + } + let Some(revision) = + revision_for_callback(&mut current, &expected_session_id, revision_id) + else { + return; + }; + match result { + Ok(plan) => { + let _ = revision.finish_plan(revision_id, operation_id, plan); + } + Err(error) => { + let _ = revision.fail_plan(revision_id, operation_id, error.message); + } + } + persist_session(¤t, &mut recent_sessions, &mut storage_notice); + session.set(Some(current)); + }); + }; + + let run_analysis = move |_| { + let Some(mut current) = session() else { + return; + }; + let Some(revision) = current.active_revision_mut() else { + return; + }; + if !AnalysisViewModel::from_revision(revision, &question()).run_enabled { + return; + } + if revision.confirm_execution().is_err() { + return; + } + let Some(AnalysisEffect::ExecuteSql { + revision_id, + operation_id, + sql, + }) = revision.take_pending_effect() + else { + return; + }; + let expected_session_id = current.id.clone(); + let interpretation_config = config(); + persist_session(¤t, &mut recent_sessions, &mut storage_notice); + session.set(Some(current)); + spawn(async move { + let result = api::query_evidence_interactive(&sql).await; + let Some(mut current) = session() else { + return; + }; + if current.id != expected_session_id { + return; + } + let Some(revision) = + revision_for_callback(&mut current, &expected_session_id, revision_id) + else { + return; + }; + let prepared = match result { + Ok(evidence) => { + let profiles = profile_rows(&evidence.rows); + finish_query_for_interpretation( + revision, + revision_id, + operation_id, + evidence, + profiles, + ) + .ok() + .flatten() + } + Err(message) => { + let _ = revision.fail_query(revision_id, operation_id, message); + None + } + }; + persist_session(¤t, &mut recent_sessions, &mut storage_notice); + session.set(Some(current)); + if let Some(prepared) = prepared { + launch_interpretation( + interpretation_config, + expected_session_id, + prepared, + session, + recent_sessions, + storage_notice, + ); + } + }); + }; + + let catalog_for_refinement = catalog.clone(); + let prepare_refinement = move |refinement: AnalysisRefinement| { + let Some(catalog) = catalog_for_refinement.clone() else { + return; + }; + if !config().is_configured() { + return; + } + let Some(mut current) = session() else { + return; + }; + let Some(source) = current + .revisions + .iter() + .find(|revision| revision.id == current.active_revision_id) + else { + return; + }; + let draft_question = question(); + if !refinement_plan_allowed( + source.id, + refinement_source_revision_id(&refinement), + &draft_question, + &source.question, + ) { + return; + } + let prompt = source.question.clone(); + let scope = source.scope.clone(); + let previous_plan = source.plan.clone(); + let revision = current.new_revision(prompt.clone(), scope.clone()); + revision.prior_plan_context = previous_plan.clone(); + let revision_id = revision.id; + let Ok(operation_id) = revision.begin_plan_generation() else { + return; + }; + let expected_session_id = current.id.clone(); + let persisted = persist_session(¤t, &mut recent_sessions, &mut storage_notice); + if let Some(session_id) = persisted_session_id(&expected_session_id, persisted) { + on_session_change.call(session_id); + } + session.set(Some(current)); + + let request = PlanRequest { + config: config(), + catalog, + scope, + question: prompt, + plan_id: revision_id, + previous_plan, + refinement: Some(refinement), + }; + spawn(async move { + let result = analysis_agent::generate_plan(request).await; + let Some(mut current) = session() else { + return; + }; + let Some(revision) = + revision_for_callback(&mut current, &expected_session_id, revision_id) + else { + return; + }; + match result { + Ok(plan) => { + let _ = revision.finish_plan(revision_id, operation_id, plan); + } + Err(error) => { + let _ = revision.fail_plan(revision_id, operation_id, error.message); + } + } + persist_session(¤t, &mut recent_sessions, &mut storage_notice); + session.set(Some(current)); + }); + }; + + let retry_interpretation = move |_| { + if !config().is_configured() { + return; + } + let Some(mut current) = session() else { + return; + }; + let expected_session_id = current.id.clone(); + let Some(revision) = current.active_revision_mut() else { + return; + }; + let Ok(prepared) = retry_interpretation_from_evidence(revision) else { + return; + }; + let interpretation_config = config(); + persist_session(¤t, &mut recent_sessions, &mut storage_notice); + session.set(Some(current)); + launch_interpretation( + interpretation_config, + expected_session_id, + prepared, + session, + recent_sessions, + storage_notice, + ); + }; + + let catalog_for_follow_up = catalog.clone(); + let generate_follow_up = move |suggested_question: String| { + let Some(catalog) = catalog_for_follow_up.clone() else { + return; + }; + if !config().is_configured() { + return; + } + let Some(mut current) = session() else { + return; + }; + let Some(source) = current + .revisions + .iter() + .find(|revision| revision.id == current.active_revision_id) + else { + return; + }; + if !follow_up_plan_allowed( + source.id, + current.active_revision_id, + &question(), + &source.question, + ) { + return; + } + let previous_plan = source.plan.clone(); + let scope = source.scope.clone(); + let Ok(revision) = current.new_follow_up(suggested_question.clone()) else { + return; + }; + let revision_id = revision.id; + let Ok(operation_id) = revision.begin_plan_generation() else { + return; + }; + let expected_session_id = current.id.clone(); + question.set(suggested_question.clone()); + let persisted = persist_session(¤t, &mut recent_sessions, &mut storage_notice); + if let Some(session_id) = persisted_session_id(&expected_session_id, persisted) { + on_session_change.call(session_id); + } + session.set(Some(current)); + + let request = PlanRequest { + config: config(), + catalog, + scope, + question: suggested_question, + plan_id: revision_id, + previous_plan, + refinement: None, + }; + spawn(async move { + let result = analysis_agent::generate_plan(request).await; + let Some(mut current) = session() else { + return; + }; + if current.id != expected_session_id { + return; + } + let Some(revision) = + revision_for_callback(&mut current, &expected_session_id, revision_id) + else { + return; + }; + match result { + Ok(plan) => { + let _ = revision.finish_plan(revision_id, operation_id, plan); + } + Err(error) => { + let _ = revision.fail_plan(revision_id, operation_id, error.message); + } + } + persist_session(¤t, &mut recent_sessions, &mut storage_notice); + session.set(Some(current)); + }); + }; + + let edit_follow_up = move |suggested_question: String| { + let Some(current) = session() else { + return; + }; + let Some(source) = current + .revisions + .iter() + .find(|revision| revision.id == current.active_revision_id) + else { + return; + }; + if follow_up_plan_allowed( + source.id, + current.active_revision_id, + &question(), + &source.question, + ) { + question.set(suggested_question); + } + }; + + let rewrite_problem = move |_| { + if let Some(current) = session() { + if let Some(revision) = current + .revisions + .iter() + .find(|revision| revision.id == current.active_revision_id) + { + question.set(revision.question.clone()); + } + } + session.set(None); + }; + let regenerate_plan = generate_plan.clone(); + + let catalog_for_scope_removal = catalog.clone(); + let remove_scope_item = EventHandler::new(move |index: usize| { + let Some(current_scope) = scope() else { + return; + }; + let Some(next_scope) = + scope_without_item(¤t_scope, index, catalog_for_scope_removal.as_ref()) + else { + return; + }; + if let Some(mut current) = session() { + if current + .apply_working_scope_change(question(), next_scope.clone()) + .is_err() + { + return; + } + persist_session(¤t, &mut recent_sessions, &mut storage_notice); + session.set(Some(current)); + } + scope.set(Some(next_scope)); + }); + + let catalog_for_recent = catalog.clone(); + let select_recent_session = EventHandler::new(move |session_id: String| { + if let Some(mut departing) = session() { + if departing.id != session_id { + departing.normalize_inflight_for_navigation(); + persist_session(&departing, &mut recent_sessions, &mut storage_notice); + } + } + let Some(mut selected) = recent_sessions() + .into_iter() + .find(|candidate| candidate.id == session_id) + else { + return; + }; + if let Some(catalog) = catalog_for_recent.as_ref() { + selected.reconcile_catalog(&catalog.snapshot_id); + if let Some(revision) = selected.active_revision() { + scope.set(Some(scope_for_catalog(&revision.scope, catalog))); + } + } + if let Some(revision) = selected.active_revision() { + question.set(revision.question.clone()); + } + let persisted = persist_session(&selected, &mut recent_sessions, &mut storage_notice); + if let Some(session_id) = persisted_session_id(&selected.id, persisted) { + on_session_change.call(session_id); + } + session.set(Some(selected)); + }); + + let catalog_for_revision = catalog.clone(); + let select_revision = EventHandler::new(move |revision_id: u64| { + let Some(mut current) = session() else { + return; + }; + if current.select_revision(revision_id).is_err() { + return; + } + if let Some(revision) = current.active_revision() { + question.set(revision.question.clone()); + if let Some(catalog) = catalog_for_revision.as_ref() { + scope.set(Some(scope_for_catalog(&revision.scope, catalog))); + } else { + scope.set(Some(revision.scope.clone())); + } + } + persist_session(¤t, &mut recent_sessions, &mut storage_notice); + session.set(Some(current)); + }); + + let catalog_for_clear = catalog.clone(); + let clear_history = move |_| { + let fingerprint = catalog_for_clear + .as_ref() + .map(|catalog| { + analysis_session::storage_fingerprint(&catalog.database, &catalog.storage_path) + }) + .or_else(|| session().map(|current| current.storage_fingerprint)); + let Some(fingerprint) = fingerprint else { + return; + }; + match analysis_session::clear_sessions(&fingerprint) { + Ok(()) => { + recent_sessions.set(Vec::new()); + session.set(None); + question.set(String::new()); + clear_confirmation.set(false); + storage_notice.set(Some("Analysis history cleared for this catalog.".into())); + on_session_change.call(String::new()); + } + Err(message) => storage_notice.set(Some(message)), + } + }; + + let current_session_id = session().map(|current| current.id).unwrap_or_default(); + let current_revision_id = session() + .map(|current| current.active_revision_id) + .unwrap_or_default(); + let revision_history = session() + .map(|current| current.revisions) + .unwrap_or_default(); + let timeline_now_ms = current_time_millis(); + let scope_revision_state = active_revision + .as_ref() + .map(|revision| revision.state.clone()); + let sql_text = active_revision + .as_ref() + .and_then(|revision| revision.plan.as_ref()) + .map(|plan| plan.sql.clone()) + .unwrap_or_default(); + let sql_locked = active_revision.as_ref().is_some_and(|revision| { + matches!( + revision.state, + RevisionState::GeneratingPlan | RevisionState::Executing + ) + }); + let run_enabled = view_model.as_ref().is_some_and(|model| model.run_enabled); + let schema_tables = catalog + .as_ref() + .map(crate::model::queryable_tables) + .unwrap_or_default(); + let selected_table_name = { + let current = selected_table(); + if schema_tables.iter().any(|table| table.name == current) { + current + } else { + schema_tables + .first() + .map(|table| table.name.clone()) + .unwrap_or_default() + } + }; + let selected_schema_table = schema_tables + .iter() + .find(|table| table.name == selected_table_name) + .cloned(); + + rsx! { + section { class: "analyze-workspace", aria_label: "Question-driven analysis workspace", + header { class: "analyze-header", + div { class: "analyze-header-inner", + div { class: "analyze-header-bar", + p { class: "analyze-eyebrow", "pChronicle / Analyze" } + div { class: "analyze-header-actions", + if !recent_sessions().is_empty() { + label { class: "analyze-recent-select", + span { "Recent analysis" } + select { + value: "{current_session_id}", + onchange: move |event| select_recent_session.call(event.value()), + for saved in recent_sessions() { + option { value: "{saved.id}", "{session_label(&saved)}" } + } + } + } + } + if clear_confirmation() { + div { class: "analyze-clear-confirmation", role: "group", aria_label: "Confirm clearing analysis history", + span { "Clear this catalog's analysis history?" } + button { class: "button", r#type: "button", onclick: clear_history, "Clear" } + button { class: "analyze-link-button", r#type: "button", onclick: move |_| clear_confirmation.set(false), "Cancel" } + } + } else { + button { class: "analyze-link-button", r#type: "button", onclick: move |_| clear_confirmation.set(true), "Clear analysis history" } + } + button { class: "button analyze-settings-button", r#type: "button", onclick: move |_| settings_open.set(true), + span { aria_hidden: "true", "⚙" } + "Model settings" + } + } + } + h1 { "Ask a question. Or write SQL." } + p { class: "analyze-header-lede", "Describe what you want in plain language, or write a read-only query. Nothing is queried until you Run." } + } + } + + div { class: "analyze-layout", + nav { class: "analyze-schema", aria_label: "Catalog schema", + div { class: "analyze-schema-heading", + p { class: "analyze-eyebrow", "Catalog" } + h2 { "SQL tables" } + p { "Names you can use in FROM. Click a table, then a field to insert it at the SQL cursor." } + } + if schema_tables.is_empty() { + p { class: "analyze-schema-empty", "Catalog is still loading." } + } else { + ul { class: "analyze-schema-tables", + for table in schema_tables.iter() { + { + let table_name = table.name.clone(); + let selected = table_name == selected_table_name; + rsx! { + li { + button { + class: if selected { "analyze-schema-table active" } else { "analyze-schema-table" }, + r#type: "button", + aria_pressed: selected, + onclick: move |_| selected_table.set(table_name.clone()), + strong { "{table.name}" } + small { "{table.grain}" } + } + } + } + } + } + } + if let Some(table) = selected_schema_table.as_ref() { + div { class: "analyze-schema-fields", + h3 { "{table.name} fields" } + if !table.description.is_empty() { + p { class: "analyze-schema-table-copy", "{table.description}" } + } + ul { + for field in table.fields.iter() { + { + let token = field_sql_token(&table.name, &field.name); + let locked = sql_locked; + let data_type = field.data_type.clone(); + let description = field.description.clone(); + rsx! { + li { + button { + class: "analyze-schema-field", + r#type: "button", + disabled: locked, + title: if description.is_empty() { data_type.clone() } else { format!("{data_type} · {description}") }, + onclick: move |_| { + let Some(mut current) = session() else { return; }; + let Some(active) = current.active_revision_mut() else { return; }; + let Ok(caret) = apply_inserted_token(active, &token, sql_textarea_cursor()) else { return; }; + persist_session(¤t, &mut recent_sessions, &mut storage_notice); + session.set(Some(current)); + sql_caret.set(Some(caret)); + }, + code { "{field.name}" } + small { "{field.data_type}" } + if !field.description.is_empty() { + span { "{field.description}" } + } + } + } + } + } + } + } + } + } + } + } + div { class: "analyze-main", + if let Some(message) = storage_notice() { + p { class: "analyze-storage-notice analyze-storage-notice-inline", role: "status", "{message}" } + } + section { class: "analyze-question-card", aria_label: "Analysis question", + div { class: "analyze-section-heading", + div { span { "01" } div { h2 { "What do you want to understand?" } p { "Describe the comparison, pattern, or anomaly you want to investigate." } } } + span { class: "analyze-step-state", if generating { "Planning…" } else { "Draft" } } + } + label { class: "analyze-question-label", r#for: "analysis-question", "Question" } + textarea { + id: "analysis-question", + class: "analyze-question-input", + rows: "5", + value: "{question}", + placeholder: "Ask about runs, errors, latency, tool use, or model behavior…", + disabled: generating, + oninput: move |event| question.set(event.value()), + } + div { class: "analyze-context-row", aria_label: "Analysis context", + span { class: if catalog.is_some() { "analyze-status ready" } else { "analyze-status" }, + span { aria_hidden: "true" } + if catalog.is_some() { "Catalog ready" } else { "Loading catalog…" } + } + span { class: "analyze-chip lock", "Read-only" } + if let Some(scope) = scope() { + for (index, item) in scope.items.iter().enumerate() { + { + let label = scope_item_label(item); + let only_item = scope.items.len() == 1; + let removal_enabled = scope_item_removal_enabled( + &scope, + catalog.as_ref(), + scope_revision_state.as_ref(), + ); + let blocked_by_operation = matches!( + scope_revision_state.as_ref(), + Some(RevisionState::GeneratingPlan | RevisionState::Executing) + ); + let single_dataset = only_item && matches!( + scope.items.first(), + Some(AnalysisScopeItem::Dataset { .. }) + ); + rsx! { + span { class: "analyze-chip", + "{label}" + button { + class: "analyze-chip-remove", + r#type: "button", + disabled: !removal_enabled, + aria_label: if removal_enabled { "Remove {label} from analysis scope" } else if blocked_by_operation { "Analysis scope cannot change while an operation is running" } else if single_dataset { "The dataset analysis scope cannot be removed" } else { "The catalog is required before this scope can be removed" }, + title: if removal_enabled { "Remove scope" } else if blocked_by_operation { "Wait for the current plan or query operation to finish" } else if single_dataset { "At least one explicit scope is required" } else { "Wait for the catalog to load" }, + onclick: move |_| remove_scope_item.call(index), + "×" + } + } + } + } + } + } + } + div { class: "analyze-starters", aria_label: "Question starters", + span { "Try a starting point" } + div { + for starter in QUESTION_STARTERS { + button { r#type: "button", disabled: generating, onclick: move |_| question.set(starter.into()), "{starter}" } + } + } + } + if !config().is_configured() { + div { class: "analyze-config-callout", role: "status", + div { strong { "Connect a model to generate a plan" } p { "Your draft stays here while you configure the endpoint." } } + button { class: "button", r#type: "button", onclick: move |_| settings_open.set(true), "Open model settings" } + } + } + if let Some(revision) = active_revision.as_ref() { + if revision.state == RevisionState::PlanError { + div { class: "analyze-error", role: "alert", + strong { "The plan could not be generated" } + if let Some(message) = revision.error.as_ref() { p { "{message}" } } + else { p { "The model did not return a valid plan." } } + p { "Your question is unchanged. Adjust it or generate the plan again." } + } + } + } + div { class: "analyze-question-actions", + p { "Generate plan fills the SQL editor. Run is the only query." } + button { class: "button primary", r#type: "button", disabled: !can_generate, onclick: generate_plan, + if generating { span { class: "analyze-spinner", aria_hidden: "true" } "Generating plan…" } else { "Generate plan" } + } + } + } + + section { class: "analyze-sql-card", aria_label: "SQL editor", + div { class: "analyze-section-heading", + div { span { "02" } div { h2 { "SQL" } p { "Always visible. Generate plan writes this editor. Field clicks insert at the cursor." } } } + if active_revision.as_ref().is_some_and(|revision| revision.manually_edited) { + span { class: "analyze-edited-badge", "Manually edited" } + } + } + label { class: "analyze-question-label", r#for: "analysis-sql", "Read-only query" } + textarea { + id: "analysis-sql", + class: "analyze-sql-editor", + rows: "10", + value: "{sql_text}", + placeholder: "SELECT …", + disabled: sql_locked, + oninput: move |event| { + let Some(mut current) = session() else { return; }; + let Some(active) = current.active_revision_mut() else { return; }; + if apply_manual_sql(active, event.value()).is_err() { + return; + } + persist_session(¤t, &mut recent_sessions, &mut storage_notice); + session.set(Some(current)); + }, + } + if let Some(revision) = active_revision.as_ref() { + if revision.state == RevisionState::QueryError { + if let Some(error) = revision.error.as_ref() { + div { class: "analyze-error", role: "alert", strong { "Analysis could not run" } p { "{error}" } p { "The SQL is unchanged. Retry when ready." } } + } + } + if revision.needs_rerun { + div { class: "analyze-config-callout", role: "status", + div { strong { "Rerun to restore rows" } p { "Saved summaries remain visible, but result rows are never stored in browser history." } } + } + } + } + if view_model.as_ref().is_some_and(|model| model.question_out_of_date) { + div { class: "analyze-config-callout", role: "status", + div { + strong { "This plan is for the previous question" } + p { "Regenerate to review a plan for the current question, or restore the reviewed question to run this SQL." } + } + } + } + div { class: "analyze-plan-actions", + p { "Nothing is queried until you Run." } + button { class: "button primary", r#type: "button", + disabled: !run_enabled, + onclick: run_analysis, + if active_revision.as_ref().is_some_and(|revision| revision.state == RevisionState::Executing) { span { class: "analyze-spinner", aria_hidden: "true" } "Running analysis…" } + else if active_revision.as_ref().is_some_and(|revision| revision.needs_rerun) { "Rerun to restore rows" } + else if view_model.as_ref().is_some_and(|model| model.primary_action == PrimaryAction::RetryAnalysis) { "Retry analysis" } + else { "Run analysis" } + } + } + } + + if !revision_history.is_empty() { + nav { class: "analyze-revision-timeline", aria_label: "Analysis revision history", + for revision in revision_history { + button { + class: if revision.id == current_revision_id { "analyze-revision active" } else { "analyze-revision" }, + r#type: "button", + onclick: move |_| select_revision.call(revision.id), + span { class: "analyze-revision-marker", aria_hidden: "true" } + span { class: "analyze-revision-copy", + strong { "{revision_heading(&revision)}" } + small { "{revision_state_label(&revision.state)} · {relative_time_label(revision.updated_at_ms, timeline_now_ms)} · {revision_row_label(&revision)}" } + } + } + } + } + } + + if let Some(revision) = active_revision.as_ref() { + if let Some(plan) = revision.plan.as_ref() { + if shows_plan_summary(plan) { + section { class: "analyze-plan-card", aria_label: "Proposed analysis plan", + div { class: "analyze-section-heading", + div { span { "Plan" } div { h2 { "Review the analysis plan" } p { "Copilot proposed this intent. Edit SQL above or regenerate." } } } + if revision.manually_edited { span { class: "analyze-edited-badge", "Manually edited" } } + } + dl { class: "analyze-plan-summary", + div { dt { "Intent" } dd { "{plan.intent_summary}" } } + div { dt { "Scope" } dd { "{plan.scope_summary}" } } + PlanListRow { label: "Filters", values: plan.filters.clone() } + PlanListRow { label: "Grouping", values: plan.groupings.clone() } + PlanListRow { label: "Measures", values: plan.measures.clone() } + } + if !plan.warnings.is_empty() { + div { class: "analyze-warnings", role: "note", strong { "Plan warnings" } ul { for warning in &plan.warnings { li { "{warning}" } } } } + } + div { class: "analyze-plan-actions", + button { class: "button", r#type: "button", disabled: revision.state == RevisionState::Executing || generating, onclick: regenerate_plan, "Regenerate" } + } + } + } + } + + if let Some(evidence) = revision.evidence.clone() { + section { class: "analyze-result-card", aria_label: "Analysis result", + div { class: "analyze-section-heading", div { span { "03" } div { h2 { "Analysis result" } p { "Bounded evidence returned by the confirmed query." } } } } + if evidence.rows.is_empty() { + div { class: "analyze-empty-result", + div { + strong { "No rows matched this plan" } + p { "Rewrite the question or broaden the plan before trying again." } + } + button { class: "button", r#type: "button", onclick: rewrite_problem, "Rewrite question" } + } + } else { + ResultExplorer { + evidence: evidence.clone(), + profiles: revision.execution.as_ref().map(|execution| execution.profiles.clone()).unwrap_or_default(), + revision_id: revision.id, + refinement_enabled: refinement_plan_allowed( + revision.id, + revision.id, + &draft_question, + &revision.question, + ), + on_stage_filter: move |_| {}, + on_prepare_refinement: prepare_refinement, + } + if revision.state == RevisionState::Interpreting { + div { class: "analyze-interpretation-status", role: "status", + span { class: "analyze-spinner", aria_hidden: "true" } + div { strong { "Interpreting the returned evidence…" } p { "The Result Explorer remains available while the model prepares a grounded summary." } } + } + } + if revision.state == RevisionState::InterpretationError { + div { class: "analyze-interpretation-error", role: "alert", + div { + strong { "The evidence could not be interpreted" } + if let Some(message) = revision.error.as_ref() { p { "{message}" } } + p { "Returned rows and profiles are preserved. Retrying does not rerun SQL." } + } + button { class: "button", r#type: "button", onclick: retry_interpretation, "Retry interpretation" } + } + } + if let Some(interpretation) = revision.interpretation.clone() { + InterpretationPanel { + interpretation, + follow_up_enabled: config().is_configured() && follow_up_plan_allowed( + revision.id, + revision.id, + &draft_question, + &revision.question, + ), + on_follow_up: generate_follow_up, + on_edit_follow_up: edit_follow_up, + } + } + } + } + } else if let Some(interpretation) = revision.interpretation.clone() { + section { class: "analyze-result-card", aria_label: "Saved analysis interpretation", + div { class: "analyze-section-heading", div { span { "03" } div { h2 { "Saved interpretation" } p { "The summary was restored from this analysis session." } } } } + div { class: "analyze-saved-interpretation-note", role: "note", + strong { "Returned rows are not stored in the browser" } + p { "This saved interpretation remains available. Rerun to restore rows in Result Explorer." } + } + InterpretationPanel { + interpretation, + follow_up_enabled: config().is_configured() && follow_up_plan_allowed( + revision.id, + revision.id, + &draft_question, + &revision.question, + ), + on_follow_up: generate_follow_up, + on_edit_follow_up: edit_follow_up, + } + } + } + } + } + } + } + if settings_open() { + LlmSettings { + config: config(), + on_close: move |_| settings_open.set(false), + on_save: move |value| { + llm::save_config(&value); + config.set(value); + settings_open.set(false); + }, + } + } + } +} + +fn persist_session( + session: &AnalysisSession, + recent_sessions: &mut Signal>, + storage_notice: &mut Signal>, +) -> bool { + let mut persisted_session = session.clone(); + persisted_session.mark_updated(); + let mut sessions = match analysis_session::load_sessions(&persisted_session.storage_fingerprint) + { + Ok(sessions) => sessions, + Err(message) => { + recent_sessions.set(vec![persisted_session]); + storage_notice.set(Some(message)); + return false; + } + }; + sessions.retain(|saved| saved.id != persisted_session.id); + sessions.push(persisted_session.clone()); + analysis_session::trim_sessions(&mut sessions); + let persisted = if let Err(message) = + analysis_session::save_sessions(&persisted_session.storage_fingerprint, &sessions) + { + storage_notice.set(Some(message)); + false + } else { + true + }; + recent_sessions.set(sessions); + persisted +} + +fn persisted_session_id(session_id: &str, persisted: bool) -> Option { + persisted.then(|| session_id.to_string()) +} + +fn scope_for_catalog(scope: &AnalysisScope, catalog: &QueryCatalog) -> AnalysisScope { + AnalysisScope { + database: catalog.database.clone(), + storage_path: catalog.storage_path.clone(), + snapshot_id: catalog.snapshot_id.clone(), + items: scope.items.clone(), + } +} + +fn refinement_source_revision_id(refinement: &AnalysisRefinement) -> u64 { + match refinement { + AnalysisRefinement::Filter { intent } => intent.source_revision_id, + AnalysisRefinement::FullProfile { + source_revision_id, .. + } => *source_revision_id, + } +} + +fn refinement_plan_allowed( + source_revision_id: u64, + refinement_source_revision_id: u64, + draft_question: &str, + source_question: &str, +) -> bool { + refinement_source_revision_id == source_revision_id + && draft_question.trim() == source_question.trim() +} + +fn scope_item_label(item: &AnalysisScopeItem) -> String { + match item { + AnalysisScopeItem::Dataset { name } => format!("Dataset · {name}"), + AnalysisScopeItem::Root { + dataset, + root_session_id, + .. + } => format!("Root · {dataset} / {root_session_id}"), + AnalysisScopeItem::Run { run } => { + let mut coordinates = vec![ + run.dataset.as_str(), + run.file.as_str(), + run.agent_id.as_str(), + ]; + if let Some(root) = run.root_session_id.as_deref() { + coordinates.push(root); + } + coordinates.push(run.session_id.as_str()); + if let Some(run_id) = run.run_id.as_deref() { + coordinates.push(run_id); + } + format!("Run · {}", coordinates.join(" / ")) + } + } +} + +fn first_sql_line(sql: &str) -> Option<&str> { + sql.lines().map(str::trim).find(|line| !line.is_empty()) +} + +fn revision_heading(revision: &AnalysisRevision) -> String { + let question = revision.question.trim(); + if !question.is_empty() { + return question.into(); + } + revision + .plan + .as_ref() + .and_then(|plan| first_sql_line(&plan.sql)) + .unwrap_or("Draft") + .into() +} + +fn session_label(session: &AnalysisSession) -> String { + let title = session.title.trim(); + if !title.is_empty() { + return title.into(); + } + let Some(revision) = session.active_revision() else { + return "New analysis".into(); + }; + let question = revision.question.trim(); + if !question.is_empty() { + return question.into(); + } + revision + .plan + .as_ref() + .and_then(|plan| first_sql_line(&plan.sql).map(str::to_string)) + .unwrap_or_else(|| "New analysis".into()) +} + +fn shows_plan_summary(plan: &AnalysisPlan) -> bool { + plan.intent_summary != "Manual SQL" + || !plan.filters.is_empty() + || !plan.groupings.is_empty() + || !plan.measures.is_empty() + || !plan.warnings.is_empty() +} + +fn quote_sql_ident(name: &str) -> String { + let is_plain = name + .bytes() + .next() + .is_some_and(|byte| byte.is_ascii_alphabetic() || byte == b'_') + && name + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || byte == b'_'); + if is_plain { + name.to_string() + } else { + format!("\"{}\"", name.replace('"', "\"\"")) + } +} + +fn field_sql_token(table: &str, field: &str) -> String { + let table = table + .split('.') + .map(quote_sql_ident) + .collect::>() + .join("."); + format!("{table}.{}", quote_sql_ident(field)) +} + +fn sql_textarea_cursor() -> usize { + web_sys::window() + .and_then(|window| window.document()) + .and_then(|document| document.get_element_by_id("analysis-sql")) + .and_then(|element| element.dyn_into::().ok()) + .and_then(|textarea| textarea.selection_start().ok().flatten()) + .unwrap_or(0) as usize +} + +fn set_sql_textarea_cursor(index: usize) { + let Some(textarea) = web_sys::window() + .and_then(|window| window.document()) + .and_then(|document| document.get_element_by_id("analysis-sql")) + .and_then(|element| element.dyn_into::().ok()) + else { + return; + }; + let index = index as u32; + let _ = textarea.set_selection_start(Some(index)); + let _ = textarea.set_selection_end(Some(index)); +} + +fn revision_state_label(state: &RevisionState) -> &'static str { + match state { + RevisionState::Draft => "Draft", + RevisionState::GeneratingPlan => "Planning", + RevisionState::PlanReady => "Plan ready", + RevisionState::Executing => "Running", + RevisionState::Interpreting => "Interpreting", + RevisionState::Complete => "Complete", + RevisionState::PlanError => "Plan error", + RevisionState::QueryError => "Rerun required", + RevisionState::InterpretationError => "Interpretation error", + RevisionState::Stale => "Stale", + } +} + +fn revision_row_label(revision: &AnalysisRevision) -> String { + revision + .execution + .as_ref() + .map(|execution| format!("{} rows", execution.returned_rows)) + .unwrap_or_else(|| "Not run".into()) +} + +fn current_time_millis() -> u64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|duration| duration.as_millis().min(u64::MAX as u128) as u64) + .unwrap_or_default() +} + +fn relative_time_label(updated_at_ms: u64, now_ms: u64) -> String { + let elapsed_seconds = now_ms.saturating_sub(updated_at_ms) / 1_000; + if elapsed_seconds < 60 { + "Just now".into() + } else if elapsed_seconds < 60 * 60 { + format!("{} min ago", elapsed_seconds / 60) + } else if elapsed_seconds < 24 * 60 * 60 { + format!("{} hr ago", elapsed_seconds / (60 * 60)) + } else { + format!("{} days ago", elapsed_seconds / (24 * 60 * 60)) + } +} + +#[component] +fn PlanListRow(label: &'static str, values: Vec) -> Element { + rsx! { + div { + dt { "{label}" } + dd { + if values.is_empty() { + span { class: "analyze-none", "None" } + } else { + ul { for value in values { li { "{value}" } } } + } + } + } + } +} + +#[component] +fn InterpretationPanel( + interpretation: AnalysisInterpretation, + follow_up_enabled: bool, + on_follow_up: EventHandler, + on_edit_follow_up: EventHandler, +) -> Element { + rsx! { + section { class: "analyze-interpretation", aria_label: "Evidence interpretation", + div { class: "analyze-interpretation-grid", + section { class: "analyze-interpretation-block observed", + h3 { "Observed in this result" } + if interpretation.observations.is_empty() { + p { class: "analyze-none", "No direct observations were returned." } + } else { + ul { for observation in &interpretation.observations { li { "{observation}" } } } + } + if !interpretation.references.is_empty() { + div { class: "analyze-interpretation-references", aria_label: "Grounded evidence references", + for reference in &interpretation.references { + if let Some(identity) = interpretation_reference_identity(reference) { + span { class: "analyze-interpretation-reference linked", + span { "{reference.label}" } + a { href: "{identity.run_href}", "Run" } + if let Some(turn_href) = identity.turn_href { a { href: "{turn_href}", "Turn" } } + } + } else { + span { class: "analyze-interpretation-reference", "{reference.label}" } + } + } + } + } + } + section { class: "analyze-interpretation-block inferred", + h3 { "Possible explanation" } + if interpretation.inferences.is_empty() { + p { class: "analyze-none", "No inference was offered from this evidence." } + } else { + ul { for inference in &interpretation.inferences { li { "{inference}" } } } + } + } + section { class: "analyze-interpretation-block limitations", + h3 { "Coverage and limitations" } + if interpretation.limitations.is_empty() { + p { class: "analyze-none", "No additional limitations were reported." } + } else { + ul { for limitation in &interpretation.limitations { li { "{limitation}" } } } + } + } + section { class: "analyze-interpretation-block follow-ups", + h3 { "Continue investigating" } + if !follow_up_enabled && !interpretation.follow_ups.is_empty() { + p { class: "analyze-follow-up-stale", role: "status", "Follow-up planning is paused because the draft question changed. Restore the reviewed question or generate the edited draft." } + } + if interpretation.follow_ups.is_empty() { + p { class: "analyze-none", "No follow-up questions were suggested." } + } else { + div { class: "analyze-follow-up-list", + for (index, follow_up) in interpretation.follow_ups.iter().enumerate() { + div { class: "analyze-follow-up", key: "follow-up-{index}", + p { "{follow_up}" } + div { + button { + class: "button primary", + r#type: "button", + disabled: !follow_up_enabled, + onclick: { + let follow_up = follow_up.clone(); + move |_| on_follow_up.call(follow_up.clone()) + }, + "Generate plan" + } + button { + class: "analyze-link-button", + r#type: "button", + disabled: !follow_up_enabled, + onclick: { + let follow_up = follow_up.clone(); + move |_| on_edit_follow_up.call(follow_up.clone()) + }, + "Edit question" + } + } + } + } + } + } + } + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::analysis_session::{ + AnalysisEffect, AnalysisPlan, AnalysisRevision, AnalysisScope, AnalysisScopeItem, + EvidenceReference, RevisionState, SuggestedView, + }; + use crate::model::{QueryEvidence, RunSummary}; + + fn plan_ready_revision() -> AnalysisRevision { + let scope = AnalysisScope { + database: "default".into(), + storage_path: "/tmp/evidence".into(), + snapshot_id: "snapshot-1".into(), + items: vec![AnalysisScopeItem::Dataset { + name: "default".into(), + }], + }; + let mut revision = AnalysisRevision::draft(7, "Compare run outcomes", scope); + let operation_id = revision.begin_plan_generation().unwrap(); + let effect = revision + .finish_plan( + 7, + operation_id, + AnalysisPlan { + id: 7, + question: "Compare run outcomes".into(), + intent_summary: "Compare successful and failed runs".into(), + scope_summary: "The selected dataset".into(), + filters: vec!["Current snapshot".into()], + groupings: vec!["status".into()], + measures: vec!["run count".into()], + expected_columns: vec!["status".into(), "run_count".into()], + suggested_view: SuggestedView::Table, + sql: "SELECT status, COUNT(*) AS run_count FROM default.runs GROUP BY status" + .into(), + warnings: Vec::new(), + }, + ) + .unwrap(); + assert_eq!(revision.state, RevisionState::PlanReady); + assert!(effect.is_none()); + revision + } + + #[test] + fn plan_ready_exposes_run_but_never_auto_runs() { + let revision = plan_ready_revision(); + let model = AnalysisViewModel::from_revision(&revision, &revision.question); + + assert_eq!(model.primary_action, PrimaryAction::RunAnalysis); + assert!(!model.query_in_flight); + assert_eq!(model.sql_disclosure_label, "SQL"); + assert!(revision.pending_effect.is_none()); + } + + #[test] + fn manual_sql_is_marked_and_still_waits_for_run() { + let mut revision = plan_ready_revision(); + + apply_manual_sql(&mut revision, "SELECT 1".into()).unwrap(); + + let model = AnalysisViewModel::from_revision(&revision, &revision.question); + assert!(model.manually_edited); + assert_eq!(revision.state, RevisionState::PlanReady); + assert!(revision.pending_effect.is_none()); + } + + fn empty_draft() -> AnalysisRevision { + AnalysisRevision::draft( + 1, + "", + AnalysisScope { + database: "default".into(), + storage_path: "/tmp/evidence".into(), + snapshot_id: "snapshot-1".into(), + items: vec![AnalysisScopeItem::Dataset { + name: "default".into(), + }], + }, + ) + } + + #[test] + fn draft_sql_enables_run_without_a_copilot_plan() { + let mut revision = empty_draft(); + apply_manual_sql(&mut revision, "SELECT status, COUNT(*) FROM runs".into()).unwrap(); + + let model = AnalysisViewModel::from_revision(&revision, ""); + assert!(revision.manually_edited); + assert_eq!(revision.state, RevisionState::PlanReady); + assert_eq!( + revision.plan.as_ref().map(|plan| plan.sql.as_str()), + Some("SELECT status, COUNT(*) FROM runs") + ); + assert!(model.run_enabled); + assert!(revision.pending_effect.is_none()); + } + + #[test] + fn empty_sql_cannot_run() { + let mut revision = empty_draft(); + apply_manual_sql(&mut revision, " ".into()).unwrap(); + let model = AnalysisViewModel::from_revision(&revision, ""); + assert!(!model.run_enabled); + } + + #[test] + fn manual_sql_can_be_edited_after_a_finished_run() { + for state in [RevisionState::Complete, RevisionState::InterpretationError] { + let mut revision = plan_ready_revision(); + revision.state = state; + apply_manual_sql(&mut revision, "SELECT 2".into()).unwrap(); + let model = AnalysisViewModel::from_revision(&revision, &revision.question); + assert_eq!(revision.state, RevisionState::PlanReady); + assert!(model.run_enabled); + assert!(model.manually_edited); + } + } + + #[test] + fn manual_sql_can_run_after_the_question_changes() { + let mut revision = empty_draft(); + apply_manual_sql(&mut revision, "SELECT 1".into()).unwrap(); + let model = AnalysisViewModel::from_revision(&revision, "a later question"); + assert!(model.run_enabled); + assert!(!model.question_out_of_date); + } + + #[test] + fn insert_sql_token_lands_at_the_cursor() { + assert_eq!(insert_sql_token("SELECT ", 7, "status"), "SELECT status"); + assert_eq!(insert_sql_token("", 0, "runs.status"), "runs.status"); + assert_eq!( + insert_sql_token("SELECT FROM runs", 7, "status"), + "SELECT status FROM runs" + ); + } + + #[test] + fn field_sql_token_uses_dataset_qualified_table_names() { + assert_eq!(field_sql_token("atif.runs", "status"), "atif.runs.status"); + assert_eq!(field_sql_token("atif.runs", "_file_"), "atif.runs._file_"); + } + + #[test] + fn session_label_uses_manual_sql_when_question_is_empty() { + let mut revision = empty_draft(); + apply_manual_sql(&mut revision, "SELECT status FROM runs\nLIMIT 10".into()).unwrap(); + let mut session = AnalysisSession::with_revision(revision); + session.title.clear(); + assert_eq!(session_label(&session), "SELECT status FROM runs"); + } + + #[test] + fn history_labels_expose_state_and_row_count() { + let revision = plan_ready_revision(); + let mut session = AnalysisSession::with_revision(revision); + session.title.clear(); + let empty = AnalysisSession::with_revision(AnalysisRevision::draft( + 1, + "", + session.active_revision().unwrap().scope.clone(), + )); + + assert_eq!(session_label(&session), "Compare run outcomes"); + assert_eq!(session_label(&empty), "New analysis"); + assert_eq!( + revision_state_label(&session.active_revision().unwrap().state), + "Plan ready" + ); + assert_eq!( + revision_row_label(session.active_revision().unwrap()), + "Not run" + ); + assert_eq!(relative_time_label(1_000, 31_000), "Just now"); + assert_eq!(relative_time_label(1_000, 301_000), "5 min ago"); + assert_eq!(relative_time_label(1_000, 7_201_000), "2 hr ago"); + } + + #[test] + fn failed_persistence_does_not_promote_bootstrap_session() { + assert_eq!(persisted_session_id("analysis-123", false), None); + assert_eq!( + persisted_session_id("analysis-123", true), + Some("analysis-123".into()) + ); + } + + #[test] + fn run_scope_chip_exposes_run_and_root_coordinates() { + let item = AnalysisScopeItem::Run { + run: RunSummary { + dataset: "default".into(), + file: "source.json".into(), + run_id: Some("run-a".into()), + agent_id: "agent".into(), + model_name: None, + session_id: "session-a".into(), + root_session_id: Some("root-a".into()), + path: "agent/root-a/session-a".into(), + row_count: 1, + duplicate_event_ids: 0, + status: "ok".into(), + }, + }; + + assert_eq!( + scope_item_label(&item), + "Run · default / source.json / agent / root-a / session-a / run-a" + ); + } + + #[test] + fn async_session_fence_rejects_another_session_with_the_same_revision_id() { + let mut session = AnalysisSession::with_revision(plan_ready_revision()); + let expected_session_id = session.id.clone(); + + assert!(revision_for_callback(&mut session, &expected_session_id, 7).is_some()); + assert!(revision_for_callback(&mut session, "another-session", 7).is_none()); + } + + #[test] + fn callback_can_finish_an_inactive_revision_but_operation_token_still_decides() { + let mut planning = + AnalysisRevision::draft(7, "Compare run outcomes", plan_ready_revision().scope); + let operation_id = planning.begin_plan_generation().unwrap(); + let mut session = AnalysisSession::with_revision(planning); + let expected_session_id = session.id.clone(); + let active_id = session + .new_revision("Another question", plan_ready_revision().scope) + .id; + + let revision = revision_for_callback(&mut session, &expected_session_id, 7).unwrap(); + assert_eq!( + revision + .finish_plan(7, operation_id + 1, plan_ready_revision().plan.unwrap()) + .unwrap(), + None + ); + assert_eq!(revision.state, RevisionState::GeneratingPlan); + revision + .finish_plan(7, operation_id, plan_ready_revision().plan.unwrap()) + .unwrap(); + + assert_eq!(revision.state, RevisionState::PlanReady); + assert_eq!(session.active_revision_id, active_id); + } + + #[test] + fn query_and_interpretation_callbacks_can_finish_inactive_revisions() { + let mut executing = plan_ready_revision(); + executing.confirm_execution().unwrap(); + let (revision_id, query_operation) = match executing.take_pending_effect().unwrap() { + AnalysisEffect::ExecuteSql { + revision_id, + operation_id, + .. + } => (revision_id, operation_id), + effect => panic!("expected execute effect, got {effect:?}"), + }; + let mut query_session = AnalysisSession::with_revision(executing); + let query_session_id = query_session.id.clone(); + let query_active_id = query_session + .new_revision("Another question", plan_ready_revision().scope) + .id; + + revision_for_callback(&mut query_session, &query_session_id, revision_id) + .unwrap() + .finish_query( + revision_id, + query_operation, + QueryEvidence { + rows: Vec::new(), + returned_rows: 0, + truncated: false, + max_rows: 100, + max_bytes: 4 * 1024 * 1024, + }, + Vec::new(), + ) + .unwrap(); + + assert_eq!( + query_session + .revisions + .iter() + .find(|revision| revision.id == revision_id) + .unwrap() + .state, + RevisionState::Complete + ); + assert_eq!(query_session.active_revision_id, query_active_id); + + let mut interpreting = plan_ready_revision(); + interpreting.confirm_execution().unwrap(); + let (revision_id, query_operation) = match interpreting.take_pending_effect().unwrap() { + AnalysisEffect::ExecuteSql { + revision_id, + operation_id, + .. + } => (revision_id, operation_id), + effect => panic!("expected execute effect, got {effect:?}"), + }; + let interpretation_operation = match interpreting + .finish_query( + revision_id, + query_operation, + QueryEvidence { + rows: vec![serde_json::json!({"status": "failed"})], + returned_rows: 1, + truncated: false, + max_rows: 100, + max_bytes: 4 * 1024 * 1024, + }, + Vec::new(), + ) + .unwrap() + .unwrap() + { + AnalysisEffect::Interpret { operation_id, .. } => operation_id, + effect => panic!("expected interpretation effect, got {effect:?}"), + }; + let mut interpretation_session = AnalysisSession::with_revision(interpreting); + let interpretation_session_id = interpretation_session.id.clone(); + let interpretation_active_id = interpretation_session + .new_revision("Another question", plan_ready_revision().scope) + .id; + + revision_for_callback( + &mut interpretation_session, + &interpretation_session_id, + revision_id, + ) + .unwrap() + .finish_interpretation( + revision_id, + interpretation_operation, + AnalysisInterpretation::default(), + ) + .unwrap(); + + assert_eq!( + interpretation_session + .revisions + .iter() + .find(|revision| revision.id == revision_id) + .unwrap() + .state, + RevisionState::Complete + ); + assert_eq!( + interpretation_session.active_revision_id, + interpretation_active_id + ); + } + + #[test] + fn scope_removal_policy_blocks_the_last_chip_and_plan_or_query_generation() { + let catalog = QueryCatalog { + snapshot_id: "snapshot-1".into(), + read_only: true, + database: "default".into(), + storage_path: "/tmp/evidence".into(), + path_column: "_file_".into(), + datasets: Vec::new(), + tables: Vec::new(), + }; + let dataset_scope = AnalysisScope::from_catalog(&catalog); + let root_scope = AnalysisScope::from_root(&catalog, "default", "source.json", "root-a"); + assert!(!scope_item_removal_enabled( + &dataset_scope, + Some(&catalog), + None + )); + assert!(scope_item_removal_enabled( + &root_scope, + Some(&catalog), + None + )); + assert!(!scope_item_removal_enabled(&root_scope, None, None)); + assert!(!scope_item_removal_enabled( + &root_scope, + Some(&catalog), + Some(&RevisionState::GeneratingPlan) + )); + assert!(!scope_item_removal_enabled( + &root_scope, + Some(&catalog), + Some(&RevisionState::Executing) + )); + assert!(scope_item_removal_enabled( + &root_scope, + Some(&catalog), + Some(&RevisionState::Interpreting) + )); + } + + #[test] + fn run_requires_draft_question_to_match_reviewed_question() { + for state in [RevisionState::PlanReady, RevisionState::QueryError] { + let mut revision = plan_ready_revision(); + revision.state = state; + + let changed = AnalysisViewModel::from_revision(&revision, "Compare model latency"); + assert!(!changed.run_enabled); + assert!(changed.question_out_of_date); + + let reviewed = AnalysisViewModel::from_revision(&revision, " Compare run outcomes "); + assert!(reviewed.run_enabled); + assert!(!reviewed.question_out_of_date); + } + } + + #[test] + fn refinement_plan_requires_source_revision_and_current_reviewed_question() { + assert!(refinement_plan_allowed( + 7, + 7, + " Compare run outcomes ", + "Compare run outcomes", + )); + assert!(!refinement_plan_allowed( + 7, + 7, + "Compare model latency", + "Compare run outcomes", + )); + assert!(!refinement_plan_allowed( + 8, + 7, + "Compare run outcomes", + "Compare run outcomes", + )); + } + + #[test] + fn query_success_prepares_a_digest_after_publishing_evidence() { + let mut revision = plan_ready_revision(); + revision.confirm_execution().unwrap(); + let (revision_id, operation_id) = match revision.take_pending_effect().unwrap() { + AnalysisEffect::ExecuteSql { + revision_id, + operation_id, + .. + } => (revision_id, operation_id), + effect => panic!("expected execute effect, got {effect:?}"), + }; + let evidence = QueryEvidence { + rows: vec![serde_json::json!({"status": "failed"})], + returned_rows: 1, + truncated: false, + max_rows: 100, + max_bytes: 4 * 1024 * 1024, + }; + + let prepared = finish_query_for_interpretation( + &mut revision, + revision_id, + operation_id, + evidence.clone(), + profile_rows(&evidence.rows), + ) + .unwrap() + .unwrap(); + + assert_eq!(revision.evidence, Some(evidence)); + assert_eq!(revision.state, RevisionState::Interpreting); + assert_eq!(prepared.revision_id, revision_id); + assert_eq!(prepared.digest.rows.len(), 1); + } + + #[test] + fn manual_sql_on_a_restored_revision_discards_all_derived_result_state() { + let mut revision = plan_ready_revision(); + revision.confirm_execution().unwrap(); + let (revision_id, query_operation) = match revision.take_pending_effect().unwrap() { + AnalysisEffect::ExecuteSql { + revision_id, + operation_id, + .. + } => (revision_id, operation_id), + effect => panic!("expected execute effect, got {effect:?}"), + }; + let interpretation_effect = revision + .finish_query( + revision_id, + query_operation, + QueryEvidence { + rows: vec![serde_json::json!({"status": "failed"})], + returned_rows: 1, + truncated: false, + max_rows: 100, + max_bytes: 4 * 1024 * 1024, + }, + Vec::new(), + ) + .unwrap() + .unwrap(); + let AnalysisEffect::Interpret { + operation_id: interpretation_operation, + .. + } = interpretation_effect + else { + panic!("expected interpretation effect"); + }; + revision + .finish_interpretation( + revision_id, + interpretation_operation, + AnalysisInterpretation { + observations: vec!["old conclusion".into()], + ..AnalysisInterpretation::default() + }, + ) + .unwrap(); + revision.evidence = None; + revision.state = RevisionState::QueryError; + revision.needs_rerun = true; + + apply_manual_sql(&mut revision, "SELECT 1".into()).unwrap(); + + assert_eq!(revision.state, RevisionState::PlanReady); + assert!(revision.execution.is_none()); + assert!(revision.evidence.is_none()); + assert!(revision.interpretation.is_none()); + assert!(!revision.needs_rerun); + } + + #[test] + fn removing_scope_items_changes_only_the_working_scope_and_never_removes_the_last() { + let mut working_scope = plan_ready_revision().scope; + working_scope.items.push(AnalysisScopeItem::Dataset { + name: "secondary".into(), + }); + let reviewed_scope = working_scope.clone(); + + let next_scope = scope_without_item(&working_scope, 0, None).unwrap(); + + assert_eq!(next_scope.items.len(), 1); + assert_eq!( + next_scope.items[0], + AnalysisScopeItem::Dataset { + name: "secondary".into() + } + ); + assert_eq!(working_scope, reviewed_scope); + assert!(scope_without_item(&next_scope, 0, None).is_none()); + assert!(scope_without_item(&working_scope, 9, None).is_none()); + } + + #[test] + fn removing_a_single_run_or_root_falls_back_to_catalog_scope() { + let catalog = QueryCatalog { + snapshot_id: "snapshot-1".into(), + read_only: true, + database: "default".into(), + storage_path: "/tmp/evidence".into(), + path_column: "_file_".into(), + datasets: Vec::new(), + tables: Vec::new(), + }; + let run_scope = AnalysisScope::from_run( + &catalog, + RunSummary { + dataset: "default".into(), + file: "source.json".into(), + run_id: Some("run-a".into()), + agent_id: "agent".into(), + model_name: None, + session_id: "session-a".into(), + root_session_id: Some("root-a".into()), + path: "agent/root-a/session-a".into(), + row_count: 1, + duplicate_event_ids: 0, + status: "ok".into(), + }, + ); + let root_scope = AnalysisScope::from_root(&catalog, "default", "source.json", "root-a"); + let dataset_scope = AnalysisScope::from_catalog(&catalog); + let expected = AnalysisScope::from_catalog(&catalog); + + assert_eq!( + scope_without_item(&run_scope, 0, Some(&catalog)), + Some(expected.clone()) + ); + assert_eq!( + scope_without_item(&root_scope, 0, Some(&catalog)), + Some(expected) + ); + assert!(scope_without_item(&dataset_scope, 0, Some(&catalog)).is_none()); + assert!(scope_without_item(&run_scope, 0, None).is_none()); + } + + #[test] + fn follow_up_planning_requires_the_current_revision_and_unchanged_draft() { + assert!(follow_up_plan_allowed( + 7, + 7, + " Compare run outcomes ", + "Compare run outcomes", + )); + assert!(!follow_up_plan_allowed( + 7, + 8, + "Compare run outcomes", + "Compare run outcomes", + )); + assert!(!follow_up_plan_allowed( + 7, + 7, + "Compare model latency", + "Compare run outcomes", + )); + } + + #[test] + fn interpretation_reference_reuses_result_identity_coordinates() { + let reference = EvidenceReference { + label: "failed turn".into(), + row_index: Some(0), + dataset: Some("default".into()), + file: Some("source.json".into()), + run_id: Some("run-1".into()), + agent_id: Some("agent-1".into()), + session_id: Some("session-1".into()), + root_session_id: Some("root-1".into()), + turn_id: Some(4), + }; + + let identity = interpretation_reference_identity(&reference).unwrap(); + + assert_eq!( + identity.run_href, + "?page=detail&dataset=default&file=source.json&run_id=run-1&agent_id=agent-1&session_id=session-1&root_session_id=root-1" + ); + assert_eq!( + identity.turn_href, + Some(format!("{}&turn=4", identity.run_href)) + ); + } +} diff --git a/pchronicle-web/src/analysis_agent.rs b/pchronicle-web/src/analysis_agent.rs new file mode 100644 index 00000000..7a67fae4 --- /dev/null +++ b/pchronicle-web/src/analysis_agent.rs @@ -0,0 +1,1446 @@ +#![allow(dead_code)] + +use std::collections::BTreeSet; + +use serde::{Deserialize, Serialize}; +use serde_json::{json, Value}; + +use crate::analysis_session::{ + AnalysisInterpretation, AnalysisPlan, AnalysisScope, AnalysisScopeItem, EvidenceReference, + SuggestedView, +}; +use crate::llm::{self, CompletionRequest, LlmConfig}; +use crate::model::QueryCatalog; +use crate::result_profile::{AnalysisRefinement, ColumnProfile}; + +pub const EVIDENCE_DIGEST_BYTES: usize = 64 * 1024; +const SQL_DIGEST_CHARS: usize = 8 * 1024; +const CELL_DIGEST_CHARS: usize = 512; +const MAX_DIGEST_ROWS: usize = 50; +const INTERACTIVE_MAX_ROWS: usize = 100; +const INTERACTIVE_MAX_BYTES: usize = 4 * 1024 * 1024; +const QUESTION_DIGEST_CHARS: usize = 4 * 1024; +const SCOPE_TEXT_DIGEST_CHARS: usize = 512; +const PROFILE_TEXT_DIGEST_CHARS: usize = 512; +const MAX_SCOPE_ITEMS: usize = 16; +const MAX_DIGEST_COLUMNS: usize = 64; + +pub struct PlanRequest { + pub config: LlmConfig, + pub catalog: QueryCatalog, + pub scope: AnalysisScope, + pub question: String, + pub plan_id: u64, + pub previous_plan: Option, + pub refinement: Option, +} + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct EvidenceDigest { + pub question: String, + pub scope: AnalysisScope, + pub sql: String, + pub columns: Vec, + pub profiles: Vec, + pub rows: Vec, + pub returned_rows: usize, + pub query_truncated: bool, + pub max_rows: usize, + pub max_bytes: usize, + pub digest_truncated: bool, +} + +pub struct InterpretationRequest { + pub config: LlmConfig, + pub revision_id: u64, + pub digest: EvidenceDigest, +} + +#[derive(Clone, Debug, PartialEq)] +pub struct AnalysisAgentError { + pub message: String, +} + +impl AnalysisAgentError { + fn new(message: impl Into) -> Self { + Self { + message: message.into(), + } + } +} + +impl From for AnalysisAgentError { + fn from(error: serde_json::Error) -> Self { + Self::new(error.to_string()) + } +} + +pub async fn generate_plan(request: PlanRequest) -> Result { + let system = plan_system_prompt( + &request.catalog, + &request.scope, + request.previous_plan.as_ref(), + request.refinement.as_ref(), + )?; + let messages = vec![json!({ + "role": "user", + "content": serde_json::to_string(&json!({"question": request.question}))?, + })]; + let content = request_json_content(&request.config, &system, messages).await?; + match parse_plan_content(&content, request.plan_id, &request.question) { + Ok(plan) => Ok(plan), + Err(first_error) => { + let repair_messages = vec![ + json!({"role":"user", "content": content}), + json!({ + "role":"user", + "content": format!( + "Return one corrected AnalysisPlan JSON object only. Validation error: {}", + first_error.message + ), + }), + ]; + let repaired = request_json_content(&request.config, &system, repair_messages).await?; + parse_plan_content(&repaired, request.plan_id, &request.question) + } + } +} + +pub async fn interpret( + request: InterpretationRequest, +) -> Result { + let system = interpretation_system_prompt(); + let messages = vec![json!({ + "role": "user", + "content": serde_json::to_string(&evidence_digest_prompt_value(&request.digest))?, + })]; + let content = request_json_content(&request.config, &system, messages).await?; + match parse_interpretation_content(&content) + .and_then(|interpretation| prepare_interpretation(interpretation, &request.digest)) + { + Ok(interpretation) => Ok(interpretation), + Err(first_error) => { + let repair_messages = vec![ + json!({"role":"user", "content": content}), + json!({ + "role":"user", + "content": format!( + "Return one corrected AnalysisInterpretation JSON object only. Validation error: {}", + first_error.message + ), + }), + ]; + let repaired = request_json_content(&request.config, &system, repair_messages).await?; + parse_interpretation_content(&repaired) + .and_then(|interpretation| prepare_interpretation(interpretation, &request.digest)) + } + } +} + +pub fn build_evidence_digest( + plan: &AnalysisPlan, + scope: &AnalysisScope, + evidence: &crate::model::QueryEvidence, + profiles: &[ColumnProfile], +) -> EvidenceDigest { + let (question, question_truncated) = clamp_text(&plan.question, QUESTION_DIGEST_CHARS); + let (sql, sql_truncated) = clamp_text(&plan.sql, SQL_DIGEST_CHARS); + let (scope, scope_truncated) = compact_scope(scope); + let (profiles, profiles_truncated) = compact_profiles(profiles); + let (columns, columns_truncated) = digest_columns(&evidence.rows); + let mut digest = EvidenceDigest { + question, + scope, + sql, + columns, + profiles, + rows: Vec::new(), + returned_rows: evidence.returned_rows, + query_truncated: evidence.truncated, + max_rows: evidence.max_rows, + max_bytes: evidence.max_bytes, + digest_truncated: question_truncated + || sql_truncated + || scope_truncated + || profiles_truncated + || columns_truncated, + }; + + fit_metadata(&mut digest); + for row in evidence.rows.iter().take(MAX_DIGEST_ROWS) { + let (row, cells_truncated) = clamp_row(row); + let mut candidate = digest.clone(); + candidate.rows.push(row); + candidate.digest_truncated |= cells_truncated; + if serialized_len(&candidate) <= EVIDENCE_DIGEST_BYTES { + digest = candidate; + } else { + digest.digest_truncated = true; + break; + } + } + if evidence.rows.len() > digest.rows.len() { + digest.digest_truncated = true; + } + fit_metadata(&mut digest); + digest +} + +pub fn ensure_truncation_limitation( + interpretation: &mut AnalysisInterpretation, + digest: &EvidenceDigest, +) { + if !(digest.query_truncated || digest.digest_truncated) + || interpretation + .limitations + .iter() + .any(|limitation| describes_incomplete_coverage(limitation)) + { + return; + } + interpretation.limitations.insert( + 0, + "This interpretation covers only the bounded evidence sent to the model because the query result or evidence digest was truncated." + .into(), + ); +} + +fn describes_incomplete_coverage(limitation: &str) -> bool { + let limitation = limitation.to_ascii_lowercase(); + limitation.contains("truncat") + || limitation.contains("incomplete coverage") + || limitation.contains("partial coverage") +} + +pub fn plan_system_prompt( + catalog: &QueryCatalog, + scope: &AnalysisScope, + previous_plan: Option<&AnalysisPlan>, + refinement: Option<&AnalysisRefinement>, +) -> Result { + let catalog = catalog_prompt_value(catalog); + let scope = scope_prompt_value(scope); + let previous_plan = previous_plan.map(serde_json::to_value).transpose()?; + let refinement = refinement.map(serde_json::to_value).transpose()?; + let context = json!({ + "catalog": catalog, + "scope": scope, + "prior_plan": previous_plan, + "refinement": refinement, + "server_budgets": { + "max_rows": INTERACTIVE_MAX_ROWS, + "max_bytes": INTERACTIVE_MAX_BYTES, + }, + }); + Ok(format!( + "You create reviewable analysis plans for pChronicle. Never execute SQL; only return an AnalysisPlan proposal. You have no tools and must only propose read-only SQL for later user-confirmed execution. Return a single JSON object with intent_summary, scope_summary, filters, groupings, measures, expected_columns, suggested_view, sql, and warnings. SQL must begin with SELECT, WITH, or EXPLAIN. Use only the catalog and scope below; do not invent schema or evidence.\n\nPlanning context:\n{}", + serde_json::to_string(&context)? + )) +} + +pub fn interpretation_system_prompt() -> String { + "AnalysisInterpretation\nInterpret only the supplied evidence digest. Do not add facts not present in that digest. Return one JSON object with the required arrays observations, inferences, limitations, follow_ups, and references. Keep observations separate from inferences; references must identify digest rows or scope coordinates. If query_truncated or digest_truncated is true, limitations must explicitly describe that incomplete coverage." + .into() +} + +async fn request_json_content( + config: &LlmConfig, + system: &str, + messages: Vec, +) -> Result { + let message = match llm::complete( + config, + CompletionRequest { + system: system.into(), + messages: messages.clone(), + tools: None, + response_format: Some(json!({"type": "json_object"})), + temperature: 0.1, + }, + ) + .await + { + Ok(message) => message, + Err(error) if error.suggests_response_format_unsupported() => llm::complete( + config, + CompletionRequest { + system: system.into(), + messages, + tools: None, + response_format: None, + temperature: 0.1, + }, + ) + .await + .map_err(completion_error)?, + Err(error) => return Err(completion_error(error)), + }; + if message + .get("tool_calls") + .and_then(Value::as_array) + .is_some_and(|calls| !calls.is_empty()) + { + return Err(AnalysisAgentError::new( + "LLM returned tool calls, but structured analysis agents do not use tools.", + )); + } + message + .get("content") + .and_then(Value::as_str) + .map(str::trim) + .filter(|content| !content.is_empty()) + .map(str::to_string) + .ok_or_else(|| AnalysisAgentError::new("LLM returned an empty structured response.")) +} + +fn completion_error(error: llm::CompletionError) -> AnalysisAgentError { + AnalysisAgentError::new(error.message) +} + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct PlanPayload { + intent_summary: String, + scope_summary: String, + filters: Vec, + groupings: Vec, + measures: Vec, + expected_columns: Vec, + suggested_view: SuggestedView, + sql: String, + warnings: Vec, +} + +fn parse_plan_content( + raw: &str, + plan_id: u64, + question: &str, +) -> Result { + require_json_object(raw)?; + let payload: PlanPayload = serde_json::from_str(raw) + .map_err(|error| AnalysisAgentError::new(format!("Invalid AnalysisPlan JSON: {error}")))?; + require_text("intent_summary", &payload.intent_summary)?; + require_text("scope_summary", &payload.scope_summary)?; + validate_text_array("filters", &payload.filters)?; + validate_text_array("groupings", &payload.groupings)?; + validate_text_array("measures", &payload.measures)?; + validate_text_array("expected_columns", &payload.expected_columns)?; + validate_text_array("warnings", &payload.warnings)?; + let sql = payload.sql.trim(); + if !has_allowed_sql_keyword(sql) || !has_at_most_one_sql_statement(sql) { + return Err(AnalysisAgentError::new( + "AnalysisPlan SQL must contain one SELECT, WITH, or EXPLAIN statement.", + )); + } + Ok(AnalysisPlan { + id: plan_id, + question: question.into(), + intent_summary: payload.intent_summary, + scope_summary: payload.scope_summary, + filters: payload.filters, + groupings: payload.groupings, + measures: payload.measures, + expected_columns: payload.expected_columns, + suggested_view: payload.suggested_view, + sql: sql.into(), + warnings: payload.warnings, + }) +} + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct InterpretationPayload { + observations: Vec, + inferences: Vec, + limitations: Vec, + follow_ups: Vec, + references: Vec, +} + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct EvidenceReferencePayload { + label: String, + row_index: Option, + dataset: Option, + file: Option, + run_id: Option, + agent_id: Option, + session_id: Option, + root_session_id: Option, + turn_id: Option, +} + +fn parse_interpretation_content(raw: &str) -> Result { + require_json_object(raw)?; + let payload: InterpretationPayload = serde_json::from_str(raw).map_err(|error| { + AnalysisAgentError::new(format!("Invalid AnalysisInterpretation JSON: {error}")) + })?; + validate_text_array("observations", &payload.observations)?; + validate_text_array("inferences", &payload.inferences)?; + validate_text_array("limitations", &payload.limitations)?; + validate_text_array("follow_ups", &payload.follow_ups)?; + let references = payload + .references + .into_iter() + .map(|reference| { + require_text("references[].label", &reference.label)?; + Ok(EvidenceReference { + label: reference.label, + row_index: reference.row_index, + dataset: reference.dataset, + file: reference.file, + run_id: reference.run_id, + agent_id: reference.agent_id, + session_id: reference.session_id, + root_session_id: reference.root_session_id, + turn_id: reference.turn_id, + }) + }) + .collect::, AnalysisAgentError>>()?; + Ok(AnalysisInterpretation { + observations: payload.observations, + inferences: payload.inferences, + limitations: payload.limitations, + follow_ups: payload.follow_ups, + references, + }) +} + +fn validate_interpretation( + interpretation: AnalysisInterpretation, + digest: &EvidenceDigest, +) -> Result { + if (digest.query_truncated || digest.digest_truncated) && interpretation.limitations.is_empty() + { + return Err(AnalysisAgentError::new( + "AnalysisInterpretation must describe truncated evidence in limitations.", + )); + } + for reference in &interpretation.references { + validate_reference(reference, digest)?; + } + Ok(interpretation) +} + +fn prepare_interpretation( + mut interpretation: AnalysisInterpretation, + digest: &EvidenceDigest, +) -> Result { + ensure_truncation_limitation(&mut interpretation, digest); + validate_interpretation(interpretation, digest) +} + +fn validate_reference( + reference: &EvidenceReference, + digest: &EvidenceDigest, +) -> Result<(), AnalysisAgentError> { + if let Some(row_index) = reference.row_index { + let row = digest.rows.get(row_index).ok_or_else(|| { + AnalysisAgentError::new("AnalysisInterpretation reference row_index is out of range.") + })?; + if !reference_matches_row(reference, row) { + return Err(AnalysisAgentError::new( + "AnalysisInterpretation reference coordinates do not match its digest row.", + )); + } + return Ok(()); + } + if !reference_has_coordinates(reference) { + return Ok(()); + } + if digest + .rows + .iter() + .any(|row| reference_matches_row(reference, row)) + { + return Ok(()); + } + if reference.turn_id.is_none() + && digest + .scope + .items + .iter() + .any(|item| reference_matches_scope_item(reference, item)) + { + return Ok(()); + } + Err(AnalysisAgentError::new( + "AnalysisInterpretation reference coordinates are not grounded in the evidence digest.", + )) +} + +fn reference_has_coordinates(reference: &EvidenceReference) -> bool { + reference.dataset.is_some() + || reference.file.is_some() + || reference.run_id.is_some() + || reference.agent_id.is_some() + || reference.session_id.is_some() + || reference.root_session_id.is_some() + || reference.turn_id.is_some() +} + +fn reference_matches_row(reference: &EvidenceReference, row: &Value) -> bool { + matches_row_text(row, "dataset", reference.dataset.as_deref()) + && matches_row_text(row, "file", reference.file.as_deref()) + && matches_row_text(row, "run_id", reference.run_id.as_deref()) + && matches_row_text(row, "agent_id", reference.agent_id.as_deref()) + && matches_row_text(row, "session_id", reference.session_id.as_deref()) + && matches_row_text(row, "root_session_id", reference.root_session_id.as_deref()) + && matches_row_turn(row, reference.turn_id) +} + +fn matches_row_text(row: &Value, name: &str, expected: Option<&str>) -> bool { + expected.is_none_or(|expected| { + row.get(name) + .or_else(|| (name == "file").then(|| row.get("_file_")).flatten()) + .and_then(Value::as_str) + == Some(expected) + }) +} + +fn matches_row_turn(row: &Value, expected: Option) -> bool { + expected.is_none_or(|expected| row.get("turn_id").and_then(Value::as_i64) == Some(expected)) +} + +fn reference_matches_scope_item(reference: &EvidenceReference, item: &AnalysisScopeItem) -> bool { + match item { + AnalysisScopeItem::Dataset { name } => { + matches_optional_text(reference.dataset.as_deref(), name) + && reference.file.is_none() + && reference.run_id.is_none() + && reference.agent_id.is_none() + && reference.session_id.is_none() + && reference.root_session_id.is_none() + } + AnalysisScopeItem::Root { + dataset, + file, + root_session_id, + } => { + matches_optional_text(reference.dataset.as_deref(), dataset) + && matches_optional_text(reference.file.as_deref(), file) + && matches_optional_text(reference.root_session_id.as_deref(), root_session_id) + && reference.run_id.is_none() + && reference.agent_id.is_none() + && reference.session_id.is_none() + } + AnalysisScopeItem::Run { run } => { + matches_optional_text(reference.dataset.as_deref(), &run.dataset) + && matches_optional_text(reference.file.as_deref(), &run.file) + && matches_optional_optional_text( + reference.run_id.as_deref(), + run.run_id.as_deref(), + ) + && matches_optional_text(reference.agent_id.as_deref(), &run.agent_id) + && matches_optional_text(reference.session_id.as_deref(), &run.session_id) + && matches_optional_optional_text( + reference.root_session_id.as_deref(), + run.root_session_id.as_deref(), + ) + } + } +} + +fn matches_optional_text(expected: Option<&str>, actual: &str) -> bool { + expected.is_none_or(|expected| expected == actual) +} + +fn matches_optional_optional_text(expected: Option<&str>, actual: Option<&str>) -> bool { + expected.is_none_or(|expected| actual == Some(expected)) +} + +fn has_allowed_sql_keyword(sql: &str) -> bool { + let sql = sql.trim_start().to_ascii_uppercase(); + ["SELECT", "WITH", "EXPLAIN"].iter().any(|keyword| { + sql.strip_prefix(keyword).is_some_and(|rest| { + rest.chars().next().is_none_or(|character| { + !(character.is_ascii_alphanumeric() || matches!(character, '_' | '$')) + }) + }) + }) +} + +fn has_at_most_one_sql_statement(sql: &str) -> bool { + #[derive(Clone, Copy, PartialEq, Eq)] + enum State { + Normal, + SingleQuoted, + DoubleQuoted, + BacktickQuoted, + LineComment, + BlockComment, + } + + let mut characters = sql.chars().peekable(); + let mut state = State::Normal; + let mut terminated = false; + while let Some(character) = characters.next() { + match state { + State::Normal if terminated => match character { + whitespace if whitespace.is_whitespace() => {} + '-' if characters.next_if_eq(&'-').is_some() => state = State::LineComment, + '/' if characters.next_if_eq(&'*').is_some() => state = State::BlockComment, + _ => return false, + }, + State::Normal => match character { + '\'' => state = State::SingleQuoted, + '"' => state = State::DoubleQuoted, + '`' => state = State::BacktickQuoted, + '-' if characters.next_if_eq(&'-').is_some() => state = State::LineComment, + '/' if characters.next_if_eq(&'*').is_some() => state = State::BlockComment, + ';' => terminated = true, + _ => {} + }, + State::SingleQuoted => { + if character == '\\' { + let _ = characters.next(); + } else if character == '\'' { + if characters.next_if_eq(&'\'').is_none() { + state = State::Normal; + } + } + } + State::DoubleQuoted => { + if character == '"' && characters.next_if_eq(&'"').is_none() { + state = State::Normal; + } + } + State::BacktickQuoted => { + if character == '`' && characters.next_if_eq(&'`').is_none() { + state = State::Normal; + } + } + State::LineComment if character == '\n' || character == '\r' => state = State::Normal, + State::LineComment => {} + State::BlockComment if character == '*' && characters.next_if_eq(&'/').is_some() => { + state = State::Normal; + } + State::BlockComment => {} + } + } + matches!(state, State::Normal | State::LineComment) +} + +fn require_json_object(raw: &str) -> Result<(), AnalysisAgentError> { + let trimmed = raw.trim(); + if !trimmed.starts_with('{') || !trimmed.ends_with('}') { + return Err(AnalysisAgentError::new( + "Expected one raw JSON object without Markdown or surrounding prose.", + )); + } + Ok(()) +} + +fn require_text(name: &str, value: &str) -> Result<(), AnalysisAgentError> { + if value.trim().is_empty() { + return Err(AnalysisAgentError::new(format!( + "{name} must not be empty." + ))); + } + Ok(()) +} + +fn validate_text_array(name: &str, values: &[String]) -> Result<(), AnalysisAgentError> { + for value in values { + require_text(name, value)?; + } + Ok(()) +} + +fn catalog_prompt_value(catalog: &QueryCatalog) -> Value { + json!({ + "tables": crate::model::queryable_tables(catalog).iter().map(|table| json!({ + "name": table.name, + "description": table.description, + "grain": table.grain, + "fields": table.fields.iter().map(|field| json!({ + "name": field.name, + "data_type": field.data_type, + "description": field.description, + })).collect::>(), + })).collect::>(), + }) +} + +fn scope_prompt_value(scope: &AnalysisScope) -> Value { + json!({ + "database": scope.database, + "items": scope.items.iter().map(scope_item_prompt_value).collect::>(), + }) +} + +fn scope_item_prompt_value(item: &AnalysisScopeItem) -> Value { + match item { + AnalysisScopeItem::Dataset { name } => json!({ + "kind": "dataset", + "name": name, + }), + AnalysisScopeItem::Root { + dataset, + file, + root_session_id, + } => json!({ + "kind": "root", + "dataset": dataset, + "file": file, + "root_session_id": root_session_id, + }), + AnalysisScopeItem::Run { run } => json!({ + "kind": "run", + "run": { + "dataset": run.dataset, + "file": run.file, + "run_id": run.run_id, + "agent_id": run.agent_id, + "session_id": run.session_id, + "root_session_id": run.root_session_id, + }, + }), + } +} + +fn evidence_digest_prompt_value(digest: &EvidenceDigest) -> Value { + json!({ + "question": digest.question, + "scope": scope_prompt_value(&digest.scope), + "sql": digest.sql, + "columns": digest.columns, + "profiles": digest.profiles, + "rows": digest.rows, + "returned_rows": digest.returned_rows, + "query_truncated": digest.query_truncated, + "max_rows": digest.max_rows, + "max_bytes": digest.max_bytes, + "digest_truncated": digest.digest_truncated, + }) +} + +fn digest_columns(rows: &[Value]) -> (Vec, bool) { + let columns = rows + .iter() + .filter_map(Value::as_object) + .flat_map(|row| row.keys().cloned()) + .collect::>(); + let mut truncated = columns.len() > MAX_DIGEST_COLUMNS; + let columns = columns + .into_iter() + .take(MAX_DIGEST_COLUMNS) + .map(|column| { + let (column, was_truncated) = clamp_text(&column, CELL_DIGEST_CHARS); + truncated |= was_truncated; + column + }) + .collect(); + (columns, truncated) +} + +fn compact_scope(scope: &AnalysisScope) -> (AnalysisScope, bool) { + let (database, mut truncated) = clamp_text(&scope.database, SCOPE_TEXT_DIGEST_CHARS); + let (storage_path, storage_truncated) = + clamp_text(&scope.storage_path, SCOPE_TEXT_DIGEST_CHARS); + let (snapshot_id, snapshot_truncated) = clamp_text(&scope.snapshot_id, SCOPE_TEXT_DIGEST_CHARS); + truncated |= storage_truncated || snapshot_truncated || scope.items.len() > MAX_SCOPE_ITEMS; + let items = scope + .items + .iter() + .take(MAX_SCOPE_ITEMS) + .map(|item| compact_scope_item(item, &mut truncated)) + .collect(); + ( + AnalysisScope { + database, + storage_path, + snapshot_id, + items, + }, + truncated, + ) +} + +fn compact_scope_item(item: &AnalysisScopeItem, truncated: &mut bool) -> AnalysisScopeItem { + match item { + AnalysisScopeItem::Dataset { name } => { + let (name, was_truncated) = clamp_text(name, SCOPE_TEXT_DIGEST_CHARS); + *truncated |= was_truncated; + AnalysisScopeItem::Dataset { name } + } + AnalysisScopeItem::Root { + dataset, + file, + root_session_id, + } => { + let (dataset, dataset_truncated) = clamp_text(dataset, SCOPE_TEXT_DIGEST_CHARS); + let (file, file_truncated) = clamp_text(file, SCOPE_TEXT_DIGEST_CHARS); + let (root_session_id, root_truncated) = + clamp_text(root_session_id, SCOPE_TEXT_DIGEST_CHARS); + *truncated |= dataset_truncated || file_truncated || root_truncated; + AnalysisScopeItem::Root { + dataset, + file, + root_session_id, + } + } + AnalysisScopeItem::Run { run } => { + let mut run = run.clone(); + clamp_run_text(&mut run, truncated); + AnalysisScopeItem::Run { run } + } + } +} + +fn clamp_run_text(run: &mut crate::model::RunSummary, truncated: &mut bool) { + for value in [ + &mut run.dataset, + &mut run.file, + &mut run.agent_id, + &mut run.session_id, + &mut run.path, + &mut run.status, + ] { + let (clamped, was_truncated) = clamp_text(value, SCOPE_TEXT_DIGEST_CHARS); + *value = clamped; + *truncated |= was_truncated; + } + for value in [ + &mut run.run_id, + &mut run.model_name, + &mut run.root_session_id, + ] { + if let Some(value) = value { + let (clamped, was_truncated) = clamp_text(value, SCOPE_TEXT_DIGEST_CHARS); + *value = clamped; + *truncated |= was_truncated; + } + } +} + +fn compact_profiles(profiles: &[ColumnProfile]) -> (Vec, bool) { + let mut truncated = false; + let profiles = profiles + .iter() + .cloned() + .map(|mut profile| { + let (name, was_truncated) = clamp_text(&profile.name, PROFILE_TEXT_DIGEST_CHARS); + profile.name = name; + truncated |= was_truncated; + for value in &mut profile.top_values { + let (label, was_truncated) = clamp_text(&value.label, PROFILE_TEXT_DIGEST_CHARS); + value.label = label; + truncated |= was_truncated; + } + let mut type_counts = std::collections::BTreeMap::new(); + for (name, count) in profile.type_counts { + let (name, was_truncated) = clamp_text(&name, PROFILE_TEXT_DIGEST_CHARS); + truncated |= was_truncated; + *type_counts.entry(name).or_insert(0) += count; + } + profile.type_counts = type_counts; + profile + }) + .collect(); + (profiles, truncated) +} + +fn clamp_row(row: &Value) -> (Value, bool) { + let Some(object) = row.as_object() else { + return clamp_cell(row); + }; + let mut truncated = false; + let mut clamped = serde_json::Map::new(); + for (name, value) in object { + let (name, name_truncated) = clamp_text(name, CELL_DIGEST_CHARS); + let (value, value_truncated) = clamp_cell(value); + truncated |= name_truncated || value_truncated; + clamped.insert(name, value); + } + (Value::Object(clamped), truncated) +} + +fn clamp_cell(value: &Value) -> (Value, bool) { + if let Some(value) = value.as_str() { + let (value, truncated) = clamp_text(value, CELL_DIGEST_CHARS); + return (Value::String(value), truncated); + } + if serialized_len(value) <= CELL_DIGEST_CHARS { + return (value.clone(), false); + } + let text = serde_json::to_string(value).expect("JSON values serialize"); + let (text, _) = clamp_text(&text, CELL_DIGEST_CHARS); + (Value::String(text), true) +} + +fn clamp_text(value: &str, max_chars: usize) -> (String, bool) { + if value.chars().count() <= max_chars { + return (value.into(), false); + } + let mut end = value.len(); + let mut seen = 0; + for (index, _) in value.char_indices() { + if seen == max_chars { + end = index; + break; + } + seen += 1; + } + (value[..end].into(), true) +} + +fn fit_metadata(digest: &mut EvidenceDigest) { + while serialized_len(digest) > EVIDENCE_DIGEST_BYTES { + digest.digest_truncated = true; + if digest.rows.pop().is_some() { + continue; + } + if digest.profiles.pop().is_some() { + continue; + } + if digest.columns.pop().is_some() { + continue; + } + if digest.scope.items.pop().is_some() { + continue; + } + let current_chars = digest.question.chars().count(); + if current_chars > 1 { + digest.question = clamp_text(&digest.question, current_chars / 2).0; + continue; + } + break; + } +} + +fn serialized_len(value: &T) -> usize { + serde_json::to_vec(value) + .expect("digest values serialize") + .len() +} + +#[cfg(test)] +mod tests { + use serde_json::{json, Value}; + + use super::*; + use crate::analysis_session::{AnalysisPlan, AnalysisScope, AnalysisScopeItem, SuggestedView}; + use crate::model::{ + QueryCatalog, QueryDatasetSummary, QueryEvidence, QueryFieldSummary, QueryTableSummary, + RunSummary, + }; + use crate::result_profile::profile_rows; + + #[test] + fn plan_parser_accepts_only_complete_structured_content() { + let raw = r#"{ + "intent_summary":"Compare outcomes", + "scope_summary":"current dataset", + "filters":[], + "groupings":["status"], + "measures":["run count"], + "expected_columns":["status","run_count"], + "suggested_view":"distribution", + "sql":"SELECT status, COUNT(*) AS run_count FROM default.runs GROUP BY status", + "warnings":[] + }"#; + let plan = parse_plan_content(raw, 7, "compare outcomes").unwrap(); + assert_eq!(plan.id, 7); + assert_eq!(plan.question, "compare outcomes"); + assert!(plan.sql.starts_with("SELECT")); + } + + #[test] + fn plan_parser_rejects_markdown_wrapped_json() { + let raw = "```json\n{\"sql\":\"SELECT 1\"}\n```"; + assert!(parse_plan_content(raw, 1, "question").is_err()); + } + + #[test] + fn plan_parser_rejects_keyword_prefixes_and_multiple_sql_statements() { + assert!(parse_plan_content(&plan_payload("SELECTED 1"), 1, "question").is_err()); + assert!(parse_plan_content( + &plan_payload("SELECT 1; DELETE FROM default.runs"), + 1, + "question" + ) + .is_err()); + assert!( + parse_plan_content(&plan_payload("SELECT 1; 'not a comment'"), 1, "question").is_err() + ); + } + + #[test] + fn plan_parser_allows_quoted_and_commented_semicolons_with_one_terminator() { + let sql = "SELECT ';' AS value, \"semi;identifier\" FROM default.runs /* ; */; -- ;\n"; + assert!(parse_plan_content(&plan_payload(sql), 1, "question").is_ok()); + } + + #[test] + fn interpretation_parser_requires_all_structured_sections() { + let raw = r#"{ + "observations":["One run is failed."], + "inferences":["Failures may warrant follow-up."], + "limitations":["Only the returned rows are available."], + "follow_ups":["Inspect failed runs."], + "references":[{ + "label":"failed row", + "row_index":0, + "dataset":"default", + "file":null, + "run_id":null, + "agent_id":null, + "session_id":null, + "root_session_id":null, + "turn_id":null + }] + }"#; + let interpretation = parse_interpretation_content(raw).unwrap(); + assert_eq!(interpretation.observations, vec!["One run is failed."]); + assert_eq!(interpretation.references[0].row_index, Some(0)); + + assert!(parse_interpretation_content( + r#"{"observations":[],"inferences":[],"limitations":[],"follow_ups":[]}"# + ) + .is_err()); + } + + #[test] + fn truncated_digest_requires_a_nonempty_limitation() { + let interpretation = parse_interpretation_content( + r#"{ + "observations":[], + "inferences":[], + "limitations":[], + "follow_ups":[], + "references":[] + }"#, + ) + .unwrap(); + let digest = build_evidence_digest(&plan(), &scope(), &evidence(Vec::new(), true), &[]); + assert!(validate_interpretation(interpretation, &digest).is_err()); + } + + #[test] + fn truncated_digest_gets_a_deterministic_limitation_when_the_model_omits_it() { + let mut interpretation = AnalysisInterpretation { + limitations: vec!["Latency was not selected by this query.".into()], + ..AnalysisInterpretation::default() + }; + let digest = build_evidence_digest(&plan(), &scope(), &evidence(Vec::new(), true), &[]); + + ensure_truncation_limitation(&mut interpretation, &digest); + + assert_eq!( + interpretation.limitations, + vec![ + "This interpretation covers only the bounded evidence sent to the model because the query result or evidence digest was truncated.", + "Latency was not selected by this query.", + ] + ); + } + + #[test] + fn interpretation_references_reject_out_of_range_and_fabricated_coordinates() { + let digest = digest_with_rows(vec![json!({ + "dataset":"default", + "file":"source.json", + "run_id":"run-1", + "agent_id":"agent-1", + "session_id":"session-1", + "root_session_id":"root-1", + "turn_id":4 + })]); + let out_of_range = interpretation_with_reference(EvidenceReference { + label: "outside rows".into(), + row_index: Some(1), + dataset: Some("default".into()), + file: None, + run_id: None, + agent_id: None, + session_id: None, + root_session_id: None, + turn_id: None, + }); + assert!(validate_interpretation(out_of_range, &digest).is_err()); + + let fabricated = interpretation_with_reference(EvidenceReference { + label: "fabricated run".into(), + row_index: Some(0), + dataset: Some("default".into()), + file: None, + run_id: Some("other-run".into()), + agent_id: None, + session_id: None, + root_session_id: None, + turn_id: None, + }); + assert!(validate_interpretation(fabricated, &digest).is_err()); + + let ungrounded = interpretation_with_reference(EvidenceReference { + label: "not in scope or rows".into(), + row_index: None, + dataset: Some("other".into()), + file: None, + run_id: None, + agent_id: None, + session_id: None, + root_session_id: None, + turn_id: None, + }); + assert!(validate_interpretation(ungrounded, &digest).is_err()); + } + + #[test] + fn interpretation_references_accept_grounded_rows_scope_and_labels() { + let digest = digest_with_rows(vec![json!({ + "dataset":"default", + "file":"source.json", + "run_id":"run-1", + "turn_id":4 + })]); + let row_reference = interpretation_with_reference(EvidenceReference { + label: "row match".into(), + row_index: Some(0), + dataset: Some("default".into()), + file: Some("source.json".into()), + run_id: Some("run-1".into()), + agent_id: None, + session_id: None, + root_session_id: None, + turn_id: Some(4), + }); + assert!(validate_interpretation(row_reference, &digest).is_ok()); + + let scope_reference = interpretation_with_reference(EvidenceReference { + label: "scope match".into(), + row_index: None, + dataset: Some("default".into()), + file: None, + run_id: None, + agent_id: None, + session_id: None, + root_session_id: None, + turn_id: None, + }); + assert!(validate_interpretation(scope_reference, &digest).is_ok()); + + let label_only = interpretation_with_reference(EvidenceReference { + label: "plain label".into(), + row_index: None, + dataset: None, + file: None, + run_id: None, + agent_id: None, + session_id: None, + root_session_id: None, + turn_id: None, + }); + assert!(validate_interpretation(label_only, &digest).is_ok()); + } + + #[test] + fn interpretation_reference_accepts_the_result_explorer_file_coordinate() { + let digest = digest_with_rows(vec![json!({ + "dataset":"default", + "_file_":"source.json", + "run_id":"run-1", + "agent_id":"agent-1", + "session_id":"session-1", + "root_session_id":"root-1" + })]); + let reference = interpretation_with_reference(EvidenceReference { + label: "grounded run".into(), + row_index: Some(0), + dataset: Some("default".into()), + file: Some("source.json".into()), + run_id: Some("run-1".into()), + agent_id: Some("agent-1".into()), + session_id: Some("session-1".into()), + root_session_id: Some("root-1".into()), + turn_id: None, + }); + + assert!(validate_interpretation(reference, &digest).is_ok()); + } + + #[test] + fn evidence_digest_is_bounded_and_marks_truncation() { + let huge = "轨".repeat(80_000); + let evidence = evidence(vec![json!({"message": huge})], true); + let digest = + build_evidence_digest(&plan(), &scope(), &evidence, &profile_rows(&evidence.rows)); + let encoded = serde_json::to_vec(&digest).unwrap(); + assert!(encoded.len() <= EVIDENCE_DIGEST_BYTES); + assert!(digest.digest_truncated); + assert!(digest.query_truncated); + } + + #[test] + fn plan_prompt_keeps_catalog_descriptions_and_no_execution_rule() { + let prompt = plan_system_prompt(&catalog(), &scope(), None, None).unwrap(); + assert!(prompt.contains("Never execute SQL; only return an AnalysisPlan proposal.")); + assert!(prompt.contains("default.runs")); + assert!(prompt.contains("Status of each recorded run")); + assert!(prompt.contains("Stable identifier for a recorded run")); + assert!(prompt.contains("one row per recorded run")); + } + + #[test] + fn plan_prompt_sends_only_approved_catalog_and_scope_context() { + let mut catalog = catalog(); + catalog.datasets = vec![QueryDatasetSummary { + name: "private-dataset".into(), + uri: "s3://secret-bucket/?token=private".into(), + ready_sources: 17, + error_sources: 4, + }]; + let scope = private_scope(); + + let prompt = plan_system_prompt(&catalog, &scope, None, None).unwrap(); + let (_, context) = prompt.split_once("Planning context:\n").unwrap(); + let context: Value = serde_json::from_str(context).unwrap(); + + assert_eq!( + context, + json!({ + "catalog": { + "tables": [{ + "name": "default.runs", + "description": "Recorded agent runs", + "grain": "one row per recorded run", + "fields": [ + { + "name": "status", + "data_type": "VARCHAR", + "description": "Status of each recorded run", + }, + { + "name": "run_id", + "data_type": "VARCHAR", + "description": "Stable identifier for a recorded run", + }, + ], + }], + }, + "scope": { + "database": "default", + "items": [ + { + "kind": "dataset", + "name": "default", + }, + { + "kind": "root", + "dataset": "default", + "file": "source.json", + "root_session_id": "root-1", + }, + { + "kind": "run", + "run": { + "dataset": "default", + "file": "source.json", + "run_id": "run-1", + "agent_id": "agent-1", + "session_id": "session-1", + "root_session_id": "root-1", + }, + }, + ], + }, + "prior_plan": null, + "refinement": null, + "server_budgets": { + "max_rows": 100, + "max_bytes": 4 * 1024 * 1024, + }, + }) + ); + } + + #[test] + fn interpretation_digest_sends_only_approved_scope_context() { + let mut digest = digest_with_rows(vec![json!({"status": "failed"})]); + digest.scope = private_scope(); + digest.columns = vec!["status".into()]; + + assert_eq!( + evidence_digest_prompt_value(&digest), + json!({ + "question": "compare outcomes", + "scope": { + "database": "default", + "items": [ + { + "kind": "dataset", + "name": "default", + }, + { + "kind": "root", + "dataset": "default", + "file": "source.json", + "root_session_id": "root-1", + }, + { + "kind": "run", + "run": { + "dataset": "default", + "file": "source.json", + "run_id": "run-1", + "agent_id": "agent-1", + "session_id": "session-1", + "root_session_id": "root-1", + }, + }, + ], + }, + "sql": "SELECT 1", + "columns": ["status"], + "profiles": [], + "rows": [{"status": "failed"}], + "returned_rows": 1, + "query_truncated": false, + "max_rows": 100, + "max_bytes": 4 * 1024 * 1024, + "digest_truncated": false, + }) + ); + } + + fn private_scope() -> AnalysisScope { + AnalysisScope { + database: "default".into(), + storage_path: "/Users/alice/private-trajectories".into(), + snapshot_id: "snapshot-secret".into(), + items: vec![ + AnalysisScopeItem::Dataset { + name: "default".into(), + }, + AnalysisScopeItem::Root { + dataset: "default".into(), + file: "source.json".into(), + root_session_id: "root-1".into(), + }, + AnalysisScopeItem::Run { + run: RunSummary { + dataset: "default".into(), + file: "source.json".into(), + run_id: Some("run-1".into()), + agent_id: "agent-1".into(), + model_name: Some("private-model".into()), + session_id: "session-1".into(), + root_session_id: Some("root-1".into()), + path: "private/internal/path".into(), + row_count: 99, + duplicate_event_ids: 3, + status: "private-status".into(), + }, + }, + ], + } + } + + fn catalog() -> QueryCatalog { + QueryCatalog { + snapshot_id: "snapshot-a".into(), + read_only: true, + database: "default".into(), + storage_path: "tmp/test/".into(), + path_column: "_file_".into(), + datasets: Vec::new(), + tables: vec![QueryTableSummary { + name: "default.runs".into(), + description: "Recorded agent runs".into(), + grain: "one row per recorded run".into(), + fields: vec![ + QueryFieldSummary { + name: "status".into(), + data_type: "VARCHAR".into(), + description: "Status of each recorded run".into(), + }, + QueryFieldSummary { + name: "run_id".into(), + data_type: "VARCHAR".into(), + description: "Stable identifier for a recorded run".into(), + }, + ], + }], + } + } + + fn scope() -> AnalysisScope { + AnalysisScope { + database: "default".into(), + storage_path: "tmp/test/".into(), + snapshot_id: "snapshot-a".into(), + items: vec![AnalysisScopeItem::Dataset { + name: "default".into(), + }], + } + } + + fn plan() -> AnalysisPlan { + AnalysisPlan { + id: 1, + question: "compare outcomes".into(), + intent_summary: "Compare outcomes".into(), + scope_summary: "current dataset".into(), + filters: Vec::new(), + groupings: vec!["status".into()], + measures: vec!["run count".into()], + expected_columns: vec!["status".into(), "run_count".into()], + suggested_view: SuggestedView::Distribution, + sql: "SELECT status, COUNT(*) AS run_count FROM default.runs GROUP BY status".into(), + warnings: Vec::new(), + } + } + + fn plan_payload(sql: &str) -> String { + json!({ + "intent_summary": "Compare outcomes", + "scope_summary": "current dataset", + "filters": [], + "groupings": ["status"], + "measures": ["run count"], + "expected_columns": ["status", "run_count"], + "suggested_view": "distribution", + "sql": sql, + "warnings": [], + }) + .to_string() + } + + fn digest_with_rows(rows: Vec) -> EvidenceDigest { + EvidenceDigest { + question: "compare outcomes".into(), + scope: scope(), + sql: "SELECT 1".into(), + columns: Vec::new(), + profiles: Vec::new(), + returned_rows: rows.len(), + rows, + query_truncated: false, + max_rows: 100, + max_bytes: 4 * 1024 * 1024, + digest_truncated: false, + } + } + + fn interpretation_with_reference(reference: EvidenceReference) -> AnalysisInterpretation { + AnalysisInterpretation { + observations: Vec::new(), + inferences: Vec::new(), + limitations: Vec::new(), + follow_ups: Vec::new(), + references: vec![reference], + } + } + + fn evidence(rows: Vec, truncated: bool) -> QueryEvidence { + QueryEvidence { + returned_rows: rows.len(), + rows, + truncated, + max_rows: 100, + max_bytes: 4 * 1024 * 1024, + } + } +} diff --git a/pchronicle-web/src/analysis_session.rs b/pchronicle-web/src/analysis_session.rs new file mode 100644 index 00000000..057fa22c --- /dev/null +++ b/pchronicle-web/src/analysis_session.rs @@ -0,0 +1,2024 @@ +#![allow(dead_code)] + +use serde::{Deserialize, Serialize}; +use web_time::{SystemTime, UNIX_EPOCH}; + +use crate::model::{QueryCatalog, QueryEvidence, RunSummary}; +use crate::result_profile::ColumnProfile; + +pub const MAX_ANALYSIS_SESSIONS: usize = 20; +pub const MAX_SESSION_BYTES: usize = 256 * 1024; +pub const STORAGE_PREFIX: &str = "pchronicle_analysis:"; + +pub type AnalysisOperationId = u64; + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(tag = "kind", rename_all = "snake_case")] +pub enum AnalysisScopeItem { + Dataset { + name: String, + }, + Root { + dataset: String, + file: String, + root_session_id: String, + }, + Run { + run: RunSummary, + }, +} + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct AnalysisScope { + pub database: String, + pub storage_path: String, + pub snapshot_id: String, + pub items: Vec, +} + +impl AnalysisScope { + pub fn from_catalog(catalog: &QueryCatalog) -> Self { + Self { + database: catalog.database.clone(), + storage_path: catalog.storage_path.clone(), + snapshot_id: catalog.snapshot_id.clone(), + items: vec![AnalysisScopeItem::Dataset { + name: catalog.database.clone(), + }], + } + } + + pub fn from_root( + catalog: &QueryCatalog, + dataset: impl Into, + file: impl Into, + root_session_id: impl Into, + ) -> Self { + Self { + database: catalog.database.clone(), + storage_path: catalog.storage_path.clone(), + snapshot_id: catalog.snapshot_id.clone(), + items: vec![AnalysisScopeItem::Root { + dataset: dataset.into(), + file: file.into(), + root_session_id: root_session_id.into(), + }], + } + } + + pub fn from_run(catalog: &QueryCatalog, run: RunSummary) -> Self { + Self::from_runs(catalog, vec![run]) + } + + pub fn from_runs(catalog: &QueryCatalog, runs: Vec) -> Self { + Self { + database: catalog.database.clone(), + storage_path: catalog.storage_path.clone(), + snapshot_id: catalog.snapshot_id.clone(), + items: runs + .into_iter() + .map(|run| AnalysisScopeItem::Run { run }) + .collect(), + } + } +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum RevisionState { + Draft, + GeneratingPlan, + PlanReady, + Executing, + Interpreting, + Complete, + PlanError, + QueryError, + InterpretationError, + Stale, +} + +#[derive(Clone, Debug, PartialEq)] +pub enum AnalysisEffect { + ExecuteSql { + revision_id: u64, + operation_id: AnalysisOperationId, + sql: String, + }, + Interpret { + revision_id: u64, + operation_id: AnalysisOperationId, + }, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum SuggestedView { + Table, + Distribution, + Trend, +} + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct AnalysisPlan { + pub id: u64, + pub question: String, + pub intent_summary: String, + pub scope_summary: String, + pub filters: Vec, + pub groupings: Vec, + pub measures: Vec, + pub expected_columns: Vec, + pub suggested_view: SuggestedView, + pub sql: String, + pub warnings: Vec, +} + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct EvidenceReference { + pub label: String, + pub row_index: Option, + pub dataset: Option, + pub file: Option, + pub run_id: Option, + pub agent_id: Option, + pub session_id: Option, + pub root_session_id: Option, + pub turn_id: Option, +} + +#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] +pub struct AnalysisInterpretation { + pub observations: Vec, + pub inferences: Vec, + pub limitations: Vec, + pub follow_ups: Vec, + pub references: Vec, +} + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct ExecutionSummary { + pub returned_rows: usize, + pub truncated: bool, + pub max_rows: usize, + pub max_bytes: usize, + pub executed_at_ms: u64, + pub profiles: Vec, +} + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct AnalysisRevision { + pub id: u64, + pub question: String, + pub scope: AnalysisScope, + pub state: RevisionState, + pub plan: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub prior_plan_context: Option, + pub manually_edited: bool, + pub execution: Option, + pub interpretation: Option, + pub error: Option, + pub created_at_ms: u64, + pub updated_at_ms: u64, + pub needs_rerun: bool, + #[serde(skip)] + pub evidence: Option, + #[serde(skip)] + pub pending_effect: Option, + #[serde(skip)] + pub active_operation_id: Option, + #[serde(skip)] + next_operation_id: AnalysisOperationId, +} + +impl AnalysisRevision { + pub fn draft(id: u64, question: impl Into, scope: AnalysisScope) -> Self { + let now = now_millis(); + Self { + id, + question: question.into(), + scope, + state: RevisionState::Draft, + plan: None, + prior_plan_context: None, + manually_edited: false, + execution: None, + interpretation: None, + error: None, + created_at_ms: now, + updated_at_ms: now, + needs_rerun: false, + evidence: None, + pending_effect: None, + active_operation_id: None, + next_operation_id: 0, + } + } + + pub fn begin_plan_generation(&mut self) -> Result { + match self.state { + RevisionState::Draft | RevisionState::PlanError | RevisionState::Stale => { + self.state = RevisionState::GeneratingPlan; + self.error = None; + self.pending_effect = None; + self.touch(); + Ok(self.begin_operation()) + } + _ => Err( + "A plan can only be generated from a draft, plan error, or stale revision.".into(), + ), + } + } + + pub fn finish_plan( + &mut self, + revision_id: u64, + operation_id: AnalysisOperationId, + plan: AnalysisPlan, + ) -> Result, String> { + if !self.accepts(revision_id, operation_id) { + return Ok(None); + } + if self.state != RevisionState::GeneratingPlan { + return Err("This revision is not waiting for a generated plan.".into()); + } + self.plan = Some(plan); + self.state = RevisionState::PlanReady; + self.error = None; + self.pending_effect = None; + self.active_operation_id = None; + self.touch(); + Ok(None) + } + + pub fn confirm_execution(&mut self) -> Result<(), String> { + if !matches!( + self.state, + RevisionState::PlanReady | RevisionState::QueryError + ) { + return Err("Review a ready plan before running this analysis.".into()); + } + let Some(plan) = self.plan.as_ref() else { + return Err("A plan is required before running this analysis.".into()); + }; + let sql = plan.sql.clone(); + self.execution = None; + self.evidence = None; + self.interpretation = None; + self.needs_rerun = false; + let operation_id = self.begin_operation(); + self.state = RevisionState::Executing; + self.error = None; + self.pending_effect = Some(AnalysisEffect::ExecuteSql { + revision_id: self.id, + operation_id, + sql, + }); + self.touch(); + Ok(()) + } + + pub fn finish_query( + &mut self, + revision_id: u64, + operation_id: AnalysisOperationId, + evidence: QueryEvidence, + profiles: Vec, + ) -> Result, String> { + if !self.accepts(revision_id, operation_id) { + return Ok(None); + } + if self.state != RevisionState::Executing { + return Err("This revision is not waiting for query results.".into()); + } + let has_rows = !evidence.rows.is_empty(); + self.execution = Some(ExecutionSummary { + returned_rows: evidence.returned_rows, + truncated: evidence.truncated, + max_rows: evidence.max_rows, + max_bytes: evidence.max_bytes, + executed_at_ms: now_millis(), + profiles, + }); + self.evidence = Some(evidence); + self.interpretation = None; + self.error = None; + self.needs_rerun = false; + let effect = has_rows.then(|| AnalysisEffect::Interpret { + revision_id: self.id, + operation_id: self.begin_operation(), + }); + self.pending_effect = effect.clone(); + self.state = if effect.is_some() { + RevisionState::Interpreting + } else { + self.active_operation_id = None; + RevisionState::Complete + }; + self.touch(); + Ok(effect) + } + + pub fn finish_interpretation( + &mut self, + revision_id: u64, + operation_id: AnalysisOperationId, + interpretation: AnalysisInterpretation, + ) -> Result, String> { + if !self.accepts(revision_id, operation_id) { + return Ok(None); + } + if self.state != RevisionState::Interpreting { + return Err("This revision is not waiting for an interpretation.".into()); + } + self.interpretation = Some(interpretation); + self.state = RevisionState::Complete; + self.error = None; + self.pending_effect = None; + self.active_operation_id = None; + self.touch(); + Ok(None) + } + + pub fn fail_plan( + &mut self, + revision_id: u64, + operation_id: AnalysisOperationId, + error: impl Into, + ) -> Result, String> { + self.fail( + revision_id, + operation_id, + RevisionState::GeneratingPlan, + RevisionState::PlanError, + error, + ) + } + + pub fn fail_query( + &mut self, + revision_id: u64, + operation_id: AnalysisOperationId, + error: impl Into, + ) -> Result, String> { + self.fail( + revision_id, + operation_id, + RevisionState::Executing, + RevisionState::QueryError, + error, + ) + } + + pub fn fail_interpretation( + &mut self, + revision_id: u64, + operation_id: AnalysisOperationId, + error: impl Into, + ) -> Result, String> { + self.fail( + revision_id, + operation_id, + RevisionState::Interpreting, + RevisionState::InterpretationError, + error, + ) + } + + pub fn take_pending_effect(&mut self) -> Option { + self.pending_effect.take() + } + + pub fn retry_interpretation(&mut self) -> Result { + if self.state != RevisionState::InterpretationError || self.evidence.is_none() { + return Err("Interpretation can only be retried after an interpretation error.".into()); + } + let operation_id = self.begin_operation(); + self.state = RevisionState::Interpreting; + self.error = None; + let effect = AnalysisEffect::Interpret { + revision_id: self.id, + operation_id, + }; + self.pending_effect = Some(effect.clone()); + self.touch(); + Ok(effect) + } + + fn accepts(&self, revision_id: u64, operation_id: AnalysisOperationId) -> bool { + self.id == revision_id && self.active_operation_id == Some(operation_id) + } + + fn fail( + &mut self, + revision_id: u64, + operation_id: AnalysisOperationId, + expected: RevisionState, + failed: RevisionState, + error: impl Into, + ) -> Result, String> { + if !self.accepts(revision_id, operation_id) { + return Ok(None); + } + if self.state != expected { + return Err("This revision is no longer waiting for that result.".into()); + } + self.state = failed; + self.error = Some(error.into()); + self.pending_effect = None; + self.active_operation_id = None; + self.touch(); + Ok(None) + } + + fn touch(&mut self) { + self.updated_at_ms = now_millis(); + } + + fn begin_operation(&mut self) -> AnalysisOperationId { + self.next_operation_id = self.next_operation_id.saturating_add(1); + self.active_operation_id = Some(self.next_operation_id); + self.next_operation_id + } +} + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct AnalysisSession { + pub id: String, + pub title: String, + pub storage_fingerprint: String, + pub revisions: Vec, + pub active_revision_id: u64, + pub created_at_ms: u64, + pub updated_at_ms: u64, +} + +impl AnalysisSession { + pub fn with_revision(revision: AnalysisRevision) -> Self { + let now = now_millis(); + let storage_fingerprint = + storage_fingerprint(&revision.scope.database, &revision.scope.storage_path); + Self { + id: format!("analysis-{}", now_nanos()), + title: revision.question.clone(), + storage_fingerprint, + active_revision_id: revision.id, + revisions: vec![revision], + created_at_ms: now, + updated_at_ms: now, + } + } + + pub fn new_revision( + &mut self, + question: impl Into, + scope: AnalysisScope, + ) -> &mut AnalysisRevision { + let id = self + .revisions + .iter() + .map(|revision| revision.id) + .max() + .unwrap_or(0) + .saturating_add(1); + self.revisions + .push(AnalysisRevision::draft(id, question, scope)); + self.active_revision_id = id; + self.updated_at_ms = now_millis(); + self.revisions + .last_mut() + .expect("a revision was just pushed") + } + + pub fn new_follow_up( + &mut self, + question: impl Into, + ) -> Result<&mut AnalysisRevision, String> { + let question = question.into(); + if question.trim().is_empty() { + return Err("A follow-up question is required.".into()); + } + let (scope, prior_plan_context) = self + .revisions + .iter() + .find(|revision| revision.id == self.active_revision_id) + .map(|revision| (revision.scope.clone(), revision.plan.clone())) + .ok_or_else(|| "The active analysis revision is unavailable.".to_string())?; + let revision = self.new_revision(question, scope); + revision.prior_plan_context = prior_plan_context; + Ok(revision) + } + + pub fn active_revision_mut(&mut self) -> Option<&mut AnalysisRevision> { + self.revisions + .iter_mut() + .find(|revision| revision.id == self.active_revision_id) + } + + pub fn active_revision(&self) -> Option<&AnalysisRevision> { + self.revisions + .iter() + .find(|revision| revision.id == self.active_revision_id) + } + + pub fn reconcile_catalog(&mut self, snapshot_id: &str) { + let mut changed = false; + for revision in &mut self.revisions { + if revision.scope.snapshot_id == snapshot_id { + continue; + } + if revision.execution.is_none() + && revision.plan.is_some() + && matches!( + revision.state, + RevisionState::PlanReady | RevisionState::QueryError + ) + { + revision.state = RevisionState::Stale; + revision.error = None; + revision.pending_effect = None; + revision.active_operation_id = None; + revision.touch(); + changed = true; + } + } + if changed { + self.updated_at_ms = now_millis(); + } + } + + pub fn apply_working_scope_change( + &mut self, + question: impl Into, + next_scope: AnalysisScope, + ) -> Result { + let (active_revision_id, state, was_executed, prior_plan_context) = self + .active_revision() + .map(|revision| { + ( + revision.id, + revision.state.clone(), + revision.execution.is_some(), + revision.plan.clone(), + ) + }) + .ok_or_else(|| "The active analysis revision is unavailable.".to_string())?; + + if matches!( + &state, + RevisionState::GeneratingPlan | RevisionState::Executing + ) { + return Err("Analysis scope cannot change while an operation is running.".into()); + } + + if was_executed { + let revision = self.new_revision(question, next_scope); + revision.prior_plan_context = prior_plan_context; + return Ok(revision.id); + } + + let next_state = match state { + RevisionState::Draft | RevisionState::PlanError => RevisionState::Draft, + RevisionState::PlanReady | RevisionState::QueryError | RevisionState::Stale => { + if prior_plan_context.is_some() { + RevisionState::Stale + } else { + RevisionState::Draft + } + } + _ => return Err("Analysis scope cannot change in this revision state.".into()), + }; + + let revision = self + .active_revision_mut() + .ok_or_else(|| "The active analysis revision is unavailable.".to_string())?; + revision.scope = next_scope; + revision.state = next_state; + revision.error = None; + revision.needs_rerun = false; + revision.pending_effect = None; + revision.active_operation_id = None; + revision.touch(); + self.updated_at_ms = now_millis(); + Ok(active_revision_id) + } + + pub fn normalize_inflight_for_navigation(&mut self) { + let mut changed = false; + for revision in &mut self.revisions { + let normalized = match revision.state { + RevisionState::GeneratingPlan => { + revision.state = RevisionState::Draft; + revision.error = None; + true + } + RevisionState::Executing => { + revision.state = RevisionState::PlanReady; + revision.error = None; + revision.needs_rerun = true; + true + } + RevisionState::Interpreting => { + if revision.evidence.is_some() { + revision.state = RevisionState::InterpretationError; + revision.error = Some( + "Interpretation was interrupted when this analysis was left.".into(), + ); + } else { + revision.state = RevisionState::QueryError; + revision.error = None; + revision.needs_rerun = true; + } + true + } + _ => false, + }; + if normalized { + revision.pending_effect = None; + revision.active_operation_id = None; + revision.touch(); + changed = true; + } + } + if changed { + self.updated_at_ms = now_millis(); + } + } + + pub fn select_revision(&mut self, revision_id: u64) -> Result<(), String> { + if !self + .revisions + .iter() + .any(|revision| revision.id == revision_id) + { + return Err("The selected analysis revision is unavailable.".into()); + } + self.active_revision_id = revision_id; + self.updated_at_ms = now_millis(); + Ok(()) + } + + pub fn mark_updated(&mut self) { + self.updated_at_ms = now_millis(); + } + + pub fn persisted_bytes(&self) -> Result, String> { + let mut persisted = self.clone(); + prepare_for_storage(&mut persisted); + fit_session_budget(&mut persisted)?; + serde_json::to_vec(&persisted) + .map_err(|error| format!("Could not prepare the analysis session for storage: {error}")) + } +} + +pub fn trim_sessions(sessions: &mut Vec) { + sessions.sort_by(|left, right| { + right + .updated_at_ms + .cmp(&left.updated_at_ms) + .then_with(|| right.created_at_ms.cmp(&left.created_at_ms)) + }); + sessions.truncate(MAX_ANALYSIS_SESSIONS); +} + +pub fn storage_fingerprint(database: &str, storage_path: &str) -> String { + let mut hash = 0xcbf29ce484222325_u64; + for byte in database + .as_bytes() + .iter() + .copied() + .chain(std::iter::once(0)) + .chain(storage_path.as_bytes().iter().copied()) + { + hash ^= u64::from(byte); + hash = hash.wrapping_mul(0x100000001b3); + } + format!("{hash:016x}") +} + +pub fn load_sessions(storage_fingerprint: &str) -> Result, String> { + let storage = local_storage()?; + let raw = storage + .get_item(&storage_key(storage_fingerprint)) + .map_err(|_| { + "Could not restore local analysis sessions from browser storage.".to_string() + })?; + let Some(raw) = raw else { + return Ok(Vec::new()); + }; + let mut sessions: Vec = serde_json::from_str(&raw).map_err(|_| { + "Could not restore local analysis sessions because the saved data is invalid.".to_string() + })?; + for session in &mut sessions { + for revision in &mut session.revisions { + normalize_persisted_revision(revision); + } + } + trim_sessions(&mut sessions); + Ok(sessions) +} + +pub fn save_sessions( + storage_fingerprint: &str, + sessions: &[AnalysisSession], +) -> Result<(), String> { + let storage = local_storage()?; + let raw = serialized_sessions(sessions)?; + storage + .set_item(&storage_key(storage_fingerprint), &raw) + .map_err(|_| { + "Could not save local analysis sessions; they will not survive a refresh.".to_string() + }) +} + +pub fn clear_sessions(storage_fingerprint: &str) -> Result<(), String> { + local_storage()? + .remove_item(&storage_key(storage_fingerprint)) + .map_err(|_| "Could not clear local analysis sessions from browser storage.".to_string()) +} + +pub fn restore_session(raw: &str) -> Result { + let mut session: AnalysisSession = serde_json::from_str(raw).map_err(|_| { + "Could not restore the local analysis session because the saved data is invalid." + .to_string() + })?; + prepare_for_storage(&mut session); + Ok(session) +} + +pub fn analysis_href(scope: &AnalysisScope) -> String { + let encoded = serde_json::to_string(scope).expect("analysis scopes are serializable"); + format!( + "?page=tools&analysis_scope={}", + urlencoding::encode(&encoded) + ) +} + +pub fn scope_from_query(query: &str) -> Result { + let query = query.strip_prefix('?').unwrap_or(query); + let encoded = query + .split('&') + .find_map(|parameter| { + parameter + .split_once('=') + .filter(|(key, _)| *key == "analysis_scope") + .map(|(_, value)| value) + }) + .ok_or_else(|| "The Analyze link has no scope.".to_string())?; + let decoded = urlencoding::decode(encoded) + .map_err(|_| "The Analyze link has an invalid scope.".to_string())?; + let scope: AnalysisScope = serde_json::from_str(&decoded) + .map_err(|_| "The Analyze link has an invalid scope.".to_string())?; + if scope.database.trim().is_empty() + || scope.storage_path.trim().is_empty() + || scope.items.is_empty() + || scope.items.iter().any(|item| match item { + AnalysisScopeItem::Dataset { name } => name.trim().is_empty(), + AnalysisScopeItem::Root { + dataset, + file, + root_session_id, + } => { + dataset.trim().is_empty() + || file.trim().is_empty() + || root_session_id.trim().is_empty() + } + AnalysisScopeItem::Run { run } => { + run.dataset.trim().is_empty() + || run.file.trim().is_empty() + || run.agent_id.trim().is_empty() + || run.session_id.trim().is_empty() + } + }) + { + return Err("The Analyze link has an incomplete scope.".into()); + } + Ok(scope) +} + +fn serialized_sessions(sessions: &[AnalysisSession]) -> Result { + let mut persisted = sessions.to_vec(); + trim_sessions(&mut persisted); + for session in &mut persisted { + prepare_for_storage(session); + fit_session_budget(session)?; + } + serde_json::to_string(&persisted) + .map_err(|error| format!("Could not prepare analysis sessions for storage: {error}")) +} + +fn prepare_for_storage(session: &mut AnalysisSession) { + for revision in &mut session.revisions { + normalize_persisted_revision(revision); + } +} + +fn normalize_persisted_revision(revision: &mut AnalysisRevision) { + revision.evidence = None; + revision.pending_effect = None; + revision.active_operation_id = None; + revision.next_operation_id = 0; + + let was_in_flight = matches!( + revision.state, + RevisionState::GeneratingPlan | RevisionState::Executing | RevisionState::Interpreting + ); + revision.state = match &revision.state { + RevisionState::GeneratingPlan => RevisionState::Draft, + RevisionState::Executing => RevisionState::PlanReady, + RevisionState::Interpreting + | RevisionState::InterpretationError + | RevisionState::Complete + if revision.execution.is_some() => + { + RevisionState::QueryError + } + state => state.clone(), + }; + if was_in_flight || revision.execution.is_some() { + revision.needs_rerun = true; + } +} + +fn fit_session_budget(session: &mut AnalysisSession) -> Result<(), String> { + compact_session(session); + while serde_json::to_vec(&*session) + .map_err(|error| format!("Could not prepare the analysis session for storage: {error}"))? + .len() + > MAX_SESSION_BYTES + { + if discard_oldest_derived_data(session) { + continue; + } + if session.revisions.len() <= 1 { + return Err( + "Analysis session exceeds the local storage budget and could not be compacted." + .into(), + ); + } + session.revisions.remove(0); + session.active_revision_id = session + .revisions + .last() + .map(|revision| revision.id) + .unwrap_or_default(); + } + Ok(()) +} + +fn discard_oldest_derived_data(session: &mut AnalysisSession) -> bool { + for revision in &mut session.revisions { + if let Some(execution) = &mut revision.execution { + if !execution.profiles.is_empty() { + execution.profiles.clear(); + return true; + } + } + if revision.interpretation.take().is_some() { + return true; + } + } + false +} + +fn compact_session(session: &mut AnalysisSession) { + truncate_text(&mut session.id, 256); + truncate_text(&mut session.title, 4 * 1024); + truncate_text(&mut session.storage_fingerprint, 4 * 1024); + for revision in &mut session.revisions { + truncate_text(&mut revision.question, 8 * 1024); + truncate_text(&mut revision.scope.database, 1024); + truncate_text(&mut revision.scope.storage_path, 4 * 1024); + truncate_text(&mut revision.scope.snapshot_id, 1024); + revision.scope.items.truncate(64); + for item in &mut revision.scope.items { + match item { + AnalysisScopeItem::Dataset { name } => truncate_text(name, 1024), + AnalysisScopeItem::Root { + dataset, + file, + root_session_id, + } => { + truncate_text(dataset, 1024); + truncate_text(file, 4 * 1024); + truncate_text(root_session_id, 1024); + } + AnalysisScopeItem::Run { run } => compact_run(run), + } + } + if let Some(plan) = &mut revision.plan { + compact_plan(plan); + } + if let Some(plan) = &mut revision.prior_plan_context { + compact_plan(plan); + } + if let Some(error) = &mut revision.error { + truncate_text(error, 4 * 1024); + } + } +} + +fn compact_run(run: &mut RunSummary) { + truncate_text(&mut run.dataset, 1024); + truncate_text(&mut run.file, 4 * 1024); + if let Some(run_id) = &mut run.run_id { + truncate_text(run_id, 1024); + } + truncate_text(&mut run.agent_id, 1024); + if let Some(model_name) = &mut run.model_name { + truncate_text(model_name, 1024); + } + truncate_text(&mut run.session_id, 1024); + if let Some(root_session_id) = &mut run.root_session_id { + truncate_text(root_session_id, 1024); + } + truncate_text(&mut run.path, 4 * 1024); + truncate_text(&mut run.status, 1024); +} + +fn compact_plan(plan: &mut AnalysisPlan) { + for text in [ + &mut plan.question, + &mut plan.intent_summary, + &mut plan.scope_summary, + &mut plan.sql, + ] { + truncate_text(text, 8 * 1024); + } + for values in [ + &mut plan.filters, + &mut plan.groupings, + &mut plan.measures, + &mut plan.expected_columns, + &mut plan.warnings, + ] { + values.truncate(64); + for value in values { + truncate_text(value, 1024); + } + } +} + +fn truncate_text(value: &mut String, max_chars: usize) { + let Some((index, _)) = value.char_indices().nth(max_chars) else { + return; + }; + value.truncate(index); +} + +fn storage_key(storage_fingerprint: &str) -> String { + format!("{STORAGE_PREFIX}{storage_fingerprint}") +} + +fn local_storage() -> Result { + let window = web_sys::window() + .ok_or_else(|| "Local analysis sessions are unavailable outside a browser.".to_string())?; + window + .local_storage() + .map_err(|_| "Could not access browser storage for analysis sessions.".to_string())? + .ok_or_else(|| "Browser storage is unavailable for analysis sessions.".to_string()) +} + +fn now_millis() -> u64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|duration| duration.as_millis().min(u64::MAX as u128) as u64) + .unwrap_or(1) + .max(1) +} + +fn now_nanos() -> u128 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|duration| duration.as_nanos()) + .unwrap_or(1) + .max(1) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::model::QueryEvidence; + + #[test] + fn generated_plan_waits_for_explicit_execution() { + let mut revision = AnalysisRevision::draft(1, "compare failures", scope()); + let plan_operation = revision.begin_plan_generation().unwrap(); + revision.finish_plan(1, plan_operation, plan()).unwrap(); + assert_eq!(revision.state, RevisionState::PlanReady); + assert!(revision.pending_effect.is_none()); + + revision.confirm_execution().unwrap(); + assert_eq!( + revision.pending_effect, + Some(AnalysisEffect::ExecuteSql { + revision_id: 1, + operation_id: 2, + sql: "SELECT status, COUNT(*) FROM default.runs GROUP BY status".into(), + }) + ); + } + + #[test] + fn query_result_rows_are_not_persisted() { + let mut revision = AnalysisRevision::draft(1, "question", scope()); + revision.evidence = Some(QueryEvidence { + rows: vec![serde_json::json!({"secret-row":"not persisted"})], + returned_rows: 1, + truncated: false, + max_rows: 100, + max_bytes: 4 * 1024 * 1024, + }); + let encoded = serde_json::to_string(&AnalysisSession::with_revision(revision)).unwrap(); + assert!(!encoded.contains("secret-row")); + } + + #[test] + fn completed_interpretation_summary_persists_without_query_rows() { + let session = complete_session(); + + let restored: AnalysisSession = + serde_json::from_slice(&session.persisted_bytes().unwrap()).unwrap(); + let revision = restored.revisions.first().unwrap(); + + assert!(revision.evidence.is_none()); + assert_eq!( + revision.interpretation.as_ref().unwrap().observations, + vec!["One failed row was returned."] + ); + } + + #[test] + fn empty_query_result_skips_interpretation() { + let (mut revision, query_operation) = executing_revision(); + let effect = revision + .finish_query(1, query_operation, empty_evidence(), Vec::new()) + .unwrap(); + assert_eq!(revision.state, RevisionState::Complete); + assert_eq!(effect, None); + } + + #[test] + fn query_rows_become_visible_before_interpretation_finishes() { + let (mut revision, query_operation) = executing_revision(); + let evidence = evidence_with_rows(); + + let effect = revision + .finish_query(1, query_operation, evidence.clone(), Vec::new()) + .unwrap(); + + assert_eq!(revision.evidence, Some(evidence)); + assert_eq!(revision.state, RevisionState::Interpreting); + assert!(matches!( + effect, + Some(AnalysisEffect::Interpret { revision_id: 1, .. }) + )); + } + + #[test] + fn interpretation_failure_keeps_query_evidence() { + let (mut revision, interpretation_operation) = interpreting_revision(); + let evidence = revision.evidence.clone(); + + revision + .fail_interpretation(1, interpretation_operation, "provider unavailable") + .unwrap(); + + assert_eq!(revision.state, RevisionState::InterpretationError); + assert_eq!(revision.evidence, evidence); + assert!(revision.execution.is_some()); + } + + #[test] + fn follow_up_creates_a_new_unexecuted_revision() { + let mut session = complete_session(); + + let next = session.new_follow_up("only failed runs").unwrap(); + + assert_eq!(next.question, "only failed runs"); + assert_eq!(next.state, RevisionState::Draft); + assert!(next.evidence.is_none()); + assert!(next.execution.is_none()); + assert!(next.plan.is_none()); + assert!(next.interpretation.is_none()); + assert_eq!(next.scope, scope()); + assert_eq!(next.prior_plan_context, Some(plan())); + } + + #[test] + fn stale_async_result_is_ignored_without_changing_state() { + let mut revision = AnalysisRevision::draft(1, "compare failures", scope()); + let plan_operation = revision.begin_plan_generation().unwrap(); + + let effect = revision.finish_plan(2, plan_operation, plan()).unwrap(); + + assert_eq!(effect, None); + assert_eq!(revision.state, RevisionState::GeneratingPlan); + assert!(revision.plan.is_none()); + } + + #[test] + fn query_error_requires_explicit_retry() { + let (mut revision, query_operation) = executing_revision(); + revision + .fail_query(1, query_operation, "query timed out") + .unwrap(); + assert_eq!(revision.state, RevisionState::QueryError); + assert!(revision.take_pending_effect().is_none()); + + revision.confirm_execution().unwrap(); + + assert_eq!(revision.state, RevisionState::Executing); + assert_eq!( + revision.take_pending_effect(), + Some(AnalysisEffect::ExecuteSql { + revision_id: 1, + operation_id: query_operation + 1, + sql: "SELECT status, COUNT(*) FROM default.runs GROUP BY status".into(), + }) + ); + } + + #[test] + fn rerun_failure_cannot_reveal_a_previous_interpretation() { + let raw = serde_json::to_string(&complete_session()).unwrap(); + let mut restored = restore_session(&raw).unwrap(); + let revision = restored.active_revision_mut().unwrap(); + assert!(revision.execution.is_some()); + assert!(revision.interpretation.is_some()); + assert!(revision.needs_rerun); + + revision.confirm_execution().unwrap(); + let (revision_id, operation_id) = match revision.take_pending_effect().unwrap() { + AnalysisEffect::ExecuteSql { + revision_id, + operation_id, + .. + } => (revision_id, operation_id), + effect => panic!("expected execute effect, got {effect:?}"), + }; + revision + .fail_query(revision_id, operation_id, "rerun failed") + .unwrap(); + + assert_eq!(revision.state, RevisionState::QueryError); + assert!(revision.execution.is_none()); + assert!(revision.evidence.is_none()); + assert!(revision.interpretation.is_none()); + assert!(!revision.needs_rerun); + } + + #[test] + fn new_query_evidence_defensively_discards_an_old_interpretation() { + let (mut revision, query_operation) = executing_revision(); + revision.interpretation = Some(AnalysisInterpretation { + observations: vec!["old conclusion".into()], + ..AnalysisInterpretation::default() + }); + + revision + .finish_query(1, query_operation, evidence_with_rows(), Vec::new()) + .unwrap(); + + assert!(revision.interpretation.is_none()); + assert_eq!(revision.state, RevisionState::Interpreting); + } + + #[test] + fn analysis_href_round_trips_dataset_root_run_and_multi_run_scopes() { + let scopes = vec![ + scope(), + AnalysisScope { + database: "default".into(), + storage_path: "tmp/test/".into(), + snapshot_id: "snapshot-a".into(), + items: vec![AnalysisScopeItem::Root { + dataset: "default".into(), + file: "source.json".into(), + root_session_id: "root-a".into(), + }], + }, + AnalysisScope { + database: "default".into(), + storage_path: "tmp/test/".into(), + snapshot_id: "snapshot-a".into(), + items: vec![AnalysisScopeItem::Run { run: run("one") }], + }, + AnalysisScope { + database: "default".into(), + storage_path: "tmp/test/".into(), + snapshot_id: "snapshot-a".into(), + items: vec![ + AnalysisScopeItem::Run { run: run("one") }, + AnalysisScopeItem::Run { run: run("two") }, + ], + }, + ]; + + for scope in scopes { + assert_eq!(scope_from_query(&analysis_href(&scope)).unwrap(), scope); + } + } + + #[test] + fn multi_run_scope_round_trips_through_analyze_url() { + let scope = AnalysisScope::from_runs(&catalog(), vec![run("left"), run("right")]); + let href = analysis_href(&scope); + let decoded = scope_from_query(href.split_once('?').unwrap().1).unwrap(); + + assert_eq!(decoded.items, scope.items); + } + + #[test] + fn analyze_url_rejects_incomplete_scope_coordinates() { + let incomplete = AnalysisScope { + items: vec![AnalysisScopeItem::Root { + dataset: "default".into(), + file: String::new(), + root_session_id: "root-a".into(), + }], + ..scope() + }; + + assert!(scope_from_query(&analysis_href(&incomplete)).is_err()); + } + + #[test] + fn restored_session_has_summaries_but_requires_rows_to_be_rerun() { + let mut restored = + restore_session(&serde_json::to_string(&complete_session()).unwrap()).unwrap(); + let revision = restored.active_revision().unwrap(); + + assert!(revision.evidence.is_none()); + assert!(revision.execution.is_some()); + assert!(revision.interpretation.is_some()); + assert!(revision.needs_rerun); + assert_eq!(revision.state, RevisionState::QueryError); + assert!(restored + .active_revision_mut() + .unwrap() + .confirm_execution() + .is_ok()); + } + + #[test] + fn catalog_snapshot_change_marks_unexecuted_plan_stale() { + let mut session = plan_ready_session("snapshot-a"); + + session.reconcile_catalog("snapshot-b"); + + assert_eq!( + session.active_revision().unwrap().state, + RevisionState::Stale + ); + } + + #[test] + fn scope_change_marks_only_unexecuted_review_stale() { + for state in [RevisionState::PlanReady, RevisionState::QueryError] { + let mut unexecuted = plan_ready_session("snapshot-a"); + unexecuted.active_revision_mut().unwrap().state = state; + let active_revision_id = unexecuted.active_revision_id; + let reviewed_scope = unexecuted.active_revision().unwrap().scope.clone(); + let next_scope = AnalysisScope { + items: vec![AnalysisScopeItem::Dataset { + name: "secondary".into(), + }], + ..reviewed_scope.clone() + }; + + let returned_revision_id = unexecuted + .apply_working_scope_change("compare failures", next_scope.clone()) + .unwrap(); + + assert_eq!(returned_revision_id, active_revision_id); + assert_eq!( + unexecuted.active_revision().unwrap().state, + RevisionState::Stale + ); + assert_eq!(unexecuted.active_revision().unwrap().scope, next_scope); + } + } + + #[test] + fn draft_and_plan_error_scope_changes_persist_as_editable_drafts() { + let next_scope = AnalysisScope { + items: vec![AnalysisScopeItem::Dataset { + name: "secondary".into(), + }], + ..scope() + }; + let draft = AnalysisRevision::draft(1, "draft question", scope()); + let mut failed = AnalysisRevision::draft(1, "failed question", scope()); + let operation_id = failed.begin_plan_generation().unwrap(); + failed + .fail_plan(1, operation_id, "provider unavailable") + .unwrap(); + + for revision in [draft, failed] { + let mut session = AnalysisSession::with_revision(revision); + session + .apply_working_scope_change("working question", next_scope.clone()) + .unwrap(); + + let changed = session.active_revision().unwrap(); + assert_eq!(changed.scope, next_scope); + assert_eq!(changed.state, RevisionState::Draft); + assert!(changed.error.is_none()); + assert_eq!(session.revisions.len(), 1); + } + } + + #[test] + fn reviewed_unexecuted_scope_changes_persist_and_become_stale() { + for state in [ + RevisionState::PlanReady, + RevisionState::QueryError, + RevisionState::Stale, + ] { + let mut session = plan_ready_session("snapshot-a"); + let revision = session.active_revision_mut().unwrap(); + revision.state = state; + revision.error = Some("old state error".into()); + let next_scope = AnalysisScope { + items: vec![AnalysisScopeItem::Dataset { + name: "secondary".into(), + }], + ..scope() + }; + + session + .apply_working_scope_change("compare failures", next_scope.clone()) + .unwrap(); + + let changed = session.active_revision().unwrap(); + assert_eq!(changed.scope, next_scope); + assert_eq!(changed.state, RevisionState::Stale); + assert!(changed.error.is_none()); + assert_eq!(session.revisions.len(), 1); + } + } + + #[test] + fn changed_draft_scope_survives_refresh_and_revision_selection() { + let mut session = + AnalysisSession::with_revision(AnalysisRevision::draft(1, "draft question", scope())); + let next_scope = AnalysisScope { + items: vec![AnalysisScopeItem::Dataset { + name: "secondary".into(), + }], + ..scope() + }; + session + .apply_working_scope_change("draft question", next_scope.clone()) + .unwrap(); + let changed_revision_id = session.active_revision_id; + session.new_revision("another question", scope()); + + let mut restored: AnalysisSession = + serde_json::from_slice(&session.persisted_bytes().unwrap()).unwrap(); + restored.select_revision(changed_revision_id).unwrap(); + + assert_eq!(restored.active_revision().unwrap().scope, next_scope); + assert_eq!( + restored.active_revision().unwrap().state, + RevisionState::Draft + ); + } + + #[test] + fn executed_scope_change_creates_a_draft_and_preserves_the_old_snapshot() { + let mut session = complete_session(); + let old_revision_id = session.active_revision_id; + let old_scope = session.active_revision().unwrap().scope.clone(); + let old_plan = session.active_revision().unwrap().plan.clone(); + let next_scope = AnalysisScope { + items: vec![AnalysisScopeItem::Dataset { + name: "secondary".into(), + }], + ..old_scope.clone() + }; + + let next_revision_id = session + .apply_working_scope_change("Compare the new scope", next_scope.clone()) + .unwrap(); + + assert_ne!(next_revision_id, old_revision_id); + let next = session.active_revision().unwrap(); + assert_eq!(next.state, RevisionState::Draft); + assert_eq!(next.question, "Compare the new scope"); + assert_eq!(next.scope, next_scope); + assert_eq!(next.prior_plan_context, old_plan); + let old = session + .revisions + .iter() + .find(|revision| revision.id == old_revision_id) + .unwrap(); + assert_eq!(old.state, RevisionState::Complete); + assert_eq!(old.scope, old_scope); + assert!(old.execution.is_some()); + } + + #[test] + fn scope_change_is_rejected_while_plan_or_query_generation_is_in_flight() { + let next_scope = AnalysisScope { + items: vec![AnalysisScopeItem::Dataset { + name: "secondary".into(), + }], + ..scope() + }; + let mut planning = + AnalysisSession::with_revision(AnalysisRevision::draft(1, "planning", scope())); + planning + .active_revision_mut() + .unwrap() + .begin_plan_generation() + .unwrap(); + + assert!(planning + .apply_working_scope_change("planning", next_scope.clone()) + .is_err()); + assert_eq!( + planning.active_revision().unwrap().state, + RevisionState::GeneratingPlan + ); + + let (executing, _) = executing_revision(); + let mut querying = AnalysisSession::with_revision(executing); + + assert!(querying + .apply_working_scope_change("executing", next_scope) + .is_err()); + assert_eq!( + querying.active_revision().unwrap().state, + RevisionState::Executing + ); + } + + #[test] + fn restored_query_error_scope_change_also_preserves_the_executed_snapshot() { + let raw = serde_json::to_string(&complete_session()).unwrap(); + let mut session = restore_session(&raw).unwrap(); + let old_revision_id = session.active_revision_id; + assert_eq!( + session.active_revision().unwrap().state, + RevisionState::QueryError + ); + assert!(session.active_revision().unwrap().execution.is_some()); + let next_scope = AnalysisScope { + items: vec![AnalysisScopeItem::Dataset { + name: "secondary".into(), + }], + ..scope() + }; + + session + .apply_working_scope_change("retry with less scope", next_scope.clone()) + .unwrap(); + + assert_eq!( + session.active_revision().unwrap().state, + RevisionState::Draft + ); + assert_eq!(session.active_revision().unwrap().scope, next_scope); + let old = session + .revisions + .iter() + .find(|revision| revision.id == old_revision_id) + .unwrap(); + assert_eq!(old.state, RevisionState::QueryError); + assert!(old.execution.is_some()); + assert!(old.interpretation.is_some()); + } + + #[test] + fn leaving_a_session_normalizes_every_inflight_revision_for_retry() { + let mut session = + AnalysisSession::with_revision(AnalysisRevision::draft(1, "planning", scope())); + session + .active_revision_mut() + .unwrap() + .begin_plan_generation() + .unwrap(); + + let mut executing = AnalysisRevision::draft(2, "executing", scope()); + let plan_operation = executing.begin_plan_generation().unwrap(); + executing.finish_plan(2, plan_operation, plan()).unwrap(); + executing.confirm_execution().unwrap(); + session.revisions.push(executing); + + let (mut interpreting, query_operation) = executing_revision(); + interpreting.id = 3; + if let Some(AnalysisEffect::ExecuteSql { revision_id, .. }) = + interpreting.pending_effect.as_mut() + { + *revision_id = 3; + } + interpreting + .finish_query(3, query_operation, evidence_with_rows(), Vec::new()) + .unwrap(); + session.revisions.push(interpreting); + + session.normalize_inflight_for_navigation(); + + assert_eq!(session.revisions[0].state, RevisionState::Draft); + assert_eq!(session.revisions[1].state, RevisionState::PlanReady); + assert!(session.revisions[1].needs_rerun); + assert_eq!( + session.revisions[2].state, + RevisionState::InterpretationError + ); + assert!(session.revisions[2].evidence.is_some()); + for revision in &session.revisions { + assert!(revision.active_operation_id.is_none()); + assert!(revision.pending_effect.is_none()); + } + } + + #[test] + fn storage_fingerprint_partitions_database_and_storage_path() { + let baseline = storage_fingerprint("default", "tmp/test/"); + + assert_eq!( + AnalysisSession::with_revision(AnalysisRevision::draft(1, "question", scope())) + .storage_fingerprint, + baseline + ); + assert_ne!(baseline, storage_fingerprint("other", "tmp/test/")); + assert_ne!(baseline, storage_fingerprint("default", "tmp/other/")); + } + + #[test] + fn catalog_reconciliation_preserves_executed_revision_snapshot() { + let mut session = complete_session(); + + session.reconcile_catalog("snapshot-b"); + + let revision = session.active_revision().unwrap(); + assert_eq!(revision.state, RevisionState::Complete); + assert_eq!(revision.scope.snapshot_id, "snapshot-a"); + assert!(revision.execution.is_some()); + } + + #[test] + fn selecting_history_changes_only_the_active_revision() { + let mut session = complete_session(); + let first_revision_id = session.active_revision_id; + let second_revision_id = session + .new_follow_up("compare only explicit failures") + .unwrap() + .id; + + session.select_revision(first_revision_id).unwrap(); + + assert_eq!(session.active_revision_id, first_revision_id); + assert_eq!( + session.active_revision().unwrap().state, + RevisionState::Complete + ); + assert!(session.active_revision().unwrap().pending_effect.is_none()); + assert!(session + .revisions + .iter() + .any(|revision| revision.id == second_revision_id)); + } + + #[test] + fn delayed_plan_result_cannot_complete_a_regenerated_attempt() { + let mut revision = AnalysisRevision::draft(1, "compare failures", scope()); + let first_operation = revision.begin_plan_generation().unwrap(); + revision + .fail_plan(1, first_operation, "provider unavailable") + .unwrap(); + let second_operation = revision.begin_plan_generation().unwrap(); + + assert_eq!( + revision.finish_plan(1, first_operation, plan()).unwrap(), + None + ); + assert_eq!(revision.state, RevisionState::GeneratingPlan); + assert!(revision.plan.is_none()); + + revision.finish_plan(1, second_operation, plan()).unwrap(); + assert_eq!(revision.state, RevisionState::PlanReady); + } + + #[test] + fn delayed_query_result_cannot_complete_a_retried_attempt() { + let (mut revision, first_operation) = executing_revision(); + revision + .fail_query(1, first_operation, "query timed out") + .unwrap(); + revision.confirm_execution().unwrap(); + let second_operation = take_execute_operation(&mut revision); + + assert_eq!( + revision + .finish_query(1, first_operation, empty_evidence(), Vec::new()) + .unwrap(), + None + ); + assert_eq!(revision.state, RevisionState::Executing); + assert!(revision.execution.is_none()); + + revision + .finish_query(1, second_operation, empty_evidence(), Vec::new()) + .unwrap(); + assert_eq!(revision.state, RevisionState::Complete); + } + + #[test] + fn delayed_interpretation_result_cannot_complete_a_retried_attempt() { + let (mut revision, query_operation) = executing_revision(); + let first_operation = match revision + .finish_query(1, query_operation, evidence_with_rows(), Vec::new()) + .unwrap() + { + Some(AnalysisEffect::Interpret { operation_id, .. }) => operation_id, + effect => panic!("expected an interpretation effect, got {effect:?}"), + }; + revision + .fail_interpretation(1, first_operation, "provider unavailable") + .unwrap(); + let second_operation = match revision.retry_interpretation().unwrap() { + AnalysisEffect::Interpret { operation_id, .. } => operation_id, + effect => panic!("expected an interpretation effect, got {effect:?}"), + }; + + assert_eq!( + revision + .finish_interpretation(1, first_operation, AnalysisInterpretation::default()) + .unwrap(), + None + ); + assert_eq!(revision.state, RevisionState::Interpreting); + assert!(revision.interpretation.is_none()); + + revision + .finish_interpretation(1, second_operation, AnalysisInterpretation::default()) + .unwrap(); + assert_eq!(revision.state, RevisionState::Complete); + } + + #[test] + fn restored_generating_plan_becomes_a_rerunnable_draft_without_an_operation() { + let mut revision = AnalysisRevision::draft(1, "compare failures", scope()); + revision.begin_plan_generation().unwrap(); + + let mut restored = restored_revision(revision); + + assert_eq!(restored.state, RevisionState::Draft); + assert!(restored.needs_rerun); + assert!(restored.active_operation_id.is_none()); + assert!(restored.pending_effect.is_none()); + assert!(restored.begin_plan_generation().is_ok()); + } + + #[test] + fn restored_executing_plan_becomes_confirmable_without_an_operation() { + let (revision, _) = executing_revision(); + + let mut restored = restored_revision(revision); + + assert_eq!(restored.state, RevisionState::PlanReady); + assert!(restored.needs_rerun); + assert!(restored.plan.is_some()); + assert!(restored.active_operation_id.is_none()); + assert!(restored.pending_effect.is_none()); + assert!(restored.confirm_execution().is_ok()); + } + + #[test] + fn restored_interpretation_becomes_confirmable_without_an_operation() { + let (mut revision, query_operation) = executing_revision(); + revision + .finish_query(1, query_operation, evidence_with_rows(), Vec::new()) + .unwrap(); + + let mut restored = restored_revision(revision); + + assert_eq!(restored.state, RevisionState::QueryError); + assert!(restored.needs_rerun); + assert!(restored.plan.is_some()); + assert!(restored.execution.is_some()); + assert!(restored.active_operation_id.is_none()); + assert!(restored.pending_effect.is_none()); + assert!(restored.confirm_execution().is_ok()); + } + + #[test] + fn restored_interpretation_error_requires_a_query_rerun_without_fabricating_evidence() { + let (mut revision, interpretation_operation) = interpreting_revision(); + revision + .fail_interpretation(1, interpretation_operation, "provider unavailable") + .unwrap(); + + let mut restored = restored_revision(revision); + + assert_eq!(restored.state, RevisionState::QueryError); + assert!(restored.evidence.is_none()); + assert!(restored.retry_interpretation().is_err()); + assert!(restored.confirm_execution().is_ok()); + } + + #[test] + fn trim_sessions_keeps_the_newest_twenty() { + let mut sessions = (0..21) + .map(|id| { + let mut session = AnalysisSession::with_revision(AnalysisRevision::draft( + id, + format!("question {id}"), + scope(), + )); + session.updated_at_ms = id; + session + }) + .collect::>(); + + trim_sessions(&mut sessions); + + assert_eq!(sessions.len(), MAX_ANALYSIS_SESSIONS); + assert!(!sessions.iter().any(|session| session.updated_at_ms == 0)); + assert!(sessions.iter().any(|session| session.updated_at_ms == 20)); + } + + #[test] + fn persisted_mutation_keeps_an_old_session_at_the_twenty_session_boundary() { + let mut sessions = (0..21) + .map(|id| { + let mut session = AnalysisSession::with_revision(AnalysisRevision::draft( + id, + format!("question {id}"), + scope(), + )); + session.id = format!("session-{id}"); + session.updated_at_ms = id; + session + }) + .collect::>(); + sessions[0].mark_updated(); + + trim_sessions(&mut sessions); + + assert!(sessions.iter().any(|session| session.id == "session-0")); + assert!(!sessions.iter().any(|session| session.id == "session-1")); + } + + #[test] + fn persisted_session_fits_storage_budget() { + let session = AnalysisSession::with_revision(AnalysisRevision::draft( + 1, + "x".repeat(MAX_SESSION_BYTES), + scope(), + )); + + assert!(serde_json::to_vec(&session).unwrap().len() > MAX_SESSION_BYTES); + assert!(session.persisted_bytes().unwrap().len() <= MAX_SESSION_BYTES); + } + + #[test] + fn serialized_bundle_retains_twenty_sessions_when_each_fits_its_own_budget() { + let sessions = (0..MAX_ANALYSIS_SESSIONS) + .map(|id| { + let mut revision = AnalysisRevision::draft(id as u64, "question", scope()); + revision.plan = Some(large_plan()); + revision.state = RevisionState::PlanReady; + AnalysisSession::with_revision(revision) + }) + .collect::>(); + + let encoded = serialized_sessions(&sessions).unwrap(); + let restored: Vec = serde_json::from_str(&encoded).unwrap(); + + assert_eq!(restored.len(), MAX_ANALYSIS_SESSIONS); + assert!(restored + .iter() + .all(|session| serde_json::to_vec(session).unwrap().len() <= MAX_SESSION_BYTES)); + } + + #[test] + fn oversized_session_discards_oldest_profiles_and_interpretations_before_revisions() { + let mut session = + AnalysisSession::with_revision(AnalysisRevision::draft(1, "old", scope())); + for id in 1..=5 { + let mut revision = AnalysisRevision::draft(id, format!("question {id}"), scope()); + revision.execution = Some(huge_execution()); + revision.interpretation = Some(huge_interpretation()); + session.revisions.push(revision); + } + session.active_revision_id = 5; + + let restored: AnalysisSession = + serde_json::from_slice(&session.persisted_bytes().unwrap()).unwrap(); + + assert_eq!(restored.revisions.len(), 6); + assert!(restored.revisions[1] + .execution + .as_ref() + .unwrap() + .profiles + .is_empty()); + assert!(restored.revisions[1].interpretation.is_none()); + assert!(!restored.revisions[2] + .execution + .as_ref() + .unwrap() + .profiles + .is_empty()); + assert!(restored.revisions[2].interpretation.is_some()); + } + + fn scope() -> AnalysisScope { + AnalysisScope { + database: "default".into(), + storage_path: "tmp/test/".into(), + snapshot_id: "snapshot-a".into(), + items: vec![AnalysisScopeItem::Dataset { + name: "default".into(), + }], + } + } + + fn catalog() -> QueryCatalog { + QueryCatalog { + snapshot_id: "snapshot-a".into(), + read_only: true, + database: "default".into(), + storage_path: "tmp/test/".into(), + path_column: "_file_".into(), + datasets: Vec::new(), + tables: Vec::new(), + } + } + + fn plan() -> AnalysisPlan { + AnalysisPlan { + id: 1, + question: "compare failures".into(), + intent_summary: "Compare failures by status".into(), + scope_summary: "default dataset".into(), + filters: Vec::new(), + groupings: vec!["status".into()], + measures: vec!["run count".into()], + expected_columns: vec!["status".into(), "run_count".into()], + suggested_view: SuggestedView::Distribution, + sql: "SELECT status, COUNT(*) FROM default.runs GROUP BY status".into(), + warnings: Vec::new(), + } + } + + fn large_plan() -> AnalysisPlan { + AnalysisPlan { + id: 1, + question: "q".repeat(16 * 1024), + intent_summary: "i".repeat(16 * 1024), + scope_summary: "s".repeat(16 * 1024), + filters: Vec::new(), + groupings: Vec::new(), + measures: Vec::new(), + expected_columns: Vec::new(), + suggested_view: SuggestedView::Table, + sql: "x".repeat(16 * 1024), + warnings: Vec::new(), + } + } + + fn empty_evidence() -> QueryEvidence { + QueryEvidence { + rows: Vec::new(), + returned_rows: 0, + truncated: false, + max_rows: 100, + max_bytes: 4 * 1024 * 1024, + } + } + + fn evidence_with_rows() -> QueryEvidence { + QueryEvidence { + rows: vec![serde_json::json!({"status": "failed"})], + returned_rows: 1, + truncated: false, + max_rows: 100, + max_bytes: 4 * 1024 * 1024, + } + } + + fn run(id: &str) -> RunSummary { + RunSummary { + dataset: "default".into(), + file: "source.json".into(), + run_id: Some(id.into()), + agent_id: "agent".into(), + model_name: None, + session_id: format!("session-{id}"), + root_session_id: Some("root-a".into()), + path: format!("agent/root-a/{id}"), + row_count: 1, + duplicate_event_ids: 0, + status: "ok".into(), + } + } + + fn huge_execution() -> ExecutionSummary { + ExecutionSummary { + returned_rows: 1, + truncated: false, + max_rows: 100, + max_bytes: 4 * 1024 * 1024, + executed_at_ms: 1, + profiles: (0..1) + .map(|index| ColumnProfile { + name: format!("column-{index}"), + kind: crate::result_profile::ColumnKind::Text, + row_count: 1, + non_null_count: 1, + missing_count: 0, + unique_count: 1, + min: None, + max: None, + mean: None, + median: None, + histogram: Vec::new(), + top_values: (0..10) + .map(|value| crate::result_profile::ValueCount { + label: format!("{index}-{value}-{}", "x".repeat(1024)), + count: 1, + }) + .collect(), + other_count: 0, + type_counts: Default::default(), + }) + .collect(), + } + } + + fn huge_interpretation() -> AnalysisInterpretation { + AnalysisInterpretation { + observations: (0..12).map(|_| "o".repeat(4 * 1024)).collect(), + ..AnalysisInterpretation::default() + } + } + + fn take_execute_operation(revision: &mut AnalysisRevision) -> u64 { + match revision.take_pending_effect() { + Some(AnalysisEffect::ExecuteSql { operation_id, .. }) => operation_id, + effect => panic!("expected an execute effect, got {effect:?}"), + } + } + + fn restored_revision(revision: AnalysisRevision) -> AnalysisRevision { + let session = AnalysisSession::with_revision(revision); + let restored: AnalysisSession = + serde_json::from_slice(&session.persisted_bytes().unwrap()).unwrap(); + restored.revisions.into_iter().next().unwrap() + } + + fn executing_revision() -> (AnalysisRevision, u64) { + let mut revision = AnalysisRevision::draft(1, "compare failures", scope()); + let plan_operation = revision.begin_plan_generation().unwrap(); + revision.finish_plan(1, plan_operation, plan()).unwrap(); + revision.confirm_execution().unwrap(); + let query_operation = take_execute_operation(&mut revision); + (revision, query_operation) + } + + fn interpreting_revision() -> (AnalysisRevision, u64) { + let (mut revision, query_operation) = executing_revision(); + let interpretation_operation = match revision + .finish_query(1, query_operation, evidence_with_rows(), Vec::new()) + .unwrap() + { + Some(AnalysisEffect::Interpret { operation_id, .. }) => operation_id, + effect => panic!("expected an interpretation effect, got {effect:?}"), + }; + (revision, interpretation_operation) + } + + fn complete_session() -> AnalysisSession { + let (mut revision, interpretation_operation) = interpreting_revision(); + revision + .finish_interpretation( + 1, + interpretation_operation, + AnalysisInterpretation { + observations: vec!["One failed row was returned.".into()], + inferences: vec!["Failures may warrant investigation.".into()], + limitations: Vec::new(), + follow_ups: vec!["only failed runs".into()], + references: Vec::new(), + }, + ) + .unwrap(); + AnalysisSession::with_revision(revision) + } + + fn plan_ready_session(snapshot_id: &str) -> AnalysisSession { + let mut scope = scope(); + scope.snapshot_id = snapshot_id.into(); + let mut revision = AnalysisRevision::draft(1, "compare failures", scope); + let operation_id = revision.begin_plan_generation().unwrap(); + revision.finish_plan(1, operation_id, plan()).unwrap(); + AnalysisSession::with_revision(revision) + } +} diff --git a/pchronicle-web/src/api.rs b/pchronicle-web/src/api.rs index 7d40d966..55eb68cd 100644 --- a/pchronicle-web/src/api.rs +++ b/pchronicle-web/src/api.rs @@ -1,5 +1,6 @@ use crate::model::{ - QueryCatalog, QueryEvidence, RunAnalysis, RunPage, RunSummary, TurnDetail, TurnPage, + CatalogTree, QueryCatalog, QueryEvidence, RunAnalysis, RunPage, RunSummary, TurnDetail, + TurnPage, }; use gloo_net::http::{Request, Response}; use serde_json::json; @@ -24,16 +25,31 @@ pub async fn explorer_runs( sort: &str, direction: &str, path: &str, + file: &str, offset: usize, ) -> Result { let url = format!( - "/api/explorer/runs?q={}&dataset={}&status={}&sort={}&direction={}&path={}&offset={offset}&limit=50", + "/api/explorer/runs?q={}&dataset={}&status={}&sort={}&direction={}&path={}&file={}&offset={offset}&limit=50", urlencoding::encode(q), urlencoding::encode(dataset), urlencoding::encode(status), urlencoding::encode(sort), urlencoding::encode(direction), urlencoding::encode(path), + urlencoding::encode(file), + ); + checked(Request::get(&url).send().await.map_err(|e| e.to_string())?) + .await? + .json() + .await + .map_err(|e| e.to_string()) +} + +pub async fn explorer_tree(dataset: &str, prefix: &str) -> Result { + let url = format!( + "/api/explorer/tree?dataset={}&prefix={}", + urlencoding::encode(dataset), + urlencoding::encode(prefix), ); checked(Request::get(&url).send().await.map_err(|e| e.to_string())?) .await? diff --git a/pchronicle-web/src/catalog.rs b/pchronicle-web/src/catalog.rs new file mode 100644 index 00000000..c00c8d60 --- /dev/null +++ b/pchronicle-web/src/catalog.rs @@ -0,0 +1,373 @@ +use dioxus::prelude::*; + +use crate::model::{CatalogTree, CatalogTreeChild}; + +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct TileBox { + pub x: f64, + pub y: f64, + pub w: f64, + pub h: f64, +} + +pub fn layout_treemap(sizes: &[f64], width: f64, height: f64) -> Vec { + if sizes.is_empty() || width <= 0.0 || height <= 0.0 { + return Vec::new(); + } + let total: f64 = sizes.iter().copied().sum(); + if total <= 0.0 { + return sizes + .iter() + .map(|_| TileBox { + x: 0.0, + y: 0.0, + w: 0.0, + h: 0.0, + }) + .collect(); + } + split(sizes, 0.0, 0.0, width, height) +} + +fn split(areas: &[f64], x: f64, y: f64, w: f64, h: f64) -> Vec { + match areas { + [] => Vec::new(), + [_] => vec![TileBox { x, y, w, h }], + _ => { + let total: f64 = areas.iter().sum(); + let mut acc = 0.0; + let mut cut = 1; + for (index, area) in areas.iter().enumerate() { + acc += area; + cut = index + 1; + if acc >= total / 2.0 { + break; + } + } + cut = cut.clamp(1, areas.len() - 1); + let left_sum: f64 = areas[..cut].iter().sum(); + let frac = left_sum / total; + if w >= h { + let left = w * frac; + let mut tiles = split(&areas[..cut], x, y, left, h); + tiles.extend(split(&areas[cut..], x + left, y, w - left, h)); + tiles + } else { + let top = h * frac; + let mut tiles = split(&areas[..cut], x, y, w, top); + tiles.extend(split(&areas[cut..], x, y + top, w, h - top)); + tiles + } + } + } +} + +#[component] +pub fn CatalogExplorer( + tree: Option, + loading: bool, + on_open: EventHandler<(String, String)>, + on_runs: EventHandler<(String, String)>, +) -> Element { + let mut other_open = use_signal(|| false); + let dataset = tree + .as_ref() + .and_then(|tree| tree.dataset.clone()) + .unwrap_or_default(); + let prefix = tree + .as_ref() + .map(|tree| tree.prefix.clone()) + .unwrap_or_default(); + let inside = !dataset.is_empty(); + rsx! { + section { class: "pc-catalog", + header { class: "pc-catalog-head", + div { class: "pc-catalog-title", + p { class: "eyebrow", "pChronicle" } + CatalogBreadcrumb { + dataset: dataset.clone(), + prefix: prefix.clone(), + on_open, + } + p { "{catalog_subtitle(tree.as_ref())}" } + } + button { + class: "button", + onclick: move |_| on_runs.call((dataset.clone(), prefix.clone())), + "Open in Runs" + } + } + if inside { + CatalogStats { tree: tree.clone() } + } + div { class: "pc-catalog-mosaic", + if loading && tree.is_none() { + div { class: "pc-catalog-empty", span { class: "spinner" } "Loading datasets…" } + } else if tree.as_ref().is_none_or(|tree| tree.children.is_empty() && tree.run_count == 0) { + div { class: "pc-catalog-empty", strong { "No datasets" } span { "Mount a Dataset and refresh the local store." } } + } else if tree.as_ref().is_some_and(|tree| tree.children.is_empty()) { + div { class: "pc-catalog-empty", + strong { "Source" } + span { "This prefix is a single source. Open it in Runs to inspect trajectories." } + } + } else { + CatalogMosaic { + tree: tree.clone().unwrap(), + on_open, + on_runs, + on_other: move |_| other_open.set(!other_open()), + } + } + } + if other_open() { + if let Some(other) = tree.as_ref().and_then(other_child) { + ul { class: "pc-catalog-other", + for entry in other.entries.clone() { + OtherEntry { + key: "{entry.path}", + dataset: dataset.clone(), + entry, + on_open, + on_runs, + } + } + } + } + } + } + } +} + +#[component] +fn CatalogBreadcrumb( + dataset: String, + prefix: String, + on_open: EventHandler<(String, String)>, +) -> Element { + let segments = prefix + .split('/') + .filter(|segment| !segment.is_empty()) + .map(str::to_string) + .collect::>(); + let root_dataset = dataset.clone(); + rsx! { + h1 { + button { class: "pc-catalog-crumb", onclick: move |_| on_open.call((String::new(), String::new())), "Datasets" } + if !dataset.is_empty() { + span { " / " } + button { + class: "pc-catalog-crumb", + onclick: move |_| on_open.call((root_dataset.clone(), String::new())), + "{dataset}" + } + } + for (index, segment) in segments.iter().enumerate() { + span { " / " } + { + let dataset = dataset.clone(); + let path = segments[..=index].join("/"); + let label = segment.clone(); + rsx! { + button { + class: "pc-catalog-crumb", + onclick: move |_| on_open.call((dataset.clone(), path.clone())), + "{label}" + } + } + } + } + } + } +} + +#[component] +fn CatalogStats(tree: Option) -> Element { + let Some(tree) = tree else { + return rsx! {}; + }; + let fail = if tree.run_count == 0 { + "—".into() + } else { + format!( + "{:.1}%", + 100.0 * tree.failed_count as f64 / tree.run_count as f64 + ) + }; + let errors = tree.error_sources.unwrap_or(0); + rsx! { + div { class: "pc-catalog-stats", + div { span { "Runs" } strong { "{tree.run_count}" } } + div { span { "Fail rate" } strong { "{fail}" } } + div { span { "Duration" } strong { "{format_duration(tree.duration_ms)}" } } + div { span { "Tokens" } strong { "{format_tokens(tree.total_tokens)}" } } + } + if errors > 0 { + p { class: "pc-catalog-errors", "{errors} sources failed to project" } + } + } +} + +#[component] +fn CatalogMosaic( + tree: CatalogTree, + on_open: EventHandler<(String, String)>, + on_runs: EventHandler<(String, String)>, + on_other: EventHandler, +) -> Element { + let sizes = tree + .children + .iter() + .map(|child| child.run_count.max(1) as f64) + .collect::>(); + let boxes = layout_treemap(&sizes, 100.0, 100.0); + let dataset = tree.dataset.clone().unwrap_or_default(); + rsx! { + div { class: "pc-catalog-tree", + for (index, child) in tree.children.iter().cloned().enumerate() { + { + let tile = boxes.get(index).copied().unwrap_or(TileBox { x: 0.0, y: 0.0, w: 0.0, h: 0.0 }); + let tone = index % 6; + rsx! { + CatalogTile { + key: "{child.kind}:{child.path}", + child, + dataset: dataset.clone(), + tile, + tone, + on_open, + on_runs, + on_other, + } + } + } + } + } + } +} + +#[component] +fn CatalogTile( + child: CatalogTreeChild, + dataset: String, + tile: TileBox, + tone: usize, + on_open: EventHandler<(String, String)>, + on_runs: EventHandler<(String, String)>, + on_other: EventHandler, +) -> Element { + let style = format!( + "left:{:.3}%;top:{:.3}%;width:{:.3}%;height:{:.3}%;", + tile.x, tile.y, tile.w, tile.h + ); + let kind = child.kind.clone(); + let path = child.path.clone(); + let name = child.name.clone(); + rsx! { + button { + class: "pc-catalog-tile tone-{tone} kind-{kind}", + style, + title: "{name} · {child.run_count} runs", + onclick: move |event| { + match kind.as_str() { + "other" => on_other.call(event), + "file" => on_runs.call((dataset.clone(), path.clone())), + "dataset" => on_open.call((name.clone(), String::new())), + _ => on_open.call((dataset.clone(), path.clone())), + } + }, + strong { "{child.name}" } + small { "{child.run_count}" } + } + } +} + +#[component] +fn OtherEntry( + dataset: String, + entry: CatalogTreeChild, + on_open: EventHandler<(String, String)>, + on_runs: EventHandler<(String, String)>, +) -> Element { + let kind = entry.kind.clone(); + let path = entry.path.clone(); + let name = entry.name.clone(); + rsx! { + li { + button { + onclick: move |_| { + match kind.as_str() { + "file" => on_runs.call((dataset.clone(), path.clone())), + "dataset" => on_open.call((name.clone(), String::new())), + _ => on_open.call((dataset.clone(), path.clone())), + } + }, + strong { "{entry.name}" } + span { "{entry.run_count}" } + } + } + } +} + +fn other_child(tree: &CatalogTree) -> Option<&CatalogTreeChild> { + tree.children.iter().find(|child| child.kind == "other") +} + +fn catalog_subtitle(tree: Option<&CatalogTree>) -> String { + let Some(tree) = tree else { + return "Browse Datasets by captured run volume.".into(); + }; + if tree.dataset.is_none() { + format!("{} datasets · {} runs", tree.children.len(), tree.run_count) + } else if tree.prefix.is_empty() { + "Folders follow Dataset _file_ paths.".into() + } else { + format!("Prefix {} · {} runs", tree.prefix, tree.run_count) + } +} + +fn format_duration(ms: Option) -> String { + let Some(ms) = ms.filter(|value| *value >= 0) else { + return "—".into(); + }; + if ms >= 3_600_000 { + format!("{:.0}h", ms as f64 / 3_600_000.0) + } else if ms >= 60_000 { + format!("{:.0}m", ms as f64 / 60_000.0) + } else if ms >= 1_000 { + format!("{:.1}s", ms as f64 / 1_000.0) + } else { + format!("{ms}ms") + } +} + +fn format_tokens(tokens: Option) -> String { + let Some(tokens) = tokens else { + return "—".into(); + }; + if tokens >= 1_000_000 { + format!("{:.0}M", tokens as f64 / 1_000_000.0) + } else if tokens >= 1_000 { + format!("{:.1}k", tokens as f64 / 1_000.0) + } else { + tokens.to_string() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn treemap_covers_the_canvas_and_keeps_area_proportional() { + let boxes = layout_treemap(&[2.0, 1.0, 1.0], 100.0, 100.0); + assert_eq!(boxes.len(), 3); + let area: f64 = boxes.iter().map(|tile| tile.w * tile.h).sum(); + assert!((area - 10_000.0).abs() < 0.01); + assert!((boxes[0].w * boxes[0].h - 5_000.0).abs() < 0.01); + } + + #[test] + fn empty_sizes_yield_no_tiles() { + assert!(layout_treemap(&[], 100.0, 100.0).is_empty()); + } +} diff --git a/pchronicle-web/src/components.rs b/pchronicle-web/src/components.rs index 42c2ee61..63bc0c1c 100644 --- a/pchronicle-web/src/components.rs +++ b/pchronicle-web/src/components.rs @@ -6,7 +6,7 @@ use serde_json::Value; use crate::chat_view::{chat_row_visible, group_chats, source_class, step_row_visible, TraceCard}; use crate::json_value::{is_structured_json, JsonValue}; -use crate::model::{QueryEvidence, TurnDetail, TurnSummary}; +use crate::model::{extract_message_text, QueryEvidence, TurnDetail, TurnSummary, WireToolCall}; #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] pub struct TrajectoryEmbed { @@ -891,13 +891,31 @@ fn CompactTurnRow( fn InlineTurnDetail(value: TurnDetail) -> Element { let message = value.turn.message.clone(); let message_text = value.turn.text(); + let message_is_text_bearing = extract_message_text(&message).is_some(); let structured_message = is_structured_json(&message); - let tool_calls = - serde_json::to_value(&value.wire_tool_calls).unwrap_or(Value::Array(Vec::new())); + let embedded_from_message = parse_embedded_tool_calls_from_text(&message_text); + let mut embedded_seen = HashSet::new(); + for call in &embedded_from_message { + let _ = embedded_seen.insert((call.name.clone(), call.arguments.to_string())); + } + let deduped_wire_calls: Vec = value + .wire_tool_calls + .into_iter() + .filter(|call| !embedded_seen.contains(&(call.name.clone(), call.arguments.to_string()))) + .collect(); + let wire_tool_calls_value = + serde_json::to_value(&deduped_wire_calls).unwrap_or(Value::Array(Vec::new())); let events = serde_json::to_value(&value.events).unwrap_or(Value::Array(Vec::new())); - rsx! { div { class: "pc2-inline-detail-head", strong { "Full turn evidence" } } + let tool_block_title = if embedded_from_message.len() == 1 { + "Tool call" + } else { + "Tool calls" + }; + rsx! { div { class: "pc2-inspector-facts", Fact { label: "Turn", value: format!("#{}", value.summary.id) } Fact { label: "Source", value: value.summary.source.clone() } Fact { label: "Kind", value: value.summary.kind.clone().unwrap_or_else(|| "unavailable".into()) } Fact { label: "Model", value: value.summary.model_name.clone().unwrap_or_else(|| "unavailable".into()) } Fact { label: "Latency", value: value.summary.latency_ms.map(format_ms).unwrap_or_else(|| "unavailable".into()) } Fact { label: "TTFT", value: value.summary.ttft_ms.map(format_ms).unwrap_or_else(|| "unavailable".into()) } Fact { label: "Tokens", value: value.summary.total_tokens.map(|tokens| tokens.to_string()).unwrap_or_else(|| "unavailable".into()) } Fact { label: "Token split", value: format!("{} in · {} out", optional_u64(value.summary.prompt_tokens), optional_u64(value.summary.completion_tokens)) } Fact { label: "Events", value: value.events.len().to_string() } } - if structured_message { + if !embedded_from_message.is_empty() { + EvidenceBlock { title: tool_block_title, open: true, ToolCallCards { calls: embedded_from_message } } + } else if structured_message && !message_is_text_bearing { EvidenceBlock { title: "Message", open: true, JsonValue { value: message } } } else { EvidenceBlock { title: "Message", open: true, pre { "{message_text}" } } @@ -905,8 +923,8 @@ fn InlineTurnDetail(value: TurnDetail) -> Element { if let Some(reasoning) = &value.turn.reasoning_content { EvidenceBlock { title: "Reasoning", pre { "{reasoning.clone()}" } } } - if !value.wire_tool_calls.is_empty() { - EvidenceBlock { title: "Tool calls", JsonValue { value: tool_calls } } + if !deduped_wire_calls.is_empty() { + EvidenceBlock { title: "Tool calls", JsonValue { value: wire_tool_calls_value } } } if let Some(observation) = value.turn.observation.clone() { EvidenceBlock { title: "Observation", JsonValue { value: observation } } @@ -937,6 +955,95 @@ fn EvidenceBlock( rsx! { details { class: "pc2-evidence-block", open, summary { "{title}" } {children} } } } +#[component] +fn ToolCallCards(calls: Vec) -> Element { + rsx! { + div { class: "pc2-tool-call-stack", + for call in calls { + div { class: "pc2-tool-call-card", + div { class: "pc2-tool-call-header", + div { class: "pc2-tool-call-head-left", + span { class: "pc2-tool-call-type", "function" } + strong { "{clean_tool_call_name(&call.name)}" } + } + div { class: "pc2-tool-call-head-right", + span { class: "pc2-tool-call-meta", "{argument_count_label(&call.arguments)}" } + if let Some(id) = &call.id { + span { class: "pc2-tool-call-id", "#{id}" } + } + } + } + div { class: "pc2-tool-call-body", + if let Value::Object(args) = &call.arguments { + for (key, val) in args { + div { class: "pc2-tool-call-arg", + code { "{key}" } + span { "{format_tool_call_arg(val)}" } + } + } + } else { + pre { "{call.arguments}" } + } + } + details { class: "pc2-tool-call-raw", + summary { "Raw call" } + pre { "{serde_json::to_string_pretty(&call).unwrap_or_default()}" } + } + } + } + } + } +} + +fn clean_tool_call_name(name: &str) -> String { + clean_embedded_text(name) +} + +/// Trim whitespace plus literal `\n` / `\r` / `\t` escape sequences that models +/// often emit around embedded tool call fields (e.g. `\ncat foo\n`). +fn clean_embedded_text(value: &str) -> String { + let mut text = value.trim(); + while let Some(stripped) = text + .strip_prefix("\\r\\n") + .or_else(|| text.strip_prefix("\\n")) + .or_else(|| text.strip_prefix("\\r")) + .or_else(|| text.strip_prefix("\\t")) + { + text = stripped.trim_start(); + } + while let Some(stripped) = text + .strip_suffix("\\r\\n") + .or_else(|| text.strip_suffix("\\n")) + .or_else(|| text.strip_suffix("\\r")) + .or_else(|| text.strip_suffix("\\t")) + { + text = stripped.trim_end(); + } + text.to_string() +} + +fn argument_count_label(arguments: &Value) -> String { + match arguments { + Value::Object(args) => format!( + "{} arg{}", + args.len(), + if args.len() == 1 { "" } else { "s" } + ), + _ => "raw".to_string(), + } +} + +fn format_tool_call_arg(value: &Value) -> String { + match value { + Value::String(text) => text.clone(), + Value::Null => "null".to_string(), + Value::Bool(v) => v.to_string(), + Value::Number(n) => n.to_string(), + Value::Array(arr) => serde_json::to_string(arr).unwrap_or_default(), + Value::Object(obj) => serde_json::to_string(obj).unwrap_or_default(), + } +} + fn format_ms(value: f64) -> String { if value >= 1000.0 { format!("{:.2}s", value / 1000.0) @@ -950,6 +1057,61 @@ fn optional_u64(value: Option) -> String { .map(|value| value.to_string()) .unwrap_or_else(|| "—".into()) } + +fn parse_embedded_tool_calls_from_text(text: &str) -> Vec { + let mut calls = Vec::new(); + let mut remaining = text; + loop { + let (after_offset, end_tag) = if let Some(offset) = remaining.find("") { + (offset + "".len(), "") + } else if let Some(offset) = remaining.find("") + } else { + break; + }; + let after = &remaining[after_offset..]; + let name_end = after.find(['>', '\n', '<']).unwrap_or(after.len()); + let name = clean_embedded_text(&after[..name_end]); + let after_name = &after[name_end..]; + let (block, rest) = if let Some(end) = after_name.find(end_tag) { + (&after_name[..end], &after_name[end + end_tag.len()..]) + } else { + (after_name, "") + }; + let mut arguments = serde_json::Map::new(); + let mut param_remaining = block; + while let Some((_, after_param)) = param_remaining.split_once("') else { + break; + }; + let key = key.trim(); + if key.is_empty() { + param_remaining = after_opening; + continue; + } + let (value, rest_param) = after_opening + .split_once("") + .unwrap_or((after_opening, "")); + arguments.insert(key.to_string(), Value::String(clean_embedded_text(value))); + param_remaining = rest_param; + } + if !name.is_empty() { + calls.push(WireToolCall { + id: Some(format!( + "embedded-{name}-{}-{}-{}-0", + name.len(), + calls.len(), + text.len() + )), + name: name.to_string(), + arguments: Value::Object(arguments), + }); + } + remaining = rest; + } + calls +} + #[cfg(test)] mod tests { use super::*; @@ -1193,4 +1355,39 @@ mod tests { assert_eq!(groups[0].label, "#1"); assert_eq!(groups[1].first_seq, 3); } + + #[test] + fn parse_embedded_tool_calls_from_text_extracts_multiple_calls() { + let text = "execute_bashcat /workspace/README.mdexecute_bashls"; + let calls = parse_embedded_tool_calls_from_text(text); + assert_eq!(calls.len(), 2); + assert_eq!(calls[0].name, "execute_bash"); + assert_eq!( + calls[0].arguments, + serde_json::json!({"command": "cat /workspace/README.md"}) + ); + assert_eq!(calls[1].name, "execute_bash"); + assert_eq!(calls[1].arguments, serde_json::json!({"command": "ls"})); + } + + #[test] + fn parse_embedded_function_call_from_text_extracts_parameters() { + let text = "pwd"; + let calls = parse_embedded_tool_calls_from_text(text); + assert_eq!(calls.len(), 1); + assert_eq!(calls[0].name, "execute_bash"); + assert_eq!(calls[0].arguments, serde_json::json!({"command": "pwd"})); + } + + #[test] + fn parse_embedded_tool_call_strips_literal_escape_sequences() { + let text = "execute_bash\\n\\ncat /workspace/README.md\\n"; + let calls = parse_embedded_tool_calls_from_text(text); + assert_eq!(calls.len(), 1); + assert_eq!(calls[0].name, "execute_bash"); + assert_eq!( + calls[0].arguments, + serde_json::json!({"command": "cat /workspace/README.md"}) + ); + } } diff --git a/pchronicle-web/src/llm.rs b/pchronicle-web/src/llm.rs new file mode 100644 index 00000000..ca981bd2 --- /dev/null +++ b/pchronicle-web/src/llm.rs @@ -0,0 +1,210 @@ +use serde::{Deserialize, Serialize}; +use serde_json::{json, Value}; + +pub const STORAGE_KEY: &str = "pchronicle_llm_config"; + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct LlmConfig { + pub api_base: String, + pub api_key: String, + pub model: String, +} + +#[derive(Clone, Debug, PartialEq)] +pub struct CompletionRequest { + pub system: String, + pub messages: Vec, + pub tools: Option, + pub response_format: Option, + pub temperature: f64, +} + +impl Default for LlmConfig { + fn default() -> Self { + Self { + api_base: "https://api.deepseek.com/v1".into(), + api_key: String::new(), + model: "deepseek-chat".into(), + } + } +} + +impl LlmConfig { + pub fn is_configured(&self) -> bool { + !self.api_base.trim().is_empty() + && !self.api_key.trim().is_empty() + && !self.model.trim().is_empty() + } +} + +pub fn load_config() -> LlmConfig { + let Some(window) = web_sys::window() else { + return LlmConfig::default(); + }; + let Some(storage) = window.local_storage().ok().flatten() else { + return LlmConfig::default(); + }; + storage + .get_item(STORAGE_KEY) + .ok() + .flatten() + .and_then(|raw| serde_json::from_str(&raw).ok()) + .unwrap_or_default() +} + +pub fn save_config(config: &LlmConfig) { + let Some(window) = web_sys::window() else { + return; + }; + let Some(storage) = window.local_storage().ok().flatten() else { + return; + }; + if let Ok(raw) = serde_json::to_string(config) { + let _ = storage.set_item(STORAGE_KEY, &raw); + } +} + +#[derive(Clone, Debug, PartialEq)] +pub struct CompletionError { + pub status: Option, + pub message: String, +} + +impl CompletionError { + pub fn suggests_tools_unsupported(&self) -> bool { + matches!(self.status, Some(400 | 422)) + && ["tools", "tool_choice", "function", "response_format"] + .iter() + .any(|needle| self.message.to_ascii_lowercase().contains(needle)) + } + + pub fn suggests_response_format_unsupported(&self) -> bool { + matches!(self.status, Some(400 | 422)) + && self + .message + .to_ascii_lowercase() + .contains("response_format") + } +} + +pub fn completion_body(model: &str, request: CompletionRequest) -> Value { + let mut messages = vec![json!({"role":"system", "content": request.system})]; + messages.extend(request.messages); + let mut body = json!({ + "model": model.trim(), + "temperature": request.temperature, + "messages": messages, + }); + if let Some(tools) = request.tools { + body["tools"] = tools; + body["tool_choice"] = json!("auto"); + } + if let Some(response_format) = request.response_format { + body["response_format"] = response_format; + } + body +} + +pub async fn complete( + config: &LlmConfig, + request: CompletionRequest, +) -> Result { + let url = format!( + "{}/chat/completions", + config.api_base.trim().trim_end_matches('/') + ); + let body = completion_body(&config.model, request); + let response = gloo_net::http::Request::post(&url) + .header( + "Authorization", + &format!("Bearer {}", config.api_key.trim()), + ) + .header("Content-Type", "application/json") + .json(&body) + .map_err(|error| CompletionError { + status: None, + message: error.to_string(), + })? + .send() + .await + .map_err(|error| CompletionError { + status: None, + message: format!("LLM request failed (check API base, key, and CORS): {error}"), + })?; + let status = response.status(); + let raw = response.text().await.map_err(|error| CompletionError { + status: Some(status), + message: error.to_string(), + })?; + if !(200..300).contains(&status) { + return Err(CompletionError { + status: Some(status), + message: format!("LLM HTTP {status}: {raw}"), + }); + } + let value: Value = serde_json::from_str(&raw).map_err(|error| CompletionError { + status: Some(status), + message: format!("LLM returned invalid JSON: {error}"), + })?; + let message = value + .pointer("/choices/0/message") + .cloned() + .ok_or_else(|| CompletionError { + status: Some(status), + message: "LLM returned an empty response".into(), + })?; + let has_content = message + .get("content") + .and_then(Value::as_str) + .is_some_and(|content| !content.trim().is_empty()); + let has_tool_calls = message + .get("tool_calls") + .and_then(Value::as_array) + .is_some_and(|calls| !calls.is_empty()); + if !has_content && !has_tool_calls { + return Err(CompletionError { + status: Some(status), + message: "LLM returned an empty response".into(), + }); + } + Ok(message) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn completion_body_omits_optional_fields() { + let body = completion_body( + "model-a", + CompletionRequest { + system: "system".into(), + messages: vec![json!({"role":"user","content":"question"})], + tools: None, + response_format: None, + temperature: 0.2, + }, + ); + assert_eq!(body["model"], "model-a"); + assert_eq!(body["messages"].as_array().unwrap().len(), 2); + assert!(body.get("tools").is_none()); + assert!(body.get("response_format").is_none()); + } + + #[test] + fn completion_body_includes_tools_and_json_contract() { + let body = completion_body( + "model-a", + CompletionRequest { + system: "system".into(), + messages: Vec::new(), + tools: Some(json!([{"type":"function"}])), + response_format: Some(json!({"type":"json_object"})), + temperature: 0.1, + }, + ); + assert!(body.get("tools").is_some()); + assert_eq!(body["response_format"]["type"], "json_object"); + } +} diff --git a/pchronicle-web/src/llm_settings.rs b/pchronicle-web/src/llm_settings.rs new file mode 100644 index 00000000..f0c06e4f --- /dev/null +++ b/pchronicle-web/src/llm_settings.rs @@ -0,0 +1,15 @@ +use dioxus::prelude::*; + +use crate::llm::LlmConfig; + +#[component] +pub fn LlmSettings( + config: LlmConfig, + on_close: EventHandler, + on_save: EventHandler, +) -> Element { + let mut api_base = use_signal(|| config.api_base.clone()); + let mut api_key = use_signal(|| config.api_key.clone()); + let mut model = use_signal(|| config.model.clone()); + rsx! { div { class: "pc2-modal-backdrop high", section { class: "pc2-settings", role: "dialog", aria_modal: "true", header { div { p { class: "eyebrow", "Browser BYOK" } h2 { "Copilot model" } } button { onclick: on_close, "×" } } p { class: "pc2-settings-note", "The key stays in this browser's localStorage. Selected evidence is sent directly to this OpenAI-compatible endpoint; pChronicle server never receives the key." } div { class: "pc2-form", label { span { "API base" } input { value: "{api_base}", oninput: move |event| api_base.set(event.value()) } } label { span { "API key" } input { r#type: "password", value: "{api_key}", oninput: move |event| api_key.set(event.value()) } } label { span { "Model" } input { value: "{model}", oninput: move |event| model.set(event.value()) } } } footer { button { class: "button", onclick: on_close, "Cancel" } button { class: "button primary", onclick: move |_| on_save.call(LlmConfig { api_base: api_base(), api_key: api_key(), model: model() }), "Save locally" } } } } } +} diff --git a/pchronicle-web/src/main.rs b/pchronicle-web/src/main.rs index 4a9c6fdd..0e0c36d4 100644 --- a/pchronicle-web/src/main.rs +++ b/pchronicle-web/src/main.rs @@ -1,11 +1,19 @@ #![allow(non_snake_case)] mod agent; +mod analysis; +mod analysis_agent; +mod analysis_session; mod api; +mod catalog; mod chat_view; mod components; mod json_value; +mod llm; +mod llm_settings; mod model; +mod result_explorer; +mod result_profile; mod tools; mod workspace; diff --git a/pchronicle-web/src/model.rs b/pchronicle-web/src/model.rs index 5bf0596d..c1d027b5 100644 --- a/pchronicle-web/src/model.rs +++ b/pchronicle-web/src/model.rs @@ -88,6 +88,42 @@ pub struct QueryDatasetSummary { pub error_sources: usize, } +#[derive(Clone, Debug, PartialEq, Eq, Deserialize)] +pub struct CatalogTree { + #[serde(default)] + pub dataset: Option, + #[serde(default)] + pub prefix: String, + #[serde(default)] + pub run_count: usize, + #[serde(default)] + pub failed_count: usize, + #[serde(default)] + pub ready_sources: Option, + #[serde(default)] + pub error_sources: Option, + #[serde(default)] + pub duration_ms: Option, + #[serde(default)] + pub total_tokens: Option, + #[serde(default)] + pub children: Vec, +} + +#[derive(Clone, Debug, PartialEq, Eq, Deserialize)] +pub struct CatalogTreeChild { + pub name: String, + pub kind: String, + #[serde(default)] + pub path: String, + #[serde(default)] + pub run_count: usize, + #[serde(default)] + pub failed_count: usize, + #[serde(default)] + pub entries: Vec, +} + #[derive(Clone, Debug, PartialEq, Eq, Deserialize)] pub struct QueryTableSummary { pub name: String, @@ -103,6 +139,35 @@ pub struct QueryFieldSummary { pub description: String, } +pub fn queryable_tables(catalog: &QueryCatalog) -> Vec { + if catalog.tables.iter().any(|table| table.name.contains('.')) { + return catalog.tables.clone(); + } + let datasets: Vec = if catalog.datasets.is_empty() { + if catalog.database.trim().is_empty() { + Vec::new() + } else { + vec![catalog.database.clone()] + } + } else { + catalog + .datasets + .iter() + .map(|dataset| dataset.name.clone()) + .collect() + }; + datasets + .into_iter() + .flat_map(|dataset| { + catalog.tables.iter().map(move |table| { + let mut qualified = table.clone(); + qualified.name = format!("{dataset}.{}", table.name); + qualified + }) + }) + .collect() +} + impl RunSummary { pub fn query(&self) -> String { let mut out = format!( @@ -164,10 +229,38 @@ pub struct StorylineTurn { impl StorylineTurn { pub fn text(&self) -> String { - match &self.message { - Value::String(value) => value.clone(), - value => serde_json::to_string_pretty(value).unwrap_or_default(), + extract_message_text(&self.message) + .unwrap_or_else(|| serde_json::to_string_pretty(&self.message).unwrap_or_default()) + } +} + +/// Extract human-readable text from common message shapes: +/// - plain string +/// - `{ "type": "text", "text": "..." }` object +/// - `[{ "type": "text", "text": "..." }, ...]` content array +pub fn extract_message_text(message: &Value) -> Option { + match message { + Value::String(value) => Some(value.clone()), + Value::Object(object) => object + .get("text") + .and_then(|value| value.as_str()) + .map(|value| value.to_string()), + Value::Array(array) => { + let mut parts = Vec::new(); + for item in array { + if let Some(text) = extract_message_text(item) { + if !text.is_empty() { + parts.push(text); + } + } + } + if parts.is_empty() { + None + } else { + Some(parts.join("\n")) + } } + _ => None, } } @@ -403,4 +496,110 @@ mod tests { .unwrap(); assert_eq!(rfc3339.timestamp.as_deref(), Some("2026-07-29T00:00:00Z")); } + + #[test] + fn extract_message_text_prefers_text_field_in_object() { + let message = serde_json::json!({ + "type": "text", + "text": "...", + "image_bytes": null, + "image_url": null, + "input_audio": null, + "media_type": null + }); + assert_eq!( + extract_message_text(&message), + Some("...".into()) + ); + } + + #[test] + fn extract_message_text_joins_text_parts_in_array() { + let message = serde_json::json!([ + {"type": "text", "text": "first"}, + {"type": "image", "image_url": {"url": "http://example.com/a.png"}}, + {"type": "text", "text": "second"} + ]); + assert_eq!(extract_message_text(&message), Some("first\nsecond".into())); + } + + #[test] + fn extract_message_text_falls_back_to_none_for_pure_objects() { + let message = serde_json::json!({"foo": "bar"}); + assert_eq!(extract_message_text(&message), None); + } + + fn kind_catalog() -> QueryCatalog { + QueryCatalog { + snapshot_id: "s".into(), + read_only: true, + database: "atif".into(), + storage_path: "/tmp".into(), + path_column: "_file_".into(), + datasets: vec![ + QueryDatasetSummary { + name: "atif".into(), + uri: "atif".into(), + ready_sources: 1, + error_sources: 0, + }, + QueryDatasetSummary { + name: "actf".into(), + uri: "actf".into(), + ready_sources: 1, + error_sources: 0, + }, + ], + tables: vec![ + QueryTableSummary { + name: "runs".into(), + description: "trajectories".into(), + grain: "trajectory".into(), + fields: Vec::new(), + }, + QueryTableSummary { + name: "steps".into(), + description: "steps".into(), + grain: "step".into(), + fields: Vec::new(), + }, + ], + } + } + + #[test] + fn queryable_tables_are_dataset_qualified_sql_names() { + let names: Vec<_> = queryable_tables(&kind_catalog()) + .into_iter() + .map(|table| table.name) + .collect(); + assert_eq!( + names, + vec!["atif.runs", "atif.steps", "actf.runs", "actf.steps"] + ); + } + + #[test] + fn queryable_tables_use_database_when_datasets_are_missing() { + let mut catalog = kind_catalog(); + catalog.datasets.clear(); + catalog.database = "dataset".into(); + let names: Vec<_> = queryable_tables(&catalog) + .into_iter() + .map(|table| table.name) + .collect(); + assert_eq!(names, vec!["dataset.runs", "dataset.steps"]); + } + + #[test] + fn queryable_tables_keep_already_qualified_names() { + let mut catalog = kind_catalog(); + catalog.tables[0].name = "default.runs".into(); + catalog.tables.truncate(1); + let names: Vec<_> = queryable_tables(&catalog) + .into_iter() + .map(|table| table.name) + .collect(); + assert_eq!(names, vec!["default.runs"]); + } } diff --git a/pchronicle-web/src/result_explorer.rs b/pchronicle-web/src/result_explorer.rs new file mode 100644 index 00000000..dc387f8f --- /dev/null +++ b/pchronicle-web/src/result_explorer.rs @@ -0,0 +1,710 @@ +use std::collections::BTreeSet; + +use dioxus::prelude::*; +use serde_json::Value; +use time::{format_description::well_known::Rfc3339, OffsetDateTime}; + +use crate::json_value::{is_structured_json, JsonValue}; +use crate::model::QueryEvidence; +use crate::result_profile::{ + AnalysisRefinement, ColumnKind, ColumnProfile, RefinementIntent, RefinementPredicate, +}; + +const MAX_COLUMNS: usize = 16; +const MAX_CELL_CHARS: usize = 180; + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct ResultIdentity { + pub run_href: String, + pub turn_href: Option, +} + +pub fn identity_href(row: &Value) -> Option { + let object = row.as_object()?; + let coordinate = |name: &str| { + object + .get(name) + .and_then(Value::as_str) + .filter(|value| !value.is_empty()) + }; + let dataset = coordinate("dataset")?; + let file = coordinate("_file_")?; + let agent_id = coordinate("agent_id")?; + let session_id = coordinate("session_id")?; + let mut run_href = format!( + "?page=detail&dataset={}&file={}", + urlencoding::encode(dataset), + urlencoding::encode(file), + ); + if let Some(run_id) = coordinate("run_id") { + run_href.push_str("&run_id="); + run_href.push_str(&urlencoding::encode(run_id)); + } + run_href.push_str("&agent_id="); + run_href.push_str(&urlencoding::encode(agent_id)); + run_href.push_str("&session_id="); + run_href.push_str(&urlencoding::encode(session_id)); + if let Some(root_session_id) = coordinate("root_session_id") { + run_href.push_str("&root_session_id="); + run_href.push_str(&urlencoding::encode(root_session_id)); + } + let turn_id = object.get("turn_id").and_then(Value::as_i64); + Some(ResultIdentity { + turn_href: turn_id.map(|turn_id| format!("{run_href}&turn={turn_id}")), + run_href, + }) +} + +pub fn profile_scope_label(evidence: &QueryEvidence) -> String { + if evidence.returned_rows == 0 { + return "No distribution · 0 returned rows".into(); + } + format!( + "{} · {} returned {}{}", + if evidence.truncated { + "Preview distribution" + } else { + "Distribution of all returned rows" + }, + evidence.returned_rows, + if evidence.returned_rows == 1 { + "row" + } else { + "rows" + }, + if evidence.truncated { + " · truncated" + } else { + "" + }, + ) +} + +#[component] +pub fn ResultExplorer( + evidence: QueryEvidence, + profiles: Vec, + revision_id: u64, + refinement_enabled: bool, + on_stage_filter: EventHandler, + on_prepare_refinement: EventHandler, +) -> Element { + let columns = table_columns(&evidence.rows); + let visible_columns = columns + .iter() + .take(MAX_COLUMNS) + .cloned() + .collect::>(); + let hidden_columns = columns.len().saturating_sub(visible_columns.len()); + let initial_column = visible_columns.first().cloned(); + let mut selected_column = use_signal(move || initial_column); + let mut staged_intent = use_signal(|| None::); + let selected_profile = selected_column().and_then(|selected| { + profiles + .iter() + .find(|profile| profile.name == selected) + .cloned() + }); + let scope_label = profile_scope_label(&evidence); + let byte_budget = format_bytes(evidence.max_bytes); + + rsx! { + section { class: "result-explorer", aria_label: "Result Explorer", + header { class: "result-explorer-header", + div { + strong { "Result Explorer" } + span { "{scope_label}" } + } + span { class: "result-explorer-count", "{evidence.returned_rows} rows · {columns.len()} columns" } + } + if !refinement_enabled { + div { class: "result-refinement-stale", role: "status", + strong { "Refinement planning is paused" } + span { "Regenerate for the edited question, or restore the reviewed question to prepare a refinement." } + } + } + div { class: "result-explorer-layout", + div { class: "result-explorer-table-region", + div { class: "result-explorer-scroll", + table { class: "result-explorer-table", + thead { tr { + for column in &visible_columns { + if let Some(profile) = profiles.iter().find(|profile| &profile.name == column) { + th { key: "profile-{column}", + ProfileHeader { + profile: profile.clone(), + revision_id, + selected: selected_column().as_deref() == Some(column.as_str()), + on_select: move |name| selected_column.set(Some(name)), + on_stage: { + let on_stage_filter = on_stage_filter; + move |intent: RefinementIntent| { + staged_intent.set(Some(intent.clone())); + on_stage_filter.call(intent); + } + }, + } + } + } else { + th { key: "column-{column}", button { class: "result-profile-title", onclick: { let column = column.clone(); move |_| selected_column.set(Some(column.clone())) }, "{column}" } } + } + } + } } + tbody { + for (row_index, row) in evidence.rows.iter().enumerate() { + tr { key: "row-{row_index}", + for (column_index, column) in visible_columns.iter().enumerate() { + td { key: "cell-{row_index}-{column_index}", + if column_index == 0 { + if let Some(identity) = identity_href(row) { + div { class: "result-identity-links", + a { href: "{identity.run_href}", "Run" } + if let Some(turn_href) = identity.turn_href { a { href: "{turn_href}", "Turn" } } + } + } + } + BoundedCell { value: table_value(row, column).clone() } + } + } + } + } + } + } + } + if let Some(intent) = staged_intent() { + div { class: "result-refinement-stage", role: "status", + div { + span { "Staged refinement" } + strong { "{intent.column} · {intent.label}" } + small { "No query has run and the current SQL is unchanged." } + } + div { class: "result-refinement-actions", + button { class: "analyze-link-button", r#type: "button", onclick: move |_| staged_intent.set(None), "Cancel" } + button { class: "button primary", r#type: "button", disabled: !refinement_enabled, onclick: move |_| on_prepare_refinement.call(AnalysisRefinement::Filter { intent: intent.clone() }), "Apply through Copilot" } + } + } + } + } + if let Some(profile) = selected_profile { + ProfilePanel { + profile, + scope_label: scope_label.clone(), + revision_id, + refinement_enabled, + on_stage: { + let on_stage_filter = on_stage_filter; + move |intent: RefinementIntent| { + staged_intent.set(Some(intent.clone())); + on_stage_filter.call(intent); + } + }, + on_prepare_refinement, + } + } + } + footer { class: "result-explorer-footer", + span { "Server budget · {evidence.max_rows} rows / {byte_budget}" } + if hidden_columns > 0 { span { "+{hidden_columns} columns hidden" } } + if evidence.truncated { span { "Returned rows only; the server truncated this result." } } + } + } + } +} + +#[component] +fn ProfileHeader( + profile: ColumnProfile, + revision_id: u64, + selected: bool, + on_select: EventHandler, + on_stage: EventHandler, +) -> Element { + let summary = profile_summary(&profile); + let missing = percent(profile.missing_count, profile.row_count); + let max_count = profile_max_count(&profile); + rsx! { + div { class: if selected { "result-profile-header selected" } else { "result-profile-header" }, + button { class: "result-profile-title", r#type: "button", title: "Inspect {profile.name}", onclick: { let name = profile.name.clone(); move |_| on_select.call(name.clone()) }, + strong { "{profile.name}" } + span { "{kind_label(&profile.kind)}" } + } + div { class: "result-mini-profile", aria_label: "Returned-row preview for {profile.name}", + if supports_value_filter(&profile.kind) { + for (index, count) in profile_counts(&profile).into_iter().enumerate() { + button { + key: "bar-{index}", + class: "result-mini-bar", + r#type: "button", + title: "Stage {count.label}", + onclick: { + let intent = value_intent(revision_id, &profile, index); + move |event| { event.stop_propagation(); if let Some(intent) = intent.clone() { on_stage.call(intent); } } + }, + i { style: format!("height:{}%", percent(count.count, max_count).max(8.0)) } + } + } + } else { + for (index, count) in profile_counts(&profile).into_iter().enumerate() { + span { key: "bar-{index}", class: "result-mini-bar", i { style: format!("height:{}%", percent(count.count, max_count).max(8.0)) } } + } + } + } + div { class: "result-profile-meta", span { "{summary}" } + if profile.missing_count > 0 { + button { r#type: "button", onclick: { let intent = missing_intent(revision_id, &profile); move |event| { event.stop_propagation(); on_stage.call(intent.clone()); } }, "{missing:.0}% missing" } + } else { span { "0% missing" } } + } + } + } +} + +#[component] +fn ProfilePanel( + profile: ColumnProfile, + scope_label: String, + revision_id: u64, + refinement_enabled: bool, + on_stage: EventHandler, + on_prepare_refinement: EventHandler, +) -> Element { + let max_count = profile_max_count(&profile); + let missing = percent(profile.missing_count, profile.row_count); + let full_profile = AnalysisRefinement::FullProfile { + source_revision_id: revision_id, + column: profile.name.clone(), + column_kind: profile.kind.clone(), + }; + rsx! { + aside { class: "result-profile-panel", aria_label: "Column profile for {profile.name}", + p { class: "analyze-eyebrow", "Selected column" } + h3 { "{profile.name}" } + div { class: "result-profile-kind", "{kind_label(&profile.kind)}" } + p { class: "result-profile-scope", "{scope_label}" } + dl { class: "result-profile-stats", + div { dt { "Present" } dd { "{profile.non_null_count}" } } + div { dt { "Unique" } dd { "{profile.unique_count}" } } + div { dt { "Missing" } dd { "{missing:.1}%" } } + for (label, value) in profile_stat_rows(&profile) { + div { dt { "{label}" } dd { "{value}" } } + } + } + if profile.row_count == 0 { + p { class: "result-profile-none", "No returned rows; no distribution is available." } + } else { + div { class: "result-profile-bars", + for (index, count) in profile_counts(&profile).into_iter().enumerate() { + if supports_value_filter(&profile.kind) { + button { key: "detail-{index}", r#type: "button", onclick: { let intent = value_intent(revision_id, &profile, index); move |_| if let Some(intent) = intent.clone() { on_stage.call(intent) } }, + span { "{count.label}" } + i { span { style: format!("width:{}%", percent(count.count, max_count)) } } + code { "{count.count}" } + } + } else { + div { key: "detail-{index}", span { "{count.label}" } i { span { style: format!("width:{}%", percent(count.count, max_count)) } } code { "{count.count}" } } + } + } + } + } + if profile.missing_count > 0 { + button { class: "result-profile-missing", r#type: "button", onclick: { let intent = missing_intent(revision_id, &profile); move |_| on_stage.call(intent.clone()) }, "Stage missing values · {profile.missing_count}" } + } + button { class: "button result-full-profile", r#type: "button", disabled: !refinement_enabled, onclick: move |_| on_prepare_refinement.call(full_profile.clone()), "Create full-distribution query" } + if refinement_enabled { + small { "Copilot will draft an aggregate plan for review. It will not run automatically." } + } else { + small { "Regenerate or restore the reviewed question before preparing this query." } + } + } + } +} + +#[derive(Clone)] +struct ProfileCount { + label: String, + count: usize, +} + +fn profile_counts(profile: &ColumnProfile) -> Vec { + if !profile.top_values.is_empty() { + return profile + .top_values + .iter() + .map(|value| ProfileCount { + label: value.label.clone(), + count: value.count, + }) + .collect(); + } + profile + .histogram + .iter() + .enumerate() + .map(|(index, bin)| ProfileCount { + label: format!( + "{} to {}{}", + format_profile_value(&profile.kind, bin.lower), + format_profile_value(&profile.kind, bin.upper), + if index + 1 == profile.histogram.len() { + " (inclusive)" + } else { + "" + } + ), + count: bin.count, + }) + .collect() +} + +fn profile_max_count(profile: &ColumnProfile) -> usize { + profile_counts(profile) + .into_iter() + .map(|value| value.count) + .max() + .unwrap_or(1) +} + +fn supports_value_filter(kind: &ColumnKind) -> bool { + matches!( + kind, + ColumnKind::Categorical | ColumnKind::Boolean | ColumnKind::Number + ) +} + +fn value_intent( + revision_id: u64, + profile: &ColumnProfile, + index: usize, +) -> Option { + match profile.kind { + ColumnKind::Categorical | ColumnKind::Boolean => { + let value = profile.top_values.get(index)?; + let predicate_value = if profile.kind == ColumnKind::Boolean { + Value::Bool(value.label == "true") + } else { + Value::String(value.label.clone()) + }; + Some(RefinementIntent { + source_revision_id: revision_id, + column: profile.name.clone(), + label: format!("equals {}", value.label), + predicate: RefinementPredicate::Equals { + value: predicate_value, + }, + }) + } + ColumnKind::Number => { + let bin = profile.histogram.get(index)?; + Some(RefinementIntent { + source_revision_id: revision_id, + column: profile.name.clone(), + label: format!( + "{} to {}", + format_number(bin.lower), + format_number(bin.upper) + ), + predicate: RefinementPredicate::NumericRange { + lower: bin.lower, + upper: bin.upper, + include_upper: index + 1 == profile.histogram.len(), + }, + }) + } + _ => None, + } +} + +fn missing_intent(revision_id: u64, profile: &ColumnProfile) -> RefinementIntent { + RefinementIntent { + source_revision_id: revision_id, + column: profile.name.clone(), + label: "is missing".into(), + predicate: RefinementPredicate::Missing, + } +} + +#[component] +fn BoundedCell(value: Value) -> Element { + let structured = is_structured_json(&value); + let (preview, truncated) = bounded_text(&value, MAX_CELL_CHARS); + let full_value = value_text(&value, true); + let kind = value_kind(&value); + let mut expanded = use_signal(|| false); + rsx! { + if truncated || structured { + button { class: "result-cell result-cell-expand {kind}", r#type: "button", title: "Open full cell value", aria_label: "Open full cell value", onclick: move |_| expanded.set(true), + span { "{preview}" } i { "↗" } + } + } else { + span { class: "result-cell {kind}", "{preview}" } + } + if expanded() { + div { class: "result-cell-backdrop", role: "presentation", onclick: move |_| expanded.set(false), + section { class: "result-cell-modal", role: "dialog", aria_modal: "true", aria_label: "Full cell value", tabindex: "-1", onclick: move |event| event.stop_propagation(), onkeydown: move |event| if event.key() == Key::Escape { expanded.set(false); }, + header { div { strong { "Full cell value" } span { "{kind}" } } button { aria_label: "Close full cell value", onclick: move |_| expanded.set(false), "×" } } + if structured { div { class: "result-cell-json", JsonValue { value: value.clone() } } } + else { pre { "{full_value}" } } + footer { span { "{full_value.chars().count()} characters" } button { class: "button primary", onclick: move |_| expanded.set(false), "Close" } } + } + } + } + } +} + +fn table_columns(rows: &[Value]) -> Vec { + let mut columns = BTreeSet::new(); + let mut has_scalar = false; + for row in rows { + if let Value::Object(object) = row { + columns.extend(object.keys().cloned()); + } else { + has_scalar = true; + } + } + if has_scalar { + columns.insert("value".into()); + } + columns.into_iter().collect() +} + +fn table_value<'a>(row: &'a Value, column: &str) -> &'a Value { + static NULL: Value = Value::Null; + match row { + Value::Object(object) => object.get(column).unwrap_or(&NULL), + value if column == "value" => value, + _ => &NULL, + } +} + +fn bounded_text(value: &Value, limit: usize) -> (String, bool) { + let raw = value_text(value, false); + if raw.chars().count() <= limit { + (raw, false) + } else { + ( + format!( + "{}…", + raw.chars() + .take(limit.saturating_sub(1)) + .collect::() + ), + true, + ) + } +} + +fn value_text(value: &Value, pretty: bool) -> String { + match value { + Value::Null => "null".into(), + Value::String(value) => value.clone(), + Value::Bool(value) => value.to_string(), + Value::Number(value) => value.to_string(), + value if pretty => serde_json::to_string_pretty(value).unwrap_or_default(), + value => serde_json::to_string(value).unwrap_or_default(), + } +} + +fn value_kind(value: &Value) -> &'static str { + match value { + Value::Null => "null", + Value::Number(_) => "number", + Value::Bool(_) => "boolean", + Value::Array(_) | Value::Object(_) => "structured", + Value::String(_) => "text", + } +} + +fn kind_label(kind: &ColumnKind) -> &'static str { + match kind { + ColumnKind::Empty => "empty", + ColumnKind::Number => "number", + ColumnKind::Boolean => "boolean", + ColumnKind::Categorical => "categorical", + ColumnKind::Text => "text", + ColumnKind::DateTime => "datetime", + ColumnKind::Object => "object", + ColumnKind::Array => "array", + ColumnKind::Identifier => "identifier", + ColumnKind::Mixed => "mixed", + } +} + +fn profile_summary(profile: &ColumnProfile) -> String { + match (profile.min, profile.max) { + (Some(min), Some(max)) => format!( + "{}–{}", + format_profile_value(&profile.kind, min), + format_profile_value(&profile.kind, max) + ), + _ => format!("{} unique", profile.unique_count), + } +} + +fn profile_stat_rows(profile: &ColumnProfile) -> Vec<(&'static str, String)> { + let labels = match profile.kind { + ColumnKind::Number => Some(("Minimum", "Maximum", "Mean", "Median")), + ColumnKind::Text | ColumnKind::Array => Some(( + "Minimum length", + "Maximum length", + "Mean length", + "Median length", + )), + ColumnKind::DateTime => Some(("Earliest", "Latest", "", "")), + _ => None, + }; + let Some((min_label, max_label, mean_label, median_label)) = labels else { + return Vec::new(); + }; + let mut rows = Vec::new(); + if let Some(value) = profile.min { + rows.push((min_label, format_profile_value(&profile.kind, value))); + } + if let Some(value) = profile.max { + rows.push((max_label, format_profile_value(&profile.kind, value))); + } + if !mean_label.is_empty() { + if let Some(value) = profile.mean { + rows.push((mean_label, format_profile_value(&profile.kind, value))); + } + } + if !median_label.is_empty() { + if let Some(value) = profile.median { + rows.push((median_label, format_profile_value(&profile.kind, value))); + } + } + rows +} + +fn format_profile_value(kind: &ColumnKind, value: f64) -> String { + if kind != &ColumnKind::DateTime || !value.is_finite() { + return format_number(value); + } + let nanos = (value * 1_000_000_000.0).round(); + if nanos < i128::MIN as f64 || nanos > i128::MAX as f64 { + return format_number(value); + } + OffsetDateTime::from_unix_timestamp_nanos(nanos as i128) + .ok() + .and_then(|timestamp| timestamp.format(&Rfc3339).ok()) + .unwrap_or_else(|| format_number(value)) +} + +fn format_number(value: f64) -> String { + if value.abs() >= 10_000.0 || (value != 0.0 && value.abs() < 0.01) { + format!("{value:.2e}") + } else if value.fract().abs() < f64::EPSILON { + format!("{value:.0}") + } else { + format!("{value:.2}") + } +} + +fn percent(value: usize, total: usize) -> f64 { + if total == 0 { + 0.0 + } else { + value as f64 * 100.0 / total as f64 + } +} + +fn format_bytes(value: usize) -> String { + if value >= 1024 * 1024 && value.is_multiple_of(1024 * 1024) { + format!("{} MiB", value / (1024 * 1024)) + } else if value >= 1024 && value.is_multiple_of(1024) { + format!("{} KiB", value / 1024) + } else { + format!("{value} bytes") + } +} + +#[cfg(test)] +mod tests { + use serde_json::{json, Value}; + + use crate::model::QueryEvidence; + + use crate::result_profile::profile_rows; + + use super::{identity_href, profile_counts, profile_scope_label}; + + fn evidence(rows: Vec, returned_rows: usize, truncated: bool) -> QueryEvidence { + QueryEvidence { + rows, + returned_rows, + truncated, + max_rows: 100, + max_bytes: 4 * 1024 * 1024, + } + } + + #[test] + fn complete_coordinates_create_run_and_turn_links() { + let row = json!({ + "dataset":"captures", + "_file_":"nested/run.json", + "run_id":"run-1", + "agent_id":"agent-a", + "session_id":"session-a", + "root_session_id":"root-a", + "turn_id":12 + }); + + let identity = identity_href(&row).unwrap(); + + assert!(identity.run_href.contains("page=detail")); + assert!(identity.run_href.contains("session_id=session-a")); + assert!(identity.turn_href.unwrap().contains("turn=12")); + } + + #[test] + fn incomplete_coordinates_do_not_guess_a_link() { + assert_eq!(identity_href(&json!({"session_id":"only"})), None); + } + + #[test] + fn nullable_run_and_root_coordinates_still_create_links() { + let row = json!({ + "dataset":"captures", + "_file_":"gateway/events.lance", + "agent_id":"gateway", + "session_id":"session-a", + "run_id": null, + "root_session_id": null, + "turn_id": 12 + }); + + let identity = identity_href(&row).expect("detail supports nullable run and root ids"); + + assert!(!identity.run_href.contains("run_id=")); + assert!(!identity.run_href.contains("root_session_id=")); + assert!(identity.run_href.contains("agent_id=gateway")); + assert!(identity.turn_href.unwrap().contains("turn=12")); + } + + #[test] + fn truncated_results_are_labeled_as_preview() { + assert_eq!( + profile_scope_label(&evidence(Vec::new(), 100, true)), + "Preview distribution · 100 returned rows · truncated" + ); + } + + #[test] + fn complete_results_are_labeled_as_all_returned_rows() { + assert_eq!( + profile_scope_label(&evidence(Vec::new(), 3, false)), + "Distribution of all returned rows · 3 returned rows" + ); + } + + #[test] + fn datetime_profile_bins_use_datetime_labels() { + let profiles = profile_rows(&[ + json!({"occurred_at":"2026-08-22T01:02:03Z"}), + json!({"occurred_at":"2026-08-23T02:03:04Z"}), + ]); + let counts = profile_counts(&profiles[0]); + + assert!(counts[0].label.contains("2026-08-22")); + assert!(!counts[0].label.contains("1.77e")); + } +} diff --git a/pchronicle-web/src/result_profile.rs b/pchronicle-web/src/result_profile.rs new file mode 100644 index 00000000..f1162347 --- /dev/null +++ b/pchronicle-web/src/result_profile.rs @@ -0,0 +1,710 @@ +use std::collections::{BTreeMap, BTreeSet}; + +use serde::{Deserialize, Serialize}; +use serde_json::Value; +use time::{format_description::well_known::Rfc3339, OffsetDateTime}; + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum ColumnKind { + Empty, + Number, + Boolean, + Categorical, + Text, + DateTime, + Object, + Array, + Identifier, + Mixed, +} + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct HistogramBin { + pub lower: f64, + pub upper: f64, + pub count: usize, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct ValueCount { + pub label: String, + pub count: usize, +} + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct ColumnProfile { + pub name: String, + pub kind: ColumnKind, + pub row_count: usize, + pub non_null_count: usize, + pub missing_count: usize, + pub unique_count: usize, + pub min: Option, + pub max: Option, + pub mean: Option, + #[serde(default)] + pub median: Option, + pub histogram: Vec, + pub top_values: Vec, + pub other_count: usize, + pub type_counts: BTreeMap, +} + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct RefinementIntent { + pub source_revision_id: u64, + pub column: String, + pub label: String, + pub predicate: RefinementPredicate, +} + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(tag = "kind", rename_all = "snake_case")] +pub enum RefinementPredicate { + Equals { + value: Value, + }, + NumericRange { + lower: f64, + upper: f64, + include_upper: bool, + }, + Missing, +} + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(tag = "kind", rename_all = "snake_case")] +pub enum AnalysisRefinement { + Filter { + intent: RefinementIntent, + }, + FullProfile { + source_revision_id: u64, + column: String, + column_kind: ColumnKind, + }, +} + +const MAX_BINS: usize = 10; +const MAX_TOP_VALUES: usize = 10; + +struct CountedValue { + label: String, + count: usize, +} + +pub fn profile_rows(rows: &[Value]) -> Vec { + let mut columns = BTreeSet::new(); + for row in rows { + if let Some(object) = row.as_object() { + columns.extend(object.keys().cloned()); + } + } + + columns + .into_iter() + .map(|name| profile_column(rows, name)) + .collect() +} + +fn profile_column(rows: &[Value], name: String) -> ColumnProfile { + let values = rows + .iter() + .filter_map(|row| row.as_object().and_then(|object| object.get(&name))) + .filter(|value| !value.is_null()) + .collect::>(); + let row_count = rows.len(); + let non_null_count = values.len(); + let missing_count = row_count - non_null_count; + let value_counts = count_values(&values); + let unique_count = value_counts.len(); + + let kind = infer_kind(&name, &values, unique_count); + let mut profile = ColumnProfile { + name, + kind: kind.clone(), + row_count, + non_null_count, + missing_count, + unique_count, + min: None, + max: None, + mean: None, + median: None, + histogram: Vec::new(), + top_values: Vec::new(), + other_count: 0, + type_counts: BTreeMap::new(), + }; + + match kind { + ColumnKind::Number => add_numeric_summary(&mut profile, &values), + ColumnKind::DateTime => add_datetime_summary(&mut profile, &values), + ColumnKind::Text => add_text_summary(&mut profile, &values), + ColumnKind::Object => add_object_summary(&mut profile, &values), + ColumnKind::Array => add_array_summary(&mut profile, &values), + ColumnKind::Categorical | ColumnKind::Boolean => add_top_values(&mut profile, value_counts), + ColumnKind::Mixed => profile.type_counts = count_types(&values), + ColumnKind::Empty | ColumnKind::Identifier => {} + } + + profile +} + +fn infer_kind(name: &str, values: &[&Value], unique_count: usize) -> ColumnKind { + if values.is_empty() { + return ColumnKind::Empty; + } + if is_identity_column(name) { + return ColumnKind::Identifier; + } + if values.iter().all(|value| value.is_number()) { + return ColumnKind::Number; + } + if values.iter().all(|value| value.is_boolean()) { + return ColumnKind::Boolean; + } + if values.iter().all(|value| value.is_object()) { + return ColumnKind::Object; + } + if values.iter().all(|value| value.is_array()) { + return ColumnKind::Array; + } + if values.iter().all(|value| value.is_string()) { + let strings = values + .iter() + .map(|value| value.as_str().expect("all values were checked as strings")); + if strings + .clone() + .all(|value| OffsetDateTime::parse(value, &Rfc3339).is_ok()) + { + return ColumnKind::DateTime; + } + let non_null_count = values.len(); + if unique_count <= 20 && unique_count * 2 <= non_null_count { + return ColumnKind::Categorical; + } + return ColumnKind::Text; + } + ColumnKind::Mixed +} + +fn is_identity_column(name: &str) -> bool { + matches!( + name, + "_file_" + | "dataset" + | "id" + | "uuid" + | "run_id" + | "agent_id" + | "session_id" + | "root_session_id" + | "turn_id" + ) +} + +fn count_values(values: &[&Value]) -> BTreeMap { + let mut counts = BTreeMap::new(); + for value in values { + let entry = counts + .entry(serialized_value(value)) + .or_insert_with(|| CountedValue { + label: display_label(value), + count: 0, + }); + entry.count += 1; + } + counts +} + +fn serialized_value(value: &Value) -> String { + serde_json::to_string(value).expect("serde_json values always serialize") +} + +fn display_label(value: &Value) -> String { + match value { + Value::String(value) => value.clone(), + _ => serialized_value(value), + } +} + +fn count_types(values: &[&Value]) -> BTreeMap { + let mut counts = BTreeMap::new(); + for value in values { + let label = match value { + Value::Null => "null", + Value::Bool(_) => "boolean", + Value::Number(_) => "number", + Value::String(_) => "string", + Value::Array(_) => "array", + Value::Object(_) => "object", + }; + *counts.entry(label.to_string()).or_default() += 1; + } + counts +} + +fn add_numeric_summary(profile: &mut ColumnProfile, values: &[&Value]) { + let Some(numbers) = values + .iter() + .map(|value| value.as_f64().filter(|number| number.is_finite())) + .collect::>>() + else { + return; + }; + add_distribution_summary(profile, &numbers); +} + +fn add_datetime_summary(profile: &mut ColumnProfile, values: &[&Value]) { + let Some(timestamps) = values + .iter() + .map(|value| { + OffsetDateTime::parse( + value + .as_str() + .expect("datetime inference verified string values"), + &Rfc3339, + ) + .ok() + .map(|value| value.unix_timestamp_nanos() as f64 / 1_000_000_000.0) + }) + .collect::>>() + else { + return; + }; + add_distribution_summary(profile, ×tamps); +} + +fn add_text_summary(profile: &mut ColumnProfile, values: &[&Value]) { + let lengths = values + .iter() + .map(|value| { + value + .as_str() + .expect("text inference verified string values") + .chars() + .count() as f64 + }) + .collect::>(); + add_distribution_summary(profile, &lengths); +} + +fn add_object_summary(profile: &mut ColumnProfile, values: &[&Value]) { + let mut counts = BTreeMap::::new(); + for object in values.iter().filter_map(|value| value.as_object()) { + for key in object.keys() { + *counts.entry(key.clone()).or_default() += 1; + } + } + let mut counts = counts.into_iter().collect::>(); + counts.sort_by(|(left_key, left_count), (right_key, right_count)| { + right_count + .cmp(left_count) + .then_with(|| left_key.cmp(right_key)) + }); + profile.other_count = counts + .iter() + .skip(MAX_TOP_VALUES) + .map(|(_, count)| *count) + .sum(); + profile.top_values = counts + .into_iter() + .take(MAX_TOP_VALUES) + .map(|(label, count)| ValueCount { label, count }) + .collect(); +} + +fn add_array_summary(profile: &mut ColumnProfile, values: &[&Value]) { + let lengths = values + .iter() + .filter_map(|value| value.as_array()) + .map(|array| array.len() as f64) + .collect::>(); + add_distribution_summary(profile, &lengths); +} + +fn add_distribution_summary(profile: &mut ColumnProfile, values: &[f64]) { + let Some(min) = values.iter().copied().reduce(f64::min) else { + return; + }; + let max = values.iter().copied().reduce(f64::max).expect("min exists"); + profile.min = Some(min); + profile.max = Some(max); + profile.mean = Some(values.iter().enumerate().fold(0.0, |mean, (index, value)| { + let count = (index + 1) as f64; + mean * ((count - 1.0) / count) + value / count + })); + let mut ordered = values.to_vec(); + ordered.sort_by(f64::total_cmp); + let middle = ordered.len() / 2; + profile.median = Some(if ordered.len().is_multiple_of(2) { + ordered[middle - 1] / 2.0 + ordered[middle] / 2.0 + } else { + ordered[middle] + }); + profile.histogram = equal_width_histogram(values, min, max); +} + +fn equal_width_histogram(values: &[f64], min: f64, max: f64) -> Vec { + if values.is_empty() { + return Vec::new(); + } + if min == max { + return vec![HistogramBin { + lower: min, + upper: max, + count: values.len(), + }]; + } + + let bin_count = values.len().min(MAX_BINS); + let range = max - min; + let width = range / bin_count as f64; + if !range.is_finite() || !width.is_finite() || width <= 0.0 { + return vec![HistogramBin { + lower: min, + upper: max, + count: values.len(), + }]; + } + let mut bins = (0..bin_count) + .map(|index| HistogramBin { + lower: min + width * index as f64, + upper: if index + 1 == bin_count { + max + } else { + min + width * (index + 1) as f64 + }, + count: 0, + }) + .collect::>(); + + for value in values { + let index = (((value - min) / width).floor() as usize).min(bin_count - 1); + bins[index].count += 1; + } + bins +} + +fn add_top_values(profile: &mut ColumnProfile, counts: BTreeMap) { + let mut values = counts.into_iter().collect::>(); + values.sort_by(|(left_key, left_value), (right_key, right_value)| { + right_value + .count + .cmp(&left_value.count) + .then_with(|| left_key.cmp(right_key)) + }); + profile.other_count = values + .iter() + .skip(MAX_TOP_VALUES) + .map(|(_, value)| value.count) + .sum(); + profile.top_values = values + .into_iter() + .take(MAX_TOP_VALUES) + .map(|(_, value)| ValueCount { + label: value.label, + count: value.count, + }) + .collect(); +} + +#[cfg(test)] +mod tests { + use serde_json::json; + + use super::{profile_rows, ColumnKind, ColumnProfile}; + + fn profile<'a>(profiles: &'a [ColumnProfile], name: &str) -> &'a ColumnProfile { + profiles + .iter() + .find(|profile| profile.name == name) + .unwrap() + } + + #[test] + fn profiles_numeric_categorical_text_and_missing_values() { + let rows = vec![ + json!({"latency_ms": 10, "status": "ok", "message": "short"}), + json!({"latency_ms": 20, "status": "failed", "message": "a longer message"}), + json!({"latency_ms": null, "status": "ok", "message": "free text three"}), + json!({"latency_ms": 30, "status": "ok", "message": "free text four"}), + ]; + let profiles = profile_rows(&rows); + assert_eq!(profile(&profiles, "latency_ms").kind, ColumnKind::Number); + assert_eq!(profile(&profiles, "latency_ms").missing_count, 1); + assert_eq!(profile(&profiles, "status").kind, ColumnKind::Categorical); + assert_eq!(profile(&profiles, "status").top_values[0].label, "ok"); + assert_eq!(profile(&profiles, "message").kind, ColumnKind::Text); + } + + #[test] + fn numeric_histogram_handles_single_value_without_fake_range() { + let profiles = profile_rows(&[json!({"value": 7}), json!({"value": 7})]); + let bins = &profile(&profiles, "value").histogram; + assert_eq!(bins.len(), 1); + assert_eq!((bins[0].lower, bins[0].upper, bins[0].count), (7.0, 7.0, 2)); + } + + #[test] + fn top_values_use_label_as_stable_tie_breaker() { + let rows = vec![ + json!({"kind":"b"}), + json!({"kind":"a"}), + json!({"kind":"b"}), + json!({"kind":"a"}), + ]; + let profiles = profile_rows(&rows); + let values = &profile(&profiles, "kind").top_values; + assert_eq!( + values.iter().map(|v| v.label.as_str()).collect::>(), + vec!["a", "b"] + ); + } + + #[test] + fn two_distinct_strings_in_small_samples_are_text() { + let two_rows = profile_rows(&[json!({"kind":"a"}), json!({"kind":"b"})]); + let three_rows = profile_rows(&[ + json!({"kind":"a"}), + json!({"kind":"b"}), + json!({"kind":"a"}), + ]); + + assert_eq!(profile(&two_rows, "kind").kind, ColumnKind::Text); + assert_eq!(profile(&three_rows, "kind").kind, ColumnKind::Text); + } + + #[test] + fn profiles_only_strict_rfc3339_strings_as_datetimes() { + let profiles = profile_rows(&[ + json!({"occurred_at": "2026-08-22T01:02:03Z"}), + json!({"occurred_at": "2026-08-23T02:03:04+08:00"}), + json!({"locale_date": "08/22/2026"}), + json!({"locale_date": "08/23/2026"}), + json!({"locale_date": "08/24/2026"}), + ]); + + let datetime = profile(&profiles, "occurred_at"); + assert_eq!(datetime.kind, ColumnKind::DateTime); + assert_eq!( + datetime + .histogram + .iter() + .map(|bin| bin.count) + .sum::(), + 2 + ); + assert_eq!(profile(&profiles, "locale_date").kind, ColumnKind::Text); + } + + #[test] + fn known_identity_names_override_string_cardinality_inference() { + let profiles = profile_rows(&[ + json!({"run_id": "run-a", "status": "ok"}), + json!({"run_id": "run-b", "status": "ok"}), + ]); + + assert_eq!(profile(&profiles, "run_id").kind, ColumnKind::Identifier); + assert_eq!(profile(&profiles, "status").kind, ColumnKind::Categorical); + } + + #[test] + fn profiles_uniform_boolean_object_and_array_values() { + let profiles = profile_rows(&[ + json!({"enabled": true, "metadata": {"a": 1}, "tags": ["a"]}), + json!({"enabled": false, "metadata": {"a": 2, "b": 2}, "tags": ["b", "c"]}), + json!({"enabled": true, "metadata": {"a": 3}, "tags": ["d", "e", "f"]}), + ]); + + assert_eq!(profile(&profiles, "enabled").kind, ColumnKind::Boolean); + let object = profile(&profiles, "metadata"); + assert_eq!(object.kind, ColumnKind::Object); + assert_eq!(object.top_values[0].label, "a"); + assert_eq!(object.top_values[0].count, 3); + + let array = profile(&profiles, "tags"); + assert_eq!(array.kind, ColumnKind::Array); + assert_eq!( + (array.min, array.max, array.median), + (Some(1.0), Some(3.0), Some(2.0)) + ); + assert_eq!( + array.histogram.iter().map(|bin| bin.count).sum::(), + 3 + ); + } + + #[test] + fn numeric_profiles_include_an_even_sample_median() { + let profiles = profile_rows(&[ + json!({"latency_ms": 1}), + json!({"latency_ms": 9}), + json!({"latency_ms": 3}), + json!({"latency_ms": 5}), + ]); + + assert_eq!(profile(&profiles, "latency_ms").median, Some(4.0)); + } + + #[test] + fn mixed_scalars_only_report_type_counts() { + let profiles = profile_rows(&[ + json!({"value": 1}), + json!({"value": "one"}), + json!({"value": true}), + json!({"value": null}), + ]); + let mixed = profile(&profiles, "value"); + + assert_eq!(mixed.kind, ColumnKind::Mixed); + assert_eq!(mixed.missing_count, 1); + assert_eq!(mixed.type_counts.get("number"), Some(&1)); + assert_eq!(mixed.type_counts.get("string"), Some(&1)); + assert_eq!(mixed.type_counts.get("boolean"), Some(&1)); + assert!(mixed.histogram.is_empty()); + assert!(mixed.top_values.is_empty()); + } + + #[test] + fn unique_count_distinguishes_json_values_with_matching_display_text() { + let profiles = profile_rows(&[ + json!({"value": "true"}), + json!({"value": true}), + json!({"value": "[1]"}), + json!({"value": [1]}), + ]); + let mixed = profile(&profiles, "value"); + + assert_eq!(mixed.kind, ColumnKind::Mixed); + assert_eq!(mixed.unique_count, 4); + } + + #[test] + fn empty_and_all_null_columns_are_empty_with_separate_missing_counts() { + assert!(profile_rows(&[]).is_empty()); + + let profiles = profile_rows(&[json!({"only_null": null}), json!({})]); + let empty = profile(&profiles, "only_null"); + assert_eq!(empty.kind, ColumnKind::Empty); + assert_eq!(empty.row_count, 2); + assert_eq!(empty.non_null_count, 0); + assert_eq!(empty.missing_count, 2); + assert_eq!(empty.unique_count, 0); + } + + #[test] + fn numeric_ranges_cover_negative_values_without_losing_the_upper_bound() { + let profiles = profile_rows(&[ + json!({"delta": -10}), + json!({"delta": -5}), + json!({"delta": 0}), + ]); + let numeric = profile(&profiles, "delta"); + + assert_eq!( + (numeric.min, numeric.max, numeric.mean), + (Some(-10.0), Some(0.0), Some(-5.0)) + ); + assert!(numeric.histogram.len() <= 10); + assert_eq!(numeric.histogram.first().unwrap().lower, -10.0); + assert_eq!(numeric.histogram.last().unwrap().upper, 0.0); + assert_eq!( + numeric.histogram.iter().map(|bin| bin.count).sum::(), + 3 + ); + } + + #[test] + fn extreme_finite_numeric_ranges_use_a_finite_fallback_bin() { + let profiles = profile_rows(&[json!({"value": -1.0e308}), json!({"value": 1.0e308})]); + let numeric = profile(&profiles, "value"); + + assert_eq!(numeric.histogram.len(), 1); + assert_eq!(numeric.histogram[0].count, 2); + assert!(numeric.histogram[0].lower.is_finite()); + assert!(numeric.histogram[0].upper.is_finite()); + + let same_sign = profile_rows(&[json!({"value": 1.0e308}), json!({"value": 1.0e308})]); + let same_sign = profile(&same_sign, "value"); + assert!(same_sign.mean.unwrap().is_finite()); + assert!(same_sign.median.unwrap().is_finite()); + } + + #[test] + fn text_profiles_bin_character_lengths() { + let profiles = profile_rows(&[ + json!({"message": "a"}), + json!({"message": "abc"}), + json!({"message": "abcde"}), + ]); + let text = profile(&profiles, "message"); + + assert_eq!(text.kind, ColumnKind::Text); + assert_eq!( + (text.min, text.max, text.mean), + (Some(1.0), Some(5.0), Some(3.0)) + ); + assert!(text.histogram.len() <= 10); + assert_eq!(text.histogram.iter().map(|bin| bin.count).sum::(), 3); + } + + #[test] + fn categorical_top_values_are_limited_to_ten_and_track_other_values() { + let rows = (0..11) + .flat_map(|index| { + std::iter::repeat(json!({"kind": format!("kind-{index:02}")})).take(2) + }) + .collect::>(); + let profiles = profile_rows(&rows); + let categorical = profile(&profiles, "kind"); + + assert_eq!(categorical.kind, ColumnKind::Categorical); + assert_eq!(categorical.top_values.len(), 10); + assert_eq!(categorical.top_values[0].label, "kind-00"); + assert_eq!(categorical.other_count, 2); + } + + #[test] + fn serde_json_rejects_non_finite_numbers_before_profiling() { + assert!(serde_json::Number::from_f64(f64::NAN).is_none()); + assert!(serde_json::Number::from_f64(f64::INFINITY).is_none()); + assert!(serde_json::Number::from_f64(f64::NEG_INFINITY).is_none()); + } + + #[test] + fn refinement_data_serializes_without_embedded_sql() { + let refinement = super::AnalysisRefinement::Filter { + intent: super::RefinementIntent { + source_revision_id: 42, + column: "latency_ms".into(), + label: "10 through 20".into(), + predicate: super::RefinementPredicate::NumericRange { + lower: 10.0, + upper: 20.0, + include_upper: false, + }, + }, + }; + + assert_eq!( + serde_json::to_value(refinement).unwrap(), + json!({ + "kind": "filter", + "intent": { + "source_revision_id": 42, + "column": "latency_ms", + "label": "10 through 20", + "predicate": { + "kind": "numeric_range", + "lower": 10.0, + "upper": 20.0, + "include_upper": false, + }, + }, + }) + ); + } +} diff --git a/pchronicle-web/src/tools.rs b/pchronicle-web/src/tools.rs index 5b42f733..2aaa5d70 100644 --- a/pchronicle-web/src/tools.rs +++ b/pchronicle-web/src/tools.rs @@ -1,129 +1,19 @@ use dioxus::prelude::*; -use crate::api; -use crate::components::DataTable; -use crate::model::{QueryCatalog, QueryEvidence}; - -fn sql_literal(value: &str) -> String { - value.replace('\'', "''") -} - -fn path_filter_sql(database: &str, table: &str, value: &str, exact: bool) -> String { - let normalized = if exact { - value.to_string() - } else { - value.replace('*', "%").replace('?', "_") - }; - let operator = if exact { "=" } else { "LIKE" }; - format!( - "SELECT * FROM {database}.{table}\nWHERE _file_ {operator} '{}'\nLIMIT 100", - sql_literal(&normalized) - ) -} +use crate::analysis::AnalysisWorkspace; +use crate::analysis_session::AnalysisScope; +use crate::model::QueryCatalog; #[component] -pub fn ToolsWorkspace( - catalog: Option, - mut selected_table: Signal, -) -> Element { - let mut sql_text = use_signal(String::new); - let mut applied_table = use_signal(String::new); - let mut path_filter = use_signal(String::new); - let mut path_match = use_signal(|| "like".to_string()); - let mut output = use_signal(|| None::>); - let mut busy = use_signal(|| false); - let database = catalog - .as_ref() - .map(|catalog| catalog.database.clone()) - .unwrap_or_else(|| "data".into()); - let selected = selected_table(); - let table = catalog - .as_ref() - .and_then(|catalog| catalog.tables.iter().find(|table| table.name == selected)) - .cloned(); - let effect_database = database.clone(); - use_effect(move || { - let table = selected_table(); - let key = format!("{effect_database}.{table}"); - if !table.is_empty() && applied_table() != key { - sql_text.set(format!("SELECT * FROM {effect_database}.{table} LIMIT 100")); - applied_table.set(key); - path_filter.set(String::new()); - output.set(None); - } - }); - rsx! { div { class: "tools-workspace", - div { class: "workspace-header", - div { class: "title-block", div { class: "breadcrumb", "pChronicle / Analyze / {database}" } h2 { "Directory query workspace" } div { class: "header-meta", if let Some(catalog) = &catalog { code { "{catalog.storage_path}" } } else { span { "Loading query catalog…" } } } } - } - div { class: "tools-grid", - aside { class: "schema-panel", - if let Some(catalog) = &catalog { - div { class: "schema-table-heading", div { span { "Virtual tables" } strong { "{catalog.tables.len()}" } } p { "Select a table to inspect its queryable columns." } } - nav { class: "schema-table-list", aria_label: "Queryable virtual tables", - for candidate in &catalog.tables { - button { class: if candidate.name == selected { "active" } else { "" }, aria_current: if candidate.name == selected { "true" } else { "false" }, onclick: { let name = candidate.name.clone(); move |_| selected_table.set(name.clone()) }, - div { code { "{database}.{candidate.name}" } span { "{candidate.fields.len()} fields" } } - p { "{candidate.grain}" } - } - } - } - } - if let Some(table) = &table { - div { class: "schema-panel-heading selected-schema", span { "Selected schema" } h3 { "{database}.{table.name}" } p { "{table.description}" } div { span { "Grain" } strong { "{table.grain}" } } } - div { class: "schema-field-heading", "Fields · {table.fields.len()}" } - div { class: "schema-field-list", for field in &table.fields { div { class: "schema-field", div { code { "{field.name}" } span { "{field.data_type}" } } p { "{field.description}" } } } } - } else { ToolEmpty { title: "Loading schema", detail: "Choose a virtual table from the catalog." } } - } - section { class: "tool-surface", - if catalog.is_none() { ToolEmpty { title: "Query catalog unavailable", detail: "The directory schema could not be loaded." } } - else { - div { class: "tool-heading", h3 { "Read-only SQL" } p { "Use qualified tables such as {database}.runs, {database}.steps, and {database}.tool_calls." } } - div { class: "path-filter-card", - div { strong { "Path filter" } span { "Uses the virtual _file_ column" } } - div { class: "path-filter-controls", - select { value: "{path_match}", aria_label: "Path match type", onchange: move |event| path_match.set(event.value()), option { value: "like", "Wildcard (LIKE)" } option { value: "exact", "Exact path" } } - input { value: "{path_filter}", placeholder: "cybergym_*.json or batch/%", aria_label: "Source path filter", oninput: move |event| path_filter.set(event.value()) } - button { class: "button", disabled: path_filter().trim().is_empty(), onclick: { let database = database.clone(); move |_| sql_text.set(path_filter_sql(&database, &selected_table(), path_filter().trim(), path_match() == "exact")) }, "Apply" } - button { class: "button", onclick: { let database = database.clone(); move |_| { path_filter.set(String::new()); sql_text.set(format!("SELECT * FROM {}.{} LIMIT 100", database, selected_table())); } }, "Clear" } - } - } - textarea { class: "sql-editor", value: "{sql_text}", oninput: move |event| sql_text.set(event.value()) } - div { class: "query-actions", - button { class: "button primary", disabled: busy(), onclick: move |_| { let query = sql_text(); busy.set(true); spawn(async move { output.set(Some(api::query_evidence_interactive(&query).await)); busy.set(false); }); }, if busy() { "Running…" } else { "Run query" } } - span { "SELECT, WITH, and EXPLAIN only · bounded structured results" } - } - div { class: "tool-output pc2-tool-result", div { span { "Output" } button { class: "icon-button", aria_label: "Clear output", onclick: move |_| output.set(None), "×" } } - if let Some(result) = output() { - match result { - Ok(evidence) => rsx! { DataTable { evidence, title: Some("SQL query result".into()) } }, - Err(message) => rsx! { div { class: "pc2-query-error", "{message}" } }, - } - } else { div { class: "pc2-query-placeholder", "Run the prepared query to load a bounded table preview." } } - } - } - } +pub fn ToolsWorkspace(catalog: Option, selected_table: Signal) -> Element { + let _selected_table = selected_table(); + let initial_scope = catalog.as_ref().map(AnalysisScope::from_catalog); + rsx! { + AnalysisWorkspace { + catalog, + initial_scope, + requested_session_id: None, + on_session_change: move |_session_id: String| {}, } - } } -} - -#[component] -fn ToolEmpty(title: &'static str, detail: &'static str) -> Element { - rsx! { div { class: "empty-state", div { class: "empty-icon", "◇" } strong { "{title}" } p { "{detail}" } } } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn path_filter_supports_shell_and_sql_wildcards() { - assert_eq!( - path_filter_sql("data", "steps", "batch/*.json", false), - "SELECT * FROM data.steps\nWHERE _file_ LIKE 'batch/%.json'\nLIMIT 100" - ); - assert!( - path_filter_sql("data", "runs", "it's.json", true).contains("_file_ = 'it''s.json'") - ); } } diff --git a/pchronicle-web/src/workspace.rs b/pchronicle-web/src/workspace.rs index 57b860b1..0ade01bb 100644 --- a/pchronicle-web/src/workspace.rs +++ b/pchronicle-web/src/workspace.rs @@ -3,24 +3,18 @@ use std::collections::BTreeMap; use dioxus::prelude::*; use wasm_bindgen::JsValue; -use crate::agent::{self, AgentAnswer, LlmConfig}; +use crate::agent::{self, ThreadMessage, ThreadRole}; use crate::api; +use crate::catalog::CatalogExplorer; use crate::chat_view::normalize_trace_view; use crate::components::{parse_rich_blocks, DataTable, RichBlock, TrajectoryView}; +use crate::llm; +use crate::llm_settings::LlmSettings; use crate::model::{ - DimensionAggregate, HistogramBucket, QueryCatalog, QueryDatasetSummary, RunAnalysis, - RunExplorerItem, RunPage, RunSummary, ToolAggregate, TurnDetail, TurnSummary, + CatalogTree, DimensionAggregate, HistogramBucket, QueryCatalog, QueryDatasetSummary, + RunAnalysis, RunExplorerItem, RunPage, RunSummary, ToolAggregate, TurnDetail, TurnSummary, }; -#[derive(Clone, Debug, PartialEq)] -struct ChatMessage { - user: bool, - text: String, - action: Option, - sql: Option, - truncated: bool, -} - #[derive(Clone, Debug, PartialEq)] struct WorkspaceNotice { title: String, @@ -75,6 +69,7 @@ struct RunFilters { sort: String, direction: String, path: String, + file: String, offset: usize, } @@ -103,12 +98,22 @@ pub fn App() -> Element { duplicate_event_ids: 0, status: "loading".into(), }); + let initial_analysis_session_id = url_param("analysis_session").unwrap_or_default(); + let initial_analysis_seed_scope = if initial_analysis_session_id.is_empty() { + web_sys::window() + .and_then(|window| window.location().search().ok()) + .and_then(|search| crate::analysis_session::scope_from_query(&search).ok()) + } else { + None + }; let initial_page = if initial_run.is_some() { "detail" - } else if url_param("page").as_deref() == Some("tools") { - "tools" } else { - "runs" + match url_param("page").as_deref() { + Some("tools") => "tools", + Some("runs") => "runs", + _ => "catalog", + } }; let mut page = use_signal(move || initial_page.to_string()); let runs = use_signal(|| None::); @@ -120,6 +125,11 @@ pub fn App() -> Element { let mut sort = use_signal(|| url_param("sort").unwrap_or_else(|| "session".into())); let mut direction = use_signal(|| url_param("direction").unwrap_or_else(|| "asc".into())); let mut run_path = use_signal(|| url_param("path").unwrap_or_default()); + let mut file_prefix = use_signal(|| url_param("file_prefix").unwrap_or_default()); + let mut catalog_dataset = use_signal(|| String::new()); + let mut catalog_prefix = use_signal(|| String::new()); + let catalog_tree = use_signal(|| None::); + let catalog_loading = use_signal(|| false); let mut offset = use_signal(|| 0usize); let mut error = use_signal(|| None::); @@ -141,6 +151,8 @@ pub fn App() -> Element { let mut catalog = use_signal(|| None::); let mut selected_table = use_signal(String::new); let mut copilot_open = use_signal(|| false); + let mut analysis_session_id = use_signal(move || initial_analysis_session_id); + let mut analysis_seed_scope = use_signal(move || initial_analysis_seed_scope); use_effect(move || { load_runs( @@ -151,6 +163,7 @@ pub fn App() -> Element { sort: sort(), direction: direction(), path: run_path(), + file: file_prefix(), offset: offset(), }, runs, @@ -159,6 +172,19 @@ pub fn App() -> Element { ); }); + use_effect(move || { + if page() != "catalog" { + return; + } + load_catalog_tree( + catalog_dataset(), + catalog_prefix(), + catalog_tree, + catalog_loading, + error, + ); + }); + use_effect(move || { if analysis().is_none() { if let Some(run) = selected_run() { @@ -188,6 +214,8 @@ pub fn App() -> Element { use_effect(move || { sync_workspace_url( &page(), + &analysis_session_id(), + analysis_seed_scope().is_some(), selected_run().as_ref(), &query(), &dataset_filter(), @@ -195,6 +223,7 @@ pub fn App() -> Element { &sort(), &direction(), &run_path(), + &file_prefix(), &detail_mode(), &trace_mode(), &source(), @@ -238,6 +267,7 @@ pub fn App() -> Element { a { class: "skip-link", href: "#pc2-main", "Skip to trajectory workspace" } nav { class: "rail", aria_label: "pChronicle workspace", div { class: "brand-mark", title: "pChronicle", "pC" } + RailButton { active: page() == "catalog", icon: "▣", label: "Data", onclick: move |_| { catalog_dataset.set(String::new()); catalog_prefix.set(String::new()); page.set("catalog".into()); } } RailButton { active: page() == "runs" || page() == "detail", icon: "◫", label: "Runs", onclick: move |_| page.set("runs".into()) } RailButton { active: page() == "tools", icon: "⌁", label: "Analyze", onclick: move |_| page.set("tools".into()) } div { class: "rail-spacer" } @@ -260,7 +290,34 @@ pub fn App() -> Element { } } match page().as_str() { - "tools" => rsx! { crate::tools::ToolsWorkspace { catalog: catalog(), selected_table } }, + "catalog" => rsx! { + CatalogExplorer { + tree: catalog_tree(), + loading: catalog_loading(), + on_open: move |(dataset, prefix): (String, String)| { + catalog_dataset.set(dataset); + catalog_prefix.set(prefix); + }, + on_runs: move |(dataset, prefix): (String, String)| { + dataset_filter.set(if dataset.is_empty() { "all".into() } else { dataset }); + file_prefix.set(prefix); + run_path.set(String::new()); + offset.set(0); + page.set("runs".into()); + }, + } + }, + "tools" => rsx! { + crate::analysis::AnalysisWorkspace { + catalog: catalog(), + initial_scope: analysis_seed_scope(), + requested_session_id: (!analysis_session_id().is_empty()).then(|| analysis_session_id()), + on_session_change: move |session_id: String| { + analysis_session_id.set(session_id); + analysis_seed_scope.set(None); + }, + } + }, "detail" => { let path_runs = runs().map(|page| page.path_index).unwrap_or_default(); let selected_path = analysis().map(|value| value.run.path).or_else(|| selected_run().map(|run| run.path)).unwrap_or_default(); @@ -301,6 +358,12 @@ pub fn App() -> Element { } }, on_open_copilot: move |_| copilot_open.set(true), + on_analyze: move |run: RunSummary| { + let Some(active_catalog) = catalog() else { return; }; + analysis_session_id.set(String::new()); + analysis_seed_scope.set(Some(run_analysis_scope(&active_catalog, run))); + page.set("tools".into()); + }, } } else { LoadingWorkspace { label: "Building trajectory evidence…" } } } } @@ -320,14 +383,16 @@ pub fn App() -> Element { sort: sort(), direction: direction(), path: run_path(), + file: file_prefix(), datasets: catalog().map(|value| value.datasets).unwrap_or_default(), dataset: dataset_filter(), on_query: move |value| query.set(value), - on_dataset: move |value| { dataset_filter.set(value); run_path.set(String::new()); offset.set(0); }, + on_dataset: move |value| { dataset_filter.set(value); run_path.set(String::new()); file_prefix.set(String::new()); offset.set(0); }, on_status: move |value| status.set(value), on_sort: move |value| sort.set(value), on_direction: move |value| direction.set(value), on_path: move |value| { run_path.set(value); offset.set(0); }, + on_file: move |value| { file_prefix.set(value); offset.set(0); }, on_refresh: move |_| { let filters = RunFilters { query: query(), @@ -336,6 +401,7 @@ pub fn App() -> Element { sort: sort(), direction: direction(), path: run_path(), + file: file_prefix(), offset: offset(), }; spawn(async move { @@ -409,6 +475,7 @@ fn load_runs( &filters.sort, &filters.direction, &filters.path, + &filters.file, filters.offset, ) .await @@ -420,6 +487,23 @@ fn load_runs( }); } +fn load_catalog_tree( + dataset: String, + prefix: String, + mut tree: Signal>, + mut loading: Signal, + mut error: Signal>, +) { + loading.set(true); + spawn(async move { + match api::explorer_tree(&dataset, &prefix).await { + Ok(value) => tree.set(Some(value)), + Err(message) => error.set(Some(workspace_notice(message))), + } + loading.set(false); + }); +} + fn load_workspace( run: RunSummary, mut analysis: Signal>, @@ -589,12 +673,14 @@ fn RunsExplorer( sort: String, direction: String, path: String, + file: String, on_query: EventHandler, on_dataset: EventHandler, on_status: EventHandler, on_sort: EventHandler, on_direction: EventHandler, on_path: EventHandler, + on_file: EventHandler, on_refresh: EventHandler, on_page: EventHandler, on_select: EventHandler, @@ -620,6 +706,7 @@ fn RunsExplorer( select { value: "{sort}", aria_label: "Sort runs", onchange: move |event| on_sort.call(event.value()), option { value: "session", "Session" } option { value: "events", "Events" } option { value: "status", "Status" } option { value: "agent", "Agent" } } button { class: "pc2-sort", aria_label: "Toggle sort direction", onclick: move |_| on_direction.call(if direction == "asc" { "desc".into() } else { "asc".into() }), if direction == "asc" { "↑ Asc" } else { "↓ Desc" } } if !path.is_empty() { button { class: "pc2-path-filter", title: "{path}", onclick: move |_| on_path.call(String::new()), "⌁ {short(&path, 24)} ×" } } + if !file.is_empty() { button { class: "pc2-path-filter", title: "{file}", onclick: move |_| on_file.call(String::new()), "_file_ {short(&file, 24)} ×" } } span { class: "pc2-result-count", "{total} runs" } } div { class: "pc2-table-wrap", @@ -699,6 +786,7 @@ fn RunDetailWorkspace( on_apply_filter: EventHandler<()>, on_turn: EventHandler, on_open_copilot: EventHandler, + on_analyze: EventHandler, ) -> Element { let chats_active = view == "chats"; let steps_active = view == "steps"; @@ -709,9 +797,20 @@ fn RunDetailWorkspace( section { class: "pc2-detail", header { class: "pc2-detail-head", div { class: "pc2-detail-title", button { class: "pc2-back", onclick: on_back, "← Runs" } div { p { "{run.agent_id}" } h1 { title: "{run.session_id}", "{run.session_id}" } div { StatusBadge { value: run.status.clone() } if let Some(root) = &run.root_session_id { code { "root {short(root, 24)}" } } } } } - div { class: "pc2-head-actions", button { class: "button primary", onclick: on_open_copilot, "◇ Ask Copilot" } a { class: "button", href: "/api/export/otlp?{run.query()}", "OTLP" } } + div { class: "pc2-head-actions", + button { class: "button primary", onclick: on_open_copilot, "◇ Ask Copilot" } + button { class: "button", onclick: { let run = run.clone(); move |_| on_analyze.call(run.clone()) }, "Analyze this run" } + a { class: "button", href: "/api/export/otlp?{run.query()}", "OTLP" } + } } MetricsStrip { analysis: analysis.clone() } + if detail_mode == "trace" { + CompactOverviewStrip { + analysis: analysis.clone(), + turns: turns.clone(), + on_open_analysis: move |_| on_detail_mode.call("analysis".into()), + } + } nav { class: "pc2-detail-tabs", aria_label: "Trajectory detail view", button { class: if detail_mode == "trace" { "active" } else { "" }, onclick: move |_| on_detail_mode.call("trace".into()), "Trace" } button { class: if detail_mode == "analysis" { "active" } else { "" }, onclick: move |_| on_detail_mode.call("analysis".into()), "Analysis" } @@ -767,6 +866,80 @@ fn Metric(label: String, value: String, detail: String) -> Element { rsx! { div { class: "pc2-metric", span { "{label}" } strong { "{value}" } small { "{detail}" } } } } +#[component] +fn CompactOverviewStrip( + analysis: RunAnalysis, + turns: Vec, + on_open_analysis: EventHandler, +) -> Element { + let sources = compact_mix(&analysis.source_breakdown, 3); + let kinds = compact_mix(&analysis.kind_breakdown, 3); + let models = compact_mix(&analysis.model_breakdown, 3); + let coverage = coverage_points( + analysis.latency_ms.sample_count, + analysis.latency_ms.total_count, + analysis.ttft_ms.sample_count, + analysis.ttft_ms.total_count, + turns.iter().filter(|turn| turn.timestamp.is_some()).count(), + analysis.turn_count, + turns + .iter() + .filter(|turn| turn.total_tokens.is_some()) + .count(), + analysis.turn_count, + ); + rsx! { div { class: "pc2-trace-overview", + CompactMixCard { title: "Composition", tone: "blue", segments: sources, onclick: on_open_analysis } + CompactMixCard { title: "Behavior", tone: "violet", segments: kinds, onclick: on_open_analysis } + CompactMixCard { title: "Models", tone: "green", segments: models, onclick: on_open_analysis } + CompactCoverageCard { points: coverage, onclick: on_open_analysis } + } } +} + +#[component] +fn CompactMixCard( + title: &'static str, + tone: &'static str, + segments: Vec, + onclick: EventHandler, +) -> Element { + let legend = segments.clone(); + let title_attr = mix_title(&segments); + rsx! { button { class: "pc2-trace-overview-card", r#type: "button", aria_label: "Open Analysis overview · {title}", title: "Open Analysis for the full chart", onclick, + span { class: "pc2-trace-overview-title", "{title}" } + if segments.is_empty() { + span { class: "pc2-trace-overview-empty", "No captured values" } + } else { + div { class: "pc2-mix-track {tone}", title: "{title_attr}", + for segment in segments { + i { style: format!("width:{:.2}%", segment.share), title: format!("{} {}", segment.name, segment.count) } + } + } + div { class: "pc2-mix-legend", + for (index, segment) in legend.into_iter().enumerate() { + span { class: "pc2-mix-key {tone} n{index}", title: "{segment.name} {segment.count}", "{short(&segment.name, 16)} {segment.count}" } + } + } + } + } } +} + +#[component] +fn CompactCoverageCard(points: Vec, onclick: EventHandler) -> Element { + rsx! { button { class: "pc2-trace-overview-card", r#type: "button", aria_label: "Open Analysis overview · Coverage", title: "Open Analysis for the full chart", onclick, + span { class: "pc2-trace-overview-title", "Coverage" } + div { class: "pc2-mini-coverage", + for point in points { + div { class: "pc2-mini-coverage-row", + span { "{point.label}" } + code { "{point.observed}/{point.total}" } + span { class: "pc2-coverage-track", i { style: format!("width:{:.2}%", percent(point.observed as f64, point.total as f64)) } } + } + } + } + } } +} + #[component] fn AnalysisWorkspace( analysis: RunAnalysis, @@ -1053,67 +1226,152 @@ fn CopilotPanel( on_close: EventHandler, on_turn: EventHandler, ) -> Element { - let mut messages = use_signal(Vec::::new); + let initial_run = run.clone(); + let mut thread = use_signal(move || agent::load_thread(&initial_run)); let mut input = use_signal(String::new); let mut busy = use_signal(|| false); - let mut include_full = use_signal(|| false); let mut settings = use_signal(|| false); - let mut config = use_signal(agent::load_config); + let mut config = use_signal(llm::load_config); + let mut step = use_signal(|| "Working…".to_string()); + let submit_run = run.clone(); + let submit_analysis = analysis.clone(); + let focused_turn_id = selected.as_ref().map(|detail| detail.summary.id); + let update_step = Callback::new(move |next: String| step.set(next)); + let submit_copilot = Callback::new(move |()| { + let question = input().trim().to_string(); + if question.is_empty() || busy() { + return; + } + let config_value = config(); + if !config_value.is_configured() { + const CONFIGURE_MESSAGE: &str = + "Configure an OpenAI-compatible model in Settings before asking Copilot."; + settings.set(true); + let mut next_thread = thread(); + let already_shown = next_thread.messages.last().is_some_and(|message| { + message.role == ThreadRole::Assistant && message.text == CONFIGURE_MESSAGE + }); + if !already_shown { + next_thread.messages.push(ThreadMessage { + role: ThreadRole::Assistant, + text: CONFIGURE_MESSAGE.into(), + tool_calls: None, + tool_call_id: None, + tool_name: None, + sql: None, + truncated: false, + }); + agent::save_thread(&submit_run, &next_thread); + thread.set(next_thread); + } + return; + } + + let prior_thread = thread(); + let mut pending_thread = prior_thread.clone(); + pending_thread.messages.push(ThreadMessage { + role: ThreadRole::User, + text: question.clone(), + tool_calls: None, + tool_call_id: None, + tool_name: None, + sql: None, + truncated: false, + }); + thread.set(pending_thread.clone()); + input.set(String::new()); + step.set("Working…".into()); + busy.set(true); + let run_value = submit_run.clone(); + let analysis_value = submit_analysis.clone(); + spawn(async move { + let report_step = |next: &str| update_step.call(next.to_string()); + let result = agent::answer(agent::AnswerRequest { + config: &config_value, + user_message: &question, + run: &run_value, + analysis: &analysis_value, + focused_turn_id, + thread: prior_thread, + on_step: Some(&report_step), + }) + .await; + match result { + Ok(answer) => { + agent::save_thread(&run_value, &answer.thread); + thread.set(answer.thread); + } + Err(message) => { + pending_thread.messages.push(ThreadMessage { + role: ThreadRole::Assistant, + text: format!("Unable to complete analysis: {message}"), + tool_calls: None, + tool_call_id: None, + tool_name: None, + sql: None, + truncated: false, + }); + agent::save_thread(&run_value, &pending_thread); + thread.set(pending_thread); + } + } + busy.set(false); + }); + }); rsx! { aside { class: "pc2-copilot", div { class: "pc2-copilot-head", div { strong { "Trajectory Copilot" } span { "Read-only · minimal evidence" } } div { button { aria_label: "LLM settings", onclick: move |_| settings.set(true), "⚙" } button { aria_label: "Close Copilot", onclick: on_close, "×" } } } - div { class: "pc2-context-card", div { span { "Grounded in" } strong { "{short(&run.session_id, 30)}" } } div { span { "Evidence" } strong { "{analysis.turn_count} turns · {analysis.error_count} explicit errors" } } label { input { r#type: "checkbox", checked: include_full(), disabled: selected.is_none(), onchange: move |event| include_full.set(event.checked()) } "Include selected turn content once (max 64 KiB)" } } - div { class: "pc2-skill-chips", for skill in agent::skill_ids() { button { disabled: busy(), onclick: move |_| input.set(format!("/{skill}")), "{skill_label(skill)}" } } } + div { class: "pc2-context-card", div { span { "Grounded in" } strong { "{short(&run.session_id, 30)}" } } div { span { "Evidence" } strong { "{analysis.turn_count} turns · {analysis.error_count} explicit errors" } } } div { class: "pc2-chat", - if messages().is_empty() { div { class: "pc2-chat-welcome", span { "◇" } strong { "Ask Copilot" } p { "Copilot can summarize this run, locate explicit failures, rank latency, inspect tool usage, or compare cohorts." } } } - for (index, message) in messages().iter().enumerate() { ChatBubble { key: "message-{index}", message: message.clone(), turns: turns.clone(), on_turn } } - if busy() { div { class: "pc2-chat-working", span { class: "spinner" } "Selecting one read-only analysis action…" } } + if thread().messages.is_empty() { + div { class: "pc2-chat-welcome", span { "◇" } strong { "Ask Copilot" } + if config().is_configured() { + p { "Copilot can inspect this analysis, examine a turn, or run read-only SQL." } + } else { + p { "Configure an OpenAI-compatible model in Settings before asking Copilot." } + } + } + } + for (index, message) in thread().messages.iter().enumerate() { + if !(message.role == ThreadRole::Assistant + && message.text.trim().is_empty() + && message.tool_calls.as_ref().is_some_and(|calls| !calls.is_empty())) + { + ChatBubble { key: "message-{index}", message: message.clone(), turns: turns.clone(), on_turn } + } + } + if busy() { div { class: "pc2-chat-working", span { class: "spinner" } "{step}" } } } - form { class: "pc2-composer", onsubmit: move |event| { - event.prevent_default(); - let question = input().trim().to_string(); - if question.is_empty() || busy() { return; } - messages.write().push(ChatMessage { user: true, text: question.clone(), action: None, sql: None, truncated: false }); - input.set(String::new()); - busy.set(true); - let config_value = config(); - let run_value = run.clone(); - let analysis_value = analysis.clone(); - let turns_value = turns.clone(); - let selected_value = selected.clone(); - let include_value = include_full(); - spawn(async move { - let result = agent::answer(agent::AnswerRequest { - config: &config_value, - user_message: &question, - run: &run_value, - analysis: &analysis_value, - turns: &turns_value, - selected: selected_value.as_ref(), - include_full_turn: include_value, - }).await; - let message = match result { - Ok(AgentAnswer { text, action, sql, truncated }) => ChatMessage { user: false, text, action: Some(action), sql, truncated }, - Err(message) => ChatMessage { user: false, text: format!("Unable to complete analysis: {message}"), action: Some("error".into()), sql: None, truncated: false }, - }; - messages.write().push(message); - include_full.set(false); - busy.set(false); - }); - }, textarea { value: "{input}", rows: "3", placeholder: "Ask Copilot about this trajectory…", oninput: move |event| input.set(event.value()), onkeydown: move |event| if event.key() == Key::Enter && !event.modifiers().shift() { event.prevent_default(); }, disabled: busy() } button { class: "button primary", disabled: busy() || input().trim().is_empty(), "Ask Copilot" } } - if settings() { LlmSettings { config: config(), on_close: move |_| settings.set(false), on_save: move |value| { agent::save_config(&value); config.set(value); settings.set(false); } } } + form { class: "pc2-composer", onsubmit: move |event| { event.prevent_default(); submit_copilot.call(()); }, + textarea { value: "{input}", rows: "3", placeholder: "Ask Copilot about this trajectory…", oninput: move |event| input.set(event.value()), onkeydown: move |event| if event.key() == Key::Enter && !event.modifiers().shift() { event.prevent_default(); submit_copilot.call(()); }, disabled: busy() } + button { class: "button primary", disabled: busy() || input().trim().is_empty(), "Ask Copilot" } + } + if settings() { LlmSettings { config: config(), on_close: move |_| settings.set(false), on_save: move |value| { llm::save_config(&value); config.set(value); settings.set(false); } } } } } } #[component] fn ChatBubble( - message: ChatMessage, + message: ThreadMessage, turns: Vec, on_turn: EventHandler, ) -> Element { + if message.role == ThreadRole::Tool { + let action = message + .tool_name + .clone() + .unwrap_or_else(|| "tool".to_string()); + return rsx! { + div { class: "pc2-message tool", + span { class: "pc2-action-label", "{action}" } + if !message.text.trim().is_empty() { + details { summary { "Tool evidence" } pre { "{message.text}" } } + } + } + }; + } let refs = turn_references(&message.text); let blocks = parse_rich_blocks(&message.text); - rsx! { div { class: if message.user { "pc2-message user" } else { "pc2-message assistant" }, - if let Some(action) = &message.action { span { class: "pc2-action-label", "{action}" } } + rsx! { div { class: if message.role == ThreadRole::User { "pc2-message user" } else { "pc2-message assistant" }, for (index, block) in blocks.into_iter().enumerate() { match block { RichBlock::Text(text) => rsx! { MessageText { key: "text-{index}", text } }, @@ -1139,18 +1397,6 @@ fn MessageText(text: String) -> Element { rsx! { div { class: "pc2-message-text", for line in text.lines() { if let Some(item) = line.strip_prefix("- ") { div { class: "pc2-bullet", span { "•" } p { "{clean_markdown(item)}" } } } else if !line.trim().is_empty() { p { "{clean_markdown(line)}" } } } } } } -#[component] -fn LlmSettings( - config: LlmConfig, - on_close: EventHandler, - on_save: EventHandler, -) -> Element { - let mut api_base = use_signal(|| config.api_base.clone()); - let mut api_key = use_signal(|| config.api_key.clone()); - let mut model = use_signal(|| config.model.clone()); - rsx! { div { class: "pc2-modal-backdrop high", section { class: "pc2-settings", role: "dialog", aria_modal: "true", header { div { p { class: "eyebrow", "Browser BYOK" } h2 { "Copilot model" } } button { onclick: on_close, "×" } } p { class: "pc2-settings-note", "The key stays in this browser's localStorage. Selected evidence is sent directly to this OpenAI-compatible endpoint; pChronicle server never receives the key." } div { class: "pc2-form", label { span { "API base" } input { value: "{api_base}", oninput: move |event| api_base.set(event.value()) } } label { span { "API key" } input { r#type: "password", value: "{api_key}", oninput: move |event| api_key.set(event.value()) } } label { span { "Model" } input { value: "{model}", oninput: move |event| model.set(event.value()) } } } footer { button { class: "button", onclick: on_close, "Cancel" } button { class: "button primary", onclick: move |_| on_save.call(LlmConfig { api_base: api_base(), api_key: api_key(), model: model() }), "Save locally" } } } } } -} - fn short(value: &str, limit: usize) -> String { if value.chars().count() <= limit { value.into() @@ -1177,6 +1423,101 @@ fn optional_u64(value: Option) -> String { .map(|value| value.to_string()) .unwrap_or_else(|| "—".into()) } + +#[derive(Clone, Debug, PartialEq)] +struct MixSegment { + name: String, + count: usize, + share: f64, +} + +#[derive(Clone, Debug, PartialEq)] +struct CoveragePoint { + label: &'static str, + observed: usize, + total: usize, +} + +fn compact_mix(items: &[DimensionAggregate], limit: usize) -> Vec { + let total: usize = items.iter().map(|item| item.turn_count).sum(); + if total == 0 || limit == 0 { + return Vec::new(); + } + let mut ranked = items.to_vec(); + ranked.sort_by(|left, right| right.turn_count.cmp(&left.turn_count)); + let mut segments: Vec = ranked + .iter() + .take(limit) + .map(|item| MixSegment { + name: item.name.clone(), + count: item.turn_count, + share: percent(item.turn_count as f64, total as f64), + }) + .collect(); + let rest: usize = ranked.iter().skip(limit).map(|item| item.turn_count).sum(); + if rest > 0 { + segments.push(MixSegment { + name: "other".into(), + count: rest, + share: percent(rest as f64, total as f64), + }); + } + if !segments.is_empty() { + let used: f64 = segments + .iter() + .rev() + .skip(1) + .map(|segment| segment.share) + .sum(); + if let Some(last) = segments.last_mut() { + last.share = (100.0 - used).clamp(0.0, 100.0); + } + } + segments +} + +fn coverage_points( + latency_observed: usize, + latency_total: usize, + ttft_observed: usize, + ttft_total: usize, + timestamp_observed: usize, + timestamp_total: usize, + token_observed: usize, + token_total: usize, +) -> Vec { + vec![ + CoveragePoint { + label: "Latency", + observed: latency_observed, + total: latency_total, + }, + CoveragePoint { + label: "TTFT", + observed: ttft_observed, + total: ttft_total, + }, + CoveragePoint { + label: "Timestamp", + observed: timestamp_observed, + total: timestamp_total, + }, + CoveragePoint { + label: "Tokens", + observed: token_observed, + total: token_total, + }, + ] +} + +fn mix_title(segments: &[MixSegment]) -> String { + segments + .iter() + .map(|segment| format!("{} {}", segment.name, segment.count)) + .collect::>() + .join(" · ") +} + fn percent(value: f64, total: f64) -> f64 { if !value.is_finite() || !total.is_finite() || total <= 0.0 { 0.0 @@ -1214,10 +1555,6 @@ fn metric_value(turn: &TurnSummary, metric: &str) -> String { fn clean_markdown(value: &str) -> String { value.replace("**", "").replace('`', "") } -fn skill_label(value: &str) -> String { - value.replace('_', " ") -} - fn turn_references(value: &str) -> Vec { let mut ids = Vec::new(); let mut rest = value; @@ -1243,9 +1580,37 @@ fn url_param(name: &str) -> Option { .get(name) } +fn analyze_workspace_url(session_id: &str) -> String { + if session_id.is_empty() { + "/?page=tools".into() + } else { + format!( + "/?page=tools&analysis_session={}", + urlencoding::encode(session_id) + ) + } +} + +fn analysis_url_sync_target(session_id: &str, seed_scope_pending: bool) -> Option { + if seed_scope_pending && session_id.is_empty() { + None + } else { + Some(analyze_workspace_url(session_id)) + } +} + +fn run_analysis_scope( + catalog: &QueryCatalog, + run: RunSummary, +) -> crate::analysis_session::AnalysisScope { + crate::analysis_session::AnalysisScope::from_run(catalog, run) +} + #[allow(clippy::too_many_arguments)] fn sync_workspace_url( page: &str, + analysis_session_id: &str, + analysis_seed_scope_pending: bool, run: Option<&RunSummary>, query: &str, dataset_filter: &str, @@ -1253,6 +1618,7 @@ fn sync_workspace_url( sort: &str, direction: &str, path: &str, + file_prefix: &str, workspace: &str, view: &str, source: &str, @@ -1262,6 +1628,16 @@ fn sync_workspace_url( let Some(window) = web_sys::window() else { return; }; + if page == "tools" { + let Some(url) = analysis_url_sync_target(analysis_session_id, analysis_seed_scope_pending) + else { + return; + }; + let _ = window + .history() + .and_then(|history| history.replace_state_with_url(&JsValue::NULL, "", Some(&url))); + return; + } let mut params = vec![format!("page={}", urlencoding::encode(page))]; if let Some(run) = run.filter(|_| page == "detail") { params.push(format!("dataset={}", urlencoding::encode(&run.dataset))); @@ -1302,6 +1678,9 @@ fn sync_workspace_url( if !path.is_empty() { params.push(format!("path={}", urlencoding::encode(path))); } + if !file_prefix.is_empty() { + params.push(format!("file_prefix={}", urlencoding::encode(file_prefix))); + } } let url = format!("/?{}", params.join("&")); let _ = window @@ -1355,6 +1734,117 @@ mod tests { assert_eq!(percent(150.0, 100.0), 100.0); } + #[test] + fn analyze_workspace_url_retains_only_the_session_id() { + assert_eq!( + analyze_workspace_url("analysis-123"), + "/?page=tools&analysis_session=analysis-123" + ); + assert_eq!(analyze_workspace_url(""), "/?page=tools"); + } + + #[test] + fn bootstrap_scope_url_is_not_replaced_until_the_session_is_persisted() { + assert_eq!(analysis_url_sync_target("", true), None); + assert_eq!( + analysis_url_sync_target("analysis-123", false), + Some("/?page=tools&analysis_session=analysis-123".into()) + ); + } + + #[test] + fn analyze_this_run_keeps_full_catalog_and_run_coordinates() { + let run = run_at("agent/root/session-a"); + let catalog = QueryCatalog { + snapshot_id: "snapshot-a".into(), + read_only: true, + database: "default".into(), + storage_path: "/tmp/evidence".into(), + path_column: "_file_".into(), + datasets: Vec::new(), + tables: Vec::new(), + }; + + let scope = run_analysis_scope(&catalog, run.clone()); + + assert_eq!(scope.database, "default"); + assert_eq!(scope.storage_path, "/tmp/evidence"); + assert_eq!(scope.snapshot_id, "snapshot-a"); + assert_eq!( + scope.items, + vec![crate::analysis_session::AnalysisScopeItem::Run { run }] + ); + } + + fn dim(name: &str, count: usize) -> DimensionAggregate { + DimensionAggregate { + name: name.into(), + turn_count: count, + error_count: 0, + latency_sample_count: 0, + average_latency_ms: None, + total_tokens: None, + } + } + + #[test] + fn compact_mix_keeps_small_breakdowns_intact() { + let segments = compact_mix(&[dim("user", 43), dim("agent", 42), dim("system", 1)], 3); + assert_eq!( + segments + .iter() + .map(|segment| (segment.name.as_str(), segment.count)) + .collect::>(), + vec![("user", 43), ("agent", 42), ("system", 1)] + ); + let share: f64 = segments.iter().map(|segment| segment.share).sum(); + assert!((share - 100.0).abs() < 0.01); + } + + #[test] + fn compact_mix_folds_the_tail_into_other() { + let segments = compact_mix( + &[ + dim("a", 10), + dim("b", 8), + dim("c", 6), + dim("d", 3), + dim("e", 1), + ], + 3, + ); + assert_eq!( + segments + .iter() + .map(|segment| (segment.name.as_str(), segment.count)) + .collect::>(), + vec![("a", 10), ("b", 8), ("c", 6), ("other", 4)] + ); + } + + #[test] + fn compact_mix_ignores_empty_breakdowns() { + assert!(compact_mix(&[], 3).is_empty()); + assert!(compact_mix(&[dim("user", 0)], 3).is_empty()); + } + + #[test] + fn coverage_points_use_observed_over_total() { + let points = coverage_points(42, 86, 8, 86, 84, 86, 42, 86); + assert_eq!( + points + .iter() + .map(|point| (point.label, point.observed, point.total)) + .collect::>(), + vec![ + ("Latency", 42, 86), + ("TTFT", 8, 86), + ("Timestamp", 84, 86), + ("Tokens", 42, 86), + ] + ); + } + #[test] fn evidence_notice_keeps_serde_details_collapsed() { let notice = evidence_notice( diff --git a/pchronicle-web/tests/fixtures/mock-openai.mjs b/pchronicle-web/tests/fixtures/mock-openai.mjs new file mode 100644 index 00000000..f5c48aff --- /dev/null +++ b/pchronicle-web/tests/fixtures/mock-openai.mjs @@ -0,0 +1,47 @@ +import http from "node:http"; + +const plan = { + intent_summary: "Compare run outcomes by tool", + scope_summary: "Current visible analysis scope", + filters: [], + groupings: ["status", "tool_name"], + measures: ["run count", "average latency", "error rate"], + expected_columns: ["status", "tool_name", "avg_latency_ms", "error_rate", "run_count"], + suggested_view: "distribution", + sql: "SELECT 'success' AS status, 'read_file' AS tool_name, 284.0 AS avg_latency_ms, 0.0 AS error_rate, 48 AS run_count UNION ALL SELECT 'failed', 'shell', 912.0, 0.25, 12 UNION ALL SELECT 'success', 'shell', 521.0, 0.05, 31", + warnings: [] +}; + +const interpretation = { + observations: ["The returned rows contain more than one status and tool group."], + inferences: ["Tool mix may help explain the observed outcome difference."], + limitations: ["This interpretation is limited to the returned query evidence."], + follow_ups: ["Only compare failed runs"], + references: [] +}; + +const server = http.createServer((request, response) => { + response.setHeader("Access-Control-Allow-Origin", "*"); + response.setHeader("Access-Control-Allow-Headers", "authorization,content-type"); + response.setHeader("Access-Control-Allow-Methods", "POST,OPTIONS"); + if (request.method === "OPTIONS") { + response.writeHead(204); + response.end(); + return; + } + let raw = ""; + request.on("data", chunk => { raw += chunk; }); + request.on("end", () => { + const body = JSON.parse(raw || "{}"); + const system = body.messages?.[0]?.content || ""; + const payload = system.includes("AnalysisInterpretation") ? interpretation : plan; + response.writeHead(200, { "Content-Type": "application/json" }); + response.end(JSON.stringify({ + choices: [{ message: { role: "assistant", content: JSON.stringify(payload) } }] + })); + }); +}); + +server.listen(9988, "127.0.0.1", () => { + process.stdout.write("mock-openai http://127.0.0.1:9988/v1\n"); +});