@team-agent/installer 0.5.103 → 0.5.105

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (40) hide show
  1. package/Cargo.lock +1 -1
  2. package/Cargo.toml +1 -1
  3. package/crates/team-agent/src/cli/adapters.rs +2 -0
  4. package/crates/team-agent/src/cli/emit.rs +102 -5
  5. package/crates/team-agent/src/cli/mod.rs +8 -14
  6. package/crates/team-agent/src/cli/spec.rs +3 -3
  7. package/crates/team-agent/src/cli/tests/run_delegation.rs +1 -0
  8. package/crates/team-agent/src/cli/types.rs +3 -1
  9. package/crates/team-agent/src/compiler/tests.rs +19 -27
  10. package/crates/team-agent/src/compiler.rs +11 -105
  11. package/crates/team-agent/src/lifecycle/launch/add_agent.rs +6 -2
  12. package/crates/team-agent/src/lifecycle/launch/add_agent_state.rs +10 -6
  13. package/crates/team-agent/src/lifecycle/launch/clone_agent.rs +2 -5
  14. package/crates/team-agent/src/lifecycle/launch/identity.rs +9 -13
  15. package/crates/team-agent/src/lifecycle/launch/role_source.rs +2 -26
  16. package/crates/team-agent/src/lifecycle/launch/spawn.rs +1 -0
  17. package/crates/team-agent/src/lifecycle/launch/spec_state.rs +7 -2
  18. package/crates/team-agent/src/lifecycle/launch.rs +4 -1
  19. package/crates/team-agent/src/lifecycle/mod.rs +1 -0
  20. package/crates/team-agent/src/lifecycle/profile_launch.rs +0 -12
  21. package/crates/team-agent/src/lifecycle/restart/agent.rs +31 -0
  22. package/crates/team-agent/src/lifecycle/restart/avatar.rs +0 -1
  23. package/crates/team-agent/src/lifecycle/restart/common.rs +1 -1
  24. package/crates/team-agent/src/lifecycle/restart.rs +1 -1
  25. package/crates/team-agent/src/lifecycle/role_config.rs +1123 -0
  26. package/crates/team-agent/src/lifecycle/tests/cursor_require_explicit_model_red.rs +7 -19
  27. package/crates/team-agent/src/lifecycle/tests/grok_require_explicit_model_red.rs +8 -41
  28. package/crates/team-agent/src/lifecycle/tests/pi_compiler_red.rs +14 -8
  29. package/crates/team-agent/src/lifecycle/tests/pi_dual_entry_red.rs +8 -4
  30. package/crates/team-agent/src/lifecycle/worker_command_context.rs +4 -4
  31. package/crates/team-agent/src/model/enums.rs +0 -9
  32. package/crates/team-agent/src/model/spec.rs +2 -2
  33. package/crates/team-agent/src/provider/adapters/cursor_agent.rs +7 -8
  34. package/crates/team-agent/src/provider/adapters/grok.rs +5 -18
  35. package/crates/team-agent/src/provider/tests/adapter.rs +15 -24
  36. package/crates/team-agent/src/provider/wire.rs +2 -29
  37. package/crates/team-agent/src/state/repository.rs +2 -1
  38. package/package.json +4 -4
  39. package/skills/team-agent/SKILL.md +83 -33
  40. package/skills/team-agent/references/team-agent-operator.md +54 -523
