Skip to content
Open
Show file tree
Hide file tree
Changes from 2 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
4 changes: 3 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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-<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
4 changes: 4 additions & 0 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
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"
);
}
}
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
14 changes: 10 additions & 4 deletions src/trace_athena.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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();
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 Down Expand Up @@ -1184,7 +1190,7 @@ mod tests {
None,
None,
false,
Some("2026-07-22T10:00:00Z"),
Some(&started),
None,
"recent",
20,
Expand Down
24 changes: 23 additions & 1 deletion src/track.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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)?;
Expand All @@ -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.
Expand Down Expand Up @@ -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();
Expand Down
10 changes: 9 additions & 1 deletion src/view.rs
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,12 @@ pub struct Status {

/// What synty holds and how fresh it is.
pub fn status() -> Result<Status> {
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<Status> {
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();
Expand Down Expand Up @@ -133,7 +139,9 @@ pub fn status() -> Result<Status> {
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()
Expand Down
Loading