Skip to content
Open
Show file tree
Hide file tree
Changes from 6 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 12 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -169,11 +171,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-<machine>-<source>` 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
Expand Down
4 changes: 3 additions & 1 deletion docs/design.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 → `✓
Expand Down
45 changes: 42 additions & 3 deletions src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<String>,
/// 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<String>,
Comment thread
coderabbitai[bot] marked this conversation as resolved.
/// 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
Expand Down Expand Up @@ -171,14 +175,25 @@ pub fn captured_at(ts: &str, cutoff_ms: Option<i64>) -> 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(())
}

Expand All @@ -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);
}
}
15 changes: 14 additions & 1 deletion src/init.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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");
Expand Down Expand Up @@ -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"
);
}
}
11 changes: 6 additions & 5 deletions src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand All @@ -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());
}
}
}
Expand Down
30 changes: 29 additions & 1 deletion src/mcp.rs
Original file line number Diff line number Diff line change
Expand Up @@ -193,6 +193,7 @@ pub(crate) struct Server {
scope: crate::policy::ReadScope,
redaction: crate::redact::Profile,
allow_repo_paths: bool,
bucket: Option<String>,
trace: TraceBackend,
/// The build the engine serves + encoder + index — loaded on the first
/// search call, kept warm, reopened when the pointer moves.
Expand All @@ -216,6 +217,7 @@ impl Server {
scope,
redaction,
allow_repo_paths,
bucket,
trace,
engine: None,
}
Expand Down Expand Up @@ -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("");
Expand Down Expand Up @@ -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(
Expand Down
37 changes: 36 additions & 1 deletion src/sync.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<String> = 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,
Expand Down Expand Up @@ -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<String>,
capture_repos: &[String],
) -> std::collections::HashSet<String> {
configured
.into_iter()
.chain(capture_repos.iter().cloned())
.collect()
}

fn ensure_upload_policy_compatible(
state: &UploadState,
bucket_uri: &str,
Expand Down Expand Up @@ -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!(
Expand Down
17 changes: 12 additions & 5 deletions src/trace_athena.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Comment thread
coderabbitai[bot] marked this conversation as resolved.
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"}),
),
Expand All @@ -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
Expand All @@ -1184,7 +1191,7 @@ mod tests {
None,
None,
false,
Some("2026-07-22T10:00:00Z"),
Some(&started),
None,
"recent",
20,
Expand Down
Loading
Loading