use std::path::{Path, PathBuf};
use anyhow::{bail, Context, Result};
use chrono::{DateTime, Utc};
use mecha_slack::{chat, Slack};
use serde::{Deserialize, Serialize};
pub const STATE_LIVE: &str = "live";
pub const STATE_ATTACHING: &str = "attaching";
pub const STATE_COLD: &str = "cold";
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AttachRecord {
pub name: String,
#[serde(default)]
pub channel_id: Option<String>,
#[serde(default)]
pub thread_ts: Option<String>,
pub session_id: String,
pub pid: u32,
pub workspace: PathBuf,
pub attached_at: DateTime<Utc>,
pub state: String,
#[serde(default)]
pub ended_reason: Option<String>,
pub updated_at: DateTime<Utc>,
}
impl AttachRecord {
pub fn new(name: &str, session_id: &str, workspace: PathBuf) -> Self {
let now = Utc::now();
AttachRecord {
name: name.to_string(),
channel_id: None,
thread_ts: None,
session_id: session_id.to_string(),
pid: std::process::id(),
workspace,
attached_at: now,
state: STATE_LIVE.to_string(),
ended_reason: None,
updated_at: now,
}
}
pub fn is_live(&self) -> bool {
self.state == STATE_LIVE && mecha_core::process_alive(self.pid)
}
pub fn go_cold(&mut self, reason: &str) {
self.state = STATE_COLD.to_string();
self.ended_reason = Some(reason.to_string());
self.updated_at = Utc::now();
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Claim {
Fresh,
TakenOver {
previous_session: String,
thread_ts: Option<String>,
},
AlreadyMine,
Refused { pid: u32, session_id: String },
}
fn held(rec: &AttachRecord) -> bool {
rec.state == STATE_LIVE || rec.state == STATE_ATTACHING
}
pub fn decide(
existing: Option<&AttachRecord>,
my_session: &str,
alive: impl Fn(u32) -> bool,
) -> Claim {
let Some(rec) = existing else {
return Claim::Fresh;
};
if held(rec) && alive(rec.pid) && rec.pid != std::process::id() {
return Claim::Refused {
pid: rec.pid,
session_id: rec.session_id.clone(),
};
}
if rec.session_id == my_session && rec.state == STATE_LIVE {
return Claim::AlreadyMine;
}
Claim::TakenOver {
previous_session: rec.session_id.clone(),
thread_ts: rec.thread_ts.clone(),
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct InboundLine {
pub id: String,
pub text: String,
#[serde(default)]
pub files: Vec<String>,
pub received_at: DateTime<Utc>,
}
fn free_name(dir: &Path, name: &str) -> String {
if !dir.join(name).exists() {
return name.to_string();
}
let (stem, ext) = match name.rsplit_once('.') {
Some((stem, ext)) if !stem.is_empty() => (stem, format!(".{ext}")),
_ => (name, String::new()),
};
for n in 2..1000 {
let candidate = format!("{stem}-{n}{ext}");
if !dir.join(&candidate).exists() {
return candidate;
}
}
format!(
"{stem}-{}{ext}",
Utc::now().timestamp_nanos_opt().unwrap_or_default()
)
}
pub struct RemoteStore {
root: PathBuf,
}
impl RemoteStore {
pub fn default_root() -> Result<PathBuf> {
Ok(mecha_core::work::mecha_home()?.join("remote"))
}
pub fn open(root: impl Into<PathBuf>) -> Result<Self> {
let root = root.into();
mecha_core::create_private_dir(&root)
.with_context(|| format!("creating {}", root.display()))?;
Ok(RemoteStore { root })
}
pub fn open_default() -> Result<Self> {
Self::open(Self::default_root()?)
}
fn dir(&self, name: &str) -> Result<PathBuf> {
mecha_core::work::valid_producer(name)
.with_context(|| format!("`{name}` is not a usable remote-control name"))?;
Ok(self.root.join(name))
}
fn path(&self, name: &str) -> Result<PathBuf> {
Ok(self.dir(name)?.join("record.json"))
}
pub fn get(&self, name: &str) -> Result<Option<AttachRecord>> {
let path = self.path(name)?;
let Ok(text) = std::fs::read_to_string(&path) else {
return Ok(None);
};
let rec =
serde_json::from_str(&text).with_context(|| format!("reading {}", path.display()))?;
Ok(Some(rec))
}
pub fn put(&self, rec: &AttachRecord) -> Result<()> {
let dir = self.dir(&rec.name)?;
mecha_core::create_private_dir(&dir)?;
let path = dir.join("record.json");
let tmp = path.with_extension("json.tmp");
std::fs::write(&tmp, serde_json::to_string_pretty(rec)?)?;
std::fs::rename(&tmp, &path)?;
Ok(())
}
pub fn list(&self) -> Result<Vec<AttachRecord>> {
let mut out = Vec::new();
let Ok(entries) = std::fs::read_dir(&self.root) else {
return Ok(out);
};
for entry in entries.flatten() {
let path = entry.path().join("record.json");
let Ok(text) = std::fs::read_to_string(&path) else {
continue;
};
if let Ok(rec) = serde_json::from_str::<AttachRecord>(&text) {
out.push(rec);
}
}
out.sort_by_key(|r| std::cmp::Reverse(r.attached_at));
Ok(out)
}
pub fn attached_thread(
&self,
channel_id: &str,
thread_ts: &str,
) -> Result<Option<AttachRecord>> {
let entries = match std::fs::read_dir(&self.root) {
Ok(entries) => entries,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None),
Err(e) => return Err(e).context("reading the remote store"),
};
for entry in entries {
let dir = entry.context("reading the remote store")?.path();
if !dir.is_dir() {
continue;
}
let path = dir.join("record.json");
let text = match std::fs::read_to_string(&path) {
Ok(text) => text,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => continue,
Err(e) => return Err(e).with_context(|| format!("reading {}", path.display())),
};
let rec: AttachRecord = serde_json::from_str(&text)
.with_context(|| format!("reading {}", path.display()))?;
if rec.channel_id.as_deref() == Some(channel_id)
&& rec.thread_ts.as_deref() == Some(thread_ts)
{
return Ok(Some(rec));
}
}
Ok(None)
}
pub fn files_dir(&self, name: &str) -> Result<PathBuf> {
Ok(self.dir(name)?.join("files"))
}
pub fn stage_file(&self, name: &str, filename: &str, bytes: &[u8]) -> Result<String> {
let dir = self.files_dir(name)?;
mecha_core::create_private_dir(&dir)?;
let safe = crate::slack::connector::safe_filename(filename);
let stored = free_name(&dir, &safe);
std::fs::write(dir.join(&stored), bytes)?;
Ok(stored)
}
pub fn take_files(
&self,
name: &str,
files: &[String],
workspace: &Path,
) -> Result<Vec<String>> {
if files.is_empty() {
return Ok(Vec::new());
}
let staged = self.files_dir(name)?;
let inbox = workspace.join("inbox");
std::fs::create_dir_all(&inbox).with_context(|| format!("creating {}", inbox.display()))?;
let mut landed = Vec::new();
for file in files {
let safe = crate::slack::connector::safe_filename(file);
let from = staged.join(&safe);
if !from.is_file() {
continue;
}
let stored = free_name(&inbox, &safe);
let to = inbox.join(&stored);
if std::fs::rename(&from, &to).is_err() {
std::fs::copy(&from, &to)
.with_context(|| format!("copying {} to {}", from.display(), to.display()))?;
let _ = std::fs::remove_file(&from);
}
landed.push(format!("./inbox/{stored}"));
}
Ok(landed)
}
pub fn attached_here(&self) -> Result<Option<AttachRecord>> {
let me = std::process::id();
Ok(self
.list()?
.into_iter()
.find(|r| r.pid == me && r.is_live() && r.thread_ts.is_some()))
}
pub fn push_inbound(&self, name: &str, text: &str, files: Vec<String>) -> Result<InboundLine> {
let inbox = self.dir(name)?.join("inbox");
mecha_core::create_private_dir(&inbox)?;
let now = Utc::now();
let line = InboundLine {
id: format!(
"{}-{}",
now.timestamp_nanos_opt().unwrap_or_default(),
std::process::id()
),
text: text.to_string(),
files,
received_at: now,
};
let path = inbox.join(format!("{}.json", line.id));
let tmp = path.with_extension("json.tmp");
std::fs::write(&tmp, serde_json::to_string_pretty(&line)?)?;
std::fs::rename(&tmp, &path)?;
Ok(line)
}
pub fn claim_inbound(&self, name: &str) -> Result<Vec<InboundLine>> {
let inbox = self.dir(name)?.join("inbox");
let Ok(entries) = std::fs::read_dir(&inbox) else {
return Ok(Vec::new());
};
let mut out: Vec<(PathBuf, InboundLine)> = Vec::new();
for entry in entries.flatten() {
let path = entry.path();
if path.extension().and_then(|e| e.to_str()) != Some("json") {
continue;
}
let Ok(text) = std::fs::read_to_string(&path) else {
continue;
};
if let Ok(line) = serde_json::from_str::<InboundLine>(&text) {
out.push((path, line));
}
}
out.sort_by(|a, b| (a.1.received_at, &a.1.id).cmp(&(b.1.received_at, &b.1.id)));
let mut claimed = Vec::with_capacity(out.len());
for (path, line) in out {
let _ = std::fs::remove_file(&path);
claimed.push(line);
}
Ok(claimed)
}
pub fn sweep(&self) -> Result<Vec<AttachRecord>> {
let mut swept = Vec::new();
for mut rec in self.list()? {
if held(&rec) && !mecha_core::process_alive(rec.pid) {
rec.go_cold("the terminal session ended without detaching");
self.put(&rec)?;
swept.push(rec);
}
}
Ok(swept)
}
}
#[derive(Clone)]
pub struct Attached {
pub name: String,
pub channel_id: String,
pub thread_ts: String,
pub slack: Slack,
pub flush_chars: usize,
pub flush_ms: u64,
}
pub fn header_text(
name: &str,
workspace: &Path,
model: &str,
taint: (bool, bool),
prior_messages: usize,
takeover_of: Option<&str>,
) -> String {
let mut out = String::new();
if let Some(previous) = takeover_of {
out.push_str(&format!(
"_The session that held `{name}` ended (was `{previous}`). \
Picking the name up again._\n\n"
));
}
out.push_str(&format!("*mecha · {name}*\n"));
out.push_str(&format!("`{}`\n", workspace.display()));
out.push_str(&format!("model `{model}`\n"));
out.push_str(match taint {
(true, true) => {
"⚠️ already holds private data *and* third-party content — \
outbound calls will be refused\n"
}
(true, false) => "holds private data\n",
(false, true) => "holds third-party content\n",
(false, false) => "clean\n",
});
if prior_messages > 0 {
out.push_str(&format!(
"_{prior_messages} earlier message(s) are not repeated here; this thread starts now._\n"
));
}
out.push_str(
"\nType here and it reaches this session; files land in its workspace. \
Slash commands and `!` escapes stay at the terminal. Inbound needs \
`mecha slack connect` running.",
);
out
}
#[allow(clippy::too_many_arguments)]
pub async fn attach(
name: &str,
session_id: &str,
workspace: &Path,
model: &str,
taint: (bool, bool),
prior_messages: usize,
) -> Result<(Attached, String)> {
mecha_core::work::valid_producer(name)
.with_context(|| format!("`{name}` is not a usable remote-control name"))?;
let store = RemoteStore::open_default()?;
let existing = store.get(name)?;
let claim = decide(existing.as_ref(), session_id, mecha_core::process_alive);
let (reuse_thread, takeover_of, notice) = match &claim {
Claim::Refused { pid, session_id } => bail!(
"`{name}` is held by a live session ({session_id}, pid {pid}) — pick another \
name, or detach it there first"
),
Claim::AlreadyMine => (
existing.as_ref().and_then(|r| r.thread_ts.clone()),
None,
format!("already attached as `{name}`"),
),
Claim::TakenOver {
previous_session,
thread_ts,
} => (
thread_ts.clone(),
Some(previous_session.clone()),
format!("attached as `{name}` — reusing the thread the previous session left"),
),
Claim::Fresh => (None, None, format!("attached as `{name}`")),
};
let mut record = match existing {
Some(mut r) => {
r.session_id = session_id.to_string();
r.pid = std::process::id();
r.workspace = workspace.to_path_buf();
r.state = STATE_ATTACHING.to_string();
r.ended_reason = None;
r.attached_at = Utc::now();
r.updated_at = Utc::now();
r
}
None => {
let mut r = AttachRecord::new(name, session_id, workspace.to_path_buf());
r.state = STATE_ATTACHING.to_string();
r
}
};
store.put(&record)?;
fn release(store: &RemoteStore, record: &mut AttachRecord, why: &str) {
record.go_cold(why);
let _ = store.put(record);
}
let cfg = mecha_core::config::Config::load_global()?;
let slack_store =
mecha_slack::binding::SlackStore::open(mecha_core::work::mecha_home()?.join("slack"))?;
let (slack, channel) = match crate::slack::send::owner_dm(&slack_store).await {
Ok(pair) => pair,
Err(e) => {
release(
&store,
&mut record,
"attach failed before the thread was opened",
);
return Err(e);
}
};
let text = header_text(
name,
workspace,
model,
taint,
prior_messages,
takeover_of.as_deref(),
);
let ts = match chat::post_message(&slack, &channel, reuse_thread.as_deref(), &text, None).await
{
Ok(ts) => ts,
Err(e) => {
release(
&store,
&mut record,
"attach failed while opening the thread",
);
return Err(e.into());
}
};
let thread_ts = reuse_thread.unwrap_or_else(|| ts.to_string());
record.channel_id = Some(channel.clone());
record.thread_ts = Some(thread_ts.clone());
record.state = STATE_LIVE.to_string();
record.updated_at = Utc::now();
store.put(&record)?;
Ok((
Attached {
name: name.to_string(),
channel_id: channel,
thread_ts,
slack,
flush_chars: cfg.slack.stream_flush_chars,
flush_ms: cfg.slack.stream_flush_ms,
},
notice,
))
}
pub fn echo_text(text: &str, steering: bool) -> String {
let who = if steering {
"_you, steering mid-run:_"
} else {
"_you, at the terminal:_"
};
let quoted: String = text
.lines()
.map(|l| format!("> {l}"))
.collect::<Vec<_>>()
.join("\n");
format!("{who}\n{quoted}")
}
pub async fn detach(attached: &Attached, reason: &str) -> Result<()> {
let store = RemoteStore::open_default()?;
if let Some(mut rec) = store.get(&attached.name)? {
rec.go_cold(reason);
store.put(&rec)?;
}
let _ = chat::post_message(
&attached.slack,
&attached.channel_id,
Some(&attached.thread_ts),
&format!(
"_{reason}. `/remote-control {}` picks this thread up again._",
attached.name
),
None,
)
.await;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
fn scratch(name: &str) -> PathBuf {
let dir = std::env::temp_dir().join(format!(
"mecha-remote-{name}-{}-{}",
std::process::id(),
Utc::now().timestamp_nanos_opt().unwrap_or_default()
));
let _ = std::fs::remove_dir_all(&dir);
dir
}
fn rec(name: &str, session: &str, pid: u32) -> AttachRecord {
let mut r = AttachRecord::new(name, session, PathBuf::from("/w"));
r.pid = pid;
r
}
const DEAD: fn(u32) -> bool = |_| false;
const ALIVE: fn(u32) -> bool = |_| true;
fn header(taint: (bool, bool), prior: usize, takeover: Option<&str>) -> String {
header_text(
"lab",
Path::new("/home/u/project"),
"claude-opus-5",
taint,
prior,
takeover,
)
}
#[test]
fn what_the_user_typed_is_quoted_and_attributed_to_them() {
let e = echo_text("summarise the inbox", false);
assert!(e.contains("you, at the terminal"), "{e}");
assert!(e.contains("> summarise the inbox"), "{e}");
}
#[test]
fn every_line_of_a_multi_line_prompt_is_quoted() {
let e = echo_text("first line\nsecond line\nthird", false);
for line in ["> first line", "> second line", "> third"] {
assert!(e.contains(line), "{line} missing from {e}");
}
}
#[test]
fn steering_reads_differently_from_starting_a_turn() {
assert!(echo_text("actually, skip the news", true).contains("steering mid-run"));
assert!(!echo_text("actually, skip the news", false).contains("steering"));
}
#[test]
fn the_header_names_the_session_its_workspace_and_its_model() {
let h = header((false, false), 0, None);
assert!(h.contains("mecha · lab"), "{h}");
assert!(h.contains("/home/u/project"), "{h}");
assert!(h.contains("claude-opus-5"), "{h}");
}
#[test]
fn a_fully_armed_interlock_is_stated_rather_than_discovered() {
let h = header((true, true), 0, None);
assert!(h.contains("refused"), "{h}");
assert!(h.contains("private data"), "{h}");
assert!(h.contains("third-party"), "{h}");
}
#[test]
fn each_taint_leg_reads_differently_and_clean_says_clean() {
assert!(header((true, false), 0, None).contains("holds private data"));
assert!(header((false, true), 0, None).contains("third-party content"));
assert!(header((false, false), 0, None).contains("clean"));
}
#[test]
fn earlier_messages_are_counted_and_never_repeated() {
let h = header((false, false), 12, None);
assert!(h.contains("12 earlier message"), "{h}");
assert!(h.contains("starts now"), "{h}");
assert!(!header((false, false), 0, None).contains("earlier message"));
}
#[test]
fn a_takeover_says_whose_thread_this_was() {
let h = header((false, false), 0, Some("sess-old"));
assert!(h.contains("sess-old"), "{h}");
assert!(h.contains("ended"), "{h}");
assert!(!header((false, false), 0, None).contains("ended"));
}
#[test]
fn the_header_describes_what_the_thread_can_actually_do() {
let h = header((false, false), 0, None);
assert!(h.contains("reaches this session"), "{h}");
assert!(h.contains("files land in its workspace"), "{h}");
assert!(h.contains("stay at the terminal"), "{h}");
assert!(h.contains("mecha slack connect"), "{h}");
assert!(
!h.contains("Nothing typed here"),
"the stale rung-2 wording came back: {h}"
);
}
#[test]
fn an_unused_name_is_free() {
assert_eq!(decide(None, "s1", ALIVE), Claim::Fresh);
}
#[test]
fn a_dead_holder_is_taken_over_and_hands_its_thread_on() {
let mut old = rec("lab", "s1", 424242);
old.thread_ts = Some("1755.0001".into());
assert_eq!(
decide(Some(&old), "s2", DEAD),
Claim::TakenOver {
previous_session: "s1".into(),
thread_ts: Some("1755.0001".into())
}
);
}
#[test]
fn a_live_holder_refuses_and_says_which_process() {
let old = rec("lab", "s1", 4242);
assert_eq!(
decide(Some(&old), "s2", ALIVE),
Claim::Refused {
pid: 4242,
session_id: "s1".into()
}
);
}
#[test]
fn my_own_live_record_is_not_a_collision() {
let mine = rec("lab", "s1", std::process::id());
assert_eq!(decide(Some(&mine), "s1", ALIVE), Claim::AlreadyMine);
}
#[test]
fn a_second_process_resuming_the_same_session_is_refused_not_welcomed() {
let theirs = rec("lab", "s1", 4242);
assert_eq!(
decide(Some(&theirs), "s1", ALIVE),
Claim::Refused {
pid: 4242,
session_id: "s1".into()
}
);
}
#[test]
fn re_attaching_after_detaching_reports_picking_the_thread_up() {
let mut mine = rec("lab", "s1", std::process::id());
mine.go_cold("detached from the terminal");
assert!(matches!(
decide(Some(&mine), "s1", ALIVE),
Claim::TakenOver { .. }
));
}
#[test]
fn a_cold_record_is_free_even_if_its_pid_was_recycled() {
let mut old = rec("lab", "s1", 4242);
old.go_cold("detached");
assert!(matches!(
decide(Some(&old), "s2", ALIVE),
Claim::TakenOver { .. }
));
}
#[test]
fn a_live_record_whose_process_is_gone_does_not_read_as_live() {
let mut r = rec("lab", "s1", 424242);
assert_eq!(r.state, STATE_LIVE);
assert!(!r.is_live(), "a dead pid must not read as live");
r.pid = std::process::id();
assert!(r.is_live());
}
#[test]
fn going_cold_keeps_the_thread_it_was_using() {
let store = RemoteStore::open(scratch("cold")).unwrap();
let mut r = AttachRecord::new("lab", "s1", PathBuf::from("/w"));
r.channel_id = Some("D1".into());
r.thread_ts = Some("1755.0001".into());
store.put(&r).unwrap();
let mut back = store.get("lab").unwrap().unwrap();
back.go_cold("detached");
store.put(&back).unwrap();
let after = store.get("lab").unwrap().unwrap();
assert_eq!(after.state, STATE_COLD);
assert_eq!(after.thread_ts.as_deref(), Some("1755.0001"));
assert_eq!(after.channel_id.as_deref(), Some("D1"));
assert_eq!(after.ended_reason.as_deref(), Some("detached"));
}
#[test]
fn a_name_that_is_a_path_is_refused_before_it_becomes_one() {
let store = RemoteStore::open(scratch("names")).unwrap();
for bad in ["../escape", "has/slash", "Upper", "with space", ""] {
assert!(store.get(bad).is_err(), "{bad:?} should be refused");
}
assert!(store.get("lab-2").is_ok());
}
#[test]
fn inbound_lines_are_claimed_oldest_first_and_only_once() {
let store = RemoteStore::open(scratch("inbox")).unwrap();
store
.put(&AttachRecord::new("lab", "s1", PathBuf::from("/w")))
.unwrap();
for text in ["first", "second", "third"] {
store.push_inbound("lab", text, Vec::new()).unwrap();
}
let claimed = store.claim_inbound("lab").unwrap();
assert_eq!(
claimed.iter().map(|l| l.text.as_str()).collect::<Vec<_>>(),
["first", "second", "third"]
);
assert!(store.claim_inbound("lab").unwrap().is_empty());
}
#[test]
fn a_staged_file_lands_in_the_workspace_inbox_and_is_named_relatively() {
let root = scratch("files");
let store = RemoteStore::open(&root).unwrap();
store
.put(&AttachRecord::new("lab", "s1", PathBuf::from("/w")))
.unwrap();
let ws = root.join("workspace");
std::fs::create_dir_all(&ws).unwrap();
let stored = store.stage_file("lab", "shot.png", b"PNGDATA").unwrap();
let landed = store.take_files("lab", &[stored], &ws).unwrap();
assert_eq!(landed, vec!["./inbox/shot.png".to_string()]);
assert_eq!(
std::fs::read(ws.join("inbox/shot.png")).unwrap(),
b"PNGDATA"
);
assert!(!store.files_dir("lab").unwrap().join("shot.png").exists());
}
#[test]
fn an_attachment_name_cannot_climb_out_of_either_directory() {
let root = scratch("climb");
let store = RemoteStore::open(&root).unwrap();
store
.put(&AttachRecord::new("lab", "s1", PathBuf::from("/w")))
.unwrap();
let ws = root.join("workspace");
std::fs::create_dir_all(&ws).unwrap();
let stored = store
.stage_file("lab", "../../.mecha/slack/credentials.json", b"x")
.unwrap();
assert!(!stored.contains(".."), "{stored}");
assert!(!stored.contains('/'), "{stored}");
let landed = store.take_files("lab", &[stored], &ws).unwrap();
assert_eq!(landed.len(), 1);
assert!(landed[0].starts_with("./inbox/"), "{:?}", landed[0]);
assert!(ws.join("inbox").read_dir().unwrap().count() == 1);
}
#[test]
fn a_second_file_of_the_same_name_does_not_replace_the_first() {
let root = scratch("collide");
let store = RemoteStore::open(&root).unwrap();
store
.put(&AttachRecord::new("lab", "s1", PathBuf::from("/w")))
.unwrap();
let ws = root.join("workspace");
std::fs::create_dir_all(&ws).unwrap();
let a = store.stage_file("lab", "Screenshot.png", b"first").unwrap();
let b = store
.stage_file("lab", "Screenshot.png", b"second")
.unwrap();
assert_ne!(a, b, "the staging directory collided");
let landed = store.take_files("lab", &[a, b], &ws).unwrap();
assert_eq!(landed.len(), 2);
assert_ne!(landed[0], landed[1], "the workspace inbox collided");
let mut seen: Vec<Vec<u8>> = landed
.iter()
.map(|p| std::fs::read(ws.join(p.trim_start_matches("./"))).unwrap())
.collect();
seen.sort();
assert_eq!(seen, vec![b"first".to_vec(), b"second".to_vec()]);
}
#[test]
fn disambiguating_a_name_keeps_its_extension() {
let dir = scratch("freename");
std::fs::create_dir_all(&dir).unwrap();
std::fs::write(dir.join("shot.png"), b"x").unwrap();
assert_eq!(free_name(&dir, "shot.png"), "shot-2.png");
assert_eq!(free_name(&dir, "notes"), "notes");
std::fs::write(dir.join("notes"), b"x").unwrap();
assert_eq!(free_name(&dir, "notes"), "notes-2");
}
#[test]
fn a_missing_staged_file_is_skipped_rather_than_failing_the_line() {
let root = scratch("missing");
let store = RemoteStore::open(&root).unwrap();
store
.put(&AttachRecord::new("lab", "s1", PathBuf::from("/w")))
.unwrap();
let ws = root.join("workspace");
std::fs::create_dir_all(&ws).unwrap();
let landed = store
.take_files("lab", &["never-existed.png".to_string()], &ws)
.unwrap();
assert!(landed.is_empty());
}
#[test]
fn an_empty_or_absent_inbox_is_not_an_error() {
let store = RemoteStore::open(scratch("empty")).unwrap();
store
.put(&AttachRecord::new("lab", "s1", PathBuf::from("/w")))
.unwrap();
assert!(store.claim_inbound("lab").unwrap().is_empty());
}
#[test]
fn a_thread_is_matched_on_both_its_channel_and_its_timestamp() {
let store = RemoteStore::open(scratch("lookup")).unwrap();
let mut r = AttachRecord::new("lab", "s1", PathBuf::from("/w"));
r.channel_id = Some("D1".into());
r.thread_ts = Some("1755.0001".into());
store.put(&r).unwrap();
assert_eq!(
store
.attached_thread("D1", "1755.0001")
.unwrap()
.map(|r| r.name),
Some("lab".to_string())
);
assert!(store.attached_thread("D1", "1755.9999").unwrap().is_none());
assert!(store.attached_thread("D2", "1755.0001").unwrap().is_none());
}
#[test]
fn a_record_with_no_thread_yet_matches_nothing() {
let store = RemoteStore::open(scratch("nothread")).unwrap();
store
.put(&AttachRecord::new("lab", "s1", PathBuf::from("/w")))
.unwrap();
assert!(store.attached_thread("D1", "1755.0001").unwrap().is_none());
}
#[test]
fn a_reserved_name_does_not_route_anything_until_its_thread_is_confirmed() {
let mut r = AttachRecord::new("lab", "s1", PathBuf::from("/w"));
r.state = STATE_ATTACHING.to_string();
assert!(
!r.is_live(),
"an attach that never finished must not look attached"
);
r.state = STATE_LIVE.to_string();
assert!(r.is_live());
}
#[test]
fn a_name_being_attached_by_another_live_process_is_not_takeable() {
let mut reserving = rec("lab", "s1", 4242);
reserving.state = STATE_ATTACHING.to_string();
assert_eq!(
decide(Some(&reserving), "s2", ALIVE),
Claim::Refused {
pid: 4242,
session_id: "s1".into()
}
);
assert!(matches!(
decide(Some(&reserving), "s2", DEAD),
Claim::TakenOver { .. }
));
}
#[test]
fn a_stray_file_in_the_store_does_not_disable_routing() {
let root = scratch("stray");
let store = RemoteStore::open(&root).unwrap();
let mut r = AttachRecord::new("lab", "s1", PathBuf::from("/w"));
r.channel_id = Some("D1".into());
r.thread_ts = Some("1755.0001".into());
store.put(&r).unwrap();
std::fs::write(root.join(".DS_Store"), b"junk").unwrap();
std::fs::write(root.join("notes.txt"), b"junk").unwrap();
assert_eq!(
store
.attached_thread("D1", "1755.0001")
.expect("a stray file must not break the lookup")
.map(|r| r.name),
Some("lab".to_string())
);
}
#[test]
fn a_sweep_also_cools_a_reservation_whose_process_died() {
let store = RemoteStore::open(scratch("sweepattach")).unwrap();
let mut stuck = AttachRecord::new("stuck", "s1", PathBuf::from("/w"));
stuck.pid = 424242;
stuck.state = STATE_ATTACHING.to_string();
store.put(&stuck).unwrap();
let swept = store.sweep().unwrap();
assert_eq!(swept.len(), 1);
assert_eq!(store.get("stuck").unwrap().unwrap().state, STATE_COLD);
}
#[test]
fn an_unreadable_record_refuses_to_answer_rather_than_saying_not_attached() {
let root = scratch("failclosed");
let store = RemoteStore::open(&root).unwrap();
let mut r = AttachRecord::new("lab", "s1", PathBuf::from("/w"));
r.channel_id = Some("D1".into());
r.thread_ts = Some("1755.0001".into());
store.put(&r).unwrap();
assert!(store.attached_thread("D1", "1755.0001").unwrap().is_some());
std::fs::write(root.join("lab").join("record.json"), b"{ not json").unwrap();
assert!(
store.attached_thread("D1", "1755.0001").is_err(),
"a malformed record read as `not attached`"
);
}
#[test]
fn a_sweep_cools_a_dead_attachment_and_leaves_a_live_one() {
let store = RemoteStore::open(scratch("sweep")).unwrap();
let mut dead = AttachRecord::new("gone", "s1", PathBuf::from("/w"));
dead.pid = 424242;
store.put(&dead).unwrap();
store
.put(&AttachRecord::new("here", "s2", PathBuf::from("/w")))
.unwrap();
let swept = store.sweep().unwrap();
assert_eq!(swept.len(), 1);
assert_eq!(swept[0].name, "gone");
assert_eq!(store.get("gone").unwrap().unwrap().state, STATE_COLD);
assert_eq!(store.get("here").unwrap().unwrap().state, STATE_LIVE);
}
}