Skip to main content

agent_id_cli/
registry.rs

1use std::{
2    collections::{BTreeMap, BTreeSet},
3    env,
4    fs::{self, File},
5    io::Write,
6    path::{Path, PathBuf},
7    sync::atomic::{AtomicU64, Ordering},
8    time::{SystemTime, UNIX_EPOCH},
9};
10
11use anyhow::{anyhow, bail, Context, Result};
12use chrono::{DateTime, Duration, Utc};
13use serde::{Deserialize, Serialize};
14use sha2::{Digest, Sha256};
15
16use crate::{
17    activity::{ActivityState, ActivityStateValue},
18    cli::{AnnotateArgs, CurrentArgs, DiscoverArgs, LookupArgs, PruneArgs, RegisterArgs},
19    names,
20};
21
22#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
23pub struct ActivitySummary {
24    pub text: String,
25    pub updated_at: DateTime<Utc>,
26}
27
28#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
29pub struct ExtensionMetadata {
30    pub data: serde_json::Value,
31    pub updated_at: DateTime<Utc>,
32}
33
34#[derive(Debug, Clone)]
35pub struct ActivityUpdate {
36    pub summary: Option<String>,
37    pub clear_summary: bool,
38    pub state: Option<ActivityStateValue>,
39    pub clear_state: bool,
40    pub cwd: Option<String>,
41    pub clear_cwd: bool,
42    pub extensions: BTreeMap<String, serde_json::Value>,
43    pub clear_extensions: BTreeSet<String>,
44}
45
46#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
47pub struct Assignment {
48    pub version: u8,
49    pub session_id: String,
50    pub name: String,
51    pub slug: String,
52    pub first_name: String,
53    pub family_name: String,
54    pub realm: String,
55    pub summary: Option<ActivitySummary>,
56    pub state: ActivityState,
57    pub cwd: Option<String>,
58    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
59    pub extensions: BTreeMap<String, ExtensionMetadata>,
60    pub created_at: DateTime<Utc>,
61    pub updated_at: DateTime<Utc>,
62}
63
64#[derive(Debug, Clone, Serialize)]
65pub struct PrunedIdentity {
66    pub session_id: String,
67    pub name: String,
68    pub slug: String,
69    pub updated_at: DateTime<Utc>,
70    pub claim_removed: bool,
71}
72
73#[derive(Debug, Clone, Serialize)]
74pub struct PruneReport {
75    pub cutoff: DateTime<Utc>,
76    pub dry_run: bool,
77    pub candidates: Vec<Assignment>,
78    pub removed: Vec<PrunedIdentity>,
79    pub errors: Vec<String>,
80}
81
82#[derive(Debug, Clone)]
83pub struct Registry {
84    root: PathBuf,
85}
86
87impl Registry {
88    pub fn from_env() -> Result<Self> {
89        let root = if let Some(path) = env::var_os("AGENT_ID_HOME") {
90            PathBuf::from(path)
91        } else if let Some(path) = env::var_os("XDG_DATA_HOME") {
92            PathBuf::from(path).join("agent-id")
93        } else {
94            home_dir()?.join(".local/share/agent-id")
95        };
96
97        Ok(Self::new(root))
98    }
99
100    pub fn new(root: PathBuf) -> Self {
101        Self { root }
102    }
103
104    pub fn register(
105        &self,
106        session_id: &str,
107        family_name: Option<&str>,
108        realm: &str,
109    ) -> Result<Assignment> {
110        let session_id = validate_session_id(session_id)?;
111        let session_path = self.session_path(&session_id);
112        if session_path.exists() {
113            let mut existing = read_assignment(&session_path)
114                .with_context(|| format!("read existing assignment {}", session_path.display()))?;
115            existing.updated_at = Utc::now();
116            replace_assignment(&session_path, &existing)?;
117            return Ok(existing);
118        }
119
120        let realm = normalize_component(realm, "realm")?;
121        let requested_family = family_name
122            .map(|value| normalize_component(value, "family name"))
123            .transpose()?;
124        let first_names = names::first_names();
125        let family_names = names::family_names();
126        if first_names.is_empty() || family_names.is_empty() {
127            bail!("name lists are empty");
128        }
129
130        fs::create_dir_all(self.root.join("by-session"))
131            .with_context(|| format!("create {}", self.root.display()))?;
132        fs::create_dir_all(self.root.join("by-name"))
133            .with_context(|| format!("create {}", self.root.display()))?;
134
135        for attempt in 0..100_000_u64 {
136            let (first, family) = candidate(
137                &session_id,
138                attempt,
139                &first_names,
140                &family_names,
141                requested_family.as_deref(),
142            );
143            if first == family {
144                continue;
145            }
146
147            let first_name = title_word(first);
148            let family_name = title_word(&family);
149
150            let name = format!("{first_name} {family_name} of {realm}");
151            let slug = slug(&first_name, &family_name, &realm);
152            let claim_path = self.root.join("by-name").join(&slug);
153
154            match claim_name(&claim_path, &session_id)? {
155                Claim::Owned => {}
156                Claim::Claimed => {}
157                Claim::Other => continue,
158            }
159
160            let now = Utc::now();
161            let assignment = Assignment {
162                version: 1,
163                session_id: session_id.clone(),
164                name,
165                slug,
166                first_name,
167                family_name,
168                realm: realm.clone(),
169                state: ActivityState::unknown(now),
170                cwd: None,
171                summary: None,
172                extensions: BTreeMap::new(),
173                created_at: now,
174                updated_at: now,
175            };
176            write_assignment(&session_path, &assignment)?;
177            return Ok(assignment);
178        }
179
180        bail!("exhausted available names for realm {realm}")
181    }
182
183    pub fn lookup(&self, input: &str) -> Result<Assignment> {
184        let input = require_nonempty(input, "lookup identifier")?;
185        if let Ok(session_id) = validate_session_id(&input) {
186            let session_path = self.session_path(&session_id);
187            if session_path.exists() {
188                return self.lookup_session(&session_id);
189            }
190        }
191
192        for slug in lookup_slugs(&input) {
193            let claim_path = self.root.join("by-name").join(&slug);
194            if !claim_path.is_file() {
195                continue;
196            }
197
198            let session_id = require_nonempty(
199                &fs::read_to_string(&claim_path)
200                    .with_context(|| format!("read name claim {}", claim_path.display()))?,
201                "claimed session ID",
202            )?;
203            return self
204                .lookup_session(&session_id)
205                .with_context(|| format!("resolve name claim {slug}"));
206        }
207
208        bail!(
209            "no identity found for '{input}'; lookup accepts a session ID, canonical name, or slug"
210        )
211    }
212
213    fn lookup_session(&self, session_id: &str) -> Result<Assignment> {
214        let session_id = validate_session_id(session_id)?;
215        let path = self.session_path(&session_id);
216        if !path.exists() {
217            bail!(
218                "no identity registered for session {session_id}; run `agent-id register {session_id}`"
219            );
220        }
221
222        let assignment = read_assignment(&path)
223            .with_context(|| format!("read assignment for session {session_id}"))?;
224        if assignment.session_id != session_id {
225            bail!("identity registry entry does not belong to session {session_id}");
226        }
227        Ok(assignment)
228    }
229
230    pub fn annotate(&self, session_id: &str, update: ActivityUpdate) -> Result<Assignment> {
231        let session_id = validate_session_id(session_id)?;
232        if update.summary.is_none()
233            && !update.clear_summary
234            && update.state.is_none()
235            && !update.clear_state
236            && update.cwd.is_none()
237            && !update.clear_cwd
238            && update.extensions.is_empty()
239            && update.clear_extensions.is_empty()
240        {
241            bail!("pass at least one activity update");
242        }
243        if update.summary.is_some() && update.clear_summary {
244            bail!("summary and clear_summary are mutually exclusive");
245        }
246        if update.state.is_some() && update.clear_state {
247            bail!("state and clear_state are mutually exclusive");
248        }
249        if update.cwd.is_some() && update.clear_cwd {
250            bail!("cwd and clear_cwd are mutually exclusive");
251        }
252        if let Some(owner) = update
253            .extensions
254            .keys()
255            .find(|owner| update.clear_extensions.contains(*owner))
256        {
257            bail!("extension {owner} cannot be set and cleared together");
258        }
259
260        let mut assignment = self.lookup_session(&session_id)?;
261        let summary = update
262            .summary
263            .as_deref()
264            .map(normalize_summary)
265            .transpose()?;
266        let cwd = update.cwd.as_deref().map(normalize_cwd).transpose()?;
267        let now = Utc::now();
268        if update.summary.is_some() {
269            assignment.summary = summary.map(|text| ActivitySummary {
270                text,
271                updated_at: now,
272            });
273        } else if update.clear_summary {
274            assignment.summary = None;
275        }
276        if update.cwd.is_some() {
277            assignment.cwd = cwd;
278        } else if update.clear_cwd {
279            assignment.cwd = None;
280        }
281        for (owner, data) in update.extensions {
282            assignment.extensions.insert(
283                owner,
284                ExtensionMetadata {
285                    data,
286                    updated_at: now,
287                },
288            );
289        }
290        for owner in update.clear_extensions {
291            assignment.extensions.remove(&owner);
292        }
293        if let Some(value) = update.state {
294            set_omp_activity_state(&mut assignment.extensions, value, now);
295        } else if update.clear_state {
296            clear_omp_activity_state(&mut assignment.extensions, now);
297        }
298        normalize_omp_activity_state(&mut assignment.extensions, now);
299        assignment.updated_at = now;
300        assignment.state = materialize_omp_activity_state(&assignment.extensions, now);
301        replace_assignment(&self.session_path(&session_id), &assignment)?;
302        Ok(assignment)
303    }
304
305    pub fn discover(
306        &self,
307        recent_hours: Option<i64>,
308        realm: Option<&str>,
309    ) -> Result<Vec<Assignment>> {
310        if recent_hours.is_some_and(|hours| hours < 0) {
311            bail!("--recent must be non-negative");
312        }
313        let realm = realm
314            .map(|value| normalize_component(value, "realm"))
315            .transpose()?;
316        let cutoff = recent_hours.map(|hours| Utc::now() - Duration::hours(hours));
317        let path = self.root.join("by-session");
318        let entries = match fs::read_dir(&path) {
319            Ok(entries) => entries,
320            Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()),
321            Err(error) => return Err(error).with_context(|| format!("read {}", path.display())),
322        };
323        let mut assignments = Vec::new();
324        for entry in entries {
325            let path = entry?.path();
326            if path.extension().and_then(|value| value.to_str()) != Some("json") {
327                continue;
328            }
329            let assignment = read_assignment(&path)
330                .with_context(|| format!("read assignment {}", path.display()))?;
331            if realm
332                .as_deref()
333                .is_some_and(|value| value != assignment.realm)
334            {
335                continue;
336            }
337            if cutoff.is_some_and(|value| assignment.updated_at < value) {
338                continue;
339            }
340            assignments.push(assignment);
341        }
342        assignments.sort_by(|left, right| right.updated_at.cmp(&left.updated_at));
343        Ok(assignments)
344    }
345
346    pub fn prune(&self, cutoff: DateTime<Utc>, dry_run: bool) -> Result<PruneReport> {
347        let path = self.root.join("by-session");
348        let entries = match fs::read_dir(&path) {
349            Ok(entries) => entries,
350            Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
351                return Ok(PruneReport {
352                    cutoff,
353                    dry_run,
354                    candidates: Vec::new(),
355                    removed: Vec::new(),
356                    errors: Vec::new(),
357                })
358            }
359            Err(error) => return Err(error).with_context(|| format!("read {}", path.display())),
360        };
361        let mut candidates = Vec::new();
362        for entry in entries {
363            let path = entry?.path();
364            if path.extension().and_then(|value| value.to_str()) != Some("json") {
365                continue;
366            }
367            let assignment = read_assignment(&path)
368                .with_context(|| format!("read assignment {}", path.display()))?;
369            if assignment.updated_at < cutoff {
370                candidates.push(assignment);
371            }
372        }
373        candidates.sort_by(|left, right| left.updated_at.cmp(&right.updated_at));
374        let mut report = PruneReport {
375            cutoff,
376            dry_run,
377            candidates,
378            removed: Vec::new(),
379            errors: Vec::new(),
380        };
381        if dry_run {
382            return Ok(report);
383        }
384
385        for assignment in &report.candidates {
386            let session_path = self.session_path(&assignment.session_id);
387            if let Err(error) = fs::remove_file(&session_path) {
388                report
389                    .errors
390                    .push(format!("remove {}: {error}", session_path.display()));
391                continue;
392            }
393
394            let claim_path = self.root.join("by-name").join(&assignment.slug);
395            let claim_removed = match fs::read_to_string(&claim_path) {
396                Ok(owner) if owner.trim() == assignment.session_id => {
397                    if let Err(error) = fs::remove_file(&claim_path) {
398                        report.errors.push(format!(
399                            "remove name claim {}: {error}",
400                            claim_path.display()
401                        ));
402                        false
403                    } else {
404                        true
405                    }
406                }
407                Ok(owner) => {
408                    report.errors.push(format!(
409                        "name claim {} belongs to {}, not {}",
410                        claim_path.display(),
411                        owner.trim(),
412                        assignment.session_id
413                    ));
414                    false
415                }
416                Err(error) if error.kind() == std::io::ErrorKind::NotFound => false,
417                Err(error) => {
418                    report
419                        .errors
420                        .push(format!("read name claim {}: {error}", claim_path.display()));
421                    false
422                }
423            };
424            report.removed.push(PrunedIdentity {
425                session_id: assignment.session_id.clone(),
426                name: assignment.name.clone(),
427                slug: assignment.slug.clone(),
428                updated_at: assignment.updated_at,
429                claim_removed,
430            });
431        }
432        Ok(report)
433    }
434
435    fn session_path(&self, session_id: &str) -> PathBuf {
436        self.root
437            .join("by-session")
438            .join(format!("{session_id}.json"))
439    }
440}
441
442pub fn execute_register(args: &RegisterArgs) -> Result<()> {
443    let session_id = resolve_session(args.explicit_session())?;
444    let realm = resolve_realm(args.realm.as_deref())?;
445    let assignment = Registry::from_env()?.register(&session_id, args.family.as_deref(), &realm)?;
446    print_assignment(&assignment, args.json)
447}
448
449pub fn execute_lookup(args: &LookupArgs) -> Result<()> {
450    let input = resolve_session(args.explicit_input())?;
451    let assignment = Registry::from_env()?.lookup(&input)?;
452    print_assignment(&assignment, args.json)
453}
454
455pub fn execute_current(args: &CurrentArgs) -> Result<()> {
456    let session_id = resolve_session(None)?;
457    let assignment = Registry::from_env()?.lookup(&session_id)?;
458    print_assignment(&assignment, args.json)
459}
460
461pub fn execute_annotate(args: &AnnotateArgs) -> Result<()> {
462    let session_id = resolve_session(args.explicit_session())?;
463    let update = ActivityUpdate {
464        summary: args.summary.clone(),
465        clear_summary: args.clear_summary,
466        state: args.state,
467        clear_state: args.clear_state,
468        cwd: args.cwd.clone(),
469        clear_cwd: args.clear_cwd,
470        extensions: parse_extension_updates(&args.extensions)?,
471        clear_extensions: parse_extension_owners(&args.clear_extensions)?,
472    };
473    let assignment = Registry::from_env()?.annotate(&session_id, update)?;
474    print_assignment(&assignment, args.json)
475}
476
477pub fn execute_discover(args: &DiscoverArgs) -> Result<()> {
478    let assignments = Registry::from_env()?.discover(args.recent, args.realm.as_deref())?;
479    let mut records = crate::herdr::augment_discovery(assignments, args.all);
480    if !args.all {
481        records.retain(|record| record.assignment.state.value != ActivityStateValue::Stopped);
482    }
483    if args.limit > 0 {
484        records.truncate(args.limit);
485    }
486    if args.json {
487        println!("{}", serde_json::to_string_pretty(&records)?);
488    } else if records.is_empty() {
489        println!("(no identities)");
490    } else {
491        for record in records {
492            let assignment = &record.assignment;
493            let mut annotations = Vec::new();
494            annotations.push(format!("state:{}", assignment.state.value));
495            if let Some(summary) = assignment.summary.as_ref() {
496                annotations.push(format!("summary:{}", summary.text));
497            }
498            if let Some(cwd) = assignment.cwd.as_ref() {
499                annotations.push(format!("cwd:{cwd}"));
500            }
501            if let Some(runtime) = record.runtime {
502                for location in runtime.locations {
503                    let workspace = location
504                        .workspace_label
505                        .as_deref()
506                        .unwrap_or(&location.workspace_id);
507                    annotations.push(format!(
508                        "herdr:{} pane:{} workspace:{}",
509                        location.agent_status, location.pane_id, workspace
510                    ));
511                }
512            }
513            println!(
514                "{}\t{}\t{}",
515                assignment.name,
516                assignment.session_id,
517                annotations.join("\t")
518            );
519        }
520    }
521    Ok(())
522}
523
524pub fn execute_prune(args: &PruneArgs) -> Result<()> {
525    let cutoff = DateTime::parse_from_rfc3339(&args.before)
526        .with_context(|| format!("parse --before timestamp {}", args.before))?
527        .with_timezone(&Utc);
528    let report = Registry::from_env()?.prune(cutoff, args.dry_run)?;
529    if args.json {
530        println!("{}", serde_json::to_string_pretty(&report)?);
531    } else {
532        let action = if args.dry_run {
533            "would prune"
534        } else {
535            "pruned"
536        };
537        println!(
538            "{action} {} identities before {}",
539            report.candidates.len(),
540            cutoff
541        );
542        for assignment in &report.candidates {
543            println!(
544                "{}\t{}\tupdated:{}",
545                assignment.name, assignment.session_id, assignment.updated_at
546            );
547        }
548        for error in &report.errors {
549            eprintln!("agent-id: {error}");
550        }
551    }
552    if report.errors.is_empty() {
553        Ok(())
554    } else {
555        bail!("prune completed with {} errors", report.errors.len())
556    }
557}
558
559pub fn resolve_session(explicit: Option<&str>) -> Result<String> {
560    if let Some(session_id) = explicit.filter(|value| !value.trim().is_empty()) {
561        return Ok(session_id.trim().to_string());
562    }
563
564    if let Some(value) = env::var_os("AGENT_ID_SESSION_ID") {
565        let value = value.to_string_lossy();
566        if !value.trim().is_empty() {
567            return Ok(value.trim().to_string());
568        }
569    }
570
571    bail!("no session ID found; pass SESSION_ID or --session-id, or set AGENT_ID_SESSION_ID")
572}
573
574fn resolve_realm(explicit: Option<&str>) -> Result<String> {
575    if let Some(realm) = explicit.filter(|value| !value.trim().is_empty()) {
576        return normalize_component(realm, "realm");
577    }
578    if let Some(realm) = env::var_os("AGENT_REALM") {
579        let realm = realm.to_string_lossy();
580        if !realm.trim().is_empty() {
581            return normalize_component(&realm, "realm");
582        }
583    }
584
585    let home = home_dir()?;
586    let config_home = env::var_os("XDG_CONFIG_HOME")
587        .map(PathBuf::from)
588        .unwrap_or_else(|| home.join(".config"));
589    let realm_path = config_home.join("agent-id/realm");
590
591    if realm_path.is_file() {
592        let value = fs::read_to_string(&realm_path)
593            .with_context(|| format!("read realm from {}", realm_path.display()))?;
594        return normalize_component(&value, "realm");
595    }
596
597    auto_create_realm(&realm_path)
598}
599
600fn auto_create_realm(path: &Path) -> Result<String> {
601    let candidates = names::candidate_realms();
602    if candidates.is_empty() {
603        bail!("bundled realm candidate list is empty");
604    }
605
606    let nanos = SystemTime::now()
607        .duration_since(UNIX_EPOCH)
608        .map(|duration| duration.as_nanos())
609        .unwrap_or_default();
610    let pid = std::process::id();
611    let hostname = env::var("HOSTNAME").unwrap_or_default();
612    let digest = Sha256::digest(format!("{nanos}:{pid}:{hostname}").as_bytes());
613    let index = usize::try_from(u64::from_be_bytes(digest[0..8].try_into().unwrap())).unwrap_or(0)
614        % candidates.len();
615    let realm = normalize_component(candidates[index], "realm")?;
616
617    let parent = path
618        .parent()
619        .ok_or_else(|| anyhow!("realm path has no parent directory"))?;
620    fs::create_dir_all(parent).with_context(|| format!("create {}", parent.display()))?;
621
622    let temp = temporary_path(path);
623    let mut file = File::create(&temp)
624        .with_context(|| format!("create temporary realm {}", temp.display()))?;
625    file.write_all(format!("{realm}\n").as_bytes())?;
626    file.sync_all()?;
627
628    match fs::hard_link(&temp, path) {
629        Ok(()) => {
630            let _ = fs::remove_file(temp);
631            Ok(realm)
632        }
633        Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => {
634            let _ = fs::remove_file(temp);
635            let value = fs::read_to_string(path)
636                .with_context(|| format!("read existing realm {}", path.display()))?;
637            normalize_component(&value, "realm")
638        }
639        Err(error) => {
640            let _ = fs::remove_file(temp);
641            Err(error).with_context(|| format!("create realm file {}", path.display()))
642        }
643    }
644}
645
646fn print_assignment(assignment: &Assignment, json: bool) -> Result<()> {
647    if json {
648        println!("{}", serde_json::to_string_pretty(assignment)?);
649    } else {
650        println!("{}", assignment.name);
651    }
652    Ok(())
653}
654
655fn lookup_slugs(input: &str) -> Vec<String> {
656    let mut slugs = Vec::new();
657    let input = input.trim();
658    if is_slug(input) {
659        slugs.push(input.to_ascii_lowercase());
660    }
661    if let Some(slug) = canonical_name_slug(input) {
662        if !slugs.contains(&slug) {
663            slugs.push(slug);
664        }
665    }
666    slugs
667}
668
669fn canonical_name_slug(input: &str) -> Option<String> {
670    let mut parts = input.split_whitespace();
671    let first = normalize_component(parts.next()?, "first name").ok()?;
672    let family = normalize_component(parts.next()?, "family name").ok()?;
673    if !parts.next()?.eq_ignore_ascii_case("of") {
674        return None;
675    }
676    let realm = normalize_component(parts.next()?, "realm").ok()?;
677    if parts.next().is_some() {
678        return None;
679    }
680    Some(slug(&first, &family, &realm))
681}
682
683fn is_slug(input: &str) -> bool {
684    !input.is_empty()
685        && !input.starts_with('-')
686        && !input.ends_with('-')
687        && input
688            .chars()
689            .all(|character| character.is_ascii_alphanumeric() || character == '-')
690}
691
692fn candidate<'a>(
693    session_id: &str,
694    attempt: u64,
695    first_names: &'a [&'a str],
696    family_names: &'a [&'a str],
697    requested_family: Option<&str>,
698) -> (&'a str, String) {
699    let digest = Sha256::digest(format!("{session_id}:{attempt}").as_bytes());
700    let first_index = usize::try_from(u64::from_be_bytes(digest[0..8].try_into().unwrap()))
701        .unwrap_or(0)
702        % first_names.len();
703    let family_index = usize::try_from(u64::from_be_bytes(digest[8..16].try_into().unwrap()))
704        .unwrap_or(0)
705        % family_names.len();
706    let family = requested_family
707        .map(ToOwned::to_owned)
708        .unwrap_or_else(|| family_names[family_index].to_string());
709    (first_names[first_index], family)
710}
711
712#[derive(Debug, Clone, Copy, PartialEq, Eq)]
713enum Claim {
714    Claimed,
715    Owned,
716    Other,
717}
718
719fn claim_name(path: &Path, session_id: &str) -> Result<Claim> {
720    let parent = path
721        .parent()
722        .ok_or_else(|| anyhow!("name claim has no parent directory"))?;
723    fs::create_dir_all(parent).with_context(|| format!("create {}", parent.display()))?;
724
725    let temp = temporary_path(path);
726    let mut file = File::create(&temp)
727        .with_context(|| format!("create temporary claim {}", temp.display()))?;
728    file.write_all(session_id.as_bytes())?;
729    file.write_all(b"\n")?;
730    file.sync_all()?;
731
732    let claim = match fs::hard_link(&temp, path) {
733        Ok(()) => Claim::Claimed,
734        Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => {
735            let owner = fs::read_to_string(path).unwrap_or_default();
736            if owner.trim() == session_id {
737                Claim::Owned
738            } else {
739                Claim::Other
740            }
741        }
742        Err(error) => return Err(error).with_context(|| format!("claim name {}", path.display())),
743    };
744    let _ = fs::remove_file(temp);
745    Ok(claim)
746}
747
748#[derive(Debug, Serialize)]
749struct PersistedAssignment<'a> {
750    version: u8,
751    session_id: &'a str,
752    name: &'a str,
753    slug: &'a str,
754    first_name: &'a str,
755    family_name: &'a str,
756    realm: &'a str,
757    summary: &'a Option<ActivitySummary>,
758    cwd: &'a Option<String>,
759    #[serde(skip_serializing_if = "BTreeMap::is_empty")]
760    extensions: &'a BTreeMap<String, ExtensionMetadata>,
761    created_at: DateTime<Utc>,
762    updated_at: DateTime<Utc>,
763}
764
765fn persisted_assignment(assignment: &Assignment) -> PersistedAssignment<'_> {
766    PersistedAssignment {
767        version: assignment.version,
768        session_id: &assignment.session_id,
769        name: &assignment.name,
770        slug: &assignment.slug,
771        first_name: &assignment.first_name,
772        family_name: &assignment.family_name,
773        realm: &assignment.realm,
774        summary: &assignment.summary,
775        cwd: &assignment.cwd,
776        extensions: &assignment.extensions,
777        created_at: assignment.created_at,
778        updated_at: assignment.updated_at,
779    }
780}
781
782#[derive(Debug, Deserialize)]
783#[serde(deny_unknown_fields)]
784struct StoredAssignment {
785    version: u8,
786    session_id: String,
787    name: String,
788    slug: String,
789    first_name: String,
790    family_name: String,
791    realm: String,
792    #[serde(default)]
793    summary: Option<ActivitySummary>,
794    #[serde(default)]
795    cwd: Option<String>,
796    #[serde(default)]
797    extensions: BTreeMap<String, ExtensionMetadata>,
798    created_at: DateTime<Utc>,
799    updated_at: DateTime<Utc>,
800}
801
802impl StoredAssignment {
803    fn into_assignment(self) -> Assignment {
804        let StoredAssignment {
805            version,
806            session_id,
807            name,
808            slug,
809            first_name,
810            family_name,
811            realm,
812            summary,
813            cwd,
814            extensions,
815            created_at,
816            updated_at,
817        } = self;
818        let state = materialize_omp_activity_state(&extensions, updated_at);
819        Assignment {
820            version,
821            session_id,
822            name,
823            slug,
824            first_name,
825            family_name,
826            realm,
827            summary,
828            state,
829            cwd,
830            extensions,
831            created_at,
832            updated_at,
833        }
834    }
835}
836
837fn write_assignment(path: &Path, assignment: &Assignment) -> Result<()> {
838    let parent = path
839        .parent()
840        .ok_or_else(|| anyhow!("assignment has no parent directory"))?;
841    fs::create_dir_all(parent).with_context(|| format!("create {}", parent.display()))?;
842    let contents = format!(
843        "{}\n",
844        serde_json::to_string_pretty(&persisted_assignment(assignment))?
845    );
846    let temp = temporary_path(path);
847    let mut file = File::create(&temp)
848        .with_context(|| format!("create temporary assignment {}", temp.display()))?;
849    file.write_all(contents.as_bytes())?;
850    file.sync_all()?;
851
852    let result = match fs::hard_link(&temp, path) {
853        Ok(()) => Ok(()),
854        Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => {
855            bail!(
856                "session {} already has a registered identity",
857                assignment.session_id
858            )
859        }
860        Err(error) => Err(error).with_context(|| format!("write assignment {}", path.display())),
861    };
862    let _ = fs::remove_file(temp);
863    result
864}
865
866fn replace_assignment(path: &Path, assignment: &Assignment) -> Result<()> {
867    let parent = path
868        .parent()
869        .ok_or_else(|| anyhow!("assignment has no parent directory"))?;
870    fs::create_dir_all(parent).with_context(|| format!("create {}", parent.display()))?;
871    let contents = format!(
872        "{}\n",
873        serde_json::to_string_pretty(&persisted_assignment(assignment))?
874    );
875    let temp = temporary_path(path);
876    let mut file = File::create(&temp)
877        .with_context(|| format!("create temporary assignment {}", temp.display()))?;
878    file.write_all(contents.as_bytes())?;
879    file.sync_all()?;
880
881    let result =
882        fs::rename(&temp, path).with_context(|| format!("replace assignment {}", path.display()));
883    if result.is_err() {
884        let _ = fs::remove_file(temp);
885    }
886    result
887}
888
889fn read_assignment(path: &Path) -> Result<Assignment> {
890    let contents = fs::read_to_string(path)?;
891    Ok(serde_json::from_str::<StoredAssignment>(&contents)?.into_assignment())
892}
893const OMP_EXTENSION_OWNER: &str = "omp";
894const OMP_STATE_KEY: &str = "state";
895
896fn omp_activity_state(extensions: &BTreeMap<String, ExtensionMetadata>) -> Option<ActivityState> {
897    let metadata = extensions.get(OMP_EXTENSION_OWNER)?;
898    let state = metadata.data.get(OMP_STATE_KEY)?;
899    if let Some(value) = state.as_str() {
900        return Some(ActivityState::from_external(value, metadata.updated_at));
901    }
902    serde_json::from_value(state.clone()).ok()
903}
904
905fn materialize_omp_activity_state(
906    extensions: &BTreeMap<String, ExtensionMetadata>,
907    fallback_updated_at: DateTime<Utc>,
908) -> ActivityState {
909    omp_activity_state(extensions).unwrap_or_else(|| ActivityState::unknown(fallback_updated_at))
910}
911
912fn set_omp_activity_state(
913    extensions: &mut BTreeMap<String, ExtensionMetadata>,
914    value: ActivityStateValue,
915    updated_at: DateTime<Utc>,
916) {
917    let metadata = extensions
918        .entry(OMP_EXTENSION_OWNER.to_string())
919        .or_insert_with(|| ExtensionMetadata {
920            data: serde_json::json!({}),
921            updated_at,
922        });
923    if !metadata.data.is_object() {
924        metadata.data = serde_json::json!({});
925    }
926    metadata
927        .data
928        .as_object_mut()
929        .expect("state extension data was just normalized to an object")
930        .insert(
931            OMP_STATE_KEY.to_string(),
932            serde_json::json!({
933                "value": value,
934                "updated_at": updated_at,
935            }),
936        );
937    metadata.updated_at = updated_at;
938}
939
940fn clear_omp_activity_state(
941    extensions: &mut BTreeMap<String, ExtensionMetadata>,
942    updated_at: DateTime<Utc>,
943) {
944    let Some(metadata) = extensions.get_mut(OMP_EXTENSION_OWNER) else {
945        return;
946    };
947    if let Some(data) = metadata.data.as_object_mut() {
948        data.remove(OMP_STATE_KEY);
949        metadata.updated_at = updated_at;
950    }
951}
952
953fn normalize_omp_activity_state(
954    extensions: &mut BTreeMap<String, ExtensionMetadata>,
955    updated_at: DateTime<Utc>,
956) {
957    let value = extensions
958        .get(OMP_EXTENSION_OWNER)
959        .and_then(|metadata| metadata.data.get(OMP_STATE_KEY))
960        .and_then(|state| state.as_str())
961        .map(ActivityStateValue::from_external);
962    if let Some(value) = value {
963        set_omp_activity_state(extensions, value, updated_at);
964    }
965}
966
967static TEMP_COUNTER: AtomicU64 = AtomicU64::new(0);
968
969fn temporary_path(path: &Path) -> PathBuf {
970    let nanos = SystemTime::now()
971        .duration_since(UNIX_EPOCH)
972        .map(|duration| duration.as_nanos())
973        .unwrap_or_default();
974    let counter = TEMP_COUNTER.fetch_add(1, Ordering::Relaxed);
975    path.with_extension(format!("tmp-{}-{nanos}-{counter}", std::process::id()))
976}
977
978fn slug(first_name: &str, family_name: &str, realm: &str) -> String {
979    [first_name, family_name, realm]
980        .iter()
981        .map(|part| part.to_ascii_lowercase().replace([' ', '\'', '_'], "-"))
982        .collect::<Vec<_>>()
983        .join("-")
984}
985
986fn normalize_component(value: &str, kind: &str) -> Result<String> {
987    let value = value.trim();
988    let valid = !value.is_empty()
989        && value.chars().all(|character| {
990            character.is_ascii_alphabetic() || character == '-' || character == '\''
991        })
992        && value
993            .chars()
994            .next()
995            .is_some_and(|character| character.is_ascii_alphabetic())
996        && value
997            .chars()
998            .last()
999            .is_some_and(|character| character.is_ascii_alphabetic());
1000    if !valid {
1001        bail!("{kind} must contain only letters, apostrophes, or hyphens")
1002    }
1003    Ok(title_word(value))
1004}
1005
1006fn title_word(value: &str) -> String {
1007    let mut characters = value.chars();
1008    let Some(first) = characters.next() else {
1009        return String::new();
1010    };
1011    first.to_uppercase().collect::<String>() + &characters.as_str().to_ascii_lowercase()
1012}
1013
1014fn validate_session_id(value: &str) -> Result<String> {
1015    let value = require_nonempty(value, "session ID")?;
1016    let safe = value
1017        .chars()
1018        .all(|character| character.is_ascii_alphanumeric() || matches!(character, '-' | '_' | '.'));
1019    if !safe || value == "." || value == ".." {
1020        bail!("session ID must be a filename-safe value using letters, digits, '.', '_' or '-'");
1021    }
1022    Ok(value)
1023}
1024
1025const MAX_EXTENSION_OWNER_CHARS: usize = 64;
1026const MAX_EXTENSION_JSON_BYTES: usize = 16 * 1024;
1027
1028fn parse_extension_updates(values: &[String]) -> Result<BTreeMap<String, serde_json::Value>> {
1029    let mut extensions = BTreeMap::new();
1030    for value in values {
1031        let (owner, json) = value
1032            .split_once('=')
1033            .ok_or_else(|| anyhow!("--extension must use OWNER=JSON"))?;
1034        let owner = normalize_extension_owner(owner)?;
1035        if json.len() > MAX_EXTENSION_JSON_BYTES {
1036            bail!("extension {owner} JSON must be at most {MAX_EXTENSION_JSON_BYTES} bytes");
1037        }
1038        let data = serde_json::from_str(json)
1039            .with_context(|| format!("parse JSON for extension {owner}"))?;
1040        if extensions.insert(owner.clone(), data).is_some() {
1041            bail!("extension {owner} was provided more than once");
1042        }
1043    }
1044    Ok(extensions)
1045}
1046
1047fn parse_extension_owners(values: &[String]) -> Result<BTreeSet<String>> {
1048    values
1049        .iter()
1050        .map(|owner| normalize_extension_owner(owner))
1051        .collect()
1052}
1053
1054fn normalize_extension_owner(value: &str) -> Result<String> {
1055    let owner = require_nonempty(value, "extension owner")?;
1056    let valid = owner.chars().count() <= MAX_EXTENSION_OWNER_CHARS
1057        && owner
1058            .chars()
1059            .next()
1060            .is_some_and(|character| character.is_ascii_lowercase() || character.is_ascii_digit())
1061        && owner.chars().all(|character| {
1062            character.is_ascii_lowercase()
1063                || character.is_ascii_digit()
1064                || matches!(character, '.' | '_' | '-')
1065        });
1066    if !valid {
1067        bail!(
1068            "extension owner must be at most {MAX_EXTENSION_OWNER_CHARS} characters using lowercase letters, digits, '.', '_' or '-'"
1069        );
1070    }
1071    Ok(owner)
1072}
1073
1074const MAX_SUMMARY_CHARS: usize = 240;
1075
1076fn normalize_summary(value: &str) -> Result<String> {
1077    let mut normalized = String::new();
1078    for word in value.split_whitespace() {
1079        if !normalized.is_empty() {
1080            normalized.push(' ');
1081        }
1082        normalized.push_str(word);
1083    }
1084    if normalized.is_empty() {
1085        bail!("summary cannot be empty");
1086    }
1087    if normalized.chars().count() > MAX_SUMMARY_CHARS {
1088        bail!("summary must be at most {MAX_SUMMARY_CHARS} characters");
1089    }
1090    Ok(normalized)
1091}
1092
1093const MAX_CWD_CHARS: usize = 4096;
1094
1095fn normalize_cwd(value: &str) -> Result<String> {
1096    let value = require_nonempty(value, "working directory")?;
1097    if value.chars().any(char::is_control) {
1098        bail!("working directory cannot contain control characters");
1099    }
1100    if value.chars().count() > MAX_CWD_CHARS {
1101        bail!("working directory must be at most {MAX_CWD_CHARS} characters");
1102    }
1103    Ok(value)
1104}
1105
1106fn require_nonempty(value: &str, kind: &str) -> Result<String> {
1107    let value = value.trim();
1108    if value.is_empty() {
1109        bail!("{kind} cannot be empty")
1110    }
1111    Ok(value.to_string())
1112}
1113
1114fn home_dir() -> Result<PathBuf> {
1115    env::var_os("HOME")
1116        .map(PathBuf::from)
1117        .ok_or_else(|| anyhow!("HOME is not set; set AGENT_ID_HOME explicitly"))
1118}
1119
1120#[cfg(test)]
1121mod tests {
1122    use super::*;
1123
1124    #[test]
1125    fn generated_identity_has_canonical_parts() {
1126        let registry = Registry::new(tempfile::tempdir().unwrap().path().to_path_buf());
1127        let assignment = registry
1128            .register("session-1", Some("Oak"), "Darkwood")
1129            .unwrap();
1130
1131        assert_eq!(assignment.family_name, "Oak");
1132        assert_eq!(assignment.realm, "Darkwood");
1133        assert_eq!(
1134            assignment.slug,
1135            format!(
1136                "{}-oak-darkwood",
1137                assignment.first_name.to_ascii_lowercase()
1138            )
1139        );
1140        assert_eq!(
1141            assignment.name,
1142            format!("{} Oak of Darkwood", assignment.first_name)
1143        );
1144    }
1145
1146    #[test]
1147    fn session_id_is_the_registry_filename() {
1148        let root = tempfile::tempdir().unwrap();
1149        let registry = Registry::new(root.path().to_path_buf());
1150        registry
1151            .register("session-visible", None, "Darkwood")
1152            .unwrap();
1153
1154        assert!(root
1155            .path()
1156            .join("by-session/session-visible.json")
1157            .is_file());
1158    }
1159
1160    #[test]
1161    fn unsafe_session_ids_are_rejected() {
1162        let registry = Registry::new(tempfile::tempdir().unwrap().path().to_path_buf());
1163        let error = registry
1164            .register("../escape", None, "Darkwood")
1165            .unwrap_err();
1166        assert!(error.to_string().contains("filename-safe"));
1167    }
1168
1169    #[test]
1170    fn lookup_accepts_session_name_and_slug() {
1171        let registry = Registry::new(tempfile::tempdir().unwrap().path().to_path_buf());
1172        let assignment = registry
1173            .register("session-lookup", Some("Oak"), "Darkwood")
1174            .unwrap();
1175
1176        assert_eq!(registry.lookup(&assignment.session_id).unwrap(), assignment);
1177        assert_eq!(registry.lookup(&assignment.name).unwrap(), assignment);
1178        assert_eq!(registry.lookup(&assignment.slug).unwrap(), assignment);
1179    }
1180
1181    #[test]
1182    fn missing_realm_is_auto_created_and_reused() {
1183        let config_dir = tempfile::tempdir().unwrap();
1184        let realm_file = config_dir.path().join("agent-id/realm");
1185        assert!(!realm_file.exists());
1186
1187        let first = auto_create_realm(&realm_file).unwrap();
1188        assert!(realm_file.is_file());
1189        let contents = fs::read_to_string(&realm_file).unwrap();
1190        assert_eq!(contents.trim(), first);
1191
1192        let second = auto_create_realm(&realm_file).unwrap();
1193        assert_eq!(second, first);
1194    }
1195
1196    #[test]
1197    fn concurrent_realm_creation_keeps_one_value() {
1198        let config_dir = tempfile::tempdir().unwrap();
1199        let realm_file = std::sync::Arc::new(config_dir.path().join("agent-id/realm"));
1200        let handles = (0..8)
1201            .map(|_| {
1202                let realm_file = std::sync::Arc::clone(&realm_file);
1203                std::thread::spawn(move || auto_create_realm(&realm_file).unwrap())
1204            })
1205            .collect::<Vec<_>>();
1206        let mut handles = handles.into_iter();
1207        let first = handles.next().unwrap().join().unwrap();
1208        for handle in handles {
1209            assert_eq!(handle.join().unwrap(), first);
1210        }
1211    }
1212
1213    #[test]
1214    fn cwd_metadata_is_bounded_and_single_line() {
1215        assert_eq!(normalize_cwd("  /tmp/agent-id  ").unwrap(), "/tmp/agent-id");
1216        assert!(normalize_cwd("/tmp/agent\nid").is_err());
1217        assert!(normalize_cwd(&"x".repeat(MAX_CWD_CHARS + 1)).is_err());
1218    }
1219
1220    #[test]
1221    fn summaries_are_single_line_and_bounded() {
1222        assert_eq!(
1223            normalize_summary("  Implementing\n activity summaries  ").unwrap(),
1224            "Implementing activity summaries"
1225        );
1226        assert!(normalize_summary(" \n\t ").is_err());
1227        assert!(normalize_summary(&"x".repeat(MAX_SUMMARY_CHARS + 1)).is_err());
1228    }
1229
1230    #[test]
1231    fn registering_a_session_updates_existing_identity() {
1232        let registry = Registry::new(tempfile::tempdir().unwrap().path().to_path_buf());
1233        let first = registry.register("session-1", None, "Darkwood").unwrap();
1234        let second = registry.register("session-1", None, "Darkwood").unwrap();
1235
1236        assert_eq!(second.name, first.name);
1237        assert_eq!(second.session_id, first.session_id);
1238        assert_eq!(second.created_at, first.created_at);
1239        assert!(second.updated_at >= first.updated_at);
1240    }
1241
1242    #[test]
1243    fn lookup_requires_a_registered_session() {
1244        let registry = Registry::new(tempfile::tempdir().unwrap().path().to_path_buf());
1245        let error = registry.lookup("missing").unwrap_err();
1246        assert!(error.to_string().contains("no identity found"));
1247    }
1248}