@@ -0,0 +1,1123 @@
1
+ //! CLI convenience is a role-document transaction, not a provider override.
2
+ //! Existing file compilation and process/session lifecycle remain authoritative.
3
+
4
+ use std::fs::{self, OpenOptions};
5
+ use std::io::Write;
6
+ use std::path::{Path, PathBuf};
7
+ use std::sync::atomic::{AtomicU64, Ordering};
8
+
9
+ use crate::model::ids::AgentId;
10
+ use crate::model::yaml::{self, Value};
11
+ use crate::state::repository::{StateRepository, StateWriteIntent};
12
+ use crate::state::selector::{SelectedTeam, SelectorMode};
13
+ use crate::transport::Transport;
14
+
15
+ use super::lock::{acquire_agent_lifecycle_lock, LifecycleLockRequest};
16
+ use super::{AddAgentReport, LifecycleError, StartAgentOutcome};
17
+
18
+ #[derive(Debug, Clone, Default, PartialEq, Eq)]
19
+ pub struct RoleConfigPatch {
20
+ pub provider: Option<String>,
21
+ pub model: Option<String>,
22
+ pub effort: Option<String>,
23
+ pub bypass: Option<bool>,
24
+ pub prompt: Option<String>,
25
+ pub profile: Option<String>,
26
+ }
27
+
28
+ pub fn add_agent_from_role(
29
+ workspace: &Path,
30
+ agent_id: &AgentId,
31
+ source: Option<&Path>,
32
+ patch: &RoleConfigPatch,
33
+ force: bool,
34
+ team: Option<&str>,
35
+ ) -> Result<AddAgentReport, LifecycleError> {
36
+ if let Some(path) = source {
37
+ if !path.is_file() {
38
+ return Err(LifecycleError::Compile(format!(
39
+ "role file not found: {}",
40
+ path.display()
41
+ )));
42
+ }
43
+ }
44
+ let selected = select(workspace, team)?;
45
+ with_selected(&selected, agent_id, |selected| {
46
+ let transport = super::restart::lifecycle_worker_tmux_backend_selection_for_state(
47
+ &selected.run_workspace,
48
+ &selected.state,
49
+ )?
50
+ .backend;
51
+ add_at_selected(selected, agent_id, source, patch, force, &transport)
52
+ })
53
+ }
54
+
55
+ pub fn start_agent_from_role(
56
+ workspace: &Path,
57
+ agent_id: &AgentId,
58
+ patch: &RoleConfigPatch,
59
+ allow_fresh: bool,
60
+ team: Option<&str>,
61
+ ) -> Result<StartAgentOutcome, LifecycleError> {
62
+ let selected = select(workspace, team)?;
63
+ with_selected(&selected, agent_id, |selected| {
64
+ let transport = super::restart::lifecycle_worker_tmux_backend_selection_for_state(
65
+ &selected.run_workspace,
66
+ &selected.state,
67
+ )?
68
+ .backend;
69
+ start_at_selected(selected, agent_id, patch, allow_fresh, &transport)
70
+ })
71
+ }
72
+
73
+ fn select(workspace: &Path, team: Option<&str>) -> Result<SelectedTeam, LifecycleError> {
74
+ crate::state::selector::resolve_active_team_readonly(workspace, team, SelectorMode::RuntimeOnly)
75
+ .map_err(|error| LifecycleError::TeamSelect(error.to_string()))
76
+ }
77
+
78
+ fn with_selected<T>(
79
+ selected: &SelectedTeam,
80
+ agent_id: &AgentId,
81
+ operation: impl FnOnce(&SelectedTeam) -> Result<T, LifecycleError>,
82
+ ) -> Result<T, LifecycleError> {
83
+ validate_id(agent_id)?;
84
+ let _lock = acquire_agent_lifecycle_lock(LifecycleLockRequest {
85
+ workspace: &selected.run_workspace,
86
+ operation: "role-config",
87
+ team: Some(&selected.team_key),
88
+ agent_id: Some(agent_id),
89
+ })?;
90
+ let latest = select(&selected.run_workspace, Some(&selected.team_key))?;
91
+ super::launch::ensure_owner_allowed_for_state(&latest.state, Some(agent_id))?;
92
+ operation(&latest)
93
+ }
94
+
95
+ fn add_at_selected(
96
+ selected: &SelectedTeam,
97
+ agent_id: &AgentId,
98
+ source: Option<&Path>,
99
+ patch: &RoleConfigPatch,
100
+ force: bool,
101
+ transport: &dyn Transport,
102
+ ) -> Result<AddAgentReport, LifecycleError> {
103
+ let recreate = admit_add(&selected.state, agent_id, force, transport)?;
104
+ let spec = read_spec(selected)?;
105
+ if !recreate && spec_has_agent(&spec, agent_id) {
106
+ return Err(LifecycleError::RequirementUnmet(format!(
107
+ "agent id already exists: {agent_id}"
108
+ )));
109
+ }
110
+ let role_path = role_path(selected, agent_id);
111
+ if !recreate {
112
+ check_destination(&role_path, source)?;
113
+ }
114
+ let text = match source {
115
+ Some(path) => read_text(path)?,
116
+ None => String::new(),
117
+ };
118
+ let reconciled = reconcile_creation(&text, patch)?;
119
+ let next = patched_role(&text, agent_id, &reconciled, source.is_none())?;
120
+ let snapshot = RoleSnapshot::capture(selected, &role_path)?;
121
+ let result = (|| {
122
+ if snapshot.role.bytes.as_deref() != Some(next.as_bytes()) {
123
+ atomic_write(&role_path, next.as_bytes())?;
124
+ }
125
+ // Reuse existing lifecycle transactions; force never admits a live seat.
126
+ let add = if recreate {
127
+ super::launch::force_recreate_with_transport_locked
128
+ } else {
129
+ super::launch::add_agent_with_transport_at_paths_locked
130
+ };
131
+ add(
132
+ &selected.run_workspace,
133
+ &selected.team_dir,
134
+ agent_id,
135
+ &role_path,
136
+ true,
137
+ Some(&selected.team_key),
138
+ transport,
139
+ )
140
+ })();
141
+ match result {
142
+ Ok(report) => Ok(report),
143
+ Err(error) => Err(snapshot.rollback(selected, agent_id, error)),
144
+ }
145
+ }
146
+
147
+ // Disaster recovery is opt-in and requires positive death of a recorded pane.
148
+ // An absent/unknown pane binding is not permission to replace an existing seat.
149
+ fn admit_add(
150
+ state: &serde_json::Value,
151
+ agent_id: &AgentId,
152
+ force: bool,
153
+ transport: &dyn Transport,
154
+ ) -> Result<bool, LifecycleError> {
155
+ let Some(agent) = state
156
+ .get("agents")
157
+ .and_then(|agents| agents.get(agent_id.as_str()))
158
+ else {
159
+ return Ok(false);
160
+ };
161
+ let duplicate =
162
+ || LifecycleError::RequirementUnmet(format!("agent id already exists: {agent_id}"));
163
+ if !force {
164
+ return Err(duplicate());
165
+ }
166
+ super::restart::ensure_agent_not_running(state, agent_id, transport)?;
167
+ let Some(pane) = agent
168
+ .get("pane_id")
169
+ .and_then(serde_json::Value::as_str)
170
+ .filter(|value| !value.is_empty())
171
+ else {
172
+ return Err(duplicate());
173
+ };
174
+ let pane = crate::transport::PaneId::new(pane);
175
+ let dead = match transport.has_pane(&pane) {
176
+ Ok(Some(present)) => !present,
177
+ Ok(None) | Err(_) => matches!(
178
+ transport.liveness(&pane),
179
+ Ok(crate::model::enums::PaneLiveness::Dead)
180
+ ),
181
+ };
182
+ if dead {
183
+ Ok(true)
184
+ } else {
185
+ Err(duplicate())
186
+ }
187
+ }
188
+
189
+ fn start_at_selected(
190
+ selected: &SelectedTeam,
191
+ agent_id: &AgentId,
192
+ patch: &RoleConfigPatch,
193
+ allow_fresh: bool,
194
+ transport: &dyn Transport,
195
+ ) -> Result<StartAgentOutcome, LifecycleError> {
196
+ let old_agent = selected
197
+ .state
198
+ .get("agents")
199
+ .and_then(|agents| agents.get(agent_id.as_str()))
200
+ .ok_or_else(|| LifecycleError::RequirementUnmet(format!("agent {agent_id} not found")))?;
201
+ super::restart::ensure_agent_not_running(&selected.state, agent_id, transport)?;
202
+ if old_agent.get("paused").and_then(serde_json::Value::as_bool) == Some(true) {
203
+ return Err(LifecycleError::RequirementUnmet(format!(
204
+ "agent {agent_id} is paused"
205
+ )));
206
+ }
207
+ let source = super::launch::role_source::resolve_role_source(
208
+ &selected.run_workspace,
209
+ &selected.team_dir,
210
+ &selected.state,
211
+ agent_id,
212
+ )?;
213
+ let role_path = role_path(selected, agent_id);
214
+ check_destination(&role_path, Some(&source))?;
215
+ let text = read_text(&source)?;
216
+ let next = patched_role(&text, agent_id, patch, false)?;
217
+ // Compare to the established engine, never to a freshly edited spec or model name.
218
+ let old_provider = old_agent
219
+ .get("provider")
220
+ .and_then(serde_json::Value::as_str)
221
+ .and_then(crate::provider::wire::parse_provider)
222
+ .ok_or_else(|| {
223
+ LifecycleError::RequirementUnmet("established provider is unknown".into())
224
+ })?;
225
+ let meta = crate::compiler::read_front_matter(&source)
226
+ .map_err(|error| LifecycleError::Compile(error.to_string()))?
227
+ .0;
228
+ // Preserve the traditional compiler's interpretation of omitted metadata.
229
+ let provider = patch
230
+ .provider
231
+ .as_deref()
232
+ .or_else(|| meta.get("provider").and_then(Value::as_str))
233
+ .filter(|value| !value.trim().is_empty())
234
+ .unwrap_or("pi");
235
+ let provider = crate::provider::wire::parse_provider(provider)
236
+ .ok_or_else(|| LifecycleError::Compile("unknown role provider".into()))?;
237
+ if provider != old_provider {
238
+ return Err(LifecycleError::RequirementUnmet(format!(
239
+ "provider change is not allowed for agent {agent_id}: {} -> {}",
240
+ crate::provider::wire::provider_wire(old_provider),
241
+ crate::provider::wire::provider_wire(provider),
242
+ )));
243
+ }
244
+ let snapshot = RoleSnapshot::capture(selected, &role_path)?;
245
+ let result = (|| {
246
+ if snapshot.role.bytes.as_deref() != Some(next.as_bytes()) {
247
+ atomic_write(&role_path, next.as_bytes())?;
248
+ }
249
+ let team_meta = crate::compiler::read_front_matter(&selected.team_dir.join("TEAM.md"))
250
+ .map_err(|error| LifecycleError::Compile(error.to_string()))?
251
+ .0;
252
+ let compiled = crate::compiler::compile_role_agent(
253
+ &role_path,
254
+ &team_meta,
255
+ &selected.run_workspace.to_string_lossy(),
256
+ )
257
+ .map_err(|error| LifecycleError::Compile(error.to_string()))?;
258
+ if compiled.id != agent_id.as_str() {
259
+ return Err(LifecycleError::Compile(
260
+ "role identity does not match agent id".into(),
261
+ ));
262
+ }
263
+ if compiled
264
+ .agent
265
+ .get("provider")
266
+ .and_then(Value::as_str)
267
+ .and_then(crate::provider::wire::parse_provider)
268
+ != Some(old_provider)
269
+ {
270
+ return Err(LifecycleError::RequirementUnmet(
271
+ "compiled provider does not match established provider".into(),
272
+ ));
273
+ }
274
+ let mut spec = read_spec(selected)?;
275
+ replace_spec_agent(&mut spec, agent_id, &compiled.agent)?;
276
+ crate::model::spec::validate_spec(&spec, &selected.run_workspace)
277
+ .map_err(|error| LifecycleError::Compile(error.to_string()))?;
278
+ atomic_write(&snapshot.spec.path, yaml::dumps(&spec).as_bytes())?;
279
+ update_agent_config(selected, agent_id, &compiled.agent, &role_path)?;
280
+ let outcome = super::restart::start_agent_at_paths(
281
+ &selected.run_workspace,
282
+ snapshot.spec.path.parent().unwrap_or(&selected.team_dir),
283
+ agent_id,
284
+ false,
285
+ true,
286
+ allow_fresh,
287
+ Some(&selected.team_key),
288
+ transport,
289
+ )?;
290
+ match outcome {
291
+ StartAgentOutcome::Running { .. } => Ok(outcome),
292
+ StartAgentOutcome::Noop { .. } => Err(LifecycleError::RequirementUnmet(format!(
293
+ "agent {agent_id} is already running; use stop-agent first"
294
+ ))),
295
+ StartAgentOutcome::Paused { .. } => Err(LifecycleError::RequirementUnmet(format!(
296
+ "agent {agent_id} is paused"
297
+ ))),
298
+ }
299
+ })();
300
+ match result {
301
+ Ok(outcome) => Ok(outcome),
302
+ Err(error) => Err(snapshot.rollback(selected, agent_id, error)),
303
+ }
304
+ }
305
+
306
+ // A file is an explicit configuration source, not a template to silently override.
307
+ // Equal supplied values are omitted from the patch to preserve existing file bytes.
308
+ fn reconcile_creation(
309
+ text: &str,
310
+ patch: &RoleConfigPatch,
311
+ ) -> Result<RoleConfigPatch, LifecycleError> {
312
+ let (meta, body) = role_parts(text)?;
313
+ for field in ["provider", "model", "effort", "profile"] {
314
+ if meta
315
+ .get(field)
316
+ .is_some_and(|value| !matches!(value, Value::Null | Value::Str(_)))
317
+ {
318
+ return Err(LifecycleError::Compile(format!(
319
+ "role {field} must be a string"
320
+ )));
321
+ }
322
+ }
323
+ let mut next = patch.clone();
324
+ let conflict = |field: &str| {
325
+ LifecycleError::RequirementUnmet(format!(
326
+ "conflicting {field}: --role-file and CLI values differ"
327
+ ))
328
+ };
329
+ let file_provider = meta
330
+ .get("provider")
331
+ .and_then(Value::as_str)
332
+ .filter(|value| !value.trim().is_empty());
333
+ if let Some(file_provider) = file_provider {
334
+ let file_provider = crate::provider::wire::parse_provider(file_provider)
335
+ .ok_or_else(|| LifecycleError::Compile("unknown role provider".into()))?;
336
+ if let Some(provider) = &next.provider {
337
+ if crate::provider::wire::parse_provider(provider) != Some(file_provider) {
338
+ return Err(conflict("provider"));
339
+ }
340
+ next.provider = None;
341
+ }
342
+ } else if next.provider.is_none() {
343
+ return Err(LifecycleError::RequirementUnmet(
344
+ "add-agent requires provider in --role-file or --provider <name>".into(),
345
+ ));
346
+ }
347
+ match meta.get("dangerously_skip_permissions") {
348
+ Some(Value::Bool(value)) => {
349
+ if let Some(bypass) = next.bypass {
350
+ if bypass != *value {
351
+ return Err(conflict("bypass"));
352
+ }
353
+ next.bypass = None;
354
+ }
355
+ }
356
+ Some(_) => {
357
+ return Err(LifecycleError::Compile(
358
+ "role dangerously_skip_permissions must be a boolean".into(),
359
+ ))
360
+ }
361
+ None if next.bypass.is_none() => {
362
+ return Err(LifecycleError::RequirementUnmet(
363
+ "add-agent requires bypass in --role-file or --bypass <true|false>".into(),
364
+ ))
365
+ }
366
+ None => {}
367
+ }
368
+ for (field, supplied) in [
369
+ ("model", &mut next.model),
370
+ ("effort", &mut next.effort),
371
+ ("profile", &mut next.profile),
372
+ ] {
373
+ if let Some(file_value) = meta
374
+ .get(field)
375
+ .and_then(Value::as_str)
376
+ .filter(|value| !value.trim().is_empty())
377
+ {
378
+ if let Some(value) = supplied.as_ref() {
379
+ let equal = if field == "effort" {
380
+ crate::model::enums::ProviderEffort::parse(file_value)
381
+ == crate::model::enums::ProviderEffort::parse(value)
382
+ } else {
383
+ value == file_value
384
+ };
385
+ if !equal {
386
+ return Err(conflict(field));
387
+ }
388
+ *supplied = None;
389
+ }
390
+ }
391
+ }
392
+ if let Some(prompt) = &next.prompt {
393
+ if !body.trim().is_empty() {
394
+ if prompt.trim() != body.trim() {
395
+ return Err(conflict("prompt"));
396
+ }
397
+ next.prompt = None;
398
+ }
399
+ }
400
+ Ok(next)
401
+ }
402
+
403
+ fn role_path(selected: &SelectedTeam, agent_id: &AgentId) -> PathBuf {
404
+ selected
405
+ .team_dir
406
+ .join("agents")
407
+ .join(format!("{agent_id}.md"))
408
+ }
409
+
410
+ fn validate_id(agent_id: &AgentId) -> Result<(), LifecycleError> {
411
+ let id = agent_id.as_str();
412
+ if id.is_empty() || id == "." || id == ".." || id.contains(['/', '\\', '\0']) {
413
+ return Err(LifecycleError::RequirementUnmet("invalid agent id".into()));
414
+ }
415
+ Ok(())
416
+ }
417
+
418
+ fn check_destination(path: &Path, source: Option<&Path>) -> Result<(), LifecycleError> {
419
+ if fs::symlink_metadata(path).is_ok() {
420
+ if !source.is_some_and(|source| {
421
+ fs::canonicalize(source)
422
+ .ok()
423
+ .zip(fs::canonicalize(path).ok())
424
+ .is_some_and(|(source, target)| source == target)
425
+ }) {
426
+ return Err(LifecycleError::RequirementUnmet(format!(
427
+ "role file already exists: {}",
428
+ path.display()
429
+ )));
430
+ }
431
+ }
432
+ Ok(())
433
+ }
434
+
435
+ fn read_text(path: &Path) -> Result<String, LifecycleError> {
436
+ fs::read_to_string(path)
437
+ .map_err(|error| LifecycleError::Compile(format!("{}: {error}", path.display())))
438
+ }
439
+
440
+ // Unlike read_front_matter, this returns the unnormalised body slice, including blank lines.
441
+ fn role_parts(text: &str) -> Result<(Value, &str), LifecycleError> {
442
+ let (end, opening) = raw_line_end(text, 0);
443
+ if &text[..end] != "---" || end == opening {
444
+ return Ok((Value::Map(Vec::new()), text));
445
+ }
446
+ let mut offset = opening;
447
+ while offset < text.len() {
448
+ let (end, next) = raw_line_end(text, offset);
449
+ if &text[offset..end] == "---" {
450
+ let header = text[opening..offset]
451
+ .replace("\r\n", "\n")
452
+ .replace('\r', "\n");
453
+ let meta = if header.trim().is_empty() {
454
+ Value::Map(Vec::new())
455
+ } else {
456
+ yaml::loads(&header).map_err(|error| LifecycleError::Compile(error.to_string()))?
457
+ };
458
+ if !meta.is_map() {
459
+ return Err(LifecycleError::Compile(
460
+ "role frontmatter must be a map".into(),
461
+ ));
462
+ }
463
+ return Ok((meta, &text[next..]));
464
+ }
465
+ offset = next;
466
+ }
467
+ Err(LifecycleError::Compile(
468
+ "unterminated role frontmatter".into(),
469
+ ))
470
+ }
471
+
472
+ fn raw_line_end(text: &str, start: usize) -> (usize, usize) {
473
+ match text[start..].find(['\r', '\n']) {
474
+ Some(relative) => {
475
+ let end = start + relative;
476
+ (
477
+ end,
478
+ end + if text[end..].starts_with("\r\n") {
479
+ 2
480
+ } else {
481
+ 1
482
+ },
483
+ )
484
+ }
485
+ None => (text.len(), text.len()),
486
+ }
487
+ }
488
+
489
+ fn patched_role(
490
+ text: &str,
491
+ agent_id: &AgentId,
492
+ patch: &RoleConfigPatch,
493
+ generated: bool,
494
+ ) -> Result<String, LifecycleError> {
495
+ if !generated && patch == &RoleConfigPatch::default() {
496
+ return Ok(text.to_string());
497
+ }
498
+ let (mut meta, body) = role_parts(text)?;
499
+ for key in ["agent_id", "name"] {
500
+ if meta
501
+ .get(key)
502
+ .and_then(Value::as_str)
503
+ .is_some_and(|id| id != agent_id.as_str())
504
+ {
505
+ return Err(LifecycleError::Compile(format!(
506
+ "role {key} does not match {agent_id}"
507
+ )));
508
+ }
509
+ }
510
+ let before = meta.clone();
511
+ if generated {
512
+ set(&mut meta, "name", Value::Str(agent_id.as_str().into()))?;
513
+ }
514
+ for (key, value) in [
515
+ ("provider", patch.provider.as_ref()),
516
+ ("model", patch.model.as_ref()),
517
+ ("effort", patch.effort.as_ref()),
518
+ ("profile", patch.profile.as_ref()),
519
+ ] {
520
+ if let Some(value) = value {
521
+ set(&mut meta, key, Value::Str(value.clone()))?;
522
+ }
523
+ }
524
+ if let Some(value) = patch.bypass {
525
+ set(
526
+ &mut meta,
527
+ "dangerously_skip_permissions",
528
+ Value::Bool(value),
529
+ )?;
530
+ }
531
+ if before == meta && patch.prompt.is_none() && !generated {
532
+ return Ok(text.to_string());
533
+ }
534
+ let body = patch.prompt.as_deref().unwrap_or(body);
535
+ Ok(format!(
536
+ "---\n{}---\n{}",
537
+ super::launch::role_source::dump_role_frontmatter(&meta),
538
+ body
539
+ ))
540
+ }
541
+
542
+ fn set(map: &mut Value, key: &str, value: Value) -> Result<(), LifecycleError> {
543
+ let Value::Map(pairs) = map else {
544
+ return Err(LifecycleError::Compile("expected map".into()));
545
+ };
546
+ if pairs.iter().filter(|(name, _)| name == key).count() > 1 {
547
+ return Err(LifecycleError::Compile(format!(
548
+ "duplicate role field: {key}"
549
+ )));
550
+ }
551
+ if let Some((_, old)) = pairs.iter_mut().find(|(name, _)| name == key) {
552
+ *old = value;
553
+ } else {
554
+ pairs.push((key.into(), value));
555
+ }
556
+ Ok(())
557
+ }
558
+
559
+ fn read_spec(selected: &SelectedTeam) -> Result<Value, LifecycleError> {
560
+ let path = selected
561
+ .spec_path
562
+ .as_ref()
563
+ .ok_or_else(|| LifecycleError::Compile("missing spec".into()))?;
564
+ yaml::loads(&read_text(path)?).map_err(|error| LifecycleError::Compile(error.to_string()))
565
+ }
566
+
567
+ fn spec_has_agent(spec: &Value, agent_id: &AgentId) -> bool {
568
+ spec.get("agents")
569
+ .and_then(Value::as_list)
570
+ .is_some_and(|agents| {
571
+ agents
572
+ .iter()
573
+ .any(|agent| agent.get("id").and_then(Value::as_str) == Some(agent_id.as_str()))
574
+ })
575
+ }
576
+
577
+ fn replace_spec_agent(
578
+ spec: &mut Value,
579
+ agent_id: &AgentId,
580
+ next: &Value,
581
+ ) -> Result<(), LifecycleError> {
582
+ let Value::Map(fields) = spec else {
583
+ return Err(LifecycleError::Compile("invalid spec".into()));
584
+ };
585
+ let Some((_, Value::List(agents))) = fields.iter_mut().find(|(key, _)| key == "agents") else {
586
+ return Err(LifecycleError::Compile("spec agents missing".into()));
587
+ };
588
+ let target = agents
589
+ .iter_mut()
590
+ .find(|agent| agent.get("id").and_then(Value::as_str) == Some(agent_id.as_str()))
591
+ .ok_or_else(|| LifecycleError::Compile("agent missing from spec".into()))?;
592
+ *target = next.clone();
593
+ Ok(())
594
+ }
595
+
596
+ fn to_json(value: &Value) -> serde_json::Value {
597
+ match value {
598
+ Value::Null => serde_json::Value::Null,
599
+ Value::Bool(value) => (*value).into(),
600
+ Value::Int(value) => (*value).into(),
601
+ Value::Float(value) => serde_json::json!(value),
602
+ Value::Str(value) => value.clone().into(),
603
+ Value::List(values) => values.iter().map(to_json).collect(),
604
+ Value::Map(values) => values
605
+ .iter()
606
+ .map(|(key, value)| (key.clone(), to_json(value)))
607
+ .collect(),
608
+ }
609
+ }
610
+
611
+ fn update_agent_config(
612
+ selected: &SelectedTeam,
613
+ agent_id: &AgentId,
614
+ compiled: &Value,
615
+ role: &Path,
616
+ ) -> Result<(), LifecycleError> {
617
+ StateRepository::new(&selected.run_workspace)
618
+ .commit(
619
+ StateWriteIntent::StartAgent {
620
+ team_key: &selected.team_key,
621
+ agent_id: agent_id.as_str(),
622
+ },
623
+ |state| {
624
+ if let Some(agent) = state
625
+ .get_mut("agents")
626
+ .and_then(|agents| agents.get_mut(agent_id.as_str()))
627
+ .and_then(serde_json::Value::as_object_mut)
628
+ {
629
+ for field in [
630
+ "provider",
631
+ "model",
632
+ "role",
633
+ "system_prompt",
634
+ "output_contract",
635
+ "auth_mode",
636
+ "effort",
637
+ "profile",
638
+ "dangerously_skip_permissions",
639
+ "communication_mode",
640
+ ] {
641
+ if let Some(value) = compiled.get(field) {
642
+ agent.insert(field.into(), to_json(value));
643
+ } else {
644
+ agent.remove(field);
645
+ }
646
+ }
647
+ agent.insert(
648
+ "dynamic_role_file".into(),
649
+ role.to_string_lossy().to_string().into(),
650
+ );
651
+ agent.insert("role_source_ownership".into(), "external".into());
652
+ agent.insert(
653
+ "model_source".into(),
654
+ if compiled.get("model").and_then(Value::as_str).is_some() {
655
+ "role".into()
656
+ } else {
657
+ "default".into()
658
+ },
659
+ );
660
+ // Imported roles are self-contained in this Team, including
661
+ // profile lookup; never retain an external credential root.
662
+ if compiled.get("profile").and_then(Value::as_str).is_some() {
663
+ agent.insert(
664
+ "_profile_dir".into(),
665
+ selected
666
+ .team_dir
667
+ .join("profiles")
668
+ .to_string_lossy()
669
+ .to_string()
670
+ .into(),
671
+ );
672
+ } else {
673
+ agent.remove("_profile_dir");
674
+ }
675
+ }
676
+ },
677
+ )
678
+ .map(|_| ())
679
+ .map_err(|error| LifecycleError::StatePersist(error.to_string()))
680
+ }
681
+
682
+ struct FileSnapshot {
683
+ path: PathBuf,
684
+ bytes: Option<Vec<u8>>,
685
+ }
686
+ impl FileSnapshot {
687
+ fn capture(path: PathBuf) -> Result<Self, LifecycleError> {
688
+ let bytes = match fs::read(&path) {
689
+ Ok(bytes) => Some(bytes),
690
+ Err(error) if error.kind() == std::io::ErrorKind::NotFound => None,
691
+ Err(error) => return Err(LifecycleError::StatePersist(error.to_string())),
692
+ };
693
+ Ok(Self { path, bytes })
694
+ }
695
+ fn restore(&self) -> Result<(), LifecycleError> {
696
+ if self.bytes.is_some() && fs::read(&self.path).ok().as_ref() == self.bytes.as_ref() {
697
+ return Ok(());
698
+ }
699
+ match self.bytes.as_ref() {
700
+ Some(bytes) => atomic_write(&self.path, bytes),
701
+ None => fs::remove_file(&self.path)
702
+ .or_else(|error| {
703
+ if error.kind() == std::io::ErrorKind::NotFound {
704
+ Ok(())
705
+ } else {
706
+ Err(error)
707
+ }
708
+ })
709
+ .map_err(|error| LifecycleError::StatePersist(error.to_string())),
710
+ }
711
+ }
712
+ }
713
+
714
+ struct RoleSnapshot {
715
+ role: FileSnapshot,
716
+ spec: FileSnapshot,
717
+ }
718
+ impl RoleSnapshot {
719
+ fn capture(selected: &SelectedTeam, role: &Path) -> Result<Self, LifecycleError> {
720
+ Ok(Self {
721
+ role: FileSnapshot::capture(role.to_path_buf())?,
722
+ spec: FileSnapshot::capture(crate::model::paths::runtime_spec_path(
723
+ &selected.run_workspace,
724
+ &selected.team_key,
725
+ ))?,
726
+ })
727
+ }
728
+ fn rollback(
729
+ &self,
730
+ selected: &SelectedTeam,
731
+ agent_id: &AgentId,
732
+ error: LifecycleError,
733
+ ) -> LifecycleError {
734
+ let mut failures = Vec::new();
735
+ for snapshot in [&self.role, &self.spec] {
736
+ if let Err(error) = snapshot.restore() {
737
+ failures.push(error.to_string());
738
+ }
739
+ }
740
+ let restored = StateRepository::new(&selected.run_workspace).commit(
741
+ StateWriteIntent::StartAgent {
742
+ team_key: &selected.team_key,
743
+ agent_id: agent_id.as_str(),
744
+ },
745
+ |state| {
746
+ for field in ["agents", "agent_lifecycle"] {
747
+ if let Some(rows) = state
748
+ .get_mut(field)
749
+ .and_then(serde_json::Value::as_object_mut)
750
+ {
751
+ if let Some(old) = selected
752
+ .state
753
+ .get(field)
754
+ .and_then(|rows| rows.get(agent_id.as_str()))
755
+ {
756
+ rows.insert(agent_id.as_str().into(), old.clone());
757
+ } else {
758
+ rows.remove(agent_id.as_str());
759
+ }
760
+ }
761
+ }
762
+ },
763
+ );
764
+ if let Err(error) = restored {
765
+ failures.push(error.to_string());
766
+ }
767
+ if failures.is_empty() {
768
+ error
769
+ } else {
770
+ LifecycleError::StatePersist(format!(
771
+ "{error}; rollback failed: {}",
772
+ failures.join("; ")
773
+ ))
774
+ }
775
+ }
776
+ }
777
+
778
+ fn atomic_write(path: &Path, bytes: &[u8]) -> Result<(), LifecycleError> {
779
+ static NEXT: AtomicU64 = AtomicU64::new(0);
780
+ if let Some(parent) = path.parent() {
781
+ fs::create_dir_all(parent).map_err(persist_error)?;
782
+ }
783
+ if fs::symlink_metadata(path).is_ok_and(|meta| meta.file_type().is_symlink()) {
784
+ return Err(LifecycleError::StatePersist(format!(
785
+ "refusing to replace symlink: {}",
786
+ path.display()
787
+ )));
788
+ }
789
+ let temp = path.with_extension(format!(
790
+ "tmp-{}-{}",
791
+ std::process::id(),
792
+ NEXT.fetch_add(1, Ordering::Relaxed)
793
+ ));
794
+ let mut created = false;
795
+ let result = (|| {
796
+ let mut file = OpenOptions::new()
797
+ .write(true)
798
+ .create_new(true)
799
+ .open(&temp)?;
800
+ created = true;
801
+ if let Ok(meta) = fs::metadata(path) {
802
+ file.set_permissions(meta.permissions())?;
803
+ }
804
+ file.write_all(bytes)?;
805
+ file.sync_all()?;
806
+ drop(file);
807
+ fs::rename(&temp, path)
808
+ })();
809
+ if result.is_err() && created {
810
+ let _ = fs::remove_file(&temp);
811
+ }
812
+ result.map_err(persist_error)
813
+ }
814
+
815
+ fn persist_error(error: std::io::Error) -> LifecycleError {
816
+ LifecycleError::StatePersist(error.to_string())
817
+ }
818
+
819
+ #[cfg(test)]
820
+ mod tests {
821
+ #![allow(clippy::unwrap_used, clippy::expect_used)]
822
+ use super::*;
823
+
824
+ fn pane(id: &str, session: &str, window: &str) -> crate::transport::PaneInfo {
825
+ crate::transport::PaneInfo {
826
+ pane_id: crate::transport::PaneId::new(id),
827
+ session: crate::transport::SessionName::new(session),
828
+ window_name: Some(crate::transport::WindowName::new(window)),
829
+ window_index: None,
830
+ pane_index: None,
831
+ tty: None,
832
+ current_command: None,
833
+ current_path: None,
834
+ active: true,
835
+ pane_pid: None,
836
+ leader_env: Default::default(),
837
+ }
838
+ }
839
+
840
+ #[test]
841
+ fn live_guard_does_not_borrow_a_peer_pane_or_ignore_own_live_cohort() {
842
+ use crate::transport::test_support::OfflineTransport;
843
+ let state = serde_json::json!({"session_name": "s", "agents": {
844
+ "a": {"pane_id": "%b", "window": "a", "status": "running"},
845
+ "b": {"pane_id": "%b", "window": "b", "status": "running"}
846
+ }});
847
+ let id = AgentId::new("a");
848
+ let transport = OfflineTransport::new()
849
+ .with_targets(vec![pane("%b", "s", "b")])
850
+ .with_pane_presence("%b", true);
851
+ super::super::restart::ensure_agent_not_running(&state, &id, &transport).unwrap();
852
+ let transport = transport
853
+ .with_targets(vec![pane("%a", "s", "a"), pane("%b", "s", "b")])
854
+ .with_pane_presence("%a", true);
855
+ let error = super::super::restart::ensure_agent_not_running(&state, &id, &transport)
856
+ .unwrap_err()
857
+ .to_string();
858
+ assert!(error.contains("already running") && error.contains("cohort proof"));
859
+ assert!(error.contains("window=a pane=%a"));
860
+ assert!(!error.contains("pane=%b"));
861
+ let transport = transport.with_pane_presence("%a", false);
862
+ super::super::restart::ensure_agent_not_running(&state, &id, &transport).unwrap();
863
+ }
864
+
865
+ #[test]
866
+ fn force_admission_requires_positive_death_and_never_replaces_a_live_seat() {
867
+ use crate::transport::test_support::OfflineTransport;
868
+ let state = serde_json::json!({"session_name": "s", "agents": {
869
+ "a": {"pane_id": "%a", "window": "a", "status": "running"}
870
+ }});
871
+ let id = AgentId::new("a");
872
+ let dead = OfflineTransport::new()
873
+ .with_targets(vec![pane("%other", "other-team", "a")])
874
+ .with_pane_presence("%a", false)
875
+ .with_pane_presence("%other", true);
876
+ assert!(admit_add(&state, &id, false, &dead)
877
+ .unwrap_err()
878
+ .to_string()
879
+ .contains("agent id already exists"));
880
+ assert!(admit_add(&state, &id, true, &dead).unwrap());
881
+ let live = OfflineTransport::new()
882
+ .with_targets(vec![pane("%a", "s", "a")])
883
+ .with_pane_presence("%a", true);
884
+ assert!(admit_add(&state, &id, true, &live)
885
+ .unwrap_err()
886
+ .to_string()
887
+ .contains("already running"));
888
+ assert!(admit_add(&state, &id, true, &OfflineTransport::new()).is_err());
889
+ let absent_binding = serde_json::json!({"agents": {"a": {"status": "stopped"}}});
890
+ assert!(admit_add(&absent_binding, &id, true, &dead).is_err());
891
+ assert!(!admit_add(&serde_json::json!({"agents": {}}), &id, true, &dead).unwrap());
892
+ assert!(admit_add(
893
+ &state,
894
+ &id,
895
+ true,
896
+ &dead.with_list_targets_error("unknown topology")
897
+ )
898
+ .is_err());
899
+ }
900
+
901
+ #[test]
902
+ fn file_decisions_need_no_repeated_flags_and_conflicts_fail_closed() {
903
+ let text = "---\nprovider: codex\ndangerously_skip_permissions: false\nmodel: native-model\neffort: ultra\nprofile: local\n---\n\nrole body\n";
904
+ assert_eq!(
905
+ reconcile_creation(text, &RoleConfigPatch::default()).unwrap(),
906
+ RoleConfigPatch::default()
907
+ );
908
+ let same = RoleConfigPatch {
909
+ provider: Some("codex".into()),
910
+ bypass: Some(false),
911
+ model: Some("native-model".into()),
912
+ effort: Some("ultra".into()),
913
+ profile: Some("local".into()),
914
+ prompt: Some("role body".into()),
915
+ };
916
+ assert_eq!(
917
+ reconcile_creation(text, &same).unwrap(),
918
+ RoleConfigPatch::default()
919
+ );
920
+ for different in [
921
+ RoleConfigPatch {
922
+ provider: Some("pi".into()),
923
+ ..Default::default()
924
+ },
925
+ RoleConfigPatch {
926
+ bypass: Some(true),
927
+ ..Default::default()
928
+ },
929
+ RoleConfigPatch {
930
+ model: Some("other".into()),
931
+ ..Default::default()
932
+ },
933
+ RoleConfigPatch {
934
+ effort: Some("high".into()),
935
+ ..Default::default()
936
+ },
937
+ RoleConfigPatch {
938
+ prompt: Some("other".into()),
939
+ ..Default::default()
940
+ },
941
+ RoleConfigPatch {
942
+ profile: Some("other".into()),
943
+ ..Default::default()
944
+ },
945
+ ] {
946
+ assert!(reconcile_creation(text, &different).is_err());
947
+ }
948
+ let supplied = RoleConfigPatch {
949
+ provider: Some("pi".into()),
950
+ bypass: Some(false),
951
+ ..Default::default()
952
+ };
953
+ assert_eq!(reconcile_creation("body", &supplied).unwrap(), supplied);
954
+ assert!(reconcile_creation("body", &RoleConfigPatch::default()).is_err());
955
+ // An invalid existing declaration is not a missing field to overwrite.
956
+ for field in ["provider", "model", "effort", "profile"] {
957
+ let text =
958
+ format!("---\n{field}: true\ndangerously_skip_permissions: false\n---\nbody");
959
+ assert!(reconcile_creation(&text, &supplied).is_err());
960
+ }
961
+ }
962
+
963
+ #[test]
964
+ fn legacy_cr_only_header_does_not_change_engine_when_patched() {
965
+ let text = "---\rprovider: codex\ndangerously_skip_permissions: false\r---\r\rbody\r";
966
+ let patch = RoleConfigPatch {
967
+ effort: Some("ultra".into()),
968
+ ..Default::default()
969
+ };
970
+ let next = patched_role(text, &AgentId::new("w"), &patch, false).unwrap();
971
+ let (meta, body) = role_parts(&next).unwrap();
972
+ assert_eq!(meta.get("provider"), Some(&Value::Str("codex".into())));
973
+ assert_eq!(body, "\rbody\r");
974
+ assert!(reconcile_creation(text, &RoleConfigPatch::default()).is_ok());
975
+ }
976
+
977
+ #[test]
978
+ fn partial_patch_preserves_body_and_false_presence() {
979
+ let body = "\r\n\r\n中文\r\n```yaml\r\nx: y\r\n```\r\n";
980
+ let original = format!("---\r\nname: worker\r\nprovider: pi\r\ncustom: kept\r\neffort: max\r\ndangerously_skip_permissions: true\r\n---\r\n{body}");
981
+ let patch = RoleConfigPatch {
982
+ effort: Some("high".into()),
983
+ bypass: Some(false),
984
+ ..Default::default()
985
+ };
986
+ let next = patched_role(&original, &AgentId::new("worker"), &patch, false).unwrap();
987
+ let (meta, actual_body) = role_parts(&next).unwrap();
988
+ assert_eq!(actual_body, body);
989
+ assert_eq!(meta.get("custom"), Some(&Value::Str("kept".into())));
990
+ assert_eq!(meta.get("effort"), Some(&Value::Str("high".into())));
991
+ assert_eq!(
992
+ meta.get("dangerously_skip_permissions"),
993
+ Some(&Value::Bool(false))
994
+ );
995
+ }
996
+
997
+ #[test]
998
+ fn equal_patch_is_byte_identical() {
999
+ let text = "---\n# keep formatting\nprovider: pi\ndangerously_skip_permissions: false\n---\n\nbody\n";
1000
+ let patch = RoleConfigPatch {
1001
+ provider: Some("pi".into()),
1002
+ bypass: Some(false),
1003
+ ..Default::default()
1004
+ };
1005
+ assert_eq!(
1006
+ patched_role(text, &AgentId::new("w"), &patch, false).unwrap(),
1007
+ text
1008
+ );
1009
+ }
1010
+
1011
+ #[test]
1012
+ fn prompt_replaces_instead_of_appending() {
1013
+ let patch = RoleConfigPatch {
1014
+ prompt: Some("new\n---\nbody".into()),
1015
+ ..Default::default()
1016
+ };
1017
+ let text = "---\nprovider: codex\n---\nold body";
1018
+ let next = patched_role(text, &AgentId::new("w"), &patch, false).unwrap();
1019
+ assert_eq!(role_parts(&next).unwrap().1, "new\n---\nbody");
1020
+ assert!(!next.contains("old body"));
1021
+ }
1022
+
1023
+ #[test]
1024
+ fn traditional_plain_and_empty_header_remain_supported() {
1025
+ for text in ["plain\r\nrole", "---\n---\n\nbody"] {
1026
+ assert_eq!(
1027
+ patched_role(text, &AgentId::new("w"), &RoleConfigPatch::default(), false).unwrap(),
1028
+ text
1029
+ );
1030
+ }
1031
+ }
1032
+
1033
+ #[test]
1034
+ fn imported_profile_is_team_local_and_failed_update_restores_target_only() {
1035
+ let nonce = std::time::SystemTime::now()
1036
+ .duration_since(std::time::UNIX_EPOCH)
1037
+ .unwrap()
1038
+ .as_nanos();
1039
+ let root =
1040
+ std::env::temp_dir().join(format!("role-projection-{}-{nonce}", std::process::id()));
1041
+ let team_dir = root.join("roles");
1042
+ let old = serde_json::json!({
1043
+ "provider": "codex", "model": "old", "effort": "high",
1044
+ "profile": "local", "_profile_dir": "/external/profiles",
1045
+ "session_id": "preserved-session", "status": "stopped"
1046
+ });
1047
+ let sibling = serde_json::json!({"provider": "pi", "model": "keep"});
1048
+ let selected_state = serde_json::json!({"agents": {"w": old.clone()}});
1049
+ crate::state::persist::save_runtime_state(
1050
+ &root,
1051
+ &serde_json::json!({
1052
+ "teams": {
1053
+ "t1": selected_state.clone(),
1054
+ "t2": {"agents": {"w": sibling.clone()}}
1055
+ }
1056
+ }),
1057
+ )
1058
+ .unwrap();
1059
+ let spec_path = crate::model::paths::runtime_spec_path(&root, "t1");
1060
+ atomic_write(&spec_path, b"old spec\r\n").unwrap();
1061
+ let selected = SelectedTeam {
1062
+ run_workspace: root.clone(),
1063
+ team_key: "t1".into(),
1064
+ state: selected_state,
1065
+ team_dir: team_dir.clone(),
1066
+ spec_workspace: spec_path.parent().map(Path::to_path_buf),
1067
+ spec_path: Some(spec_path.clone()),
1068
+ };
1069
+ let id = AgentId::new("w");
1070
+ let role = role_path(&selected, &id);
1071
+ let snapshot = RoleSnapshot::capture(&selected, &role).unwrap();
1072
+ let compiled = yaml::loads("provider: codex\nmodel: new\neffort: ultra\nprofile: local\ndangerously_skip_permissions: false\nsystem_prompt: new instructions\n").unwrap();
1073
+ atomic_write(&role, b"new role").unwrap();
1074
+ atomic_write(&spec_path, b"new spec").unwrap();
1075
+ update_agent_config(&selected, &id, &compiled, &role).unwrap();
1076
+ let updated = crate::state::persist::load_runtime_state(&root).unwrap();
1077
+ let target = &updated["teams"]["t1"]["agents"]["w"];
1078
+ assert_eq!(target["model"], "new");
1079
+ assert_eq!(target["effort"], "ultra");
1080
+ assert_eq!(target["system_prompt"], "new instructions");
1081
+ assert_eq!(target["dangerously_skip_permissions"], false);
1082
+ assert_eq!(target["session_id"], "preserved-session");
1083
+ assert_eq!(
1084
+ target["_profile_dir"],
1085
+ team_dir.join("profiles").to_string_lossy().as_ref()
1086
+ );
1087
+ assert_eq!(updated["teams"]["t2"]["agents"]["w"], sibling);
1088
+ let error = snapshot.rollback(
1089
+ &selected,
1090
+ &id,
1091
+ LifecycleError::RequirementUnmet("injected failure".into()),
1092
+ );
1093
+ assert!(error.to_string().contains("injected failure"));
1094
+ assert!(!error.to_string().contains("rollback failed"));
1095
+ assert!(!role.exists());
1096
+ assert_eq!(fs::read(&spec_path).unwrap(), b"old spec\r\n");
1097
+ let restored = crate::state::persist::load_runtime_state(&root).unwrap();
1098
+ assert_eq!(restored["teams"]["t1"]["agents"]["w"], old);
1099
+ assert_eq!(restored["teams"]["t2"]["agents"]["w"], sibling);
1100
+ fs::remove_dir_all(root).unwrap();
1101
+ }
1102
+
1103
+ #[test]
1104
+ fn file_snapshot_restores_original_bytes_and_absence() {
1105
+ let nonce = std::time::SystemTime::now()
1106
+ .duration_since(std::time::UNIX_EPOCH)
1107
+ .unwrap()
1108
+ .as_nanos();
1109
+ let root = std::env::temp_dir().join(format!("role-config-{}-{nonce}", std::process::id()));
1110
+ fs::create_dir_all(&root).unwrap();
1111
+ let path = root.join("role.md");
1112
+ let absent = FileSnapshot::capture(path.clone()).unwrap();
1113
+ atomic_write(&path, b"old\r\n\n").unwrap();
1114
+ let existing = FileSnapshot::capture(path.clone()).unwrap();
1115
+ atomic_write(&path, b"new").unwrap();
1116
+ existing.restore().unwrap();
1117
+ assert_eq!(fs::read(&path).unwrap(), b"old\r\n\n");
1118
+ absent.restore().unwrap();
1119
+ assert!(!path.exists());
1120
+ assert_eq!(fs::read_dir(&root).unwrap().count(), 0);
1121
+ fs::remove_dir(&root).unwrap();
1122
+ }
1123
+ }