@team-agent/installer 0.5.104 → 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.
- package/Cargo.lock +1 -1
- package/Cargo.toml +1 -1
- package/crates/team-agent/src/cli/adapters.rs +2 -0
- package/crates/team-agent/src/cli/emit.rs +102 -5
- package/crates/team-agent/src/cli/mod.rs +8 -14
- package/crates/team-agent/src/cli/spec.rs +2 -2
- package/crates/team-agent/src/cli/tests/run_delegation.rs +1 -0
- package/crates/team-agent/src/cli/types.rs +3 -1
- package/crates/team-agent/src/lifecycle/launch/add_agent.rs +6 -2
- package/crates/team-agent/src/lifecycle/launch/add_agent_state.rs +6 -4
- package/crates/team-agent/src/lifecycle/launch/role_source.rs +1 -1
- package/crates/team-agent/src/lifecycle/launch/spec_state.rs +7 -2
- package/crates/team-agent/src/lifecycle/launch.rs +4 -1
- package/crates/team-agent/src/lifecycle/mod.rs +1 -0
- package/crates/team-agent/src/lifecycle/restart/agent.rs +31 -0
- package/crates/team-agent/src/lifecycle/restart.rs +1 -1
- package/crates/team-agent/src/lifecycle/role_config.rs +1123 -0
- package/crates/team-agent/src/state/repository.rs +2 -1
- package/package.json +4 -4
- package/skills/team-agent/SKILL.md +55 -20
- package/skills/team-agent/references/team-agent-operator.md +14 -6
|
@@ -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
|
+
}
|