@team-agent/installer 0.5.113 → 0.5.115
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 +252 -84
- package/Cargo.toml +3 -1
- package/crates/team-agent/Cargo.toml +3 -0
- package/crates/team-agent/src/cli/adapters.rs +28 -15
- package/crates/team-agent/src/cli/diagnose.rs +36 -13
- package/crates/team-agent/src/cli/emit.rs +57 -22
- package/crates/team-agent/src/cli/leader.rs +16 -0
- package/crates/team-agent/src/cli/leader_prompt.rs +261 -0
- package/crates/team-agent/src/cli/mod.rs +157 -8
- package/crates/team-agent/src/cli/models.rs +119 -18
- package/crates/team-agent/src/cli/spec.rs +7 -1
- package/crates/team-agent/src/cli/status_port/brief.rs +268 -101
- package/crates/team-agent/src/cli/tests/base.rs +88 -0
- package/crates/team-agent/src/cli/tests/status_send.rs +2 -0
- package/crates/team-agent/src/compiler.rs +66 -18
- package/crates/team-agent/src/contract_runtime/backend.rs +959 -0
- package/crates/team-agent/src/contract_runtime/commands.rs +245 -0
- package/crates/team-agent/src/contract_runtime/config.rs +340 -0
- package/crates/team-agent/src/contract_runtime/config_tests.rs +56 -0
- package/crates/team-agent/src/contract_runtime/discovery.rs +383 -0
- package/crates/team-agent/src/contract_runtime/forward.rs +229 -0
- package/crates/team-agent/src/contract_runtime/framework.rs +294 -0
- package/crates/team-agent/src/contract_runtime/mcp.rs +396 -0
- package/crates/team-agent/src/contract_runtime/mod.rs +16 -0
- package/crates/team-agent/src/contract_runtime/prompt.rs +216 -0
- package/crates/team-agent/src/contract_runtime/pump.rs +269 -0
- package/crates/team-agent/src/contract_runtime/registry.rs +197 -0
- package/crates/team-agent/src/contract_runtime/workspace.rs +160 -0
- package/crates/team-agent/src/coordinator/tick.rs +26 -8
- package/crates/team-agent/src/leader/mod.rs +1 -0
- package/crates/team-agent/src/leader/prompt.rs +906 -0
- package/crates/team-agent/src/leader/start.rs +353 -21
- package/crates/team-agent/src/leader/tests/mod.rs +2 -0
- package/crates/team-agent/src/leader/tests/prompt_contract.rs +268 -0
- package/crates/team-agent/src/leader/types.rs +6 -0
- package/crates/team-agent/src/lib.rs +4 -0
- package/crates/team-agent/src/lifecycle/launch/quick_start.rs +571 -389
- package/crates/team-agent/src/lifecycle/launch/spawn.rs +38 -9
- package/crates/team-agent/src/lifecycle/launch/state_projection.rs +15 -0
- package/crates/team-agent/src/lifecycle/launch.rs +45 -28
- package/crates/team-agent/src/lifecycle/restart/rebuild.rs +23 -1
- package/crates/team-agent/src/lifecycle/restart/selection.rs +34 -0
- package/crates/team-agent/src/lifecycle/role_config.rs +17 -1
- package/crates/team-agent/src/lifecycle/types.rs +4 -1
- package/crates/team-agent/src/lifecycle/worker_command_context.rs +37 -27
- package/crates/team-agent/src/main.rs +6 -0
- package/crates/team-agent/src/messaging/delivery.rs +50 -1
- package/crates/team-agent/src/messaging/send.rs +10 -0
- package/crates/team-agent/src/messaging/tests/leader_inject_acceptance.rs +131 -0
- package/crates/team-agent/src/model/spec.rs +24 -12
- package/crates/team-agent/src/state/persist.rs +73 -0
- package/package.json +4 -4
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
//! Public lifecycle dispatch for contract seats. A private native pane is never
|
|
2
|
+
//! operated through the legacy workspace socket or provider-enum fallback.
|
|
3
|
+
use std::path::Path;
|
|
4
|
+
|
|
5
|
+
use serde_json::{json, Value};
|
|
6
|
+
use team_agent_contract::contract::types::SeatId;
|
|
7
|
+
use team_agent_contract::orchestration::store::{Outcome, SeatStatus};
|
|
8
|
+
use team_agent_contract::host::process::{sample_process, ProcessState};
|
|
9
|
+
|
|
10
|
+
use crate::cli::CliError;
|
|
11
|
+
use crate::lifecycle::lock::{acquire_agent_lifecycle_lock, LifecycleLockRequest};
|
|
12
|
+
use crate::model::ids::AgentId;
|
|
13
|
+
use crate::state::repository::{StateRepository, StateWriteIntent};
|
|
14
|
+
use crate::state::selector::{resolve_active_team_readonly, SelectedTeam, SelectorMode};
|
|
15
|
+
|
|
16
|
+
use super::{backend::{self, Backend, BackendError}, config, framework};
|
|
17
|
+
|
|
18
|
+
fn error(error: impl std::fmt::Display) -> CliError {
|
|
19
|
+
CliError::Runtime(error.to_string())
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/// None delegates only a non-contract selection. Once selected, any identity,
|
|
23
|
+
/// ownership, state or physical error stays on this route and cannot fall back.
|
|
24
|
+
fn with_agent(
|
|
25
|
+
workspace: &Path,
|
|
26
|
+
team: Option<&str>,
|
|
27
|
+
agent: &str,
|
|
28
|
+
operation: &'static str,
|
|
29
|
+
apply: impl FnOnce(&SelectedTeam, &SeatId) -> Result<Value, CliError>,
|
|
30
|
+
) -> Result<Option<Value>, CliError> {
|
|
31
|
+
let Ok(selected) = resolve_active_team_readonly(workspace, team, SelectorMode::RuntimeOnly) else {
|
|
32
|
+
return Ok(None);
|
|
33
|
+
};
|
|
34
|
+
if !selected.state.get("agents").and_then(|agents| agents.get(agent)).is_some_and(framework::is_contract) {
|
|
35
|
+
return Ok(None);
|
|
36
|
+
}
|
|
37
|
+
let agent_id = AgentId::new(agent);
|
|
38
|
+
let _lock = acquire_agent_lifecycle_lock(LifecycleLockRequest {
|
|
39
|
+
workspace: &selected.run_workspace,
|
|
40
|
+
operation,
|
|
41
|
+
team: Some(&selected.team_key),
|
|
42
|
+
agent_id: Some(&agent_id),
|
|
43
|
+
}).map_err(error)?;
|
|
44
|
+
let latest = resolve_active_team_readonly(
|
|
45
|
+
&selected.run_workspace, Some(&selected.team_key), SelectorMode::RuntimeOnly,
|
|
46
|
+
).map_err(error)?;
|
|
47
|
+
crate::lifecycle::launch::ensure_owner_allowed_for_state(&latest.state, Some(&agent_id)).map_err(error)?;
|
|
48
|
+
if !latest.state.get("agents").and_then(|agents| agents.get(agent)).is_some_and(framework::is_contract) {
|
|
49
|
+
return Err(CliError::Runtime("contract seat changed while acquiring lifecycle ownership".into()));
|
|
50
|
+
}
|
|
51
|
+
apply(&latest, &SeatId::new(agent).map_err(error)?).map(Some)
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
fn publish(selected: &SelectedTeam, id: &SeatId) -> Result<Value, CliError> {
|
|
55
|
+
publish_at(&selected.run_workspace, &selected.team_key, id)
|
|
56
|
+
}
|
|
57
|
+
fn publish_at(workspace: &Path, team: &str, id: &SeatId) -> Result<Value, CliError> {
|
|
58
|
+
let projected = framework::project_seat(workspace, team, id.as_str()).map_err(error)?;
|
|
59
|
+
StateRepository::new(workspace).commit(
|
|
60
|
+
StateWriteIntent::StartAgent { team_key: team, agent_id: id.as_str() },
|
|
61
|
+
|state| {
|
|
62
|
+
if let Some(row) = state.get_mut("agents").and_then(|agents| agents.get_mut(id.as_str())).and_then(Value::as_object_mut) {
|
|
63
|
+
for (key, value) in projected.as_object().expect("native projection object") {
|
|
64
|
+
row.insert(key.clone(), value.clone());
|
|
65
|
+
}
|
|
66
|
+
row.insert("paused".into(), Value::Bool(false));
|
|
67
|
+
}
|
|
68
|
+
},
|
|
69
|
+
).map_err(error)?;
|
|
70
|
+
Ok(projected)
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
fn check_record(receipt: &team_agent_contract::orchestration::store::OperationRecord) -> Result<(), CliError> {
|
|
74
|
+
if receipt.outcome != Outcome::Committed {
|
|
75
|
+
return Err(error(BackendError::Shutdown {
|
|
76
|
+
operation: receipt.id.clone(), phase: receipt.phase, outcome: receipt.outcome, failure: receipt.failure.clone(),
|
|
77
|
+
}));
|
|
78
|
+
}
|
|
79
|
+
Ok(())
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
pub fn start_agent(
|
|
83
|
+
workspace: &Path,
|
|
84
|
+
team: Option<&str>,
|
|
85
|
+
agent: &str,
|
|
86
|
+
patch: &crate::lifecycle::role_config::RoleConfigPatch,
|
|
87
|
+
) -> Result<Option<Value>, CliError> {
|
|
88
|
+
with_agent(workspace, team, agent, "start-agent", |selected, id| {
|
|
89
|
+
let args = crate::cli::QuickStartArgs {
|
|
90
|
+
workspace: selected.run_workspace.clone(), agents_dir: selected.team_dir.clone(),
|
|
91
|
+
name: None, team_id: Some(selected.team_key.clone()), yes: false, json: false,
|
|
92
|
+
detail: false, backend: Some("tmux".into()),
|
|
93
|
+
};
|
|
94
|
+
// Contract configuration, not a stale legacy dynamic_role_file, is the
|
|
95
|
+
// authoritative source. Never patch an unrelated external role file.
|
|
96
|
+
let original = config::read_team(&args).map_err(error)?.ok_or(BackendError::Metadata).map_err(error)?;
|
|
97
|
+
let source = original.roles.iter().find(|role| &role.id == id)
|
|
98
|
+
.ok_or(BackendError::Metadata).map_err(error)?.source.clone();
|
|
99
|
+
let previous = std::fs::read_to_string(&source).map_err(error)?;
|
|
100
|
+
let next = crate::lifecycle::role_config::patched_role(&previous, &AgentId::new(agent), patch, false).map_err(error)?;
|
|
101
|
+
let config = config::read_team_with_role_override(&args, &source, &next).map_err(error)?.ok_or(BackendError::Metadata).map_err(error)?;
|
|
102
|
+
let role = config.roles.iter().find(|role| &role.id == id).ok_or(BackendError::Metadata).map_err(error)?;
|
|
103
|
+
let row = selected.state.get("agents").and_then(|agents| agents.get(agent)).ok_or(BackendError::Metadata).map_err(error)?;
|
|
104
|
+
if row.get("provider").and_then(Value::as_str) != Some(role.provider.as_str()) {
|
|
105
|
+
return Err(error("provider change requires a new seat identity"));
|
|
106
|
+
}
|
|
107
|
+
if role.paused {
|
|
108
|
+
return Ok(json!({"ok":false,"agent_id":agent,"status":"paused","reason":"agent_paused"}));
|
|
109
|
+
}
|
|
110
|
+
let unstarted = framework::is_paused_unstarted(row);
|
|
111
|
+
let prepared = if unstarted {
|
|
112
|
+
// Full pure team admission precedes native allocation or role writes.
|
|
113
|
+
backend::preflight(&config).map_err(error)?;
|
|
114
|
+
None
|
|
115
|
+
} else {
|
|
116
|
+
let backend = Backend::open_readonly(&selected.run_workspace, &selected.team_key).map_err(error)?;
|
|
117
|
+
let seat = backend.store.seat(id).map_err(error)?.ok_or(BackendError::Metadata).map_err(error)?;
|
|
118
|
+
if seat.status == SeatStatus::Unknown {
|
|
119
|
+
return Err(error("native seat state is unknown; explicit reconciliation required"));
|
|
120
|
+
}
|
|
121
|
+
if seat.status != SeatStatus::Stopped && seat.physical.as_ref().is_some_and(|target| sample_process(&target.process) == ProcessState::Alive) {
|
|
122
|
+
return Err(error("native seat is already running; use stop-agent first"));
|
|
123
|
+
}
|
|
124
|
+
Some(backend::prepare_resume(&selected.run_workspace, &selected.team_key, id, Some(role)).map_err(error)?)
|
|
125
|
+
};
|
|
126
|
+
crate::lifecycle::role_config::commit_contract_role(&source, &previous, &next).map_err(error)?;
|
|
127
|
+
let receipt = match prepared {
|
|
128
|
+
Some(prepared) => backend::resume(prepared).map_err(error)?,
|
|
129
|
+
None => backend::start(&selected.run_workspace, &selected.team_key, role, &config.members).map_err(error)?.1,
|
|
130
|
+
};
|
|
131
|
+
// An uncertain operation never rolls the authoritative native store back
|
|
132
|
+
// to the old generation. The receipt remains available for recovery.
|
|
133
|
+
let projection = publish(selected, id)?;
|
|
134
|
+
check_record(&receipt)?;
|
|
135
|
+
let coordinator = crate::coordinator::start_coordinator_with_team(
|
|
136
|
+
&crate::coordinator::WorkspacePath::new(selected.run_workspace.clone()), Some(&selected.team_key),
|
|
137
|
+
).map_err(error)?;
|
|
138
|
+
Ok(json!({"ok":coordinator.ok,"agent_id":agent,"status":"running","runtime":"contract",
|
|
139
|
+
"start_mode":if unstarted {"fresh"} else {"resumed"}, "operation":receipt.id,
|
|
140
|
+
"session_id":projection.get("session_id"),"generation":projection.get("generation"),
|
|
141
|
+
"coordinator":crate::lifecycle::coordinator_start_summary_value(
|
|
142
|
+
&crate::lifecycle::CoordinatorStartSummary::from_start_report(&coordinator))}))
|
|
143
|
+
})
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
pub(crate) struct NativeRestartResult {
|
|
147
|
+
pub agent_id: String,
|
|
148
|
+
pub session_id: Option<String>,
|
|
149
|
+
pub outcome: Result<(), String>,
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/// Called with the ordinary workspace lifecycle lock already held. Native
|
|
153
|
+
/// panes never enter legacy transport spawn, backing scan or shared teardown.
|
|
154
|
+
pub(crate) fn restart_prepared(
|
|
155
|
+
workspace: &Path,
|
|
156
|
+
team: &str,
|
|
157
|
+
state: &mut Value,
|
|
158
|
+
prepared: Vec<backend::PreparedResume>,
|
|
159
|
+
) -> Vec<NativeRestartResult> {
|
|
160
|
+
prepared.into_iter().map(|prepared| {
|
|
161
|
+
let id = prepared.seat().clone();
|
|
162
|
+
let session_id = prepared.session().map(str::to_owned);
|
|
163
|
+
let outcome = (|| {
|
|
164
|
+
let receipt = backend::resume(prepared).map_err(error)?;
|
|
165
|
+
let projection = publish_at(workspace, team, &id)?;
|
|
166
|
+
if let Some(row) = state.get_mut("agents").and_then(|agents| agents.get_mut(id.as_str())).and_then(Value::as_object_mut) {
|
|
167
|
+
for (key, value) in projection.as_object().expect("native projection object") {
|
|
168
|
+
row.insert(key.clone(), value.clone());
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
check_record(&receipt)
|
|
172
|
+
})().map_err(|error| error.to_string());
|
|
173
|
+
NativeRestartResult { agent_id: id.as_str().into(), session_id, outcome }
|
|
174
|
+
}).collect()
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
pub fn restart(workspace: &Path, team: Option<&str>) -> Result<Option<Value>, CliError> {
|
|
178
|
+
let Ok(selected) = resolve_active_team_readonly(workspace, team, SelectorMode::RuntimeOnly) else {
|
|
179
|
+
return Ok(None);
|
|
180
|
+
};
|
|
181
|
+
if !framework::only_contract(&selected.state) { return Ok(None); }
|
|
182
|
+
let _lock = acquire_agent_lifecycle_lock(LifecycleLockRequest {
|
|
183
|
+
workspace: &selected.run_workspace, operation: "restart",
|
|
184
|
+
team: Some(&selected.team_key), agent_id: None,
|
|
185
|
+
}).map_err(error)?;
|
|
186
|
+
let latest = resolve_active_team_readonly(&selected.run_workspace, Some(&selected.team_key), SelectorMode::RuntimeOnly).map_err(error)?;
|
|
187
|
+
crate::lifecycle::launch::ensure_owner_allowed_for_state(&latest.state, None).map_err(error)?;
|
|
188
|
+
if !framework::only_contract(&latest.state) { return Err(error("native team changed while acquiring lifecycle ownership")); }
|
|
189
|
+
let prepared = backend::prepare_restart(&latest.run_workspace, &latest.team_key, &latest.team_dir, &latest.state).map_err(error)?;
|
|
190
|
+
let mut state = latest.state.clone();
|
|
191
|
+
let results = restart_prepared(&latest.run_workspace, &latest.team_key, &mut state, prepared);
|
|
192
|
+
let succeeded: Vec<_> = results.iter().filter(|result| result.outcome.is_ok()).map(|result| json!({
|
|
193
|
+
"agent_id":result.agent_id,"restart_mode":"resumed","session_id":result.session_id,
|
|
194
|
+
})).collect();
|
|
195
|
+
let failed: Vec<_> = results.iter().filter_map(|result| result.outcome.as_ref().err().map(|error| json!({
|
|
196
|
+
"agent_id":result.agent_id,"phase":"native_resume","error":error,
|
|
197
|
+
}))).collect();
|
|
198
|
+
let coordinator = crate::coordinator::start_coordinator_with_team(
|
|
199
|
+
&crate::coordinator::WorkspacePath::new(latest.run_workspace.clone()), Some(&latest.team_key),
|
|
200
|
+
).map_err(error)?;
|
|
201
|
+
Ok(Some(json!({"ok":failed.is_empty() && coordinator.ok,"runtime":"contract",
|
|
202
|
+
"status":if failed.is_empty() {"restarted"} else if succeeded.is_empty() {"failed"} else {"partial"},
|
|
203
|
+
"team_id":latest.team_key,"agents":succeeded,"failed_agents":failed,
|
|
204
|
+
"coordinator":crate::lifecycle::coordinator_start_summary_value(
|
|
205
|
+
&crate::lifecycle::CoordinatorStartSummary::from_start_report(&coordinator))})))
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
pub fn stop_agent(
|
|
209
|
+
workspace: &Path,
|
|
210
|
+
team: Option<&str>,
|
|
211
|
+
agent: &str,
|
|
212
|
+
) -> Result<Option<Value>, CliError> {
|
|
213
|
+
with_agent(workspace, team, agent, "stop-agent", |selected, id| {
|
|
214
|
+
if selected.state.get("agents").and_then(|agents| agents.get(agent)).is_some_and(framework::is_paused_unstarted) {
|
|
215
|
+
return Ok(json!({"ok":true,"agent_id":agent,"stopped":false,"status":"paused","runtime":"contract"}));
|
|
216
|
+
}
|
|
217
|
+
let backend = Backend::open_readonly(&selected.run_workspace, &selected.team_key).map_err(error)?;
|
|
218
|
+
let seat = backend.store.seat(id).map_err(error)?.ok_or(BackendError::Metadata).map_err(error)?;
|
|
219
|
+
let already_stopped = seat.status == SeatStatus::Stopped;
|
|
220
|
+
drop(backend);
|
|
221
|
+
let operation = if already_stopped {
|
|
222
|
+
None
|
|
223
|
+
} else {
|
|
224
|
+
let receipt = backend::stop(&selected.run_workspace, &selected.team_key, id).map_err(error)?;
|
|
225
|
+
if receipt.outcome != Outcome::Committed {
|
|
226
|
+
return Err(error(BackendError::Shutdown {
|
|
227
|
+
operation: receipt.id, phase: receipt.phase, outcome: receipt.outcome, failure: receipt.failure,
|
|
228
|
+
}));
|
|
229
|
+
}
|
|
230
|
+
Some(receipt.id)
|
|
231
|
+
};
|
|
232
|
+
let projected = framework::project_seat(&selected.run_workspace, &selected.team_key, id.as_str()).map_err(error)?;
|
|
233
|
+
let mut state = selected.state.clone();
|
|
234
|
+
let row = state.get_mut("agents").and_then(|agents| agents.get_mut(agent)).and_then(Value::as_object_mut).ok_or(BackendError::Metadata).map_err(error)?;
|
|
235
|
+
for (key, value) in projected.as_object().expect("native projection object") {
|
|
236
|
+
row.insert(key.clone(), value.clone());
|
|
237
|
+
}
|
|
238
|
+
StateRepository::new(&selected.run_workspace).save(
|
|
239
|
+
StateWriteIntent::StopAgent { team_key: &selected.team_key, agent_id: agent }, &state,
|
|
240
|
+
).map_err(error)?;
|
|
241
|
+
Ok(json!({"ok":true,"agent_id":agent,"stopped":!already_stopped,
|
|
242
|
+
"status":if already_stopped {"already_stopped"} else {"stopped"},
|
|
243
|
+
"runtime":"contract","operation":operation}))
|
|
244
|
+
})
|
|
245
|
+
}
|
|
@@ -0,0 +1,340 @@
|
|
|
1
|
+
//! Read-only per-agent routing/preflight. Contract roles opt in independently;
|
|
2
|
+
//! other roles keep the legacy engine, including in the same logical team.
|
|
3
|
+
#[cfg(test)]
|
|
4
|
+
#[path = "config_tests.rs"]
|
|
5
|
+
mod tests;
|
|
6
|
+
use std::collections::BTreeSet;
|
|
7
|
+
use std::path::{Path, PathBuf};
|
|
8
|
+
|
|
9
|
+
use serde::{Deserialize, Serialize};
|
|
10
|
+
use team_agent_contract::contract::{
|
|
11
|
+
session::CwdIdentity,
|
|
12
|
+
types::{ProviderId, ScopeId, SeatId},
|
|
13
|
+
};
|
|
14
|
+
use team_agent_contract::host::{digest, digest_hex, process::resolve_cwd};
|
|
15
|
+
use thiserror::Error;
|
|
16
|
+
|
|
17
|
+
use super::prompt::{self, Prompt, Source};
|
|
18
|
+
use crate::cli::QuickStartArgs;
|
|
19
|
+
use crate::communication_mode::CommunicationMode;
|
|
20
|
+
use crate::compiler::split_front_matter;
|
|
21
|
+
use crate::model::yaml::Value;
|
|
22
|
+
|
|
23
|
+
#[derive(Debug, Error)]
|
|
24
|
+
pub enum ConfigError {
|
|
25
|
+
#[error("invalid contract configuration field {field} in {path:?}")]
|
|
26
|
+
Invalid { path: PathBuf, field: &'static str },
|
|
27
|
+
#[error("contract runtime capability is not available: {0}")]
|
|
28
|
+
Unsupported(&'static str),
|
|
29
|
+
#[error("contract configuration I/O: {0}")]
|
|
30
|
+
Io(#[from] std::io::Error),
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// Prompts intentionally have no Debug implementation and are never diagnostics.
|
|
34
|
+
#[derive(Clone, Serialize, Deserialize)]
|
|
35
|
+
pub struct RoleConfig {
|
|
36
|
+
pub id: SeatId,
|
|
37
|
+
pub provider: ProviderId,
|
|
38
|
+
pub source: PathBuf,
|
|
39
|
+
pub role: String,
|
|
40
|
+
pub model: String,
|
|
41
|
+
pub effort: Option<String>,
|
|
42
|
+
pub bypass: bool,
|
|
43
|
+
#[serde(default)]
|
|
44
|
+
pub paused: bool,
|
|
45
|
+
pub body: String,
|
|
46
|
+
pub prompt: Prompt,
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
|
50
|
+
pub enum RuntimeFamily {
|
|
51
|
+
Legacy,
|
|
52
|
+
Contract,
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
|
56
|
+
pub struct MemberRoute {
|
|
57
|
+
pub id: String,
|
|
58
|
+
pub provider: String,
|
|
59
|
+
pub source: PathBuf,
|
|
60
|
+
pub runtime: RuntimeFamily,
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
#[derive(Clone, Serialize, Deserialize)]
|
|
64
|
+
pub struct TeamConfig {
|
|
65
|
+
pub selector: String,
|
|
66
|
+
pub label: String,
|
|
67
|
+
pub scope: ScopeId,
|
|
68
|
+
pub team_dir: PathBuf,
|
|
69
|
+
pub workspace: CwdIdentity,
|
|
70
|
+
pub objective: String,
|
|
71
|
+
pub members: Vec<MemberRoute>,
|
|
72
|
+
/// Only contract roles; the legacy member definitions are not rewritten.
|
|
73
|
+
pub roles: Vec<RoleConfig>,
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
fn invalid(path: &Path, field: &'static str) -> ConfigError {
|
|
77
|
+
ConfigError::Invalid {
|
|
78
|
+
path: path.into(),
|
|
79
|
+
field,
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
fn text(meta: &Value, field: &'static str, path: &Path) -> Result<Option<String>, ConfigError> {
|
|
83
|
+
match meta.get(field) {
|
|
84
|
+
None | Some(Value::Null) => Ok(None),
|
|
85
|
+
Some(Value::Str(value)) if !value.trim().is_empty() && !value.contains('\0') => {
|
|
86
|
+
Ok(Some(value.clone()))
|
|
87
|
+
}
|
|
88
|
+
_ => Err(invalid(path, field)),
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
fn required(meta: &Value, field: &'static str, path: &Path) -> Result<String, ConfigError> {
|
|
92
|
+
text(meta, field, path)?.ok_or_else(|| invalid(path, field))
|
|
93
|
+
}
|
|
94
|
+
fn mode(meta: &Value, path: &Path) -> Result<Option<CommunicationMode>, ConfigError> {
|
|
95
|
+
text(meta, "communication_mode", path)?
|
|
96
|
+
.map(|value| {
|
|
97
|
+
CommunicationMode::parse(&value).ok_or_else(|| invalid(path, "communication_mode"))
|
|
98
|
+
})
|
|
99
|
+
.transpose()
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/// A malformed explicit contract header cannot fall back to the default Pi worker.
|
|
103
|
+
fn malformed_contract_header(body: &str) -> bool {
|
|
104
|
+
body.strip_prefix("---\n").is_some_and(|header| {
|
|
105
|
+
header
|
|
106
|
+
.lines()
|
|
107
|
+
.take_while(|line| *line != "---")
|
|
108
|
+
.any(|line| {
|
|
109
|
+
line.split_once(':').is_some_and(|(key, value)| {
|
|
110
|
+
key.trim() == "provider"
|
|
111
|
+
&& super::registry::recognizes(
|
|
112
|
+
value
|
|
113
|
+
.split('#')
|
|
114
|
+
.next()
|
|
115
|
+
.unwrap_or("")
|
|
116
|
+
.trim()
|
|
117
|
+
.trim_matches(['\'', '"']),
|
|
118
|
+
)
|
|
119
|
+
})
|
|
120
|
+
})
|
|
121
|
+
})
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/// None delegates exactly to the existing legacy/leader-only path. No native
|
|
125
|
+
/// command, model probe, config write or process is created by this selection.
|
|
126
|
+
pub fn read_team(args: &QuickStartArgs) -> Result<Option<TeamConfig>, ConfigError> {
|
|
127
|
+
read_team_with_user_source(args, prompt::user_source)
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/// A unified framework user-policy source can be injected without making the
|
|
131
|
+
/// provider know its storage location. Tests use this port instead of real HOME.
|
|
132
|
+
pub fn read_team_with_user_source(
|
|
133
|
+
args: &QuickStartArgs,
|
|
134
|
+
user_source: impl FnOnce() -> Result<Option<Source>, ConfigError>,
|
|
135
|
+
) -> Result<Option<TeamConfig>, ConfigError> {
|
|
136
|
+
read_team_inner(args, user_source, None)
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/// Compile an admitted role-document change without first writing any file.
|
|
140
|
+
pub(crate) fn read_team_with_role_override(
|
|
141
|
+
args: &QuickStartArgs,
|
|
142
|
+
path: &Path,
|
|
143
|
+
contents: &str,
|
|
144
|
+
) -> Result<Option<TeamConfig>, ConfigError> {
|
|
145
|
+
read_team_inner(args, prompt::user_source, Some((path, contents)))
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
fn read_team_inner(
|
|
149
|
+
args: &QuickStartArgs,
|
|
150
|
+
user_source: impl FnOnce() -> Result<Option<Source>, ConfigError>,
|
|
151
|
+
role_override: Option<(&Path, &str)>,
|
|
152
|
+
) -> Result<Option<TeamConfig>, ConfigError> {
|
|
153
|
+
let agents = args.agents_dir.join("agents");
|
|
154
|
+
let entries = match std::fs::read_dir(&agents) {
|
|
155
|
+
Ok(entries) => entries,
|
|
156
|
+
Err(_) => return Ok(None),
|
|
157
|
+
};
|
|
158
|
+
let mut paths = Vec::new();
|
|
159
|
+
for entry in entries {
|
|
160
|
+
let entry = entry?;
|
|
161
|
+
let path = entry.path();
|
|
162
|
+
if path.extension().and_then(|s| s.to_str()) == Some("md") {
|
|
163
|
+
paths.push(path);
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
paths.sort();
|
|
167
|
+
let mut roles = Vec::new();
|
|
168
|
+
let mut selected = false;
|
|
169
|
+
for path in paths {
|
|
170
|
+
let raw = match role_override.filter(|(source, _)| path.canonicalize().ok().as_deref() == Some(*source)) {
|
|
171
|
+
Some((_, contents)) => contents.to_owned(),
|
|
172
|
+
None => match std::fs::read_to_string(&path) {
|
|
173
|
+
Ok(raw) => raw,
|
|
174
|
+
Err(error) if selected => return Err(error.into()),
|
|
175
|
+
Err(_) => return Ok(None), // let the legacy compiler report its existing I/O error
|
|
176
|
+
},
|
|
177
|
+
};
|
|
178
|
+
let (meta, body) = split_front_matter(&raw);
|
|
179
|
+
let normalized = raw.replace("\r\n", "\n").replace('\r', "\n");
|
|
180
|
+
if meta.get("provider").is_none() && malformed_contract_header(&normalized) {
|
|
181
|
+
return Err(invalid(&path, "front matter"));
|
|
182
|
+
}
|
|
183
|
+
if meta
|
|
184
|
+
.get("provider")
|
|
185
|
+
.and_then(Value::as_str)
|
|
186
|
+
.is_some_and(super::registry::recognizes)
|
|
187
|
+
{
|
|
188
|
+
selected = true;
|
|
189
|
+
}
|
|
190
|
+
roles.push((path, raw, meta, body));
|
|
191
|
+
}
|
|
192
|
+
if !selected {
|
|
193
|
+
return Ok(None);
|
|
194
|
+
}
|
|
195
|
+
if args
|
|
196
|
+
.backend
|
|
197
|
+
.as_deref()
|
|
198
|
+
.is_some_and(|backend| backend != "tmux")
|
|
199
|
+
{
|
|
200
|
+
return Err(ConfigError::Unsupported(
|
|
201
|
+
"contract native transport requires tmux",
|
|
202
|
+
));
|
|
203
|
+
}
|
|
204
|
+
let team_dir = args.agents_dir.canonicalize()?;
|
|
205
|
+
let team_path = team_dir.join("TEAM.md");
|
|
206
|
+
let team_source = Source::read(&team_path)?;
|
|
207
|
+
let (team_meta, team_body) = split_front_matter(&team_source.contents);
|
|
208
|
+
let label = args
|
|
209
|
+
.name
|
|
210
|
+
.clone()
|
|
211
|
+
.or(text(&team_meta, "name", &team_path)?)
|
|
212
|
+
.or_else(|| {
|
|
213
|
+
team_dir
|
|
214
|
+
.file_name()
|
|
215
|
+
.and_then(|s| s.to_str())
|
|
216
|
+
.map(str::to_owned)
|
|
217
|
+
})
|
|
218
|
+
.ok_or_else(|| invalid(&team_path, "name"))?;
|
|
219
|
+
let selector = args.team_id.clone().unwrap_or_else(|| label.clone());
|
|
220
|
+
if selector.trim().is_empty() || selector.chars().any(char::is_control) {
|
|
221
|
+
return Err(invalid(&team_path, "team selector"));
|
|
222
|
+
}
|
|
223
|
+
let workspace =
|
|
224
|
+
resolve_cwd(&args.workspace).map_err(|_| invalid(&args.workspace, "workspace identity"))?;
|
|
225
|
+
let key =
|
|
226
|
+
serde_json::to_vec(&(&workspace, &selector)).map_err(|_| invalid(&team_path, "scope"))?;
|
|
227
|
+
let scope = ScopeId::new(format!("contract-{}", &digest_hex(digest(&key))[..32]))
|
|
228
|
+
.map_err(|_| invalid(&team_path, "scope"))?;
|
|
229
|
+
let objective = text(&team_meta, "objective", &team_path)?.unwrap_or(team_body);
|
|
230
|
+
let user = user_source()?;
|
|
231
|
+
let project = prompt::project_source(&workspace.path)?;
|
|
232
|
+
let team_instructions = prompt::team_sources(&team_source, &team_meta)?;
|
|
233
|
+
let team_mode = mode(&team_meta, &team_path)?.unwrap_or_default();
|
|
234
|
+
let mut ids = BTreeSet::new();
|
|
235
|
+
let mut compiled = Vec::new();
|
|
236
|
+
let mut members = Vec::new();
|
|
237
|
+
for (source, raw, meta, body) in roles {
|
|
238
|
+
let id = text(&meta, "agent_id", &source)?
|
|
239
|
+
.or(text(&meta, "name", &source)?)
|
|
240
|
+
.or_else(|| {
|
|
241
|
+
source
|
|
242
|
+
.file_stem()
|
|
243
|
+
.and_then(|s| s.to_str())
|
|
244
|
+
.map(str::to_owned)
|
|
245
|
+
})
|
|
246
|
+
.ok_or_else(|| invalid(&source, "agent id"))?;
|
|
247
|
+
if !ids.insert(id.clone()) {
|
|
248
|
+
return Err(invalid(&source, "duplicate agent id"));
|
|
249
|
+
}
|
|
250
|
+
let provider = text(&meta, "provider", &source)?.unwrap_or_else(|| "pi".into());
|
|
251
|
+
let registration = super::registry::descriptor(&provider);
|
|
252
|
+
if registration.is_none() && super::registry::recognizes(&provider) {
|
|
253
|
+
return Err(invalid(&source, "provider"));
|
|
254
|
+
}
|
|
255
|
+
let runtime = if registration.is_some() {
|
|
256
|
+
RuntimeFamily::Contract
|
|
257
|
+
} else {
|
|
258
|
+
RuntimeFamily::Legacy
|
|
259
|
+
};
|
|
260
|
+
members.push(MemberRoute {
|
|
261
|
+
id: id.clone(),
|
|
262
|
+
provider: registration.map_or(provider, |descriptor| descriptor.identity.id.into()),
|
|
263
|
+
source: source.canonicalize()?,
|
|
264
|
+
runtime,
|
|
265
|
+
});
|
|
266
|
+
let Some(registration) = registration else {
|
|
267
|
+
continue;
|
|
268
|
+
};
|
|
269
|
+
let provider =
|
|
270
|
+
ProviderId::new(registration.identity.id).map_err(|_| invalid(&source, "provider"))?;
|
|
271
|
+
let id = SeatId::new(id).map_err(|_| invalid(&source, "agent id"))?;
|
|
272
|
+
if id.as_str() == "leader" {
|
|
273
|
+
return Err(invalid(&source, "reserved worker id"));
|
|
274
|
+
}
|
|
275
|
+
if text(&meta, "auth_mode", &source)?.is_some_and(|value| value != "subscription")
|
|
276
|
+
|| text(&meta, "profile", &source)?.is_some()
|
|
277
|
+
{
|
|
278
|
+
return Err(ConfigError::Unsupported(
|
|
279
|
+
"contract native providers use an existing subscription; API/profile mappings are unavailable",
|
|
280
|
+
));
|
|
281
|
+
}
|
|
282
|
+
let bypass = match meta.get("dangerously_skip_permissions") {
|
|
283
|
+
None => false,
|
|
284
|
+
Some(Value::Bool(value)) => *value,
|
|
285
|
+
_ => return Err(invalid(&source, "dangerously_skip_permissions")),
|
|
286
|
+
};
|
|
287
|
+
let paused = match meta.get("paused") {
|
|
288
|
+
None => false,
|
|
289
|
+
Some(Value::Bool(value)) => *value,
|
|
290
|
+
_ => return Err(invalid(&source, "paused")),
|
|
291
|
+
};
|
|
292
|
+
if body.trim().is_empty() || body.contains('\0') {
|
|
293
|
+
return Err(invalid(&source, "role body"));
|
|
294
|
+
}
|
|
295
|
+
if ["args", "cli_args"]
|
|
296
|
+
.iter()
|
|
297
|
+
.any(|field| meta.get(field).is_some())
|
|
298
|
+
{
|
|
299
|
+
return Err(ConfigError::Unsupported(
|
|
300
|
+
"unregistered native arguments cannot override the managed launch plan",
|
|
301
|
+
));
|
|
302
|
+
}
|
|
303
|
+
let role = text(&meta, "role", &source)?.unwrap_or_else(|| id.as_str().into());
|
|
304
|
+
let document = Source {
|
|
305
|
+
path: source.canonicalize()?,
|
|
306
|
+
contents: raw,
|
|
307
|
+
};
|
|
308
|
+
let prompt = prompt::assemble(
|
|
309
|
+
id.as_str(),
|
|
310
|
+
&role,
|
|
311
|
+
mode(&meta, &source)?.unwrap_or(team_mode),
|
|
312
|
+
user.as_ref(),
|
|
313
|
+
project.as_ref(),
|
|
314
|
+
&document,
|
|
315
|
+
&team_instructions,
|
|
316
|
+
)?;
|
|
317
|
+
compiled.push(RoleConfig {
|
|
318
|
+
id,
|
|
319
|
+
provider,
|
|
320
|
+
source: document.path,
|
|
321
|
+
role,
|
|
322
|
+
model: required(&meta, "model", &source)?,
|
|
323
|
+
effort: text(&meta, "effort", &source)?,
|
|
324
|
+
bypass,
|
|
325
|
+
paused,
|
|
326
|
+
body,
|
|
327
|
+
prompt,
|
|
328
|
+
});
|
|
329
|
+
}
|
|
330
|
+
Ok(Some(TeamConfig {
|
|
331
|
+
selector,
|
|
332
|
+
label,
|
|
333
|
+
scope,
|
|
334
|
+
team_dir,
|
|
335
|
+
workspace,
|
|
336
|
+
objective,
|
|
337
|
+
members,
|
|
338
|
+
roles: compiled,
|
|
339
|
+
}))
|
|
340
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
use super::*;
|
|
2
|
+
use std::os::unix::fs::DirBuilderExt;
|
|
3
|
+
|
|
4
|
+
struct Fixture { root: PathBuf, args: QuickStartArgs, role: PathBuf }
|
|
5
|
+
impl Fixture {
|
|
6
|
+
fn new() -> Self {
|
|
7
|
+
let root = std::env::temp_dir().join(format!("contract-override-{}-{}", std::process::id(),
|
|
8
|
+
std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos()));
|
|
9
|
+
std::fs::DirBuilder::new().mode(0o700).create(&root).unwrap();
|
|
10
|
+
let directory = root.join("roles");
|
|
11
|
+
std::fs::create_dir_all(directory.join("agents")).unwrap();
|
|
12
|
+
std::fs::write(directory.join("TEAM.md"), "---\nname: test\n---\nBounded fixture\n").unwrap();
|
|
13
|
+
let role = directory.join("agents/worker.md");
|
|
14
|
+
std::fs::write(&role, "---\nprovider: cursor\nmodel: old-model\n---\nOld instructions\n").unwrap();
|
|
15
|
+
let args = QuickStartArgs { workspace: root.clone(), agents_dir: directory, name: None,
|
|
16
|
+
team_id: None, yes: false, json: false, detail: false, backend: Some("tmux".into()) };
|
|
17
|
+
Self { root, args, role }
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
impl Drop for Fixture { fn drop(&mut self) { let _ = std::fs::remove_dir_all(&self.root); } }
|
|
21
|
+
|
|
22
|
+
#[test]
|
|
23
|
+
fn virtual_role_change_is_readonly_and_binds_new_instruction_digest() {
|
|
24
|
+
let fixture = Fixture::new();
|
|
25
|
+
let before = std::fs::read(&fixture.role).unwrap();
|
|
26
|
+
let path = fixture.role.canonicalize().unwrap();
|
|
27
|
+
let contents = "---\nprovider: cursor\nmodel: new-model\n---\nNew instructions\n";
|
|
28
|
+
let original = read_team_inner(&fixture.args, || Ok(None), None).unwrap().unwrap();
|
|
29
|
+
let changed = read_team_inner(&fixture.args, || Ok(None), Some((&path, contents))).unwrap().unwrap();
|
|
30
|
+
assert_eq!(changed.roles[0].model, "new-model");
|
|
31
|
+
assert_eq!(changed.roles[0].body, "New instructions\n");
|
|
32
|
+
assert_ne!(changed.roles[0].prompt.sha256, original.roles[0].prompt.sha256);
|
|
33
|
+
assert_eq!(std::fs::read(&fixture.role).unwrap(), before);
|
|
34
|
+
assert!(!fixture.root.join(".team").exists());
|
|
35
|
+
assert!(!fixture.root.join(".cursor").exists());
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
#[test]
|
|
39
|
+
fn virtual_api_profile_is_refused_without_file_effects() {
|
|
40
|
+
let fixture = Fixture::new();
|
|
41
|
+
let before = std::fs::read(&fixture.role).unwrap();
|
|
42
|
+
let path = fixture.role.canonicalize().unwrap();
|
|
43
|
+
let contents = "---\nprovider: cursor\nmodel: new-model\nprofile: unsupported\n---\nInstructions\n";
|
|
44
|
+
assert!(matches!(read_team_inner(&fixture.args, || Ok(None), Some((&path, contents))), Err(ConfigError::Unsupported(_))));
|
|
45
|
+
assert_eq!(std::fs::read(&fixture.role).unwrap(), before);
|
|
46
|
+
assert!(!fixture.root.join(".team").exists());
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
#[test]
|
|
50
|
+
fn virtual_role_override_cannot_match_a_different_source() {
|
|
51
|
+
let fixture = Fixture::new();
|
|
52
|
+
let other = fixture.root.join("other.md");
|
|
53
|
+
let contents = "---\nprovider: cursor\nmodel: wrong-model\n---\nOther\n";
|
|
54
|
+
let config = read_team_inner(&fixture.args, || Ok(None), Some((&other, contents))).unwrap().unwrap();
|
|
55
|
+
assert_eq!(config.roles[0].model, "old-model");
|
|
56
|
+
}
|