From 7281489c4243842e62b80bc395698eb5df170325 Mon Sep 17 00:00:00 2001 From: Attila Laszlo Nagy Date: Thu, 30 Jul 2026 15:46:56 +0200 Subject: [PATCH 1/8] MCP: preserve runtime bucket and tracker identity Persist the resolved machine id and reuse it when init renders login-time tracking, keeping activation and edge stream names identical. Report the explicit server bucket from synty_status even when a supervised MCP pod has no workstation config. The default 304-scenario suite and the 325-scenario cloud/HTTP/Athena matrix pass. --- README.md | 4 +++- docs/design.md | 4 +++- src/config.rs | 4 ++++ src/init.rs | 15 ++++++++++++++- src/mcp.rs | 30 +++++++++++++++++++++++++++++- src/track.rs | 24 +++++++++++++++++++++++- src/view.rs | 10 +++++++++- 7 files changed, 85 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index c45bed9..9fcf369 100644 --- a/README.md +++ b/README.md @@ -173,7 +173,9 @@ The bucket is the durable shared backplane; an S3 deployment may optionally add Glue catalog metadata and a bounded Athena workgroup for remote trace queries. There is still no migration, crawler, build server, or coordination service. Each machine writes a stable `edge--` stream, so -writers do not overwrite one another. Local readers and builders pull every +writers do not overwrite one another. `init` persists the resolved machine id +and writes that same id into the login-time tracker, activation marker, and +stream names. Local readers and builders pull every stream plus the latest published read-model. MCP-only readers pull the semantic index and compact analysis projection. With `--athena-workgroup`, their trace tools query time/stream-pruned raw event rows directly and do not download diff --git a/docs/design.md b/docs/design.md index b08f4b3..1f130b7 100644 --- a/docs/design.md +++ b/docs/design.md @@ -328,7 +328,9 @@ the watcher. `--capture-since` persists an absolute event boundary and `--upload-interval` sets the network batching cadence. Optional `--campaign` / `--role` persist campaign stamps used by later track/import runs. `--capture-repo`, `--upload-redaction`, and `--mcp-redaction` persist the -corresponding privacy policy. A systemd +corresponding privacy policy. The resolved `--machine` identity is persisted +and reused by autostart, so activation and event streams cannot silently fall +back to `local`. A systemd user service starts at boot on a headless developer VM when the administrator enables lingering for that user (`loginctl enable-linger`). The state shows on `status` and the TUI footer (`◐ local`, accent → `✓ diff --git a/src/config.rs b/src/config.rs index 896eac4..251cb11 100644 --- a/src/config.rs +++ b/src/config.rs @@ -34,6 +34,10 @@ pub struct Config { /// defaults to it; an explicit --bucket flag still wins. #[serde(default, skip_serializing_if = "Option::is_none")] pub bucket: Option, + /// Resolved machine id used by the login-time tracker. Persisting it keeps + /// the autostart unit aligned with the activation marker and stream names. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub machine: Option, /// Named AWS shared-config profile used by the S3 credential chain. For an /// unattended workstation this should use credential_process (including /// IAM Roles Anywhere); ordinary shared credentials also work. Empty means diff --git a/src/init.rs b/src/init.rs index 377cecd..f0bbd15 100644 --- a/src/init.rs +++ b/src/init.rs @@ -42,6 +42,7 @@ pub fn run(opts: Opts) -> Result<()> { no_build, no_autostart, } = opts; + let machine = identity::resolve_machine(&machine); let mut cfg = config::load(); // 1. Bucket: setting it is the local→bucket switch; absent → stay local @@ -73,6 +74,7 @@ pub fn run(opts: Opts) -> Result<()> { if let Some(role) = role.filter(|value| !value.is_empty()) { cfg.campaign_role = Some(role); } + cfg.machine = Some(machine.clone()); // 2. GitHub identity — best-effort, no prompts. With a token, pin the login // (so sessions merge with the person's PRs) and default the backfill org @@ -114,7 +116,7 @@ pub fn run(opts: Opts) -> Result<()> { if no_autostart { eprintln!("init: autostart skipped — run `synty track --watch` under your supervisor"); } else { - track::autostart_set(true).context( + track::autostart_set_for_machine(true, Some(&machine)).context( "enable login-time tracker (configuration was saved, but initialization is not complete)", )?; eprintln!("init: login-time tracker enabled"); @@ -165,4 +167,15 @@ mod tests { assert!(root.join("members/dev-b/activation.json").is_file()); let _ = std::fs::remove_dir_all(&root); } + + #[test] + fn explicit_machine_is_resolved_once_for_activation_and_tracking() { + let requested = "runner/7"; + let resolved = identity::resolve_machine(requested); + assert_eq!(resolved, "runner-7"); + assert_eq!( + track::autostart_machine(Some(&resolved), Some("stale-machine")), + "runner-7" + ); + } } diff --git a/src/mcp.rs b/src/mcp.rs index bf57cba..1be5a86 100644 --- a/src/mcp.rs +++ b/src/mcp.rs @@ -193,6 +193,7 @@ pub(crate) struct Server { scope: crate::policy::ReadScope, redaction: crate::redact::Profile, allow_repo_paths: bool, + bucket: Option, trace: TraceBackend, /// The build the engine serves + encoder + index — loaded on the first /// search call, kept warm, reopened when the pointer moves. @@ -216,6 +217,7 @@ impl Server { scope, redaction, allow_repo_paths, + bucket, trace, engine: None, } @@ -264,7 +266,9 @@ impl Server { "synty_related" => self.related(a), "synty_topics" => topics_text(a, &self.scope), "synty_recent" => recent_text(a, &self.scope), - "synty_status" => view::status().map(|s| view::status_md(&s)), + "synty_status" => { + view::status_for_bucket(self.bucket.as_deref()).map(|s| view::status_md(&s)) + } "synty_stats" => view::stats(bounded_positive(a, "weeks", 4, 52)).map(|s| view::stats_md(&s)), "synty_tool" => { let name = a["name"].as_str().unwrap_or(""); @@ -753,6 +757,30 @@ mod tests { assert!(called["error"]["message"].as_str().unwrap().contains("restricted")); } + #[test] + fn status_reports_the_server_bucket_without_local_config() { + let mut server = Server::new( + "m".into(), + crate::policy::McpRole::Operator, + crate::policy::ReadScope::default(), + crate::redact::Profile::Off, + true, + Some("s3://team-synty".into()), + None, + ); + let response = server + .handle(&json!({"jsonrpc":"2.0","id":1,"method":"tools/call", + "params":{"name":"synty_status","arguments":{}}})) + .unwrap(); + assert_eq!(response["result"]["isError"], false); + assert!( + response["result"]["content"][0]["text"] + .as_str() + .unwrap() + .contains("✓ on the team — s3://team-synty") + ); + } + #[test] fn remote_related_schema_requires_context_and_hides_server_paths() { let tools = tool_defs( diff --git a/src/track.rs b/src/track.rs index f6155c3..a4c96e6 100644 --- a/src/track.rs +++ b/src/track.rs @@ -565,10 +565,18 @@ pub fn autostart_unit() -> Option<(String, &'static str)> { /// Turn login-time autostart on or off and verify the service manager accepted /// it. A failed bootstrap is an error, not a green status badge. pub fn autostart_set(on: bool) -> Result<()> { + autostart_set_for_machine(on, None) +} + +/// Install the tracker with the exact machine identity selected by `init`. +/// TUI toggles omit the override and reuse the persisted identity. +pub(crate) fn autostart_set_for_machine(on: bool, machine: Option<&str>) -> Result<()> { let (path, kind) = autostart_unit().ok_or_else(|| anyhow!("autostart unsupported on this platform"))?; if on { - write_unit(kind, &path, "corpus/local", "local")?; + let cfg = crate::config::load(); + let machine = autostart_machine(machine, cfg.machine.as_deref()); + write_unit(kind, &path, "corpus/local", &machine)?; loader(kind, &path, true)?; } else { loader(kind, &path, false)?; @@ -577,6 +585,10 @@ pub fn autostart_set(on: bool) -> Result<()> { Ok(()) } +pub(crate) fn autostart_machine(explicit: Option<&str>, configured: Option<&str>) -> String { + explicit.or(configured).unwrap_or("local").to_string() +} + /// Restart the login-time tracker so a freshly installed binary takes over /// (called by `synty upgrade`). Ok(false) when no unit is installed — nothing /// to restart. @@ -1036,6 +1048,16 @@ mod tests { ); } + #[test] + fn autostart_prefers_init_machine_then_persisted_machine() { + assert_eq!( + autostart_machine(Some("eval-1"), Some("workstation-2")), + "eval-1" + ); + assert_eq!(autostart_machine(None, Some("workstation-2")), "workstation-2"); + assert_eq!(autostart_machine(None, None), "local"); + } + #[test] fn autostart_waits_for_normal_manager_startup_latency() { let mut states = [false, false, true].into_iter(); diff --git a/src/view.rs b/src/view.rs index b172395..e062fe5 100644 --- a/src/view.rs +++ b/src/view.rs @@ -77,6 +77,12 @@ pub struct Status { /// What synty holds and how fresh it is. pub fn status() -> Result { + status_for_bucket(None) +} + +/// Build status with an explicit runtime bucket when a supervised MCP process +/// receives `--bucket` without a workstation config file. +pub fn status_for_bucket(runtime_bucket: Option<&str>) -> Result { use std::collections::HashSet; let docs = load_docs(readmodel::docs_path()).unwrap_or_default(); let github = docs.iter().filter(|d| d.meta.source == "github").count(); @@ -133,7 +139,9 @@ pub fn status() -> Result { by_model.sort_by(|a, b| b.tok_out.cmp(&a.tok_out).then(a.model.cmp(&b.model))); // A newer binary published to GitHub Releases (cached, token-gated, best- // effort). Independent of the bucket — it's about synty itself, not the data. - let bucket = crate::config::load().bucket; + let bucket = runtime_bucket + .map(ToOwned::to_owned) + .or_else(|| crate::config::load().bucket); let bucket_freshness = crate::mcp::bucket_freshness(); let upgrade = crate::release::available(); let fleet = crate::units::analysis_roster() From 6933a47ba3bcdec6754796430e60bea9fc9c0e01 Mon Sep 17 00:00:00 2001 From: Attila Laszlo Nagy Date: Thu, 30 Jul 2026 15:46:56 +0200 Subject: [PATCH 2/8] Athena: keep reconstruction scenario inside lookback Generate the fake raw events relative to the scenario start so the seven-day production guard does not make the test expire with wall-clock time. The complete 325-scenario S3, GCS, HTTP MCP, and Athena matrix passes. --- src/trace_athena.rs | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/src/trace_athena.rs b/src/trace_athena.rs index 7e3df21..26abbfe 100644 --- a/src/trace_athena.rs +++ b/src/trace_athena.rs @@ -1137,22 +1137,28 @@ mod tests { #[test] fn raw_athena_rows_reconstruct_the_existing_span_surface() { + let started = Utc::now() - Duration::minutes(2); + let called = started + Duration::seconds(1); + let completed = started + Duration::seconds(3); + let started = started.to_rfc3339(); + let called = called.to_rfc3339(); + let completed = completed.to_rfc3339(); let lines = vec![ event( "start", - "2026-07-22T10:00:00Z", + &started, "session_start", json!({"cwd":"/work/synty"}), ), event( "call-1", - "2026-07-22T10:00:01Z", + &called, "tool_call", json!({"name":"exec_command","call_id":"c1","arguments":"{\"cmd\":\"cargo test\"}"}), ), event( "result-1", - "2026-07-22T10:00:03Z", + &completed, "tool_result", json!({"call_id":"c1","output":"Process exited with code 0"}), ), @@ -1184,7 +1190,7 @@ mod tests { None, None, false, - Some("2026-07-22T10:00:00Z"), + Some(&started), None, "recent", 20, From ac0a7dddc6c07fc78cc27fb1a7f14858b8505fc7 Mon Sep 17 00:00:00 2001 From: Attila Laszlo Nagy Date: Thu, 30 Jul 2026 16:06:22 +0200 Subject: [PATCH 3/8] Fleet: resolve explicitly captured local repositories Treat capture_repos as repository identity input as well as upload policy. This lets a local-only checkout such as sie-harness pass the fail-closed upload boundary from its cwd without requiring a Git remote, while unlisted local sessions remain denied. --- README.md | 10 ++++++---- src/sync.rs | 37 ++++++++++++++++++++++++++++++++++++- 2 files changed, 42 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index 9fcf369..3dcf775 100644 --- a/README.md +++ b/README.md @@ -157,10 +157,12 @@ when set (otherwise `~/.codex` / `~/.claude`). MCP responses default to the `mcp_safe` redaction profile. Upload redaction defaults to `off`, preserving raw events as the rebuildable source of truth; opt in with `init --upload-redaction standard`. A repository allowlist is enforced before upload -and during import. Unknown sessions fail closed. Changing upload redaction or -the repository allowlist after offsets advance requires a new bucket prefix or -an intentional ledger reset, because already-uploaded chunks are immutable and -filtered history cannot be backfilled from an advanced cursor. +and during import. An explicitly allowed local-only repository is resolved from +its working-directory path even when it intentionally has no Git remote. +Unknown sessions fail closed. Changing upload redaction or the repository +allowlist after offsets advance requires a new bucket prefix or an intentional +ledger reset, because already-uploaded chunks are immutable and filtered +history cannot be backfilled from an advanced cursor. On a systemd-based EC2 developer VM, enable lingering once so the per-user tracker starts at boot without an SSH login, then run `init` normally: diff --git a/src/sync.rs b/src/sync.rs index 94d8bc5..58e8ded 100644 --- a/src/sync.rs +++ b/src/sync.rs @@ -232,7 +232,7 @@ fn push_events_scoped( let profile = crate::config::upload_redaction(); let config = crate::config::load(); let capture_repos = config.capture_repos; - let known_repos: std::collections::HashSet = config.repos.into_iter().collect(); + let known_repos = upload_known_repos(config.repos, &capture_repos); let profile_name = profile.as_str(); ensure_upload_policy_compatible( &state, @@ -398,6 +398,19 @@ fn push_events_scoped( Ok(n) } +/// Repository names that can be resolved without consulting a checkout's Git +/// remote. Explicit upload policy is part of this set: local-only repositories +/// have no remote by design, but their cwd still identifies them unambiguously. +fn upload_known_repos( + configured: Vec, + capture_repos: &[String], +) -> std::collections::HashSet { + configured + .into_iter() + .chain(capture_repos.iter().cloned()) + .collect() +} + fn ensure_upload_policy_compatible( state: &UploadState, bucket_uri: &str, @@ -1139,6 +1152,28 @@ mod tests { assert!(!filtered.contains("not-json")); } + #[test] + fn explicitly_allowed_local_repo_resolves_without_a_git_remote() { + let capture_repos = vec!["sie-harness".to_string()]; + let known_repos = upload_known_repos(Vec::new(), &capture_repos); + let raw = concat!( + r#"{"session_id":"local","kind":"session_start","payload":{"cwd":"/mnt/cache/workspaces/sie-harness"}}"#, + "\n", + r#"{"session_id":"other","kind":"session_start","payload":{"cwd":"/mnt/cache/workspaces/unlisted-local"}}"#, + "\n", + ); + let mut allowed = BTreeSet::new(); + + update_allowed_sessions( + raw.as_bytes(), + &capture_repos, + &known_repos, + &mut allowed, + ); + + assert_eq!(allowed, BTreeSet::from(["local".to_string()])); + } + #[test] fn upload_redaction_removes_bearer_tokens_while_off_is_lossless() { let raw = concat!( From 72b5f0c3a32de844ff3c4b3c4a03b9be7636a8b0 Mon Sep 17 00:00:00 2001 From: Attila Laszlo Nagy Date: Thu, 30 Jul 2026 16:06:46 +0200 Subject: [PATCH 4/8] Track: keep installed state under one .synty Run installed launchd and systemd trackers from the user's home because every persistent path already includes .synty. This keeps config, cursors, corpus, uploads, and logs in one state directory instead of silently creating .synty/.synty. --- README.md | 3 +++ src/main.rs | 11 ++++++----- src/track.rs | 24 +++++++++++++++++++----- 3 files changed, 28 insertions(+), 10 deletions(-) diff --git a/README.md b/README.md index 3dcf775..06b0ee3 100644 --- a/README.md +++ b/README.md @@ -171,6 +171,9 @@ tracker starts at boot without an SSH login, then run `init` normally: sudo loginctl enable-linger "$USER" ``` +The installed service runs from `$HOME`; its config, cursors, corpus, upload +ledger, and log therefore stay under exactly one `$HOME/.synty/` directory. + The bucket is the durable shared backplane; an S3 deployment may optionally add Glue catalog metadata and a bounded Athena workgroup for remote trace queries. There is still no migration, crawler, build server, or coordination service. diff --git a/src/main.rs b/src/main.rs index af55650..339b2a9 100644 --- a/src/main.rs +++ b/src/main.rs @@ -696,9 +696,9 @@ enum TraceCmd { /// Run from the synty home: an explicit $SYNTY_HOME wins; a cwd that already /// holds synty state (.synty/) is its own home (the dev-checkout case); else -/// fall back to ~/.synty when the installer created it. Every state path in -/// the binary is home-relative, so this one chdir makes `synty tui` work from -/// any directory. +/// fall back to $HOME when the installer created ~/.synty. Every state path in +/// the binary already includes `.synty/`, so this one chdir makes `synty tui` +/// work from any directory without nesting the state directory. fn resolve_home() { if let Ok(h) = std::env::var("SYNTY_HOME") { if let Err(e) = std::env::set_current_dir(&h) { @@ -711,8 +711,9 @@ fn resolve_home() { } if let Ok(home) = std::env::var("HOME") { let d = std::path::Path::new(&home).join(".synty"); - if d.is_dir() && std::env::set_current_dir(&d).is_ok() { - eprintln!("synty: home {}", d.display()); + let workdir = track::installed_workdir(std::path::Path::new(&home)); + if d.is_dir() && std::env::set_current_dir(&workdir).is_ok() { + eprintln!("synty: home {}", workdir.display()); } } } diff --git a/src/track.rs b/src/track.rs index a4c96e6..0d50956 100644 --- a/src/track.rs +++ b/src/track.rs @@ -604,10 +604,10 @@ pub fn restart() -> Result { Ok(true) } -/// The directory the autostart unit runs from. The current home when it holds -/// synty state (the dev-checkout case), else ~/.synty — created so a fresh -/// install's tracker has a stable, machine-wide home instead of whatever -/// directory `init` happened to run in. +/// The directory the autostart unit runs from. The current directory when it +/// holds synty state (the dev-checkout case), else $HOME. State paths already +/// include `.synty/`, so running from ~/.synty would accidentally create a +/// second nested state directory. fn unit_workdir() -> Result { if Path::new(".synty").exists() { return Ok(std::env::current_dir()?.display().to_string()); @@ -615,7 +615,11 @@ fn unit_workdir() -> Result { let home = std::env::var("HOME").map_err(|_| anyhow!("no $HOME"))?; let d = Path::new(&home).join(".synty"); std::fs::create_dir_all(&d)?; - Ok(d.display().to_string()) + Ok(installed_workdir(Path::new(&home)).display().to_string()) +} + +pub(crate) fn installed_workdir(home: &Path) -> std::path::PathBuf { + home.to_path_buf() } fn launch_domain() -> String { @@ -1048,6 +1052,16 @@ mod tests { ); } + #[test] + fn installed_tracker_keeps_state_under_one_dot_synty_directory() { + let workdir = installed_workdir(Path::new("/home/ec2-user")); + assert_eq!(workdir, Path::new("/home/ec2-user")); + assert_eq!( + workdir.join(".synty/track.log"), + Path::new("/home/ec2-user/.synty/track.log") + ); + } + #[test] fn autostart_prefers_init_machine_then_persisted_machine() { assert_eq!( From e80c2451a2cc7292a4f5d119270503378b9e8fa5 Mon Sep 17 00:00:00 2001 From: Attila Laszlo Nagy Date: Thu, 30 Jul 2026 16:09:11 +0200 Subject: [PATCH 5/8] Config: cover persisted machine compatibility Exercise the same path-backed save and load helpers used by runtime configuration. The scenario verifies that a resolved machine survives a round trip and that a pre-machine config still deserializes with no identity. --- src/config.rs | 41 ++++++++++++++++++++++++++++++++++++++--- 1 file changed, 38 insertions(+), 3 deletions(-) diff --git a/src/config.rs b/src/config.rs index 251cb11..1daecef 100644 --- a/src/config.rs +++ b/src/config.rs @@ -175,14 +175,25 @@ pub fn captured_at(ts: &str, cutoff_ms: Option) -> bool { } pub fn load() -> Config { - std::fs::read_to_string(PATH).ok().and_then(|s| serde_json::from_str(&s).ok()).unwrap_or_default() + load_from(Path::new(PATH)) } pub fn save(c: &Config) -> Result<()> { - if let Some(dir) = Path::new(PATH).parent() { + save_to(Path::new(PATH), c) +} + +fn load_from(path: &Path) -> Config { + std::fs::read_to_string(path).ok().and_then(|s| serde_json::from_str(&s).ok()).unwrap_or_default() +} + +fn save_to(path: &Path, c: &Config) -> Result<()> { + if let Some(dir) = path.parent() { std::fs::create_dir_all(dir)?; } - crate::write_atomic(PATH, serde_json::to_string_pretty(c)?.as_bytes())?; + let path = path + .to_str() + .ok_or_else(|| anyhow::anyhow!("config path must be valid UTF-8"))?; + crate::write_atomic(path, serde_json::to_string_pretty(c)?.as_bytes())?; Ok(()) } @@ -206,4 +217,28 @@ mod tests { assert!(captured_at("2026-07-21T00:00:00Z", Some(cutoff))); assert!(captured_at("future-envelope-time", Some(cutoff))); } + + #[test] + fn machine_identity_roundtrips_and_legacy_config_defaults_to_none() { + let root = std::env::temp_dir().join(format!( + "synty-machine-config-{}", + std::process::id() + )); + let _ = std::fs::remove_dir_all(&root); + let path = root.join("config.json"); + let configured = Config { + machine: Some("sie-dev-cuda-rust".into()), + ..Default::default() + }; + + save_to(&path, &configured).unwrap(); + assert_eq!( + load_from(&path).machine.as_deref(), + Some("sie-dev-cuda-rust") + ); + + std::fs::write(&path, r#"{"bucket":"s3://team"}"#).unwrap(); + assert!(load_from(&path).machine.is_none()); + let _ = std::fs::remove_dir_all(&root); + } } From 60f5dee05251fec93420777e2955daa9f550d71a Mon Sep 17 00:00:00 2001 From: Attila Laszlo Nagy Date: Thu, 30 Jul 2026 16:09:21 +0200 Subject: [PATCH 6/8] Athena: derive reconstruction fixture partition Keep the raw-row reconstruction scenario's physical day aligned with its runtime-generated timestamp. The fixture now remains partition-consistent across calendar boundaries instead of depending on a historical hard-coded date. --- src/trace_athena.rs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/trace_athena.rs b/src/trace_athena.rs index 26abbfe..82959f3 100644 --- a/src/trace_athena.rs +++ b/src/trace_athena.rs @@ -1140,6 +1140,7 @@ mod tests { let started = Utc::now() - Duration::minutes(2); let called = started + Duration::seconds(1); let completed = started + Duration::seconds(3); + let day = started.format("%Y-%m-%d").to_string(); let started = started.to_rfc3339(); let called = called.to_rfc3339(); let completed = completed.to_rfc3339(); @@ -1178,7 +1179,7 @@ mod tests { calls: Arc::clone(&calls), }), streams: Some(vec!["edge-m-codex".into()]), - days: Some(vec!["2026-07-22".into()]), + days: Some(vec![day]), cached: None, }; let out = backend From 3c10131437a8c2c3562863d1972974c39a3611d9 Mon Sep 17 00:00:00 2001 From: Attila Laszlo Nagy Date: Thu, 30 Jul 2026 16:26:28 +0200 Subject: [PATCH 7/8] Trace: preserve edge repository attribution Stamp the canonical repository into session starts while the edge checkout and capture policy are available, then consume that metadata during remote reconstruction. This keeps repo-filtered Athena traces usable for local-only repositories without weakening the unknown-session upload boundary. --- README.md | 12 +++++++----- src/trace.rs | 41 ++++++++++++++++++++++++++++++++++++++++- src/track.rs | 43 +++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 90 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index 06b0ee3..8e98437 100644 --- a/README.md +++ b/README.md @@ -158,11 +158,13 @@ when set (otherwise `~/.codex` / `~/.claude`). MCP responses default to the events as the rebuildable source of truth; opt in with `init --upload-redaction standard`. A repository allowlist is enforced before upload and during import. An explicitly allowed local-only repository is resolved from -its working-directory path even when it intentionally has no Git remote. -Unknown sessions fail closed. Changing upload redaction or the repository -allowlist after offsets advance requires a new bucket prefix or an intentional -ledger reset, because already-uploaded chunks are immutable and filtered -history cannot be backfilled from an advanced cursor. +its working-directory path even when it intentionally has no Git remote; the +tracker stamps that canonical repository into session metadata so remote trace +queries retain the same attribution. Unknown sessions fail closed. Changing +upload redaction or the repository allowlist after offsets advance requires a +new bucket prefix or an intentional ledger reset, because already-uploaded +chunks are immutable and filtered history cannot be backfilled from an +advanced cursor. On a systemd-based EC2 developer VM, enable lingering once so the per-user tracker starts at boot without an SSH login, then run `init` normally: diff --git a/src/trace.rs b/src/trace.rs index cdcde07..cea7fb1 100644 --- a/src/trace.rs +++ b/src/trace.rs @@ -482,9 +482,14 @@ impl TraceStore { if ctx.role.is_empty() { ctx.role = crate::policy::role(ev).to_string(); } + if ctx.repo.is_empty() { + ctx.repo = ev.payload["repo"].as_str().unwrap_or("").to_string(); + } if let Some(cwd) = event_cwd(&ev.payload).filter(|cwd| !cwd.is_empty()) { if ctx.cwd.is_empty() { - ctx.repo = crate::units::resolve_repo(&cwd, known); + if ctx.repo.is_empty() { + ctx.repo = crate::units::resolve_repo(&cwd, known); + } ctx.cwd = cwd; } } @@ -2917,6 +2922,40 @@ mod tests { assert_eq!(hits[0].repo, "repo"); } + #[test] + fn remote_trace_uses_edge_stamped_repo_without_a_local_checkout() { + let lines = [ + ev( + "start", + "2026-06-01T10:00:00Z", + "codex_cli", + "S", + "session_start", + json!({"cwd":"/mnt/cache/workspaces/sie-harness","repo":"sie-harness"}), + ), + ev( + "call", + "2026-06-01T10:00:01Z", + "codex_cli", + "S", + "tool_call", + json!({"name":"exec_command","call_id":"c1","arguments":"{\"cmd\":\"pytest\"}"}), + ), + ev( + "result", + "2026-06-01T10:00:02Z", + "codex_cli", + "S", + "tool_result", + json!({"call_id":"c1","output":"Process exited with code 0"}), + ), + ]; + let refs: Vec<&str> = lines.iter().map(String::as_str).collect(); + let store = TraceStore::from_lines(&refs); + + assert_eq!(store.spans[0].repo, "sie-harness"); + } + #[test] fn published_trace_excludes_pre_boundary_evidence_but_keeps_context() { let lines = [ diff --git a/src/track.rs b/src/track.rs index 0d50956..ca4e158 100644 --- a/src/track.rs +++ b/src/track.rs @@ -289,6 +289,7 @@ impl Tracker { impl Stream { fn drain(&mut self, cutoff_ms: i64, cursors: &HashMap) -> Result { let mut events = Vec::new(); + let known_repos = configured_repositories(); for path in discover(&self.roots, cutoff_ms) { let Ok(content) = std::fs::read(&path) else { continue }; @@ -349,6 +350,7 @@ impl Stream { e.payload["campaign_id"] = json!(self.campaign); } e.payload["backend"] = json!(self.src.envelope_source()); + stamp_session_repo(e, &known_repos); } } if cutoff_ms > 0 { @@ -448,6 +450,26 @@ impl Stream { } } +fn configured_repositories() -> HashSet { + let config = crate::config::load(); + config + .repos + .into_iter() + .chain(config.capture_repos) + .collect() +} + +/// Stamp the canonical repository while the source checkout is available. +/// Remote trace readers cannot inspect this machine's Git metadata, and +/// local-only repositories intentionally have no remote. +fn stamp_session_repo(event: &mut Event, known_repos: &HashSet) { + let cwd = event.payload["cwd"].as_str().unwrap_or(""); + let repo = crate::units::resolve_repo(cwd, known_repos); + if !repo.is_empty() { + event.payload["repo"] = json!(repo); + } +} + fn event_time_ms(e: &Event) -> Option { chrono::DateTime::parse_from_rfc3339(&e.ts) .ok() @@ -1062,6 +1084,27 @@ mod tests { ); } + #[test] + fn session_start_stamps_an_explicitly_captured_local_repo() { + let mut event = Event { + v: crate::event::ENVELOPE_V, + event_id: "event".into(), + stream: "edge-machine-codex".into(), + seq: 0, + ts: "2026-07-30T14:00:00Z".into(), + source: "codex_cli".into(), + session_id: "session".into(), + kind: kind::SESSION_START.into(), + payload: json!({"cwd":"/mnt/cache/workspaces/sie-harness"}), + rollup_dim: String::new(), + }; + let known = HashSet::from(["sie-harness".to_string()]); + + stamp_session_repo(&mut event, &known); + + assert_eq!(event.payload["repo"], "sie-harness"); + } + #[test] fn autostart_prefers_init_machine_then_persisted_machine() { assert_eq!( From de490ea375e5fdbe3d9d0b1382fe82ec7eb0c800 Mon Sep 17 00:00:00 2001 From: Attila Laszlo Nagy Date: Thu, 30 Jul 2026 16:30:46 +0200 Subject: [PATCH 8/8] Track: keep installed corpus in persistent state Render relative tracker output below .synty when the managed service runs from the user's home, while preserving repository-local corpus paths in development checkouts. This keeps the installed corpus on the persistent state volume without reintroducing nested config paths. --- src/track.rs | 31 ++++++++++++++++++++++++++++++- 1 file changed, 30 insertions(+), 1 deletion(-) diff --git a/src/track.rs b/src/track.rs index ca4e158..6c8e8a2 100644 --- a/src/track.rs +++ b/src/track.rs @@ -644,6 +644,15 @@ pub(crate) fn installed_workdir(home: &Path) -> std::path::PathBuf { home.to_path_buf() } +fn unit_output_for(cwd: &Path, home: &Path, out: &str) -> String { + let path = Path::new(out); + if path.is_absolute() || out.starts_with(".synty/") || cwd != home { + out.to_string() + } else { + format!(".synty/{out}") + } +} + fn launch_domain() -> String { #[cfg(unix)] unsafe { @@ -829,11 +838,14 @@ fn github_due(elapsed_since_last: Option, every: Duration) -> bool { fn write_unit(kind: &str, path: &str, out: &str, machine: &str) -> Result<()> { let exe = std::env::current_exe()?.display().to_string(); let cwd = unit_workdir()?; + let out = std::env::var("HOME") + .map(|home| unit_output_for(Path::new(&cwd), Path::new(&home), out)) + .unwrap_or_else(|_| out.to_string()); let mut args = vec![ "track".to_string(), "--watch".to_string(), "--out".to_string(), - out.to_string(), + out, "--machine".to_string(), machine.to_string(), ]; @@ -1082,6 +1094,23 @@ mod tests { workdir.join(".synty/track.log"), Path::new("/home/ec2-user/.synty/track.log") ); + assert_eq!( + unit_output_for( + &workdir, + Path::new("/home/ec2-user"), + "corpus/local" + ), + ".synty/corpus/local" + ); + assert_eq!( + unit_output_for( + Path::new("/work/synty"), + Path::new("/home/ec2-user"), + "corpus/local" + ), + "corpus/local", + "a checkout keeps its repository-local corpus" + ); } #[test]