diff --git a/README.md b/README.md index c45bed9..8e98437 100644 --- a/README.md +++ b/README.md @@ -157,10 +157,14 @@ 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; 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: @@ -169,11 +173,16 @@ 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. 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..1daecef 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 @@ -171,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(()) } @@ -202,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); + } } 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/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/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/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!( 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/trace_athena.rs b/src/trace_athena.rs index 7e3df21..82959f3 100644 --- a/src/trace_athena.rs +++ b/src/trace_athena.rs @@ -1137,22 +1137,29 @@ 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 day = started.format("%Y-%m-%d").to_string(); + 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"}), ), @@ -1172,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 @@ -1184,7 +1191,7 @@ mod tests { None, None, false, - Some("2026-07-22T10:00:00Z"), + Some(&started), None, "recent", 20, diff --git a/src/track.rs b/src/track.rs index f6155c3..6c8e8a2 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() @@ -565,10 +587,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 +607,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. @@ -592,10 +626,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()); @@ -603,7 +637,20 @@ 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 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 { @@ -791,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(), ]; @@ -1036,6 +1086,64 @@ 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") + ); + 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] + 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!( + 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()