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}