Plugins
Le kit Rust
Un plugin en Rust est un module WASM, compilé par le store depuis ses sources, sur les mêmes méthodes qu’un plugin en TypeScript. Cette page dit quelles crates utiliser, comment une source, un hébergeur ou un harness s’écrit, et ce que la compilation refuse.
Les trois crates
Elles sont sur crates.io en 0.1.0, sous licence MIT : un plugin garde le choix de la sienne. Leur documentation est sur docs.rs, pour le natif et pour wasm32-wasip1.
| Crate | Ce qu’elle donne | Quand t’en servir |
|---|---|---|
escouade-plugin-kit |
La boucle (serve), le client http, sleep et timeout, le service des hébergeurs et des sources (hosts), les erreurs typées, la langue. |
Presque toujours. |
escouade-plugin-protocol |
Le protocole : ses types, turn et module!, le contrat des harness (engine). Le kit le réexporte (escouade_plugin_kit::protocol). |
Pour un harness, seul. |
escouade-acp |
Le client ACP, une machine à états sans process. | Pour un harness qui parle ACP et que le manifeste seul ne suffit pas à décrire. |
Un plugin en dépend par version, avec son Cargo.lock :
sdk/create-escouade-plugin/templates/rust/Cargo.toml (extrait)
[lib]
name = "plugin"
crate-type = ["cdylib", "rlib"]
[dependencies]
escouade-plugin-kit = "0.1.0"
serde_json = "1"
# Only for `cargo test`, on your machine: the module has no tokio.
[target.'cfg(not(target_family = "wasm"))'.dev-dependencies]
tokio = { version = "1", features = ["io-util", "rt"] }
# The plugin stands alone: a repository with several plugins does not make it a member of anything.
[workspace]
La même source compile deux fois : en process sur ta machine pour cargo test, avec tokio et reqwest, et pour wasm32-wasip1 en module, sans eux.
Un plugin
Plugin a une méthode, call, que le kit appelle pour tout ce qui arrive, sauf initialize et shutdown, qu’il répond lui-même :
sdk/create-escouade-plugin/templates/rust/src/lib.rs (extrait)
struct Template;
impl Plugin for Template {
// Every method of Escouade comes here but `initialize` and `shutdown`, which the kit answers. A notification too: what
// it returns goes nowhere. Nothing may block here: an `.await` gives the turn back to Escouade.
fn call<'a>(
&'a self,
cx: &'a Cx,
method: &'a str,
params: Value,
) -> BoxFuture<'a, Result<Value, Refusal>> {
Box::pin(async move {
match method {
"events/fact" => {
fact(cx, ¶ms);
Ok(Value::Null)
}
"permissions/decide" => Ok(decide(¶ms)),
other => Err(Refusal::not_found(other)),
}
})
}
}
Refusal::not_found répond -32601 à ce que le plugin ne sert pas. Reste à dire ce qui tourne, et à en faire un module, en une ligne, qui ne produit rien en natif :
sdk/create-escouade-plugin/templates/rust/src/lib.rs (extrait)
/// What the module runs at its first turn: the plugin, kept for the turns to come. Natively, the loop on stdin and stdout.
pub fn run() -> i32 {
serve::run("plugin", Template)
}
// The one line that makes this crate a module of Escouade. Natively, it is nothing.
escouade_plugin_kit::protocol::module!(run);
npm create escouade-plugin@latest mon-plugin -- --kind rust écrit tout cela, avec ses tests : voir un premier plugin. Chaque appel de l’hôte a son exemple dans les appels.
Le modèle par tours
Une instance par plugin, sur un fil, réveillée par ce qui arrive : une ligne d’Escouade, la fin d’un net/fetch, un morceau de flux, un message de socket, la sortie d’un programme, un minuteur. Escouade écrit tout ce qui est arrivé sur l’entrée du module, lance un tour, puis sert ce que le module a écrit.
Rien n’attend dans un tour. Une demande à l’hôte est une ligne, et sa réponse arrive à un tour suivant.
- Un
.awaitrend la main. Le kit garde la tâche et la reprend au tour où sa réponse arrive : plusieurs appels avancent à la fois, et un appel lent ne retient pas les autres. - Ce qui bloque le fil bloque tout le plugin : une boucle, un calcul long,
std::thread::sleep. Il n’y a pas de thread, etstd::processéchoue.escouade_plugin_kit::time::sleepremplace l’attente. - Il n’y a pas d’environnement : la langue vient d’
initialize, lue parescouade_plugin_kit::i18n, et le dossier d’un agent paragent/attach. - Le réseau passe par
http::reqwest, la façade du kit : la surface de reqwest dont les plugins se servent, surnet/fetch. Le proxy, les certificats et le délai restent à l’hôte, et ce que le plugin en dit n’a pas d’effet.
Un tour ne garde rien pour lui : l’état vit dans le plugin, entre deux tours, comme dans un process. Il n’est pas gardé d’une instance à l’autre.
La porte synchrone
Une machine à états qui doit lire un fichier avant de répondre, comme un harness, a un appel de plus : escouade.call, un import du module, servi sur le fil du plugin. turn::read_file lit par lui, et turn::host_call envoie fs/* ou proc/*. Il compte dans le temps du tour.
Les limites
| Limite | Valeur |
|---|---|
| Mémoire | 64 Mio |
| Un tour | 2 s, 500 ms quand il tient une décision |
net/fetch |
16 à la fois |
net/stream |
8 à la fois |
| WebSocket | 4 à la fois |
| Minuteurs | 64 à la fois |
| Programmes | 4 à la fois |
Le temps d’attente du réseau ne compte pas : il n’est pas dans un tour. Une instance qui dépasse, ou qui panique, est finie et refaite, avec les mêmes trois relances en cinq minutes qu’un process. Le journal dit pourquoi, et ce qu’elle tenait lui est redonné par agent/attach.
Écrire une source
Une source du Kanban sert tickets/*. Le kit fait presque tout : Hosts répond le protocole, demande le jeton du compte, garde un client par compte et met les erreurs dans la forme attendue. Un plugin donne sa sorte (SourceKind) et un client (TicketSource) :
plugins/linear/src/lib.rs (extrait)
impl SourceKind for LinearSource {
fn id(&self) -> &str {
ID
}
fn name(&self) -> &str {
S
}
fn client(&self, http: reqwest::Client, account: &Credentials) -> Box<dyn TicketSource> {
let base = account.api.clone().filter(|a| !a.trim().is_empty());
Box::new(Linear::new(http, account, base.as_deref().unwrap_or(API)))
}
}
et les branche, dans run :
plugins/linear/src/lib.rs (extrait)
pub fn run() -> i32 {
escouade_plugin_kit::serve::run(
"linear",
escouade_plugin_kit::hosts::Hosts::new(vec![], vec![Box::new(LinearSource)]),
)
}
escouade_plugin_kit::protocol::module!(run);
Le manifeste déclare le compte et la source, avec network et account:<nom> dans permissions.secrets :
plugins/linear/escouade-plugin.json (extrait)
"ticketSources": [{ "id": "linear", "name": "Linear", "account": "linear" }]
Une erreur http reste une erreur : HttpError devient -(32000 + statut), -32401 pour un jeton refusé, et Refused devient -32010, un refus durable. Le code entier est dans plugins/linear, avec Jira, Trello et ClickUp à côté.
Écrire un hébergeur
Un hébergeur de code sert forge/*, de la même façon : ForgeKind et le client Forge du kit, dont chaque méthode répond Unsupported tant que tu ne l’écris pas. Le plugin Gitea, qui sert toutes les instances d’un service auto-hébergé, tient en quelques lignes :
plugins/gitea/src/lib.rs (extrait)
impl ForgeKind for GiteaForge {
fn id(&self) -> &str {
"gitea"
}
fn name(&self) -> &str {
"Gitea / Forgejo"
}
fn client(&self, http: reqwest::Client, account: &Credentials) -> Box<dyn Forge> {
let base = account
.api
.clone()
.filter(|a| !a.trim().is_empty())
.unwrap_or_else(|| api_base(&account.url));
Box::new(pulls::Gitea::new(http, base, account.token.clone()))
}
}
account.url est l’instance que l’utilisateur a saisie, d’où {account} dans permissions.network. Un jeton refusé, une limite atteinte et le budget d’appels sont traités par Hosts. Le banc joue account/me et forge/pullRequestsOfBranch dès que contributes.forges est déclaré.
Écrire un harness
Pour un agent qui parle ACP, le manifeste suffit, sans une ligne de code. Sinon, un harness est une machine à états sans attente, qui ne dépend que du protocole : turn::run tient la boucle, sur les deux cibles.
plugins/codex/src/lib.rs (extrait)
pub fn run() -> i32 {
let mut plugin = Plugin::default();
turn::run(
"codex",
json!({ "harnesses": ["codex"] }),
move |m, params| plugin.handle(m, params),
)
}
escouade_plugin_protocol::module!(run);
Le contrat est dans escouade_plugin_protocol::engine. Le cœur lance le programme de l’agent et lit sa sortie ; le plugin dit comment le lancer, puis traduit ses lignes en éléments de conversation. std::process::id se remplace par turn::instance_id, et std::fs par turn::read_file, sous une racine de permissions.files. Un client ACP se branche par escouade_acp::Agent, dont frames lit les lignes de l’agent.
Ce que la compilation refuse
escouade-plugin build compile comme le store, et escouade-plugin validate dit la même chose sans rien lancer. Un plugin compile seul dans son dossier, sur une copie, avec rustc 1.98.0 et wasm32-wasip1 que la commande télécharge elle-même, un profil imposé et --locked : pour les mêmes sources et le même Cargo.lock, le module est le même à l’octet sous Windows et sous Linux.
| Refusé | Pourquoi | Quoi faire |
|---|---|---|
Pas de Cargo.lock |
Le store compile ce qu’il dit, et rien d’autre. | Commite-le. |
Un build.rs dans le plugin |
Il tournerait en natif sur le store. | Génère le code avant et commite-le. |
| Une macro procédurale à toi | La même raison. | Une macro macro_rules!, ou un crate de la liste. |
| Un crate hors de la liste fermée qui tourne à la compilation | Un script, une macro ou ce dont ils dépendent. Ce qui ne tourne qu’en WASM n’a pas besoin d’y être. | Retire-le, ou reste sur les versions de la liste. |
Une dépendance git ou d’un autre registre |
Le lock doit venir de crates.io seul. | Publie-la sur crates.io. |
Un path qui sort du dossier |
Le store ne lit que le dossier du plugin. | Garde tes sous-crates dans le dossier. |
[patch], [replace] |
Le module ne serait plus ce que dit le lock. | Retire-les. |
.cargo/config.toml, rust-toolchain, [profile.*.package], build-override |
La chaîne et le profil sont imposés. | Retire-les. Un [profile] simple est permis, et ignoré. |
Autre chose qu’un cdylib (une rlib à côté est permise) ; un [[bin]], src/main.rs, src/bin |
Un plugin est un module, sans main. |
crate-type = ["cdylib"]. |
module! manquant |
Le module n’a pas l’export escouade-turn. |
La ligne de module!. |
Attention
La liste fermée nomme des versions : serde 1.0.229, syn 3.0.9… Un cargo update qui monte l’un de ces crates peut faire refuser le plugin (« … n’est pas sur la liste relue »). Lance escouade-plugin validate et build après chaque mise à jour du lock. Le modèle écrit un Cargo.lock qui passe.
Ensuite
- Tester un plugin :
cargo test, le banc et le faux Escouade. - Publier sur le store : l’étiquette, les contrôles, la revue.