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